neonctl 2.24.2 → 2.25.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.
@@ -349,11 +349,11 @@ The organization ID has been saved in ${props.contextFile}
349
349
 
350
350
  If you'd like to change the default organization later, use
351
351
 
352
- neonctl set-context --org-id <org_id>
352
+ neonctl link --org-id <org_id>
353
353
 
354
354
  Or to clear the context file and forget the default organization
355
355
 
356
- neonctl set-context
356
+ neonctl link --clear
357
357
 
358
358
  `);
359
359
  }
@@ -1,6 +1,7 @@
1
1
  import { applyContext } from '../context.js';
2
+ import { log } from '../log.js';
2
3
  export const command = 'set-context';
3
- export const describe = 'Set the current context';
4
+ export const describe = 'Deprecated: use `neonctl link`. Set the .neon context (raw write).';
4
5
  export const builder = (argv) => argv.usage('$0 set-context [options]').options({
5
6
  'project-id': {
6
7
  describe: 'Project ID',
@@ -16,6 +17,9 @@ export const builder = (argv) => argv.usage('$0 set-context [options]').options(
16
17
  },
17
18
  });
18
19
  export const handler = (props) => {
20
+ log.warning('`neonctl set-context` is deprecated and will be removed in a future release. ' +
21
+ 'Use `neonctl link` instead — it verifies inputs and infers the org for you ' +
22
+ '(or `neonctl link --no-checks` for the same write-without-checks behavior).');
19
23
  const context = {
20
24
  projectId: props.projectId,
21
25
  orgId: props.orgId,
package/config_format.js CHANGED
@@ -54,13 +54,19 @@ const toPreviewView = (preview) => {
54
54
  if (!preview)
55
55
  return undefined;
56
56
  const out = {};
57
- if (preview.aiGatewayEnabled)
58
- out.aiGateway = true;
59
57
  if (preview.functions.length > 0) {
60
58
  out.functions = Object.fromEntries(preview.functions.map((fn) => [fn.slug, { name: fn.name }]));
61
59
  }
62
60
  if (preview.buckets.length > 0) {
63
61
  out.buckets = Object.fromEntries(preview.buckets.map((b) => [b.name, { access: b.access }]));
64
62
  }
63
+ if (preview.credentials.length > 0) {
64
+ out.credentials = preview.credentials.map((c) => ({
65
+ id: c.tokenIdShort,
66
+ ...(c.name ? { name: c.name } : {}),
67
+ scopes: c.scopes,
68
+ ...(c.lastUsedAt ? { lastUsedAt: c.lastUsedAt } : {}),
69
+ }));
70
+ }
65
71
  return Object.keys(out).length > 0 ? out : undefined;
66
72
  };
package/context.js CHANGED
@@ -2,6 +2,12 @@ import { accessSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, normalize, resolve } from 'node:path';
4
4
  import { log } from './log.js';
5
+ /**
6
+ * The branch pinned in a context, reading the current `branch` field and
7
+ * falling back to the legacy `branchId` so pre-migration `.neon` files keep
8
+ * working.
9
+ */
10
+ export const contextBranch = (context) => context.branch ?? context.branchId;
5
11
  const CONTEXT_FILE = '.neon';
6
12
  const GITIGNORE_FILE = '.gitignore';
7
13
  const wrapWithContextFile = (dir) => resolve(dir, CONTEXT_FILE);
@@ -48,7 +54,10 @@ export const readContextFile = (file) => {
48
54
  }
49
55
  };
50
56
  export const enrichFromContext = (args) => {
51
- if (args._[0] === 'set-context' || args._[0] === 'link') {
57
+ // `link` and the deprecated `set-context` manage the context file themselves
58
+ // and must see the raw flags rather than values pre-filled from an existing
59
+ // `.neon`, so skip enrichment for both.
60
+ if (args._[0] === 'link' || args._[0] === 'set-context') {
52
61
  return;
53
62
  }
54
63
  const context = readContextFile(args.contextFile);
@@ -62,16 +71,17 @@ export const enrichFromContext = (args) => {
62
71
  !args.id &&
63
72
  !args.name &&
64
73
  context.projectId === args.projectId) {
65
- args.branch = context.branchId;
74
+ args.branch = contextBranch(context);
66
75
  }
67
76
  };
68
77
  export const updateContextFile = (file, context) => {
69
78
  writeFileSync(file, JSON.stringify(context, null, 2));
70
79
  };
71
80
  /**
72
- * Shared primitive used by `set-context`, `link`, and `checkout` to persist
73
- * context. Mirrors the destructive write semantics of `updateContextFile` —
74
- * any field not present in `context` is dropped from the file.
81
+ * Shared primitive used by `link`, the deprecated `set-context`, and `checkout`
82
+ * to persist context. Mirrors the destructive write semantics of
83
+ * `updateContextFile` — any field not present in `context` is dropped from the
84
+ * file.
75
85
  *
76
86
  * `.gitignore` scaffolding only happens when the context file is being
77
87
  * *created* (it didn't exist before this write). On updates to an existing
@@ -86,6 +96,24 @@ export const applyContext = (file, context) => {
86
96
  ensureGitignored(file);
87
97
  }
88
98
  };
99
+ /**
100
+ * Low-level writer for callers that already hold the resolved identifiers and
101
+ * just need to record them — e.g. `init` or `projects create`, which create a
102
+ * project and want to link it without the resolution, verification, prompting,
103
+ * or env-pull that `link` performs.
104
+ *
105
+ * Unlike the loose {@link applyContext}, this enforces at the type level that
106
+ * `orgId` and `projectId` are present, so the `.neon` file never ends up with a
107
+ * dangling project that has no org. The branch stays optional. It writes through
108
+ * {@link applyContext}, so the same `.gitignore` scaffolding applies.
109
+ */
110
+ export const setContext = (file, context) => {
111
+ applyContext(file, {
112
+ orgId: context.orgId,
113
+ projectId: context.projectId,
114
+ branch: context.branch,
115
+ });
116
+ };
89
117
  /**
90
118
  * Make sure the `.gitignore` next to `file` lists the file's basename
91
119
  * (currently always `.neon`). Creates the `.gitignore` if it doesn't exist,
package/dev/env.js CHANGED
@@ -179,6 +179,7 @@ const fetchAndProject = async (config, ctx) => {
179
179
  projectId: ctx.projectId,
180
180
  branchId: ctx.branchId,
181
181
  ...apiOptions(ctx),
182
+ ...(ctx.env ? { env: ctx.env } : {}),
182
183
  });
183
184
  return toEntries(env);
184
185
  };
@@ -187,6 +188,35 @@ const fetchAndProject = async (config, ctx) => {
187
188
  * root. Returns `null` when there is none (the common "no config" case), and
188
189
  * surfaces real load errors (e.g. a syntax error in an existing file).
189
190
  */
191
+ /**
192
+ * Substrings that mark a module-resolution failure while loading `neon.ts` —
193
+ * almost always because the project's dependencies aren't installed yet (the
194
+ * config imports `@neondatabase/config` & friends). Deliberately specific:
195
+ * the generic "…or a missing dependency…" hint the loader always appends is
196
+ * NOT in here, so a real syntax/runtime error doesn't get mislabeled.
197
+ */
198
+ const MISSING_DEPENDENCY_HINTS = [
199
+ 'cannot find module',
200
+ 'cannot find package',
201
+ 'err_module_not_found',
202
+ 'failed to resolve',
203
+ 'could not resolve',
204
+ 'module not found',
205
+ ];
206
+ /** Flatten an error and its `cause` chain to one lowercased string for matching. */
207
+ const errorChainText = (err) => {
208
+ const parts = [];
209
+ let current = err;
210
+ for (let depth = 0; current instanceof Error && depth < 6; depth++) {
211
+ parts.push(current.message);
212
+ current = current.cause;
213
+ }
214
+ return parts.join('\n').toLowerCase();
215
+ };
216
+ const looksLikeMissingDependency = (err) => {
217
+ const text = errorChainText(err);
218
+ return MISSING_DEPENDENCY_HINTS.some((hint) => text.includes(hint));
219
+ };
190
220
  const loadNeonConfig = async (cwd) => {
191
221
  try {
192
222
  const { config } = await loadConfigFromFile({ cwd });
@@ -197,6 +227,14 @@ const loadNeonConfig = async (cwd) => {
197
227
  if (/Could not find a Neon config file/i.test(message)) {
198
228
  return null;
199
229
  }
230
+ // A neon.ts that imports a package which isn't installed fails here with a
231
+ // cryptic "Cannot find module …". Turn that into the actionable thing to do.
232
+ if (looksLikeMissingDependency(err)) {
233
+ throw new Error('Could not load neon.ts: a package it imports is not installed. ' +
234
+ 'Did you run `npm install`? Install your dependencies ' +
235
+ '(npm / pnpm / yarn / bun), then try again.\n' +
236
+ `Original error: ${message}`);
237
+ }
200
238
  throw err;
201
239
  }
202
240
  };
package/dev/functions.js CHANGED
@@ -32,7 +32,6 @@ export const resolveFunctionsFromConfig = async (cwd, branchName) => {
32
32
  slug: fn.slug,
33
33
  name: fn.name,
34
34
  source,
35
- portless: fn.dev?.portless === true,
36
35
  ...(devPort(fn.dev) !== undefined
37
36
  ? { port: devPort(fn.dev) }
38
37
  : {}),
@@ -42,9 +41,8 @@ export const resolveFunctionsFromConfig = async (cwd, branchName) => {
42
41
  return { configPath, functions: planned };
43
42
  };
44
43
  /**
45
- * Read the `port` off a {@link FunctionDevConfig}. The discriminated union guarantees a
46
- * `port` is present whenever `portless` is true, so this is `undefined` only for the
47
- * non-portless, port-omitted case (the supervisor then searches for a free port).
44
+ * Read the `port` off a {@link FunctionDevConfig}. `undefined` when no `dev.port` is set
45
+ * (the supervisor then searches for a free port).
48
46
  */
49
47
  const devPort = (dev) => dev?.port;
50
48
  /**
package/dev/runtime.js CHANGED
@@ -100,8 +100,8 @@ export const startRuntime = async ({ source, port, hostname, }) => {
100
100
  * Build a {@link PortSelection} from the environment. Precedence:
101
101
  * 1. `NEON_DEV_PORT` -> explicit bind (crash if taken). Set by `neon dev` from an
102
102
  * explicit `--port` / `dev.port`.
103
- * 2. `PORT` -> explicit bind. This is what `portless` injects (and what a bare
104
- * `PORT=3000 neon dev` sets), so the runtime binds the port chosen for it.
103
+ * 2. `PORT` -> explicit bind. A bare `PORT=3000 neon dev` sets this, so the
104
+ * runtime binds the port chosen for it.
105
105
  * 3. otherwise -> search upward from `NEON_DEV_PORT_BASE` (or the default base).
106
106
  */
107
107
  export const portSelectionFromEnv = (env) => {
package/index.js CHANGED
@@ -40,6 +40,7 @@ const NO_SUBCOMMANDS_VERBS = [
40
40
  'init',
41
41
  'dev',
42
42
  'deploy',
43
+ 'bootstrap',
43
44
  // aliases
44
45
  ];
45
46
  let builder = yargs(hideBin(process.argv));
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.24.2",
8
+ "version": "2.25.0",
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.4.2",
63
- "@neondatabase/config-runtime": "0.4.2",
64
- "@neondatabase/env": "0.3.2",
62
+ "@neondatabase/config": "0.7.0",
63
+ "@neondatabase/config-runtime": "0.7.0",
64
+ "@neondatabase/env": "0.5.0",
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.14.0",
74
+ "neon-init": "0.15.0",
75
75
  "open": "10.1.0",
76
76
  "openid-client": "6.8.1",
77
77
  "pg-protocol": "^1.14.0",
package/storage_api.js CHANGED
@@ -112,3 +112,37 @@ export const deleteProjectBranchBucketObjectsByPrefix = (apiClient, { projectId,
112
112
  format: 'json',
113
113
  secure: true,
114
114
  });
115
+ /**
116
+ * Request a presigned PUT URL for uploading an object to a bucket on a branch.
117
+ *
118
+ * Returns the URL, the headers that must accompany the PUT for the signature to
119
+ * verify, and the expiry. The actual upload (a `PUT` to the returned `url` with
120
+ * the returned `headers` and the file stream) is performed by the caller, NOT
121
+ * through this api-client, since it targets the branch S3 data-plane endpoint
122
+ * rather than the console API. No SigV4 or credential handling happens here.
123
+ *
124
+ * The object key may contain `/`; it is percent-encoded into a single path
125
+ * segment so nested keys are routed to the `{object_key}` parameter.
126
+ *
127
+ * This targets the unified presign endpoint, passing `operation: "upload"` to
128
+ * request a presigned PUT. (The same endpoint also serves `download`, but that
129
+ * path is API-only and has no neonctl command.)
130
+ *
131
+ * @request POST /projects/{project_id}/branches/{branch_id}/buckets/{bucket_name}/objects/{object_key}/presign
132
+ */
133
+ export const presignUpload = (apiClient, { projectId, branchId, bucketName, objectKey, contentType, expiresInSeconds, }) => {
134
+ const body = { operation: 'upload' };
135
+ if (contentType !== undefined) {
136
+ body.content_type = contentType;
137
+ }
138
+ if (expiresInSeconds !== undefined) {
139
+ body.expires_in_seconds = expiresInSeconds;
140
+ }
141
+ return apiClient.request({
142
+ path: `${bucketPath(projectId, branchId, bucketName)}/objects/${encodeURIComponent(objectKey)}/presign`,
143
+ method: 'POST',
144
+ body,
145
+ format: 'json',
146
+ secure: true,
147
+ });
148
+ };
@@ -0,0 +1,243 @@
1
+ import axios, { isAxiosError } from 'axios';
2
+ import YAML from 'yaml';
3
+ import { log } from '../log.js';
4
+ /** Hardcoded fallback used when the remote manifest cannot be fetched. */
5
+ export const FALLBACK_TEMPLATES = [
6
+ {
7
+ id: 'hono',
8
+ title: 'Hono API (Drizzle, Neon Postgres) on Neon Functions',
9
+ description: 'A Hono API using Drizzle ORM and Neon Postgres, ready to deploy as a Neon Function.',
10
+ services: ['Postgres', 'Functions'],
11
+ source: {
12
+ owner: 'neondatabase',
13
+ repo: 'examples',
14
+ ref: 'main',
15
+ subdir: 'with-hono',
16
+ },
17
+ },
18
+ ];
19
+ export const templateIds = (templates) => templates.map((t) => t.id).join(', ');
20
+ 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
+ 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 = () => ({
34
+ 'User-Agent': 'neonctl',
35
+ ...(githubToken() ? { Authorization: `Bearer ${githubToken()}` } : {}),
36
+ });
37
+ const isRecord = (value) => typeof value === 'object' && value !== null;
38
+ /**
39
+ * Normalize a manifest entry's `services` into a clean string list. Tolerant by
40
+ * design: a missing or non-array value yields `undefined`, and non-string items
41
+ * are dropped, so a malformed `services` never sinks an otherwise-valid
42
+ * template (it just renders without its badge).
43
+ */
44
+ const parseServices = (value) => {
45
+ if (!Array.isArray(value)) {
46
+ return undefined;
47
+ }
48
+ const services = value.filter((item) => typeof item === 'string' && item.trim() !== '');
49
+ return services.length > 0 ? services : undefined;
50
+ };
51
+ // ---------------------------------------------------------------------------
52
+ // Remote template manifest
53
+ // ---------------------------------------------------------------------------
54
+ const manifestUrl = () => process.env.NEON_BOOTSTRAP_MANIFEST_URL ??
55
+ `${githubRawBase()}/neondatabase/examples/main/bootstrap.yaml`;
56
+ export const parseManifest = (text) => {
57
+ const data = YAML.parse(text);
58
+ if (!isRecord(data) || !Array.isArray(data.templates)) {
59
+ throw new Error('Invalid bootstrap manifest: missing "templates" array.');
60
+ }
61
+ const templates = [];
62
+ for (let i = 0; i < data.templates.length; i++) {
63
+ const item = data.templates[i];
64
+ if (!isRecord(item) ||
65
+ typeof item.id !== 'string' ||
66
+ typeof item.title !== 'string' ||
67
+ typeof item.description !== 'string' ||
68
+ !isRecord(item.source) ||
69
+ typeof item.source.owner !== 'string' ||
70
+ typeof item.source.repo !== 'string' ||
71
+ typeof item.source.ref !== 'string' ||
72
+ typeof item.source.subdir !== 'string') {
73
+ log.warning('bootstrap: skipping malformed template entry at index %d in manifest.', i);
74
+ continue;
75
+ }
76
+ const services = parseServices(item.services);
77
+ templates.push({
78
+ id: item.id,
79
+ title: item.title,
80
+ description: item.description,
81
+ ...(services ? { services } : {}),
82
+ source: {
83
+ owner: item.source.owner,
84
+ repo: item.source.repo,
85
+ ref: item.source.ref,
86
+ subdir: item.source.subdir,
87
+ },
88
+ });
89
+ }
90
+ return templates;
91
+ };
92
+ /**
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.
96
+ */
97
+ 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;
109
+ }
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
+ }
116
+ };
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 };
129
+ };
130
+ const parseTree = (data) => {
131
+ if (!isRecord(data) || !Array.isArray(data.tree)) {
132
+ throw malformed('the template file tree');
133
+ }
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 });
141
+ }
142
+ }
143
+ return { truncated: data.truncated === true, tree };
144
+ };
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.`);
150
+ }
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.');
154
+ }
155
+ }
156
+ return err instanceof Error ? err : new Error(String(err));
157
+ };
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);
165
+ }
166
+ };
167
+ /**
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.
172
+ */
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') {
178
+ continue;
179
+ }
180
+ if (!node.path.startsWith(prefix)) {
181
+ continue;
182
+ }
183
+ const path = node.path.slice(prefix.length);
184
+ if (node.mode === '120000') {
185
+ entries.push({ kind: 'symlink', path, repoPath: node.path });
186
+ }
187
+ else {
188
+ entries.push({
189
+ kind: 'file',
190
+ path,
191
+ repoPath: node.path,
192
+ executable: node.mode === '100755',
193
+ });
194
+ }
195
+ }
196
+ return entries;
197
+ };
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}.`);
216
+ }
217
+ log.debug('bootstrap: resolved %d files for template "%s" at %s', entries.length, template.id, commit.commitSha);
218
+ return { commitSha: commit.commitSha, entries };
219
+ };
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);
224
+ try {
225
+ const res = await axios.get(url, {
226
+ responseType: 'arraybuffer',
227
+ headers: rawHeaders(),
228
+ });
229
+ return Buffer.from(res.data);
230
+ }
231
+ catch (err) {
232
+ throw friendlyGithubError(err, url);
233
+ }
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');
243
+ };
package/utils/esbuild.js CHANGED
@@ -6,6 +6,12 @@ import which from 'which';
6
6
  const NOT_FOUND = 'esbuild not found. neonctl ships esbuild for most platforms; if you see ' +
7
7
  'this, install esbuild and ensure it is on your PATH (e.g. `npm i -g ' +
8
8
  'esbuild`), or set NEON_ESBUILD_PATH to an esbuild binary.';
9
+ // Prepended to the ESM bundle. Bundled dependencies are frequently CommonJS, but an ESM
10
+ // output (`--format=esm`) has no `require` / `__filename` / `__dirname` in scope — so any
11
+ // bundled CJS code that calls `require(...)` would fail at load with
12
+ // `Dynamic require of "x" is not supported`. Re-create those globals via `createRequire`
13
+ // so CJS and ESM dependencies coexist in the single `index.mjs`.
14
+ const ESM_CJS_INTEROP_BANNER = "import{createRequire as ___cr}from'module';import{fileURLToPath as ___f}from'url';import{dirname as ___d}from'path';const require=___cr(import.meta.url);const __filename=___f(import.meta.url);const __dirname=___d(__filename);";
9
15
  const defaultDeps = {
10
16
  // @yao-pkg/pkg defines process.pkg inside the packaged binary.
11
17
  isPackaged: () => process.pkg !== undefined,
@@ -57,7 +63,10 @@ const bundleViaModule = async (source, loadEsbuild) => {
57
63
  minify: true,
58
64
  format: 'esm',
59
65
  platform: 'node',
60
- packages: 'external',
66
+ // Bundle dependencies into the entry so the deployed archive is self-contained (the
67
+ // Functions runtime has no node_modules). Node built-ins stay external on
68
+ // platform:'node'. The banner re-creates require/__filename/__dirname for bundled CJS.
69
+ banner: { js: ESM_CJS_INTEROP_BANNER },
61
70
  logLevel: 'silent',
62
71
  })
63
72
  .catch((err) => {
@@ -118,7 +127,7 @@ const bundleViaBinary = async (source) => {
118
127
  '--minify',
119
128
  '--format=esm',
120
129
  '--platform=node',
121
- '--packages=external',
130
+ `--banner:js=${ESM_CJS_INTEROP_BANNER}`,
122
131
  '--log-level=error',
123
132
  ]);
124
133
  if (code !== 0) {