@oscharko-dev/keiko-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/context.d.ts +3 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +103 -0
  5. package/dist/doctor.d.ts +24 -0
  6. package/dist/doctor.d.ts.map +1 -0
  7. package/dist/doctor.js +108 -0
  8. package/dist/evaluate.d.ts +8 -0
  9. package/dist/evaluate.d.ts.map +1 -0
  10. package/dist/evaluate.js +270 -0
  11. package/dist/evidence.d.ts +9 -0
  12. package/dist/evidence.d.ts.map +1 -0
  13. package/dist/evidence.js +129 -0
  14. package/dist/gateway-config.d.ts +12 -0
  15. package/dist/gateway-config.d.ts.map +1 -0
  16. package/dist/gateway-config.js +19 -0
  17. package/dist/gen-tests.d.ts +8 -0
  18. package/dist/gen-tests.d.ts.map +1 -0
  19. package/dist/gen-tests.js +216 -0
  20. package/dist/index.d.ts +18 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +26 -0
  23. package/dist/init.d.ts +9 -0
  24. package/dist/init.d.ts.map +1 -0
  25. package/dist/init.js +122 -0
  26. package/dist/install-layout.d.ts +19 -0
  27. package/dist/install-layout.d.ts.map +1 -0
  28. package/dist/install-layout.js +76 -0
  29. package/dist/investigate.d.ts +9 -0
  30. package/dist/investigate.d.ts.map +1 -0
  31. package/dist/investigate.js +249 -0
  32. package/dist/launcher-paths.d.ts +4 -0
  33. package/dist/launcher-paths.d.ts.map +1 -0
  34. package/dist/launcher-paths.js +69 -0
  35. package/dist/launcher-platforms.d.ts +25 -0
  36. package/dist/launcher-platforms.d.ts.map +1 -0
  37. package/dist/launcher-platforms.js +131 -0
  38. package/dist/launcher-state.d.ts +25 -0
  39. package/dist/launcher-state.d.ts.map +1 -0
  40. package/dist/launcher-state.js +228 -0
  41. package/dist/launcher.d.ts +21 -0
  42. package/dist/launcher.d.ts.map +1 -0
  43. package/dist/launcher.js +439 -0
  44. package/dist/lifecycle.d.ts +22 -0
  45. package/dist/lifecycle.d.ts.map +1 -0
  46. package/dist/lifecycle.js +425 -0
  47. package/dist/memory.d.ts +14 -0
  48. package/dist/memory.d.ts.map +1 -0
  49. package/dist/memory.js +290 -0
  50. package/dist/models.d.ts +4 -0
  51. package/dist/models.d.ts.map +1 -0
  52. package/dist/models.js +62 -0
  53. package/dist/prompt-enhancer.d.ts +13 -0
  54. package/dist/prompt-enhancer.d.ts.map +1 -0
  55. package/dist/prompt-enhancer.js +261 -0
  56. package/dist/repair.d.ts +10 -0
  57. package/dist/repair.d.ts.map +1 -0
  58. package/dist/repair.js +402 -0
  59. package/dist/run.d.ts +10 -0
  60. package/dist/run.d.ts.map +1 -0
  61. package/dist/run.js +269 -0
  62. package/dist/runner.d.ts +7 -0
  63. package/dist/runner.d.ts.map +1 -0
  64. package/dist/runner.js +108 -0
  65. package/dist/state-paths.d.ts +43 -0
  66. package/dist/state-paths.d.ts.map +1 -0
  67. package/dist/state-paths.js +396 -0
  68. package/dist/ui.d.ts +39 -0
  69. package/dist/ui.d.ts.map +1 -0
  70. package/dist/ui.js +450 -0
  71. package/dist/uninstall.d.ts +10 -0
  72. package/dist/uninstall.d.ts.map +1 -0
  73. package/dist/uninstall.js +345 -0
  74. package/dist/verify.d.ts +3 -0
  75. package/dist/verify.d.ts.map +1 -0
  76. package/dist/verify.js +108 -0
  77. package/package.json +42 -0
@@ -0,0 +1,228 @@
1
+ // Persistent record of generated launcher shortcut paths, written under the existing
2
+ // `.keiko/` state dir alongside `ui.pid` / `ui.log` (see `lifecycle.ts`). The state file
3
+ // is plaintext JSON and contains ONLY:
4
+ // - the absolute path of each generated shortcut,
5
+ // - the SHA-256 hash of the content Keiko generated for that path at install time,
6
+ // - the platform id and an ISO timestamp for human inspection.
7
+ //
8
+ // It MUST NOT contain credentials, model identifiers, workspace data, or any
9
+ // deployment-specific value (spec §"Security-critical patterns to NOT miss").
10
+ //
11
+ // SAFETY CONTRACT (spec §"Filesystem safety contract"):
12
+ // - State file is opened with `O_NOFOLLOW` on POSIX; symlinks at the state path are
13
+ // refused. On Windows the equivalent is to refuse if `lstat` reports a symlink.
14
+ // - All writes are atomic via mkdtemp → write → rename (atomic on POSIX; on Windows we
15
+ // accept the standard rename semantics; the file lives under the user's `.keiko/`).
16
+ // - The state dir itself is created with mode 0o700.
17
+ // - `loadState` returns an empty state when the file is missing OR malformed; we never
18
+ // throw on a missing/corrupt state file at read-time, but we DO refuse to write into
19
+ // a symlinked state path.
20
+ import { closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
21
+ import { createHash } from "node:crypto";
22
+ import { join } from "node:path";
23
+ import { LauncherError, launcherFor } from "./launcher-platforms.js";
24
+ import { isRealpathContained } from "./launcher-paths.js";
25
+ export const LAUNCHER_STATE_VERSION = 1;
26
+ const STATE_FILE_NAME = "launcher-state.json";
27
+ const HASH_HEX_RE = /^[0-9a-f]{64}$/;
28
+ export function hashContent(content) {
29
+ return createHash("sha256").update(content, "utf8").digest("hex");
30
+ }
31
+ function stateFilePath(stateDir) {
32
+ return join(stateDir, STATE_FILE_NAME);
33
+ }
34
+ function emptyState() {
35
+ return { version: LAUNCHER_STATE_VERSION, entries: [] };
36
+ }
37
+ function isObject(v) {
38
+ return typeof v === "object" && v !== null;
39
+ }
40
+ function isPlatform(v) {
41
+ return v === "linux" || v === "darwin" || v === "win32";
42
+ }
43
+ function parseEntryShape(raw) {
44
+ if (!isObject(raw))
45
+ return null;
46
+ const { path, platform, contentSha256, createdAt } = raw;
47
+ if (typeof path !== "string" || path.length === 0)
48
+ return null;
49
+ if (!isPlatform(platform))
50
+ return null;
51
+ if (typeof contentSha256 !== "string" || !HASH_HEX_RE.test(contentSha256))
52
+ return null;
53
+ if (typeof createdAt !== "string")
54
+ return null;
55
+ return { path, platform, contentSha256, createdAt };
56
+ }
57
+ function isEntryContained(entry, options) {
58
+ if (options.homedir === undefined)
59
+ return true;
60
+ const approvedDir = launcherFor(entry.platform).installDirFor(options.homedir);
61
+ if (isRealpathContained(approvedDir, entry.path))
62
+ return true;
63
+ options.onWarn?.(`keiko launcher: refusing tampered state entry — path is outside the approved directory.\n approved: ${approvedDir}\n path: ${entry.path}\n`);
64
+ return false;
65
+ }
66
+ function parseEntry(raw, options = {}) {
67
+ const entry = parseEntryShape(raw);
68
+ if (entry === null)
69
+ return null;
70
+ return isEntryContained(entry, options) ? entry : null;
71
+ }
72
+ export function parseState(raw, options = {}) {
73
+ if (!isObject(raw))
74
+ return emptyState();
75
+ if (raw.version !== LAUNCHER_STATE_VERSION)
76
+ return emptyState();
77
+ if (!Array.isArray(raw.entries))
78
+ return emptyState();
79
+ const entries = [];
80
+ for (const item of raw.entries) {
81
+ const parsed = parseEntry(item, options);
82
+ if (parsed !== null)
83
+ entries.push(parsed);
84
+ }
85
+ return { version: LAUNCHER_STATE_VERSION, entries };
86
+ }
87
+ function defaultWarn(msg) {
88
+ process.stderr.write(msg);
89
+ }
90
+ // Returns the lstat result, OR `null` to signal "treat as empty state". Refuses
91
+ // (throws LauncherError) when the state path is a symlink (defense-in-depth).
92
+ function statStateFile(file, warn) {
93
+ let stat;
94
+ try {
95
+ stat = lstatSync(file);
96
+ }
97
+ catch (e) {
98
+ const code = e.code;
99
+ if (code !== "ENOENT") {
100
+ warn(`keiko launcher: cannot stat state file (${code ?? "unknown error"}): ${file}\n`);
101
+ }
102
+ return null;
103
+ }
104
+ if (stat.isSymbolicLink()) {
105
+ throw new LauncherError("STATE_SYMLINK_REFUSED", `keiko launcher: state file is a symlink and was refused: ${file}`);
106
+ }
107
+ if (!stat.isFile())
108
+ return null;
109
+ return stat;
110
+ }
111
+ function readStateRaw(file, warn) {
112
+ try {
113
+ return readWithoutFollow(file);
114
+ }
115
+ catch (e) {
116
+ if (e instanceof LauncherError)
117
+ throw e;
118
+ const code = e.code;
119
+ warn(`keiko launcher: cannot read state file (${code ?? "unknown error"}): ${file}\n`);
120
+ return null;
121
+ }
122
+ }
123
+ // Reads the state file, refusing to follow symlinks at the state path. Returns the empty
124
+ // state if the file is missing, unreadable, malformed, or contains an unrecognised version.
125
+ // Throws LauncherError ONLY when the state path exists as a symlink (defense-in-depth).
126
+ //
127
+ // When `options.homedir` is provided, every parsed entry is also containment-checked
128
+ // against the approved installDir for its declared platform (F1 parse-time barrier).
129
+ // Tampered entries are silently dropped and reported via `options.onWarn`.
130
+ //
131
+ // F6 error classification: ENOENT (missing file) is the silent-empty path; any other
132
+ // `lstat`/read failure (EACCES/EIO/EISDIR) is surfaced via `options.onWarn` so the user
133
+ // sees a signal instead of a phantom empty state. We still return `emptyState()` so the
134
+ // workflow continues — `loadState` is best-effort by contract.
135
+ export function loadState(stateDir, options = {}) {
136
+ const file = stateFilePath(stateDir);
137
+ const warn = options.onWarn ?? defaultWarn;
138
+ if (statStateFile(file, warn) === null)
139
+ return emptyState();
140
+ const raw = readStateRaw(file, warn);
141
+ if (raw === null)
142
+ return emptyState();
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ }
147
+ catch {
148
+ warn(`keiko launcher: state file is not valid JSON; ignoring: ${file}\n`);
149
+ return emptyState();
150
+ }
151
+ return parseState(parsed, options);
152
+ }
153
+ // F5: cap on the state-file size we will allocate a Buffer for. The launcher records
154
+ // a handful of entries (~ a few hundred bytes each); 1 MiB is overwhelming headroom
155
+ // and prevents a hostile/corrupt 1 GB state file from OOM-ing the launcher.
156
+ export const MAX_STATE_FILE_BYTES = 1 << 20;
157
+ // O_NOFOLLOW-based read: refuses to traverse a symlink at the final path component.
158
+ // We avoid `readFileSync(file)` because it would follow a symlink. On Windows the
159
+ // O_NOFOLLOW flag is undefined; we fall back to a normal open after the `lstat` check
160
+ // in `loadState` has already proved the path is not a link.
161
+ function readWithoutFollow(file) {
162
+ const nofollow = fsConstants.O_NOFOLLOW ?? 0;
163
+ const fd = openSync(file, fsConstants.O_RDONLY | nofollow);
164
+ try {
165
+ const stat = fstatSync(fd);
166
+ if (stat.size > MAX_STATE_FILE_BYTES) {
167
+ throw new LauncherError("STATE_TOO_LARGE", `keiko launcher: state file exceeds ${String(MAX_STATE_FILE_BYTES)} bytes (got ${String(stat.size)}); refusing to load.`);
168
+ }
169
+ if (stat.size === 0)
170
+ return "";
171
+ const buf = Buffer.alloc(stat.size);
172
+ let offset = 0;
173
+ while (offset < buf.length) {
174
+ const n = readSync(fd, buf, offset, buf.length - offset, offset);
175
+ if (n === 0)
176
+ break;
177
+ offset += n;
178
+ }
179
+ return buf.subarray(0, offset).toString("utf8");
180
+ }
181
+ finally {
182
+ closeSync(fd);
183
+ }
184
+ }
185
+ // Atomic write via mkdtemp → write → rename. The state dir is created with mode 0o700.
186
+ // The final rename is atomic on POSIX and best-effort on Windows; both are acceptable
187
+ // for a user-local state file. If the final path is a symlink we refuse before write
188
+ // (defense-in-depth: a hostile state-dir actor could plant a symlink to /etc/passwd).
189
+ export function saveState(stateDir, state) {
190
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
191
+ const file = stateFilePath(stateDir);
192
+ try {
193
+ const stat = lstatSync(file);
194
+ if (stat.isSymbolicLink()) {
195
+ throw new LauncherError("STATE_SYMLINK_REFUSED", `keiko launcher: state file is a symlink and was refused: ${file}`);
196
+ }
197
+ }
198
+ catch (e) {
199
+ if (e instanceof LauncherError)
200
+ throw e;
201
+ // ENOENT — fine; we're about to create it.
202
+ }
203
+ const tmpDir = mkdtempSync(join(stateDir, ".launcher-state-"));
204
+ const tmpFile = join(tmpDir, "state.json");
205
+ try {
206
+ writeFileSync(tmpFile, JSON.stringify(state, null, 2) + "\n", {
207
+ encoding: "utf8",
208
+ mode: 0o600,
209
+ });
210
+ renameSync(tmpFile, file);
211
+ }
212
+ finally {
213
+ rmSync(tmpDir, { recursive: true, force: true });
214
+ }
215
+ }
216
+ export function upsertEntry(state, entry) {
217
+ const others = state.entries.filter((e) => e.path !== entry.path);
218
+ return { version: LAUNCHER_STATE_VERSION, entries: [...others, entry] };
219
+ }
220
+ export function removeEntry(state, path) {
221
+ return {
222
+ version: LAUNCHER_STATE_VERSION,
223
+ entries: state.entries.filter((e) => e.path !== path),
224
+ };
225
+ }
226
+ export function findEntry(state, path) {
227
+ return state.entries.find((e) => e.path === path);
228
+ }
@@ -0,0 +1,21 @@
1
+ import type { EnvSource } from "@oscharko-dev/keiko-model-gateway";
2
+ import type { CliIo } from "./runner.js";
3
+ export interface LauncherCliDeps {
4
+ readonly cwd?: string | undefined;
5
+ readonly homedir?: () => string;
6
+ readonly platform?: () => NodeJS.Platform;
7
+ readonly resolveExe?: (env: EnvSource) => string;
8
+ readonly stateDir?: string;
9
+ }
10
+ export interface RemoveLauncherResult {
11
+ readonly removed: number;
12
+ readonly refused: number;
13
+ readonly missing: number;
14
+ }
15
+ export declare function removeLauncherShortcuts(io: CliIo, deps: {
16
+ readonly stateDir: string;
17
+ readonly homedir: string;
18
+ readonly dryRun: boolean;
19
+ }): RemoveLauncherResult;
20
+ export declare function runLauncherCli(args: readonly string[], io: CliIo, env: EnvSource, deps?: LauncherCliDeps): number;
21
+ //# sourceMappingURL=launcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launcher.d.ts","sourceRoot":"","sources":["../src/launcher.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AACnE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAoCzC,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAC,QAAQ,CAAC;IAG1C,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,MAAM,CAAC;IAGjD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAkVD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAQD,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,KAAK,EACT,IAAI,EAAE;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GACtF,oBAAoB,CAwBtB;AAqED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,KAAK,EACT,GAAG,EAAE,SAAS,EACd,IAAI,GAAE,eAAoB,GACzB,MAAM,CA+BR"}
@@ -0,0 +1,439 @@
1
+ // `keiko launcher` — generates a reversible, user-local OS shortcut that starts the local
2
+ // Keiko server in one user action. CLI-only surface; no UI route, no server change, no
3
+ // new runtime dependency. Per ADR-0024 D8 / D9 #125 checklist:
4
+ //
5
+ // - No postinstall side effect; shortcut creation is explicitly user-invoked.
6
+ // - No shell injection: the generated file content is built from a sanitized exec path
7
+ // (allow-list regex in launcher-platforms.ts) and a validated integer port.
8
+ // - Removal command documented and tested: `keiko launcher remove`.
9
+ // - No admin/root required: install paths are user-local (`~/.local/share/...`,
10
+ // `~/Applications/...`, `%APPDATA%\Microsoft\Windows\Start Menu\Programs\...`).
11
+ // - Generated locations enumerated below.
12
+ //
13
+ // SUBCOMMANDS:
14
+ // keiko launcher install [--dry-run] [--explain] [--port PORT]
15
+ // keiko launcher remove [--dry-run] [--explain]
16
+ // keiko launcher status
17
+ // keiko launcher --help
18
+ //
19
+ // GENERATED FILE LOCATIONS (per-platform, user-local approved directories — these are the
20
+ // only directories the launcher will ever write to; the resolved target is realpath-
21
+ // contained against the approved directory before any write):
22
+ // Linux: ~/.local/share/applications/keiko.desktop
23
+ // macOS: ~/Applications/Keiko Launcher.command
24
+ // Windows: %APPDATA%\Microsoft\Windows\Start Menu\Programs\Keiko.bat
25
+ //
26
+ // The launcher does NOT import lifecycle.ts; the generated shortcut spawns
27
+ // `keiko start --open` as a subprocess. This keeps the launcher independent of the
28
+ // lifecycle handler's runtime.
29
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync, } from "node:fs";
30
+ import { homedir as defaultHomedir } from "node:os";
31
+ import { dirname, isAbsolute, join, resolve } from "node:path";
32
+ import { LauncherError, launcherFor, validateExecPath, validatePort, } from "./launcher-platforms.js";
33
+ import { assertRealpathContained } from "./launcher-paths.js";
34
+ import { hashContent, loadState, removeEntry, saveState, upsertEntry, } from "./launcher-state.js";
35
+ import { resolveKeikoBinary } from "./install-layout.js";
36
+ const USAGE = `Usage:
37
+ keiko launcher install [--dry-run] [--explain] [--port PORT]
38
+ keiko launcher remove [--dry-run] [--explain]
39
+ keiko launcher status
40
+ keiko launcher --help
41
+
42
+ Generates a user-local OS shortcut that runs \`keiko start --open\` in one user action.
43
+ Generated file locations:
44
+ Linux: ~/.local/share/applications/keiko.desktop
45
+ macOS: ~/Applications/Keiko Launcher.command
46
+ Windows: %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Keiko.bat
47
+ `;
48
+ function parsePortFlag(value) {
49
+ if (value === undefined || value.startsWith("--")) {
50
+ return { ok: false, message: "missing value for --port" };
51
+ }
52
+ if (!/^\d{1,5}$/.test(value)) {
53
+ return { ok: false, message: `invalid --port value: ${value}` };
54
+ }
55
+ try {
56
+ return { ok: true, value: validatePort(Number(value)) };
57
+ }
58
+ catch (e) {
59
+ if (e instanceof LauncherError)
60
+ return { ok: false, message: e.message };
61
+ throw e;
62
+ }
63
+ }
64
+ function parseInstallArgs(rest) {
65
+ let dryRun = false;
66
+ let explain = false;
67
+ let port;
68
+ for (let i = 0; i < rest.length; i += 1) {
69
+ const arg = rest[i];
70
+ if (arg === "--dry-run") {
71
+ dryRun = true;
72
+ }
73
+ else if (arg === "--explain") {
74
+ explain = true;
75
+ }
76
+ else if (arg === "--port") {
77
+ const parsed = parsePortFlag(rest[i + 1]);
78
+ if (!parsed.ok)
79
+ return parsed;
80
+ port = parsed.value;
81
+ i += 1;
82
+ }
83
+ else {
84
+ return { ok: false, message: `unknown flag: ${arg ?? "(undefined)"}` };
85
+ }
86
+ }
87
+ return { ok: true, value: { dryRun, explain, port } };
88
+ }
89
+ function parseRemoveArgs(rest) {
90
+ let dryRun = false;
91
+ let explain = false;
92
+ for (const arg of rest) {
93
+ if (arg === "--dry-run") {
94
+ dryRun = true;
95
+ continue;
96
+ }
97
+ if (arg === "--explain") {
98
+ explain = true;
99
+ continue;
100
+ }
101
+ return { ok: false, message: `unknown flag: ${arg}` };
102
+ }
103
+ return { ok: true, value: { dryRun, explain } };
104
+ }
105
+ function defaultResolveExe(env) {
106
+ const resolution = resolveKeikoBinary(process.cwd(), env, process.argv);
107
+ if (resolution === undefined) {
108
+ throw new LauncherError("EXE_NOT_FOUND", "keiko launcher: cannot locate the `keiko` executable on PATH. Install with `npm install -g @oscharko-dev/keiko` before re-running.");
109
+ }
110
+ return validateExecPath(resolution.binPath);
111
+ }
112
+ // Path-containment helpers (realpathSync + ancestor walk) live in `./launcher-paths.ts`
113
+ // so `launcher-state.ts` can apply the same boundary at state-file parse time.
114
+ // Adapts the CliIo writer to the `onWarn(msg)` shape consumed by `loadState`. We wrap
115
+ // in a block body (not an arrow shorthand) because `io.err` returns `void`, and the
116
+ // `no-confusing-void-expression` lint rule forbids implicit-return shorthand-void.
117
+ function ioWarn(io) {
118
+ return (msg) => {
119
+ io.err(msg);
120
+ };
121
+ }
122
+ function assertApprovedDirNotSymlink(approvedDir) {
123
+ try {
124
+ const stat = lstatSync(approvedDir);
125
+ if (stat.isSymbolicLink()) {
126
+ throw new LauncherError("APPROVED_DIR_SYMLINK_REFUSED", `keiko launcher: approved directory is a symlink and was refused: ${approvedDir}`);
127
+ }
128
+ }
129
+ catch (e) {
130
+ if (e instanceof LauncherError)
131
+ throw e;
132
+ // ENOENT — fine; we'll mkdir it below.
133
+ }
134
+ }
135
+ function assertTargetNotSymlink(target) {
136
+ try {
137
+ const stat = lstatSync(target);
138
+ if (stat.isSymbolicLink()) {
139
+ throw new LauncherError("TARGET_SYMLINK_REFUSED", `keiko launcher: refusing to write through a symlink at: ${target}`);
140
+ }
141
+ }
142
+ catch {
143
+ // ENOENT — fine, target will be created.
144
+ }
145
+ }
146
+ // Atomic O_EXCL write: opens with O_WRONLY|O_CREAT|O_EXCL so we can never overwrite an
147
+ // existing file. If the file already exists we read it for content-hash comparison
148
+ // (idempotent re-install / collision detection). On POSIX we also pass O_NOFOLLOW so a
149
+ // symlink at the final path component is rejected; on Windows the lstat above is the
150
+ // only available guard.
151
+ //
152
+ // MINOR (verifier): the `existsSync(targetPath)` check in `cmdInstall` and the `openSync`
153
+ // call below form a TOCTOU window. The O_EXCL flag closes it — if another process plants
154
+ // a file at `target` between the two calls, `openSync` raises `EEXIST` and we surface a
155
+ // `TARGET_EXISTS` LauncherError (user-readable) rather than letting the raw ErrnoException
156
+ // propagate. The conversion is the only reason we catch+rethrow here.
157
+ function writeAtomicExcl(target, content, mode) {
158
+ const dir = dirname(target);
159
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
160
+ const nofollow = fsConstants.O_NOFOLLOW ?? 0;
161
+ const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | nofollow;
162
+ let fd;
163
+ try {
164
+ fd = openSync(target, flags, mode);
165
+ }
166
+ catch (e) {
167
+ if (e.code === "EEXIST") {
168
+ throw new LauncherError("TARGET_EXISTS", `keiko launcher: ${target} appeared between the pre-flight existsSync check and the O_EXCL open. Refusing to overwrite (TOCTOU defense).`);
169
+ }
170
+ throw e;
171
+ }
172
+ try {
173
+ writeSync(fd, content);
174
+ }
175
+ finally {
176
+ closeSync(fd);
177
+ }
178
+ // Some platforms ignore the mode passed to open() when O_CREAT files exist; we set
179
+ // it explicitly here so the macOS .command script is executable.
180
+ try {
181
+ chmodSync(target, mode);
182
+ }
183
+ catch {
184
+ // best-effort
185
+ }
186
+ }
187
+ // F4: when `KEIKO_STATE_DIR` is set, its resolved path MUST be contained under the
188
+ // user's homedir. Without this guard, an attacker who can plant the env var (wrapper
189
+ // script in PATH, dev-container `.env`, exported in a parent shell) can combine with
190
+ // F1 to steer the launcher state file to a world-writable location and from there to
191
+ // arbitrary-file primitives. We re-use `assertRealpathContained` so symlinked-ancestor
192
+ // edge cases compare consistently. The thrown error is re-classified as
193
+ // `STATE_DIR_ESCAPE` so the user-facing message is unambiguous.
194
+ function defaultStateDir(cwd, env, home) {
195
+ const fromEnv = env.KEIKO_STATE_DIR ?? process.env.KEIKO_STATE_DIR;
196
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
197
+ const resolved = isAbsolute(fromEnv) ? fromEnv : resolve(cwd, fromEnv);
198
+ try {
199
+ assertRealpathContained(home, resolved);
200
+ }
201
+ catch (e) {
202
+ if (e instanceof LauncherError && e.code === "PATH_ESCAPE") {
203
+ throw new LauncherError("STATE_DIR_ESCAPE", `keiko launcher: KEIKO_STATE_DIR ${fromEnv} resolves outside the user's home directory (${home}); refusing to proceed.`);
204
+ }
205
+ throw e;
206
+ }
207
+ return resolved;
208
+ }
209
+ return resolve(cwd, ".keiko");
210
+ }
211
+ function buildInstallPlan(launcher, homedir, args, exe) {
212
+ const approvedDir = launcher.installDirFor(homedir);
213
+ const targetPath = join(approvedDir, launcher.safeFileName());
214
+ const contentInput = { exe, port: args.port };
215
+ const content = launcher.generateContent(contentInput);
216
+ return {
217
+ platform: launcher.id,
218
+ approvedDir,
219
+ targetPath,
220
+ content,
221
+ contentInput,
222
+ fileMode: launcher.fileMode,
223
+ };
224
+ }
225
+ function describePlan(plan, explain) {
226
+ const lines = [];
227
+ lines.push(`platform: ${plan.platform}`);
228
+ lines.push(`path: ${plan.targetPath}`);
229
+ lines.push(`mode: 0o${plan.fileMode.toString(8)}`);
230
+ if (explain) {
231
+ lines.push("--- begin generated content ---");
232
+ lines.push(plan.content.replace(/\r\n/g, "\n"));
233
+ lines.push("--- end generated content ---");
234
+ lines.push("Remove with: keiko launcher remove");
235
+ }
236
+ return lines.join("\n") + "\n";
237
+ }
238
+ function planToEntry(plan) {
239
+ return {
240
+ path: plan.targetPath,
241
+ platform: plan.platform,
242
+ contentSha256: hashContent(plan.content),
243
+ createdAt: new Date().toISOString(),
244
+ };
245
+ }
246
+ function handleExistingTarget(plan, stateDir, homedir, io) {
247
+ const existing = readFileSync(plan.targetPath, "utf8");
248
+ if (existing !== plan.content) {
249
+ throw new LauncherError("TARGET_FOREIGN", `keiko launcher: refusing to overwrite ${plan.targetPath}.\nA file with different content already exists. Move or remove it manually before re-running.`);
250
+ }
251
+ const loadOpts = { homedir, onWarn: ioWarn(io) };
252
+ saveState(stateDir, upsertEntry(loadState(stateDir, loadOpts), planToEntry(plan)));
253
+ io.out(`Keiko launcher already installed at ${plan.targetPath} (idempotent).\n`);
254
+ return 0;
255
+ }
256
+ function cmdInstall(args, io, env, deps) {
257
+ const launcher = launcherFor(deps.platform());
258
+ const exe = deps.resolveExe(env);
259
+ const home = deps.homedir();
260
+ const plan = buildInstallPlan(launcher, home, args, exe);
261
+ assertRealpathContained(plan.approvedDir, plan.targetPath);
262
+ if (args.dryRun || args.explain) {
263
+ io.out(describePlan(plan, args.explain));
264
+ if (args.dryRun)
265
+ io.out("(dry run — no file written.)\n");
266
+ return 0;
267
+ }
268
+ mkdirSync(plan.approvedDir, { recursive: true, mode: 0o755 });
269
+ assertApprovedDirNotSymlink(plan.approvedDir);
270
+ assertTargetNotSymlink(plan.targetPath);
271
+ if (existsSync(plan.targetPath)) {
272
+ return handleExistingTarget(plan, deps.stateDir, home, io);
273
+ }
274
+ writeAtomicExcl(plan.targetPath, plan.content, plan.fileMode);
275
+ const loadOpts = { homedir: home, onWarn: ioWarn(io) };
276
+ saveState(deps.stateDir, upsertEntry(loadState(deps.stateDir, loadOpts), planToEntry(plan)));
277
+ io.out(`Installed Keiko launcher at ${plan.targetPath}.\n`);
278
+ io.out("Remove with: keiko launcher remove\n");
279
+ return 0;
280
+ }
281
+ // F1 remove-time barrier: even though parseEntry filters out-of-bounds entries at load
282
+ // time, we repeat the containment check here so a state row that bypassed the parser
283
+ // (e.g. via a future call site without homedir context) cannot reach `unlinkSync` with
284
+ // an attacker-controlled path. The throw is caught by `runLauncherCli` and surfaced as
285
+ // a non-zero exit; we never delete or even probe `existsSync` on an out-of-bounds path.
286
+ function processRemoveEntry(entry, args, io, homedir) {
287
+ assertRealpathContained(launcherFor(entry.platform).installDirFor(homedir), entry.path);
288
+ if (!existsSync(entry.path)) {
289
+ io.out(`missing: ${entry.path} (already gone — state cleared)\n`);
290
+ return "missing";
291
+ }
292
+ const existing = readFileSync(entry.path, "utf8");
293
+ if (hashContent(existing) !== entry.contentSha256) {
294
+ io.err(`refusing: ${entry.path} (content does not match the launcher Keiko generated; not deleted)\n`);
295
+ return "refused";
296
+ }
297
+ if (args.dryRun || args.explain) {
298
+ io.out(`would-delete: ${entry.path}\n`);
299
+ return "would-delete";
300
+ }
301
+ unlinkSync(entry.path);
302
+ io.out(`removed: ${entry.path}\n`);
303
+ return "removed";
304
+ }
305
+ // Shared launcher-shortcut removal used by both `keiko launcher remove` and
306
+ // `keiko uninstall`. Loads the home-contained launcher state, content-hash-verifies
307
+ // every recorded shortcut before unlinking (refusing any file whose content no longer
308
+ // matches what Keiko generated), and persists the pruned state. When `dryRun` is true
309
+ // it reports `would-delete` without touching the filesystem or the state file. The
310
+ // returned counts let `uninstall` fold launcher refusals into its overall exit code.
311
+ export function removeLauncherShortcuts(io, deps) {
312
+ const state = loadState(deps.stateDir, { homedir: deps.homedir, onWarn: ioWarn(io) });
313
+ if (state.entries.length === 0) {
314
+ io.out("Keiko launcher: nothing to remove (no recorded shortcuts).\n");
315
+ return { removed: 0, refused: 0, missing: 0 };
316
+ }
317
+ const removeArgs = { dryRun: deps.dryRun, explain: false };
318
+ let nextState = state;
319
+ let removed = 0;
320
+ let refused = 0;
321
+ let missing = 0;
322
+ for (const entry of state.entries) {
323
+ const outcome = processRemoveEntry(entry, removeArgs, io, deps.homedir);
324
+ if (outcome === "missing" || outcome === "removed")
325
+ nextState = removeEntry(nextState, entry.path);
326
+ if (outcome === "removed")
327
+ removed += 1;
328
+ else if (outcome === "refused")
329
+ refused += 1;
330
+ else if (outcome === "missing")
331
+ missing += 1;
332
+ }
333
+ if (!deps.dryRun) {
334
+ saveState(deps.stateDir, nextState);
335
+ io.out(`Keiko launcher: removed ${String(removed)} shortcut(s).\n`);
336
+ }
337
+ return { removed, refused, missing };
338
+ }
339
+ function cmdRemove(args, io, deps) {
340
+ const result = removeLauncherShortcuts(io, {
341
+ stateDir: deps.stateDir,
342
+ homedir: deps.homedir,
343
+ dryRun: args.dryRun || args.explain,
344
+ });
345
+ return result.refused > 0 ? 1 : 0;
346
+ }
347
+ function cmdStatus(io, deps) {
348
+ // F2 — parse-time containment in `loadState` already filters out-of-bounds entries
349
+ // before we reach `existsSync`/`readFileSync`, so a tampered state file cannot turn
350
+ // `status` into an arbitrary-file-existence/content probe. We additionally wrap the
351
+ // read in try/catch so an unreadable/EISDIR target classifies as `unreadable` instead
352
+ // of leaking the OS error (and its stack) onto stderr.
353
+ const state = loadState(deps.stateDir, { homedir: deps.homedir, onWarn: ioWarn(io) });
354
+ if (state.entries.length === 0) {
355
+ io.out("Keiko launcher: no shortcuts recorded.\n");
356
+ return 0;
357
+ }
358
+ for (const entry of state.entries) {
359
+ if (!existsSync(entry.path)) {
360
+ io.out(`${entry.path}\tmissing\n`);
361
+ continue;
362
+ }
363
+ let existing;
364
+ try {
365
+ existing = readFileSync(entry.path, "utf8");
366
+ }
367
+ catch {
368
+ io.out(`${entry.path}\tunreadable\n`);
369
+ continue;
370
+ }
371
+ const matches = hashContent(existing) === entry.contentSha256;
372
+ io.out(`${entry.path}\t${matches ? "ok" : "modified"}\n`);
373
+ }
374
+ return 0;
375
+ }
376
+ function isLauncherSubcommand(s) {
377
+ return s === "install" || s === "remove" || s === "status";
378
+ }
379
+ function resolveDeps(env, deps) {
380
+ const cwd = deps.cwd ?? process.cwd();
381
+ const homedirFn = deps.homedir ?? defaultHomedir;
382
+ return {
383
+ homedir: homedirFn,
384
+ platform: deps.platform ?? (() => process.platform),
385
+ resolveExe: deps.resolveExe ?? defaultResolveExe,
386
+ stateDir: deps.stateDir ?? defaultStateDir(cwd, env, homedirFn()),
387
+ };
388
+ }
389
+ export function runLauncherCli(args, io, env, deps = {}) {
390
+ const first = args[0];
391
+ if (first === undefined || first === "--help" || first === "-h") {
392
+ io.out(USAGE);
393
+ return 0;
394
+ }
395
+ if (!isLauncherSubcommand(first)) {
396
+ io.err(`keiko launcher: unknown subcommand: ${first}\n`);
397
+ io.err(USAGE);
398
+ return 2;
399
+ }
400
+ const rest = args.slice(1);
401
+ try {
402
+ // `resolveDeps` may throw `STATE_DIR_ESCAPE` (F4) when KEIKO_STATE_DIR resolves
403
+ // outside the user's home — it MUST be inside the try/catch so the LauncherError
404
+ // is converted to a `1` exit instead of an uncaught throw.
405
+ const r = resolveDeps(env, deps);
406
+ const home = r.homedir();
407
+ const handlers = {
408
+ install: () => dispatchInstall(rest, io, env, r),
409
+ remove: () => dispatchRemove(rest, io, { stateDir: r.stateDir, homedir: home }),
410
+ status: () => cmdStatus(io, { stateDir: r.stateDir, homedir: home }),
411
+ };
412
+ return handlers[first]();
413
+ }
414
+ catch (e) {
415
+ if (e instanceof LauncherError) {
416
+ io.err(`${e.message}\n`);
417
+ return 1;
418
+ }
419
+ throw e;
420
+ }
421
+ }
422
+ function dispatchInstall(rest, io, env, ctx) {
423
+ const parsed = parseInstallArgs(rest);
424
+ if (!parsed.ok) {
425
+ io.err(`keiko launcher install: ${parsed.message}\n`);
426
+ io.err(USAGE);
427
+ return 2;
428
+ }
429
+ return cmdInstall(parsed.value, io, env, ctx);
430
+ }
431
+ function dispatchRemove(rest, io, ctx) {
432
+ const parsed = parseRemoveArgs(rest);
433
+ if (!parsed.ok) {
434
+ io.err(`keiko launcher remove: ${parsed.message}\n`);
435
+ io.err(USAGE);
436
+ return 2;
437
+ }
438
+ return cmdRemove(parsed.value, io, ctx);
439
+ }