faceless-cli 1.1.6 → 1.1.8
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/package.json +1 -1
- package/src/client.mjs +96 -103
- package/src/config.mjs +29 -29
- package/src/generated/operations.json +1 -1
- package/src/index.mjs +521 -633
- package/src/mcp/stdio.mjs +76 -85
- package/src/oauth.mjs +181 -0
- package/src/output.mjs +83 -86
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -1,129 +1,122 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
export class CliError extends Error {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
constructor(type, message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliError";
|
|
7
|
+
this.type = type || "internal_error";
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function exitCodeFor(err) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const type = err && err.type;
|
|
14
|
+
if (type === "unauthorized" || type === "forbidden_scope") return 2;
|
|
15
|
+
if (type === "insufficient_credits") return 3;
|
|
16
|
+
if (type === "rate_limited") return 4;
|
|
17
|
+
return 1;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function typeForStatus(status) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
21
|
+
switch (status) {
|
|
22
|
+
case 400:
|
|
23
|
+
return "invalid_input";
|
|
24
|
+
case 401:
|
|
25
|
+
return "unauthorized";
|
|
26
|
+
case 402:
|
|
27
|
+
return "insufficient_credits";
|
|
28
|
+
case 403:
|
|
29
|
+
return "forbidden_scope";
|
|
30
|
+
case 404:
|
|
31
|
+
return "not_found";
|
|
32
|
+
case 409:
|
|
33
|
+
return "conflict";
|
|
34
|
+
case 429:
|
|
35
|
+
return "rate_limited";
|
|
36
|
+
default:
|
|
37
|
+
return "internal_error";
|
|
38
|
+
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function buildUrl(baseUrl, path, query) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
const url = new URL(baseUrl + path);
|
|
43
|
+
if (query) {
|
|
44
|
+
for (const [key, value] of Object.entries(query)) {
|
|
45
|
+
if (value === undefined || value === null || value === "") continue;
|
|
46
|
+
url.searchParams.set(key, String(value));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return url;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function sleep(ms) {
|
|
53
|
-
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function retryAfterMs(res) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
const header = res.headers.get("retry-after");
|
|
58
|
+
let seconds = Number(header);
|
|
59
|
+
if (!Number.isFinite(seconds) || seconds < 0) seconds = 1;
|
|
60
|
+
return Math.min(seconds, 30) * 1000;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export async function request({ method, path, query, body, apiKey, baseUrl, idempotencyKey }) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
// Credential order: an explicit key (flag > env > saved key, resolved by the caller) wins; with
|
|
65
|
+
// no key, fall back to the stored OAuth session from `faceless login`, refreshing it as needed.
|
|
66
|
+
// The import is lazy because oauth.mjs imports CliError from this file.
|
|
67
|
+
let bearer = apiKey;
|
|
68
|
+
if (!bearer) {
|
|
69
|
+
const { ensureOauthAccessToken } = await import("./oauth.mjs");
|
|
70
|
+
bearer = await ensureOauthAccessToken();
|
|
71
|
+
}
|
|
72
|
+
if (!bearer) {
|
|
73
|
+
throw new CliError("unauthorized", 'Not authenticated. Run "faceless login" (opens your browser, no key needed) or set FACELESS_API_KEY.');
|
|
74
|
+
}
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
const url = buildUrl(baseUrl, path, query);
|
|
77
|
+
const headers = { Authorization: `Bearer ${bearer}` };
|
|
78
|
+
const httpMethod = method.toUpperCase();
|
|
79
|
+
const init = { method: httpMethod, headers };
|
|
80
|
+
if (body !== undefined && body !== null) {
|
|
81
|
+
headers["Content-Type"] = "application/json";
|
|
82
|
+
init.body = JSON.stringify(body);
|
|
83
|
+
}
|
|
84
|
+
// Every mutating request carries an Idempotency-Key so a retry can never be
|
|
85
|
+
// applied (or charge credits) twice. Ops without server-side idempotency
|
|
86
|
+
// simply ignore the header. A user-provided --idempotency-key wins.
|
|
87
|
+
const isMutating = httpMethod !== "GET" && httpMethod !== "HEAD";
|
|
88
|
+
if (isMutating && !idempotencyKey) idempotencyKey = randomUUID();
|
|
89
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(url, init);
|
|
94
|
+
// Retry a rate-limited request once. Insufficient credits is 402 (never
|
|
95
|
+
// retried); mutating retries are safe because of the Idempotency-Key.
|
|
96
|
+
if (res.status === 429) {
|
|
97
|
+
await sleep(retryAfterMs(res));
|
|
98
|
+
res = await fetch(url, init);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new CliError("internal_error", `Network error calling ${url.origin}: ${err.message}`);
|
|
102
|
+
}
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
res.status
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
let parsed = null;
|
|
106
|
+
if (text) {
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new CliError(typeForStatus(res.status), `Unexpected non-JSON response (HTTP ${res.status})`, res.status);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!res.ok) {
|
|
122
|
-
throw new CliError(
|
|
123
|
-
typeForStatus(res.status),
|
|
124
|
-
`Request failed (HTTP ${res.status})`,
|
|
125
|
-
res.status
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
return parsed;
|
|
114
|
+
if (parsed && parsed.success === false) {
|
|
115
|
+
const e = parsed.error || {};
|
|
116
|
+
throw new CliError(e.type || typeForStatus(res.status), e.message || `Request failed (HTTP ${res.status})`, res.status);
|
|
117
|
+
}
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
throw new CliError(typeForStatus(res.status), `Request failed (HTTP ${res.status})`, res.status);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
129
122
|
}
|
package/src/config.mjs
CHANGED
|
@@ -3,45 +3,45 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_BASE_URL = "https://faceless.so/api/v1";
|
|
6
|
-
|
|
6
|
+
// FACELESS_CONFIG_DIR exists for tests and for containers that mount config somewhere writable.
|
|
7
|
+
export const CONFIG_DIR = process.env.FACELESS_CONFIG_DIR || path.join(os.homedir(), ".faceless");
|
|
7
8
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
8
9
|
|
|
9
10
|
export function loadConfig() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function saveConfig(partial) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
21
|
+
const config = { ...loadConfig(), ...partial };
|
|
22
|
+
for (const key of Object.keys(config)) {
|
|
23
|
+
if (config[key] === undefined || config[key] === null) delete config[key];
|
|
24
|
+
}
|
|
25
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
26
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
|
|
27
|
+
mode: 0o600,
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
31
|
+
} catch {
|
|
32
|
+
/* best effort on platforms without chmod */
|
|
33
|
+
}
|
|
34
|
+
return config;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function resolveApiKey(flags = {}) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
if (flags.apiKey) return flags.apiKey;
|
|
39
|
+
if (process.env.FACELESS_API_KEY) return process.env.FACELESS_API_KEY;
|
|
40
|
+
const config = loadConfig();
|
|
41
|
+
return config.apiKey || null;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function resolveBaseUrl(flags = {}) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return String(url).replace(/\/+$/, "");
|
|
45
|
+
const url = flags.apiUrl || process.env.FACELESS_API_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
|
|
46
|
+
return String(url).replace(/\/+$/, "");
|
|
47
47
|
}
|