@substrat-run/cli 0.26.5 → 0.30.1
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/dist/cli.js +81 -3
- package/dist/cli.js.map +1 -1
- package/dist/model.d.ts +65 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +87 -0
- package/dist/model.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +16 -22
- package/dist/preview.js.map +1 -1
- package/dist/problem.d.ts.map +1 -1
- package/dist/problem.js +40 -4
- package/dist/problem.js.map +1 -1
- package/dist/push.d.ts +104 -8
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +396 -40
- package/dist/push.js.map +1 -1
- package/package.json +4 -3
package/dist/push.js
CHANGED
|
@@ -5,8 +5,8 @@ import { join, basename, extname, relative, resolve } from 'node:path';
|
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { webcrypto } from 'node:crypto';
|
|
7
7
|
import { build } from 'esbuild';
|
|
8
|
-
import { ASSET_PART_PREFIX, assetHash, assetsNeed, buildPermissionRegistry, deployManifest, runtimeNeeds, RUNTIME_BASELINE, } from '@substrat-run/contracts';
|
|
9
|
-
import { lint, loadConfig as loadBoundaryLintConfig, resolvePackages, declaredEngines, formatViolations, } from '@substrat-run/boundary-lint';
|
|
8
|
+
import { ASSET_PART_PREFIX, assetHash, assetsNeed, buildPermissionRegistry, deployManifest, emittedModel, envVarSpec, runtimeNeeds, RUNTIME_BASELINE, } from '@substrat-run/contracts';
|
|
9
|
+
import { lint, loadConfig as loadBoundaryLintConfig, resolvePackages, declaredEngines, formatViolations, maskSource, } from '@substrat-run/boundary-lint';
|
|
10
10
|
import { warnIfStale } from './version.js';
|
|
11
11
|
import { parseJsonBody, readAllEntries } from './http.js';
|
|
12
12
|
import { failureMessage } from './problem.js';
|
|
@@ -57,9 +57,36 @@ function stableStringify(v) {
|
|
|
57
57
|
* builder's own entry is not a new trust boundary. A missing pointer, a missing entry, or an
|
|
58
58
|
* entry that exports no `permissions` is a hard error: a deployable vertical must declare its
|
|
59
59
|
* surface — absence is never silently an empty surface (D-41).
|
|
60
|
+
*
|
|
61
|
+
* The same import also reads an optional `envSpec` export (#1206): the manifest's declared
|
|
62
|
+
* config surface, re-exported from the entry so `src/manifest.ts` is its single declaration.
|
|
63
|
+
* Optional because pre-#1206 verticals declare it in package.json `substrat.envSpec` instead —
|
|
64
|
+
* see `resolveDeclaredEnvSpec` for how the two are reconciled.
|
|
60
65
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Every module's declared schedules, flattened with the owning module id (#1232) —
|
|
68
|
+
* what the deploy manifest carries so the dashboard can compute next-due off
|
|
69
|
+
* `everyMinutes`. Undefined when no module declares any, so the field stays absent.
|
|
70
|
+
*/
|
|
71
|
+
export function flattenDeclaredSchedules(permissions) {
|
|
72
|
+
const schedules = permissions.modules.flatMap((m) => (m.manifest.schedules ?? []).map((s) => ({ ...s, moduleId: m.manifest.id })));
|
|
73
|
+
return schedules.length > 0 ? schedules : undefined;
|
|
74
|
+
}
|
|
75
|
+
/** The freshness twin of `flattenDeclaredSchedules` (#1232) — `within.hours` exists
|
|
76
|
+
* nowhere off the manifest, and the dashboard's declared-vs-observed read needs it. */
|
|
77
|
+
export function flattenDeclaredFreshness(permissions) {
|
|
78
|
+
const freshness = permissions.modules.flatMap((m) => (m.manifest.freshness ?? []).map((f) => ({ ...f, moduleId: m.manifest.id })));
|
|
79
|
+
return freshness.length > 0 ? freshness : undefined;
|
|
80
|
+
}
|
|
81
|
+
export async function deriveDeclaredSurface(dir) {
|
|
82
|
+
const pkgPath = join(dir, 'package.json');
|
|
83
|
+
// Named rather than left as an ENOENT trace: `substrat push --check` (#1205) is run from
|
|
84
|
+
// wherever a CI job happens to stand, and "wrong directory" is the likeliest reason there
|
|
85
|
+
// is no package.json to read.
|
|
86
|
+
if (!existsSync(pkgPath)) {
|
|
87
|
+
throw new Error(`no package.json under ${resolve(dir)} — run this from the vertical's directory, or name it (\`substrat push <dir>\`).`);
|
|
88
|
+
}
|
|
89
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
63
90
|
const entry = pkg.substrat?.permissions;
|
|
64
91
|
if (!entry) {
|
|
65
92
|
throw new Error(`${basename(dir)} declares no permission surface. Add \`"substrat": { "permissions": "src/…" }\` ` +
|
|
@@ -74,28 +101,114 @@ export async function deriveRegistry(dir) {
|
|
|
74
101
|
// immediately after import. The unique name avoids the ESM import cache across pushes.
|
|
75
102
|
const out = join(dir, `.substrat.permissions.${Date.now()}.mjs`);
|
|
76
103
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
let mod;
|
|
105
|
+
try {
|
|
106
|
+
await build({
|
|
107
|
+
entryPoints: [entryPath],
|
|
108
|
+
bundle: true,
|
|
109
|
+
platform: 'node',
|
|
110
|
+
format: 'esm',
|
|
111
|
+
packages: 'external',
|
|
112
|
+
outfile: out,
|
|
113
|
+
logLevel: 'silent',
|
|
114
|
+
});
|
|
115
|
+
// @vite-ignore: this is a real filesystem path imported at runtime, never a bundler input —
|
|
116
|
+
// the comment keeps vitest/vite from trying to resolve it through their transform pipeline.
|
|
117
|
+
mod = (await import(/* @vite-ignore */ pathToFileURL(out).href));
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
// The third silent-until-deploy failure (#1205): the entry exists and exports the right
|
|
121
|
+
// name, but cannot be read OUTSIDE the vertical's runtime — a worker-only import, a
|
|
122
|
+
// `node:*` on a path the bundler follows, an import-time side effect wanting a live host.
|
|
123
|
+
// Named as that, rather than as a bare esbuild/ESM stack, because the remedy is specific:
|
|
124
|
+
// `definePermissions(...)` returns a plain object, so the module holding it must stay
|
|
125
|
+
// importable as data.
|
|
126
|
+
throw new Error(`substrat.permissions points at "${entry}", which could not be bundled and imported as data — ` +
|
|
127
|
+
`the declared surface must be readable without a live host (no worker-only imports on the ` +
|
|
128
|
+
`path it pulls in, no import-time side effects).\n${e instanceof Error ? e.message : String(e)}`);
|
|
129
|
+
}
|
|
89
130
|
if (!mod.permissions) {
|
|
90
131
|
throw new Error(`${entry} exports no \`permissions\`. Export ` +
|
|
91
132
|
`\`const permissions = definePermissions({ modules, roles, entityGrants })\`.`);
|
|
92
133
|
}
|
|
93
|
-
|
|
134
|
+
let spec;
|
|
135
|
+
if (mod.envSpec !== undefined) {
|
|
136
|
+
if (!Array.isArray(mod.envSpec)) {
|
|
137
|
+
throw new Error(`${entry} exports \`envSpec\`, but it is not an array of env-var specs.`);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
spec = mod.envSpec.map((e) => envVarSpec.parse(e));
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
throw new Error(`${entry} exports an \`envSpec\` that is not a valid env-var spec list.\n` +
|
|
144
|
+
`${e instanceof Error ? e.message : String(e)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
registry: buildPermissionRegistry(mod.permissions),
|
|
149
|
+
envSpec: spec,
|
|
150
|
+
// #1232: the same import that yields the permission surface already holds every
|
|
151
|
+
// module manifest — the schedules ride out of it with zero extra reads.
|
|
152
|
+
schedules: flattenDeclaredSchedules(mod.permissions),
|
|
153
|
+
freshness: flattenDeclaredFreshness(mod.permissions),
|
|
154
|
+
};
|
|
94
155
|
}
|
|
95
156
|
finally {
|
|
96
157
|
rmSync(out, { force: true });
|
|
97
158
|
}
|
|
98
159
|
}
|
|
160
|
+
/** The declared permission surface alone — `deriveDeclaredSurface` for the callers that
|
|
161
|
+
* want only the registry (D-41). */
|
|
162
|
+
export async function deriveRegistry(dir) {
|
|
163
|
+
return (await deriveDeclaredSurface(dir)).registry;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Which `envSpec` a push ships (#1206). The code-side declaration (the entry's `envSpec`
|
|
167
|
+
* export — `src/manifest.ts`, re-exported) is canonical when it exists: it is the copy the
|
|
168
|
+
* worker actually reads at runtime (`resolveEnvSpec(manifest.envSpec, env)`), so a key
|
|
169
|
+
* declared only there used to be a key nobody could ever set — the settings form is rendered
|
|
170
|
+
* from what the push uploads, and the push read only package.json. Deriving closes that.
|
|
171
|
+
*
|
|
172
|
+
* A vertical that has NOT adopted the export keeps the pre-#1206 behaviour: package.json
|
|
173
|
+
* `substrat.envSpec` ships, unchanged. One that has adopted it and still carries the
|
|
174
|
+
* package.json copy is refused on drift rather than warned: the duplicated copy silently
|
|
175
|
+
* losing a key is the exact defect, and a warning in CI logs is a diff surfaced nowhere.
|
|
176
|
+
* An identical leftover copy passes with a note, so adoption is a two-step that cannot
|
|
177
|
+
* wedge a release between its steps.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveDeclaredEnvSpec(derived, pkgCopy, log = console.log) {
|
|
180
|
+
if (!derived)
|
|
181
|
+
return pkgCopy;
|
|
182
|
+
if (pkgCopy !== undefined) {
|
|
183
|
+
let pkgParsed;
|
|
184
|
+
try {
|
|
185
|
+
pkgParsed = Array.isArray(pkgCopy) ? pkgCopy.map((e) => envVarSpec.parse(e)) : undefined;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
pkgParsed = undefined;
|
|
189
|
+
}
|
|
190
|
+
if (!pkgParsed || stableStringify(pkgParsed) !== stableStringify(derived)) {
|
|
191
|
+
const keysOf = (s) => new Set((s ?? []).map((e) => e.key));
|
|
192
|
+
const code = keysOf(derived);
|
|
193
|
+
const pkg = keysOf(pkgParsed);
|
|
194
|
+
const onlyCode = [...code].filter((k) => !pkg.has(k));
|
|
195
|
+
const onlyPkg = [...pkg].filter((k) => !code.has(k));
|
|
196
|
+
const detail = onlyCode.length || onlyPkg.length
|
|
197
|
+
? [
|
|
198
|
+
onlyCode.length ? `only in the code declaration: ${onlyCode.join(', ')}` : '',
|
|
199
|
+
onlyPkg.length ? `only in package.json: ${onlyPkg.join(', ')}` : '',
|
|
200
|
+
]
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.join('; ')
|
|
203
|
+
: 'same keys, differing content';
|
|
204
|
+
throw new Error(`envSpec is declared twice and the copies disagree (${detail}). The code declaration ` +
|
|
205
|
+
`(the \`envSpec\` export beside \`permissions\`) is what ships — delete ` +
|
|
206
|
+
`package.json's \`substrat.envSpec\` block, which no longer does anything.`);
|
|
207
|
+
}
|
|
208
|
+
log('note: package.json `substrat.envSpec` duplicates the code declaration — it is ignored and can be deleted.');
|
|
209
|
+
}
|
|
210
|
+
return derived;
|
|
211
|
+
}
|
|
99
212
|
/**
|
|
100
213
|
* The permission digest (D-39): a content hash of the vertical's declared permission surface —
|
|
101
214
|
* what the promotion checkpoint compares to fire "permissions changed". A pure function of the
|
|
@@ -105,6 +218,79 @@ export async function deriveRegistry(dir) {
|
|
|
105
218
|
export async function permissionDigest(registry) {
|
|
106
219
|
return sha256(Buffer.from(stableStringify(registry)));
|
|
107
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* The push's permission preflight, on its own (#1205).
|
|
223
|
+
*
|
|
224
|
+
* Everything a push does to the declared surface — resolve `package.json`
|
|
225
|
+
* `substrat.permissions`, bundle the entry, import it, derive the registry, hash it — happens
|
|
226
|
+
* before any credential is needed and touches no network. A vertical that wants that as a CI
|
|
227
|
+
* gate was reaching it by deep-importing `dist/push.js`, which is not a public surface: no
|
|
228
|
+
* `exports` map declares it, so any file move breaks a consumer nothing upstream knows about.
|
|
229
|
+
* The alternative — a second implementation of the derivation — is the two-descriptions defect
|
|
230
|
+
* this whole area exists to remove. So the gate is the CLI's own command, and the internals
|
|
231
|
+
* stay internal.
|
|
232
|
+
*
|
|
233
|
+
* Every failure is a throw carrying its own remedy (see `deriveRegistry`): a missing pointer,
|
|
234
|
+
* a pointer naming a file that has moved, an entry that stopped exporting `permissions`, and
|
|
235
|
+
* an entry that cannot be imported outside the vertical's runtime. The CLI's top-level handler
|
|
236
|
+
* turns each into a non-zero exit, which is what makes this usable as a gate.
|
|
237
|
+
*/
|
|
238
|
+
export async function checkPermissionSurface(dir) {
|
|
239
|
+
const { registry, envSpec: derived } = await deriveDeclaredSurface(dir);
|
|
240
|
+
// The envSpec drift check (#1206) runs here too, so `--check` in CI refuses exactly what a
|
|
241
|
+
// push would. The duplicate-copy note is push-time chatter, not part of the check artifact.
|
|
242
|
+
const envSpec = resolveDeclaredEnvSpec(derived, readVerticalMeta(dir).envSpec, () => { });
|
|
243
|
+
return {
|
|
244
|
+
registry,
|
|
245
|
+
digest: await permissionDigest(registry),
|
|
246
|
+
...(derived ? { envSpec: envSpec } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Render a checked surface for a human reading CI output: every key with the module(s) that
|
|
251
|
+
* declare it and its description, every role with the keys it holds, every entity-grant shape,
|
|
252
|
+
* then the digest. Sorted throughout (`buildPermissionRegistry` guarantees it), so two runs of
|
|
253
|
+
* the same tree produce byte-identical text and a diff between them is a real surface change.
|
|
254
|
+
*/
|
|
255
|
+
export function formatPermissionSurface(surface, label) {
|
|
256
|
+
const { registry, digest } = surface;
|
|
257
|
+
const lines = [];
|
|
258
|
+
const counts = [
|
|
259
|
+
`${registry.permissions.length} key(s)`,
|
|
260
|
+
`${registry.roles.length} role(s)`,
|
|
261
|
+
`${registry.entityGrants.length} entity-grant shape(s)`,
|
|
262
|
+
].join(', ');
|
|
263
|
+
lines.push(`permission surface${label ? ` — ${label}` : ''}: ${counts}`);
|
|
264
|
+
const width = Math.max(0, ...registry.permissions.map((p) => p.key.length));
|
|
265
|
+
lines.push('', 'keys:');
|
|
266
|
+
if (registry.permissions.length === 0)
|
|
267
|
+
lines.push(' (none declared)');
|
|
268
|
+
for (const p of registry.permissions) {
|
|
269
|
+
lines.push(` ${p.key.padEnd(width)} [${p.declaredBy.join(', ')}] ${p.description}`);
|
|
270
|
+
}
|
|
271
|
+
lines.push('', 'roles:');
|
|
272
|
+
if (registry.roles.length === 0)
|
|
273
|
+
lines.push(' (none declared)');
|
|
274
|
+
for (const r of registry.roles) {
|
|
275
|
+
lines.push(` ${r.key} (${r.source}, ${r.permissions.length} key(s))`);
|
|
276
|
+
for (const key of r.permissions)
|
|
277
|
+
lines.push(` ${key}`);
|
|
278
|
+
}
|
|
279
|
+
if (registry.entityGrants.length > 0) {
|
|
280
|
+
lines.push('', 'entity grants:');
|
|
281
|
+
for (const g of registry.entityGrants) {
|
|
282
|
+
lines.push(` ${g.entityType}: ${g.permissions.join(', ')}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (surface.envSpec) {
|
|
286
|
+
lines.push('', `env keys (code-declared, #1206): ${surface.envSpec.length}`);
|
|
287
|
+
for (const e of surface.envSpec) {
|
|
288
|
+
lines.push(` ${e.key}${e.required ? ' (required)' : ''}${e.secret ? ' (secret)' : ''}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
lines.push('', `digest: ${digest} (digests.permission — the promotion checkpoint compares this)`);
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
108
294
|
/**
|
|
109
295
|
* The MIME type a static file is SERVED as (#340). Cloudflare attaches the `Content-Type` of
|
|
110
296
|
* each uploaded part and replays it on every request, so this table is the vertical's served
|
|
@@ -269,6 +455,126 @@ export function readRuntimeNeeds(dir) {
|
|
|
269
455
|
}
|
|
270
456
|
return raw === undefined ? undefined : runtimeNeeds.parse(raw);
|
|
271
457
|
}
|
|
458
|
+
/**
|
|
459
|
+
* A string literal as `maskSource` left it: the quotes stand, the body is blank.
|
|
460
|
+
*
|
|
461
|
+
* The scanner is `boundary-lint`'s own, reused rather than rewritten — it already blanks
|
|
462
|
+
* comments, string bodies and regex literals for R7 and R8, and a second hand-rolled lexer
|
|
463
|
+
* here would be one more thing that has to agree with it about regex-versus-division. The
|
|
464
|
+
* mask is what makes this readable at all: a raw-text regex over source cannot tell an
|
|
465
|
+
* import from a line that merely quotes one, so `// import './assets.generated.js'` and
|
|
466
|
+
* `const help = "import './assets.generated.js'"` would both have granted the exemption
|
|
467
|
+
* below. Against the masked copy neither can — a comment and a string body are blank there,
|
|
468
|
+
* and a regex literal is blanked whole, so `/['"]/` cannot open a string.
|
|
469
|
+
*
|
|
470
|
+
* What the mask keeps is POSITION: same length, same offsets. So a specifier matched here is
|
|
471
|
+
* read straight back out of the original source, between the quotes the match found.
|
|
472
|
+
*/
|
|
473
|
+
const MASKED_LITERAL = String.raw `(['"\`])\s*?\1`;
|
|
474
|
+
/**
|
|
475
|
+
* A literal in import position, one pattern per form the language actually has.
|
|
476
|
+
*
|
|
477
|
+
* Split rather than one alternation because the forms differ in what may sit around the
|
|
478
|
+
* keyword, and a laxer shape reads an ordinary call as an import: `from` in an import takes
|
|
479
|
+
* no parenthesis (`from('./x')` is a method call), and `require` in an import is never a
|
|
480
|
+
* member (`loader.require('./x')` is somebody's loader). The `(?<![.$\w])` guard is what
|
|
481
|
+
* keeps `x.from`/`myRequire` out; `import` needs it too, though only against an identifier
|
|
482
|
+
* ending in it, since the word itself is reserved.
|
|
483
|
+
*/
|
|
484
|
+
/** `import x from './a'`, `export * from './a'` — no parenthesis, ever. */
|
|
485
|
+
const FROM_LITERAL = new RegExp(String.raw `(?<![.$\w])from\s*` + MASKED_LITERAL, 'g');
|
|
486
|
+
/** `import './a'` and `import('./a')`. */
|
|
487
|
+
const IMPORT_LITERAL = new RegExp(String.raw `(?<![.$\w])import\s*\(?\s*` + MASKED_LITERAL, 'g');
|
|
488
|
+
/** `require('./a')` — parenthesised, and not a method on something. */
|
|
489
|
+
const REQUIRE_LITERAL = new RegExp(String.raw `(?<![.$\w])require\s*\(\s*` + MASKED_LITERAL, 'g');
|
|
490
|
+
/** The `import`/`export` keyword a `from` belongs to — the LAST one before it. */
|
|
491
|
+
const IMPORT_KEYWORD = /(?<![.$\w])(?:import|export)\b/g;
|
|
492
|
+
/** The specifier this match found, read out of the UNMASKED source: the match's own quote
|
|
493
|
+
* opens it (nothing before it in any of the patterns above can be one) and ends it. */
|
|
494
|
+
function specifierOf(source, m) {
|
|
495
|
+
const open = m.index + m[0].indexOf(m[1]);
|
|
496
|
+
return source.slice(open + 1, m.index + m[0].length - 1);
|
|
497
|
+
}
|
|
498
|
+
/** What stands between the declaration's keyword and its `from`, or undefined if no keyword
|
|
499
|
+
* precedes it (which is not a declaration this understands). */
|
|
500
|
+
function clauseBefore(code, at) {
|
|
501
|
+
const prefix = code.slice(0, at);
|
|
502
|
+
let last;
|
|
503
|
+
for (const m of prefix.matchAll(IMPORT_KEYWORD))
|
|
504
|
+
last = m;
|
|
505
|
+
return last ? prefix.slice(last.index + last[0].length) : undefined;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Is this the clause of a declaration TypeScript ERASES — `import type … from`,
|
|
509
|
+
* `export type … from`, or one whose every named binding is `type`-prefixed?
|
|
510
|
+
*
|
|
511
|
+
* Such a declaration names the module without importing anything at runtime, so it is not
|
|
512
|
+
* evidence that the worker serves the app: the emitted JavaScript has no reference to the
|
|
513
|
+
* inlined-assets module at all.
|
|
514
|
+
*/
|
|
515
|
+
function isTypeOnlyClause(clause) {
|
|
516
|
+
const c = clause.trim();
|
|
517
|
+
if (/^type\b/.test(c))
|
|
518
|
+
return true;
|
|
519
|
+
const named = /^\{([^}]*)\}$/.exec(c);
|
|
520
|
+
if (!named)
|
|
521
|
+
return false;
|
|
522
|
+
const bindings = named[1].split(',').map((b) => b.trim()).filter(Boolean);
|
|
523
|
+
return bindings.length > 0 && bindings.every((b) => /^type\b/.test(b));
|
|
524
|
+
}
|
|
525
|
+
/** The specifier of the inlined-assets module, with or without an extension — TypeScript
|
|
526
|
+
* source writes it as `.js`, as `.ts`, or bare, and it may sit in a subdirectory. */
|
|
527
|
+
const INLINED_ASSETS_SPECIFIER = /(?:^|\/)assets\.generated(?:\.[cm]?[jt]sx?)?$/;
|
|
528
|
+
/** Does this module import the inlined-assets module? */
|
|
529
|
+
function importsInlinedAssets(source) {
|
|
530
|
+
const code = maskSource(source);
|
|
531
|
+
const isTheModule = (m) => INLINED_ASSETS_SPECIFIER.test(specifierOf(source, m));
|
|
532
|
+
for (const m of code.matchAll(FROM_LITERAL)) {
|
|
533
|
+
if (!isTheModule(m))
|
|
534
|
+
continue;
|
|
535
|
+
// The only form that can be erased: `import type … from './assets.generated.js'` names
|
|
536
|
+
// the module and imports nothing. Anything the clause scan cannot read is treated as a
|
|
537
|
+
// real import — the conservative direction here is to keep refusing, not to exempt.
|
|
538
|
+
const clause = clauseBefore(code, m.index);
|
|
539
|
+
if (clause !== undefined && isTypeOnlyClause(clause))
|
|
540
|
+
continue;
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
for (const pattern of [IMPORT_LITERAL, REQUIRE_LITERAL]) {
|
|
544
|
+
for (const m of code.matchAll(pattern))
|
|
545
|
+
if (isTheModule(m))
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Does the worker serve its front end from an inlined-assets module — the pre-#340 pattern,
|
|
552
|
+
* where a generated module holds the built bytes and `src/` serves them?
|
|
553
|
+
*
|
|
554
|
+
* Two pieces of evidence, and the second one is why this is a function (#1209). The
|
|
555
|
+
* generated module itself is BUILD OUTPUT and normally gitignored, while `assertUiIsServed`
|
|
556
|
+
* deliberately runs before the declared build — so on a fresh checkout, which is every CI
|
|
557
|
+
* run, the file simply is not there yet and the exemption missed a UI it would in fact have
|
|
558
|
+
* served. The import is the durable half: the worker's own source names the module, and
|
|
559
|
+
* that source is committed. Either one is enough.
|
|
560
|
+
*/
|
|
561
|
+
function servesInlinedAssets(src) {
|
|
562
|
+
if (!existsSync(src))
|
|
563
|
+
return false;
|
|
564
|
+
const files = walkFiles(src);
|
|
565
|
+
if (files.some((f) => /assets\.generated\.[cm]?[jt]s$/.test(f)))
|
|
566
|
+
return true;
|
|
567
|
+
return files.some((f) => {
|
|
568
|
+
if (!/\.[cm]?[jt]sx?$/.test(f))
|
|
569
|
+
return false;
|
|
570
|
+
try {
|
|
571
|
+
return importsInlinedAssets(readFileSync(f, 'utf8'));
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
}
|
|
272
578
|
/**
|
|
273
579
|
* The UI-reachability preflight (#881): a scaffolded `app/` that the manifest never
|
|
274
580
|
* declares ships a vertical whose front end is real, tested, and answers 404 at its own
|
|
@@ -286,7 +592,7 @@ export function readRuntimeNeeds(dir) {
|
|
|
286
592
|
* - no `assets` in EITHER vocabulary (runtimeNeeds or a hand-authored wrangler.jsonc),
|
|
287
593
|
* so nothing is uploaded to the runtime's asset store.
|
|
288
594
|
* - no inlined-assets module under `src/` — the pre-#340 base64 pattern serves its files
|
|
289
|
-
* from the worker and therefore declares nothing, correctly.
|
|
595
|
+
* from the worker and therefore declares nothing, correctly (`servesInlinedAssets`).
|
|
290
596
|
*
|
|
291
597
|
* `--allow-unserved-ui` is the deliberate override for the case this cannot see from the
|
|
292
598
|
* tree alone: an `app/` that is a mock, a fixture, or built and deployed by somebody else.
|
|
@@ -296,10 +602,7 @@ export function assertUiIsServed(dir, needs, assets, allowUnservedUi = false) {
|
|
|
296
602
|
return;
|
|
297
603
|
if (!existsSync(join(dir, 'app', 'index.html')))
|
|
298
604
|
return;
|
|
299
|
-
|
|
300
|
-
// the worker. It serves the app without declaring assets, and must keep pushing.
|
|
301
|
-
const src = join(dir, 'src');
|
|
302
|
-
if (existsSync(src) && walkFiles(src).some((f) => /assets\.generated\.[cm]?[jt]s$/.test(f)))
|
|
605
|
+
if (servesInlinedAssets(join(dir, 'src')))
|
|
303
606
|
return;
|
|
304
607
|
throw new Error([
|
|
305
608
|
'this vertical has a UI (app/index.html) that nothing in the push would serve.',
|
|
@@ -329,28 +632,34 @@ export function assertUiIsServed(dir, needs, assets, allowUnservedUi = false) {
|
|
|
329
632
|
...(needs ? [] : ['', ' (This vertical authors wrangler.jsonc; the assets block goes there instead.)']),
|
|
330
633
|
].join('\n'));
|
|
331
634
|
}
|
|
332
|
-
|
|
635
|
+
/**
|
|
636
|
+
* `log` is where the gate's own narration goes, and it is a parameter because one caller
|
|
637
|
+
* needs it off stdout: `substrat push --check --json` prints the registry as the ONLY thing
|
|
638
|
+
* on stdout, so a redirect into a file is a usable artifact. Everything else takes the
|
|
639
|
+
* default and reads exactly as before.
|
|
640
|
+
*/
|
|
641
|
+
export function assertLayerRules(dir, skipLint = false, log = console.log) {
|
|
333
642
|
const root = resolve(dir);
|
|
334
643
|
if (skipLint) {
|
|
335
|
-
|
|
336
|
-
return { root,
|
|
644
|
+
log('note: --skip-lint — the layer rules were NOT checked; this push is ungated');
|
|
645
|
+
return { root, gate: 'skipped' };
|
|
337
646
|
}
|
|
338
647
|
const config = loadBoundaryLintConfig(root);
|
|
339
648
|
const packages = resolvePackages(root, config);
|
|
340
649
|
const linted = packages.filter((p) => p.lint);
|
|
341
650
|
if (linted.length === 0) {
|
|
342
|
-
|
|
651
|
+
log('note: boundary-lint found no module code to check (expected `src/`, or a ' +
|
|
343
652
|
'`boundary-lint.config.json` naming it) — this push is ungated');
|
|
344
|
-
return { root,
|
|
653
|
+
return { root, gate: 'none' };
|
|
345
654
|
}
|
|
346
655
|
if (packages.every((p) => p.lint) && declaredEngines(root, config).length > 0) {
|
|
347
|
-
|
|
656
|
+
log('note: engines are declared but none resolved under node_modules/@substrat-run — ' +
|
|
348
657
|
'R5 (tables private) checked nothing this push');
|
|
349
658
|
}
|
|
350
659
|
const violations = lint(root, config);
|
|
351
660
|
if (violations.length === 0) {
|
|
352
|
-
|
|
353
|
-
return { root,
|
|
661
|
+
log(`boundary-lint: all layer rules hold (${linted.length} package(s))`);
|
|
662
|
+
return { root, gate: 'passed' };
|
|
354
663
|
}
|
|
355
664
|
throw new Error([
|
|
356
665
|
`${violations.length} layer-rule violation(s) — this vertical cannot be pushed.`,
|
|
@@ -454,11 +763,12 @@ export async function push(opts) {
|
|
|
454
763
|
// the source tree and needs nothing else — a violation is refused in a second rather than
|
|
455
764
|
// after a wrangler build whose output was never going to be admissible. Skipped only for
|
|
456
765
|
// the caller that just ran it on this same directory AND under the same skip decision
|
|
457
|
-
// (`opts.linted`, the CLI's pre-flight) — a skipped receipt is not a check.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
766
|
+
// (`opts.linted`, the CLI's pre-flight) — a skipped receipt is not a check. The receipt
|
|
767
|
+
// is KEPT either way: its verdict rides the upload as `origin.gate` below.
|
|
768
|
+
const linted = opts.linted?.root === resolve(opts.dir) &&
|
|
769
|
+
(opts.linted.gate === 'skipped') === Boolean(opts.skipLint)
|
|
770
|
+
? opts.linted
|
|
771
|
+
: assertLayerRules(opts.dir, opts.skipLint);
|
|
462
772
|
// Substrate-vocabulary path (D-38): when `substrat.runtimeNeeds` is present the builder
|
|
463
773
|
// authored no wrangler config, so none is read — the CLI derives it. The generated file
|
|
464
774
|
// lands next to the vertical (a relative `main` and the build command's cwd both resolve
|
|
@@ -524,7 +834,16 @@ export async function push(opts) {
|
|
|
524
834
|
// The declared permission surface (D-39/D-41), DERIVED from the vertical's typed
|
|
525
835
|
// `definePermissions(...)` entry — shipped in the manifest and hashed into digests.permission
|
|
526
836
|
// below. Throws if the vertical declares no surface: absence is never a silent empty registry.
|
|
527
|
-
|
|
837
|
+
// The same import reads the entry's `envSpec` export (#1206); when it exists it is the copy
|
|
838
|
+
// that ships, and a drifted package.json duplicate refuses the push.
|
|
839
|
+
const { registry, envSpec: derivedEnvSpec, schedules, freshness } = await deriveDeclaredSurface(opts.dir);
|
|
840
|
+
const envSpec = resolveDeclaredEnvSpec(derivedEnvSpec, opts.envSpec);
|
|
841
|
+
// The emitted entity model (#1214), read from the checked-in `model.json` beside
|
|
842
|
+
// package.json — the artifact of record (#697) — so the dashboard can render the
|
|
843
|
+
// DEPLOYED version's model. Absence is fine (a vertical that has not adopted the
|
|
844
|
+
// entity registry pushes without one); a model.json that fails the shape is refused,
|
|
845
|
+
// because shipping a manifest the control plane would bounce helps nobody.
|
|
846
|
+
const model = readDeclaredModel(opts.dir);
|
|
528
847
|
// Parsed with the SAME schema the control plane applies at the trust boundary
|
|
529
848
|
// (contracts' deployManifest, re-parsed server-side in control-plane-api). Drift
|
|
530
849
|
// between what the CLI builds and what the server accepts fails here, before the
|
|
@@ -574,8 +893,9 @@ export async function push(opts) {
|
|
|
574
893
|
: {}),
|
|
575
894
|
// The vertical's declared config surface, carried to the registry (control-plane-side
|
|
576
895
|
// validated) so the platform renders a settings form for it. Not part of any admission
|
|
577
|
-
// digest — it's metadata, not code.
|
|
578
|
-
|
|
896
|
+
// digest — it's metadata, not code. Resolved above: the code-side declaration when the
|
|
897
|
+
// entry exports one, else package.json's copy (#1206).
|
|
898
|
+
...(envSpec ? { envSpec } : {}),
|
|
579
899
|
// Registry-driven install metadata (marketplace-publish.md §3) — carried so the dashboard
|
|
580
900
|
// installs without a hardcoded catalog entry. Metadata, not code; not in any digest.
|
|
581
901
|
...(opts.ownerGrants ? { ownerGrants: opts.ownerGrants } : {}),
|
|
@@ -586,6 +906,13 @@ export async function push(opts) {
|
|
|
586
906
|
...(opts.sendsEmail ? { sendsEmail: true } : {}),
|
|
587
907
|
...(opts.usesModels ? { usesModels: true } : {}),
|
|
588
908
|
...(opts.surfaces ? { surfaces: opts.surfaces } : {}),
|
|
909
|
+
// The emitted entity model (#1214) — metadata like envSpec/surfaces, not in any digest:
|
|
910
|
+
// it describes what the migrations built, it does not build anything.
|
|
911
|
+
...(model ? { model } : {}),
|
|
912
|
+
// #1232: the declared schedules travel with the version — the dashboard's
|
|
913
|
+
// schedule-health view needs `everyMinutes`, which exists nowhere off the manifest.
|
|
914
|
+
...(schedules ? { schedules } : {}),
|
|
915
|
+
...(freshness ? { freshness } : {}),
|
|
589
916
|
// The declared outbound surface (#303, D-46) — ALWAYS sent, `[]` when undeclared,
|
|
590
917
|
// because absence means "pre-#303 push" to the egress worker (unenforced, metered
|
|
591
918
|
// only) and a new-CLI push must not read as that. Unlike the metadata above it is
|
|
@@ -609,8 +936,11 @@ export async function push(opts) {
|
|
|
609
936
|
if (opts.allowFork)
|
|
610
937
|
form.set('allowFork', '1');
|
|
611
938
|
// Provenance rides beside the pin for the same reason: it describes THIS push, not the
|
|
612
|
-
// code, so it stays out of every digest. Self-reported — a label, never authority.
|
|
613
|
-
|
|
939
|
+
// code, so it stays out of every digest. Self-reported — a label, never authority. The
|
|
940
|
+
// gate receipt rides with it (#955): the platform never sees the source this push was
|
|
941
|
+
// linted against, so recording what the gate found — or that it was skipped — is the
|
|
942
|
+
// only way an ungated version is a stored fact rather than a lost stdout warning.
|
|
943
|
+
form.set('origin', JSON.stringify({ ...pushOrigin(), gate: linted.gate }));
|
|
614
944
|
for (const m of modules) {
|
|
615
945
|
form.set(m.name, new Blob([m.content], { type: 'application/javascript+module' }), m.name);
|
|
616
946
|
}
|
|
@@ -677,6 +1007,32 @@ export function readVerticalMeta(dir) {
|
|
|
677
1007
|
outbound: s?.outbound,
|
|
678
1008
|
};
|
|
679
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* The vertical's emitted entity model (#1214), from the `model.json` beside its
|
|
1012
|
+
* package.json — the artifact `pnpm lint:model` emits and gates (#697). `undefined` when
|
|
1013
|
+
* there is none: a vertical that has not adopted the entity registry pushes exactly as it
|
|
1014
|
+
* did before. A present-but-malformed file REFUSES the push with the artifact named — it
|
|
1015
|
+
* means the file was hand-edited or emitted by an incompatible toolchain, and the control
|
|
1016
|
+
* plane would bounce the manifest anyway; failing here costs no network round-trip.
|
|
1017
|
+
*/
|
|
1018
|
+
export function readDeclaredModel(dir) {
|
|
1019
|
+
const file = join(dir, 'model.json');
|
|
1020
|
+
if (!existsSync(file))
|
|
1021
|
+
return undefined;
|
|
1022
|
+
let parsed;
|
|
1023
|
+
try {
|
|
1024
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
1025
|
+
}
|
|
1026
|
+
catch (e) {
|
|
1027
|
+
throw new Error(`${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
1028
|
+
}
|
|
1029
|
+
const result = emittedModel.safeParse(parsed);
|
|
1030
|
+
if (!result.success) {
|
|
1031
|
+
throw new Error(`${file} is not an emitted model — re-emit it (pnpm lint:model) rather than hand-editing.\n` +
|
|
1032
|
+
result.error.issues.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n'));
|
|
1033
|
+
}
|
|
1034
|
+
return result.data;
|
|
1035
|
+
}
|
|
680
1036
|
/**
|
|
681
1037
|
* Pin the pushed-to workspace into the project's package.json (`substrat.tenant`) so every
|
|
682
1038
|
* later push — any teammate, any machine, CI — lands in the same workspace without asking.
|