@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 +19 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/messenger/context.d.ts +1 -1
- package/dist/cli/messenger/context.js +6 -6
- package/dist/cli/messenger/store-maintenance-command.js +1 -1
- package/dist/cli/moved.d.ts +4 -0
- package/dist/cli/moved.js +16 -0
- package/dist/cli/program.js +3 -12
- package/dist/mcp/server.js +1 -1
- package/dist/services/project-gathering.js +7 -3
- package/dist/store/migrations.d.ts +4 -2
- package/dist/store/migrations.js +16 -2
- package/dist/store/sqlite/clients.d.ts +26 -0
- package/dist/store/sqlite/clients.js +79 -0
- package/dist/store/sqlite/open-store.d.ts +5 -1
- package/dist/store/sqlite/open-store.js +5 -3
- package/dist/store/sqlite/read-compatibility.d.ts +1 -1
- package/dist/store/sqlite/read-compatibility.js +3 -2
- package/package.json +3 -3
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
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -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));
|
package/dist/cli/moved.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/cli/program.js
CHANGED
|
@@ -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 {
|
|
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
|
|
425
|
-
|
|
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 = [];
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
90
|
-
//
|
|
91
|
-
|
|
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
|
-
}) =>
|
|
34
|
+
}) => string | undefined;
|
package/dist/store/migrations.js
CHANGED
|
@@ -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}) —
|
|
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
|
-
|
|
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};
|
|
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.
|
|
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.
|
|
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.
|
|
236
|
+
"@wirecat/cli-base": ">=0.53.0 <0.54.0",
|
|
237
237
|
"commander": ">=15"
|
|
238
238
|
},
|
|
239
239
|
"bin": {
|