@flatkey-ai/cli 0.1.5 → 0.1.7
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 +27 -1
- package/package.json +5 -1
- package/src/api.js +66 -5
- package/src/artifacts.js +9 -0
- package/src/cli.js +238 -3
- package/src/config.js +60 -3
- package/src/help.js +174 -4
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -50
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
- package/.github/pull_request_template.md +0 -13
- package/.github/workflows/npm-publish.yml +0 -36
- package/CONTRIBUTING.md +0 -37
- package/SECURITY.md +0 -13
- package/docs/superpowers/plans/2026-07-17-flatkey-cli.md +0 -90
- package/docs/superpowers/specs/2026-07-17-flatkey-cli-design.md +0 -209
- package/flatkey-output/audio-01.mp3 +0 -1
- package/release/deb/README.md +0 -16
- package/release/homebrew/flatkey.rb +0 -16
- package/release/msi/README.md +0 -15
- package/test/animation.test.js +0 -30
- package/test/api.test.js +0 -282
- package/test/artifacts.test.js +0 -108
- package/test/cli.test.js +0 -143
- package/test/config.test.js +0 -62
- package/test/help.test.js +0 -33
- package/test/integration.test.js +0 -274
- package/test/live-flatkey.test.js +0 -121
- package/test/models.test.js +0 -56
- package/test/package.test.js +0 -29
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
Creative generation from the terminal for media teams and AI agents. Generate images, videos, audio, and text through one Flatkey credit balance.
|
|
9
9
|
|
|
10
|
+
Languages: [English](README.md) | [简体中文](docs/readme/README.zh-CN.md) | [日本語](docs/readme/README.ja-JP.md)
|
|
11
|
+
|
|
10
12
|
Website: [flatkey.ai](https://flatkey.ai/?utm_source=github&utm_medium=readme&utm_campaign=flatkey_cli)
|
|
11
13
|
|
|
12
14
|
```bash
|
|
@@ -29,6 +31,21 @@ flatkey credits --json
|
|
|
29
31
|
- Cross-platform npm package for macOS, Linux, and Windows.
|
|
30
32
|
- Cool terminal progress animation for human runs; disabled in `--json` mode.
|
|
31
33
|
|
|
34
|
+
## Product Comparison
|
|
35
|
+
|
|
36
|
+
Flatkey is built for media teams that want programmatic generation without paying a premium for a closed creative suite or waiting in long queues. Higgsfield is strong for creator-facing workflows, and Dreamina/Jimeng has good ByteDance video models, but both can become expensive or slow once the workflow moves from manual creation to repeated AI-agent testing. In our testing, Dreamina/Jimeng video generation can wait up to 5 hours during peak time, which makes one-by-one prompt iteration impractical.
|
|
37
|
+
|
|
38
|
+
| Need | Flatkey | Higgsfield | Dreamina / Jimeng |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| Cost for repeat testing | Flatkey token pricing is at least ~20% lower than comparable retail routes, and some modalities such as image can be ~40% lower. | Credit packages can be expensive once premium video/image models are used heavily. | Subscription credits can be cheaper in some local workflows, but API-style automation usually has plan and region constraints. |
|
|
41
|
+
| Programmatic access | First-class CLI and API-router workflow. Designed for agents, scripts, and batch testing. | Strong product UI for creators; CLI-style automation is not the primary product promise. | CLI/API access is gated behind higher-tier setup in many workflows, not the default creator entry path. |
|
|
42
|
+
| Queue / iteration speed | Built for fast CLI retries, `--dry-run`, local outputs, and unified model routing. | Good for polished creator workflows, but heavy credit usage makes broad testing costly. | Peak-time queues can block video iteration; we have observed waits up to 5 hours for a single video generation test. |
|
|
43
|
+
| Modalities | Image, video, TTS, SFX, music, text, credits, status, and model discovery behind one Flatkey key. | Strong image/video creator stack and media tooling. | Strong ByteDance image/video models, especially Seedance/Dreamina-style workflows. |
|
|
44
|
+
| Agent ergonomics | `--json`, `--output/-o`, `--dry-run`, `models`, `audio voices`, and predictable stdout/stderr. | More oriented around human creative workflows. | More oriented around app/product workflow than simple agent protocol. |
|
|
45
|
+
| Best fit | Media teams building repeatable generation pipelines and AI-agent workflows. | Creators who want a polished visual production suite. | Teams already deep in CapCut/Dreamina/Jimeng ecosystem and willing to accept plan/queue tradeoffs. |
|
|
46
|
+
|
|
47
|
+
In short: use Flatkey when the job is repeated media generation, cost-sensitive testing, and AI-agent automation. Use creator suites when you want their native editing UI more than a programmable router.
|
|
48
|
+
|
|
32
49
|
## Install
|
|
33
50
|
|
|
34
51
|
```bash
|
|
@@ -62,7 +79,7 @@ Or save it locally:
|
|
|
62
79
|
flatkey onboard --api-key <key>
|
|
63
80
|
```
|
|
64
81
|
|
|
65
|
-
|
|
82
|
+
Create a key at [console.flatkey.ai/keys](https://console.flatkey.ai/keys), then pass it with `--api-key`.
|
|
66
83
|
|
|
67
84
|
## Commands
|
|
68
85
|
|
|
@@ -81,9 +98,14 @@ flatkey image generate \
|
|
|
81
98
|
flatkey video generate \
|
|
82
99
|
--prompt "8 second cinematic product reveal, glossy black background" \
|
|
83
100
|
--model seedance2 \
|
|
101
|
+
--ratio 16:9 \
|
|
102
|
+
--resolution 720p \
|
|
84
103
|
-o launch.mp4
|
|
85
104
|
```
|
|
86
105
|
|
|
106
|
+
Video ratios: `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `1:1`.
|
|
107
|
+
Video resolutions: `480p`, `720p`, `1080p`.
|
|
108
|
+
|
|
87
109
|
### Generate Audio
|
|
88
110
|
|
|
89
111
|
Text to speech:
|
|
@@ -222,6 +244,10 @@ npm test
|
|
|
222
244
|
node bin/flatkey.js --version
|
|
223
245
|
```
|
|
224
246
|
|
|
247
|
+
## Star History
|
|
248
|
+
|
|
249
|
+
[](https://www.star-history.com/#flatkey-ai/flatkey-cli&Date)
|
|
250
|
+
|
|
225
251
|
## Links
|
|
226
252
|
|
|
227
253
|
- Website: [flatkey.ai](https://flatkey.ai/?utm_source=github&utm_medium=readme&utm_campaign=flatkey_cli_links)
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flatkey-ai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Flatkey media generation CLI for image, video, audio, credits, status, and models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"flatkey": "bin/flatkey.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
9
13
|
"scripts": {
|
|
10
14
|
"test": "node --test"
|
|
11
15
|
},
|
package/src/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const DEFAULT_BASE_URL = "https://router.flatkey.ai";
|
|
2
2
|
export const DEFAULT_MODELS_BASE_URL = "https://console.flatkey.ai";
|
|
3
|
+
export const DEFAULT_CONSOLE_URL = "https://console.flatkey.ai";
|
|
3
4
|
|
|
4
5
|
export class FlatkeyError extends Error {
|
|
5
6
|
constructor(message, { status } = {}) {
|
|
@@ -43,11 +44,24 @@ export function generateVideo(options) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function planVideoRequest(options) {
|
|
47
|
+
const ratio = validateOptionalValue(
|
|
48
|
+
optionValue(options, "ratio", "aspect"),
|
|
49
|
+
["16:9", "9:16", "4:3", "3:4", "21:9", "1:1"],
|
|
50
|
+
"ratio",
|
|
51
|
+
);
|
|
52
|
+
const resolution = validateOptionalValue(
|
|
53
|
+
options.resolution,
|
|
54
|
+
["480p", "720p", "1080p"],
|
|
55
|
+
"resolution",
|
|
56
|
+
);
|
|
46
57
|
return planJsonPost(options, "/v1/video/generations", cleanObject({
|
|
47
58
|
model: options.model ?? "veo-3",
|
|
48
59
|
prompt: options.prompt,
|
|
49
60
|
duration: parseOptionalInteger(options.duration),
|
|
50
|
-
aspect:
|
|
61
|
+
aspect: ratio,
|
|
62
|
+
ratio,
|
|
63
|
+
resolution,
|
|
64
|
+
quality: resolution,
|
|
51
65
|
fps: parseOptionalInteger(options.fps),
|
|
52
66
|
}));
|
|
53
67
|
}
|
|
@@ -111,11 +125,17 @@ export function planTextRequest(options) {
|
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
export function getCredits(options) {
|
|
114
|
-
return requestJson(
|
|
128
|
+
return requestJson({
|
|
129
|
+
...options,
|
|
130
|
+
baseUrl: options.baseUrl ?? DEFAULT_CONSOLE_URL,
|
|
131
|
+
}, "/v1/credits");
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
export function getStatus(options) {
|
|
118
|
-
return requestJson(
|
|
135
|
+
return requestJson({
|
|
136
|
+
...options,
|
|
137
|
+
baseUrl: options.baseUrl ?? DEFAULT_CONSOLE_URL,
|
|
138
|
+
}, "/v1/status");
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
export function getModels(options) {
|
|
@@ -125,6 +145,32 @@ export function getModels(options) {
|
|
|
125
145
|
}, "/v1/available_models");
|
|
126
146
|
}
|
|
127
147
|
|
|
148
|
+
export async function createDeviceAuthorization(options) {
|
|
149
|
+
return requestJson({
|
|
150
|
+
...options,
|
|
151
|
+
baseUrl: options.consoleUrl ?? DEFAULT_CONSOLE_URL,
|
|
152
|
+
}, "/api/cli/device_authorizations", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
client_name: options.clientName ?? "flatkey-cli",
|
|
156
|
+
client_version: options.clientVersion,
|
|
157
|
+
device_id: options.deviceId,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function pollDeviceAuthorization(options) {
|
|
163
|
+
return requestJson({
|
|
164
|
+
...options,
|
|
165
|
+
baseUrl: options.consoleUrl ?? DEFAULT_CONSOLE_URL,
|
|
166
|
+
}, "/api/cli/device_authorizations/token", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
device_code: options.deviceCode,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
128
174
|
async function postJson(options, path, payload) {
|
|
129
175
|
return requestJsonFromPlan(options, planJsonPost(options, path, payload));
|
|
130
176
|
}
|
|
@@ -223,6 +269,12 @@ function optionValue(options, ...keys) {
|
|
|
223
269
|
return undefined;
|
|
224
270
|
}
|
|
225
271
|
|
|
272
|
+
function validateOptionalValue(value, allowed, name) {
|
|
273
|
+
if (value === undefined) return undefined;
|
|
274
|
+
if (allowed.includes(value)) return value;
|
|
275
|
+
throw new Error(`Invalid ${name}: ${value}. Allowed values: ${allowed.join(", ")}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
226
278
|
function cleanObject(value) {
|
|
227
279
|
return Object.fromEntries(
|
|
228
280
|
Object.entries(value).filter(([, entry]) => entry !== undefined),
|
|
@@ -238,7 +290,16 @@ async function readJson(response) {
|
|
|
238
290
|
}
|
|
239
291
|
|
|
240
292
|
function extractErrorMessage(body, status) {
|
|
241
|
-
|
|
242
|
-
|
|
293
|
+
const message = typeof body?.error?.message === "string"
|
|
294
|
+
? body.error.message
|
|
295
|
+
: typeof body?.message === "string"
|
|
296
|
+
? body.message
|
|
297
|
+
: undefined;
|
|
298
|
+
if (message === "Token not provided") return missingApiKeyMessage();
|
|
299
|
+
if (message) return message;
|
|
243
300
|
return `Flatkey API request failed with HTTP ${status}`;
|
|
244
301
|
}
|
|
302
|
+
|
|
303
|
+
function missingApiKeyMessage() {
|
|
304
|
+
return "Missing Flatkey API key. Create one at https://console.flatkey.ai/keys, then run `flatkey onboard --api-key <key>` or set FLATKEY_API_KEY.";
|
|
305
|
+
}
|
package/src/artifacts.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { dirname, extname, join } from "node:path";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_EXTENSIONS = {
|
|
@@ -25,6 +26,7 @@ export async function persistArtifacts({
|
|
|
25
26
|
output,
|
|
26
27
|
fetch: fetchImpl = fetch,
|
|
27
28
|
}) {
|
|
29
|
+
output = expandHomePath(output);
|
|
28
30
|
const items = extractItems(response);
|
|
29
31
|
const artifacts = [];
|
|
30
32
|
await mkdir(output ? dirname(output) : outDir, { recursive: true });
|
|
@@ -37,6 +39,13 @@ export async function persistArtifacts({
|
|
|
37
39
|
return artifacts;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
function expandHomePath(path) {
|
|
43
|
+
if (typeof path !== "string") return path;
|
|
44
|
+
if (path === "~") return homedir();
|
|
45
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
|
|
41
50
|
const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
|
|
42
51
|
if (dataUrl?.startsWith("data:")) {
|
package/src/cli.js
CHANGED
|
@@ -3,31 +3,62 @@ const COMMANDS = new Set([
|
|
|
3
3
|
"credits",
|
|
4
4
|
"help",
|
|
5
5
|
"image",
|
|
6
|
+
"login",
|
|
6
7
|
"models",
|
|
7
8
|
"onboard",
|
|
9
|
+
"logout",
|
|
10
|
+
"auth",
|
|
8
11
|
"status",
|
|
9
12
|
"text",
|
|
10
13
|
"version",
|
|
11
14
|
"video",
|
|
12
15
|
]);
|
|
13
16
|
|
|
14
|
-
const GROUP_ACTIONS = new Set(["audio", "image", "text", "video"]);
|
|
17
|
+
const GROUP_ACTIONS = new Set(["audio", "auth", "image", "text", "video"]);
|
|
15
18
|
|
|
16
19
|
export function parseArgv(argv) {
|
|
17
20
|
const [group, maybeAction, ...rest] = argv;
|
|
18
21
|
if (!group) {
|
|
19
22
|
return { group: "help", action: undefined, options: {} };
|
|
20
23
|
}
|
|
24
|
+
if (group === "--help" || group === "-h") {
|
|
25
|
+
return { group: "help", action: undefined, options: {} };
|
|
26
|
+
}
|
|
21
27
|
if (group === "--version" || group === "-v") {
|
|
22
28
|
return { group: "version", action: undefined, options: {} };
|
|
23
29
|
}
|
|
24
30
|
if (!COMMANDS.has(group)) {
|
|
25
31
|
throw new Error(`Unknown command: ${group}`);
|
|
26
32
|
}
|
|
33
|
+
if (group === "help" && maybeAction && !maybeAction.startsWith("--")) {
|
|
34
|
+
return {
|
|
35
|
+
group: "help",
|
|
36
|
+
action: undefined,
|
|
37
|
+
options: { command: maybeAction, ...parseOptions(rest) },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
const hasAction = GROUP_ACTIONS.has(group);
|
|
42
|
+
if (hasAction && isHelpToken(maybeAction)) {
|
|
43
|
+
return { group, action: undefined, options: { help: true } };
|
|
44
|
+
}
|
|
45
|
+
if (!hasAction && isHelpToken(maybeAction)) {
|
|
46
|
+
return { group, action: undefined, options: { help: true } };
|
|
47
|
+
}
|
|
29
48
|
const action = hasAction ? maybeAction : undefined;
|
|
30
49
|
const optionTokens = hasAction ? rest : argv.slice(1);
|
|
50
|
+
const hasHelpOption = optionTokens.some((token) => token === "--help" || token === "-h")
|
|
51
|
+
|| (optionTokens.length === 1 && optionTokens[0] === "help");
|
|
52
|
+
if (hasHelpOption) {
|
|
53
|
+
return {
|
|
54
|
+
group,
|
|
55
|
+
action,
|
|
56
|
+
options: {
|
|
57
|
+
...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h")),
|
|
58
|
+
help: true,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
31
62
|
|
|
32
63
|
return {
|
|
33
64
|
group,
|
|
@@ -36,6 +67,10 @@ export function parseArgv(argv) {
|
|
|
36
67
|
};
|
|
37
68
|
}
|
|
38
69
|
|
|
70
|
+
function isHelpToken(token) {
|
|
71
|
+
return token === "help" || token === "--help" || token === "-h";
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
function parseOptions(tokens) {
|
|
40
75
|
const options = {};
|
|
41
76
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
@@ -66,6 +101,11 @@ function parseOptions(tokens) {
|
|
|
66
101
|
|
|
67
102
|
export async function main(argv) {
|
|
68
103
|
const command = parseArgv(argv);
|
|
104
|
+
if (command.options.help) {
|
|
105
|
+
const result = await runCommand(command);
|
|
106
|
+
process.stdout.write(`${formatHuman(result)}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
69
109
|
if (command.group === "onboard") {
|
|
70
110
|
const { writeConfig } = await import("./config.js");
|
|
71
111
|
const configPath = await writeConfig({ apiKey: command.options.api_key });
|
|
@@ -86,9 +126,14 @@ export async function runCommand(command, deps = {}) {
|
|
|
86
126
|
const stderr = deps.stderr ?? process.stderr;
|
|
87
127
|
|
|
88
128
|
if (command.group === "help") {
|
|
89
|
-
const { getAiHelp, getHumanHelp } = await import("./help.js");
|
|
129
|
+
const { getAiHelp, getCommandHelp, getHumanHelp } = await import("./help.js");
|
|
130
|
+
if (command.options.command) return getCommandHelp(command.options.command);
|
|
90
131
|
return command.options.ai ? getAiHelp() : getHumanHelp();
|
|
91
132
|
}
|
|
133
|
+
if (command.options.help) {
|
|
134
|
+
const { getCommandHelp } = await import("./help.js");
|
|
135
|
+
return getCommandHelp(command.group, command.action);
|
|
136
|
+
}
|
|
92
137
|
if (command.group === "version") {
|
|
93
138
|
const version = await readPackageVersion();
|
|
94
139
|
return command.options.json ? { version } : version;
|
|
@@ -98,6 +143,21 @@ export async function runCommand(command, deps = {}) {
|
|
|
98
143
|
return handleModels(command, deps);
|
|
99
144
|
}
|
|
100
145
|
|
|
146
|
+
if (command.group === "login") {
|
|
147
|
+
return handleLogin(command, { ...deps, stdout, stderr });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (command.group === "logout") {
|
|
151
|
+
return handleLogout(command, deps);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (command.group === "auth") {
|
|
155
|
+
if (command.action !== "status") {
|
|
156
|
+
throw new Error(`Unknown action for auth: ${command.action}`);
|
|
157
|
+
}
|
|
158
|
+
return handleAuthStatus(command, deps);
|
|
159
|
+
}
|
|
160
|
+
|
|
101
161
|
if (command.group === "credits" || command.group === "status") {
|
|
102
162
|
return handleUtility(command, deps);
|
|
103
163
|
}
|
|
@@ -118,6 +178,150 @@ export async function runCommand(command, deps = {}) {
|
|
|
118
178
|
throw new Error(`Unknown command: ${command.group}`);
|
|
119
179
|
}
|
|
120
180
|
|
|
181
|
+
async function handleLogin(command, deps) {
|
|
182
|
+
const { ensureDeviceId, writeAuthConfig } = await import("./config.js");
|
|
183
|
+
const { createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
|
|
184
|
+
const deviceId = await ensureDeviceId({ configDir: deps.configDir });
|
|
185
|
+
const version = await readPackageVersion();
|
|
186
|
+
const consoleUrl = command.options.console_url;
|
|
187
|
+
const authorization = await createDeviceAuthorization({
|
|
188
|
+
consoleUrl,
|
|
189
|
+
deviceId,
|
|
190
|
+
clientName: "flatkey-cli",
|
|
191
|
+
clientVersion: version,
|
|
192
|
+
fetch: deps.fetch,
|
|
193
|
+
});
|
|
194
|
+
const data = authorization?.data ?? authorization;
|
|
195
|
+
if (!data?.device_code || !data?.verification_uri_complete) {
|
|
196
|
+
throw new Error("Flatkey login failed: missing device authorization response.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!command.options.json) {
|
|
200
|
+
deps.stdout?.write?.(`Open this URL to approve Flatkey CLI:\n${data.verification_uri_complete}\n\n`);
|
|
201
|
+
}
|
|
202
|
+
if (command.options.open !== false && command.options.no_open !== true) {
|
|
203
|
+
await openBrowser(data.verification_uri_complete, deps);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const initialIntervalMs = Math.max(Number(data.interval ?? 5), 5) * 1000;
|
|
207
|
+
const deadline = Date.now() + Math.max(Number(data.expires_in ?? 600), 1) * 1000;
|
|
208
|
+
const startedAt = Date.now();
|
|
209
|
+
while (Date.now() < deadline) {
|
|
210
|
+
await delay(nextLoginPollDelay(startedAt, initialIntervalMs), deps);
|
|
211
|
+
const poll = await pollDeviceAuthorization({
|
|
212
|
+
consoleUrl,
|
|
213
|
+
deviceCode: data.device_code,
|
|
214
|
+
fetch: deps.fetch,
|
|
215
|
+
});
|
|
216
|
+
const pollData = poll?.data ?? poll;
|
|
217
|
+
if (pollData?.status === "approved") {
|
|
218
|
+
if (!pollData.api_key) {
|
|
219
|
+
throw new Error("Flatkey login approved but no API key was returned.");
|
|
220
|
+
}
|
|
221
|
+
const configPath = await writeAuthConfig({
|
|
222
|
+
apiKey: pollData.api_key,
|
|
223
|
+
auth: {
|
|
224
|
+
deviceId,
|
|
225
|
+
userId: pollData.user_id,
|
|
226
|
+
tokenId: pollData.token_id,
|
|
227
|
+
loginAt: Math.floor(Date.now() / 1000),
|
|
228
|
+
},
|
|
229
|
+
configDir: deps.configDir,
|
|
230
|
+
});
|
|
231
|
+
return command.options.json
|
|
232
|
+
? { success: true, configPath, tokenId: pollData.token_id, userId: pollData.user_id }
|
|
233
|
+
: `Flatkey CLI authorized. Saved config: ${configPath}`;
|
|
234
|
+
}
|
|
235
|
+
if (pollData?.status === "denied") {
|
|
236
|
+
throw new Error("Flatkey login denied.");
|
|
237
|
+
}
|
|
238
|
+
if (pollData?.status === "expired") {
|
|
239
|
+
throw new Error("Flatkey login expired. Run `flatkey login` again.");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error("Flatkey login timed out. Run `flatkey login` again.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function nextLoginPollDelay(startedAt, initialIntervalMs) {
|
|
246
|
+
const elapsed = Date.now() - startedAt;
|
|
247
|
+
const base = elapsed < 30_000
|
|
248
|
+
? initialIntervalMs
|
|
249
|
+
: elapsed < 120_000
|
|
250
|
+
? Math.max(initialIntervalMs, 10_000)
|
|
251
|
+
: Math.max(initialIntervalMs, 15_000);
|
|
252
|
+
return base + Math.floor(Math.random() * 800);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function delay(ms, deps = {}) {
|
|
256
|
+
const sleep = deps.sleep ?? ((duration) => new Promise((resolve) => setTimeout(resolve, duration)));
|
|
257
|
+
return sleep(ms);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function openBrowser(url, deps = {}) {
|
|
261
|
+
if (deps.openBrowser) return deps.openBrowser(url);
|
|
262
|
+
const { spawn } = await import("node:child_process");
|
|
263
|
+
const platform = deps.platform ?? process.platform;
|
|
264
|
+
const command = platform === "darwin"
|
|
265
|
+
? "open"
|
|
266
|
+
: platform === "win32"
|
|
267
|
+
? "cmd"
|
|
268
|
+
: "xdg-open";
|
|
269
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
270
|
+
try {
|
|
271
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
272
|
+
child.unref();
|
|
273
|
+
} catch {
|
|
274
|
+
// Printed URL is enough when open is unavailable.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function maskKey(key) {
|
|
279
|
+
if (!key) return "";
|
|
280
|
+
if (key.length <= 8) return `${key.slice(0, 2)}****${key.slice(-2)}`;
|
|
281
|
+
return `${key.slice(0, 6)}...${key.slice(-4)}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatAuthStatus(status) {
|
|
285
|
+
if (!status.authenticated) return "Not authenticated";
|
|
286
|
+
return `Authenticated via ${status.source}: ${status.key}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function handleLogout(command, deps = {}) {
|
|
290
|
+
const { clearSavedApiKey } = await import("./config.js");
|
|
291
|
+
const configPath = await clearSavedApiKey({ configDir: deps.configDir });
|
|
292
|
+
return command.options.json
|
|
293
|
+
? { success: true, configPath }
|
|
294
|
+
: `Removed saved Flatkey API key from ${configPath}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function handleAuthStatus(command, deps) {
|
|
298
|
+
const { readConfig, resolveApiKey } = await import("./config.js");
|
|
299
|
+
const saved = await readConfig(deps.configDir);
|
|
300
|
+
let apiKey;
|
|
301
|
+
try {
|
|
302
|
+
apiKey = await resolveApiKey({
|
|
303
|
+
apiKey: command.options.api_key,
|
|
304
|
+
env: deps.env ?? process.env,
|
|
305
|
+
configDir: deps.configDir,
|
|
306
|
+
});
|
|
307
|
+
} catch {
|
|
308
|
+
apiKey = "";
|
|
309
|
+
}
|
|
310
|
+
const status = {
|
|
311
|
+
authenticated: Boolean(apiKey),
|
|
312
|
+
source: command.options.api_key
|
|
313
|
+
? "option"
|
|
314
|
+
: (deps.env ?? process.env).FLATKEY_API_KEY
|
|
315
|
+
? "env"
|
|
316
|
+
: saved?.apiKey
|
|
317
|
+
? "config"
|
|
318
|
+
: "none",
|
|
319
|
+
key: maskKey(apiKey),
|
|
320
|
+
auth: saved?.auth ?? null,
|
|
321
|
+
};
|
|
322
|
+
return command.options.json ? status : formatAuthStatus(status);
|
|
323
|
+
}
|
|
324
|
+
|
|
121
325
|
async function handleGenerate(command, deps) {
|
|
122
326
|
const { resolveApiKey } = await import("./config.js");
|
|
123
327
|
const {
|
|
@@ -191,12 +395,33 @@ async function handleGenerate(command, deps) {
|
|
|
191
395
|
output: command.options.output,
|
|
192
396
|
fetch: deps.fetch,
|
|
193
397
|
});
|
|
194
|
-
return { kind: command.group, artifacts, response };
|
|
398
|
+
return { kind: command.group, artifacts, response: scrubArtifactResponse(response) };
|
|
195
399
|
} finally {
|
|
196
400
|
animation.stop();
|
|
197
401
|
}
|
|
198
402
|
}
|
|
199
403
|
|
|
404
|
+
function scrubArtifactResponse(value) {
|
|
405
|
+
if (Array.isArray(value)) return value.map(scrubArtifactResponse);
|
|
406
|
+
if (!value || typeof value !== "object") {
|
|
407
|
+
if (typeof value === "string" && value.startsWith("data:")) return "<artifact omitted>";
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
return Object.fromEntries(
|
|
411
|
+
Object.entries(value).map(([key, entry]) => {
|
|
412
|
+
if (["b64_json", "base64", "data"].includes(key) && typeof entry === "string") {
|
|
413
|
+
return [key, "<artifact omitted>"];
|
|
414
|
+
}
|
|
415
|
+
if (["url", "data_url", "dataUrl"].includes(key)
|
|
416
|
+
&& typeof entry === "string"
|
|
417
|
+
&& entry.startsWith("data:")) {
|
|
418
|
+
return [key, "<artifact omitted>"];
|
|
419
|
+
}
|
|
420
|
+
return [key, scrubArtifactResponse(entry)];
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
200
425
|
async function handleVoices(command, deps) {
|
|
201
426
|
const { resolveApiKey } = await import("./config.js");
|
|
202
427
|
const { getVoices } = await import("./api.js");
|
|
@@ -220,6 +445,7 @@ function extractText(response) {
|
|
|
220
445
|
|
|
221
446
|
async function writeTextOutput(text, output) {
|
|
222
447
|
if (!output) return undefined;
|
|
448
|
+
output = await expandHomePath(output);
|
|
223
449
|
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
224
450
|
const { dirname } = await import("node:path");
|
|
225
451
|
await mkdir(dirname(output), { recursive: true });
|
|
@@ -227,6 +453,15 @@ async function writeTextOutput(text, output) {
|
|
|
227
453
|
return output;
|
|
228
454
|
}
|
|
229
455
|
|
|
456
|
+
async function expandHomePath(path) {
|
|
457
|
+
if (typeof path !== "string") return path;
|
|
458
|
+
if (path !== "~" && !path.startsWith("~/")) return path;
|
|
459
|
+
const { homedir } = await import("node:os");
|
|
460
|
+
const { join } = await import("node:path");
|
|
461
|
+
if (path === "~") return homedir();
|
|
462
|
+
return join(homedir(), path.slice(2));
|
|
463
|
+
}
|
|
464
|
+
|
|
230
465
|
function redactRequest(request) {
|
|
231
466
|
return {
|
|
232
467
|
...request,
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
@@ -22,13 +23,13 @@ export async function resolveApiKey({
|
|
|
22
23
|
if (saved?.apiKey) return saved.apiKey;
|
|
23
24
|
|
|
24
25
|
throw new Error(
|
|
25
|
-
"Missing Flatkey API key.
|
|
26
|
+
"Missing Flatkey API key. Create one at https://console.flatkey.ai/keys, then run `flatkey onboard --api-key <key>` or set FLATKEY_API_KEY.",
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export async function writeConfig({ apiKey, configDir = getDefaultConfigDir() }) {
|
|
30
|
-
if (
|
|
31
|
-
throw new Error("Missing --api-key value.");
|
|
31
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
32
|
+
throw new Error("Missing --api-key value. Create a key at https://console.flatkey.ai/keys, then run `flatkey onboard --api-key <key>`.");
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
await mkdir(configDir, { recursive: true });
|
|
@@ -44,6 +45,62 @@ export async function writeConfig({ apiKey, configDir = getDefaultConfigDir() })
|
|
|
44
45
|
return configPath;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
export async function writeAuthConfig({
|
|
49
|
+
apiKey,
|
|
50
|
+
auth,
|
|
51
|
+
configDir = getDefaultConfigDir(),
|
|
52
|
+
}) {
|
|
53
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
54
|
+
throw new Error("Missing API key from Flatkey login response.");
|
|
55
|
+
}
|
|
56
|
+
const saved = await readSavedConfig(configDir) ?? {};
|
|
57
|
+
const next = {
|
|
58
|
+
...saved,
|
|
59
|
+
apiKey,
|
|
60
|
+
auth: {
|
|
61
|
+
...(saved.auth ?? {}),
|
|
62
|
+
...auth,
|
|
63
|
+
type: "device",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
await mkdir(configDir, { recursive: true });
|
|
67
|
+
const configPath = getConfigPath(configDir);
|
|
68
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, {
|
|
69
|
+
mode: 0o600,
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
await chmod(configPath, 0o600);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (process.platform !== "win32") throw error;
|
|
75
|
+
}
|
|
76
|
+
return configPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function ensureDeviceId({ configDir = getDefaultConfigDir() } = {}) {
|
|
80
|
+
const saved = await readSavedConfig(configDir);
|
|
81
|
+
if (typeof saved?.auth?.deviceId === "string" && saved.auth.deviceId) {
|
|
82
|
+
return saved.auth.deviceId;
|
|
83
|
+
}
|
|
84
|
+
return randomUUID();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function clearSavedApiKey({ configDir = getDefaultConfigDir() } = {}) {
|
|
88
|
+
const saved = await readSavedConfig(configDir);
|
|
89
|
+
if (!saved) return getConfigPath(configDir);
|
|
90
|
+
const next = { ...saved };
|
|
91
|
+
delete next.apiKey;
|
|
92
|
+
await mkdir(configDir, { recursive: true });
|
|
93
|
+
const configPath = getConfigPath(configDir);
|
|
94
|
+
await writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, {
|
|
95
|
+
mode: 0o600,
|
|
96
|
+
});
|
|
97
|
+
return configPath;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function readConfig(configDir = getDefaultConfigDir()) {
|
|
101
|
+
return readSavedConfig(configDir);
|
|
102
|
+
}
|
|
103
|
+
|
|
47
104
|
async function readSavedConfig(configDir) {
|
|
48
105
|
try {
|
|
49
106
|
return JSON.parse(await readFile(getConfigPath(configDir), "utf8"));
|