@wirecat/cli-core 0.414.0 → 0.416.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
@@ -5,6 +5,25 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.416.0 — 25.10.2026
11
+
12
+ ### Fixed
13
+
14
+ - **A busy linked chat left alone drains at full speed with `--cursor`.** Each linked chat's share of a run's items is now split among the linked chats that still have something to read, and the word and sender lanes take a share only while they do too. Before, chats that had already finished still took a share, so with ten linked chats the last busy one got 20 items a run and needed about 45 runs instead of about 10.
15
+
16
+ ## 0.415.0 — 25.10.2026
17
+
18
+ ### Added
19
+
20
+ - **`movedCommandError(program, args)` from `/cli`** answers a renamed or moved command path with the pointer `run` gives ("`drive models text download` is not here — use `models download`"), for a CLI that parses without `run`. drive is the one today.
21
+ - **The store records which CLIs open it, so an upgrade that locks some out names them** (available from cli-core 0.415.0). `openStore({ command, version })` saves each CLI's command, version, cli-core version and the newest store schema it speaks in `store_settings` (`clients`). A migration that raises the store's minimum prints one line to stderr naming each recorded CLI it locks out and the command to run (`memo upgrade`); a CLI refused by a newer store now says `run \`<its command> upgrade\`` and which CLI moved the store. The messenger context, the MCP server and `store migrate` pass the CLI's version already; other CLIs pass `version` to `openStore`.
22
+
23
+ ### Changed — may break callers
24
+
25
+ - **cli-core needs cli-base 0.53.** The peer range is now `>=0.53.0 <0.54.0`, so a CLI moves its cli-base pin to 0.53.0 together with this release. cli-base 0.53.0 makes the command lint require a query declaration on every handwritten list (`WC-CMD-20`); its runtime API is unchanged.
26
+
8
27
  ## 0.414.0 — 25.10.2026
9
28
 
10
29
  ### Added
@@ -96,6 +96,7 @@ export type { Running, ServerOptions, ServerProcess, ServerSystem } from "./mess
96
96
  export { storeSummary } from "./messenger/store-maintenance-command.js";
97
97
  export { type Saving, stored } from "./messenger/stored.js";
98
98
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
99
+ export { movedCommandError } from "./moved.js";
99
100
  export { window, withPaging } from "./paging.js";
100
101
  export { knownBeside, knownPermissionKeys, type PermissionKeyOf, unknownPermissionKeys } from "./permission-keys.js";
101
102
  export { asFirstWord, commandWords, liftProfile, refuseCommandName, rootOf, usableProfileName } from "./profile.js";
package/dist/cli/index.js CHANGED
@@ -87,6 +87,7 @@ export { STEMS_EVERY_MS, STEMS_SLICE_MS, stemFills } from "./messenger/serve-ste
87
87
  export { storeSummary } from "./messenger/store-maintenance-command.js";
88
88
  export { stored } from "./messenger/stored.js";
89
89
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
90
+ export { movedCommandError } from "./moved.js";
90
91
  export { window, withPaging } from "./paging.js";
91
92
  export { knownBeside, knownPermissionKeys, unknownPermissionKeys } from "./permission-keys.js";
92
93
  export { asFirstWord, commandWords, liftProfile, refuseCommandName, rootOf, usableProfileName } from "./profile.js";
@@ -47,7 +47,7 @@ export interface LazyStore {
47
47
  open: () => Promise<MessageStore>;
48
48
  close: () => Promise<void>;
49
49
  }
50
- export declare const lazyStore: (env: NodeJS.ProcessEnv, command: string) => LazyStore;
50
+ export declare const lazyStore: (env: NodeJS.ProcessEnv, command: string, version?: string) => LazyStore;
51
51
  export declare const storedMessages: (provider: Provider, store: () => Promise<MessageStore>, account: () => Id | null | undefined) => StoredMessages;
52
52
  /** What one messenger CLI hands the shared commands. Everything else about it stays in its own code. */
53
53
  export interface Messenger extends ServiceMessenger {
@@ -17,11 +17,11 @@ import { flooded } from "./flooded.js";
17
17
  import { refuseLacking } from "./lacks.js";
18
18
  import { observed } from "./observed.js";
19
19
  import { stored } from "./stored.js";
20
- export const lazyStore = (env, command) => {
20
+ export const lazyStore = (env, command, version) => {
21
21
  let opened;
22
22
  return {
23
23
  open: () => {
24
- opened ??= openStore({ env, command });
24
+ opened ??= openStore({ env, command, ...(version ? { version } : {}) });
25
25
  return opened;
26
26
  },
27
27
  close: async () => {
@@ -195,7 +195,7 @@ const waitsOf = (command) => {
195
195
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
196
196
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
197
197
  */
198
- export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, unreadCounts, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
198
+ export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, unreadCounts, }, { settings, env, renderer }, events, store = lazyStore(env, app.command, app.version), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
199
199
  const self = connection.self();
200
200
  if (self !== null)
201
201
  rememberAccount(app, settings.profile, self, env);
@@ -268,7 +268,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
268
268
  };
269
269
  /** `connectFor` and then `connected`, over one store handle: what the reads save and what the adapter looks up. */
270
270
  export const connectedFor = async (command, messenger, base, options) => {
271
- const store = lazyStore(base.env, messenger.app.command);
271
+ const store = lazyStore(base.env, messenger.app.command, messenger.app.version);
272
272
  let connection;
273
273
  try {
274
274
  connection = await connectFor(command, messenger, base, {
@@ -392,7 +392,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
392
392
  }, { unbounded: options.listen === true }),
393
393
  withStore: (work, { name } = {}) => base.run(async () => {
394
394
  const account = accountFor();
395
- const store = await openStore({ env: base.env, command: app.command });
395
+ const store = await openStore({ env: base.env, command: app.command, version: app.version });
396
396
  try {
397
397
  return await work(store, account);
398
398
  }
@@ -406,7 +406,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
406
406
  let warned;
407
407
  let released;
408
408
  const releaseConnection = () => (released ??= held?.close() ?? Promise.resolve());
409
- const openOnce = () => (store ??= openStore({ env: base.env, command: app.command }));
409
+ const openOnce = () => (store ??= openStore({ env: base.env, command: app.command, version: app.version }));
410
410
  const deps = {
411
411
  messenger,
412
412
  profile,
@@ -375,7 +375,7 @@ const migrateCommand = (messenger) => annotate(new Command("migrate"), { output:
375
375
  }
376
376
  const answer = await reading(path, (database) => {
377
377
  const from = schemaOf(database).version;
378
- migrate(database, { command: messenger.app.command });
378
+ migrate(database, { command: messenger.app.command, version: messenger.app.version });
379
379
  const { normalized, indexed, terms } = buildWordIndex(database, (note) => renderer.note(note));
380
380
  const stemmed = buildStems(database, (note) => renderer.note(note));
381
381
  const notes = buildNoteIndex(database, (note) => renderer.note(note));
@@ -1,3 +1,5 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import type { Command } from "commander";
1
3
  /**
2
4
  * Commands a source tool no longer has, each with the memo command that does the job
3
5
  * (docs/dev/TOOL-BOUNDARIES.md). The CLI and the MCP server answer with the pointer instead of
@@ -12,3 +14,5 @@ export declare const RENAMED_COMMANDS: Readonly<Record<string, string>>;
12
14
  export declare const movedPointer: (path: string) => string | undefined;
13
15
  /** The longest moved path the words start with, unless the program still has it. */
14
16
  export declare const movedPath: (words: readonly string[], has: (path: string) => boolean) => string | undefined;
17
+ /** The pointer for a moved or renamed path in `args`, for a CLI that parses without `run`. */
18
+ export declare const movedCommandError: (program: Command, args: readonly string[]) => CliError | undefined;
package/dist/cli/moved.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { commandAt } from "./steps.js";
1
3
  /**
2
4
  * Commands a source tool no longer has, each with the memo command that does the job
3
5
  * (docs/dev/TOOL-BOUNDARIES.md). The CLI and the MCP server answer with the pointer instead of
@@ -54,3 +56,17 @@ export const movedPath = (words, has) => [...Object.keys(MOVED_TO_MEMO), ...Obje
54
56
  .filter((path) => path.split(" ").every((word, at) => words[at] === word))
55
57
  .sort((a, b) => b.length - a.length)
56
58
  .find((path) => !has(path));
59
+ const mountedOrArgument = (program, path) => {
60
+ const words = path.split(" ");
61
+ return words.some((_, at) => {
62
+ const command = commandAt(program, words.slice(0, at + 1).join(" "));
63
+ return command !== undefined && (at === words.length - 1 || command.commands.length === 0);
64
+ });
65
+ };
66
+ /** The pointer for a moved or renamed path in `args`, for a CLI that parses without `run`. */
67
+ export const movedCommandError = (program, args) => {
68
+ const moved = movedPath(args.filter((word) => !word.startsWith("-")), (path) => mountedOrArgument(program, path));
69
+ return moved === undefined
70
+ ? undefined
71
+ : new CliError("validation_error", `\`${program.name()} ${moved}\` is not here — ${movedPointer(moved)}`);
72
+ };
@@ -25,7 +25,7 @@ import { installHelp } from "./help/first-screen.js";
25
25
  import { showInputFields } from "./input-fields.js";
26
26
  import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
27
27
  import { checkJson, liftJson } from "./json-fields.js";
28
- import { movedPath, movedPointer } from "./moved.js";
28
+ import { movedCommandError } from "./moved.js";
29
29
  import { PreviewComplete, preview } from "./preview.js";
30
30
  import { commandWords, liftProfile, PROFILE_FREE, valuedOptions } from "./profile.js";
31
31
  import { absolutePaths, relayTarget } from "./relay-route.js";
@@ -421,10 +421,8 @@ const runOnce = async (argv, definition, options) => {
421
421
  const settled = await settleFailure(failure, { definition, program, rest, profile, options });
422
422
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
423
423
  }
424
- const words = rest.filter((word) => !word.startsWith("-"));
425
- const moved = movedPath(words, (path) => mountedOrArgument(program, path));
426
- if (moved !== undefined) {
427
- const failure = new CliError("validation_error", `\`${command} ${moved}\` is not here — ${movedPointer(moved)}`);
424
+ const failure = movedCommandError(program, rest);
425
+ if (failure !== undefined) {
428
426
  const settled = await settleFailure(failure, { definition, program, rest, profile, options });
429
427
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
430
428
  }
@@ -591,13 +589,6 @@ const keepFailure = async (failure, { definition, program, rest, profile, option
591
589
  }).catch(() => { });
592
590
  };
593
591
  /** Mounted, or reached through a command that takes no subcommand, where the rest of the path is its argument. */
594
- const mountedOrArgument = (program, path) => {
595
- const words = path.split(" ");
596
- return words.some((_, at) => {
597
- const command = commandAt(program, words.slice(0, at + 1).join(" "));
598
- return command !== undefined && (at === words.length - 1 || command.commands.length === 0);
599
- });
600
- };
601
592
  /** `messages list` from `messages list 111 --limit 5`: the words that name commands, up to the first that does not. */
602
593
  const commandPath = (program, rest) => {
603
594
  const words = [];
@@ -176,7 +176,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
176
176
  }),
177
177
  });
178
178
  const skill = messenger.skill ? skillResource(app, messenger.skill) : undefined;
179
- const log = agentLog(() => openStore({ env: context.env, command: app.command }));
179
+ const log = agentLog(() => openStore({ env: context.env, command: app.command, version: app.version }));
180
180
  const audit = async (call) => {
181
181
  if (!recalledAccount(app, provider, settings.profile, context.env))
182
182
  return;
@@ -86,9 +86,13 @@ export const gatherProjectEvidence = async (ports, request) => {
86
86
  let stopped = null;
87
87
  const open = (u) => !terminal.has(u.state) && u.state !== "read-limited";
88
88
  const linkedLanes = new Set(plan.linkedChats.flatMap((c) => (c.lane === null ? [] : [c.lane])));
89
- // One even share per linked chat plus one for the word and sender lanes, so one busy chat cannot fill a run; never
90
- // below a page, so every chat still gets its whole first page.
91
- const linkedLimit = Math.max(PAGE, Math.floor(plan.budgets.maxItems / (linkedLanes.size + 1)));
89
+ // One even share per linked chat still open plus one for the word and sender lanes while any is open, so one busy
90
+ // chat cannot fill a run while others wait, yet a chat left alone gets the whole run; never below a page, so every
91
+ // chat still gets its whole first page.
92
+ const openLanes = new Set(progress.flatMap((u, index) => (open(u) ? [units[index]?.lane.id] : [])));
93
+ const shares = [...linkedLanes].filter((id) => openLanes.has(id)).length +
94
+ ([...openLanes].some((id) => id !== undefined && !linkedLanes.has(id)) ? 1 : 0);
95
+ const linkedLimit = Math.max(PAGE, Math.floor(plan.budgets.maxItems / Math.max(1, shares)));
92
96
  const linkedTaken = new Map();
93
97
  const capped = new Set();
94
98
  /** Records another lane finding a reference this run already returned; true when it had. */
@@ -24,9 +24,11 @@ export declare const MIGRATIONS: Migration[];
24
24
  * that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
25
25
  * the file. An app without that command (drive) passes no `command`, and the advice names none.
26
26
  */
27
- export declare const migrate: (database: CacheDatabase, { migrations, now, command, ownTable, }?: {
27
+ export declare const migrate: (database: CacheDatabase, { migrations, now, command, version, ownTable, }?: {
28
28
  migrations?: Migration[];
29
29
  now?: () => number;
30
30
  command?: string;
31
+ /** The CLI's own version: with `command`, it is recorded so a later upgrade can name who it locks out. */
32
+ version?: string;
31
33
  ownTable?: string | null;
32
- }) => void;
34
+ }) => string | undefined;
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { lockedOutNotice, recordClient, refusalAdvice, storeClients } from "./sqlite/clients.js";
2
3
  import { generatedMigrations } from "./sqlite/manifest.js";
3
4
  /**
4
5
  * Every migration is generated by drizzle-kit and numbered in `sqlite/manifest.ts`. Version 1 creates the whole
@@ -36,7 +37,7 @@ const fileState = (database) => {
36
37
  * that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
37
38
  * the file. An app without that command (drive) passes no `command`, and the advice names none.
38
39
  */
39
- export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, ownTable = OWN_TABLE, } = {}) => {
40
+ export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, command, version, ownTable = OWN_TABLE, } = {}) => {
40
41
  const ours = latest(migrations);
41
42
  database.exec("BEGIN IMMEDIATE");
42
43
  try {
@@ -48,8 +49,18 @@ export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, com
48
49
  }
49
50
  if (file.version > ours && file.minCompatible > ours) {
50
51
  throw new CliError("configuration_error", `the message store was written by a newer version (schema ${file.version}, needs at least ` +
51
- `${file.minCompatible}; this one speaks ${ours}) — upgrade this tool`);
52
+ `${file.minCompatible}; this one speaks ${ours}) — ${refusalAdvice(storeClients(database), command, file.version)}`);
52
53
  }
54
+ const raisedTo = migrations
55
+ .filter((migration) => migration.version > file.version)
56
+ .reduce((highest, { minCompatible }) => Math.max(highest, minCompatible), file.minCompatible);
57
+ const notice = file.version > 0 && raisedTo > file.minCompatible
58
+ ? lockedOutNotice(storeClients(database), {
59
+ ...(command ? { command } : {}),
60
+ schema: ours,
61
+ minCompatible: raisedTo,
62
+ })
63
+ : undefined;
53
64
  for (const migration of migrations) {
54
65
  if (migration.version <= file.version)
55
66
  continue;
@@ -60,7 +71,10 @@ export const migrate = (database, { migrations = MIGRATIONS, now = Date.now, com
60
71
  .prepare("INSERT INTO schema_migrations (version, min_compatible, applied_at) VALUES (?, ?, ?)")
61
72
  .run(migration.version, migration.minCompatible, now());
62
73
  }
74
+ if (command && version)
75
+ recordClient(database, { command, version, speaks: ours }, now());
63
76
  database.exec("COMMIT");
77
+ return notice;
64
78
  }
65
79
  catch (error) {
66
80
  database.exec("ROLLBACK");
@@ -0,0 +1,26 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ export declare const CLIENTS_SETTING = "clients";
3
+ /** A CLI that opened the store for writing: what it is and the newest store schema it speaks. */
4
+ export interface StoreClient {
5
+ command: string;
6
+ version: string;
7
+ core: string;
8
+ speaks: number;
9
+ seenAt: number;
10
+ }
11
+ /** Readable from any schema: `store_settings` is in the initial migration. */
12
+ export declare const storeClients: (database: CacheDatabase) => StoreClient[];
13
+ /** Writes only when the CLI's version, cli-core or schema changed, or a day passed: every run calls it. */
14
+ export declare const recordClient: (database: CacheDatabase, { command, version, speaks }: {
15
+ command: string;
16
+ version: string;
17
+ speaks: number;
18
+ }, now: number) => void;
19
+ /** The sentence an upgrade that raised the store's minimum prints: who can no longer open it, and what to run. */
20
+ export declare const lockedOutNotice: (clients: readonly StoreClient[], { command, schema, minCompatible }: {
21
+ command?: string;
22
+ schema: number;
23
+ minCompatible: number;
24
+ }) => string | undefined;
25
+ /** Added to a refusal: this tool's own command, and the CLI that moved the store, so the owner knows what to run. */
26
+ export declare const refusalAdvice: (clients: readonly StoreClient[], command: string | undefined, schema: number) => string;
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+ export const CLIENTS_SETTING = "clients";
3
+ const coreVersion = (() => {
4
+ try {
5
+ const manifest = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
6
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
7
+ }
8
+ catch {
9
+ return "unknown";
10
+ }
11
+ })();
12
+ const clientOf = (value) => {
13
+ if (typeof value !== "object" || value === null)
14
+ return undefined;
15
+ const { command, version, core, speaks, seenAt } = value;
16
+ return typeof command === "string" &&
17
+ typeof version === "string" &&
18
+ typeof core === "string" &&
19
+ Number.isSafeInteger(speaks) &&
20
+ Number.isSafeInteger(seenAt)
21
+ ? { command, version, core, speaks: speaks, seenAt: seenAt }
22
+ : undefined;
23
+ };
24
+ // A store built from a test's own migrations may have no settings table.
25
+ const hasSettings = (database) => database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'store_settings'").get() !== undefined;
26
+ /** Readable from any schema: `store_settings` is in the initial migration. */
27
+ export const storeClients = (database) => {
28
+ if (!hasSettings(database))
29
+ return [];
30
+ const row = database.prepare("SELECT value FROM store_settings WHERE key = ?").get(CLIENTS_SETTING);
31
+ if (!row)
32
+ return [];
33
+ try {
34
+ const value = JSON.parse(String(row.value));
35
+ return Array.isArray(value) ? value.flatMap((one) => clientOf(one) ?? []) : [];
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ };
41
+ /** Writes only when the CLI's version, cli-core or schema changed, or a day passed: every run calls it. */
42
+ export const recordClient = (database, { command, version, speaks }, now) => {
43
+ if (!hasSettings(database))
44
+ return;
45
+ const stored = storeClients(database);
46
+ const same = stored.find((one) => one.command === command);
47
+ if (same &&
48
+ same.version === version &&
49
+ same.core === coreVersion &&
50
+ same.speaks === speaks &&
51
+ now - same.seenAt < 86_400_000)
52
+ return;
53
+ const entry = { command, version, core: coreVersion, speaks, seenAt: now };
54
+ database
55
+ .prepare("INSERT OR REPLACE INTO store_settings (key, value, at) VALUES (?, ?, ?)")
56
+ .run(CLIENTS_SETTING, JSON.stringify([...stored.filter((one) => one.command !== command), entry]), now);
57
+ };
58
+ const upgradeOf = (clients) => clients.map(({ command }) => `\`${command} upgrade\``).join(", ");
59
+ /** The sentence an upgrade that raised the store's minimum prints: who can no longer open it, and what to run. */
60
+ export const lockedOutNotice = (clients, { command, schema, minCompatible }) => {
61
+ const others = clients.filter((one) => one.command !== command);
62
+ const behind = others.filter(({ speaks }) => speaks < minCompatible);
63
+ const named = behind.map(({ command, version }) => `${command} ${version}`).join(", ");
64
+ if (behind.length)
65
+ return `the store moved to schema ${schema}; ${named} can no longer open it until upgraded — run ${upgradeOf(behind)}`;
66
+ // A CLI that has not opened the store since clients were recorded is unknown here, but may still be behind.
67
+ if (!others.length)
68
+ return (`the store moved to schema ${schema}; every other WireCat CLI that uses it needs a version that speaks it — ` +
69
+ "run `<cli> upgrade` for each");
70
+ return undefined;
71
+ };
72
+ /** Added to a refusal: this tool's own command, and the CLI that moved the store, so the owner knows what to run. */
73
+ export const refusalAdvice = (clients, command, schema) => {
74
+ const mover = clients
75
+ .filter(({ speaks, command: other }) => speaks >= schema && other !== command)
76
+ .sort((a, b) => b.seenAt - a.seenAt)[0];
77
+ const own = command ? `run \`${command} upgrade\`` : "upgrade this tool";
78
+ return mover ? `${own} (${mover.command} ${mover.version} moved the store to schema ${schema})` : own;
79
+ };
@@ -7,6 +7,10 @@ export interface StoreOptions {
7
7
  now?: () => number;
8
8
  /** The CLI's own name, for the advice a failed migration gives. */
9
9
  command?: string;
10
+ /** The CLI's own version: with `command`, recorded in the store so an upgrade can name the CLIs it locks out. */
11
+ version?: string;
12
+ /** Where the notice of an upgrade that locks other CLIs out goes; stderr, since stdout may carry JSON or MCP. */
13
+ warn?: (line: string) => void;
10
14
  }
11
15
  export declare const BACKFILL_ON_OPEN = 5000;
12
- export declare const openStoreContext: ({ path, env, now, command, readOnly, }?: StoreOptions) => Promise<StoreContext>;
16
+ export declare const openStoreContext: ({ path, env, now, command, version, warn, readOnly, }?: StoreOptions) => Promise<StoreContext>;
@@ -28,7 +28,7 @@ const createWritable = (file) => {
28
28
  throw new CliError("permission_error", "the message store cannot be written from here; run where it is writable, or set MESSAGING_STORE", { reason: "store.not-writable" });
29
29
  }
30
30
  };
31
- export const openStoreContext = async ({ path, env, now = Date.now, command, readOnly = false, } = {}) => {
31
+ export const openStoreContext = async ({ path, env, now = Date.now, command, version, warn = (line) => process.stderr.write(`${line}\n`), readOnly = false, } = {}) => {
32
32
  if (typeof readOnly !== "boolean")
33
33
  throw new CliError("validation_error", "readOnly must be a boolean");
34
34
  await storeCapable();
@@ -43,9 +43,11 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, rea
43
43
  const database = opened.database;
44
44
  try {
45
45
  if (readOnly)
46
- assertReadCompatible(database);
46
+ assertReadCompatible(database, command);
47
47
  else {
48
- migrate(database, { now, command });
48
+ const notice = migrate(database, { now, command, ...(version ? { version } : {}) });
49
+ if (notice)
50
+ warn(notice);
49
51
  fillFolded(database);
50
52
  const pending = pendingNormalization(database);
51
53
  if (pending > 0 && pending <= BACKFILL_ON_OPEN)
@@ -1,2 +1,2 @@
1
1
  import type { CacheDatabase } from "../driver.js";
2
- export declare const assertReadCompatible: (database: CacheDatabase) => void;
2
+ export declare const assertReadCompatible: (database: CacheDatabase, command?: string) => void;
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { MIGRATIONS } from "../migrations.js";
3
- export const assertReadCompatible = (database) => {
3
+ import { refusalAdvice, storeClients } from "./clients.js";
4
+ export const assertReadCompatible = (database, command) => {
4
5
  const ours = MIGRATIONS.at(-1)?.version ?? 0;
5
6
  if (!database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_migrations'").get())
6
7
  throw new CliError("configuration_error", "The store has no schema history; initialize it explicitly before a read-only open");
@@ -17,5 +18,5 @@ export const assertReadCompatible = (database) => {
17
18
  if (version < ours)
18
19
  throw new CliError("configuration_error", `The store needs schema ${ours} maintenance before a read-only open (found ${version})`);
19
20
  if (minCompatible > ours)
20
- throw new CliError("configuration_error", `The store requires schema ${minCompatible}, but this tool supports ${ours}; upgrade this tool`);
21
+ throw new CliError("configuration_error", `The store requires schema ${minCompatible}, but this tool supports ${ours}; ${refusalAdvice(storeClients(database), command, version)}`);
21
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.414.0",
3
+ "version": "0.416.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",
@@ -213,7 +213,7 @@
213
213
  "@types/node": "^22.20.4",
214
214
  "@types/pngjs": "6.0.5",
215
215
  "@vitest/coverage-v8": "^5.0.2",
216
- "@wirecat/cli-base": "0.51.0",
216
+ "@wirecat/cli-base": "0.53.0",
217
217
  "commander": "15.0.0",
218
218
  "cspell": "10.3.6",
219
219
  "drizzle-kit": "1.0.0-rc.4",
@@ -233,7 +233,7 @@
233
233
  },
234
234
  "peerDependencies": {
235
235
  "@napi-rs/canvas": ">=0.1.69 <2",
236
- "@wirecat/cli-base": ">=0.51.0 <0.52.0",
236
+ "@wirecat/cli-base": ">=0.53.0 <0.54.0",
237
237
  "commander": ">=15"
238
238
  },
239
239
  "bin": {