@wirecat/cli-core 0.413.0 → 0.415.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,7 +5,33 @@ 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
8
+ ## 0.415.0 — 25.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`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.
13
+ - **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`.
14
+
15
+ ### Changed — may break callers
16
+
17
+ - **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.
18
+
19
+ ## 0.414.0 — 25.10.2026
20
+
21
+ ### Added
22
+
23
+ - `AppIdentity.contentLanguages`: ISO 639-1 codes of the languages a CLI's content is in. On a new store the first run pre-ticks the ones search can stem in place of English and the system's language; the person still confirms the shared choice. Unset, nothing changes.
24
+ - Export `publicUrlOf` and `portOf` through `/mcp`, so non-messenger consumers reuse the same HTTP listener option validation as the messenger commands.
25
+ - **A contact saved with an address joins the mail contact of that exact address.** `savePeople` takes an optional
26
+ `email`; the provider's contact and the mail contact become one person (`same-email`), whichever is saved first.
27
+ A name never joins anyone, and two identities the owner unlinked stay apart.
28
+ - **The first screen of `tg --help`, `max --help` and `wa --help` points at `account list`.** A row under "Start here" names `<tool> account list`, which lists every profile on this computer without a profile in front, so a reader who does not know their profile names finds them. A tool without `account list` shows no row.
29
+
30
+ ### Fixed
31
+
32
+ - Generated MCP commands accept a positional argument or its same-named flag alternative once. A single `text` input no longer emits both the argument and `--text`, which made Memo note creation fail. Positional requiredness is retained.
33
+ - Public listener origins refuse embedded credentials before startup diagnostics can expose them.
34
+ - **"Returns" lists the fields of commands whose output has several shapes.** A command's help and the generated `docs/commands.md` showed `message send — ; every field: …` with no fields for every output declared as a union (`message_send`, `store_info`, `chat_group_change` and others; 41 lines in tg-cli's page). The line now lists the fields of every shape, required first, as for any other output.
9
35
 
10
36
  ## 0.413.0 — 25.10.2026
11
37
 
package/dist/cli/entry.js CHANGED
@@ -61,6 +61,7 @@ export const startCli = async (entry) => {
61
61
  atStart: (command) => setupAtStart({
62
62
  command: entry.app.command,
63
63
  env: io.env,
64
+ contentLanguages: entry.app.contentLanguages,
64
65
  path: commandPathOf(command),
65
66
  headless: headless(command),
66
67
  readOnlyLock: readOnlyLockOf(entry.app, io.env),
@@ -25,6 +25,8 @@ export interface FirstRun {
25
25
  command: string;
26
26
  env: NodeJS.ProcessEnv;
27
27
  io: FirstRunIo;
28
+ /** `AppIdentity.contentLanguages`: ticked on a new store in place of English and the system's language. */
29
+ contentLanguages?: readonly string[] | undefined;
28
30
  /** Every question, as `setup` asks them; otherwise only those no WireCat tool asked yet. */
29
31
  all?: boolean;
30
32
  /** Where tools are looked for and how one is installed; injected by tests. */
@@ -58,6 +60,7 @@ export declare const askSetupQuestions: (run: FirstRun) => Promise<Question[]>;
58
60
  export declare const setupAtStart: (start: {
59
61
  command: string;
60
62
  env: NodeJS.ProcessEnv;
63
+ contentLanguages?: readonly string[] | undefined;
61
64
  path: readonly string[];
62
65
  headless: boolean;
63
66
  readOnlyLock?: string | undefined;
@@ -85,17 +85,19 @@ const installerHere = () => {
85
85
  return "unknown";
86
86
  }
87
87
  };
88
- const askLanguages = async ({ command, env, io }) => {
88
+ const suggested = (env, contentLanguages) => {
89
+ const own = LANGUAGES.filter(({ code }) => contentLanguages?.includes(code));
90
+ const system = systemLanguage(env);
91
+ return (own.length ? own : LANGUAGES.filter(({ code }) => code === "en" || code === system)).map(({ name }) => name);
92
+ };
93
+ const askLanguages = async ({ command, env, io, contentLanguages }) => {
89
94
  const store = await openStore({ env });
90
95
  let rebuild = false;
91
96
  try {
92
97
  const saved = (await store.searchIndex.stemmers()) ?? undefined;
93
98
  // A store already in use keeps the languages it was built with, so Enter changes nothing (RISK-1612).
94
99
  const inUse = saved !== undefined || (await store.accounts.accounts()).length > 0;
95
- const system = systemLanguage(env);
96
- const ticked = inUse
97
- ? languagesOf(saved ?? DEFAULT_STEMMERS)
98
- : LANGUAGES.filter(({ code }) => code === "en" || code === system).map(({ name }) => name);
100
+ const ticked = inUse ? languagesOf(saved ?? DEFAULT_STEMMERS) : suggested(env, contentLanguages);
99
101
  const numbers = ticked.map((name) => LANGUAGES.findIndex((one) => one.name === name) + 1);
100
102
  io.say("Languages you write and speak — search finds every form of their words, and spellings speech mangles:");
101
103
  LANGUAGES.forEach(({ name, label }, at) => {
@@ -213,6 +215,7 @@ export const setupAtStart = async (start) => {
213
215
  await askSetupQuestions({
214
216
  command: start.command,
215
217
  env: start.env,
218
+ contentLanguages: start.contentLanguages,
216
219
  all: start.path[0] === "setup",
217
220
  io: {
218
221
  ask: (prompt) => readSecret(prompt, { input, echo: true }),
@@ -100,16 +100,19 @@ const call = (command) => [
100
100
  ...commandPathOf(command),
101
101
  ...command.registeredArguments.map((one) => (one.required ? `<${one.name()}>` : `[${one.name()}]`)),
102
102
  ].join(" ");
103
+ const fieldsOf = (schema) => {
104
+ const variants = schema.anyOf;
105
+ if (variants)
106
+ return variants.flatMap(fieldsOf);
107
+ const required = schema.required ?? [];
108
+ return [...required, ...Object.keys(schema.properties ?? {})];
109
+ };
103
110
  const returnsOf = (command) => {
104
111
  const output = metaOf(command).output;
105
112
  const { item, coverage } = outputOf(command);
106
113
  if (!output || coverage !== "declared-entity")
107
114
  return short(command.summary() || command.description()) || "not declared";
108
- const required = item.required ?? [];
109
- const fields = [
110
- ...required,
111
- ...Object.keys(item.properties ?? {}).filter((name) => !required.includes(name)),
112
- ];
115
+ const fields = [...new Set(fieldsOf(item))];
113
116
  const noun = (output.view ? `${output.entity} ${output.view}` : output.entity).replaceAll("_", " ");
114
117
  const more = fields.length > 8 ? `, and ${fields.length - 8} more` : "";
115
118
  const root = rootOf(command);
@@ -64,6 +64,9 @@ const pointers = (program, cli, start, profile) => {
64
64
  ].filter((row) => row !== undefined);
65
65
  const rows = [
66
66
  ...(start ? [["Start here:", start, profile ? "(any profile name works)" : ""]] : []),
67
+ ...(has("account list")
68
+ ? [["", `${cli} account list`, "your profiles and their logged-in accounts"]]
69
+ : []),
67
70
  ...next.map(([command = "", does = ""], at) => [at === 0 ? "Next:" : "", command, does]),
68
71
  ...(has("skill show") ? [["For agents:", `${cli} skill show`, "rules and the discovery loop"]] : []),
69
72
  ];
@@ -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";
@@ -267,6 +267,7 @@ const runOnce = async (argv, definition, options) => {
267
267
  await setupAtStart({
268
268
  command,
269
269
  env: commandEnv,
270
+ contentLanguages: definition.app.contentLanguages,
270
271
  path,
271
272
  headless,
272
273
  readOnlyLock: lock,
@@ -420,10 +421,8 @@ const runOnce = async (argv, definition, options) => {
420
421
  const settled = await settleFailure(failure, { definition, program, rest, profile, options });
421
422
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
422
423
  }
423
- const words = rest.filter((word) => !word.startsWith("-"));
424
- const moved = movedPath(words, (path) => mountedOrArgument(program, path));
425
- if (moved !== undefined) {
426
- const failure = new CliError("validation_error", `\`${command} ${moved}\` is not here — ${movedPointer(moved)}`);
424
+ const failure = movedCommandError(program, rest);
425
+ if (failure !== undefined) {
427
426
  const settled = await settleFailure(failure, { definition, program, rest, profile, options });
428
427
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
429
428
  }
@@ -590,13 +589,6 @@ const keepFailure = async (failure, { definition, program, rest, profile, option
590
589
  }).catch(() => { });
591
590
  };
592
591
  /** Mounted, or reached through a command that takes no subcommand, where the rest of the path is its argument. */
593
- const mountedOrArgument = (program, path) => {
594
- const words = path.split(" ");
595
- return words.some((_, at) => {
596
- const command = commandAt(program, words.slice(0, at + 1).join(" "));
597
- return command !== undefined && (at === words.length - 1 || command.commands.length === 0);
598
- });
599
- };
600
592
  /** `messages list` from `messages list 111 --limit 5`: the words that name commands, up to the first that does not. */
601
593
  const commandPath = (program, rest) => {
602
594
  const words = [];
@@ -14,6 +14,8 @@ export const publicUrlOf = (given, { missing, reaches }) => {
14
14
  catch {
15
15
  throw new CliError("validation_error", `--public-url is not an address: ${EXAMPLE}`);
16
16
  }
17
+ if (url.username || url.password)
18
+ throw new CliError("validation_error", "--public-url must not contain login credentials");
17
19
  const local = url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname);
18
20
  if (url.protocol !== "https:" && !local)
19
21
  throw new CliError("validation_error", `--public-url must be https — ${reaches} through it`);
package/dist/mcp/agent.js CHANGED
@@ -111,7 +111,10 @@ const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
111
111
  const long = longOf(option);
112
112
  if (!long || option.mcp === false)
113
113
  continue;
114
- fields.push(optionField(option, long, longs.has(`--no-${long.slice(2)}`)));
114
+ const field = optionField(option, long, longs.has(`--no-${long.slice(2)}`));
115
+ // A positional and its flag alternative share one MCP input; emitting both supplies the value twice.
116
+ if (!fields.some((existing) => existing.name === field.name))
117
+ fields.push(field);
115
118
  }
116
119
  if (offline && info.effects?.remote === "read" && info.offline !== false)
117
120
  fields.push(OFFLINE);
@@ -1,3 +1,4 @@
1
+ export { portOf, publicUrlOf } from "../http/public-url.js";
1
2
  export { type AgentAnswer, type AgentContext, type AgentProgram, type AgentTool, type CatalogueOptions, type CommandAvailability, runCommandForAgent, toolsFromCommands, } from "./agent.js";
2
3
  export { type BotServerOptions, createBotServer, type RunBotCommand } from "./bot/server.js";
3
4
  export { revokeAll } from "./http/oauth.js";
package/dist/mcp/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Apart from the `cli` entry, so a CLI loads the MCP SDK and zod only when it serves MCP.
2
+ export { portOf, publicUrlOf } from "../http/public-url.js";
2
3
  export { runCommandForAgent, toolsFromCommands, } from "./agent.js";
3
4
  export { createBotServer } from "./bot/server.js";
4
5
  export { revokeAll } from "./http/oauth.js";
@@ -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;
@@ -14,6 +14,11 @@ export interface AppIdentity {
14
14
  issues?: string;
15
15
  /** The language of the transcript a person reads: its day headings and the word for "you". `ru-RU` when unset. */
16
16
  locale?: string;
17
+ /**
18
+ * ISO 639-1 codes of the languages its content is mostly in (`["ru"]`). The first run pre-ticks the ones search
19
+ * can stem in place of English and the system's language; the person still confirms, since the choice is shared.
20
+ */
21
+ contentLanguages?: readonly string[];
17
22
  /** Error reasons and detail keys of its own — max's `max_code`, tg's `migrated_chat_id` — kept in every error it prints. */
18
23
  errors?: AppErrors;
19
24
  }
@@ -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");
@@ -12,8 +12,11 @@ export const accountPeopleOver = (context) => {
12
12
  return {
13
13
  savePeople: async (key, people) => write(database, () => {
14
14
  const accountKey = accountPk(key);
15
- for (const person of people)
15
+ for (const person of people) {
16
16
  identityPk(accountKey, key.provider, person.id, person.name, person);
17
+ if (person.email)
18
+ personLinks.joinByAddress(context, { provider: key.provider, id: person.id }, person.email, key.provider);
19
+ }
17
20
  }),
18
21
  contacts: async (key, options) => {
19
22
  const accountKey = findAccountPk(key);
@@ -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)
@@ -14,6 +14,12 @@ export declare const linkIdentityToPerson: (context: StoreContext, identityPk: n
14
14
  export declare const linkIdentities: (context: StoreContext, person: IdentityRef, other: IdentityRef, options: LinkOptions) => PersonRecord;
15
15
  /** Moves every identity of `from` to `to`, as `linkIdentities` does once it has found both people. */
16
16
  export declare const mergePerson: (context: StoreContext, from: number, to: number, options: LinkOptions) => void;
17
+ /**
18
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
19
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
20
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
21
+ */
22
+ export declare const joinByAddress: (context: StoreContext, ref: IdentityRef, address: string, by: string) => void;
17
23
  /** Gives the identity a person of its own again; the ones left keep theirs. */
18
24
  export declare const unlinkIdentity: (context: StoreContext, ref: IdentityRef, options: LinkOptions) => PersonRecord;
19
25
  /** Messages that name the person: by id where the messenger said so, or by `@username` in the text. */
@@ -1,5 +1,6 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { and, eq, inArray, isNull, sql } from "./drizzle/core.js";
3
+ import { identityOf } from "./identities.js";
3
4
  import { resolvePersonLinks } from "./person-resolution.js";
4
5
  import { accountIdentities, accounts, chats, identities, identityLinkEvents, identityLinks, messages, persons, } from "./schema.js";
5
6
  import { hitsWhere } from "./search.js";
@@ -135,6 +136,32 @@ export const mergePerson = (context, from, to, options) => {
135
136
  for (const { identityPk } of together)
136
137
  move(context, identityPk, from, to, options);
137
138
  };
139
+ /** The owner once moved one of the two identities away from a person the other belonged to. */
140
+ const separatedByOwner = ({ database }, one, other) => database
141
+ .prepare(`SELECT 1 FROM identity_link_events moved
142
+ JOIN identity_link_events held ON held.to_person_id = moved.from_person_id
143
+ WHERE moved.author = 'owner' AND ((moved.identity_id = ? AND held.identity_id = ?)
144
+ OR (moved.identity_id = ? AND held.identity_id = ?))
145
+ LIMIT 1`)
146
+ .get(one, other, other, one) !== undefined;
147
+ /**
148
+ * Joins the identity to the person of the mail contact with this exact address, making that contact when mail has
149
+ * not seen it yet, so whichever import comes second lands on one person. The contact is seen by no account, so it is
150
+ * no one's mail correspondent. An owner's unlink wins: two identities the owner separated stay apart.
151
+ */
152
+ export const joinByAddress = (context, ref, address, by) => {
153
+ const key = address.trim().toLowerCase();
154
+ if (!/^[^\s@]+@[^\s@]+$/.test(key) || ref.provider === "email")
155
+ return;
156
+ const found = required(context, ref);
157
+ if (!identityRow(context, { provider: "email", id: key }))
158
+ identityOf(context, "email", key, null);
159
+ const contact = required(context, { provider: "email", id: key });
160
+ if (contact.personPk === found.personPk || separatedByOwner(context, found.pk, contact.pk))
161
+ return;
162
+ mergePerson(context, found.personPk, contact.personPk, { method: "same-email", by });
163
+ resolvePersonLinks(context.database, recordOf(context, contact.personPk).identities.flatMap(({ name, username }) => [name, username]));
164
+ };
138
165
  /** Gives the identity a person of its own again; the ones left keep theirs. */
139
166
  export const unlinkIdentity = (context, ref, options) => {
140
167
  const found = required(context, ref);
@@ -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
  };
@@ -32,6 +32,8 @@ export interface PersonFacts {
32
32
  username?: string | null;
33
33
  isBot?: boolean | null;
34
34
  description?: string | null;
35
+ /** An address the provider shows for them: they become one person with the mail contact of that exact address. */
36
+ email?: string | null;
35
37
  }
36
38
  /** One identity in one messenger, by the messenger's own id. `provider` is any string: `email` is one. */
37
39
  export interface IdentityRef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.413.0",
3
+ "version": "0.415.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": {