@gurulu/cli 0.4.2 → 0.4.4

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.
@@ -75,6 +75,10 @@ function detectFramework(projectDir) {
75
75
  return 'react-vite';
76
76
  if (deps['react-scripts'])
77
77
  return 'react-cra';
78
+ if (deps['fastify'])
79
+ return 'fastify';
80
+ if (deps['hono'])
81
+ return 'hono';
78
82
  if (deps['express'])
79
83
  return 'express';
80
84
  return 'unknown';
@@ -253,6 +257,60 @@ export function guruluMiddleware(req: Request, res: Response, next: NextFunction
253
257
  }`,
254
258
  instruction: 'Add app.use(guruluMiddleware) in your Express app',
255
259
  };
260
+ case 'fastify':
261
+ return {
262
+ file: 'src/gurulu.ts',
263
+ code: `// Gurulu.io Server Analytics — Fastify plugin
264
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
265
+
266
+ const SITE_ID = '${siteId}';
267
+ const TOKEN = '${token}';
268
+
269
+ export async function guruluPlugin(fastify: FastifyInstance) {
270
+ fastify.addHook('onRequest', async (req: FastifyRequest, _reply: FastifyReply) => {
271
+ fetch('https://ingest.gurulu.io/api/events', {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
274
+ body: JSON.stringify({
275
+ site_id: SITE_ID,
276
+ event: 'pageview',
277
+ url: req.url,
278
+ referrer: (req.headers.referer as string) || '',
279
+ user_agent: (req.headers['user-agent'] as string) || '',
280
+ ip: req.ip,
281
+ timestamp: new Date().toISOString(),
282
+ }),
283
+ }).catch(() => {});
284
+ });
285
+ }`,
286
+ instruction: 'Register with fastify.register(guruluPlugin) in your Fastify app',
287
+ };
288
+ case 'hono':
289
+ return {
290
+ file: 'src/gurulu.ts',
291
+ code: `// Gurulu.io Server Analytics — Hono middleware
292
+ import type { MiddlewareHandler } from 'hono';
293
+
294
+ const SITE_ID = '${siteId}';
295
+ const TOKEN = '${token}';
296
+
297
+ export const guruluMiddleware: MiddlewareHandler = async (c, next) => {
298
+ fetch('https://ingest.gurulu.io/api/events', {
299
+ method: 'POST',
300
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
301
+ body: JSON.stringify({
302
+ site_id: SITE_ID,
303
+ event: 'pageview',
304
+ url: c.req.url,
305
+ referrer: c.req.header('referer') || '',
306
+ user_agent: c.req.header('user-agent') || '',
307
+ timestamp: new Date().toISOString(),
308
+ }),
309
+ }).catch(() => {});
310
+ await next();
311
+ };`,
312
+ instruction: 'Add app.use(guruluMiddleware) in your Hono app',
313
+ };
256
314
  case 'nestjs':
257
315
  return {
258
316
  file: 'src/gurulu.middleware.ts',
@@ -372,6 +430,8 @@ function getFrameworkDisplayName(fw) {
372
430
  'sveltekit': 'SvelteKit',
373
431
  'astro': 'Astro',
374
432
  'express': 'Express',
433
+ 'fastify': 'Fastify',
434
+ 'hono': 'Hono',
375
435
  'nestjs': 'NestJS',
376
436
  'html': 'HTML',
377
437
  'react-native': 'React Native',
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ const status_1 = require("./commands/status");
18
18
  const doctor_1 = require("./commands/doctor");
19
19
  const add_server_1 = require("./commands/add-server");
20
20
  const install_1 = require("./commands/install");
21
+ const upgrade_1 = require("./commands/upgrade");
21
22
  const warehouse_1 = require("./commands/warehouse");
22
23
  // Phase 19.5 W2 — Read-surface subcommands
23
24
  const audiences_1 = require("./commands/audiences");
@@ -41,9 +42,26 @@ const sourcemap_1 = require("./commands/sourcemap");
41
42
  const db_1 = require("./commands/db");
42
43
  // Sprint B Group VII B14 — live event stream
43
44
  const watch_1 = require("./commands/watch");
45
+ // Sprint E SE-A / SE-B — analytics + observability CLI surface
46
+ const attribution_1 = require("./commands/attribution");
47
+ const releases_1 = require("./commands/releases");
48
+ const skad_1 = require("./commands/skad");
49
+ const errors_1 = require("./commands/errors");
50
+ const replay_1 = require("./commands/replay");
51
+ const conversion_paths_1 = require("./commands/conversion-paths");
52
+ // CLI@0.4.4 — consent + secrets management
53
+ const consent_1 = require("./commands/consent");
54
+ const secrets_1 = require("./commands/secrets");
44
55
  (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
45
56
  .scriptName('gurulu')
46
57
  .option('profile', { type: 'string', describe: 'Use a specific profile (default: personal)' })
58
+ // FA-1 P0-5 — GDPR consent. Also honored via env vars: GURULU_TELEMETRY=off
59
+ // (or 0/false/no) and DO_NOT_TRACK=1, plus persisted choice in
60
+ // ~/.gurulu/config.json (`telemetry: false`).
61
+ .option('no-telemetry', {
62
+ type: 'boolean',
63
+ describe: 'Disable anonymous install telemetry for this run',
64
+ })
47
65
  .command('init', 'Set up Gurulu analytics in your project', (y) => y
48
66
  .option('site-id', { type: 'string', describe: 'Site ID' })
49
67
  .option('token', { type: 'string', describe: 'Site token' })
@@ -225,6 +243,10 @@ const watch_1 = require("./commands/watch");
225
243
  .option('skip-env', { type: 'boolean', describe: 'Skip .env file merge' })
226
244
  .option('yes', { type: 'boolean', alias: 'y', describe: 'Non-interactive (assume yes)' })
227
245
  .option('ingest-url', { type: 'string', describe: 'Override ingest base URL' })
246
+ // FA-1 P0-2 — self-hosted tracker tag override. When set, install.ts
247
+ // forwards this to the agentic-install script so the rendered script
248
+ // tag points at the customer's own asset host instead of gurulu.io/t.js.
249
+ .option('script-src', { type: 'string', describe: 'Override tracker tag <script src> URL (self-hosted)' })
228
250
  .option('verify', { type: 'boolean', default: false, describe: 'Live smoke test after install (requires playwright-core)' })
229
251
  .option('skip-intent', { type: 'boolean', describe: 'Skip install-time intent discovery' })
230
252
  .option('intent-dry-run', { type: 'boolean', describe: 'Show intent proposal without pre-seeding' })
@@ -261,6 +283,7 @@ const watch_1 = require("./commands/watch");
261
283
  skipEnv: args['skip-env'],
262
284
  yes: args.yes,
263
285
  ingestUrl: args['ingest-url'],
286
+ scriptSrc: args['script-src'],
264
287
  verify: args['skip-verify'] ? false : args.verify,
265
288
  profile: args.profile,
266
289
  skipIntent: args['skip-intent'],
@@ -270,6 +293,20 @@ const watch_1 = require("./commands/watch");
270
293
  autoProperties: args['auto-properties'],
271
294
  });
272
295
  })
296
+ .command(
297
+ // FA-1 P1-4 — `gurulu upgrade` bumps installed @gurulu/* packages to the
298
+ // latest version published on npm. Defaults to @gurulu/web; `--all` bumps
299
+ // cli + node + web in one pass.
300
+ 'upgrade [path]', 'Upgrade installed Gurulu packages to the latest npm version', (y) => y
301
+ .positional('path', { type: 'string', describe: 'Target project path (default: cwd)' })
302
+ .option('package', { type: 'string', describe: 'Package to upgrade (default: @gurulu/web)' })
303
+ .option('all', { type: 'boolean', describe: 'Upgrade @gurulu/cli + @gurulu/node + @gurulu/web' })
304
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be upgraded' }), (args) => (0, upgrade_1.upgradeCommand)({
305
+ path: args.path,
306
+ package: args.package,
307
+ all: args.all,
308
+ dryRun: args['dry-run'],
309
+ }))
273
310
  .command('warehouse <action>', 'Warehouse exports (BigQuery)', (y) => y
274
311
  .positional('action', { type: 'string', describe: 'Action: export' })
275
312
  .option('tenant', { type: 'string', describe: 'Tenant id (forward-compat)' })
@@ -458,18 +495,49 @@ const watch_1 = require("./commands/watch");
458
495
  json: args.json,
459
496
  profile: args.profile,
460
497
  }))
461
- .command('identity <action> [sub]', 'View identity state (decay stats, transfers list, cdc-sources list)', (y) => y
462
- .positional('action', { type: 'string', describe: 'decay | transfers | cdc-sources' })
498
+ .command('identity <action> [sub]', 'Identity state + writes (decay stats | transfers list | cdc-sources list | identify | alias | merge | bulk)', (y) => y
499
+ .positional('action', {
500
+ type: 'string',
501
+ describe: 'decay | transfers | cdc-sources | identify | alias | merge | bulk',
502
+ })
463
503
  .positional('sub', { type: 'string', describe: 'Subaction (stats, list)' })
464
504
  .option('direction', { type: 'string', describe: 'outbound | inbound | all' })
465
505
  .option('status', { type: 'string', describe: 'Filter by transfer status' })
466
506
  .option('limit', { type: 'number', describe: 'Max rows' })
507
+ // identify / alias / merge
508
+ .option('site', { type: 'string', describe: 'Site ID (write actions)' })
509
+ .option('user-id', { type: 'string', describe: 'User ID (identify)' })
510
+ .option('email', { type: 'string', describe: 'Email (identify)' })
511
+ .option('phone', { type: 'string', describe: 'Phone (identify)' })
512
+ .option('traits', { type: 'string', describe: 'Traits as JSON object (identify)' })
513
+ .option('previous-user-id', { type: 'string', describe: 'Anonymous/old user id (alias)' })
514
+ .option('new-user-id', { type: 'string', describe: 'New canonical user id (alias)' })
515
+ .option('canonical-id', { type: 'string', describe: 'Winner canonical profile id (merge)' })
516
+ .option('duplicate-id', { type: 'string', describe: 'Loser canonical profile id (merge)' })
517
+ // bulk
518
+ .option('file', { type: 'string', describe: 'Input file path (bulk)' })
519
+ .option('format', { type: 'string', describe: 'csv | json (bulk; auto-detected from extension)' })
520
+ .option('resume-from', { type: 'number', describe: 'Skip first N records (bulk)' })
521
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
467
522
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, identity_1.identityCommand)({
468
523
  action: args.action,
469
524
  sub: args.sub,
470
525
  direction: args.direction,
471
526
  status: args.status,
472
527
  limit: args.limit,
528
+ site: args.site,
529
+ userId: args['user-id'],
530
+ email: args.email,
531
+ phone: args.phone,
532
+ traits: args.traits,
533
+ previousUserId: args['previous-user-id'],
534
+ newUserId: args['new-user-id'],
535
+ canonicalId: args['canonical-id'],
536
+ duplicateId: args['duplicate-id'],
537
+ file: args.file,
538
+ format: args.format,
539
+ resumeFrom: args['resume-from'],
540
+ yes: args.yes,
473
541
  json: args.json,
474
542
  profile: args.profile,
475
543
  }))
@@ -571,6 +639,161 @@ const watch_1 = require("./commands/watch");
571
639
  tail: args.tail,
572
640
  json: args.json,
573
641
  profile: args.profile,
642
+ }))
643
+ // ── Sprint E SE-A/B — analytics + observability CLI surface ──────────
644
+ .command('attribution <action>', 'Attribution analytics (report, compare, channels)', (y) => y
645
+ .positional('action', { type: 'string', describe: 'report | compare | channels' })
646
+ .option('site', { type: 'string', describe: 'Site ID' })
647
+ .option('from', { type: 'string', describe: 'ISO date (inclusive)' })
648
+ .option('to', { type: 'string', describe: 'ISO date (inclusive)' })
649
+ .option('range', { type: 'string', describe: '7d | 30d | 90d shorthand' })
650
+ .option('model', { type: 'string', describe: 'last-touch | first-touch | linear | time-decay | position-based | data-driven' })
651
+ .option('baseline-model', { type: 'string', describe: 'Baseline model id (compare)' })
652
+ .option('variant-model', { type: 'string', describe: 'Variant model id (compare)' })
653
+ .option('conversion-event', { type: 'string', describe: 'Conversion event (default $purchase)' })
654
+ .option('format', { type: 'string', describe: 'json | table' })
655
+ .option('json', { type: 'boolean', describe: 'JSON output (default)' }), (args) => (0, attribution_1.attributionCommand)({
656
+ action: args.action,
657
+ site: args.site,
658
+ from: args.from,
659
+ to: args.to,
660
+ range: args.range,
661
+ model: args.model,
662
+ baselineModel: args['baseline-model'],
663
+ variantModel: args['variant-model'],
664
+ conversionEvent: args['conversion-event'],
665
+ format: args.format,
666
+ json: args.json,
667
+ profile: args.profile,
668
+ }))
669
+ .command('releases <action>', 'Release health (list)', (y) => y
670
+ .positional('action', { type: 'string', describe: 'list' })
671
+ .option('site', { type: 'string', describe: 'Site ID' })
672
+ .option('range', { type: 'string', describe: '24h | 7d | 30d (default 7d)' })
673
+ .option('environment', { type: 'string', describe: 'Optional environment filter' })
674
+ .option('limit', { type: 'number', describe: 'Max rows (default 20)' })
675
+ .option('format', { type: 'string', describe: 'json | table' })
676
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, releases_1.releasesCommand)({
677
+ action: args.action,
678
+ site: args.site,
679
+ range: args.range,
680
+ environment: args.environment,
681
+ limit: args.limit,
682
+ format: args.format,
683
+ json: args.json,
684
+ profile: args.profile,
685
+ }))
686
+ .command('skad <action>', 'Apple SKAdNetwork postbacks (postbacks)', (y) => y
687
+ .positional('action', { type: 'string', describe: 'postbacks' })
688
+ .option('site', { type: 'string', describe: 'Site ID' })
689
+ .option('from', { type: 'string', describe: 'ISO ts or 7d/24h' })
690
+ .option('to', { type: 'string', describe: 'ISO ts (default now)' })
691
+ .option('goal', { type: 'string', describe: 'Filter by resolved goal' })
692
+ .option('limit', { type: 'number', describe: 'Max rows (1..500)' })
693
+ .option('format', { type: 'string', describe: 'json | table' })
694
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, skad_1.skadCommand)({
695
+ action: args.action,
696
+ site: args.site,
697
+ from: args.from,
698
+ to: args.to,
699
+ goal: args.goal,
700
+ limit: args.limit,
701
+ format: args.format,
702
+ json: args.json,
703
+ profile: args.profile,
704
+ }))
705
+ .command('errors <action>', 'Error tracking (list, detail, resolve, mute)', (y) => y
706
+ .positional('action', { type: 'string', describe: 'list | detail | resolve | mute | upload-sourcemap | upload-native-symbols' })
707
+ .option('site', { type: 'string', describe: 'Site ID' })
708
+ .option('fingerprint', { type: 'string', describe: 'Error group fingerprint' })
709
+ .option('level', { type: 'string', describe: 'error | warning | info' })
710
+ .option('resolved', { type: 'string', describe: 'true | false' })
711
+ .option('environment', { type: 'string', describe: 'Environment filter' })
712
+ .option('release-id', { type: 'string', describe: 'Filter to a release' })
713
+ .option('limit', { type: 'number', describe: 'Max rows' })
714
+ .option('duration', { type: 'string', describe: 'Mute duration (e.g. 24h, 7d)' })
715
+ .option('format', { type: 'string', describe: 'json | table' })
716
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
717
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, errors_1.errorsCommand)({
718
+ action: args.action,
719
+ site: args.site,
720
+ fingerprint: args.fingerprint,
721
+ level: args.level,
722
+ resolved: args.resolved,
723
+ environment: args.environment,
724
+ releaseId: args['release-id'],
725
+ limit: args.limit,
726
+ duration: args.duration,
727
+ format: args.format,
728
+ yes: args.yes,
729
+ json: args.json,
730
+ profile: args.profile,
731
+ }))
732
+ .command('replay <action>', 'Session replay (list, get)', (y) => y
733
+ .positional('action', { type: 'string', describe: 'list | get' })
734
+ .option('site', { type: 'string', describe: 'Site ID' })
735
+ .option('session-id', { type: 'string', describe: 'Session id (for get)' })
736
+ .option('range', { type: 'string', describe: '7d | 30d (default 7d)' })
737
+ .option('has-error', { type: 'boolean', describe: 'Filter to sessions with errors' })
738
+ .option('limit', { type: 'number', describe: 'Max rows' })
739
+ .option('format', { type: 'string', describe: 'json | table' })
740
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, replay_1.replayCommand)({
741
+ action: args.action,
742
+ site: args.site,
743
+ sessionId: args['session-id'],
744
+ range: args.range,
745
+ hasError: args['has-error'],
746
+ limit: args.limit,
747
+ format: args.format,
748
+ json: args.json,
749
+ profile: args.profile,
750
+ }))
751
+ .command('conversion-paths <action>', 'Conversion path analytics (list)', (y) => y
752
+ .positional('action', { type: 'string', describe: 'list' })
753
+ .option('site', { type: 'string', describe: 'Site ID' })
754
+ .option('from', { type: 'string', describe: 'ISO date' })
755
+ .option('to', { type: 'string', describe: 'ISO date' })
756
+ .option('range', { type: 'string', describe: '7d | 30d | 90d' })
757
+ .option('conversion-event', { type: 'string', describe: 'Conversion event' })
758
+ .option('limit', { type: 'number', describe: 'Max rows' })
759
+ .option('format', { type: 'string', describe: 'json | table' })
760
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, conversion_paths_1.conversionPathsCommand)({
761
+ action: args.action,
762
+ site: args.site,
763
+ from: args.from,
764
+ to: args.to,
765
+ range: args.range,
766
+ conversionEvent: args['conversion-event'],
767
+ limit: args.limit,
768
+ format: args.format,
769
+ json: args.json,
770
+ profile: args.profile,
771
+ }))
772
+ // ── CLI@0.4.4 — consent (per-user GDPR scopes) ───────────────────────
773
+ .command('consent <action>', 'Manage per-user consent (set, get, revoke, check)', (y) => y
774
+ .positional('action', { type: 'string', describe: 'set | get | revoke | check' })
775
+ .option('user-id', { type: 'string', describe: 'User ID' })
776
+ .option('scope', { type: 'string', describe: 'Consent scope (e.g. marketing_external, analytics)' })
777
+ .option('state', { type: 'string', choices: ['granted', 'denied'], describe: 'Consent state (for set)' })
778
+ .option('destination', { type: 'string', describe: 'Destination key (for check, e.g. capi)' })
779
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, consent_1.consentCommand)({
780
+ action: args.action,
781
+ userId: args['user-id'],
782
+ scope: args.scope,
783
+ state: args.state,
784
+ destination: args.destination,
785
+ json: args.json,
786
+ profile: args.profile,
787
+ }))
788
+ // ── CLI@0.4.4 — secrets vault (list, rotate) ─────────────────────────
789
+ .command('secrets <action>', 'Tenant credential vault (list, rotate)', (y) => y
790
+ .positional('action', { type: 'string', describe: 'list | rotate' })
791
+ .option('key', { type: 'string', describe: 'Secret key name (for rotate)' })
792
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, secrets_1.secretsCommand)({
793
+ action: args.action,
794
+ key: args.key,
795
+ json: args.json,
796
+ profile: args.profile,
574
797
  }))
575
798
  .demandCommand(1, 'Run gurulu --help for available commands')
576
799
  .strict()
@@ -0,0 +1,14 @@
1
+ /**
2
+ * CLI@0.4.4 — sensitive-arg redaction helper.
3
+ *
4
+ * Used before any debug/log/telemetry path that might serialize CLI args.
5
+ * Mask any key whose name contains password / token / secret / api-key / authorization
6
+ * (case-insensitive). Returns a shallow clone with offending values replaced
7
+ * by `***REDACTED***` (or `undefined` when the original was falsy, so the log
8
+ * does not imply a value was present).
9
+ */
10
+ export declare function redactSensitiveArgs<T extends Record<string, unknown>>(args: T): T;
11
+ /**
12
+ * Convenience: produce a JSON string with sensitive fields redacted.
13
+ */
14
+ export declare function safeStringifyArgs(args: Record<string, unknown>): string;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * CLI@0.4.4 — sensitive-arg redaction helper.
4
+ *
5
+ * Used before any debug/log/telemetry path that might serialize CLI args.
6
+ * Mask any key whose name contains password / token / secret / api-key / authorization
7
+ * (case-insensitive). Returns a shallow clone with offending values replaced
8
+ * by `***REDACTED***` (or `undefined` when the original was falsy, so the log
9
+ * does not imply a value was present).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.redactSensitiveArgs = redactSensitiveArgs;
13
+ exports.safeStringifyArgs = safeStringifyArgs;
14
+ const SENSITIVE_KEY_RX = /(password|passwd|pwd|token|secret|api[_-]?key|authorization|auth[_-]?token|access[_-]?key|private[_-]?key|client[_-]?secret|session[_-]?token)/i;
15
+ function redactSensitiveArgs(args) {
16
+ if (!args || typeof args !== 'object')
17
+ return args;
18
+ const out = Array.isArray(args)
19
+ ? [...args]
20
+ : { ...args };
21
+ for (const k of Object.keys(out)) {
22
+ const v = out[k];
23
+ if (SENSITIVE_KEY_RX.test(k)) {
24
+ out[k] = v ? '***REDACTED***' : undefined;
25
+ continue;
26
+ }
27
+ // Recurse into nested objects (but not class instances / Buffers / Dates).
28
+ if (v &&
29
+ typeof v === 'object' &&
30
+ !Buffer.isBuffer(v) &&
31
+ !(v instanceof Date) &&
32
+ (Object.getPrototypeOf(v) === Object.prototype || Array.isArray(v))) {
33
+ out[k] = redactSensitiveArgs(v);
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Convenience: produce a JSON string with sensitive fields redacted.
40
+ */
41
+ function safeStringifyArgs(args) {
42
+ try {
43
+ return JSON.stringify(redactSensitiveArgs(args));
44
+ }
45
+ catch {
46
+ return '[unserializable]';
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gurulu/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"
@@ -37,29 +37,18 @@ async function retry(fn, label) {
37
37
  async function main() {
38
38
  console.log('[bootstrap] Starting runtime schema bootstrap...');
39
39
 
40
- // Step 1/3: Prisma migrate deploy
40
+ // Step 1/2: Prisma migrate deploy — see /scripts/bootstrap-runtime-schema.mjs
41
+ // for the FA-11 #3 / Sprint I-ops Faz 2 rationale.
41
42
  if (process.env.POSTGRES_AUTO_MIGRATE !== 'false') {
42
- console.log('\n[bootstrap] Step 1/3: Prisma migrate deploy');
43
+ console.log('\n[bootstrap] Step 1/2: Prisma migrate deploy');
43
44
  await retry(async () => {
44
- try {
45
- exec('npx prisma migrate deploy');
46
- } catch (err) {
47
- if (
48
- process.env.PRISMA_MIGRATE_FALLBACK_DB_PUSH !== 'false' &&
49
- err.message?.includes('P3005')
50
- ) {
51
- console.log('[bootstrap] P3005 detected, falling back to db push...');
52
- exec('npx prisma db push --accept-data-loss');
53
- } else {
54
- throw err;
55
- }
56
- }
45
+ exec('npx prisma migrate deploy');
57
46
  }, 'migrate deploy');
58
47
  }
59
48
 
60
- // Step 2/3: SQL hooks
49
+ // Step 2/2: SQL hooks
61
50
  if (process.env.POSTGRES_AUTO_MIGRATE !== 'false') {
62
- console.log('\n[bootstrap] Step 2/3: SQL hooks');
51
+ console.log('\n[bootstrap] Step 2/2: SQL hooks');
63
52
  const hooksDir = join(process.cwd(), 'prisma', 'sql-hooks');
64
53
 
65
54
  if (existsSync(hooksDir)) {
@@ -77,14 +66,7 @@ async function main() {
77
66
  console.log('[bootstrap] No sql-hooks directory found, skipping.');
78
67
  }
79
68
  }
80
-
81
- // Step 3/3: db push (catch drift)
82
- if (process.env.POSTGRES_AUTO_MIGRATE !== 'false') {
83
- console.log('\n[bootstrap] Step 3/3: Prisma db push (drift catch)');
84
- await retry(async () => {
85
- exec('npx prisma db push --accept-data-loss');
86
- }, 'db push');
87
- }
69
+ // Step 3/3 (db push drift catch) removed — drift is now a hard fail.
88
70
 
89
71
  // ClickHouse migration
90
72
  if (process.env.CLICKHOUSE_AUTO_MIGRATE !== 'false') {
@@ -380,16 +380,44 @@ function generateServerMap(scan /*, opts*/) {
380
380
  };
381
381
  }
382
382
 
383
+ /**
384
+ * Sprint E1.3b — singularize plural table/model names to canonical singular.
385
+ * The scan layer normalises mutations as `{ op: '<model>.<operation>' }` so we
386
+ * derive the operation from `m.op` when `m.operation` isn't present, and
387
+ * collapse common irregular plurals to their singular form so e.g. raw-SQL
388
+ * `INSERT INTO orders` and a Drizzle `pgTable('orders', ...)` both fold into
389
+ * the same `order` row in db-map.
390
+ */
391
+ const SINGULARIZE_IRREGULARS = {
392
+ users: 'user',
393
+ orders: 'order',
394
+ items: 'item',
395
+ };
396
+ function singularizeModelName(raw) {
397
+ const lower = String(raw || '').toLowerCase();
398
+ if (!lower) return lower;
399
+ if (Object.prototype.hasOwnProperty.call(SINGULARIZE_IRREGULARS, lower)) {
400
+ return SINGULARIZE_IRREGULARS[lower];
401
+ }
402
+ return lower;
403
+ }
404
+
383
405
  /** db-map.json — table → event emitter mapping from detected mutations. */
384
406
  function generateDbMap(scan, opts) {
385
407
  const byModel = new Map();
386
408
  for (const m of scan.mutations || []) {
387
- const name = String(m.model || '').trim();
388
- if (!name) continue;
389
- const op = cdcOp(m.operation);
409
+ const rawName = String(m.model || '').trim();
410
+ if (!rawName) continue;
411
+ const name = singularizeModelName(rawName);
412
+ // Sprint E1.3b — `scan.mutations` from gurulu-scan.lib.cjs normalises the
413
+ // operation as `m.op = "<model>.<operation>"` and drops `m.operation`.
414
+ // Derive the operation verb from whichever field is populated.
415
+ const operation =
416
+ m.operation || (typeof m.op === 'string' ? m.op.split('.').pop() : null);
417
+ const op = cdcOp(operation);
390
418
  if (!op) continue;
391
419
  if (!byModel.has(name)) byModel.set(name, new Set());
392
- const key = `${op}::${inferEventName(name, m.operation)}`;
420
+ const key = `${op}::${inferEventName(name, operation)}`;
393
421
  byModel.get(name).add(key);
394
422
  }
395
423
 
@@ -278,10 +278,13 @@ async function runPatchMode(repoRoot, args) {
278
278
  process.exit(1);
279
279
  }
280
280
  const framework = args.framework || 'auto';
281
- const { patcher, detection, error } = patches.resolvePatcher(repoRoot, framework);
281
+ const { patcher, detection, error, unsupportedFramework } = patches.resolvePatcher(repoRoot, framework);
282
282
  if (error) {
283
283
  process.stderr.write(`Error: ${error}\n`);
284
- process.exit(1);
284
+ // Sprint E1.6 — exit 6 specifically for mobile / not-yet-supported
285
+ // frameworks so the wrapping CLI can surface docs links instead of a
286
+ // generic install failure.
287
+ process.exit(unsupportedFramework ? 6 : 1);
285
288
  }
286
289
  if (!patcher) {
287
290
  process.stderr.write('No supported framework detected in ' + repoRoot + '\n');
@@ -412,6 +415,11 @@ async function runPatchMode(repoRoot, args) {
412
415
  `Auto-instrument failed (${reason}); rollback threw: ${err.stack || err.message || err}\n`,
413
416
  );
414
417
  }
418
+ // Sprint E1.1 — exit code 5 signals "patches rolled back" so install.ts
419
+ // can skip the npm-install / .env-merge / ingest-ping steps and surface
420
+ // the rollback to the user. The wrapping CLI converts this into a
421
+ // partially-installed state with `summary.rolledBack=true`.
422
+ process.exit(5);
415
423
  }
416
424
  if (args.autoInstrument) {
417
425
  const intentPath = args['intent-result'];