mobile-automator 0.25.0 → 0.27.1
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/README.md +13 -2
- package/bin/mauto-session-daemon.js +85 -16
- package/package.json +3 -3
- package/src/cli.js +754 -65
- package/src/device/action-catalog.js +2 -2
- package/src/device/bridge.js +66 -9
- package/src/device/connection.js +23 -4
- package/src/device/crash-model.js +110 -0
- package/src/device/device-call.js +145 -0
- package/src/device/device-resolver.js +2 -1
- package/src/device/element-model.js +37 -15
- package/src/device/engine-hints.js +83 -0
- package/src/device/failure-probe.js +276 -0
- package/src/device/mobile-mcp-client.js +3 -1
- package/src/device/mobile-mcp-tools.js +64 -0
- package/src/device/resolve-connection.js +8 -10
- package/src/device/session-daemon.js +265 -51
- package/src/device/session-handle.js +53 -0
- package/src/device/session-log.js +5 -16
- package/src/device/swipe-geometry.js +39 -0
- package/src/device/tool-args.js +3 -1
- package/src/guide/content/execute.agnostic.md +48 -5
- package/src/guide/content/execute.aware.md +42 -1
- package/src/guide/content/execute.invariants.md +1 -0
- package/src/observe/accepts.js +108 -0
- package/src/observe/event.js +241 -0
- package/src/observe/failure-capture.js +166 -0
- package/src/observe/flush.js +177 -0
- package/src/observe/paths.js +125 -0
- package/src/observe/recorder.js +178 -0
- package/src/observe/settings.js +51 -0
- package/src/observe/sinks/file.js +101 -0
- package/src/observe/sinks/stderr.js +20 -0
- package/src/observe/spool.js +199 -0
- package/src/observe/telemetry.js +143 -0
- package/src/observe/trace.js +174 -0
- package/src/observe/transport.js +169 -0
- package/src/observe/vocab.js +96 -0
- package/src/output/envelope.js +6 -0
- package/src/result/capability-catalog.js +14 -0
- package/src/result/store.js +98 -1
- package/src/schemas/config_schema.json +11 -0
- package/src/schemas/result_schema.json +55 -0
- package/src/setup/scaffold.js +27 -0
- package/src/util/log-rotate.js +34 -0
- package/src/workspace/discover.js +69 -0
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ const { version: PKG_VERSION } = require('../package.json');
|
|
|
8
8
|
const { ok, fail, render, exitCodeFor } = require('./output/envelope');
|
|
9
9
|
const { DeviceBridge } = require('./device/bridge');
|
|
10
10
|
const selectionStore = require('./device/selection');
|
|
11
|
+
const { engineHint } = require('./device/engine-hints');
|
|
11
12
|
const { ScenarioValidator } = require('./scenario/validator');
|
|
12
13
|
const { evaluate, MECHANICAL_TYPES } = require('./assertion/evaluator');
|
|
13
14
|
const { ResultStore } = require('./result/store');
|
|
@@ -23,6 +24,19 @@ const guideEmitter = require('./guide/emitter');
|
|
|
23
24
|
const { ADAPTERS } = require('./init/adapters');
|
|
24
25
|
const { isSemanticAction, ACTION_METHOD, selectResolver } = require('./device/semantic-press');
|
|
25
26
|
const connection = require('./device/connection');
|
|
27
|
+
const { record } = require('./observe/recorder');
|
|
28
|
+
const { resolveRunId } = require('./observe/settings');
|
|
29
|
+
const { runTracePath } = require('./observe/paths');
|
|
30
|
+
const { readTrace, deriveRun, pruneRunTraces } = require('./observe/trace');
|
|
31
|
+
const { readHandle, readSessionId } = require('./device/session-handle');
|
|
32
|
+
const { captureOnFailure } = require('./observe/failure-capture');
|
|
33
|
+
const { crashTimestampMs } = require('./device/crash-model');
|
|
34
|
+
const { probeCrashes } = require('./device/failure-probe');
|
|
35
|
+
const observeTelemetry = require('./observe/telemetry');
|
|
36
|
+
const observeSpool = require('./observe/spool');
|
|
37
|
+
const observeTransport = require('./observe/transport');
|
|
38
|
+
const { makeFlusher } = require('./observe/flush');
|
|
39
|
+
const { findWorkspaceRoot, noWorkspaceFailure } = require('./workspace/discover');
|
|
26
40
|
|
|
27
41
|
// Commander throws (via exitOverride) for two very different reasons, and the
|
|
28
42
|
// split below is the ONLY thing that tells them apart.
|
|
@@ -145,6 +159,16 @@ function toEnvelope(err) {
|
|
|
145
159
|
};
|
|
146
160
|
}
|
|
147
161
|
|
|
162
|
+
// The hint for any device-kind failure. Precedence: an explicit err.hint wins
|
|
163
|
+
// (our own code attached it knowing the exact cause — a DeviceResolutionError,
|
|
164
|
+
// the daemon-log pointer, a timeout); then engineHint, which recognises engine
|
|
165
|
+
// messages that are useless on their own (the 1.0.5 engine's missing iOS agent
|
|
166
|
+
// and changed Android device ids, #199); then the handler's generic fallback.
|
|
167
|
+
// Every device fail() below goes through here so no handler can miss it.
|
|
168
|
+
function deviceHint(err, fallback) {
|
|
169
|
+
return (err && err.hint) || engineHint(err && err.message) || fallback;
|
|
170
|
+
}
|
|
171
|
+
|
|
148
172
|
// ---------------------------------------------------------------------------
|
|
149
173
|
// Handlers — pure-ish: accept injected deps, return { envelope, exitKind }.
|
|
150
174
|
// No process.exit / printing here so they are trivially unit-testable.
|
|
@@ -159,7 +183,7 @@ async function handleElements({ deviceBridge }) {
|
|
|
159
183
|
envelope: fail(
|
|
160
184
|
'device',
|
|
161
185
|
err.message || String(err),
|
|
162
|
-
err
|
|
186
|
+
deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
|
|
163
187
|
),
|
|
164
188
|
exitKind: 'device',
|
|
165
189
|
};
|
|
@@ -175,7 +199,7 @@ async function handleScreenshot({ deviceBridge }, destPath) {
|
|
|
175
199
|
envelope: fail(
|
|
176
200
|
'device',
|
|
177
201
|
err.message || String(err),
|
|
178
|
-
err
|
|
202
|
+
deviceHint(err, 'Ensure a device or simulator is connected.')
|
|
179
203
|
),
|
|
180
204
|
exitKind: 'device',
|
|
181
205
|
};
|
|
@@ -234,7 +258,7 @@ function deviceFail(err) {
|
|
|
234
258
|
envelope: fail(
|
|
235
259
|
kind,
|
|
236
260
|
err.message || String(err),
|
|
237
|
-
err
|
|
261
|
+
deviceHint(err, 'Ensure a device or simulator is connected and the app is running.')
|
|
238
262
|
),
|
|
239
263
|
exitKind: kind,
|
|
240
264
|
};
|
|
@@ -266,7 +290,20 @@ function parseCoordinates(raw) {
|
|
|
266
290
|
error: fail('invalid_input', `coordinates must be numbers, got "${raw}"`, 'Pass --at <x,y>, e.g. --at 100,250.'),
|
|
267
291
|
};
|
|
268
292
|
}
|
|
269
|
-
|
|
293
|
+
// Check AFTER rounding: the device receives the rounded ints, so -0.4 (-> 0)
|
|
294
|
+
// is a valid on-screen point while -0.6 (-> -1) is not. `+ 0` normalizes -0
|
|
295
|
+
// to 0 so neither the envelope nor the engine sees a signed zero. Negatives
|
|
296
|
+
// must be caught here because mobile-mcp 1.0.5 declares coordinates .min(0)
|
|
297
|
+
// and would reject them device-side, misclassified as a device failure that
|
|
298
|
+
// triggers the crash probe (#199).
|
|
299
|
+
const rx = Math.round(x) + 0;
|
|
300
|
+
const ry = Math.round(y) + 0;
|
|
301
|
+
if (rx < 0 || ry < 0) {
|
|
302
|
+
return {
|
|
303
|
+
error: fail('invalid_input', `coordinates must be non-negative, got "${raw}"`, 'Coordinates must be >= 0 (screen pixels from the top-left), e.g. --at 100,250.'),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return { x: rx, y: ry };
|
|
270
307
|
}
|
|
271
308
|
|
|
272
309
|
async function handleTap({ deviceBridge }, raw) {
|
|
@@ -625,6 +662,75 @@ function handleResultAddAssertion({ resultStoreFactory, projectRoot }, opts) {
|
|
|
625
662
|
return { envelope: ok({ run_id: runId, assertion: entry }, storeHint(store)), exitKind: 'ok' };
|
|
626
663
|
}
|
|
627
664
|
|
|
665
|
+
// Record an observed app crash.
|
|
666
|
+
function handleResultAddCrash({ resultStoreFactory, projectRoot }, opts = {}) {
|
|
667
|
+
const store = resultStoreFactory({
|
|
668
|
+
runId: opts.runId,
|
|
669
|
+
scenarioId: opts.scenarioId,
|
|
670
|
+
projectRoot,
|
|
671
|
+
});
|
|
672
|
+
const entry = store.addCrash({
|
|
673
|
+
crash_id: opts.crashId,
|
|
674
|
+
process: opts.process,
|
|
675
|
+
timestamp: opts.crashTimestamp,
|
|
676
|
+
step_id: opts.stepId,
|
|
677
|
+
excerpt: opts.excerpt,
|
|
678
|
+
report_path: opts.reportPath,
|
|
679
|
+
});
|
|
680
|
+
return { envelope: ok({ crash: entry }, storeHint(store)), exitKind: 'ok' };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Tolerance for calling a reported duration wrong.
|
|
684
|
+
//
|
|
685
|
+
// The measured value is a wall-clock span between two recorded events, so
|
|
686
|
+
// sub-second differences are rounding rather than disagreement, and 10% absorbs
|
|
687
|
+
// the honest gap between "the run" and "the part of the run mauto saw" —
|
|
688
|
+
// setup before the first verb, and finalize's own execution after the last.
|
|
689
|
+
//
|
|
690
|
+
// Symmetric on purpose. An UNDER-claim means the agent lost track of its own
|
|
691
|
+
// retries; an OVER-claim means it counted its planning as run time. Both are
|
|
692
|
+
// worth a human seeing, and one rule is two tests instead of four.
|
|
693
|
+
const DURATION_TOLERANCE_SECONDS = 1;
|
|
694
|
+
const DURATION_TOLERANCE_RATIO = 0.1;
|
|
695
|
+
|
|
696
|
+
// Turn the run's trace into the duration to record and the measurement block to
|
|
697
|
+
// record beside it. Total: an unreadable, absent or unmeasurable trace yields
|
|
698
|
+
// the reported value and a `measurements` block that says so, never an error.
|
|
699
|
+
function measureRun({ projectRoot, runId, reported }) {
|
|
700
|
+
const derived = deriveRun(readTrace(runTracePath(projectRoot, runId)));
|
|
701
|
+
const reportedSeconds = reported === undefined ? null : Number(reported);
|
|
702
|
+
const hasReported = reportedSeconds !== null && Number.isFinite(reportedSeconds);
|
|
703
|
+
|
|
704
|
+
const base = {
|
|
705
|
+
reported_duration_seconds: hasReported ? reportedSeconds : null,
|
|
706
|
+
trace_events: derived ? derived.trace_events : 0,
|
|
707
|
+
device_failures: derived ? derived.device_failures : 0,
|
|
708
|
+
trace_truncated: derived ? derived.trace_truncated : false,
|
|
709
|
+
failure_screenshots: derived ? derived.failure_screenshots : [],
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// No trace, or a trace too short to span any time. Falling back to the flag
|
|
713
|
+
// is 0.24.0's behaviour, and `source` records that it IS a fallback rather
|
|
714
|
+
// than dressing a self-report as a measurement.
|
|
715
|
+
if (!derived || derived.duration_seconds === null) {
|
|
716
|
+
return {
|
|
717
|
+
durationSeconds: hasReported ? reportedSeconds : 0,
|
|
718
|
+
measurements: { source: hasReported ? 'reported' : 'none', duration_disagreement: false, ...base },
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const measured = derived.duration_seconds;
|
|
723
|
+
const tolerance = Math.max(DURATION_TOLERANCE_SECONDS, measured * DURATION_TOLERANCE_RATIO);
|
|
724
|
+
return {
|
|
725
|
+
durationSeconds: measured,
|
|
726
|
+
measurements: {
|
|
727
|
+
source: 'trace',
|
|
728
|
+
duration_disagreement: hasReported && Math.abs(reportedSeconds - measured) > tolerance,
|
|
729
|
+
...base,
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
628
734
|
function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectRoot }, opts) {
|
|
629
735
|
const { runId, scenarioId, status, duration } = opts;
|
|
630
736
|
if (!runId) {
|
|
@@ -643,11 +749,20 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
|
|
|
643
749
|
if (opts.apiLevel !== undefined) metadata.api_level = opts.apiLevel;
|
|
644
750
|
if (opts.environment !== undefined) metadata.environment = opts.environment;
|
|
645
751
|
|
|
752
|
+
// Measure BEFORE constructing the store: the trace is read-only here, and
|
|
753
|
+
// deriving first keeps the store's lock window as small as it already is.
|
|
754
|
+
const measured = measureRun({ projectRoot, runId, reported: duration });
|
|
755
|
+
|
|
646
756
|
const store = resultStoreFactory({ runId, scenarioId, projectRoot });
|
|
647
757
|
const result = store.finalize({
|
|
648
758
|
status,
|
|
649
|
-
|
|
759
|
+
// Derived, not trusted. `--duration` survives in
|
|
760
|
+
// measurements.reported_duration_seconds and, when it disagrees with the
|
|
761
|
+
// clock, in a typed state_context observation — the disagreement is a
|
|
762
|
+
// signal about the agent, so it is recorded rather than resolved.
|
|
763
|
+
durationSeconds: measured.durationSeconds,
|
|
650
764
|
metadata,
|
|
765
|
+
measurements: measured.measurements,
|
|
651
766
|
// Undefined when --summary is omitted; ResultStore.finalize's own
|
|
652
767
|
// `summary || <generated default>` already treats that as "no override",
|
|
653
768
|
// so no provided-keys-only guard is needed here (unlike metadata, whose
|
|
@@ -655,6 +770,12 @@ function handleResultFinalize({ resultStoreFactory, memoryStoreFactory, projectR
|
|
|
655
770
|
summary: opts.summary,
|
|
656
771
|
});
|
|
657
772
|
|
|
773
|
+
// Retention, at the only moment a run is known to be over and the only place
|
|
774
|
+
// off the hot path. Never this run's own trace — it is the evidence behind
|
|
775
|
+
// every number just written. Best-effort by construction; pruneRunTraces
|
|
776
|
+
// swallows everything and returns a count.
|
|
777
|
+
pruneRunTraces(projectRoot, { except: runId });
|
|
778
|
+
|
|
658
779
|
// Auto-harvest into cross-session memory. This is best-effort: a memory
|
|
659
780
|
// failure must never fail an otherwise-successful finalize, so its warnings
|
|
660
781
|
// fold into the envelope hint instead of throwing.
|
|
@@ -691,7 +812,11 @@ function storeHint(store) {
|
|
|
691
812
|
// on success; only their ERROR paths (unknown topic/name) fall back to the
|
|
692
813
|
// `fail(...)` envelope + exit 3.
|
|
693
814
|
|
|
694
|
-
|
|
815
|
+
// `ancestorRoot` is the workspace discovery found ABOVE cwd, if any. setup
|
|
816
|
+
// still scaffolds in cwd — it is the one verb whose job is to create a
|
|
817
|
+
// workspace where the user stands — but a nested workspace silently shadows
|
|
818
|
+
// the ancestor for every later verb run below it, so say so.
|
|
819
|
+
function handleSetup({ projectRoot, ancestorRoot = null }, opts = {}) {
|
|
695
820
|
const raw = opts.mode === undefined ? 'aware' : String(opts.mode);
|
|
696
821
|
const mode = MODE_ALIASES[raw];
|
|
697
822
|
if (!mode) {
|
|
@@ -701,8 +826,24 @@ function handleSetup({ projectRoot }, opts = {}) {
|
|
|
701
826
|
};
|
|
702
827
|
}
|
|
703
828
|
const r = scaffold(projectRoot, { mode });
|
|
829
|
+
const hint = ancestorRoot
|
|
830
|
+
? `An existing workspace was found in an ancestor directory (${ancestorRoot}). This new workspace in ${projectRoot} shadows it for every mauto verb run from here or below; remove it if you meant to use the ancestor's.`
|
|
831
|
+
: null;
|
|
704
832
|
return {
|
|
705
|
-
envelope: ok(
|
|
833
|
+
envelope: ok(
|
|
834
|
+
{
|
|
835
|
+
created: r.created,
|
|
836
|
+
mode: r.mode,
|
|
837
|
+
// A NOTICE, not a prompt. mauto verbs are invoked by an agent, so there
|
|
838
|
+
// is nobody at the keyboard to answer a question — and an agent answering
|
|
839
|
+
// a consent question on a human's behalf is worse than not asking. It
|
|
840
|
+
// rides in the envelope (structured, in-band) rather than as a stderr
|
|
841
|
+
// banner, so the agent can surface it to the human it belongs to.
|
|
842
|
+
telemetry: { enabled: false, notice: observeTelemetry.CONSENT_NOTICE },
|
|
843
|
+
next: 'run `mauto guide setup`',
|
|
844
|
+
},
|
|
845
|
+
hint
|
|
846
|
+
),
|
|
706
847
|
exitKind: 'ok',
|
|
707
848
|
};
|
|
708
849
|
}
|
|
@@ -732,6 +873,87 @@ function handleConfigSet({ projectRoot }, key, rawValue) {
|
|
|
732
873
|
return { envelope: ok({ key, value }), exitKind: 'ok' };
|
|
733
874
|
}
|
|
734
875
|
|
|
876
|
+
// --- Slice 5: telemetry consent surface -----------------------------------
|
|
877
|
+
//
|
|
878
|
+
// `status` is the informed half of informed consent, and it is COMPUTED: the
|
|
879
|
+
// field lists come from EVENT_FIELDS at runtime, so the disclosure a user reads
|
|
880
|
+
// before opting in cannot drift from what the uploader actually sends. Prose
|
|
881
|
+
// that restates a catalog is prose that will eventually be wrong.
|
|
882
|
+
|
|
883
|
+
function telemetryReport({ projectRoot, env }, extra = {}) {
|
|
884
|
+
const decision = observeTelemetry.decideForProject(projectRoot, env);
|
|
885
|
+
return {
|
|
886
|
+
enabled: decision.enabled,
|
|
887
|
+
reason: decision.reason,
|
|
888
|
+
endpoint: observeTransport.endpointUrl(env),
|
|
889
|
+
fields_sent: observeTelemetry.sentFields(),
|
|
890
|
+
fields_never_sent: observeTelemetry.neverSentFields(),
|
|
891
|
+
spool: observeSpool.stats({ projectRoot, env }),
|
|
892
|
+
notice: observeTelemetry.CONSENT_NOTICE,
|
|
893
|
+
enable_with: 'mauto telemetry enable',
|
|
894
|
+
...extra,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function handleTelemetryStatus({ projectRoot, env = process.env }) {
|
|
899
|
+
return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Writes the durable intent, then reports the RESOLVED state — which may still
|
|
903
|
+
// be off, because an env kill switch wins. Reporting `enabled: true` here when
|
|
904
|
+
// MAUTO_TELEMETRY=0 is set would be a lie, and the config is not the place to
|
|
905
|
+
// resolve a conflict the environment owns.
|
|
906
|
+
function handleTelemetryEnable({ projectRoot, env = process.env }) {
|
|
907
|
+
observeTelemetry.setEnabled(projectRoot, true);
|
|
908
|
+
const data = telemetryReport({ projectRoot, env });
|
|
909
|
+
const hint =
|
|
910
|
+
data.reason === 'kill_switch'
|
|
911
|
+
? 'Telemetry is enabled in config.json but MAUTO_TELEMETRY=0 is set in this environment and overrides it.'
|
|
912
|
+
: data.reason === 'do_not_track'
|
|
913
|
+
? 'Telemetry is enabled in config.json but DO_NOT_TRACK is set in this environment and overrides it.'
|
|
914
|
+
: data.reason === 'no_token'
|
|
915
|
+
? 'Telemetry is enabled in config.json but this build ships no project token, so nothing will be sent.'
|
|
916
|
+
: undefined;
|
|
917
|
+
return { envelope: ok(data, hint), exitKind: 'ok' };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function handleTelemetryDisable({ projectRoot, env = process.env }) {
|
|
921
|
+
observeTelemetry.setEnabled(projectRoot, false);
|
|
922
|
+
return { envelope: ok(telemetryReport({ projectRoot, env })), exitKind: 'ok' };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// The explicit escape hatch. This is the ONE verb allowed a network round trip,
|
|
926
|
+
// because the user asked for one and is willing to wait for it — every other
|
|
927
|
+
// verb appends to the spool and exits. A machine that never runs a daemon
|
|
928
|
+
// drains here.
|
|
929
|
+
async function handleTelemetryFlush({ projectRoot, env = process.env, flusher }) {
|
|
930
|
+
const flush = flusher || makeFlusher({ projectRoot, env });
|
|
931
|
+
const r = await flush();
|
|
932
|
+
// exitKind stays 'ok': the envelope's own `ok` says the VERB executed — an
|
|
933
|
+
// undelivered spool is a non-error state by design (src/observe/spool.js).
|
|
934
|
+
// The flush's verdict — including permanently discarded events — is data:
|
|
935
|
+
// `ok` below, alongside the counts. Units: `kept` counts BATCHES (files);
|
|
936
|
+
// `sent`, `dropped` and `kept_events` count events.
|
|
937
|
+
const hint =
|
|
938
|
+
r.dropped > 0
|
|
939
|
+
? `${r.dropped} event${r.dropped === 1 ? '' : 's'} permanently discarded — the endpoint rejected them (revoked token, wrong host, or a non-retryable status). They will not be retried.`
|
|
940
|
+
: r.kept > 0
|
|
941
|
+
? `${r.kept} batch${r.kept === 1 ? '' : 'es'} (${r.kept_events} events) kept for retry — the next daemon drain or mauto telemetry flush will resend them.`
|
|
942
|
+
: undefined;
|
|
943
|
+
return {
|
|
944
|
+
envelope: ok({
|
|
945
|
+
skipped: r.skipped,
|
|
946
|
+
sent: r.sent,
|
|
947
|
+
dropped: r.dropped,
|
|
948
|
+
kept: r.kept,
|
|
949
|
+
kept_events: r.kept_events,
|
|
950
|
+
ok: r.ok,
|
|
951
|
+
spool: observeSpool.stats({ projectRoot, env }),
|
|
952
|
+
}, hint),
|
|
953
|
+
exitKind: 'ok',
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
735
957
|
// RAW content on success; fail envelope only when the topic is unknown.
|
|
736
958
|
function handleGuide({ projectRoot, emitter = guideEmitter, config = configManager }, topic) {
|
|
737
959
|
const cfg = config.load(projectRoot);
|
|
@@ -1043,7 +1265,7 @@ async function handleDevices({ deviceBridge }) {
|
|
|
1043
1265
|
envelope: fail(
|
|
1044
1266
|
'device',
|
|
1045
1267
|
err.message || String(err),
|
|
1046
|
-
err
|
|
1268
|
+
deviceHint(err, 'Ensure a device or simulator is connected and reachable.')
|
|
1047
1269
|
),
|
|
1048
1270
|
exitKind: 'device',
|
|
1049
1271
|
};
|
|
@@ -1067,7 +1289,7 @@ async function handleDevicesUse({ deviceBridge, store = selectionStore, projectR
|
|
|
1067
1289
|
devices = await deviceBridge.listDevices();
|
|
1068
1290
|
} catch (err) {
|
|
1069
1291
|
return {
|
|
1070
|
-
envelope: fail('device', err.message || String(err), err
|
|
1292
|
+
envelope: fail('device', err.message || String(err), deviceHint(err, 'Ensure a device or simulator is connected and reachable.')),
|
|
1071
1293
|
exitKind: 'device',
|
|
1072
1294
|
};
|
|
1073
1295
|
}
|
|
@@ -1118,6 +1340,134 @@ async function handleDevicesClear({
|
|
|
1118
1340
|
};
|
|
1119
1341
|
}
|
|
1120
1342
|
|
|
1343
|
+
// --- Crash diagnostics (slice 4) -----------------------------------------
|
|
1344
|
+
//
|
|
1345
|
+
// A DIAGNOSTIC verb, like `devices` / `session` / `memory` — deliberately NOT
|
|
1346
|
+
// in src/device/action-catalog.js, which holds exactly the scenario-schema
|
|
1347
|
+
// actions and whose lint guard asserts parity in both directions.
|
|
1348
|
+
|
|
1349
|
+
const CRASH_REPORT_HEAD_LINES = 200;
|
|
1350
|
+
const CRASH_LIST_HINT =
|
|
1351
|
+
'The device engine could not read crash reports. Run `mauto devices` to confirm the device is reachable; crash reporting needs the helper binary that ships with the device engine.';
|
|
1352
|
+
|
|
1353
|
+
// Default watermark: when a device session is live, its handle already records
|
|
1354
|
+
// started_at (src/device/session-daemon.js). Bind, do not copy — there is no new
|
|
1355
|
+
// timestamp artifact and no second source of truth for "when did this session
|
|
1356
|
+
// begin". No handle means no watermark, which means UNSCOPED, which the envelope
|
|
1357
|
+
// reports as `since: null` rather than pretending the list is recent.
|
|
1358
|
+
function sessionWatermark(projectRoot) {
|
|
1359
|
+
const handle = readHandle(projectRoot);
|
|
1360
|
+
const t = handle && handle.started_at;
|
|
1361
|
+
return typeof t === 'string' && Number.isFinite(Date.parse(t)) ? t : null;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Split a crash list around a watermark. Reports whose own time is unreadable
|
|
1365
|
+
// are neither included nor discarded — they are counted, so the caller can see
|
|
1366
|
+
// that the device returned something the tool could not place in time.
|
|
1367
|
+
function scopeCrashes(crashes, sinceMs) {
|
|
1368
|
+
if (sinceMs == null) return { crashes, unattributed: 0 };
|
|
1369
|
+
const inWindow = [];
|
|
1370
|
+
let unattributed = 0;
|
|
1371
|
+
for (const c of crashes) {
|
|
1372
|
+
const ms = crashTimestampMs(c);
|
|
1373
|
+
if (ms == null) unattributed += 1;
|
|
1374
|
+
else if (ms >= sinceMs) inWindow.push(c);
|
|
1375
|
+
}
|
|
1376
|
+
return { crashes: inWindow, unattributed };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function handleCrashList({ deviceBridge, projectRoot }, opts = {}) {
|
|
1380
|
+
const sinceRaw = opts.since !== undefined ? opts.since : sessionWatermark(projectRoot);
|
|
1381
|
+
let sinceMs = null;
|
|
1382
|
+
if (sinceRaw != null) {
|
|
1383
|
+
sinceMs = Date.parse(sinceRaw);
|
|
1384
|
+
if (!Number.isFinite(sinceMs)) {
|
|
1385
|
+
return {
|
|
1386
|
+
envelope: fail(
|
|
1387
|
+
'invalid_input',
|
|
1388
|
+
`could not parse --since "${sinceRaw}" as a date`,
|
|
1389
|
+
'Pass an ISO 8601 timestamp, e.g. --since 2026-09-05T10:00:00Z.'
|
|
1390
|
+
),
|
|
1391
|
+
exitKind: 'invalid_input',
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
let all;
|
|
1397
|
+
try {
|
|
1398
|
+
all = await deviceBridge.listCrashes();
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
// "Could not look" is NOT "found none". Reporting ok:true with an empty
|
|
1401
|
+
// list here would be this slice's own bug with the sign flipped.
|
|
1402
|
+
return {
|
|
1403
|
+
envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
|
|
1404
|
+
exitKind: err.kind || 'device',
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const { crashes, unattributed } = scopeCrashes(all, sinceMs);
|
|
1409
|
+
record(
|
|
1410
|
+
{
|
|
1411
|
+
level: 'info',
|
|
1412
|
+
src: 'cli',
|
|
1413
|
+
event: 'crash.list',
|
|
1414
|
+
crash_count: Number.isFinite(crashes.length) ? crashes.length : undefined,
|
|
1415
|
+
},
|
|
1416
|
+
{ projectRoot }
|
|
1417
|
+
);
|
|
1418
|
+
return {
|
|
1419
|
+
envelope: ok({
|
|
1420
|
+
crashes,
|
|
1421
|
+
count: crashes.length,
|
|
1422
|
+
since: sinceMs == null ? null : sinceRaw,
|
|
1423
|
+
unattributed,
|
|
1424
|
+
}),
|
|
1425
|
+
exitKind: 'ok',
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async function handleCrashGet({ deviceBridge, fs: fsDep = fs }, id, opts = {}) {
|
|
1430
|
+
let report;
|
|
1431
|
+
try {
|
|
1432
|
+
report = await deviceBridge.getCrash(id);
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
return {
|
|
1435
|
+
envelope: fail(err.kind || 'device', err.message || String(err), deviceHint(err, CRASH_LIST_HINT)),
|
|
1436
|
+
exitKind: err.kind || 'device',
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// --out writes the FULL report and returns only its path. This is how a
|
|
1441
|
+
// tens-of-kilobytes native tombstone reaches a result file without passing
|
|
1442
|
+
// through the agent's context window.
|
|
1443
|
+
if (opts.out) {
|
|
1444
|
+
try {
|
|
1445
|
+
fsDep.writeFileSync(opts.out, report);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
return {
|
|
1448
|
+
envelope: fail(
|
|
1449
|
+
'environment',
|
|
1450
|
+
`could not write the crash report to ${opts.out}: ${err.message}`,
|
|
1451
|
+
'Check the directory exists and is writable.'
|
|
1452
|
+
),
|
|
1453
|
+
exitKind: 'environment',
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
return { envelope: ok({ crash_id: id, path: opts.out, truncated: false }), exitKind: 'ok' };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const lines = String(report).split('\n');
|
|
1460
|
+
const truncated = !opts.full && lines.length > CRASH_REPORT_HEAD_LINES;
|
|
1461
|
+
return {
|
|
1462
|
+
envelope: ok({
|
|
1463
|
+
crash_id: id,
|
|
1464
|
+
report: truncated ? lines.slice(0, CRASH_REPORT_HEAD_LINES).join('\n') : String(report),
|
|
1465
|
+
truncated,
|
|
1466
|
+
}),
|
|
1467
|
+
exitKind: 'ok',
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1121
1471
|
// ---------------------------------------------------------------------------
|
|
1122
1472
|
// Program wiring
|
|
1123
1473
|
// ---------------------------------------------------------------------------
|
|
@@ -1142,10 +1492,31 @@ function buildProgram(deps = {}) {
|
|
|
1142
1492
|
resultStoreFactory = (args) => new ResultStore(args),
|
|
1143
1493
|
// Factory for the cross-session memory store. Overridable in tests.
|
|
1144
1494
|
memoryStoreFactory = (args) => new MemoryStore(args),
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1495
|
+
// Where the creating verbs (setup, init) write: always the directory the
|
|
1496
|
+
// user stands in, never a discovered ancestor. Defaults to an injected
|
|
1497
|
+
// projectRoot so tests that inject only a root keep writing there.
|
|
1498
|
+
cwd = deps.projectRoot !== undefined ? deps.projectRoot : process.cwd(),
|
|
1499
|
+
// The upward-discovery result `{ root, searched }` (#188): run() resolves
|
|
1500
|
+
// it once and passes it in, tests inject it wholesale, and a bare
|
|
1501
|
+
// buildProgram() discovers from cwd exactly as run() does. ONE fabrication
|
|
1502
|
+
// rule, stated once: a caller that injects ONLY a projectRoot is trusted
|
|
1503
|
+
// to know where the workspace is ({ root: projectRoot }) — the legacy
|
|
1504
|
+
// handler-test seam, and deliberately gate-arming. run() passes cwd +
|
|
1505
|
+
// workspace and NEVER projectRoot, so production cannot arm this branch
|
|
1506
|
+
// and silently disable the requireWorkspace gate.
|
|
1507
|
+
workspace = deps.projectRoot !== undefined
|
|
1508
|
+
? { root: deps.projectRoot, searched: [deps.projectRoot] }
|
|
1509
|
+
: findWorkspaceRoot(cwd),
|
|
1510
|
+
// Project root used to resolve mobile-automator/results/ — and, through the
|
|
1511
|
+
// emitters below, mobile-automator/.logs/. One root, one tree. The
|
|
1512
|
+
// discovered workspace when there is one; cwd otherwise, which is what the
|
|
1513
|
+
// tolerant verbs (guide, mcp, read-only device/session) have always used.
|
|
1514
|
+
projectRoot = workspace.root || cwd,
|
|
1515
|
+
// The exit paths. run() passes its own instance so the help/version and
|
|
1516
|
+
// fatal paths it owns share this program's resolved verb.
|
|
1517
|
+
emitters = makeEmitters({ projectRoot }),
|
|
1147
1518
|
// Sink used to emit the rendered envelope + drive the exit code.
|
|
1148
|
-
emit =
|
|
1519
|
+
emit = emitters.emit,
|
|
1149
1520
|
} = deps;
|
|
1150
1521
|
|
|
1151
1522
|
const program = new Command();
|
|
@@ -1184,6 +1555,31 @@ function buildProgram(deps = {}) {
|
|
|
1184
1555
|
});
|
|
1185
1556
|
program.configureOutput({ writeErr: () => {} });
|
|
1186
1557
|
|
|
1558
|
+
// Name the verb for the observability record, from commander's own resolution
|
|
1559
|
+
// rather than from argv. preAction fires only after a SUCCESSFUL parse and
|
|
1560
|
+
// only for a command that exists, which is what makes `verb` provably a name
|
|
1561
|
+
// out of the vocabulary this CLI ships — the property event.js's `sends: true`
|
|
1562
|
+
// classification asserts. Hooks registered on the root are inherited by every
|
|
1563
|
+
// subcommand, so one registration covers all of them.
|
|
1564
|
+
//
|
|
1565
|
+
// `actionCommand` is the innermost command (`get` in `config get mode`), so
|
|
1566
|
+
// walk up to the child of the root: the record names the verb, not its
|
|
1567
|
+
// subcommand, keeping the vocabulary the size of the verb list.
|
|
1568
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
1569
|
+
// Read --run-id off the INNERMOST command (e.g. `add-step`) before walking
|
|
1570
|
+
// up to name the verb (`result`). The three result verbs already require
|
|
1571
|
+
// it, so they correlate with no new flag and no environment variable; an
|
|
1572
|
+
// explicit flag beats the ambient MAUTO_RUN_ID because it is scoped to this
|
|
1573
|
+
// invocation and the environment is not.
|
|
1574
|
+
const opts = actionCommand.opts();
|
|
1575
|
+
emitters.setRunId(
|
|
1576
|
+
(typeof opts.runId === 'string' && opts.runId ? opts.runId : null) || resolveRunId(process.env)
|
|
1577
|
+
);
|
|
1578
|
+
let command = actionCommand;
|
|
1579
|
+
while (command.parent && command.parent.parent) command = command.parent;
|
|
1580
|
+
emitters.setVerb(command.name());
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1187
1583
|
const humanFlag = () => Boolean(program.opts().human);
|
|
1188
1584
|
|
|
1189
1585
|
// The single envelope boundary. Wrap every `.action(...)` callback so any
|
|
@@ -1203,6 +1599,20 @@ function buildProgram(deps = {}) {
|
|
|
1203
1599
|
}
|
|
1204
1600
|
};
|
|
1205
1601
|
|
|
1602
|
+
// Gate for verbs whose whole job is reading or writing the workspace. With
|
|
1603
|
+
// no workspace discovered, carrying on against cwd is what produced #188's
|
|
1604
|
+
// confident wrong answers — `ok:true` with a value silently absent, an
|
|
1605
|
+
// "empty" memory store, a result file written where nobody will look — and
|
|
1606
|
+
// `config set` would even create a stray workspace. Fail environment
|
|
1607
|
+
// (exit 5) naming where we looked, so the agent can recover.
|
|
1608
|
+
const requireWorkspace = (action) => (...args) => {
|
|
1609
|
+
if (!workspace.root) {
|
|
1610
|
+
emit(noWorkspaceFailure(workspace.searched), humanFlag());
|
|
1611
|
+
return undefined;
|
|
1612
|
+
}
|
|
1613
|
+
return action(...args);
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1206
1616
|
// Resolve which device a verb targets: explicit --device wins, else the
|
|
1207
1617
|
// persisted selection, else null (mobile-mcp auto-selects a single device).
|
|
1208
1618
|
// Keeping the no-selection fast path returning null preserves zero-config use.
|
|
@@ -1221,13 +1631,55 @@ function buildProgram(deps = {}) {
|
|
|
1221
1631
|
let bridge;
|
|
1222
1632
|
let close;
|
|
1223
1633
|
try {
|
|
1224
|
-
|
|
1634
|
+
// autostart: the daemon is a per-workspace artifact — its .session/ tree
|
|
1635
|
+
// and logs live under mobile-automator/. With no discovered workspace
|
|
1636
|
+
// there is nowhere legitimate to put one, so never autostart: the verb
|
|
1637
|
+
// connects one-shot and creates nothing. `ok:true` plus a phantom
|
|
1638
|
+
// workspace is the exact #188 pathology.
|
|
1639
|
+
({ bridge, close } = await deviceBridgeFactory({
|
|
1640
|
+
device,
|
|
1641
|
+
projectRoot,
|
|
1642
|
+
autostart: Boolean(workspace.root),
|
|
1643
|
+
}));
|
|
1644
|
+
// AFTER the connect, deliberately: the handle is written when the daemon
|
|
1645
|
+
// starts listening, so before this point it may legitimately not exist
|
|
1646
|
+
// yet. Best-effort — readSessionId never throws and returns null for an
|
|
1647
|
+
// absent or malformed handle, which is a normal state (a one-shot
|
|
1648
|
+
// fallback connection has no daemon at all).
|
|
1649
|
+
emitters.setSessionId(readSessionId(projectRoot));
|
|
1225
1650
|
} catch (err) {
|
|
1226
1651
|
emit(deviceFail(err), humanFlag());
|
|
1227
1652
|
return;
|
|
1228
1653
|
}
|
|
1229
1654
|
try {
|
|
1230
1655
|
const r = await fn(bridge);
|
|
1656
|
+
// The ONE window where a device failure can still be investigated: after
|
|
1657
|
+
// fn(bridge) has produced its verdict and before `finally` closes the
|
|
1658
|
+
// connection. The connect-failure catch above cannot do this — there is
|
|
1659
|
+
// no bridge there — which is why this is here and not wrapped around the
|
|
1660
|
+
// whole function.
|
|
1661
|
+
//
|
|
1662
|
+
// The return value of BOTH calls is deliberately unused. `r` reaches
|
|
1663
|
+
// emit() untouched (aside from probeCrashes's own in-place, additive
|
|
1664
|
+
// mutation of r.envelope) whether either helper succeeded, failed, or
|
|
1665
|
+
// never ran; neither may mask or replace the error the caller actually
|
|
1666
|
+
// asked about. Probe before screenshot: the probe has its own short,
|
|
1667
|
+
// gated deadline and must not sit behind the screenshot's device round
|
|
1668
|
+
// trip.
|
|
1669
|
+
await probeCrashes({
|
|
1670
|
+
bridge,
|
|
1671
|
+
envelope: r.envelope,
|
|
1672
|
+
verb: emitters.getVerb(),
|
|
1673
|
+
projectRoot,
|
|
1674
|
+
watermark: sessionWatermark(projectRoot),
|
|
1675
|
+
});
|
|
1676
|
+
await captureOnFailure({
|
|
1677
|
+
bridge,
|
|
1678
|
+
result: r,
|
|
1679
|
+
projectRoot,
|
|
1680
|
+
runId: emitters.getRunId(),
|
|
1681
|
+
verb: emitters.getVerb(),
|
|
1682
|
+
});
|
|
1231
1683
|
emit(r, humanFlag());
|
|
1232
1684
|
} finally {
|
|
1233
1685
|
if (typeof close === 'function') await close();
|
|
@@ -1382,7 +1834,7 @@ function buildProgram(deps = {}) {
|
|
|
1382
1834
|
[]
|
|
1383
1835
|
)
|
|
1384
1836
|
.option('--capture <name=value>', 'captured variable; repeatable', collect, [])
|
|
1385
|
-
.action(withEnvelope((opts) => {
|
|
1837
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1386
1838
|
const r = handleResultAddStep(
|
|
1387
1839
|
{ resultStoreFactory, projectRoot },
|
|
1388
1840
|
{
|
|
@@ -1398,7 +1850,7 @@ function buildProgram(deps = {}) {
|
|
|
1398
1850
|
}
|
|
1399
1851
|
);
|
|
1400
1852
|
emit(r, humanFlag());
|
|
1401
|
-
}));
|
|
1853
|
+
})));
|
|
1402
1854
|
|
|
1403
1855
|
result
|
|
1404
1856
|
.command('add-assertion')
|
|
@@ -1412,7 +1864,7 @@ function buildProgram(deps = {}) {
|
|
|
1412
1864
|
.option('--message <text>', 'human-readable justification for the verdict')
|
|
1413
1865
|
.option('--expected <v>', 'expected value')
|
|
1414
1866
|
.option('--actual <v>', 'observed value')
|
|
1415
|
-
.action(withEnvelope((opts) => {
|
|
1867
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1416
1868
|
const r = handleResultAddAssertion(
|
|
1417
1869
|
{ resultStoreFactory, projectRoot },
|
|
1418
1870
|
{
|
|
@@ -1428,7 +1880,35 @@ function buildProgram(deps = {}) {
|
|
|
1428
1880
|
}
|
|
1429
1881
|
);
|
|
1430
1882
|
emit(r, humanFlag());
|
|
1431
|
-
}));
|
|
1883
|
+
})));
|
|
1884
|
+
|
|
1885
|
+
result
|
|
1886
|
+
.command('add-crash')
|
|
1887
|
+
.description('Record an app crash observed during the run')
|
|
1888
|
+
.requiredOption('--run-id <id>', 'run identifier (run_YYYYMMDD_HHMMSS)')
|
|
1889
|
+
.option('--scenario-id <id>', 'scenario identifier')
|
|
1890
|
+
.requiredOption('--crash-id <id>', 'device-local report id (from `mauto crash list`)')
|
|
1891
|
+
.option('--step-id <id>', 'step in flight when the crash was observed')
|
|
1892
|
+
.option('--process <name>', 'package/bundle identifier of the process that died')
|
|
1893
|
+
.option('--crash-timestamp <iso>', "the device's own time for the report")
|
|
1894
|
+
.option('--excerpt <text>', 'head of the crash report (stored capped at 2000 chars)')
|
|
1895
|
+
.option('--report-path <path>', 'path to the full report written by `mauto crash get --out`')
|
|
1896
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1897
|
+
const r = handleResultAddCrash(
|
|
1898
|
+
{ resultStoreFactory, projectRoot },
|
|
1899
|
+
{
|
|
1900
|
+
runId: opts.runId,
|
|
1901
|
+
scenarioId: opts.scenarioId,
|
|
1902
|
+
crashId: opts.crashId,
|
|
1903
|
+
stepId: opts.stepId,
|
|
1904
|
+
process: opts.process,
|
|
1905
|
+
crashTimestamp: opts.crashTimestamp,
|
|
1906
|
+
excerpt: opts.excerpt,
|
|
1907
|
+
reportPath: opts.reportPath,
|
|
1908
|
+
}
|
|
1909
|
+
);
|
|
1910
|
+
emit(r, humanFlag());
|
|
1911
|
+
})));
|
|
1432
1912
|
|
|
1433
1913
|
result
|
|
1434
1914
|
.command('finalize')
|
|
@@ -1442,7 +1922,7 @@ function buildProgram(deps = {}) {
|
|
|
1442
1922
|
.option('--api-level <v>', 'OS API level / version')
|
|
1443
1923
|
.option('--environment <v>', "target environment (e.g. 'staging')")
|
|
1444
1924
|
.option('--summary <text>', 'override the generated one-line result summary')
|
|
1445
|
-
.action(withEnvelope((opts) => {
|
|
1925
|
+
.action(withEnvelope(requireWorkspace((opts) => {
|
|
1446
1926
|
const r = handleResultFinalize(
|
|
1447
1927
|
{ resultStoreFactory, memoryStoreFactory, projectRoot },
|
|
1448
1928
|
{
|
|
@@ -1458,7 +1938,7 @@ function buildProgram(deps = {}) {
|
|
|
1458
1938
|
}
|
|
1459
1939
|
);
|
|
1460
1940
|
emit(r, humanFlag());
|
|
1461
|
-
}));
|
|
1941
|
+
})));
|
|
1462
1942
|
|
|
1463
1943
|
// --- Slice 3 verbs --------------------------------------------------------
|
|
1464
1944
|
|
|
@@ -1467,7 +1947,7 @@ function buildProgram(deps = {}) {
|
|
|
1467
1947
|
// anything else is an envelope error and goes through the normal emit path.
|
|
1468
1948
|
const emitMaybeRaw = (r) => {
|
|
1469
1949
|
if (r.raw !== undefined && r.exitKind === 'ok') {
|
|
1470
|
-
emitRaw(r.raw, r.exitKind);
|
|
1950
|
+
emitters.emitRaw(r.raw, r.exitKind);
|
|
1471
1951
|
} else {
|
|
1472
1952
|
emit(r, humanFlag());
|
|
1473
1953
|
}
|
|
@@ -1478,7 +1958,10 @@ function buildProgram(deps = {}) {
|
|
|
1478
1958
|
.description('Scaffold the workspace (mobile-automator/) and write a config')
|
|
1479
1959
|
.option('--mode <mode>', 'aware (platform-aware, default) | agnostic (platform-agnostic)')
|
|
1480
1960
|
.action(withEnvelope((opts) => {
|
|
1481
|
-
|
|
1961
|
+
// cwd, not the discovered root: setup creates a workspace where the user
|
|
1962
|
+
// stands. A discovered root that is not cwd is an ancestor worth naming.
|
|
1963
|
+
const ancestorRoot = workspace.root && workspace.root !== cwd ? workspace.root : null;
|
|
1964
|
+
const r = handleSetup({ projectRoot: cwd, ancestorRoot }, { mode: opts.mode });
|
|
1482
1965
|
emit(r, humanFlag());
|
|
1483
1966
|
}));
|
|
1484
1967
|
|
|
@@ -1486,11 +1969,11 @@ function buildProgram(deps = {}) {
|
|
|
1486
1969
|
config
|
|
1487
1970
|
.command('get <key>')
|
|
1488
1971
|
.description('Get a dotted-path config value')
|
|
1489
|
-
.action(withEnvelope((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag())));
|
|
1972
|
+
.action(withEnvelope(requireWorkspace((key) => emit(handleConfigGet({ projectRoot }, key), humanFlag()))));
|
|
1490
1973
|
config
|
|
1491
1974
|
.command('set <key> <value>')
|
|
1492
1975
|
.description('Set a dotted-path config value (coerced to its declared type per `mauto schema config`)')
|
|
1493
|
-
.action(withEnvelope((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag())));
|
|
1976
|
+
.action(withEnvelope(requireWorkspace((key, value) => emit(handleConfigSet({ projectRoot }, key, value), humanFlag()))));
|
|
1494
1977
|
|
|
1495
1978
|
program
|
|
1496
1979
|
.command('guide <topic>')
|
|
@@ -1516,28 +1999,28 @@ function buildProgram(deps = {}) {
|
|
|
1516
1999
|
.description('Print RAW memory markdown (run-history[, app-knowledge, preferences])')
|
|
1517
2000
|
.option('--kind <k>', 'run-history | app-knowledge | preferences')
|
|
1518
2001
|
.option('--scenario <id>', 'filter run-history to one scenario')
|
|
1519
|
-
.action(withEnvelope((opts) =>
|
|
2002
|
+
.action(withEnvelope(requireWorkspace((opts) =>
|
|
1520
2003
|
emitMaybeRaw(
|
|
1521
2004
|
handleMemoryShow({ memoryStoreFactory, projectRoot }, { kind: opts.kind, scenario: opts.scenario })
|
|
1522
2005
|
)
|
|
1523
|
-
));
|
|
2006
|
+
)));
|
|
1524
2007
|
|
|
1525
2008
|
memory
|
|
1526
2009
|
.command('add <text>')
|
|
1527
2010
|
.description('Record an agent-authored memory entry (app-knowledge or preferences)')
|
|
1528
2011
|
.requiredOption('--kind <k>', 'app-knowledge | preferences')
|
|
1529
|
-
.action(withEnvelope((text, opts) =>
|
|
2012
|
+
.action(withEnvelope(requireWorkspace((text, opts) =>
|
|
1530
2013
|
emit(handleMemoryAdd({ memoryStoreFactory, projectRoot }, { kind: opts.kind, text }), humanFlag())
|
|
1531
|
-
));
|
|
2014
|
+
)));
|
|
1532
2015
|
|
|
1533
2016
|
memory
|
|
1534
2017
|
.command('forget')
|
|
1535
2018
|
.description('Remove agent-authored memory entries whose text contains a substring')
|
|
1536
2019
|
.requiredOption('--kind <k>', 'app-knowledge | preferences')
|
|
1537
2020
|
.requiredOption('--match <substr>', 'remove entries containing this substring')
|
|
1538
|
-
.action(withEnvelope((opts) =>
|
|
2021
|
+
.action(withEnvelope(requireWorkspace((opts) =>
|
|
1539
2022
|
emit(handleMemoryForget({ memoryStoreFactory, projectRoot }, { kind: opts.kind, match: opts.match }), humanFlag())
|
|
1540
|
-
));
|
|
2023
|
+
)));
|
|
1541
2024
|
|
|
1542
2025
|
// --- Slice 7: vendor init + MCP prompts server ---------------------------
|
|
1543
2026
|
|
|
@@ -1546,7 +2029,9 @@ function buildProgram(deps = {}) {
|
|
|
1546
2029
|
.description('Install native Agent Skills (+ slash commands/rules + MCP entry) for an agent')
|
|
1547
2030
|
.requiredOption('--agent <name>', 'claude | cursor | gemini | copilot | agents | all')
|
|
1548
2031
|
.action(withEnvelope((opts) => {
|
|
1549
|
-
|
|
2032
|
+
// cwd, as before discovery existed: init writes host files where the
|
|
2033
|
+
// user invoked it.
|
|
2034
|
+
const r = handleInit({ projectRoot: cwd }, opts.agent);
|
|
1550
2035
|
emit(r, humanFlag());
|
|
1551
2036
|
}));
|
|
1552
2037
|
|
|
@@ -1561,10 +2046,10 @@ function buildProgram(deps = {}) {
|
|
|
1561
2046
|
.description('Start the device session daemon (subsequent verbs reuse its connection)')
|
|
1562
2047
|
.option('--device <id>', 'pin the daemon to a target device id')
|
|
1563
2048
|
.option('--idle <ms>', 'idle timeout in milliseconds before the daemon self-reaps')
|
|
1564
|
-
.action(withEnvelope(async (opts) => {
|
|
2049
|
+
.action(withEnvelope(requireWorkspace(async (opts) => {
|
|
1565
2050
|
const r = await handleSessionStart({ projectRoot }, { device: opts.device, idle: opts.idle });
|
|
1566
2051
|
emit(r, humanFlag());
|
|
1567
|
-
}));
|
|
2052
|
+
})));
|
|
1568
2053
|
|
|
1569
2054
|
session
|
|
1570
2055
|
.command('status')
|
|
@@ -1596,11 +2081,11 @@ function buildProgram(deps = {}) {
|
|
|
1596
2081
|
devices
|
|
1597
2082
|
.command('use <id>')
|
|
1598
2083
|
.description('Persist a device selection so subsequent verbs reuse it (--device still overrides per-call)')
|
|
1599
|
-
.action(withEnvelope((id) =>
|
|
2084
|
+
.action(withEnvelope(requireWorkspace((id) =>
|
|
1600
2085
|
connectBridge(null, (bridge) =>
|
|
1601
2086
|
handleDevicesUse({ deviceBridge: bridge, projectRoot }, id)
|
|
1602
2087
|
)
|
|
1603
|
-
));
|
|
2088
|
+
)));
|
|
1604
2089
|
|
|
1605
2090
|
devices
|
|
1606
2091
|
.command('clear')
|
|
@@ -1610,6 +2095,69 @@ function buildProgram(deps = {}) {
|
|
|
1610
2095
|
emit(r, humanFlag());
|
|
1611
2096
|
}));
|
|
1612
2097
|
|
|
2098
|
+
const crash = program
|
|
2099
|
+
.command('crash')
|
|
2100
|
+
.description('Diagnostics: crash reports currently readable on the device');
|
|
2101
|
+
|
|
2102
|
+
crash
|
|
2103
|
+
.command('list')
|
|
2104
|
+
.description('List crash reports, scoped to the current device session by default')
|
|
2105
|
+
.option('--device <id>', 'target device id')
|
|
2106
|
+
.option('--since <iso>', 'only reports at or after this ISO timestamp (default: session start)')
|
|
2107
|
+
.action(withEnvelope((opts) =>
|
|
2108
|
+
connectBridge(resolveVerbDevice(opts.device), (bridge) =>
|
|
2109
|
+
handleCrashList(
|
|
2110
|
+
{ deviceBridge: bridge, projectRoot },
|
|
2111
|
+
opts.since === undefined ? {} : { since: opts.since }
|
|
2112
|
+
)
|
|
2113
|
+
)
|
|
2114
|
+
));
|
|
2115
|
+
|
|
2116
|
+
crash
|
|
2117
|
+
.command('get <id>')
|
|
2118
|
+
.description('Fetch one crash report (head by default)')
|
|
2119
|
+
.option('--device <id>', 'target device id')
|
|
2120
|
+
.option('--full', 'return the whole report instead of its head')
|
|
2121
|
+
.option('--out <path>', 'write the full report to a file and return its path')
|
|
2122
|
+
.action(withEnvelope((id, opts) =>
|
|
2123
|
+
connectBridge(resolveVerbDevice(opts.device), (bridge) =>
|
|
2124
|
+
handleCrashGet({ deviceBridge: bridge }, id, { full: opts.full, out: opts.out })
|
|
2125
|
+
)
|
|
2126
|
+
));
|
|
2127
|
+
|
|
2128
|
+
// --- Slice 5: telemetry ---------------------------------------------------
|
|
2129
|
+
|
|
2130
|
+
const telemetryCmd = program
|
|
2131
|
+
.command('telemetry')
|
|
2132
|
+
.description('Inspect and control anonymous usage telemetry (off by default)');
|
|
2133
|
+
|
|
2134
|
+
// requireWorkspace, same as config get/set: telemetry status/enable/disable
|
|
2135
|
+
// all read or write mobile-automator/config.json, and flush reads/claims
|
|
2136
|
+
// mobile-automator/.logs/telemetry.spool, so all four have #188's exact
|
|
2137
|
+
// failure mode without the gate — status would report the SAME
|
|
2138
|
+
// not_configured/no_token answer for "no workspace here" as for "a real,
|
|
2139
|
+
// just-unconfigured workspace", and enable/disable would create a stray
|
|
2140
|
+
// mobile-automator/ wherever the caller happened to be standing.
|
|
2141
|
+
telemetryCmd
|
|
2142
|
+
.command('status')
|
|
2143
|
+
.description('Report whether telemetry is on, what would be sent, and what is queued')
|
|
2144
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryStatus({ projectRoot }), humanFlag()))));
|
|
2145
|
+
|
|
2146
|
+
telemetryCmd
|
|
2147
|
+
.command('enable')
|
|
2148
|
+
.description('Turn on anonymous usage telemetry')
|
|
2149
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryEnable({ projectRoot }), humanFlag()))));
|
|
2150
|
+
|
|
2151
|
+
telemetryCmd
|
|
2152
|
+
.command('disable')
|
|
2153
|
+
.description('Turn off anonymous usage telemetry')
|
|
2154
|
+
.action(withEnvelope(requireWorkspace(() => emit(handleTelemetryDisable({ projectRoot }), humanFlag()))));
|
|
2155
|
+
|
|
2156
|
+
telemetryCmd
|
|
2157
|
+
.command('flush')
|
|
2158
|
+
.description('Upload any spooled telemetry now instead of waiting for the daemon')
|
|
2159
|
+
.action(withEnvelope(requireWorkspace(async () => emit(await handleTelemetryFlush({ projectRoot }), humanFlag()))));
|
|
2160
|
+
|
|
1613
2161
|
program
|
|
1614
2162
|
.command('mcp')
|
|
1615
2163
|
.description('Run the MCP prompts server (stdio) exposing the mauto workflows as prompts')
|
|
@@ -1623,18 +2171,6 @@ function buildProgram(deps = {}) {
|
|
|
1623
2171
|
return program;
|
|
1624
2172
|
}
|
|
1625
2173
|
|
|
1626
|
-
function defaultEmit({ envelope, exitKind }, human) {
|
|
1627
|
-
process.stdout.write(render(envelope, { human }) + '\n');
|
|
1628
|
-
process.exit(exitCodeFor(exitKind));
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// Print raw content (markdown / JSON schema / text) verbatim — no envelope
|
|
1632
|
-
// wrapping — then exit. Used by guide/schema/bootstrap success paths.
|
|
1633
|
-
function emitRaw(content, exitKind) {
|
|
1634
|
-
process.stdout.write(content.endsWith('\n') ? content : content + '\n');
|
|
1635
|
-
process.exit(exitCodeFor(exitKind));
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
2174
|
// Keep the stack of a genuinely-unexpected error on STDERR so a crash stays
|
|
1639
2175
|
// debuggable. The stdout envelope (the agent's contract) is untouched, and the
|
|
1640
2176
|
// expected/diagnosed classes (invalid_input, environment) carry an actionable
|
|
@@ -1645,19 +2181,146 @@ function diagnose(err, exitKind) {
|
|
|
1645
2181
|
}
|
|
1646
2182
|
}
|
|
1647
2183
|
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
process.
|
|
2184
|
+
// The exit paths, bound to ONE invocation's resolved dependencies. Per
|
|
2185
|
+
// invocation rather than module-scope because every input they need is: as
|
|
2186
|
+
// module-level functions, finish() could not see the projectRoot buildProgram()
|
|
2187
|
+
// was handed, so it reached around the program's own DI to process.cwd() and
|
|
2188
|
+
// wrote the record of a verb into a different tree than the verb ran against.
|
|
2189
|
+
function makeEmitters({ projectRoot = process.cwd() } = {}) {
|
|
2190
|
+
// The top-level command commander actually RESOLVED, or null when parsing
|
|
2191
|
+
// never got that far (a parse failure, or help/version). Set via setVerb()
|
|
2192
|
+
// from buildProgram()'s preAction hook.
|
|
2193
|
+
//
|
|
2194
|
+
// Deliberately not `process.argv[2]`: event.js marks `verb` as `sends: true`
|
|
2195
|
+
// on the grounds that it is "a fixed vocabulary we ship", and slice 5 ships
|
|
2196
|
+
// it to a third party on that justification. argv[2] is arbitrary user text —
|
|
2197
|
+
// `mauto --human config get mode` recorded `--human`.
|
|
2198
|
+
let resolvedVerb = null;
|
|
2199
|
+
|
|
2200
|
+
// The run this invocation belongs to, and the daemon lifetime it used.
|
|
2201
|
+
//
|
|
2202
|
+
// Both follow resolvedVerb's pattern for resolvedVerb's reason: finish() is
|
|
2203
|
+
// closed over this invocation's state, and every exit path reaches it
|
|
2204
|
+
// through commander's own call stack, so there is nowhere else to put them.
|
|
2205
|
+
//
|
|
2206
|
+
// resolvedRunId is NOT validated here. It becomes a filename in exactly one
|
|
2207
|
+
// place — runTracePath — which refuses anything unsafe and returns null, so
|
|
2208
|
+
// a second predicate here could only disagree with that one.
|
|
2209
|
+
let resolvedRunId = null;
|
|
2210
|
+
// Set ONLY by connectBridge, and only after a successful connect. That is
|
|
2211
|
+
// what makes `session_id` on a CLI event mean "this verb reached the device
|
|
2212
|
+
// through that daemon".
|
|
2213
|
+
let resolvedSessionId = null;
|
|
2214
|
+
|
|
2215
|
+
// THE process-ending path: every way this CLI terminates goes through here,
|
|
2216
|
+
// so "is this invocation observable" has exactly one answer. Only `mauto mcp`
|
|
2217
|
+
// is outside it — it serves until its client disconnects and then returns,
|
|
2218
|
+
// with no termination to record.
|
|
2219
|
+
//
|
|
2220
|
+
// One function rather than four copies of a ten-line record() literal. Two of
|
|
2221
|
+
// the four got written, and the classes left unrecorded were parse failures
|
|
2222
|
+
// (#146) — the crashes this instrumentation exists to see — and help/version.
|
|
2223
|
+
//
|
|
2224
|
+
// `text` is optional: commander writes help/version itself, so that path has
|
|
2225
|
+
// an invocation to record but nothing left to print.
|
|
2226
|
+
//
|
|
2227
|
+
// dur_ms uses process.uptime() (seconds since THIS process started, no state
|
|
2228
|
+
// needed). A Date.now() stamped at require time would measure from whenever
|
|
2229
|
+
// cli.js was loaded, which is not process start for anything that requires it
|
|
2230
|
+
// first (a test harness, bin/mauto.js's own guards).
|
|
2231
|
+
function finish({ text = '', exitKind, ok, errorKind, exitCode = exitCodeFor(exitKind) }) {
|
|
2232
|
+
// The flag-resolved id when a command parsed, the environment otherwise: a
|
|
2233
|
+
// parse failure reaches no preAction hook and so has no flag, but it is
|
|
2234
|
+
// still part of the run and is exactly the class of failure (#146) this
|
|
2235
|
+
// instrumentation exists to see.
|
|
2236
|
+
const runId = resolvedRunId || resolveRunId(process.env);
|
|
2237
|
+
// null for an id that cannot safely name a file. Every hostile
|
|
2238
|
+
// MAUTO_RUN_ID lands here as "no trace" — but NOT as "no run_id below":
|
|
2239
|
+
// the raw id is still recorded on the mauto.ndjson line, so a refused id
|
|
2240
|
+
// is provably distinguishable from one never exported at all (no field).
|
|
2241
|
+
const tracePath = runTracePath(projectRoot, runId);
|
|
2242
|
+
|
|
2243
|
+
// Record BEFORE the write: process.exit() below is immediate.
|
|
2244
|
+
const fields = {
|
|
2245
|
+
event: 'verb.end',
|
|
2246
|
+
// `info`, not `warn`-on-failure: the failure is carried by `ok` and
|
|
2247
|
+
// `error_kind`, and the LEVEL only decides routing. At `warn` every
|
|
2248
|
+
// failed verb would clear the stderr threshold and echo the envelope the
|
|
2249
|
+
// CLI just wrote. stderr stays a human's channel; the file sink
|
|
2250
|
+
// (threshold `info`) keeps the forensic record either way.
|
|
2251
|
+
level: 'info',
|
|
2252
|
+
src: 'cli',
|
|
2253
|
+
// Absent, not guessed, when no command resolved. makeEvent drops
|
|
2254
|
+
// undefined fields, so the line carries no `verb` rather than a lie.
|
|
2255
|
+
verb: resolvedVerb || undefined,
|
|
2256
|
+
// Recorded even when the id could not name a trace file: the
|
|
2257
|
+
// correlation still belongs in mauto.ndjson, where it is the only thing
|
|
2258
|
+
// tying this line to a run. sends:false in the event catalog.
|
|
2259
|
+
run_id: runId || undefined,
|
|
2260
|
+
session_id: resolvedSessionId || undefined,
|
|
2261
|
+
ok,
|
|
2262
|
+
error_kind: errorKind,
|
|
2263
|
+
exit_code: exitCode,
|
|
2264
|
+
dur_ms: Math.round(process.uptime() * 1000),
|
|
2265
|
+
};
|
|
2266
|
+
record(fields, { projectRoot, tracePath: tracePath || undefined });
|
|
2267
|
+
if (text) process.stdout.write(text.endsWith('\n') ? text : text + '\n');
|
|
2268
|
+
process.exit(exitCode);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
const fromEnvelope = (envelope, exitKind, human) => ({
|
|
2272
|
+
text: render(envelope, { human }),
|
|
2273
|
+
exitKind,
|
|
2274
|
+
ok: Boolean(envelope && envelope.ok),
|
|
2275
|
+
errorKind: envelope && envelope.error ? envelope.error.kind : undefined,
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
return {
|
|
2279
|
+
finish,
|
|
2280
|
+
setVerb: (verb) => {
|
|
2281
|
+
resolvedVerb = verb;
|
|
2282
|
+
},
|
|
2283
|
+
setRunId: (runId) => {
|
|
2284
|
+
resolvedRunId = runId;
|
|
2285
|
+
},
|
|
2286
|
+
setSessionId: (sessionId) => {
|
|
2287
|
+
resolvedSessionId = sessionId;
|
|
2288
|
+
},
|
|
2289
|
+
// Read-only counterparts to the setters above, for the one other call site
|
|
2290
|
+
// that needs this invocation's resolved run id / verb: connectBridge's
|
|
2291
|
+
// screenshot-on-failure hook. Both values are already committed by the
|
|
2292
|
+
// preAction hook by the time any verb's fn(bridge) can have returned, so
|
|
2293
|
+
// there is nothing to resolve here — only to expose.
|
|
2294
|
+
getRunId: () => resolvedRunId,
|
|
2295
|
+
getVerb: () => resolvedVerb,
|
|
2296
|
+
|
|
2297
|
+
emit: ({ envelope, exitKind }, human) => finish(fromEnvelope(envelope, exitKind, human)),
|
|
2298
|
+
|
|
2299
|
+
// Raw content (markdown / JSON schema / text) verbatim, no envelope
|
|
2300
|
+
// wrapping. Used by the guide/schema/bootstrap success paths.
|
|
2301
|
+
emitRaw: (content, exitKind) => finish({ text: content, exitKind, ok: true }),
|
|
2302
|
+
|
|
2303
|
+
// Last-resort emit for errors that escape BEFORE any action runs, so the
|
|
2304
|
+
// in-program withEnvelope boundary can't see them — a throw inside
|
|
2305
|
+
// buildProgram, or a stray unhandled rejection. One JSON envelope on stdout
|
|
2306
|
+
// + the mapped exit code, never a raw stack trace.
|
|
2307
|
+
emitFatal: (err, human = false) => {
|
|
2308
|
+
const { envelope, exitKind } = toEnvelope(err);
|
|
2309
|
+
diagnose(err, exitKind);
|
|
2310
|
+
finish(fromEnvelope(envelope, exitKind, human));
|
|
2311
|
+
},
|
|
2312
|
+
};
|
|
1659
2313
|
}
|
|
1660
2314
|
|
|
2315
|
+
// bin/mauto.js registers the exported emitFatal as a PROCESS-wide
|
|
2316
|
+
// unhandledRejection handler, which can fire before run() has built anything —
|
|
2317
|
+
// so it needs a module-level way to reach the invocation in flight. A pointer to
|
|
2318
|
+
// the per-invocation instance, not a second copy of its state: an unhandled
|
|
2319
|
+
// rejection is exactly the crash class this instrumentation exists to see, and
|
|
2320
|
+
// it should carry the verb and projectRoot the invocation actually resolved.
|
|
2321
|
+
let activeEmitters = null;
|
|
2322
|
+
const emitFatal = (err, human = false) => (activeEmitters || makeEmitters()).emitFatal(err, human);
|
|
2323
|
+
|
|
1661
2324
|
async function run(argv) {
|
|
1662
2325
|
// Belt-and-suspenders around the in-program withEnvelope boundary: a throw in
|
|
1663
2326
|
// buildProgram() (e.g. the ScenarioValidator default-param compiling a corrupt
|
|
@@ -1666,21 +2329,40 @@ async function run(argv) {
|
|
|
1666
2329
|
// `unhandledRejection` guard lives in bin/mauto.js (the process entry point),
|
|
1667
2330
|
// not here, so calling run() in tests never installs a global exit-on-reject
|
|
1668
2331
|
// listener that would leak across the suite.
|
|
2332
|
+
//
|
|
2333
|
+
// The workspace is resolved ONCE, here, and the same root reaches both the
|
|
2334
|
+
// emitters (the .logs/ file sink and run traces) and every handler — and,
|
|
2335
|
+
// through connection.js, the session daemon's MAUTO_SESSION_PROJECT_ROOT.
|
|
2336
|
+
// No workspace found falls back to cwd for the verbs that tolerate that;
|
|
2337
|
+
// buildProgram fails the ones that cannot (#188).
|
|
2338
|
+
const cwd = process.cwd();
|
|
2339
|
+
const workspace = findWorkspaceRoot(cwd);
|
|
2340
|
+
const projectRoot = workspace.root || cwd;
|
|
2341
|
+
const emitters = makeEmitters({ projectRoot });
|
|
2342
|
+
activeEmitters = emitters;
|
|
1669
2343
|
try {
|
|
1670
|
-
|
|
2344
|
+
// No projectRoot here, deliberately: buildProgram derives it as
|
|
2345
|
+
// `workspace.root || cwd`, and an injected projectRoot is the test seam
|
|
2346
|
+
// that FABRICATES a workspace — passing one would arm that branch and
|
|
2347
|
+
// silently disable the requireWorkspace gate.
|
|
2348
|
+
const program = buildProgram({ emitters, cwd, workspace });
|
|
1671
2349
|
await program.parseAsync(argv);
|
|
1672
2350
|
} catch (err) {
|
|
1673
2351
|
// Help/version are NOT errors: commander already wrote the human-readable
|
|
1674
|
-
// text to stdout
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
2352
|
+
// text to stdout, so they get no envelope wrapped around them (#146). Same
|
|
2353
|
+
// COMMANDER_NON_FAILURES set toEnvelope classifies against, so "which
|
|
2354
|
+
// commander outcomes aren't failures" has exactly one answer.
|
|
2355
|
+
//
|
|
2356
|
+
// They still exit through finish(), with no text of their own to print.
|
|
2357
|
+
// They are real invocations, and slice 5 computes rates off this stream —
|
|
2358
|
+
// omitting a whole invocation class skews every one of those denominators.
|
|
2359
|
+
// `exitCode` comes from commander rather than a kind because commander
|
|
2360
|
+
// already computed one: `.help({error:true})` yields 1, not 0.
|
|
1677
2361
|
if (err && err.name === 'CommanderError' && COMMANDER_NON_FAILURES.has(err.code)) {
|
|
1678
|
-
|
|
1679
|
-
// invoked with `{error:true}` computes 1.
|
|
1680
|
-
process.exit(err.exitCode || 0);
|
|
2362
|
+
emitters.finish({ exitCode: err.exitCode || 0, ok: true });
|
|
1681
2363
|
return;
|
|
1682
2364
|
}
|
|
1683
|
-
emitFatal(err, argv.slice(2).includes('--human'));
|
|
2365
|
+
emitters.emitFatal(err, argv.slice(2).includes('--human'));
|
|
1684
2366
|
}
|
|
1685
2367
|
}
|
|
1686
2368
|
|
|
@@ -1707,6 +2389,7 @@ module.exports = {
|
|
|
1707
2389
|
handleAssert,
|
|
1708
2390
|
handleResultAddStep,
|
|
1709
2391
|
handleResultAddAssertion,
|
|
2392
|
+
handleResultAddCrash,
|
|
1710
2393
|
handleResultFinalize,
|
|
1711
2394
|
handleSetup,
|
|
1712
2395
|
handleConfigGet,
|
|
@@ -1724,4 +2407,10 @@ module.exports = {
|
|
|
1724
2407
|
handleDevices,
|
|
1725
2408
|
handleDevicesUse,
|
|
1726
2409
|
handleDevicesClear,
|
|
2410
|
+
handleTelemetryStatus,
|
|
2411
|
+
handleTelemetryEnable,
|
|
2412
|
+
handleTelemetryDisable,
|
|
2413
|
+
handleTelemetryFlush,
|
|
2414
|
+
handleCrashList,
|
|
2415
|
+
handleCrashGet,
|
|
1727
2416
|
};
|