@wirecat/cli-core 0.493.0 → 0.495.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 +26 -0
- package/dist/oauth/keeper.js +26 -3
- package/dist/oauth/storage.d.ts +5 -0
- package/dist/oauth/storage.js +8 -2
- package/dist/services/chat-coverage.d.ts +1 -1
- package/dist/services/chat-coverage.js +2 -1
- package/dist/services/project-gathering.d.ts +15 -4
- package/dist/services/project-gathering.js +49 -16
- package/dist/store/chats.d.ts +2 -0
- package/dist/store/chats.js +9 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.495.0 — 31.10.2026
|
|
11
|
+
|
|
12
|
+
### Changed — may break callers
|
|
13
|
+
|
|
14
|
+
- **`gatherProjectEvidence` says what the store holds of each linked chat, and which ones the run never reached.** A new
|
|
15
|
+
optional port `coverage(account, chatIds)` (pass `chatCoverage` over the store) fills each `linkedChats` row with
|
|
16
|
+
`title` (the chat row's, also for a chat with no stored message), `completeness` and `held`; without it they are `null` and `completeness` stays `"unknown"`. The top-level
|
|
17
|
+
`completeness` is `partial` when any linked chat is `partial`, `unknown` when none is but one is `unknown` (a chat the
|
|
18
|
+
store has no row for counts), and `complete` only when every linked chat in scope is. `complete` keeps its meaning,
|
|
19
|
+
"the planned reads finished"; a host's `meta.complete` is `complete && completeness === "complete"`. A linked chat
|
|
20
|
+
none of whose reads has started is `state: "pending"` and has no `items` or `capped`, where it said `in-scope` with
|
|
21
|
+
`items: 0, capped: false`. `ChatCoverage.title` now comes from the chat row when the chat has no message, through the
|
|
22
|
+
new `store.chats.chatTitle`.
|
|
23
|
+
|
|
24
|
+
## 0.494.0 — 31.10.2026
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- **A run with moved folders no longer copies the owner's sign-in into the keyring when it refreshes a token.** Since
|
|
29
|
+
cli-base 0.65.5 such a run (`<PREFIX>_*_DIR` overrides, or `HOME` or XDG folders away from the account's home) writes
|
|
30
|
+
to a temporary entry `<app>:<config folder>`, so every refresh of an expired Google token left a full copy of the
|
|
31
|
+
owner's sign-in there until `session end`. `tokenKeeper` now keeps such a refresh in memory for the rest of the
|
|
32
|
+
process and saves nothing; a new sign-in, a token saved by the CLI's own flow, and a refresh that rotated the
|
|
33
|
+
refresh token are still saved. `credentialStorage` also no longer copies an older account's entry to the new
|
|
34
|
+
account in such a run. New optional `TokenStorage.moved`.
|
|
35
|
+
|
|
10
36
|
## 0.493.0 — 31.10.2026
|
|
11
37
|
|
|
12
38
|
### Added
|
package/dist/oauth/keeper.js
CHANGED
|
@@ -6,6 +6,7 @@ import { acquireLock } from "../files/index.js";
|
|
|
6
6
|
import { authorize } from "./authorize.js";
|
|
7
7
|
import { requestTokens, revokeToken, TOKEN_LOG_EVENT } from "./requests.js";
|
|
8
8
|
const inFlight = new Map();
|
|
9
|
+
const refreshedInMemory = new Map();
|
|
9
10
|
export const tokenKeeper = (options) => {
|
|
10
11
|
const { provider, storage, signal, log } = options;
|
|
11
12
|
const now = options.now ?? Date.now;
|
|
@@ -81,12 +82,20 @@ export const tokenKeeper = (options) => {
|
|
|
81
82
|
inFlight.set(lockPath, started);
|
|
82
83
|
return started;
|
|
83
84
|
};
|
|
85
|
+
const remembered = (saved) => {
|
|
86
|
+
const kept = refreshedInMemory.get(lockPath);
|
|
87
|
+
return kept && fresh(kept) && kept.refreshToken === saved.refreshToken ? kept : undefined;
|
|
88
|
+
};
|
|
89
|
+
const forget = () => refreshedInMemory.delete(lockPath);
|
|
84
90
|
const tokens = async () => {
|
|
85
91
|
const seen = storage.read();
|
|
86
92
|
if (!seen)
|
|
87
93
|
throw notSignedIn();
|
|
88
94
|
if (fresh(seen.tokens))
|
|
89
95
|
return seen.tokens;
|
|
96
|
+
const kept = remembered(seen.tokens);
|
|
97
|
+
if (kept)
|
|
98
|
+
return kept;
|
|
90
99
|
return shared(async () => {
|
|
91
100
|
const current = storage.read();
|
|
92
101
|
if (!current)
|
|
@@ -94,9 +103,16 @@ export const tokenKeeper = (options) => {
|
|
|
94
103
|
// Another process refreshed while this one waited for the lock: its result is the one to use.
|
|
95
104
|
if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
|
|
96
105
|
return current.tokens;
|
|
106
|
+
const keptMeanwhile = remembered(current.tokens);
|
|
107
|
+
if (keptMeanwhile)
|
|
108
|
+
return keptMeanwhile;
|
|
97
109
|
const client = clientOf(current);
|
|
98
110
|
const refreshed = await exchange({ grant_type: "refresh_token", refresh_token: current.tokens.refreshToken }, context(client, current.tokens));
|
|
99
|
-
|
|
111
|
+
// A rotated refresh token is saved even so: kept only in memory, it would die with the process.
|
|
112
|
+
if (storage.moved && refreshed.refreshToken === current.tokens.refreshToken)
|
|
113
|
+
refreshedInMemory.set(lockPath, refreshed);
|
|
114
|
+
else
|
|
115
|
+
storage.write({ tokens: refreshed, client });
|
|
100
116
|
return refreshed;
|
|
101
117
|
});
|
|
102
118
|
};
|
|
@@ -119,11 +135,18 @@ export const tokenKeeper = (options) => {
|
|
|
119
135
|
code_verifier: authorization.codeVerifier,
|
|
120
136
|
}, context(client));
|
|
121
137
|
const extra = (await accept?.(received)) ?? undefined;
|
|
122
|
-
await locked(async () =>
|
|
138
|
+
await locked(async () => {
|
|
139
|
+
forget();
|
|
140
|
+
storage.write({ tokens: received, client, ...(extra ? { extra } : {}) });
|
|
141
|
+
});
|
|
123
142
|
return { scopes: received.scopes };
|
|
124
143
|
},
|
|
125
|
-
save: (saved, extra) => locked(async () =>
|
|
144
|
+
save: (saved, extra) => locked(async () => {
|
|
145
|
+
forget();
|
|
146
|
+
storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) });
|
|
147
|
+
}),
|
|
126
148
|
signOut: ({ revoke = false } = {}) => locked(async () => {
|
|
149
|
+
forget();
|
|
127
150
|
const current = storage.read();
|
|
128
151
|
let revoked = false;
|
|
129
152
|
if (revoke && current) {
|
package/dist/oauth/storage.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface TokenCodec {
|
|
|
21
21
|
export interface TokenStorage {
|
|
22
22
|
/** Names the entry across processes; the lock is keyed by it. */
|
|
23
23
|
readonly key: string;
|
|
24
|
+
/**
|
|
25
|
+
* A run with moved folders: cli-base sends its writes to a temporary keyring entry, so a refresh is kept in memory
|
|
26
|
+
* instead of copying the owner's sign-in there. Only a new sign-in is saved.
|
|
27
|
+
*/
|
|
28
|
+
readonly moved?: boolean;
|
|
24
29
|
/** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
|
|
25
30
|
probe(): void;
|
|
26
31
|
read(): StoredTokens | undefined;
|
package/dist/oauth/storage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CliError, Credentials, keyringService, } from "@wirecat/cli-base";
|
|
1
|
+
import { CliError, Credentials, isMovedRun, keyringService, systemKeyring, } from "@wirecat/cli-base";
|
|
2
2
|
const invalid = () => new CliError("authentication_error", "the saved sign-in cannot be read; sign in again", { reason: "oauth.invalid" });
|
|
3
3
|
const parse = (raw) => {
|
|
4
4
|
let value;
|
|
@@ -149,6 +149,10 @@ export const credentialStorage = (options) => {
|
|
|
149
149
|
throw new CliError("configuration_error", "a sign-in store needs its keyring passed in, never a default");
|
|
150
150
|
const credentials = new Credentials(options.credentials);
|
|
151
151
|
const { account, codec } = options;
|
|
152
|
+
// cli-base also counts a moved HOME as moved, but only with the system keyring; the sandboxed tests move HOME.
|
|
153
|
+
const moved = options.credentials.isolated === true ||
|
|
154
|
+
(options.credentials.keyring === systemKeyring &&
|
|
155
|
+
isMovedRun({ appName: options.credentials.service, env: options.credentials.env ?? process.env }));
|
|
152
156
|
const service = keyringService(options.credentials.service, options.credentials.configDir, options.credentials.isolated);
|
|
153
157
|
const raw = (name) => {
|
|
154
158
|
try {
|
|
@@ -175,13 +179,15 @@ export const credentialStorage = (options) => {
|
|
|
175
179
|
if (found === undefined)
|
|
176
180
|
continue;
|
|
177
181
|
codec.decode(found);
|
|
178
|
-
|
|
182
|
+
if (!moved)
|
|
183
|
+
save(found);
|
|
179
184
|
return found;
|
|
180
185
|
}
|
|
181
186
|
return undefined;
|
|
182
187
|
};
|
|
183
188
|
return {
|
|
184
189
|
key: `${service}\u0000${account}`,
|
|
190
|
+
moved,
|
|
185
191
|
probe: () => {
|
|
186
192
|
raw(account);
|
|
187
193
|
},
|
|
@@ -3,7 +3,7 @@ import type { AccountKey, ChatCompleteness, MessageStore } from "../store/store.
|
|
|
3
3
|
/** What the store holds of one chat, and how far that goes: never message text. */
|
|
4
4
|
export interface ChatCoverage {
|
|
5
5
|
chatId: Id;
|
|
6
|
-
/** `null`
|
|
6
|
+
/** The chat row's title; `null` when the store has no row for the chat or the row has none. */
|
|
7
7
|
title: string | null;
|
|
8
8
|
completeness: ChatCompleteness;
|
|
9
9
|
held: {
|
|
@@ -9,6 +9,7 @@ export const chatCoverage = async (store, account, chatIds) => {
|
|
|
9
9
|
const found = new Map((await store.chats.chatCompleteness(account, ids)).map((one) => [one.chatId, one]));
|
|
10
10
|
return Promise.all(ids.map(async (chatId) => {
|
|
11
11
|
const [stats] = await store.chats.chatStats(account, chatId);
|
|
12
|
+
const title = stats?.title ?? (await store.chats.chatTitle(account, chatId));
|
|
12
13
|
const completeness = found.get(chatId) ?? {
|
|
13
14
|
chatId,
|
|
14
15
|
state: "unknown",
|
|
@@ -19,7 +20,7 @@ export const chatCoverage = async (store, account, chatIds) => {
|
|
|
19
20
|
};
|
|
20
21
|
return {
|
|
21
22
|
chatId,
|
|
22
|
-
title
|
|
23
|
+
title,
|
|
23
24
|
completeness,
|
|
24
25
|
held: { messages: stats?.messages ?? 0, oldestAt: stats?.oldestAt ?? null, newestAt: stats?.newestAt ?? null },
|
|
25
26
|
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ContextEvidenceItem, ContextEvidenceReader, ContextIndex, ContextSource } from "../context/evidence.js";
|
|
2
|
+
import type { AccountKey } from "../store/types.js";
|
|
3
|
+
import type { ChatCoverage } from "./chat-coverage.js";
|
|
2
4
|
import { type ProjectNeighbour, type ProjectNeighbourReader } from "./project-gathering-around.js";
|
|
3
5
|
import { type ProjectFetchState, type ProjectGatheringRequest, type ProjectLane } from "./project-gathering-request.js";
|
|
4
6
|
/** A connector's history fetch into the store; the store reads that follow find what it saved. */
|
|
@@ -18,6 +20,8 @@ export interface ProjectGatheringPorts {
|
|
|
18
20
|
fetcher?: ProjectSourceFetcher;
|
|
19
21
|
index?: ContextIndex;
|
|
20
22
|
neighbours?: ProjectNeighbourReader;
|
|
23
|
+
/** What the store holds of the named chats of one account, e.g. `chatCoverage` over the store. */
|
|
24
|
+
coverage?: (account: AccountKey, chatIds: string[]) => Promise<ChatCoverage[]>;
|
|
21
25
|
now?: () => number;
|
|
22
26
|
}
|
|
23
27
|
export interface ProjectIndexCatchUp {
|
|
@@ -88,9 +92,16 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
|
|
|
88
92
|
ref: string;
|
|
89
93
|
source: number | null;
|
|
90
94
|
lane: string | null;
|
|
91
|
-
state: "in-scope" | "out-of-scope" | "unavailable";
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
state: "in-scope" | "out-of-scope" | "pending" | "unavailable";
|
|
96
|
+
title: string | null;
|
|
97
|
+
completeness: import("../store/exports.js").ChatCompleteness | null;
|
|
98
|
+
held: {
|
|
99
|
+
messages: number;
|
|
100
|
+
oldestAt: string | null;
|
|
101
|
+
newestAt: string | null;
|
|
102
|
+
} | null;
|
|
103
|
+
items?: number | undefined;
|
|
104
|
+
capped?: boolean | undefined;
|
|
94
105
|
}[];
|
|
95
106
|
fetch: {
|
|
96
107
|
state: ProjectFetchState;
|
|
@@ -136,7 +147,7 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
|
|
|
136
147
|
};
|
|
137
148
|
stopped: ProjectGatheringStop;
|
|
138
149
|
complete: boolean;
|
|
139
|
-
completeness: "unknown";
|
|
150
|
+
completeness: "complete" | "partial" | "unknown";
|
|
140
151
|
checkpoint: string | null;
|
|
141
152
|
}>;
|
|
142
153
|
export type ProjectGathering = Awaited<ReturnType<typeof gatherProjectEvidence>>;
|
|
@@ -264,6 +264,20 @@ export const gatherProjectEvidence = async (ports, request) => {
|
|
|
264
264
|
},
|
|
265
265
|
}, (ref) => byRef.has(ref) || seen.has(seenKey(ref)), signal);
|
|
266
266
|
const people = peopleOf(items, senderOf);
|
|
267
|
+
const coverageOf = new Map();
|
|
268
|
+
if (ports.coverage) {
|
|
269
|
+
const byAccount = new Map();
|
|
270
|
+
for (const { provider, account, chat, source } of plan.linkedChats) {
|
|
271
|
+
if (source === null)
|
|
272
|
+
continue;
|
|
273
|
+
const group = byAccount.get(JSON.stringify([provider, account])) ?? { account: { provider, account }, chats: [] };
|
|
274
|
+
group.chats.push(chat);
|
|
275
|
+
byAccount.set(JSON.stringify([provider, account]), group);
|
|
276
|
+
}
|
|
277
|
+
for (const [key, { account, chats }] of byAccount)
|
|
278
|
+
for (const row of await ports.coverage(account, chats))
|
|
279
|
+
coverageOf.set(`${key}${row.chatId}`, row);
|
|
280
|
+
}
|
|
267
281
|
const totals = {
|
|
268
282
|
reads: (previous?.totals.reads ?? 0) + usage.reads,
|
|
269
283
|
readBytes: (previous?.totals.readBytes ?? 0) + usage.readBytes,
|
|
@@ -284,6 +298,39 @@ export const gatherProjectEvidence = async (ports, request) => {
|
|
|
284
298
|
seen: [...seen],
|
|
285
299
|
totals,
|
|
286
300
|
};
|
|
301
|
+
const linkedRows = plan.linkedChats.map(({ provider, account, chat, ref, source, lane }) => {
|
|
302
|
+
const laneUnits = units.flatMap((unit, index) => (unit.lane.id === lane ? [progress[index]] : []));
|
|
303
|
+
const state = source === null
|
|
304
|
+
? "out-of-scope"
|
|
305
|
+
: laneUnits.every((u) => u?.state === "unavailable")
|
|
306
|
+
? "unavailable"
|
|
307
|
+
: laneUnits.every((u) => u?.state === "pending")
|
|
308
|
+
? "pending"
|
|
309
|
+
: "in-scope";
|
|
310
|
+
const row = coverageOf.get(`${JSON.stringify([provider, account])}${chat}`);
|
|
311
|
+
return {
|
|
312
|
+
provider,
|
|
313
|
+
account,
|
|
314
|
+
chat,
|
|
315
|
+
ref,
|
|
316
|
+
source,
|
|
317
|
+
lane,
|
|
318
|
+
state,
|
|
319
|
+
title: row?.title ?? null,
|
|
320
|
+
completeness: row?.completeness ?? null,
|
|
321
|
+
held: row?.held ?? null,
|
|
322
|
+
...(state === "pending" ? {} : { items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0) }),
|
|
323
|
+
...(state === "pending" ? {} : { capped: lane !== null && capped.has(lane) }),
|
|
324
|
+
};
|
|
325
|
+
});
|
|
326
|
+
const states = linkedRows.flatMap((row) => (row.completeness ? [row.completeness.state] : []));
|
|
327
|
+
const completeness = !states.length
|
|
328
|
+
? "unknown"
|
|
329
|
+
: states.includes("partial")
|
|
330
|
+
? "partial"
|
|
331
|
+
: states.includes("unknown") || states.length < linkedRows.filter((row) => row.source !== null).length
|
|
332
|
+
? "unknown"
|
|
333
|
+
: "complete";
|
|
287
334
|
return {
|
|
288
335
|
schemaVersion: 1,
|
|
289
336
|
runKey: plan.runKey,
|
|
@@ -300,21 +347,7 @@ export const gatherProjectEvidence = async (ports, request) => {
|
|
|
300
347
|
},
|
|
301
348
|
scope: { origin: plan.scope, sources: plan.sources },
|
|
302
349
|
lanes: plan.lanes,
|
|
303
|
-
linkedChats:
|
|
304
|
-
provider,
|
|
305
|
-
account,
|
|
306
|
-
chat,
|
|
307
|
-
ref,
|
|
308
|
-
source,
|
|
309
|
-
lane,
|
|
310
|
-
state: source === null
|
|
311
|
-
? "out-of-scope"
|
|
312
|
-
: units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
|
|
313
|
-
? "unavailable"
|
|
314
|
-
: "in-scope",
|
|
315
|
-
items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0),
|
|
316
|
-
capped: lane !== null && capped.has(lane),
|
|
317
|
-
})),
|
|
350
|
+
linkedChats: linkedRows,
|
|
318
351
|
fetch: fetched.map((f, index) => ({ source: index, ...f })),
|
|
319
352
|
index: indexed,
|
|
320
353
|
units: units.map((unit, index) => ({
|
|
@@ -335,7 +368,7 @@ export const gatherProjectEvidence = async (ports, request) => {
|
|
|
335
368
|
totals,
|
|
336
369
|
stopped,
|
|
337
370
|
complete: !resumable,
|
|
338
|
-
completeness
|
|
371
|
+
completeness,
|
|
339
372
|
checkpoint: resumable ? encodeCheckpoint(checkpoint) : null,
|
|
340
373
|
};
|
|
341
374
|
};
|
package/dist/store/chats.d.ts
CHANGED
|
@@ -66,6 +66,8 @@ export interface ChatsStore {
|
|
|
66
66
|
renamedChatId(key: AccountKey, chatId: Id): Promise<Id | undefined>;
|
|
67
67
|
/** A stored chat's kind, with or without messages; `undefined` when the store does not know the chat. */
|
|
68
68
|
chatKind(key: AccountKey, chatId: Id): Promise<ChatKind | undefined>;
|
|
69
|
+
/** A stored chat's title, with or without messages; `null` when it has none or the store does not know the chat. */
|
|
70
|
+
chatTitle(key: AccountKey, chatId: Id): Promise<string | null>;
|
|
69
71
|
/** Per chat that spoke since `since` (ms): its newest message and who sent it when stored, never the text. */
|
|
70
72
|
/** `where` is a `tasks agenda` query compiled over `AGENDA_COLUMNS`. */
|
|
71
73
|
chatActivity(key: AccountKey, since: number, where?: Where): Promise<{
|
package/dist/store/chats.js
CHANGED
|
@@ -94,6 +94,15 @@ export const chatsStoreOver = (context) => {
|
|
|
94
94
|
.get(accountKey, chatId);
|
|
95
95
|
return row ? String(row.kind) : undefined;
|
|
96
96
|
},
|
|
97
|
+
chatTitle: async (key, chatId) => {
|
|
98
|
+
const accountKey = findAccountPk(key);
|
|
99
|
+
if (accountKey === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
const row = context.database
|
|
102
|
+
.prepare("SELECT title FROM chats WHERE account_id = ? AND external_id = ?")
|
|
103
|
+
.get(accountKey, chatId);
|
|
104
|
+
return row?.title == null ? null : String(row.title);
|
|
105
|
+
},
|
|
97
106
|
chatActivity: async (key, since, where) => {
|
|
98
107
|
const accountKey = findAccountPk(key);
|
|
99
108
|
return accountKey === undefined
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.495.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",
|
|
@@ -247,7 +247,7 @@
|
|
|
247
247
|
"@types/node": "^22.20.5",
|
|
248
248
|
"@types/pngjs": "6.0.5",
|
|
249
249
|
"@vitest/coverage-v8": "^5.0.5",
|
|
250
|
-
"@wirecat/cli-base": "0.65.
|
|
250
|
+
"@wirecat/cli-base": "0.65.5",
|
|
251
251
|
"@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
|
|
252
252
|
"commander": "15.0.0",
|
|
253
253
|
"cspell": "10.4.0",
|