@celilo/cli 0.5.0-alpha.9 → 0.6.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/package.json +2 -2
- package/src/api-clients/proxmox.test.ts +30 -20
- package/src/api-clients/proxmox.ts +34 -23
- package/src/cli/command-registry.ts +19 -0
- package/src/cli/commands/module-update.test.ts +252 -0
- package/src/cli/commands/module-update.ts +571 -0
- package/src/cli/commands/module-upgrade.test.ts +55 -225
- package/src/cli/commands/module-upgrade.ts +205 -500
- package/src/cli/commands/proxmox-vm-template-build.ts +25 -18
- package/src/cli/commands/system-update.ts +3 -3
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +3 -0
- package/src/hooks/capability-loader.ts +7 -3
- package/src/manifest/schema.ts +3 -1
- package/src/services/deploy-posture.test.ts +106 -0
- package/src/services/deploy-posture.ts +87 -0
- package/src/services/module-subscriptions.test.ts +32 -2
- package/src/services/proxmox-state-recovery.ts +26 -7
- package/src/templates/generator.test.ts +22 -1
- package/src/templates/generator.ts +22 -8
|
@@ -1,571 +1,276 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* `celilo module upgrade [name]` — the build-bus CD verb (ISS-0138) + the
|
|
3
|
+
* registry-poll it drives (ISS-0139). v2/BUILD_BUS.md.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* module upgrade <name> one module: registry-latest → update → [backup,
|
|
6
|
+
* posture-gated] → deploy → verify.
|
|
7
|
+
* module upgrade NO name = the CD poll: every INSTALLED module that
|
|
8
|
+
* opted into `auto_upgrade` and has a newer registry
|
|
9
|
+
* version, upgraded the same way. This is what the
|
|
10
|
+
* timer-driven CD job runs.
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* `module update` refreshes the stored definition; `module deploy` applies it;
|
|
13
|
+
* `module upgrade` is the safe compound. Fast/safe posture is derived
|
|
14
|
+
* (deploy-posture.ts): a low-risk revision/patch skips the backup, a minor/major
|
|
15
|
+
* backs up first.
|
|
10
16
|
*/
|
|
11
17
|
|
|
12
|
-
import { cpSync, existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
-
import { unlink } from 'node:fs/promises';
|
|
14
|
-
import { tmpdir } from 'node:os';
|
|
15
|
-
import { join, resolve } from 'node:path';
|
|
16
18
|
import { eq } from 'drizzle-orm';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { getDb } from '../../db/client';
|
|
20
|
-
import { capabilities, modules } from '../../db/schema';
|
|
21
|
-
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
19
|
+
import { type DbClient, getDb } from '../../db/client';
|
|
20
|
+
import { modules } from '../../db/schema';
|
|
22
21
|
import type { ModuleManifest } from '../../manifest/schema';
|
|
23
|
-
import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
|
|
24
22
|
import { RegistryClient } from '../../registry/client';
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
23
|
+
import { createModuleBackup } from '../../services/backup-create';
|
|
24
|
+
import { type UpgradePolicy, resolveDeployPosture } from '../../services/deploy-posture';
|
|
25
|
+
import { runModuleHealthCheck } from '../../services/health-runner';
|
|
26
|
+
import { getModuleConfigValue } from '../../services/module-config';
|
|
27
|
+
import { deployModule } from '../../services/module-deploy';
|
|
28
|
+
import { getArg, getFlag } from '../parser';
|
|
27
29
|
import { log } from '../prompts';
|
|
28
30
|
import type { CommandResult } from '../types';
|
|
31
|
+
import { classifyVersionChange, fetchAndUpdate } from './module-update';
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
| {
|
|
32
|
-
status: 'success';
|
|
33
|
-
moduleId: string;
|
|
34
|
-
/** Version that was on disk before the upgrade (manifest.yml semver). */
|
|
35
|
-
previousVersion: string;
|
|
36
|
-
/** Version that's now installed. Includes +N revision when known
|
|
37
|
-
* (registry-driven upgrades pass the canonical "1.0.0+5" form;
|
|
38
|
-
* path-driven upgrades fall back to the manifest semver). */
|
|
39
|
-
newVersion: string;
|
|
40
|
-
}
|
|
41
|
-
| { status: 'failed'; moduleId: string; error: string }
|
|
42
|
-
// `skipped` means the path expanded from a glob but isn't an
|
|
43
|
-
// upgradable target — either no manifest at all (probably a non-
|
|
44
|
-
// module sibling like `modules/archive/`) or a real module that
|
|
45
|
-
// isn't installed in this celilo. Treated as a soft pass so
|
|
46
|
-
// `celilo module update modules/*` does what users expect.
|
|
47
|
-
| { status: 'skipped'; moduleId: string; reason: string };
|
|
33
|
+
const VALID_POLICIES: readonly UpgradePolicy[] = ['by-semver', 'always-safe', 'always-fast'];
|
|
48
34
|
|
|
49
35
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* lets the registry caller carry the canonical `+N` revision through
|
|
54
|
-
* to both the DB column and the success log.
|
|
36
|
+
* Pick the effective upgrade policy. Pure (Rule 10): operator config override
|
|
37
|
+
* wins over the manifest default; an unknown/absent value falls back to
|
|
38
|
+
* `by-semver`.
|
|
55
39
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
40
|
+
export function pickUpgradePolicy(
|
|
41
|
+
fromConfig: string | undefined,
|
|
42
|
+
fromManifest: string | undefined,
|
|
43
|
+
): UpgradePolicy {
|
|
44
|
+
const candidate = fromConfig ?? fromManifest;
|
|
45
|
+
return VALID_POLICIES.includes(candidate as UpgradePolicy)
|
|
46
|
+
? (candidate as UpgradePolicy)
|
|
47
|
+
: 'by-semver';
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* segments default to 0; non-numeric segments are clamped to 0 so we
|
|
66
|
-
* never throw on weird upstream input.
|
|
51
|
+
* Resolve whether a module opts into auto-upgrade by the poll. Pure (Rule 10):
|
|
52
|
+
* operator config override wins over the manifest default; the default is OFF
|
|
53
|
+
* (opt-in — a production module isn't auto-upgraded unless chosen).
|
|
67
54
|
*/
|
|
68
|
-
function
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return [num(parts[0]), num(parts[1]), num(parts[2]), num(rev)];
|
|
55
|
+
export function pickAutoUpgrade(
|
|
56
|
+
fromConfig: string | boolean | undefined,
|
|
57
|
+
fromManifest: boolean | undefined,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (typeof fromConfig === 'boolean') return fromConfig;
|
|
60
|
+
if (fromConfig === 'true') return true;
|
|
61
|
+
if (fromConfig === 'false') return false;
|
|
62
|
+
return fromManifest ?? false;
|
|
77
63
|
}
|
|
78
64
|
|
|
79
|
-
export
|
|
65
|
+
export interface PollCandidate {
|
|
66
|
+
moduleId: string;
|
|
67
|
+
installed: string;
|
|
68
|
+
/** Latest registry version, or null when the module isn't on the registry. */
|
|
69
|
+
latest: string | null;
|
|
70
|
+
autoUpgrade: boolean;
|
|
71
|
+
}
|
|
80
72
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
* `patch` = bugfix or revision-only (+N) bump. Auto-applied.
|
|
86
|
-
* `up-to-date` = identical version.
|
|
87
|
-
* `ahead` = installed is newer than registry. Skip silently — usually
|
|
88
|
-
* means the operator pushed locally without publishing.
|
|
89
|
-
*
|
|
90
|
-
* Exported for unit tests.
|
|
91
|
-
*/
|
|
92
|
-
export function classifyVersionChange(installed: string, latest: string): VersionChangeKind {
|
|
93
|
-
const [aMaj, aMin, aPat, aRev] = parseModuleVersion(installed);
|
|
94
|
-
const [bMaj, bMin, bPat, bRev] = parseModuleVersion(latest);
|
|
95
|
-
if (bMaj > aMaj) return 'major';
|
|
96
|
-
if (bMaj < aMaj) return 'ahead';
|
|
97
|
-
if (bMin > aMin) return 'minor';
|
|
98
|
-
if (bMin < aMin) return 'ahead';
|
|
99
|
-
if (bPat > aPat) return 'patch';
|
|
100
|
-
if (bPat < aPat) return 'ahead';
|
|
101
|
-
if (bRev > aRev) return 'patch';
|
|
102
|
-
if (bRev < aRev) return 'ahead';
|
|
103
|
-
return 'up-to-date';
|
|
73
|
+
export interface PollTarget {
|
|
74
|
+
moduleId: string;
|
|
75
|
+
from: string;
|
|
76
|
+
to: string;
|
|
104
77
|
}
|
|
105
78
|
|
|
106
79
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
80
|
+
* Pure: which candidates the poll should upgrade — opted into auto_upgrade AND a
|
|
81
|
+
* newer registry version exists. (Rule 10; the I/O — registry queries — happens
|
|
82
|
+
* in the caller.)
|
|
110
83
|
*/
|
|
111
|
-
export
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const tmpPath = join(tmpdir(), `${moduleId}-${version}-${Date.now()}.netapp`);
|
|
119
|
-
try {
|
|
120
|
-
const pkgData = await client.download(moduleId, version);
|
|
121
|
-
await Bun.write(tmpPath, pkgData);
|
|
122
|
-
} catch (err) {
|
|
123
|
-
return {
|
|
124
|
-
status: 'failed',
|
|
125
|
-
moduleId,
|
|
126
|
-
error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
try {
|
|
130
|
-
// Registry packages are pre-verified at publish time; skip the
|
|
131
|
-
// signature check here to match `module import`'s registry path.
|
|
132
|
-
// `quiet: true` suppresses upgradeOne's per-call log lines so the
|
|
133
|
-
// sweep can render its own structured per-module output without
|
|
134
|
-
// duplicates. `displayVersion: version` carries the registry's
|
|
135
|
-
// canonical "X.Y.Z+N" through to both the DB column and the success
|
|
136
|
-
// log line — without it, output would say "v1.0.0 → v1.0.0" because
|
|
137
|
-
// the manifest semver doesn't include the +N revision.
|
|
138
|
-
return await upgradeOne(
|
|
139
|
-
tmpPath,
|
|
140
|
-
db,
|
|
141
|
-
{ ...flags, 'skip-verify': true },
|
|
142
|
-
{ quiet: true, displayVersion: version },
|
|
143
|
-
);
|
|
144
|
-
} finally {
|
|
145
|
-
try {
|
|
146
|
-
await unlink(tmpPath);
|
|
147
|
-
} catch {}
|
|
84
|
+
export function selectPollTargets(candidates: PollCandidate[]): PollTarget[] {
|
|
85
|
+
const targets: PollTarget[] = [];
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
if (!c.autoUpgrade || !c.latest) continue;
|
|
88
|
+
const change = classifyVersionChange(c.installed, c.latest);
|
|
89
|
+
if (change === 'up-to-date' || change === 'ahead') continue;
|
|
90
|
+
targets.push({ moduleId: c.moduleId, from: c.installed, to: c.latest });
|
|
148
91
|
}
|
|
92
|
+
return targets;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve a module's auto_upgrade flag from config (override) + manifest (default). */
|
|
96
|
+
function resolveAutoUpgrade(moduleId: string, manifest: ModuleManifest): boolean {
|
|
97
|
+
const cfg = getModuleConfigValue(moduleId, 'auto_upgrade');
|
|
98
|
+
const fromConfig =
|
|
99
|
+
typeof cfg?.value === 'string' || typeof cfg?.value === 'boolean' ? cfg.value : undefined;
|
|
100
|
+
return pickAutoUpgrade(
|
|
101
|
+
fromConfig,
|
|
102
|
+
(manifest as ModuleManifest & { auto_upgrade?: boolean }).auto_upgrade,
|
|
103
|
+
);
|
|
149
104
|
}
|
|
150
105
|
|
|
151
106
|
/**
|
|
152
|
-
* Upgrade
|
|
107
|
+
* Upgrade ONE module to a known target version: posture → update → backup →
|
|
108
|
+
* deploy → verify. The shared core for both the single-module command and the
|
|
109
|
+
* poll. `mod` is the current DB row; `targetVersion` is the registry latest.
|
|
153
110
|
*/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
111
|
+
async function upgradeOneModule(
|
|
112
|
+
mod: typeof modules.$inferSelect,
|
|
113
|
+
targetVersion: string,
|
|
114
|
+
client: RegistryClient,
|
|
115
|
+
db: DbClient,
|
|
116
|
+
flags: Record<string, string | boolean>,
|
|
117
|
+
): Promise<CommandResult> {
|
|
118
|
+
const moduleId = mod.id;
|
|
119
|
+
const manifest = mod.manifestData as ModuleManifest;
|
|
120
|
+
|
|
121
|
+
// Posture.
|
|
122
|
+
const configPolicy = getModuleConfigValue(moduleId, 'upgrade_policy');
|
|
123
|
+
const modulePolicy = pickUpgradePolicy(
|
|
124
|
+
typeof configPolicy?.value === 'string' ? configPolicy.value : undefined,
|
|
125
|
+
(manifest as ModuleManifest & { upgrade_policy?: string }).upgrade_policy,
|
|
126
|
+
);
|
|
127
|
+
// Per-release deploy_posture override lives in the .netapp release metadata;
|
|
128
|
+
// reading it requires fetching the package first. Deferred — the classifier
|
|
129
|
+
// supports it (deploy-posture.ts) and no app stamps it yet.
|
|
130
|
+
const { posture, reason } = resolveDeployPosture({
|
|
131
|
+
installed: mod.version,
|
|
132
|
+
next: targetVersion,
|
|
133
|
+
releasePosture: null,
|
|
134
|
+
modulePolicy,
|
|
135
|
+
});
|
|
136
|
+
log.info(`Upgrading ${moduleId} ${mod.version} → ${targetVersion} (${posture} — ${reason})`);
|
|
137
|
+
|
|
138
|
+
// Update (refresh stored def).
|
|
139
|
+
const updated = await fetchAndUpdate(client, moduleId, targetVersion, db, flags);
|
|
140
|
+
if (updated.status !== 'success') {
|
|
141
|
+
const why = updated.status === 'failed' ? updated.error : updated.reason;
|
|
142
|
+
return { success: false, error: `Update failed for ${moduleId}: ${why}` };
|
|
168
143
|
}
|
|
169
144
|
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const extractResult = await extractPackage(importPath);
|
|
176
|
-
if (!extractResult.success || !extractResult.tempDir) {
|
|
177
|
-
return {
|
|
178
|
-
status: 'failed',
|
|
179
|
-
moduleId: sourcePath,
|
|
180
|
-
error: extractResult.error || 'Failed to extract package',
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
tempDir = extractResult.tempDir;
|
|
184
|
-
actualPath = tempDir;
|
|
185
|
-
|
|
186
|
-
// Skip signature verification if --skip-verify
|
|
187
|
-
if (flags['skip-verify'] !== true) {
|
|
188
|
-
const { verifyPackageIntegrity } = await import('../../module/packaging/extract');
|
|
189
|
-
const verifyResult = await verifyPackageIntegrity(tempDir);
|
|
190
|
-
if (!verifyResult.success) {
|
|
191
|
-
await cleanupTempDir(tempDir);
|
|
145
|
+
// Safe → back up first (when there's a backup hook).
|
|
146
|
+
if (posture === 'safe') {
|
|
147
|
+
if (manifest.hooks?.on_backup) {
|
|
148
|
+
const backup = await createModuleBackup(moduleId);
|
|
149
|
+
if (!backup.success) {
|
|
192
150
|
return {
|
|
193
|
-
|
|
194
|
-
moduleId:
|
|
195
|
-
error: verifyResult.error || 'Package verification failed',
|
|
151
|
+
success: false,
|
|
152
|
+
error: `Pre-upgrade backup failed for ${moduleId}: ${backup.error}`,
|
|
196
153
|
};
|
|
197
154
|
}
|
|
198
|
-
|
|
199
|
-
|
|
155
|
+
log.success(`Backed up ${moduleId} before deploy`);
|
|
156
|
+
} else {
|
|
157
|
+
log.warn(`${moduleId} has no on_backup hook — proceeding without a pre-upgrade backup`);
|
|
200
158
|
}
|
|
201
159
|
}
|
|
202
160
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// No manifest means the path isn't a module directory at all —
|
|
207
|
-
// a likely outcome of `module update modules/*` matching a
|
|
208
|
-
// non-module sibling. Skip silently rather than fail the batch.
|
|
161
|
+
// Deploy (idempotent).
|
|
162
|
+
const deployed = await deployModule(moduleId, db, {});
|
|
163
|
+
if (!deployed.success) {
|
|
209
164
|
return {
|
|
210
|
-
|
|
211
|
-
moduleId:
|
|
212
|
-
reason: 'not a module directory (no manifest.yml)',
|
|
165
|
+
success: false,
|
|
166
|
+
error: `Deploy failed for ${moduleId} (now at ${targetVersion}): ${deployed.error}`,
|
|
213
167
|
};
|
|
214
168
|
}
|
|
215
169
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const parsed = parseYaml(raw);
|
|
220
|
-
newManifest = ModuleManifestSchema.parse(parsed);
|
|
221
|
-
} catch (err) {
|
|
222
|
-
if (tempDir) await cleanupTempDir(tempDir);
|
|
223
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
-
return { status: 'failed', moduleId: sourcePath, error: `Invalid manifest: ${msg}` };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const moduleId = newManifest.id;
|
|
228
|
-
|
|
229
|
-
const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
230
|
-
if (!module) {
|
|
231
|
-
if (tempDir) await cleanupTempDir(tempDir);
|
|
232
|
-
// Module isn't installed in this celilo. Don't fail the batch —
|
|
233
|
-
// `module update modules/*` should keep going for everything
|
|
234
|
-
// that IS installed. Caller surfaces the skip count so the user
|
|
235
|
-
// sees what was passed over.
|
|
170
|
+
// Verify.
|
|
171
|
+
const health = await runModuleHealthCheck(moduleId, db, {});
|
|
172
|
+
if (health.status === 'unhealthy' || health.status === 'error') {
|
|
236
173
|
return {
|
|
237
|
-
|
|
238
|
-
moduleId
|
|
239
|
-
reason: `not installed (run 'celilo module import ${sourcePath}' to add)`,
|
|
174
|
+
success: false,
|
|
175
|
+
error: `Upgraded ${moduleId} to ${targetVersion} but post-deploy verify failed (health: ${health.status}${health.error ? ` — ${health.error}` : ''}).`,
|
|
240
176
|
};
|
|
241
177
|
}
|
|
178
|
+
const verifyNote = health.status === 'degraded' ? ' (health: degraded)' : '';
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
message: `Upgraded ${moduleId} ${mod.version} → ${targetVersion} (${posture}); verified${verifyNote}.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
242
184
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const newVersion = opts.displayVersion ?? newManifest.version;
|
|
251
|
-
if (!opts.quiet) {
|
|
252
|
-
log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Copy new module files, preserving generated output and state
|
|
256
|
-
const installedPath = module.sourcePath;
|
|
257
|
-
const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
|
|
258
|
-
|
|
259
|
-
const skipDirs = new Set(['.git', 'node_modules', '.next', '.cache']);
|
|
260
|
-
const entries = readdirSync(actualPath);
|
|
261
|
-
for (const entry of entries) {
|
|
262
|
-
if (preserveDirs.has(entry) || skipDirs.has(entry)) continue;
|
|
263
|
-
const src = join(actualPath, entry);
|
|
264
|
-
const dest = join(installedPath, entry);
|
|
265
|
-
cpSync(src, dest, { recursive: true, force: true });
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Clean up temp dir if we extracted a .netapp
|
|
269
|
-
if (tempDir) await cleanupTempDir(tempDir);
|
|
270
|
-
|
|
271
|
-
// Update manifest in database. We persist the display version (with
|
|
272
|
-
// +N when known) so subsequent `module list` / `module update` calls
|
|
273
|
-
// see the same version string the registry reported.
|
|
274
|
-
db.update(modules)
|
|
275
|
-
.set({
|
|
276
|
-
manifestData: newManifest as unknown as Record<string, unknown>,
|
|
277
|
-
version: newVersion,
|
|
278
|
-
name: newManifest.name,
|
|
279
|
-
})
|
|
280
|
-
.where(eq(modules.id, moduleId))
|
|
281
|
-
.run();
|
|
282
|
-
|
|
283
|
-
// Re-register capabilities
|
|
284
|
-
db.delete(capabilities).where(eq(capabilities.moduleId, moduleId)).run();
|
|
285
|
-
|
|
286
|
-
if (newManifest.provides?.capabilities && newManifest.provides.capabilities.length > 0) {
|
|
287
|
-
const regResult = await registerModuleCapabilities(moduleId, newManifest, db.$client);
|
|
288
|
-
if (!regResult.success && !opts.quiet) {
|
|
289
|
-
// Capability re-registration warnings are useful when upgrading
|
|
290
|
-
// from a path (operator iterating on dev module); for the
|
|
291
|
-
// registry sweep, the caller will surface them itself if needed.
|
|
292
|
-
log.warn(` ${moduleId}: capability re-registration warning: ${regResult.error}`);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// (Re-)register event-bus subscriptions from the new manifest —
|
|
297
|
-
// mirrors import.ts (ISS-0091: update used to skip this, so a
|
|
298
|
-
// refreshed module silently lost its reconcile subscriptions).
|
|
299
|
-
// registerModuleSubscriptions is idempotent; best-effort like import:
|
|
300
|
-
// a bus problem shouldn't wedge the upgrade, but must be loud.
|
|
301
|
-
try {
|
|
302
|
-
const { registerModuleSubscriptions } = await import('../../services/module-subscriptions');
|
|
303
|
-
registerModuleSubscriptions(newManifest, installedPath);
|
|
304
|
-
} catch (error) {
|
|
305
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
306
|
-
log.warn(` ${moduleId}: failed to register event-bus subscriptions: ${msg}`);
|
|
307
|
-
log.warn(
|
|
308
|
-
' Module upgraded, but reactive flows on the event bus will not fire until this is fixed.',
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (!opts.quiet) {
|
|
313
|
-
log.success(`Upgraded ${moduleId} (${previousVersion} → ${newVersion})`);
|
|
314
|
-
}
|
|
315
|
-
return { status: 'success', moduleId, previousVersion, newVersion };
|
|
185
|
+
/** Latest registry version for a module, or null when absent. */
|
|
186
|
+
async function latestRegistryVersion(
|
|
187
|
+
client: RegistryClient,
|
|
188
|
+
moduleId: string,
|
|
189
|
+
): Promise<string | null> {
|
|
190
|
+
const entries = await client.getIndex(moduleId);
|
|
191
|
+
return entries.length > 0 ? (client.latestVersion(entries)?.vers ?? null) : null;
|
|
316
192
|
}
|
|
317
193
|
|
|
318
194
|
/**
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
* @returns Command result
|
|
195
|
+
* The CD poll (ISS-0139): upgrade every INSTALLED module that opted into
|
|
196
|
+
* auto_upgrade and has a newer registry version. This is what the timer-driven
|
|
197
|
+
* CD job runs (install-mode gating — ISS-0142 — applies at the scheduling layer).
|
|
323
198
|
*/
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
flags: Record<string, string | boolean
|
|
199
|
+
async function runRegistryPoll(
|
|
200
|
+
db: DbClient,
|
|
201
|
+
flags: Record<string, string | boolean>,
|
|
327
202
|
): Promise<CommandResult> {
|
|
328
|
-
const
|
|
203
|
+
const client = new RegistryClient(getFlag(flags, 'registry', '') || undefined);
|
|
204
|
+
const installed = db.select().from(modules).all();
|
|
329
205
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
206
|
+
const candidates: PollCandidate[] = [];
|
|
207
|
+
const rowById = new Map<string, typeof modules.$inferSelect>();
|
|
208
|
+
for (const mod of installed) {
|
|
209
|
+
rowById.set(mod.id, mod);
|
|
210
|
+
candidates.push({
|
|
211
|
+
moduleId: mod.id,
|
|
212
|
+
installed: mod.version,
|
|
213
|
+
latest: await latestRegistryVersion(client, mod.id),
|
|
214
|
+
autoUpgrade: resolveAutoUpgrade(mod.id, mod.manifestData as ModuleManifest),
|
|
215
|
+
});
|
|
335
216
|
}
|
|
336
217
|
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const result = await upgradeOne(path, db, flags);
|
|
341
|
-
results.push(result);
|
|
218
|
+
const targets = selectPollTargets(candidates);
|
|
219
|
+
if (targets.length === 0) {
|
|
220
|
+
return { success: true, message: 'Registry poll: all auto_upgrade modules are up to date.' };
|
|
342
221
|
}
|
|
343
222
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// up `modules/archive/`) shouldn't even be mentioned — they're not
|
|
356
|
-
// signal. Skips for "module not installed" ARE signal because the
|
|
357
|
-
// user explicitly named the path; surface those.
|
|
358
|
-
const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
|
|
359
|
-
|
|
360
|
-
if (failed.length > 0) {
|
|
361
|
-
const errors = failed.map((r) => ` ${r.moduleId}: ${r.error}`).join('\n');
|
|
362
|
-
const parts: string[] = [];
|
|
363
|
-
if (succeeded.length > 0) {
|
|
364
|
-
parts.push(
|
|
365
|
-
`Upgraded ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
if (meaningfulSkips.length > 0) {
|
|
369
|
-
const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
|
|
370
|
-
parts.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
|
|
371
|
-
}
|
|
372
|
-
parts.push(`Failed ${failed.length}:\n${errors}`);
|
|
373
|
-
return { success: false, error: parts.join('\n\n') };
|
|
223
|
+
log.info(`Registry poll: ${targets.length} module(s) to upgrade.`);
|
|
224
|
+
const upgraded: string[] = [];
|
|
225
|
+
const failed: string[] = [];
|
|
226
|
+
// Serial, in installed order. (Strict provider-before-consumer ordering via
|
|
227
|
+
// topologicalOrder is a refinement; the poll is idempotent + re-runs.)
|
|
228
|
+
for (const t of targets) {
|
|
229
|
+
const mod = rowById.get(t.moduleId);
|
|
230
|
+
if (!mod) continue;
|
|
231
|
+
const result = await upgradeOneModule(mod, t.to, client, db, flags);
|
|
232
|
+
if (result.success) upgraded.push(`${t.moduleId}→${t.to}`);
|
|
233
|
+
else failed.push(`${t.moduleId}: ${result.error}`);
|
|
374
234
|
}
|
|
375
235
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
`
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
if (meaningfulSkips.length > 0) {
|
|
383
|
-
const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
|
|
384
|
-
lines.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
|
|
385
|
-
}
|
|
386
|
-
if (lines.length === 0) {
|
|
387
|
-
// Every arg was a non-module sibling — odd but not a failure.
|
|
388
|
-
lines.push(
|
|
389
|
-
`No modules to update (${results.length} path(s) skipped — none had a manifest.yml)`,
|
|
390
|
-
);
|
|
236
|
+
if (failed.length > 0) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
error: `Registry poll: upgraded ${upgraded.length}, FAILED ${failed.length}:\n ${failed.join('\n ')}`,
|
|
240
|
+
};
|
|
391
241
|
}
|
|
392
|
-
return {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
moduleId: string;
|
|
397
|
-
installedVersion: string;
|
|
398
|
-
targetVersion: string;
|
|
399
|
-
classification: Exclude<VersionChangeKind, 'up-to-date' | 'ahead'>;
|
|
242
|
+
return {
|
|
243
|
+
success: true,
|
|
244
|
+
message: `Registry poll: upgraded ${upgraded.length} — ${upgraded.join(', ')}.`,
|
|
245
|
+
};
|
|
400
246
|
}
|
|
401
247
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
* breaking (major) updates. Modules absent from the registry are
|
|
406
|
-
* surfaced as a skip — typically these are local-only modules the
|
|
407
|
-
* operator imported from a path, never published.
|
|
408
|
-
*/
|
|
409
|
-
async function runRegistrySweep(
|
|
410
|
-
db: ReturnType<typeof getDb>,
|
|
411
|
-
flags: Record<string, string | boolean>,
|
|
248
|
+
export async function handleModuleUpgrade(
|
|
249
|
+
args: string[],
|
|
250
|
+
flags: Record<string, string | boolean> = {},
|
|
412
251
|
): Promise<CommandResult> {
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
return { success: true, message: 'No modules installed.' };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const registryUrl = getFlag(flags, 'registry', '');
|
|
419
|
-
const client = new RegistryClient(registryUrl || undefined);
|
|
420
|
-
|
|
421
|
-
log.info(`Checking ${installed.length} installed module(s) against the registry…`);
|
|
422
|
-
|
|
423
|
-
const plans: UpdatePlan[] = [];
|
|
424
|
-
const upToDate: string[] = [];
|
|
425
|
-
const notInRegistry: string[] = [];
|
|
426
|
-
const errored: Array<{ moduleId: string; error: string }> = [];
|
|
252
|
+
const db = getDb();
|
|
253
|
+
const moduleId = getArg(args, 0);
|
|
427
254
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (entries.length === 0) {
|
|
432
|
-
notInRegistry.push(mod.id);
|
|
433
|
-
continue;
|
|
434
|
-
}
|
|
435
|
-
const latest = client.latestVersion(entries);
|
|
436
|
-
if (!latest) {
|
|
437
|
-
// All versions yanked.
|
|
438
|
-
notInRegistry.push(mod.id);
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
const cmp = classifyVersionChange(mod.version, latest.vers);
|
|
442
|
-
if (cmp === 'up-to-date' || cmp === 'ahead') {
|
|
443
|
-
upToDate.push(mod.id);
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
plans.push({
|
|
447
|
-
moduleId: mod.id,
|
|
448
|
-
installedVersion: mod.version,
|
|
449
|
-
targetVersion: latest.vers,
|
|
450
|
-
classification: cmp,
|
|
451
|
-
});
|
|
452
|
-
} catch (err) {
|
|
453
|
-
errored.push({
|
|
454
|
-
moduleId: mod.id,
|
|
455
|
-
error: err instanceof Error ? err.message : String(err),
|
|
456
|
-
});
|
|
457
|
-
}
|
|
255
|
+
// No name → the CD poll over all auto_upgrade modules.
|
|
256
|
+
if (!moduleId) {
|
|
257
|
+
return runRegistryPoll(db, flags);
|
|
458
258
|
}
|
|
459
259
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
lines.push(`Not in registry: ${notInRegistry.join(', ')}`);
|
|
464
|
-
}
|
|
465
|
-
if (errored.length > 0) {
|
|
466
|
-
lines.push(`Registry errors: ${errored.map((e) => `${e.moduleId} (${e.error})`).join('; ')}`);
|
|
467
|
-
}
|
|
468
|
-
return { success: true, message: lines.join('\n') };
|
|
260
|
+
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
261
|
+
if (!mod) {
|
|
262
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
469
263
|
}
|
|
470
264
|
|
|
471
|
-
const
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const failed: Array<{ moduleId: string; error: string }> = [];
|
|
476
|
-
|
|
477
|
-
if (nonBreaking.length > 0) {
|
|
478
|
-
log.info(`Auto-applying ${nonBreaking.length} non-breaking update(s):`);
|
|
479
|
-
for (const plan of nonBreaking) {
|
|
480
|
-
const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
481
|
-
if (result.status === 'failed') {
|
|
482
|
-
failed.push({ moduleId: plan.moduleId, error: result.error });
|
|
483
|
-
console.log(
|
|
484
|
-
` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (${plan.classification}, FAILED)`,
|
|
485
|
-
);
|
|
486
|
-
} else if (result.status === 'success') {
|
|
487
|
-
appliedNonBreaking++;
|
|
488
|
-
console.log(
|
|
489
|
-
` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (${plan.classification})`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
// status === 'skipped' shouldn't happen for registry-fetched packages
|
|
493
|
-
// (we know the module is installed; the package definitely has a
|
|
494
|
-
// manifest), but treat it as a no-op if it does.
|
|
495
|
-
}
|
|
265
|
+
const client = new RegistryClient(getFlag(flags, 'registry', '') || undefined);
|
|
266
|
+
const latest = await latestRegistryVersion(client, moduleId);
|
|
267
|
+
if (!latest) {
|
|
268
|
+
return { success: false, error: `${moduleId} is not on the registry — nothing to upgrade to.` };
|
|
496
269
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (breaking.length > 0) {
|
|
502
|
-
log.info('\nBreaking updates available — review required (semver-major bump):');
|
|
503
|
-
for (const plan of breaking) {
|
|
504
|
-
console.log(
|
|
505
|
-
` ⚠ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion}`,
|
|
506
|
-
);
|
|
507
|
-
}
|
|
508
|
-
log.message('Each breaking update will be applied only on explicit confirmation.\n');
|
|
509
|
-
|
|
510
|
-
for (const plan of breaking) {
|
|
511
|
-
const proceed = await withInterviewSession(() =>
|
|
512
|
-
askConfirm({
|
|
513
|
-
scope: `module-upgrade:${plan.moduleId}`,
|
|
514
|
-
key: 'apply_breaking',
|
|
515
|
-
message: `Apply breaking update for ${plan.moduleId} (${plan.installedVersion} → ${plan.targetVersion})?`,
|
|
516
|
-
defaultValue: false,
|
|
517
|
-
}),
|
|
518
|
-
);
|
|
519
|
-
if (!proceed) {
|
|
520
|
-
skippedBreaking++;
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
const result = await fetchAndUpgrade(client, plan.moduleId, plan.targetVersion, db, flags);
|
|
524
|
-
if (result.status === 'failed') {
|
|
525
|
-
failed.push({ moduleId: plan.moduleId, error: result.error });
|
|
526
|
-
console.log(
|
|
527
|
-
` ✗ ${plan.moduleId.padEnd(30)} ${plan.installedVersion} → ${plan.targetVersion} (major, FAILED)`,
|
|
528
|
-
);
|
|
529
|
-
} else if (result.status === 'success') {
|
|
530
|
-
appliedBreaking++;
|
|
531
|
-
console.log(
|
|
532
|
-
` ✓ ${plan.moduleId.padEnd(30)} ${result.previousVersion} → ${result.newVersion} (major)`,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
270
|
+
const change = classifyVersionChange(mod.version, latest);
|
|
271
|
+
if (change === 'up-to-date' || change === 'ahead') {
|
|
272
|
+
return { success: true, message: `${moduleId} is already up to date (${mod.version}).` };
|
|
536
273
|
}
|
|
537
274
|
|
|
538
|
-
|
|
539
|
-
const summary: string[] = [];
|
|
540
|
-
const totalApplied = appliedNonBreaking + appliedBreaking;
|
|
541
|
-
if (totalApplied > 0) {
|
|
542
|
-
const parts = [`${appliedNonBreaking} non-breaking`];
|
|
543
|
-
if (appliedBreaking > 0) parts.push(`${appliedBreaking} breaking`);
|
|
544
|
-
summary.push(`Applied ${totalApplied} update(s) (${parts.join(', ')}).`);
|
|
545
|
-
} else {
|
|
546
|
-
summary.push('No updates applied.');
|
|
547
|
-
}
|
|
548
|
-
if (skippedBreaking > 0) {
|
|
549
|
-
summary.push(`Skipped ${skippedBreaking} breaking update(s) (operator declined).`);
|
|
550
|
-
}
|
|
551
|
-
if (notInRegistry.length > 0) {
|
|
552
|
-
summary.push(`Not in registry (${notInRegistry.length}): ${notInRegistry.join(', ')}`);
|
|
553
|
-
}
|
|
554
|
-
if (errored.length > 0) {
|
|
555
|
-
summary.push(
|
|
556
|
-
`Registry errors (${errored.length}): ${errored.map((e) => `${e.moduleId} — ${e.error}`).join('; ')}`,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
if (failed.length > 0) {
|
|
560
|
-
return {
|
|
561
|
-
success: false,
|
|
562
|
-
error: [
|
|
563
|
-
...summary,
|
|
564
|
-
'',
|
|
565
|
-
'Failures:',
|
|
566
|
-
...failed.map((f) => ` ${f.moduleId}: ${f.error}`),
|
|
567
|
-
].join('\n'),
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
return { success: true, message: summary.join('\n') };
|
|
275
|
+
return upgradeOneModule(mod, latest, client, db, flags);
|
|
571
276
|
}
|