@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.
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 +5 -4
  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,596 @@
1
+ /**
2
+ * Workspace publish — Phase 1 of `celilo publish`.
3
+ *
4
+ * Planner/executor split per v2/PUBLILO_CLI.md Phase 2:
5
+ *
6
+ * planWorkspace(opts) → WorkspaceItem[] // pure, no mutations
7
+ * executeWorkspace(plan, ...) // mutates package.json,
8
+ * // runs bun publish,
9
+ * // restores, verifies
10
+ *
11
+ * The planner figures out which versions each package would publish at
12
+ * (real, alpha-N, promoted-base), which workspace:^ deps need explicit
13
+ * rewriting, whether the package should be skipped (already on npm,
14
+ * source unchanged since prior alpha), and which pre-publish hooks
15
+ * fire. The executor applies the plan top-to-bottom — no decisions are
16
+ * made during execution, just side effects.
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import {
21
+ cpSync,
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ readdirSync,
26
+ rmSync,
27
+ statSync,
28
+ unlinkSync,
29
+ writeFileSync,
30
+ } from 'node:fs';
31
+ import { join, relative } from 'node:path';
32
+ import { ALPHA_TAG, alphaSkipDecision, nextAlphaNumber, stripAlphaSuffix } from './alpha';
33
+ import { REPO_ROOT, isPublished, readPkg } from './helpers';
34
+ import type {
35
+ PackageJson,
36
+ PublishMode,
37
+ PublishResult,
38
+ RewriteOptions,
39
+ WorkspaceItem,
40
+ WorkspaceRewrite,
41
+ WorkspaceVersionMap,
42
+ } from './types';
43
+
44
+ // ─── Planner ───────────────────────────────────────────────────────
45
+
46
+ export interface PlanWorkspaceInput {
47
+ mode: PublishMode;
48
+ packages: readonly string[];
49
+ /**
50
+ * Starting workspace-version map (typically built from each
51
+ * package.json's `version` field). The planner mutates a local copy
52
+ * — in alpha mode it tightens the map to the alpha versions so
53
+ * sibling rewrites resolve correctly. Doesn't mutate the input.
54
+ */
55
+ baseWorkspaceVersions: WorkspaceVersionMap;
56
+ gitHead: string;
57
+ }
58
+
59
+ export interface PlanWorkspaceOutput {
60
+ items: WorkspaceItem[];
61
+ /**
62
+ * The map of name → versionToPublish for non-skipped items. The
63
+ * executor uses this to rewrite workspace:^ deps consistently across
64
+ * sibling packages in the same publish run.
65
+ */
66
+ workspaceVersions: WorkspaceVersionMap;
67
+ }
68
+
69
+ /**
70
+ * Pre-publish hooks that fire for @celilo/e2e (the only package that
71
+ * needs them today). Listed in the WorkspaceItem so dry-run can show
72
+ * "@celilo/e2e (will rebuild netapps, stage caches)" without executing
73
+ * anything.
74
+ */
75
+ function workspaceHooksFor(pkg: string): WorkspaceItem['hooks'] {
76
+ if (pkg !== 'packages/e2e') return [];
77
+ return ['registryServerBundle', 'rebuildE2eNetapps', 'stageE2ePublishCaches'];
78
+ }
79
+
80
+ /**
81
+ * Build the workspace publish plan. Pure given the world-state reads
82
+ * exposed by helpers (npm view, git log) — same world → same plan.
83
+ *
84
+ * In promote mode, only the named package is planned; everything else
85
+ * is filtered out. In alpha mode, the local workspaceVersions map gets
86
+ * tightened to alpha versions as we iterate, so later packages in
87
+ * dependency order can pin to the alpha version of earlier siblings.
88
+ */
89
+ export function planWorkspace(opts: PlanWorkspaceInput): PlanWorkspaceOutput {
90
+ const { mode, packages, baseWorkspaceVersions } = opts;
91
+ const workspaceVersions = new Map(baseWorkspaceVersions);
92
+
93
+ const packagesToPlan: readonly string[] =
94
+ mode.kind === 'promote'
95
+ ? packages.filter((p) => readPkg(p).name === mode.target.name)
96
+ : packages;
97
+
98
+ if (mode.kind === 'promote' && packagesToPlan.length === 0) {
99
+ throw new Error(
100
+ `--promote target "${mode.target.name}" is not a known workspace package. Known packages: ${[
101
+ ...workspaceVersions.keys(),
102
+ ].join(', ')}`,
103
+ );
104
+ }
105
+
106
+ const items: WorkspaceItem[] = [];
107
+ for (const pkg of packagesToPlan) {
108
+ const { name, version } = readPkg(pkg);
109
+ if (!name || !version) {
110
+ throw new Error(`${pkg}/package.json missing name or version`);
111
+ }
112
+
113
+ let versionToPublish: string;
114
+ let skipReason: string | undefined;
115
+ let tag: WorkspaceItem['tag'];
116
+
117
+ if (mode.kind === 'promote') {
118
+ versionToPublish = stripAlphaSuffix(mode.target.version);
119
+ if (versionToPublish === mode.target.version) {
120
+ throw new Error(
121
+ `--promote target "${mode.target.name}@${mode.target.version}" is not an alpha (no -alpha.N suffix).`,
122
+ );
123
+ }
124
+ } else if (mode.kind === 'alpha') {
125
+ const n = nextAlphaNumber(name, version);
126
+ const decision = alphaSkipDecision(pkg, name, version, n);
127
+ if (decision.skip) {
128
+ // Skipped (unchanged since its last alpha): it will NOT be
129
+ // republished, so consumers must pin to the alpha that already
130
+ // exists on npm (alpha.{n-1}), not the computed-next alpha.{n}
131
+ // which would never be published. (n >= 1 whenever skip is true —
132
+ // nextN === 0 can't skip.) Pinning to alpha.{n} made dependents
133
+ // unpublishable: "@celilo/cli-display@0.1.9-alpha.1 is not on npm".
134
+ skipReason = decision.reason;
135
+ versionToPublish = `${version}-alpha.${n - 1}`;
136
+ } else {
137
+ versionToPublish = `${version}-alpha.${n}`;
138
+ }
139
+ tag = ALPHA_TAG;
140
+ workspaceVersions.set(name, versionToPublish);
141
+ } else {
142
+ versionToPublish = version;
143
+ if (isPublished(name, versionToPublish)) {
144
+ skipReason = 'already published';
145
+ }
146
+ }
147
+
148
+ items.push({
149
+ pkg,
150
+ name,
151
+ baseVersion: version,
152
+ versionToPublish,
153
+ tag,
154
+ rewriteOptions: {
155
+ targetVersion: versionToPublish !== version ? versionToPublish : undefined,
156
+ exactPins: mode.kind === 'alpha',
157
+ gitHead: mode.kind === 'alpha' ? opts.gitHead : undefined,
158
+ },
159
+ hooks: workspaceHooksFor(pkg),
160
+ skipReason,
161
+ });
162
+ }
163
+
164
+ return { items, workspaceVersions };
165
+ }
166
+
167
+ // ─── Write-side helpers (used by executor) ─────────────────────────
168
+
169
+ /**
170
+ * Bun's publish-time rewrite of `workspace:^` is unreliable — empirically,
171
+ * it's been baking in `^0.1.0` regardless of the actual current version
172
+ * of the dep. We rewrite explicitly here:
173
+ * workspace:^ → ^<current-workspace-version> (or exact, in alpha mode)
174
+ * workspace:~ → ~<current-workspace-version> (or exact, in alpha mode)
175
+ * workspace:* → * (passes through; means "any version")
176
+ * workspace:X.Y.Z → X.Y.Z (explicit; passes through)
177
+ *
178
+ * Returns the original package.json content so the caller can restore
179
+ * after `bun publish` runs. We don't want to commit the rewritten form
180
+ * — `workspace:^` in source is more readable for dev.
181
+ */
182
+ export function rewriteWorkspaceDeps(
183
+ pkg: string,
184
+ versions: WorkspaceVersionMap,
185
+ opts: RewriteOptions = {},
186
+ /**
187
+ * Optional override for the repo root. Production code uses the
188
+ * module-resolved REPO_ROOT (the real monorepo); tests can hand in
189
+ * a synthetic workspace path so the rewrite hits temp-dir files.
190
+ */
191
+ repoRoot: string = REPO_ROOT,
192
+ ): { original: string; rewrites: WorkspaceRewrite[] } {
193
+ const path = join(repoRoot, pkg, 'package.json');
194
+ const original = readFileSync(path, 'utf-8');
195
+ const parsed = JSON.parse(original) as PackageJson & { gitHead?: string };
196
+ const rewrites: WorkspaceRewrite[] = [];
197
+
198
+ let mutated = false;
199
+ if (opts.targetVersion !== undefined && opts.targetVersion !== parsed.version) {
200
+ parsed.version = opts.targetVersion;
201
+ mutated = true;
202
+ }
203
+ if (opts.gitHead !== undefined && opts.gitHead !== parsed.gitHead) {
204
+ parsed.gitHead = opts.gitHead;
205
+ mutated = true;
206
+ }
207
+
208
+ const buckets: Array<keyof PackageJson> = [
209
+ 'dependencies',
210
+ 'devDependencies',
211
+ 'peerDependencies',
212
+ 'optionalDependencies',
213
+ ];
214
+ for (const bucket of buckets) {
215
+ const deps = parsed[bucket] as Record<string, string> | undefined;
216
+ if (!deps) continue;
217
+ for (const [name, oldSpec] of Object.entries(deps)) {
218
+ if (!oldSpec.startsWith('workspace:')) continue;
219
+ const wsRange = oldSpec.slice('workspace:'.length);
220
+ const targetVersion = versions.get(name);
221
+ if (!targetVersion) {
222
+ continue;
223
+ }
224
+ let newSpec: string;
225
+ if (wsRange === '*' || wsRange === '') {
226
+ newSpec = '*';
227
+ } else if (opts.exactPins) {
228
+ newSpec = targetVersion;
229
+ } else if (wsRange === '^') {
230
+ newSpec = `^${targetVersion}`;
231
+ } else if (wsRange === '~') {
232
+ newSpec = `~${targetVersion}`;
233
+ } else {
234
+ newSpec = wsRange;
235
+ }
236
+ deps[name] = newSpec;
237
+ rewrites.push({ depName: name, bucket: String(bucket), oldSpec, newSpec });
238
+ }
239
+ }
240
+ if (rewrites.length > 0 || mutated) {
241
+ const trailingNl = original.endsWith('\n') ? '\n' : '';
242
+ writeFileSync(path, JSON.stringify(parsed, null, 2) + trailingNl);
243
+ }
244
+ return { original, rewrites };
245
+ }
246
+
247
+ export function restorePackageJson(
248
+ pkg: string,
249
+ original: string,
250
+ repoRoot: string = REPO_ROOT,
251
+ ): void {
252
+ writeFileSync(join(repoRoot, pkg, 'package.json'), original);
253
+ }
254
+
255
+ /**
256
+ * After `bun publish` succeeds, ask npm what dep versions the freshly-
257
+ * published package.json contains. If any rewrite we made didn't make
258
+ * it through to the published artifact, fail loudly — don't silently
259
+ * leave a broken pin on npm.
260
+ */
261
+ export async function verifyPublishedDeps(
262
+ name: string,
263
+ version: string,
264
+ rewrites: WorkspaceRewrite[],
265
+ /**
266
+ * Optional override pointing `npm view` at a different registry.
267
+ * Production omits this (uses the operator's configured npm/bun
268
+ * registry); tests pass their verdaccio URL.
269
+ */
270
+ registryUrl?: string,
271
+ ): Promise<void> {
272
+ // npm's registry replication is eventually-consistent. A package
273
+ // published 1s ago can still return empty for `npm view ... dep.X`
274
+ // even though the publish succeeded. Retry with backoff before
275
+ // declaring failure — only fail if the pin is genuinely WRONG, not
276
+ // just temporarily missing.
277
+ for (const r of rewrites) {
278
+ let actualPin = '';
279
+ let attempt = 0;
280
+ const maxAttempts = 6;
281
+ const delays = [500, 1000, 2000, 3000, 5000, 8000];
282
+ const baseArgs = ['view', `${name}@${version}`, `${r.bucket}.${r.depName}`];
283
+ const args = registryUrl ? [...baseArgs, '--registry', registryUrl] : baseArgs;
284
+ while (attempt < maxAttempts) {
285
+ const result = spawnSync('npm', args, {
286
+ stdio: ['ignore', 'pipe', 'pipe'],
287
+ encoding: 'utf-8',
288
+ });
289
+ actualPin = (result.stdout ?? '').trim();
290
+ if (actualPin) break;
291
+ attempt++;
292
+ if (attempt < maxAttempts) {
293
+ const delay = delays[attempt - 1];
294
+ process.stdout.write(
295
+ ` (verify retry ${attempt} for ${r.depName} — npm view returned empty, waiting ${delay}ms…)\n`,
296
+ );
297
+ await new Promise((r2) => setTimeout(r2, delay));
298
+ }
299
+ }
300
+ if (!actualPin) {
301
+ console.warn(
302
+ `⚠ verify could not read ${name}@${version} ${r.bucket}.${r.depName} from npm after ${maxAttempts} retries. Skipping verify for this dep.`,
303
+ );
304
+ continue;
305
+ }
306
+ if (actualPin !== r.newSpec) {
307
+ console.error(
308
+ `✗ ${name}@${version} published with ${r.depName} pinned at "${actualPin}", expected "${r.newSpec}"`,
309
+ );
310
+ console.error(
311
+ ' The pre-publish workspace:^ rewrite either failed silently or\n' +
312
+ ' bun stripped our edit. Inspect the published artifact and the\n' +
313
+ ' source package.json. The package is on npm with the wrong pin —\n' +
314
+ ' consumers will get a stale dep until you republish.',
315
+ );
316
+ process.exit(1);
317
+ }
318
+ }
319
+ }
320
+
321
+ // ─── Per-package pre-publish hooks ─────────────────────────────────
322
+
323
+ /**
324
+ * Modules excluded from the @celilo/e2e netapps shipment:
325
+ * - celilo-registry: bundles the bun-compiled registry server
326
+ * binaries (~76 MB packed). Not used by typical consumer e2e
327
+ * tests; including it would bloat the npm tarball past the SSL
328
+ * transport's reliable window.
329
+ * - archive: not a real module dir.
330
+ */
331
+ const E2E_NETAPP_EXCLUDES = new Set(['archive', 'celilo-registry']);
332
+
333
+ /**
334
+ * Pre-publish step for @celilo/e2e: package each (non-excluded)
335
+ * module under `<root>/modules/` into `packages/e2e/netapps/`. Replaces
336
+ * whatever was last left there by a local `cele2e build-infra` so the
337
+ * shipped tarball is always built from the current branch, not the
338
+ * publisher's last local development build.
339
+ */
340
+ export function rebuildE2eNetapps(repoRoot: string): void {
341
+ const modulesDir = join(repoRoot, 'modules');
342
+ const netappsDir = join(repoRoot, 'packages/e2e/netapps');
343
+ const celiloWrapper = join(repoRoot, 'celilo');
344
+
345
+ if (!existsSync(modulesDir)) {
346
+ console.warn(
347
+ `⚠ ${modulesDir} not found — skipping netapp rebuild. The published tarball will reuse whatever's currently in packages/e2e/netapps/.`,
348
+ );
349
+ return;
350
+ }
351
+ if (!existsSync(celiloWrapper)) {
352
+ console.warn(
353
+ `⚠ ${celiloWrapper} not found — skipping netapp rebuild. Same staleness risk as above.`,
354
+ );
355
+ return;
356
+ }
357
+
358
+ console.log('Rebuilding packages/e2e/netapps/ from infra/modules/...');
359
+
360
+ for (const existing of readdirSync(netappsDir).filter((f) => f.endsWith('.netapp'))) {
361
+ try {
362
+ unlinkSync(join(netappsDir, existing));
363
+ } catch {
364
+ // Best effort
365
+ }
366
+ }
367
+
368
+ const moduleDirs = readdirSync(modulesDir).filter((name) => {
369
+ if (E2E_NETAPP_EXCLUDES.has(name)) return false;
370
+ const dir = join(modulesDir, name);
371
+ try {
372
+ return statSync(dir).isDirectory() && existsSync(join(dir, 'manifest.yml'));
373
+ } catch {
374
+ return false;
375
+ }
376
+ });
377
+
378
+ let okCount = 0;
379
+ const failures: Array<{ name: string; stderr: string }> = [];
380
+ for (const name of moduleDirs) {
381
+ const moduleDir = join(modulesDir, name);
382
+ const out = join(netappsDir, `${name}.netapp`);
383
+ const r = spawnSync(celiloWrapper, ['package', moduleDir, '--output', out], {
384
+ cwd: repoRoot,
385
+ stdio: ['ignore', 'pipe', 'pipe'],
386
+ encoding: 'utf-8',
387
+ });
388
+ if (r.status === 0) {
389
+ okCount++;
390
+ } else {
391
+ failures.push({ name, stderr: (r.stderr ?? '').trim() || `exit ${r.status}` });
392
+ }
393
+ }
394
+
395
+ if (failures.length > 0) {
396
+ console.error(`✗ Failed to package ${failures.length} module(s):`);
397
+ for (const f of failures) {
398
+ console.error(` ${f.name}: ${f.stderr}`);
399
+ }
400
+ console.error(
401
+ 'Aborting publish — shipping with missing netapps would silently break consumer e2e tests.',
402
+ );
403
+ process.exit(1);
404
+ }
405
+
406
+ console.log(
407
+ `Refreshed ${okCount} netapp(s) in packages/e2e/netapps/ (excluded: ${[...E2E_NETAPP_EXCLUDES].join(', ')}).\n`,
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Pre-publish step for @celilo/e2e: stage `.celilo-website-cache/` and
413
+ * `.npm-registry-cache/` inside packages/e2e/ so the tarball ships with
414
+ * everything Dockerfile.celilo-website-sim and Dockerfile.npm-registry-sim
415
+ * need to COPY at docker-build time.
416
+ */
417
+ export function stageE2ePublishCaches(repoRoot: string): void {
418
+ const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
419
+ const e2eDir = join(repoRoot, 'packages', 'e2e');
420
+ const websiteCache = join(e2eDir, '.celilo-website-cache');
421
+ const npmCache = join(e2eDir, '.npm-registry-cache');
422
+ const packScript = join(e2eDir, 'scripts', 'pack-celilo-packages.ts');
423
+
424
+ if (!existsSync(websiteSrc)) {
425
+ console.error(`✗ ${websiteSrc} not found — cannot stage .celilo-website-cache.`);
426
+ process.exit(1);
427
+ }
428
+ if (!existsSync(packScript)) {
429
+ console.error(`✗ ${packScript} not found — cannot stage .npm-registry-cache.`);
430
+ process.exit(1);
431
+ }
432
+
433
+ console.log('Staging .celilo-website-cache/ from modules/celilo-website/site/...');
434
+ const installResult = spawnSync('bun', ['install'], { cwd: websiteSrc, stdio: 'pipe' });
435
+ if (installResult.status !== 0) {
436
+ console.error('✗ bun install for celilo-website failed:');
437
+ console.error(installResult.stderr?.toString());
438
+ process.exit(1);
439
+ }
440
+ const buildResult = spawnSync('bun', ['run', 'build'], { cwd: websiteSrc, stdio: 'pipe' });
441
+ if (buildResult.status !== 0) {
442
+ console.error('✗ bun run build for celilo-website failed:');
443
+ console.error(buildResult.stderr?.toString());
444
+ process.exit(1);
445
+ }
446
+ rmSync(websiteCache, { recursive: true, force: true });
447
+ mkdirSync(websiteCache, { recursive: true });
448
+ cpSync(join(websiteSrc, 'dist'), websiteCache, { recursive: true });
449
+ console.log(`✓ Staged .celilo-website-cache/ (from ${relative(repoRoot, websiteSrc)}/dist/)\n`);
450
+
451
+ console.log('Staging .npm-registry-cache/ from @celilo/* workspace tarballs...');
452
+ const packResult = spawnSync('bun', ['run', packScript], { cwd: repoRoot, stdio: 'pipe' });
453
+ if (packResult.status !== 0) {
454
+ console.error('✗ pack-celilo-packages.ts failed:');
455
+ console.error(packResult.stderr?.toString());
456
+ process.exit(1);
457
+ }
458
+ const packed = existsSync(npmCache)
459
+ ? readdirSync(npmCache).filter((f) => f.endsWith('.tgz'))
460
+ : [];
461
+ console.log(`✓ Staged .npm-registry-cache/ with ${packed.length} workspace tarball(s)\n`);
462
+ }
463
+
464
+ // ─── Executor ──────────────────────────────────────────────────────
465
+
466
+ export interface ExecuteWorkspaceInput {
467
+ items: WorkspaceItem[];
468
+ workspaceVersions: WorkspaceVersionMap;
469
+ mode: PublishMode;
470
+ /** From the confirm() helper in index.ts — passed in to avoid coupling. */
471
+ confirm: (question: string) => Promise<boolean>;
472
+ }
473
+
474
+ export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise<PublishResult> {
475
+ const { items, workspaceVersions, mode, confirm } = input;
476
+ const published: PublishResult['published'] = [];
477
+ const skipped: string[] = [];
478
+
479
+ for (const item of items) {
480
+ const { pkg, name, baseVersion, versionToPublish } = item;
481
+
482
+ if (item.skipReason) {
483
+ console.log(`\n→ ${name}@${versionToPublish} skipped (${item.skipReason}).`);
484
+ skipped.push(`${name}@${versionToPublish} (${item.skipReason})`);
485
+ continue;
486
+ }
487
+
488
+ console.log('\n──────────────────────────────────────────────');
489
+ const tag = mode.kind === 'alpha' ? ' [alpha]' : mode.kind === 'promote' ? ' [promote]' : '';
490
+ console.log(` ${name}@${versionToPublish} (${pkg})${tag}`);
491
+ console.log('──────────────────────────────────────────────\n');
492
+
493
+ // Per-package pre-publish hooks. Driven off the WorkspaceItem so the
494
+ // dry-run plan listed them too.
495
+ if (item.hooks.includes('registryServerBundle')) {
496
+ const { ensureRegistryServerBundle } = await import(
497
+ join(REPO_ROOT, 'packages/e2e/src/registry-bundle.ts')
498
+ );
499
+ ensureRegistryServerBundle(join(REPO_ROOT, 'packages/e2e'));
500
+ console.log(
501
+ 'Refreshed packages/e2e/registry-server/ bundle from packages/registry-server.\n',
502
+ );
503
+ }
504
+ if (item.hooks.includes('rebuildE2eNetapps')) {
505
+ rebuildE2eNetapps(REPO_ROOT);
506
+ }
507
+ if (item.hooks.includes('stageE2ePublishCaches')) {
508
+ stageE2ePublishCaches(REPO_ROOT);
509
+ }
510
+
511
+ const { original: pkgJsonOriginal, rewrites: workspaceRewrites } = rewriteWorkspaceDeps(
512
+ pkg,
513
+ workspaceVersions,
514
+ item.rewriteOptions,
515
+ );
516
+ if (workspaceRewrites.length > 0) {
517
+ console.log('Rewrote workspace deps to explicit versions:');
518
+ for (const r of workspaceRewrites) {
519
+ console.log(` ${r.bucket}.${r.depName}: ${r.oldSpec} → ${r.newSpec}`);
520
+ }
521
+ console.log();
522
+ }
523
+
524
+ // Belt-and-suspenders: refuse to publish if any @celilo/* dep
525
+ // (after workspace-rewrite) doesn't exist on npm. We publish in dep
526
+ // order, so this should be a no-op — but it catches operator typos
527
+ // and any external `bun publish` invocation that bypasses the
528
+ // ordered loop.
529
+ const checkResult = spawnSync('bun', [join(REPO_ROOT, 'scripts/check-publishable.ts'), pkg], {
530
+ cwd: REPO_ROOT,
531
+ stdio: 'inherit',
532
+ });
533
+ if (checkResult.status !== 0) {
534
+ restorePackageJson(pkg, pkgJsonOriginal);
535
+ console.error(
536
+ `✗ Refusing to publish ${name}@${versionToPublish} — see check-publishable output above.`,
537
+ );
538
+ process.exit(1);
539
+ }
540
+
541
+ const publishArgs = ['publish', '--access', 'public'];
542
+ if (item.tag) publishArgs.push('--tag', item.tag);
543
+
544
+ let publishStatus: number | null = null;
545
+ let publishError: unknown = null;
546
+ try {
547
+ spawnSync('bun', [...publishArgs, '--dry-run'], {
548
+ cwd: join(REPO_ROOT, pkg),
549
+ stdio: 'inherit',
550
+ });
551
+
552
+ if (!(await confirm(`\nPublish ${name}@${versionToPublish} for real? [y/N] `))) {
553
+ console.log('Skipped.');
554
+ skipped.push(`${name}@${versionToPublish} (manual skip)`);
555
+ continue;
556
+ }
557
+
558
+ const r = spawnSync('bun', publishArgs, { cwd: join(REPO_ROOT, pkg), stdio: 'inherit' });
559
+ publishStatus = r.status;
560
+ } catch (err) {
561
+ publishError = err;
562
+ } finally {
563
+ // ALWAYS restore the source package.json so workspace:^ stays in
564
+ // git. Even on publish failure or operator skip.
565
+ restorePackageJson(pkg, pkgJsonOriginal);
566
+ }
567
+
568
+ if (publishError) throw publishError;
569
+
570
+ if (publishStatus !== 0) {
571
+ // npm view can be transiently stale — recheck once before failing.
572
+ if (isPublished(name, versionToPublish)) {
573
+ console.log(
574
+ `\n→ ${name}@${versionToPublish} reports already-published on recheck (initial isPublished hit a stale npm view). Skipping.`,
575
+ );
576
+ skipped.push(`${name}@${versionToPublish} (already published — detected on retry)`);
577
+ continue;
578
+ }
579
+ console.error(`✗ Publish failed for ${name}@${versionToPublish}`);
580
+ process.exit(publishStatus ?? 1);
581
+ }
582
+
583
+ if (workspaceRewrites.length > 0) {
584
+ await verifyPublishedDeps(name, versionToPublish, workspaceRewrites);
585
+ }
586
+
587
+ // Silence the unused-var warning on baseVersion — we keep the value
588
+ // on the WorkspaceItem for dry-run output ("0.7.13 → 0.7.13-alpha.0").
589
+ void baseVersion;
590
+
591
+ console.log(`✓ Published ${name}@${versionToPublish}`);
592
+ published.push({ name, version: versionToPublish });
593
+ }
594
+
595
+ return { published, skipped };
596
+ }