neonctl 2.25.1 → 2.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/auth.js CHANGED
@@ -110,6 +110,9 @@ export const ensureAuth = async (props) => {
110
110
  // login. It uses an API key / stored credentials when present (harmless),
111
111
  // otherwise it proceeds with no API client.
112
112
  const isBootstrap = props._[0] === 'bootstrap';
113
+ // `init` manages its own auth flow (asks the user if they have an account,
114
+ // then triggers OAuth at the right time). Skip the global auth middleware.
115
+ const isInit = props._[0] === 'init';
113
116
  // Use existing API key or handle auth command
114
117
  if (props.apiKey || props._[0] === 'auth') {
115
118
  if (props.apiKey) {
@@ -162,6 +165,10 @@ export const ensureAuth = async (props) => {
162
165
  log.debug('bootstrap: no usable credentials; continuing without auth');
163
166
  return;
164
167
  }
168
+ if (isInit) {
169
+ log.debug('init: skipping global auth; init manages its own auth flow');
170
+ return;
171
+ }
165
172
  // Start new auth flow if no valid token exists or refresh failed
166
173
  const apiKey = await authFlow(props);
167
174
  props.apiKey = apiKey;
@@ -6,7 +6,7 @@ import prompts from 'prompts';
6
6
  import which from 'which';
7
7
  import { isCi } from '../env.js';
8
8
  import { log } from '../log.js';
9
- import { FALLBACK_TEMPLATES, fetchFileBytes, fetchSymlinkTarget, fetchTemplates, findTemplate, resolveTemplate, templateIds, } from '../utils/bootstrap.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]';
@@ -101,18 +101,18 @@ const resolveTemplateList = async (props) => props.template && findTemplate(FALL
101
101
  ? FALLBACK_TEMPLATES
102
102
  : fetchTemplates();
103
103
  /**
104
- * The picker label for a template: the title prefixed with the Neon services it
105
- * uses as a dim badge, e.g. "[Postgres · Functions] Hono API …". The badge is
106
- * styled with chalk.dim only (never a foreground color) so it survives the
107
- * cyan/underline `prompts` paints over the focused row dim resets with the
108
- * intensity SGR, leaving the row's color and underline intact. The one-line
109
- * description renders under the title on focus (handled by `prompts`).
104
+ * The picker label for a template: the title first, then the Neon services it
105
+ * uses as a dim, italic suffix, e.g. "Hono API … Postgres · Functions". The
106
+ * suffix is styled with chalk.dim (and italic) only never a foreground color
107
+ * so it survives the cyan/underline `prompts` paints over the focused row: dim
108
+ * and italic reset with their own SGRs, leaving the row's color and underline
109
+ * intact. Descriptions are intentionally omitted to keep the picker uncluttered.
110
110
  */
111
111
  const formatTemplateTitle = (template) => {
112
112
  if (!template.services || template.services.length === 0) {
113
113
  return template.title;
114
114
  }
115
- return `${chalk.dim(`[${template.services.join(' · ')}]`)} ${template.title}`;
115
+ return `${template.title} ${chalk.dim.italic(template.services.join(' · '))}`;
116
116
  };
117
117
  const resolveSelectedTemplate = async (props, interactive, templates) => {
118
118
  if (props.template) {
@@ -141,7 +141,6 @@ const resolveSelectedTemplate = async (props, interactive, templates) => {
141
141
  message: 'Which template would you like to use?',
142
142
  choices: templates.map((template) => ({
143
143
  title: formatTemplateTitle(template),
144
- description: template.description,
145
144
  value: template.id,
146
145
  })),
147
146
  initial: 0,
@@ -207,25 +206,23 @@ const ensureTargetUsable = (dir, force) => {
207
206
  };
208
207
  const scaffold = async (template, targetDir) => {
209
208
  log.info('Fetching template "%s" from GitHub…', template.id);
210
- const { commitSha, entries } = await resolveTemplate(template);
209
+ const files = await downloadTemplate(template);
211
210
  mkdirSync(targetDir, { recursive: true });
212
- log.info('Scaffolding %d files into %s…', entries.length, targetDir);
213
- await mapWithConcurrency(entries, 8, async (entry) => {
214
- const dest = join(targetDir, entry.path);
211
+ log.info('Scaffolding %d files into %s…', files.length, targetDir);
212
+ for (const file of files) {
213
+ const dest = join(targetDir, file.path);
215
214
  mkdirSync(dirname(dest), { recursive: true });
216
- if (entry.kind === 'symlink') {
217
- const target = await fetchSymlinkTarget(template, commitSha, entry.repoPath);
218
- writeSymlink(dest, target);
215
+ if (file.kind === 'symlink') {
216
+ writeSymlink(dest, file.target);
219
217
  }
220
218
  else {
221
- const bytes = await fetchFileBytes(template, commitSha, entry.repoPath);
222
- writeFileSync(dest, bytes);
223
- if (entry.executable) {
219
+ writeFileSync(dest, file.bytes);
220
+ if (file.executable) {
224
221
  chmodSync(dest, 0o755);
225
222
  }
226
223
  }
227
- });
228
- return entries.length;
224
+ }
225
+ return files.length;
229
226
  };
230
227
  const writeSymlink = (dest, target) => {
231
228
  if (isSymlink(dest)) {
@@ -561,16 +558,6 @@ const displayDir = (targetDir) => {
561
558
  }
562
559
  return rel.startsWith('..') ? targetDir : rel;
563
560
  };
564
- const mapWithConcurrency = async (items, limit, fn) => {
565
- const queue = [...items];
566
- const worker = async () => {
567
- for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
568
- await fn(next);
569
- }
570
- };
571
- const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker());
572
- await Promise.all(workers);
573
- };
574
561
  const isSymlink = (path) => {
575
562
  try {
576
563
  return lstatSync(path).isSymbolicLink();
@@ -1,6 +1,7 @@
1
1
  import { isAxiosError } from 'axios';
2
+ import chalk from 'chalk';
2
3
  import prompts from 'prompts';
3
- import { applyContext, readContextFile } from '../context.js';
4
+ import { applyContext, contextBranch, readContextFile } from '../context.js';
4
5
  import { isCi } from '../env.js';
5
6
  import { log } from '../log.js';
6
7
  import { createBranch, pickBranchInteractively, } from '../utils/branch_picker.js';
@@ -47,6 +48,13 @@ export const builder = (argv) => argv
47
48
  ],
48
49
  ]);
49
50
  export const handler = async (props) => {
51
+ // Show where the context is pinned *before* we switch it, so the user sees the move
52
+ // ("currently on X" → "checked out Y") and can catch a checkout they didn't mean to make.
53
+ // Read straight from `.neon` (a name, no API call); silent when nothing is pinned yet.
54
+ const previousBranch = contextBranch(readContextFile(props.contextFile));
55
+ if (previousBranch) {
56
+ log.info('%s Currently on branch %s', chalk.dim('→'), chalk.cyan.bold(previousBranch));
57
+ }
50
58
  // Branch listing is project-scoped, so `projectId` is the only thing
51
59
  // `checkout` actually needs. Resolve it through the standard chain
52
60
  // (--project-id flag > .neon file > single-project auto-detect); when
@@ -3,7 +3,8 @@ import { apply, createBranch as createBranchFromPolicy, inspect, loadConfigFromF
3
3
  import { toNeonConfigView } from '../config_format.js';
4
4
  import { log } from '../log.js';
5
5
  import { loadEnvFileIntoProcess } from '../env_file.js';
6
- import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
6
+ import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
7
+ import { announceTargetBranch } from '../utils/branch_notice.js';
7
8
  import { bundleEntry } from '../utils/esbuild.js';
8
9
  import { zipBundle } from '../utils/zip.js';
9
10
  import { writer } from '../writer.js';
@@ -119,7 +120,13 @@ const loadConfig = async (props) => {
119
120
  return config;
120
121
  };
121
122
  export const status = async (props) => {
122
- const branchId = await branchIdFromProps(props);
123
+ const branch = await resolveBranchRef(props);
124
+ // `--config-json` is a script-friendly mode that emits only JSON to stdout, so keep it
125
+ // pristine; the regular human view gets the "which branch am I inspecting" guardrail.
126
+ if (!props.configJson) {
127
+ announceTargetBranch(props, branch, 'Inspecting branch');
128
+ }
129
+ const branchId = branch.branchId;
123
130
  const live = await inspect({
124
131
  projectId: props.projectId,
125
132
  branchId,
@@ -152,7 +159,9 @@ export const status = async (props) => {
152
159
  };
153
160
  export const planCmd = async (props) => {
154
161
  const config = await loadConfig(props);
155
- const branchId = await branchIdFromProps(props);
162
+ const branch = await resolveBranchRef(props);
163
+ announceTargetBranch(props, branch, 'Planning against branch');
164
+ const branchId = branch.branchId;
156
165
  // `plan` is a dry run that never bundles, so its options don't accept (or need)
157
166
  // an injected bundler — only `apply` does (it uses neonctlBundler).
158
167
  const result = await plan(config, {
@@ -166,7 +175,9 @@ export const planCmd = async (props) => {
166
175
  };
167
176
  export const applyCmd = async (props) => {
168
177
  const config = await loadConfig(props);
169
- const branchId = await branchIdFromProps(props);
178
+ const branch = await resolveBranchRef(props);
179
+ announceTargetBranch(props, branch, 'Applying to branch');
180
+ const branchId = branch.branchId;
170
181
  const result = await apply(config, {
171
182
  projectId: props.projectId,
172
183
  branchId,
package/commands/dev.js CHANGED
@@ -480,9 +480,8 @@ const spawnChild = (unit, runtimePath, bundlePath) => {
480
480
  const writeBundle = async (source, bundleDir) => {
481
481
  const files = await bundleEntry(source);
482
482
  mkdirSync(bundleDir, { recursive: true });
483
- // bundleEntry emits `index.mjs` (+ `index.mjs.map`). The `.mjs` extension makes Node load
484
- // it as ESM directly, so no `package.json` `"type": "module"` marker is needed, and esbuild
485
- // points the sourcemap link at `index.mjs.map` for us.
483
+ // bundleEntry emits a single `index.mjs` (no source map). The `.mjs` extension makes Node
484
+ // load it as ESM directly, so no `package.json` `"type": "module"` marker is needed.
486
485
  for (const [name, contents] of Object.entries(files)) {
487
486
  writeFileSync(join(bundleDir, name), contents);
488
487
  }
package/commands/env.js CHANGED
@@ -4,7 +4,8 @@ import { existsSync } from 'node:fs';
4
4
  import { log } from '../log.js';
5
5
  import { resolveNeonEnvVars } from '../dev/env.js';
6
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from '../env_file.js';
7
- import { branchIdFromProps, fillSingleProject } from '../utils/enrichers.js';
7
+ import { fillSingleProject, resolveBranchRef } from '../utils/enrichers.js';
8
+ import { announceTargetBranch } from '../utils/branch_notice.js';
8
9
  export const command = 'env';
9
10
  export const describe = "Manage a branch's Neon env variables locally";
10
11
  /**
@@ -33,15 +34,23 @@ export const builder = (argv) => argv
33
34
  })
34
35
  .example('$0 env pull', "Write the linked branch's Neon vars into .env.local (or .env if present)")
35
36
  .example('$0 env pull --branch preview --file .env.preview', 'Pull a specific branch into a specific file'), async (args) => {
36
- await pull(args);
37
+ // Explicit `env pull` announces the branch it's reading from up front so the user
38
+ // can catch "pulled env from the wrong branch" before it overwrites their .env. The
39
+ // bundled auto-pull (link / checkout / apply) stays quiet — those already report the
40
+ // branch they pinned/applied to.
41
+ await pull(args, { announce: true });
37
42
  })
38
43
  .demandCommand(1);
39
44
  export const handler = (args) => args;
40
45
  /** Every OS-level env var name `@neondatabase/env` can emit, used only for reporting. */
41
46
  const NEON_VAR_NAMES = Object.values(NEON_ENV_VAR_KEYS).flatMap((group) => Object.values(group));
42
- export const pull = async (props) => {
47
+ export const pull = async (props, opts = {}) => {
43
48
  const cwd = props.cwd ?? process.cwd();
44
- const branchId = await branchIdFromProps(props);
49
+ const branch = await resolveBranchRef(props);
50
+ if (opts.announce) {
51
+ announceTargetBranch(props, branch, 'Pulling env from branch');
52
+ }
53
+ const branchId = branch.branchId;
45
54
  // Resolve the target file first and layer its current contents under the resolver's env
46
55
  // source. This lets `fetchEnv` reuse one-time secrets that are already on disk — Neon Auth
47
56
  // keys and the unified branch credential's `api_token` / `s3_secret_access_key`, which the
package/commands/init.js CHANGED
@@ -1,27 +1,6 @@
1
- import { init } from 'neon-init';
1
+ import { detectAgent, enrichResponse, interactiveInit, orchestrate, routeDataStep, } from 'neon-init';
2
2
  import { sendError } from '../analytics.js';
3
3
  import { log } from '../log.js';
4
- const AGENT_FLAG_VALUES = ['cursor', 'copilot', 'claude'];
5
- function parseAgentToEditor(value) {
6
- const normalized = value.trim().toLowerCase();
7
- switch (normalized) {
8
- case 'cursor':
9
- return 'Cursor';
10
- case 'github-copilot':
11
- case 'copilot':
12
- case 'vs code':
13
- case 'vscode':
14
- case 'vs-code':
15
- return 'VS Code';
16
- case 'claude-code':
17
- case 'claude cli':
18
- case 'claude-cli':
19
- case 'claude':
20
- return 'Claude CLI';
21
- default:
22
- return null;
23
- }
24
- }
25
4
  export const command = 'init';
26
5
  export const describe = 'Initialize a project with Neon using your AI coding assistant';
27
6
  export const builder = (yargs) => yargs
@@ -31,23 +10,64 @@ export const builder = (yargs) => yargs
31
10
  .option('agent', {
32
11
  alias: 'a',
33
12
  type: 'string',
34
- describe: 'Agent to configure (cursor, copilot, code).',
13
+ describe: 'Agent to configure (cursor, copilot, claude, etc.).',
14
+ })
15
+ .option('data', {
16
+ type: 'string',
17
+ describe: 'JSON object with a "step" field to route to a specific phase and phase-specific options.',
18
+ })
19
+ .option('skip-neon-auth', {
20
+ type: 'boolean',
21
+ default: false,
22
+ describe: 'Skip the Neon Auth setup phase.',
23
+ })
24
+ .option('skip-migrations', {
25
+ type: 'boolean',
26
+ default: false,
27
+ describe: 'Skip the migrations phase.',
28
+ })
29
+ .option('preview', {
30
+ type: 'boolean',
31
+ default: false,
32
+ describe: 'Enable preview features (e.g. project bootstrapping from templates).',
35
33
  })
36
34
  .strict(false);
37
35
  export const handler = async (argv) => {
38
- let options;
39
- const agentArg = argv.agent;
40
- if (agentArg !== undefined) {
41
- const editor = parseAgentToEditor(agentArg);
42
- if (editor === null) {
43
- log.error(`Invalid --agent value: "${agentArg}". Supported: ${AGENT_FLAG_VALUES.join(', ')}`);
44
- process.exit(1);
45
- return;
46
- }
47
- options = { agent: editor };
48
- }
49
36
  try {
50
- await init(options);
37
+ // Auto-detect agent from environment if --agent not explicitly provided.
38
+ // For IDE-based detection (Cursor, VS Code, Windsurf), require non-TTY stdin
39
+ // to distinguish "agent spawned this" from "human typed this in terminal".
40
+ const agent = argv.agent || (!process.stdin.isTTY ? detectAgent() : null) || undefined;
41
+ const isAgentMode = agent !== undefined;
42
+ // --data with a "step" field routes to the appropriate phase
43
+ if (argv.data && isAgentMode) {
44
+ let data;
45
+ try {
46
+ data = JSON.parse(argv.data);
47
+ }
48
+ catch {
49
+ log.error('Invalid JSON in --data flag. Expected a JSON object.');
50
+ process.exit(1);
51
+ return;
52
+ }
53
+ if (typeof data.step === 'string') {
54
+ const result = await routeDataStep(data, agent);
55
+ log.info(JSON.stringify(enrichResponse(result), null, 2));
56
+ return;
57
+ }
58
+ }
59
+ if (isAgentMode) {
60
+ const result = await orchestrate({
61
+ agent,
62
+ skipNeonAuth: argv.skipNeonAuth,
63
+ skipMigrations: argv.skipMigrations,
64
+ preview: argv.preview,
65
+ });
66
+ log.info(JSON.stringify(enrichResponse(result), null, 2));
67
+ }
68
+ else {
69
+ await interactiveInit({ preview: argv.preview });
70
+ }
51
71
  }
52
72
  catch {
53
73
  const exitError = new Error(`failed to run neon-init`);
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.25.1",
8
+ "version": "2.26.2",
9
9
  "description": "CLI tool for NeonDB Cloud management",
10
10
  "main": "index.js",
11
11
  "author": "NeonDB",
@@ -59,9 +59,9 @@
59
59
  "dependencies": {
60
60
  "@hono/node-server": "2.0.4",
61
61
  "@neondatabase/api-client": "2.7.1",
62
- "@neondatabase/config": "0.7.0",
63
- "@neondatabase/config-runtime": "0.7.0",
64
- "@neondatabase/env": "0.5.0",
62
+ "@neondatabase/config": "0.7.1",
63
+ "@neondatabase/config-runtime": "0.7.1",
64
+ "@neondatabase/env": "0.5.1",
65
65
  "@segment/analytics-node": "1.3.0",
66
66
  "axios": "1.7.2",
67
67
  "axios-debug-log": "1.0.0",
@@ -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.15.0",
74
+ "neon-init": "0.16.3",
75
75
  "open": "10.1.0",
76
76
  "openid-client": "6.8.1",
77
77
  "pg-protocol": "^1.14.0",
@@ -1,7 +1,13 @@
1
1
  import axios, { isAxiosError } from 'axios';
2
+ import { gunzipSync } from 'fflate';
2
3
  import YAML from 'yaml';
3
4
  import { log } from '../log.js';
4
- /** Hardcoded fallback used when the remote manifest cannot be fetched. */
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
+ */
5
11
  export const FALLBACK_TEMPLATES = [
6
12
  {
7
13
  id: 'hono',
@@ -15,25 +21,44 @@ export const FALLBACK_TEMPLATES = [
15
21
  subdir: 'with-hono',
16
22
  },
17
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
+ },
18
48
  ];
19
49
  export const templateIds = (templates) => templates.map((t) => t.id).join(', ');
20
50
  export const findTemplate = (templates, id) => templates.find((t) => t.id === id);
21
- // Hosts are overridable so the e2e tests can point the downloader at a local
22
- // server (the same trick `--api-host` uses to redirect the Neon API in tests).
23
- // The defaults hit public GitHub; copying a public template needs no auth.
24
- const githubApiBase = () => process.env.NEON_BOOTSTRAP_GITHUB_API ?? 'https://api.github.com';
25
- const githubRawBase = () => process.env.NEON_BOOTSTRAP_GITHUB_RAW ?? 'https://raw.githubusercontent.com';
26
51
  const githubToken = () => process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '';
27
- const apiHeaders = () => ({
28
- Accept: 'application/vnd.github+json',
29
- 'X-GitHub-Api-Version': '2022-11-28',
30
- 'User-Agent': 'neonctl',
31
- ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
32
- });
33
- const rawHeaders = () => ({
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 = () => ({
34
56
  'User-Agent': 'neonctl',
35
57
  ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
36
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';
37
62
  const isRecord = (value) => typeof value === 'object' && value !== null;
38
63
  /**
39
64
  * Normalize a manifest entry's `services` into a clean string list. Tolerant by
@@ -51,8 +76,18 @@ const parseServices = (value) => {
51
76
  // ---------------------------------------------------------------------------
52
77
  // Remote template manifest
53
78
  // ---------------------------------------------------------------------------
54
- const manifestUrl = () => process.env.NEON_BOOTSTRAP_MANIFEST_URL ??
55
- `${githubRawBase()}/neondatabase/examples/main/bootstrap.yaml`;
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
+ };
56
91
  export const parseManifest = (text) => {
57
92
  const data = YAML.parse(text);
58
93
  if (!isRecord(data) || !Array.isArray(data.templates)) {
@@ -90,154 +125,240 @@ export const parseManifest = (text) => {
90
125
  return templates;
91
126
  };
92
127
  /**
93
- * Fetch the template manifest from the remote `bootstrap.yaml` in the
94
- * neondatabase/examples repo. Falls back to the hardcoded list on any error
95
- * so the command never fails just because GitHub is unreachable.
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.
96
132
  */
97
133
  export const fetchTemplates = async () => {
98
- const url = manifestUrl();
99
- try {
100
- const res = await axios.get(url, {
101
- responseType: 'text',
102
- headers: rawHeaders(),
103
- timeout: 10000,
104
- });
105
- const templates = parseManifest(res.data);
106
- if (templates.length === 0) {
107
- log.warning('Remote bootstrap manifest at %s contained no templates; using built-in defaults.', url);
108
- return FALLBACK_TEMPLATES;
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));
109
149
  }
110
- return templates;
111
- }
112
- catch (err) {
113
- log.debug('bootstrap: failed to fetch manifest from %s: %s — using built-in defaults.', url, err instanceof Error ? err.message : String(err));
114
- return FALLBACK_TEMPLATES;
115
150
  }
151
+ log.debug('bootstrap: all manifest sources exhausted; using built-in defaults.');
152
+ return FALLBACK_TEMPLATES;
116
153
  };
117
- const malformed = (what) => new Error(`Unexpected GitHub API response while resolving ${what}.`);
118
- const parseCommit = (data) => {
119
- if (!isRecord(data) || typeof data.sha !== 'string') {
120
- throw malformed('the template commit');
121
- }
122
- const { commit } = data;
123
- if (!isRecord(commit) ||
124
- !isRecord(commit.tree) ||
125
- typeof commit.tree.sha !== 'string') {
126
- throw malformed('the template tree');
127
- }
128
- return { commitSha: data.sha, treeSha: commit.tree.sha };
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);
129
162
  };
130
- const parseTree = (data) => {
131
- if (!isRecord(data) || !Array.isArray(data.tree)) {
132
- throw malformed('the template file tree');
163
+ const readTarOctal = (buf, offset, length) => {
164
+ const text = readTarString(buf, offset, length).trim();
165
+ if (text === '') {
166
+ return 0;
133
167
  }
134
- const tree = [];
135
- for (const item of data.tree) {
136
- if (isRecord(item) &&
137
- typeof item.path === 'string' &&
138
- typeof item.mode === 'string' &&
139
- typeof item.type === 'string') {
140
- tree.push({ path: item.path, mode: item.mode, type: item.type });
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;
141
175
  }
142
176
  }
143
- return { truncated: data.truncated === true, tree };
177
+ return true;
144
178
  };
145
- const friendlyGithubError = (err, url) => {
146
- if (isAxiosError(err)) {
147
- const status = err.response?.status;
148
- if (status === 404) {
149
- return new Error(`GitHub returned 404 for ${url}. The template repo, ref, or subdirectory may have moved.`);
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;
150
196
  }
151
- if (status === 403 &&
152
- err.response?.headers['x-ratelimit-remaining'] === '0') {
153
- return new Error('GitHub API rate limit exceeded. Set a GITHUB_TOKEN environment variable to raise the limit, then retry.');
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);
154
201
  }
202
+ pos += len;
155
203
  }
156
- return err instanceof Error ? err : new Error(String(err));
204
+ return records;
157
205
  };
158
- const getJson = async (url) => {
159
- try {
160
- const res = await axios.get(url, { headers: apiHeaders() });
161
- return res.data;
162
- }
163
- catch (err) {
164
- throw friendlyGithubError(err, url);
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) });
165
272
  }
273
+ return entries;
166
274
  };
167
275
  /**
168
- * Map a flat (recursive) git tree to the entries under `subdir`, with the
169
- * `subdir/` prefix stripped from each `path`. Pure so it can be unit tested
170
- * without touching the network. Directory nodes are dropped — git never
171
- * stores empty directories, and writing files re-creates their parents.
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.
172
280
  */
173
- export const selectSubtreeEntries = (tree, subdir) => {
174
- const prefix = `${subdir.replace(/\/+$/, '')}/`;
175
- const entries = [];
176
- for (const node of tree) {
177
- if (node.type !== 'blob') {
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)) {
178
293
  continue;
179
294
  }
180
- if (!node.path.startsWith(prefix)) {
295
+ const path = repoPath.slice(prefix.length);
296
+ if (path === '') {
181
297
  continue;
182
298
  }
183
- const path = node.path.slice(prefix.length);
184
- if (node.mode === '120000') {
185
- entries.push({ kind: 'symlink', path, repoPath: node.path });
299
+ if (entry.type === '2') {
300
+ files.push({ kind: 'symlink', path, target: entry.linkname });
186
301
  }
187
- else {
188
- entries.push({
302
+ else if (entry.type === '0' || entry.type === '7') {
303
+ files.push({
189
304
  kind: 'file',
190
305
  path,
191
- repoPath: node.path,
192
- executable: node.mode === '100755',
306
+ bytes: entry.data,
307
+ executable: (entry.mode & 0o111) !== 0,
193
308
  });
194
309
  }
310
+ // Directories ('5') and any other node types are intentionally skipped.
195
311
  }
196
- return entries;
312
+ return files;
197
313
  };
198
- /**
199
- * Resolve a template to the exact set of files to write. Pins everything to a
200
- * single immutable commit: the ref is resolved to a commit sha, the tree is
201
- * read from that commit's tree, and every blob is later fetched by that same
202
- * commit so a push to the template repo mid-copy can't produce a mismatched
203
- * checkout.
204
- */
205
- export const resolveTemplate = async (template) => {
206
- const { owner, repo, ref, subdir } = template.source;
207
- const api = githubApiBase();
208
- const commit = parseCommit(await getJson(`${api}/repos/${owner}/${repo}/commits/${ref}`));
209
- const { truncated, tree } = parseTree(await getJson(`${api}/repos/${owner}/${repo}/git/trees/${commit.treeSha}?recursive=1`));
210
- if (truncated) {
211
- throw new Error(`GitHub returned a truncated file tree for ${owner}/${repo}; cannot reliably copy template "${template.id}".`);
212
- }
213
- const entries = selectSubtreeEntries(tree, subdir);
214
- if (entries.length === 0) {
215
- throw new Error(`Template subdirectory "${subdir}" was not found in ${owner}/${repo}@${ref}.`);
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
+ }
216
327
  }
217
- log.debug('bootstrap: resolved %d files for template "%s" at %s', entries.length, template.id, commit.commitSha);
218
- return { commitSha: commit.commitSha, entries };
328
+ return err instanceof Error ? err : new Error(String(err));
219
329
  };
220
- const rawUrl = (template, commitSha, repoPath) => `${githubRawBase()}/${template.source.owner}/${template.source.repo}/${commitSha}/${repoPath}`;
221
- /** Download a file's raw bytes, pinned to the resolved commit. */
222
- export const fetchFileBytes = async (template, commitSha, repoPath) => {
223
- const url = rawUrl(template, commitSha, repoPath);
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;
224
339
  try {
225
340
  const res = await axios.get(url, {
226
341
  responseType: 'arraybuffer',
227
- headers: rawHeaders(),
342
+ headers: downloadHeaders(),
343
+ timeout: 30000,
228
344
  });
229
- return Buffer.from(res.data);
345
+ gzipped = Buffer.from(res.data);
230
346
  }
231
347
  catch (err) {
232
348
  throw friendlyGithubError(err, url);
233
349
  }
234
- };
235
- /**
236
- * Read a symlink's target. In a git blob a symlink is stored as a regular file
237
- * whose contents are the (relative) link target, so the raw bytes are exactly
238
- * the string we pass to `symlink(2)`.
239
- */
240
- export const fetchSymlinkTarget = async (template, commitSha, repoPath) => {
241
- const bytes = await fetchFileBytes(template, commitSha, repoPath);
242
- return bytes.toString('utf8');
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;
243
364
  };
@@ -0,0 +1,22 @@
1
+ import chalk from 'chalk';
2
+ import { log } from '../log.js';
3
+ /**
4
+ * Print a one-line "this command is targeting <branch>" notice to **stderr** so
5
+ * the user can sanity-check they're acting on the branch they think they are —
6
+ * before a `status` / `plan` / `apply` / `env pull` does its work. This is the
7
+ * cheap guardrail that catches "I planned against the wrong branch" / "I pulled
8
+ * env from the wrong branch" before it bites.
9
+ *
10
+ * - Skipped for machine-readable output (`--output json|yaml`) so it never has
11
+ * to be reasoned about by a script; it's stderr-only regardless, keeping
12
+ * `--output table` stdout clean for piping too.
13
+ * - `verb` is the leading phrase, e.g. `'Planning against branch'` →
14
+ * `→ Planning against branch main (br-…)`.
15
+ */
16
+ export const announceTargetBranch = (props, branch, verb) => {
17
+ if (props.output === 'json' || props.output === 'yaml') {
18
+ return;
19
+ }
20
+ const suffix = branch.usedDefault ? chalk.dim(' · project default') : '';
21
+ log.info('%s %s %s %s%s', chalk.dim('→'), verb, chalk.cyan.bold(branch.branchName), chalk.dim(`(${branch.branchId})`), suffix);
22
+ };
@@ -40,6 +40,45 @@ export const branchIdFromProps = async (props) => {
40
40
  props.branchId = await getBranchIdFromProps(props);
41
41
  return props.branchId;
42
42
  };
43
+ export const resolveBranchRef = async (props) => {
44
+ const branch = 'branch' in props && typeof props.branch === 'string'
45
+ ? props.branch
46
+ : props.id;
47
+ const { data } = await props.apiClient.listProjectBranches({
48
+ projectId: props.projectId,
49
+ });
50
+ const branches = data.branches;
51
+ if (branch) {
52
+ const ref = branch.toString();
53
+ const found = looksLikeBranchId(ref)
54
+ ? branches.find((b) => b.id === ref)
55
+ : branches.find((b) => b.name === ref);
56
+ if (found) {
57
+ return {
58
+ branchId: found.id,
59
+ branchName: found.name ?? found.id,
60
+ usedDefault: false,
61
+ };
62
+ }
63
+ // A `br-…` id absent from the listing is still usable as an id (trust it like
64
+ // branchIdResolve does); only an unresolved *name* is a genuine error.
65
+ if (looksLikeBranchId(ref)) {
66
+ return { branchId: ref, branchName: ref, usedDefault: false };
67
+ }
68
+ throw new Error(`Branch ${ref} not found.\nAvailable branches: ${branches
69
+ .map((b) => b.name)
70
+ .join(', ')}`);
71
+ }
72
+ const defaultBranch = branches.find((b) => b.default);
73
+ if (!defaultBranch) {
74
+ throw new Error('No default branch found');
75
+ }
76
+ return {
77
+ branchId: defaultBranch.id,
78
+ branchName: defaultBranch.name ?? defaultBranch.id,
79
+ usedDefault: true,
80
+ };
81
+ };
43
82
  export const resolveSingleDatabase = async (props) => {
44
83
  const { data } = await props.apiClient.listProjectBranchDatabases(props.projectId, props.branchId);
45
84
  const databases = data.databases;
package/utils/esbuild.js CHANGED
@@ -59,7 +59,6 @@ const bundleViaModule = async (source, loadEsbuild) => {
59
59
  // output as a module without needing a `package.json` type marker alongside it.
60
60
  outfile: 'index.mjs',
61
61
  write: false,
62
- sourcemap: true,
63
62
  minify: true,
64
63
  format: 'esm',
65
64
  platform: 'node',
@@ -73,8 +72,8 @@ const bundleViaModule = async (source, loadEsbuild) => {
73
72
  throw new Error(`Failed to bundle function from ${source}. ${message(err)}`.trim());
74
73
  });
75
74
  const files = result.outputFiles ?? [];
76
- // write:false with one entry always yields index.mjs + index.mjs.map; an empty set
77
- // means the API contract changed under us — fail loud rather than ship an
75
+ // write:false with one entry always yields index.mjs (no source map we don't emit one);
76
+ // an empty set means the API contract changed under us — fail loud rather than ship an
78
77
  // empty archive.
79
78
  if (files.length === 0) {
80
79
  throw new Error(`Failed to bundle function from ${source}. esbuild produced no output.`);
@@ -123,7 +122,6 @@ const bundleViaBinary = async (source) => {
123
122
  source,
124
123
  '--bundle',
125
124
  `--outfile=${outfile}`,
126
- '--sourcemap',
127
125
  '--minify',
128
126
  '--format=esm',
129
127
  '--platform=node',
@@ -133,9 +131,10 @@ const bundleViaBinary = async (source) => {
133
131
  if (code !== 0) {
134
132
  throw new Error(`Failed to bundle function from ${source}. ${stderr.trim()}`.trim());
135
133
  }
134
+ // No `--sourcemap`: the Functions runtime has no source-map support, so an uploaded
135
+ // `index.mjs.map` is never consumed — emitting it only inflated the archive.
136
136
  return {
137
137
  'index.mjs': new Uint8Array(readFileSync(outfile)),
138
- 'index.mjs.map': new Uint8Array(readFileSync(`${outfile}.map`)),
139
138
  };
140
139
  }
141
140
  finally {
package/utils/zip.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { zipSync } from 'fflate';
2
- // Zip the esbuild output (index.mjs + index.mjs.map) into the archive the Functions
3
- // deploy endpoint expects. Compression level 6 matches the previous bundler.
2
+ // Zip the esbuild output (index.mjs) into the archive the Functions deploy endpoint
3
+ // expects. Compression level 6 matches the previous bundler.
4
4
  export const zipBundle = (entries) => zipSync(entries, { level: 6 });