@wirecat/cli-core 0.565.0 → 0.566.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,34 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.566.0 — 05.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **cli-core now needs `@wirecat/cli-base` 0.76.0 or later** (peer range `>=0.76.0 <0.78.0`), which holds
15
+ `loadConstants`.
16
+ - **`appDoctorCommand`'s `oauthClient` returns `oauthClientReport`'s shape, `{ client, source, reason? }`**, and a
17
+ throw there is reported as `{ client: null, source: null, reason }` (was `error`). drive's and gcal's callers
18
+ change with their bump.
19
+
20
+ ### Added
21
+
22
+ - **`@wirecat/cli-core/constants`: WireCat's sign-in apps ship in cli-core's `constants.json`.** `constants()` reads
23
+ cli-base's, cli-core's and the tool's `constants.json` once per process, merged, validated and frozen; a tool
24
+ passes its own layer and a schema built on `constantsSchema`. It holds WireCat's Google Desktop app
25
+ (`google.app.client_id`, `client_secret`) and Microsoft client id (`microsoft.app.client_id`), which drive and mail
26
+ kept in source and gcal expected from a file CI wrote. `googleApp({ env, prefix, clientFile, clientId,
27
+ configClientId })` and `microsoftApp(…)` pick the app in WC-SECR-25's order: `--client-file` or `--client-id`
28
+ (`flag`), `<PREFIX>_<PROVIDER>_CLIENT_ID` (`env`), the account's `<provider>.client_id` (`config`), WireCat's app
29
+ (`constants`); a `_SECRET` with no id is a `configuration_error`, reason `sign-in.secret-without-id`. `oauthApp`
30
+ does the same for a provider whose app is in a tool's layer, such as Zoom, and returns nothing when there is none.
31
+ `oauthClientReport(app, reason?)` is `doctor`'s `oauth_client`: `{ client: "wirecat" | "own" | null, source }`.
32
+ See [Constants](docs/dev/CONSTANTS.md).
33
+ - **A sign-in entry records where its app came from** (`clientSource` in the standard and drive entries, read back
34
+ as `client.source`), so `doctor` reports the app the account signed in with. The standard entry also keeps
35
+ WireCat's public secret, so a refresh still works after a release ships another default app; an owner's own
36
+ secret is still never written.
37
+
10
38
  ## 0.565.0 — 05.11.2026
11
39
 
12
40
  ### Changed — may break callers
package/constants.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "google": {
3
+ "app": {
4
+ "client_id": "_WAlQe_1Jamd97lrbGU1kcXOJ8TWiJSbdfyTOEEF6IqtlI9-L2lr8iTW7MmAqUn7XMpK1OXeRZNLCFlYULdV3C5HGV28xT2CL0QFEI3FC3oHZp-ak5TX9g",
5
+ "client_secret": "X6afcHNZ_v0d_N6-jCWHFQSeT2O-saKLY8_WY_W5aIlswI6q2EZ6GtF3IFhy6WjIHUzB"
6
+ }
7
+ },
8
+ "microsoft": {
9
+ "app": {
10
+ "client_id": "f7f05d3a-b948-4d50-b3ce-59c816ce69e9"
11
+ }
12
+ }
13
+ }
@@ -1,14 +1,14 @@
1
1
  import { Command } from "commander";
2
+ import type { OAuthClientReport } from "../constants/index.js";
2
3
  import { type AppIdentity } from "../ports/app.js";
3
4
  import type { Configuration } from "./settings.js";
4
5
  export interface AppDoctorOptions {
5
6
  /**
6
- * Which OAuth client the CLI signs in with — WireCat's or one of the owner's own — read from the environment and
7
- * files only. A throw is reported as its message.
7
+ * The OAuth app a new sign-in would use (WC-SECR-25), read from the environment and files only, usually
8
+ * `oauthClientReport` of the tool's `googleApp`/`oauthApp`. A throw is reported as a null client with its message as
9
+ * the reason.
8
10
  */
9
- oauthClient?: (env: NodeJS.ProcessEnv) => {
10
- client: "wirecat" | "own";
11
- };
11
+ oauthClient?: (env: NodeJS.ProcessEnv) => OAuthClientReport;
12
12
  /** The npm package, so `--online` can say whether a newer version is published; without it that check is left out. */
13
13
  packageName?: string;
14
14
  /**
@@ -48,10 +48,7 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
48
48
  path: string;
49
49
  exists: boolean;
50
50
  };
51
- oauth_client?: {
52
- client: "wirecat" | "own" | null;
53
- error?: string;
54
- } | undefined;
51
+ oauth_client?: OAuthClientReport | undefined;
55
52
  read_only: string | null;
56
53
  profile_lock: string | null;
57
54
  config: {
@@ -90,10 +87,7 @@ export declare const appDoctorReport: (app: AppIdentity, configuration: Configur
90
87
  path: string;
91
88
  exists: boolean;
92
89
  };
93
- oauth_client?: {
94
- client: "wirecat" | "own" | null;
95
- error?: string;
96
- } | undefined;
90
+ oauth_client?: OAuthClientReport | undefined;
97
91
  read_only: string | null;
98
92
  profile_lock: string | null;
99
93
  config: {
@@ -85,7 +85,7 @@ export const appDoctorReport = (app, configuration, env, command, options = {})
85
85
  oauth = options.oauthClient(env);
86
86
  }
87
87
  catch (error) {
88
- oauth = { client: null, error: failure(error) };
88
+ oauth = { client: null, source: null, reason: failure(error) };
89
89
  }
90
90
  const common = {
91
91
  version: app.version,
@@ -0,0 +1,83 @@
1
+ import * as v from "valibot";
2
+ import type { AppSource, OAuthClient } from "../oauth/provider.js";
3
+ /** cli-core's layer, read after cli-base's and before the tool's. */
4
+ export declare const CORE_CONSTANTS: import("url").URL;
5
+ /** What cli-base's and cli-core's layers hold; a tool extends it with `v.object({ ...constantsSchema.entries, … })`. */
6
+ export declare const constantsSchema: v.ObjectSchema<{
7
+ readonly google: v.ObjectSchema<{
8
+ readonly app: v.ObjectSchema<{
9
+ readonly client_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.NonEmptyAction<string, undefined>, v.TransformAction<string, string>]>;
10
+ readonly client_secret: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TransformAction<string, string>]>, undefined>;
11
+ }, undefined>;
12
+ }, undefined>;
13
+ readonly microsoft: v.ObjectSchema<{
14
+ readonly app: v.ObjectSchema<{
15
+ readonly client_id: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.NonEmptyAction<string, undefined>]>;
16
+ }, undefined>;
17
+ }, undefined>;
18
+ }, undefined>;
19
+ export type Constants = v.InferOutput<typeof constantsSchema>;
20
+ /**
21
+ * The packaged constants, base → core → the tool's `constants.json` when given, read once per process for each tool
22
+ * layer. A tool with constants of its own passes its layer and a schema built on `constantsSchema`; the first call for
23
+ * a layer decides its schema.
24
+ */
25
+ export declare const constants: <TSchema extends v.GenericSchema<unknown, Constants> = typeof constantsSchema>(toolLayer?: URL, schema?: TSchema) => v.InferOutput<TSchema>;
26
+ export interface AppOptions {
27
+ env: NodeJS.ProcessEnv;
28
+ /** The tool's environment prefix: `GCAL` with provider `google` reads `GCAL_GOOGLE_CLIENT_ID` and `_SECRET`. */
29
+ prefix: string;
30
+ /** `--client-id`. A secret for it comes from `<PREFIX>_<PROVIDER>_CLIENT_SECRET`. */
31
+ clientId?: string | undefined;
32
+ /** The account's config key `<provider>.client_id`. A config file never holds the secret: it comes from the env. */
33
+ configClientId?: string | undefined;
34
+ }
35
+ export interface OAuthAppOptions extends AppOptions {
36
+ /** `google`, `microsoft`, `zoom`: names the env variables, upper-cased. */
37
+ provider: string;
38
+ /** WireCat's app from the constants; an empty or missing id means WireCat has none for this provider. */
39
+ wirecat?: {
40
+ client_id?: string | undefined;
41
+ client_secret?: string | undefined;
42
+ } | undefined;
43
+ }
44
+ export interface OAuthApp {
45
+ client: OAuthClient & {
46
+ source: AppSource;
47
+ };
48
+ source: AppSource;
49
+ }
50
+ /**
51
+ * The app a sign-in uses, first match wins (WC-SECR-25): `--client-id`, `<PREFIX>_<PROVIDER>_CLIENT_ID`, the account's
52
+ * `<provider>.client_id`, WireCat's app. A secret without any of the first three ids is a `configuration_error`.
53
+ * Undefined when nothing names an app and WireCat has none.
54
+ */
55
+ export declare const oauthApp: (options: OAuthAppOptions) => OAuthApp | undefined;
56
+ export interface GoogleAppOptions extends AppOptions {
57
+ /** The owner's Desktop client JSON, `--client-file`; it must be private to the owner. */
58
+ clientFile?: string | undefined;
59
+ platform?: NodeJS.Platform;
60
+ /** The tool's own constants, when its layer overrides the app. */
61
+ constants?: Constants;
62
+ }
63
+ /**
64
+ * The Google Desktop app a sign-in uses: `--client-file`, then `oauthApp`'s order. Google treats a Desktop app's
65
+ * secret as public (https://developers.google.com/identity/protocols/oauth2/native-app), so WireCat's ships, obscured,
66
+ * in cli-core's constants.
67
+ */
68
+ export declare const googleApp: (options: GoogleAppOptions) => OAuthApp;
69
+ /** WireCat's Microsoft app is a public client: it has an id and no secret. */
70
+ export declare const microsoftApp: (options: AppOptions & {
71
+ constants?: Constants;
72
+ }) => OAuthApp;
73
+ export interface OAuthClientReport {
74
+ client: "wirecat" | "own" | null;
75
+ source: AppSource | null;
76
+ /** Why there is no app, when `client` is null. */
77
+ reason?: string;
78
+ }
79
+ /** `doctor`'s `oauth_client` for one provider: the app a new sign-in would use; doctor reads no keyring. */
80
+ export declare const oauthClientReport: (app: {
81
+ source?: AppSource | undefined;
82
+ } | undefined, reason?: string) => OAuthClientReport;
83
+ export type { AppSource };
@@ -0,0 +1,77 @@
1
+ import { BASE_CONSTANTS, baseConstantsSchema, CliError, loadConstants } from "@wirecat/cli-base";
2
+ import * as v from "valibot";
3
+ import { readClientFile } from "../oauth/client-file.js";
4
+ import { reveal } from "./obscure.js";
5
+ /** cli-core's layer, read after cli-base's and before the tool's. */
6
+ export const CORE_CONSTANTS = new URL("../../constants.json", import.meta.url);
7
+ /** What cli-base's and cli-core's layers hold; a tool extends it with `v.object({ ...constantsSchema.entries, … })`. */
8
+ export const constantsSchema = v.object({
9
+ ...baseConstantsSchema.entries,
10
+ google: v.object({
11
+ app: v.object({
12
+ client_id: v.pipe(v.string(), v.nonEmpty(), v.transform(reveal)),
13
+ client_secret: v.optional(v.pipe(v.string(), v.transform(reveal))),
14
+ }),
15
+ }),
16
+ microsoft: v.object({ app: v.object({ client_id: v.pipe(v.string(), v.nonEmpty()) }) }),
17
+ });
18
+ const loaded = new Map();
19
+ /**
20
+ * The packaged constants, base → core → the tool's `constants.json` when given, read once per process for each tool
21
+ * layer. A tool with constants of its own passes its layer and a schema built on `constantsSchema`; the first call for
22
+ * a layer decides its schema.
23
+ */
24
+ export const constants = (toolLayer, schema) => {
25
+ const key = toolLayer?.href ?? "";
26
+ if (!loaded.has(key)) {
27
+ const layers = toolLayer ? [BASE_CONSTANTS, CORE_CONSTANTS, toolLayer] : [BASE_CONSTANTS, CORE_CONSTANTS];
28
+ loaded.set(key, loadConstants(layers, schema ?? constantsSchema));
29
+ }
30
+ return loaded.get(key);
31
+ };
32
+ /**
33
+ * The app a sign-in uses, first match wins (WC-SECR-25): `--client-id`, `<PREFIX>_<PROVIDER>_CLIENT_ID`, the account's
34
+ * `<provider>.client_id`, WireCat's app. A secret without any of the first three ids is a `configuration_error`.
35
+ * Undefined when nothing names an app and WireCat has none.
36
+ */
37
+ export const oauthApp = (options) => {
38
+ const { env, prefix, provider } = options;
39
+ const name = `${prefix}_${provider.toUpperCase()}_CLIENT`;
40
+ const clientSecret = env[`${name}_SECRET`];
41
+ const own = [
42
+ [options.clientId, "flag"],
43
+ [env[`${name}_ID`], "env"],
44
+ [options.configClientId, "config"],
45
+ ];
46
+ const [clientId, source] = own.find(([id]) => id) ?? [];
47
+ if (clientId && source)
48
+ return app({ clientId, ...(clientSecret ? { clientSecret } : {}) }, source);
49
+ if (clientSecret)
50
+ throw new CliError("configuration_error", `${name}_SECRET is set without a client id; set ${name}_ID too, or unset it to use WireCat's app`, { reason: "sign-in.secret-without-id" });
51
+ const wirecat = options.wirecat;
52
+ if (!wirecat?.client_id)
53
+ return undefined;
54
+ return app({ clientId: wirecat.client_id, ...(wirecat.client_secret ? { clientSecret: wirecat.client_secret } : {}) }, "constants");
55
+ };
56
+ const app = (client, source) => ({ client: { ...client, source }, source });
57
+ /**
58
+ * The Google Desktop app a sign-in uses: `--client-file`, then `oauthApp`'s order. Google treats a Desktop app's
59
+ * secret as public (https://developers.google.com/identity/protocols/oauth2/native-app), so WireCat's ships, obscured,
60
+ * in cli-core's constants.
61
+ */
62
+ export const googleApp = (options) => {
63
+ const { clientFile, platform } = options;
64
+ if (clientFile)
65
+ return app(readClientFile(clientFile, platform ? { platform } : {}), "flag");
66
+ const wirecat = (options.constants ?? constants()).google.app;
67
+ return oauthApp({ ...options, provider: "google", wirecat });
68
+ };
69
+ /** WireCat's Microsoft app is a public client: it has an id and no secret. */
70
+ export const microsoftApp = (options) => {
71
+ const wirecat = (options.constants ?? constants()).microsoft.app;
72
+ return oauthApp({ ...options, provider: "microsoft", wirecat });
73
+ };
74
+ /** `doctor`'s `oauth_client` for one provider: the app a new sign-in would use; doctor reads no keyring. */
75
+ export const oauthClientReport = (app, reason) => app?.source
76
+ ? { client: app.source === "constants" ? "wirecat" : "own", source: app.source }
77
+ : { client: null, source: null, ...(reason ? { reason } : {}) };
@@ -0,0 +1 @@
1
+ export declare const reveal: (obscured: string) => string;
@@ -0,0 +1,9 @@
1
+ import { createDecipheriv } from "node:crypto";
2
+ // Not protection: anyone holding the package can reveal these. It keeps secret scanners and a casual read from
3
+ // treating WireCat's public Desktop app as a leaked credential, as rclone does for its own app.
4
+ const KEY = Buffer.from("d43012779aa2dded3e98092a3dbcf5d8980c564172f5ce0a5fd0574e5f740339", "hex");
5
+ export const reveal = (obscured) => {
6
+ const bytes = Buffer.from(obscured, "base64url");
7
+ const decipher = createDecipheriv("aes-256-ctr", KEY, bytes.subarray(0, 16));
8
+ return Buffer.concat([decipher.update(bytes.subarray(16)), decipher.final()]).toString("utf8");
9
+ };
@@ -33,6 +33,7 @@ const SIGN_IN = [
33
33
  ["sign-in.client-file-invalid", "configuration_error", "the file is not a Desktop app OAuth client JSON"],
34
34
  ["oauth.scope-missing", "permission_error", "the sign-in lacks a permission this command needs"],
35
35
  ["sign-in.client-rejected", "configuration_error", "the provider rejected the OAuth client"],
36
+ ["sign-in.secret-without-id", "configuration_error", "an OAuth client secret is set without its client id"],
36
37
  ];
37
38
  /** Reasons every CLI over a provider's API raises alike: gcal and drive today. */
38
39
  const PROVIDER_API = [
@@ -1,6 +1,6 @@
1
1
  export { type Authorization, type AuthorizeOptions, authorize } from "./authorize.js";
2
2
  export { type ClientFileOptions, readClientFile } from "./client-file.js";
3
3
  export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, type TokensOptions, tokenKeeper, } from "./keeper.js";
4
- export type { ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
4
+ export type { AppSource, ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
5
5
  export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
6
6
  export { type CredentialStorageOptions, credentialStorage, driveSessionCodec, type EncodeInput, oauthAccount, type StoredTokens, standardCodec, type TokenCodec, type TokenStorage, zoomTokenCodec, } from "./storage.js";
@@ -3,7 +3,12 @@ import type { Logger } from "@wirecat/cli-base";
3
3
  export interface OAuthClient {
4
4
  clientId: string;
5
5
  clientSecret?: string;
6
+ /** Where the sign-in found the app (WC-SECR-25); saved with the entry, so `doctor` reports the app it used. */
7
+ source?: AppSource;
6
8
  }
9
+ /** `flag`: `--client-file` or `--client-id`; `env`: `<TOOL>_<PROVIDER>_CLIENT_ID`; `config`: the account's
10
+ * `<provider>.client_id`; `constants`: WireCat's app. */
11
+ export type AppSource = "flag" | "env" | "config" | "constants";
7
12
  export interface TokenSet {
8
13
  accessToken?: string;
9
14
  refreshToken: string;
@@ -40,8 +40,9 @@ export const standardCodec = (providerId) => ({
40
40
  throw invalid();
41
41
  if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
42
42
  throw invalid();
43
+ const source = text(value.clientSource);
43
44
  return {
44
- client: { clientId },
45
+ client: defined({ clientId, clientSecret: text(value.clientSecret), source }),
45
46
  tokens: defined({
46
47
  accessToken: text(value.accessToken),
47
48
  refreshToken,
@@ -57,6 +58,10 @@ export const standardCodec = (providerId) => ({
57
58
  version: 1,
58
59
  provider: providerId,
59
60
  clientId: client.clientId,
61
+ // Only WireCat's secret, which is public: a later release may ship another default app, and a refresh needs
62
+ // the secret of the app that signed in. An owner's own secret stays where the owner keeps it.
63
+ clientSecret: client.source === "constants" ? client.clientSecret : undefined,
64
+ clientSource: client.source,
60
65
  accessToken: tokens.accessToken,
61
66
  refreshToken: tokens.refreshToken,
62
67
  expiresAt: tokens.expiresAt,
@@ -80,7 +85,11 @@ export const driveSessionCodec = {
80
85
  if (!refreshToken || !clientId || typeof value.principal !== "string")
81
86
  throw invalid();
82
87
  return {
83
- client: defined({ clientId, clientSecret: text(value.clientSecret) }),
88
+ client: defined({
89
+ clientId,
90
+ clientSecret: text(value.clientSecret),
91
+ source: text(value.clientSource),
92
+ }),
84
93
  tokens: defined({
85
94
  accessToken: text(fields.access_token),
86
95
  refreshToken,
@@ -98,6 +107,7 @@ export const driveSessionCodec = {
98
107
  ...extra,
99
108
  clientId: client.clientId,
100
109
  ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
110
+ ...(client.source ? { clientSource: client.source } : {}),
101
111
  tokens: defined({
102
112
  ...oldTokens,
103
113
  access_token: tokens.accessToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.565.0",
3
+ "version": "0.566.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",
@@ -19,6 +19,7 @@
19
19
  "packageManager": "pnpm@11.28.8",
20
20
  "files": [
21
21
  "dist",
22
+ "constants.json",
22
23
  "parity.json",
23
24
  "skills",
24
25
  "README.md",
@@ -166,6 +167,10 @@
166
167
  "./oauth": {
167
168
  "types": "./dist/oauth/index.d.ts",
168
169
  "default": "./dist/oauth/index.js"
170
+ },
171
+ "./constants": {
172
+ "types": "./dist/constants/index.d.ts",
173
+ "default": "./dist/constants/index.js"
169
174
  }
170
175
  },
171
176
  "scripts": {
@@ -273,7 +278,7 @@
273
278
  },
274
279
  "peerDependencies": {
275
280
  "@napi-rs/canvas": ">=0.1.69 <2",
276
- "@wirecat/cli-base": ">=0.75.0 <0.78.0",
281
+ "@wirecat/cli-base": ">=0.76.0 <0.78.0",
277
282
  "commander": ">=15"
278
283
  },
279
284
  "bin": {