@jimhoyd/urlcode 0.4.0-alpha.3 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +10 -0
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CONTRIBUTING.md +36 -0
  4. package/README.md +20 -15
  5. package/ROADMAP.md +25 -16
  6. package/dist/BUILD-MANIFEST.json +20 -19
  7. package/dist/authoring.js +15 -1
  8. package/dist/capability-query.js +0 -1
  9. package/dist/catalog.js +0 -1
  10. package/dist/cli.js +24 -7
  11. package/dist/config.js +1 -1
  12. package/dist/explain.js +1 -1
  13. package/dist/extensions.js +78 -1
  14. package/dist/http-response.js +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/init-with.js +36 -11
  17. package/dist/manifest.js +1 -1
  18. package/dist/mcp-authoring.js +2 -2
  19. package/dist/mcp.js +1 -1
  20. package/dist/policies/cache.js +2 -2
  21. package/dist/policy.js +16 -0
  22. package/dist/project-dependencies.js +305 -0
  23. package/dist/readiness.js +5 -1
  24. package/dist/runtime.js +1 -1
  25. package/dist/tooling.js +2 -1
  26. package/dist/trusted-functions.js +4 -5
  27. package/dist/types/authoring.d.ts +9 -1
  28. package/dist/types/capability-query.d.ts +0 -1
  29. package/dist/types/catalog.d.ts +0 -4
  30. package/dist/types/config.d.ts +1 -9
  31. package/dist/types/explain.d.ts +0 -1
  32. package/dist/types/extensions.d.ts +58 -0
  33. package/dist/types/http-response.d.ts +0 -1
  34. package/dist/types/index.d.ts +1 -0
  35. package/dist/types/init-with.d.ts +7 -13
  36. package/dist/types/manifest.d.ts +0 -1
  37. package/dist/types/project-dependencies.d.ts +78 -0
  38. package/dist/types/readiness.d.ts +3 -0
  39. package/dist/types/tooling.d.ts +1 -0
  40. package/dist/types/trusted-functions.d.ts +1 -4
  41. package/docs/AI-AUTHORING.md +10 -5
  42. package/docs/AWS.md +9 -0
  43. package/docs/CI-FOLLOWUP-2026-09-19.md +1 -1
  44. package/docs/CODEBASE-AUDIT-2026-09-20.md +6 -0
  45. package/docs/COMPOSING-A-SITE.md +287 -0
  46. package/docs/CONTAINER-PROMOTION.md +74 -0
  47. package/docs/DEVELOPMENT-PIPELINE.md +242 -119
  48. package/docs/EXTENSIONS.md +88 -93
  49. package/docs/FRAMEWORK.md +45 -30
  50. package/docs/FUNCTION-SECURITY.md +5 -8
  51. package/docs/INSTALL.md +13 -8
  52. package/docs/MIDDLEWARE.md +10 -4
  53. package/docs/OPEN-DECISIONS.md +64 -99
  54. package/docs/READINESS.md +8 -4
  55. package/docs/README.md +18 -13
  56. package/docs/RELEASE-0.4.1.md +73 -0
  57. package/docs/RELEASE-0.4.2.md +30 -0
  58. package/docs/RELEASE-READINESS.md +40 -11
  59. package/docs/RELEASE-SECURITY.md +33 -14
  60. package/docs/SPECIFICATION.md +5 -1
  61. package/docs/SPIKE-CORE-LAYERING.md +1 -1
  62. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +9 -13
  63. package/docs/STARTERS.md +17 -5
  64. package/docs/TOOLING.md +7 -5
  65. package/docs/VERCEL.md +10 -2
  66. package/docs/VERSION-ALIGNMENT.md +50 -8
  67. package/docs/archive/2026-09-19/ROADMAP.md +1 -0
  68. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +1 -0
  69. package/docs/{SPIKE-LAMBDA-COMPILE.md → archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md} +168 -12
  70. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +2 -0
  71. package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +116 -0
  72. package/docs/archive/README.md +2 -0
  73. package/docs/yaml/functions.md +10 -2
  74. package/docs/yaml/middleware.md +5 -3
  75. package/examples/cookbook/middleware/envelope.mjs +4 -2
  76. package/llms-full.txt +458 -143
  77. package/llms.txt +2 -1
  78. package/package.json +8 -5
  79. package/packaging/claude-plugin/.claude-plugin/plugin.json +1 -1
  80. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +10 -0
  81. package/recipes/middleware/middleware/envelope.mjs +4 -2
  82. package/skills/urlcode/SKILL.md +8 -1
package/dist/init-with.js CHANGED
@@ -8,13 +8,21 @@ import { mcpConfigFile, renderMcpConfig } from './agents-guide.js';
8
8
  import { loadDocument, parseYaml, validateDocument } from './config.js';
9
9
  import { inspectExtensionRevision } from './extensions.js';
10
10
 
11
+ import { collectDependencySet, installSteps, renderPackageManifest } from './project-dependencies.js';
12
+
11
13
  import { ConfigError, assert } from './errors.js';
12
14
 
13
15
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
14
- export const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
16
+ const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
15
17
  const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
16
-
17
-
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
18
26
 
19
27
  export function parseWithNames(value ) {
20
28
  const names = value.split(',').map(name => name.trim());
@@ -22,7 +30,7 @@ export function parseWithNames(value ) {
22
30
  assert(new Set(names).size === names.length, 'Duplicate --with names');
23
31
  return names;
24
32
  }
25
- export const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
33
+ const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
26
34
  const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
27
35
  const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
28
36
  const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -32,7 +40,7 @@ const record = (value ) => value !== n
32
40
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
33
41
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
34
42
  */
35
- export async function loadScaffold(name , request , cwd ) {
43
+ async function loadScaffold(name , request , cwd ) {
36
44
  const pkg = packageName(name);
37
45
  let entry ;
38
46
  try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
@@ -68,7 +76,7 @@ async function write(target , content , mode = 0o644)
68
76
  const file = await open(target, 'wx', mode);
69
77
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
70
78
  }
71
- export function renderHost(names , results ) {
79
+ function renderHost(names , results ) {
72
80
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
73
81
  for (const result of results) lines.push(...result.hostImports);
74
82
  lines.push('');
@@ -85,12 +93,23 @@ function demote(markdown ) {
85
93
  let fence = false;
86
94
  return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
87
95
  }
88
- export function renderReadme(directory , names , results , starter , env , projectSha256 ) {
89
- const steps = results.flatMap(result => result.nextSteps);
96
+ function renderDependencySection(directory , set ) {
97
+ const rows = set.pins.map(pin => `- \`${pin.name}\` ${pin.version} (${pin.role})${pin.specifier === pin.version ? '' : ` installed from \`${pin.specifier}\``}`);
98
+ const lines = ['## Dependencies', '',
99
+ '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
100
+ ...rows, '',
101
+ ...installSteps(directory, set).flatMap(step => [step, '']),
102
+ set.local ? 'At least one pin is a local path or tarball rather than a registry version: reproducing this install needs that path to exist, so keep it under your control or replace the specifier before publishing the site.' : 'The pins are registry versions; `npm install` resolves them without the network only if your cache or mirror already holds them.', '',
103
+ 'There is no upgrade command. Changing a pinned version today means editing `package.json` yourself and re-running `npm install`; review the extension changelogs first.', ''];
104
+ return lines.join('\n');
105
+ }
106
+ function renderReadme(directory , names , results , starter , env , projectSha256 , set ) {
107
+ const steps = [...(set ? installSteps(directory, set) : []), ...results.flatMap(result => result.nextSteps)];
90
108
  const parts = [`# ${basename(directory)}`, '',
91
109
  `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
92
110
  '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
93
111
  for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
112
+ if (set) parts.push(renderDependencySection(directory, set));
94
113
  parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
95
114
  if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
96
115
  parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
@@ -102,7 +121,7 @@ export function renderReadme(directory , names , result
102
121
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
103
122
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
104
123
  */
105
- export async function initProjectWith(destination , names , { cwd = process.cwd() } = {}) {
124
+ export async function initProjectWith(destination , names , { cwd = process.cwd(), manifest = true, pins } = {}) {
106
125
  assert(names.length > 0, 'Provide at least one --with name');
107
126
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
108
127
  const request = { directory, project, hostFile, names };
@@ -120,6 +139,9 @@ export async function initProjectWith(destination , names
120
139
  const seen = new Set ();
121
140
  for (const file of result.files) { const path = filePath(directory, file.path); assert(!seen.has(path), `${result.name} scaffolds ${file.path} twice`); seen.add(path); }
122
141
  }
142
+ // Also resolved before the destination exists: an incompatible or incompletely installed set refuses with
143
+ // nothing written. It runs after the scaffold conflicts so a composition error is still reported as one.
144
+ const dependencies = manifest ? await collectDependencySet(names, names.map(packageName), { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
123
145
  await mkdir(dirname(directory), { recursive: true });
124
146
  await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
125
147
  try {
@@ -153,13 +175,16 @@ export async function initProjectWith(destination , names
153
175
  await write(target, file.content, file.mode ?? 0o644); written.add(target);
154
176
  }
155
177
  await write(hostFile, renderHost(names, results), 0o600);
156
- await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256));
178
+ if (dependencies) await write(join(directory, 'package.json'), renderPackageManifest(directory, dependencies));
179
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies));
157
180
  await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
158
181
  // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
159
182
  await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
160
183
  // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
161
184
  // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
162
- return { directory, project, hostFile, extensions: [...names], projectSha256, nextSteps: results.flatMap(result => result.nextSteps) };
185
+ return { directory, project, hostFile, extensions: [...names], projectSha256,
186
+ nextSteps: [...(dependencies ? installSteps(directory, dependencies) : []), ...results.flatMap(result => result.nextSteps)],
187
+ dependencies: dependencies?.pins ?? [] };
163
188
  } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
164
189
  } finally { wipe(); }
165
190
  }
package/dist/manifest.js CHANGED
@@ -111,5 +111,5 @@ export async function buildManifest(project ,options ={})
111
111
  }
112
112
  /** The manifest as `build` writes it: two-space JSON with a trailing newline. */
113
113
  export function renderManifest(manifest ) {return JSON.stringify(manifest,null,2)+'\n';}
114
- export const manifestFileName='manifest.json';
114
+ const manifestFileName='manifest.json';
115
115
  export function manifestPath(out ) {return join(out,manifestFileName);}
@@ -54,7 +54,7 @@ async function verdict(root ,origin ) {
54
54
  catch{return {valid:false ,note:'Project does not validate; call run_validate for the CLI report.'};}
55
55
  }
56
56
  function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
57
- function expandHandler(path ,handler ) {
57
+ function expandHandler(handler ) {
58
58
  if(object(handler))return handler;
59
59
  assert(typeof handler==='string','Handler must be a route object or a short form');
60
60
  if(/^https?:\/\//.test(handler))return {redirect:{url:handler}};
@@ -83,7 +83,7 @@ async function createRoute(root ,args ,origin
83
83
  assert(file==='urlcode.yaml'||(loaded.document.includes??[]).includes(file),'file must be urlcode.yaml or an include listed in it');
84
84
  const target=await confinedPath(root,file);
85
85
  assert(!Object.hasOwn(loaded.routes,path),'Route already exists');
86
- const route={...expandHandler(path,args.handler)};
86
+ const route={...expandHandler(args.handler)};
87
87
  const middleware=expandMiddleware(args.middleware);if(middleware)route.middleware=middleware;
88
88
  const lockPath=join(root,'urlcode.yaml.lock'),lock=await open(lockPath,'wx',0o600);
89
89
  let temp ;
package/dist/mcp.js CHANGED
@@ -78,7 +78,7 @@ export async function serveMcp(options ) {
78
78
  if(message.method==='initialize') {
79
79
  if(initialized){await error(id,-32600,'Already initialized');return;}
80
80
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
81
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.0-alpha.3'}}});return;
81
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.2'}}});return;
82
82
  }
83
83
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
84
84
  if(!ready){await error(id,-32002,'Initialize first');return;}
@@ -198,7 +198,7 @@ function bodyOf(result ) { return result.body ? (Buffer.is
198
198
 
199
199
  // Conditional requests for results the handler did not validate itself:
200
200
  // assets answer 304 before this phase, so only 200 results are examined.
201
- function revalidate(state , req , result ) {
201
+ function revalidate(req , result ) {
202
202
  if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
203
203
  let headers = result.headers, etag = header(headers, 'etag');
204
204
  if (!etag) {
@@ -249,7 +249,7 @@ export function onResponse(state , req , result
249
249
  // refusal produced ahead of the handler keeps its own headers.
250
250
  if (flight || state.statuses.has(result.status)) headers = mergeVary(headers, state.vary);
251
251
  let out = { ...result, headers };
252
- if (state.strategy === 'revalidate') out = revalidate(state, req, out);
252
+ if (state.strategy === 'revalidate') out = revalidate(req, out);
253
253
  if (!flight) return out;
254
254
  // Store decision for the request that reached the handler; waiters are
255
255
  // released either way, with the entry or with nothing.
package/dist/policy.js CHANGED
@@ -32,6 +32,22 @@ export async function prepareFunctionSnapshot(loaded )
32
32
  if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
33
33
  else trusted.push({middleware, function: fn});
34
34
  }
35
+ // Extension hooks are a core primitive even though their names and payloads
36
+ // belong to each extension. Include every declared entry module in the
37
+ // reviewed project revision, so editing trusted hook code invalidates the
38
+ // operator's extension pin just like editing a trusted route function.
39
+ for(const [extension,declaration] of Object.entries(loaded.document.extensions??{})){
40
+ const hooks=declaration.config.hooks;
41
+ if(hooks===undefined)continue;
42
+ assert(hooks&&typeof hooks==='object'&&!Array.isArray(hooks),`Extension ${extension} hooks must be an object`);
43
+ for(const [name,raw] of Object.entries(hooks )){
44
+ assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${extension}.${name}`);
45
+ const reference=typeof raw==='string'?{source:raw}:raw ;
46
+ assert(typeof reference.source==='string',`Invalid extension hook: ${extension}.${name}`);
47
+ assert(reference.export===undefined||typeof reference.export==='string',`Invalid extension hook: ${extension}.${name}`);
48
+ trusted.push({function:await resolveOne({source:reference.source,...(reference.export===undefined?{}:{export:reference.export })})});
49
+ }
50
+ }
35
51
  const collected = await collectFunctionSources(sandboxed,loaded.root);
36
52
  const trustedSources = await collectTrustedSources(trusted,loaded.root);
37
53
  // The hash operator grants pin to still covers trusted routes' own source, so
@@ -0,0 +1,305 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ConfigError, assert } from './errors.js';
5
+
6
+ /**
7
+ * Exact dependency pins for a generated application.
8
+ *
9
+ * `urlcode init --with` resolves whatever `@jimhoyd/urlcode-<name>` packages are already installed beside the
10
+ * invoking directory. Without a manifest the generated site records nothing about which versions it was built
11
+ * against, so a later `npm install @jimhoyd/urlcode-auth` in that site can resolve a different set (#212). This
12
+ * module reads the versions that were actually resolved, validates the whole set against the packages' own
13
+ * declared `peerDependencies`, and renders a `package.json` pinning every one of them exactly.
14
+ *
15
+ * It never runs a package manager: generating a lockfile stays an explicit `npm install` the operator runs after
16
+ * reviewing the manifest. It also never imports an extension implementation -- only package metadata is read, by
17
+ * a name the caller supplied -- so core's generic extension boundary is unchanged.
18
+ */
19
+
20
+ export const CORE_PACKAGE = '@jimhoyd/urlcode';
21
+ const namePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
22
+
23
+
24
+ const versionPattern = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
25
+
26
+ export function parseVersion(value ) {
27
+ const match = versionPattern.exec(value.trim());
28
+ if (!match) return null;
29
+ const pre = match[4] === undefined ? [] : match[4].split('.').map(part => /^\d+$/.test(part) ? Number(part) : part);
30
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), pre };
31
+ }
32
+ function comparePre(a , b ) {
33
+ // A version with a prerelease is lower than the same version without one.
34
+ if (!a.length || !b.length) return a.length === b.length ? 0 : a.length ? -1 : 1;
35
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
36
+ const left = a[index], right = b[index];
37
+ if (left === undefined) return -1;
38
+ if (right === undefined) return 1;
39
+ if (left === right) continue;
40
+ if (typeof left === 'number' && typeof right === 'number') return left < right ? -1 : 1;
41
+ if (typeof left === 'number') return -1; // numeric identifiers rank lower than alphanumeric ones
42
+ if (typeof right === 'number') return 1;
43
+ return left < right ? -1 : 1;
44
+ }
45
+ return 0;
46
+ }
47
+ export function compareVersions(a , b ) {
48
+ for (const key of ['major', 'minor', 'patch'] ) if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
49
+ return comparePre(a.pre, b.pre);
50
+ }
51
+
52
+ /**
53
+ * A deliberately small subset of the range grammar: `*`, exact versions, the comparators, `^` and `~` over a
54
+ * complete `x.y.z`, whitespace for AND and `||` for OR. Anything else refuses rather than guessing, so an
55
+ * unrecognized peer range surfaces as a refusal instead of a silently wrong compatibility answer.
56
+ */
57
+ function parseComparatorSet(text , context ) {
58
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
59
+ if (!tokens.length || tokens.every(token => token === '*' || token === 'x' || token === 'X')) return 'any';
60
+ const comparators = [];
61
+ for (const token of tokens) {
62
+ const match = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(token);
63
+ const version = match ? parseVersion(match[2] ) : null;
64
+ if (!match || !version) throw new ConfigError(`Unsupported version range ${JSON.stringify(text)} (${context}); supported forms are *, x.y.z, >=, >, <, <=, = and ^ or ~ over a complete x.y.z`);
65
+ const operator = (match[1] ?? '=') ;
66
+ if (operator === '^' || operator === '~') {
67
+ // ^0.x is minor-bounded, ^0.0.x is patch-bounded, ^x is major-bounded; ~ is always minor-bounded.
68
+ const upper = operator === '~' || version.major === 0
69
+ ? (operator === '^' && version.major === 0 && version.minor === 0
70
+ ? { major: 0, minor: 0, patch: version.patch + 1, pre: [] }
71
+ : { major: version.major, minor: version.minor + 1, patch: 0, pre: [] })
72
+ : { major: version.major + 1, minor: 0, patch: 0, pre: [] };
73
+ comparators.push({ operator: '>=', version }, { operator: '<', version: upper });
74
+ continue;
75
+ }
76
+ comparators.push({ operator, version });
77
+ }
78
+ return comparators;
79
+ }
80
+ function satisfiesComparators(version , comparators ) {
81
+ // npm's prerelease rule: a prerelease version only satisfies a set that itself names a prerelease of the same
82
+ // x.y.z, so 0.5.0-alpha.1 never slips past `<0.5.0`.
83
+ if (version.pre.length && !comparators.some(item => item.version.pre.length && item.version.major === version.major && item.version.minor === version.minor && item.version.patch === version.patch)) return false;
84
+ return comparators.every(item => {
85
+ const order = compareVersions(version, item.version);
86
+ switch (item.operator) {
87
+ case '>': return order > 0;
88
+ case '>=': return order >= 0;
89
+ case '<': return order < 0;
90
+ case '<=': return order <= 0;
91
+ default: return order === 0;
92
+ }
93
+ });
94
+ }
95
+ export function satisfiesRange(version , range , context = 'peer range') {
96
+ const parsed = parseVersion(version);
97
+ if (!parsed) throw new ConfigError(`Unsupported version ${JSON.stringify(version)} (${context})`);
98
+ return range.split('||').some(part => {
99
+ const set = parseComparatorSet(part, context);
100
+ return set === 'any' || satisfiesComparators(parsed, set);
101
+ });
102
+ }
103
+
104
+
105
+
106
+
107
+
108
+
109
+ const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
110
+ const strings = (value ) => {
111
+ const out = {};
112
+ if (record(value)) for (const [key, item] of Object.entries(value)) if (typeof item === 'string') out[key] = item;
113
+ return out;
114
+ };
115
+ async function readManifest(file ) {
116
+ let parsed ;
117
+ try { parsed = JSON.parse(await readFile(file, 'utf8')) ; }
118
+ catch { return null; }
119
+ if (typeof parsed.name !== 'string' || typeof parsed.version !== 'string') return null;
120
+ const optional = new Set ();
121
+ if (record(parsed.peerDependenciesMeta)) for (const [key, value] of Object.entries(parsed.peerDependenciesMeta)) if (record(value) && value.optional === true) optional.add(key);
122
+ const engines = record(parsed.engines) && typeof parsed.engines.node === 'string' ? parsed.engines.node : undefined;
123
+ return { name: parsed.name, version: parsed.version, directory: dirname(file), peers: strings(parsed.peerDependencies), optionalPeers: optional, node: engines };
124
+ }
125
+ /**
126
+ * Walks `node_modules` upwards from the invoking directory, exactly like Node's own resolution but reading the
127
+ * package's manifest rather than its entry point. Reading the manifest directly (instead of resolving the entry)
128
+ * means a package whose `exports` does not expose `./package.json` is still inspectable, and nothing in the
129
+ * package is loaded or executed.
130
+ */
131
+ export async function findInstalledPackage(name , from ) {
132
+ assert(namePattern.test(name), `Invalid package name: ${name}`);
133
+ let directory = resolve(from);
134
+ for (;;) {
135
+ const found = await readManifest(join(directory, 'node_modules', ...name.split('/'), 'package.json'));
136
+ if (found && found.name === name) return found;
137
+ const parent = dirname(directory);
138
+ if (parent === directory) return null;
139
+ directory = parent;
140
+ }
141
+ }
142
+ /** The version of the runtime executing this command; that is the version a generated site is pinned to. */
143
+ export async function runningCore() {
144
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
145
+ const manifest = await readManifest(file);
146
+ assert(manifest && manifest.name === CORE_PACKAGE, `Could not read the running runtime manifest at ${file}`);
147
+ return manifest;
148
+ }
149
+
150
+ /**
151
+ * `resolved`/`link` entries from npm's hidden lockfile, used only to notice that a package was installed from a
152
+ * local directory or tarball. Such a package's version number is not installable from a registry, so the manifest
153
+ * has to record the local specifier instead of the exact version for the site to install offline.
154
+ */
155
+ async function localSpecifiers(cwd ) {
156
+ const out = new Map ();
157
+ let parsed ;
158
+ try { parsed = JSON.parse(await readFile(join(cwd, 'node_modules', '.package-lock.json'), 'utf8')); }
159
+ catch { return out; }
160
+ if (!record(parsed) || !record(parsed.packages)) return out;
161
+ for (const [key, value] of Object.entries(parsed.packages)) {
162
+ const index = key.lastIndexOf('node_modules/');
163
+ if (index !== 0 || !record(value)) continue; // nested installs belong to another package's tree
164
+ const name = key.slice('node_modules/'.length);
165
+ const resolvedTo = typeof value.resolved === 'string' ? value.resolved : '';
166
+ if (value.link === true) { if (resolvedTo) out.set(name, 'file:' + resolve(cwd, resolvedTo)); continue; }
167
+ if (resolvedTo.startsWith('file:')) out.set(name, 'file:' + resolve(cwd, resolvedTo.slice('file:'.length)));
168
+ }
169
+ return out;
170
+ }
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+
191
+
192
+
193
+ export function parsePin(value ) {
194
+ const index = value.indexOf('=');
195
+ assert(index > 0, 'Use --pin <package>=<specifier>, for example --pin @jimhoyd/urlcode-auth=file:/abs/path/urlcode-auth.tgz');
196
+ const name = value.slice(0, index).trim(), specifier = value.slice(index + 1).trim();
197
+ assert(namePattern.test(name), `Invalid --pin package name: ${name}`);
198
+ assert(specifier.length > 0 && specifier.length <= 512 && !/[\s\0]/.test(specifier), `Invalid --pin specifier for ${name}`);
199
+ return [name, specifier];
200
+ }
201
+ function nodeFloor(ranges ) {
202
+ let best ;
203
+ for (const range of ranges) {
204
+ // Only a plain `>=x.y.z` floor is recognized; anything else is left out rather than reinterpreted.
205
+ const match = range === undefined ? null : /^>=\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(range.trim());
206
+ const version = match ? parseVersion(match[1] ) : null;
207
+ if (version && (!best || compareVersions(version, best) > 0)) best = version;
208
+ }
209
+ return best ? `>=${best.major}.${best.minor}.${best.patch}${best.pre.length ? '-' + best.pre.join('.') : ''}` : undefined;
210
+ }
211
+
212
+ /**
213
+ * Resolves core plus every named extension and their declared peers, then validates the whole set against every
214
+ * declared peer range before returning. Compatibility is judged as a set: a mismatch anywhere refuses, listing
215
+ * every mismatch rather than the first.
216
+ */
217
+ export async function collectDependencySet(names , packageNames , { cwd = process.cwd(), overrides } = {}) {
218
+ assert(names.length === packageNames.length, 'Each extension name needs its package name');
219
+ const core = await runningCore();
220
+ const resolved = new Map ([[CORE_PACKAGE, core]]);
221
+ const roles = new Map ([[CORE_PACKAGE, 'runtime']]);
222
+ const missing = [];
223
+ const pending = [];
224
+ for (const pkg of packageNames) { roles.set(pkg, 'extension'); pending.push(pkg); }
225
+ // A copy of core installed beside the project would be resolved by the generated site, not the one running
226
+ // here; pinning one version while the other is installed is exactly the inconsistency this refuses to record.
227
+ const installedCore = await findInstalledPackage(CORE_PACKAGE, cwd);
228
+ if (installedCore && installedCore.version !== core.version)
229
+ throw new ConfigError(`${CORE_PACKAGE} ${installedCore.version} is installed in ${cwd} but this command is ${core.version}; run the matching CLI or align the installed runtime before recording pins`);
230
+ while (pending.length) {
231
+ const name = pending.shift() ;
232
+ if (resolved.has(name)) continue;
233
+ const found = await findInstalledPackage(name, cwd);
234
+ if (!found) { missing.push(name); continue; }
235
+ resolved.set(name, found);
236
+ for (const peer of Object.keys(found.peers)) {
237
+ if (resolved.has(peer) || found.optionalPeers.has(peer)) continue;
238
+ if (!roles.has(peer)) roles.set(peer, 'peer');
239
+ pending.push(peer);
240
+ }
241
+ }
242
+ if (missing.length) {
243
+ const required = missing.map(name => {
244
+ const source = [...resolved.values()].find(pkg => Object.hasOwn(pkg.peers, name));
245
+ return source ? `${name} (required by ${source.name} ${source.peers[name]})` : name;
246
+ });
247
+ throw new ConfigError(`Cannot record exact pins: ${required.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not installed in ${cwd}. Install the missing package(s) there, or pass --no-manifest to generate the site without a dependency manifest.`);
248
+ }
249
+ const conflicts = [];
250
+ for (const pkg of resolved.values())
251
+ for (const [peer, range] of Object.entries(pkg.peers)) {
252
+ const installed = resolved.get(peer);
253
+ if (!installed) continue; // optional peer that is not installed
254
+ if (!satisfiesRange(installed.version, range, `${pkg.name} peerDependencies.${peer}`)) conflicts.push(`${pkg.name} ${pkg.version} requires ${peer} ${range}, but ${installed.version} is installed`);
255
+ }
256
+ if (conflicts.length) throw new ConfigError(`Incompatible versions: ${conflicts.join('; ')}`);
257
+ const locals = await localSpecifiers(cwd);
258
+ const pins = [...resolved.values()]
259
+ .map(pkg => {
260
+ const override = overrides?.get(pkg.name);
261
+ const local = locals.get(pkg.name);
262
+ const specifier = override ?? local ?? pkg.version;
263
+ return { name: pkg.name, version: pkg.version, specifier, local: specifier !== pkg.version, role: roles.get(pkg.name) ?? 'peer' };
264
+ })
265
+ .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
266
+ for (const [name] of overrides ?? []) assert(pins.some(pin => pin.name === name), `--pin ${name} names a package that is not part of this project's dependency set`);
267
+ const dependencies = {};
268
+ for (const pin of pins) dependencies[pin.name] = pin.specifier;
269
+ return { pins, dependencies, node: nodeFloor([...resolved.values()].map(pkg => pkg.node)), local: pins.some(pin => pin.local) };
270
+ }
271
+
272
+ const TRIMMED = '._-';
273
+ const manifestName = (directory ) => {
274
+ const mapped = (directory.split(/[\\/]/).pop() ?? 'urlcode-site').toLowerCase().replace(/[^a-z0-9._-]+/g, '-');
275
+ // Trimmed with indices rather than /^[._-]+|[-._]+$/: an anchored quantifier
276
+ // over a repeated character is retried from every start position, which is
277
+ // quadratic on a directory name of many dashes (CodeQL js/polynomial-redos).
278
+ let start = 0, end = mapped.length;
279
+ while (start < end && TRIMMED.includes(mapped[start] ?? '')) start += 1;
280
+ while (end > start && TRIMMED.includes(mapped[end - 1] ?? '')) end -= 1;
281
+ const base = mapped.slice(start, end);
282
+ return base.length ? base.slice(0, 214) : 'urlcode-site';
283
+ };
284
+ /** The generated manifest: private, module type, exact pins, and nothing that runs a package manager. */
285
+ export function renderPackageManifest(directory , set ) {
286
+ return JSON.stringify({
287
+ name: manifestName(directory),
288
+ private: true,
289
+ version: '0.0.0',
290
+ type: 'module',
291
+ ...(set.node ? { engines: { node: set.node } } : {}),
292
+ dependencies: set.dependencies,
293
+ }, null, 2) + '\n';
294
+ }
295
+ /**
296
+ * The install step is printed, never run: generating `package-lock.json` executes a package manager, which
297
+ * resolves and downloads code, so it stays the operator's explicit action after reviewing the manifest.
298
+ */
299
+ export function installSteps(directory , set ) {
300
+ const where = isAbsolute(directory) ? directory : resolve(directory);
301
+ return [
302
+ `Review ${join(where, 'package.json')}; it pins ${set.pins.map(pin => `${pin.name}@${pin.version}`).join(', ')}.`,
303
+ `Run \`npm install\` in ${where} to install those exact versions and generate package-lock.json${set.local ? ' (add `--offline` when the local paths are your only source)' : ''}. urlcode never runs a package manager for you.`,
304
+ ];
305
+ }
package/dist/readiness.js CHANGED
@@ -37,6 +37,9 @@ import { runCompliance } from './compliance.js';
37
37
 
38
38
 
39
39
 
40
+
41
+
42
+
40
43
 
41
44
 
42
45
 
@@ -188,7 +191,8 @@ export async function auditProject(app , {expectRoutes,log=()=>{},c
188
191
  const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
189
192
  // The per-route capability table: which policies apply and whether this
190
193
  // host enforces, compiles or delegates each one. Refusals never get here.
191
- return {elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
194
+ const notReadyReasons=[...(counts.active>0?[]:['no-active-routes']),...(countMatches?[]:['route-count-mismatch']),...(failed?['failed-checks']:[]),...(uncovered.length?['uncovered-route-methods']:[])];
195
+ return {elapsedMs:performance.now()-began,ready:!notReadyReasons.length,notReadyReasons,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
192
196
  }
193
197
  export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
194
198
  assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
package/dist/runtime.js CHANGED
@@ -117,7 +117,7 @@ export async function createRuntime(project , rawOptions
117
117
  // route is trusted-by-default and dispatches through `trusted` below,
118
118
  // in-process, with no worker or WASM engine involved at all.
119
119
  const pool=await new FunctionPool(routes.filter(route=>route.sandbox===true), { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();
120
- const trusted = new TrustedFunctions({ timeoutMs: options.timeoutMs, maxBytes: options.maxBytes, log: options.log });
120
+ const trusted = new TrustedFunctions({ timeoutMs: options.timeoutMs, maxBytes: options.maxBytes });
121
121
  // Eagerly validated up front, exactly like the sandboxed pool above: a
122
122
  // trusted route with a broken module or a missing export fails activation
123
123
  // here rather than on its first request.
package/dist/tooling.js CHANGED
@@ -68,7 +68,7 @@ export async function previewImport(options ) {return importR
68
68
  export async function previewExport(project ,format ,acceptProviderDifferences=false) {const loaded=await loadDocument(project);const {includes:_includes,...document}=loaded.document;return exportRoutes({format,document:{...document,routes:loaded.routes},acceptProviderDifferences});}
69
69
 
70
70
 
71
-
71
+
72
72
 
73
73
 
74
74
  /** Reports registered extension contracts against the project's declarations. Never activates an extension. */
@@ -84,6 +84,7 @@ export async function describeExtensions(project ,registrations
84
84
  name:String(registration.name),version:String(registration.version),targets:Array.isArray(registration.targets)?registration.targets.map(String):[],
85
85
  credentialHeaders:Array.isArray(registration.credentialHeaders)?registration.credentialHeaders.map(String):[],
86
86
  schema:structuredClone(registration.schema??{}),policySchema:registration.policySchema?structuredClone(registration.policySchema):null,
87
+ hooks:structuredClone(registration.hooks??[]) ,
87
88
  declared:Object.hasOwn(loaded.document.extensions??{},registration.name),revisionPinned:registration.projectSha256===projectSha256,
88
89
  mounts:mountsOf(registration.name),policyRoutes:policyRoutesOf(registration.name),
89
90
  }));
@@ -33,16 +33,15 @@ import { routeFunctions } from './function-sources.js';
33
33
 
34
34
 
35
35
 
36
-
37
36
 
38
-
37
+
39
38
 
40
39
 
41
40
 
42
41
 
43
42
 
44
43
  export class TrustedFunctions {
45
- timeoutMs ; maxBytes ; log ;
44
+ timeoutMs ; maxBytes ;
46
45
  // Node's ESM loader caches a resolved module forever by URL, unlike a
47
46
  // sandboxed worker, which gets a genuinely fresh module registry on every
48
47
  // reload/restart. A snapshot reload constructs a brand-new TrustedFunctions
@@ -51,8 +50,8 @@ export class TrustedFunctions {
51
50
  // sandboxed pool's "new workers, new snapshot" reload contract, while a
52
51
  // single instance still only imports each module once per process.
53
52
  epoch = randomUUID();
54
- constructor({ timeoutMs = 5000, maxBytes = 1048576, log = () => {} } = {}) {
55
- this.timeoutMs = timeoutMs; this.maxBytes = maxBytes; this.log = log;
53
+ constructor({ timeoutMs = 5000, maxBytes = 1048576 } = {}) {
54
+ this.timeoutMs = timeoutMs; this.maxBytes = maxBytes;
56
55
  }
57
56
  // Eagerly imports and validates every declared export exists as a function,
58
57
  // the same guarantee FunctionPool.start() gives the sandboxed path: a
@@ -1,2 +1,10 @@
1
- export declare function initProject(destination: string): Promise<string>;
1
+ import type { DependencySet } from './project-dependencies.ts';
2
+ export interface InitOptions {
3
+ /**
4
+ * When given, a `package.json` pinning exactly these versions is written beside `urlcode.yaml`. Route-only
5
+ * initialization stays the default: a project whose runtime is managed elsewhere gets no manifest at all.
6
+ */
7
+ manifest?: DependencySet | undefined;
8
+ }
9
+ export declare function initProject(destination: string, { manifest }?: InitOptions): Promise<string>;
2
10
  export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
@@ -17,7 +17,6 @@ export interface CapabilityEntry extends CapabilityDetail {
17
17
  recipes: CapabilityUsage[];
18
18
  cookbook: CapabilityUsage[];
19
19
  }
20
- export declare function capabilityNameList(): readonly CapabilityName[];
21
20
  /** One catalog entry with its schema fragments and bundled usage. No project, credentials or network are read. */
22
21
  export declare function getCapability(name: string): CapabilityEntry;
23
22
  export declare function formatCapability(entry: CapabilityEntry): string;
@@ -46,10 +46,6 @@ export interface SearchHit<T extends CatalogMetadata> {
46
46
  score: number;
47
47
  matched: string[];
48
48
  }
49
- export declare const metadataFiles: {
50
- readonly recipe: "recipe.yaml";
51
- readonly example: "example.yaml";
52
- };
53
49
  /** Reads and schema-validates one metadata file; the id must equal the directory name, and file lists stay authoring-safe paths. */
54
50
  export declare function readMetadata(root: string, id: string, file: 'recipe.yaml' | 'example.yaml'): Promise<CatalogMetadata>;
55
51
  /** Preflight only: loads the project, expands site routes and asks the capability analysis for every target. No bindings, code or activation. */