@wirecat/cli-base 0.67.0 → 0.68.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
@@ -8,6 +8,38 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.68.0 — 31.10.2026
12
+
13
+ ### Removed
14
+
15
+ - **WC-REL-10: a messenger CLI is released after the owner's yes in the conversation, with no signed report.**
16
+ `bin/release` in max-cli and whatsapp-cli no longer reads a release report or a `Signed off:` line. The agent asks
17
+ the owner before it runs `bin/release`; one yes covers one version.
18
+
19
+ ### Changed — may break callers
20
+
21
+ - **`Credentials` in `auto` does the whole file fallback itself (WC-SECR-4).** A secret the file holds is read from the
22
+ file before the keyring, so a locked keyring is not tried for it, and a refresh `write` keeps it there. A sign-in
23
+ passes `write(account, secret, { signIn: true })`: it tries the keyring and, once the keyring takes it, removes the
24
+ file copy. The "could not reach the OS keyring" warning comes once per run (per `env` object), not once per
25
+ `Credentials`. Watch for: a file copy left next to a newer keyring entry by an older CLI now wins the read; sign in
26
+ again to move it back. CLIs can drop their own file-first read, "remove the file copy" step and `warnOnce`.
27
+ - **WC-SECR-4 is the one credential-storage standard.** The keyring by default; when it fails, the file without
28
+ asking, one warning on stderr, and the answer that saved the secret says where it went;
29
+ `--credential-storage file` on the sign-in command chooses the file and is discouraged; a secret in the file is
30
+ read from there until a sign-in reaches the keyring again. No CLI asks, none is keyring-only. `Credentials` already
31
+ falls back this way in `auto`; the rule now says every CLI must use it.
32
+
33
+ ### Fixed
34
+
35
+ - **`Credentials.remove` with `storage: "keyring"` leaves the file alone.** It also deleted the account from the
36
+ credentials file, so a CLI that cleared the keyring copy after a file sign-in lost the sign-in it had just saved.
37
+
38
+ ### Added
39
+
40
+ - **`--credential-storage <storage>` in the flag vocabulary** (role `mode`), so `cli-dev lint` accepts it on a sign-in
41
+ command without a per-CLI override.
42
+
11
43
  ## 0.67.0 — 31.10.2026
12
44
 
13
45
  ### Added
@@ -38,17 +38,25 @@ export declare const keyringService: (service: string, _configDir?: string, _iso
38
38
  * shape `bin/keyring-dedupe` already finds and removes.
39
39
  */
40
40
  export declare const movedRunService: (service: string, configDir: string) => string;
41
+ export interface WriteOptions {
42
+ /**
43
+ * A new sign-in rather than a refresh: it tries the keyring even when the file holds the account, and once the
44
+ * keyring takes it, removes the file copy (WC-SECR-4).
45
+ */
46
+ signIn?: boolean;
47
+ }
41
48
  /**
42
- * Environment first, then the OS keyring, then a file.
49
+ * Environment first, then the OS keyring, then a file. In `auto`, a file that holds the account is read before the
50
+ * keyring and keeps its refreshes, so a locked keyring is not tried again for it; a sign-in moves it back.
43
51
  *
44
52
  * `auto` does not probe whether a keyring exists: it attempts the operation, and on failure warns
45
- * once and falls through. A probe would touch the user's keychain for nothing and could still
53
+ * once per run and falls through. A probe would touch the user's keychain for nothing and could still
46
54
  * succeed where the real operation fails.
47
55
  */
48
56
  export declare class Credentials {
49
57
  #private;
50
58
  constructor(options: CredentialsOptions);
51
59
  read(account: string): StoredCredential | undefined;
52
- write(account: string, secret: string): CredentialSource;
60
+ write(account: string, secret: string, options?: WriteOptions): CredentialSource;
53
61
  remove(account: string): CredentialSource[];
54
62
  }
@@ -15,11 +15,14 @@ export const keyringService = (service, _configDir, _isolated) => service;
15
15
  * shape `bin/keyring-dedupe` already finds and removes.
16
16
  */
17
17
  export const movedRunService = (service, configDir) => `${service}:${resolve(configDir)}`;
18
+ // One warning per run, not per instance: a run over several profiles builds one Credentials for each.
19
+ const warnedRuns = new WeakSet();
18
20
  /**
19
- * Environment first, then the OS keyring, then a file.
21
+ * Environment first, then the OS keyring, then a file. In `auto`, a file that holds the account is read before the
22
+ * keyring and keeps its refreshes, so a locked keyring is not tried again for it; a sign-in moves it back.
20
23
  *
21
24
  * `auto` does not probe whether a keyring exists: it attempts the operation, and on failure warns
22
- * once and falls through. A probe would touch the user's keychain for nothing and could still
25
+ * once per run and falls through. A probe would touch the user's keychain for nothing and could still
23
26
  * succeed where the real operation fails.
24
27
  */
25
28
  export class Credentials {
@@ -33,7 +36,6 @@ export class Credentials {
33
36
  #warn;
34
37
  /** Set only for a moved run with the system keyring. */
35
38
  #temporaryService;
36
- #warned = false;
37
39
  constructor(options) {
38
40
  this.#configDir = options.configDir;
39
41
  this.#storage = options.storage ?? "auto";
@@ -50,23 +52,29 @@ export class Credentials {
50
52
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
51
53
  if (fromEnv)
52
54
  return { secret: fromEnv, source: "environment" };
55
+ const fromFile = this.#storage === "keyring" ? undefined : this.#readFile()?.[account]?.secret;
56
+ if (fromFile)
57
+ return { secret: fromFile, source: "file" };
53
58
  if (this.#storage !== "file") {
54
59
  const temporary = this.#temporaryService;
55
60
  const fromKeyring = this.#tryKeyring(() => (temporary && this.#keyring.get(temporary, account)) || this.#keyring.get(this.#service, account));
56
61
  if (fromKeyring)
57
62
  return { secret: fromKeyring, source: "keyring" };
58
63
  }
59
- const secret = this.#readFile()?.[account]?.secret;
60
- return secret ? { secret, source: "file" } : undefined;
64
+ return undefined;
61
65
  }
62
- write(account, secret) {
63
- if (this.#storage !== "file") {
66
+ write(account, secret, options = {}) {
67
+ const inFile = this.#storage === "auto" && this.#readFile()?.[account] !== undefined;
68
+ if (this.#storage === "keyring" || (this.#storage === "auto" && (options.signIn || !inFile))) {
64
69
  const stored = this.#tryKeyring(() => {
65
70
  this.#keyring.set(this.#temporaryService ?? this.#service, account, secret);
66
71
  return true;
67
72
  });
68
- if (stored)
73
+ if (stored) {
74
+ if (inFile)
75
+ this.#removeFromFile(account);
69
76
  return "keyring";
77
+ }
70
78
  }
71
79
  const store = this.#readFile();
72
80
  if (!store) {
@@ -91,14 +99,18 @@ export class Credentials {
91
99
  "moved folders never removes it");
92
100
  }
93
101
  }
94
- const store = this.#readFile();
95
- if (store?.[account] !== undefined) {
96
- delete store[account];
97
- this.#writeFile(store);
102
+ if (this.#storage !== "keyring" && this.#removeFromFile(account))
98
103
  removed.push("file");
99
- }
100
104
  return removed;
101
105
  }
106
+ #removeFromFile(account) {
107
+ const store = this.#readFile();
108
+ if (store?.[account] === undefined)
109
+ return false;
110
+ delete store[account];
111
+ this.#writeFile(store);
112
+ return true;
113
+ }
102
114
  /** Only for the warning: a keyring that fails here must not fail a removal that already happened. */
103
115
  #ownersEntryExists(account) {
104
116
  try {
@@ -141,8 +153,8 @@ export class Credentials {
141
153
  return operation();
142
154
  }
143
155
  catch (error) {
144
- if (!this.#warned) {
145
- this.#warned = true;
156
+ if (!warnedRuns.has(this.#env)) {
157
+ warnedRuns.add(this.#env);
146
158
  this.#warn(`could not reach the OS keyring — it may be locked, or no keyring service is running ` +
147
159
  `(${error instanceof Error ? error.message : String(error)}); ` +
148
160
  `using ${this.#path()} instead, readable only by you`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BATCH_LIMITS, type BatchContext, type BatchHandler, type BatchOperation, type BatchReport, type BatchResult, type PreparedBatchRequest, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
- export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential } from "./credentials.js";
3
+ export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential, WriteOptions, } from "./credentials.js";
4
4
  export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
5
5
  export { createDeadline, type Deadline, type DeadlineOptions } from "./deadline.js";
6
6
  export { CliError, type CliErrorDetails, type ErrorCode, errorCodes, isCliError } from "./errors.js";
@@ -375,6 +375,13 @@ export const HOW_FLAGS = [
375
375
  type: "string",
376
376
  meaning: "the browser program to open for sign-in",
377
377
  },
378
+ {
379
+ name: "--credential-storage",
380
+ value: "<storage>",
381
+ role: "mode",
382
+ type: "choice",
383
+ meaning: "where a sign-in is kept: keyring, which falls back to a private file by itself, or file (WC-SECR-4)",
384
+ },
378
385
  { name: "--checkpoint", value: "<file>", role: "mode", type: "path", meaning: "keep resume progress in this file" },
379
386
  {
380
387
  name: "--expected-event-id",
@@ -42,7 +42,7 @@ the four steps that need a person. A new CLI is ready for its first release when
42
42
  | [Security](security.md) | `SECR` | 23 | Secrets, logs, keyring, argv, error text, untrusted text, scanners, workflow hardening, reporting, OAuth sign-in, meeting secrets, keyring in moved-folder runs |
43
43
  | [Documents](docs.md) | `DOC` | 20 | User pages and developer pages, generated pages, current facts, changelog |
44
44
  | [Comparisons](comparisons.md) | `CMP` | 11 | Which tools a comparison page lists, rows as user tasks, agents and safety, where others are better, proof of marks |
45
- | [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, release reports, trusted publishing |
45
+ | [Releases](releases.md) | `REL` | 15 | `bin/release`, the release lock, versions, the owner's yes, trusted publishing |
46
46
  | [CI](ci.md) | `CI` | 12 | Hooks, the pull request budget, full validation, monthly runs |
47
47
  | [Time](time.md) | `TIME` | 6 | UTC in the store and in answers, the zone at input and display edges, date-only filters, tests in another zone |
48
48
  | [Style](style.md) | `STYLE` | 10 | Comments, TypeScript habits, commit messages |
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Scope
6
6
 
7
- How a CLI is released: `bin/release`, the release lock, version numbers, the release report and trusted publishing.
7
+ How a CLI is released: `bin/release`, the release lock, version numbers, the owner's yes and trusted publishing.
8
8
 
9
9
  Rule ids on this page are `WC-REL-<n>`. How to read a rule, how ids work and which rule wins are in the
10
10
  [index](README.md). The changelog a release dates is on the [Documents](docs.md) page. The checks a release runs are
@@ -106,17 +106,16 @@ on the [CI](ci.md) page.
106
106
 
107
107
  ### WC-REL-9 · Merged into [WC-REL-8](#wc-rel-8--any-package-may-release-at-any-time)
108
108
 
109
- ### WC-REL-10 · A messenger CLI is released only with a release report the owner signed for that commit
110
-
111
- - **Why:** the judgement half of a release (changelog wording, docs against the change, live checks) has no other
112
- record that it ran for this exact commit.
113
- - **Enforced by:** `max-cli: bin/release` · `whatsapp-cli: bin/release`.
114
- - **Example:** max-cli's and whatsapp-cli's `bin/release` refuse unless the report has `Commit: <HEAD>` and
115
- `Signed off: <date>`. When `bin/release` only renumbers or folds the changelog, it carries the report to the new
116
- commit. A fork passes its own report with `bin/release --report <path>`.
117
- - **Exceptions:** none. max-cli and whatsapp-cli (from 0.1.0) check the report in `bin/release`. tg-cli has the
118
- release skill, but its `bin/release` does not check a report yet.
119
- - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
109
+ ### WC-REL-10 · A messenger CLI is released only after the owner says yes in the conversation
110
+
111
+ - **Why:** a messenger CLI acts on the owner's own accounts, so the owner decides when a new version ships. A signed
112
+ release report added a step without adding a check: the owner removed it on 2026-10-31 ("remove the signing
113
+ requirements instead just ask").
114
+ - **Enforced by:** review only.
115
+ - **Example:** the agent finishes the release skill, then asks "release max 0.91.0?". It runs `bin/release` only after
116
+ the owner says yes for that version. One yes covers one version.
117
+ - **Exceptions:** none.
118
+ - **Applies to:** messenger CLIs. **Since:** cli-base 0.41.0. **Rulings:** owner, 2026-10-31.
120
119
 
121
120
  ### WC-REL-11 · Publish from GitHub Actions with npm trusted publishing
122
121
 
@@ -67,19 +67,31 @@ name. Any other top-level key is written as `[not allowed]`. A key that names co
67
67
  ### WC-SECR-4 · Secrets come from the environment, then the OS keyring, then a private file
68
68
 
69
69
  cli-base's `Credentials` reads the environment variable first (for CI), then the OS keyring, then a file with mode
70
- `0600`. The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
70
+ `0600` in the CLI's own folder. Storage is the keyring by default (`auto`): when the keyring fails (locked, not
71
+ running, no D-Bus), the CLI uses the file without asking, warns once on stderr, and the answer that saved the secret
72
+ says where it went (gcal: `credential_storage: "file"`). `--credential-storage file` on the sign-in command chooses
73
+ the file outright; help and docs discourage it. Once the file holds a secret, it is read from the file, so a locked
74
+ keyring is not tried again for it; the next sign-in that reaches the keyring moves it back and removes the file copy.
75
+ `Credentials` in `auto` does all of this: the CLI passes `{ signIn: true }` to `write` for a new sign-in and nothing
76
+ for a refresh.
77
+ No CLI asks which store to use, and none is keyring-only.
78
+ The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
71
79
  the command word people type (`max`, `gcal`). A run with moved folders reads the same entry, writes a temporary one,
72
80
  and never replaces or deletes the owner's
73
81
  ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
74
82
 
75
- - **Why:** the keyring is the safest store on the computer.
83
+ - **Why:** the keyring is the safest store on the computer, and a sign-in that fails on a server or in a container
84
+ because no keyring runs there is a broken CLI, not a safe one.
76
85
  - **Enforced by:** `src/credentials.test.ts`.
77
86
  - **Example:** a bot token is read from `MAX_BOT_TOKEN`, then the keyring entry `bot:<profile>`, then the file.
78
- - **Exceptions:** `auto` storage tries the keyring and, when it fails, warns once on stderr and uses the file. A
79
- messenger library's own device store, which the library opens as a file and which cannot live in the keyring
80
- (WhatsApp's `private.db`), is an owner-only file under
81
- [WC-SECR-10](#wc-secr-10--private-files-are-written-atomically-with-private-modes) instead.
82
- - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-295.
87
+ `gcal personal session start` on a computer without a keyring signs in, warns once, and answers
88
+ `credential_storage: "file"`.
89
+ - **Exceptions:** a messenger library's own device store, which the library opens as a file and which cannot live in
90
+ the keyring (WhatsApp's `private.db`, tg's session file), is an owner-only file under
91
+ [WC-SECR-10](#wc-secr-10--private-files-are-written-atomically-with-private-modes) instead. A copy between two
92
+ profiles that must land in the keyring (cli-core's bot transfer) may use `storage: "keyring"`.
93
+ - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-295; owner, 2026-10-31 (never ask, keyring
94
+ by default, fall back to a file on error, a discouraged flag for the file).
83
95
 
84
96
  ### WC-SECR-5 · A secret is never taken on the command line
85
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",