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.
@@ -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
@@ -38,6 +38,8 @@ const NO_SUBCOMMANDS_VERBS = [
38
38
  'checkout',
39
39
  'link',
40
40
  'init',
41
+ 'dev',
42
+ 'deploy',
41
43
  // aliases
42
44
  ];
43
45
  let builder = yargs(hideBin(process.argv));
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.23.1",
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": "4.9.5",
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
- export default JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf-8'));
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
  /**
@@ -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
  /**
@@ -1270,7 +1270,7 @@ const fakeField = (name) => ({
1270
1270
  name,
1271
1271
  tableID: 0,
1272
1272
  columnID: 0,
1273
- dataTypeID: 25,
1273
+ dataTypeID: 25, // text
1274
1274
  dataTypeSize: -1,
1275
1275
  dataTypeModifier: -1,
1276
1276
  format: 0,