@wirecat/cli-core 0.597.0 → 0.599.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,61 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.599.0 — 07.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`store recover`** tells whether the store file is damaged: it reads every page and every search index, names the
15
+ damaged tables, and answers in plain words what happened, likely causes, how to look further, what is affected, that
16
+ the messengers', mail servers' and Drive's own data is not, and the commands to run. It changes nothing.
17
+ `storeRecoveryCheck` gives the same to a CLI with its own `store` command.
18
+ - **`store recover run`** rebuilds a damaged store: it copies every readable row into a new file with the `sqlite3`
19
+ program (3.43 or newer; node:sqlite has no recovery API), rebuilds every search index, the mentions and the vector
20
+ index from the data, checks the result, and puts it in place of the store, keeping the damaged file beside it. It
21
+ refuses while a WireCat timer is scheduled or a process has the store open, and names them. The answer lists the
22
+ tables that lost rows, the rows that point at lost ones, and the mail sync that fetches lost mail again.
23
+ `storeRecoveryRun` and `storeRecoverCommand` give it to other CLIs.
24
+ - **`doctor` checks the store's health**: the file header, its size and the schema's own pages, in milliseconds. A
25
+ damaged store is explained in the answer's `store.damage` (`explainDamage`: what, causes, investigate, impact,
26
+ external_unaffected, steps) and points at `store recover`, the whole-file check. `storeSummary` takes
27
+ `{ deep, command }`, and `DOCTOR_DEEP_OPTION` words a CLI's own `--deep`.
28
+
29
+ ### Fixed
30
+
31
+ - **Opening the store a second time in one process no longer drops the locks of the connection already open.** The
32
+ open touched the existing file through its own descriptor, and on Linux closing any descriptor of a file releases
33
+ every lock the process holds on it, so another process could checkpoint and delete the write-ahead log under a
34
+ writer that went on writing into it. That can damage the store. The file is now created only when it is missing.
35
+
36
+ ## 0.598.0 — 07.11.2026
37
+
38
+ ### Added
39
+
40
+ - **`runScopeCases`** (`@wirecat/cli-core/testing`): cases that each remote command of a program runs inside
41
+ cli-core's run, so `--record` keeps a run named after it, and that `--timeout` ends it when the service never
42
+ answers. A command beside the run answers under the process run and fails. `neverAnswers` is the `fetch` for the
43
+ silent run.
44
+ - **`inCommandRun(command, body)`** (`@wirecat/cli-core/cli`): runs `body` inside cli-core's run with the settings of
45
+ the app `run()` was given, which `run()` now hands every command (`environment.resolveSettings`).
46
+
47
+ ### Changed — may break callers
48
+
49
+ - **`addMeetingCommands` takes `inRun`**, which `meetings sync` runs inside: pass `inCommandRun` from
50
+ `@wirecat/cli-core/cli`. The meetings module cannot reach cli-core's run itself.
51
+
52
+ ### Fixed
53
+
54
+ - **The shared CRM, calendar and meetings commands run inside cli-core's run.** `sync` and the deal and lead writes
55
+ from `addCrmCommands`, every command from `addCalendarCommands`, and `meetings sync` answered under the process
56
+ run: `--record` kept no run, and `runs show` could not find the id they answered. A CLI that wrapped them in a run
57
+ of its own can drop that.
58
+ - **An MCP server's `--permission` levels refuse a messenger write before it connects.** The early refusal of a change
59
+ on the messenger read the command's flags only, so `mcp serve --permission messages.send=deny` let the command
60
+ connect and left the refusal to the send guard. It now merges the server's levels as `baseContext` does.
61
+ `withAgentLevels(command, flags)` (`@wirecat/cli-core/cli`) is that merge, for a CLI's own resolver.
62
+ - **An app's `mcp serve` over stdio ends when it was stopped before it listened.** `serveAppOverStdio` waited only
63
+ for an abort event, which a signal that had already fired never sends, so the server hung with stdin open.
64
+
10
65
  ## 0.597.0 — 07.11.2026
11
66
 
12
67
  ### 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";
@@ -99,6 +99,7 @@ export { accountFileFor, profilesHere, rememberAccount } from "./messenger/accou
99
99
  export { terminalAsker } from "./messenger/ask.js";
100
100
  export type { CompletionOptions } from "./messenger/complete-command.js";
101
101
  export { type ConnectOptions, connectFor, type Messenger, type MessengerContext, messengerContext, type ProfileState, type ReadConnection, type StoredMessages, } from "./messenger/context.js";
102
+ export { DOCTOR_DEEP_OPTION, damageNotes } from "./messenger/doctor-command.js";
102
103
  export { COMMAND_NEEDS } from "./messenger/lacks.js";
103
104
  export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from "./messenger/mcp-command.js";
104
105
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
@@ -131,5 +132,6 @@ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, a
131
132
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
132
133
  export { runnable, withSafety } from "./steps.js";
133
134
  export { type StoreInfo, sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
135
+ export { type RecoveryCheck, type RecoveryRun, recoveryNotes, storeRecoverCommand, storeRecoveryCheck, storeRecoveryRun, } from "./store-recover.js";
134
136
  export { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
135
137
  export { restartServers, type UpgradeContext } from "./upgrade-command.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";
@@ -89,6 +89,7 @@ export { readMeetingSeriesInventory } from "./meeting-series-inventory-read.js";
89
89
  export { accountFileFor, profilesHere, rememberAccount } from "./messenger/accounts.js";
90
90
  export { terminalAsker } from "./messenger/ask.js";
91
91
  export { connectFor, messengerContext, } from "./messenger/context.js";
92
+ export { DOCTOR_DEEP_OPTION, damageNotes } from "./messenger/doctor-command.js";
92
93
  export { COMMAND_NEEDS } from "./messenger/lacks.js";
93
94
  export { httpTokenFile, serverEntry } from "./messenger/mcp-command.js";
94
95
  export { messageItem } from "./messenger/message-items.js";
@@ -120,5 +121,6 @@ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, a
120
121
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
121
122
  export { runnable, withSafety } from "./steps.js";
122
123
  export { sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
124
+ export { recoveryNotes, storeRecoverCommand, storeRecoveryCheck, storeRecoveryRun, } from "./store-recover.js";
123
125
  export { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
124
126
  export { restartServers } from "./upgrade-command.js";
@@ -8,6 +8,9 @@ import { type Messenger } from "./context.js";
8
8
  * open — each is a field in the answer, never an exception.
9
9
  */
10
10
  export declare const doctorCommand: (messenger: Messenger) => Command;
11
+ export declare const DOCTOR_DEEP_OPTION = "also read every page of the store and check every search index (about 30 s for 6 GB); without it, only the file header and schema";
12
+ /** The plain-words account of a damaged store, under the answer. */
13
+ export declare const damageNotes: (store: unknown) => string[];
11
14
  /** What the messenger asked this profile to wait for, still in force — what the next command would be refused. */
12
15
  export declare const floodState: (memory: FloodMemory) => {
13
16
  path: string;
@@ -49,10 +49,24 @@ export const doctorCommand = (messenger) => {
49
49
  renderAnswer(this, { ...output, ...(renderer ? { renderer } : {}) }, itemOf(report, { next: unchecked ? [nextStep("doctor.online", { args: { online: true } })] : [] }));
50
50
  for (const fix of fixes)
51
51
  (renderer ?? output.renderer).note(`to make it private: ${fix}`);
52
+ for (const line of damageNotes(report.store))
53
+ (renderer ?? output.renderer).note(line);
52
54
  });
53
55
  command.addCommand(reportCommand(messenger));
54
56
  return command;
55
57
  };
58
+ export const DOCTOR_DEEP_OPTION = "also read every page of the store and check every search index (about 30 s for 6 GB); without it, only the file header and schema";
59
+ /** The shared doctor stays quick; `store recover` is its deep check, and a CLI may add `--deep` to its own doctor. */
60
+ const summaryOptions = (command, app) => ({
61
+ deep: command.opts().deep === true,
62
+ command: app,
63
+ });
64
+ /** The plain-words account of a damaged store, under the answer. */
65
+ export const damageNotes = (store) => {
66
+ const damage = store
67
+ ?.damage;
68
+ return damage ? [damage.what, damage.external_unaffected, ...damage.steps.map((step) => `next: ${step}`)] : [];
69
+ };
56
70
  const INCLUDES = [
57
71
  "the version, the runtime and the operating system",
58
72
  "what `doctor` answers: paths with the home folder shown as ~, the store's schema and counts, the messenger's own checks",
@@ -149,7 +163,7 @@ const diagnose = async (command, messenger) => {
149
163
  cli,
150
164
  config: { path, found: existsSync(path), issues: [] },
151
165
  settings: { error: messageOf(error) },
152
- store: snakeKeys(await storeSummary(env)),
166
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
153
167
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
154
168
  files: filesReport(files),
155
169
  },
@@ -174,7 +188,7 @@ const diagnose = async (command, messenger) => {
174
188
  cli,
175
189
  profile: null,
176
190
  config,
177
- store: snakeKeys(await storeSummary(env)),
191
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
178
192
  readers: engineStates(),
179
193
  runs: { directory: runsDirFor(app, env), kept: listRuns(runsDirFor(app, env)).length },
180
194
  files: filesReport(files),
@@ -210,7 +224,7 @@ const diagnose = async (command, messenger) => {
210
224
  account: { remembered: account?.account ?? null },
211
225
  // A session file on disk says nothing about whether the messenger still accepts it.
212
226
  login: { state: "not checked" },
213
- store: snakeKeys(await storeSummary(env)),
227
+ store: snakeKeys(await storeSummary(env, summaryOptions(command, app.command))),
214
228
  readers: engineStates(),
215
229
  sends: sendsState(new SendJournal(sendsPathFor(app, profile, env))),
216
230
  flood: floodState(new FloodMemory(flood)),
@@ -1,12 +1,27 @@
1
1
  import { Command } from "commander";
2
2
  import type { CacheDatabase } from "../../store/driver.js";
3
3
  import type { Messenger } from "./context.js";
4
- /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
5
- export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
4
+ /**
5
+ * `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. `health` is the quick
6
+ * check unless `deep`; `damage` says in plain words what a failed one means and what to run.
7
+ */
8
+ export declare const storeSummary: (env: NodeJS.ProcessEnv, { deep, command }?: {
9
+ deep?: boolean;
10
+ command?: string;
11
+ }) => Promise<{
6
12
  path: string;
7
13
  exists: boolean;
8
- error?: undefined;
9
14
  } | {
15
+ health: {
16
+ checked: "deep" | "quick";
17
+ covers: string;
18
+ ok: boolean;
19
+ ms: number;
20
+ problems?: number | undefined;
21
+ damagedTables?: import("../../store/health.js").DamagedTable[] | undefined;
22
+ damagedSearchIndexes?: string[] | undefined;
23
+ };
24
+ damage?: import("../../store/damage.js").DamageExplanation | undefined;
10
25
  path: string;
11
26
  exists: boolean;
12
27
  schema: number;
@@ -15,6 +30,16 @@ export declare const storeSummary: (env: NodeJS.ProcessEnv) => Promise<{
15
30
  chats?: number | undefined;
16
31
  messages?: number | undefined;
17
32
  } | {
33
+ health: {
34
+ checked: "deep" | "quick";
35
+ covers: string;
36
+ ok: boolean;
37
+ ms: number;
38
+ problems?: number | undefined;
39
+ damagedTables?: import("../../store/health.js").DamagedTable[] | undefined;
40
+ damagedSearchIndexes?: string[] | undefined;
41
+ };
42
+ damage?: import("../../store/damage.js").DamageExplanation | undefined;
18
43
  path: string;
19
44
  exists: boolean;
20
45
  error: string;
@@ -8,6 +8,8 @@ import { RULES_VERSION } from "../../conversations/link.js";
8
8
  import { syncsOneChat } from "../../ports/traits.js";
9
9
  import { isSealed, sealFile, unsealFile } from "../../sealed.js";
10
10
  import { fileSchemaOf, SPEAKS, writableBy } from "../../store/compatibility.js";
11
+ import { explainDamage } from "../../store/damage.js";
12
+ import { QUICK_HEALTH_CHECKS, storeHealth } from "../../store/health.js";
11
13
  import { migrate } from "../../store/migrations.js";
12
14
  import { openCache } from "../../store/open.js";
13
15
  import { storePath } from "../../store/path.js";
@@ -18,6 +20,7 @@ import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
18
20
  import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
19
21
  import { lineOf, nextStep } from "../steps.js";
20
22
  import { storeInfoCommand } from "../store-info.js";
23
+ import { storeRecoverCommand } from "../store-recover.js";
21
24
  import { answerOf } from "./ask.js";
22
25
  import { ENCRYPT_OPTION, passwordOf } from "./password.js";
23
26
  import { servingProfiles } from "./serve-command.js";
@@ -55,11 +58,28 @@ const readingOnly = async (path, read) => {
55
58
  database.close();
56
59
  }
57
60
  };
58
- /** `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. */
59
- export const storeSummary = async (env) => {
61
+ /**
62
+ * `doctor`'s short summary of the store. Must answer for a broken file, so an error is a field. `health` is the quick
63
+ * check unless `deep`; `damage` says in plain words what a failed one means and what to run.
64
+ */
65
+ export const storeSummary = async (env, { deep = false, command } = {}) => {
60
66
  const path = storePath(env);
61
67
  if (!existsSync(path))
62
68
  return { path, exists: false };
69
+ const checked = await storeHealth(path, { deep });
70
+ const health = {
71
+ checked: checked.checked,
72
+ covers: deep ? "every page and every search index" : QUICK_HEALTH_CHECKS,
73
+ ok: checked.ok,
74
+ ms: checked.ms,
75
+ ...(checked.problems ? { problems: checked.problems } : {}),
76
+ ...(checked.damagedTables?.length ? { damagedTables: checked.damagedTables } : {}),
77
+ ...(checked.searchIndexes?.some((index) => !index.ok)
78
+ ? { damagedSearchIndexes: checked.searchIndexes.filter((index) => !index.ok).map((index) => index.name) }
79
+ : {}),
80
+ };
81
+ const damage = command ? explainDamage(checked, command) : null;
82
+ const healthOf = { health, ...(damage ? { damage } : {}) };
63
83
  try {
64
84
  return await reading(path, (database) => {
65
85
  const { version, writable } = schemaOf(database);
@@ -70,11 +90,12 @@ export const storeSummary = async (env) => {
70
90
  speaks: SPEAKS,
71
91
  writable,
72
92
  ...(version > 0 ? { chats: count(database, "chats"), messages: count(database, "messages") } : {}),
93
+ ...healthOf,
73
94
  };
74
95
  });
75
96
  }
76
97
  catch (error) {
77
- return { path, exists: true, error: messageOf(error) };
98
+ return { path, exists: true, error: messageOf(error), ...healthOf };
78
99
  }
79
100
  };
80
101
  /** A maintenance answer: one item, snake_case, its hints as steps where a registered one fits. */
@@ -91,6 +112,7 @@ export const storeMaintenanceCommands = (messenger) => [
91
112
  resetCommand(messenger),
92
113
  copiesCommand(),
93
114
  storeTablesCommand(messenger),
115
+ storeRecoverCommand({ command: messenger.app.command, version: messenger.app.version }),
94
116
  ];
95
117
  /** Renaming tables under a running `serve` would pull them from under its statements. */
96
118
  const refuseWhileServing = (command, messenger) => {
@@ -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,
@@ -9,4 +9,7 @@ export type StoreInfo = v.InferOutput<typeof storeInfoEntity>;
9
9
  export declare const storeInfo: (env: NodeJS.ProcessEnv) => Promise<StoreInfo>;
10
10
  export declare const storeInfoCommand: () => Command;
11
11
  /** `store info` for a tool with no `store` command of its own. */
12
- export declare const sharedStoreCommand: () => Command;
12
+ export declare const sharedStoreCommand: (app?: {
13
+ command: string;
14
+ version?: string;
15
+ }) => Command;
@@ -7,6 +7,7 @@ import { fileFacts, openSqlite, pendingNormalization, rowCounts, searchIndexStat
7
7
  import { environmentOf, outputFor } from "./context.js";
8
8
  import { LOCAL_READ } from "./effects.js";
9
9
  import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
10
+ import { storeRecoverCommand } from "./store-recover.js";
10
11
  /** Before version 6 the column does not exist, and a file behind this build is not migrated here. */
11
12
  const pendingIfKnown = (database) => database.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = 'normalized_text'").get()
12
13
  ? pendingNormalization(database)
@@ -67,6 +68,9 @@ export const storeInfoCommand = () => annotate(new Command("info"), { output: {
67
68
  renderAnswer(this, outputFor(this), itemOf(answer));
68
69
  });
69
70
  /** `store info` for a tool with no `store` command of its own. */
70
- export const sharedStoreCommand = () => annotate(new Command("store"), { effects: LOCAL_READ })
71
- .description("the shared local store every WireCat tool reads")
72
- .addCommand(storeInfoCommand());
71
+ export const sharedStoreCommand = (app) => {
72
+ const store = annotate(new Command("store"), { effects: LOCAL_READ })
73
+ .description("the shared local store every WireCat tool reads")
74
+ .addCommand(storeInfoCommand());
75
+ return app ? store.addCommand(storeRecoverCommand(app)) : store;
76
+ };
@@ -0,0 +1,56 @@
1
+ import { Command } from "commander";
2
+ import { type DamageExplanation } from "../store/damage.js";
3
+ import { type RecoverOptions, type Recovery } from "../store/recover.js";
4
+ export interface RecoveryCheck {
5
+ path: string;
6
+ exists: boolean;
7
+ ok: boolean;
8
+ seconds?: number;
9
+ problems?: number;
10
+ damagedTables?: {
11
+ table: string | null;
12
+ problems: number;
13
+ sample: string;
14
+ }[];
15
+ damagedSearchIndexes?: {
16
+ name: string;
17
+ error?: string;
18
+ }[];
19
+ rows?: Record<string, number | null>;
20
+ explanation?: DamageExplanation | null;
21
+ inUse?: {
22
+ pid: number;
23
+ name: string;
24
+ }[] | null;
25
+ timers?: string[] | null;
26
+ sqlite3?: {
27
+ version: string | null;
28
+ usable: boolean;
29
+ };
30
+ /** The commands to run, in order. */
31
+ next: string[];
32
+ }
33
+ /** `store recover`: what is wrong with the store and what to run, reading every page; changes nothing. */
34
+ export declare const storeRecoveryCheck: (env: NodeJS.ProcessEnv, command: string) => Promise<RecoveryCheck>;
35
+ export type RecoveryRun = Omit<Recovery, "health" | "copyErrors"> & {
36
+ /** `sqlite3` reported these while copying; most are rows that broke a constraint and were skipped. */
37
+ copyErrors: number;
38
+ checkedOk: boolean;
39
+ /** The commands that fetch again what was lost, then the timers to start. */
40
+ next: string[];
41
+ };
42
+ /** `store recover run`: refuses while a WireCat timer is scheduled, so none writes to the file being replaced. */
43
+ export declare const storeRecoveryRun: (env: NodeJS.ProcessEnv, { command, version, note, timers: scheduled, copy, }: {
44
+ command: string;
45
+ version?: string;
46
+ note?: (text: string) => void;
47
+ timers?: () => string[] | undefined;
48
+ copy?: RecoverOptions["copy"];
49
+ }) => Promise<RecoveryRun>;
50
+ /** The lines a person reads under the answer; the JSON carries the same in its fields. */
51
+ export declare const recoveryNotes: (answer: RecoveryCheck | RecoveryRun) => string[];
52
+ /** `store recover` and `store recover run`, for a CLI whose commands run on cli-core's context. */
53
+ export declare const storeRecoverCommand: ({ command, version }: {
54
+ command: string;
55
+ version?: string;
56
+ }) => Command;