@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,171 @@
1
+ /**
2
+ * bun global install refresh — Phase 3 of `celilo publish`.
3
+ *
4
+ * Force-pins just-published packages into `~/.bun/install/global` so
5
+ * the operator's `cele2e` / `celilo` binaries end up on the version
6
+ * this run just shipped (rather than whatever `@latest` resolves to a
7
+ * moment later). For managed packages NOT published this run, runs
8
+ * `bun update -g <name>` to chase npm-latest (catches drift between
9
+ * publish events).
10
+ *
11
+ * Skipped entirely in --alpha mode unless --track-alpha is passed.
12
+ * When tracking alpha, only force-pins packages we just published —
13
+ * leaves other managed globals alone (they may already be on their
14
+ * own alpha streams).
15
+ */
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { PACKAGES, readGlobalInstalledVersion, readPkg } from './helpers';
21
+ import type { GlobalUpdateItem, PackageJson } from './types';
22
+
23
+ // ─── Planner ───────────────────────────────────────────────────────
24
+
25
+ export interface PlanGlobalUpdateInput {
26
+ /** Versions we just published this run (force-pin targets). */
27
+ justPublished: Array<{ name: string; version: string }>;
28
+ /** True only when `--alpha --track-alpha` was passed. */
29
+ trackAlpha: boolean;
30
+ }
31
+
32
+ /**
33
+ * Inputs the global-update decision actually depends on. Lifted out
34
+ * as a type so `decideGlobalUpdates` is pure and unit-testable: the
35
+ * disk wrapper (`planGlobalUpdate`) reads bun's global package.json
36
+ * and ~/.bun/install/global/node_modules/<name>/package.json then
37
+ * delegates here.
38
+ */
39
+ export interface GlobalUpdateDecisionInputs {
40
+ /** Names currently installed globally (intersected with `ourNames`). */
41
+ installed: string[];
42
+ /** Names this run will publish (already filtered for managed packages). */
43
+ justPublished: Array<{ name: string; version: string }>;
44
+ /** True only when `--alpha --track-alpha` was passed. */
45
+ trackAlpha: boolean;
46
+ /**
47
+ * Resolver from name → currently-installed version. Pure: caller
48
+ * fetches versions once and hands them in; we don't re-read disk
49
+ * per call. `null` means "couldn't read" (treated as unknown).
50
+ */
51
+ installedVersion: (name: string) => string | null;
52
+ }
53
+
54
+ /**
55
+ * Pure decision function: produce the per-package `GlobalUpdateItem`
56
+ * list from the gathered inputs. Encodes the gating semantics of
57
+ * --track-alpha (filter to just-published only) and the
58
+ * force-pin-vs-update-pull policy (force-pin when we know the target,
59
+ * `bun update -g` otherwise).
60
+ */
61
+ export function decideGlobalUpdates(inputs: GlobalUpdateDecisionInputs): GlobalUpdateItem[] {
62
+ const publishedMap = new Map(inputs.justPublished.map((p) => [p.name, p.version] as const));
63
+
64
+ let toUpdate = inputs.installed;
65
+ if (inputs.trackAlpha) {
66
+ const justPublishedNames = new Set(inputs.justPublished.map((p) => p.name));
67
+ toUpdate = toUpdate.filter((n) => justPublishedNames.has(n));
68
+ }
69
+
70
+ const items: GlobalUpdateItem[] = [];
71
+ for (const name of toUpdate) {
72
+ const before = inputs.installedVersion(name);
73
+ const expected = publishedMap.get(name);
74
+ // Force-pin to a known target when we just published it; otherwise
75
+ // resolve to whatever the executor's `bun update -g` lands on.
76
+ items.push({
77
+ name,
78
+ installed: before,
79
+ target: expected ?? before ?? '?',
80
+ forcePin: !!expected,
81
+ });
82
+ }
83
+ return items;
84
+ }
85
+
86
+ export function planGlobalUpdate(input: PlanGlobalUpdateInput): GlobalUpdateItem[] {
87
+ const globalPkgPath = join(process.env.HOME ?? '', '.bun', 'install', 'global', 'package.json');
88
+ if (!existsSync(globalPkgPath)) return [];
89
+
90
+ let globalPkg: PackageJson;
91
+ try {
92
+ globalPkg = JSON.parse(readFileSync(globalPkgPath, 'utf-8'));
93
+ } catch {
94
+ // Malformed global pkg — caller will surface this differently
95
+ // (or skip the phase entirely). Returning [] is consistent with
96
+ // "nothing planned".
97
+ return [];
98
+ }
99
+
100
+ const ourNames = new Set(PACKAGES.map((p) => readPkg(p).name).filter((n): n is string => !!n));
101
+ const installed = Object.keys(globalPkg.dependencies ?? {}).filter((n) => ourNames.has(n));
102
+
103
+ return decideGlobalUpdates({
104
+ installed,
105
+ justPublished: input.justPublished,
106
+ trackAlpha: input.trackAlpha,
107
+ installedVersion: readGlobalInstalledVersion,
108
+ });
109
+ }
110
+
111
+ // ─── Executor ──────────────────────────────────────────────────────
112
+
113
+ export function executeGlobalUpdate(items: GlobalUpdateItem[]): void {
114
+ if (items.length === 0) {
115
+ return;
116
+ }
117
+
118
+ const globalPkgPath = join(process.env.HOME ?? '', '.bun', 'install', 'global', 'package.json');
119
+ if (!existsSync(globalPkgPath)) {
120
+ console.log('\nNo bun global install found — skipping global update pass.');
121
+ return;
122
+ }
123
+
124
+ console.log('\n──────────────────────────────────────────────');
125
+ console.log(' Updating bun global install');
126
+ console.log('──────────────────────────────────────────────');
127
+
128
+ const mismatches: Array<{ name: string; expected: string; actual: string | null }> = [];
129
+
130
+ for (const item of items) {
131
+ const before = readGlobalInstalledVersion(item.name);
132
+ const cmd = item.forcePin
133
+ ? ['add', '-g', `${item.name}@${item.target}`]
134
+ : ['update', '-g', item.name];
135
+
136
+ const r = spawnSync('bun', cmd, { stdio: 'pipe', encoding: 'utf-8' });
137
+ const after = readGlobalInstalledVersion(item.name);
138
+
139
+ const delta =
140
+ before === after ? `unchanged at ${after ?? '?'}` : `${before ?? '?'} → ${after ?? '?'}`;
141
+
142
+ if (r.status !== 0) {
143
+ console.log(`✗ ${item.name}: ${delta}`);
144
+ console.log(` bun ${cmd.join(' ')} failed (exit ${r.status})`);
145
+ if (r.stderr) console.log(` ${r.stderr.trim().split('\n').join('\n ')}`);
146
+ if (item.forcePin && after !== item.target) {
147
+ mismatches.push({ name: item.name, expected: item.target, actual: after });
148
+ }
149
+ continue;
150
+ }
151
+
152
+ if (item.forcePin && after !== item.target) {
153
+ console.log(`⚠ ${item.name}: ${delta} (expected ${item.target})`);
154
+ mismatches.push({ name: item.name, expected: item.target, actual: after });
155
+ } else {
156
+ console.log(`✓ ${item.name}: ${delta}`);
157
+ }
158
+ }
159
+
160
+ if (mismatches.length > 0) {
161
+ console.log('\n✗ Global install ended up on the wrong version for some packages:');
162
+ for (const m of mismatches) {
163
+ console.log(` ${m.name}: have ${m.actual ?? '(missing)'}, expected ${m.expected}`);
164
+ console.log(` fix: bun add -g ${m.name}@${m.expected}`);
165
+ }
166
+ console.log(
167
+ '\n Until this is resolved, your global cele2e/celilo binary is NOT the one this publish just shipped.',
168
+ );
169
+ process.exit(1);
170
+ }
171
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Pure-read utilities shared across the publish planner + executor.
3
+ *
4
+ * Nothing here mutates state — these helpers query npm / git / fs to
5
+ * inform planning decisions. The planner is "logically pure" (same
6
+ * world state → same plan) even though it makes I/O calls, because
7
+ * everything in this file is a read.
8
+ *
9
+ * Write-side helpers (rewriteWorkspaceDeps, restorePackageJson, etc.)
10
+ * live alongside the executors in their respective phase files.
11
+ */
12
+
13
+ import { execSync, spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import type { PackageJson, WorkspaceVersionMap } from './types';
17
+
18
+ // Publish in dependency order: leaves first, then consumers.
19
+ // Exported so tests can construct a synthetic packages list against the
20
+ // same shape; runtime always uses these in this exact order.
21
+ export const PACKAGES = [
22
+ 'packages/cli-display',
23
+ 'packages/capabilities',
24
+ 'packages/event-bus',
25
+ 'apps/celilo',
26
+ 'packages/e2e',
27
+ ] as const;
28
+
29
+ // REPO_ROOT discovery — this file lives at
30
+ // apps/celilo/src/cli/commands/publish/helpers.ts, six dirs deep.
31
+ // Kept as a relative path (matches existing convention in
32
+ // services/module-types-drift.test.ts and friends) rather than
33
+ // `git rev-parse` to avoid a subprocess on every publish startup.
34
+ export const REPO_ROOT = resolve(import.meta.dir, '../../../../../..');
35
+
36
+ export const ENV_FILE = join(REPO_ROOT, '.env');
37
+
38
+ export function readPkg(dir: string): PackageJson {
39
+ return JSON.parse(readFileSync(join(REPO_ROOT, dir, 'package.json'), 'utf-8'));
40
+ }
41
+
42
+ export function isPublished(name: string, version: string): boolean {
43
+ const r = spawnSync('npm', ['view', `${name}@${version}`, 'version'], {
44
+ stdio: ['ignore', 'pipe', 'pipe'],
45
+ });
46
+ return r.status === 0;
47
+ }
48
+
49
+ export function currentGitHead(): string {
50
+ return execSync('git rev-parse HEAD', { encoding: 'utf-8', cwd: REPO_ROOT }).trim();
51
+ }
52
+
53
+ /**
54
+ * Find the most recent commit touching any of the given pathspecs. Used
55
+ * by the stale-version check to compare "last src change" vs "last
56
+ * package.json change."
57
+ */
58
+ export function lastCommitTouching(pathspec: string[]): string | null {
59
+ const r = spawnSync('git', ['log', '-1', '--format=%H', '--', ...pathspec], {
60
+ cwd: REPO_ROOT,
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ encoding: 'utf-8',
63
+ });
64
+ if (r.status !== 0) return null;
65
+ const sha = r.stdout.trim();
66
+ return sha || null;
67
+ }
68
+
69
+ export function isAncestor(maybeAncestor: string, descendant: string): boolean {
70
+ const r = spawnSync('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], {
71
+ cwd: REPO_ROOT,
72
+ stdio: 'ignore',
73
+ });
74
+ return r.status === 0;
75
+ }
76
+
77
+ export function buildWorkspaceVersionMap(): WorkspaceVersionMap {
78
+ const m = new Map<string, string>();
79
+ for (const pkg of PACKAGES) {
80
+ const { name, version } = readPkg(pkg);
81
+ if (name && version) m.set(name, version);
82
+ }
83
+ return m;
84
+ }
85
+
86
+ /**
87
+ * List every publishable module under modules/. Excludes `archive/`
88
+ * (operator-confirmed: not publishable) and any dir lacking a
89
+ * manifest.yml.
90
+ */
91
+ export function listModuleDirs(): string[] {
92
+ const modulesRoot = join(REPO_ROOT, 'modules');
93
+ if (!existsSync(modulesRoot)) return [];
94
+ const out: string[] = [];
95
+ for (const name of readdirSync(modulesRoot)) {
96
+ if (name === 'archive') continue;
97
+ const dir = join(modulesRoot, name);
98
+ let st: ReturnType<typeof statSync>;
99
+ try {
100
+ st = statSync(dir);
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (!st.isDirectory()) continue;
105
+ if (!existsSync(join(dir, 'manifest.yml'))) continue;
106
+ out.push(dir);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Read `EXTERNAL_PROJECT_PATHS` from .env (space-separated absolute paths).
113
+ * Returns [] if the file is absent or the var is unset — bumping just
114
+ * the in-repo modules is still useful on its own.
115
+ */
116
+ export function readExternalProjectPaths(): string[] {
117
+ if (!existsSync(ENV_FILE)) return [];
118
+ const content = readFileSync(ENV_FILE, 'utf-8');
119
+ for (const line of content.split('\n')) {
120
+ const m = line.match(/^\s*EXTERNAL_PROJECT_PATHS\s*=\s*(.+?)\s*$/);
121
+ if (!m) continue;
122
+ const raw = m[1].replace(/^["']|["']$/g, '');
123
+ return raw.split(/\s+/).filter(Boolean);
124
+ }
125
+ return [];
126
+ }
127
+
128
+ /**
129
+ * Recursively find every package.json under a root, skipping
130
+ * node_modules and common build-output dirs (so we don't try to rewrite
131
+ * lock-installed copies — those get refreshed by `bun install`).
132
+ */
133
+ export function findPackageJsons(root: string): string[] {
134
+ const out: string[] = [];
135
+ const SKIP_DIRS = new Set(['node_modules', '.bun', 'dist', '.nx', '.git']);
136
+
137
+ function walk(dir: string): void {
138
+ let entries: string[];
139
+ try {
140
+ entries = readdirSync(dir);
141
+ } catch {
142
+ return;
143
+ }
144
+ for (const name of entries) {
145
+ if (SKIP_DIRS.has(name)) continue;
146
+ const full = join(dir, name);
147
+ let st: ReturnType<typeof statSync>;
148
+ try {
149
+ st = statSync(full);
150
+ } catch {
151
+ continue;
152
+ }
153
+ if (st.isDirectory()) {
154
+ walk(full);
155
+ } else if (name === 'package.json') {
156
+ out.push(full);
157
+ }
158
+ }
159
+ }
160
+ walk(root);
161
+ return out;
162
+ }
163
+
164
+ /** Strip semver operators (^, ~, =, >=, etc.) to get the bare version. */
165
+ export function bareVersion(spec: string): string {
166
+ return spec.replace(/^[\s^~=><]+/, '').trim();
167
+ }
168
+
169
+ /**
170
+ * Re-apply the same operator that was on the old spec. An exact pin
171
+ * (`"0.1.9"`) stays exact (`"0.1.10"`). A caret pin (`"^0.1.9"`) stays
172
+ * caret (`"^0.1.10"`). Don't widen exact pins to caret silently —
173
+ * that masks a deliberate operator choice.
174
+ */
175
+ export function withOperator(oldSpec: string, newVersion: string): string {
176
+ if (oldSpec.startsWith('workspace:')) return oldSpec;
177
+ if (/^[a-z]+:/.test(oldSpec) && !oldSpec.startsWith('npm:')) return oldSpec;
178
+ const m = oldSpec.match(/^(\^|~|>=|<=|=|>|<)?/);
179
+ const op = m?.[1] ?? '';
180
+ return `${op}${newVersion}`;
181
+ }
182
+
183
+ /**
184
+ * Read the installed version of `<name>` from bun's global node_modules.
185
+ * Returns null if the package isn't installed there (or its package.json
186
+ * is unreadable/malformed — we'd rather warn loudly later than crash here).
187
+ */
188
+ export function readGlobalInstalledVersion(name: string): string | null {
189
+ const pkgJsonPath = join(
190
+ process.env.HOME ?? '',
191
+ '.bun',
192
+ 'install',
193
+ 'global',
194
+ 'node_modules',
195
+ ...name.split('/'),
196
+ 'package.json',
197
+ );
198
+ if (!existsSync(pkgJsonPath)) return null;
199
+ try {
200
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as PackageJson;
201
+ return pkg.version ?? null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Look up the npm-latest version of every package we manage. Runs
209
+ * regardless of whether anything was published this session — the
210
+ * goal is to drag consumers up to current head, not just to apply
211
+ * the deltas from this run.
212
+ */
213
+ export function fetchLatestVersions(): Map<string, string> {
214
+ const out = new Map<string, string>();
215
+ for (const pkg of PACKAGES) {
216
+ const { name } = readPkg(pkg);
217
+ if (!name) continue;
218
+ const r = spawnSync('npm', ['view', name, 'version'], {
219
+ stdio: ['ignore', 'pipe', 'ignore'],
220
+ encoding: 'utf-8',
221
+ });
222
+ if (r.status !== 0) continue;
223
+ const version = r.stdout.trim();
224
+ if (version) out.set(name, version);
225
+ }
226
+ return out;
227
+ }