postgresai 0.15.0-rc.6 → 0.15.0-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,9 +10,9 @@ Command-line interface for PostgresAI monitoring and database management.
10
10
  npm install -g postgresai
11
11
  ```
12
12
 
13
- Or install the latest beta release explicitly:
13
+ For reproducible installs, pin the 0.15 release explicitly:
14
14
  ```bash
15
- npm install -g postgresai@beta
15
+ npm install -g postgresai@0.15.0
16
16
  ```
17
17
 
18
18
  Note: in this repository, `cli/package.json` uses a placeholder version (`0.0.0-dev.0`). The real published version is set by the git tag in CI when publishing to npm.
@@ -590,6 +590,208 @@ async function ensureDefaultMonitoringProject(): Promise<PathResolution> {
590
590
  return { fs, path, projectDir, composeFile, instancesFile };
591
591
  }
592
592
 
593
+ /**
594
+ * Sanitize a PGAI_TAG value before it is interpolated into the compose backup
595
+ * filename (`docker-compose.yml.bak-<tag>-<hash8>`). The value flows straight
596
+ * into a filename via path.resolve, so it is quote-stripped and validated
597
+ * against a conservative charset: a malformed or hostile tag (e.g.
598
+ * `../../../tmp/x`, or one carrying a path separator) is rejected to null so it
599
+ * can never escape projectDir or otherwise poison the backup path. Callers fall
600
+ * back to a timestamp suffix when this returns null.
601
+ *
602
+ * Applied centrally to BOTH tag sources — the .env read (readDeployedTag) and
603
+ * the OLD tag passed in by callers that rewrite .env first (e.g. local-install)
604
+ * — so neither path can bypass the validation.
605
+ */
606
+ function sanitizeTagForBackup(tag: string | null | undefined): string | null {
607
+ if (tag == null) return null;
608
+ const stripped = stripMatchingQuotes(tag);
609
+ return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
610
+ }
611
+
612
+ /**
613
+ * Read the deployed PGAI_TAG out of a project's .env (returns null if absent or
614
+ * if the value fails {@link sanitizeTagForBackup}). Used only to compute the
615
+ * compose backup file suffix; callers fall back to a timestamp when this is null.
616
+ */
617
+ function readDeployedTag(projectDir: string): string | null {
618
+ const envFile = path.resolve(projectDir, ".env");
619
+ if (!fs.existsSync(envFile)) return null;
620
+ const m = fs.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
621
+ return m ? sanitizeTagForBackup(m[1]) : null;
622
+ }
623
+
624
+ /**
625
+ * Validate that a freshly fetched payload is genuinely a postgres_ai
626
+ * docker-compose.yml, NOT an HTML login/captcha/proxy/maintenance page that a
627
+ * 200 response can still carry. `downloadText` only checks `response.ok`, so a
628
+ * non-compose 200 body would otherwise silently clobber a working compose with
629
+ * junk — strictly worse than keeping the stale-but-valid one.
630
+ *
631
+ * Requires:
632
+ * - non-empty, and not an obvious HTML document (<!DOCTYPE / <html ...);
633
+ * - parseable YAML resolving to an object;
634
+ * - a `services` map that contains the keystone `sink-prometheus` service
635
+ * (the very service whose VM_AUTH wiring this refresh exists to deliver).
636
+ */
637
+ function isValidComposeYaml(text: string): boolean {
638
+ const trimmed = text.trim();
639
+ if (!trimmed) return false;
640
+ // Cheap early reject for HTML error/login/proxy pages served with a 200.
641
+ if (/^<(?:!doctype|html|\?xml)\b/i.test(trimmed)) return false;
642
+
643
+ let doc: unknown;
644
+ try {
645
+ doc = yaml.load(trimmed);
646
+ } catch {
647
+ return false;
648
+ }
649
+ if (!doc || typeof doc !== "object") return false;
650
+ const services = (doc as { services?: unknown }).services;
651
+ if (!services || typeof services !== "object") return false;
652
+ // The keystone service must be present — this is what carries the VM_AUTH_*
653
+ // wiring that the whole refresh exists to deliver.
654
+ return Object.prototype.hasOwnProperty.call(services, "sink-prometheus");
655
+ }
656
+
657
+ /**
658
+ * Fetch the target docker-compose.yml for a given list of candidate refs.
659
+ *
660
+ * Test-only seam (PGAI_COMPOSE_SOURCE): when NODE_ENV === "test" AND the var is
661
+ * set to a local file path, the compose is read from disk instead of fetched
662
+ * over the network — keeping the refresh hermetically testable offline without
663
+ * exposing a local-file→compose injection surface in a normal user environment.
664
+ * The production path (GitLab raw fetch) is otherwise unchanged.
665
+ *
666
+ * Returns null if nothing could be obtained.
667
+ */
668
+ async function fetchTargetCompose(refs: string[]): Promise<string | null> {
669
+ const localSource = process.env.PGAI_COMPOSE_SOURCE;
670
+ if (process.env.NODE_ENV === "test" && localSource && localSource.trim()) {
671
+ try {
672
+ return fs.readFileSync(localSource.trim(), "utf8");
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
677
+
678
+ for (const ref of refs) {
679
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
680
+ try {
681
+ return await downloadText(url);
682
+ } catch {
683
+ // try next ref
684
+ }
685
+ }
686
+ return null;
687
+ }
688
+
689
+ /**
690
+ * Compare two compose documents ignoring trailing-whitespace-only differences,
691
+ * so a lone trailing-newline delta doesn't trigger needless churn + a backup.
692
+ */
693
+ function composeContentEqual(a: string, b: string): boolean {
694
+ return a.replace(/\s+$/, "") === b.replace(/\s+$/, "");
695
+ }
696
+
697
+ /**
698
+ * Refresh the bundled, CLI-owned docker-compose.yml for NON-GIT installs when it
699
+ * is stale relative to the target stack version (the CLI's own pkg.version).
700
+ *
701
+ * Why this exists: docker-compose.yml is a version-coupled static asset. 0.15
702
+ * added VM basic auth, wiring VM_AUTH_* into the sink-prometheus (VictoriaMetrics)
703
+ * service and the Grafana datasource. `mon update` already refreshes the compose
704
+ * for git checkouts via `git pull`, and green-field installs fetch it once via
705
+ * ensureDefaultMonitoringProject(). But npx / global-npm upgrades are non-git and
706
+ * only advance PGAI_TAG — leaving the OLD compose in place. That mismatch crashes
707
+ * sink-prometheus (`missing "VM_AUTH_USERNAME" env var`) and blanks all dashboards.
708
+ *
709
+ * Contract:
710
+ * - No-op for git checkouts (.git present) — they refresh via `git pull`.
711
+ * - No-op when there is no deployed compose yet (bootstrap path handles it).
712
+ * - No-op when the deployed compose content already matches the target.
713
+ * - No-op (treated exactly like a fetch failure) when the fetched payload does
714
+ * not validate as a real compose — keep the existing compose, no backup,
715
+ * warn, return false. Prevents an HTML/proxy 200 body from clobbering a
716
+ * working compose with junk.
717
+ * - Backs up the prior compose before overwriting and NEVER overwrites an
718
+ * existing backup, so the first/pristine compose is always preserved across
719
+ * repeated runs (the backup name is uniquified by old-content hash).
720
+ * - Touches ONLY docker-compose.yml. Never .env / instances.yml / .pgwatch-config.
721
+ * - Best-effort: a fetch/validation failure warns and keeps the existing
722
+ * compose (the upgrade still proceeds) — we must not turn a metrics-only
723
+ * outage into a hard CLI failure.
724
+ *
725
+ * @param projectDir monitoring project directory.
726
+ * @param oldTag the PGAI_TAG of the *deployed* (pre-upgrade) compose, used to
727
+ * label the backup. Callers that rewrite .env's PGAI_TAG before this runs
728
+ * (e.g. local-install) MUST pass the captured OLD tag here so the backup
729
+ * reflects the OLD version. When omitted, it is read from the project's .env.
730
+ * @returns true if the compose was refreshed, false otherwise.
731
+ */
732
+ async function refreshBundledComposeIfStale(projectDir: string, oldTag?: string | null): Promise<boolean> {
733
+ // Git checkouts manage docker-compose.yml via the repo itself (`git pull`).
734
+ if (fs.existsSync(path.resolve(projectDir, ".git"))) return false;
735
+
736
+ const composeFile = path.resolve(projectDir, "docker-compose.yml");
737
+ // Nothing deployed yet -> the green-field bootstrap path handles fetching it.
738
+ if (!fs.existsSync(composeFile)) return false;
739
+
740
+ const refs = [
741
+ process.env.PGAI_PROJECT_REF,
742
+ pkg.version,
743
+ `v${pkg.version}`,
744
+ ].filter((v): v is string => Boolean(v && v.trim()));
745
+
746
+ const fetched = await fetchTargetCompose(refs);
747
+ // Validate BEFORE doing anything destructive: an empty body, a fetch failure,
748
+ // or a non-compose 200 (HTML login/proxy/maintenance page) are all treated
749
+ // identically — keep the existing compose, write no backup, warn, no-op.
750
+ if (!fetched || !isValidComposeYaml(fetched)) {
751
+ console.error(`⚠ Could not refresh docker-compose.yml to ${pkg.version} (no valid compose was retrieved).`);
752
+ console.error(" Keeping the existing compose. If dashboards are blank after upgrade, re-run this command once network is available.");
753
+ return false;
754
+ }
755
+
756
+ // Compare on-disk CONTENT against the freshly fetched target (whitespace-only
757
+ // diffs ignored). This is correct regardless of when PGAI_TAG was rewritten in
758
+ // .env (local-install rewrites it before this runs), so we never rely on a
759
+ // possibly-stale tag heuristic.
760
+ const existing = fs.readFileSync(composeFile, "utf8");
761
+ if (composeContentEqual(existing, fetched)) return false; // already current
762
+
763
+ // Label the backup with the OLD (deployed) tag. Callers that already rewrote
764
+ // .env pass it in (raw); otherwise read it from .env. Sanitize centrally so the
765
+ // caller-supplied oldTag (e.g. local-install's previousTag) cannot bypass the
766
+ // filename validation — a hostile/malformed tag falls back to the timestamp.
767
+ const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
768
+ const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
769
+ // Uniquify with a short hash of the OLD content so repeated runs (e.g.
770
+ // update-config, where PGAI_TAG never advances) cannot overwrite the first,
771
+ // pristine backup. Always preserve the original compose.
772
+ const oldHash = crypto.createHash("sha256").update(existing).digest("hex").slice(0, 8);
773
+ const backup = path.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
774
+ let backupName: string | null = null;
775
+ try {
776
+ // "wx" => fail if the backup already exists, so we never clobber a prior
777
+ // backup. If this exact old content was already backed up, that's fine —
778
+ // the pristine copy is already on disk under the same name.
779
+ fs.writeFileSync(backup, existing, { encoding: "utf8", mode: 0o600, flag: "wx" });
780
+ backupName = path.basename(backup);
781
+ } catch (err) {
782
+ const e = err as NodeJS.ErrnoException;
783
+ if (e && e.code === "EEXIST") {
784
+ // Identical old content already backed up under this name — keep it.
785
+ backupName = path.basename(backup);
786
+ }
787
+ // Any other error: non-fatal, proceed with the refresh even without a backup.
788
+ }
789
+ fs.writeFileSync(composeFile, fetched, { encoding: "utf8", mode: 0o600 });
790
+ const backupNote = backupName ? ` (backup: ${backupName})` : "";
791
+ console.log(`✓ Refreshed docker-compose.yml to ${pkg.version}${backupNote}`);
792
+ return true;
793
+ }
794
+
593
795
  /**
594
796
  * Get configuration from various sources
595
797
  * @param opts - Command line options
@@ -2427,10 +2629,15 @@ mon
2427
2629
  let existingReplicatorPassword: string | null = null;
2428
2630
  let existingVmAuthUsername: string | null = null;
2429
2631
  let existingVmAuthPassword: string | null = null;
2632
+ // Capture the OLD (deployed) tag BEFORE we rewrite PGAI_TAG below, so the
2633
+ // compose-refresh backup is labeled with the version being upgraded FROM.
2634
+ let previousTag: string | null = null;
2430
2635
 
2431
2636
  if (fs.existsSync(envFile)) {
2432
2637
  const existingEnv = fs.readFileSync(envFile, "utf8");
2433
2638
  // Extract existing values (except tag - always use CLI version)
2639
+ const previousTagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
2640
+ if (previousTagMatch) previousTag = previousTagMatch[1].trim();
2434
2641
  const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
2435
2642
  if (registryMatch) existingRegistry = registryMatch[1].trim();
2436
2643
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
@@ -2463,6 +2670,14 @@ mon
2463
2670
  envLines.push(`VM_AUTH_PASSWORD=${existingVmAuthPassword || crypto.randomBytes(18).toString("base64")}`);
2464
2671
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
2465
2672
 
2673
+ // Non-git upgrade safety: bring the CLI-owned compose up to the target version
2674
+ // so newly-required service wiring (e.g. VM_AUTH_* on sink-prometheus) is present.
2675
+ // Compares deployed compose CONTENT against the target, so it's correct even
2676
+ // though PGAI_TAG was just rewritten above. No-op for git checkouts / when current.
2677
+ // Pass the OLD tag (captured before the .env rewrite) so the backup is labeled
2678
+ // with the version we're upgrading FROM, not the new one.
2679
+ await refreshBundledComposeIfStale(projectDir, previousTag);
2680
+
2466
2681
  if (opts.tag) {
2467
2682
  console.log(`Using image tag: ${imageTag}\n`);
2468
2683
  }
@@ -3069,6 +3284,12 @@ mon
3069
3284
  console.log("(existing values were preserved; missing keys filled with safe defaults)\n");
3070
3285
  }
3071
3286
 
3287
+ // Non-git installs: refresh the CLI-owned compose so it matches the target
3288
+ // stack version. Otherwise newly-required service wiring (e.g. VM_AUTH_* on
3289
+ // sink-prometheus, added in 0.15) is missing and VictoriaMetrics crashes.
3290
+ // No-op for git checkouts and when the compose already matches.
3291
+ await refreshBundledComposeIfStale(projectDir);
3292
+
3072
3293
  const code = await runCompose(["run", "--rm", "sources-generator"]);
3073
3294
  if (code !== 0) process.exitCode = code;
3074
3295
  });
@@ -3120,7 +3341,14 @@ mon
3120
3341
  const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
3121
3342
  console.log(pullOut);
3122
3343
  } else {
3123
- console.log("(not a git checkout skipping git fetch/pull and going straight to image pull)");
3344
+ // npx / global-npm installs are non-git: `git pull` can't refresh the
3345
+ // compose, so bring the CLI-owned docker-compose.yml up to the target
3346
+ // version in place (with a backup). This is what wires VM_AUTH_* into
3347
+ // sink-prometheus for users upgrading from a pre-0.15 (no-VM-auth) stack.
3348
+ // The helper logs only when it actually refreshes/warns, so don't
3349
+ // pre-announce a refresh that may turn out to be a no-op.
3350
+ console.log("(not a git checkout — checking bundled docker-compose.yml)");
3351
+ await refreshBundledComposeIfStale(projectDir);
3124
3352
  }
3125
3353
 
3126
3354
  // Step 3: pull new images.
@@ -5068,6 +5296,16 @@ mcp
5068
5296
  }
5069
5297
  });
5070
5298
 
5071
- program.parseAsync(process.argv).finally(() => {
5072
- closeReadline();
5073
- });
5299
+ // Only parse argv when run as the CLI entrypoint (npx / `bun postgres-ai.ts`).
5300
+ // When the module is imported (e.g. unit tests exercising the exported helpers),
5301
+ // skip the auto-parse so importing doesn't kick off the whole command tree.
5302
+ // `import.meta.main` is honored both under bun and in the node-targeted build.
5303
+ if (import.meta.main) {
5304
+ program.parseAsync(process.argv).finally(() => {
5305
+ closeReadline();
5306
+ });
5307
+ }
5308
+
5309
+ // Exported for unit tests (the CLI surface above is unaffected; these are the
5310
+ // same functions used by the `mon` commands).
5311
+ export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
@@ -13423,7 +13423,7 @@ var {
13423
13423
  // package.json
13424
13424
  var package_default = {
13425
13425
  name: "postgresai",
13426
- version: "0.15.0-rc.6",
13426
+ version: "0.15.0-rc.8",
13427
13427
  description: "postgres_ai CLI",
13428
13428
  license: "Apache-2.0",
13429
13429
  private: false,
@@ -16254,7 +16254,7 @@ var Result = import_lib.default.Result;
16254
16254
  var TypeOverrides = import_lib.default.TypeOverrides;
16255
16255
  var defaults = import_lib.default.defaults;
16256
16256
  // package.json
16257
- var version = "0.15.0-rc.6";
16257
+ var version = "0.15.0-rc.8";
16258
16258
  var package_default2 = {
16259
16259
  name: "postgresai",
16260
16260
  version,
@@ -33887,6 +33887,91 @@ async function ensureDefaultMonitoringProject() {
33887
33887
  }
33888
33888
  return { fs: fs8, path: path7, projectDir, composeFile, instancesFile };
33889
33889
  }
33890
+ function sanitizeTagForBackup(tag) {
33891
+ if (tag == null)
33892
+ return null;
33893
+ const stripped = stripMatchingQuotes(tag);
33894
+ return /^[A-Za-z0-9._-]{1,64}$/.test(stripped) ? stripped : null;
33895
+ }
33896
+ function readDeployedTag(projectDir) {
33897
+ const envFile = path7.resolve(projectDir, ".env");
33898
+ if (!fs8.existsSync(envFile))
33899
+ return null;
33900
+ const m = fs8.readFileSync(envFile, "utf8").match(/^PGAI_TAG=(.+)$/m);
33901
+ return m ? sanitizeTagForBackup(m[1]) : null;
33902
+ }
33903
+ function isValidComposeYaml(text) {
33904
+ const trimmed = text.trim();
33905
+ if (!trimmed)
33906
+ return false;
33907
+ if (/^<(?:!doctype|html|\?xml)\b/i.test(trimmed))
33908
+ return false;
33909
+ let doc2;
33910
+ try {
33911
+ doc2 = load(trimmed);
33912
+ } catch {
33913
+ return false;
33914
+ }
33915
+ if (!doc2 || typeof doc2 !== "object")
33916
+ return false;
33917
+ const services = doc2.services;
33918
+ if (!services || typeof services !== "object")
33919
+ return false;
33920
+ return Object.prototype.hasOwnProperty.call(services, "sink-prometheus");
33921
+ }
33922
+ async function fetchTargetCompose(refs) {
33923
+ const localSource = process.env.PGAI_COMPOSE_SOURCE;
33924
+ if (false) {}
33925
+ for (const ref of refs) {
33926
+ const url = `https://gitlab.com/postgres-ai/postgres_ai/-/raw/${encodeURIComponent(ref)}/docker-compose.yml`;
33927
+ try {
33928
+ return await downloadText(url);
33929
+ } catch {}
33930
+ }
33931
+ return null;
33932
+ }
33933
+ function composeContentEqual(a, b) {
33934
+ return a.replace(/\s+$/, "") === b.replace(/\s+$/, "");
33935
+ }
33936
+ async function refreshBundledComposeIfStale(projectDir, oldTag) {
33937
+ if (fs8.existsSync(path7.resolve(projectDir, ".git")))
33938
+ return false;
33939
+ const composeFile = path7.resolve(projectDir, "docker-compose.yml");
33940
+ if (!fs8.existsSync(composeFile))
33941
+ return false;
33942
+ const refs = [
33943
+ process.env.PGAI_PROJECT_REF,
33944
+ package_default.version,
33945
+ `v${package_default.version}`
33946
+ ].filter((v) => Boolean(v && v.trim()));
33947
+ const fetched = await fetchTargetCompose(refs);
33948
+ if (!fetched || !isValidComposeYaml(fetched)) {
33949
+ console.error(`\u26A0 Could not refresh docker-compose.yml to ${package_default.version} (no valid compose was retrieved).`);
33950
+ console.error(" Keeping the existing compose. If dashboards are blank after upgrade, re-run this command once network is available.");
33951
+ return false;
33952
+ }
33953
+ const existing = fs8.readFileSync(composeFile, "utf8");
33954
+ if (composeContentEqual(existing, fetched))
33955
+ return false;
33956
+ const deployedTag = sanitizeTagForBackup(oldTag ?? readDeployedTag(projectDir));
33957
+ const tagPart = deployedTag ?? new Date().toISOString().replace(/[:.]/g, "-");
33958
+ const oldHash = crypto2.createHash("sha256").update(existing).digest("hex").slice(0, 8);
33959
+ const backup = path7.resolve(projectDir, `docker-compose.yml.bak-${tagPart}-${oldHash}`);
33960
+ let backupName = null;
33961
+ try {
33962
+ fs8.writeFileSync(backup, existing, { encoding: "utf8", mode: 384, flag: "wx" });
33963
+ backupName = path7.basename(backup);
33964
+ } catch (err) {
33965
+ const e = err;
33966
+ if (e && e.code === "EEXIST") {
33967
+ backupName = path7.basename(backup);
33968
+ }
33969
+ }
33970
+ fs8.writeFileSync(composeFile, fetched, { encoding: "utf8", mode: 384 });
33971
+ const backupNote = backupName ? ` (backup: ${backupName})` : "";
33972
+ console.log(`\u2713 Refreshed docker-compose.yml to ${package_default.version}${backupNote}`);
33973
+ return true;
33974
+ }
33890
33975
  function getConfig(opts) {
33891
33976
  let apiKey = opts.apiKey || process.env.PGAI_API_KEY || "";
33892
33977
  if (!apiKey) {
@@ -35329,8 +35414,12 @@ mon.command("local-install").description("install local monitoring stack (genera
35329
35414
  let existingReplicatorPassword = null;
35330
35415
  let existingVmAuthUsername = null;
35331
35416
  let existingVmAuthPassword = null;
35417
+ let previousTag = null;
35332
35418
  if (fs8.existsSync(envFile)) {
35333
35419
  const existingEnv = fs8.readFileSync(envFile, "utf8");
35420
+ const previousTagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
35421
+ if (previousTagMatch)
35422
+ previousTag = previousTagMatch[1].trim();
35334
35423
  const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
35335
35424
  if (registryMatch)
35336
35425
  existingRegistry = registryMatch[1].trim();
@@ -35361,6 +35450,7 @@ mon.command("local-install").description("install local monitoring stack (genera
35361
35450
  fs8.writeFileSync(envFile, envLines.join(`
35362
35451
  `) + `
35363
35452
  `, { encoding: "utf8", mode: 384 });
35453
+ await refreshBundledComposeIfStale(projectDir, previousTag);
35364
35454
  if (opts.tag) {
35365
35455
  console.log(`Using image tag: ${imageTag}
35366
35456
  `);
@@ -35878,6 +35968,7 @@ mon.command("update-config").description("apply monitoring services configuratio
35878
35968
  console.log(`(existing values were preserved; missing keys filled with safe defaults)
35879
35969
  `);
35880
35970
  }
35971
+ await refreshBundledComposeIfStale(projectDir);
35881
35972
  const code = await runCompose(["run", "--rm", "sources-generator"]);
35882
35973
  if (code !== 0)
35883
35974
  process.exitCode = code;
@@ -35915,7 +36006,8 @@ mon.command("update").description("update monitoring stack (migrate .env, pull i
35915
36006
  const { stdout: pullOut } = await execFilePromise("git", ["pull", "origin", currentBranch]);
35916
36007
  console.log(pullOut);
35917
36008
  } else {
35918
- console.log("(not a git checkout \u2014 skipping git fetch/pull and going straight to image pull)");
36009
+ console.log("(not a git checkout \u2014 checking bundled docker-compose.yml)");
36010
+ await refreshBundledComposeIfStale(projectDir);
35919
36011
  }
35920
36012
  console.log(`
35921
36013
  Updating Docker images...`);
@@ -37417,6 +37509,13 @@ mcp.command("install [client]").description("install MCP server configuration fo
37417
37509
  process.exitCode = 1;
37418
37510
  }
37419
37511
  });
37420
- program2.parseAsync(process.argv).finally(() => {
37421
- closeReadline();
37422
- });
37512
+ if (__require.main == __require.module) {
37513
+ program2.parseAsync(process.argv).finally(() => {
37514
+ closeReadline();
37515
+ });
37516
+ }
37517
+ export {
37518
+ refreshBundledComposeIfStale,
37519
+ readDeployedTag,
37520
+ isValidComposeYaml
37521
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.15.0-rc.6",
3
+ "version": "0.15.0-rc.8",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -543,3 +543,514 @@ describe("in-place upgrade env migration (mon update / update-config)", () => {
543
543
  expect(envContent).not.toMatch(/PGAI_TAG=0\.14\.0VM_AUTH_USERNAME/);
544
544
  }, { timeout: TEST_TIMEOUT });
545
545
  });
546
+
547
+ describe("in-place upgrade compose refresh (non-git npx upgrade)", () => {
548
+ /**
549
+ * Regression tests for the GA-blocking 0.14 -> 0.15 npx-upgrade gap (#186).
550
+ *
551
+ * The documented npx upgrade (`npm i -g postgresai@latest` then `pgai mon update`)
552
+ * only ever refreshed docker-compose.yml for *git* checkouts (via `git pull`).
553
+ * npx/global-npm installs are non-git, so the OLD 0.14 docker-compose.yml was
554
+ * retained while PGAI_TAG advanced to 0.15. VM basic auth is new in 0.15, so the
555
+ * retained 0.14 compose never wires VM_AUTH_* into the sink-prometheus
556
+ * (VictoriaMetrics) service. The 0.15 configs image ships a prometheus.yml that
557
+ * templates %{VM_AUTH_USERNAME}, so VictoriaMetrics aborts on boot:
558
+ *
559
+ * Exited (255): missing "VM_AUTH_USERNAME" env var
560
+ *
561
+ * -> all dashboards are dataless after upgrade. This hits every existing npx
562
+ * self-hosted user and blocks 0.15.0 GA.
563
+ *
564
+ * The fix: for NON-GIT installs, refresh the CLI-owned docker-compose.yml from the
565
+ * target ref when it is stale, backing up the prior compose first, and never
566
+ * touching user-owned files (.env / instances.yml).
567
+ *
568
+ * Hermetic seam: PGAI_COMPOSE_SOURCE points the refresh at a local fixture file
569
+ * (instead of fetching over the network) so these tests are offline-safe. The
570
+ * seam is only honored when NODE_ENV === "test" (bun sets this automatically;
571
+ * we also set it explicitly), so it is never reachable in a user environment.
572
+ */
573
+
574
+ // A valid target 0.15 compose fixture: it carries the VM_AUTH_* wiring that the
575
+ // stale 0.14 compose lacks AND the keystone `sink-prometheus` service the
576
+ // validator requires. Self-contained so the test doesn't depend on the live
577
+ // repo compose changing under it.
578
+ const TARGET_0_15_COMPOSE = [
579
+ "version: '3.8'",
580
+ "services:",
581
+ " sink-prometheus:",
582
+ " image: victoriametrics/victoria-metrics:v1.140.0",
583
+ " container_name: sink-prometheus",
584
+ " environment:",
585
+ " - VM_AUTH_USERNAME=${VM_AUTH_USERNAME:-}",
586
+ " - VM_AUTH_PASSWORD=${VM_AUTH_PASSWORD:-}",
587
+ " grafana:",
588
+ " image: grafana/grafana:11.0.0",
589
+ "",
590
+ ].join("\n");
591
+
592
+ // A 0.14-shaped compose: a real-ish stack with ZERO VM_AUTH wiring.
593
+ const STALE_0_14_COMPOSE = [
594
+ "version: '3.8'",
595
+ "services:",
596
+ " sink-prometheus:",
597
+ " image: victoriametrics/victoria-metrics:v1.115.0",
598
+ " container_name: sink-prometheus",
599
+ " ports:",
600
+ ' - "9090:8428"',
601
+ " grafana:",
602
+ " image: grafana/grafana:11.0.0",
603
+ "",
604
+ ].join("\n");
605
+
606
+ let tempDir: string;
607
+ // Path to a written-out copy of TARGET_0_15_COMPOSE, used as the fetch fixture.
608
+ let targetComposePath: string;
609
+
610
+ const listBackups = (dir: string): string[] =>
611
+ fs.readdirSync(dir).filter((f) => f.startsWith("docker-compose.yml.bak")).sort();
612
+
613
+ beforeAll(() => {
614
+ tempDir = fs.mkdtempSync(resolve(os.tmpdir(), "pgai-upgrade-compose-refresh-"));
615
+ targetComposePath = resolve(tempDir, "fixture-target-compose.yml");
616
+ fs.writeFileSync(targetComposePath, TARGET_0_15_COMPOSE);
617
+ });
618
+
619
+ afterAll(() => {
620
+ if (tempDir && fs.existsSync(tempDir)) {
621
+ fs.rmSync(tempDir, { recursive: true, force: true });
622
+ }
623
+ });
624
+
625
+ test("non-git upgrade refreshes a stale docker-compose.yml to the target version (VM_AUTH wiring), preserves instances.yml, and writes a backup labeled with the OLD tag", () => {
626
+ const testDir = resolve(tempDir, "update-config-stale-compose");
627
+ fs.mkdirSync(testDir, { recursive: true });
628
+
629
+ // Arrange: a 0.14-shaped, NON-GIT install. Old compose has no VM_AUTH wiring.
630
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
631
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
632
+ fs.writeFileSync(
633
+ resolve(testDir, "instances.yml"),
634
+ "# PostgreSQL instances to monitor\n- name: keep-me\n conn_str: postgresql://m:p@h:5432/db\n",
635
+ );
636
+
637
+ // Sanity: the stale compose really lacks VM_AUTH wiring before the upgrade.
638
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).not.toMatch(/VM_AUTH_USERNAME/);
639
+
640
+ // Act: documented in-place upgrade step on a non-git install. The compose `run`
641
+ // will fail (no Docker in CI), but the compose refresh runs first. Point the
642
+ // refresh at the local fixture so the test is hermetic (no network).
643
+ runCliInDir(["mon", "update-config"], testDir, {
644
+ NODE_ENV: "test",
645
+ PGAI_TAG: undefined,
646
+ PGAI_COMPOSE_SOURCE: targetComposePath,
647
+ });
648
+
649
+ // Assert: deployed compose is REFRESHED to the target version (VM_AUTH wired).
650
+ const after = fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8");
651
+ expect(after).toMatch(/VM_AUTH_USERNAME/);
652
+ expect(after).toMatch(/sink-prometheus/);
653
+
654
+ // Assert: instances.yml is PRESERVED (user-owned file untouched).
655
+ expect(fs.readFileSync(resolve(testDir, "instances.yml"), "utf8")).toMatch(/keep-me/);
656
+
657
+ // Assert: exactly one backup, labeled with the OLD (0.14.0) tag, containing
658
+ // the pristine pre-upgrade compose. Name is `bak-<oldtag>-<hash8>`.
659
+ const backups = listBackups(testDir);
660
+ expect(backups.length).toBe(1);
661
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
662
+ expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
663
+ }, { timeout: TEST_TIMEOUT });
664
+
665
+ test("non-git upgrade via `mon update` also refreshes a stale docker-compose.yml", () => {
666
+ const testDir = resolve(tempDir, "update-stale-compose");
667
+ fs.mkdirSync(testDir, { recursive: true });
668
+
669
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
670
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
671
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n- name: keep-me\n");
672
+
673
+ runCliInDir(["mon", "update"], testDir, {
674
+ NODE_ENV: "test",
675
+ PGAI_TAG: undefined,
676
+ PGAI_COMPOSE_SOURCE: targetComposePath,
677
+ });
678
+
679
+ const after = fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8");
680
+ expect(after).toMatch(/VM_AUTH_USERNAME/);
681
+ expect(fs.readFileSync(resolve(testDir, "instances.yml"), "utf8")).toMatch(/keep-me/);
682
+ const backups = listBackups(testDir);
683
+ expect(backups.length).toBe(1);
684
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
685
+ }, { timeout: TEST_TIMEOUT });
686
+
687
+ test("git checkouts are left untouched (compose managed by git pull)", () => {
688
+ const testDir = resolve(tempDir, "git-checkout-no-refresh");
689
+ fs.mkdirSync(testDir, { recursive: true });
690
+ fs.mkdirSync(resolve(testDir, ".git"), { recursive: true });
691
+
692
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
693
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
694
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
695
+
696
+ runCliInDir(["mon", "update-config"], testDir, {
697
+ NODE_ENV: "test",
698
+ PGAI_TAG: undefined,
699
+ PGAI_COMPOSE_SOURCE: targetComposePath,
700
+ });
701
+
702
+ // Compose must be unchanged for git checkouts, and no backup written.
703
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
704
+ expect(listBackups(testDir)).toEqual([]);
705
+ }, { timeout: TEST_TIMEOUT });
706
+
707
+ test("already-current compose is not rewritten and no backup is created (idempotent)", () => {
708
+ const testDir = resolve(tempDir, "already-current-compose");
709
+ fs.mkdirSync(testDir, { recursive: true });
710
+
711
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
712
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), TARGET_0_15_COMPOSE);
713
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
714
+
715
+ runCliInDir(["mon", "update-config"], testDir, {
716
+ NODE_ENV: "test",
717
+ PGAI_TAG: undefined,
718
+ PGAI_COMPOSE_SOURCE: targetComposePath,
719
+ });
720
+
721
+ // Content already matches target -> no rewrite, no backup.
722
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(TARGET_0_15_COMPOSE);
723
+ expect(listBackups(testDir)).toEqual([]);
724
+ }, { timeout: TEST_TIMEOUT });
725
+
726
+ test("a trailing-newline-only difference is NOT treated as stale (no churn, no backup)", () => {
727
+ const testDir = resolve(tempDir, "trailing-newline-noop");
728
+ fs.mkdirSync(testDir, { recursive: true });
729
+
730
+ // Deployed compose == target but with extra trailing whitespace/newlines.
731
+ const deployed = TARGET_0_15_COMPOSE + "\n\n";
732
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
733
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), deployed);
734
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
735
+
736
+ runCliInDir(["mon", "update-config"], testDir, {
737
+ NODE_ENV: "test",
738
+ PGAI_TAG: undefined,
739
+ PGAI_COMPOSE_SOURCE: targetComposePath,
740
+ });
741
+
742
+ // Whitespace-only delta must not trigger a rewrite or a backup.
743
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(deployed);
744
+ expect(listBackups(testDir)).toEqual([]);
745
+ }, { timeout: TEST_TIMEOUT });
746
+
747
+ describe("fetched payload validation (BLOCKER #186 hardening)", () => {
748
+ /**
749
+ * A 200 response can still carry a NON-compose body (HTML login/captcha/
750
+ * proxy/maintenance page) or an empty/garbage body. Such a payload must
751
+ * NEVER clobber a working docker-compose.yml — that would be strictly worse
752
+ * than keeping the stale-but-valid one. The refresh must validate the fetched
753
+ * text and, on failure, behave EXACTLY like a clean fetch failure: keep the
754
+ * existing compose, write NO backup, warn, no-op.
755
+ */
756
+
757
+ test("an HTML (non-compose) 200 body leaves the deployed compose UNCHANGED, writes NO backup, and warns", () => {
758
+ const testDir = resolve(tempDir, "garbage-html-body");
759
+ fs.mkdirSync(testDir, { recursive: true });
760
+
761
+ // The "fetched" payload is an HTML login/proxy page served with a 200.
762
+ const htmlBody = "<!DOCTYPE html>\n<html><head><title>Sign in</title></head>\n<body>Please log in to continue.</body></html>\n";
763
+ const htmlSource = resolve(testDir, "html-source.html");
764
+ fs.writeFileSync(htmlSource, htmlBody);
765
+
766
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
767
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
768
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
769
+
770
+ const result = runCliInDir(["mon", "update-config"], testDir, {
771
+ NODE_ENV: "test",
772
+ PGAI_TAG: undefined,
773
+ PGAI_COMPOSE_SOURCE: htmlSource,
774
+ });
775
+
776
+ // The working compose must be left intact — NOT clobbered with HTML.
777
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
778
+ // No backup is written when nothing is refreshed.
779
+ expect(listBackups(testDir)).toEqual([]);
780
+ // The user is warned that no valid compose was retrieved.
781
+ expect(result.stderr).toMatch(/Could not refresh docker-compose\.yml/);
782
+ }, { timeout: TEST_TIMEOUT });
783
+
784
+ test("a YAML body WITHOUT a sink-prometheus service is rejected (compose untouched, no backup)", () => {
785
+ const testDir = resolve(tempDir, "yaml-missing-keystone");
786
+ fs.mkdirSync(testDir, { recursive: true });
787
+
788
+ // Valid YAML, has services, but lacks the keystone sink-prometheus service.
789
+ const wrongCompose = "version: '3.8'\nservices:\n grafana:\n image: grafana/grafana:11.0.0\n";
790
+ const wrongSource = resolve(testDir, "wrong-compose.yml");
791
+ fs.writeFileSync(wrongSource, wrongCompose);
792
+
793
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
794
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
795
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
796
+
797
+ runCliInDir(["mon", "update-config"], testDir, {
798
+ NODE_ENV: "test",
799
+ PGAI_TAG: undefined,
800
+ PGAI_COMPOSE_SOURCE: wrongSource,
801
+ });
802
+
803
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
804
+ expect(listBackups(testDir)).toEqual([]);
805
+ }, { timeout: TEST_TIMEOUT });
806
+
807
+ test("an empty fetched body leaves the deployed compose intact and writes NO backup", () => {
808
+ const testDir = resolve(tempDir, "empty-body");
809
+ fs.mkdirSync(testDir, { recursive: true });
810
+
811
+ const emptySource = resolve(testDir, "empty.yml");
812
+ fs.writeFileSync(emptySource, "");
813
+
814
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
815
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
816
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
817
+
818
+ runCliInDir(["mon", "update-config"], testDir, {
819
+ NODE_ENV: "test",
820
+ PGAI_TAG: undefined,
821
+ PGAI_COMPOSE_SOURCE: emptySource,
822
+ });
823
+
824
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
825
+ expect(listBackups(testDir)).toEqual([]);
826
+ }, { timeout: TEST_TIMEOUT });
827
+ });
828
+
829
+ test("repeated update-config runs preserve the FIRST/pristine backup (no overwrite)", () => {
830
+ const testDir = resolve(tempDir, "backup-collision");
831
+ fs.mkdirSync(testDir, { recursive: true });
832
+
833
+ // PGAI_TAG stays 0.14.0 across update-config runs (it doesn't advance there).
834
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
835
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
836
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
837
+
838
+ // First run: refreshes to target, backs up the pristine 0.14 compose.
839
+ runCliInDir(["mon", "update-config"], testDir, {
840
+ NODE_ENV: "test",
841
+ PGAI_TAG: undefined,
842
+ PGAI_COMPOSE_SOURCE: targetComposePath,
843
+ });
844
+ const afterFirst = listBackups(testDir);
845
+ expect(afterFirst.length).toBe(1);
846
+ const pristineBackup = afterFirst[0]!;
847
+ expect(fs.readFileSync(resolve(testDir, pristineBackup), "utf8")).toBe(STALE_0_14_COMPOSE);
848
+
849
+ // Now the deployed compose IS the target. Simulate a second drift back to a
850
+ // (different) stale compose, then run update-config again with the SAME tag.
851
+ const SECOND_STALE = STALE_0_14_COMPOSE.replace("v1.115.0", "v1.116.0");
852
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), SECOND_STALE);
853
+
854
+ runCliInDir(["mon", "update-config"], testDir, {
855
+ NODE_ENV: "test",
856
+ PGAI_TAG: undefined,
857
+ PGAI_COMPOSE_SOURCE: targetComposePath,
858
+ });
859
+
860
+ // The FIRST/pristine backup must still be intact (not overwritten), and the
861
+ // second distinct old content gets its own backup (unique by content hash).
862
+ expect(fs.readFileSync(resolve(testDir, pristineBackup), "utf8")).toBe(STALE_0_14_COMPOSE);
863
+ const afterSecond = listBackups(testDir);
864
+ expect(afterSecond.length).toBe(2);
865
+ expect(afterSecond).toContain(pristineBackup);
866
+ }, { timeout: TEST_TIMEOUT });
867
+
868
+ test("local-install labels the backup with the OLD tag, not the new CLI version", () => {
869
+ const testDir = resolve(tempDir, "local-install-old-tag-backup");
870
+ fs.mkdirSync(testDir, { recursive: true });
871
+
872
+ // 0.14 install. local-install rewrites .env PGAI_TAG to the CLI version
873
+ // BEFORE the compose refresh; the backup must still reflect the OLD (0.14.0)
874
+ // tag, not the new one.
875
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
876
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
877
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
878
+
879
+ runCliInDir(
880
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
881
+ testDir,
882
+ {
883
+ NODE_ENV: "test",
884
+ PGAI_TAG: undefined,
885
+ PGAI_COMPOSE_SOURCE: targetComposePath,
886
+ },
887
+ );
888
+
889
+ // .env PGAI_TAG was advanced to the new CLI version...
890
+ const envAfter = fs.readFileSync(resolve(testDir, ".env"), "utf8");
891
+ expect(envAfter).not.toMatch(/PGAI_TAG=0\.14\.0/);
892
+
893
+ // ...but the backup of the OLD compose must be labeled with 0.14.0, NOT the
894
+ // new tag, and must contain the pristine pre-upgrade compose.
895
+ const backups = listBackups(testDir);
896
+ expect(backups.length).toBe(1);
897
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-0\.14\.0-[0-9a-f]{8}$/);
898
+ expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
899
+ }, { timeout: TEST_TIMEOUT });
900
+
901
+ test("a fetch failure (no payload retrieved) leaves the deployed compose intact, writes NO backup, warns, and does not crash", () => {
902
+ // Contract bullet: "Best-effort — a fetch failure warns and keeps the
903
+ // existing compose." Force fetchTargetCompose() -> null hermetically by
904
+ // pointing the test seam at a path that does not exist. This is the network
905
+ // -down / GitLab-5xx branch: it must NOT turn a metrics-only outage into a
906
+ // hard CLI failure, and must never clobber the stale-but-valid compose.
907
+ const testDir = resolve(tempDir, "fetch-failure-null");
908
+ fs.mkdirSync(testDir, { recursive: true });
909
+
910
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
911
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
912
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
913
+
914
+ const result = runCliInDir(["mon", "update-config"], testDir, {
915
+ NODE_ENV: "test",
916
+ PGAI_TAG: undefined,
917
+ // Nonexistent fixture path -> fetchTargetCompose() returns null.
918
+ PGAI_COMPOSE_SOURCE: resolve(testDir, "does-not-exist.yml"),
919
+ });
920
+
921
+ // The working compose must be untouched, and no backup written.
922
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
923
+ expect(listBackups(testDir)).toEqual([]);
924
+ // The user is warned, and the CLI does not crash on the env-migration step.
925
+ expect(result.stderr).toMatch(/Could not refresh docker-compose\.yml/);
926
+ }, { timeout: TEST_TIMEOUT });
927
+
928
+ test("no deployed compose yet is a clean no-op: returns false, writes nothing, no backup", async () => {
929
+ // Guard: `if (!fs.existsSync(composeFile)) return false` keeps the refresh
930
+ // from racing with the green-field bootstrap path. NOTE: this branch is not
931
+ // reachable through the black-box `mon update-config` flow — resolveOrInitPaths
932
+ // bootstraps (fetches) a compose BEFORE the refresh runs, so by then the file
933
+ // always exists. We therefore exercise the guard directly against the exported
934
+ // helper, which is the only faithful, hermetic way to cover it.
935
+ const { refreshBundledComposeIfStale } = await import("../bin/postgres-ai.ts");
936
+
937
+ const testDir = resolve(tempDir, "no-deployed-compose");
938
+ fs.mkdirSync(testDir, { recursive: true });
939
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
940
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
941
+ // Intentionally NO docker-compose.yml.
942
+
943
+ process.env.NODE_ENV = "test";
944
+ process.env.PGAI_COMPOSE_SOURCE = targetComposePath;
945
+ const refreshed = await refreshBundledComposeIfStale(testDir);
946
+
947
+ // No compose on disk -> no-op: returns false, materializes no compose, and
948
+ // writes no backup. The bootstrap path owns the first fetch.
949
+ expect(refreshed).toBe(false);
950
+ expect(fs.existsSync(resolve(testDir, "docker-compose.yml"))).toBe(false);
951
+ expect(listBackups(testDir)).toEqual([]);
952
+ }, { timeout: TEST_TIMEOUT });
953
+
954
+ test("git checkout is a no-op even with a stale compose (direct helper guard)", async () => {
955
+ // Companion guard: `.git` present -> the repo manages the compose via
956
+ // `git pull`, so the helper must return false and touch nothing. Covered
957
+ // black-box too (below), but pinned here against the exported helper.
958
+ const { refreshBundledComposeIfStale } = await import("../bin/postgres-ai.ts");
959
+
960
+ const testDir = resolve(tempDir, "git-checkout-helper-noop");
961
+ fs.mkdirSync(resolve(testDir, ".git"), { recursive: true });
962
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=0.14.0\n");
963
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
964
+
965
+ process.env.NODE_ENV = "test";
966
+ process.env.PGAI_COMPOSE_SOURCE = targetComposePath;
967
+ const refreshed = await refreshBundledComposeIfStale(testDir);
968
+
969
+ expect(refreshed).toBe(false);
970
+ expect(fs.readFileSync(resolve(testDir, "docker-compose.yml"), "utf8")).toBe(STALE_0_14_COMPOSE);
971
+ expect(listBackups(testDir)).toEqual([]);
972
+ }, { timeout: TEST_TIMEOUT });
973
+
974
+ test("backup falls back to a timestamp suffix when .env has no PGAI_TAG line", () => {
975
+ // When readDeployedTag() returns null (no PGAI_TAG to label the backup with),
976
+ // the backup name falls back to an ISO-8601 timestamp suffix
977
+ // (`bak-<YYYY-MM-DDTHH-MM-SS-mmmZ>-<hash8>`). Every other test seeds
978
+ // PGAI_TAG=0.14.0, so this branch was previously unexercised.
979
+ const testDir = resolve(tempDir, "timestamp-suffix-fallback");
980
+ fs.mkdirSync(testDir, { recursive: true });
981
+
982
+ // .env exists (non-git install) but carries NO PGAI_TAG line.
983
+ fs.writeFileSync(resolve(testDir, ".env"), "GRAFANA_PASSWORD=secret\n");
984
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
985
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
986
+
987
+ runCliInDir(["mon", "update-config"], testDir, {
988
+ NODE_ENV: "test",
989
+ PGAI_TAG: undefined,
990
+ PGAI_COMPOSE_SOURCE: targetComposePath,
991
+ });
992
+
993
+ // The compose was refreshed, and the single backup is labeled with a
994
+ // timestamp suffix (NOT a tag), still uniquified by the content hash.
995
+ const backups = listBackups(testDir);
996
+ expect(backups.length).toBe(1);
997
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
998
+ expect(fs.readFileSync(resolve(testDir, backups[0]!), "utf8")).toBe(STALE_0_14_COMPOSE);
999
+ }, { timeout: TEST_TIMEOUT });
1000
+
1001
+ test("a malformed/hostile PGAI_TAG is rejected and the backup falls back to a timestamp suffix (no path traversal)", () => {
1002
+ // readDeployedTag() validates the tag against a conservative charset before
1003
+ // it flows into the backup filename, so a path-traversal-shaped value cannot
1004
+ // escape projectDir; it falls back to the timestamp suffix instead.
1005
+ const testDir = resolve(tempDir, "hostile-tag-rejected");
1006
+ fs.mkdirSync(testDir, { recursive: true });
1007
+
1008
+ fs.writeFileSync(resolve(testDir, ".env"), "PGAI_TAG=../../../../tmp/evil\n");
1009
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
1010
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
1011
+
1012
+ runCliInDir(["mon", "update-config"], testDir, {
1013
+ NODE_ENV: "test",
1014
+ PGAI_TAG: undefined,
1015
+ PGAI_COMPOSE_SOURCE: targetComposePath,
1016
+ });
1017
+
1018
+ // The single backup stays inside projectDir with a timestamp suffix — the
1019
+ // traversal value never reaches the filename.
1020
+ const backups = listBackups(testDir);
1021
+ expect(backups.length).toBe(1);
1022
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
1023
+ // Nothing was written outside projectDir.
1024
+ expect(fs.existsSync("/tmp/evil")).toBe(false);
1025
+ }, { timeout: TEST_TIMEOUT });
1026
+
1027
+ test("local-install sanitizes the OLD tag it passes in: a hostile PGAI_TAG falls back to a timestamp suffix", () => {
1028
+ // local-install captures the OLD .env PGAI_TAG and passes it to the refresh as
1029
+ // `oldTag`, BYPASSING readDeployedTag. Sanitization must therefore happen
1030
+ // centrally inside the helper so a hostile tag on THIS path also cannot escape
1031
+ // projectDir or land literal `/`/quote chars in the backup filename.
1032
+ const testDir = resolve(tempDir, "local-install-hostile-old-tag");
1033
+ fs.mkdirSync(testDir, { recursive: true });
1034
+
1035
+ fs.writeFileSync(resolve(testDir, ".env"), 'PGAI_TAG="../../../../tmp/evil"\n');
1036
+ fs.writeFileSync(resolve(testDir, "docker-compose.yml"), STALE_0_14_COMPOSE);
1037
+ fs.writeFileSync(resolve(testDir, "instances.yml"), "# instances\n");
1038
+
1039
+ runCliInDir(
1040
+ ["mon", "local-install", "--db-url", "postgresql://u:p@h:5432/d", "--yes"],
1041
+ testDir,
1042
+ {
1043
+ NODE_ENV: "test",
1044
+ PGAI_TAG: undefined,
1045
+ PGAI_COMPOSE_SOURCE: targetComposePath,
1046
+ },
1047
+ );
1048
+
1049
+ // The hostile oldTag never reaches the filename: the single backup stays
1050
+ // inside projectDir with a timestamp suffix, and nothing escapes to /tmp.
1051
+ const backups = listBackups(testDir);
1052
+ expect(backups.length).toBe(1);
1053
+ expect(backups[0]).toMatch(/^docker-compose\.yml\.bak-\d{4}-\d{2}-\d{2}T[\dZ-]+-[0-9a-f]{8}$/);
1054
+ expect(fs.existsSync("/tmp/evil")).toBe(false);
1055
+ }, { timeout: TEST_TIMEOUT });
1056
+ });