@reticlehq/init 2.14.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 (114) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +5 -0
  3. package/README.md +29 -0
  4. package/dist/agent-rules.d.ts +116 -0
  5. package/dist/agent-rules.js +279 -0
  6. package/dist/app-choice.d.ts +29 -0
  7. package/dist/app-choice.js +39 -0
  8. package/dist/astro-host.d.ts +21 -0
  9. package/dist/astro-host.js +27 -0
  10. package/dist/astro-patch.d.ts +33 -0
  11. package/dist/astro-patch.js +103 -0
  12. package/dist/brace-scan.d.ts +24 -0
  13. package/dist/brace-scan.js +46 -0
  14. package/dist/capabilities.d.ts +40 -0
  15. package/dist/capabilities.js +167 -0
  16. package/dist/closing-hint.d.ts +35 -0
  17. package/dist/closing-hint.js +122 -0
  18. package/dist/codex-toml.d.ts +10 -0
  19. package/dist/codex-toml.js +144 -0
  20. package/dist/connect-steps.d.ts +55 -0
  21. package/dist/connect-steps.js +92 -0
  22. package/dist/cra.d.ts +82 -0
  23. package/dist/cra.js +183 -0
  24. package/dist/csp-check.d.ts +49 -0
  25. package/dist/csp-check.js +168 -0
  26. package/dist/csp-doctor.d.ts +32 -0
  27. package/dist/csp-doctor.js +62 -0
  28. package/dist/desktop-doctor.d.ts +46 -0
  29. package/dist/desktop-doctor.js +201 -0
  30. package/dist/detect.d.ts +225 -0
  31. package/dist/detect.js +334 -0
  32. package/dist/dev-script.d.ts +51 -0
  33. package/dist/dev-script.js +66 -0
  34. package/dist/dev-server-ports.d.ts +20 -0
  35. package/dist/dev-server-ports.js +45 -0
  36. package/dist/electron-patch.d.ts +11 -0
  37. package/dist/electron-patch.js +70 -0
  38. package/dist/electron-vite-patch.d.ts +12 -0
  39. package/dist/electron-vite-patch.js +100 -0
  40. package/dist/existing-config.d.ts +31 -0
  41. package/dist/existing-config.js +63 -0
  42. package/dist/format-generated.d.ts +18 -0
  43. package/dist/format-generated.js +48 -0
  44. package/dist/framework-adapter.d.ts +60 -0
  45. package/dist/framework-adapter.js +138 -0
  46. package/dist/host.d.ts +41 -0
  47. package/dist/host.js +21 -0
  48. package/dist/index.d.ts +25 -0
  49. package/dist/index.js +25 -0
  50. package/dist/init-failure.d.ts +19 -0
  51. package/dist/init-failure.js +18 -0
  52. package/dist/init-opt-out.d.ts +17 -0
  53. package/dist/init-opt-out.js +30 -0
  54. package/dist/install-hint.d.ts +9 -0
  55. package/dist/install-hint.js +41 -0
  56. package/dist/install-retries.d.ts +22 -0
  57. package/dist/install-retries.js +65 -0
  58. package/dist/install-source-config.d.ts +18 -0
  59. package/dist/install-source-config.js +34 -0
  60. package/dist/lockfiles.d.ts +14 -0
  61. package/dist/lockfiles.js +49 -0
  62. package/dist/mcp-clients.d.ts +94 -0
  63. package/dist/mcp-clients.js +327 -0
  64. package/dist/mcp-registered.d.ts +12 -0
  65. package/dist/mcp-registered.js +14 -0
  66. package/dist/mcp.d.ts +89 -0
  67. package/dist/mcp.js +129 -0
  68. package/dist/next-patch.d.ts +49 -0
  69. package/dist/next-patch.js +237 -0
  70. package/dist/node-io.d.ts +22 -0
  71. package/dist/node-io.js +150 -0
  72. package/dist/non-js-project.d.ts +44 -0
  73. package/dist/non-js-project.js +137 -0
  74. package/dist/nuxt-patch.d.ts +18 -0
  75. package/dist/nuxt-patch.js +33 -0
  76. package/dist/patch-kind.d.ts +20 -0
  77. package/dist/patch-kind.js +10 -0
  78. package/dist/plan-electron-vite.d.ts +16 -0
  79. package/dist/plan-electron-vite.js +72 -0
  80. package/dist/plan-framework.d.ts +122 -0
  81. package/dist/plan-framework.js +773 -0
  82. package/dist/plan.d.ts +312 -0
  83. package/dist/plan.js +650 -0
  84. package/dist/platform.d.ts +16 -0
  85. package/dist/platform.js +16 -0
  86. package/dist/preflight.d.ts +30 -0
  87. package/dist/preflight.js +52 -0
  88. package/dist/project-id.d.ts +12 -0
  89. package/dist/project-id.js +25 -0
  90. package/dist/refresh-rules.d.ts +40 -0
  91. package/dist/refresh-rules.js +51 -0
  92. package/dist/remember-project.d.ts +24 -0
  93. package/dist/remember-project.js +40 -0
  94. package/dist/run.d.ts +122 -0
  95. package/dist/run.js +762 -0
  96. package/dist/slash-command.d.ts +27 -0
  97. package/dist/slash-command.js +132 -0
  98. package/dist/snippets.d.ts +267 -0
  99. package/dist/snippets.js +898 -0
  100. package/dist/tanstack-start.d.ts +18 -0
  101. package/dist/tanstack-start.js +57 -0
  102. package/dist/version.d.ts +18 -0
  103. package/dist/version.js +23 -0
  104. package/dist/vite-config.d.ts +18 -0
  105. package/dist/vite-config.js +111 -0
  106. package/dist/vite-owning-config.d.ts +25 -0
  107. package/dist/vite-owning-config.js +219 -0
  108. package/dist/windows-quote.d.ts +38 -0
  109. package/dist/windows-quote.js +70 -0
  110. package/dist/workspace-apps.d.ts +40 -0
  111. package/dist/workspace-apps.js +188 -0
  112. package/dist/workspace-redirect.d.ts +22 -0
  113. package/dist/workspace-redirect.js +97 -0
  114. package/package.json +53 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The TanStack Start connect recipe.
3
+ *
4
+ * Its own module rather than another entry in `snippets.ts`, which was at the 1000-line backstop:
5
+ * Start is the one framework whose document module `init` deliberately refuses to write, so the
6
+ * recipe and the reasoning behind that refusal are a unit.
7
+ */
8
+ /** TanStack Start's document module — the file that SSRs `<html>`. */
9
+ export declare const TANSTACK_START_ROOT_PATH = "src/routes/__root.tsx";
10
+ /**
11
+ * The TanStack Start recipe, printed rather than written.
12
+ *
13
+ * `__root.tsx` is the document. A static import of the SDK on that module SSRs and 500s, so `init`
14
+ * does not write one. The connect has to be a client-only dynamic import inside `useEffect`, with
15
+ * the pairing token the plugin inlines as `__RETICLE_TOKEN__`. Same judgement React Router already
16
+ * makes about `app/entry.client.tsx`: a half-written document is worse than a documented manual step.
17
+ */
18
+ export declare function tanstackStartManual(port: number | undefined, projectId?: string, rootPath?: string): string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The TanStack Start connect recipe.
3
+ *
4
+ * Its own module rather than another entry in `snippets.ts`, which was at the 1000-line backstop:
5
+ * Start is the one framework whose document module `init` deliberately refuses to write, so the
6
+ * recipe and the reasoning behind that refusal are a unit.
7
+ */
8
+ import { UiLibrary } from './detect.js';
9
+ import { connectArg, sdkImport, UNVERIFIED_TANSTACK_START_NOTE } from './snippets.js';
10
+ /** TanStack Start's document module — the file that SSRs `<html>`. */
11
+ export const TANSTACK_START_ROOT_PATH = 'src/routes/__root.tsx';
12
+ /**
13
+ * The TanStack Start recipe, printed rather than written.
14
+ *
15
+ * `__root.tsx` is the document. A static import of the SDK on that module SSRs and 500s, so `init`
16
+ * does not write one. The connect has to be a client-only dynamic import inside `useEffect`, with
17
+ * the pairing token the plugin inlines as `__RETICLE_TOKEN__`. Same judgement React Router already
18
+ * makes about `app/entry.client.tsx`: a half-written document is worse than a documented manual step.
19
+ */
20
+ export function tanstackStartManual(port, projectId, rootPath = TANSTACK_START_ROOT_PATH) {
21
+ const sdk = sdkImport(UiLibrary.REACT);
22
+ const base = connectArg(port, projectId);
23
+ const fields = '' === base ? '' : `${base.slice(1, -1).trim()}, `;
24
+ const installLine = sdk.usesInstall
25
+ ? ' install();'
26
+ : ' // No React adapter here: the sensor has no install() to call.';
27
+ const imports = sdk.usesInstall ? 'reticle, install' : 'reticle';
28
+ return `TanStack Start SSRs <html> from ${rootPath} (HeadContent / Scripts) and never sends Vite's
29
+ index.html, so the plugin's connect injection never fires. Keep the plugin anyway, with
30
+ reticle({ inject: false }): only the injection half is inapplicable, and the stamping half is
31
+ what puts data-reticle-source on the JSX. Dropping the plugin because one of its two jobs did
32
+ not apply is how Remix lost file:line.
33
+
34
+ Connect from a CLIENT-ONLY effect in ${rootPath}. A static SDK import on that module SSRs and
35
+ 500s. Do not guard on window.location.hostname === 'localhost' — import.meta.env.DEV is the
36
+ correct guard, and it does not care what host you develop on.
37
+
38
+ Add this useEffect to the App component (import useEffect from react if it is not already there):
39
+
40
+ useEffect(() => {
41
+ if (import.meta.env.DEV) {
42
+ const token = typeof __RETICLE_TOKEN__ !== 'undefined' ? __RETICLE_TOKEN__ : '';
43
+ void import('${sdk.specifier}').then(({ ${imports} }) => {
44
+ ${installLine}
45
+ reticle.connect({
46
+ ${fields}...(token.length > 0 ? { token } : {}),
47
+ });
48
+ });
49
+ }
50
+ }, []);
51
+
52
+ The plugin inlines __RETICLE_TOKEN__ when Vite resolves its config. Start the daemon BEFORE the
53
+ dev server so that file exists; a server that started first froze an empty token and the page
54
+ looks like it "won't dial". Restart after init, and after the daemon is up.
55
+
56
+ ${UNVERIFIED_TANSTACK_START_NOTE}`;
57
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The release this scaffolder belongs to, read from its own package.json at startup.
3
+ *
4
+ * Read here rather than injected because it is a MODULE constant in three generators
5
+ * (`snippets.ts`, `agent-rules.ts`, `mcp.ts`) — the CDN snippet pins `@reticlehq/browser@<this>`
6
+ * and cannot be built without it. Every published `@reticlehq/*` package shares one version, and
7
+ * `@reticlehq/server` depends on this one by exact version, so what a user resolves here is by
8
+ * construction the version of the server that invoked it. `version-lockstep.test.ts` in the server
9
+ * fails the fast gate if the two manifests ever disagree.
10
+ */
11
+ export declare const RETICLE_VERSION: string;
12
+ /**
13
+ * The published npm package that carries the `reticle` bin — the thing this scaffolder tells users
14
+ * and agents to run. NOT this package's own name: `@reticlehq/init` has no bin, and a self-update
15
+ * or an `npx` line pointing at it installs a library nobody can execute. Pinned as a constant and
16
+ * checked against `@reticlehq/server`'s manifest by `version-lockstep.test.ts`.
17
+ */
18
+ export declare const RETICLE_NPM_PACKAGE = "@reticlehq/server";
@@ -0,0 +1,23 @@
1
+ import { createRequire } from 'node:module';
2
+ // NOTE: a RUNTIME path, not an import — `tsc` does not check it and a file move will not rewrite
3
+ // it. One level up because this file sits at the package src root: from `src/` and from `dist/`
4
+ // alike, `../package.json` is the package manifest.
5
+ const _pkg = createRequire(import.meta.url)('../package.json');
6
+ /**
7
+ * The release this scaffolder belongs to, read from its own package.json at startup.
8
+ *
9
+ * Read here rather than injected because it is a MODULE constant in three generators
10
+ * (`snippets.ts`, `agent-rules.ts`, `mcp.ts`) — the CDN snippet pins `@reticlehq/browser@<this>`
11
+ * and cannot be built without it. Every published `@reticlehq/*` package shares one version, and
12
+ * `@reticlehq/server` depends on this one by exact version, so what a user resolves here is by
13
+ * construction the version of the server that invoked it. `version-lockstep.test.ts` in the server
14
+ * fails the fast gate if the two manifests ever disagree.
15
+ */
16
+ export const RETICLE_VERSION = _pkg.version;
17
+ /**
18
+ * The published npm package that carries the `reticle` bin — the thing this scaffolder tells users
19
+ * and agents to run. NOT this package's own name: `@reticlehq/init` has no bin, and a self-update
20
+ * or an `npx` line pointing at it installs a library nobody can execute. Pinned as a constant and
21
+ * checked against `@reticlehq/server`'s manifest by `version-lockstep.test.ts`.
22
+ */
23
+ export const RETICLE_NPM_PACKAGE = '@reticlehq/server';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Pure, conservative patcher for a Vite config: add the `@reticlehq/vite-plugin` import and drop
3
+ * `reticle` into the `plugins` array. Only handles the obvious, common shape — anything ambiguous
4
+ * bails to a `manual` result so we never half-edit a build config (a broken config is worse than a
5
+ * documented manual step).
6
+ */
7
+ import { PatchKind, type SourcePatch } from './patch-kind.js';
8
+ export declare const VITE_IMPORT = "import { reticle } from '@reticlehq/vite-plugin';";
9
+ /** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
10
+ export declare const VitePatchKind: {
11
+ readonly APPLY: "apply";
12
+ readonly ALREADY: "already";
13
+ readonly MANUAL: "manual";
14
+ };
15
+ export type VitePatchKind = PatchKind;
16
+ type VitePatch = SourcePatch;
17
+ export declare function patchViteConfig(source: string, port?: number, captureBodies?: boolean, inject?: boolean, sourceMapping?: boolean): VitePatch;
18
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Pure, conservative patcher for a Vite config: add the `@reticlehq/vite-plugin` import and drop
3
+ * `reticle` into the `plugins` array. Only handles the obvious, common shape — anything ambiguous
4
+ * bails to a `manual` result so we never half-edit a build config (a broken config is worse than a
5
+ * documented manual step).
6
+ */
7
+ import { PatchKind } from './patch-kind.js';
8
+ export const VITE_IMPORT = "import { reticle } from '@reticlehq/vite-plugin';";
9
+ const RETICLE_MARKER = '@reticlehq/vite-plugin';
10
+ /**
11
+ * The `reticle(...)` call — the bridge port so the injected connect targets it.
12
+ *
13
+ * **`captureNetworkBodies` is OPT-IN.** It was written in by default, and the argument for that was
14
+ * a good one: without a body, a write that answers 2xx grades `unknown / outcome_unread`, because a
15
+ * 200 describes the transport and not the result. Measured on a real payments UI — a refund posted
16
+ * rupees into a paise field, the server answered 200 having refunded a hundredth of it, the page
17
+ * rendered the amount the user had typed, and every DOM-level check passed.
18
+ *
19
+ * That argument justifies the CAPABILITY. It does not justify the default, and the distinction is
20
+ * what #705 is about. A healthcare workspace proxying authenticated API traffic through Vite ran
21
+ * `init`, and on the first drive their login tokens and patient payloads were in the daemon's
22
+ * buffer. The old comment here conceded the exact gap — "the credential classes are redacted ... but
23
+ * an address or an email is not, and nothing here should decide that for someone silently" — and
24
+ * then decided it for them, because writing the line into their config IS deciding. `init` is run
25
+ * unattended by an agent; the person who knows the data is sensitive is not in the room.
26
+ *
27
+ * So the default is off and the ways in are all deliberate:
28
+ * - `reticle init --capture-bodies` writes the line, for someone who has decided
29
+ * - `VITE_RETICLE_CAPTURE_BODIES=1` turns it on for ONE dev-server run, no config edit
30
+ * - adding the option by hand, which is what the tools tell you to do
31
+ *
32
+ * Nothing goes quiet in exchange. Every tool that needs a body already says so when it is missing
33
+ * and names the option — see honesty/uncaptured-bodies.ts, honesty/verified.ts, predicate-eval.ts
34
+ * and reconcile-tools.ts — so the capability is discovered at the moment it is wanted, by the person
35
+ * who wanted it, instead of being switched on before anyone has asked.
36
+ */
37
+ function reticlePluginCall(opts) {
38
+ const options = [
39
+ ...(opts.port === undefined ? [] : [`port: ${String(opts.port)}`]),
40
+ ...(false === opts.inject ? ['inject: false'] : []),
41
+ ...(opts.captureBodies ? ['captureNetworkBodies: true'] : []),
42
+ ...(opts.sourceMapping ? [] : ['sourceMapping: false']),
43
+ ];
44
+ return 0 === options.length ? 'reticle()' : `reticle({ ${options.join(', ')} })`;
45
+ }
46
+ /** Matches the start of a `plugins: [` array literal. */
47
+ const PLUGINS_ARRAY = /plugins\s*:\s*\[/;
48
+ /** Matches an ES import statement (used to place our import after the last one). */
49
+ const IMPORT_LINE = /^import\s.+from\s+['"][^'"]+['"];?\s*$/gm;
50
+ /**
51
+ * The opening `{` of the exported config object — `defineConfig({`, or a bare `export default {`.
52
+ * Used only when there is no `plugins` array to extend: the object is right there, so adding the
53
+ * key is the same edit as extending the array, and bailing sent a user whose config merely set
54
+ * `server.port` to a manual paste for a change we can make correctly.
55
+ *
56
+ * A config built by a call (`defineConfig(buildOptions())`) has no literal to extend and still
57
+ * bails — the rule stays "only edit a shape we can see whole".
58
+ */
59
+ const CONFIG_OBJECT = /(export\s+default\s+(?:defineConfig\s*\(\s*)?)\{/;
60
+ /** Alias kept so existing call sites read in Vite terms; the vocabulary is shared (see patch-kind). */
61
+ export const VitePatchKind = PatchKind;
62
+ const NO_PLUGINS_REASON = "couldn't find a `plugins: [...]` array to extend";
63
+ function insertImport(source) {
64
+ const matches = [...source.matchAll(IMPORT_LINE)];
65
+ const last = matches[matches.length - 1];
66
+ if (last?.index === undefined) {
67
+ return `${VITE_IMPORT}\n${source}`;
68
+ }
69
+ const end = last.index + last[0].length;
70
+ return `${source.slice(0, end)}\n${VITE_IMPORT}${source.slice(end)}`;
71
+ }
72
+ /**
73
+ * Insert right after the opening `[` of the plugins array, spaced the way the surrounding line is.
74
+ *
75
+ * A multi-line array puts a newline next, and `[reticle(), \n` leaves trailing whitespace — exactly
76
+ * what a formatter rewrites, turning a one-line install into a diff against the user's own style. A
77
+ * single-line array needs the space, or the result reads `[reticle(),react()]`.
78
+ */
79
+ function insertPlugin(source, call) {
80
+ return source.replace(PLUGINS_ARRAY, (match, _g, offset) => {
81
+ const next = source[offset + match.length] ?? '';
82
+ const separator = '' === next || /\s/.test(next) ? '' : ' ';
83
+ return `${match}${call},${separator}`;
84
+ });
85
+ }
86
+ /**
87
+ * Add a whole `plugins: [reticle()]` key to a config object that has none, matching the layout of
88
+ * the object it lands in: a multi-line object gets its own indented line, a one-liner stays inline.
89
+ */
90
+ function insertPluginsKey(source, call) {
91
+ return source.replace(CONFIG_OBJECT, (_match, prefix, offset) => {
92
+ const rest = source.slice(offset + _match.length);
93
+ const multiline = /^\s*\n/.test(rest);
94
+ const indent = /^\s*\n(\s*)\S/.exec(rest)?.[1] ?? ' ';
95
+ const key = `plugins: [${call}],`;
96
+ return multiline ? `${prefix}{\n${indent}${key}` : `${prefix}{ ${key}`;
97
+ });
98
+ }
99
+ export function patchViteConfig(source, port, captureBodies = false, inject = true, sourceMapping = true) {
100
+ if (source.includes(RETICLE_MARKER)) {
101
+ return { kind: VitePatchKind.ALREADY };
102
+ }
103
+ const call = reticlePluginCall({ port, captureBodies, inject, sourceMapping });
104
+ if (PLUGINS_ARRAY.test(source)) {
105
+ return { kind: VitePatchKind.APPLY, code: insertImport(insertPlugin(source, call)) };
106
+ }
107
+ if (CONFIG_OBJECT.test(source)) {
108
+ return { kind: VitePatchKind.APPLY, code: insertImport(insertPluginsKey(source, call)) };
109
+ }
110
+ return { kind: VitePatchKind.MANUAL, reason: NO_PLUGINS_REASON };
111
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Patching a build config that owns its own Vite instance.
3
+ *
4
+ * Astro and Nuxt are the same problem twice: each runs Vite itself and renders its own HTML, so the
5
+ * Vite plugin never loads and nothing sits in the page's path to inject the pairing token. So the
6
+ * token has to be inlined by the config, the dep cache has to be warmed for the dynamic SDK import,
7
+ * and the daemon's journal has to be kept out of the dev-server watcher — three edits to one `vite:`
8
+ * block, merged into whatever the app already put there.
9
+ *
10
+ * Lifted out of `astro-patch.ts` unchanged when Nuxt needed the identical patch. The two frameworks
11
+ * differ in exactly two ways, and both are parameters here: the config-factory call to extend
12
+ * (`defineConfig` / `defineNuxtConfig`), and which SDK package to pre-bundle.
13
+ *
14
+ * Every branch bails to `manual` (the printed recipe) on a shape it does not fully recognise.
15
+ * Half-editing somebody's build config is worse than a documented manual step.
16
+ */
17
+ import { type SourcePatch } from './patch-kind.js';
18
+ /** What differs between one Vite-owning framework and the next. */
19
+ export interface ViteOwningConfig {
20
+ /** The config-factory call whose object literal our `vite:` block goes into. */
21
+ readonly defineCall: RegExp;
22
+ /** The SDK package this framework's connect imports, declared so Vite pre-bundles it. */
23
+ readonly sdkSpecifier: string;
24
+ }
25
+ export declare function patchViteOwningConfig(source: string, config: ViteOwningConfig): SourcePatch;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Patching a build config that owns its own Vite instance.
3
+ *
4
+ * Astro and Nuxt are the same problem twice: each runs Vite itself and renders its own HTML, so the
5
+ * Vite plugin never loads and nothing sits in the page's path to inject the pairing token. So the
6
+ * token has to be inlined by the config, the dep cache has to be warmed for the dynamic SDK import,
7
+ * and the daemon's journal has to be kept out of the dev-server watcher — three edits to one `vite:`
8
+ * block, merged into whatever the app already put there.
9
+ *
10
+ * Lifted out of `astro-patch.ts` unchanged when Nuxt needed the identical patch. The two frameworks
11
+ * differ in exactly two ways, and both are parameters here: the config-factory call to extend
12
+ * (`defineConfig` / `defineNuxtConfig`), and which SDK package to pre-bundle.
13
+ *
14
+ * Every branch bails to `manual` (the printed recipe) on a shape it does not fully recognise.
15
+ * Half-editing somebody's build config is worse than a documented manual step.
16
+ */
17
+ import { ReticleDir } from '@reticlehq/core';
18
+ import { blockAfter } from './brace-scan.js';
19
+ import { PatchKind } from './patch-kind.js';
20
+ /** Present in a patched config AND in a hand-followed recipe — so both count as already wired. */
21
+ const CONFIG_MARKER = '__RETICLE_TOKEN__';
22
+ /**
23
+ * The SDK, declared so Vite pre-bundles it BEFORE the first page load.
24
+ *
25
+ * The connect does `await import('@reticlehq/react')`. Undeclared, Vite meets that import mid-load,
26
+ * pre-bundles it, and the hashed `/node_modules/.vite/deps/@reticlehq_react.js?v=…` URL the browser
27
+ * already requested stops existing — the import rejects with "Failed to fetch dynamically imported
28
+ * module", connect() never runs, and the page looks entirely normal. Measured on astro-nanostores,
29
+ * intermittent on whether the dep cache was warm. The Vite plugin has declared the SDK for this
30
+ * exact reason since the bug was first found on React; a hand-patched config never got it.
31
+ */
32
+ function sdkInclude(config) {
33
+ return `'${config.sdkSpecifier}'`;
34
+ }
35
+ /**
36
+ * The daemon's journal directory, kept out of the dev server's watcher.
37
+ *
38
+ * The daemon writes `.reticle/` into the project root and rewrites `ambient.json` atomically
39
+ * (`.tmp` + rename) for as long as a session is live. These dev servers are Vite watching that root,
40
+ * and `.reticle/` is not in Vite's default ignore list — so every journal write read as a project
41
+ * file changing and Vite answered with a full page reload. That closes a loop with no exit: the page
42
+ * loads, the SDK connects and streams events, the daemon journals them, Vite reloads the page, the
43
+ * SDK reconnects. It ran several times a second for as long as the dev server was up, and the
44
+ * symptom looked nothing like the cause — stale refs, actions dying mid-flight, and a log full of
45
+ * connect/disconnect pairs that read as a flapping SDK.
46
+ *
47
+ * The Vite plugin sets the same ignore in its `config` hook. These frameworks never load that
48
+ * plugin, so they need their own.
49
+ *
50
+ * A RegExp, not a glob. chokidar dropped glob support in v4 and Vite 7+ ships v4/v5, where a
51
+ * double-star pattern is accepted and matches nothing — the ignore would be visibly present in the
52
+ * config and do nothing at all. Measured against the chokidar this repo resolves.
53
+ */
54
+ const WATCH_IGNORE_LITERAL = `/(^|[\\\\/])\\.${ReticleDir.ROOT.slice(1)}([\\\\/]|$)/`;
55
+ /**
56
+ * An `include:` the app already declared — ours joins that array instead of adding a second key.
57
+ * Deliberately not anchored to a newline: `optimizeDeps: { include: ['x'] }` on one line is the
58
+ * common way to write it, and requiring a line break made the merge miss it and duplicate the key.
59
+ */
60
+ const EXISTING_INCLUDE = /(\s*include\s*:\s*\[)/;
61
+ /** A `vite:` key whose value is an object LITERAL — the one shape we can merge into safely. */
62
+ const VITE_OBJECT_KEY = /(^\s*vite\s*:\s*\{)/m;
63
+ /** Any `vite:` key at all. One that is not an object literal (a spread, an imported config) is ours to refuse. */
64
+ const ANY_VITE_KEY = /^\s*vite\s*:/m;
65
+ const HELPER = `
66
+ function reticleToken() {
67
+ const dir = process.env['RETICLE_PAIRING_TOKEN_DIR'] || join(homedir(), '.reticle');
68
+ try { return readFileSync(join(dir, 'pairing-token'), 'utf8').trim(); } catch { return ''; }
69
+ }
70
+ `;
71
+ const HELPER_IMPORTS = `import { readFileSync } from 'node:fs';
72
+ import { homedir } from 'node:os';
73
+ import { join } from 'node:path';
74
+ `;
75
+ /**
76
+ * The keys we add inside `vite:`, each with what it must contain.
77
+ *
78
+ * Kept as a list rather than one blob because a config that already sets one of these must be merged
79
+ * INTO, not duplicated: the real astro-nanostores config has `build: { chunkSizeWarningLimit }`, and
80
+ * inserting our own `build` beside it gave the object two — where the last one wins, so
81
+ * `target: 'es2022'` was silently discarded while init reported success. Losing that target is not
82
+ * cosmetic: Astro's default down-levels the modern SDK bundle and dies on a destructuring transform.
83
+ */
84
+ function viteKeys(config) {
85
+ return [
86
+ { key: 'build', inner: `\n target: 'es2022',` },
87
+ {
88
+ key: 'optimizeDeps',
89
+ inner: `\n include: [${sdkInclude(config)}],\n esbuildOptions: { target: 'es2022' },`,
90
+ },
91
+ {
92
+ key: 'define',
93
+ inner: `\n __RETICLE_TOKEN__: JSON.stringify(reticleToken()),\n __RETICLE_ROOT__: JSON.stringify(process.cwd()),`,
94
+ },
95
+ // Merged in first, so a `server: { port }` the app already set keeps its port. An app that
96
+ // already sets `server.watch` itself is the one shape this loses to — the inner `watch` key would
97
+ // be duplicated and the app's would win — which is the same nested-merge ceiling `build` has.
98
+ { key: 'server', inner: `\n watch: { ignored: [${WATCH_IGNORE_LITERAL}] },` },
99
+ ];
100
+ }
101
+ /** Whole-key form, for a `vite:` block that does not have the key at all. */
102
+ function wholeKeys(config) {
103
+ return {
104
+ build: `\n build: { target: 'es2022' },`,
105
+ optimizeDeps: `\n optimizeDeps: { include: [${sdkInclude(config)}], esbuildOptions: { target: 'es2022' } },`,
106
+ define: `\n define: {\n __RETICLE_TOKEN__: JSON.stringify(reticleToken()),\n __RETICLE_ROOT__: JSON.stringify(process.cwd()),\n },`,
107
+ server: `\n server: { watch: { ignored: [${WATCH_IGNORE_LITERAL}] } },`,
108
+ };
109
+ }
110
+ /**
111
+ * Add our keys to an existing `vite: { ... }` block, merging into any the app already set.
112
+ *
113
+ * Returns null when a colliding key is not an object literal (`build: sharedConfig`) — there is no
114
+ * brace to merge into, and duplicating or replacing it would corrupt someone's build.
115
+ */
116
+ function mergeIntoViteBlock(source, braceAt, config) {
117
+ let out = source;
118
+ const whole = wholeKeys(config);
119
+ for (const { key, inner } of viteKeys(config)) {
120
+ // Scoped to the vite block by searching from its opening brace: a `build:` under `markdown:` or
121
+ // at the top level is somebody else's key and must not be touched.
122
+ const existing = new RegExp(`(\\n\\s*${key}\\s*:\\s*)([\\{A-Za-z_])`).exec(out.slice(braceAt));
123
+ if (existing?.index === undefined) {
124
+ out = `${out.slice(0, braceAt)}${whole[key] ?? ''}${out.slice(braceAt)}`;
125
+ continue;
126
+ }
127
+ // The character after the colon decides: `{` is a literal we can open; anything else is a
128
+ // reference we must not touch.
129
+ if (existing[2] !== '{')
130
+ return null;
131
+ const at = braceAt + existing.index + existing[0].length; // just past the `{`
132
+ // An `include:` the app already wrote is an ARRAY, and a second `include:` key in the same
133
+ // object literal is not a merge — the last one wins and one of the two silently disappears.
134
+ // That is how `build.target` was lost while init still reported success. Join their array.
135
+ // Scoped to THIS block's braces: an `include:` under a later key is somebody else's array, and
136
+ // appending the SDK to it would both miss the target and edit a config we were not asked to.
137
+ const own = 'optimizeDeps' === key ? EXISTING_INCLUDE.exec(blockAfter(out, at)) : null;
138
+ if (own?.index === undefined) {
139
+ out = insertAt(out, at, inner);
140
+ continue;
141
+ }
142
+ // Their array first, then the rest of our keys at the block's start — inserting at the LOWER
143
+ // offset second keeps the first offset valid.
144
+ out = insertAt(out, at + own.index + own[0].length, `${sdkInclude(config)}, `);
145
+ out = insertAt(out, at, withoutInclude(inner, config));
146
+ }
147
+ return out;
148
+ }
149
+ function insertAt(source, at, text) {
150
+ return `${source.slice(0, at)}${text}${source.slice(at)}`;
151
+ }
152
+ /** The optimizeDeps inner minus our `include:` line, for when the app already has one to join. */
153
+ function withoutInclude(inner, config) {
154
+ return inner.replace(`\n include: [${sdkInclude(config)}],`, '');
155
+ }
156
+ /**
157
+ * The `vite:` block these frameworks need. `build.target` is raised because Astro's default
158
+ * down-levels the modern SDK bundle and dies on a destructuring transform; `__RETICLE_ROOT__` is
159
+ * defined because without it every source pointer comes back as an absolute path from the machine
160
+ * that ran `init`.
161
+ */
162
+ function viteBlock(config) {
163
+ return ` vite: {
164
+ build: { target: 'es2022' },
165
+ optimizeDeps: { include: [${sdkInclude(config)}], esbuildOptions: { target: 'es2022' } },
166
+ define: {
167
+ __RETICLE_TOKEN__: JSON.stringify(reticleToken()),
168
+ __RETICLE_ROOT__: JSON.stringify(process.cwd()),
169
+ },
170
+ server: { watch: { ignored: [${WATCH_IGNORE_LITERAL}] } },
171
+ },
172
+ `;
173
+ }
174
+ export function patchViteOwningConfig(source, config) {
175
+ if (source.includes(CONFIG_MARKER))
176
+ return { kind: PatchKind.ALREADY };
177
+ // A `vite: { ... }` block is an object literal, so our keys can go straight after the brace and
178
+ // whatever is already in there is untouched. Refusing outright was the only genuine install defect
179
+ // left in the gate: the config bailed while the LAYOUT patch applied anyway, leaving an app with a
180
+ // connect snippet, no inlined token and no raised build target — which cannot connect, reported as
181
+ // one OK step and one warning.
182
+ const viteObject = VITE_OBJECT_KEY.exec(source);
183
+ if (viteObject?.index !== undefined) {
184
+ const at = viteObject.index + viteObject[0].length;
185
+ const merged = mergeIntoViteBlock(source, at, config);
186
+ if (merged !== null) {
187
+ return {
188
+ kind: PatchKind.APPLY,
189
+ code: `${HELPER_IMPORTS}${merged.trimStart()}\n${HELPER}`.trimEnd() + '\n',
190
+ };
191
+ }
192
+ return {
193
+ kind: PatchKind.MANUAL,
194
+ reason: 'this config sets a `vite` key Reticle needs (build / optimizeDeps / define) to something ' +
195
+ 'other than an object literal — merging into it is your call, not a text edit Reticle should make',
196
+ };
197
+ }
198
+ if (ANY_VITE_KEY.test(source)) {
199
+ // `vite: sharedConfig` — not an object literal, so there is no brace to merge into and guessing
200
+ // would corrupt a build config. The recipe is the honest answer here.
201
+ return {
202
+ kind: PatchKind.MANUAL,
203
+ reason: 'this config sets `vite:` to something other than an object literal — merging into it is your call, not a text edit Reticle should make',
204
+ };
205
+ }
206
+ const opening = config.defineCall.exec(source);
207
+ if (opening?.index === undefined) {
208
+ return {
209
+ kind: PatchKind.MANUAL,
210
+ reason: "couldn't find a config call to extend",
211
+ };
212
+ }
213
+ const at = opening.index + opening[0].length;
214
+ const withBlock = `${source.slice(0, at)}\n${viteBlock(config)}${source.slice(at)}`;
215
+ return {
216
+ kind: PatchKind.APPLY,
217
+ code: `${HELPER_IMPORTS}${withBlock.trimStart()}\n${HELPER}`.trimEnd() + '\n',
218
+ };
219
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
3
+ *
4
+ * The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
5
+ * the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
6
+ * pure function is most of the fix: it can be asserted anywhere, and it is.
7
+ *
8
+ * ## What was wrong
9
+ *
10
+ * ```ts
11
+ * /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
12
+ * ```
13
+ *
14
+ * Two independent holes, both reachable from an ordinary directory path:
15
+ *
16
+ * 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
17
+ * `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
18
+ * region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
19
+ * (`js/incomplete-sanitization`, high).
20
+ * 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
21
+ * neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
22
+ * nothing for that; it needs the metacharacters in the trigger.
23
+ *
24
+ * ## The rule implemented here
25
+ *
26
+ * `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
27
+ * run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
28
+ * so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
29
+ * it does not interpret inside a quoted region.
30
+ *
31
+ * ## Known limit, stated rather than papered over
32
+ *
33
+ * `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
34
+ * `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
35
+ * variable substitution, not command execution, and every caller here passes paths and package
36
+ * names rather than user prose.
37
+ */
38
+ export declare function windowsShellArg(arg: string): string;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Quote one argument for a Windows command line. Pure, and deliberately NOT platform-gated.
3
+ *
4
+ * The rule used to live inside a `process.platform === WINDOWS` branch in node-io.ts, which meant
5
+ * the Windows path could not be exercised on a mac or in CI's Linux job — so it stayed wrong. A
6
+ * pure function is most of the fix: it can be asserted anywhere, and it is.
7
+ *
8
+ * ## What was wrong
9
+ *
10
+ * ```ts
11
+ * /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg
12
+ * ```
13
+ *
14
+ * Two independent holes, both reachable from an ordinary directory path:
15
+ *
16
+ * 1. **Backslashes were not escaped.** `C:\Users\ada\My Projects\` became
17
+ * `"C:\Users\ada\My Projects\"`, whose trailing `\"` reads as an ESCAPED quote — the quoted
18
+ * region never closes and the rest of the line is re-parsed by cmd.exe. CodeQL flagged this one
19
+ * (`js/incomplete-sanitization`, high).
20
+ * 2. **The predicate only looked for whitespace and quotes.** An argument like `foo&whoami` has
21
+ * neither, so it was handed to cmd.exe completely unquoted. Correct backslash escaping does
22
+ * nothing for that; it needs the metacharacters in the trigger.
23
+ *
24
+ * ## The rule implemented here
25
+ *
26
+ * `CommandLineToArgvW`: a run of backslashes is literal unless it precedes a `"`, in which case the
27
+ * run is doubled and the quote escaped; a run at the very end sits before the closing quote we add,
28
+ * so it is doubled too. Correctly-terminated quotes also neutralise cmd.exe's metacharacters, which
29
+ * it does not interpret inside a quoted region.
30
+ *
31
+ * ## Known limit, stated rather than papered over
32
+ *
33
+ * `%VAR%` is expanded by cmd.exe even inside quotes, and no quoting prevents it — only avoiding
34
+ * `shell: true` does, which Windows needs so `pnpm.cmd`/`npx.cmd` resolve (see `shellOpt`). That is
35
+ * variable substitution, not command execution, and every caller here passes paths and package
36
+ * names rather than user prose.
37
+ */
38
+ /**
39
+ * Characters that make cmd.exe do something other than pass the text along.
40
+ *
41
+ * Whitespace and `"` split or delimit; the rest are the shell's control operators. An argument
42
+ * containing any of them must be quoted, not just one containing a space.
43
+ */
44
+ const NEEDS_QUOTING = /[\s"&|<>^()!%,;=]/;
45
+ export function windowsShellArg(arg) {
46
+ // An empty argument must still occupy a slot in argv. Unquoted it disappears entirely, silently
47
+ // shifting every argument after it by one.
48
+ if (0 === arg.length)
49
+ return '""';
50
+ if (!NEEDS_QUOTING.test(arg))
51
+ return arg;
52
+ let out = '"';
53
+ let backslashes = 0;
54
+ for (const ch of arg) {
55
+ if ('\\' === ch) {
56
+ backslashes += 1;
57
+ continue;
58
+ }
59
+ if ('"' === ch) {
60
+ // 2n+1 backslashes → n literal backslashes and an escaped quote.
61
+ out += '\\'.repeat(2 * backslashes + 1) + '"';
62
+ backslashes = 0;
63
+ continue;
64
+ }
65
+ out += '\\'.repeat(backslashes) + ch;
66
+ backslashes = 0;
67
+ }
68
+ // A trailing run sits immediately before the closing quote, so it must be doubled or it escapes it.
69
+ return `${out}${'\\'.repeat(2 * backslashes)}"`;
70
+ }
@@ -0,0 +1,40 @@
1
+ import type { InitIo } from './run.js';
2
+ /**
3
+ * The file names that say what a directory IS, shared by everything that has to ask.
4
+ *
5
+ * They lived in run.ts and are read from here now because `findWorkspaceApps` moved and needs them —
6
+ * one definition, imported in both directions of the question ("is this an app?" and "which app?").
7
+ */
8
+ export declare const PACKAGE_JSON = "package.json";
9
+ export declare const VITE_CONFIG_CANDIDATES: string[];
10
+ export declare const ELECTRON_VITE_CONFIG_CANDIDATES: string[];
11
+ export declare const NEXT_CONFIG_CANDIDATES: string[];
12
+ interface WorkspaceSources {
13
+ /** Raw contents of pnpm-workspace.yaml, when present. */
14
+ pnpmWorkspace?: string;
15
+ /** The `workspaces` field of package.json — array form or the yarn/npm object form. */
16
+ pkgWorkspaces?: unknown;
17
+ /** Directory names at the repo root, used when nothing is declared. */
18
+ topLevelDirs?: readonly string[];
19
+ }
20
+ export declare function workspaceParents(sources: WorkspaceSources): string[];
21
+ /**
22
+ * Which directories under this workspace are runnable apps.
23
+ *
24
+ * Moved here from run.ts, which was over the 1000-line cap and had carried a note saying it was at
25
+ * its cohesion limit. `library-path-boundary.test.ts` had already written down that this function
26
+ * "squats in init/ for historical reasons" — it is a general-purpose question about a repository's
27
+ * shape, next to `workspaceParents`, which answers the other half of it.
28
+ */
29
+ /** Deps that mark a directory as a runnable web app even when it has no bundler config file. */
30
+ export declare const APP_DEPS: readonly ["next", "vite", "electron-vite"];
31
+ /**
32
+ * App directories under a workspace root.
33
+ *
34
+ * Running `reticle init` at the repo root is what people actually do, and in a monorepo the app is a
35
+ * directory down — so init detected "no framework", printed a wall of manual HTML instructions, and
36
+ * would have installed the SDK into the ROOT package.json. It already walks UP for the lockfile, so
37
+ * it knows it is in a workspace; this is the matching walk DOWN.
38
+ */
39
+ export declare function findWorkspaceApps(io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
40
+ export {};