@ckanner/flywheel 0.22.1 → 0.28.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/npm/install.js ADDED
@@ -0,0 +1,422 @@
1
+ // Copyright (c) 2026 ckanner. SPDX-License-Identifier: UNLICENSED
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const fsp = require("node:fs/promises");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
8
+ const crypto = require("node:crypto");
9
+ const { spawn } = require("node:child_process");
10
+ const { createRequire } = require("node:module");
11
+ const { StringDecoder } = require("node:string_decoder");
12
+ const meta = require("../package.json");
13
+
14
+ const OWNER = "@ckanner/flywheel";
15
+ const PLUGIN = "@ckanner/dsh-plugin-flywheel";
16
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
17
+ const HELP = `Flywheel npm installation
18
+
19
+ npx -y @ckanner/flywheel@latest install [options]
20
+ flywheel setup [options]
21
+
22
+ Install this exact version of the CLI, worker, embedded skills and DSH plugin.
23
+ No model inference or account login is performed. Node >=22.18.0 and the existing
24
+ DSH desktop command are prerequisites. Kimi/Codex availability is reported by
25
+ the local preflight; this installer does not replace another harness.
26
+
27
+ Options:
28
+ --json Print one structured result (including partial failures)
29
+ --prefix PATH npm global installation prefix (default: npm prefix -g)
30
+ --project PATH Install skills for this project, instead of the user
31
+ --dsh-bin PATH Existing DSH desktop command (default: env/PATH)
32
+ --profile NAME DSH target profile (default: desktop)
33
+ --no-dsh Explicitly install only CLI, worker and shared skills
34
+ --no-global Configure this already installed package without npm -g
35
+ --timeout-ms N Deadline per external installation step (default: 600000)
36
+ --help Show this help
37
+ `;
38
+
39
+ function parseOptions(args) {
40
+ const options = { json: false, dsh: true, global: true, profile: "desktop", timeoutMs: 600000 };
41
+ const values = new Map([["--prefix", "prefix"], ["--project", "project"], ["--dsh-bin", "dshBin"], ["--profile", "profile"], ["--timeout-ms", "timeoutMs"]]);
42
+ const seen = new Set();
43
+ for (let index = 0; index < args.length; index++) {
44
+ const argument = args[index];
45
+ if (seen.has(argument)) throw new Error(`duplicate option: ${argument}`);
46
+ seen.add(argument);
47
+ if (argument === "--json") options.json = true;
48
+ else if (argument === "--no-dsh") options.dsh = false;
49
+ else if (argument === "--no-global") options.global = false;
50
+ else if (argument === "--help" || argument === "-h") options.help = true;
51
+ else if (values.has(argument)) {
52
+ const value = args[++index];
53
+ if (!value || value.startsWith("--") || value.includes("\0")) throw new Error(`${argument} requires a value`);
54
+ options[values.get(argument)] = value;
55
+ } else throw new Error(`unknown installation option: ${argument}`);
56
+ }
57
+ options.timeoutMs = Number(options.timeoutMs);
58
+ if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 100 || options.timeoutMs > 1800000) {
59
+ throw new Error("--timeout-ms must be an integer between 100 and 1800000");
60
+ }
61
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(options.profile)) throw new Error("invalid DSH profile name");
62
+ if (options.prefix) options.prefix = path.resolve(options.prefix);
63
+ if (options.project) options.project = fs.realpathSync(options.project);
64
+ return options;
65
+ }
66
+
67
+ function digest(data) {
68
+ return crypto.createHash("sha256").update(data).digest("hex");
69
+ }
70
+
71
+ function shellQuote(value) {
72
+ if (value.includes("\0") || value.includes("\n") || value.includes("\r")) throw new Error("installation paths cannot contain NUL or newline");
73
+ return "'" + value.replaceAll("'", "'\\''") + "'";
74
+ }
75
+
76
+ function findExecutable(name, environment = process.env) {
77
+ const candidates = name.includes(path.sep) ? [path.resolve(name)] : (environment.PATH || "").split(path.delimiter)
78
+ .filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, name));
79
+ for (const candidate of candidates) {
80
+ try {
81
+ fs.accessSync(candidate, fs.constants.X_OK);
82
+ if (fs.statSync(candidate).isFile()) return fs.realpathSync(candidate);
83
+ } catch { /* Try the next explicit PATH entry. */ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function npmCommand(environment = process.env) {
89
+ const script = environment.npm_execpath;
90
+ if (script && path.isAbsolute(script) && fs.existsSync(script)) return [process.execPath, [script]];
91
+ const binary = findExecutable("npm", environment);
92
+ if (!binary) throw new Error("npm is required; install Node.js 22.18.0 or newer, then rerun this command");
93
+ return [binary, []];
94
+ }
95
+
96
+ /** A bounded installation command. Runtime model-process ownership stays in Go. */
97
+ function capture(command, args, { environment = process.env, cwd, signal, timeoutMs = 600000,
98
+ cleanupGraceMs = 20000, cleanupTimeoutMs = 5000 } = {}) {
99
+ return new Promise(resolve => {
100
+ if (signal?.aborted) return resolve({ code: 1, stdout: "", stderr: "installation cancelled", cancelled: true });
101
+ const child = spawn(command, args, { cwd, env: environment, shell: false, detached: true, stdio: ["ignore", "pipe", "pipe"] });
102
+ const output = { stdout: "", stderr: "" };
103
+ const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
104
+ let bytes = 0;
105
+ let stopped;
106
+ let startError;
107
+ let escalation;
108
+ let cleanupDeadline;
109
+ let probeTimer;
110
+ let closed = false;
111
+ let closedAt;
112
+ let settled = false;
113
+ let exitCode;
114
+ let exitSignal;
115
+ const groupAlive = () => {
116
+ if (!child.pid) return false;
117
+ try { process.kill(-child.pid, 0); return true; }
118
+ catch (error) {
119
+ if (error.code === 'ESRCH') return false;
120
+ // macOS can briefly return EPERM while a terminating group is reaped.
121
+ // Keep polling within the same deadline; absence is the success proof.
122
+ if (error.code !== 'EPERM') startError ??= `cannot confirm installation process cleanup: ${error.code}`;
123
+ return true;
124
+ }
125
+ };
126
+ const kill = name => {
127
+ if (!child.pid) return;
128
+ try { process.kill(-child.pid, name); } catch (error) { if (error.code !== "ESRCH") startError ??= error.message; }
129
+ };
130
+ const stop = reason => {
131
+ if (stopped) return;
132
+ stopped = reason;
133
+ kill("SIGTERM");
134
+ // A leader's exit is not evidence that its group is gone. In particular,
135
+ // npm/pnpm can have a child that closes stdio and ignores TERM.
136
+ escalation = setTimeout(() => kill("SIGKILL"), cleanupGraceMs);
137
+ cleanupDeadline = setTimeout(() => {
138
+ if (groupAlive()) startError ??= "could not confirm installation process-group cleanup";
139
+ if (!closed) startError ??= "installation command did not close its output streams";
140
+ child.stdout.destroy();
141
+ child.stderr.destroy();
142
+ child.unref();
143
+ finish();
144
+ }, cleanupGraceMs + cleanupTimeoutMs);
145
+ };
146
+ const cancel = () => stop("cancelled");
147
+ signal?.addEventListener("abort", cancel, { once: true });
148
+ const timer = setTimeout(() => stop("timed out"), timeoutMs);
149
+ for (const stream of ["stdout", "stderr"]) child[stream].on("data", chunk => {
150
+ bytes += chunk.length;
151
+ if (bytes > 1024 * 1024) return stop("output exceeded 1 MiB");
152
+ output[stream] += decoders[stream].write(chunk);
153
+ });
154
+ const finish = () => {
155
+ if (settled) return;
156
+ settled = true;
157
+ clearTimeout(timer);
158
+ clearTimeout(escalation);
159
+ clearTimeout(cleanupDeadline);
160
+ clearTimeout(probeTimer);
161
+ signal?.removeEventListener("abort", cancel);
162
+ for (const stream of ["stdout", "stderr"]) output[stream] += decoders[stream].end();
163
+ resolve({ ...output, code: stopped || startError || exitSignal ? (exitCode || 1) : (exitCode ?? 1), signal: exitSignal,
164
+ ...(stopped ? { cancelled: stopped === "cancelled", error: stopped } : {}), ...(startError ? { error: startError } : {}) });
165
+ };
166
+ const confirm = () => {
167
+ if (settled) return;
168
+ if (!groupAlive()) return finish();
169
+ if (stopped || Date.now() - closedAt >= cleanupTimeoutMs) stop("installation command left descendants running");
170
+ probeTimer = setTimeout(confirm, 25);
171
+ };
172
+ child.once("error", error => { startError = error.message; });
173
+ child.once("close", (code, name) => {
174
+ closed = true;
175
+ closedAt = Date.now();
176
+ exitCode = code;
177
+ exitSignal = name;
178
+ clearTimeout(timer);
179
+ confirm();
180
+ });
181
+ });
182
+ }
183
+
184
+ function requireSuccess(result, label) {
185
+ if (result.code !== 0 || result.error || result.signal) {
186
+ const detail = result.error || result.stderr.trim().slice(-6000) || `exit ${result.code}`;
187
+ throw new Error(`${label}: ${detail}`);
188
+ }
189
+ return result.stdout.trim();
190
+ }
191
+
192
+ function parseDocument(result, label) {
193
+ try { return JSON.parse(result.stdout); } catch { throw new Error(`${label} did not return one valid JSON document`); }
194
+ }
195
+
196
+ async function readReceipt(filename) {
197
+ try {
198
+ const stat = await fsp.lstat(filename);
199
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("installation record is not a regular file");
200
+ const value = JSON.parse(await fsp.readFile(filename, "utf8"));
201
+ if (value.protocol !== 1 || value.owner !== OWNER || typeof value.launcher?.sha256 !== "string") throw new Error("unrecognized installation record");
202
+ return value;
203
+ } catch (error) {
204
+ if (error.code === "ENOENT") return undefined;
205
+ throw new Error(`preserving installation record ${filename}: ${error.message}`);
206
+ }
207
+ }
208
+
209
+ async function atomicWrite(filename, data, mode) {
210
+ const temporary = filename + ".tmp-" + crypto.randomUUID();
211
+ try {
212
+ await fsp.writeFile(temporary, data, { mode, flag: "wx" });
213
+ await fsp.rename(temporary, filename);
214
+ } finally {
215
+ await fsp.rm(temporary, { force: true });
216
+ }
217
+ }
218
+
219
+ function launcherContent({ cli, prefix, dshBin, environment = process.env }) {
220
+ const dirs = [path.dirname(process.execPath), path.join(prefix, "bin"), dshBin && path.dirname(dshBin),
221
+ ...(environment.PATH || "").split(path.delimiter)].filter(directory => directory && path.isAbsolute(directory)
222
+ && !directory.includes(`${path.sep}node_modules${path.sep}.bin`) && !directory.includes(`${path.sep}_npx${path.sep}`));
223
+ const lines = ["#!/bin/sh", "# Managed by @ckanner/flywheel. User edits are preserved on setup.",
224
+ `export PATH=${shellQuote([...new Set(dirs)].join(path.delimiter))}`];
225
+ if (dshBin) lines.push('if [ "${FLYWHEEL_DSH_BIN+x}" != x ]; then', ` export FLYWHEEL_DSH_BIN=${shellQuote(dshBin)}`, "fi");
226
+ lines.push(`exec ${shellQuote(process.execPath)} ${shellQuote(cli)} "$@"`, "");
227
+ return lines.join("\n");
228
+ }
229
+
230
+ async function writeLauncher({ directory, previous, cli, prefix, worker, codexProWorker, adapter, dshBin, environment }) {
231
+ await fsp.mkdir(directory, { recursive: true });
232
+ const launcher = path.join(directory, "npm-launcher");
233
+ const content = launcherContent({ cli, prefix, dshBin, environment });
234
+ try {
235
+ const stat = await fsp.lstat(launcher);
236
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("launcher is not a regular managed file");
237
+ const current = await fsp.readFile(launcher);
238
+ if (digest(current) !== digest(content) && (previous?.launcher?.path !== launcher || previous.launcher.sha256 !== digest(current))) {
239
+ throw new Error("launcher has user changes; preserve it and configure flywheelBinary explicitly, or move it aside before retrying");
240
+ }
241
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
242
+ await atomicWrite(launcher, content, 0o755);
243
+ const receipt = { protocol: 1, owner: OWNER, version: meta.version, prefix, cli, worker, codexProWorker, adapter,
244
+ node: process.execPath, launcher: { path: launcher, sha256: digest(content) } };
245
+ await atomicWrite(path.join(directory, "installation.json"), JSON.stringify(receipt, null, 2) + "\n", 0o600);
246
+ return receipt;
247
+ }
248
+
249
+ function hasPluginVersion(document, version) {
250
+ return Array.isArray(document) && document.some(project => project.dependencies?.[PLUGIN]?.version === version);
251
+ }
252
+
253
+ async function installationLock(directory) {
254
+ await fsp.mkdir(directory, { recursive: true });
255
+ const filename = path.join(directory, 'npm-install.lock');
256
+ const token = crypto.randomUUID();
257
+ let handle;
258
+ try { handle = await fsp.open(filename, 'wx', 0o600); }
259
+ catch (error) {
260
+ if (error.code === 'EEXIST') throw new Error(`installation lock exists: ${filename}; wait for the other installer, or remove this lock only after confirming a previous crashed installer has exited`);
261
+ throw error;
262
+ }
263
+ try { await handle.writeFile(JSON.stringify({ pid: process.pid, token })); }
264
+ catch (error) { await fsp.rm(filename, { force: true }); throw error; }
265
+ finally { await handle.close(); }
266
+ return async () => {
267
+ const value = JSON.parse(await fsp.readFile(filename, 'utf8'));
268
+ if (value.token !== token) throw new Error('installation lock ownership changed; preserving the lock');
269
+ await fsp.unlink(filename);
270
+ };
271
+ }
272
+
273
+ async function install(options, { environment = process.env, cwd = process.cwd() } = {}) {
274
+ const report = { protocol: 1, ok: false, version: meta.version, steps: [], ready: false, authentication: "unknown" };
275
+ const controller = new AbortController();
276
+ let cancelledBy;
277
+ let unlock;
278
+ const handlers = SIGNALS.map(name => {
279
+ const handler = () => { cancelledBy ??= name; controller.abort(); };
280
+ process.on(name, handler);
281
+ return [name, handler];
282
+ });
283
+ const run = (command, args, extra = {}) => capture(command, args, { environment, cwd, timeoutMs: options.timeoutMs, signal: controller.signal, ...extra });
284
+ const step = async (name, action) => {
285
+ if (controller.signal.aborted) throw new Error("installation cancelled");
286
+ const item = { name, status: "running" };
287
+ report.steps.push(item);
288
+ try { await action(item); if (item.status === "running") item.status = "ok"; }
289
+ catch (error) { item.status = "failed"; item.error = error.message; throw error; }
290
+ };
291
+ try {
292
+ if (!['darwin', 'linux'].includes(process.platform) || !['arm64', 'x64'].includes(process.arch)) throw new Error("unsupported installation platform");
293
+ require('../bin/runtime.js').assertSupportedNode();
294
+ const home = environment.HOME;
295
+ if (!home || !path.isAbsolute(home)) throw new Error("HOME must identify the user's absolute home directory");
296
+ const directory = path.join(home, ".config", "flywheel");
297
+ unlock = await installationLock(directory);
298
+ const previous = await readReceipt(path.join(directory, "installation.json"));
299
+ const [npm, npmArgs] = npmCommand(environment);
300
+ let prefix = options.prefix;
301
+ if (!prefix) prefix = requireSuccess(await run(npm, [...npmArgs, "prefix", "--global"]), "npm global prefix");
302
+ if (!path.isAbsolute(prefix)) throw new Error("npm did not return an absolute global prefix");
303
+ report.prefix = prefix;
304
+ let installedRoot = path.resolve(__dirname, "..");
305
+ await step("global-cli", async item => {
306
+ if (options.global) {
307
+ requireSuccess(await run(npm, [...npmArgs, "install", "--global", "--prefix", prefix, "--ignore-scripts", "--include=optional",
308
+ "--no-audit", "--no-fund", `${OWNER}@${meta.version}`]), "npm global installation");
309
+ const globalRoot = requireSuccess(await run(npm, [...npmArgs, "root", "--global", "--prefix", prefix]), "npm global package directory");
310
+ if (!path.isAbsolute(globalRoot)) throw new Error("npm did not return an absolute global package directory");
311
+ installedRoot = path.join(globalRoot, "@ckanner", "flywheel");
312
+ } else item.action = "existing-package";
313
+ const installed = JSON.parse(await fsp.readFile(path.join(installedRoot, "package.json"), "utf8"));
314
+ if (installed.name !== OWNER || installed.version !== meta.version) throw new Error("installed package does not match this installer version");
315
+ const installedRequire = createRequire(path.join(installedRoot, "package.json"));
316
+ const runtime = installedRequire("./bin/runtime.js");
317
+ report.paths = { cli: path.join(installedRoot, "bin", "flywheel.js"), native: runtime.loadNativePath(), worker: runtime.loadWorkerPath(),
318
+ codexProWorker: runtime.loadCodexProWorkerPath() };
319
+ const probe = await run(process.execPath, [report.paths.codexProWorker, '--doctor']);
320
+ requireSuccess(probe, 'installed Codex Pro dependency');
321
+ const adapter = parseDocument(probe, 'installed Codex Pro dependency');
322
+ if (adapter.protocol !== 1 || adapter.type !== 'doctor' || adapter.package !== 'dsh-subagent-codex-pro' ||
323
+ adapter.version !== '0.8.0' || adapter.machineProtocol !== 1 || adapter.available !== true || adapter.inferenceTested !== false) {
324
+ throw new Error('installed Codex Pro dependency did not verify the exact public machine API');
325
+ }
326
+ report.adapter = { package: adapter.package, version: adapter.version, protocol: adapter.machineProtocol, inferenceTested: false };
327
+ const version = requireSuccess(await run(process.execPath, [report.paths.cli, "version"]), "installed CLI version");
328
+ if (!version.split(/\s+/).includes(meta.version)) throw new Error("native binary version does not match the npm package");
329
+ item.version = version;
330
+ });
331
+ await step("skills", async item => {
332
+ const args = [report.paths.cli, "install-skills", "--json", ...(options.project ? ["--project", options.project] : [])];
333
+ const result = await run(process.execPath, args);
334
+ if (result.error || result.signal || ![0, 1].includes(result.code)) requireSuccess(result, 'skills installation');
335
+ const summary = parseDocument(result, "skills installation");
336
+ item.summary = summary;
337
+ if (summary.protocol !== 1 || !Array.isArray(summary.results) || summary.results.length === 0) throw new Error("invalid skills installation result");
338
+ requireSuccess(result, "skills installation");
339
+ if (summary.ok !== true || summary.results.some(skill => ["error", "stale", "conflict"].includes(skill.action))) throw new Error("some skills were preserved due to conflicts; inspect the results and retry");
340
+ report.skills = { count: summary.results.length, directory: summary.targetBase };
341
+ });
342
+ const requestedDsh = options.dshBin ?? environment.FLYWHEEL_DSH_BIN ?? "dsh";
343
+ const dshBin = findExecutable(requestedDsh, environment);
344
+ await step("desktop-launcher", async item => {
345
+ const receipt = await writeLauncher({ directory, previous, cli: report.paths.cli, prefix, worker: report.paths.worker,
346
+ codexProWorker: report.paths.codexProWorker, adapter: report.adapter, dshBin, environment });
347
+ report.paths.launcher = receipt.launcher.path;
348
+ item.path = receipt.launcher.path;
349
+ });
350
+ await step("dsh-plugin", async item => {
351
+ if (!options.dsh) { item.status = "skipped"; item.reason = "explicit --no-dsh"; return; }
352
+ if (!dshBin) throw new Error("DSH command not found. Enable the command in the existing desktop app, then rerun with --dsh-bin /absolute/path/to/dsh");
353
+ item.version = requireSuccess(await run(dshBin, ["--version"]), "DSH version");
354
+ requireSuccess(await run(dshBin, ["plugin", "--profile", options.profile, "add", "--save-exact", "--ignore-scripts", `${PLUGIN}@${meta.version}`]), "DSH plugin registration");
355
+ const listing = await run(dshBin, ["plugin", "--profile", options.profile, "list", PLUGIN, "--depth", "0", "--json"]);
356
+ requireSuccess(listing, "DSH plugin verification");
357
+ if (!hasPluginVersion(parseDocument(listing, "DSH plugin list"), meta.version)) throw new Error("DSH profile did not report the exact installed plugin version");
358
+ item.profile = options.profile;
359
+ item.reloadRequired = true;
360
+ });
361
+ await step("preflight", async item => {
362
+ const result = await run(report.paths.launcher, ["doctor", "--cwd", options.project || cwd, "--json"]);
363
+ if (result.error || result.signal || ![0, 1].includes(result.code)) requireSuccess(result, 'installed CLI preflight');
364
+ const diagnostic = parseDocument(result, "installed CLI preflight");
365
+ if (result.error || result.signal || ![0, 1].includes(result.code) || typeof diagnostic.ok !== "boolean" || !Array.isArray(diagnostic.checks)
366
+ || diagnostic.ok !== (result.code === 0)) throw new Error("installed CLI returned an invalid preflight result");
367
+ report.diagnostic = diagnostic;
368
+ report.ready = diagnostic.ok;
369
+ if (!diagnostic.ok) { item.status = "warning"; item.reason = "components installed; resolve the reported harness/configuration prerequisites before running a task"; }
370
+ });
371
+ const finalVersion = requireSuccess(await run(process.execPath, [report.paths.cli, 'version']), 'final installed version');
372
+ const finalPackage = JSON.parse(await fsp.readFile(path.join(installedRoot, 'package.json'), 'utf8'));
373
+ if (finalPackage.version !== meta.version || !finalVersion.split(/\s+/).includes(meta.version)) throw new Error('installed version changed during setup; rerun the intended exact version');
374
+ report.ok = !controller.signal.aborted;
375
+ if (!report.ok) throw new Error("installation cancelled");
376
+ } catch (error) {
377
+ report.error = error.message;
378
+ } finally {
379
+ if (unlock) {
380
+ try { await unlock(); }
381
+ catch (error) { report.ok = false; report.error = error.message; }
382
+ }
383
+ for (const [name, handler] of handlers) process.removeListener(name, handler);
384
+ }
385
+ if (cancelledBy) {
386
+ report.cancelledBy = cancelledBy;
387
+ report.ok = false;
388
+ const cancellation = `installation cancelled by ${cancelledBy}`;
389
+ report.error = report.error ? `${report.error}; ${cancellation}` : cancellation;
390
+ }
391
+ return report;
392
+ }
393
+
394
+ async function main(args) {
395
+ let options;
396
+ try { options = parseOptions(args); }
397
+ catch (error) {
398
+ if (args.includes("--json")) console.log(JSON.stringify({ protocol: 1, ok: false, version: meta.version, steps: [], error: error.message }));
399
+ else console.error(`flywheel install: ${error.message}`);
400
+ return 1;
401
+ }
402
+ if (options.help) { process.stdout.write(HELP); return 0; }
403
+ const report = await install(options);
404
+ if (options.json) console.log(JSON.stringify(report));
405
+ else {
406
+ console.log(`Flywheel ${report.version}: ${report.ok ? "安装完成" : "安装尚未完成"}`);
407
+ for (const item of report.steps) console.log(` ${item.status.padEnd(7)} ${item.name}${item.error ? ": " + item.error : ""}`);
408
+ if (report.paths?.launcher) console.log(`桌面启动入口: ${report.paths.launcher}`);
409
+ if (report.skills) console.log(`Skills: ${report.skills.count} 个 → ${report.skills.directory}`);
410
+ if (report.ok && report.steps.some(step => step.name === 'dsh-plugin' && step.status === 'ok')) console.log("请按 DSH 提示重载插件。");
411
+ if (report.ok) console.log("账号授权与真实模型任务尚未验证;先查看 doctor 的检查项。");
412
+ if (report.diagnostic?.checks) for (const check of report.diagnostic.checks.filter(value => value.status === "fail")) {
413
+ console.log(` 预检: ${check.name || check.id}: ${check.summary || check.message || "检查失败"}`);
414
+ if (check.remedy) console.log(` ${check.remedy}`);
415
+ }
416
+ if (report.error) console.error(report.error);
417
+ }
418
+ if (report.cancelledBy) return 128 + os.constants.signals[report.cancelledBy];
419
+ return report.ok ? 0 : 1;
420
+ }
421
+
422
+ module.exports = { main, install, parseOptions, findExecutable, npmCommand, capture, shellQuote, digest, readReceipt, writeLauncher, launcherContent, hasPluginVersion, installationLock };
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@ckanner/flywheel",
3
- "version": "0.22.1",
4
- "description": "Flywheel — Loop Engineering orchestrator (Go). Deterministic orchestrator driving Plan/Execute/Review agent loops from frozen on-disk contracts. Platform binaries ride optionalDependencies (@ckanner/flywheel-<os>-<arch>).",
3
+ "version": "0.28.0",
4
+ "description": "A deterministic Go orchestrator for coding-agent loops, with task contracts, verification gates, Git worktrees, and DSH desktop integration.",
5
5
  "bin": {
6
- "flywheel": "bin/flywheel.js"
6
+ "flywheel": "bin/flywheel.js",
7
+ "flywheel-dsh-worker": "bin/flywheel-dsh-worker.js",
8
+ "flywheel-codex-pro-worker": "bin/flywheel-codex-pro-worker.js"
7
9
  },
8
10
  "os": [
9
11
  "darwin",
@@ -14,7 +16,7 @@
14
16
  "arm64"
15
17
  ],
16
18
  "engines": {
17
- "node": ">=16"
19
+ "node": ">=22.18.0"
18
20
  },
19
21
  "repository": {
20
22
  "type": "git",
@@ -22,16 +24,33 @@
22
24
  },
23
25
  "license": "UNLICENSED",
24
26
  "files": [
25
- "bin/flywheel.js",
26
- "CHANGELOG.md"
27
+ "bin",
28
+ "npm/*.js",
29
+ "README.md",
30
+ "README.en.md",
31
+ "CHANGELOG.md",
32
+ "docs/getting-started.md",
33
+ "docs/configuration.md",
34
+ "docs/workflow.md",
35
+ "docs/en/getting-started.md",
36
+ "docs/en/configuration.md",
37
+ "docs/en/workflow.md",
38
+ "docs/examples/create-first-task.sh"
27
39
  ],
28
40
  "optionalDependencies": {
29
- "@ckanner/flywheel-darwin-arm64": "0.22.1",
30
- "@ckanner/flywheel-darwin-amd64": "0.22.1",
31
- "@ckanner/flywheel-linux-arm64": "0.22.1",
32
- "@ckanner/flywheel-linux-amd64": "0.22.1"
41
+ "@ckanner/flywheel-darwin-arm64": "0.28.0",
42
+ "@ckanner/flywheel-darwin-amd64": "0.28.0",
43
+ "@ckanner/flywheel-linux-arm64": "0.28.0",
44
+ "@ckanner/flywheel-linux-amd64": "0.28.0"
33
45
  },
34
46
  "scripts": {
35
- "release:check": "node scripts/release-preflight.js"
47
+ "release:check": "node scripts/release-preflight.js",
48
+ "test": "node --test npm/test/*.test.js",
49
+ "test:install": "node --test npm/test/install.integration.js",
50
+ "test:registry": "node npm/test/registry-smoke.js",
51
+ "build:npm": "bash scripts/build-npm.sh 0.28.0 dist-npm"
52
+ },
53
+ "dependencies": {
54
+ "@ckanner/dsh-plugin-flywheel": "0.28.0"
36
55
  }
37
56
  }