neonctl 2.22.2 → 2.23.1
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 +84 -0
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/connection_string.js +9 -1
- package/commands/functions.js +268 -0
- package/commands/index.js +4 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +6 -1
- package/functions_api.js +43 -0
- package/package.json +15 -5
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/esbuild.js +147 -0
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/checkout.test.js +0 -170
- package/commands/connection_string.test.js +0 -196
- package/commands/data_api.test.js +0 -169
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/link.test.js +0 -381
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/psql.test.js +0 -49
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/context.test.js +0 -119
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
package/utils/esbuild.js
ADDED
|
@@ -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
|
+
};
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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 (!
|
|
17
|
-
|
|
81
|
+
if (!child.killed && code !== null) {
|
|
82
|
+
child.kill(code);
|
|
18
83
|
}
|
|
19
84
|
});
|
|
20
85
|
}
|
|
21
|
-
|
|
22
|
-
|
|
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
package/writer.js
CHANGED
package/commands/auth.test.js
DELETED
|
@@ -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
|
-
});
|