@topogram/cli 0.3.46 → 0.3.47

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 +535 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.46",
3
+ "version": "0.3.47",
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",
@@ -178,6 +192,7 @@ function printUsage(options = {}) {
178
192
  console.log("Usage: topogram doctor [--json] [--catalog <path-or-source>]");
179
193
  console.log("Usage: topogram setup package-auth|catalog-auth");
180
194
  console.log("Usage: topogram release status [--json] [--strict]");
195
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push]");
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");
@@ -744,13 +760,17 @@ function printPackageHelp() {
744
760
 
745
761
  function printReleaseHelp() {
746
762
  console.log("Usage: topogram release status [--json] [--strict]");
763
+ console.log(" or: topogram release roll-consumers <version|--latest> [--json] [--no-push]");
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 prints latest workflow URLs.");
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 roll-consumers 0.3.46");
773
+ console.log(" topogram release roll-consumers --latest");
754
774
  console.log("");
755
775
  console.log("Release preparation and publishing are repo-level tasks in the Topogram source checkout:");
756
776
  console.log(" npm run release:prepare -- <version>");
@@ -2493,8 +2513,208 @@ function printPackageUpdateCli(payload) {
2493
2513
  }
2494
2514
 
2495
2515
  /**
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[] }}
2516
+ * @param {string} requested
2517
+ * @param {{ cwd?: string, push?: boolean }} [options]
2518
+ * @returns {{ ok: boolean, packageName: string, requestedVersion: string, requestedLatest: boolean, pushed: boolean, consumers: Array<Record<string, any>>, diagnostics: Array<Record<string, any>>, errors: string[] }}
2519
+ */
2520
+ function buildReleaseRollConsumersPayload(requested, options = {}) {
2521
+ const cwd = options.cwd || process.cwd();
2522
+ const push = options.push !== false;
2523
+ const requestedLatest = requested === "latest" || requested === "--latest";
2524
+ const diagnostics = [];
2525
+ const version = requestedLatest
2526
+ ? resolveLatestTopogramCliVersionForPackageUpdate(cwd, diagnostics)
2527
+ : requested;
2528
+ if (!isPackageVersion(version)) {
2529
+ throw new Error("topogram release roll-consumers requires <version> or --latest.");
2530
+ }
2531
+ const consumers = [];
2532
+ for (const consumer of discoverTopogramCliVersionConsumers(cwd)) {
2533
+ const workflow = expectedConsumerWorkflowName(consumer.name);
2534
+ const item = {
2535
+ name: consumer.name,
2536
+ root: consumer.root,
2537
+ workflow,
2538
+ updated: false,
2539
+ committed: false,
2540
+ pushed: false,
2541
+ commit: null,
2542
+ update: null,
2543
+ ci: null,
2544
+ diagnostics: []
2545
+ };
2546
+ consumers.push(item);
2547
+ if (!consumer.root || !fs.existsSync(consumer.root)) {
2548
+ item.diagnostics.push({
2549
+ code: "release_consumer_repo_missing",
2550
+ severity: "error",
2551
+ message: `First-party consumer repo ${consumer.name} was not found.`,
2552
+ path: consumer.path,
2553
+ suggestedFix: `Clone ${consumer.name} beside the topogram repo, then rerun roll-consumers.`
2554
+ });
2555
+ diagnostics.push(...item.diagnostics);
2556
+ continue;
2557
+ }
2558
+ const packagePath = path.join(consumer.root, "package.json");
2559
+ if (!fs.existsSync(packagePath)) {
2560
+ item.diagnostics.push({
2561
+ code: "release_consumer_package_missing",
2562
+ severity: "error",
2563
+ message: `First-party consumer repo ${consumer.name} does not contain package.json.`,
2564
+ path: packagePath,
2565
+ suggestedFix: "Only package-backed first-party consumers can be rolled by this command."
2566
+ });
2567
+ diagnostics.push(...item.diagnostics);
2568
+ continue;
2569
+ }
2570
+ const clean = inspectGitWorktreeClean(consumer.root);
2571
+ if (clean.ok !== true) {
2572
+ item.diagnostics.push({
2573
+ code: "release_consumer_worktree_dirty",
2574
+ severity: "error",
2575
+ message: clean.error || `First-party consumer repo ${consumer.name} has uncommitted changes.`,
2576
+ path: consumer.root,
2577
+ suggestedFix: "Commit, stash, or discard unrelated consumer changes before rolling the CLI version."
2578
+ });
2579
+ diagnostics.push(...item.diagnostics);
2580
+ continue;
2581
+ }
2582
+ try {
2583
+ item.update = buildPackageUpdateCliPayload(version, { cwd: consumer.root });
2584
+ item.updated = true;
2585
+ } catch (error) {
2586
+ item.diagnostics.push({
2587
+ code: "release_consumer_update_failed",
2588
+ severity: "error",
2589
+ message: `Failed to update ${consumer.name}: ${messageFromError(error)}`,
2590
+ path: consumer.root,
2591
+ suggestedFix: "Fix the consumer update/check failure, then rerun roll-consumers."
2592
+ });
2593
+ diagnostics.push(...item.diagnostics);
2594
+ continue;
2595
+ }
2596
+ const filesToStage = ["package.json", "package-lock.json", "topogram-cli.version"]
2597
+ .filter((file) => fs.existsSync(path.join(consumer.root, file)));
2598
+ const addResult = runGit(["add", ...filesToStage], consumer.root);
2599
+ if (addResult.status !== 0) {
2600
+ item.diagnostics.push(commandDiagnostic({
2601
+ code: "release_consumer_git_add_failed",
2602
+ severity: "error",
2603
+ message: `Failed to stage ${consumer.name} CLI update.`,
2604
+ path: consumer.root,
2605
+ suggestedFix: "Inspect git output, stage the changed files manually, then commit and push.",
2606
+ result: addResult
2607
+ }));
2608
+ diagnostics.push(...item.diagnostics);
2609
+ continue;
2610
+ }
2611
+ const staged = hasStagedGitChanges(consumer.root);
2612
+ if (!staged.ok) {
2613
+ item.diagnostics.push(commandDiagnostic({
2614
+ code: "release_consumer_git_diff_failed",
2615
+ severity: "error",
2616
+ message: `Could not inspect staged changes for ${consumer.name}.`,
2617
+ path: consumer.root,
2618
+ suggestedFix: "Inspect git status manually before committing.",
2619
+ result: staged.result
2620
+ }));
2621
+ diagnostics.push(...item.diagnostics);
2622
+ continue;
2623
+ }
2624
+ if (!staged.changed) {
2625
+ item.ci = inspectConsumerCi(consumer, { strict: false });
2626
+ item.diagnostics.push(...item.ci.diagnostics);
2627
+ diagnostics.push(...item.ci.diagnostics);
2628
+ continue;
2629
+ }
2630
+ const commitResult = runGit(["commit", "-m", `Update Topogram CLI to ${version}`], consumer.root);
2631
+ if (commitResult.status !== 0) {
2632
+ item.diagnostics.push(commandDiagnostic({
2633
+ code: "release_consumer_git_commit_failed",
2634
+ severity: "error",
2635
+ message: `Failed to commit ${consumer.name} CLI update.`,
2636
+ path: consumer.root,
2637
+ suggestedFix: "Inspect git output, commit the consumer update manually, then push.",
2638
+ result: commitResult
2639
+ }));
2640
+ diagnostics.push(...item.diagnostics);
2641
+ continue;
2642
+ }
2643
+ item.committed = true;
2644
+ item.commit = currentGitHead(consumer.root);
2645
+ if (push) {
2646
+ const pushResult = runGit(["push", "origin", "main"], consumer.root);
2647
+ if (pushResult.status !== 0) {
2648
+ item.diagnostics.push(commandDiagnostic({
2649
+ code: "release_consumer_git_push_failed",
2650
+ severity: "error",
2651
+ message: `Failed to push ${consumer.name} CLI update.`,
2652
+ path: consumer.root,
2653
+ suggestedFix: "Push the consumer update manually, then confirm its verification workflow passes.",
2654
+ result: pushResult
2655
+ }));
2656
+ diagnostics.push(...item.diagnostics);
2657
+ continue;
2658
+ }
2659
+ item.pushed = true;
2660
+ }
2661
+ item.ci = inspectConsumerCi(consumer, { strict: false });
2662
+ item.diagnostics.push(...item.ci.diagnostics);
2663
+ diagnostics.push(...item.ci.diagnostics);
2664
+ }
2665
+ const errors = diagnostics
2666
+ .filter((diagnostic) => diagnostic.severity === "error")
2667
+ .map((diagnostic) => diagnostic.message);
2668
+ return {
2669
+ ok: errors.length === 0,
2670
+ packageName: CLI_PACKAGE_NAME,
2671
+ requestedVersion: version,
2672
+ requestedLatest,
2673
+ pushed: push,
2674
+ consumers,
2675
+ diagnostics,
2676
+ errors
2677
+ };
2678
+ }
2679
+
2680
+ /**
2681
+ * @param {ReturnType<typeof buildReleaseRollConsumersPayload>} payload
2682
+ * @returns {void}
2683
+ */
2684
+ function printReleaseRollConsumers(payload) {
2685
+ console.log(payload.ok ? "Topogram consumer rollout completed." : "Topogram consumer rollout found issues.");
2686
+ if (payload.requestedLatest) {
2687
+ console.log(`Resolved latest version: ${payload.requestedVersion}`);
2688
+ }
2689
+ console.log(`Package: ${payload.packageName}@${payload.requestedVersion}`);
2690
+ console.log(`Push: ${payload.pushed ? "enabled" : "disabled"}`);
2691
+ for (const consumer of payload.consumers) {
2692
+ const state = consumer.committed
2693
+ ? consumer.pushed ? "pushed" : "committed"
2694
+ : consumer.updated ? "updated" : "skipped";
2695
+ console.log(`- ${consumer.name}: ${state}`);
2696
+ if (consumer.update) {
2697
+ console.log(` Checks run: ${consumer.update.scriptsRun.join(", ") || "none"}`);
2698
+ }
2699
+ if (consumer.commit) {
2700
+ console.log(` Commit: ${consumer.commit}`);
2701
+ }
2702
+ if (consumer.ci?.run?.url) {
2703
+ const run = consumer.ci.run;
2704
+ console.log(` CI: ${run.workflowName || consumer.workflow} ${run.status || "unknown"}/${run.conclusion || "unknown"} ${run.url}`);
2705
+ } else if (consumer.workflow) {
2706
+ console.log(` CI: ${consumer.workflow} not found`);
2707
+ }
2708
+ for (const diagnostic of consumer.diagnostics || []) {
2709
+ const label = diagnostic.severity === "error" ? "Error" : diagnostic.severity === "warning" ? "Warning" : "Note";
2710
+ console.log(` ${label}: ${diagnostic.message}`);
2711
+ }
2712
+ }
2713
+ }
2714
+
2715
+ /**
2716
+ * @param {{ cwd?: string, strict?: boolean }} [options]
2717
+ * @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
2718
  */
2499
2719
  function buildReleaseStatusPayload(options = {}) {
2500
2720
  const cwd = options.cwd || process.cwd();
@@ -2517,9 +2737,20 @@ function buildReleaseStatusPayload(options = {}) {
2517
2737
  diagnostics.push(...git.diagnostics);
2518
2738
  const consumers = discoverTopogramCliVersionConsumers(cwd).map((consumer) => ({
2519
2739
  ...consumer,
2520
- matchesLocal: consumer.version ? consumer.version === localVersion : null
2740
+ matchesLocal: consumer.version ? consumer.version === localVersion : null,
2741
+ workflow: expectedConsumerWorkflowName(consumer.name),
2742
+ ci: null
2521
2743
  }));
2744
+ if (strict) {
2745
+ for (const consumer of consumers) {
2746
+ if (consumer.matchesLocal === true) {
2747
+ consumer.ci = inspectConsumerCi(consumer, { strict: true });
2748
+ diagnostics.push(...consumer.ci.diagnostics);
2749
+ }
2750
+ }
2751
+ }
2522
2752
  const consumerPins = summarizeConsumerPins(consumers);
2753
+ const consumerCi = summarizeConsumerCi(consumers);
2523
2754
  const currentPublished = latestVersion ? latestVersion === localVersion : null;
2524
2755
  if (strict) {
2525
2756
  diagnostics.push(...releaseStatusStrictDiagnostics({
@@ -2527,7 +2758,8 @@ function buildReleaseStatusPayload(options = {}) {
2527
2758
  latestVersion,
2528
2759
  currentPublished,
2529
2760
  git,
2530
- consumerPins
2761
+ consumerPins,
2762
+ consumerCi
2531
2763
  }));
2532
2764
  }
2533
2765
  const errors = diagnostics
@@ -2542,6 +2774,7 @@ function buildReleaseStatusPayload(options = {}) {
2542
2774
  currentPublished,
2543
2775
  git,
2544
2776
  consumerPins,
2777
+ consumerCi,
2545
2778
  consumers,
2546
2779
  diagnostics,
2547
2780
  errors
@@ -2554,7 +2787,8 @@ function buildReleaseStatusPayload(options = {}) {
2554
2787
  * latestVersion: string|null,
2555
2788
  * currentPublished: boolean|null,
2556
2789
  * git: ReturnType<typeof inspectReleaseGitTag>,
2557
- * consumerPins: ReturnType<typeof summarizeConsumerPins>
2790
+ * consumerPins: ReturnType<typeof summarizeConsumerPins>,
2791
+ * consumerCi: ReturnType<typeof summarizeConsumerCi>
2558
2792
  * }} release
2559
2793
  * @returns {Array<{ code: string, severity: "error", message: string, path: string, suggestedFix: string }>}
2560
2794
  */
@@ -2598,6 +2832,15 @@ function releaseStatusStrictDiagnostics(release) {
2598
2832
  suggestedFix: "Roll first-party consumer repositories to the current CLI version before treating this release as complete."
2599
2833
  });
2600
2834
  }
2835
+ if (release.consumerCi.allCheckedAndPassing !== true) {
2836
+ diagnostics.push({
2837
+ code: "release_consumer_ci_not_current",
2838
+ severity: "error",
2839
+ message: "First-party consumer verification workflows are not all passing on the checked-out consumer commits.",
2840
+ path: "GitHub Actions",
2841
+ suggestedFix: "Wait for or fix the consumer verification workflows, then rerun `topogram release status --strict`."
2842
+ });
2843
+ }
2601
2844
  return diagnostics;
2602
2845
  }
2603
2846
 
@@ -2618,13 +2861,27 @@ function printReleaseStatus(payload) {
2618
2861
  `Consumer pins: ${payload.consumerPins.pinned}/${payload.consumerPins.known} pinned, ` +
2619
2862
  `${payload.consumerPins.matching} matching, ${payload.consumerPins.differing} differing, ${payload.consumerPins.missing} missing`
2620
2863
  );
2864
+ if (payload.strict) {
2865
+ console.log(
2866
+ `Consumer CI: ${payload.consumerCi.passing}/${payload.consumerCi.checked} passing, ` +
2867
+ `${payload.consumerCi.failing} failing, ${payload.consumerCi.unavailable} unavailable, ${payload.consumerCi.skipped} skipped`
2868
+ );
2869
+ }
2621
2870
  for (const consumer of payload.consumers) {
2622
2871
  const status = consumer.matchesLocal === true
2623
2872
  ? "matches"
2624
2873
  : consumer.matchesLocal === false
2625
2874
  ? "differs"
2626
2875
  : "missing";
2627
- console.log(`- ${consumer.name}: ${consumer.version || "missing"} (${status})`);
2876
+ const ciStatus = consumer.ci?.run
2877
+ ? `; ${consumer.ci.run.workflowName || consumer.workflow}: ${consumer.ci.run.status || "unknown"}/${consumer.ci.run.conclusion || "unknown"}`
2878
+ : consumer.ci?.checked
2879
+ ? `; ${consumer.workflow || "workflow"} unavailable`
2880
+ : "";
2881
+ console.log(`- ${consumer.name}: ${consumer.version || "missing"} (${status})${ciStatus}`);
2882
+ if (consumer.ci?.run?.url) {
2883
+ console.log(` CI: ${consumer.ci.run.url}`);
2884
+ }
2628
2885
  }
2629
2886
  if (payload.diagnostics.length > 0) {
2630
2887
  console.log("Diagnostics:");
@@ -2698,8 +2955,235 @@ function inspectReleaseGitTag(version, cwd) {
2698
2955
  }
2699
2956
 
2700
2957
  /**
2958
+ * @param {string} name
2959
+ * @returns {string|null}
2960
+ */
2961
+ function expectedConsumerWorkflowName(name) {
2962
+ return KNOWN_CLI_CONSUMER_WORKFLOWS[name] || null;
2963
+ }
2964
+
2965
+ /**
2966
+ * @param {string[]} args
2701
2967
  * @param {string} cwd
2702
- * @returns {Array<{ name: string, path: string, version: string|null, found: boolean }>}
2968
+ * @returns {ReturnType<typeof childProcess.spawnSync>}
2969
+ */
2970
+ function runGit(args, cwd) {
2971
+ return childProcess.spawnSync("git", args, {
2972
+ cwd,
2973
+ encoding: "utf8",
2974
+ env: { ...process.env, PATH: process.env.PATH || "" }
2975
+ });
2976
+ }
2977
+
2978
+ /**
2979
+ * @param {string} cwd
2980
+ * @returns {{ ok: boolean, dirty: boolean|null, error: string|null }}
2981
+ */
2982
+ function inspectGitWorktreeClean(cwd) {
2983
+ const result = runGit(["status", "--porcelain"], cwd);
2984
+ if (result.status !== 0) {
2985
+ return {
2986
+ ok: false,
2987
+ dirty: null,
2988
+ error: `Could not inspect git status: ${commandOutput(result) || "unknown error"}`
2989
+ };
2990
+ }
2991
+ const dirty = String(result.stdout || "").trim().length > 0;
2992
+ return {
2993
+ ok: !dirty,
2994
+ dirty,
2995
+ error: dirty ? "Consumer repo has uncommitted changes." : null
2996
+ };
2997
+ }
2998
+
2999
+ /**
3000
+ * @param {string} cwd
3001
+ * @returns {{ ok: boolean, changed: boolean, result: ReturnType<typeof childProcess.spawnSync> }}
3002
+ */
3003
+ function hasStagedGitChanges(cwd) {
3004
+ const result = runGit(["diff", "--cached", "--quiet"], cwd);
3005
+ return {
3006
+ ok: result.status === 0 || result.status === 1,
3007
+ changed: result.status === 1,
3008
+ result
3009
+ };
3010
+ }
3011
+
3012
+ /**
3013
+ * @param {string} cwd
3014
+ * @returns {string|null}
3015
+ */
3016
+ function currentGitHead(cwd) {
3017
+ const result = runGit(["rev-parse", "HEAD"], cwd);
3018
+ return result.status === 0 ? String(result.stdout || "").trim() || null : null;
3019
+ }
3020
+
3021
+ /**
3022
+ * @param {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string, result: ReturnType<typeof childProcess.spawnSync> }} input
3023
+ * @returns {{ code: string, severity: "error"|"warning", message: string, path: string|null, suggestedFix: string }}
3024
+ */
3025
+ function commandDiagnostic(input) {
3026
+ const output = commandOutput(input.result);
3027
+ return {
3028
+ code: input.code,
3029
+ severity: input.severity,
3030
+ message: output ? `${input.message}\n${output}` : input.message,
3031
+ path: input.path,
3032
+ suggestedFix: input.suggestedFix
3033
+ };
3034
+ }
3035
+
3036
+ /**
3037
+ * @param {ReturnType<typeof childProcess.spawnSync>} result
3038
+ * @returns {string}
3039
+ */
3040
+ function commandOutput(result) {
3041
+ return [result.error?.message, result.stderr, result.stdout].filter(Boolean).join("\n").trim();
3042
+ }
3043
+
3044
+ /**
3045
+ * @param {{ name: string, root?: string|null, workflow?: string|null }} consumer
3046
+ * @param {{ strict?: boolean }} [options]
3047
+ * @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>> }}
3048
+ */
3049
+ function inspectConsumerCi(consumer, options = {}) {
3050
+ const diagnostics = [];
3051
+ const expectedWorkflow = consumer.workflow || expectedConsumerWorkflowName(consumer.name);
3052
+ if (!consumer.root || !fs.existsSync(consumer.root)) {
3053
+ return {
3054
+ checked: false,
3055
+ ok: null,
3056
+ expectedWorkflow,
3057
+ headSha: null,
3058
+ run: null,
3059
+ diagnostics: []
3060
+ };
3061
+ }
3062
+ const headSha = currentGitHead(consumer.root);
3063
+ if (!headSha) {
3064
+ diagnostics.push({
3065
+ code: "release_consumer_head_unavailable",
3066
+ severity: options.strict ? "error" : "warning",
3067
+ message: `Could not inspect local HEAD for ${consumer.name}.`,
3068
+ path: consumer.root,
3069
+ suggestedFix: "Run from a checked-out consumer git repository."
3070
+ });
3071
+ }
3072
+ if (!expectedWorkflow) {
3073
+ diagnostics.push({
3074
+ code: "release_consumer_workflow_unknown",
3075
+ severity: options.strict ? "error" : "warning",
3076
+ message: `No expected verification workflow is configured for ${consumer.name}.`,
3077
+ path: consumer.name,
3078
+ suggestedFix: "Add the consumer repo to KNOWN_CLI_CONSUMER_WORKFLOWS."
3079
+ });
3080
+ return {
3081
+ checked: true,
3082
+ ok: false,
3083
+ expectedWorkflow,
3084
+ headSha,
3085
+ run: null,
3086
+ diagnostics
3087
+ };
3088
+ }
3089
+ const result = childProcess.spawnSync("gh", [
3090
+ "run",
3091
+ "list",
3092
+ "--repo",
3093
+ `attebury/${consumer.name}`,
3094
+ "--branch",
3095
+ "main",
3096
+ "--workflow",
3097
+ expectedWorkflow,
3098
+ "--limit",
3099
+ "1",
3100
+ "--json",
3101
+ "databaseId,workflowName,status,conclusion,headSha,url"
3102
+ ], {
3103
+ cwd: consumer.root,
3104
+ encoding: "utf8",
3105
+ env: { ...process.env, PATH: process.env.PATH || "" }
3106
+ });
3107
+ if (result.status !== 0) {
3108
+ diagnostics.push(commandDiagnostic({
3109
+ code: "release_consumer_ci_unavailable",
3110
+ severity: options.strict ? "error" : "warning",
3111
+ message: `Could not inspect ${expectedWorkflow} for ${consumer.name}.`,
3112
+ path: `attebury/${consumer.name}`,
3113
+ suggestedFix: "Check GitHub CLI auth/network access, then rerun release status.",
3114
+ result
3115
+ }));
3116
+ return {
3117
+ checked: true,
3118
+ ok: false,
3119
+ expectedWorkflow,
3120
+ headSha,
3121
+ run: null,
3122
+ diagnostics
3123
+ };
3124
+ }
3125
+ let runs = [];
3126
+ try {
3127
+ runs = JSON.parse(String(result.stdout || "[]"));
3128
+ } catch (error) {
3129
+ diagnostics.push({
3130
+ code: "release_consumer_ci_unreadable",
3131
+ severity: options.strict ? "error" : "warning",
3132
+ message: `Could not parse ${consumer.name} workflow status: ${messageFromError(error)}`,
3133
+ path: `attebury/${consumer.name}`,
3134
+ suggestedFix: "Rerun release status after GitHub CLI output is valid JSON."
3135
+ });
3136
+ }
3137
+ const run = Array.isArray(runs) && runs.length > 0 ? runs[0] : null;
3138
+ if (!run) {
3139
+ diagnostics.push({
3140
+ code: "release_consumer_ci_missing",
3141
+ severity: options.strict ? "error" : "warning",
3142
+ message: `${consumer.name} has no ${expectedWorkflow} run on main.`,
3143
+ path: `attebury/${consumer.name}`,
3144
+ suggestedFix: "Push the consumer repo and wait for its verification workflow."
3145
+ });
3146
+ return {
3147
+ checked: true,
3148
+ ok: false,
3149
+ expectedWorkflow,
3150
+ headSha,
3151
+ run: null,
3152
+ diagnostics
3153
+ };
3154
+ }
3155
+ if (headSha && run.headSha && run.headSha !== headSha) {
3156
+ diagnostics.push({
3157
+ code: "release_consumer_ci_head_mismatch",
3158
+ severity: options.strict ? "error" : "warning",
3159
+ message: `${consumer.name} latest ${expectedWorkflow} run is for ${run.headSha}, not checked-out HEAD ${headSha}.`,
3160
+ path: run.url || `attebury/${consumer.name}`,
3161
+ suggestedFix: "Wait for the verification workflow on the current consumer commit, then rerun release status."
3162
+ });
3163
+ }
3164
+ if (run.status !== "completed" || run.conclusion !== "success") {
3165
+ diagnostics.push({
3166
+ code: "release_consumer_ci_not_successful",
3167
+ severity: options.strict ? "error" : "warning",
3168
+ message: `${consumer.name} ${expectedWorkflow} is ${run.status || "unknown"}/${run.conclusion || "unknown"}.`,
3169
+ path: run.url || `attebury/${consumer.name}`,
3170
+ suggestedFix: "Wait for or fix the consumer verification workflow, then rerun release status."
3171
+ });
3172
+ }
3173
+ return {
3174
+ checked: true,
3175
+ ok: diagnostics.filter((diagnostic) => diagnostic.severity === "error").length === 0 &&
3176
+ (!options.strict || (run.status === "completed" && run.conclusion === "success" && (!headSha || !run.headSha || run.headSha === headSha))),
3177
+ expectedWorkflow,
3178
+ headSha,
3179
+ run,
3180
+ diagnostics
3181
+ };
3182
+ }
3183
+
3184
+ /**
3185
+ * @param {string} cwd
3186
+ * @returns {Array<{ name: string, root: string|null, path: string, version: string|null, found: boolean }>}
2703
3187
  */
2704
3188
  function discoverTopogramCliVersionConsumers(cwd) {
2705
3189
  const roots = [];
@@ -2718,6 +3202,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2718
3202
  if (fs.existsSync(consumerRoot) && !fs.existsSync(versionPath)) {
2719
3203
  found = {
2720
3204
  name,
3205
+ root: consumerRoot,
2721
3206
  path: versionPath,
2722
3207
  version: null,
2723
3208
  found: false
@@ -2729,6 +3214,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2729
3214
  }
2730
3215
  found = {
2731
3216
  name,
3217
+ root: consumerRoot,
2732
3218
  path: versionPath,
2733
3219
  version: fs.readFileSync(versionPath, "utf8").trim() || null,
2734
3220
  found: true
@@ -2737,6 +3223,7 @@ function discoverTopogramCliVersionConsumers(cwd) {
2737
3223
  }
2738
3224
  consumers.push(found || {
2739
3225
  name,
3226
+ root: null,
2740
3227
  path: path.join(roots[0], name, "topogram-cli.version"),
2741
3228
  version: null,
2742
3229
  found: false
@@ -2766,6 +3253,30 @@ function summarizeConsumerPins(consumers) {
2766
3253
  };
2767
3254
  }
2768
3255
 
3256
+ /**
3257
+ * @param {Array<{ name: string, matchesLocal?: boolean|null, ci?: ReturnType<typeof inspectConsumerCi>|null }>} consumers
3258
+ * @returns {{ checked: number, passing: number, failing: number, unavailable: number, skipped: number, allCheckedAndPassing: boolean, passingNames: string[], failingNames: string[], unavailableNames: string[], skippedNames: string[] }}
3259
+ */
3260
+ function summarizeConsumerCi(consumers) {
3261
+ const checked = consumers.filter((consumer) => consumer.ci?.checked);
3262
+ const passingNames = checked.filter((consumer) => consumer.ci?.ok === true).map((consumer) => consumer.name);
3263
+ const failingNames = checked.filter((consumer) => consumer.ci?.ok === false && consumer.ci?.run).map((consumer) => consumer.name);
3264
+ const unavailableNames = checked.filter((consumer) => consumer.ci?.ok === false && !consumer.ci?.run).map((consumer) => consumer.name);
3265
+ const skippedNames = consumers.filter((consumer) => !consumer.ci?.checked).map((consumer) => consumer.name);
3266
+ return {
3267
+ checked: checked.length,
3268
+ passing: passingNames.length,
3269
+ failing: failingNames.length,
3270
+ unavailable: unavailableNames.length,
3271
+ skipped: skippedNames.length,
3272
+ allCheckedAndPassing: consumers.length > 0 && checked.length === consumers.length && failingNames.length === 0 && unavailableNames.length === 0,
3273
+ passingNames,
3274
+ failingNames,
3275
+ unavailableNames,
3276
+ skippedNames
3277
+ };
3278
+ }
3279
+
2769
3280
  /**
2770
3281
  * @param {string|null} source
2771
3282
  * @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 +8118,8 @@ if (args[0] === "version" || args[0] === "--version") {
7607
8118
  commandArgs = { doctor: true, inputPath: args[1] && !args[1].startsWith("-") ? args[1] : null };
7608
8119
  } else if (args[0] === "release" && args[1] === "status") {
7609
8120
  commandArgs = { releaseStatus: true, inputPath: null };
8121
+ } else if (args[0] === "release" && args[1] === "roll-consumers") {
8122
+ commandArgs = { releaseRollConsumers: true, releaseRollVersion: args[2], inputPath: null };
7610
8123
  } else if (args[0] === "new" || args[0] === "create") {
7611
8124
  commandArgs = args.includes("--list-templates")
7612
8125
  ? { templateList: true, inputPath: null }
@@ -7823,9 +8336,11 @@ if (commandArgs && Object.prototype.hasOwnProperty.call(commandArgs, "inputPath"
7823
8336
  }
7824
8337
  const emitJson = args.includes("--json");
7825
8338
  const strictReleaseStatus = args.includes("--strict");
8339
+ const shouldPushReleaseConsumers = !args.includes("--no-push");
7826
8340
  const shouldVersion = Boolean(commandArgs?.version);
7827
8341
  const shouldDoctor = Boolean(commandArgs?.doctor);
7828
8342
  const shouldReleaseStatus = Boolean(commandArgs?.releaseStatus);
8343
+ const shouldReleaseRollConsumers = Boolean(commandArgs?.releaseRollConsumers);
7829
8344
  const shouldCheck = Boolean(commandArgs?.check);
7830
8345
  const shouldComponentCheck = Boolean(commandArgs?.componentCheck);
7831
8346
  const shouldComponentBehavior = Boolean(commandArgs?.componentBehavior);
@@ -8059,6 +8574,18 @@ try {
8059
8574
  process.exit(payload.ok ? 0 : 1);
8060
8575
  }
8061
8576
 
8577
+ if (shouldReleaseRollConsumers) {
8578
+ const payload = buildReleaseRollConsumersPayload(commandArgs.releaseRollVersion, {
8579
+ push: shouldPushReleaseConsumers
8580
+ });
8581
+ if (emitJson) {
8582
+ console.log(stableStringify(payload));
8583
+ } else {
8584
+ printReleaseRollConsumers(payload);
8585
+ }
8586
+ process.exit(payload.ok ? 0 : 1);
8587
+ }
8588
+
8062
8589
  if (shouldQueryList) {
8063
8590
  const payload = buildQueryListPayload();
8064
8591
  if (emitJson) {