neonctl 2.22.0 → 2.22.2
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 +158 -16
- package/commands/checkout.js +249 -0
- package/commands/checkout.test.js +170 -0
- package/commands/connection_string.js +6 -1
- package/commands/data_api.js +286 -0
- package/commands/data_api.test.js +169 -0
- package/commands/index.js +8 -0
- package/commands/link.js +667 -0
- package/commands/link.test.js +381 -0
- package/commands/psql.js +57 -0
- package/commands/psql.test.js +49 -0
- package/commands/set_context.js +7 -2
- package/context.js +86 -14
- package/context.test.js +119 -0
- package/index.js +3 -0
- package/package.json +48 -49
- package/utils/enrichers.js +18 -1
- package/utils/middlewares.js +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { beforeAll, describe, expect } from 'vitest';
|
|
6
|
+
import { test as originalTest } from '../test_utils/fixtures';
|
|
7
|
+
// All tests in this file share a single temporary directory whose path is
|
|
8
|
+
// normalized in snapshots to `<TMP>` so that absolute paths in command output
|
|
9
|
+
// (both human summaries and agent JSON) remain stable across runs and machines.
|
|
10
|
+
const TEST_TMP = mkdtempSync(join(tmpdir(), 'neonctl-link-'));
|
|
11
|
+
const TMP_TOKEN = '<TMP>';
|
|
12
|
+
beforeAll(() => {
|
|
13
|
+
// Replace any reference to the per-run tmp directory with a stable token so
|
|
14
|
+
// snapshots only carry the deterministic suffix portion of paths.
|
|
15
|
+
expect.addSnapshotSerializer({
|
|
16
|
+
test: (val) => typeof val === 'string' && val.includes(TEST_TMP),
|
|
17
|
+
serialize: (val, config, indentation, depth, refs, printer) => printer(val.split(TEST_TMP).join(TMP_TOKEN), config, indentation, depth, refs),
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
const test = originalTest.extend({
|
|
21
|
+
// eslint-disable-next-line no-empty-pattern
|
|
22
|
+
cleanupFile: async ({}, use) => {
|
|
23
|
+
let writtenFilename;
|
|
24
|
+
await use((name) => (writtenFilename = name));
|
|
25
|
+
if (writtenFilename) {
|
|
26
|
+
try {
|
|
27
|
+
rmSync(writtenFilename);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// ignore
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
readFile: async ({ cleanupFile }, use) => {
|
|
35
|
+
await use((name) => {
|
|
36
|
+
const content = readFileSync(name, 'utf-8');
|
|
37
|
+
cleanupFile(name);
|
|
38
|
+
return content;
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
// eslint-disable-next-line no-empty-pattern
|
|
42
|
+
removeFile: async ({}, use) => {
|
|
43
|
+
await use((name) => {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(name);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
// Each test gets its OWN sub-directory under TEST_TMP so the
|
|
53
|
+
// `.gitignore` scaffolded next to the `.neon` written by one test doesn't
|
|
54
|
+
// affect another test in the same file.
|
|
55
|
+
// eslint-disable-next-line no-empty-pattern
|
|
56
|
+
tmpContext: async ({}, use) => {
|
|
57
|
+
await use((label) => {
|
|
58
|
+
const dir = join(TEST_TMP, label);
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
return join(dir, '.neon');
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
runLinkInCi: async ({ runMockServer }, use) => {
|
|
64
|
+
await use(async (args) => {
|
|
65
|
+
const server = await runMockServer('main');
|
|
66
|
+
const port = server.address().port;
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const cp = fork(join(process.cwd(), './dist/index.js'), [
|
|
69
|
+
'--api-host',
|
|
70
|
+
`http://localhost:${port}`,
|
|
71
|
+
'--output',
|
|
72
|
+
'yaml',
|
|
73
|
+
'--api-key',
|
|
74
|
+
'test-key',
|
|
75
|
+
'--no-analytics',
|
|
76
|
+
...args,
|
|
77
|
+
], {
|
|
78
|
+
stdio: 'pipe',
|
|
79
|
+
env: {
|
|
80
|
+
PATH: `mocks/bin:${process.env.PATH}`,
|
|
81
|
+
CI: 'true',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
cp.stdout?.on('data', (data) => {
|
|
87
|
+
stdout += data.toString();
|
|
88
|
+
});
|
|
89
|
+
cp.stderr?.on('data', (data) => {
|
|
90
|
+
stderr += data.toString();
|
|
91
|
+
});
|
|
92
|
+
cp.on('error', reject);
|
|
93
|
+
cp.on('close', (code) => {
|
|
94
|
+
resolve({ code: code ?? -1, stdout, stderr });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
describe('link', () => {
|
|
101
|
+
describe('non-interactive flag mode', () => {
|
|
102
|
+
test('link to existing project writes .neon with default branch id', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
103
|
+
const ctx = tmpContext('flag_existing');
|
|
104
|
+
await testCliCommand([
|
|
105
|
+
'link',
|
|
106
|
+
'--org-id',
|
|
107
|
+
'org-2',
|
|
108
|
+
'--project-id',
|
|
109
|
+
'test',
|
|
110
|
+
'--context-file',
|
|
111
|
+
ctx,
|
|
112
|
+
]);
|
|
113
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
114
|
+
});
|
|
115
|
+
test('link with --params JSON behaves like flags', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
116
|
+
const ctx = tmpContext('flag_params');
|
|
117
|
+
await testCliCommand([
|
|
118
|
+
'link',
|
|
119
|
+
'--params',
|
|
120
|
+
JSON.stringify({ orgId: 'org-2', projectId: 'test' }),
|
|
121
|
+
'--context-file',
|
|
122
|
+
ctx,
|
|
123
|
+
]);
|
|
124
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
125
|
+
});
|
|
126
|
+
test('link creates a new project and writes .neon', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
127
|
+
const ctx = tmpContext('flag_create');
|
|
128
|
+
await testCliCommand([
|
|
129
|
+
'link',
|
|
130
|
+
'--org-id',
|
|
131
|
+
'org-2',
|
|
132
|
+
'--project-name',
|
|
133
|
+
'test_project',
|
|
134
|
+
'--region-id',
|
|
135
|
+
'aws-us-east-2',
|
|
136
|
+
'--context-file',
|
|
137
|
+
ctx,
|
|
138
|
+
]);
|
|
139
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
140
|
+
});
|
|
141
|
+
test('conflicting inputs (--project-id with --project-name) fails', async ({ testCliCommand, tmpContext, }) => {
|
|
142
|
+
await testCliCommand([
|
|
143
|
+
'link',
|
|
144
|
+
'--org-id',
|
|
145
|
+
'org-2',
|
|
146
|
+
'--project-id',
|
|
147
|
+
'test',
|
|
148
|
+
'--project-name',
|
|
149
|
+
'test_project',
|
|
150
|
+
'--context-file',
|
|
151
|
+
tmpContext('flag_conflict'),
|
|
152
|
+
], {
|
|
153
|
+
code: 1,
|
|
154
|
+
stderr: 'ERROR: Conflicting inputs: --project-id selects an existing project; --project-name and --region-id describe a new one. Pass only one set.',
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('--agent mode', () => {
|
|
159
|
+
test('with no flags emits needs_org JSON', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
160
|
+
const ctx = tmpContext('agent_needs_org');
|
|
161
|
+
await testCliCommand(['link', '--agent', '--context-file', ctx]);
|
|
162
|
+
removeFile(ctx);
|
|
163
|
+
});
|
|
164
|
+
test('with only --org-id emits needs_project JSON', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
165
|
+
const ctx = tmpContext('agent_needs_project');
|
|
166
|
+
await testCliCommand([
|
|
167
|
+
'link',
|
|
168
|
+
'--agent',
|
|
169
|
+
'--org-id',
|
|
170
|
+
'org-2',
|
|
171
|
+
'--context-file',
|
|
172
|
+
ctx,
|
|
173
|
+
]);
|
|
174
|
+
removeFile(ctx);
|
|
175
|
+
});
|
|
176
|
+
test('with org+project emits linked JSON and writes .neon', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
177
|
+
const ctx = tmpContext('agent_linked_existing');
|
|
178
|
+
await testCliCommand([
|
|
179
|
+
'link',
|
|
180
|
+
'--agent',
|
|
181
|
+
'--org-id',
|
|
182
|
+
'org-2',
|
|
183
|
+
'--project-id',
|
|
184
|
+
'test',
|
|
185
|
+
'--context-file',
|
|
186
|
+
ctx,
|
|
187
|
+
]);
|
|
188
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
189
|
+
});
|
|
190
|
+
test('with org+projectName but no region emits needs_project_details JSON', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
191
|
+
const ctx = tmpContext('agent_needs_region');
|
|
192
|
+
await testCliCommand([
|
|
193
|
+
'link',
|
|
194
|
+
'--agent',
|
|
195
|
+
'--org-id',
|
|
196
|
+
'org-2',
|
|
197
|
+
'--project-name',
|
|
198
|
+
'demo',
|
|
199
|
+
'--context-file',
|
|
200
|
+
ctx,
|
|
201
|
+
]);
|
|
202
|
+
removeFile(ctx);
|
|
203
|
+
});
|
|
204
|
+
test('with full project details creates project and emits linked JSON', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
205
|
+
const ctx = tmpContext('agent_linked_create');
|
|
206
|
+
await testCliCommand([
|
|
207
|
+
'link',
|
|
208
|
+
'--agent',
|
|
209
|
+
'--org-id',
|
|
210
|
+
'org-2',
|
|
211
|
+
'--project-name',
|
|
212
|
+
'test_project',
|
|
213
|
+
'--region-id',
|
|
214
|
+
'aws-us-east-2',
|
|
215
|
+
'--context-file',
|
|
216
|
+
ctx,
|
|
217
|
+
]);
|
|
218
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe('org-scoped API key behavior', () => {
|
|
222
|
+
test('agent mode with no orgs available and no projects emits orgKeyLimited needs_org', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
223
|
+
const ctx = tmpContext('orgkey_empty');
|
|
224
|
+
await testCliCommand(['link', '--agent', '--context-file', ctx], {
|
|
225
|
+
mockDir: 'org-key-empty',
|
|
226
|
+
});
|
|
227
|
+
removeFile(ctx);
|
|
228
|
+
});
|
|
229
|
+
test('agent mode auto-detects org from existing projects when org listing is forbidden', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
230
|
+
const ctx = tmpContext('orgkey_autodetect');
|
|
231
|
+
await testCliCommand(['link', '--agent', '--context-file', ctx], {
|
|
232
|
+
mockDir: 'org-key',
|
|
233
|
+
});
|
|
234
|
+
removeFile(ctx);
|
|
235
|
+
});
|
|
236
|
+
test('agent mode falls back to static regions when getActiveRegions is forbidden', async ({ testCliCommand, removeFile, tmpContext, }) => {
|
|
237
|
+
const ctx = tmpContext('orgkey_regions_fallback');
|
|
238
|
+
await testCliCommand([
|
|
239
|
+
'link',
|
|
240
|
+
'--agent',
|
|
241
|
+
'--org-id',
|
|
242
|
+
'org-detected-99887766',
|
|
243
|
+
'--project-name',
|
|
244
|
+
'whatever',
|
|
245
|
+
'--context-file',
|
|
246
|
+
ctx,
|
|
247
|
+
], { mockDir: 'org-key' });
|
|
248
|
+
removeFile(ctx);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
describe('agent error responses', () => {
|
|
252
|
+
test('invalid --params JSON yields error JSON, exit 1', async ({ runLinkInCi, tmpContext, }) => {
|
|
253
|
+
const result = await runLinkInCi([
|
|
254
|
+
'link',
|
|
255
|
+
'--agent',
|
|
256
|
+
'--params',
|
|
257
|
+
'not-valid-json',
|
|
258
|
+
'--context-file',
|
|
259
|
+
tmpContext('agent_bad_params'),
|
|
260
|
+
]);
|
|
261
|
+
expect(result.code).toBe(1);
|
|
262
|
+
const parsed = JSON.parse(result.stdout);
|
|
263
|
+
expect(parsed.status).toBe('error');
|
|
264
|
+
expect(parsed.code).toBe('INTERNAL_ERROR');
|
|
265
|
+
expect(parsed.message).toContain('Failed to parse --params JSON');
|
|
266
|
+
});
|
|
267
|
+
test('conflicting flags in agent mode yields error JSON, exit 1', async ({ runLinkInCi, tmpContext, }) => {
|
|
268
|
+
const result = await runLinkInCi([
|
|
269
|
+
'link',
|
|
270
|
+
'--agent',
|
|
271
|
+
'--org-id',
|
|
272
|
+
'org-2',
|
|
273
|
+
'--project-id',
|
|
274
|
+
'test',
|
|
275
|
+
'--project-name',
|
|
276
|
+
'x',
|
|
277
|
+
'--context-file',
|
|
278
|
+
tmpContext('agent_conflict'),
|
|
279
|
+
]);
|
|
280
|
+
expect(result.code).toBe(1);
|
|
281
|
+
const parsed = JSON.parse(result.stdout);
|
|
282
|
+
expect(parsed.status).toBe('error');
|
|
283
|
+
expect(parsed.message).toContain('Conflicting inputs');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
describe('CI guard', () => {
|
|
287
|
+
test('errors out with helpful message when no inputs provided in CI', async ({ runLinkInCi, tmpContext, }) => {
|
|
288
|
+
const result = await runLinkInCi([
|
|
289
|
+
'link',
|
|
290
|
+
'--context-file',
|
|
291
|
+
tmpContext('ci_guard'),
|
|
292
|
+
]);
|
|
293
|
+
expect(result.code).toBe(1);
|
|
294
|
+
expect(result.stderr).toContain('CI environment detected');
|
|
295
|
+
expect(result.stderr).toContain('neonctl link --agent');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
describe('set-context regression', () => {
|
|
299
|
+
test('set-context --branch-id writes branchId to .neon', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
300
|
+
const ctx = tmpContext('sc_branch');
|
|
301
|
+
await testCliCommand([
|
|
302
|
+
'set-context',
|
|
303
|
+
'--project-id',
|
|
304
|
+
'test_project',
|
|
305
|
+
'--branch-id',
|
|
306
|
+
'br-main-branch-123456',
|
|
307
|
+
'--context-file',
|
|
308
|
+
ctx,
|
|
309
|
+
]);
|
|
310
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
test('overwrites an existing .neon when re-linking non-interactively', async ({ testCliCommand, readFile, tmpContext, }) => {
|
|
314
|
+
const ctx = tmpContext('overwrite');
|
|
315
|
+
writeFileSync(ctx, JSON.stringify({ orgId: 'old', projectId: 'old', branchId: 'old' }));
|
|
316
|
+
await testCliCommand([
|
|
317
|
+
'link',
|
|
318
|
+
'--org-id',
|
|
319
|
+
'org-2',
|
|
320
|
+
'--project-id',
|
|
321
|
+
'test',
|
|
322
|
+
'--context-file',
|
|
323
|
+
ctx,
|
|
324
|
+
]);
|
|
325
|
+
expect(readFile(ctx)).toMatchSnapshot();
|
|
326
|
+
});
|
|
327
|
+
describe('gitignore scaffolding', () => {
|
|
328
|
+
test('creates a .gitignore listing .neon next to the context file', async ({ testCliCommand, tmpContext, }) => {
|
|
329
|
+
const ctx = tmpContext('gi_creates');
|
|
330
|
+
await testCliCommand([
|
|
331
|
+
'link',
|
|
332
|
+
'--org-id',
|
|
333
|
+
'org-2',
|
|
334
|
+
'--project-id',
|
|
335
|
+
'test',
|
|
336
|
+
'--context-file',
|
|
337
|
+
ctx,
|
|
338
|
+
]);
|
|
339
|
+
const giPath = join(ctx, '..', '.gitignore');
|
|
340
|
+
expect(readFileSync(giPath, 'utf-8')).toBe('.neon\n');
|
|
341
|
+
});
|
|
342
|
+
test('appends .neon to an existing .gitignore without duplicating', async ({ testCliCommand, tmpContext, }) => {
|
|
343
|
+
const ctx = tmpContext('gi_appends');
|
|
344
|
+
const giPath = join(ctx, '..', '.gitignore');
|
|
345
|
+
writeFileSync(giPath, 'node_modules\ndist\n');
|
|
346
|
+
await testCliCommand([
|
|
347
|
+
'link',
|
|
348
|
+
'--org-id',
|
|
349
|
+
'org-2',
|
|
350
|
+
'--project-id',
|
|
351
|
+
'test',
|
|
352
|
+
'--context-file',
|
|
353
|
+
ctx,
|
|
354
|
+
]);
|
|
355
|
+
expect(readFileSync(giPath, 'utf-8')).toBe('node_modules\ndist\n.neon\n');
|
|
356
|
+
// Re-link in the same dir must not produce a duplicate entry.
|
|
357
|
+
await testCliCommand([
|
|
358
|
+
'link',
|
|
359
|
+
'--org-id',
|
|
360
|
+
'org-2',
|
|
361
|
+
'--project-id',
|
|
362
|
+
'test',
|
|
363
|
+
'--context-file',
|
|
364
|
+
ctx,
|
|
365
|
+
]);
|
|
366
|
+
expect(readFileSync(giPath, 'utf-8')).toBe('node_modules\ndist\n.neon\n');
|
|
367
|
+
});
|
|
368
|
+
test('set-context also scaffolds .gitignore via the shared applyContext', async ({ testCliCommand, tmpContext, }) => {
|
|
369
|
+
const ctx = tmpContext('gi_set_context');
|
|
370
|
+
await testCliCommand([
|
|
371
|
+
'set-context',
|
|
372
|
+
'--project-id',
|
|
373
|
+
'test',
|
|
374
|
+
'--context-file',
|
|
375
|
+
ctx,
|
|
376
|
+
]);
|
|
377
|
+
const giPath = join(ctx, '..', '.gitignore');
|
|
378
|
+
expect(readFileSync(giPath, 'utf-8')).toBe('.neon\n');
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
package/commands/psql.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { EndpointType } from '@neondatabase/api-client';
|
|
2
|
+
import { fillSingleProject } from '../utils/enrichers.js';
|
|
3
|
+
import { handler as connectionStringHandler, SSL_MODES, } from './connection_string.js';
|
|
4
|
+
export const command = 'psql [branch]';
|
|
5
|
+
export const describe = 'Connect to a database via psql';
|
|
6
|
+
export const builder = (argv) => {
|
|
7
|
+
return argv
|
|
8
|
+
.usage('$0 psql [branch] [options] [-- psql-args]')
|
|
9
|
+
.example('$0 psql', 'Connect to the default branch via psql')
|
|
10
|
+
.example('$0 psql main', 'Connect to the main branch via psql')
|
|
11
|
+
.example('$0 psql main -- -c "SELECT 1"', 'Run a single query against the main branch')
|
|
12
|
+
.example('$0 psql main@2024-01-01T00:00:00Z', 'Connect to the main branch at a specific point in time')
|
|
13
|
+
.positional('branch', {
|
|
14
|
+
describe: `Branch name or id. Defaults to the default branch if omitted. Can be written in the point-in-time format: "branch@timestamp" or "branch@lsn"`,
|
|
15
|
+
type: 'string',
|
|
16
|
+
})
|
|
17
|
+
.options({
|
|
18
|
+
'project-id': {
|
|
19
|
+
type: 'string',
|
|
20
|
+
describe: 'Project ID',
|
|
21
|
+
},
|
|
22
|
+
'role-name': {
|
|
23
|
+
type: 'string',
|
|
24
|
+
describe: 'Role name',
|
|
25
|
+
},
|
|
26
|
+
'database-name': {
|
|
27
|
+
type: 'string',
|
|
28
|
+
describe: 'Database name',
|
|
29
|
+
},
|
|
30
|
+
pooled: {
|
|
31
|
+
type: 'boolean',
|
|
32
|
+
describe: 'Use pooled connection',
|
|
33
|
+
default: false,
|
|
34
|
+
},
|
|
35
|
+
'endpoint-type': {
|
|
36
|
+
type: 'string',
|
|
37
|
+
choices: Object.values(EndpointType),
|
|
38
|
+
describe: 'Endpoint type',
|
|
39
|
+
},
|
|
40
|
+
ssl: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
choices: SSL_MODES,
|
|
43
|
+
default: 'require',
|
|
44
|
+
describe: 'SSL mode',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
.strict()
|
|
48
|
+
.middleware(fillSingleProject);
|
|
49
|
+
};
|
|
50
|
+
export const handler = async (props) => {
|
|
51
|
+
await connectionStringHandler({
|
|
52
|
+
...props,
|
|
53
|
+
psql: true,
|
|
54
|
+
prisma: false,
|
|
55
|
+
extended: false,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe } from 'vitest';
|
|
2
|
+
import { test } from '../test_utils/fixtures';
|
|
3
|
+
describe('psql', () => {
|
|
4
|
+
test('psql connects to a branch', async ({ testCliCommand }) => {
|
|
5
|
+
await testCliCommand([
|
|
6
|
+
'psql',
|
|
7
|
+
'test_branch',
|
|
8
|
+
'--project-id',
|
|
9
|
+
'test',
|
|
10
|
+
'--database-name',
|
|
11
|
+
'test_db',
|
|
12
|
+
'--role-name',
|
|
13
|
+
'test_role',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
test('psql forwards args after --', async ({ testCliCommand }) => {
|
|
17
|
+
await testCliCommand([
|
|
18
|
+
'psql',
|
|
19
|
+
'test_branch',
|
|
20
|
+
'--project-id',
|
|
21
|
+
'test',
|
|
22
|
+
'--database-name',
|
|
23
|
+
'test_db',
|
|
24
|
+
'--role-name',
|
|
25
|
+
'test_role',
|
|
26
|
+
'--',
|
|
27
|
+
'-c',
|
|
28
|
+
'SELECT 1',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
test('psql pooled', async ({ testCliCommand }) => {
|
|
32
|
+
await testCliCommand([
|
|
33
|
+
'psql',
|
|
34
|
+
'test_branch',
|
|
35
|
+
'--project-id',
|
|
36
|
+
'test',
|
|
37
|
+
'--database-name',
|
|
38
|
+
'test_db',
|
|
39
|
+
'--role-name',
|
|
40
|
+
'test_role',
|
|
41
|
+
'--pooled',
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
test('psql without any args should pass', async ({ testCliCommand }) => {
|
|
45
|
+
await testCliCommand(['psql'], {
|
|
46
|
+
mockDir: 'single_project',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
package/commands/set_context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyContext } from '../context.js';
|
|
2
2
|
export const command = 'set-context';
|
|
3
3
|
export const describe = 'Set the current context';
|
|
4
4
|
export const builder = (argv) => argv.usage('$0 set-context [options]').options({
|
|
@@ -10,11 +10,16 @@ export const builder = (argv) => argv.usage('$0 set-context [options]').options(
|
|
|
10
10
|
describe: 'Organization ID',
|
|
11
11
|
type: 'string',
|
|
12
12
|
},
|
|
13
|
+
'branch-id': {
|
|
14
|
+
describe: 'Branch ID',
|
|
15
|
+
type: 'string',
|
|
16
|
+
},
|
|
13
17
|
});
|
|
14
18
|
export const handler = (props) => {
|
|
15
19
|
const context = {
|
|
16
20
|
projectId: props.projectId,
|
|
17
21
|
orgId: props.orgId,
|
|
22
|
+
branchId: props.branchId,
|
|
18
23
|
};
|
|
19
|
-
|
|
24
|
+
applyContext(props.contextFile, context);
|
|
20
25
|
};
|
package/context.js
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
|
-
import { accessSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { accessSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { normalize, resolve } from 'node:path';
|
|
3
|
+
import { dirname, normalize, resolve } from 'node:path';
|
|
4
|
+
import { log } from './log.js';
|
|
4
5
|
const CONTEXT_FILE = '.neon';
|
|
5
|
-
const
|
|
6
|
+
const GITIGNORE_FILE = '.gitignore';
|
|
6
7
|
const wrapWithContextFile = (dir) => resolve(dir, CONTEXT_FILE);
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the default `.neon` path for the current working directory.
|
|
10
|
+
*
|
|
11
|
+
* Walks UP from `cwd` looking ONLY for an already-existing `.neon` file so
|
|
12
|
+
* commands run from a sub-directory of a linked project still pick up the
|
|
13
|
+
* project's context. If no `.neon` is found, the path defaults to
|
|
14
|
+
* `<cwd>/.neon`, which makes `neonctl link` and `neonctl set-context`
|
|
15
|
+
* predictable: they always write the context file into the directory they
|
|
16
|
+
* were invoked from.
|
|
17
|
+
*
|
|
18
|
+
* Historically the walk also considered `package.json` and `.git` as project
|
|
19
|
+
* markers, but that led to surprising behaviour when running `link` from a
|
|
20
|
+
* fresh sub-directory inside an unrelated repo (the new link would land in
|
|
21
|
+
* the parent repo's root instead of the cwd).
|
|
22
|
+
*
|
|
23
|
+
* `cwd` is overridable so tests can exercise the walk-up without mutating
|
|
24
|
+
* `process.cwd()` (which would race with other tests running in parallel).
|
|
25
|
+
*/
|
|
26
|
+
export const currentContextFile = (cwd = process.cwd()) => {
|
|
9
27
|
let currentDir = cwd;
|
|
10
28
|
const root = normalize('/');
|
|
11
29
|
const home = homedir();
|
|
12
30
|
while (currentDir !== root && currentDir !== home) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// ignore
|
|
20
|
-
}
|
|
31
|
+
try {
|
|
32
|
+
accessSync(resolve(currentDir, CONTEXT_FILE));
|
|
33
|
+
return wrapWithContextFile(currentDir);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// ignore
|
|
21
37
|
}
|
|
22
38
|
currentDir = resolve(currentDir, '..');
|
|
23
39
|
}
|
|
@@ -32,7 +48,7 @@ export const readContextFile = (file) => {
|
|
|
32
48
|
}
|
|
33
49
|
};
|
|
34
50
|
export const enrichFromContext = (args) => {
|
|
35
|
-
if (args._[0] === 'set-context') {
|
|
51
|
+
if (args._[0] === 'set-context' || args._[0] === 'link') {
|
|
36
52
|
return;
|
|
37
53
|
}
|
|
38
54
|
const context = readContextFile(args.contextFile);
|
|
@@ -52,3 +68,59 @@ export const enrichFromContext = (args) => {
|
|
|
52
68
|
export const updateContextFile = (file, context) => {
|
|
53
69
|
writeFileSync(file, JSON.stringify(context, null, 2));
|
|
54
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Shared primitive used by `set-context`, `link`, and `checkout` to persist
|
|
73
|
+
* context. Mirrors the destructive write semantics of `updateContextFile` —
|
|
74
|
+
* any field not present in `context` is dropped from the file.
|
|
75
|
+
*
|
|
76
|
+
* `.gitignore` scaffolding only happens when the context file is being
|
|
77
|
+
* *created* (it didn't exist before this write). On updates to an existing
|
|
78
|
+
* `.neon` we never touch `.gitignore`, so a user who deliberately un-ignored
|
|
79
|
+
* the file (e.g. to commit shared context) won't have the entry re-added on
|
|
80
|
+
* every subsequent command.
|
|
81
|
+
*/
|
|
82
|
+
export const applyContext = (file, context) => {
|
|
83
|
+
const isNewFile = !existsSync(file);
|
|
84
|
+
updateContextFile(file, context);
|
|
85
|
+
if (isNewFile) {
|
|
86
|
+
ensureGitignored(file);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Make sure the `.gitignore` next to `file` lists the file's basename
|
|
91
|
+
* (currently always `.neon`). Creates the `.gitignore` if it doesn't exist,
|
|
92
|
+
* or appends `.neon` if it's missing — never duplicates an existing entry.
|
|
93
|
+
*
|
|
94
|
+
* Best-effort: a failure here (e.g. read-only filesystem) is logged at debug
|
|
95
|
+
* level and swallowed; persisting the context file is the primary goal and
|
|
96
|
+
* must not be blocked by a `.gitignore` write error.
|
|
97
|
+
*/
|
|
98
|
+
export const ensureGitignored = (file) => {
|
|
99
|
+
try {
|
|
100
|
+
const dir = dirname(file);
|
|
101
|
+
const entry = basenameOf(file);
|
|
102
|
+
const gitignorePath = resolve(dir, GITIGNORE_FILE);
|
|
103
|
+
if (!existsSync(gitignorePath)) {
|
|
104
|
+
writeFileSync(gitignorePath, `${entry}\n`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const current = readFileSync(gitignorePath, 'utf-8');
|
|
108
|
+
if (hasGitignoreEntry(current, entry)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const needsLeadingNewline = current.length > 0 && !current.endsWith('\n');
|
|
112
|
+
const addition = `${needsLeadingNewline ? '\n' : ''}${entry}\n`;
|
|
113
|
+
writeFileSync(gitignorePath, current + addition);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
log.debug('Failed to update .gitignore next to %s: %s', file, message);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const basenameOf = (file) => {
|
|
121
|
+
const parts = file.split(/[\\/]/);
|
|
122
|
+
return parts[parts.length - 1] || CONTEXT_FILE;
|
|
123
|
+
};
|
|
124
|
+
const hasGitignoreEntry = (content, entry) => {
|
|
125
|
+
return content.split(/\r?\n/).some((line) => line.trim() === entry);
|
|
126
|
+
};
|