@substrat-run/cli 0.49.6 → 0.50.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 +8 -0
- package/dist/changeset.d.ts.map +1 -1
- package/dist/changeset.js +19 -6
- package/dist/changeset.js.map +1 -1
- package/dist/cli.js +96 -28
- package/dist/cli.js.map +1 -1
- package/dist/data-loss-check.d.ts.map +1 -1
- package/dist/data-loss-check.js +11 -1
- package/dist/data-loss-check.js.map +1 -1
- package/dist/dev-preload.d.ts +2 -0
- package/dist/dev-preload.d.ts.map +1 -0
- package/dist/dev-preload.js +32 -0
- package/dist/dev-preload.js.map +1 -0
- package/dist/dev-traces.d.ts +16 -0
- package/dist/dev-traces.d.ts.map +1 -0
- package/dist/dev-traces.js +188 -0
- package/dist/dev-traces.js.map +1 -0
- package/dist/dev.d.ts +6 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +65 -0
- package/dist/dev.js.map +1 -0
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +89 -0
- package/dist/doctor.js.map +1 -0
- package/dist/login.d.ts +12 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +22 -3
- package/dist/login.js.map +1 -1
- package/dist/outbound-scan.d.ts +1 -1
- package/dist/outbound-scan.d.ts.map +1 -1
- package/dist/outbound-scan.js +6 -4
- package/dist/outbound-scan.js.map +1 -1
- package/dist/promote.d.ts +2 -1
- package/dist/promote.d.ts.map +1 -1
- package/dist/promote.js +4 -3
- package/dist/promote.js.map +1 -1
- package/dist/prompt.d.ts +21 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +39 -0
- package/dist/prompt.js.map +1 -0
- package/dist/push.d.ts +38 -1
- package/dist/push.d.ts.map +1 -1
- package/dist/push.js +267 -111
- package/dist/push.js.map +1 -1
- package/dist/store-driver.d.ts.map +1 -1
- package/dist/store-driver.js +5 -3
- package/dist/store-driver.js.map +1 -1
- package/dist/worker-node-imports.d.ts.map +1 -1
- package/dist/worker-node-imports.js +2 -1
- package/dist/worker-node-imports.js.map +1 -1
- package/package.json +5 -5
package/dist/push.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import {
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { join, basename, delimiter, dirname, extname, relative, resolve } from 'node:path';
|
|
5
6
|
import { pathToFileURL } from 'node:url';
|
|
6
7
|
import { webcrypto } from 'node:crypto';
|
|
7
8
|
import { build } from 'esbuild';
|
|
8
|
-
import { migrationDigestOf, DECLARED_OUTPUT_FIELDS_MAX, ASSET_PART_PREFIX, assetHash, assetsNeed, ATTACHMENT_BLOB_BINDING, buildPermissionRegistry, declaredOwnerGrants, ownerGrantSets, reservedKeyMessage, reservedKeyOffences, reservedKeysOf, declaresAttachmentTargets, DECLARED_MIGRATIONS_MAX, DECLARED_MIGRATIONS_SQL_BYTES_MAX, DEPLOY_MANIFEST_BYTES_SAFE, deployManifest, emittedModel, declaredMigrationDeclarations, sqlBytes, utf8Length, envVarSpec, runtimeNeeds, RUNTIME_BASELINE, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, assertDeclaredScopeStores, storeNamesOfBindings, manifestStoresOf, } from '@substrat-run/contracts';
|
|
9
|
+
import { migrationDigestOf, DECLARED_OUTPUT_FIELDS_MAX, ASSET_PART_PREFIX, assetHash, assetsNeed, ATTACHMENT_BLOB_BINDING, buildPermissionRegistry, declaredOwnerGrants, ownerGrantSets, reservedKeyMessage, reservedKeyOffences, reservedKeysOf, declaresAttachmentTargets, DECLARED_MIGRATIONS_MAX, DECLARED_MIGRATIONS_SQL_BYTES_MAX, DEPLOY_MANIFEST_BYTES_SAFE, deployManifest, emittedModel, declaredMigrationDeclarations, sqlBytes, utf8Length, envVarSpec, runtimeNeeds, RUNTIME_BASELINE, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, assertDeclaredScopeStores, storeNamesOfBindings, manifestStoresOf, pushedConnectorProblems, verticalConnectorDeclarations, } from '@substrat-run/contracts';
|
|
9
10
|
import { lint, loadConfig as loadBoundaryLintConfig, resolvePackages, declaredEngines, formatViolations, maskSource, } from '@substrat-run/boundary-lint';
|
|
10
11
|
import { walkPages } from '@substrat-run/control-plane-client';
|
|
11
12
|
import { checkMigrationDataLoss } from './data-loss-check.js';
|
|
@@ -251,6 +252,32 @@ export function boundManifest(manifest, warn = console.warn) {
|
|
|
251
252
|
const { migrations: _omitted, ...rest } = manifest;
|
|
252
253
|
return rest;
|
|
253
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Bundle `contents` — an ES module whose imports resolve from `dir` — with every package left
|
|
257
|
+
* external, write it INTO `dir` so Node resolves those imports (@substrat-run/*, and any node
|
|
258
|
+
* addon a node-ful entry pulls) from the vertical's own node_modules, import it, and remove it.
|
|
259
|
+
* The unique name avoids the ESM import cache across pushes.
|
|
260
|
+
*/
|
|
261
|
+
async function importBundledAsData(dir, stem, contents) {
|
|
262
|
+
const out = join(dir, `.substrat.${stem}.${Date.now()}.mjs`);
|
|
263
|
+
try {
|
|
264
|
+
await build({
|
|
265
|
+
stdin: { contents, resolveDir: resolve(dir), sourcefile: `substrat-${stem}.mjs`, loader: 'js' },
|
|
266
|
+
bundle: true,
|
|
267
|
+
platform: 'node',
|
|
268
|
+
format: 'esm',
|
|
269
|
+
packages: 'external',
|
|
270
|
+
outfile: out,
|
|
271
|
+
logLevel: 'silent',
|
|
272
|
+
});
|
|
273
|
+
// @vite-ignore: this is a real filesystem path imported at runtime, never a bundler input —
|
|
274
|
+
// the comment keeps vitest/vite from trying to resolve it through their transform pipeline.
|
|
275
|
+
return (await import(/* @vite-ignore */ pathToFileURL(out).href));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
rmSync(out, { force: true });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
254
281
|
export async function deriveDeclaredSurface(dir) {
|
|
255
282
|
const pkgPath = join(dir, 'package.json');
|
|
256
283
|
// Named rather than left as an ENOENT trace: `substrat push --check` (#1205) is run from
|
|
@@ -274,121 +301,173 @@ export async function deriveDeclaredSurface(dir) {
|
|
|
274
301
|
if (!existsSync(entryPath)) {
|
|
275
302
|
throw new Error(`substrat.permissions points at "${entry}", which does not exist under ${dir}.`);
|
|
276
303
|
}
|
|
277
|
-
|
|
278
|
-
// and any node addon a node-ful entry pulls) from the vertical's own node_modules. Removed
|
|
279
|
-
// immediately after import. The unique name avoids the ESM import cache across pushes.
|
|
280
|
-
const out = join(dir, `.substrat.permissions.${Date.now()}.mjs`);
|
|
304
|
+
let mod;
|
|
281
305
|
try {
|
|
282
|
-
|
|
306
|
+
// The entry, plus the vertical's OWN kernel (#1677), imported where the vertical
|
|
307
|
+
// resolves it: `packages: 'external'` keeps the specifier, so it loads from the
|
|
308
|
+
// vertical's node_modules. Optional — a vertical with none still reads as data.
|
|
309
|
+
// Both absolute: a CI job names the dir relatively (`substrat push demos/ticket0`), and
|
|
310
|
+
// a relative path written as an import specifier is a BARE one — `packages: 'external'`
|
|
311
|
+
// then keeps `demos/…` as a package named `demos`, which Node cannot find. `join` also
|
|
312
|
+
// strips a leading `./`, so spelling the dir `./demos/…` would not have saved it.
|
|
313
|
+
// The specifiers are the VERTICAL's packages, resolved from its node_modules, never the CLI's
|
|
314
|
+
// own dependencies; spelled through `q` so `lint:deps`, which reads text, does not count them.
|
|
315
|
+
const q = JSON.stringify;
|
|
316
|
+
mod = await importBundledAsData(dir, 'permissions', `export * from ${q(resolve(entryPath))};\n` +
|
|
317
|
+
`export const __substratKernel = await import(${q('@substrat-run/kernel')}).catch(() => null);\n` +
|
|
318
|
+
// The spine the data-loss replay builds first (#2321), from the vertical's own
|
|
319
|
+
// adapter-sqlite — imported only when a replay runs, since it loads a native driver.
|
|
320
|
+
`export const __substratSpine = () => import(${q('@substrat-run/adapter-sqlite')}).then((m) => m.SCOPE_SPINE_DDL, () => undefined);\n` +
|
|
321
|
+
// Where that kernel resolves, for the replay's own process to import (#2321).
|
|
322
|
+
`export const __substratKernelUrl = (() => { try { return import.meta.resolve(${q('@substrat-run/kernel')}); } catch { return null; } })();\n`);
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
// The third silent-until-deploy failure (#1205): the entry exists and exports the right
|
|
326
|
+
// name, but cannot be read OUTSIDE the vertical's runtime — a worker-only import, a
|
|
327
|
+
// `node:*` on a path the bundler follows, an import-time side effect wanting a live host.
|
|
328
|
+
// Named as that, rather than as a bare esbuild/ESM stack, because the remedy is specific:
|
|
329
|
+
// `definePermissions(...)` returns a plain object, so the module holding it must stay
|
|
330
|
+
// importable as data.
|
|
331
|
+
throw new Error(`substrat.permissions points at "${entry}", which could not be bundled and imported as data — ` +
|
|
332
|
+
`the declared surface must be readable without a live host (no worker-only imports on the ` +
|
|
333
|
+
`path it pulls in, no import-time side effects).\n${e instanceof Error ? e.message : String(e)}`);
|
|
334
|
+
}
|
|
335
|
+
if (!mod.permissions) {
|
|
336
|
+
throw new Error(`${entry} exports no \`permissions\`. Export ` +
|
|
337
|
+
`\`const permissions = definePermissions({ modules, roles, entityGrants })\`.`);
|
|
338
|
+
}
|
|
339
|
+
let spec;
|
|
340
|
+
if (mod.envSpec !== undefined) {
|
|
341
|
+
if (!Array.isArray(mod.envSpec)) {
|
|
342
|
+
throw new Error(`${entry} exports \`envSpec\`, but it is not an array of env-var specs.`);
|
|
343
|
+
}
|
|
283
344
|
try {
|
|
284
|
-
|
|
285
|
-
// The entry, plus the vertical's OWN kernel (#1677), imported where the vertical
|
|
286
|
-
// resolves it: `packages: 'external'` keeps the specifier, so it loads from the
|
|
287
|
-
// vertical's node_modules. Optional — a vertical with none still reads as data.
|
|
288
|
-
// Both absolute: a CI job names the dir relatively (`substrat push demos/ticket0`), and
|
|
289
|
-
// a relative path written as an import specifier is a BARE one — `packages: 'external'`
|
|
290
|
-
// then keeps `demos/…` as a package named `demos`, which Node cannot find. `join` also
|
|
291
|
-
// strips a leading `./`, so spelling the dir `./demos/…` would not have saved it.
|
|
292
|
-
stdin: {
|
|
293
|
-
contents: `export * from ${JSON.stringify(resolve(entryPath))};\n` +
|
|
294
|
-
`export const __substratKernel = await import('@substrat-run/kernel').catch(() => null);\n` +
|
|
295
|
-
// The spine the data-loss replay builds first (#2321), from the vertical's own
|
|
296
|
-
// adapter-sqlite — imported only when a replay runs, since it loads a native driver.
|
|
297
|
-
`export const __substratSpine = () => import('@substrat-run/adapter-sqlite').then((m) => m.SCOPE_SPINE_DDL, () => undefined);\n` +
|
|
298
|
-
// Where that kernel resolves, for the replay's own process to import (#2321).
|
|
299
|
-
`export const __substratKernelUrl = (() => { try { return import.meta.resolve('@substrat-run/kernel'); } catch { return null; } })();\n`,
|
|
300
|
-
resolveDir: resolve(dir),
|
|
301
|
-
sourcefile: 'substrat-surface.mjs',
|
|
302
|
-
loader: 'js',
|
|
303
|
-
},
|
|
304
|
-
bundle: true,
|
|
305
|
-
platform: 'node',
|
|
306
|
-
format: 'esm',
|
|
307
|
-
packages: 'external',
|
|
308
|
-
outfile: out,
|
|
309
|
-
logLevel: 'silent',
|
|
310
|
-
});
|
|
311
|
-
// @vite-ignore: this is a real filesystem path imported at runtime, never a bundler input —
|
|
312
|
-
// the comment keeps vitest/vite from trying to resolve it through their transform pipeline.
|
|
313
|
-
mod = (await import(/* @vite-ignore */ pathToFileURL(out).href));
|
|
345
|
+
spec = mod.envSpec.map((e) => envVarSpec.parse(e));
|
|
314
346
|
}
|
|
315
347
|
catch (e) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// `node:*` on a path the bundler follows, an import-time side effect wanting a live host.
|
|
319
|
-
// Named as that, rather than as a bare esbuild/ESM stack, because the remedy is specific:
|
|
320
|
-
// `definePermissions(...)` returns a plain object, so the module holding it must stay
|
|
321
|
-
// importable as data.
|
|
322
|
-
throw new Error(`substrat.permissions points at "${entry}", which could not be bundled and imported as data — ` +
|
|
323
|
-
`the declared surface must be readable without a live host (no worker-only imports on the ` +
|
|
324
|
-
`path it pulls in, no import-time side effects).\n${e instanceof Error ? e.message : String(e)}`);
|
|
325
|
-
}
|
|
326
|
-
if (!mod.permissions) {
|
|
327
|
-
throw new Error(`${entry} exports no \`permissions\`. Export ` +
|
|
328
|
-
`\`const permissions = definePermissions({ modules, roles, entityGrants })\`.`);
|
|
348
|
+
throw new Error(`${entry} exports an \`envSpec\` that is not a valid env-var spec list.\n` +
|
|
349
|
+
`${e instanceof Error ? e.message : String(e)}`);
|
|
329
350
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
})),
|
|
365
|
-
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
351
|
+
}
|
|
352
|
+
// #2430: a key a module declares `holder: 'system'` in a role a person can hold, an entity grant
|
|
353
|
+
// shape or an owner grant (a module's, or this package's `substrat.ownerGrants`, which an install
|
|
354
|
+
// grants its owner), refused before anything uploads, in `lint:permissions`' own words. The
|
|
355
|
+
// registry build below refuses most of it too; this is the message that names the remedy.
|
|
356
|
+
const reserved = reservedKeysOf(mod.permissions.modules);
|
|
357
|
+
const offences = reservedKeyOffences({
|
|
358
|
+
roles: mod.permissions.roles,
|
|
359
|
+
grants: mod.permissions.entityGrants ?? [],
|
|
360
|
+
personGrants: ownerGrantSets(mod.permissions.modules, ownerGrants.grants),
|
|
361
|
+
}, reserved);
|
|
362
|
+
if (offences.length > 0)
|
|
363
|
+
throw new Error(reservedKeyMessage(basename(resolve(dir)), entry, offences, reserved));
|
|
364
|
+
// #1234: the declared half of a declared-vs-observed finding — what the modules say
|
|
365
|
+
// they emit and consume, which exists nowhere off their manifests.
|
|
366
|
+
const declaredEvents = flattenDeclaredEvents(mod.permissions);
|
|
367
|
+
const connectors = await readDeclaredConnectors(dir, pkg.substrat?.connectors);
|
|
368
|
+
const registry = buildPermissionRegistry(mod.permissions);
|
|
369
|
+
if (connectors) {
|
|
370
|
+
// The standing grants join the registry, so widening them moves the permission digest the
|
|
371
|
+
// promote checkpoint acknowledges. Sorted like the rest of the registry, so the digest is a
|
|
372
|
+
// function of content. Absent when there are none: a digest must not move for a vertical
|
|
373
|
+
// without connectors.
|
|
374
|
+
registry.connectors = connectors
|
|
375
|
+
.map((c) => ({ provider: c.provider, grants: [...c.grants].sort() }))
|
|
376
|
+
.sort((a, b) => a.provider.localeCompare(b.provider));
|
|
377
|
+
assertConnectorsAdmissible(basename(resolve(dir)), pkg.substrat?.connectors, connectors, registry, mod.permissions);
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
registry,
|
|
381
|
+
modules: mod.permissions.modules.map(({ manifest }) => ({
|
|
382
|
+
id: manifest.id,
|
|
383
|
+
permissions: manifest.permissions.map(({ key, description, holder }) => ({
|
|
384
|
+
key, description, ...(holder ? { holder } : {}),
|
|
366
385
|
})),
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
+
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
387
|
+
})),
|
|
388
|
+
envSpec: spec,
|
|
389
|
+
// #1232: the same import that yields the permission surface already holds every
|
|
390
|
+
// module manifest — the schedules ride out of it with zero extra reads.
|
|
391
|
+
schedules: flattenDeclaredSchedules(mod.permissions),
|
|
392
|
+
freshness: flattenDeclaredFreshness(mod.permissions),
|
|
393
|
+
declaredEvents: declaredEvents.events,
|
|
394
|
+
declaredEventsTruncated: declaredEvents.truncated,
|
|
395
|
+
declaresAttachments: declaresAttachmentTargets(mod.permissions.modules.map((m) => m.manifest)),
|
|
396
|
+
migrations: flattenDeclaredMigrations(mod.permissions, typeof mod.__substratKernel?.moduleMigrations === 'function'
|
|
397
|
+
? mod.__substratKernel.moduleMigrations
|
|
398
|
+
: undefined),
|
|
399
|
+
replay: {
|
|
400
|
+
kernel: mod.__substratKernel ?? null,
|
|
401
|
+
kernelUrl: typeof mod.__substratKernelUrl === 'string' ? mod.__substratKernelUrl : null,
|
|
402
|
+
modules: mod.permissions.modules,
|
|
403
|
+
spineDdl: async () => {
|
|
404
|
+
const ddl = await mod.__substratSpine?.();
|
|
405
|
+
return typeof ddl === 'string' ? ddl : undefined;
|
|
386
406
|
},
|
|
387
|
-
}
|
|
407
|
+
},
|
|
408
|
+
connectors,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The vertical's connectors (vertical-connectors.md §5.1): `substrat.connectors` names a module
|
|
413
|
+
* exporting `connectors`, a `defineConnectors(...)` value whose `declarations()` is the static half
|
|
414
|
+
* a push carries. Imported as data the way the permission entry is. Undefined when the package
|
|
415
|
+
* names no such module or its declarations are empty.
|
|
416
|
+
*/
|
|
417
|
+
async function readDeclaredConnectors(dir, entry) {
|
|
418
|
+
if (entry === undefined)
|
|
419
|
+
return undefined;
|
|
420
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
421
|
+
throw new Error(`${basename(resolve(dir))}/package.json declares \`substrat.connectors\` as ${JSON.stringify(entry)}, ` +
|
|
422
|
+
`not a path. Point it at the module that exports \`connectors = defineConnectors(...)\`.`);
|
|
388
423
|
}
|
|
389
|
-
|
|
390
|
-
|
|
424
|
+
const entryPath = join(dir, entry);
|
|
425
|
+
if (!existsSync(entryPath))
|
|
426
|
+
throw new Error(`substrat.connectors points at "${entry}", which does not exist under ${dir}.`);
|
|
427
|
+
let mod;
|
|
428
|
+
try {
|
|
429
|
+
mod = await importBundledAsData(dir, 'connectors', `export * from ${JSON.stringify(resolve(entryPath))};\n`);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
throw new Error(`substrat.connectors points at "${entry}", which could not be bundled and imported as data — ` +
|
|
433
|
+
`the connectors module must be importable without a live host (no worker-only imports on the ` +
|
|
434
|
+
`path it pulls in, no import-time side effects).\n${e instanceof Error ? e.message : String(e)}`);
|
|
435
|
+
}
|
|
436
|
+
const declarations = mod.connectors?.declarations;
|
|
437
|
+
if (typeof declarations !== 'function') {
|
|
438
|
+
throw new Error(`${entry} exports no \`connectors\` with a \`declarations()\`. Export ` +
|
|
439
|
+
`\`const connectors = defineConnectors({ … })\` from @substrat-run/vertical-host/connectors.`);
|
|
440
|
+
}
|
|
441
|
+
let raw;
|
|
442
|
+
try {
|
|
443
|
+
raw = declarations.call(mod.connectors);
|
|
391
444
|
}
|
|
445
|
+
catch (e) {
|
|
446
|
+
throw new Error(`${entry}: \`connectors.declarations()\` threw.\n${e instanceof Error ? e.message : String(e)}`);
|
|
447
|
+
}
|
|
448
|
+
const parsed = verticalConnectorDeclarations.safeParse(raw);
|
|
449
|
+
if (!parsed.success) {
|
|
450
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.length ? `${i.path.join('.')}: ` : ''}${i.message}`);
|
|
451
|
+
throw new Error(`${entry} declares connectors a push cannot carry:\n${issues.join('\n')}`);
|
|
452
|
+
}
|
|
453
|
+
return parsed.data.length > 0 ? parsed.data : undefined;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* vertical-connectors.md §7.1, the refusals the static half allows: a standing grant the registry
|
|
457
|
+
* does not declare or reserves to a system principal (`pushedConnectorProblems`, the check the
|
|
458
|
+
* control plane repeats), and a routed event type no module of the vertical declares it emits.
|
|
459
|
+
*/
|
|
460
|
+
function assertConnectorsAdmissible(vertical, entry, connectors, registry, permissions) {
|
|
461
|
+
const emitted = new Set(permissions.modules.flatMap((m) => (m.manifest.events?.emits ?? []).map((e) => e.type)));
|
|
462
|
+
const problems = [
|
|
463
|
+
...pushedConnectorProblems({ connectors, registry }),
|
|
464
|
+
...connectors.flatMap((c) => c.events.filter((t) => !emitted.has(t)).map((t) => `connector ${c.provider} routes ${t}, which no module of ${vertical} declares it emits`)),
|
|
465
|
+
];
|
|
466
|
+
if (problems.length === 0)
|
|
467
|
+
return;
|
|
468
|
+
throw new Error(`${vertical}: ${entry} declares connectors a push cannot carry:\n${problems.map((p) => ` - ${p}`).join('\n')}\n` +
|
|
469
|
+
`Remedy: a standing grant must be a key a module declares and does not reserve (\`holder: 'system'\`); ` +
|
|
470
|
+
`a routed event must be one a module's manifest lists under \`events.emits\`.`);
|
|
392
471
|
}
|
|
393
472
|
/** The declared permission surface alone — `deriveDeclaredSurface` for the callers that
|
|
394
473
|
* want only the registry (D-41). */
|
|
@@ -527,6 +606,13 @@ export function formatPermissionSurface(surface, label) {
|
|
|
527
606
|
lines.push(` ${l.provider}: ${l.operation} holding ${l.permission}`);
|
|
528
607
|
}
|
|
529
608
|
}
|
|
609
|
+
// A vertical-defined connector's standing grants, which the digest also covers.
|
|
610
|
+
if (registry.connectors?.length) {
|
|
611
|
+
lines.push('', 'connector standing grants:');
|
|
612
|
+
for (const c of registry.connectors) {
|
|
613
|
+
lines.push(` ${c.provider}: ${c.grants.length ? c.grants.join(', ') : '(none)'}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
530
616
|
if (surface.envSpec) {
|
|
531
617
|
lines.push('', `env keys (code-declared, #1206): ${surface.envSpec.length}`);
|
|
532
618
|
for (const e of surface.envSpec) {
|
|
@@ -1206,18 +1292,84 @@ export function assertAttachmentBindingFree(bindings, blobStores) {
|
|
|
1206
1292
|
export function generatedConfigPath(dir) {
|
|
1207
1293
|
return resolve(dir, '.wrangler.substrat.json');
|
|
1208
1294
|
}
|
|
1295
|
+
/**
|
|
1296
|
+
* The vertical's own wrangler: the script its `bin` names, and the version that script is.
|
|
1297
|
+
*
|
|
1298
|
+
* Resolved the way Node resolves an import from the vertical (its `node_modules`, then each
|
|
1299
|
+
* parent's, so a workspace that hoists wrangler to its root still finds it), and run as
|
|
1300
|
+
* `node <bin>`. Not through `npx`, for two reasons:
|
|
1301
|
+
*
|
|
1302
|
+
* - On Windows `npx` is `npx.cmd`. Node will not spawn a batch file without a shell
|
|
1303
|
+
* (CVE-2024-27980), and through a shell every argument is parsed twice — by cmd.exe and
|
|
1304
|
+
* again by the batch file's `%*` — so a build path holding a space, `&`, `%` or `^` cannot
|
|
1305
|
+
* be passed safely. `push` failed there with `spawnSync npx ENOENT` before it built anything.
|
|
1306
|
+
* - `npx` downloads the newest wrangler when the project has none, without asking when stdin
|
|
1307
|
+
* is not a TTY. The bundle a deploy ships then depends on the day it was built. The
|
|
1308
|
+
* project's pinned wrangler is the only one this runs; a project without one is told so.
|
|
1309
|
+
*
|
|
1310
|
+
* Every node_modules directory is checked for `wrangler/package.json` on disk rather than
|
|
1311
|
+
* through `require.resolve('wrangler/package.json')`, so the lookup never depends on which
|
|
1312
|
+
* subpaths a given wrangler release lists in its `exports`.
|
|
1313
|
+
*/
|
|
1314
|
+
export function resolveWrangler(dir) {
|
|
1315
|
+
const root = resolve(dir);
|
|
1316
|
+
const candidates = createRequire(join(root, 'package.json')).resolve.paths('wrangler') ?? [];
|
|
1317
|
+
const pkgFile = candidates.map((nm) => join(nm, 'wrangler', 'package.json')).find((f) => existsSync(f));
|
|
1318
|
+
if (!pkgFile) {
|
|
1319
|
+
throw new Error([
|
|
1320
|
+
`wrangler is not installed in this project (${root}).`,
|
|
1321
|
+
' substrat push builds the Worker with the wrangler your project pins, and never downloads one.',
|
|
1322
|
+
' Add it as a devDependency, then push again:',
|
|
1323
|
+
' npm install --save-dev wrangler (or: pnpm add -D wrangler, yarn add -D wrangler)',
|
|
1324
|
+
' Yarn Plug\'n\'Play installs no node_modules, which the push reads: set `nodeLinker: node-modules`',
|
|
1325
|
+
' in .yarnrc.yml and run `yarn install`.',
|
|
1326
|
+
].join('\n'));
|
|
1327
|
+
}
|
|
1328
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
1329
|
+
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.wrangler;
|
|
1330
|
+
if (!rel)
|
|
1331
|
+
throw new Error(`${pkgFile} names no \`wrangler\` bin — reinstall wrangler in this project`);
|
|
1332
|
+
return { bin: resolve(dirname(pkgFile), rel), version: pkg.version ?? 'unknown' };
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* The environment wrangler builds in: this process's, with the project's `node_modules/.bin`
|
|
1336
|
+
* directories (its own, then each parent's, nearest first) and Node's own directory put in
|
|
1337
|
+
* front of PATH.
|
|
1338
|
+
*
|
|
1339
|
+
* That is what `npx` used to give it, and the project's build command depends on it. Wrangler
|
|
1340
|
+
* runs `build.command` (`runtimeNeeds.build`) through a shell on the PATH it was handed, so a
|
|
1341
|
+
* build of `vite build` or `tsc` finds the project's own `vite` or `tsc` only when its `.bin`
|
|
1342
|
+
* is there. A CLI installed globally, or started from a repository root, has no such entry.
|
|
1343
|
+
*/
|
|
1344
|
+
export function wranglerEnv(dir, env = process.env) {
|
|
1345
|
+
const bins = [];
|
|
1346
|
+
for (let at = resolve(dir);; at = dirname(at)) {
|
|
1347
|
+
bins.push(join(at, 'node_modules', '.bin'));
|
|
1348
|
+
if (dirname(at) === at)
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
// Windows names it `Path`, and environment keys are case-insensitive there. A copy is a
|
|
1352
|
+
// plain object, so set the key that is already present instead of adding a second one.
|
|
1353
|
+
const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
|
|
1354
|
+
const path = [...bins, dirname(process.execPath), env[key]].filter(Boolean).join(delimiter);
|
|
1355
|
+
return { ...env, [key]: path };
|
|
1356
|
+
}
|
|
1209
1357
|
const checkedWorkerBundles = new WeakSet();
|
|
1210
1358
|
/** Run Wrangler's configured build once, then check the source graph it produced. */
|
|
1211
1359
|
export async function prepareWorkerBundle(dir, resolved = resolveWranglerConfig(dir), label = 'Worker', output = 'stdout') {
|
|
1212
1360
|
const { cfg, needs } = resolved;
|
|
1361
|
+
// Before anything is written: a project without wrangler leaves no temp dir or config behind.
|
|
1362
|
+
const wrangler = resolveWrangler(dir);
|
|
1213
1363
|
const out = mkdtempSync(join(tmpdir(), 'substrat-build-'));
|
|
1214
1364
|
const generated = needs ? generatedConfigPath(dir) : undefined;
|
|
1215
|
-
(output === 'stderr' ? console.error : console.log)(`building ${label} …`);
|
|
1365
|
+
(output === 'stderr' ? console.error : console.log)(`building ${label} with wrangler ${wrangler.version} …`);
|
|
1216
1366
|
try {
|
|
1217
1367
|
if (generated)
|
|
1218
1368
|
writeFileSync(generated, JSON.stringify(cfg, null, 2) + '\n');
|
|
1219
1369
|
try {
|
|
1220
|
-
|
|
1370
|
+
// `node <wrangler bin>`, no shell: each argument reaches wrangler as one argv entry on
|
|
1371
|
+
// every OS, so `out` and `generated` may hold spaces or non-ASCII (see resolveWrangler).
|
|
1372
|
+
execFileSync(process.execPath, [wrangler.bin, 'deploy', '--dry-run', '--outdir', out, ...(generated ? ['--config', generated] : [])], { cwd: dir, env: wranglerEnv(dir), stdio: output === 'stderr' ? ['inherit', 2, 2] : 'inherit' });
|
|
1221
1373
|
}
|
|
1222
1374
|
finally {
|
|
1223
1375
|
if (generated)
|
|
@@ -1286,7 +1438,7 @@ export async function push(opts) {
|
|
|
1286
1438
|
// below. Throws if the vertical declares no surface: absence is never a silent empty registry.
|
|
1287
1439
|
// The same import reads the entry's `envSpec` export (#1206); when it exists it is the copy
|
|
1288
1440
|
// that ships, and a drifted package.json duplicate refuses the push.
|
|
1289
|
-
const { registry, modules: declaredModules, envSpec: derivedEnvSpec, schedules, freshness, declaredEvents, declaredEventsTruncated, migrations, declaresAttachments, replay, } = await deriveDeclaredSurface(opts.dir);
|
|
1441
|
+
const { registry, modules: declaredModules, envSpec: derivedEnvSpec, schedules, freshness, declaredEvents, declaredEventsTruncated, migrations, declaresAttachments, replay, connectors, } = await deriveDeclaredSurface(opts.dir);
|
|
1290
1442
|
// #2321: the vertical's migrations, replayed against a populated in-memory scope through its own
|
|
1291
1443
|
// kernel's data-loss fence — before anything is uploaded, so a migration that would close every
|
|
1292
1444
|
// populated scope at promote is refused here instead. Throws on a refusal.
|
|
@@ -1431,6 +1583,10 @@ export async function push(opts) {
|
|
|
1431
1583
|
// `[]` when undeclared, because absence means "pre-#1706 push" (unenforced) and a
|
|
1432
1584
|
// new-CLI push must not read as that. Enforcement input, versioned with the code.
|
|
1433
1585
|
calls: opts.calls ?? [],
|
|
1586
|
+
// The vertical's connector declarations (vertical-connectors.md §5.1), absent when none, so a
|
|
1587
|
+
// vertical without connectors pushes the manifest it did before. Their standing grants are
|
|
1588
|
+
// also in `registry.connectors`, which the permission digest covers.
|
|
1589
|
+
...(connectors ? { connectors } : {}),
|
|
1434
1590
|
// The declared permission surface travels with the bundle (D-39/D-41): keys+descriptions,
|
|
1435
1591
|
// role templates, entity-grant shapes. Required — its content hash is digests.permission.
|
|
1436
1592
|
registry,
|