@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 +1 -1
- package/src/agent-brief.js +18 -3
- package/src/cli/commands/check.js +15 -1
- package/src/cli/commands/release-rollout.js +191 -10
- package/src/cli/commands/release-shared.js +51 -2
- package/src/cli/commands/release.js +16 -3
- package/src/cli/help.js +1 -1
- package/src/generator/adapters.js +1 -1
- package/src/generator/runtime/app-bundle.js +3 -2
- package/src/generator/runtime/environment/index.js +15 -6
- package/src/generator/runtime/shared/index.js +1 -0
- package/src/generator/surfaces/databases/lifecycle-shared.js +175 -13
- package/src/import/extractors/cli/generic.js +107 -40
- package/src/project-config/index.js +97 -0
- package/src/project-config.js +1 -0
package/package.json
CHANGED
package/src/agent-brief.js
CHANGED
|
@@ -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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
237
|
-
? consumer.pushed ? "pushed" : "
|
|
238
|
-
: consumer.
|
|
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, {
|
|
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,
|