neon-init 0.17.2 → 0.19.0

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,3 +1,6 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { gunzipSync } from "fflate";
1
4
  import YAML from "yaml";
2
5
  //#region src/lib/bootstrap.ts
3
6
  /** Default features when a template doesn't specify `requires`. */
@@ -11,8 +14,10 @@ const DEFAULT_REQUIRES = ["database"];
11
14
  const FALLBACK_TEMPLATES = [
12
15
  {
13
16
  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.",
17
+ title: "REST API",
18
+ description: "A Hono REST API on Neon Functions, backed by Neon Postgres via Drizzle.",
19
+ tools: ["Hono", "Drizzle"],
20
+ services: ["Postgres", "Functions"],
16
21
  requires: ["database", "functions"],
17
22
  source: {
18
23
  owner: "neondatabase",
@@ -23,8 +28,15 @@ const FALLBACK_TEMPLATES = [
23
28
  },
24
29
  {
25
30
  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.",
31
+ title: "Image-generation agent",
32
+ description: "A Vercel AI SDK agent that streams chat through the Neon AI Gateway and stores generated images in Neon object storage, indexed in Postgres via Drizzle.",
33
+ tools: ["AI SDK", "Drizzle"],
34
+ services: [
35
+ "Postgres",
36
+ "Functions",
37
+ "Object Storage",
38
+ "AI Gateway"
39
+ ],
28
40
  requires: [
29
41
  "database",
30
42
  "functions",
@@ -40,8 +52,14 @@ const FALLBACK_TEMPLATES = [
40
52
  },
41
53
  {
42
54
  id: "mastra",
43
- title: "Mastra personal agent (AI Gateway, Mastra Memory) on Neon Functions",
44
- 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.",
55
+ title: "Personal-assistant agent",
56
+ description: "A Mastra agent that streams chat through the Neon AI Gateway and uses Mastra Memory on Neon Postgres to remember you across threads.",
57
+ tools: ["Mastra", "Mastra Memory"],
58
+ services: [
59
+ "Postgres",
60
+ "Functions",
61
+ "AI Gateway"
62
+ ],
45
63
  requires: [
46
64
  "database",
47
65
  "functions",
@@ -55,6 +73,26 @@ const FALLBACK_TEMPLATES = [
55
73
  }
56
74
  }
57
75
  ];
76
+ const templateIds = (templates) => templates.map((t) => t.id).join(", ");
77
+ const findTemplate = (templates, id) => templates.find((t) => t.id === id);
78
+ const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
79
+ const downloadHeaders = () => ({
80
+ "User-Agent": "neon-init",
81
+ ...githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}
82
+ });
83
+ const codeloadBase = () => process.env.NEON_BOOTSTRAP_GITHUB_CODELOAD ?? "https://codeload.github.com";
84
+ const isRecord = (value) => typeof value === "object" && value !== null;
85
+ /**
86
+ * Normalize a manifest entry's string list (`tools` or `services`) into a clean
87
+ * array. Tolerant by design: a missing or non-array value yields `undefined`,
88
+ * and non-string/blank items are dropped, so a malformed list never sinks an
89
+ * otherwise-valid template (it just renders without that detail).
90
+ */
91
+ const parseStringList = (value) => {
92
+ if (!Array.isArray(value)) return void 0;
93
+ const items = value.filter((item) => typeof item === "string" && item.trim() !== "");
94
+ return items.length > 0 ? items : void 0;
95
+ };
58
96
  const NEON_MANIFEST_URL = "https://neon.com/bootstrap/templates.yaml";
59
97
  const GITHUB_RAW_MANIFEST_URL = "https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml";
60
98
  function manifestUrls() {
@@ -62,7 +100,6 @@ function manifestUrls() {
62
100
  if (override) return [override];
63
101
  return [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];
64
102
  }
65
- const isRecord = (value) => typeof value === "object" && value !== null;
66
103
  function parseManifest(text) {
67
104
  const data = YAML.parse(text);
68
105
  if (!isRecord(data) || !Array.isArray(data.templates)) throw new Error("Invalid bootstrap manifest: missing \"templates\" array.");
@@ -70,10 +107,14 @@ function parseManifest(text) {
70
107
  for (const item of data.templates) {
71
108
  if (!isRecord(item) || typeof item.id !== "string" || typeof item.title !== "string" || typeof item.description !== "string" || !isRecord(item.source) || typeof item.source.owner !== "string" || typeof item.source.repo !== "string" || typeof item.source.ref !== "string" || typeof item.source.subdir !== "string") continue;
72
109
  const requires = Array.isArray(item.requires) && item.requires.every((r) => typeof r === "string") ? item.requires : DEFAULT_REQUIRES;
110
+ const tools = parseStringList(item.tools);
111
+ const services = parseStringList(item.services);
73
112
  templates.push({
74
113
  id: item.id,
75
114
  title: item.title,
76
115
  description: item.description,
116
+ ...tools ? { tools } : {},
117
+ ...services ? { services } : {},
77
118
  requires,
78
119
  source: {
79
120
  owner: item.source.owner,
@@ -93,14 +134,247 @@ function parseManifest(text) {
93
134
  */
94
135
  async function fetchTemplates() {
95
136
  for (const url of manifestUrls()) try {
96
- const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
137
+ const res = await fetch(url, {
138
+ headers: downloadHeaders(),
139
+ signal: AbortSignal.timeout(1e4)
140
+ });
97
141
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
98
142
  const templates = parseManifest(await res.text());
99
143
  if (templates.length > 0) return templates;
100
144
  } catch {}
101
145
  return FALLBACK_TEMPLATES;
102
146
  }
147
+ const TAR_BLOCK = 512;
148
+ const readTarString = (buf, offset, length) => {
149
+ let end = offset;
150
+ const max = offset + length;
151
+ while (end < max && buf[end] !== 0) end++;
152
+ return buf.toString("utf8", offset, end);
153
+ };
154
+ const readTarOctal = (buf, offset, length) => {
155
+ const text = readTarString(buf, offset, length).trim();
156
+ if (text === "") return 0;
157
+ const value = parseInt(text, 8);
158
+ return Number.isNaN(value) ? 0 : value;
159
+ };
160
+ const isZeroBlock = (buf, offset) => {
161
+ for (let i = offset; i < offset + TAR_BLOCK; i++) if (buf[i] !== 0) return false;
162
+ return true;
163
+ };
164
+ /**
165
+ * Parse pax extended-header records ("<len> <key>=<value>\n"). GitHub uses
166
+ * these for the global header and for any path that doesn't fit the legacy
167
+ * 100-byte name field, so we must honor at least `path` and `linkpath`.
168
+ */
169
+ const parsePaxRecords = (data) => {
170
+ const records = {};
171
+ let pos = 0;
172
+ const text = data.toString("utf8");
173
+ while (pos < text.length) {
174
+ const space = text.indexOf(" ", pos);
175
+ if (space === -1) break;
176
+ const len = parseInt(text.slice(pos, space), 10);
177
+ if (Number.isNaN(len) || len <= 0) break;
178
+ const record = text.slice(space + 1, pos + len - 1);
179
+ const eq = record.indexOf("=");
180
+ if (eq !== -1) records[record.slice(0, eq)] = record.slice(eq + 1);
181
+ pos += len;
182
+ }
183
+ return records;
184
+ };
185
+ /**
186
+ * Decode a (decompressed) tar archive into its file/symlink entries. Pure and
187
+ * dependency-free so it can be unit tested without touching the network.
188
+ * Handles the ustar `prefix` field, pax extended headers (type 'x'/'g'), and
189
+ * GNU long-name/long-link headers (type 'L'/'K') so deep template paths and
190
+ * long symlink targets round-trip correctly.
191
+ */
192
+ const parseTar = (buf) => {
193
+ const entries = [];
194
+ let overridePath;
195
+ let overrideLink;
196
+ let offset = 0;
197
+ while (offset + TAR_BLOCK <= buf.length) {
198
+ if (isZeroBlock(buf, offset)) break;
199
+ let name = readTarString(buf, offset, 100);
200
+ const mode = readTarOctal(buf, offset + 100, 8);
201
+ const size = readTarOctal(buf, offset + 124, 12);
202
+ const typeByte = buf[offset + 156];
203
+ const type = typeByte === 0 ? "0" : String.fromCharCode(typeByte);
204
+ let linkname = readTarString(buf, offset + 157, 100);
205
+ if (readTarString(buf, offset + 257, 6).startsWith("ustar")) {
206
+ const prefix = readTarString(buf, offset + 345, 155);
207
+ if (prefix !== "") name = `${prefix}/${name}`;
208
+ }
209
+ offset += TAR_BLOCK;
210
+ const data = buf.subarray(offset, offset + size);
211
+ offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
212
+ if (type === "x") {
213
+ const records = parsePaxRecords(data);
214
+ if (records.path !== void 0) overridePath = records.path;
215
+ if (records.linkpath !== void 0) overrideLink = records.linkpath;
216
+ continue;
217
+ }
218
+ if (type === "g") continue;
219
+ if (type === "L" || type === "K") {
220
+ const longValue = data.toString("utf8").replace(/\0+$/, "");
221
+ if (type === "L") overridePath = longValue;
222
+ else overrideLink = longValue;
223
+ continue;
224
+ }
225
+ if (overridePath !== void 0) name = overridePath;
226
+ if (overrideLink !== void 0) linkname = overrideLink;
227
+ overridePath = void 0;
228
+ overrideLink = void 0;
229
+ entries.push({
230
+ name,
231
+ type,
232
+ mode,
233
+ linkname,
234
+ data: Buffer.from(data)
235
+ });
236
+ }
237
+ return entries;
238
+ };
239
+ /**
240
+ * Map decoded tar entries to the files under `subdir`, with the top-level
241
+ * archive directory and the `subdir/` prefix stripped from each path. Pure so
242
+ * it can be unit tested. Directory and other non-regular entries are dropped —
243
+ * writing files re-creates their parent directories.
244
+ */
245
+ const selectTemplateFiles = (entries, subdir) => {
246
+ const prefix = `${subdir.replace(/^\/+|\/+$/g, "")}/`;
247
+ const files = [];
248
+ for (const entry of entries) {
249
+ const slash = entry.name.indexOf("/");
250
+ if (slash === -1) continue;
251
+ const repoPath = entry.name.slice(slash + 1);
252
+ if (!repoPath.startsWith(prefix)) continue;
253
+ const path = repoPath.slice(prefix.length);
254
+ if (path === "") continue;
255
+ if (entry.type === "2") files.push({
256
+ kind: "symlink",
257
+ path,
258
+ target: entry.linkname
259
+ });
260
+ else if (entry.type === "0" || entry.type === "7") files.push({
261
+ kind: "file",
262
+ path,
263
+ bytes: entry.data,
264
+ executable: (entry.mode & 73) !== 0
265
+ });
266
+ }
267
+ return files;
268
+ };
269
+ const tarballUrl = (template) => {
270
+ const { owner, repo, ref } = template.source;
271
+ return `${codeloadBase()}/${owner}/${repo}/tar.gz/${ref}`;
272
+ };
273
+ const friendlyGithubError = (status, url) => {
274
+ if (status === 404) return /* @__PURE__ */ new Error(`GitHub returned 404 for ${url}. The template repo or ref may have moved.`);
275
+ if (status === 403 || status === 429) return /* @__PURE__ */ new Error(`GitHub rate limited the template download (${url}). Set a GITHUB_TOKEN environment variable to raise the limit, then retry.`);
276
+ return /* @__PURE__ */ new Error(`GitHub returned HTTP ${status} for ${url}.`);
277
+ };
278
+ /**
279
+ * Download a template and resolve it to the exact set of files to write. The
280
+ * entire subtree is captured in one tarball request, so the copy is atomically
281
+ * consistent: a push to the template repo mid-download cannot produce a
282
+ * mismatched checkout (unlike fetching a file list and then each blob).
283
+ */
284
+ const downloadTemplate = async (template) => {
285
+ const url = tarballUrl(template);
286
+ let gzipped;
287
+ try {
288
+ const res = await fetch(url, {
289
+ headers: downloadHeaders(),
290
+ signal: AbortSignal.timeout(3e4)
291
+ });
292
+ if (!res.ok) throw friendlyGithubError(res.status, url);
293
+ gzipped = Buffer.from(await res.arrayBuffer());
294
+ } catch (err) {
295
+ throw err instanceof Error ? err : new Error(String(err));
296
+ }
297
+ let tar;
298
+ try {
299
+ tar = Buffer.from(gunzipSync(new Uint8Array(gzipped)));
300
+ } catch (err) {
301
+ throw new Error(`Failed to decompress the template archive from ${url}: ${err instanceof Error ? err.message : String(err)}`);
302
+ }
303
+ const { owner, repo, ref, subdir } = template.source;
304
+ const files = selectTemplateFiles(parseTar(tar), subdir);
305
+ if (files.length === 0) throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
306
+ return files;
307
+ };
308
+ /**
309
+ * A bad caller-supplied input that an agent (or human) can correct: an unknown
310
+ * template id or a non-empty target directory. Carries an `agentCode` so an
311
+ * agent surface can report a precise error code instead of a generic
312
+ * INTERNAL_ERROR, while a human path just surfaces the clear `message`.
313
+ */
314
+ var BootstrapInputError = class extends Error {
315
+ agentCode;
316
+ constructor(message, agentCode) {
317
+ super(message);
318
+ this.name = "BootstrapInputError";
319
+ this.agentCode = agentCode;
320
+ }
321
+ };
322
+ /**
323
+ * Ensure `dir` is safe to scaffold into: it must be missing, or an empty
324
+ * directory (a lone `.git` is ignored so you can scaffold into a freshly
325
+ * `git init`ed folder). `force` allows scaffolding into a non-empty directory,
326
+ * overwriting colliding files. Throws a {@link BootstrapInputError} otherwise.
327
+ */
328
+ const ensureTargetUsable = (dir, force) => {
329
+ if (!existsSync(dir)) return;
330
+ if (!statSync(dir).isDirectory()) throw new BootstrapInputError(`Target ${dir} already exists and is not a directory.`, "TARGET_NOT_DIRECTORY");
331
+ if (readdirSync(dir).filter((name) => name !== ".git").length > 0 && !force) 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");
332
+ };
333
+ const isSymlink = (path) => {
334
+ try {
335
+ return lstatSync(path).isSymbolicLink();
336
+ } catch {
337
+ return false;
338
+ }
339
+ };
340
+ const errnoCode = (err) => {
341
+ if (typeof err === "object" && err !== null && "code" in err && typeof err.code === "string") return err.code;
342
+ };
343
+ const writeSymlink = (dest, target, onWarn) => {
344
+ if (isSymlink(dest)) rmSync(dest, { force: true });
345
+ try {
346
+ symlinkSync(target, dest);
347
+ } catch (err) {
348
+ if (errnoCode(err) === "EPERM" || process.platform === "win32") {
349
+ onWarn?.(`Could not create symlink ${dest} -> ${target}; wrote it as a regular file instead.`);
350
+ writeFileSync(dest, target);
351
+ return;
352
+ }
353
+ throw err;
354
+ }
355
+ };
356
+ /**
357
+ * Download `template` and materialize its files into `targetDir`, creating
358
+ * parent directories, preserving executable bits, and recreating symlinks
359
+ * (with a graceful regular-file fallback on platforms that disallow them).
360
+ * Returns the number of files written. The caller is responsible for any
361
+ * target validation ({@link ensureTargetUsable}) and user-facing progress.
362
+ */
363
+ const scaffoldTemplate = async (template, targetDir, options = {}) => {
364
+ const files = await downloadTemplate(template);
365
+ mkdirSync(targetDir, { recursive: true });
366
+ for (const file of files) {
367
+ const dest = join(targetDir, file.path);
368
+ mkdirSync(dirname(dest), { recursive: true });
369
+ if (file.kind === "symlink") writeSymlink(dest, file.target, options.onWarn);
370
+ else {
371
+ writeFileSync(dest, file.bytes);
372
+ if (file.executable) chmodSync(dest, 493);
373
+ }
374
+ }
375
+ return files.length;
376
+ };
103
377
  //#endregion
104
- export { FALLBACK_TEMPLATES, fetchTemplates, parseManifest };
378
+ export { BootstrapInputError, FALLBACK_TEMPLATES, downloadTemplate, ensureTargetUsable, fetchTemplates, findTemplate, parseManifest, parseTar, scaffoldTemplate, selectTemplateFiles, templateIds };
105
379
 
106
380
  //# sourceMappingURL=bootstrap.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/lib/bootstrap.ts"],"sourcesContent":["import YAML from \"yaml\";\n\n/**\n * Neon features that a template or project may require.\n * Each feature maps to a setup phase that the orchestrator can run.\n */\nexport type NeonFeature =\n\t| \"database\"\n\t| \"auth\"\n\t| \"functions\"\n\t| \"ai-gateway\"\n\t| \"object-storage\";\n\n/** Default features when a template doesn't specify `requires`. */\nconst DEFAULT_REQUIRES: NeonFeature[] = [\"database\"];\n\nexport interface BootstrapTemplate {\n\tid: string;\n\ttitle: string;\n\tdescription: string;\n\t/** Neon features this template needs (defaults to [\"database\"]) */\n\trequires: NeonFeature[];\n\tsource: {\n\t\towner: string;\n\t\trepo: string;\n\t\tref: string;\n\t\tsubdir: string;\n\t};\n}\n\n/**\n * Hardcoded fallback used when every remote manifest source is unreachable.\n * Kept in sync with `neondatabase/examples/bootstrap.yaml` (the source of\n * truth) so that, even fully offline from the manifest, the picker still offers\n * the full set of starters rather than a single template.\n */\nexport const FALLBACK_TEMPLATES: BootstrapTemplate[] = [\n\t{\n\t\tid: \"hono\",\n\t\ttitle: \"Hono API (Drizzle, Neon Postgres) on Neon Functions\",\n\t\tdescription:\n\t\t\t\"A Hono API using Drizzle ORM and Neon Postgres, ready to deploy as a Neon Function.\",\n\t\trequires: [\"database\", \"functions\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-hono\",\n\t\t},\n\t},\n\t{\n\t\tid: \"ai-sdk\",\n\t\ttitle: \"AI SDK agent (AI Gateway, object storage, Drizzle) on Neon Functions\",\n\t\tdescription:\n\t\t\t\"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.\",\n\t\trequires: [\"database\", \"functions\", \"object-storage\", \"ai-gateway\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-ai-sdk\",\n\t\t},\n\t},\n\t{\n\t\tid: \"mastra\",\n\t\ttitle: \"Mastra personal agent (AI Gateway, Mastra Memory) on Neon Functions\",\n\t\tdescription:\n\t\t\t\"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.\",\n\t\trequires: [\"database\", \"functions\", \"ai-gateway\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-mastra\",\n\t\t},\n\t},\n];\n\n// Primary manifest host is neon.com (CDN-backed, no GitHub rate limiting), with\n// the raw GitHub copy as a fallback and the hardcoded list as the last resort.\n// A single env override (used by tests) short-circuits the chain.\nconst NEON_MANIFEST_URL = \"https://neon.com/bootstrap/templates.yaml\";\nconst GITHUB_RAW_MANIFEST_URL =\n\t\"https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml\";\n\nfunction manifestUrls(): string[] {\n\tconst override = process.env.NEON_BOOTSTRAP_MANIFEST_URL;\n\tif (override) return [override];\n\treturn [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n\ttypeof value === \"object\" && value !== null;\n\nexport function parseManifest(text: string): BootstrapTemplate[] {\n\tconst data: unknown = YAML.parse(text);\n\tif (!isRecord(data) || !Array.isArray(data.templates)) {\n\t\tthrow new Error(\n\t\t\t'Invalid bootstrap manifest: missing \"templates\" array.',\n\t\t);\n\t}\n\tconst templates: BootstrapTemplate[] = [];\n\tfor (const item of data.templates) {\n\t\tif (\n\t\t\t!isRecord(item) ||\n\t\t\ttypeof item.id !== \"string\" ||\n\t\t\ttypeof item.title !== \"string\" ||\n\t\t\ttypeof item.description !== \"string\" ||\n\t\t\t!isRecord(item.source) ||\n\t\t\ttypeof item.source.owner !== \"string\" ||\n\t\t\ttypeof item.source.repo !== \"string\" ||\n\t\t\ttypeof item.source.ref !== \"string\" ||\n\t\t\ttypeof item.source.subdir !== \"string\"\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\t\t// Parse requires — accept string array, default to [\"database\"]\n\t\tconst requires: NeonFeature[] =\n\t\t\tArray.isArray(item.requires) &&\n\t\t\titem.requires.every((r: unknown) => typeof r === \"string\")\n\t\t\t\t? (item.requires as NeonFeature[])\n\t\t\t\t: DEFAULT_REQUIRES;\n\n\t\ttemplates.push({\n\t\t\tid: item.id,\n\t\t\ttitle: item.title,\n\t\t\tdescription: item.description,\n\t\t\trequires,\n\t\t\tsource: {\n\t\t\t\towner: item.source.owner,\n\t\t\t\trepo: item.source.repo,\n\t\t\t\tref: item.source.ref,\n\t\t\t\tsubdir: item.source.subdir,\n\t\t\t},\n\t\t});\n\t}\n\treturn templates;\n}\n\n/**\n * Fetch the template manifest, trying each source in {@link manifestUrls} in\n * order and returning the first that yields a non-empty template list. Falls\n * back to the hardcoded list when every source is unreachable or empty, so the\n * picker never fails just because a host is down.\n */\nexport async function fetchTemplates(): Promise<BootstrapTemplate[]> {\n\tfor (const url of manifestUrls()) {\n\t\ttry {\n\t\t\tconst res = await fetch(url, {\n\t\t\t\tsignal: AbortSignal.timeout(10_000),\n\t\t\t});\n\t\t\tif (!res.ok) throw new Error(`HTTP ${res.status}`);\n\t\t\tconst templates = parseManifest(await res.text());\n\t\t\tif (templates.length > 0) return templates;\n\t\t} catch {\n\t\t\t// Try the next source.\n\t\t}\n\t}\n\treturn FALLBACK_TEMPLATES;\n}\n"],"mappings":";;;AAcA,MAAM,mBAAkC,CAAC,UAAU;;;;;;;AAsBnD,MAAa,qBAA0C;CACtD;EACC,IAAI;EACJ,OAAO;EACP,aACC;EACD,UAAU,CAAC,YAAY,WAAW;EAClC,QAAQ;GACP,OAAO;GACP,MAAM;GACN,KAAK;GACL,QAAQ;EACT;CACD;CACA;EACC,IAAI;EACJ,OAAO;EACP,aACC;EACD,UAAU;GAAC;GAAY;GAAa;GAAkB;EAAY;EAClE,QAAQ;GACP,OAAO;GACP,MAAM;GACN,KAAK;GACL,QAAQ;EACT;CACD;CACA;EACC,IAAI;EACJ,OAAO;EACP,aACC;EACD,UAAU;GAAC;GAAY;GAAa;EAAY;EAChD,QAAQ;GACP,OAAO;GACP,MAAM;GACN,KAAK;GACL,QAAQ;EACT;CACD;AACD;AAKA,MAAM,oBAAoB;AAC1B,MAAM,0BACL;AAED,SAAS,eAAyB;CACjC,MAAM,WAAW,QAAQ,IAAI;CAC7B,IAAI,UAAU,OAAO,CAAC,QAAQ;CAC9B,OAAO,CAAC,mBAAmB,uBAAuB;AACnD;AAEA,MAAM,YAAY,UACjB,OAAO,UAAU,YAAY,UAAU;AAExC,SAAgB,cAAc,MAAmC;CAChE,MAAM,OAAgB,KAAK,MAAM,IAAI;CACrC,IAAI,CAAC,SAAS,IAAI,KAAK,CAAC,MAAM,QAAQ,KAAK,SAAS,GACnD,MAAM,IAAI,MACT,0DACD;CAED,MAAM,YAAiC,CAAC;CACxC,KAAK,MAAM,QAAQ,KAAK,WAAW;EAClC,IACC,CAAC,SAAS,IAAI,KACd,OAAO,KAAK,OAAO,YACnB,OAAO,KAAK,UAAU,YACtB,OAAO,KAAK,gBAAgB,YAC5B,CAAC,SAAS,KAAK,MAAM,KACrB,OAAO,KAAK,OAAO,UAAU,YAC7B,OAAO,KAAK,OAAO,SAAS,YAC5B,OAAO,KAAK,OAAO,QAAQ,YAC3B,OAAO,KAAK,OAAO,WAAW,UAE9B;EAGD,MAAM,WACL,MAAM,QAAQ,KAAK,QAAQ,KAC3B,KAAK,SAAS,OAAO,MAAe,OAAO,MAAM,QAAQ,IACrD,KAAK,WACN;EAEJ,UAAU,KAAK;GACd,IAAI,KAAK;GACT,OAAO,KAAK;GACZ,aAAa,KAAK;GAClB;GACA,QAAQ;IACP,OAAO,KAAK,OAAO;IACnB,MAAM,KAAK,OAAO;IAClB,KAAK,KAAK,OAAO;IACjB,QAAQ,KAAK,OAAO;GACrB;EACD,CAAC;CACF;CACA,OAAO;AACR;;;;;;;AAQA,eAAsB,iBAA+C;CACpE,KAAK,MAAM,OAAO,aAAa,GAC9B,IAAI;EACH,MAAM,MAAM,MAAM,MAAM,KAAK,EAC5B,QAAQ,YAAY,QAAQ,GAAM,EACnC,CAAC;EACD,IAAI,CAAC,IAAI,IAAI,MAAM,IAAI,MAAM,QAAQ,IAAI,QAAQ;EACjD,MAAM,YAAY,cAAc,MAAM,IAAI,KAAK,CAAC;EAChD,IAAI,UAAU,SAAS,GAAG,OAAO;CAClC,QAAQ,CAER;CAED,OAAO;AACR"}
1
+ {"version":3,"file":"bootstrap.js","names":[],"sources":["../../src/lib/bootstrap.ts"],"sourcesContent":["import {\n\tchmodSync,\n\texistsSync,\n\tlstatSync,\n\tmkdirSync,\n\treaddirSync,\n\trmSync,\n\tstatSync,\n\tsymlinkSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { gunzipSync } from \"fflate\";\nimport YAML from \"yaml\";\n\n/**\n * A scaffold template that lives in a subdirectory of a public GitHub repo we\n * control. Bootstrapping copies that subdirectory into a target folder —\n * conceptually the same as `degit user/repo/subdir`, but implemented in-house.\n *\n * The whole template is pulled in a single request: we download the repo's\n * gzipped tarball from `codeload.github.com` and extract only the subdir we\n * want. That endpoint is unauthenticated and is NOT subject to the 60-requests\n * per-hour limit of the REST API (`api.github.com`), so bootstrapping works out\n * of the box on shared/corporate networks without a GITHUB_TOKEN. We lean on\n * `fflate` for gunzip and parse the tar in-house, so we never pull in a heavy\n * dependency tree just to copy a few files.\n */\n\n/**\n * Neon features that a template or project may require.\n * Each feature maps to a setup phase that the orchestrator can run.\n */\nexport type NeonFeature =\n\t| \"database\"\n\t| \"auth\"\n\t| \"functions\"\n\t| \"ai-gateway\"\n\t| \"object-storage\";\n\n/** Default features when a template doesn't specify `requires`. */\nconst DEFAULT_REQUIRES: NeonFeature[] = [\"database\"];\n\nexport interface BootstrapTemplate {\n\t/** Stable id used by `--template` and analytics. */\n\tid: string;\n\t/** Human label shown in the interactive selector. */\n\ttitle: string;\n\t/** One-line description shown under the title in the selector. */\n\tdescription: string;\n\t/**\n\t * Libraries/frameworks that shape the project (e.g. \"Hono\", \"Drizzle\").\n\t * Rendered next to the title in the picker so the row reads\n\t * \"Title (tools)\". Optional — older manifests omit it.\n\t */\n\ttools?: string[];\n\t/**\n\t * Neon services the template uses (e.g. \"Postgres\", \"Functions\"). Surfaced\n\t * alongside the description in the focused row's hint. Optional — older\n\t * manifests omit it.\n\t */\n\tservices?: string[];\n\t/** Neon features this template needs (defaults to [\"database\"]). */\n\trequires: NeonFeature[];\n\tsource: {\n\t\towner: string;\n\t\trepo: string;\n\t\t/** Branch (or tag) the template is pulled from. */\n\t\tref: string;\n\t\t/** Subdirectory within the repo to copy (no leading/trailing slash). */\n\t\tsubdir: string;\n\t};\n}\n\n/**\n * Hardcoded fallback used when every remote manifest source is unreachable.\n * Kept in sync with `neondatabase/examples/bootstrap.yaml` (the source of\n * truth) so that, even fully offline from the manifest, the picker still offers\n * the full set of starters rather than a single template.\n */\nexport const FALLBACK_TEMPLATES: BootstrapTemplate[] = [\n\t{\n\t\tid: \"hono\",\n\t\ttitle: \"REST API\",\n\t\tdescription:\n\t\t\t\"A Hono REST API on Neon Functions, backed by Neon Postgres via Drizzle.\",\n\t\ttools: [\"Hono\", \"Drizzle\"],\n\t\tservices: [\"Postgres\", \"Functions\"],\n\t\trequires: [\"database\", \"functions\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-hono\",\n\t\t},\n\t},\n\t{\n\t\tid: \"ai-sdk\",\n\t\ttitle: \"Image-generation agent\",\n\t\tdescription:\n\t\t\t\"A Vercel AI SDK agent that streams chat through the Neon AI Gateway and stores generated images in Neon object storage, indexed in Postgres via Drizzle.\",\n\t\ttools: [\"AI SDK\", \"Drizzle\"],\n\t\tservices: [\"Postgres\", \"Functions\", \"Object Storage\", \"AI Gateway\"],\n\t\trequires: [\"database\", \"functions\", \"object-storage\", \"ai-gateway\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-ai-sdk\",\n\t\t},\n\t},\n\t{\n\t\tid: \"mastra\",\n\t\ttitle: \"Personal-assistant agent\",\n\t\tdescription:\n\t\t\t\"A Mastra agent that streams chat through the Neon AI Gateway and uses Mastra Memory on Neon Postgres to remember you across threads.\",\n\t\ttools: [\"Mastra\", \"Mastra Memory\"],\n\t\tservices: [\"Postgres\", \"Functions\", \"AI Gateway\"],\n\t\trequires: [\"database\", \"functions\", \"ai-gateway\"],\n\t\tsource: {\n\t\t\towner: \"neondatabase\",\n\t\t\trepo: \"examples\",\n\t\t\tref: \"main\",\n\t\t\tsubdir: \"with-mastra\",\n\t\t},\n\t},\n];\n\nexport const templateIds = (templates: BootstrapTemplate[]): string =>\n\ttemplates.map((t) => t.id).join(\", \");\n\nexport const findTemplate = (\n\ttemplates: BootstrapTemplate[],\n\tid: string,\n): BootstrapTemplate | undefined => templates.find((t) => t.id === id);\n\n/** A single file or symlink to materialize, already resolved with its bytes. */\nexport type TemplateFile =\n\t| {\n\t\t\tkind: \"file\";\n\t\t\t/** Path relative to the target directory (subdir prefix stripped). */\n\t\t\tpath: string;\n\t\t\tbytes: Buffer;\n\t\t\texecutable: boolean;\n\t }\n\t| {\n\t\t\tkind: \"symlink\";\n\t\t\tpath: string;\n\t\t\t/** The (relative) link target. */\n\t\t\ttarget: string;\n\t };\n\nconst githubToken = (): string =>\n\tprocess.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? \"\";\n\n// A token is never required for public templates, but we forward it when\n// present so the same code path works behind proxies that authenticate, and\n// (in future) for private template repos.\nconst downloadHeaders = (): Record<string, string> => ({\n\t\"User-Agent\": \"neon-init\",\n\t...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),\n});\n\n// The codeload host is overridable so the e2e tests can point the downloader at\n// a local server (the same trick `--api-host` uses to redirect the Neon API).\nconst codeloadBase = (): string =>\n\tprocess.env.NEON_BOOTSTRAP_GITHUB_CODELOAD ?? \"https://codeload.github.com\";\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n\ttypeof value === \"object\" && value !== null;\n\n/**\n * Normalize a manifest entry's string list (`tools` or `services`) into a clean\n * array. Tolerant by design: a missing or non-array value yields `undefined`,\n * and non-string/blank items are dropped, so a malformed list never sinks an\n * otherwise-valid template (it just renders without that detail).\n */\nconst parseStringList = (value: unknown): string[] | undefined => {\n\tif (!Array.isArray(value)) return undefined;\n\tconst items = value.filter(\n\t\t(item): item is string =>\n\t\t\ttypeof item === \"string\" && item.trim() !== \"\",\n\t);\n\treturn items.length > 0 ? items : undefined;\n};\n\n// ---------------------------------------------------------------------------\n// Remote template manifest\n// ---------------------------------------------------------------------------\n\n// Primary manifest host is neon.com (CDN-backed, no GitHub rate limiting), with\n// the raw GitHub copy as a fallback and the hardcoded list as the last resort.\n// A single env override (used by tests) short-circuits the chain.\nconst NEON_MANIFEST_URL = \"https://neon.com/bootstrap/templates.yaml\";\nconst GITHUB_RAW_MANIFEST_URL =\n\t\"https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml\";\n\nfunction manifestUrls(): string[] {\n\tconst override = process.env.NEON_BOOTSTRAP_MANIFEST_URL;\n\tif (override) return [override];\n\treturn [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];\n}\n\nexport function parseManifest(text: string): BootstrapTemplate[] {\n\tconst data: unknown = YAML.parse(text);\n\tif (!isRecord(data) || !Array.isArray(data.templates)) {\n\t\tthrow new Error(\n\t\t\t'Invalid bootstrap manifest: missing \"templates\" array.',\n\t\t);\n\t}\n\tconst templates: BootstrapTemplate[] = [];\n\tfor (const item of data.templates) {\n\t\tif (\n\t\t\t!isRecord(item) ||\n\t\t\ttypeof item.id !== \"string\" ||\n\t\t\ttypeof item.title !== \"string\" ||\n\t\t\ttypeof item.description !== \"string\" ||\n\t\t\t!isRecord(item.source) ||\n\t\t\ttypeof item.source.owner !== \"string\" ||\n\t\t\ttypeof item.source.repo !== \"string\" ||\n\t\t\ttypeof item.source.ref !== \"string\" ||\n\t\t\ttypeof item.source.subdir !== \"string\"\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\t\t// Parse requires — accept a string array, default to [\"database\"].\n\t\tconst requires: NeonFeature[] =\n\t\t\tArray.isArray(item.requires) &&\n\t\t\titem.requires.every((r: unknown) => typeof r === \"string\")\n\t\t\t\t? (item.requires as NeonFeature[])\n\t\t\t\t: DEFAULT_REQUIRES;\n\t\tconst tools = parseStringList(item.tools);\n\t\tconst services = parseStringList(item.services);\n\t\ttemplates.push({\n\t\t\tid: item.id,\n\t\t\ttitle: item.title,\n\t\t\tdescription: item.description,\n\t\t\t...(tools ? { tools } : {}),\n\t\t\t...(services ? { services } : {}),\n\t\t\trequires,\n\t\t\tsource: {\n\t\t\t\towner: item.source.owner,\n\t\t\t\trepo: item.source.repo,\n\t\t\t\tref: item.source.ref,\n\t\t\t\tsubdir: item.source.subdir,\n\t\t\t},\n\t\t});\n\t}\n\treturn templates;\n}\n\n/**\n * Fetch the template manifest, trying each source in {@link manifestUrls} in\n * order and returning the first that yields a non-empty template list. Falls\n * back to the hardcoded list when every source is unreachable or empty, so the\n * picker never fails just because a host is down.\n */\nexport async function fetchTemplates(): Promise<BootstrapTemplate[]> {\n\tfor (const url of manifestUrls()) {\n\t\ttry {\n\t\t\tconst res = await fetch(url, {\n\t\t\t\theaders: downloadHeaders(),\n\t\t\t\tsignal: AbortSignal.timeout(10_000),\n\t\t\t});\n\t\t\tif (!res.ok) throw new Error(`HTTP ${res.status}`);\n\t\t\tconst templates = parseManifest(await res.text());\n\t\t\tif (templates.length > 0) return templates;\n\t\t} catch {\n\t\t\t// Try the next source.\n\t\t}\n\t}\n\treturn FALLBACK_TEMPLATES;\n}\n\n// ---------------------------------------------------------------------------\n// Tar parsing\n// ---------------------------------------------------------------------------\n\n/** A raw entry decoded from a tar stream, before subdir filtering. */\ntype TarEntry = {\n\t/** Full path as stored in the archive (includes the top-level dir). */\n\tname: string;\n\t/** POSIX type flag: '0' file, '5' directory, '2' symlink, etc. */\n\ttype: string;\n\t/** File permission bits. */\n\tmode: number;\n\t/** Symlink target (for type '2'). */\n\tlinkname: string;\n\t/** File contents (for type '0'). */\n\tdata: Buffer;\n};\n\nconst TAR_BLOCK = 512;\n\nconst readTarString = (buf: Buffer, offset: number, length: number): string => {\n\tlet end = offset;\n\tconst max = offset + length;\n\twhile (end < max && buf[end] !== 0) end++;\n\treturn buf.toString(\"utf8\", offset, end);\n};\n\nconst readTarOctal = (buf: Buffer, offset: number, length: number): number => {\n\tconst text = readTarString(buf, offset, length).trim();\n\tif (text === \"\") return 0;\n\tconst value = parseInt(text, 8);\n\treturn Number.isNaN(value) ? 0 : value;\n};\n\nconst isZeroBlock = (buf: Buffer, offset: number): boolean => {\n\tfor (let i = offset; i < offset + TAR_BLOCK; i++) {\n\t\tif (buf[i] !== 0) return false;\n\t}\n\treturn true;\n};\n\n/**\n * Parse pax extended-header records (\"<len> <key>=<value>\\n\"). GitHub uses\n * these for the global header and for any path that doesn't fit the legacy\n * 100-byte name field, so we must honor at least `path` and `linkpath`.\n */\nconst parsePaxRecords = (data: Buffer): Record<string, string> => {\n\tconst records: Record<string, string> = {};\n\tlet pos = 0;\n\tconst text = data.toString(\"utf8\");\n\twhile (pos < text.length) {\n\t\tconst space = text.indexOf(\" \", pos);\n\t\tif (space === -1) break;\n\t\tconst len = parseInt(text.slice(pos, space), 10);\n\t\tif (Number.isNaN(len) || len <= 0) break;\n\t\tconst record = text.slice(space + 1, pos + len - 1); // drop trailing \"\\n\"\n\t\tconst eq = record.indexOf(\"=\");\n\t\tif (eq !== -1) records[record.slice(0, eq)] = record.slice(eq + 1);\n\t\tpos += len;\n\t}\n\treturn records;\n};\n\n/**\n * Decode a (decompressed) tar archive into its file/symlink entries. Pure and\n * dependency-free so it can be unit tested without touching the network.\n * Handles the ustar `prefix` field, pax extended headers (type 'x'/'g'), and\n * GNU long-name/long-link headers (type 'L'/'K') so deep template paths and\n * long symlink targets round-trip correctly.\n */\nexport const parseTar = (buf: Buffer): TarEntry[] => {\n\tconst entries: TarEntry[] = [];\n\t// Overrides carried from a preceding pax/GNU header to the next real entry.\n\tlet overridePath: string | undefined;\n\tlet overrideLink: string | undefined;\n\tlet offset = 0;\n\n\twhile (offset + TAR_BLOCK <= buf.length) {\n\t\tif (isZeroBlock(buf, offset)) break;\n\n\t\tlet name = readTarString(buf, offset, 100);\n\t\tconst mode = readTarOctal(buf, offset + 100, 8);\n\t\tconst size = readTarOctal(buf, offset + 124, 12);\n\t\tconst typeByte = buf[offset + 156];\n\t\tconst type = typeByte === 0 ? \"0\" : String.fromCharCode(typeByte);\n\t\tlet linkname = readTarString(buf, offset + 157, 100);\n\t\tconst magic = readTarString(buf, offset + 257, 6);\n\t\tif (magic.startsWith(\"ustar\")) {\n\t\t\tconst prefix = readTarString(buf, offset + 345, 155);\n\t\t\tif (prefix !== \"\") name = `${prefix}/${name}`;\n\t\t}\n\n\t\toffset += TAR_BLOCK;\n\t\tconst data = buf.subarray(offset, offset + size);\n\t\toffset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;\n\n\t\tif (type === \"x\") {\n\t\t\tconst records = parsePaxRecords(data);\n\t\t\tif (records.path !== undefined) overridePath = records.path;\n\t\t\tif (records.linkpath !== undefined) overrideLink = records.linkpath;\n\t\t\tcontinue;\n\t\t}\n\t\tif (type === \"g\") {\n\t\t\t// Global pax header (e.g. GitHub's comment block): not per-entry state.\n\t\t\tcontinue;\n\t\t}\n\t\tif (type === \"L\" || type === \"K\") {\n\t\t\tconst longValue = data.toString(\"utf8\").replace(/\\0+$/, \"\");\n\t\t\tif (type === \"L\") overridePath = longValue;\n\t\t\telse overrideLink = longValue;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (overridePath !== undefined) name = overridePath;\n\t\tif (overrideLink !== undefined) linkname = overrideLink;\n\t\toverridePath = undefined;\n\t\toverrideLink = undefined;\n\n\t\tentries.push({ name, type, mode, linkname, data: Buffer.from(data) });\n\t}\n\n\treturn entries;\n};\n\n/**\n * Map decoded tar entries to the files under `subdir`, with the top-level\n * archive directory and the `subdir/` prefix stripped from each path. Pure so\n * it can be unit tested. Directory and other non-regular entries are dropped —\n * writing files re-creates their parent directories.\n */\nexport const selectTemplateFiles = (\n\tentries: TarEntry[],\n\tsubdir: string,\n): TemplateFile[] => {\n\tconst prefix = `${subdir.replace(/^\\/+|\\/+$/g, \"\")}/`;\n\tconst files: TemplateFile[] = [];\n\tfor (const entry of entries) {\n\t\t// codeload wraps everything in a single top-level dir (\"<repo>-<ref>/\");\n\t\t// strip that first segment to get the repo-relative path.\n\t\tconst slash = entry.name.indexOf(\"/\");\n\t\tif (slash === -1) continue;\n\t\tconst repoPath = entry.name.slice(slash + 1);\n\t\tif (!repoPath.startsWith(prefix)) continue;\n\t\tconst path = repoPath.slice(prefix.length);\n\t\tif (path === \"\") continue;\n\t\tif (entry.type === \"2\") {\n\t\t\tfiles.push({ kind: \"symlink\", path, target: entry.linkname });\n\t\t} else if (entry.type === \"0\" || entry.type === \"7\") {\n\t\t\tfiles.push({\n\t\t\t\tkind: \"file\",\n\t\t\t\tpath,\n\t\t\t\tbytes: entry.data,\n\t\t\t\texecutable: (entry.mode & 0o111) !== 0,\n\t\t\t});\n\t\t}\n\t\t// Directories ('5') and any other node types are intentionally skipped.\n\t}\n\treturn files;\n};\n\nconst tarballUrl = (template: BootstrapTemplate): string => {\n\tconst { owner, repo, ref } = template.source;\n\treturn `${codeloadBase()}/${owner}/${repo}/tar.gz/${ref}`;\n};\n\nconst friendlyGithubError = (status: number, url: string): Error => {\n\tif (status === 404) {\n\t\treturn new Error(\n\t\t\t`GitHub returned 404 for ${url}. The template repo or ref may have moved.`,\n\t\t);\n\t}\n\tif (status === 403 || status === 429) {\n\t\treturn new Error(\n\t\t\t`GitHub rate limited the template download (${url}). Set a GITHUB_TOKEN environment variable to raise the limit, then retry.`,\n\t\t);\n\t}\n\treturn new Error(`GitHub returned HTTP ${status} for ${url}.`);\n};\n\n/**\n * Download a template and resolve it to the exact set of files to write. The\n * entire subtree is captured in one tarball request, so the copy is atomically\n * consistent: a push to the template repo mid-download cannot produce a\n * mismatched checkout (unlike fetching a file list and then each blob).\n */\nexport const downloadTemplate = async (\n\ttemplate: BootstrapTemplate,\n): Promise<TemplateFile[]> => {\n\tconst url = tarballUrl(template);\n\n\tlet gzipped: Buffer;\n\ttry {\n\t\tconst res = await fetch(url, {\n\t\t\theaders: downloadHeaders(),\n\t\t\tsignal: AbortSignal.timeout(30_000),\n\t\t});\n\t\tif (!res.ok) throw friendlyGithubError(res.status, url);\n\t\tgzipped = Buffer.from(await res.arrayBuffer());\n\t} catch (err) {\n\t\tthrow err instanceof Error ? err : new Error(String(err));\n\t}\n\n\tlet tar: Buffer;\n\ttry {\n\t\ttar = Buffer.from(gunzipSync(new Uint8Array(gzipped)));\n\t} catch (err) {\n\t\tthrow new Error(\n\t\t\t`Failed to decompress the template archive from ${url}: ${\n\t\t\t\terr instanceof Error ? err.message : String(err)\n\t\t\t}`,\n\t\t);\n\t}\n\n\tconst { owner, repo, ref, subdir } = template.source;\n\tconst files = selectTemplateFiles(parseTar(tar), subdir);\n\tif (files.length === 0) {\n\t\tthrow new Error(\n\t\t\t`Template subdirectory \"${subdir}\" was not found in ${owner}/${repo}@${ref}.`,\n\t\t);\n\t}\n\treturn files;\n};\n\n// ---------------------------------------------------------------------------\n// Target validation + scaffolding to disk\n// ---------------------------------------------------------------------------\n\n/**\n * A bad caller-supplied input that an agent (or human) can correct: an unknown\n * template id or a non-empty target directory. Carries an `agentCode` so an\n * agent surface can report a precise error code instead of a generic\n * INTERNAL_ERROR, while a human path just surfaces the clear `message`.\n */\nexport class BootstrapInputError extends Error {\n\treadonly agentCode: string;\n\tconstructor(message: string, agentCode: string) {\n\t\tsuper(message);\n\t\tthis.name = \"BootstrapInputError\";\n\t\tthis.agentCode = agentCode;\n\t}\n}\n\n/**\n * Ensure `dir` is safe to scaffold into: it must be missing, or an empty\n * directory (a lone `.git` is ignored so you can scaffold into a freshly\n * `git init`ed folder). `force` allows scaffolding into a non-empty directory,\n * overwriting colliding files. Throws a {@link BootstrapInputError} otherwise.\n */\nexport const ensureTargetUsable = (dir: string, force: boolean): void => {\n\tif (!existsSync(dir)) return;\n\tif (!statSync(dir).isDirectory()) {\n\t\tthrow new BootstrapInputError(\n\t\t\t`Target ${dir} already exists and is not a directory.`,\n\t\t\t\"TARGET_NOT_DIRECTORY\",\n\t\t);\n\t}\n\tconst contents = readdirSync(dir).filter((name) => name !== \".git\");\n\tif (contents.length > 0 && !force) {\n\t\tthrow new BootstrapInputError(\n\t\t\t`Target directory ${dir} is not empty. Use --force to scaffold into it anyway (colliding files will be overwritten), or choose an empty directory.`,\n\t\t\t\"TARGET_NOT_EMPTY\",\n\t\t);\n\t}\n};\n\nconst isSymlink = (path: string): boolean => {\n\ttry {\n\t\treturn lstatSync(path).isSymbolicLink();\n\t} catch {\n\t\treturn false;\n\t}\n};\n\nconst errnoCode = (err: unknown): string | undefined => {\n\tif (\n\t\ttypeof err === \"object\" &&\n\t\terr !== null &&\n\t\t\"code\" in err &&\n\t\ttypeof err.code === \"string\"\n\t) {\n\t\treturn err.code;\n\t}\n\treturn undefined;\n};\n\nconst writeSymlink = (\n\tdest: string,\n\ttarget: string,\n\tonWarn?: (message: string) => void,\n): void => {\n\tif (isSymlink(dest)) rmSync(dest, { force: true });\n\ttry {\n\t\tsymlinkSync(target, dest);\n\t} catch (err) {\n\t\t// Windows refuses symlinks without elevated rights / developer mode. The\n\t\t// template still works for most tooling if we drop a regular file holding\n\t\t// the link target, so we degrade gracefully instead of failing the copy.\n\t\tif (errnoCode(err) === \"EPERM\" || process.platform === \"win32\") {\n\t\t\tonWarn?.(\n\t\t\t\t`Could not create symlink ${dest} -> ${target}; wrote it as a regular file instead.`,\n\t\t\t);\n\t\t\twriteFileSync(dest, target);\n\t\t\treturn;\n\t\t}\n\t\tthrow err;\n\t}\n};\n\nexport interface ScaffoldOptions {\n\t/** Called for non-fatal warnings (e.g. a symlink that fell back to a file). */\n\tonWarn?: (message: string) => void;\n}\n\n/**\n * Download `template` and materialize its files into `targetDir`, creating\n * parent directories, preserving executable bits, and recreating symlinks\n * (with a graceful regular-file fallback on platforms that disallow them).\n * Returns the number of files written. The caller is responsible for any\n * target validation ({@link ensureTargetUsable}) and user-facing progress.\n */\nexport const scaffoldTemplate = async (\n\ttemplate: BootstrapTemplate,\n\ttargetDir: string,\n\toptions: ScaffoldOptions = {},\n): Promise<number> => {\n\tconst files = await downloadTemplate(template);\n\n\tmkdirSync(targetDir, { recursive: true });\n\tfor (const file of files) {\n\t\tconst dest = join(targetDir, file.path);\n\t\tmkdirSync(dirname(dest), { recursive: true });\n\t\tif (file.kind === \"symlink\") {\n\t\t\twriteSymlink(dest, file.target, options.onWarn);\n\t\t} else {\n\t\t\twriteFileSync(dest, file.bytes);\n\t\t\tif (file.executable) chmodSync(dest, 0o755);\n\t\t}\n\t}\n\treturn files.length;\n};\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"setup.d.ts","names":[],"sources":["../../../src/lib/phases/setup.ts"],"mappings":";;;;UAsBiB,iBAAA;;EAAA;EAAiB,GAAA,CAAA,EAAA,MAAA;;SAatB,CAAA,EAAA,OAAA;EAAW;EA2BD,MAAA,CAAA,EAAA,OAAA;EAAgB;UAC5B,CAAA,EAAA,MAAA;;kBACP,CAAA,EA/BiB,WA+BjB,EAAA;EAAO;aA7BE;;;;;;;;;;;;;;;;;;;;;;;iBA2BU,gBAAA,UACZ,oBACP,QAAQ"}
1
+ {"version":3,"file":"setup.d.ts","names":[],"sources":["../../../src/lib/phases/setup.ts"],"mappings":";;;;UAwBiB,iBAAA;;EAAA;EAAiB,GAAA,CAAA,EAAA,MAAA;;SAatB,CAAA,EAAA,OAAA;EAAW;EA2BD,MAAA,CAAA,EAAA,OAAA;EAAgB;UAC5B,CAAA,EAAA,MAAA;;kBACP,CAAA,EA/BiB,WA+BjB,EAAA;EAAO;aA7BE;;;;;;;;;;;;;;;;;;;;;;;iBA2BU,gBAAA,UACZ,oBACP,QAAQ"}
@@ -1,5 +1,5 @@
1
1
  import { resolveAddMcpAgentId } from "../agents.js";
2
- import { FALLBACK_TEMPLATES, fetchTemplates } from "../bootstrap.js";
2
+ import { FALLBACK_TEMPLATES, fetchTemplates, findTemplate, scaffoldTemplate } from "../bootstrap.js";
3
3
  import { detectIde, isCursorInstalled, isVSCodeInstalled } from "../detect-agent.js";
4
4
  import { NEON_EXTENSION_ID, downloadVsix } from "../vsix.js";
5
5
  import { findEditorCommand } from "../extension.js";
@@ -54,10 +54,13 @@ function buildTemplatePreference(templates) {
54
54
  id: "template",
55
55
  question: "No application was detected in this directory. Would you like to scaffold a new project from a template?",
56
56
  phase: "before_checks",
57
- options: [...templates.map((t) => ({
58
- value: t.id,
59
- label: `${t.title} — ${t.description}`
60
- })), {
57
+ options: [...templates.map((t) => {
58
+ const tools = t.tools && t.tools.length > 0 ? ` (${t.tools.join(", ")})` : "";
59
+ return {
60
+ value: t.id,
61
+ label: `${t.title}${tools} — ${t.description}`
62
+ };
63
+ }), {
61
64
  value: "none",
62
65
  label: "No thanks — continue without scaffolding"
63
66
  }],
@@ -266,18 +269,9 @@ async function executeBatchedInstallation(options) {
266
269
  const results = [];
267
270
  const isBootstrap = !!options.template;
268
271
  if (isBootstrap && options.template) try {
269
- await execa("npx", [
270
- "-y",
271
- "neonctl@latest",
272
- "bootstrap",
273
- ".",
274
- "--template",
275
- options.template,
276
- "--force"
277
- ], {
278
- stdio: "pipe",
279
- timeout: 12e4
280
- });
272
+ const template = findTemplate(await fetchTemplates(), options.template) ?? findTemplate(FALLBACK_TEMPLATES, options.template);
273
+ if (!template) throw new Error(`Unknown template "${options.template}".`);
274
+ await scaffoldTemplate(template, ".");
281
275
  results.push({
282
276
  id: "bootstrap",
283
277
  description: `Scaffolded project from template "${options.template}"`,