@celilo/cli 0.3.30 → 0.4.0-alpha.1

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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +6 -5
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Alpha-helper tests — v2/PUBLILO_CLI.md Phase 3 payoff.
3
+ *
4
+ * The pure functions in alpha.ts (parsePackageSpec, stripAlphaSuffix,
5
+ * isAlphaVersion, pickNextAlphaN, decideAlphaSkip) need no I/O to
6
+ * exercise. The impure wrappers (nextAlphaNumber, alphaSkipDecision)
7
+ * just glue npm view + git log onto the pure inputs, so testing the
8
+ * pure layer covers the real logic.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import {
13
+ decideAlphaSkip,
14
+ isAlphaVersion,
15
+ parsePackageSpec,
16
+ pickNextAlphaN,
17
+ stripAlphaSuffix,
18
+ } from './alpha';
19
+
20
+ describe('parsePackageSpec', () => {
21
+ test('parses scoped name@version', () => {
22
+ expect(parsePackageSpec('@celilo/e2e@0.7.14-alpha.3')).toEqual({
23
+ name: '@celilo/e2e',
24
+ version: '0.7.14-alpha.3',
25
+ });
26
+ });
27
+
28
+ test('parses unscoped name@version', () => {
29
+ expect(parsePackageSpec('lodash@4.17.21')).toEqual({
30
+ name: 'lodash',
31
+ version: '4.17.21',
32
+ });
33
+ });
34
+
35
+ test('throws when no @ separator beyond an initial scope @', () => {
36
+ expect(() => parsePackageSpec('@celilo/e2e')).toThrow('Invalid package spec');
37
+ expect(() => parsePackageSpec('lodash')).toThrow('Invalid package spec');
38
+ });
39
+
40
+ test('uses the LAST @ as the separator (scoped names contain another)', () => {
41
+ // @scope/name@1.0.0-pre@special — last @ before version wins. (Not a
42
+ // realistic form, but documents the contract.)
43
+ expect(parsePackageSpec('@a/b@1.0.0-pre@x')).toEqual({
44
+ name: '@a/b@1.0.0-pre',
45
+ version: 'x',
46
+ });
47
+ });
48
+ });
49
+
50
+ describe('stripAlphaSuffix', () => {
51
+ test('removes -alpha.N suffix', () => {
52
+ expect(stripAlphaSuffix('0.7.14-alpha.3')).toBe('0.7.14');
53
+ expect(stripAlphaSuffix('1.0.0-alpha.0')).toBe('1.0.0');
54
+ expect(stripAlphaSuffix('10.20.30-alpha.999')).toBe('10.20.30');
55
+ });
56
+
57
+ test('leaves real versions alone', () => {
58
+ expect(stripAlphaSuffix('0.7.14')).toBe('0.7.14');
59
+ expect(stripAlphaSuffix('1.0.0')).toBe('1.0.0');
60
+ });
61
+
62
+ test('leaves other pre-releases alone (only -alpha.N matches)', () => {
63
+ expect(stripAlphaSuffix('1.0.0-beta.0')).toBe('1.0.0-beta.0');
64
+ expect(stripAlphaSuffix('1.0.0-rc.1')).toBe('1.0.0-rc.1');
65
+ expect(stripAlphaSuffix('1.0.0-alpha')).toBe('1.0.0-alpha'); // no .N
66
+ });
67
+ });
68
+
69
+ describe('isAlphaVersion', () => {
70
+ test('identifies -alpha.N', () => {
71
+ expect(isAlphaVersion('0.7.14-alpha.0')).toBe(true);
72
+ expect(isAlphaVersion('0.7.14-alpha.3')).toBe(true);
73
+ expect(isAlphaVersion('1.0.0-alpha.999')).toBe(true);
74
+ });
75
+
76
+ test('rejects other forms', () => {
77
+ expect(isAlphaVersion('0.7.14')).toBe(false);
78
+ expect(isAlphaVersion('0.7.14-beta.0')).toBe(false);
79
+ expect(isAlphaVersion('0.7.14-alpha')).toBe(false); // no .N
80
+ });
81
+ });
82
+
83
+ describe('pickNextAlphaN', () => {
84
+ test('returns 0 when no alphas exist for the target core', () => {
85
+ expect(pickNextAlphaN([], '0.7.14')).toBe(0);
86
+ expect(pickNextAlphaN(['0.7.13', '0.7.14', '0.7.15'], '0.7.14')).toBe(0);
87
+ });
88
+
89
+ test('returns max+1 when alphas exist', () => {
90
+ expect(pickNextAlphaN(['0.7.14-alpha.0'], '0.7.14')).toBe(1);
91
+ expect(pickNextAlphaN(['0.7.14-alpha.0', '0.7.14-alpha.1'], '0.7.14')).toBe(2);
92
+ expect(pickNextAlphaN(['0.7.14-alpha.0', '0.7.14-alpha.1', '0.7.14-alpha.5'], '0.7.14')).toBe(
93
+ 6,
94
+ );
95
+ });
96
+
97
+ test('ignores alphas of other cores', () => {
98
+ expect(pickNextAlphaN(['0.7.13-alpha.99', '0.7.14-alpha.0', '0.7.15-alpha.10'], '0.7.14')).toBe(
99
+ 1,
100
+ );
101
+ });
102
+
103
+ test("ignores versions that aren't -alpha.N", () => {
104
+ expect(
105
+ pickNextAlphaN(['0.7.14', '0.7.14-beta.0', '0.7.14-rc.5', '0.7.14-alpha.2'], '0.7.14'),
106
+ ).toBe(3);
107
+ });
108
+
109
+ test('handles gaps (uses max, not min-gap)', () => {
110
+ // Operator manually published 0.7.14-alpha.3 and 0.7.14-alpha.7 —
111
+ // next is 8, NOT 4. Avoid reusing numbers, ever.
112
+ expect(pickNextAlphaN(['0.7.14-alpha.3', '0.7.14-alpha.7'], '0.7.14')).toBe(8);
113
+ });
114
+
115
+ test('regex-escapes dots in the semver core', () => {
116
+ // If we didn't escape, "0.7.14" would match "0X7X14" too. Verify
117
+ // the escape works by feeding noise that would match a non-escaped
118
+ // pattern.
119
+ expect(pickNextAlphaN(['0X7X14-alpha.0'], '0.7.14')).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe('decideAlphaSkip', () => {
124
+ test('publishes the first alpha (N=0)', () => {
125
+ expect(
126
+ decideAlphaSkip({
127
+ nextN: 0,
128
+ priorGitHead: '',
129
+ priorHeadReachable: false,
130
+ sourceChangedSincePrior: false,
131
+ priorVersion: '',
132
+ }),
133
+ ).toEqual({ skip: false });
134
+ });
135
+
136
+ test('publishes when prior alpha has no gitHead', () => {
137
+ const decision = decideAlphaSkip({
138
+ nextN: 3,
139
+ priorGitHead: '',
140
+ priorHeadReachable: false,
141
+ sourceChangedSincePrior: false,
142
+ priorVersion: '0.7.14-alpha.2',
143
+ });
144
+ expect(decision.skip).toBe(false);
145
+ expect(decision.reason).toContain('no gitHead on 0.7.14-alpha.2');
146
+ });
147
+
148
+ test('publishes when prior gitHead is unreachable', () => {
149
+ const decision = decideAlphaSkip({
150
+ nextN: 3,
151
+ priorGitHead: 'deadbeef1234',
152
+ priorHeadReachable: false,
153
+ sourceChangedSincePrior: false,
154
+ priorVersion: '0.7.14-alpha.2',
155
+ });
156
+ expect(decision.skip).toBe(false);
157
+ expect(decision.reason).toContain('deadbeef unreachable');
158
+ });
159
+
160
+ test('publishes when source changed since prior alpha', () => {
161
+ const decision = decideAlphaSkip({
162
+ nextN: 3,
163
+ priorGitHead: 'abcdef1234567890',
164
+ priorHeadReachable: true,
165
+ sourceChangedSincePrior: true,
166
+ priorVersion: '0.7.14-alpha.2',
167
+ });
168
+ expect(decision).toEqual({ skip: false });
169
+ });
170
+
171
+ test('skips when prior alpha exists and source is unchanged', () => {
172
+ const decision = decideAlphaSkip({
173
+ nextN: 3,
174
+ priorGitHead: 'abcdef1234567890',
175
+ priorHeadReachable: true,
176
+ sourceChangedSincePrior: false,
177
+ priorVersion: '0.7.14-alpha.2',
178
+ });
179
+ expect(decision.skip).toBe(true);
180
+ if (decision.skip) {
181
+ expect(decision.reason).toContain('no source changes since 0.7.14-alpha.2');
182
+ expect(decision.reason).toContain('gitHead abcdef12');
183
+ }
184
+ });
185
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Alpha-tagging logic — `--alpha` publish + `--promote` graduation.
3
+ *
4
+ * The semantics live in `v2/FIRST_PUBLILO_ALPHA_TAGGING.md`; this
5
+ * module is the implementation surface those flags drive. Per
6
+ * v2/PUBLILO_CLI.md Phase 3, the alpha-specific helpers live here
7
+ * (not in helpers.ts) so it's clear where to look when extending the
8
+ * model — e.g. to add `--beta` once `v2/BUILD_BUS.md`'s validation
9
+ * signal lands.
10
+ *
11
+ * Two-layer design for testability:
12
+ *
13
+ * pure: pickNextAlphaN, decideAlphaSkip — operate on inputs the
14
+ * caller has already fetched. Unit-tested directly without
15
+ * spawning npm or git.
16
+ * impure: nextAlphaNumber, alphaSkipDecision — thin wrappers that
17
+ * fetch from npm/git and delegate to the pure helpers. The
18
+ * planner uses these; tests mock them at the import
19
+ * boundary (the workspace.test.ts pattern).
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { REPO_ROOT } from './helpers';
24
+
25
+ /** npm dist-tag used by `--alpha` publishes. */
26
+ export const ALPHA_TAG = 'alpha';
27
+
28
+ // ─── Pure helpers ──────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Parse `<name>@<version>` — handles scoped names where the package
32
+ * name itself starts with `@` (e.g. `@celilo/e2e@0.7.14-alpha.3`).
33
+ * Pure — used by both --promote parsing and any test fixture that
34
+ * needs to round-trip a spec string.
35
+ */
36
+ export function parsePackageSpec(spec: string): { name: string; version: string } {
37
+ const lastAt = spec.lastIndexOf('@');
38
+ if (lastAt <= 0) {
39
+ throw new Error(`Invalid package spec "${spec}". Expected "<name>@<version>".`);
40
+ }
41
+ return {
42
+ name: spec.slice(0, lastAt),
43
+ version: spec.slice(lastAt + 1),
44
+ };
45
+ }
46
+
47
+ export function stripAlphaSuffix(version: string): string {
48
+ return version.replace(/-alpha\.\d+$/, '');
49
+ }
50
+
51
+ export function isAlphaVersion(version: string): boolean {
52
+ return /-alpha\.\d+$/.test(version);
53
+ }
54
+
55
+ /**
56
+ * Pure inner of `nextAlphaNumber`. Given the full list of versions
57
+ * for a package (whatever `npm view <name> versions --json` returned)
58
+ * and a target semver core, returns the next alpha N to ship.
59
+ *
60
+ * - No existing `X.Y.Z-alpha.*` → 0
61
+ * - Existing `X.Y.Z-alpha.0`, `X.Y.Z-alpha.1`, `X.Y.Z-alpha.3` → 4
62
+ * (gaps in the sequence don't matter; we want max+1, not min-gap)
63
+ * - Versions outside the target X.Y.Z are ignored.
64
+ */
65
+ export function pickNextAlphaN(versions: string[], semverCore: string): number {
66
+ const re = new RegExp(`^${semverCore.replace(/\./g, '\\.')}-alpha\\.(\\d+)$`);
67
+ let max = -1;
68
+ for (const v of versions) {
69
+ const m = v.match(re);
70
+ if (m) {
71
+ const n = Number.parseInt(m[1], 10);
72
+ if (n > max) max = n;
73
+ }
74
+ }
75
+ return max + 1;
76
+ }
77
+
78
+ /**
79
+ * Inputs the alpha-skip decision actually depends on. Lifted out as a
80
+ * type so the pure decideAlphaSkip can be tested without mocking npm
81
+ * or git: callers construct one of these, the function returns the
82
+ * decision.
83
+ */
84
+ export interface AlphaSkipInputs {
85
+ /** Next alpha number we'd ship (0 means no prior alpha exists). */
86
+ nextN: number;
87
+ /**
88
+ * `gitHead` recorded on the prior alpha's package.json. Empty string
89
+ * when npm returned nothing — we stamp gitHead ourselves on every
90
+ * alpha publish, but pre-feature alphas (or hand-published ones) may
91
+ * not have it.
92
+ */
93
+ priorGitHead: string;
94
+ /**
95
+ * Was the prior gitHead reachable in the current git history? When
96
+ * false (history rewritten, shallow clone, etc.), the planner can't
97
+ * tell whether the source moved — defaults to "publish to be safe."
98
+ */
99
+ priorHeadReachable: boolean;
100
+ /**
101
+ * Whether any commits between priorGitHead..HEAD touched this
102
+ * package's source. Only meaningful when priorHeadReachable is true.
103
+ */
104
+ sourceChangedSincePrior: boolean;
105
+ /** Tag of the prior version (e.g. "0.7.14-alpha.3"). Used in skip reason text. */
106
+ priorVersion: string;
107
+ }
108
+
109
+ export type AlphaSkipDecision = { skip: false; reason?: string } | { skip: true; reason: string };
110
+
111
+ /**
112
+ * Pure decision function. Given the inputs the planner has gathered,
113
+ * decide whether this package should skip its alpha publish this run.
114
+ *
115
+ * Conservative bias: any signal we can't read cleanly defaults to
116
+ * publish. Specifically:
117
+ * - N === 0 (no prior alpha) → publish (nothing to compare to).
118
+ * - priorGitHead is empty → publish (can't tell if source changed).
119
+ * - priorHead unreachable → publish (history likely got rewritten).
120
+ * - sourceChanged → publish.
121
+ * - everything else → skip (clean diff against prior alpha).
122
+ */
123
+ export function decideAlphaSkip(inputs: AlphaSkipInputs): AlphaSkipDecision {
124
+ if (inputs.nextN === 0) return { skip: false };
125
+ if (!inputs.priorGitHead) {
126
+ return { skip: false, reason: `no gitHead on ${inputs.priorVersion}; publishing` };
127
+ }
128
+ if (!inputs.priorHeadReachable) {
129
+ return {
130
+ skip: false,
131
+ reason: `prior gitHead ${inputs.priorGitHead.slice(0, 8)} unreachable; publishing`,
132
+ };
133
+ }
134
+ if (inputs.sourceChangedSincePrior) {
135
+ return { skip: false };
136
+ }
137
+ return {
138
+ skip: true,
139
+ reason: `no source changes since ${inputs.priorVersion} (gitHead ${inputs.priorGitHead.slice(0, 8)})`,
140
+ };
141
+ }
142
+
143
+ // ─── Impure wrappers (npm + git I/O) ───────────────────────────────
144
+
145
+ /**
146
+ * Next alpha number for `<semverCore>-alpha.N` on npm. Returns 0 if no
147
+ * alphas exist for this core (or the package isn't on npm yet). Matches
148
+ * the spec: N auto-increments per X.Y.Z, resets when the publisher bumps
149
+ * the core.
150
+ */
151
+ export function nextAlphaNumber(name: string, semverCore: string): number {
152
+ const r = spawnSync('npm', ['view', name, 'versions', '--json'], {
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ encoding: 'utf-8',
155
+ });
156
+ if (r.status !== 0) return 0;
157
+ let versions: string[];
158
+ try {
159
+ const parsed = JSON.parse(r.stdout || '[]');
160
+ versions = Array.isArray(parsed) ? parsed : [String(parsed)];
161
+ } catch {
162
+ return 0;
163
+ }
164
+ return pickNextAlphaN(versions, semverCore);
165
+ }
166
+
167
+ /**
168
+ * Did this package's source change since the last alpha publish?
169
+ *
170
+ * Reads the prior alpha's `gitHead` (stamped onto package.json by the
171
+ * executor during prior publishes) and asks git whether any commits
172
+ * between that head and current HEAD touched this package's source.
173
+ * Delegates the actual decision to `decideAlphaSkip` so the logic can
174
+ * be unit-tested without spawning anything.
175
+ */
176
+ export function alphaSkipDecision(
177
+ pkg: string,
178
+ name: string,
179
+ semverCore: string,
180
+ nextN: number,
181
+ ): AlphaSkipDecision {
182
+ if (nextN === 0)
183
+ return decideAlphaSkip({
184
+ nextN,
185
+ priorGitHead: '',
186
+ priorHeadReachable: false,
187
+ sourceChangedSincePrior: false,
188
+ priorVersion: '',
189
+ });
190
+
191
+ const priorVersion = `${semverCore}-alpha.${nextN - 1}`;
192
+ const r = spawnSync('npm', ['view', `${name}@${priorVersion}`, 'gitHead'], {
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ encoding: 'utf-8',
195
+ });
196
+ // `npm view` couldn't reach the registry / version — bias to publish.
197
+ if (r.status !== 0) return { skip: false };
198
+
199
+ const priorGitHead = r.stdout.trim();
200
+ if (!priorGitHead) {
201
+ return decideAlphaSkip({
202
+ nextN,
203
+ priorGitHead: '',
204
+ priorHeadReachable: false,
205
+ sourceChangedSincePrior: false,
206
+ priorVersion,
207
+ });
208
+ }
209
+
210
+ const log = spawnSync(
211
+ 'git',
212
+ ['log', '--format=%H', `${priorGitHead}..HEAD`, '--', pkg, `:(exclude)${pkg}/node_modules`],
213
+ { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' },
214
+ );
215
+
216
+ const priorHeadReachable = log.status === 0;
217
+ const sourceChangedSincePrior = priorHeadReachable && log.stdout.trim() !== '';
218
+
219
+ return decideAlphaSkip({
220
+ nextN,
221
+ priorGitHead,
222
+ priorHeadReachable,
223
+ sourceChangedSincePrior,
224
+ priorVersion,
225
+ });
226
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Changesets integration tests — covers the pure helpers in
3
+ * changesets.ts. The impure pieces (running `bunx changeset version`,
4
+ * doing git commits) are thin wrappers around well-tested external
5
+ * tools; we don't unit-test those.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { filterChangesetEntries, parseGitStatusFiles } from './changesets';
10
+
11
+ describe('filterChangesetEntries', () => {
12
+ test('returns nothing for an empty dir', () => {
13
+ expect(filterChangesetEntries([])).toEqual([]);
14
+ });
15
+
16
+ test('returns only .md files', () => {
17
+ expect(
18
+ filterChangesetEntries([
19
+ 'fancy-tigers-jump.md',
20
+ 'config.json',
21
+ '.DS_Store',
22
+ 'noisy-cats-walk.md',
23
+ ]),
24
+ ).toEqual(['fancy-tigers-jump.md', 'noisy-cats-walk.md']);
25
+ });
26
+
27
+ test('excludes README.md (the default scaffolding doc)', () => {
28
+ expect(filterChangesetEntries(['README.md', 'happy-birds-sing.md'])).toEqual([
29
+ 'happy-birds-sing.md',
30
+ ]);
31
+ });
32
+
33
+ test('excludes config.json regardless of extension filtering', () => {
34
+ // config.json doesn't end in .md so it's already excluded by the
35
+ // extension filter; this test documents that the extension check
36
+ // alone is sufficient — no special-case needed for config.json.
37
+ expect(filterChangesetEntries(['config.json'])).toEqual([]);
38
+ });
39
+
40
+ test('preserves input order (changesets has no inherent ordering)', () => {
41
+ expect(filterChangesetEntries(['z-z-z.md', 'a-a-a.md', 'm-m-m.md'])).toEqual([
42
+ 'z-z-z.md',
43
+ 'a-a-a.md',
44
+ 'm-m-m.md',
45
+ ]);
46
+ });
47
+ });
48
+
49
+ describe('parseGitStatusFiles', () => {
50
+ test('returns empty for empty input', () => {
51
+ expect(parseGitStatusFiles('')).toEqual([]);
52
+ expect(parseGitStatusFiles('\n\n')).toEqual([]);
53
+ });
54
+
55
+ test('parses modified and untracked entries (XY-space-path format)', () => {
56
+ const output = [
57
+ ' M packages/cli-display/package.json',
58
+ ' M packages/event-bus/package.json',
59
+ 'A packages/cli-display/CHANGELOG.md',
60
+ '?? .changeset/quick-flowers-jump.md',
61
+ ].join('\n');
62
+
63
+ expect(parseGitStatusFiles(output)).toEqual([
64
+ 'packages/cli-display/package.json',
65
+ 'packages/event-bus/package.json',
66
+ 'packages/cli-display/CHANGELOG.md',
67
+ '.changeset/quick-flowers-jump.md',
68
+ ]);
69
+ });
70
+
71
+ test('handles deletions (changeset version removes consumed changesets)', () => {
72
+ const output = [' D .changeset/old-bumpy-cats.md', 'D .changeset/consumed.md'].join('\n');
73
+
74
+ expect(parseGitStatusFiles(output)).toEqual([
75
+ '.changeset/old-bumpy-cats.md',
76
+ '.changeset/consumed.md',
77
+ ]);
78
+ });
79
+
80
+ test('handles renames (uses destination path)', () => {
81
+ const output = 'R old/path.md -> new/path.md';
82
+ expect(parseGitStatusFiles(output)).toEqual(['new/path.md']);
83
+ });
84
+
85
+ test('ignores blank lines', () => {
86
+ const output = [' M foo.txt', '', ' ', ' M bar.txt'].join('\n');
87
+ expect(parseGitStatusFiles(output)).toEqual(['foo.txt', 'bar.txt']);
88
+ });
89
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @changesets/cli integration — v2/PUBLILO_CLI.md Phase 4.
3
+ *
4
+ * Changesets is the "what versions does this publish produce" piece
5
+ * of the workflow. During dev, the operator runs `bun changeset` to
6
+ * record a pending bump as a markdown file under `.changeset/`. At
7
+ * publish time, `celilo publish` finds those pending changesets and
8
+ * runs `bunx changeset version` to apply the bumps (writes new
9
+ * versions to each affected package.json, generates / updates
10
+ * CHANGELOG.md, deletes the consumed changesets), then commits the
11
+ * resulting diff. The downstream workspace publisher reads from
12
+ * package.json as before — it doesn't need to know changesets exist.
13
+ *
14
+ * Falls back to "operator manually bumped package.json" when there
15
+ * are no pending changesets — preserving the muscle memory operators
16
+ * built during the pre-changesets era. The `--skip-changesets` flag
17
+ * is the escape hatch for cases where pending changesets exist but
18
+ * the operator wants to publish what's in source right now.
19
+ *
20
+ * Skipped entirely in alpha mode (alphas are throwaway pre-releases;
21
+ * applying changesets would burn them on alpha publishes) and in
22
+ * promote mode (promote is graduating an existing alpha, not bumping
23
+ * to a new version).
24
+ */
25
+
26
+ import { spawnSync } from 'node:child_process';
27
+ import { existsSync, readdirSync } from 'node:fs';
28
+ import { join, relative } from 'node:path';
29
+ import { REPO_ROOT } from './helpers';
30
+
31
+ /**
32
+ * Pure: given a directory listing of `.changeset/`, return the
33
+ * filenames that represent actual pending changesets. Filters out
34
+ * `README.md` and `config.json` (the changesets default scaffolding)
35
+ * and anything that isn't a markdown file.
36
+ *
37
+ * Exposed for unit testing — `listPendingChangesets` is the disk
38
+ * wrapper.
39
+ */
40
+ export function filterChangesetEntries(filenames: string[]): string[] {
41
+ return filenames.filter((f) => f.endsWith('.md') && f !== 'README.md');
42
+ }
43
+
44
+ /**
45
+ * List pending changesets. Returns absolute paths to the markdown
46
+ * files under `.changeset/` that represent actual unapplied bumps.
47
+ */
48
+ export function listPendingChangesets(): string[] {
49
+ const dir = join(REPO_ROOT, '.changeset');
50
+ if (!existsSync(dir)) return [];
51
+ return filterChangesetEntries(readdirSync(dir)).map((f) => join(dir, f));
52
+ }
53
+
54
+ /**
55
+ * Parse `git status --porcelain` output into a list of file paths.
56
+ * Used to figure out exactly which files `bunx changeset version`
57
+ * touched so we can git-add them specifically (per CLAUDE.md's
58
+ * "prefer adding specific files by name" guidance).
59
+ *
60
+ * Pure — exposed so the test suite can exercise the parser without
61
+ * a real git invocation.
62
+ */
63
+ export function parseGitStatusFiles(porcelainOutput: string): string[] {
64
+ const files: string[] = [];
65
+ for (const line of porcelainOutput.split('\n')) {
66
+ if (!line.trim()) continue;
67
+ // Porcelain format: "XY <path>" where XY is two-char status,
68
+ // followed by a space, followed by the path. Renamed entries
69
+ // use " -> " separator; for our purposes we only care about
70
+ // the destination side, which is what changeset version emits
71
+ // in the new-file case anyway.
72
+ const match = line.match(/^.{2}\s+(.+?)(?:\s+->\s+(.+))?$/);
73
+ if (!match) continue;
74
+ files.push(match[2] ?? match[1]);
75
+ }
76
+ return files;
77
+ }
78
+
79
+ /**
80
+ * Apply pending changesets:
81
+ * 1. Run `bunx changeset version` to bump every affected
82
+ * package.json + write CHANGELOG.md + delete consumed
83
+ * .changeset/*.md files.
84
+ * 2. Stage every file the previous step changed (parsed from
85
+ * `git status --porcelain`).
86
+ * 3. Commit with a message naming the count.
87
+ *
88
+ * Throws on any failure; the caller decides what to do (typically:
89
+ * exit non-zero).
90
+ */
91
+ export function applyPendingChangesets(pendingCount: number): void {
92
+ const versionResult = spawnSync('bunx', ['changeset', 'version'], {
93
+ cwd: REPO_ROOT,
94
+ stdio: 'inherit',
95
+ });
96
+ if (versionResult.status !== 0) {
97
+ throw new Error(`bunx changeset version failed (exit ${versionResult.status})`);
98
+ }
99
+
100
+ // Find every file changeset touched and stage it specifically.
101
+ const statusResult = spawnSync('git', ['status', '--porcelain'], {
102
+ cwd: REPO_ROOT,
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ encoding: 'utf-8',
105
+ });
106
+ if (statusResult.status !== 0) {
107
+ throw new Error(`git status failed after changeset version (exit ${statusResult.status})`);
108
+ }
109
+ const changedFiles = parseGitStatusFiles(statusResult.stdout ?? '');
110
+ if (changedFiles.length === 0) {
111
+ // `bunx changeset version` ran but didn't change anything — possible
112
+ // if the changesets resolved to no-ops. Nothing to commit.
113
+ console.log('changeset version produced no changes — nothing to commit.');
114
+ return;
115
+ }
116
+
117
+ const addResult = spawnSync('git', ['add', '--', ...changedFiles], {
118
+ cwd: REPO_ROOT,
119
+ stdio: 'inherit',
120
+ });
121
+ if (addResult.status !== 0) {
122
+ throw new Error(`git add failed after changeset version (exit ${addResult.status})`);
123
+ }
124
+
125
+ const message = `publish: apply ${pendingCount} pending changeset${pendingCount === 1 ? '' : 's'}`;
126
+ const commitResult = spawnSync('git', ['commit', '-m', message], {
127
+ cwd: REPO_ROOT,
128
+ stdio: 'inherit',
129
+ });
130
+ if (commitResult.status !== 0) {
131
+ throw new Error(`git commit failed after changeset version (exit ${commitResult.status})`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Display the pending changesets to the operator before prompting
137
+ * for approval. Shows relative paths (less noise than absolute).
138
+ */
139
+ export function printPendingChangesets(paths: string[]): void {
140
+ console.log(`\n${paths.length} pending changeset${paths.length === 1 ? '' : 's'} found:`);
141
+ for (const p of paths) {
142
+ console.log(` ${relative(REPO_ROOT, p)}`);
143
+ }
144
+ }