diff --git a/docs/reference/copilot-plugin.md b/docs/reference/copilot-plugin.md index 03e2a7a..f95b098 100644 --- a/docs/reference/copilot-plugin.md +++ b/docs/reference/copilot-plugin.md @@ -1,6 +1,29 @@ # Install and use the Copilot plugin -The SQL Apps plugin adds project-specific skills to GitHub Copilot. It does not require publication, a Git push, an Azure subscription, or another platform plugin. Copilot itself still requires its normal account/access and network connectivity; the local application does not call cloud APIs after prerequisite downloads. +The SQL Apps plugin adds project-specific skills to GitHub Copilot. Installing its skills and launcher does not require npm publication, an Azure subscription, or another platform plugin. Copilot itself still requires its normal account/access and network connectivity; the local application does not call cloud APIs after prerequisite downloads. + +## Install from GitHub + +Register the repository marketplace and install the dedicated plugin: + +```powershell +copilot plugin marketplace add microsoft/sql-apps +copilot plugin install sql-apps@sql-apps-skills +``` + +This installs the manifest, six skills and launcher, not the application runtime or project factory. A listing in the default Copilot marketplace requires separate upstream review; registering this repository does not create that listing. + +First-project creation still needs a foundation checkout. Select it explicitly in the terminal used to launch Copilot, without replacing a remote marketplace registration with the local installer: + +```powershell +git clone https://github.com/microsoft/sql-apps.git "C:\git\sql-apps" +$env:SQL_APPS_HOME = "C:\git\sql-apps" +copilot +``` + +Use an existing foundation checkout instead of cloning again when appropriate. `SQL_APPS_HOME` takes precedence for project creation when it names the foundation; a generated application is not a project factory. The existing local installer below remains an alternative for checkout-based development and creates a persistent foundation binding. + +Generated applications restore exactly `@microsoft/sql-apps@0.2.3` from the configured npm feed. Plugin installation does not publish that package or prove it is available. A missing package blocks application restoration; a local tarball or foundation build is not clean-feed release acceptance. See the [release prerequisites](progressive-creation.md). ## Install from your checkout diff --git a/package-lock.json b/package-lock.json index 6e89ed5..2ddffa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6828,12 +6828,6 @@ "node": ">= 10.x" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha1-SRS5A6L4toXRf994pw6RfocuREo=", - "license": "BSD-3-Clause" - }, "node_modules/ssh-remote-port-forward": { "version": "1.0.4", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", @@ -7058,9 +7052,9 @@ } }, "node_modules/tedious": { - "version": "20.3.5", - "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tedious/-/tedious-20.3.5.tgz", - "integrity": "sha512-JdP9O9cLGpic4uWuXNjsZVfA8AZzrLQMkvlsc8VEYD76IzZMpvFKCrlqcSuNjdEwRz64Ull6eakuPBdefKZnfA==", + "version": "20.3.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tedious/-/tedious-20.3.6.tgz", + "integrity": "sha512-TLO9Uj1BSYJ+WWx1mhw/IvrucVbt4v3+4LL3zzE8jUYONslBcccYx5qAukflsmhR2uQMDqqpWL8/Pmu7z2x+iA==", "license": "MIT", "dependencies": { "@azure/core-auth": "^1.10.1", @@ -7071,8 +7065,7 @@ "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", - "native-duplexpair": "^1.0.0", - "sprintf-js": "^1.1.3" + "native-duplexpair": "^1.0.0" }, "engines": { "node": ">=22" diff --git a/plugins/sql-apps/scripts/sql-apps.mjs b/plugins/sql-apps/scripts/sql-apps.mjs index 27b1b22..71ee5ea 100644 --- a/plugins/sql-apps/scripts/sql-apps.mjs +++ b/plugins/sql-apps/scripts/sql-apps.mjs @@ -74,6 +74,11 @@ export async function resolveHome(options = {}) { async function resolveToolHome(options = {}) { const environment = options.environment ?? process.env; + if (environment.SQL_APPS_HOME !== undefined) { + const home = await validateHome(environment.SQL_APPS_HOME); + const pkg = JSON.parse(await readFile(join(home, 'package.json'), 'utf8')); + if (pkg.name === 'sql-apps') return home; + } const userHome = resolve(options.userHome ?? homedir()); const configuredBinding = join(environment.COPILOT_HOME || join(userHome, '.copilot'), 'sql-apps-local.json'); let binding; diff --git a/scripts/check-application.mjs b/scripts/check-application.mjs index cd3fe64..72e6559 100644 --- a/scripts/check-application.mjs +++ b/scripts/check-application.mjs @@ -1,7 +1,6 @@ import { readFile, readdir, lstat } from 'node:fs/promises'; import { join, resolve, extname, relative, dirname, basename } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import ts from 'typescript'; const sampleMarkers = [ /\b(?:TodoExampleClient|TodoOwnerPredicate|TodoOwnerPolicy)\b/, @@ -242,7 +241,8 @@ async function customizedUi(root) { new Set(['app.js', 'auth/redirect.js'])); } -function exampleDependencies(path, content) { +async function exampleDependencies(path, content) { + const { default: ts } = await import('typescript'); const source = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); const dependencies = new Set(); const scopes = [new Map()]; @@ -316,7 +316,7 @@ export async function checkApplication(root = process.cwd(), model) { inspected++; const content = await readFile(path, 'utf8'); if (tests) { - for (const dependency of exampleDependencies(path, content)) { + for (const dependency of await exampleDependencies(path, content)) { violations.push(`Unselected example dependency in ${path}: ${dependency}`); } } else if (sampleMarkers.some(marker => marker.test(content))) violations.push(path); diff --git a/tests/creation-front-door.test.mjs b/tests/creation-front-door.test.mjs index ae8124b..fb6e396 100644 --- a/tests/creation-front-door.test.mjs +++ b/tests/creation-front-door.test.mjs @@ -64,6 +64,7 @@ test('one origin serves pre-install status, verified app, proxy and isolated fai const root = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-front-door-'))); let door, upstream, releaseSlowCheck, slowCheckStarted; const slowCheckStartedPromise = new Promise(done => { slowCheckStarted = done; }); + const slowCheckReleased = new Promise(done => { releaseSlowCheck = done; }); t.after(async () => { releaseSlowCheck?.(); if (door) await door.close(); @@ -95,7 +96,8 @@ test('one origin serves pre-install status, verified app, proxy and isolated fai } else if (request.url === '/api/Reports') { response.writeHead(503).end('{"error":"Fixture report unavailable"}'); } else if (request.url === '/api/Slow') { - await new Promise(done => { releaseSlowCheck = done; slowCheckStarted(); }); + slowCheckStarted(); + await slowCheckReleased; response.end('{"value":[{"ready":1}]}'); } else if (request.url === '/api/Fast') { response.end('{"value":[{"ready":1}]}'); @@ -121,6 +123,8 @@ test('one origin serves pre-install status, verified app, proxy and isolated fai const usable = await eventually(publicOrigin, state => state.usable); assert.equal(usable.ready, false); assert.deepEqual(usable.blockers, ['browser-workflow', 'design-review']); + const completed = await (await fetch(`${publicOrigin}/__sql_apps/check`)).json(); + assert.equal(completed.usable, true); await new Promise(done => setTimeout(done, 225)); const cached = await (await fetch(`${publicOrigin}/__sql_apps/progress`)).json(); assert.equal(cached.usable, false, 'an idle cached observation must not claim current health'); @@ -165,7 +169,8 @@ test('one origin serves pre-install status, verified app, proxy and isolated fai valid = false; await eventually(publicOrigin, state => state.capabilities.some(item => item.state === 'failed')); healthy = false; - const dataAccessFailure = await eventually(publicOrigin, state => state.diagnostic === 'DATA_ACCESS_NOT_READY'); + const dataAccessFailure = await eventually(publicOrigin, state => state.diagnostic === 'DATA_ACCESS_NOT_READY' && + state.capabilities.find(item => item.id === 'data').state === 'verified'); assert.equal(dataAccessFailure.capabilities.find(item => item.id === 'data').state, 'verified'); assert.match(await (await fetch(publicOrigin)).text(), /Bringing your application to life/); healthy = true; valid = true; diff --git a/tests/creation-process.test.mjs b/tests/creation-process.test.mjs index 8160708..0efe7ca 100644 --- a/tests/creation-process.test.mjs +++ b/tests/creation-process.test.mjs @@ -1,13 +1,31 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { join } from 'node:path'; +import { mkdir, mkdtemp, rm, cp } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; import { tmpdir } from 'node:os'; import { initialProgress } from '../src/creation-progress.mjs'; import { readCreationJson, writeCreationJson } from '../src/creation-session.mjs'; import { startCreationProcess } from '../scripts/creation.mjs'; +test('cold creation and design evidence helpers load without restored packages or build output', async t => { + const root = await mkdtemp(join(tmpdir(), 'sql-apps-cold-creation-')); + t.after(() => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })); + for (const directory of ['src', 'scripts']) { + await cp(resolve(directory), join(root, directory), { recursive: true }); + } + const module = pathToFileURL(join(root, 'scripts', 'creation.mjs')).href; + const check = pathToFileURL(join(root, 'scripts', 'check-application.mjs')).href; + const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '-e', + `await import(${JSON.stringify(module)}); const { inspectDesignAcceptance } = await import(${JSON.stringify(check)}); ` + + `console.log(JSON.stringify(await inspectDesignAcceptance(process.cwd())));`], + { cwd: root }); + assert.equal(JSON.parse(stdout).accepted, false); +}); + async function freeOrigin() { const server = createServer(); await new Promise(done => server.listen(0, '127.0.0.1', done)); diff --git a/tests/local-consent.test.mjs b/tests/local-consent.test.mjs index 5ea09eb..1a0198e 100644 --- a/tests/local-consent.test.mjs +++ b/tests/local-consent.test.mjs @@ -16,7 +16,7 @@ const execute = promisify(execFile); const cli = resolve('scripts/local-consent.mjs'); async function fixture(t, selected = false) { - const home = await mkdtemp(join(tmpdir(), 'sql-apps-consent-')); + const home = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-consent-'))); t.after(() => rm(home, { recursive: true, force: true })); const files = { 'package.json': JSON.stringify({ name: 'sql-apps', scripts: { build: 'node scripts/build-provenance.mjs' } }), @@ -105,19 +105,6 @@ test('Express instance changes are separate, target-bound one-operation grants, await assert.rejects(assertConsent(home, authRequest), /permission/); }); -test('endpoint CLI initializes its approved workspace without interpreting the port block as a container', async t => { - const home = await realpath(await fixture(t)); - const { proposeWorkspace } = await import('../src/workspace.mjs'); - const proposal = await proposeWorkspace(home, undefined, { includeSql: false }); - const workspace = String((proposal.conflicts.length ? proposal.alternative : proposal.descriptor).basePort); - const sqlTarget = { version: 1, provider: 'express', instance: 'SQLEXPRESS', server: '127.0.0.1', port: 1433, database: 'development' }; - const bundle = await planBundle(home, { profile: 'role-based-data', workspace, sqlTarget }); - await grantBundle(home, { profile: 'role-based-data', workspace, sqlTarget, approval: bundle.approval }); - await execute(process.execPath, [resolve('dist', 'src', 'local-cli.js'), 'workspace-init', workspace], - { cwd: home, env: { ...process.env, SQL_APPS_PROJECT_HOME: home } }); - assert.equal(JSON.parse(await readFile(join(home, '.sql-apps', 'workspace.json'), 'utf8')).basePort, Number(workspace)); -}); - test('composite startup lists every missing scope before any mutation', async t => { const home = await fixture(t, true); const plan = await planConsent(home, { operation: 'role-based-app' }); diff --git a/tests/local.test.ts b/tests/local.test.ts index eda6f10..871c865 100644 --- a/tests/local.test.ts +++ b/tests/local.test.ts @@ -1,10 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { cp, readFile, realpath, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import { localCommand, localConnection, localDabConfig, localPrincipal, verifyLocal, startLocalSql, sqlImage, dabImage } from '../src/local.js'; import { run, type Run } from '../src/process.js'; import { startLocalApplication } from '../src/local-startup.js'; -import { runtimeFor, type WorkspaceRuntime } from '../src/workspace.mjs'; +import { proposeWorkspace, runtimeFor, type WorkspaceRuntime } from '../src/workspace.mjs'; +import { planBundle, grantBundle } from '../src/local-consent.mjs'; import { localWorkspaceFixture } from './local-workspace-fixture.js'; function sqlOwnership(runtime: WorkspaceRuntime, args: readonly string[]): string | undefined { @@ -62,6 +64,34 @@ test('actual application CLI rejects invalid input during preflight before start /Invalid SQL container name/.test(error.message) && !/Starting SQL container/.test(error.message)); }); +test('endpoint CLI initializes its approved workspace without interpreting the port block as a container', async t => { + const source = process.cwd(); + await localWorkspaceFixture(t); + const home = await realpath(process.cwd()); + for (const directory of ['src', 'scripts', 'sql', 'dab', '.config']) { + await cp(join(source, directory), join(home, directory), { + recursive: true, + filter: path => !/(?:^|[\\/])(?:bin|obj|node_modules)(?:[\\/]|$)/.test(path), + }); + } + for (const file of ['package.json', 'package-lock.json', 'tsconfig.json', 'tsconfig.runtime.json']) { + await cp(join(source, file), join(home, file)); + } + await writeFile(join(home, 'role-based-data.json'), + '{"profile":"role-based-data","requiredRole":"AppUser","readinessPath":"/api/Ready"}'); + const proposal = await proposeWorkspace(home, undefined, { includeSql: false }); + const selection = proposal.conflicts?.length ? proposal.alternative : proposal.descriptor; + assert.ok(selection, 'A free workspace port block must be available'); + const workspace = String(selection.basePort); + const sqlTarget = { version: 1, provider: 'express', instance: 'SQLEXPRESS', server: '127.0.0.1', + port: 1433, database: 'development' } as const; + const bundle = await planBundle(home, { profile: 'role-based-data', workspace, sqlTarget }); + await grantBundle(home, { profile: 'role-based-data', workspace, sqlTarget, approval: bundle.approval }); + await run(process.execPath, [resolve(source, 'dist', 'src', 'local-cli.js'), 'workspace-init', workspace], + { env: { SQL_APPS_PROJECT_HOME: home } }); + assert.equal(JSON.parse(await readFile(join(home, '.sql-apps', 'workspace.json'), 'utf8')).basePort, Number(workspace)); +}); + test('local workflow fails closed on SQL Server instead of Azure SQL', async () => { await verifyLocal('sqldbdev', async () => '5|SQL Azure\r\n'); for (const result of ['3|Developer Edition', '', '5|unrecognized']) { diff --git a/tests/projects.test.mjs b/tests/projects.test.mjs index dd48e57..b1903c5 100644 --- a/tests/projects.test.mjs +++ b/tests/projects.test.mjs @@ -243,7 +243,7 @@ test('a fresh generated project installs its exact tooling version with npm ci f test('create scaffolds an independent named project and records it as active', async t => { const source = await sourceFixture(t); - const userHome = await mkdtemp(join(tmpdir(), 'sql-apps-project-user-')); + const userHome = await realpath(await mkdtemp(join(tmpdir(), 'sql-apps-project-user-'))); t.after(() => rm(userHome, { recursive: true, force: true })); const created = await projects.createProject(source, 'To Do', { userHome, initializeGit: false }); assert.equal(created.name, 'To Do'); @@ -411,3 +411,16 @@ test('plugin project dispatch uses the bound foundation as scaffold source witho }); assert.deepEqual(calls, [{ args: ['create', 'A New App'], home: source }]); }); + +test('marketplace project dispatch accepts an explicit foundation without a local-plugin binding', async t => { + const source = await sourceFixture(t); + const userHome = await mkdtemp(join(tmpdir(), 'sql-apps-marketplace-project-')); + t.after(() => rm(userHome, { recursive: true, force: true })); + const calls = []; + await plugin.dispatchProjects(['create-plan', 'A New App'], { + userHome, + environment: { SQL_APPS_HOME: source }, + projectMain: async (args, home) => calls.push({ args, home }), + }); + assert.deepEqual(calls, [{ args: ['create-plan', 'A New App'], home: source }]); +}); diff --git a/tests/tooling-package.test.mjs b/tests/tooling-package.test.mjs index 64445c0..d1af90e 100644 --- a/tests/tooling-package.test.mjs +++ b/tests/tooling-package.test.mjs @@ -72,6 +72,12 @@ test('tooling:pack uses a POSIX-portable package path', async () => { ); }); +test('locked tooling dependencies exclude the unpatched sprintf-js advisory', async () => { + const lock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8')); + assert.equal(Object.keys(lock.packages).some(path => /(?:^|\/)node_modules\/sprintf-js$/.test(path)), false, + 'Use the upstream tedious fix instead of shipping CVE-2026-97058'); +}); + test('stages publishable package metadata with synchronized exact runtime dependencies', async () => { const [pkg, source, rootPackage] = await Promise.all([ readFile(join(staged, 'package.json'), 'utf8').then(JSON.parse),