@0xmaxma/claude-gateway 1.6.4 → 1.6.6

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.
@@ -42,8 +42,29 @@ const os = __importStar(require("node:os"));
42
42
  const crypto = __importStar(require("node:crypto"));
43
43
  const node_child_process_1 = require("node:child_process");
44
44
  const compose_generator_1 = require("./compose-generator");
45
+ const cleanup_1 = require("../history/cleanup");
45
46
  // ─── Constants ────────────────────────────────────────────────────────────────
46
47
  const DEFAULT_APPS_DIR = path.join(os.homedir(), '.claude-gateway', 'apps');
48
+ // Backups live under `<appsDir>/.backups/<app>/`. A dot-prefixed dir here is
49
+ // never mistaken for an installed app (the registry is driven by `apps.json`,
50
+ // not by enumerating the apps directory), and — being a sibling of each app's
51
+ // own dir rather than inside it — the archive survives that app's uninstall,
52
+ // which removes only `<appsDir>/<app>/`.
53
+ const APP_BACKUPS_DIRNAME = '.backups';
54
+ // Default per-app backup ceiling when config omits it. The N most recent are
55
+ // kept; older archives are pruned after each successful backup and by the daily
56
+ // scheduler.
57
+ const DEFAULT_BACKUP_RETENTION = 3;
58
+ // Default age ceiling (days) when config omits it. Backups older than this are
59
+ // pruned regardless of count. 0 disables age pruning.
60
+ const DEFAULT_BACKUP_MAX_AGE_DAYS = 30;
61
+ // Wall-clock ceiling for a single helper-container tar (backup or restore) of
62
+ // one volume. A few hundred MB tars in seconds; this only bounds a pathological
63
+ // hang so a stuck helper never wedges a backup job forever.
64
+ const VOLUME_TAR_TIMEOUT_MS = 300000;
65
+ // OCI image used for the throwaway tar helper. Small, ubiquitous, already a
66
+ // transitive dependency of most stacks, so it is almost always cache-warm.
67
+ const BACKUP_HELPER_IMAGE = 'alpine';
47
68
  // Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
48
69
  // the background (non-blocking), so this only bounds how long a hung container
49
70
  // keeps its child process alive — not the gateway's responsiveness. Shorter than
@@ -55,21 +76,57 @@ const RESTORE_COMPOSE_TIMEOUT_MS = 180000;
55
76
  const RESTORE_MAX_CONCURRENCY = 4;
56
77
  const COMMIT_RE = /^[0-9a-f]{40}$/;
57
78
  const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
79
+ /**
80
+ * Validate an IANA timezone from config before it reaches `Intl.DateTimeFormat`.
81
+ * An invalid string would otherwise throw a RangeError inside the daily-cleanup
82
+ * scheduler at boot — config is untrusted input, so a typo must degrade to UTC,
83
+ * never crash the gateway.
84
+ */
85
+ function isValidTimezone(tz) {
86
+ if (typeof tz !== 'string' || tz.length === 0)
87
+ return false;
88
+ try {
89
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
58
96
  // Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
59
97
  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
98
  // ─── Installer ────────────────────────────────────────────────────────────────
61
99
  class AppInstaller {
62
- constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn) {
100
+ constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
63
101
  this.registry = registry;
64
102
  this.registryClient = registryClient;
65
103
  this.callbacks = callbacks;
66
104
  this.spawn = spawn;
67
105
  this.agentManager = agentManager;
68
106
  this.spawnAsync = spawnAsync;
107
+ this.housekeepingConfig = housekeepingConfig;
69
108
  this.jobs = new Map();
70
109
  /** Tracks app names currently being installed to prevent concurrent installs of the same name. */
71
110
  this.installingNames = new Set();
72
111
  this.appsDir = appsDir ?? DEFAULT_APPS_DIR;
112
+ this.backupsDir = backupsDir ?? path.join(this.appsDir, APP_BACKUPS_DIRNAME);
113
+ this.appBackupConfig = {
114
+ retention: appBackupConfig?.retention !== undefined && appBackupConfig.retention >= 0
115
+ ? Math.floor(appBackupConfig.retention)
116
+ : DEFAULT_BACKUP_RETENTION,
117
+ maxAgeDays: appBackupConfig?.maxAgeDays !== undefined && appBackupConfig.maxAgeDays >= 0
118
+ ? Math.floor(appBackupConfig.maxAgeDays)
119
+ : DEFAULT_BACKUP_MAX_AGE_DAYS,
120
+ cleanupHour: appBackupConfig?.cleanupHour !== undefined &&
121
+ Number.isInteger(appBackupConfig.cleanupHour) &&
122
+ appBackupConfig.cleanupHour >= 0 &&
123
+ appBackupConfig.cleanupHour <= 23
124
+ ? appBackupConfig.cleanupHour
125
+ : 0,
126
+ cleanupTimezone: isValidTimezone(appBackupConfig?.cleanupTimezone) ? appBackupConfig.cleanupTimezone : 'UTC',
127
+ autoBackupBeforeUninstall: appBackupConfig?.autoBackupBeforeUninstall ?? true,
128
+ autoBackupBeforeUpdate: appBackupConfig?.autoBackupBeforeUpdate ?? true,
129
+ };
73
130
  }
74
131
  // ─── Public API ───────────────────────────────────────────────────────────
75
132
  /** Start an async install job. Returns jobId immediately. */
@@ -161,6 +218,7 @@ class AppInstaller {
161
218
  commit,
162
219
  secretKeys: generated.secretKeys,
163
220
  generatedKeys: generated.generatedKeys,
221
+ secretDefaults: generated.secretDefaults,
164
222
  ports: generated.ports,
165
223
  agentDeclaration: generated.agentDeclaration,
166
224
  warnings: generated.warnings,
@@ -254,6 +312,17 @@ class AppInstaller {
254
312
  return;
255
313
  }
256
314
  const appDir = entry.installPath;
315
+ // Safety hook: snapshot the app's data before tearing it down, so an
316
+ // accidental or regretted uninstall has a restore point. Best-effort — a
317
+ // backup failure must never block the uninstall the operator asked for.
318
+ if (this.appBackupConfig.autoBackupBeforeUninstall && fs.existsSync(appDir)) {
319
+ try {
320
+ await this.performBackup(entry);
321
+ }
322
+ catch (err) {
323
+ console.warn(`[apps] auto-backup before uninstall of "${appName}" failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
324
+ }
325
+ }
257
326
  // docker compose down --rmi all (graceful fallback if dir is already gone)
258
327
  if (fs.existsSync(appDir)) {
259
328
  try {
@@ -291,6 +360,510 @@ class AppInstaller {
291
360
  }
292
361
  await this.registry.remove(appName);
293
362
  }
363
+ // ─── Backup / Restore ───────────────────────────────────────────────────────
364
+ /**
365
+ * Start an async backup job. Returns jobId immediately; poll {@link getJob}.
366
+ * A backup is a permission-safe snapshot of the app's Docker named volumes +
367
+ * config (`.env`/`app.yaml`/compose) into a single archive under
368
+ * `<backupsDir>/<app>/`. The app is stopped for the snapshot and restarted
369
+ * afterwards (see {@link performBackup}).
370
+ */
371
+ backup(appName) {
372
+ this.pruneOldJobs();
373
+ if (this.installingNames.has(appName)) {
374
+ throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
375
+ }
376
+ this.installingNames.add(appName);
377
+ const jobId = crypto.randomUUID();
378
+ const job = {
379
+ id: jobId,
380
+ status: 'pending',
381
+ logs: [],
382
+ startedAt: Date.now(),
383
+ updatedAt: Date.now(),
384
+ };
385
+ this.jobs.set(jobId, job);
386
+ void this.runBackup(job, appName)
387
+ .catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
388
+ .finally(() => this.installingNames.delete(appName));
389
+ return jobId;
390
+ }
391
+ /**
392
+ * Start an async restore job. Returns jobId immediately; poll {@link getJob}.
393
+ * Restores the app's volumes + config from a prior backup, then starts it.
394
+ */
395
+ restore(appName, backupId) {
396
+ this.pruneOldJobs();
397
+ if (this.installingNames.has(appName)) {
398
+ throw new Error(`App "${appName}" is busy (install/update/backup in progress)`);
399
+ }
400
+ this.installingNames.add(appName);
401
+ const jobId = crypto.randomUUID();
402
+ const job = {
403
+ id: jobId,
404
+ status: 'pending',
405
+ logs: [],
406
+ startedAt: Date.now(),
407
+ updatedAt: Date.now(),
408
+ };
409
+ this.jobs.set(jobId, job);
410
+ void this.runRestore(job, appName, backupId)
411
+ .catch((err) => this.failJob(job, err instanceof Error ? err.message : String(err)))
412
+ .finally(() => this.installingNames.delete(appName));
413
+ return jobId;
414
+ }
415
+ /** List an app's backups, newest first. Reads the sidecar manifests. */
416
+ listBackups(appName) {
417
+ const dir = this.appBackupDir(appName);
418
+ if (!fs.existsSync(dir))
419
+ return [];
420
+ const infos = [];
421
+ for (const file of fs.readdirSync(dir)) {
422
+ if (!file.endsWith('.json'))
423
+ continue;
424
+ try {
425
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
426
+ // Only surface a backup whose archive is actually present.
427
+ if (!fs.existsSync(path.join(dir, `${meta.id}.tar.gz`)))
428
+ continue;
429
+ infos.push({
430
+ id: meta.id,
431
+ createdAt: meta.createdAt,
432
+ sizeBytes: meta.sizeBytes,
433
+ appVersion: meta.appVersion,
434
+ });
435
+ }
436
+ catch {
437
+ /* skip malformed manifest */
438
+ }
439
+ }
440
+ return infos.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
441
+ }
442
+ /** Delete one backup (archive + sidecar). Idempotent. */
443
+ deleteBackup(appName, backupId) {
444
+ if (!/^[\w.-]+$/.test(backupId))
445
+ throw new Error(`Invalid backup id "${backupId}"`);
446
+ const dir = this.appBackupDir(appName);
447
+ for (const ext of ['.tar.gz', '.json']) {
448
+ const p = path.join(dir, `${backupId}${ext}`);
449
+ try {
450
+ fs.rmSync(p, { force: true });
451
+ }
452
+ catch {
453
+ /* best-effort */
454
+ }
455
+ }
456
+ }
457
+ async runBackup(job, appName) {
458
+ job.status = 'running';
459
+ const entry = await this.registry.get(appName);
460
+ if (!entry)
461
+ throw new Error(`App "${appName}" is not installed`);
462
+ const info = await this.performBackup(entry, (m) => this.log(job, m));
463
+ job.backup = info;
464
+ job.status = 'completed';
465
+ job.updatedAt = Date.now();
466
+ }
467
+ async runRestore(job, appName, backupId) {
468
+ job.status = 'running';
469
+ const entry = await this.registry.get(appName);
470
+ if (!entry)
471
+ throw new Error(`App "${appName}" is not installed`);
472
+ const info = await this.performRestore(entry, backupId, (m) => this.log(job, m));
473
+ job.backup = info;
474
+ job.status = 'completed';
475
+ job.updatedAt = Date.now();
476
+ }
477
+ /**
478
+ * Core backup routine (shared by the job path and the auto-backup hooks).
479
+ *
480
+ * Consistency: a running app is stopped for the snapshot so no writes land
481
+ * mid-tar, then ALWAYS restarted in a `finally` — a backup that throws never
482
+ * leaves the app stuck stopped.
483
+ *
484
+ * Permission safety: each named volume is tarred inside a throwaway root
485
+ * container (`docker run … tar czf`), so uid/gid/mode are preserved in the
486
+ * archive and no host `cp`/`chown`/sudo ever touches the volume data.
487
+ */
488
+ async performBackup(entry, logSink) {
489
+ const log = (m) => logSink?.(m);
490
+ const appName = entry.name;
491
+ const appDir = entry.installPath;
492
+ if (!fs.existsSync(appDir)) {
493
+ throw new Error(`App "${appName}" directory is gone — cannot back up`);
494
+ }
495
+ const volumes = this.discoverVolumes(appName, appDir);
496
+ const bindMounts = this.discoverBindMounts(appName, appDir);
497
+ const wasRunning = (await this.queryRuntimeStatus(entry)) === 'running';
498
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `bkp-${appName}-`));
499
+ try {
500
+ if (wasRunning) {
501
+ log(`Stopping "${appName}" for a consistent snapshot`);
502
+ this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
503
+ }
504
+ const volDir = path.join(staging, 'volumes');
505
+ fs.mkdirSync(volDir, { recursive: true });
506
+ for (const vol of volumes) {
507
+ log(`Archiving volume "${vol}"`);
508
+ this.run([
509
+ 'docker', 'run', '--rm',
510
+ '-v', `${appName}_${vol}:/data:ro`,
511
+ '-v', `${volDir}:/backup`,
512
+ BACKUP_HELPER_IMAGE,
513
+ 'tar', 'czf', `/backup/${vol}.tar.gz`, '-C', '/data', '.',
514
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
515
+ }
516
+ // Bind-mount data dirs under the app dir (not Docker named volumes).
517
+ // Archive each with the same root-helper tar so ownership (e.g. the
518
+ // postgres uid) survives without host-side cp/sudo. Indexed filenames
519
+ // avoid collisions between paths that flatten to the same name.
520
+ const bindDir = path.join(staging, 'binds');
521
+ fs.mkdirSync(bindDir, { recursive: true });
522
+ const capturedBinds = [];
523
+ for (const rel of bindMounts) {
524
+ const abs = path.join(appDir, rel);
525
+ if (!fs.existsSync(abs)) {
526
+ log(`Bind mount "${rel}" missing on disk — skipping`);
527
+ continue;
528
+ }
529
+ log(`Archiving bind mount "${rel}"`);
530
+ this.run([
531
+ 'docker', 'run', '--rm',
532
+ '-v', `${abs}:/data:ro`,
533
+ '-v', `${bindDir}:/backup`,
534
+ BACKUP_HELPER_IMAGE,
535
+ 'tar', 'czf', `/backup/bind-${capturedBinds.length}.tar.gz`, '-C', '/data', '.',
536
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
537
+ capturedBinds.push(rel);
538
+ }
539
+ // Config (owned by the gateway user, copied on the host).
540
+ const cfgDir = path.join(staging, 'config');
541
+ fs.mkdirSync(cfgDir, { recursive: true });
542
+ for (const f of ['.env', 'app.yaml', 'docker-compose.yml']) {
543
+ this.copyIfExists(path.join(appDir, f), path.join(cfgDir, f));
544
+ }
545
+ const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto
546
+ .randomUUID()
547
+ .slice(0, 8)}`;
548
+ const meta = {
549
+ id,
550
+ appName,
551
+ appVersion: entry.version,
552
+ createdAt: new Date().toISOString(),
553
+ volumes,
554
+ bindMounts: capturedBinds,
555
+ sizeBytes: 0,
556
+ };
557
+ fs.writeFileSync(path.join(staging, 'metadata.json'), JSON.stringify(meta, null, 2));
558
+ const outDir = this.appBackupDir(appName);
559
+ fs.mkdirSync(outDir, { recursive: true });
560
+ const archivePath = path.join(outDir, `${id}.tar.gz`);
561
+ // The gateway host tars the staging tree. Volume tarballs written by the
562
+ // root helper are world-readable (0644), so this read succeeds without sudo.
563
+ this.run(['tar', 'czf', archivePath, '-C', staging, '.'], undefined, VOLUME_TAR_TIMEOUT_MS);
564
+ let sizeBytes = 0;
565
+ try {
566
+ sizeBytes = fs.statSync(archivePath).size;
567
+ }
568
+ catch {
569
+ /* archive stat failed — leave size 0 */
570
+ }
571
+ meta.sizeBytes = sizeBytes;
572
+ fs.writeFileSync(path.join(outDir, `${id}.json`), JSON.stringify(meta, null, 2));
573
+ this.pruneBackups(appName);
574
+ log(`Backup "${id}" complete (${sizeBytes} bytes, ${volumes.length} volume(s), ` +
575
+ `${capturedBinds.length} bind mount(s))`);
576
+ return { id, createdAt: meta.createdAt, sizeBytes, appVersion: meta.appVersion };
577
+ }
578
+ finally {
579
+ try {
580
+ this.rmrf(staging);
581
+ }
582
+ catch {
583
+ /* best-effort staging cleanup */
584
+ }
585
+ if (wasRunning) {
586
+ try {
587
+ log(`Restarting "${appName}"`);
588
+ this.composeUp(appName, appDir);
589
+ }
590
+ catch (restartErr) {
591
+ log(`WARNING: failed to restart "${appName}" after backup: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`);
592
+ }
593
+ }
594
+ }
595
+ }
596
+ /**
597
+ * Core restore routine. Extracts the archive, wipes+repopulates each volume
598
+ * via the root helper (preserving inner ownership), restores `.env`, and
599
+ * starts the app on the restored data. Restoring across a differing
600
+ * appVersion is allowed but warns (possible schema/migration mismatch).
601
+ */
602
+ async performRestore(entry, backupId, logSink) {
603
+ const log = (m) => logSink?.(m);
604
+ if (!/^[\w.-]+$/.test(backupId))
605
+ throw new Error(`Invalid backup id "${backupId}"`);
606
+ const appName = entry.name;
607
+ const appDir = entry.installPath;
608
+ const archivePath = path.join(this.appBackupDir(appName), `${backupId}.tar.gz`);
609
+ if (!fs.existsSync(archivePath)) {
610
+ throw new Error(`Backup "${backupId}" not found for app "${appName}"`);
611
+ }
612
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), `rst-${appName}-`));
613
+ try {
614
+ this.run(['tar', 'xzf', archivePath, '-C', staging], undefined, VOLUME_TAR_TIMEOUT_MS);
615
+ const meta = JSON.parse(fs.readFileSync(path.join(staging, 'metadata.json'), 'utf-8'));
616
+ if (meta.appVersion !== entry.version) {
617
+ log(`WARNING: restoring backup from version "${meta.appVersion}" onto installed "${entry.version}" — possible schema/migration mismatch`);
618
+ }
619
+ log(`Stopping "${appName}" before restore`);
620
+ try {
621
+ this.run(['docker', 'compose', '-p', appName, 'stop'], appDir, 120000);
622
+ }
623
+ catch {
624
+ /* may already be stopped */
625
+ }
626
+ const volDir = path.join(staging, 'volumes');
627
+ for (const vol of meta.volumes) {
628
+ const tarball = path.join(volDir, `${vol}.tar.gz`);
629
+ if (!fs.existsSync(tarball)) {
630
+ log(`WARNING: volume "${vol}" missing from backup — skipping`);
631
+ continue;
632
+ }
633
+ log(`Restoring volume "${vol}"`);
634
+ this.run([
635
+ 'docker', 'run', '--rm',
636
+ '-v', `${appName}_${vol}:/data`,
637
+ '-v', `${volDir}:/backup`,
638
+ BACKUP_HELPER_IMAGE,
639
+ 'sh', '-c',
640
+ // Wipe the live volume, then untar the snapshot (uid/gid preserved).
641
+ `rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/${vol}.tar.gz -C /data`,
642
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
643
+ }
644
+ // Restore bind-mount data dirs under the app dir. Indexed filenames match
645
+ // performBackup's capture order. Each restored path is re-checked to stay
646
+ // under the app dir (defense-in-depth against a tampered metadata path).
647
+ const bindDir = path.join(staging, 'binds');
648
+ const bindMounts = meta.bindMounts ?? [];
649
+ bindMounts.forEach((rel, i) => {
650
+ const abs = path.resolve(appDir, rel);
651
+ const base = appDir.endsWith(path.sep) ? appDir : appDir + path.sep;
652
+ if (abs !== appDir && !abs.startsWith(base)) {
653
+ log(`WARNING: bind mount "${rel}" escapes the app dir — skipping`);
654
+ return;
655
+ }
656
+ const tarball = path.join(bindDir, `bind-${i}.tar.gz`);
657
+ if (!fs.existsSync(tarball)) {
658
+ log(`WARNING: bind mount "${rel}" missing from backup — skipping`);
659
+ return;
660
+ }
661
+ log(`Restoring bind mount "${rel}"`);
662
+ this.run([
663
+ 'docker', 'run', '--rm',
664
+ '-v', `${abs}:/data`,
665
+ '-v', `${bindDir}:/backup`,
666
+ BACKUP_HELPER_IMAGE,
667
+ 'sh', '-c',
668
+ // Wipe the live bind dir, then untar the snapshot (uid/gid preserved).
669
+ `rm -rf /data/* /data/..?* 2>/dev/null; tar xzf /backup/bind-${i}.tar.gz -C /data`,
670
+ ], undefined, VOLUME_TAR_TIMEOUT_MS);
671
+ });
672
+ // Restore config that carries generated secrets, so the app boots with the
673
+ // same credentials the volume data was created under.
674
+ this.copyIfExists(path.join(staging, 'config', '.env'), path.join(appDir, '.env'));
675
+ log(`Starting "${appName}" on restored data`);
676
+ this.composeUp(appName, appDir);
677
+ await this.registry.updateStatus(appName, 'running').catch(() => { });
678
+ log(`Restore of "${backupId}" complete`);
679
+ return {
680
+ id: meta.id,
681
+ createdAt: meta.createdAt,
682
+ sizeBytes: meta.sizeBytes,
683
+ appVersion: meta.appVersion,
684
+ };
685
+ }
686
+ finally {
687
+ try {
688
+ this.rmrf(staging);
689
+ }
690
+ catch {
691
+ /* best-effort */
692
+ }
693
+ }
694
+ }
695
+ /**
696
+ * List an app's compose-level named volumes (the top-level `volumes:` keys).
697
+ * Returns [] when the app declares none or the query fails — a config-only
698
+ * backup is still useful (it captures `.env`).
699
+ */
700
+ discoverVolumes(appName, appDir) {
701
+ try {
702
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--volumes'], appDir, 30000);
703
+ return stdout
704
+ .split('\n')
705
+ .map((s) => s.trim())
706
+ .filter((s) => s.length > 0);
707
+ }
708
+ catch {
709
+ return [];
710
+ }
711
+ }
712
+ /**
713
+ * Discover bind-mount source directories that live **under the app dir**
714
+ * (e.g. `./data/photos` → `data/photos`). These hold app-owned data that is
715
+ * not a Docker named volume, so {@link discoverVolumes} never reports them —
716
+ * yet they are deleted on uninstall and must be captured in a backup.
717
+ *
718
+ * Returns app-dir-relative POSIX paths, deduped and sorted. Bind mounts whose
719
+ * source resolves outside the app dir (shared host resources such as a
720
+ * read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
721
+ * returns `[]` on any failure, mirroring {@link discoverVolumes}.
722
+ */
723
+ discoverBindMounts(appName, appDir) {
724
+ try {
725
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
726
+ const parsed = JSON.parse(stdout);
727
+ // Local-dev installs symlink the app dir into appsDir, and the generated
728
+ // compose resolves bind sources against the symlink's *realpath*. Match a
729
+ // source that sits under either the symlink path or its target, so those
730
+ // bind mounts are not wrongly excluded.
731
+ const bases = [appDir];
732
+ try {
733
+ const real = fs.realpathSync(appDir);
734
+ if (real !== appDir)
735
+ bases.push(real);
736
+ }
737
+ catch {
738
+ /* app dir unreadable — fall back to the literal path */
739
+ }
740
+ const rels = new Set();
741
+ for (const svc of Object.values(parsed.services ?? {})) {
742
+ for (const vol of svc.volumes ?? []) {
743
+ if (vol.type !== 'bind' || typeof vol.source !== 'string')
744
+ continue;
745
+ const abs = path.resolve(appDir, vol.source);
746
+ const matched = bases.find((b) => abs === b || abs.startsWith(b + path.sep));
747
+ if (!matched)
748
+ continue; // outside the app dir
749
+ const rel = path.relative(matched, abs);
750
+ if (rel.length > 0)
751
+ rels.add(rel.split(path.sep).join('/'));
752
+ }
753
+ }
754
+ return Array.from(rels).sort();
755
+ }
756
+ catch {
757
+ return [];
758
+ }
759
+ }
760
+ /**
761
+ * Prune an app's backups by the union policy (issue #310): a backup is
762
+ * deleted when it is beyond the retention count **OR** older than
763
+ * `maxAgeDays` — whichever matches. `retention === 0` disables the count cap;
764
+ * `maxAgeDays === 0` disables the age cap. Runs after each successful backup
765
+ * and from the daily scheduler.
766
+ */
767
+ pruneBackups(appName, now = Date.now()) {
768
+ const { retention, maxAgeDays } = this.appBackupConfig;
769
+ if (retention <= 0 && maxAgeDays <= 0)
770
+ return; // both caps disabled
771
+ const all = this.listBackups(appName); // newest first
772
+ const doomed = new Set();
773
+ // Count cap: everything past the N newest.
774
+ if (retention > 0) {
775
+ for (const stale of all.slice(retention))
776
+ doomed.add(stale.id);
777
+ }
778
+ // Age cap: anything older than the cutoff (union — dedupe via the set).
779
+ if (maxAgeDays > 0) {
780
+ const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
781
+ for (const b of all) {
782
+ const created = Date.parse(b.createdAt);
783
+ if (!Number.isNaN(created) && created < cutoff)
784
+ doomed.add(b.id);
785
+ }
786
+ }
787
+ for (const id of doomed)
788
+ this.deleteBackup(appName, id);
789
+ }
790
+ /**
791
+ * Prune **every** app's backups by the union policy. Enumerates the backup
792
+ * subdirs under `.backups/` (skipping anything that is not a valid app name),
793
+ * so it also reaches apps that are no longer being backed up. Best-effort:
794
+ * a failure on one app never aborts the sweep.
795
+ */
796
+ cleanupAllBackups(now = Date.now()) {
797
+ const { retention, maxAgeDays } = this.appBackupConfig;
798
+ if (retention <= 0 && maxAgeDays <= 0)
799
+ return; // both caps disabled
800
+ let entries;
801
+ try {
802
+ entries = fs.readdirSync(this.backupsDir, { withFileTypes: true });
803
+ }
804
+ catch {
805
+ return; // no backups dir yet — nothing to prune
806
+ }
807
+ for (const entry of entries) {
808
+ if (!entry.isDirectory())
809
+ continue;
810
+ if (!APP_NAME_RE.test(entry.name))
811
+ continue; // skip stray dirs
812
+ try {
813
+ this.pruneBackups(entry.name, now);
814
+ }
815
+ catch {
816
+ /* best-effort — one bad app must not abort the sweep */
817
+ }
818
+ }
819
+ }
820
+ /**
821
+ * Start the daily backup-cleanup scheduler (issue #310). Fires once per day at
822
+ * `cleanupHour` in `cleanupTimezone`, pruning all apps by the union policy.
823
+ * Returns a cancel function. No-op (returns a noop canceller) when both caps
824
+ * are disabled. The timer is `unref`'d so it never holds the event loop open.
825
+ */
826
+ startBackupCleanup() {
827
+ const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
828
+ if (retention <= 0 && maxAgeDays <= 0)
829
+ return () => { };
830
+ let timer;
831
+ const schedule = () => {
832
+ const delay = (0, cleanup_1.msUntilNextHour)(cleanupHour, cleanupTimezone);
833
+ timer = setTimeout(() => {
834
+ try {
835
+ this.cleanupAllBackups();
836
+ }
837
+ catch {
838
+ /* best-effort */
839
+ }
840
+ schedule(); // reschedule for the next day
841
+ }, delay);
842
+ if (typeof timer.unref === 'function') {
843
+ timer.unref();
844
+ }
845
+ };
846
+ schedule();
847
+ return () => clearTimeout(timer);
848
+ }
849
+ appBackupDir(appName) {
850
+ // Never trust the name reaching the filesystem: an unvalidated value (e.g.
851
+ // a `%2F`-smuggled `../../x` from a route param) would let path.join escape
852
+ // the backups tree. Every backup op funnels through here, so this one guard
853
+ // covers backup/restore/list/delete.
854
+ if (!APP_NAME_RE.test(appName))
855
+ throw new Error(`Invalid app name "${appName}"`);
856
+ return path.join(this.backupsDir, appName);
857
+ }
858
+ copyIfExists(src, dest) {
859
+ try {
860
+ if (fs.existsSync(src))
861
+ fs.copyFileSync(src, dest);
862
+ }
863
+ catch {
864
+ /* best-effort — a missing/unreadable optional file is not fatal */
865
+ }
866
+ }
294
867
  async startStopRestart(appName, action) {
295
868
  const entry = await this.registry.get(appName);
296
869
  if (!entry)
@@ -570,8 +1143,21 @@ class AppInstaller {
570
1143
  this.log(job, `Warning: ${w}`);
571
1144
  }
572
1145
  // ── Write .env ────────────────────────────────────────────────────────
1146
+ // Carry over an existing .env before writing, but ONLY for a local
1147
+ // (symlinked) install. A local install can re-point at a source tree that
1148
+ // still holds a prior .env alongside persisted data (e.g. a postgres pgdata
1149
+ // bind mount). writeEnvFile treats an already-present generated secret as
1150
+ // pinned, so reusing the existing .env keeps DB_PASSWORD/etc. stable and
1151
+ // lets the app reconnect to that data instead of failing auth (mirrors the
1152
+ // reconfigure path). This is deliberately scoped to `source === 'local'`:
1153
+ // a registry/GitHub install checks out into appDir, and a repo that
1154
+ // committed a `.env` would otherwise get its secrets pinned to committed
1155
+ // values — so for those sources we ignore any checked-out .env and generate
1156
+ // fresh, unchanged from before. Operator-supplied envVars still win.
573
1157
  this.log(job, 'Writing .env');
574
- const generatedNames = this.writeEnvFile(appDir, appName, generated, options.envVars ?? {});
1158
+ const existingEnv = source === 'local' ? this.readEnvFile(appDir) : {};
1159
+ const mergedEnv = { ...existingEnv, ...(options.envVars ?? {}) };
1160
+ const generatedNames = this.writeEnvFile(appDir, appName, generated, mergedEnv);
575
1161
  if (generatedNames.length > 0) {
576
1162
  this.log(job, `Generated secrets: ${generatedNames.join(', ')}`);
577
1163
  }
@@ -665,6 +1251,9 @@ class AppInstaller {
665
1251
  // ── Update status to running ──────────────────────────────────────────
666
1252
  await this.registry.updateStatus(appName, 'running');
667
1253
  this.log(job, 'Containers healthy');
1254
+ // ── Housekeeping: reclaim leaked build cache + dangling images ────────
1255
+ // Best-effort, config-gated, after the new stack is up (issue #302).
1256
+ this.pruneAfterBuild(job);
668
1257
  // ── Register proxy routes ─────────────────────────────────────────────
669
1258
  this.callbacks.registerRoutes(appName, generated.ports);
670
1259
  // ── Build result ──────────────────────────────────────────────────────
@@ -743,6 +1332,17 @@ class AppInstaller {
743
1332
  return;
744
1333
  }
745
1334
  this.log(job, `Updating ${appName} ${entry.commit.slice(0, 8)} → ${target.newCommit.slice(0, 8)}`);
1335
+ // Safety hook: snapshot before the update so a bad new image can be rolled
1336
+ // back. Best-effort — a backup failure must not block the update.
1337
+ if (this.appBackupConfig.autoBackupBeforeUpdate) {
1338
+ try {
1339
+ const info = await this.performBackup(entry, (m) => this.log(job, m));
1340
+ this.log(job, `Pre-update backup "${info.id}" created`);
1341
+ }
1342
+ catch (err) {
1343
+ this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1344
+ }
1345
+ }
746
1346
  const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
747
1347
  try {
748
1348
  // ── Shallow fetch of specific commit into tmp dir ─────────────────────
@@ -802,6 +1402,9 @@ class AppInstaller {
802
1402
  // we can reclaim exactly those images after the update — without a
803
1403
  // `compose down` that would collide with the new stack (issue #283).
804
1404
  const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
1405
+ // Also capture the app's declared image refs (repo:tag) so a superseded
1406
+ // *pulled* tag can be reclaimed after the update (issue #302).
1407
+ const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
805
1408
  // ── Bring old containers down (keeps images for rollback) ─────────────
806
1409
  this.log(job, 'Stopping old containers');
807
1410
  this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
@@ -840,6 +1443,7 @@ class AppInstaller {
840
1443
  // below never removes an image the new containers depend on (e.g. when the
841
1444
  // old and new versions happen to share a base/image).
842
1445
  const newImageIds = this.captureComposeImageIds(appName, tmpDir);
1446
+ const newImageRefs = this.captureComposeImageRefs(appName, tmpDir);
843
1447
  // ── Swap dirs ─────────────────────────────────────────────────────────
844
1448
  // Swap in place at the recorded install path — NOT path.join(appsDir, appName).
845
1449
  // For legacy installs the on-disk dir is named after the source repo/URL
@@ -892,7 +1496,14 @@ class AppInstaller {
892
1496
  }
893
1497
  catch { /* still in use or already gone — non-fatal */ }
894
1498
  }
1499
+ // Reclaim a superseded *pulled* tag the image-ID reclaim above can't
1500
+ // (a pulled image with >1 repo tag isn't removable by ID) — only this
1501
+ // app's own prior refs the new stack no longer uses (issue #302).
1502
+ this.reclaimSupersededTags(job, oldImageRefs, newImageRefs);
895
1503
  this.safeRmrf(oldBackupDir, job, 'old backup dir');
1504
+ // ── Housekeeping: reclaim leaked build cache + dangling images ────────
1505
+ // Best-effort, config-gated, after the image reclaim (issue #302).
1506
+ this.pruneAfterBuild(job);
896
1507
  // ── Build result ──────────────────────────────────────────────────────
897
1508
  const proxyUrls = {};
898
1509
  for (const p of generated.ports) {
@@ -1143,9 +1754,16 @@ class AppInstaller {
1143
1754
  }
1144
1755
  }
1145
1756
  const envLines = [];
1757
+ const secretDefaults = generated.secretDefaults ?? {};
1146
1758
  for (const key of generated.secretKeys) {
1147
- const val = (merged[key] ?? '').replace(/[\r\n]/g, '');
1148
- envLines.push(`${key}=${val}`);
1759
+ // Precedence: operator-supplied value declared default → empty. An
1760
+ // empty operator value (UI always sends the field, possibly blank) falls
1761
+ // through to the default, matching how generated keys treat '' as unset.
1762
+ const provided = merged[key];
1763
+ const raw = provided !== undefined && provided !== ''
1764
+ ? provided
1765
+ : secretDefaults[key] ?? '';
1766
+ envLines.push(`${key}=${raw.replace(/[\r\n]/g, '')}`);
1149
1767
  }
1150
1768
  const generatedKeySet = new Set(generated.generatedKeys.map((g) => g.key));
1151
1769
  const generatedNames = [];
@@ -1453,6 +2071,152 @@ class AppInstaller {
1453
2071
  return [];
1454
2072
  }
1455
2073
  }
2074
+ /**
2075
+ * Image references (repo:tag) an app's compose file declares, e.g.
2076
+ * "ghcr.io/x/monitor:1.1". Used to reclaim a superseded *pulled* tag on
2077
+ * update — a tag bump the image-ID reclaim misses, because a pulled image
2078
+ * carrying more than one repo tag cannot be removed by ID (issue #302).
2079
+ * Best-effort — returns [] on any error.
2080
+ */
2081
+ captureComposeImageRefs(appName, dir) {
2082
+ try {
2083
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--images'], dir, 15000);
2084
+ return [...new Set(stdout.trim().split('\n').map((s) => s.trim()).filter(Boolean))];
2085
+ }
2086
+ catch {
2087
+ return [];
2088
+ }
2089
+ }
2090
+ // ─── Docker housekeeping (issue #302) ───────────────────────────────────────
2091
+ /** Resolve the housekeeping toggles against their conservative defaults. */
2092
+ resolveHousekeeping() {
2093
+ const hk = this.housekeepingConfig ?? {};
2094
+ return {
2095
+ buildCachePrune: hk.buildCachePrune ?? true,
2096
+ buildCacheMaxAgeHours: hk.buildCacheMaxAgeHours ?? 168,
2097
+ danglingImagePrune: hk.danglingImagePrune ?? true,
2098
+ };
2099
+ }
2100
+ /**
2101
+ * Best-effort Docker housekeeping after a successful build (install + update).
2102
+ * Reclaims ONLY provably-unreferenced junk:
2103
+ * - build cache older than the configured window — time-filtered on purpose
2104
+ * so a concurrent build's fresh layers are never evicted;
2105
+ * - dangling `<none>` images with no container (safe by definition).
2106
+ * Gated by config (all toggles off ⇒ no prune calls). Every prune is wrapped
2107
+ * so a failure NEVER fails the parent install/update. Enforces the safety
2108
+ * floor: no `-a`, no `system prune`, no volume prune (issue #302).
2109
+ */
2110
+ pruneAfterBuild(job) {
2111
+ const hk = this.resolveHousekeeping();
2112
+ if (hk.buildCachePrune) {
2113
+ try {
2114
+ this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
2115
+ this.log(job, `Build cache pruned (older than ${hk.buildCacheMaxAgeHours}h)`);
2116
+ }
2117
+ catch (err) {
2118
+ this.log(job, `Build-cache prune skipped (non-fatal): ${err.message}`);
2119
+ }
2120
+ }
2121
+ if (hk.danglingImagePrune) {
2122
+ try {
2123
+ // `image prune -f` (NEVER `-a`) — removes only untagged <none> layers
2124
+ // with no container, so tagged images of other stopped apps survive.
2125
+ this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
2126
+ this.log(job, 'Dangling images pruned');
2127
+ }
2128
+ catch (err) {
2129
+ this.log(job, `Dangling-image prune skipped (non-fatal): ${err.message}`);
2130
+ }
2131
+ }
2132
+ }
2133
+ /**
2134
+ * Reclaim this app's own superseded pulled tags after an update (issue #302).
2135
+ * The image-ID reclaim misses a pulled-tag bump (e.g. monitor:1.1 → :1.2.0):
2136
+ * a pulled image with more than one repo tag can't be removed by ID. Remove
2137
+ * exactly the app's prior refs that the NEW stack no longer references.
2138
+ * `image rm <ref>` fails safely (and is caught) when a container still uses
2139
+ * the image, so an in-use image is never yanked. Only this app's own tags are
2140
+ * ever touched — never a blanket prune.
2141
+ */
2142
+ reclaimSupersededTags(job, oldImageRefs, newImageRefs) {
2143
+ const newRefSet = new Set(newImageRefs);
2144
+ for (const ref of oldImageRefs) {
2145
+ if (newRefSet.has(ref))
2146
+ continue; // still used by new stack — keep
2147
+ try {
2148
+ this.run(['docker', 'image', 'rm', ref], os.tmpdir(), 60000);
2149
+ this.log(job, `Reclaimed superseded image tag ${ref}`);
2150
+ }
2151
+ catch {
2152
+ /* in use / already gone — non-fatal */
2153
+ }
2154
+ }
2155
+ }
2156
+ /** Read-only reclaim report (issue #302). Best-effort; never mutates state. */
2157
+ housekeepingReport() {
2158
+ return this.buildHousekeepingReport();
2159
+ }
2160
+ /**
2161
+ * Execute the SAFE reclaim (build cache + dangling images only) and return a
2162
+ * fresh report. This is an explicit operator action, so it runs regardless of
2163
+ * the auto-path config toggles — but it still honors the fixed safety floor:
2164
+ * never `-a`, never `system prune`, never a volume/auto delete. The build-cache
2165
+ * window uses the configured value (default 168h).
2166
+ */
2167
+ housekeepingPrune() {
2168
+ const hk = this.resolveHousekeeping();
2169
+ const pruned = { buildCache: false, danglingImages: false };
2170
+ try {
2171
+ this.run(['docker', 'builder', 'prune', '-f', '--filter', `until=${hk.buildCacheMaxAgeHours}h`], os.tmpdir(), 120000);
2172
+ pruned.buildCache = true;
2173
+ }
2174
+ catch {
2175
+ /* best-effort */
2176
+ }
2177
+ try {
2178
+ this.run(['docker', 'image', 'prune', '-f'], os.tmpdir(), 120000);
2179
+ pruned.danglingImages = true;
2180
+ }
2181
+ catch {
2182
+ /* best-effort */
2183
+ }
2184
+ return { mode: 'prune', pruned, report: this.buildHousekeepingReport() };
2185
+ }
2186
+ buildHousekeepingReport() {
2187
+ return {
2188
+ buildCacheReclaimable: this.readBuildCacheReclaimable(),
2189
+ danglingImageCount: this.splitLines(this.safeRunStdout(['docker', 'image', 'ls', '--filter', 'dangling=true', '--quiet'])).length,
2190
+ orphanVolumes: this.splitLines(this.safeRunStdout(['docker', 'volume', 'ls', '--filter', 'dangling=true', '--quiet'])),
2191
+ };
2192
+ }
2193
+ /** `docker` stdout, or '' on any error (report helpers must never throw). */
2194
+ safeRunStdout(args, timeoutMs = 30000) {
2195
+ try {
2196
+ return this.run(args, os.tmpdir(), timeoutMs).stdout;
2197
+ }
2198
+ catch {
2199
+ return '';
2200
+ }
2201
+ }
2202
+ splitLines(s) {
2203
+ return s.trim().split('\n').map((x) => x.trim()).filter(Boolean);
2204
+ }
2205
+ /** Reclaimable build-cache size from the `docker system df` "Build Cache" row. */
2206
+ readBuildCacheReclaimable() {
2207
+ const out = this.safeRunStdout([
2208
+ 'docker', 'system', 'df', '--format', '{{.Type}}\t{{.Reclaimable}}',
2209
+ ]);
2210
+ for (const line of this.splitLines(out)) {
2211
+ const tab = line.indexOf('\t');
2212
+ if (tab < 0)
2213
+ continue;
2214
+ const type = line.slice(0, tab).trim().toLowerCase();
2215
+ if (type.includes('build cache'))
2216
+ return line.slice(tab + 1).trim();
2217
+ }
2218
+ return '';
2219
+ }
1456
2220
  run(args, cwd, timeoutMs = 30000) {
1457
2221
  const opts = {
1458
2222
  encoding: 'utf-8',