@gurulu/cli 0.1.0 → 0.1.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.
Files changed (54) hide show
  1. package/package.json +7 -3
  2. package/scripts/.gitkeep +0 -0
  3. package/scripts/README-gurulu-agentic-install.md +114 -0
  4. package/scripts/README-gurulu-scan.md +98 -0
  5. package/scripts/audit-cli-scopes.mjs +204 -0
  6. package/scripts/backfill-tenant-id.mjs +172 -0
  7. package/scripts/backfill-tenant-links.ts +252 -0
  8. package/scripts/backup-clickhouse.sh +27 -0
  9. package/scripts/backup-postgres.sh +19 -0
  10. package/scripts/bootstrap-runtime-schema.mjs +105 -0
  11. package/scripts/bootstrap-stripe.mjs +158 -0
  12. package/scripts/gurulu-agentic-install.lib.cjs +734 -0
  13. package/scripts/gurulu-agentic-install.mjs +343 -0
  14. package/scripts/gurulu-scan.lib.cjs +989 -0
  15. package/scripts/gurulu-scan.mjs +91 -0
  16. package/scripts/gurulu-verify-install.lib.cjs +334 -0
  17. package/scripts/gurulu-verify-install.mjs +59 -0
  18. package/scripts/init-ssl.sh +26 -0
  19. package/scripts/migrate-flow-graph-enums.sh +86 -0
  20. package/scripts/monitor-disk.sh +24 -0
  21. package/scripts/patches/astro.patch.cjs +73 -0
  22. package/scripts/patches/auto-instrument/ast-helper.cjs +332 -0
  23. package/scripts/patches/auto-instrument/astro.cjs +267 -0
  24. package/scripts/patches/auto-instrument/express.cjs +368 -0
  25. package/scripts/patches/auto-instrument/fastify.cjs +258 -0
  26. package/scripts/patches/auto-instrument/index.cjs +78 -0
  27. package/scripts/patches/auto-instrument/nestjs.cjs +282 -0
  28. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +318 -0
  29. package/scripts/patches/auto-instrument/nextjs-pages.cjs +348 -0
  30. package/scripts/patches/auto-instrument/remix.cjs +164 -0
  31. package/scripts/patches/auto-instrument/singleton-helper.cjs +193 -0
  32. package/scripts/patches/auto-instrument/sveltekit.cjs +157 -0
  33. package/scripts/patches/auto-instrument/vite-react.cjs +37 -0
  34. package/scripts/patches/auto-instrument/vue.cjs +192 -0
  35. package/scripts/patches/express.patch.cjs +99 -0
  36. package/scripts/patches/fastify.patch.cjs +107 -0
  37. package/scripts/patches/index.cjs +294 -0
  38. package/scripts/patches/nestjs.patch.cjs +111 -0
  39. package/scripts/patches/nextjs-app-router.patch.cjs +95 -0
  40. package/scripts/patches/nextjs-pages.patch.cjs +96 -0
  41. package/scripts/patches/remix.patch.cjs +74 -0
  42. package/scripts/patches/sveltekit.patch.cjs +71 -0
  43. package/scripts/patches/vite-react.patch.cjs +72 -0
  44. package/scripts/patches/vue.patch.cjs +81 -0
  45. package/scripts/renew-ssl.sh +14 -0
  46. package/scripts/resolve-migration.sh +23 -0
  47. package/scripts/seed-cli-dev-keys.mjs +130 -0
  48. package/scripts/seed-test-data.mjs +391 -0
  49. package/scripts/spike-browserless.ts +65 -0
  50. package/scripts/tenant-pivot-consistency-check.mjs +205 -0
  51. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +258 -0
  52. package/scripts/tenant-pivot-phase-3-cleanup.mjs +98 -0
  53. package/scripts/test-identity-resolution.ts +804 -0
  54. package/scripts/validate-gurulu-schemas.mjs +79 -0
@@ -0,0 +1,348 @@
1
+ // scripts/patches/auto-instrument/nextjs-pages.cjs — Phase 20 W1 A1.
2
+ //
3
+ // AST-based auto-instrumentation for Next.js Pages Router handlers. The
4
+ // default export is the handler. We parse with Babel, find the exported
5
+ // handler (default export function or arrow), locate a method branch if the
6
+ // handler switches on `req.method`, and inject `gurulu.track(...)` before
7
+ // the final `return` inside that branch (or the handler body when no branch
8
+ // exists).
9
+ //
10
+ // Regex fallback is preserved for malformed sources.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const ast = require('./ast-helper.cjs');
16
+ const singleton = require('./singleton-helper.cjs');
17
+
18
+ const NAME = 'auto-instrument-nextjs-pages';
19
+ const SUPPORTED = ['nextjs-pages'];
20
+ const MARKER = '// @gurulu-instrumented';
21
+ const IMPORT_LINE = "import { gurulu } from '@/lib/gurulu';";
22
+
23
+ function resolveRouteFileCandidates(routeStr) {
24
+ if (!routeStr || typeof routeStr !== 'string') return null;
25
+ const m = routeStr.trim().match(/^([A-Z]+)\s+(\/.*)$/);
26
+ if (!m) return null;
27
+ const method = m[1].toUpperCase();
28
+ const urlPath = m[2].replace(/^\//, '').replace(/\/+$/, '');
29
+ const exts = ['ts', 'tsx', 'js', 'jsx'];
30
+ const prefixes = ['pages', 'src/pages'];
31
+ const candidates = [];
32
+ for (const p of prefixes) {
33
+ for (const ext of exts) {
34
+ candidates.push(path.posix.join(p, `${urlPath}.${ext}`));
35
+ candidates.push(path.posix.join(p, urlPath, `index.${ext}`));
36
+ }
37
+ }
38
+ return { method, candidates };
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // AST path
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Given a handler body and a method string, return the BlockStatement of
47
+ * `if (req.method === 'POST') { ... }` or null if no method branch is found.
48
+ */
49
+ function findMethodBranchBody(handlerBody, method) {
50
+ if (!handlerBody || !Array.isArray(handlerBody.body)) return null;
51
+ const t = ast.t;
52
+ for (const stmt of handlerBody.body) {
53
+ if (!t.isIfStatement(stmt)) continue;
54
+ if (!matchesMethodTest(stmt.test, method)) continue;
55
+ if (t.isBlockStatement(stmt.consequent)) return stmt.consequent;
56
+ }
57
+ // Recurse into else-if chains.
58
+ for (const stmt of handlerBody.body) {
59
+ if (!t.isIfStatement(stmt)) continue;
60
+ let cursor = stmt.alternate;
61
+ while (cursor) {
62
+ if (t.isIfStatement(cursor)) {
63
+ if (matchesMethodTest(cursor.test, method) && t.isBlockStatement(cursor.consequent)) {
64
+ return cursor.consequent;
65
+ }
66
+ cursor = cursor.alternate;
67
+ } else {
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function matchesMethodTest(node, method) {
76
+ const t = ast.t;
77
+ if (!node) return false;
78
+ if (t.isBinaryExpression(node) && (node.operator === '===' || node.operator === '==')) {
79
+ const isMethod = (n) =>
80
+ t.isMemberExpression(n) &&
81
+ t.isIdentifier(n.object) &&
82
+ n.object.name === 'req' &&
83
+ t.isIdentifier(n.property) &&
84
+ n.property.name === 'method';
85
+ const isMethodLiteral = (n) => t.isStringLiteral(n) && n.value.toUpperCase() === method;
86
+ if (isMethod(node.left) && isMethodLiteral(node.right)) return true;
87
+ if (isMethod(node.right) && isMethodLiteral(node.left)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function astInstrumentFile(source, method, events) {
93
+ const tree = ast.parseSource(source);
94
+ const fns = ast.findExportedFunction(tree, 'default', { defaultExport: true });
95
+ if (fns.length === 0) {
96
+ return { ok: false, reason: 'handler-not-found' };
97
+ }
98
+ const target = fns[0];
99
+ if (ast.hasInstrumentedMarker(target.fn)) {
100
+ return {
101
+ ok: true,
102
+ after: source,
103
+ instrumented: [],
104
+ skipped: events.map((e) => ({ event: e.name, reason: 'already-instrumented' })),
105
+ changed: false,
106
+ };
107
+ }
108
+ const branch = findMethodBranchBody(target.body, method);
109
+ const injectBody = branch || target.body;
110
+ const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
111
+ ast.injectTrackBeforeLastReturn(target.fn, injectBody, stmts);
112
+ ast.ensureGuruluImport(tree);
113
+ const after = ast.generateSource(tree, source);
114
+ return {
115
+ ok: true,
116
+ after,
117
+ instrumented: events.map((e) => e.name),
118
+ skipped: [],
119
+ changed: true,
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Regex fallback
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function regexEnsureImport(source) {
128
+ if (source.includes(IMPORT_LINE)) return source;
129
+ const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
130
+ const m = source.match(importRegex);
131
+ if (m) {
132
+ const end = m.index + m[0].length;
133
+ return source.slice(0, end) + IMPORT_LINE + '\n' + source.slice(end);
134
+ }
135
+ return IMPORT_LINE + '\n' + source;
136
+ }
137
+
138
+ function regexFindHandlerBody(source) {
139
+ const patterns = [
140
+ /export\s+default\s+async\s+function\s+\w+\s*\([^)]*\)\s*\{/,
141
+ /export\s+default\s+function\s+\w+\s*\([^)]*\)\s*\{/,
142
+ /export\s+default\s+async\s*\([^)]*\)\s*=>\s*\{/,
143
+ /export\s+default\s*\([^)]*\)\s*=>\s*\{/,
144
+ ];
145
+ for (const re of patterns) {
146
+ const m = re.exec(source);
147
+ if (m) {
148
+ const openIdx = m.index + m[0].length - 1;
149
+ let depth = 0;
150
+ for (let i = openIdx; i < source.length; i++) {
151
+ const c = source[i];
152
+ if (c === '{') depth++;
153
+ else if (c === '}') {
154
+ depth--;
155
+ if (depth === 0) return { start: openIdx, end: i };
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function regexFindLastResponseCall(source, start, end) {
164
+ const snippet = source.slice(start, end);
165
+ const patterns = [
166
+ /res\.status\s*\([^)]*\)\s*\.json\s*\(/g,
167
+ /res\.status\s*\([^)]*\)\s*\.send\s*\(/g,
168
+ /res\.json\s*\(/g,
169
+ /res\.send\s*\(/g,
170
+ /res\.sendStatus\s*\(/g,
171
+ /res\.end\s*\(/g,
172
+ ];
173
+ let lastIdx = -1;
174
+ for (const re of patterns) {
175
+ let m;
176
+ while ((m = re.exec(snippet)) !== null) {
177
+ if (m.index > lastIdx) lastIdx = m.index;
178
+ }
179
+ }
180
+ if (lastIdx === -1) return null;
181
+ const absoluteIdx = start + lastIdx;
182
+ let lineStart = absoluteIdx;
183
+ while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--;
184
+ const indentMatch = source.slice(lineStart, absoluteIdx).match(/^(\s*)/);
185
+ const indent = (indentMatch && indentMatch[1]) || ' ';
186
+ return { insertAt: lineStart, indent };
187
+ }
188
+
189
+ function regexBuildTrackCall(eventName, indent) {
190
+ const safeName = JSON.stringify(eventName);
191
+ return `${indent}${MARKER} ${eventName}\n${indent}gurulu.track(${safeName}, {});\n`;
192
+ }
193
+
194
+ function regexInstrumentFile(before, method, events) {
195
+ const body = regexFindHandlerBody(before);
196
+ if (!body) return { ok: false, reason: 'handler-not-found' };
197
+ const snippet = before.slice(body.start, body.end);
198
+ const needed = [];
199
+ const skipped = [];
200
+ for (const e of events) {
201
+ if (snippet.includes(`${MARKER} ${e.name}`)) {
202
+ skipped.push({ event: e.name, reason: 'already-instrumented' });
203
+ } else {
204
+ needed.push(e);
205
+ }
206
+ }
207
+ if (needed.length === 0) return { ok: true, after: before, instrumented: [], skipped, changed: false };
208
+ const ret = regexFindLastResponseCall(before, body.start, body.end);
209
+ if (!ret) return { ok: false, reason: 'no-response-found' };
210
+ const block = needed.map((e) => regexBuildTrackCall(e.name, ret.indent)).join('');
211
+ let after = before.slice(0, ret.insertAt) + block + before.slice(ret.insertAt);
212
+ after = regexEnsureImport(after);
213
+ return {
214
+ ok: true,
215
+ after,
216
+ instrumented: needed.map((e) => e.name),
217
+ skipped,
218
+ changed: true,
219
+ };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Glue
224
+ // ---------------------------------------------------------------------------
225
+
226
+ function instrumentRouteFile(ctx, relPath, method, events) {
227
+ const abs = path.join(ctx.repoRoot, relPath);
228
+ if (!fs.existsSync(abs)) {
229
+ return { change: null, skipped: events.map((e) => ({ event: e.name, reason: 'route-file-missing' })), notes: [] };
230
+ }
231
+ const before = fs.readFileSync(abs, 'utf8');
232
+ const notes = [];
233
+ let res;
234
+ try {
235
+ res = astInstrumentFile(before, method, events);
236
+ } catch (err) {
237
+ const msg = (err && err.message) || String(err);
238
+ // eslint-disable-next-line no-console
239
+ console.warn(`[auto-instrument] patch.fallback ${relPath}: ${msg}`);
240
+ notes.push(`patch.fallback:${relPath}:${msg}`);
241
+ res = regexInstrumentFile(before, method, events);
242
+ }
243
+ if (!res.ok) {
244
+ return {
245
+ change: null,
246
+ skipped: events.map((e) => ({ event: e.name, reason: res.reason || 'instrument-failed' })),
247
+ notes,
248
+ };
249
+ }
250
+ if (!res.changed) return { change: null, skipped: res.skipped, notes };
251
+ return {
252
+ change: {
253
+ relPath,
254
+ before,
255
+ after: res.after,
256
+ reason: `auto-instrument-${method}`,
257
+ type: 'auto-instrument',
258
+ },
259
+ skipped: res.skipped,
260
+ instrumented: res.instrumented,
261
+ notes,
262
+ };
263
+ }
264
+
265
+ function groupEventsByRoute(ctx, events) {
266
+ const groups = new Map();
267
+ const unresolved = [];
268
+ for (const e of events || []) {
269
+ const routeStr = e && e.source && e.source.route;
270
+ if (!routeStr) {
271
+ unresolved.push({ event: e && e.name, reason: 'no-source-route' });
272
+ continue;
273
+ }
274
+ const parsed = resolveRouteFileCandidates(routeStr);
275
+ if (!parsed) {
276
+ unresolved.push({ event: e.name, reason: `unparseable-route:${routeStr}` });
277
+ continue;
278
+ }
279
+ const found = parsed.candidates.find((rel) =>
280
+ fs.existsSync(path.join(ctx.repoRoot, rel)),
281
+ );
282
+ if (!found) {
283
+ unresolved.push({ event: e.name, reason: 'route-file-not-found' });
284
+ continue;
285
+ }
286
+ const key = `${found}::${parsed.method}`;
287
+ if (!groups.has(key)) groups.set(key, { relPath: found, method: parsed.method, events: [] });
288
+ groups.get(key).events.push(e);
289
+ }
290
+ return { groups: Array.from(groups.values()), unresolved };
291
+ }
292
+
293
+ function instrumentEvents(ctx, events) {
294
+ const helper = singleton.ensureSingletonHelper(ctx, 'nextjs-pages');
295
+ const changes = [...helper.changes];
296
+ const notes = [...helper.notes];
297
+ let eventsInstrumented = 0;
298
+ let eventsSkipped = 0;
299
+
300
+ if (helper.collision) {
301
+ return {
302
+ changes: [],
303
+ notes,
304
+ filesModified: 0,
305
+ eventsInstrumented: 0,
306
+ eventsSkipped: events ? events.length : 0,
307
+ collision: true,
308
+ };
309
+ }
310
+
311
+ const { groups, unresolved } = groupEventsByRoute(ctx, events || []);
312
+ for (const u of unresolved) {
313
+ notes.push(`skip:${u.event || '(unknown)'}:${u.reason}`);
314
+ eventsSkipped++;
315
+ }
316
+ for (const group of groups) {
317
+ const res = instrumentRouteFile(ctx, group.relPath, group.method, group.events);
318
+ if (res.notes && res.notes.length) notes.push(...res.notes);
319
+ if (res.change) {
320
+ changes.push(res.change);
321
+ eventsInstrumented += (res.instrumented || []).length;
322
+ }
323
+ for (const s of res.skipped || []) {
324
+ notes.push(`skip:${s.event}:${s.reason}`);
325
+ eventsSkipped++;
326
+ }
327
+ }
328
+ const filesModified = changes.filter((c) => c.type === 'auto-instrument').length;
329
+ return { changes, notes, filesModified, eventsInstrumented, eventsSkipped, collision: false };
330
+ }
331
+
332
+ function ensureSingletonHelper(ctx) {
333
+ return singleton.ensureSingletonHelper(ctx, 'nextjs-pages');
334
+ }
335
+
336
+ module.exports = {
337
+ name: NAME,
338
+ supportedFrameworks: SUPPORTED,
339
+ ensureSingletonHelper,
340
+ instrumentEvents,
341
+ _internals: {
342
+ resolveRouteFileCandidates,
343
+ astInstrumentFile,
344
+ regexInstrumentFile,
345
+ groupEventsByRoute,
346
+ instrumentRouteFile,
347
+ },
348
+ };
@@ -0,0 +1,164 @@
1
+ // scripts/patches/auto-instrument/remix.cjs — Phase 20 W1 A3.
2
+ //
3
+ // Auto-instruments Remix route modules. Remix routes export `loader` and
4
+ // `action` functions from files under `app/routes/**`. We map a Proposed
5
+ // Event with `source.route = 'POST /cart/add'` onto `app/routes/cart.add.tsx`
6
+ // (Remix flat routes) or `app/routes/cart/add.tsx`, then inject a
7
+ // `gurulu.track(...)` call into the `action` (writes) or `loader` (reads).
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const ast = require('./ast-helper.cjs');
13
+ const singleton = require('./singleton-helper.cjs');
14
+
15
+ const NAME = 'auto-instrument-remix';
16
+ const SUPPORTED = ['remix'];
17
+
18
+ function parseEventRoute(routeStr) {
19
+ if (!routeStr || typeof routeStr !== 'string') return null;
20
+ const m = routeStr.trim().match(/^([A-Z]+)\s+(\/.*)$/);
21
+ if (!m) return null;
22
+ return { method: m[1].toUpperCase(), urlPath: m[2].replace(/\/+$/, '') || '/' };
23
+ }
24
+
25
+ function routeToCandidates(urlPath) {
26
+ const cleaned = urlPath.replace(/^\//, '').replace(/\/+$/, '');
27
+ const segments = cleaned.split('/').filter(Boolean);
28
+ if (segments.length === 0) segments.push('_index');
29
+ const exts = ['ts', 'tsx', 'js', 'jsx'];
30
+ const out = [];
31
+ // Nested: app/routes/cart/add.tsx
32
+ for (const ext of exts) {
33
+ out.push(path.posix.join('app', 'routes', ...segments) + `.${ext}`);
34
+ out.push(path.posix.join('app', 'routes', ...segments, `index.${ext}`));
35
+ }
36
+ // Flat routes: app/routes/cart.add.tsx
37
+ for (const ext of exts) {
38
+ out.push(path.posix.join('app', 'routes', segments.join('.')) + `.${ext}`);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function astInstrumentFile(source, method, events) {
44
+ const tree = ast.parseSource(source);
45
+ // Reads → `loader`, writes → `action`. Per Remix convention.
46
+ const exportName = method === 'GET' || method === 'HEAD' ? 'loader' : 'action';
47
+ const fns = ast.findExportedFunction(tree, exportName);
48
+ if (fns.length === 0) return { ok: false, reason: `${exportName}-not-found` };
49
+ const target = fns[0];
50
+ if (ast.hasInstrumentedMarker(target.fn)) {
51
+ return {
52
+ ok: true,
53
+ after: source,
54
+ instrumented: [],
55
+ skipped: events.map((e) => ({ event: e.name, reason: 'already-instrumented' })),
56
+ changed: false,
57
+ };
58
+ }
59
+ const stmts = events.map((e) => ast.buildTrackStatement(e.name, e.autoProperties));
60
+ ast.injectTrackBeforeLastReturn(target.fn, target.body, stmts);
61
+ ast.ensureGuruluImport(tree);
62
+ const after = ast.generateSource(tree, source);
63
+ return {
64
+ ok: true,
65
+ after,
66
+ instrumented: events.map((e) => e.name),
67
+ skipped: [],
68
+ changed: true,
69
+ };
70
+ }
71
+
72
+ function instrumentEvents(ctx, events) {
73
+ const helper = singleton.ensureSingletonHelper(ctx, 'remix');
74
+ const changes = [...helper.changes];
75
+ const notes = [...helper.notes];
76
+ let eventsInstrumented = 0;
77
+ let eventsSkipped = 0;
78
+
79
+ if (helper.collision) {
80
+ return {
81
+ changes: [],
82
+ notes,
83
+ filesModified: 0,
84
+ eventsInstrumented: 0,
85
+ eventsSkipped: events ? events.length : 0,
86
+ collision: true,
87
+ };
88
+ }
89
+
90
+ const groups = new Map();
91
+ for (const e of events || []) {
92
+ const parsed = parseEventRoute(e && e.source && e.source.route);
93
+ if (!parsed) {
94
+ notes.push(`skip:${e && e.name}:no-source-route`);
95
+ eventsSkipped++;
96
+ continue;
97
+ }
98
+ const candidates = routeToCandidates(parsed.urlPath);
99
+ const found = candidates.find((rel) => fs.existsSync(path.join(ctx.repoRoot, rel)));
100
+ if (!found) {
101
+ notes.push(`skip:${e.name}:route-file-not-found`);
102
+ eventsSkipped++;
103
+ continue;
104
+ }
105
+ const key = `${found}::${parsed.method}`;
106
+ if (!groups.has(key)) groups.set(key, { relPath: found, method: parsed.method, events: [] });
107
+ groups.get(key).events.push(e);
108
+ }
109
+
110
+ for (const group of groups.values()) {
111
+ const abs = path.join(ctx.repoRoot, group.relPath);
112
+ const before = fs.readFileSync(abs, 'utf8');
113
+ let res;
114
+ try {
115
+ res = astInstrumentFile(before, group.method, group.events);
116
+ } catch (err) {
117
+ const msg = (err && err.message) || String(err);
118
+ // eslint-disable-next-line no-console
119
+ console.warn(`[auto-instrument] patch.fallback ${group.relPath}: ${msg}`);
120
+ notes.push(`patch.fallback:${group.relPath}:${msg}`);
121
+ continue;
122
+ }
123
+ if (!res.ok) {
124
+ for (const e of group.events) {
125
+ notes.push(`skip:${e.name}:${res.reason}`);
126
+ eventsSkipped++;
127
+ }
128
+ continue;
129
+ }
130
+ for (const s of res.skipped || []) {
131
+ notes.push(`skip:${s.event}:${s.reason}`);
132
+ eventsSkipped++;
133
+ }
134
+ if (res.changed) {
135
+ changes.push({
136
+ relPath: group.relPath,
137
+ before,
138
+ after: res.after,
139
+ reason: `auto-instrument-remix`,
140
+ type: 'auto-instrument',
141
+ });
142
+ eventsInstrumented += (res.instrumented || []).length;
143
+ }
144
+ }
145
+
146
+ const filesModified = changes.filter((c) => c.type === 'auto-instrument').length;
147
+ return { changes, notes, filesModified, eventsInstrumented, eventsSkipped, collision: false };
148
+ }
149
+
150
+ function ensureSingletonHelper(ctx) {
151
+ return singleton.ensureSingletonHelper(ctx, 'remix');
152
+ }
153
+
154
+ module.exports = {
155
+ name: NAME,
156
+ supportedFrameworks: SUPPORTED,
157
+ ensureSingletonHelper,
158
+ instrumentEvents,
159
+ _internals: {
160
+ parseEventRoute,
161
+ routeToCandidates,
162
+ astInstrumentFile,
163
+ },
164
+ };
@@ -0,0 +1,193 @@
1
+ // scripts/patches/auto-instrument/singleton-helper.cjs — Phase 18.7 B5.
2
+ //
3
+ // Shared utility for the per-framework auto-instrument modules. Responsible
4
+ // for:
5
+ //
6
+ // 1. Creating a `src/lib/gurulu.ts` singleton that constructs the
7
+ // `@gurulu/node` Gurulu client from env vars.
8
+ // 2. Adding `@gurulu/node` to the target repo's `package.json` dependencies
9
+ // if missing.
10
+ // 3. Appending `GURULU_SITE_ID` / `GURULU_SECRET_KEY` / `GURULU_INGEST_URL`
11
+ // env vars to `.env.local` (Next.js) or `.env` (Express/other).
12
+ //
13
+ // None of these helpers shell out — they produce in-memory `change` records
14
+ // that the caller can thread through the existing `applyPlan()` backup +
15
+ // patch-log infrastructure. That way auto-instrument patches participate in
16
+ // the same `--rollback` flow as the script-tag patches.
17
+ //
18
+ // Pure Node, zero deps. Idempotency is achieved via the
19
+ // `// @gurulu-instrumented` marker + JSON dep deduplication.
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const HELPER_MARKER = '// @gurulu-instrumented';
25
+ const HELPER_REL_PATH = 'src/lib/gurulu.ts';
26
+ const NODE_DEP = '@gurulu/node';
27
+ const NODE_DEP_VERSION = '^0.1.0';
28
+
29
+ function buildHelperContent() {
30
+ return (
31
+ `${HELPER_MARKER}\n` +
32
+ `// Auto-generated by Gurulu agentic install (phase 18.7).\n` +
33
+ `// Edit to customize the singleton, but keep the @gurulu-instrumented\n` +
34
+ `// marker so \`gurulu install --rollback\` can find this file.\n` +
35
+ `import { Gurulu } from '@gurulu/node';\n\n` +
36
+ `export const gurulu = new Gurulu({\n` +
37
+ ` siteId: process.env.GURULU_SITE_ID ?? '',\n` +
38
+ ` apiKey: process.env.GURULU_SECRET_KEY ?? '',\n` +
39
+ ` endpoint: process.env.GURULU_INGEST_URL ?? 'https://gurulu.io',\n` +
40
+ `});\n`
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Plan a singleton helper file change. Returns a `change` record compatible
46
+ * with `patches.applyPlan()` or `null` if the helper already exists with our
47
+ * marker (idempotent). Throws when the file exists but does not carry the
48
+ * marker — that indicates a collision with user code and refuses to
49
+ * overwrite.
50
+ */
51
+ function planSingletonHelper(ctx) {
52
+ const repoRoot = ctx.repoRoot;
53
+ const abs = path.join(repoRoot, HELPER_REL_PATH);
54
+ if (fs.existsSync(abs)) {
55
+ const existing = fs.readFileSync(abs, 'utf8');
56
+ if (existing.includes(HELPER_MARKER)) {
57
+ return { change: null, reason: 'helper-exists' };
58
+ }
59
+ const err = new Error(
60
+ `auto-instrument: refusing to overwrite existing ${HELPER_REL_PATH} ` +
61
+ `(no @gurulu-instrumented marker). Delete or rename the file to proceed.`,
62
+ );
63
+ err.code = 'GURULU_HELPER_COLLISION';
64
+ throw err;
65
+ }
66
+ return {
67
+ change: {
68
+ relPath: HELPER_REL_PATH,
69
+ before: '',
70
+ after: buildHelperContent(),
71
+ reason: 'create-singleton-helper',
72
+ },
73
+ reason: 'helper-created',
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Plan the `package.json` edit that adds `@gurulu/node` to dependencies if
79
+ * missing. Returns a change record or null when already present.
80
+ */
81
+ function planPackageJsonDependency(ctx) {
82
+ const abs = path.join(ctx.repoRoot, 'package.json');
83
+ if (!fs.existsSync(abs)) {
84
+ return { change: null, reason: 'package-json-missing' };
85
+ }
86
+ const before = fs.readFileSync(abs, 'utf8');
87
+ let pkg;
88
+ try {
89
+ pkg = JSON.parse(before);
90
+ } catch {
91
+ return { change: null, reason: 'package-json-invalid' };
92
+ }
93
+ const deps = pkg.dependencies || {};
94
+ if (deps[NODE_DEP]) {
95
+ return { change: null, reason: 'node-dep-present' };
96
+ }
97
+ pkg.dependencies = { ...deps, [NODE_DEP]: NODE_DEP_VERSION };
98
+ // Preserve the trailing newline convention.
99
+ const trailing = before.endsWith('\n') ? '\n' : '';
100
+ const after = JSON.stringify(pkg, null, 2) + trailing;
101
+ return {
102
+ change: {
103
+ relPath: 'package.json',
104
+ before,
105
+ after,
106
+ reason: 'add-gurulu-node-dep',
107
+ },
108
+ reason: 'node-dep-added',
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Plan the `.env.local` / `.env` edit that appends the GURULU_* keys. Never
114
+ * overwrites existing values. Returns a change record or null when all keys
115
+ * are already present.
116
+ */
117
+ function planEnvFile(ctx, framework) {
118
+ const isNext = !!(framework && /^nextjs/.test(framework));
119
+ const filename = isNext ? '.env.local' : '.env';
120
+ const abs = path.join(ctx.repoRoot, filename);
121
+ const before = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
122
+ const presentKeys = new Set(
123
+ before
124
+ .split('\n')
125
+ .map((l) => l.trim())
126
+ .filter((l) => l && !l.startsWith('#'))
127
+ .map((l) => l.split('=')[0].trim()),
128
+ );
129
+ const want = {
130
+ GURULU_SITE_ID: '',
131
+ GURULU_SECRET_KEY: '',
132
+ GURULU_INGEST_URL: 'https://gurulu.io',
133
+ };
134
+ const missing = Object.entries(want).filter(([k]) => !presentKeys.has(k));
135
+ if (missing.length === 0) {
136
+ return { change: null, reason: 'env-already-present' };
137
+ }
138
+ const separator = before && !before.endsWith('\n') ? '\n' : '';
139
+ const header = before ? '' : '# Gurulu agentic install (phase 18.7)\n';
140
+ const newLines = missing.map(([k, v]) => `${k}=${v}`).join('\n');
141
+ const after = before + separator + header + newLines + '\n';
142
+ return {
143
+ change: {
144
+ relPath: filename,
145
+ before,
146
+ after,
147
+ reason: 'append-gurulu-env',
148
+ },
149
+ reason: 'env-added',
150
+ addedKeys: missing.map(([k]) => k),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Convenience: plan all three prerequisite changes at once. Returns
156
+ * `{ changes: [...], notes: [...] }` following the same shape the
157
+ * per-framework instrument modules return, so it can be merged directly by
158
+ * callers.
159
+ */
160
+ function ensureSingletonHelper(ctx, framework) {
161
+ const changes = [];
162
+ const notes = [];
163
+ try {
164
+ const helper = planSingletonHelper(ctx);
165
+ if (helper.change) changes.push(helper.change);
166
+ else notes.push(`singleton:${helper.reason}`);
167
+ } catch (err) {
168
+ if (err.code === 'GURULU_HELPER_COLLISION') {
169
+ notes.push(`singleton-collision:${err.message}`);
170
+ return { changes: [], notes, collision: true };
171
+ }
172
+ throw err;
173
+ }
174
+ const dep = planPackageJsonDependency(ctx);
175
+ if (dep.change) changes.push(dep.change);
176
+ else notes.push(`package-json:${dep.reason}`);
177
+ const env = planEnvFile(ctx, framework);
178
+ if (env.change) changes.push(env.change);
179
+ else notes.push(`env:${env.reason}`);
180
+ return { changes, notes, collision: false };
181
+ }
182
+
183
+ module.exports = {
184
+ HELPER_MARKER,
185
+ HELPER_REL_PATH,
186
+ NODE_DEP,
187
+ NODE_DEP_VERSION,
188
+ buildHelperContent,
189
+ planSingletonHelper,
190
+ planPackageJsonDependency,
191
+ planEnvFile,
192
+ ensureSingletonHelper,
193
+ };