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.
- package/README.md +126 -41
- package/commands/auth.js +9 -0
- package/commands/bootstrap.js +603 -0
- package/commands/branches.js +6 -4
- package/commands/bucket.js +118 -5
- package/commands/checkout.js +25 -8
- package/commands/config.js +98 -10
- package/commands/deploy.js +2 -1
- package/commands/dev.js +11 -57
- package/commands/env.js +9 -2
- package/commands/functions.js +53 -5
- package/commands/index.js +2 -0
- package/commands/link.js +441 -108
- package/commands/projects.js +2 -2
- package/commands/set_context.js +5 -1
- package/config_format.js +8 -2
- package/context.js +33 -5
- package/dev/env.js +38 -0
- package/dev/functions.js +2 -4
- package/dev/runtime.js +2 -2
- package/index.js +1 -0
- package/package.json +5 -5
- package/storage_api.js +34 -0
- package/utils/bootstrap.js +243 -0
- package/utils/esbuild.js +11 -2
package/commands/projects.js
CHANGED
|
@@ -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
|
|
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
|
|
356
|
+
neonctl link --clear
|
|
357
357
|
|
|
358
358
|
`);
|
|
359
359
|
}
|
package/commands/set_context.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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`,
|
|
73
|
-
* context. Mirrors the destructive write semantics of
|
|
74
|
-
* any field not present in `context` is dropped from the
|
|
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}.
|
|
46
|
-
*
|
|
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.
|
|
104
|
-
*
|
|
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
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.
|
|
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.
|
|
63
|
-
"@neondatabase/config-runtime": "0.
|
|
64
|
-
"@neondatabase/env": "0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
`--banner:js=${ESM_CJS_INTEROP_BANNER}`,
|
|
122
131
|
'--log-level=error',
|
|
123
132
|
]);
|
|
124
133
|
if (code !== 0) {
|