@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 +29 -0
- package/dist/calendar/cli/index.js +4 -11
- package/dist/cli/context.d.ts +12 -0
- package/dist/cli/context.js +17 -3
- package/dist/cli/contract-templates.js +1 -0
- package/dist/cli/early-refusal.js +7 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/program.js +3 -0
- package/dist/crm/cli/index.js +8 -6
- package/dist/kit/index.d.ts +1 -0
- package/dist/kit/index.js +1 -0
- package/dist/kit/run-scope.d.ts +34 -0
- package/dist/kit/run-scope.js +112 -0
- package/dist/mcp/app-server.js +7 -2
- package/dist/meetings/cli/index.d.ts +5 -0
- package/dist/meetings/cli/index.js +4 -3
- package/package.json +1 -1
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 =
|
|
80
|
+
const within = (command, work, view) => inCommandRun(command, async () => {
|
|
81
81
|
const run = await options.open(command, { readOnly: readsOnly(command) });
|
|
82
|
-
|
|
83
|
-
|
|
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) => {
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/cli/context.js
CHANGED
|
@@ -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
|
|
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 = [];
|
|
@@ -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 {
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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;
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/cli/program.js
CHANGED
|
@@ -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,
|
package/dist/crm/cli/index.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
464
|
+
page,
|
|
463
465
|
has_more: found.length > end,
|
|
464
466
|
...(words.length ? { args: { query: words } } : {}),
|
|
465
467
|
});
|
package/dist/kit/index.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/mcp/app-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
+
const source = deps.source;
|
|
125
|
+
if (!source)
|
|
125
126
|
throw new MeetingError("configuration_error", "meeting source is unavailable");
|
|
126
|
-
print(await pull(
|
|
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.
|
|
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",
|