@topogram/cli 0.3.46 → 0.3.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +712 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -137,6 +137,20 @@ const KNOWN_CLI_CONSUMER_REPOS = [
137
137
  "topogram-demo-todo",
138
138
  "topogram-hello"
139
139
  ];
140
+ const KNOWN_CLI_CONSUMER_WORKFLOWS = {
141
+ "topogram-generator-express-api": "Generator Verification",
142
+ "topogram-generator-hono-api": "Generator Verification",
143
+ "topogram-generator-postgres-db": "Generator Verification",
144
+ "topogram-generator-react-web": "Generator Verification",
145
+ "topogram-generator-sqlite-db": "Generator Verification",
146
+ "topogram-generator-sveltekit-web": "Generator Verification",
147
+ "topogram-generator-swiftui-native": "Generator Verification",
148
+ "topogram-generator-vanilla-web": "Generator Verification",
149
+ "topogram-starters": "Starter Verification",
150
+ "topogram-template-todo": "Template Verification",
151
+ "topogram-demo-todo": "Demo Verification",
152
+ "topogram-hello": "Topogram Package Verification"
153
+ };
140
154
  const PACKAGE_UPDATE_CLI_CHECK_SCRIPTS = [
141
155
  "cli:surface",
142
156
  "doctor",
@@ -177,7 +191,8 @@ function printUsage(options = {}) {
177
191
  console.log("Usage: topogram version [--json]");
178
192
  console.log("Usage: topogram doctor [--json] [--catalog <path-or-source>]");
179
193
  console.log("Usage: topogram setup package-auth|catalog-auth");
180
- console.log("Usage: topogram release status [--json] [--strict]");
194
+ console.log("Usage: topogram release status [--json] [--strict] [--markdown|--write-report <path>]");
195
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch]");
181
196
  console.log("Usage: topogram check [path] [--json]");
182
197
  console.log(" or: topogram component check [path] [--projection <id>] [--component <id>] [--json]");
183
198
  console.log(" or: topogram component behavior [path] [--projection <id>] [--component <id>] [--json]");
@@ -231,6 +246,7 @@ function printUsage(options = {}) {
231
246
  console.log(" topogram doctor");
232
247
  console.log(" topogram setup package-auth");
233
248
  console.log(" topogram release status");
249
+ console.log(" topogram release roll-consumers --latest");
234
250
  console.log(" topogram new ./my-app");
235
251
  console.log(" topogram new --list-templates");
236
252
  console.log(" topogram new ./my-app --template todo");
@@ -743,14 +759,19 @@ function printPackageHelp() {
743
759
  }
744
760
 
745
761
  function printReleaseHelp() {
746
- console.log("Usage: topogram release status [--json] [--strict]");
762
+ console.log("Usage: topogram release status [--json] [--strict] [--markdown|--write-report <path>]");
763
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push] [--watch]");
747
764
  console.log("");
748
- console.log("Checks the local CLI version, latest published package version, release tag, and first-party consumer pins.");
765
+ console.log("Checks the local CLI version, latest published package version, release tag, first-party consumer pins, and strict consumer CI state.");
766
+ console.log("Rolls first-party consumers to a published CLI version, runs their checks, commits, pushes, and can wait for current workflow runs.");
749
767
  console.log("");
750
768
  console.log("Examples:");
751
769
  console.log(" topogram release status");
752
770
  console.log(" topogram release status --json");
753
771
  console.log(" topogram release status --strict");
772
+ console.log(" topogram release status --strict --write-report ./release-baseline.md");
773
+ console.log(" topogram release roll-consumers 0.3.46 --watch");
774
+ console.log(" topogram release roll-consumers --latest --watch");
754
775
  console.log("");
755
776
  console.log("Release preparation and publishing are repo-level tasks in the Topogram source checkout:");
756
777
  console.log(" npm run release:prepare -- <version>");
@@ -2493,8 +2514,235 @@ function printPackageUpdateCli(payload) {
2493
2514
  }
2494
2515
 
2495
2516
  /**
2496
- * @param {{ cwd?: string }} [options]
2497
- * @returns {{ ok: boolean, packageName: string, localVersion: string, latestVersion: string|null, currentPublished: boolean|null, git: { tag: string, local: boolean|null, remote: boolean|null, diagnostics: Array<Record<string, any>> }, consumerPins: ReturnType<typeof summarizeConsumerPins>, consumers: Array<{ name: string, path: string, version: string|null, found: boolean, matchesLocal: boolean|null }>, diagnostics: Array<Record<string, any>>, errors: string[] }}
2517
+ * @param {string} requested
2518
+ * @param {{ cwd?: string, push?: boolean, watch?: boolean }} [options]
2519
+ * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, watched: boolean, consumers: Array<Record<string, any>>, diagnostics: Array<Record<string, any>>, errors: string[] }}
2520
+ */
2521
+ function buildReleaseRollConsumersPayload(requested, options = {}) {
2522
+ const cwd = options.cwd || process.cwd();
2523
+ const push = options.push !== false;
2524
+ const watch = Boolean(options.watch);
2525
+ const requestedLatest = requested === "latest" || requested === "--latest";
2526
+ const diagnostics = [];
2527
+ if (watch && !push) {
2528
+ diagnostics.push({
2529
+ code: "release_roll_watch_requires_push",
2530
+ severity: "error",
2531
+ message: "`topogram release roll-consumers --watch` requires pushing consumer commits.",
2532
+ path: "release roll-consumers",
2533
+ suggestedFix: "Remove --no-push or run without --watch and verify consumer CI separately."
2534
+ });
2535
+ return {
2536
+ ok: false,
2537
+ packageName: CLI_PACKAGE_NAME,
2538
+ requestedVersion: requestedLatest ? "latest" : requested,
2539
+ requestedLatest,
2540
+ pushed: push,
2541
+ watched: watch,
2542
+ consumers: [],
2543
+ diagnostics,
2544
+ errors: diagnostics.map((diagnostic) => diagnostic.message)
2545
+ };
2546
+ }
2547
+ const version = requestedLatest
2548
+ ? resolveLatestTopogramCliVersionForPackageUpdate(cwd, diagnostics)
2549
+ : requested;
2550
+ if (!isPackageVersion(version)) {
2551
+ throw new Error("topogram release roll-consumers requires <version> or --latest.");
2552
+ }
2553
+ const consumers = [];
2554
+ for (const consumer of discoverTopogramCliVersionConsumers(cwd)) {
2555
+ const workflow = expectedConsumerWorkflowName(consumer.name);
2556
+ const item = {
2557
+ name: consumer.name,
2558
+ root: consumer.root,
2559
+ workflow,
2560
+ updated: false,
2561
+ committed: false,
2562
+ pushed: false,
2563
+ commit: null,
2564
+ update: null,
2565
+ ci: null,
2566
+ diagnostics: []
2567
+ };
2568
+ consumers.push(item);
2569
+ if (!consumer.root || !fs.existsSync(consumer.root)) {
2570
+ item.diagnostics.push({
2571
+ code: "release_consumer_repo_missing",
2572
+ severity: "error",
2573
+ message: `First-party consumer repo ${consumer.name} was not found.`,
2574
+ path: consumer.path,
2575
+ suggestedFix: `Clone ${consumer.name} beside the topogram repo, then rerun roll-consumers.`
2576
+ });
2577
+ diagnostics.push(...item.diagnostics);
2578
+ continue;
2579
+ }
2580
+ const packagePath = path.join(consumer.root, "package.json");
2581
+ if (!fs.existsSync(packagePath)) {
2582
+ item.diagnostics.push({
2583
+ code: "release_consumer_package_missing",
2584
+ severity: "error",
2585
+ message: `First-party consumer repo ${consumer.name} does not contain package.json.`,
2586
+ path: packagePath,
2587
+ suggestedFix: "Only package-backed first-party consumers can be rolled by this command."
2588
+ });
2589
+ diagnostics.push(...item.diagnostics);
2590
+ continue;
2591
+ }
2592
+ const clean = inspectGitWorktreeClean(consumer.root);
2593
+ if (clean.ok !== true) {
2594
+ item.diagnostics.push({
2595
+ code: "release_consumer_worktree_dirty",
2596
+ severity: "error",
2597
+ message: clean.error || `First-party consumer repo ${consumer.name} has uncommitted changes.`,
2598
+ path: consumer.root,
2599
+ suggestedFix: "Commit, stash, or discard unrelated consumer changes before rolling the CLI version."
2600
+ });
2601
+ diagnostics.push(...item.diagnostics);
2602
+ continue;
2603
+ }
2604
+ try {
2605
+ item.update = buildPackageUpdateCliPayload(version, { cwd: consumer.root });
2606
+ item.updated = true;
2607
+ } catch (error) {
2608
+ item.diagnostics.push({
2609
+ code: "release_consumer_update_failed",
2610
+ severity: "error",
2611
+ message: `Failed to update ${consumer.name}: ${messageFromError(error)}`,
2612
+ path: consumer.root,
2613
+ suggestedFix: "Fix the consumer update/check failure, then rerun roll-consumers."
2614
+ });
2615
+ diagnostics.push(...item.diagnostics);
2616
+ continue;
2617
+ }
2618
+ const filesToStage = ["package.json", "package-lock.json", "topogram-cli.version"]
2619
+ .filter((file) => fs.existsSync(path.join(consumer.root, file)));
2620
+ const addResult = runGit(["add", ...filesToStage], consumer.root);
2621
+ if (addResult.status !== 0) {
2622
+ item.diagnostics.push(commandDiagnostic({
2623
+ code: "release_consumer_git_add_failed",
2624
+ severity: "error",
2625
+ message: `Failed to stage ${consumer.name} CLI update.`,
2626
+ path: consumer.root,
2627
+ suggestedFix: "Inspect git output, stage the changed files manually, then commit and push.",
2628
+ result: addResult
2629
+ }));
2630
+ diagnostics.push(...item.diagnostics);
2631
+ continue;
2632
+ }
2633
+ const staged = hasStagedGitChanges(consumer.root);
2634
+ if (!staged.ok) {
2635
+ item.diagnostics.push(commandDiagnostic({
2636
+ code: "release_consumer_git_diff_failed",
2637
+ severity: "error",
2638
+ message: `Could not inspect staged changes for ${consumer.name}.`,
2639
+ path: consumer.root,
2640
+ suggestedFix: "Inspect git status manually before committing.",
2641
+ result: staged.result
2642
+ }));
2643
+ diagnostics.push(...item.diagnostics);
2644
+ continue;
2645
+ }
2646
+ if (!staged.changed) {
2647
+ item.ci = watch
2648
+ ? waitForConsumerCi(consumer)
2649
+ : inspectConsumerCi(consumer, { strict: false });
2650
+ item.diagnostics.push(...item.ci.diagnostics);
2651
+ diagnostics.push(...item.ci.diagnostics);
2652
+ continue;
2653
+ }
2654
+ const commitResult = runGit(["commit", "-m", `Update Topogram CLI to ${version}`], consumer.root);
2655
+ if (commitResult.status !== 0) {
2656
+ item.diagnostics.push(commandDiagnostic({
2657
+ code: "release_consumer_git_commit_failed",
2658
+ severity: "error",
2659
+ message: `Failed to commit ${consumer.name} CLI update.`,
2660
+ path: consumer.root,
2661
+ suggestedFix: "Inspect git output, commit the consumer update manually, then push.",
2662
+ result: commitResult
2663
+ }));
2664
+ diagnostics.push(...item.diagnostics);
2665
+ continue;
2666
+ }
2667
+ item.committed = true;
2668
+ item.commit = currentGitHead(consumer.root);
2669
+ if (push) {
2670
+ const pushResult = runGit(["push", "origin", "main"], consumer.root);
2671
+ if (pushResult.status !== 0) {
2672
+ item.diagnostics.push(commandDiagnostic({
2673
+ code: "release_consumer_git_push_failed",
2674
+ severity: "error",
2675
+ message: `Failed to push ${consumer.name} CLI update.`,
2676
+ path: consumer.root,
2677
+ suggestedFix: "Push the consumer update manually, then confirm its verification workflow passes.",
2678
+ result: pushResult
2679
+ }));
2680
+ diagnostics.push(...item.diagnostics);
2681
+ continue;
2682
+ }
2683
+ item.pushed = true;
2684
+ }
2685
+ item.ci = watch
2686
+ ? waitForConsumerCi(consumer)
2687
+ : inspectConsumerCi(consumer, { strict: false });
2688
+ item.diagnostics.push(...item.ci.diagnostics);
2689
+ diagnostics.push(...item.ci.diagnostics);
2690
+ }
2691
+ const errors = diagnostics
2692
+ .filter((diagnostic) => diagnostic.severity === "error")
2693
+ .map((diagnostic) => diagnostic.message);
2694
+ return {
2695
+ ok: errors.length === 0,
2696
+ packageName: CLI_PACKAGE_NAME,
2697
+ requestedVersion: version,
2698
+ requestedLatest,
2699
+ pushed: push,
2700
+ watched: watch,
2701
+ consumers,
2702
+ diagnostics,
2703
+ errors
2704
+ };
2705
+ }
2706
+
2707
+ /**
2708
+ * @param {ReturnType<typeof buildReleaseRollConsumersPayload>} payload
2709
+ * @returns {void}
2710
+ */
2711
+ function printReleaseRollConsumers(payload) {
2712
+ console.log(payload.ok ? "Topogram consumer rollout completed." : "Topogram consumer rollout found issues.");
2713
+ if (payload.requestedLatest) {
2714
+ console.log(`Resolved latest version: ${payload.requestedVersion}`);
2715
+ }
2716
+ console.log(`Package: ${payload.packageName}@${payload.requestedVersion}`);
2717
+ console.log(`Push: ${payload.pushed ? "enabled" : "disabled"}`);
2718
+ console.log(`Watch: ${payload.watched ? "enabled" : "disabled"}`);
2719
+ for (const consumer of payload.consumers) {
2720
+ const state = consumer.committed
2721
+ ? consumer.pushed ? "pushed" : "committed"
2722
+ : consumer.updated ? "updated" : "skipped";
2723
+ console.log(`- ${consumer.name}: ${state}`);
2724
+ if (consumer.update) {
2725
+ console.log(` Checks run: ${consumer.update.scriptsRun.join(", ") || "none"}`);
2726
+ }
2727
+ if (consumer.commit) {
2728
+ console.log(` Commit: ${consumer.commit}`);
2729
+ }
2730
+ if (consumer.ci?.run?.url) {
2731
+ const run = consumer.ci.run;
2732
+ console.log(` CI: ${run.workflowName || consumer.workflow} ${run.status || "unknown"}/${run.conclusion || "unknown"} ${run.url}`);
2733
+ } else if (consumer.workflow) {
2734
+ console.log(` CI: ${consumer.workflow} not found`);
2735
+ }
2736
+ for (const diagnostic of consumer.diagnostics || []) {
2737
+ const label = diagnostic.severity === "error" ? "Error" : diagnostic.severity === "warning" ? "Warning" : "Note";
2738
+ console.log(` ${label}: ${diagnostic.message}`);
2739
+ }
2740
+ }
2741
+ }
2742
+
2743
+ /**
2744
+ * @param {{ cwd?: string, strict?: boolean }} [options]
2745
+ * @returns {{ ok: boolean, packageName: string, localVersion: string, latestVersion: string|null, currentPublished: boolean|null, git: { tag: string, local: boolean|null, remote: boolean|null, diagnostics: Array<Record<string, any>> }, consumerPins: ReturnType<typeof summarizeConsumerPins>, consumerCi: ReturnType<typeof summarizeConsumerCi>, consumers: Array<{ name: string, root: string|null, path: string, version: string|null, found: boolean, matchesLocal: boolean|null, workflow: string|null, ci: ReturnType<typeof inspectConsumerCi>|null }>, diagnostics: Array<Record<string, any>>, errors: string[] }}
2498
2746
  */
2499
2747
  function buildReleaseStatusPayload(options = {}) {
2500
2748
  const cwd = options.cwd || process.cwd();
@@ -2517,9 +2765,20 @@ function buildReleaseStatusPayload(options = {}) {
2517
2765
  diagnostics.push(...git.diagnostics);
2518
2766
  const consumers = discoverTopogramCliVersionConsumers(cwd).map((consumer) => ({
2519
2767
  ...consumer,
2520
- matchesLocal: consumer.version ? consumer.version === localVersion : null
2768
+ matchesLocal: consumer.version ? consumer.version === localVersion : null,
2769
+ workflow: expectedConsumerWorkflowName(consumer.name),
2770
+ ci: null
2521
2771
  }));
2772
+ if (strict) {
2773
+ for (const consumer of consumers) {
2774
+ if (consumer.matchesLocal === true) {
2775
+ consumer.ci = inspectConsumerCi(consumer, { strict: true });
2776
+ diagnostics.push(...consumer.ci.diagnostics);
2777
+ }
2778
+ }
2779
+ }
2522
2780
  const consumerPins = summarizeConsumerPins(consumers);
2781
+ const consumerCi = summarizeConsumerCi(consumers);
2523
2782
  const currentPublished = latestVersion ? latestVersion === localVersion : null;
2524
2783
  if (strict) {
2525
2784
  diagnostics.push(...releaseStatusStrictDiagnostics({
@@ -2527,7 +2786,8 @@ function buildReleaseStatusPayload(options = {}) {
2527
2786
  latestVersion,
2528
2787
  currentPublished,
2529
2788
  git,
2530
- consumerPins
2789
+ consumerPins,
2790
+ consumerCi
2531
2791
  }));
2532
2792
  }
2533
2793
  const errors = diagnostics
@@ -2542,6 +2802,7 @@ function buildReleaseStatusPayload(options = {}) {
2542
2802
  currentPublished,
2543
2803
  git,
2544
2804
  consumerPins,
2805
+ consumerCi,
2545
2806
  consumers,
2546
2807
  diagnostics,
2547
2808
  errors
@@ -2554,7 +2815,8 @@ function buildReleaseStatusPayload(options = {}) {
2554
2815
  * latestVersion: string|null,
2555
2816
  * currentPublished: boolean|null,
2556
2817
  * git: ReturnType<typeof inspectReleaseGitTag>,
2557
- * consumerPins: ReturnType<typeof summarizeConsumerPins>
2818
+ * consumerPins: ReturnType<typeof summarizeConsumerPins>,
2819
+ * consumerCi: ReturnType<typeof summarizeConsumerCi>
2558
2820
  * }} release
2559
2821
  * @returns {Array<{ code: string, severity: "error", message: string, path: string, suggestedFix: string }>}
2560
2822
  */
@@ -2571,13 +2833,13 @@ function releaseStatusStrictDiagnostics(release) {
2571
2833
  suggestedFix: "Publish the current CLI package version or fix npm package registry auth, then rerun `topogram release status --strict`."
2572
2834
  });
2573
2835
  }
2574
- if (release.git.local !== true) {
2836
+ if (release.git.local !== true && release.git.remote !== true) {
2575
2837
  diagnostics.push({
2576
2838
  code: "release_local_tag_missing",
2577
2839
  severity: "error",
2578
2840
  message: `Release tag ${release.git.tag} is missing locally.`,
2579
2841
  path: release.git.tag,
2580
- suggestedFix: `Fetch or create the local ${release.git.tag} tag before treating this release as complete.`
2842
+ suggestedFix: `Fetch, create, or push ${release.git.tag} before treating this release as complete.`
2581
2843
  });
2582
2844
  }
2583
2845
  if (release.git.remote !== true) {
@@ -2598,6 +2860,15 @@ function releaseStatusStrictDiagnostics(release) {
2598
2860
  suggestedFix: "Roll first-party consumer repositories to the current CLI version before treating this release as complete."
2599
2861
  });
2600
2862
  }
2863
+ if (release.consumerCi.allCheckedAndPassing !== true) {
2864
+ diagnostics.push({
2865
+ code: "release_consumer_ci_not_current",
2866
+ severity: "error",
2867
+ message: "First-party consumer verification workflows are not all passing on the checked-out consumer commits.",
2868
+ path: "GitHub Actions",
2869
+ suggestedFix: "Wait for or fix the consumer verification workflows, then rerun `topogram release status --strict`."
2870
+ });
2871
+ }
2601
2872
  return diagnostics;
2602
2873
  }
2603
2874
 
@@ -2618,13 +2889,27 @@ function printReleaseStatus(payload) {
2618
2889
  `Consumer pins: ${payload.consumerPins.pinned}/${payload.consumerPins.known} pinned, ` +
2619
2890
  `${payload.consumerPins.matching} matching, ${payload.consumerPins.differing} differing, ${payload.consumerPins.missing} missing`
2620
2891
  );
2892
+ if (payload.strict) {
2893
+ console.log(
2894
+ `Consumer CI: ${payload.consumerCi.passing}/${payload.consumerCi.checked} passing, ` +
2895
+ `${payload.consumerCi.failing} failing, ${payload.consumerCi.unavailable} unavailable, ${payload.consumerCi.skipped} skipped`
2896
+ );
2897
+ }
2621
2898
  for (const consumer of payload.consumers) {
2622
2899
  const status = consumer.matchesLocal === true
2623
2900
  ? "matches"
2624
2901
  : consumer.matchesLocal === false
2625
2902
  ? "differs"
2626
2903
  : "missing";
2627
- console.log(`- ${consumer.name}: ${consumer.version || "missing"} (${status})`);
2904
+ const ciStatus = consumer.ci?.run
2905
+ ? `; ${consumer.ci.run.workflowName || consumer.workflow}: ${consumer.ci.run.status || "unknown"}/${consumer.ci.run.conclusion || "unknown"}`
2906
+ : consumer.ci?.checked
2907
+ ? `; ${consumer.workflow || "workflow"} unavailable`
2908
+ : "";
2909
+ console.log(`- ${consumer.name}: ${consumer.version || "missing"} (${status})${ciStatus}`);
2910
+ if (consumer.ci?.run?.url) {
2911
+ console.log(` CI: ${consumer.ci.run.url}`);
2912
+ }
2628
2913
  }
2629
2914
  if (payload.diagnostics.length > 0) {
2630
2915
  console.log("Diagnostics:");
@@ -2642,6 +2927,62 @@ function printReleaseStatus(payload) {
2642
2927
  }
2643
2928
  }
2644
2929
 
2930
+ /**
2931
+ * @param {ReturnType<typeof buildReleaseStatusPayload>} payload
2932
+ * @returns {string}
2933
+ */
2934
+ function renderReleaseStatusMarkdown(payload) {
2935
+ const lines = [
2936
+ `# Topogram CLI release ${payload.localVersion}`,
2937
+ "",
2938
+ `Date checked: ${new Date().toISOString().slice(0, 10)}`,
2939
+ "",
2940
+ "## Summary",
2941
+ "",
2942
+ `- Package: \`${payload.packageName}@${payload.localVersion}\``,
2943
+ `- Latest published: \`${payload.latestVersion || "unknown"}\`${payload.currentPublished === true ? " (current)" : payload.currentPublished === false ? " (differs)" : ""}`,
2944
+ `- Release tag: \`${payload.git.tag}\` (local=${labelBoolean(payload.git.local)}, remote=${labelBoolean(payload.git.remote)})`,
2945
+ `- Consumer pins: ${payload.consumerPins.matching}/${payload.consumerPins.known} matching`,
2946
+ `- Consumer CI: ${payload.consumerCi.passing}/${payload.consumerCi.checked} passing`,
2947
+ `- Strict status: ${payload.ok ? "passed" : "failed"}`,
2948
+ "",
2949
+ "## Consumers",
2950
+ "",
2951
+ "| Repo | Pin | Workflow | Status | Run |",
2952
+ "| --- | --- | --- | --- | --- |"
2953
+ ];
2954
+ for (const consumer of payload.consumers) {
2955
+ const workflow = consumer.workflow || consumer.ci?.expectedWorkflow || "";
2956
+ const run = consumer.ci?.run;
2957
+ const status = run ? `${run.status || "unknown"}/${run.conclusion || "unknown"}` : consumer.ci?.checked ? "unavailable" : "not checked";
2958
+ const url = run?.url ? `[${run.databaseId || "run"}](${run.url})` : "";
2959
+ lines.push(`| \`${consumer.name}\` | \`${consumer.version || "missing"}\` | ${escapeMarkdownTableCell(workflow)} | ${escapeMarkdownTableCell(status)} | ${url} |`);
2960
+ }
2961
+ if (payload.diagnostics.length > 0) {
2962
+ lines.push("", "## Diagnostics", "");
2963
+ for (const diagnostic of payload.diagnostics) {
2964
+ const label = diagnostic.severity === "warning"
2965
+ ? "Warning"
2966
+ : diagnostic.severity === "info"
2967
+ ? "Note"
2968
+ : "Error";
2969
+ lines.push(`- **${label}** \`${diagnostic.code}\`: ${diagnostic.message}`);
2970
+ if (diagnostic.suggestedFix) {
2971
+ lines.push(` Fix: ${diagnostic.suggestedFix}`);
2972
+ }
2973
+ }
2974
+ }
2975
+ return `${lines.join("\n")}\n`;
2976
+ }
2977
+
2978
+ /**
2979
+ * @param {string|null|undefined} value
2980
+ * @returns {string}
2981
+ */
2982
+ function escapeMarkdownTableCell(value) {
2983
+ return String(value || "").replace(/\|/g, "\\|").replace(/\n/g, "<br>");
2984
+ }
2985
+
2645
2986
  /**
2646
2987
  * @param {boolean|null} value
2647
2988
  * @returns {string}
@@ -2697,9 +3038,294 @@ function inspectReleaseGitTag(version, cwd) {
2697
3038
  return { tag, local, remote, diagnostics };
2698
3039
  }
2699
3040
 
3041
+ /**
3042
+ * @param {string} name
3043
+ * @returns {string|null}
3044
+ */
3045
+ function expectedConsumerWorkflowName(name) {
3046
+ return KNOWN_CLI_CONSUMER_WORKFLOWS[name] || null;
3047
+ }
3048
+
3049
+ /**
3050
+ * @param {string[]} args
3051
+ * @param {string} cwd
3052
+ * @returns {ReturnType<typeof childProcess.spawnSync>}
3053
+ */
3054
+ function runGit(args, cwd) {
3055
+ return childProcess.spawnSync("git", args, {
3056
+ cwd,
3057
+ encoding: "utf8",
3058
+ env: { ...process.env, PATH: process.env.PATH || "" }
3059
+ });
3060
+ }
3061
+
2700
3062
  /**
2701
3063
  * @param {string} cwd
2702
- * @returns {Array<{ name: string, path: string, version: string|null, found: boolean }>}
3064
+ * @returns {{ ok: boolean, dirty: boolean|null, error: string|null }}
3065
+ */
3066
+ function inspectGitWorktreeClean(cwd) {
3067
+ const result = runGit(["status", "--porcelain"], cwd);
3068
+ if (result.status !== 0) {
3069
+ return {
3070
+ ok: false,
3071
+ dirty: null,
3072
+ error: `Could not inspect git status: ${commandOutput(result) || "unknown error"}`
3073
+ };
3074
+ }
3075
+ const dirty = String(result.stdout || "").trim().length > 0;
3076
+ return {
3077
+ ok: !dirty,
3078
+ dirty,
3079
+ error: dirty ? "Consumer repo has uncommitted changes." : null
3080
+ };
3081
+ }
3082
+
3083
+ /**
3084
+ * @param {string} cwd
3085
+ * @returns {{ ok: boolean, changed: boolean, result: ReturnType<typeof childProcess.spawnSync> }}
3086
+ */
3087
+ function hasStagedGitChanges(cwd) {
3088
+ const result = runGit(["diff", "--cached", "--quiet"], cwd);
3089
+ return {
3090
+ ok: result.status === 0 || result.status === 1,
3091
+ changed: result.status === 1,
3092
+ result
3093
+ };
3094
+ }
3095
+
3096
+ /**
3097
+ * @param {string} cwd
3098
+ * @returns {string|null}
3099
+ */
3100
+ function currentGitHead(cwd) {
3101
+ const result = runGit(["rev-parse", "HEAD"], cwd);
3102
+ return result.status === 0 ? String(result.stdout || "").trim() || null : null;
3103
+ }
3104
+
3105
+ /**
3106
+ * @param {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string, result: ReturnType<typeof childProcess.spawnSync> }} input
3107
+ * @returns {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string }}
3108
+ */
3109
+ function commandDiagnostic(input) {
3110
+ const output = commandOutput(input.result);
3111
+ return {
3112
+ code: input.code,
3113
+ severity: input.severity,
3114
+ message: output ? `${input.message}\n${output}` : input.message,
3115
+ path: input.path,
3116
+ suggestedFix: input.suggestedFix
3117
+ };
3118
+ }
3119
+
3120
+ /**
3121
+ * @param {ReturnType<typeof childProcess.spawnSync>} result
3122
+ * @returns {string}
3123
+ */
3124
+ function commandOutput(result) {
3125
+ return [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim();
3126
+ }
3127
+
3128
+ /**
3129
+ * @param {number} ms
3130
+ * @returns {void}
3131
+ */
3132
+ function sleepSync(ms) {
3133
+ if (ms <= 0) {
3134
+ return;
3135
+ }
3136
+ const buffer = new SharedArrayBuffer(4);
3137
+ const view = new Int32Array(buffer);
3138
+ Atomics.wait(view, 0, 0, ms);
3139
+ }
3140
+
3141
+ /**
3142
+ * @param {string} name
3143
+ * @param {number} fallback
3144
+ * @returns {number}
3145
+ */
3146
+ function positiveIntegerEnv(name, fallback) {
3147
+ const value = Number.parseInt(process.env[name] || "", 10);
3148
+ return Number.isFinite(value) && value > 0 ? value : fallback;
3149
+ }
3150
+
3151
+ /**
3152
+ * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
3153
+ * @param {{ timeoutMs?: number, intervalMs?: number }} [options]
3154
+ * @returns {ReturnType<typeof inspectConsumerCi>}
3155
+ */
3156
+ function waitForConsumerCi(consumer, options = {}) {
3157
+ const timeoutMs = options.timeoutMs || positiveIntegerEnv("TOPOGRAM_RELEASE_WATCH_TIMEOUT_MS", 20 * 60 * 1000);
3158
+ const intervalMs = options.intervalMs || positiveIntegerEnv("TOPOGRAM_RELEASE_WATCH_INTERVAL_MS", 5000);
3159
+ const startedAt = Date.now();
3160
+ let latest = inspectConsumerCi(consumer, { strict: false });
3161
+ while (true) {
3162
+ const currentRun = latest.run &&
3163
+ latest.headSha &&
3164
+ latest.run.headSha &&
3165
+ latest.run.headSha === latest.headSha;
3166
+ if (currentRun && latest.run.status === "completed") {
3167
+ return inspectConsumerCi(consumer, { strict: true });
3168
+ }
3169
+ if (Date.now() - startedAt >= timeoutMs) {
3170
+ const strictLatest = inspectConsumerCi(consumer, { strict: true });
3171
+ strictLatest.diagnostics.push({
3172
+ code: "release_consumer_ci_watch_timeout",
3173
+ severity: "error",
3174
+ message: `${consumer.name} verification workflow did not complete on the current commit before the watch timeout.`,
3175
+ path: strictLatest.run?.url || `attebury/${consumer.name}`,
3176
+ suggestedFix: "Open the consumer workflow, fix failures if needed, then rerun release status."
3177
+ });
3178
+ strictLatest.ok = false;
3179
+ return strictLatest;
3180
+ }
3181
+ sleepSync(intervalMs);
3182
+ latest = inspectConsumerCi(consumer, { strict: false });
3183
+ }
3184
+ }
3185
+
3186
+ /**
3187
+ * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
3188
+ * @param {{ strict?: boolean }} [options]
3189
+ * @returns {{ checked: boolean, ok: boolean|null, expectedWorkflow: string|null, headSha: string|null, run: { databaseId?: number, workflowName?: string, status?: string, conclusion?: string, headSha?: string, url?: string }|null, diagnostics: Array<Record<string, any>> }}
3190
+ */
3191
+ function inspectConsumerCi(consumer, options = {}) {
3192
+ const diagnostics = [];
3193
+ const expectedWorkflow = consumer.workflow || expectedConsumerWorkflowName(consumer.name);
3194
+ if (!consumer.root || !fs.existsSync(consumer.root)) {
3195
+ return {
3196
+ checked: false,
3197
+ ok: null,
3198
+ expectedWorkflow,
3199
+ headSha: null,
3200
+ run: null,
3201
+ diagnostics: []
3202
+ };
3203
+ }
3204
+ const headSha = currentGitHead(consumer.root);
3205
+ if (!headSha) {
3206
+ diagnostics.push({
3207
+ code: "release_consumer_head_unavailable",
3208
+ severity: options.strict ? "error" : "warning",
3209
+ message: `Could not inspect local HEAD for ${consumer.name}.`,
3210
+ path: consumer.root,
3211
+ suggestedFix: "Run from a checked-out consumer git repository."
3212
+ });
3213
+ }
3214
+ if (!expectedWorkflow) {
3215
+ diagnostics.push({
3216
+ code: "release_consumer_workflow_unknown",
3217
+ severity: options.strict ? "error" : "warning",
3218
+ message: `No expected verification workflow is configured for ${consumer.name}.`,
3219
+ path: consumer.name,
3220
+ suggestedFix: "Add the consumer repo to KNOWN_CLI_CONSUMER_WORKFLOWS."
3221
+ });
3222
+ return {
3223
+ checked: true,
3224
+ ok: false,
3225
+ expectedWorkflow,
3226
+ headSha,
3227
+ run: null,
3228
+ diagnostics
3229
+ };
3230
+ }
3231
+ const result = childProcess.spawnSync("gh", [
3232
+ "run",
3233
+ "list",
3234
+ "--repo",
3235
+ `attebury/${consumer.name}`,
3236
+ "--branch",
3237
+ "main",
3238
+ "--workflow",
3239
+ expectedWorkflow,
3240
+ "--limit",
3241
+ "1",
3242
+ "--json",
3243
+ "databaseId,workflowName,status,conclusion,headSha,url"
3244
+ ], {
3245
+ cwd: consumer.root,
3246
+ encoding: "utf8",
3247
+ env: { ...process.env, PATH: process.env.PATH || "" }
3248
+ });
3249
+ if (result.status !== 0) {
3250
+ diagnostics.push(commandDiagnostic({
3251
+ code: "release_consumer_ci_unavailable",
3252
+ severity: options.strict ? "error" : "warning",
3253
+ message: `Could not inspect ${expectedWorkflow} for ${consumer.name}.`,
3254
+ path: `attebury/${consumer.name}`,
3255
+ suggestedFix: "Check GitHub CLI auth/network access, then rerun release status.",
3256
+ result
3257
+ }));
3258
+ return {
3259
+ checked: true,
3260
+ ok: false,
3261
+ expectedWorkflow,
3262
+ headSha,
3263
+ run: null,
3264
+ diagnostics
3265
+ };
3266
+ }
3267
+ let runs = [];
3268
+ try {
3269
+ runs = JSON.parse(String(result.stdout || "[]"));
3270
+ } catch (error) {
3271
+ diagnostics.push({
3272
+ code: "release_consumer_ci_unreadable",
3273
+ severity: options.strict ? "error" : "warning",
3274
+ message: `Could not parse ${consumer.name} workflow status: ${messageFromError(error)}`,
3275
+ path: `attebury/${consumer.name}`,
3276
+ suggestedFix: "Rerun release status after GitHub CLI output is valid JSON."
3277
+ });
3278
+ }
3279
+ const run = Array.isArray(runs) && runs.length > 0 ? runs[0] : null;
3280
+ if (!run) {
3281
+ diagnostics.push({
3282
+ code: "release_consumer_ci_missing",
3283
+ severity: options.strict ? "error" : "warning",
3284
+ message: `${consumer.name} has no ${expectedWorkflow} run on main.`,
3285
+ path: `attebury/${consumer.name}`,
3286
+ suggestedFix: "Push the consumer repo and wait for its verification workflow."
3287
+ });
3288
+ return {
3289
+ checked: true,
3290
+ ok: false,
3291
+ expectedWorkflow,
3292
+ headSha,
3293
+ run: null,
3294
+ diagnostics
3295
+ };
3296
+ }
3297
+ if (headSha && run.headSha && run.headSha !== headSha) {
3298
+ diagnostics.push({
3299
+ code: "release_consumer_ci_head_mismatch",
3300
+ severity: options.strict ? "error" : "warning",
3301
+ message: `${consumer.name} latest ${expectedWorkflow} run is for ${run.headSha}, not checked-out HEAD ${headSha}.`,
3302
+ path: run.url || `attebury/${consumer.name}`,
3303
+ suggestedFix: "Wait for the verification workflow on the current consumer commit, then rerun release status."
3304
+ });
3305
+ }
3306
+ if (run.status !== "completed" || run.conclusion !== "success") {
3307
+ diagnostics.push({
3308
+ code: "release_consumer_ci_not_successful",
3309
+ severity: options.strict ? "error" : "warning",
3310
+ message: `${consumer.name} ${expectedWorkflow} is ${run.status || "unknown"}/${run.conclusion || "unknown"}.`,
3311
+ path: run.url || `attebury/${consumer.name}`,
3312
+ suggestedFix: "Wait for or fix the consumer verification workflow, then rerun release status."
3313
+ });
3314
+ }
3315
+ return {
3316
+ checked: true,
3317
+ ok: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length === 0 &&
3318
+ (!options.strict || (run.status === "completed" && run.conclusion === "success" && (!headSha || !run.headSha || run.headSha === headSha))),
3319
+ expectedWorkflow,
3320
+ headSha,
3321
+ run,
3322
+ diagnostics
3323
+ };
3324
+ }
3325
+
3326
+ /**
3327
+ * @param {string} cwd
3328
+ * @returns {Array<{ name: string, root: string|null, path: string, version: string|null, found: boolean }>}
2703
3329
  */
2704
3330
  function discoverTopogramCliVersionConsumers(cwd) {
2705
3331
  const roots = [];
@@ -2718,6 +3344,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2718
3344
  if (fs.existsSync(consumerRoot) && !fs.existsSync(versionPath)) {
2719
3345
  found = {
2720
3346
  name,
3347
+ root: consumerRoot,
2721
3348
  path: versionPath,
2722
3349
  version: null,
2723
3350
  found: false
@@ -2729,6 +3356,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2729
3356
  }
2730
3357
  found = {
2731
3358
  name,
3359
+ root: consumerRoot,
2732
3360
  path: versionPath,
2733
3361
  version: fs.readFileSync(versionPath, "utf8").trim() || null,
2734
3362
  found: true
@@ -2737,6 +3365,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2737
3365
  }
2738
3366
  consumers.push(found || {
2739
3367
  name,
3368
+ root: null,
2740
3369
  path: path.join(roots[0], name, "topogram-cli.version"),
2741
3370
  version: null,
2742
3371
  found: false
@@ -2766,6 +3395,30 @@ function summarizeConsumerPins(consumers) {
2766
3395
  };
2767
3396
  }
2768
3397
 
3398
+ /**
3399
+ * @param {Array<{ name: string, matchesLocal?: boolean|null, ci?: ReturnType<typeof inspectConsumerCi>|null }>} consumers
3400
+ * @returns {{ checked: number, passing: number, failing: number, unavailable: number, skipped: number, allCheckedAndPassing: boolean, passingNames: string[], failingNames: string[], unavailableNames: string[], skippedNames: string[] }}
3401
+ */
3402
+ function summarizeConsumerCi(consumers) {
3403
+ const checked = consumers.filter((consumer) => consumer.ci?.checked);
3404
+ const passingNames = checked.filter((consumer) => consumer.ci?.ok === true).map((consumer) => consumer.name);
3405
+ const failingNames = checked.filter((consumer) => consumer.ci?.ok === false && consumer.ci?.run).map((consumer) => consumer.name);
3406
+ const unavailableNames = checked.filter((consumer) => consumer.ci?.ok === false && !consumer.ci?.run).map((consumer) => consumer.name);
3407
+ const skippedNames = consumers.filter((consumer) => !consumer.ci?.checked).map((consumer) => consumer.name);
3408
+ return {
3409
+ checked: checked.length,
3410
+ passing: passingNames.length,
3411
+ failing: failingNames.length,
3412
+ unavailable: unavailableNames.length,
3413
+ skipped: skippedNames.length,
3414
+ allCheckedAndPassing: consumers.length > 0 && checked.length === consumers.length && failingNames.length === 0 && unavailableNames.length === 0,
3415
+ passingNames,
3416
+ failingNames,
3417
+ unavailableNames,
3418
+ skippedNames
3419
+ };
3420
+ }
3421
+
2769
3422
  /**
2770
3423
  * @param {string|null} source
2771
3424
  * @returns {{ ok: boolean, node: { version: string, minimum: string, ok: boolean, diagnostics: any[] }, npm: { available: boolean, version: string|null, diagnostics: any[] }, packageRegistry: { required: boolean, reason: string|null, registry: string, configuredRegistry: string|null, registryConfigured: boolean, nodeAuthTokenEnv: boolean, packageName: string, packageSpec: string|null, packageAccess: { ok: boolean, checkedVersion: string|null, diagnostics: any[] } }, lockfile: ReturnType<typeof inspectTopogramCliLockfile>, catalog: ReturnType<typeof buildCatalogDoctorPayload>, diagnostics: any[], errors: string[] }}
@@ -7607,6 +8260,8 @@ if (args[0] === "version" || args[0] === "--version") {
7607
8260
  commandArgs = { doctor: true, inputPath: args[1] && !args[1].startsWith("-") ? args[1] : null };
7608
8261
  } else if (args[0] === "release" && args[1] === "status") {
7609
8262
  commandArgs = { releaseStatus: true, inputPath: null };
8263
+ } else if (args[0] === "release" && args[1] === "roll-consumers") {
8264
+ commandArgs = { releaseRollConsumers: true, releaseRollVersion: args[2], inputPath: null };
7610
8265
  } else if (args[0] === "new" || args[0] === "create") {
7611
8266
  commandArgs = args.includes("--list-templates")
7612
8267
  ? { templateList: true, inputPath: null }
@@ -7823,9 +8478,19 @@ if (commandArgs && Object.prototype.hasOwnProperty.call(commandArgs, "inputPath"
7823
8478
  }
7824
8479
  const emitJson = args.includes("--json");
7825
8480
  const strictReleaseStatus = args.includes("--strict");
8481
+ const shouldPushReleaseConsumers = !args.includes("--no-push");
8482
+ const shouldWatchReleaseConsumers = args.includes("--watch");
8483
+ const shouldPrintReleaseMarkdown = args.includes("--markdown");
8484
+ const releaseReportIndex = args.indexOf("--write-report");
8485
+ const releaseReportPath = releaseReportIndex >= 0 &&
8486
+ args[releaseReportIndex + 1] &&
8487
+ !args[releaseReportIndex + 1].startsWith("-")
8488
+ ? args[releaseReportIndex + 1]
8489
+ : null;
7826
8490
  const shouldVersion = Boolean(commandArgs?.version);
7827
8491
  const shouldDoctor = Boolean(commandArgs?.doctor);
7828
8492
  const shouldReleaseStatus = Boolean(commandArgs?.releaseStatus);
8493
+ const shouldReleaseRollConsumers = Boolean(commandArgs?.releaseRollConsumers);
7829
8494
  const shouldCheck = Boolean(commandArgs?.check);
7830
8495
  const shouldComponentCheck = Boolean(commandArgs?.componentCheck);
7831
8496
  const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
@@ -8000,6 +8665,18 @@ if (shouldPackageUpdateCli && !inputPath) {
8000
8665
  process.exit(1);
8001
8666
  }
8002
8667
 
8668
+ if (shouldReleaseStatus && args.includes("--write-report") && !releaseReportPath) {
8669
+ console.error("Missing required --write-report <path>.");
8670
+ printReleaseHelp();
8671
+ process.exit(1);
8672
+ }
8673
+
8674
+ if (shouldReleaseRollConsumers && shouldWatchReleaseConsumers && !shouldPushReleaseConsumers) {
8675
+ console.error("Use either --watch or --no-push, not both.");
8676
+ printReleaseHelp();
8677
+ process.exit(1);
8678
+ }
8679
+
8003
8680
  if (shouldImportWorkspace && !outPath) {
8004
8681
  console.error("Missing required --out <target>.");
8005
8682
  printImportHelp();
@@ -8051,10 +8728,33 @@ try {
8051
8728
 
8052
8729
  if (shouldReleaseStatus) {
8053
8730
  const payload = buildReleaseStatusPayload({ strict: strictReleaseStatus });
8731
+ if (releaseReportPath) {
8732
+ const target = path.resolve(releaseReportPath);
8733
+ fs.mkdirSync(path.dirname(target), { recursive: true });
8734
+ fs.writeFileSync(target, renderReleaseStatusMarkdown(payload), "utf8");
8735
+ }
8054
8736
  if (emitJson) {
8055
8737
  console.log(stableStringify(payload));
8738
+ } else if (shouldPrintReleaseMarkdown) {
8739
+ console.log(renderReleaseStatusMarkdown(payload).trimEnd());
8056
8740
  } else {
8057
8741
  printReleaseStatus(payload);
8742
+ if (releaseReportPath) {
8743
+ console.log(`Report: ${path.resolve(releaseReportPath)}`);
8744
+ }
8745
+ }
8746
+ process.exit(payload.ok ? 0 : 1);
8747
+ }
8748
+
8749
+ if (shouldReleaseRollConsumers) {
8750
+ const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, {
8751
+ push: shouldPushReleaseConsumers,
8752
+ watch: shouldWatchReleaseConsumers
8753
+ });
8754
+ if (emitJson) {
8755
+ console.log(stableStringify(payload));
8756
+ } else {
8757
+ printReleaseRollConsumers(payload);
8058
8758
  }
8059
8759
  process.exit(payload.ok ? 0 : 1);
8060
8760
  }