@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.
- package/dist/detect/capabilities.d.ts +4 -1
- package/dist/detect/capabilities.js +45 -9
- package/dist/detect/detect.d.ts +58 -4
- package/dist/detect/detect.js +117 -7
- package/dist/detect/dev-script.d.ts +26 -2
- package/dist/detect/dev-script.js +80 -7
- package/dist/detect/non-js-project.d.ts +1 -1
- package/dist/detect/non-js-project.js +6 -1
- package/dist/detect/workspace-apps.d.ts +21 -0
- package/dist/detect/workspace-apps.js +111 -12
- package/dist/detect/workspace-redirect.d.ts +2 -1
- package/dist/detect/workspace-redirect.js +28 -2
- package/dist/diagnose/containerised-dev-server.d.ts +12 -4
- package/dist/diagnose/containerised-dev-server.js +36 -4
- package/dist/diagnose/csp-check.d.ts +12 -6
- package/dist/diagnose/csp-check.js +61 -13
- package/dist/diagnose/csp-doctor.d.ts +33 -2
- package/dist/diagnose/csp-doctor.js +103 -6
- package/dist/diagnose/csp-step.d.ts +27 -0
- package/dist/diagnose/csp-step.js +86 -0
- package/dist/diagnose/desktop-doctor.d.ts +1 -1
- package/dist/diagnose/desktop-doctor.js +8 -2
- package/dist/diagnose/install-hint.d.ts +9 -1
- package/dist/diagnose/install-hint.js +17 -7
- package/dist/diagnose/install-retries.d.ts +18 -2
- package/dist/diagnose/install-retries.js +50 -3
- package/dist/diagnose/terminal-wrap.d.ts +18 -0
- package/dist/diagnose/terminal-wrap.js +63 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +3 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +8 -3
- package/dist/no-package-json.d.ts +10 -0
- package/dist/no-package-json.js +121 -0
- package/dist/node-io.d.ts +9 -5
- package/dist/node-io.js +25 -7
- package/dist/patch/angular.d.ts +68 -0
- package/dist/patch/angular.js +216 -0
- package/dist/patch/astro-patch.d.ts +48 -13
- package/dist/patch/astro-patch.js +151 -43
- package/dist/patch/cra.d.ts +0 -2
- package/dist/patch/cra.js +0 -2
- package/dist/patch/electron-patch.d.ts +10 -1
- package/dist/patch/electron-patch.js +79 -27
- package/dist/patch/electron-vite-patch.js +1 -1
- package/dist/patch/nuxt-patch.d.ts +1 -1
- package/dist/patch/nuxt-patch.js +2 -2
- package/dist/patch/nuxt-snippets.d.ts +44 -0
- package/dist/patch/nuxt-snippets.js +135 -0
- package/dist/patch/remix.d.ts +24 -0
- package/dist/patch/remix.js +59 -0
- package/dist/patch/snippets.d.ts +58 -57
- package/dist/patch/snippets.js +181 -202
- package/dist/patch/static-page.d.ts +39 -0
- package/dist/patch/static-page.js +102 -0
- package/dist/patch/tanstack-start.d.ts +36 -8
- package/dist/patch/tanstack-start.js +118 -29
- package/dist/patch/vite-config.d.ts +5 -0
- package/dist/patch/vite-config.js +8 -0
- package/dist/patch/vite-env-types.d.ts +9 -0
- package/dist/patch/vite-env-types.js +19 -0
- package/dist/patch/vite-owning-config.d.ts +47 -0
- package/dist/patch/vite-owning-config.js +104 -19
- package/dist/plan/connect-steps.d.ts +10 -3
- package/dist/plan/connect-steps.js +22 -3
- package/dist/plan/framework-adapter.d.ts +5 -6
- package/dist/plan/framework-adapter.js +105 -11
- package/dist/plan/notices.js +4 -3
- package/dist/plan/plan-angular.d.ts +6 -0
- package/dist/plan/plan-angular.js +89 -0
- package/dist/plan/plan-electron-vite.d.ts +6 -3
- package/dist/plan/plan-electron-vite.js +39 -8
- package/dist/plan/plan-framework.d.ts +9 -47
- package/dist/plan/plan-framework.js +114 -323
- package/dist/plan/plan-types.d.ts +47 -2
- package/dist/plan/plan-vite.d.ts +67 -0
- package/dist/plan/plan-vite.js +256 -0
- package/dist/plan/plan.js +155 -69
- package/dist/plan/port-steps.d.ts +45 -0
- package/dist/plan/port-steps.js +119 -0
- package/dist/plan/preflight.d.ts +17 -3
- package/dist/plan/preflight.js +53 -23
- package/dist/plan/stop-hook-step.d.ts +4 -0
- package/dist/plan/stop-hook-step.js +49 -0
- package/dist/project/agent-rules.js +2 -0
- package/dist/register/mcp-clients.d.ts +13 -0
- package/dist/register/mcp-clients.js +22 -0
- package/dist/register/mcp.d.ts +26 -5
- package/dist/register/mcp.js +64 -8
- package/dist/run-types.d.ts +10 -0
- package/dist/run.js +132 -78
- package/package.json +3 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
|
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
|
-
? [
|
|
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
|
-
{
|
|
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
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,
|
|
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,
|
|
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 —
|
|
12
|
-
* are the only things the plan can ask for. It is a named constant for the same reason
|
|
13
|
-
* wire string here is one, and it is CHECKED because of the shell: on Windows these spawn
|
|
14
|
-
* a shell, and a shell turns the command name into something the shell parses rather than a
|
|
15
|
-
* executes. Arguments are quoted by `shellSafe`; the command name was the half nothing
|
|
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
|