@jimhoyd/urlcode 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/urlcode-authoring/SKILL.md +8 -0
- package/README.md +9 -3
- package/dist/BUILD-MANIFEST.json +23 -21
- package/dist/agents-guide.js +9 -5
- package/dist/authoring.js +36 -3
- package/dist/build-cloudflare.js +5 -2
- package/dist/build-static.js +1 -0
- package/dist/cli.js +47 -15
- package/dist/cloudflare.js +6 -3
- package/dist/config.js +7 -1
- package/dist/context.js +99 -1
- package/dist/extension-artifacts.js +147 -0
- package/dist/extension-bundles.js +70 -0
- package/dist/extensions.js +4 -0
- package/dist/functions.js +2 -1
- package/dist/index.js +4 -2
- package/dist/init-with.js +55 -23
- package/dist/interchange.js +1 -1
- package/dist/match.js +23 -5
- package/dist/mcp.js +11 -4
- package/dist/readiness.js +2 -2
- package/dist/router.js +20 -8
- package/dist/runtime.js +1 -0
- package/dist/site.js +19 -1
- package/dist/tooling.js +2 -2
- package/dist/types/agents-guide.d.ts +6 -1
- package/dist/types/authoring.d.ts +1 -1
- package/dist/types/cloudflare.d.ts +1 -0
- package/dist/types/context.d.ts +56 -0
- package/dist/types/extension-artifacts.d.ts +87 -0
- package/dist/types/extension-bundles.d.ts +49 -0
- package/dist/types/extensions.d.ts +4 -0
- package/dist/types/functions.d.ts +4 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/init-with.d.ts +6 -1
- package/dist/types/match.d.ts +1 -0
- package/dist/types/site.d.ts +2 -0
- package/dist/types/tooling.d.ts +2 -2
- package/dist/types/types.d.ts +1 -0
- package/dist/types.js +1 -1
- package/llms-full.txt +188 -24
- package/llms.txt +63 -107
- package/package.json +11 -3
- package/recipes/redirect/README.md +19 -5
- package/recipes/redirect/recipe.yaml +12 -10
- package/recipes/redirect/tests/requests.json +22 -0
- package/recipes/redirect/urlcode.yaml +11 -1
- package/skills/urlcode/SKILL.md +2 -0
- package/starters/default/AGENTS.md +3 -3
package/dist/context.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {readFile} from 'node:fs/promises';
|
|
2
|
-
import {relative} from 'node:path';
|
|
2
|
+
import {join,relative} from 'node:path';
|
|
3
3
|
import {stringify} from 'yaml';
|
|
4
4
|
import {loadDocument} from './config.js';
|
|
5
5
|
import {applySite} from './site.js';
|
|
@@ -151,3 +151,101 @@ function fitBudget(context ,budget ) {
|
|
|
151
151
|
export async function documentationTokens() {
|
|
152
152
|
return Math.ceil((await readFile(new URL('../llms-full.txt',import.meta.url),'utf8')).length/4);
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/** Tasks `--task` / MCP `get_context` accept. Each is fixed guidance plus the project's own facts for that task. */
|
|
156
|
+
export const contextTasks=['redirects'] ;
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
const idParam=(name )=>({name,in:'path',required:true,schema:{type:'string',minLength:1,maxLength:64}});
|
|
169
|
+
/** Established by running `urlcode validate` and `urlcode test` on each shape; test/context.test.ts compiles every `yaml` entry so this cannot drift from the runtime. */
|
|
170
|
+
export const redirectShapes =[
|
|
171
|
+
{need:'fixed',support:'supported',yaml:{routes:{'/old':{redirect:{url:'https://example.com/new',status:301}}}},note:'status defaults to 302; allowed 301, 302, 303, 307, 308. Only GET/HEAD match unless methods is set.'},
|
|
172
|
+
{need:'parameterized path (/users/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/users/{id}':{parameters:[idParam('id')],redirect:{url:'https://example.com/profiles/{id}',status:308}}}},note:'{name} placeholders only in the destination path, each naming a declared path parameter; the value is encoded as one component.'},
|
|
173
|
+
{need:'root-relative destination (/people/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/people/{id}':{parameters:[idParam('id')],redirect:{url:'/profiles/{id}'}}}},note:'A single leading slash keeps the redirect on this site; Location is the path. {name} placeholders as above. `//host`, dot segments and a scheme-less host are refused.'},
|
|
174
|
+
{need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'supported',yaml:{routes:{'/legacy/**':{redirect:{url:'https://example.com/modern/{**}'}}}},note:'Route key ends in a terminal `/**` (literal prefix required). `{**}` is the remaining segments, each encoded, at most once and only in the path; one or more segments, so /legacy and /legacy/ are 404. Exact and {param} routes win over it. Redirect only; refused on static and Cloudflare targets. Works with a relative destination too.'},
|
|
175
|
+
{need:'query-string preservation',support:'supported',yaml:{routes:{'/search':{parameters:[{name:'q',in:'query',schema:{type:'string',maxLength:100}}],redirect:{url:'https://example.com/find',query:{pass:['q','utm_source']}}}}},note:'Nothing is forwarded by default; pass is an explicit allowlist (pass: true is refused by the schema); query.map renames or maps declared inputs.'},
|
|
176
|
+
{need:'method-preserving redirect',support:'supported',yaml:{routes:{'/form':{methods:['GET','POST'],redirect:{url:'https://example.com/form2',status:307}}}},note:'Default methods GET/HEAD; other methods answer 405. Use 307/308 to keep the method and body.'},
|
|
177
|
+
{need:'404 for unmatched paths',support:'supported',yaml:{site:{notFound:'404.html'}},note:'Unmatched GET/HEAD answer 404 (plain without site.notFound; that .html file, still status 404, with it). Trailing slashes are not normalized: /old/ is a 404 unless declared as its own route.'},
|
|
178
|
+
{need:'host or scheme chosen from the request',support:'gap',note:'A destination is either a literal absolute http(s) URL or a root-relative path: "//h/x" and other schemes fail ("Redirect URL must be an absolute HTTP(S) URL or a root-relative path" / "Redirect must use HTTP(S) without credentials"); {param} in the host or query fails with "Redirect placeholders are allowed only in path segments". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
|
|
179
|
+
{need:'redirect loop detection',support:'gap',note:'Validation accepts a route that redirects to its own URL; nothing detects cycles. Write a fixture with expectHeaders location for each redirect and review chains by hand.'},
|
|
180
|
+
];
|
|
181
|
+
/** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
/** Merges every supported shape's YAML; test/context-task.test.ts compiles the result, so it cannot drift from the runtime. */
|
|
191
|
+
export function redirectStarter() {
|
|
192
|
+
const document ={version:'1',routes:{}};
|
|
193
|
+
for(const shape of redirectShapes) {
|
|
194
|
+
if(shape.support!=='supported'||!shape.yaml)continue;
|
|
195
|
+
const {routes,site}=shape.yaml ;
|
|
196
|
+
Object.assign(document.routes,routes);
|
|
197
|
+
if(site)document.site={...document.site,...site};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
file:'urlcode.yaml',
|
|
201
|
+
yaml:stringify(document,{lineWidth:0,aliasDuplicateObjects:false}),
|
|
202
|
+
companions:{'404.html':'<!doctype html><title>Not found</title><h1>404</h1>\n'},
|
|
203
|
+
packageScripts:{start:'urlcode serve --project . --host 0.0.0.0 --port ${PORT:-3000}'},
|
|
204
|
+
note:'Delete the routes you do not need and adjust the rest. `npm start` honors PORT. Shapes marked gap above are not in this file; do not add them.',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
export function renderTaskContext(context ) {return stringify(context,{lineWidth:0,aliasDuplicateObjects:false,flowCollectionPadding:false});}
|
|
217
|
+
/**
|
|
218
|
+
* One bounded call for a task: fixed guidance plus this project's facts for that task. Same compiler as buildContext;
|
|
219
|
+
* a directory without urlcode.yaml still gets the guidance, any other load failure propagates.
|
|
220
|
+
*/
|
|
221
|
+
export async function buildTaskContext(project ,task ,options ={}) {
|
|
222
|
+
if(!(contextTasks ).includes(task))throw new Error(`Unknown context task; use one of: ${contextTasks.join(', ')}`);
|
|
223
|
+
const budget=options.budget;
|
|
224
|
+
if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
|
|
225
|
+
const flag=options.projectFlag??project;
|
|
226
|
+
const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape})),starter:redirectStarter()};
|
|
227
|
+
const exists=await readFile(join(project,'urlcode.yaml')).then(()=>true,()=>false);
|
|
228
|
+
if(exists) {
|
|
229
|
+
const host=await loadOperatorHost(options.hostFile,project);
|
|
230
|
+
try {
|
|
231
|
+
const {loaded,compiled,routes}=await compile(project);
|
|
232
|
+
context.project={entry:'urlcode.yaml',routes:compiled.count,redirects:routes.filter(route=>route.redirect).map(route=>({path:route.pattern,status:route.redirect .status??302,url:route.redirect .url})).sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0).slice(0,20),site:sorted(Object.keys(loaded.document.site??{}))};
|
|
233
|
+
} finally {await host.close?.();}
|
|
234
|
+
}
|
|
235
|
+
context.recipe='urlcode recipes show redirect';
|
|
236
|
+
context.commands={validate:`urlcode validate --local --project ${flag}`,test:`urlcode test --project ${flag}`,audit:`urlcode audit --project ${flag} --expect-routes ${context.project?context.project.routes:'N'}`,schema:'urlcode schema redirect'};
|
|
237
|
+
if(budget===undefined)return context;
|
|
238
|
+
// Fixed order, like fitBudget: this project's facts, then commands, then the notes, then the shapes.
|
|
239
|
+
const omitted =[];
|
|
240
|
+
const fits=()=>estimateTokens(renderTaskContext(omitted.length?{...context,omitted}:context))<=budget;
|
|
241
|
+
const steps =[
|
|
242
|
+
['project',()=>{delete context.project;}],
|
|
243
|
+
['commands',()=>{delete context.commands;delete context.recipe;}],
|
|
244
|
+
['starter',()=>{delete context.starter;}],
|
|
245
|
+
['notes',()=>{context.shapes=context.shapes .map(({need,support,yaml})=>({need,support,...(yaml?{yaml}:{})}));}],
|
|
246
|
+
['shapes',()=>{delete context.shapes;}],
|
|
247
|
+
];
|
|
248
|
+
for(const [name,drop] of steps) {if(fits())break;drop();omitted.push(name);}
|
|
249
|
+
if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
|
|
250
|
+
return omitted.length?{...context,omitted}:context;
|
|
251
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gunzipSync } from 'node:zlib';
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { ConfigError, assert } from './errors.js';
|
|
8
|
+
|
|
9
|
+
/** Offline, declarative extension bundles. These are deliberately not Node packages. */
|
|
10
|
+
export const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
|
|
11
|
+
export const ARTIFACT_WORKFLOW = 'jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml';
|
|
12
|
+
const MAX_ARCHIVE = 16 * 1024 * 1024, MAX_EXPANDED = 32 * 1024 * 1024, MAX_FILES = 128, MAX_FILE = 2 * 1024 * 1024;
|
|
13
|
+
const MAX_TOOL_FILE = 512 * 1024;
|
|
14
|
+
const hex = /^[a-f0-9]{64}$/;
|
|
15
|
+
const name = /^[a-z][a-z0-9-]{0,63}$/;
|
|
16
|
+
const tag = /^extensions@v[0-9][0-9A-Za-z._-]{0,100}$/;
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const record=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
|
|
24
|
+
const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
|
|
25
|
+
function text(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what} in extension artifact metadata`); return value; }
|
|
26
|
+
function exactKeys(value , expected , what ) { const actual=Object.keys(value).sort(); assert(JSON.stringify(actual)===JSON.stringify([...expected].sort()),`${what} has unknown or missing fields`); }
|
|
27
|
+
|
|
28
|
+
/** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
|
|
29
|
+
export function parseCatalog(bytes , requestedTag ) {
|
|
30
|
+
let raw ; try { raw=JSON.parse(new TextDecoder().decode(bytes)); } catch { throw new ConfigError('Extension catalog is not valid JSON'); }
|
|
31
|
+
assert(record(raw) && raw.format===1,'Unsupported extension catalog format');
|
|
32
|
+
exactKeys(raw,['format','tag','commit','artifacts','revoked'],'Extension catalog');
|
|
33
|
+
const catalogTag=text(raw.tag,'catalog tag'), commit=text(raw.commit,'catalog commit');
|
|
34
|
+
assert(tag.test(catalogTag) && catalogTag===requestedTag,'Extension catalog tag does not match the immutable requested release');
|
|
35
|
+
assert(/^[a-f0-9]{40}$/.test(commit),'Extension catalog has an invalid commit pin');
|
|
36
|
+
assert(Array.isArray(raw.artifacts) && Array.isArray(raw.revoked),'Extension catalog is incomplete');
|
|
37
|
+
const seen=new Set (), assets=new Set (), digests=new Set (), artifacts =[];
|
|
38
|
+
for(const value of raw.artifacts) {
|
|
39
|
+
assert(record(value),'Invalid extension catalog artifact');
|
|
40
|
+
exactKeys(value,['name','version','asset','sha256','kind'],'Extension catalog artifact');
|
|
41
|
+
const item ={name:text(value.name,'artifact name'),version:text(value.version,'artifact version'),asset:text(value.asset,'artifact asset'),sha256:text(value.sha256,'artifact sha256'),kind:value.kind==='declarative'?'declarative':(() => { throw new ConfigError('Extension catalog permits declarative artifacts only'); })()};
|
|
42
|
+
assert(name.test(item.name) && /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(item.version) && /^[A-Za-z0-9._-]+\.tgz$/.test(item.asset) && hex.test(item.sha256),'Invalid extension catalog artifact');
|
|
43
|
+
assert(!seen.has(item.name),`Extension catalog names ${item.name} more than once`); assert(!assets.has(item.asset)&&!digests.has(item.sha256),'Extension catalog repeats an artifact asset or digest'); seen.add(item.name); assets.add(item.asset); digests.add(item.sha256); artifacts.push(item);
|
|
44
|
+
}
|
|
45
|
+
const revoked =[], revokedDigests=new Set ();
|
|
46
|
+
for(const value of raw.revoked) { assert(record(value),'Invalid extension revocation'); exactKeys(value,['sha256','reason'],'Extension revocation'); const sha256=text(value.sha256,'revocation sha256'), reason=text(value.reason,'revocation reason'); assert(hex.test(sha256)&&!revokedDigests.has(sha256),'Invalid or duplicate extension revocation'); revokedDigests.add(sha256); revoked.push({sha256,reason}); }
|
|
47
|
+
return {format:1,tag:catalogTag,commit,artifacts,revoked};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
function octal(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,'').trim(); assert(/^[0-7]*$/.test(value),'Malformed extension archive'); return value ? Number.parseInt(value,8) : 0; }
|
|
53
|
+
function archivePath(name ,prefix ) {
|
|
54
|
+
const decode=(bytes )=>new TextDecoder().decode(bytes).replace(/\0.*$/,'');
|
|
55
|
+
const base=decode(name), directory=decode(prefix), value=directory?`${directory}/${base}`:base;
|
|
56
|
+
assert(base.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path');
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/** A minimal tar reader: only regular files are accepted, before any write occurs. */
|
|
60
|
+
export function readBoundedTgz(source , limits ) {
|
|
61
|
+
assert(source.byteLength>0 && source.byteLength<=limits.archive,`${limits.label} exceeds the size limit`);
|
|
62
|
+
let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:limits.expanded}); } catch { throw new ConfigError(`${limits.label} is not a valid bounded gzip tarball`); }
|
|
63
|
+
const files =[]; let ended=false;
|
|
64
|
+
for(let at=0;at<bytes.length;) {
|
|
65
|
+
const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),`Malformed ${limits.label} terminator`); ended=true; break; }
|
|
66
|
+
assert(header.length===512,`Truncated ${limits.label}`); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,`${limits.label} has an invalid tar checksum`); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
|
|
67
|
+
assert(type===0 || type===48,`${limits.label} may contain regular files only`); assert(size<=limits.file && at+512+size<=bytes.length,`Invalid ${limits.label} member`);
|
|
68
|
+
const path=archivePath(header.subarray(0,100),header.subarray(345,500)); assert(!files.some(file=>file.path===path),`${limits.label} repeats a path`);
|
|
69
|
+
files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=limits.files,`${limits.label} has too many files`); at+=512+Math.ceil(size/512)*512;
|
|
70
|
+
}
|
|
71
|
+
assert(ended,`${limits.label} has no complete tar terminator`);
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
function readTgz(source ) { return readBoundedTgz(source,{archive:MAX_ARCHIVE,expanded:MAX_EXPANDED,files:MAX_FILES,file:MAX_FILE,label:'Extension archive'}); }
|
|
75
|
+
async function diskFiles(root ,prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),'Extension cache contains a link or special file'); if(item.isDirectory()) found.push(...await diskFiles(root,path)); else found.push(path); } return found.sort(); }
|
|
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`); }
|
|
77
|
+
function validateFiles(files , entry ) {
|
|
78
|
+
const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
|
|
79
|
+
assert(files.length>0 && files.every(file=>allowed.test(file.path)),'Extension artifact contains a file type that is not declarative data');
|
|
80
|
+
for(const file of files) if(file.path.endsWith('.json')) try { JSON.parse(new TextDecoder().decode(file.bytes)); } catch { throw new ConfigError(`Extension artifact contains invalid JSON in ${file.path}`); }
|
|
81
|
+
const manifest=files.find(file=>file.path==='extension.json'); assert(manifest,'Extension artifact is missing extension.json');
|
|
82
|
+
let raw ; try { raw=JSON.parse(new TextDecoder().decode(manifest.bytes)); } catch { throw new ConfigError('extension.json is not valid JSON'); }
|
|
83
|
+
assert(record(raw),'extension.json must be an object'); exactKeys(raw,['format','kind','name','version'],'extension.json');
|
|
84
|
+
assert(record(raw) && raw.format===1 && raw.kind==='declarative' && raw.name===entry.name && raw.version===entry.version,'extension.json does not match its signed catalog entry');
|
|
85
|
+
}
|
|
86
|
+
export async function extractArtifact(bytes , entry , destination ) {
|
|
87
|
+
assert(digest(bytes)===entry.sha256,`Extension artifact ${entry.name} does not match its signed SHA-256`); const files=readTgz(bytes); validateFiles(files,entry);
|
|
88
|
+
const root=resolve(destination), temporary=join(tmpdir(),`urlcode-extension-${process.pid}-${Math.random().toString(16).slice(2)}`); await mkdir(temporary,{recursive:true});
|
|
89
|
+
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
|
+
}
|
|
91
|
+
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 writeFile(temporary,JSON.stringify(lock,null,2)+'\n',{flag:'wx'}); try { await rename(temporary,path); } finally { await rm(temporary,{force:true}); } }
|
|
93
|
+
export function cachePath(project , sha256 ) { assert(hex.test(sha256),'Invalid extension digest'); return join(project,'.urlcode','extensions',sha256); }
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
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
|
+
/** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
|
|
101
|
+
export const githubTransport ={
|
|
102
|
+
async release(tagName) { assert(tag.test(tagName),'Use an immutable extension release tag such as extensions@v1.0.0'); const response=await fetch(releaseUrl(tagName),{headers:{accept:'application/vnd.github+json'}}); assert(response.ok,`Could not fetch extension release ${tagName}`); const raw =await response.json(); assert(record(raw)&&Array.isArray(raw.assets),'Extension release has no asset inventory'); const seen=new Set (); return raw.assets.map(item=>{ assert(record(item)&&typeof item.name==='string'&&typeof item.browser_download_url==='string'&&!seen.has(item.name),'Invalid or duplicate extension release asset'); seen.add(item.name); const url=new URL(item.browser_download_url); assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${ARTIFACT_REPOSITORY}/releases/download/`),'Extension release asset is not a GitHub download'); return {name:item.name,url:url.href}; }); },
|
|
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}; }
|
|
108
|
+
export async function installArtifact(project , release , artifactName , transport =githubTransport) {
|
|
109
|
+
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 verifiedAsset(assets,entry.asset,release,transport); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
|
|
111
|
+
let prior ; try { prior=await readLock(project); } catch { /* first install */ }
|
|
112
|
+
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
|
+
}
|
|
114
|
+
export async function inspectArtifacts(project ) { const lock=await readLock(project), cached =[], missing =[], invalid =[]; for(const item of lock.artifacts) { const root=cachePath(project,item.sha256); try { await validateCached(root,item); cached.push(item.name); } catch { try { await lstat(root); invalid.push(item.name); } catch { missing.push(item.name); } } } return {lock,cached,missing,invalid}; }
|
|
115
|
+
|
|
116
|
+
/** Read-only inventory for authoring tools. Paths come from the verified archive, never from an arbitrary filesystem argument. */
|
|
117
|
+
export async function describeArtifactCache(project ) {
|
|
118
|
+
const report=await inspectArtifacts(project), cached=new Set(report.cached), missing=new Set(report.missing);
|
|
119
|
+
const artifacts=[];
|
|
120
|
+
for(const item of report.lock.artifacts) {
|
|
121
|
+
const status =cached.has(item.name)?'cached':missing.has(item.name)?'missing':'invalid';
|
|
122
|
+
let files =[];
|
|
123
|
+
if(status==='cached') {
|
|
124
|
+
const archive=await readFile(join(cachePath(project,item.sha256),'.artifact.tgz'));
|
|
125
|
+
assert(digest(archive)===item.sha256,`Cached extension artifact ${item.name} does not match its lockfile`);
|
|
126
|
+
const members=readTgz(archive); validateFiles(members,item); files=members.map(file=>file.path).sort();
|
|
127
|
+
}
|
|
128
|
+
artifacts.push({...item,status,files});
|
|
129
|
+
}
|
|
130
|
+
return {format:1,artifacts};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Return one bounded text/JSON member from a verified cached artifact for MCP/agent consumers. */
|
|
134
|
+
export async function readArtifactMember(project ,artifactName ,path ) {
|
|
135
|
+
assert(name.test(artifactName),'Invalid extension artifact name');
|
|
136
|
+
const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
|
|
137
|
+
assert(allowed.test(path),'Invalid extension artifact member path');
|
|
138
|
+
const lock=await readLock(project), artifact=lock.artifacts.find(item=>item.name===artifactName);
|
|
139
|
+
assert(artifact,`Extension artifact ${artifactName} is not locked`);
|
|
140
|
+
const root=cachePath(project,artifact.sha256); await validateCached(root,artifact);
|
|
141
|
+
const archive=await readFile(join(root,'.artifact.tgz'));
|
|
142
|
+
assert(digest(archive)===artifact.sha256,`Cached extension artifact ${artifact.name} does not match its lockfile`);
|
|
143
|
+
const files=readTgz(archive); validateFiles(files,artifact); const member=files.find(file=>file.path===path);
|
|
144
|
+
assert(member,`Extension artifact ${artifactName} has no ${path}`); assert(member.bytes.byteLength<=MAX_TOOL_FILE,'Extension artifact member exceeds the tooling output limit');
|
|
145
|
+
let textValue ; try { textValue=new TextDecoder('utf-8',{fatal:true}).decode(member.bytes); } catch { throw new ConfigError(`Extension artifact ${path} is not UTF-8 text`); }
|
|
146
|
+
const json=path.endsWith('.json'); return {format:1,artifact,path,mediaType:json?'application/json':'text/markdown',content:json?JSON.parse(textValue):textValue};
|
|
147
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { ConfigError, assert } from './errors.js';
|
|
8
|
+
import { readBoundedTgz, } from './extension-artifacts.js';
|
|
9
|
+
|
|
10
|
+
/** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
|
|
11
|
+
export const BUNDLE_REPOSITORY='jimhoyd-com/urlcode';
|
|
12
|
+
export const BUNDLE_WORKFLOW='jimhoyd-com/urlcode/.github/workflows/extension-bundles.yml';
|
|
13
|
+
const tag=/^extension-bundles@v[0-9][0-9A-Za-z._-]{0,100}$/;
|
|
14
|
+
const name=/^[a-z][a-z0-9-]{0,63}$/;
|
|
15
|
+
const version=/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
16
|
+
const hex=/^[a-f0-9]{64}$/;
|
|
17
|
+
const entry=/^node_modules\/@jimhoyd\/urlcode-[a-z][a-z0-9-]{0,63}\/dist\/[A-Za-z0-9._/-]+\.js$/;
|
|
18
|
+
const safeEntry=(value ) =>entry.test(value)&&value.split('/').every(segment=>segment!=='.'&&segment!=='..');
|
|
19
|
+
const limits={archive:128*1024*1024,expanded:512*1024*1024,files:12000,file:32*1024*1024,label:'Extension bundle archive'};
|
|
20
|
+
|
|
21
|
+
const record=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
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`);
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function parseEntry(value ,what ,strict=true) {
|
|
33
|
+
assert(record(value),`Invalid ${what}`); if(strict)exact(value,['name','version','asset','sha256','entry'],what);
|
|
34
|
+
const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),asset:text(value.asset,'bundle asset'),sha256:text(value.sha256,'bundle SHA-256'),entry:text(value.entry,'bundle entry')};
|
|
35
|
+
assert(name.test(item.name)&&version.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&safeEntry(item.entry),`Invalid ${what}`);
|
|
36
|
+
return item;
|
|
37
|
+
}
|
|
38
|
+
/** Parse only a catalog whose attestation was already verified against the requested immutable tag. */
|
|
39
|
+
export function parseBundleCatalog(bytes ,requested ) {
|
|
40
|
+
let raw ;try{raw=JSON.parse(new TextDecoder().decode(bytes));}catch{throw new ConfigError('Extension bundle catalog is not valid JSON');}
|
|
41
|
+
assert(record(raw)&&raw.format===1,'Unsupported extension bundle catalog format');exact(raw,['format','tag','commit','coreVersion','bundles','revoked'],'Extension bundle catalog');
|
|
42
|
+
const catalogTag=text(raw.tag,'catalog tag'),commit=text(raw.commit,'catalog commit'),coreVersion=text(raw.coreVersion,'catalog core version');
|
|
43
|
+
assert(tag.test(catalogTag)&&catalogTag===requested,'Extension bundle catalog tag does not match the immutable requested release');assert(/^[a-f0-9]{40}$/.test(commit)&&version.test(coreVersion),'Extension bundle catalog has an invalid pin');assert(Array.isArray(raw.bundles)&&Array.isArray(raw.revoked),'Extension bundle catalog is incomplete');
|
|
44
|
+
const names=new Set (),bundles=raw.bundles.map(value=>{const item=parseEntry(value,'extension bundle catalog entry');assert(!names.has(item.name),`Extension bundle catalog names ${item.name} more than once`);names.add(item.name);return item;});
|
|
45
|
+
const revoked =[],digests=new Set ();for(const value of raw.revoked){assert(record(value),'Invalid extension bundle revocation');exact(value,['sha256','reason'],'Extension bundle revocation');const sha256=text(value.sha256,'revocation SHA-256'),reason=text(value.reason,'revocation reason');assert(hex.test(sha256)&&!digests.has(sha256),'Invalid or duplicate extension bundle revocation');digests.add(sha256);revoked.push({sha256,reason});}
|
|
46
|
+
return {format:1,tag:catalogTag,commit,coreVersion,bundles,revoked};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bundleManifest(files ) {
|
|
50
|
+
const member=files.find(file=>file.path==='bundle.json');assert(member,'Extension bundle is missing bundle.json');let raw ;try{raw=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(member.bytes));}catch{throw new ConfigError('Extension bundle bundle.json is not valid UTF-8 JSON');}
|
|
51
|
+
assert(record(raw)&&raw.format===1,'Unsupported extension bundle manifest format');exact(raw,['format','coreVersion','bundles'],'Extension bundle manifest');const coreVersion=text(raw.coreVersion,'bundle core version');assert(version.test(coreVersion)&&Array.isArray(raw.bundles)&&raw.bundles.length>0,'Invalid extension bundle manifest');const seen=new Set ();const bundles=raw.bundles.map(value=>{assert(record(value),'Invalid extension bundle manifest entry');exact(value,['name','version','entry'],'Extension bundle manifest entry');const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),entry:text(value.entry,'bundle entry')};assert(name.test(item.name)&&version.test(item.version)&&safeEntry(item.entry)&&!seen.has(item.name),'Invalid or duplicate extension bundle manifest entry');seen.add(item.name);return item;});return {format:1,coreVersion,bundles};
|
|
52
|
+
}
|
|
53
|
+
function validateFiles(files ,item ,coreVersion ) {
|
|
54
|
+
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
|
+
}
|
|
56
|
+
async function diskFiles(root ,prefix='') {const found =[];for(const item of await readdir(join(root,prefix),{withFileTypes:true})){const path=prefix?`${prefix}/${item.name}`:item.name;assert(item.isDirectory()||item.isFile(),'Extension bundle cache contains a link or special file');if(item.isDirectory())found.push(...await diskFiles(root,path));else found.push(path);}return found.sort();}
|
|
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`);}
|
|
58
|
+
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
|
+
export function bundleCachePath(project ,sha256 ) {assert(hex.test(sha256),'Invalid extension bundle digest');return join(project,'.urlcode','extension-bundles',sha256);}
|
|
60
|
+
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 writeFile(temporary,JSON.stringify(lock,null,2)+'\n',{flag:'wx'});try{await rename(temporary,path);}finally{await rm(temporary,{force:true});}}
|
|
62
|
+
function releaseUrl(value ) {return `https://api.github.com/repos/${BUNDLE_REPOSITORY}/releases/tags/${encodeURIComponent(value)}`;}
|
|
63
|
+
function downloadUrl(value ) {const url=new URL(value);assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),'Extension bundle release redirect left GitHub');return url;}
|
|
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});}}
|
|
67
|
+
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
|
+
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
|
+
/** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
|
|
70
|
+
export async function loadExtensionBundle(project ,bundleName ) {assert(name.test(bundleName),'Invalid extension bundle name');const lock=await readBundleLock(project),item=lock.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not locked`);assert(item.coreVersion===await runningCoreVersion(),`Extension bundle ${bundleName} requires core ${item.coreVersion}; this runtime is incompatible`);const root=bundleCachePath(project,item.sha256);await validateCached(root,item);const module=await import(pathToFileURL(join(root,item.entry)).href);assert(module&&typeof module==='object',`Extension bundle ${bundleName} entry did not export a module`);return module ;}
|
package/dist/extensions.js
CHANGED
package/dist/functions.js
CHANGED
|
@@ -30,7 +30,8 @@ import { assert, ConfigError, HttpError } from './errors.js';
|
|
|
30
30
|
|
|
31
31
|
// The worker protocol. Only JSON-shaped data and byte buffers cross it.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/** `route.pattern` is the route key that matched, so one module can serve several routes without reading `request.url`. */
|
|
34
|
+
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
|
package/dist/index.js
CHANGED
|
@@ -21,8 +21,8 @@ export {buildTypeScriptProject} from './typescript-authoring.js';
|
|
|
21
21
|
|
|
22
22
|
export {importBulkProject} from './bulk.js';
|
|
23
23
|
|
|
24
|
-
export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens} from './tooling.js';
|
|
25
|
-
|
|
24
|
+
export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks} from './tooling.js';
|
|
25
|
+
|
|
26
26
|
export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
|
|
27
27
|
|
|
28
28
|
export {serveMcp} from './mcp.js';
|
|
@@ -44,3 +44,5 @@ export {initProject, addRedirect} from './authoring.js';
|
|
|
44
44
|
export {initProjectWith} from './init-with.js';
|
|
45
45
|
export {collectDependencySet,renderPackageManifest,installSteps} from './project-dependencies.js';
|
|
46
46
|
|
|
47
|
+
export {installBundle,loadExtensionBundle,readBundleLock,parseBundleCatalog} from './extension-bundles.js';
|
|
48
|
+
|