@exposify/cli 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/index.d.ts +318 -0
- package/dist/index.js +2215 -0
- package/package.json +41 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2215 @@
|
|
|
1
|
+
// src/run.ts
|
|
2
|
+
import { existsSync as existsSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
3
|
+
import { join as join4, relative } from "path";
|
|
4
|
+
|
|
5
|
+
// src/io.ts
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { createInterface } from "readline/promises";
|
|
11
|
+
async function ask(question, prefill) {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
rl.on("SIGINT", () => {
|
|
14
|
+
rl.close();
|
|
15
|
+
console.log("\nCancelled.");
|
|
16
|
+
process.exit(130);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
const answer = rl.question(question);
|
|
20
|
+
if (prefill) rl.write(prefill);
|
|
21
|
+
return (await answer).trim();
|
|
22
|
+
} finally {
|
|
23
|
+
rl.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function editInEditor(text) {
|
|
27
|
+
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
28
|
+
if (!editor) return null;
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "exposify-"));
|
|
30
|
+
const file = join(dir, "description.txt");
|
|
31
|
+
try {
|
|
32
|
+
writeFileSync(file, `${text}
|
|
33
|
+
`);
|
|
34
|
+
const result = spawnSync(editor, [file], { stdio: "inherit", shell: true });
|
|
35
|
+
if (result.status !== 0) return null;
|
|
36
|
+
return readFileSync(file, "utf8").replace(/\s+/g, " ").trim();
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/describe/file.ts
|
|
43
|
+
import { createHash } from "crypto";
|
|
44
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
45
|
+
import { DESCRIPTIONS_FILE, parseDescriptions } from "@exposify/core";
|
|
46
|
+
|
|
47
|
+
// src/checklist.ts
|
|
48
|
+
var routeKey = (r) => `${r.method} ${r.path}`;
|
|
49
|
+
var isRead = (r) => r.method === "GET" || r.method === "HEAD";
|
|
50
|
+
function isLocked(r) {
|
|
51
|
+
return r.marker !== void 0;
|
|
52
|
+
}
|
|
53
|
+
function initialState(routes, opts) {
|
|
54
|
+
const actions = opts.actions ?? [];
|
|
55
|
+
const listed = new Set(opts.listedActions ?? []);
|
|
56
|
+
const selected = /* @__PURE__ */ new Set();
|
|
57
|
+
for (const r of routes) {
|
|
58
|
+
const ticked = opts.hasConfig || isLocked(r) ? r.exposed : isRead(r);
|
|
59
|
+
if (ticked) selected.add(routeKey(r));
|
|
60
|
+
}
|
|
61
|
+
const readsOnly = new Set(routes.filter((r) => isLocked(r) ? r.exposed : isRead(r)).map(routeKey));
|
|
62
|
+
return {
|
|
63
|
+
routes,
|
|
64
|
+
selected,
|
|
65
|
+
actions,
|
|
66
|
+
// Actions are never ticked by default: the ones listed in config now.
|
|
67
|
+
selectedActions: new Set(actions.filter((a) => !isLocked(a) && listed.has(a.configKey)).map((a) => a.configKey)),
|
|
68
|
+
nuclear: opts.nuclear,
|
|
69
|
+
...opts.nuclear ? { beforeNuclear: readsOnly } : {},
|
|
70
|
+
cursor: 0,
|
|
71
|
+
filter: ""
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function visible(state) {
|
|
75
|
+
const f = state.filter.trim().toLowerCase();
|
|
76
|
+
if (!f) return state.routes;
|
|
77
|
+
return state.routes.filter((r) => routeKey(r).toLowerCase().includes(f) || r.tool.includes(f));
|
|
78
|
+
}
|
|
79
|
+
function visibleActions(state) {
|
|
80
|
+
const f = state.filter.trim().toLowerCase();
|
|
81
|
+
if (!f) return state.actions;
|
|
82
|
+
return state.actions.filter((a) => `${a.export} ${a.file} ${a.tool}`.toLowerCase().includes(f));
|
|
83
|
+
}
|
|
84
|
+
function focused(state) {
|
|
85
|
+
const routes = visible(state);
|
|
86
|
+
if (state.cursor < routes.length) return { route: routes[state.cursor] };
|
|
87
|
+
const action = visibleActions(state)[state.cursor - routes.length];
|
|
88
|
+
return action ? { action } : void 0;
|
|
89
|
+
}
|
|
90
|
+
function setTicks(state, pick, untickActions = false) {
|
|
91
|
+
const selected = new Set(state.selected);
|
|
92
|
+
for (const r of visible(state)) {
|
|
93
|
+
if (isLocked(r)) continue;
|
|
94
|
+
if (pick(r)) selected.add(routeKey(r));
|
|
95
|
+
else selected.delete(routeKey(r));
|
|
96
|
+
}
|
|
97
|
+
if (!untickActions) return { ...state, selected };
|
|
98
|
+
const selectedActions = new Set(state.selectedActions);
|
|
99
|
+
for (const a of visibleActions(state)) selectedActions.delete(a.configKey);
|
|
100
|
+
return { ...state, selected, selectedActions };
|
|
101
|
+
}
|
|
102
|
+
function reduce(state, action) {
|
|
103
|
+
switch (action.type) {
|
|
104
|
+
case "move": {
|
|
105
|
+
const n = visible(state).length + visibleActions(state).length;
|
|
106
|
+
if (n === 0) return { ...state, cursor: 0 };
|
|
107
|
+
return { ...state, cursor: Math.max(0, Math.min(n - 1, state.cursor + action.by)) };
|
|
108
|
+
}
|
|
109
|
+
case "toggle": {
|
|
110
|
+
const row = focused(state);
|
|
111
|
+
if (row && "action" in row) {
|
|
112
|
+
if (isLocked(row.action)) return state;
|
|
113
|
+
const selectedActions = new Set(state.selectedActions);
|
|
114
|
+
if (selectedActions.has(row.action.configKey)) selectedActions.delete(row.action.configKey);
|
|
115
|
+
else selectedActions.add(row.action.configKey);
|
|
116
|
+
return { ...state, selectedActions };
|
|
117
|
+
}
|
|
118
|
+
if (!row || isLocked(row.route)) return state;
|
|
119
|
+
const route = row.route;
|
|
120
|
+
const selected = new Set(state.selected);
|
|
121
|
+
const key = routeKey(route);
|
|
122
|
+
if (selected.has(key)) selected.delete(key);
|
|
123
|
+
else selected.add(key);
|
|
124
|
+
return { ...state, selected };
|
|
125
|
+
}
|
|
126
|
+
// "all" ticks every row but stays an explicit list; only the nuclear
|
|
127
|
+
// option produces expose: "*".
|
|
128
|
+
case "all":
|
|
129
|
+
return setTicks(state, () => true);
|
|
130
|
+
case "none":
|
|
131
|
+
return setTicks(state, () => false, true);
|
|
132
|
+
case "readsOnly":
|
|
133
|
+
return setTicks(state, isRead, true);
|
|
134
|
+
case "filter":
|
|
135
|
+
return { ...state, filter: action.text, cursor: 0 };
|
|
136
|
+
case "enterNuclear": {
|
|
137
|
+
if (state.nuclear) return state;
|
|
138
|
+
const selected = new Set(state.selected);
|
|
139
|
+
for (const r of state.routes) if (!isLocked(r)) selected.add(routeKey(r));
|
|
140
|
+
return { ...state, nuclear: true, beforeNuclear: state.selected, selected };
|
|
141
|
+
}
|
|
142
|
+
case "leaveNuclear": {
|
|
143
|
+
if (!state.nuclear) return state;
|
|
144
|
+
const { beforeNuclear, ...rest } = state;
|
|
145
|
+
return { ...rest, nuclear: false, selected: new Set(beforeNuclear ?? state.selected) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function nuclearWrites(routes) {
|
|
150
|
+
const order = (m) => m === "DELETE" ? 0 : m === "PUT" || m === "PATCH" ? 1 : 2;
|
|
151
|
+
return routes.filter((r) => !isRead(r) && !isLocked(r)).sort((a, b) => order(a.method) - order(b.method));
|
|
152
|
+
}
|
|
153
|
+
var NUCLEAR_PHRASE = "expose all";
|
|
154
|
+
|
|
155
|
+
// src/describe/file.ts
|
|
156
|
+
function signatureFor(route) {
|
|
157
|
+
const input = JSON.stringify([route.method, route.path, route.params, route.source ?? ""]);
|
|
158
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 16);
|
|
159
|
+
}
|
|
160
|
+
var emptyFile = () => ({ version: 1, descriptions: {} });
|
|
161
|
+
function readDescriptionsFile(path) {
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
text = readFileSync2(path, "utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
return emptyFile();
|
|
167
|
+
}
|
|
168
|
+
return parseDescriptions(text);
|
|
169
|
+
}
|
|
170
|
+
function renderDescriptionsFile(file) {
|
|
171
|
+
const sorted = {};
|
|
172
|
+
for (const key of Object.keys(file.descriptions).sort()) {
|
|
173
|
+
const e = file.descriptions[key];
|
|
174
|
+
sorted[key] = {
|
|
175
|
+
text: e.text,
|
|
176
|
+
source: e.source,
|
|
177
|
+
signature: e.signature,
|
|
178
|
+
...e.model ? { model: e.model } : {},
|
|
179
|
+
...e.generatedAt ? { generatedAt: e.generatedAt } : {}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const inputs = {};
|
|
183
|
+
for (const key of Object.keys(file.inputs ?? {}).sort()) {
|
|
184
|
+
const e = file.inputs[key];
|
|
185
|
+
inputs[key] = {
|
|
186
|
+
fields: e.fields,
|
|
187
|
+
...e.unknown?.length ? { unknown: e.unknown } : {},
|
|
188
|
+
source: e.source,
|
|
189
|
+
signature: e.signature,
|
|
190
|
+
...e.model ? { model: e.model } : {},
|
|
191
|
+
...e.generatedAt ? { generatedAt: e.generatedAt } : {}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return `${JSON.stringify({ version: 1, descriptions: sorted, ...Object.keys(inputs).length > 0 ? { inputs } : {} }, null, 2)}
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
function writeDescriptionsFile(path, file) {
|
|
198
|
+
writeFileSync2(path, renderDescriptionsFile(file));
|
|
199
|
+
}
|
|
200
|
+
function statusOf(route, file) {
|
|
201
|
+
if (route.describedText || route.openApiText) return { kind: "in-code" };
|
|
202
|
+
const entry = file.descriptions[routeKey(route)];
|
|
203
|
+
if (!entry) return { kind: "missing" };
|
|
204
|
+
return entry.signature === signatureFor(route) ? { kind: "current", entry } : { kind: "stale", entry };
|
|
205
|
+
}
|
|
206
|
+
function needingDescriptions(routes, file, opts = {}) {
|
|
207
|
+
return routes.filter((r) => {
|
|
208
|
+
if (!r.exposed) return false;
|
|
209
|
+
const s = statusOf(r, file);
|
|
210
|
+
if (s.kind === "missing") return true;
|
|
211
|
+
if (s.kind === "stale") return s.entry.source === "generated";
|
|
212
|
+
if (s.kind === "current") return Boolean(opts.regenerate) && s.entry.source === "generated";
|
|
213
|
+
return false;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function entryFor(route, text, source, model) {
|
|
217
|
+
return {
|
|
218
|
+
text: text.trim(),
|
|
219
|
+
source,
|
|
220
|
+
signature: signatureFor(route),
|
|
221
|
+
...model ? { model, generatedAt: (/* @__PURE__ */ new Date()).toISOString() } : {}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/checklist-tty.ts
|
|
226
|
+
var color = !process.env.NO_COLOR && Boolean(process.stdout.isTTY);
|
|
227
|
+
function disableColor() {
|
|
228
|
+
color = false;
|
|
229
|
+
}
|
|
230
|
+
var paint = (code) => (s) => color ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
231
|
+
var dim = paint("2");
|
|
232
|
+
var bold = paint("1");
|
|
233
|
+
var red = paint("31");
|
|
234
|
+
var green = paint("32");
|
|
235
|
+
var yellow = paint("33");
|
|
236
|
+
var cyan = paint("36");
|
|
237
|
+
function groupOf(path) {
|
|
238
|
+
const literal = [];
|
|
239
|
+
for (const seg of path.split("/").filter(Boolean)) {
|
|
240
|
+
if (seg.startsWith(":") || literal.length === 2) break;
|
|
241
|
+
literal.push(seg);
|
|
242
|
+
}
|
|
243
|
+
return `/${literal.join("/")}`;
|
|
244
|
+
}
|
|
245
|
+
function rowText(r, state, focused2) {
|
|
246
|
+
const ticked = state.selected.has(routeKey(r));
|
|
247
|
+
const box = isLocked(r) ? "[\xB7]" : ticked ? green("[x]") : "[ ]";
|
|
248
|
+
const method = r.method.padEnd(6);
|
|
249
|
+
const path = r.path.padEnd(36);
|
|
250
|
+
let note;
|
|
251
|
+
if (r.marker) note = dim(`(${r.marker === "described" ? "describe()" : `${r.marker}()`} in code)`);
|
|
252
|
+
else note = dim(r.derived ?? "");
|
|
253
|
+
const write = !isRead(r) && !isLocked(r) ? yellow(" <- write") : "";
|
|
254
|
+
const line = ` ${box} ${isRead(r) ? method : yellow(method)} ${path} ${note}${write}`;
|
|
255
|
+
return focused2 ? `${cyan(">")}${line.slice(1)}` : line;
|
|
256
|
+
}
|
|
257
|
+
var ACTION_STATUS = { complete: "input known", partial: "input partly unknown", flagged: "flagged" };
|
|
258
|
+
function actionText(a, state, focused2) {
|
|
259
|
+
const box = isLocked(a) ? "[\xB7]" : state.selectedActions.has(a.configKey) ? green("[x]") : "[ ]";
|
|
260
|
+
const where = dim(`${a.file}:${a.line}`.padEnd(36));
|
|
261
|
+
const note = isLocked(a) ? dim(`(${a.marker === "described" ? "describe()" : `${a.marker}()`} in code)`) : a.status === "complete" ? dim(ACTION_STATUS[a.status]) : yellow(ACTION_STATUS[a.status]);
|
|
262
|
+
const line = ` ${box} ${yellow(a.export.padEnd(24))} ${where} ${note}`;
|
|
263
|
+
return focused2 ? `${cyan(">")}${line.slice(1)}` : line;
|
|
264
|
+
}
|
|
265
|
+
function render(state, height, filtering) {
|
|
266
|
+
const out = [];
|
|
267
|
+
out.push(bold("Step 1/5 \xB7 Which endpoints should agents be able to call?"));
|
|
268
|
+
if (state.nuclear) {
|
|
269
|
+
out.push(red(bold(' expose: "*" -- every write route is exposed, including ones added later.')));
|
|
270
|
+
out.push(dim(" Un-tick rows to exclude them \xB7 x back to an explicit list \xB7 enter confirm"));
|
|
271
|
+
} else {
|
|
272
|
+
out.push(dim(" Reads are pre-selected. space toggle \xB7 a all \xB7 n none \xB7 r reads only \xB7 / filter"));
|
|
273
|
+
out.push(dim(' ! expose everything (expose: "*"), asks for confirmation \xB7 enter confirm \xB7 ctrl-c quit'));
|
|
274
|
+
}
|
|
275
|
+
out.push(filtering || state.filter ? ` filter: ${state.filter}${filtering ? "\u258F" : ""}` : "");
|
|
276
|
+
const rows = visible(state);
|
|
277
|
+
const lines = [];
|
|
278
|
+
let group = "";
|
|
279
|
+
rows.forEach((r, i) => {
|
|
280
|
+
const g = groupOf(r.path);
|
|
281
|
+
if (g !== group) {
|
|
282
|
+
group = g;
|
|
283
|
+
lines.push({ text: bold(` ${g}`) });
|
|
284
|
+
}
|
|
285
|
+
lines.push({ text: rowText(r, state, i === state.cursor), rowIndex: i });
|
|
286
|
+
});
|
|
287
|
+
const actions = visibleActions(state);
|
|
288
|
+
if (actions.length > 0) {
|
|
289
|
+
lines.push({ text: `${bold(" Server Actions")} ${dim("(writes: space picks each one; a and ! leave them alone)")}` });
|
|
290
|
+
actions.forEach((a, j) => {
|
|
291
|
+
const i = rows.length + j;
|
|
292
|
+
lines.push({ text: actionText(a, state, i === state.cursor), rowIndex: i });
|
|
293
|
+
if (i === state.cursor) for (const note of a.notes) lines.push({ text: dim(` ${note}`) });
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const viewport = Math.max(5, height - out.length - 3);
|
|
297
|
+
const cursorLine = lines.findIndex((l) => l.rowIndex === state.cursor);
|
|
298
|
+
const start = Math.max(0, Math.min(cursorLine - Math.floor(viewport / 2), lines.length - viewport));
|
|
299
|
+
const shown = lines.slice(start, start + viewport);
|
|
300
|
+
out.push(...shown.map((l) => l.text));
|
|
301
|
+
if (rows.length === 0 && actions.length === 0) out.push(dim(" (no endpoints match the filter)"));
|
|
302
|
+
const configurable = state.routes.filter((r) => !isLocked(r));
|
|
303
|
+
const picked = configurable.filter((r) => state.selected.has(routeKey(r)));
|
|
304
|
+
const writes = picked.filter((r) => !isRead(r)).length;
|
|
305
|
+
const more = lines.length > viewport ? dim(` \xB7 ${start + 1}-${start + shown.length} of ${lines.length} lines`) : "";
|
|
306
|
+
const counts = [
|
|
307
|
+
...state.routes.length > 0 ? [`${picked.length} of ${configurable.length} selected, ${writes} write${writes === 1 ? "" : "s"}`] : [],
|
|
308
|
+
...state.actions.length > 0 ? [`${state.selectedActions.size} of ${state.actions.filter((a) => !isLocked(a)).length} Server Actions`] : []
|
|
309
|
+
];
|
|
310
|
+
out.push("", ` ${counts.join(", ")}${more}`);
|
|
311
|
+
return out.join("\n");
|
|
312
|
+
}
|
|
313
|
+
function renderNuclearConfirm(state, typed, height) {
|
|
314
|
+
const writes = nuclearWrites(state.routes);
|
|
315
|
+
const out = [
|
|
316
|
+
red(bold('Expose everything (expose: "*")?')),
|
|
317
|
+
"",
|
|
318
|
+
` Agents will be able to call all ${writes.length} write route${writes.length === 1 ? "" : "s"}:`
|
|
319
|
+
];
|
|
320
|
+
const room = Math.max(3, height - 10);
|
|
321
|
+
for (const r of writes.slice(0, room)) out.push(` ${r.method === "DELETE" ? red(r.method.padEnd(6)) : yellow(r.method.padEnd(6))} ${r.path}`);
|
|
322
|
+
if (writes.length > room) out.push(dim(` \u2026 and ${writes.length - room} more`));
|
|
323
|
+
out.push(
|
|
324
|
+
"",
|
|
325
|
+
red(" Write routes added to your app later are exposed automatically, with no review."),
|
|
326
|
+
"",
|
|
327
|
+
` Type ${bold(NUCLEAR_PHRASE)} to confirm, or Esc to go back: ${typed}\u258F`
|
|
328
|
+
);
|
|
329
|
+
return out.join("\n");
|
|
330
|
+
}
|
|
331
|
+
function splitKeys(chunk) {
|
|
332
|
+
const keys = [];
|
|
333
|
+
for (let i = 0; i < chunk.length; ) {
|
|
334
|
+
if (chunk[i] === "\x1B" && chunk[i + 1] === "[") {
|
|
335
|
+
let j = i + 2;
|
|
336
|
+
while (j < chunk.length && !/[@-~]/.test(chunk[j])) j++;
|
|
337
|
+
keys.push(chunk.slice(i, j + 1));
|
|
338
|
+
i = j + 1;
|
|
339
|
+
} else {
|
|
340
|
+
const cp = chunk.codePointAt(i);
|
|
341
|
+
const ch = String.fromCodePoint(cp);
|
|
342
|
+
keys.push(ch);
|
|
343
|
+
i += ch.length;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return keys;
|
|
347
|
+
}
|
|
348
|
+
function runChecklist(initial) {
|
|
349
|
+
const stdin = process.stdin;
|
|
350
|
+
const stdout = process.stdout;
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
let state = initial;
|
|
353
|
+
let filtering = false;
|
|
354
|
+
let confirming = null;
|
|
355
|
+
const draw = () => {
|
|
356
|
+
const height = stdout.rows || 30;
|
|
357
|
+
const body = confirming !== null ? renderNuclearConfirm(state, confirming, height) : render(state, height, filtering);
|
|
358
|
+
stdout.write(`\x1B[H\x1B[2J${body}`);
|
|
359
|
+
};
|
|
360
|
+
const finish = (result) => {
|
|
361
|
+
stdin.off("data", onData);
|
|
362
|
+
stdin.setRawMode?.(false);
|
|
363
|
+
stdin.pause();
|
|
364
|
+
stdout.write("\x1B[?25h\x1B[?1049l");
|
|
365
|
+
resolve(result);
|
|
366
|
+
};
|
|
367
|
+
const act = (a) => {
|
|
368
|
+
state = reduce(state, a);
|
|
369
|
+
};
|
|
370
|
+
const onData = (buf) => {
|
|
371
|
+
for (const key of splitKeys(buf.toString("utf8"))) {
|
|
372
|
+
if (onKey(key) === "done") return;
|
|
373
|
+
}
|
|
374
|
+
draw();
|
|
375
|
+
};
|
|
376
|
+
const onKey = (key) => {
|
|
377
|
+
if (key === "") {
|
|
378
|
+
finish(null);
|
|
379
|
+
return "done";
|
|
380
|
+
}
|
|
381
|
+
if (confirming !== null) {
|
|
382
|
+
if (key === "\x1B") confirming = null;
|
|
383
|
+
else if (key === "\r") {
|
|
384
|
+
if (confirming.trim() === NUCLEAR_PHRASE) act({ type: "enterNuclear" });
|
|
385
|
+
confirming = confirming.trim() === NUCLEAR_PHRASE ? null : confirming;
|
|
386
|
+
} else if (key === "\x7F") confirming = confirming.slice(0, -1);
|
|
387
|
+
else if (key.length === 1 && key >= " ") confirming += key;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (filtering) {
|
|
391
|
+
if (key === "\r" || key === "\x1B") filtering = false;
|
|
392
|
+
else if (key === "\x7F") act({ type: "filter", text: state.filter.slice(0, -1) });
|
|
393
|
+
else if (key.length === 1 && key >= " ") act({ type: "filter", text: state.filter + key });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const page = Math.max(5, (stdout.rows || 30) - 10);
|
|
397
|
+
switch (key) {
|
|
398
|
+
case "\x1B[A":
|
|
399
|
+
case "k":
|
|
400
|
+
act({ type: "move", by: -1 });
|
|
401
|
+
break;
|
|
402
|
+
case "\x1B[B":
|
|
403
|
+
case "j":
|
|
404
|
+
act({ type: "move", by: 1 });
|
|
405
|
+
break;
|
|
406
|
+
case "\x1B[5~":
|
|
407
|
+
act({ type: "move", by: -page });
|
|
408
|
+
break;
|
|
409
|
+
case "\x1B[6~":
|
|
410
|
+
act({ type: "move", by: page });
|
|
411
|
+
break;
|
|
412
|
+
case " ":
|
|
413
|
+
act({ type: "toggle" });
|
|
414
|
+
break;
|
|
415
|
+
case "a":
|
|
416
|
+
act({ type: "all" });
|
|
417
|
+
break;
|
|
418
|
+
case "n":
|
|
419
|
+
act({ type: "none" });
|
|
420
|
+
break;
|
|
421
|
+
case "r":
|
|
422
|
+
act({ type: "readsOnly" });
|
|
423
|
+
break;
|
|
424
|
+
case "/":
|
|
425
|
+
filtering = true;
|
|
426
|
+
break;
|
|
427
|
+
case "\x1B":
|
|
428
|
+
if (state.filter) act({ type: "filter", text: "" });
|
|
429
|
+
break;
|
|
430
|
+
case "!":
|
|
431
|
+
if (!state.nuclear) confirming = "";
|
|
432
|
+
break;
|
|
433
|
+
case "x":
|
|
434
|
+
act({ type: "leaveNuclear" });
|
|
435
|
+
break;
|
|
436
|
+
case "\r":
|
|
437
|
+
finish(state);
|
|
438
|
+
return "done";
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
stdout.write("\x1B[?1049h\x1B[?25l");
|
|
442
|
+
stdin.setRawMode?.(true);
|
|
443
|
+
stdin.resume();
|
|
444
|
+
stdin.on("data", onData);
|
|
445
|
+
draw();
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/describe/credentials.ts
|
|
450
|
+
import { chmodSync, mkdirSync, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
451
|
+
import { homedir } from "os";
|
|
452
|
+
import { dirname, join as join2 } from "path";
|
|
453
|
+
var LICENSE_KEY_ENV = "EXPOSIFY_KEY";
|
|
454
|
+
function credentialsPath(env = process.env) {
|
|
455
|
+
const base = process.platform === "win32" && env.APPDATA ? env.APPDATA : env.XDG_CONFIG_HOME || join2(homedir(), ".config");
|
|
456
|
+
return join2(base, "exposify", "credentials.json");
|
|
457
|
+
}
|
|
458
|
+
function resolveLicenseKey(flag, env = process.env) {
|
|
459
|
+
if (flag) return { key: flag, from: "--license-key" };
|
|
460
|
+
if (env[LICENSE_KEY_ENV]) return { key: env[LICENSE_KEY_ENV], from: LICENSE_KEY_ENV };
|
|
461
|
+
try {
|
|
462
|
+
const saved = JSON.parse(readFileSync3(credentialsPath(env), "utf8"));
|
|
463
|
+
if (typeof saved.licenseKey === "string" && saved.licenseKey) return { key: saved.licenseKey, from: credentialsPath(env) };
|
|
464
|
+
} catch {
|
|
465
|
+
}
|
|
466
|
+
return void 0;
|
|
467
|
+
}
|
|
468
|
+
function saveLicenseKey(key, env = process.env) {
|
|
469
|
+
const path = credentialsPath(env);
|
|
470
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
471
|
+
writeFileSync3(path, `${JSON.stringify({ licenseKey: key }, null, 2)}
|
|
472
|
+
`, { mode: 384 });
|
|
473
|
+
chmodSync(path, 384);
|
|
474
|
+
return path;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/describe/prompt.ts
|
|
478
|
+
function redact(source) {
|
|
479
|
+
return source.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "<redacted private key>").replace(/\b(sk|pk|rk)[-_](live|test|ant|proj)?[-_]?[A-Za-z0-9_-]{16,}/g, "<redacted key>").replace(/\b(xox[abpr]-[A-Za-z0-9-]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})/g, "<redacted key>").replace(/(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/g, "$1 <redacted>").replace(
|
|
480
|
+
/(["'`])([A-Za-z0-9+/_=-]{32,})\1/g,
|
|
481
|
+
(match, quote, body) => (
|
|
482
|
+
// Long single-token literals with mixed character classes look like secrets; identifiers and paths don't.
|
|
483
|
+
/[0-9]/.test(body) && /[A-Za-z]/.test(body) && !body.includes("/") ? `${quote}<redacted>${quote}` : match
|
|
484
|
+
)
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/describe/service.ts
|
|
489
|
+
var DEFAULT_API_URL = "https://infra.agemno.com";
|
|
490
|
+
var API_URL_ENV = "EXPOSIFY_API_URL";
|
|
491
|
+
var MAX_ROUTES_PER_REQUEST = 25;
|
|
492
|
+
var REQUEST_TIMEOUT_MS = 18e4;
|
|
493
|
+
var ServiceError = class extends Error {
|
|
494
|
+
constructor(kind, message) {
|
|
495
|
+
super(message);
|
|
496
|
+
this.kind = kind;
|
|
497
|
+
}
|
|
498
|
+
kind;
|
|
499
|
+
};
|
|
500
|
+
function validateApiUrl(url) {
|
|
501
|
+
try {
|
|
502
|
+
const u = new URL(url);
|
|
503
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
504
|
+
if (u.protocol === "https:" || u.protocol === "http:" && local) return url.replace(/\/+$/, "");
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
function payloadFor(route, includeSource) {
|
|
510
|
+
return {
|
|
511
|
+
method: route.method,
|
|
512
|
+
path: route.path,
|
|
513
|
+
params: route.params,
|
|
514
|
+
...route.middleware?.length ? { middleware: route.middleware } : {},
|
|
515
|
+
...route.derived ? { derived: route.derived } : {},
|
|
516
|
+
...includeSource && route.source ? { source: redact(route.source) } : {}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function serviceClient(cfg, includeSource, fetchImpl = fetch) {
|
|
520
|
+
return async (routes) => {
|
|
521
|
+
const merged = { model: "", tier: "anonymous", results: /* @__PURE__ */ new Map() };
|
|
522
|
+
for (let i = 0; i < routes.length; i += MAX_ROUTES_PER_REQUEST) {
|
|
523
|
+
const chunk = routes.slice(i, i + MAX_ROUTES_PER_REQUEST);
|
|
524
|
+
let res;
|
|
525
|
+
try {
|
|
526
|
+
res = await fetchImpl(`${cfg.apiUrl}/api/v1/describe`, {
|
|
527
|
+
method: "POST",
|
|
528
|
+
headers: {
|
|
529
|
+
"content-type": "application/json",
|
|
530
|
+
...cfg.licenseKey ? { authorization: `Bearer ${cfg.licenseKey}` } : {}
|
|
531
|
+
},
|
|
532
|
+
body: JSON.stringify({ routes: chunk.map((r) => payloadFor(r, includeSource)) }),
|
|
533
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
534
|
+
});
|
|
535
|
+
} catch (err) {
|
|
536
|
+
throw new ServiceError("unreachable", `couldn't reach the description service at ${cfg.apiUrl} (${err.message})`);
|
|
537
|
+
}
|
|
538
|
+
if (res.status === 401) throw new ServiceError("invalid_key", "that secret key isn't an active project key");
|
|
539
|
+
if (res.status === 429) throw new ServiceError("rate_limited", "too many requests to the description service; wait a bit and retry");
|
|
540
|
+
if (res.status === 503) throw new ServiceError("unavailable", "the description service is unavailable right now");
|
|
541
|
+
if (!res.ok) throw new ServiceError("bad_response", `the description service answered HTTP ${res.status}`);
|
|
542
|
+
let body;
|
|
543
|
+
try {
|
|
544
|
+
body = await res.json();
|
|
545
|
+
} catch {
|
|
546
|
+
throw new ServiceError("bad_response", "the description service sent something that isn't JSON");
|
|
547
|
+
}
|
|
548
|
+
if (!Array.isArray(body.results)) throw new ServiceError("bad_response", "the description service's reply has no results");
|
|
549
|
+
merged.model = body.model ?? merged.model;
|
|
550
|
+
merged.tier = body.tier === "project" ? "project" : "anonymous";
|
|
551
|
+
if (body.signupUrl) merged.signupUrl = body.signupUrl;
|
|
552
|
+
if (typeof body.storedForDays === "number") merged.storedForDays = body.storedForDays;
|
|
553
|
+
const wanted = new Set(chunk.map(routeKey));
|
|
554
|
+
for (const r of body.results) {
|
|
555
|
+
if (!wanted.has(r.route)) continue;
|
|
556
|
+
if (typeof r.text === "string" && r.text.trim()) merged.results.set(r.route, { text: r.text.trim() });
|
|
557
|
+
else {
|
|
558
|
+
const error = r.error === "quota" || r.error === "declined" ? r.error : "failed";
|
|
559
|
+
merged.results.set(r.route, { error, message: r.message ?? "no description" });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if ([...merged.results.values()].some((o) => "error" in o && o.error === "quota")) {
|
|
563
|
+
for (const r of routes.slice(i + MAX_ROUTES_PER_REQUEST)) {
|
|
564
|
+
merged.results.set(routeKey(r), { error: "quota", message: "allowance used up" });
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return merged;
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function proposeClient(cfg, includeSource, fetchImpl = fetch) {
|
|
573
|
+
return async (actions) => {
|
|
574
|
+
const merged = { model: "", tier: "anonymous", results: /* @__PURE__ */ new Map() };
|
|
575
|
+
for (let i = 0; i < actions.length; i += MAX_ROUTES_PER_REQUEST) {
|
|
576
|
+
const chunk = actions.slice(i, i + MAX_ROUTES_PER_REQUEST).map((a) => ({
|
|
577
|
+
name: a.name,
|
|
578
|
+
file: a.file,
|
|
579
|
+
fields: a.fields,
|
|
580
|
+
unresolved: a.unresolved,
|
|
581
|
+
...includeSource && a.source ? { source: redact(a.source) } : {},
|
|
582
|
+
...includeSource && a.forms?.length ? { forms: a.forms.map(redact) } : {}
|
|
583
|
+
}));
|
|
584
|
+
let res;
|
|
585
|
+
try {
|
|
586
|
+
res = await fetchImpl(`${cfg.apiUrl}/api/v1/propose-input`, {
|
|
587
|
+
method: "POST",
|
|
588
|
+
headers: { "content-type": "application/json", ...cfg.licenseKey ? { authorization: `Bearer ${cfg.licenseKey}` } : {} },
|
|
589
|
+
body: JSON.stringify({ actions: chunk }),
|
|
590
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
591
|
+
});
|
|
592
|
+
} catch (err) {
|
|
593
|
+
throw new ServiceError("unreachable", `couldn't reach the description service at ${cfg.apiUrl} (${err.message})`);
|
|
594
|
+
}
|
|
595
|
+
if (res.status === 401) throw new ServiceError("invalid_key", "that license key isn't an active project key");
|
|
596
|
+
if (res.status === 429) throw new ServiceError("rate_limited", "too many requests to the description service; wait a bit and retry");
|
|
597
|
+
if (res.status === 503) throw new ServiceError("unavailable", "the description service is unavailable right now");
|
|
598
|
+
if (res.status === 404) throw new ServiceError("unavailable", "the description service doesn't propose inputs yet");
|
|
599
|
+
if (!res.ok) throw new ServiceError("bad_response", `the description service answered HTTP ${res.status}`);
|
|
600
|
+
let body;
|
|
601
|
+
try {
|
|
602
|
+
body = await res.json();
|
|
603
|
+
} catch {
|
|
604
|
+
throw new ServiceError("bad_response", "the description service sent something that isn't JSON");
|
|
605
|
+
}
|
|
606
|
+
if (!Array.isArray(body.results)) throw new ServiceError("bad_response", "the description service's reply has no results");
|
|
607
|
+
merged.model = body.model ?? merged.model;
|
|
608
|
+
merged.tier = body.tier === "project" ? "project" : "anonymous";
|
|
609
|
+
if (body.signupUrl) merged.signupUrl = body.signupUrl;
|
|
610
|
+
if (typeof body.storedForDays === "number") merged.storedForDays = body.storedForDays;
|
|
611
|
+
for (const r of body.results) {
|
|
612
|
+
if (Array.isArray(r.fields)) merged.results.set(r.action, { fields: r.fields, unknown: Array.isArray(r.unknown) ? r.unknown : [] });
|
|
613
|
+
else merged.results.set(r.action, { error: r.error === "quota" || r.error === "declined" ? r.error : "failed", message: r.message ?? "no proposal" });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return merged;
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/describe/flow.ts
|
|
621
|
+
async function propose(client, routes) {
|
|
622
|
+
const reply = await client(routes);
|
|
623
|
+
const byRoute = /* @__PURE__ */ new Map();
|
|
624
|
+
for (const r of routes) {
|
|
625
|
+
const o = reply.results.get(routeKey(r));
|
|
626
|
+
byRoute.set(routeKey(r), o ?? { error: "failed", message: "the service returned nothing for this route" });
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
byRoute,
|
|
630
|
+
model: reply.model,
|
|
631
|
+
tier: reply.tier,
|
|
632
|
+
...reply.signupUrl ? { signupUrl: reply.signupUrl } : {},
|
|
633
|
+
...reply.storedForDays !== void 0 ? { storedForDays: reply.storedForDays } : {}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
var quotaRoutes = (p, routes) => routes.filter((r) => {
|
|
637
|
+
const x = p.byRoute.get(routeKey(r));
|
|
638
|
+
return x && "error" in x && x.error === "quota";
|
|
639
|
+
});
|
|
640
|
+
async function upgradeWithLicenseKey(opts, proposals) {
|
|
641
|
+
const missed = quotaRoutes(proposals, opts.routes);
|
|
642
|
+
const got = opts.routes.length - missed.length;
|
|
643
|
+
console.log(
|
|
644
|
+
yellow(
|
|
645
|
+
got === 0 ? "\n This service describes routes for projects only (or today's free allowance is used up)." : `
|
|
646
|
+
Described ${got} of ${opts.routes.length}: that's the free allowance without an account.`
|
|
647
|
+
)
|
|
648
|
+
);
|
|
649
|
+
console.log(` Create a free project to describe the rest: ${bold(proposals.signupUrl ?? "the Exposify dashboard")}`);
|
|
650
|
+
for (; ; ) {
|
|
651
|
+
const key = await ask(` Paste its secret key ${dim("(enter to stop here)")}: `);
|
|
652
|
+
if (!key) return proposals;
|
|
653
|
+
const service = { ...opts.service, licenseKey: key };
|
|
654
|
+
const client = opts.client ?? serviceClient(service, opts.includeSource);
|
|
655
|
+
let more;
|
|
656
|
+
try {
|
|
657
|
+
more = await propose(client, missed);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
if (err instanceof ServiceError && err.kind === "invalid_key") {
|
|
660
|
+
console.log(red(" That key isn't an active project key. Check you copied all of it."));
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
console.log(red(` ${err.message}`));
|
|
664
|
+
return proposals;
|
|
665
|
+
}
|
|
666
|
+
opts.service.licenseKey = key;
|
|
667
|
+
const save = (await ask(` Save it to ${credentialsPath()} for next time? (Y/n) `)).toLowerCase();
|
|
668
|
+
if (save !== "n" && save !== "no") {
|
|
669
|
+
const path = saveLicenseKey(key);
|
|
670
|
+
console.log(dim(` Saved to ${path} (only you can read it). EXPOSIFY_KEY or --license-key override it.`));
|
|
671
|
+
}
|
|
672
|
+
for (const [k, v] of more.byRoute) proposals.byRoute.set(k, v);
|
|
673
|
+
return { ...proposals, model: more.model || proposals.model, tier: "project" };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function header(route, i, n) {
|
|
677
|
+
const kind = isRead(route) ? dim("read") : yellow("write");
|
|
678
|
+
console.log(`
|
|
679
|
+
${dim(`[${i}/${n}]`)} ${bold(`${route.method} ${route.path}`)} ${kind}`);
|
|
680
|
+
if (route.derived) console.log(` ${dim("now: ")} ${route.derived}`);
|
|
681
|
+
}
|
|
682
|
+
function proposalConsent(actions, host, includeSource) {
|
|
683
|
+
if (!includeSource) {
|
|
684
|
+
return [
|
|
685
|
+
` This sends only each action's name, file path and the fields the scan found (--no-source) to Exposify's description service (${host}), which proposes inputs with an LLM.`,
|
|
686
|
+
dim(" Without the code, it can rarely add anything: run without --no-source for useful proposals."),
|
|
687
|
+
dim(" The service may log each request, with your IP address, for debugging.")
|
|
688
|
+
];
|
|
689
|
+
}
|
|
690
|
+
const lines = [` This sends the following code to Exposify's description service (${host}), which proposes inputs with an LLM:`];
|
|
691
|
+
for (const a of actions) {
|
|
692
|
+
lines.push(` ${a.export}: ${a.file} ${dim("(the action's function)")}`);
|
|
693
|
+
for (const f of a.proposal?.formFiles ?? []) lines.push(` ${" ".repeat(a.export.length)} ${f} ${dim("(the whole file, up to 6,000 characters: it has a form that submits to it)")}`);
|
|
694
|
+
}
|
|
695
|
+
lines.push(dim(" Strings that look like secrets are masked first. Pass --no-source to send names and fields only."));
|
|
696
|
+
lines.push(dim(" The service may log each request, with your IP address, for debugging."));
|
|
697
|
+
return lines;
|
|
698
|
+
}
|
|
699
|
+
async function describeInteractively(opts) {
|
|
700
|
+
const file = { version: 1, descriptions: { ...opts.file.descriptions } };
|
|
701
|
+
let changed = 0;
|
|
702
|
+
const n = opts.routes.length;
|
|
703
|
+
console.log(`
|
|
704
|
+
${bold(opts.title)} \xB7 Tool descriptions ${dim("(what agents read to pick a tool)")}`);
|
|
705
|
+
if (n === 0) {
|
|
706
|
+
console.log(green(" \u2713 Every tool has a description."));
|
|
707
|
+
return { file, changed };
|
|
708
|
+
}
|
|
709
|
+
console.log(` ${n} tool${n === 1 ? " has" : "s have"} only an auto-derived description, like "${opts.routes[0].derived ?? `${opts.routes[0].method} ${opts.routes[0].path}`}".`);
|
|
710
|
+
for (; ; ) {
|
|
711
|
+
const choice = (await ask(` ${cyan("a")} generate with AI and review \xB7 ${cyan("w")} write them myself \xB7 ${cyan("s")} skip ${dim("(a)")} `)).toLowerCase();
|
|
712
|
+
if (choice === "s") {
|
|
713
|
+
console.log(dim(` Skipped: agents get the auto-derived descriptions. Run \`${opts.bin ?? "exposify"} describe\` any time.`));
|
|
714
|
+
return { file, changed };
|
|
715
|
+
}
|
|
716
|
+
if (choice === "w") {
|
|
717
|
+
changed += await writeByHand(opts.routes, file);
|
|
718
|
+
return { file, changed };
|
|
719
|
+
}
|
|
720
|
+
if (choice !== "" && choice !== "a") continue;
|
|
721
|
+
const host = new URL(opts.service.apiUrl).host;
|
|
722
|
+
const what = opts.includeSource ? "each route's handler source (strings that look like secrets are masked first)" : "only each route's method, path, parameters and middleware names (--no-source)";
|
|
723
|
+
console.log(` This sends ${what} to Exposify's description service (${host}), which writes them with an LLM.`);
|
|
724
|
+
console.log(dim(" The service may log each request, with your IP address, for debugging."));
|
|
725
|
+
const go = (await ask(" Send? (Y/n) ")).toLowerCase();
|
|
726
|
+
if (go === "n" || go === "no") continue;
|
|
727
|
+
const client = opts.client ?? serviceClient(opts.service, opts.includeSource);
|
|
728
|
+
let proposals;
|
|
729
|
+
process.stdout.write(dim(` Generating ${n} description${n === 1 ? "" : "s"} \u2026`));
|
|
730
|
+
try {
|
|
731
|
+
proposals = await propose(client, opts.routes);
|
|
732
|
+
process.stdout.write("\n");
|
|
733
|
+
} catch (err) {
|
|
734
|
+
process.stdout.write("\n");
|
|
735
|
+
console.log(red(` ${err.message}`));
|
|
736
|
+
if (err instanceof ServiceError && err.kind === "invalid_key") {
|
|
737
|
+
console.log(dim(` (key from ${resolveLicenseKey(void 0)?.from ?? "--license-key"}; fix or remove it)`));
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (proposals.storedForDays !== void 0) {
|
|
742
|
+
const days = proposals.storedForDays;
|
|
743
|
+
console.log(dim(` (The service keeps these requests${days === 0 ? "" : ` for ${days} day${days === 1 ? "" : "s"}`}.)`));
|
|
744
|
+
}
|
|
745
|
+
if (proposals.tier === "anonymous" && quotaRoutes(proposals, opts.routes).length > 0) {
|
|
746
|
+
proposals = await upgradeWithLicenseKey(opts, proposals);
|
|
747
|
+
}
|
|
748
|
+
changed += await review(opts, proposals, file);
|
|
749
|
+
return { file, changed };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
async function review(opts, proposals, file) {
|
|
753
|
+
let changed = 0;
|
|
754
|
+
let acceptReads = false;
|
|
755
|
+
const n = opts.routes.length;
|
|
756
|
+
const regenerate = async (route) => {
|
|
757
|
+
try {
|
|
758
|
+
const client = opts.client ?? serviceClient(opts.service, opts.includeSource);
|
|
759
|
+
return (await propose(client, [route])).byRoute.get(routeKey(route));
|
|
760
|
+
} catch (err) {
|
|
761
|
+
return { error: "failed", message: err.message };
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
for (const [i, route] of opts.routes.entries()) {
|
|
765
|
+
const key = routeKey(route);
|
|
766
|
+
let proposal = proposals.byRoute.get(key);
|
|
767
|
+
if (acceptReads && isRead(route) && "text" in proposal) {
|
|
768
|
+
file.descriptions[key] = entryFor(route, proposal.text, "generated", proposals.model);
|
|
769
|
+
changed++;
|
|
770
|
+
console.log(` ${dim(`[${i + 1}/${n}]`)} ${route.method} ${route.path}: ${green("accepted")}`);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
header(route, i + 1, n);
|
|
774
|
+
for (; ; ) {
|
|
775
|
+
if ("error" in proposal) {
|
|
776
|
+
if (proposal.error === "quota") {
|
|
777
|
+
console.log(` ${dim("no proposal: free allowance used up")}`);
|
|
778
|
+
const a3 = (await ask(` ${cyan("w")} write it \xB7 ${cyan("s")} skip ${dim("(s)")} `)).toLowerCase();
|
|
779
|
+
if (a3 === "w") changed += await writeOne(route, file);
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
console.log(` ${red("couldn't generate:")} ${proposal.message}`);
|
|
783
|
+
const a2 = (await ask(` ${cyan("r")} retry \xB7 ${cyan("w")} write it \xB7 ${cyan("s")} skip ${dim("(s)")} `)).toLowerCase();
|
|
784
|
+
if (a2 === "r") {
|
|
785
|
+
proposal = await regenerate(route);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (a2 === "w") changed += await writeOne(route, file);
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
console.log(` ${dim("proposed:")} ${proposal.text}`);
|
|
792
|
+
const bulk = isRead(route) ? ` \xB7 ${cyan("A")} accept this and the remaining reads` : "";
|
|
793
|
+
const a = await ask(` ${cyan("enter")} accept \xB7 ${cyan("e")} edit \xB7 ${cyan("r")} regenerate \xB7 ${cyan("s")} skip${bulk} `);
|
|
794
|
+
if (a === "" || a.toLowerCase() === "a" || a === "A" && isRead(route)) {
|
|
795
|
+
file.descriptions[key] = entryFor(route, proposal.text, "generated", proposals.model);
|
|
796
|
+
changed++;
|
|
797
|
+
if (a === "A") acceptReads = true;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
if (a.toLowerCase() === "e") {
|
|
801
|
+
const edited = (await ask(" edit: ", proposal.text)).trim();
|
|
802
|
+
if (edited) {
|
|
803
|
+
const unchanged = edited === proposal.text;
|
|
804
|
+
file.descriptions[key] = entryFor(route, edited, unchanged ? "generated" : "edited", unchanged ? proposals.model : void 0);
|
|
805
|
+
changed++;
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
if (a.toLowerCase() === "r") {
|
|
810
|
+
proposal = await regenerate(route);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (a.toLowerCase() === "s") break;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return changed;
|
|
817
|
+
}
|
|
818
|
+
async function writeOne(route, file) {
|
|
819
|
+
let text = await ask(` ${dim("yours (enter skips, e opens $EDITOR):")} `);
|
|
820
|
+
if (text.toLowerCase() === "e") text = editInEditor(route.derived ?? "") ?? await ask(" (no $EDITOR set) yours: ");
|
|
821
|
+
if (!text.trim()) return 0;
|
|
822
|
+
file.descriptions[routeKey(route)] = entryFor(route, text, "edited");
|
|
823
|
+
return 1;
|
|
824
|
+
}
|
|
825
|
+
async function writeByHand(routes, file) {
|
|
826
|
+
let changed = 0;
|
|
827
|
+
for (const [i, route] of routes.entries()) {
|
|
828
|
+
header(route, i + 1, routes.length);
|
|
829
|
+
changed += await writeOne(route, file);
|
|
830
|
+
}
|
|
831
|
+
return changed;
|
|
832
|
+
}
|
|
833
|
+
async function describeUnattended(opts) {
|
|
834
|
+
const file = { version: 1, descriptions: { ...opts.file.descriptions } };
|
|
835
|
+
const reads = opts.routes.filter(isRead);
|
|
836
|
+
const skippedWrites = opts.routes.filter((r) => !isRead(r));
|
|
837
|
+
const failed = [];
|
|
838
|
+
let changed = 0;
|
|
839
|
+
let quotaExhausted = false;
|
|
840
|
+
if (reads.length > 0) {
|
|
841
|
+
const proposals = await propose(opts.client ?? serviceClient(opts.service, opts.includeSource), reads);
|
|
842
|
+
for (const route of reads) {
|
|
843
|
+
const p = proposals.byRoute.get(routeKey(route));
|
|
844
|
+
if ("text" in p) {
|
|
845
|
+
file.descriptions[routeKey(route)] = entryFor(route, p.text, "generated", proposals.model);
|
|
846
|
+
changed++;
|
|
847
|
+
} else {
|
|
848
|
+
if (p.error === "quota") quotaExhausted = true;
|
|
849
|
+
failed.push({ route, error: p.message });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return { file, changed, skippedWrites, failed, quotaExhausted };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/snapshot.ts
|
|
857
|
+
var SNAPSHOT_FILE = "exposify.tools.json";
|
|
858
|
+
function snapshotOf(routes) {
|
|
859
|
+
const tools = {};
|
|
860
|
+
for (const r of [...routes].filter((x) => x.exposed).sort((a, b) => routeKey(a).localeCompare(routeKey(b)))) {
|
|
861
|
+
tools[routeKey(r)] = r.tool;
|
|
862
|
+
}
|
|
863
|
+
return { version: 1, tools };
|
|
864
|
+
}
|
|
865
|
+
function renderSnapshot(snapshot) {
|
|
866
|
+
return `${JSON.stringify(snapshot, null, 2)}
|
|
867
|
+
`;
|
|
868
|
+
}
|
|
869
|
+
function parseSnapshot(text) {
|
|
870
|
+
try {
|
|
871
|
+
const data = JSON.parse(text);
|
|
872
|
+
if (data.version !== 1 || typeof data.tools !== "object" || data.tools === null) return "not an exposify tools snapshot (version 1)";
|
|
873
|
+
return data;
|
|
874
|
+
} catch (err) {
|
|
875
|
+
return `invalid JSON: ${err.message}`;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
function diffSnapshot(snapshot, routes) {
|
|
879
|
+
const now = snapshotOf(routes).tools;
|
|
880
|
+
const byKey = new Map(routes.map((r) => [routeKey(r), r]));
|
|
881
|
+
const diff = { added: [], removed: [], renamed: [] };
|
|
882
|
+
for (const [route, tool] of Object.entries(now)) {
|
|
883
|
+
const before = snapshot.tools[route];
|
|
884
|
+
if (before === void 0) diff.added.push({ route, tool, write: !isRead(byKey.get(route)) });
|
|
885
|
+
else if (before !== tool) diff.renamed.push({ route, from: before, to: tool });
|
|
886
|
+
}
|
|
887
|
+
for (const [route, tool] of Object.entries(snapshot.tools)) {
|
|
888
|
+
if (!(route in now)) diff.removed.push({ route, tool });
|
|
889
|
+
}
|
|
890
|
+
diff.added.sort((a, b) => Number(b.write) - Number(a.write) || a.route.localeCompare(b.route));
|
|
891
|
+
return diff;
|
|
892
|
+
}
|
|
893
|
+
function isEmptyDiff(d) {
|
|
894
|
+
return d.added.length + d.removed.length + d.renamed.length === 0;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/run.ts
|
|
898
|
+
import { toolNames } from "@exposify/core";
|
|
899
|
+
|
|
900
|
+
// src/env-file.ts
|
|
901
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
902
|
+
import { parseEnv } from "util";
|
|
903
|
+
function readEnvFile(path) {
|
|
904
|
+
let text;
|
|
905
|
+
try {
|
|
906
|
+
text = readFileSync4(path, "utf8");
|
|
907
|
+
} catch (err) {
|
|
908
|
+
return err.code === "ENOENT" ? "no such file" : err.message;
|
|
909
|
+
}
|
|
910
|
+
return parseEnv(text);
|
|
911
|
+
}
|
|
912
|
+
function envForScan(fileVars, env) {
|
|
913
|
+
const out = {};
|
|
914
|
+
for (const [key, value] of Object.entries(fileVars)) if (env[key] === void 0) out[key] = value;
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/config.ts
|
|
919
|
+
import { decideExposure, matchesPathGlob, parseMethodEntry, resolveOptions } from "@exposify/core";
|
|
920
|
+
function buildConfig(routes, selected, opts) {
|
|
921
|
+
const configurable = routes.filter((r) => !isLocked(r));
|
|
922
|
+
const ticked = (r) => selected.has(routeKey(r));
|
|
923
|
+
const globs = (opts.current?.exclude ?? []).filter((e) => !parseMethodEntry(e));
|
|
924
|
+
const keptGlobs = [];
|
|
925
|
+
const droppedGlobs = [];
|
|
926
|
+
for (const glob of globs) {
|
|
927
|
+
if (configurable.some((r) => ticked(r) && matchesPathGlob(r.path, glob))) droppedGlobs.push(glob);
|
|
928
|
+
else keptGlobs.push(glob);
|
|
929
|
+
}
|
|
930
|
+
const coveredByGlob = (r) => keptGlobs.length > 0 && matchesPathGlob(r.path, keptGlobs);
|
|
931
|
+
const config = {};
|
|
932
|
+
if (opts.nuclear) {
|
|
933
|
+
config.expose = "*";
|
|
934
|
+
} else {
|
|
935
|
+
const writes = configurable.filter((r) => (r.optIn || !isRead(r)) && ticked(r)).map(routeKey);
|
|
936
|
+
if (writes.length > 0) config.expose = writes;
|
|
937
|
+
}
|
|
938
|
+
const exclude = [
|
|
939
|
+
...keptGlobs,
|
|
940
|
+
...configurable.filter((r) => !r.optIn && !ticked(r) && !coveredByGlob(r) && (opts.nuclear || isRead(r))).map(routeKey)
|
|
941
|
+
];
|
|
942
|
+
if (exclude.length > 0) config.exclude = exclude;
|
|
943
|
+
if (!opts.startupLog) config.startupLog = false;
|
|
944
|
+
return { config, droppedGlobs };
|
|
945
|
+
}
|
|
946
|
+
function exposedUnder(routes, config) {
|
|
947
|
+
const cfg = resolveOptions({ expose: config.expose, exclude: config.exclude });
|
|
948
|
+
const out = /* @__PURE__ */ new Set();
|
|
949
|
+
for (const r of routes) {
|
|
950
|
+
const marker = r.marker === "described" ? { kind: "described" } : r.marker ? { kind: r.marker } : void 0;
|
|
951
|
+
if (decideExposure({ method: r.method, path: r.path, params: r.params, marker, ...r.optIn ? { optIn: true } : {} }, cfg).exposed) out.add(routeKey(r));
|
|
952
|
+
}
|
|
953
|
+
return out;
|
|
954
|
+
}
|
|
955
|
+
var NUCLEAR_COMMENT = '// expose: "*" exposes every write route, including ones added later.';
|
|
956
|
+
function renderConfig(config, opts) {
|
|
957
|
+
const lines = [];
|
|
958
|
+
if (opts.importFrom) lines.push(`import { aiReady } from "${opts.importFrom}";`, "");
|
|
959
|
+
const body = [];
|
|
960
|
+
if (opts.otherKeys && opts.otherKeys.length > 0) {
|
|
961
|
+
body.push(` // keep your existing options here: ${opts.otherKeys.join(", ")}`);
|
|
962
|
+
}
|
|
963
|
+
body.push(...renderProps(config));
|
|
964
|
+
if (body.length === 0) lines.push("app.use(aiReady());");
|
|
965
|
+
else lines.push("app.use(aiReady({", ...body, "}));");
|
|
966
|
+
return lines.join("\n");
|
|
967
|
+
}
|
|
968
|
+
function renderProps(config) {
|
|
969
|
+
const body = [];
|
|
970
|
+
if (config.expose === "*") body.push(` ${NUCLEAR_COMMENT}`, ' expose: "*",');
|
|
971
|
+
else if (config.expose) body.push(...list("expose", config.expose));
|
|
972
|
+
if (config.exclude) body.push(...list("exclude", config.exclude));
|
|
973
|
+
if (config.startupLog === false) body.push(" startupLog: false,");
|
|
974
|
+
return body;
|
|
975
|
+
}
|
|
976
|
+
function list(key, values) {
|
|
977
|
+
const inline = ` ${key}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`;
|
|
978
|
+
if (inline.length <= 80) return [inline];
|
|
979
|
+
return [` ${key}: [`, ...values.map((v) => ` ${JSON.stringify(v)},`), " ],"];
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// src/apply.ts
|
|
983
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
984
|
+
import { dirname as dirname2 } from "path";
|
|
985
|
+
import { parse } from "@babel/parser";
|
|
986
|
+
function parseSource(source, file) {
|
|
987
|
+
const plugins = /\.tsx$/.test(file) ? ["typescript", "jsx"] : /\.[mc]?ts$/.test(file) ? ["typescript"] : ["jsx"];
|
|
988
|
+
try {
|
|
989
|
+
const ast = parse(source, { sourceType: "module", plugins, errorRecovery: true });
|
|
990
|
+
if (ast.errors?.length) return { blocked: "the file has syntax errors" };
|
|
991
|
+
return { program: ast.program };
|
|
992
|
+
} catch {
|
|
993
|
+
return { blocked: "couldn't parse the file" };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function walk(node, visit, parents = []) {
|
|
997
|
+
if (!node || typeof node !== "object") return;
|
|
998
|
+
if (Array.isArray(node)) {
|
|
999
|
+
for (const child of node) walk(child, visit, parents);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const n = node;
|
|
1003
|
+
if (typeof n.type !== "string") return;
|
|
1004
|
+
visit(n, parents);
|
|
1005
|
+
parents.push(n);
|
|
1006
|
+
for (const key of Object.keys(n)) {
|
|
1007
|
+
if (key === "loc" || key.endsWith("Comments")) continue;
|
|
1008
|
+
const value = n[key];
|
|
1009
|
+
if (value && typeof value === "object") walk(value, visit, parents);
|
|
1010
|
+
}
|
|
1011
|
+
parents.pop();
|
|
1012
|
+
}
|
|
1013
|
+
var unwrap = (n) => {
|
|
1014
|
+
while (n && (n.type === "TSAsExpression" || n.type === "TSSatisfiesExpression" || n.type === "TSNonNullExpression" || n.type === "ParenthesizedExpression")) n = n.expression;
|
|
1015
|
+
return n;
|
|
1016
|
+
};
|
|
1017
|
+
var keyName = (n) => n?.type === "Identifier" ? n.name : n?.type === "StringLiteral" ? n.value : void 0;
|
|
1018
|
+
var lineStart = (source, at) => source.lastIndexOf("\n", at - 1) + 1;
|
|
1019
|
+
var indentAt = (source, at) => /^[ \t]*/.exec(source.slice(lineStart(source, at)))[0];
|
|
1020
|
+
function applyEdits(source, edits) {
|
|
1021
|
+
let out = source;
|
|
1022
|
+
for (const e of [...edits].sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
1023
|
+
return out;
|
|
1024
|
+
}
|
|
1025
|
+
function propertyRange(source, prop) {
|
|
1026
|
+
let start = prop.start;
|
|
1027
|
+
let end = prop.end;
|
|
1028
|
+
while (source[end] === " " || source[end] === " ") end++;
|
|
1029
|
+
if (source[end] === ",") end++;
|
|
1030
|
+
const ownsLine = /^[ \t]*$/.test(source.slice(lineStart(source, start), start));
|
|
1031
|
+
if (!ownsLine) return { start, end };
|
|
1032
|
+
start = lineStart(source, start);
|
|
1033
|
+
while (source[end] === " " || source[end] === " ") end++;
|
|
1034
|
+
if (source[end] === "\r") end++;
|
|
1035
|
+
if (source[end] === "\n") end++;
|
|
1036
|
+
const prevLineStart = lineStart(source, start - 1);
|
|
1037
|
+
if (start > 0 && source.slice(prevLineStart, start).trim() === NUCLEAR_COMMENT) start = prevLineStart;
|
|
1038
|
+
return { start, end };
|
|
1039
|
+
}
|
|
1040
|
+
var withIndent = (lines, indent) => lines.map((l) => l.length > 0 ? indent + l : l).join("\n");
|
|
1041
|
+
function importEdit(source, program, spec) {
|
|
1042
|
+
const body = program.body;
|
|
1043
|
+
const has = body.some(
|
|
1044
|
+
(s) => s.type === "ImportDeclaration" && s.source.value === spec.from && s.specifiers.some((x) => x.imported?.name === spec.name || x.local?.name === spec.name)
|
|
1045
|
+
);
|
|
1046
|
+
if (has) return null;
|
|
1047
|
+
const imports = body.filter((s) => s.type === "ImportDeclaration");
|
|
1048
|
+
if (imports.length > 0) {
|
|
1049
|
+
const at = imports[imports.length - 1].end;
|
|
1050
|
+
return { start: at, end: at, text: `
|
|
1051
|
+
import { ${spec.name} } from "${spec.from}";` };
|
|
1052
|
+
}
|
|
1053
|
+
const requires = body.filter((s) => s.type === "VariableDeclaration" && source.slice(s.start, s.end).includes("require("));
|
|
1054
|
+
if (requires.length > 0) {
|
|
1055
|
+
const at = requires[requires.length - 1].end;
|
|
1056
|
+
return { start: at, end: at, text: `
|
|
1057
|
+
const { ${spec.name} } = require("${spec.from}");` };
|
|
1058
|
+
}
|
|
1059
|
+
return { blocked: `couldn't find where to import ${spec.name}` };
|
|
1060
|
+
}
|
|
1061
|
+
function replaceAiReadyOptions(input) {
|
|
1062
|
+
const { source, file, config } = input;
|
|
1063
|
+
const parsed = parseSource(source, file);
|
|
1064
|
+
if ("blocked" in parsed) return parsed;
|
|
1065
|
+
const calls = [];
|
|
1066
|
+
walk(parsed.program, (n) => {
|
|
1067
|
+
if (n.type === "CallExpression" && n.callee.type === "Identifier" && input.callNames.includes(n.callee.name)) calls.push(n);
|
|
1068
|
+
});
|
|
1069
|
+
const call = calls.find((c) => c.loc?.start.line === input.line) ?? (calls.length === 1 ? calls[0] : void 0);
|
|
1070
|
+
if (!call) return { blocked: `couldn't find the ${input.callNames[0]}() call at line ${input.line}` };
|
|
1071
|
+
const args = call.arguments;
|
|
1072
|
+
if (args.length < input.optionsArg) return { blocked: `the ${input.callNames[0]}() call doesn't have the arguments setup expects` };
|
|
1073
|
+
const options = unwrap(args[input.optionsArg]);
|
|
1074
|
+
if (options && options.type !== "ObjectExpression") return { blocked: "the options passed to aiReady() are a variable, not an object written in place" };
|
|
1075
|
+
const managed = /* @__PURE__ */ new Set(["expose", "exclude", "startupLog", ...input.actionEntries ? ["actions"] : []]);
|
|
1076
|
+
const propIndent = indentAt(source, call.start) + " ";
|
|
1077
|
+
const edits = [];
|
|
1078
|
+
const props = (options?.properties ?? []).filter((p) => p.type !== "ObjectProperty" || !managed.has(keyName(p.key) ?? ""));
|
|
1079
|
+
const managedProps = (options?.properties ?? []).filter((p) => p.type === "ObjectProperty" && managed.has(keyName(p.key) ?? ""));
|
|
1080
|
+
const odd = managedProps.find((p) => p.computed);
|
|
1081
|
+
if (odd) return { blocked: "an option is written with a computed key" };
|
|
1082
|
+
const newLines = renderProps(config);
|
|
1083
|
+
let actionsProp;
|
|
1084
|
+
if (input.actionEntries) {
|
|
1085
|
+
const picked = new Set(config.actions ?? []);
|
|
1086
|
+
actionsProp = managedProps.find((p) => keyName(p.key) === "actions");
|
|
1087
|
+
const existing = unwrap(actionsProp?.value);
|
|
1088
|
+
if (actionsProp && existing?.type !== "ObjectExpression") return { blocked: "`actions` isn't an object written in place" };
|
|
1089
|
+
if (picked.size > 0 && !actionsProp) {
|
|
1090
|
+
newLines.push(" actions: {", ...[...picked].flatMap((k) => (input.actionEntries[k] ?? `${k}: true,`).split("\n").map((l) => ` ${l}`)), " },");
|
|
1091
|
+
} else if (picked.size > 0) {
|
|
1092
|
+
const entries = existing.properties.filter((e) => e.type === "ObjectProperty");
|
|
1093
|
+
const have = /* @__PURE__ */ new Set();
|
|
1094
|
+
for (const e of entries) {
|
|
1095
|
+
const name = keyName(e.key);
|
|
1096
|
+
if (name !== void 0 && picked.has(name)) have.add(name);
|
|
1097
|
+
else {
|
|
1098
|
+
const r = propertyRange(source, e);
|
|
1099
|
+
edits.push({ start: r.start, end: r.end, text: "" });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const missing = [...picked].filter((k) => !have.has(k));
|
|
1103
|
+
if (missing.length > 0) {
|
|
1104
|
+
const outer = indentAt(source, actionsProp.start);
|
|
1105
|
+
const inner = outer + " ";
|
|
1106
|
+
const text = withIndent(missing.flatMap((k) => (input.actionEntries[k] ?? `${k}: true,`).split("\n")), inner);
|
|
1107
|
+
edits.push({ start: existing.start + 1, end: existing.start + 1, text: `
|
|
1108
|
+
${text}${/^\s*\n/.test(source.slice(existing.start + 1)) ? "" : `
|
|
1109
|
+
${outer}`}` });
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (actionsProp && (config.actions?.length ?? 0) > 0) managedProps.splice(managedProps.indexOf(actionsProp), 1);
|
|
1114
|
+
for (const p of managedProps) {
|
|
1115
|
+
const r = propertyRange(source, p);
|
|
1116
|
+
edits.push({ start: r.start, end: r.end, text: "" });
|
|
1117
|
+
}
|
|
1118
|
+
const hadActions = actionsProp !== void 0 && (config.actions?.length ?? 0) > 0;
|
|
1119
|
+
const remaining = props.length + (hadActions ? 1 : 0);
|
|
1120
|
+
if (newLines.length > 0) {
|
|
1121
|
+
const text = withIndent(newLines.map((l) => l.replace(/^ {2}/, "")), propIndent);
|
|
1122
|
+
if (options) {
|
|
1123
|
+
const closeIndent = indentAt(source, call.start);
|
|
1124
|
+
if (remaining === 0) {
|
|
1125
|
+
edits.length = 0;
|
|
1126
|
+
edits.push({ start: options.start, end: options.end, text: `{
|
|
1127
|
+
${text}
|
|
1128
|
+
${closeIndent}}` });
|
|
1129
|
+
} else {
|
|
1130
|
+
const next = source.slice(options.start + 1);
|
|
1131
|
+
edits.push({ start: options.start + 1, end: options.start + 1, text: `
|
|
1132
|
+
${text}${/^[ \t]*\r?\n/.test(next) ? "" : `
|
|
1133
|
+
${propIndent}`}` });
|
|
1134
|
+
}
|
|
1135
|
+
} else {
|
|
1136
|
+
const last = args[args.length - 1];
|
|
1137
|
+
const at = last ? last.end : call.end - 1;
|
|
1138
|
+
edits.push({ start: at, end: at, text: `${last ? ", " : ""}{
|
|
1139
|
+
${text}
|
|
1140
|
+
${indentAt(source, call.start)}}` });
|
|
1141
|
+
}
|
|
1142
|
+
} else if (options && remaining === 0 && managedProps.length > 0) {
|
|
1143
|
+
edits.length = 0;
|
|
1144
|
+
const prev = args[input.optionsArg - 1];
|
|
1145
|
+
edits.push({ start: prev ? prev.end : options.start, end: options.end, text: "" });
|
|
1146
|
+
}
|
|
1147
|
+
const imp = input.needsImport && !new RegExp(`from\\s+["']${input.needsImport.from}["']`).test(source) ? importEdit(source, parsed.program, input.needsImport) : null;
|
|
1148
|
+
if (imp && "blocked" in imp) return imp;
|
|
1149
|
+
if (imp) edits.push(imp);
|
|
1150
|
+
return { after: applyEdits(source, edits) };
|
|
1151
|
+
}
|
|
1152
|
+
function insertAiReadyCall(input) {
|
|
1153
|
+
const { source, file } = input;
|
|
1154
|
+
const parsed = parseSource(source, file);
|
|
1155
|
+
if ("blocked" in parsed) return parsed;
|
|
1156
|
+
const statements = [];
|
|
1157
|
+
walk(parsed.program, (n, parents) => {
|
|
1158
|
+
const parent = parents[parents.length - 1];
|
|
1159
|
+
if (parent && (parent.type === "Program" || parent.type === "BlockStatement") && (n.type.endsWith("Statement") || n.type.endsWith("Declaration"))) statements.push({ node: n, parent });
|
|
1160
|
+
});
|
|
1161
|
+
const startingAt = (line) => statements.find((s) => s.node.loc?.start.line === line)?.node;
|
|
1162
|
+
let appVar;
|
|
1163
|
+
walk(parsed.program, (n) => {
|
|
1164
|
+
if (appVar || n.type !== "VariableDeclarator" || n.id?.type !== "Identifier" || n.init?.type !== "CallExpression") return;
|
|
1165
|
+
if (input.createdLine ? n.loc?.start.line === input.createdLine : n.init.callee?.name === "express") appVar = n.id.name;
|
|
1166
|
+
});
|
|
1167
|
+
appVar ??= /\b(?:const|let|var)\s+app\b/.test(source) ? "app" : void 0;
|
|
1168
|
+
if (!appVar) return { blocked: "couldn't tell what your Express app variable is called" };
|
|
1169
|
+
const code = input.code.replace(/^app\./, `${appVar}.`);
|
|
1170
|
+
const edits = [];
|
|
1171
|
+
const before = input.beforeLine === void 0 ? void 0 : startingAt(input.beforeLine);
|
|
1172
|
+
const after = input.afterLine === void 0 ? void 0 : startingAt(input.afterLine);
|
|
1173
|
+
if (before) {
|
|
1174
|
+
const indent = indentAt(source, before.start);
|
|
1175
|
+
edits.push({ start: lineStart(source, before.start), end: lineStart(source, before.start), text: `${withIndent(code.split("\n"), indent)}
|
|
1176
|
+
` });
|
|
1177
|
+
} else if (after) {
|
|
1178
|
+
const indent = indentAt(source, after.start);
|
|
1179
|
+
edits.push({ start: after.end, end: after.end, text: `
|
|
1180
|
+
${withIndent(code.split("\n"), indent)}` });
|
|
1181
|
+
} else return { blocked: "couldn't find a statement to place it next to" };
|
|
1182
|
+
const imp = importEdit(source, parsed.program, { from: input.importFrom, name: "aiReady" });
|
|
1183
|
+
if (imp && "blocked" in imp) return imp;
|
|
1184
|
+
if (imp) edits.push(imp);
|
|
1185
|
+
return { after: applyEdits(source, edits) };
|
|
1186
|
+
}
|
|
1187
|
+
function writePlan(plan) {
|
|
1188
|
+
try {
|
|
1189
|
+
if (plan.before === null) {
|
|
1190
|
+
if (existsSync(plan.file)) return { ok: false, error: `${plan.file} already exists` };
|
|
1191
|
+
mkdirSync2(dirname2(plan.file), { recursive: true });
|
|
1192
|
+
} else if (readFileSync5(plan.file, "utf8") !== plan.before) {
|
|
1193
|
+
return { ok: false, error: "the file changed since it was scanned" };
|
|
1194
|
+
}
|
|
1195
|
+
writeFileSync4(plan.file, plan.after);
|
|
1196
|
+
return { ok: true };
|
|
1197
|
+
} catch (err) {
|
|
1198
|
+
return { ok: false, error: err.message };
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/clipboard.ts
|
|
1203
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1204
|
+
function commands(platform, env) {
|
|
1205
|
+
if (platform === "darwin") return [["pbcopy", []]];
|
|
1206
|
+
if (platform === "win32") return [["clip.exe", []], ["clip", []]];
|
|
1207
|
+
const out = [];
|
|
1208
|
+
if (env.WAYLAND_DISPLAY) out.push(["wl-copy", []]);
|
|
1209
|
+
if (env.DISPLAY) out.push(["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]);
|
|
1210
|
+
out.push(["clip.exe", []]);
|
|
1211
|
+
return out;
|
|
1212
|
+
}
|
|
1213
|
+
function copyToClipboard(text, deps = {}) {
|
|
1214
|
+
const env = deps.env ?? process.env;
|
|
1215
|
+
const run = deps.run ?? ((cmd, args, input) => {
|
|
1216
|
+
const r = spawnSync2(cmd, args, { input, stdio: ["pipe", "ignore", "ignore"], timeout: 3e3 });
|
|
1217
|
+
return r.status === 0 && !r.error;
|
|
1218
|
+
});
|
|
1219
|
+
if (!env.SSH_TTY && !env.SSH_CONNECTION) {
|
|
1220
|
+
for (const [cmd, args] of commands(deps.platform ?? process.platform, env)) {
|
|
1221
|
+
if (run(cmd, args, text)) return { method: cmd, certain: true };
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (deps.isTty ?? process.stdout.isTTY) {
|
|
1225
|
+
const write = deps.writeTty ?? ((s) => process.stdout.write(s));
|
|
1226
|
+
write(`\x1B]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
1227
|
+
return { method: "osc52", certain: false };
|
|
1228
|
+
}
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/sign-in.ts
|
|
1233
|
+
import { spawn } from "child_process";
|
|
1234
|
+
import { existsSync as existsSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1235
|
+
import { join as join3 } from "path";
|
|
1236
|
+
import { parseEnv as parseEnv2 } from "util";
|
|
1237
|
+
var KEY_ENV = "EXPOSIFY_KEY";
|
|
1238
|
+
var KNOWN = [
|
|
1239
|
+
{ test: (p) => p.startsWith("@auth0/") || p === "auth0" || p === "express-openid-connect", label: "Auth0", path: "oauth" },
|
|
1240
|
+
{ test: (p) => p.startsWith("@clerk/"), label: "Clerk", path: "oauth" },
|
|
1241
|
+
{ test: (p) => p.startsWith("@workos-inc/"), label: "WorkOS", path: "oauth" },
|
|
1242
|
+
{ test: (p) => p.startsWith("@kinde-oss/"), label: "Kinde", path: "oauth" },
|
|
1243
|
+
{ test: (p) => p === "@supabase/supabase-js" || p === "@supabase/ssr", label: "Supabase Auth", path: "broker" },
|
|
1244
|
+
{ test: (p) => p === "firebase" || p === "firebase-admin", label: "Firebase Auth", path: "broker" },
|
|
1245
|
+
{ test: (p) => p === "next-auth" || p === "@auth/core", label: "Auth.js (NextAuth)", path: "broker" },
|
|
1246
|
+
{ test: (p) => p === "better-auth", label: "Better Auth", path: "broker" },
|
|
1247
|
+
{ test: (p) => p === "lucia", label: "Lucia", path: "broker" },
|
|
1248
|
+
{ test: (p) => p === "passport", label: "Passport", path: "broker" },
|
|
1249
|
+
{ test: (p) => p === "iron-session", label: "iron-session", path: "broker" },
|
|
1250
|
+
{ test: (p) => p === "express-session" || p === "cookie-session", label: "server sessions", path: "broker" },
|
|
1251
|
+
{ test: (p) => p === "jsonwebtoken" || p === "jose" || p === "@nestjs/jwt", label: "your own JWTs", path: "broker" }
|
|
1252
|
+
];
|
|
1253
|
+
function detectAuthStacks(root) {
|
|
1254
|
+
let pkg;
|
|
1255
|
+
try {
|
|
1256
|
+
pkg = JSON.parse(readFileSync6(join3(root, "package.json"), "utf8"));
|
|
1257
|
+
} catch {
|
|
1258
|
+
return [];
|
|
1259
|
+
}
|
|
1260
|
+
const names = Object.keys({ ...pkg.devDependencies, ...pkg.dependencies });
|
|
1261
|
+
const found = [];
|
|
1262
|
+
for (const known of KNOWN) {
|
|
1263
|
+
const hit = names.find(known.test);
|
|
1264
|
+
if (hit && !found.some((f) => f.label === known.label)) found.push({ label: known.label, pkg: hit, path: known.path });
|
|
1265
|
+
}
|
|
1266
|
+
return found;
|
|
1267
|
+
}
|
|
1268
|
+
function appNameOf(root) {
|
|
1269
|
+
try {
|
|
1270
|
+
const name = JSON.parse(readFileSync6(join3(root, "package.json"), "utf8")).name;
|
|
1271
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
1272
|
+
} catch {
|
|
1273
|
+
}
|
|
1274
|
+
return "My app";
|
|
1275
|
+
}
|
|
1276
|
+
function existingKey(root, env) {
|
|
1277
|
+
if (env[KEY_ENV]?.trim()) return { from: "your shell" };
|
|
1278
|
+
try {
|
|
1279
|
+
const vars = parseEnv2(readFileSync6(join3(root, ".env"), "utf8"));
|
|
1280
|
+
if (vars[KEY_ENV]?.trim()) return { from: ".env" };
|
|
1281
|
+
} catch {
|
|
1282
|
+
}
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
function writeKeyToEnv(root, key) {
|
|
1286
|
+
const path = join3(root, ".env");
|
|
1287
|
+
const line = `${KEY_ENV}=${key}`;
|
|
1288
|
+
let created = false;
|
|
1289
|
+
if (existsSync2(path)) {
|
|
1290
|
+
const text = readFileSync6(path, "utf8");
|
|
1291
|
+
const re = new RegExp(`^\\s*(export\\s+)?${KEY_ENV}\\s*=.*$`, "m");
|
|
1292
|
+
const next = re.test(text) ? text.replace(re, line) : `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${line}
|
|
1293
|
+
`;
|
|
1294
|
+
writeFileSync5(path, next);
|
|
1295
|
+
} else {
|
|
1296
|
+
writeFileSync5(path, `${line}
|
|
1297
|
+
`, { mode: 384 });
|
|
1298
|
+
created = true;
|
|
1299
|
+
}
|
|
1300
|
+
let ignored = false;
|
|
1301
|
+
try {
|
|
1302
|
+
ignored = readFileSync6(join3(root, ".gitignore"), "utf8").split("\n").map((l) => l.trim()).some((l) => [".env", "/.env", ".env*", "/.env*", "*.env"].includes(l));
|
|
1303
|
+
} catch {
|
|
1304
|
+
}
|
|
1305
|
+
return { path, created, ignored };
|
|
1306
|
+
}
|
|
1307
|
+
async function signInToExposify(deps) {
|
|
1308
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
1309
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1310
|
+
let started;
|
|
1311
|
+
try {
|
|
1312
|
+
const res = await fetchImpl(`${deps.apiUrl}/api/v1/cli/logins`, {
|
|
1313
|
+
method: "POST",
|
|
1314
|
+
headers: { "content-type": "application/json" },
|
|
1315
|
+
body: JSON.stringify({ appName: deps.appName }),
|
|
1316
|
+
signal: AbortSignal.timeout(15e3)
|
|
1317
|
+
});
|
|
1318
|
+
if (!res.ok) {
|
|
1319
|
+
const body = await res.json().catch(() => ({}));
|
|
1320
|
+
return { ok: false, reason: "unreachable", message: body.error ?? `Exposify answered HTTP ${res.status}` };
|
|
1321
|
+
}
|
|
1322
|
+
started = await res.json();
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
return { ok: false, reason: "unreachable", message: `couldn't reach ${deps.apiUrl} (${err.message})` };
|
|
1325
|
+
}
|
|
1326
|
+
const opened = (deps.openUrl ?? openInBrowser)(started.verifyUrl);
|
|
1327
|
+
deps.show({ userCode: started.userCode, verifyUrl: started.verifyUrl, opened });
|
|
1328
|
+
const deadline = Date.now() + started.expiresIn * 1e3;
|
|
1329
|
+
let failures = 0;
|
|
1330
|
+
while (Date.now() < deadline) {
|
|
1331
|
+
await sleep(Math.max(1, started.interval) * 1e3);
|
|
1332
|
+
let res;
|
|
1333
|
+
try {
|
|
1334
|
+
res = await fetchImpl(`${deps.apiUrl}/api/v1/cli/logins/poll`, {
|
|
1335
|
+
method: "POST",
|
|
1336
|
+
headers: { "content-type": "application/json" },
|
|
1337
|
+
body: JSON.stringify({ deviceCode: started.deviceCode }),
|
|
1338
|
+
signal: AbortSignal.timeout(15e3)
|
|
1339
|
+
});
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
if (++failures >= 5) return { ok: false, reason: "unreachable", message: `lost contact with ${deps.apiUrl} (${err.message})` };
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
failures = 0;
|
|
1345
|
+
if (res.status === 202) continue;
|
|
1346
|
+
const body = await res.json().catch(() => ({}));
|
|
1347
|
+
if (res.ok && body.status === "approved" && body.key && body.projectId) {
|
|
1348
|
+
return { ok: true, key: body.key, projectId: body.projectId, projectName: body.projectName ?? "" };
|
|
1349
|
+
}
|
|
1350
|
+
if (body.status === "denied") return { ok: false, reason: "denied", message: "cancelled in the browser" };
|
|
1351
|
+
return { ok: false, reason: "expired", message: "the sign-in link expired" };
|
|
1352
|
+
}
|
|
1353
|
+
return { ok: false, reason: "expired", message: "the sign-in link expired" };
|
|
1354
|
+
}
|
|
1355
|
+
function openInBrowser(url) {
|
|
1356
|
+
if (process.env.EXPOSIFY_NO_BROWSER === "1") return false;
|
|
1357
|
+
const [cmd, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
1358
|
+
try {
|
|
1359
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
1360
|
+
child.on("error", () => {
|
|
1361
|
+
});
|
|
1362
|
+
child.unref();
|
|
1363
|
+
return true;
|
|
1364
|
+
} catch {
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/run.ts
|
|
1370
|
+
var usage = (bin, entryHelp) => `Usage: ${bin} <command> [options]
|
|
1371
|
+
|
|
1372
|
+
Commands:
|
|
1373
|
+
setup Pick which endpoints agents can call, then get the aiReady() config to paste
|
|
1374
|
+
inspect Show what agents see: every route, whether it's a tool, and why
|
|
1375
|
+
describe Give tools real descriptions (AI-proposed and reviewed, or your own) in
|
|
1376
|
+
${DESCRIPTIONS_FILE}, which aiReady() loads
|
|
1377
|
+
|
|
1378
|
+
Options:
|
|
1379
|
+
--entry <path> ${entryHelp} (default: detected)
|
|
1380
|
+
--env-vars <path> Load these variables into the scan (setup asks about .env otherwise)
|
|
1381
|
+
--no-env-vars Don't load any .env file (your shell's variables still apply)
|
|
1382
|
+
|
|
1383
|
+
setup:
|
|
1384
|
+
--quick Skip the questions (reads, or your current config; log unchanged),
|
|
1385
|
+
still copy the config and verify the paste
|
|
1386
|
+
--ci No prompts, no clipboard, no colors; fails instead of asking.
|
|
1387
|
+
On automatically when the CI environment variable is set.
|
|
1388
|
+
--expose "POST /x" Also expose this write route (repeatable); skips the checklist
|
|
1389
|
+
--expose-all Expose every write route (expose: "*"), including future ones
|
|
1390
|
+
--action <name> Also expose this Server Action (repeatable); skips the question
|
|
1391
|
+
--startup-log on|off Show or hide the "[agentready] MCP ready" line
|
|
1392
|
+
--copy / --no-copy Copy the config to the clipboard (default: copy unless --ci)
|
|
1393
|
+
--apply / --no-apply Make the change in your code instead of leaving it to paste (asked in a
|
|
1394
|
+
terminal otherwise; --quick and --ci only apply with --apply). Check it with git diff.
|
|
1395
|
+
--no-verify Skip the re-scan after you paste
|
|
1396
|
+
--snapshot Also save ${SNAPSHOT_FILE} (asked at the end otherwise; default no)
|
|
1397
|
+
--no-sign-in Skip "How your users sign in" (in a terminal, setup otherwise asks, and
|
|
1398
|
+
can fetch EXPOSIFY_KEY with a browser sign-in and write it to .env)
|
|
1399
|
+
|
|
1400
|
+
setup and describe (descriptions come from Exposify's service, not a provider key of yours):
|
|
1401
|
+
--no-source Send only method, path and middleware names, not handler source
|
|
1402
|
+
--license-key <key> A project secret key (else ${LICENSE_KEY_ENV}, else a saved one);
|
|
1403
|
+
without one you get a small free allowance
|
|
1404
|
+
--api-url <url> The description service (else ${API_URL_ENV}, else ${DEFAULT_API_URL})
|
|
1405
|
+
|
|
1406
|
+
describe:
|
|
1407
|
+
--check Exit 1 if a tool has no description or one is out of date (for CI)
|
|
1408
|
+
--regenerate Also redo AI descriptions that are still current (never your own)
|
|
1409
|
+
--ci AI descriptions for read routes only, no prompts; writes need a person
|
|
1410
|
+
|
|
1411
|
+
inspect:
|
|
1412
|
+
--json Print the full scan as JSON
|
|
1413
|
+
--snapshot Write ${SNAPSHOT_FILE}: the routes agents can call. Commit it.
|
|
1414
|
+
--check Exit 1 if the app's tools no longer match ${SNAPSHOT_FILE}, or a
|
|
1415
|
+
Server Action's written input no longer matches its code (for CI)
|
|
1416
|
+
--file <path> Snapshot path (default: ${SNAPSHOT_FILE} in the project root)
|
|
1417
|
+
`;
|
|
1418
|
+
function parseArgs(argv) {
|
|
1419
|
+
const args = {
|
|
1420
|
+
json: false,
|
|
1421
|
+
quick: false,
|
|
1422
|
+
ci: isCiEnv(process.env),
|
|
1423
|
+
snapshot: false,
|
|
1424
|
+
check: false,
|
|
1425
|
+
expose: [],
|
|
1426
|
+
exposeAll: false,
|
|
1427
|
+
actions: [],
|
|
1428
|
+
verify: true,
|
|
1429
|
+
signIn: true,
|
|
1430
|
+
help: false,
|
|
1431
|
+
noEnvFile: false,
|
|
1432
|
+
noSource: false,
|
|
1433
|
+
regenerate: false
|
|
1434
|
+
};
|
|
1435
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1436
|
+
const a = argv[i];
|
|
1437
|
+
const value = () => {
|
|
1438
|
+
const v = argv[++i];
|
|
1439
|
+
if (v === void 0) throw new UsageError(`${a} needs a value`);
|
|
1440
|
+
return v;
|
|
1441
|
+
};
|
|
1442
|
+
if (a === "--entry") args.entry = value();
|
|
1443
|
+
else if (a === "--json") args.json = true;
|
|
1444
|
+
else if (a === "--quick") args.quick = true;
|
|
1445
|
+
else if (a === "--ci") args.ci = true;
|
|
1446
|
+
else if (a === "--snapshot") args.snapshot = true;
|
|
1447
|
+
else if (a === "--check") args.check = true;
|
|
1448
|
+
else if (a === "--license-key") args.licenseKey = value();
|
|
1449
|
+
else if (a === "--api-url") args.apiUrl = value();
|
|
1450
|
+
else if (a === "--no-source") args.noSource = true;
|
|
1451
|
+
else if (a === "--regenerate") args.regenerate = true;
|
|
1452
|
+
else if (a === "--file") args.file = value();
|
|
1453
|
+
else if (a === "--env-vars") args.envFile = value();
|
|
1454
|
+
else if (a === "--no-env-vars") args.noEnvFile = true;
|
|
1455
|
+
else if (a === "--expose") args.expose.push(value());
|
|
1456
|
+
else if (a === "--expose-all") args.exposeAll = true;
|
|
1457
|
+
else if (a === "--action") args.actions.push(value());
|
|
1458
|
+
else if (a === "--startup-log") {
|
|
1459
|
+
const v = value();
|
|
1460
|
+
if (v !== "on" && v !== "off") throw new UsageError("--startup-log takes on or off");
|
|
1461
|
+
args.startupLog = v === "on";
|
|
1462
|
+
} else if (a === "--copy") args.copy = true;
|
|
1463
|
+
else if (a === "--no-copy") args.copy = false;
|
|
1464
|
+
else if (a === "--apply") args.apply = true;
|
|
1465
|
+
else if (a === "--no-apply") args.apply = false;
|
|
1466
|
+
else if (a === "--no-verify") args.verify = false;
|
|
1467
|
+
else if (a === "--no-sign-in") args.signIn = false;
|
|
1468
|
+
else if (a === "--help" || a === "-h") args.help = true;
|
|
1469
|
+
else if (!a.startsWith("-") && !args.command) args.command = a;
|
|
1470
|
+
else throw new UsageError(`unknown option: ${a}`);
|
|
1471
|
+
}
|
|
1472
|
+
return args;
|
|
1473
|
+
}
|
|
1474
|
+
var UsageError = class extends Error {
|
|
1475
|
+
};
|
|
1476
|
+
function isCiEnv(env) {
|
|
1477
|
+
const v = env.CI?.trim().toLowerCase();
|
|
1478
|
+
return v !== void 0 && v !== "" && v !== "false" && v !== "0";
|
|
1479
|
+
}
|
|
1480
|
+
var interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1481
|
+
function readLineAt(site) {
|
|
1482
|
+
try {
|
|
1483
|
+
return readFileSync7(site.file, "utf8").split("\n")[site.line - 1];
|
|
1484
|
+
} catch {
|
|
1485
|
+
return void 0;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function sortRoutes(routes) {
|
|
1489
|
+
const methodOrder = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
|
|
1490
|
+
return [...routes].sort(
|
|
1491
|
+
(a, b) => groupOf(a.path).localeCompare(groupOf(b.path)) || a.path.localeCompare(b.path) || methodOrder.indexOf(a.method) - methodOrder.indexOf(b.method)
|
|
1492
|
+
);
|
|
1493
|
+
}
|
|
1494
|
+
async function envFor(args, root, cwd, offer) {
|
|
1495
|
+
if (args.noEnvFile) return {};
|
|
1496
|
+
const explicit = args.envFile !== void 0;
|
|
1497
|
+
const path = explicit ? join4(cwd, args.envFile) : join4(root, ".env");
|
|
1498
|
+
if (!explicit && (!offer || args.ci || !existsSync3(path))) return {};
|
|
1499
|
+
const vars = readEnvFile(path);
|
|
1500
|
+
const shown = relative(cwd, path) || path;
|
|
1501
|
+
if (typeof vars === "string") {
|
|
1502
|
+
if (!explicit) return {};
|
|
1503
|
+
console.error(red(`Couldn't read ${shown}: ${vars}`));
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
const count = Object.keys(vars).length;
|
|
1507
|
+
const label = `${shown} (${count} variable${count === 1 ? "" : "s"})`;
|
|
1508
|
+
if (!explicit && !args.quick && interactive()) {
|
|
1509
|
+
console.log(`Found ${label}. Load it into the scan?`);
|
|
1510
|
+
console.log(dim(" Your app gets these as it would from `npm run dev`; nothing leaves this machine."));
|
|
1511
|
+
const answer = (await ask(" (Y/n) ")).toLowerCase();
|
|
1512
|
+
if (answer === "n" || answer === "no") return {};
|
|
1513
|
+
}
|
|
1514
|
+
if (!args.json) console.log(dim(`Loaded ${label}.`));
|
|
1515
|
+
return envForScan(vars, process.env);
|
|
1516
|
+
}
|
|
1517
|
+
var adapter;
|
|
1518
|
+
async function scan(args, cwd, offerEnv = false) {
|
|
1519
|
+
const location = adapter.locate(cwd, args.entry);
|
|
1520
|
+
if (!location) {
|
|
1521
|
+
console.error(red("Couldn't find your app's entry file."));
|
|
1522
|
+
console.error(` Pass it explicitly: ${adapter.bin} setup --entry ${adapter.entryExample}`);
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
const root = location.root;
|
|
1526
|
+
const env = await envFor(args, root, cwd, offerEnv);
|
|
1527
|
+
if (!env) return null;
|
|
1528
|
+
if (!args.json) console.log(dim(`Scanning ${relative(cwd, location.entry) || location.entry} \u2026`));
|
|
1529
|
+
const outcome = await adapter.scan(location, env);
|
|
1530
|
+
if (!outcome.ok) {
|
|
1531
|
+
console.error(red(`Scan failed: ${outcome.error}`));
|
|
1532
|
+
if (outcome.fix) console.error(` fix: ${outcome.fix}`);
|
|
1533
|
+
const tail = (outcome.output ?? "").trim().split("\n").slice(-12).join("\n");
|
|
1534
|
+
if (tail) console.error(dim(`
|
|
1535
|
+
Your app's output${outcome.loader ? ` (run with ${outcome.loader})` : ""}:
|
|
1536
|
+
${tail.replace(/^/gm, " ")}`));
|
|
1537
|
+
return null;
|
|
1538
|
+
}
|
|
1539
|
+
const result = outcome.result;
|
|
1540
|
+
const registered = result.routes;
|
|
1541
|
+
result.routes = sortRoutes(result.routes);
|
|
1542
|
+
if (!args.json) {
|
|
1543
|
+
const pages = result.routes.filter((r) => r.kind === "page").length;
|
|
1544
|
+
const n = result.routes.length - pages;
|
|
1545
|
+
const m = result.actions?.length ?? 0;
|
|
1546
|
+
const extra = [pages > 0 ? `${bold(String(pages))} page${pages === 1 ? "" : "s"}` : "", m > 0 ? `${bold(String(m))} Server Action${m === 1 ? "" : "s"}` : ""].filter(Boolean);
|
|
1547
|
+
const list2 = [`${bold(String(n))} endpoint${n === 1 ? "" : "s"}`, ...extra];
|
|
1548
|
+
console.log(`Found ${list2.length > 1 ? `${list2.slice(0, -1).join(", ")} and ${list2.at(-1)}` : list2[0]}.`);
|
|
1549
|
+
if (result.crashed) console.log(yellow(` Your app threw while starting (${result.crashed}); showing the routes it registered before that.`));
|
|
1550
|
+
for (const note of result.notes ?? []) console.log(yellow(` ${note}`));
|
|
1551
|
+
}
|
|
1552
|
+
return { result, root, registered, env, location };
|
|
1553
|
+
}
|
|
1554
|
+
function inspect(result, cwd) {
|
|
1555
|
+
const opts = result.aiReady?.options;
|
|
1556
|
+
const where = result.aiReady?.site ? `at ${relative(cwd, result.aiReady.site.file)}:${result.aiReady.site.line}` : "";
|
|
1557
|
+
console.log(result.aiReady ? `aiReady() ${where}` : yellow("aiReady() isn't mounted yet: showing the defaults (reads exposed, writes hidden)."));
|
|
1558
|
+
if (opts?.expose === "*") console.log(red(' expose: "*" -- every write route is exposed, including ones added later'));
|
|
1559
|
+
if (opts) console.log(dim(` startup log: ${opts.startupLog === false ? "off" : "on"}`));
|
|
1560
|
+
console.log("");
|
|
1561
|
+
if (result.routes.length === 0 && result.actions?.length) {
|
|
1562
|
+
console.log(dim(" No route handlers."));
|
|
1563
|
+
inspectActions(result, cwd);
|
|
1564
|
+
inspectLogin(result);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const toolWidth = Math.max(4, ...result.routes.map((r) => r.tool.length));
|
|
1568
|
+
const routeWidth = Math.max(5, ...result.routes.map((r) => r.method.length + 1 + r.path.length));
|
|
1569
|
+
console.log(dim(` ${"TOOL".padEnd(toolWidth)} ${"ROUTE".padEnd(routeWidth)} ${"TOOL?".padEnd(5)} WHY`));
|
|
1570
|
+
for (const r of result.routes) {
|
|
1571
|
+
const mark = r.exposed ? green("yes ") : dim("no ");
|
|
1572
|
+
const route = `${r.method} ${r.path}`.padEnd(routeWidth);
|
|
1573
|
+
const tool = r.tool.padEnd(toolWidth);
|
|
1574
|
+
console.log(` ${r.exposed ? tool : dim(tool)} ${route} ${mark} ${dim(r.reason)}`);
|
|
1575
|
+
}
|
|
1576
|
+
const tools = result.routes.filter((r) => r.exposed);
|
|
1577
|
+
const writes = tools.filter((r) => !isRead(r)).length;
|
|
1578
|
+
const pageTools = tools.filter((r) => r.kind === "page").length;
|
|
1579
|
+
const routeTools = tools.length - pageTools;
|
|
1580
|
+
const counts = [`${routeTools} route tool${routeTools === 1 ? "" : "s"} (${routeTools - writes} read, ${writes} write)`, ...pageTools > 0 ? [`${pageTools} page tool${pageTools === 1 ? "" : "s"}`] : []];
|
|
1581
|
+
console.log(`
|
|
1582
|
+
${counts.join(", ")}, plus search_docs when docs are found.`);
|
|
1583
|
+
if (result.actions && result.actions.length > 0) inspectActions(result, cwd);
|
|
1584
|
+
inspectLogin(result);
|
|
1585
|
+
}
|
|
1586
|
+
function inspectLogin(result) {
|
|
1587
|
+
if (!result.login) return;
|
|
1588
|
+
console.log(`
|
|
1589
|
+
Sign-in for AI assistants: your app's own login, ${result.login.what} (${result.login.fields.join(", ")}).`);
|
|
1590
|
+
console.log(dim(` With ${KEY_ENV} set, they sign in on a form your app serves at /mcp/login; passwords stay on your app.`));
|
|
1591
|
+
if (result.login.others?.length) console.log(dim(` Also looked like a login: ${result.login.others.join(", ")}. Pick another with aiReady({ login }).`));
|
|
1592
|
+
}
|
|
1593
|
+
var STATUS_TEXT = { complete: "input known", partial: "input partly unknown", flagged: "flagged" };
|
|
1594
|
+
function inspectActions(result, _cwd) {
|
|
1595
|
+
const actions = result.actions;
|
|
1596
|
+
console.log(`
|
|
1597
|
+
${bold("Server Actions")}`);
|
|
1598
|
+
const toolWidth = Math.max(4, ...actions.map((a) => a.tool.length));
|
|
1599
|
+
const whereWidth = Math.max(6, ...actions.map((a) => `${a.export} ${a.file}:${a.line}`.length));
|
|
1600
|
+
console.log(dim(` ${"TOOL".padEnd(toolWidth)} ${"ACTION".padEnd(whereWidth)} ${"TOOL?".padEnd(5)} WHY`));
|
|
1601
|
+
for (const a of actions) {
|
|
1602
|
+
const tool = a.tool.padEnd(toolWidth);
|
|
1603
|
+
const where = `${a.export} ${dim(`${a.file}:${a.line}`)}${" ".repeat(Math.max(0, whereWidth - `${a.export} ${a.file}:${a.line}`.length))}`;
|
|
1604
|
+
const status = a.status === "complete" ? dim(STATUS_TEXT[a.status]) : yellow(STATUS_TEXT[a.status]);
|
|
1605
|
+
console.log(` ${a.exposed ? tool : dim(tool)} ${where} ${a.exposed ? green("yes ") : dim("no ")} ${dim(a.reason)} \xB7 ${status}`);
|
|
1606
|
+
for (const note of a.notes) console.log(dim(` ${note}`));
|
|
1607
|
+
for (const d of a.drift) console.log(yellow(` ! ${d}`));
|
|
1608
|
+
}
|
|
1609
|
+
const exposed = actions.filter((a) => a.exposed).length;
|
|
1610
|
+
console.log(`
|
|
1611
|
+
${exposed} action tool${exposed === 1 ? "" : "s"} (writes).`);
|
|
1612
|
+
if (exposed > 0 && result.actionsNote) console.log(yellow(` ${result.actionsNote}`));
|
|
1613
|
+
}
|
|
1614
|
+
function toolRows(routes, actions = []) {
|
|
1615
|
+
return [
|
|
1616
|
+
...routes,
|
|
1617
|
+
...actions.map((a) => {
|
|
1618
|
+
const [method, path] = a.route.split(" ");
|
|
1619
|
+
return {
|
|
1620
|
+
method,
|
|
1621
|
+
path,
|
|
1622
|
+
params: [],
|
|
1623
|
+
tool: a.tool,
|
|
1624
|
+
exposed: a.exposed,
|
|
1625
|
+
reason: a.reason,
|
|
1626
|
+
derived: `Runs the ${a.export} server action (${a.file}).`,
|
|
1627
|
+
...a.source ? { source: a.source } : {},
|
|
1628
|
+
...a.describedText ? { describedText: a.describedText } : {}
|
|
1629
|
+
};
|
|
1630
|
+
})
|
|
1631
|
+
];
|
|
1632
|
+
}
|
|
1633
|
+
function checkDrift(result) {
|
|
1634
|
+
const drifted = (result.actions ?? []).filter((a) => a.drift.length > 0);
|
|
1635
|
+
if (drifted.length === 0) return 0;
|
|
1636
|
+
console.log(red(`\u2717 ${drifted.length} Server Action${drifted.length === 1 ? "'s" : "s'"} written input no longer match${drifted.length === 1 ? "es" : ""} the code:`));
|
|
1637
|
+
for (const a of drifted) for (const d of a.drift) console.log(` ~ ${a.export} (${a.file}): ${d}`);
|
|
1638
|
+
console.log(dim(` Update the input in aiReady({ actions }) (or describe()); \`${adapter.bin} setup\` prints what the scan finds.`));
|
|
1639
|
+
return 1;
|
|
1640
|
+
}
|
|
1641
|
+
function actionsFromFlags(result, args) {
|
|
1642
|
+
const actions = result.actions ?? [];
|
|
1643
|
+
const current = new Set(result.aiReady?.options.actions ?? []);
|
|
1644
|
+
const selected = new Set(actions.filter((a) => !a.marker && current.has(a.configKey)).map((a) => a.configKey));
|
|
1645
|
+
for (const name of args.actions) {
|
|
1646
|
+
const a = actions.find((x) => x.configKey === name || x.export === name || x.tool === name);
|
|
1647
|
+
if (!a) return `--action ${name} doesn't match any Server Action (they're listed by \`${adapter.bin} inspect\`)`;
|
|
1648
|
+
if (a.marker) return `--action ${name}: its exposure is set in code with ${a.marker}()`;
|
|
1649
|
+
selected.add(a.configKey);
|
|
1650
|
+
}
|
|
1651
|
+
return selected;
|
|
1652
|
+
}
|
|
1653
|
+
function selectionFromFlags(result, args) {
|
|
1654
|
+
const state = initialState(result.routes, { hasConfig: Boolean(result.aiReady), nuclear: false });
|
|
1655
|
+
const selected = new Set(state.selected);
|
|
1656
|
+
for (const entry of args.expose) {
|
|
1657
|
+
const [method, ...rest] = entry.trim().split(/\s+/);
|
|
1658
|
+
const key = `${method?.toUpperCase()} ${rest.join(" ")}`;
|
|
1659
|
+
const route = result.routes.find((r) => routeKey(r) === key);
|
|
1660
|
+
if (!route) return `--expose "${entry}" doesn't match any route (routes are listed by \`${adapter.bin} inspect\`)`;
|
|
1661
|
+
if (isLocked(route)) return `--expose "${entry}": that route's exposure is set in code with ${route.marker}()`;
|
|
1662
|
+
selected.add(key);
|
|
1663
|
+
}
|
|
1664
|
+
if (args.exposeAll) {
|
|
1665
|
+
for (const r of result.routes) if (!isLocked(r)) selected.add(routeKey(r));
|
|
1666
|
+
}
|
|
1667
|
+
return { selected, nuclear: args.exposeAll };
|
|
1668
|
+
}
|
|
1669
|
+
function differences(routes, want, got) {
|
|
1670
|
+
const out = [];
|
|
1671
|
+
for (const r of routes) {
|
|
1672
|
+
const key = routeKey(r);
|
|
1673
|
+
if (want.has(key) && !got.has(key)) out.push(`${key} is still hidden`);
|
|
1674
|
+
if (!want.has(key) && got.has(key)) out.push(`${key} is still exposed`);
|
|
1675
|
+
}
|
|
1676
|
+
return out;
|
|
1677
|
+
}
|
|
1678
|
+
async function setup(args, cwd) {
|
|
1679
|
+
const scanned = await scan(args, cwd, true);
|
|
1680
|
+
if (!scanned) return 1;
|
|
1681
|
+
const { result, root, registered, env, location } = scanned;
|
|
1682
|
+
if (result.routes.length === 0 && !result.actions?.length) {
|
|
1683
|
+
console.error(red("No routes found on your app."));
|
|
1684
|
+
console.error(` If routes are added after an await or in a sub-app, see the README's "Mounted routers" section.`);
|
|
1685
|
+
return 1;
|
|
1686
|
+
}
|
|
1687
|
+
const tty = interactive() && !args.ci;
|
|
1688
|
+
const selectionFromCli = args.ci || args.quick || args.exposeAll || args.expose.length > 0 || args.actions.length > 0;
|
|
1689
|
+
const current = result.aiReady?.options;
|
|
1690
|
+
const currentNuclear = current?.expose === "*";
|
|
1691
|
+
if (!interactive() && !selectionFromCli) {
|
|
1692
|
+
console.error("Not a terminal: pass --ci to accept the defaults (reads only), or --expose / --expose-all.");
|
|
1693
|
+
return 1;
|
|
1694
|
+
}
|
|
1695
|
+
let selected;
|
|
1696
|
+
let nuclear;
|
|
1697
|
+
let checklistActions;
|
|
1698
|
+
if (tty && !selectionFromCli) {
|
|
1699
|
+
const final = await runChecklist(
|
|
1700
|
+
initialState(result.routes, { hasConfig: Boolean(result.aiReady), nuclear: currentNuclear, actions: result.actions ?? [], listedActions: current?.actions ?? [] })
|
|
1701
|
+
);
|
|
1702
|
+
if (!final) {
|
|
1703
|
+
console.log("Cancelled.");
|
|
1704
|
+
return 130;
|
|
1705
|
+
}
|
|
1706
|
+
({ selected, nuclear } = final);
|
|
1707
|
+
checklistActions = final.selectedActions;
|
|
1708
|
+
} else if (result.routes.length === 0) {
|
|
1709
|
+
selected = /* @__PURE__ */ new Set();
|
|
1710
|
+
nuclear = false;
|
|
1711
|
+
} else {
|
|
1712
|
+
const picked2 = selectionFromFlags(result, args);
|
|
1713
|
+
if (typeof picked2 === "string") {
|
|
1714
|
+
console.error(red(picked2));
|
|
1715
|
+
return 1;
|
|
1716
|
+
}
|
|
1717
|
+
({ selected, nuclear } = picked2);
|
|
1718
|
+
}
|
|
1719
|
+
const picked = result.routes.filter((r) => selected.has(routeKey(r)));
|
|
1720
|
+
const pickedWrites = picked.filter((r) => !isRead(r)).length;
|
|
1721
|
+
if (result.routes.length > 0) {
|
|
1722
|
+
console.log(
|
|
1723
|
+
`
|
|
1724
|
+
${bold("Step 1/5")} \xB7 ${picked.length} tool${picked.length === 1 ? "" : "s"} (${picked.length - pickedWrites} read, ${pickedWrites} write)` + (nuclear ? red(' -- expose: "*": all write routes, including future ones') : "")
|
|
1725
|
+
);
|
|
1726
|
+
} else console.log(`
|
|
1727
|
+
${bold("Step 1/5")} \xB7 Server Actions`);
|
|
1728
|
+
if (selectionFromCli && currentNuclear && !nuclear) {
|
|
1729
|
+
console.log(yellow(' Your config has expose: "*". This writes it out as an explicit list, so write routes added later'));
|
|
1730
|
+
console.log(yellow(` won't be exposed automatically. Pass --expose-all to keep "*".`));
|
|
1731
|
+
}
|
|
1732
|
+
if (nuclear && selectionFromCli) {
|
|
1733
|
+
console.log(red(` expose: "*" exposes these ${nuclearWrites(result.routes).length} write routes, and any added later:`));
|
|
1734
|
+
for (const r of nuclearWrites(result.routes)) console.log(` ${r.method.padEnd(6)} ${r.path}`);
|
|
1735
|
+
}
|
|
1736
|
+
const pickedActions = checklistActions ?? actionsFromFlags(result, args);
|
|
1737
|
+
if (typeof pickedActions === "string") {
|
|
1738
|
+
console.error(red(pickedActions));
|
|
1739
|
+
return 1;
|
|
1740
|
+
}
|
|
1741
|
+
const actionRows = result.actions ?? [];
|
|
1742
|
+
const chosenActions = actionRows.filter((a) => pickedActions.has(a.configKey));
|
|
1743
|
+
if (actionRows.length > 0) {
|
|
1744
|
+
const inCode = actionRows.filter((a) => a.marker && a.exposed).length;
|
|
1745
|
+
console.log(` ${chosenActions.length} Server Action${chosenActions.length === 1 ? "" : "s"} picked${inCode > 0 ? `, ${inCode} exposed in code` : ""}.`);
|
|
1746
|
+
for (const a of chosenActions.filter((x) => x.status !== "complete")) console.log(yellow(` ! ${a.export}: ${a.notes.filter((n) => !n.startsWith("form: ")).join("; ")}`));
|
|
1747
|
+
if (chosenActions.length + inCode > 0 && result.actionsNote) console.log(yellow(` ${result.actionsNote}`));
|
|
1748
|
+
}
|
|
1749
|
+
let startupLog = args.startupLog ?? current?.startupLog ?? true;
|
|
1750
|
+
if (tty && !args.quick && args.startupLog === void 0) {
|
|
1751
|
+
console.log(`
|
|
1752
|
+
${bold("Step 2/5")} \xB7 Print "[agentready] MCP ready \u2026" when your server starts?`);
|
|
1753
|
+
console.log(dim(" It shows the tool count and auth mode. Failures always print."));
|
|
1754
|
+
const answer = (await ask(` ${startupLog ? "(Y/n)" : "(y/N)"} `)).toLowerCase();
|
|
1755
|
+
if (answer === "y" || answer === "yes") startupLog = true;
|
|
1756
|
+
else if (answer === "n" || answer === "no") startupLog = false;
|
|
1757
|
+
}
|
|
1758
|
+
const { config, droppedGlobs } = buildConfig(result.routes, selected, { nuclear, startupLog, current });
|
|
1759
|
+
if (pickedActions.size > 0) config.actions = actionRows.filter((a) => pickedActions.has(a.configKey)).map((a) => a.configKey);
|
|
1760
|
+
const sameActions = (listed = []) => listed.length === pickedActions.size && listed.every((k) => pickedActions.has(k));
|
|
1761
|
+
const effective = exposedUnder(result.routes, config);
|
|
1762
|
+
const mismatch = differences(result.routes, selected, effective);
|
|
1763
|
+
if (mismatch.length > 0) {
|
|
1764
|
+
console.error(red(`Internal error: the generated config wouldn't match your selection (${mismatch.join("; ")}). Please report this.`));
|
|
1765
|
+
return 1;
|
|
1766
|
+
}
|
|
1767
|
+
const alreadyExposed = new Set(result.routes.filter((r) => r.exposed).map(routeKey));
|
|
1768
|
+
const unchanged = result.aiReady && differences(result.routes, selected, alreadyExposed).length === 0 && (current?.startupLog ?? true) === startupLog && currentNuclear === nuclear && sameActions(current?.actions);
|
|
1769
|
+
if (unchanged) {
|
|
1770
|
+
console.log(`
|
|
1771
|
+
${green("\u2713 Already set up")}: your aiReady() config already matches. Nothing to change.`);
|
|
1772
|
+
if (tty && !args.quick) {
|
|
1773
|
+
await signInStep(args, root, cwd, "Step 4/5", result.login);
|
|
1774
|
+
const code2 = await describeStep(args, toolRows(result.routes, result.actions), root, cwd, "Step 5/5");
|
|
1775
|
+
if (code2 !== 0) return code2;
|
|
1776
|
+
}
|
|
1777
|
+
await offerSnapshot(args, tty, toolRows(result.routes, result.actions), root, cwd);
|
|
1778
|
+
return 0;
|
|
1779
|
+
}
|
|
1780
|
+
const { code, hint, apply } = adapter.configStep({ result, config, current, root, readLine: readLineAt });
|
|
1781
|
+
const shouldCopy = args.copy ?? tty;
|
|
1782
|
+
const copied = shouldCopy ? copyToClipboard(code) : null;
|
|
1783
|
+
const copyNote = copied?.certain ? green(" (copied to clipboard \u2713)") : copied ? dim(" (sent to your terminal's clipboard, if it supports that)") : "";
|
|
1784
|
+
console.log(`
|
|
1785
|
+
${bold("Step 3/5")} \xB7 Your aiReady() config${copyNote}
|
|
1786
|
+
`);
|
|
1787
|
+
console.log(code.replace(/^/gm, " "));
|
|
1788
|
+
let applied = false;
|
|
1789
|
+
if (args.apply !== false && (args.apply || tty && !args.quick)) {
|
|
1790
|
+
if (!apply) {
|
|
1791
|
+
if (args.apply) console.log(yellow("\n Can't apply automatically here: paste it as described below."));
|
|
1792
|
+
} else if ("blocked" in apply) {
|
|
1793
|
+
console.log(yellow(`
|
|
1794
|
+
Can't apply automatically: ${apply.blocked}. Paste it as described below.`));
|
|
1795
|
+
} else {
|
|
1796
|
+
const { summary } = apply.plan;
|
|
1797
|
+
const yes = args.apply === true || ["y", "yes"].includes((await ask(`
|
|
1798
|
+
Apply this: ${summary}? ${dim("(y/N)")} `)).toLowerCase());
|
|
1799
|
+
if (yes) {
|
|
1800
|
+
const written = writePlan(apply.plan);
|
|
1801
|
+
if (written.ok) {
|
|
1802
|
+
applied = true;
|
|
1803
|
+
console.log(green(` \u2713 Applied: ${summary}.`) + dim(apply.plan.before === null ? "" : " Review it with git diff."));
|
|
1804
|
+
} else console.log(yellow(` Couldn't apply: ${written.error}. Paste it as described below.`));
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
if (!applied) console.log(`
|
|
1809
|
+
${hint.lines[0]}`);
|
|
1810
|
+
for (const line of hint.lines.slice(1)) console.log(` ${line}`);
|
|
1811
|
+
if (hint.warning) console.log(yellow(` ! ${hint.warning}`));
|
|
1812
|
+
if (droppedGlobs.length > 0) {
|
|
1813
|
+
console.log(yellow(` ! Dropped exclude ${droppedGlobs.map((g) => `"${g}"`).join(", ")}: it would hide endpoints you ticked.`));
|
|
1814
|
+
}
|
|
1815
|
+
printSuggestions(result);
|
|
1816
|
+
let verified = null;
|
|
1817
|
+
let verifiedActions = [];
|
|
1818
|
+
if (tty && args.verify) {
|
|
1819
|
+
let firstRound = applied;
|
|
1820
|
+
for (; ; ) {
|
|
1821
|
+
const answer = firstRound ? "" : (await ask(`
|
|
1822
|
+
Press Enter once pasted to verify ${dim("(s to skip)")} `)).toLowerCase();
|
|
1823
|
+
firstRound = false;
|
|
1824
|
+
if (answer === "s") break;
|
|
1825
|
+
const again = await adapter.scan(location, env);
|
|
1826
|
+
if (!again.ok) {
|
|
1827
|
+
console.log(red(` Couldn't re-scan: ${again.error}`));
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
const after = again.result;
|
|
1831
|
+
const now = new Set(after.routes.filter((r) => r.exposed).map(routeKey));
|
|
1832
|
+
const diffs = differences(result.routes, selected, now);
|
|
1833
|
+
if (!after.aiReady) diffs.unshift("aiReady() isn't mounted yet");
|
|
1834
|
+
if ((after.aiReady?.options.startupLog ?? true) !== startupLog) diffs.push(`startup log is ${startupLog ? "off" : "on"}`);
|
|
1835
|
+
for (const a of after.actions ?? []) {
|
|
1836
|
+
if (a.marker) continue;
|
|
1837
|
+
const want = pickedActions.has(a.configKey);
|
|
1838
|
+
if (want && !a.exposed) diffs.push(`Server Action ${a.export} is still hidden (${a.reason})`);
|
|
1839
|
+
if (!want && a.exposed) diffs.push(`Server Action ${a.export} is still exposed`);
|
|
1840
|
+
}
|
|
1841
|
+
if (diffs.length === 0) {
|
|
1842
|
+
const writes = after.routes.filter((r) => r.exposed && !isRead(r)).length;
|
|
1843
|
+
const pageTools = after.routes.filter((r) => r.exposed && r.kind === "page").length;
|
|
1844
|
+
const tools = after.routes.filter((r) => r.exposed).length - pageTools;
|
|
1845
|
+
const pages = pageTools > 0 ? ` and ${pageTools} page tool${pageTools === 1 ? "" : "s"}` : "";
|
|
1846
|
+
console.log(green(` \u2713 Verified: ${tools} route tools (${tools - writes} read, ${writes} write)${pages}, matching your selection.`));
|
|
1847
|
+
verified = toolRows(after.routes, after.actions);
|
|
1848
|
+
verifiedActions = after.actions ?? [];
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
console.log(yellow(" Not yet:"));
|
|
1852
|
+
for (const d of diffs.slice(0, 8)) console.log(` - ${d}`);
|
|
1853
|
+
if (diffs.length > 8) console.log(dim(` \u2026 and ${diffs.length - 8} more`));
|
|
1854
|
+
console.log(dim(" Check the paste location above, save the file, and press Enter to re-check."));
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
const finalRoutes = verified ?? toolRows(routesAsSelected(registered, selected), actionRows.map((a) => ({ ...a, exposed: a.marker ? a.exposed : pickedActions.has(a.configKey) })));
|
|
1858
|
+
if (tty && !args.quick) {
|
|
1859
|
+
await signInStep(args, root, cwd, "Step 4/5", result.login);
|
|
1860
|
+
const code2 = await describeStep(args, finalRoutes, root, cwd, "Step 5/5");
|
|
1861
|
+
if (code2 !== 0) return code2;
|
|
1862
|
+
const proposed = await proposeStep(args, verifiedActions, root, cwd);
|
|
1863
|
+
if (proposed !== 0) return proposed;
|
|
1864
|
+
} else {
|
|
1865
|
+
console.log(dim(`
|
|
1866
|
+
Descriptions: run \`${adapter.bin} describe\` to replace the auto-derived ones.`));
|
|
1867
|
+
}
|
|
1868
|
+
await offerSnapshot(args, tty, finalRoutes, root, cwd);
|
|
1869
|
+
console.log(`
|
|
1870
|
+
${dim("Next:")} run ${cyan(`${adapter.bin} inspect`)} any time to see what agents get.`);
|
|
1871
|
+
return 0;
|
|
1872
|
+
}
|
|
1873
|
+
function printSuggestions(result) {
|
|
1874
|
+
if (!result.suggestions?.length) return;
|
|
1875
|
+
console.log(`
|
|
1876
|
+
${bold("Suggested changes")} ${dim("(not applied: read them, then make them yourself if they fit)")}`);
|
|
1877
|
+
for (const s of result.suggestions) {
|
|
1878
|
+
console.log(`
|
|
1879
|
+
${s.title}
|
|
1880
|
+
`);
|
|
1881
|
+
for (const line of s.diff.split("\n")) {
|
|
1882
|
+
const painted = line.startsWith("+++") || line.startsWith("---") ? bold(line) : line.startsWith("+") ? green(line) : line.startsWith("-") ? red(line) : dim(line);
|
|
1883
|
+
console.log(` ${painted}`);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
function routesAsSelected(registered, selected) {
|
|
1888
|
+
const names = toolNames(registered, (r) => selected.has(routeKey(r)));
|
|
1889
|
+
return registered.map((r) => ({ ...r, exposed: selected.has(routeKey(r)), tool: names.get(routeKey(r)) }));
|
|
1890
|
+
}
|
|
1891
|
+
async function offerSnapshot(args, tty, routes, root, cwd) {
|
|
1892
|
+
const file = args.file ? join4(cwd, args.file) : join4(root, SNAPSHOT_FILE);
|
|
1893
|
+
let save = args.snapshot;
|
|
1894
|
+
if (!save && tty && !args.quick) {
|
|
1895
|
+
const shown = relative(cwd, file) || file;
|
|
1896
|
+
console.log(`
|
|
1897
|
+
${bold("Optional")} \xB7 Save the tools agents get to ${shown}?`);
|
|
1898
|
+
console.log(dim(` Commit it and run \`${adapter.bin} inspect --check\` in CI to catch tool changes.`));
|
|
1899
|
+
const answer = (await ask(` ${existsSync3(file) ? "Overwrite it? " : ""}(y/N) `)).toLowerCase();
|
|
1900
|
+
save = answer === "y" || answer === "yes";
|
|
1901
|
+
}
|
|
1902
|
+
if (save) writeSnapshot(routes, file, cwd);
|
|
1903
|
+
}
|
|
1904
|
+
async function signInStep(args, root, cwd, title, login) {
|
|
1905
|
+
if (!args.signIn) return;
|
|
1906
|
+
const stacks = detectAuthStacks(root);
|
|
1907
|
+
const oauth = stacks.find((s) => s.path === "oauth");
|
|
1908
|
+
console.log(`
|
|
1909
|
+
${bold(title)} \xB7 How your users sign in`);
|
|
1910
|
+
if (stacks.length > 0) console.log(dim(` Found ${stacks.map((s) => `${s.label} (${s.pkg})`).join(", ")} in package.json.`));
|
|
1911
|
+
if (oauth) {
|
|
1912
|
+
console.log(` ${oauth.label} can sign agents in itself: MCP clients log in to it directly, so no Exposify broker is needed.`);
|
|
1913
|
+
console.log(` Add ${cyan("oauth: { issuer, resource }")} to aiReady(): issuer is your ${oauth.label} domain, resource your /mcp URL.`);
|
|
1914
|
+
console.log(dim(" The README's OAuth section lists the settings to turn on in the provider's dashboard."));
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const own = login ? `your app's own login, ${login.what} (${login.fields.join(", ")})` : null;
|
|
1918
|
+
if (own) console.log(` Found ${own}. AI assistants sign in with it on a form your app serves at /mcp/login, so there's nothing to set up for it.`);
|
|
1919
|
+
const have = existingKey(root, process.env);
|
|
1920
|
+
if (have) {
|
|
1921
|
+
console.log(green(` \u2713 ${KEY_ENV} is set (${have.from}):`) + " agents sign in through Exposify's broker.");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const via = stacks[0] ? `With ${stacks[0].label}` : "With your own login";
|
|
1925
|
+
console.log(` ${via}, agents sign in through Exposify's hosted broker, which keeps each person's login fresh.`);
|
|
1926
|
+
console.log(dim(` Your app needs one setting, ${KEY_ENV}. Free for up to 25 connected agents.`));
|
|
1927
|
+
const answer = (await ask(" Sign in to Exposify in your browser to get it? (Y/n) ")).toLowerCase();
|
|
1928
|
+
if (answer === "n" || answer === "no") {
|
|
1929
|
+
console.log(dim(` Later: create a project in the Exposify dashboard and put its secret key in ${KEY_ENV}.`));
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const service = serviceConfig(args);
|
|
1933
|
+
if (typeof service === "string") {
|
|
1934
|
+
console.error(red(` ${service}`));
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
const outcome = await signInToExposify({
|
|
1938
|
+
apiUrl: service.apiUrl,
|
|
1939
|
+
appName: appNameOf(root),
|
|
1940
|
+
show: ({ userCode, verifyUrl, opened }) => {
|
|
1941
|
+
console.log(` ${opened ? "Opened" : "Open"} ${cyan(verifyUrl)}${opened ? "" : " in your browser"}`);
|
|
1942
|
+
console.log(` and check it shows ${bold(userCode)}. Waiting\u2026 ${dim("(Ctrl-C to stop)")}`);
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
if (!outcome.ok) {
|
|
1946
|
+
console.log(yellow(` Didn't get a key: ${outcome.message}.`) + dim(` Run setup again, or set ${KEY_ENV} by hand.`));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const written = writeKeyToEnv(root, outcome.key);
|
|
1950
|
+
process.env[KEY_ENV] = outcome.key;
|
|
1951
|
+
console.log(green(` \u2713 ${written.created ? "Created" : "Updated"} ${relative(cwd, written.path) || written.path} with ${KEY_ENV}`) + ` (project "${outcome.projectName}").`);
|
|
1952
|
+
if (!written.ignored) console.log(yellow(` ! Add .env to .gitignore: it now holds a secret.`));
|
|
1953
|
+
if (own) console.log(` Your app tells Exposify about its login when it starts with ${KEY_ENV}: nothing to add in the dashboard.`);
|
|
1954
|
+
else console.log(` Next, in the dashboard: add how your users log in (presets for GitHub, Google, Supabase, \u2026).`);
|
|
1955
|
+
console.log(dim(` Deploying? Set ${KEY_ENV} there too. Restart your server to pick it up.`));
|
|
1956
|
+
}
|
|
1957
|
+
function serviceConfig(args) {
|
|
1958
|
+
const raw = args.apiUrl ?? process.env[API_URL_ENV] ?? DEFAULT_API_URL;
|
|
1959
|
+
const apiUrl = validateApiUrl(raw);
|
|
1960
|
+
if (!apiUrl) return `${raw} isn't a usable service URL (https, or http on localhost)`;
|
|
1961
|
+
const key = resolveLicenseKey(args.licenseKey);
|
|
1962
|
+
return { apiUrl, ...key ? { licenseKey: key.key } : {} };
|
|
1963
|
+
}
|
|
1964
|
+
async function describeStep(args, routes, root, cwd, title) {
|
|
1965
|
+
const path = join4(root, DESCRIPTIONS_FILE);
|
|
1966
|
+
const file = readDescriptionsFile(path);
|
|
1967
|
+
if (typeof file === "string") {
|
|
1968
|
+
console.error(red(`${relative(cwd, path)}: ${file}. Fix or delete it, then run \`${adapter.bin} describe\`.`));
|
|
1969
|
+
return 1;
|
|
1970
|
+
}
|
|
1971
|
+
const service = serviceConfig(args);
|
|
1972
|
+
if (typeof service === "string") {
|
|
1973
|
+
console.error(red(service));
|
|
1974
|
+
return 1;
|
|
1975
|
+
}
|
|
1976
|
+
reportStaleEdits(routes, file);
|
|
1977
|
+
const result = await describeInteractively({
|
|
1978
|
+
bin: adapter.bin,
|
|
1979
|
+
// Same order as the checklist.
|
|
1980
|
+
routes: needingDescriptions(sortRoutes(routes), file, { regenerate: args.regenerate }),
|
|
1981
|
+
file,
|
|
1982
|
+
service,
|
|
1983
|
+
includeSource: !args.noSource,
|
|
1984
|
+
title
|
|
1985
|
+
});
|
|
1986
|
+
if (result.changed > 0) {
|
|
1987
|
+
writeDescriptionsFile(path, result.file);
|
|
1988
|
+
console.log(green(` \u2713 Wrote ${relative(cwd, path) || path} (${result.changed} description${result.changed === 1 ? "" : "s"}).`) + dim(" aiReady() loads it on startup: restart your server."));
|
|
1989
|
+
}
|
|
1990
|
+
return 0;
|
|
1991
|
+
}
|
|
1992
|
+
async function proposeStep(args, actions, root, cwd) {
|
|
1993
|
+
const targets = actions.filter((a) => a.proposal && a.proposal.status !== "current");
|
|
1994
|
+
if (targets.length === 0) return 0;
|
|
1995
|
+
console.log(`
|
|
1996
|
+
${bold("Server Action inputs")} \xB7 ${targets.length} opted-in action${targets.length === 1 ? " is" : "s are"} hidden because the scan can't tell all of ${targets.length === 1 ? "its" : "their"} input:`);
|
|
1997
|
+
for (const a of targets) console.log(` ${a.export} ${dim(`${a.file}:${a.line}`)}${a.proposal.status === "stale" ? yellow(" (changed since its input was accepted)") : ""}`);
|
|
1998
|
+
const service = serviceConfig(args);
|
|
1999
|
+
if (typeof service === "string") {
|
|
2000
|
+
console.error(red(service));
|
|
2001
|
+
return 1;
|
|
2002
|
+
}
|
|
2003
|
+
console.log(` The description service can propose ${targets.length === 1 ? "it" : "them"} from the code, for you to review.`);
|
|
2004
|
+
for (const line of proposalConsent(targets, new URL(service.apiUrl).host, !args.noSource)) console.log(line);
|
|
2005
|
+
const answer = (await ask(" Send? (y/N) ")).toLowerCase();
|
|
2006
|
+
if (answer !== "y" && answer !== "yes") {
|
|
2007
|
+
console.log(dim(` Nothing sent. Write ${targets.length === 1 ? "its" : "their"} \`input\` by hand in aiReady({ actions }), or run \`${adapter.bin} describe\` later.`));
|
|
2008
|
+
return 0;
|
|
2009
|
+
}
|
|
2010
|
+
let reply;
|
|
2011
|
+
try {
|
|
2012
|
+
reply = await proposeClient(service, !args.noSource)(targets.map((a) => a.proposal.request));
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
console.error(red(` Couldn't get proposals: ${err.message}`));
|
|
2015
|
+
return 1;
|
|
2016
|
+
}
|
|
2017
|
+
const path = join4(root, DESCRIPTIONS_FILE);
|
|
2018
|
+
const file = readDescriptionsFile(path);
|
|
2019
|
+
if (typeof file === "string") {
|
|
2020
|
+
console.error(red(`${relative(cwd, path)}: ${file}`));
|
|
2021
|
+
return 1;
|
|
2022
|
+
}
|
|
2023
|
+
let accepted = 0;
|
|
2024
|
+
for (const a of targets) {
|
|
2025
|
+
const outcome = reply.results.get(`${a.file}#${a.export}`);
|
|
2026
|
+
console.log(`
|
|
2027
|
+
${bold(a.export)} ${dim(`${a.file}:${a.line}`)}`);
|
|
2028
|
+
if (!outcome || "error" in outcome) {
|
|
2029
|
+
console.log(yellow(` no proposal: ${outcome?.message ?? "the service didn't answer for it"}`));
|
|
2030
|
+
if (outcome && "error" in outcome && outcome.error === "quota" && reply.signupUrl) console.log(dim(` More with a project license key: ${reply.signupUrl}`));
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
for (const f of outcome.fields) {
|
|
2034
|
+
const type = f.enum?.length ? f.enum.map((v) => JSON.stringify(v)).join(" | ") : f.type;
|
|
2035
|
+
console.log(` ${f.name}: ${type}${f.required ? "" : dim(" (optional)")}${f.description ? dim(` ${f.description}`) : ""}`);
|
|
2036
|
+
}
|
|
2037
|
+
for (const u of outcome.unknown) console.log(yellow(` ? ${u}`));
|
|
2038
|
+
const choice = (await ask(` ${outcome.unknown.length ? "Some of it is still unknown. " : ""}(a)ccept \xB7 (s)kip `)).toLowerCase();
|
|
2039
|
+
if (choice !== "a" && choice !== "accept") continue;
|
|
2040
|
+
const [method, routePath] = a.route.split(" ");
|
|
2041
|
+
file.inputs = {
|
|
2042
|
+
...file.inputs,
|
|
2043
|
+
[a.route]: {
|
|
2044
|
+
fields: outcome.fields,
|
|
2045
|
+
...outcome.unknown.length ? { unknown: outcome.unknown } : {},
|
|
2046
|
+
source: "generated",
|
|
2047
|
+
signature: signatureFor({ method, path: routePath, params: [], ...a.source ? { source: a.source } : {} }),
|
|
2048
|
+
model: reply.model,
|
|
2049
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
accepted++;
|
|
2053
|
+
}
|
|
2054
|
+
if (accepted > 0) {
|
|
2055
|
+
writeDescriptionsFile(path, file);
|
|
2056
|
+
console.log(green(`
|
|
2057
|
+
\u2713 Wrote ${accepted} input${accepted === 1 ? "" : "s"} to ${relative(cwd, path) || path}.`) + dim(" The MCP route uses them once exposify.generated.ts is regenerated (next dev / next build)."));
|
|
2058
|
+
}
|
|
2059
|
+
if (typeof reply.storedForDays === "number") console.log(dim(` The service keeps what it was sent for ${reply.storedForDays} days.`));
|
|
2060
|
+
return 0;
|
|
2061
|
+
}
|
|
2062
|
+
function reportStaleEdits(routes, file) {
|
|
2063
|
+
const stale = routes.filter((r) => r.exposed && statusOf(r, file).kind === "stale" && file.descriptions[routeKey(r)]?.source === "edited");
|
|
2064
|
+
if (stale.length === 0) return;
|
|
2065
|
+
console.log(yellow(` These routes changed since you described them; check the text still holds:`));
|
|
2066
|
+
for (const r of stale) console.log(yellow(` ${routeKey(r)}`));
|
|
2067
|
+
}
|
|
2068
|
+
async function describeCommand(args, cwd) {
|
|
2069
|
+
const scanned = await scan(args, cwd, !args.check);
|
|
2070
|
+
if (!scanned) return 1;
|
|
2071
|
+
const { result, root } = scanned;
|
|
2072
|
+
const path = join4(root, DESCRIPTIONS_FILE);
|
|
2073
|
+
const shown = relative(cwd, path) || path;
|
|
2074
|
+
const file = readDescriptionsFile(path);
|
|
2075
|
+
if (typeof file === "string") {
|
|
2076
|
+
console.error(red(`${shown}: ${file}`));
|
|
2077
|
+
return 1;
|
|
2078
|
+
}
|
|
2079
|
+
const exposed = toolRows(result.routes, result.actions).filter((r) => r.exposed);
|
|
2080
|
+
if (args.check) {
|
|
2081
|
+
const problems = [];
|
|
2082
|
+
for (const r of exposed) {
|
|
2083
|
+
const s = statusOf(r, file);
|
|
2084
|
+
if (s.kind === "missing") problems.push(` - ${routeKey(r)} has no description (only the auto-derived one)`);
|
|
2085
|
+
if (s.kind === "stale") problems.push(` ~ ${routeKey(r)} changed since it was described (${s.entry.source === "edited" ? "written by a person" : "AI"})`);
|
|
2086
|
+
}
|
|
2087
|
+
for (const a of result.actions ?? []) {
|
|
2088
|
+
if (a.proposal?.status === "stale") problems.push(` ~ ${a.route} (${a.export}) changed since its input was accepted: it's hidden until it's reviewed again`);
|
|
2089
|
+
}
|
|
2090
|
+
if (problems.length === 0) {
|
|
2091
|
+
console.log(green(`\u2713 All ${exposed.length} tools have current descriptions.`));
|
|
2092
|
+
return 0;
|
|
2093
|
+
}
|
|
2094
|
+
console.log(red(`\u2717 ${problems.length} tool${problems.length === 1 ? " needs" : "s need"} a description:`));
|
|
2095
|
+
for (const p of problems) console.log(p);
|
|
2096
|
+
console.log(dim(` Run \`${adapter.bin} describe\` and commit the result.`));
|
|
2097
|
+
return 1;
|
|
2098
|
+
}
|
|
2099
|
+
const routes = needingDescriptions(exposed, file, { regenerate: args.regenerate });
|
|
2100
|
+
if (args.ci) {
|
|
2101
|
+
const service = serviceConfig(args);
|
|
2102
|
+
if (typeof service === "string") {
|
|
2103
|
+
console.error(red(service));
|
|
2104
|
+
return 1;
|
|
2105
|
+
}
|
|
2106
|
+
let out;
|
|
2107
|
+
try {
|
|
2108
|
+
out = await describeUnattended({ routes, file, service, includeSource: !args.noSource });
|
|
2109
|
+
} catch (err) {
|
|
2110
|
+
console.error(red(`Can't generate descriptions: ${err.message}`));
|
|
2111
|
+
if (err instanceof ServiceError && err.kind === "invalid_key") console.error(` Check ${LICENSE_KEY_ENV}.`);
|
|
2112
|
+
return 1;
|
|
2113
|
+
}
|
|
2114
|
+
if (out.changed > 0) writeDescriptionsFile(path, out.file);
|
|
2115
|
+
console.log(`Described ${out.changed} read route${out.changed === 1 ? "" : "s"} in ${shown}.`);
|
|
2116
|
+
for (const f of out.failed) console.log(yellow(` ! ${routeKey(f.route)}: ${f.error}`));
|
|
2117
|
+
if (out.quotaExhausted) console.log(` The free allowance ran out: set ${LICENSE_KEY_ENV} to a project secret key for CI.`);
|
|
2118
|
+
if (out.skippedWrites.length > 0) {
|
|
2119
|
+
console.log(` ${out.skippedWrites.length} write route${out.skippedWrites.length === 1 ? "" : "s"} left for a person (AI text for writes is always reviewed):`);
|
|
2120
|
+
for (const r of out.skippedWrites) console.log(` ${routeKey(r)}`);
|
|
2121
|
+
console.log(dim(` Run \`${adapter.bin} describe\` in a terminal to review them.`));
|
|
2122
|
+
}
|
|
2123
|
+
return out.failed.length > 0 ? 1 : 0;
|
|
2124
|
+
}
|
|
2125
|
+
if (!interactive()) {
|
|
2126
|
+
console.error("Not a terminal: pass --ci (reads only) or --check.");
|
|
2127
|
+
return 1;
|
|
2128
|
+
}
|
|
2129
|
+
const code = await describeStep(args, exposed, root, cwd, "Describe");
|
|
2130
|
+
if (code !== 0) return code;
|
|
2131
|
+
return proposeStep(args, result.actions ?? [], root, cwd);
|
|
2132
|
+
}
|
|
2133
|
+
function writeSnapshot(routes, file, cwd) {
|
|
2134
|
+
const snapshot = snapshotOf(routes);
|
|
2135
|
+
writeFileSync6(file, renderSnapshot(snapshot));
|
|
2136
|
+
const n = Object.keys(snapshot.tools).length;
|
|
2137
|
+
console.log(`Wrote ${relative(cwd, file) || file}: ${n} tool${n === 1 ? "" : "s"}. Commit it, and run \`${adapter.bin} inspect --check\` in CI.`);
|
|
2138
|
+
return 0;
|
|
2139
|
+
}
|
|
2140
|
+
function checkSnapshot(result, file, cwd) {
|
|
2141
|
+
const shown = relative(cwd, file) || file;
|
|
2142
|
+
let text;
|
|
2143
|
+
try {
|
|
2144
|
+
text = readFileSync7(file, "utf8");
|
|
2145
|
+
} catch {
|
|
2146
|
+
console.error(red(`No snapshot at ${shown}.`) + ` Create it with \`${adapter.bin} inspect --snapshot\` and commit it.`);
|
|
2147
|
+
return 1;
|
|
2148
|
+
}
|
|
2149
|
+
const snapshot = parseSnapshot(text);
|
|
2150
|
+
if (typeof snapshot === "string") {
|
|
2151
|
+
console.error(red(`${shown}: ${snapshot}`));
|
|
2152
|
+
return 1;
|
|
2153
|
+
}
|
|
2154
|
+
const diff = diffSnapshot(snapshot, toolRows(result.routes, result.actions));
|
|
2155
|
+
if (isEmptyDiff(diff)) {
|
|
2156
|
+
const n = Object.keys(snapshot.tools).length;
|
|
2157
|
+
console.log(green(`\u2713 ${n} tool${n === 1 ? "" : "s"} match ${shown}.`));
|
|
2158
|
+
return 0;
|
|
2159
|
+
}
|
|
2160
|
+
console.log(red(`\u2717 Your app's tools don't match ${shown}:`));
|
|
2161
|
+
for (const a of diff.added) console.log(` ${a.write ? red("+") : "+"} ${a.route} is now a tool (${a.tool})${a.write ? red(" <- write") : ""}`);
|
|
2162
|
+
for (const r of diff.removed) console.log(` - ${r.route} is no longer a tool (was ${r.tool})`);
|
|
2163
|
+
for (const r of diff.renamed) console.log(` ~ ${r.route}: tool renamed ${r.from} -> ${r.to}`);
|
|
2164
|
+
console.log(dim(` If this change is intended, run \`${adapter.bin} inspect --snapshot\` and commit the result.`));
|
|
2165
|
+
return 1;
|
|
2166
|
+
}
|
|
2167
|
+
async function runCli(argv, forAdapter, cwd = process.cwd()) {
|
|
2168
|
+
adapter = forAdapter;
|
|
2169
|
+
let args;
|
|
2170
|
+
try {
|
|
2171
|
+
args = parseArgs(argv);
|
|
2172
|
+
} catch (err) {
|
|
2173
|
+
console.error(red(err.message));
|
|
2174
|
+
console.error(usage(adapter.bin, adapter.entryHelp));
|
|
2175
|
+
return 2;
|
|
2176
|
+
}
|
|
2177
|
+
if (args.help || !args.command) {
|
|
2178
|
+
console.log(usage(adapter.bin, adapter.entryHelp));
|
|
2179
|
+
return args.help ? 0 : 2;
|
|
2180
|
+
}
|
|
2181
|
+
if (args.ci) disableColor();
|
|
2182
|
+
if (args.command === "inspect") {
|
|
2183
|
+
if (args.snapshot && args.check) {
|
|
2184
|
+
console.error(red("--snapshot writes the file and --check compares against it: pass one."));
|
|
2185
|
+
return 2;
|
|
2186
|
+
}
|
|
2187
|
+
const scanned = await scan(args, cwd);
|
|
2188
|
+
if (!scanned) return 1;
|
|
2189
|
+
const file = args.file ? join4(cwd, args.file) : join4(scanned.root, SNAPSHOT_FILE);
|
|
2190
|
+
if (args.snapshot) return writeSnapshot(toolRows(scanned.result.routes, scanned.result.actions), file, cwd);
|
|
2191
|
+
if (args.check) return Math.max(checkSnapshot(scanned.result, file, cwd), checkDrift(scanned.result));
|
|
2192
|
+
if (args.json) console.log(JSON.stringify(scanned.result, null, 2));
|
|
2193
|
+
else inspect(scanned.result, scanned.root);
|
|
2194
|
+
return 0;
|
|
2195
|
+
}
|
|
2196
|
+
if (args.command === "setup") return setup(args, cwd);
|
|
2197
|
+
if (args.command === "describe") return describeCommand(args, cwd);
|
|
2198
|
+
console.error(red(`unknown command: ${args.command}`));
|
|
2199
|
+
console.error(usage(adapter.bin, adapter.entryHelp));
|
|
2200
|
+
return 2;
|
|
2201
|
+
}
|
|
2202
|
+
export {
|
|
2203
|
+
buildConfig,
|
|
2204
|
+
exposedUnder,
|
|
2205
|
+
insertAiReadyCall,
|
|
2206
|
+
isCiEnv,
|
|
2207
|
+
isLocked,
|
|
2208
|
+
isRead,
|
|
2209
|
+
renderConfig,
|
|
2210
|
+
renderProps,
|
|
2211
|
+
replaceAiReadyOptions,
|
|
2212
|
+
routeKey,
|
|
2213
|
+
runCli,
|
|
2214
|
+
signatureFor
|
|
2215
|
+
};
|