@jimhoyd/urlcode 0.4.8 → 0.5.5

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 (58) hide show
  1. package/README.md +18 -18
  2. package/dist/BUILD-MANIFEST.json +27 -23
  3. package/dist/agents-guide.js +8 -4
  4. package/dist/authoring.js +81 -7
  5. package/dist/build-cloudflare.js +1 -0
  6. package/dist/build-static.js +1 -0
  7. package/dist/cli.js +39 -14
  8. package/dist/config.js +7 -1
  9. package/dist/context.js +32 -4
  10. package/dist/ecosystem-cli.js +6 -0
  11. package/dist/explain-cli.js +1 -1
  12. package/dist/explain.js +2 -2
  13. package/dist/extension-artifacts.js +28 -34
  14. package/dist/extension-bundles.js +62 -0
  15. package/dist/extension-transport.js +41 -0
  16. package/dist/extensions.js +4 -0
  17. package/dist/feature-plan.js +99 -0
  18. package/dist/functions.js +2 -1
  19. package/dist/index.js +4 -2
  20. package/dist/init-with.js +55 -23
  21. package/dist/interchange.js +1 -1
  22. package/dist/match.js +23 -5
  23. package/dist/mcp.js +6 -2
  24. package/dist/readiness.js +2 -2
  25. package/dist/review.js +206 -0
  26. package/dist/router.js +35 -10
  27. package/dist/runtime.js +1 -0
  28. package/dist/tooling.js +4 -0
  29. package/dist/types/agents-guide.d.ts +6 -1
  30. package/dist/types/authoring.d.ts +3 -1
  31. package/dist/types/context.d.ts +12 -0
  32. package/dist/types/explain.d.ts +3 -0
  33. package/dist/types/extension-artifacts.d.ts +15 -4
  34. package/dist/types/extension-bundles.d.ts +49 -0
  35. package/dist/types/extension-transport.d.ts +31 -0
  36. package/dist/types/extensions.d.ts +4 -0
  37. package/dist/types/feature-plan.d.ts +67 -0
  38. package/dist/types/functions.d.ts +4 -0
  39. package/dist/types/index.d.ts +4 -2
  40. package/dist/types/init-with.d.ts +6 -1
  41. package/dist/types/match.d.ts +1 -0
  42. package/dist/types/review.d.ts +30 -0
  43. package/dist/types/tooling.d.ts +4 -0
  44. package/dist/types/types.d.ts +10 -1
  45. package/dist/types.js +10 -3
  46. package/docs/AI-AUTHORING.md +466 -0
  47. package/docs/FUNCTION-SECURITY.md +251 -0
  48. package/docs/README.md +96 -0
  49. package/docs/TOOLING.md +422 -0
  50. package/docs/YAML-REFERENCE.md +473 -0
  51. package/llms-full.txt +190 -83
  52. package/llms.txt +73 -128
  53. package/package.json +15 -4
  54. package/recipes/redirect/README.md +2 -2
  55. package/recipes/store-crud/README.md +9 -10
  56. package/recipes/store-crud/recipe.yaml +1 -1
  57. package/schemas/urlcode.schema.json +3 -0
  58. package/starters/default/AGENTS.md +2 -2
package/README.md CHANGED
@@ -53,22 +53,19 @@ application by adding YAML; the operator wires trusted packages in one host
53
53
  file outside the project. The full map, the composition contract and the rules
54
54
  an AI agent must follow are in [the framework](docs/FRAMEWORK.md).
55
55
 
56
- | Package | Adds | Stable npm channel |
56
+ | Component | Adds | Supported distribution |
57
57
  |---|---|---|
58
- | [urlcode](https://github.com/jimhoyd-com/urlcode) (this repository) | Runtime, CLI, policies, provider adapters, extension contract | [![npm stable version](https://img.shields.io/npm/v/%40jimhoyd%2Furlcode?label=stable)](https://www.npmjs.com/package/@jimhoyd/urlcode) |
59
- | [urlcode-ui](packages/ui) (in this repository) | Shared presentation: escaped templates, shadcn/ui partials, themes, translations | [![npm stable version](https://img.shields.io/npm/v/%40jimhoyd%2Furlcode-ui?label=stable)](https://www.npmjs.com/package/@jimhoyd/urlcode-ui) |
60
- | [urlcode-auth](packages/auth) (in this repository) | Accounts: password, passkeys, OIDC, email codes, TOTP, sessions, roles, account page | [![npm stable version](https://img.shields.io/npm/v/%40jimhoyd%2Furlcode-auth?label=stable)](https://www.npmjs.com/package/@jimhoyd/urlcode-auth) |
61
- | [urlcode-admin](packages/admin) (in this repository) | Administration: users, sessions, roles, audit, approvals, cases, impersonation | [![npm stable version](https://img.shields.io/npm/v/%40jimhoyd%2Furlcode-admin?label=stable)](https://www.npmjs.com/package/@jimhoyd/urlcode-admin) |
62
-
63
- The badges report each package's live npm `latest` channel, which is the stable
64
- version of that package. Stability is independent: matching version numbers are
65
- not required. Releases created by the current [GitHub release
66
- publisher](https://github.com/jimhoyd-com/urlcode/releases) show the exact
67
- four-package combination tested together, its declared peer
68
- requirements and a copyable exact-version install command; the signed
69
- `train.json` asset is the machine-readable receipt. Peer ranges, channel rules
70
- and generated application pins are documented in [package and channel
71
- alignment](docs/VERSION-ALIGNMENT.md).
58
+ | [urlcode](https://github.com/jimhoyd-com/urlcode) (this repository) | Runtime, CLI, policies, provider adapters, extension contract | [npm](https://www.npmjs.com/package/@jimhoyd/urlcode), [GitHub Releases](https://github.com/jimhoyd-com/urlcode/releases), Homebrew |
59
+ | [urlcode-ui](packages/ui) (workspace source) | Shared presentation: escaped templates, shadcn/ui partials, themes, translations | signed `extension-bundles@v…` GitHub Release |
60
+ | [urlcode-auth](packages/auth) (workspace source) | Accounts: password, passkeys, OIDC, email codes, TOTP, sessions, roles, account page | signed `extension-bundles@v…` GitHub Release |
61
+ | [urlcode-admin](packages/admin) (workspace source) | Administration: users, sessions, roles, audit, approvals, cases, impersonation | signed `extension-bundles@v…` GitHub Release |
62
+
63
+ Core stays available from npm, GitHub Releases and Homebrew. New sites install
64
+ extensions from an immutable, attested `extension-bundles@v…` GitHub Release;
65
+ the generated manifest has no extension npm dependencies. The old extension
66
+ npm packages are deprecated migration artifacts, not the recommended install
67
+ path. The exact supported core and bundle releases are documented in [package
68
+ and channel alignment](docs/VERSION-ALIGNMENT.md).
72
69
 
73
70
  `urlcode-dynamic-link` and `urlcode-short` were published once as
74
71
  `0.1.0-alpha.1` and have since been retired: both were unpublished from npm and
@@ -110,9 +107,12 @@ it. Cross-repository acceptance is tracked in
110
107
  ## Status
111
108
 
112
109
  <!-- urlcode-current-version:start -->
113
- The `0.4.8` release line brings core, UI, auth and admin to matching stable
114
- versions. A stable version selects the npm `latest` channel; it does not close
115
- the review and deployment evidence gaps below. `0.4.0-alpha.1`
110
+ This checkout prepares the coordinated `0.5.5` core, UI, auth, admin and store
111
+ release. Package availability remains a live registry fact: use `npm run
112
+ release:status` or the release's signed `train.json` before selecting an
113
+ install combination. Independent package versioning remains enabled, and a
114
+ stable core version does not close the review and deployment evidence gaps
115
+ below. `0.4.0-alpha.1`
116
116
  added the extension contract, capabilities and provider conformance, strict
117
117
  redirect interchange, bulk import, recipes and search, TypeScript guest
118
118
  authoring, conditions, bounded proxy and signals, and the MCP read and
@@ -4,19 +4,19 @@
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": "8ad311348154a00b62598e7a93e21ebee125cfce67482f748b31f98e1c49efbf",
7
+ "dist/agents-guide.js": "421df50bcf258f23aba742148e53074512584a10ba35a6264e70d3eacc016e3e",
8
8
  "dist/assets.js": "0833b093d2fe457ce68281110dc07d47914992753607741c13b339d92c76cf5c",
9
9
  "dist/authoring-files.js": "ad32814f9ae9c549c6247396f982aa700d13a293cc0a589609eccc68f8965550",
10
- "dist/authoring.js": "bb3d4e7c6c982c4a1985a7663f960d05a08459d935e2609d404f4001b7ea767f",
10
+ "dist/authoring.js": "a52d289c67043cad170babb6adab7ca23be1d7c7e5a67e43eaadfa73d2fe7665",
11
11
  "dist/aws.js": "7d8e3a97b4f6dbefbd68da058f05192469d1efcaed5458b284863ebe462e060e",
12
12
  "dist/body-schema.js": "0227ef8cf2b380e3ef65a358533aa0e0f4a119c94e63747b8eb03fe65a6b9193",
13
- "dist/build-cloudflare.js": "ea25c36b99f9bd7d7df0272e0bd104b426c169b3717ed689eb37e813daff93ea",
14
- "dist/build-static.js": "fe01ef4fcc66d83d8be7a787538b55974e84fd3d7c89e26f0a2f1a152762d565",
13
+ "dist/build-cloudflare.js": "ff92e72d0a63d70dd8c28b52978edd9fb23cb16601e4dfebd07c2cef7bd73661",
14
+ "dist/build-static.js": "3920b54bad9a44c75de79f0bef2b2a919bfb3e20a8619eaf8d48cad2856978ac",
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": "b15a87501d2d73073b33e880934a972acc0a1c54763cf3ae3ea3791ad5cf603c",
19
+ "dist/cli.js": "b8ff5c19dd2e1619ad4ba7de6eb811770f38e0b394f2bcded156d388d39c8f0b",
20
20
  "dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
21
21
  "dist/cloudflare.js": "31da478a6b851b5923c14dbd11885684cecec51cc7e0a5d6031d8512f3ecc040",
22
22
  "dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
@@ -26,32 +26,35 @@
26
26
  "dist/compliance.js": "29a18da3cefea94802ac7d817d1ab4e3674abd5c3ac4ef893cb8b01c6a23fb61",
27
27
  "dist/conditions.js": "ff25e97da550a3bdd80af55522045669438aa31a77d2bc83ad602bcefdee0475",
28
28
  "dist/config-worker.js": "bc2170d60c35f8d5de227cbfc4c5067dc61d7cbc5b98aed88e68a11797072da5",
29
- "dist/config.js": "33c8d1f80aa7f1ce25eab12644f6cbef961a897b72b1585d4bfa6424985f0d16",
30
- "dist/context.js": "b3a45856662fbda7a0217c7856ab1deb7c09e3e12d7dd1559c0a0cac85a9bcff",
31
- "dist/ecosystem-cli.js": "1e71bacd53d6fa8c7857cfcce3c3012ce054771a434b78d1f39e892e32642edd",
29
+ "dist/config.js": "6fecd80a269c5c8097449205a09a7e3522336e1000dcb3a3c88adb1b349093db",
30
+ "dist/context.js": "225a25ad8dce00f52de2bb52a17aa85128cf34c4b82aa0cae846c8fc89b1cc52",
31
+ "dist/ecosystem-cli.js": "553911bf7e51c579ecd9d806f2ccfcba03ac601b87aa677c35147af36840e24d",
32
32
  "dist/egress.js": "2ae29fe2cb4590fd2f715abe6817db47fed2f50dd31f37946ecf42eafbb0dd9e",
33
33
  "dist/errors.js": "fef26eb834dd61b8b5f587eb7ab265d216122d182545f0a1c5b19c20840dffb0",
34
34
  "dist/examples.js": "8523799797d2530fe48ec5e96c34970bdba03c49afb31fdaab6a9c162a17dfa9",
35
- "dist/explain-cli.js": "f0f8f3b5046430d03c6abb629b38651c9c734b03456040735ce9d7e582b2bcd5",
36
- "dist/explain.js": "7fb632036cc68280701dd512c74d27d6e0f97b4848e9024b284d7c5393b35eb5",
37
- "dist/extension-artifacts.js": "b196f3a9372188eb5f21f57b6e6ccee0c03aa0bbdd34cd6d88a0b3ccc470d363",
38
- "dist/extensions.js": "966441cca677b0d88683d29d89d36c61cf487ae2b70d94eaa0c71c50730a4212",
35
+ "dist/explain-cli.js": "59050ad1139d419a23af4a844f2dc6b927c2bfd6599af7a2130b304733485f2d",
36
+ "dist/explain.js": "480f9590df4171ba0dc7187c878fc95623eefbc2fc47d38b974f36bc27ce6d00",
37
+ "dist/extension-artifacts.js": "01e1618799a0e920ef63b5cddf3680f245204895ad47bf956a4e4d8cbf706508",
38
+ "dist/extension-bundles.js": "48d4cb64d2bbee8010447845eef491ee0be994e4867755f69ad8ee9105afbb29",
39
+ "dist/extension-transport.js": "5b312e754cac0c7623e35379d91478c2787134e8564caae2f87bb91c8ecc4fd4",
40
+ "dist/extensions.js": "ec4b1784fe278a5293ec12651765184bf57dac7146c0627dc9cb31deeb01a928",
41
+ "dist/feature-plan.js": "214af2d3aac042386df2ca7ce06ee505a78d2e80a38bb4ef0e5b96a4a9866a97",
39
42
  "dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
40
43
  "dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
41
- "dist/functions.js": "0f09d1bd27eb655ceb5900a4a5e2b760d2028b02cb637a6a89a4eb5940828008",
44
+ "dist/functions.js": "d9f5a493ba389191707107972562edc5b5f435579622d5a0caa4d942d7b1326a",
42
45
  "dist/guest-api.js": "49aec9ed72733ad119e63bcfcee18158dcc8ceb951e1823b8947f6407ff24afc",
43
46
  "dist/header-validation.js": "465181dbb08ff05f52defdd29fda025c0c64589bf319fa87fa6d3ab4b68216d5",
44
47
  "dist/http-policy.js": "393a6383bdf9e05597c98721b3a4c4207a9bbe359883babcbfa06b696c70f56c",
45
48
  "dist/http-response.js": "565826d1ece4bd30dfc1150c75800acec1fef584f732e964409aa92958fc8550",
46
- "dist/index.js": "a852f2512d8af0ba0645a6a8317f7ab2e576c442ab7a31e1570794ff0e635074",
47
- "dist/init-with.js": "0d2dccb83f0ad53181c6f5c95a4c663ce06ee9a1fa9074facac9a1aee7599a96",
49
+ "dist/index.js": "5e535104c672e70fa42f847ce6722c3b0bb2e3a9eac37f6a3c45d1a771f65f50",
50
+ "dist/init-with.js": "4b4bfca3081f57c525ec2b3617c4735d773c5317f285337948227ede4734f214",
48
51
  "dist/interchange-cli.js": "35bd70ba8077af5c3e39404ff0e5d28707632090a59a90645141252449fd3f04",
49
- "dist/interchange.js": "26789420af33344d9a08c00fe6b2708aa71994224df527b3075295be2151611e",
52
+ "dist/interchange.js": "3352228bc4ca652dd285fdaeddc8586f731fea5bd49521f408cce9a727f50ea8",
50
53
  "dist/logging.js": "8ec57fea26983aa071b05fe15d680d6550b03488101163ed9666da674118cf31",
51
54
  "dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
52
- "dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
55
+ "dist/match.js": "2299245c0ba85481afe470c95f0f79502b6310b6870f776d16d88525a5e5da63",
53
56
  "dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
54
- "dist/mcp.js": "51965c4e85c8e3ce283baf4255ca9db149ce5ee650e5a1a7c6a097516d9de5b2",
57
+ "dist/mcp.js": "630936eaabd15cca18d0bf8249333f4f1b6eded29a1369512355e9200a705612",
55
58
  "dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
56
59
  "dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
57
60
  "dist/pattern-guard.js": "6f157ced99666a89ca5af1418f5a14568d850985042dea309bf9ec0c19dbaca5",
@@ -68,20 +71,21 @@
68
71
  "dist/project-tests.js": "63562978411ee94a69dcf45d4d47e84fba009d8654ac421c2a02a418cd80b4db",
69
72
  "dist/provider-verification.js": "ffcf9f05e8cb5f71ea46a97e279e8a613fdfdf9435ab02bd57af11e2680a9107",
70
73
  "dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
71
- "dist/readiness.js": "2b67bc86c0c73528f3f798b6a0ee1bd5aec5ac6f4a0998c45443c903a622af4f",
74
+ "dist/readiness.js": "04143b22dbb8deec4b21b0f86c0c45c67f62a511efe1f99764b660791ef83343",
72
75
  "dist/recipes.js": "7af62ea9f612c82ea21e97e9d14451f05e0b509d6e420fb99fec96b22e3533f1",
76
+ "dist/review.js": "65c078fb027a4600dbbaa610d2f4cd639badffe7e508fe63e5ae90b17e86f17b",
73
77
  "dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
74
- "dist/router.js": "b2b26409321c5d119ac54793efe6bb6a2f9bbf2f36db251c68337c25b9d836b9",
75
- "dist/runtime.js": "e36153ab250b566ea778584a447ce603cf283274d9f8ac54a22aa9969c2284e8",
78
+ "dist/router.js": "e5318f3a3b7aa9e0edfe2552f67555f1d7cc820ceec5c77d452c439b834f7434",
79
+ "dist/runtime.js": "6731200cdc1e8ac66b5d0ba7d0f42686ec31321b24e7d51130fe130e69478f37",
76
80
  "dist/sandbox.js": "c88c5aa5dd95b4f55f1ba62da7969ad66dbd97c30f72e5b5f2817a2098ac1c65",
77
81
  "dist/scaffold.js": "0f936cabd20b609c7ebfebd7895710ce6c30882c3088a2356ac75bb4e0d10c55",
78
82
  "dist/schema-query.js": "bfd1844acd8d54ac361115191fcfbaf828a67e223b9ec6e9c22f158761af149b",
79
83
  "dist/server.js": "165b66fc4ed3b8e7c46ae4402ef66a5bc988c0910fb3e11556b040781e09d03c",
80
84
  "dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
81
85
  "dist/site.js": "9f8154d0a62ed1b650c064a24e609d50dd370f0ff2cee2567628029e30855616",
82
- "dist/tooling.js": "f377128dc37cff4685ba8503f4c38089ca4d7e97551e832bae4cdc24e357ba97",
86
+ "dist/tooling.js": "9fa72ac5fdcdb56c9e17ee5d8efae83e100d3ad862f90e855dd5766424830b57",
83
87
  "dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
84
- "dist/types.js": "827f1afde90d19715e79500515ecaa80f5b65ce552f0ef24d2fc561ef77e6e40",
88
+ "dist/types.js": "483bf520fb8088d9793c40b6a79cce068506a6137f8128fd3923e179ee3377b3",
85
89
  "dist/typescript-authoring.js": "df3d9c82e6b545cae5caae04c443ed4d2feae96def5e5cde250c0a90d83ae98c",
86
90
  "dist/vercel.js": "a0da20c095d0c7fa8f579ed4dd5951e7a34191f8a72a57c5e782c9eab39f6f08",
87
91
  "dist/verify-deployment.js": "2d3b6301b70684abfccea669f7901afda7fae9d9d66902809ecdef65f22aea16",
@@ -11,10 +11,14 @@ export const mcpConfigFile = '.mcp.json';
11
11
  /**
12
12
  * Renders `.mcp.json` registering the read-only `urlcode mcp` server for the project at `project`, relative
13
13
  * to the file. `--allow-authoring` is deliberately absent: the operator adds it by hand when they want it.
14
+ * `local` is for a project whose package.json pins the runtime: the server is then launched through `npx --no`,
15
+ * which uses the installed copy and refuses to fetch anything (a bare `urlcode` is not on PATH for a local-only install,
16
+ * and `npx urlcode` would resolve an unrelated registry package). Without a pin the bare command is kept for global installs.
14
17
  */
15
- export function renderMcpConfig(project = '.') {
18
+ export function renderMcpConfig(project = '.', { local = false } = {}) {
16
19
  assert(/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(project) && !project.split('/').includes('..'), 'MCP project path must be a relative path without ..');
17
- return JSON.stringify({ mcpServers: { urlcode: { command: 'urlcode', args: ['mcp', '--project', project] } } }, null, 2) + '\n';
20
+ const server = local ? { command: 'npx', args: ['--no', '--package', '@jimhoyd/urlcode', 'urlcode', 'mcp', '--project', project] } : { command: 'urlcode', args: ['mcp', '--project', project] };
21
+ return JSON.stringify({ mcpServers: { urlcode: server } }, null, 2) + '\n';
18
22
  }
19
23
  const handlerNames = ['redirect','respond','page','static','download','function','proxy','conditional','extension'];
20
24
 
@@ -47,7 +51,7 @@ static serving and authentication. Read this file before changing anything.
47
51
  (limits; \`--target NAME\` before promising a provider), \`get_schema\`,
48
52
  \`recipes search TEXT\`/\`search_recipes\` then \`recipes add NAME --out DIR\`,
49
53
  \`explain\` and, with an operator host file, \`get_extensions\`. Bare
50
- \`capabilities\` and \`recipes list\` are complete catalogs: fallback, not step one.
54
+ \`capabilities\` and \`recipes list\` are complete catalogs: fallback, not step one. Do not read or grep \`llms-full.txt\` or the packaged docs for a routine task.
51
55
  4. Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement. Check supported extensions and recipes first; explain any capability gap.
52
56
 
53
57
  ## MCP
@@ -81,7 +85,7 @@ skill only in a React frontend with \`components.json\`; start with \`shadcn inf
81
85
  A \`function\`/\`middleware\` is trusted, in-process Node with only declared
82
86
  \`args\`/\`env\`/\`secrets\`. Add \`sandbox: true\` for code needing isolation, not
83
87
  merely untrusted input. The sandbox is text/JSON-only; use \`proxy\`/a binding and
84
- record the reason in \`sandboxReason\`.
88
+ record the reason in \`sandboxReason\`. Try \`redirect\` (relative or \`/**\`) or \`respond\` first; a function gets \`context.route.pattern\`.
85
89
 
86
90
  ## Checks that count as evidence
87
91
 
package/dist/authoring.js CHANGED
@@ -1,4 +1,4 @@
1
- import { cp, readdir, mkdir, mkdtemp, rename, rm, readFile, open } from 'node:fs/promises';
1
+ import { cp, readdir, mkdir, mkdtemp, rename, rm, readFile, writeFile, open } from 'node:fs/promises';
2
2
  import { resolve, dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { randomBytes } from 'node:crypto';
@@ -10,6 +10,7 @@ import { prepareFunctionSnapshot, requestedPermissions } from './policy.js';
10
10
  import { assert } from './errors.js';
11
11
  import { renderPackageManifest } from './project-dependencies.js';
12
12
 
13
+ import { redirectStarter } from './context.js';
13
14
 
14
15
 
15
16
 
@@ -19,21 +20,57 @@ import { renderPackageManifest } from './project-dependencies.js';
19
20
 
20
21
 
21
22
 
22
-
23
+
23
24
 
25
+ // Agents install the runtime before they can read its docs, so `init .` has to work after `npm init` and `npm install`.
26
+ // A directory is accepted in place only when it holds nothing but what npm and git create; anything else is user work
27
+ // this command must never merge into.
28
+ const inPlaceEntries = new Set(['package.json', 'package-lock.json', 'node_modules', '.git']);
29
+
30
+ async function inspectExisting(target ) {
31
+ let names ;
32
+ try { names = await readdir(target); } catch (error) { if ((error ).code === 'ENOENT') return undefined; throw error; }
33
+ const unmergeable = names.filter(name => !inPlaceEntries.has(name));
34
+ assert(!unmergeable.length, `Directory already contains ${unmergeable.slice(0, 5).join(', ')}; init into a new or empty directory, or one holding only package.json, package-lock.json, node_modules or .git`);
35
+ const packageJson = names.includes('package.json') ? await readFile(join(target, 'package.json'), 'utf8') : undefined;
36
+ let pinned = false;
37
+ if (packageJson !== undefined) {
38
+ let manifest ;
39
+ try { manifest = JSON.parse(packageJson); } catch { assert(false, 'Existing package.json is not valid JSON'); }
40
+ pinned = Boolean(manifest.dependencies?.['@jimhoyd/urlcode'] ?? manifest.devDependencies?.['@jimhoyd/urlcode']);
41
+ }
42
+ return { entries: new Set(names), packageJson, pinned };
43
+ }
44
+ /** Adds what the starter needs to an existing package.json and changes nothing else; a conflicting script is refused, never overwritten. */
45
+ export function mergePackageJson(text , scripts , dependency , version ) {
46
+ const manifest = JSON.parse(text) ;
47
+ for (const [name, command] of Object.entries(scripts)) {
48
+ const current = manifest.scripts?.[name];
49
+ assert(current === undefined || current === command, `package.json already defines scripts.${name}; remove it or set it to: ${command}`);
50
+ manifest.scripts = { ...manifest.scripts, [name]: command };
51
+ }
52
+ if (manifest.dependencies?.[dependency] === undefined && manifest.devDependencies?.[dependency] === undefined) manifest.dependencies = { ...manifest.dependencies, [dependency]: version };
53
+ return JSON.stringify(manifest, null, 2) + '\n';
54
+ }
24
55
  export async function initProject(destination , { manifest, template = 'default' } = {}) {
25
56
  const target = resolve(destination);
26
57
  await mkdir(dirname(target), { recursive: true });
27
- // Reserve destination before copying; never merge into existing user files.
28
- await mkdir(target);
58
+ const existing = await inspectExisting(target);
59
+ if (existing) assert(!(manifest && existing.packageJson !== undefined), '--manifest cannot write package.json over an existing one; drop --manifest and keep your package.json');
60
+ // A new directory is reserved exclusively before copying; an existing one is only ever added to.
61
+ if (!existing) await mkdir(target);
29
62
  try {
63
+ if (template === 'redirects') { await writeRedirectsStarter(target, existing); return target; }
30
64
  const source = fileURLToPath(new URL(`../starters/${template === 'page' ? 'page' : 'default'}/`, import.meta.url));
31
65
  for (const file of await readdir(source)) {
32
66
  if (file === '.gitignore' || file === 'AGENTS.md' || file === mcpConfigFile) continue;
33
67
  await cp(join(source,file), join(target,file === 'gitignore.template' ? '.gitignore' : file), { recursive: true, force: false, errorOnExist: true });
34
68
  }
35
69
  if (template === 'page') {
36
- await loadDocument(target);
70
+ const routes = Object.keys((await loadDocument(target)).routes).length;
71
+ // The page starter is the smallest project, but an agent opened in it still needs the same first-step guidance and MCP registration.
72
+ await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
73
+ await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: manifest !== undefined || existing?.pinned === true }));
37
74
  if (manifest) {
38
75
  const pkg = await open(join(target,'package.json'), 'wx', 0o644);
39
76
  try { await pkg.writeFile(renderPackageManifest(target, manifest)); } finally { await pkg.close(); }
@@ -48,15 +85,52 @@ export async function initProject(destination , { manifest, template = 'd
48
85
  try { await guide.writeFile(renderAgentsGuide({ routes })); } finally { await guide.close(); }
49
86
  // .mcp.json registers the read-only server for repository-aware agents; the starter carries the same bytes.
50
87
  const mcp = await open(join(target,mcpConfigFile), 'wx', 0o644);
51
- try { await mcp.writeFile(renderMcpConfig('.')); } finally { await mcp.close(); }
88
+ try { await mcp.writeFile(renderMcpConfig('.', { local: manifest !== undefined || existing?.pinned === true })); } finally { await mcp.close(); }
52
89
  if (manifest) {
53
90
  // Exclusive create: the starter ships no package.json, so this never merges into or overwrites one.
54
91
  const pkg = await open(join(target,'package.json'), 'wx', 0o644);
55
92
  try { await pkg.writeFile(renderPackageManifest(target, manifest)); } finally { await pkg.close(); }
56
93
  }
57
- } catch (error) { await rm(target, { recursive: true, force: true }); throw error; }
94
+ } catch (error) {
95
+ if (!existing) await rm(target, { recursive: true, force: true });
96
+ else {
97
+ // Remove only what this run created and put package.json back; the user's own files are never touched.
98
+ for (const name of await readdir(target)) if (!existing.entries.has(name)) await rm(join(target, name), { recursive: true, force: true });
99
+ if (existing.packageJson !== undefined) await writeFile(join(target, 'package.json'), existing.packageJson);
100
+ }
101
+ throw error;
102
+ }
58
103
  return target;
59
104
  }
105
+ const redirectFixtures = [
106
+ { path: '/old', status: 301, expectHeaders: { location: 'https://example.com/new' } },
107
+ { path: '/users/42', status: 308, expectHeaders: { location: 'https://example.com/profiles/42' } },
108
+ { path: '/people/7', status: 302, expectHeaders: { location: '/profiles/7' } },
109
+ { path: '/legacy/a/b/c/d/e/f/g/h/i', status: 302, expectHeaders: { location: 'https://example.com/modern/a/b/c/d/e/f/g/h/i' } },
110
+ { path: '/search?q=tea', status: 302, expectHeaders: { location: 'https://example.com/find?q=tea' } },
111
+ { path: '/missing', status: 404 },
112
+ ];
113
+ /** The redirect starter is the `--task redirects` starter, so init and `urlcode context` cannot disagree. */
114
+ async function writeRedirectsStarter(target , existing ) {
115
+ const starter = redirectStarter();
116
+ const version = (JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) ).version;
117
+ const files = {
118
+ [starter.file]: starter.yaml,
119
+ ...starter.companions,
120
+ 'tests/requests.json': JSON.stringify(redirectFixtures, null, 2) + '\n',
121
+ };
122
+ await mkdir(join(target, 'tests'));
123
+ for (const [name, body] of Object.entries(files)) await writeExclusive(join(target, name), body);
124
+ if (existing?.packageJson !== undefined) await writeFile(join(target, 'package.json'), mergePackageJson(existing.packageJson, starter.packageScripts, '@jimhoyd/urlcode', version));
125
+ else await writeExclusive(join(target, 'package.json'), JSON.stringify({ name: 'redirects', version: '1.0.0', private: true, scripts: starter.packageScripts, dependencies: { '@jimhoyd/urlcode': version } }, null, 2) + '\n');
126
+ const routes = Object.keys((await loadDocument(target)).routes).length;
127
+ await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
128
+ await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: true }));
129
+ }
130
+ async function writeExclusive(file , body ) {
131
+ const handle = await open(file, 'wx', 0o644);
132
+ try { await handle.writeFile(body); } finally { await handle.close(); }
133
+ }
60
134
  export async function addRedirect(project , destination , alias ) {
61
135
  const loaded = await loadDocument(project);
62
136
  const slug = alias || randomBytes(6).toString('base64url');
@@ -81,6 +81,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
81
81
 
82
82
  assertTargetCompatibility(analyzeCompiledCapabilities(loaded.document, compiled, 'cloudflare'));
83
83
  for (const route of routes) {
84
+ assert(!route.wildcard, `${route.pattern}: the Worker route table has no suffix matching yet, so a /** wildcard redirect is refused on this target`);
84
85
  const policies = effectivePolicies(loaded.document, route);
85
86
  for (const name of Object.keys(policies)) {
86
87
  assert(isPolicyName(name), `Unknown policy "${name}"`);
@@ -64,6 +64,7 @@ export async function buildStatic(project , { out = 'dist/static', origin
64
64
  assert(defaultMethods.size === route.methods.length && [...defaultMethods].every(method => route.methods.includes(method)), `${route.pattern}: static hosting cannot preserve a GET-only or HEAD-only method restriction; declare both GET and HEAD`);
65
65
  if (route.reply) assert(route.reply.status === 200, `${route.pattern}: static hosting serves response objects with status 200; declared status ${route.reply.status} cannot be preserved`);
66
66
  if (route.redirect) {
67
+ assert(!route.wildcard, `${route.pattern}: static hosting cannot redirect a path suffix; S3 per-object redirects match one exact path`);
67
68
  assert(!route.names.length, `${route.pattern}: static hosting cannot redirect a path pattern; only an exact literal path can carry an S3 per-object redirect`);
68
69
  const query = route.redirect.query;
69
70
  assert(!query?.pass?.length && !(query?.map && Object.keys(query.map).length), `${route.pattern}: static hosting cannot pass or map query parameters into a redirect target; this needs request-time logic S3 does not have`);
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { verifyDeployment, failLevels } from './verify-deployment.js';
20
20
 
21
21
  import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
22
22
  import { loadDocument } from './config.js';
23
- import { describeExtensions } from './tooling.js';
23
+ import { describeExtensions, planFeature, reviewProject } from './tooling.js';
24
24
 
25
25
  import { ConfigError, HttpError } from './errors.js';
26
26
  import { registry as policyRegistry } from './policies.js';
@@ -28,11 +28,14 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
30
  import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
31
+ import { installBundle, readBundleLock } from './extension-bundles.js';
31
32
 
32
- const usage = `URLCode 0.4.8 — local/self-hosted runtime
33
- urlcode init <directory> [--template page] [--with ui,auth,admin] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
+ const usage = `URLCode 0.5.5 — local/self-hosted runtime
34
+ urlcode init <directory> [--template page|redirects] [--with ui,auth,admin] [--bundle-release extension-bundles@vX.Y.Z] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
35
+ # --template redirects: the tested redirect starter from 'urlcode context --task redirects' (urlcode.yaml, 404.html, package.json with a PORT-aware start script, tests, AGENTS.md, .mcp.json); not combinable with --with
36
+ # init works in place in a directory holding only package.json, package-lock.json, node_modules or .git: package.json is merged (scripts.start added, an existing pin kept), any other existing file is refused
34
37
  # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
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
38
+ # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --bundle-release instead verifies frozen first-party bundles and writes no npm extension dependency. --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
36
39
  # --ack: repeatable, qualified acknowledgement of a risk an extension names when it refuses (for example store:public-write); do not pass it pre-emptively, the refusal prints the exact command. Rejected when no scaffold consumes it
37
40
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
38
41
  # --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
@@ -69,11 +72,15 @@ const usage = `URLCode 0.4.8 — local/self-hosted runtime
69
72
  urlcode extension-artifacts update <name> --artifact-release extensions@vX.Y.Z [--project directory]
70
73
  urlcode extension-artifacts inspect [--project directory] [--json]
71
74
  # signed, data-only extension bundles cached under .urlcode/extensions; they never execute or replace --host-file
75
+ urlcode extension-bundles install <name> --bundle-release extension-bundles@vX.Y.Z [--project directory]
76
+ urlcode extension-bundles inspect [--project directory] [--json]
77
+ # signed executable first-party bundles cached under .urlcode/extension-bundles; installation is explicit and host code loads them
72
78
  urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
73
79
  urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
74
80
  conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
75
81
  urlcode recipes [list|search <text>|show <name>|add <name> --out new-directory] [--dry-run] [--json]
76
82
  urlcode examples [list|search <text>] [--json] # bundled runnable examples and the cookbook route index
83
+ urlcode docs search <text> [--json] # same as MCP search_docs: at most three bounded excerpts from the packaged agent docs, instead of grepping llms-full.txt
77
84
  urlcode build-typescript [--project directory] --out new-directory [--dry-run]
78
85
  urlcode bulk-import csv|json|yaml <file> --out new-directory [--dry-run]
79
86
  urlcode verify-provider --target self-hosted|aws|vercel|cloudflare --origin https://owned-fixture.example
@@ -84,8 +91,12 @@ const usage = `URLCode 0.4.8 — local/self-hosted runtime
84
91
  urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
85
92
  urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static | --task redirects] [--budget 500] [--json] [--stats]
86
93
  # 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
94
+ urlcode plan-feature <goal> [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--host-file ...] [--json]
95
+ # bounded read-only feature plan from compiled facts, local catalogs, locked inert artifacts and registrations already loaded from the operator host
96
+ urlcode review [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--host-file ...] [--json]
97
+ # opt-in read-only static review for avoidable plumbing; host file registrations sharpen extension-alternative findings (registered/revision-pinned), never required
87
98
  urlcode doctor
88
- serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
99
+ serve/dev/validate/test/routes/audit/benchmark/explain/context/plan-feature/review/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
89
100
  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.
90
101
  `;
91
102
  const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
@@ -99,7 +110,7 @@ const options = {
99
110
  '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'},
100
111
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
101
112
  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'},
113
+ 'artifact-release':{type:'string'}, 'bundle-release':{type:'string'},
103
114
  } ;
104
115
 
105
116
 
@@ -166,7 +177,7 @@ try {
166
177
  if (values.help || !command) print(usage);
167
178
  else {
168
179
  if (values['host-file'] !== undefined) {
169
- if (!['serve','dev','validate','test','routes','audit','benchmark','explain','context','extensions','mcp'].includes(command)) throw new ConfigError('--host-file is only supported by serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp');
180
+ if (!['serve','dev','validate','test','routes','audit','benchmark','explain','context','plan-feature','review','extensions','mcp'].includes(command)) throw new ConfigError('--host-file is only supported by serve/dev/validate/test/routes/audit/benchmark/explain/context/plan-feature/review/extensions/mcp');
170
181
  // The MCP server and context command load and release the host themselves.
171
182
  if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
172
183
  }
@@ -177,19 +188,25 @@ try {
177
188
  if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
178
189
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
179
190
  if (values['artifact-release'] !== undefined && command !== 'extension-artifacts') throw new ConfigError('--artifact-release is only supported by extension-artifacts');
191
+ if (values['bundle-release'] !== undefined && command !== 'extension-bundles' && command !== 'init') throw new ConfigError('--bundle-release is only supported by extension-bundles or init --with');
192
+ if (values['bundle-release'] !== undefined && command === 'init' && values.with === undefined) throw new ConfigError('--bundle-release needs init --with');
180
193
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
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');
194
+ if ((!['import','recipes','recipe','examples','example','docs','bulk-import','extension-artifacts','extension-bundles'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','docs','bulk-import','explain','capabilities','schema','plan-feature','extension-artifacts','extension-bundles'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
182
195
 
183
196
  if(command==='extension-artifacts'){
184
197
  const operation=arg;
185
198
  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
199
  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
200
  else throw new ConfigError('Use extension-artifacts install, update or inspect');
201
+ }else if(command==='extension-bundles'){
202
+ if(arg==='install') { const bundle=extra[0]; if(!bundle || extra.length!==1) throw new ConfigError('Use urlcode extension-bundles install <name> --bundle-release extension-bundles@vX.Y.Z'); if(!values['bundle-release']) throw new ConfigError('Use --bundle-release with an immutable extension bundle release tag'); const lock=await installBundle(values.project,values['bundle-release'],bundle); print(values.json?lock:{event:'extension-bundle-installed',name:bundle,lockfile:'urlcode.extension-bundles.lock.json'}); }
203
+ else if(arg==='inspect') { if(extra.length) throw new ConfigError('Use urlcode extension-bundles inspect'); const lock=await readBundleLock(values.project); print(values.json?lock:{bundles:lock.bundles.map(item=>({name:item.name,version:item.version,release:item.catalog.tag,coreVersion:item.coreVersion}))}); }
204
+ else throw new ConfigError('Use extension-bundles install or inspect');
188
205
  }else if(command==='import'||command==='export'){
189
206
  const { runInterchange } = await import('./interchange-cli.js');
190
207
  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']});
191
208
  print(converted.text); if(!converted.report.ok)process.exitCode=1;
192
- }else if(['recipes','recipe','examples','example','build-typescript','bulk-import','verify-provider','mcp'].includes(command)){
209
+ }else if(['recipes','recipe','examples','example','docs','build-typescript','bulk-import','verify-provider','mcp'].includes(command)){
193
210
  const {runEcosystemCommand}=await import('./ecosystem-cli.js');
194
211
  await runEcosystemCommand(command,positionals.slice(1),values,print);
195
212
  }else if(command==='explain'||command==='manifest'){
@@ -203,6 +220,13 @@ try {
203
220
  if(arg===undefined)throw new ConfigError('Use urlcode schema <path>');
204
221
  const fragment=getSchemaFragment(arg);
205
222
  print(values.yaml ? stringifyYaml(fragment.schema) : JSON.stringify(fragment.schema,null,2)+'\n');
223
+ }else if(command==='plan-feature'){
224
+ if(arg===undefined)throw new ConfigError('Use urlcode plan-feature <goal>');
225
+ const plan=await planFeature(values.project,arg,{...(values.target===undefined?{}:{target:values.target}),...(operatorHost.extensions===undefined?{}:{extensions:operatorHost.extensions})});
226
+ print(values.json?plan:stringifyYaml(plan,{lineWidth:0,aliasDuplicateObjects:false}));
227
+ }else if(command==='review'){
228
+ const review=await reviewProject(values.project,{...(values.target===undefined?{}:{target:values.target}),...(values.origin===undefined?{}:{origin:values.origin}),...(operatorHost.extensions===undefined?{}:{extensions:operatorHost.extensions})});
229
+ print(values.json?review:stringifyYaml(review,{lineWidth:0,aliasDuplicateObjects:false}));
206
230
  }else if(command==='context'){
207
231
  if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
208
232
  const { buildContext, buildTaskContext, renderContext, renderTaskContext, estimateTokens, documentationTokens } = await import('./context.js');
@@ -315,15 +339,16 @@ try {
315
339
  const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
316
340
  const pins = new Map((values.pin ?? []).map(parsePin));
317
341
  if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
318
- if (values.template !== undefined && values.template !== 'default' && values.template !== 'page') throw new ConfigError('--template must be page');
319
- if (values.template === 'page' && values.with !== undefined) throw new ConfigError('--template page cannot be combined with --with');
342
+ if (values.template !== undefined && values.template !== 'default' && values.template !== 'page' && values.template !== 'redirects') throw new ConfigError('--template must be page or redirects');
343
+ if ((values.template === 'page' || values.template === 'redirects') && values.with !== undefined) throw new ConfigError(`--template ${values.template} cannot be combined with --with`);
344
+ if (values.template === 'redirects' && (wanted || pins.size)) throw new ConfigError('--template redirects writes its own package.json pinning this runtime; drop --manifest and --pin');
320
345
  if (values.with === undefined) {
321
346
  const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
322
- const created = await initProject(arg, { manifest: set, template: values.template === 'page' ? 'page' : 'default' });
347
+ const created = await initProject(arg, { manifest: set, template: values.template === 'page' || values.template === 'redirects' ? values.template : 'default' });
323
348
  print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
324
349
  break;
325
350
  }
326
- const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [] });
351
+ const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [], bundleRelease: values['bundle-release'] });
327
352
  print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
328
353
  break;
329
354
  }
@@ -339,7 +364,7 @@ try {
339
364
  print(result); if (result.failed) process.exitCode = 1; break;
340
365
  }
341
366
  case 'doctor':
342
- print({ node:process.version, platform:process.platform, architecture:process.arch, runtime:'node-process', functionDefault:'trusted-in-process', sandboxEngine:'quickjs-wasm', trustedFilesystem:true, trustedNetwork:true, sandboxedFilesystem:false, sandboxedNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
367
+ print({ node:process.version, platform:process.platform, architecture:process.arch, runtime:'node-process', functionDefault:'trusted-in-process', sandboxEngine:'quickjs-wasm', trustedFilesystem:true, trustedNetwork:true, sandboxedFilesystem:false, sandboxedNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','docs','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
343
368
  case 'dev': case 'serve': {
344
369
  const port = Number(values.port);
345
370
  if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
package/dist/config.js CHANGED
@@ -146,7 +146,12 @@ function normalizeRoute(pattern , route
146
146
  const needsFunction = typeof authored.function === 'string';
147
147
  const needsMiddleware = authored.middleware?.some(entry => typeof entry === 'string') ?? false;
148
148
  const needsCache = authored.cache !== undefined;
149
- if (!needsFunction && !needsMiddleware && !needsCache) return route ;
149
+ // Long form without `args`: bind every declared path input, exactly as the short form does, so `function: {source}`
150
+ // is not silently handed an empty `args` (a route that wants none declares `args: {}`).
151
+ const declaredPathNames = typeof authored.function === 'object' && authored.function !== null && authored.function.args === undefined
152
+ ? (authored.parameters ?? []).filter(p => p.in === 'path').map(p => p.name) : [];
153
+ const needsArgs = declaredPathNames.length > 0;
154
+ if (!needsFunction && !needsMiddleware && !needsCache && !needsArgs) return route ;
150
155
  const result = { ...(route ) };
151
156
  if (needsMiddleware) result.middleware = authored.middleware .map((entry) => typeof entry === 'string' ? { source: modulePath(pattern, 'middleware', entry) } : entry);
152
157
  if (needsFunction) {
@@ -158,6 +163,7 @@ function normalizeRoute(pattern , route
158
163
  if (parameters.length) result.parameters = parameters;
159
164
  result.function = expanded;
160
165
  }
166
+ if (needsArgs) result.function = { ...(authored.function ), args: Object.fromEntries(declaredPathNames.map(name => [name, { from: 'path' , name }])) };
161
167
  if (needsCache) {
162
168
  assert(result.policies?.cache === undefined, `Route ${pattern} declares both cache and policies.cache; use one form`);
163
169
  result.policies = { ...result.policies, cache: authored.cache };