flamecast 0.4.1 → 0.5.0

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/dist/cli.js CHANGED
@@ -14,69 +14,159 @@ function getVersion() {
14
14
  return "unknown";
15
15
  }
16
16
  }
17
- // Load .env from the CLI package root (written by scripts/dev-setup.mjs)
18
- const __cli_dir = dirname(fileURLToPath(import.meta.url));
19
- const envPath = resolve(__cli_dir, "..", ".env");
20
- if (existsSync(envPath)) {
21
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
22
- const match = line.match(/^([^#=]+)=(.*)$/);
23
- if (match && !process.env[match[1]]) {
24
- process.env[match[1]] = match[2];
17
+ // Load .env from the CLI package root, but only in dev (cli:local sets
18
+ // FLAMECAST_BASE_URL as a real env var; the .env file is for local dev only).
19
+ if (process.env.NODE_ENV === "development") {
20
+ const __cli_dir = dirname(fileURLToPath(import.meta.url));
21
+ const envPath = resolve(__cli_dir, "..", ".env");
22
+ if (existsSync(envPath)) {
23
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
24
+ const match = line.match(/^([^#=]+)=(.*)$/);
25
+ if (match && !process.env[match[1]]) {
26
+ process.env[match[1]] = match[2];
27
+ }
25
28
  }
26
29
  }
27
30
  }
28
- import { handleCastAdd } from "./commands/cast/add.js";
29
- import { handleCastList } from "./commands/cast/list.js";
30
- import { handleRun } from "./commands/run.js";
31
- import { handleStatus } from "./commands/status.js";
32
- import { handleResult } from "./commands/result.js";
33
- import { handleApi } from "./commands/api.js";
34
- import { handleLogin } from "./commands/login.js";
35
- import { handleLogout } from "./commands/logout.js";
36
- import { handleSetupClaude } from "./commands/setup-claude.js";
37
- const args = process.argv.slice(2);
38
- const [sub1, sub2, ...rest] = args;
39
- if (sub1 === "--version" || sub1 === "-v") {
40
- console.log(getVersion());
41
- process.exit(0);
31
+ import { Command } from "commander";
32
+ const program = new Command()
33
+ .name("flame")
34
+ .version(getVersion())
35
+ .description("Flamecast CLI");
36
+ // ── workspace | ws | cast ──────────────────────────
37
+ function registerWorkspaceCommands(cmd) {
38
+ cmd
39
+ .command("create")
40
+ .alias("c")
41
+ .alias("add")
42
+ .description("Create a new workspace (interactive)")
43
+ .action(async () => {
44
+ const { handleWorkspaceCreate } = await import("./commands/workspace/create.js");
45
+ await handleWorkspaceCreate();
46
+ });
47
+ cmd
48
+ .command("list")
49
+ .alias("ls")
50
+ .description("List all workspaces")
51
+ .action(async () => {
52
+ const { handleWorkspaceList } = await import("./commands/workspace/list.js");
53
+ await handleWorkspaceList();
54
+ });
55
+ // Default action when no subcommand — show list
56
+ cmd.action(async () => {
57
+ const { handleWorkspaceList } = await import("./commands/workspace/list.js");
58
+ await handleWorkspaceList();
59
+ });
60
+ cmd
61
+ .command("get <ref>")
62
+ .alias("g")
63
+ .description("Get workspace details by name or ID")
64
+ .action(async (ref) => {
65
+ const { handleWorkspaceGet } = await import("./commands/workspace/get.js");
66
+ await handleWorkspaceGet(ref);
67
+ });
68
+ cmd
69
+ .command("delete <ref>")
70
+ .alias("rm")
71
+ .description("Delete a workspace")
72
+ .action(async (ref) => {
73
+ const { handleWorkspaceDelete } = await import("./commands/workspace/delete.js");
74
+ await handleWorkspaceDelete(ref);
75
+ });
76
+ cmd
77
+ .command("set-default <ref>")
78
+ .description("Set the default workspace")
79
+ .action(async (ref) => {
80
+ const { handleWorkspaceSetDefault } = await import("./commands/workspace/set-default.js");
81
+ await handleWorkspaceSetDefault(ref);
82
+ });
83
+ cmd
84
+ .command("set-secrets <ref> [secrets...]")
85
+ .description("Set secrets on a workspace (KEY=VALUE ...)")
86
+ .action(async (ref, secrets) => {
87
+ const { handleWorkspaceSetSecrets } = await import("./commands/workspace/set-secrets.js");
88
+ await handleWorkspaceSetSecrets(ref, secrets);
89
+ });
90
+ cmd
91
+ .command("sync <ref>")
92
+ .alias("sync-workflows")
93
+ .description("Sync workflow files to the workspace repo")
94
+ .action(async (ref) => {
95
+ const { handleWorkspaceSyncWorkflows } = await import("./commands/workspace/sync-workflows.js");
96
+ await handleWorkspaceSyncWorkflows(ref);
97
+ });
42
98
  }
43
- else if (sub1 === "cast" && sub2 === "add") {
44
- await handleCastAdd();
45
- }
46
- else if (sub1 === "cast" && (sub2 === "list" || sub2 === undefined)) {
47
- await handleCastList();
48
- }
49
- else if (sub1 === "run" && sub2) {
50
- await handleRun(sub2, rest);
51
- }
52
- else if (sub1 === "status") {
53
- await handleStatus();
54
- }
55
- else if (sub1 === "result" && sub2) {
56
- await handleResult(sub2);
57
- }
58
- else if (sub1 === "api") {
59
- handleApi();
60
- }
61
- else if (sub1 === "login") {
99
+ const workspaceCmd = program
100
+ .command("workspace")
101
+ .alias("ws")
102
+ .description("Manage workspaces");
103
+ registerWorkspaceCommands(workspaceCmd);
104
+ // "cast" as a top-level alias for "workspace"
105
+ const castCmd = program
106
+ .command("cast")
107
+ .description("Manage workspaces (alias for workspace)");
108
+ registerWorkspaceCommands(castCmd);
109
+ // ── task | t ───────────────────────────────────────
110
+ const taskCmd = program.command("task").alias("t").description("Manage tasks");
111
+ taskCmd
112
+ .command("create <prompt...>")
113
+ .alias("c")
114
+ .description("Create a new task")
115
+ .option("-w, --workspace <ref>", "Workspace name or ID (default: default workspace)")
116
+ .action(async (prompt, opts) => {
117
+ const { handleTaskCreate } = await import("./commands/task/create.js");
118
+ await handleTaskCreate(prompt, opts.workspace);
119
+ });
120
+ taskCmd
121
+ .command("list")
122
+ .alias("ls")
123
+ .description("List tasks for a workspace")
124
+ .option("-w, --workspace <ref>", "Workspace name or ID (default: default workspace)")
125
+ .action(async (opts) => {
126
+ const { handleTaskList } = await import("./commands/task/list.js");
127
+ await handleTaskList(opts.workspace);
128
+ });
129
+ // Default action when no subcommand — show list
130
+ taskCmd.action(async () => {
131
+ const { handleTaskList } = await import("./commands/task/list.js");
132
+ await handleTaskList();
133
+ });
134
+ taskCmd
135
+ .command("get <taskId>")
136
+ .alias("g")
137
+ .description("Get task details")
138
+ .option("-w, --workspace <ref>", "Workspace name or ID (default: default workspace)")
139
+ .action(async (taskId, opts) => {
140
+ const { handleTaskGet } = await import("./commands/task/get.js");
141
+ await handleTaskGet(taskId, opts.workspace);
142
+ });
143
+ // ── top-level commands ─────────────────────────────
144
+ program
145
+ .command("login")
146
+ .description("Authenticate with Flamecast")
147
+ .action(async () => {
148
+ const { handleLogin } = await import("./commands/login.js");
62
149
  await handleLogin();
63
- }
64
- else if (sub1 === "logout") {
150
+ });
151
+ program
152
+ .command("logout")
153
+ .description("Clear saved credentials")
154
+ .action(async () => {
155
+ const { handleLogout } = await import("./commands/logout.js");
65
156
  await handleLogout();
66
- }
67
- else if (sub1 === "setup-claude") {
157
+ });
158
+ program
159
+ .command("api")
160
+ .description("Print API key")
161
+ .action(async () => {
162
+ const { handleApi } = await import("./commands/api.js");
163
+ handleApi();
164
+ });
165
+ program
166
+ .command("setup-claude")
167
+ .description("Set up Claude Code token")
168
+ .action(async () => {
169
+ const { handleSetupClaude } = await import("./commands/setup-claude.js");
68
170
  await handleSetupClaude();
69
- }
70
- else {
71
- console.error("Usage:");
72
- console.error(" flame cast add Create a new cast member");
73
- console.error(" flame cast [list] List all cast members");
74
- console.error(" flame run <name> <prompt> Dispatch a task to a cast member");
75
- console.error(" flame status Show status of all active tasks");
76
- console.error(" flame result <name> latest Show latest task result");
77
- console.error(" flame api Print API key");
78
- console.error(" flame login Authenticate with Flamecast");
79
- console.error(" flame setup-claude Set up Claude Code token");
80
- console.error(" flame logout Clear saved credentials");
81
- process.exit(1);
82
- }
171
+ });
172
+ await program.parseAsync();
@@ -1,8 +1,7 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { saveConfig } from "../../lib/config.js";
3
3
  import { spawnSync } from "node:child_process";
4
- import { client } from "../../lib/auth.js";
5
- import { authenticateViaBrowser } from "../../lib/auth.js";
4
+ import { client, authenticateViaBrowser, verifyEmailAndExchange, exchangeForApiKey, } from "../../lib/auth.js";
6
5
  import { openBrowser } from "../../lib/open-browser.js";
7
6
  import { generateEmployeeName } from "../../lib/names.js";
8
7
  import { detectClaudeCode, discoverLocalConfig } from "../../lib/detect.js";
@@ -18,15 +17,38 @@ export async function handleCastAdd() {
18
17
  const needsSetup = !username;
19
18
  if (needsSetup) {
20
19
  p.log.step("\u2500\u2500 Setup (one-time) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
21
- // Step 1: Authenticate with Flamecast (WorkOS device flow)
20
+ // Step 1: Authenticate with Flamecast
22
21
  const s = p.spinner();
23
- s.start("Waiting for browser login...");
22
+ s.start("Starting authentication...");
24
23
  try {
25
- const apiKey = await authenticateViaBrowser((userCode, verificationUri) => {
26
- s.stop(`Confirm code: ${userCode}`);
27
- p.log.info(`If the browser didn't open, visit: ${verificationUri}`);
28
- s.start("Waiting for confirmation...");
24
+ const result = await authenticateViaBrowser(message => {
25
+ s.message(message);
29
26
  });
27
+ let apiKey;
28
+ if (result.pendingAuthenticationToken) {
29
+ s.stop("Email verification required");
30
+ const code = await p.text({
31
+ message: "Check your email for a verification code and enter it here:",
32
+ validate(value) {
33
+ if (!value.trim())
34
+ return "Verification code cannot be empty";
35
+ },
36
+ });
37
+ if (p.isCancel(code)) {
38
+ p.cancel("Setup cancelled");
39
+ process.exit(0);
40
+ }
41
+ s.start("Verifying...");
42
+ apiKey = await verifyEmailAndExchange(result.pendingAuthenticationToken, code, result.githubToken);
43
+ }
44
+ else {
45
+ apiKey = await exchangeForApiKey({
46
+ refreshToken: result.refreshToken,
47
+ ...(result.githubToken && {
48
+ githubToken: result.githubToken,
49
+ }),
50
+ });
51
+ }
30
52
  saveConfig({ apiKey });
31
53
  s.stop("Authenticated with Flamecast");
32
54
  }
@@ -187,7 +209,18 @@ export async function handleCastAdd() {
187
209
  });
188
210
  const workflowUrl = `https://github.com/${githubRepo}/actions/runs/${task.id}`;
189
211
  s6.stop(`Task dispatched \u2192 ${workflowUrl}`);
190
- p.outro(`\u{1F525} Run \`flame status\` to check in.`);
212
+ let taskGetCommand = `flame task get ${task.id}`;
213
+ try {
214
+ const { defaultWorkspaceId } = await client.workspaces.default.get();
215
+ if (defaultWorkspaceId !== workspaceId) {
216
+ taskGetCommand += ` --workspace ${workspaceId}`;
217
+ }
218
+ }
219
+ catch {
220
+ // Fall back to explicit workspace if we can't resolve the default.
221
+ taskGetCommand += ` --workspace ${workspaceId}`;
222
+ }
223
+ p.outro(`\u{1F525} Run \`${taskGetCommand}\` to check in.`);
191
224
  }
192
225
  catch (err) {
193
226
  s6.stop("Failed to dispatch task");
@@ -1,15 +1,36 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { saveConfig } from "../lib/config.js";
3
- import { authenticateViaBrowser } from "../lib/auth.js";
3
+ import { authenticateViaBrowser, verifyEmailAndExchange, exchangeForApiKey, } from "../lib/auth.js";
4
4
  export async function handleLogin() {
5
5
  const s = p.spinner();
6
- s.start("Waiting for browser login...");
6
+ s.start("Starting authentication...");
7
7
  try {
8
- const apiKey = await authenticateViaBrowser((userCode, verificationUri) => {
9
- s.stop(`Confirm code: ${userCode}`);
10
- p.log.info(`If the browser didn't open, visit: ${verificationUri}`);
11
- s.start("Waiting for confirmation...");
8
+ const result = await authenticateViaBrowser(message => {
9
+ s.message(message);
12
10
  });
11
+ let apiKey;
12
+ if (result.pendingAuthenticationToken) {
13
+ s.stop("Email verification required");
14
+ const code = await p.text({
15
+ message: "Check your email for a verification code and enter it here:",
16
+ validate(value) {
17
+ if (!value.trim())
18
+ return "Verification code cannot be empty";
19
+ },
20
+ });
21
+ if (p.isCancel(code)) {
22
+ p.cancel("Login cancelled");
23
+ process.exit(0);
24
+ }
25
+ s.start("Verifying...");
26
+ apiKey = await verifyEmailAndExchange(result.pendingAuthenticationToken, code, result.githubToken);
27
+ }
28
+ else {
29
+ apiKey = await exchangeForApiKey({
30
+ refreshToken: result.refreshToken,
31
+ ...(result.githubToken && { githubToken: result.githubToken }),
32
+ });
33
+ }
13
34
  saveConfig({ apiKey });
14
35
  s.stop("Logged in");
15
36
  }
@@ -6,7 +6,7 @@ export async function handleTaskCreate(promptParts, workspaceRef) {
6
6
  console.error("Usage: flame task create <prompt> [-w workspace]");
7
7
  process.exit(1);
8
8
  }
9
- const ws = await resolveWorkspace(client, workspaceRef);
9
+ const ws = await resolveWorkspace(workspaceRef);
10
10
  const task = await client.workspaces.tasks.create(ws.id, { prompt });
11
11
  console.log(`Task dispatched to ${ws.name}.`);
12
12
  if (task.workflowRunId) {
@@ -1,7 +1,7 @@
1
1
  import { client } from "../../lib/auth.js";
2
2
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
3
  export async function handleTaskGet(taskId, workspaceRef) {
4
- const ws = await resolveWorkspace(client, workspaceRef);
4
+ const ws = await resolveWorkspace(workspaceRef);
5
5
  const { task, outputs } = await client.workspaces.tasks.get(taskId, {
6
6
  workspaceId: ws.id,
7
7
  });
@@ -1,7 +1,7 @@
1
1
  import { client } from "../../lib/auth.js";
2
2
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
3
  export async function handleTaskList(workspaceRef) {
4
- const ws = await resolveWorkspace(client, workspaceRef);
4
+ const ws = await resolveWorkspace(workspaceRef);
5
5
  const page = await client.workspaces.tasks.list(ws.id);
6
6
  const tasks = page.tasks ?? [];
7
7
  if (tasks.length === 0) {
@@ -2,7 +2,7 @@ import * as p from "@clack/prompts";
2
2
  import { client } from "../../lib/auth.js";
3
3
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
4
4
  export async function handleWorkspaceDelete(ref) {
5
- const ws = await resolveWorkspace(client, ref);
5
+ const ws = await resolveWorkspace(ref);
6
6
  const confirmed = await p.confirm({
7
7
  message: `Delete workspace "${ws.name}" (${ws.githubRepo})? This will also delete the GitHub repo.`,
8
8
  initialValue: false,
@@ -1,7 +1,6 @@
1
- import { client } from "../../lib/auth.js";
2
1
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
2
  export async function handleWorkspaceGet(ref) {
4
- const ws = await resolveWorkspace(client, ref);
3
+ const ws = await resolveWorkspace(ref);
5
4
  console.log(` Name: ${ws.name}`);
6
5
  console.log(` ID: ${ws.id}`);
7
6
  console.log(` Status: ${ws.status}`);
@@ -1,7 +1,7 @@
1
1
  import { client } from "../../lib/auth.js";
2
2
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
3
  export async function handleWorkspaceSetDefault(ref) {
4
- const ws = await resolveWorkspace(client, ref);
4
+ const ws = await resolveWorkspace(ref);
5
5
  await client.workspaces.default.set({ workspaceId: ws.id });
6
6
  console.log(`Default workspace set to "${ws.name}".`);
7
7
  }
@@ -1,7 +1,7 @@
1
1
  import { client } from "../../lib/auth.js";
2
2
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
3
  export async function handleWorkspaceSetSecrets(ref, secretArgs) {
4
- const ws = await resolveWorkspace(client, ref);
4
+ const ws = await resolveWorkspace(ref);
5
5
  const secrets = {};
6
6
  for (const arg of secretArgs) {
7
7
  const eqIdx = arg.indexOf("=");
@@ -1,7 +1,7 @@
1
1
  import { client } from "../../lib/auth.js";
2
2
  import { resolveWorkspace } from "../../lib/resolve-workspace.js";
3
3
  export async function handleWorkspaceSyncWorkflows(ref) {
4
- const ws = await resolveWorkspace(client, ref);
4
+ const ws = await resolveWorkspace(ref);
5
5
  const result = await client.workspaces.syncWorkflows.sync(ws.id);
6
6
  console.log(`Synced workflows for "${ws.name}".`);
7
7
  console.log(` PR: ${result.pullRequestUrl}`);
package/dist/lib/auth.js CHANGED
@@ -1,95 +1,100 @@
1
+ import { createWorkOS } from "@workos-inc/node";
1
2
  import { openBrowser } from "./open-browser.js";
2
3
  import { loadConfig, saveConfig } from "./config.js";
4
+ import { startCallbackServer } from "./callback-server.js";
3
5
  import Flamecast from "@flamecast/api";
4
6
  import * as dotenv from "dotenv";
5
7
  dotenv.config();
6
8
  const WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? "client_01KD3FTW7R2QD0NW6QB7QP6Q0B";
7
9
  export const FLAMECAST_BASE_URL = process.env.FLAMECAST_BASE_URL || "https://api.flamecast.dev";
8
- function sleep(ms) {
9
- return new Promise(resolve => setTimeout(resolve, ms));
10
- }
10
+ const workos = createWorkOS({ clientId: WORKOS_CLIENT_ID });
11
11
  /**
12
- * Authenticates via WorkOS Device Authorization flow (OAuth 2.0 RFC 8628).
12
+ * Authenticates via WorkOS Authorization Code flow with PKCE.
13
13
  *
14
- * 1. Request device code from WorkOS
15
- * 2. Open browser to verification URL
16
- * 3. Poll for tokens
17
- * 4. Exchange WorkOS refresh token for a Flamecast API key
18
- * (optionally sends the user's local GitHub token for server-side storage)
14
+ * 1. Start a local callback server
15
+ * 2. Generate authorization URL with PKCE
16
+ * 3. Open browser to WorkOS/GitHub auth
17
+ * 4. Receive callback with authorization code
18
+ * 5. Exchange code for tokens (includes GitHub OAuth token)
19
+ * 6. If email verification is required, return the pending token
20
+ * 7. Otherwise exchange WorkOS refresh token for a Flamecast API key
19
21
  */
20
- export async function authenticateViaBrowser(onDeviceCode) {
21
- // Step 1: Request device authorization
22
- const deviceRes = await fetch("https://api.workos.com/user_management/authorize/device", {
23
- method: "POST",
24
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
25
- body: new URLSearchParams({ client_id: WORKOS_CLIENT_ID }),
26
- });
27
- if (!deviceRes.ok) {
28
- const text = await deviceRes.text();
29
- throw new Error(`Failed to start device authorization: ${text}`);
22
+ export async function authenticateViaBrowser(onStatus) {
23
+ const { redirectUri, waitForCallback, close } = await startCallbackServer();
24
+ try {
25
+ // Generate PKCE authorization URL
26
+ const { url, state, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({
27
+ provider: "GitHubOAuth",
28
+ redirectUri,
29
+ providerScopes: ["repo", "read:user"],
30
+ });
31
+ onStatus?.("Opening browser for authentication...");
32
+ openBrowser(url);
33
+ // Wait for the OAuth callback
34
+ onStatus?.("Waiting for browser authentication...");
35
+ const callback = await waitForCallback();
36
+ if (callback.state !== state) {
37
+ throw new Error("OAuth state mismatch");
38
+ }
39
+ // Exchange code for tokens — this returns oauthTokens (GitHub token)
40
+ try {
41
+ const authResult = await workos.userManagement.authenticateWithCodeAndVerifier({
42
+ code: callback.code,
43
+ codeVerifier,
44
+ });
45
+ return {
46
+ refreshToken: authResult.refreshToken,
47
+ githubToken: authResult.oauthTokens?.accessToken,
48
+ };
49
+ }
50
+ catch (err) {
51
+ // WorkOS requires email verification — extract the pending token
52
+ const rawData = err?.rawData;
53
+ const pendingToken = rawData?.pending_authentication_token;
54
+ if (!pendingToken)
55
+ throw err;
56
+ return { refreshToken: "", pendingAuthenticationToken: pendingToken };
57
+ }
30
58
  }
31
- const device = (await deviceRes.json());
32
- // Step 2: Notify caller of the user code, then open browser
33
- onDeviceCode?.(device.user_code, device.verification_uri);
34
- openBrowser(device.verification_uri_complete);
35
- // Step 3: Poll for tokens
36
- const tokens = await pollForTokens({
37
- deviceCode: device.device_code,
38
- expiresIn: device.expires_in,
39
- interval: device.interval,
40
- });
41
- // Step 4: Exchange WorkOS refresh token for Flamecast API key
42
- const exchangeBody = {
43
- refreshToken: tokens.refresh_token,
44
- };
45
- const exchangeRes = await fetch(`${FLAMECAST_BASE_URL}/auth/cli/exchange`, {
59
+ finally {
60
+ close();
61
+ }
62
+ }
63
+ export async function exchangeForApiKey(body) {
64
+ const res = await fetch(`${FLAMECAST_BASE_URL}/auth/cli/exchange`, {
46
65
  method: "POST",
47
66
  headers: { "Content-Type": "application/json" },
48
- body: JSON.stringify(exchangeBody),
67
+ body: JSON.stringify(body),
49
68
  });
50
- if (!exchangeRes.ok) {
51
- const text = await exchangeRes.text();
69
+ if (!res.ok) {
70
+ const text = await res.text();
52
71
  throw new Error(`Failed to exchange token: ${text}`);
53
72
  }
54
- const { apiKey } = (await exchangeRes.json());
73
+ const { apiKey } = (await res.json());
55
74
  return apiKey;
56
75
  }
57
- async function pollForTokens({ deviceCode, expiresIn = 300, interval = 5, }) {
58
- const deadline = Date.now() + expiresIn * 1000;
59
- let pollInterval = interval;
60
- while (Date.now() < deadline) {
61
- await sleep(pollInterval * 1000);
62
- const res = await fetch("https://api.workos.com/user_management/authenticate", {
63
- method: "POST",
64
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
65
- body: new URLSearchParams({
66
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
67
- device_code: deviceCode,
68
- client_id: WORKOS_CLIENT_ID,
69
- }),
70
- });
71
- const data = (await res.json());
72
- if (res.ok && data.access_token && data.refresh_token) {
73
- return data;
74
- }
75
- switch (data.error) {
76
- case "authorization_pending":
77
- break;
78
- case "slow_down":
79
- pollInterval += 1;
80
- break;
81
- case "access_denied":
82
- case "expired_token":
83
- throw new Error("Authorization was denied or expired");
84
- default:
85
- throw new Error(`Unexpected auth error: ${data.error}`);
86
- }
76
+ export async function verifyEmailAndExchange(pendingAuthenticationToken, verificationCode, githubToken) {
77
+ const body = {
78
+ pendingAuthenticationToken,
79
+ code: verificationCode,
80
+ };
81
+ if (githubToken)
82
+ body.githubToken = githubToken;
83
+ const res = await fetch(`${FLAMECAST_BASE_URL}/auth/cli/verify-email`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify(body),
87
+ });
88
+ if (!res.ok) {
89
+ const text = await res.text();
90
+ throw new Error(`Email verification failed: ${text}`);
87
91
  }
88
- throw new Error("Authentication timed out");
92
+ const { apiKey } = (await res.json());
93
+ return apiKey;
89
94
  }
90
95
  /**
91
96
  * Ensures the user is authenticated. Returns a valid API key.
92
- * If no valid key exists, runs the device auth flow.
97
+ * If no valid key exists, runs the auth flow.
93
98
  */
94
99
  export async function ensureAuthenticated() {
95
100
  const config = loadConfig();
@@ -108,10 +113,29 @@ export async function ensureAuthenticated() {
108
113
  }
109
114
  try {
110
115
  console.log("Opening browser to authenticate...");
111
- const apiKey = await authenticateViaBrowser((userCode, verificationUri) => {
112
- console.log(`\nConfirm code: ${userCode}`);
113
- console.log(`If the browser didn't open, visit: ${verificationUri}\n`);
114
- });
116
+ const result = await authenticateViaBrowser();
117
+ let apiKey;
118
+ if (result.pendingAuthenticationToken) {
119
+ // Email verification required — prompt in terminal
120
+ const readline = await import("node:readline");
121
+ const rl = readline.createInterface({
122
+ input: process.stdin,
123
+ output: process.stdout,
124
+ });
125
+ const code = await new Promise(resolve => {
126
+ rl.question("Check your email for a verification code and enter it here: ", answer => {
127
+ rl.close();
128
+ resolve(answer.trim());
129
+ });
130
+ });
131
+ apiKey = await verifyEmailAndExchange(result.pendingAuthenticationToken, code, result.githubToken);
132
+ }
133
+ else {
134
+ apiKey = await exchangeForApiKey({
135
+ refreshToken: result.refreshToken,
136
+ ...(result.githubToken && { githubToken: result.githubToken }),
137
+ });
138
+ }
115
139
  saveConfig({ apiKey });
116
140
  return { apiKey };
117
141
  }
@@ -0,0 +1,72 @@
1
+ import { createServer } from "node:http";
2
+ import { URL } from "node:url";
3
+ const PORTS = [19284, 19285, 19286];
4
+ const TIMEOUT_MS = 120_000;
5
+ const SUCCESS_HTML = `<!DOCTYPE html>
6
+ <html><body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
7
+ <div style="text-align:center"><h2>Authentication successful</h2><p>You can close this tab and return to the terminal.</p></div>
8
+ </body></html>`;
9
+ function tryListen(server, port) {
10
+ return new Promise((resolve, reject) => {
11
+ server.once("error", reject);
12
+ server.listen(port, "127.0.0.1", () => {
13
+ server.removeListener("error", reject);
14
+ resolve();
15
+ });
16
+ });
17
+ }
18
+ export async function startCallbackServer() {
19
+ let resolve;
20
+ let reject;
21
+ const callbackPromise = new Promise((res, rej) => {
22
+ resolve = res;
23
+ reject = rej;
24
+ });
25
+ const server = createServer((req, res) => {
26
+ if (!req.url?.startsWith("/callback")) {
27
+ res.writeHead(404);
28
+ res.end();
29
+ return;
30
+ }
31
+ const url = new URL(req.url, `http://127.0.0.1`);
32
+ const code = url.searchParams.get("code");
33
+ const state = url.searchParams.get("state");
34
+ const error = url.searchParams.get("error");
35
+ res.writeHead(200, { "Content-Type": "text/html" });
36
+ res.end(SUCCESS_HTML);
37
+ if (error) {
38
+ reject(new Error(`Authentication failed: ${error}`));
39
+ }
40
+ else if (code && state) {
41
+ resolve({ code, state });
42
+ }
43
+ else {
44
+ reject(new Error("Missing code or state in callback"));
45
+ }
46
+ });
47
+ let boundPort;
48
+ for (const port of PORTS) {
49
+ try {
50
+ await tryListen(server, port);
51
+ boundPort = port;
52
+ break;
53
+ }
54
+ catch { }
55
+ }
56
+ if (!boundPort) {
57
+ throw new Error(`Could not start callback server on any port (${PORTS.join(", ")})`);
58
+ }
59
+ const timeout = setTimeout(() => {
60
+ reject(new Error("Authentication timed out"));
61
+ server.close();
62
+ }, TIMEOUT_MS);
63
+ return {
64
+ port: boundPort,
65
+ redirectUri: `http://localhost:${boundPort}/callback`,
66
+ waitForCallback: () => callbackPromise,
67
+ close: () => {
68
+ clearTimeout(timeout);
69
+ server.close();
70
+ },
71
+ };
72
+ }
@@ -1,9 +1,10 @@
1
+ import { client } from "./auth.js";
1
2
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
3
  /**
3
4
  * Resolve a workspace from a name, UUID, or "default".
4
5
  * If `ref` is omitted/undefined, resolves the user's default workspace.
5
6
  */
6
- export async function resolveWorkspace(client, ref) {
7
+ export async function resolveWorkspace(ref) {
7
8
  // No ref provided → use default
8
9
  if (!ref) {
9
10
  const { defaultWorkspaceId } = await client.workspaces.default.get();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flamecast",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "flame": "./dist/cli.js"
@@ -16,13 +16,15 @@
16
16
  "check-types": "tsc --noEmit",
17
17
  "cli": "tsx src/cli.ts",
18
18
  "cli:local": "FLAMECAST_BASE_URL=http://localhost:6970 tsx src/cli.ts",
19
- "compile": "bun run scripts/compile.ts",
20
- "compile:local": "bun run scripts/compile.ts local"
19
+ "secrets:pull": "infisical export --path=/packages/cli --env=dev --format=dotenv > .env",
20
+ "secrets:push": "infisical secrets set --file=.env --path=/packages/cli --env=dev"
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^0.9",
24
- "dotenv": "^17.3.1",
25
- "@flamecast/api": "https://pkg.stainless.com/s/flamecast-typescript/a6defd2f595fa445dd2fd116076a7bbffd3f65b9/dist.tar.gz"
24
+ "@flamecast/api": "https://pkg.stainless.com/s/flamecast-typescript/8bcb3971cf86ab592fcb8d2eb121e4f70abff474/dist.tar.gz",
25
+ "@workos-inc/node": "^8",
26
+ "commander": "^14.0.3",
27
+ "dotenv": "^17.3.1"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@flamecast/typescript-config": "workspace:*",
package/dist/lib/api.js DELETED
@@ -1,11 +0,0 @@
1
- import Flamecast from "@flamecast/api";
2
- const DEFAULT_API_URL = "https://api.flamecast.dev";
3
- export function getBaseUrl(apiUrl) {
4
- return apiUrl || process.env.FLAMECAST_BASE_URL || DEFAULT_API_URL;
5
- }
6
- export function createApiClient(apiKey, apiUrl) {
7
- return new Flamecast({
8
- apiKey,
9
- baseURL: getBaseUrl(apiUrl),
10
- });
11
- }