robot-resources 1.14.2 → 1.15.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/bin/setup.js +6 -1
- package/lib/detect.js +4 -1
- package/lib/non-oc-wizard.js +282 -19
- package/lib/source-edit-attach.js +469 -0
- package/lib/uninstall.js +31 -0
- package/lib/wizard.js +83 -44
- package/package.json +1 -1
package/bin/setup.js
CHANGED
|
@@ -25,13 +25,18 @@ if (args.includes('--uninstall')) {
|
|
|
25
25
|
const scopeArg = args.find((a) => a.startsWith('--scope='));
|
|
26
26
|
const scope = scopeArg ? scopeArg.slice('--scope='.length) : 'full';
|
|
27
27
|
|
|
28
|
+
// Phase 11: --auto-attach-source opts into source-edit injection in non-
|
|
29
|
+
// interactive contexts (CI). Default off — auto-rewriting source files
|
|
30
|
+
// without consent is too aggressive. Interactive runs always show Y/N.
|
|
31
|
+
const autoAttachSource = args.includes('--auto-attach-source');
|
|
32
|
+
|
|
28
33
|
// Treat piped/CI runs (no TTY on stdin OR stdout) as non-interactive so the
|
|
29
34
|
// wizard never blocks on a prompt that can't be answered. The interactive
|
|
30
35
|
// menu is only opened when both stdin and stdout are real terminals.
|
|
31
36
|
const hasTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
32
37
|
const nonInteractive = explicitNonInteractive || !hasTty;
|
|
33
38
|
|
|
34
|
-
runWizard({ nonInteractive, target, scope }).catch((err) => {
|
|
39
|
+
runWizard({ nonInteractive, target, scope, autoAttachSource }).catch((err) => {
|
|
35
40
|
console.error(`\n ✗ Setup failed: ${err.message}\n`);
|
|
36
41
|
process.exit(1);
|
|
37
42
|
});
|
package/lib/detect.js
CHANGED
|
@@ -137,7 +137,10 @@ export function isCursorInstalled() {
|
|
|
137
137
|
// means a generic project (e.g. just package.json, no LLM SDK deps yet) —
|
|
138
138
|
// still picks the language but with low confidence.
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
// Exported (Phase 11.1) so the SDK-import scanner in source-edit-attach.js
|
|
141
|
+
// can reuse the same canonical list — single source of truth for "which npm
|
|
142
|
+
// packages count as an AI SDK signal."
|
|
143
|
+
export const NODE_AGENT_DEPS = [
|
|
141
144
|
'@anthropic-ai/sdk',
|
|
142
145
|
'openai',
|
|
143
146
|
'@google/generative-ai',
|
package/lib/non-oc-wizard.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
2
|
+
import { extname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { select, input } from '@inquirer/prompts';
|
|
4
5
|
import { isClaudeCodeInstalled, isCursorInstalled, detectAgentRuntime } from './detect.js';
|
|
5
6
|
import { configureClaudeCode, configureCursor } from './tool-config.js';
|
|
6
|
-
import { header, info, success, warn, blank } from './ui.js';
|
|
7
|
+
import { header, info, success, warn, blank, confirm } from './ui.js';
|
|
7
8
|
import { readConfig } from './config.mjs';
|
|
8
9
|
import { installNodeShim } from './install-node-shim.js';
|
|
9
10
|
import { installPythonShim } from './install-python-shim.js';
|
|
11
|
+
import {
|
|
12
|
+
findAgentSourceFile,
|
|
13
|
+
hasSourceMarker,
|
|
14
|
+
writeSourceMarker,
|
|
15
|
+
previewInjection,
|
|
16
|
+
pathForTelemetry,
|
|
17
|
+
} from './source-edit-attach.js';
|
|
10
18
|
|
|
11
19
|
const PLATFORM_URL = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
|
|
12
20
|
|
|
@@ -89,7 +97,259 @@ async function emitPathChosen(path) {
|
|
|
89
97
|
}
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
async function
|
|
100
|
+
async function emitNodeEntryPatched(payload) {
|
|
101
|
+
const config = readConfig();
|
|
102
|
+
if (!config.api_key) return;
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`${PLATFORM_URL}/v1/telemetry`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
product: 'cli',
|
|
112
|
+
event_type: 'node_entry_patched',
|
|
113
|
+
payload: { ...payload, platform: process.platform },
|
|
114
|
+
}),
|
|
115
|
+
signal: AbortSignal.timeout(5_000),
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// Best-effort — never let telemetry break the install path.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const VALID_SOURCE_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Expand `~` to $HOME. Same trick shells use. Anything else (env vars,
|
|
126
|
+
* `..` segments) we leave to `path.resolve` to handle.
|
|
127
|
+
*/
|
|
128
|
+
function expandPath(raw, cwd) {
|
|
129
|
+
let p = raw.trim();
|
|
130
|
+
if (p === '~') p = homedir();
|
|
131
|
+
else if (p.startsWith('~/') || p.startsWith('~\\')) p = join(homedir(), p.slice(2));
|
|
132
|
+
if (!isAbsolute(p)) p = resolve(cwd, p);
|
|
133
|
+
return p;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Phase 11 + 11.1 — offer to inject `require('@robot-resources/router/auto')`
|
|
138
|
+
* (or ESM equivalent) at the top of the user's entry file. Phase 11 wrote
|
|
139
|
+
* the plumbing; Phase 11.1 swaps the package.json-gated detector for an
|
|
140
|
+
* SDK-import scanner that finds files actually talking to AI models.
|
|
141
|
+
*
|
|
142
|
+
* Why source-edit at all: NODE_OPTIONS in shell config never reaches cron /
|
|
143
|
+
* Docker / systemd / Lambda agents because those launchers don't read shell
|
|
144
|
+
* rc files. A line in the source survives any launcher.
|
|
145
|
+
*
|
|
146
|
+
* Decision matrix (now driven by findAgentSourceFile):
|
|
147
|
+
* - 0 SDK files found → free-text path prompt (interactive) or skip (CI)
|
|
148
|
+
* - 1 candidate → preview + Y/N (skip the chooser)
|
|
149
|
+
* - 2+, clear winner → preview winner + Y/N (skip the chooser)
|
|
150
|
+
* - 2+, ambiguous → select() chooser, then Y/N
|
|
151
|
+
* - Marker already present → skip silently
|
|
152
|
+
* - Non-interactive without `autoAttachSource` → skip with a hint
|
|
153
|
+
*
|
|
154
|
+
* Source files are sacred — every accept path goes through Y/N (or the
|
|
155
|
+
* explicit `--auto-attach-source` opt-in for CI).
|
|
156
|
+
*/
|
|
157
|
+
async function maybeInjectSourceEdit({ nonInteractive, autoAttachSource, cwd = process.cwd() } = {}) {
|
|
158
|
+
const scan = findAgentSourceFile(cwd);
|
|
159
|
+
const baseTelemetry = {
|
|
160
|
+
candidates_scanned: scan.scanned,
|
|
161
|
+
total_files_walked: scan.walked,
|
|
162
|
+
winner_score: scan.candidates[0]?.score ?? null,
|
|
163
|
+
runner_up_score: scan.candidates[1]?.score ?? null,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ── 0 candidates ───────────────────────────────────────────────────────
|
|
167
|
+
if (!scan.winner) {
|
|
168
|
+
if (nonInteractive) {
|
|
169
|
+
blank();
|
|
170
|
+
info(`We scanned ${scan.walked} source files and found no AI SDK imports.`);
|
|
171
|
+
info('Add this line manually at the top of your agent entry file:');
|
|
172
|
+
info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
|
|
173
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'no_sdk_imports_found' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Interactive 0-candidate: ask the user to point us at the file.
|
|
178
|
+
blank();
|
|
179
|
+
info(`We scanned ${scan.walked} source files in this directory and found no AI SDK imports.`);
|
|
180
|
+
blank();
|
|
181
|
+
let raw;
|
|
182
|
+
try {
|
|
183
|
+
raw = await input({
|
|
184
|
+
message: 'Path to your agent file (or press Enter to skip):',
|
|
185
|
+
default: '',
|
|
186
|
+
});
|
|
187
|
+
} catch {
|
|
188
|
+
// Ctrl-C or terminal closed.
|
|
189
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!raw || !raw.trim()) {
|
|
193
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const target = expandPath(raw, cwd);
|
|
198
|
+
if (!existsSync(target)) {
|
|
199
|
+
warn(`File does not exist: ${target}`);
|
|
200
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'failed', error: 'path_missing' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!VALID_SOURCE_EXTS.has(extname(target).toLowerCase())) {
|
|
204
|
+
warn(`Refusing to edit ${extname(target)} — only .js/.mjs/.cjs/.ts/.tsx/.jsx are supported.`);
|
|
205
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'failed', error: 'invalid_extension' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (hasSourceMarker(target)) {
|
|
209
|
+
info(`${pathForTelemetry(target, cwd)} already has the auto-attach line — leaving it as-is.`);
|
|
210
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'already_present', entry_path: pathForTelemetry(target, cwd) });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Outside-cwd: extra confirmation. The user meant a path relative to
|
|
215
|
+
// their project; if they typed something far away, double-check.
|
|
216
|
+
const isOutsideCwd = !target.startsWith(cwd + '/') && target !== cwd;
|
|
217
|
+
if (isOutsideCwd) {
|
|
218
|
+
const ok = await confirm(`${target} is outside your current directory. Edit it anyway?`, { defaultYes: false });
|
|
219
|
+
if (!ok) {
|
|
220
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined_path_prompt' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return await applyInjection({
|
|
226
|
+
target,
|
|
227
|
+
cwd,
|
|
228
|
+
nonInteractive,
|
|
229
|
+
autoAttachSource,
|
|
230
|
+
baseTelemetry,
|
|
231
|
+
viaPrompt: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── 1+ candidates ──────────────────────────────────────────────────────
|
|
236
|
+
const winnerPath = scan.winner;
|
|
237
|
+
|
|
238
|
+
if (hasSourceMarker(winnerPath)) {
|
|
239
|
+
info(`${pathForTelemetry(winnerPath, cwd)} already has the auto-attach line — leaving it as-is.`);
|
|
240
|
+
await emitNodeEntryPatched({
|
|
241
|
+
...baseTelemetry,
|
|
242
|
+
outcome: 'already_present',
|
|
243
|
+
entry_path: pathForTelemetry(winnerPath, cwd),
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (nonInteractive && !autoAttachSource) {
|
|
249
|
+
blank();
|
|
250
|
+
info('Skipping source-attach in non-interactive mode (auto-rewriting source needs consent).');
|
|
251
|
+
info(`To enable it in CI, re-run with: npx robot-resources --for=langchain --auto-attach-source`);
|
|
252
|
+
info(`Or add this line manually at the top of ${pathForTelemetry(winnerPath, cwd)}:`);
|
|
253
|
+
info(" require('@robot-resources/router/auto'); // CJS, or import '...' for ESM/TS");
|
|
254
|
+
await emitNodeEntryPatched({
|
|
255
|
+
...baseTelemetry,
|
|
256
|
+
outcome: 'skipped_non_interactive',
|
|
257
|
+
entry_path: pathForTelemetry(winnerPath, cwd),
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Resolve the actual target. Skip the chooser when winner is unambiguous —
|
|
263
|
+
// most users see exactly one Y/N.
|
|
264
|
+
let target = winnerPath;
|
|
265
|
+
if (!nonInteractive && scan.ambiguous && scan.candidates.length > 1) {
|
|
266
|
+
const choices = scan.candidates.slice(0, 6).map((c) => ({
|
|
267
|
+
name: `${pathForTelemetry(c.path, cwd)} (score ${c.score})`,
|
|
268
|
+
value: c.path,
|
|
269
|
+
}));
|
|
270
|
+
choices.push({ name: 'Skip — I\'ll add the line manually', value: '__skip__' });
|
|
271
|
+
try {
|
|
272
|
+
target = await select({
|
|
273
|
+
message: 'Multiple files import an AI SDK. Which one is your agent?',
|
|
274
|
+
default: winnerPath,
|
|
275
|
+
choices,
|
|
276
|
+
});
|
|
277
|
+
} catch {
|
|
278
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (target === '__skip__') {
|
|
282
|
+
await emitNodeEntryPatched({ ...baseTelemetry, outcome: 'declined' });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return await applyInjection({
|
|
288
|
+
target,
|
|
289
|
+
cwd,
|
|
290
|
+
nonInteractive,
|
|
291
|
+
autoAttachSource,
|
|
292
|
+
baseTelemetry,
|
|
293
|
+
viaPrompt: false,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Common write path used by both the "winner detected" and "user typed a
|
|
299
|
+
* path" branches. Shows the diff, asks Y/N (unless --auto-attach-source),
|
|
300
|
+
* writes the marker, emits telemetry. Single source of truth so the two
|
|
301
|
+
* branches can't drift.
|
|
302
|
+
*/
|
|
303
|
+
async function applyInjection({ target, cwd, nonInteractive, autoAttachSource, baseTelemetry, viaPrompt }) {
|
|
304
|
+
const preview = previewInjection(target);
|
|
305
|
+
blank();
|
|
306
|
+
success('Add the auto-attach line so routing also works under cron / Docker / systemd / Lambda?');
|
|
307
|
+
blank();
|
|
308
|
+
info(`File: ${pathForTelemetry(target, cwd)}`);
|
|
309
|
+
blank();
|
|
310
|
+
for (const line of preview.split('\n')) info(line);
|
|
311
|
+
blank();
|
|
312
|
+
|
|
313
|
+
let proceed = autoAttachSource;
|
|
314
|
+
if (!nonInteractive && !autoAttachSource) {
|
|
315
|
+
proceed = await confirm('Add the line?', { defaultYes: true });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!proceed) {
|
|
319
|
+
info('Skipped — you can re-run anytime.');
|
|
320
|
+
await emitNodeEntryPatched({
|
|
321
|
+
...baseTelemetry,
|
|
322
|
+
outcome: 'declined',
|
|
323
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = writeSourceMarker(target);
|
|
329
|
+
if (result.ok) {
|
|
330
|
+
success(`Added the auto-attach line to ${pathForTelemetry(target, cwd)} (${result.syntax}).`);
|
|
331
|
+
if (result.backupWritten) info(` Backup written to ${pathForTelemetry(`${target}.rr-backup`, cwd)}`);
|
|
332
|
+
info('Now your agent loads the router on every startup — no env vars, no terminal restart.');
|
|
333
|
+
await emitNodeEntryPatched({
|
|
334
|
+
...baseTelemetry,
|
|
335
|
+
outcome: viaPrompt ? 'patched_via_prompt' : 'patched',
|
|
336
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
337
|
+
import_syntax: result.syntax,
|
|
338
|
+
backup_written: !!result.backupWritten,
|
|
339
|
+
});
|
|
340
|
+
} else {
|
|
341
|
+
warn(`Could not patch ${pathForTelemetry(target, cwd)}: ${result.error}`);
|
|
342
|
+
info('You can add the line yourself — see the snippet above.');
|
|
343
|
+
await emitNodeEntryPatched({
|
|
344
|
+
...baseTelemetry,
|
|
345
|
+
outcome: 'failed',
|
|
346
|
+
entry_path: pathForTelemetry(target, cwd),
|
|
347
|
+
error: result.error,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function showJsPath({ nonInteractive = false, autoAttachSource = false } = {}) {
|
|
93
353
|
blank();
|
|
94
354
|
success('JS/TS integration');
|
|
95
355
|
blank();
|
|
@@ -105,14 +365,10 @@ async function showJsPath() {
|
|
|
105
365
|
}
|
|
106
366
|
}
|
|
107
367
|
blank();
|
|
108
|
-
info('
|
|
109
|
-
info('
|
|
110
|
-
info('
|
|
111
|
-
|
|
112
|
-
info('Open a new cmd / PowerShell window — current terminals will not see the change.');
|
|
113
|
-
} else {
|
|
114
|
-
info('Open a new terminal — or run: source ~/.zshrc (or your shell rc)');
|
|
115
|
-
}
|
|
368
|
+
info('Phase 3+ NODE_OPTIONS shim installed. Works for desktop dev sessions');
|
|
369
|
+
info('after a terminal restart, but does NOT reach cron / Docker / systemd /');
|
|
370
|
+
info('Lambda. The next step adds a one-line require to your agent source so');
|
|
371
|
+
info('routing works regardless of how the process is launched.');
|
|
116
372
|
} else {
|
|
117
373
|
warn(result.message);
|
|
118
374
|
blank();
|
|
@@ -124,6 +380,11 @@ async function showJsPath() {
|
|
|
124
380
|
info(' export NODE_OPTIONS="${NODE_OPTIONS:-} --require ~/.robot-resources/router/auto.cjs"');
|
|
125
381
|
}
|
|
126
382
|
}
|
|
383
|
+
|
|
384
|
+
// Phase 11 — source-edit injection. Reaches cron / Docker / systemd / Lambda
|
|
385
|
+
// since the line lives in the user's source, not in shell config.
|
|
386
|
+
await maybeInjectSourceEdit({ nonInteractive, autoAttachSource });
|
|
387
|
+
|
|
127
388
|
blank();
|
|
128
389
|
info('Docs: https://robotresources.ai/docs/langchain');
|
|
129
390
|
blank();
|
|
@@ -204,9 +465,9 @@ function showInstallOcPath() {
|
|
|
204
465
|
blank();
|
|
205
466
|
}
|
|
206
467
|
|
|
207
|
-
async function runPath(path) {
|
|
468
|
+
async function runPath(path, opts = {}) {
|
|
208
469
|
switch (path) {
|
|
209
|
-
case 'js': await showJsPath(); break;
|
|
470
|
+
case 'js': await showJsPath(opts); break;
|
|
210
471
|
case 'python': await showPythonPath(); break;
|
|
211
472
|
case 'mcp': showMcpPath(); break;
|
|
212
473
|
case 'docs': showDocsPath(); break;
|
|
@@ -221,11 +482,11 @@ async function runPath(path) {
|
|
|
221
482
|
* - non-interactive AND no target: print hint with --for= options and exit
|
|
222
483
|
* - interactive: 5-option menu via @inquirer/prompts.select
|
|
223
484
|
*/
|
|
224
|
-
export async function runNonOcWizard({ nonInteractive = false, target = null } = {}) {
|
|
485
|
+
export async function runNonOcWizard({ nonInteractive = false, target = null, autoAttachSource = false } = {}) {
|
|
225
486
|
const normalized = normalizeTarget(target);
|
|
226
487
|
|
|
227
488
|
if (normalized) {
|
|
228
|
-
await runPath(normalized);
|
|
489
|
+
await runPath(normalized, { nonInteractive, autoAttachSource });
|
|
229
490
|
await emitPathChosen(normalized);
|
|
230
491
|
return;
|
|
231
492
|
}
|
|
@@ -249,7 +510,7 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
249
510
|
info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
|
|
250
511
|
info(' Pass --for=<other> to override, or --uninstall to remove later.');
|
|
251
512
|
blank();
|
|
252
|
-
await runPath(autoTarget);
|
|
513
|
+
await runPath(autoTarget, { nonInteractive, autoAttachSource });
|
|
253
514
|
await emitPathChosen(autoTarget);
|
|
254
515
|
return;
|
|
255
516
|
}
|
|
@@ -334,7 +595,9 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
334
595
|
if (autoTarget) {
|
|
335
596
|
info(`Detected a ${autoTarget === 'js' ? 'Node' : 'Python'} project — installing the matching shim automatically.`);
|
|
336
597
|
blank();
|
|
337
|
-
|
|
598
|
+
// Treat the timed-out interactive session as non-interactive for the
|
|
599
|
+
// source-edit step too — we don't have a TTY to ask the Y/N on.
|
|
600
|
+
await runPath(autoTarget, { nonInteractive: true, autoAttachSource });
|
|
338
601
|
await emitPathChosen(autoTarget);
|
|
339
602
|
return;
|
|
340
603
|
}
|
|
@@ -347,6 +610,6 @@ export async function runNonOcWizard({ nonInteractive = false, target = null } =
|
|
|
347
610
|
return;
|
|
348
611
|
}
|
|
349
612
|
|
|
350
|
-
await runPath(chosen);
|
|
613
|
+
await runPath(chosen, { nonInteractive, autoAttachSource });
|
|
351
614
|
await emitPathChosen(chosen);
|
|
352
615
|
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync, openSync, readSync, closeSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { NODE_AGENT_DEPS } from './detect.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Source-edit injection for non-OC Node agents (Phase 11).
|
|
7
|
+
*
|
|
8
|
+
* The Phase-3 NODE_OPTIONS approach only reaches processes that inherit
|
|
9
|
+
* from a shell that loaded ~/.bashrc or ~/.zshrc. Cron / Docker / systemd /
|
|
10
|
+
* Lambda / Cloud Run / serverless functions do NOT inherit shell rc files,
|
|
11
|
+
* so the shim never loads in the agents that matter most. Funnel data
|
|
12
|
+
* showed a 45-point cliff: 53% reach `node_shim_installed`, only 8.6% ever
|
|
13
|
+
* emit `adapter_attached`.
|
|
14
|
+
*
|
|
15
|
+
* This module injects ONE LINE at the top of the user's entry source file:
|
|
16
|
+
*
|
|
17
|
+
* // >>> robot-resources: auto-attach >>>
|
|
18
|
+
* require('@robot-resources/router/auto');
|
|
19
|
+
* // <<< robot-resources <<<
|
|
20
|
+
*
|
|
21
|
+
* (or `import '@robot-resources/router/auto';` for ESM/TS files.)
|
|
22
|
+
*
|
|
23
|
+
* The line runs whenever the agent process starts, regardless of how it
|
|
24
|
+
* was launched. Cron, Docker, systemd, Lambda — all work the same.
|
|
25
|
+
*
|
|
26
|
+
* Marker-block convention mirrors shell-config.js exactly so an `--uninstall`
|
|
27
|
+
* remove pass is text-based and never destructive on partial state.
|
|
28
|
+
*
|
|
29
|
+
* Backup: a `.rr-backup` is written once on first inject (never overwritten
|
|
30
|
+
* on re-inject) so `--uninstall --purge` can restore the original source
|
|
31
|
+
* even if the marker block was tampered with.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const MARK_BEGIN = '// >>> robot-resources: auto-attach >>>';
|
|
35
|
+
export const MARK_END = '// <<< robot-resources <<<';
|
|
36
|
+
|
|
37
|
+
const REQUIRE_LINE = "require('@robot-resources/router/auto');";
|
|
38
|
+
const IMPORT_LINE = "import '@robot-resources/router/auto';";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide which import syntax to inject for a given source file.
|
|
42
|
+
* Implements Node's resolution rules verbatim:
|
|
43
|
+
* .cjs → cjs
|
|
44
|
+
* .mjs → esm
|
|
45
|
+
* .ts / .tsx → emit `import` (works under both ts-node CJS and tsx/ESM)
|
|
46
|
+
* .js / .jsx → walk up to nearest package.json, check "type" field.
|
|
47
|
+
* "module" → esm; anything else (or absent) → cjs.
|
|
48
|
+
*/
|
|
49
|
+
export function detectImportSyntax(entryPath) {
|
|
50
|
+
const ext = extname(entryPath).toLowerCase();
|
|
51
|
+
if (ext === '.cjs') return 'cjs';
|
|
52
|
+
if (ext === '.mjs') return 'esm';
|
|
53
|
+
if (ext === '.ts' || ext === '.tsx') return 'esm';
|
|
54
|
+
|
|
55
|
+
// Walk up from the file's directory until we hit a package.json or
|
|
56
|
+
// exhaust the path. This is what Node itself does for `.js` files.
|
|
57
|
+
let dir = dirname(resolve(entryPath));
|
|
58
|
+
while (true) {
|
|
59
|
+
const pkgPath = join(dir, 'package.json');
|
|
60
|
+
if (existsSync(pkgPath)) {
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
63
|
+
return pkg.type === 'module' ? 'esm' : 'cjs';
|
|
64
|
+
} catch {
|
|
65
|
+
return 'cjs';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(dir);
|
|
69
|
+
if (parent === dir) return 'cjs';
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Phase 11.1 — directories pruned during the recursive walk. Skipping these
|
|
75
|
+
// is the difference between a 50ms walk and a 30s one. Hardcoded — chasing
|
|
76
|
+
// `.gitignore` would add a transitive dep risk for marginal coverage gain
|
|
77
|
+
// (real false-positive rate from these dirs is already ~0).
|
|
78
|
+
const SKIP_DIRS = new Set([
|
|
79
|
+
'node_modules', 'dist', 'build', '.next', 'out', 'coverage',
|
|
80
|
+
'.git', 'venv', '.venv', '__pycache__', '.svelte-kit', 'target',
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
// Walker bounds. 4KB read because imports are always hoisted to the top of
|
|
84
|
+
// the file — anything below isn't an import, and reading more is waste on
|
|
85
|
+
// network filesystems / WSL. Depth 2 catches `src/agent.ts`,
|
|
86
|
+
// `apps/api/src/index.ts`. File cap 500 stops huge monorepos from blowing
|
|
87
|
+
// the wizard's budget.
|
|
88
|
+
const WALK_DEPTH_MAX = 2;
|
|
89
|
+
const WALK_FILE_CAP = 500;
|
|
90
|
+
const READ_BYTES = 4096;
|
|
91
|
+
|
|
92
|
+
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']);
|
|
93
|
+
|
|
94
|
+
// Deliberately omits `index` — too generic to reliably signal "this is the
|
|
95
|
+
// agent." A CLI tool's `cli.js` (matched via package.json `bin`) will lose
|
|
96
|
+
// to a generic `index.js` if `index` is in this set, even though `bin` is
|
|
97
|
+
// what the user actually runs. Names here are strong agent signals on
|
|
98
|
+
// their own; `index` requires a complementary signal (`main`/`bin` match)
|
|
99
|
+
// to be picked up.
|
|
100
|
+
const ENTRY_NAME_PATTERN = /^(agent|bot|app|main|server|worker|handler)\./i;
|
|
101
|
+
const TEST_FILENAME_PATTERN = /\.(test|spec)\./i;
|
|
102
|
+
const TEST_PATH_FRAGMENT = /[\\/](?:test|tests|__tests__|spec|examples|scripts)[\\/]/i;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Locate the user's agent source file under cwd by scanning for AI-SDK
|
|
106
|
+
* imports. Returns { winner, candidates, scanned, walked, ambiguous }.
|
|
107
|
+
*
|
|
108
|
+
* `winner` — absolute path of the highest-scoring file, or null if no file
|
|
109
|
+
* in cwd imports any SDK from `NODE_AGENT_DEPS`.
|
|
110
|
+
* `candidates` — every file that scored > 0, sorted by score descending.
|
|
111
|
+
* Each entry: { path, score, syntax, reasons: string[] }.
|
|
112
|
+
* `scanned` — count of files that imported any SDK (subset of `walked`).
|
|
113
|
+
* `walked` — total source files inspected (capped at WALK_FILE_CAP).
|
|
114
|
+
* `ambiguous` — true when winner's lead over runner-up is too narrow to
|
|
115
|
+
* silently pick (`winner.score < runner_up.score + 2 &&
|
|
116
|
+
* winner.score < runner_up.score * 1.5`). The wizard surfaces
|
|
117
|
+
* a chooser only when this is true.
|
|
118
|
+
*
|
|
119
|
+
* Why this replaces the package.json-gated detector: production data after
|
|
120
|
+
* the Phase 11 release showed 5 of 5 fresh real users (JP/US/RU) failing
|
|
121
|
+
* with `outcome: 'no_entry_detected'` because they ran the wizard from
|
|
122
|
+
* a server's home dir, with no `package.json` at cwd. Real Node agents
|
|
123
|
+
* commonly look like a single `agent.js` next to a `node_modules/` —
|
|
124
|
+
* `package.json` is a project-shape signal, not an agent signal. The
|
|
125
|
+
* actual ground truth for "this is an agent" is "this file imports an
|
|
126
|
+
* AI SDK." `package.json` here downgrades to a *ranking hint*, used to
|
|
127
|
+
* boost candidates whose path matches `bin` or `main`, but never to
|
|
128
|
+
* gate detection.
|
|
129
|
+
*/
|
|
130
|
+
export function findAgentSourceFile(cwd = process.cwd()) {
|
|
131
|
+
let walked = 0;
|
|
132
|
+
const sdkFiles = [];
|
|
133
|
+
|
|
134
|
+
// Recursive walker with hard caps. Each recursion increments depth;
|
|
135
|
+
// depth 0 is cwd itself, depth 1 is direct children, etc.
|
|
136
|
+
const walk = (dir, depth) => {
|
|
137
|
+
if (walked >= WALK_FILE_CAP) return;
|
|
138
|
+
if (depth > WALK_DEPTH_MAX) return;
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
142
|
+
} catch {
|
|
143
|
+
// Permission or read error — skip this directory silently.
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (walked >= WALK_FILE_CAP) return;
|
|
148
|
+
if (entry.isDirectory()) {
|
|
149
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
150
|
+
walk(join(dir, entry.name), depth + 1);
|
|
151
|
+
} else if (entry.isFile()) {
|
|
152
|
+
const ext = extname(entry.name).toLowerCase();
|
|
153
|
+
if (!SCAN_EXTS.has(ext)) continue;
|
|
154
|
+
walked++;
|
|
155
|
+
const abs = join(dir, entry.name);
|
|
156
|
+
const head = readHead(abs);
|
|
157
|
+
if (!head) continue;
|
|
158
|
+
const sdks = sdksFoundIn(head);
|
|
159
|
+
if (sdks.size > 0) {
|
|
160
|
+
sdkFiles.push({ path: abs, head, sdks, depth });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
walk(cwd, 0);
|
|
167
|
+
|
|
168
|
+
// Score each candidate. The scoring system — finalised after a design
|
|
169
|
+
// review — is:
|
|
170
|
+
// +5 imports any NODE_AGENT_DEPS SDK (boolean per file; multiple
|
|
171
|
+
// imports from the same file don't pile points, so a wrapper file
|
|
172
|
+
// with 6 re-exports doesn't outrank an actual agent file)
|
|
173
|
+
// +1 also has a constructor call (`new Anthropic(...)`, etc.)
|
|
174
|
+
// +4 filename matches /^(agent|bot|index|app|main|server|worker|handler)\./
|
|
175
|
+
// +1 file lives at cwd root (depth 0)
|
|
176
|
+
// -3 file is under /test/, /tests/, /__tests__/, /spec/, /examples/, /scripts/
|
|
177
|
+
// -3 filename matches *.test.* or *.spec.*
|
|
178
|
+
// +5 file matches package.json `bin` (when present — `bin` strictly dominates `main`)
|
|
179
|
+
// +3 file matches package.json `main` (when present and ≠ `bin`)
|
|
180
|
+
const pkg = readPkg(cwd);
|
|
181
|
+
const binPath = pkg ? resolveBinPath(pkg, cwd) : null;
|
|
182
|
+
const mainPath = pkg ? resolveMainPath(pkg, cwd) : null;
|
|
183
|
+
|
|
184
|
+
const candidates = sdkFiles.map(({ path, head, sdks, depth }) => {
|
|
185
|
+
const reasons = [];
|
|
186
|
+
let score = 0;
|
|
187
|
+
|
|
188
|
+
score += 5; reasons.push(`+5 imports SDK (${[...sdks].join(',')})`);
|
|
189
|
+
|
|
190
|
+
if (hasConstructor(head)) { score += 1; reasons.push('+1 constructor call'); }
|
|
191
|
+
|
|
192
|
+
if (ENTRY_NAME_PATTERN.test(basename(path))) { score += 4; reasons.push('+4 entry-name match'); }
|
|
193
|
+
|
|
194
|
+
if (depth === 0) { score += 1; reasons.push('+1 cwd root'); }
|
|
195
|
+
|
|
196
|
+
if (TEST_PATH_FRAGMENT.test(path)) { score -= 3; reasons.push('-3 test/spec/examples path'); }
|
|
197
|
+
if (TEST_FILENAME_PATTERN.test(basename(path))) { score -= 3; reasons.push('-3 test/spec filename'); }
|
|
198
|
+
|
|
199
|
+
if (binPath && path === binPath) { score += 5; reasons.push('+5 package.json bin'); }
|
|
200
|
+
else if (mainPath && path === mainPath) { score += 3; reasons.push('+3 package.json main'); }
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
path,
|
|
204
|
+
score,
|
|
205
|
+
syntax: detectImportSyntax(path),
|
|
206
|
+
reasons,
|
|
207
|
+
};
|
|
208
|
+
}).sort((a, b) => b.score - a.score);
|
|
209
|
+
|
|
210
|
+
const winner = candidates[0] ?? null;
|
|
211
|
+
const runnerUp = candidates[1] ?? null;
|
|
212
|
+
|
|
213
|
+
// "Clear winner" threshold: lead ≥ 2 absolute OR ≥ 1.5x runner-up.
|
|
214
|
+
// Either condition means we silently pick `winner`. Both close → ambiguous.
|
|
215
|
+
let ambiguous = false;
|
|
216
|
+
if (runnerUp) {
|
|
217
|
+
const leadOk = winner.score >= runnerUp.score + 2;
|
|
218
|
+
const ratioOk = winner.score >= runnerUp.score * 1.5;
|
|
219
|
+
ambiguous = !(leadOk || ratioOk);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
winner: winner?.path ?? null,
|
|
224
|
+
candidates,
|
|
225
|
+
scanned: sdkFiles.length,
|
|
226
|
+
walked,
|
|
227
|
+
ambiguous,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read the head of a file (first READ_BYTES bytes). Imports are always
|
|
233
|
+
* hoisted to the top of a Node module — TypeScript and ESM both enforce
|
|
234
|
+
* import hoisting, and CJS users put requires at the top by convention.
|
|
235
|
+
* Reading more is waste on slow filesystems.
|
|
236
|
+
*/
|
|
237
|
+
function readHead(filePath) {
|
|
238
|
+
let fd;
|
|
239
|
+
try {
|
|
240
|
+
fd = openSync(filePath, 'r');
|
|
241
|
+
const buf = Buffer.alloc(READ_BYTES);
|
|
242
|
+
const bytes = readSync(fd, buf, 0, READ_BYTES, 0);
|
|
243
|
+
return buf.slice(0, bytes).toString('utf-8');
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
} finally {
|
|
247
|
+
if (fd != null) { try { closeSync(fd); } catch { /* ignore */ } }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Return the set of SDK package names found in `text`. Boolean per SDK —
|
|
253
|
+
* multiple `require('@anthropic-ai/sdk')` lines in the same file count
|
|
254
|
+
* once. Both CJS (`require('foo')`) and ESM (`from 'foo'`) shapes are
|
|
255
|
+
* checked; we don't care about quote style.
|
|
256
|
+
*/
|
|
257
|
+
function sdksFoundIn(text) {
|
|
258
|
+
const found = new Set();
|
|
259
|
+
for (const sdk of NODE_AGENT_DEPS) {
|
|
260
|
+
const escaped = sdk.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
261
|
+
const requireRe = new RegExp(`require\\(\\s*['"]${escaped}['"]\\s*\\)`);
|
|
262
|
+
const importRe = new RegExp(`from\\s+['"]${escaped}['"]`);
|
|
263
|
+
if (requireRe.test(text) || importRe.test(text)) found.add(sdk);
|
|
264
|
+
}
|
|
265
|
+
return found;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function hasConstructor(text) {
|
|
269
|
+
// Cheap regex; over-matches `if (new Anthropic())` etc. — that's fine,
|
|
270
|
+
// they still count as evidence that this file talks to a model.
|
|
271
|
+
return /new\s+(Anthropic|OpenAI|GoogleGenerativeAI)\s*\(/.test(text);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readPkg(cwd) {
|
|
275
|
+
const p = join(cwd, 'package.json');
|
|
276
|
+
if (!existsSync(p)) return null;
|
|
277
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function resolveBinPath(pkg, cwd) {
|
|
281
|
+
let raw = null;
|
|
282
|
+
if (typeof pkg.bin === 'string') raw = pkg.bin;
|
|
283
|
+
else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
284
|
+
const vals = Object.values(pkg.bin).filter((v) => typeof v === 'string');
|
|
285
|
+
if (vals.length === 1) raw = vals[0];
|
|
286
|
+
}
|
|
287
|
+
if (!raw) return null;
|
|
288
|
+
const abs = join(cwd, raw.replace(/^\.\//, ''));
|
|
289
|
+
return existsSync(abs) ? abs : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function resolveMainPath(pkg, cwd) {
|
|
293
|
+
if (typeof pkg.main !== 'string') return null;
|
|
294
|
+
const abs = join(cwd, pkg.main.replace(/^\.\//, ''));
|
|
295
|
+
return existsSync(abs) ? abs : null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Returns true if the given source file already contains our marker.
|
|
300
|
+
* Used to skip-if-already-installed and to gate uninstall.
|
|
301
|
+
*/
|
|
302
|
+
export function hasSourceMarker(filePath) {
|
|
303
|
+
try {
|
|
304
|
+
return readFileSync(filePath, 'utf-8').includes(MARK_BEGIN);
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Idempotently inject the auto-attach marker block at the top of the file
|
|
312
|
+
* (after shebang, if present). Writes a one-time backup at `${filePath}.rr-backup`.
|
|
313
|
+
*
|
|
314
|
+
* Returns { ok, alreadyInstalled, path, syntax, backupPath, error }.
|
|
315
|
+
*/
|
|
316
|
+
export function writeSourceMarker(filePath, opts = {}) {
|
|
317
|
+
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
318
|
+
|
|
319
|
+
let original;
|
|
320
|
+
try { original = readFileSync(filePath, 'utf-8'); }
|
|
321
|
+
catch (err) {
|
|
322
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `read_failed: ${err.message}` };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (original.includes(MARK_BEGIN)) {
|
|
326
|
+
return { ok: true, alreadyInstalled: true, path: filePath, syntax, backupPath: backupPathFor(filePath) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Backup once. Never overwrite — preserves the user's pristine original
|
|
330
|
+
// even if they manually edit before our second pass.
|
|
331
|
+
const backupPath = backupPathFor(filePath);
|
|
332
|
+
let backupWritten = false;
|
|
333
|
+
if (!existsSync(backupPath)) {
|
|
334
|
+
try {
|
|
335
|
+
writeFileSync(backupPath, original, { mode: getMode(filePath) });
|
|
336
|
+
backupWritten = true;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `backup_failed: ${err.message}` };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Insert position: after shebang line if present, otherwise at top.
|
|
343
|
+
// We do NOT try to skip past `"use strict";` — modern Node treats it as
|
|
344
|
+
// a normal directive; placing our require/import before it is harmless.
|
|
345
|
+
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
346
|
+
const block = `${MARK_BEGIN}\n${line}\n${MARK_END}\n`;
|
|
347
|
+
|
|
348
|
+
let next;
|
|
349
|
+
if (original.startsWith('#!')) {
|
|
350
|
+
const nl = original.indexOf('\n');
|
|
351
|
+
if (nl === -1) {
|
|
352
|
+
// single-line file, only a shebang — append our block on a new line
|
|
353
|
+
next = original + '\n' + block;
|
|
354
|
+
} else {
|
|
355
|
+
next = original.slice(0, nl + 1) + block + original.slice(nl + 1);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
next = block + original;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
return { ok: false, alreadyInstalled: false, path: filePath, syntax, error: `write_failed: ${err.message}`, backupPath, backupWritten };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { ok: true, alreadyInstalled: false, path: filePath, syntax, backupPath, backupWritten };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Idempotently remove the marker block from the source file. Mirror of
|
|
372
|
+
* writeSourceMarker. Returns { ok, removed, restored, path, error }.
|
|
373
|
+
*
|
|
374
|
+
* If `restoreFromBackup` is true and a `.rr-backup` exists, restores from
|
|
375
|
+
* the backup instead of splicing. Used for `--purge`. The backup is
|
|
376
|
+
* deleted after a successful restore.
|
|
377
|
+
*/
|
|
378
|
+
export function removeSourceMarker(filePath, { restoreFromBackup = false } = {}) {
|
|
379
|
+
let original;
|
|
380
|
+
try { original = readFileSync(filePath, 'utf-8'); }
|
|
381
|
+
catch (err) {
|
|
382
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `read_failed: ${err.message}` };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (restoreFromBackup) {
|
|
386
|
+
const backupPath = backupPathFor(filePath);
|
|
387
|
+
if (existsSync(backupPath)) {
|
|
388
|
+
try {
|
|
389
|
+
const pristine = readFileSync(backupPath, 'utf-8');
|
|
390
|
+
writeFileSync(filePath, pristine, { mode: getMode(filePath) });
|
|
391
|
+
unlinkSync(backupPath);
|
|
392
|
+
return { ok: true, removed: true, restored: true, path: filePath };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `restore_failed: ${err.message}` };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// No backup → fall through to marker splice.
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const startIdx = original.indexOf(MARK_BEGIN);
|
|
401
|
+
if (startIdx === -1) {
|
|
402
|
+
return { ok: true, removed: false, restored: false, path: filePath };
|
|
403
|
+
}
|
|
404
|
+
const endIdx = original.indexOf(MARK_END, startIdx);
|
|
405
|
+
if (endIdx === -1) {
|
|
406
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: 'marker_end_missing' };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Splice from MARK_BEGIN through end of MARK_END line + the trailing
|
|
410
|
+
// newline our writer added. Walk backward over leading newlines so
|
|
411
|
+
// repeated install/uninstall cycles don't accumulate blanks.
|
|
412
|
+
const afterEnd = original.indexOf('\n', endIdx);
|
|
413
|
+
const sliceEnd = afterEnd === -1 ? original.length : afterEnd + 1;
|
|
414
|
+
|
|
415
|
+
let sliceStart = startIdx;
|
|
416
|
+
while (sliceStart > 0 && original[sliceStart - 1] === '\n') sliceStart--;
|
|
417
|
+
|
|
418
|
+
const next = original.slice(0, sliceStart) +
|
|
419
|
+
(sliceStart > 0 ? '\n' : '') +
|
|
420
|
+
original.slice(sliceEnd);
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
writeFileSync(filePath, next, { mode: getMode(filePath) });
|
|
424
|
+
} catch (err) {
|
|
425
|
+
return { ok: false, removed: false, restored: false, path: filePath, error: `write_failed: ${err.message}` };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { ok: true, removed: true, restored: false, path: filePath };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Build the proposed diff a wizard can show before asking Y/N. Returns a
|
|
433
|
+
* UI-friendly string with the marker block + a few lines of context.
|
|
434
|
+
*/
|
|
435
|
+
export function previewInjection(filePath, opts = {}) {
|
|
436
|
+
const syntax = opts.syntax ?? detectImportSyntax(filePath);
|
|
437
|
+
const line = syntax === 'esm' ? IMPORT_LINE : REQUIRE_LINE;
|
|
438
|
+
let original = '';
|
|
439
|
+
try { original = readFileSync(filePath, 'utf-8'); } catch { /* ignore */ }
|
|
440
|
+
const firstLines = original.split('\n').slice(0, 3).join('\n');
|
|
441
|
+
return [
|
|
442
|
+
`+ ${MARK_BEGIN}`,
|
|
443
|
+
`+ ${line}`,
|
|
444
|
+
`+ ${MARK_END}`,
|
|
445
|
+
firstLines.split('\n').map((l) => ` ${l}`).join('\n'),
|
|
446
|
+
].join('\n');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function backupPathFor(filePath) {
|
|
450
|
+
return `${filePath}.rr-backup`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function getMode(filePath) {
|
|
454
|
+
try { return statSync(filePath).mode & 0o777; } catch { return 0o644; }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Best-effort cwd-relative path for telemetry, falling back to basename
|
|
459
|
+
* if the file is outside cwd. Used to avoid leaking absolute paths (which
|
|
460
|
+
* can contain usernames) into Supabase.
|
|
461
|
+
*/
|
|
462
|
+
export function pathForTelemetry(filePath, cwd = process.cwd()) {
|
|
463
|
+
const rel = relative(cwd, filePath);
|
|
464
|
+
if (rel.startsWith('..') || rel.includes(sep + '..' + sep)) {
|
|
465
|
+
// Outside cwd — basename only.
|
|
466
|
+
return filePath.split(sep).pop();
|
|
467
|
+
}
|
|
468
|
+
return rel;
|
|
469
|
+
}
|
package/lib/uninstall.js
CHANGED
|
@@ -6,6 +6,7 @@ import { removeShellLine } from './shell-config.js';
|
|
|
6
6
|
import { detectVenv } from './venv-detect.js';
|
|
7
7
|
import { spawnSync } from 'node:child_process';
|
|
8
8
|
import { removePersistedNodeOptions } from './windows-env.js';
|
|
9
|
+
import { detectEntryFile, hasSourceMarker, removeSourceMarker } from './source-edit-attach.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Single source of truth for `npx robot-resources --uninstall`.
|
|
@@ -115,6 +116,36 @@ export function runUninstall({ purge = false } = {}) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// 3a. Phase 11 — source-edit auto-attach line. If the wizard injected
|
|
120
|
+
// `require('@robot-resources/router/auto')` at the top of the user's
|
|
121
|
+
// entry file, peel the marker block out (or restore from .rr-backup
|
|
122
|
+
// when --purge). Idempotent: no-op when no entry detected or marker
|
|
123
|
+
// not present. Runs against cwd, so users who --uninstall from a
|
|
124
|
+
// different repo won't accidentally wipe the wrong file.
|
|
125
|
+
try {
|
|
126
|
+
const detection = detectEntryFile();
|
|
127
|
+
if (detection.path && hasSourceMarker(detection.path)) {
|
|
128
|
+
const r = removeSourceMarker(detection.path, { restoreFromBackup: !!purge });
|
|
129
|
+
if (r.ok && (r.removed || r.restored)) {
|
|
130
|
+
components_removed.push(r.restored ? 'node_entry_restored_from_backup' : 'node_entry_marker_removed');
|
|
131
|
+
} else if (!r.ok) {
|
|
132
|
+
errors.push({ component: 'node_entry_source_edit', message: r.error || 'unknown' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// --purge also wipes any leftover .rr-backup (covers the case where the
|
|
136
|
+
// user manually deleted the marker but kept the backup file around).
|
|
137
|
+
if (purge && detection.path && existsSync(`${detection.path}.rr-backup`)) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(`${detection.path}.rr-backup`, { force: true });
|
|
140
|
+
components_removed.push('node_entry_backup_purged');
|
|
141
|
+
} catch (err) {
|
|
142
|
+
errors.push({ component: 'node_entry_backup_purged', message: err.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch (err) {
|
|
146
|
+
errors.push({ component: 'node_entry_source_edit', message: err.message });
|
|
147
|
+
}
|
|
148
|
+
|
|
118
149
|
// 3b. Copied router dir at ~/.robot-resources/router/ (Phase 8). The shell
|
|
119
150
|
// line points at this absolute path — once the line is gone, the
|
|
120
151
|
// copied files are dead weight. Remove them.
|
package/lib/wizard.js
CHANGED
|
@@ -26,6 +26,43 @@ const CLI_VERSION = (() => {
|
|
|
26
26
|
}
|
|
27
27
|
})();
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Fire `install_complete` once after the wizard finishes — for BOTH OC
|
|
31
|
+
* and non-OC paths. Phase 11 fix: previously this only fired on the OC
|
|
32
|
+
* path because the non-OC branch returned early. The 7-day funnel showed
|
|
33
|
+
* 0/58 non-OC users hitting this event for a week. Now both paths fire
|
|
34
|
+
* with a `path: 'oc' | 'non-oc'` discriminator so funnel queries can
|
|
35
|
+
* segment without a second event type.
|
|
36
|
+
*
|
|
37
|
+
* Best-effort with one retry. Total budget: ~20s. Telemetry never blocks
|
|
38
|
+
* the wizard exit beyond that.
|
|
39
|
+
*/
|
|
40
|
+
async function emitInstallComplete({ apiKey, payload }) {
|
|
41
|
+
if (!apiKey) return;
|
|
42
|
+
const platformUrl = process.env.RR_PLATFORM_URL || 'https://api.robotresources.ai';
|
|
43
|
+
const body = JSON.stringify({
|
|
44
|
+
product: 'cli',
|
|
45
|
+
event_type: 'install_complete',
|
|
46
|
+
payload,
|
|
47
|
+
});
|
|
48
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${platformUrl}/v1/telemetry`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body,
|
|
57
|
+
signal: AbortSignal.timeout(10_000),
|
|
58
|
+
});
|
|
59
|
+
if (res.ok) break;
|
|
60
|
+
} catch {
|
|
61
|
+
// Try again on next iteration.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* Main setup wizard. In Option 4 (post-PR-2.5) the wizard does NOT install
|
|
31
68
|
* a Python daemon, register a system service, or run a localhost health
|
|
@@ -43,7 +80,7 @@ const CLI_VERSION = (() => {
|
|
|
43
80
|
*
|
|
44
81
|
* No Python, no venv, no systemd, no port probe.
|
|
45
82
|
*/
|
|
46
|
-
export async function runWizard({ nonInteractive = false, target = null, scope = 'full' } = {}) {
|
|
83
|
+
export async function runWizard({ nonInteractive = false, target = null, scope = 'full', autoAttachSource = false } = {}) {
|
|
47
84
|
header();
|
|
48
85
|
|
|
49
86
|
// Detect OC once up front. Used both to branch into the non-OC wizard and
|
|
@@ -165,7 +202,31 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
|
|
|
165
202
|
// MCP config / docs / install-OC). The non-OC wizard's wizard_path_chosen
|
|
166
203
|
// telemetry now fires too, since Step 0 above provisioned an api_key.
|
|
167
204
|
if (!openclawDetected) {
|
|
168
|
-
await runNonOcWizard({ nonInteractive, target });
|
|
205
|
+
await runNonOcWizard({ nonInteractive, target, autoAttachSource });
|
|
206
|
+
|
|
207
|
+
// Phase 11: install_complete now fires for non-OC too. Closes the
|
|
208
|
+
// funnel signal that was 0/58 for a week.
|
|
209
|
+
if (results.auth) {
|
|
210
|
+
try {
|
|
211
|
+
const config = readConfig();
|
|
212
|
+
await emitInstallComplete({
|
|
213
|
+
apiKey: config.api_key,
|
|
214
|
+
payload: {
|
|
215
|
+
source: 'wizard',
|
|
216
|
+
path: 'non-oc',
|
|
217
|
+
cli_version: CLI_VERSION,
|
|
218
|
+
target: target ?? null,
|
|
219
|
+
scope,
|
|
220
|
+
auto_attach_source: !!autoAttachSource,
|
|
221
|
+
platform: process.platform,
|
|
222
|
+
os_release: osRelease(),
|
|
223
|
+
node_version: process.version,
|
|
224
|
+
install_duration_ms: Date.now() - wizardStartMs,
|
|
225
|
+
non_interactive: nonInteractive,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
} catch { /* non-fatal */ }
|
|
229
|
+
}
|
|
169
230
|
return;
|
|
170
231
|
}
|
|
171
232
|
|
|
@@ -250,54 +311,32 @@ export async function runWizard({ nonInteractive = false, target = null, scope =
|
|
|
250
311
|
|
|
251
312
|
// ── Install Complete Telemetry ───────────────────────────────────────────
|
|
252
313
|
//
|
|
253
|
-
// Fire once after
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// Retry once with longer timeout — Cloudflare analytics showed client-side
|
|
257
|
-
// aborts on the original 5s single-attempt, leaving stranded signups with
|
|
258
|
-
// no telemetry. Two 10s attempts catch the long tail. Still fire-and-forget.
|
|
314
|
+
// Fire once after the OC install path completes. Non-OC fires its own
|
|
315
|
+
// install_complete higher up (right after runNonOcWizard returns), so
|
|
316
|
+
// both paths now produce this funnel signal — see emitInstallComplete().
|
|
259
317
|
|
|
260
318
|
if (results.auth) {
|
|
261
319
|
try {
|
|
262
320
|
const config = readConfig();
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
event_type: 'install_complete',
|
|
280
|
-
payload: installPayload,
|
|
321
|
+
await emitInstallComplete({
|
|
322
|
+
apiKey: config.api_key,
|
|
323
|
+
payload: {
|
|
324
|
+
source: 'wizard',
|
|
325
|
+
path: 'oc',
|
|
326
|
+
cli_version: CLI_VERSION,
|
|
327
|
+
plugin_installed: results.pluginInstalled,
|
|
328
|
+
scraper: results.scraper || false,
|
|
329
|
+
platform: process.platform,
|
|
330
|
+
os_release: osRelease(),
|
|
331
|
+
node_version: process.version,
|
|
332
|
+
install_duration_ms: Date.now() - wizardStartMs,
|
|
333
|
+
openclaw_detected: results.openclawDetected,
|
|
334
|
+
openclaw_config_patched: results.openclawConfigPatched,
|
|
335
|
+
scraper_mcp_registered: results.scraperMcpRegistered,
|
|
336
|
+
},
|
|
281
337
|
});
|
|
282
|
-
|
|
283
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
284
|
-
try {
|
|
285
|
-
const res = await fetch(`${platformUrl}/v1/telemetry`, {
|
|
286
|
-
method: 'POST',
|
|
287
|
-
headers: {
|
|
288
|
-
'Authorization': `Bearer ${config.api_key}`,
|
|
289
|
-
'Content-Type': 'application/json',
|
|
290
|
-
},
|
|
291
|
-
body,
|
|
292
|
-
signal: AbortSignal.timeout(10_000),
|
|
293
|
-
});
|
|
294
|
-
if (res.ok) break;
|
|
295
|
-
} catch {
|
|
296
|
-
// Try again on next iteration; outer catch handles total failure
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
338
|
} catch {
|
|
300
|
-
// Non-fatal — install_complete is best-effort
|
|
339
|
+
// Non-fatal — install_complete is best-effort.
|
|
301
340
|
}
|
|
302
341
|
}
|
|
303
342
|
|