neonctl 2.26.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 +7 -0
- package/commands/bootstrap.js +18 -31
- package/commands/checkout.js +9 -1
- package/commands/config.js +15 -4
- package/commands/dev.js +2 -3
- package/commands/env.js +13 -4
- package/commands/init.js +30 -4
- package/package.json +2 -2
- package/utils/bootstrap.js +247 -126
- package/utils/branch_notice.js +22 -0
- package/utils/enrichers.js +39 -0
- package/utils/esbuild.js +4 -5
- package/utils/zip.js +2 -2
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;
|
package/commands/bootstrap.js
CHANGED
|
@@ -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,
|
|
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
|
|
105
|
-
* uses as a dim
|
|
106
|
-
* styled with chalk.dim only
|
|
107
|
-
* cyan/underline `prompts` paints over the focused row
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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(
|
|
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
|
|
209
|
+
const files = await downloadTemplate(template);
|
|
211
210
|
mkdirSync(targetDir, { recursive: true });
|
|
212
|
-
log.info('Scaffolding %d files into %s…',
|
|
213
|
-
|
|
214
|
-
const dest = join(targetDir,
|
|
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 (
|
|
217
|
-
|
|
218
|
-
writeSymlink(dest, target);
|
|
215
|
+
if (file.kind === 'symlink') {
|
|
216
|
+
writeSymlink(dest, file.target);
|
|
219
217
|
}
|
|
220
218
|
else {
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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();
|
package/commands/checkout.js
CHANGED
|
@@ -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
|
package/commands/config.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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` (
|
|
484
|
-
// it as ESM directly, so no `package.json` `"type": "module"` marker is needed
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { interactiveInit, orchestrate } 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
4
|
export const command = 'init';
|
|
@@ -11,6 +11,10 @@ export const builder = (yargs) => yargs
|
|
|
11
11
|
alias: 'a',
|
|
12
12
|
type: 'string',
|
|
13
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.',
|
|
14
18
|
})
|
|
15
19
|
.option('skip-neon-auth', {
|
|
16
20
|
type: 'boolean',
|
|
@@ -30,14 +34,36 @@ export const builder = (yargs) => yargs
|
|
|
30
34
|
.strict(false);
|
|
31
35
|
export const handler = async (argv) => {
|
|
32
36
|
try {
|
|
33
|
-
if
|
|
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) {
|
|
34
60
|
const result = await orchestrate({
|
|
35
|
-
agent
|
|
61
|
+
agent,
|
|
36
62
|
skipNeonAuth: argv.skipNeonAuth,
|
|
37
63
|
skipMigrations: argv.skipMigrations,
|
|
38
64
|
preview: argv.preview,
|
|
39
65
|
});
|
|
40
|
-
log.info(JSON.stringify(result, null, 2));
|
|
66
|
+
log.info(JSON.stringify(enrichResponse(result), null, 2));
|
|
41
67
|
}
|
|
42
68
|
else {
|
|
43
69
|
await interactiveInit({ preview: argv.preview });
|
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.26.
|
|
8
|
+
"version": "2.26.2",
|
|
9
9
|
"description": "CLI tool for NeonDB Cloud management",
|
|
10
10
|
"main": "index.js",
|
|
11
11
|
"author": "NeonDB",
|
|
@@ -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.16.
|
|
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",
|
package/utils/bootstrap.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
const readTarOctal = (buf, offset, length) => {
|
|
164
|
+
const text = readTarString(buf, offset, length).trim();
|
|
165
|
+
if (text === '') {
|
|
166
|
+
return 0;
|
|
133
167
|
}
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
177
|
+
return true;
|
|
144
178
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
204
|
+
return records;
|
|
157
205
|
};
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
169
|
-
* `subdir/` prefix stripped from each
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
174
|
-
const prefix = `${subdir.replace(
|
|
175
|
-
const
|
|
176
|
-
for (const
|
|
177
|
-
|
|
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
|
-
|
|
295
|
+
const path = repoPath.slice(prefix.length);
|
|
296
|
+
if (path === '') {
|
|
181
297
|
continue;
|
|
182
298
|
}
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
302
|
+
else if (entry.type === '0' || entry.type === '7') {
|
|
303
|
+
files.push({
|
|
189
304
|
kind: 'file',
|
|
190
305
|
path,
|
|
191
|
-
|
|
192
|
-
executable:
|
|
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
|
|
312
|
+
return files;
|
|
197
313
|
};
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
return { commitSha: commit.commitSha, entries };
|
|
328
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
219
329
|
};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
342
|
+
headers: downloadHeaders(),
|
|
343
|
+
timeout: 30000,
|
|
228
344
|
});
|
|
229
|
-
|
|
345
|
+
gzipped = Buffer.from(res.data);
|
|
230
346
|
}
|
|
231
347
|
catch (err) {
|
|
232
348
|
throw friendlyGithubError(err, url);
|
|
233
349
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
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
|
+
};
|
package/utils/enrichers.js
CHANGED
|
@@ -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
|
|
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
|
|
3
|
-
//
|
|
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 });
|