neon 6.2.4 → 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.
@@ -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 };
@@ -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 FUNCTION_TEMPLATE = `export default async function hello(): Promise<Response> {
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/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { log } from "./log.js";
2
2
  import pkg_default from "./pkg.js";
3
- import { isNeonApiError, messageFromBody } from "./api.js";
3
+ import { codeFromBody, isNeonApiError, messageFromBody } from "./api.js";
4
4
  import { getAuthContext } from "./auth_context.js";
5
5
  import { r as defaultDir } from "./_chunks/paths-DMq0Lt7a.js";
6
6
  import { ensureConfigDir } from "./config.js";
@@ -14,6 +14,7 @@ import { recoverFrom401 } from "./auth_recovery.js";
14
14
  import { showHelp } from "./help.js";
15
15
  import { rewriteUnknownAgentArg } from "./init/plan.js";
16
16
  import commands_default from "./commands/index.js";
17
+ import { recoverFromSSO } from "./sso_recovery.js";
17
18
  import { notifyIfUpdateAvailable } from "./update_notifier.js";
18
19
  import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
19
20
  import yargs from "yargs";
@@ -165,6 +166,10 @@ async function handleError(msg, err, canRetry) {
165
166
  log.error("Request timed out");
166
167
  sendError(err, "REQUEST_TIMEOUT");
167
168
  return false;
169
+ } else if (codeFromBody(err.data) === "SSO_AUTHORIZATION_REQUIRED" || codeFromBody(err.data) === "SSO_ORG_CREDS_ONLY") {
170
+ if (await recoverFromSSO(err, canRetry)) return true;
171
+ sendError(err, "AUTH_FAILED");
172
+ return false;
168
173
  } else if (err.status === 401) {
169
174
  sendError(err, "AUTH_FAILED");
170
175
  return await recoverFrom401(canRetry);
@@ -49,6 +49,8 @@ const printInitDone = (text) => {
49
49
  const agentSetupLabel = (setup) => {
50
50
  if (setup === "plugin") return "plugin";
51
51
  if (setup === "skills-mcp") return "skills and MCP";
52
+ if (setup === "skills") return "skills";
53
+ if (setup === "mcp") return "MCP";
52
54
  return "skipped";
53
55
  };
54
56
  const agentSetupDoneLabel = (input) => {
@@ -56,13 +58,31 @@ const agentSetupDoneLabel = (input) => {
56
58
  return agentSetupLabel(input.setup);
57
59
  };
58
60
  const formatAgentIds = (agents) => agents.join(", ");
61
+ const SETUP_LABELS = {
62
+ skip: "skipped",
63
+ plugin: "Neon plugin",
64
+ skills: "skills",
65
+ "skills-mcp": "skills and MCP",
66
+ mixed: "plugin and skills/MCP"
67
+ };
68
+ /** Names the agents behind each mechanism, since init picks skills and MCP agents separately. */
59
69
  const agentsRowValue = (input) => {
60
- if (input.setup === null || input.setup === "skip") return "skipped";
61
- if (input.setup === "skills") return "default Neon skills in this directory";
62
- const ids = formatAgentIds(input.agents);
63
- if (input.setup === "plugin") return ids.length > 0 ? `Neon plugin: ${ids}` : "Neon plugin";
64
- if (input.setup === "skills-mcp") return ids.length > 0 ? `skills and MCP: ${ids}` : "skills and MCP";
65
- return ids.length > 0 ? `plugin and skills/MCP: ${ids}` : "plugin and skills/MCP";
70
+ const { tooling } = input;
71
+ const parts = [];
72
+ const add = (label, ids) => {
73
+ const ran = ids.filter((id) => input.installed.includes(id));
74
+ if (ran.length > 0) parts.push(`${label}: ${formatAgentIds(ran)}`);
75
+ };
76
+ if (tooling.setup === "plugin" || tooling.setup === "skills") add(SETUP_LABELS[tooling.setup], tooling.agents);
77
+ else if (tooling.setup === "skills-mcp" || tooling.setup === "mixed") {
78
+ if (tooling.setup === "mixed") add("Neon plugin", tooling.pluginAgents);
79
+ if (tooling.skillsAgents.length === tooling.mcpAgents.length && tooling.skillsAgents.every((id) => tooling.mcpAgents.includes(id))) add("skills and MCP", tooling.skillsAgents);
80
+ else {
81
+ add("skills", tooling.skillsAgents);
82
+ add("MCP", tooling.mcpAgents);
83
+ }
84
+ }
85
+ return parts.length > 0 ? parts.join("; ") : SETUP_LABELS[tooling.setup];
66
86
  };
67
87
  const projectRowValue = (link) => {
68
88
  switch (link) {
package/dist/init/copy.js CHANGED
@@ -16,6 +16,12 @@ const AGENT_SETUP_SKIP_DESCRIPTION = "Continue to project setup without changing
16
16
  const AGENT_PICKER_MESSAGE = "Which coding agents should receive Neon tooling? (space to toggle, enter to confirm)";
17
17
  const AGENT_DETECTED_DESCRIPTION = "Detected on this machine or in this directory.";
18
18
  const AGENT_OTHER_DESCRIPTION = "Install for this agent.";
19
+ const agentMissingCommandDescription = (command) => `Detected, but "${command}" was not found on PATH. Its plugin cannot install.`;
20
+ const AGENT_PLUGIN_SKIP_MESSAGE = "No agents selected. Continue without installing the Neon plugin?";
21
+ const AGENT_SKILLS_PICKER_MESSAGE = "Which coding agents should receive Neon skills? (space to toggle, enter to confirm)";
22
+ const AGENT_MCP_PICKER_MESSAGE = "Which coding agents should receive the Neon MCP server? (space to toggle, enter to confirm)";
23
+ const AGENT_SKILLS_SKIP_MESSAGE = "No agents selected. Continue without installing Neon skills?";
24
+ const AGENT_MCP_SKIP_MESSAGE = "No agents selected. Continue without configuring the Neon MCP server?";
19
25
  const SKILLS_PICKER_MESSAGE = "Which Neon skills should be installed? (space to toggle, enter to confirm)";
20
26
  const MCP_SCOPE_MESSAGE = "Where should the Neon MCP server be configured?";
21
27
  const MCP_SCOPE_GLOBAL_TITLE = "User configuration";
@@ -130,4 +136,4 @@ const headingForOutcome = (outcome) => {
130
136
  }
131
137
  };
132
138
  //#endregion
133
- export { AGENT_DETECTED_DESCRIPTION, AGENT_OTHER_DESCRIPTION, AGENT_PICKER_MESSAGE, AGENT_SETUP_MESSAGE, AGENT_SETUP_PLUGIN_DESCRIPTION, AGENT_SETUP_PLUGIN_TITLE, AGENT_SETUP_SKILLS_DESCRIPTION, AGENT_SETUP_SKILLS_TITLE, AGENT_SETUP_SKIP_DESCRIPTION, AGENT_SETUP_SKIP_TITLE, CANCELLED_BODY, CLAIMABLE_ACCOUNT_FLAGS, CLAIMABLE_ALREADY_LINKED, CLAIMABLE_MCP_API_KEY, CLAIMABLE_NO_LINK, CONFIG_CONFIRM_HINT, CONFIG_CONFIRM_MESSAGE, HEADING_CANCELLED, HEADING_COMPLETE, HEADING_FAILED, HEADING_PENDING, INIT_SUBTITLE, MCP_AUTH_API_KEY_DESCRIPTION, MCP_AUTH_API_KEY_TITLE, MCP_AUTH_MESSAGE, MCP_AUTH_OAUTH_DESCRIPTION, MCP_AUTH_OAUTH_TITLE, MCP_SCOPED_NEEDS_PROJECT, MCP_SCOPE_GLOBAL_DESCRIPTION, MCP_SCOPE_GLOBAL_TITLE, MCP_SCOPE_MESSAGE, MCP_SCOPE_PROJECT_DESCRIPTION, MCP_SCOPE_PROJECT_TITLE, MODE_CUSTOM_DESCRIPTION, MODE_CUSTOM_TITLE, MODE_MESSAGE, MODE_RECOMMENDED_DESCRIPTION, MODE_RECOMMENDED_TITLE, NON_TTY_AGENT_SETUP, NON_TTY_LINK_NEEDS_AUTH, NO_AGENTS_FALLBACK_STATUS, NO_AGENT_SETUP_CONFLICT, PACKAGE_MANAGER_MESSAGE, PROGRESS, PROJECT_SETUP_CLAIMABLE_DESCRIPTION, PROJECT_SETUP_CLAIMABLE_TITLE, PROJECT_SETUP_LINK_DESCRIPTION, PROJECT_SETUP_LINK_TITLE, PROJECT_SETUP_MESSAGE, SERVICES_HINT, SERVICES_MESSAGE, SKILLS_PICKER_MESSAGE, YES_LINK_NEEDS_AUTH, claimableNext, claimableThenServicesNext, envPullFailedNext, existingConfigNext, extraServicesNext, headingForOutcome, installFailedNext, mcpConfigLocationSkipped, mcpConfigLocationUnavailable, namedAgentsUnavailable, skippedLinkNext, unattendedUnauthedNext };
139
+ export { AGENT_DETECTED_DESCRIPTION, AGENT_MCP_PICKER_MESSAGE, AGENT_MCP_SKIP_MESSAGE, AGENT_OTHER_DESCRIPTION, AGENT_PICKER_MESSAGE, AGENT_PLUGIN_SKIP_MESSAGE, AGENT_SETUP_MESSAGE, AGENT_SETUP_PLUGIN_DESCRIPTION, AGENT_SETUP_PLUGIN_TITLE, AGENT_SETUP_SKILLS_DESCRIPTION, AGENT_SETUP_SKILLS_TITLE, AGENT_SETUP_SKIP_DESCRIPTION, AGENT_SETUP_SKIP_TITLE, AGENT_SKILLS_PICKER_MESSAGE, AGENT_SKILLS_SKIP_MESSAGE, CANCELLED_BODY, CLAIMABLE_ACCOUNT_FLAGS, CLAIMABLE_ALREADY_LINKED, CLAIMABLE_MCP_API_KEY, CLAIMABLE_NO_LINK, CONFIG_CONFIRM_HINT, CONFIG_CONFIRM_MESSAGE, HEADING_CANCELLED, HEADING_COMPLETE, HEADING_FAILED, HEADING_PENDING, INIT_SUBTITLE, MCP_AUTH_API_KEY_DESCRIPTION, MCP_AUTH_API_KEY_TITLE, MCP_AUTH_MESSAGE, MCP_AUTH_OAUTH_DESCRIPTION, MCP_AUTH_OAUTH_TITLE, MCP_SCOPED_NEEDS_PROJECT, MCP_SCOPE_GLOBAL_DESCRIPTION, MCP_SCOPE_GLOBAL_TITLE, MCP_SCOPE_MESSAGE, MCP_SCOPE_PROJECT_DESCRIPTION, MCP_SCOPE_PROJECT_TITLE, MODE_CUSTOM_DESCRIPTION, MODE_CUSTOM_TITLE, MODE_MESSAGE, MODE_RECOMMENDED_DESCRIPTION, MODE_RECOMMENDED_TITLE, NON_TTY_AGENT_SETUP, NON_TTY_LINK_NEEDS_AUTH, NO_AGENTS_FALLBACK_STATUS, NO_AGENT_SETUP_CONFLICT, PACKAGE_MANAGER_MESSAGE, PROGRESS, PROJECT_SETUP_CLAIMABLE_DESCRIPTION, PROJECT_SETUP_CLAIMABLE_TITLE, PROJECT_SETUP_LINK_DESCRIPTION, PROJECT_SETUP_LINK_TITLE, PROJECT_SETUP_MESSAGE, SERVICES_HINT, SERVICES_MESSAGE, SKILLS_PICKER_MESSAGE, YES_LINK_NEEDS_AUTH, agentMissingCommandDescription, claimableNext, claimableThenServicesNext, envPullFailedNext, existingConfigNext, extraServicesNext, headingForOutcome, installFailedNext, mcpConfigLocationSkipped, mcpConfigLocationUnavailable, namedAgentsUnavailable, skippedLinkNext, unattendedUnauthedNext };
package/dist/init/mode.js CHANGED
@@ -18,10 +18,8 @@ const assertAgentSetupFlags = (input) => {
18
18
  };
19
19
  const inferInitAgentSetup = (input) => {
20
20
  if (input.skipAgents) return { kind: "skip" };
21
- const hasSkills = hasSkillsFlag(input.skills);
22
- if (hasSkills && input.hasMcpFlags) return { kind: "skills-mcp" };
23
- if (hasSkills) return { kind: "skills" };
24
21
  if (input.hasMcpFlags) return { kind: "skills-mcp" };
22
+ if (hasSkillsFlag(input.skills)) return { kind: "skills" };
25
23
  if (input.namedAgents || input.yes) return { kind: "auto" };
26
24
  if (input.canAsk) return { kind: "ask" };
27
25
  throw new Error(NON_TTY_AGENT_SETUP);