robot-resources 1.15.1 → 1.15.3

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.
@@ -1,183 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync, appendFileSync, statSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- /**
6
- * Idempotent writer for the NODE_OPTIONS auto-attach line in shell rc files.
7
- *
8
- * Phase 3 ships POSIX-only support: zsh, bash, fish. Windows ships a printed-
9
- * instructions fallback (Phase 6 problem). Every write is wrapped in a
10
- * marker block so `--uninstall` can find and remove cleanly without
11
- * regex-matching against the user's actual shell content:
12
- *
13
- * # >>> robot-resources: NODE_OPTIONS auto-attach >>>
14
- * export NODE_OPTIONS="${NODE_OPTIONS:-} --require /Users/x/.robot-resources/router/auto.cjs"
15
- * # <<< robot-resources <<<
16
- *
17
- * Phase 8 fix: NODE_OPTIONS now uses an ABSOLUTE PATH to the auto.cjs the
18
- * wizard copied to ~/.robot-resources/router/. The previous bare-module
19
- * form `--require @robot-resources/router/auto` only resolved when the user
20
- * was cd'd inside a project that had `@robot-resources/router` in its
21
- * node_modules — and broke EVERY Node command from any other cwd with
22
- * `Cannot find module`. Result: every wizard-success Node user pre-Phase-8
23
- * had a NODE_OPTIONS line that crashed `node`/`npm`/etc. Symptom in
24
- * Supabase: `node_shim_installed: 8` but `adapter_attached: 0`.
25
- *
26
- * Behavior decisions (preserved from Phase 3):
27
- * - If NODE_OPTIONS is already set with a different --require (rare; e.g.
28
- * dd-trace), append ours after theirs. Both load. The user keeps their
29
- * existing tooling. The shell expansion `${NODE_OPTIONS:-} ...` handles
30
- * this correctly — POSIX shells concat space-separated --require flags.
31
- * - Never clobber. If our marker block already exists, it's a no-op.
32
- * - Write to ALL detected rc files (e.g. user has both .zshrc + .bashrc),
33
- * so the user gets routing in whichever shell they actually open.
34
- */
35
-
36
- const MARK_BEGIN = '# >>> robot-resources: NODE_OPTIONS auto-attach >>>';
37
- const MARK_END = '# <<< robot-resources <<<';
38
-
39
- function buildPosixLine(autoPath) {
40
- return `export NODE_OPTIONS="\${NODE_OPTIONS:-} --require ${autoPath}"`;
41
- }
42
-
43
- function buildFishLine(autoPath) {
44
- // Fish has different syntax (no `export`, uses `set -x`).
45
- return `set -x NODE_OPTIONS "$NODE_OPTIONS --require ${autoPath}"`;
46
- }
47
-
48
- /**
49
- * Discover which rc files are present for this user. Returns a list of
50
- * absolute paths in priority order (zsh first, bash second, fish third).
51
- * The wizard writes to ALL of them — users frequently edit one shell's
52
- * rc and forget another, and we'd rather over-cover than under-cover.
53
- */
54
- export function listShellRcFiles(home = homedir()) {
55
- const candidates = [
56
- { kind: 'zsh', path: join(home, '.zshrc') },
57
- { kind: 'bash', path: join(home, '.bashrc') },
58
- { kind: 'bash', path: join(home, '.bash_profile') }, // macOS often uses this
59
- { kind: 'fish', path: join(home, '.config', 'fish', 'config.fish') },
60
- ];
61
- return candidates.filter((c) => existsSync(c.path));
62
- }
63
-
64
- /**
65
- * Returns true if at least one rc file already has our marker block.
66
- * Used by both the wizard (skip-if-already-installed) and uninstall
67
- * (gate the "remove" step).
68
- */
69
- export function hasShellLine(home = homedir()) {
70
- for (const { path } of listShellRcFiles(home)) {
71
- try {
72
- const text = readFileSync(path, 'utf-8');
73
- if (text.includes(MARK_BEGIN)) return true;
74
- } catch { /* unreadable rc, skip */ }
75
- }
76
- return false;
77
- }
78
-
79
- /**
80
- * Idempotently append the marker block to every detected rc file. Returns
81
- * a list of files actually modified (empty if everything already had it).
82
- *
83
- * Each rc file is treated independently: the writer never aborts the
84
- * others on one failure. Per-file errors are returned as warnings the
85
- * caller can surface.
86
- */
87
- export function writeShellLine({ autoPath, home = homedir() }) {
88
- if (!autoPath) {
89
- throw new Error('writeShellLine requires { autoPath } — absolute path to auto.cjs');
90
- }
91
-
92
- const rcs = listShellRcFiles(home);
93
- const written = [];
94
- const errors = [];
95
-
96
- if (rcs.length === 0) {
97
- // POSIX shells but no rc file yet — create ~/.zshrc on macOS (default
98
- // since 10.15), ~/.bashrc on Linux. Better than silently no-op'ing.
99
- const fallback = process.platform === 'darwin'
100
- ? { kind: 'zsh', path: join(home, '.zshrc') }
101
- : { kind: 'bash', path: join(home, '.bashrc') };
102
- rcs.push(fallback);
103
- }
104
-
105
- for (const rc of rcs) {
106
- try {
107
- let text = '';
108
- try { text = readFileSync(rc.path, 'utf-8'); } catch { /* file may not exist yet */ }
109
-
110
- if (text.includes(MARK_BEGIN)) {
111
- // Already installed. Skip silently.
112
- continue;
113
- }
114
-
115
- const line = rc.kind === 'fish' ? buildFishLine(autoPath) : buildPosixLine(autoPath);
116
- const block =
117
- (text && !text.endsWith('\n') ? '\n' : '') +
118
- '\n' + MARK_BEGIN + '\n' + line + '\n' + MARK_END + '\n';
119
-
120
- // Append, don't rewrite — preserves the user's content exactly.
121
- appendFileSync(rc.path, block, { mode: 0o644 });
122
- written.push(rc.path);
123
- } catch (err) {
124
- errors.push({ path: rc.path, message: err.message });
125
- }
126
- }
127
-
128
- return { written, errors };
129
- }
130
-
131
- /**
132
- * Idempotently REMOVE the marker block from every detected rc file.
133
- * Mirror of writeShellLine. Returns a list of files actually modified.
134
- *
135
- * Removal is text-based (find MARK_BEGIN, find MARK_END, splice). If the
136
- * block was tampered with — e.g. user manually deleted MARK_END — we leave
137
- * the file alone and surface a warning. Never destructive on partial state.
138
- */
139
- export function removeShellLine(home = homedir()) {
140
- const rcs = listShellRcFiles(home);
141
- const removed = [];
142
- const errors = [];
143
-
144
- for (const rc of rcs) {
145
- try {
146
- const text = readFileSync(rc.path, 'utf-8');
147
- const startIdx = text.indexOf(MARK_BEGIN);
148
- if (startIdx === -1) continue;
149
- const endIdx = text.indexOf(MARK_END, startIdx);
150
- if (endIdx === -1) {
151
- errors.push({ path: rc.path, message: 'marker_end_missing' });
152
- continue;
153
- }
154
-
155
- // Splice from MARK_BEGIN through end of MARK_END line + trailing newline.
156
- const afterEnd = text.indexOf('\n', endIdx);
157
- const sliceEnd = afterEnd === -1 ? text.length : afterEnd + 1;
158
-
159
- // Walk back over the leading newline our writer added so we don't
160
- // accumulate blank lines on repeated install/uninstall cycles.
161
- let sliceStart = startIdx;
162
- while (sliceStart > 0 && text[sliceStart - 1] === '\n') sliceStart--;
163
-
164
- const next = text.slice(0, sliceStart) +
165
- (sliceStart > 0 ? '\n' : '') +
166
- text.slice(sliceEnd);
167
-
168
- writeFileSync(rc.path, next, { mode: getMode(rc.path) });
169
- removed.push(rc.path);
170
- } catch (err) {
171
- errors.push({ path: rc.path, message: err.message });
172
- }
173
- }
174
-
175
- return { removed, errors };
176
- }
177
-
178
- function getMode(path) {
179
- try { return statSync(path).mode & 0o777; } catch { return 0o644; }
180
- }
181
-
182
- // Exported for tests + telemetry payloads.
183
- export { MARK_BEGIN, MARK_END, buildPosixLine, buildFishLine };
@@ -1,469 +0,0 @@
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
- }