neonctl 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +242 -16
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/checkout.js +249 -0
  5. package/commands/connection_string.js +15 -2
  6. package/commands/data_api.js +286 -0
  7. package/commands/functions.js +277 -0
  8. package/commands/index.js +12 -0
  9. package/commands/link.js +667 -0
  10. package/commands/neon_auth.js +1013 -0
  11. package/commands/projects.js +9 -1
  12. package/commands/psql.js +62 -0
  13. package/commands/set_context.js +7 -2
  14. package/context.js +86 -14
  15. package/functions_api.js +44 -0
  16. package/index.js +3 -0
  17. package/package.json +60 -51
  18. package/psql/cli.js +51 -0
  19. package/psql/command/cmd_cond.js +437 -0
  20. package/psql/command/cmd_connect.js +815 -0
  21. package/psql/command/cmd_copy.js +1025 -0
  22. package/psql/command/cmd_describe.js +1810 -0
  23. package/psql/command/cmd_format.js +909 -0
  24. package/psql/command/cmd_io.js +2187 -0
  25. package/psql/command/cmd_lo.js +385 -0
  26. package/psql/command/cmd_meta.js +970 -0
  27. package/psql/command/cmd_misc.js +187 -0
  28. package/psql/command/cmd_pipeline.js +1141 -0
  29. package/psql/command/cmd_restrict.js +171 -0
  30. package/psql/command/cmd_show.js +751 -0
  31. package/psql/command/dispatch.js +343 -0
  32. package/psql/command/inputQueue.js +42 -0
  33. package/psql/command/shared.js +71 -0
  34. package/psql/complete/filenames.js +139 -0
  35. package/psql/complete/index.js +104 -0
  36. package/psql/complete/matcher.js +314 -0
  37. package/psql/complete/psqlVars.js +247 -0
  38. package/psql/complete/queries.js +491 -0
  39. package/psql/complete/rules.js +2387 -0
  40. package/psql/core/common.js +1250 -0
  41. package/psql/core/help.js +576 -0
  42. package/psql/core/mainloop.js +1353 -0
  43. package/psql/core/prompt.js +437 -0
  44. package/psql/core/settings.js +684 -0
  45. package/psql/core/sqlHelp.js +1066 -0
  46. package/psql/core/startup.js +840 -0
  47. package/psql/core/syncVars.js +116 -0
  48. package/psql/core/variables.js +287 -0
  49. package/psql/describe/formatters.js +1277 -0
  50. package/psql/describe/processNamePattern.js +270 -0
  51. package/psql/describe/queries.js +2373 -0
  52. package/psql/describe/versionGate.js +43 -0
  53. package/psql/index.js +2005 -0
  54. package/psql/io/history.js +299 -0
  55. package/psql/io/input.js +120 -0
  56. package/psql/io/lineEditor/buffer.js +323 -0
  57. package/psql/io/lineEditor/complete.js +227 -0
  58. package/psql/io/lineEditor/filename.js +159 -0
  59. package/psql/io/lineEditor/index.js +891 -0
  60. package/psql/io/lineEditor/keymap.js +738 -0
  61. package/psql/io/lineEditor/vt100.js +363 -0
  62. package/psql/io/pgpass.js +202 -0
  63. package/psql/io/pgservice.js +194 -0
  64. package/psql/io/psqlrc.js +422 -0
  65. package/psql/print/aligned.js +1756 -0
  66. package/psql/print/asciidoc.js +248 -0
  67. package/psql/print/crosstab.js +460 -0
  68. package/psql/print/csv.js +92 -0
  69. package/psql/print/html.js +258 -0
  70. package/psql/print/json.js +96 -0
  71. package/psql/print/latex.js +396 -0
  72. package/psql/print/pager.js +265 -0
  73. package/psql/print/troff.js +258 -0
  74. package/psql/print/unaligned.js +118 -0
  75. package/psql/print/units.js +135 -0
  76. package/psql/scanner/slash.js +513 -0
  77. package/psql/scanner/sql.js +910 -0
  78. package/psql/scanner/stringutils.js +390 -0
  79. package/psql/types/backslash.js +1 -0
  80. package/psql/types/connection.js +1 -0
  81. package/psql/types/index.js +7 -0
  82. package/psql/types/printer.js +1 -0
  83. package/psql/types/repl.js +1 -0
  84. package/psql/types/scanner.js +24 -0
  85. package/psql/types/settings.js +1 -0
  86. package/psql/types/variables.js +1 -0
  87. package/psql/wire/connection.js +2844 -0
  88. package/psql/wire/copy.js +108 -0
  89. package/psql/wire/notify.js +59 -0
  90. package/psql/wire/pipeline.js +519 -0
  91. package/psql/wire/protocol.js +466 -0
  92. package/psql/wire/sasl.js +296 -0
  93. package/psql/wire/tls.js +596 -0
  94. package/test_utils/fixtures.js +1 -0
  95. package/utils/enrichers.js +18 -1
  96. package/utils/esbuild.js +147 -0
  97. package/utils/middlewares.js +1 -1
  98. package/utils/psql.js +107 -11
  99. package/utils/zip.js +4 -0
  100. package/writer.js +1 -1
  101. package/commands/auth.test.js +0 -211
  102. package/commands/branches.test.js +0 -460
  103. package/commands/connection_string.test.js +0 -196
  104. package/commands/databases.test.js +0 -39
  105. package/commands/help.test.js +0 -9
  106. package/commands/init.test.js +0 -56
  107. package/commands/ip_allow.test.js +0 -59
  108. package/commands/operations.test.js +0 -7
  109. package/commands/orgs.test.js +0 -7
  110. package/commands/projects.test.js +0 -144
  111. package/commands/roles.test.js +0 -37
  112. package/commands/set_context.test.js +0 -159
  113. package/commands/vpc_endpoints.test.js +0 -69
  114. package/env.test.js +0 -55
  115. package/utils/formats.test.js +0 -32
  116. package/writer.test.js +0 -104
@@ -0,0 +1,147 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import which from 'which';
6
+ const NOT_FOUND = 'esbuild not found. neonctl ships esbuild for most platforms; if you see ' +
7
+ 'this, install esbuild and ensure it is on your PATH (e.g. `npm i -g ' +
8
+ 'esbuild`), or set NEON_ESBUILD_PATH to an esbuild binary.';
9
+ const defaultDeps = {
10
+ // @yao-pkg/pkg defines process.pkg inside the packaged binary.
11
+ isPackaged: () => process.pkg !== undefined,
12
+ loadEsbuild: (name) => import(name),
13
+ };
14
+ // Internal signal: the esbuild JS module could not be imported (exotic platform
15
+ // or not installed). Tells bundleEntry to fall back to the binary; never shown
16
+ // to the user.
17
+ class ModuleNotAvailable extends Error {
18
+ }
19
+ const message = (err) => err instanceof Error ? err.message : String(err);
20
+ const toFilesByBasename = (files) => {
21
+ const out = {};
22
+ for (const f of files)
23
+ out[basename(f.path)] = f.contents;
24
+ return out;
25
+ };
26
+ const bundleViaModule = async (source, loadEsbuild) => {
27
+ // esbuild is resolved by a COMPUTED specifier, never the literal string
28
+ // 'esbuild'. Both rollup (bundle step) and @yao-pkg/pkg (binary step)
29
+ // statically scan for literal import()/require() calls and would otherwise
30
+ // pull esbuild and its native Go binary into the bundle/snapshot — bloating
31
+ // the packaged CLI and emitting "cannot bundle native binary" warnings. The
32
+ // packaged binary never imports esbuild at all (the isPackaged guard sends it
33
+ // to the binary path), so keeping this specifier invisible to the scanners is
34
+ // what keeps esbuild out of the snapshot.
35
+ // Do NOT "simplify" this back to import('esbuild').
36
+ const name = ['es', 'build'].join('');
37
+ let esbuild;
38
+ try {
39
+ esbuild = await loadEsbuild(name);
40
+ }
41
+ catch {
42
+ throw new ModuleNotAvailable();
43
+ }
44
+ // Mirrors the binary-path bundling flags; write:false keeps output in memory.
45
+ // logLevel:'silent' suppresses esbuild's own stderr — the rejected error
46
+ // still carries the diagnostic, matching the binary path's captured-stderr.
47
+ const result = await esbuild
48
+ .build({
49
+ entryPoints: [source],
50
+ bundle: true,
51
+ outfile: 'out.js',
52
+ write: false,
53
+ sourcemap: true,
54
+ minify: true,
55
+ format: 'esm',
56
+ platform: 'node',
57
+ packages: 'external',
58
+ logLevel: 'silent',
59
+ })
60
+ .catch((err) => {
61
+ throw new Error(`Failed to bundle function from ${source}. ${message(err)}`.trim());
62
+ });
63
+ const files = result.outputFiles ?? [];
64
+ // write:false with one entry always yields out.js + out.js.map; an empty set
65
+ // means the API contract changed under us — fail loud rather than ship an
66
+ // empty archive.
67
+ if (files.length === 0) {
68
+ throw new Error(`Failed to bundle function from ${source}. esbuild produced no output.`);
69
+ }
70
+ return toFilesByBasename(files);
71
+ };
72
+ // Find the esbuild binary at deploy time. An explicit override is authoritative
73
+ // (so it fails loudly if wrong); otherwise prefer the host PATH, then a locally
74
+ // installed copy.
75
+ const resolveEsbuild = () => {
76
+ const override = process.env.NEON_ESBUILD_PATH;
77
+ if (override) {
78
+ if (existsSync(override))
79
+ return override;
80
+ throw new Error(NOT_FOUND);
81
+ }
82
+ const onPath = which.sync('esbuild', { nothrow: true });
83
+ if (onPath)
84
+ return onPath;
85
+ // CWD-relative (not install-relative): helps the dev checkout where esbuild is
86
+ // a devDependency. In `npm i -g` and pkg installs the PATH branch above wins.
87
+ const local = join(process.cwd(), 'node_modules', '.bin', 'esbuild');
88
+ if (existsSync(local))
89
+ return local;
90
+ throw new Error(NOT_FOUND);
91
+ };
92
+ const runEsbuild = (bin, args) => new Promise((resolve, reject) => {
93
+ // stderr is captured (NOT inherited): with --log-level=error a success emits
94
+ // nothing, and a failure's diagnostic is read out below. Never use 'inherit'.
95
+ const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
96
+ let stderr = '';
97
+ child.stderr.on('data', (chunk) => {
98
+ stderr += chunk.toString();
99
+ });
100
+ child.on('error', reject);
101
+ child.on('close', (code) => {
102
+ resolve({ code, stderr });
103
+ });
104
+ });
105
+ const bundleViaBinary = async (source) => {
106
+ const bin = resolveEsbuild();
107
+ const outDir = mkdtempSync(join(tmpdir(), 'neon-fn-bundle-'));
108
+ const outfile = join(outDir, 'out.js');
109
+ try {
110
+ const { code, stderr } = await runEsbuild(bin, [
111
+ source,
112
+ '--bundle',
113
+ `--outfile=${outfile}`,
114
+ '--sourcemap',
115
+ '--minify',
116
+ '--format=esm',
117
+ '--platform=node',
118
+ '--packages=external',
119
+ '--log-level=error',
120
+ ]);
121
+ if (code !== 0) {
122
+ throw new Error(`Failed to bundle function from ${source}. ${stderr.trim()}`.trim());
123
+ }
124
+ return {
125
+ 'out.js': new Uint8Array(readFileSync(outfile)),
126
+ 'out.js.map': new Uint8Array(readFileSync(`${outfile}.map`)),
127
+ };
128
+ }
129
+ finally {
130
+ rmSync(outDir, { recursive: true, force: true });
131
+ }
132
+ };
133
+ // Bundle `source` into the files the Functions archive expects, keyed by
134
+ // basename. npm installs bundle in-process via the esbuild module; the packaged
135
+ // binary (and platforms esbuild can't run on) shell out to an esbuild binary.
136
+ export const bundleEntry = async (source, deps = defaultDeps) => {
137
+ if (deps.isPackaged())
138
+ return bundleViaBinary(source);
139
+ try {
140
+ return await bundleViaModule(source, deps.loadEsbuild);
141
+ }
142
+ catch (err) {
143
+ if (err instanceof ModuleNotAvailable)
144
+ return bundleViaBinary(source);
145
+ throw err;
146
+ }
147
+ };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export const fillInArgs = (args, currentArgs = args, acc = []) => {
7
7
  Object.entries(currentArgs).forEach(([k, v]) => {
8
- if (k === '_') {
8
+ if (k === '_' || k === '--') {
9
9
  return;
10
10
  }
11
11
  // check if the value is an Object
package/utils/psql.js CHANGED
@@ -1,24 +1,120 @@
1
- import { log } from '../log.js';
2
1
  import { spawn } from 'child_process';
3
2
  import which from 'which';
4
- export const psql = async (connection_uri, args = []) => {
5
- const psqlPathOrNull = await which('psql', { nothrow: true });
6
- if (psqlPathOrNull === null) {
7
- log.error(`psql is not available in the PATH`);
8
- process.exit(1);
3
+ import { closeAnalytics, trackEvent } from '../analytics.js';
4
+ import { log } from '../log.js';
5
+ const FALLBACK_ENV = 'NEONCTL_PSQL_FALLBACK';
6
+ /** Max time we wait for the analytics flush before handing off to psql. */
7
+ const ANALYTICS_FLUSH_TIMEOUT_MS = 3000;
8
+ /**
9
+ * Decide which psql implementation will run, and why. The PATH probe is
10
+ * skipped when TS is forced (flag or env) — we don't need it and it'd be a
11
+ * wasted lookup — so `nativeAvailable` is `null` ("not checked") in those
12
+ * cases rather than a misleading `false`.
13
+ */
14
+ const planPsql = async (opts) => {
15
+ if (opts.mode === 'ts') {
16
+ return {
17
+ implementation: 'ts',
18
+ reason: 'forced_flag',
19
+ nativeAvailable: null,
20
+ nativePath: null,
21
+ };
22
+ }
23
+ if (process.env[FALLBACK_ENV] === '1') {
24
+ return {
25
+ implementation: 'ts',
26
+ reason: 'forced_env',
27
+ nativeAvailable: null,
28
+ nativePath: null,
29
+ };
9
30
  }
31
+ const nativePath = await which('psql', { nothrow: true });
32
+ const nativeAvailable = nativePath !== null;
33
+ if (opts.mode === 'native') {
34
+ return {
35
+ implementation: 'native',
36
+ reason: 'forced_native',
37
+ nativeAvailable,
38
+ nativePath,
39
+ };
40
+ }
41
+ // 'auto' (or unset): strict fallback — prefer native, TS only if missing.
42
+ return nativeAvailable
43
+ ? {
44
+ implementation: 'native',
45
+ reason: 'native_available',
46
+ nativeAvailable,
47
+ nativePath,
48
+ }
49
+ : {
50
+ implementation: 'ts',
51
+ reason: 'fallback_no_native',
52
+ nativeAvailable,
53
+ nativePath,
54
+ };
55
+ };
56
+ /**
57
+ * Record which psql implementation is about to run, then flush analytics.
58
+ *
59
+ * Both exec paths below call `process.exit()`, which short-circuits the
60
+ * main loop's `closeAnalytics()` — so without flushing here the event (and
61
+ * any earlier queued events, e.g. `CLI Started`) would be dropped. The
62
+ * flush is bounded by {@link ANALYTICS_FLUSH_TIMEOUT_MS} so a slow or
63
+ * unreachable analytics endpoint can't stall the psql launch. No-ops when
64
+ * analytics is disabled (`--analytics false`), since the client is absent.
65
+ */
66
+ const reportPsqlInvocation = async (plan) => {
67
+ trackEvent('psql_invoked', {
68
+ implementation: plan.implementation,
69
+ reason: plan.reason,
70
+ nativeAvailable: plan.nativeAvailable,
71
+ });
72
+ await closeAnalytics({ timeout: ANALYTICS_FLUSH_TIMEOUT_MS });
73
+ };
74
+ const execNative = async (binary, connection_uri, args) => {
10
75
  log.info('Connecting to the database using psql...');
11
- const psql = spawn(psqlPathOrNull, [connection_uri, ...args], {
76
+ const child = spawn(binary, [connection_uri, ...args], {
12
77
  stdio: 'inherit',
13
78
  });
14
79
  for (const signame of ['SIGINT', 'SIGTERM']) {
15
80
  process.on(signame, (code) => {
16
- if (!psql.killed && code !== null) {
17
- psql.kill(code);
81
+ if (!child.killed && code !== null) {
82
+ child.kill(code);
18
83
  }
19
84
  });
20
85
  }
21
- psql.on('exit', (code) => {
22
- process.exit(code === null ? 1 : code);
86
+ return new Promise((_, reject) => {
87
+ child.on('exit', (code) => {
88
+ process.exit(code === null ? 1 : code);
89
+ });
90
+ child.on('error', reject);
91
+ });
92
+ };
93
+ const execTs = async (connection_uri, args) => {
94
+ log.info('Connecting to the database using embedded psql (TypeScript)...');
95
+ const { runPsql } = await import('../psql/index.js');
96
+ const code = await runPsql([connection_uri, ...args], {
97
+ stdin: process.stdin,
98
+ stdout: process.stdout,
99
+ stderr: process.stderr,
23
100
  });
101
+ process.exit(code);
102
+ };
103
+ export const psql = async (connection_uri, args = [], opts = {}) => {
104
+ const plan = await planPsql(opts);
105
+ await reportPsqlInvocation(plan);
106
+ if (plan.implementation === 'ts') {
107
+ if (plan.reason === 'fallback_no_native') {
108
+ log.info('psql binary not found on PATH; falling back to embedded TypeScript psql');
109
+ }
110
+ return execTs(connection_uri, args);
111
+ }
112
+ // implementation === 'native'
113
+ if (plan.nativePath === null) {
114
+ // Only reachable when native was explicitly requested (mode: 'native')
115
+ // but no binary is on PATH.
116
+ log.error(`psql is not available in the PATH`);
117
+ process.exit(1);
118
+ }
119
+ return execNative(plan.nativePath, connection_uri, args);
24
120
  };
package/utils/zip.js ADDED
@@ -0,0 +1,4 @@
1
+ import { zipSync } from 'fflate';
2
+ // Zip the esbuild output (out.js + out.js.map) into the archive the Functions
3
+ // deploy endpoint expects. Compression level 6 matches the previous bundler.
4
+ export const zipBundle = (entries) => zipSync(entries, { level: 6 });
package/writer.js CHANGED
@@ -46,7 +46,7 @@ const writeTable = (chunks, out) => {
46
46
  ? value.join('\n')
47
47
  : isObject(value)
48
48
  ? JSON.stringify(value, null, 2)
49
- : value;
49
+ : (value ?? '');
50
50
  }));
51
51
  });
52
52
  if (title) {
@@ -1,211 +0,0 @@
1
- import axios from 'axios';
2
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
3
- import { join } from 'path';
4
- import { afterAll, beforeAll, beforeEach, describe, expect, vi } from 'vitest';
5
- import * as authModule from '../auth';
6
- import { test } from '../test_utils/fixtures';
7
- import { startOauthServer } from '../test_utils/oauth_server';
8
- import { authFlow, ensureAuth, deleteCredentials } from './auth';
9
- vi.mock('open', () => ({ default: vi.fn((url) => axios.get(url)) }));
10
- vi.mock('../pkg.ts', () => ({ default: { version: '0.0.0' } }));
11
- describe('auth', () => {
12
- let configDir = '';
13
- let oauthServer;
14
- beforeAll(async () => {
15
- configDir = mkdtempSync('test-config');
16
- oauthServer = await startOauthServer();
17
- });
18
- afterAll(async () => {
19
- rmSync(configDir, { recursive: true });
20
- await oauthServer.stop();
21
- });
22
- test('should auth', async ({ runMockServer }) => {
23
- const server = await runMockServer('main');
24
- await authFlow({
25
- _: ['auth'],
26
- apiHost: `http://localhost:${server.address().port}`,
27
- clientId: 'test-client-id',
28
- configDir,
29
- forceAuth: true,
30
- oauthHost: `http://localhost:${oauthServer.address().port}`,
31
- allowUnsafeTls: true,
32
- });
33
- const credentials = JSON.parse(readFileSync(`${configDir}/credentials.json`, 'utf-8'));
34
- expect(credentials.access_token).toEqual(expect.any(String));
35
- expect(credentials.refresh_token).toEqual(expect.any(String));
36
- expect(credentials.user_id).toEqual(expect.any(String));
37
- });
38
- });
39
- describe('ensureAuth', () => {
40
- let configDir = '';
41
- let oauthServer;
42
- let mockApiClient;
43
- let authSpy;
44
- let refreshTokenSpy;
45
- beforeAll(async () => {
46
- configDir = mkdtempSync('test-config');
47
- oauthServer = await startOauthServer();
48
- mockApiClient = {};
49
- authSpy = vi.spyOn(authModule, 'auth');
50
- refreshTokenSpy = vi.spyOn(authModule, 'refreshToken');
51
- });
52
- afterAll(async () => {
53
- rmSync(configDir, { recursive: true });
54
- await oauthServer.stop();
55
- vi.restoreAllMocks();
56
- });
57
- beforeEach(() => {
58
- authSpy.mockClear();
59
- refreshTokenSpy.mockClear();
60
- });
61
- const setupTestProps = (server) => ({
62
- _: ['some-command'],
63
- configDir,
64
- oauthHost: `http://localhost:${oauthServer.address().port}`,
65
- clientId: 'test-client-id',
66
- forceAuth: true,
67
- apiKey: '',
68
- apiHost: `http://localhost:${server.address().port}`,
69
- help: false,
70
- apiClient: mockApiClient,
71
- allowUnsafeTls: true,
72
- });
73
- test('should start new auth flow when refresh token fails', async ({ runMockServer, }) => {
74
- refreshTokenSpy.mockImplementationOnce(() => Promise.reject(new Error('AUTH_REFRESH_FAILED')));
75
- authSpy.mockImplementationOnce(() => Promise.resolve({
76
- access_token: 'new-auth-token',
77
- refresh_token: 'new-refresh-token',
78
- expires_at: Math.floor(Date.now() / 1000) + 3600,
79
- }));
80
- const server = await runMockServer('main');
81
- const expiredTokenSet = {
82
- access_token: 'expired-token',
83
- refresh_token: 'refresh-token',
84
- expires_at: Date.now() - 3600 * 1000,
85
- };
86
- writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
87
- const props = setupTestProps(server);
88
- await ensureAuth(props);
89
- expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
90
- expect(authSpy).toHaveBeenCalledTimes(1);
91
- expect(props.apiKey).toBe('new-auth-token');
92
- });
93
- test('should trigger auth flow when credentials.json does not exist', async ({ runMockServer, }) => {
94
- const server = await runMockServer('main');
95
- // Ensure the credentials file does not exist
96
- const credentialsPath = join(configDir, 'credentials.json');
97
- if (existsSync(credentialsPath)) {
98
- rmSync(credentialsPath);
99
- }
100
- const props = setupTestProps(server);
101
- await ensureAuth(props);
102
- expect(authSpy).toHaveBeenCalledTimes(1);
103
- expect(refreshTokenSpy).not.toHaveBeenCalled();
104
- expect(props.apiKey).toEqual(expect.any(String));
105
- });
106
- test('should trigger auth flow when credentials.json is invalid', async ({ runMockServer, }) => {
107
- const server = await runMockServer('main');
108
- // Write an empty credentials file
109
- writeFileSync(join(configDir, 'credentials.json'), '', { mode: 0o700 });
110
- const props = setupTestProps(server);
111
- await ensureAuth(props);
112
- expect(authSpy).toHaveBeenCalledTimes(1);
113
- expect(refreshTokenSpy).not.toHaveBeenCalled();
114
- expect(props.apiKey).toEqual(expect.any(String));
115
- });
116
- test('should try refresh when token is missing access_token but has refresh_token', async ({ runMockServer, }) => {
117
- const server = await runMockServer('main');
118
- const tokenWithoutAccess = {
119
- refresh_token: 'refresh-token',
120
- };
121
- writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(tokenWithoutAccess), { mode: 0o700 });
122
- refreshTokenSpy.mockImplementationOnce(() => Promise.resolve({
123
- access_token: 'refreshed-token',
124
- refresh_token: 'new-refresh-token',
125
- expires_at: Math.floor(Date.now() / 1000) + 3600,
126
- }));
127
- const props = setupTestProps(server);
128
- await ensureAuth(props);
129
- expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
130
- expect(authSpy).not.toHaveBeenCalled();
131
- expect(props.apiKey).toBe('refreshed-token');
132
- });
133
- test('should use existing valid token', async ({ runMockServer }) => {
134
- const server = await runMockServer('main');
135
- const validTokenSet = {
136
- access_token: 'valid-token',
137
- refresh_token: 'refresh-token',
138
- expires_at: Date.now() + 3600 * 1000, // 1 hour from now
139
- };
140
- writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(validTokenSet), { mode: 0o700 });
141
- const props = setupTestProps(server);
142
- await ensureAuth(props);
143
- expect(authSpy).not.toHaveBeenCalled();
144
- expect(refreshTokenSpy).not.toHaveBeenCalled();
145
- expect(props.apiKey).toBe('valid-token');
146
- });
147
- test('should require auth for init command', async ({ runMockServer }) => {
148
- const server = await runMockServer('main');
149
- const credentialsPath = join(configDir, 'credentials.json');
150
- if (existsSync(credentialsPath)) {
151
- rmSync(credentialsPath);
152
- }
153
- const props = {
154
- ...setupTestProps(server),
155
- _: ['init'],
156
- };
157
- await ensureAuth(props);
158
- expect(authSpy).toHaveBeenCalledTimes(1);
159
- expect(refreshTokenSpy).not.toHaveBeenCalled();
160
- expect(props.apiKey).toEqual(expect.any(String));
161
- });
162
- test('should successfully refresh expired token', async ({ runMockServer, }) => {
163
- refreshTokenSpy.mockImplementationOnce(() => Promise.resolve({
164
- access_token: 'new-token',
165
- refresh_token: 'new-refresh-token',
166
- expires_at: Math.floor(Date.now() / 1000) + 3600,
167
- }));
168
- const server = await runMockServer('main');
169
- const expiredTokenSet = {
170
- access_token: 'expired-token',
171
- refresh_token: 'refresh-token',
172
- expires_at: Date.now() - 3600 * 1000, // expired 1 hour ago
173
- };
174
- writeFileSync(join(configDir, 'credentials.json'), JSON.stringify(expiredTokenSet), { mode: 0o700 });
175
- const props = setupTestProps(server);
176
- await ensureAuth(props);
177
- expect(refreshTokenSpy).toHaveBeenCalledTimes(1);
178
- expect(authSpy).not.toHaveBeenCalled();
179
- expect(props.apiKey).toBe('new-token');
180
- });
181
- });
182
- describe('deleteCredentials', () => {
183
- let configDir = '';
184
- beforeAll(() => {
185
- configDir = mkdtempSync('test-config-delete');
186
- });
187
- afterAll(() => {
188
- rmSync(configDir, { recursive: true });
189
- });
190
- test('should successfully delete credentials file', () => {
191
- const credentialsPath = join(configDir, 'credentials.json');
192
- writeFileSync(credentialsPath, 'test-content', { mode: 0o700 });
193
- expect(existsSync(credentialsPath)).toBe(true);
194
- deleteCredentials(configDir);
195
- expect(existsSync(credentialsPath)).toBe(false);
196
- });
197
- test('should handle non-existent file gracefully', () => {
198
- const nonExistentDir = mkdtempSync('test-config-nonexistent');
199
- // Ensure the file doesn't exist
200
- const credentialsPath = join(nonExistentDir, 'credentials.json');
201
- if (existsSync(credentialsPath)) {
202
- rmSync(credentialsPath);
203
- }
204
- expect(existsSync(credentialsPath)).toBe(false);
205
- // Should not throw an error
206
- expect(() => {
207
- deleteCredentials(nonExistentDir);
208
- }).not.toThrow();
209
- rmSync(nonExistentDir, { recursive: true });
210
- });
211
- });