@wirecat/cli-core 0.597.0 → 0.598.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 CHANGED
@@ -7,6 +7,35 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.598.0 — 07.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`runScopeCases`** (`@wirecat/cli-core/testing`): cases that each remote command of a program runs inside
15
+ cli-core's run, so `--record` keeps a run named after it, and that `--timeout` ends it when the service never
16
+ answers. A command beside the run answers under the process run and fails. `neverAnswers` is the `fetch` for the
17
+ silent run.
18
+ - **`inCommandRun(command, body)`** (`@wirecat/cli-core/cli`): runs `body` inside cli-core's run with the settings of
19
+ the app `run()` was given, which `run()` now hands every command (`environment.resolveSettings`).
20
+
21
+ ### Changed — may break callers
22
+
23
+ - **`addMeetingCommands` takes `inRun`**, which `meetings sync` runs inside: pass `inCommandRun` from
24
+ `@wirecat/cli-core/cli`. The meetings module cannot reach cli-core's run itself.
25
+
26
+ ### Fixed
27
+
28
+ - **The shared CRM, calendar and meetings commands run inside cli-core's run.** `sync` and the deal and lead writes
29
+ from `addCrmCommands`, every command from `addCalendarCommands`, and `meetings sync` answered under the process
30
+ run: `--record` kept no run, and `runs show` could not find the id they answered. A CLI that wrapped them in a run
31
+ of its own can drop that.
32
+ - **An MCP server's `--permission` levels refuse a messenger write before it connects.** The early refusal of a change
33
+ on the messenger read the command's flags only, so `mcp serve --permission messages.send=deny` let the command
34
+ connect and left the refusal to the send guard. It now merges the server's levels as `baseContext` does.
35
+ `withAgentLevels(command, flags)` (`@wirecat/cli-core/cli`) is that merge, for a CLI's own resolver.
36
+ - **An app's `mcp serve` over stdio ends when it was stopped before it listened.** `serveAppOverStdio` waited only
37
+ for an abort event, which a signal that had already fired never sends, so the server hung with stdin open.
38
+
10
39
  ## 0.597.0 — 07.11.2026
11
40
 
12
41
  ### Added
@@ -3,7 +3,7 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
4
  import { clockMeta } from "@wirecat/cli-base/standards";
5
5
  import { Argument, InvalidArgumentError, Option } from "commander";
6
- import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { changesStore, ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
9
9
  import { renderAnswer } from "../../cli/envelope.js";
@@ -77,17 +77,10 @@ const addCalendarCommandsDefinition = (program, options) => {
77
77
  renderAnswer(command, output, body, view ?? ((shown) => localTimesView(shown, zone(), output.color)));
78
78
  };
79
79
  /** Opens the profile for `work` and closes it before the answer prints, also when `work` fails. */
80
- const within = async (command, work, view) => {
80
+ const within = (command, work, view) => inCommandRun(command, async () => {
81
81
  const run = await options.open(command, { readOnly: readsOnly(command) });
82
- let body;
83
- try {
84
- body = await work(run);
85
- }
86
- finally {
87
- await run.close?.();
88
- }
89
- answer(command, body, view);
90
- };
82
+ answer(command, await work(run).finally(() => run.close?.()), view);
83
+ });
91
84
  const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} personal ${line}`) });
92
85
  const calendars = annotate(program.command("calendars").description("the account's calendars"), { effects: GROUP });
93
86
  withQuery(example(annotate(calendars.command("list"), { effects: FETCH, offline: false }).description("list the account's calendars from the provider, and which of them sync"), "calendars list --json", "calendars list synced:true"), CALENDARS_QUERY).action(async (words, _opts, command) => {
@@ -34,6 +34,8 @@ export interface BaseEnvironment {
34
34
  answer?: (question: string) => Promise<string | null> | string | null;
35
35
  /** Set by `run()`; a command's run is recorded only when it is known whose run it is. */
36
36
  app?: AppIdentity;
37
+ /** Set by `run()`: the app's own, so a shared command group opens its run with the app's settings. */
38
+ resolveSettings?: (flags: GlobalFlags, options?: ResolveOptions) => Settings;
37
39
  /** Set by `run()` from `process.platform` unless a test hands one in: the OS a command's paths and units are for. */
38
40
  platform?: NodeJS.Platform;
39
41
  /** The local speech recognizer in place of the downloaded model; tests hand one in. */
@@ -120,6 +122,10 @@ export declare const runBudgetOf: (command: Command, requests?: number) => RunBu
120
122
  export declare const setExitCode: (command: Command, code: number) => void;
121
123
  export declare const provide: (program: Command, environment: BaseEnvironment) => void;
122
124
  export declare const environmentOf: <E extends BaseEnvironment = BaseEnvironment>(command: Command) => E;
125
+ /** The command's flags with an MCP server's `--permission` levels first: they reach a run on its agent, never in argv. */
126
+ export declare const withAgentLevels: <F extends {
127
+ permission?: string[];
128
+ }>(command: Command, flags: F) => F;
123
129
  /**
124
130
  * The input and output `readSecret` uses for this command: the run's own, so a prompt goes where the run's other
125
131
  * output goes and a test that captures it sees it. `readSecret(prompt, { ...secretInputOf(this), echo: true })`.
@@ -166,4 +172,10 @@ export interface BaseContext {
166
172
  type Resolve = (flags: GlobalFlags, options?: ResolveOptions) => Settings;
167
173
  /** Everything a command needs that no messenger decides, resolved once. */
168
174
  export declare const baseContext: (command: Command, resolveSettings: Resolve) => BaseContext;
175
+ /**
176
+ * A shared group's command inside cli-core's run, with the settings of the app `run()` was given: recorded, and ended
177
+ * by `--timeout` (WC-LIFE-5, WC-LIFE-15). The group's own opener still decides whether it needs a profile. A program
178
+ * parsed without `run()` has no run to open, so `body` just runs.
179
+ */
180
+ export declare const inCommandRun: <T>(command: Command, body: (events: EventSink) => Promise<T>) => Promise<T>;
169
181
  export {};
@@ -37,6 +37,11 @@ export const provide = (program, environment) => {
37
37
  environments.set(program, environment);
38
38
  };
39
39
  export const environmentOf = (command) => (environments.get(rootOf(command)) ?? {});
40
+ /** The command's flags with an MCP server's `--permission` levels first: they reach a run on its agent, never in argv. */
41
+ export const withAgentLevels = (command, flags) => {
42
+ const overrides = environmentOf(command).agent?.overrides ?? [];
43
+ return overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags;
44
+ };
40
45
  /**
41
46
  * The input and output `readSecret` uses for this command: the run's own, so a prompt goes where the run's other
42
47
  * output goes and a test that captures it sees it. `readSecret(prompt, { ...secretInputOf(this), echo: true })`.
@@ -70,9 +75,7 @@ export const outputFor = (command) => {
70
75
  export const baseContext = (command, resolveSettings) => {
71
76
  const environment = environmentOf(command);
72
77
  const env = environment.env ?? process.env;
73
- const flags = command.optsWithGlobals();
74
- const overrides = environment.agent?.overrides ?? [];
75
- const settings = resolveSettings(overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags, { env });
78
+ const settings = resolveSettings(withAgentLevels(command, command.optsWithGlobals()), { env });
76
79
  const { renderer, format, color, streams, compact, full } = resolveOutput({
77
80
  ...settings,
78
81
  env,
@@ -141,6 +144,17 @@ export const baseContext = (command, resolveSettings) => {
141
144
  },
142
145
  };
143
146
  };
147
+ /**
148
+ * A shared group's command inside cli-core's run, with the settings of the app `run()` was given: recorded, and ended
149
+ * by `--timeout` (WC-LIFE-5, WC-LIFE-15). The group's own opener still decides whether it needs a profile. A program
150
+ * parsed without `run()` has no run to open, so `body` just runs.
151
+ */
152
+ export const inCommandRun = (command, body) => {
153
+ const { resolveSettings } = environmentOf(command);
154
+ if (!resolveSettings)
155
+ return body(() => { });
156
+ return baseContext(command, (flags, options) => resolveSettings(flags, { ...options, needsProfile: false })).run(body);
157
+ };
144
158
  /** `messages send` — the words that name commands, never the arguments: those can be a message. */
145
159
  const commandPath = (command) => {
146
160
  const words = [];
@@ -41,6 +41,7 @@ export const sharedCommandTemplate = (app, name) => {
41
41
  },
42
42
  accountId: 0,
43
43
  write: unavailable,
44
+ inRun: unavailable,
44
45
  });
45
46
  const meetings = root.commands.find((command) => command.name() === "meetings");
46
47
  if (!meetings)
@@ -7,7 +7,7 @@ import { SendJournal, sendsPathFor } from "../sends/journal.js";
7
7
  import { CEILING_KEY, keyForCommand, levelFor, writeOfKey } from "../sends/permissions.js";
8
8
  import { newOperationId } from "../sends/send-id.js";
9
9
  import { commandPathOf } from "./command-contract.js";
10
- import { environmentOf } from "./context.js";
10
+ import { withAgentLevels } from "./context.js";
11
11
  import { changesMessenger } from "./effects.js";
12
12
  import { refuseUnknownPermissionKeys } from "./permission-keys.js";
13
13
  import { NONE_NAMED, settingsFor } from "./settings.js";
@@ -33,7 +33,7 @@ export const refuseWriteBeforeConnecting = (action, { app, env, dryRun, resolve
33
33
  const key = keyForCommand(path);
34
34
  if (!key)
35
35
  return;
36
- const settings = (resolve ?? settingsFor(app).resolveSettings)(action.optsWithGlobals(), {
36
+ const settings = (resolve ?? settingsFor(app).resolveSettings)(withAgentLevels(action, action.optsWithGlobals()), {
37
37
  env,
38
38
  needsProfile: false,
39
39
  readOnly: true,
@@ -91,9 +91,11 @@ export const refuseByCommandPermissions = (action, permissions, { app, env, dryR
91
91
  const preview = dryRun && app.messenger === false;
92
92
  if (lock && effects?.remote === "write" && !preview)
93
93
  throw readOnlyRefusal(lock, `run ${commandPathOf(action).join(" ")}`);
94
- const flags = action.optsWithGlobals();
95
- const overrides = environmentOf(action).agent?.overrides ?? [];
96
- const settings = resolve(overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags, { env, needsProfile: false, readOnly: true });
94
+ const settings = resolve(withAgentLevels(action, action.optsWithGlobals()), {
95
+ env,
96
+ needsProfile: false,
97
+ readOnly: true,
98
+ });
97
99
  refuseUnknownPermissionKeys(action, settings, app.command, permissions);
98
100
  if (dryRun || settings.sources?.profile === NONE_NAMED)
99
101
  return;
@@ -66,7 +66,7 @@ export { type CompactOptions, compactText, entityRows, GENERIC_ROW, type RowColu
66
66
  export { refuseUnknownKey } from "./config-command.js";
67
67
  export { type ConfigIssue, type ConfigIssueKind, checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
68
68
  export { loadSnakeCaseConfig } from "./config-keys.js";
69
- export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
69
+ export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, inCommandRun, outputFor, provide, runBudgetOf, secretInputOf, withAgentLevels, } from "./context.js";
70
70
  export { addContractInfo, type ContractEvidenceOptions, contractEvidence } from "./contract-evidence.js";
71
71
  export { sharedCommandTemplate } from "./contract-templates.js";
72
72
  export { type Closeable, withDeadline } from "./deadline.js";
package/dist/cli/index.js CHANGED
@@ -57,7 +57,7 @@ export { compactText, entityRows, GENERIC_ROW, rowShapeOf, rowsOfEntity, SHARED_
57
57
  export { refuseUnknownKey } from "./config-command.js";
58
58
  export { checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
59
59
  export { loadSnakeCaseConfig } from "./config-keys.js";
60
- export { baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
60
+ export { baseContext, environmentOf, inCommandRun, outputFor, provide, runBudgetOf, secretInputOf, withAgentLevels, } from "./context.js";
61
61
  export { addContractInfo, contractEvidence } from "./contract-evidence.js";
62
62
  export { sharedCommandTemplate } from "./contract-templates.js";
63
63
  export { withDeadline } from "./deadline.js";
@@ -228,6 +228,9 @@ const runOnce = async (argv, definition, options) => {
228
228
  commandSignal: control.signal,
229
229
  trackCloseable: control.trackCloseable,
230
230
  app: definition.app,
231
+ // `settingsFor`'s, which every CLI passes; the definition's type is looser only so a test can stub it.
232
+ resolveSettings: (definition.configuration ?? settingsFor(definition.app))
233
+ .resolveSettings,
231
234
  ...(definition.rows ? { rows: definition.rows } : {}),
232
235
  platform: options.platform ?? process.platform,
233
236
  exit,
@@ -3,7 +3,7 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { Argument, InvalidArgumentError } from "commander";
4
4
  import * as v from "valibot";
5
5
  import { answerEntity } from "../../cli/answer-entity.js";
6
- import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
9
9
  import { itemOf, listOf, renderAnswer, snakeKeys, snakeKeysKeeping } from "../../cli/envelope.js";
@@ -142,7 +142,7 @@ const dealKeys = snakeKeysKeeping(["providerMetadata"]);
142
142
  const marked = (items, untrusted) => items.map((item) => ({ ...dealKeys(item), untrusted }));
143
143
  const addCrmCommandsDefinition = (program, options) => {
144
144
  const now = () => options.now?.() ?? Date.now();
145
- const within = async (command, work) => {
145
+ const within = (command, work) => inCommandRun(command, async () => {
146
146
  const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
147
147
  let body;
148
148
  try {
@@ -152,7 +152,7 @@ const addCrmCommandsDefinition = (program, options) => {
152
152
  await run.close?.();
153
153
  }
154
154
  renderAnswer(command, outputFor(command), body);
155
- };
155
+ });
156
156
  const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
157
157
  example(answerEntity(annotate(program.command("sync"), { effects: FETCH, offline: false, cooperative: true, partial: true }), "crm_sync", crmSync).description("bring the CRM's copy in the local store up to date: pipelines, companies, contacts, deals and leads that " +
158
158
  "changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
@@ -444,7 +444,8 @@ const addCrmCommandsDefinition = (program, options) => {
444
444
  .description("the CRM's custom fields of one kind, in its order, with a list field's options")
445
445
  .argument("<kind>", fieldKinds.join(", "))
446
446
  .option("--limit <n>", `fields per page, 1–${MOST}`, integer(MOST), 50)
447
- .option("--page <n>", "which page, starting at 1", integer(1_000_000), 1)
447
+ // No default: the run's settings refuse --all beside a --page, given or defaulted.
448
+ .option("--page <n>", "which page, starting at 1", integer(1_000_000))
448
449
  .option("--all", "every field, no paging"), FIELDS_QUERY), "fields list deals", "fields list people --json").action(async (kind, words, opts, command) => {
449
450
  if (!fieldKinds.includes(kind))
450
451
  throw new CliError("validation_error", `fields are listed for ${fieldKinds.join(", ")}; got ${kind}`, {
@@ -454,12 +455,13 @@ const addCrmCommandsDefinition = (program, options) => {
454
455
  queryTerms(words, FIELDS_QUERY, "fields list");
455
456
  await within(command, async (run) => {
456
457
  const found = await run.store.crm.fields(run.account, FIELD_KINDS[kind]);
457
- const offset = opts.all ? 0 : (opts.page - 1) * opts.limit;
458
+ const page = opts.page ?? 1;
459
+ const offset = opts.all ? 0 : (page - 1) * opts.limit;
458
460
  const end = opts.all ? found.length : offset + opts.limit;
459
461
  return listOf(pathOf(command).join(" "), {
460
462
  items: found.slice(offset, end).map((field) => ({ ...field, untrusted: FIELD_UNTRUSTED })),
461
463
  limit: opts.all ? Math.max(1, found.length) : opts.limit,
462
- page: opts.page,
464
+ page,
463
465
  has_more: found.length > end,
464
466
  ...(words.length ? { args: { query: words } } : {}),
465
467
  });
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { type ContractCase, type ContractOptions, contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { type FakeAdapter, fakeAdapter } from "./fake.js";
4
4
  export { type McpStdio, type McpStdioStreams, type McpToolResult, mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, type RunScopeCase, type RunScopeOptions, runScopeCases } from "./run-scope.js";
5
6
  export { type SearchRecipes, searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, type IdKind, type IdMaker, type Seed, wordIds } from "./seed.js";
package/dist/kit/index.js CHANGED
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { fakeAdapter } from "./fake.js";
4
4
  export { mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, runScopeCases } from "./run-scope.js";
5
6
  export { searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, wordIds } from "./seed.js";
@@ -0,0 +1,34 @@
1
+ import { type ProgramDefinition, type RunOptions } from "../cli/program.js";
2
+ export interface RunScopeCase {
3
+ name: string;
4
+ /** Throws an `AssertionError` when the command does not run inside cli-core's run. */
5
+ run(): Promise<void>;
6
+ }
7
+ export interface RunScopeOptions {
8
+ /** The CLI's own program definition, as its `run()` hands it to cli-core. */
9
+ definition: ProgramDefinition;
10
+ /**
11
+ * The words that run each remote command to success against the answering fake, by command path:
12
+ * `{ "comments show": ["personal", "comments", "show", "c1"] }`. A write whose level is `ask` needs a profile that
13
+ * allows it, or its refusal fails the case.
14
+ */
15
+ commands: Record<string, readonly string[]>;
16
+ /** Remote commands left out, each with its reason: one that listens until stopped, one that asks npm. */
17
+ skip?: Record<string, string>;
18
+ /** What `run()` gets beside the streams: the keyring, and the fake that answers every request. */
19
+ answering: () => RunOptions;
20
+ /** The same, with a fake that takes every request and never settles it, `neverAnswers` for an HTTP service. */
21
+ silent: () => RunOptions;
22
+ /** The `--timeout` a silent run gets; 100 ms. */
23
+ timeoutMs?: number;
24
+ }
25
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
26
+ export declare const neverAnswers: typeof fetch;
27
+ /**
28
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
29
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
30
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
31
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
32
+ * on a remote command with no words and no skip.
33
+ */
34
+ export declare const runScopeCases: (options: RunScopeOptions) => RunScopeCase[];
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { metaOf } from "@wirecat/cli-base/commands";
6
+ import { createProgram, run } from "../cli/program.js";
7
+ import { findRun, runsDirFor } from "../runs/run.js";
8
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
9
+ export const neverAnswers = (() => new Promise(() => { }));
10
+ const GRACE_MS = 2000;
11
+ const remoteCommands = (program, path = []) => program.commands.flatMap((command) => {
12
+ const at = [...path, command.name()];
13
+ if (command.commands.length > 0)
14
+ return remoteCommands(command, at);
15
+ const meta = metaOf(command);
16
+ const remote = meta.effects?.remote;
17
+ return remote && remote !== "none" ? [{ path: at.join(" "), remote, partial: meta.partial === true }] : [];
18
+ });
19
+ const within = async (ms, what, body) => {
20
+ let timer;
21
+ const late = new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new assert.AssertionError({ message: what })), ms);
23
+ timer.unref();
24
+ });
25
+ try {
26
+ return await Promise.race([body, late]);
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ }
31
+ };
32
+ const parsed = (text) => {
33
+ try {
34
+ return JSON.parse(text.trim());
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ /**
41
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
42
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
43
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
44
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
45
+ * on a remote command with no words and no skip.
46
+ */
47
+ export const runScopeCases = (options) => {
48
+ const { definition, commands, skip = {}, timeoutMs = 100 } = options;
49
+ const { app } = definition;
50
+ const cli = async (argv, given) => {
51
+ const out = [];
52
+ const err = [];
53
+ const env = {
54
+ ...process.env,
55
+ ...given.env,
56
+ [`${app.envPrefix}_STATE_DIR`]: mkdtempSync(join(tmpdir(), "run-scope-")),
57
+ };
58
+ const code = await run(argv, definition, {
59
+ ...given,
60
+ env,
61
+ tty: false,
62
+ streams: { data: (text) => out.push(text), diagnostic: (text) => err.push(text) },
63
+ });
64
+ return { code, out: out.join(""), err: err.join(""), env };
65
+ };
66
+ const machine = (words) => words.some((word) => word === "--json" || word.startsWith("--json=")) ? [...words] : [...words, "--json"];
67
+ const recordedCase = (path, words) => ({
68
+ name: `${path} runs in a recorded run named after it`,
69
+ run: async () => {
70
+ const ran = await within(GRACE_MS, `${path} did not end against the answering fake`, (async () => cli(["--record", ...machine(words)], options.answering()))());
71
+ assert.equal(ran.code, 0, `${path} must succeed against the answering fake; it exited ${ran.code}: ${ran.err}`);
72
+ const runId = parsed(ran.out)?.meta?.run_id;
73
+ assert.equal(typeof runId, "string", `${path} answered without meta.run_id`);
74
+ assert.doesNotMatch(String(runId), /-process-/, `${path} answered outside a run of its own: ${runId}`);
75
+ const kept = findRun(runsDirFor(app, ran.env), String(runId));
76
+ assert.ok(kept, `--record kept no run ${runId} for ${path}`);
77
+ assert.equal(kept.metadata.command, path, `the run of ${path} is named ${kept.metadata.command}`);
78
+ },
79
+ });
80
+ const timeoutCase = ({ path, remote, partial }, words) => {
81
+ const expected = remote === "write" ? "outcome_unknown" : "timeout";
82
+ return {
83
+ name: `${path} ends in ${expected}${partial ? " or a partial answer" : ""} at --timeout when the service never answers`,
84
+ run: async () => {
85
+ const ran = await within(timeoutMs + GRACE_MS, `${path} was still running ${GRACE_MS} ms after its --timeout ${timeoutMs}ms`, cli(["--timeout", `${timeoutMs}ms`, ...machine(words)], options.silent()));
86
+ const answered = parsed(ran.out)?.meta?.complete;
87
+ if (partial && ran.code === 0 && answered === false)
88
+ return;
89
+ const code = parsed(ran.err)?.error?.code;
90
+ assert.equal(code, expected, `${path} against a silent service ended in ${String(code)}: ${ran.out}${ran.err}`);
91
+ },
92
+ };
93
+ };
94
+ const remote = remoteCommands(createProgram(definition));
95
+ const coverage = {
96
+ name: "every remote command has its words or a reason to skip it",
97
+ run: async () => {
98
+ const missing = remote.map(({ path }) => path).filter((path) => !(path in commands) && !(path in skip));
99
+ assert.deepEqual(missing, [], `remote commands with no words in \`commands\` and no reason in \`skip\``);
100
+ const known = new Set(remote.map(({ path }) => path));
101
+ const stray = [...Object.keys(commands), ...Object.keys(skip)].filter((path) => !known.has(path));
102
+ assert.deepEqual(stray, [], "paths in `commands` or `skip` that are not remote commands of this program");
103
+ },
104
+ };
105
+ return [
106
+ coverage,
107
+ ...remote.flatMap((one) => {
108
+ const words = commands[one.path];
109
+ return words ? [recordedCase(one.path, words), timeoutCase(one, words)] : [];
110
+ }),
111
+ ];
112
+ };
@@ -124,8 +124,13 @@ export const serveAppOverStdio = async (command, context, options) => {
124
124
  });
125
125
  const ended = await new Promise((resolve) => {
126
126
  wire.ended.then(() => resolve("input"));
127
- if (own)
128
- signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
127
+ if (own) {
128
+ // A stop that came before this line fires no abort event.
129
+ if (signal?.aborted)
130
+ resolve("stopped");
131
+ else
132
+ signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
133
+ }
129
134
  else {
130
135
  process.once("SIGINT", () => resolve("stopped"));
131
136
  process.once("SIGTERM", () => resolve("stopped"));
@@ -11,6 +11,11 @@ export interface MeetingCommandDeps {
11
11
  importHelp?: string;
12
12
  writeFile?: (path: string, content: string) => Promise<void>;
13
13
  write: (value: unknown, format: "json" | "jsonl" | "text") => void;
14
+ /**
15
+ * Runs a command that reaches the account inside cli-core's run, so it is recorded and ended by `--timeout`:
16
+ * `inCommandRun` from `@wirecat/cli-core/cli`. Required, since this module cannot reach the run itself.
17
+ */
18
+ inRun: <T>(command: Command, body: () => Promise<T>) => Promise<T>;
14
19
  now?: () => number;
15
20
  }
16
21
  export { addMeetingPassageCommand, createMeetingPassageCommand, type MeetingPassageFlags, meetingPassageRequest, } from "./passage.js";
@@ -121,14 +121,15 @@ const addMeetingCommandsDefinition = (program, deps) => {
121
121
  .option("--since-time <time>", "fetch meetings starting at or after this ISO 8601 date or time", date)
122
122
  .option("--lookback-days <days>", "revisit recent occurrences for late transcripts (0–31)", lookbackDays, 0)
123
123
  .action(async (opts, command) => {
124
- if (!deps.source)
124
+ const source = deps.source;
125
+ if (!source)
125
126
  throw new MeetingError("configuration_error", "meeting source is unavailable");
126
- print(await pull(deps.source, deps.store, {
127
+ await deps.inRun(command, async () => print(await pull(source, deps.store, {
127
128
  accountId: deps.accountId,
128
129
  since: opts.sinceTime === undefined ? undefined : new Date(opts.sinceTime).toISOString(),
129
130
  now: deps.now?.(),
130
131
  lookbackMs: opts.lookbackDays * 86400000,
131
- }), command);
132
+ }), command));
132
133
  });
133
134
  const importCommand = annotate(group.command("import"), {
134
135
  effects: LOCAL_WRITE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.597.0",
3
+ "version": "0.598.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",