@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.
Files changed (52) hide show
  1. package/README.md +8 -0
  2. package/dist/changeset.d.ts.map +1 -1
  3. package/dist/changeset.js +19 -6
  4. package/dist/changeset.js.map +1 -1
  5. package/dist/cli.js +96 -28
  6. package/dist/cli.js.map +1 -1
  7. package/dist/data-loss-check.d.ts.map +1 -1
  8. package/dist/data-loss-check.js +11 -1
  9. package/dist/data-loss-check.js.map +1 -1
  10. package/dist/dev-preload.d.ts +2 -0
  11. package/dist/dev-preload.d.ts.map +1 -0
  12. package/dist/dev-preload.js +32 -0
  13. package/dist/dev-preload.js.map +1 -0
  14. package/dist/dev-traces.d.ts +16 -0
  15. package/dist/dev-traces.d.ts.map +1 -0
  16. package/dist/dev-traces.js +188 -0
  17. package/dist/dev-traces.js.map +1 -0
  18. package/dist/dev.d.ts +6 -0
  19. package/dist/dev.d.ts.map +1 -0
  20. package/dist/dev.js +65 -0
  21. package/dist/dev.js.map +1 -0
  22. package/dist/doctor.d.ts +34 -0
  23. package/dist/doctor.d.ts.map +1 -0
  24. package/dist/doctor.js +89 -0
  25. package/dist/doctor.js.map +1 -0
  26. package/dist/login.d.ts +12 -0
  27. package/dist/login.d.ts.map +1 -1
  28. package/dist/login.js +22 -3
  29. package/dist/login.js.map +1 -1
  30. package/dist/outbound-scan.d.ts +1 -1
  31. package/dist/outbound-scan.d.ts.map +1 -1
  32. package/dist/outbound-scan.js +6 -4
  33. package/dist/outbound-scan.js.map +1 -1
  34. package/dist/promote.d.ts +2 -1
  35. package/dist/promote.d.ts.map +1 -1
  36. package/dist/promote.js +4 -3
  37. package/dist/promote.js.map +1 -1
  38. package/dist/prompt.d.ts +21 -0
  39. package/dist/prompt.d.ts.map +1 -0
  40. package/dist/prompt.js +39 -0
  41. package/dist/prompt.js.map +1 -0
  42. package/dist/push.d.ts +38 -1
  43. package/dist/push.d.ts.map +1 -1
  44. package/dist/push.js +267 -111
  45. package/dist/push.js.map +1 -1
  46. package/dist/store-driver.d.ts.map +1 -1
  47. package/dist/store-driver.js +5 -3
  48. package/dist/store-driver.js.map +1 -1
  49. package/dist/worker-node-imports.d.ts.map +1 -1
  50. package/dist/worker-node-imports.js +2 -1
  51. package/dist/worker-node-imports.js.map +1 -1
  52. 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 { join, basename, extname, relative, resolve } from 'node:path';
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
- // Written INTO the vertical dir so Node resolves the externalised imports (@substrat-run/*,
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
- let mod;
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
- await build({
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
- // The third silent-until-deploy failure (#1205): the entry exists and exports the right
317
- // name, but cannot be read OUTSIDE the vertical's runtime — a worker-only import, a
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
- let spec;
331
- if (mod.envSpec !== undefined) {
332
- if (!Array.isArray(mod.envSpec)) {
333
- throw new Error(`${entry} exports \`envSpec\`, but it is not an array of env-var specs.`);
334
- }
335
- try {
336
- spec = mod.envSpec.map((e) => envVarSpec.parse(e));
337
- }
338
- catch (e) {
339
- throw new Error(`${entry} exports an \`envSpec\` that is not a valid env-var spec list.\n` +
340
- `${e instanceof Error ? e.message : String(e)}`);
341
- }
342
- }
343
- // #2430: a key a module declares `holder: 'system'` in a role a person can hold, an entity grant
344
- // shape or an owner grant (a module's, or this package's `substrat.ownerGrants`, which an install
345
- // grants its owner), refused before anything uploads, in `lint:permissions`' own words. The
346
- // registry build below refuses most of it too; this is the message that names the remedy.
347
- const reserved = reservedKeysOf(mod.permissions.modules);
348
- const offences = reservedKeyOffences({
349
- roles: mod.permissions.roles,
350
- grants: mod.permissions.entityGrants ?? [],
351
- personGrants: ownerGrantSets(mod.permissions.modules, ownerGrants.grants),
352
- }, reserved);
353
- if (offences.length > 0)
354
- throw new Error(reservedKeyMessage(basename(resolve(dir)), entry, offences, reserved));
355
- // #1234: the declared half of a declared-vs-observed finding — what the modules say
356
- // they emit and consume, which exists nowhere off their manifests.
357
- const declaredEvents = flattenDeclaredEvents(mod.permissions);
358
- return {
359
- registry: buildPermissionRegistry(mod.permissions),
360
- modules: mod.permissions.modules.map(({ manifest }) => ({
361
- id: manifest.id,
362
- permissions: manifest.permissions.map(({ key, description, holder }) => ({
363
- key, description, ...(holder ? { holder } : {}),
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
- envSpec: spec,
368
- // #1232: the same import that yields the permission surface already holds every
369
- // module manifest — the schedules ride out of it with zero extra reads.
370
- schedules: flattenDeclaredSchedules(mod.permissions),
371
- freshness: flattenDeclaredFreshness(mod.permissions),
372
- declaredEvents: declaredEvents.events,
373
- declaredEventsTruncated: declaredEvents.truncated,
374
- declaresAttachments: declaresAttachmentTargets(mod.permissions.modules.map((m) => m.manifest)),
375
- migrations: flattenDeclaredMigrations(mod.permissions, typeof mod.__substratKernel?.moduleMigrations === 'function'
376
- ? mod.__substratKernel.moduleMigrations
377
- : undefined),
378
- replay: {
379
- kernel: mod.__substratKernel ?? null,
380
- kernelUrl: typeof mod.__substratKernelUrl === 'string' ? mod.__substratKernelUrl : null,
381
- modules: mod.permissions.modules,
382
- spineDdl: async () => {
383
- const ddl = await mod.__substratSpine?.();
384
- return typeof ddl === 'string' ? ddl : undefined;
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
- finally {
390
- rmSync(out, { force: true });
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
- execFileSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', out, ...(generated ? ['--config', generated] : [])], { cwd: dir, stdio: output === 'stderr' ? ['inherit', 2, 2] : 'inherit' });
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,