@0xmaxma/claude-gateway 1.6.4 → 1.6.5

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.
@@ -44,6 +44,22 @@ const node_child_process_1 = require("node:child_process");
44
44
  const compose_generator_1 = require("./compose-generator");
45
45
  // ─── Constants ────────────────────────────────────────────────────────────────
46
46
  const DEFAULT_APPS_DIR = path.join(os.homedir(), '.claude-gateway', 'apps');
47
+ // Backups live under `<appsDir>/.backups/<app>/`. A dot-prefixed dir here is
48
+ // never mistaken for an installed app (the registry is driven by `apps.json`,
49
+ // not by enumerating the apps directory), and — being a sibling of each app's
50
+ // own dir rather than inside it — the archive survives that app's uninstall,
51
+ // which removes only `<appsDir>/<app>/`.
52
+ const APP_BACKUPS_DIRNAME = '.backups';
53
+ // Default per-app backup ceiling when config omits it. The N most recent are
54
+ // kept; older archives are pruned after each successful backup.
55
+ const DEFAULT_BACKUP_RETENTION = 10;
56
+ // Wall-clock ceiling for a single helper-container tar (backup or restore) of
57
+ // one volume. A few hundred MB tars in seconds; this only bounds a pathological
58
+ // hang so a stuck helper never wedges a backup job forever.
59
+ const VOLUME_TAR_TIMEOUT_MS = 300000;
60
+ // OCI image used for the throwaway tar helper. Small, ubiquitous, already a
61
+ // transitive dependency of most stacks, so it is almost always cache-warm.
62
+ const BACKUP_HELPER_IMAGE = 'alpine';
47
63
  // Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
48
64
  // the background (non-blocking), so this only bounds how long a hung container
49
65
  // keeps its child process alive — not the gateway's responsiveness. Shorter than
@@ -59,17 +75,26 @@ const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
59
75
  const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
60
76
  // ─── Installer ────────────────────────────────────────────────────────────────
61
77
  class AppInstaller {
62
- constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn) {
78
+ constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
63
79
  this.registry = registry;
64
80
  this.registryClient = registryClient;
65
81
  this.callbacks = callbacks;
66
82
  this.spawn = spawn;
67
83
  this.agentManager = agentManager;
68
84
  this.spawnAsync = spawnAsync;
85
+ this.housekeepingConfig = housekeepingConfig;
69
86
  this.jobs = new Map();
70
87
  /** Tracks app names currently being installed to prevent concurrent installs of the same name. */
71
88
  this.installingNames = new Set();
72
89
  this.appsDir = appsDir ?? DEFAULT_APPS_DIR;
90
+ this.backupsDir = backupsDir ?? path.join(this.appsDir, APP_BACKUPS_DIRNAME);
91
+ this.appBackupConfig = {
92
+ retention: appBackupConfig?.retention !== undefined && appBackupConfig.retention >= 0
93
+ ? Math.floor(appBackupConfig.retention)
94
+ : DEFAULT_BACKUP_RETENTION,
95
+ autoBackupBeforeUninstall: appBackupConfig?.autoBackupBeforeUninstall ?? true,
96
+ autoBackupBeforeUpdate: appBackupConfig?.autoBackupBeforeUpdate ?? true,
97
+ };
73
98
  }
74
99
  // ─── Public API ───────────────────────────────────────────────────────────
75
100
  /** Start an async install job. Returns jobId immediately. */
@@ -161,6 +186,7 @@ class AppInstaller {
161
186
  commit,
162
187
  secretKeys: generated.secretKeys,
163
188
  generatedKeys: generated.generatedKeys,
189
+ secretDefaults: generated.secretDefaults,
164
190
  ports: generated.ports,
165
191
  agentDeclaration: generated.agentDeclaration,
166
192
  warnings: generated.warnings,
@@ -254,6 +280,17 @@ class AppInstaller {
254
280
  return;
255
281
  }
256
282
  const appDir = entry.installPath;
283
+ // Safety hook: snapshot the app's data before tearing it down, so an
284
+ // accidental or regretted uninstall has a restore point. Best-effort — a
285
+ // backup failure must never block the uninstall the operator asked for.
286
+ if (this.appBackupConfig.autoBackupBeforeUninstall && fs.existsSync(appDir)) {
287
+ try {
288
+ await this.performBackup(entry);
289
+ }
290
+ catch (err) {
291
+ console.warn(`[apps] auto-backup before uninstall of "${appName}" failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
292
+ }
293
+ }
257
294
  // docker compose down --rmi all (graceful fallback if dir is already gone)
258
295
  if (fs.existsSync(appDir)) {
259
296
  try {
@@ -291,6 +328,431 @@ class AppInstaller {
291
328
  }
292
329
  await this.registry.remove(appName);
293
330
  }
331
+ // ─── Backup / Restore ───────────────────────────────────────────────────────
332
+ /**
333
+ * Start an async backup job. Returns jobId immediately; poll {@link getJob}.
334
+ * A backup is a permission-safe snapshot of the app's Docker named volumes +
335
+ * config (`.env`/`app.yaml`/compose) into a single archive under
336
+ * `<backupsDir>/<app>/`. The app is stopped for the snapshot and restarted
337
+ * afterwards (see {@link performBackup}).
338
+ */
339
+ backup(appName) {
340
+ this.pruneOldJobs();
341
+ if (this.installingNames.has(appName)) {
342
+ throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
343
+ }
344
+ this.installingNames.add(appName);
345
+ const jobId = crypto.randomUUID();
346
+ const job = {
347
+ id: jobId,
348
+ status: 'pending',
349
+ logs: [],
350
+ startedAt: Date.now(),
351
+ updatedAt: Date.now(),
352
+ };
353
+ this.jobs.set(jobId, job);
354
+ void this.runBackup(job, appName)
355
+ .catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
356
+ .finally(() => this.installingNames.delete(appName));
357
+ return jobId;
358
+ }
359
+ /**
360
+ * Start an async restore job. Returns jobId immediately; poll {@link getJob}.
361
+ * Restores the app's volumes + config from a prior backup, then starts it.
362
+ */
363
+ restore(appName, backupId) {
364
+ this.pruneOldJobs();
365
+ if (this.installingNames.has(appName)) {
366
+ throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
367
+ }
368
+ this.installingNames.add(appName);
369
+ const jobId = crypto.randomUUID();
370
+ const job = {
371
+ id: jobId,
372
+ status: 'pending',
373
+ logs: [],
374
+ startedAt: Date.now(),
375
+ updatedAt: Date.now(),
376
+ };
377
+ this.jobs.set(jobId, job);
378
+ void this.runRestore(job, appName, backupId)
379
+ .catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
380
+ .finally(() => this.installingNames.delete(appName));
381
+ return jobId;
382
+ }
383
+ /** List an app's backups, newest first. Reads the sidecar manifests. */
384
+ listBackups(appName) {
385
+ const dir = this.appBackupDir(appName);
386
+ if (!fs.existsSync(dir))
387
+ return [];
388
+ const infos = [];
389
+ for (const file of fs.readdirSync(dir)) {
390
+ if (!file.endsWith('.json'))
391
+ continue;
392
+ try {
393
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
394
+ // Only surface a backup whose archive is actually present.
395
+ if (!fs.existsSync(path.join(dir, `${meta.id}.tar.gz`)))
396
+ continue;
397
+ infos.push({
398
+ id: meta.id,
399
+ createdAt: meta.createdAt,
400
+ sizeBytes: meta.sizeBytes,
401
+ appVersion: meta.appVersion,
402
+ });
403
+ }
404
+ catch {
405
+ /* skip malformed manifest */
406
+ }
407
+ }
408
+ return infos.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
409
+ }
410
+ /** Delete one backup (archive + sidecar). Idempotent. */
411
+ deleteBackup(appName, backupId) {
412
+ if (!/^[\w.-]+$/.test(backupId))
413
+ throw new Error(`Invalid backup id "${backupId}"`);
414
+ const dir = this.appBackupDir(appName);
415
+ for (const ext of ['.tar.gz', '.json']) {
416
+ const p = path.join(dir, `${backupId}${ext}`);
417
+ try {
418
+ fs.rmSync(p, { force: true });
419
+ }
420
+ catch {
421
+ /* best-effort */
422
+ }
423
+ }
424
+ }
425
+ async runBackup(job, appName) {
426
+ job.status = 'running';
427
+ const entry = await this.registry.get(appName);
428
+ if (!entry)
429
+ throw new Error(`App "${appName}" is not installed`);
430
+ const info = await this.performBackup(entry, (m) => this.log(job, m));
431
+ job.backup = info;
432
+ job.status = 'completed';
433
+ job.updatedAt = Date.now();
434
+ }
435
+ async runRestore(job, appName, backupId) {
436
+ job.status = 'running';
437
+ const entry = await this.registry.get(appName);
438
+ if (!entry)
439
+ throw new Error(`App "${appName}" is not installed`);
440
+ const info = await this.performRestore(entry, backupId, (m) => this.log(job, m));
441
+ job.backup = info;
442
+ job.status = 'completed';
443
+ job.updatedAt = Date.now();
444
+ }
445
+ /**
446
+ * Core backup routine (shared by the job path and the auto-backup hooks).
447
+ *
448
+ * Consistency: a running app is stopped for the snapshot so no writes land
449
+ * mid-tar, then ALWAYS restarted in a `finally` — a backup that throws never
450
+ * leaves the app stuck stopped.
451
+ *
452
+ * Permission safety: each named volume is tarred inside a throwaway root
453
+ * container (`docker run … tar czf`), so uid/gid/mode are preserved in the
454
+ * archive and no host `cp`/`chown`/sudo ever touches the volume data.
455
+ */
456
+ async performBackup(entry, logSink) {
457
+ const log = (m) => logSink?.(m);
458
+ const appName = entry.name;
459
+ const appDir = entry.installPath;
460
+ if (!fs.existsSync(appDir)) {
461
+ throw new Error(`App "${appName}" directory is gone — cannot back up`);
462
+ }
463
+ const volumes = this.discoverVolumes(appName, appDir);
464
+ const bindMounts = this.discoverBindMounts(appName, appDir);
465
+ const wasRunning = (await this.queryRuntimeStatus(entry)) === 'running';
466
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `bkp-${appName}-`));
467
+ try {
468
+ if (wasRunning) {
469
+ log(`Stopping "${appName}" for a consistent snapshot`);
470
+ this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
471
+ }
472
+ const volDir = path.join(staging, 'volumes');
473
+ fs.mkdirSync(volDir, { recursive: true });
474
+ for (const vol of volumes) {
475
+ log(`Archiving volume "${vol}"`);
476
+ this.run([
477
+ 'docker', 'run', '--rm',
478
+ '-v', `${appName}_${vol}:/data:ro`,
479
+ '-v', `${volDir}:/backup`,
480
+ BACKUP_HELPER_IMAGE,
481
+ 'tar', 'czf', `/backup/${vol}.tar.gz`, '-C', '/data', '.',
482
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
483
+ }
484
+ // Bind-mount data dirs under the app dir (not Docker named volumes).
485
+ // Archive each with the same root-helper tar so ownership (e.g. the
486
+ // postgres uid) survives without host-side cp/sudo. Indexed filenames
487
+ // avoid collisions between paths that flatten to the same name.
488
+ const bindDir = path.join(staging, 'binds');
489
+ fs.mkdirSync(bindDir, { recursive: true });
490
+ const capturedBinds = [];
491
+ for (const rel of bindMounts) {
492
+ const abs = path.join(appDir, rel);
493
+ if (!fs.existsSync(abs)) {
494
+ log(`Bind mount "${rel}" missing on disk — skipping`);
495
+ continue;
496
+ }
497
+ log(`Archiving bind mount "${rel}"`);
498
+ this.run([
499
+ 'docker', 'run', '--rm',
500
+ '-v', `${abs}:/data:ro`,
501
+ '-v', `${bindDir}:/backup`,
502
+ BACKUP_HELPER_IMAGE,
503
+ 'tar', 'czf', `/backup/bind-${capturedBinds.length}.tar.gz`, '-C', '/data', '.',
504
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
505
+ capturedBinds.push(rel);
506
+ }
507
+ // Config (owned by the gateway user, copied on the host).
508
+ const cfgDir = path.join(staging, 'config');
509
+ fs.mkdirSync(cfgDir, { recursive: true });
510
+ for (const f of ['.env', 'app.yaml', 'docker-compose.yml']) {
511
+ this.copyIfExists(path.join(appDir, f), path.join(cfgDir, f));
512
+ }
513
+ const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto
514
+ .randomUUID()
515
+ .slice(0, 8)}`;
516
+ const meta = {
517
+ id,
518
+ appName,
519
+ appVersion: entry.version,
520
+ createdAt: new Date().toISOString(),
521
+ volumes,
522
+ bindMounts: capturedBinds,
523
+ sizeBytes: 0,
524
+ };
525
+ fs.writeFileSync(path.join(staging, 'metadata.json'), JSON.stringify(meta, null, 2));
526
+ const outDir = this.appBackupDir(appName);
527
+ fs.mkdirSync(outDir, { recursive: true });
528
+ const archivePath = path.join(outDir, `${id}.tar.gz`);
529
+ // The gateway host tars the staging tree. Volume tarballs written by the
530
+ // root helper are world-readable (0644), so this read succeeds without sudo.
531
+ this.run(['tar', 'czf', archivePath, '-C', staging, '.'], undefined, VOLUME_TAR_TIMEOUT_MS);
532
+ let sizeBytes = 0;
533
+ try {
534
+ sizeBytes = fs.statSync(archivePath).size;
535
+ }
536
+ catch {
537
+ /* archive stat failed — leave size 0 */
538
+ }
539
+ meta.sizeBytes = sizeBytes;
540
+ fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(meta, null, 2));
541
+ this.pruneBackups(appName);
542
+ log(`Backup "${id}" complete (${sizeBytes} bytes, ${volumes.length} volume(s), ` +
543
+ `${capturedBinds.length} bind mount(s))`);
544
+ return { id, createdAt: meta.createdAt, sizeBytes, appVersion: meta.appVersion };
545
+ }
546
+ finally {
547
+ try {
548
+ this.rmrf(staging);
549
+ }
550
+ catch {
551
+ /* best-effort staging cleanup */
552
+ }
553
+ if (wasRunning) {
554
+ try {
555
+ log(`Restarting "${appName}"`);
556
+ this.composeUp(appName, appDir);
557
+ }
558
+ catch (restartErr) {
559
+ log(`WARNING: failed to restart "${appName}" after backup: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`);
560
+ }
561
+ }
562
+ }
563
+ }
564
+ /**
565
+ * Core restore routine. Extracts the archive, wipes+repopulates each volume
566
+ * via the root helper (preserving inner ownership), restores `.env`, and
567
+ * starts the app on the restored data. Restoring across a differing
568
+ * appVersion is allowed but warns (possible schema/migration mismatch).
569
+ */
570
+ async performRestore(entry, backupId, logSink) {
571
+ const log = (m) => logSink?.(m);
572
+ if (!/^[\w.-]+$/.test(backupId))
573
+ throw new Error(`Invalid backup id "${backupId}"`);
574
+ const appName = entry.name;
575
+ const appDir = entry.installPath;
576
+ const archivePath = path.join(this.appBackupDir(appName), `${backupId}.tar.gz`);
577
+ if (!fs.existsSync(archivePath)) {
578
+ throw new Error(`Backup "${backupId}" not found for app "${appName}"`);
579
+ }
580
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `rst-${appName}-`));
581
+ try {
582
+ this.run(['tar', 'xzf', archivePath, '-C', staging], undefined, VOLUME_TAR_TIMEOUT_MS);
583
+ const meta = JSON.parse(fs.readFileSync(path.join(staging, 'metadata.json'), 'utf-8'));
584
+ if (meta.appVersion !== entry.version) {
585
+ log(`WARNING: restoring backup from version "${meta.appVersion}" onto installed "${entry.version}" — possible schema/migration mismatch`);
586
+ }
587
+ log(`Stopping "${appName}" before restore`);
588
+ try {
589
+ this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
590
+ }
591
+ catch {
592
+ /* may already be stopped */
593
+ }
594
+ const volDir = path.join(staging, 'volumes');
595
+ for (const vol of meta.volumes) {
596
+ const tarball = path.join(volDir, `${vol}.tar.gz`);
597
+ if (!fs.existsSync(tarball)) {
598
+ log(`WARNING: volume "${vol}" missing from backup — skipping`);
599
+ continue;
600
+ }
601
+ log(`Restoring volume "${vol}"`);
602
+ this.run([
603
+ 'docker', 'run', '--rm',
604
+ '-v', `${appName}_${vol}:/data`,
605
+ '-v', `${volDir}:/backup`,
606
+ BACKUP_HELPER_IMAGE,
607
+ 'sh', '-c',
608
+ // Wipe the live volume, then untar the snapshot (uid/gid preserved).
609
+ `rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/${vol}.tar.gz -C /data`,
610
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
611
+ }
612
+ // Restore bind-mount data dirs under the app dir. Indexed filenames match
613
+ // performBackup's capture order. Each restored path is re-checked to stay
614
+ // under the app dir (defense-in-depth against a tampered metadata path).
615
+ const bindDir = path.join(staging, 'binds');
616
+ const bindMounts = meta.bindMounts ?? [];
617
+ bindMounts.forEach((rel, i) => {
618
+ const abs = path.resolve(appDir, rel);
619
+ const base = appDir.endsWith(path.sep) ? appDir : appDir + path.sep;
620
+ if (abs !== appDir && !abs.startsWith(base)) {
621
+ log(`WARNING: bind mount "${rel}" escapes the app dir — skipping`);
622
+ return;
623
+ }
624
+ const tarball = path.join(bindDir, `bind-${i}.tar.gz`);
625
+ if (!fs.existsSync(tarball)) {
626
+ log(`WARNING: bind mount "${rel}" missing from backup — skipping`);
627
+ return;
628
+ }
629
+ log(`Restoring bind mount "${rel}"`);
630
+ this.run([
631
+ 'docker', 'run', '--rm',
632
+ '-v', `${abs}:/data`,
633
+ '-v', `${bindDir}:/backup`,
634
+ BACKUP_HELPER_IMAGE,
635
+ 'sh', '-c',
636
+ // Wipe the live bind dir, then untar the snapshot (uid/gid preserved).
637
+ `rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/bind-${i}.tar.gz -C /data`,
638
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
639
+ });
640
+ // Restore config that carries generated secrets, so the app boots with the
641
+ // same credentials the volume data was created under.
642
+ this.copyIfExists(path.join(staging, 'config', '.env'), path.join(appDir, '.env'));
643
+ log(`Starting "${appName}" on restored data`);
644
+ this.composeUp(appName, appDir);
645
+ await this.registry.updateStatus(appName, 'running').catch(() => { });
646
+ log(`Restore of "${backupId}" complete`);
647
+ return {
648
+ id: meta.id,
649
+ createdAt: meta.createdAt,
650
+ sizeBytes: meta.sizeBytes,
651
+ appVersion: meta.appVersion,
652
+ };
653
+ }
654
+ finally {
655
+ try {
656
+ this.rmrf(staging);
657
+ }
658
+ catch {
659
+ /* best-effort */
660
+ }
661
+ }
662
+ }
663
+ /**
664
+ * List an app's compose-level named volumes (the top-level `volumes:` keys).
665
+ * Returns [] when the app declares none or the query fails — a config-only
666
+ * backup is still useful (it captures `.env`).
667
+ */
668
+ discoverVolumes(appName, appDir) {
669
+ try {
670
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--volumes'], appDir, 30000);
671
+ return stdout
672
+ .split('\n')
673
+ .map((s) => s.trim())
674
+ .filter((s) => s.length > 0);
675
+ }
676
+ catch {
677
+ return [];
678
+ }
679
+ }
680
+ /**
681
+ * Discover bind-mount source directories that live **under the app dir**
682
+ * (e.g. `./data/photos` → `data/photos`). These hold app-owned data that is
683
+ * not a Docker named volume, so {@link discoverVolumes} never reports them —
684
+ * yet they are deleted on uninstall and must be captured in a backup.
685
+ *
686
+ * Returns app-dir-relative POSIX paths, deduped and sorted. Bind mounts whose
687
+ * source resolves outside the app dir (shared host resources such as a
688
+ * read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
689
+ * returns `[]` on any failure, mirroring {@link discoverVolumes}.
690
+ */
691
+ discoverBindMounts(appName, appDir) {
692
+ try {
693
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
694
+ const parsed = JSON.parse(stdout);
695
+ // Local-dev installs symlink the app dir into appsDir, and the generated
696
+ // compose resolves bind sources against the symlink's *realpath*. Match a
697
+ // source that sits under either the symlink path or its target, so those
698
+ // bind mounts are not wrongly excluded.
699
+ const bases = [appDir];
700
+ try {
701
+ const real = fs.realpathSync(appDir);
702
+ if (real !== appDir)
703
+ bases.push(real);
704
+ }
705
+ catch {
706
+ /* app dir unreadable — fall back to the literal path */
707
+ }
708
+ const rels = new Set();
709
+ for (const svc of Object.values(parsed.services ?? {})) {
710
+ for (const vol of svc.volumes ?? []) {
711
+ if (vol.type !== 'bind' || typeof vol.source !== 'string')
712
+ continue;
713
+ const abs = path.resolve(appDir, vol.source);
714
+ const matched = bases.find((b) => abs === b || abs.startsWith(b + path.sep));
715
+ if (!matched)
716
+ continue; // outside the app dir
717
+ const rel = path.relative(matched, abs);
718
+ if (rel.length > 0)
719
+ rels.add(rel.split(path.sep).join('/'));
720
+ }
721
+ }
722
+ return Array.from(rels).sort();
723
+ }
724
+ catch {
725
+ return [];
726
+ }
727
+ }
728
+ /** Keep the N most recent backups per app (by createdAt); prune older. */
729
+ pruneBackups(appName) {
730
+ const retention = this.appBackupConfig.retention;
731
+ if (retention <= 0)
732
+ return; // 0 = unbounded
733
+ const all = this.listBackups(appName); // newest first
734
+ for (const stale of all.slice(retention)) {
735
+ this.deleteBackup(appName, stale.id);
736
+ }
737
+ }
738
+ appBackupDir(appName) {
739
+ // Never trust the name reaching the filesystem: an unvalidated value (e.g.
740
+ // a `%2F`-smuggled `../../x` from a route param) would let path.join escape
741
+ // the backups tree. Every backup op funnels through here, so this one guard
742
+ // covers backup/restore/list/delete.
743
+ if (!APP_NAME_RE.test(appName))
744
+ throw new Error(`Invalid app name "${appName}"`);
745
+ return path.join(this.backupsDir, appName);
746
+ }
747
+ copyIfExists(src, dest) {
748
+ try {
749
+ if (fs.existsSync(src))
750
+ fs.copyFileSync(src, dest);
751
+ }
752
+ catch {
753
+ /* best-effort — a missing/unreadable optional file is not fatal */
754
+ }
755
+ }
294
756
  async startStopRestart(appName, action) {
295
757
  const entry = await this.registry.get(appName);
296
758
  if (!entry)
@@ -570,8 +1032,21 @@ class AppInstaller {
570
1032
  this.log(job, `Warning: ${w}`);
571
1033
  }
572
1034
  // ── Write .env ────────────────────────────────────────────────────────
1035
+ // Carry over an existing .env before writing, but ONLY for a local
1036
+ // (symlinked) install. A local install can re-point at a source tree that
1037
+ // still holds a prior .env alongside persisted data (e.g. a postgres pgdata
1038
+ // bind mount). writeEnvFile treats an already-present generated secret as
1039
+ // pinned, so reusing the existing .env keeps DB_PASSWORD/etc. stable and
1040
+ // lets the app reconnect to that data instead of failing auth (mirrors the
1041
+ // reconfigure path). This is deliberately scoped to `source === 'local'`:
1042
+ // a registry/GitHub install checks out into appDir, and a repo that
1043
+ // committed a `.env` would otherwise get its secrets pinned to committed
1044
+ // values — so for those sources we ignore any checked-out .env and generate
1045
+ // fresh, unchanged from before. Operator-supplied envVars still win.
573
1046
  this.log(job, 'Writing .env');
574
- const generatedNames = this.writeEnvFile(appDir, appName, generated, options.envVars ?? {});
1047
+ const existingEnv = source === 'local' ? this.readEnvFile(appDir) : {};
1048
+ const mergedEnv = { ...existingEnv, ...(options.envVars ?? {}) };
1049
+ const generatedNames = this.writeEnvFile(appDir, appName, generated, mergedEnv);
575
1050
  if (generatedNames.length > 0) {
576
1051
  this.log(job, `Generated secrets: ${generatedNames.join(', ')}`);
577
1052
  }
@@ -665,6 +1140,9 @@ class AppInstaller {
665
1140
  // ── Update status to running ──────────────────────────────────────────
666
1141
  await this.registry.updateStatus(appName, 'running');
667
1142
  this.log(job, 'Containers healthy');
1143
+ // ── Housekeeping: reclaim leaked build cache + dangling images ────────
1144
+ // Best-effort, config-gated, after the new stack is up (issue #302).
1145
+ this.pruneAfterBuild(job);
668
1146
  // ── Register proxy routes ─────────────────────────────────────────────
669
1147
  this.callbacks.registerRoutes(appName, generated.ports);
670
1148
  // ── Build result ──────────────────────────────────────────────────────
@@ -743,6 +1221,17 @@ class AppInstaller {
743
1221
  return;
744
1222
  }
745
1223
  this.log(job, `Updating ${appName} ${entry.commit.slice(0, 8)} → ${target.newCommit.slice(0, 8)}`);
1224
+ // Safety hook: snapshot before the update so a bad new image can be rolled
1225
+ // back. Best-effort — a backup failure must not block the update.
1226
+ if (this.appBackupConfig.autoBackupBeforeUpdate) {
1227
+ try {
1228
+ const info = await this.performBackup(entry, (m) => this.log(job, m));
1229
+ this.log(job, `Pre-update backup "${info.id}" created`);
1230
+ }
1231
+ catch (err) {
1232
+ this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1233
+ }
1234
+ }
746
1235
  const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
747
1236
  try {
748
1237
  // ── Shallow fetch of specific commit into tmp dir ─────────────────────
@@ -802,6 +1291,9 @@ class AppInstaller {
802
1291
  // we can reclaim exactly those images after the update — without a
803
1292
  // `compose down` that would collide with the new stack (issue #283).
804
1293
  const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
1294
+ // Also capture the app's declared image refs (repo:tag) so a superseded
1295
+ // *pulled* tag can be reclaimed after the update (issue #302).
1296
+ const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
805
1297
  // ── Bring old containers down (keeps images for rollback) ─────────────
806
1298
  this.log(job, 'Stopping old containers');
807
1299
  this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
@@ -840,6 +1332,7 @@ class AppInstaller {
840
1332
  // below never removes an image the new containers depend on (e.g. when the
841
1333
  // old and new versions happen to share a base/image).
842
1334
  const newImageIds = this.captureComposeImageIds(appName, tmpDir);
1335
+ const newImageRefs = this.captureComposeImageRefs(appName, tmpDir);
843
1336
  // ── Swap dirs ─────────────────────────────────────────────────────────
844
1337
  // Swap in place at the recorded install path — NOT path.join(appsDir, appName).
845
1338
  // For legacy installs the on-disk dir is named after the source repo/URL
@@ -892,7 +1385,14 @@ class AppInstaller {
892
1385
  }
893
1386
  catch { /* still in use or already gone — non-fatal */ }
894
1387
  }
1388
+ // Reclaim a superseded *pulled* tag the image-ID reclaim above can't
1389
+ // (a pulled image with >1 repo tag isn't removable by ID) — only this
1390
+ // app's own prior refs the new stack no longer uses (issue #302).
1391
+ this.reclaimSupersededTags(job, oldImageRefs, newImageRefs);
895
1392
  this.safeRmrf(oldBackupDir, job, 'old backup dir');
1393
+ // ── Housekeeping: reclaim leaked build cache + dangling images ────────
1394
+ // Best-effort, config-gated, after the image reclaim (issue #302).
1395
+ this.pruneAfterBuild(job);
896
1396
  // ── Build result ──────────────────────────────────────────────────────
897
1397
  const proxyUrls = {};
898
1398
  for (const p of generated.ports) {
@@ -1143,9 +1643,16 @@ class AppInstaller {
1143
1643
  }
1144
1644
  }
1145
1645
  const envLines = [];
1646
+ const secretDefaults = generated.secretDefaults ?? {};
1146
1647
  for (const key of generated.secretKeys) {
1147
- const val = (merged[key] ?? '').replace(/[\r\n]/g, '');
1148
- envLines.push(`${key}=${val}`);
1648
+ // Precedence: operator-supplied value → declared default → empty. An
1649
+ // empty operator value (UI always sends the field, possibly blank) falls
1650
+ // through to the default, matching how generated keys treat '' as unset.
1651
+ const provided = merged[key];
1652
+ const raw = provided !== undefined && provided !== ''
1653
+ ? provided
1654
+ : secretDefaults[key] ?? '';
1655
+ envLines.push(`${key}=${raw.replace(/[\r\n]/g, '')}`);
1149
1656
  }
1150
1657
  const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
1151
1658
  const generatedNames = [];
@@ -1453,6 +1960,152 @@ class AppInstaller {
1453
1960
  return [];
1454
1961
  }
1455
1962
  }
1963
+ /**
1964
+ * Image references (repo:tag) an app's compose file declares, e.g.
1965
+ * "ghcr.io/x/monitor:1.1". Used to reclaim a superseded *pulled* tag on
1966
+ * update — a tag bump the image-ID reclaim misses, because a pulled image
1967
+ * carrying more than one repo tag cannot be removed by ID (issue #302).
1968
+ * Best-effort — returns [] on any error.
1969
+ */
1970
+ captureComposeImageRefs(appName, dir) {
1971
+ try {
1972
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--images'], dir, 15000);
1973
+ return [...new Set(stdout.trim().split('\n').map((s) => s.trim()).filter(Boolean))];
1974
+ }
1975
+ catch {
1976
+ return [];
1977
+ }
1978
+ }
1979
+ // ─── Docker housekeeping (issue #302) ───────────────────────────────────────
1980
+ /** Resolve the housekeeping toggles against their conservative defaults. */
1981
+ resolveHousekeeping() {
1982
+ const hk = this.housekeepingConfig ?? {};
1983
+ return {
1984
+ buildCachePrune: hk.buildCachePrune ?? true,
1985
+ buildCacheMaxAgeHours: hk.buildCacheMaxAgeHours ?? 168,
1986
+ danglingImagePrune: hk.danglingImagePrune ?? true,
1987
+ };
1988
+ }
1989
+ /**
1990
+ * Best-effort Docker housekeeping after a successful build (install + update).
1991
+ * Reclaims ONLY provably-unreferenced junk:
1992
+ * - build cache older than the configured window — time-filtered on purpose
1993
+ * so a concurrent build's fresh layers are never evicted;
1994
+ * - dangling `<none>` images with no container (safe by definition).
1995
+ * Gated by config (all toggles off ⇒ no prune calls). Every prune is wrapped
1996
+ * so a failure NEVER fails the parent install/update. Enforces the safety
1997
+ * floor: no `-a`, no `system prune`, no volume prune (issue #302).
1998
+ */
1999
+ pruneAfterBuild(job) {
2000
+ const hk = this.resolveHousekeeping();
2001
+ if (hk.buildCachePrune) {
2002
+ try {
2003
+ this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
2004
+ this.log(job, `Build cache pruned (older than ${hk.buildCacheMaxAgeHours}h)`);
2005
+ }
2006
+ catch (err) {
2007
+ this.log(job, `Build-cache prune skipped (non-fatal): ${err.message}`);
2008
+ }
2009
+ }
2010
+ if (hk.danglingImagePrune) {
2011
+ try {
2012
+ // `image prune -f` (NEVER `-a`) — removes only untagged <none> layers
2013
+ // with no container, so tagged images of other stopped apps survive.
2014
+ this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
2015
+ this.log(job, 'Dangling images pruned');
2016
+ }
2017
+ catch (err) {
2018
+ this.log(job, `Dangling-image prune skipped (non-fatal): ${err.message}`);
2019
+ }
2020
+ }
2021
+ }
2022
+ /**
2023
+ * Reclaim this app's own superseded pulled tags after an update (issue #302).
2024
+ * The image-ID reclaim misses a pulled-tag bump (e.g. monitor:1.1 → :1.2.0):
2025
+ * a pulled image with more than one repo tag can't be removed by ID. Remove
2026
+ * exactly the app's prior refs that the NEW stack no longer references.
2027
+ * `image rm <ref>` fails safely (and is caught) when a container still uses
2028
+ * the image, so an in-use image is never yanked. Only this app's own tags are
2029
+ * ever touched — never a blanket prune.
2030
+ */
2031
+ reclaimSupersededTags(job, oldImageRefs, newImageRefs) {
2032
+ const newRefSet = new Set(newImageRefs);
2033
+ for (const ref of oldImageRefs) {
2034
+ if (newRefSet.has(ref))
2035
+ continue; // still used by new stack — keep
2036
+ try {
2037
+ this.run(['docker', 'image', 'rm', ref], os.tmpdir(), 60000);
2038
+ this.log(job, `Reclaimed superseded image tag ${ref}`);
2039
+ }
2040
+ catch {
2041
+ /* in use / already gone — non-fatal */
2042
+ }
2043
+ }
2044
+ }
2045
+ /** Read-only reclaim report (issue #302). Best-effort; never mutates state. */
2046
+ housekeepingReport() {
2047
+ return this.buildHousekeepingReport();
2048
+ }
2049
+ /**
2050
+ * Execute the SAFE reclaim (build cache + dangling images only) and return a
2051
+ * fresh report. This is an explicit operator action, so it runs regardless of
2052
+ * the auto-path config toggles — but it still honors the fixed safety floor:
2053
+ * never `-a`, never `system prune`, never a volume/auto delete. The build-cache
2054
+ * window uses the configured value (default 168h).
2055
+ */
2056
+ housekeepingPrune() {
2057
+ const hk = this.resolveHousekeeping();
2058
+ const pruned = { buildCache: false, danglingImages: false };
2059
+ try {
2060
+ this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
2061
+ pruned.buildCache = true;
2062
+ }
2063
+ catch {
2064
+ /* best-effort */
2065
+ }
2066
+ try {
2067
+ this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
2068
+ pruned.danglingImages = true;
2069
+ }
2070
+ catch {
2071
+ /* best-effort */
2072
+ }
2073
+ return { mode: 'prune', pruned, report: this.buildHousekeepingReport() };
2074
+ }
2075
+ buildHousekeepingReport() {
2076
+ return {
2077
+ buildCacheReclaimable: this.readBuildCacheReclaimable(),
2078
+ danglingImageCount: this.splitLines(this.safeRunStdout(['docker', 'image', 'ls', '--filter', 'dangling=true', '--quiet'])).length,
2079
+ orphanVolumes: this.splitLines(this.safeRunStdout(['docker', 'volume', 'ls', '--filter', 'dangling=true', '--quiet'])),
2080
+ };
2081
+ }
2082
+ /** `docker` stdout, or '' on any error (report helpers must never throw). */
2083
+ safeRunStdout(args, timeoutMs = 30000) {
2084
+ try {
2085
+ return this.run(args, os.tmpdir(), timeoutMs).stdout;
2086
+ }
2087
+ catch {
2088
+ return '';
2089
+ }
2090
+ }
2091
+ splitLines(s) {
2092
+ return s.trim().split('\n').map((x) => x.trim()).filter(Boolean);
2093
+ }
2094
+ /** Reclaimable build-cache size from the `docker system df` "Build Cache" row. */
2095
+ readBuildCacheReclaimable() {
2096
+ const out = this.safeRunStdout([
2097
+ 'docker', 'system', 'df', '--format', '{{.Type}}\t{{.Reclaimable}}',
2098
+ ]);
2099
+ for (const line of this.splitLines(out)) {
2100
+ const tab = line.indexOf('\t');
2101
+ if (tab < 0)
2102
+ continue;
2103
+ const type = line.slice(0, tab).trim().toLowerCase();
2104
+ if (type.includes('build cache'))
2105
+ return line.slice(tab + 1).trim();
2106
+ }
2107
+ return '';
2108
+ }
1456
2109
  run(args, cwd, timeoutMs = 30000) {
1457
2110
  const opts = {
1458
2111
  encoding: 'utf-8',