@jimhoyd/urlcode 0.4.7 → 0.5.0

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 (49) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +8 -0
  2. package/README.md +9 -3
  3. package/dist/BUILD-MANIFEST.json +23 -21
  4. package/dist/agents-guide.js +9 -5
  5. package/dist/authoring.js +36 -3
  6. package/dist/build-cloudflare.js +5 -2
  7. package/dist/build-static.js +1 -0
  8. package/dist/cli.js +47 -15
  9. package/dist/cloudflare.js +6 -3
  10. package/dist/config.js +7 -1
  11. package/dist/context.js +99 -1
  12. package/dist/extension-artifacts.js +147 -0
  13. package/dist/extension-bundles.js +70 -0
  14. package/dist/extensions.js +4 -0
  15. package/dist/functions.js +2 -1
  16. package/dist/index.js +4 -2
  17. package/dist/init-with.js +55 -23
  18. package/dist/interchange.js +1 -1
  19. package/dist/match.js +23 -5
  20. package/dist/mcp.js +11 -4
  21. package/dist/readiness.js +2 -2
  22. package/dist/router.js +20 -8
  23. package/dist/runtime.js +1 -0
  24. package/dist/site.js +19 -1
  25. package/dist/tooling.js +2 -2
  26. package/dist/types/agents-guide.d.ts +6 -1
  27. package/dist/types/authoring.d.ts +1 -1
  28. package/dist/types/cloudflare.d.ts +1 -0
  29. package/dist/types/context.d.ts +56 -0
  30. package/dist/types/extension-artifacts.d.ts +87 -0
  31. package/dist/types/extension-bundles.d.ts +49 -0
  32. package/dist/types/extensions.d.ts +4 -0
  33. package/dist/types/functions.d.ts +4 -0
  34. package/dist/types/index.d.ts +4 -2
  35. package/dist/types/init-with.d.ts +6 -1
  36. package/dist/types/match.d.ts +1 -0
  37. package/dist/types/site.d.ts +2 -0
  38. package/dist/types/tooling.d.ts +2 -2
  39. package/dist/types/types.d.ts +1 -0
  40. package/dist/types.js +1 -1
  41. package/llms-full.txt +188 -24
  42. package/llms.txt +63 -107
  43. package/package.json +11 -3
  44. package/recipes/redirect/README.md +19 -5
  45. package/recipes/redirect/recipe.yaml +12 -10
  46. package/recipes/redirect/tests/requests.json +22 -0
  47. package/recipes/redirect/urlcode.yaml +11 -1
  48. package/skills/urlcode/SKILL.md +2 -0
  49. package/starters/default/AGENTS.md +3 -3
package/dist/init-with.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, open, readFile, rename, rm, unlink, lstat } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
@@ -11,6 +11,7 @@ import { inspectExtensionRevision } from './extensions.js';
11
11
  import { collectDependencySet, installSteps, renderPackageManifest } from './project-dependencies.js';
12
12
 
13
13
  import { ConfigError, assert } from './errors.js';
14
+ import { installBundle, loadExtensionBundle, } from './extension-bundles.js';
14
15
 
15
16
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
16
17
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
@@ -24,6 +25,10 @@ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9
24
25
 
25
26
 
26
27
 
28
+
29
+
30
+
31
+
27
32
 
28
33
 
29
34
 
@@ -79,15 +84,19 @@ export function orderScaffolds(results )
79
84
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
80
85
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
81
86
  */
82
- async function loadScaffold(name , request , cwd , retry ) {
87
+ async function loadScaffold(name , request , cwd , retry , bundleProject ) {
83
88
  const pkg = packageName(name);
84
- let entry ;
85
- try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
86
- catch (error) {
87
- if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
88
- throw error;
89
+ let module ;
90
+ if (bundleProject) module = await loadExtensionBundle(bundleProject, name);
91
+ else {
92
+ let entry ;
93
+ try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
94
+ catch (error) {
95
+ if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
96
+ throw error;
97
+ }
98
+ module = await import(pathToFileURL(entry).href) ;
89
99
  }
90
- const module = await import(pathToFileURL(entry).href) ;
91
100
  const scaffold = module.scaffold;
92
101
  if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
93
102
  let result ;
@@ -102,6 +111,8 @@ async function loadScaffold(name , request , cwd ,
102
111
  assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
103
112
  assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
104
113
  assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
114
+ assert(result.hostBundleExports === undefined || (strings(result.hostBundleExports) && result.hostBundleExports.every(value => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value))), `${pkg} scaffold hostBundleExports must be JavaScript identifiers`);
115
+ if (request.distribution === 'bundle') assert(Array.isArray(result.hostBundleExports) && result.hostBundleExports.length > 0, `${pkg} scaffold must declare hostBundleExports for executable bundle distribution`);
105
116
  assert(result.acknowledged === undefined || (strings(result.acknowledged) && result.acknowledged.every(id => request.acknowledgements.includes(id) && id.startsWith(`${name}:`))), `${pkg} scaffold acknowledged may only list ${name}:<id> acknowledgements the operator passed`);
106
117
  assert(result.routeNotes === undefined || (strings(result.routeNotes) && result.routeNotes.every(note => note.length <= 300 && !/[\r\n]/.test(note))), `${pkg} scaffold routeNotes must be single-line strings`);
107
118
  assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
@@ -124,8 +135,17 @@ async function write(target , content , mode = 0o644)
124
135
  const file = await open(target, 'wx', mode);
125
136
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
126
137
  }
127
- function renderHost(names , results ) {
138
+ function renderHost(names , results , distribution ='npm') {
128
139
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
140
+ if (distribution === 'bundle') {
141
+ lines.push("import {loadExtensionBundle} from '@jimhoyd/urlcode/extension-bundles';", "import {fileURLToPath} from 'node:url';", "const extensionBundleDirectory = fileURLToPath(new URL('.', import.meta.url));");
142
+ const exports = new Set ();
143
+ for (const result of results) {
144
+ const names = result.hostBundleExports ;
145
+ for (const name of names) { assert(!exports.has(name), `Bundle host export ${name} is declared by more than one extension`); exports.add(name); }
146
+ lines.push(`const {${names.join(', ')}} = await loadExtensionBundle(extensionBundleDirectory, '${result.name}');`);
147
+ }
148
+ }
129
149
  // Extensions that need the same module (node:url, for example) each list it; an identical line is written once so the host stays valid ESM.
130
150
  for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
131
151
  lines.push('');
@@ -142,23 +162,23 @@ function demote(markdown ) {
142
162
  let fence = false;
143
163
  return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
144
164
  }
145
- function renderDependencySection(directory , set ) {
165
+ function renderDependencySection(directory , set , bundle = false) {
146
166
  const rows = set.pins.map(pin => `- \`${pin.name}\` ${pin.version} (${pin.role})${pin.specifier === pin.version ? '' : ` installed from \`${pin.specifier}\``}`);
147
167
  const lines = ['## Dependencies', '',
148
- '`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.', '',
168
+ bundle ? '`package.json` pins only the URLCode runtime. Executable extensions are locked GitHub Release bundles in `urlcode.extension-bundles.lock.json`, not npm dependencies.' : '`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.', '',
149
169
  ...rows, '',
150
170
  ...installSteps(directory, set).flatMap(step => [step, '']),
151
171
  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.', '',
152
172
  '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.', ''];
153
173
  return lines.join('\n');
154
174
  }
155
- function renderReadme(directory , names , results , starter , env , projectSha256 , set ) {
175
+ function renderReadme(directory , names , results , starter , env , projectSha256 , set , distribution ='npm') {
156
176
  const steps = [...(set ? installSteps(directory, set) : []), ...results.flatMap(result => result.nextSteps)];
157
177
  const parts = [`# ${basename(directory)}`, '',
158
- `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}"\`.`, '',
178
+ `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 ${distribution === 'bundle' ? 'the explicitly installed, verified extension bundles' : '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}"\`.`, '',
159
179
  '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
160
180
  for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
161
- if (set) parts.push(renderDependencySection(directory, set));
181
+ if (set) parts.push(renderDependencySection(directory, set, distribution === 'bundle'));
162
182
  parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
163
183
  if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
164
184
  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.`, '');
@@ -170,7 +190,7 @@ function renderReadme(directory , names , results
170
190
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
171
191
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
172
192
  */
173
- export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
193
+ export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [], bundleRelease, bundleTransport } = {}) {
174
194
  assert(requested.length > 0, 'Provide at least one --with name');
175
195
  assert(new Set(requested).size === requested.length, 'Duplicate --with names');
176
196
  // --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
@@ -178,13 +198,20 @@ export async function initProjectWith(destination , requested
178
198
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
179
199
  assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
180
200
  const acked = [...new Set(acknowledgements)].sort();
181
- const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
201
+ const distribution =bundleRelease===undefined?'npm':'bundle';
202
+ const request = { directory, project, hostFile, names: sorted, acknowledgements: acked, distribution };
182
203
  const quote = (value ) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
183
- const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
204
+ const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(bundleRelease===undefined?[]:['--bundle-release',bundleRelease]), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
184
205
  const results = [];
206
+ let bundleRoot ;
185
207
  const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
186
208
  try {
187
- for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
209
+ if (bundleRelease) {
210
+ await mkdir(dirname(directory), { recursive: true });
211
+ bundleRoot=await mkdtemp(join(dirname(directory),'.urlcode-bundle-init-'));
212
+ for (const name of sorted) await installBundle(bundleRoot,bundleRelease,name,bundleTransport);
213
+ }
214
+ for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry, bundleRoot));
188
215
  const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
189
216
  const unused = acked.filter(id => !consumed.has(id));
190
217
  assert(unused.length === 0, `--ack ${unused.join(', ')} has no effect here: no scaffold in --with (${sorted.join(', ')}) consumed it. Remove it, or check the extension name and id in that extension's documentation`);
@@ -202,11 +229,16 @@ export async function initProjectWith(destination , requested
202
229
  }
203
230
  // Also resolved before the destination exists: an incompatible or incompletely installed set refuses with
204
231
  // nothing written. It runs after the scaffold conflicts so a composition error is still reported as one.
205
- const dependencies = manifest ? await collectDependencySet(names, names.map(packageName), { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
232
+ const dependencies = manifest ? await collectDependencySet(bundleRelease===undefined?names:[], bundleRelease===undefined?names.map(packageName):[], { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
206
233
  await mkdir(dirname(directory), { recursive: true });
207
234
  await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
208
235
  try {
209
236
  await initProject(project);
237
+ if (bundleRoot) {
238
+ await rename(join(bundleRoot,'.urlcode'),join(directory,'.urlcode'));
239
+ await rename(join(bundleRoot,'urlcode.extension-bundles.lock.json'),join(directory,'urlcode.extension-bundles.lock.json'));
240
+ await rm(bundleRoot,{recursive:true,force:true}); bundleRoot=undefined;
241
+ }
210
242
  const starter = await readFile(join(project, 'README.md'), 'utf8');
211
243
  await unlink(join(project, 'README.md')); // its content moves into the site README
212
244
  await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
@@ -236,17 +268,17 @@ export async function initProjectWith(destination , requested
236
268
  while (probe !== directory && probe.startsWith(directory)) { try { assert(!(await lstat(probe)).isSymbolicLink(), `Scaffold path passes through a symlink: ${file.path}`); } catch (error) { if (!isCode(error, 'ENOENT')) throw error; } probe = dirname(probe); }
237
269
  await write(target, file.content, file.mode ?? 0o644); written.add(target);
238
270
  }
239
- await write(hostFile, renderHost(names, results), 0o600);
271
+ await write(hostFile, renderHost(names, results, distribution), 0o600);
240
272
  if (dependencies) await write(join(directory, 'package.json'), renderPackageManifest(directory, dependencies));
241
- await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies));
273
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies, distribution));
242
274
  await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
243
275
  // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
244
- await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
276
+ await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY, { local: dependencies !== undefined }));
245
277
  // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
246
278
  // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
247
279
  return { directory, project, hostFile, extensions: [...names], projectSha256,
248
280
  nextSteps: [...(dependencies ? installSteps(directory, dependencies) : []), ...results.flatMap(result => result.nextSteps)],
249
281
  dependencies: dependencies?.pins ?? [] };
250
282
  } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
251
- } finally { wipe(); }
283
+ } finally { if(bundleRoot)await rm(bundleRoot,{recursive:true,force:true}); wipe(); }
252
284
  }
@@ -166,7 +166,7 @@ export async function exportRoutes(options )
166
166
  keys(record(options.document),['version','routes']);validateDocument(options.document);
167
167
  if(Object.keys(options.document.routes).length>100000)throw new Error('Input exceeds 100,000 routes');
168
168
  for(const [path,config]of Object.entries(options.document.routes)) {
169
- try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
169
+ try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');if(path.includes('*')||config.redirect.url.startsWith('/'))throw new Error('Suffix wildcards and root-relative destinations have no provider equivalent');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
170
170
  catch{diagnostics.push({severity:'error',code:'runtime-required',source,path,message:'Only literal redirects with default methods and no extra behavior can be exported'});}
171
171
  }
172
172
  rows.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
package/dist/match.js CHANGED
@@ -19,7 +19,7 @@ import { HttpError } from './errors.js';
19
19
 
20
20
  /** The part of a compiled route that request-time matching reads. router.ts widens it. */
21
21
 
22
-
22
+
23
23
 
24
24
 
25
25
 
@@ -52,6 +52,8 @@ export function parseTarget(target ) {
52
52
  if (parts.length > 32 || parts.some(p => p === '.' || p === '..')) throw new HttpError(400, 'Invalid path');
53
53
  return { path, parts, query: new URLSearchParams(query) };
54
54
  }
55
+ /** Longest suffix a `/**` redirect captures; a longer one is simply not matched. */
56
+ const wildcardMaxLength = 1024;
55
57
  export function matchRoute (compiled , target ) {
56
58
  const exact = compiled.exact.get(target.path);
57
59
  if (exact) return { route: exact, path: dict() };
@@ -64,7 +66,15 @@ export function matchRoute (compiled
64
66
  return p === actual;
65
67
  })) return { route, path };
66
68
  }
67
- for (const route of compiled.mounts) if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
69
+ for (const route of compiled.mounts) {
70
+ if (route.wildcard) {
71
+ // `/prefix/**`: one or more whole segments, no empty segment (so a redirect can never gain a `//`), bounded length.
72
+ const rest = route.prefix !== undefined && target.path.startsWith(route.prefix) ? target.path.slice(route.prefix.length) : '';
73
+ if (rest && rest.length <= wildcardMaxLength && !rest.split('/').includes('')) { const path = dict (); path['**'] = rest; return { route, path }; }
74
+ continue;
75
+ }
76
+ if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
77
+ }
68
78
  return null;
69
79
  }
70
80
  function scalar(value , type ) {
@@ -102,14 +112,21 @@ export function contextFor(route , path ,
102
112
  if (!p.validate(value)) throw new HttpError(400, 'Invalid parameter');
103
113
  (inputs[p.in] )[p.name] = value;
104
114
  }
115
+ if (route.wildcard) inputs.path['**'] = path['**'] ?? '';
105
116
  return { inputs, env: route.env, secrets: route.secrets };
106
117
  }
107
118
  // The router accepts a placeholder only for a declared path input, and a path
108
119
  // input always matches a segment, so the '' fallback is unreachable through a
109
120
  // compiled route; it keeps a direct call with an undeclared name from writing
110
121
  // the text "undefined" into the location.
122
+ // A root-relative destination (`/profiles/{id}`) is resolved against this placeholder origin only to normalize and encode it;
123
+ // the origin never appears in the Location.
124
+ const relativeBase = 'https://relative.invalid';
111
125
  export function redirectLocation(route , context , query ) {
112
- const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encodeURIComponent(context.inputs.path[name] ?? '')));
126
+ const relative = route.redirect.url.startsWith('/');
127
+ // `{**}` is the captured suffix: each segment encoded on its own, joined by the `/` that separated them.
128
+ const encode = (name ) => name === '**' ? (context.inputs.path['**'] ?? '').split('/').map(encodeURIComponent).join('/') : encodeURIComponent(context.inputs.path[name] ?? '');
129
+ const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encode(name)), relativeBase);
113
130
  function append(key , value ) {
114
131
  if (value === undefined) return;
115
132
  for (const item of Array.isArray(value) ? value : [value]) location.searchParams.append(key, String(item));
@@ -119,6 +136,7 @@ export function redirectLocation(route
119
136
  if (own(context.inputs.query, key)) append(key, context.inputs.query[key]);
120
137
  else for (const value of query.getAll(key)) append(key,value);
121
138
  }
122
- if (location.href.length > 16384) throw new HttpError(400, 'Redirect URL too long');
123
- return location.href;
139
+ const result = relative ? location.pathname + location.search + location.hash : location.href;
140
+ if (result.length > 16384) throw new HttpError(400, 'Redirect URL too long');
141
+ return result;
124
142
  }
package/dist/mcp.js CHANGED
@@ -2,12 +2,13 @@ import {realpath} from 'node:fs/promises';
2
2
 
3
3
  import {once} from 'node:events';
4
4
  import {Ajv} from 'ajv';
5
- import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext} from './tooling.js';
5
+ import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext} from './tooling.js';
6
6
  import {loadOperatorHost} from './operator-host.js';
7
7
  import {buildManifest} from './manifest.js';
8
8
 
9
9
  import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
10
10
  import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
11
+ import {describeArtifactCache,readArtifactMember} from './extension-artifacts.js';
11
12
  const protocolVersion='2025-11-25';
12
13
  const maxBytes=1048576;
13
14
  const text={type:'string',maxLength:8192};
@@ -32,7 +33,9 @@ const definitions=[
32
33
  {name:'get_example',description:'Return the README and urlcode.yaml from one bundled runnable example.',properties:{name:{type:'string',maxLength:64}},required:['name']},
33
34
  {name:'validate_yaml',description:'Validate supplied URLCode YAML syntax and schema only. It never reads includes, source files, bindings or a project directory.',properties:{yaml:{type:'string',maxLength:524288}},required:['yaml']},
34
35
  {name:'explain_error',description:'Give deterministic next-step guidance for supplied URLCode validation output.',properties:{error:{type:'string',maxLength:8192}},required:['error']},
35
- {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Optional token budget drops sections in a fixed order.',properties:{target:text,budget:{type:'integer',minimum:1}}},
36
+ {name:'get_extension_artifacts',description:'Validate and list the project\'s locked declarative extension artifacts and their allowlisted files. Artifacts are inert data and do not activate extension code.',properties:{}},
37
+ {name:'get_extension_artifact',description:'Read one bounded JSON or Markdown file from a verified cached declarative extension artifact. The artifact name and member path must exist in the project lock/cache.',properties:{name:{type:'string',maxLength:64},path:{type:'string',maxLength:128}},required:['name','path']},
38
+ {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Pass `task: "redirects"` for a bounded, redirect-focused call instead (supported/gap shapes, exact YAML, this project\'s redirects). Optional token budget drops sections in a fixed order.',properties:{target:text,task:{enum:['redirects']},budget:{type:'integer',minimum:1}}},
36
39
  ];
37
40
  // Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
38
41
  const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
@@ -76,8 +79,12 @@ export async function serveMcp(options ) {
76
79
  case 'get_example':return getExample(args.name );
77
80
  case 'validate_yaml':return validateYaml(args.yaml );
78
81
  case 'explain_error':return explainError(args.error );
82
+ case 'get_extension_artifacts':return describeArtifactCache(project);
83
+ case 'get_extension_artifact':return readArtifactMember(project,args.name ,args.path );
79
84
  case 'get_extensions':return describeExtensions(project,host.extensions??[]);
80
- case 'get_context':return buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
85
+ case 'get_context':return typeof args.task==='string'
86
+ ?buildTaskContext(project,args.task,{...(typeof args.budget==='number'?{budget:args.budget}:{})})
87
+ :buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
81
88
  default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
82
89
  }
83
90
  };
@@ -91,7 +98,7 @@ export async function serveMcp(options ) {
91
98
  if(message.method==='initialize') {
92
99
  if(initialized){await error(id,-32600,'Already initialized');return;}
93
100
  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;}
94
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.7'}}});return;
101
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.0'}}});return;
95
102
  }
96
103
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
97
104
  if(!ready){await error(id,-32002,'Initialize first');return;}
package/dist/readiness.js CHANGED
@@ -119,11 +119,11 @@ export function projectPlan(compiled )
119
119
  if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.middleware?.length || route.names.length) continue;
120
120
  // Required inputs need intentional fixtures; never invent business data.
121
121
  let context ;
122
- try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
122
+ try { context = contextFor(route,route.wildcard ? {'**':'sample'} : {},new URLSearchParams(),new Headers()); } catch { continue; }
123
123
  if (route.request?.body?.required) continue;
124
124
  const files = route.asset instanceof Map ? route.asset : undefined;
125
125
  const prefix = route.prefix ?? '';
126
- const paths = route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
126
+ const paths = route.wildcard ? [prefix + 'sample'] : route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
127
127
  for (const path of paths) for (const method of route.methods) {
128
128
  if (!['GET','HEAD'].includes(method)) continue;
129
129
  const test = {path,method,status:(route.redirect ? route.redirect.status || 302 : route.reply?.status || 200)};
package/dist/router.js CHANGED
@@ -73,7 +73,10 @@ export async function compileRoutes(loaded , bindings
73
73
  const parts = segments(pattern);
74
74
  assert(!pattern.startsWith('/_urlcode'), 'The /_urlcode prefix is reserved for runtime operations');
75
75
  const names = parts.map(parameterName).filter((name) => Boolean(name));
76
- assert(!pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard');
76
+ // `/prefix/**` is the redirect-only suffix wildcard: a literal prefix, one terminal `**`, no path parameters.
77
+ const wildcardRedirect = Boolean(config.redirect) && pattern.endsWith('/**');
78
+ if (wildcardRedirect) assert(pattern !== '/**' && pattern.indexOf('*') === pattern.length - 2 && parts.at(-1) === '**' && !names.length && !config.conditional, 'A /** wildcard redirect needs a literal prefix, one terminal **, no path parameters and no conditional');
79
+ assert(wildcardRedirect || !pattern.includes('*') || ((config.static || config.extension) && pattern.endsWith('/*') && parts.filter(p => p.includes('*')).length === 1 && parts.at(-1) === '*' && !names.length), 'Only static or extension routes support a terminal /* wildcard; a redirect uses a terminal /** instead');
77
80
  assert(!config.static || pattern.endsWith('/*'), 'Static routes require a terminal /* wildcard');
78
81
  if (config.page || config.download || config.static) assert((config.methods || methodsDefault).every(m => methodsDefault.includes(m)), 'Asset routes support only GET and HEAD');
79
82
  assert(new Set(names).size === names.length, 'Duplicate path parameter');
@@ -167,14 +170,21 @@ export async function compileRoutes(loaded , bindings
167
170
  if (declaredRedirect) {
168
171
  const value = declaredRedirect.url;
169
172
  assert(!/[\u0000-\u0020\u007f\\]/u.test(value), 'Redirect URL contains unsafe characters');
173
+ // A root-relative destination (`/profiles/{id}`) stays on this site: a single leading slash, so never `//host`, and no dot segments.
174
+ const relative = value.startsWith('/') && !value.startsWith('//');
170
175
  let dest ;
171
- try { dest = new URL(value); } catch { assert(false, 'Redirect URL must be absolute HTTP(S)'); }
172
- assert(['http:', 'https:'].includes(dest.protocol) && !dest.username && !dest.password, 'Redirect must use HTTP(S) without credentials');
173
- const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
174
- assert(authority && !/[{}]/.test(authority) && !/[{}]/.test(dest.search + dest.hash), 'Redirect placeholders are allowed only in path segments');
176
+ try { dest = new URL(value, relative ? 'https://relative.invalid' : undefined); } catch { assert(false, 'Redirect URL must be an absolute HTTP(S) URL or a root-relative path'); }
177
+ if (relative) assert(dest.origin === 'https://relative.invalid' && !value.split(/[?#]/, 1)[0] .split('/').some(part => part === '.' || part === '..'), 'Root-relative redirect must be a plain path without dot segments');
178
+ else {
179
+ assert(['http:', 'https:'].includes(dest.protocol) && !dest.username && !dest.password, 'Redirect must use HTTP(S) without credentials');
180
+ const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
181
+ assert(authority && !/[{}]/.test(authority), 'Redirect placeholders are allowed only in path segments');
182
+ }
183
+ assert(!/[{}]/.test(dest.search + dest.hash), 'Redirect placeholders are allowed only in path segments');
175
184
  const placeholders = [...value.matchAll(/\{([^}]+)\}/g)].map(m => m[1] );
176
- assert(placeholders.every(n => token.test(n) && names.includes(n)), 'Redirect placeholder must reference a declared path input');
177
- assert(!/[{}]/.test(value.replace(/\{[A-Za-z_][A-Za-z0-9_]*\}/g, '')), 'Invalid redirect placeholder');
185
+ assert(placeholders.every(n => (n === '**' && wildcardRedirect) || (token.test(n) && names.includes(n))), 'Redirect placeholder must reference a declared path input');
186
+ assert(placeholders.filter(n => n === '**').length <= 1, '{**} may appear once in a redirect destination');
187
+ assert(!/[{}]/.test(value.replace(/\{(?:[A-Za-z_][A-Za-z0-9_]*|\*\*)\}/g, '')), 'Invalid redirect placeholder');
178
188
  const query = declaredRedirect.query || {};
179
189
  const reserved = new Set(dest.searchParams.keys());
180
190
  for (const [key, ref] of Object.entries(query.map || {})) {
@@ -195,7 +205,8 @@ export async function compileRoutes(loaded , bindings
195
205
  route.function = { ...declaredFunction, source, export: declaredFunction.export || 'default' };
196
206
  for (const ref of Object.values(declaredFunction.args || {})) referenceCheck(ref, route, true);
197
207
  }
198
- if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
208
+ if (wildcardRedirect) { route.prefix = pattern.slice(0, -2); route.wildcard = true; mounts.push(route); }
209
+ else if (config.static || config.extension) { route.prefix = pattern.slice(0, -1); mounts.push(route); }
199
210
  else if (!names.length) exact.set(pattern, route);
200
211
  else {
201
212
  assert(dynamic.length < 1000, 'Maximum 1000 parameterized routes per snapshot');
@@ -215,6 +226,7 @@ export async function compileRoutes(loaded , bindings
215
226
  byLength.get(route.parts.length) .push(route);
216
227
  }
217
228
  for(const mount of mounts.filter(route=>route.extension)){const base=mount.parts.slice(0,-1);for(const candidate of [...exact.values(),...dynamic,...mounts]){if(candidate===mount)continue;const parts=candidate.parts;const shared=Math.min(base.length,parts.length-(candidate.prefix?1:0));const compatible=base.slice(0,shared).every((part,index)=>part===parts[index]||parameterName(parts[index] ));assert(!compatible||(!candidate.prefix&&parts.length<base.length),'Extension mount overlaps another route');}}
229
+ assert(new Set(mounts.map(mount => mount.prefix)).size === mounts.length, 'A /** wildcard redirect cannot share its prefix with a static or extension mount');
218
230
  mounts.sort((a,b) => b.prefix .length - a.prefix .length);
219
231
  assert(performance.now()<deadline, 'Route compilation deadline exceeded');
220
232
  return { exact, byLength, mounts, modules: [...modules.keys()], count: exact.size + dynamic.length + mounts.length };
package/dist/runtime.js CHANGED
@@ -299,6 +299,7 @@ export async function createRuntime(project , rawOptions
299
299
  }
300
300
  if (native && !route.middleware.length) return await finishResponse(native);
301
301
  context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
302
+ context.route = { pattern: route.pattern };
302
303
  // Uniform for `function` and `middleware` alike: a route dispatches
303
304
  // through the sandboxed worker pool only when it declares
304
305
  // `sandbox: true`; every other route runs trusted, in-process
package/dist/site.js CHANGED
@@ -1,4 +1,4 @@
1
- import { stat, readdir, lstat } from 'node:fs/promises';
1
+ import { stat, readdir, lstat, readFile } from 'node:fs/promises';
2
2
  import { join, extname } from 'node:path';
3
3
  import { assert, ConfigError } from './errors.js';
4
4
  import { safeFile } from './config.js';
@@ -233,3 +233,21 @@ export async function applySite(loaded , options = {
233
233
  for (const [path, route] of Object.entries(generated)) loaded.routes[path] = route;
234
234
  return generated;
235
235
  }
236
+
237
+ // The Worker has no filesystem and no asset binding, so the one not-found page
238
+ // is read here and carried inline as a respond route at /404.html. The page is
239
+ // bounded and static: no templating, no request data. It is text/html; the
240
+ // artifact is JSON, which does the escaping, and a body that is not valid
241
+ // UTF-8 is refused rather than silently altered.
242
+ export const notFoundInlineLimit = 65536;
243
+ export async function inlineNotFound(loaded ) {
244
+ const site = loaded.document.site, path = generatedPaths.notFound;
245
+ const route = loaded.routes[path];
246
+ if (!site?.notFound || route?.generated !== 'site.notFound') return false;
247
+ const file = await safeFile(loaded.root, route.page?.file);
248
+ assert((await stat(file)).size <= notFoundInlineLimit, `site.notFound exceeds ${notFoundInlineLimit} bytes; the Cloudflare Worker carries it inline, so keep the page under 64 KiB`);
249
+ let text ;
250
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(await readFile(file)); } catch { throw new ConfigError('site.notFound must be valid UTF-8 to be carried inline in the Worker'); }
251
+ loaded.routes[path] = { respond: { text }, response: { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } }, description: route.description ?? 'generated by site.notFound', generated: 'site.notFound' };
252
+ return true;
253
+ }
package/dist/tooling.js CHANGED
@@ -21,8 +21,8 @@ export {getCapability} from './capability-query.js';
21
21
  export {getSchemaFragment,schemaPathNames} from './schema-query.js';
22
22
 
23
23
  export {listRecipes,showRecipe,searchRecipes,listExamples,searchExamples};
24
- export {buildContext,renderContext,estimateTokens,documentationTokens} from './context.js';
25
-
24
+ export {buildContext,renderContext,estimateTokens,documentationTokens,buildTaskContext,renderTaskContext,contextTasks} from './context.js';
25
+
26
26
 
27
27
  /** `extensions` are operator registrations from a host file; explain reports whether each requirement has a provider. Nothing is activated. */
28
28
 
@@ -5,8 +5,13 @@ export declare const mcpConfigFile = ".mcp.json";
5
5
  /**
6
6
  * Renders `.mcp.json` registering the read-only `urlcode mcp` server for the project at `project`, relative
7
7
  * to the file. `--allow-authoring` is deliberately absent: the operator adds it by hand when they want it.
8
+ * `local` is for a project whose package.json pins the runtime: the server is then launched through `npx --no`,
9
+ * which uses the installed copy and refuses to fetch anything (a bare `urlcode` is not on PATH for a local-only install,
10
+ * and `npx urlcode` would resolve an unrelated registry package). Without a pin the bare command is kept for global installs.
8
11
  */
9
- export declare function renderMcpConfig(project?: string): string;
12
+ export declare function renderMcpConfig(project?: string, { local }?: {
13
+ local?: boolean;
14
+ }): string;
10
15
  /**
11
16
  * The application-level AGENTS.md written by `urlcode init`. Built from the
12
17
  * installed runtime's capability catalog, so it names only the handlers,
@@ -6,7 +6,7 @@ export interface InitOptions {
6
6
  */
7
7
  manifest?: DependencySet | undefined;
8
8
  /** `default` (function, middleware, redirect) or `page`: urlcode.yaml, public/index.html, a README and fixtures only. */
9
- template?: 'default' | 'page' | undefined;
9
+ template?: 'default' | 'page' | 'redirects' | undefined;
10
10
  }
11
11
  export declare function initProject(destination: string, { manifest, template }?: InitOptions): Promise<string>;
12
12
  export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
@@ -39,6 +39,7 @@ export interface Artifact {
39
39
  policies?: {
40
40
  security: SecurityConfig;
41
41
  };
42
+ notFound?: true;
42
43
  }
43
44
  export type Validator = (value: unknown) => boolean;
44
45
  export type Validators = Record<string, Validator | undefined>;
@@ -64,3 +64,59 @@ export declare function renderContext(context: ProjectContext): string;
64
64
  export declare function buildContext(project: string, options?: ContextOptions): Promise<ProjectContext>;
65
65
  /** Estimated size of the shipped offline documentation bundle, for comparison with an emitted context. */
66
66
  export declare function documentationTokens(): Promise<number>;
67
+ /** Tasks `--task` / MCP `get_context` accept. Each is fixed guidance plus the project's own facts for that task. */
68
+ export declare const contextTasks: readonly ["redirects"];
69
+ export type ContextTask = typeof contextTasks[number];
70
+ export interface TaskShape {
71
+ need: string;
72
+ support: 'supported' | 'gap';
73
+ /** Exact YAML to merge into urlcode.yaml (`routes` entries or `site`); absent for a gap. */
74
+ yaml?: Record<string, unknown>;
75
+ /** The rule that applies, or the exact validation error a gap produces. */
76
+ note?: string;
77
+ /** For a gap: the tested declarative alternative. */
78
+ workaround?: string;
79
+ }
80
+ /** Established by running `urlcode validate` and `urlcode test` on each shape; test/context.test.ts compiles every `yaml` entry so this cannot drift from the runtime. */
81
+ export declare const redirectShapes: TaskShape[];
82
+ /** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
83
+ export interface TaskStarter {
84
+ file: string;
85
+ yaml: string;
86
+ /** Files the yaml references that must exist, with minimal content. */
87
+ companions: Record<string, string>;
88
+ packageScripts: Record<string, string>;
89
+ note: string;
90
+ }
91
+ /** Merges every supported shape's YAML; test/context-task.test.ts compiles the result, so it cannot drift from the runtime. */
92
+ export declare function redirectStarter(): TaskStarter;
93
+ export interface TaskContext {
94
+ urlcode: string;
95
+ schema: '1';
96
+ task: ContextTask;
97
+ shapes?: TaskShape[];
98
+ starter?: TaskStarter;
99
+ project?: {
100
+ entry: string;
101
+ routes: number;
102
+ redirects: {
103
+ path: string;
104
+ status: number;
105
+ url: string;
106
+ }[];
107
+ site: string[];
108
+ };
109
+ recipe?: string;
110
+ commands?: Record<string, string>;
111
+ omitted?: string[];
112
+ }
113
+ export declare function renderTaskContext(context: TaskContext): string;
114
+ /**
115
+ * One bounded call for a task: fixed guidance plus this project's facts for that task. Same compiler as buildContext;
116
+ * a directory without urlcode.yaml still gets the guidance, any other load failure propagates.
117
+ */
118
+ export declare function buildTaskContext(project: string, task: string, options?: {
119
+ budget?: number | undefined;
120
+ hostFile?: string | undefined;
121
+ projectFlag?: string | undefined;
122
+ }): Promise<TaskContext>;