neon 6.3.0 → 6.4.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 +31 -1
- package/dist/commands/auth.js +2 -1
- package/dist/commands/claim.js +1 -1
- package/dist/commands/config.js +204 -24
- package/dist/config_edit.js +237 -0
- package/dist/config_source.js +270 -0
- package/dist/config_template.js +10 -2
- package/dist/context.js +6 -1
- package/dist/utils/enrichers.js +2 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -665,7 +665,37 @@ export default async function hello(): Promise<Response> {
|
|
|
665
665
|
}
|
|
666
666
|
```
|
|
667
667
|
|
|
668
|
-
An existing `neon.ts` (or `hello.ts`) is never overwritten.
|
|
668
|
+
An existing `neon.ts` (or `hello.ts`) is never overwritten. To change one, use `config add`.
|
|
669
|
+
|
|
670
|
+
### Editing a `neon.ts` (`config add`)
|
|
671
|
+
|
|
672
|
+
`neon config add` declares one service in the `neon.ts` you already have and leaves the rest of the file (comments, the `branch` closure, formatting) as it was. It is purely local — no auth, no API calls — and creates the starter `neon.ts` first when there is none.
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
neon config add auth # auth: true
|
|
676
|
+
neon config add data-api # dataApi: true, and auth: true (the default provider needs it)
|
|
677
|
+
neon config add ai-gateway # aiGateway: true
|
|
678
|
+
neon config add function sendemail # functions.sendemail + functions/sendemail.ts
|
|
679
|
+
neon config add function sendemail --name "Send email" --source ./src/send.ts
|
|
680
|
+
neon config add bucket uploads # buckets.uploads = { access: 'private' }
|
|
681
|
+
neon config add bucket assets --access public_read
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
Each command prints what it changed and the next step:
|
|
685
|
+
|
|
686
|
+
```
|
|
687
|
+
$ neon config add function sendemail
|
|
688
|
+
INFO: Created functions/sendemail.ts.
|
|
689
|
+
INFO: Updated neon.ts: added functions.sendemail.
|
|
690
|
+
INFO: Next: `neon dev` to run it locally, `neon config apply` to deploy.
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
- **Functions** take a slug of 1-20 lowercase letters and digits, the same rule `plan` enforces. The handler file is written first and an existing file is never overwritten, so pointing `--source` at code you already have works. A `.js` `neon.ts` gets a `.js` handler.
|
|
694
|
+
- Enabling a service that `neon.ts` already enables changes nothing and exits 0. Adding a function slug or bucket name that already exists is an error.
|
|
695
|
+
- `--config <path>` edits a specific file; otherwise the closest `neon.ts` above the current directory is used, and the suggested `plan` / `apply` commands repeat the flag.
|
|
696
|
+
- `--no-install` applies when the command had to create `neon.ts`; editing an existing file never installs anything.
|
|
697
|
+
|
|
698
|
+
`config add` edits the file as text and checks its own output: it re-applies the edit to the result and fails if that is not a no-op. When the file is in a shape it cannot edit safely (a single-line `defineConfig({ auth: true })`, a service declared under `preview`, a value that is not a literal), it changes nothing and prints the exact lines to paste.
|
|
669
699
|
|
|
670
700
|
Four sub-commands plus two top-level aliases drive it:
|
|
671
701
|
|
package/dist/commands/auth.js
CHANGED
|
@@ -5,7 +5,7 @@ import { log } from "../log.js";
|
|
|
5
5
|
import { getApiClient, isNeonApiError } from "../api.js";
|
|
6
6
|
import { setAuthContext } from "../auth_context.js";
|
|
7
7
|
import { a as isOwnedCredentialPath } from "../_chunks/paths-DMq0Lt7a.js";
|
|
8
|
-
import { currentContextFile, isAskCommand, isClaimCommand, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
|
|
8
|
+
import { currentContextFile, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile } from "../context.js";
|
|
9
9
|
import { _ as upsertProfile, d as newProfileLocation, g as selectProfileName, h as resolveProfile, i as assertValidProfileName, l as locationForName, m as readProfiles, n as KEYRING_CREDENTIALS, p as profilesUsingPath, r as assertProfilesUsable, s as isKeyringPointer, t as DEFAULT_PROFILE, u as locationOf } from "../_chunks/profiles-CvnFEQyd.js";
|
|
10
10
|
import { extendTokenSet } from "../utils/auth.js";
|
|
11
11
|
import { AuthRefreshError, auth, classifyRefreshFailure, refreshToken } from "../auth.js";
|
|
@@ -296,6 +296,7 @@ const ensureAuth = async (props) => {
|
|
|
296
296
|
if (props._.length === 0 || props.help) return;
|
|
297
297
|
if (isCurrentBranchProbe(props)) return;
|
|
298
298
|
if (isConfigInit(props)) return;
|
|
299
|
+
if (isConfigAdd(props)) return;
|
|
299
300
|
if (isProfileCommand(props)) return;
|
|
300
301
|
if (isGitLocalCommand(props)) return;
|
|
301
302
|
if (isUnfollowedGitHookSync(props)) return;
|
package/dist/commands/claim.js
CHANGED
|
@@ -6,8 +6,8 @@ import { applyContext, contextBranch, readContextFile } from "../context.js";
|
|
|
6
6
|
import { t as credentialInputs } from "../_chunks/auth_selection-pitMFzDe.js";
|
|
7
7
|
import { ClaimableClient, ClaimableServiceError } from "../claimable/api.js";
|
|
8
8
|
import { assertionHasExpired, listClaimableCredentials, readClaimableCredentials, readLinkedClaimableCredentials, removeClaimableCredentials, writeClaimableCredentials } from "../claimable/state.js";
|
|
9
|
-
import { claimableDataApiCreateBody, declaredNeonServices } from "../config_services.js";
|
|
10
9
|
import { NEON_SERVICES, deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
|
|
10
|
+
import { claimableDataApiCreateBody, declaredNeonServices } from "../config_services.js";
|
|
11
11
|
import { resolveEnvFilePath } from "../env_file.js";
|
|
12
12
|
import { writer } from "../writer.js";
|
|
13
13
|
import { noPassthrough } from "../utils/flags.js";
|
package/dist/commands/config.js
CHANGED
|
@@ -4,10 +4,11 @@ import { log } from "../log.js";
|
|
|
4
4
|
import { getApiClient } from "../api.js";
|
|
5
5
|
import { contextBranch, ensureDirectoryGitignored, readContextFile } from "../context.js";
|
|
6
6
|
import { getCliName } from "../utils/cli_name.js";
|
|
7
|
+
import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
|
|
8
|
+
import { CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderFunctionSource, renderNeonConfig, renderNeonConfigFromView } from "../config_template.js";
|
|
9
|
+
import { editNeonConfig } from "../config_edit.js";
|
|
7
10
|
import { toNeonConfigView } from "../config_format.js";
|
|
8
11
|
import { declaredNeonServices } from "../config_services.js";
|
|
9
|
-
import { deprecatedServiceMessage, parseServices, servicesFlagValue, servicesOption } from "../neon_services.js";
|
|
10
|
-
import { CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, FUNCTION_FILENAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView } from "../config_template.js";
|
|
11
12
|
import { loadEnvFileIntoProcess } from "../env_file.js";
|
|
12
13
|
import { writer } from "../writer.js";
|
|
13
14
|
import { fillSingleProject, resolveBranchRef } from "../utils/enrichers.js";
|
|
@@ -21,14 +22,16 @@ import { resolveHookEnv, runDeployAfterHook, runDeployBeforeHook } from "../util
|
|
|
21
22
|
import { pickServicesInteractively } from "../utils/service_picker.js";
|
|
22
23
|
import { zipBundle } from "../utils/zip.js";
|
|
23
24
|
import { autoPullEnvAfterPin } from "./env.js";
|
|
24
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
-
import { dirname, join } from "node:path";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
26
28
|
import chalk from "chalk";
|
|
27
29
|
import { packagesToStage, previewGaWarningForConfig, resolveConfig } from "@neon/config";
|
|
28
30
|
import { PushConflictError, apply, assertZipWithinLimits, bundleAsIs, createBranch, describeNativeFinding, enforceLimits, findUndeclaredNativePackages, inspect, isPartialBranchCreateError, loadConfigFromFile as loadConfigFromFile$1, plan, resolveEsbuildEntry, traceNativePackages, zipFunctionBundle } from "@neon/config-runtime";
|
|
29
31
|
//#region src/commands/config.ts
|
|
30
32
|
var config_exports = /* @__PURE__ */ __exportAll({
|
|
31
33
|
ConfigInstallFailed: () => ConfigInstallFailed,
|
|
34
|
+
addCmd: () => addCmd,
|
|
32
35
|
applyCmd: () => applyCmd,
|
|
33
36
|
applyFlags: () => applyFlags,
|
|
34
37
|
applyPolicyOnCreate: () => applyPolicyOnCreate,
|
|
@@ -213,6 +216,28 @@ const seedFromBranch = async (props) => {
|
|
|
213
216
|
branchName: live.branch.name
|
|
214
217
|
};
|
|
215
218
|
};
|
|
219
|
+
/** Install the config packages the project is missing, or say how to. */
|
|
220
|
+
const ensureConfigPackages = async (cwd, props) => {
|
|
221
|
+
const run = props.run ?? runCommand;
|
|
222
|
+
const missing = missingDependencies(cwd);
|
|
223
|
+
if (missing.length === 0) {
|
|
224
|
+
if (!props.silent) log.info("%s are already installed.", REQUIRED_PACKAGES.join(" and "));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const pm = props.packageManager ?? resolvePackageManager(cwd);
|
|
228
|
+
const args = installArgs(pm, missing);
|
|
229
|
+
if (props.install === false) {
|
|
230
|
+
if (!props.silent) log.info("Install the Neon config packages to use neon.ts: %s", formatInstallCommand(pm, missing));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
ensureDirectoryGitignored(join(cwd, "node_modules"));
|
|
234
|
+
if (!props.silent) log.info("Installing %s with %s…", missing.join(", "), pm);
|
|
235
|
+
if (!await run(pm, args, cwd)) {
|
|
236
|
+
const command = formatInstallCommand(pm, missing);
|
|
237
|
+
if (props.requireInstall) throw new ConfigInstallFailed(command);
|
|
238
|
+
log.warning("Could not install the config packages automatically. Run by hand: %s", command);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
216
241
|
/**
|
|
217
242
|
* Scaffold a `neon.ts` policy and make sure the Neon config packages are
|
|
218
243
|
* installed, so a project can go straight to `neon config plan` / `apply`.
|
|
@@ -220,10 +245,12 @@ const seedFromBranch = async (props) => {
|
|
|
220
245
|
*/
|
|
221
246
|
const initCmd = async (props) => {
|
|
222
247
|
const cwd = props.cwd ?? process.cwd();
|
|
223
|
-
const run = props.run ?? runCommand;
|
|
224
248
|
const existing = neonConfigFilename(cwd);
|
|
225
249
|
if (existing) {
|
|
226
|
-
if (!props.silent)
|
|
250
|
+
if (!props.silent) {
|
|
251
|
+
log.info("Found an existing %s — leaving it untouched.", existing);
|
|
252
|
+
if (props.services !== void 0) log.warning("--services was ignored. To declare services in the existing %s, use `%s config add`.", existing, getCliName());
|
|
253
|
+
}
|
|
227
254
|
} else if (props.fromBranch) {
|
|
228
255
|
const { source, seeded, branchName } = await seedFromBranch(props);
|
|
229
256
|
writeFileSync(join(cwd, "neon.ts"), source);
|
|
@@ -240,26 +267,139 @@ const initCmd = async (props) => {
|
|
|
240
267
|
}
|
|
241
268
|
if (services.includes("functions")) scaffoldFunction(cwd);
|
|
242
269
|
}
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
270
|
+
await ensureConfigPackages(cwd, props);
|
|
271
|
+
if (!props.silent) log.info("Next: edit neon.ts, then run `neon config plan` to preview and `neon config apply`.");
|
|
272
|
+
};
|
|
273
|
+
/** The rule the platform enforces on function slugs, so a bad one fails before any write. */
|
|
274
|
+
const FUNCTION_SLUG_PATTERN = /^[a-z0-9]{1,20}$/;
|
|
275
|
+
const SCRIPT_EXTENSION = /\.(?:ts|mts|cts|js|mjs|cjs)$/;
|
|
276
|
+
const TYPESCRIPT_EXTENSION = /\.(?:ts|mts|cts)$/;
|
|
277
|
+
/**
|
|
278
|
+
* The nearest neon.ts, found the way the config loader finds it: walk up from `start`, stop
|
|
279
|
+
* at the first directory holding `.git` (or at the home directory).
|
|
280
|
+
*/
|
|
281
|
+
const findNeonConfigUpward = (start) => {
|
|
282
|
+
let dir = resolve(start);
|
|
283
|
+
for (;;) {
|
|
284
|
+
const name = neonConfigFilename(dir);
|
|
285
|
+
if (name) return join(dir, name);
|
|
286
|
+
if (existsSync(join(dir, ".git")) || dir === homedir()) return void 0;
|
|
287
|
+
const parent = dirname(dir);
|
|
288
|
+
if (parent === dir) return void 0;
|
|
289
|
+
dir = parent;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const asRelativeModulePath = (path) => path.startsWith("./") || path.startsWith("../") || path.startsWith("/") ? path : `./${path}`;
|
|
293
|
+
const shellQuote = (value) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
294
|
+
/**
|
|
295
|
+
* `explicitConfig` is the `--config` the user passed. The commands suggested afterwards must
|
|
296
|
+
* carry it, or they would act on whichever `neon.ts` a walk up from cwd finds instead.
|
|
297
|
+
*/
|
|
298
|
+
const planAdd = (target, configPath, explicitConfig) => {
|
|
299
|
+
const language = TYPESCRIPT_EXTENSION.test(configPath) ? "ts" : "js";
|
|
300
|
+
const cli = getCliName();
|
|
301
|
+
const flag = explicitConfig ? ` --config ${shellQuote(explicitConfig)}` : "";
|
|
302
|
+
const provision = `Next: \`${cli} config plan${flag}\` to preview, \`${cli} config apply${flag}\` to provision.`;
|
|
303
|
+
switch (target.kind) {
|
|
304
|
+
case "service": return {
|
|
305
|
+
edit: {
|
|
306
|
+
kind: "service",
|
|
307
|
+
service: target.service
|
|
308
|
+
},
|
|
309
|
+
next: provision
|
|
310
|
+
};
|
|
311
|
+
case "bucket":
|
|
312
|
+
if (target.name.length < 1 || target.name.length > 255) throw new Error("A bucket name must be 1-255 characters.");
|
|
313
|
+
if (target.name === "__proto__") throw new Error("\"__proto__\" cannot be used as a bucket name.");
|
|
314
|
+
return {
|
|
315
|
+
edit: {
|
|
316
|
+
kind: "bucket",
|
|
317
|
+
name: target.name,
|
|
318
|
+
access: target.access ?? "private"
|
|
319
|
+
},
|
|
320
|
+
next: provision
|
|
321
|
+
};
|
|
322
|
+
case "function": {
|
|
323
|
+
if (!FUNCTION_SLUG_PATTERN.test(target.slug)) {
|
|
324
|
+
const suggestion = target.slug.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 20);
|
|
325
|
+
throw new Error(`"${target.slug}" is not a valid function slug: use 1-20 lowercase letters and digits, no hyphens.` + (suggestion ? ` Try "${suggestion}".` : ""));
|
|
258
326
|
}
|
|
327
|
+
if (target.source !== void 0 && !SCRIPT_EXTENSION.test(target.source)) throw new Error(`--source must be a .ts or .js file, got "${target.source}". Declare a directory source in neon.ts by hand.`);
|
|
328
|
+
const source = asRelativeModulePath(target.source ?? `functions/${target.slug}.${language}`);
|
|
329
|
+
return {
|
|
330
|
+
edit: {
|
|
331
|
+
kind: "function",
|
|
332
|
+
slug: target.slug,
|
|
333
|
+
name: target.name ?? target.slug,
|
|
334
|
+
source
|
|
335
|
+
},
|
|
336
|
+
sourceFile: resolve(dirname(configPath), source),
|
|
337
|
+
next: explicitConfig ? `Next: \`${cli} config apply${flag}\` to deploy.` : `Next: \`${cli} dev\` to run it locally, \`${cli} config apply\` to deploy.`
|
|
338
|
+
};
|
|
259
339
|
}
|
|
260
340
|
}
|
|
261
|
-
if (!props.silent) log.info("Next: edit neon.ts, then run `neon config plan` to preview and `neon config apply`.");
|
|
262
341
|
};
|
|
342
|
+
/**
|
|
343
|
+
* Declare a service, function, or bucket in the project's `neon.ts` with a text edit that
|
|
344
|
+
* leaves the rest of the file alone, creating the file (and installing the config packages)
|
|
345
|
+
* when the project has none. Local only, like `config init`.
|
|
346
|
+
*/
|
|
347
|
+
const addCmd = async (props) => {
|
|
348
|
+
const cwd = props.cwd ?? process.cwd();
|
|
349
|
+
let existing;
|
|
350
|
+
if (props.config) {
|
|
351
|
+
existing = resolve(cwd, props.config);
|
|
352
|
+
if (!existsSync(existing)) throw new Error(`--config ${props.config} does not exist.`);
|
|
353
|
+
} else existing = findNeonConfigUpward(cwd);
|
|
354
|
+
const configPath = existing ?? join(cwd, "neon.ts");
|
|
355
|
+
const shown = (path) => relative(cwd, path) || path;
|
|
356
|
+
const plan = planAdd(props.target, configPath, props.config);
|
|
357
|
+
const { source, changes } = editNeonConfig(existing ? readFileSync(existing, "utf8") : renderNeonConfig([]), plan.edit);
|
|
358
|
+
if (changes.length === 0) {
|
|
359
|
+
if (props.target.kind === "service") {
|
|
360
|
+
log.info("%s already enables that; nothing to change.", shown(configPath));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
throw new Error(`${shown(configPath)} already declares ${props.target.kind} "${props.target.kind === "function" ? props.target.slug : props.target.name}". Edit it there, or pick another name.`);
|
|
364
|
+
}
|
|
365
|
+
if (plan.sourceFile) {
|
|
366
|
+
if (existsSync(plan.sourceFile)) log.info("Found an existing %s — leaving it untouched.", shown(plan.sourceFile));
|
|
367
|
+
else {
|
|
368
|
+
mkdirSync(dirname(plan.sourceFile), { recursive: true });
|
|
369
|
+
const slug = props.target.kind === "function" ? props.target.slug : "";
|
|
370
|
+
writeFileSync(plan.sourceFile, renderFunctionSource(slug, TYPESCRIPT_EXTENSION.test(plan.sourceFile) ? "ts" : "js"));
|
|
371
|
+
log.info("Created %s.", shown(plan.sourceFile));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
writeFileSync(configPath, source);
|
|
375
|
+
log.info("%s %s: %s.", existing ? "Updated" : "Created", shown(configPath), changes.join(", "));
|
|
376
|
+
if (!existing) await ensureConfigPackages(dirname(configPath), props);
|
|
377
|
+
log.info("%s", plan.next);
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* Options every `config add` sub-command takes. `--project-id` and `--branch` are inherited
|
|
381
|
+
* from `config` but mean nothing to a command that never leaves the working tree, so they are
|
|
382
|
+
* hidden from its help.
|
|
383
|
+
*/
|
|
384
|
+
const addSharedOptions = (yargs) => yargs.strict().options({
|
|
385
|
+
"project-id": { hidden: true },
|
|
386
|
+
branch: { hidden: true },
|
|
387
|
+
config: {
|
|
388
|
+
describe: "Path to the neon.ts to edit (defaults to walking up from cwd; created in cwd when there is none)",
|
|
389
|
+
type: "string"
|
|
390
|
+
},
|
|
391
|
+
install: {
|
|
392
|
+
describe: "Install @neon/config and @neon/env when creating neon.ts. On by default; use --no-install to just print the command.",
|
|
393
|
+
type: "boolean",
|
|
394
|
+
default: true
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
/** The `config add` props for a sub-command's parsed flags. */
|
|
398
|
+
const addProps = (args, target) => ({
|
|
399
|
+
target,
|
|
400
|
+
install: args.install,
|
|
401
|
+
...args.config !== void 0 ? { config: args.config } : {}
|
|
402
|
+
});
|
|
263
403
|
const command = "config";
|
|
264
404
|
const describe = "Manage a branch with a neon.ts policy";
|
|
265
405
|
const builder = (argv) => argv.usage("$0 config <sub-command> [options]").options({
|
|
@@ -296,7 +436,47 @@ const builder = (argv) => argv.usage("$0 config <sub-command> [options]").option
|
|
|
296
436
|
...envFlag,
|
|
297
437
|
...applyFlags,
|
|
298
438
|
...envPullFlag
|
|
299
|
-
}), (args) => applyCmd(args)).command("
|
|
439
|
+
}), (args) => applyCmd(args)).command("add", "Add a service, function, or bucket to neon.ts (creates it if missing)", (yargs) => yargs.command("auth", "Enable Neon Auth (auth: true)", addSharedOptions, (args) => addCmd(addProps(args, {
|
|
440
|
+
kind: "service",
|
|
441
|
+
service: "auth"
|
|
442
|
+
}))).command("data-api", "Enable the Data API (dataApi: true) and Neon Auth, which the default provider requires", addSharedOptions, (args) => addCmd(addProps(args, {
|
|
443
|
+
kind: "service",
|
|
444
|
+
service: "data-api"
|
|
445
|
+
}))).command("ai-gateway", "Enable the AI Gateway (aiGateway: true)", addSharedOptions, (args) => addCmd(addProps(args, {
|
|
446
|
+
kind: "service",
|
|
447
|
+
service: "ai-gateway"
|
|
448
|
+
}))).command("function <slug>", "Declare a Neon Function and create its handler file", (yargs) => addSharedOptions(yargs).example("$0 config add function sendemail", "Creates functions/sendemail.ts and declares it").example("$0 config add function sendemail --source src/send.ts", "Declares an existing handler").positional("slug", {
|
|
449
|
+
describe: "Function slug: 1-20 lowercase letters and digits",
|
|
450
|
+
type: "string",
|
|
451
|
+
demandOption: true
|
|
452
|
+
}).options({
|
|
453
|
+
name: {
|
|
454
|
+
describe: "Display name. Defaults to the slug",
|
|
455
|
+
type: "string"
|
|
456
|
+
},
|
|
457
|
+
source: {
|
|
458
|
+
describe: "Handler file, relative to neon.ts. Defaults to functions/<slug>.ts. Created when missing, left alone when it exists",
|
|
459
|
+
type: "string"
|
|
460
|
+
}
|
|
461
|
+
}), (args) => addCmd(addProps(args, {
|
|
462
|
+
kind: "function",
|
|
463
|
+
slug: args.slug,
|
|
464
|
+
...args.name !== void 0 ? { name: args.name } : {},
|
|
465
|
+
...args.source !== void 0 ? { source: args.source } : {}
|
|
466
|
+
}))).command("bucket <name>", "Declare an Object Storage bucket", (yargs) => addSharedOptions(yargs).example("$0 config add bucket assets --access public_read", "Declares a bucket that allows anonymous reads").positional("name", {
|
|
467
|
+
describe: "Bucket name",
|
|
468
|
+
type: "string",
|
|
469
|
+
demandOption: true
|
|
470
|
+
}).options({ access: {
|
|
471
|
+
describe: "Anonymous access: private requires credentials, public_read allows anonymous reads",
|
|
472
|
+
type: "string",
|
|
473
|
+
choices: ["private", "public_read"],
|
|
474
|
+
default: "private"
|
|
475
|
+
} }), (args) => addCmd(addProps(args, {
|
|
476
|
+
kind: "bucket",
|
|
477
|
+
name: args.name,
|
|
478
|
+
access: args.access
|
|
479
|
+
}))).demandCommand(1, "Specify what to add: auth, data-api, ai-gateway, function, or bucket.")).command("init", "Scaffold a neon.ts policy and install the Neon config packages", (yargs) => yargs.options({
|
|
300
480
|
install: {
|
|
301
481
|
describe: "Install @neon/config and @neon/env if they're missing. On by default; use --no-install to just print the command.",
|
|
302
482
|
type: "boolean",
|
|
@@ -803,4 +983,4 @@ const createBranchFromPolicyOnCheckout = async (props) => {
|
|
|
803
983
|
}
|
|
804
984
|
};
|
|
805
985
|
//#endregion
|
|
806
|
-
export { ConfigInstallFailed, applyCmd, applyFlags, applyPolicyOnCreate, builder, command, createBranchFromPolicyOnCheckout, describe, envFlag, envPullFlag, handler, hasNeonConfigFile, initCmd, neonConfigFilename, planCmd, status, config_exports as t };
|
|
986
|
+
export { ConfigInstallFailed, addCmd, applyCmd, applyFlags, applyPolicyOnCreate, builder, command, createBranchFromPolicyOnCheckout, describe, envFlag, envPullFlag, handler, hasNeonConfigFile, initCmd, neonConfigFilename, planCmd, status, config_exports as t };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { SourceScanError, classify, entryObject, findConfigObject, findEntry, isCodeAt, isCommentAt, lineIndent, lineStart, scanObject } from "./config_source.js";
|
|
2
|
+
import { renderKey } from "./config_template.js";
|
|
3
|
+
//#region src/config_edit.ts
|
|
4
|
+
/**
|
|
5
|
+
* Edits to an existing `neon.ts`, applied as text splices so comments, formatting, and every
|
|
6
|
+
* property the edit does not name survive untouched. The file is never regenerated.
|
|
7
|
+
*
|
|
8
|
+
* Each edit either declares something new, flips a disabled toggle on, or reports that the
|
|
9
|
+
* file already declares it (`changes` is empty). A file whose layout the scanner cannot
|
|
10
|
+
* place an edit into is refused with the exact lines to add by hand.
|
|
11
|
+
*/
|
|
12
|
+
var ConfigEditError = class extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConfigEditError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
/** Top-level keys in the order the starter policy writes them. New keys land in this order. */
|
|
19
|
+
const KEY_ORDER = [
|
|
20
|
+
"auth",
|
|
21
|
+
"dataApi",
|
|
22
|
+
"aiGateway",
|
|
23
|
+
"functions",
|
|
24
|
+
"buckets",
|
|
25
|
+
"triggers",
|
|
26
|
+
"preview",
|
|
27
|
+
"branch",
|
|
28
|
+
"experimental"
|
|
29
|
+
];
|
|
30
|
+
const keyRank = (key) => key === void 0 ? -1 : KEY_ORDER.indexOf(key);
|
|
31
|
+
const unsupported = (reason, snippet) => {
|
|
32
|
+
throw new ConfigEditError(`Cannot edit this neon.ts automatically: ${reason}\nDeclare it by hand:\n\n${snippet.map((line) => ` ${line}`).join("\n")}`);
|
|
33
|
+
};
|
|
34
|
+
const parse = (source) => {
|
|
35
|
+
try {
|
|
36
|
+
const kinds = classify(source);
|
|
37
|
+
const root = assertPlain(scanObject(source, kinds, findConfigObject(source, kinds)));
|
|
38
|
+
const first = root.entries[0];
|
|
39
|
+
const base = lineIndent(source, root.open);
|
|
40
|
+
const firstIndent = first ? lineIndent(source, first.start) : base;
|
|
41
|
+
const unit = firstIndent.startsWith(base) && firstIndent.length > base.length ? firstIndent.slice(base.length) : " ";
|
|
42
|
+
return {
|
|
43
|
+
source,
|
|
44
|
+
kinds,
|
|
45
|
+
root,
|
|
46
|
+
eol: source.includes("\r\n") ? "\r\n" : "\n",
|
|
47
|
+
unit
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof SourceScanError) throw new ConfigEditError(`Cannot edit this neon.ts automatically: ${error.message}.`);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A spread, a computed key, or a repeated key can override the property an edit just wrote,
|
|
56
|
+
* so the effective value cannot be read from the text.
|
|
57
|
+
*/
|
|
58
|
+
const assertPlain = (scan) => {
|
|
59
|
+
const seen = /* @__PURE__ */ new Set();
|
|
60
|
+
for (const entry of scan.entries) {
|
|
61
|
+
if (entry.key === void 0 || seen.has(entry.key)) throw new ConfigEditError("Cannot edit this neon.ts automatically: an object it edits has a spread, computed, or repeated property that could override the edit.");
|
|
62
|
+
seen.add(entry.key);
|
|
63
|
+
}
|
|
64
|
+
return scan;
|
|
65
|
+
};
|
|
66
|
+
/** The object literal `entry` holds, or `undefined` when its value is anything else. */
|
|
67
|
+
const objectOf = (parsed, entry) => {
|
|
68
|
+
const scan = entry.keyed ? entryObject(parsed.source, parsed.kinds, entry) : void 0;
|
|
69
|
+
return scan && assertPlain(scan);
|
|
70
|
+
};
|
|
71
|
+
const startsLine = (source, index) => source.slice(lineStart(source, index), index).trim() === "";
|
|
72
|
+
/**
|
|
73
|
+
* Where a new entry goes, as a splice into `parsed.source`. `before` places it above that
|
|
74
|
+
* entry (and above the comment lines that describe it); without it the entry is appended.
|
|
75
|
+
*/
|
|
76
|
+
const insertEntry = (parsed, scan, lines, snippet, before) => {
|
|
77
|
+
const { source, kinds, eol, unit } = parsed;
|
|
78
|
+
const baseIndent = lineIndent(source, scan.open);
|
|
79
|
+
const first = scan.entries[0];
|
|
80
|
+
if (!first) {
|
|
81
|
+
if (source.slice(scan.open + 1, scan.close).trim() !== "") return unsupported("the object it goes in holds only comments.", snippet);
|
|
82
|
+
const indent = baseIndent + unit;
|
|
83
|
+
return source.slice(0, scan.open + 1) + eol + lines.map((line) => indent + line).join(eol) + eol + baseIndent + source.slice(scan.close);
|
|
84
|
+
}
|
|
85
|
+
const last = scan.entries[scan.entries.length - 1];
|
|
86
|
+
if (!startsLine(source, first.start) || !source.slice(last.end, scan.close).includes("\n")) return unsupported("the object it goes in is not laid out one property per line.", snippet);
|
|
87
|
+
const indent = lineIndent(source, first.start);
|
|
88
|
+
if (before) {
|
|
89
|
+
if (!startsLine(source, before.start)) return unsupported("a property sharing its line with another one.", snippet);
|
|
90
|
+
let at = lineStart(source, before.start);
|
|
91
|
+
while (at > 0) {
|
|
92
|
+
const previous = lineStart(source, at - 1);
|
|
93
|
+
const text = source.slice(previous, at);
|
|
94
|
+
const marker = previous + text.search(/\S|$/);
|
|
95
|
+
if (previous <= scan.open || !text.trim().startsWith("//") || !isCommentAt(kinds, marker)) break;
|
|
96
|
+
at = previous;
|
|
97
|
+
}
|
|
98
|
+
return source.slice(0, at) + lines.map((line) => indent + line + eol).join("") + source.slice(at);
|
|
99
|
+
}
|
|
100
|
+
let lineEnd = source.indexOf("\n", last.end);
|
|
101
|
+
if (source[lineEnd - 1] === "\r") lineEnd--;
|
|
102
|
+
let hasComma = false;
|
|
103
|
+
for (let i = last.end; i < scan.close; i++) {
|
|
104
|
+
if (!isCodeAt(kinds, i) || source[i] !== ",") continue;
|
|
105
|
+
if (i >= lineEnd) return unsupported("a trailing comma on a line of its own.", snippet);
|
|
106
|
+
hasComma = true;
|
|
107
|
+
}
|
|
108
|
+
return source.slice(0, last.end) + (hasComma ? "" : ",") + source.slice(last.end, lineEnd) + eol + lines.map((line) => indent + line).join(eol) + source.slice(lineEnd);
|
|
109
|
+
};
|
|
110
|
+
/** The first top-level entry that belongs after `key` in the starter policy's order. */
|
|
111
|
+
const entryAfter = (parsed, key) => parsed.root.entries.find((entry) => keyRank(entry.key) > keyRank(key));
|
|
112
|
+
/**
|
|
113
|
+
* `preview` still accepts `aiGateway`, `functions`, and `buckets`, and declaring the same key
|
|
114
|
+
* in both places fails validation. Adding to the other home would break the file.
|
|
115
|
+
*/
|
|
116
|
+
const assertNotUnderPreview = (parsed, key, snippet) => {
|
|
117
|
+
const preview = findEntry(parsed.root, "preview");
|
|
118
|
+
if (!preview) return;
|
|
119
|
+
const scan = objectOf(parsed, preview);
|
|
120
|
+
if (!scan) {
|
|
121
|
+
unsupported(`preview is not an object literal, so it may already declare ${key}.`, snippet);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (findEntry(scan, key)) unsupported(`${key} is declared under the deprecated \`preview\` block. Lift it to the top level first.`, snippet);
|
|
125
|
+
};
|
|
126
|
+
const readToggle = (parsed, entry, key, snippet) => {
|
|
127
|
+
const { source } = parsed;
|
|
128
|
+
const value = source.slice(entry.valueStart, entry.valueEnd);
|
|
129
|
+
if (entry.keyed && value === "true") return { on: true };
|
|
130
|
+
if (entry.keyed && value === "false") return {
|
|
131
|
+
on: false,
|
|
132
|
+
span: [entry.valueStart, entry.valueEnd],
|
|
133
|
+
path: key
|
|
134
|
+
};
|
|
135
|
+
const object = objectOf(parsed, entry);
|
|
136
|
+
if (!object) return unsupported(`${key} is set to an expression, not true or false.`, snippet);
|
|
137
|
+
const enabled = findEntry(object, "enabled");
|
|
138
|
+
if (!enabled) return { on: true };
|
|
139
|
+
const flag = source.slice(enabled.valueStart, enabled.valueEnd);
|
|
140
|
+
if (enabled.keyed && flag === "true") return { on: true };
|
|
141
|
+
if (enabled.keyed && flag === "false") return {
|
|
142
|
+
on: false,
|
|
143
|
+
span: [enabled.valueStart, enabled.valueEnd],
|
|
144
|
+
path: `${key}.enabled`
|
|
145
|
+
};
|
|
146
|
+
return unsupported(`${key}.enabled is set to an expression, not true or false.`, snippet);
|
|
147
|
+
};
|
|
148
|
+
const setToggle = (parsed, key) => {
|
|
149
|
+
const snippet = [`${key}: true,`];
|
|
150
|
+
if (key === "aiGateway") assertNotUnderPreview(parsed, key, snippet);
|
|
151
|
+
const entry = findEntry(parsed.root, key);
|
|
152
|
+
if (!entry) return {
|
|
153
|
+
source: insertEntry(parsed, parsed.root, snippet, snippet, entryAfter(parsed, key)),
|
|
154
|
+
change: `added ${key}: true`
|
|
155
|
+
};
|
|
156
|
+
const toggle = readToggle(parsed, entry, key, snippet);
|
|
157
|
+
if (toggle.on) return { source: parsed.source };
|
|
158
|
+
const [from, to] = toggle.span;
|
|
159
|
+
return {
|
|
160
|
+
source: parsed.source.slice(0, from) + "true" + parsed.source.slice(to),
|
|
161
|
+
change: `set ${toggle.path}: false → true`
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Whether `dataApi` verifies a third-party IdP, in which case it does not need Neon Auth. An
|
|
166
|
+
* `authProvider` that is neither literal is refused rather than assumed to be Neon's.
|
|
167
|
+
*/
|
|
168
|
+
const dataApiIsExternal = (parsed) => {
|
|
169
|
+
const entry = findEntry(parsed.root, "dataApi");
|
|
170
|
+
const object = entry ? objectOf(parsed, entry) : void 0;
|
|
171
|
+
const provider = object ? findEntry(object, "authProvider") : void 0;
|
|
172
|
+
if (!provider) return false;
|
|
173
|
+
const value = parsed.source.slice(provider.valueStart, provider.valueEnd).replace(/\s+as\s+const$/, "");
|
|
174
|
+
if (provider.keyed && /^["']external["']$/.test(value)) return true;
|
|
175
|
+
if (provider.keyed && /^["']neon["']$/.test(value)) return false;
|
|
176
|
+
return unsupported("dataApi.authProvider is set to an expression.", ["auth: true,", "dataApi: true,"]);
|
|
177
|
+
};
|
|
178
|
+
/** A named entry (a function or a bucket) inside `functions` / `buckets`. */
|
|
179
|
+
const addNamed = (parsed, container, name, entryLines) => {
|
|
180
|
+
const block = [
|
|
181
|
+
`${container}: {`,
|
|
182
|
+
...entryLines.map((line) => parsed.unit + line),
|
|
183
|
+
"},"
|
|
184
|
+
];
|
|
185
|
+
assertNotUnderPreview(parsed, container, block);
|
|
186
|
+
const entry = findEntry(parsed.root, container);
|
|
187
|
+
if (!entry) return {
|
|
188
|
+
source: insertEntry(parsed, parsed.root, block, block, entryAfter(parsed, container)),
|
|
189
|
+
change: `added ${container}.${name}`
|
|
190
|
+
};
|
|
191
|
+
const object = objectOf(parsed, entry);
|
|
192
|
+
if (!object) return unsupported(`${container} is not an object literal.`, block);
|
|
193
|
+
if (findEntry(object, name)) return { source: parsed.source };
|
|
194
|
+
return {
|
|
195
|
+
source: insertEntry(parsed, object, entryLines, block),
|
|
196
|
+
change: `added ${container}.${name}`
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
const apply = (source, edit) => {
|
|
200
|
+
const steps = [];
|
|
201
|
+
let current = source;
|
|
202
|
+
const run = (build) => {
|
|
203
|
+
const step = build(parse(current));
|
|
204
|
+
current = step.source;
|
|
205
|
+
steps.push(step);
|
|
206
|
+
};
|
|
207
|
+
switch (edit.kind) {
|
|
208
|
+
case "service":
|
|
209
|
+
if (edit.service === "auth") run((parsed) => setToggle(parsed, "auth"));
|
|
210
|
+
else if (edit.service === "ai-gateway") run((parsed) => setToggle(parsed, "aiGateway"));
|
|
211
|
+
else {
|
|
212
|
+
if (!dataApiIsExternal(parse(current))) run((parsed) => setToggle(parsed, "auth"));
|
|
213
|
+
run((parsed) => setToggle(parsed, "dataApi"));
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case "function":
|
|
217
|
+
run((parsed) => addNamed(parsed, "functions", edit.slug, [`${renderKey(edit.slug)}: { name: ${JSON.stringify(edit.name)}, source: ${JSON.stringify(edit.source)} },`]));
|
|
218
|
+
break;
|
|
219
|
+
case "bucket": run((parsed) => addNamed(parsed, "buckets", edit.name, [`${renderKey(edit.name)}: { access: ${JSON.stringify(edit.access)} },`]));
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
source: current,
|
|
223
|
+
changes: steps.flatMap((step) => step.change ? [step.change] : [])
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Apply `edit` to the text of a `neon.ts`. The result is re-read before it is returned: a
|
|
228
|
+
* splice the scanner cannot find its own edit in is a bug in the splice, and it throws
|
|
229
|
+
* instead of handing back a file that does not declare what was asked for.
|
|
230
|
+
*/
|
|
231
|
+
const editNeonConfig = (source, edit) => {
|
|
232
|
+
const result = apply(source, edit);
|
|
233
|
+
if (result.changes.length > 0 && apply(result.source, edit).changes.length > 0) throw new ConfigEditError("Editing neon.ts would not produce a file that declares the requested change. Nothing was written.");
|
|
234
|
+
return result;
|
|
235
|
+
};
|
|
236
|
+
//#endregion
|
|
237
|
+
export { ConfigEditError, editNeonConfig };
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
//#region src/config_source.ts
|
|
2
|
+
/**
|
|
3
|
+
* A scanner for the shape of a `neon.ts` file, just deep enough to find the config object
|
|
4
|
+
* literal and the spans of its properties, so an edit can splice text into the file and leave
|
|
5
|
+
* everything else byte-for-byte alone.
|
|
6
|
+
*
|
|
7
|
+
* It is not a JavaScript parser. It classifies every character as code, comment, or literal
|
|
8
|
+
* (string, template text, regex), matches brackets over the code characters, and splits an
|
|
9
|
+
* object literal on its top-level commas. Anything it cannot place with confidence throws
|
|
10
|
+
* {@link SourceScanError}; callers turn that into "edit it by hand" rather than guessing.
|
|
11
|
+
*/
|
|
12
|
+
var SourceScanError = class extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "SourceScanError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const CODE = 0;
|
|
19
|
+
const COMMENT = 1;
|
|
20
|
+
const LITERAL = 2;
|
|
21
|
+
const REGEX_MAY_FOLLOW = /* @__PURE__ */ new Set([..."(,=:[!&|?{};+-*%<>~^"]);
|
|
22
|
+
const REGEX_KEYWORDS = /* @__PURE__ */ new Set([
|
|
23
|
+
"return",
|
|
24
|
+
"typeof",
|
|
25
|
+
"case",
|
|
26
|
+
"in",
|
|
27
|
+
"of",
|
|
28
|
+
"delete",
|
|
29
|
+
"void",
|
|
30
|
+
"throw",
|
|
31
|
+
"new",
|
|
32
|
+
"else",
|
|
33
|
+
"do",
|
|
34
|
+
"yield",
|
|
35
|
+
"await"
|
|
36
|
+
]);
|
|
37
|
+
const isIdentChar = (char) => /[\w$]/.test(char);
|
|
38
|
+
const isSpace = (char) => /\s/.test(char);
|
|
39
|
+
const unterminated = (what, at) => new SourceScanError(`unterminated ${what} at offset ${at}`);
|
|
40
|
+
/** Whether the `/` at `index` opens a regex literal rather than dividing. */
|
|
41
|
+
const opensRegex = (source, kinds, index) => {
|
|
42
|
+
let prev = index - 1;
|
|
43
|
+
while (prev >= 0 && (isSpace(source[prev]) || kinds[prev] === COMMENT)) prev--;
|
|
44
|
+
if (prev < 0) return true;
|
|
45
|
+
if (kinds[prev] === LITERAL) return false;
|
|
46
|
+
const char = source[prev];
|
|
47
|
+
if (isIdentChar(char)) {
|
|
48
|
+
let start = prev;
|
|
49
|
+
while (start > 0 && isIdentChar(source[start - 1])) start--;
|
|
50
|
+
return REGEX_KEYWORDS.has(source.slice(start, prev + 1));
|
|
51
|
+
}
|
|
52
|
+
return REGEX_MAY_FOLLOW.has(char);
|
|
53
|
+
};
|
|
54
|
+
/** Classify every character of `source` as code, comment, or literal. */
|
|
55
|
+
const classify = (source) => {
|
|
56
|
+
const kinds = new Uint8Array(source.length);
|
|
57
|
+
const length = source.length;
|
|
58
|
+
const templateDepths = [];
|
|
59
|
+
const mark = (from, to, kind) => {
|
|
60
|
+
kinds.fill(kind, from, to);
|
|
61
|
+
};
|
|
62
|
+
const scanTemplateText = (from) => {
|
|
63
|
+
let i = from + 1;
|
|
64
|
+
while (i < length) if (source[i] === "\\") i += 2;
|
|
65
|
+
else if (source[i] === "`") {
|
|
66
|
+
mark(from, i + 1, LITERAL);
|
|
67
|
+
return i + 1;
|
|
68
|
+
} else if (source[i] === "$" && source[i + 1] === "{") {
|
|
69
|
+
mark(from, i + 2, LITERAL);
|
|
70
|
+
templateDepths.push({
|
|
71
|
+
start: i,
|
|
72
|
+
depth: 0
|
|
73
|
+
});
|
|
74
|
+
return i + 2;
|
|
75
|
+
} else i++;
|
|
76
|
+
throw unterminated("template literal", from);
|
|
77
|
+
};
|
|
78
|
+
let i = 0;
|
|
79
|
+
while (i < length) {
|
|
80
|
+
const char = source[i];
|
|
81
|
+
const next = source[i + 1];
|
|
82
|
+
if (char === "/" && next === "/") {
|
|
83
|
+
const newline = source.indexOf("\n", i);
|
|
84
|
+
const end = newline === -1 ? length : newline;
|
|
85
|
+
mark(i, end, COMMENT);
|
|
86
|
+
i = end;
|
|
87
|
+
} else if (char === "/" && next === "*") {
|
|
88
|
+
const close = source.indexOf("*/", i + 2);
|
|
89
|
+
if (close === -1) throw unterminated("block comment", i);
|
|
90
|
+
mark(i, close + 2, COMMENT);
|
|
91
|
+
i = close + 2;
|
|
92
|
+
} else if (char === "\"" || char === "'") {
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < length && source[j] !== char) {
|
|
95
|
+
if (source[j] === "\n") throw unterminated("string", i);
|
|
96
|
+
j += source[j] === "\\" ? 2 : 1;
|
|
97
|
+
}
|
|
98
|
+
if (j >= length) throw unterminated("string", i);
|
|
99
|
+
mark(i, j + 1, LITERAL);
|
|
100
|
+
i = j + 1;
|
|
101
|
+
} else if (char === "`") i = scanTemplateText(i);
|
|
102
|
+
else if (char === "/") {
|
|
103
|
+
if (!opensRegex(source, kinds, i)) {
|
|
104
|
+
i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
let j = i + 1;
|
|
108
|
+
let inClass = false;
|
|
109
|
+
while (j < length) {
|
|
110
|
+
const c = source[j];
|
|
111
|
+
if (c === "\n") throw unterminated("regular expression", i);
|
|
112
|
+
if (c === "\\") {
|
|
113
|
+
j += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "[") inClass = true;
|
|
117
|
+
else if (c === "]") inClass = false;
|
|
118
|
+
else if (c === "/" && !inClass) break;
|
|
119
|
+
j++;
|
|
120
|
+
}
|
|
121
|
+
if (j >= length) throw unterminated("regular expression", i);
|
|
122
|
+
j++;
|
|
123
|
+
while (j < length && /[a-z]/i.test(source[j])) j++;
|
|
124
|
+
mark(i, j, LITERAL);
|
|
125
|
+
i = j;
|
|
126
|
+
} else if (char === "{" && templateDepths.length > 0) {
|
|
127
|
+
templateDepths[templateDepths.length - 1].depth++;
|
|
128
|
+
i++;
|
|
129
|
+
} else if (char === "}" && templateDepths.length > 0) {
|
|
130
|
+
const top = templateDepths[templateDepths.length - 1];
|
|
131
|
+
if (top.depth === 0) {
|
|
132
|
+
templateDepths.pop();
|
|
133
|
+
mark(top.start, i, LITERAL);
|
|
134
|
+
i = scanTemplateText(i);
|
|
135
|
+
} else {
|
|
136
|
+
top.depth--;
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
} else i++;
|
|
140
|
+
}
|
|
141
|
+
if (templateDepths.length > 0) throw unterminated("template expression", length);
|
|
142
|
+
return kinds;
|
|
143
|
+
};
|
|
144
|
+
const isTrivia = (source, kinds, index) => isSpace(source[index]) || kinds[index] === COMMENT;
|
|
145
|
+
const skipTrivia = (source, kinds, from, to) => {
|
|
146
|
+
let i = from;
|
|
147
|
+
while (i < to && isTrivia(source, kinds, i)) i++;
|
|
148
|
+
return i;
|
|
149
|
+
};
|
|
150
|
+
const trimTriviaEnd = (source, kinds, from, to) => {
|
|
151
|
+
let i = to;
|
|
152
|
+
while (i > from && isTrivia(source, kinds, i - 1)) i--;
|
|
153
|
+
return i;
|
|
154
|
+
};
|
|
155
|
+
const readEntry = (source, kinds, from, to) => {
|
|
156
|
+
const start = skipTrivia(source, kinds, from, to);
|
|
157
|
+
if (start >= to) return void 0;
|
|
158
|
+
const end = trimTriviaEnd(source, kinds, start, to);
|
|
159
|
+
const other = (key) => ({
|
|
160
|
+
key,
|
|
161
|
+
keyed: false,
|
|
162
|
+
start,
|
|
163
|
+
end,
|
|
164
|
+
valueStart: end,
|
|
165
|
+
valueEnd: end
|
|
166
|
+
});
|
|
167
|
+
let key;
|
|
168
|
+
let afterKey;
|
|
169
|
+
if (kinds[start] === LITERAL && (source[start] === "\"" || source[start] === "'")) {
|
|
170
|
+
let close = start;
|
|
171
|
+
while (close < end && kinds[close] === LITERAL) close++;
|
|
172
|
+
const inner = source.slice(start + 1, close - 1);
|
|
173
|
+
if (inner.includes("\\")) return other();
|
|
174
|
+
key = inner;
|
|
175
|
+
afterKey = close;
|
|
176
|
+
} else {
|
|
177
|
+
const ident = /^[A-Za-z_$][\w$]*/.exec(source.slice(start, end));
|
|
178
|
+
if (kinds[start] !== CODE || ident === null) return other();
|
|
179
|
+
key = ident[0];
|
|
180
|
+
afterKey = start + key.length;
|
|
181
|
+
}
|
|
182
|
+
const colon = skipTrivia(source, kinds, afterKey, end);
|
|
183
|
+
if (colon >= end || source[colon] !== ":" || kinds[colon] !== CODE) return other(key);
|
|
184
|
+
const valueStart = skipTrivia(source, kinds, colon + 1, end);
|
|
185
|
+
return {
|
|
186
|
+
key,
|
|
187
|
+
keyed: true,
|
|
188
|
+
start,
|
|
189
|
+
end,
|
|
190
|
+
valueStart,
|
|
191
|
+
valueEnd: end
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/** Scan the object literal whose `{` is at `open`. */
|
|
195
|
+
const scanObject = (source, kinds, open) => {
|
|
196
|
+
if (source[open] !== "{" || kinds[open] !== CODE) throw new SourceScanError(`expected an object literal at offset ${open}`);
|
|
197
|
+
const entries = [];
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let entryStart = open + 1;
|
|
200
|
+
for (let i = open + 1; i < source.length; i++) {
|
|
201
|
+
if (kinds[i] !== CODE) continue;
|
|
202
|
+
const char = source[i];
|
|
203
|
+
if (char === "{" || char === "[" || char === "(") depth++;
|
|
204
|
+
else if (char === "}" || char === "]" || char === ")") {
|
|
205
|
+
if (depth > 0) {
|
|
206
|
+
depth--;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (char !== "}") throw new SourceScanError(`unbalanced "${char}" at offset ${i}`);
|
|
210
|
+
const last = readEntry(source, kinds, entryStart, i);
|
|
211
|
+
if (last) entries.push(last);
|
|
212
|
+
return {
|
|
213
|
+
open,
|
|
214
|
+
close: i,
|
|
215
|
+
entries
|
|
216
|
+
};
|
|
217
|
+
} else if (char === "," && depth === 0) {
|
|
218
|
+
const entry = readEntry(source, kinds, entryStart, i);
|
|
219
|
+
if (entry) entries.push(entry);
|
|
220
|
+
entryStart = i + 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
throw new SourceScanError(`object literal at offset ${open} is never closed`);
|
|
224
|
+
};
|
|
225
|
+
/** The entry named `key`, when the object has one. */
|
|
226
|
+
const findEntry = (scan, key) => scan.entries.find((entry) => entry.key === key);
|
|
227
|
+
/**
|
|
228
|
+
* The object literal an entry's value is, or `undefined` when the value is anything else
|
|
229
|
+
* (`true`, an identifier, `{ … } as const`, a call). Only a value that is exactly one object
|
|
230
|
+
* literal can be edited in place.
|
|
231
|
+
*/
|
|
232
|
+
const entryObject = (source, kinds, entry) => {
|
|
233
|
+
if (!entry.keyed || source[entry.valueStart] !== "{") return void 0;
|
|
234
|
+
const scan = scanObject(source, kinds, entry.valueStart);
|
|
235
|
+
return scan.close + 1 === entry.valueEnd ? scan : void 0;
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* The index of the `{` holding the policy: the argument of the `defineConfig(` call, or the
|
|
239
|
+
* object a bare `export default { … }` exports. More than one candidate is refused, since
|
|
240
|
+
* the first one in the text is not necessarily the one exported.
|
|
241
|
+
*/
|
|
242
|
+
const findConfigObject = (source, kinds) => {
|
|
243
|
+
const candidates = [];
|
|
244
|
+
for (const call of source.matchAll(/\bdefineConfig\s*\(/g)) {
|
|
245
|
+
if (kinds[call.index] !== CODE) continue;
|
|
246
|
+
const arg = skipTrivia(source, kinds, call.index + call[0].length, source.length);
|
|
247
|
+
if (source[arg] !== "{") throw new SourceScanError("defineConfig() is not called with an object literal");
|
|
248
|
+
candidates.push(arg);
|
|
249
|
+
}
|
|
250
|
+
for (const exported of source.matchAll(/\bexport\s+default\s+(?=\{)/g)) if (kinds[exported.index] === CODE) candidates.push(exported.index + exported[0].length);
|
|
251
|
+
if (candidates.length === 0) throw new SourceScanError("no defineConfig({ … }) call found");
|
|
252
|
+
if (candidates.length > 1) throw new SourceScanError("found more than one config object, so the exported one is ambiguous");
|
|
253
|
+
return candidates[0];
|
|
254
|
+
};
|
|
255
|
+
/** Start index of the line containing `index`. */
|
|
256
|
+
const lineStart = (source, index) => source.lastIndexOf("\n", index - 1) + 1;
|
|
257
|
+
/**
|
|
258
|
+
* The whitespace that indents the line containing `index`, whatever else is on that line.
|
|
259
|
+
*/
|
|
260
|
+
const lineIndent = (source, index) => {
|
|
261
|
+
const start = lineStart(source, index);
|
|
262
|
+
const indent = /^[ \t]*/.exec(source.slice(start));
|
|
263
|
+
return indent ? indent[0] : "";
|
|
264
|
+
};
|
|
265
|
+
/** Whether the character at `index` is a comment character. */
|
|
266
|
+
const isCommentAt = (kinds, index) => kinds[index] === COMMENT;
|
|
267
|
+
/** Whether the character at `index` is code (not a comment, string, or regex). */
|
|
268
|
+
const isCodeAt = (kinds, index) => kinds[index] === CODE;
|
|
269
|
+
//#endregion
|
|
270
|
+
export { SourceScanError, classify, entryObject, findConfigObject, findEntry, isCodeAt, isCommentAt, lineIndent, lineStart, scanObject };
|
package/dist/config_template.js
CHANGED
|
@@ -186,10 +186,18 @@ ${body}
|
|
|
186
186
|
* `resolveFetchHandler`), and the bare function is less to read and less to get wrong. It is
|
|
187
187
|
* named rather than anonymous so a project's linter has nothing to say about it, and takes no
|
|
188
188
|
* parameter because a scaffold shipping an unused `req` fails a `noUnusedParameters` project.
|
|
189
|
+
*
|
|
190
|
+
* The name is the slug when that is a usable identifier (a slug may start with a digit or be a
|
|
191
|
+
* reserved word such as `default`), and
|
|
192
|
+
* the return type is dropped for a `.js` config, where it would not parse.
|
|
189
193
|
*/
|
|
190
|
-
const
|
|
194
|
+
const RESERVED_WORDS = new Set("await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield let static implements interface package private protected public eval arguments".split(" "));
|
|
195
|
+
const renderFunctionSource = (slug, language) => {
|
|
196
|
+
return `export default async function ${/^[A-Za-z_$][\w$]*$/.test(slug) && !RESERVED_WORDS.has(slug) ? slug : "handler"}()${language === "ts" ? ": Promise<Response>" : ""} {
|
|
191
197
|
return new Response("Hello from Neon Functions");
|
|
192
198
|
}
|
|
193
199
|
`;
|
|
200
|
+
};
|
|
201
|
+
const FUNCTION_TEMPLATE = renderFunctionSource(FUNCTION_SLUG, "ts");
|
|
194
202
|
//#endregion
|
|
195
|
-
export { BUCKET_NAME, CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, CONFIG_PACKAGE, ENV_PACKAGE, FUNCTION_FILENAME, FUNCTION_NAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderNeonConfig, renderNeonConfigFromView };
|
|
203
|
+
export { BUCKET_NAME, CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, CONFIG_INIT_UNAVAILABLE, CONFIG_PACKAGE, ENV_PACKAGE, FUNCTION_FILENAME, FUNCTION_NAME, FUNCTION_SLUG, FUNCTION_TEMPLATE, REQUIRED_PACKAGES, renderFunctionSource, renderKey, renderNeonConfig, renderNeonConfigFromView };
|
package/dist/context.js
CHANGED
|
@@ -37,6 +37,11 @@ const isCurrentBranchProbe = (args) => args.currentBranch === true && (args._[0]
|
|
|
37
37
|
*/
|
|
38
38
|
const isConfigInit = (args) => args._[0] === "config" && args._[1] === "init" && args.fromBranch !== true && !process.argv.includes("--from-branch");
|
|
39
39
|
/**
|
|
40
|
+
* `config add` edits the local `neon.ts` and never calls the Neon API, so the global auth
|
|
41
|
+
* middleware and the single-project resolver skip it, like {@link isConfigInit}.
|
|
42
|
+
*/
|
|
43
|
+
const isConfigAdd = (args) => args._[0] === "config" && args._[1] === "add";
|
|
44
|
+
/**
|
|
40
45
|
* `neon profile …` manages credentials on disk and never calls the Neon API, so the global
|
|
41
46
|
* auth middleware must skip it — mirroring {@link isConfigInit}.
|
|
42
47
|
*
|
|
@@ -350,4 +355,4 @@ const globToRegExp = (pattern) => {
|
|
|
350
355
|
return new RegExp(`^${source}$`);
|
|
351
356
|
};
|
|
352
357
|
//#endregion
|
|
353
|
-
export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
|
|
358
|
+
export { CONTEXT_FILE, applyContext, contextBranch, currentContextFile, enrichFromContext, ensureDirectoryGitignored, ensureGitignored, gitBranchMap, gitBranchMapping, isApiKeysCommand, isAskCommand, isClaimCommand, isConfigAdd, isConfigInit, isCurrentBranchProbe, isGitLocalCommand, isInspectDbUrl, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, isUnfollowedGitHookSync, readContextFile, setContext, setGitBranchMap, setGitBranchMapping, setGitFollow, updateContextFile, walkContextFile };
|
package/dist/utils/enrichers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isNeonApiError, messageFromBody } from "../api.js";
|
|
2
|
-
import { isConfigInit, isCurrentBranchProbe } from "../context.js";
|
|
2
|
+
import { isConfigAdd, isConfigInit, isCurrentBranchProbe } from "../context.js";
|
|
3
3
|
import { looksLikeBranchId } from "./formats.js";
|
|
4
4
|
//#region src/utils/enrichers.ts
|
|
5
5
|
const BRANCHES_LIST_LIMIT = 100;
|
|
@@ -93,6 +93,7 @@ const resolveSingleDatabase = async (props) => {
|
|
|
93
93
|
const fillSingleProject = async (props) => {
|
|
94
94
|
if (isCurrentBranchProbe(props)) return props;
|
|
95
95
|
if (isConfigInit(props)) return props;
|
|
96
|
+
if (isConfigAdd(props)) return props;
|
|
96
97
|
if (props.projectId) return {
|
|
97
98
|
...props,
|
|
98
99
|
projectId: props.projectId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"yaml": "^2.9.0",
|
|
65
65
|
"yargs": "17.7.2",
|
|
66
66
|
"yoctocolors": "^2.1.2",
|
|
67
|
-
"@neon/config": "1.8.2",
|
|
68
67
|
"@neon/config-runtime": "1.7.2",
|
|
69
|
-
"@neon/sdk": "6.1.2"
|
|
68
|
+
"@neon/sdk": "6.1.2",
|
|
69
|
+
"@neon/config": "1.8.2"
|
|
70
70
|
},
|
|
71
71
|
"optionalDependencies": {
|
|
72
72
|
"@napi-rs/keyring": "1.3.0",
|