@cairnvibe/indexer 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- console.error(`[cairn] retryable error (attempt ${attempt}/${maxAttempts}), waiting ${Math.round(delayMs)}ms:`, errorMessage(err));
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
  }
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // `cairn setup`'s other departure from `init`'s "never touch an existing
3
+ // file" rule, for the same class of reason as inject-widget.ts: without
4
+ // this, @cairnvibe/sdk and @cairnvibe/core — which ship raw, untranspiled
5
+ // .tsx/.ts as their main entry deliberately, so bundlers apply the
6
+ // *consuming* project's own JSX/TS settings — have no instruction telling
7
+ // Next.js to transform that source at all. Found live, not theoretical:
8
+ // a real project on Next.js 16 + Turbopack failed cold with "Unknown
9
+ // module type" on @cairnvibe/sdk/src/index.tsx the moment `next dev` ran,
10
+ // because nothing in that project's next.config.ts listed it in
11
+ // transpilePackages. Fixing that one project by hand isn't the fix — every
12
+ // consuming project needs this, automatically, which is what this does.
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.ensureTranspilePackages = ensureTranspilePackages;
18
+ const node_fs_1 = __importDefault(require("node:fs"));
19
+ const node_path_1 = __importDefault(require("node:path"));
20
+ const ts_morph_1 = require("ts-morph");
21
+ const REQUIRED_PACKAGES = ["@cairnvibe/sdk", "@cairnvibe/core"];
22
+ const CONFIG_CANDIDATES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"];
23
+ function findConfigFile(absDir) {
24
+ for (const name of CONFIG_CANDIDATES) {
25
+ const p = node_path_1.default.join(absDir, name);
26
+ if (node_fs_1.default.existsSync(p))
27
+ return p;
28
+ }
29
+ return null;
30
+ }
31
+ /** Resolves `export default X` / `module.exports = X` down to the actual
32
+ * object literal, following one level of `const nextConfig = {...}`
33
+ * indirection — covers the two shapes basically every real Next.js
34
+ * config file uses. Anything else (a config wrapped in a plugin function
35
+ * call like `withSentryConfig(nextConfig)`) returns null on purpose —
36
+ * safely falling back to printed instructions beats guessing which
37
+ * argument of an arbitrary function call is the "real" config. */
38
+ function resolveConfigObject(sf, expr) {
39
+ if (!expr)
40
+ return null;
41
+ if (expr.getKind() === ts_morph_1.SyntaxKind.ObjectLiteralExpression)
42
+ return expr;
43
+ if (expr.getKind() === ts_morph_1.SyntaxKind.Identifier) {
44
+ const varDecl = sf.getVariableDeclaration(expr.getText());
45
+ const init = varDecl?.getInitializer();
46
+ if (init?.getKind() === ts_morph_1.SyntaxKind.ObjectLiteralExpression)
47
+ return init;
48
+ }
49
+ return null;
50
+ }
51
+ function findExportedConfigObject(sf) {
52
+ // `export default X;`
53
+ const defaultExport = sf.getExportAssignments()[0];
54
+ if (defaultExport) {
55
+ const resolved = resolveConfigObject(sf, defaultExport.getExpression());
56
+ if (resolved)
57
+ return resolved;
58
+ }
59
+ // `module.exports = X;`
60
+ const moduleExports = sf
61
+ .getDescendantsOfKind(ts_morph_1.SyntaxKind.BinaryExpression)
62
+ .find((b) => b.getOperatorToken().getText() === "=" && b.getLeft().getText() === "module.exports");
63
+ if (moduleExports) {
64
+ const resolved = resolveConfigObject(sf, moduleExports.getRight());
65
+ if (resolved)
66
+ return resolved;
67
+ }
68
+ return null;
69
+ }
70
+ function ensureTranspilePackages(dir) {
71
+ const absDir = node_path_1.default.resolve(dir);
72
+ const existing = findConfigFile(absDir);
73
+ if (!existing) {
74
+ // No config at all yet — the simplest possible one, ESM since that's
75
+ // what every current Next.js version accepts for a fresh project.
76
+ const newPath = node_path_1.default.join(absDir, "next.config.mjs");
77
+ node_fs_1.default.writeFileSync(newPath, `/** @type {import('next').NextConfig} */\nconst nextConfig = {\n transpilePackages: ${JSON.stringify(REQUIRED_PACKAGES)},\n};\n\nexport default nextConfig;\n`);
78
+ return { ok: true, filePath: newPath, created: true };
79
+ }
80
+ const relTarget = node_path_1.default.relative(absDir, existing) || existing;
81
+ const manualHint = `add manually: transpilePackages: ${JSON.stringify(REQUIRED_PACKAGES)}`;
82
+ try {
83
+ const project = new ts_morph_1.Project({ useInMemoryFileSystem: false, skipAddingFilesFromTsConfig: true });
84
+ const sf = project.addSourceFileAtPath(existing);
85
+ const configObject = findExportedConfigObject(sf);
86
+ if (!configObject) {
87
+ return { ok: false, reason: `couldn't confidently find the config object in ${relTarget} (it may be wrapped in a plugin function) — ${manualHint}` };
88
+ }
89
+ const existingProp = configObject.getProperty("transpilePackages");
90
+ if (existingProp?.getKind() === ts_morph_1.SyntaxKind.PropertyAssignment) {
91
+ const initializer = existingProp.asKindOrThrow(ts_morph_1.SyntaxKind.PropertyAssignment).getInitializer();
92
+ if (initializer?.getKind() !== ts_morph_1.SyntaxKind.ArrayLiteralExpression) {
93
+ return { ok: false, reason: `${relTarget}'s transpilePackages isn't a plain array — ${manualHint}` };
94
+ }
95
+ const arr = initializer.asKindOrThrow(ts_morph_1.SyntaxKind.ArrayLiteralExpression);
96
+ const current = arr.getElements().map((e) => e.getText().replace(/^["']|["']$/g, ""));
97
+ const toAdd = REQUIRED_PACKAGES.filter((p) => !current.includes(p));
98
+ if (toAdd.length === 0) {
99
+ return { ok: false, reason: `${relTarget} already lists these packages — leaving it alone` };
100
+ }
101
+ for (const pkg of toAdd)
102
+ arr.addElement(`"${pkg}"`);
103
+ }
104
+ else {
105
+ configObject.addPropertyAssignment({ name: "transpilePackages", initializer: JSON.stringify(REQUIRED_PACKAGES) });
106
+ }
107
+ sf.saveSync();
108
+ return { ok: true, filePath: existing };
109
+ }
110
+ catch (err) {
111
+ return { ok: false, reason: `couldn't safely modify ${relTarget} (${err.message}) — ${manualHint}` };
112
+ }
113
+ }
@@ -1,11 +1,29 @@
1
1
  "use strict";
2
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.
3
+ // file" rule — but only for this one, narrow, reversible edit (a new
4
+ // wrapper component file, plus one import + one JSX tag in the real
5
+ // layout), and only ever via a real AST parse, never blind string
6
+ // splicing. Any structure this doesn't recognize falls back to printing
7
+ // the two-line manual instruction instead of guessing the same "don't
8
+ // corrupt what you don't understand" discipline `init` uses for whole
9
+ // files, applied here at the node level.
10
+ //
11
+ // Real bug this fixes, found by testing against an actual project, not
12
+ // a synthetic fixture: an earlier version inserted `<Copilot
13
+ // onDo={(action, target) => {...}} />` directly into app/layout.tsx.
14
+ // layout.tsx is a Server Component by default (App Router) — React
15
+ // Server Components cannot accept a plain inline function as a prop on
16
+ // a Client Component, which <Copilot/> is ("Event handlers cannot be
17
+ // passed to Client Component props"). The working example app
18
+ // (examples/demo-app/components/CopilotWithActions.tsx) already solved
19
+ // this the right way: a small "use client" wrapper component that
20
+ // *defines* onDo itself, so no function ever crosses the server/client
21
+ // boundary — layout.tsx only ever references the wrapper by name, with
22
+ // zero function props. This module now generates that same wrapper
23
+ // instead of inlining Copilot directly, for both App Router and Pages
24
+ // Router (Pages Router doesn't strictly need it — no RSC boundary
25
+ // there — but the same shape avoids a special case and matches the one
26
+ // real, working example this project has).
9
27
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
28
  return (mod && mod.__esModule) ? mod : { "default": mod };
11
29
  };
@@ -14,7 +32,27 @@ exports.injectWidget = injectWidget;
14
32
  const node_fs_1 = __importDefault(require("node:fs"));
15
33
  const node_path_1 = __importDefault(require("node:path"));
16
34
  const ts_morph_1 = require("ts-morph");
17
- const WIDGET_JSX = `<Copilot registeredActions={[]} onDo={(action, target) => { /* run it through your own auth */ }} />`;
35
+ const WRAPPER_COMPONENT_NAME = "CairnCopilot";
36
+ function wrapperSource() {
37
+ return `"use client";
38
+
39
+ import { Copilot } from "@cairnvibe/sdk";
40
+
41
+ // A small client wrapper so the layout/app file (a server component, for
42
+ // metadata etc.) never has to pass a function prop across the server/client
43
+ // boundary — see the comment in inject-widget.ts for why that fails.
44
+ export function ${WRAPPER_COMPONENT_NAME}() {
45
+ return (
46
+ <Copilot
47
+ registeredActions={[]}
48
+ onDo={(action, target) => {
49
+ // run it through your own auth
50
+ }}
51
+ />
52
+ );
53
+ }
54
+ `;
55
+ }
18
56
  function findLayoutFile(absDir, framework) {
19
57
  const candidates = framework === "next-app-router"
20
58
  ? ["app/layout.tsx", "app/layout.jsx"]
@@ -26,17 +64,34 @@ function findLayoutFile(absDir, framework) {
26
64
  }
27
65
  return null;
28
66
  }
67
+ function toPosixRelativeImport(fromFile, toFileNoExt) {
68
+ let rel = node_path_1.default.relative(node_path_1.default.dirname(fromFile), toFileNoExt).split(node_path_1.default.sep).join("/");
69
+ if (!rel.startsWith("."))
70
+ rel = `./${rel}`;
71
+ return rel;
72
+ }
29
73
  function injectWidget(dir, framework) {
30
74
  const absDir = node_path_1.default.resolve(dir);
31
75
  const target = findLayoutFile(absDir, framework);
32
76
  if (!target) {
33
- return { injected: false, reason: "no app/layout.tsx or pages/_app.tsx found — add <Copilot/> manually" };
77
+ return { injected: false, reason: "no app/layout.tsx or pages/_app.tsx found — add the widget manually" };
34
78
  }
35
79
  const relTarget = node_path_1.default.relative(absDir, target) || target;
36
80
  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` };
81
+ if (original.includes(WRAPPER_COMPONENT_NAME) || original.includes("@cairnvibe/sdk") || original.includes("<Copilot")) {
82
+ return { injected: false, reason: `${relTarget} already references the widget — leaving it alone` };
83
+ }
84
+ // The wrapper always matches the layout file's own extension (.tsx stays
85
+ // .tsx, .jsx stays .jsx — a .tsx file dropped into a plain-JS project has
86
+ // no type checker configured for it and would just confuse tooling).
87
+ const ext = node_path_1.default.extname(target); // ".tsx" or ".jsx"
88
+ const wrapperPath = node_path_1.default.join(absDir, "components", `${WRAPPER_COMPONENT_NAME}${ext}`);
89
+ if (!node_fs_1.default.existsSync(wrapperPath)) {
90
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(wrapperPath), { recursive: true });
91
+ node_fs_1.default.writeFileSync(wrapperPath, wrapperSource());
39
92
  }
93
+ const importPath = toPosixRelativeImport(target, wrapperPath.slice(0, -ext.length));
94
+ const widgetJsx = `<${WRAPPER_COMPONENT_NAME} />`;
40
95
  try {
41
96
  const project = new ts_morph_1.Project({
42
97
  useInMemoryFileSystem: false,
@@ -44,9 +99,9 @@ function injectWidget(dir, framework) {
44
99
  compilerOptions: { jsx: ts_morph_1.ts.JsxEmit.ReactJSX, allowJs: true, esModuleInterop: true, target: ts_morph_1.ts.ScriptTarget.ES2022 },
45
100
  });
46
101
  const sf = project.addSourceFileAtPath(target);
47
- const hasImport = sf.getImportDeclarations().some((d) => d.getModuleSpecifierValue() === "@cairnvibe/sdk");
102
+ const hasImport = sf.getImportDeclarations().some((d) => d.getModuleSpecifierValue() === importPath);
48
103
  if (!hasImport) {
49
- sf.addImportDeclaration({ moduleSpecifier: "@cairnvibe/sdk", namedImports: ["Copilot"] });
104
+ sf.addImportDeclaration({ moduleSpecifier: importPath, namedImports: [WRAPPER_COMPONENT_NAME] });
50
105
  }
51
106
  // `insertText` at a position, not `replaceWithText` on a node — the latter
52
107
  // asks ts-morph to structurally reconcile old vs. new trees, which fails
@@ -62,7 +117,7 @@ function injectWidget(dir, framework) {
62
117
  if (bodyOpening) {
63
118
  const closing = bodyOpening.getParentIfKind(ts_morph_1.SyntaxKind.JsxElement)?.getClosingElement();
64
119
  if (closing) {
65
- sf.insertText(closing.getStart(), `${WIDGET_JSX}\n `);
120
+ sf.insertText(closing.getStart(), `${widgetJsx}\n `);
66
121
  inserted = true;
67
122
  }
68
123
  }
@@ -72,7 +127,7 @@ function injectWidget(dir, framework) {
72
127
  .getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxExpression)
73
128
  .find((e) => e.getExpression()?.getText() === "children");
74
129
  if (childrenExpr) {
75
- sf.insertText(childrenExpr.getEnd(), `\n ${WIDGET_JSX}`);
130
+ sf.insertText(childrenExpr.getEnd(), `\n ${widgetJsx}`);
76
131
  inserted = true;
77
132
  }
78
133
  }
@@ -89,20 +144,22 @@ function injectWidget(dir, framework) {
89
144
  // first insertion changes the source text underneath it.
90
145
  const start = componentTag.getStart();
91
146
  const end = componentTag.getEnd();
92
- sf.insertText(end, `\n ${WIDGET_JSX}\n </>`);
147
+ sf.insertText(end, `\n ${widgetJsx}\n </>`);
93
148
  sf.insertText(start, `<>\n `);
94
149
  inserted = true;
95
150
  }
96
151
  }
97
152
  if (!inserted) {
98
- return { injected: false, reason: `couldn't find a safe spot in ${relTarget} — add <Copilot/> manually` };
153
+ return { injected: false, reason: `couldn't find a safe spot in ${relTarget} — add the widget manually` };
99
154
  }
100
155
  sf.saveSync();
101
- return { injected: true, filePath: target };
156
+ return { injected: true, filePath: target, wrapperPath };
102
157
  }
103
158
  catch (err) {
104
159
  // Never leave a half-written file — ts-morph only writes on saveSync(),
105
160
  // 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` };
161
+ // The wrapper component file, if it was just created, is still valid
162
+ // and harmless on its own — it's just not referenced from anywhere yet.
163
+ return { injected: false, reason: `couldn't safely modify ${relTarget} (${err.message}) — add the widget manually` };
107
164
  }
108
165
  }
@@ -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 CHANGED
@@ -12,6 +12,7 @@ exports.closePrompts = closePrompts;
12
12
  exports.ask = ask;
13
13
  exports.askYesNo = askYesNo;
14
14
  exports.askOptional = askOptional;
15
+ exports.selectFromList = selectFromList;
15
16
  const node_readline_1 = __importDefault(require("node:readline"));
16
17
  let sharedInterface = null;
17
18
  function rl() {
@@ -40,3 +41,25 @@ async function askOptional(question) {
40
41
  const answer = await ask(question);
41
42
  return answer.length > 0 ? answer : null;
42
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 CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  // `cairn setup` — the one-command onboarding path: install what's
3
- // needed, ask only for what's actually optional (skippable), scaffold
4
- // the backend, wire the widget into the real layout file, build the
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
5
6
  // manifest once now, and leave a `prebuild` hook so it rebuilds itself
6
7
  // on every future `npm run build` without another manual step.
7
8
  //
@@ -9,8 +10,8 @@
9
10
  // `init` stays the safe, deterministic, non-interactive primitive
10
11
  // (never touches an existing file, never installs anything, never
11
12
  // prompts); `setup` is the opinionated wizard built from those same
12
- // primitives plus the two things `init` intentionally doesn't do:
13
- // install dependencies and edit an existing layout file.
13
+ // primitives plus the things `init` intentionally doesn't do: install
14
+ // dependencies, edit an existing layout file, and actually build.
14
15
  var __importDefault = (this && this.__importDefault) || function (mod) {
15
16
  return (mod && mod.__esModule) ? mod : { "default": mod };
16
17
  };
@@ -21,6 +22,7 @@ const node_path_1 = __importDefault(require("node:path"));
21
22
  const node_child_process_1 = require("node:child_process");
22
23
  const init_1 = require("./init");
23
24
  const inject_widget_1 = require("./inject-widget");
25
+ const ensure_transpile_1 = require("./ensure-transpile");
24
26
  const prompt_1 = require("./prompt");
25
27
  const l1_scan_1 = require("./l1-scan");
26
28
  const l2_reachability_1 = require("./l2-reachability");
@@ -28,7 +30,14 @@ const l3_describe_1 = require("./l3-describe");
28
30
  const llm_1 = require("./llm");
29
31
  const manifest_1 = require("./manifest");
30
32
  const core_1 = require("@cairnvibe/core");
33
+ const ui_1 = require("./ui");
31
34
  const PACKAGES = ["@cairnvibe/core", "@cairnvibe/sdk", "@cairnvibe/indexer"];
35
+ // Lower than cairn build's own default (6) — a first-time setup is exactly
36
+ // the scenario most likely to be running on a free-tier key with a tight
37
+ // per-minute token budget; found live, not theoretical (a real `cairn
38
+ // setup` run against Groq's on-demand tier hit a 429-retry cascade at the
39
+ // default concurrency on a small handful of pages).
40
+ const SETUP_BUILD_CONCURRENCY = 3;
32
41
  function readPackageJson(absDir) {
33
42
  const p = node_path_1.default.join(absDir, "package.json");
34
43
  if (!node_fs_1.default.existsSync(p))
@@ -46,13 +55,98 @@ function alreadyInstalled(pkg) {
46
55
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
47
56
  return PACKAGES.every((p) => !!deps[p]);
48
57
  }
58
+ /** One build attempt — spinner-driven, quiet on individual retries (they
59
+ * update the same line instead of scrolling the terminal), and honest
60
+ * about failure instead of throwing a raw stack trace at the user. */
61
+ async function attemptBuild(dir, provider, key) {
62
+ if (provider === "anthropic")
63
+ process.env.ANTHROPIC_API_KEY = key;
64
+ if (provider === "groq")
65
+ process.env.GROQ_API_KEYS = key;
66
+ const spinner = new ui_1.Spinner(`Building the manifest (${provider}) ...`);
67
+ spinner.start();
68
+ try {
69
+ const client = provider === "anthropic" ? new llm_1.AnthropicDescribeClient() : new llm_1.GroqDescribeClient();
70
+ const facts = (0, l1_scan_1.scanL1)(dir);
71
+ const l2 = (0, l2_reachability_1.computeL2)(dir, facts);
72
+ const l3 = await (0, l3_describe_1.describeAll)(dir, facts, client, SETUP_BUILD_CONCURRENCY, (info) => {
73
+ spinner.update(`Building the manifest (${provider}) ... rate-limited, retrying in ${Math.round(info.delayMs / 1000)}s (attempt ${info.attempt}/${info.maxAttempts})`);
74
+ });
75
+ const manifest = core_1.ManifestSchema.parse((0, manifest_1.assembleManifest)(dir, facts, l2, l3));
76
+ 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");
77
+ spinner.stop((0, ui_1.green)(`✓ wrote ui-manifest.json (${manifest.pages.length} page(s))`));
78
+ return { ok: true, pageCount: manifest.pages.length };
79
+ }
80
+ catch (err) {
81
+ spinner.stop((0, ui_1.red)("✗ build failed"));
82
+ return { ok: false, error: err };
83
+ }
84
+ }
85
+ /** Runs after a failed build: explains what actually went wrong in plain
86
+ * English, then offers real next actions instead of just dying. Loops
87
+ * until the user picks something that resolves (a successful retry) or
88
+ * explicitly chooses to skip. */
89
+ async function recoverFromBuildFailure(dir, provider, key, err) {
90
+ const classified = (0, ui_1.classifyError)(err);
91
+ console.log("");
92
+ console.log((0, ui_1.yellow)(`Here's what happened: ${classified.summary}`));
93
+ const options = classified.kind === "rate_limit"
94
+ ? [
95
+ { label: "Try again in a bit (same provider)", value: "retry" },
96
+ { label: "Switch to the other provider and try that instead", value: "switch" },
97
+ { label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
98
+ ]
99
+ : classified.kind === "auth"
100
+ ? [
101
+ { label: "Paste the key again (I probably mistyped it)", value: "rekey" },
102
+ { label: "Switch to the other provider instead", value: "switch" },
103
+ { label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
104
+ ]
105
+ : [
106
+ { label: "Try again", value: "retry" },
107
+ { label: "Switch to the other provider instead", value: "switch" },
108
+ { label: "Skip for now — I'll run `npx cairn build .` later", value: "skip" },
109
+ ];
110
+ for (;;) {
111
+ const choice = await (0, prompt_1.selectFromList)("What do you want to do?", options, 0);
112
+ if (choice === "skip")
113
+ return null;
114
+ let nextProvider = provider;
115
+ let nextKey = key;
116
+ if (choice === "switch") {
117
+ nextProvider = provider === "anthropic" ? "groq" : "anthropic";
118
+ const pasted = await (0, prompt_1.askOptional)(nextProvider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
119
+ if (!pasted) {
120
+ console.log((0, ui_1.dim)("No key given — back to the menu."));
121
+ continue;
122
+ }
123
+ nextKey = pasted;
124
+ }
125
+ else if (choice === "rekey") {
126
+ const pasted = await (0, prompt_1.askOptional)(provider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
127
+ if (!pasted) {
128
+ console.log((0, ui_1.dim)("No key given — back to the menu."));
129
+ continue;
130
+ }
131
+ nextKey = pasted;
132
+ }
133
+ // choice === "retry" falls through with the same provider/key.
134
+ const result = await attemptBuild(dir, nextProvider, nextKey);
135
+ if (result.ok)
136
+ return { provider: nextProvider, key: nextKey };
137
+ console.log("");
138
+ console.log((0, ui_1.yellow)(`Still failing: ${(0, ui_1.classifyError)(result.error).summary}`));
139
+ // loop back to the menu rather than recursing — keeps this one flat retry
140
+ // loop instead of a call stack that grows with every attempt
141
+ }
142
+ }
49
143
  async function runSetup(dir) {
50
144
  const absDir = node_path_1.default.resolve(dir);
51
- console.log(`cairn setup: looking at ${absDir}\n`);
145
+ console.log(`${(0, ui_1.bold)("cairn setup")} looking at ${absDir}\n`);
52
146
  // 1. Scaffold what init already safely can — framework detection, the
53
147
  // backend route, .env.example. Never overwrites anything that exists.
54
148
  const init = (0, init_1.runInit)(dir);
55
- console.log(`Detected: ${init.framework}`);
149
+ console.log(`Detected: ${(0, ui_1.bold)(init.framework)}`);
56
150
  for (const f of init.filesWritten)
57
151
  console.log(` wrote ${node_path_1.default.relative(absDir, f) || f}`);
58
152
  for (const f of init.filesSkipped)
@@ -71,31 +165,43 @@ async function runSetup(dir) {
71
165
  // cleanly if already present (e.g. re-running setup after a partial run).
72
166
  const pkg = readPackageJson(absDir);
73
167
  if (!alreadyInstalled(pkg)) {
74
- console.log(`Installing ${PACKAGES.join(", ")} ...`);
168
+ const spinner = new ui_1.Spinner(`Installing ${PACKAGES.join(", ")} ...`);
169
+ spinner.start();
75
170
  try {
76
- (0, node_child_process_1.execSync)(`npm install ${PACKAGES.join(" ")}`, { cwd: absDir, stdio: "inherit" });
171
+ (0, node_child_process_1.execSync)(`npm install ${PACKAGES.join(" ")}`, { cwd: absDir, stdio: "pipe" });
172
+ spinner.stop((0, ui_1.green)(`✓ installed ${PACKAGES.join(", ")}`));
77
173
  }
78
174
  catch {
79
- console.error("\nnpm install failed — install these yourself and re-run `cairn setup`:");
80
- console.error(` npm install ${PACKAGES.join(" ")}`);
175
+ spinner.stop((0, ui_1.red)(" npm install failed"));
176
+ console.error(`Install these yourself and re-run \`cairn setup\`:\n npm install ${PACKAGES.join(" ")}`);
81
177
  return;
82
178
  }
83
179
  }
84
180
  else {
85
- console.log("Dependencies already installed — skipping.\n");
181
+ console.log((0, ui_1.dim)("Dependencies already installed — skipping."));
86
182
  }
87
- // 3. Ask only what's actually needed, everything skippable.
88
- console.log("\nA couple of quick questions press enter to skip anything you'll add later.\n");
183
+ // 3. Ask only what's actually needed, everything skippable, picked from a
184
+ // real menu rather than typed free text.
185
+ console.log(`\n${(0, ui_1.bold)("A couple of quick questions")} — press enter to skip anything you'll add later.\n`);
89
186
  let provider = null;
90
187
  let providerKey = null;
91
- const wantsLLM = await (0, prompt_1.askYesNo)("Set up an LLM provider now? (needed for the agent to actually answer anything)", true);
92
- if (wantsLLM) {
93
- const choice = (await (0, prompt_1.ask)("Anthropic or Groq? [anthropic] ")).toLowerCase();
94
- provider = choice.startsWith("g") ? "groq" : "anthropic";
188
+ const llmChoice = await (0, prompt_1.selectFromList)("Set up an LLM provider now? (needed for the agent to actually answer anything)", [
189
+ { label: "Anthropic (Claude)", value: "anthropic" },
190
+ { label: "Groq", value: "groq" },
191
+ { label: "Skip I'll add one to .env later", value: "skip" },
192
+ ], 0);
193
+ if (llmChoice !== "skip") {
194
+ provider = llmChoice;
95
195
  providerKey = await (0, prompt_1.askOptional)(provider === "anthropic" ? "Paste your ANTHROPIC_API_KEY: " : "Paste your GROQ_API_KEYS: ");
96
196
  }
97
- const wantsVoice = await (0, prompt_1.askYesNo)("Set up voice (Deepgram speech in/out) now?", false);
98
- const deepgramKey = wantsVoice ? await (0, prompt_1.askOptional)("Paste your DEEPGRAM_API_KEY: ") : null;
197
+ // Honest about what's actually implemented here Deepgram is the only
198
+ // voice provider this SDK wires up today, so this is "on or off," not a
199
+ // real multi-provider menu dressed up as one.
200
+ const voiceChoice = await (0, prompt_1.selectFromList)("Set up voice now?", [
201
+ { label: "Deepgram (speech in + out)", value: "deepgram" },
202
+ { label: "Skip — no voice for now", value: "skip" },
203
+ ], 1);
204
+ const deepgramKey = voiceChoice === "deepgram" ? await (0, prompt_1.askOptional)("Paste your DEEPGRAM_API_KEY: ") : null;
99
205
  (0, prompt_1.closePrompts)();
100
206
  // 4. Write a real .env (not just .env.example) with whatever was actually given.
101
207
  const envLines = [];
@@ -120,37 +226,42 @@ async function runSetup(dir) {
120
226
  const framework = init.framework;
121
227
  const inject = (0, inject_widget_1.injectWidget)(dir, framework);
122
228
  if (inject.injected) {
123
- console.log(`wired <Copilot/> into ${node_path_1.default.relative(absDir, inject.filePath)}`);
229
+ console.log((0, ui_1.green)(`✓ wired the widget into ${node_path_1.default.relative(absDir, inject.filePath)} (via a new components/CairnCopilot.tsx wrapper)`));
124
230
  }
125
231
  else {
126
- console.log(`\n<Copilot/> not auto-wired (${inject.reason}). Add it yourself:`);
232
+ console.log(`\nWidget not auto-wired (${inject.reason}). Add it yourself:`);
127
233
  console.log(' import { Copilot } from "@cairnvibe/sdk";');
128
234
  console.log(" <Copilot registeredActions={[]} onDo={(action, target) => { /* run it */ }} />");
235
+ console.log(" (in a \"use client\" component — see examples/demo-app/components/CopilotWithActions.tsx for why)");
129
236
  }
130
- // 6. Build the manifest once now, if we actually have a usable key —
131
- // no point trying (and failing loudly) with nothing to call.
132
- const haveUsableKey = (provider === "anthropic" && providerKey) || (provider === "groq" && providerKey);
133
- if (haveUsableKey) {
134
- console.log(`\nBuilding the manifest (${provider}) ...`);
135
- try {
136
- // Reuse the same env-var-driven construction `cairn build` uses, rather
137
- // than duplicating each client's options shape here the .env written
138
- // above already has this exact value, this just makes it live for the
139
- // rest of this process too.
140
- if (provider === "anthropic")
141
- process.env.ANTHROPIC_API_KEY = providerKey;
142
- if (provider === "groq")
143
- process.env.GROQ_API_KEYS = providerKey;
144
- const client = provider === "anthropic" ? new llm_1.AnthropicDescribeClient() : new llm_1.GroqDescribeClient();
145
- const facts = (0, l1_scan_1.scanL1)(dir);
146
- const l2 = (0, l2_reachability_1.computeL2)(dir, facts);
147
- const l3 = await (0, l3_describe_1.describeAll)(dir, facts, client);
148
- const manifest = core_1.ManifestSchema.parse((0, manifest_1.assembleManifest)(dir, facts, l2, l3));
149
- node_fs_1.default.writeFileSync(node_path_1.default.join(absDir, "ui-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
150
- console.log(`wrote ui-manifest.json (${manifest.pages.length} page(s))`);
151
- }
152
- catch (err) {
153
- console.error(`manifest build failed (${err.message}) — run \`npx cairn build .\` yourself once your key is confirmed working.`);
237
+ // 5b. @cairnvibe/sdk and @cairnvibe/core ship raw TS/TSX as their main
238
+ // entry deliberately bundlers need transpilePackages to know to
239
+ // transform it. Without this, real projects fail cold at `next dev`
240
+ // with "Unknown module type", not something a demo on a fresh project
241
+ // would ever surface (this repo's own next.config.js already has it).
242
+ const transpile = (0, ensure_transpile_1.ensureTranspilePackages)(dir);
243
+ if (transpile.ok) {
244
+ console.log((0, ui_1.green)(`✓ ${transpile.created ? "created" : "updated"} ${node_path_1.default.relative(absDir, transpile.filePath)} with transpilePackages`));
245
+ }
246
+ else {
247
+ console.log(`\ntranspilePackages not auto-added (${transpile.reason})`);
248
+ }
249
+ // 6. Build the manifest once now, if we actually have a usable key — no
250
+ // point trying (and failing loudly) with nothing to call. On failure,
251
+ // don't just print a stack trace and give up: classify what went wrong
252
+ // and offer real next steps (retry / switch provider / skip).
253
+ if (provider && providerKey) {
254
+ console.log("");
255
+ let result = await attemptBuild(dir, provider, providerKey);
256
+ if (!result.ok) {
257
+ const recovered = await recoverFromBuildFailure(dir, provider, providerKey, result.error);
258
+ if (recovered) {
259
+ provider = recovered.provider;
260
+ providerKey = recovered.key;
261
+ }
262
+ // else: user chose to skip — fall through with the original provider/key
263
+ // still recorded for the prebuild script below; ui-manifest.json is
264
+ // simply not written yet.
154
265
  }
155
266
  }
156
267
  else {
@@ -167,9 +278,9 @@ async function runSetup(dir) {
167
278
  ? `${fresh.scripts.prebuild} && cairn build . --provider ${providerFlag} --if-configured`
168
279
  : `cairn build . --provider ${providerFlag} --if-configured`;
169
280
  node_fs_1.default.writeFileSync(pkgPath, JSON.stringify(fresh, null, 2) + "\n");
170
- console.log('added a "prebuild" script — the manifest regenerates automatically on every `npm run build`.');
171
- console.log("(--if-configured means a build with no key set yet skips this step instead of failing the whole build —");
172
- console.log(" set the same key as an environment variable on whatever platform you deploy to.)");
281
+ console.log('\nadded a "prebuild" script — the manifest regenerates automatically on every `npm run build`.');
282
+ 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 —"));
283
+ console.log((0, ui_1.dim)(" set the same key as an environment variable on whatever platform you deploy to.)"));
173
284
  }
174
- console.log("\nDone. `npm run dev` and ask it something.");
285
+ console.log(`\n${(0, ui_1.bold)("Done.")} \`npm run dev\` and ask it something.`);
175
286
  }
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.2.0",
3
+ "version": "0.2.2",
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" },