@topogram/cli 0.3.72 → 0.3.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -195
- package/package.json +1 -1
- package/src/adoption/plan/index.js +2 -1
- package/src/agent-brief.js +46 -2
- package/src/archive/archive.js +1 -1
- package/src/archive/jsonl.js +18 -8
- package/src/archive/resolver-bridge.js +34 -1
- package/src/archive/schema.js +1 -1
- package/src/archive/unarchive.js +26 -0
- package/src/cli/command-parsers/sdlc.js +66 -0
- package/src/cli/commands/import/help.js +1 -0
- package/src/cli/commands/import/plan.js +9 -0
- package/src/cli/commands/import/workspace.js +3 -0
- package/src/cli/commands/query/definitions.js +11 -10
- package/src/cli/commands/query/workspace.js +23 -2
- 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/commands/sdlc.js +213 -5
- package/src/cli/dispatcher.js +8 -0
- package/src/cli/help.js +15 -3
- package/src/cli/options.js +1 -0
- package/src/generator/context/shared/domain-sdlc.js +27 -0
- package/src/generator/context/shared/relationships.js +2 -1
- package/src/generator/context/shared/types.d.ts +1 -0
- package/src/generator/context/shared.d.ts +2 -0
- package/src/generator/context/shared.js +2 -0
- package/src/generator/context/slice/core.js +3 -0
- package/src/generator/context/slice/sdlc.js +57 -2
- package/src/generator/context/task-mode.js +7 -0
- package/src/generator/sdlc/board.js +2 -0
- package/src/generator/sdlc/traceability-matrix.js +5 -1
- package/src/import/core/context.js +1 -1
- package/src/import/core/contracts.js +3 -3
- package/src/import/core/registry.js +3 -0
- package/src/import/core/runner/candidates.js +7 -0
- package/src/import/core/runner/reports.js +9 -1
- package/src/import/core/runner/tracks.js +3 -0
- package/src/import/extractors/cli/generic.js +340 -0
- package/src/new-project/project-files.js +10 -3
- package/src/resolver/enrich/task.js +3 -1
- package/src/resolver/index.js +6 -0
- package/src/resolver/normalize.js +31 -0
- package/src/resolver/projections-cli.js +158 -0
- package/src/sdlc/adopt.js +4 -1
- package/src/sdlc/check.js +24 -2
- package/src/sdlc/complete.js +47 -0
- package/src/sdlc/dod/index.js +2 -0
- package/src/sdlc/dod/plan.js +15 -0
- package/src/sdlc/dod/task.js +7 -3
- package/src/sdlc/explain.js +53 -1
- package/src/sdlc/gate.js +352 -0
- package/src/sdlc/history.d.ts +7 -0
- package/src/sdlc/history.js +50 -5
- package/src/sdlc/link.js +172 -0
- package/src/sdlc/paths.d.ts +4 -0
- package/src/sdlc/paths.js +8 -0
- package/src/sdlc/plan-steps.js +71 -0
- package/src/sdlc/plan.js +245 -0
- package/src/sdlc/policy.js +249 -0
- package/src/sdlc/prep.js +186 -0
- package/src/sdlc/scaffold.js +4 -2
- package/src/sdlc/status-filter.js +2 -0
- package/src/sdlc/transitions/index.js +3 -0
- package/src/sdlc/transitions/plan.js +32 -0
- package/src/validator/common.js +25 -4
- package/src/validator/index.js +10 -0
- package/src/validator/kinds.d.ts +7 -0
- package/src/validator/kinds.js +32 -0
- package/src/validator/per-kind/plan.js +128 -0
- package/src/validator/per-kind/task.js +19 -0
- package/src/validator/projections/cli.js +267 -0
- package/src/validator.d.ts +1 -0
- package/src/workflows/import-app/shared.js +1 -1
- package/src/workflows/reconcile/adoption-plan/build.js +3 -1
- package/src/workflows/reconcile/adoption-plan/reasons.js +5 -0
- package/src/workflows/reconcile/bundle-core/index.js +3 -0
- package/src/workflows/reconcile/candidate-model.js +15 -0
- package/src/workflows/reconcile/gap-report.js +4 -2
- package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
- package/src/workflows/reconcile/renderers.js +82 -0
- package/src/workflows/reconcile/summary.js +4 -0
- package/src/workflows/reconcile/workflow.js +2 -1
- package/src/workspace-paths.js +26 -2
|
@@ -40,6 +40,15 @@ export const BROWNFIELD_BROAD_ADOPT_SELECTORS = [
|
|
|
40
40
|
},
|
|
41
41
|
{ selector: "workflows", kind: "track", label: "workflows", matches: (/** @type {AnyRecord} */ item) => item.track === "workflows" || item.kind === "decision" },
|
|
42
42
|
{ selector: "verification", kind: "kind", label: "verification", matches: (/** @type {AnyRecord} */ item) => item.kind === "verification" },
|
|
43
|
+
{
|
|
44
|
+
selector: "cli",
|
|
45
|
+
kind: "track",
|
|
46
|
+
label: "CLI surfaces",
|
|
47
|
+
matches: (/** @type {AnyRecord} */ item) =>
|
|
48
|
+
item.bundle === "cli" ||
|
|
49
|
+
item.track === "cli" ||
|
|
50
|
+
item.suggested_action === "promote_cli_surface"
|
|
51
|
+
},
|
|
43
52
|
{
|
|
44
53
|
selector: "ui",
|
|
45
54
|
kind: "track",
|
|
@@ -116,6 +116,9 @@ export function importCandidateCounts(summary) {
|
|
|
116
116
|
uiRoutes: candidates.ui?.routes?.length || 0,
|
|
117
117
|
uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
|
|
118
118
|
uiShapes: candidates.ui?.shapes?.length || 0,
|
|
119
|
+
cliCommands: candidates.cli?.commands?.length || 0,
|
|
120
|
+
cliCapabilities: candidates.cli?.capabilities?.length || 0,
|
|
121
|
+
cliSurfaces: candidates.cli?.surfaces?.length || 0,
|
|
119
122
|
workflows: candidates.workflows?.workflows?.length || 0,
|
|
120
123
|
verifications: candidates.verification?.verifications?.length || 0
|
|
121
124
|
};
|
|
@@ -32,21 +32,22 @@
|
|
|
32
32
|
* @returns {QueryDefinition[]}
|
|
33
33
|
*/
|
|
34
34
|
export function queryDefinitions() {
|
|
35
|
+
const contextSelectors = ["mode", "capability", "workflow", "projection", "widget", "entity", "journey", "surface", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document", "from-topogram"];
|
|
35
36
|
return [
|
|
36
37
|
{
|
|
37
38
|
name: "slice",
|
|
38
39
|
purpose: "Give an agent the smallest graph slice needed to reason about one selected semantic surface.",
|
|
39
40
|
description: "Return a focused semantic context slice for one selected surface.",
|
|
40
|
-
selectors: ["capability", "workflow", "projection", "widget", "entity", "journey", "domain"],
|
|
41
|
+
selectors: ["capability", "workflow", "projection", "widget", "entity", "journey", "domain", "pitch", "requirement", "acceptance", "task", "plan", "bug", "document"],
|
|
41
42
|
args: ["[path]", "[selectors]", "[--json]"],
|
|
42
43
|
output: "context_slice",
|
|
43
|
-
example: "topogram query slice ./topo --
|
|
44
|
+
example: "topogram query slice ./topo --task task_implement_audit_writer"
|
|
44
45
|
},
|
|
45
46
|
{
|
|
46
47
|
name: "verification-targets",
|
|
47
48
|
purpose: "Map a selected change or mode to the smallest verification set worth running.",
|
|
48
49
|
description: "Return the smallest verification target set for a mode, selector, or diff.",
|
|
49
|
-
selectors:
|
|
50
|
+
selectors: contextSelectors.filter((selector) => selector !== "surface" && selector !== "domain" && selector !== "pitch" && selector !== "requirement" && selector !== "acceptance" && selector !== "plan" && selector !== "bug" && selector !== "document"),
|
|
50
51
|
args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
51
52
|
output: "verification_targets",
|
|
52
53
|
example: "topogram query verification-targets ./topo --widget widget_data_grid"
|
|
@@ -64,7 +65,7 @@ export function queryDefinitions() {
|
|
|
64
65
|
name: "change-plan",
|
|
65
66
|
purpose: "Summarize what a selected change affects before code or Topogram edits start.",
|
|
66
67
|
description: "Return the semantic change plan, generator targets, risk, and alignment recommendations.",
|
|
67
|
-
selectors:
|
|
68
|
+
selectors: contextSelectors,
|
|
68
69
|
args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
69
70
|
output: "change_plan_query",
|
|
70
71
|
example: "topogram query change-plan ./topo --widget widget_data_grid"
|
|
@@ -73,7 +74,7 @@ export function queryDefinitions() {
|
|
|
73
74
|
name: "review-packet",
|
|
74
75
|
purpose: "Bundle the context a human or agent needs to review a selected semantic change.",
|
|
75
76
|
description: "Return the review packet for a selected change or diff.",
|
|
76
|
-
selectors:
|
|
77
|
+
selectors: contextSelectors,
|
|
77
78
|
args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
78
79
|
output: "review_packet_query",
|
|
79
80
|
example: "topogram query review-packet ./topo --widget widget_data_grid"
|
|
@@ -82,7 +83,7 @@ export function queryDefinitions() {
|
|
|
82
83
|
name: "resolved-workflow-context",
|
|
83
84
|
purpose: "Resolve workflow guidance and artifact load order for a selected mode or change.",
|
|
84
85
|
description: "Return resolved workflow guidance, artifact load order, preset policy, and recommended artifact queries.",
|
|
85
|
-
selectors: [
|
|
86
|
+
selectors: [...contextSelectors.filter((selector) => selector !== "document"), "provider", "preset"],
|
|
86
87
|
args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
87
88
|
output: "resolved_workflow_context_query",
|
|
88
89
|
example: "topogram query resolved-workflow-context ./topo --mode modeling --widget widget_data_grid --json"
|
|
@@ -91,7 +92,7 @@ export function queryDefinitions() {
|
|
|
91
92
|
name: "single-agent-plan",
|
|
92
93
|
purpose: "Give one coding agent a bounded plan, artifact set, and write guidance.",
|
|
93
94
|
description: "Return a single-agent operating plan for a mode and optional selector.",
|
|
94
|
-
selectors:
|
|
95
|
+
selectors: contextSelectors.filter((selector) => selector !== "document"),
|
|
95
96
|
args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
96
97
|
output: "single_agent_plan_query",
|
|
97
98
|
example: "topogram query single-agent-plan ./topo --mode modeling --widget widget_data_grid --json"
|
|
@@ -100,7 +101,7 @@ export function queryDefinitions() {
|
|
|
100
101
|
name: "risk-summary",
|
|
101
102
|
purpose: "Surface behavioral, ownership, and verification risks for a selected change.",
|
|
102
103
|
description: "Return the risk summary for a selected change, mode, or diff.",
|
|
103
|
-
selectors:
|
|
104
|
+
selectors: contextSelectors,
|
|
104
105
|
args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
105
106
|
output: "risk_summary_query",
|
|
106
107
|
example: "topogram query risk-summary ./topo --widget widget_data_grid"
|
|
@@ -109,7 +110,7 @@ export function queryDefinitions() {
|
|
|
109
110
|
name: "proceed-decision",
|
|
110
111
|
purpose: "Tell a human or agent whether enough context and proof exist to proceed.",
|
|
111
112
|
description: "Return a proceed/no-go decision for the current selected work.",
|
|
112
|
-
selectors:
|
|
113
|
+
selectors: contextSelectors,
|
|
113
114
|
args: ["[path]", "[--mode <id>]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
114
115
|
output: "proceed_decision_query",
|
|
115
116
|
example: "topogram query proceed-decision ./topo --mode verification"
|
|
@@ -118,7 +119,7 @@ export function queryDefinitions() {
|
|
|
118
119
|
name: "write-scope",
|
|
119
120
|
purpose: "Define where an agent may edit for a selected semantic surface.",
|
|
120
121
|
description: "Return safe edit boundaries for a selected mode or semantic surface.",
|
|
121
|
-
selectors:
|
|
122
|
+
selectors: contextSelectors.filter((selector) => selector !== "surface" && selector !== "document"),
|
|
122
123
|
args: ["[path]", "[selectors]", "[--from-topogram <path>]", "[--json]"],
|
|
123
124
|
output: "write_scope_query",
|
|
124
125
|
example: "topogram query write-scope ./topo --widget widget_data_grid"
|
|
@@ -74,6 +74,13 @@ export function importAdoptOnlyRequested(options = {}) {
|
|
|
74
74
|
options.journeyId ||
|
|
75
75
|
options.surfaceId ||
|
|
76
76
|
options.domainId ||
|
|
77
|
+
options.pitchId ||
|
|
78
|
+
options.requirementId ||
|
|
79
|
+
options.acceptanceId ||
|
|
80
|
+
options.taskId ||
|
|
81
|
+
options.planId ||
|
|
82
|
+
options.bugId ||
|
|
83
|
+
options.documentId ||
|
|
77
84
|
options.fromTopogramPath
|
|
78
85
|
);
|
|
79
86
|
}
|
|
@@ -202,7 +209,14 @@ export function hasSelectors(options) {
|
|
|
202
209
|
options.entityId ||
|
|
203
210
|
options.journeyId ||
|
|
204
211
|
options.surfaceId ||
|
|
205
|
-
options.domainId
|
|
212
|
+
options.domainId ||
|
|
213
|
+
options.pitchId ||
|
|
214
|
+
options.requirementId ||
|
|
215
|
+
options.acceptanceId ||
|
|
216
|
+
options.taskId ||
|
|
217
|
+
options.planId ||
|
|
218
|
+
options.bugId ||
|
|
219
|
+
options.documentId
|
|
206
220
|
);
|
|
207
221
|
}
|
|
208
222
|
|
|
@@ -228,7 +242,14 @@ export function selectorOptions(options) {
|
|
|
228
242
|
entityId: options.entityId,
|
|
229
243
|
journeyId: options.journeyId,
|
|
230
244
|
surfaceId: options.surfaceId,
|
|
231
|
-
domainId: options.domainId
|
|
245
|
+
domainId: options.domainId,
|
|
246
|
+
pitchId: options.pitchId,
|
|
247
|
+
requirementId: options.requirementId,
|
|
248
|
+
acceptanceId: options.acceptanceId,
|
|
249
|
+
taskId: options.taskId,
|
|
250
|
+
planId: options.planId,
|
|
251
|
+
bugId: options.bugId,
|
|
252
|
+
documentId: options.documentId
|
|
232
253
|
};
|
|
233
254
|
}
|
|
234
255
|
|
|
@@ -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 {
|