diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 34b72a24..92623f41 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -325,12 +325,16 @@ boundary, not a JavaScript sandbox or an independent security review. [framework page](FRAMEWORK.md#the-composition-contract) describes in one command: the starter under `/app/`, one `host.mjs`, one `README.md`, and each extension's own operator files. Core never bundles or imports the -extension packages at build time; at run time it resolves -`@jimhoyd/urlcode-` for each name with Node's package resolution from -the invoking directory. Install a compatible package set there, as shown in -[the framework guide](FRAMEWORK.md#the-composition-contract), before using -`--with ui,auth`; UI must activate before auth. Core imports the package and calls its -`scaffold` export with this request: +extension packages at build time. `--with` always requests **bundle +distribution**: no npm resolution happens, and no operator installs anything +before running the command. Core resolves and verifies a signed +`extension-bundles@v` GitHub Release for the requested names, and by +default that tag is `extension-bundles@v` — it auto-resolves +from the core version you have installed, with no fallback. Pass +`--bundle-release extension-bundles@vX.Y.Z` to pin an explicit release +instead (for example an older, already-published one). Core downloads each +verified `.tgz`, extracts it under `/.urlcode/extension-bundles/`, +then imports and calls its `scaffold` export with this request: ```ts interface ScaffoldRequest { @@ -338,13 +342,19 @@ interface ScaffoldRequest { project: string; // absolute route project, /app (holds urlcode.yaml) hostFile: string; // absolute combined host module, /host.mjs names: readonly string[]; // every name in --with order, including this one + distribution?: 'npm' | 'bundle'; // --with always sends 'bundle'; 'npm' is reachable only from a package's own standalone quickstart CLI (urlcode-auth init, urlcode-admin init), never from --with + acknowledgements: readonly string[]; // sorted, de-duplicated --ack : values; always present, possibly empty } interface ScaffoldFile { path: string; content: string | Uint8Array; mode?: number } interface ScaffoldResult { name: string; // must equal the requested name + provides?: string[]; requires?: string[]; after?: string[]; conflicts?: string[]; // declarative composition, see below + acknowledged?: string[]; // the : acknowledgements this scaffold consumed + routeNotes?: string[]; // single-line comments written above this extension's routes extensions: Record; // merged into the project's top-level extensions routes: Record; // merged into app/routes/extensions.yaml hostImports: string[]; hostSetup: string[]; hostEntries: string[]; hostClose?: string[]; + hostBundleExports?: string[]; // named exports core binds from this extension's verified bundle; required for bundle distribution files: ScaffoldFile[]; // written relative to directory with their modes readme: string; nextSteps: string[]; // README section and numbered steps env?: Record; // environment variables the host reads @@ -353,8 +363,32 @@ interface ScaffoldResult { `scaffold` writes nothing; it returns fragments and may generate key material in memory (core zeroes `Uint8Array` contents after writing or on failure). The -types are exported from `@jimhoyd/urlcode` for packages that want to typecheck -against them. +types are exported from `@jimhoyd/urlcode` (`packages/core/src/extensions.ts`, +the authoritative definition) for packages that want to typecheck against +them. + +### Discovering what's installable + +`urlcode extension-bundles list` prints the first-party bundle names this +core release builds, with a one-line description of each, before you run +`init --with`: + +```sh +urlcode extension-bundles list +``` + +The list is static, baked into core at release time from the same source that +builds the signed bundles (`scripts/prepare-extension-bundles.ts`), so it +answers instantly with no network call; the live signed catalog for a +specific `--bundle-release` remains the authority `install`/`init --with` +actually verify against, and can in principle differ (for example naming a +bundle this static list does not yet know about, or one not yet published for +a brand-new core release). Naming a bundle that is not in that live catalog +refuses with the valid names it did find, for example: + +``` +Extension bundle store is not in the signed catalog for extension-bundles@vX.Y.Z; valid names: admin, auth, ui +``` `--with` is an unordered set. Core sorts the requested names before calling each `scaffold` (so `names` is the same for every spelling), then orders the @@ -378,11 +412,19 @@ from the set. Host setup should be self-contained (own identifiers, such as Assembly rules, in the resolved order: -- Every package is resolved and every `scaffold` is called before anything is - written. A name that is not installed refuses with the `npm install` command; - a package without a `scaffold` export refuses and names the package; an error +- Every requested bundle is resolved against the signed catalog and every + `scaffold` is called before anything is written. A name that is not in the + catalog for the resolved release refuses and lists the valid names found + there (see [Discovering what's installable](#discovering-whats-installable) + above); a bundle without a `scaffold` export refuses and names it; an error thrown by a `scaffold` (for example admin without auth in the same `--with`) - is reported as that package's refusal. No directory is left behind. + is reported as that bundle's refusal. No directory is left behind. +- If the release fetch itself fails — the tag doesn't exist, or (for the + auto-resolved default) hasn't published yet — the error names + `--bundle-release ` as the way to pin an explicit, already-published + release instead, and notes that a core release's matching bundle release + publishes on a separate workflow and can take a few minutes (commonly under + ten) to appear after a brand-new core version ships. - `extensions` fragments are declared in `app/urlcode.yaml`; `routes` fragments are written to `app/routes/extensions.yaml`, appended to the starter's `includes`, so the starter's own routes load first. A route or @@ -407,12 +449,14 @@ Assembly rules, in the resolved order: ### Recorded versions -`init --with --bundle-release extension-bundles@v…` writes a private -`/package.json` pinning the running core and an extension bundle -lockfile naming the verified archives. It does not add extension npm -dependencies. Before anything is written, the selected catalog checks every -required extension and core compatibility; a missing requirement or incompatible -bundle refuses and names it, leaving no directory behind. +`init --with` writes a private `/package.json` pinning the +running core, and `/urlcode.extension-bundles.lock.json` naming +the verified archives it resolved (explicitly with `--bundle-release`, or +auto-resolved from the running core version otherwise). It does not add +extension npm dependencies. Before anything is written, the selected catalog +checks every required extension and core compatibility; a missing +requirement or incompatible bundle refuses and names it, leaving no +directory behind. Nothing is installed. The generated site has no `node_modules` and no `package-lock.json` until you run `npm install` in it yourself for core, which diff --git a/llms-full.txt b/llms-full.txt index 89db817e..19550bb4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,5 +1,5 @@ - + # URLCode @@ -3203,12 +3203,16 @@ boundary, not a JavaScript sandbox or an independent security review. [framework page](https://github.com/jimhoyd-com/urlcode/blob/main/docs/FRAMEWORK.md#the-composition-contract) describes in one command: the starter under `/app/`, one `host.mjs`, one `README.md`, and each extension's own operator files. Core never bundles or imports the -extension packages at build time; at run time it resolves -`@jimhoyd/urlcode-` for each name with Node's package resolution from -the invoking directory. Install a compatible package set there, as shown in -[the framework guide](https://github.com/jimhoyd-com/urlcode/blob/main/docs/FRAMEWORK.md#the-composition-contract), before using -`--with ui,auth`; UI must activate before auth. Core imports the package and calls its -`scaffold` export with this request: +extension packages at build time. `--with` always requests **bundle +distribution**: no npm resolution happens, and no operator installs anything +before running the command. Core resolves and verifies a signed +`extension-bundles@v` GitHub Release for the requested names, and by +default that tag is `extension-bundles@v` — it auto-resolves +from the core version you have installed, with no fallback. Pass +`--bundle-release extension-bundles@vX.Y.Z` to pin an explicit release +instead (for example an older, already-published one). Core downloads each +verified `.tgz`, extracts it under `/.urlcode/extension-bundles/`, +then imports and calls its `scaffold` export with this request: ```ts interface ScaffoldRequest { @@ -3216,13 +3220,19 @@ interface ScaffoldRequest { project: string; // absolute route project, /app (holds urlcode.yaml) hostFile: string; // absolute combined host module, /host.mjs names: readonly string[]; // every name in --with order, including this one + distribution?: 'npm' | 'bundle'; // --with always sends 'bundle'; 'npm' is reachable only from a package's own standalone quickstart CLI (urlcode-auth init, urlcode-admin init), never from --with + acknowledgements: readonly string[]; // sorted, de-duplicated --ack : values; always present, possibly empty } interface ScaffoldFile { path: string; content: string | Uint8Array; mode?: number } interface ScaffoldResult { name: string; // must equal the requested name + provides?: string[]; requires?: string[]; after?: string[]; conflicts?: string[]; // declarative composition, see below + acknowledged?: string[]; // the : acknowledgements this scaffold consumed + routeNotes?: string[]; // single-line comments written above this extension's routes extensions: Record; // merged into the project's top-level extensions routes: Record; // merged into app/routes/extensions.yaml hostImports: string[]; hostSetup: string[]; hostEntries: string[]; hostClose?: string[]; + hostBundleExports?: string[]; // named exports core binds from this extension's verified bundle; required for bundle distribution files: ScaffoldFile[]; // written relative to directory with their modes readme: string; nextSteps: string[]; // README section and numbered steps env?: Record; // environment variables the host reads @@ -3231,8 +3241,32 @@ interface ScaffoldResult { `scaffold` writes nothing; it returns fragments and may generate key material in memory (core zeroes `Uint8Array` contents after writing or on failure). The -types are exported from `@jimhoyd/urlcode` for packages that want to typecheck -against them. +types are exported from `@jimhoyd/urlcode` (`packages/core/src/extensions.ts`, +the authoritative definition) for packages that want to typecheck against +them. + +#### Discovering what's installable + +`urlcode extension-bundles list` prints the first-party bundle names this +core release builds, with a one-line description of each, before you run +`init --with`: + +```sh +urlcode extension-bundles list +``` + +The list is static, baked into core at release time from the same source that +builds the signed bundles (`scripts/prepare-extension-bundles.ts`), so it +answers instantly with no network call; the live signed catalog for a +specific `--bundle-release` remains the authority `install`/`init --with` +actually verify against, and can in principle differ (for example naming a +bundle this static list does not yet know about, or one not yet published for +a brand-new core release). Naming a bundle that is not in that live catalog +refuses with the valid names it did find, for example: + +``` +Extension bundle store is not in the signed catalog for extension-bundles@vX.Y.Z; valid names: admin, auth, ui +``` `--with` is an unordered set. Core sorts the requested names before calling each `scaffold` (so `names` is the same for every spelling), then orders the @@ -3256,11 +3290,19 @@ from the set. Host setup should be self-contained (own identifiers, such as Assembly rules, in the resolved order: -- Every package is resolved and every `scaffold` is called before anything is - written. A name that is not installed refuses with the `npm install` command; - a package without a `scaffold` export refuses and names the package; an error +- Every requested bundle is resolved against the signed catalog and every + `scaffold` is called before anything is written. A name that is not in the + catalog for the resolved release refuses and lists the valid names found + there (see [Discovering what's installable](#discovering-whats-installable) + above); a bundle without a `scaffold` export refuses and names it; an error thrown by a `scaffold` (for example admin without auth in the same `--with`) - is reported as that package's refusal. No directory is left behind. + is reported as that bundle's refusal. No directory is left behind. +- If the release fetch itself fails — the tag doesn't exist, or (for the + auto-resolved default) hasn't published yet — the error names + `--bundle-release ` as the way to pin an explicit, already-published + release instead, and notes that a core release's matching bundle release + publishes on a separate workflow and can take a few minutes (commonly under + ten) to appear after a brand-new core version ships. - `extensions` fragments are declared in `app/urlcode.yaml`; `routes` fragments are written to `app/routes/extensions.yaml`, appended to the starter's `includes`, so the starter's own routes load first. A route or @@ -3285,12 +3327,14 @@ Assembly rules, in the resolved order: #### Recorded versions -`init --with --bundle-release extension-bundles@v…` writes a private -`/package.json` pinning the running core and an extension bundle -lockfile naming the verified archives. It does not add extension npm -dependencies. Before anything is written, the selected catalog checks every -required extension and core compatibility; a missing requirement or incompatible -bundle refuses and names it, leaving no directory behind. +`init --with` writes a private `/package.json` pinning the +running core, and `/urlcode.extension-bundles.lock.json` naming +the verified archives it resolved (explicitly with `--bundle-release`, or +auto-resolved from the running core version otherwise). It does not add +extension npm dependencies. Before anything is written, the selected catalog +checks every required extension and core compatibility; a missing +requirement or incompatible bundle refuses and names it, leaving no +directory behind. Nothing is installed. The generated site has no `node_modules` and no `package-lock.json` until you run `npm install` in it yourself for core, which diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 4e9cb9d5..b5c67524 100755 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -28,7 +28,7 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.ts'; import { readFile } from 'node:fs/promises'; import { installArtifact, inspectArtifacts } from './extension-artifacts.ts'; -import { installBundle, readBundleLock } from './extension-bundles.ts'; +import { installBundle, readBundleLock, BUNDLE_CATALOG_NAMES } from './extension-bundles.ts'; const usage = `URLCode 0.5.9 — local/self-hosted runtime urlcode init [--with ui,auth,admin] [--bundle-release extension-bundles@vX.Y.Z] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier] @@ -79,7 +79,9 @@ const usage = `URLCode 0.5.9 — local/self-hosted runtime # signed, data-only extension bundles cached under .urlcode/extensions; they never execute or replace --host-file urlcode extension-bundles install --bundle-release extension-bundles@vX.Y.Z [--project directory] urlcode extension-bundles inspect [--project directory] [--json] + urlcode extension-bundles list [--json] # signed executable first-party bundles cached under .urlcode/extension-bundles; installation is explicit and host code loads them + # list: the first-party bundle names this core version's release builds, from a static list baked in at release (no network call); the live signed catalog for a specific --bundle-release is still authoritative for install/init --with urlcode import [netlify|cloudflare|vercel|netlify-toml] [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json] urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json] conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime @@ -159,6 +161,11 @@ async function complianceOptions(values: Values): Promise ` ${item.name}: ${item.description}`), + '', 'Install with: urlcode init --with name[,name] (auto-resolves extension-bundles@v), or', 'urlcode extension-bundles install --bundle-release extension-bundles@vX.Y.Z']; + return lines.join('\n') + '\n'; +} function formatExtensions(report: ExtensionInspection): string { const lines = [`Project revision: ${report.projectSha256}`]; for (const item of report.declared) lines.push(`Declared: ${item.name} (contract ${item.version}) ${item.registered ? 'registered' : report.hostLoaded ? 'NOT registered by the host file' : 'schemas need --host-file'}`, ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`); @@ -223,7 +230,8 @@ try { }else if(command==='extension-bundles'){ if(arg==='install') { const bundle=extra[0]; if(!bundle || extra.length!==1) throw new ConfigError('Use urlcode extension-bundles install --bundle-release extension-bundles@vX.Y.Z'); if(!values['bundle-release']) throw new ConfigError('Use --bundle-release with an immutable extension bundle release tag'); const lock=await installBundle(values.project,values['bundle-release'],bundle); print(values.json?lock:{event:'extension-bundle-installed',name:bundle,lockfile:'urlcode.extension-bundles.lock.json'}); } else if(arg==='inspect') { if(extra.length) throw new ConfigError('Use urlcode extension-bundles inspect'); const lock=await readBundleLock(values.project); print(values.json?lock:{bundles:lock.bundles.map(item=>({name:item.name,version:item.version,release:item.catalog.tag,coreVersion:item.coreVersion}))}); } - else throw new ConfigError('Use extension-bundles install or inspect'); + else if(arg==='list') { if(extra.length) throw new ConfigError('Use urlcode extension-bundles list'); print(values.json?BUNDLE_CATALOG_NAMES:formatBundleCatalogNames()); } + else throw new ConfigError('Use extension-bundles install, inspect or list'); }else if(command==='import'||command==='export'){ const { runInterchange } = await import('./interchange-cli.ts'); const converted = await runInterchange(command,positionals.slice(1),{project:values.project,target:values.target,format:values.format,out:values.out,report:values.report,dryRun:values['dry-run'],acceptProviderDifferences:values['accept-provider-differences']}); diff --git a/packages/core/src/extension-bundles.ts b/packages/core/src/extension-bundles.ts index ffd25d3e..0e933609 100644 --- a/packages/core/src/extension-bundles.ts +++ b/packages/core/src/extension-bundles.ts @@ -56,7 +56,27 @@ export async function readBundleLock(project:string):Promise{let raw async function writeLock(project:string,lock:BundleLock):Promise{const path=join(project,'urlcode.extension-bundles.lock.json'),temporary=join(project,`.urlcode.extension-bundles.lock.${process.pid}.${Math.random().toString(16).slice(2)}`);await writeLockAtomic(path,temporary,lock);} export const githubBundleTransport:BundleTransport=createGithubTransport({repository:BUNDLE_REPOSITORY,workflow:BUNDLE_WORKFLOW,tagPattern:tag,exampleTag:'extension-bundles@v1.0.0',maxAssetSize:limits.archive,itemLabel:'extension bundle'}); async function verified(assets:ReleaseAsset[],asset:string,release:string,transport:BundleTransport):Promise{return verifiedReleaseAsset(assets,asset,release,transport,'extension bundle','urlcode-bundle-attest');} -export async function installBundle(project:string,release:string,bundleName:string,transport:BundleTransport=githubBundleTransport):Promise{assert(name.test(bundleName),'Invalid extension bundle name');const assets=await transport.release(release),catalog=parseBundleCatalog(await verified(assets,'extension-bundles-catalog.json',release,transport),release),item=catalog.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not in the signed catalog`);const revoked=catalog.revoked.find(value=>value.sha256===item.sha256);assert(!revoked,`Extension bundle ${bundleName} is revoked: ${revoked?.reason??'unknown reason'}`);const locked:{catalog:{tag:string;commit:string};coreVersion:string}&BundleEntry={...item,catalog:{tag:catalog.tag,commit:catalog.commit},coreVersion:catalog.coreVersion};await extractBundle(await verified(assets,item.asset,release,transport),locked,bundleCachePath(project,item.sha256));let prior:BundleLock|undefined;try{prior=await readBundleLock(project);}catch{/* first install */}const bundles=(prior?.bundles??[]).filter(value=>value.name!==item.name);bundles.push(locked);bundles.sort((left,right)=>left.name.localeCompare(right.name));const lock={format:1 as const,bundles};await writeLock(project,lock);return lock;} +/** + * The first-party bundle names this core version's release process builds (`scripts/prepare-extension-bundles.ts`), + * with the one-line descriptions from the workspace table in README.md. This is a static list baked into core at + * release time, not a live catalog fetch: `urlcode extension-bundles list` can answer instantly with no network + * dependency, at the cost of possibly naming a bundle (or a version of it) not yet published for this exact core + * release -- `install`/`init --with` still verify against the live signed catalog and are authoritative. + */ +export const BUNDLE_CATALOG_NAMES:readonly {name:string;description:string}[] = [ + {name:'ui',description:'Shared presentation: escaped templates, shadcn/ui partials, themes, translations'}, + {name:'auth',description:'Accounts: password, passkeys, OIDC, email codes, TOTP, sessions, roles, account page'}, + {name:'admin',description:'Administration: users, sessions, roles, audit, approvals, cases, impersonation'}, + {name:'store',description:'Durable bounded JSON collections exposed as a typed CRUD API'}, + {name:'forms',description:'Bounded server-rendered form flows: escaped controls, admission, CSRF, validation'}, +]; +/** Enriches a failed release fetch with the two things an operator needs to tell a timing gap from a real problem: the escape hatch to pin an already-published release, and the known window where a core release exists before its matching bundle release does. */ +function releaseFetchFailure(error:unknown,release:string):unknown { + if(error instanceof Error && error.message===`Could not fetch extension bundle release ${release}`) + return new ConfigError(`${error.message}. The extension-bundles release for a core version publishes on a separate workflow after the core release and can take a few minutes (commonly under ten) to appear; if this core version just released, wait and retry. To pin an explicit, already-published release instead, use --bundle-release (for example an earlier compatible extension-bundles@vX.Y.Z).`); + return error; +} +export async function installBundle(project:string,release:string,bundleName:string,transport:BundleTransport=githubBundleTransport):Promise{assert(name.test(bundleName),'Invalid extension bundle name');let assets:{name:string;url:string}[];try{assets=await transport.release(release);}catch(error){throw releaseFetchFailure(error,release);}const catalog=parseBundleCatalog(await verified(assets,'extension-bundles-catalog.json',release,transport),release),item=catalog.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not in the signed catalog for ${release}; valid names: ${catalog.bundles.map(value=>value.name).sort().join(', ') || '(none)'}`);const revoked=catalog.revoked.find(value=>value.sha256===item.sha256);assert(!revoked,`Extension bundle ${bundleName} is revoked: ${revoked?.reason??'unknown reason'}`);const locked:{catalog:{tag:string;commit:string};coreVersion:string}&BundleEntry={...item,catalog:{tag:catalog.tag,commit:catalog.commit},coreVersion:catalog.coreVersion};await extractBundle(await verified(assets,item.asset,release,transport),locked,bundleCachePath(project,item.sha256));let prior:BundleLock|undefined;try{prior=await readBundleLock(project);}catch{/* first install */}const bundles=(prior?.bundles??[]).filter(value=>value.name!==item.name);bundles.push(locked);bundles.sort((left,right)=>left.name.localeCompare(right.name));const lock={format:1 as const,bundles};await writeLock(project,lock);return lock;} export async function runningCoreVersion():Promise{const raw:unknown=JSON.parse(await readFile(new URL('../../../package.json',import.meta.url),'utf8'));assert(record(raw)&&typeof raw.version==='string'&&version.test(raw.version),'Could not read the running core version');return raw.version;} /** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */ export async function loadExtensionBundle(project:string,bundleName:string):Promise>{assert(name.test(bundleName),'Invalid extension bundle name');const lock=await readBundleLock(project),item=lock.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not locked`);assert(item.coreVersion===await runningCoreVersion(),`Extension bundle ${bundleName} requires core ${item.coreVersion}; this runtime is incompatible`);const root=bundleCachePath(project,item.sha256);await validateCached(root,item);const module=await import(pathToFileURL(join(root,item.entry)).href);assert(module&&typeof module==='object',`Extension bundle ${bundleName} entry did not export a module`);return module as Record;} diff --git a/test/cli.test.ts b/test/cli.test.ts index af8a2ebe..e8c3f411 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -196,3 +196,15 @@ test('urlcode test still runs where no temporary data directory can be created', assert.equal(run.status,0); assert.equal((JSON.parse(run.stdout.trim().split('\n').pop() ?? '') as { failed:number }).failed,0); }); + +test('urlcode extension-bundles list discovers the installable first-party bundles with no network call', async () => { + const text = spawnSync(process.execPath,[cli,'extension-bundles','list'],{ encoding:'utf8',timeout:10000 }); + assert.equal(text.status,0); + for (const name of ['ui','auth','admin','store','forms']) assert.ok(text.stdout.includes(name),`extension-bundles list is missing ${name}`); + assert.ok(text.stdout.includes('init'),'extension-bundles list should point at how to install'); + const json = spawnSync(process.execPath,[cli,'extension-bundles','list','--json'],{ encoding:'utf8',timeout:10000 }); + const parsed = JSON.parse(json.stdout) as { name:string; description:string }[]; + assert.deepEqual(parsed.map(item => item.name).sort(),['admin','auth','forms','store','ui']); + for (const item of parsed) assert.ok(item.description.length > 0); + assert.ok(spawnSync(process.execPath,[cli,'--help'],{ encoding:'utf8',timeout:10000 }).stdout.includes('extension-bundles list')); +}); diff --git a/test/extension-bundles.test.ts b/test/extension-bundles.test.ts index 636e10c1..d2ec045e 100644 --- a/test/extension-bundles.test.ts +++ b/test/extension-bundles.test.ts @@ -5,7 +5,7 @@ import { gzipSync } from 'node:zlib'; import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { bundleCachePath, extractBundle, installBundle, loadExtensionBundle, parseBundleCatalog, readBundleLock, type BundleTransport } from '../packages/core/src/extension-bundles.ts'; +import { BUNDLE_CATALOG_NAMES, bundleCachePath, extractBundle, installBundle, loadExtensionBundle, parseBundleCatalog, readBundleLock, type BundleTransport } from '../packages/core/src/extension-bundles.ts'; import { readBoundedTgz } from '../packages/core/src/extension-artifacts.ts'; function tar(files:Record):Buffer { const pieces:Buffer[]=[]; for(const [path,text] of Object.entries(files)) { const body=Buffer.from(text),header=Buffer.alloc(512);header.write(path);header.write(body.length.toString(8).padStart(11,'0')+'\0',124);header[156]=48;header.fill(32,148,156);const checksum=[...header].reduce((sum,byte)=>sum+byte,0);header.write(checksum.toString(8).padStart(6,'0')+'\0 ',148);pieces.push(header,body,Buffer.alloc((512-body.length%512)%512)); }pieces.push(Buffer.alloc(1024));return gzipSync(Buffer.concat(pieces)); } @@ -43,3 +43,25 @@ test('bundle extraction accepts a standard USTAR prefix path',async()=>{ const long=`node_modules/${'dependency/'.repeat(12)}module.js`, archive=ustar(long,'export{}'); assert.equal(readBoundedTgz(archive,{archive:1024*1024,expanded:1024*1024,files:2,file:1024,label:'test'})[0]?.path,long); }); + +test('installBundle lists the valid catalog names when the requested bundle is unknown',async t=>{ + const project=await mkdtemp(join(tmpdir(),'urlcode-bundle-unknown-'));t.after(async()=>{await import('node:fs/promises').then(fs=>fs.rm(project,{recursive:true,force:true}));}); + const bytes=archive(),item=entry(bytes),catalog=Buffer.from(JSON.stringify({format:1,tag:'extension-bundles@v1.0.0',commit:'a'.repeat(40),coreVersion,bundles:[item],revoked:[]})); + const transport:BundleTransport={release:async()=>[{name:'extension-bundles-catalog.json',url:'catalog'}],download:async()=>catalog,attest:async()=>{}}; + await assert.rejects(()=>installBundle(project,'extension-bundles@v1.0.0','missing',transport),/Extension bundle missing is not in the signed catalog for extension-bundles@v1\.0\.0; valid names: sample/); +}); + +test('installBundle enriches a failed release fetch with --bundle-release and the known publish-timing gap',async t=>{ + const project=await mkdtemp(join(tmpdir(),'urlcode-bundle-fetch-fail-'));t.after(async()=>{await import('node:fs/promises').then(fs=>fs.rm(project,{recursive:true,force:true}));}); + const release='extension-bundles@v9.9.9'; + const transport:BundleTransport={release:async()=>{throw new Error(`Could not fetch extension bundle release ${release}`);},download:async()=>{throw new Error('unused');},attest:async()=>{}}; + await assert.rejects(()=>installBundle(project,release,'sample',transport),/Could not fetch extension bundle release extension-bundles@v9\.9\.9.*--bundle-release /s); + // A differently-worded failure from a custom transport passes through unchanged rather than being misrepresented as this specific gap. + const other:BundleTransport={release:async()=>{throw new Error('network is down');},download:async()=>{throw new Error('unused');},attest:async()=>{}}; + await assert.rejects(()=>installBundle(project,release,'sample',other),/^Error: network is down$/); +}); + +test('BUNDLE_CATALOG_NAMES lists every first-party bundle this release builds',()=>{ + assert.deepEqual(BUNDLE_CATALOG_NAMES.map(item=>item.name).sort(),['admin','auth','forms','store','ui']); + for(const item of BUNDLE_CATALOG_NAMES)assert.ok(item.description.length>0); +});