@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,294 @@
1
+ // scripts/patches/index.cjs — Phase 16 A1 patch engine registry.
2
+ //
3
+ // Each patcher exports { name, detect, plan, apply, rollback }. The CLI
4
+ // resolves them in priority order via `resolvePatcher()`. Backups and patch
5
+ // logs are produced by shared helpers in this file.
6
+ //
7
+ // Pure Node, no deps. Regex-based patching (ts-morph not available).
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // BEGIN: Agent A (Phase 18.7) — Framework patcher registration
15
+ // Script-tag injection patchers. Order matters for auto-detection: more
16
+ // specific frameworks first (Next.js before generic Vite+React, Remix
17
+ // before Vue, NestJS/Fastify server-only last so SPA frontends in the
18
+ // same repo win).
19
+ // ---------------------------------------------------------------------------
20
+ const nextAppRouter = require('./nextjs-app-router.patch.cjs');
21
+ const nextPages = require('./nextjs-pages.patch.cjs');
22
+ const remix = require('./remix.patch.cjs');
23
+ const sveltekit = require('./sveltekit.patch.cjs');
24
+ const astro = require('./astro.patch.cjs');
25
+ const viteReact = require('./vite-react.patch.cjs');
26
+ const vue = require('./vue.patch.cjs');
27
+ const nestjs = require('./nestjs.patch.cjs');
28
+ const fastify = require('./fastify.patch.cjs');
29
+ const express = require('./express.patch.cjs');
30
+
31
+ const PATCHERS = [
32
+ nextAppRouter,
33
+ nextPages,
34
+ remix,
35
+ sveltekit,
36
+ astro,
37
+ viteReact,
38
+ vue,
39
+ nestjs,
40
+ fastify,
41
+ express,
42
+ ];
43
+
44
+ const PATCHER_BY_NAME = {
45
+ 'nextjs-app': nextAppRouter,
46
+ 'nextjs-app-router': nextAppRouter,
47
+ 'nextjs-pages': nextPages,
48
+ remix,
49
+ sveltekit,
50
+ astro,
51
+ 'vite-react': viteReact,
52
+ vue,
53
+ vue3: vue,
54
+ nestjs,
55
+ fastify,
56
+ express,
57
+ };
58
+ // ---------------------------------------------------------------------------
59
+ // END: Agent A (Phase 18.7) — Framework patcher registration
60
+ // NOTE for Agent B: Auto-instrument dispatcher goes in its own section below
61
+ // (or in scripts/patches/auto-instrument/index.cjs). Do not inline it in the
62
+ // PATCHERS array above — those are script-tag patchers only.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // BEGIN: Agent B (Phase 18.7) — Auto-instrumentation dispatcher
67
+ // Route-handler instrumentation modules live in `./auto-instrument/*.cjs`.
68
+ // They share a singleton-helper utility that keeps `src/lib/gurulu.ts`,
69
+ // `package.json`, and `.env.local` / `.env` in sync. The dispatcher below
70
+ // is a thin wrapper that `scripts/gurulu-agentic-install.mjs` calls when
71
+ // `--auto-instrument` is set. Changes produced by auto-instrument modules
72
+ // are stamped `type: 'auto-instrument'` so `applyAutoInstrumentPlan()` can
73
+ // append them to the existing `.gurulu/patch-log.json` (rather than
74
+ // overwriting the script-tag entries) — `rollback()` then restores both
75
+ // patch generations in a single pass.
76
+ // ---------------------------------------------------------------------------
77
+ const autoInstrumentDispatcher = require('./auto-instrument/index.cjs');
78
+
79
+ function autoInstrumentDispatch(framework, ctx, events) {
80
+ return autoInstrumentDispatcher.dispatch(framework, ctx, events);
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // END: Agent B (Phase 18.7) — Auto-instrumentation dispatcher
84
+ // ---------------------------------------------------------------------------
85
+
86
+ // Tracker tag context — a small object the patchers stamp into the code they inject.
87
+ function buildInjection(opts) {
88
+ const siteId = opts.siteId || '';
89
+ const tenantId = opts.tenantId || '';
90
+ const publishableKey = opts.publishableKey || '';
91
+ const src = opts.scriptSrc || '/gurulu-tracker.js';
92
+ return { siteId, tenantId, publishableKey, scriptSrc: src };
93
+ }
94
+
95
+ function resolvePatcher(repoRoot, frameworkHint) {
96
+ if (frameworkHint && frameworkHint !== 'auto') {
97
+ const p = PATCHER_BY_NAME[frameworkHint];
98
+ if (!p) return { patcher: null, detection: null, error: `Unknown framework: ${frameworkHint}` };
99
+ const detection = p.detect(repoRoot);
100
+ return { patcher: p, detection: detection || { framework: p.name, files: [] } };
101
+ }
102
+ for (const p of PATCHERS) {
103
+ const detection = p.detect(repoRoot);
104
+ if (detection) return { patcher: p, detection };
105
+ }
106
+ return { patcher: null, detection: null };
107
+ }
108
+
109
+ // Backup one file into .gurulu-backup/<timestamp>/<relPath>.
110
+ function backupFile(repoRoot, relPath, timestamp) {
111
+ const abs = path.join(repoRoot, relPath);
112
+ const original = fs.readFileSync(abs, 'utf8');
113
+ const backupDir = path.join(repoRoot, '.gurulu-backup', timestamp);
114
+ const backupPath = path.join(backupDir, relPath);
115
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
116
+ fs.writeFileSync(backupPath, original, 'utf8');
117
+ return { backupPath: path.relative(repoRoot, backupPath), original };
118
+ }
119
+
120
+ function hashContent(text) {
121
+ return crypto.createHash('sha256').update(text).digest('hex').slice(0, 16);
122
+ }
123
+
124
+ function ensureGitignore(repoRoot) {
125
+ const gi = path.join(repoRoot, '.gitignore');
126
+ let contents = '';
127
+ if (fs.existsSync(gi)) contents = fs.readFileSync(gi, 'utf8');
128
+ if (!/\.gurulu-backup/.test(contents)) {
129
+ const append = (contents.endsWith('\n') || !contents ? '' : '\n') + '.gurulu-backup/\n';
130
+ fs.writeFileSync(gi, contents + append, 'utf8');
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ // Utility: produce a plain unified-diff-ish output for dry-run (tiny, no deps).
137
+ function unifiedDiff(relPath, before, after) {
138
+ if (before === after) return `--- a/${relPath}\n+++ b/${relPath}\n(no changes)\n`;
139
+ const beforeLines = before.split('\n');
140
+ const afterLines = after.split('\n');
141
+ const lines = [`--- a/${relPath}`, `+++ b/${relPath}`];
142
+ // Naive: show all "-" then all "+" — sufficient for alpha dry-run display.
143
+ for (const l of beforeLines) lines.push(`-${l}`);
144
+ for (const l of afterLines) lines.push(`+${l}`);
145
+ return lines.join('\n') + '\n';
146
+ }
147
+
148
+ // Plan → { changes: [{ relPath, before, after, reason }], notes: [] }
149
+ // Apply consumes a plan, writes files, creates backups, writes patch-log.
150
+ async function applyPlan(repoRoot, patcher, plan) {
151
+ if (!plan || !plan.changes || plan.changes.length === 0) {
152
+ return { applied: false, reason: 'no-changes', files: [] };
153
+ }
154
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
155
+ const fileRecords = [];
156
+ for (const change of plan.changes) {
157
+ const abs = path.join(repoRoot, change.relPath);
158
+ // Ensure parent dir exists (for new files added by patcher if any).
159
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
160
+ let backupRel = null;
161
+ if (fs.existsSync(abs)) {
162
+ const { backupPath } = backupFile(repoRoot, change.relPath, timestamp);
163
+ backupRel = backupPath;
164
+ }
165
+ fs.writeFileSync(abs, change.after, 'utf8');
166
+ fileRecords.push({
167
+ path: change.relPath,
168
+ hash: hashContent(change.after),
169
+ backupPath: backupRel,
170
+ type: change.type || 'script-tag',
171
+ });
172
+ }
173
+ ensureGitignore(repoRoot);
174
+ const logDir = path.join(repoRoot, '.gurulu');
175
+ fs.mkdirSync(logDir, { recursive: true });
176
+ const logFile = path.join(logDir, 'patch-log.json');
177
+ const log = {
178
+ appliedAt: new Date().toISOString(),
179
+ framework: patcher.name,
180
+ timestamp,
181
+ files: fileRecords,
182
+ rollbackAvailable: true,
183
+ };
184
+ fs.writeFileSync(logFile, JSON.stringify(log, null, 2) + '\n', 'utf8');
185
+ return { applied: true, log, files: fileRecords };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Phase 18.7 B6 — applyAutoInstrumentPlan
190
+ // Applies the auto-instrument plan (helper + route handler changes) using
191
+ // the same backup mechanism as applyPlan, but APPENDS to an existing
192
+ // `.gurulu/patch-log.json` rather than overwriting it. This lets the
193
+ // script-tag patch and the auto-instrument patch coexist under a single
194
+ // rollback pass.
195
+ // ---------------------------------------------------------------------------
196
+ async function applyAutoInstrumentPlan(repoRoot, plan) {
197
+ if (!plan || !plan.changes || plan.changes.length === 0) {
198
+ return { applied: false, reason: 'no-changes', files: [] };
199
+ }
200
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
201
+ const fileRecords = [];
202
+ for (const change of plan.changes) {
203
+ const abs = path.join(repoRoot, change.relPath);
204
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
205
+ let backupRel = null;
206
+ if (fs.existsSync(abs)) {
207
+ const { backupPath } = backupFile(repoRoot, change.relPath, timestamp);
208
+ backupRel = backupPath;
209
+ }
210
+ fs.writeFileSync(abs, change.after, 'utf8');
211
+ fileRecords.push({
212
+ path: change.relPath,
213
+ hash: hashContent(change.after),
214
+ backupPath: backupRel,
215
+ type: change.type || 'auto-instrument',
216
+ });
217
+ }
218
+ ensureGitignore(repoRoot);
219
+ const logDir = path.join(repoRoot, '.gurulu');
220
+ fs.mkdirSync(logDir, { recursive: true });
221
+ const logFile = path.join(logDir, 'patch-log.json');
222
+ let log;
223
+ if (fs.existsSync(logFile)) {
224
+ try {
225
+ log = JSON.parse(fs.readFileSync(logFile, 'utf8'));
226
+ } catch {
227
+ log = null;
228
+ }
229
+ }
230
+ if (!log) {
231
+ log = {
232
+ appliedAt: new Date().toISOString(),
233
+ framework: plan.framework || 'auto-instrument',
234
+ timestamp,
235
+ files: [],
236
+ rollbackAvailable: true,
237
+ };
238
+ }
239
+ log.files = [...(log.files || []), ...fileRecords];
240
+ log.autoInstrumentAppliedAt = new Date().toISOString();
241
+ log.autoInstrumentTimestamp = timestamp;
242
+ log.rollbackAvailable = true;
243
+ fs.writeFileSync(logFile, JSON.stringify(log, null, 2) + '\n', 'utf8');
244
+ return { applied: true, log, files: fileRecords };
245
+ }
246
+
247
+ function rollback(repoRoot) {
248
+ const logFile = path.join(repoRoot, '.gurulu', 'patch-log.json');
249
+ if (!fs.existsSync(logFile)) {
250
+ return { rolledBack: false, reason: 'no-patch-log' };
251
+ }
252
+ const log = JSON.parse(fs.readFileSync(logFile, 'utf8'));
253
+ if (!log.rollbackAvailable) return { rolledBack: false, reason: 'rollback-unavailable' };
254
+ const restored = [];
255
+ for (const f of log.files) {
256
+ if (!f.backupPath) {
257
+ // File was newly created by patcher — delete it.
258
+ const abs = path.join(repoRoot, f.path);
259
+ if (fs.existsSync(abs)) fs.unlinkSync(abs);
260
+ restored.push({ path: f.path, action: 'deleted' });
261
+ continue;
262
+ }
263
+ const backupAbs = path.join(repoRoot, f.backupPath);
264
+ if (!fs.existsSync(backupAbs)) {
265
+ restored.push({ path: f.path, action: 'missing-backup' });
266
+ continue;
267
+ }
268
+ const original = fs.readFileSync(backupAbs, 'utf8');
269
+ fs.writeFileSync(path.join(repoRoot, f.path), original, 'utf8');
270
+ restored.push({ path: f.path, action: 'restored' });
271
+ }
272
+ // Mark log as consumed.
273
+ log.rollbackAvailable = false;
274
+ log.rolledBackAt = new Date().toISOString();
275
+ fs.writeFileSync(logFile, JSON.stringify(log, null, 2) + '\n', 'utf8');
276
+ return { rolledBack: true, files: restored };
277
+ }
278
+
279
+ module.exports = {
280
+ PATCHERS,
281
+ PATCHER_BY_NAME,
282
+ resolvePatcher,
283
+ buildInjection,
284
+ backupFile,
285
+ hashContent,
286
+ ensureGitignore,
287
+ unifiedDiff,
288
+ applyPlan,
289
+ rollback,
290
+ // Phase 18.7 B — auto-instrumentation exports.
291
+ autoInstrumentDispatch,
292
+ autoInstrument: autoInstrumentDispatcher,
293
+ applyAutoInstrumentPlan,
294
+ };
@@ -0,0 +1,111 @@
1
+ // nestjs.patch.cjs — Phase 18.7 A1
2
+ //
3
+ // NestJS is a server-side framework. Most real-world NestJS apps serve a
4
+ // separate frontend (React/Vue/Angular) or bundle a static client under
5
+ // `client/dist/` / `public/`. When we find a static `index.html` we patch
6
+ // it with a plain <script> tag. Otherwise we emit a detection note telling
7
+ // the operator to use --auto-instrument for server-side tracking (Phase
8
+ // 18.7 Agent B territory).
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const NAME = 'nestjs';
14
+ const HTML_CANDIDATES = [
15
+ 'public/index.html',
16
+ 'client/dist/index.html',
17
+ 'client/build/index.html',
18
+ 'dist/client/index.html',
19
+ ];
20
+ const SERVER_CANDIDATES = [
21
+ 'src/main.ts',
22
+ 'src/main.js',
23
+ ];
24
+
25
+ const MARKER = 'data-gurulu-install="1"';
26
+
27
+ function looksLikeNest(repoRoot) {
28
+ // Detect either via package.json `@nestjs/core` dep or the idiomatic
29
+ // `NestFactory.create(...)` bootstrap call in main.ts.
30
+ const pkgPath = path.join(repoRoot, 'package.json');
31
+ if (fs.existsSync(pkgPath)) {
32
+ try {
33
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
34
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
35
+ if (deps['@nestjs/core']) return true;
36
+ } catch {
37
+ /* ignore malformed package.json */
38
+ }
39
+ }
40
+ for (const rel of SERVER_CANDIDATES) {
41
+ const abs = path.join(repoRoot, rel);
42
+ if (fs.existsSync(abs)) {
43
+ const source = fs.readFileSync(abs, 'utf8');
44
+ if (/NestFactory\.create\s*\(/.test(source) || /@nestjs\/core/.test(source)) {
45
+ return true;
46
+ }
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function detect(repoRoot) {
53
+ if (!looksLikeNest(repoRoot)) return null;
54
+ for (const rel of HTML_CANDIDATES) {
55
+ if (fs.existsSync(path.join(repoRoot, rel))) {
56
+ return { framework: NAME, files: [rel] };
57
+ }
58
+ }
59
+ // Server-only — no frontend to patch. Return detection with empty file list
60
+ // so the caller can surface the note.
61
+ return { framework: NAME, files: [], serverOnly: true };
62
+ }
63
+
64
+ function buildScriptTag(injection) {
65
+ const pkAttr = injection.publishableKey
66
+ ? ` data-gurulu-publishable-key="${injection.publishableKey}"`
67
+ : '';
68
+ return (
69
+ ` <script\n` +
70
+ ` src="${injection.scriptSrc}"\n` +
71
+ ` data-gurulu-site-id="${injection.siteId}"\n` +
72
+ ` data-gurulu-tenant-id="${injection.tenantId}"${pkAttr}\n` +
73
+ ` ${MARKER}\n` +
74
+ ` async\n` +
75
+ ` ></script>\n`
76
+ );
77
+ }
78
+
79
+ function injectScriptTag(source, injection) {
80
+ if (source.includes(MARKER)) return source;
81
+ const tag = buildScriptTag(injection);
82
+ if (/<\/body>/.test(source)) {
83
+ return source.replace(/<\/body>/, `${tag} </body>`);
84
+ }
85
+ return source + '\n' + tag;
86
+ }
87
+
88
+ function plan(ctx) {
89
+ const detection = detect(ctx.repoRoot);
90
+ if (!detection) return { changes: [], notes: ['no-nestjs'] };
91
+ if (detection.serverOnly) {
92
+ return {
93
+ changes: [],
94
+ notes: [
95
+ 'server-only; script injection skipped, use --auto-instrument for server-side tracking',
96
+ ],
97
+ };
98
+ }
99
+ const changes = [];
100
+ for (const rel of detection.files) {
101
+ const abs = path.join(ctx.repoRoot, rel);
102
+ const before = fs.readFileSync(abs, 'utf8');
103
+ const after = injectScriptTag(before, ctx.injection);
104
+ if (after !== before) {
105
+ changes.push({ relPath: rel, before, after, reason: 'inject-script-tag' });
106
+ }
107
+ }
108
+ return { changes, notes: [] };
109
+ }
110
+
111
+ module.exports = { name: NAME, detect, plan };
@@ -0,0 +1,95 @@
1
+ // nextjs-app-router.patch.cjs — Phase 16 A1
2
+ //
3
+ // Detects a Next.js App Router layout (`src/app/layout.tsx` or `app/layout.tsx`)
4
+ // and injects a <Script> tag loaded via next/script that points to the Gurulu
5
+ // Web SDK tracker (`/gurulu-tracker.js` by default).
6
+ //
7
+ // Regex-based: fast alpha, good enough for most idiomatic RootLayouts.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const NAME = 'nextjs-app-router';
13
+ const CANDIDATES = [
14
+ 'src/app/layout.tsx',
15
+ 'src/app/layout.jsx',
16
+ 'app/layout.tsx',
17
+ 'app/layout.jsx',
18
+ ];
19
+
20
+ const MARKER = 'data-gurulu-install="1"';
21
+
22
+ function detect(repoRoot) {
23
+ for (const rel of CANDIDATES) {
24
+ if (fs.existsSync(path.join(repoRoot, rel))) {
25
+ return { framework: NAME, files: [rel] };
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ function buildScriptTag(injection) {
32
+ const pkAttr = injection.publishableKey
33
+ ? ` data-gurulu-publishable-key=${JSON.stringify(injection.publishableKey)}\n`
34
+ : '';
35
+ return (
36
+ ` <Script\n` +
37
+ ` id="gurulu-tracker"\n` +
38
+ ` src=${JSON.stringify(injection.scriptSrc)}\n` +
39
+ ` strategy="afterInteractive"\n` +
40
+ ` data-gurulu-site-id=${JSON.stringify(injection.siteId)}\n` +
41
+ ` data-gurulu-tenant-id=${JSON.stringify(injection.tenantId)}\n` +
42
+ pkAttr +
43
+ ` data-features="errors"\n` +
44
+ ` ${MARKER}\n` +
45
+ ` />\n`
46
+ );
47
+ }
48
+
49
+ function ensureScriptImport(source) {
50
+ if (/from\s+['"]next\/script['"]/.test(source)) return source;
51
+ // Insert after the last import statement (or at top).
52
+ const importRegex = /^(?:import[\s\S]*?;\s*\n)+/m;
53
+ const m = source.match(importRegex);
54
+ const inject = `import Script from 'next/script';\n`;
55
+ if (m) {
56
+ const end = m.index + m[0].length;
57
+ return source.slice(0, end) + inject + source.slice(end);
58
+ }
59
+ return inject + source;
60
+ }
61
+
62
+ function injectScriptTag(source, injection) {
63
+ if (source.includes(MARKER)) return source; // idempotent
64
+ const tag = buildScriptTag(injection);
65
+ // Prefer to insert just before </body>. Fall back to before the closing
66
+ // top-level fragment/element.
67
+ if (/<\/body>/.test(source)) {
68
+ return source.replace(/<\/body>/, `${tag} </body>`);
69
+ }
70
+ // Insert before the final closing tag of children area — best-effort.
71
+ return source.replace(/(\n\s*<\/(?:html|main|div)>\s*\)\s*;?\s*\}\s*$)/, (m) => `\n${tag}${m}`);
72
+ }
73
+
74
+ function plan(ctx) {
75
+ const detection = detect(ctx.repoRoot);
76
+ if (!detection) return { changes: [], notes: ['no-layout-found'] };
77
+ const changes = [];
78
+ for (const rel of detection.files) {
79
+ const abs = path.join(ctx.repoRoot, rel);
80
+ const before = fs.readFileSync(abs, 'utf8');
81
+ let after = ensureScriptImport(before);
82
+ after = injectScriptTag(after, ctx.injection);
83
+ if (after !== before) {
84
+ changes.push({ relPath: rel, before, after, reason: 'inject-next-script' });
85
+ } else {
86
+ changes.push({ relPath: rel, before, after, reason: 'already-installed', skip: true });
87
+ }
88
+ }
89
+ return {
90
+ changes: changes.filter((c) => !c.skip),
91
+ notes: changes.filter((c) => c.skip).map((c) => `skip:${c.relPath}`),
92
+ };
93
+ }
94
+
95
+ module.exports = { name: NAME, detect, plan };
@@ -0,0 +1,96 @@
1
+ // nextjs-pages.patch.cjs — Phase 16 A1
2
+ //
3
+ // Detects a legacy Next.js Pages Router `_app` file and injects a useEffect
4
+ // that appends the Gurulu tracker <script> to document.head.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const NAME = 'nextjs-pages';
10
+ const CANDIDATES = [
11
+ 'pages/_app.tsx',
12
+ 'pages/_app.jsx',
13
+ 'src/pages/_app.tsx',
14
+ 'src/pages/_app.jsx',
15
+ ];
16
+
17
+ const MARKER = '/* gurulu:install */';
18
+
19
+ function detect(repoRoot) {
20
+ for (const rel of CANDIDATES) {
21
+ if (fs.existsSync(path.join(repoRoot, rel))) {
22
+ return { framework: NAME, files: [rel] };
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function ensureUseEffectImport(source) {
29
+ if (/from\s+['"]react['"]/.test(source)) {
30
+ if (/useEffect/.test(source)) return source;
31
+ return source.replace(/import\s+([^;]*?)from\s+(['"])react\2/, (m, names, q) => {
32
+ if (/\{[^}]*\}/.test(names)) {
33
+ return m.replace(/\{([^}]*)\}/, (mm, inner) => `{${inner.trim()}, useEffect }`);
34
+ }
35
+ return `${m.trim()}\nimport { useEffect } from 'react'`;
36
+ });
37
+ }
38
+ return `import { useEffect } from 'react';\n` + source;
39
+ }
40
+
41
+ function buildHook(injection) {
42
+ const pkLine = injection.publishableKey
43
+ ? ` s.setAttribute('data-gurulu-publishable-key', ${JSON.stringify(injection.publishableKey)});\n`
44
+ : '';
45
+ return (
46
+ ` ${MARKER}\n` +
47
+ ` useEffect(() => {\n` +
48
+ ` if (typeof window === 'undefined') return;\n` +
49
+ ` if (document.querySelector('script[data-gurulu-install]')) return;\n` +
50
+ ` const s = document.createElement('script');\n` +
51
+ ` s.src = ${JSON.stringify(injection.scriptSrc)};\n` +
52
+ ` s.async = true;\n` +
53
+ ` s.setAttribute('data-gurulu-install', '1');\n` +
54
+ ` s.setAttribute('data-gurulu-site-id', ${JSON.stringify(injection.siteId)});\n` +
55
+ ` s.setAttribute('data-gurulu-tenant-id', ${JSON.stringify(injection.tenantId)});\n` +
56
+ pkLine +
57
+ ` document.head.appendChild(s);\n` +
58
+ ` }, []);\n`
59
+ );
60
+ }
61
+
62
+ function injectHook(source, injection) {
63
+ if (source.includes(MARKER)) return source;
64
+ const hook = buildHook(injection);
65
+ // Match: function MyApp({ Component, pageProps }) { ... — insert hook after `{`.
66
+ const fnMatch = source.match(/function\s+\w+\s*\([^)]*\)\s*\{/);
67
+ if (fnMatch) {
68
+ const end = fnMatch.index + fnMatch[0].length;
69
+ return source.slice(0, end) + '\n' + hook + source.slice(end);
70
+ }
71
+ // Arrow form: `const MyApp = ({ Component, pageProps }) => {`
72
+ const arrowMatch = source.match(/const\s+\w+\s*=\s*\([^)]*\)\s*=>\s*\{/);
73
+ if (arrowMatch) {
74
+ const end = arrowMatch.index + arrowMatch[0].length;
75
+ return source.slice(0, end) + '\n' + hook + source.slice(end);
76
+ }
77
+ return source;
78
+ }
79
+
80
+ function plan(ctx) {
81
+ const detection = detect(ctx.repoRoot);
82
+ if (!detection) return { changes: [], notes: ['no-_app-found'] };
83
+ const changes = [];
84
+ for (const rel of detection.files) {
85
+ const abs = path.join(ctx.repoRoot, rel);
86
+ const before = fs.readFileSync(abs, 'utf8');
87
+ let after = injectHook(before, ctx.injection);
88
+ if (after !== before) after = ensureUseEffectImport(after);
89
+ if (after !== before) {
90
+ changes.push({ relPath: rel, before, after, reason: 'inject-useeffect' });
91
+ }
92
+ }
93
+ return { changes, notes: [] };
94
+ }
95
+
96
+ module.exports = { name: NAME, detect, plan };
@@ -0,0 +1,74 @@
1
+ // remix.patch.cjs — Phase 18.7 A1
2
+ //
3
+ // Detects a Remix app by the presence of `app/root.tsx` (or `.jsx`) and
4
+ // injects a plain <script> tag before the `<Scripts />` component inside
5
+ // the root layout's <body>. Remix's <Scripts /> handles hydration; we keep
6
+ // our tracker as a vanilla async <script> so it's independent of the React
7
+ // runtime.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const NAME = 'remix';
13
+ const CANDIDATES = [
14
+ 'app/root.tsx',
15
+ 'app/root.jsx',
16
+ ];
17
+
18
+ const MARKER = 'data-gurulu-install="1"';
19
+
20
+ function detect(repoRoot) {
21
+ for (const rel of CANDIDATES) {
22
+ if (fs.existsSync(path.join(repoRoot, rel))) {
23
+ return { framework: NAME, files: [rel] };
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function buildScriptTag(injection) {
30
+ const pkAttr = injection.publishableKey
31
+ ? ` data-gurulu-publishable-key=${JSON.stringify(injection.publishableKey)}\n`
32
+ : '';
33
+ return (
34
+ ` <script\n` +
35
+ ` src=${JSON.stringify(injection.scriptSrc)}\n` +
36
+ ` data-gurulu-site-id=${JSON.stringify(injection.siteId)}\n` +
37
+ ` data-gurulu-tenant-id=${JSON.stringify(injection.tenantId)}\n` +
38
+ pkAttr +
39
+ ` ${MARKER}\n` +
40
+ ` async\n` +
41
+ ` />\n`
42
+ );
43
+ }
44
+
45
+ function injectScriptTag(source, injection) {
46
+ if (source.includes(MARKER)) return source;
47
+ const tag = buildScriptTag(injection);
48
+ // Prefer to insert just before <Scripts /> (self-closing or open tag).
49
+ if (/<Scripts\s*\/?>/.test(source)) {
50
+ return source.replace(/(<Scripts\s*\/?>)/, `${tag} $1`);
51
+ }
52
+ // Fallback: before </body>.
53
+ if (/<\/body>/.test(source)) {
54
+ return source.replace(/<\/body>/, `${tag} </body>`);
55
+ }
56
+ return source;
57
+ }
58
+
59
+ function plan(ctx) {
60
+ const detection = detect(ctx.repoRoot);
61
+ if (!detection) return { changes: [], notes: ['no-remix-root'] };
62
+ const changes = [];
63
+ for (const rel of detection.files) {
64
+ const abs = path.join(ctx.repoRoot, rel);
65
+ const before = fs.readFileSync(abs, 'utf8');
66
+ const after = injectScriptTag(before, ctx.injection);
67
+ if (after !== before) {
68
+ changes.push({ relPath: rel, before, after, reason: 'inject-script-tag' });
69
+ }
70
+ }
71
+ return { changes, notes: [] };
72
+ }
73
+
74
+ module.exports = { name: NAME, detect, plan };