@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.
- package/dist/.tsbuildinfo +1 -0
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +103 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +108 -0
- package/dist/evaluate.d.ts +8 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +270 -0
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +129 -0
- package/dist/gateway-config.d.ts +12 -0
- package/dist/gateway-config.d.ts.map +1 -0
- package/dist/gateway-config.js +19 -0
- package/dist/gen-tests.d.ts +8 -0
- package/dist/gen-tests.d.ts.map +1 -0
- package/dist/gen-tests.js +216 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +122 -0
- package/dist/install-layout.d.ts +19 -0
- package/dist/install-layout.d.ts.map +1 -0
- package/dist/install-layout.js +76 -0
- package/dist/investigate.d.ts +9 -0
- package/dist/investigate.d.ts.map +1 -0
- package/dist/investigate.js +249 -0
- package/dist/launcher-paths.d.ts +4 -0
- package/dist/launcher-paths.d.ts.map +1 -0
- package/dist/launcher-paths.js +69 -0
- package/dist/launcher-platforms.d.ts +25 -0
- package/dist/launcher-platforms.d.ts.map +1 -0
- package/dist/launcher-platforms.js +131 -0
- package/dist/launcher-state.d.ts +25 -0
- package/dist/launcher-state.d.ts.map +1 -0
- package/dist/launcher-state.js +228 -0
- package/dist/launcher.d.ts +21 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +439 -0
- package/dist/lifecycle.d.ts +22 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +425 -0
- package/dist/memory.d.ts +14 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +290 -0
- package/dist/models.d.ts +4 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +62 -0
- package/dist/prompt-enhancer.d.ts +13 -0
- package/dist/prompt-enhancer.d.ts.map +1 -0
- package/dist/prompt-enhancer.js +261 -0
- package/dist/repair.d.ts +10 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +402 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +269 -0
- package/dist/runner.d.ts +7 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +108 -0
- package/dist/state-paths.d.ts +43 -0
- package/dist/state-paths.d.ts.map +1 -0
- package/dist/state-paths.js +396 -0
- package/dist/ui.d.ts +39 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +450 -0
- package/dist/uninstall.d.ts +10 -0
- package/dist/uninstall.d.ts.map +1 -0
- package/dist/uninstall.js +345 -0
- package/dist/verify.d.ts +3 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +108 -0
- 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"}
|
package/dist/launcher.js
ADDED
|
@@ -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
|
+
}
|