neon 2.44.0 → 2.46.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.
Files changed (54) hide show
  1. package/README.md +62 -1
  2. package/dist/_shared/paths.js +3 -4
  3. package/dist/commands/bootstrap.js +12 -11
  4. package/dist/commands/checkout.js +7 -6
  5. package/dist/commands/config.js +2 -1
  6. package/dist/commands/data_api.js +4 -3
  7. package/dist/commands/env.js +5 -4
  8. package/dist/commands/functions.js +3 -2
  9. package/dist/commands/init.js +82 -37
  10. package/dist/commands/ip_allow.js +3 -2
  11. package/dist/commands/link.js +17 -16
  12. package/dist/commands/projects.js +3 -2
  13. package/dist/commands/set_context.js +5 -4
  14. package/dist/current_branch_fast_path.js +2 -1
  15. package/dist/dev/env.js +8 -7
  16. package/dist/dev/runtime.js +45 -1
  17. package/dist/dev/websocket.js +1031 -0
  18. package/dist/index.js +2 -2
  19. package/dist/init/agents.js +127 -0
  20. package/dist/init/auth.js +77 -0
  21. package/dist/init/bootstrap.js +448 -0
  22. package/dist/init/build_config.js +2 -0
  23. package/dist/init/detect_agent.js +108 -0
  24. package/dist/init/editors.js +62 -0
  25. package/dist/init/enrich_output.js +71 -0
  26. package/dist/init/extension.js +191 -0
  27. package/dist/init/inspect.js +287 -0
  28. package/dist/init/interactive.js +651 -0
  29. package/dist/init/neonctl.js +184 -0
  30. package/dist/init/orchestrate.js +190 -0
  31. package/dist/init/phases/auth.js +209 -0
  32. package/dist/init/phases/cleanup.js +27 -0
  33. package/dist/init/phases/db.js +283 -0
  34. package/dist/init/phases/getting_started.js +228 -0
  35. package/dist/init/phases/mcp.js +227 -0
  36. package/dist/init/phases/migrations.js +251 -0
  37. package/dist/init/phases/neon_auth.js +135 -0
  38. package/dist/init/phases/setup.js +729 -0
  39. package/dist/init/phases/skills.js +89 -0
  40. package/dist/init/phases/status.js +70 -0
  41. package/dist/init/resolve_context.js +107 -0
  42. package/dist/init/route_command.js +100 -0
  43. package/dist/init/skills.js +248 -0
  44. package/dist/init/types.js +1 -0
  45. package/dist/init/vsix.js +111 -0
  46. package/dist/psql/command/cmd_meta.js +2 -2
  47. package/dist/psql/core/mainloop.js +1 -1
  48. package/dist/psql/core/startup.js +1 -1
  49. package/dist/psql/core/syncVars.js +3 -3
  50. package/dist/psql/index.js +1 -1
  51. package/dist/utils/cli_name.js +14 -0
  52. package/dist/utils/esbuild.js +1 -1
  53. package/dist/utils/write_sync.js +39 -0
  54. package/package.json +18 -12
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { basename } from "node:path";
2
1
  import yargs from "yargs";
3
2
  import { hideBin } from "yargs/helpers";
4
3
  import { analyticsMiddleware, closeAnalytics, getAnalyticsEventProperties, initAnalyticsClientMiddleware, sendError, trackEvent, } from "./analytics.js";
@@ -13,6 +12,7 @@ import { isNetworkError, matchErrorCode, NETWORK_ERROR_MESSAGE, } from "./errors
13
12
  import { showHelp } from "./help.js";
14
13
  import { log } from "./log.js";
15
14
  import pkg from "./pkg.js";
15
+ import { getCliName } from "./utils/cli_name.js";
16
16
  import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
17
17
  const NO_SUBCOMMANDS_VERBS = [
18
18
  // `api <path>` has a handler but no subcommands (like `status`), so the
@@ -159,7 +159,7 @@ builder = builder
159
159
  .group("version", "Global options:")
160
160
  .alias("version", "v")
161
161
  .completion()
162
- .scriptName(basename(process.argv[1]) === "neon" ? "neon" : "neonctl")
162
+ .scriptName(getCliName())
163
163
  .epilog("For more information, visit https://neon.com/docs/reference/neon-cli")
164
164
  .wrap(null)
165
165
  .fail(false);
@@ -0,0 +1,127 @@
1
+ /**
2
+ * All agents that can be configured via neon-init.
3
+ * Aligns with add-mcp's supported agents table.
4
+ * https://github.com/neondatabase/add-mcp#supported-agents
5
+ */
6
+ export const ALL_CONFIGURABLE_AGENTS = [
7
+ {
8
+ editor: "Cursor",
9
+ addMcpId: "cursor",
10
+ hint: "Neon Local Connect extension",
11
+ },
12
+ {
13
+ editor: "VS Code",
14
+ addMcpId: "vscode",
15
+ hint: "Neon Local Connect extension",
16
+ },
17
+ { editor: "Claude CLI", addMcpId: "claude-code", hint: "MCP Server" },
18
+ {
19
+ editor: "Claude Desktop",
20
+ addMcpId: "claude-desktop",
21
+ hint: "MCP Server",
22
+ },
23
+ { editor: "Codex", addMcpId: "codex", hint: "MCP Server" },
24
+ { editor: "OpenCode", addMcpId: "opencode", hint: "MCP Server" },
25
+ { editor: "Antigravity", addMcpId: "antigravity", hint: "MCP Server" },
26
+ { editor: "Cline", addMcpId: "cline", hint: "MCP Server" },
27
+ { editor: "Cline CLI", addMcpId: "cline-cli", hint: "MCP Server" },
28
+ { editor: "Gemini CLI", addMcpId: "gemini-cli", hint: "MCP Server" },
29
+ {
30
+ editor: "GitHub Copilot CLI",
31
+ addMcpId: "github-copilot-cli",
32
+ hint: "MCP Server",
33
+ },
34
+ { editor: "Goose", addMcpId: "goose", hint: "MCP Server" },
35
+ { editor: "MCPorter", addMcpId: "mcporter", hint: "MCP Server" },
36
+ { editor: "Zed", addMcpId: "zed", hint: "MCP Server" },
37
+ ];
38
+ export function getAddMcpAgentId(editor) {
39
+ const agent = ALL_CONFIGURABLE_AGENTS.find((a) => a.editor === editor);
40
+ if (!agent) {
41
+ throw new Error(`No add-mcp agent ID found for editor: ${editor}`);
42
+ }
43
+ return agent.addMcpId;
44
+ }
45
+ /**
46
+ * Maps a raw agent identifier (as reported by agents or passed via --agent)
47
+ * to the add-mcp compatible agent ID.
48
+ *
49
+ * This handles aliases like "copilot" → "vscode", "claude" → "claude-code", etc.
50
+ */
51
+ const AGENT_ALIAS_TO_MCP_ID = {
52
+ cursor: "cursor",
53
+ copilot: "vscode",
54
+ "github-copilot": "vscode",
55
+ "vs-code": "vscode",
56
+ vscode: "vscode",
57
+ claude: "claude-code",
58
+ "claude-code": "claude-code",
59
+ "claude-desktop": "claude-desktop",
60
+ codex: "codex",
61
+ opencode: "opencode",
62
+ antigravity: "antigravity",
63
+ cline: "cline",
64
+ "cline-cli": "cline-cli",
65
+ "gemini-cli": "gemini-cli",
66
+ gemini: "gemini-cli",
67
+ goose: "goose",
68
+ windsurf: "windsurf",
69
+ "github-copilot-cli": "github-copilot-cli",
70
+ mcporter: "mcporter",
71
+ zed: "zed",
72
+ };
73
+ export function resolveAddMcpAgentId(rawAgent) {
74
+ const resolved = AGENT_ALIAS_TO_MCP_ID[rawAgent.toLowerCase()];
75
+ if (!resolved) {
76
+ throw new Error(`Unknown agent: "${rawAgent}". Supported agents: ${Object.keys(AGENT_ALIAS_TO_MCP_ID).join(", ")}`);
77
+ }
78
+ return resolved;
79
+ }
80
+ /**
81
+ * Maps a raw agent identifier to the skills CLI agent name.
82
+ */
83
+ export function getSkillsAgentName(agent) {
84
+ switch (agent.toLowerCase()) {
85
+ case "cursor":
86
+ return "cursor";
87
+ case "copilot":
88
+ case "vscode":
89
+ case "vs-code":
90
+ case "github-copilot":
91
+ return "github-copilot";
92
+ case "claude":
93
+ case "claude-code":
94
+ return "claude-code";
95
+ case "codex":
96
+ return "codex";
97
+ case "opencode":
98
+ return "opencode";
99
+ case "antigravity":
100
+ return "antigravity";
101
+ case "cline":
102
+ return "cline";
103
+ case "gemini-cli":
104
+ return "gemini-cli";
105
+ case "goose":
106
+ return "goose";
107
+ case "claude-desktop":
108
+ return "claude-code";
109
+ case "cline-cli":
110
+ return "cline";
111
+ case "gemini":
112
+ return "gemini-cli";
113
+ case "windsurf":
114
+ return "windsurf";
115
+ case "github-copilot-cli":
116
+ return "github-copilot";
117
+ case "mcporter":
118
+ return "mcporter";
119
+ case "zed":
120
+ return "zed";
121
+ default:
122
+ // Fall back to "cursor" as a safe default — skills CLI uses the
123
+ // agent name to pick the output directory (.agents/skills, .cursor/skills, etc.)
124
+ // and "cursor" uses .agents/skills which works for all agents.
125
+ return "cursor";
126
+ }
127
+ }
@@ -0,0 +1,77 @@
1
+ import { log } from "@clack/prompts";
2
+ import { execa } from "execa";
3
+ import { inspectCredentials, interpretCredentials, } from "../_shared/credentials.js";
4
+ import { resolveConfigFile } from "../_shared/paths.js";
5
+ import { DEFAULT_PROFILE } from "../_shared/profiles.js";
6
+ /**
7
+ * Ensures neonctl is authenticated by running a command that triggers auth if needed
8
+ * This will automatically start the OAuth flow if the user isn't already authenticated
9
+ */
10
+ export async function ensureNeonctlAuth(options) {
11
+ const quiet = options?.json === true;
12
+ // If already authenticated (e.g. ran in a terminal before), we can proceed
13
+ const existingToken = await getNeonctlAccessToken();
14
+ if (existingToken)
15
+ return true;
16
+ try {
17
+ // Use execa to authenticate with neonctl
18
+ await execa("npx", ["-y", "neonctl", "me"], {
19
+ // Shows OAuth URL and prompts to the user
20
+ stdio: "inherit",
21
+ // Unset CI so neonctl doesn't refuse to open the browser (e.g. when run from agent chat)
22
+ env: { ...process.env, CI: undefined },
23
+ });
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ const msg = error instanceof Error ? error.message : "Unknown error";
28
+ if (!quiet) {
29
+ if (msg.includes("interactive auth") || msg.includes("CI")) {
30
+ log.error("Auth requires an interactive terminal. Run neon init in your system terminal (outside the chat) to sign in.");
31
+ }
32
+ else {
33
+ log.error(`Authentication failed: ${msg}`);
34
+ }
35
+ }
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Checks whether neonctl has stored OAuth credentials.
41
+ */
42
+ export async function isAuthenticated() {
43
+ const token = await getNeonctlAccessToken();
44
+ return token !== null;
45
+ }
46
+ /**
47
+ * The credential the Neon CLI has stored, or `null` when there is none.
48
+ *
49
+ * Shares the reader with `neon` and `@neon/env` rather than keeping a copy — the inline path
50
+ * resolution this replaced was the third implementation of "where is the config directory", and
51
+ * it also looked only for `access_token`, so an account signed in with an API key read as not
52
+ * authenticated and got sent to a browser. See `shared/cli-core/README.md`.
53
+ *
54
+ * `null` means *absent*, and only absent. A file that exists and cannot be read throws: this
55
+ * value decides whether to start a browser sign-in, and a sign-in overwrites the file it could
56
+ * not read — as a different account, if a different one is chosen. Catching every error here
57
+ * made a damaged credential indistinguishable from a fresh machine.
58
+ */
59
+ async function getNeonctlAccessToken() {
60
+ const { path } = resolveConfigFile("credentials.json");
61
+ const read = inspectCredentials(path);
62
+ if (read.kind === "absent")
63
+ return null;
64
+ if (read.kind === "unusable") {
65
+ throw new Error(`${read.reason}. Replace it deliberately with \`neon profile create ${DEFAULT_PROFILE} --force\`, or delete the file.`);
66
+ }
67
+ // `neon init` has no profile selection — it reads the default credential and refuses
68
+ // when one is named, so `DEFAULT` is the only profile this can ever be about.
69
+ const credential = interpretCredentials(read.credentials, {
70
+ path,
71
+ profile: DEFAULT_PROFILE,
72
+ });
73
+ if (credential.kind === "api_key")
74
+ return credential.apiKey;
75
+ const token = read.credentials.access_token;
76
+ return typeof token === "string" && token.trim() !== "" ? token : null;
77
+ }
@@ -0,0 +1,448 @@
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";
4
+ import YAML from "yaml";
5
+ /** Default features when a template doesn't specify `requires`. */
6
+ const DEFAULT_REQUIRES = ["database"];
7
+ /**
8
+ * Hardcoded fallback used when every remote manifest source is unreachable.
9
+ * Kept in sync with `neondatabase/examples/bootstrap.yaml` (the source of
10
+ * truth) so that, even fully offline from the manifest, the picker still offers
11
+ * the full set of starters rather than a single template.
12
+ */
13
+ export const FALLBACK_TEMPLATES = [
14
+ {
15
+ id: "hono",
16
+ title: "REST API",
17
+ description: "A Hono REST API on Neon Functions, backed by Lakebase Postgres via Drizzle.",
18
+ tools: ["Hono", "Drizzle"],
19
+ services: ["Postgres", "Functions"],
20
+ requires: ["database", "functions"],
21
+ source: {
22
+ owner: "neondatabase",
23
+ repo: "examples",
24
+ ref: "main",
25
+ subdir: "with-hono",
26
+ },
27
+ },
28
+ {
29
+ id: "ai-sdk",
30
+ title: "Image-generation agent",
31
+ 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.",
32
+ tools: ["AI SDK", "Drizzle"],
33
+ services: ["Postgres", "Functions", "Object Storage", "AI Gateway"],
34
+ requires: ["database", "functions", "object-storage", "ai-gateway"],
35
+ source: {
36
+ owner: "neondatabase",
37
+ repo: "examples",
38
+ ref: "main",
39
+ subdir: "with-ai-sdk",
40
+ },
41
+ },
42
+ {
43
+ id: "mastra",
44
+ title: "Personal-assistant agent",
45
+ description: "A Mastra agent that streams chat through the Neon AI Gateway and uses Mastra Memory on Lakebase Postgres to remember you across threads.",
46
+ tools: ["Mastra", "Mastra Memory"],
47
+ services: ["Postgres", "Functions", "AI Gateway"],
48
+ requires: ["database", "functions", "ai-gateway"],
49
+ source: {
50
+ owner: "neondatabase",
51
+ repo: "examples",
52
+ ref: "main",
53
+ subdir: "with-mastra",
54
+ },
55
+ },
56
+ ];
57
+ export const templateIds = (templates) => templates.map((t) => t.id).join(", ");
58
+ export const findTemplate = (templates, id) => templates.find((t) => t.id === id);
59
+ const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
60
+ // A token is never required for public templates, but we forward it when
61
+ // present so the same code path works behind proxies that authenticate, and
62
+ // (in future) for private template repos.
63
+ const downloadHeaders = () => ({
64
+ // GitHub rejects a request with no User-Agent; the value is free-form and this
65
+ // one only has to name the client honestly.
66
+ "User-Agent": "neon",
67
+ ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
68
+ });
69
+ // The codeload host is overridable so the e2e tests can point the downloader at
70
+ // a local server (the same trick `--api-host` uses to redirect the Neon API).
71
+ const codeloadBase = () => process.env.NEON_BOOTSTRAP_GITHUB_CODELOAD ?? "https://codeload.github.com";
72
+ const isRecord = (value) => typeof value === "object" && value !== null;
73
+ /**
74
+ * Normalize a manifest entry's string list (`tools` or `services`) into a clean
75
+ * array. Tolerant by design: a missing or non-array value yields `undefined`,
76
+ * and non-string/blank items are dropped, so a malformed list never sinks an
77
+ * otherwise-valid template (it just renders without that detail).
78
+ */
79
+ const parseStringList = (value) => {
80
+ if (!Array.isArray(value))
81
+ return undefined;
82
+ const items = value.filter((item) => typeof item === "string" && item.trim() !== "");
83
+ return items.length > 0 ? items : undefined;
84
+ };
85
+ // ---------------------------------------------------------------------------
86
+ // Remote template manifest
87
+ // ---------------------------------------------------------------------------
88
+ // Primary manifest host is neon.com (CDN-backed, no GitHub rate limiting), with
89
+ // the raw GitHub copy as a fallback and the hardcoded list as the last resort.
90
+ // A single env override (used by tests) short-circuits the chain.
91
+ const NEON_MANIFEST_URL = "https://neon.com/bootstrap/templates.yaml";
92
+ const GITHUB_RAW_MANIFEST_URL = "https://raw.githubusercontent.com/neondatabase/examples/main/bootstrap.yaml";
93
+ function manifestUrls() {
94
+ const override = process.env.NEON_BOOTSTRAP_MANIFEST_URL;
95
+ if (override)
96
+ return [override];
97
+ return [NEON_MANIFEST_URL, GITHUB_RAW_MANIFEST_URL];
98
+ }
99
+ export function parseManifest(text) {
100
+ const data = YAML.parse(text);
101
+ if (!isRecord(data) || !Array.isArray(data.templates)) {
102
+ throw new Error('Invalid bootstrap manifest: missing "templates" array.');
103
+ }
104
+ const templates = [];
105
+ for (const item of data.templates) {
106
+ if (!isRecord(item) ||
107
+ typeof item.id !== "string" ||
108
+ typeof item.title !== "string" ||
109
+ typeof item.description !== "string" ||
110
+ !isRecord(item.source) ||
111
+ typeof item.source.owner !== "string" ||
112
+ typeof item.source.repo !== "string" ||
113
+ typeof item.source.ref !== "string" ||
114
+ typeof item.source.subdir !== "string") {
115
+ continue;
116
+ }
117
+ // Parse requires — accept a string array, default to ["database"].
118
+ const requires = Array.isArray(item.requires) &&
119
+ item.requires.every((r) => typeof r === "string")
120
+ ? item.requires
121
+ : DEFAULT_REQUIRES;
122
+ const tools = parseStringList(item.tools);
123
+ const services = parseStringList(item.services);
124
+ templates.push({
125
+ id: item.id,
126
+ title: item.title,
127
+ description: item.description,
128
+ ...(tools ? { tools } : {}),
129
+ ...(services ? { services } : {}),
130
+ requires,
131
+ source: {
132
+ owner: item.source.owner,
133
+ repo: item.source.repo,
134
+ ref: item.source.ref,
135
+ subdir: item.source.subdir,
136
+ },
137
+ });
138
+ }
139
+ return templates;
140
+ }
141
+ /**
142
+ * Fetch the template manifest, trying each source in {@link manifestUrls} in
143
+ * order and returning the first that yields a non-empty template list. Falls
144
+ * back to the hardcoded list when every source is unreachable or empty, so the
145
+ * picker never fails just because a host is down.
146
+ */
147
+ export async function fetchTemplates() {
148
+ for (const url of manifestUrls()) {
149
+ try {
150
+ const res = await fetch(url, {
151
+ headers: downloadHeaders(),
152
+ signal: AbortSignal.timeout(10000),
153
+ });
154
+ if (!res.ok)
155
+ throw new Error(`HTTP ${res.status}`);
156
+ const templates = parseManifest(await res.text());
157
+ if (templates.length > 0)
158
+ return templates;
159
+ }
160
+ catch {
161
+ // Try the next source.
162
+ }
163
+ }
164
+ return FALLBACK_TEMPLATES;
165
+ }
166
+ const TAR_BLOCK = 512;
167
+ const readTarString = (buf, offset, length) => {
168
+ let end = offset;
169
+ const max = offset + length;
170
+ while (end < max && buf[end] !== 0)
171
+ end++;
172
+ return buf.toString("utf8", offset, end);
173
+ };
174
+ const readTarOctal = (buf, offset, length) => {
175
+ const text = readTarString(buf, offset, length).trim();
176
+ if (text === "")
177
+ return 0;
178
+ const value = parseInt(text, 8);
179
+ return Number.isNaN(value) ? 0 : value;
180
+ };
181
+ const isZeroBlock = (buf, offset) => {
182
+ for (let i = offset; i < offset + TAR_BLOCK; i++) {
183
+ if (buf[i] !== 0)
184
+ return false;
185
+ }
186
+ return true;
187
+ };
188
+ /**
189
+ * Parse pax extended-header records ("<len> <key>=<value>\n"). GitHub uses
190
+ * these for the global header and for any path that doesn't fit the legacy
191
+ * 100-byte name field, so we must honor at least `path` and `linkpath`.
192
+ */
193
+ const parsePaxRecords = (data) => {
194
+ const records = {};
195
+ let pos = 0;
196
+ const text = data.toString("utf8");
197
+ while (pos < text.length) {
198
+ const space = text.indexOf(" ", pos);
199
+ if (space === -1)
200
+ break;
201
+ const len = parseInt(text.slice(pos, space), 10);
202
+ if (Number.isNaN(len) || len <= 0)
203
+ break;
204
+ const record = text.slice(space + 1, pos + len - 1); // drop trailing "\n"
205
+ const eq = record.indexOf("=");
206
+ if (eq !== -1)
207
+ records[record.slice(0, eq)] = record.slice(eq + 1);
208
+ pos += len;
209
+ }
210
+ return records;
211
+ };
212
+ /**
213
+ * Decode a (decompressed) tar archive into its file/symlink entries. Pure and
214
+ * dependency-free so it can be unit tested without touching the network.
215
+ * Handles the ustar `prefix` field, pax extended headers (type 'x'/'g'), and
216
+ * GNU long-name/long-link headers (type 'L'/'K') so deep template paths and
217
+ * long symlink targets round-trip correctly.
218
+ */
219
+ export const parseTar = (buf) => {
220
+ const entries = [];
221
+ // Overrides carried from a preceding pax/GNU header to the next real entry.
222
+ let overridePath;
223
+ let overrideLink;
224
+ let offset = 0;
225
+ while (offset + TAR_BLOCK <= buf.length) {
226
+ if (isZeroBlock(buf, offset))
227
+ break;
228
+ let name = readTarString(buf, offset, 100);
229
+ const mode = readTarOctal(buf, offset + 100, 8);
230
+ const size = readTarOctal(buf, offset + 124, 12);
231
+ const typeByte = buf[offset + 156];
232
+ const type = typeByte === 0 ? "0" : String.fromCharCode(typeByte);
233
+ let linkname = readTarString(buf, offset + 157, 100);
234
+ const magic = readTarString(buf, offset + 257, 6);
235
+ if (magic.startsWith("ustar")) {
236
+ const prefix = readTarString(buf, offset + 345, 155);
237
+ if (prefix !== "")
238
+ name = `${prefix}/${name}`;
239
+ }
240
+ offset += TAR_BLOCK;
241
+ const data = buf.subarray(offset, offset + size);
242
+ offset += Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
243
+ if (type === "x") {
244
+ const records = parsePaxRecords(data);
245
+ if (records.path !== undefined)
246
+ overridePath = records.path;
247
+ if (records.linkpath !== undefined)
248
+ overrideLink = records.linkpath;
249
+ continue;
250
+ }
251
+ if (type === "g") {
252
+ // Global pax header (e.g. GitHub's comment block): not per-entry state.
253
+ continue;
254
+ }
255
+ if (type === "L" || type === "K") {
256
+ const longValue = data.toString("utf8").replace(/\0+$/, "");
257
+ if (type === "L")
258
+ overridePath = longValue;
259
+ else
260
+ overrideLink = longValue;
261
+ continue;
262
+ }
263
+ if (overridePath !== undefined)
264
+ name = overridePath;
265
+ if (overrideLink !== undefined)
266
+ linkname = overrideLink;
267
+ overridePath = undefined;
268
+ overrideLink = undefined;
269
+ entries.push({ name, type, mode, linkname, data: Buffer.from(data) });
270
+ }
271
+ return entries;
272
+ };
273
+ /**
274
+ * Map decoded tar entries to the files under `subdir`, with the top-level
275
+ * archive directory and the `subdir/` prefix stripped from each path. Pure so
276
+ * it can be unit tested. Directory and other non-regular entries are dropped —
277
+ * writing files re-creates their parent directories.
278
+ */
279
+ export const selectTemplateFiles = (entries, subdir) => {
280
+ const prefix = `${subdir.replace(/^\/+|\/+$/g, "")}/`;
281
+ const files = [];
282
+ for (const entry of entries) {
283
+ // codeload wraps everything in a single top-level dir ("<repo>-<ref>/");
284
+ // strip that first segment to get the repo-relative path.
285
+ const slash = entry.name.indexOf("/");
286
+ if (slash === -1)
287
+ continue;
288
+ const repoPath = entry.name.slice(slash + 1);
289
+ if (!repoPath.startsWith(prefix))
290
+ continue;
291
+ const path = repoPath.slice(prefix.length);
292
+ if (path === "")
293
+ continue;
294
+ if (entry.type === "2") {
295
+ files.push({ kind: "symlink", path, target: entry.linkname });
296
+ }
297
+ else if (entry.type === "0" || entry.type === "7") {
298
+ files.push({
299
+ kind: "file",
300
+ path,
301
+ bytes: entry.data,
302
+ executable: (entry.mode & 0o111) !== 0,
303
+ });
304
+ }
305
+ // Directories ('5') and any other node types are intentionally skipped.
306
+ }
307
+ return files;
308
+ };
309
+ const tarballUrl = (template) => {
310
+ const { owner, repo, ref } = template.source;
311
+ return `${codeloadBase()}/${owner}/${repo}/tar.gz/${ref}`;
312
+ };
313
+ const friendlyGithubError = (status, url) => {
314
+ if (status === 404) {
315
+ return new Error(`GitHub returned 404 for ${url}. The template repo or ref may have moved.`);
316
+ }
317
+ if (status === 403 || status === 429) {
318
+ return new Error(`GitHub rate limited the template download (${url}). Set a GITHUB_TOKEN environment variable to raise the limit, then retry.`);
319
+ }
320
+ return new Error(`GitHub returned HTTP ${status} for ${url}.`);
321
+ };
322
+ /**
323
+ * Download a template and resolve it to the exact set of files to write. The
324
+ * entire subtree is captured in one tarball request, so the copy is atomically
325
+ * consistent: a push to the template repo mid-download cannot produce a
326
+ * mismatched checkout (unlike fetching a file list and then each blob).
327
+ */
328
+ export const downloadTemplate = async (template) => {
329
+ const url = tarballUrl(template);
330
+ let gzipped;
331
+ try {
332
+ const res = await fetch(url, {
333
+ headers: downloadHeaders(),
334
+ signal: AbortSignal.timeout(30000),
335
+ });
336
+ if (!res.ok)
337
+ throw friendlyGithubError(res.status, url);
338
+ gzipped = Buffer.from(await res.arrayBuffer());
339
+ }
340
+ catch (err) {
341
+ throw err instanceof Error ? err : new Error(String(err));
342
+ }
343
+ let tar;
344
+ try {
345
+ tar = Buffer.from(gunzipSync(new Uint8Array(gzipped)));
346
+ }
347
+ catch (err) {
348
+ throw new Error(`Failed to decompress the template archive from ${url}: ${err instanceof Error ? err.message : String(err)}`);
349
+ }
350
+ const { owner, repo, ref, subdir } = template.source;
351
+ const files = selectTemplateFiles(parseTar(tar), subdir);
352
+ if (files.length === 0) {
353
+ throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
354
+ }
355
+ return files;
356
+ };
357
+ // ---------------------------------------------------------------------------
358
+ // Target validation + scaffolding to disk
359
+ // ---------------------------------------------------------------------------
360
+ /**
361
+ * A bad caller-supplied input that an agent (or human) can correct: an unknown
362
+ * template id or a non-empty target directory. Carries an `agentCode` so an
363
+ * agent surface can report a precise error code instead of a generic
364
+ * INTERNAL_ERROR, while a human path just surfaces the clear `message`.
365
+ */
366
+ export class BootstrapInputError extends Error {
367
+ constructor(message, agentCode) {
368
+ super(message);
369
+ this.name = "BootstrapInputError";
370
+ this.agentCode = agentCode;
371
+ }
372
+ }
373
+ /**
374
+ * Ensure `dir` is safe to scaffold into: it must be missing, or an empty
375
+ * directory (a lone `.git` is ignored so you can scaffold into a freshly
376
+ * `git init`ed folder). `force` allows scaffolding into a non-empty directory,
377
+ * overwriting colliding files. Throws a {@link BootstrapInputError} otherwise.
378
+ */
379
+ export const ensureTargetUsable = (dir, force) => {
380
+ if (!existsSync(dir))
381
+ return;
382
+ if (!statSync(dir).isDirectory()) {
383
+ throw new BootstrapInputError(`Target ${dir} already exists and is not a directory.`, "TARGET_NOT_DIRECTORY");
384
+ }
385
+ const contents = readdirSync(dir).filter((name) => name !== ".git");
386
+ if (contents.length > 0 && !force) {
387
+ 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");
388
+ }
389
+ };
390
+ const isSymlink = (path) => {
391
+ try {
392
+ return lstatSync(path).isSymbolicLink();
393
+ }
394
+ catch {
395
+ return false;
396
+ }
397
+ };
398
+ const errnoCode = (err) => {
399
+ if (typeof err === "object" &&
400
+ err !== null &&
401
+ "code" in err &&
402
+ typeof err.code === "string") {
403
+ return err.code;
404
+ }
405
+ return undefined;
406
+ };
407
+ const writeSymlink = (dest, target, onWarn) => {
408
+ if (isSymlink(dest))
409
+ rmSync(dest, { force: true });
410
+ try {
411
+ symlinkSync(target, dest);
412
+ }
413
+ catch (err) {
414
+ // Windows refuses symlinks without elevated rights / developer mode. The
415
+ // template still works for most tooling if we drop a regular file holding
416
+ // the link target, so we degrade gracefully instead of failing the copy.
417
+ if (errnoCode(err) === "EPERM" || process.platform === "win32") {
418
+ onWarn?.(`Could not create symlink ${dest} -> ${target}; wrote it as a regular file instead.`);
419
+ writeFileSync(dest, target);
420
+ return;
421
+ }
422
+ throw err;
423
+ }
424
+ };
425
+ /**
426
+ * Download `template` and materialize its files into `targetDir`, creating
427
+ * parent directories, preserving executable bits, and recreating symlinks
428
+ * (with a graceful regular-file fallback on platforms that disallow them).
429
+ * Returns the number of files written. The caller is responsible for any
430
+ * target validation ({@link ensureTargetUsable}) and user-facing progress.
431
+ */
432
+ export const scaffoldTemplate = async (template, targetDir, options = {}) => {
433
+ const files = await downloadTemplate(template);
434
+ mkdirSync(targetDir, { recursive: true });
435
+ for (const file of files) {
436
+ const dest = join(targetDir, file.path);
437
+ mkdirSync(dirname(dest), { recursive: true });
438
+ if (file.kind === "symlink") {
439
+ writeSymlink(dest, file.target, options.onWarn);
440
+ }
441
+ else {
442
+ writeFileSync(dest, file.bytes);
443
+ if (file.executable)
444
+ chmodSync(dest, 0o755);
445
+ }
446
+ }
447
+ return files.length;
448
+ };
@@ -0,0 +1,2 @@
1
+ // Auto-generated by scripts/set-vsx-gallery.mjs — do not edit
2
+ export const INTERNAL_VSX_GALLERY = "";