@reticlehq/init 3.3.0 → 3.5.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 (84) 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 +45 -2
  4. package/dist/detect/detect.js +76 -5
  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.js +28 -2
  12. package/dist/diagnose/containerised-dev-server.d.ts +12 -4
  13. package/dist/diagnose/containerised-dev-server.js +36 -4
  14. package/dist/diagnose/csp-check.d.ts +12 -6
  15. package/dist/diagnose/csp-check.js +61 -13
  16. package/dist/diagnose/csp-doctor.d.ts +13 -1
  17. package/dist/diagnose/csp-doctor.js +2 -2
  18. package/dist/diagnose/csp-step.d.ts +27 -0
  19. package/dist/diagnose/csp-step.js +86 -0
  20. package/dist/diagnose/desktop-doctor.js +8 -2
  21. package/dist/diagnose/init-failure.d.ts +7 -0
  22. package/dist/diagnose/init-failure.js +7 -0
  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 +16 -1
  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/index.d.ts +6 -2
  30. package/dist/index.js +6 -2
  31. package/dist/no-package-json.d.ts +10 -0
  32. package/dist/no-package-json.js +131 -0
  33. package/dist/node-io.d.ts +12 -6
  34. package/dist/node-io.js +33 -11
  35. package/dist/patch/angular.d.ts +68 -0
  36. package/dist/patch/angular.js +216 -0
  37. package/dist/patch/astro-patch.d.ts +1 -1
  38. package/dist/patch/astro-patch.js +2 -2
  39. package/dist/patch/cra.d.ts +0 -2
  40. package/dist/patch/cra.js +0 -2
  41. package/dist/patch/electron-patch.d.ts +10 -1
  42. package/dist/patch/electron-patch.js +79 -27
  43. package/dist/patch/electron-vite-patch.js +1 -1
  44. package/dist/patch/nuxt-patch.d.ts +1 -1
  45. package/dist/patch/nuxt-patch.js +2 -2
  46. package/dist/patch/nuxt-snippets.js +6 -6
  47. package/dist/patch/remix.d.ts +24 -0
  48. package/dist/patch/remix.js +59 -0
  49. package/dist/patch/snippets.d.ts +57 -9
  50. package/dist/patch/snippets.js +119 -28
  51. package/dist/patch/static-page.d.ts +39 -0
  52. package/dist/patch/static-page.js +102 -0
  53. package/dist/patch/tanstack-start.d.ts +36 -8
  54. package/dist/patch/tanstack-start.js +118 -29
  55. package/dist/patch/vite-config.d.ts +5 -0
  56. package/dist/patch/vite-config.js +8 -0
  57. package/dist/patch/vite-owning-config.d.ts +34 -0
  58. package/dist/patch/vite-owning-config.js +63 -5
  59. package/dist/plan/connect-steps.d.ts +7 -1
  60. package/dist/plan/connect-steps.js +18 -1
  61. package/dist/plan/framework-adapter.d.ts +3 -1
  62. package/dist/plan/framework-adapter.js +99 -6
  63. package/dist/plan/notices.js +4 -3
  64. package/dist/plan/plan-angular.d.ts +6 -0
  65. package/dist/plan/plan-angular.js +89 -0
  66. package/dist/plan/plan-electron-vite.d.ts +6 -3
  67. package/dist/plan/plan-electron-vite.js +39 -8
  68. package/dist/plan/plan-framework.d.ts +8 -46
  69. package/dist/plan/plan-framework.js +81 -312
  70. package/dist/plan/plan-types.d.ts +29 -0
  71. package/dist/plan/plan-vite.d.ts +67 -0
  72. package/dist/plan/plan-vite.js +256 -0
  73. package/dist/plan/plan.js +38 -34
  74. package/dist/plan/port-steps.d.ts +45 -0
  75. package/dist/plan/port-steps.js +119 -0
  76. package/dist/plan/preflight.d.ts +17 -3
  77. package/dist/plan/preflight.js +53 -23
  78. package/dist/register/mcp-clients.js +5 -3
  79. package/dist/register/mcp.d.ts +18 -5
  80. package/dist/register/mcp.js +48 -8
  81. package/dist/run-types.d.ts +8 -0
  82. package/dist/run.js +118 -80
  83. package/package.json +3 -4
  84. package/NOTICE +0 -5
@@ -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,6 +6,7 @@
6
6
  * a different cost and a different sentence to say afterwards.
7
7
  */
8
8
  import { PackageManager } from '../detect/detect.js';
9
+ import type { InitIo } from '../run-types.js';
9
10
  interface InstallRetry {
10
11
  command: string;
11
12
  args: string[];
@@ -18,5 +19,19 @@ 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;
22
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/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,12 +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';
31
34
  export { deriveProjectId, packageName } from './project/project-id.js';
32
35
  export { refreshAgentRules } from './project/refresh-rules.js';
33
36
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
34
37
  export { CspBasis, diagnoseObservedWebCsp, diagnoseWebCsp, resolveWebCspFindings, type CspDiagnosis, type ObservedWebDocument, } from './diagnose/csp-doctor.js';
38
+ export { webCspOptionsFor } from './diagnose/csp-step.js';
35
39
  export { reticleConfigContent } from './patch/snippets.js';
36
40
  /**
37
41
  * MCP registration, for a caller that has no project.
@@ -42,5 +46,5 @@ export { reticleConfigContent } from './patch/snippets.js';
42
46
  * here", and the two would disagree the first time a client was added to one of them.
43
47
  */
44
48
  export { McpClient, ConfigScope, MCP_CLIENTS, ClientMergeStatus, clientSpec, mergeClientConfig, } from './register/mcp-clients.js';
45
- export { claudeAddCommand, claudeAvailableProbe, claudeExistsProbe, } from './register/mcp.js';
49
+ export { claudeAddCommand, claudeAvailableProbe, claudeHasReticle, } from './register/mcp.js';
46
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,12 +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';
31
34
  export { deriveProjectId, packageName } from './project/project-id.js';
32
35
  export { refreshAgentRules } from './project/refresh-rules.js';
33
36
  export { diagnoseDesktop, isDesktopProject } from './diagnose/desktop-doctor.js';
34
37
  export { CspBasis, diagnoseObservedWebCsp, diagnoseWebCsp, resolveWebCspFindings, } from './diagnose/csp-doctor.js';
38
+ export { webCspOptionsFor } from './diagnose/csp-step.js';
35
39
  export { reticleConfigContent } from './patch/snippets.js';
36
40
  /**
37
41
  * MCP registration, for a caller that has no project.
@@ -46,5 +50,5 @@ export { claudeAddCommand,
46
50
  // The two probes `run.ts` uses to decide about Claude Code. Exported because `reticle setup mcp`
47
51
  // asks the same question and had no way to ask it: Claude Code keeps no config file, so the
48
52
  // file-reading detector cannot see it, and the installer silently skipped the commonest client.
49
- claudeAvailableProbe, claudeExistsProbe, } from './register/mcp.js';
53
+ claudeAvailableProbe, claudeHasReticle, } from './register/mcp.js';
50
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,131 @@
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
+ const DRY_RUN_PAIRING_TOKEN = 'DRY_RUN_TOKEN_NOT_FOR_USE';
25
+ /**
26
+ * Write the snippet into `index.html`, and hand over to setup as a success.
27
+ *
28
+ * This is the path where the snippet is the whole install, proven to connect when pasted. It used to
29
+ * print the snippet and exit 1 with `no_package_json`, which read to the funnel — and to the person —
30
+ * as the setup failing on exactly the path that works.
31
+ */
32
+ function wireStaticPage(options, io, connect) {
33
+ for (const [path, content] of Object.entries(staticTokenFiles(io.host.pairingToken(), (p) => io.readFile(p)))) {
34
+ io.writeFile(path, content);
35
+ }
36
+ const html = io.readFile(HTML_INDEX_PATH) ?? '';
37
+ // A page that already connects still has its port compared: the snippet bakes the daemon URL, and
38
+ // a re-run with a new `--port` that answered "already" left the page dialling the old daemon.
39
+ const written = withStaticSnippet(html, connect);
40
+ const moved = null === written ? retargetPort(html, options.port) : null;
41
+ const patched = written ?? moved?.code ?? null;
42
+ if (null !== patched)
43
+ io.writeFile(HTML_INDEX_PATH, patched);
44
+ io.print(null !== written
45
+ ? SNIPPET_WRITTEN
46
+ : null !== moved && options.port !== undefined
47
+ ? `Updated ${HTML_INDEX_PATH}: ${portMoveDetail(moved.from, options.port)}.`
48
+ : SNIPPET_ALREADY);
49
+ const outcome = { ok: true, stack: Framework.HTML };
50
+ if (true !== options.deferOutcome)
51
+ io.host.reportOutcome(outcome);
52
+ return {
53
+ ok: true,
54
+ applied: null === patched ? 0 : 1,
55
+ manual: 0,
56
+ // Setup takes it from here: with `--url` it opens the page and waits for the session, which is
57
+ // the only proof that matters; without one it says the page has no dev server to start.
58
+ context: { appDir: options.cwd, framework: Framework.HTML, packageManager: PackageManager.NPM },
59
+ ...(true === options.deferOutcome ? { outcome } : {}),
60
+ };
61
+ }
62
+ export function initWithoutPackageJson(options, io) {
63
+ const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
64
+ // Asked only when Streamlit already said no, so the two can never both claim the page.
65
+ const django = !streamlit && detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
66
+ // Scope the project on disk before anything else, so the daemon can serve this page.
67
+ //
68
+ // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
69
+ // asked for the Django path had to hand-write one alongside the middleware. Without it the
70
+ // daemon has no config in its own directory, refuses the page's dial, and every downstream
71
+ // symptom points somewhere other than the cause (see #685). The projectId derivation already
72
+ // handles the no-package case: it falls back to the root folder name, fingerprinted by the
73
+ // absolute path so two checkouts stay distinct.
74
+ //
75
+ // Written only when absent. A config a user or an earlier run already placed here is theirs.
76
+ const nonJsProjectId = deriveProjectId(undefined, io.cwd());
77
+ const writeConfig = () => {
78
+ if (options.dryRun) {
79
+ io.print('Dry run: no files written; the snippet uses a placeholder pairing token.');
80
+ return;
81
+ }
82
+ // Except its port. A re-run with a new `--port` starts the daemon there, and a config still
83
+ // naming the old one sends the agent to a daemon that is no longer running.
84
+ const existingConfig = io.readFile(RETICLE_CONFIG_FILE);
85
+ const moved = null === existingConfig ? null : retargetConfigPort(existingConfig, options.port);
86
+ if (null !== moved && options.port !== undefined) {
87
+ io.writeFile(RETICLE_CONFIG_FILE, moved.code);
88
+ io.print(`Updated ${RETICLE_CONFIG_FILE}: ${portMoveDetail(moved.from, options.port)}.`);
89
+ }
90
+ if (io.exists(RETICLE_CONFIG_FILE))
91
+ return;
92
+ io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
93
+ rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
94
+ io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
95
+ };
96
+ const connect = connectArgWithToken(options.port, nonJsProjectId, options.dryRun ? DRY_RUN_PAIRING_TOKEN : io.host.pairingToken());
97
+ // One page at the root is a page we can see whole. Several, or none, is a choice that is not ours.
98
+ if (!streamlit && !django && io.exists(HTML_INDEX_PATH)) {
99
+ writeConfig();
100
+ if (options.dryRun) {
101
+ io.print(staticPageSnippet(connect));
102
+ return { ok: true, applied: 0, manual: 0 };
103
+ }
104
+ // Without the token: the page is the deployable artifact, so the token goes beside it instead.
105
+ return wireStaticPage(options, io, connectArg(options.port, nonJsProjectId));
106
+ }
107
+ io.print(
108
+ // Two genuinely different situations used to share one sentence: a JS developer in the wrong
109
+ // directory, and a project that is not JavaScript at all. The second reads the old wording as
110
+ // a path problem and goes looking for a directory that cannot exist — reported from a
111
+ // Streamlit app, where the search continued into hunting for a browser bundle to inject by
112
+ // hand before the real answer surfaced.
113
+ streamlit
114
+ ? streamlitSetupMessage()
115
+ : django
116
+ ? djangoSetupMessage()
117
+ : noPackageJsonMessage((file) => io.exists(join(options.cwd, file)), io.rootFiles()));
118
+ writeConfig();
119
+ // The message says "add the snippet below". Print the snippet, or the message is the same broken
120
+ // promise in the other direction.
121
+ io.print(streamlit
122
+ ? streamlitPageSnippet(connect)
123
+ : django
124
+ ? djangoMiddlewareSnippet(connect)
125
+ : staticPageSnippet(connect));
126
+ // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
127
+ // from someone who never ran the command — the two failure modes with the most different fixes.
128
+ if (!options.dryRun)
129
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
130
+ return { ok: false, applied: 0, manual: 0 };
131
+ }
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
@@ -30,4 +34,6 @@ export declare const RUNNABLE_COMMANDS: readonly string[];
30
34
  * reason a second, broken copy existed.
31
35
  */
32
36
  export declare function probeCli(command: string, args: readonly string[]): boolean;
33
- export declare function buildNodeIo(cwd: string, host: InitHost): InitIo;
37
+ export declare function buildNodeIo(cwd: string, host: InitHost, output?: {
38
+ stderr?: boolean;
39
+ }): InitIo;
package/dist/node-io.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * `runInit` stays testable with an in-memory IO. Prints to stdout — `init` is a one-shot CLI
4
4
  * command, not the MCP stdio transport.
5
5
  */
6
+ import { terminalWidth, wrapForTerminal } from './diagnose/terminal-wrap.js';
6
7
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, accessSync, constants, } from 'node:fs';
7
8
  import { NodePlatform } from './detect/platform.js';
8
9
  import { join, dirname, isAbsolute } from 'node:path';
@@ -23,17 +24,29 @@ function shellSafe(args) {
23
24
  /**
24
25
  * Every program `init` is ever allowed to run.
25
26
  *
26
- * The set is closed and tiny — the four package managers, `npx`, and the Claude CLI — because those
27
- * are the only things the plan can ask for. It is a named constant for the same reason every other
28
- * wire string here is one, and it is CHECKED because of the shell: on Windows these spawn through
29
- * a shell, and a shell turns the command name into something the shell parses rather than a file it
30
- * executes. Arguments are quoted by `shellSafe`; the command name was the half nothing covered.
27
+ * The set is closed and tiny — the four package managers, `npx`, `corepack`, and the Claude CLI —
28
+ * because those are the only things the plan can ask for. It is a named constant for the same reason
29
+ * every other wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
30
+ * through a shell, and a shell turns the command name into something the shell parses rather than a
31
+ * file it executes. Arguments are quoted by `shellSafe`; the command name was the half nothing
32
+ * covered.
33
+ *
34
+ * `corepack` runs the package manager, never IS it (see `preflight.ts`, #1149) — the allowlist has to
35
+ * name the actual program being spawned.
31
36
  *
32
37
  * A command outside this set is a programming error — the plan built something no branch of this
33
38
  * package can produce — so it throws rather than returning `false`, which would read to the caller
34
39
  * as "the command ran and failed" and hide the bug in a retry.
35
40
  */
36
- export const RUNNABLE_COMMANDS = ['pnpm', 'yarn', 'bun', 'npm', 'npx', 'claude'];
41
+ export const RUNNABLE_COMMANDS = [
42
+ 'pnpm',
43
+ 'yarn',
44
+ 'bun',
45
+ 'npm',
46
+ 'npx',
47
+ 'corepack',
48
+ 'claude',
49
+ ];
37
50
  /** The command, proven to be one of `RUNNABLE_COMMANDS` — never the caller's string. */
38
51
  function runnable(command) {
39
52
  const found = RUNNABLE_COMMANDS.find((allowed) => allowed === command);
@@ -89,7 +102,7 @@ export function probeCli(command, args) {
89
102
  return false;
90
103
  }
91
104
  }
92
- export function buildNodeIo(cwd, host) {
105
+ export function buildNodeIo(cwd, host, output = {}) {
93
106
  // Project-relative by default; absolute paths (e.g. ~/.cursor/mcp.json) pass through unchanged.
94
107
  const abs = (rel) => (isAbsolute(rel) ? rel : join(cwd, rel));
95
108
  return {
@@ -148,13 +161,21 @@ export function buildNodeIo(cwd, host) {
148
161
  scoped(rel) {
149
162
  // The host travels with the re-rooted IO: a monorepo redirect re-enters `runInit` through
150
163
  // this, and an untraced, unreported inner run is the half of init that actually did the work.
151
- return buildNodeIo(abs(rel), host);
164
+ return buildNodeIo(abs(rel), host, output);
152
165
  },
153
166
  exec(command, args) {
154
- // Inherit stdio so the install's own progress is visible to the user.
155
- const result = spawnAllowed(command, args, { cwd, stdio: 'inherit' });
167
+ // JSON callers reserve stdout for the result, including across package-manager subprocesses.
168
+ const result = spawnAllowed(command, args, {
169
+ cwd,
170
+ stdio: true === output.stderr ? ['inherit', 2, 2] : 'inherit',
171
+ });
156
172
  return 0 === result.status;
157
173
  },
174
+ capture(command, args) {
175
+ const result = spawnAllowed(command, args, { cwd, stdio: 'pipe', encoding: 'utf8' });
176
+ const text = (part) => ('string' === typeof part ? part : '');
177
+ return { ok: 0 === result.status, output: `${text(result.stdout)}${text(result.stderr)}` };
178
+ },
158
179
  /** One access check, rather than discovering it as an EACCES stack four phases later. */
159
180
  canWrite() {
160
181
  try {
@@ -171,7 +192,8 @@ export function buildNodeIo(cwd, host) {
171
192
  return 0 === result.status;
172
193
  },
173
194
  print(line) {
174
- process.stdout.write(`${line}\n`);
195
+ const stream = true === output.stderr ? process.stderr : process.stdout;
196
+ stream.write(`${wrapForTerminal(line, terminalWidth())}\n`);
175
197
  },
176
198
  host,
177
199
  };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Angular CLI: a dev-only connect in the browser entry, and a way for the pairing token to reach it.
3
+ *
4
+ * The connect is the part proven by hand on Angular 22, with and without SSR: a dynamic
5
+ * `import('@reticlehq/browser')` guarded on `ngDevMode` (see DEV_GUARD for why not `isDevMode()`),
6
+ * which the production build folds to `false` and drops together with the chunk it would load.
7
+ *
8
+ * The token is the hard part, and why this is not one line. The bridge refuses a hello without the
9
+ * machine's pairing token even on localhost, and every other stack inlines it from Node at build
10
+ * time — the Vite plugin's `define`, Next's `NEXT_PUBLIC_*`, CRA's gitignored `.env.development.local`.
11
+ * The Angular CLI has no config file that runs code, so each of those has a failure here, all
12
+ * measured on a scaffolded Angular 21 app before this was written:
13
+ *
14
+ * - A literal in `angular.json` (`define`, `fileReplacements`) commits a per-machine secret.
15
+ * - A gitignored module imported from `main.ts` is bundled by `ng build` too: with the file present
16
+ * the production output held a lazy chunk carrying the token behind `isDevMode()`, which the
17
+ * build cannot fold — and a token must not depend on a guard being written exactly right.
18
+ * - Making that module a dev-only `fileReplacements` target breaks `ng serve` for every teammate
19
+ * who has not run `init` ("path in file replacements does not exist").
20
+ *
21
+ * What is left is the serve target, which `ng build` never reads. Its `proxyConfig` is a JavaScript
22
+ * module that `ng serve` loads in Node, so `reticle.proxy.mjs` reads `~/.reticle/pairing-token` at
23
+ * request time — the same file the Vite and Next plugins read — and answers one same-origin route
24
+ * with it. The token is never written into the project, and a production build has no route to it.
25
+ */
26
+ import { type SourcePatch } from './patch-kind.js';
27
+ /** The generated dev-server module, beside `angular.json`. */
28
+ export declare const ANGULAR_PROXY_PATH = "reticle.proxy.mjs";
29
+ /** The one route that module answers, fetched by the entry's dev-only connect. */
30
+ export declare const ANGULAR_TOKEN_PATH = "/__reticle/pairing-token";
31
+ /** What an Angular CLI app's browser entry is when `angular.json` does not say. */
32
+ export declare const ANGULAR_DEFAULT_ENTRY = "src/main.ts";
33
+ /**
34
+ * The browser entry `angular.json` names — `browser` on the application builder (v17+), `main` on
35
+ * the webpack one — or the CLI's default when it cannot be read.
36
+ */
37
+ export declare function angularEntry(source: string | null): string;
38
+ /**
39
+ * Point the one app's serve target at `reticle.proxy.mjs`.
40
+ *
41
+ * An existing `proxyConfig` is the app's, and is never replaced: the step goes MANUAL naming the
42
+ * entry to add to it. The file is rewritten through `JSON.stringify` with the CLI's own two-space
43
+ * indent, which is how `ng new` writes it, so the diff is the one key.
44
+ */
45
+ export declare function patchAngularJson(source: string): SourcePatch;
46
+ /**
47
+ * The dev-server module. Plain ESM, no dependencies, because it runs in the app's `ng serve`.
48
+ *
49
+ * Two refusals, both about who may read a token: a request that did not come over loopback (an
50
+ * `ng serve --host 0.0.0.0` on a shared network), and a browser request from another origin — a
51
+ * different localhost app — which `Sec-Fetch-Site` names on every current browser.
52
+ *
53
+ * `bypass` answers the request itself and returns `false`, so the dummy `target` is never dialled.
54
+ * ponytail: relies on the Vite-based dev server (Angular 17+) ignoring the 404 it sets after the
55
+ * response has already ended; the older webpack dev server calls `next()` instead, and if it ever
56
+ * matters there the upgrade is a `configure` hook instead of `bypass`.
57
+ */
58
+ export declare function angularProxyFile(): string;
59
+ /**
60
+ * Add the dev-only connect to the browser entry, or ALREADY when it is there.
61
+ *
62
+ * Appended after the bootstrap rather than before it: the app must never wait on Reticle, and a
63
+ * connect that fails (no daemon yet) must not be able to stop Angular from starting.
64
+ *
65
+ * An entry wired by the previous version (`if (isDevMode())`) is rewritten in place, so a re-run is
66
+ * what takes the connect out of an existing app's production bundle.
67
+ */
68
+ export declare function patchAngularMain(source: string, port: number | undefined, projectId: string | undefined): SourcePatch;