@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
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The plan's Content-Security-Policy step: the one check that runs before any framework's wiring,
3
+ * because a policy that refuses the bridge makes every other step moot.
4
+ */
5
+ import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
6
+ import { Framework } from '../detect/detect.js';
7
+ import { StepStatus } from '../plan/plan-types.js';
8
+ import { CSP_STEP_TITLE, cspPatchedDetail, externalScriptRemedy, patchCspMetaConnectSrc, } from './csp-check.js';
9
+ import { diagnoseWebCsp } from './csp-doctor.js';
10
+ /**
11
+ * How the app connects, as the CSP diagnosis needs to know it. Only the plain-HTML path pastes an
12
+ * inline snippet, so only there can `script-src` stop the connect; see WebCspOptions.
13
+ *
14
+ * Shared with `reticle doctor`, which reads the framework `init` stamped into `.reticle.json`. It
15
+ * used to assume the inline snippet for every app, so on electron-vite it reported the inline rule
16
+ * while `init` reported the connect-src block that actually stops the bridge. Unknown wiring keeps
17
+ * the conservative default.
18
+ */
19
+ export function webCspOptionsFor(framework) {
20
+ return framework === undefined ? {} : { inlineSnippet: Framework.HTML === framework };
21
+ }
22
+ /**
23
+ * A Content-Security-Policy whose `connect-src` excludes the bridge, said out loud at install time.
24
+ *
25
+ * Both reported cases were Next apps where `init` printed success for every step and the app then
26
+ * never connected: the browser blocked the WebSocket and reported it in its own console, which
27
+ * nothing on this side reads. It was a NOTICE, and on electron-vite's own template that meant init
28
+ * waited out its whole connect budget without saying the app could not connect. So a `<meta>` policy
29
+ * in the app's HTML is now edited to admit the bridge, and a policy anywhere else is a blocking ⚠
30
+ * carrying the exact text to paste.
31
+ */
32
+ export function cspStep(input) {
33
+ // Reads the SAME list `reticle doctor` reads. It used to read a hand-written pair —
34
+ // `[nextConfigSource, nextLayout?.source]` — while csp-doctor.ts already carried the full set
35
+ // including `index.html`, which is where every Vite and Electron app declares its policy. So the
36
+ // command you run BEFORE anything works checked less than the one you run after it has failed.
37
+ //
38
+ // Measured on MarkText, a production Electron editor: its renderer sets `default-src 'self'` with
39
+ // no `connect-src`, the browser blocked the bridge WebSocket, and `init` printed a clean plan. The
40
+ // daemon cannot see a dial that never left the page, so nothing anywhere said why — and the check
41
+ // that would have said it was sitting one import away.
42
+ // The pre-read Next sources are folded in ON TOP of the shared list, not replaced by it: init
43
+ // resolves `nextConfigFile` across more spellings than CSP_FILES names (`.mts`, for one), and a
44
+ // layout is found by search rather than by a fixed path. Two sources of the same truth is the
45
+ // problem being fixed here — one of them being a SUPERSET is not.
46
+ const extra = {
47
+ ...(input.nextConfigFile !== null && input.nextConfigFile !== undefined
48
+ ? { [input.nextConfigFile]: input.nextConfigSource ?? undefined }
49
+ : {}),
50
+ ...(input.nextLayout ? { [input.nextLayout.path]: input.nextLayout.source } : {}),
51
+ };
52
+ const read = (file) => extra[file] ?? input.cspSources?.[file];
53
+ const port = input.options.port ?? RETICLE_DEFAULT_PORT;
54
+ const findings = diagnoseWebCsp(read, port, [...Object.keys(extra)], webCspOptionsFor(input.detection.framework));
55
+ const first = findings[0];
56
+ if (first === undefined)
57
+ return [];
58
+ // A `<meta>` policy in the app's own HTML is edited in place. Anything else — a header built in
59
+ // next.config, middleware, a host's config — is a ⚠ that fails init: the browser is guaranteed to
60
+ // refuse the socket, and a NOTICE let init wait out its whole budget without saying so.
61
+ const source = read(first.file);
62
+ const patched = first.file.endsWith('.html') && source !== undefined && first.fix !== externalScriptRemedy()
63
+ ? patchCspMetaConnectSrc(source, port)
64
+ : undefined;
65
+ if (patched !== undefined) {
66
+ return [
67
+ {
68
+ title: CSP_STEP_TITLE,
69
+ target: first.file,
70
+ status: StepStatus.APPLY,
71
+ detail: cspPatchedDetail(port),
72
+ write: { path: first.file, content: patched },
73
+ },
74
+ ];
75
+ }
76
+ return [
77
+ {
78
+ title: CSP_STEP_TITLE,
79
+ target: first.file,
80
+ status: StepStatus.MANUAL,
81
+ // `problem` already ends with the text to paste; `fix` is the same sentence for callers that
82
+ // want it on its own (doctor renders them separately). Printing both said it twice.
83
+ detail: first.problem,
84
+ },
85
+ ];
86
+ }
@@ -23,7 +23,7 @@ export declare const DesktopFinding: {
23
23
  readonly ELECTRON_CAPTURE_MISSING: "electron-capture-missing";
24
24
  };
25
25
  export type DesktopFinding = (typeof DesktopFinding)[keyof typeof DesktopFinding];
26
- export interface DesktopDiagnosis {
26
+ interface DesktopDiagnosis {
27
27
  code: DesktopFinding;
28
28
  /** Which file to look at. */
29
29
  file: string;
@@ -1,4 +1,4 @@
1
- import { RETICLE_IPC_GLOBAL, realmOf, realmOfProject } from '@reticlehq/core';
1
+ import { RETICLE_CAPTURE_CHANNEL, RETICLE_IPC_GLOBAL, realmOf, realmOfProject, } from '@reticlehq/core';
2
2
  /**
3
3
  * Setup RCA for desktop apps.
4
4
  *
@@ -165,7 +165,13 @@ function diagnoseElectron(read, main) {
165
165
  });
166
166
  }
167
167
  const mainSource = read(main);
168
- if (mainSource !== undefined && !mainSource.includes(CAPTURE_REQUIRE)) {
168
+ // The require OR the contract's capture channel. `main` is usually the BUILD output, and bundling
169
+ // erases the require: an electron-vite app that did install the helper was told it never had. The
170
+ // helper registers the channel by its contract string, which survives bundling — the same
171
+ // artifact-level evidence the preload check above reads from the IPC global.
172
+ const captureWired = true === mainSource?.includes(CAPTURE_REQUIRE) ||
173
+ true === mainSource?.includes(RETICLE_CAPTURE_CHANNEL);
174
+ if (mainSource !== undefined && !captureWired) {
169
175
  findings.push({
170
176
  code: DesktopFinding.ELECTRON_CAPTURE_MISSING,
171
177
  file: main,
@@ -5,4 +5,12 @@
5
5
  * which package manager was used.
6
6
  */
7
7
  import { PackageManager } from '../detect/detect.js';
8
- export declare function installFailureHint(pm: PackageManager): string;
8
+ /**
9
+ * `pm` picks which hint applies; `pmCommand` is what the reader actually types.
10
+ *
11
+ * A corepack-only machine (see `preflight.ts`, #1149) has no bare `pnpm` on PATH — that is the whole
12
+ * shape of the bug this hint is used alongside. Printing `pnpm config set …` to that reader hands them
13
+ * a command that fails the same way the original install did; `pmCommand` is the resolved invocation
14
+ * (`corepack pnpm`, there) that is actually run on this machine.
15
+ */
16
+ export declare function installFailureHint(pm: PackageManager, pmCommand?: string): string;
@@ -24,17 +24,27 @@ const REGISTRY_HINT = 'If it could not reach the registry at all (offline, a pro
24
24
  * sent the reader through their dependency versions looking for a problem that was actually
25
25
  * about where the store lives (#683).
26
26
  */
27
- const VIRTUAL_STORE_HINT = "If pnpm reported ERR_PNPM_UNEXPECTED_VIRTUAL_STORE, this checkout's node_modules is " +
28
- "symlinked into another checkout's pnpm store (a git worktree, or an A/B harness). Either run " +
29
- '`pnpm install` once in that other checkout first, or point this one at its own store:\n' +
30
- ' pnpm add -D --config.virtual-store-dir=node_modules/.pnpm <packages>';
31
- export function installFailureHint(pm) {
27
+ function virtualStoreHint(pmCommand) {
28
+ return ("If pnpm reported ERR_PNPM_UNEXPECTED_VIRTUAL_STORE, this checkout's node_modules is " +
29
+ "symlinked into another checkout's pnpm store (a git worktree, or an A/B harness). Either run " +
30
+ `\`${pmCommand} install\` once in that other checkout first, or point this one at its own store:\n` +
31
+ ` ${pmCommand} add -D --config.virtual-store-dir=node_modules/.pnpm <packages>`);
32
+ }
33
+ /**
34
+ * `pm` picks which hint applies; `pmCommand` is what the reader actually types.
35
+ *
36
+ * A corepack-only machine (see `preflight.ts`, #1149) has no bare `pnpm` on PATH — that is the whole
37
+ * shape of the bug this hint is used alongside. Printing `pnpm config set …` to that reader hands them
38
+ * a command that fails the same way the original install did; `pmCommand` is the resolved invocation
39
+ * (`corepack pnpm`, there) that is actually run on this machine.
40
+ */
41
+ export function installFailureHint(pm, pmCommand = pm) {
32
42
  if (pm !== PackageManager.PNPM) {
33
43
  return `If the version was refused, install the SDK yourself. ${REGISTRY_HINT}`;
34
44
  }
35
45
  return ('If pnpm reported ERR_PNPM_NO_MATURE_MATCHING_VERSION, its minimumReleaseAge setting is holding ' +
36
46
  'this release back. Either wait out the window, or allow these packages explicitly:\n' +
37
- ' pnpm config set minimumReleaseAgeExclude "@reticlehq/*"\n' +
47
+ ` ${pmCommand} config set minimumReleaseAgeExclude "@reticlehq/*"\n` +
38
48
  'Do NOT drop the version pin — unpinned, pnpm installs an older SDK against a newer daemon, and ' +
39
- `that mismatch surfaces as a -32000 with nothing naming a version.\n${VIRTUAL_STORE_HINT}\n${REGISTRY_HINT}`);
49
+ `that mismatch surfaces as a -32000 with nothing naming a version.\n${virtualStoreHint(pmCommand)}\n${REGISTRY_HINT}`);
40
50
  }
@@ -6,7 +6,8 @@
6
6
  * a different cost and a different sentence to say afterwards.
7
7
  */
8
8
  import { PackageManager } from '../detect/detect.js';
9
- export interface InstallRetry {
9
+ import type { InitIo } from '../run-types.js';
10
+ interface InstallRetry {
10
11
  command: string;
11
12
  args: string[];
12
13
  note: string;
@@ -18,4 +19,19 @@ export interface InstallRetry {
18
19
  * pnpm, yarn and bun would reject an unknown argument — turning one failed install into two, with
19
20
  * the second failure blaming a flag the user never asked for.
20
21
  */
21
- export declare function installRetries(pm: PackageManager, pinned: readonly string[], unpinned: readonly string[], sdkVersion: string | undefined): InstallRetry[];
22
+ export declare function installRetries(pm: PackageManager, pinned: readonly string[], unpinned: readonly string[], sdkVersion: string | undefined,
23
+ /**
24
+ * The prefix that actually invokes `pm` on this machine — `pm` itself, or `corepack ${pm}` on a
25
+ * corepack-only machine (see `preflight.ts`, #1149). A retry that spawned the bare binary here,
26
+ * after the first attempt already went through the resolved prefix, would fail with the same
27
+ * ENOENT the resolution exists to avoid — on the one machine shape that needs a retry to reach a
28
+ * working install at all. Defaults to `pm` for callers that never went through preflight.
29
+ */
30
+ pmCommand?: string): InstallRetry[];
31
+ export declare function quarantineNote(command: string): string;
32
+ /** Why the dependency install failed, when that can be read; undefined keeps the generic text. */
33
+ export declare function explainInstallFailure(io: Pick<InitIo, 'capture'>, packageManager: PackageManager, exec: {
34
+ command: string;
35
+ args: readonly string[];
36
+ }, display: string): string | undefined;
37
+ export {};
@@ -50,14 +50,61 @@ function unpinnedRetryNote(version, pm) {
50
50
  * pnpm, yarn and bun would reject an unknown argument — turning one failed install into two, with
51
51
  * the second failure blaming a flag the user never asked for.
52
52
  */
53
- export function installRetries(pm, pinned, unpinned, sdkVersion) {
53
+ export function installRetries(pm, pinned, unpinned, sdkVersion,
54
+ /**
55
+ * The prefix that actually invokes `pm` on this machine — `pm` itself, or `corepack ${pm}` on a
56
+ * corepack-only machine (see `preflight.ts`, #1149). A retry that spawned the bare binary here,
57
+ * after the first attempt already went through the resolved prefix, would fail with the same
58
+ * ENOENT the resolution exists to avoid — on the one machine shape that needs a retry to reach a
59
+ * working install at all. Defaults to `pm` for callers that never went through preflight.
60
+ */
61
+ pmCommand = pm) {
54
62
  return [
55
63
  // Peers first: concedes only peer resolution and KEEPS the version pin.
56
64
  ...(pm === PackageManager.NPM
57
- ? [{ ...installCommandParts(pm, pinned, [LEGACY_PEER_DEPS]), note: LEGACY_PEER_NOTE }]
65
+ ? [
66
+ {
67
+ ...installCommandParts(pm, pinned, [LEGACY_PEER_DEPS], pmCommand),
68
+ note: LEGACY_PEER_NOTE,
69
+ },
70
+ ]
58
71
  : []),
59
72
  // Unpinned, last. pnpm resolves the newest MATURE version there, which is how a project with a
60
73
  // release-age hold gets a working install instead of no install.
61
- { ...installCommandParts(pm, unpinned), note: unpinnedRetryNote(sdkVersion, pm) },
74
+ {
75
+ ...installCommandParts(pm, unpinned, [], pmCommand),
76
+ note: unpinnedRetryNote(sdkVersion, pm),
77
+ },
62
78
  ];
63
79
  }
80
+ /**
81
+ * Yarn Berry refusing a release for being too new, told as what it is.
82
+ *
83
+ * Yarn 4 quarantines versions younger than its `npmMinimalAgeGate`, and answers an exact pin with
84
+ * `All versions satisfying "<v>" are quarantined`. A Reticle release is exactly that young on the
85
+ * day a user installs it. `init` printed "run manually: yarn add -D …", which fails the same way,
86
+ * and the runtime then booted the app and blamed the dev server for an SDK that was never installed.
87
+ *
88
+ * `exec` inherits stdio so the install's progress is live, which also means its text never reaches
89
+ * us. So on a Yarn failure the pinned command is run once more, quietly, to read why — a quarantine
90
+ * is refused at resolution, before anything is fetched, so the second run costs well under a
91
+ * second. Every other failure keeps the generic fallback.
92
+ */
93
+ /** Yarn's own wording, and the environment variable that is its one-shot override. */
94
+ const QUARANTINED = /are quarantined/;
95
+ const AGE_GATE_ENV = 'YARN_NPM_MINIMAL_AGE_GATE';
96
+ export function quarantineNote(command) {
97
+ return ("Yarn refused the release: it is younger than Yarn's `npmMinimalAgeGate`, so every matching " +
98
+ 'version is quarantined ("are quarantined" above) and running the same command by hand fails ' +
99
+ 'the same way. Nothing was wired, so there is no app to start yet. Either wait until the ' +
100
+ 'release is older than the gate, or lift the gate for this one install and re-run init:\n\n' +
101
+ ` ${AGE_GATE_ENV}=0 ${command}\n\n` +
102
+ ` (PowerShell: $env:${AGE_GATE_ENV}=0; ${command})`);
103
+ }
104
+ /** Why the dependency install failed, when that can be read; undefined keeps the generic text. */
105
+ export function explainInstallFailure(io, packageManager, exec, display) {
106
+ if (PackageManager.YARN !== packageManager || io.capture === undefined)
107
+ return undefined;
108
+ const { output } = io.capture(exec.command, exec.args);
109
+ return QUARANTINED.test(output) ? quarantineNote(display) : undefined;
110
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Setup output, laid out for the person reading it in a terminal.
3
+ *
4
+ * Setup's sentences are written one per line and were printed as they are, so a paragraph ran off
5
+ * the right edge and the terminal broke it mid-word, or they were broken by hand at a width that
6
+ * matched nobody's window. This wraps at the reader's real width instead, keeping each line's indent
7
+ * and hanging continuation lines under a list marker, so a numbered step still reads as one step.
8
+ *
9
+ * Commands are never wrapped: a command split across two lines is one nobody can paste. And output
10
+ * that is not going to a terminal — an agent reading a pipe — is left exactly as written.
11
+ */
12
+ /** The width to wrap at, or undefined when stdout is not a terminal. */
13
+ export declare function terminalWidth(stream?: {
14
+ isTTY?: boolean;
15
+ columns?: number;
16
+ }): number | undefined;
17
+ /** `text` wrapped line by line to `width`; unchanged when `width` is undefined. */
18
+ export declare function wrapForTerminal(text: string, width: number | undefined): string;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Setup output, laid out for the person reading it in a terminal.
3
+ *
4
+ * Setup's sentences are written one per line and were printed as they are, so a paragraph ran off
5
+ * the right edge and the terminal broke it mid-word, or they were broken by hand at a width that
6
+ * matched nobody's window. This wraps at the reader's real width instead, keeping each line's indent
7
+ * and hanging continuation lines under a list marker, so a numbered step still reads as one step.
8
+ *
9
+ * Commands are never wrapped: a command split across two lines is one nobody can paste. And output
10
+ * that is not going to a terminal — an agent reading a pipe — is left exactly as written.
11
+ */
12
+ /** Narrow enough to read, wide enough that a step is not ten lines tall. */
13
+ const MAX_WIDTH = 100;
14
+ const MIN_WIDTH = 40;
15
+ /** A line a reader runs rather than reads. */
16
+ const COMMAND = /^\s*(?:\$ |npx |npm |pnpm |yarn |bun |reticle |curl |cd |export |import )/;
17
+ /** What a continuation line hangs under: `1. `, `- `, `• `, `✓ `, `⚠ `, `why: `, `[✓] `. */
18
+ const MARKER = /^(\s*)(\d+\.\s+|[-•✓⚠]\s+|why:\s+|\[.\]\s+)?/u;
19
+ /** The width to wrap at, or undefined when stdout is not a terminal. */
20
+ export function terminalWidth(stream = process.stdout) {
21
+ if (true !== stream.isTTY || stream.columns === undefined)
22
+ return undefined;
23
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, stream.columns - 1));
24
+ }
25
+ function wrapLine(line, width) {
26
+ if (line.length <= width || COMMAND.test(line))
27
+ return line;
28
+ const match = MARKER.exec(line);
29
+ const lead = match?.[0] ?? '';
30
+ const hang = ' '.repeat(lead.length);
31
+ const words = line
32
+ .slice(lead.length)
33
+ .split(' ')
34
+ .filter((w) => w.length > 0);
35
+ // A path or URL wider than the window cannot be kept whole by wrapping, and breaking the words
36
+ // around it only strands its label on a line of its own. The terminal's own wrap does better.
37
+ if (words.some((w) => hang.length + w.length > width))
38
+ return line;
39
+ const out = [];
40
+ let current = lead;
41
+ let prefix = lead;
42
+ for (const word of words) {
43
+ if (current.length > prefix.length && current.length + 1 + word.length > width) {
44
+ out.push(current);
45
+ prefix = hang;
46
+ current = hang + word;
47
+ }
48
+ else {
49
+ current = current.length > prefix.length ? `${current} ${word}` : current + word;
50
+ }
51
+ }
52
+ out.push(current);
53
+ return out.join('\n');
54
+ }
55
+ /** `text` wrapped line by line to `width`; unchanged when `width` is undefined. */
56
+ export function wrapForTerminal(text, width) {
57
+ if (width === undefined)
58
+ return text;
59
+ return text
60
+ .split('\n')
61
+ .map((line) => wrapLine(line, width))
62
+ .join('\n');
63
+ }
package/dist/host.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface InitHost {
43
43
  pairingToken(): string;
44
44
  /** The install channel the environment declares, or undefined when nothing declared one. */
45
45
  installSource(): string | undefined;
46
+ /**
47
+ * Whether this run was started from inside Claude Code: the one case where writing its project
48
+ * `.mcp.json` is known to be wanted. A host question because it is read from the environment,
49
+ * which the scaffolder does not read for itself.
50
+ */
51
+ insideClaudeCode(): boolean;
46
52
  }
47
53
  /**
48
54
  * A host that does nothing, for callers that drive `init`'s file surface without running an install
package/dist/host.js CHANGED
@@ -21,4 +21,7 @@ export const SILENT_HOST = {
21
21
  installSource() {
22
22
  return undefined;
23
23
  },
24
+ insideClaudeCode() {
25
+ return false;
26
+ },
24
27
  };
package/dist/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export { runInit, resolveLockfiles, type InitIo, type InitOptions, type InitResu
19
19
  */
20
20
  export { windowsShellArg } from './register/windows-quote.js';
21
21
  export { buildNodeIo, probeCli } from './node-io.js';
22
+ export { terminalWidth, wrapForTerminal } from './diagnose/terminal-wrap.js';
23
+ export { FEEDBACK_HINT } from './diagnose/closing-hint.js';
22
24
  export { type InitHost, SILENT_HOST } from './host.js';
23
25
  export { RETICLE_VERSION, RETICLE_NPM_PACKAGE } from './version.js';
24
26
  export { InitFailure } from './diagnose/init-failure.js';
@@ -26,11 +28,14 @@ export { NodePlatform } from './detect/platform.js';
26
28
  export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from './detect/dev-server-ports.js';
27
29
  export { configWithInstallSource } from './project/install-source-config.js';
28
30
  export { projectIdOf, rememberProjectOnDisk, type RegistryIo } from './project/remember-project.js';
29
- export { detectPackageManager, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
31
+ export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
30
32
  export { findWorkspaceApps } from './detect/workspace-apps.js';
33
+ export { desktopLaunch, type DesktopLaunch } from './detect/dev-script.js';
34
+ export { deriveProjectId, packageName } from './project/project-id.js';
31
35
  export { refreshAgentRules } from './project/refresh-rules.js';
32
36
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
33
- export { diagnoseWebCsp } from './diagnose/csp-doctor.js';
37
+ export { CspBasis, diagnoseObservedWebCsp, diagnoseWebCsp, resolveWebCspFindings, type CspDiagnosis, type ObservedWebDocument, } from './diagnose/csp-doctor.js';
38
+ export { webCspOptionsFor } from './diagnose/csp-step.js';
34
39
  export { reticleConfigContent } from './patch/snippets.js';
35
40
  /**
36
41
  * MCP registration, for a caller that has no project.
@@ -41,5 +46,5 @@ export { reticleConfigContent } from './patch/snippets.js';
41
46
  * here", and the two would disagree the first time a client was added to one of them.
42
47
  */
43
48
  export { McpClient, ConfigScope, MCP_CLIENTS, ClientMergeStatus, clientSpec, mergeClientConfig, } from './register/mcp-clients.js';
44
- export { claudeAddCommand, claudeAvailableProbe, claudeExistsProbe, } from './register/mcp.js';
49
+ export { claudeAddCommand, claudeAvailableProbe, claudeHasReticle, } from './register/mcp.js';
45
50
  export { detectMcpClients, type DetectedClient } from './register/detect-clients.js';
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ export { runInit, resolveLockfiles, } from './run.js';
19
19
  */
20
20
  export { windowsShellArg } from './register/windows-quote.js';
21
21
  export { buildNodeIo, probeCli } from './node-io.js';
22
+ export { terminalWidth, wrapForTerminal } from './diagnose/terminal-wrap.js';
23
+ export { FEEDBACK_HINT } from './diagnose/closing-hint.js';
22
24
  export { SILENT_HOST } from './host.js';
23
25
  export { RETICLE_VERSION, RETICLE_NPM_PACKAGE } from './version.js';
24
26
  export { InitFailure } from './diagnose/init-failure.js';
@@ -26,11 +28,14 @@ export { NodePlatform } from './detect/platform.js';
26
28
  export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning, } from './detect/dev-server-ports.js';
27
29
  export { configWithInstallSource } from './project/install-source-config.js';
28
30
  export { projectIdOf, rememberProjectOnDisk } from './project/remember-project.js';
29
- export { detectPackageManager, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
31
+ export { detectPackageManager, Framework, installCommandParts, parseMajor, PackageManager, } from './detect/detect.js';
30
32
  export { findWorkspaceApps } from './detect/workspace-apps.js';
33
+ export { desktopLaunch } from './detect/dev-script.js';
34
+ export { deriveProjectId, packageName } from './project/project-id.js';
31
35
  export { refreshAgentRules } from './project/refresh-rules.js';
32
36
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
33
- export { diagnoseWebCsp } from './diagnose/csp-doctor.js';
37
+ export { CspBasis, diagnoseObservedWebCsp, diagnoseWebCsp, resolveWebCspFindings, } from './diagnose/csp-doctor.js';
38
+ export { webCspOptionsFor } from './diagnose/csp-step.js';
34
39
  export { reticleConfigContent } from './patch/snippets.js';
35
40
  /**
36
41
  * MCP registration, for a caller that has no project.
@@ -45,5 +50,5 @@ export { claudeAddCommand,
45
50
  // The two probes `run.ts` uses to decide about Claude Code. Exported because `reticle setup mcp`
46
51
  // asks the same question and had no way to ask it: Claude Code keeps no config file, so the
47
52
  // file-reading detector cannot see it, and the installer silently skipped the commonest client.
48
- claudeAvailableProbe, claudeExistsProbe, } from './register/mcp.js';
53
+ claudeAvailableProbe, claudeHasReticle, } from './register/mcp.js';
49
54
  export { detectMcpClients } from './register/detect-clients.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `init` in a directory with no package.json, once the workspace redirect has found no app below it.
3
+ *
4
+ * Its own module because it is its own flow: nothing here detects a framework, plans steps or runs a
5
+ * package manager. It scopes the project on disk, then either WRITES the connect snippet into a
6
+ * static page it can see whole, or prints the snippet for the stack it recognised (Streamlit,
7
+ * Django, several pages) and stops.
8
+ */
9
+ import type { InitIo, InitOptions, InitResult } from './run-types.js';
10
+ export declare function initWithoutPackageJson(options: InitOptions, io: InitIo): InitResult;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `init` in a directory with no package.json, once the workspace redirect has found no app below it.
3
+ *
4
+ * Its own module because it is its own flow: nothing here detects a framework, plans steps or runs a
5
+ * package manager. It scopes the project on disk, then either WRITES the connect snippet into a
6
+ * static page it can see whole, or prints the snippet for the stack it recognised (Streamlit,
7
+ * Django, several pages) and stops.
8
+ */
9
+ import { join } from 'node:path';
10
+ import { PackageManager, Framework } from './detect/detect.js';
11
+ import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, noPackageJsonMessage, streamlitSetupMessage, } from './detect/non-js-project.js';
12
+ import { InitFailure } from './diagnose/init-failure.js';
13
+ import { HTML_INDEX_PATH, STATIC_TOKEN_MODULE, staticTokenFiles, withStaticSnippet, } from './patch/static-page.js';
14
+ import { connectArg, connectArgWithToken, djangoMiddlewareSnippet, reticleConfigContent, staticPageSnippet, streamlitPageSnippet, } from './patch/snippets.js';
15
+ import { RETICLE_CONFIG_FILE } from './detect/existing-config.js';
16
+ import { deriveProjectId } from './project/project-id.js';
17
+ import { portMoveDetail, retargetConfigPort, retargetPort } from './plan/port-steps.js';
18
+ import { rememberProjectOnDisk } from './project/remember-project.js';
19
+ const SNIPPET_WRITTEN = `Wrote the dev-only connect snippet into ${HTML_INDEX_PATH}. It runs only when the page is ` +
20
+ `opened on localhost. This machine’s pairing token is in ${STATIC_TOKEN_MODULE}, which is ` +
21
+ 'gitignored — keep it out of anything you publish, and let each teammate run `reticle init` ' +
22
+ 'for their own.';
23
+ const SNIPPET_ALREADY = `${HTML_INDEX_PATH} already loads the Reticle SDK — nothing to change.`;
24
+ /**
25
+ * Write the snippet into `index.html`, and hand over to setup as a success.
26
+ *
27
+ * This is the path where the snippet is the whole install, proven to connect when pasted. It used to
28
+ * print the snippet and exit 1 with `no_package_json`, which read to the funnel — and to the person —
29
+ * as the setup failing on exactly the path that works.
30
+ */
31
+ function wireStaticPage(options, io, connect) {
32
+ for (const [path, content] of Object.entries(staticTokenFiles(io.host.pairingToken(), (p) => io.readFile(p)))) {
33
+ io.writeFile(path, content);
34
+ }
35
+ const html = io.readFile(HTML_INDEX_PATH) ?? '';
36
+ // A page that already connects still has its port compared: the snippet bakes the daemon URL, and
37
+ // a re-run with a new `--port` that answered "already" left the page dialling the old daemon.
38
+ const written = withStaticSnippet(html, connect);
39
+ const moved = null === written ? retargetPort(html, options.port) : null;
40
+ const patched = written ?? moved?.code ?? null;
41
+ if (null !== patched)
42
+ io.writeFile(HTML_INDEX_PATH, patched);
43
+ io.print(null !== written
44
+ ? SNIPPET_WRITTEN
45
+ : null !== moved && options.port !== undefined
46
+ ? `Updated ${HTML_INDEX_PATH}: ${portMoveDetail(moved.from, options.port)}.`
47
+ : SNIPPET_ALREADY);
48
+ const outcome = { ok: true, stack: Framework.HTML };
49
+ if (true !== options.deferOutcome)
50
+ io.host.reportOutcome(outcome);
51
+ return {
52
+ ok: true,
53
+ applied: null === patched ? 0 : 1,
54
+ manual: 0,
55
+ // Setup takes it from here: with `--url` it opens the page and waits for the session, which is
56
+ // the only proof that matters; without one it says the page has no dev server to start.
57
+ context: { appDir: options.cwd, framework: Framework.HTML, packageManager: PackageManager.NPM },
58
+ ...(true === options.deferOutcome ? { outcome } : {}),
59
+ };
60
+ }
61
+ export function initWithoutPackageJson(options, io) {
62
+ const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
63
+ // Asked only when Streamlit already said no, so the two can never both claim the page.
64
+ const django = !streamlit && detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
65
+ // Scope the project on disk before anything else, so the daemon can serve this page.
66
+ //
67
+ // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
68
+ // asked for the Django path had to hand-write one alongside the middleware. Without it the
69
+ // daemon has no config in its own directory, refuses the page's dial, and every downstream
70
+ // symptom points somewhere other than the cause (see #685). The projectId derivation already
71
+ // handles the no-package case: it falls back to the root folder name, fingerprinted by the
72
+ // absolute path so two checkouts stay distinct.
73
+ //
74
+ // Written only when absent. A config a user or an earlier run already placed here is theirs.
75
+ const nonJsProjectId = deriveProjectId(undefined, io.cwd());
76
+ const writeConfig = () => {
77
+ // Except its port. A re-run with a new `--port` starts the daemon there, and a config still
78
+ // naming the old one sends the agent to a daemon that is no longer running.
79
+ const existingConfig = io.readFile(RETICLE_CONFIG_FILE);
80
+ const moved = null === existingConfig ? null : retargetConfigPort(existingConfig, options.port);
81
+ if (null !== moved && options.port !== undefined) {
82
+ io.writeFile(RETICLE_CONFIG_FILE, moved.code);
83
+ io.print(`Updated ${RETICLE_CONFIG_FILE}: ${portMoveDetail(moved.from, options.port)}.`);
84
+ }
85
+ if (io.exists(RETICLE_CONFIG_FILE))
86
+ return;
87
+ io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
88
+ rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
89
+ io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
90
+ };
91
+ const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
92
+ // One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
93
+ if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
94
+ writeConfig();
95
+ // Without the token: the page is the deployable artifact, so the token goes beside it instead.
96
+ return wireStaticPage(options, io, connectArg(options.port, nonJsProjectId));
97
+ }
98
+ io.print(
99
+ // Two genuinely different situations used to share one sentence: a JS developer in the wrong
100
+ // directory, and a project that is not JavaScript at all. The second reads the old wording as
101
+ // a path problem and goes looking for a directory that cannot exist — reported from a
102
+ // Streamlit app, where the search continued into hunting for a browser bundle to inject by
103
+ // hand before the real answer surfaced.
104
+ streamlit
105
+ ? streamlitSetupMessage()
106
+ : django
107
+ ? djangoSetupMessage()
108
+ : noPackageJsonMessage((file) => io.exists(join(options.cwd, file)), io.rootFiles()));
109
+ writeConfig();
110
+ // The message says "add the snippet below". Print the snippet, or the message is the same broken
111
+ // promise in the other direction.
112
+ io.print(streamlit
113
+ ? streamlitPageSnippet(connect)
114
+ : django
115
+ ? djangoMiddlewareSnippet(connect)
116
+ : staticPageSnippet(connect));
117
+ // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
118
+ // from someone who never ran the command — the two failure modes with the most different fixes.
119
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
120
+ return { ok: false, applied: 0, manual: 0 };
121
+ }
package/dist/node-io.d.ts CHANGED
@@ -8,11 +8,15 @@ import type { InitHost } from './host.js';
8
8
  /**
9
9
  * Every program `init` is ever allowed to run.
10
10
  *
11
- * The set is closed and tiny — the four package managers, `npx`, and the Claude CLI — because those
12
- * are the only things the plan can ask for. It is a named constant for the same reason every other
13
- * wire string here is one, and it is CHECKED because of the shell: on Windows these spawn through
14
- * a shell, and a shell turns the command name into something the shell parses rather than a file it
15
- * executes. Arguments are quoted by `shellSafe`; the command name was the half nothing covered.
11
+ * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude CLI —
12
+ * because those are the only things the plan can ask for. It is a named constant for the same reason
13
+ * every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
14
+ * through a shell, and a shell turns the command name into something the shell parses rather than a
15
+ * file it executes. Arguments are quoted by `shellSafe`; the command name was the half nothing
16
+ * covered.
17
+ *
18
+ * `corepack` runs the package manager, never IS it (see `preflight.ts`, #1149) — the allowlist has to
19
+ * name the actual program being spawned.
16
20
  *
17
21
  * A command outside this set is a programming error — the plan built something no branch of this
18
22
  * package can produce — so it throws rather than returning `false`, which would read to the caller