@owlmeans/cli-auth 0.1.18-rc.1
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/README.md +15 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/cli-auth/SKILL.md +100 -0
- package/build/consts.d.ts +10 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +10 -0
- package/build/consts.js.map +1 -0
- package/build/env-file.d.ts +42 -0
- package/build/env-file.d.ts.map +1 -0
- package/build/env-file.js +107 -0
- package/build/env-file.js.map +1 -0
- package/build/holder.d.ts +38 -0
- package/build/holder.d.ts.map +1 -0
- package/build/holder.js +153 -0
- package/build/holder.js.map +1 -0
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/lock.d.ts +39 -0
- package/build/lock.d.ts.map +1 -0
- package/build/lock.js +49 -0
- package/build/lock.js.map +1 -0
- package/build/open-browser.d.ts +11 -0
- package/build/open-browser.d.ts.map +1 -0
- package/build/open-browser.js +40 -0
- package/build/open-browser.js.map +1 -0
- package/package.json +38 -0
- package/src/consts.ts +12 -0
- package/src/env-file.ts +123 -0
- package/src/holder.ts +215 -0
- package/src/index.ts +5 -0
- package/src/lock.ts +77 -0
- package/src/open-browser.ts +40 -0
- package/tests/env-file.spec.ts +110 -0
- package/tests/holder.spec.ts +242 -0
- package/tests/lock.spec.ts +85 -0
- package/tests/open-browser.spec.ts +14 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +11 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
2
|
+
## Agent guidance
|
|
3
|
+
|
|
4
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
5
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
6
|
+
your project's skill store (`.agents/skills/`):
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.28
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
13
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
14
|
+
open a PR against the source monorepo.
|
|
15
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"package": "@owlmeans/cli-auth",
|
|
4
|
+
"version": "0.1.18-rc.1",
|
|
5
|
+
"generatedAt": "2026-09-18T17:54:11.948Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "cli-auth",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/cli-auth/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/cli-auth/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cli-auth
|
|
3
|
+
description: How to use @owlmeans/cli-auth — Node-side browser sign-in for a command-line tool or stdio MCP server: the `~/.owlmeans` dotenv credentials file and its precedence, atomic 0600 writes, the browser opener and its suppression, the cross-process sign-in lock, and makeCliCredentials (token/require/invalidate/signOut) that runs the device grant. Auto-invoked when adding sign-in to a CLI, reading or writing the credentials file, diagnosing "signed in but the token is ignored", or writing tests that must not touch the developer's real credentials.
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
7
|
+
|
|
8
|
+
# @owlmeans/cli-auth
|
|
9
|
+
|
|
10
|
+
**Layer:** Tooling (Node/Bun — no React, no DOM, no framework runtime)
|
|
11
|
+
**Install:** `"@owlmeans/cli-auth": "^0.1.18-rc.1"` in `dependencies`
|
|
12
|
+
**Contracts:** `@owlmeans/oauth` (discovery, device authorization, polling, revoke, `SignInRequired`)
|
|
13
|
+
**Server half:** any API built on `@owlmeans/server-oauth`; **consumers:** `@owlmeans/viable-mcp`
|
|
14
|
+
|
|
15
|
+
## Key Exports
|
|
16
|
+
|
|
17
|
+
| Export | Description |
|
|
18
|
+
|--------|-------------|
|
|
19
|
+
| `makeCliCredentials(opts)` | The holder: `token()` · `require(waitMs?)` · `invalidate(rejected)` · `signOut()` |
|
|
20
|
+
| `resolveEnvFile(env?)` | `OWLMEANS_CREDENTIALS`, else `~/.owlmeans` |
|
|
21
|
+
| `parseEnv(content)` | `KEY=value`, optional `export `, `#` comments, one level of quotes |
|
|
22
|
+
| `readCredentialsFile(env?)` | The file's values alone — what a token is *bound* to |
|
|
23
|
+
| `loadOwlmeansEnv(env?)` | File overlaid by the process environment (below) |
|
|
24
|
+
| `setEnvValues(path, values)` | Replace named keys, keep every other line; atomic, `0600`; returns `{ insecurePermissions }` |
|
|
25
|
+
| `openBrowser(url, env?)` | Best-effort detached opener; `false` on any refusal |
|
|
26
|
+
| `claimOrJoinLock` · `readLock` · `releaseLock` · `lockPathFor` · `SignInLockInfo` | The sign-in lock |
|
|
27
|
+
| `ENV_CREDENTIALS_FILE` · `DEFAULT_CREDENTIALS_FILENAME` · `DEFAULT_WAIT_MS` (20 s) · `MAX_SIGN_IN_WAIT_MS` (15 min) | Constants |
|
|
28
|
+
|
|
29
|
+
`CliCredentialsOptions`: `apiUrl`, `clientId` (a static client the server declared, or a CIMD URL),
|
|
30
|
+
`tokenEnvKey`, `apiUrlEnvKey` (both required — the package is not tied to one product's key
|
|
31
|
+
names), `deviceName?` (defaults to `host · user`), `resource?` (defaults to `apiUrl`), `scope?`,
|
|
32
|
+
`env?` (defaults to `process.env`), `onNotify?`.
|
|
33
|
+
|
|
34
|
+
## The credentials file
|
|
35
|
+
|
|
36
|
+
Dotenv-style `KEY=VALUE`, e.g. `VIABLE_API_URL=…` and `VIABLE_API_TOKEN=…`. Never merged with another
|
|
37
|
+
file; a CLI that serves several deployments points `OWLMEANS_CREDENTIALS` at one file each.
|
|
38
|
+
|
|
39
|
+
- **The environment wins over the file** (`loadOwlmeansEnv` → `{ ...file, ...envWithoutEmpty }`).
|
|
40
|
+
- **An empty environment value counts as unset.** A harness config that expands an unset variable
|
|
41
|
+
(`${VIABLE_API_TOKEN:-}`) produces `''`, which must not shadow the file — "I did not set it" and
|
|
42
|
+
"set it to nothing" are not the same, and the file is the more deliberate of the two.
|
|
43
|
+
- **A token belongs to the file's API URL.** `token()` refuses a file token when the file names a
|
|
44
|
+
different `apiUrlEnvKey` value than the one asked for; a file that names none belongs to whichever
|
|
45
|
+
URL is asked for. A token is never sent to another API.
|
|
46
|
+
- **Writes are atomic and private:** temp file in the same directory, `rename`, mode `0600`, other
|
|
47
|
+
lines and comments preserved. Loose permissions on an existing file are *reported*
|
|
48
|
+
(`insecurePermissions`), not silently tightened — someone may have set them on purpose.
|
|
49
|
+
- The token is never read from argv, never written to a harness/config file.
|
|
50
|
+
|
|
51
|
+
## The holder
|
|
52
|
+
|
|
53
|
+
- **`token()`** — the environment value, else the bound file value, else `null`.
|
|
54
|
+
- **`require(waitMs = DEFAULT_WAIT_MS)`** — returns a token, starting or joining ONE device sign-in
|
|
55
|
+
per API URL: discover → `requestDeviceAuthorization` → claim/join the lock → `notify("Sign in at
|
|
56
|
+
<url> with code <code>")` → open the browser (owner only) → `pollDeviceToken` → persist
|
|
57
|
+
`{tokenEnvKey, apiUrlEnvKey}` → resolve. If `waitMs` runs out first it throws `signInRequired`
|
|
58
|
+
(`url`, `code`, `expiresAt` from the lock) and **polling continues in the background**; the next
|
|
59
|
+
`require()` joins it instead of starting over. `denied`/`expired`/`aborted` become
|
|
60
|
+
`OAuthAccessDenied` / `OAuthError`.
|
|
61
|
+
- **The `waitMs` ceiling is a timer that is cleared** when the race is decided. A pending timer keeps
|
|
62
|
+
a Node process alive, so an uncleared one made `viable-mcp login` (ceiling 15 minutes) hang after
|
|
63
|
+
it had signed in. Any new timeout in this package clears its timer the same way.
|
|
64
|
+
- **Single-flight in-process:** `beginOrJoin` is synchronous up to the module-level
|
|
65
|
+
`inFlightByApiUrl` map insert, so two racing `require()` calls converge. An `await` before that
|
|
66
|
+
insert reopens the race (a `Promise<Promise<…>>` there once made `require()` hang forever).
|
|
67
|
+
- **Single-flight across processes:** `<credentials file>.lock` (mode `0600`) records `pid`,
|
|
68
|
+
`apiUrl`, `verificationUri`, `userCode`, `deviceCode`, `interval`, `expiresAt` and a `nonce`. A live
|
|
69
|
+
lock for the same URL (unexpired, pid alive) makes a second process a **joiner** — it shows the same
|
|
70
|
+
code, does not open a second tab, and polls the same authorization. `releaseLock` deletes only a
|
|
71
|
+
lock carrying the caller's own `nonce`. Best effort, not mutual exclusion: two processes racing the
|
|
72
|
+
same instant may each drive a sign-in, costing a tab, never a corrupt file.
|
|
73
|
+
- **`invalidate(rejected)`** — call on a 401. A file token that is still the rejected one is removed
|
|
74
|
+
(next `require()` signs in); an **environment** token is never silently replaced — it throws
|
|
75
|
+
`TokenRejected(tokenEnvKey)` so the operator hears "VIABLE_API_TOKEN was refused" instead of the
|
|
76
|
+
tool quietly becoming another identity.
|
|
77
|
+
- **`signOut()`** — best-effort `revokeToken` at the server, then remove the file token.
|
|
78
|
+
|
|
79
|
+
## Opening the browser
|
|
80
|
+
|
|
81
|
+
`openBrowser` spawns `open` / `xdg-open` / `cmd /c start` **detached with `stdio: 'ignore'`**, because
|
|
82
|
+
stdout may carry a protocol (an MCP server's JSON-RPC) that nothing the opened program writes may
|
|
83
|
+
reach. It refuses (returns `false`) when `OWLMEANS_NO_BROWSER=1` or `BROWSER=none`, and on Linux
|
|
84
|
+
with neither `DISPLAY` nor `WAYLAND_DISPLAY`. Automation and end-to-end runs set
|
|
85
|
+
`OWLMEANS_NO_BROWSER=1` and open the printed URL themselves. A failed open is never fatal — the
|
|
86
|
+
"sign in at … with code …" line is the real interface and works over SSH and in containers.
|
|
87
|
+
|
|
88
|
+
## Testing
|
|
89
|
+
|
|
90
|
+
Category A: `bun test ./tests` (`env-file`, `holder`, `lock`, `open-browser` specs). Rules that keep
|
|
91
|
+
the suite off the developer's machine:
|
|
92
|
+
|
|
93
|
+
- Point `OWLMEANS_CREDENTIALS` at a temp path — never let a test read the real `~/.owlmeans`.
|
|
94
|
+
- Holder tests use a **distinct `apiUrl` per test**: `inFlightByApiUrl` is module-level, so a shared
|
|
95
|
+
URL leaks a pending sign-in from one test into the next.
|
|
96
|
+
- `holder.spec.ts` replaces `globalThis.fetch` with a fake device-flow server (metadata,
|
|
97
|
+
`device_authorization` with `interval: 0.001`, a token endpoint that stays `authorization_pending`
|
|
98
|
+
for N polls, revoke) and restores it in `afterEach`; never wait out a real 5 s interval.
|
|
99
|
+
- Anything that spawns a CLI passes `OWLMEANS_NO_BROWSER=1` and deletes the token and any URL
|
|
100
|
+
override variables from the inherited environment.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Overrides where the credentials file lives. Unset means `~/.owlmeans`. */
|
|
2
|
+
export declare const ENV_CREDENTIALS_FILE = "OWLMEANS_CREDENTIALS";
|
|
3
|
+
export declare const DEFAULT_CREDENTIALS_FILENAME = ".owlmeans";
|
|
4
|
+
/** How long `require()` waits for a sign-in this call itself started before returning
|
|
5
|
+
* `SignInRequired` and letting the sign-in continue in the background. */
|
|
6
|
+
export declare const DEFAULT_WAIT_MS = 20000;
|
|
7
|
+
/** RFC 8628's own ceiling is whatever the server answered with; this is the poller's OWN patience
|
|
8
|
+
* before it gives up entirely, independent of `expires_in`. */
|
|
9
|
+
export declare const MAX_SIGN_IN_WAIT_MS: number;
|
|
10
|
+
//# sourceMappingURL=consts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,eAAO,MAAM,oBAAoB,yBAAyB,CAAA;AAE1D,eAAO,MAAM,4BAA4B,cAAc,CAAA;AAEvD;0EAC0E;AAC1E,eAAO,MAAM,eAAe,QAAS,CAAA;AAErC;+DAC+D;AAC/D,eAAO,MAAM,mBAAmB,QAAiB,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Overrides where the credentials file lives. Unset means `~/.owlmeans`. */
|
|
2
|
+
export const ENV_CREDENTIALS_FILE = 'OWLMEANS_CREDENTIALS';
|
|
3
|
+
export const DEFAULT_CREDENTIALS_FILENAME = '.owlmeans';
|
|
4
|
+
/** How long `require()` waits for a sign-in this call itself started before returning
|
|
5
|
+
* `SignInRequired` and letting the sign-in continue in the background. */
|
|
6
|
+
export const DEFAULT_WAIT_MS = 20_000;
|
|
7
|
+
/** RFC 8628's own ceiling is whatever the server answered with; this is the poller's OWN patience
|
|
8
|
+
* before it gives up entirely, independent of `expires_in`. */
|
|
9
|
+
export const MAX_SIGN_IN_WAIT_MS = 15 * 60 * 1000;
|
|
10
|
+
//# sourceMappingURL=consts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAA;AAE1D,MAAM,CAAC,MAAM,4BAA4B,GAAG,WAAW,CAAA;AAEvD;0EAC0E;AAC1E,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAA;AAErC;+DAC+D;AAC/D,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the credentials file lives: `OWLMEANS_CREDENTIALS`, or `~/.owlmeans`.
|
|
3
|
+
*
|
|
4
|
+
* A CLI that talks to more than one deployment (a staging environment, a self-hosted instance)
|
|
5
|
+
* points this at a different file per deployment — the file is never merged with another one, and
|
|
6
|
+
* a token it holds is meaningless anywhere but the API URL it was signed in against.
|
|
7
|
+
*/
|
|
8
|
+
export declare const resolveEnvFile: (env?: NodeJS.ProcessEnv) => string;
|
|
9
|
+
/**
|
|
10
|
+
* Parse a dotenv-shaped body: `KEY=value`, an optional `export ` prefix, `#` comments, one level
|
|
11
|
+
* of quoting. Deliberately small — a dotenv library would add a dependency for a format this
|
|
12
|
+
* package itself writes, and this is the one shape it ever needs to read back.
|
|
13
|
+
*/
|
|
14
|
+
export declare const parseEnv: (content: string) => Record<string, string>;
|
|
15
|
+
/**
|
|
16
|
+
* The credentials file's values alone, with no environment overlay — what a token is bound to.
|
|
17
|
+
*/
|
|
18
|
+
export declare const readCredentialsFile: (env?: NodeJS.ProcessEnv) => Promise<Record<string, string>>;
|
|
19
|
+
/**
|
|
20
|
+
* The file, with the process environment layered over it — environment wins, but an environment
|
|
21
|
+
* value that is the EMPTY STRING is treated as unset.
|
|
22
|
+
*
|
|
23
|
+
* The empty-string rule exists because a harness config commonly expands an unset shell variable
|
|
24
|
+
* to `''` (`${VIABLE_API_TOKEN:-}`), and a literal empty override must not shadow a real value the
|
|
25
|
+
* file holds — that would make "I have not set this" indistinguishable from "I am overriding this
|
|
26
|
+
* to nothing", and the file is always the more deliberate of the two.
|
|
27
|
+
*/
|
|
28
|
+
export declare const loadOwlmeansEnv: (env?: NodeJS.ProcessEnv) => Promise<Record<string, string>>;
|
|
29
|
+
/**
|
|
30
|
+
* Replace the named keys in the credentials file, keeping every other line — comments, a key this
|
|
31
|
+
* call did not touch, blank lines — exactly where they were.
|
|
32
|
+
*
|
|
33
|
+
* Written atomically (a temp file in the same directory, then a rename) so a process killed
|
|
34
|
+
* mid-write never leaves a half-written credentials file behind, and created with mode `0600`
|
|
35
|
+
* because this file can hold a live access token. An existing file that is readable by anyone but
|
|
36
|
+
* its owner is reported back rather than silently tightened — permissions someone else set on
|
|
37
|
+
* purpose are theirs to change.
|
|
38
|
+
*/
|
|
39
|
+
export declare const setEnvValues: (path: string, values: Record<string, string | undefined>) => Promise<{
|
|
40
|
+
insecurePermissions: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
//# sourceMappingURL=env-file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env-file.d.ts","sourceRoot":"","sources":["../src/env-file.ts"],"names":[],"mappings":"AAMA;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,SAAS,MAAM,CAAC,UAAU,KAAiB,MAGnB,CAAA;AAEnD;;;;GAIG;AACH,eAAO,MAAM,QAAQ,YAAa,MAAM,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAkB/D,CAAA;AAKD;;GAEG;AACH,eAAO,MAAM,mBAAmB,SAAe,MAAM,CAAC,UAAU,KAAiB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CACvD,CAAA;AAExD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,SAAe,MAAM,CAAC,UAAU,KAAiB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ1G,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,SACjB,MAAM,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,KACvD,OAAO,CAAC;IAAE,mBAAmB,EAAE,OAAO,CAAA;CAAE,CAuC1C,CAAA"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { DEFAULT_CREDENTIALS_FILENAME, ENV_CREDENTIALS_FILE } from './consts.js';
|
|
6
|
+
/**
|
|
7
|
+
* Where the credentials file lives: `OWLMEANS_CREDENTIALS`, or `~/.owlmeans`.
|
|
8
|
+
*
|
|
9
|
+
* A CLI that talks to more than one deployment (a staging environment, a self-hosted instance)
|
|
10
|
+
* points this at a different file per deployment — the file is never merged with another one, and
|
|
11
|
+
* a token it holds is meaningless anywhere but the API URL it was signed in against.
|
|
12
|
+
*/
|
|
13
|
+
export const resolveEnvFile = (env = process.env) => env[ENV_CREDENTIALS_FILE] != null && env[ENV_CREDENTIALS_FILE] !== ''
|
|
14
|
+
? env[ENV_CREDENTIALS_FILE]
|
|
15
|
+
: join(homedir(), DEFAULT_CREDENTIALS_FILENAME);
|
|
16
|
+
/**
|
|
17
|
+
* Parse a dotenv-shaped body: `KEY=value`, an optional `export ` prefix, `#` comments, one level
|
|
18
|
+
* of quoting. Deliberately small — a dotenv library would add a dependency for a format this
|
|
19
|
+
* package itself writes, and this is the one shape it ever needs to read back.
|
|
20
|
+
*/
|
|
21
|
+
export const parseEnv = (content) => {
|
|
22
|
+
const values = {};
|
|
23
|
+
for (const raw of content.split('\n')) {
|
|
24
|
+
const line = raw.trim();
|
|
25
|
+
if (line === '' || line.startsWith('#'))
|
|
26
|
+
continue;
|
|
27
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
28
|
+
if (match == null)
|
|
29
|
+
continue;
|
|
30
|
+
let value = match[2].trim();
|
|
31
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length > 1)
|
|
32
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length > 1)) {
|
|
33
|
+
value = value.slice(1, -1);
|
|
34
|
+
}
|
|
35
|
+
values[match[1]] = value;
|
|
36
|
+
}
|
|
37
|
+
return values;
|
|
38
|
+
};
|
|
39
|
+
const readFileIfPresent = async (path) => await readFile(path, 'utf-8').catch(() => '');
|
|
40
|
+
/**
|
|
41
|
+
* The credentials file's values alone, with no environment overlay — what a token is bound to.
|
|
42
|
+
*/
|
|
43
|
+
export const readCredentialsFile = async (env = process.env) => parseEnv(await readFileIfPresent(resolveEnvFile(env)));
|
|
44
|
+
/**
|
|
45
|
+
* The file, with the process environment layered over it — environment wins, but an environment
|
|
46
|
+
* value that is the EMPTY STRING is treated as unset.
|
|
47
|
+
*
|
|
48
|
+
* The empty-string rule exists because a harness config commonly expands an unset shell variable
|
|
49
|
+
* to `''` (`${VIABLE_API_TOKEN:-}`), and a literal empty override must not shadow a real value the
|
|
50
|
+
* file holds — that would make "I have not set this" indistinguishable from "I am overriding this
|
|
51
|
+
* to nothing", and the file is always the more deliberate of the two.
|
|
52
|
+
*/
|
|
53
|
+
export const loadOwlmeansEnv = async (env = process.env) => {
|
|
54
|
+
const file = await readCredentialsFile(env);
|
|
55
|
+
const fromEnv = {};
|
|
56
|
+
Object.entries(env).forEach(([key, value]) => {
|
|
57
|
+
if (value != null && value !== '')
|
|
58
|
+
fromEnv[key] = value;
|
|
59
|
+
});
|
|
60
|
+
return { ...file, ...fromEnv };
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Replace the named keys in the credentials file, keeping every other line — comments, a key this
|
|
64
|
+
* call did not touch, blank lines — exactly where they were.
|
|
65
|
+
*
|
|
66
|
+
* Written atomically (a temp file in the same directory, then a rename) so a process killed
|
|
67
|
+
* mid-write never leaves a half-written credentials file behind, and created with mode `0600`
|
|
68
|
+
* because this file can hold a live access token. An existing file that is readable by anyone but
|
|
69
|
+
* its owner is reported back rather than silently tightened — permissions someone else set on
|
|
70
|
+
* purpose are theirs to change.
|
|
71
|
+
*/
|
|
72
|
+
export const setEnvValues = async (path, values) => {
|
|
73
|
+
const existing = await readFileIfPresent(path);
|
|
74
|
+
const lines = existing === '' ? [] : existing.split('\n');
|
|
75
|
+
const claimed = new Set();
|
|
76
|
+
const rewritten = lines.map(line => {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(trimmed);
|
|
79
|
+
if (match == null || !(match[1] in values))
|
|
80
|
+
return line;
|
|
81
|
+
claimed.add(match[1]);
|
|
82
|
+
const value = values[match[1]];
|
|
83
|
+
return value == null ? null : `${match[1]}=${value}`;
|
|
84
|
+
}).filter((line) => line != null);
|
|
85
|
+
Object.entries(values).forEach(([key, value]) => {
|
|
86
|
+
if (claimed.has(key) || value == null)
|
|
87
|
+
return;
|
|
88
|
+
rewritten.push(`${key}=${value}`);
|
|
89
|
+
});
|
|
90
|
+
const body = `${rewritten.join('\n').replace(/\n+$/, '')}\n`;
|
|
91
|
+
await mkdir(dirname(path), { recursive: true });
|
|
92
|
+
const tmp = join(dirname(path), `.${DEFAULT_CREDENTIALS_FILENAME}.${randomBytes(6).toString('hex')}.tmp`);
|
|
93
|
+
await writeFile(tmp, body, { mode: 0o600 });
|
|
94
|
+
await rename(tmp, path);
|
|
95
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
96
|
+
let insecurePermissions = false;
|
|
97
|
+
try {
|
|
98
|
+
const info = await stat(path);
|
|
99
|
+
insecurePermissions = (info.mode & 0o077) !== 0;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Nothing to report if the stat itself fails right after a successful write — unusual enough
|
|
103
|
+
// that guessing at a permission problem would be noise.
|
|
104
|
+
}
|
|
105
|
+
return { insecurePermissions };
|
|
106
|
+
};
|
|
107
|
+
//# sourceMappingURL=env-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env-file.js","sourceRoot":"","sources":["../src/env-file.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface CliCredentialsOptions {
|
|
2
|
+
/** The API origin this credential set is for, and the OAuth `resource` it is scoped to unless
|
|
3
|
+
* `resource` says otherwise. */
|
|
4
|
+
apiUrl: string;
|
|
5
|
+
/** This CLI's OAuth `client_id` — a static one the authorization server declared, or an https
|
|
6
|
+
* Client ID Metadata Document URL. */
|
|
7
|
+
clientId: string;
|
|
8
|
+
deviceName?: string;
|
|
9
|
+
resource?: string;
|
|
10
|
+
scope?: string;
|
|
11
|
+
/** Which key in `~/.owlmeans` (and the environment) carries the token. */
|
|
12
|
+
tokenEnvKey: string;
|
|
13
|
+
/** Which key records the URL a stored token belongs to. A file naming no URL at all is treated
|
|
14
|
+
* as belonging to whichever `apiUrl` is asked for — only an explicit MISMATCH refuses it. */
|
|
15
|
+
apiUrlEnvKey: string;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
/** Best-effort progress — "open this URL and enter this code", "signed in", a failure. A host
|
|
18
|
+
* wires this to stderr, an MCP `notifications/message`, or nothing at all. */
|
|
19
|
+
onNotify?: (message: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface CliCredentials {
|
|
22
|
+
/** The token this call site should use right now: the environment, then the bound file value,
|
|
23
|
+
* or `null` when neither has one. */
|
|
24
|
+
token: () => Promise<string | null>;
|
|
25
|
+
/** Ensure a usable token exists. Starts or joins a device sign-in when there is none, waits up
|
|
26
|
+
* to `waitMs` for it to be approved, and returns the token. The sign-in keeps running in the
|
|
27
|
+
* background past that wait — a later `require()` call picks up wherever it left off, rather
|
|
28
|
+
* than starting over. */
|
|
29
|
+
require: (waitMs?: number) => Promise<string>;
|
|
30
|
+
/** A 401 happened while presenting `rejectedToken`. A token that came from the FILE is forgotten
|
|
31
|
+
* so the next `require()` signs in again; a token that came from the ENVIRONMENT is reported —
|
|
32
|
+
* silently trying another identity behind an operator's back is worse than failing loudly. */
|
|
33
|
+
invalidate: (rejectedToken: string) => Promise<void>;
|
|
34
|
+
/** Revoke the current token at the server and remove it from the file. */
|
|
35
|
+
signOut: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export declare const makeCliCredentials: (opts: CliCredentialsOptions) => CliCredentials;
|
|
38
|
+
//# sourceMappingURL=holder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"holder.d.ts","sourceRoot":"","sources":["../src/holder.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,qBAAqB;IACpC;oCACgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd;0CACsC;IACtC,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAA;IACnB;iGAC6F;IAC7F,YAAY,EAAE,MAAM,CAAA;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB;kFAC8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACrC;AAED,MAAM,WAAW,cAAc;IAC7B;yCACqC;IACrC,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnC;;;6BAGyB;IACzB,OAAO,EAAE,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IAC7C;;kGAE8F;IAC9F,UAAU,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACpD,0EAA0E;IAC1E,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7B;AAkBD,eAAO,MAAM,kBAAkB,SAAU,qBAAqB,KAAG,cAoJhE,CAAA"}
|
package/build/holder.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { hostname, userInfo } from 'node:os';
|
|
2
|
+
import { discoverAuthorizationServer, OAuthAccessDenied, OAuthError, OAUTH_DEVICE_NAME_MAX, pollDeviceToken, requestDeviceAuthorization, revokeToken, signInRequired, TokenRejected } from '@owlmeans/oauth';
|
|
3
|
+
import { DEFAULT_WAIT_MS } from './consts.js';
|
|
4
|
+
import { readCredentialsFile, resolveEnvFile, setEnvValues } from './env-file.js';
|
|
5
|
+
import { claimOrJoinLock, lockPathFor, readLock, releaseLock } from './lock.js';
|
|
6
|
+
import { openBrowser } from './open-browser.js';
|
|
7
|
+
const defaultDeviceName = () => {
|
|
8
|
+
let who = 'cli';
|
|
9
|
+
try {
|
|
10
|
+
who = userInfo().username;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Some sandboxes have no passwd entry for the running uid; the hostname alone still helps.
|
|
14
|
+
}
|
|
15
|
+
return `${hostname()} · ${who}`.slice(0, OAUTH_DEVICE_NAME_MAX);
|
|
16
|
+
};
|
|
17
|
+
/** One in-flight sign-in per API URL, per process — a second `require()` call while the first is
|
|
18
|
+
* still waiting joins the SAME poll rather than requesting a second device code. */
|
|
19
|
+
const inFlightByApiUrl = new Map();
|
|
20
|
+
export const makeCliCredentials = (opts) => {
|
|
21
|
+
const env = opts.env ?? process.env;
|
|
22
|
+
const notify = (message) => opts.onNotify?.(message);
|
|
23
|
+
const credentialsPath = resolveEnvFile(env);
|
|
24
|
+
const lockPath = lockPathFor(credentialsPath);
|
|
25
|
+
const token = async () => {
|
|
26
|
+
const envValue = env[opts.tokenEnvKey];
|
|
27
|
+
if (envValue != null && envValue !== '')
|
|
28
|
+
return envValue;
|
|
29
|
+
const file = await readCredentialsFile(env);
|
|
30
|
+
const boundUrl = file[opts.apiUrlEnvKey];
|
|
31
|
+
if (boundUrl != null && boundUrl !== '' && boundUrl !== opts.apiUrl)
|
|
32
|
+
return null;
|
|
33
|
+
const fileToken = file[opts.tokenEnvKey];
|
|
34
|
+
return fileToken != null && fileToken !== '' ? fileToken : null;
|
|
35
|
+
};
|
|
36
|
+
/** Start a fresh device authorization, or adopt another live process's own — either way, claim
|
|
37
|
+
* or join the lock BEFORE requesting one, so the decision and the request agree. */
|
|
38
|
+
const claimJoinOrStart = async (server) => {
|
|
39
|
+
const authorization = await requestDeviceAuthorization(server, {
|
|
40
|
+
client_id: opts.clientId, scope: opts.scope, resource: opts.resource ?? opts.apiUrl,
|
|
41
|
+
device_name: opts.deviceName ?? defaultDeviceName(),
|
|
42
|
+
});
|
|
43
|
+
return await claimOrJoinLock(lockPath, opts.apiUrl, {
|
|
44
|
+
verificationUri: authorization.verification_uri,
|
|
45
|
+
verificationUriComplete: authorization.verification_uri_complete,
|
|
46
|
+
userCode: authorization.user_code,
|
|
47
|
+
deviceCode: authorization.device_code,
|
|
48
|
+
interval: authorization.interval,
|
|
49
|
+
expiresAt: Date.now() + authorization.expires_in * 1000,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Synchronous on purpose, up to the point it records itself in `inFlightByApiUrl` — that is
|
|
54
|
+
* what makes two `require()` calls racing in the SAME process converge on one sign-in rather
|
|
55
|
+
* than each starting its own before either has had a chance to publish that it is working on
|
|
56
|
+
* it. (Cross-PROCESS concurrency is what the file lock inside `claimJoinOrStart` is for.)
|
|
57
|
+
*/
|
|
58
|
+
const beginOrJoin = () => {
|
|
59
|
+
const existing = inFlightByApiUrl.get(opts.apiUrl);
|
|
60
|
+
if (existing != null)
|
|
61
|
+
return existing;
|
|
62
|
+
const promise = (async () => {
|
|
63
|
+
const server = await discoverAuthorizationServer(opts.apiUrl);
|
|
64
|
+
const claim = await claimJoinOrStart(server);
|
|
65
|
+
notify(claim.info.userCode != null
|
|
66
|
+
? `Sign in at ${claim.info.verificationUri} with code ${claim.info.userCode}`
|
|
67
|
+
: `Sign in at ${claim.info.verificationUri}`);
|
|
68
|
+
// Only the owner opens a browser — a joining process's own (unused) device authorization is
|
|
69
|
+
// simply left to expire, since a second, un-displayed code would only teach the server's
|
|
70
|
+
// rate limiter that this client polls too eagerly.
|
|
71
|
+
if (claim.owner) {
|
|
72
|
+
openBrowser(claim.info.verificationUriComplete ?? claim.info.verificationUri);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const outcome = await pollDeviceToken(server, {
|
|
76
|
+
clientId: opts.clientId, deviceCode: claim.info.deviceCode, interval: claim.info.interval,
|
|
77
|
+
expiresAt: claim.info.expiresAt,
|
|
78
|
+
});
|
|
79
|
+
if (outcome.status === 'authorized') {
|
|
80
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: outcome.token, [opts.apiUrlEnvKey]: opts.apiUrl });
|
|
81
|
+
notify('Signed in.');
|
|
82
|
+
}
|
|
83
|
+
return outcome;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
inFlightByApiUrl.delete(opts.apiUrl);
|
|
87
|
+
if (claim.owner)
|
|
88
|
+
await releaseLock(lockPath, claim.info.nonce);
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
inFlightByApiUrl.set(opts.apiUrl, promise);
|
|
92
|
+
return promise;
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
token,
|
|
96
|
+
require: async (waitMs = DEFAULT_WAIT_MS) => {
|
|
97
|
+
const existing = await token();
|
|
98
|
+
if (existing != null)
|
|
99
|
+
return existing;
|
|
100
|
+
// Deliberately NOT awaited here — `beginOrJoin()` is the pending sign-in itself, and
|
|
101
|
+
// racing it (rather than awaiting it first) is what lets `require()` return control to
|
|
102
|
+
// its caller after `waitMs` while the sign-in keeps running toward its own resolution.
|
|
103
|
+
const pollPromise = beginOrJoin();
|
|
104
|
+
// The ceiling's timer is cleared once the race is decided: a pending timer keeps a process
|
|
105
|
+
// alive, and `viable-mcp login` (waiting up to 15 minutes) must exit the moment it is signed in.
|
|
106
|
+
let ceiling;
|
|
107
|
+
const raced = await Promise.race([
|
|
108
|
+
pollPromise.then(outcome => ({ settled: true, outcome })),
|
|
109
|
+
new Promise(resolve => {
|
|
110
|
+
ceiling = setTimeout(() => resolve({ settled: false }), waitMs);
|
|
111
|
+
}),
|
|
112
|
+
]).finally(() => clearTimeout(ceiling));
|
|
113
|
+
if (!raced.settled) {
|
|
114
|
+
const lock = await readLock(lockPath);
|
|
115
|
+
throw signInRequired({
|
|
116
|
+
url: lock?.verificationUri ?? opts.apiUrl, code: lock?.userCode, expiresAt: lock?.expiresAt,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
switch (raced.outcome.status) {
|
|
120
|
+
case 'authorized':
|
|
121
|
+
return raced.outcome.token;
|
|
122
|
+
case 'denied':
|
|
123
|
+
throw new OAuthAccessDenied('sign-in');
|
|
124
|
+
case 'expired':
|
|
125
|
+
throw new OAuthError('sign-in:expired');
|
|
126
|
+
default:
|
|
127
|
+
throw new OAuthError('sign-in:aborted');
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
invalidate: async (rejectedToken) => {
|
|
131
|
+
const file = await readCredentialsFile(env);
|
|
132
|
+
if (file[opts.tokenEnvKey] === rejectedToken) {
|
|
133
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined });
|
|
134
|
+
notify('The stored token was refused. Signing in again.');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (env[opts.tokenEnvKey] === rejectedToken) {
|
|
138
|
+
throw new TokenRejected(opts.tokenEnvKey);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
signOut: async () => {
|
|
142
|
+
const current = await token();
|
|
143
|
+
if (current == null)
|
|
144
|
+
return;
|
|
145
|
+
const server = await discoverAuthorizationServer(opts.apiUrl).catch(() => null);
|
|
146
|
+
if (server != null) {
|
|
147
|
+
await revokeToken(server, { token: current, clientId: opts.clientId }).catch(() => undefined);
|
|
148
|
+
}
|
|
149
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined });
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
//# sourceMappingURL=holder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"holder.js","sourceRoot":"","sources":["../src/holder.ts"],"names":[],"mappings":"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"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA"}
|
package/build/lock.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one process tells every other one about the sign-in it is driving — the browser URL and
|
|
3
|
+
* code so a SECOND process can show the same "waiting on…" state instead of opening a second
|
|
4
|
+
* browser tab for the same API URL.
|
|
5
|
+
*/
|
|
6
|
+
export interface SignInLockInfo {
|
|
7
|
+
pid: number;
|
|
8
|
+
apiUrl: string;
|
|
9
|
+
verificationUri: string;
|
|
10
|
+
verificationUriComplete?: string;
|
|
11
|
+
userCode?: string;
|
|
12
|
+
/** The device flow's own secret — protected the same way the eventual access token is (mode
|
|
13
|
+
* `0600`, same directory as the credentials file), so a joining process can poll the SAME
|
|
14
|
+
* pending authorization instead of requesting a second one nobody will ever display. */
|
|
15
|
+
deviceCode: string;
|
|
16
|
+
interval: number;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
/** Proves ownership at release time — a process only clears the lock it itself wrote. */
|
|
19
|
+
nonce: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const lockPathFor: (credentialsPath: string) => string;
|
|
22
|
+
export declare const readLock: (path: string) => Promise<SignInLockInfo | null>;
|
|
23
|
+
/**
|
|
24
|
+
* Become the one process driving this API URL's sign-in, or find out somebody else already is.
|
|
25
|
+
*
|
|
26
|
+
* Best-effort, not a mutual-exclusion guarantee: two processes racing this at the exact same
|
|
27
|
+
* instant can both conclude they are the owner, and each then drives its own independent device
|
|
28
|
+
* authorization. That costs an extra browser tab, never a corrupted file or a double-spent code —
|
|
29
|
+
* this is a convenience for the ordinary case (a person running two terminal tabs), not a
|
|
30
|
+
* correctness boundary.
|
|
31
|
+
*/
|
|
32
|
+
export declare const claimOrJoinLock: (path: string, apiUrl: string, info: Omit<SignInLockInfo, 'pid' | 'nonce' | 'apiUrl'>) => Promise<{
|
|
33
|
+
owner: boolean;
|
|
34
|
+
info: SignInLockInfo;
|
|
35
|
+
}>;
|
|
36
|
+
/** Clear the lock, but only the copy of it this process itself wrote — a stale read after
|
|
37
|
+
* somebody else has already reclaimed the same path must never delete THEIR lock instead. */
|
|
38
|
+
export declare const releaseLock: (path: string, nonce: string) => Promise<void>;
|
|
39
|
+
//# sourceMappingURL=lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAIA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,eAAe,EAAE,MAAM,CAAA;IACvB,uBAAuB,CAAC,EAAE,MAAM,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;4FAEwF;IACxF,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAA;CACd;AAED,eAAO,MAAM,WAAW,oBAAqB,MAAM,KAAG,MAAmC,CAAA;AAEzF,eAAO,MAAM,QAAQ,SAAgB,MAAM,KAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAM1E,CAAA;AAYD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,SACpB,MAAM,UAAU,MAAM,QAAQ,IAAI,CAAC,cAAc,EAAE,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC,KACnF,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,cAAc,CAAA;CAAE,CAWlD,CAAA;AAED;6FAC6F;AAC7F,eAAO,MAAM,WAAW,SAAgB,MAAM,SAAS,MAAM,KAAG,OAAO,CAAC,IAAI,CAK3E,CAAA"}
|