@moewolf/moe-icons-cli 0.0.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/LICENSE +15 -0
- package/README.md +32 -0
- package/bin/moeicons.js +20 -0
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.js +105 -0
- package/dist/auth/callback.d.ts +37 -0
- package/dist/auth/callback.js +72 -0
- package/dist/auth/device-login.d.ts +29 -0
- package/dist/auth/device-login.js +74 -0
- package/dist/auth/login.d.ts +41 -0
- package/dist/auth/login.js +85 -0
- package/dist/auth/open-browser.d.ts +2 -0
- package/dist/auth/open-browser.js +15 -0
- package/dist/auth/pkce.d.ts +17 -0
- package/dist/auth/pkce.js +20 -0
- package/dist/auth/token-store.d.ts +38 -0
- package/dist/auth/token-store.js +126 -0
- package/dist/catalog/catalog.d.ts +32 -0
- package/dist/catalog/catalog.js +80 -0
- package/dist/catalog/catalog.json +9371 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +507 -0
- package/dist/commands/parser.d.ts +41 -0
- package/dist/commands/parser.js +146 -0
- package/dist/core/auth.d.ts +32 -0
- package/dist/core/auth.js +131 -0
- package/dist/core/bitmap-assets.d.ts +29 -0
- package/dist/core/bitmap-assets.js +77 -0
- package/dist/core/cli-update.d.ts +43 -0
- package/dist/core/cli-update.js +71 -0
- package/dist/core/context.d.ts +22 -0
- package/dist/core/context.js +1 -0
- package/dist/core/free-download.d.ts +66 -0
- package/dist/core/free-download.js +193 -0
- package/dist/core/generate.d.ts +33 -0
- package/dist/core/generate.js +285 -0
- package/dist/core/icon-names.d.ts +14 -0
- package/dist/core/icon-names.js +54 -0
- package/dist/core/init.d.ts +12 -0
- package/dist/core/init.js +23 -0
- package/dist/core/install.d.ts +67 -0
- package/dist/core/install.js +117 -0
- package/dist/core/library-update.d.ts +24 -0
- package/dist/core/library-update.js +161 -0
- package/dist/core/manage.d.ts +24 -0
- package/dist/core/manage.js +38 -0
- package/dist/core/pro-download.d.ts +29 -0
- package/dist/core/pro-download.js +112 -0
- package/dist/core/pro-install.d.ts +24 -0
- package/dist/core/pro-install.js +72 -0
- package/dist/core/release-descriptor.d.ts +40 -0
- package/dist/core/release-descriptor.js +114 -0
- package/dist/core/resource-variant.d.ts +35 -0
- package/dist/core/resource-variant.js +70 -0
- package/dist/core/signed-artifact.d.ts +29 -0
- package/dist/core/signed-artifact.js +81 -0
- package/dist/core/target-subtree.d.ts +49 -0
- package/dist/core/target-subtree.js +132 -0
- package/dist/core/update-policy.d.ts +31 -0
- package/dist/core/update-policy.js +27 -0
- package/dist/core/version-service.d.ts +26 -0
- package/dist/core/version-service.js +71 -0
- package/dist/core/wizard.d.ts +49 -0
- package/dist/core/wizard.js +71 -0
- package/dist/errors/errors.d.ts +34 -0
- package/dist/errors/errors.js +39 -0
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/generator/cn.d.ts +5 -0
- package/dist/generator/cn.js +14 -0
- package/dist/generator/generate.d.ts +34 -0
- package/dist/generator/generate.js +562 -0
- package/dist/generator/theme-resolve.d.ts +26 -0
- package/dist/generator/theme-resolve.js +100 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.js +176 -0
- package/dist/project/config.d.ts +74 -0
- package/dist/project/config.js +353 -0
- package/dist/project/detect.d.ts +26 -0
- package/dist/project/detect.js +104 -0
- package/dist/project/install-metadata.d.ts +29 -0
- package/dist/project/install-metadata.js +108 -0
- package/dist/project/install.d.ts +115 -0
- package/dist/project/install.js +537 -0
- package/dist/project/project-lock.d.ts +11 -0
- package/dist/project/project-lock.js +81 -0
- package/dist/project/tailwind.d.ts +51 -0
- package/dist/project/tailwind.js +188 -0
- package/dist/project/tar-gz.d.ts +12 -0
- package/dist/project/tar-gz.js +100 -0
- package/dist/tui/primitives.d.ts +36 -0
- package/dist/tui/primitives.js +88 -0
- package/dist/ui/banner.d.ts +11 -0
- package/dist/ui/banner.js +105 -0
- package/dist/ui/clack.d.ts +8 -0
- package/dist/ui/clack.js +53 -0
- package/dist/ui/create-ui.d.ts +14 -0
- package/dist/ui/create-ui.js +16 -0
- package/dist/ui/non-interactive.d.ts +5 -0
- package/dist/ui/non-interactive.js +30 -0
- package/dist/ui/stream.d.ts +11 -0
- package/dist/ui/stream.js +50 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# moeicons
|
|
2
|
+
|
|
3
|
+
Public CLI for the Moeicons icon library. Installs icon style groups into your
|
|
4
|
+
project, manages login/tokens, generates React/Vue proxy components, and exposes
|
|
5
|
+
an MCP server for AI clients.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
moeicons interactive guided flow (free / pro / login)
|
|
11
|
+
moeicons install [group] install an icon group
|
|
12
|
+
moeicons login browser login (PKCE)
|
|
13
|
+
moeicons logout clear local session
|
|
14
|
+
moeicons account show account/tier info
|
|
15
|
+
moeicons groups list available icon groups
|
|
16
|
+
moeicons generate generate target-specific project files
|
|
17
|
+
moeicons mcp start the MCP stdio server
|
|
18
|
+
npm install -g @moewolf/moe-icons-cli
|
|
19
|
+
moeicons --version show version
|
|
20
|
+
moeicons --help show help
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Security
|
|
24
|
+
|
|
25
|
+
- Tokens are stored in the OS keychain where available.
|
|
26
|
+
- API keys are never logged or written in plaintext.
|
|
27
|
+
- Installation is transactional: failures never corrupt an existing project.
|
|
28
|
+
- Downloads verify SHA-256 and never shell out to `unzip`/`tar`.
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
Scaffold in progress (Phase 5 of the Moeicons development plan).
|
package/bin/moeicons.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../dist/cli.js";
|
|
3
|
+
|
|
4
|
+
const runtime = {
|
|
5
|
+
cwd: () => process.cwd(),
|
|
6
|
+
stdout: (text) => process.stdout.write(text),
|
|
7
|
+
stderr: (text) => process.stderr.write(text),
|
|
8
|
+
env: process.env,
|
|
9
|
+
isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
main(process.argv.slice(2), runtime).then(
|
|
13
|
+
(code) => {
|
|
14
|
+
process.exitCode = code;
|
|
15
|
+
},
|
|
16
|
+
(error) => {
|
|
17
|
+
process.stderr.write(`fatal: ${String(error)}\n`);
|
|
18
|
+
process.exitCode = 5;
|
|
19
|
+
},
|
|
20
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed backend client. Fixed configured base URL, timeout/abort, auth
|
|
3
|
+
* injection, JSON/content-type/size validation, request ID capture, and retry
|
|
4
|
+
* for GET/idempotent calls with capped backoff.
|
|
5
|
+
*/
|
|
6
|
+
export interface ApiConfig {
|
|
7
|
+
readonly baseUrl: string;
|
|
8
|
+
readonly timeoutMs?: number;
|
|
9
|
+
readonly maxBodyBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface RequestJsonOptions {
|
|
12
|
+
readonly method?: "GET" | "POST" | "PUT" | "DELETE";
|
|
13
|
+
readonly auth?: string;
|
|
14
|
+
readonly authHeader?: string;
|
|
15
|
+
readonly body?: unknown;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
readonly retries?: number;
|
|
18
|
+
readonly requestId?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ApiResult<T> {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly data: T;
|
|
23
|
+
readonly requestId: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Perform a JSON request against the fixed base URL. Retries GET/idempotent
|
|
27
|
+
* calls with capped exponential backoff on 5xx and network errors.
|
|
28
|
+
*/
|
|
29
|
+
export declare function requestJson<T>(config: ApiConfig, path: string, options?: RequestJsonOptions): Promise<ApiResult<T>>;
|
|
30
|
+
export interface ApiKeyEntitlement {
|
|
31
|
+
readonly valid: boolean;
|
|
32
|
+
readonly tier: "free" | "pro" | "ent";
|
|
33
|
+
readonly accountId: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Verify an API key. Trim once, never log, send via the configured header,
|
|
37
|
+
* map 401/403/429/5xx distinctly. No persistent storage by default.
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyApiKey(config: ApiConfig, key: string, options?: {
|
|
40
|
+
verifyPath?: string;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}): Promise<ApiKeyEntitlement>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { CliError } from "../errors/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Perform a JSON request against the fixed base URL. Retries GET/idempotent
|
|
4
|
+
* calls with capped exponential backoff on 5xx and network errors.
|
|
5
|
+
*/
|
|
6
|
+
export async function requestJson(config, path, options = {}) {
|
|
7
|
+
const method = options.method ?? "GET";
|
|
8
|
+
const retries = options.retries ?? 0;
|
|
9
|
+
const timeoutMs = config.timeoutMs ?? 10_000;
|
|
10
|
+
const requestId = options.requestId ??
|
|
11
|
+
`req-${Math.random().toString(36).slice(2, 10)}`;
|
|
12
|
+
const attempt = async () => {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
15
|
+
const externalSignal = options.signal;
|
|
16
|
+
const abortHandler = () => controller.abort();
|
|
17
|
+
externalSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
18
|
+
const headers = {
|
|
19
|
+
"content-type": "application/json",
|
|
20
|
+
"x-request-id": requestId,
|
|
21
|
+
};
|
|
22
|
+
if (options.auth) {
|
|
23
|
+
headers[options.authHeader ?? "authorization"] = options.auth;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(`${config.baseUrl.replace(/\/$/, "")}${path}`, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
});
|
|
32
|
+
const text = await response.text();
|
|
33
|
+
if (text.length > (config.maxBodyBytes ?? 5 * 1024 * 1024)) {
|
|
34
|
+
throw new CliError("VALIDATION_ERROR", "response body exceeds size limit");
|
|
35
|
+
}
|
|
36
|
+
let data = null;
|
|
37
|
+
if (text.length > 0) {
|
|
38
|
+
try {
|
|
39
|
+
data = JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new CliError("VALIDATION_ERROR", "response is not valid JSON");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (response.status === 401)
|
|
46
|
+
throw new CliError("AUTH_ERROR", "unauthorized");
|
|
47
|
+
if (response.status === 403)
|
|
48
|
+
throw new CliError("FORBIDDEN", "forbidden");
|
|
49
|
+
if (response.status === 429) {
|
|
50
|
+
throw new CliError("NETWORK_ERROR", "rate limited");
|
|
51
|
+
}
|
|
52
|
+
if (response.status >= 500 && retries > 0) {
|
|
53
|
+
throw new RetryableStatus(response.status);
|
|
54
|
+
}
|
|
55
|
+
if (response.status >= 400) {
|
|
56
|
+
throw new CliError("NETWORK_ERROR", `request failed with ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
return { status: response.status, data: data, requestId };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
externalSignal?.removeEventListener("abort", abortHandler);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
let lastError;
|
|
66
|
+
for (let i = 0; i <= retries; i += 1) {
|
|
67
|
+
try {
|
|
68
|
+
return await attempt();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof CliError)
|
|
72
|
+
throw error;
|
|
73
|
+
if (error instanceof RetryableStatus && i < retries) {
|
|
74
|
+
const backoff = Math.min(500 * 2 ** i, 4000);
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
lastError = error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw lastError;
|
|
82
|
+
}
|
|
83
|
+
class RetryableStatus extends Error {
|
|
84
|
+
status;
|
|
85
|
+
constructor(status) {
|
|
86
|
+
super(`retryable status ${status}`);
|
|
87
|
+
this.status = status;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Verify an API key. Trim once, never log, send via the configured header,
|
|
92
|
+
* map 401/403/429/5xx distinctly. No persistent storage by default.
|
|
93
|
+
*/
|
|
94
|
+
export async function verifyApiKey(config, key, options = {}) {
|
|
95
|
+
const trimmed = key.trim();
|
|
96
|
+
if (trimmed.length === 0) {
|
|
97
|
+
throw new CliError("VALIDATION_ERROR", "API key must not be empty");
|
|
98
|
+
}
|
|
99
|
+
const result = await requestJson(config, options.verifyPath ?? "/v1/apikey/verify", {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: { key: trimmed },
|
|
102
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
103
|
+
});
|
|
104
|
+
return result.data;
|
|
105
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* Loopback callback server for OAuth Authorization Code + PKCE. Binds loopback
|
|
4
|
+
* only, serves one successful callback, cleans up on timeout/abort.
|
|
5
|
+
*/
|
|
6
|
+
export interface CallbackServerOptions {
|
|
7
|
+
readonly host?: string;
|
|
8
|
+
readonly port?: number;
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly timeoutMs?: number;
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
export interface CallbackResult {
|
|
14
|
+
readonly code: string;
|
|
15
|
+
readonly state: string;
|
|
16
|
+
}
|
|
17
|
+
export type CallbackOutcome = {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly value: CallbackResult;
|
|
20
|
+
} | {
|
|
21
|
+
readonly ok: false;
|
|
22
|
+
readonly code: "TIMEOUT" | "ABORTED" | "SERVER_ERROR" | "BAD_CALLBACK";
|
|
23
|
+
readonly message: string;
|
|
24
|
+
};
|
|
25
|
+
export interface ActiveCallbackServer {
|
|
26
|
+
readonly server: Server;
|
|
27
|
+
/** The port the server is bound to (for building the redirect URL). */
|
|
28
|
+
readonly port: number;
|
|
29
|
+
/** Resolves when the single allowed callback arrives (or timeout/abort). */
|
|
30
|
+
readonly outcome: Promise<CallbackOutcome>;
|
|
31
|
+
readonly stop: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Start a loopback callback server. The caller must build the authorization
|
|
35
|
+
* URL with the returned port, then await `outcome`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function startLoopbackCallbackServer(options: CallbackServerOptions): ActiveCallbackServer;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { parse } from "node:url";
|
|
3
|
+
const LOOPBACK = "127.0.0.1";
|
|
4
|
+
/**
|
|
5
|
+
* Start a loopback callback server. The caller must build the authorization
|
|
6
|
+
* URL with the returned port, then await `outcome`.
|
|
7
|
+
*/
|
|
8
|
+
export function startLoopbackCallbackServer(options) {
|
|
9
|
+
const host = options.host ?? LOOPBACK;
|
|
10
|
+
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
|
|
11
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
12
|
+
let stopCalled = false;
|
|
13
|
+
let resolveOutcome = () => undefined;
|
|
14
|
+
const outcome = new Promise((resolve) => {
|
|
15
|
+
resolveOutcome = resolve;
|
|
16
|
+
});
|
|
17
|
+
let boundPort = options.port ?? 0;
|
|
18
|
+
let timer;
|
|
19
|
+
const server = createServer((req, res) => {
|
|
20
|
+
const url = parse(req.url ?? "/", true);
|
|
21
|
+
if (url.pathname !== path) {
|
|
22
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
23
|
+
res.end("Not found");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const code = typeof url.query.code === "string" ? url.query.code : undefined;
|
|
27
|
+
const state = typeof url.query.state === "string" ? url.query.state : undefined;
|
|
28
|
+
if (!code || !state) {
|
|
29
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
30
|
+
res.end("Missing code or state");
|
|
31
|
+
// a malformed callback is not the success callback; do not resolve yet
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
35
|
+
res.end("<html><body><h1>Login successful</h1><p>You can close this tab and return to the terminal.</p></body></html>");
|
|
36
|
+
stop();
|
|
37
|
+
resolveOutcome({ ok: true, value: { code, state } });
|
|
38
|
+
});
|
|
39
|
+
server.on("error", (error) => {
|
|
40
|
+
stop();
|
|
41
|
+
resolveOutcome({
|
|
42
|
+
ok: false,
|
|
43
|
+
code: "SERVER_ERROR",
|
|
44
|
+
message: error.message,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
server.listen(options.port ?? 0, host, () => {
|
|
48
|
+
const address = server.address();
|
|
49
|
+
if (typeof address === "object" && address !== null) {
|
|
50
|
+
boundPort = address.port;
|
|
51
|
+
}
|
|
52
|
+
timer = setTimeout(() => {
|
|
53
|
+
stop();
|
|
54
|
+
resolveOutcome({ ok: false, code: "TIMEOUT", message: "login timed out" });
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
});
|
|
57
|
+
const stop = () => {
|
|
58
|
+
if (stopCalled)
|
|
59
|
+
return;
|
|
60
|
+
stopCalled = true;
|
|
61
|
+
if (timer)
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
64
|
+
server.close();
|
|
65
|
+
};
|
|
66
|
+
const abortHandler = () => {
|
|
67
|
+
stop();
|
|
68
|
+
resolveOutcome({ ok: false, code: "ABORTED", message: "login aborted" });
|
|
69
|
+
};
|
|
70
|
+
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
71
|
+
return { server, port: boundPort, outcome, stop };
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { StoredSession, TokenStore } from './token-store.js';
|
|
2
|
+
export interface DeviceLoginConfig {
|
|
3
|
+
readonly apiBaseUrl: string;
|
|
4
|
+
readonly websiteOrigin: string;
|
|
5
|
+
readonly auth0Issuer: string;
|
|
6
|
+
readonly auth0ClientId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface DeviceLoginDependencies {
|
|
9
|
+
readonly request: <T>(path: string, options: {
|
|
10
|
+
method: 'GET' | 'POST' | 'DELETE';
|
|
11
|
+
auth?: string;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}) => Promise<{
|
|
15
|
+
status: number;
|
|
16
|
+
data: T;
|
|
17
|
+
}>;
|
|
18
|
+
readonly openBrowser: (url: string) => Promise<void>;
|
|
19
|
+
readonly sleep: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
20
|
+
readonly tokenStore: TokenStore;
|
|
21
|
+
readonly now: () => number;
|
|
22
|
+
}
|
|
23
|
+
export declare function loginWithDeviceSession(config: DeviceLoginConfig, deps: DeviceLoginDependencies, signal?: AbortSignal): Promise<StoredSession>;
|
|
24
|
+
export declare function refreshAuth0Session(config: DeviceLoginConfig, session: StoredSession, deps: {
|
|
25
|
+
fetch: typeof fetch;
|
|
26
|
+
tokenStore: TokenStore;
|
|
27
|
+
now: () => number;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<StoredSession>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createLoginState, createPkcePair } from './pkce.js';
|
|
2
|
+
import { CliError } from '../errors/index.js';
|
|
3
|
+
function assertBrowserUrl(value, expectedOrigin) {
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
if (url.origin !== new URL(expectedOrigin).origin || url.pathname !== '/cli-login') {
|
|
6
|
+
throw new CliError('AUTH_ERROR', 'backend returned an untrusted login URL');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function loginWithDeviceSession(config, deps, signal) {
|
|
10
|
+
const pkce = createPkcePair();
|
|
11
|
+
const state = createLoginState();
|
|
12
|
+
const created = await deps.request('/v1/cli-login-sessions', {
|
|
13
|
+
method: 'POST', body: { codeChallenge: pkce.challenge, clientNonce: state.nonce, state: state.state }, ...(signal ? { signal } : {}),
|
|
14
|
+
});
|
|
15
|
+
assertBrowserUrl(created.data.browserUrl, config.websiteOrigin);
|
|
16
|
+
if (!Number.isSafeInteger(created.data.intervalSeconds) || created.data.intervalSeconds < 1 || created.data.intervalSeconds > 60) {
|
|
17
|
+
throw new CliError('AUTH_ERROR', 'backend returned an invalid polling interval');
|
|
18
|
+
}
|
|
19
|
+
await deps.openBrowser(created.data.browserUrl);
|
|
20
|
+
const auth = `Bearer ${created.data.pollingToken}`;
|
|
21
|
+
const sessionPath = `/v1/cli-login-sessions/${encodeURIComponent(created.data.loginId)}`;
|
|
22
|
+
try {
|
|
23
|
+
while (deps.now() < Date.parse(created.data.expiresAt)) {
|
|
24
|
+
if (signal?.aborted)
|
|
25
|
+
throw new CliError('CANCELLED', 'login cancelled');
|
|
26
|
+
const polled = await deps.request(sessionPath, { method: 'GET', auth, ...(signal ? { signal } : {}) });
|
|
27
|
+
if (polled.status === 200 && polled.data.status === 'complete' && polled.data.exchangeCode) {
|
|
28
|
+
const exchanged = await deps.request(`${sessionPath}/exchange`, {
|
|
29
|
+
method: 'POST', auth,
|
|
30
|
+
body: { exchangeCode: polled.data.exchangeCode, clientNonce: state.nonce, codeVerifier: pkce.verifier },
|
|
31
|
+
...(signal ? { signal } : {}),
|
|
32
|
+
});
|
|
33
|
+
const stored = {
|
|
34
|
+
accountId: exchanged.data.accountId,
|
|
35
|
+
accessToken: exchanged.data.accessToken,
|
|
36
|
+
refreshToken: exchanged.data.refreshToken,
|
|
37
|
+
expiresAt: deps.now() + exchanged.data.expiresIn * 1000,
|
|
38
|
+
scope: 'openid profile email offline_access',
|
|
39
|
+
storedAt: deps.now(),
|
|
40
|
+
};
|
|
41
|
+
deps.tokenStore.set(stored);
|
|
42
|
+
return stored;
|
|
43
|
+
}
|
|
44
|
+
await deps.sleep(created.data.intervalSeconds * 1000, signal);
|
|
45
|
+
}
|
|
46
|
+
throw new CliError('AUTH_ERROR', 'login session expired');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
try {
|
|
50
|
+
await deps.request(sessionPath, { method: 'DELETE', auth });
|
|
51
|
+
}
|
|
52
|
+
catch { /* best effort */ }
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function refreshAuth0Session(config, session, deps) {
|
|
57
|
+
const response = await deps.fetch(`${config.auth0Issuer.replace(/\/$/, '')}/oauth/token`, {
|
|
58
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({ grant_type: 'refresh_token', client_id: config.auth0ClientId, refresh_token: session.refreshToken }),
|
|
60
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
61
|
+
});
|
|
62
|
+
const body = await response.json();
|
|
63
|
+
if (!response.ok || typeof body.access_token !== 'string')
|
|
64
|
+
throw new CliError('AUTH_ERROR', 'Auth0 refresh failed');
|
|
65
|
+
const updated = {
|
|
66
|
+
...session,
|
|
67
|
+
accessToken: body.access_token,
|
|
68
|
+
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : session.refreshToken,
|
|
69
|
+
expiresAt: deps.now() + (typeof body.expires_in === 'number' ? body.expires_in : 3600) * 1000,
|
|
70
|
+
storedAt: deps.now(),
|
|
71
|
+
};
|
|
72
|
+
deps.tokenStore.set(updated);
|
|
73
|
+
return updated;
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type PkcePair, type LoginState } from "./pkce.js";
|
|
2
|
+
/**
|
|
3
|
+
* OAuth 2.0 Authorization Code + PKCE login flow. Never accepts issuer from the
|
|
4
|
+
* callback; validates state; exchanges code; stores token only after all
|
|
5
|
+
* checks; closes the callback server in finally.
|
|
6
|
+
*/
|
|
7
|
+
export interface AuthConfig {
|
|
8
|
+
readonly issuer: string;
|
|
9
|
+
readonly clientId: string;
|
|
10
|
+
readonly audience: string;
|
|
11
|
+
readonly redirectPath: string;
|
|
12
|
+
readonly scope: string;
|
|
13
|
+
}
|
|
14
|
+
/** Build the authorization URL from configured values; never from the callback. */
|
|
15
|
+
export declare function buildAuthorizationUrl(config: AuthConfig, attempt: {
|
|
16
|
+
port: number;
|
|
17
|
+
pkce: PkcePair;
|
|
18
|
+
state: LoginState;
|
|
19
|
+
}): string;
|
|
20
|
+
export interface TokenResponse {
|
|
21
|
+
readonly accessToken: string;
|
|
22
|
+
readonly refreshToken: string;
|
|
23
|
+
readonly expiresIn: number;
|
|
24
|
+
readonly scope: string;
|
|
25
|
+
}
|
|
26
|
+
/** POST the authorization code and runtime-validate the token response. */
|
|
27
|
+
export declare function exchangeAuthorizationCode(config: AuthConfig, code: string, verifier: string, deps: {
|
|
28
|
+
fetchJson: (url: string, body: unknown) => Promise<unknown>;
|
|
29
|
+
}): Promise<TokenResponse>;
|
|
30
|
+
export interface LoginDeps {
|
|
31
|
+
readonly openBrowser: (url: string) => Promise<void>;
|
|
32
|
+
readonly fetchJson: (url: string, body: unknown) => Promise<unknown>;
|
|
33
|
+
readonly signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Start the callback server before opening the browser; validate state then
|
|
37
|
+
* exchange; store token only after all checks; close server in finally.
|
|
38
|
+
*/
|
|
39
|
+
export declare function loginWithBrowser(config: AuthConfig, deps: LoginDeps): Promise<TokenResponse>;
|
|
40
|
+
/** SHA-256 hex of an API key for redaction/audit (never store the raw key). */
|
|
41
|
+
export declare function hashApiKey(key: string): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { startLoopbackCallbackServer } from "./callback.js";
|
|
3
|
+
import { createPkcePair, createLoginState } from "./pkce.js";
|
|
4
|
+
import { CliError } from "../errors/index.js";
|
|
5
|
+
/** Build the authorization URL from configured values; never from the callback. */
|
|
6
|
+
export function buildAuthorizationUrl(config, attempt) {
|
|
7
|
+
const params = new URLSearchParams({
|
|
8
|
+
client_id: config.clientId,
|
|
9
|
+
response_type: "code",
|
|
10
|
+
redirect_uri: `http://127.0.0.1:${attempt.port}${config.redirectPath}`,
|
|
11
|
+
scope: config.scope,
|
|
12
|
+
audience: config.audience,
|
|
13
|
+
code_challenge: attempt.pkce.challenge,
|
|
14
|
+
code_challenge_method: "S256",
|
|
15
|
+
state: attempt.state.state,
|
|
16
|
+
nonce: attempt.state.nonce,
|
|
17
|
+
});
|
|
18
|
+
return `${config.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
|
|
19
|
+
}
|
|
20
|
+
/** POST the authorization code and runtime-validate the token response. */
|
|
21
|
+
export async function exchangeAuthorizationCode(config, code, verifier, deps) {
|
|
22
|
+
const raw = await deps.fetchJson(`${config.issuer.replace(/\/$/, "")}/oauth/token`, {
|
|
23
|
+
grant_type: "authorization_code",
|
|
24
|
+
client_id: config.clientId,
|
|
25
|
+
code,
|
|
26
|
+
redirect_uri: "",
|
|
27
|
+
code_verifier: verifier,
|
|
28
|
+
});
|
|
29
|
+
if (typeof raw !== "object" || raw === null) {
|
|
30
|
+
throw new CliError("AUTH_ERROR", "invalid token response");
|
|
31
|
+
}
|
|
32
|
+
const record = raw;
|
|
33
|
+
if (typeof record.access_token !== "string" || record.access_token.length === 0) {
|
|
34
|
+
throw new CliError("AUTH_ERROR", "token response missing access_token");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
accessToken: record.access_token,
|
|
38
|
+
refreshToken: typeof record.refresh_token === "string" ? record.refresh_token : "",
|
|
39
|
+
expiresIn: typeof record.expires_in === "number" ? record.expires_in : 3600,
|
|
40
|
+
scope: typeof record.scope === "string" ? record.scope : "",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Start the callback server before opening the browser; validate state then
|
|
45
|
+
* exchange; store token only after all checks; close server in finally.
|
|
46
|
+
*/
|
|
47
|
+
export async function loginWithBrowser(config, deps) {
|
|
48
|
+
const active = startLoopbackCallbackServer({
|
|
49
|
+
host: "127.0.0.1",
|
|
50
|
+
port: 0,
|
|
51
|
+
path: config.redirectPath,
|
|
52
|
+
timeoutMs: 120_000,
|
|
53
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
const pkce = createPkcePair();
|
|
57
|
+
const loginState = createLoginState();
|
|
58
|
+
const url = buildAuthorizationUrl(config, { port: active.port, pkce, state: loginState });
|
|
59
|
+
let browserOpened = false;
|
|
60
|
+
try {
|
|
61
|
+
await deps.openBrowser(url);
|
|
62
|
+
browserOpened = true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// browser-open failure is surfaced below regardless of browserOpened
|
|
66
|
+
throw new CliError("AUTH_ERROR", "failed to open the browser; please open the URL manually");
|
|
67
|
+
}
|
|
68
|
+
void browserOpened;
|
|
69
|
+
const callback = await active.outcome;
|
|
70
|
+
if (!callback.ok) {
|
|
71
|
+
throw new CliError(callback.code === "TIMEOUT" ? "NETWORK_ERROR" : "AUTH_ERROR", callback.message);
|
|
72
|
+
}
|
|
73
|
+
if (callback.value.state !== loginState.state) {
|
|
74
|
+
throw new CliError("AUTH_ERROR", "state mismatch in callback; possible CSRF");
|
|
75
|
+
}
|
|
76
|
+
return await exchangeAuthorizationCode(config, callback.value.code, pkce.verifier, deps);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
active.stop();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** SHA-256 hex of an API key for redaction/audit (never store the raw key). */
|
|
83
|
+
export function hashApiKey(key) {
|
|
84
|
+
return createHash("sha256").update(key).digest("hex");
|
|
85
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { CliError } from "../errors/index.js";
|
|
3
|
+
/** Open a trusted URL without invoking a shell. */
|
|
4
|
+
export function openBrowser(url) {
|
|
5
|
+
const parsed = new URL(url);
|
|
6
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && parsed.hostname === "localhost")) {
|
|
7
|
+
return Promise.reject(new CliError("AUTH_ERROR", "refusing to open an untrusted browser URL"));
|
|
8
|
+
}
|
|
9
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = spawn(command, [parsed.toString()], { detached: true, stdio: "ignore" });
|
|
12
|
+
child.once("error", () => reject(new CliError("AUTH_ERROR", "failed to open the browser")));
|
|
13
|
+
child.once("spawn", () => { child.unref(); resolve(); });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE: cryptographically random verifier and S256 challenge.
|
|
3
|
+
*/
|
|
4
|
+
export interface PkcePair {
|
|
5
|
+
readonly verifier: string;
|
|
6
|
+
readonly challenge: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Create a PKCE pair: 43-128 char verifier and S256 challenge.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createPkcePair(): PkcePair;
|
|
12
|
+
/** Login state (state + nonce) for a single active attempt. */
|
|
13
|
+
export interface LoginState {
|
|
14
|
+
readonly state: string;
|
|
15
|
+
readonly nonce: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function createLoginState(): LoginState;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
/** RFC 7636-compatible base64url without padding. */
|
|
3
|
+
function base64Url(input) {
|
|
4
|
+
return Buffer.from(input).toString("base64url").replace(/=+$/, "");
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Create a PKCE pair: 43-128 char verifier and S256 challenge.
|
|
8
|
+
*/
|
|
9
|
+
export function createPkcePair() {
|
|
10
|
+
const verifierBytes = randomBytes(32); // 43 base64url chars
|
|
11
|
+
const verifier = base64Url(verifierBytes);
|
|
12
|
+
const challenge = base64Url(createHash("sha256").update(verifier).digest());
|
|
13
|
+
return { verifier, challenge };
|
|
14
|
+
}
|
|
15
|
+
export function createLoginState() {
|
|
16
|
+
return {
|
|
17
|
+
state: base64Url(randomBytes(24)),
|
|
18
|
+
nonce: base64Url(randomBytes(24)),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* TokenStore: interface with OS keychain preferred, documented fallback with
|
|
4
|
+
* mode 0600. Stores refresh token + metadata; never logs tokens.
|
|
5
|
+
*/
|
|
6
|
+
export interface StoredSession {
|
|
7
|
+
readonly accountId: string;
|
|
8
|
+
readonly accessToken: string;
|
|
9
|
+
readonly refreshToken: string;
|
|
10
|
+
readonly expiresAt: number;
|
|
11
|
+
readonly scope: string;
|
|
12
|
+
readonly storedAt: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TokenStore {
|
|
15
|
+
get(accountId: string): StoredSession | undefined;
|
|
16
|
+
getActive(): StoredSession | undefined;
|
|
17
|
+
set(session: StoredSession): void;
|
|
18
|
+
delete(accountId: string): void;
|
|
19
|
+
clear(): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* File-backed fallback store (mode 0600). Used only when no OS keychain is
|
|
23
|
+
* available and the owner approves the fallback. Never logs token values.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createFileTokenStore(options?: {
|
|
26
|
+
rootDir?: string;
|
|
27
|
+
}): TokenStore;
|
|
28
|
+
/** Use the native credential store when the platform provides a supported CLI. */
|
|
29
|
+
export declare function createSystemTokenStore(options?: {
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
execFile?: typeof execFileSync;
|
|
32
|
+
}): TokenStore | undefined;
|
|
33
|
+
/** Redact a session for logging: never expose tokens. */
|
|
34
|
+
export declare function redactSession(session: StoredSession): {
|
|
35
|
+
accountId: string;
|
|
36
|
+
scope: string;
|
|
37
|
+
expiresAt: number;
|
|
38
|
+
};
|