@farmslot/recipe-cli 0.8.0 → 0.10.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/CHANGELOG.md +29 -0
- package/dist/harness/adapter-plugins.d.ts +1 -0
- package/dist/harness/adapter-plugins.d.ts.map +1 -1
- package/dist/harness/adapter-plugins.js +3 -0
- package/dist/harness/adapter-plugins.js.map +1 -1
- package/dist/harness/adapters.d.ts +0 -2
- package/dist/harness/adapters.d.ts.map +1 -1
- package/dist/harness/adapters.js +0 -4
- package/dist/harness/adapters.js.map +1 -1
- package/dist/harness/catalog.d.ts.map +1 -1
- package/dist/harness/catalog.js +2 -5
- package/dist/harness/catalog.js.map +1 -1
- package/dist/harness/command-contract.d.ts +16 -1
- package/dist/harness/command-contract.d.ts.map +1 -1
- package/dist/harness/command-contract.js +8 -0
- package/dist/harness/command-contract.js.map +1 -1
- package/dist/harness/command-journal.d.ts.map +1 -1
- package/dist/harness/command-journal.js +1 -10
- package/dist/harness/command-journal.js.map +1 -1
- package/dist/harness/commands/call.d.ts +3 -3
- package/dist/harness/commands/call.d.ts.map +1 -1
- package/dist/harness/commands/call.js +49 -27
- package/dist/harness/commands/call.js.map +1 -1
- package/dist/harness/commands/checklist.d.ts +16 -0
- package/dist/harness/commands/checklist.d.ts.map +1 -0
- package/dist/harness/commands/checklist.js +171 -0
- package/dist/harness/commands/checklist.js.map +1 -0
- package/dist/harness/commands/doctor.d.ts +45 -0
- package/dist/harness/commands/doctor.d.ts.map +1 -0
- package/dist/harness/commands/doctor.js +636 -0
- package/dist/harness/commands/doctor.js.map +1 -0
- package/dist/harness/commands/execution-template.d.ts +2 -0
- package/dist/harness/commands/execution-template.d.ts.map +1 -0
- package/dist/harness/commands/execution-template.js +27 -0
- package/dist/harness/commands/execution-template.js.map +1 -0
- package/dist/harness/commands/launch.js +3 -0
- package/dist/harness/commands/launch.js.map +1 -1
- package/dist/harness/commands/prepare.d.ts +35 -0
- package/dist/harness/commands/prepare.d.ts.map +1 -0
- package/dist/harness/commands/prepare.js +608 -0
- package/dist/harness/commands/prepare.js.map +1 -0
- package/dist/harness/commands/recipe-quality.d.ts +13 -0
- package/dist/harness/commands/recipe-quality.d.ts.map +1 -0
- package/dist/harness/commands/recipe-quality.js +112 -0
- package/dist/harness/commands/recipe-quality.js.map +1 -0
- package/dist/harness/commands/run.d.ts +12 -0
- package/dist/harness/commands/run.d.ts.map +1 -1
- package/dist/harness/commands/run.js +55 -11
- package/dist/harness/commands/run.js.map +1 -1
- package/dist/harness/commands/status-watch.d.ts +20 -0
- package/dist/harness/commands/status-watch.d.ts.map +1 -0
- package/dist/harness/commands/status-watch.js +402 -0
- package/dist/harness/commands/status-watch.js.map +1 -0
- package/dist/harness/commands/status.d.ts +5 -0
- package/dist/harness/commands/status.d.ts.map +1 -0
- package/dist/harness/commands/status.js +167 -0
- package/dist/harness/commands/status.js.map +1 -0
- package/dist/harness/commands/task-init.d.ts +5 -0
- package/dist/harness/commands/task-init.d.ts.map +1 -0
- package/dist/harness/commands/task-init.js +54 -0
- package/dist/harness/commands/task-init.js.map +1 -0
- package/dist/harness/doctor-report.d.ts +58 -0
- package/dist/harness/doctor-report.d.ts.map +1 -0
- package/dist/harness/doctor-report.js +154 -0
- package/dist/harness/doctor-report.js.map +1 -0
- package/dist/harness/harness-cli.d.ts +10 -1
- package/dist/harness/harness-cli.d.ts.map +1 -1
- package/dist/harness/harness-cli.js +4 -6
- package/dist/harness/harness-cli.js.map +1 -1
- package/dist/harness/heal-bounds.d.ts.map +1 -1
- package/dist/harness/heal-bounds.js +6 -0
- package/dist/harness/heal-bounds.js.map +1 -1
- package/dist/harness/index.d.ts +17 -5
- package/dist/harness/index.d.ts.map +1 -1
- package/dist/harness/index.js +16 -4
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/json-stream.d.ts +9 -1
- package/dist/harness/json-stream.d.ts.map +1 -1
- package/dist/harness/json-stream.js +26 -0
- package/dist/harness/json-stream.js.map +1 -1
- package/dist/harness/paths.d.ts +2 -0
- package/dist/harness/paths.d.ts.map +1 -1
- package/dist/harness/paths.js +2 -0
- package/dist/harness/paths.js.map +1 -1
- package/dist/harness/readiness.d.ts +31 -0
- package/dist/harness/readiness.d.ts.map +1 -0
- package/dist/harness/readiness.js +12 -0
- package/dist/harness/readiness.js.map +1 -0
- package/dist/harness/recipe-validation.d.ts +0 -2
- package/dist/harness/recipe-validation.d.ts.map +1 -1
- package/dist/harness/recipe-validation.js +11 -25
- package/dist/harness/recipe-validation.js.map +1 -1
- package/dist/harness/recording-target.d.ts +11 -0
- package/dist/harness/recording-target.d.ts.map +1 -1
- package/dist/harness/recording-target.js +31 -2
- package/dist/harness/recording-target.js.map +1 -1
- package/dist/harness/run-engine.d.ts +14 -1
- package/dist/harness/run-engine.d.ts.map +1 -1
- package/dist/harness/run-engine.js +25 -4
- package/dist/harness/run-engine.js.map +1 -1
- package/dist/harness/run-options.js +2 -11
- package/dist/harness/run-options.js.map +1 -1
- package/dist/harness/stage-progress.d.ts +25 -0
- package/dist/harness/stage-progress.d.ts.map +1 -0
- package/dist/harness/stage-progress.js +170 -0
- package/dist/harness/stage-progress.js.map +1 -0
- package/dist/harness/task-view.d.ts +142 -0
- package/dist/harness/task-view.d.ts.map +1 -0
- package/dist/harness/task-view.js +451 -0
- package/dist/harness/task-view.js.map +1 -0
- package/package.json +3 -3
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// doctor — readiness for a checkout without launching anything: the shared and
|
|
2
|
+
// platform checks, the runtime probe, the devices, and what to run next.
|
|
3
|
+
// --expect-live turns it into an exit-coded liveness gate; --print-ready prints
|
|
4
|
+
// only Farmslot's health.ready_indicator; --fix repairs what it can.
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { adapterPluginChecks } from '../adapter-plugins.js';
|
|
8
|
+
import { adapterDetectNext, adapterForPlatform, assertAdapter, detectAdapter, harnessAdapter, undetectedAdapterMessage, } from '../adapters.js';
|
|
9
|
+
import { acquireCheckoutLock } from '../checkout-lock.js';
|
|
10
|
+
import { color } from '../cli-color.js';
|
|
11
|
+
import { createDoctorReport, requiredDoctorCheckSummary, } from '../doctor-report.js';
|
|
12
|
+
import { ensureOverlay, newHealState, recipeRunning } from '../heal-bounds.js';
|
|
13
|
+
import { harnessHost, hostEnvName } from '../host.js';
|
|
14
|
+
import { failStream, JsonStreamWriter } from '../json-stream.js';
|
|
15
|
+
import { resolveRuntimeContextPath } from '../overlay.js';
|
|
16
|
+
import { actionManifestPathOption, applyRuntimeDirOption, optionFlag, optionString, shellQuote, targetPath, } from '../parse-args.js';
|
|
17
|
+
import { adapterReadiness, renderFeatureFlagLine, } from '../readiness.js';
|
|
18
|
+
import { checkoutBusyOut, EXIT, usageOut } from '../shared.js';
|
|
19
|
+
import { createStageReporter } from '../stage-progress.js';
|
|
20
|
+
// --expect-live may carry the slot's ports on the flag line (hooks template
|
|
21
|
+
// {{cdp_port}}/{{port}}); apply them AFTER resolveSlotPorts so an explicit flag
|
|
22
|
+
// wins over the checkout/pool-resolved port, matching launch/run precedence. A
|
|
23
|
+
// CDP readiness probe only reaches CDP when CDP_PORT is set, so without this the
|
|
24
|
+
// liveness answer would fall back to "unverified" and fail closed.
|
|
25
|
+
function applyDoctorRuntimePorts(options) {
|
|
26
|
+
const cdpPort = optionString(options, 'cdpPort');
|
|
27
|
+
if (cdpPort) {
|
|
28
|
+
process.env.CDP_PORT = cdpPort;
|
|
29
|
+
process.env.RECIPE_CDP_PORT = cdpPort;
|
|
30
|
+
}
|
|
31
|
+
const watcherPort = optionString(options, 'watcherPort') ??
|
|
32
|
+
optionString(options, 'port') ??
|
|
33
|
+
optionString(options, 'metroPort');
|
|
34
|
+
if (watcherPort) {
|
|
35
|
+
process.env.WATCHER_PORT = watcherPort;
|
|
36
|
+
process.env.METRO_PORT = watcherPort;
|
|
37
|
+
process.env.RECIPE_WATCHER_PORT = watcherPort;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function executableName() {
|
|
41
|
+
const executable = process.env[hostEnvName('EXECUTABLE')];
|
|
42
|
+
return executable ? shellQuote(executable) : harnessHost().name;
|
|
43
|
+
}
|
|
44
|
+
export async function handleDoctor(parsed, commandOptions) {
|
|
45
|
+
// --json-stream (doctor --fix): stdout is NDJSON, a `stage` event per fix and
|
|
46
|
+
// then `complete` with what was fixed; anything else printed goes to stderr.
|
|
47
|
+
const stream = new JsonStreamWriter('doctor', optionFlag(parsed.options, 'jsonStream'));
|
|
48
|
+
if (stream.enabled && !optionFlag(parsed.options, 'fix')) {
|
|
49
|
+
stream.error({
|
|
50
|
+
code: 'CLI_USAGE_ERROR',
|
|
51
|
+
message: '--json-stream is for doctor --fix; use doctor --json for the report',
|
|
52
|
+
userAction: `${harnessHost().name} doctor --fix --json-stream --adapter <adapter> --target <path>`,
|
|
53
|
+
});
|
|
54
|
+
stream.complete('fail', EXIT.usage);
|
|
55
|
+
return EXIT.usage;
|
|
56
|
+
}
|
|
57
|
+
const restoreStdout = stream.isolateStdout();
|
|
58
|
+
try {
|
|
59
|
+
const exitCode = await handleDoctorBody(parsed, commandOptions, stream);
|
|
60
|
+
// A refusal sent its error event; the stream still ends.
|
|
61
|
+
stream.complete(exitCode === EXIT.ok ? 'pass' : 'fail', exitCode);
|
|
62
|
+
return exitCode;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
failStream(stream, error, 'DOCTOR_FAILED');
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
restoreStdout();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function handleDoctorBody({ options }, commandOptions, stream) {
|
|
73
|
+
applyRuntimeDirOption(options);
|
|
74
|
+
const host = harnessHost().name;
|
|
75
|
+
const target = targetPath(options);
|
|
76
|
+
const json = optionFlag(options, 'json') || stream.enabled;
|
|
77
|
+
// Under --json-stream a refusal is an error event too: its envelope goes to stderr.
|
|
78
|
+
const refuse = (message, userAction) => {
|
|
79
|
+
stream.error({ code: 'USAGE', message, userAction });
|
|
80
|
+
return usageOut(json, 'doctor', message, userAction);
|
|
81
|
+
};
|
|
82
|
+
const printReady = optionFlag(options, 'printReady');
|
|
83
|
+
// Farmslot health_check uses --print-ready alone; it implies the exit-coded live probe.
|
|
84
|
+
const expectLive = optionFlag(options, 'expectLive') || printReady;
|
|
85
|
+
const allDevices = optionFlag(options, 'allDevices');
|
|
86
|
+
const platformOption = optionString(options, 'platform');
|
|
87
|
+
const explicitAdapter = optionString(options, 'adapter') ?? adapterForPlatform(platformOption);
|
|
88
|
+
const adapter = explicitAdapter ?? detectAdapter(target);
|
|
89
|
+
if (!adapter) {
|
|
90
|
+
return refuse(undetectedAdapterMessage(target), adapterDetectNext());
|
|
91
|
+
}
|
|
92
|
+
assertAdapter(adapter);
|
|
93
|
+
const surface = harnessAdapter(adapter);
|
|
94
|
+
const readiness = adapterReadiness(surface);
|
|
95
|
+
if (!fs.existsSync(target)) {
|
|
96
|
+
return refuse(`target does not exist: ${target}`, `pass --target <${harnessHost().product.toLowerCase()}-checkout> pointing to an existing checkout`);
|
|
97
|
+
}
|
|
98
|
+
if (printReady && json) {
|
|
99
|
+
return refuse('--print-ready owns stdout for Farmslot health_check; drop --json (use --expect-live --json for the doctor envelope)', `${host} doctor --print-ready --adapter <adapter> --target <path>`);
|
|
100
|
+
}
|
|
101
|
+
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
102
|
+
const manifestValidation = await commandOptions.manifest(adapter, optionString(options, 'actionManifest'));
|
|
103
|
+
const flags = commandOptions.featureFlags;
|
|
104
|
+
const orphanField = commandOptions.orphanDevServers?.field ?? 'orphanDevServers';
|
|
105
|
+
const orphanLabel = commandOptions.orphanDevServers?.label ?? 'orphan dev server';
|
|
106
|
+
if (optionFlag(options, 'fix')) {
|
|
107
|
+
surface.resolveSlotPorts(target);
|
|
108
|
+
const preview = commandOptions.previewDevice?.('doctor', adapter, options);
|
|
109
|
+
if (preview && !preview.ok) {
|
|
110
|
+
return refuse(preview.message, preview.userAction);
|
|
111
|
+
}
|
|
112
|
+
applyDoctorRuntimePorts(options);
|
|
113
|
+
const lock = acquireCheckoutLock(target, 'doctor-fix');
|
|
114
|
+
if ('message' in lock) {
|
|
115
|
+
stream.error({ code: 'SANDBOX_BUSY', message: lock.message });
|
|
116
|
+
return checkoutBusyOut(json, 'doctor', lock.message, lock.path);
|
|
117
|
+
}
|
|
118
|
+
let fixed;
|
|
119
|
+
let failed;
|
|
120
|
+
try {
|
|
121
|
+
({ fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json, commandOptions.fix, stream.enabled ? (fields) => stream.emit('stage', fields) : undefined));
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
lock.release();
|
|
125
|
+
}
|
|
126
|
+
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath, undefined, commandOptions.report);
|
|
127
|
+
let runtime;
|
|
128
|
+
let runtimeProbeError;
|
|
129
|
+
try {
|
|
130
|
+
runtime = await surface.runtimeStatus(target);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
runtimeProbeError = error instanceof Error ? error.message : String(error);
|
|
134
|
+
}
|
|
135
|
+
const ready = runtime?.decision === 'ready';
|
|
136
|
+
const block = readiness.runtimeBlock?.(target, runtime);
|
|
137
|
+
if (block)
|
|
138
|
+
failed.push(block.id);
|
|
139
|
+
const nextActions = doctorFixNextActions(failed, adapter, target, block, commandOptions.fix);
|
|
140
|
+
if (failed.length === 0 && !ready && runtime?.nextAction)
|
|
141
|
+
nextActions.push(runtime.nextAction);
|
|
142
|
+
const status = result.status === 'pass' && failed.length === 0 ? 'pass' : 'fail';
|
|
143
|
+
const retryDoctor = `${host} doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
144
|
+
const fixUserAction = commandOptions.fix?.userAction?.(failed, retryDoctor) ?? nextActions[0] ?? retryDoctor;
|
|
145
|
+
const error = status === 'fail'
|
|
146
|
+
? {
|
|
147
|
+
code: 'DOCTOR_FIX_INCOMPLETE',
|
|
148
|
+
message: 'doctor --fix could not make every required check pass',
|
|
149
|
+
userAction: fixUserAction,
|
|
150
|
+
}
|
|
151
|
+
: undefined;
|
|
152
|
+
if (stream.enabled)
|
|
153
|
+
stream.complete(status, status === 'pass' ? 0 : 1, {
|
|
154
|
+
fixed,
|
|
155
|
+
failed,
|
|
156
|
+
ready,
|
|
157
|
+
nextActions,
|
|
158
|
+
...(error ? { error } : {}),
|
|
159
|
+
});
|
|
160
|
+
else if (json)
|
|
161
|
+
console.log(JSON.stringify({
|
|
162
|
+
...result,
|
|
163
|
+
status,
|
|
164
|
+
fixed,
|
|
165
|
+
failed,
|
|
166
|
+
ready,
|
|
167
|
+
runtime,
|
|
168
|
+
...(runtimeProbeError ? { runtimeProbeError } : {}),
|
|
169
|
+
nextActions,
|
|
170
|
+
...(error ? { error } : {}),
|
|
171
|
+
}, null, 2));
|
|
172
|
+
else {
|
|
173
|
+
const mode = typeof result.compatibilityMode === 'string' ? ` ${result.compatibilityMode}` : '';
|
|
174
|
+
console.log(`${status} ${adapter}${mode} ready=${ready} manifest=${actionManifestPath} fixed=[${fixed.join(',')}] failed=[${failed.join(',')}]`);
|
|
175
|
+
if (runtime) {
|
|
176
|
+
console.log(`runtime: decision=${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ''}`);
|
|
177
|
+
for (const reason of runtime.reasons)
|
|
178
|
+
console.log(` ${reason}`);
|
|
179
|
+
}
|
|
180
|
+
else if (runtimeProbeError) {
|
|
181
|
+
console.log(`runtime probe failed: ${runtimeProbeError}`);
|
|
182
|
+
}
|
|
183
|
+
for (const action of nextActions)
|
|
184
|
+
console.error(` Next: ${action}`);
|
|
185
|
+
}
|
|
186
|
+
return status === 'pass' ? 0 : 1;
|
|
187
|
+
}
|
|
188
|
+
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath, platformOption, commandOptions.report);
|
|
189
|
+
// Before the slot-port resolve below, which hydrates the env from the context
|
|
190
|
+
// file and would make every file value look like a live env override.
|
|
191
|
+
const view = commandOptions.checkoutView?.(target, adapter);
|
|
192
|
+
const missingRequiredTool = result.checks.find((check) => check.required &&
|
|
193
|
+
check.status === 'fail' &&
|
|
194
|
+
check.id.startsWith('device-tool-') &&
|
|
195
|
+
check.userAction);
|
|
196
|
+
if (missingRequiredTool) {
|
|
197
|
+
const error = {
|
|
198
|
+
code: 'DOCTOR_CHECKS_FAILED',
|
|
199
|
+
message: missingRequiredTool.message,
|
|
200
|
+
userAction: missingRequiredTool.userAction,
|
|
201
|
+
};
|
|
202
|
+
if (json) {
|
|
203
|
+
console.log(JSON.stringify({ ...result, ready: false, error }, null, 2));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
console.error(`✗ doctor: ${error.message}\n Next: ${error.userAction}`);
|
|
207
|
+
}
|
|
208
|
+
return EXIT.runtime;
|
|
209
|
+
}
|
|
210
|
+
let runtime;
|
|
211
|
+
let runtimeProbeError;
|
|
212
|
+
try {
|
|
213
|
+
surface.resolveSlotPorts(target);
|
|
214
|
+
// doctor previews the same device run/call would use, and the host refuses
|
|
215
|
+
// device options a platform has no devices for.
|
|
216
|
+
const preview = commandOptions.previewDevice?.('doctor', adapter, options);
|
|
217
|
+
if (preview && !preview.ok) {
|
|
218
|
+
if (json)
|
|
219
|
+
console.log(JSON.stringify({
|
|
220
|
+
schemaVersion: 1,
|
|
221
|
+
command: 'doctor',
|
|
222
|
+
adapter,
|
|
223
|
+
target,
|
|
224
|
+
error: {
|
|
225
|
+
code: preview.code,
|
|
226
|
+
message: preview.message,
|
|
227
|
+
userAction: preview.userAction,
|
|
228
|
+
},
|
|
229
|
+
}, null, 2));
|
|
230
|
+
else
|
|
231
|
+
console.error(`✗ doctor: ${preview.message}\n Next: ${preview.userAction}`);
|
|
232
|
+
return EXIT.usage;
|
|
233
|
+
}
|
|
234
|
+
applyDoctorRuntimePorts(options);
|
|
235
|
+
runtime = await surface.runtimeStatus(target);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
runtimeProbeError = error instanceof Error ? error.message : String(error);
|
|
239
|
+
}
|
|
240
|
+
const runtimeCheck = doctorRuntimeCheck(runtime, runtimeProbeError, expectLive);
|
|
241
|
+
const checks = [
|
|
242
|
+
...result.checks,
|
|
243
|
+
runtimeCheck,
|
|
244
|
+
...((await readiness.liveChecks?.(target)) ?? []),
|
|
245
|
+
...(await platformDoctorChecks(adapter, target)),
|
|
246
|
+
// Which library plugin code this process loaded: library, module and digest.
|
|
247
|
+
...adapterPluginChecks(),
|
|
248
|
+
];
|
|
249
|
+
const requiredChecks = requiredDoctorCheckSummary(checks);
|
|
250
|
+
const doctorResult = {
|
|
251
|
+
...result,
|
|
252
|
+
status: requiredChecks.status,
|
|
253
|
+
checks,
|
|
254
|
+
requiredChecks,
|
|
255
|
+
};
|
|
256
|
+
// Dev servers a killed/superseded launch left behind on other ports. doctor
|
|
257
|
+
// surfaces them (stop reaps them) so they never silently pile up.
|
|
258
|
+
const orphans = readiness.orphanDevServers?.(target, process.env.WATCHER_PORT) ?? [];
|
|
259
|
+
// Screen-Recording / capture readiness (macOS): shell the capture-helper doctor
|
|
260
|
+
// so a denied Screen-Recording grant surfaces as a WARN with the grant step,
|
|
261
|
+
// rather than only failing later inside ui.screenshot/--record.
|
|
262
|
+
const capture = captureHealth(readiness.captureProviders);
|
|
263
|
+
// The connected devices (id/name/state + which one is the selected target), so
|
|
264
|
+
// operators see what run/call would target. A platform without devices has none.
|
|
265
|
+
const deviceSource = await readiness.devices?.();
|
|
266
|
+
const deviceView = deviceSource ? deviceSource.view(allDevices) : null;
|
|
267
|
+
const liveView = deviceSource && deviceView ? await deviceSource.live(target, deviceView) : null;
|
|
268
|
+
const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
|
|
269
|
+
const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
|
|
270
|
+
const discovery = deviceView?.deviceDiscoveryErrors.length
|
|
271
|
+
? { deviceDiscoveryErrors: deviceView.deviceDiscoveryErrors }
|
|
272
|
+
: {};
|
|
273
|
+
// --expect-live: exit-coded liveness gate (prepare recovery, scripts that only need pass/fail).
|
|
274
|
+
// --print-ready: Farmslot health_check mode — implies --expect-live and prints
|
|
275
|
+
// the platform's ready indicator on stdout.
|
|
276
|
+
if (expectLive) {
|
|
277
|
+
return emitExpectLive({
|
|
278
|
+
adapter,
|
|
279
|
+
target,
|
|
280
|
+
runtime,
|
|
281
|
+
result: doctorResult,
|
|
282
|
+
orphans: { [orphanField]: orphans },
|
|
283
|
+
capture,
|
|
284
|
+
devices,
|
|
285
|
+
additionalReachableDevices,
|
|
286
|
+
json,
|
|
287
|
+
printReady,
|
|
288
|
+
discovery,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const advisory = commandOptions.advisory?.(target) ?? [];
|
|
292
|
+
// Which A/B variant this slot is currently pinned to: a pinned record is a
|
|
293
|
+
// file read; otherwise the live device probe that already ran above answers
|
|
294
|
+
// (doctor and status are held to one probe each). `null` is "no runtime
|
|
295
|
+
// answered", rendered as (no runtime), never as 0 overrides.
|
|
296
|
+
const pinnedFlags = readiness.pinnedFlags?.(target) ?? null;
|
|
297
|
+
const featureFlags = flags
|
|
298
|
+
? pinnedFlags
|
|
299
|
+
? flags.report(pinnedFlags.overrides, pinnedFlags)
|
|
300
|
+
: flags.report(liveView?.featureFlags ?? null)
|
|
301
|
+
: undefined;
|
|
302
|
+
const doctorUserAction = doctorResult.checks.find((check) => check.required && check.status === 'fail' && check.userAction)?.userAction ??
|
|
303
|
+
`${host} doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
304
|
+
const doctorError = doctorResult.status === 'fail'
|
|
305
|
+
? {
|
|
306
|
+
code: 'DOCTOR_CHECKS_FAILED',
|
|
307
|
+
message: 'one or more required doctor checks failed',
|
|
308
|
+
userAction: doctorUserAction,
|
|
309
|
+
}
|
|
310
|
+
: undefined;
|
|
311
|
+
const next = doctorError?.userAction ??
|
|
312
|
+
discovery.deviceDiscoveryErrors?.[0]?.userAction ??
|
|
313
|
+
(runtime?.decision === 'ready' ? undefined : runtime?.nextAction);
|
|
314
|
+
if (json) {
|
|
315
|
+
console.log(JSON.stringify({
|
|
316
|
+
...doctorResult,
|
|
317
|
+
ready: runtime?.decision === 'ready',
|
|
318
|
+
runtime,
|
|
319
|
+
...(view ? { view: view.json } : {}),
|
|
320
|
+
[orphanField]: orphans,
|
|
321
|
+
capture,
|
|
322
|
+
...Object.fromEntries(advisory.map((section) => [section.key, section.value])),
|
|
323
|
+
...(featureFlags ? { featureFlags } : {}),
|
|
324
|
+
devices,
|
|
325
|
+
additionalReachableDevices,
|
|
326
|
+
...discovery,
|
|
327
|
+
...(next ? { next } : {}),
|
|
328
|
+
...(doctorError ? { error: doctorError } : {}),
|
|
329
|
+
}, null, 2));
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
333
|
+
const stateStyle = (value, good) => value === good ? 'ok' : 'warn';
|
|
334
|
+
const mode = typeof doctorResult.compatibilityMode === 'string'
|
|
335
|
+
? ` ${doctorResult.compatibilityMode}`
|
|
336
|
+
: '';
|
|
337
|
+
console.log(`${out(doctorResult.status === 'pass' ? 'ok' : 'err', doctorResult.status)} ${out('bold', adapter)}${mode} ${out('dim', `manifest=${actionManifestPath}`)}`);
|
|
338
|
+
console.log(`${out('label', 'harness:')} ${doctorResult.runner.packageName}@${doctorResult.runner.version} ` +
|
|
339
|
+
`${out('accent', doctorResult.runner.installKind)} ${out('dim', `executable=${doctorResult.runner.executablePath}`)}`);
|
|
340
|
+
if (runtime) {
|
|
341
|
+
const provisionedDepsPending = runtime.reasonCode === 'app-installed-deps-pending';
|
|
342
|
+
const decisionStyle = provisionedDepsPending
|
|
343
|
+
? 'info'
|
|
344
|
+
: runtime.decision === 'ready'
|
|
345
|
+
? 'ok'
|
|
346
|
+
: runtime.decision === 'blocked'
|
|
347
|
+
? 'err'
|
|
348
|
+
: 'warn';
|
|
349
|
+
const depsStyle = provisionedDepsPending ? 'info' : stateStyle(runtime.deps, 'current');
|
|
350
|
+
const devServerStyle = provisionedDepsPending ? 'info' : undefined;
|
|
351
|
+
const devServer = runtime.devServer
|
|
352
|
+
? ` ${runtime.devServer.label}=${out(devServerStyle ?? stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
|
|
353
|
+
: '';
|
|
354
|
+
console.log(`${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(depsStyle, runtime.deps ?? 'unknown')}${devServer}`);
|
|
355
|
+
for (const reason of runtime.reasons)
|
|
356
|
+
console.log(` ${out('dim', reason)}`);
|
|
357
|
+
}
|
|
358
|
+
else if (runtimeProbeError) {
|
|
359
|
+
console.log(`${out('err', 'runtime probe failed:')} ${out('dim', runtimeProbeError)}`);
|
|
360
|
+
}
|
|
361
|
+
if (view)
|
|
362
|
+
console.log(view.text);
|
|
363
|
+
if (featureFlags)
|
|
364
|
+
renderFeatureFlagLine(featureFlags, out);
|
|
365
|
+
for (const line of readiness.lines?.(target) ?? [])
|
|
366
|
+
console.log(line);
|
|
367
|
+
if (orphans.length > 0) {
|
|
368
|
+
console.log(`${out('warn', `${orphanLabel}:`)} ${orphans.length} leaked bundler(s) for this checkout (pids ${orphans.join(', ')})`);
|
|
369
|
+
console.log(` ${out('dim', `Next: ${host} stop # reaps every bundler this checkout leaked`)}`);
|
|
370
|
+
}
|
|
371
|
+
if (capture)
|
|
372
|
+
renderCaptureHealth(capture, out);
|
|
373
|
+
for (const section of advisory)
|
|
374
|
+
section.render(out);
|
|
375
|
+
if (deviceSource) {
|
|
376
|
+
if (deviceView)
|
|
377
|
+
deviceSource.renderList(deviceView, out);
|
|
378
|
+
if (additionalReachableDevices.length > 0)
|
|
379
|
+
deviceSource.renderAdditional(additionalReachableDevices, out);
|
|
380
|
+
if (liveView)
|
|
381
|
+
deviceSource.renderLive(deviceView?.devices ?? [], liveView.liveMap, out);
|
|
382
|
+
}
|
|
383
|
+
if (next)
|
|
384
|
+
console.log(` ${out('dim', `Next: ${next}`)}`);
|
|
385
|
+
}
|
|
386
|
+
return doctorResult.status === 'pass' ? 0 : 1;
|
|
387
|
+
}
|
|
388
|
+
// The platform's own checks (a library plugin's, and what it extends). Checks
|
|
389
|
+
// that throw are a required failure in the report, not a crash.
|
|
390
|
+
async function platformDoctorChecks(adapter, target) {
|
|
391
|
+
try {
|
|
392
|
+
return (await harnessAdapter(adapter).doctor?.(target)) ?? [];
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
return [
|
|
396
|
+
{
|
|
397
|
+
id: `adapter-plugin:${adapter}:doctor`,
|
|
398
|
+
status: 'fail',
|
|
399
|
+
required: true,
|
|
400
|
+
message: `${adapter}'s doctor checks failed to run.`,
|
|
401
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
402
|
+
userAction: `fix the doctor() of the ${adapter} adapter, then rerun ${harnessHost().name} doctor --adapter ${adapter}`,
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function doctorRuntimeCheck(runtime, error, expectLive) {
|
|
408
|
+
if (error) {
|
|
409
|
+
return {
|
|
410
|
+
id: 'runtime',
|
|
411
|
+
status: 'fail',
|
|
412
|
+
required: true,
|
|
413
|
+
message: 'Runtime readiness probe failed.',
|
|
414
|
+
detail: error,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const ready = runtime?.decision === 'ready';
|
|
418
|
+
return {
|
|
419
|
+
id: 'runtime',
|
|
420
|
+
status: ready ? 'pass' : 'fail',
|
|
421
|
+
required: expectLive,
|
|
422
|
+
message: ready
|
|
423
|
+
? 'Runtime is ready.'
|
|
424
|
+
: `Runtime is not ready${runtime?.reasonCode ? ` (${runtime.reasonCode})` : '.'}`,
|
|
425
|
+
detail: runtime?.reasons.join(' | ') || 'Runtime readiness probe returned no result.',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Recovery commands embed the resolved --adapter and absolute --target so each
|
|
429
|
+
// runs verbatim from any cwd without relying on adapter re-detection. They are
|
|
430
|
+
// prefixed with the resolved executable (<PREFIX>_EXECUTABLE, set by the host's
|
|
431
|
+
// bin) so they also run in task-local installs where the host is not on PATH;
|
|
432
|
+
// the host name is the fallback only for direct helper invocations.
|
|
433
|
+
export function doctorFixNextActions(failed, adapter, target,
|
|
434
|
+
// The platform's runtime block (readiness.runtimeBlock), when doctor found one.
|
|
435
|
+
block, fix) {
|
|
436
|
+
const executable = executableName();
|
|
437
|
+
const scope = `--adapter ${adapter} --target ${shellQuote(target)}`;
|
|
438
|
+
const actions = [
|
|
439
|
+
...(fix?.nextActions?.(failed, { adapter, target, executable, scope }) ?? []),
|
|
440
|
+
];
|
|
441
|
+
if (failed.includes('runtime-context')) {
|
|
442
|
+
actions.push(`inspect ${shellQuote(resolveRuntimeContextPath(target))}, then retry ${executable} doctor --fix ${scope}`);
|
|
443
|
+
}
|
|
444
|
+
if (failed.includes('overlay'))
|
|
445
|
+
actions.push(`${executable} install ${scope}`);
|
|
446
|
+
if (block?.userAction && failed.includes(block.id))
|
|
447
|
+
actions.push(block.userAction);
|
|
448
|
+
return actions;
|
|
449
|
+
}
|
|
450
|
+
// Print a teaching stderr escape for --expect-live / --print-ready failures.
|
|
451
|
+
function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
|
|
452
|
+
const out = (style, text) => color(style, text, { stream: process.stderr });
|
|
453
|
+
const detail = runtime
|
|
454
|
+
? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ''}`
|
|
455
|
+
: 'runtime probe unavailable';
|
|
456
|
+
console.error(`${out('err', verdict)} ${out('bold', adapter)} ${detail} ${out('dim', target)}`);
|
|
457
|
+
for (const reason of runtime?.reasons ?? [])
|
|
458
|
+
console.error(` ${out('dim', reason)}`);
|
|
459
|
+
console.error(` Next: ${runtime?.nextAction ?? harnessAdapter(adapter).hints.runtimeProbeRecovery(target)}`);
|
|
460
|
+
}
|
|
461
|
+
// Emit the --expect-live answer: with --json the doctor envelope goes to stdout and
|
|
462
|
+
// the exit code carries the live answer; with --print-ready stdout is only the
|
|
463
|
+
// Farmslot ready_indicator line. Fail closed when the runtime probe is unavailable.
|
|
464
|
+
function emitExpectLive(input) {
|
|
465
|
+
const { adapter, target, runtime, json, printReady } = input;
|
|
466
|
+
const live = runtime?.decision === 'ready';
|
|
467
|
+
if (printReady) {
|
|
468
|
+
if (live) {
|
|
469
|
+
// A platform without an indicator is never ready for Farmslot's health check.
|
|
470
|
+
const indicator = adapterReadiness(harnessAdapter(adapter)).readyIndicator?.(input.devices) ?? '';
|
|
471
|
+
if (indicator) {
|
|
472
|
+
console.log(indicator);
|
|
473
|
+
return EXIT.ok;
|
|
474
|
+
}
|
|
475
|
+
emitExpectLiveVerdict(adapter, target, runtime, 'not-ready');
|
|
476
|
+
return EXIT.runtime;
|
|
477
|
+
}
|
|
478
|
+
emitExpectLiveVerdict(adapter, target, runtime, 'not-live');
|
|
479
|
+
return EXIT.runtime;
|
|
480
|
+
}
|
|
481
|
+
if (json) {
|
|
482
|
+
const userAction = runtime?.nextAction ?? harnessAdapter(adapter).hints.runtimeProbeRecovery(target);
|
|
483
|
+
const error = live
|
|
484
|
+
? undefined
|
|
485
|
+
: { code: 'RUNTIME_NOT_LIVE', message: `${adapter} runtime is not live`, userAction };
|
|
486
|
+
console.log(JSON.stringify({
|
|
487
|
+
...input.result,
|
|
488
|
+
ready: live,
|
|
489
|
+
runtime,
|
|
490
|
+
...input.orphans,
|
|
491
|
+
capture: input.capture,
|
|
492
|
+
devices: input.devices,
|
|
493
|
+
additionalReachableDevices: input.additionalReachableDevices,
|
|
494
|
+
...input.discovery,
|
|
495
|
+
...(error ? { error } : {}),
|
|
496
|
+
}, null, 2));
|
|
497
|
+
return live ? EXIT.ok : EXIT.runtime;
|
|
498
|
+
}
|
|
499
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
500
|
+
if (live) {
|
|
501
|
+
console.log(`${out('ok', 'live')} ${out('bold', adapter)} runtime ready`);
|
|
502
|
+
return EXIT.ok;
|
|
503
|
+
}
|
|
504
|
+
emitExpectLiveVerdict(adapter, target, runtime, 'not-live');
|
|
505
|
+
return EXIT.runtime;
|
|
506
|
+
}
|
|
507
|
+
function captureHealth(providers) {
|
|
508
|
+
if (!providers)
|
|
509
|
+
return null;
|
|
510
|
+
const fallback = [...providers];
|
|
511
|
+
const helper = captureHelperHealth();
|
|
512
|
+
if (!helper) {
|
|
513
|
+
return {
|
|
514
|
+
status: 'fallback',
|
|
515
|
+
failing: [],
|
|
516
|
+
screenshots: { available: true, providers: fallback },
|
|
517
|
+
recording: { available: false, provider: null },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
status: helper.status,
|
|
522
|
+
failing: helper.failing,
|
|
523
|
+
screenshots: { available: true, providers: ['capture-helper', ...fallback] },
|
|
524
|
+
recording: {
|
|
525
|
+
available: helper.status === 'pass',
|
|
526
|
+
provider: helper.status === 'pass' ? 'capture-helper' : null,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function captureHelperHealth() {
|
|
531
|
+
if (process.platform !== 'darwin')
|
|
532
|
+
return null;
|
|
533
|
+
const bin = process.env.CAPTURE_HELPER_PATH || 'capture-helper';
|
|
534
|
+
try {
|
|
535
|
+
const out = execFileSync(bin, ['doctor', '--json'], {
|
|
536
|
+
encoding: 'utf8',
|
|
537
|
+
timeout: 10000,
|
|
538
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
539
|
+
});
|
|
540
|
+
const parsed = JSON.parse(out);
|
|
541
|
+
const failing = (parsed.checks ?? [])
|
|
542
|
+
.filter((c) => c.required === true && c.ok === false)
|
|
543
|
+
.map((c) => c.name ?? c.id ?? 'check');
|
|
544
|
+
return { status: parsed.ok === true && failing.length === 0 ? 'pass' : 'warn', failing };
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function renderCaptureHealth(capture, out) {
|
|
551
|
+
const video = capture.recording.available ? 'capture-helper' : 'unavailable';
|
|
552
|
+
console.log(`${out('label', 'capture:')} ${out(capture.status === 'pass' ? 'ok' : 'warn', capture.status)} ` +
|
|
553
|
+
`${out('dim', `(screenshots: ${capture.screenshots.providers.join(' → ')}; video: ${video})`)}`);
|
|
554
|
+
if (capture.status === 'warn') {
|
|
555
|
+
if (capture.failing.length > 0)
|
|
556
|
+
console.log(` ${out('dim', `failing: ${capture.failing.join(', ')}`)}`);
|
|
557
|
+
console.log(` ${out('dim', 'Next: grant Screen Recording (System Settings → Privacy & Security → Screen Recording), or run: capture-helper doctor --open-permissions')}`);
|
|
558
|
+
}
|
|
559
|
+
else if (capture.status === 'fallback') {
|
|
560
|
+
console.log(` ${out('dim', 'Next: screenshots work without capture-helper; install/configure capture-helper only when recipe video is required')}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function runDoctorFix(adapter, target, manifestValidation, json, fix,
|
|
564
|
+
// The --json-stream `stage` event for each stage line.
|
|
565
|
+
onStageEvent) {
|
|
566
|
+
const fixed = [];
|
|
567
|
+
const failed = [];
|
|
568
|
+
if (recipeRunning(target)) {
|
|
569
|
+
failed.push('recipe-running');
|
|
570
|
+
return { fixed, failed };
|
|
571
|
+
}
|
|
572
|
+
if (Number(manifestValidation.summary?.errors ?? 0) > 0)
|
|
573
|
+
failed.push('manifest');
|
|
574
|
+
// One stage per fix, on stderr, so a slow repair shows which one is running.
|
|
575
|
+
const repairs = adapterReadiness(harnessAdapter(adapter)).fixes ?? [];
|
|
576
|
+
const stages = createStageReporter(onStageEvent ? { event: onStageEvent } : {});
|
|
577
|
+
const total = 2 + repairs.length + (fix?.repair ? 1 : 0);
|
|
578
|
+
let index = 0;
|
|
579
|
+
const stage = (name) => stages.stage(name, { index: ++index, total });
|
|
580
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
581
|
+
try {
|
|
582
|
+
const contextStage = stage('runtime-context');
|
|
583
|
+
try {
|
|
584
|
+
harnessAdapter(adapter).resolveSlotPorts(target);
|
|
585
|
+
const runtimeContext = fix?.ensureRuntimeContext?.(target, adapter);
|
|
586
|
+
if (runtimeContext?.created)
|
|
587
|
+
fixed.push('runtime-context');
|
|
588
|
+
contextStage.done(runtimeContext?.created ? 'created' : undefined);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
failed.push('runtime-context');
|
|
592
|
+
contextStage.failed(reason(error));
|
|
593
|
+
}
|
|
594
|
+
for (const repair of repairs) {
|
|
595
|
+
const repairStage = stage(repair.id);
|
|
596
|
+
try {
|
|
597
|
+
const repaired = await repair.apply(target);
|
|
598
|
+
if (repaired)
|
|
599
|
+
fixed.push(repair.id);
|
|
600
|
+
repairStage.done(repaired ? 'fixed' : undefined);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
failed.push(repair.id);
|
|
604
|
+
repairStage.failed(reason(error));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const overlayStage = stage('overlay');
|
|
608
|
+
const state = newHealState();
|
|
609
|
+
const ensured = await ensureOverlay(adapter, target, 'infra-only', state, json);
|
|
610
|
+
if (!ensured.ok) {
|
|
611
|
+
failed.push('overlay');
|
|
612
|
+
overlayStage.failed(ensured.error);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
if (state.mutations.length > 0)
|
|
616
|
+
fixed.push('overlay');
|
|
617
|
+
overlayStage.done(state.mutations.length > 0 ? 'installed' : undefined);
|
|
618
|
+
}
|
|
619
|
+
const hostStage = fix?.repair ? stage('host repair') : undefined;
|
|
620
|
+
const host = fix?.repair?.(target, adapter, json);
|
|
621
|
+
if (host) {
|
|
622
|
+
fixed.push(...host.fixed);
|
|
623
|
+
failed.push(...host.failed);
|
|
624
|
+
if (host.failed.length > 0)
|
|
625
|
+
hostStage?.failed(host.failed.join(', '));
|
|
626
|
+
else
|
|
627
|
+
hostStage?.done(host.fixed.length > 0 ? `fixed ${host.fixed.join(', ')}` : undefined);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
// A fix that threw leaves its stage open; end it with the command.
|
|
632
|
+
stages.close('failed', 'doctor --fix stopped');
|
|
633
|
+
}
|
|
634
|
+
return { fixed, failed };
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=doctor.js.map
|