@jimhoyd/urlcode 0.5.0 → 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.
- package/README.md +13 -16
- package/dist/BUILD-MANIFEST.json +15 -12
- package/dist/authoring.js +50 -9
- package/dist/cli.js +20 -7
- package/dist/ecosystem-cli.js +6 -0
- package/dist/explain-cli.js +1 -1
- package/dist/explain.js +2 -2
- package/dist/extension-artifacts.js +10 -23
- package/dist/extension-bundles.js +8 -16
- package/dist/extension-transport.js +41 -0
- package/dist/feature-plan.js +99 -0
- package/dist/index.js +2 -2
- package/dist/mcp.js +6 -2
- package/dist/review.js +206 -0
- package/dist/router.js +15 -2
- package/dist/tooling.js +4 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/extension-artifacts.d.ts +2 -4
- package/dist/types/extension-transport.d.ts +31 -0
- package/dist/types/feature-plan.d.ts +67 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/review.d.ts +30 -0
- package/dist/types/tooling.d.ts +4 -0
- package/dist/types/types.d.ts +9 -1
- package/dist/types.js +9 -2
- package/docs/AI-AUTHORING.md +466 -0
- package/docs/FUNCTION-SECURITY.md +251 -0
- package/docs/README.md +96 -0
- package/docs/TOOLING.md +422 -0
- package/docs/YAML-REFERENCE.md +473 -0
- package/llms-full.txt +114 -79
- package/llms.txt +28 -19
- package/package.json +9 -3
- package/recipes/store-crud/README.md +9 -10
- package/recipes/store-crud/recipe.yaml +1 -1
- package/schemas/urlcode.schema.json +3 -0
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
|
-
|
|
|
56
|
+
| Component | Adds | Supported distribution |
|
|
57
57
|
|---|---|---|
|
|
58
|
-
| [urlcode](https://github.com/jimhoyd-com/urlcode) (this repository) | Runtime, CLI, policies, provider adapters, extension contract | [
|
|
59
|
-
| [urlcode-ui](packages/ui) (
|
|
60
|
-
| [urlcode-auth](packages/auth) (
|
|
61
|
-
| [urlcode-admin](packages/admin) (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,7 +107,7 @@ it. Cross-repository acceptance is tracked in
|
|
|
110
107
|
## Status
|
|
111
108
|
|
|
112
109
|
<!-- urlcode-current-version:start -->
|
|
113
|
-
This checkout prepares the coordinated `0.5.
|
|
110
|
+
This checkout prepares the coordinated `0.5.5` core, UI, auth, admin and store
|
|
114
111
|
release. Package availability remains a live registry fact: use `npm run
|
|
115
112
|
release:status` or the release's signed `train.json` before selecting an
|
|
116
113
|
install combination. Independent package versioning remains enabled, and a
|
package/dist/BUILD-MANIFEST.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"dist/agents-guide.js": "421df50bcf258f23aba742148e53074512584a10ba35a6264e70d3eacc016e3e",
|
|
8
8
|
"dist/assets.js": "0833b093d2fe457ce68281110dc07d47914992753607741c13b339d92c76cf5c",
|
|
9
9
|
"dist/authoring-files.js": "ad32814f9ae9c549c6247396f982aa700d13a293cc0a589609eccc68f8965550",
|
|
10
|
-
"dist/authoring.js": "
|
|
10
|
+
"dist/authoring.js": "a52d289c67043cad170babb6adab7ca23be1d7c7e5a67e43eaadfa73d2fe7665",
|
|
11
11
|
"dist/aws.js": "7d8e3a97b4f6dbefbd68da058f05192469d1efcaed5458b284863ebe462e060e",
|
|
12
12
|
"dist/body-schema.js": "0227ef8cf2b380e3ef65a358533aa0e0f4a119c94e63747b8eb03fe65a6b9193",
|
|
13
13
|
"dist/build-cloudflare.js": "ff92e72d0a63d70dd8c28b52978edd9fb23cb16601e4dfebd07c2cef7bd73661",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dist/capabilities.js": "bb98f5bbf54cc4e82fa197f999a621ad815273176f02692c35566886e2bb8777",
|
|
17
17
|
"dist/capability-query.js": "9f68bc94901451d7b9ecdd3aee8c3376dca7f0b890e31d8205306cf8120dea86",
|
|
18
18
|
"dist/catalog.js": "c378ef9bfd63b940790f6975f7db54557c2459722b65a7c170b97df24184bc52",
|
|
19
|
-
"dist/cli.js": "
|
|
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",
|
|
@@ -28,15 +28,17 @@
|
|
|
28
28
|
"dist/config-worker.js": "bc2170d60c35f8d5de227cbfc4c5067dc61d7cbc5b98aed88e68a11797072da5",
|
|
29
29
|
"dist/config.js": "6fecd80a269c5c8097449205a09a7e3522336e1000dcb3a3c88adb1b349093db",
|
|
30
30
|
"dist/context.js": "225a25ad8dce00f52de2bb52a17aa85128cf34c4b82aa0cae846c8fc89b1cc52",
|
|
31
|
-
"dist/ecosystem-cli.js": "
|
|
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": "
|
|
36
|
-
"dist/explain.js": "
|
|
37
|
-
"dist/extension-artifacts.js": "
|
|
38
|
-
"dist/extension-bundles.js": "
|
|
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",
|
|
39
40
|
"dist/extensions.js": "ec4b1784fe278a5293ec12651765184bf57dac7146c0627dc9cb31deeb01a928",
|
|
41
|
+
"dist/feature-plan.js": "214af2d3aac042386df2ca7ce06ee505a78d2e80a38bb4ef0e5b96a4a9866a97",
|
|
40
42
|
"dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
|
|
41
43
|
"dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
|
|
42
44
|
"dist/functions.js": "d9f5a493ba389191707107972562edc5b5f435579622d5a0caa4d942d7b1326a",
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
"dist/header-validation.js": "465181dbb08ff05f52defdd29fda025c0c64589bf319fa87fa6d3ab4b68216d5",
|
|
45
47
|
"dist/http-policy.js": "393a6383bdf9e05597c98721b3a4c4207a9bbe359883babcbfa06b696c70f56c",
|
|
46
48
|
"dist/http-response.js": "565826d1ece4bd30dfc1150c75800acec1fef584f732e964409aa92958fc8550",
|
|
47
|
-
"dist/index.js": "
|
|
49
|
+
"dist/index.js": "5e535104c672e70fa42f847ce6722c3b0bb2e3a9eac37f6a3c45d1a771f65f50",
|
|
48
50
|
"dist/init-with.js": "4b4bfca3081f57c525ec2b3617c4735d773c5317f285337948227ede4734f214",
|
|
49
51
|
"dist/interchange-cli.js": "35bd70ba8077af5c3e39404ff0e5d28707632090a59a90645141252449fd3f04",
|
|
50
52
|
"dist/interchange.js": "3352228bc4ca652dd285fdaeddc8586f731fea5bd49521f408cce9a727f50ea8",
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
|
|
53
55
|
"dist/match.js": "2299245c0ba85481afe470c95f0f79502b6310b6870f776d16d88525a5e5da63",
|
|
54
56
|
"dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
|
|
55
|
-
"dist/mcp.js": "
|
|
57
|
+
"dist/mcp.js": "630936eaabd15cca18d0bf8249333f4f1b6eded29a1369512355e9200a705612",
|
|
56
58
|
"dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
|
|
57
59
|
"dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
|
|
58
60
|
"dist/pattern-guard.js": "6f157ced99666a89ca5af1418f5a14568d850985042dea309bf9ec0c19dbaca5",
|
|
@@ -71,8 +73,9 @@
|
|
|
71
73
|
"dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
|
|
72
74
|
"dist/readiness.js": "04143b22dbb8deec4b21b0f86c0c45c67f62a511efe1f99764b660791ef83343",
|
|
73
75
|
"dist/recipes.js": "7af62ea9f612c82ea21e97e9d14451f05e0b509d6e420fb99fec96b22e3533f1",
|
|
76
|
+
"dist/review.js": "65c078fb027a4600dbbaa610d2f4cd639badffe7e508fe63e5ae90b17e86f17b",
|
|
74
77
|
"dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
|
|
75
|
-
"dist/router.js": "
|
|
78
|
+
"dist/router.js": "e5318f3a3b7aa9e0edfe2552f67555f1d7cc820ceec5c77d452c439b834f7434",
|
|
76
79
|
"dist/runtime.js": "6731200cdc1e8ac66b5d0ba7d0f42686ec31321b24e7d51130fe130e69478f37",
|
|
77
80
|
"dist/sandbox.js": "c88c5aa5dd95b4f55f1ba62da7969ad66dbd97c30f72e5b5f2817a2098ac1c65",
|
|
78
81
|
"dist/scaffold.js": "0f936cabd20b609c7ebfebd7895710ce6c30882c3088a2356ac75bb4e0d10c55",
|
|
@@ -80,9 +83,9 @@
|
|
|
80
83
|
"dist/server.js": "165b66fc4ed3b8e7c46ae4402ef66a5bc988c0910fb3e11556b040781e09d03c",
|
|
81
84
|
"dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
|
|
82
85
|
"dist/site.js": "9f8154d0a62ed1b650c064a24e609d50dd370f0ff2cee2567628029e30855616",
|
|
83
|
-
"dist/tooling.js": "
|
|
86
|
+
"dist/tooling.js": "9fa72ac5fdcdb56c9e17ee5d8efae83e100d3ad862f90e855dd5766424830b57",
|
|
84
87
|
"dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
|
|
85
|
-
"dist/types.js": "
|
|
88
|
+
"dist/types.js": "483bf520fb8088d9793c40b6a79cce068506a6137f8128fd3923e179ee3377b3",
|
|
86
89
|
"dist/typescript-authoring.js": "df3d9c82e6b545cae5caae04c443ed4d2feae96def5e5cde250c0a90d83ae98c",
|
|
87
90
|
"dist/vercel.js": "a0da20c095d0c7fa8f579ed4dd5951e7a34191f8a72a57c5e782c9eab39f6f08",
|
|
88
91
|
"dist/verify-deployment.js": "2d3b6301b70684abfccea669f7901afda7fae9d9d66902809ecdef65f22aea16",
|
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';
|
|
@@ -22,13 +22,45 @@ import { redirectStarter } from './context.js';
|
|
|
22
22
|
|
|
23
23
|
|
|
24
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
|
+
}
|
|
25
55
|
export async function initProject(destination , { manifest, template = 'default' } = {}) {
|
|
26
56
|
const target = resolve(destination);
|
|
27
57
|
await mkdir(dirname(target), { recursive: true });
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
|
30
62
|
try {
|
|
31
|
-
if (template === 'redirects') { await writeRedirectsStarter(target); return target; }
|
|
63
|
+
if (template === 'redirects') { await writeRedirectsStarter(target, existing); return target; }
|
|
32
64
|
const source = fileURLToPath(new URL(`../starters/${template === 'page' ? 'page' : 'default'}/`, import.meta.url));
|
|
33
65
|
for (const file of await readdir(source)) {
|
|
34
66
|
if (file === '.gitignore' || file === 'AGENTS.md' || file === mcpConfigFile) continue;
|
|
@@ -38,7 +70,7 @@ export async function initProject(destination , { manifest, template = 'd
|
|
|
38
70
|
const routes = Object.keys((await loadDocument(target)).routes).length;
|
|
39
71
|
// The page starter is the smallest project, but an agent opened in it still needs the same first-step guidance and MCP registration.
|
|
40
72
|
await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
|
|
41
|
-
await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: manifest !== undefined }));
|
|
73
|
+
await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: manifest !== undefined || existing?.pinned === true }));
|
|
42
74
|
if (manifest) {
|
|
43
75
|
const pkg = await open(join(target,'package.json'), 'wx', 0o644);
|
|
44
76
|
try { await pkg.writeFile(renderPackageManifest(target, manifest)); } finally { await pkg.close(); }
|
|
@@ -53,13 +85,21 @@ export async function initProject(destination , { manifest, template = 'd
|
|
|
53
85
|
try { await guide.writeFile(renderAgentsGuide({ routes })); } finally { await guide.close(); }
|
|
54
86
|
// .mcp.json registers the read-only server for repository-aware agents; the starter carries the same bytes.
|
|
55
87
|
const mcp = await open(join(target,mcpConfigFile), 'wx', 0o644);
|
|
56
|
-
try { await mcp.writeFile(renderMcpConfig('.', { local: manifest !== undefined })); } finally { await mcp.close(); }
|
|
88
|
+
try { await mcp.writeFile(renderMcpConfig('.', { local: manifest !== undefined || existing?.pinned === true })); } finally { await mcp.close(); }
|
|
57
89
|
if (manifest) {
|
|
58
90
|
// Exclusive create: the starter ships no package.json, so this never merges into or overwrites one.
|
|
59
91
|
const pkg = await open(join(target,'package.json'), 'wx', 0o644);
|
|
60
92
|
try { await pkg.writeFile(renderPackageManifest(target, manifest)); } finally { await pkg.close(); }
|
|
61
93
|
}
|
|
62
|
-
} catch (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
|
+
}
|
|
63
103
|
return target;
|
|
64
104
|
}
|
|
65
105
|
const redirectFixtures = [
|
|
@@ -71,17 +111,18 @@ const redirectFixtures = [
|
|
|
71
111
|
{ path: '/missing', status: 404 },
|
|
72
112
|
];
|
|
73
113
|
/** The redirect starter is the `--task redirects` starter, so init and `urlcode context` cannot disagree. */
|
|
74
|
-
async function writeRedirectsStarter(target ) {
|
|
114
|
+
async function writeRedirectsStarter(target , existing ) {
|
|
75
115
|
const starter = redirectStarter();
|
|
76
116
|
const version = (JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) ).version;
|
|
77
117
|
const files = {
|
|
78
118
|
[starter.file]: starter.yaml,
|
|
79
119
|
...starter.companions,
|
|
80
|
-
'package.json': JSON.stringify({ name: 'redirects', version: '1.0.0', private: true, scripts: starter.packageScripts, dependencies: { '@jimhoyd/urlcode': version } }, null, 2) + '\n',
|
|
81
120
|
'tests/requests.json': JSON.stringify(redirectFixtures, null, 2) + '\n',
|
|
82
121
|
};
|
|
83
122
|
await mkdir(join(target, 'tests'));
|
|
84
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');
|
|
85
126
|
const routes = Object.keys((await loadDocument(target)).routes).length;
|
|
86
127
|
await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
|
|
87
128
|
await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: true }));
|
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';
|
|
@@ -30,9 +30,10 @@ import { readFile } from 'node:fs/promises';
|
|
|
30
30
|
import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
|
|
31
31
|
import { installBundle, readBundleLock } from './extension-bundles.js';
|
|
32
32
|
|
|
33
|
-
const usage = `URLCode 0.5.
|
|
33
|
+
const usage = `URLCode 0.5.5 — local/self-hosted runtime
|
|
34
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
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
|
|
36
37
|
# --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
|
|
37
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
|
|
38
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
|
|
@@ -79,6 +80,7 @@ const usage = `URLCode 0.5.0 — local/self-hosted runtime
|
|
|
79
80
|
conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
|
|
80
81
|
urlcode recipes [list|search <text>|show <name>|add <name> --out new-directory] [--dry-run] [--json]
|
|
81
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
|
|
82
84
|
urlcode build-typescript [--project directory] --out new-directory [--dry-run]
|
|
83
85
|
urlcode bulk-import csv|json|yaml <file> --out new-directory [--dry-run]
|
|
84
86
|
urlcode verify-provider --target self-hosted|aws|vercel|cloudflare --origin https://owned-fixture.example
|
|
@@ -89,8 +91,12 @@ const usage = `URLCode 0.5.0 — local/self-hosted runtime
|
|
|
89
91
|
urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
|
|
90
92
|
urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static | --task redirects] [--budget 500] [--json] [--stats]
|
|
91
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
|
|
92
98
|
urlcode doctor
|
|
93
|
-
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)
|
|
94
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.
|
|
95
101
|
`;
|
|
96
102
|
const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
|
|
@@ -171,7 +177,7 @@ try {
|
|
|
171
177
|
if (values.help || !command) print(usage);
|
|
172
178
|
else {
|
|
173
179
|
if (values['host-file'] !== undefined) {
|
|
174
|
-
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');
|
|
175
181
|
// The MCP server and context command load and release the host themselves.
|
|
176
182
|
if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
|
|
177
183
|
}
|
|
@@ -185,7 +191,7 @@ try {
|
|
|
185
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');
|
|
186
192
|
if (values['bundle-release'] !== undefined && command === 'init' && values.with === undefined) throw new ConfigError('--bundle-release needs init --with');
|
|
187
193
|
const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
|
|
188
|
-
if ((!['import','recipes','recipe','examples','example','bulk-import','extension-artifacts','extension-bundles'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema','extension-artifacts','extension-bundles'].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');
|
|
189
195
|
|
|
190
196
|
if(command==='extension-artifacts'){
|
|
191
197
|
const operation=arg;
|
|
@@ -200,7 +206,7 @@ try {
|
|
|
200
206
|
const { runInterchange } = await import('./interchange-cli.js');
|
|
201
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']});
|
|
202
208
|
print(converted.text); if(!converted.report.ok)process.exitCode=1;
|
|
203
|
-
}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)){
|
|
204
210
|
const {runEcosystemCommand}=await import('./ecosystem-cli.js');
|
|
205
211
|
await runEcosystemCommand(command,positionals.slice(1),values,print);
|
|
206
212
|
}else if(command==='explain'||command==='manifest'){
|
|
@@ -214,6 +220,13 @@ try {
|
|
|
214
220
|
if(arg===undefined)throw new ConfigError('Use urlcode schema <path>');
|
|
215
221
|
const fragment=getSchemaFragment(arg);
|
|
216
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}));
|
|
217
230
|
}else if(command==='context'){
|
|
218
231
|
if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
|
|
219
232
|
const { buildContext, buildTaskContext, renderContext, renderTaskContext, estimateTokens, documentationTokens } = await import('./context.js');
|
|
@@ -351,7 +364,7 @@ try {
|
|
|
351
364
|
print(result); if (result.failed) process.exitCode = 1; break;
|
|
352
365
|
}
|
|
353
366
|
case 'doctor':
|
|
354
|
-
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;
|
|
355
368
|
case 'dev': case 'serve': {
|
|
356
369
|
const port = Number(values.port);
|
|
357
370
|
if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
|
package/dist/ecosystem-cli.js
CHANGED
|
@@ -33,6 +33,12 @@ export async function runEcosystemCommand(command ,args ,options
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
else assert(false,'Use examples list or search');
|
|
36
|
+
}else if(command==='docs'){
|
|
37
|
+
const [operation,text]=args;
|
|
38
|
+
assert(operation==='search' && args.length===2,'Use docs search <text>');
|
|
39
|
+
const {searchDocs}=await import('./agent-context.js');
|
|
40
|
+
const found=await searchDocs(text );
|
|
41
|
+
print(options.json?found:found.results.length?found.results.map(hit=>`## ${hit.id}: ${hit.title}\n${hit.summary}\nmatched: ${hit.matched.join(' ')}\n\n${hit.excerpt}\n`).join('\n'):`No match for "${found.query}"\n`);
|
|
36
42
|
}else if(command==='build-typescript'){
|
|
37
43
|
assert(args.length===0 && options.out,'Provide --out new-directory');
|
|
38
44
|
const {buildTypeScriptProject}=await import('./typescript-authoring.js');
|
package/dist/explain-cli.js
CHANGED
|
@@ -27,7 +27,7 @@ function detail(explanation ,targets )
|
|
|
27
27
|
for(const [name,entry] of Object.entries(explanation.policies.inventory))lines.push(` ${name}: ${JSON.stringify(entry)}`);
|
|
28
28
|
for(const [name,entry] of Object.entries(explanation.policies.extensions))lines.push(` extensions.${name}: requires ${JSON.stringify(entry.requirement)}${entry.provider?entry.provider.registered?` (provider registered, revision ${entry.provider.revisionMatch?'matches':'differs'}, requirement ${entry.provider.requirementValid===false?'invalid':'valid'})`:' (no provider in host file)':''}`);
|
|
29
29
|
lines.push(`cache: ${explanation.cache.outcome}${explanation.cache.cacheControl?` (${explanation.cache.cacheControl})`:''}${explanation.cache.forcedNoStore?' forced':''}; ${explanation.cache.reason}`);
|
|
30
|
-
const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
|
|
30
|
+
const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}${'default' in ref?` (default ${JSON.stringify(ref.default)})`:''}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
|
|
31
31
|
lines.push(`bindings: ${[...env,...secrets].join(', ')||'none'}`);
|
|
32
32
|
if(explanation.egress.proxy||explanation.egress.signals)lines.push(`egress: ${[explanation.egress.proxy?`proxy ${explanation.egress.proxy}`:'',...(explanation.egress.signals??[]).map(item=>`signal ${item}`)].filter(Boolean).join(', ')}`);
|
|
33
33
|
if(explanation.responseHeaders.length)lines.push(`response headers: ${explanation.responseHeaders.map(([name,value])=>`${name}: ${value}`).join('; ')}`);
|
package/dist/explain.js
CHANGED
|
@@ -37,7 +37,7 @@ const handlerNames=['extension','proxy','conditional','redirect','function','pag
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
|
|
@@ -102,7 +102,7 @@ export function explainCompiledRoute(loaded ,route ,c
|
|
|
102
102
|
const extensions ={};
|
|
103
103
|
for(const name of extensionNames){const provider=providerOf(name,extensionRequirements[name],options);extensions[name]={requirement:extensionRequirements[name] ,...(provider?{provider}:{})};}
|
|
104
104
|
const env ={};
|
|
105
|
-
for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?{env:ref.env}:{literal:true};
|
|
105
|
+
for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?(ref.default!==undefined?{env:ref.env,default:ref.default}:{env:ref.env}):{literal:true};
|
|
106
106
|
const secrets ={};
|
|
107
107
|
for(const [alias,ref]of Object.entries(declared?.secrets??{}))secrets[alias]={secret:ref.secret};
|
|
108
108
|
const inventory=chain?.describe??{};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { gunzipSync } from 'node:zlib';
|
|
3
|
-
import { lstat, mkdir, readFile,
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
4
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
5
|
import { ConfigError, assert } from './errors.js';
|
|
6
|
+
import { isRecord as record, digestHex as digest, textField, exactKeys as sharedExactKeys, listCachedFiles, writeLockAtomic, createGithubTransport, verifiedReleaseAsset, } from './extension-transport.js';
|
|
8
7
|
|
|
9
8
|
/** Offline, declarative extension bundles. These are deliberately not Node packages. */
|
|
10
9
|
export const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
|
|
@@ -19,11 +18,8 @@ const tag = /^extensions@v[0-9][0-9A-Za-z._-]{0,100}$/;
|
|
|
19
18
|
|
|
20
19
|
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
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`); }
|
|
21
|
+
function text(value , what ) { return textField(value, `${what} in extension artifact metadata`); }
|
|
22
|
+
function exactKeys(value , expected , what ) { sharedExactKeys(value, expected, what); }
|
|
27
23
|
|
|
28
24
|
/** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
|
|
29
25
|
export function parseCatalog(bytes , requestedTag ) {
|
|
@@ -72,8 +68,7 @@ export function readBoundedTgz(source , limits )
|
|
|
72
68
|
return files;
|
|
73
69
|
}
|
|
74
70
|
function readTgz(source ) { return readBoundedTgz(source,{archive:MAX_ARCHIVE,expanded:MAX_EXPANDED,files:MAX_FILES,file:MAX_FILE,label:'Extension archive'}); }
|
|
75
|
-
async function
|
|
76
|
-
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`); }
|
|
71
|
+
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 listCachedFiles(root,'extension'))===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`); }
|
|
77
72
|
function validateFiles(files , entry ) {
|
|
78
73
|
const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
|
|
79
74
|
assert(files.length>0 && files.every(file=>allowed.test(file.path)),'Extension artifact contains a file type that is not declarative data');
|
|
@@ -89,25 +84,17 @@ export async function extractArtifact(bytes , entry , des
|
|
|
89
84
|
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}); }
|
|
90
85
|
}
|
|
91
86
|
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}; }
|
|
92
|
-
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
|
|
87
|
+
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 writeLockAtomic(path,temporary,lock); }
|
|
93
88
|
export function cachePath(project , sha256 ) { assert(hex.test(sha256),'Invalid extension digest'); return join(project,'.urlcode','extensions',sha256); }
|
|
94
89
|
|
|
95
|
-
|
|
90
|
+
|
|
96
91
|
|
|
97
|
-
function releaseUrl(tagName ) { return `https://api.github.com/repos/${ARTIFACT_REPOSITORY}/releases/tags/${encodeURIComponent(tagName)}`; }
|
|
98
|
-
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; }
|
|
99
|
-
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'); }
|
|
100
92
|
/** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
|
|
101
|
-
export const githubTransport ={
|
|
102
|
-
|
|
103
|
-
async download(url) { return githubDownload(url); },
|
|
104
|
-
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'))); }); },
|
|
105
|
-
};
|
|
106
|
-
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}); } }
|
|
107
|
-
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}; }
|
|
93
|
+
export const githubTransport =createGithubTransport({repository:ARTIFACT_REPOSITORY,workflow:ARTIFACT_WORKFLOW,tagPattern:tag,exampleTag:'extensions@v1.0.0',maxAssetSize:MAX_ARCHIVE,itemLabel:'extension artifact'});
|
|
94
|
+
export async function resolveCatalog(release , transport =githubTransport) { const assets=await transport.release(release); const bytes=await verifiedReleaseAsset(assets,'extensions-catalog.json',release,transport,'extension artifact','urlcode-attest'); return {catalog:parseCatalog(bytes,release),assets}; }
|
|
108
95
|
export async function installArtifact(project , release , artifactName , transport =githubTransport) {
|
|
109
96
|
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'}`);
|
|
110
|
-
const bytes=await
|
|
97
|
+
const bytes=await verifiedReleaseAsset(assets,entry.asset,release,transport,'extension artifact','urlcode-attest'); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
|
|
111
98
|
let prior ; try { prior=await readLock(project); } catch { /* first install */ }
|
|
112
99
|
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;
|
|
113
100
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
2
|
import { tmpdir } from 'node:os';
|
|
5
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
4
|
import { pathToFileURL } from 'node:url';
|
|
7
5
|
import { ConfigError, assert } from './errors.js';
|
|
8
6
|
import { readBoundedTgz, } from './extension-artifacts.js';
|
|
7
|
+
import { isRecord as record, digestHex as digest, textField, exactKeys as sharedExactKeys, listCachedFiles, writeLockAtomic, createGithubTransport, verifiedReleaseAsset, } from './extension-transport.js';
|
|
9
8
|
|
|
10
9
|
/** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
|
|
11
10
|
export const BUNDLE_REPOSITORY='jimhoyd-com/urlcode';
|
|
@@ -17,11 +16,8 @@ const hex=/^[a-f0-9]{64}$/;
|
|
|
17
16
|
const entry=/^node_modules\/@jimhoyd\/urlcode-[a-z][a-z0-9-]{0,63}\/dist\/[A-Za-z0-9._/-]+\.js$/;
|
|
18
17
|
const safeEntry=(value ) =>entry.test(value)&&value.split('/').every(segment=>segment!=='.'&&segment!=='..');
|
|
19
18
|
const limits={archive:128*1024*1024,expanded:512*1024*1024,files:12000,file:32*1024*1024,label:'Extension bundle archive'};
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
|
|
23
|
-
const text=(value ,what ) =>{assert(typeof value==='string'&&value.length>0&&value.length<256,`Invalid ${what} in extension bundle metadata`);return value;};
|
|
24
|
-
const exact=(value ,keys ,what ) =>assert(JSON.stringify(Object.keys(value).sort())===JSON.stringify([...keys].sort()),`${what} has unknown or missing fields`);
|
|
19
|
+
const text=(value ,what ) =>textField(value,`${what} in extension bundle metadata`);
|
|
20
|
+
const exact=(value ,keys ,what ) =>sharedExactKeys(value,keys,what);
|
|
25
21
|
|
|
26
22
|
|
|
27
23
|
|
|
@@ -53,17 +49,13 @@ function bundleManifest(files ) {
|
|
|
53
49
|
function validateFiles(files ,item ,coreVersion ) {
|
|
54
50
|
assert(files.length>1&&files.every(file=>file.path==='bundle.json'||file.path.startsWith('node_modules/')),'Extension bundle contains a file outside its frozen module tree');const manifest=bundleManifest(files);assert(manifest.coreVersion===coreVersion,'Extension bundle core version does not match its signed catalog');assert(manifest.bundles.some(value=>value.name===item.name&&value.version===item.version&&value.entry===item.entry),'Extension bundle manifest does not match its signed catalog entry');assert(files.some(file=>file.path===item.entry),'Extension bundle is missing its declared entry module');
|
|
55
51
|
}
|
|
56
|
-
async function
|
|
57
|
-
async function validateCached(root ,item ) {const archive=await readFile(join(root,'.bundle.tgz'));assert(digest(archive)===item.sha256,`Cached extension bundle ${item.name} does not match its lockfile`);const files=readBoundedTgz(archive,limits);validateFiles(files,item,item.coreVersion);const expected=['.bundle.tgz',...files.map(file=>file.path)].sort();assert(JSON.stringify(await diskFiles(root))===JSON.stringify(expected),`Cached extension bundle ${item.name} has unexpected files`);for(const file of files)assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension bundle ${item.name} was modified`);}
|
|
52
|
+
async function validateCached(root ,item ) {const archive=await readFile(join(root,'.bundle.tgz'));assert(digest(archive)===item.sha256,`Cached extension bundle ${item.name} does not match its lockfile`);const files=readBoundedTgz(archive,limits);validateFiles(files,item,item.coreVersion);const expected=['.bundle.tgz',...files.map(file=>file.path)].sort();assert(JSON.stringify(await listCachedFiles(root,'extension bundle'))===JSON.stringify(expected),`Cached extension bundle ${item.name} has unexpected files`);for(const file of files)assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension bundle ${item.name} was modified`);}
|
|
58
53
|
export async function extractBundle(bytes ,item ,destination ) {assert(digest(bytes)===item.sha256,`Extension bundle ${item.name} does not match its signed SHA-256`);const files=readBoundedTgz(bytes,limits);validateFiles(files,item,item.coreVersion);const root=resolve(destination),temporary=join(tmpdir(),`urlcode-extension-bundle-${process.pid}-${Math.random().toString(16).slice(2)}`);await mkdir(temporary,{recursive:true});try{for(const file of files){const target=resolve(temporary,file.path),rel=relative(temporary,target);assert(rel&&!rel.startsWith('..'),'Unsafe extension bundle path');await mkdir(dirname(target),{recursive:true});await writeFile(target,file.bytes,{flag:'wx'});}await writeFile(join(temporary,'.bundle.tgz'),bytes,{flag:'wx'});await mkdir(dirname(root),{recursive:true});try{await rename(temporary,root);}catch{try{await validateCached(root,item );return;}catch{throw new ConfigError(`Extension bundle cache entry ${item.sha256} already exists but is not identical`);}}}finally{await rm(temporary,{recursive:true,force:true});}}
|
|
59
54
|
export function bundleCachePath(project ,sha256 ) {assert(hex.test(sha256),'Invalid extension bundle digest');return join(project,'.urlcode','extension-bundles',sha256);}
|
|
60
55
|
export async function readBundleLock(project ) {let raw ;try{const path=join(project,'urlcode.extension-bundles.lock.json'),info=await lstat(path);assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension bundle 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 bundle lockfile; install a bundle first');}assert(record(raw)&&raw.format===1&&Array.isArray(raw.bundles),'Invalid extension bundle lockfile');exact(raw,['format','bundles'],'Extension bundle lockfile');const seen=new Set (),bundles=raw.bundles.map(value=>{assert(record(value)&&record(value.catalog),'Invalid extension bundle lock entry');exact(value,['name','version','asset','sha256','entry','catalog','coreVersion'],'Extension bundle lock entry');exact(value.catalog,['tag','commit'],'Extension bundle lock catalog');const item={...parseEntry(value,'extension bundle lock entry',false),catalog:{tag:text(value.catalog.tag,'lock tag'),commit:text(value.catalog.commit,'lock commit')},coreVersion:text(value.coreVersion,'lock core version')};assert(tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&version.test(item.coreVersion)&&!seen.has(item.name),'Invalid or duplicate extension bundle lock entry');seen.add(item.name);return item;});return {format:1,bundles};}
|
|
61
|
-
async function writeLock(project ,lock ) {const path=join(project,'urlcode.extension-bundles.lock.json'),temporary=join(project,`.urlcode.extension-bundles.lock.${process.pid}.${Math.random().toString(16).slice(2)}`);await
|
|
62
|
-
|
|
63
|
-
function
|
|
64
|
-
async function download(value ) {let url=downloadUrl(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 bundle release asset has an invalid redirect');url=downloadUrl(new URL(location,url).href);continue;}assert(response.ok&&response.body,'Could not download extension bundle release asset');const chunks =[];let size=0;for await(const chunk of response.body){size+=chunk.byteLength;assert(size<=limits.archive,'Extension bundle release asset exceeds the size limit');chunks.push(chunk);}const result=new Uint8Array(size);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.byteLength;}return result;}throw new ConfigError('Extension bundle release asset redirected too many times');}
|
|
65
|
-
export const githubBundleTransport ={async release(value){assert(tag.test(value),'Use an immutable extension bundle release tag such as extension-bundles@v1.0.0');const response=await fetch(releaseUrl(value),{headers:{accept:'application/vnd.github+json'}});assert(response.ok,`Could not fetch extension bundle release ${value}`);const raw =await response.json();assert(record(raw)&&Array.isArray(raw.assets),'Extension bundle release has no asset inventory');const seen=new Set ();return raw.assets.map(asset=>{assert(record(asset)&&typeof asset.name==='string'&&typeof asset.browser_download_url==='string'&&!seen.has(asset.name),'Invalid or duplicate extension bundle release asset');seen.add(asset.name);const url=new URL(asset.browser_download_url);assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${BUNDLE_REPOSITORY}/releases/download/`),'Extension bundle release asset is not a GitHub download');return {name:asset.name,url:url.href};});},download,async attest(path,release){assert(tag.test(release),'Invalid extension bundle release tag');await new Promise ((accept,reject)=>{const child=spawn('gh',['attestation','verify',path,'--repo',BUNDLE_REPOSITORY,'--signer-workflow',BUNDLE_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 bundles')));child.on('exit',code=>code===0?accept():reject(new ConfigError('GitHub attestation verification refused the extension bundle')));});}};
|
|
66
|
-
async function verified(assets ,asset ,release ,transport ) {const found=assets.filter(value=>value.name===asset);assert(found.length===1,`Extension bundle release is missing or repeats ${asset}`);const bytes=await transport.download(found[0] .url),temporary=join(tmpdir(),`urlcode-bundle-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});}}
|
|
56
|
+
async function writeLock(project ,lock ) {const path=join(project,'urlcode.extension-bundles.lock.json'),temporary=join(project,`.urlcode.extension-bundles.lock.${process.pid}.${Math.random().toString(16).slice(2)}`);await writeLockAtomic(path,temporary,lock);}
|
|
57
|
+
export const githubBundleTransport =createGithubTransport({repository:BUNDLE_REPOSITORY,workflow:BUNDLE_WORKFLOW,tagPattern:tag,exampleTag:'extension-bundles@v1.0.0',maxAssetSize:limits.archive,itemLabel:'extension bundle'});
|
|
58
|
+
async function verified(assets ,asset ,release ,transport ) {return verifiedReleaseAsset(assets,asset,release,transport,'extension bundle','urlcode-bundle-attest');}
|
|
67
59
|
export async function installBundle(project ,release ,bundleName ,transport =githubBundleTransport) {assert(name.test(bundleName),'Invalid extension bundle name');const assets=await transport.release(release),catalog=parseBundleCatalog(await verified(assets,'extension-bundles-catalog.json',release,transport),release),item=catalog.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not in the signed catalog`);const revoked=catalog.revoked.find(value=>value.sha256===item.sha256);assert(!revoked,`Extension bundle ${bundleName} is revoked: ${revoked?.reason??'unknown reason'}`);const locked ={...item,catalog:{tag:catalog.tag,commit:catalog.commit},coreVersion:catalog.coreVersion};await extractBundle(await verified(assets,item.asset,release,transport),locked,bundleCachePath(project,item.sha256));let prior ;try{prior=await readBundleLock(project);}catch{/* first install */}const bundles=(prior?.bundles??[]).filter(value=>value.name!==item.name);bundles.push(locked);bundles.sort((left,right)=>left.name.localeCompare(right.name));const lock={format:1 ,bundles};await writeLock(project,lock);return lock;}
|
|
68
60
|
async function runningCoreVersion() {const raw =JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));assert(record(raw)&&typeof raw.version==='string'&&version.test(raw.version),'Could not read the running core version');return raw.version;}
|
|
69
61
|
/** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
|