@cairnvibe/indexer 0.1.0 → 0.2.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/dist/cli.js +18 -3
- package/dist/concurrency.js +10 -12
- package/dist/init.js +1 -1
- package/dist/inject-widget.js +108 -0
- package/dist/l3-describe.js +3 -3
- package/dist/prompt.js +65 -0
- package/dist/setup.js +272 -0
- package/dist/ui.js +79 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ const manifest_1 = require("./manifest");
|
|
|
17
17
|
const diff_1 = require("./diff");
|
|
18
18
|
const docs_1 = require("./docs");
|
|
19
19
|
const init_1 = require("./init");
|
|
20
|
+
const setup_1 = require("./setup");
|
|
20
21
|
function parseArgs(rest) {
|
|
21
22
|
const positional = [];
|
|
22
23
|
const flags = {};
|
|
@@ -43,11 +44,20 @@ async function main() {
|
|
|
43
44
|
}
|
|
44
45
|
if (command === "build") {
|
|
45
46
|
const provider = flags.provider === "groq" ? "groq" : "anthropic";
|
|
46
|
-
|
|
47
|
+
const keyMissing = provider === "anthropic" ? !process.env.ANTHROPIC_API_KEY : !process.env.GROQ_API_KEYS;
|
|
48
|
+
if (keyMissing && "if-configured" in flags) {
|
|
49
|
+
// Used by the prebuild hook `cairn setup` wires in: a deploy with no key
|
|
50
|
+
// set yet (e.g. the very first one, before env vars are configured on the
|
|
51
|
+
// hosting platform) skips this step instead of failing the whole build —
|
|
52
|
+
// the app still builds and runs, just without an updated manifest.
|
|
53
|
+
console.error(`cairn build --if-configured: no key set for ${provider} — skipping, leaving any existing ui-manifest.json as-is.`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (provider === "anthropic" && keyMissing) {
|
|
47
57
|
console.error("cairn build: ANTHROPIC_API_KEY is not set. Export it, or pass --provider groq, and re-run.");
|
|
48
58
|
process.exit(1);
|
|
49
59
|
}
|
|
50
|
-
if (provider === "groq" &&
|
|
60
|
+
if (provider === "groq" && keyMissing) {
|
|
51
61
|
console.error("cairn build --provider groq: GROQ_API_KEYS is not set (comma-separated). Export it and re-run.");
|
|
52
62
|
process.exit(1);
|
|
53
63
|
}
|
|
@@ -92,6 +102,10 @@ async function main() {
|
|
|
92
102
|
console.error(`wrote ${outPath}`);
|
|
93
103
|
return;
|
|
94
104
|
}
|
|
105
|
+
if (command === "setup") {
|
|
106
|
+
await (0, setup_1.runSetup)(dir);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
95
109
|
if (command === "init") {
|
|
96
110
|
const result = (0, init_1.runInit)(dir);
|
|
97
111
|
console.error(`cairn init: detected ${result.framework}.`);
|
|
@@ -129,7 +143,8 @@ async function main() {
|
|
|
129
143
|
return;
|
|
130
144
|
}
|
|
131
145
|
console.error("usage:");
|
|
132
|
-
console.error(" cairn
|
|
146
|
+
console.error(" cairn setup [dir] (the one-command path: installs deps, asks for keys — skippable, wires the widget in, builds once, auto-rebuilds on future `npm run build`)");
|
|
147
|
+
console.error(" cairn init <dir> (scaffolds the API route/server + .env.example, detects your framework — no prompts, no installs)");
|
|
133
148
|
console.error(" cairn scan <dir>");
|
|
134
149
|
console.error(" cairn build <dir> [--provider anthropic|groq] (Next.js source scan)");
|
|
135
150
|
console.error(" cairn build <url> [--provider anthropic|groq] [--out <dir>] [--storage-state <file>] (any framework — crawls a running app; --storage-state replays a saved logged-in session for auth-gated apps)");
|
package/dist/concurrency.js
CHANGED
|
@@ -25,17 +25,6 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
25
25
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
26
26
|
return results;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Retries a rate-limited (429) or transient-server-error (5xx) call with
|
|
30
|
-
* backoff — found live and necessary, not theoretical: raising describeAll's
|
|
31
|
-
* concurrency (see DEFAULT_DESCRIBE_CONCURRENCY) surfaced a real 429 from
|
|
32
|
-
* Groq on a 40-page build within seconds ("Rate limit reached... tokens per
|
|
33
|
-
* minute"), which without this would have thrown straight out of
|
|
34
|
-
* mapWithConcurrency's Promise.all and aborted the *entire* build,
|
|
35
|
-
* discarding every other page's already-completed work too. Honors the
|
|
36
|
-
* provider's Retry-After header when present (both the Anthropic and Groq
|
|
37
|
-
* SDKs expose one on a 429), falls back to exponential backoff otherwise.
|
|
38
|
-
*/
|
|
39
28
|
async function withRetry(fn, opts) {
|
|
40
29
|
const maxAttempts = opts?.maxAttempts ?? 4;
|
|
41
30
|
const baseDelayMs = opts?.baseDelayMs ?? 1000;
|
|
@@ -49,7 +38,16 @@ async function withRetry(fn, opts) {
|
|
|
49
38
|
if (!isRetryable(err) || attempt === maxAttempts)
|
|
50
39
|
throw err;
|
|
51
40
|
const delayMs = retryAfterMs(err) ?? baseDelayMs * 2 ** (attempt - 1);
|
|
52
|
-
|
|
41
|
+
const message = errorMessage(err);
|
|
42
|
+
// Default behavior (plain `cairn build`, unchanged): log every attempt directly.
|
|
43
|
+
// A caller that wants something quieter/nicer (`cairn setup`'s spinner) passes
|
|
44
|
+
// its own onRetry instead of getting this raw per-attempt line.
|
|
45
|
+
if (opts?.onRetry) {
|
|
46
|
+
opts.onRetry({ attempt, maxAttempts, delayMs: Math.round(delayMs), message });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.error(`[cairn] retryable error (attempt ${attempt}/${maxAttempts}), waiting ${Math.round(delayMs)}ms:`, message);
|
|
50
|
+
}
|
|
53
51
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
54
52
|
}
|
|
55
53
|
}
|
package/dist/init.js
CHANGED
|
@@ -67,7 +67,7 @@ function runInit(dir) {
|
|
|
67
67
|
}
|
|
68
68
|
else {
|
|
69
69
|
writeIfAbsent(node_path_1.default.join(absDir, "cairn-server.cjs"), STANDALONE_SERVER, result);
|
|
70
|
-
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. npm install express @cairnvibe/sdk @cairnvibe/core
|
|
70
|
+
result.nextSteps.push("1. cp .env.example .env and fill in your key(s).", "2. npm install express @cairnvibe/sdk @cairnvibe/core", "3. Start your app, then: npx cairn build http://localhost:PORT (crawls the running app — works for any framework)", "4. node cairn-server.cjs (the copilot backend, separate from your app's own server)", "5. Add this to your HTML, pointed at wherever cairn-server.cjs is running:", ' <script src="/cairn-widget.js"></script>', ' <cairn-widget endpoint="http://localhost:4000/api/copilot"></cairn-widget>', " (copy node_modules/@cairnvibe/sdk/dist/cairn-widget.js into your app's static assets as cairn-widget.js)");
|
|
71
71
|
}
|
|
72
72
|
return result;
|
|
73
73
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `cairn setup`'s one departure from `init`'s "never touch an existing
|
|
3
|
+
// file" rule — but only for this one, narrow, reversible edit (adding an
|
|
4
|
+
// import + one JSX tag), and only ever via a real AST parse, never blind
|
|
5
|
+
// string splicing. Any structure this doesn't recognize falls back to
|
|
6
|
+
// printing the two-line manual instruction instead of guessing — the
|
|
7
|
+
// same "don't corrupt what you don't understand" discipline `init` uses
|
|
8
|
+
// for whole files, applied here at the node level.
|
|
9
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.injectWidget = injectWidget;
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const ts_morph_1 = require("ts-morph");
|
|
17
|
+
const WIDGET_JSX = `<Copilot registeredActions={[]} onDo={(action, target) => { /* run it through your own auth */ }} />`;
|
|
18
|
+
function findLayoutFile(absDir, framework) {
|
|
19
|
+
const candidates = framework === "next-app-router"
|
|
20
|
+
? ["app/layout.tsx", "app/layout.jsx"]
|
|
21
|
+
: ["pages/_app.tsx", "pages/_app.jsx"];
|
|
22
|
+
for (const c of candidates) {
|
|
23
|
+
const p = node_path_1.default.join(absDir, c);
|
|
24
|
+
if (node_fs_1.default.existsSync(p))
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function injectWidget(dir, framework) {
|
|
30
|
+
const absDir = node_path_1.default.resolve(dir);
|
|
31
|
+
const target = findLayoutFile(absDir, framework);
|
|
32
|
+
if (!target) {
|
|
33
|
+
return { injected: false, reason: "no app/layout.tsx or pages/_app.tsx found — add <Copilot/> manually" };
|
|
34
|
+
}
|
|
35
|
+
const relTarget = node_path_1.default.relative(absDir, target) || target;
|
|
36
|
+
const original = node_fs_1.default.readFileSync(target, "utf8");
|
|
37
|
+
if (original.includes("@cairnvibe/sdk") || original.includes("<Copilot")) {
|
|
38
|
+
return { injected: false, reason: `${relTarget} already references Copilot — leaving it alone` };
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const project = new ts_morph_1.Project({
|
|
42
|
+
useInMemoryFileSystem: false,
|
|
43
|
+
skipAddingFilesFromTsConfig: true,
|
|
44
|
+
compilerOptions: { jsx: ts_morph_1.ts.JsxEmit.ReactJSX, allowJs: true, esModuleInterop: true, target: ts_morph_1.ts.ScriptTarget.ES2022 },
|
|
45
|
+
});
|
|
46
|
+
const sf = project.addSourceFileAtPath(target);
|
|
47
|
+
const hasImport = sf.getImportDeclarations().some((d) => d.getModuleSpecifierValue() === "@cairnvibe/sdk");
|
|
48
|
+
if (!hasImport) {
|
|
49
|
+
sf.addImportDeclaration({ moduleSpecifier: "@cairnvibe/sdk", namedImports: ["Copilot"] });
|
|
50
|
+
}
|
|
51
|
+
// `insertText` at a position, not `replaceWithText` on a node — the latter
|
|
52
|
+
// asks ts-morph to structurally reconcile old vs. new trees, which fails
|
|
53
|
+
// ("children of the old and new trees were expected to have the same
|
|
54
|
+
// count") the moment the replacement text contains a whole new element
|
|
55
|
+
// rather than equivalent-shaped content. Plain positional insertion has
|
|
56
|
+
// no tree to reconcile, so it can't hit that class of error.
|
|
57
|
+
let inserted = false;
|
|
58
|
+
// App Router: insert right before </body>, wherever it is in the tree.
|
|
59
|
+
const bodyOpening = sf
|
|
60
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxOpeningElement)
|
|
61
|
+
.find((el) => el.getTagNameNode().getText() === "body");
|
|
62
|
+
if (bodyOpening) {
|
|
63
|
+
const closing = bodyOpening.getParentIfKind(ts_morph_1.SyntaxKind.JsxElement)?.getClosingElement();
|
|
64
|
+
if (closing) {
|
|
65
|
+
sf.insertText(closing.getStart(), `${WIDGET_JSX}\n `);
|
|
66
|
+
inserted = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// App Router fallback: a custom layout with no literal <body> — drop it right after {children}.
|
|
70
|
+
if (!inserted) {
|
|
71
|
+
const childrenExpr = sf
|
|
72
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxExpression)
|
|
73
|
+
.find((e) => e.getExpression()?.getText() === "children");
|
|
74
|
+
if (childrenExpr) {
|
|
75
|
+
sf.insertText(childrenExpr.getEnd(), `\n ${WIDGET_JSX}`);
|
|
76
|
+
inserted = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Pages Router: wrap the returned <Component .../> as a sibling inside a fragment,
|
|
80
|
+
// wherever it sits (works whether or not it's already inside other providers).
|
|
81
|
+
// Insert the closing half first so the earlier (opening-half) position isn't shifted yet.
|
|
82
|
+
if (!inserted) {
|
|
83
|
+
const componentTag = sf
|
|
84
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement)
|
|
85
|
+
.find((el) => el.getTagNameNode().getText() === "Component");
|
|
86
|
+
if (componentTag) {
|
|
87
|
+
// Capture both positions before either insertText call — the node
|
|
88
|
+
// reference goes stale ("removed or forgotten") the instant the
|
|
89
|
+
// first insertion changes the source text underneath it.
|
|
90
|
+
const start = componentTag.getStart();
|
|
91
|
+
const end = componentTag.getEnd();
|
|
92
|
+
sf.insertText(end, `\n ${WIDGET_JSX}\n </>`);
|
|
93
|
+
sf.insertText(start, `<>\n `);
|
|
94
|
+
inserted = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!inserted) {
|
|
98
|
+
return { injected: false, reason: `couldn't find a safe spot in ${relTarget} — add <Copilot/> manually` };
|
|
99
|
+
}
|
|
100
|
+
sf.saveSync();
|
|
101
|
+
return { injected: true, filePath: target };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
// Never leave a half-written file — ts-morph only writes on saveSync(),
|
|
105
|
+
// so a thrown error here means the original file on disk is untouched.
|
|
106
|
+
return { injected: false, reason: `couldn't safely modify ${relTarget} (${err.message}) — add <Copilot/> manually` };
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/l3-describe.js
CHANGED
|
@@ -18,7 +18,7 @@ const GLOBAL_ROUTE_LABEL = "(present on every page — layout/framework elements
|
|
|
18
18
|
// rather than maximal: still bounded by whatever the provider's real rate
|
|
19
19
|
// limit is, this just stops leaving 3 of 4 rotated keys idle.
|
|
20
20
|
const DEFAULT_DESCRIBE_CONCURRENCY = 6;
|
|
21
|
-
async function describeAll(rootDir, facts, client, concurrency = DEFAULT_DESCRIBE_CONCURRENCY) {
|
|
21
|
+
async function describeAll(rootDir, facts, client, concurrency = DEFAULT_DESCRIBE_CONCURRENCY, onRetry) {
|
|
22
22
|
const absRoot = node_path_1.default.resolve(rootDir);
|
|
23
23
|
const cacheDir = node_path_1.default.join(absRoot, CACHE_DIR);
|
|
24
24
|
node_fs_1.default.mkdirSync(cacheDir, { recursive: true });
|
|
@@ -51,7 +51,7 @@ async function describeAll(rootDir, facts, client, concurrency = DEFAULT_DESCRIB
|
|
|
51
51
|
file: page.file,
|
|
52
52
|
source,
|
|
53
53
|
elements: page.elements.map(toDescribeElementInput),
|
|
54
|
-
}));
|
|
54
|
+
}), { onRetry });
|
|
55
55
|
}
|
|
56
56
|
catch (err) {
|
|
57
57
|
// One page permanently failing (retries exhausted, or a
|
|
@@ -94,7 +94,7 @@ async function describeAll(rootDir, facts, client, concurrency = DEFAULT_DESCRIB
|
|
|
94
94
|
file: files.join(", "),
|
|
95
95
|
source,
|
|
96
96
|
elements: facts.frameworkElements.map(toDescribeElementInput),
|
|
97
|
-
}));
|
|
97
|
+
}), { onRetry });
|
|
98
98
|
}
|
|
99
99
|
catch (err) {
|
|
100
100
|
console.error(`[cairn] describing framework elements failed after retries — degrading:`, err);
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A tiny prompt helper for `cairn setup` — built on Node's own `readline`
|
|
3
|
+
// deliberately, not a new dependency, for something this small. Every
|
|
4
|
+
// prompt this module offers is skippable: an empty answer means "skip,"
|
|
5
|
+
// never a forced choice, matching setup's whole "ask, don't require"
|
|
6
|
+
// design.
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.closePrompts = closePrompts;
|
|
12
|
+
exports.ask = ask;
|
|
13
|
+
exports.askYesNo = askYesNo;
|
|
14
|
+
exports.askOptional = askOptional;
|
|
15
|
+
exports.selectFromList = selectFromList;
|
|
16
|
+
const node_readline_1 = __importDefault(require("node:readline"));
|
|
17
|
+
let sharedInterface = null;
|
|
18
|
+
function rl() {
|
|
19
|
+
if (!sharedInterface) {
|
|
20
|
+
sharedInterface = node_readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
}
|
|
22
|
+
return sharedInterface;
|
|
23
|
+
}
|
|
24
|
+
function closePrompts() {
|
|
25
|
+
sharedInterface?.close();
|
|
26
|
+
sharedInterface = null;
|
|
27
|
+
}
|
|
28
|
+
function ask(question) {
|
|
29
|
+
return new Promise((resolve) => rl().question(question, (answer) => resolve(answer.trim())));
|
|
30
|
+
}
|
|
31
|
+
/** A yes/no prompt. Empty answer (just pressing enter) takes `fallback`. */
|
|
32
|
+
async function askYesNo(question, fallback) {
|
|
33
|
+
const suffix = fallback ? " [Y/n] " : " [y/N] ";
|
|
34
|
+
const answer = (await ask(question + suffix)).toLowerCase();
|
|
35
|
+
if (!answer)
|
|
36
|
+
return fallback;
|
|
37
|
+
return answer.startsWith("y");
|
|
38
|
+
}
|
|
39
|
+
/** A free-text prompt where an empty answer means "skip this." Returns null when skipped. */
|
|
40
|
+
async function askOptional(question) {
|
|
41
|
+
const answer = await ask(question);
|
|
42
|
+
return answer.length > 0 ? answer : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A numbered menu instead of free text — the actual fix for "I had to
|
|
46
|
+
* type the exact provider name." Prints the choices, accepts a number,
|
|
47
|
+
* and (for anyone who prefers typing) also accepts the label/value text
|
|
48
|
+
* itself, case-insensitively. Empty answer takes `defaultIndex`.
|
|
49
|
+
*/
|
|
50
|
+
async function selectFromList(question, options, defaultIndex = 0) {
|
|
51
|
+
console.log(question);
|
|
52
|
+
options.forEach((o, i) => {
|
|
53
|
+
const marker = i === defaultIndex ? " (default)" : "";
|
|
54
|
+
console.log(` ${i + 1}. ${o.label}${marker}`);
|
|
55
|
+
});
|
|
56
|
+
const answer = await ask(`Choose [1-${options.length}]: `);
|
|
57
|
+
if (!answer)
|
|
58
|
+
return options[defaultIndex].value;
|
|
59
|
+
const asNumber = Number(answer);
|
|
60
|
+
if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= options.length) {
|
|
61
|
+
return options[asNumber - 1].value;
|
|
62
|
+
}
|
|
63
|
+
const byText = options.find((o) => o.value.toLowerCase() === answer.toLowerCase() || o.label.toLowerCase().includes(answer.toLowerCase()));
|
|
64
|
+
return byText ? byText.value : options[defaultIndex].value;
|
|
65
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `cairn setup` — the one-command onboarding path: install what's
|
|
3
|
+
// needed, ask only for what's actually optional (skippable, and picked
|
|
4
|
+
// from a real numbered menu rather than typed free text), scaffold the
|
|
5
|
+
// backend, wire the widget into the real layout file, build the
|
|
6
|
+
// manifest once now, and leave a `prebuild` hook so it rebuilds itself
|
|
7
|
+
// on every future `npm run build` without another manual step.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately layered on top of `runInit` rather than replacing it —
|
|
10
|
+
// `init` stays the safe, deterministic, non-interactive primitive
|
|
11
|
+
// (never touches an existing file, never installs anything, never
|
|
12
|
+
// prompts); `setup` is the opinionated wizard built from those same
|
|
13
|
+
// primitives plus the things `init` intentionally doesn't do: install
|
|
14
|
+
// dependencies, edit an existing layout file, and actually build.
|
|
15
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
16
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
17
|
+
};
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.runSetup = runSetup;
|
|
20
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
21
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
22
|
+
const node_child_process_1 = require("node:child_process");
|
|
23
|
+
const init_1 = require("./init");
|
|
24
|
+
const inject_widget_1 = require("./inject-widget");
|
|
25
|
+
const prompt_1 = require("./prompt");
|
|
26
|
+
const l1_scan_1 = require("./l1-scan");
|
|
27
|
+
const l2_reachability_1 = require("./l2-reachability");
|
|
28
|
+
const l3_describe_1 = require("./l3-describe");
|
|
29
|
+
const llm_1 = require("./llm");
|
|
30
|
+
const manifest_1 = require("./manifest");
|
|
31
|
+
const core_1 = require("@cairnvibe/core");
|
|
32
|
+
const ui_1 = require("./ui");
|
|
33
|
+
const PACKAGES = ["@cairnvibe/core", "@cairnvibe/sdk", "@cairnvibe/indexer"];
|
|
34
|
+
// Lower than cairn build's own default (6) — a first-time setup is exactly
|
|
35
|
+
// the scenario most likely to be running on a free-tier key with a tight
|
|
36
|
+
// per-minute token budget; found live, not theoretical (a real `cairn
|
|
37
|
+
// setup` run against Groq's on-demand tier hit a 429-retry cascade at the
|
|
38
|
+
// default concurrency on a small handful of pages).
|
|
39
|
+
const SETUP_BUILD_CONCURRENCY = 3;
|
|
40
|
+
function readPackageJson(absDir) {
|
|
41
|
+
const p = node_path_1.default.join(absDir, "package.json");
|
|
42
|
+
if (!node_fs_1.default.existsSync(p))
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(node_fs_1.default.readFileSync(p, "utf8"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function alreadyInstalled(pkg) {
|
|
52
|
+
if (!pkg)
|
|
53
|
+
return false;
|
|
54
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
55
|
+
return PACKAGES.every((p) => !!deps[p]);
|
|
56
|
+
}
|
|
57
|
+
/** One build attempt — spinner-driven, quiet on individual retries (they
|
|
58
|
+
* update the same line instead of scrolling the terminal), and honest
|
|
59
|
+
* about failure instead of throwing a raw stack trace at the user. */
|
|
60
|
+
async function attemptBuild(dir, provider, key) {
|
|
61
|
+
if (provider === "anthropic")
|
|
62
|
+
process.env.ANTHROPIC_API_KEY = key;
|
|
63
|
+
if (provider === "groq")
|
|
64
|
+
process.env.GROQ_API_KEYS = key;
|
|
65
|
+
const spinner = new ui_1.Spinner(`Building the manifest (${provider}) ...`);
|
|
66
|
+
spinner.start();
|
|
67
|
+
try {
|
|
68
|
+
const client = provider === "anthropic" ? new llm_1.AnthropicDescribeClient() : new llm_1.GroqDescribeClient();
|
|
69
|
+
const facts = (0, l1_scan_1.scanL1)(dir);
|
|
70
|
+
const l2 = (0, l2_reachability_1.computeL2)(dir, facts);
|
|
71
|
+
const l3 = await (0, l3_describe_1.describeAll)(dir, facts, client, SETUP_BUILD_CONCURRENCY, (info) => {
|
|
72
|
+
spinner.update(`Building the manifest (${provider}) ... rate-limited, retrying in ${Math.round(info.delayMs / 1000)}s (attempt ${info.attempt}/${info.maxAttempts})`);
|
|
73
|
+
});
|
|
74
|
+
const manifest = core_1.ManifestSchema.parse((0, manifest_1.assembleManifest)(dir, facts, l2, l3));
|
|
75
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(node_path_1.default.resolve(dir), "ui-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
76
|
+
spinner.stop((0, ui_1.green)(`✓ wrote ui-manifest.json (${manifest.pages.length} page(s))`));
|
|
77
|
+
return { ok: true, pageCount: manifest.pages.length };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
spinner.stop((0, ui_1.red)("✗ build failed"));
|
|
81
|
+
return { ok: false, error: err };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Runs after a failed build: explains what actually went wrong in plain
|
|
85
|
+
* English, then offers real next actions instead of just dying. Loops
|
|
86
|
+
* until the user picks something that resolves (a successful retry) or
|
|
87
|
+
* explicitly chooses to skip. */
|
|
88
|
+
async function recoverFromBuildFailure(dir, provider, key, err) {
|
|
89
|
+
const classified = (0, ui_1.classifyError)(err);
|
|
90
|
+
console.log("");
|
|
91
|
+
console.log((0, ui_1.yellow)(`Here's what happened: ${classified.summary}`));
|
|
92
|
+
const options = classified.kind === "rate_limit"
|
|
93
|
+
? [
|
|
94
|
+
{ label: "Try again in a bit (same provider)", value: "retry" },
|
|
95
|
+
{ label: "Switch to the other provider and try that instead", value: "switch" },
|
|
96
|
+
{ label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
|
|
97
|
+
]
|
|
98
|
+
: classified.kind === "auth"
|
|
99
|
+
? [
|
|
100
|
+
{ label: "Paste the key again (I probably mistyped it)", value: "rekey" },
|
|
101
|
+
{ label: "Switch to the other provider instead", value: "switch" },
|
|
102
|
+
{ label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
|
|
103
|
+
]
|
|
104
|
+
: [
|
|
105
|
+
{ label: "Try again", value: "retry" },
|
|
106
|
+
{ label: "Switch to the other provider instead", value: "switch" },
|
|
107
|
+
{ label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
|
|
108
|
+
];
|
|
109
|
+
for (;;) {
|
|
110
|
+
const choice = await (0, prompt_1.selectFromList)("What do you want to do?", options, 0);
|
|
111
|
+
if (choice === "skip")
|
|
112
|
+
return null;
|
|
113
|
+
let nextProvider = provider;
|
|
114
|
+
let nextKey = key;
|
|
115
|
+
if (choice === "switch") {
|
|
116
|
+
nextProvider = provider === "anthropic" ? "groq" : "anthropic";
|
|
117
|
+
const pasted = await (0, prompt_1.askOptional)(nextProvider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
|
|
118
|
+
if (!pasted) {
|
|
119
|
+
console.log((0, ui_1.dim)("No key given — back to the menu."));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
nextKey = pasted;
|
|
123
|
+
}
|
|
124
|
+
else if (choice === "rekey") {
|
|
125
|
+
const pasted = await (0, prompt_1.askOptional)(provider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
|
|
126
|
+
if (!pasted) {
|
|
127
|
+
console.log((0, ui_1.dim)("No key given — back to the menu."));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
nextKey = pasted;
|
|
131
|
+
}
|
|
132
|
+
// choice === "retry" falls through with the same provider/key.
|
|
133
|
+
const result = await attemptBuild(dir, nextProvider, nextKey);
|
|
134
|
+
if (result.ok)
|
|
135
|
+
return { provider: nextProvider, key: nextKey };
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log((0, ui_1.yellow)(`Still failing: ${(0, ui_1.classifyError)(result.error).summary}`));
|
|
138
|
+
// loop back to the menu rather than recursing — keeps this one flat retry
|
|
139
|
+
// loop instead of a call stack that grows with every attempt
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function runSetup(dir) {
|
|
143
|
+
const absDir = node_path_1.default.resolve(dir);
|
|
144
|
+
console.log(`${(0, ui_1.bold)("cairn setup")} — looking at ${absDir}\n`);
|
|
145
|
+
// 1. Scaffold what init already safely can — framework detection, the
|
|
146
|
+
// backend route, .env.example. Never overwrites anything that exists.
|
|
147
|
+
const init = (0, init_1.runInit)(dir);
|
|
148
|
+
console.log(`Detected: ${(0, ui_1.bold)(init.framework)}`);
|
|
149
|
+
for (const f of init.filesWritten)
|
|
150
|
+
console.log(` wrote ${node_path_1.default.relative(absDir, f) || f}`);
|
|
151
|
+
for (const f of init.filesSkipped)
|
|
152
|
+
console.log(` skipped ${node_path_1.default.relative(absDir, f) || f} (already exists)`);
|
|
153
|
+
console.log("");
|
|
154
|
+
if (init.framework === "other") {
|
|
155
|
+
// A generic backend needs a real framework decision (Express? Fastify? something
|
|
156
|
+
// else?) this wizard shouldn't guess at — print init's own manual steps instead
|
|
157
|
+
// of half-automating something it can't verify is right.
|
|
158
|
+
console.log("Not a detected Next.js project — falling back to the manual steps:\n");
|
|
159
|
+
for (const step of init.nextSteps)
|
|
160
|
+
console.log(` ${step}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// 2. Install what's needed — the actual "one command" part. Skips
|
|
164
|
+
// cleanly if already present (e.g. re-running setup after a partial run).
|
|
165
|
+
const pkg = readPackageJson(absDir);
|
|
166
|
+
if (!alreadyInstalled(pkg)) {
|
|
167
|
+
const spinner = new ui_1.Spinner(`Installing ${PACKAGES.join(", ")} ...`);
|
|
168
|
+
spinner.start();
|
|
169
|
+
try {
|
|
170
|
+
(0, node_child_process_1.execSync)(`npm install ${PACKAGES.join(" ")}`, { cwd: absDir, stdio: "pipe" });
|
|
171
|
+
spinner.stop((0, ui_1.green)(`✓ installed ${PACKAGES.join(", ")}`));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
spinner.stop((0, ui_1.red)("✗ npm install failed"));
|
|
175
|
+
console.error(`Install these yourself and re-run \`cairn setup\`:\n npm install ${PACKAGES.join(" ")}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log((0, ui_1.dim)("Dependencies already installed — skipping."));
|
|
181
|
+
}
|
|
182
|
+
// 3. Ask only what's actually needed, everything skippable, picked from a
|
|
183
|
+
// real menu rather than typed free text.
|
|
184
|
+
console.log(`\n${(0, ui_1.bold)("A couple of quick questions")} — press enter to skip anything you'll add later.\n`);
|
|
185
|
+
let provider = null;
|
|
186
|
+
let providerKey = null;
|
|
187
|
+
const llmChoice = await (0, prompt_1.selectFromList)("Set up an LLM provider now? (needed for the agent to actually answer anything)", [
|
|
188
|
+
{ label: "Anthropic (Claude)", value: "anthropic" },
|
|
189
|
+
{ label: "Groq", value: "groq" },
|
|
190
|
+
{ label: "Skip — I'll add one to .env later", value: "skip" },
|
|
191
|
+
], 0);
|
|
192
|
+
if (llmChoice !== "skip") {
|
|
193
|
+
provider = llmChoice;
|
|
194
|
+
providerKey = await (0, prompt_1.askOptional)(provider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
|
|
195
|
+
}
|
|
196
|
+
// Honest about what's actually implemented here — Deepgram is the only
|
|
197
|
+
// voice provider this SDK wires up today, so this is "on or off," not a
|
|
198
|
+
// real multi-provider menu dressed up as one.
|
|
199
|
+
const voiceChoice = await (0, prompt_1.selectFromList)("Set up voice now?", [
|
|
200
|
+
{ label: "Deepgram (speech in + out)", value: "deepgram" },
|
|
201
|
+
{ label: "Skip — no voice for now", value: "skip" },
|
|
202
|
+
], 1);
|
|
203
|
+
const deepgramKey = voiceChoice === "deepgram" ? await (0, prompt_1.askOptional)("Paste your DEEPGRAM_API_KEY: ") : null;
|
|
204
|
+
(0, prompt_1.closePrompts)();
|
|
205
|
+
// 4. Write a real .env (not just .env.example) with whatever was actually given.
|
|
206
|
+
const envLines = [];
|
|
207
|
+
if (provider === "anthropic")
|
|
208
|
+
envLines.push(`ANTHROPIC_API_KEY=${providerKey ?? ""}`);
|
|
209
|
+
if (provider === "groq")
|
|
210
|
+
envLines.push(`GROQ_API_KEYS=${providerKey ?? ""}`);
|
|
211
|
+
if (deepgramKey)
|
|
212
|
+
envLines.push(`DEEPGRAM_API_KEY=${deepgramKey}`);
|
|
213
|
+
envLines.push("CAIRN_REGISTERED_ACTIONS=");
|
|
214
|
+
const envPath = node_path_1.default.join(absDir, ".env");
|
|
215
|
+
if (!node_fs_1.default.existsSync(envPath)) {
|
|
216
|
+
node_fs_1.default.writeFileSync(envPath, envLines.join("\n") + "\n");
|
|
217
|
+
console.log(`\nwrote ${node_path_1.default.relative(absDir, envPath)}`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
console.log(`\n${node_path_1.default.relative(absDir, envPath)} already exists — not overwriting; add keys there yourself if you skipped any above.`);
|
|
221
|
+
}
|
|
222
|
+
// 5. Wire the widget into the real layout file — the one thing `init`
|
|
223
|
+
// deliberately doesn't do. Falls back to printing instructions on
|
|
224
|
+
// anything it can't confidently parse.
|
|
225
|
+
const framework = init.framework;
|
|
226
|
+
const inject = (0, inject_widget_1.injectWidget)(dir, framework);
|
|
227
|
+
if (inject.injected) {
|
|
228
|
+
console.log((0, ui_1.green)(`✓ wired <Copilot/> into ${node_path_1.default.relative(absDir, inject.filePath)}`));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.log(`\n<Copilot/> not auto-wired (${inject.reason}). Add it yourself:`);
|
|
232
|
+
console.log(' import { Copilot } from "@cairnvibe/sdk";');
|
|
233
|
+
console.log(" <Copilot registeredActions={[]} onDo={(action, target) => { /* run it */ }} />");
|
|
234
|
+
}
|
|
235
|
+
// 6. Build the manifest once now, if we actually have a usable key — no
|
|
236
|
+
// point trying (and failing loudly) with nothing to call. On failure,
|
|
237
|
+
// don't just print a stack trace and give up: classify what went wrong
|
|
238
|
+
// and offer real next steps (retry / switch provider / skip).
|
|
239
|
+
if (provider && providerKey) {
|
|
240
|
+
console.log("");
|
|
241
|
+
let result = await attemptBuild(dir, provider, providerKey);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
const recovered = await recoverFromBuildFailure(dir, provider, providerKey, result.error);
|
|
244
|
+
if (recovered) {
|
|
245
|
+
provider = recovered.provider;
|
|
246
|
+
providerKey = recovered.key;
|
|
247
|
+
}
|
|
248
|
+
// else: user chose to skip — fall through with the original provider/key
|
|
249
|
+
// still recorded for the prebuild script below; ui-manifest.json is
|
|
250
|
+
// simply not written yet.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
console.log("\nNo key given yet — skipping the first build. Run `npx cairn build .` once you've added one to .env.");
|
|
255
|
+
}
|
|
256
|
+
// 7. Wire a prebuild hook so this stays current on every future build/deploy —
|
|
257
|
+
// "just build and redeploy" only works if the manifest regenerates itself.
|
|
258
|
+
if (pkg && !pkg.scripts?.prebuild?.includes("cairn build")) {
|
|
259
|
+
const pkgPath = node_path_1.default.join(absDir, "package.json");
|
|
260
|
+
const fresh = JSON.parse(node_fs_1.default.readFileSync(pkgPath, "utf8"));
|
|
261
|
+
fresh.scripts = fresh.scripts ?? {};
|
|
262
|
+
const providerFlag = provider === "groq" ? "groq" : "anthropic";
|
|
263
|
+
fresh.scripts.prebuild = fresh.scripts.prebuild
|
|
264
|
+
? `${fresh.scripts.prebuild} && cairn build . --provider ${providerFlag} --if-configured`
|
|
265
|
+
: `cairn build . --provider ${providerFlag} --if-configured`;
|
|
266
|
+
node_fs_1.default.writeFileSync(pkgPath, JSON.stringify(fresh, null, 2) + "\n");
|
|
267
|
+
console.log('\nadded a "prebuild" script — the manifest regenerates automatically on every `npm run build`.');
|
|
268
|
+
console.log((0, ui_1.dim)("(--if-configured means a build with no key set yet skips this step instead of failing the whole build —"));
|
|
269
|
+
console.log((0, ui_1.dim)(" set the same key as an environment variable on whatever platform you deploy to.)"));
|
|
270
|
+
}
|
|
271
|
+
console.log(`\n${(0, ui_1.bold)("Done.")} \`npm run dev\` and ask it something.`);
|
|
272
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A small terminal UI toolkit for `cairn setup` — zero new dependencies,
|
|
3
|
+
// plain ANSI codes (this is a handful of escape sequences, not a reason
|
|
4
|
+
// to pull in a whole chalk/ora dependency tree). Every piece degrades
|
|
5
|
+
// gracefully when stdout isn't a real TTY (CI logs, piped output): the
|
|
6
|
+
// spinner just prints its text once instead of animating, and colors
|
|
7
|
+
// still work fine (most CI systems render ANSI color codes correctly
|
|
8
|
+
// even without an interactive terminal).
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.Spinner = exports.cyan = exports.red = exports.yellow = exports.green = exports.bold = exports.dim = void 0;
|
|
11
|
+
exports.classifyError = classifyError;
|
|
12
|
+
const ESC = "\x1b[";
|
|
13
|
+
const color = (code, s) => `${ESC}${code}m${s}${ESC}0m`;
|
|
14
|
+
const dim = (s) => color("2", s);
|
|
15
|
+
exports.dim = dim;
|
|
16
|
+
const bold = (s) => color("1", s);
|
|
17
|
+
exports.bold = bold;
|
|
18
|
+
const green = (s) => color("32", s);
|
|
19
|
+
exports.green = green;
|
|
20
|
+
const yellow = (s) => color("33", s);
|
|
21
|
+
exports.yellow = yellow;
|
|
22
|
+
const red = (s) => color("31", s);
|
|
23
|
+
exports.red = red;
|
|
24
|
+
const cyan = (s) => color("36", s);
|
|
25
|
+
exports.cyan = cyan;
|
|
26
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
27
|
+
class Spinner {
|
|
28
|
+
text;
|
|
29
|
+
frame = 0;
|
|
30
|
+
timer = null;
|
|
31
|
+
animated;
|
|
32
|
+
constructor(text) {
|
|
33
|
+
this.text = text;
|
|
34
|
+
this.animated = !!process.stdout.isTTY;
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (!this.animated) {
|
|
38
|
+
console.log((0, exports.dim)(this.text));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
process.stdout.write(`\r${ESC}K${(0, exports.cyan)(FRAMES[(this.frame = (this.frame + 1) % FRAMES.length)])} ${this.text}`);
|
|
43
|
+
}, 80);
|
|
44
|
+
}
|
|
45
|
+
/** Update the line in place without starting a new one. */
|
|
46
|
+
update(text) {
|
|
47
|
+
this.text = text;
|
|
48
|
+
if (!this.animated)
|
|
49
|
+
console.log((0, exports.dim)(text));
|
|
50
|
+
}
|
|
51
|
+
/** Stop animating and print a final, non-spinning result line. */
|
|
52
|
+
stop(finalLine) {
|
|
53
|
+
if (this.timer) {
|
|
54
|
+
clearInterval(this.timer);
|
|
55
|
+
this.timer = null;
|
|
56
|
+
}
|
|
57
|
+
if (this.animated)
|
|
58
|
+
process.stdout.write(`\r${ESC}K`);
|
|
59
|
+
console.log(finalLine);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.Spinner = Spinner;
|
|
63
|
+
/** Turns a raw thrown error into a plain-English category + summary —
|
|
64
|
+
* the thing `cairn setup` actually reasons about when deciding what to
|
|
65
|
+
* offer next (switch provider vs. just retry vs. nothing to fix). */
|
|
66
|
+
function classifyError(err) {
|
|
67
|
+
const status = err?.status;
|
|
68
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
+
if (status === 429 || /rate.?limit/i.test(message)) {
|
|
70
|
+
return { kind: "rate_limit", summary: "the provider is rate-limiting requests — this key has hit its per-minute quota" };
|
|
71
|
+
}
|
|
72
|
+
if (status === 401 || status === 403 || /invalid.*key|unauthorized|authentication/i.test(message)) {
|
|
73
|
+
return { kind: "auth", summary: "that API key was rejected — check it's correct and active" };
|
|
74
|
+
}
|
|
75
|
+
if ((typeof status === "number" && status >= 500) || /ECONNRESET|ETIMEDOUT|network/i.test(message)) {
|
|
76
|
+
return { kind: "network", summary: "the provider's servers had a problem — often transient" };
|
|
77
|
+
}
|
|
78
|
+
return { kind: "unknown", summary: message };
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cairnvibe/indexer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Cairn's analyzer and installer (the `cairn` CLI) — scans Next.js source or crawls any running app, and scaffolds the backend either way.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": { "access": "public" },
|