@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,249 @@
1
+ // `keiko investigate` — investigates a bounded bug report and proposes a minimal fix + a
2
+ // regression test (ADR-0009 D14). Dry-run by default; --apply writes the fix and runs verification.
3
+ // The text path prints the proposed diff (when present) plus clearly-labelled verified facts and
4
+ // the UNVERIFIED model hypothesis; --json emits the full BugInvestigationReport. Failing output and
5
+ // stack traces may be read from files (--output-file / --stack-file) to avoid huge argv. Evidence
6
+ // files are read through the workspace boundary, never raw node:fs. The gateway ModelPort is built
7
+ // from config (loadGatewayConfigFromFile); tests inject deps.model directly so no live gateway is needed.
8
+ // Exit 0 on fix-applied/fix-proposed/investigation-only, 1 on
9
+ // rejected/cancelled/failed/runtime, 2 on usage. Mirrors runGenTestsCli's structure.
10
+ import { Gateway, ConfigInvalidError, GatewayError, assertConfiguredModel, selectConfiguredModel, redact, } from "@oscharko-dev/keiko-model-gateway";
11
+ import { GatewayModelPort } from "@oscharko-dev/keiko-harness";
12
+ import { detectWorkspace, readWorkspaceFile, WorkspaceError, } from "@oscharko-dev/keiko-workspace";
13
+ import { investigateBug, renderBugMarkdownReport } from "@oscharko-dev/keiko-workflows";
14
+ import { loadGatewayConfigFromFile } from "./gateway-config.js";
15
+ const USAGE = `Usage:
16
+ keiko investigate [--description TEXT] [--output TEXT | --output-file PATH]
17
+ [--stack TEXT | --stack-file PATH] [--file PATH[,PATH]]
18
+ [--apply] [--model MODEL_ID] [--config PATH] [--json] [--dir-root PATH]
19
+
20
+ Investigates a bounded bug report and proposes a root-cause hypothesis with a
21
+ minimal fix and a regression test, separating verified facts from model
22
+ hypotheses. At least one evidence source is required (--description, --output[-file],
23
+ --stack[-file], or --file). Dry-run by default (writes nothing); pass --apply to
24
+ write the fix and run verification through the safe tool + verification layers.
25
+ `;
26
+ // Returns the value of a `--flag value` pair, undefined if absent, or null if present without a
27
+ // value (a usage error) — identical contract to runGenTestsCli's flagValue.
28
+ function flagValue(args, name) {
29
+ const i = args.indexOf(name);
30
+ if (i === -1) {
31
+ return undefined;
32
+ }
33
+ const value = args[i + 1];
34
+ return value === undefined || value.startsWith("--") ? null : value;
35
+ }
36
+ const VALUE_FLAGS = [
37
+ "--description",
38
+ "--output",
39
+ "--output-file",
40
+ "--stack",
41
+ "--stack-file",
42
+ "--file",
43
+ "--model",
44
+ "--config",
45
+ "--dir-root",
46
+ ];
47
+ function readValueFlags(args) {
48
+ const values = {};
49
+ for (const flag of VALUE_FLAGS) {
50
+ const value = flagValue(args, flag);
51
+ if (value === null) {
52
+ return null;
53
+ }
54
+ values[flag] = value;
55
+ }
56
+ return values;
57
+ }
58
+ function parseFiles(raw) {
59
+ if (raw === undefined) {
60
+ return undefined;
61
+ }
62
+ const parts = raw
63
+ .split(",")
64
+ .map((p) => p.trim())
65
+ .filter((p) => p.length > 0);
66
+ return parts.length === 0 ? undefined : parts;
67
+ }
68
+ function parseArgs(args) {
69
+ const values = readValueFlags(args);
70
+ if (values === null) {
71
+ return null;
72
+ }
73
+ return {
74
+ description: values["--description"],
75
+ output: values["--output"],
76
+ outputFile: values["--output-file"],
77
+ stack: values["--stack"],
78
+ stackFile: values["--stack-file"],
79
+ files: parseFiles(values["--file"]),
80
+ apply: args.includes("--apply"),
81
+ model: values["--model"],
82
+ config: values["--config"],
83
+ json: args.includes("--json"),
84
+ dirRoot: values["--dir-root"] ?? ".",
85
+ };
86
+ }
87
+ // At least one evidence source must be present, else there is nothing to investigate.
88
+ function hasEvidenceFlag(parsed) {
89
+ return (parsed.description !== undefined ||
90
+ parsed.output !== undefined ||
91
+ parsed.outputFile !== undefined ||
92
+ parsed.stack !== undefined ||
93
+ parsed.stackFile !== undefined ||
94
+ parsed.files !== undefined);
95
+ }
96
+ // Resolves the failing output and stack trace, reading from files when the *-file flags are set.
97
+ // The inline flag is used only when its file counterpart is absent. Throws on a read failure (the
98
+ // CLI catch maps it to a runtime error).
99
+ function resolveReport(parsed, readFile) {
100
+ const failingOutput = parsed.outputFile !== undefined ? readFile(parsed.outputFile) : parsed.output;
101
+ const stackTrace = parsed.stackFile !== undefined ? readFile(parsed.stackFile) : parsed.stack;
102
+ return {
103
+ ...(parsed.description === undefined ? {} : { description: parsed.description }),
104
+ ...(failingOutput === undefined ? {} : { failingOutput }),
105
+ ...(stackTrace === undefined ? {} : { stackTrace }),
106
+ ...(parsed.files === undefined ? {} : { targetFiles: parsed.files }),
107
+ };
108
+ }
109
+ function workspaceEvidenceReader(workspace) {
110
+ return (path) => readWorkspaceFile(workspace, path).text;
111
+ }
112
+ function buildModel(parsed, io, env) {
113
+ try {
114
+ const path = parsed.config ?? env.KEIKO_CONFIG_FILE;
115
+ if (path === undefined) {
116
+ throw new ConfigInvalidError("no config source; pass --config PATH or set KEIKO_CONFIG_FILE");
117
+ }
118
+ const config = loadGatewayConfigFromFile(path, env);
119
+ if (parsed.model !== undefined) {
120
+ assertConfiguredModel(config, parsed.model);
121
+ }
122
+ const modelId = parsed.model ??
123
+ selectConfiguredModel(config, {
124
+ kind: "chat",
125
+ toolCalling: true,
126
+ structuredOutput: true,
127
+ });
128
+ if (modelId === undefined) {
129
+ io.err("Error: no configured workflow-capable chat model is available.\n");
130
+ return 1;
131
+ }
132
+ return { port: new GatewayModelPort(new Gateway(config)), modelId };
133
+ }
134
+ catch (error) {
135
+ if (error instanceof GatewayError) {
136
+ io.err(`Error: model gateway configuration problem — ${redact(error.message)}\n` +
137
+ `Provide a gateway config with --config PATH or KEIKO_CONFIG_FILE.\n`);
138
+ return 1;
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+ function resolveConfiguredModelId(parsed, env) {
144
+ const path = parsed.config ?? env.KEIKO_CONFIG_FILE;
145
+ if (path === undefined) {
146
+ return parsed.model ?? "default";
147
+ }
148
+ const config = loadGatewayConfigFromFile(path, env);
149
+ if (parsed.model !== undefined) {
150
+ assertConfiguredModel(config, parsed.model);
151
+ return parsed.model;
152
+ }
153
+ return selectConfiguredModel(config, {
154
+ kind: "chat",
155
+ toolCalling: true,
156
+ structuredOutput: true,
157
+ });
158
+ }
159
+ function resolveModel(parsed, io, env, deps) {
160
+ if (deps.model !== undefined) {
161
+ try {
162
+ const modelId = resolveConfiguredModelId(parsed, env);
163
+ if (modelId === undefined) {
164
+ io.err("Error: no configured workflow-capable chat model is available.\n");
165
+ return 1;
166
+ }
167
+ return { port: deps.model, modelId };
168
+ }
169
+ catch (error) {
170
+ if (error instanceof GatewayError) {
171
+ io.err(`Error: model gateway configuration problem — ${redact(error.message)}\n`);
172
+ return 1;
173
+ }
174
+ throw error;
175
+ }
176
+ }
177
+ return buildModel(parsed, io, env);
178
+ }
179
+ function printText(report, io) {
180
+ io.out(`${renderBugMarkdownReport(report)}\n`);
181
+ if (report.dryRunPreview !== undefined) {
182
+ io.out(`\n${report.dryRunPreview}\n`);
183
+ }
184
+ if (report.proposedDiff !== undefined) {
185
+ io.out(`\n--- proposed fix ---\n${report.proposedDiff}\n`);
186
+ }
187
+ }
188
+ function exitCodeFor(status) {
189
+ return status === "fix-applied" || status === "fix-proposed" || status === "investigation-only"
190
+ ? 0
191
+ : 1;
192
+ }
193
+ function emitReport(report, io, json) {
194
+ if (json) {
195
+ io.out(`${JSON.stringify(report, null, 2)}\n`);
196
+ }
197
+ else {
198
+ printText(report, io);
199
+ }
200
+ return exitCodeFor(report.status);
201
+ }
202
+ // Maps a boundary error to an exit code, or rethrows when it is not a recognised IO failure.
203
+ function handleCliError(error, io) {
204
+ if (error instanceof WorkspaceError) {
205
+ io.err(`Error [${error.code}]: ${error.message}\n`);
206
+ return 1;
207
+ }
208
+ if (error instanceof Error && isFileReadError(error)) {
209
+ io.err(`Error: could not read an evidence file — ${redact(error.message)}\n`);
210
+ return 1;
211
+ }
212
+ throw error;
213
+ }
214
+ export async function runInvestigateCli(args, io, env = {}, deps = {}) {
215
+ // Issue #640: handle --help / -h before workflow-arg validation so help discovery exits 0
216
+ // with usage on stdout, not 2 with a validation error on stderr.
217
+ if (args.includes("--help") || args.includes("-h")) {
218
+ io.out(USAGE);
219
+ return 0;
220
+ }
221
+ const parsed = parseArgs(args);
222
+ if (parsed === null || !hasEvidenceFlag(parsed)) {
223
+ io.err(USAGE);
224
+ return 2;
225
+ }
226
+ const model = resolveModel(parsed, io, env, deps);
227
+ if (typeof model === "number") {
228
+ return model;
229
+ }
230
+ try {
231
+ const workspace = detectWorkspace(parsed.dirRoot);
232
+ const readFile = deps.readFile ?? workspaceEvidenceReader(workspace);
233
+ const report = await investigateBug({
234
+ workspaceRoot: workspace.root,
235
+ report: resolveReport(parsed, readFile),
236
+ apply: parsed.apply,
237
+ modelId: model.modelId,
238
+ }, { model: model.port });
239
+ return emitReport(report, io, parsed.json);
240
+ }
241
+ catch (error) {
242
+ return handleCliError(error, io);
243
+ }
244
+ }
245
+ // A Node fs read error carries a string `code` (e.g. ENOENT); narrow without `any`.
246
+ function isFileReadError(error) {
247
+ const code = error.code;
248
+ return typeof code === "string" && code.length > 0;
249
+ }
@@ -0,0 +1,4 @@
1
+ export declare function resolveWithExistingAncestor(p: string): string;
2
+ export declare function assertRealpathContained(approvedDir: string, target: string): void;
3
+ export declare function isRealpathContained(approvedDir: string, target: string): boolean;
4
+ //# sourceMappingURL=launcher-paths.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launcher-paths.d.ts","sourceRoot":"","sources":["../src/launcher-paths.ts"],"names":[],"mappings":"AAkCA,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAgB7D;AAMD,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CASjF;AAKD,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAIhF"}
@@ -0,0 +1,69 @@
1
+ // Shared realpath-containment helpers for the launcher. Extracted from `launcher.ts`
2
+ // so `launcher-state.ts` can apply the same boundary at state-file parse time without
3
+ // each call site repeating the check (defense-in-depth against state-file tampering;
4
+ // see ADR-0024 §9 / #125 security audit findings F1/F2).
5
+ //
6
+ // The helpers operate on textual paths AND the filesystem. They realpath the deepest
7
+ // existing ancestor of both the approved dir and the target, so:
8
+ //
9
+ // - `/tmp/foo` ⇄ `/private/tmp/foo` (macOS symlink-redirected tmp) compare EQUAL;
10
+ // - a symlink at the still-textual tail is NOT silently followed (we stop walking at
11
+ // the first existing component and append the tail verbatim);
12
+ // - the walk is bounded by 64 path components to guarantee termination.
13
+ //
14
+ // `assertRealpathContained` is the PRIMARY symlink defense; `assertApprovedDirNotSymlink`
15
+ // / `assertTargetNotSymlink` in `launcher.ts` are leaf-only defense-in-depth (see header
16
+ // comments there).
17
+ import { existsSync, realpathSync } from "node:fs";
18
+ import { dirname, join, resolve, sep } from "node:path";
19
+ import { LauncherError } from "./launcher-platforms.js";
20
+ function realpathOrResolve(p) {
21
+ try {
22
+ return realpathSync(p);
23
+ }
24
+ catch {
25
+ return resolve(p);
26
+ }
27
+ }
28
+ // Walks up `p`'s ancestry until it finds an existing one; returns the realpath of that
29
+ // existing ancestor concatenated with the not-yet-existing tail. This lets us compare
30
+ // paths consistently even when leaves don't exist (mkdir not yet called), without
31
+ // silently following a symlinked ancestor: the realpath is taken of the FIRST existing
32
+ // segment in the chain, so symlinks along the still-textual tail are not resolved.
33
+ export function resolveWithExistingAncestor(p) {
34
+ const absolute = resolve(p);
35
+ const tail = [];
36
+ let current = absolute;
37
+ for (let i = 0; i < 64; i += 1) {
38
+ if (existsSync(current)) {
39
+ return tail.length === 0
40
+ ? realpathOrResolve(current)
41
+ : join(realpathOrResolve(current), ...tail.reverse());
42
+ }
43
+ tail.push(current.split(sep).pop() ?? "");
44
+ const parent = dirname(current);
45
+ if (parent === current)
46
+ return absolute;
47
+ current = parent;
48
+ }
49
+ return absolute;
50
+ }
51
+ // Asserts that `target` is contained within `approvedDir` AFTER both have been resolved
52
+ // against the real filesystem. We realpath the deepest-existing ancestor of BOTH sides
53
+ // so `/tmp` ⇄ `/private/tmp` (macOS) and other symlinked-ancestor cases compare equal,
54
+ // while symlinks at the still-textual tail are NOT silently followed.
55
+ export function assertRealpathContained(approvedDir, target) {
56
+ const realApproved = resolveWithExistingAncestor(approvedDir);
57
+ const realTarget = resolveWithExistingAncestor(target);
58
+ if (realTarget !== realApproved && !realTarget.startsWith(realApproved + sep)) {
59
+ throw new LauncherError("PATH_ESCAPE", `keiko launcher: refusing to write outside the approved directory.\n approved: ${realApproved}\n target: ${realTarget}`);
60
+ }
61
+ }
62
+ // Predicate form of `assertRealpathContained` — does not throw. Used by parse-time
63
+ // filtering where we want to silently drop tampered entries (and emit a stderr warning)
64
+ // rather than abort the entire `loadState` call.
65
+ export function isRealpathContained(approvedDir, target) {
66
+ const realApproved = resolveWithExistingAncestor(approvedDir);
67
+ const realTarget = resolveWithExistingAncestor(target);
68
+ return realTarget === realApproved || realTarget.startsWith(realApproved + sep);
69
+ }
@@ -0,0 +1,25 @@
1
+ export declare const MIN_PORT = 1024;
2
+ export declare const MAX_PORT = 65535;
3
+ export type Platform = "linux" | "darwin" | "win32";
4
+ export declare class LauncherError extends Error {
5
+ readonly code: string;
6
+ constructor(code: string, message: string);
7
+ }
8
+ export declare function validateExecPath(path: string): string;
9
+ export declare function validatePort(port: number): number;
10
+ export interface LauncherContentInput {
11
+ readonly exe: string;
12
+ readonly port: number | undefined;
13
+ }
14
+ export interface PlatformLauncher {
15
+ readonly id: Platform;
16
+ readonly installDirFor: (homedir: string) => string;
17
+ readonly safeFileName: () => string;
18
+ readonly generateContent: (input: LauncherContentInput) => string;
19
+ readonly fileMode: number;
20
+ }
21
+ export declare const linuxLauncher: PlatformLauncher;
22
+ export declare const macosLauncher: PlatformLauncher;
23
+ export declare const windowsLauncher: PlatformLauncher;
24
+ export declare function launcherFor(platform: NodeJS.Platform): PlatformLauncher;
25
+ //# sourceMappingURL=launcher-platforms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launcher-platforms.d.ts","sourceRoot":"","sources":["../src/launcher-platforms.ts"],"names":[],"mappings":"AA6BA,eAAO,MAAM,QAAQ,OAAO,CAAC;AAC7B,eAAO,MAAM,QAAQ,QAAQ,CAAC;AAE9B,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEpD,qBAAa,aAAc,SAAQ,KAAK;IACtC,SAAgB,IAAI,EAAE,MAAM,CAAC;gBACjB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK1C;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBrD;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQjD;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACpD,QAAQ,CAAC,YAAY,EAAE,MAAM,MAAM,CAAC;IACpC,QAAQ,CAAC,eAAe,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,MAAM,CAAC;IAClE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAYD,eAAO,MAAM,aAAa,EAAE,gBAoB3B,CAAC;AAEF,eAAO,MAAM,aAAa,EAAE,gBAiB3B,CAAC;AAQF,eAAO,MAAM,eAAe,EAAE,gBAW7B,CAAC;AAQF,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,GAAG,gBAAgB,CASvE"}
@@ -0,0 +1,131 @@
1
+ // Pure per-OS modules for `keiko launcher install`. Each platform exports `generateContent`
2
+ // (a pure string-producing function over a sanitized executable path + optional port),
3
+ // `installDirFor(homedir)` (the user-local approved directory), and `safeFileName()`
4
+ // (the canonical filename written under that directory).
5
+ //
6
+ // SECURITY — the content generators do NOT quote or escape; they refuse with `LauncherError`
7
+ // any executable path or port that has not been validated by `validateExecPath` /
8
+ // `validatePort`. The validators are deliberately strict allow-lists: a `keiko` executable
9
+ // path that contains a space, a shell metacharacter, or any character outside
10
+ // `[A-Za-z0-9_@\-./\\:]` is rejected. Spaces in the bin location are a documented
11
+ // unsupported edge case for this release (ADR-0024 D8 / spec §"Per-platform content rules").
12
+ //
13
+ // PLATFORMS:
14
+ // - Linux: `~/.local/share/applications/keiko.desktop` (XDG Desktop Entry, text).
15
+ // - macOS: `~/Applications/Keiko Launcher.command` (bash script, chmod 0o755).
16
+ // - Windows: `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Keiko.bat` (.bat fallback,
17
+ // per ADR-0024 D8 trade-off). The .bat opens a brief cmd window before
18
+ // handing off to keiko; the binary `.lnk` format is out of scope for this
19
+ // release. The fallback is acceptable per spec §"Windows .lnk (binary format)".
20
+ import { posix as posixPath, win32 as win32Path } from "node:path";
21
+ // Allow-list: alphanumerics + the small set of safe path separators / characters that npm
22
+ // install paths legitimately produce. Anything else — spaces, quotes, `;`, `$`, backtick,
23
+ // `&`, `|`, `(`, `)`, `<`, `>`, `*`, `?`, `~`, `#`, `!`, `,`, `=`, `+`, control chars —
24
+ // is rejected. The intent is defense-in-depth even though XDG `.desktop` and `.bat`
25
+ // have their own quoting rules: no metacharacter ever reaches the file content.
26
+ const EXEC_PATH_RE = /^[A-Za-z0-9_@\-./\\:]+$/;
27
+ export const MIN_PORT = 1024;
28
+ export const MAX_PORT = 65535;
29
+ export class LauncherError extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ this.name = "LauncherError";
35
+ }
36
+ }
37
+ export function validateExecPath(path) {
38
+ if (path.length === 0) {
39
+ throw new LauncherError("EXEC_PATH_EMPTY", "keiko launcher: resolved executable path is empty.");
40
+ }
41
+ if (path.length > 4096) {
42
+ throw new LauncherError("EXEC_PATH_TOO_LONG", "keiko launcher: resolved executable path exceeds 4096 chars.");
43
+ }
44
+ if (!EXEC_PATH_RE.test(path)) {
45
+ throw new LauncherError("EXEC_PATH_UNSAFE", `keiko launcher: resolved executable path contains disallowed characters: ${path}\nOnly [A-Za-z0-9_@\\-./\\\\:] are permitted. Re-install keiko under a path without spaces or shell metacharacters.`);
46
+ }
47
+ return path;
48
+ }
49
+ export function validatePort(port) {
50
+ if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
51
+ throw new LauncherError("PORT_OUT_OF_RANGE", `keiko launcher: --port must be an integer in [${String(MIN_PORT)}, ${String(MAX_PORT)}]; received ${String(port)}.`);
52
+ }
53
+ return port;
54
+ }
55
+ function portFlag(port) {
56
+ if (port === undefined)
57
+ return "";
58
+ validatePort(port);
59
+ return ` --port ${String(port)}`;
60
+ }
61
+ function requireSafeExe(exe) {
62
+ return validateExecPath(exe);
63
+ }
64
+ export const linuxLauncher = {
65
+ id: "linux",
66
+ installDirFor: (homedir) => posixPath.join(homedir, ".local", "share", "applications"),
67
+ safeFileName: () => "keiko.desktop",
68
+ fileMode: 0o644,
69
+ generateContent: ({ exe, port }) => {
70
+ const safeExe = requireSafeExe(exe);
71
+ const flag = portFlag(port);
72
+ return [
73
+ "[Desktop Entry]",
74
+ "Type=Application",
75
+ "Name=Keiko",
76
+ "Comment=Keiko local developer-assist workspace",
77
+ `Exec=${safeExe} start --open${flag}`,
78
+ "Terminal=false",
79
+ "Categories=Development;",
80
+ "StartupNotify=true",
81
+ "",
82
+ ].join("\n");
83
+ },
84
+ };
85
+ export const macosLauncher = {
86
+ id: "darwin",
87
+ installDirFor: (homedir) => posixPath.join(homedir, "Applications"),
88
+ safeFileName: () => "Keiko Launcher.command",
89
+ fileMode: 0o755,
90
+ generateContent: ({ exe, port }) => {
91
+ const safeExe = requireSafeExe(exe);
92
+ const flag = portFlag(port);
93
+ return [
94
+ "#!/usr/bin/env bash",
95
+ "# Keiko launcher — generated by `keiko launcher install`. Edit at your own risk.",
96
+ "# Remove with: keiko launcher remove",
97
+ "set -euo pipefail",
98
+ `exec ${safeExe} start --open${flag}`,
99
+ "",
100
+ ].join("\n");
101
+ },
102
+ };
103
+ // Windows .bat fallback (per ADR-0024 D8 trade-off, definitively chosen for this release).
104
+ // A `.bat` opens a brief cmd window before launching keiko; the alternative — a binary
105
+ // `.lnk` shortcut — would launch cleanly but requires emitting the MS-SHLLINK binary
106
+ // shell-link format by hand, which is brittle and out of scope. The brief cmd window is
107
+ // documented and acceptable for the pilot. `@start "" <exe> ...` detaches the keiko
108
+ // process from the cmd window so the latter closes immediately after dispatch.
109
+ export const windowsLauncher = {
110
+ id: "win32",
111
+ installDirFor: (homedir) => win32Path.join(homedir, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs"),
112
+ safeFileName: () => "Keiko.bat",
113
+ fileMode: 0o644,
114
+ generateContent: ({ exe, port }) => {
115
+ const safeExe = requireSafeExe(exe);
116
+ const flag = portFlag(port);
117
+ return `@start "" ${safeExe} start --open${flag}\r\n`;
118
+ },
119
+ };
120
+ const REGISTRY = {
121
+ linux: linuxLauncher,
122
+ darwin: macosLauncher,
123
+ win32: windowsLauncher,
124
+ };
125
+ export function launcherFor(platform) {
126
+ const entry = REGISTRY[platform];
127
+ if (entry === undefined) {
128
+ throw new LauncherError("PLATFORM_UNSUPPORTED", `keiko launcher: platform "${platform}" is not supported. Supported: linux, darwin, win32.`);
129
+ }
130
+ return entry;
131
+ }
@@ -0,0 +1,25 @@
1
+ import { type Platform } from "./launcher-platforms.js";
2
+ export declare const LAUNCHER_STATE_VERSION: 1;
3
+ export interface LauncherStateEntry {
4
+ readonly path: string;
5
+ readonly platform: Platform;
6
+ readonly contentSha256: string;
7
+ readonly createdAt: string;
8
+ }
9
+ export interface LauncherState {
10
+ readonly version: 1;
11
+ readonly entries: readonly LauncherStateEntry[];
12
+ }
13
+ export declare function hashContent(content: string): string;
14
+ export interface ParseOptions {
15
+ readonly homedir?: string | undefined;
16
+ readonly onWarn?: ((message: string) => void) | undefined;
17
+ }
18
+ export declare function parseState(raw: unknown, options?: ParseOptions): LauncherState;
19
+ export declare function loadState(stateDir: string, options?: ParseOptions): LauncherState;
20
+ export declare const MAX_STATE_FILE_BYTES: number;
21
+ export declare function saveState(stateDir: string, state: LauncherState): void;
22
+ export declare function upsertEntry(state: LauncherState, entry: LauncherStateEntry): LauncherState;
23
+ export declare function removeEntry(state: LauncherState, path: string): LauncherState;
24
+ export declare function findEntry(state: LauncherState, path: string): LauncherStateEntry | undefined;
25
+ //# sourceMappingURL=launcher-state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"launcher-state.d.ts","sourceRoot":"","sources":["../src/launcher-state.ts"],"names":[],"mappings":"AAmCA,OAAO,EAA8B,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGpF,eAAO,MAAM,sBAAsB,EAAG,CAAU,CAAC;AAIjD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAC;CACjD;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEnD;AA6BD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CAC3D;AA4BD,wBAAgB,UAAU,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,GAAE,YAAiB,GAAG,aAAa,CAUlF;AAsDD,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,YAAiB,GAAG,aAAa,CAcrF;AAKD,eAAO,MAAM,oBAAoB,QAAU,CAAC;AAmC5C,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CA0BtE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,kBAAkB,GAAG,aAAa,CAG1F;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAK7E;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS,CAE5F"}