@reticlehq/init 3.2.0 → 3.4.0

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 (93) hide show
  1. package/dist/detect/capabilities.d.ts +4 -1
  2. package/dist/detect/capabilities.js +45 -9
  3. package/dist/detect/detect.d.ts +58 -4
  4. package/dist/detect/detect.js +117 -7
  5. package/dist/detect/dev-script.d.ts +26 -2
  6. package/dist/detect/dev-script.js +80 -7
  7. package/dist/detect/non-js-project.d.ts +1 -1
  8. package/dist/detect/non-js-project.js +6 -1
  9. package/dist/detect/workspace-apps.d.ts +21 -0
  10. package/dist/detect/workspace-apps.js +111 -12
  11. package/dist/detect/workspace-redirect.d.ts +2 -1
  12. package/dist/detect/workspace-redirect.js +28 -2
  13. package/dist/diagnose/containerised-dev-server.d.ts +12 -4
  14. package/dist/diagnose/containerised-dev-server.js +36 -4
  15. package/dist/diagnose/csp-check.d.ts +12 -6
  16. package/dist/diagnose/csp-check.js +61 -13
  17. package/dist/diagnose/csp-doctor.d.ts +33 -2
  18. package/dist/diagnose/csp-doctor.js +103 -6
  19. package/dist/diagnose/csp-step.d.ts +27 -0
  20. package/dist/diagnose/csp-step.js +86 -0
  21. package/dist/diagnose/desktop-doctor.d.ts +1 -1
  22. package/dist/diagnose/desktop-doctor.js +8 -2
  23. package/dist/diagnose/install-hint.d.ts +9 -1
  24. package/dist/diagnose/install-hint.js +17 -7
  25. package/dist/diagnose/install-retries.d.ts +18 -2
  26. package/dist/diagnose/install-retries.js +50 -3
  27. package/dist/diagnose/terminal-wrap.d.ts +18 -0
  28. package/dist/diagnose/terminal-wrap.js +63 -0
  29. package/dist/host.d.ts +6 -0
  30. package/dist/host.js +3 -0
  31. package/dist/index.d.ts +8 -3
  32. package/dist/index.js +8 -3
  33. package/dist/no-package-json.d.ts +10 -0
  34. package/dist/no-package-json.js +121 -0
  35. package/dist/node-io.d.ts +9 -5
  36. package/dist/node-io.js +25 -7
  37. package/dist/patch/angular.d.ts +68 -0
  38. package/dist/patch/angular.js +216 -0
  39. package/dist/patch/astro-patch.d.ts +48 -13
  40. package/dist/patch/astro-patch.js +151 -43
  41. package/dist/patch/cra.d.ts +0 -2
  42. package/dist/patch/cra.js +0 -2
  43. package/dist/patch/electron-patch.d.ts +10 -1
  44. package/dist/patch/electron-patch.js +79 -27
  45. package/dist/patch/electron-vite-patch.js +1 -1
  46. package/dist/patch/nuxt-patch.d.ts +1 -1
  47. package/dist/patch/nuxt-patch.js +2 -2
  48. package/dist/patch/nuxt-snippets.d.ts +44 -0
  49. package/dist/patch/nuxt-snippets.js +135 -0
  50. package/dist/patch/remix.d.ts +24 -0
  51. package/dist/patch/remix.js +59 -0
  52. package/dist/patch/snippets.d.ts +58 -57
  53. package/dist/patch/snippets.js +181 -202
  54. package/dist/patch/static-page.d.ts +39 -0
  55. package/dist/patch/static-page.js +102 -0
  56. package/dist/patch/tanstack-start.d.ts +36 -8
  57. package/dist/patch/tanstack-start.js +118 -29
  58. package/dist/patch/vite-config.d.ts +5 -0
  59. package/dist/patch/vite-config.js +8 -0
  60. package/dist/patch/vite-env-types.d.ts +9 -0
  61. package/dist/patch/vite-env-types.js +19 -0
  62. package/dist/patch/vite-owning-config.d.ts +47 -0
  63. package/dist/patch/vite-owning-config.js +104 -19
  64. package/dist/plan/connect-steps.d.ts +10 -3
  65. package/dist/plan/connect-steps.js +22 -3
  66. package/dist/plan/framework-adapter.d.ts +5 -6
  67. package/dist/plan/framework-adapter.js +105 -11
  68. package/dist/plan/notices.js +4 -3
  69. package/dist/plan/plan-angular.d.ts +6 -0
  70. package/dist/plan/plan-angular.js +89 -0
  71. package/dist/plan/plan-electron-vite.d.ts +6 -3
  72. package/dist/plan/plan-electron-vite.js +39 -8
  73. package/dist/plan/plan-framework.d.ts +9 -47
  74. package/dist/plan/plan-framework.js +114 -323
  75. package/dist/plan/plan-types.d.ts +47 -2
  76. package/dist/plan/plan-vite.d.ts +67 -0
  77. package/dist/plan/plan-vite.js +256 -0
  78. package/dist/plan/plan.js +155 -69
  79. package/dist/plan/port-steps.d.ts +45 -0
  80. package/dist/plan/port-steps.js +119 -0
  81. package/dist/plan/preflight.d.ts +17 -3
  82. package/dist/plan/preflight.js +53 -23
  83. package/dist/plan/stop-hook-step.d.ts +4 -0
  84. package/dist/plan/stop-hook-step.js +49 -0
  85. package/dist/project/agent-rules.js +2 -0
  86. package/dist/register/mcp-clients.d.ts +13 -0
  87. package/dist/register/mcp-clients.js +22 -0
  88. package/dist/register/mcp.d.ts +26 -5
  89. package/dist/register/mcp.js +64 -8
  90. package/dist/run-types.d.ts +10 -0
  91. package/dist/run.js +132 -78
  92. package/package.json +3 -4
  93. package/NOTICE +0 -5
package/dist/run.js CHANGED
@@ -6,23 +6,30 @@
6
6
  import { OnboardingPhase, OnboardingStepStatus } from '@reticlehq/core/telemetry';
7
7
  import { detectMcpClients } from './register/detect-clients.js';
8
8
  import { dirname, join } from 'node:path';
9
- import { CONTAINER_MARKERS } from './diagnose/containerised-dev-server.js';
9
+ import { CONTAINER_MARKERS, runsDevServer } from './diagnose/containerised-dev-server.js';
10
+ import { explainInstallFailure } from './diagnose/install-retries.js';
10
11
  import { CSP_FILES } from './diagnose/csp-doctor.js';
11
- import { preflightRefusal } from './plan/preflight.js';
12
- import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, noPackageJsonMessage, streamlitSetupMessage, } from './detect/non-js-project.js';
13
- import { devCommandFrom } from './detect/dev-script.js';
12
+ import { preflight } from './plan/preflight.js';
13
+ import { devCommandFrom, devScriptBody } from './detect/dev-script.js';
14
14
  import { restartHint, FEEDBACK_HINT } from './diagnose/closing-hint.js';
15
15
  import { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
16
- import { detect, Framework, UiLibrary } from './detect/detect.js';
16
+ import { ANGULAR_WORKSPACE_FILE, detect, FORGE_RENDERER_CONFIGS, Framework, UiLibrary, } from './detect/detect.js';
17
+ import { ANGULAR_PROXY_PATH, angularEntry } from './patch/angular.js';
18
+ import { HTML_INDEX_PATH } from './patch/static-page.js';
19
+ import { initWithoutPackageJson } from './no-package-json.js';
17
20
  import { wasMcpRegistered } from './register/mcp-registered.js';
18
21
  import { pickAstroHost } from './patch/astro-host.js';
19
22
  import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './detect/workspace-apps.js';
20
23
  import { redirectToWorkspaceApp } from './detect/workspace-redirect.js';
24
+ import { enclosingWorkspaceRoot } from './detect/workspace-apps.js';
21
25
  import { isConnectStep } from './plan/connect-steps.js';
22
26
  import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './project/agent-rules.js';
27
+ import { CLAUDE_SETTINGS_PATH } from './plan/stop-hook-step.js';
23
28
  import { CRA_ENV_PATH } from './patch/cra.js';
24
29
  import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './patch/next-patch.js';
25
30
  import { formatGeneratedSource } from './patch/format-generated.js';
31
+ import { tanstackStartConnectPath } from './patch/tanstack-start.js';
32
+ import { installedViteMajor } from './patch/vite-owning-config.js';
26
33
  /** CRA's bundled entry, in the order create-react-app itself generates them. */
27
34
  const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
28
35
  function craEntryOf(io) {
@@ -34,12 +41,12 @@ function craEntryOf(io) {
34
41
  return null;
35
42
  }
36
43
  import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan/plan.js';
37
- import { claudeAvailableProbe, claudeExistsProbe } from './register/mcp.js';
44
+ import { claudeAvailableProbe, claudeHasReticle } from './register/mcp.js';
38
45
  import { reticleDevLocation } from './patch/next-patch.js';
39
46
  import { scanTestids, storeHints, scanStores } from './detect/capabilities.js';
40
- import { CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
47
+ import { CLAUDE_PROJECT_CONFIG, CURSOR_PROJECT_MARKER } from './register/mcp-clients.js';
41
48
  import { deriveProjectId, packageName } from './project/project-id.js';
42
- import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, connectArgWithToken, nuxtPluginPath, staticPageSnippet, djangoMiddlewareSnippet, reticleConfigContent, streamlitPageSnippet, } from './patch/snippets.js';
49
+ import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, nuxtPluginPath, } from './patch/snippets.js';
43
50
  import { NUXT_CONFIG_CANDIDATES } from './patch/nuxt-patch.js';
44
51
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './register/slash-command.js';
45
52
  import { RETICLE_VERSION } from './version.js';
@@ -218,6 +225,17 @@ function agentRootOf(options) {
218
225
  const root = options.agentRoot;
219
226
  return root === undefined || root === options.cwd ? undefined : root;
220
227
  }
228
+ /** What the Angular steps read: the workspace file, the entry it names, and our proxy module. */
229
+ function angularInputs(io) {
230
+ const workspace = io.readFile(ANGULAR_WORKSPACE_FILE);
231
+ const entryPath = angularEntry(workspace);
232
+ const entry = io.readFile(entryPath);
233
+ return {
234
+ angularWorkspace: null === workspace ? null : { path: ANGULAR_WORKSPACE_FILE, source: workspace },
235
+ angularEntry: null === entry ? null : { path: entryPath, source: entry },
236
+ angularProxySource: io.readFile(ANGULAR_PROXY_PATH),
237
+ };
238
+ }
221
239
  function gatherPlanInput(options, io, pkg) {
222
240
  // Stable identity derived from the app's package.json name + root, so it survives port changes.
223
241
  const projectId = deriveProjectId(packageName(pkg), options.cwd);
@@ -233,7 +251,11 @@ function gatherPlanInput(options, io, pkg) {
233
251
  nodeModulesMarkers,
234
252
  };
235
253
  const detection = detect(detectInput);
236
- const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
254
+ // Forge has no `vite.config.*`; its renderer config IS the Vite config the plugin belongs in, and
255
+ // handing it over under that name is what lets the ordinary Vite patcher do the work.
256
+ const vitePath = firstPresent(rootFiles, Framework.ELECTRON_FORGE === detection.framework
257
+ ? FORGE_RENDERER_CONFIGS
258
+ : VITE_CONFIG_CANDIDATES);
237
259
  const viteSource = null === vitePath ? null : io.readFile(vitePath);
238
260
  const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
239
261
  const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
@@ -247,8 +269,8 @@ function gatherPlanInput(options, io, pkg) {
247
269
  // global config file. Only probe when the MCP step is in play.
248
270
  const availableProbe = claudeAvailableProbe();
249
271
  const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
250
- const existsProbe = claudeExistsProbe();
251
- const mcpExists = claudeCli ? io.probe(existsProbe.command, existsProbe.args) : false;
272
+ // Read from Claude's config, never `claude mcp get`: that launches the server — see claudeHasReticle.
273
+ const mcpExists = claudeCli ? claudeHasReticle(io, io.cwd()) : false;
252
274
  // Every MCP client this machine shows evidence of. Conservative and one-directional: we write
253
275
  // into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
254
276
  // does not use them.
@@ -294,9 +316,12 @@ function gatherPlanInput(options, io, pkg) {
294
316
  return {
295
317
  detection,
296
318
  captureBodies: options.captureBodies,
319
+ hooks: options.hooks,
297
320
  cspSources,
298
321
  claudeCli,
299
322
  mcpExists,
323
+ insideClaudeCode: io.host.insideClaudeCode(),
324
+ claudeProjectConfig: options.mcp ? io.readFile(agentFile(CLAUDE_PROJECT_CONFIG)) : undefined,
300
325
  platform: process.platform,
301
326
  detectedClients,
302
327
  cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
@@ -304,7 +329,13 @@ function gatherPlanInput(options, io, pkg) {
304
329
  // repo that containerises it — `frontend/` under a root `docker-compose.yml` is the shape this
305
330
  // came from. Only the first match is reported; the note is the same whichever file found it.
306
331
  ...(() => {
307
- const marker = CONTAINER_MARKERS.find((name) => io.exists(name) || io.exists(join('..', name)));
332
+ // Only a file that RUNS the dev server: a production-only image is the common case, and
333
+ // React Router's template ships one. See runsDevServer.
334
+ const devScript = devScriptBody(pkg);
335
+ const marker = CONTAINER_MARKERS.find((name) => [name, join('..', name)].some((path) => {
336
+ const content = io.readFile(path);
337
+ return null !== content && runsDevServer(name, content, devScript);
338
+ }));
308
339
  return marker === undefined ? {} : { containerMarker: marker };
309
340
  })(),
310
341
  viteConfig,
@@ -316,6 +347,7 @@ function gatherPlanInput(options, io, pkg) {
316
347
  ? { path: layoutRelPath, source: astroLayoutSource }
317
348
  : null,
318
349
  astroEnvDts: io.readFile('src/env.d.ts'),
350
+ astroReticleDev: io.readFile('src/components/ReticleDev.ts'),
319
351
  nextConfigFile,
320
352
  nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
321
353
  nextLayout: layoutPath !== null && layoutSource !== null
@@ -323,7 +355,7 @@ function gatherPlanInput(options, io, pkg) {
323
355
  : null,
324
356
  // Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
325
357
  testids: scanTestids(sourceFiles.map((f) => f.source)),
326
- storeHints: storeHints(dependencyNames(pkg)),
358
+ storeHints: storeHints(dependencyNames(pkg), sourceFiles),
327
359
  foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
328
360
  nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
329
361
  viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
@@ -334,6 +366,7 @@ function gatherPlanInput(options, io, pkg) {
334
366
  nextReticleDevExists: io.exists(devLocation.path),
335
367
  nextReticleDevSource: io.readFile(devLocation.path),
336
368
  svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
369
+ svelteKitHooksSource: io.readFile(SVELTEKIT_HOOKS),
337
370
  reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
338
371
  // The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
339
372
  // override of React Router's default, and everything in it is load-bearing.
@@ -342,8 +375,24 @@ function gatherPlanInput(options, io, pkg) {
342
375
  ? { path: nuxtConfigPath, source: nuxtConfigSource }
343
376
  : null,
344
377
  nuxtHasAppDir,
378
+ viteMajor: installedViteMajor(io, detectInput.pkg),
345
379
  nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
346
- tanstackStartRoot: TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file)),
380
+ nuxtPluginSource: io.readFile(nuxtPluginPath(nuxtHasAppDir)),
381
+ ...(() => {
382
+ const root = TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file));
383
+ return root === undefined
384
+ ? {}
385
+ : {
386
+ tanstackStartRoot: root,
387
+ tanstackStartRootSource: io.readFile(root),
388
+ tanstackStartConnectExists: io.exists(tanstackStartConnectPath(root)),
389
+ tanstackStartConnectSource: io.readFile(tanstackStartConnectPath(root)),
390
+ };
391
+ })(),
392
+ ...(Framework.ANGULAR === detection.framework ? angularInputs(io) : {}),
393
+ ...(Framework.HTML === detection.framework
394
+ ? { htmlIndexSource: io.readFile(HTML_INDEX_PATH) }
395
+ : {}),
347
396
  craEntry: craEntryOf(io),
348
397
  craEnv: io.readFile(CRA_ENV_PATH),
349
398
  pairingToken: io.host.pairingToken(),
@@ -360,6 +409,8 @@ function gatherPlanInput(options, io, pkg) {
360
409
  cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
361
410
  claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
362
411
  cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
412
+ // Read only to MERGE into: the settings file is the user's, and other tools write in it too.
413
+ claudeSettingsContent: true === options.hooks ? io.readFile(agentFile(CLAUDE_SETTINGS_PATH)) : undefined,
363
414
  ...(agentRoot === undefined
364
415
  ? {}
365
416
  : {
@@ -381,7 +432,7 @@ function gatherPlanInput(options, io, pkg) {
381
432
  /** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
382
433
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
383
434
  'installed stops it booting. Run that install, then re-run `reticle init`.';
384
- function report(plan, dryRun, failed, skipped, degraded, io, projectDir, agentRoot,
435
+ function report(plan, dryRun, failed, skipped, degraded, why, io, projectDir, agentRoot,
385
436
  /** The project's own dev command, so the closing line names what a human would type. */
386
437
  devCommand,
387
438
  /**
@@ -393,6 +444,20 @@ devCommand,
393
444
  */
394
445
  continuesToRuntime = false) {
395
446
  io.print(dryRun ? 'reticle init (dry run, no files written)' : 'reticle init');
447
+ /*
448
+ * The escape hatches, named by the command that needs them, at the moment it needs them.
449
+ *
450
+ * `--dry-run`, `--app` and `--no-mcp` all exist and all work, and they were discoverable only
451
+ * from `--help`. A prospective user's agent refused to run this at all — correctly, since it was
452
+ * being asked to let a third-party package edit a build config and register itself with every
453
+ * agent on the machine, and nothing it could see offered a way to look first. It had already
454
+ * decided by the time `--help` would have told it.
455
+ *
456
+ * On the REAL run only. In a dry run the reader has already found the flag.
457
+ */
458
+ if (!dryRun) {
459
+ io.print(' --dry-run previews · --app <dir> picks the app · --no-mcp skips agent registration');
460
+ }
396
461
  // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
397
462
  // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
398
463
  // in `frontend/`, init redirected into it, and the report's `.reticle.json` was true about a
@@ -426,7 +491,7 @@ continuesToRuntime = false) {
426
491
  const detail = skipped.has(s.target)
427
492
  ? SKIPPED_DETAIL
428
493
  : downgraded && s.exec !== undefined
429
- ? `step failed — run manually: ${s.exec.fallback}`
494
+ ? (why.get(s.target) ?? `step failed — run manually: ${s.exec.fallback}`)
430
495
  : s.detail;
431
496
  io.print(` [${STATUS_SYMBOL[status]}] ${s.title} → ${s.target}`);
432
497
  if (status === StepStatus.APPLY)
@@ -499,7 +564,8 @@ function sdkPackagesPresent(framework, uiLibrary, io) {
499
564
  return (packages.length > 0 &&
500
565
  packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
501
566
  }
502
- function applyEffects(plan, io) {
567
+ function applyEffects(plan, io, packageManager) {
568
+ const why = new Map();
503
569
  const failed = new Set();
504
570
  const skipped = new Set();
505
571
  const degraded = new Map();
@@ -573,8 +639,12 @@ function applyEffects(plan, io) {
573
639
  failed.add(s.target);
574
640
  // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
575
641
  // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
576
- if (s.target === DEPS_TARGET)
642
+ if (s.target === DEPS_TARGET) {
577
643
  installFailed = true;
644
+ const explained = explainInstallFailure(io, packageManager, exec, s.detail);
645
+ if (explained !== undefined)
646
+ why.set(s.target, explained);
647
+ }
578
648
  }
579
649
  }
580
650
  // Where this project lives, remembered for a daemon that will be started somewhere else.
@@ -588,7 +658,7 @@ function applyEffects(plan, io) {
588
658
  if (projectId !== undefined) {
589
659
  rememberProjectOnDisk(io, projectId, io.cwd(), Date.now());
590
660
  }
591
- return { failed, skipped, degraded };
661
+ return { failed, skipped, degraded, why };
592
662
  }
593
663
  /**
594
664
  * Which STEP failed, from the step targets — not from an error string, which would carry paths.
@@ -605,8 +675,20 @@ function classifyInitFailure(failed) {
605
675
  return InitFailure.MCP_REGISTRATION;
606
676
  return InitFailure.OTHER;
607
677
  }
678
+ /**
679
+ * Where the agent stands when `init` runs inside a workspace package: the workspace root.
680
+ *
681
+ * The same answer `--app` from the root already gives (see enterApp), so both routes into one app
682
+ * leave the root in one state — a pointer config and the agent files an agent opened there reads.
683
+ */
684
+ function withWorkspaceRoot(options, io) {
685
+ if (options.agentRoot !== undefined || true === options.redirected)
686
+ return options;
687
+ const root = enclosingWorkspaceRoot(options.cwd, io);
688
+ return root === undefined ? options : { ...options, agentRoot: root };
689
+ }
608
690
  export function runInit(options, io) {
609
- const result = runInitSteps(options, io);
691
+ const result = runInitSteps(withWorkspaceRoot(options, io), io);
610
692
  // The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
611
693
  // matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
612
694
  // setup died before anything ran and the person holding the report has the least to go on. This
@@ -614,7 +696,10 @@ export function runInit(options, io) {
614
696
  //
615
697
  // `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
616
698
  // chosen directory, and the inner call must not ask again.
617
- if (true !== options.redirected) {
699
+ //
700
+ // And not when the runtime follows: it asks once it has finished, as the last thing printed,
701
+ // rather than here in the middle, before the dev server has even started.
702
+ if (true !== options.redirected && true !== options.continuesToRuntime) {
618
703
  io.print('');
619
704
  io.print(FEEDBACK_HINT);
620
705
  }
@@ -671,52 +756,8 @@ function runInitSteps(options, io) {
671
756
  const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
672
757
  if (redirectedEarly !== null)
673
758
  return redirectedEarly;
674
- if (null === pkgRaw) {
675
- const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
676
- // Asked only when Streamlit already said no, so the two can never both claim the page.
677
- const django = !streamlit &&
678
- detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
679
- io.print(
680
- // Two genuinely different situations used to share one sentence: a JS developer in the wrong
681
- // directory, and a project that is not JavaScript at all. The second reads the old wording as
682
- // a path problem and goes looking for a directory that cannot exist — reported from a
683
- // Streamlit app, where the search continued into hunting for a browser bundle to inject by
684
- // hand before the real answer surfaced.
685
- streamlit
686
- ? streamlitSetupMessage()
687
- : django
688
- ? djangoSetupMessage()
689
- : noPackageJsonMessage((file) => io.exists(join(options.cwd, file))));
690
- // The message says "add the snippet below". Print the snippet, or the message is the same
691
- // broken promise in the other direction. `connectArg` carries the port; there is no projectId
692
- // to bake, because a projectId is derived from the package.json that does not exist here.
693
- // Scope the project on disk before printing anything, so the daemon can serve this page.
694
- //
695
- // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
696
- // asked for the Django path had to hand-write one alongside the middleware. Without it the
697
- // daemon has no config in its own directory, refuses the page's dial, and every downstream
698
- // symptom points somewhere other than the cause (see #685). The projectId derivation already
699
- // handles the no-package case: it falls back to the root folder name, fingerprinted by the
700
- // absolute path so two checkouts stay distinct.
701
- //
702
- // Written only when absent. A config a user or an earlier run already placed here is theirs.
703
- const nonJsProjectId = deriveProjectId(undefined, io.cwd());
704
- if (!io.exists(RETICLE_CONFIG_FILE)) {
705
- io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
706
- rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
707
- io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
708
- }
709
- const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
710
- io.print(streamlit
711
- ? streamlitPageSnippet(connect)
712
- : django
713
- ? djangoMiddlewareSnippet(connect)
714
- : staticPageSnippet(connect));
715
- // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
716
- // from someone who never ran the command — the two failure modes with the most different fixes.
717
- io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
718
- return { ok: false, applied: 0, manual: 0 };
719
- }
759
+ if (null === pkgRaw)
760
+ return initWithoutPackageJson(options, io);
720
761
  // Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
721
762
  // 1–6s per app with no explanation of the spread. These three spans split that number into detect
722
763
  // (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
@@ -726,23 +767,32 @@ function runInitSteps(options, io) {
726
767
  // the app in frontend/ uses pnpm, and checking the file refused a scaffold the install gate proves
727
768
  // must succeed. Both conditions make every later phase fail, and each arrives far from its cause
728
769
  // when it is not checked here. See preflight.ts.
729
- const refusal = preflightRefusal({
770
+ const preflightIo = {
730
771
  cwd: () => io.cwd(),
731
772
  canWrite: () => io.canWrite(),
732
773
  probe: (command, args) => io.probe(command, args),
733
- }, planInput.detection.packageManager, { alreadyServed: options.url !== undefined && '' !== options.url });
734
- if (refusal !== undefined) {
735
- io.print(refusal);
774
+ };
775
+ const resolved = preflight(preflightIo, planInput.detection.packageManager, {
776
+ alreadyServed: options.url !== undefined && '' !== options.url,
777
+ });
778
+ if (resolved.refusal !== undefined) {
779
+ io.print(resolved.refusal);
736
780
  return { ok: false, applied: 0, manual: 1 };
737
781
  }
782
+ // The prefix that can actually run it on THIS machine — `pnpm`, or `corepack pnpm` where preflight
783
+ // just proved only corepack can. Threaded onto detection (never recomputed by `detect`, which is
784
+ // pure and never probes) so the install step, its retries and the dev command all spawn and print
785
+ // the same thing preflight just confirmed works, instead of a bare binary it already ruled out.
786
+ const packageManagerCommand = resolved.command;
787
+ planInput.detection = { ...planInput.detection, packageManagerCommand };
738
788
  const plan = io.host.span('init.plan', {}, () => buildPlan(planInput));
739
789
  const effects = options.dryRun
740
- ? { failed: new Set(), skipped: new Set(), degraded: new Map() }
741
- : io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
742
- const { failed, skipped, degraded } = effects;
790
+ ? { failed: new Set(), skipped: new Set(), degraded: new Map(), why: new Map() }
791
+ : io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io, planInput.detection.packageManager));
792
+ const { failed, skipped, degraded, why } = effects;
743
793
  // The project's own dev command, so the closing line names what a human would actually type.
744
- const devCommand = devCommandFrom(pkgRaw, planInput.detection.packageManager);
745
- const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
794
+ const devCommand = devCommandFrom(pkgRaw, packageManagerCommand);
795
+ const result = report(plan, options.dryRun, failed, skipped, degraded, why, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
746
796
  // A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
747
797
  if (options.dryRun)
748
798
  return result;
@@ -810,8 +860,12 @@ function runInitSteps(options, io) {
810
860
  stack: plan.framework,
811
861
  ...(result.ok ? {} : { reason: classifyInitFailure(failed) }),
812
862
  });
863
+ // A failed dependency install skipped every wiring step, so there is no instrumented app to boot:
864
+ // without a context the runtime stops at this report, rather than starting the dev server and
865
+ // diagnosing an SDK that was never installed as a dev server that needs restarting.
866
+ const handover = failed.has(DEPS_TARGET) ? {} : { context };
813
867
  if (true === options.deferOutcome)
814
- return { ...result, context, outcome };
868
+ return { ...result, ...handover, outcome };
815
869
  io.host.reportOutcome(outcome);
816
- return { ...result, context };
870
+ return { ...result, ...handover };
817
871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/init",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Reticle project scaffolder — detects the framework, writes the config, patches the build and registers the MCP server. The build-time half of `reticle init`.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@reticlehq/core": "3.2.0"
16
+ "@reticlehq/core": "3.4.0"
17
17
  },
18
18
  "license": "FSL-1.1-ALv2",
19
19
  "author": "Reticle contributors",
@@ -27,8 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "README.md",
30
- "LICENSE",
31
- "NOTICE"
30
+ "LICENSE"
32
31
  ],
33
32
  "sideEffects": false,
34
33
  "keywords": [
package/NOTICE DELETED
@@ -1,5 +0,0 @@
1
- Reticle
2
- Copyright 2026 Reticle Labs
3
-
4
- This product includes software developed at Reticle Labs.
5
- Licensed under the Apache License, Version 2.0.