@substrat-run/cli 0.44.0 → 0.46.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 (42) hide show
  1. package/dist/bump.d.ts +120 -0
  2. package/dist/bump.d.ts.map +1 -0
  3. package/dist/bump.js +780 -0
  4. package/dist/bump.js.map +1 -0
  5. package/dist/changeset.d.ts +129 -0
  6. package/dist/changeset.d.ts.map +1 -0
  7. package/dist/changeset.js +336 -0
  8. package/dist/changeset.js.map +1 -0
  9. package/dist/cli.js +193 -41
  10. package/dist/cli.js.map +1 -1
  11. package/dist/concept.d.ts +19 -0
  12. package/dist/concept.d.ts.map +1 -0
  13. package/dist/concept.js +25 -0
  14. package/dist/concept.js.map +1 -0
  15. package/dist/data-loss-check.d.ts +70 -0
  16. package/dist/data-loss-check.d.ts.map +1 -0
  17. package/dist/data-loss-check.js +260 -0
  18. package/dist/data-loss-check.js.map +1 -0
  19. package/dist/data-loss-replay-child.d.ts +27 -0
  20. package/dist/data-loss-replay-child.d.ts.map +1 -0
  21. package/dist/data-loss-replay-child.js +71 -0
  22. package/dist/data-loss-replay-child.js.map +1 -0
  23. package/dist/promote.d.ts +16 -1
  24. package/dist/promote.d.ts.map +1 -1
  25. package/dist/promote.js +87 -11
  26. package/dist/promote.js.map +1 -1
  27. package/dist/push.d.ts +32 -6
  28. package/dist/push.d.ts.map +1 -1
  29. package/dist/push.js +94 -32
  30. package/dist/push.js.map +1 -1
  31. package/dist/scope.d.ts +23 -2
  32. package/dist/scope.d.ts.map +1 -1
  33. package/dist/scope.js +63 -7
  34. package/dist/scope.js.map +1 -1
  35. package/dist/store-driver.d.ts +59 -0
  36. package/dist/store-driver.d.ts.map +1 -0
  37. package/dist/{store-kind.js → store-driver.js} +58 -22
  38. package/dist/store-driver.js.map +1 -0
  39. package/package.json +4 -4
  40. package/dist/store-kind.d.ts +0 -46
  41. package/dist/store-kind.d.ts.map +0 -1
  42. package/dist/store-kind.js.map +0 -1
package/dist/cli.js CHANGED
@@ -20,24 +20,27 @@
20
20
  * (in the console) still gates serving.
21
21
  */
22
22
  import { bindConnectionScope, listConnectionBindings } from './connections.js';
23
- import { join } from 'node:path';
23
+ import { join, resolve as resolvePath } from 'node:path';
24
24
  import { createInterface } from 'node:readline';
25
25
  import { loadConfig, saveConfig, resolveAuth } from './config.js';
26
26
  import { browserLogin } from './login.js';
27
27
  import { push, readVerticalMeta, nextVersion, previewVersion, pinTenant, assertLayerRules, assertSchedulesAreSwept, checkPermissionSurface, deployConfigIfAny, formatDeclaredModel, formatPermissionSurface, readDeclaredModel, } from './push.js';
28
28
  import { printVersions } from './versions.js';
29
29
  import { checkOutboundDeclared } from './outbound-scan.js';
30
- import { exportBreakLines, promote } from './promote.js';
30
+ import { exportBreakLines, parseReviewToken, promote } from './promote.js';
31
31
  import { setListing, requestPublish } from './listing.js';
32
- import { isReleaseSha, packedFilesOf, packStoreKindDir, rollBackStoreKind, sourceCommitOf, rollOutStoreKind, storeKindStatus, storeKindStatusLines, uploadStoreKindRelease } from './store-kind.js';
32
+ import { isReleaseSha, packedFilesOf, packStoreDriverDir, rebuildStoreDriver, rollBackStoreDriver, sourceCommitOf, rollOutStoreDriver, storeDriverRebuildLines, storeDriverStatus, storeDriverStatusLines, uploadStoreDriverRelease } from './store-driver.js';
33
33
  import { fetchWhoami } from './whoami.js';
34
34
  import { planeFor } from './plane.js';
35
+ import { dataLossCheckRequired, formatDataLossCheck } from './data-loss-check.js';
35
36
  import { cliVersion, warnIfDistStale } from './version.js';
36
37
  import { pullScope, restoreScope, resolveTenantId, adoptScopeServing, adoptVerticalServing, provisionScope, rebindScopeVertical, bindScopeVersion, scopeStatus } from './scope.js';
37
38
  import { printInstalls } from './installs.js';
38
39
  import { createPreview, deletePreview, listPreviews, formatPreviewLogin, formatPreviews, parseTtlHours } from './preview.js';
39
40
  import { writeCiWorkflow, detectDefaultBranch, nextStepsMessage } from './init.js';
40
41
  import { writeModelView } from './model.js';
42
+ import { CannotRun, changesetCommand } from './changeset.js';
43
+ import { syncConceptFile } from './concept.js';
41
44
  import { listVerticalHostnames, bindSurfaceHostname, bindScopeHostname, unbindHostname, verifyHostname, formatHostnames, formatDnsRecords, } from './hostnames.js';
42
45
  const argv = process.argv.slice(2);
43
46
  /** `--name value` → value, else undefined. */
@@ -107,12 +110,25 @@ Usage:
107
110
  defineScopeSweeperDO are refused — they
108
111
  would never fire; --allow-unswept-schedules
109
112
  pushes them anyway
113
+ Authored migrations are replayed against a
114
+ populated in-memory scope through the
115
+ vertical's own kernel's data-loss fence;
116
+ one that would lose undeclared data is
117
+ refused (declare it: moves / discards).
118
+ A check skipped (no node:sqlite, Node
119
+ < 22.13) or incomplete (a migration that
120
+ cannot run on made-up rows, or runs past
121
+ 30 s) is a warning;
122
+ --require-data-loss-check (or
123
+ SUBSTRAT_REQUIRE_DATA_LOSS_CHECK=1/true/yes)
124
+ makes it a refusal
110
125
  A host the code names as a literal that
111
126
  package.json substrat.outbound does not
112
127
  declare is warned about with the line to add
113
128
  (best effort); --strict makes it an error
114
129
  substrat push [dir] --check [--json] run the push's LOCAL gate and stop: the layer
115
- rules, the entity model this tree would ship
130
+ rules, the data-loss migration replay, the
131
+ entity model this tree would ship
116
132
  (or a note that it would ship none), then the
117
133
  declared permission surface — resolve
118
134
  "substrat": { "permissions" }, import it,
@@ -124,13 +140,19 @@ Usage:
124
140
  the registry as data instead of prose
125
141
  substrat promote <slug> --version <versionId>
126
142
  [--ack-permissions] [--ack-migrations] [--ack-export-break] (prod is the only channel)
143
+ [--review <token>] the review the acks answer, as the refusal printed it
127
144
  substrat publish <slug> request listing on the public marketplace (staff reviews)
128
- substrat store-kind upload --kind <kind> --dir <dir> --entry <kind_x/…/module.py> [--commit <sha>]
129
- store a release of a scope-store kind (staff, signed in)
130
- substrat store-kind rollout --kind <kind> --sha256 <sha256>
145
+ substrat store-driver upload --driver <driver> --dir <dir> --entry <driver_x/…/module.py> [--commit <sha>]
146
+ store a release of a scope-store driver (staff, signed in)
147
+ substrat store-driver rollout --driver <driver> --sha256 <sha256>
131
148
  canary a stored, pinned release, then roll it out
132
- substrat store-kind rollback --kind <kind> return to the previous good release
133
- substrat store-kind status --kind <kind> a kind's current release and rollouts
149
+ substrat store-driver rollback --driver <driver> return to the previous good release
150
+ substrat store-driver status --driver <driver> a driver's current release and rollouts
151
+ substrat store-driver rebuild --driver <driver> [--deploy-finished]
152
+ rebuild every script of the driver now, at
153
+ the release it runs (after a key is revoked);
154
+ --deploy-finished attests no control-plane
155
+ deploy is rolling out
134
156
  substrat unpublish <slug> remove from the public marketplace (staff)
135
157
  substrat versions <slug> list a vertical's versions + channels
136
158
  substrat installs <slug> list this workspace's installs of a vertical
@@ -147,6 +169,7 @@ Usage:
147
169
  Requires its existing scoped landing grant.
148
170
  Planima: --organization-id <id|null>, --currency,
149
171
  --horizon-years, --from-year and --to-year.
172
+ Microsoft 365: --folder <channel folder's drive path>.
150
173
  substrat connection bindings <id> read the connection's configured landing targets
151
174
  substrat scope restore <scopeId> --file <backup>
152
175
  load a backup into an existing hosted scope,
@@ -180,8 +203,10 @@ Usage:
180
203
  lineage's serving script, data carried;
181
204
  the old copy is deleted once the move is
182
205
  confirmed, so snapshot first to keep a way
183
- back (staff; --ack-migrations to cross a
184
- differing migration surface;
206
+ back (staff, or a workspace for its own
207
+ scope; --ack-migrations with the
208
+ --review token its refusal prints, to
209
+ cross a differing migration surface;
185
210
  --abandon-data for a source script that
186
211
  predates /internal/export — directory-only
187
212
  flip, then re-provision the scope)
@@ -206,7 +231,8 @@ Usage:
206
231
  re-forks from prod. --ttl is the GC backstop
207
232
  (default 72h; 'none' pins the preview until deleted).
208
233
  Takes push's own overrides too: --skip-lint,
209
- --allow-unserved-ui and --allow-unswept-schedules
234
+ --allow-unserved-ui, --allow-unswept-schedules
235
+ and --require-data-loss-check
210
236
  substrat preview delete --tag <tag> [--slug <s>] reap a preview (idempotent)
211
237
  substrat preview ls [--slug <s>] list a vertical's active previews
212
238
  substrat model view [dir|model.json] render the entity model as a self-contained
@@ -215,6 +241,20 @@ Usage:
215
241
  open it in a browser. Reads the checked-in
216
242
  model.json, so it needs no login and no push.
217
243
  Writes a temp file unless --out places it
244
+ substrat changeset [dir] [--base <ref>] write a .changeset file whose bump is COMPUTED
245
+ [--summary <text>] from the diff of model.json, openapi.json and
246
+ [--check] [--json] PERMISSIONS.md against the merge-base: removed
247
+ or narrowed → major, added → minor, docs → patch;
248
+ permission changes are flagged for review.
249
+ --check refuses a changeset declaring less than
250
+ the computed bump (exit 1); --json prints the
251
+ classification. --base defaults to the
252
+ changesets baseBranch on origin
253
+ substrat concept sync [dir] [--check] rewrite the generated regions of spec/concept.md —
254
+ roles and permissions, the entity diagram, one state
255
+ diagram per lifecycle — from model.json and
256
+ PERMISSIONS.md. Idempotent; --check writes nothing
257
+ and exits 1 when the file is stale
218
258
  substrat init --ci github [dir] write .github/workflows/substrat-deploy.yml —
219
259
  [--branch <b>] [--path <packageDir>] [--release trunk|changesets]
220
260
  [--out <path>] [--force]
@@ -245,7 +285,9 @@ so '--promote prod' is a complete deploy. Once a vertical is LISTED on the marke
245
285
  pushes land PENDING and prod promotion + admission are a staff decision again. A promotion
246
286
  that changes the permission or migration surface is refused until the change is
247
287
  acknowledged (--ack-permissions / --ack-migrations) — the refusal prints the permission
248
- diff and each new migration's SQL; read them first. For a non-prod environment, run the version against a copy of the data with 'substrat preview
288
+ diff and each new migration's SQL; read them first. It also prints a --review token: the
289
+ acknowledgement answers that review, and is refused if prod moved since it was read (a
290
+ migration acknowledgement is refused without one). For a non-prod environment, run the version against a copy of the data with 'substrat preview
249
291
  create' — dev/staging channels were retired (#509).
250
292
 
251
293
  Auth resolves: explicit --token/SUBSTRAT_SERVICE_TOKEN → stored browser session →
@@ -373,7 +415,10 @@ async function cmdPush() {
373
415
  // rules above have already run, which makes `substrat push --check` the whole local gate.
374
416
  // Every failure throws (main() prints it and exits non-zero) — that is the gate.
375
417
  if (argv.includes('--check')) {
376
- const surface = await checkPermissionSurface(dir);
418
+ const surface = await checkPermissionSurface(dir, {
419
+ require: dataLossCheckRequired(argv),
420
+ log: jsonCheck ? console.error : console.log,
421
+ });
377
422
  // The schedule check (#1646) the push would make — `--check` is the whole local gate, so
378
423
  // a vertical whose schedules nothing would run is refused here too. A tree with no deploy
379
424
  // config has no bindings to judge; the push itself names that refusal.
@@ -448,6 +493,8 @@ async function cmdPush() {
448
493
  // does not scan the same tree twice.
449
494
  skipLint: argv.includes('--skip-lint'),
450
495
  linted,
496
+ // A data-loss replay (#2321) that was skipped or incomplete is a loud warning; this makes it a refusal.
497
+ requireDataLossCheck: dataLossCheckRequired(argv),
451
498
  envSpec: meta.envSpec,
452
499
  ownerGrants: meta.ownerGrants,
453
500
  entitlements: meta.entitlements,
@@ -463,6 +510,7 @@ async function cmdPush() {
463
510
  controlPlaneUrl, authHeader: header,
464
511
  });
465
512
  console.log(`✓ pushed ${v.verticalSlug ?? slug}. version ${v.id} (${version}) is ${v.admission}; deploymentRef=${v.deploymentRef}`);
513
+ console.log(` ${formatDataLossCheck(v.dataLoss)}`);
466
514
  // Advisory, same spirit as the permission-surface gate: a bound surface the new
467
515
  // version stopped declaring keeps serving, but probably not what its users expect.
468
516
  for (const w of v.warnings ?? [])
@@ -520,7 +568,14 @@ async function cmdPromote() {
520
568
  const channel = flag('channel') ?? 'prod';
521
569
  const version = flag('version');
522
570
  if (!slug || slug.startsWith('--') || !version) {
523
- console.error('usage: substrat promote <slug> --version <versionId> [--ack-permissions] [--ack-migrations] [--ack-export-break]');
571
+ console.error('usage: substrat promote <slug> --version <versionId> [--ack-permissions] [--ack-migrations] [--ack-export-break] [--review <token>]');
572
+ process.exit(1);
573
+ }
574
+ // #2333: the review the acknowledgements answer, as a refusal printed it.
575
+ const reviewFlag = flag('review');
576
+ const review = reviewFlag === undefined ? undefined : parseReviewToken(reviewFlag);
577
+ if (review === null) {
578
+ console.error('--review takes the token a refused promote printed (<base>:<digest>)');
524
579
  process.exit(1);
525
580
  }
526
581
  const { controlPlaneUrl, header, as } = resolveAuth({ cp: flag('cp'), token: flag('token'), tenant: flag('tenant') });
@@ -530,8 +585,11 @@ async function cmdPromote() {
530
585
  ...(argv.includes('--ack-permissions') ? { permissionChange: true } : {}),
531
586
  ...(argv.includes('--ack-migrations') ? { migrationChange: true } : {}),
532
587
  ...(argv.includes('--ack-export-break') ? { exportBreak: true } : {}),
588
+ ...(review ? { review } : {}),
533
589
  }
534
- : undefined;
590
+ : review
591
+ ? { review }
592
+ : undefined;
535
593
  const ch = await promote({ controlPlaneUrl, header, slug, channel, versionId: version, acknowledge });
536
594
  console.log(`✓ ${slug} → ${ch.channel} now points at ${ch.versionId}`);
537
595
  if (ch.exportBreaks) {
@@ -563,55 +621,73 @@ function reportStoreBackfill(result) {
563
621
  ' scope with `substrat scope provision <scopeId>`; `substrat scope status` shows the gap.');
564
622
  }
565
623
  }
566
- const STORE_KIND_USAGE = [
567
- 'usage: substrat store-kind upload --kind <kind> --dir <dir> --entry <kind_x/…/module.py> [--commit <sha>]',
568
- ' substrat store-kind rollout --kind <kind> --sha256 <sha256>',
569
- ' substrat store-kind rollback --kind <kind>',
570
- ' substrat store-kind status --kind <kind>',
624
+ const STORE_DRIVER_USAGE = [
625
+ 'usage: substrat store-driver upload --driver <driver> --dir <dir> --entry <driver_x/…/module.py> [--commit <sha>]',
626
+ ' substrat store-driver rollout --driver <driver> --sha256 <sha256>',
627
+ ' substrat store-driver rollback --driver <driver>',
628
+ ' substrat store-driver status --driver <driver>',
629
+ ' substrat store-driver rebuild --driver <driver> [--deploy-finished]',
571
630
  ];
572
- async function cmdStoreKind() {
631
+ async function cmdStoreDriver() {
573
632
  const sub = argv[1];
574
- const kind = flag('kind');
575
- if (!['upload', 'rollout', 'rollback', 'status'].includes(sub ?? '') || !kind) {
576
- for (const line of STORE_KIND_USAGE)
633
+ if (argv.includes('--kind')) {
634
+ console.error('--kind was renamed --driver');
635
+ process.exit(1);
636
+ }
637
+ const driver = flag('driver');
638
+ if (!['upload', 'rollout', 'rollback', 'status', 'rebuild'].includes(sub ?? '') || !driver) {
639
+ for (const line of STORE_DRIVER_USAGE)
577
640
  console.error(line);
578
641
  process.exit(1);
579
642
  }
580
643
  const sha256 = flag('sha256');
581
644
  if (sub === 'rollout' && !isReleaseSha(sha256)) {
582
- console.error('a rollout names the release by --sha256 <64 lowercase hex>, as `store-kind upload` printed it');
583
- for (const line of STORE_KIND_USAGE)
645
+ console.error('a rollout names the release by --sha256 <64 lowercase hex>, as `store-driver upload` printed it');
646
+ for (const line of STORE_DRIVER_USAGE)
584
647
  console.error(line);
585
648
  process.exit(1);
586
649
  }
587
650
  const { controlPlaneUrl, header, as } = resolveAuth({ cp: flag('cp'), token: flag('token'), tenant: flag('tenant') });
588
651
  console.log(`authenticating with ${as}`);
589
652
  console.log(`control plane ${controlPlaneUrl}`);
590
- const plane = { controlPlaneUrl, header, kind };
653
+ const plane = { controlPlaneUrl, header, driver };
591
654
  if (sub === 'status') {
592
- const view = await storeKindStatus(plane);
593
- for (const line of storeKindStatusLines(kind, view))
655
+ const view = await storeDriverStatus(plane);
656
+ for (const line of storeDriverStatusLines(driver, view))
657
+ console.log(line);
658
+ return;
659
+ }
660
+ if (sub === 'rebuild') {
661
+ const { pass, rebuild } = await rebuildStoreDriver({ ...plane, deployFinished: argv.includes('--deploy-finished') });
662
+ console.log(`✓ ${driver} rebuild: ${pass.outcome}, ${pass.walked.length} script(s) walked, ${pass.uploaded.length} uploaded this run`);
663
+ for (const line of storeDriverRebuildLines(rebuild))
594
664
  console.log(line);
665
+ if (pass.outcome === 'paused')
666
+ console.log(' scripts remain: run the same command again to continue');
667
+ if (pass.outcome === 'held')
668
+ console.log(' a rollout or rollback holds the driver: run it again once that ends');
669
+ if (pass.outcome === 'busy')
670
+ console.log(' another pass is rebuilding the driver: run `store-driver status` to follow it');
595
671
  return;
596
672
  }
597
673
  if (sub === 'upload') {
598
674
  const dir = flag('dir');
599
675
  const entry = flag('entry');
600
676
  if (!dir || !entry) {
601
- for (const line of STORE_KIND_USAGE)
677
+ for (const line of STORE_DRIVER_USAGE)
602
678
  console.error(line);
603
679
  process.exit(1);
604
680
  }
605
- const body = packStoreKindDir(dir, entry, flag('commit') ?? sourceCommitOf(dir, packedFilesOf(dir)));
606
- const { release, pinned } = await uploadStoreKindRelease({ ...plane, body });
607
- console.log(`✓ stored ${kind} ${release.sha256} (commit ${release.sourceCommit})`);
608
- console.log(pinned ? ' pinned: roll it out with `substrat store-kind rollout`' : ' not pinned yet: it runs once a reviewed change pins this sha256 and commit');
681
+ const body = packStoreDriverDir(dir, entry, flag('commit') ?? sourceCommitOf(dir, packedFilesOf(dir)));
682
+ const { release, pinned } = await uploadStoreDriverRelease({ ...plane, body });
683
+ console.log(`✓ stored ${driver} ${release.sha256} (commit ${release.sourceCommit})`);
684
+ console.log(pinned ? ' pinned: roll it out with `substrat store-driver rollout`' : ' not pinned yet: it runs once a reviewed change pins this sha256 and commit');
609
685
  return;
610
686
  }
611
687
  const result = sub === 'rollback'
612
- ? await rollBackStoreKind(plane)
613
- : await rollOutStoreKind({ ...plane, sha256: sha256 });
614
- console.log(`✓ ${kind} ${result.rollout.action} to ${result.rollout.sha256}: ${result.rollout.state} (${result.moved.length} vertical(s) this run)`);
688
+ ? await rollBackStoreDriver(plane)
689
+ : await rollOutStoreDriver({ ...plane, sha256: sha256 });
690
+ console.log(`✓ ${driver} ${result.rollout.action} to ${result.rollout.sha256}: ${result.rollout.state} (${result.moved.length} vertical(s) this run)`);
615
691
  if (!result.complete)
616
692
  console.log(' verticals remain: run the same command again to continue');
617
693
  }
@@ -641,7 +717,7 @@ async function cmdConnection() {
641
717
  const sub = argv[1];
642
718
  const connectionId = argv[2];
643
719
  if (!connectionId || connectionId.startsWith('--') || !['bind', 'bindings'].includes(sub ?? '')) {
644
- throw new Error('usage: substrat connection bind <id> --scope <id> --operation <module/operation> --permission <key> [--organization-id <id|null>] [--currency SEK] [--horizon-years 50] [--from-year <year> --to-year <year>] [--tenant <id-or-slug>]\n substrat connection bindings <id> [--tenant <id-or-slug>]');
720
+ throw new Error('usage: substrat connection bind <id> --scope <id> --operation <module/operation> --permission <key> [--organization-id <id|null>] [--currency SEK] [--horizon-years 50] [--from-year <year> --to-year <year>] [--folder <drive path>] [--tenant <id-or-slug>]\n substrat connection bindings <id> [--tenant <id-or-slug>]');
645
721
  }
646
722
  const { controlPlaneUrl, header } = resolveAuth({ cp: flag('cp'), token: flag('token'), tenant: flag('tenant') });
647
723
  const tenantId = await resolveTenantId(controlPlaneUrl, header, flag('tenant') ?? process.env.SUBSTRAT_TENANT ?? loadConfig().defaultTenant);
@@ -672,6 +748,7 @@ async function cmdConnection() {
672
748
  ...(flag('currency') === undefined ? {} : { currency: flag('currency') }),
673
749
  ...(flag('horizon-years') === undefined ? {} : { horizonYears: numberFlag('horizon-years') }),
674
750
  ...(fromYear === undefined || toYear === undefined ? {} : { window: { fromYear, toYear } }),
751
+ ...(flag('folder') === undefined ? {} : { folder: flag('folder') }),
675
752
  };
676
753
  console.log(JSON.stringify(await bindConnectionScope({ ...opts, scopeId, input }), null, 2));
677
754
  }
@@ -686,7 +763,7 @@ async function cmdScope() {
686
763
  ' substrat scope adopt-serving --vertical <slug> [--ack-export-break]\n' +
687
764
  ' substrat scope bind <scopeId> --version <versionId> [--snapshot] [--ack-export-break] [--tenant <id-or-slug>]\n' +
688
765
  ' substrat scope domain <scopeId> --domain <fqdn> [--surface app] [--canonical] [--tenant <id-or-slug>]\n' +
689
- ' substrat scope rebind <scopeId> --to <vertical> [--ack-migrations] [--abandon-data] [--ack-export-break] [--tenant <id-or-slug>]';
766
+ ' substrat scope rebind <scopeId> --to <vertical> [--ack-migrations] [--review <token>] [--abandon-data] [--ack-export-break] [--tenant <id-or-slug>]';
690
767
  const known = sub === 'pull' || sub === 'restore' || sub === 'status' || sub === 'provision' ||
691
768
  sub === 'adopt-serving' || sub === 'bind' || sub === 'domain' || sub === 'rebind';
692
769
  // adopt-serving --vertical takes no positional scopeId; every other form requires one.
@@ -761,11 +838,19 @@ async function cmdScope() {
761
838
  console.error(usage);
762
839
  process.exit(1);
763
840
  }
841
+ // #2347: the review the migration acknowledgement answers, as a refusal printed it.
842
+ const reviewFlag = flag('review');
843
+ const review = reviewFlag === undefined ? undefined : parseReviewToken(reviewFlag);
844
+ if (review === null) {
845
+ console.error('--review takes the token a refused rebind printed (<base>:<digest>)');
846
+ process.exit(1);
847
+ }
764
848
  await rebindScopeVertical({
765
849
  controlPlaneUrl, header, tenantId, scopeId: scope,
766
850
  vertical: to, ackMigrations: argv.includes('--ack-migrations'),
767
851
  abandonData: argv.includes('--abandon-data'),
768
852
  ackExportBreak: argv.includes('--ack-export-break'),
853
+ ...(review ? { review } : {}),
769
854
  });
770
855
  return;
771
856
  }
@@ -943,6 +1028,8 @@ async function cmdPreview() {
943
1028
  allowUnservedUi: argv.includes('--allow-unserved-ui'),
944
1029
  // …and the same schedule check (#1646): a preview's schedules need a sweeper too.
945
1030
  allowUnsweptSchedules: argv.includes('--allow-unswept-schedules'),
1031
+ // …and the same data-loss replay (#2321), which a preview's migrations meet too.
1032
+ requireDataLossCheck: dataLossCheckRequired(argv),
946
1033
  linted,
947
1034
  envSpec: meta.envSpec,
948
1035
  ownerGrants: meta.ownerGrants,
@@ -1078,6 +1165,62 @@ async function cmdModel() {
1078
1165
  console.log(`✓ ${entities} ${entities === 1 ? 'entity' : 'entities'} rendered — open it in a browser:`);
1079
1166
  console.log(file);
1080
1167
  }
1168
+ /**
1169
+ * `substrat changeset [dir] [--base <ref>] [--summary <text>] [--check] [--json]` — the bump,
1170
+ * computed from the artifacts of record rather than chosen (#2364). No control plane and no
1171
+ * auth: it reads the checked-in artifacts at the merge-base and in the working tree.
1172
+ */
1173
+ function cmdChangeset() {
1174
+ const usage = 'usage: substrat changeset [dir] [--base <ref>] [--summary <text>] [--check] [--json]';
1175
+ for (const f of ['base', 'summary']) {
1176
+ const v = flag(f);
1177
+ if (argv.includes(`--${f}`) && (!v || v.startsWith('--'))) {
1178
+ console.error(`${usage}\n\n--${f} needs a value.`);
1179
+ process.exit(1);
1180
+ }
1181
+ }
1182
+ const booleans = ['check', 'json'];
1183
+ const dir = resolvePath(positional(0, booleans) ?? '.');
1184
+ try {
1185
+ const r = changesetCommand(dir, {
1186
+ ...(flag('base') ? { base: flag('base') } : {}),
1187
+ ...(flag('summary') ? { summary: flag('summary') } : {}),
1188
+ check: argv.includes('--check'),
1189
+ json: argv.includes('--json'),
1190
+ });
1191
+ for (const l of r.out)
1192
+ console.log(l);
1193
+ for (const l of r.err)
1194
+ console.error(l);
1195
+ if (r.code !== 0)
1196
+ process.exit(r.code);
1197
+ }
1198
+ catch (err) {
1199
+ if (err instanceof CannotRun) {
1200
+ console.error(`error: ${err.message}`);
1201
+ process.exit(2);
1202
+ }
1203
+ throw err;
1204
+ }
1205
+ }
1206
+ /**
1207
+ * `substrat concept sync [dir] [--check]` — keep `spec/concept.md`'s generated regions true.
1208
+ * Offline like `model view`: it reads the checked-in `model.json` and `PERMISSIONS.md`.
1209
+ */
1210
+ async function cmdConcept() {
1211
+ if (argv[1] !== 'sync') {
1212
+ console.error('usage: substrat concept sync [dir] [--check]' +
1213
+ (argv[1] && !argv[1].startsWith('--') ? `\n\n'concept ${argv[1]}' is not a subcommand — 'sync' is the only one.` : ''));
1214
+ process.exit(1);
1215
+ }
1216
+ const check = argv.includes('--check');
1217
+ const { file, changed } = syncConceptFile(positional(1, ['check']) ?? '.', { check });
1218
+ if (check && changed) {
1219
+ console.error(`✗ ${file} is stale — run \`substrat concept sync\``);
1220
+ process.exit(1);
1221
+ }
1222
+ console.log(changed ? `✓ rewrote the generated sections of ${file}` : `✓ ${file} is up to date`);
1223
+ }
1081
1224
  async function cmdWhoami() {
1082
1225
  const { controlPlaneUrl, header } = resolveAuth({ cp: flag('cp'), token: flag('token'), tenant: flag('tenant') });
1083
1226
  const { user, tenants } = await fetchWhoami(controlPlaneUrl, header);
@@ -1109,8 +1252,13 @@ async function main() {
1109
1252
  return cmdPromote();
1110
1253
  case 'publish':
1111
1254
  return cmdPublish();
1255
+ case 'store-driver':
1256
+ return cmdStoreDriver();
1112
1257
  case 'store-kind':
1113
- return cmdStoreKind();
1258
+ // Renamed with the concept (K-53): a store's code is its driver. Nothing to alias, since no
1259
+ // driver was ever registered for the old command to have acted on.
1260
+ console.error('`substrat store-kind` was renamed `substrat store-driver`, and its --kind flag --driver');
1261
+ process.exit(1);
1114
1262
  case 'unpublish':
1115
1263
  return cmdUnpublish();
1116
1264
  case 'whoami':
@@ -1128,6 +1276,10 @@ async function main() {
1128
1276
  return cmdInit();
1129
1277
  case 'model':
1130
1278
  return cmdModel();
1279
+ case 'changeset':
1280
+ return cmdChangeset();
1281
+ case 'concept':
1282
+ return cmdConcept();
1131
1283
  case 'version':
1132
1284
  case '--version':
1133
1285
  case '-v':