@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/build/lock.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export const lockPathFor = (credentialsPath) => `${credentialsPath}.lock`;
|
|
5
|
+
export const readLock = async (path) => {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await readFile(path, 'utf-8'));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const isAlive = (pid) => {
|
|
14
|
+
try {
|
|
15
|
+
process.kill(pid, 0);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Become the one process driving this API URL's sign-in, or find out somebody else already is.
|
|
24
|
+
*
|
|
25
|
+
* Best-effort, not a mutual-exclusion guarantee: two processes racing this at the exact same
|
|
26
|
+
* instant can both conclude they are the owner, and each then drives its own independent device
|
|
27
|
+
* authorization. That costs an extra browser tab, never a corrupted file or a double-spent code —
|
|
28
|
+
* this is a convenience for the ordinary case (a person running two terminal tabs), not a
|
|
29
|
+
* correctness boundary.
|
|
30
|
+
*/
|
|
31
|
+
export const claimOrJoinLock = async (path, apiUrl, info) => {
|
|
32
|
+
const existing = await readLock(path);
|
|
33
|
+
if (existing != null && existing.apiUrl === apiUrl && existing.expiresAt > Date.now() && isAlive(existing.pid)) {
|
|
34
|
+
return { owner: false, info: existing };
|
|
35
|
+
}
|
|
36
|
+
const mine = { ...info, apiUrl, pid: process.pid, nonce: randomBytes(8).toString('hex') };
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
await writeFile(path, JSON.stringify(mine), { mode: 0o600 });
|
|
39
|
+
return { owner: true, info: mine };
|
|
40
|
+
};
|
|
41
|
+
/** Clear the lock, but only the copy of it this process itself wrote — a stale read after
|
|
42
|
+
* somebody else has already reclaimed the same path must never delete THEIR lock instead. */
|
|
43
|
+
export const releaseLock = async (path, nonce) => {
|
|
44
|
+
const existing = await readLock(path);
|
|
45
|
+
if (existing?.nonce === nonce) {
|
|
46
|
+
await unlink(path).catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock.js","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAuBnC,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,eAAuB,EAAU,EAAE,CAAC,GAAG,eAAe,OAAO,CAAA;AAEzF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAAE,IAAY,EAAkC,EAAE;IAC7E,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAmB,CAAA;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC,CAAA;AAED,MAAM,OAAO,GAAG,CAAC,GAAW,EAAW,EAAE;IACvC,IAAI,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QAEpB,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,EAClC,IAAY,EAAE,MAAc,EAAE,IAAsD,EACjC,EAAE;IACrD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAA;IACrC,IAAI,QAAQ,IAAI,IAAI,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,IAAI,QAAQ,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/G,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;IACzC,CAAC;IAED,MAAM,IAAI,GAAmB,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;IACzG,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/C,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IAE5D,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;AACpC,CAAC,CAAA;AAED;6FAC6F;AAC7F,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,IAAY,EAAE,KAAa,EAAiB,EAAE;IAC9E,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAA;IACrC,IAAI,QAAQ,EAAE,KAAK,KAAK,KAAK,EAAE,CAAC;QAC9B,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC3C,CAAC;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
|
|
3
|
+
* whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
|
|
4
|
+
* this never learned to open a browser on, so a failure here is never fatal to the caller.
|
|
5
|
+
*
|
|
6
|
+
* Spawned detached and with every std stream ignored, because this process's stdout may be
|
|
7
|
+
* carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
|
|
8
|
+
* ever reach.
|
|
9
|
+
*/
|
|
10
|
+
export declare const openBrowser: (url: string, env?: NodeJS.ProcessEnv) => boolean;
|
|
11
|
+
//# sourceMappingURL=open-browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open-browser.d.ts","sourceRoot":"","sources":["../src/open-browser.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,QAAS,MAAM,QAAO,MAAM,CAAC,UAAU,KAAiB,OAsB/E,CAAA"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const COMMAND_BY_PLATFORM = {
|
|
3
|
+
darwin: 'open',
|
|
4
|
+
win32: 'start',
|
|
5
|
+
linux: 'xdg-open',
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
|
|
9
|
+
* whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
|
|
10
|
+
* this never learned to open a browser on, so a failure here is never fatal to the caller.
|
|
11
|
+
*
|
|
12
|
+
* Spawned detached and with every std stream ignored, because this process's stdout may be
|
|
13
|
+
* carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
|
|
14
|
+
* ever reach.
|
|
15
|
+
*/
|
|
16
|
+
export const openBrowser = (url, env = process.env) => {
|
|
17
|
+
const platform = process.platform;
|
|
18
|
+
const command = COMMAND_BY_PLATFORM[platform];
|
|
19
|
+
if (command == null)
|
|
20
|
+
return false;
|
|
21
|
+
// `BROWSER=none` is the convention other CLIs already honour; the dedicated variable is for
|
|
22
|
+
// automation (an end-to-end run drives the page itself and must not pop a window on a desktop).
|
|
23
|
+
if (env.OWLMEANS_NO_BROWSER === '1' || env.BROWSER === 'none')
|
|
24
|
+
return false;
|
|
25
|
+
// A Linux session with no display (SSH, a container) has nothing for `xdg-open` to talk to.
|
|
26
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
const child = platform === 'win32'
|
|
30
|
+
? spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
31
|
+
: spawn(command, [url], { detached: true, stdio: 'ignore' });
|
|
32
|
+
child.on('error', () => undefined); // a listener is required or Node throws on the next tick
|
|
33
|
+
child.unref();
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=open-browser.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"open-browser.js","sourceRoot":"","sources":["../src/open-browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,MAAM,mBAAmB,GAA2B;IAClD,MAAM,EAAE,MAAM;IACd,KAAK,EAAE,OAAO;IACd,KAAK,EAAE,UAAU;CAClB,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,GAAW,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAW,EAAE;IACxF,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAA;IACjC,MAAM,OAAO,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAA;IAC7C,IAAI,OAAO,IAAI,IAAI;QAAE,OAAO,KAAK,CAAA;IACjC,4FAA4F;IAC5F,gGAAgG;IAChG,IAAI,GAAG,CAAC,mBAAmB,KAAK,GAAG,IAAI,GAAG,CAAC,OAAO,KAAK,MAAM;QAAE,OAAO,KAAK,CAAA;IAC3E,4FAA4F;IAC5F,IAAI,QAAQ,KAAK,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,eAAe;QAAE,OAAO,KAAK,CAAA;IAE9E,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,QAAQ,KAAK,OAAO;YAChC,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;YAClG,CAAC,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAA;QAE9D,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA,CAAC,yDAAyD;QAC5F,KAAK,CAAC,KAAK,EAAE,CAAA;QAEb,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/cli-auth",
|
|
3
|
+
"version": "0.1.18-rc.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "OAuth device-authorization sign-in for a command-line tool: a dotenv-style ~/.owlmeans credentials file (overridable, environment always wins), a cross-process sign-in lock so several invocations converge on one browser round trip, a browser opener, and a small credential holder a CLI wraps its API calls with.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -b",
|
|
9
|
+
"dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
10
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
11
|
+
"test": "bun test ./tests"
|
|
12
|
+
},
|
|
13
|
+
"main": "build/index.js",
|
|
14
|
+
"module": "build/index.js",
|
|
15
|
+
"types": "build/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./build/index.js",
|
|
19
|
+
"require": "./build/index.js",
|
|
20
|
+
"default": "./build/index.js",
|
|
21
|
+
"module": "./build/index.js",
|
|
22
|
+
"types": "./build/index.d.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@owlmeans/error": "^0.1.18-rc.27",
|
|
27
|
+
"@owlmeans/oauth": "^0.1.18-rc.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
31
|
+
"@types/bun": "^1.4.0",
|
|
32
|
+
"nodemon": "^3.1.14",
|
|
33
|
+
"typescript": "^7.0.2"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Overrides where the credentials file lives. Unset means `~/.owlmeans`. */
|
|
2
|
+
export const ENV_CREDENTIALS_FILE = 'OWLMEANS_CREDENTIALS'
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CREDENTIALS_FILENAME = '.owlmeans'
|
|
5
|
+
|
|
6
|
+
/** How long `require()` waits for a sign-in this call itself started before returning
|
|
7
|
+
* `SignInRequired` and letting the sign-in continue in the background. */
|
|
8
|
+
export const DEFAULT_WAIT_MS = 20_000
|
|
9
|
+
|
|
10
|
+
/** RFC 8628's own ceiling is whatever the server answered with; this is the poller's OWN patience
|
|
11
|
+
* before it gives up entirely, independent of `expires_in`. */
|
|
12
|
+
export const MAX_SIGN_IN_WAIT_MS = 15 * 60 * 1000
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Where the credentials file lives: `OWLMEANS_CREDENTIALS`, or `~/.owlmeans`.
|
|
9
|
+
*
|
|
10
|
+
* A CLI that talks to more than one deployment (a staging environment, a self-hosted instance)
|
|
11
|
+
* points this at a different file per deployment — the file is never merged with another one, and
|
|
12
|
+
* a token it holds is meaningless anywhere but the API URL it was signed in against.
|
|
13
|
+
*/
|
|
14
|
+
export const resolveEnvFile = (env: NodeJS.ProcessEnv = process.env): string =>
|
|
15
|
+
env[ENV_CREDENTIALS_FILE] != null && env[ENV_CREDENTIALS_FILE] !== ''
|
|
16
|
+
? env[ENV_CREDENTIALS_FILE]
|
|
17
|
+
: join(homedir(), DEFAULT_CREDENTIALS_FILENAME)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a dotenv-shaped body: `KEY=value`, an optional `export ` prefix, `#` comments, one level
|
|
21
|
+
* of quoting. Deliberately small — a dotenv library would add a dependency for a format this
|
|
22
|
+
* package itself writes, and this is the one shape it ever needs to read back.
|
|
23
|
+
*/
|
|
24
|
+
export const parseEnv = (content: string): Record<string, string> => {
|
|
25
|
+
const values: Record<string, string> = {}
|
|
26
|
+
for (const raw of content.split('\n')) {
|
|
27
|
+
const line = raw.trim()
|
|
28
|
+
if (line === '' || line.startsWith('#')) continue
|
|
29
|
+
|
|
30
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
|
|
31
|
+
if (match == null) continue
|
|
32
|
+
|
|
33
|
+
let value = match[2].trim()
|
|
34
|
+
if ((value.startsWith('"') && value.endsWith('"') && value.length > 1)
|
|
35
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length > 1)) {
|
|
36
|
+
value = value.slice(1, -1)
|
|
37
|
+
}
|
|
38
|
+
values[match[1]] = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return values
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const readFileIfPresent = async (path: string): Promise<string> =>
|
|
45
|
+
await readFile(path, 'utf-8').catch(() => '')
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The credentials file's values alone, with no environment overlay — what a token is bound to.
|
|
49
|
+
*/
|
|
50
|
+
export const readCredentialsFile = async (env: NodeJS.ProcessEnv = process.env): Promise<Record<string, string>> =>
|
|
51
|
+
parseEnv(await readFileIfPresent(resolveEnvFile(env)))
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The file, with the process environment layered over it — environment wins, but an environment
|
|
55
|
+
* value that is the EMPTY STRING is treated as unset.
|
|
56
|
+
*
|
|
57
|
+
* The empty-string rule exists because a harness config commonly expands an unset shell variable
|
|
58
|
+
* to `''` (`${VIABLE_API_TOKEN:-}`), and a literal empty override must not shadow a real value the
|
|
59
|
+
* file holds — that would make "I have not set this" indistinguishable from "I am overriding this
|
|
60
|
+
* to nothing", and the file is always the more deliberate of the two.
|
|
61
|
+
*/
|
|
62
|
+
export const loadOwlmeansEnv = async (env: NodeJS.ProcessEnv = process.env): Promise<Record<string, string>> => {
|
|
63
|
+
const file = await readCredentialsFile(env)
|
|
64
|
+
const fromEnv: Record<string, string> = {}
|
|
65
|
+
Object.entries(env).forEach(([key, value]) => {
|
|
66
|
+
if (value != null && value !== '') fromEnv[key] = value
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return { ...file, ...fromEnv }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Replace the named keys in the credentials file, keeping every other line — comments, a key this
|
|
74
|
+
* call did not touch, blank lines — exactly where they were.
|
|
75
|
+
*
|
|
76
|
+
* Written atomically (a temp file in the same directory, then a rename) so a process killed
|
|
77
|
+
* mid-write never leaves a half-written credentials file behind, and created with mode `0600`
|
|
78
|
+
* because this file can hold a live access token. An existing file that is readable by anyone but
|
|
79
|
+
* its owner is reported back rather than silently tightened — permissions someone else set on
|
|
80
|
+
* purpose are theirs to change.
|
|
81
|
+
*/
|
|
82
|
+
export const setEnvValues = async (
|
|
83
|
+
path: string, values: Record<string, string | undefined>
|
|
84
|
+
): Promise<{ insecurePermissions: boolean }> => {
|
|
85
|
+
const existing = await readFileIfPresent(path)
|
|
86
|
+
const lines = existing === '' ? [] : existing.split('\n')
|
|
87
|
+
const claimed = new Set<string>()
|
|
88
|
+
|
|
89
|
+
const rewritten = lines.map(line => {
|
|
90
|
+
const trimmed = line.trim()
|
|
91
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(trimmed)
|
|
92
|
+
if (match == null || !(match[1] in values)) return line
|
|
93
|
+
|
|
94
|
+
claimed.add(match[1])
|
|
95
|
+
const value = values[match[1]]
|
|
96
|
+
|
|
97
|
+
return value == null ? null : `${match[1]}=${value}`
|
|
98
|
+
}).filter((line): line is string => line != null)
|
|
99
|
+
|
|
100
|
+
Object.entries(values).forEach(([key, value]) => {
|
|
101
|
+
if (claimed.has(key) || value == null) return
|
|
102
|
+
rewritten.push(`${key}=${value}`)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const body = `${rewritten.join('\n').replace(/\n+$/, '')}\n`
|
|
106
|
+
|
|
107
|
+
await mkdir(dirname(path), { recursive: true })
|
|
108
|
+
const tmp = join(dirname(path), `.${DEFAULT_CREDENTIALS_FILENAME}.${randomBytes(6).toString('hex')}.tmp`)
|
|
109
|
+
await writeFile(tmp, body, { mode: 0o600 })
|
|
110
|
+
await rename(tmp, path)
|
|
111
|
+
await chmod(path, 0o600).catch(() => undefined)
|
|
112
|
+
|
|
113
|
+
let insecurePermissions = false
|
|
114
|
+
try {
|
|
115
|
+
const info = await stat(path)
|
|
116
|
+
insecurePermissions = (info.mode & 0o077) !== 0
|
|
117
|
+
} catch {
|
|
118
|
+
// Nothing to report if the stat itself fails right after a successful write — unusual enough
|
|
119
|
+
// that guessing at a permission problem would be noise.
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { insecurePermissions }
|
|
123
|
+
}
|
package/src/holder.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { hostname, userInfo } from 'node:os'
|
|
2
|
+
import {
|
|
3
|
+
discoverAuthorizationServer, OAuthAccessDenied, OAuthError, OAUTH_DEVICE_NAME_MAX,
|
|
4
|
+
pollDeviceToken, requestDeviceAuthorization, revokeToken, signInRequired, TokenRejected
|
|
5
|
+
} from '@owlmeans/oauth'
|
|
6
|
+
import type { AuthorizationServerMetadata, DeviceSignInOutcome } from '@owlmeans/oauth'
|
|
7
|
+
import { DEFAULT_WAIT_MS } from './consts.js'
|
|
8
|
+
import { readCredentialsFile, resolveEnvFile, setEnvValues } from './env-file.js'
|
|
9
|
+
import { claimOrJoinLock, lockPathFor, readLock, releaseLock } from './lock.js'
|
|
10
|
+
import type { SignInLockInfo } from './lock.js'
|
|
11
|
+
import { openBrowser } from './open-browser.js'
|
|
12
|
+
|
|
13
|
+
export interface CliCredentialsOptions {
|
|
14
|
+
/** The API origin this credential set is for, and the OAuth `resource` it is scoped to unless
|
|
15
|
+
* `resource` says otherwise. */
|
|
16
|
+
apiUrl: string
|
|
17
|
+
/** This CLI's OAuth `client_id` — a static one the authorization server declared, or an https
|
|
18
|
+
* Client ID Metadata Document URL. */
|
|
19
|
+
clientId: string
|
|
20
|
+
deviceName?: string
|
|
21
|
+
resource?: string
|
|
22
|
+
scope?: string
|
|
23
|
+
/** Which key in `~/.owlmeans` (and the environment) carries the token. */
|
|
24
|
+
tokenEnvKey: string
|
|
25
|
+
/** Which key records the URL a stored token belongs to. A file naming no URL at all is treated
|
|
26
|
+
* as belonging to whichever `apiUrl` is asked for — only an explicit MISMATCH refuses it. */
|
|
27
|
+
apiUrlEnvKey: string
|
|
28
|
+
env?: NodeJS.ProcessEnv
|
|
29
|
+
/** Best-effort progress — "open this URL and enter this code", "signed in", a failure. A host
|
|
30
|
+
* wires this to stderr, an MCP `notifications/message`, or nothing at all. */
|
|
31
|
+
onNotify?: (message: string) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CliCredentials {
|
|
35
|
+
/** The token this call site should use right now: the environment, then the bound file value,
|
|
36
|
+
* or `null` when neither has one. */
|
|
37
|
+
token: () => Promise<string | null>
|
|
38
|
+
/** Ensure a usable token exists. Starts or joins a device sign-in when there is none, waits up
|
|
39
|
+
* to `waitMs` for it to be approved, and returns the token. The sign-in keeps running in the
|
|
40
|
+
* background past that wait — a later `require()` call picks up wherever it left off, rather
|
|
41
|
+
* than starting over. */
|
|
42
|
+
require: (waitMs?: number) => Promise<string>
|
|
43
|
+
/** A 401 happened while presenting `rejectedToken`. A token that came from the FILE is forgotten
|
|
44
|
+
* so the next `require()` signs in again; a token that came from the ENVIRONMENT is reported —
|
|
45
|
+
* silently trying another identity behind an operator's back is worse than failing loudly. */
|
|
46
|
+
invalidate: (rejectedToken: string) => Promise<void>
|
|
47
|
+
/** Revoke the current token at the server and remove it from the file. */
|
|
48
|
+
signOut: () => Promise<void>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
const defaultDeviceName = (): string => {
|
|
53
|
+
let who = 'cli'
|
|
54
|
+
try {
|
|
55
|
+
who = userInfo().username
|
|
56
|
+
} catch {
|
|
57
|
+
// Some sandboxes have no passwd entry for the running uid; the hostname alone still helps.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${hostname()} · ${who}`.slice(0, OAUTH_DEVICE_NAME_MAX)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One in-flight sign-in per API URL, per process — a second `require()` call while the first is
|
|
64
|
+
* still waiting joins the SAME poll rather than requesting a second device code. */
|
|
65
|
+
const inFlightByApiUrl = new Map<string, Promise<DeviceSignInOutcome>>()
|
|
66
|
+
|
|
67
|
+
export const makeCliCredentials = (opts: CliCredentialsOptions): CliCredentials => {
|
|
68
|
+
const env = opts.env ?? process.env
|
|
69
|
+
const notify = (message: string): void => opts.onNotify?.(message)
|
|
70
|
+
const credentialsPath = resolveEnvFile(env)
|
|
71
|
+
const lockPath = lockPathFor(credentialsPath)
|
|
72
|
+
|
|
73
|
+
const token = async (): Promise<string | null> => {
|
|
74
|
+
const envValue = env[opts.tokenEnvKey]
|
|
75
|
+
if (envValue != null && envValue !== '') return envValue
|
|
76
|
+
|
|
77
|
+
const file = await readCredentialsFile(env)
|
|
78
|
+
const boundUrl = file[opts.apiUrlEnvKey]
|
|
79
|
+
if (boundUrl != null && boundUrl !== '' && boundUrl !== opts.apiUrl) return null
|
|
80
|
+
|
|
81
|
+
const fileToken = file[opts.tokenEnvKey]
|
|
82
|
+
|
|
83
|
+
return fileToken != null && fileToken !== '' ? fileToken : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Start a fresh device authorization, or adopt another live process's own — either way, claim
|
|
87
|
+
* or join the lock BEFORE requesting one, so the decision and the request agree. */
|
|
88
|
+
const claimJoinOrStart = async (server: AuthorizationServerMetadata): Promise<{ owner: boolean, info: SignInLockInfo }> => {
|
|
89
|
+
const authorization = await requestDeviceAuthorization(server, {
|
|
90
|
+
client_id: opts.clientId, scope: opts.scope, resource: opts.resource ?? opts.apiUrl,
|
|
91
|
+
device_name: opts.deviceName ?? defaultDeviceName(),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return await claimOrJoinLock(lockPath, opts.apiUrl, {
|
|
95
|
+
verificationUri: authorization.verification_uri,
|
|
96
|
+
verificationUriComplete: authorization.verification_uri_complete,
|
|
97
|
+
userCode: authorization.user_code,
|
|
98
|
+
deviceCode: authorization.device_code,
|
|
99
|
+
interval: authorization.interval,
|
|
100
|
+
expiresAt: Date.now() + authorization.expires_in * 1000,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Synchronous on purpose, up to the point it records itself in `inFlightByApiUrl` — that is
|
|
106
|
+
* what makes two `require()` calls racing in the SAME process converge on one sign-in rather
|
|
107
|
+
* than each starting its own before either has had a chance to publish that it is working on
|
|
108
|
+
* it. (Cross-PROCESS concurrency is what the file lock inside `claimJoinOrStart` is for.)
|
|
109
|
+
*/
|
|
110
|
+
const beginOrJoin = (): Promise<DeviceSignInOutcome> => {
|
|
111
|
+
const existing = inFlightByApiUrl.get(opts.apiUrl)
|
|
112
|
+
if (existing != null) return existing
|
|
113
|
+
|
|
114
|
+
const promise = (async (): Promise<DeviceSignInOutcome> => {
|
|
115
|
+
const server = await discoverAuthorizationServer(opts.apiUrl)
|
|
116
|
+
const claim = await claimJoinOrStart(server)
|
|
117
|
+
|
|
118
|
+
notify(
|
|
119
|
+
claim.info.userCode != null
|
|
120
|
+
? `Sign in at ${claim.info.verificationUri} with code ${claim.info.userCode}`
|
|
121
|
+
: `Sign in at ${claim.info.verificationUri}`
|
|
122
|
+
)
|
|
123
|
+
// Only the owner opens a browser — a joining process's own (unused) device authorization is
|
|
124
|
+
// simply left to expire, since a second, un-displayed code would only teach the server's
|
|
125
|
+
// rate limiter that this client polls too eagerly.
|
|
126
|
+
if (claim.owner) {
|
|
127
|
+
openBrowser(claim.info.verificationUriComplete ?? claim.info.verificationUri)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const outcome = await pollDeviceToken(server, {
|
|
132
|
+
clientId: opts.clientId, deviceCode: claim.info.deviceCode, interval: claim.info.interval,
|
|
133
|
+
expiresAt: claim.info.expiresAt,
|
|
134
|
+
})
|
|
135
|
+
if (outcome.status === 'authorized') {
|
|
136
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: outcome.token, [opts.apiUrlEnvKey]: opts.apiUrl })
|
|
137
|
+
notify('Signed in.')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return outcome
|
|
141
|
+
} finally {
|
|
142
|
+
inFlightByApiUrl.delete(opts.apiUrl)
|
|
143
|
+
if (claim.owner) await releaseLock(lockPath, claim.info.nonce)
|
|
144
|
+
}
|
|
145
|
+
})()
|
|
146
|
+
inFlightByApiUrl.set(opts.apiUrl, promise)
|
|
147
|
+
|
|
148
|
+
return promise
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
token,
|
|
153
|
+
|
|
154
|
+
require: async (waitMs = DEFAULT_WAIT_MS): Promise<string> => {
|
|
155
|
+
const existing = await token()
|
|
156
|
+
if (existing != null) return existing
|
|
157
|
+
|
|
158
|
+
// Deliberately NOT awaited here — `beginOrJoin()` is the pending sign-in itself, and
|
|
159
|
+
// racing it (rather than awaiting it first) is what lets `require()` return control to
|
|
160
|
+
// its caller after `waitMs` while the sign-in keeps running toward its own resolution.
|
|
161
|
+
const pollPromise = beginOrJoin()
|
|
162
|
+
// The ceiling's timer is cleared once the race is decided: a pending timer keeps a process
|
|
163
|
+
// alive, and `viable-mcp login` (waiting up to 15 minutes) must exit the moment it is signed in.
|
|
164
|
+
let ceiling: ReturnType<typeof setTimeout> | undefined
|
|
165
|
+
const raced = await Promise.race([
|
|
166
|
+
pollPromise.then(outcome => ({ settled: true as const, outcome })),
|
|
167
|
+
new Promise<{ settled: false }>(resolve => {
|
|
168
|
+
ceiling = setTimeout(() => resolve({ settled: false }), waitMs)
|
|
169
|
+
}),
|
|
170
|
+
]).finally(() => clearTimeout(ceiling))
|
|
171
|
+
|
|
172
|
+
if (!raced.settled) {
|
|
173
|
+
const lock = await readLock(lockPath)
|
|
174
|
+
throw signInRequired({
|
|
175
|
+
url: lock?.verificationUri ?? opts.apiUrl, code: lock?.userCode, expiresAt: lock?.expiresAt,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
switch (raced.outcome.status) {
|
|
180
|
+
case 'authorized':
|
|
181
|
+
return raced.outcome.token
|
|
182
|
+
case 'denied':
|
|
183
|
+
throw new OAuthAccessDenied('sign-in')
|
|
184
|
+
case 'expired':
|
|
185
|
+
throw new OAuthError('sign-in:expired')
|
|
186
|
+
default:
|
|
187
|
+
throw new OAuthError('sign-in:aborted')
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
invalidate: async (rejectedToken: string): Promise<void> => {
|
|
192
|
+
const file = await readCredentialsFile(env)
|
|
193
|
+
if (file[opts.tokenEnvKey] === rejectedToken) {
|
|
194
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined })
|
|
195
|
+
notify('The stored token was refused. Signing in again.')
|
|
196
|
+
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (env[opts.tokenEnvKey] === rejectedToken) {
|
|
200
|
+
throw new TokenRejected(opts.tokenEnvKey)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
signOut: async (): Promise<void> => {
|
|
205
|
+
const current = await token()
|
|
206
|
+
if (current == null) return
|
|
207
|
+
|
|
208
|
+
const server = await discoverAuthorizationServer(opts.apiUrl).catch(() => null)
|
|
209
|
+
if (server != null) {
|
|
210
|
+
await revokeToken(server, { token: current, clientId: opts.clientId }).catch(() => undefined)
|
|
211
|
+
}
|
|
212
|
+
await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined })
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/index.ts
ADDED
package/src/lock.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What one process tells every other one about the sign-in it is driving — the browser URL and
|
|
7
|
+
* code so a SECOND process can show the same "waiting on…" state instead of opening a second
|
|
8
|
+
* browser tab for the same API URL.
|
|
9
|
+
*/
|
|
10
|
+
export interface SignInLockInfo {
|
|
11
|
+
pid: number
|
|
12
|
+
apiUrl: string
|
|
13
|
+
verificationUri: string
|
|
14
|
+
verificationUriComplete?: string
|
|
15
|
+
userCode?: string
|
|
16
|
+
/** The device flow's own secret — protected the same way the eventual access token is (mode
|
|
17
|
+
* `0600`, same directory as the credentials file), so a joining process can poll the SAME
|
|
18
|
+
* pending authorization instead of requesting a second one nobody will ever display. */
|
|
19
|
+
deviceCode: string
|
|
20
|
+
interval: number
|
|
21
|
+
expiresAt: number
|
|
22
|
+
/** Proves ownership at release time — a process only clears the lock it itself wrote. */
|
|
23
|
+
nonce: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const lockPathFor = (credentialsPath: string): string => `${credentialsPath}.lock`
|
|
27
|
+
|
|
28
|
+
export const readLock = async (path: string): Promise<SignInLockInfo | null> => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(await readFile(path, 'utf-8')) as SignInLockInfo
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isAlive = (pid: number): boolean => {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0)
|
|
39
|
+
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Become the one process driving this API URL's sign-in, or find out somebody else already is.
|
|
48
|
+
*
|
|
49
|
+
* Best-effort, not a mutual-exclusion guarantee: two processes racing this at the exact same
|
|
50
|
+
* instant can both conclude they are the owner, and each then drives its own independent device
|
|
51
|
+
* authorization. That costs an extra browser tab, never a corrupted file or a double-spent code —
|
|
52
|
+
* this is a convenience for the ordinary case (a person running two terminal tabs), not a
|
|
53
|
+
* correctness boundary.
|
|
54
|
+
*/
|
|
55
|
+
export const claimOrJoinLock = async (
|
|
56
|
+
path: string, apiUrl: string, info: Omit<SignInLockInfo, 'pid' | 'nonce' | 'apiUrl'>
|
|
57
|
+
): Promise<{ owner: boolean, info: SignInLockInfo }> => {
|
|
58
|
+
const existing = await readLock(path)
|
|
59
|
+
if (existing != null && existing.apiUrl === apiUrl && existing.expiresAt > Date.now() && isAlive(existing.pid)) {
|
|
60
|
+
return { owner: false, info: existing }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mine: SignInLockInfo = { ...info, apiUrl, pid: process.pid, nonce: randomBytes(8).toString('hex') }
|
|
64
|
+
await mkdir(dirname(path), { recursive: true })
|
|
65
|
+
await writeFile(path, JSON.stringify(mine), { mode: 0o600 })
|
|
66
|
+
|
|
67
|
+
return { owner: true, info: mine }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Clear the lock, but only the copy of it this process itself wrote — a stale read after
|
|
71
|
+
* somebody else has already reclaimed the same path must never delete THEIR lock instead. */
|
|
72
|
+
export const releaseLock = async (path: string, nonce: string): Promise<void> => {
|
|
73
|
+
const existing = await readLock(path)
|
|
74
|
+
if (existing?.nonce === nonce) {
|
|
75
|
+
await unlink(path).catch(() => undefined)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
const COMMAND_BY_PLATFORM: Record<string, string> = {
|
|
4
|
+
darwin: 'open',
|
|
5
|
+
win32: 'start',
|
|
6
|
+
linux: 'xdg-open',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
|
|
11
|
+
* whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
|
|
12
|
+
* this never learned to open a browser on, so a failure here is never fatal to the caller.
|
|
13
|
+
*
|
|
14
|
+
* Spawned detached and with every std stream ignored, because this process's stdout may be
|
|
15
|
+
* carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
|
|
16
|
+
* ever reach.
|
|
17
|
+
*/
|
|
18
|
+
export const openBrowser = (url: string, env: NodeJS.ProcessEnv = process.env): boolean => {
|
|
19
|
+
const platform = process.platform
|
|
20
|
+
const command = COMMAND_BY_PLATFORM[platform]
|
|
21
|
+
if (command == null) return false
|
|
22
|
+
// `BROWSER=none` is the convention other CLIs already honour; the dedicated variable is for
|
|
23
|
+
// automation (an end-to-end run drives the page itself and must not pop a window on a desktop).
|
|
24
|
+
if (env.OWLMEANS_NO_BROWSER === '1' || env.BROWSER === 'none') return false
|
|
25
|
+
// A Linux session with no display (SSH, a container) has nothing for `xdg-open` to talk to.
|
|
26
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const child = platform === 'win32'
|
|
30
|
+
? spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
31
|
+
: spawn(command, [url], { detached: true, stdio: 'ignore' })
|
|
32
|
+
|
|
33
|
+
child.on('error', () => undefined) // a listener is required or Node throws on the next tick
|
|
34
|
+
child.unref()
|
|
35
|
+
|
|
36
|
+
return true
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|