neonctl 2.26.7 → 2.26.8

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.
@@ -1,12 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
3
- import { dirname, join, relative, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
4
  import chalk from 'chalk';
5
+ import { BootstrapInputError, FALLBACK_TEMPLATES, ensureTargetUsable, fetchTemplates, findTemplate, scaffoldTemplate, templateIds, } from 'neon-init/bootstrap';
5
6
  import prompts from 'prompts';
6
7
  import which from 'which';
7
8
  import { isCi } from '../env.js';
8
9
  import { log } from '../log.js';
9
- import { FALLBACK_TEMPLATES, downloadTemplate, fetchTemplates, findTemplate, templateIds, } from '../utils/bootstrap.js';
10
10
  // The directory positional is optional: omitting it in an interactive terminal
11
11
  // prompts for one. In a non-interactive context a missing directory is an error.
12
12
  export const command = 'bootstrap [directory]';
@@ -178,70 +178,20 @@ const resolveTargetDir = async (props, interactive, template) => {
178
178
  };
179
179
  const defaultDirName = (template) => template.source.subdir.split('/').pop() || template.id;
180
180
  /**
181
- * A bad user-supplied input that an agent (or human) can correct: an unknown
182
- * template id or a non-empty target directory. Carries an `agentCode` so
183
- * `--agent` mode reports a precise `status: error` code instead of a generic
184
- * INTERNAL_ERROR, while the human path just surfaces the clear `message`.
181
+ * Download and materialize the template into `targetDir`. The actual
182
+ * download/extract/write lives in the shared `neon-init/bootstrap` core
183
+ * (exec-bit and symlink fidelity, graceful symlink fallback); here we just
184
+ * frame it with progress logging. Returns the number of files written.
185
185
  */
186
- class BootstrapInputError extends Error {
187
- constructor(message, agentCode) {
188
- super(message);
189
- this.name = 'BootstrapInputError';
190
- this.agentCode = agentCode;
191
- }
192
- }
193
- const ensureTargetUsable = (dir, force) => {
194
- if (!existsSync(dir)) {
195
- return;
196
- }
197
- if (!statSync(dir).isDirectory()) {
198
- throw new BootstrapInputError(`Target ${dir} already exists and is not a directory.`, 'TARGET_NOT_DIRECTORY');
199
- }
200
- // A lone `.git` is ignored so you can scaffold into a freshly `git init`ed
201
- // (otherwise empty) directory without reaching for --force.
202
- const contents = readdirSync(dir).filter((name) => name !== '.git');
203
- if (contents.length > 0 && !force) {
204
- throw new BootstrapInputError(`Target directory ${dir} is not empty. Use --force to scaffold into it anyway (colliding files will be overwritten), or choose an empty directory.`, 'TARGET_NOT_EMPTY');
205
- }
206
- };
207
186
  const scaffold = async (template, targetDir) => {
208
187
  log.info('Fetching template "%s" from GitHub…', template.id);
209
- const files = await downloadTemplate(template);
210
- mkdirSync(targetDir, { recursive: true });
211
- log.info('Scaffolding %d files into %s…', files.length, targetDir);
212
- for (const file of files) {
213
- const dest = join(targetDir, file.path);
214
- mkdirSync(dirname(dest), { recursive: true });
215
- if (file.kind === 'symlink') {
216
- writeSymlink(dest, file.target);
217
- }
218
- else {
219
- writeFileSync(dest, file.bytes);
220
- if (file.executable) {
221
- chmodSync(dest, 0o755);
222
- }
223
- }
224
- }
225
- return files.length;
226
- };
227
- const writeSymlink = (dest, target) => {
228
- if (isSymlink(dest)) {
229
- rmSync(dest, { force: true });
230
- }
231
- try {
232
- symlinkSync(target, dest);
233
- }
234
- catch (err) {
235
- // Windows refuses symlinks without elevated rights / developer mode. The
236
- // template still works for most tooling if we drop a regular file holding
237
- // the link target, so we degrade gracefully instead of failing the copy.
238
- if (errnoCode(err) === 'EPERM' || process.platform === 'win32') {
239
- log.warning('Could not create symlink %s -> %s; wrote it as a regular file instead.', dest, target);
240
- writeFileSync(dest, target);
241
- return;
242
- }
243
- throw err;
244
- }
188
+ const filesWritten = await scaffoldTemplate(template, targetDir, {
189
+ onWarn: (message) => {
190
+ log.warning(message);
191
+ },
192
+ });
193
+ log.info('Scaffolded %d files into %s.', filesWritten, targetDir);
194
+ return filesWritten;
245
195
  };
246
196
  // ----------------------------------------------------------------------------
247
197
  // Post-scaffold steps (install dependencies, git init, link to a Neon project)
@@ -558,23 +508,6 @@ const displayDir = (targetDir) => {
558
508
  }
559
509
  return rel.startsWith('..') ? targetDir : rel;
560
510
  };
561
- const isSymlink = (path) => {
562
- try {
563
- return lstatSync(path).isSymbolicLink();
564
- }
565
- catch {
566
- return false;
567
- }
568
- };
569
- const errnoCode = (err) => {
570
- if (typeof err === 'object' &&
571
- err !== null &&
572
- 'code' in err &&
573
- typeof err.code === 'string') {
574
- return err.code;
575
- }
576
- return undefined;
577
- };
578
511
  const shellArg = (value) => {
579
512
  if (/^[A-Za-z0-9._:/-]+$/.test(value)) {
580
513
  return value;
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.26.7",
8
+ "version": "2.26.8",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -71,7 +71,7 @@
71
71
  "cliui": "8.0.1",
72
72
  "diff": "5.2.0",
73
73
  "fflate": "^0.8.3",
74
- "neon-init": "0.17.2",
74
+ "neon-init": "0.18.0",
75
75
  "open": "10.1.0",
76
76
  "openid-client": "6.8.1",
77
77
  "pg-protocol": "^1.14.0",
@@ -1,364 +0,0 @@
1
- import axios, { isAxiosError } from 'axios';
2
- import { gunzipSync } from 'fflate';
3
- import YAML from 'yaml';
4
- import { log } from '../log.js';
5
- /**
6
- * Hardcoded fallback used when every remote manifest source is unreachable.
7
- * Kept in sync with `neondatabase/examples/bootstrap.yaml` (the source of
8
- * truth) so that, even fully offline from the manifest, the picker still offers
9
- * the full set of starters rather than a single template.
10
- */
11
- export const FALLBACK_TEMPLATES = [
12
- {
13
- id: 'hono',
14
- title: 'Hono API (Drizzle, Neon Postgres) on Neon Functions',
15
- description: 'A Hono API using Drizzle ORM and Neon Postgres, ready to deploy as a Neon Function.',
16
- services: ['Postgres', 'Functions'],
17
- source: {
18
- owner: 'neondatabase',
19
- repo: 'examples',
20
- ref: 'main',
21
- subdir: 'with-hono',
22
- },
23
- },
24
- {
25
- id: 'ai-sdk',
26
- title: 'AI SDK agent (AI Gateway, object storage, Drizzle) on Neon Functions',
27
- description: 'A Vercel AI SDK agent on Neon Functions: streams chat through the Neon AI Gateway, generates an image with OpenAI image generation, and stores it in Neon object storage indexed in Postgres via Drizzle.',
28
- services: ['Postgres', 'Functions', 'Object Storage', 'AI Gateway'],
29
- source: {
30
- owner: 'neondatabase',
31
- repo: 'examples',
32
- ref: 'main',
33
- subdir: 'with-ai-sdk',
34
- },
35
- },
36
- {
37
- id: 'mastra',
38
- title: 'Mastra personal agent (AI Gateway, Mastra Memory) on Neon Functions',
39
- description: 'A Mastra personal-assistant agent on Neon Functions: streams chat through the Neon AI Gateway and uses Mastra Memory — backed by Neon Postgres — to remember the user across conversation threads via resource-scoped working memory.',
40
- services: ['Postgres', 'Functions', 'AI Gateway'],
41
- source: {
42
- owner: 'neondatabase',
43
- repo: 'examples',
44
- ref: 'main',
45
- subdir: 'with-mastra',
46
- },
47
- },
48
- ];
49
- export const templateIds = (templates) => templates.map((t) => t.id).join(', ');
50
- export const findTemplate = (templates, id) => templates.find((t) => t.id === id);
51
- const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '';
52
- // A token is never required for public templates, but we forward it when
53
- // present so the same code path works behind proxies that authenticate, and
54
- // (in future) for private template repos.
55
- const downloadHeaders = () => ({
56
- 'User-Agent': 'neonctl',
57
- ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
58
- });
59
- // The codeload host is overridable so the e2e tests can point the downloader at
60
- // a local server (the same trick `--api-host` uses to redirect the Neon API).
61
- const codeloadBase = () => process.env.NEON_BOOTSTRAP_GITHUB_CODELOAD ?? 'https://codeload.github.com';
62
- const isRecord = (value) => typeof value === 'object' && value !== null;
63
- /**
64
- * Normalize a manifest entry's `services` into a clean string list. Tolerant by
65
- * design: a missing or non-array value yields `undefined`, and non-string items
66
- * are dropped, so a malformed `services` never sinks an otherwise-valid
67
- * template (it just renders without its badge).
68
- */
69
- const parseServices = (value) => {
70
- if (!Array.isArray(value)) {
71
- return undefined;
72
- }
73
- const services = value.filter((item) => typeof item === 'string' && item.trim() !== '');
74
- return services.length > 0 ? services : undefined;
75
- };
76
- // ---------------------------------------------------------------------------
77
- // Remote template manifest
78
- // ---------------------------------------------------------------------------
79
- // Primary manifest host is neon.com (CDN-backed, no GitHub rate limiting),
80
- // with the raw GitHub copy as a fallback and the hardcoded list as the last
81
- // resort. A single env override (used by tests) short-circuits the chain.
82
- const NEON_MANIFEST_URL = 'https://neon.com/bootstrap/templates.yaml';
83
- const GITHUB_RAW_MANIFEST_URL = 'https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml';
84
- const manifestUrls = () => {
85
- const override = process.env.NEON_BOOTSTRAP_MANIFEST_URL;
86
- if (override) {
87
- return [override];
88
- }
89
- return [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];
90
- };
91
- export const parseManifest = (text) => {
92
- const data = YAML.parse(text);
93
- if (!isRecord(data) || !Array.isArray(data.templates)) {
94
- throw new Error('Invalid bootstrap manifest: missing "templates" array.');
95
- }
96
- const templates = [];
97
- for (let i = 0; i < data.templates.length; i++) {
98
- const item = data.templates[i];
99
- if (!isRecord(item) ||
100
- typeof item.id !== 'string' ||
101
- typeof item.title !== 'string' ||
102
- typeof item.description !== 'string' ||
103
- !isRecord(item.source) ||
104
- typeof item.source.owner !== 'string' ||
105
- typeof item.source.repo !== 'string' ||
106
- typeof item.source.ref !== 'string' ||
107
- typeof item.source.subdir !== 'string') {
108
- log.warning('bootstrap: skipping malformed template entry at index %d in manifest.', i);
109
- continue;
110
- }
111
- const services = parseServices(item.services);
112
- templates.push({
113
- id: item.id,
114
- title: item.title,
115
- description: item.description,
116
- ...(services ? { services } : {}),
117
- source: {
118
- owner: item.source.owner,
119
- repo: item.source.repo,
120
- ref: item.source.ref,
121
- subdir: item.source.subdir,
122
- },
123
- });
124
- }
125
- return templates;
126
- };
127
- /**
128
- * Fetch the template manifest, trying each source in {@link manifestUrls} in
129
- * order and returning the first that yields a non-empty template list. Falls
130
- * back to the hardcoded list when every source is unreachable or empty, so the
131
- * command never fails just because a host is down.
132
- */
133
- export const fetchTemplates = async () => {
134
- for (const url of manifestUrls()) {
135
- try {
136
- const res = await axios.get(url, {
137
- responseType: 'text',
138
- headers: downloadHeaders(),
139
- timeout: 10000,
140
- });
141
- const templates = parseManifest(res.data);
142
- if (templates.length > 0) {
143
- return templates;
144
- }
145
- log.debug('bootstrap: manifest at %s contained no templates; trying next source.', url);
146
- }
147
- catch (err) {
148
- log.debug('bootstrap: failed to fetch manifest from %s: %s — trying next source.', url, err instanceof Error ? err.message : String(err));
149
- }
150
- }
151
- log.debug('bootstrap: all manifest sources exhausted; using built-in defaults.');
152
- return FALLBACK_TEMPLATES;
153
- };
154
- const TAR_BLOCK = 512;
155
- const readTarString = (buf, offset, length) => {
156
- let end = offset;
157
- const max = offset + length;
158
- while (end < max && buf[end] !== 0) {
159
- end++;
160
- }
161
- return buf.toString('utf8', offset, end);
162
- };
163
- const readTarOctal = (buf, offset, length) => {
164
- const text = readTarString(buf, offset, length).trim();
165
- if (text === '') {
166
- return 0;
167
- }
168
- const value = parseInt(text, 8);
169
- return Number.isNaN(value) ? 0 : value;
170
- };
171
- const isZeroBlock = (buf, offset) => {
172
- for (let i = offset; i < offset + TAR_BLOCK; i++) {
173
- if (buf[i] !== 0) {
174
- return false;
175
- }
176
- }
177
- return true;
178
- };
179
- /**
180
- * Parse pax extended-header records ("<len> <key>=<value>\n"). GitHub uses
181
- * these for the global header and for any path that doesn't fit the legacy
182
- * 100-byte name field, so we must honor at least `path` and `linkpath`.
183
- */
184
- const parsePaxRecords = (data) => {
185
- const records = {};
186
- let pos = 0;
187
- const text = data.toString('utf8');
188
- while (pos < text.length) {
189
- const space = text.indexOf(' ', pos);
190
- if (space === -1) {
191
- break;
192
- }
193
- const len = parseInt(text.slice(pos, space), 10);
194
- if (Number.isNaN(len) || len <= 0) {
195
- break;
196
- }
197
- const record = text.slice(space + 1, pos + len - 1); // drop trailing "\n"
198
- const eq = record.indexOf('=');
199
- if (eq !== -1) {
200
- records[record.slice(0, eq)] = record.slice(eq + 1);
201
- }
202
- pos += len;
203
- }
204
- return records;
205
- };
206
- /**
207
- * Decode a (decompressed) tar archive into its file/symlink entries. Pure and
208
- * dependency-free so it can be unit tested without touching the network.
209
- * Handles the ustar `prefix` field, pax extended headers (type 'x'/'g'), and
210
- * GNU long-name/long-link headers (type 'L'/'K') so deep template paths and
211
- * long symlink targets round-trip correctly.
212
- */
213
- export const parseTar = (buf) => {
214
- const entries = [];
215
- // Overrides carried from a preceding pax/GNU header to the next real entry.
216
- let overridePath;
217
- let overrideLink;
218
- let offset = 0;
219
- while (offset + TAR_BLOCK <= buf.length) {
220
- if (isZeroBlock(buf, offset)) {
221
- break;
222
- }
223
- let name = readTarString(buf, offset, 100);
224
- const mode = readTarOctal(buf, offset + 100, 8);
225
- const size = readTarOctal(buf, offset + 124, 12);
226
- const typeByte = buf[offset + 156];
227
- const type = typeByte === 0 ? '0' : String.fromCharCode(typeByte);
228
- let linkname = readTarString(buf, offset + 157, 100);
229
- const magic = readTarString(buf, offset + 257, 6);
230
- if (magic.startsWith('ustar')) {
231
- const prefix = readTarString(buf, offset + 345, 155);
232
- if (prefix !== '') {
233
- name = `${prefix}/${name}`;
234
- }
235
- }
236
- offset += TAR_BLOCK;
237
- const data = buf.subarray(offset, offset + size);
238
- offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
239
- if (type === 'x') {
240
- const records = parsePaxRecords(data);
241
- if (records.path !== undefined) {
242
- overridePath = records.path;
243
- }
244
- if (records.linkpath !== undefined) {
245
- overrideLink = records.linkpath;
246
- }
247
- continue;
248
- }
249
- if (type === 'g') {
250
- // Global pax header (e.g. GitHub's comment block): not per-entry state.
251
- continue;
252
- }
253
- if (type === 'L' || type === 'K') {
254
- const longValue = data.toString('utf8').replace(/\0+$/, '');
255
- if (type === 'L') {
256
- overridePath = longValue;
257
- }
258
- else {
259
- overrideLink = longValue;
260
- }
261
- continue;
262
- }
263
- if (overridePath !== undefined) {
264
- name = overridePath;
265
- }
266
- if (overrideLink !== undefined) {
267
- linkname = overrideLink;
268
- }
269
- overridePath = undefined;
270
- overrideLink = undefined;
271
- entries.push({ name, type, mode, linkname, data: Buffer.from(data) });
272
- }
273
- return entries;
274
- };
275
- /**
276
- * Map decoded tar entries to the files under `subdir`, with the top-level
277
- * archive directory and the `subdir/` prefix stripped from each path. Pure so
278
- * it can be unit tested. Directory and other non-regular entries are dropped —
279
- * writing files re-creates their parent directories.
280
- */
281
- export const selectTemplateFiles = (entries, subdir) => {
282
- const prefix = `${subdir.replace(/^\/+|\/+$/g, '')}/`;
283
- const files = [];
284
- for (const entry of entries) {
285
- // codeload wraps everything in a single top-level dir ("<repo>-<ref>/");
286
- // strip that first segment to get the repo-relative path.
287
- const slash = entry.name.indexOf('/');
288
- if (slash === -1) {
289
- continue;
290
- }
291
- const repoPath = entry.name.slice(slash + 1);
292
- if (!repoPath.startsWith(prefix)) {
293
- continue;
294
- }
295
- const path = repoPath.slice(prefix.length);
296
- if (path === '') {
297
- continue;
298
- }
299
- if (entry.type === '2') {
300
- files.push({ kind: 'symlink', path, target: entry.linkname });
301
- }
302
- else if (entry.type === '0' || entry.type === '7') {
303
- files.push({
304
- kind: 'file',
305
- path,
306
- bytes: entry.data,
307
- executable: (entry.mode & 0o111) !== 0,
308
- });
309
- }
310
- // Directories ('5') and any other node types are intentionally skipped.
311
- }
312
- return files;
313
- };
314
- const tarballUrl = (template) => {
315
- const { owner, repo, ref } = template.source;
316
- return `${codeloadBase()}/${owner}/${repo}/tar.gz/${ref}`;
317
- };
318
- const friendlyGithubError = (err, url) => {
319
- if (isAxiosError(err)) {
320
- const status = err.response?.status;
321
- if (status === 404) {
322
- return new Error(`GitHub returned 404 for ${url}. The template repo or ref may have moved.`);
323
- }
324
- if (status === 403 || status === 429) {
325
- return new Error(`GitHub rate limited the template download (${url}). Set a GITHUB_TOKEN environment variable to raise the limit, then retry.`);
326
- }
327
- }
328
- return err instanceof Error ? err : new Error(String(err));
329
- };
330
- /**
331
- * Download a template and resolve it to the exact set of files to write. The
332
- * entire subtree is captured in one tarball request, so the copy is atomically
333
- * consistent: a push to the template repo mid-download cannot produce a
334
- * mismatched checkout (unlike fetching a file list and then each blob).
335
- */
336
- export const downloadTemplate = async (template) => {
337
- const url = tarballUrl(template);
338
- let gzipped;
339
- try {
340
- const res = await axios.get(url, {
341
- responseType: 'arraybuffer',
342
- headers: downloadHeaders(),
343
- timeout: 30000,
344
- });
345
- gzipped = Buffer.from(res.data);
346
- }
347
- catch (err) {
348
- throw friendlyGithubError(err, url);
349
- }
350
- let tar;
351
- try {
352
- tar = Buffer.from(gunzipSync(new Uint8Array(gzipped)));
353
- }
354
- catch (err) {
355
- throw new Error(`Failed to decompress the template archive from ${url}: ${err instanceof Error ? err.message : String(err)}`);
356
- }
357
- const { owner, repo, ref, subdir } = template.source;
358
- const files = selectTemplateFiles(parseTar(tar), subdir);
359
- if (files.length === 0) {
360
- throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
361
- }
362
- log.debug('bootstrap: resolved %d files for template "%s" from %s', files.length, template.id, url);
363
- return files;
364
- };