@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 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
- Get a key from [flatkey.ai](https://flatkey.ai/?utm_source=github&utm_medium=readme&utm_campaign=flatkey_cli_auth).
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
+ [![Star History Chart](https://api.star-history.com/svg?repos=flatkey-ai/flatkey-cli&type=Date)](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.5",
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: options.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(options, "/v1/credits");
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(options, "/v1/status");
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
- if (typeof body?.error?.message === "string") return body.error.message;
242
- if (typeof body?.message === "string") return body.message;
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. Run `flatkey onboard --api-key <key>` or set 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 (!apiKey) {
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"));