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/src/mcp/stdio.mjs
CHANGED
|
@@ -2,112 +2,103 @@ import fs from "node:fs";
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
-
import { resolveApiKey, resolveBaseUrl } from "../config.mjs";
|
|
5
|
+
import { loadConfig, resolveApiKey, resolveBaseUrl } from "../config.mjs";
|
|
6
6
|
import { CliError, request } from "../client.mjs";
|
|
7
7
|
|
|
8
8
|
const pkg = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
9
9
|
let spec;
|
|
10
10
|
try {
|
|
11
|
-
|
|
12
|
-
fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8")
|
|
13
|
-
);
|
|
11
|
+
spec = JSON.parse(fs.readFileSync(new URL("../generated/operations.json", import.meta.url), "utf8"));
|
|
14
12
|
} catch {
|
|
15
|
-
|
|
16
|
-
'cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.'
|
|
17
|
-
);
|
|
13
|
+
throw new Error('cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.');
|
|
18
14
|
}
|
|
19
15
|
const operations = spec.operations.filter((op) => op.mcp && op.mcp.enabled);
|
|
20
16
|
|
|
21
17
|
function toolInputSchema(op) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
]),
|
|
31
|
-
];
|
|
32
|
-
const schema = { type: "object", properties, additionalProperties: false };
|
|
33
|
-
if (required.length) schema.required = required;
|
|
34
|
-
return schema;
|
|
18
|
+
const properties = {
|
|
19
|
+
...((op.querySchema && op.querySchema.properties) || {}),
|
|
20
|
+
...((op.requestSchema && op.requestSchema.properties) || {}),
|
|
21
|
+
};
|
|
22
|
+
const required = [...new Set([...((op.querySchema && op.querySchema.required) || []), ...((op.requestSchema && op.requestSchema.required) || [])])];
|
|
23
|
+
const schema = { type: "object", properties, additionalProperties: false };
|
|
24
|
+
if (required.length) schema.required = required;
|
|
25
|
+
return schema;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
function errorResult(type, message) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
29
|
+
return {
|
|
30
|
+
isError: true,
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: JSON.stringify({ success: false, error: { type, message } }),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
export async function runMcpServer() {
|
|
50
|
-
|
|
51
|
-
{ name: "faceless", version: pkg.version },
|
|
52
|
-
{ capabilities: { tools: {} } }
|
|
53
|
-
);
|
|
41
|
+
const server = new Server({ name: "faceless", version: pkg.version }, { capabilities: { tools: {} } });
|
|
54
42
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
const tools = operations.map((op) => ({
|
|
44
|
+
name: op.mcp.name,
|
|
45
|
+
description: `${op.summary}. ${op.description}`,
|
|
46
|
+
inputSchema: toolInputSchema(op),
|
|
47
|
+
}));
|
|
60
48
|
|
|
61
|
-
|
|
49
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
62
50
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
51
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
52
|
+
const name = req.params.name;
|
|
53
|
+
const args = { ...(req.params.arguments || {}) };
|
|
54
|
+
const op = operations.find((o) => o.mcp.name === name);
|
|
55
|
+
if (!op) return errorResult("invalid_input", `Unknown tool: ${name}`);
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
57
|
+
// A null key is fine when `faceless login` stored an OAuth session: request() resolves and
|
|
58
|
+
// refreshes it. Only fail fast when NEITHER credential exists, and say the browser flow first -
|
|
59
|
+
// it is the path an agent can actually complete without a human pasting a key.
|
|
60
|
+
const apiKey = resolveApiKey({});
|
|
61
|
+
if (!apiKey && !loadConfig().oauth?.refreshToken) {
|
|
62
|
+
return errorResult(
|
|
63
|
+
"unauthorized",
|
|
64
|
+
'Not authenticated. Run "faceless login" (opens a browser for OAuth - no key to paste), or set the FACELESS_API_KEY environment variable with a key from https://faceless.so/team.'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
76
67
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
let path = op.path;
|
|
69
|
+
for (const match of op.path.matchAll(/\{(\w+)\}/g)) {
|
|
70
|
+
const param = match[1];
|
|
71
|
+
if (args[param] === undefined || args[param] === null) {
|
|
72
|
+
return errorResult("invalid_input", `Missing required parameter: ${param}`);
|
|
73
|
+
}
|
|
74
|
+
path = path.replace(`{${param}}`, encodeURIComponent(String(args[param])));
|
|
75
|
+
delete args[param];
|
|
76
|
+
}
|
|
86
77
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
78
|
+
try {
|
|
79
|
+
const method = op.method.toLowerCase();
|
|
80
|
+
const hasBody = method !== "get" && method !== "delete";
|
|
81
|
+
// Transport-level replay key (same semantics as the HTTP Idempotency-Key
|
|
82
|
+
// header and the remote MCP's idempotencyKey argument), not operation input.
|
|
83
|
+
const { idempotencyKey, ...opArgs } = args;
|
|
84
|
+
const result = await request({
|
|
85
|
+
method: op.method,
|
|
86
|
+
path,
|
|
87
|
+
query: hasBody ? undefined : opArgs,
|
|
88
|
+
body: hasBody ? opArgs : undefined,
|
|
89
|
+
apiKey,
|
|
90
|
+
baseUrl: resolveBaseUrl({}),
|
|
91
|
+
idempotencyKey,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
95
|
+
};
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof CliError) return errorResult(err.type, err.message);
|
|
98
|
+
return errorResult("internal_error", err.message || "Unknown error");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
const transport = new StdioServerTransport();
|
|
103
|
+
await server.connect(transport);
|
|
113
104
|
}
|
package/src/oauth.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { loadConfig, saveConfig } from "./config.mjs";
|
|
5
|
+
import { CliError } from "./client.mjs";
|
|
6
|
+
|
|
7
|
+
// OAuth 2.0 login for the CLI and the MCP stdio server.
|
|
8
|
+
//
|
|
9
|
+
// This is what makes `faceless login` work for an AGENT, not just a human with a key to paste:
|
|
10
|
+
// dynamic client registration (RFC 7591), the authorization-code + PKCE flow with the redirect
|
|
11
|
+
// caught on a loopback listener (RFC 8252), and refresh-token rotation handled transparently on
|
|
12
|
+
// every subsequent request. The only human moment is approving the consent screen.
|
|
13
|
+
//
|
|
14
|
+
// The server side of all of this lives in the main app (src/pages/api/oauth2/*); this file is a
|
|
15
|
+
// client of the same documents any third-party agent would read.
|
|
16
|
+
|
|
17
|
+
const LOOPBACK_PORT = 8976;
|
|
18
|
+
const REDIRECT_URI = `http://127.0.0.1:${LOOPBACK_PORT}/callback`;
|
|
19
|
+
/** Refresh this many ms before expiry, so a token cannot die mid-request. */
|
|
20
|
+
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** https://faceless.so/api/v1 -> https://faceless.so (where the oauth2 + well-known routes live). */
|
|
23
|
+
export const siteBaseFrom = (apiBaseUrl) =>
|
|
24
|
+
String(apiBaseUrl || "")
|
|
25
|
+
.replace(/\/+$/, "")
|
|
26
|
+
.replace(/\/api\/v1$/, "");
|
|
27
|
+
|
|
28
|
+
export function generatePkce() {
|
|
29
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
30
|
+
const challenge = crypto.createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
31
|
+
return { verifier, challenge };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function postJson(url, body) {
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
let payload = null;
|
|
41
|
+
try {
|
|
42
|
+
payload = await response.json();
|
|
43
|
+
} catch {
|
|
44
|
+
payload = null;
|
|
45
|
+
}
|
|
46
|
+
return { status: response.status, payload };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Register (or reuse) this machine's public client. One client per config file is plenty. */
|
|
50
|
+
async function ensureClient(site) {
|
|
51
|
+
const config = loadConfig();
|
|
52
|
+
if (config.oauthClientId) return config.oauthClientId;
|
|
53
|
+
const { status, payload } = await postJson(`${site}/api/oauth2/register`, {
|
|
54
|
+
client_name: `faceless-cli (${process.env.USER || process.env.USERNAME || "user"})`,
|
|
55
|
+
redirect_uris: [REDIRECT_URI],
|
|
56
|
+
});
|
|
57
|
+
if (status !== 201 || !payload?.client_id) {
|
|
58
|
+
throw new CliError("server_error", `OAuth client registration failed (${status}): ${payload?.error_description || "unknown error"}`);
|
|
59
|
+
}
|
|
60
|
+
saveConfig({ oauthClientId: payload.client_id });
|
|
61
|
+
return payload.client_id;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every scope the server publishes; the consent screen is where the user narrows it. */
|
|
65
|
+
async function allScopes(site) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${site}/.well-known/oauth-protected-resource`);
|
|
68
|
+
const metadata = await response.json();
|
|
69
|
+
if (Array.isArray(metadata?.scopes_supported) && metadata.scopes_supported.length) return metadata.scopes_supported.join(" ");
|
|
70
|
+
} catch {
|
|
71
|
+
/* fall through */
|
|
72
|
+
}
|
|
73
|
+
return ""; // absent scope -> server's read-only default
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const openBrowser = (url) => {
|
|
77
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
78
|
+
execFile(command, [url], () => {});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The interactive login: registers a client, opens the consent screen, catches the redirect on a
|
|
83
|
+
* loopback listener, exchanges the code, and returns the token set. Writes nothing itself beyond
|
|
84
|
+
* the client id - the caller decides how to store the tokens.
|
|
85
|
+
*/
|
|
86
|
+
export async function loginWithOAuth({ baseUrl, log = () => {} }) {
|
|
87
|
+
const site = siteBaseFrom(baseUrl);
|
|
88
|
+
const clientId = await ensureClient(site);
|
|
89
|
+
const { verifier, challenge } = generatePkce();
|
|
90
|
+
const state = crypto.randomBytes(8).toString("base64url");
|
|
91
|
+
const scope = await allScopes(site);
|
|
92
|
+
|
|
93
|
+
const authorizeUrl =
|
|
94
|
+
`${site}/oauth2/authorize?response_type=code&client_id=${clientId}` +
|
|
95
|
+
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}${scope ? `&scope=${encodeURIComponent(scope)}` : ""}` +
|
|
96
|
+
`&state=${state}&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
97
|
+
|
|
98
|
+
log(`Opening your browser to authorize this machine...\n ${authorizeUrl}\n`);
|
|
99
|
+
openBrowser(authorizeUrl);
|
|
100
|
+
|
|
101
|
+
const code = await new Promise((resolve, reject) => {
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
server.close();
|
|
104
|
+
reject(new CliError("timeout", "Timed out after 5 minutes waiting for browser approval."));
|
|
105
|
+
}, 300000);
|
|
106
|
+
const server = http.createServer((req, res) => {
|
|
107
|
+
const url = new URL(req.url, `http://127.0.0.1:${LOOPBACK_PORT}`);
|
|
108
|
+
if (url.pathname !== "/callback") {
|
|
109
|
+
res.writeHead(404).end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end("<h3>Logged in. You can close this tab and return to the terminal.</h3>");
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
server.close();
|
|
115
|
+
if (url.searchParams.get("state") !== state) return reject(new CliError("unauthorized", "State mismatch in OAuth redirect."));
|
|
116
|
+
const error = url.searchParams.get("error");
|
|
117
|
+
if (error) return reject(new CliError("unauthorized", `Authorization was ${error === "access_denied" ? "denied" : `rejected: ${error}`}.`));
|
|
118
|
+
resolve(url.searchParams.get("code"));
|
|
119
|
+
});
|
|
120
|
+
server.on("error", (err) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
reject(
|
|
123
|
+
err?.code === "EADDRINUSE"
|
|
124
|
+
? new CliError("server_error", `Port ${LOOPBACK_PORT} is in use; free it and retry.`)
|
|
125
|
+
: new CliError("server_error", `Loopback listener failed: ${err.message}`)
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
server.listen(LOOPBACK_PORT, "127.0.0.1");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const { status, payload } = await postJson(`${site}/api/oauth2/token`, {
|
|
132
|
+
grant_type: "authorization_code",
|
|
133
|
+
code,
|
|
134
|
+
redirect_uri: REDIRECT_URI,
|
|
135
|
+
client_id: clientId,
|
|
136
|
+
code_verifier: verifier,
|
|
137
|
+
});
|
|
138
|
+
if (status !== 200 || !payload?.access_token) {
|
|
139
|
+
throw new CliError("server_error", `Token exchange failed (${status}): ${payload?.error_description || payload?.error || "unknown"}`);
|
|
140
|
+
}
|
|
141
|
+
return tokenSetFrom(payload, site, clientId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tokenSetFrom = (payload, site, clientId) => ({
|
|
145
|
+
site,
|
|
146
|
+
clientId,
|
|
147
|
+
accessToken: payload.access_token,
|
|
148
|
+
refreshToken: payload.refresh_token,
|
|
149
|
+
scope: payload.scope,
|
|
150
|
+
expiresAt: Date.now() + (Number(payload.expires_in) || 3600) * 1000,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A valid access token from the stored OAuth session, refreshing (and persisting the rotated pair)
|
|
155
|
+
* when it is near expiry. Returns null when there is no OAuth session at all; throws when the
|
|
156
|
+
* session existed but is dead (family revoked, refresh expired) so the caller can say "log in
|
|
157
|
+
* again" instead of a bare 401.
|
|
158
|
+
*/
|
|
159
|
+
export async function ensureOauthAccessToken() {
|
|
160
|
+
const config = loadConfig();
|
|
161
|
+
const session = config.oauth;
|
|
162
|
+
if (!session?.refreshToken) return null;
|
|
163
|
+
|
|
164
|
+
if (session.accessToken && Date.now() < Number(session.expiresAt || 0) - EXPIRY_SKEW_MS) {
|
|
165
|
+
return session.accessToken;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { status, payload } = await postJson(`${session.site}/api/oauth2/token`, {
|
|
169
|
+
grant_type: "refresh_token",
|
|
170
|
+
refresh_token: session.refreshToken,
|
|
171
|
+
client_id: session.clientId,
|
|
172
|
+
});
|
|
173
|
+
if (status !== 200 || !payload?.access_token) {
|
|
174
|
+
// Rotation means a dead refresh token is unrecoverable; clear it so the next error is honest.
|
|
175
|
+
saveConfig({ oauth: undefined });
|
|
176
|
+
throw new CliError("unauthorized", 'Your OAuth session has expired or was revoked. Run "faceless login" again.');
|
|
177
|
+
}
|
|
178
|
+
const rotated = tokenSetFrom(payload, session.site, session.clientId);
|
|
179
|
+
saveConfig({ oauth: rotated });
|
|
180
|
+
return rotated.accessToken;
|
|
181
|
+
}
|
package/src/output.mjs
CHANGED
|
@@ -1,109 +1,106 @@
|
|
|
1
1
|
const PREFERRED_COLUMNS = [
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
2
|
+
"id",
|
|
3
|
+
"name",
|
|
4
|
+
"platform",
|
|
5
|
+
"channelName",
|
|
6
|
+
"username",
|
|
7
|
+
"status",
|
|
8
|
+
"renderStatus",
|
|
9
|
+
"model",
|
|
10
|
+
"source",
|
|
11
|
+
"niche",
|
|
12
|
+
"type",
|
|
13
|
+
"credits",
|
|
14
|
+
"paused",
|
|
15
|
+
"scheduledTime",
|
|
16
|
+
"postedAt",
|
|
17
|
+
"createdAt",
|
|
18
|
+
"value",
|
|
19
|
+
"label",
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
const MAX_COLUMNS = 6;
|
|
23
23
|
const MAX_CELL = 40;
|
|
24
24
|
|
|
25
25
|
function cell(value) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
if (value === undefined || value === null) return "";
|
|
27
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
28
|
+
const flat = text.replace(/\s+/g, " ");
|
|
29
|
+
return flat.length > MAX_CELL ? flat.slice(0, MAX_CELL - 3) + "..." : flat;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function pickColumns(rows) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
const keys = [];
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
for (const key of Object.keys(row)) {
|
|
36
|
+
if (!keys.includes(key)) keys.push(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const preferred = PREFERRED_COLUMNS.filter((key) => keys.includes(key));
|
|
40
|
+
const rest = keys.filter((key) => !preferred.includes(key));
|
|
41
|
+
return [...preferred, ...rest].slice(0, MAX_COLUMNS);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function printTable(rows, indent = "") {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
for (const row of rows) {
|
|
62
|
-
process.stdout.write(line(columns.map((col) => cell(row[col]))));
|
|
63
|
-
}
|
|
45
|
+
if (!rows.length) {
|
|
46
|
+
process.stdout.write(indent + "(no results)\n");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (rows.some((row) => !row || typeof row !== "object")) {
|
|
50
|
+
for (const row of rows) process.stdout.write(indent + cell(row) + "\n");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const columns = pickColumns(rows);
|
|
54
|
+
const widths = columns.map((col) => Math.max(col.length, ...rows.map((row) => cell(row[col]).length)));
|
|
55
|
+
const line = (values) => indent + values.map((value, i) => String(value).padEnd(widths[i])).join(" ") + "\n";
|
|
56
|
+
process.stdout.write(line(columns));
|
|
57
|
+
process.stdout.write(line(widths.map((w) => "-".repeat(w))));
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
process.stdout.write(line(columns.map((col) => cell(row[col]))));
|
|
60
|
+
}
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
function printObject(obj, indent = "") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
64
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
65
|
+
if (value === undefined) continue;
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (value.length && value.every((v) => v && typeof v === "object")) {
|
|
68
|
+
process.stdout.write(`${indent}${key}:\n`);
|
|
69
|
+
printTable(value, indent + " ");
|
|
70
|
+
} else {
|
|
71
|
+
process.stdout.write(`${indent}${key}: ${value.map(cell).join(", ")}\n`);
|
|
72
|
+
}
|
|
73
|
+
} else if (value && typeof value === "object") {
|
|
74
|
+
process.stdout.write(`${indent}${key}:\n`);
|
|
75
|
+
printObject(value, indent + " ");
|
|
76
|
+
} else {
|
|
77
|
+
process.stdout.write(`${indent}${key}: ${value}\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
83
80
|
}
|
|
84
81
|
|
|
85
82
|
export function print(result, { json = false } = {}) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
if (json || !process.stdout.isTTY) {
|
|
84
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const data = result && typeof result === "object" && "data" in result ? result.data : result;
|
|
88
|
+
if (Array.isArray(data)) {
|
|
89
|
+
printTable(data);
|
|
90
|
+
} else if (data && typeof data === "object") {
|
|
91
|
+
printObject(data);
|
|
92
|
+
} else {
|
|
93
|
+
process.stdout.write(String(data) + "\n");
|
|
94
|
+
}
|
|
95
|
+
if (result && typeof result === "object" && result.pagination) {
|
|
96
|
+
const p = result.pagination;
|
|
97
|
+
const pages = p.pages || (p.limit ? Math.ceil((p.total || 0) / p.limit) : undefined);
|
|
98
|
+
process.stdout.write(`page ${p.page}${pages ? ` of ${pages}` : ""} (${p.total} total)\n`);
|
|
99
|
+
}
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
export function printError(err) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
const type = (err && err.type) || "internal_error";
|
|
104
|
+
const message = (err && err.message) || "Unknown error";
|
|
105
|
+
process.stderr.write(`error (${type}): ${message}\n`);
|
|
109
106
|
}
|