@celilo/cli 1.6.0 → 1.8.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 (64) hide show
  1. package/CELILO_CORE_MODULES.md +3 -1
  2. package/CELILO_SUBSYSTEMS.md +7 -1
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/drizzle/0027_dns_internal_records_consumer_cascade.sql +43 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +3 -3
  7. package/src/capabilities/lookup.ts +39 -29
  8. package/src/capabilities/secret-ref.test.ts +24 -0
  9. package/src/capabilities/secret-validation.ts +50 -0
  10. package/src/capabilities/validation.test.ts +238 -2
  11. package/src/capabilities/validation.ts +67 -1
  12. package/src/cli/commands/alerts-sweep.ts +18 -0
  13. package/src/cli/commands/module-remove.ts +34 -2
  14. package/src/cli/commands/module-update.test.ts +149 -2
  15. package/src/cli/commands/module-update.ts +113 -25
  16. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  17. package/src/cli/commands/service-set-credentials.ts +115 -0
  18. package/src/cli/commands/system-migrate.ts +6 -4
  19. package/src/cli/completion.ts +16 -1
  20. package/src/cli/index.ts +9 -0
  21. package/src/db/client.ts +10 -8
  22. package/src/db/dns-internal-cascade-migration.test.ts +184 -0
  23. package/src/db/migrate.test.ts +147 -0
  24. package/src/db/migrate.ts +69 -1
  25. package/src/db/schema.ts +21 -4
  26. package/src/hooks/capability-loader.test.ts +55 -0
  27. package/src/hooks/capability-loader.ts +16 -1
  28. package/src/manifest/template-validator.test.ts +47 -0
  29. package/src/manifest/template-validator.ts +18 -1
  30. package/src/module/import.ts +39 -6
  31. package/src/policy/capability-shape-baseline.ts +88 -0
  32. package/src/policy/capability-shape-drift.test.ts +162 -0
  33. package/src/policy/capability-shape.ts +117 -0
  34. package/src/policy/dns-aspect-coverage.test.ts +100 -0
  35. package/src/policy/module-business-baseline.ts +32 -18
  36. package/src/services/alerting/monitors.ts +54 -2
  37. package/src/services/alerting/sweep-runner.ts +38 -1
  38. package/src/services/capability-table-rows.test.ts +191 -0
  39. package/src/services/capability-table-rows.ts +103 -0
  40. package/src/services/consumer-cleanup.ts +18 -10
  41. package/src/services/container-service.test.ts +34 -0
  42. package/src/services/container-service.ts +44 -0
  43. package/src/services/deployed-systems.test.ts +101 -0
  44. package/src/services/deployed-systems.ts +43 -11
  45. package/src/services/dns-internal-records.test.ts +72 -1
  46. package/src/services/dns-provider-backfill.ts +30 -0
  47. package/src/services/fleet-checks.test.ts +26 -0
  48. package/src/services/fleet-checks.ts +11 -1
  49. package/src/services/module-deploy.ts +88 -41
  50. package/src/services/module-validator/capability-versions.test.ts +6 -1
  51. package/src/services/port-forwards.test.ts +6 -2
  52. package/src/services/port-forwards.ts +0 -11
  53. package/src/services/provider-arrival.test.ts +241 -0
  54. package/src/services/provider-arrival.ts +213 -0
  55. package/src/services/trusted-sources.ts +0 -5
  56. package/src/templates/generator.test.ts +35 -0
  57. package/src/templates/generator.ts +29 -1
  58. package/src/variables/context.test.ts +63 -0
  59. package/src/variables/context.ts +85 -12
  60. package/src/variables/declarative-derivation.test.ts +47 -8
  61. package/src/variables/declarative-derivation.ts +6 -4
  62. package/src/variables/lxc-nameserver.test.ts +144 -0
  63. package/src/services/public-web-republish.test.ts +0 -189
  64. package/src/services/public-web-republish.ts +0 -84
@@ -9,7 +9,15 @@
9
9
  * The module ID is read from the manifest at the given path.
10
10
  */
11
11
 
12
- import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
12
+ import {
13
+ cpSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ readFileSync,
17
+ readdirSync,
18
+ renameSync,
19
+ rmSync,
20
+ } from 'node:fs';
13
21
  import { unlink } from 'node:fs/promises';
14
22
  import { tmpdir } from 'node:os';
15
23
  import { join, relative, resolve } from 'node:path';
@@ -163,6 +171,13 @@ export async function fetchAndUpdate(
163
171
  * `generated/`, the hook runtime closure, `screenshots/`, `cookies.json` — and
164
172
  * `generated/` alone carries terraform state and provider binaries.
165
173
  */
174
+ /**
175
+ * What an update leaves alone: celilo's own output and the operator's state,
176
+ * never the package's. The set the in-place update skipped, kept verbatim so
177
+ * the staging swap changes only WHEN files move, not WHICH ones.
178
+ */
179
+ const PRESERVED_ENTRIES = new Set(['generated', 'screenshots', 'cookies.json']);
180
+
166
181
  function listPrunableFiles(root: string, dir = root): string[] {
167
182
  const found: string[] = [];
168
183
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -178,6 +193,88 @@ function listPrunableFiles(root: string, dir = root): string[] {
178
193
  return found;
179
194
  }
180
195
 
196
+ /**
197
+ * Replace an installed module tree atomically (celilo#1008).
198
+ *
199
+ * The install used to be built in place: copy the new files over the old,
200
+ * then prune what the new version dropped. A failure anywhere in that
201
+ * sequence left a module half old and half new, with nothing on disk or in
202
+ * the DB recording which files were which. The comment on the in-place copy
203
+ * already named the hazard — "the copy dies partway, after the prune has
204
+ * already run".
205
+ *
206
+ * So the new tree is assembled in a sibling directory and swapped in with two
207
+ * renames. Everything that can fail — the copy, the prune, a bad source —
208
+ * fails while the live install is still untouched, because nothing has moved
209
+ * yet. If the second rename throws, the first is undone and the original is
210
+ * back. A sibling is deliberate: it is on the same filesystem, so the renames
211
+ * are atomic rather than a copy in disguise.
212
+ *
213
+ * The staging tree is removed on both paths, so a failure leaves no debris
214
+ * beside the module for the next reader to wonder about.
215
+ *
216
+ * `prune` receives the STAGED root, so the surviving-path rule is the one
217
+ * `updateOne` has always applied — only where it runs has changed.
218
+ *
219
+ * Adapted from `replaceInstalledModule` in Jeremy Banka's `f9a57f1b`. The rest
220
+ * of that commit is superseded by celilo#925; this half is not. It uses
221
+ * celilo's `classifyModulePath` rather than that commit's hand-rolled
222
+ * preserve/skip sets, which is what makes the `celilo/types.d.ts` special case
223
+ * it carried unnecessary — the classifier already calls that file `derived`.
224
+ */
225
+ function replaceInstalledModule(
226
+ actualPath: string,
227
+ installedPath: string,
228
+ prune: (stagedRoot: string) => void,
229
+ ): void {
230
+ const suffix = `${process.pid}-${Date.now()}`;
231
+ const stagedPath = `${installedPath}.update-${suffix}`;
232
+ const previousPath = `${installedPath}.previous-${suffix}`;
233
+
234
+ rmSync(stagedPath, { recursive: true, force: true });
235
+ mkdirSync(stagedPath, { recursive: true });
236
+
237
+ try {
238
+ // The incoming version, filtered by the same classifier the in-place copy
239
+ // used, so an update from a directory still cannot plant `e2e/`,
240
+ // `*.test.ts` or `scripts/tsconfig.json` in the install.
241
+ for (const entry of readdirSync(actualPath)) {
242
+ if (PRESERVED_ENTRIES.has(entry)) continue;
243
+ cpSync(join(actualPath, entry), join(stagedPath, entry), {
244
+ recursive: true,
245
+ force: true,
246
+ filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
247
+ });
248
+ }
249
+
250
+ // Carry the live tree's own copies across. Same three entries the in-place
251
+ // update left alone, for the same reason: they are celilo's or the
252
+ // operator's, not the package's. Every other `derived` path — the hook
253
+ // runtime closure under `scripts/node_modules`, `celilo/types.d.ts` —
254
+ // still comes from the incoming version exactly as it did before, so a new
255
+ // module version can still deliver new hook dependencies.
256
+ for (const entry of PRESERVED_ENTRIES) {
257
+ const from = join(installedPath, entry);
258
+ if (!existsSync(from)) continue;
259
+ cpSync(from, join(stagedPath, entry), { recursive: true, force: true });
260
+ }
261
+
262
+ prune(stagedPath);
263
+
264
+ renameSync(installedPath, previousPath);
265
+ try {
266
+ renameSync(stagedPath, installedPath);
267
+ } catch (error) {
268
+ renameSync(previousPath, installedPath);
269
+ throw error;
270
+ }
271
+ rmSync(previousPath, { recursive: true, force: true });
272
+ } catch (error) {
273
+ rmSync(stagedPath, { recursive: true, force: true });
274
+ throw error;
275
+ }
276
+ }
277
+
181
278
  export async function updateOne(
182
279
  sourcePath: string,
183
280
  db: ReturnType<typeof getDb>,
@@ -293,27 +390,7 @@ export async function updateOne(
293
390
  log.info(`Upgrading ${moduleId}: ${previousVersion} → ${newVersion}`);
294
391
  }
295
392
 
296
- // Copy new module files, preserving generated output and state
297
393
  const installedPath = module.sourcePath;
298
- const preserveDirs = new Set(['generated', 'screenshots', 'cookies.json']);
299
-
300
- // Route the copy through the one classifier, the way `module import` does.
301
- // `updateOne` used to copy the source tree wholesale, so updating from a
302
- // directory planted `e2e/`, `*.test.ts` and `scripts/tsconfig.json` in the
303
- // install — files no package ships and no target runs. Import never did,
304
- // because a directory import goes through the packager; update is the path
305
- // that skipped it.
306
- const entries = readdirSync(actualPath);
307
- for (const entry of entries) {
308
- if (preserveDirs.has(entry)) continue;
309
- const src = join(actualPath, entry);
310
- const dest = join(installedPath, entry);
311
- cpSync(src, dest, {
312
- recursive: true,
313
- force: true,
314
- filter: (from) => classifyModulePath(relative(actualPath, from)) !== 'unknown',
315
- });
316
- }
317
394
 
318
395
  // The integrity baseline for the version just installed. Prefer the package's
319
396
  // own signed `checksums.json`; a directory update has none, so compute over
@@ -343,14 +420,25 @@ export async function updateOne(
343
420
  // pruned: `generated/`, the hook runtime closure, `screenshots/` and
344
421
  // `cookies.json` are celilo's or the operator's, and survive an update by
345
422
  // design.
423
+ //
424
+ // Unchanged except for WHERE it runs. It now prunes the staged tree, before
425
+ // anything is swapped in, so a prune that throws cannot leave the live
426
+ // install short of files (celilo#1008).
346
427
  const survivingPaths = new Set(
347
428
  Object.keys(baselineChecksums).filter((p) => classifyModulePath(p) === 'package'),
348
429
  );
349
- for (const relPath of listPrunableFiles(installedPath)) {
350
- if (!survivingPaths.has(relPath)) {
351
- rmSync(join(installedPath, relPath));
430
+ const pruneDropped = (root: string) => {
431
+ for (const relPath of listPrunableFiles(root)) {
432
+ if (!survivingPaths.has(relPath)) {
433
+ rmSync(join(root, relPath));
434
+ }
352
435
  }
353
- }
436
+ };
437
+
438
+ // Build the new tree beside the install and swap it in with two renames.
439
+ // Everything above this line is reversible by doing nothing; everything
440
+ // below it runs only once the files are really in place.
441
+ replaceInstalledModule(actualPath, installedPath, pruneDropped);
354
442
 
355
443
  // Record it. `updateOne` never touched this table, so the baseline stayed
356
444
  // frozen at the module's FIRST import no matter how many times it was
@@ -0,0 +1,108 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { closeDb } from '../../db/client';
6
+ import { runMigrations } from '../../db/migrate';
7
+ import {
8
+ type ProxmoxCredentials,
9
+ addContainerService,
10
+ getContainerService,
11
+ getServiceCredentials,
12
+ updateVerificationStatus,
13
+ } from '../../services/container-service';
14
+ import { handleServiceSetCredentials } from './service-set-credentials';
15
+
16
+ describe('service set-credentials', () => {
17
+ let testDir: string;
18
+
19
+ beforeEach(async () => {
20
+ testDir = mkdtempSync(join(tmpdir(), 'celilo-service-credentials-test-'));
21
+ process.env.CELILO_DB_PATH = join(testDir, 'test.db');
22
+ process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key');
23
+ writeFileSync(process.env.CELILO_MASTER_KEY_PATH, 'a'.repeat(64), 'utf8');
24
+ await runMigrations(process.env.CELILO_DB_PATH);
25
+ });
26
+
27
+ afterEach(() => {
28
+ closeDb();
29
+ delete process.env.CELILO_DB_PATH;
30
+ delete process.env.CELILO_MASTER_KEY_PATH;
31
+ delete process.env.PROXMOX_API_URL;
32
+ delete process.env.PROXMOX_API_TOKEN_ID;
33
+ delete process.env.PROXMOX_API_TOKEN_SECRET;
34
+ delete process.env.DIGITALOCEAN_API_TOKEN;
35
+ rmSync(testDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it('updates only the Proxmox endpoint and retains the token', async () => {
39
+ const service = await addContainerService({
40
+ name: 'Chubs',
41
+ providerName: 'proxmox',
42
+ zones: ['internal'],
43
+ providerConfig: {},
44
+ apiCredentials: {
45
+ api_url: 'https://192.168.0.50:8006',
46
+ api_token_id: 'root@pam!celilo',
47
+ api_token_secret: 'existing-secret',
48
+ },
49
+ });
50
+ await updateVerificationStatus(service.id, { success: true, message: 'Connected' });
51
+
52
+ const result = await handleServiceSetCredentials(['chubs'], {
53
+ 'api-url': 'https://10.77.20.50:8006',
54
+ });
55
+
56
+ expect(result.success).toBe(true);
57
+ expect(await getServiceCredentials(service.id)).toEqual({
58
+ api_url: 'https://10.77.20.50:8006',
59
+ api_token_id: 'root@pam!celilo',
60
+ api_token_secret: 'existing-secret',
61
+ });
62
+ expect((await getContainerService(service.id))?.verified).toBe(false);
63
+ if (!result.success) throw new Error(result.error);
64
+ expect(result.message).not.toContain('existing-secret');
65
+ });
66
+
67
+ it('accepts the endpoint through the documented environment variable', async () => {
68
+ const service = await addContainerService({
69
+ name: 'Nubs',
70
+ providerName: 'proxmox',
71
+ zones: ['internal'],
72
+ providerConfig: {},
73
+ apiCredentials: {
74
+ api_url: 'https://192.168.0.51:8006',
75
+ api_token_id: 'root@pam!celilo',
76
+ api_token_secret: 'existing-secret',
77
+ },
78
+ });
79
+ process.env.PROXMOX_API_URL = 'https://10.77.20.51:8006';
80
+
81
+ const result = await handleServiceSetCredentials(['nubs']);
82
+
83
+ expect(result.success).toBe(true);
84
+ expect(((await getServiceCredentials(service.id)) as ProxmoxCredentials).api_url).toBe(
85
+ 'https://10.77.20.51:8006',
86
+ );
87
+ });
88
+
89
+ it('refuses a no-op that would only churn encrypted state', async () => {
90
+ await addContainerService({
91
+ name: 'Chubs',
92
+ providerName: 'proxmox',
93
+ zones: ['internal'],
94
+ providerConfig: {},
95
+ apiCredentials: {
96
+ api_url: 'https://192.168.0.50:8006',
97
+ api_token_id: 'root@pam!celilo',
98
+ api_token_secret: 'existing-secret',
99
+ },
100
+ });
101
+
102
+ const result = await handleServiceSetCredentials(['chubs']);
103
+
104
+ expect(result.success).toBe(false);
105
+ if (result.success) throw new Error('Expected set-credentials to reject a no-op');
106
+ expect(result.error).toContain('No credential changes supplied');
107
+ });
108
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Service set-credentials command.
3
+ *
4
+ * Credential values travel only by flag or environment variable (D7). Fields
5
+ * omitted from the invocation retain their existing encrypted values, which
6
+ * lets an operator move a provider endpoint without rotating its API token.
7
+ */
8
+
9
+ import {
10
+ type DigitalOceanCredentials,
11
+ type ProxmoxCredentials,
12
+ getContainerServiceByServiceId,
13
+ getServiceCredentials,
14
+ updateServiceCredentials,
15
+ } from '../../services/container-service';
16
+ import type { CommandResult } from '../types';
17
+
18
+ type Flags = Record<string, boolean | string>;
19
+
20
+ function credentialUpdateValue(flags: Flags, flag: string, envVar: string): string | undefined {
21
+ const flagValue = flags[flag];
22
+ if (flagValue === true) {
23
+ throw new Error(`--${flag} requires a value`);
24
+ }
25
+ if (typeof flagValue === 'string') {
26
+ const value = flagValue.trim();
27
+ if (!value) throw new Error(`--${flag} requires a non-empty value`);
28
+ return value;
29
+ }
30
+
31
+ const envValue = process.env[envVar]?.trim();
32
+ return envValue || undefined;
33
+ }
34
+
35
+ export async function handleServiceSetCredentials(
36
+ args: string[],
37
+ flags: Flags = {},
38
+ ): Promise<CommandResult> {
39
+ const serviceId = args[0];
40
+ if (!serviceId) {
41
+ return {
42
+ success: false,
43
+ error:
44
+ 'Service ID is required\n\nUsage: celilo service set-credentials <service-id> [options]',
45
+ };
46
+ }
47
+
48
+ try {
49
+ const service = await getContainerServiceByServiceId(serviceId);
50
+ if (!service) {
51
+ return {
52
+ success: false,
53
+ error: `Service not found: ${serviceId}\n\nRun 'celilo service list' to see available services.`,
54
+ };
55
+ }
56
+
57
+ const current = await getServiceCredentials(service.id);
58
+ let changed = false;
59
+
60
+ if (service.providerName === 'proxmox') {
61
+ const existing = current as ProxmoxCredentials;
62
+ const apiUrl = credentialUpdateValue(flags, 'api-url', 'PROXMOX_API_URL');
63
+ const apiTokenId = credentialUpdateValue(flags, 'api-token-id', 'PROXMOX_API_TOKEN_ID');
64
+ const apiTokenSecret = credentialUpdateValue(
65
+ flags,
66
+ 'api-token-secret',
67
+ 'PROXMOX_API_TOKEN_SECRET',
68
+ );
69
+ changed = Boolean(apiUrl || apiTokenId || apiTokenSecret);
70
+
71
+ if (changed) {
72
+ await updateServiceCredentials(service.id, {
73
+ api_url: apiUrl ?? existing.api_url,
74
+ api_token_id: apiTokenId ?? existing.api_token_id,
75
+ api_token_secret: apiTokenSecret ?? existing.api_token_secret,
76
+ });
77
+ }
78
+ } else if (service.providerName === 'digitalocean') {
79
+ const existing = current as DigitalOceanCredentials;
80
+ const apiToken = credentialUpdateValue(flags, 'api-token', 'DIGITALOCEAN_API_TOKEN');
81
+ changed = Boolean(apiToken);
82
+
83
+ if (changed) {
84
+ await updateServiceCredentials(service.id, {
85
+ api_token: apiToken ?? existing.api_token,
86
+ });
87
+ }
88
+ } else {
89
+ return {
90
+ success: false,
91
+ error: `Credential updates are not supported for provider: ${service.providerName}`,
92
+ };
93
+ }
94
+
95
+ if (!changed) {
96
+ return {
97
+ success: false,
98
+ error:
99
+ service.providerName === 'proxmox'
100
+ ? 'No credential changes supplied. Pass --api-url, --api-token-id, or --api-token-secret (or set the corresponding PROXMOX_API_* environment variable).'
101
+ : 'No credential changes supplied. Pass --api-token or set $DIGITALOCEAN_API_TOKEN.',
102
+ };
103
+ }
104
+
105
+ return {
106
+ success: true,
107
+ message: `Updated credentials for service '${serviceId}'. Verification status cleared; run: celilo service verify ${serviceId}`,
108
+ };
109
+ } catch (error) {
110
+ return {
111
+ success: false,
112
+ error: `Failed to update service credentials: ${error instanceof Error ? error.message : String(error)}`,
113
+ };
114
+ }
115
+ }
@@ -108,9 +108,11 @@ export async function handleSystemMigrate(
108
108
  }
109
109
  }
110
110
 
111
- // getDb() auto-migrates on open; do it inside try so an existing DB that
112
- // predates the drizzle-authoritative change fails with an actionable message
113
- // instead of a raw migrator error.
111
+ // getDb() auto-migrates on open, and repairs a frozen `__drizzle_migrations`
112
+ // watermark itself when the declared schema is already complete. What reaches
113
+ // this catch is the case it will not guess at: schema that is only PARTLY
114
+ // there, where stamping would record migrations that never ran. Caught so it
115
+ // says what to do instead of surfacing a raw migrator error.
114
116
  let db: ReturnType<typeof getDb>;
115
117
  try {
116
118
  db = getDb();
@@ -118,7 +120,7 @@ export async function handleSystemMigrate(
118
120
  const msg = error instanceof Error ? error.message : String(error);
119
121
  return {
120
122
  success: false,
121
- error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanlysee ISS-0100.`,
123
+ error: `Migration failed: ${msg}\n\nThis DB's \`__drizzle_migrations\` watermark disagrees with a schema that is only partly applied, which celilo will not resolve on its own. It needs a one-time remediation by hand (create the genuinely missing objects from their migration .sql, then stamp the watermark to the latest migration) runbook in celilo#169.`,
122
124
  };
123
125
  }
124
126
 
@@ -325,7 +325,15 @@ export async function getCompletions(words: string[], current: number): Promise<
325
325
  }
326
326
 
327
327
  if (command === 'service' && currentIndex === 1) {
328
- const subcommands = ['add', 'list', 'verify', 'reconfigure', 'remove', 'config'];
328
+ const subcommands = [
329
+ 'add',
330
+ 'list',
331
+ 'verify',
332
+ 'reconfigure',
333
+ 'remove',
334
+ 'config',
335
+ 'set-credentials',
336
+ ];
329
337
  return filterSuggestions(subcommands, args[1] || '');
330
338
  }
331
339
 
@@ -356,6 +364,13 @@ export async function getCompletions(words: string[], current: number): Promise<
356
364
  return filterSuggestions(serviceIds, args[2] || '');
357
365
  }
358
366
 
367
+ // Service set-credentials - complete with service IDs
368
+ if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) {
369
+ const services = await listContainerServices();
370
+ const serviceIds = services.map((s) => s.serviceId);
371
+ return filterSuggestions(serviceIds, args[2] || '');
372
+ }
373
+
359
374
  // Service config operations
360
375
  if (command === 'service' && args[1] === 'config' && currentIndex === 2) {
361
376
  const operations = ['get', 'set'];
package/src/cli/index.ts CHANGED
@@ -109,6 +109,7 @@ import { handleServiceConfigSet } from './commands/service-config-set';
109
109
  import { handleServiceList } from './commands/service-list';
110
110
  import { handleServiceReconfigure } from './commands/service-reconfigure';
111
111
  import { handleServiceRemove } from './commands/service-remove';
112
+ import { handleServiceSetCredentials } from './commands/service-set-credentials';
112
113
  import { handleServiceVerify } from './commands/service-verify';
113
114
  import { handleStatus } from './commands/status';
114
115
  import { handleSubscribersAdd } from './commands/subscribers-add';
@@ -721,6 +722,7 @@ Subcommands:
721
722
  Options:
722
723
  --zone <zone> Filter by network zone
723
724
  verify <service-id> Re-verify a container service connection
725
+ set-credentials <service-id> Update a provider endpoint or API credential
724
726
  reconfigure <service-id> Re-run configuration interview (change template, storage, etc.)
725
727
  remove <id> Remove a container service
726
728
  Options:
@@ -751,6 +753,9 @@ Examples:
751
753
  # Verify a service connection
752
754
  celilo service verify proxmox-home-lab
753
755
 
756
+ # Move a Proxmox endpoint while retaining its existing token
757
+ celilo service set-credentials proxmox-home-lab --api-url https://10.77.20.50:8006
758
+
754
759
  # Get service configuration
755
760
  celilo service config get proxmox-home-lab
756
761
  celilo service config get proxmox-home-lab name
@@ -1750,6 +1755,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1750
1755
  return handleServiceVerify(parsed.args, parsed.flags);
1751
1756
  }
1752
1757
 
1758
+ if (parsed.subcommand === 'set-credentials') {
1759
+ return handleServiceSetCredentials(parsed.args, parsed.flags);
1760
+ }
1761
+
1753
1762
  if (parsed.subcommand === 'reconfigure') {
1754
1763
  return handleServiceReconfigure(parsed.args, parsed.flags);
1755
1764
  }
package/src/db/client.ts CHANGED
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { BUSY_TIMEOUT_MS, ensureWalMode } from '@celilo/event-bus/wal';
6
6
  import { drizzle } from 'drizzle-orm/bun-sqlite';
7
- import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
8
7
  import { getDbPath } from '../config/paths';
8
+ import { runMigrationsOn } from './migrate';
9
9
  import * as schema from './schema';
10
10
 
11
11
  /**
@@ -83,15 +83,17 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
83
83
  // idempotent: it applies every migration newer than the latest recorded in
84
84
  // `__drizzle_migrations` and no-ops once current.
85
85
  //
86
- // One-time caveat (ISS-0100): an existing DB from the hand-list era has a
87
- // frozen `__drizzle_migrations` watermark; it must be remediated by hand
88
- // (stamp the watermark to the latest migration + create any missing table)
89
- // BEFORE this code opens it, or migrate() re-runs already-applied migrations
90
- // and throws. `celilo system doctor` (checkSchemaDrift) detects the drift.
86
+ // A DB from the hand-list era has a frozen `__drizzle_migrations` watermark,
87
+ // so drizzle re-runs already-applied migrations and throws (celilo#169).
88
+ // runMigrationsOn repairs that itself where the schema is already complete.
89
+ // It has to happen HERE and not only in `celilo system migrate`, because
90
+ // that command reaches its own repair through getDb() this line — and so
91
+ // would die before getting there. A PARTIALLY applied schema still throws
92
+ // and still needs a human. `celilo system doctor` (checkSchemaDrift) detects
93
+ // the drift.
91
94
  if (!readonly) {
92
95
  try {
93
- const migrationsFolder = findMigrationsFolder();
94
- migrate(db, { migrationsFolder });
96
+ runMigrationsOn(db);
95
97
  } catch (error) {
96
98
  console.error('Failed to run migrations:', error);
97
99
  throw error;