@jimhoyd/urlcode 0.4.7 → 0.4.8

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.
@@ -43,6 +43,14 @@ When the project has an operator host file, inspect `urlcode extensions
43
43
  before writing extension configuration or project hooks. The report is the
44
44
  machine-readable source for config/policy schemas, hook contracts, supported
45
45
  project-owned authoring surfaces and fast checks.
46
+ When `urlcode.extensions.lock.json` is committed, use MCP
47
+ `get_extension_artifacts` to verify and inventory the locked declarative data,
48
+ then `get_extension_artifact` for only the needed schema, example or README.
49
+ Without MCP, run `urlcode extension-artifacts inspect --project <dir> --json`
50
+ before reading its cache. An artifact is inert authoring data: it does not
51
+ install the matching npm package, register executable code or grant authority.
52
+ Do not install/update one unless the user requests that project change and
53
+ names an immutable `extensions@v…` release.
46
54
  The `SPECIFICATION` section of `llms-full.txt` and
47
55
  `schemas/urlcode.schema.json` resolve contract questions in an installed
48
56
  package. A source checkout also has `docs/SPECIFICATION.md`. Archived plans are
package/README.md CHANGED
@@ -110,7 +110,7 @@ it. Cross-repository acceptance is tracked in
110
110
  ## Status
111
111
 
112
112
  <!-- urlcode-current-version:start -->
113
- The `0.4.7` release line brings core, UI, auth and admin to matching stable
113
+ The `0.4.8` release line brings core, UI, auth and admin to matching stable
114
114
  versions. A stable version selects the npm `latest` channel; it does not close
115
115
  the review and deployment evidence gaps below. `0.4.0-alpha.1`
116
116
  added the extension contract, capabilities and provider conformance, strict
@@ -144,6 +144,9 @@ Start with the [YAML guide and recipe book](docs/YAML-GUIDE.md),
144
144
  authoring, use [the AI guide](docs/AI-AUTHORING.md), the bundled agent skills
145
145
  ([authoring](.claude/skills/urlcode-authoring/SKILL.md),
146
146
  [operations](.claude/skills/urlcode-operations/SKILL.md)) and [llms.txt](llms.txt).
147
+ Agents can also read project-pinned, signed declarative extension schemas through
148
+ the read-only MCP tools described in [extensions](docs/EXTENSIONS.md#signed-declarative-artifacts);
149
+ those artifacts are inert data, not an alternate executable package channel.
147
150
  Follow [organization and readability practices](docs/BEST-PRACTICES.md) as your
148
151
  project grows. Operators should read [capacity/concurrency](docs/CAPACITY.md) and the
149
152
  [DDoS and recovery playbook](docs/RESILIENCE.md). Embedding the runtime from
@@ -4,21 +4,21 @@
4
4
  "dist/adapters.js": "a7153ec52f2ed7e0cdd8ed7d433504815f402ec53b2b1c3d38d0cf0e2fcf6509",
5
5
  "dist/agent-context.js": "1e4e9c7b85d155c06d99d8b70f625bd7ab31330a0207aef6ed1803243b759611",
6
6
  "dist/agent-lists.js": "35ab484198897501d011cb6cc00b5a6787192e19df29c97646e8c118fcc19eef",
7
- "dist/agents-guide.js": "14684bf514cabbe7c9bb1ad6b6b2c479f8aaf3feb299b64655fd9fba218268d6",
7
+ "dist/agents-guide.js": "8ad311348154a00b62598e7a93e21ebee125cfce67482f748b31f98e1c49efbf",
8
8
  "dist/assets.js": "0833b093d2fe457ce68281110dc07d47914992753607741c13b339d92c76cf5c",
9
9
  "dist/authoring-files.js": "ad32814f9ae9c549c6247396f982aa700d13a293cc0a589609eccc68f8965550",
10
10
  "dist/authoring.js": "bb3d4e7c6c982c4a1985a7663f960d05a08459d935e2609d404f4001b7ea767f",
11
11
  "dist/aws.js": "7d8e3a97b4f6dbefbd68da058f05192469d1efcaed5458b284863ebe462e060e",
12
12
  "dist/body-schema.js": "0227ef8cf2b380e3ef65a358533aa0e0f4a119c94e63747b8eb03fe65a6b9193",
13
- "dist/build-cloudflare.js": "e552dbf3c08036f02b0e5eb4bfd1309229bbba41d021ac15051e3f6fb165d8b4",
13
+ "dist/build-cloudflare.js": "ea25c36b99f9bd7d7df0272e0bd104b426c169b3717ed689eb37e813daff93ea",
14
14
  "dist/build-static.js": "fe01ef4fcc66d83d8be7a787538b55974e84fd3d7c89e26f0a2f1a152762d565",
15
15
  "dist/bulk.js": "aac88d422bd9a7a341a421fb4e29ff5580b28d39250b7f1f5315ba20ae92da8a",
16
16
  "dist/capabilities.js": "bb98f5bbf54cc4e82fa197f999a621ad815273176f02692c35566886e2bb8777",
17
17
  "dist/capability-query.js": "9f68bc94901451d7b9ecdd3aee8c3376dca7f0b890e31d8205306cf8120dea86",
18
18
  "dist/catalog.js": "c378ef9bfd63b940790f6975f7db54557c2459722b65a7c170b97df24184bc52",
19
- "dist/cli.js": "e1c5fb1a09a420e905ccb760e826a2626f275fb6fa2890b15c36aaaf185b17d5",
19
+ "dist/cli.js": "b15a87501d2d73073b33e880934a972acc0a1c54763cf3ae3ea3791ad5cf603c",
20
20
  "dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
21
- "dist/cloudflare.js": "17b43b0a6b6a0ef8a893415c989e4ae3976d97cc966adbf897b785f9adc472e3",
21
+ "dist/cloudflare.js": "31da478a6b851b5923c14dbd11885684cecec51cc7e0a5d6031d8512f3ecc040",
22
22
  "dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
23
23
  "dist/compliance-rules/privacy.js": "4cfb1c07d7df698c362dfc8761cd3bba893769e92d7fb0a3901e1c2d25e801f1",
24
24
  "dist/compliance-rules/shared.js": "86fd6db6a93375689acc64975cbc394ce9452dea21ae5941e743f99822515995",
@@ -27,13 +27,14 @@
27
27
  "dist/conditions.js": "ff25e97da550a3bdd80af55522045669438aa31a77d2bc83ad602bcefdee0475",
28
28
  "dist/config-worker.js": "bc2170d60c35f8d5de227cbfc4c5067dc61d7cbc5b98aed88e68a11797072da5",
29
29
  "dist/config.js": "33c8d1f80aa7f1ce25eab12644f6cbef961a897b72b1585d4bfa6424985f0d16",
30
- "dist/context.js": "f0de473b1e13649b0778fe2d5e7a65d4d6ac82614b47a1f86b453b73a85cb166",
30
+ "dist/context.js": "b3a45856662fbda7a0217c7856ab1deb7c09e3e12d7dd1559c0a0cac85a9bcff",
31
31
  "dist/ecosystem-cli.js": "1e71bacd53d6fa8c7857cfcce3c3012ce054771a434b78d1f39e892e32642edd",
32
32
  "dist/egress.js": "2ae29fe2cb4590fd2f715abe6817db47fed2f50dd31f37946ecf42eafbb0dd9e",
33
33
  "dist/errors.js": "fef26eb834dd61b8b5f587eb7ab265d216122d182545f0a1c5b19c20840dffb0",
34
34
  "dist/examples.js": "8523799797d2530fe48ec5e96c34970bdba03c49afb31fdaab6a9c162a17dfa9",
35
35
  "dist/explain-cli.js": "f0f8f3b5046430d03c6abb629b38651c9c734b03456040735ce9d7e582b2bcd5",
36
36
  "dist/explain.js": "7fb632036cc68280701dd512c74d27d6e0f97b4848e9024b284d7c5393b35eb5",
37
+ "dist/extension-artifacts.js": "b196f3a9372188eb5f21f57b6e6ccee0c03aa0bbdd34cd6d88a0b3ccc470d363",
37
38
  "dist/extensions.js": "966441cca677b0d88683d29d89d36c61cf487ae2b70d94eaa0c71c50730a4212",
38
39
  "dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
39
40
  "dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
@@ -42,7 +43,7 @@
42
43
  "dist/header-validation.js": "465181dbb08ff05f52defdd29fda025c0c64589bf319fa87fa6d3ab4b68216d5",
43
44
  "dist/http-policy.js": "393a6383bdf9e05597c98721b3a4c4207a9bbe359883babcbfa06b696c70f56c",
44
45
  "dist/http-response.js": "565826d1ece4bd30dfc1150c75800acec1fef584f732e964409aa92958fc8550",
45
- "dist/index.js": "8d7532ae0a31f9439ebb62e7b01a2748aee00f350262ff55d1226121d206c1ed",
46
+ "dist/index.js": "a852f2512d8af0ba0645a6a8317f7ab2e576c442ab7a31e1570794ff0e635074",
46
47
  "dist/init-with.js": "0d2dccb83f0ad53181c6f5c95a4c663ce06ee9a1fa9074facac9a1aee7599a96",
47
48
  "dist/interchange-cli.js": "35bd70ba8077af5c3e39404ff0e5d28707632090a59a90645141252449fd3f04",
48
49
  "dist/interchange.js": "26789420af33344d9a08c00fe6b2708aa71994224df527b3075295be2151611e",
@@ -50,7 +51,7 @@
50
51
  "dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
51
52
  "dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
52
53
  "dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
53
- "dist/mcp.js": "2fe29baed673e10c5dbb0578c982e5d53837872976a5cd9f7e0a519d3eaf12db",
54
+ "dist/mcp.js": "51965c4e85c8e3ce283baf4255ca9db149ce5ee650e5a1a7c6a097516d9de5b2",
54
55
  "dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
55
56
  "dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
56
57
  "dist/pattern-guard.js": "6f157ced99666a89ca5af1418f5a14568d850985042dea309bf9ec0c19dbaca5",
@@ -77,8 +78,8 @@
77
78
  "dist/schema-query.js": "bfd1844acd8d54ac361115191fcfbaf828a67e223b9ec6e9c22f158761af149b",
78
79
  "dist/server.js": "165b66fc4ed3b8e7c46ae4402ef66a5bc988c0910fb3e11556b040781e09d03c",
79
80
  "dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
80
- "dist/site.js": "38176ac4d1970ebf73e3f96094f39900724ce96b29d6de892b8baae01e183aa5",
81
- "dist/tooling.js": "3d2848aba0a374ef980328c6924d4b314c53094efc2cc94638f1a58b726f8161",
81
+ "dist/site.js": "9f8154d0a62ed1b650c064a24e609d50dd370f0ff2cee2567628029e30855616",
82
+ "dist/tooling.js": "f377128dc37cff4685ba8503f4c38089ca4d7e97551e832bae4cdc24e357ba97",
82
83
  "dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
83
84
  "dist/types.js": "827f1afde90d19715e79500515ecaa80f5b65ce552f0ef24d2fc561ef77e6e40",
84
85
  "dist/typescript-authoring.js": "df3d9c82e6b545cae5caae04c443ed4d2feae96def5e5cde250c0a90d83ae98c",
@@ -54,7 +54,7 @@ static serving and authentication. Read this file before changing anything.
54
54
 
55
55
  When present, \`${mcpConfigFile}\` registers the read-only \`urlcode mcp\` server; prefer its
56
56
  tools (also \`get_manifest\`) to reading documents. Inspect \`get_extensions\` before
57
- replacing extension behavior. \`--allow-authoring\` is an operator opt-in; never add it.
57
+ replacing extension behavior. \`--allow-authoring\` is an operator opt-in; never add it. For a committed artifact lock, use \`get_extension_artifacts\`/\`get_extension_artifact\`; they expose verified inert data and never activate an extension.
58
58
 
59
59
  ## What the runtime provides (this version)
60
60
 
@@ -9,7 +9,7 @@ import { compileRoutes } from './router.js';
9
9
  import { assert } from './errors.js';
10
10
  import { effectivePolicies, registry } from './policies.js';
11
11
  import { resolveLists } from './agent-lists.js';
12
- import { applySite } from './site.js';
12
+ import { applySite, inlineNotFound } from './site.js';
13
13
  import { buildManifest, renderManifest, manifestPath } from './manifest.js';
14
14
 
15
15
 
@@ -71,6 +71,8 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
71
71
  // Generated site routes are built like declared ones; the ones that need
72
72
  // an origin get it from --origin, exactly as the server does.
73
73
  await applySite(loaded, { origin, log });
74
+ // The one not-found page travels inline; every other page route is still refused below.
75
+ const notFound = await inlineNotFound(loaded);
74
76
  assertTargetCompatibility(analyzeProjectCapabilities(loaded, 'cloudflare'));
75
77
  // No bindings are resolved: a build artifact must never carry a secret, and
76
78
  // this target has no per-request operator policy to pin one to.
@@ -132,7 +134,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
132
134
  ? { security: projectPolicies.security } : undefined;
133
135
  if (errorPolicy) registry.security.compile(errorPolicy.security, { route: { pattern: '(project)' }, shared: {}, target: 'cloudflare', document: loaded.document });
134
136
 
135
- const artifact = { format:FORMAT, version:loaded.version, routes:serialised, ...(errorPolicy ? { policies: errorPolicy } : {}) };
137
+ const artifact = { format:FORMAT, version:loaded.version, routes:serialised, ...(errorPolicy ? { policies: errorPolicy } : {}), ...(notFound ? { notFound: true } : {}) };
136
138
  await mkdir(out, { recursive:true });
137
139
  await writeFile(join(out,'validators.js'), await linkRuntime(standaloneCode.default(ajv, validators)));
138
140
  await writeFile(join(out,'artifact.js'),
package/dist/cli.js CHANGED
@@ -27,8 +27,9 @@ import { registry as policyRegistry } from './policies.js';
27
27
  import { loadComplianceRules, profileNames as complianceProfiles } from './compliance.js';
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
+ import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
30
31
 
31
- const usage = `URLCode 0.4.7 — local/self-hosted runtime
32
+ const usage = `URLCode 0.4.8 — local/self-hosted runtime
32
33
  urlcode init <directory> [--template page] [--with ui,auth,admin] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
34
  # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
34
35
  # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
@@ -64,6 +65,10 @@ const usage = `URLCode 0.4.7 — local/self-hosted runtime
64
65
  # effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
65
66
  urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
66
67
  urlcode extensions [--project directory] [--host-file /absolute/operator/host.mjs] [--json] # registered contracts and schemas; executes trusted host code, activates nothing
68
+ urlcode extension-artifacts install <name> --artifact-release extensions@vX.Y.Z [--project directory]
69
+ urlcode extension-artifacts update <name> --artifact-release extensions@vX.Y.Z [--project directory]
70
+ urlcode extension-artifacts inspect [--project directory] [--json]
71
+ # signed, data-only extension bundles cached under .urlcode/extensions; they never execute or replace --host-file
67
72
  urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
68
73
  urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
69
74
  conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
@@ -77,8 +82,8 @@ const usage = `URLCode 0.4.7 — local/self-hosted runtime
77
82
  urlcode capabilities [--target self-hosted|cloudflare|aws|vercel|static] [--json]
78
83
  urlcode capabilities <name> [--json] # one catalog entry: schema fragment, constraints, grants, targets, bundled uses
79
84
  urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
80
- urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--budget 500] [--json] [--stats]
81
- # compact facts for an authoring agent from the compiled project; --stats compares estimated tokens with the docs
85
+ urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static | --task redirects] [--budget 500] [--json] [--stats]
86
+ # compact facts for an authoring agent from the compiled project; --task redirects: supported redirect shapes, gaps and this project's redirects in one bounded call; --stats compares estimated tokens with the docs
82
87
  urlcode doctor
83
88
  serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
84
89
  Dev loads .env.local and watches; serve does neither. Functions run trusted and in-process by default; a route declaring sandbox: true runs in WASM isolation. External bindings require --policy outside the project.
@@ -93,7 +98,8 @@ const options = {
93
98
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
94
99
  'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
95
100
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
96
- budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
101
+ budget:{type:'string'}, task:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
102
+ 'artifact-release':{type:'string'},
97
103
  } ;
98
104
 
99
105
 
@@ -170,10 +176,16 @@ try {
170
176
  if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
171
177
  if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
172
178
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
179
+ if (values['artifact-release'] !== undefined && command !== 'extension-artifacts') throw new ConfigError('--artifact-release is only supported by extension-artifacts');
173
180
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
174
- if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
181
+ if ((!['import','recipes','recipe','examples','example','bulk-import','extension-artifacts'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema','extension-artifacts'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
175
182
 
176
- if(command==='import'||command==='export'){
183
+ if(command==='extension-artifacts'){
184
+ const operation=arg;
185
+ if(operation==='install'||operation==='update') { const artifact=extra[0]; if(!artifact || extra.length!==1) throw new ConfigError(`Use urlcode extension-artifacts ${operation} <name> --artifact-release extensions@vX.Y.Z`); if(!values['artifact-release']) throw new ConfigError('Use --artifact-release with an immutable extension release tag'); const lock=await installArtifact(values.project,values['artifact-release'],artifact); print(values.json?lock:{event:operation==='install'?'extension-artifact-installed':'extension-artifact-updated',name:artifact,lockfile:'urlcode.extensions.lock.json'}); }
186
+ else if(operation==='inspect') { if(extra.length) throw new ConfigError('Use urlcode extension-artifacts inspect'); const report=await inspectArtifacts(values.project); print(values.json?report:{artifacts:report.lock.artifacts.map(item=>({...item,status:report.cached.includes(item.name)?'cached':report.invalid.includes(item.name)?'invalid':'missing'}))}); }
187
+ else throw new ConfigError('Use extension-artifacts install, update or inspect');
188
+ }else if(command==='import'||command==='export'){
177
189
  const { runInterchange } = await import('./interchange-cli.js');
178
190
  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']});
179
191
  print(converted.text); if(!converted.report.ok)process.exitCode=1;
@@ -193,9 +205,17 @@ try {
193
205
  print(values.yaml ? stringifyYaml(fragment.schema) : JSON.stringify(fragment.schema,null,2)+'\n');
194
206
  }else if(command==='context'){
195
207
  if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
196
- const { buildContext, renderContext, estimateTokens, documentationTokens } = await import('./context.js');
197
- const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...(values.budget === undefined ? {} : { budget:Number(values.budget) }) });
198
- const text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
208
+ const { buildContext, buildTaskContext, renderContext, renderTaskContext, estimateTokens, documentationTokens } = await import('./context.js');
209
+ const budget = values.budget === undefined ? {} : { budget:Number(values.budget) };
210
+ let text ;
211
+ if (values.task !== undefined) {
212
+ if (values.target !== undefined) throw new ConfigError('--task cannot be combined with --target');
213
+ const task = await buildTaskContext(values.project, values.task, { hostFile:values['host-file'], ...budget });
214
+ text = values.json ? JSON.stringify(task) + '\n' : renderTaskContext(task);
215
+ } else {
216
+ const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...budget });
217
+ text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
218
+ }
199
219
  print(text);
200
220
  // Estimates only (characters / 4); a tokenizer is not a dependency. Stats go to stderr so stdout stays parseable.
201
221
  if (values.stats) process.stderr.write(JSON.stringify({ event:'stats', estimate:'characters/4', documentationTokens:await documentationTokens(), contextTokens:estimateTokens(text) }) + '\n');
@@ -22,7 +22,7 @@ import * as security from './policies/security.js';
22
22
 
23
23
 
24
24
 
25
-
25
+
26
26
 
27
27
 
28
28
  /** A route's compiled policy chain on this target: the same hook pairs the Node runtime holds. */
@@ -101,7 +101,10 @@ export function createFetchHandler(artifact , validators )
101
101
  const url = new URL(request.url);
102
102
  origin = url.origin;
103
103
  const parsed = parseTarget(url.pathname + url.search);
104
- const match = matchRoute(compiled, parsed);
104
+ let match = matchRoute(compiled, parsed), fallback = false;
105
+ // site.notFound: an unmatched GET/HEAD is answered with the inlined page
106
+ // (the /404.html route the build emitted) and status 404.
107
+ if (!match && artifact.notFound && (method === 'GET' || method === 'HEAD')) { match = matchRoute(compiled, parseTarget('/404.html')); fallback = match !== null; }
105
108
  if (!match) throw new HttpError(404, 'Not found');
106
109
  const { route, path } = match;
107
110
  matched = route;
@@ -138,7 +141,7 @@ export function createFetchHandler(artifact , validators )
138
141
  const context = contextFor(route, path, parsed.query, request.headers, {});
139
142
  let native ;
140
143
  if (redirecting(route)) native = { status: route.redirect.status || 302, headers:[['location',redirectLocation(route, context, parsed.query)]], body: new Uint8Array(0) };
141
- else if (route.reply) native = { ...route.reply };
144
+ else if (route.reply) native = { ...route.reply, ...(fallback ? { status: 404 } : {}) };
142
145
  else throw new HttpError(502, 'Invalid function response');
143
146
  return respond(prepareResponse(await finish(decorateResponse(route, native)), { requestId, method }), requestId, method);
144
147
  } catch (error) {
package/dist/context.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import {readFile} from 'node:fs/promises';
2
- import {relative} from 'node:path';
2
+ import {join,relative} from 'node:path';
3
3
  import {stringify} from 'yaml';
4
4
  import {loadDocument} from './config.js';
5
5
  import {applySite} from './site.js';
@@ -151,3 +151,73 @@ function fitBudget(context ,budget ) {
151
151
  export async function documentationTokens() {
152
152
  return Math.ceil((await readFile(new URL('../llms-full.txt',import.meta.url),'utf8')).length/4);
153
153
  }
154
+
155
+ /** Tasks `--task` / MCP `get_context` accept. Each is fixed guidance plus the project's own facts for that task. */
156
+ export const contextTasks=['redirects'] ;
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+ const idParam=(name )=>({name,in:'path',required:true,schema:{type:'string',minLength:1,maxLength:64}});
169
+ /** 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. */
170
+ export const redirectShapes =[
171
+ {need:'fixed',support:'supported',yaml:{routes:{'/old':{redirect:{url:'https://example.com/new',status:301}}}},note:'status defaults to 302; allowed 301, 302, 303, 307, 308. Only GET/HEAD match unless methods is set.'},
172
+ {need:'parameterized path (/users/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/users/{id}':{parameters:[idParam('id')],redirect:{url:'https://example.com/profiles/{id}',status:308}}}},note:'{name} placeholders only in the destination path, each naming a declared path parameter; the value is encoded as one component.'},
173
+ {need:'fixed-depth suffix (/legacy/a/b to /modern/a/b)',support:'supported',yaml:{routes:{'/legacy/{a}/{b}':{parameters:[idParam('a'),idParam('b')],redirect:{url:'https://example.com/modern/{a}/{b}'}}}},note:'One route per depth; a path with more or fewer segments is a 404.'},
174
+ {need:'query-string preservation',support:'supported',yaml:{routes:{'/search':{parameters:[{name:'q',in:'query',schema:{type:'string',maxLength:100}}],redirect:{url:'https://example.com/find',query:{pass:['q','utm_source']}}}}},note:'Nothing is forwarded by default; pass is an explicit allowlist (pass: true is refused by the schema); query.map renames or maps declared inputs.'},
175
+ {need:'method-preserving redirect',support:'supported',yaml:{routes:{'/form':{methods:['GET','POST'],redirect:{url:'https://example.com/form2',status:307}}}},note:'Default methods GET/HEAD; other methods answer 405. Use 307/308 to keep the method and body.'},
176
+ {need:'404 for unmatched paths',support:'supported',yaml:{site:{notFound:'404.html'}},note:'Unmatched GET/HEAD answer 404 (plain without site.notFound; that .html file, still status 404, with it). Trailing slashes are not normalized: /old/ is a 404 unless declared as its own route.'},
177
+ {need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'gap',note:'`/legacy/*` on a redirect fails validation: "Only static or extension routes support a terminal /* wildcard"; `{rest...}` fails with "Invalid route parameter". Report the gap; proposal in docs/OPEN-DECISIONS.md.',workaround:'a fixed-depth route per depth you need, or one route per known path (urlcode bulk-import). A function handler cannot match a subtree either.'},
178
+ {need:'host, scheme or relative destination',support:'gap',note:'Destination must be a literal absolute http(s) URL: "/x" and "//h/x" fail with "Redirect URL must be absolute HTTP(S)"; {param} in host or query fails with "Redirect placeholders are allowed only in path segments"; other schemes fail with "Redirect must use HTTP(S) without credentials". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
179
+ {need:'redirect loop detection',support:'gap',note:'Validation accepts a route that redirects to its own URL; nothing detects cycles. Write a fixture with expectHeaders location for each redirect and review chains by hand.'},
180
+ ];
181
+
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+ export function renderTaskContext(context ) {return stringify(context,{lineWidth:0,aliasDuplicateObjects:false,flowCollectionPadding:false});}
190
+ /**
191
+ * One bounded call for a task: fixed guidance plus this project's facts for that task. Same compiler as buildContext;
192
+ * a directory without urlcode.yaml still gets the guidance, any other load failure propagates.
193
+ */
194
+ export async function buildTaskContext(project ,task ,options ={}) {
195
+ if(!(contextTasks ).includes(task))throw new Error(`Unknown context task; use one of: ${contextTasks.join(', ')}`);
196
+ const budget=options.budget;
197
+ if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
198
+ const flag=options.projectFlag??project;
199
+ const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape}))};
200
+ const exists=await readFile(join(project,'urlcode.yaml')).then(()=>true,()=>false);
201
+ if(exists) {
202
+ const host=await loadOperatorHost(options.hostFile,project);
203
+ try {
204
+ const {loaded,compiled,routes}=await compile(project);
205
+ context.project={entry:'urlcode.yaml',routes:compiled.count,redirects:routes.filter(route=>route.redirect).map(route=>({path:route.pattern,status:route.redirect .status??302,url:route.redirect .url})).sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0).slice(0,20),site:sorted(Object.keys(loaded.document.site??{}))};
206
+ } finally {await host.close?.();}
207
+ }
208
+ context.recipe='urlcode recipes show redirect';
209
+ context.commands={validate:`urlcode validate --local --project ${flag}`,test:`urlcode test --project ${flag}`,audit:`urlcode audit --project ${flag} --expect-routes ${context.project?context.project.routes:'N'}`,schema:'urlcode schema redirect'};
210
+ if(budget===undefined)return context;
211
+ // Fixed order, like fitBudget: this project's facts, then commands, then the notes, then the shapes.
212
+ const omitted =[];
213
+ const fits=()=>estimateTokens(renderTaskContext(omitted.length?{...context,omitted}:context))<=budget;
214
+ const steps =[
215
+ ['project',()=>{delete context.project;}],
216
+ ['commands',()=>{delete context.commands;delete context.recipe;}],
217
+ ['notes',()=>{context.shapes=context.shapes .map(({need,support,yaml})=>({need,support,...(yaml?{yaml}:{})}));}],
218
+ ['shapes',()=>{delete context.shapes;}],
219
+ ];
220
+ for(const [name,drop] of steps) {if(fits())break;drop();omitted.push(name);}
221
+ if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
222
+ return omitted.length?{...context,omitted}:context;
223
+ }
@@ -0,0 +1,140 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { gunzipSync } from 'node:zlib';
3
+ import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join, relative, resolve } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { spawn } from 'node:child_process';
7
+ import { ConfigError, assert } from './errors.js';
8
+
9
+ /** Offline, declarative extension bundles. These are deliberately not Node packages. */
10
+ export const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
11
+ export const ARTIFACT_WORKFLOW = 'jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml';
12
+ const MAX_ARCHIVE = 16 * 1024 * 1024, MAX_EXPANDED = 32 * 1024 * 1024, MAX_FILES = 128, MAX_FILE = 2 * 1024 * 1024;
13
+ const MAX_TOOL_FILE = 512 * 1024;
14
+ const hex = /^[a-f0-9]{64}$/;
15
+ const name = /^[a-z][a-z0-9-]{0,63}$/;
16
+ const tag = /^extensions@v[0-9][0-9A-Za-z._-]{0,100}$/;
17
+
18
+
19
+
20
+
21
+
22
+
23
+ const record=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
24
+ const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
25
+ function text(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what} in extension artifact metadata`); return value; }
26
+ function exactKeys(value , expected , what ) { const actual=Object.keys(value).sort(); assert(JSON.stringify(actual)===JSON.stringify([...expected].sort()),`${what} has unknown or missing fields`); }
27
+
28
+ /** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
29
+ export function parseCatalog(bytes , requestedTag ) {
30
+ let raw ; try { raw=JSON.parse(new TextDecoder().decode(bytes)); } catch { throw new ConfigError('Extension catalog is not valid JSON'); }
31
+ assert(record(raw) && raw.format===1,'Unsupported extension catalog format');
32
+ exactKeys(raw,['format','tag','commit','artifacts','revoked'],'Extension catalog');
33
+ const catalogTag=text(raw.tag,'catalog tag'), commit=text(raw.commit,'catalog commit');
34
+ assert(tag.test(catalogTag) && catalogTag===requestedTag,'Extension catalog tag does not match the immutable requested release');
35
+ assert(/^[a-f0-9]{40}$/.test(commit),'Extension catalog has an invalid commit pin');
36
+ assert(Array.isArray(raw.artifacts) && Array.isArray(raw.revoked),'Extension catalog is incomplete');
37
+ const seen=new Set (), assets=new Set (), digests=new Set (), artifacts =[];
38
+ for(const value of raw.artifacts) {
39
+ assert(record(value),'Invalid extension catalog artifact');
40
+ exactKeys(value,['name','version','asset','sha256','kind'],'Extension catalog artifact');
41
+ const item ={name:text(value.name,'artifact name'),version:text(value.version,'artifact version'),asset:text(value.asset,'artifact asset'),sha256:text(value.sha256,'artifact sha256'),kind:value.kind==='declarative'?'declarative':(() => { throw new ConfigError('Extension catalog permits declarative artifacts only'); })()};
42
+ assert(name.test(item.name) && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(item.version) && /^[A-Za-z0-9._-]+\.tgz$/.test(item.asset) && hex.test(item.sha256),'Invalid extension catalog artifact');
43
+ assert(!seen.has(item.name),`Extension catalog names ${item.name} more than once`); assert(!assets.has(item.asset)&&!digests.has(item.sha256),'Extension catalog repeats an artifact asset or digest'); seen.add(item.name); assets.add(item.asset); digests.add(item.sha256); artifacts.push(item);
44
+ }
45
+ const revoked =[], revokedDigests=new Set ();
46
+ for(const value of raw.revoked) { assert(record(value),'Invalid extension revocation'); exactKeys(value,['sha256','reason'],'Extension revocation'); const sha256=text(value.sha256,'revocation sha256'), reason=text(value.reason,'revocation reason'); assert(hex.test(sha256)&&!revokedDigests.has(sha256),'Invalid or duplicate extension revocation'); revokedDigests.add(sha256); revoked.push({sha256,reason}); }
47
+ return {format:1,tag:catalogTag,commit,artifacts,revoked};
48
+ }
49
+
50
+
51
+ function octal(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,'').trim(); assert(/^[0-7]*$/.test(value),'Malformed extension archive'); return value ? Number.parseInt(value,8) : 0; }
52
+ function archivePath(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,''); assert(value.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path'); return value; }
53
+ /** A minimal tar reader: only regular files are accepted, before any write occurs. */
54
+ function readTgz(source ) {
55
+ assert(source.byteLength>0 && source.byteLength<=MAX_ARCHIVE,'Extension archive exceeds the 16 MiB limit');
56
+ let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:MAX_EXPANDED}); } catch { throw new ConfigError('Extension artifact is not a valid bounded gzip tarball'); }
57
+ const files =[]; let ended=false;
58
+ for(let at=0;at<bytes.length;) {
59
+ const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),'Malformed extension archive terminator'); ended=true; break; }
60
+ assert(header.length===512,'Truncated extension archive'); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,'Extension archive has an invalid tar checksum'); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
61
+ assert(type===0 || type===48,'Extension archives may contain regular files only'); assert(size<=MAX_FILE && at+512+size<=bytes.length,'Invalid extension archive member');
62
+ const path=archivePath(header.subarray(0,100)); assert(!files.some(file=>file.path===path),'Extension archive repeats a path');
63
+ files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=MAX_FILES,'Extension archive has too many files'); at+=512+Math.ceil(size/512)*512;
64
+ }
65
+ assert(ended,'Extension archive has no complete tar terminator');
66
+ return files;
67
+ }
68
+ async function diskFiles(root ,prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),'Extension cache contains a link or special file'); if(item.isDirectory()) found.push(...await diskFiles(root,path)); else found.push(path); } return found.sort(); }
69
+ async function validateCached(root , entry ) { const archive=await readFile(join(root,'.artifact.tgz')); assert(digest(archive)===entry.sha256,`Cached extension artifact ${entry.name} does not match its lockfile`); const files=readTgz(archive); validateFiles(files,entry); const expected=['.artifact.tgz',...files.map(file=>file.path)].sort(); assert(JSON.stringify(await diskFiles(root))===JSON.stringify(expected),`Cached extension artifact ${entry.name} has unexpected files`); for(const file of files) assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension artifact ${entry.name} was modified`); }
70
+ function validateFiles(files , entry ) {
71
+ const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
72
+ assert(files.length>0 && files.every(file=>allowed.test(file.path)),'Extension artifact contains a file type that is not declarative data');
73
+ for(const file of files) if(file.path.endsWith('.json')) try { JSON.parse(new TextDecoder().decode(file.bytes)); } catch { throw new ConfigError(`Extension artifact contains invalid JSON in ${file.path}`); }
74
+ const manifest=files.find(file=>file.path==='extension.json'); assert(manifest,'Extension artifact is missing extension.json');
75
+ let raw ; try { raw=JSON.parse(new TextDecoder().decode(manifest.bytes)); } catch { throw new ConfigError('extension.json is not valid JSON'); }
76
+ assert(record(raw),'extension.json must be an object'); exactKeys(raw,['format','kind','name','version'],'extension.json');
77
+ assert(record(raw) && raw.format===1 && raw.kind==='declarative' && raw.name===entry.name && raw.version===entry.version,'extension.json does not match its signed catalog entry');
78
+ }
79
+ export async function extractArtifact(bytes , entry , destination ) {
80
+ assert(digest(bytes)===entry.sha256,`Extension artifact ${entry.name} does not match its signed SHA-256`); const files=readTgz(bytes); validateFiles(files,entry);
81
+ const root=resolve(destination), temporary=join(tmpdir(),`urlcode-extension-${process.pid}-${Math.random().toString(16).slice(2)}`); await mkdir(temporary,{recursive:true});
82
+ try { for(const file of files) { const target=resolve(temporary,file.path); assert(relative(temporary,target) && !relative(temporary,target).startsWith('..'),'Unsafe extension archive path'); await mkdir(dirname(target),{recursive:true}); await writeFile(target,file.bytes,{flag:'wx'}); } await writeFile(join(temporary,'.artifact.tgz'),bytes,{flag:'wx'}); await mkdir(dirname(root),{recursive:true}); try { await rename(temporary,root); } catch { try { await validateCached(root,entry); return; } catch { throw new ConfigError(`Extension cache entry ${entry.sha256} already exists but is not identical`); } } } finally { await rm(temporary,{recursive:true,force:true}); }
83
+ }
84
+ export async function readLock(project ) { let raw ; try { const path=join(project,'urlcode.extensions.lock.json'), info=await lstat(path); assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension artifact lockfile must be an ordinary file'); raw=JSON.parse(await readFile(path,'utf8')); } catch(error) { if(error instanceof ConfigError)throw error; throw new ConfigError('No extension artifact lockfile; install an artifact first'); } assert(record(raw)&&raw.format===1&&Array.isArray(raw.artifacts),'Invalid extension artifact lockfile'); exactKeys(raw,['format','artifacts'],'Extension artifact lockfile'); const seen=new Set (),digests=new Set (); const artifacts=raw.artifacts.map(value=>{ assert(record(value)&&record(value.catalog),'Invalid extension artifact lockfile'); exactKeys(value,['name','version','asset','sha256','kind','catalog'],'Extension artifact lock entry'); exactKeys(value.catalog,['tag','commit'],'Extension artifact lock catalog'); const item ={name:text(value.name,'lockfile artifact'),version:text(value.version,'lockfile artifact'),asset:text(value.asset,'lockfile artifact'),sha256:text(value.sha256,'lockfile artifact'),kind:value.kind==='declarative'?'declarative':(()=>{throw new ConfigError('Invalid extension artifact lockfile');})(),catalog:{tag:text(value.catalog.tag,'lockfile tag'),commit:text(value.catalog.commit,'lockfile commit')}}; assert(name.test(item.name)&&/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&!seen.has(item.name)&&!digests.has(item.sha256),'Invalid or duplicate extension artifact lock entry'); seen.add(item.name); digests.add(item.sha256); return item; }); return {format:1,artifacts}; }
85
+ export async function writeLock(project , lock ) { const path=join(project,'urlcode.extensions.lock.json'), temporary=join(project,`.urlcode.extensions.lock.${process.pid}.${Math.random().toString(16).slice(2)}`); await writeFile(temporary,JSON.stringify(lock,null,2)+'\n',{flag:'wx'}); try { await rename(temporary,path); } finally { await rm(temporary,{force:true}); } }
86
+ export function cachePath(project , sha256 ) { assert(hex.test(sha256),'Invalid extension digest'); return join(project,'.urlcode','extensions',sha256); }
87
+
88
+
89
+
90
+ function releaseUrl(tagName ) { return `https://api.github.com/repos/${ARTIFACT_REPOSITORY}/releases/tags/${encodeURIComponent(tagName)}`; }
91
+ function githubDownloadUrl(value ) { const url=new URL(value); assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),'Extension release redirect left GitHub'); return url; }
92
+ async function githubDownload(value ) { let url=githubDownloadUrl(value); for(let redirects=0;redirects<=3;redirects++) { const response=await fetch(url,{redirect:'manual'}); if(response.status>=300&&response.status<400) { const location=response.headers.get('location'); assert(location&&redirects<3,'Extension release asset has an invalid redirect'); url=githubDownloadUrl(new URL(location,url).href); continue; } assert(response.ok&&response.body,'Could not download extension release asset'); const length=response.headers.get('content-length'); assert(length===null||(/^\d+$/.test(length)&&Number(length)<=MAX_ARCHIVE),'Extension release asset exceeds the size limit'); const chunks =[]; let size=0; for await(const chunk of response.body) { size+=chunk.byteLength; assert(size<=MAX_ARCHIVE,'Extension release asset exceeds the size limit'); chunks.push(chunk); } const body=new Uint8Array(size); let offset=0; for(const chunk of chunks) { body.set(chunk,offset); offset+=chunk.byteLength; } return body; } throw new ConfigError('Extension release asset redirected too many times'); }
93
+ /** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
94
+ export const githubTransport ={
95
+ async release(tagName) { assert(tag.test(tagName),'Use an immutable extension release tag such as extensions@v1.0.0'); const response=await fetch(releaseUrl(tagName),{headers:{accept:'application/vnd.github+json'}}); assert(response.ok,`Could not fetch extension release ${tagName}`); const raw =await response.json(); assert(record(raw)&&Array.isArray(raw.assets),'Extension release has no asset inventory'); const seen=new Set (); return raw.assets.map(item=>{ assert(record(item)&&typeof item.name==='string'&&typeof item.browser_download_url==='string'&&!seen.has(item.name),'Invalid or duplicate extension release asset'); seen.add(item.name); const url=new URL(item.browser_download_url); assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${ARTIFACT_REPOSITORY}/releases/download/`),'Extension release asset is not a GitHub download'); return {name:item.name,url:url.href}; }); },
96
+ async download(url) { return githubDownload(url); },
97
+ async attest(path,release) { assert(tag.test(release),'Invalid extension artifact release tag'); await new Promise ((resolveVerify,reject)=>{ const child=spawn('gh',['attestation','verify',path,'--repo',ARTIFACT_REPOSITORY,'--signer-workflow',ARTIFACT_WORKFLOW,'--source-ref',`refs/tags/${release}`,'--deny-self-hosted-runners'],{stdio:'ignore'}); child.on('error',()=>reject(new ConfigError('GitHub CLI with attestation support is required to verify extension artifacts'))); child.on('exit',code=>code===0?resolveVerify():reject(new ConfigError('GitHub attestation verification refused the extension artifact'))); }); },
98
+ };
99
+ async function verifiedAsset(assets , asset , release , transport ) { const found=assets.filter(item=>item.name===asset); assert(found.length===1,`Extension release is missing or repeats ${asset}`); const bytes=await transport.download(found[0] .url); const temporary=join(tmpdir(),`urlcode-attest-${process.pid}-${Math.random().toString(16).slice(2)}`); await writeFile(temporary,bytes,{flag:'wx'}); try { await transport.attest(temporary,release); return bytes; } finally { await rm(temporary,{force:true}); } }
100
+ export async function resolveCatalog(release , transport =githubTransport) { const assets=await transport.release(release); const bytes=await verifiedAsset(assets,'extensions-catalog.json',release,transport); return {catalog:parseCatalog(bytes,release),assets}; }
101
+ export async function installArtifact(project , release , artifactName , transport =githubTransport) {
102
+ assert(name.test(artifactName),'Invalid extension artifact name'); const {catalog,assets}=await resolveCatalog(release,transport); const entry=catalog.artifacts.find(item=>item.name===artifactName); assert(entry,`Extension artifact ${artifactName} is not in the signed catalog`); const revoked=catalog.revoked.find(item=>item.sha256===entry.sha256); assert(!revoked,`Extension artifact ${artifactName} is revoked: ${revoked?.reason ?? 'unknown reason'}`);
103
+ const bytes=await verifiedAsset(assets,entry.asset,release,transport); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
104
+ let prior ; try { prior=await readLock(project); } catch { /* first install */ }
105
+ const artifacts=(prior?.artifacts ?? []).filter(item=>item.name!==entry.name); artifacts.push({...entry,catalog:{tag:catalog.tag,commit:catalog.commit}}); artifacts.sort((a,b)=>a.name.localeCompare(b.name)); const lock ={format:1,artifacts}; await writeLock(project,lock); return lock;
106
+ }
107
+ export async function inspectArtifacts(project ) { const lock=await readLock(project), cached =[], missing =[], invalid =[]; for(const item of lock.artifacts) { const root=cachePath(project,item.sha256); try { await validateCached(root,item); cached.push(item.name); } catch { try { await lstat(root); invalid.push(item.name); } catch { missing.push(item.name); } } } return {lock,cached,missing,invalid}; }
108
+
109
+ /** Read-only inventory for authoring tools. Paths come from the verified archive, never from an arbitrary filesystem argument. */
110
+ export async function describeArtifactCache(project ) {
111
+ const report=await inspectArtifacts(project), cached=new Set(report.cached), missing=new Set(report.missing);
112
+ const artifacts=[];
113
+ for(const item of report.lock.artifacts) {
114
+ const status =cached.has(item.name)?'cached':missing.has(item.name)?'missing':'invalid';
115
+ let files =[];
116
+ if(status==='cached') {
117
+ const archive=await readFile(join(cachePath(project,item.sha256),'.artifact.tgz'));
118
+ assert(digest(archive)===item.sha256,`Cached extension artifact ${item.name} does not match its lockfile`);
119
+ const members=readTgz(archive); validateFiles(members,item); files=members.map(file=>file.path).sort();
120
+ }
121
+ artifacts.push({...item,status,files});
122
+ }
123
+ return {format:1,artifacts};
124
+ }
125
+
126
+ /** Return one bounded text/JSON member from a verified cached artifact for MCP/agent consumers. */
127
+ export async function readArtifactMember(project ,artifactName ,path ) {
128
+ assert(name.test(artifactName),'Invalid extension artifact name');
129
+ const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
130
+ assert(allowed.test(path),'Invalid extension artifact member path');
131
+ const lock=await readLock(project), artifact=lock.artifacts.find(item=>item.name===artifactName);
132
+ assert(artifact,`Extension artifact ${artifactName} is not locked`);
133
+ const root=cachePath(project,artifact.sha256); await validateCached(root,artifact);
134
+ const archive=await readFile(join(root,'.artifact.tgz'));
135
+ assert(digest(archive)===artifact.sha256,`Cached extension artifact ${artifact.name} does not match its lockfile`);
136
+ const files=readTgz(archive); validateFiles(files,artifact); const member=files.find(file=>file.path===path);
137
+ assert(member,`Extension artifact ${artifactName} has no ${path}`); assert(member.bytes.byteLength<=MAX_TOOL_FILE,'Extension artifact member exceeds the tooling output limit');
138
+ let textValue ; try { textValue=new TextDecoder('utf-8',{fatal:true}).decode(member.bytes); } catch { throw new ConfigError(`Extension artifact ${path} is not UTF-8 text`); }
139
+ const json=path.endsWith('.json'); return {format:1,artifact,path,mediaType:json?'application/json':'text/markdown',content:json?JSON.parse(textValue):textValue};
140
+ }
package/dist/index.js CHANGED
@@ -21,8 +21,8 @@ export {buildTypeScriptProject} from './typescript-authoring.js';
21
21
 
22
22
  export {importBulkProject} from './bulk.js';
23
23
 
24
- export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens} from './tooling.js';
25
-
24
+ export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks} from './tooling.js';
25
+
26
26
  export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
27
27
 
28
28
  export {serveMcp} from './mcp.js';
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.4.8'}}});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;}