@yolo-labs/yolo-cli 0.23.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/README.md +57 -0
- package/dist/artifact-get.js +131 -0
- package/dist/artifact-list.js +133 -0
- package/dist/auth-context.js +60 -0
- package/dist/canonicalizer.js +208 -0
- package/dist/cli.js +1403 -0
- package/dist/context.js +58 -0
- package/dist/deploy-bundle.js +394 -0
- package/dist/deploy-cli.js +1341 -0
- package/dist/deploy-client.js +460 -0
- package/dist/deploy-config.js +301 -0
- package/dist/deploy-detect.js +498 -0
- package/dist/deploy-ship.js +389 -0
- package/dist/lockfile.js +203 -0
- package/dist/plan-diff.js +162 -0
- package/dist/plan-export.js +261 -0
- package/dist/plan-frontmatter.schema.json +184 -0
- package/dist/plan-get.js +244 -0
- package/dist/plan-import.js +420 -0
- package/dist/plan-list.js +153 -0
- package/dist/plan-open.js +100 -0
- package/dist/plan-state.js +228 -0
- package/dist/plan-validate.js +270 -0
- package/dist/revision.js +43 -0
- package/dist/run-get.js +130 -0
- package/dist/run-lifecycle.js +133 -0
- package/dist/run-list.js +148 -0
- package/dist/run-start.js +110 -0
- package/dist/run-transfer.js +128 -0
- package/dist/serve.js +227 -0
- package/dist/tileapp-developer.js +222 -0
- package/dist/tileapp-oci-assembler.js +591 -0
- package/dist/tileapp-personal.js +461 -0
- package/dist/tileapp-publisher.js +134 -0
- package/dist/tileapp-validator.js +239 -0
- package/dist/vendored/flexdb.mjs +1087 -0
- package/dist/webapp-url.js +61 -0
- package/dist/work-client.js +195 -0
- package/package.json +39 -0
package/dist/context.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container ambient context resolver for the substrate CLI.
|
|
3
|
+
*
|
|
4
|
+
* Reads `SESSION_ID`, `YOLO_COMMON_API_URL`, and the user access JWT
|
|
5
|
+
* (`~/.config/yolo/token` → `YOLO_API_TOKEN`) and exposes a typed result.
|
|
6
|
+
* Exits with `session-required` (exit code 78 = EX_CONFIG) if `SESSION_ID` is
|
|
7
|
+
* missing — outside-container invocation is out of scope for v1.
|
|
8
|
+
*
|
|
9
|
+
* `WORKSPACE_ID` is read for display purposes (operators want a
|
|
10
|
+
* fast "what workspace is this" answer in `yolo context`).
|
|
11
|
+
* The authoritative workspaceId for any *write* still comes from
|
|
12
|
+
* the session record at token-mint time — that's what the CLI's
|
|
13
|
+
* `--workspace` sanity check is comparing against. So
|
|
14
|
+
* `WORKSPACE_ID` is best-effort env state, not the source of
|
|
15
|
+
* truth, but in practice the session container sets both
|
|
16
|
+
* consistently from the same source.
|
|
17
|
+
*/
|
|
18
|
+
import { resolveUserToken } from './auth-context.js';
|
|
19
|
+
export class ContextResolutionError extends Error {
|
|
20
|
+
exitCode;
|
|
21
|
+
constructor(message, exitCode) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ContextResolutionError';
|
|
24
|
+
this.exitCode = exitCode;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function readSessionContext() {
|
|
28
|
+
const sessionId = process.env.SESSION_ID;
|
|
29
|
+
if (!sessionId || sessionId.length === 0) {
|
|
30
|
+
throw new ContextResolutionError('session-required: SESSION_ID env var is empty or missing. The substrate CLI requires a Studio container session; outside-container invocation is out of scope for v1.', 78);
|
|
31
|
+
}
|
|
32
|
+
const commonApiUrl = process.env.YOLO_COMMON_API_URL || process.env.YOLO_API_URL;
|
|
33
|
+
if (!commonApiUrl || commonApiUrl.length === 0) {
|
|
34
|
+
throw new ContextResolutionError('session-required: YOLO_COMMON_API_URL env var is empty or missing.', 78);
|
|
35
|
+
}
|
|
36
|
+
const userToken = resolveUserToken(process.env);
|
|
37
|
+
return {
|
|
38
|
+
sessionId,
|
|
39
|
+
commonApiUrl,
|
|
40
|
+
userTokenPresent: !!userToken && userToken.length > 0,
|
|
41
|
+
workspaceIdHint: process.env.WORKSPACE_ID ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function formatContext(ctx) {
|
|
45
|
+
// The "(unset)" branch is for the rare-but-possible case where
|
|
46
|
+
// SESSION_ID is set but WORKSPACE_ID isn't — substrate ops will
|
|
47
|
+
// still work (mint resolves workspace from the session record),
|
|
48
|
+
// they just can't be sanity-checked against ambient env.
|
|
49
|
+
const workspace = ctx.workspaceIdHint ?? '(unset — will resolve from session at mint time)';
|
|
50
|
+
return [
|
|
51
|
+
'yolo substrate CLI — context',
|
|
52
|
+
` sessionId ${ctx.sessionId}`,
|
|
53
|
+
` commonApiUrl ${ctx.commonApiUrl}`,
|
|
54
|
+
` userToken ${ctx.userTokenPresent ? '(set)' : '(missing)'}`,
|
|
55
|
+
` workspaceId ${workspace}`,
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local bundling for `yolo deploy` (spec
|
|
3
|
+
* `docs/MANAGED_HOSTING_CLI_SPEC.md` §2, `deploy-bundle`).
|
|
4
|
+
*
|
|
5
|
+
* Fully OFFLINE: every ceiling is enforced here, before any network —
|
|
6
|
+
* `--dry-run` stops after this module. Outputs:
|
|
7
|
+
*
|
|
8
|
+
* - **Worker module** — esbuild ESM bundle of the entry
|
|
9
|
+
* (`{bundle, format:'esm', platform:'browser',
|
|
10
|
+
* conditions:['workerd','worker'], target:'es2022', write:false,
|
|
11
|
+
* minify:true}`). If the entry already points at a BUILT `.js`/`.mjs`
|
|
12
|
+
* module (vinext/OpenNext output, skill-directed), esbuild is
|
|
13
|
+
* SKIPPED and the module ships byte-for-byte as-is — the size
|
|
14
|
+
* ceilings still apply. esbuild is lazy-imported so `yolo plan`
|
|
15
|
+
* startup never pays for it (the `serve.ts` lazy-load precedent).
|
|
16
|
+
* - **Asset manifest** — walk `assetsDir` (skip dotfiles +
|
|
17
|
+
* node_modules), URL-style path → `{hash: sha256hex, size}`.
|
|
18
|
+
* Pure-static ships NO module; common-api attaches the canonical
|
|
19
|
+
* assets-only shim server-side.
|
|
20
|
+
* - **`bundleDigest`** — sha256 over module bytes + the sorted
|
|
21
|
+
* manifest; becomes `hosting_releases.bundleDigest` and binds T3
|
|
22
|
+
* approvals to exactly these bytes.
|
|
23
|
+
*
|
|
24
|
+
* Ceilings (defaults per spec; the server returns authoritative tier
|
|
25
|
+
* caps at ship/start and re-checks — overrides below are for that and
|
|
26
|
+
* for tests):
|
|
27
|
+
* - per-file 25 MiB (CF hard limit) → `file-too-large`
|
|
28
|
+
* - 20,000 files → `too-many-files`
|
|
29
|
+
* - total assets 256 MiB → `bundle-too-large`
|
|
30
|
+
* - worker module >10 MiB → `bundle-too-large`
|
|
31
|
+
* (plus a warning when the gzipped module exceeds 1 MiB)
|
|
32
|
+
*/
|
|
33
|
+
import { createHash } from 'node:crypto';
|
|
34
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { gzipSync } from 'node:zlib';
|
|
38
|
+
// ─── Vendored @yolo-labs/flexdb ─────────────────────────────────────────────
|
|
39
|
+
//
|
|
40
|
+
// FlexDB is not published to npm yet (it will be). Until then, the deploy
|
|
41
|
+
// bundler resolves `import { FlexDB } from '@yolo-labs/flexdb'` to a copy
|
|
42
|
+
// vendored into the CLI's dist (built by scripts/build-vendored-flexdb.mjs), so
|
|
43
|
+
// a customer Worker can use FlexDB without installing it. A real installed copy
|
|
44
|
+
// is preferred once the package is published — see vendoredFlexdbPlugin.
|
|
45
|
+
let cachedFlexdbSource;
|
|
46
|
+
/** The vendored FlexDB bundle shipped in the CLI's dist, or null if absent (unbuilt dev tree). */
|
|
47
|
+
function readVendoredFlexdb() {
|
|
48
|
+
if (cachedFlexdbSource !== undefined)
|
|
49
|
+
return cachedFlexdbSource;
|
|
50
|
+
try {
|
|
51
|
+
const p = fileURLToPath(new URL('./vendored/flexdb.mjs', import.meta.url));
|
|
52
|
+
cachedFlexdbSource = existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
cachedFlexdbSource = null;
|
|
56
|
+
}
|
|
57
|
+
return cachedFlexdbSource;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* esbuild plugin that supplies `@yolo-labs/flexdb` from the CLI's vendored copy
|
|
61
|
+
* when the project hasn't installed it. A real installed copy (post-publish) is
|
|
62
|
+
* preferred. Returns null when no vendored bundle is present, leaving esbuild's
|
|
63
|
+
* default resolution untouched.
|
|
64
|
+
*/
|
|
65
|
+
function vendoredFlexdbPlugin() {
|
|
66
|
+
const source = readVendoredFlexdb();
|
|
67
|
+
if (source === null)
|
|
68
|
+
return null;
|
|
69
|
+
const NS = 'yolo-vendored-flexdb';
|
|
70
|
+
const SPECIFIER = '@yolo-labs/flexdb';
|
|
71
|
+
return {
|
|
72
|
+
name: 'yolo-vendored-flexdb',
|
|
73
|
+
setup(build) {
|
|
74
|
+
build.onResolve({ filter: /^@yolo-labs\/flexdb$/ }, async (args) => {
|
|
75
|
+
// Re-entry guard: our build.resolve() below re-fires this same filter.
|
|
76
|
+
if (args.pluginData?.vendored)
|
|
77
|
+
return null;
|
|
78
|
+
// Prefer a real installed copy if the project has one (post-publish).
|
|
79
|
+
const real = await build.resolve(args.path, {
|
|
80
|
+
importer: args.importer,
|
|
81
|
+
resolveDir: args.resolveDir,
|
|
82
|
+
kind: args.kind,
|
|
83
|
+
pluginData: { vendored: true },
|
|
84
|
+
});
|
|
85
|
+
if (real.errors.length === 0 && real.path)
|
|
86
|
+
return real;
|
|
87
|
+
// Otherwise route to the vendored virtual module.
|
|
88
|
+
return { path: SPECIFIER, namespace: NS };
|
|
89
|
+
});
|
|
90
|
+
build.onLoad({ filter: /.*/, namespace: NS }, () => ({
|
|
91
|
+
contents: source,
|
|
92
|
+
loader: 'js',
|
|
93
|
+
resolveDir: '/',
|
|
94
|
+
}));
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ─── Public types ─────────────────────────────────────────────────────────
|
|
99
|
+
export const DEPLOY_CEILINGS = {
|
|
100
|
+
/** CF hard per-file limit. */
|
|
101
|
+
maxFileBytes: 25 * 1024 * 1024,
|
|
102
|
+
maxFiles: 20_000,
|
|
103
|
+
/** Local default; the server's tier cap is authoritative at ship/start. */
|
|
104
|
+
maxTotalBytes: 256 * 1024 * 1024,
|
|
105
|
+
/** Worker module hard cap. */
|
|
106
|
+
maxModuleBytes: 10 * 1024 * 1024,
|
|
107
|
+
/** Gzipped-module size that triggers a warning (not a failure). */
|
|
108
|
+
warnModuleGzipBytes: 1 * 1024 * 1024,
|
|
109
|
+
};
|
|
110
|
+
// ─── Public entry ─────────────────────────────────────────────────────────
|
|
111
|
+
export async function bundleProject(shape, cwd, ceilings = {}) {
|
|
112
|
+
const caps = { ...DEPLOY_CEILINGS, ...definedOnly(ceilings) };
|
|
113
|
+
const warnings = [];
|
|
114
|
+
// ── Worker module (esbuild, or as-is for pre-built entries) ────────────
|
|
115
|
+
let module = null;
|
|
116
|
+
let moduleSource = null;
|
|
117
|
+
if (shape.type === 'worker') {
|
|
118
|
+
const built = await buildWorkerModule(shape.entry, cwd, shape.prebuilt === true, caps, warnings);
|
|
119
|
+
if (!built.ok)
|
|
120
|
+
return built;
|
|
121
|
+
module = built.module;
|
|
122
|
+
moduleSource = built.source;
|
|
123
|
+
}
|
|
124
|
+
// ── Asset walk + manifest + ceilings ────────────────────────────────────
|
|
125
|
+
const manifest = {};
|
|
126
|
+
const assetPaths = {};
|
|
127
|
+
const assets = [];
|
|
128
|
+
let fileCount = 0;
|
|
129
|
+
let totalAssetBytes = 0;
|
|
130
|
+
// Both arms carry assetsDir (required on static, optional on worker).
|
|
131
|
+
const assetsDir = shape.assetsDir;
|
|
132
|
+
if (assetsDir !== undefined) {
|
|
133
|
+
const rootAbs = path.resolve(cwd, assetsDir);
|
|
134
|
+
let rootStat;
|
|
135
|
+
try {
|
|
136
|
+
rootStat = statSync(rootAbs);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
rootStat = undefined;
|
|
140
|
+
}
|
|
141
|
+
if (rootStat === undefined || !rootStat.isDirectory()) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
kind: 'build-failed',
|
|
145
|
+
message: `assets directory not found: ${assetsDir} (resolved ${rootAbs})`,
|
|
146
|
+
hint: 'run the build first, or fix build.outputDir / worker.assetsDir in .yolo/deploy.json',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
for (const relPosix of walkAssetFiles(rootAbs)) {
|
|
150
|
+
const absPath = path.join(rootAbs, ...relPosix.split('/'));
|
|
151
|
+
const size = statSync(absPath).size;
|
|
152
|
+
if (size > caps.maxFileBytes) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
kind: 'file-too-large',
|
|
156
|
+
message: `${relPosix} is ${formatBytes(size)} — per-file limit is ${formatBytes(caps.maxFileBytes)}`,
|
|
157
|
+
hint: 'host large media in the project R2 bucket or external storage',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
fileCount += 1;
|
|
161
|
+
if (fileCount > caps.maxFiles) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
kind: 'too-many-files',
|
|
165
|
+
message: `more than ${caps.maxFiles} files under ${assetsDir}`,
|
|
166
|
+
hint: 'exclude generated/vendored trees from the assets dir',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
totalAssetBytes += size;
|
|
170
|
+
if (totalAssetBytes > caps.maxTotalBytes) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
kind: 'bundle-too-large',
|
|
174
|
+
message: `total assets exceed ${formatBytes(caps.maxTotalBytes)} (at ${relPosix})`,
|
|
175
|
+
hint: 'host large media in the project R2 bucket or external storage',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const manifestPath = '/' + relPosix;
|
|
179
|
+
const hash = cfAssetHash(readFileSync(absPath), manifestPath);
|
|
180
|
+
manifest[manifestPath] = { hash, size };
|
|
181
|
+
assetPaths[manifestPath] = absPath;
|
|
182
|
+
assets.push({ path: manifestPath, hash, size, absPath });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (shape.type === 'static') {
|
|
186
|
+
// Type system prevents this (static requires assetsDir), but guard anyway.
|
|
187
|
+
return { ok: false, kind: 'build-failed', message: 'static project has no assetsDir' };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
type: shape.type,
|
|
192
|
+
manifest,
|
|
193
|
+
assetPaths,
|
|
194
|
+
assets,
|
|
195
|
+
module,
|
|
196
|
+
workerModules: module === null ? [] : [module],
|
|
197
|
+
moduleSource,
|
|
198
|
+
fileCount,
|
|
199
|
+
totalAssetBytes,
|
|
200
|
+
bundleDigest: computeBundleDigest(module === null ? [] : [module], manifest),
|
|
201
|
+
warnings,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `sha256:<hex>` over name-framed module bytes + the sorted manifest.
|
|
206
|
+
*
|
|
207
|
+
* ⚠️ MIRROR — the CANONICAL implementation is the server verifier
|
|
208
|
+
* (`common-api/src/services/hosting/release-service.ts` computeBundleDigest);
|
|
209
|
+
* finalize recomputes with this exact recipe and refuses on mismatch
|
|
210
|
+
* (`bundle-digest-mismatch`). Recipe: modules sorted by name, each hashed as
|
|
211
|
+
* `name‖0x00‖bytes‖0x00`; then `JSON.stringify` of path-sorted entries shaped
|
|
212
|
+
* `[path, {hash, size}]`. The golden-vector test pins all three copies
|
|
213
|
+
* (this, the server, yolo-studio-mcp/src/lib/static-bundle.ts) to the same
|
|
214
|
+
* output — change one, change all.
|
|
215
|
+
*/
|
|
216
|
+
export function computeBundleDigest(modules, manifest) {
|
|
217
|
+
const hash = createHash('sha256');
|
|
218
|
+
const sorted = [...modules].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
219
|
+
for (const mod of sorted) {
|
|
220
|
+
hash.update(mod.name, 'utf8');
|
|
221
|
+
hash.update(Buffer.from([0]));
|
|
222
|
+
hash.update(mod.contents);
|
|
223
|
+
hash.update(Buffer.from([0]));
|
|
224
|
+
}
|
|
225
|
+
const entries = Object.keys(manifest)
|
|
226
|
+
.sort()
|
|
227
|
+
.map((path) => [path, { hash: manifest[path].hash, size: manifest[path].size }]);
|
|
228
|
+
hash.update(JSON.stringify(entries), 'utf8');
|
|
229
|
+
return `sha256:${hash.digest('hex')}`;
|
|
230
|
+
}
|
|
231
|
+
async function buildWorkerModule(entry, cwd, prebuilt, caps, warnings) {
|
|
232
|
+
const entryAbs = path.resolve(cwd, entry);
|
|
233
|
+
let contents;
|
|
234
|
+
let name;
|
|
235
|
+
let source;
|
|
236
|
+
// Ship as-is ONLY for an explicitly-configured built output (deploy.json
|
|
237
|
+
// worker.entry → .js/.mjs). Auto-detected `src/index.js` is SOURCE and must
|
|
238
|
+
// be bundled or its imports are dropped (codex P2 r6).
|
|
239
|
+
if (prebuilt) {
|
|
240
|
+
try {
|
|
241
|
+
contents = readFileSync(entryAbs);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
kind: 'build-failed',
|
|
247
|
+
message: `worker entry not found: ${entry} (resolved ${entryAbs})`,
|
|
248
|
+
hint: 'run the framework build first, or fix worker.entry in .yolo/deploy.json',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
name = path.basename(entryAbs);
|
|
252
|
+
source = 'prebuilt';
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
// Lazy-load so non-deploy verbs never pay esbuild's startup cost.
|
|
256
|
+
const esbuild = await import('esbuild');
|
|
257
|
+
// Vendor @yolo-labs/flexdb (not yet published) so a customer Worker can
|
|
258
|
+
// `import { FlexDB } from '@yolo-labs/flexdb'` without installing it.
|
|
259
|
+
const flexdbPlugin = vendoredFlexdbPlugin();
|
|
260
|
+
let result;
|
|
261
|
+
try {
|
|
262
|
+
result = await esbuild.build({
|
|
263
|
+
entryPoints: [entryAbs],
|
|
264
|
+
bundle: true,
|
|
265
|
+
format: 'esm',
|
|
266
|
+
platform: 'browser',
|
|
267
|
+
conditions: ['workerd', 'worker'],
|
|
268
|
+
target: 'es2022',
|
|
269
|
+
write: false,
|
|
270
|
+
minify: true,
|
|
271
|
+
absWorkingDir: path.resolve(cwd),
|
|
272
|
+
logLevel: 'silent',
|
|
273
|
+
plugins: flexdbPlugin ? [flexdbPlugin] : [],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
kind: 'build-failed',
|
|
281
|
+
message: `esbuild failed for ${entry}: ${msg}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const out = result.outputFiles?.[0];
|
|
285
|
+
if (out === undefined) {
|
|
286
|
+
return { ok: false, kind: 'build-failed', message: `esbuild produced no output for ${entry}` };
|
|
287
|
+
}
|
|
288
|
+
contents = out.contents;
|
|
289
|
+
name = 'index.js';
|
|
290
|
+
source = 'esbuild';
|
|
291
|
+
}
|
|
292
|
+
if (contents.byteLength > caps.maxModuleBytes) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
kind: 'bundle-too-large',
|
|
296
|
+
message: `worker module is ${formatBytes(contents.byteLength)} — limit is ${formatBytes(caps.maxModuleBytes)}`,
|
|
297
|
+
hint: 'split vendored data out of the module (assets or R2), or trim dependencies',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const gzipBytes = gzipSync(contents).byteLength;
|
|
301
|
+
if (gzipBytes > caps.warnModuleGzipBytes) {
|
|
302
|
+
warnings.push(`worker module is ${formatBytes(gzipBytes)} gzipped (> ${formatBytes(caps.warnModuleGzipBytes)}) — cold starts will suffer`);
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, module: { name, contents }, source };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Depth-first walk returning sorted posix-relative file paths. Skips
|
|
308
|
+
* dotfiles/dot-dirs and node_modules at every level. Symlinked DIRS are
|
|
309
|
+
* skipped (cycle safety). Symlinked FILES are included only when their
|
|
310
|
+
* realpath stays INSIDE the asset root (codex P1 r12) — a
|
|
311
|
+
* `public/token.txt -> ~/.config/yolo/token` link would otherwise publish a
|
|
312
|
+
* credential as a public asset.
|
|
313
|
+
*/
|
|
314
|
+
function walkAssetFiles(rootAbs) {
|
|
315
|
+
// Resolve the root once (it may itself be reached through a symlink) so the
|
|
316
|
+
// containment check compares real paths on both sides.
|
|
317
|
+
let rootReal;
|
|
318
|
+
try {
|
|
319
|
+
rootReal = realpathSync(rootAbs);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
rootReal = rootAbs;
|
|
323
|
+
}
|
|
324
|
+
const within = (abs) => {
|
|
325
|
+
try {
|
|
326
|
+
const real = realpathSync(abs);
|
|
327
|
+
return real === rootReal || real.startsWith(rootReal + path.sep);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false; // dangling / unreadable → don't include
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const files = [];
|
|
334
|
+
const walk = (relPosix) => {
|
|
335
|
+
const dirAbs = relPosix === '' ? rootAbs : path.join(rootAbs, ...relPosix.split('/'));
|
|
336
|
+
const entries = readdirSync(dirAbs, { withFileTypes: true })
|
|
337
|
+
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
338
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
const childRel = relPosix === '' ? entry.name : `${relPosix}/${entry.name}`;
|
|
341
|
+
if (entry.isDirectory()) {
|
|
342
|
+
walk(childRel);
|
|
343
|
+
}
|
|
344
|
+
else if (entry.isFile()) {
|
|
345
|
+
files.push(childRel);
|
|
346
|
+
}
|
|
347
|
+
else if (entry.isSymbolicLink()) {
|
|
348
|
+
// Follow file symlinks ONLY when they resolve to a regular file that
|
|
349
|
+
// stays under the asset root.
|
|
350
|
+
const abs = path.join(dirAbs, entry.name);
|
|
351
|
+
try {
|
|
352
|
+
if (statSync(abs).isFile() && within(abs))
|
|
353
|
+
files.push(childRel);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// dangling symlink — skip
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
walk('');
|
|
362
|
+
return files;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Cloudflare Workers-Assets manifest hash (wrangler's scheme, verified against
|
|
366
|
+
* the CF assets-upload-session API 2026-06-13): `sha256(base64(contents) +
|
|
367
|
+
* extension-without-dot)` hex-encoded, truncated to 32 chars. CF rejects a
|
|
368
|
+
* full 64-hex sha256 ("file hash size of 64 is too large", code 10304) and
|
|
369
|
+
* content-addresses uploaded assets by THIS value — it must match exactly or
|
|
370
|
+
* the bucket upload / serve fails. The server keeps this value verbatim in the
|
|
371
|
+
* manifest (it never recomputes asset hashes), so the CLI is canonical here.
|
|
372
|
+
*/
|
|
373
|
+
function cfAssetHash(bytes, manifestPath) {
|
|
374
|
+
const ext = path.extname(manifestPath).slice(1); // 'html', 'css', '' …
|
|
375
|
+
return createHash('sha256')
|
|
376
|
+
.update(Buffer.from(bytes).toString('base64') + ext)
|
|
377
|
+
.digest('hex')
|
|
378
|
+
.slice(0, 32);
|
|
379
|
+
}
|
|
380
|
+
function formatBytes(n) {
|
|
381
|
+
if (n >= 1024 * 1024)
|
|
382
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
|
|
383
|
+
if (n >= 1024)
|
|
384
|
+
return `${(n / 1024).toFixed(1)} KiB`;
|
|
385
|
+
return `${n} B`;
|
|
386
|
+
}
|
|
387
|
+
function definedOnly(obj) {
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
390
|
+
if (v !== undefined)
|
|
391
|
+
out[k] = v;
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|