@jimhoyd/urlcode 0.4.2 → 0.4.6

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 (214) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +57 -14
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +19 -15
  4. package/SECURITY.md +5 -3
  5. package/dist/BUILD-MANIFEST.json +30 -27
  6. package/dist/agent-context.js +82 -0
  7. package/dist/agents-guide.js +42 -42
  8. package/dist/authoring.js +12 -2
  9. package/dist/body-schema.js +159 -0
  10. package/dist/build-cloudflare.js +2 -0
  11. package/dist/capabilities.js +1 -1
  12. package/dist/cli.js +28 -12
  13. package/dist/config.js +74 -6
  14. package/dist/context.js +4 -6
  15. package/dist/errors.js +3 -1
  16. package/dist/examples.js +1 -1
  17. package/dist/extensions.js +60 -2
  18. package/dist/http-policy.js +19 -4
  19. package/dist/http-response.js +2 -2
  20. package/dist/init-with.js +71 -9
  21. package/dist/mcp.js +15 -2
  22. package/dist/pattern-guard.js +32 -0
  23. package/dist/policies/security.js +0 -0
  24. package/dist/project-tests.js +35 -11
  25. package/dist/readiness.js +184 -32
  26. package/dist/recipes.js +1 -1
  27. package/dist/router.js +17 -0
  28. package/dist/runtime.js +26 -3
  29. package/dist/scaffold.js +0 -0
  30. package/dist/server.js +26 -3
  31. package/dist/site.js +0 -0
  32. package/dist/tooling.js +2 -1
  33. package/dist/types/agent-context.d.ts +44 -0
  34. package/dist/types/authoring.d.ts +3 -1
  35. package/dist/types/body-schema.d.ts +58 -0
  36. package/dist/types/config.d.ts +10 -2
  37. package/dist/types/context.d.ts +1 -1
  38. package/dist/types/errors.d.ts +9 -1
  39. package/dist/types/examples.d.ts +1 -1
  40. package/dist/types/extensions.d.ts +47 -2
  41. package/dist/types/http-policy.d.ts +3 -0
  42. package/dist/types/init-with.d.ts +10 -1
  43. package/dist/types/pattern-guard.d.ts +10 -0
  44. package/dist/types/project-tests.d.ts +9 -0
  45. package/dist/types/readiness.d.ts +68 -0
  46. package/dist/types/recipes.d.ts +1 -1
  47. package/dist/types/runtime.d.ts +4 -0
  48. package/dist/types/server.d.ts +9 -1
  49. package/dist/types/tooling.d.ts +1 -0
  50. package/dist/types/types.d.ts +13 -0
  51. package/dist/types.js +10 -2
  52. package/dist/typescript-authoring.js +5 -3
  53. package/dist/verify-deployment.js +16 -10
  54. package/examples/body-validation/README.md +16 -0
  55. package/examples/body-validation/example.yaml +17 -0
  56. package/examples/body-validation/tests/requests.json +15 -0
  57. package/examples/body-validation/urlcode.yaml +34 -0
  58. package/examples/coverage-waiver/README.md +8 -0
  59. package/examples/coverage-waiver/example.yaml +16 -0
  60. package/examples/coverage-waiver/functions/notes.mjs +2 -0
  61. package/examples/coverage-waiver/tests/requests.json +3 -0
  62. package/examples/coverage-waiver/urlcode.yaml +10 -0
  63. package/examples/data-dir/README.md +39 -0
  64. package/examples/data-dir/data/welcome.txt +1 -0
  65. package/examples/data-dir/example.yaml +22 -0
  66. package/examples/data-dir/functions/note.mjs +18 -0
  67. package/examples/data-dir/tests/requests.json +6 -0
  68. package/examples/data-dir/urlcode.yaml +6 -0
  69. package/examples/lifecycle/README.md +15 -0
  70. package/examples/lifecycle/example.yaml +19 -0
  71. package/examples/lifecycle/functions/notes.mjs +29 -0
  72. package/examples/lifecycle/tests/requests.json +12 -0
  73. package/examples/lifecycle/urlcode.yaml +29 -0
  74. package/examples/not-found/README.md +10 -0
  75. package/examples/not-found/example.yaml +17 -0
  76. package/examples/not-found/public/404.html +3 -0
  77. package/examples/not-found/public/index.html +3 -0
  78. package/examples/not-found/tests/requests.json +7 -0
  79. package/examples/not-found/urlcode.yaml +7 -0
  80. package/examples/shared-blocks/README.md +11 -0
  81. package/examples/shared-blocks/example.yaml +17 -0
  82. package/examples/shared-blocks/tests/requests.json +7 -0
  83. package/examples/shared-blocks/urlcode.yaml +37 -0
  84. package/llms-full.txt +364 -49
  85. package/llms.txt +43 -5
  86. package/package.json +15 -22
  87. package/recipes/static-page/README.md +9 -0
  88. package/recipes/static-page/public/index.html +11 -0
  89. package/recipes/static-page/recipe.yaml +21 -0
  90. package/recipes/static-page/tests/requests.json +22 -0
  91. package/recipes/static-page/urlcode.yaml +7 -0
  92. package/recipes/static-plus-api/README.md +6 -0
  93. package/recipes/static-plus-api/urlcode.yaml +4 -0
  94. package/recipes/store-crud/README.md +53 -0
  95. package/recipes/store-crud/recipe.yaml +31 -0
  96. package/recipes/store-crud/tests/requests.json +18 -0
  97. package/recipes/store-crud/urlcode.yaml +18 -0
  98. package/schemas/urlcode.schema.json +125 -60
  99. package/skills/urlcode/SKILL.md +47 -27
  100. package/starters/default/AGENTS.md +43 -43
  101. package/starters/page/README.md +14 -0
  102. package/starters/page/public/index.html +12 -0
  103. package/starters/page/tests/requests.json +17 -0
  104. package/starters/page/urlcode.yaml +6 -0
  105. package/.claude-plugin/marketplace.json +0 -18
  106. package/CONTRIBUTING.md +0 -148
  107. package/ROADMAP.md +0 -70
  108. package/docs/AI-AUTHORING.md +0 -339
  109. package/docs/ASSETS.md +0 -107
  110. package/docs/AUTH-BACKUP.md +0 -32
  111. package/docs/AWS.md +0 -86
  112. package/docs/BEST-PRACTICES.md +0 -276
  113. package/docs/BULK.md +0 -79
  114. package/docs/CAPABILITIES.md +0 -192
  115. package/docs/CAPACITY.md +0 -305
  116. package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
  117. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
  118. package/docs/CI.md +0 -147
  119. package/docs/CLOUDFLARE.md +0 -109
  120. package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
  121. package/docs/COMPLIANCE.md +0 -239
  122. package/docs/COMPOSING-A-SITE.md +0 -287
  123. package/docs/CONDITIONS.md +0 -74
  124. package/docs/CONTAINER-PROMOTION.md +0 -74
  125. package/docs/DEPLOYMENT-CHECKS.md +0 -108
  126. package/docs/DEVELOPMENT-PIPELINE.md +0 -304
  127. package/docs/EGRESS.md +0 -125
  128. package/docs/EXTENSIONS.md +0 -403
  129. package/docs/FRAMEWORK.md +0 -217
  130. package/docs/FUNCTION-SECURITY.md +0 -251
  131. package/docs/HTTP.md +0 -129
  132. package/docs/INSTALL.md +0 -128
  133. package/docs/INTERCHANGE.md +0 -134
  134. package/docs/LOAD-TESTING.md +0 -91
  135. package/docs/LOCAL-DEVELOPMENT.md +0 -102
  136. package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
  137. package/docs/MIDDLEWARE.md +0 -102
  138. package/docs/MONITORING.md +0 -115
  139. package/docs/OBSERVABILITY.md +0 -222
  140. package/docs/OPEN-DECISIONS.md +0 -149
  141. package/docs/OPERATIONAL-PROOF.md +0 -41
  142. package/docs/OPERATIONS.md +0 -201
  143. package/docs/ORGANIZATION.md +0 -135
  144. package/docs/PERFORMANCE.md +0 -72
  145. package/docs/PLUGINS.md +0 -271
  146. package/docs/POLICIES.md +0 -211
  147. package/docs/PRERENDER.md +0 -245
  148. package/docs/PROJECT-DIRECTION.md +0 -118
  149. package/docs/PROVIDER-VERIFICATION.md +0 -84
  150. package/docs/READINESS.md +0 -153
  151. package/docs/README.md +0 -93
  152. package/docs/RECIPES.md +0 -99
  153. package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
  154. package/docs/RELEASE-0.4.1.md +0 -73
  155. package/docs/RELEASE-0.4.2.md +0 -30
  156. package/docs/RELEASE-READINESS.md +0 -146
  157. package/docs/RELEASE-SECURITY.md +0 -100
  158. package/docs/RESILIENCE.md +0 -161
  159. package/docs/ROUTING.md +0 -92
  160. package/docs/SANDBOX-REVIEW.md +0 -72
  161. package/docs/SCAFFOLDING.md +0 -70
  162. package/docs/SECURITY-AUDIT.md +0 -164
  163. package/docs/SITE.md +0 -150
  164. package/docs/SPECIFICATION.md +0 -359
  165. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
  166. package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
  167. package/docs/SPIKE-CORE-LAYERING.md +0 -368
  168. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -207
  169. package/docs/STANDARDS.md +0 -311
  170. package/docs/STARTERS.md +0 -83
  171. package/docs/STATIC.md +0 -105
  172. package/docs/TOOLING.md +0 -298
  173. package/docs/TUNNELS.md +0 -72
  174. package/docs/TYPESCRIPT-AUTHORING.md +0 -87
  175. package/docs/TYPESCRIPT.md +0 -123
  176. package/docs/VERCEL.md +0 -114
  177. package/docs/VERSION-ALIGNMENT.md +0 -88
  178. package/docs/YAML-GUIDE.md +0 -57
  179. package/docs/YAML-REFERENCE.md +0 -449
  180. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
  181. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
  182. package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
  183. package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
  184. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
  185. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
  186. package/docs/archive/2026-09-19/ROADMAP.md +0 -387
  187. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
  188. package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
  189. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
  190. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
  191. package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
  192. package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +0 -116
  193. package/docs/archive/README.md +0 -29
  194. package/docs/policies/agents.md +0 -182
  195. package/docs/policies/cache.md +0 -152
  196. package/docs/policies/compression.md +0 -169
  197. package/docs/policies/contract.md +0 -52
  198. package/docs/policies/hardened.md +0 -56
  199. package/docs/policies/interoperability.md +0 -169
  200. package/docs/policies/operations.md +0 -45
  201. package/docs/policies/security.md +0 -161
  202. package/docs/policies/throttle.md +0 -103
  203. package/docs/yaml/assets.md +0 -36
  204. package/docs/yaml/conditions.md +0 -20
  205. package/docs/yaml/functions.md +0 -168
  206. package/docs/yaml/middleware.md +0 -31
  207. package/docs/yaml/organization.md +0 -74
  208. package/docs/yaml/policies.md +0 -37
  209. package/docs/yaml/redirects.md +0 -64
  210. package/docs/yaml/responses.md +0 -57
  211. package/docs/yaml/site.md +0 -24
  212. package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
  213. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -130
  214. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
@@ -2,9 +2,11 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
2
2
  import { assert, HttpError } from './errors.js';
3
3
 
4
4
 
5
+ import { assertBodySchema, bodySchemaIssues, bodySchemaLine, bodySchemaJson, prefersJson } from './body-schema.js';
6
+
5
7
 
6
8
 
7
-
9
+
8
10
  /** A static reply compiled from `respond`; the body is bytes so every host, including the Worker, shares the type. */
9
11
 
10
12
  /** The declared HTTP surface of a route: response headers, request body policy and a static reply. */
@@ -18,14 +20,14 @@ import { assert, HttpError } from './errors.js';
18
20
  const encoder = new TextEncoder();
19
21
  const byteLength = (value ) => encoder.encode(value).length;
20
22
 
21
- const reserved = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
23
+ export const reservedResponseHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
22
24
  export function compileHttp(route ) {
23
25
  const seen = new Set (); let size = 0;
24
26
  const responseHeaders = route.responseHeaders = [];
25
27
  for (const [name,value] of Object.entries(route.response?.headers || {})) {
26
28
  const key = name.toLowerCase();
27
29
  assert(!seen.has(key), 'Duplicate response header (case insensitive)'); seen.add(key);
28
- assert(!reserved.has(key), 'Response header is owned by the runtime or handler');
30
+ assert(!reservedResponseHeaders.has(key), 'Response header is owned by the runtime or handler');
29
31
  assert(!Array.isArray(value) || key === 'set-cookie', 'Only Set-Cookie supports a header array');
30
32
  assert(!(route.page || route.static || route.download) || !['content-type','content-disposition','cache-control'].includes(key), 'Configure asset metadata on its handler');
31
33
  for (const item of Array.isArray(value) ? value : [value]) {
@@ -36,6 +38,11 @@ export function compileHttp(route ) {
36
38
  }
37
39
  }
38
40
  assert(size <= 16384, 'Response headers exceed 16 KiB');
41
+ const bodySchema = route.request?.body?.schema;
42
+ if (bodySchema !== undefined) {
43
+ assert(route.request?.body?.format === 'json', 'request.body.schema requires format json');
44
+ assertBodySchema(bodySchema);
45
+ }
39
46
  if (route.respond) {
40
47
  const status = route.respond.status ?? 200;
41
48
  assert(![206,304].includes(status), 'Use native asset handlers for partial/conditional responses');
@@ -61,7 +68,15 @@ export function checkRequest(route , body , headers
61
68
  try { text = new TextDecoder('utf-8',{fatal:true}).decode(body); } catch { throw new HttpError(400,'Body must be UTF-8'); }
62
69
  if (policy.format === 'json') {
63
70
  if (!/^application\/(?:[\w.+-]+\+)?json$/.test(type)) throw new HttpError(415,'Expected JSON media type');
64
- try { JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
71
+ let parsed ;
72
+ try { parsed = JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
73
+ if (policy.schema) {
74
+ const issues = bodySchemaIssues(policy.schema, parsed);
75
+ if (issues.length) {
76
+ const text = `Request body failed validation\n${issues.map(bodySchemaLine).join('\n')}`;
77
+ throw new HttpError(422, text, prefersJson(headers.get('accept')) ? { contentType: 'application/json', text: bodySchemaJson(issues) } : undefined);
78
+ }
79
+ }
65
80
  }
66
81
  }
67
82
  }
@@ -61,13 +61,13 @@ export function writeResponse(res , result , option
61
61
  // or sniffed whatever a project declares.
62
62
  export function errorResponse(error , { requestId, method, headers = [] } ) {
63
63
  const status = error instanceof HttpError ? error.status : 500;
64
- const fixed = [['content-type','text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
64
+ const fixed = [['content-type',error instanceof HttpError && error.answer ? error.answer.contentType : 'text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
65
65
  const taken = new Set(fixed.map(([key]) => key));
66
66
  const extra = headers.filter(([key]) => !taken.has(key.toLowerCase()) && !forbiddenHeaders.has(key.toLowerCase()));
67
67
  // Runtime error messages are fixed words, and the answer is text/plain
68
68
  // with nosniff; markup characters are still stripped so the body can never
69
69
  // be read as HTML by a client that ignores both.
70
- const text = `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
70
+ const text = error instanceof HttpError && error.answer ? error.answer.text + '\n' : `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
71
71
  const body = method === 'HEAD' ? undefined : text;
72
72
  // Stated explicitly so every host agrees, as prepareResponse does for results.
73
73
  return { status, headers: [...fixed, ['content-length', String(new TextEncoder().encode(text).length)], ...extra], body };
package/dist/init-with.js CHANGED
@@ -14,13 +14,16 @@ import { ConfigError, assert } from './errors.js';
14
14
 
15
15
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
16
16
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
17
- const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
17
+ const acknowledgementPattern = /^[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$/;
18
+ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9.:-]{0,63}$/;
18
19
 
19
20
 
20
21
 
21
22
 
22
23
 
23
24
 
25
+
26
+
24
27
 
25
28
 
26
29
 
@@ -35,12 +38,48 @@ const isCode = (error , code ) => error instanceof Error
35
38
  const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
36
39
  const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
37
40
 
41
+ /**
42
+ * Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
43
+ * the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
44
+ * deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
45
+ */
46
+ export function orderScaffolds(results ) {
47
+ const byName = new Map (), providers = new Map ();
48
+ for (const result of results) byName.set(result.name, result);
49
+ for (const result of results) for (const capability of result.provides ?? []) {
50
+ assert(!byName.has(capability) || capability === result.name, `${result.name} provides ${capability}, which is also an extension name`);
51
+ assert(!providers.has(capability) || providers.get(capability) === result.name, `Capability ${capability} is provided by both ${providers.get(capability)} and ${result.name}`);
52
+ providers.set(capability, result.name);
53
+ }
54
+ const locate = (dependency ) => byName.has(dependency) ? dependency : providers.get(dependency);
55
+ const edges = new Map (results.map(result => [result.name, new Set ()]));
56
+ for (const result of results) {
57
+ for (const other of result.conflicts ?? []) { const target = locate(other); assert(target === undefined || target === result.name, `${result.name} conflicts with ${target}; remove one from --with`); }
58
+ for (const dependency of result.requires ?? []) {
59
+ const target = locate(dependency);
60
+ assert(target !== undefined, `${result.name} requires ${dependency}${byName.has(dependency) ? '' : ', which is not part of this composition; add the extension that provides it to --with'}`);
61
+ if (target !== result.name) edges.get(result.name) .add(target);
62
+ }
63
+ for (const dependency of result.after ?? []) { const target = locate(dependency); if (target !== undefined && target !== result.name) edges.get(result.name) .add(target); }
64
+ }
65
+ const ordered = [], placed = new Set ();
66
+ while (ordered.length < results.length) {
67
+ const ready = [...byName.keys()].filter(name => !placed.has(name) && [...edges.get(name) ].every(dependency => placed.has(dependency))).sort();
68
+ if (ready.length === 0) {
69
+ const stuck = [...byName.keys()].filter(name => !placed.has(name)).sort();
70
+ throw new ConfigError(`Extension ordering has a cycle among ${stuck.map(name => `${name} (needs ${[...edges.get(name) ].filter(dependency => !placed.has(dependency)).sort().join(', ')})`).join('; ')}`);
71
+ }
72
+ placed.add(ready[0] ); ordered.push(byName.get(ready[0] ) );
73
+ }
74
+ return ordered;
75
+ }
76
+
38
77
  /**
39
78
  * Resolves the extension package from the invoking directory (Node's package resolution with the default
40
79
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
41
80
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
42
81
  */
43
- async function loadScaffold(name , request , cwd ) {
82
+ async function loadScaffold(name , request , cwd , retry ) {
44
83
  const pkg = packageName(name);
45
84
  let entry ;
46
85
  try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
@@ -53,11 +92,20 @@ async function loadScaffold(name , request , cwd )
53
92
  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`);
54
93
  let result ;
55
94
  try { result = await (scaffold )(request); }
56
- catch (error) { throw new ConfigError(`${pkg} scaffold refused: ${error instanceof Error ? error.message : String(error)}`); }
95
+ catch (error) {
96
+ // A refusal that names an acknowledgement id gets the exact command that would proceed; the extension owns the id and the risk wording, core only formats the retry.
97
+ const id = record(error) ? error.acknowledgement : undefined;
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ if (typeof id === 'string' && acknowledgementPattern.test(id) && id.startsWith(`${name}:`) && !request.acknowledgements.includes(id)) throw new ConfigError(`${pkg} scaffold refused: ${message}. If you accept that risk, re-run with the acknowledgement: ${retry(id)}`);
100
+ throw new ConfigError(`${pkg} scaffold refused: ${message}`);
101
+ }
57
102
  assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
58
103
  assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
59
104
  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`);
105
+ 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
+ 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`);
60
107
  assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
108
+ for (const key of ['provides', 'requires', 'after', 'conflicts'] ) assert(result[key] === undefined || (strings(result[key]) && (result[key] ).every(item => capabilityPattern.test(item))), `${pkg} scaffold ${key} must list extension names or capability names`);
61
109
  assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
62
110
  assert(Array.isArray(result.files) && result.files.every((file ) => record(file) && typeof file.path === 'string' && (typeof file.content === 'string' || file.content instanceof Uint8Array) && (file.mode === undefined || (Number.isInteger(file.mode) && (file.mode ) >= 0 && (file.mode ) <= 0o777))), `${pkg} scaffold files must carry a path, content and an optional mode`);
63
111
  return result ;
@@ -78,7 +126,8 @@ async function write(target , content , mode = 0o644)
78
126
  }
79
127
  function renderHost(names , results ) {
80
128
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
81
- for (const result of results) lines.push(...result.hostImports);
129
+ // 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
+ for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
82
131
  lines.push('');
83
132
  for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
84
133
  lines.push('export default {', ' extensions: [');
@@ -121,14 +170,26 @@ function renderReadme(directory , names , results
121
170
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
122
171
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
123
172
  */
124
- export async function initProjectWith(destination , names , { cwd = process.cwd(), manifest = true, pins } = {}) {
125
- assert(names.length > 0, 'Provide at least one --with name');
173
+ export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
174
+ assert(requested.length > 0, 'Provide at least one --with name');
175
+ assert(new Set(requested).size === requested.length, 'Duplicate --with names');
176
+ // --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
177
+ const sorted = [...requested].sort();
126
178
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
127
- const request = { directory, project, hostFile, names };
179
+ assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
180
+ const acked = [...new Set(acknowledgements)].sort();
181
+ const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
182
+ 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(' ');
128
184
  const results = [];
129
185
  const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
130
186
  try {
131
- for (const name of names) results.push(await loadScaffold(name, request, cwd));
187
+ for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
188
+ const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
189
+ const unused = acked.filter(id => !consumed.has(id));
190
+ 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`);
191
+ results.splice(0, results.length, ...orderScaffolds(results));
192
+ const names = results.map(result => result.name);
132
193
  // Cross-result conflicts are refused before the destination exists.
133
194
  const extensions = Object.create(null) , routes = Object.create(null) , env = {};
134
195
  const owners = new Map ();
@@ -161,7 +222,8 @@ export async function initProjectWith(destination , names
161
222
  doc.addIn(['includes'], ROUTES_FILE);
162
223
  const fragment = stringify({ version: '1', routes });
163
224
  validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
164
- await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${fragment}`);
225
+ const notes = results.flatMap(result => (result.routeNotes ?? []).map(note => `# ${result.name}: ${note}\n`)).join('');
226
+ await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${notes}${fragment}`);
165
227
  await rm(yamlFile); await write(yamlFile, String(doc));
166
228
  await loadDocument(project);
167
229
  const projectSha256 = await inspectExtensionRevision(project);
package/dist/mcp.js CHANGED
@@ -7,6 +7,7 @@ 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
+ import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
10
11
  const protocolVersion='2025-11-25';
11
12
  const maxBytes=1048576;
12
13
  const text={type:'string',maxLength:8192};
@@ -25,10 +26,16 @@ const definitions=[
25
26
  {name:'recipes_show',description:'Show a bundled local recipe without writing it; metadata (capabilities, targets, grants, inputs, expected behavior) comes before file contents.',properties:{name:{type:'string',maxLength:64}},required:['name']},
26
27
  {name:'search_recipes',description:'Search bundled recipes by id, description, tags and capabilities; local text matching, no service. Check here before generating a common route by hand.',properties:{text:{type:'string',maxLength:256}},required:['text']},
27
28
  {name:'search_examples',description:'Search bundled runnable examples and the cookbook route index; returns the smallest matching example and its route.',properties:{text:{type:'string',maxLength:256}},required:['text']},
29
+ {name:'list_skills',description:'List compact metadata for the bundled agent skills. Load a skill only when it applies.',properties:{}},
30
+ {name:'get_skill',description:'Load one bundled agent SKILL.md by name.',properties:{name:{type:'string',maxLength:64}},required:['name']},
31
+ {name:'search_docs',description:'Deterministically search the small packaged agent documentation corpus and return at most three short excerpts.',properties:{text:{type:'string',maxLength:256}},required:['text']},
32
+ {name:'get_example',description:'Return the README and urlcode.yaml from one bundled runnable example.',properties:{name:{type:'string',maxLength:64}},required:['name']},
33
+ {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
+ {name:'explain_error',description:'Give deterministic next-step guidance for supplied URLCode validation output.',properties:{error:{type:'string',maxLength:8192}},required:['error']},
28
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}}},
29
36
  ];
30
37
  // Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
31
- const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts with configuration and policy JSON Schemas and where the project mounts them; activates nothing.',properties:{}};
38
+ 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:{}};
32
39
  const ajv=new Ajv({strict:false});
33
40
  const readTools=definitions.map(def=>({name:def.name,description:def.description,inputSchema:{type:'object',properties:def.properties,required:def.required??[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}}));
34
41
  const hostTool={name:hostDefinition.name,description:hostDefinition.description,inputSchema:{type:'object',properties:hostDefinition.properties,required:[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}};
@@ -63,6 +70,12 @@ export async function serveMcp(options ) {
63
70
  case 'recipes_show':return showRecipe(args.name );
64
71
  case 'search_recipes':return searchRecipes(args.text );
65
72
  case 'search_examples':return searchExamples(args.text );
73
+ case 'list_skills':return listSkills();
74
+ case 'get_skill':return getSkill(args.name );
75
+ case 'search_docs':return searchDocs(args.text );
76
+ case 'get_example':return getExample(args.name );
77
+ case 'validate_yaml':return validateYaml(args.yaml );
78
+ case 'explain_error':return explainError(args.error );
66
79
  case 'get_extensions':return describeExtensions(project,host.extensions??[]);
67
80
  case 'get_context':return buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
68
81
  default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
@@ -78,7 +91,7 @@ export async function serveMcp(options ) {
78
91
  if(message.method==='initialize') {
79
92
  if(initialized){await error(id,-32600,'Already initialized');return;}
80
93
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
81
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.2'}}});return;
94
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.6'}}});return;
82
95
  }
83
96
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
84
97
  if(!ready){await error(id,-32002,'Initialize first');return;}
@@ -0,0 +1,32 @@
1
+ import { assert } from './errors.js';
2
+
3
+ export const maxPatternLength = 128;
4
+ export const maxPatternInputLength = 128;
5
+ const maxUnboundedQuantifiers = 3;
6
+
7
+ /**
8
+ * Accepts an author regex only when it is conservatively safe to run on every
9
+ * request in the host process. Node has no linear-time engine, so this refuses
10
+ * the constructs that make backtracking blow up — repeated groups, lookaround
11
+ * and backreferences — and caps unbounded quantifiers. It is a restriction, not
12
+ * a proof: callers must also bound the input length to `maxPatternInputLength`.
13
+ */
14
+ export function assertSafePattern(pattern ) {
15
+ assert(pattern.length > 0 && pattern.length <= maxPatternLength, `Pattern must be 1 to ${maxPatternLength} characters`);
16
+ try { new RegExp(pattern, 'u'); } catch { assert(false, 'Invalid pattern'); }
17
+ let unbounded = 0, inClass = false;
18
+ for (let i = 0; i < pattern.length; i++) {
19
+ const char = pattern[i] ;
20
+ if (char === '\\') {
21
+ const next = pattern[i + 1] ?? '';
22
+ assert(inClass || !/[1-9k]/.test(next), 'Pattern backreferences are not supported');
23
+ i++; continue;
24
+ }
25
+ if (inClass) { if (char === ']') inClass = false; continue; }
26
+ if (char === '[') { inClass = true; continue; }
27
+ if (char === '(') assert(!/^\(\?<?[=!]/.test(pattern.slice(i, i + 4)), 'Pattern lookaround is not supported');
28
+ if (char === ')') assert(!/^(?:[*+]|\{\d+,\})/.test(pattern.slice(i + 1)), 'Pattern cannot repeat a group without a bound');
29
+ if (char === '*' || char === '+' || (char === '{' && /^\{\d+,\}/.test(pattern.slice(i)))) unbounded++;
30
+ }
31
+ assert(unbounded <= maxUnboundedQuantifiers, `Pattern allows at most ${maxUnboundedQuantifiers} unbounded quantifiers`);
32
+ }
Binary file
@@ -1,23 +1,47 @@
1
- import { realpath } from 'node:fs/promises';
1
+ import { mkdtemp, realpath, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import { Agent } from 'node:http';
3
5
  import { startServer } from './server.js';
4
-
5
- import { readCases, hit } from './readiness.js';
6
+
7
+ import { readFixtures, runFixtures } from './readiness.js';
8
+
6
9
 
7
10
 
8
11
 
9
12
 
10
13
 
14
+ /**
15
+ * A server that fixture `restart` steps can close and start again on the same project and the
16
+ * same data directory. The data directory is fresh and empty, offered to the project as
17
+ * `URLCODE_DATA_DIR`, and removed by `close()`; on a filesystem where it cannot be created, none is offered. `restart()` gets a new port; read `address` after it.
18
+ */
19
+ export async function startRestartable(options ) {
20
+ // A read-only filesystem (a locked-down container) has nowhere to put it: run without one instead of
21
+ // failing every test run. A project that reads `URLCODE_DATA_DIR` then refuses to activate, as it would unset.
22
+ const dataDir = await mkdtemp(join(tmpdir(), 'urlcode-data-')).catch(() => undefined);
23
+ const cleanup = async () => { if (dataDir !== undefined) await rm(dataDir, { recursive: true, force: true }); };
24
+ let current ;
25
+ try { current = await startServer({ ...options, dataDir }); } catch (error) { await cleanup(); throw error; }
26
+ const running = () => { if (!current) throw new Error('Server is not running'); return current; };
27
+ return {
28
+ get address() { return running().address; }, get root() { return running().root; }, testPlan: () => running().testPlan(),
29
+ async restart() { const old = running(); current = undefined; await old.close(); current = await startServer({ ...options, dataDir }); },
30
+ async close() { try { await current?.close(); } finally { current = undefined; await cleanup(); } },
31
+ };
32
+ }
33
+
11
34
  export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
12
- const root = await realpath(project), cases = await readCases(root);
13
- const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
14
- const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
35
+ const root = await realpath(project), fixtures = await readFixtures(root);
36
+ const app = await startRestartable({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
37
+ const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0, total = 0;
15
38
  try {
16
- for (const [i,test] of cases.entries()) {
17
- const result=await hit(app,test,agent);
39
+ // Only case number, pass and status are logged: never a path, header or body, which may hold captured values.
40
+ await runFixtures(fixtures, { app, agent, restart: () => app.restart() }, ({ case: n, result }) => {
41
+ total++;
18
42
  if(!result.pass)failed++;
19
- log({event:'test',case:i+1,pass:result.pass,status:result.status});
20
- }
43
+ log({event:'test',case:n,pass:result.pass,status:result.status});
44
+ });
21
45
  } finally {agent.destroy();await app.close();}
22
- return {total:cases.length,failed};
46
+ return {total,failed};
23
47
  }