@wirecat/cli-core 0.535.0 → 0.537.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.537.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`tokenKeeper.tokens({ refresh: true })`** (and `accessToken({ refresh: true })`): after a server refuses an
15
+ access token whose `expiresAt` still called it fresh (a revoked session), refresh under the shared lock anyway. A
16
+ different fresh token another process saved meanwhile is answered instead of a second refresh.
17
+
18
+ ## 0.536.0 — 01.11.2026
19
+
20
+ ### Fixed
21
+
22
+ - **A server mail search that ran out of time says so.** When the server's own deadline cut a search or the read of
23
+ its finds short, `searchMailServer` reported `search.server-failed` (`provider_unavailable`), as if the server had
24
+ refused. It now reports `search.server-timeout` (`timeout`), as when `--server-time` ends it, so a caller knows to
25
+ give it more time.
26
+
10
27
  ## 0.535.0 — 01.11.2026
11
28
 
12
29
  ### Added
@@ -18,6 +18,12 @@ const refusal = {
18
18
  no_words: "--backend server needs words, people, a subject, a date or a state to send to the server",
19
19
  };
20
20
  const BOUND = Symbol("bound");
21
+ /** A call the server's own deadline cut short is the time bound's, as one cut by --server-time is. */
22
+ const failureOf = (error) => error instanceof CliError && error.code === "rate_limited"
23
+ ? "rate_limited"
24
+ : error instanceof CliError && error.code === "timeout"
25
+ ? "time_or_abort_bound"
26
+ : "search_failed";
21
27
  /** The newest first: a server's UIDs grow as mail arrives in the folder. */
22
28
  const newest = (uids, count) => [...uids].sort((one, other) => Number(other) - Number(one)).slice(0, count);
23
29
  /**
@@ -112,7 +118,7 @@ export const searchMailServer = async (store, mailbox, access, request) => {
112
118
  if (!open)
113
119
  return;
114
120
  pending.delete(call);
115
- fail(call, error instanceof CliError && error.code === "rate_limited" ? "rate_limited" : "search_failed");
121
+ fail(call, failureOf(error));
116
122
  }
117
123
  }));
118
124
  if (!open)
@@ -143,10 +149,7 @@ export const searchMailServer = async (store, mailbox, access, request) => {
143
149
  });
144
150
  let outcome;
145
151
  try {
146
- outcome = await Promise.race([
147
- run().then(() => "done", () => "failed"),
148
- bound,
149
- ]);
152
+ outcome = await Promise.race([run().then(() => "done", failureOf), bound]);
150
153
  }
151
154
  finally {
152
155
  open = false;
@@ -158,12 +161,13 @@ export const searchMailServer = async (store, mailbox, access, request) => {
158
161
  throw openFailure;
159
162
  if (unsupported)
160
163
  return skip("unsupported");
164
+ const ended = outcome === BOUND || outcome === "done" ? "time_or_abort_bound" : outcome;
161
165
  for (const call of pending)
162
- fail(call, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
166
+ fail(call, ended);
163
167
  if (outcome !== "done" || (fresh.length > 0 && raws === undefined)) {
164
168
  // What was found but not read in time is the archive's to answer: none of it is claimed as the server's.
165
169
  if (pending.size === 0)
166
- fail(null, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
170
+ fail(null, ended);
167
171
  for (const item of fresh)
168
172
  sources.delete(sourceKey(item.threadId, item.messageId));
169
173
  report.returned = sources.size;
@@ -1,5 +1,5 @@
1
1
  export { type Authorization, type AuthorizeOptions, authorize } from "./authorize.js";
2
- export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, tokenKeeper } from "./keeper.js";
2
+ export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, type TokensOptions, tokenKeeper, } from "./keeper.js";
3
3
  export type { ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
4
4
  export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
5
5
  export { type CredentialStorageOptions, credentialStorage, driveSessionCodec, type EncodeInput, oauthAccount, type StoredTokens, standardCodec, type TokenCodec, type TokenStorage, zoomTokenCodec, } from "./storage.js";
@@ -1,4 +1,4 @@
1
1
  export { authorize } from "./authorize.js";
2
- export { tokenKeeper } from "./keeper.js";
2
+ export { tokenKeeper, } from "./keeper.js";
3
3
  export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
4
4
  export { credentialStorage, driveSessionCodec, oauthAccount, standardCodec, zoomTokenCodec, } from "./storage.js";
@@ -23,9 +23,16 @@ export type SignInOptions = Omit<AuthorizeOptions, "provider" | "signal"> & {
23
23
  /** Runs before anything is saved; it may refuse the account or add fields to the entry (drive's `principal`). */
24
24
  accept?: (tokens: TokenSet) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined;
25
25
  };
26
+ export interface TokensOptions {
27
+ /**
28
+ * The server refused the access token this keeper last answered, though `expiresAt` called it fresh (a revoked
29
+ * session): refresh under the lock anyway, unless another process already saved a different fresh one.
30
+ */
31
+ refresh?: boolean;
32
+ }
26
33
  export interface TokenKeeper {
27
- accessToken(): Promise<string>;
28
- tokens(): Promise<TokenSet>;
34
+ accessToken(options?: TokensOptions): Promise<string>;
35
+ tokens(options?: TokensOptions): Promise<TokenSet>;
29
36
  signIn(options: SignInOptions): Promise<{
30
37
  scopes: string[];
31
38
  }>;
@@ -87,25 +87,39 @@ export const tokenKeeper = (options) => {
87
87
  return kept && fresh(kept) && kept.refreshToken === saved.refreshToken ? kept : undefined;
88
88
  };
89
89
  const forget = () => refreshedInMemory.delete(lockPath);
90
- const tokens = async () => {
90
+ let answered;
91
+ const answer = (given) => {
92
+ answered = given;
93
+ return given;
94
+ };
95
+ const tokens = async ({ refresh = false } = {}) => {
91
96
  const seen = storage.read();
92
97
  if (!seen)
93
98
  throw notSignedIn();
94
- if (fresh(seen.tokens))
95
- return seen.tokens;
96
- const kept = remembered(seen.tokens);
97
- if (kept)
98
- return kept;
99
- return shared(async () => {
99
+ if (!refresh) {
100
+ const kept = remembered(seen.tokens);
101
+ if (kept)
102
+ return answer(kept);
103
+ if (fresh(seen.tokens))
104
+ return answer(seen.tokens);
105
+ }
106
+ const refused = answered ?? remembered(seen.tokens) ?? seen.tokens;
107
+ return answer(await shared(async () => {
100
108
  const current = storage.read();
101
109
  if (!current)
102
110
  throw notSignedIn();
111
+ const latest = remembered(current.tokens) ?? current.tokens;
103
112
  // Another process refreshed while this one waited for the lock: its result is the one to use.
104
- if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
105
- return current.tokens;
106
- const keptMeanwhile = remembered(current.tokens);
107
- if (keptMeanwhile)
108
- return keptMeanwhile;
113
+ if (refresh) {
114
+ if (latest.accessToken !== refused.accessToken && fresh(latest))
115
+ return latest;
116
+ }
117
+ else {
118
+ if (latest !== current.tokens)
119
+ return latest;
120
+ if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
121
+ return current.tokens;
122
+ }
109
123
  const client = clientOf(current);
110
124
  const refreshed = await exchange({ grant_type: "refresh_token", refresh_token: current.tokens.refreshToken }, context(client, current.tokens));
111
125
  // A rotated refresh token is saved even so: kept only in memory, it would die with the process.
@@ -114,12 +128,12 @@ export const tokenKeeper = (options) => {
114
128
  else
115
129
  storage.write({ tokens: refreshed, client });
116
130
  return refreshed;
117
- });
131
+ }));
118
132
  };
119
133
  return {
120
134
  tokens,
121
- accessToken: async () => {
122
- const current = await tokens();
135
+ accessToken: async (tokensOptions) => {
136
+ const current = await tokens(tokensOptions);
123
137
  if (!current.accessToken)
124
138
  throw notSignedIn();
125
139
  return current.accessToken;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.535.0",
3
+ "version": "0.537.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",