@smeltjs/core 0.4.0 → 0.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.
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js +9 -4
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/doctor.d.ts +66 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/hooks.d.ts +36 -2
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +108 -64
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +8 -14
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/installed.d.ts +56 -0
- package/dist/cli/installed.d.ts.map +1 -0
- package/dist/cli/installed.js +105 -0
- package/dist/cli/installed.js.map +1 -0
- package/dist/cli/lava.d.ts +31 -0
- package/dist/cli/lava.d.ts.map +1 -0
- package/dist/cli/lava.js +82 -0
- package/dist/cli/lava.js.map +1 -0
- package/dist/cli/setup.d.ts +87 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +513 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.d.ts +11 -4
- package/dist/cli/shell.d.ts.map +1 -1
- package/dist/cli/shell.js.map +1 -1
- package/dist/cli/subcommands/agents.d.ts.map +1 -1
- package/dist/cli/subcommands/agents.js +2 -1
- package/dist/cli/subcommands/agents.js.map +1 -1
- package/dist/cli/subcommands/doctor.d.ts +15 -0
- package/dist/cli/subcommands/doctor.d.ts.map +1 -0
- package/dist/cli/subcommands/doctor.js +43 -0
- package/dist/cli/subcommands/doctor.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +13 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -1
- package/dist/cli/subcommands/flags.js +27 -1
- package/dist/cli/subcommands/flags.js.map +1 -1
- package/dist/cli/subcommands/hooks.d.ts.map +1 -1
- package/dist/cli/subcommands/hooks.js +10 -2
- package/dist/cli/subcommands/hooks.js.map +1 -1
- package/dist/cli/subcommands/init.d.ts.map +1 -1
- package/dist/cli/subcommands/init.js +2 -1
- package/dist/cli/subcommands/init.js.map +1 -1
- package/dist/cli/subcommands/registry.d.ts +3 -1
- package/dist/cli/subcommands/registry.d.ts.map +1 -1
- package/dist/cli/subcommands/registry.js +6 -0
- package/dist/cli/subcommands/registry.js.map +1 -1
- package/dist/cli/subcommands/setup.d.ts +23 -0
- package/dist/cli/subcommands/setup.d.ts.map +1 -0
- package/dist/cli/subcommands/setup.js +87 -0
- package/dist/cli/subcommands/setup.js.map +1 -0
- package/dist/cli/subcommands/subcommand.d.ts +1 -1
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
- package/dist/cli/subcommands/subcommand.js.map +1 -1
- package/dist/cli/wizard.d.ts +72 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +86 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/harness/claude-code.d.ts.map +1 -1
- package/dist/harness/claude-code.js +10 -0
- package/dist/harness/claude-code.js.map +1 -1
- package/dist/harness/codex.d.ts.map +1 -1
- package/dist/harness/codex.js +1 -0
- package/dist/harness/codex.js.map +1 -1
- package/dist/harness/grok.d.ts.map +1 -1
- package/dist/harness/grok.js +4 -1
- package/dist/harness/grok.js.map +1 -1
- package/dist/harness/kilocode.d.ts.map +1 -1
- package/dist/harness/kilocode.js +1 -1
- package/dist/harness/kilocode.js.map +1 -1
- package/dist/harness/opencode.d.ts.map +1 -1
- package/dist/harness/opencode.js +10 -0
- package/dist/harness/opencode.js.map +1 -1
- package/dist/harness/profile.d.ts +23 -2
- package/dist/harness/profile.d.ts.map +1 -1
- package/dist/harness/profile.js.map +1 -1
- package/dist/harness/snippet.d.ts +14 -1
- package/dist/harness/snippet.d.ts.map +1 -1
- package/dist/harness/snippet.js +18 -2
- package/dist/harness/snippet.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/setup/recipe.d.ts +73 -0
- package/dist/setup/recipe.d.ts.map +1 -0
- package/dist/setup/recipe.js +76 -0
- package/dist/setup/recipe.js.map +1 -0
- package/dist/text/json-edit.d.ts +15 -1
- package/dist/text/json-edit.d.ts.map +1 -1
- package/dist/text/json-edit.js +76 -5
- package/dist/text/json-edit.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/bin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
package/dist/cli/bin.js
CHANGED
|
@@ -144,10 +144,15 @@ try {
|
|
|
144
144
|
stdin: readStdin,
|
|
145
145
|
version: packageVersion(),
|
|
146
146
|
cwd: process.cwd(),
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
147
|
+
// The lava renderer's switch: a real interactive terminal that has not been
|
|
148
|
+
// told to keep its bytes plain. Piped output, agents and NO_COLOR all mean
|
|
149
|
+
// exactly the bytes the wizards have always written.
|
|
150
|
+
color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,
|
|
151
|
+
// The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by
|
|
152
|
+
// line, so they get the stream, not readStdin's one-shot slurp of fd 0. A
|
|
153
|
+
// *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking
|
|
154
|
+
// mode and breaks readStdin's readFileSync(0) for every other mode — only a
|
|
155
|
+
// wizard branch of runCli ever reads this property.
|
|
151
156
|
get initInput() {
|
|
152
157
|
return process.stdin;
|
|
153
158
|
},
|
package/dist/cli/bin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // `smelt init` reads answers line by line, so it gets the stream, not readStdin's\n // one-shot slurp of fd 0. A *getter*, because touching `process.stdin` at all\n // flips fd 0 into non-blocking mode and breaks readStdin's readFileSync(0) for\n // every other mode — only the `init` branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,OAAO,MAAM,cAAc,CAAC;AAEnC,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAExC;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,6FAA6F;AAC7F,SAAS,SAAS;IAChB,IAAI,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;QACd,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,kDAAkD;YAChD,uCAAuC;YACvC,uDAAuD,CAC1D,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;IACD,MAAM,KAAK,GAAG,YAAY,EAAE,CAAC;IAC7B,MAAM,MAAM,GAAG,sBAAsB,CAAC,KAAK,CAAC,CAAC;IAC7C,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;QAClB,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,kEAAkE,MAAM,CAAC,MAAM,CAAC,GAAG;YACjF,MAAM,KAAK,CAAC,MAAM,CAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,gCAAgC;YAClF,mFAAmF;YACnF,2CAA2C,CAC9C,CAAC;QACF,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,OAAO,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChC,CAAC;AAED,gFAAgF;AAChF,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,IAAI,iBAAiB,CAAC,CAAC,CAAC,CAAC,CAAC;AAEzD;;;;;GAKG;AACH,SAAS,YAAY;IACnB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACpC,SAAS,CAAC;QACR,IAAI,SAAiB,CAAC;QACtB,IAAI,CAAC;YACH,SAAS,GAAG,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;QACxD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,GAAI,KAA2B,CAAC,IAAI,CAAC;YAC/C,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,wCAAwC;gBACzE,SAAS;YACX,CAAC;YACD,IAAI,IAAI,KAAK,KAAK;gBAAE,MAAM,CAAC,4CAA4C;YACvE,MAAM,KAAK,CAAC;QACd,CAAC;QACD,IAAI,SAAS,KAAK,CAAC;YAAE,MAAM;QAC3B,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IACzD,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;GAKG;AACH,SAAS,sBAAsB,CAAC,KAAiB;IAC/C,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;QACxB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;QACvB,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACjB,CAAC,IAAI,CAAC,CAAC;YACP,SAAS;QACX,CAAC;QACD,IAAI,MAAc,CAAC;QACnB,IAAI,QAAQ,GAAG,IAAI,CAAC;QACpB,IAAI,SAAS,GAAG,IAAI,CAAC;QACrB,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACjC,MAAM,GAAG,CAAC,CAAC;QACb,CAAC;aAAM,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACxC,MAAM,GAAG,CAAC,CAAC;YACX,IAAI,IAAI,KAAK,IAAI;gBAAE,QAAQ,GAAG,IAAI,CAAC,CAAC,WAAW;YAC/C,IAAI,IAAI,KAAK,IAAI;gBAAE,SAAS,GAAG,IAAI,CAAC,CAAC,oBAAoB;QAC3D,CAAC;aAAM,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACxC,MAAM,GAAG,CAAC,CAAC;YACX,IAAI,IAAI,KAAK,IAAI;gBAAE,QAAQ,GAAG,IAAI,CAAC,CAAC,WAAW;YAC/C,IAAI,IAAI,KAAK,IAAI;gBAAE,SAAS,GAAG,IAAI,CAAC,CAAC,iBAAiB;QACxD,CAAC;aAAM,CAAC;YACN,OAAO,CAAC,CAAC,CAAC,+DAA+D;QAC3E,CAAC;QACD,IAAI,CAAC,GAAG,MAAM,GAAG,KAAK,CAAC,MAAM;YAAE,OAAO,CAAC,CAAC,CAAC,qBAAqB;QAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YACnC,MAAM,YAAY,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC;YACnC,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC;YACtC,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;YACxC,IAAI,YAAY,GAAG,GAAG,IAAI,YAAY,GAAG,IAAI;gBAAE,OAAO,CAAC,CAAC;QAC1D,CAAC;QACD,CAAC,IAAI,MAAM,CAAC;IACd,CAAC;IACD,OAAO,CAAC,CAAC,CAAC;AACZ,CAAC;AAED,wFAAwF;AACxF,SAAS,cAAc;IACrB,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,GAAG,CAAC,oBAAoB,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;IACtF,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAyB,CAAC;IAC5D,OAAO,MAAM,CAAC,OAAO,IAAI,OAAO,CAAC;AACnC,CAAC;AAED,IAAI,CAAC;IACH,OAAO,CAAC,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE;QACrD,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;QACjD,MAAM,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC;QACjD,KAAK,EAAE,SAAS;QAChB,OAAO,EAAE,cAAc,EAAE;QACzB,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE;QAClB,4EAA4E;QAC5E,2EAA2E;QAC3E,qDAAqD;QACrD,KAAK,EAAE,OAAO,CAAC,MAAM,CAAC,KAAK,KAAK,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,SAAS;QAC7E,mFAAmF;QACnF,0EAA0E;QAC1E,iFAAiF;QACjF,4EAA4E;QAC5E,oDAAoD;QACpD,IAAI,SAAS;YACX,OAAO,OAAO,CAAC,KAAK,CAAC;QACvB,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,uEAAuE;QACrE,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CACjF,CAAC;IACF,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC;AACrC,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readSync } from 'node:fs';\nimport { isatty } from 'node:tty';\nimport process from 'node:process';\n\nimport { EXIT, runCli } from './run.ts';\n\n/**\n * The `smelt` binary: the thinnest possible shell around {@link runCli}.\n *\n * Everything decidable lives in `run.ts`, which returns an exit code instead of\n * calling `exit`, so the whole CLI is testable in-process. This file owns only the\n * things that cannot be tested without a real process: the shebang, stdin on fd 0, and\n * the exit code itself. `test/cli-bin.test.ts` spawns the *built* binary for exactly\n * those things — an in-process suite cannot see them.\n *\n * Two deliberate subtleties around fd 0:\n *\n * - **fd 0 may be non-blocking, and must still be read to EOF.** Two separate ways\n * that happens in the wild: merely accessing `process.stdin` (even `.isTTY`) makes\n * Node flip fd 0 into non-blocking mode, and a parent that spawned this process\n * with piped stdio may hand over a socketpair that was *born* non-blocking. Either\n * way a one-shot `readFileSync(0)` throws `EAGAIN` whenever the producer is slower\n * than Node's startup — `(sleep 1; echo hi) | smelt --budget 100` used to die with\n * an internal error. So: the TTY check is `tty.isatty(0)` (a plain syscall, no\n * stream initialization), `process.stdin` is only handed over lazily to the one\n * mode that needs a stream (`init`), and the read itself retries `EAGAIN` with a\n * synchronous back-off until EOF.\n * - **Bytes that are not UTF-8 are refused, never mangled.** Decoding invalid bytes\n * would silently replace them with U+FFFD, and the result would still smelt,\n * round-trip, and verify — of the wrong bytes. That violates the reversibility\n * story at its root, so the binary refuses (exit code {@link EXIT.refused}) and\n * names the first offending byte offset instead.\n */\n\n/** Reading fd 0 synchronously is the whole of \"works in a pipe\", with no stream plumbing. */\nfunction readStdin(): string {\n if (isatty(0)) {\n process.stderr.write(\n 'smelt: no input. Pass a file, or pipe text in:\\n' +\n ' smelt src/server.ts --budget 4000\\n' +\n ' smelt --budget 4000 --focus TypeError < build.log\\n',\n );\n process.exit(EXIT.usage);\n }\n const bytes = readAllOfFd0();\n const offset = firstInvalidUtf8Offset(bytes);\n if (offset !== -1) {\n process.stderr.write(\n `smelt: stdin is not valid UTF-8 — first invalid byte at offset ${String(offset)} ` +\n `(0x${bytes[offset]!.toString(16).padStart(2, '0')}). Decoding it would silently ` +\n `replace bytes with U+FFFD, and the \"original\" smelt could then reconstruct would ` +\n `not be your original. Refusing instead.\\n`,\n );\n process.exit(EXIT.refused);\n }\n return bytes.toString('utf8');\n}\n\n/** `Atomics.wait` is the only dependency-free synchronous sleep Node offers. */\nconst sleeper = new Int32Array(new SharedArrayBuffer(4));\n\n/**\n * Every byte of fd 0, to EOF, whether the descriptor is blocking (a shell pipe, a\n * redirected file) or non-blocking (a socketpair from a spawning parent). `EAGAIN`\n * means \"no bytes yet, not EOF\" — wait briefly and try again; only a zero-byte read\n * (or Windows' `EOF` on a closed pipe) ends the loop.\n */\nfunction readAllOfFd0(): Buffer {\n const chunks: Buffer[] = [];\n const chunk = Buffer.alloc(1 << 16);\n for (;;) {\n let bytesRead: number;\n try {\n bytesRead = readSync(0, chunk, 0, chunk.length, null);\n } catch (error) {\n const code = (error as { code?: string }).code;\n if (code === 'EAGAIN') {\n Atomics.wait(sleeper, 0, 0, 10); // producer not ready — 10ms, then retry\n continue;\n }\n if (code === 'EOF') break; // Windows raises EOF instead of returning 0\n throw error;\n }\n if (bytesRead === 0) break;\n chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));\n }\n return Buffer.concat(chunks);\n}\n\n/**\n * The byte offset where `bytes` stops being well-formed UTF-8, or `-1` when it is\n * well-formed throughout. Implements the WHATWG/Unicode table exactly: continuation\n * ranges tightened for E0/ED/F0/F4 so overlongs, surrogates and > U+10FFFF are\n * invalid — the same sequences `Buffer.prototype.toString` would fold into U+FFFD.\n */\nfunction firstInvalidUtf8Offset(bytes: Uint8Array): number {\n let i = 0;\n while (i < bytes.length) {\n const lead = bytes[i]!;\n if (lead <= 0x7f) {\n i += 1;\n continue;\n }\n let length: number;\n let firstLow = 0x80;\n let firstHigh = 0xbf;\n if (lead >= 0xc2 && lead <= 0xdf) {\n length = 2;\n } else if (lead >= 0xe0 && lead <= 0xef) {\n length = 3;\n if (lead === 0xe0) firstLow = 0xa0; // overlong\n if (lead === 0xed) firstHigh = 0x9f; // UTF-16 surrogates\n } else if (lead >= 0xf0 && lead <= 0xf4) {\n length = 4;\n if (lead === 0xf0) firstLow = 0x90; // overlong\n if (lead === 0xf4) firstHigh = 0x8f; // above U+10FFFF\n } else {\n return i; // a bare continuation byte, or C0/C1/F5–FF — invalid as a lead\n }\n if (i + length > bytes.length) return i; // truncated sequence\n for (let k = 1; k < length; k += 1) {\n const continuation = bytes[i + k]!;\n const low = k === 1 ? firstLow : 0x80;\n const high = k === 1 ? firstHigh : 0xbf;\n if (continuation < low || continuation > high) return i;\n }\n i += length;\n }\n return -1;\n}\n\n/** `--version` should agree with the manifest, not with a second copy of the number. */\nfunction packageVersion(): string {\n const manifest = readFileSync(new URL('../../package.json', import.meta.url), 'utf8');\n const parsed = JSON.parse(manifest) as { version?: string };\n return parsed.version ?? '0.0.0';\n}\n\ntry {\n process.exitCode = await runCli(process.argv.slice(2), {\n stdout: (text) => void process.stdout.write(text),\n stderr: (text) => void process.stderr.write(text),\n stdin: readStdin,\n version: packageVersion(),\n cwd: process.cwd(),\n // The lava renderer's switch: a real interactive terminal that has not been\n // told to keep its bytes plain. Piped output, agents and NO_COLOR all mean\n // exactly the bytes the wizards have always written.\n color: process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined,\n // The wizard verbs (`init`, `hooks`, `agents split`, `setup`) read answers line by\n // line, so they get the stream, not readStdin's one-shot slurp of fd 0. A\n // *getter*, because touching `process.stdin` at all flips fd 0 into non-blocking\n // mode and breaks readStdin's readFileSync(0) for every other mode — only a\n // wizard branch of runCli ever reads this property.\n get initInput() {\n return process.stdin;\n },\n });\n} catch (error) {\n process.stderr.write(\n `smelt: unexpected internal error — this is a bug, please report it.\\n` +\n `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`,\n );\n process.exitCode = EXIT.unexpected;\n}\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smelt doctor` — the verdicts over InstalledState. The reading lives in
|
|
3
|
+
* `cli/installed.ts` (one reader, behind the three consumers); this module decides
|
|
4
|
+
* and reports: which blocks are behind the running binary, which pieces are orphans,
|
|
5
|
+
* what the repair is, and what the exit code means. Everything the writers wrote,
|
|
6
|
+
* this verb reads back and compares — and never writes a byte of (ADR-0003).
|
|
7
|
+
*
|
|
8
|
+
* When something is behind, the report ends with the exact repair command —
|
|
9
|
+
* `smelt setup`, per harness where a block is behind — and nothing more happens.
|
|
10
|
+
* The exit code carries the verdict, so the other-machine loop — upgrade, doctor,
|
|
11
|
+
* setup — needs no prose parsing.
|
|
12
|
+
*/
|
|
13
|
+
export interface DoctorIo {
|
|
14
|
+
readonly output: (text: string) => void;
|
|
15
|
+
/** Where installed state is read: config discovery, instruction files, hook files. */
|
|
16
|
+
readonly cwd: string;
|
|
17
|
+
/** The running binary's version — what "current" is measured against. */
|
|
18
|
+
readonly version: string;
|
|
19
|
+
}
|
|
20
|
+
export interface DoctorOptions {
|
|
21
|
+
readonly json: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** One instruction block found on disk, with the release that wrote it. */
|
|
24
|
+
export interface DoctorBlock {
|
|
25
|
+
readonly file: string;
|
|
26
|
+
/** The harness profiles whose instruction file this is (often exactly one). */
|
|
27
|
+
readonly harnesses: readonly string[];
|
|
28
|
+
/** The release that wrote it, or `undefined` when it predates stamping. */
|
|
29
|
+
readonly installedBy?: string;
|
|
30
|
+
readonly status: 'current' | 'behind' | 'unversioned';
|
|
31
|
+
}
|
|
32
|
+
/** The config as doctor saw it. A malformed config is a finding, not a crash. */
|
|
33
|
+
export interface DoctorConfig {
|
|
34
|
+
readonly present: boolean;
|
|
35
|
+
readonly malformed?: boolean;
|
|
36
|
+
readonly schemaVersion?: number;
|
|
37
|
+
readonly currentSchema?: boolean;
|
|
38
|
+
readonly budgetBytes?: number;
|
|
39
|
+
readonly store: {
|
|
40
|
+
readonly kind?: 'directory' | 'memory';
|
|
41
|
+
readonly path?: string;
|
|
42
|
+
readonly dirExists?: boolean;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** One MCP registration found (or notably absent) on disk. */
|
|
46
|
+
export interface DoctorMcp {
|
|
47
|
+
readonly file: string;
|
|
48
|
+
readonly server: string;
|
|
49
|
+
readonly registered: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** The machine receipt — `--json`. Everything doctor read, and the verdict. */
|
|
52
|
+
export interface DoctorReceipt {
|
|
53
|
+
readonly format: 'smelt.doctor.v1';
|
|
54
|
+
readonly version: string;
|
|
55
|
+
/** True when something is installed and nothing is behind and there are no orphans. */
|
|
56
|
+
readonly current: boolean;
|
|
57
|
+
readonly installed: boolean;
|
|
58
|
+
readonly config: DoctorConfig;
|
|
59
|
+
readonly blocks: readonly DoctorBlock[];
|
|
60
|
+
readonly hookFiles: readonly string[];
|
|
61
|
+
readonly mcp: readonly DoctorMcp[];
|
|
62
|
+
readonly orphans: readonly string[];
|
|
63
|
+
readonly repair: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
export declare function runDoctor(options: DoctorOptions, io: DoctorIo): number;
|
|
66
|
+
//# sourceMappingURL=doctor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,sFAAsF;IACtF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,aAAa,CAAC;CACvD;AAED,iFAAiF;AACjF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,QAAQ,CAAC;QACvC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;KAC9B,CAAC;CACH;AAED,8DAA8D;AAC9D,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B;AAED,+EAA+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uFAAuF;IACvF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;IACxC,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,GAAG,EAAE,SAAS,SAAS,EAAE,CAAC;IACnC,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,EAAE,QAAQ,GAAG,MAAM,CA2ItE","sourcesContent":["import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nimport { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.ts';\nimport { readInstalledState } from './installed.ts';\nimport type { InstalledBlock } from './installed.ts';\nimport { CLI_NAME, EXIT } from './shell.ts';\n\n/**\n * `smelt doctor` — the verdicts over InstalledState. The reading lives in\n * `cli/installed.ts` (one reader, behind the three consumers); this module decides\n * and reports: which blocks are behind the running binary, which pieces are orphans,\n * what the repair is, and what the exit code means. Everything the writers wrote,\n * this verb reads back and compares — and never writes a byte of (ADR-0003).\n *\n * When something is behind, the report ends with the exact repair command —\n * `smelt setup`, per harness where a block is behind — and nothing more happens.\n * The exit code carries the verdict, so the other-machine loop — upgrade, doctor,\n * setup — needs no prose parsing.\n */\n\nexport interface DoctorIo {\n readonly output: (text: string) => void;\n /** Where installed state is read: config discovery, instruction files, hook files. */\n readonly cwd: string;\n /** The running binary's version — what \"current\" is measured against. */\n readonly version: string;\n}\n\nexport interface DoctorOptions {\n readonly json: boolean;\n}\n\n/** One instruction block found on disk, with the release that wrote it. */\nexport interface DoctorBlock {\n readonly file: string;\n /** The harness profiles whose instruction file this is (often exactly one). */\n readonly harnesses: readonly string[];\n /** The release that wrote it, or `undefined` when it predates stamping. */\n readonly installedBy?: string;\n readonly status: 'current' | 'behind' | 'unversioned';\n}\n\n/** The config as doctor saw it. A malformed config is a finding, not a crash. */\nexport interface DoctorConfig {\n readonly present: boolean;\n readonly malformed?: boolean;\n readonly schemaVersion?: number;\n readonly currentSchema?: boolean;\n readonly budgetBytes?: number;\n readonly store: {\n readonly kind?: 'directory' | 'memory';\n readonly path?: string;\n readonly dirExists?: boolean;\n };\n}\n\n/** One MCP registration found (or notably absent) on disk. */\nexport interface DoctorMcp {\n readonly file: string;\n readonly server: string;\n readonly registered: boolean;\n}\n\n/** The machine receipt — `--json`. Everything doctor read, and the verdict. */\nexport interface DoctorReceipt {\n readonly format: 'smelt.doctor.v1';\n readonly version: string;\n /** True when something is installed and nothing is behind and there are no orphans. */\n readonly current: boolean;\n readonly installed: boolean;\n readonly config: DoctorConfig;\n readonly blocks: readonly DoctorBlock[];\n readonly hookFiles: readonly string[];\n readonly mcp: readonly DoctorMcp[];\n readonly orphans: readonly string[];\n readonly repair: readonly string[];\n}\n\nexport function runDoctor(options: DoctorOptions, io: DoctorIo): number {\n const say = (text: string): void => {\n if (!options.json) io.output(text);\n };\n const orphans: string[] = [];\n const repair: string[] = [];\n\n const state = readInstalledState(io.cwd);\n\n // ── verdict: blocks vs the running binary ──\n const blocks: DoctorBlock[] = state.blocks.map((block) => ({\n file: block.file,\n harnesses: block.harnesses,\n ...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),\n status: blockStatus(block, io.version),\n }));\n const behindBlocks = blocks.filter((block) => block.status === 'behind');\n for (const block of behindBlocks) {\n repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));\n }\n\n // ── config detail + the store-directory orphan ──\n let config: DoctorConfig = { present: false, store: {} };\n if (state.config.present) {\n if (state.config.malformed === true || state.config.parsed === undefined) {\n config = { present: true, malformed: true, store: {} };\n orphans.push(\n `${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`,\n );\n repair.push(`${CLI_NAME} setup`);\n } else {\n const parsed = state.config.parsed;\n const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);\n const dirExists =\n parsed.store?.kind === 'directory'\n ? existsSync(join(dirname(configPath), parsed.store.path))\n : undefined;\n config = {\n present: true,\n schemaVersion: parsed.smeltConfig,\n currentSchema: parsed.smeltConfig === CONFIG_VERSION,\n ...(parsed.defaultBudgetBytes === undefined\n ? {}\n : { budgetBytes: parsed.defaultBudgetBytes }),\n store: {\n ...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),\n ...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),\n ...(dirExists === undefined ? {} : { dirExists }),\n },\n };\n if (parsed.store?.kind === 'directory' && dirExists === false) {\n orphans.push(\n `the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`,\n );\n repair.push(`${CLI_NAME} setup`);\n }\n }\n }\n\n // ── orphans: pieces whose partners are missing ──\n const wired = state.blocks.length > 0 || state.hookFiles.length > 0;\n if (state.mcp.some((one) => one.registered) && !wired) {\n orphans.push(\n 'an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n if (wired && !state.config.present) {\n orphans.push(\n 'hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n\n // ── verdict ──\n const installed = wired || state.config.present || state.mcp.some((one) => one.registered);\n const current = installed && behindBlocks.length === 0 && orphans.length === 0;\n\n say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\\n`);\n if (!installed) {\n say(`Nothing of smelt's is installed here. \\`${CLI_NAME} setup\\` would change that.\\n`);\n } else {\n if (config.present) {\n say(\n ` ${CONFIG_FILE_NAME}: ${\n config.malformed === true\n ? 'MALFORMED'\n : `schema ${String(config.schemaVersion)}, budget ${\n config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)\n }, store ${describeStore(config)}`\n }\\n`,\n );\n } else {\n say(` ${CONFIG_FILE_NAME}: absent\\n`);\n }\n for (const block of blocks) {\n say(\n ` ${block.file}: written by ${\n block.installedBy ?? 'a pre-stamping release (unversioned)'\n } [${block.status}] — ${block.harnesses.join(', ')}\\n`,\n );\n }\n for (const name of state.hookFiles) say(` ${name}: wired\\n`);\n for (const one of state.mcp) {\n if (one.registered) say(` ${one.file}: ${one.server} registered\\n`);\n }\n for (const orphan of orphans) say(` ORPHAN: ${orphan}\\n`);\n if (behindBlocks.length > 0) {\n say(\n `\\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +\n `installed state to it:\\n` +\n [...new Set(repair)].map((command) => ` ${command}\\n`).join(''),\n );\n } else if (orphans.length > 0) {\n say(`\\nRepair:\\n${[...new Set(repair)].map((command) => ` ${command}\\n`).join('')}`);\n }\n say(\n current\n ? `Current: everything on disk agrees with binary ${io.version}.\\n`\n : `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\\n`,\n );\n }\n\n if (options.json) {\n const receipt: DoctorReceipt = {\n format: 'smelt.doctor.v1',\n version: io.version,\n current,\n installed,\n config,\n blocks,\n hookFiles: [...state.hookFiles],\n mcp: [...state.mcp],\n orphans,\n repair: [...new Set(repair)],\n };\n io.output(JSON.stringify(receipt, null, 2) + '\\n');\n }\n return current || !installed ? EXIT.ok : EXIT.refused;\n}\n\n/** The verdict over one block: whole-owned files carry no stamp to compare. */\nfunction blockStatus(block: InstalledBlock, binaryVersion: string): DoctorBlock['status'] {\n if (!block.stampable) return 'unversioned';\n return block.installedBy === binaryVersion ? 'current' : 'behind';\n}\n\nfunction describeStore(config: DoctorConfig): string {\n if (config.store.kind === undefined) return 'unset';\n if (config.store.kind === 'memory') return 'memory';\n return `directory at ${config.store.path ?? ''} (${\n config.store.dirExists ? 'present' : 'MISSING'\n })`;\n}\n"]}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.js';
|
|
4
|
+
import { readInstalledState } from './installed.js';
|
|
5
|
+
import { CLI_NAME, EXIT } from './shell.js';
|
|
6
|
+
export function runDoctor(options, io) {
|
|
7
|
+
const say = (text) => {
|
|
8
|
+
if (!options.json)
|
|
9
|
+
io.output(text);
|
|
10
|
+
};
|
|
11
|
+
const orphans = [];
|
|
12
|
+
const repair = [];
|
|
13
|
+
const state = readInstalledState(io.cwd);
|
|
14
|
+
// ── verdict: blocks vs the running binary ──
|
|
15
|
+
const blocks = state.blocks.map((block) => ({
|
|
16
|
+
file: block.file,
|
|
17
|
+
harnesses: block.harnesses,
|
|
18
|
+
...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),
|
|
19
|
+
status: blockStatus(block, io.version),
|
|
20
|
+
}));
|
|
21
|
+
const behindBlocks = blocks.filter((block) => block.status === 'behind');
|
|
22
|
+
for (const block of behindBlocks) {
|
|
23
|
+
repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));
|
|
24
|
+
}
|
|
25
|
+
// ── config detail + the store-directory orphan ──
|
|
26
|
+
let config = { present: false, store: {} };
|
|
27
|
+
if (state.config.present) {
|
|
28
|
+
if (state.config.malformed === true || state.config.parsed === undefined) {
|
|
29
|
+
config = { present: true, malformed: true, store: {} };
|
|
30
|
+
orphans.push(`${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`);
|
|
31
|
+
repair.push(`${CLI_NAME} setup`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const parsed = state.config.parsed;
|
|
35
|
+
const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);
|
|
36
|
+
const dirExists = parsed.store?.kind === 'directory'
|
|
37
|
+
? existsSync(join(dirname(configPath), parsed.store.path))
|
|
38
|
+
: undefined;
|
|
39
|
+
config = {
|
|
40
|
+
present: true,
|
|
41
|
+
schemaVersion: parsed.smeltConfig,
|
|
42
|
+
currentSchema: parsed.smeltConfig === CONFIG_VERSION,
|
|
43
|
+
...(parsed.defaultBudgetBytes === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { budgetBytes: parsed.defaultBudgetBytes }),
|
|
46
|
+
store: {
|
|
47
|
+
...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),
|
|
48
|
+
...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),
|
|
49
|
+
...(dirExists === undefined ? {} : { dirExists }),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
if (parsed.store?.kind === 'directory' && dirExists === false) {
|
|
53
|
+
orphans.push(`the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`);
|
|
54
|
+
repair.push(`${CLI_NAME} setup`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── orphans: pieces whose partners are missing ──
|
|
59
|
+
const wired = state.blocks.length > 0 || state.hookFiles.length > 0;
|
|
60
|
+
if (state.mcp.some((one) => one.registered) && !wired) {
|
|
61
|
+
orphans.push('an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together');
|
|
62
|
+
repair.push(`${CLI_NAME} setup`);
|
|
63
|
+
}
|
|
64
|
+
if (wired && !state.config.present) {
|
|
65
|
+
orphans.push('hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there');
|
|
66
|
+
repair.push(`${CLI_NAME} setup`);
|
|
67
|
+
}
|
|
68
|
+
// ── verdict ──
|
|
69
|
+
const installed = wired || state.config.present || state.mcp.some((one) => one.registered);
|
|
70
|
+
const current = installed && behindBlocks.length === 0 && orphans.length === 0;
|
|
71
|
+
say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\n`);
|
|
72
|
+
if (!installed) {
|
|
73
|
+
say(`Nothing of smelt's is installed here. \`${CLI_NAME} setup\` would change that.\n`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
if (config.present) {
|
|
77
|
+
say(` ${CONFIG_FILE_NAME}: ${config.malformed === true
|
|
78
|
+
? 'MALFORMED'
|
|
79
|
+
: `schema ${String(config.schemaVersion)}, budget ${config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)}, store ${describeStore(config)}`}\n`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
say(` ${CONFIG_FILE_NAME}: absent\n`);
|
|
83
|
+
}
|
|
84
|
+
for (const block of blocks) {
|
|
85
|
+
say(` ${block.file}: written by ${block.installedBy ?? 'a pre-stamping release (unversioned)'} [${block.status}] — ${block.harnesses.join(', ')}\n`);
|
|
86
|
+
}
|
|
87
|
+
for (const name of state.hookFiles)
|
|
88
|
+
say(` ${name}: wired\n`);
|
|
89
|
+
for (const one of state.mcp) {
|
|
90
|
+
if (one.registered)
|
|
91
|
+
say(` ${one.file}: ${one.server} registered\n`);
|
|
92
|
+
}
|
|
93
|
+
for (const orphan of orphans)
|
|
94
|
+
say(` ORPHAN: ${orphan}\n`);
|
|
95
|
+
if (behindBlocks.length > 0) {
|
|
96
|
+
say(`\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +
|
|
97
|
+
`installed state to it:\n` +
|
|
98
|
+
[...new Set(repair)].map((command) => ` ${command}\n`).join(''));
|
|
99
|
+
}
|
|
100
|
+
else if (orphans.length > 0) {
|
|
101
|
+
say(`\nRepair:\n${[...new Set(repair)].map((command) => ` ${command}\n`).join('')}`);
|
|
102
|
+
}
|
|
103
|
+
say(current
|
|
104
|
+
? `Current: everything on disk agrees with binary ${io.version}.\n`
|
|
105
|
+
: `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\n`);
|
|
106
|
+
}
|
|
107
|
+
if (options.json) {
|
|
108
|
+
const receipt = {
|
|
109
|
+
format: 'smelt.doctor.v1',
|
|
110
|
+
version: io.version,
|
|
111
|
+
current,
|
|
112
|
+
installed,
|
|
113
|
+
config,
|
|
114
|
+
blocks,
|
|
115
|
+
hookFiles: [...state.hookFiles],
|
|
116
|
+
mcp: [...state.mcp],
|
|
117
|
+
orphans,
|
|
118
|
+
repair: [...new Set(repair)],
|
|
119
|
+
};
|
|
120
|
+
io.output(JSON.stringify(receipt, null, 2) + '\n');
|
|
121
|
+
}
|
|
122
|
+
return current || !installed ? EXIT.ok : EXIT.refused;
|
|
123
|
+
}
|
|
124
|
+
/** The verdict over one block: whole-owned files carry no stamp to compare. */
|
|
125
|
+
function blockStatus(block, binaryVersion) {
|
|
126
|
+
if (!block.stampable)
|
|
127
|
+
return 'unversioned';
|
|
128
|
+
return block.installedBy === binaryVersion ? 'current' : 'behind';
|
|
129
|
+
}
|
|
130
|
+
function describeStore(config) {
|
|
131
|
+
if (config.store.kind === undefined)
|
|
132
|
+
return 'unset';
|
|
133
|
+
if (config.store.kind === 'memory')
|
|
134
|
+
return 'memory';
|
|
135
|
+
return `directory at ${config.store.path ?? ''} (${config.store.dirExists ? 'present' : 'MISSING'})`;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/cli/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\n\nimport { CONFIG_FILE_NAME, CONFIG_VERSION } from './config.ts';\nimport { readInstalledState } from './installed.ts';\nimport type { InstalledBlock } from './installed.ts';\nimport { CLI_NAME, EXIT } from './shell.ts';\n\n/**\n * `smelt doctor` — the verdicts over InstalledState. The reading lives in\n * `cli/installed.ts` (one reader, behind the three consumers); this module decides\n * and reports: which blocks are behind the running binary, which pieces are orphans,\n * what the repair is, and what the exit code means. Everything the writers wrote,\n * this verb reads back and compares — and never writes a byte of (ADR-0003).\n *\n * When something is behind, the report ends with the exact repair command —\n * `smelt setup`, per harness where a block is behind — and nothing more happens.\n * The exit code carries the verdict, so the other-machine loop — upgrade, doctor,\n * setup — needs no prose parsing.\n */\n\nexport interface DoctorIo {\n readonly output: (text: string) => void;\n /** Where installed state is read: config discovery, instruction files, hook files. */\n readonly cwd: string;\n /** The running binary's version — what \"current\" is measured against. */\n readonly version: string;\n}\n\nexport interface DoctorOptions {\n readonly json: boolean;\n}\n\n/** One instruction block found on disk, with the release that wrote it. */\nexport interface DoctorBlock {\n readonly file: string;\n /** The harness profiles whose instruction file this is (often exactly one). */\n readonly harnesses: readonly string[];\n /** The release that wrote it, or `undefined` when it predates stamping. */\n readonly installedBy?: string;\n readonly status: 'current' | 'behind' | 'unversioned';\n}\n\n/** The config as doctor saw it. A malformed config is a finding, not a crash. */\nexport interface DoctorConfig {\n readonly present: boolean;\n readonly malformed?: boolean;\n readonly schemaVersion?: number;\n readonly currentSchema?: boolean;\n readonly budgetBytes?: number;\n readonly store: {\n readonly kind?: 'directory' | 'memory';\n readonly path?: string;\n readonly dirExists?: boolean;\n };\n}\n\n/** One MCP registration found (or notably absent) on disk. */\nexport interface DoctorMcp {\n readonly file: string;\n readonly server: string;\n readonly registered: boolean;\n}\n\n/** The machine receipt — `--json`. Everything doctor read, and the verdict. */\nexport interface DoctorReceipt {\n readonly format: 'smelt.doctor.v1';\n readonly version: string;\n /** True when something is installed and nothing is behind and there are no orphans. */\n readonly current: boolean;\n readonly installed: boolean;\n readonly config: DoctorConfig;\n readonly blocks: readonly DoctorBlock[];\n readonly hookFiles: readonly string[];\n readonly mcp: readonly DoctorMcp[];\n readonly orphans: readonly string[];\n readonly repair: readonly string[];\n}\n\nexport function runDoctor(options: DoctorOptions, io: DoctorIo): number {\n const say = (text: string): void => {\n if (!options.json) io.output(text);\n };\n const orphans: string[] = [];\n const repair: string[] = [];\n\n const state = readInstalledState(io.cwd);\n\n // ── verdict: blocks vs the running binary ──\n const blocks: DoctorBlock[] = state.blocks.map((block) => ({\n file: block.file,\n harnesses: block.harnesses,\n ...(block.installedBy === undefined ? {} : { installedBy: block.installedBy }),\n status: blockStatus(block, io.version),\n }));\n const behindBlocks = blocks.filter((block) => block.status === 'behind');\n for (const block of behindBlocks) {\n repair.push(...block.harnesses.map((id) => `${CLI_NAME} setup --harness ${id}`));\n }\n\n // ── config detail + the store-directory orphan ──\n let config: DoctorConfig = { present: false, store: {} };\n if (state.config.present) {\n if (state.config.malformed === true || state.config.parsed === undefined) {\n config = { present: true, malformed: true, store: {} };\n orphans.push(\n `${CONFIG_FILE_NAME} is malformed: ${state.config.malformedWhy ?? 'unparseable JSON'}`,\n );\n repair.push(`${CLI_NAME} setup`);\n } else {\n const parsed = state.config.parsed;\n const configPath = state.config.path ?? join(io.cwd, CONFIG_FILE_NAME);\n const dirExists =\n parsed.store?.kind === 'directory'\n ? existsSync(join(dirname(configPath), parsed.store.path))\n : undefined;\n config = {\n present: true,\n schemaVersion: parsed.smeltConfig,\n currentSchema: parsed.smeltConfig === CONFIG_VERSION,\n ...(parsed.defaultBudgetBytes === undefined\n ? {}\n : { budgetBytes: parsed.defaultBudgetBytes }),\n store: {\n ...(parsed.store === undefined ? {} : { kind: parsed.store.kind }),\n ...(parsed.store?.kind === 'directory' ? { path: parsed.store.path } : {}),\n ...(dirExists === undefined ? {} : { dirExists }),\n },\n };\n if (parsed.store?.kind === 'directory' && dirExists === false) {\n orphans.push(\n `the store directory (${parsed.store.path}) does not exist — retrieves across processes would fail`,\n );\n repair.push(`${CLI_NAME} setup`);\n }\n }\n }\n\n // ── orphans: pieces whose partners are missing ──\n const wired = state.blocks.length > 0 || state.hookFiles.length > 0;\n if (state.mcp.some((one) => one.registered) && !wired) {\n orphans.push(\n 'an MCP registration is present but no hooks wiring is — the guard and the retrieval contract travel together',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n if (wired && !state.config.present) {\n orphans.push(\n 'hooks are wired but there is no smelt.config.json — the store and budget the hooks promise live there',\n );\n repair.push(`${CLI_NAME} setup`);\n }\n\n // ── verdict ──\n const installed = wired || state.config.present || state.mcp.some((one) => one.registered);\n const current = installed && behindBlocks.length === 0 && orphans.length === 0;\n\n say(`${CLI_NAME} doctor — binary ${io.version}, reading ${io.cwd}\\n`);\n if (!installed) {\n say(`Nothing of smelt's is installed here. \\`${CLI_NAME} setup\\` would change that.\\n`);\n } else {\n if (config.present) {\n say(\n ` ${CONFIG_FILE_NAME}: ${\n config.malformed === true\n ? 'MALFORMED'\n : `schema ${String(config.schemaVersion)}, budget ${\n config.budgetBytes === undefined ? 'unset' : String(config.budgetBytes)\n }, store ${describeStore(config)}`\n }\\n`,\n );\n } else {\n say(` ${CONFIG_FILE_NAME}: absent\\n`);\n }\n for (const block of blocks) {\n say(\n ` ${block.file}: written by ${\n block.installedBy ?? 'a pre-stamping release (unversioned)'\n } [${block.status}] — ${block.harnesses.join(', ')}\\n`,\n );\n }\n for (const name of state.hookFiles) say(` ${name}: wired\\n`);\n for (const one of state.mcp) {\n if (one.registered) say(` ${one.file}: ${one.server} registered\\n`);\n }\n for (const orphan of orphans) say(` ORPHAN: ${orphan}\\n`);\n if (behindBlocks.length > 0) {\n say(\n `\\nBehind: the running binary is ${io.version}; re-run setup to bring the ` +\n `installed state to it:\\n` +\n [...new Set(repair)].map((command) => ` ${command}\\n`).join(''),\n );\n } else if (orphans.length > 0) {\n say(`\\nRepair:\\n${[...new Set(repair)].map((command) => ` ${command}\\n`).join('')}`);\n }\n say(\n current\n ? `Current: everything on disk agrees with binary ${io.version}.\\n`\n : `Not current — see above. Doctor never writes; ${CLI_NAME} setup is the repair.\\n`,\n );\n }\n\n if (options.json) {\n const receipt: DoctorReceipt = {\n format: 'smelt.doctor.v1',\n version: io.version,\n current,\n installed,\n config,\n blocks,\n hookFiles: [...state.hookFiles],\n mcp: [...state.mcp],\n orphans,\n repair: [...new Set(repair)],\n };\n io.output(JSON.stringify(receipt, null, 2) + '\\n');\n }\n return current || !installed ? EXIT.ok : EXIT.refused;\n}\n\n/** The verdict over one block: whole-owned files carry no stamp to compare. */\nfunction blockStatus(block: InstalledBlock, binaryVersion: string): DoctorBlock['status'] {\n if (!block.stampable) return 'unversioned';\n return block.installedBy === binaryVersion ? 'current' : 'behind';\n}\n\nfunction describeStore(config: DoctorConfig): string {\n if (config.store.kind === undefined) return 'unset';\n if (config.store.kind === 'memory') return 'memory';\n return `directory at ${config.store.path ?? ''} (${\n config.store.dirExists ? 'present' : 'MISSING'\n })`;\n}\n"]}
|
package/dist/cli/hooks.d.ts
CHANGED
|
@@ -51,6 +51,11 @@ export interface HooksIo {
|
|
|
51
51
|
readonly cwd: string;
|
|
52
52
|
/** Home directory for detection only. Tests point it at a temp dir; nothing writes here. */
|
|
53
53
|
readonly home?: string;
|
|
54
|
+
/**
|
|
55
|
+
* The release running the install — stamped into the instruction block so
|
|
56
|
+
* `smelt doctor` can tell what wrote it. Absent (legacy callers) writes no stamp.
|
|
57
|
+
*/
|
|
58
|
+
readonly version?: string;
|
|
54
59
|
}
|
|
55
60
|
export { instructionSnippet, SNIPPET_END_MD, SNIPPET_START_MD };
|
|
56
61
|
/** A harness whose config directory exists in the project or the home directory. */
|
|
@@ -95,6 +100,8 @@ interface PlannedRemoval {
|
|
|
95
100
|
}
|
|
96
101
|
export interface HooksChoices {
|
|
97
102
|
harnesses: HarnessProfile[];
|
|
103
|
+
/** The release writing these bytes — stamped into the snippet for `smelt doctor`. */
|
|
104
|
+
writtenBy?: string;
|
|
98
105
|
guard: boolean;
|
|
99
106
|
statsOnStop: boolean;
|
|
100
107
|
mapOnStart: boolean;
|
|
@@ -119,7 +126,7 @@ interface InstallPlan {
|
|
|
119
126
|
*/
|
|
120
127
|
export declare function planInstall(cwd: string, choices: HooksChoices): InstallPlan;
|
|
121
128
|
/** Where the installed config points the persistent store, relative to the config file. */
|
|
122
|
-
export declare const DEFAULT_STORE_DIR
|
|
129
|
+
export declare const DEFAULT_STORE_DIR: ".smelt/store";
|
|
123
130
|
/**
|
|
124
131
|
* Existing config re-rendered with the hooks block, other fields carried verbatim —
|
|
125
132
|
* except that a config with **no** store block gains a directory store. The deny
|
|
@@ -151,7 +158,34 @@ export declare function renderConfigWithHooks(existing: SmeltConfig | undefined,
|
|
|
151
158
|
export declare function planRemove(cwd: string, harnesses: readonly HarnessProfile[]): readonly PlannedRemoval[];
|
|
152
159
|
/**
|
|
153
160
|
* `smelt hooks <install|remove>`, start to finish. The same testability pattern as
|
|
154
|
-
* `runInit`: a pure function over an input/output pair, exit code returned.
|
|
161
|
+
* `runInit`: a pure function over an input/output pair, exit code returned. The ask
|
|
162
|
+
* adapter, the step machine and the confirms are the wizard kit's (`cli/wizard.ts`) —
|
|
163
|
+
* this file holds what is hooks' own: the steps, the plan, the per-file consent.
|
|
155
164
|
*/
|
|
156
165
|
export declare function runHooks(action: 'install' | 'remove', harnessFlag: string | undefined, io: HooksIo): Promise<number>;
|
|
166
|
+
/**
|
|
167
|
+
* A re-run reads the toggles back off what is actually installed — every JSON hook
|
|
168
|
+
* file this installer writes, plus the guard-only shim files, both derived from the
|
|
169
|
+
* registry — so it edits instead of resetting. Harnesses that only wire the guard
|
|
170
|
+
* (gemini, grok, cursor, hermes, opencode, cline) persist no stats/map entries, so
|
|
171
|
+
* after a re-run scoped to them those toggles read back as off; the defaults below
|
|
172
|
+
* apply only when nothing of smelt's is installed at all.
|
|
173
|
+
*
|
|
174
|
+
* The two `SessionStart` toggles share one event, so they are told apart by **the
|
|
175
|
+
* command each entry runs**, not by the key it sits under. Reading `SessionStart` as
|
|
176
|
+
* one boolean would make a re-run with the map on and the lint off write both back —
|
|
177
|
+
* or neither — which is a toggle the user believed they had set.
|
|
178
|
+
*
|
|
179
|
+
* Exported for `smelt setup`, which applies the preset's *current* state the same way
|
|
180
|
+
* — read off what is installed — rather than keeping a second copy of the defaults.
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* Whether a JSON hook file's text carries entries of ours — the **one** predicate for
|
|
184
|
+
* this fact, shared by the readers (`smelt doctor`, `smelt setup`'s repair policy) and
|
|
185
|
+
* backed by the same `isOursEntry` the writer's strip-merge uses. The guard command
|
|
186
|
+
* itself carries only the shim path (no token), so a text-level `OURS_TOKEN` search
|
|
187
|
+
* would miss a guard-only install — the exact drift this exists to prevent.
|
|
188
|
+
*/
|
|
189
|
+
export declare function jsonHooksContainOurs(text: string): boolean;
|
|
190
|
+
export declare function presetToggles(cwd: string): Pick<HooksChoices, 'guard' | 'statsOnStop' | 'mapOnStart' | 'lintOnStart'>;
|
|
157
191
|
//# sourceMappingURL=hooks.d.ts.map
|