@topogram/cli 0.3.73 → 0.3.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.73",
3
+ "version": "0.3.75",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,7 +31,8 @@ import { DEFAULT_TOPO_FOLDER_NAME, resolveTopoRoot, resolveWorkspaceContext } fr
31
31
  * @typedef {{ command: string, reason: string, phase: string }} AgentBriefCommand
32
32
  * @typedef {{ path: string, ownership: string, rule: string }} AgentBriefOutputBoundary
33
33
  * @typedef {{ id: string, title: string, commands: string[], rule: string }} AgentBriefWorkflow
34
- * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null }} AgentBriefRuntime
34
+ * @typedef {{ ownership: string|null, tool: string|null, apply: string|null, statePath: string|null, snapshotPath: string|null, schemaPath: string|null, migrationsPath: string|null }} AgentBriefMigration
35
+ * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null, migration: AgentBriefMigration|null }} AgentBriefRuntime
35
36
  * @typedef {{ path: string, workspaceRoot: string, source: string|null, tracks: string[], candidateCounts: Record<string, any>, ownership: string|null }} AgentBriefImport
36
37
  * @typedef {{ ok: true, payload: Record<string, any> } | { ok: false, kind: "topogram", validation: any } | { ok: false, kind: "project", validation: any, configPath: string }} AgentBriefResult
37
38
  */
@@ -103,7 +104,18 @@ function summarizeRuntimes(config) {
103
104
  projection: typeof runtime.projection === "string" ? runtime.projection : null,
104
105
  generator: typeof runtime.generator?.id === "string" ? runtime.generator.id : null,
105
106
  uses_api: typeof runtime.uses_api === "string" ? runtime.uses_api : null,
106
- uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null
107
+ uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null,
108
+ migration: runtime.kind === "database" && runtime.migration
109
+ ? {
110
+ ownership: typeof runtime.migration.ownership === "string" ? runtime.migration.ownership : null,
111
+ tool: typeof runtime.migration.tool === "string" ? runtime.migration.tool : null,
112
+ apply: typeof runtime.migration.apply === "string" ? runtime.migration.apply : null,
113
+ statePath: typeof runtime.migration.statePath === "string" ? runtime.migration.statePath : null,
114
+ snapshotPath: typeof runtime.migration.snapshotPath === "string" ? runtime.migration.snapshotPath : null,
115
+ schemaPath: typeof runtime.migration.schemaPath === "string" ? runtime.migration.schemaPath : null,
116
+ migrationsPath: typeof runtime.migration.migrationsPath === "string" ? runtime.migration.migrationsPath : null
117
+ }
118
+ : null
107
119
  }));
108
120
  }
109
121
 
@@ -495,7 +507,10 @@ export function formatAgentBrief(brief) {
495
507
  runtime.uses_api ? `uses_api=${runtime.uses_api}` : null,
496
508
  runtime.uses_database ? `uses_database=${runtime.uses_database}` : null
497
509
  ].filter(Boolean).join(", ");
498
- lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}`);
510
+ const migration = runtime.migration
511
+ ? ` migration=${runtime.migration.ownership}/${runtime.migration.tool} apply=${runtime.migration.apply}`
512
+ : "";
513
+ lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}${migration}`);
499
514
  }
500
515
  if ((brief.topology?.runtimes || []).length === 0) {
501
516
  lines.push(" - No topology runtimes configured.");
@@ -80,6 +80,17 @@ function summarizeProjectTopology(config) {
80
80
  version: component.generator?.version || null
81
81
  },
82
82
  port: topologyComponentPort(component),
83
+ migration: component.kind === "database" && component.migration
84
+ ? {
85
+ ownership: component.migration.ownership || null,
86
+ tool: component.migration.tool || null,
87
+ apply: component.migration.apply || null,
88
+ statePath: component.migration.statePath || null,
89
+ snapshotPath: component.migration.snapshotPath || null,
90
+ schemaPath: component.migration.schemaPath || null,
91
+ migrationsPath: component.migration.migrationsPath || null
92
+ }
93
+ : null,
83
94
  references: topologyComponentReferences(component)
84
95
  }))
85
96
  .sort((left, right) => left.id.localeCompare(right.id));
@@ -136,7 +147,10 @@ function formatTopologyComponent(component) {
136
147
  .filter(([, value]) => Boolean(value))
137
148
  .map(([key, value]) => `${key} ${value}`);
138
149
  const suffix = refs.length ? ` -> ${refs.join(", ")}` : "";
139
- return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}`;
150
+ const migration = component.migration
151
+ ? ` [migration ${component.migration.ownership}/${component.migration.tool}, apply=${component.migration.apply}]`
152
+ : "";
153
+ return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}${migration}`;
140
154
  }
141
155
 
142
156
  /**
@@ -16,6 +16,7 @@ import {
16
16
  expectedConsumerWorkflowName,
17
17
  hasStagedGitChanges,
18
18
  inspectConsumerCi,
19
+ inspectGitUpstreamAhead,
19
20
  inspectGitWorktreeClean,
20
21
  messageFromError,
21
22
  runGit,
@@ -24,12 +25,101 @@ import {
24
25
 
25
26
  /**
26
27
  * @typedef {Record<string, any>} AnyRecord
28
+ * @typedef {{ consumer?: string, step: string, status: "start"|"ok"|"skip"|"waiting"|"error", message: string, elapsedMs?: number, [key: string]: any }} ReleaseRollProgress
27
29
  */
28
30
 
31
+ /**
32
+ * @param {{ onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} options
33
+ * @param {ReleaseRollProgress} event
34
+ * @returns {void}
35
+ */
36
+ function notifyProgress(options, event) {
37
+ if (typeof options.onProgress === "function") {
38
+ options.onProgress(event);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {AnyRecord} item
44
+ * @param {{ onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} options
45
+ * @param {ReleaseRollProgress["step"]} step
46
+ * @param {ReleaseRollProgress["status"]} status
47
+ * @param {string} message
48
+ * @param {AnyRecord} [detail]
49
+ * @returns {ReleaseRollProgress}
50
+ */
51
+ function recordProgress(item, options, step, status, message, detail = {}) {
52
+ /** @type {ReleaseRollProgress} */
53
+ const event = {
54
+ consumer: item.name,
55
+ step,
56
+ status,
57
+ message
58
+ };
59
+ if (typeof detail.elapsedMs === "number") {
60
+ event.elapsedMs = detail.elapsedMs;
61
+ }
62
+ if (typeof detail.headSha === "string") {
63
+ event.headSha = detail.headSha;
64
+ }
65
+ if (typeof detail.expectedWorkflow === "string") {
66
+ event.expectedWorkflow = detail.expectedWorkflow;
67
+ }
68
+ if (detail.run && typeof detail.run === "object") {
69
+ event.run = {
70
+ workflowName: detail.run.workflowName || null,
71
+ status: detail.run.status || null,
72
+ conclusion: detail.run.conclusion || null,
73
+ headSha: detail.run.headSha || null,
74
+ url: detail.run.url || null
75
+ };
76
+ }
77
+ if (options.captureProgress !== true) {
78
+ notifyProgress(options, event);
79
+ return event;
80
+ }
81
+ if (!Array.isArray(item.progress)) {
82
+ item.progress = [];
83
+ }
84
+ item.progress.push(event);
85
+ notifyProgress(options, event);
86
+ return event;
87
+ }
88
+
89
+ /**
90
+ * @param {Array<AnyRecord>} consumers
91
+ * @param {{ version: string, push: boolean, watch: boolean, noWatch?: boolean }} options
92
+ * @returns {AnyRecord}
93
+ */
94
+ function buildRecoverySummary(consumers, options) {
95
+ /**
96
+ * @param {(consumer: AnyRecord) => boolean} predicate
97
+ * @returns {string[]}
98
+ */
99
+ const namesFor = (predicate) => consumers.filter(predicate).map((consumer) => String(consumer.name));
100
+ return {
101
+ version: options.version,
102
+ alreadyCurrent: namesFor((consumer) => consumer.alreadyCurrent === true),
103
+ alreadyPushed: namesFor((consumer) => consumer.alreadyPushed === true),
104
+ updated: namesFor((consumer) => consumer.updated === true),
105
+ committed: namesFor((consumer) => consumer.committed === true),
106
+ pushed: namesFor((consumer) => consumer.pushed === true),
107
+ recoveredPushes: namesFor((consumer) => consumer.recoveredPush === true),
108
+ needsAttention: namesFor((consumer) => (
109
+ /** @type {Array<AnyRecord>} */ (consumer.diagnostics || [])
110
+ ).some((diagnostic) => diagnostic.severity === "error")),
111
+ resumeCommand: `topogram release roll-consumers ${options.version}${options.push ? "" : " --no-push"}${options.watch ? " --watch" : options.noWatch ? " --no-watch" : ""}`,
112
+ asyncVerificationCommand: "topogram release status --strict",
113
+ watchGuidance: options.watch
114
+ ? "If CI waiting is too slow or interrupted, rerun roll-consumers with --no-watch, then verify with release status --strict."
115
+ : "This rollout did not wait for CI. Verify consumers after workflows finish with release status --strict."
116
+ };
117
+ }
118
+
29
119
  /**
30
120
  * @param {string} requested
31
- * @param {{ cwd?: string, push?: boolean, watch?: boolean }} [options]
32
- * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, watched: boolean, consumers: Array<AnyRecord>, diagnostics: Array<AnyRecord>, errors: string[] }}
121
+ * @param {{ cwd?: string, push?: boolean, watch?: boolean, noWatch?: boolean, onProgress?: ((event: ReleaseRollProgress) => void)|null, captureProgress?: boolean }} [options]
122
+ * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, watched: boolean, consumers: Array<AnyRecord>, diagnostics: Array<AnyRecord>, errors: string[], recovery: AnyRecord|null }}
33
123
  */
34
124
  export function buildReleaseRollConsumersPayload(requested, options = {}) {
35
125
  const cwd = options.cwd || process.cwd();
@@ -44,7 +134,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
44
134
  severity: "error",
45
135
  message: "`topogram release roll-consumers --watch` requires pushing consumer commits.",
46
136
  path: "release roll-consumers",
47
- suggestedFix: "Remove --no-push or run without --watch and verify consumer CI separately."
137
+ suggestedFix: "Remove --no-push or use --no-watch and verify consumer CI separately."
48
138
  });
49
139
  return {
50
140
  ok: false,
@@ -55,7 +145,8 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
55
145
  watched: watch,
56
146
  consumers: [],
57
147
  diagnostics,
58
- errors: diagnostics.map((diagnostic) => diagnostic.message)
148
+ errors: diagnostics.map((diagnostic) => diagnostic.message),
149
+ recovery: null
59
150
  };
60
151
  }
61
152
  const version = requestedLatest
@@ -79,9 +170,14 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
79
170
  commit: null,
80
171
  update: null,
81
172
  ci: null,
173
+ alreadyCurrent: false,
174
+ alreadyPushed: false,
175
+ recoveredPush: false,
176
+ upstreamAhead: null,
82
177
  diagnostics: []
83
178
  };
84
179
  consumers.push(item);
180
+ recordProgress(item, options, "inspect", "start", `${consumer.name}: inspecting repository and package metadata.`);
85
181
  if (!consumer.root || !fs.existsSync(consumer.root)) {
86
182
  item.diagnostics.push({
87
183
  code: "release_consumer_repo_missing",
@@ -91,6 +187,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
91
187
  suggestedFix: `Clone ${consumer.name} beside the topogram repo, then rerun roll-consumers.`
92
188
  });
93
189
  diagnostics.push(...item.diagnostics);
190
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: repository not found.`);
94
191
  continue;
95
192
  }
96
193
  const packagePath = path.join(consumer.root, "package.json");
@@ -103,6 +200,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
103
200
  suggestedFix: "Only package-backed first-party consumers can be rolled by this command."
104
201
  });
105
202
  diagnostics.push(...item.diagnostics);
203
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: package.json missing.`);
106
204
  continue;
107
205
  }
108
206
  const clean = inspectGitWorktreeClean(consumer.root);
@@ -115,11 +213,15 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
115
213
  suggestedFix: "Commit, stash, or discard unrelated consumer changes before rolling the CLI version."
116
214
  });
117
215
  diagnostics.push(...item.diagnostics);
216
+ recordProgress(item, options, "inspect", "error", `${consumer.name}: worktree is dirty.`);
118
217
  continue;
119
218
  }
219
+ recordProgress(item, options, "inspect", "ok", `${consumer.name}: worktree is clean.`);
120
220
  try {
221
+ recordProgress(item, options, "update", "start", `${consumer.name}: updating ${CLI_PACKAGE_NAME} to ${version} and running package checks.`);
121
222
  item.update = buildPackageUpdateCliPayload(version, { cwd: consumer.root });
122
223
  item.updated = true;
224
+ recordProgress(item, options, "update", "ok", `${consumer.name}: package update/check completed.`);
123
225
  } catch (error) {
124
226
  item.diagnostics.push({
125
227
  code: "release_consumer_update_failed",
@@ -129,10 +231,12 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
129
231
  suggestedFix: "Fix the consumer update/check failure, then rerun roll-consumers."
130
232
  });
131
233
  diagnostics.push(...item.diagnostics);
234
+ recordProgress(item, options, "update", "error", `${consumer.name}: package update/check failed.`);
132
235
  continue;
133
236
  }
134
237
  const filesToStage = ["package.json", "package-lock.json", "topogram-cli.version"]
135
238
  .filter((file) => fs.existsSync(path.join(consumer.root || "", file)));
239
+ recordProgress(item, options, "stage", "start", `${consumer.name}: staging package pin changes.`);
136
240
  const addResult = runGit(["add", ...filesToStage], consumer.root);
137
241
  if (addResult.status !== 0) {
138
242
  item.diagnostics.push(commandDiagnostic({
@@ -144,6 +248,7 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
144
248
  result: addResult
145
249
  }));
146
250
  diagnostics.push(...item.diagnostics);
251
+ recordProgress(item, options, "stage", "error", `${consumer.name}: staging failed.`);
147
252
  continue;
148
253
  }
149
254
  const staged = hasStagedGitChanges(consumer.root);
@@ -157,16 +262,63 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
157
262
  result: staged.result
158
263
  }));
159
264
  diagnostics.push(...item.diagnostics);
265
+ recordProgress(item, options, "stage", "error", `${consumer.name}: could not inspect staged changes.`);
160
266
  continue;
161
267
  }
162
268
  if (!staged.changed) {
269
+ item.alreadyCurrent = true;
270
+ item.commit = currentGitHead(consumer.root);
271
+ recordProgress(item, options, "stage", "skip", `${consumer.name}: already pinned to ${CLI_PACKAGE_NAME}@${version}; no commit needed.`);
272
+ if (push) {
273
+ const ahead = inspectGitUpstreamAhead(consumer.root);
274
+ item.upstreamAhead = ahead.ok ? ahead.ahead : null;
275
+ if (!ahead.ok) {
276
+ item.diagnostics.push(commandDiagnostic({
277
+ code: "release_consumer_git_upstream_unavailable",
278
+ severity: "warning",
279
+ message: `Could not inspect whether ${consumer.name} has unpushed commits.`,
280
+ path: consumer.root,
281
+ suggestedFix: "Inspect git status manually; if the consumer branch is ahead, push it before verifying CI.",
282
+ result: ahead.result
283
+ }));
284
+ diagnostics.push(...item.diagnostics.slice(-1));
285
+ } else if ((ahead.ahead || 0) > 0) {
286
+ recordProgress(item, options, "push", "start", `${consumer.name}: branch is ahead of upstream; pushing existing rollout commit.`);
287
+ const pushResult = runGit(["push", "origin", "main"], consumer.root);
288
+ if (pushResult.status !== 0) {
289
+ item.diagnostics.push(commandDiagnostic({
290
+ code: "release_consumer_git_push_failed",
291
+ severity: "error",
292
+ message: `Failed to push ${consumer.name} existing CLI update.`,
293
+ path: consumer.root,
294
+ suggestedFix: "Push the consumer update manually, then confirm its verification workflow passes.",
295
+ result: pushResult
296
+ }));
297
+ diagnostics.push(...item.diagnostics);
298
+ recordProgress(item, options, "push", "error", `${consumer.name}: push failed.`);
299
+ continue;
300
+ }
301
+ item.pushed = true;
302
+ item.recoveredPush = true;
303
+ recordProgress(item, options, "push", "ok", `${consumer.name}: pushed existing rollout commit.`);
304
+ } else {
305
+ item.alreadyPushed = true;
306
+ recordProgress(item, options, "push", "skip", `${consumer.name}: branch already matches upstream.`);
307
+ }
308
+ }
309
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", "start", `${consumer.name}: ${watch ? "watching" : "checking"} verification workflow.`);
163
310
  item.ci = watch
164
- ? waitForConsumerCi(consumer)
311
+ ? waitForConsumerCi(consumer, {
312
+ onProgress: (event) => recordProgress(item, options, "watch-ci", event.status === "ok" ? "ok" : event.status === "error" ? "error" : "waiting", event.message, event)
313
+ })
165
314
  : inspectConsumerCi(consumer, { strict: false });
166
315
  item.diagnostics.push(...item.ci.diagnostics);
167
316
  diagnostics.push(...item.ci.diagnostics);
317
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", item.ci.ok === false ? "error" : "ok", `${consumer.name}: verification ${item.ci.ok === false ? "reported issues" : "checked"}.`);
168
318
  continue;
169
319
  }
320
+ recordProgress(item, options, "stage", "ok", `${consumer.name}: staged CLI pin changes.`);
321
+ recordProgress(item, options, "commit", "start", `${consumer.name}: committing CLI rollout.`);
170
322
  const commitResult = runGit(["commit", "-m", `Update Topogram CLI to ${version}`], consumer.root);
171
323
  if (commitResult.status !== 0) {
172
324
  item.diagnostics.push(commandDiagnostic({
@@ -178,11 +330,14 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
178
330
  result: commitResult
179
331
  }));
180
332
  diagnostics.push(...item.diagnostics);
333
+ recordProgress(item, options, "commit", "error", `${consumer.name}: commit failed.`);
181
334
  continue;
182
335
  }
183
336
  item.committed = true;
184
337
  item.commit = currentGitHead(consumer.root);
338
+ recordProgress(item, options, "commit", "ok", `${consumer.name}: committed ${item.commit || "CLI rollout"}.`);
185
339
  if (push) {
340
+ recordProgress(item, options, "push", "start", `${consumer.name}: pushing rollout commit.`);
186
341
  const pushResult = runGit(["push", "origin", "main"], consumer.root);
187
342
  if (pushResult.status !== 0) {
188
343
  item.diagnostics.push(commandDiagnostic({
@@ -194,15 +349,21 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
194
349
  result: pushResult
195
350
  }));
196
351
  diagnostics.push(...item.diagnostics);
352
+ recordProgress(item, options, "push", "error", `${consumer.name}: push failed.`);
197
353
  continue;
198
354
  }
199
355
  item.pushed = true;
356
+ recordProgress(item, options, "push", "ok", `${consumer.name}: pushed rollout commit.`);
200
357
  }
358
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", "start", `${consumer.name}: ${watch ? "watching" : "checking"} verification workflow.`);
201
359
  item.ci = watch
202
- ? waitForConsumerCi(consumer)
360
+ ? waitForConsumerCi(consumer, {
361
+ onProgress: (event) => recordProgress(item, options, "watch-ci", event.status === "ok" ? "ok" : event.status === "error" ? "error" : "waiting", event.message, event)
362
+ })
203
363
  : inspectConsumerCi(consumer, { strict: false });
204
364
  item.diagnostics.push(...item.ci.diagnostics);
205
365
  diagnostics.push(...item.ci.diagnostics);
366
+ recordProgress(item, options, watch ? "watch-ci" : "check-ci", item.ci.ok === false ? "error" : "ok", `${consumer.name}: verification ${item.ci.ok === false ? "reported issues" : "checked"}.`);
206
367
  }
207
368
  const errors = diagnostics
208
369
  .filter((diagnostic) => diagnostic.severity === "error")
@@ -216,10 +377,19 @@ export function buildReleaseRollConsumersPayload(requested, options = {}) {
216
377
  watched: watch,
217
378
  consumers,
218
379
  diagnostics,
219
- errors
380
+ errors,
381
+ recovery: buildRecoverySummary(consumers, { version, push, watch, noWatch: options.noWatch === true })
220
382
  };
221
383
  }
222
384
 
385
+ /**
386
+ * @param {ReleaseRollProgress} event
387
+ * @returns {void}
388
+ */
389
+ export function printReleaseRollProgress(event) {
390
+ console.error(`[roll-consumers] ${event.message}`);
391
+ }
392
+
223
393
  /**
224
394
  * @param {ReturnType<typeof buildReleaseRollConsumersPayload>} payload
225
395
  * @returns {void}
@@ -233,9 +403,11 @@ export function printReleaseRollConsumers(payload) {
233
403
  console.log(`Push: ${payload.pushed ? "enabled" : "disabled"}`);
234
404
  console.log(`Watch: ${payload.watched ? "enabled" : "disabled"}`);
235
405
  for (const consumer of payload.consumers) {
236
- const state = consumer.committed
237
- ? consumer.pushed ? "pushed" : "committed"
238
- : consumer.updated ? "updated" : "skipped";
406
+ const state = consumer.alreadyCurrent
407
+ ? consumer.pushed ? "pushed existing commit" : consumer.alreadyPushed ? "current" : "current"
408
+ : consumer.committed
409
+ ? consumer.pushed ? "pushed" : "committed"
410
+ : consumer.updated ? "updated" : "skipped";
239
411
  console.log(`- ${consumer.name}: ${state}`);
240
412
  if (consumer.update) {
241
413
  console.log(` Checks run: ${consumer.update.scriptsRun.join(", ") || "none"}`);
@@ -254,4 +426,13 @@ export function printReleaseRollConsumers(payload) {
254
426
  console.log(` ${label}: ${diagnostic.message}`);
255
427
  }
256
428
  }
429
+ if (payload.recovery) {
430
+ console.log("Recovery:");
431
+ console.log(` Resume: ${payload.recovery.resumeCommand}`);
432
+ console.log(` Verify: ${payload.recovery.asyncVerificationCommand}`);
433
+ if ((payload.recovery.needsAttention || []).length > 0) {
434
+ console.log(` Needs attention: ${payload.recovery.needsAttention.join(", ")}`);
435
+ }
436
+ console.log(` ${payload.recovery.watchGuidance}`);
437
+ }
257
438
  }
@@ -19,6 +19,7 @@ const REPO_ROOT = decodeURIComponent(new URL("../../../../", import.meta.url).pa
19
19
 
20
20
  /**
21
21
  * @typedef {Record<string, any>} AnyRecord
22
+ * @typedef {{ consumer?: string, step?: string, status?: string, message: string, elapsedMs?: number, headSha?: string|null, expectedWorkflow?: string|null, run?: AnyRecord|null }} ReleaseProgressEvent
22
23
  */
23
24
 
24
25
  /**
@@ -157,6 +158,23 @@ export function currentGitHead(cwd) {
157
158
  return result.status === 0 ? String(result.stdout || "").trim() || null : null;
158
159
  }
159
160
 
161
+ /**
162
+ * @param {string} cwd
163
+ * @returns {{ ok: boolean, ahead: number|null, result: ReturnType<typeof childProcess.spawnSync> }}
164
+ */
165
+ export function inspectGitUpstreamAhead(cwd) {
166
+ const result = runGit(["rev-list", "--count", "@{u}..HEAD"], cwd);
167
+ if (result.status !== 0) {
168
+ return { ok: false, ahead: null, result };
169
+ }
170
+ const ahead = Number.parseInt(String(result.stdout || "").trim(), 10);
171
+ return {
172
+ ok: Number.isFinite(ahead),
173
+ ahead: Number.isFinite(ahead) ? ahead : null,
174
+ result
175
+ };
176
+ }
177
+
160
178
  /**
161
179
  * @param {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string, result: ReturnType<typeof childProcess.spawnSync> }} input
162
180
  * @returns {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string }}
@@ -205,7 +223,7 @@ function positiveIntegerEnv(name, fallback) {
205
223
 
206
224
  /**
207
225
  * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
208
- * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
226
+ * @param {{ timeoutMs?: number, intervalMs?: number, onProgress?: ((event: ReleaseProgressEvent) => void)|null }} [options]
209
227
  * @returns {ReturnType<typeof inspectConsumerCi>}
210
228
  */
211
229
  export function waitForConsumerCi(consumer, options = {}) {
@@ -213,12 +231,23 @@ export function waitForConsumerCi(consumer, options = {}) {
213
231
  const intervalMs = options.intervalMs || positiveIntegerEnv("TOPOGRAM_RELEASE_WATCH_INTERVAL_MS", 5000);
214
232
  const startedAt = Date.now();
215
233
  let latest = inspectConsumerCi(consumer, { strict: false });
234
+ const notify = typeof options.onProgress === "function" ? options.onProgress : null;
216
235
  while (true) {
217
236
  const currentRun = latest.run &&
218
237
  latest.headSha &&
219
238
  latest.run?.headSha &&
220
239
  latest.run.headSha === latest.headSha;
221
240
  if (currentRun && latest.run?.status === "completed") {
241
+ notify?.({
242
+ consumer: consumer.name,
243
+ step: "watch-ci",
244
+ status: "ok",
245
+ message: `${consumer.name}: verification workflow completed on current commit.`,
246
+ elapsedMs: Date.now() - startedAt,
247
+ headSha: latest.headSha,
248
+ expectedWorkflow: latest.expectedWorkflow,
249
+ run: latest.run
250
+ });
222
251
  return inspectConsumerCi(consumer, { strict: true });
223
252
  }
224
253
  if (Date.now() - startedAt >= timeoutMs) {
@@ -228,11 +257,31 @@ export function waitForConsumerCi(consumer, options = {}) {
228
257
  severity: "error",
229
258
  message: `${consumer.name} verification workflow did not complete on the current commit before the watch timeout.`,
230
259
  path: strictLatest.run?.url || consumerGithubRepoSlug(consumer),
231
- suggestedFix: "Open the consumer workflow, fix failures if needed, then rerun release status."
260
+ suggestedFix: "Open the consumer workflow, fix failures if needed, then rerun release status. If you only need to push and verify later, rerun roll-consumers with --no-watch."
232
261
  });
233
262
  strictLatest.ok = false;
263
+ notify?.({
264
+ consumer: consumer.name,
265
+ step: "watch-ci",
266
+ status: "error",
267
+ message: `${consumer.name}: verification watch timed out; rerun with --no-watch to continue asynchronously.`,
268
+ elapsedMs: Date.now() - startedAt,
269
+ headSha: strictLatest.headSha,
270
+ expectedWorkflow: strictLatest.expectedWorkflow,
271
+ run: strictLatest.run
272
+ });
234
273
  return strictLatest;
235
274
  }
275
+ notify?.({
276
+ consumer: consumer.name,
277
+ step: "watch-ci",
278
+ status: "waiting",
279
+ message: `${consumer.name}: waiting for ${latest.expectedWorkflow || "verification workflow"} on ${latest.headSha || "HEAD"} (${Math.round((Date.now() - startedAt) / 1000)}s elapsed).`,
280
+ elapsedMs: Date.now() - startedAt,
281
+ headSha: latest.headSha,
282
+ expectedWorkflow: latest.expectedWorkflow,
283
+ run: latest.run
284
+ });
236
285
  sleepSync(intervalMs);
237
286
  latest = inspectConsumerCi(consumer, { strict: false });
238
287
  }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { stableStringify } from "../../format.js";
7
7
  import {
8
8
  buildReleaseRollConsumersPayload,
9
+ printReleaseRollProgress,
9
10
  printReleaseRollConsumers
10
11
  } from "./release-rollout.js";
11
12
  import {
@@ -19,10 +20,11 @@ import {
19
20
  */
20
21
  export function printReleaseHelp() {
21
22
  console.log("Usage: topogram release status [--json] [--strict] [--markdown|--write-report <path>]");
22
- console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch]");
23
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch|--no-watch]");
23
24
  console.log("");
24
25
  console.log("Checks the local CLI version, latest published package version, release tag, first-party consumer pins, and strict consumer CI state.");
25
26
  console.log("Rolls first-party consumers to a published CLI version, runs their checks, commits, pushes, and can wait for current workflow runs.");
27
+ console.log("Rollout progress prints to stderr in human mode; JSON output stays final-only. Use --no-watch to push and verify later with release status --strict.");
26
28
  console.log("");
27
29
  console.log("Examples:");
28
30
  console.log(" topogram release status");
@@ -88,13 +90,24 @@ export function runReleaseCommand(context) {
88
90
 
89
91
  if (command === "roll-consumers") {
90
92
  const push = !args.includes("--no-push");
93
+ const noWatch = args.includes("--no-watch");
91
94
  const watch = args.includes("--watch");
95
+ if (watch && noWatch) {
96
+ console.error("Use either --watch or --no-watch, not both.");
97
+ printReleaseHelp();
98
+ return 1;
99
+ }
92
100
  if (watch && !push) {
93
- console.error("Use either --watch or --no-push, not both.");
101
+ console.error("Use either --watch or --no-push, not both. Use --no-watch with --no-push when CI will be verified separately.");
94
102
  printReleaseHelp();
95
103
  return 1;
96
104
  }
97
- const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, { push, watch });
105
+ const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, {
106
+ push,
107
+ watch,
108
+ noWatch,
109
+ onProgress: json ? null : printReleaseRollProgress
110
+ });
98
111
  if (json) {
99
112
  console.log(stableStringify(payload));
100
113
  } else {
package/src/cli/help.js CHANGED
@@ -11,7 +11,7 @@ export function printUsage(options = {}) {
11
11
  console.log(" or: topogram doctor --allow-local-npmrc");
12
12
  console.log("Usage: topogram setup package-auth|catalog-auth");
13
13
  console.log("Usage: topogram release status [--json] [--strict] [--markdown|--write-report <path>]");
14
- console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch]");
14
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch|--no-watch]");
15
15
  console.log("Usage: topogram check [path] [--json]");
16
16
  console.log(" or: topogram widget check [path] [--projection <id>] [--widget <id>] [--json]");
17
17
  console.log(" or: topogram widget behavior [path] [--projection <id>] [--widget <id>] [--json]");
@@ -177,7 +177,7 @@ function buildContractsForContext(context) {
177
177
  if (surface === "database") {
178
178
  return {
179
179
  db: generateDbContractGraph(context.graph, { ...(context.options || {}), projectionId }),
180
- lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId })
180
+ lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId, runtime, component: runtime, topology: context.topology })
181
181
  };
182
182
  }
183
183
  if (surface === "native" || surface === "ios_surface" || surface === "android_surface") {
@@ -48,7 +48,7 @@ function buildAppBundlePlan(graph, options = {}) {
48
48
  const runtimeReference = runtimeReferenceFor(graph, options);
49
49
  const topology = resolveRuntimeTopology(graph, options);
50
50
  const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options);
51
- const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id }) : null;
51
+ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null;
52
52
  const environmentProfile = options.profileId || "local_process";
53
53
  const deployProfile = options.deployProfileId || "fly_io";
54
54
  const smokeVerification = buildVerificationSummary(graph, ["smoke", "journey"]);
@@ -77,7 +77,8 @@ function buildAppBundlePlan(graph, options = {}) {
77
77
  generator: runtime.generator,
78
78
  port: runtime.port ?? null,
79
79
  uses_api: runtime.api || null,
80
- uses_database: runtime.database || null
80
+ uses_database: runtime.database || null,
81
+ ...(runtime.kind === "database" && runtime.migration ? { migration: runtime.migration } : {})
81
82
  }))
82
83
  },
83
84
  runtimeReference,