postgresai 0.14.0-dev.54 → 0.14.0-dev.55

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
@@ -303,17 +303,24 @@ Normalization:
303
303
 
304
304
  ### Examples
305
305
 
306
- Linux/macOS (bash/zsh):
306
+ For production (uses default URLs):
307
307
 
308
308
  ```bash
309
+ # Production auth - uses console.postgres.ai by default
310
+ postgresai auth --debug
311
+ ```
312
+
313
+ For staging/development environments:
314
+
315
+ ```bash
316
+ # Linux/macOS (bash/zsh)
309
317
  export PGAI_API_BASE_URL=https://v2.postgres.ai/api/general/
310
318
  export PGAI_UI_BASE_URL=https://console-dev.postgres.ai
311
319
  postgresai auth --debug
312
320
  ```
313
321
 
314
- Windows PowerShell:
315
-
316
322
  ```powershell
323
+ # Windows PowerShell
317
324
  $env:PGAI_API_BASE_URL = "https://v2.postgres.ai/api/general/"
318
325
  $env:PGAI_UI_BASE_URL = "https://console-dev.postgres.ai"
319
326
  postgresai auth --debug
@@ -330,6 +337,27 @@ postgresai auth --debug \
330
337
  Notes:
331
338
  - If `PGAI_UI_BASE_URL` is not set, the default is `https://console.postgres.ai`.
332
339
 
340
+ ## Development
341
+
342
+ ### Testing
343
+
344
+ The CLI uses [Bun](https://bun.sh/) as the test runner with built-in coverage reporting.
345
+
346
+ ```bash
347
+ # Run tests with coverage (default)
348
+ bun run test
349
+
350
+ # Run tests without coverage (faster iteration during development)
351
+ bun run test:fast
352
+
353
+ # Run tests with coverage and show report location
354
+ bun run test:coverage
355
+ ```
356
+
357
+ Coverage configuration is in `bunfig.toml`. Reports are generated in `coverage/` directory:
358
+ - `coverage/lcov-report/index.html` - HTML coverage report
359
+ - `coverage/lcov.info` - LCOV format for CI integration
360
+
333
361
  ## Requirements
334
362
 
335
363
  - Node.js 18 or higher
@@ -1185,17 +1185,33 @@ mon
1185
1185
 
1186
1186
  // Update .env with custom tag if provided
1187
1187
  const envFile = path.resolve(projectDir, ".env");
1188
- const imageTag = opts.tag || pkg.version;
1189
1188
 
1190
- // Build .env content
1191
- const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1192
- // Preserve GF_SECURITY_ADMIN_PASSWORD if it exists
1189
+ // Build .env content, preserving important existing values
1190
+ // Read existing .env first to preserve CI/custom settings
1191
+ let existingTag: string | null = null;
1192
+ let existingRegistry: string | null = null;
1193
+ let existingPassword: string | null = null;
1194
+
1193
1195
  if (fs.existsSync(envFile)) {
1194
1196
  const existingEnv = fs.readFileSync(envFile, "utf8");
1197
+ // Extract existing values
1198
+ const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
1199
+ if (tagMatch) existingTag = tagMatch[1].trim();
1200
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
1201
+ if (registryMatch) existingRegistry = registryMatch[1].trim();
1195
1202
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
1196
- if (pwdMatch) {
1197
- envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${pwdMatch[1]}`);
1198
- }
1203
+ if (pwdMatch) existingPassword = pwdMatch[1].trim();
1204
+ }
1205
+
1206
+ // Priority: CLI --tag flag > existing .env > package version
1207
+ const imageTag = opts.tag || existingTag || pkg.version;
1208
+
1209
+ const envLines: string[] = [`PGAI_TAG=${imageTag}`];
1210
+ if (existingRegistry) {
1211
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
1212
+ }
1213
+ if (existingPassword) {
1214
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
1199
1215
  }
1200
1216
  fs.writeFileSync(envFile, envLines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
1201
1217
 
@@ -2102,7 +2118,8 @@ auth
2102
2118
  }
2103
2119
 
2104
2120
  // Step 3: Open browser
2105
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
2121
+ // Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
2122
+ const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
2106
2123
 
2107
2124
  if (opts.debug) {
2108
2125
  console.log(`Debug: Auth URL: ${authUrl}`);
package/bunfig.toml CHANGED
@@ -6,6 +6,14 @@
6
6
  # Integration tests that connect to databases need longer timeouts
7
7
  timeout = 30000
8
8
 
9
- # Coverage settings (if needed in future)
10
- # coverage = true
11
- # coverageDir = "coverage"
9
+ # Coverage settings - enabled by default for test runs
10
+ coverage = true
11
+ coverageDir = "coverage"
12
+
13
+ # Skip coverage for test files and node_modules
14
+ coverageSkipTestFiles = true
15
+
16
+ # Reporter format for CI integration
17
+ # - text: console output with summary table
18
+ # - lcov: standard format for coverage tools
19
+ coverageReporter = ["text", "lcov"]
@@ -13064,7 +13064,7 @@ var {
13064
13064
  // package.json
13065
13065
  var package_default = {
13066
13066
  name: "postgresai",
13067
- version: "0.14.0-dev.54",
13067
+ version: "0.14.0-dev.55",
13068
13068
  description: "postgres_ai CLI",
13069
13069
  license: "Apache-2.0",
13070
13070
  private: false,
@@ -13096,6 +13096,8 @@ var package_default = {
13096
13096
  "start:node": "node ./dist/bin/postgres-ai.js --help",
13097
13097
  dev: "bun run embed-metrics && bun --watch ./bin/postgres-ai.ts",
13098
13098
  test: "bun run embed-metrics && bun test",
13099
+ "test:fast": "bun run embed-metrics && bun test --coverage=false",
13100
+ "test:coverage": "bun run embed-metrics && bun test --coverage && echo 'Coverage report: cli/coverage/lcov-report/index.html'",
13099
13101
  typecheck: "bun run embed-metrics && bunx tsc --noEmit"
13100
13102
  },
13101
13103
  dependencies: {
@@ -15885,7 +15887,7 @@ var Result = import_lib.default.Result;
15885
15887
  var TypeOverrides = import_lib.default.TypeOverrides;
15886
15888
  var defaults = import_lib.default.defaults;
15887
15889
  // package.json
15888
- var version = "0.14.0-dev.54";
15890
+ var version = "0.14.0-dev.55";
15889
15891
  var package_default2 = {
15890
15892
  name: "postgresai",
15891
15893
  version,
@@ -15920,6 +15922,8 @@ var package_default2 = {
15920
15922
  "start:node": "node ./dist/bin/postgres-ai.js --help",
15921
15923
  dev: "bun run embed-metrics && bun --watch ./bin/postgres-ai.ts",
15922
15924
  test: "bun run embed-metrics && bun test",
15925
+ "test:fast": "bun run embed-metrics && bun test --coverage=false",
15926
+ "test:coverage": "bun run embed-metrics && bun test --coverage && echo 'Coverage report: cli/coverage/lcov-report/index.html'",
15923
15927
  typecheck: "bun run embed-metrics && bunx tsc --noEmit"
15924
15928
  },
15925
15929
  dependencies: {
@@ -26723,14 +26727,28 @@ mon.command("local-install").description("install local monitoring stack (genera
26723
26727
  console.log(`Project directory: ${projectDir}
26724
26728
  `);
26725
26729
  const envFile = path5.resolve(projectDir, ".env");
26726
- const imageTag = opts.tag || package_default.version;
26727
- const envLines = [`PGAI_TAG=${imageTag}`];
26730
+ let existingTag = null;
26731
+ let existingRegistry = null;
26732
+ let existingPassword = null;
26728
26733
  if (fs5.existsSync(envFile)) {
26729
26734
  const existingEnv = fs5.readFileSync(envFile, "utf8");
26735
+ const tagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
26736
+ if (tagMatch)
26737
+ existingTag = tagMatch[1].trim();
26738
+ const registryMatch = existingEnv.match(/^PGAI_REGISTRY=(.+)$/m);
26739
+ if (registryMatch)
26740
+ existingRegistry = registryMatch[1].trim();
26730
26741
  const pwdMatch = existingEnv.match(/^GF_SECURITY_ADMIN_PASSWORD=(.+)$/m);
26731
- if (pwdMatch) {
26732
- envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${pwdMatch[1]}`);
26733
- }
26742
+ if (pwdMatch)
26743
+ existingPassword = pwdMatch[1].trim();
26744
+ }
26745
+ const imageTag = opts.tag || existingTag || package_default.version;
26746
+ const envLines = [`PGAI_TAG=${imageTag}`];
26747
+ if (existingRegistry) {
26748
+ envLines.push(`PGAI_REGISTRY=${existingRegistry}`);
26749
+ }
26750
+ if (existingPassword) {
26751
+ envLines.push(`GF_SECURITY_ADMIN_PASSWORD=${existingPassword}`);
26734
26752
  }
26735
26753
  fs5.writeFileSync(envFile, envLines.join(`
26736
26754
  `) + `
@@ -27535,7 +27553,7 @@ Please verify the --api-base-url parameter.`);
27535
27553
  process.exit(1);
27536
27554
  return;
27537
27555
  }
27538
- const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
27556
+ const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;
27539
27557
  if (opts.debug) {
27540
27558
  console.log(`Debug: Auth URL: ${authUrl}`);
27541
27559
  }
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED FILE - DO NOT EDIT
2
2
  // Generated from config/pgwatch-prometheus/metrics.yml by scripts/embed-metrics.ts
3
- // Generated at: 2025-12-28T15:08:23.760Z
3
+ // Generated at: 2025-12-29T17:55:05.936Z
4
4
 
5
5
  /**
6
6
  * Metric definition from metrics.yml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.14.0-dev.54",
3
+ "version": "0.14.0-dev.55",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -32,6 +32,8 @@
32
32
  "start:node": "node ./dist/bin/postgres-ai.js --help",
33
33
  "dev": "bun run embed-metrics && bun --watch ./bin/postgres-ai.ts",
34
34
  "test": "bun run embed-metrics && bun test",
35
+ "test:fast": "bun run embed-metrics && bun test --coverage=false",
36
+ "test:coverage": "bun run embed-metrics && bun test --coverage && echo 'Coverage report: cli/coverage/lcov-report/index.html'",
35
37
  "typecheck": "bun run embed-metrics && bunx tsc --noEmit"
36
38
  },
37
39
  "dependencies": {
@@ -0,0 +1,258 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { resolve } from "path";
3
+
4
+ import * as util from "../lib/util";
5
+ import * as pkce from "../lib/pkce";
6
+ import * as authServer from "../lib/auth-server";
7
+
8
+ function runCli(args: string[], env: Record<string, string> = {}) {
9
+ const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
10
+ const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
11
+ const result = Bun.spawnSync([bunBin, cliPath, ...args], {
12
+ env: { ...process.env, ...env },
13
+ });
14
+ return {
15
+ status: result.exitCode,
16
+ stdout: new TextDecoder().decode(result.stdout),
17
+ stderr: new TextDecoder().decode(result.stderr),
18
+ };
19
+ }
20
+
21
+ describe("URL resolution", () => {
22
+ test("resolveBaseUrls returns correct production defaults", () => {
23
+ const result = util.resolveBaseUrls();
24
+ expect(result.apiBaseUrl).toBe("https://postgres.ai/api/general");
25
+ expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
26
+ });
27
+
28
+ test("resolveBaseUrls strips trailing slashes", () => {
29
+ const result = util.resolveBaseUrls({
30
+ apiBaseUrl: "https://example.com/api/",
31
+ uiBaseUrl: "https://example.com/",
32
+ });
33
+ expect(result.apiBaseUrl).toBe("https://example.com/api");
34
+ expect(result.uiBaseUrl).toBe("https://example.com");
35
+ });
36
+
37
+ test("resolveBaseUrls respects environment variables", () => {
38
+ const originalApiUrl = process.env.PGAI_API_BASE_URL;
39
+ const originalUiUrl = process.env.PGAI_UI_BASE_URL;
40
+
41
+ try {
42
+ process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/";
43
+ process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/";
44
+
45
+ const result = util.resolveBaseUrls();
46
+ expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api");
47
+ expect(result.uiBaseUrl).toBe("https://custom-ui.example.com");
48
+ } finally {
49
+ if (originalApiUrl === undefined) {
50
+ delete process.env.PGAI_API_BASE_URL;
51
+ } else {
52
+ process.env.PGAI_API_BASE_URL = originalApiUrl;
53
+ }
54
+ if (originalUiUrl === undefined) {
55
+ delete process.env.PGAI_UI_BASE_URL;
56
+ } else {
57
+ process.env.PGAI_UI_BASE_URL = originalUiUrl;
58
+ }
59
+ }
60
+ });
61
+
62
+ test("resolveBaseUrls prefers CLI options over env vars", () => {
63
+ const originalApiUrl = process.env.PGAI_API_BASE_URL;
64
+
65
+ try {
66
+ process.env.PGAI_API_BASE_URL = "https://env.example.com/api/";
67
+
68
+ const result = util.resolveBaseUrls({
69
+ apiBaseUrl: "https://cli-option.example.com/api/",
70
+ });
71
+ expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api");
72
+ } finally {
73
+ if (originalApiUrl === undefined) {
74
+ delete process.env.PGAI_API_BASE_URL;
75
+ } else {
76
+ process.env.PGAI_API_BASE_URL = originalApiUrl;
77
+ }
78
+ }
79
+ });
80
+
81
+ test("resolveBaseUrls uses config baseUrl for API", () => {
82
+ const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" });
83
+ expect(result.apiBaseUrl).toBe("https://config.example.com/api");
84
+ // UI should still use default since config doesn't have uiBaseUrl
85
+ expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
86
+ });
87
+
88
+ test("normalizeBaseUrl throws on invalid URL", () => {
89
+ expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/);
90
+ });
91
+
92
+ test("normalizeBaseUrl accepts valid URLs", () => {
93
+ expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com");
94
+ expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com");
95
+ expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api");
96
+ });
97
+ });
98
+
99
+ describe("PKCE module", () => {
100
+ test("generateCodeVerifier returns correct length string", () => {
101
+ const verifier = pkce.generateCodeVerifier();
102
+ expect(typeof verifier).toBe("string");
103
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
104
+ expect(verifier.length).toBeLessThanOrEqual(128);
105
+ });
106
+
107
+ test("generateCodeChallenge returns base64url encoded SHA256", () => {
108
+ const verifier = pkce.generateCodeVerifier();
109
+ const challenge = pkce.generateCodeChallenge(verifier);
110
+ expect(typeof challenge).toBe("string");
111
+ expect(challenge.length).toBeGreaterThan(0);
112
+ // Base64url encoding should not contain + or / characters
113
+ expect(challenge).not.toMatch(/[+/]/);
114
+ });
115
+
116
+ test("generateState returns random string", () => {
117
+ const state1 = pkce.generateState();
118
+ const state2 = pkce.generateState();
119
+ expect(typeof state1).toBe("string");
120
+ expect(state1.length).toBeGreaterThan(0);
121
+ expect(state1).not.toBe(state2); // Should be random
122
+ });
123
+
124
+ test("generatePKCEParams returns all required parameters", () => {
125
+ const params = pkce.generatePKCEParams();
126
+ expect(params.codeVerifier).toBeTruthy();
127
+ expect(params.codeChallenge).toBeTruthy();
128
+ expect(params.codeChallengeMethod).toBe("S256");
129
+ expect(params.state).toBeTruthy();
130
+ });
131
+ });
132
+
133
+ describe("Auth callback server", () => {
134
+ test("createCallbackServer returns correct interface", () => {
135
+ const server = authServer.createCallbackServer(0, "test-state", 1000);
136
+ expect(server.server).toBeTruthy();
137
+ expect(server.server.stop).toBeInstanceOf(Function);
138
+ expect(server.promise).toBeInstanceOf(Promise);
139
+ expect(server.ready).toBeInstanceOf(Promise);
140
+ expect(server.getPort).toBeInstanceOf(Function);
141
+
142
+ // Clean up
143
+ server.server.stop();
144
+ });
145
+
146
+ test("createCallbackServer binds to a port", async () => {
147
+ const server = authServer.createCallbackServer(0, "test-state", 5000);
148
+ const port = await server.ready;
149
+ expect(typeof port).toBe("number");
150
+ expect(port).toBeGreaterThan(0);
151
+
152
+ // Clean up
153
+ server.server.stop();
154
+ });
155
+
156
+ test("createCallbackServer responds to callback requests", async () => {
157
+ const testState = "test-state-" + Math.random().toString(36).substring(7);
158
+ const server = authServer.createCallbackServer(0, testState, 5000);
159
+ const port = await server.ready;
160
+
161
+ // Simulate OAuth callback
162
+ const testCode = "test-auth-code";
163
+ const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`;
164
+
165
+ const fetchPromise = fetch(callbackUrl);
166
+ const result = await server.promise;
167
+
168
+ expect(result.code).toBe(testCode);
169
+ expect(result.state).toBe(testState);
170
+
171
+ // Check response
172
+ const response = await fetchPromise;
173
+ expect(response.status).toBe(200);
174
+ const text = await response.text();
175
+ expect(text).toMatch(/Authentication successful/);
176
+ });
177
+
178
+ test("createCallbackServer rejects on state mismatch", async () => {
179
+ const server = authServer.createCallbackServer(0, "expected-state", 5000);
180
+ const port = await server.ready;
181
+
182
+ const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`;
183
+
184
+ const fetchPromise = fetch(callbackUrl);
185
+
186
+ await expect(server.promise).rejects.toThrow(/State mismatch/);
187
+
188
+ const response = await fetchPromise;
189
+ expect(response.status).toBe(400);
190
+ });
191
+
192
+ test("createCallbackServer handles OAuth errors", async () => {
193
+ const server = authServer.createCallbackServer(0, "test-state", 5000);
194
+ const port = await server.ready;
195
+
196
+ const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`;
197
+
198
+ const fetchPromise = fetch(callbackUrl);
199
+
200
+ await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/);
201
+
202
+ const response = await fetchPromise;
203
+ expect(response.status).toBe(400);
204
+ });
205
+
206
+ test("createCallbackServer times out", async () => {
207
+ const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout
208
+ await server.ready;
209
+
210
+ await expect(server.promise).rejects.toThrow(/timeout/i);
211
+ });
212
+ });
213
+
214
+ describe("CLI auth commands", () => {
215
+ test("cli: auth login --help shows all options", () => {
216
+ const r = runCli(["auth", "login", "--help"]);
217
+ expect(r.status).toBe(0);
218
+ expect(r.stdout).toMatch(/--set-key/);
219
+ expect(r.stdout).toMatch(/--debug/);
220
+ });
221
+
222
+ test("cli: auth show-key --help works", () => {
223
+ const r = runCli(["auth", "show-key", "--help"]);
224
+ expect(r.status).toBe(0);
225
+ expect(r.stdout).toMatch(/show.*key/i);
226
+ });
227
+
228
+ test("cli: auth remove-key --help works", () => {
229
+ const r = runCli(["auth", "remove-key", "--help"]);
230
+ expect(r.status).toBe(0);
231
+ expect(r.stdout).toMatch(/remove.*key/i);
232
+ });
233
+ });
234
+
235
+ describe("maskSecret utility", () => {
236
+ test("masks short secrets completely", () => {
237
+ expect(util.maskSecret("abc")).toBe("****");
238
+ expect(util.maskSecret("12345678")).toBe("****");
239
+ });
240
+
241
+ test("masks medium secrets with visible ends", () => {
242
+ const masked = util.maskSecret("1234567890123456");
243
+ // maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings
244
+ expect(masked).toMatch(/^1234\*+3456$/);
245
+ });
246
+
247
+ test("masks long secrets appropriately", () => {
248
+ const secret = "abcdefghij1234567890klmnopqrstuvwxyz";
249
+ const masked = util.maskSecret(secret);
250
+ expect(masked.startsWith("abcdefghij12")).toBe(true);
251
+ expect(masked.endsWith("wxyz")).toBe(true);
252
+ expect(masked).toMatch(/\*+/);
253
+ });
254
+
255
+ test("handles empty string", () => {
256
+ expect(util.maskSecret("")).toBe("");
257
+ });
258
+ });