@wirecat/cli-core 0.412.0 → 0.414.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
@@ -5,6 +5,35 @@ 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
+ ## 0.414.0 — 25.10.2026
9
+
10
+ ### Added
11
+
12
+ - `AppIdentity.contentLanguages`: ISO 639-1 codes of the languages a CLI's content is in. On a new store the first run pre-ticks the ones search can stem in place of English and the system's language; the person still confirms the shared choice. Unset, nothing changes.
13
+ - Export `publicUrlOf` and `portOf` through `/mcp`, so non-messenger consumers reuse the same HTTP listener option validation as the messenger commands.
14
+ - **A contact saved with an address joins the mail contact of that exact address.** `savePeople` takes an optional
15
+ `email`; the provider's contact and the mail contact become one person (`same-email`), whichever is saved first.
16
+ A name never joins anyone, and two identities the owner unlinked stay apart.
17
+ - **The first screen of `tg --help`, `max --help` and `wa --help` points at `account list`.** A row under "Start here" names `<tool> account list`, which lists every profile on this computer without a profile in front, so a reader who does not know their profile names finds them. A tool without `account list` shows no row.
18
+
19
+ ### Fixed
20
+
21
+ - Generated MCP commands accept a positional argument or its same-named flag alternative once. A single `text` input no longer emits both the argument and `--text`, which made Memo note creation fail. Positional requiredness is retained.
22
+ - Public listener origins refuse embedded credentials before startup diagnostics can expose them.
23
+ - **"Returns" lists the fields of commands whose output has several shapes.** A command's help and the generated `docs/commands.md` showed `message send — ; every field: …` with no fields for every output declared as a union (`message_send`, `store_info`, `chat_group_change` and others; 41 lines in tg-cli's page). The line now lists the fields of every shape, required first, as for any other output.
24
+
25
+ ## 0.413.0 — 25.10.2026
26
+
27
+ ### Added
28
+
29
+ - **`startCli`, one entry-point call for a CLI that builds its own program** (from `@wirecat/cli-core/cli`). It does what drive, zm and memo wire by hand in `src/bin/`: the crash catch, cancellation on SIGINT and SIGTERM, a quiet exit 0 on a closed pipe, the SQLite swap, the first-run questions (`entry.atStart`), the consent prompt (`entry.ask`) and the browser (`entry.openBrowser`). The program and its failure report stay the CLI's own. Nothing changes for a CLI that keeps its own wiring.
30
+ - **Sign-in module `@wirecat/cli-core/oauth`** (available from cli-core 0.413.0). A CLI declares its provider (URLs, client, loopback or relay redirect) and gets authorization code with PKCE S256 (state check, timeout, cancel, paste fallback), refresh and revoke over plain `fetch`, and a `tokenKeeper` whose refresh takes a lock per keyring entry shared by every process, so two processes refreshing at once send one request. `credentialStorage` sits on cli-base's `Credentials`; `standardCodec` writes new entries as `oauth:<provider>:<profile>`, and `driveSessionCodec` and `zoomTokenCodec` read and write drive's and zm's existing entries unchanged, so nobody signs in again.
31
+ - **`gatherProjectEvidence` caps each linked chat per invocation.** A linked chat returns at most `max(20, floor(maxItems / (linked chats read + 1)))` new items per run (50 for one chat at the default 100 items, 20 from four chats up); the rest stays in the checkpoint for the next `--cursor` run, which gives it a fresh share. Every linked chat still gets its first page before any chat gets a second. Each `linkedChats` entry now has `items` (new items it returned in this run) and `capped` (it hit the limit and has more to read). Before, one busy chat could fill the whole run. Checkpoints stay version 1, and old ones still resume.
32
+
33
+ ### Fixed
34
+
35
+ - **A file lock no longer fails with "no such file" when another writer releases at the same moment** (available from cli-core 0.413.0). The releaser removes the empty marker folder; an acquirer that had just created it now creates it again instead of throwing `ENOENT`. `acquireLock`, the archive writer lease and the sign-in module's refresh lock all use it.
36
+ - **The 0.412.0 notes for the provider registry, unknown providers and the `events` kind say "available from cli-core 0.413.0"; they shipped in 0.412.0.** The release folded them in from `main`'s head. Nothing else in those entries changes.
8
37
 
9
38
  ## 0.412.0 — 24.10.2026
10
39
 
@@ -0,0 +1,59 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import type { ErrorReason } from "@wirecat/cli-base/standards";
3
+ import type { Command } from "commander";
4
+ import { type AppIdentity } from "../ports/app.js";
5
+ /** What `startCli` hands the CLI's own program: the process, wired once. */
6
+ export interface EntryContext {
7
+ /** `process.argv` without the runtime and the script. */
8
+ args: string[];
9
+ env: NodeJS.ProcessEnv;
10
+ /** Whether stdout is a terminal. */
11
+ tty: boolean;
12
+ /** Aborted on SIGINT, SIGTERM and a closed or failing stdout. */
13
+ signal: AbortSignal;
14
+ /** The first-run questions, before the command acts: call it from a `preAction` hook with the action command. */
15
+ atStart: (command: Command) => Promise<void>;
16
+ /** A yes/no question on stderr; false without asking when there is no terminal, under CI, `--json`, `--jsonl` or `--no-input`. */
17
+ ask: (question: string, command?: Command) => Promise<boolean>;
18
+ /** Opens the URL in the system browser, or in `browser` when given; never waits for the browser to close. */
19
+ openBrowser: (url: string, browser?: string) => Promise<void>;
20
+ /** True once a reader closed the output (`| head`): nothing more reaches the terminal. */
21
+ outputClosed: () => boolean;
22
+ }
23
+ /** The parts of the process `startCli` touches, replaced in tests. */
24
+ export interface EntryIo {
25
+ argv: readonly string[];
26
+ env: NodeJS.ProcessEnv;
27
+ platform: NodeJS.Platform;
28
+ stdin: NodeJS.ReadableStream & {
29
+ isTTY?: boolean;
30
+ };
31
+ stdout: NodeJS.WritableStream & {
32
+ isTTY?: boolean;
33
+ };
34
+ stderr: NodeJS.WritableStream & {
35
+ isTTY?: boolean;
36
+ };
37
+ signals: Pick<NodeJS.Process, "on" | "off">;
38
+ setExitCode: (code: number) => void;
39
+ spawn: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
40
+ }
41
+ export interface CliEntry {
42
+ app: AppIdentity;
43
+ /** Builds and runs the CLI's program; a returned number is the exit code, nothing keeps the one already set. */
44
+ run: (entry: EntryContext) => Promise<number | undefined>;
45
+ /** Says a failure the program threw, once, and gives its exit code. */
46
+ fail: (error: unknown, entry: EntryContext) => number;
47
+ /** Whether a crash may print the human form; stdout being a terminal when left out. */
48
+ interactive?: (args: readonly string[], tty: boolean) => boolean;
49
+ reasons?: readonly ErrorReason[];
50
+ /** Swap a system SQLite that cannot hold the store before anything runs (`ensureSqlite`). */
51
+ sqlite?: boolean;
52
+ io?: Partial<EntryIo>;
53
+ }
54
+ /**
55
+ * The entry point of a CLI that builds its own commander program: crash catch, cancellation, a quiet end on a closed
56
+ * pipe, the first-run questions, the consent prompt and the browser. It sets `process.exitCode` and never exits, so
57
+ * output still flushes.
58
+ */
59
+ export declare const startCli: (entry: CliEntry) => Promise<void>;
@@ -0,0 +1,137 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CliError, catchCrashes } from "@wirecat/cli-base";
3
+ import { readOnlyLockOf } from "../ports/app.js";
4
+ import { ensureSqlite } from "../sqlite-runtime.js";
5
+ import { readSecret } from "../terminal/prompt.js";
6
+ import { commandPathOf } from "./command-contract.js";
7
+ import { setupAtStart } from "./first-run.js";
8
+ const processIo = () => ({
9
+ argv: process.argv,
10
+ env: process.env,
11
+ platform: process.platform,
12
+ stdin: process.stdin,
13
+ stdout: process.stdout,
14
+ stderr: process.stderr,
15
+ signals: process,
16
+ setExitCode: (code) => {
17
+ process.exitCode = code;
18
+ },
19
+ spawn,
20
+ });
21
+ /**
22
+ * The entry point of a CLI that builds its own commander program: crash catch, cancellation, a quiet end on a closed
23
+ * pipe, the first-run questions, the consent prompt and the browser. It sets `process.exitCode` and never exits, so
24
+ * output still flushes.
25
+ */
26
+ export const startCli = async (entry) => {
27
+ const io = { ...processIo(), ...entry.io };
28
+ const args = io.argv.slice(2);
29
+ const tty = io.stdout.isTTY === true;
30
+ const release = catchCrashes({
31
+ cli: entry.app.command,
32
+ interactive: entry.interactive?.(args, tty) ?? tty,
33
+ ...(entry.reasons ? { reasons: entry.reasons } : {}),
34
+ target: io.signals,
35
+ });
36
+ const controller = new AbortController();
37
+ let closed = false;
38
+ let unwritable = false;
39
+ const interrupted = (signal) => () => controller.abort(new CliError("cancelled", `command interrupted by ${signal}`, { reason: signal, retryable: false }));
40
+ const sigint = interrupted("SIGINT");
41
+ const sigterm = interrupted("SIGTERM");
42
+ const outputFailed = (error) => {
43
+ if (error.code === "EPIPE") {
44
+ closed = true;
45
+ controller.abort(new CliError("cancelled", "output pipe closed", { retryable: false }));
46
+ }
47
+ else {
48
+ unwritable = true;
49
+ controller.abort(new CliError("configuration_error", "could not write command output"));
50
+ }
51
+ };
52
+ const headless = (command) => {
53
+ const flags = command?.optsWithGlobals() ?? {};
54
+ return Boolean(flags.json || flags.jsonl || flags.input === false || io.env.CI) || !tty;
55
+ };
56
+ const context = {
57
+ args,
58
+ env: io.env,
59
+ tty,
60
+ signal: controller.signal,
61
+ atStart: (command) => setupAtStart({
62
+ command: entry.app.command,
63
+ env: io.env,
64
+ contentLanguages: entry.app.contentLanguages,
65
+ path: commandPathOf(command),
66
+ headless: headless(command),
67
+ readOnlyLock: readOnlyLockOf(entry.app, io.env),
68
+ input: io.stdin,
69
+ }),
70
+ ask: async (question, command) => {
71
+ if (headless(command) || io.stdin.isTTY !== true)
72
+ return false;
73
+ const answer = await readSecret(`${question} [y/N] `, {
74
+ input: io.stdin,
75
+ output: io.stderr,
76
+ echo: true,
77
+ signal: controller.signal,
78
+ });
79
+ return /^(y|yes)$/i.test(answer.trim());
80
+ },
81
+ openBrowser: (url, browser) => openBrowser(io, url, browser),
82
+ outputClosed: () => closed,
83
+ };
84
+ let code;
85
+ try {
86
+ if (entry.sqlite)
87
+ await ensureSqlite();
88
+ io.signals.on("SIGINT", sigint).on("SIGTERM", sigterm);
89
+ io.stdout.on("error", outputFailed);
90
+ io.stderr.on("error", outputFailed);
91
+ const returned = await entry.run(context);
92
+ if (typeof returned === "number")
93
+ code = returned;
94
+ }
95
+ catch (error) {
96
+ code = commanderExit(error) ?? entry.fail(error, context);
97
+ }
98
+ finally {
99
+ io.signals.off("SIGINT", sigint).off("SIGTERM", sigterm);
100
+ release();
101
+ }
102
+ // The listeners stay: output still flushing after the command can meet the closed pipe too.
103
+ if (closed)
104
+ io.setExitCode(0);
105
+ else if (unwritable)
106
+ io.setExitCode(3);
107
+ else if (code !== undefined)
108
+ io.setExitCode(code);
109
+ };
110
+ /** Help and the version were printed already; help for a missing command is a usage mistake, as in `run()`. */
111
+ const commanderExit = (error) => {
112
+ if (!(error instanceof Error))
113
+ return undefined;
114
+ const { code, exitCode } = error;
115
+ if (typeof code !== "string" || !code.startsWith("commander.") || typeof exitCode !== "number")
116
+ return undefined;
117
+ if (exitCode === 0)
118
+ return 0;
119
+ return code === "commander.help" ? 2 : undefined;
120
+ };
121
+ const openBrowser = (io, url, browser) => {
122
+ const [command, prefix] = browser !== undefined
123
+ ? [browser, []]
124
+ : io.platform === "darwin"
125
+ ? ["open", []]
126
+ : io.platform === "win32"
127
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler"]]
128
+ : ["xdg-open", []];
129
+ return new Promise((resolve, reject) => {
130
+ const child = io.spawn(command, [...prefix, url], { detached: true, stdio: "ignore", windowsHide: true });
131
+ child.once("error", () => reject(new CliError("configuration_error", `could not start ${command} — open the printed URL in a browser`)));
132
+ child.once("spawn", () => {
133
+ child.unref();
134
+ resolve();
135
+ });
136
+ });
137
+ };
@@ -25,6 +25,8 @@ export interface FirstRun {
25
25
  command: string;
26
26
  env: NodeJS.ProcessEnv;
27
27
  io: FirstRunIo;
28
+ /** `AppIdentity.contentLanguages`: ticked on a new store in place of English and the system's language. */
29
+ contentLanguages?: readonly string[] | undefined;
28
30
  /** Every question, as `setup` asks them; otherwise only those no WireCat tool asked yet. */
29
31
  all?: boolean;
30
32
  /** Where tools are looked for and how one is installed; injected by tests. */
@@ -53,11 +55,12 @@ export declare const askSetupQuestions: (run: FirstRun) => Promise<Question[]>;
53
55
  /**
54
56
  * The first-run step a tool runs before each command: asks when `asksAtStart` says so, and lets the command run
55
57
  * whatever happens to the questions. `setup` asks all three again, since a refusal sends the owner there.
56
- * A tool that builds its own program (zm, memo, drive) calls this with its command path.
58
+ * A tool that builds its own program gets it from `startCli` as `entry.atStart`.
57
59
  */
58
60
  export declare const setupAtStart: (start: {
59
61
  command: string;
60
62
  env: NodeJS.ProcessEnv;
63
+ contentLanguages?: readonly string[] | undefined;
61
64
  path: readonly string[];
62
65
  headless: boolean;
63
66
  readOnlyLock?: string | undefined;
@@ -85,17 +85,19 @@ const installerHere = () => {
85
85
  return "unknown";
86
86
  }
87
87
  };
88
- const askLanguages = async ({ command, env, io }) => {
88
+ const suggested = (env, contentLanguages) => {
89
+ const own = LANGUAGES.filter(({ code }) => contentLanguages?.includes(code));
90
+ const system = systemLanguage(env);
91
+ return (own.length ? own : LANGUAGES.filter(({ code }) => code === "en" || code === system)).map(({ name }) => name);
92
+ };
93
+ const askLanguages = async ({ command, env, io, contentLanguages }) => {
89
94
  const store = await openStore({ env });
90
95
  let rebuild = false;
91
96
  try {
92
97
  const saved = (await store.searchIndex.stemmers()) ?? undefined;
93
98
  // A store already in use keeps the languages it was built with, so Enter changes nothing (RISK-1612).
94
99
  const inUse = saved !== undefined || (await store.accounts.accounts()).length > 0;
95
- const system = systemLanguage(env);
96
- const ticked = inUse
97
- ? languagesOf(saved ?? DEFAULT_STEMMERS)
98
- : LANGUAGES.filter(({ code }) => code === "en" || code === system).map(({ name }) => name);
100
+ const ticked = inUse ? languagesOf(saved ?? DEFAULT_STEMMERS) : suggested(env, contentLanguages);
99
101
  const numbers = ticked.map((name) => LANGUAGES.findIndex((one) => one.name === name) + 1);
100
102
  io.say("Languages you write and speak — search finds every form of their words, and spellings speech mangles:");
101
103
  LANGUAGES.forEach(({ name, label }, at) => {
@@ -203,7 +205,7 @@ export const askSetupQuestions = async (run) => {
203
205
  /**
204
206
  * The first-run step a tool runs before each command: asks when `asksAtStart` says so, and lets the command run
205
207
  * whatever happens to the questions. `setup` asks all three again, since a refusal sends the owner there.
206
- * A tool that builds its own program (zm, memo, drive) calls this with its command path.
208
+ * A tool that builds its own program gets it from `startCli` as `entry.atStart`.
207
209
  */
208
210
  export const setupAtStart = async (start) => {
209
211
  const input = start.input ?? process.stdin;
@@ -213,6 +215,7 @@ export const setupAtStart = async (start) => {
213
215
  await askSetupQuestions({
214
216
  command: start.command,
215
217
  env: start.env,
218
+ contentLanguages: start.contentLanguages,
216
219
  all: start.path[0] === "setup",
217
220
  io: {
218
221
  ask: (prompt) => readSecret(prompt, { input, echo: true }),
@@ -100,16 +100,19 @@ const call = (command) => [
100
100
  ...commandPathOf(command),
101
101
  ...command.registeredArguments.map((one) => (one.required ? `<${one.name()}>` : `[${one.name()}]`)),
102
102
  ].join(" ");
103
+ const fieldsOf = (schema) => {
104
+ const variants = schema.anyOf;
105
+ if (variants)
106
+ return variants.flatMap(fieldsOf);
107
+ const required = schema.required ?? [];
108
+ return [...required, ...Object.keys(schema.properties ?? {})];
109
+ };
103
110
  const returnsOf = (command) => {
104
111
  const output = metaOf(command).output;
105
112
  const { item, coverage } = outputOf(command);
106
113
  if (!output || coverage !== "declared-entity")
107
114
  return short(command.summary() || command.description()) || "not declared";
108
- const required = item.required ?? [];
109
- const fields = [
110
- ...required,
111
- ...Object.keys(item.properties ?? {}).filter((name) => !required.includes(name)),
112
- ];
115
+ const fields = [...new Set(fieldsOf(item))];
113
116
  const noun = (output.view ? `${output.entity} ${output.view}` : output.entity).replaceAll("_", " ");
114
117
  const more = fields.length > 8 ? `, and ${fields.length - 8} more` : "";
115
118
  const root = rootOf(command);
@@ -64,6 +64,9 @@ const pointers = (program, cli, start, profile) => {
64
64
  ].filter((row) => row !== undefined);
65
65
  const rows = [
66
66
  ...(start ? [["Start here:", start, profile ? "(any profile name works)" : ""]] : []),
67
+ ...(has("account list")
68
+ ? [["", `${cli} account list`, "your profiles and their logged-in accounts"]]
69
+ : []),
67
70
  ...next.map(([command = "", does = ""], at) => [at === 0 ? "Next:" : "", command, does]),
68
71
  ...(has("skill show") ? [["For agents:", `${cli} skill show`, "rules and the discovery loop"]] : []),
69
72
  ];
@@ -59,6 +59,7 @@ export { type Closeable, withDeadline } from "./deadline.js";
59
59
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
60
60
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
61
61
  export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, } from "./effects.js";
62
+ export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
62
63
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
63
64
  export { withAbort } from "./execution.js";
64
65
  export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
package/dist/cli/index.js CHANGED
@@ -52,6 +52,7 @@ export { withDeadline } from "./deadline.js";
52
52
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
53
53
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
54
54
  export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, } from "./effects.js";
55
+ export { startCli } from "./entry.js";
55
56
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
56
57
  export { withAbort } from "./execution.js";
57
58
  export { askSetupQuestions, asksAtStart, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
@@ -267,6 +267,7 @@ const runOnce = async (argv, definition, options) => {
267
267
  await setupAtStart({
268
268
  command,
269
269
  env: commandEnv,
270
+ contentLanguages: definition.app.contentLanguages,
270
271
  path,
271
272
  headless,
272
273
  readOnlyLock: lock,
@@ -15,6 +15,21 @@ const QUERY = [
15
15
  ["query.invalid-topic", "a topic in the query is not valid"],
16
16
  ["query.topic-scope", "a topic needs its chat in the query"],
17
17
  ].map(([reason, message]) => validation(reason, message));
18
+ const SIGN_IN = [
19
+ ["oauth.declined", "authentication_error", "the provider's consent was declined"],
20
+ ["oauth.timeout", "timeout", "the sign-in was not finished in time"],
21
+ ["oauth.mismatch", "authentication_error", "the pasted sign-in result belongs to another sign-in"],
22
+ ["oauth.missing", "authentication_error", "this profile is not signed in"],
23
+ ["oauth.locked", "provider_unavailable", "another process holds this sign-in's lock"],
24
+ ["oauth.no-response", "outcome_unknown", "the token request did not finish"],
25
+ ["oauth.unavailable", "provider_unavailable", "the provider's token endpoint is unavailable"],
26
+ ["oauth.expired", "authentication_error", "the provider refused the saved sign-in; sign in again"],
27
+ ["oauth.invalid-response", "invalid_response", "the provider's token response could not be used"],
28
+ ["oauth.failed", "authentication_error", "the provider returned no offline refresh token"],
29
+ ["oauth.revoke-failed", "outcome_unknown", "the provider did not confirm the revoke"],
30
+ ["oauth.invalid", "authentication_error", "the saved sign-in cannot be read"],
31
+ ["oauth.not-saved", "configuration_error", "the sign-in cannot be saved or read where it is kept"],
32
+ ];
18
33
  const OTHERS = [
19
34
  ["model.offline", "permission_error", "downloading a model needs the network; --offline was requested"],
20
35
  [
@@ -105,7 +120,7 @@ export const INPUT_FIELD_REASONS = defineReasons([
105
120
  export const REASONS = defineReasons([
106
121
  ...QUERY_REASONS,
107
122
  ...INPUT_FIELD_REASONS,
108
- ...[...QUERY, ...OTHERS].map(([reason, code, message]) => ({
123
+ ...[...QUERY, ...SIGN_IN, ...OTHERS].map(([reason, code, message]) => ({
109
124
  reason,
110
125
  code,
111
126
  message,
@@ -22,15 +22,26 @@ export const nativeWriterLeaseStore = (layout) => {
22
22
  const root = await realpath(destination);
23
23
  signal?.throwIfAborted();
24
24
  const folder = join(root, markerDirectory);
25
- await mkdir(folder, { recursive: true });
26
- if ((await lstat(folder)).isSymbolicLink())
27
- throw new CliError("validation_error", "archive lock directory must not be a symlink");
28
- await inspection.recover(root, { apply: true, signal });
29
- signal?.throwIfAborted();
30
25
  const owner = { version: 1, pid: process.pid, host: hostname(), nonce: randomUUID() };
31
26
  const name = `${owner.pid}-${owner.nonce}.json`;
32
27
  const path = join(folder, name);
33
- const file = await open(path, "wx", 0o600);
28
+ // Another writer removes the folder once it is empty, so it can vanish before our marker is in it.
29
+ const marker = async (attempt = 1) => {
30
+ await mkdir(folder, { recursive: true });
31
+ try {
32
+ if ((await lstat(folder)).isSymbolicLink())
33
+ throw new CliError("validation_error", "archive lock directory must not be a symlink");
34
+ await inspection.recover(root, { apply: true, signal });
35
+ signal?.throwIfAborted();
36
+ return await open(path, "wx", 0o600);
37
+ }
38
+ catch (error) {
39
+ if (attempt < 5 && missingLeaseFile(error))
40
+ return marker(attempt + 1);
41
+ throw error;
42
+ }
43
+ };
44
+ const file = await marker();
34
45
  let retained = false;
35
46
  let identity;
36
47
  try {
@@ -14,6 +14,8 @@ export const publicUrlOf = (given, { missing, reaches }) => {
14
14
  catch {
15
15
  throw new CliError("validation_error", `--public-url is not an address: ${EXAMPLE}`);
16
16
  }
17
+ if (url.username || url.password)
18
+ throw new CliError("validation_error", "--public-url must not contain login credentials");
17
19
  const local = url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname);
18
20
  if (url.protocol !== "https:" && !local)
19
21
  throw new CliError("validation_error", `--public-url must be https — ${reaches} through it`);
package/dist/mcp/agent.js CHANGED
@@ -111,7 +111,10 @@ const fieldsOf = (info, { omit = [], offline = false, stdin }) => {
111
111
  const long = longOf(option);
112
112
  if (!long || option.mcp === false)
113
113
  continue;
114
- fields.push(optionField(option, long, longs.has(`--no-${long.slice(2)}`)));
114
+ const field = optionField(option, long, longs.has(`--no-${long.slice(2)}`));
115
+ // A positional and its flag alternative share one MCP input; emitting both supplies the value twice.
116
+ if (!fields.some((existing) => existing.name === field.name))
117
+ fields.push(field);
115
118
  }
116
119
  if (offline && info.effects?.remote === "read" && info.offline !== false)
117
120
  fields.push(OFFLINE);
@@ -1,3 +1,4 @@
1
+ export { portOf, publicUrlOf } from "../http/public-url.js";
1
2
  export { type AgentAnswer, type AgentContext, type AgentProgram, type AgentTool, type CatalogueOptions, type CommandAvailability, runCommandForAgent, toolsFromCommands, } from "./agent.js";
2
3
  export { type BotServerOptions, createBotServer, type RunBotCommand } from "./bot/server.js";
3
4
  export { revokeAll } from "./http/oauth.js";
package/dist/mcp/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Apart from the `cli` entry, so a CLI loads the MCP SDK and zod only when it serves MCP.
2
+ export { portOf, publicUrlOf } from "../http/public-url.js";
2
3
  export { runCommandForAgent, toolsFromCommands, } from "./agent.js";
3
4
  export { createBotServer } from "./bot/server.js";
4
5
  export { revokeAll } from "./http/oauth.js";
@@ -0,0 +1,21 @@
1
+ import type { OAuthProvider } from "./provider.js";
2
+ export interface AuthorizeOptions {
3
+ provider: OAuthProvider;
4
+ scopes: readonly string[];
5
+ openBrowser: (url: string) => Promise<void>;
6
+ showUrl?: (url: string) => void;
7
+ /** The redirected URL, or its query, pasted when the browser cannot reach this machine's loopback. */
8
+ readPaste?: (signal: AbortSignal) => Promise<string>;
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ export interface Authorization {
13
+ code: string;
14
+ redirectUri: string;
15
+ codeVerifier: string;
16
+ }
17
+ /**
18
+ * Authorization code with PKCE S256 for an installed app: a loopback listener on 127.0.0.1, a `state` check, a
19
+ * timeout, cancel and a paste fallback. Answers the code without exchanging it.
20
+ */
21
+ export declare const authorize: (options: AuthorizeOptions) => Promise<Authorization>;
@@ -0,0 +1,130 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { CliError, isCliError } from "@wirecat/cli-base";
4
+ const DEFAULT_TIMEOUT_MS = 300_000;
5
+ const pkce = () => {
6
+ const codeVerifier = randomBytes(48).toString("base64url");
7
+ return { codeVerifier, codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url") };
8
+ };
9
+ const queryOf = (text) => {
10
+ const trimmed = text.trim();
11
+ const start = trimmed.indexOf("?");
12
+ return new URLSearchParams(start >= 0 ? trimmed.slice(start + 1).split("#")[0] : trimmed);
13
+ };
14
+ /**
15
+ * Authorization code with PKCE S256 for an installed app: a loopback listener on 127.0.0.1, a `state` check, a
16
+ * timeout, cancel and a paste fallback. Answers the code without exchanging it.
17
+ */
18
+ export const authorize = async (options) => {
19
+ const { provider } = options;
20
+ const cancelled = () => isCliError(options.signal?.reason)
21
+ ? options.signal.reason
22
+ : new CliError("cancelled", `${provider.name} sign-in was cancelled`);
23
+ if (options.signal?.aborted)
24
+ throw cancelled();
25
+ const { codeVerifier, codeChallenge } = pkce();
26
+ const paste = new AbortController();
27
+ let state = "";
28
+ let settled = false;
29
+ let accept = () => { };
30
+ let fail = () => { };
31
+ const code = new Promise((resolve, reject) => {
32
+ accept = (value) => {
33
+ settled = true;
34
+ resolve(value);
35
+ };
36
+ fail = (error) => {
37
+ settled = true;
38
+ reject(error);
39
+ };
40
+ });
41
+ void code.catch(() => { });
42
+ const settle = (query) => {
43
+ const value = query.get("code");
44
+ if (value)
45
+ accept(value);
46
+ else
47
+ fail(new CliError("authentication_error", `${provider.name} sign-in was declined`, { reason: "oauth.declined" }));
48
+ };
49
+ const server = createServer((request, response) => {
50
+ let url;
51
+ try {
52
+ url = new URL(request.url ?? "/", "http://127.0.0.1");
53
+ }
54
+ catch {
55
+ response.writeHead(400).end();
56
+ return;
57
+ }
58
+ if (request.method !== "GET" || url.pathname !== "/callback") {
59
+ response.writeHead(404).end();
60
+ return;
61
+ }
62
+ if (settled || !state || url.searchParams.get("state") !== state) {
63
+ response.writeHead(400).end("This sign-in link does not belong to the waiting sign-in");
64
+ return;
65
+ }
66
+ if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
67
+ response.writeHead(400).end("Missing sign-in result");
68
+ return;
69
+ }
70
+ response.end("Sign-in received; you can close this tab");
71
+ settle(url.searchParams);
72
+ });
73
+ const onAbort = () => fail(cancelled());
74
+ options.signal?.addEventListener("abort", onAbort, { once: true });
75
+ const timer = setTimeout(() => fail(new CliError("timeout", `${provider.name} sign-in timed out`, { reason: "oauth.timeout" })), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(0, "127.0.0.1", resolve);
80
+ });
81
+ const address = server.address();
82
+ if (!address || typeof address === "string")
83
+ throw new CliError("configuration_error", "could not start the local sign-in listener");
84
+ const random = randomBytes(32).toString("base64url");
85
+ const relay = provider.redirect.kind === "relay";
86
+ state = relay ? `${address.port}.${random}` : random;
87
+ const redirectUri = provider.redirect.kind === "relay" ? provider.redirect.url : `http://127.0.0.1:${address.port}/callback`;
88
+ const url = new URL(provider.authorizeUrl);
89
+ for (const [key, value] of Object.entries({
90
+ ...provider.authorizeParams,
91
+ response_type: "code",
92
+ client_id: provider.client.clientId,
93
+ redirect_uri: redirectUri,
94
+ ...(options.scopes.length ? { scope: options.scopes.join(" ") } : {}),
95
+ state,
96
+ code_challenge: codeChallenge,
97
+ code_challenge_method: "S256",
98
+ }))
99
+ url.searchParams.set(key, value);
100
+ options.showUrl?.(url.toString());
101
+ options
102
+ .readPaste?.(paste.signal)
103
+ .then((text) => {
104
+ if (settled)
105
+ return;
106
+ const query = queryOf(text);
107
+ if (query.get("state") !== state || (!query.get("code") && !query.get("error")))
108
+ fail(new CliError("authentication_error", "the pasted sign-in result does not match this sign-in", {
109
+ reason: "oauth.mismatch",
110
+ }));
111
+ else
112
+ settle(query);
113
+ })
114
+ .catch(() => { });
115
+ void Promise.resolve()
116
+ .then(() => options.openBrowser(url.toString()))
117
+ .catch(() => {
118
+ if (!options.readPaste && !options.showUrl)
119
+ fail(new CliError("configuration_error", "could not open the browser, and no sign-in link can be shown"));
120
+ });
121
+ return { code: await code, redirectUri, codeVerifier };
122
+ }
123
+ finally {
124
+ clearTimeout(timer);
125
+ options.signal?.removeEventListener("abort", onAbort);
126
+ paste.abort();
127
+ server.closeAllConnections();
128
+ await new Promise((resolve) => server.close(() => resolve()));
129
+ }
130
+ };
@@ -0,0 +1,5 @@
1
+ export { type Authorization, type AuthorizeOptions, authorize } from "./authorize.js";
2
+ export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, tokenKeeper } from "./keeper.js";
3
+ export type { ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
4
+ export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
5
+ export { type CredentialStorageOptions, credentialStorage, driveSessionCodec, type EncodeInput, oauthAccount, type StoredTokens, standardCodec, type TokenCodec, type TokenStorage, zoomTokenCodec, } from "./storage.js";
@@ -0,0 +1,4 @@
1
+ export { authorize } from "./authorize.js";
2
+ export { tokenKeeper } from "./keeper.js";
3
+ export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
4
+ export { credentialStorage, driveSessionCodec, oauthAccount, standardCodec, zoomTokenCodec, } from "./storage.js";