@celilo/cli 0.3.30 → 0.4.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/drizzle/0005_module_operations.sql +12 -0
- package/drizzle/0006_base_module_aspects.sql +15 -0
- package/drizzle/0007_module_systems.sql +17 -0
- package/drizzle/meta/_journal.json +21 -0
- package/package.json +5 -4
- package/schemas/system_config.json +14 -28
- package/src/ansible/inventory.test.ts +46 -62
- package/src/ansible/inventory.ts +48 -25
- package/src/capabilities/registration.ts +25 -7
- package/src/capabilities/validation.test.ts +30 -0
- package/src/capabilities/validation.ts +8 -0
- package/src/cli/backup-rename.test.ts +95 -0
- package/src/cli/cli.test.ts +17 -23
- package/src/cli/command-registry.ts +199 -0
- package/src/cli/commands/backup-list.ts +1 -1
- package/src/cli/commands/events.ts +96 -0
- package/src/cli/commands/machine-add.ts +103 -59
- package/src/cli/commands/module-import.ts +153 -4
- package/src/cli/commands/module-remove.ts +86 -17
- package/src/cli/commands/module-status.ts +6 -2
- package/src/cli/commands/publish/alpha.test.ts +185 -0
- package/src/cli/commands/publish/alpha.ts +226 -0
- package/src/cli/commands/publish/changesets.test.ts +89 -0
- package/src/cli/commands/publish/changesets.ts +144 -0
- package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
- package/src/cli/commands/publish/consumer-pins.ts +149 -0
- package/src/cli/commands/publish/execute.ts +131 -0
- package/src/cli/commands/publish/global-install.test.ts +154 -0
- package/src/cli/commands/publish/global-install.ts +171 -0
- package/src/cli/commands/publish/helpers.ts +227 -0
- package/src/cli/commands/publish/index.ts +365 -0
- package/src/cli/commands/publish/module-registry.test.ts +40 -0
- package/src/cli/commands/publish/module-registry.ts +64 -0
- package/src/cli/commands/publish/plan.ts +107 -0
- package/src/cli/commands/publish/preflight.ts +238 -0
- package/src/cli/commands/publish/types.ts +264 -0
- package/src/cli/commands/publish/workspace.test.ts +323 -0
- package/src/cli/commands/publish/workspace.ts +596 -0
- package/src/cli/commands/restore.ts +126 -0
- package/src/cli/commands/storage-add-local.ts +1 -1
- package/src/cli/commands/storage-add-s3.ts +1 -1
- package/src/cli/commands/subscribers-add.ts +68 -0
- package/src/cli/commands/subscribers-list.ts +48 -0
- package/src/cli/commands/subscribers-remove.ts +38 -0
- package/src/cli/commands/subscribers-serve.ts +77 -0
- package/src/cli/commands/subscribers-status.ts +33 -0
- package/src/cli/commands/subscribers-test.ts +71 -0
- package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
- package/src/cli/commands/system-apply-config.test.ts +70 -0
- package/src/cli/commands/system-apply-config.ts +130 -0
- package/src/cli/commands/system-audit.ts +2 -1
- package/src/cli/commands/system-init-deprecation.test.ts +90 -0
- package/src/cli/commands/system-init.ts +36 -70
- package/src/cli/commands/system-update.ts +3 -2
- package/src/cli/completion.ts +22 -1
- package/src/cli/index.ts +214 -6
- package/src/cli/interactive-config.test.ts +19 -0
- package/src/cli/restore-command.test.ts +131 -0
- package/src/db/client.ts +42 -0
- package/src/db/schema.test.ts +13 -16
- package/src/db/schema.ts +161 -9
- package/src/hooks/capability-loader-firewall.test.ts +6 -15
- package/src/hooks/capability-loader.test.ts +2 -3
- package/src/hooks/capability-loader.ts +36 -2
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/executor.test.ts +18 -0
- package/src/hooks/executor.ts +21 -2
- package/src/hooks/load-hook-config.test.ts +26 -24
- package/src/hooks/load-hook-config.ts +11 -2
- package/src/hooks/run-named-hook.ts +16 -0
- package/src/hooks/types.ts +9 -1
- package/src/manifest/contracts/v1.ts +70 -0
- package/src/manifest/schema.ts +262 -16
- package/src/manifest/validate-privileged.test.ts +84 -0
- package/src/manifest/validate.test.ts +156 -0
- package/src/manifest/validate.ts +69 -0
- package/src/module/import.ts +12 -0
- package/src/services/aspect-approvals.test.ts +231 -0
- package/src/services/aspect-approvals.ts +120 -0
- package/src/services/aspect-runner.test.ts +493 -0
- package/src/services/aspect-runner.ts +438 -0
- package/src/services/aspect-template-resolver.test.ts +101 -0
- package/src/services/aspect-template-resolver.ts +122 -0
- package/src/services/backup-create.ts +104 -25
- package/src/services/backup-envelope-roundtrip.test.ts +199 -0
- package/src/services/backup-in-flight-refusal.test.ts +163 -0
- package/src/services/backup-manifest.test.ts +115 -0
- package/src/services/backup-manifest.ts +163 -0
- package/src/services/backup-restore.ts +154 -19
- package/src/services/build-bus/delivery-events.ts +92 -0
- package/src/services/build-bus/event-factory.ts +54 -0
- package/src/services/build-bus/fan-out.test.ts +279 -0
- package/src/services/build-bus/fan-out.ts +161 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
- package/src/services/build-bus/hook-dispatch.test.ts +207 -0
- package/src/services/build-bus/hook-dispatch.ts +198 -0
- package/src/services/build-bus/hook-dispatcher.ts +115 -0
- package/src/services/build-bus/index.ts +41 -0
- package/src/services/build-bus/receiver-server.test.ts +179 -0
- package/src/services/build-bus/receiver-server.ts +159 -0
- package/src/services/build-bus/status.test.ts +212 -0
- package/src/services/build-bus/status.ts +213 -0
- package/src/services/build-bus/subscriber-store.ts +113 -0
- package/src/services/celilo-events.test.ts +70 -0
- package/src/services/celilo-events.ts +92 -0
- package/src/services/celilo-mgmt-hooks.test.ts +296 -0
- package/src/services/config-interview.ts +13 -95
- package/src/services/cross-module-data-manager.ts +2 -31
- package/src/services/cross-module-read.test.ts +250 -0
- package/src/services/cross-module-read.ts +232 -0
- package/src/services/deploy-validation.ts +7 -0
- package/src/services/deployed-systems.test.ts +235 -0
- package/src/services/deployed-systems.ts +308 -0
- package/src/services/dns-provider-backfill.ts +75 -0
- package/src/services/health-runner.ts +19 -3
- package/src/services/infrastructure-variable-resolver.test.ts +6 -32
- package/src/services/infrastructure-variable-resolver.ts +3 -13
- package/src/services/machine-detector.ts +104 -48
- package/src/services/machine-pool.ts +145 -2
- package/src/services/module-config.ts +78 -120
- package/src/services/module-deploy.ts +113 -40
- package/src/services/module-operations.test.ts +154 -0
- package/src/services/module-operations.ts +154 -0
- package/src/services/module-subscriptions.test.ts +58 -0
- package/src/services/module-subscriptions.ts +24 -1
- package/src/services/module-types-generator.test.ts +3 -3
- package/src/services/module-types-generator.ts +7 -2
- package/src/services/proxmox-reconcile.test.ts +333 -0
- package/src/services/proxmox-reconcile.ts +156 -0
- package/src/services/proxmox-state-recovery.ts +3 -24
- package/src/services/restore-from-file.test.ts +177 -0
- package/src/services/restore-from-file.ts +355 -0
- package/src/services/restore-preflight.test.ts +127 -0
- package/src/services/restore-preflight.ts +118 -0
- package/src/services/storage-providers/s3.ts +10 -2
- package/src/services/system-identity.ts +30 -0
- package/src/services/system-init.test.ts +64 -21
- package/src/services/system-init.ts +28 -26
- package/src/templates/generator.test.ts +7 -16
- package/src/templates/generator.ts +28 -115
- package/src/test-utils/integration.ts +5 -2
- package/src/types/infrastructure.ts +8 -0
- package/src/variables/computed/computed-integration.test.ts +191 -0
- package/src/variables/computed/computed.test.ts +177 -0
- package/src/variables/computed/evaluate.ts +271 -0
- package/src/variables/computed/marker.ts +53 -0
- package/src/variables/computed/parse.ts +262 -0
- package/src/variables/computed/provider-lookup.ts +130 -0
- package/src/variables/context.test.ts +89 -28
- package/src/variables/context.ts +196 -191
- package/src/variables/parser.ts +3 -3
- package/src/variables/resolver.test.ts +61 -0
- package/src/variables/resolver.ts +81 -0
- package/src/variables/types.ts +23 -1
- 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
|
+
}
|