neonctl 2.23.1 → 2.24.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 +109 -18
- package/commands/auth.js +12 -0
- package/commands/branches.js +14 -6
- package/commands/bucket.js +368 -0
- package/commands/checkout.js +49 -61
- package/commands/config.js +249 -0
- package/commands/deploy.js +25 -0
- package/commands/dev.js +743 -0
- package/commands/env.js +121 -0
- package/commands/index.js +10 -0
- package/commands/link.js +76 -12
- package/config_format.js +66 -0
- package/dev/env.js +199 -0
- package/dev/functions.js +72 -0
- package/dev/inputs.js +63 -0
- package/dev/runtime.js +146 -0
- package/env_file.js +146 -0
- package/index.js +2 -0
- package/package.json +7 -2
- package/pkg.js +23 -1
- package/psql/command/cmd_format.js +12 -12
- package/psql/command/cmd_io.js +2 -2
- package/psql/command/cmd_restrict.js +8 -8
- package/psql/describe/formatters.js +1 -1
- package/psql/print/aligned.js +30 -30
- package/psql/print/json.js +5 -5
- package/psql/scanner/sql.js +4 -4
- package/storage_api.js +114 -0
- package/utils/branch_picker.js +88 -0
- package/utils/esbuild.js +8 -5
- package/utils/zip.js +1 -1
package/dev/functions.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { loadConfigFromFile, resolveConfig, } from '@neondatabase/config';
|
|
4
|
+
/**
|
|
5
|
+
* Load `neon.ts` (if any) and resolve the list of functions it declares into
|
|
6
|
+
* {@link PlannedFunction}s for `neon dev` to serve. Returns `null` when there is no
|
|
7
|
+
* `neon.ts` on the path from `cwd` up to the repo root — the caller turns that into a
|
|
8
|
+
* "no --source and no neon.ts" error.
|
|
9
|
+
*
|
|
10
|
+
* `branchName` is used only to evaluate a policy that switches on `branch.name`; the
|
|
11
|
+
* function list is otherwise branch-independent, so a placeholder is fine when unknown.
|
|
12
|
+
*/
|
|
13
|
+
export const resolveFunctionsFromConfig = async (cwd, branchName) => {
|
|
14
|
+
const loaded = await loadNeonConfig(cwd);
|
|
15
|
+
if (!loaded)
|
|
16
|
+
return null;
|
|
17
|
+
const { config, configDir, configPath } = loaded;
|
|
18
|
+
const resolved = resolveConfig(config, {
|
|
19
|
+
name: branchName ?? 'local',
|
|
20
|
+
exists: branchName !== undefined,
|
|
21
|
+
});
|
|
22
|
+
const functions = resolved.preview?.functions ?? [];
|
|
23
|
+
const planned = functions.map((fn) => {
|
|
24
|
+
const source = isAbsolute(fn.source)
|
|
25
|
+
? fn.source
|
|
26
|
+
: resolve(configDir, fn.source);
|
|
27
|
+
if (!existsSync(source)) {
|
|
28
|
+
throw new Error(`Function "${fn.slug}" points at a source that does not exist: ${source} ` +
|
|
29
|
+
`(from neon.ts "${fn.source}"). Fix the source path and re-run.`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
slug: fn.slug,
|
|
33
|
+
name: fn.name,
|
|
34
|
+
source,
|
|
35
|
+
portless: fn.dev?.portless === true,
|
|
36
|
+
...(devPort(fn.dev) !== undefined
|
|
37
|
+
? { port: devPort(fn.dev) }
|
|
38
|
+
: {}),
|
|
39
|
+
env: { ...fn.env },
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
return { configPath, functions: planned };
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Read the `port` off a {@link FunctionDevConfig}. The discriminated union guarantees a
|
|
46
|
+
* `port` is present whenever `portless` is true, so this is `undefined` only for the
|
|
47
|
+
* non-portless, port-omitted case (the supervisor then searches for a free port).
|
|
48
|
+
*/
|
|
49
|
+
const devPort = (dev) => dev?.port;
|
|
50
|
+
/**
|
|
51
|
+
* Load a `neon.ts` policy if one exists, returning the loaded config, the resolved path to
|
|
52
|
+
* the config file (used by the dev server to watch it), and the directory it lives in (used
|
|
53
|
+
* to resolve each function's relative `source`). Returns `null` when no config file is
|
|
54
|
+
* found; surfaces real load errors (e.g. a syntax error).
|
|
55
|
+
*/
|
|
56
|
+
const loadNeonConfig = async (cwd) => {
|
|
57
|
+
try {
|
|
58
|
+
const { config, resolvedPath } = await loadConfigFromFile({ cwd });
|
|
59
|
+
return {
|
|
60
|
+
config,
|
|
61
|
+
configDir: dirname(resolvedPath),
|
|
62
|
+
configPath: resolvedPath,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
if (/Could not find a Neon config file/i.test(message)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
};
|
package/dev/inputs.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
const defaultDeps = {
|
|
3
|
+
isPackaged: () => process.pkg !== undefined,
|
|
4
|
+
loadEsbuild: (name) => import(name),
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the exact set of files esbuild reads to produce the bundle for
|
|
8
|
+
* `source` — the entry plus every local module it imports (npm deps are left
|
|
9
|
+
* external, so they never appear). These are the files the dev watcher should
|
|
10
|
+
* watch, so a single edit triggers exactly one rebuild.
|
|
11
|
+
*
|
|
12
|
+
* Returns absolute paths, or `null` when the precise set cannot be computed —
|
|
13
|
+
* either inside the packaged binary (which cannot import esbuild as a module;
|
|
14
|
+
* it shells out to a binary that has no JSON-metafile equivalent here) or on a
|
|
15
|
+
* platform where the esbuild module won't load. Callers fall back to a coarser
|
|
16
|
+
* watch in that case.
|
|
17
|
+
*
|
|
18
|
+
* This performs a metafile-only pass (`write:false`, `metafile:true`) so it
|
|
19
|
+
* never emits output; the actual bundle bytes still come from `bundleEntry`.
|
|
20
|
+
*/
|
|
21
|
+
export const resolveWatchInputs = async (source, deps = defaultDeps) => {
|
|
22
|
+
if (deps.isPackaged())
|
|
23
|
+
return null;
|
|
24
|
+
// esbuild is resolved by a COMPUTED specifier, never the literal string
|
|
25
|
+
// 'esbuild', for the same reason as src/utils/esbuild.ts: rollup and
|
|
26
|
+
// @yao-pkg/pkg statically scan for literal import()/require() and would pull
|
|
27
|
+
// esbuild's native Go binary into the bundle/snapshot. Keep it invisible.
|
|
28
|
+
const name = ['es', 'build'].join('');
|
|
29
|
+
let esbuild;
|
|
30
|
+
try {
|
|
31
|
+
esbuild = await deps.loadEsbuild(name);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
let metafile;
|
|
37
|
+
try {
|
|
38
|
+
// Mirrors bundleEntry's flags so the resolved input graph matches the real
|
|
39
|
+
// bundle. metafile:true + write:false makes this a pure analysis pass.
|
|
40
|
+
const result = await esbuild.build({
|
|
41
|
+
entryPoints: [source],
|
|
42
|
+
bundle: true,
|
|
43
|
+
write: false,
|
|
44
|
+
metafile: true,
|
|
45
|
+
format: 'esm',
|
|
46
|
+
platform: 'node',
|
|
47
|
+
packages: 'external',
|
|
48
|
+
logLevel: 'silent',
|
|
49
|
+
});
|
|
50
|
+
metafile = result.metafile;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// A bundle error here is non-fatal for watching: bundleEntry surfaces the
|
|
54
|
+
// real diagnostic. Fall back to the coarser watch so edits still rebuild.
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const inputs = metafile?.inputs;
|
|
58
|
+
if (!inputs)
|
|
59
|
+
return null;
|
|
60
|
+
// metafile input keys are paths relative to esbuild's cwd; resolve to absolute
|
|
61
|
+
// so they compare cleanly against chokidar's watched paths.
|
|
62
|
+
return Object.keys(inputs).map((p) => resolve(process.cwd(), p));
|
|
63
|
+
};
|
package/dev/runtime.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { getRequestListener } from '@hono/node-server';
|
|
5
|
+
const isFunction = (value) => typeof value === 'function';
|
|
6
|
+
const hasFetchMethod = (value) => typeof value === 'object' &&
|
|
7
|
+
value !== null &&
|
|
8
|
+
'fetch' in value &&
|
|
9
|
+
typeof value.fetch === 'function';
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the user's exported handler to a single fetch callback.
|
|
12
|
+
*
|
|
13
|
+
* Resolution order (first match wins):
|
|
14
|
+
* 1. `export default { fetch }` — Workers / Neon Functions style
|
|
15
|
+
* 2. `export default function (req)` — bare (async) default function
|
|
16
|
+
*/
|
|
17
|
+
export const resolveFetchHandler = (mod) => {
|
|
18
|
+
const defaultExport = mod.default;
|
|
19
|
+
if (hasFetchMethod(defaultExport)) {
|
|
20
|
+
const target = defaultExport;
|
|
21
|
+
return (req) => target.fetch(req);
|
|
22
|
+
}
|
|
23
|
+
if (isFunction(defaultExport)) {
|
|
24
|
+
return defaultExport;
|
|
25
|
+
}
|
|
26
|
+
throw new Error('No request handler found in the source module. Export one of:\n' +
|
|
27
|
+
' export default { fetch(req) { /* ... */ } }\n' +
|
|
28
|
+
' export default function (req) { /* ... */ }');
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Wrap a fetch handler so user errors become a 500 response (with the message
|
|
32
|
+
* in the body during dev) instead of crashing the child process.
|
|
33
|
+
*/
|
|
34
|
+
export const withErrorBoundary = (handler) => {
|
|
35
|
+
return async (req) => {
|
|
36
|
+
try {
|
|
37
|
+
return await handler(req);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
41
|
+
process.stderr.write(`Request handler threw an error:\n${message}\n`);
|
|
42
|
+
return new Response(`Internal Server Error\n\n${message}`, {
|
|
43
|
+
status: 500,
|
|
44
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const isAddressInUse = (err) => typeof err === 'object' &&
|
|
50
|
+
err !== null &&
|
|
51
|
+
err.code === 'EADDRINUSE';
|
|
52
|
+
const DEFAULT_SEARCH_BASE = 8787;
|
|
53
|
+
const MAX_SEARCH_STEPS = 100;
|
|
54
|
+
const bindPort = async (server, selection, hostname) => {
|
|
55
|
+
if (selection.mode === 'explicit') {
|
|
56
|
+
return listen(server, selection.port, hostname);
|
|
57
|
+
}
|
|
58
|
+
for (let step = 0; step < MAX_SEARCH_STEPS; step++) {
|
|
59
|
+
try {
|
|
60
|
+
return await listen(server, selection.from + step, hostname);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (!isAddressInUse(err))
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Could not find a free port in ${selection.from}-${selection.from + MAX_SEARCH_STEPS - 1}`);
|
|
68
|
+
};
|
|
69
|
+
const listen = (server, port, hostname) => new Promise((resolveListen, rejectListen) => {
|
|
70
|
+
const onError = (err) => {
|
|
71
|
+
server.off('listening', onListening);
|
|
72
|
+
rejectListen(err);
|
|
73
|
+
};
|
|
74
|
+
const onListening = () => {
|
|
75
|
+
server.off('error', onError);
|
|
76
|
+
resolveListen(server.address().port);
|
|
77
|
+
};
|
|
78
|
+
server.once('error', onError);
|
|
79
|
+
server.once('listening', onListening);
|
|
80
|
+
server.listen(port, hostname);
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Load the (already-bundled) user module, build the listener, and start an HTTP
|
|
84
|
+
* server. Announces the bound port on stdout as `neon-dev:ready <port>` so the
|
|
85
|
+
* parent can render the URL. Resolves with the bound port.
|
|
86
|
+
*/
|
|
87
|
+
export const startRuntime = async ({ source, port, hostname, }) => {
|
|
88
|
+
const absoluteSource = resolve(process.cwd(), source);
|
|
89
|
+
const mod = (await import(pathToFileURL(absoluteSource).href));
|
|
90
|
+
const handler = withErrorBoundary(resolveFetchHandler(mod));
|
|
91
|
+
const listener = getRequestListener(handler, { hostname });
|
|
92
|
+
const server = createServer((incoming, outgoing) => {
|
|
93
|
+
void listener(incoming, outgoing);
|
|
94
|
+
});
|
|
95
|
+
const boundPort = await bindPort(server, port, hostname);
|
|
96
|
+
process.stdout.write(`neon-dev:ready ${boundPort}\n`);
|
|
97
|
+
return boundPort;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Build a {@link PortSelection} from the environment. Precedence:
|
|
101
|
+
* 1. `NEON_DEV_PORT` -> explicit bind (crash if taken). Set by `neon dev` from an
|
|
102
|
+
* explicit `--port` / `dev.port`.
|
|
103
|
+
* 2. `PORT` -> explicit bind. This is what `portless` injects (and what a bare
|
|
104
|
+
* `PORT=3000 neon dev` sets), so the runtime binds the port chosen for it.
|
|
105
|
+
* 3. otherwise -> search upward from `NEON_DEV_PORT_BASE` (or the default base).
|
|
106
|
+
*/
|
|
107
|
+
export const portSelectionFromEnv = (env) => {
|
|
108
|
+
const explicit = env.NEON_DEV_PORT;
|
|
109
|
+
if (explicit !== undefined && explicit !== '') {
|
|
110
|
+
return { mode: 'explicit', port: parsePort(explicit, 'NEON_DEV_PORT') };
|
|
111
|
+
}
|
|
112
|
+
const injected = env.PORT;
|
|
113
|
+
if (injected !== undefined && injected !== '') {
|
|
114
|
+
return { mode: 'explicit', port: parsePort(injected, 'PORT') };
|
|
115
|
+
}
|
|
116
|
+
const base = Number(env.NEON_DEV_PORT_BASE ?? DEFAULT_SEARCH_BASE);
|
|
117
|
+
return {
|
|
118
|
+
mode: 'search',
|
|
119
|
+
from: Number.isInteger(base) ? base : DEFAULT_SEARCH_BASE,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const parsePort = (value, varName) => {
|
|
123
|
+
const port = Number(value);
|
|
124
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
125
|
+
throw new Error(`Invalid ${varName}: "${value}"`);
|
|
126
|
+
}
|
|
127
|
+
return port;
|
|
128
|
+
};
|
|
129
|
+
const isDirectExecution = () => {
|
|
130
|
+
const entry = process.argv[1];
|
|
131
|
+
if (!entry)
|
|
132
|
+
return false;
|
|
133
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
134
|
+
};
|
|
135
|
+
if (isDirectExecution()) {
|
|
136
|
+
const source = process.env.NEON_DEV_SOURCE ?? process.argv[2];
|
|
137
|
+
if (!source) {
|
|
138
|
+
process.stderr.write('neon-dev runtime: missing source path\n');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
startRuntime({ source, port: portSelectionFromEnv(process.env) }).catch((err) => {
|
|
142
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
143
|
+
process.stderr.write(`neon-dev runtime failed to start: ${msg}\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
});
|
|
146
|
+
}
|
package/env_file.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Default dotenv file `env pull` writes to: `.env` when one already exists in the working
|
|
5
|
+
* directory (update where secrets already live), otherwise `.env.local` — matching the
|
|
6
|
+
* `vercel env pull` convention. An explicit `--file` always wins over this.
|
|
7
|
+
*/
|
|
8
|
+
export const resolveEnvFilePath = (cwd, file) => {
|
|
9
|
+
if (file)
|
|
10
|
+
return join(cwd, file);
|
|
11
|
+
if (existsSync(join(cwd, '.env')))
|
|
12
|
+
return join(cwd, '.env');
|
|
13
|
+
return join(cwd, '.env.local');
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Merge `updates` into the dotenv content at `path`, preserving every other line
|
|
17
|
+
* (comments, blank lines, unrelated keys) and the file's existing order. Keys present in
|
|
18
|
+
* both are updated in place; keys only in `updates` are appended. A non-existent file is
|
|
19
|
+
* treated as empty. Returns the list of keys that were written (for reporting).
|
|
20
|
+
*/
|
|
21
|
+
export const mergeEnvFile = (path, updates) => {
|
|
22
|
+
const original = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
23
|
+
const { content, written } = mergeEnvContent(original, updates);
|
|
24
|
+
writeFileSync(path, content);
|
|
25
|
+
return { written };
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Pure core of {@link mergeEnvFile}: takes the current file content and the updates, and
|
|
29
|
+
* returns the new content plus which keys were written. Kept side-effect-free so it can be
|
|
30
|
+
* unit-tested without touching the filesystem.
|
|
31
|
+
*/
|
|
32
|
+
export const mergeEnvContent = (original, updates) => {
|
|
33
|
+
const keys = Object.keys(updates);
|
|
34
|
+
if (keys.length === 0)
|
|
35
|
+
return { content: original, written: [] };
|
|
36
|
+
const remaining = new Set(keys);
|
|
37
|
+
const lines = original === '' ? [] : original.split('\n');
|
|
38
|
+
// Update keys in place where they already appear, so their position and any surrounding
|
|
39
|
+
// comments are preserved.
|
|
40
|
+
const updatedLines = lines.map((line) => {
|
|
41
|
+
const key = parseKey(line);
|
|
42
|
+
if (key !== null && remaining.has(key)) {
|
|
43
|
+
remaining.delete(key);
|
|
44
|
+
return formatLine(key, updates[key]);
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
});
|
|
48
|
+
// Append keys that weren't already present, in the order they were given.
|
|
49
|
+
const appended = keys
|
|
50
|
+
.filter((key) => remaining.has(key))
|
|
51
|
+
.map((key) => formatLine(key, updates[key]));
|
|
52
|
+
const body = trimTrailingBlank(updatedLines);
|
|
53
|
+
const content = [...body, ...appended].join('\n');
|
|
54
|
+
return {
|
|
55
|
+
// A dotenv file ends with a trailing newline.
|
|
56
|
+
content: content === '' ? '' : `${content}\n`,
|
|
57
|
+
written: keys,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Read a dotenv file at `path` into a plain `{ KEY: value }` map. A non-existent file is an
|
|
62
|
+
* error (callers pass an explicit `--env` path, so a typo should fail loudly rather than
|
|
63
|
+
* silently load nothing). Quotes are stripped and `\"` / `\\` unescaped, matching the
|
|
64
|
+
* quoting {@link mergeEnvFile} writes. Comments, blank lines, and non-assignment lines are
|
|
65
|
+
* ignored.
|
|
66
|
+
*/
|
|
67
|
+
export const readEnvFile = (path) => {
|
|
68
|
+
if (!existsSync(path)) {
|
|
69
|
+
throw new Error(`Env file not found: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
73
|
+
const parsed = parseAssignment(line);
|
|
74
|
+
if (parsed)
|
|
75
|
+
out[parsed.key] = parsed.value;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Load a dotenv file into `process.env` so values are available to anything read later
|
|
81
|
+
* (e.g. a `neon.ts` whose function `env` values come from `process.env.X`). Existing
|
|
82
|
+
* `process.env` entries are **not** overridden — an already-exported var wins over the
|
|
83
|
+
* file, matching dotenv's default. Returns the keys that were applied.
|
|
84
|
+
*/
|
|
85
|
+
export const loadEnvFileIntoProcess = (path) => {
|
|
86
|
+
const parsed = readEnvFile(path);
|
|
87
|
+
const applied = [];
|
|
88
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
89
|
+
if (process.env[key] === undefined) {
|
|
90
|
+
process.env[key] = value;
|
|
91
|
+
applied.push(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return applied;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Extract the variable name from a dotenv line, or `null` for comments / blank lines / any
|
|
98
|
+
* line that isn't a `KEY=value` assignment. Tolerates a leading `export ` and surrounding
|
|
99
|
+
* whitespace, matching common `.env` styles.
|
|
100
|
+
*/
|
|
101
|
+
const parseKey = (line) => {
|
|
102
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
103
|
+
return match ? match[1] : null;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Parse a `KEY=value` dotenv line into its key and unquoted value, or `null` for
|
|
107
|
+
* comments / blank lines / non-assignments. Mirrors {@link formatLine}'s quoting.
|
|
108
|
+
*/
|
|
109
|
+
const parseAssignment = (line) => {
|
|
110
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
111
|
+
const key = match?.[1];
|
|
112
|
+
const raw = match?.[2];
|
|
113
|
+
if (key === undefined || raw === undefined)
|
|
114
|
+
return null;
|
|
115
|
+
return { key, value: unquote(raw.trim()) };
|
|
116
|
+
};
|
|
117
|
+
/** Strip matching surrounding quotes and unescape `\"` / `\\` inside double quotes. */
|
|
118
|
+
const unquote = (value) => {
|
|
119
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
120
|
+
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
121
|
+
}
|
|
122
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
123
|
+
return value.slice(1, -1);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Render a `KEY=value` line. The value is wrapped in double quotes when it contains
|
|
129
|
+
* characters that would otherwise break parsing (spaces, `#`, quotes, `=`), with inner
|
|
130
|
+
* quotes/backslashes escaped — Neon connection strings and URLs are safe either way, but
|
|
131
|
+
* quoting defensively avoids surprises for tools that re-parse the file.
|
|
132
|
+
*/
|
|
133
|
+
const formatLine = (key, value) => {
|
|
134
|
+
const needsQuotes = /[\s#"'=]/.test(value);
|
|
135
|
+
if (!needsQuotes)
|
|
136
|
+
return `${key}=${value}`;
|
|
137
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
138
|
+
return `${key}="${escaped}"`;
|
|
139
|
+
};
|
|
140
|
+
/** Drop trailing blank lines so we don't accumulate them across repeated merges. */
|
|
141
|
+
const trimTrailingBlank = (lines) => {
|
|
142
|
+
const out = [...lines];
|
|
143
|
+
while (out.length > 0 && out[out.length - 1]?.trim() === '')
|
|
144
|
+
out.pop();
|
|
145
|
+
return out;
|
|
146
|
+
};
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"url": "git+ssh://git@github.com/neondatabase/neonctl.git"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"version": "2.
|
|
8
|
+
"version": "2.24.1",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"rollup": "3.29.4",
|
|
50
50
|
"strip-ansi": "7.1.0",
|
|
51
51
|
"tsx": "4.22.3",
|
|
52
|
-
"typescript": "
|
|
52
|
+
"typescript": "5.8.3",
|
|
53
53
|
"typescript-eslint": "8.28.0",
|
|
54
54
|
"vitest": "1.6.1"
|
|
55
55
|
},
|
|
@@ -57,11 +57,16 @@
|
|
|
57
57
|
"esbuild": "0.28.0"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
+
"@hono/node-server": "2.0.4",
|
|
60
61
|
"@neondatabase/api-client": "2.7.1",
|
|
62
|
+
"@neondatabase/config": "0.4.0",
|
|
63
|
+
"@neondatabase/config-runtime": "0.4.0",
|
|
64
|
+
"@neondatabase/env": "0.3.0",
|
|
61
65
|
"@segment/analytics-node": "1.3.0",
|
|
62
66
|
"axios": "1.7.2",
|
|
63
67
|
"axios-debug-log": "1.0.0",
|
|
64
68
|
"chalk": "5.3.0",
|
|
69
|
+
"chokidar": "5.0.0",
|
|
65
70
|
"cli-table": "0.3.11",
|
|
66
71
|
"cliui": "8.0.1",
|
|
67
72
|
"diff": "5.2.0",
|
package/pkg.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Load the CLI's package.json for version metadata. In the built CLI it sits right next to
|
|
6
|
+
* this module (the build copies it into `dist`); when running from source (tests, `tsx`) it
|
|
7
|
+
* does not, so we walk up to the nearest `package.json`. Both layouts resolve to the same
|
|
8
|
+
* file, keeping `pkg.version` correct everywhere without a test-only shim.
|
|
9
|
+
*/
|
|
10
|
+
const loadPkg = () => {
|
|
11
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) {
|
|
19
|
+
throw new Error('Could not locate package.json for version detection.');
|
|
20
|
+
}
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
export default loadPkg();
|
|
@@ -233,18 +233,18 @@ const NORMALISED_ENCODINGS = new Set([
|
|
|
233
233
|
'johab',
|
|
234
234
|
'shiftjis2004',
|
|
235
235
|
// Aliases upstream's encoding_match_list accepts as recognised names.
|
|
236
|
-
'unicode',
|
|
237
|
-
'mskanji',
|
|
238
|
-
'shiftjis',
|
|
239
|
-
'windows949',
|
|
240
|
-
'windows950',
|
|
241
|
-
'windows936',
|
|
242
|
-
'tcvn',
|
|
243
|
-
'tcvn5712',
|
|
244
|
-
'vscii',
|
|
245
|
-
'alt',
|
|
246
|
-
'win',
|
|
247
|
-
'koi8',
|
|
236
|
+
'unicode', // → UTF8
|
|
237
|
+
'mskanji', // → SJIS
|
|
238
|
+
'shiftjis', // → SJIS
|
|
239
|
+
'windows949', // → UHC
|
|
240
|
+
'windows950', // → BIG5
|
|
241
|
+
'windows936', // → GBK
|
|
242
|
+
'tcvn', // → WIN1258
|
|
243
|
+
'tcvn5712', // → WIN1258
|
|
244
|
+
'vscii', // → WIN1258
|
|
245
|
+
'alt', // → WIN866
|
|
246
|
+
'win', // → WIN1251
|
|
247
|
+
'koi8', // → KOI8R
|
|
248
248
|
'abc', // → WIN1258
|
|
249
249
|
]);
|
|
250
250
|
/**
|
package/psql/command/cmd_io.js
CHANGED
|
@@ -1514,7 +1514,7 @@ const GDESC_SYNTHETIC_FIELDS = [
|
|
|
1514
1514
|
name: 'Column',
|
|
1515
1515
|
tableID: 0,
|
|
1516
1516
|
columnID: 0,
|
|
1517
|
-
dataTypeID: 25,
|
|
1517
|
+
dataTypeID: 25, // text
|
|
1518
1518
|
dataTypeSize: -1,
|
|
1519
1519
|
dataTypeModifier: -1,
|
|
1520
1520
|
format: 0,
|
|
@@ -1523,7 +1523,7 @@ const GDESC_SYNTHETIC_FIELDS = [
|
|
|
1523
1523
|
name: 'Type',
|
|
1524
1524
|
tableID: 0,
|
|
1525
1525
|
columnID: 0,
|
|
1526
|
-
dataTypeID: 25,
|
|
1526
|
+
dataTypeID: 25, // text
|
|
1527
1527
|
dataTypeSize: -1,
|
|
1528
1528
|
dataTypeModifier: -1,
|
|
1529
1529
|
format: 0,
|
|
@@ -48,18 +48,18 @@ export const RESTRICTED_REFUSAL_MESSAGE = (cmdName) => `\\${cmdName}: command is
|
|
|
48
48
|
* registry level" and avoids touching cmd_copy.ts internals.
|
|
49
49
|
*/
|
|
50
50
|
export const RESTRICTED_COMMANDS = new Set([
|
|
51
|
-
'!',
|
|
52
|
-
'cd',
|
|
53
|
-
'copy',
|
|
54
|
-
'setenv',
|
|
55
|
-
'w',
|
|
51
|
+
'!', // shell escape
|
|
52
|
+
'cd', // change directory
|
|
53
|
+
'copy', // \copy — including \copy FROM PROGRAM
|
|
54
|
+
'setenv', // mutate process env
|
|
55
|
+
'w', // \w / \write — file write of query buffer
|
|
56
56
|
// `\o`/`\g`/`\gx` route through openWriter(), which spawns `sh -c <cmd>`
|
|
57
57
|
// for a `|command` target (arbitrary shell exec) and writes the filesystem
|
|
58
58
|
// for a FILE target; `\s FILE` writes history to disk. Block them by name
|
|
59
59
|
// (review item #13). Plain query execution still works via `;`.
|
|
60
|
-
'o',
|
|
61
|
-
'g',
|
|
62
|
-
'gx',
|
|
60
|
+
'o', // \o [FILE | |cmd]
|
|
61
|
+
'g', // \g [FILE | |cmd]
|
|
62
|
+
'gx', // \gx [FILE | |cmd]
|
|
63
63
|
's', // \s [FILE] — write command history
|
|
64
64
|
]);
|
|
65
65
|
/**
|