@asteroidai/cli 0.0.0 → 1.8.197

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 CHANGED
@@ -1 +1,51 @@
1
- {"name":"@asteroidai/cli","version":"0.0.0","description":"The Asteroid CLI. First release coming soon."}
1
+ {
2
+ "name": "@asteroidai/cli",
3
+ "version": "1.8.197",
4
+ "description": "The asteroid command-line interface for the Asteroid platform.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "asteroid": "./dist/asteroid.js"
9
+ },
10
+ "exports": {
11
+ "./core": {
12
+ "types": "./src/core.ts",
13
+ "development": "./src/core.ts",
14
+ "default": "./dist/core.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "dependencies": {
25
+ "@bomb.sh/tab": "^0.0.22",
26
+ "@clack/prompts": "^1.8.1",
27
+ "@napi-rs/keyring": "^2.1.0",
28
+ "cli-table3": "^0.6.5",
29
+ "commander": "^15.0.0",
30
+ "env-paths": "^4.0.0",
31
+ "proper-lockfile": "^4.1.2"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.20.1",
35
+ "@types/proper-lockfile": "^4.1.4",
36
+ "esbuild": "^0.28.2",
37
+ "prettier": "3.9.6",
38
+ "tsx": "^4.23.12",
39
+ "typescript": "5.9.3",
40
+ "vitest": "^4.1.11",
41
+ "yaml": "^2.9.0"
42
+ },
43
+ "scripts": {
44
+ "generate": "tsx scripts/generate.ts && pnpm exec prettier --write src/generated/commands.ts",
45
+ "build": "tsc --noEmit && node scripts/bundle.mjs",
46
+ "build:core": "node scripts/bundle.mjs",
47
+ "check-types": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "smoke": "node scripts/smoke.mjs"
50
+ }
51
+ }
package/src/account.ts ADDED
@@ -0,0 +1,224 @@
1
+ import { UsageError } from "./args";
2
+ import {
3
+ API_KEY_OVERRIDES,
4
+ AuthError,
5
+ orgFromToken,
6
+ rememberOrg,
7
+ resolveAuth,
8
+ UNSET_API_KEY,
9
+ } from "./auth/credentials";
10
+ import { CredentialStore } from "./auth/store";
11
+ import {
12
+ authorizeDevice,
13
+ pollDevice,
14
+ tokenClaims,
15
+ type WorkosOrg,
16
+ } from "./auth/workos";
17
+ import {
18
+ findOrg,
19
+ loadConfig,
20
+ mergeOrgs,
21
+ orgLabel,
22
+ requestedOrg,
23
+ saveConfig,
24
+ } from "./config";
25
+ import { send } from "./http";
26
+ import { clean, render } from "./output";
27
+ import type { Deps } from "./run";
28
+
29
+ async function chooseOrg(deps: Deps, orgs: WorkosOrg[]): Promise<WorkosOrg> {
30
+ if (orgs.length === 0)
31
+ throw new AuthError("your account belongs to no organization");
32
+ if (orgs.length === 1 || !deps.ui) return orgs[0];
33
+ const id = await deps.ui.select(
34
+ "Choose an organization",
35
+ orgs.map((org) => ({ value: org.id, label: clean(org.name) })),
36
+ );
37
+ const chosen = orgs.find((org) => org.id === id);
38
+ if (!chosen) throw new AuthError("sign-in cancelled");
39
+ return chosen;
40
+ }
41
+
42
+ export async function login(deps: Deps): Promise<number> {
43
+ if (deps.env.apiKey)
44
+ deps.stderr(
45
+ `Warning: ${API_KEY_OVERRIDES} Every command will use the key, not this sign-in. ${UNSET_API_KEY}\n`,
46
+ );
47
+ const client = {
48
+ clientId: deps.env.workosClientId,
49
+ apiUrl: deps.env.workosApiUrl,
50
+ fetch: deps.fetch,
51
+ };
52
+ const device = await authorizeDevice(client);
53
+ const url = device.verification_uri_complete ?? device.verification_uri;
54
+ deps.stderr(
55
+ `Open ${clean(url)}\nand confirm the code ${clean(device.user_code)}\n`,
56
+ );
57
+ deps.openUrl?.(url);
58
+
59
+ let offered: WorkosOrg[] = [];
60
+ const polling = pollDevice(client, device, (orgs) => {
61
+ offered = orgs;
62
+ return chooseOrg(deps, orgs);
63
+ });
64
+ const session = deps.ui
65
+ ? await deps.ui.wait(
66
+ "Waiting for you to confirm in the browser",
67
+ "Confirmed",
68
+ polling,
69
+ )
70
+ : await polling;
71
+
72
+ const store = new CredentialStore(deps.env);
73
+ const where = await store.writeLogin({
74
+ refreshToken: session.refreshToken,
75
+ email: session.email,
76
+ });
77
+ const org = orgFromToken(session.accessToken);
78
+ const expiresAt = (tokenClaims(session.accessToken).exp ?? 0) * 1000;
79
+ if (org && expiresAt)
80
+ store.cacheToken(org.workosId, {
81
+ accessToken: session.accessToken,
82
+ expiresAt,
83
+ });
84
+
85
+ const config = loadConfig(deps.env);
86
+ config.orgs = mergeOrgs(
87
+ config.orgs,
88
+ offered.map((o) => ({ workosId: o.id, name: o.name })),
89
+ );
90
+ config.org = undefined;
91
+ saveConfig(deps.env, config);
92
+ rememberOrg(deps.env, config, org);
93
+
94
+ deps.stderr(
95
+ `Signed in as ${clean(session.email ?? "you")}${config.org ? ` in ${orgLabel(config.org)}` : ""}. ` +
96
+ `Token saved to the ${where === "keychain" ? "OS keychain" : "config directory"}.\n`,
97
+ );
98
+ return 0;
99
+ }
100
+
101
+ export async function logout(deps: Deps): Promise<number> {
102
+ const cleared = await new CredentialStore(deps.env).clear();
103
+ saveConfig(deps.env, { ...loadConfig(deps.env), org: undefined });
104
+ if (!cleared) {
105
+ deps.stderr(
106
+ "Could not remove the sign-in from the OS keychain. Delete the asteroid-cli entry by hand.\n",
107
+ );
108
+ return 1;
109
+ }
110
+ deps.stderr("Signed out.\n");
111
+ return 0;
112
+ }
113
+
114
+ export async function orgs(
115
+ deps: Deps,
116
+ flagOrg: string | undefined,
117
+ json: boolean,
118
+ ): Promise<number> {
119
+ if (deps.env.apiKey)
120
+ throw new UsageError(
121
+ `${API_KEY_OVERRIDES} The key acts in one organization; \`asteroid whoami\` shows it. ${UNSET_API_KEY}`,
122
+ );
123
+ const config = loadConfig(deps.env);
124
+ const auth = await resolveAuth(
125
+ deps.env,
126
+ config,
127
+ requestedOrg(flagOrg, deps.env, deps.cwd, config),
128
+ deps.fetch,
129
+ );
130
+ const list = loadConfig(deps.env).orgs ?? [];
131
+ const active = auth.org?.workosId;
132
+ if (json) {
133
+ deps.stdout(
134
+ `${JSON.stringify(
135
+ list.map((org) => ({ ...org, active: org.workosId === active })),
136
+ null,
137
+ 2,
138
+ )}\n`,
139
+ );
140
+ return 0;
141
+ }
142
+ for (const org of list)
143
+ deps.stdout(`${org.workosId === active ? "*" : " "} ${orgLabel(org)}\n`);
144
+ return 0;
145
+ }
146
+
147
+ export async function use(
148
+ deps: Deps,
149
+ ref: string | undefined,
150
+ ): Promise<number> {
151
+ if (deps.env.apiKey)
152
+ throw new UsageError(
153
+ `${API_KEY_OVERRIDES} \`use\` only switches the login's organization. ${UNSET_API_KEY}`,
154
+ );
155
+ const config = loadConfig(deps.env);
156
+ if (!ref && deps.ui && (config.orgs?.length ?? 0) > 0) {
157
+ ref = await deps.ui.select(
158
+ "Use which organization?",
159
+ (config.orgs ?? []).map((o) => ({
160
+ value: o.workosId,
161
+ label: orgLabel(o),
162
+ })),
163
+ );
164
+ if (!ref) return 1;
165
+ }
166
+ if (!ref) throw new UsageError("usage: asteroid use <org>");
167
+ const org = findOrg(ref, config.orgs ?? []);
168
+ if (!org)
169
+ throw new UsageError(
170
+ `unknown organization ${ref}. Run \`asteroid orgs\` to list yours.`,
171
+ );
172
+ const auth = await resolveAuth(deps.env, config, org.workosId, deps.fetch);
173
+ const updated = loadConfig(deps.env);
174
+ updated.org =
175
+ updated.orgs?.find((o) => o.workosId === org.workosId) ?? auth.org ?? org;
176
+ saveConfig(deps.env, updated);
177
+ deps.stderr(`Now using ${orgLabel(updated.org)}.\n`);
178
+ return 0;
179
+ }
180
+
181
+ export async function whoami(
182
+ deps: Deps,
183
+ flagOrg: string | undefined,
184
+ json: boolean,
185
+ ): Promise<number> {
186
+ const config = loadConfig(deps.env);
187
+ const auth = await resolveAuth(
188
+ deps.env,
189
+ config,
190
+ requestedOrg(flagOrg, deps.env, deps.cwd, config),
191
+ deps.fetch,
192
+ );
193
+ const response = await send(
194
+ deps.env.apiUrl,
195
+ auth,
196
+ { method: "GET", path: "/users/v2/whoami", query: new URLSearchParams() },
197
+ false,
198
+ deps.fetch,
199
+ );
200
+ const body = (response.json ?? {}) as Record<string, unknown>;
201
+ const minutes = auth.expiresAt
202
+ ? Math.max(0, Math.round((auth.expiresAt - Date.now()) / 60_000))
203
+ : undefined;
204
+ if (json) {
205
+ deps.stdout(
206
+ `${JSON.stringify({ ...body, organizationName: auth.org?.name, tokenExpiresInMinutes: minutes }, null, 2)}\n`,
207
+ );
208
+ return 0;
209
+ }
210
+ const person = body.person as { email?: string } | undefined;
211
+ const orgId = body.organizationId as string | undefined;
212
+ const organization = auth.org ? orgLabel(auth.org) : (orgId ?? "none");
213
+ deps.stdout(
214
+ `${render({
215
+ as: person?.email ?? body.id,
216
+ kind: body.kind,
217
+ credential: body.credential,
218
+ source: auth.kind === "api-key" ? "ASTEROID_API_KEY" : "asteroid login",
219
+ organization,
220
+ ...(minutes === undefined ? {} : { tokenExpiresIn: `${minutes}m` }),
221
+ })}\n`,
222
+ );
223
+ return 0;
224
+ }
package/src/args.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { CliCommand } from "./types";
2
+
3
+ export class UsageError extends Error {}
4
+
5
+ export interface Parsed {
6
+ positionals: string[];
7
+ flags: Map<string, string[]>;
8
+ json: boolean;
9
+ org?: string;
10
+ body?: string;
11
+ output?: string;
12
+ }
13
+
14
+ export function leadingWords(argv: string[]): string[] {
15
+ const words: string[] = [];
16
+ for (const token of argv) {
17
+ if (token.startsWith("-")) break;
18
+ words.push(token);
19
+ }
20
+ return words;
21
+ }
22
+
23
+ export function matchCommand(
24
+ words: string[],
25
+ commands: CliCommand[],
26
+ ): { command?: CliCommand; prefix: string[] } {
27
+ for (let length = Math.min(words.length, 4); length > 0; length--) {
28
+ const candidate = words.slice(0, length).join(" ");
29
+ const command = commands.find((c) => c.command === candidate);
30
+ if (command) return { command, prefix: words.slice(0, length) };
31
+ }
32
+ for (let length = Math.min(words.length, 3); length > 0; length--) {
33
+ const prefix = words.slice(0, length);
34
+ if (commands.some((c) => c.command.startsWith(`${prefix.join(" ")} `))) {
35
+ return { prefix };
36
+ }
37
+ }
38
+ return { prefix: [] };
39
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ type Config,
3
+ type Env,
4
+ findOrg,
5
+ mergeOrgs,
6
+ type Org,
7
+ orgLabel,
8
+ saveConfig,
9
+ } from "../config";
10
+ import { CredentialStore } from "./store";
11
+ import { refresh, tokenClaims, WorkosError } from "./workos";
12
+
13
+ export class AuthError extends Error {}
14
+
15
+ export const API_KEY_OVERRIDES =
16
+ "ASTEROID_API_KEY is set, so the CLI uses that key and ignores `asteroid login`.";
17
+ export const UNSET_API_KEY =
18
+ "Run `unset ASTEROID_API_KEY` to use your login instead.";
19
+
20
+ export interface Auth {
21
+ kind: "api-key" | "bearer";
22
+ headers: Record<string, string>;
23
+ org?: Org;
24
+ expiresAt?: number;
25
+ }
26
+
27
+ export function orgFromToken(token: string): Org | undefined {
28
+ const claims = tokenClaims(token);
29
+ if (!claims.org_id) return undefined;
30
+ return {
31
+ workosId: claims.org_id,
32
+ id: claims.org?.id,
33
+ name: claims.org?.name,
34
+ };
35
+ }
36
+
37
+ export function rememberOrg(env: Env, config: Config, org: Org | undefined) {
38
+ if (!org) return;
39
+ config.orgs = mergeOrgs(config.orgs, [org]);
40
+ if (!config.org || config.org.workosId === org.workosId) {
41
+ config.org = config.orgs.find((o) => o.workosId === org.workosId);
42
+ }
43
+ saveConfig(env, config);
44
+ }
45
+
46
+ function bearer(token: string, expiresAt: number): Auth {
47
+ return {
48
+ kind: "bearer",
49
+ headers: { Authorization: `Bearer ${token}` },
50
+ org: orgFromToken(token),
51
+ expiresAt,
52
+ };
53
+ }
54
+
55
+ const SSO_OR_MFA =
56
+ /^(sso_required|mfa_.*|organization_authentication_methods_required)$/;
57
+
58
+ function refreshError(error: WorkosError, org: Org): AuthError {
59
+ if (error.code === "invalid_grant") {
60
+ return new AuthError("your sign-in has expired. Run `asteroid login`.");
61
+ }
62
+ if (SSO_OR_MFA.test(error.code)) {
63
+ return new AuthError(
64
+ `${orgLabel(org)} requires SSO or MFA. Run \`asteroid login\` and sign in to it again.`,
65
+ );
66
+ }
67
+ if (error.code === "organization_membership_not_found") {
68
+ return new AuthError(
69
+ `you are not a member of ${orgLabel(org)}. Staff access to other organizations is not available yet.`,
70
+ );
71
+ }
72
+ return new AuthError(
73
+ `signing in to ${orgLabel(org)} failed: ${error.message}`,
74
+ );
75
+ }
76
+
77
+ export async function resolveAuth(
78
+ env: Env,
79
+ config: Config,
80
+ orgRef: string | undefined,
81
+ fetchImpl: typeof fetch = fetch,
82
+ ): Promise<Auth> {
83
+ if (env.apiKey)
84
+ return { kind: "api-key", headers: { "X-Api-Key": env.apiKey } };
85
+
86
+ const store = new CredentialStore(env);
87
+ if (!(await store.readLogin())) {
88
+ throw new AuthError(
89
+ "not signed in. Run `asteroid login`, or set ASTEROID_API_KEY.",
90
+ );
91
+ }
92
+
93
+ const org = orgRef ? findOrg(orgRef, config.orgs ?? []) : config.org;
94
+ if (orgRef && !org) {
95
+ throw new AuthError(
96
+ `unknown organization ${orgRef}. Run \`asteroid orgs\` to list yours.`,
97
+ );
98
+ }
99
+ if (!org) {
100
+ throw new AuthError(
101
+ "no organization. Run `asteroid use <org>`, or pass --org.",
102
+ );
103
+ }
104
+
105
+ const cached = store.cachedToken(org.workosId);
106
+ if (cached) return bearer(cached.accessToken, cached.expiresAt);
107
+
108
+ return store.withRefreshLock(async () => {
109
+ const fresh = store.cachedToken(org.workosId);
110
+ if (fresh) return bearer(fresh.accessToken, fresh.expiresAt);
111
+ const login = await store.readLogin();
112
+ if (!login) {
113
+ throw new AuthError("not signed in. Run `asteroid login`.");
114
+ }
115
+ try {
116
+ const session = await refresh(
117
+ {
118
+ clientId: env.workosClientId,
119
+ apiUrl: env.workosApiUrl,
120
+ fetch: fetchImpl,
121
+ },
122
+ login.refreshToken,
123
+ org.workosId,
124
+ );
125
+ await store.writeLogin({ ...login, refreshToken: session.refreshToken });
126
+ const expiresAt =
127
+ (tokenClaims(session.accessToken).exp ?? 0) * 1000 ||
128
+ Date.now() + 60_000;
129
+ const auth = bearer(session.accessToken, expiresAt);
130
+ store.cacheToken(auth.org?.workosId ?? org.workosId, {
131
+ accessToken: session.accessToken,
132
+ expiresAt,
133
+ });
134
+ rememberOrg(env, config, auth.org);
135
+ return auth;
136
+ } catch (error) {
137
+ if (error instanceof WorkosError) throw refreshError(error, org);
138
+ throw error;
139
+ }
140
+ });
141
+ }
@@ -0,0 +1,145 @@
1
+ import { mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import lockfile from "proper-lockfile";
4
+
5
+ import { type Env, readJson, writePrivateJson } from "../config";
6
+ import { WorkosError } from "./workos";
7
+
8
+ const SERVICE = "asteroid-cli";
9
+ const LOCK_STALE_MS = 30_000;
10
+ const ACCOUNT = "refresh-token";
11
+
12
+ export interface Login {
13
+ refreshToken: string;
14
+ email?: string;
15
+ }
16
+
17
+ export interface CachedToken {
18
+ accessToken: string;
19
+ expiresAt: number;
20
+ }
21
+
22
+ interface Keyring {
23
+ get(): string | null;
24
+ set(value: string): void;
25
+ delete(): void;
26
+ }
27
+
28
+ async function keyring(): Promise<Keyring | undefined> {
29
+ if (process.env.ASTEROID_NO_KEYCHAIN) return undefined;
30
+ try {
31
+ const { Entry } = await import("@napi-rs/keyring");
32
+ const entry = new Entry(SERVICE, ACCOUNT);
33
+ entry.getPassword();
34
+ return {
35
+ get: () => entry.getPassword() ?? null,
36
+ set: (value) => entry.setPassword(value),
37
+ delete: () => {
38
+ entry.deletePassword();
39
+ },
40
+ };
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function tryGet(ring: Keyring): { ok: boolean; value: string | null } {
47
+ try {
48
+ return { ok: true, value: ring.get() };
49
+ } catch {
50
+ return { ok: false, value: null };
51
+ }
52
+ }
53
+
54
+ function trySet(ring: Keyring, value: string | null): boolean {
55
+ try {
56
+ if (value === null) ring.delete();
57
+ else ring.set(value);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export class CredentialStore {
65
+ constructor(private readonly env: Env) {}
66
+
67
+ private get loginFile() {
68
+ return join(this.env.configDir, "credentials.json");
69
+ }
70
+
71
+ private get tokenFile() {
72
+ return join(this.env.configDir, "tokens.json");
73
+ }
74
+
75
+ async readLogin(): Promise<Login | undefined> {
76
+ const ring = await keyring();
77
+ const raw = ring ? tryGet(ring).value : null;
78
+ if (raw) return JSON.parse(raw) as Login;
79
+ return readJson<Login | undefined>(this.loginFile, undefined);
80
+ }
81
+
82
+ async writeLogin(login: Login): Promise<"keychain" | "file"> {
83
+ const ring = await keyring();
84
+ if (ring && trySet(ring, JSON.stringify(login))) {
85
+ rmSync(this.loginFile, { force: true });
86
+ return "keychain";
87
+ }
88
+ const stale = ring ? tryGet(ring) : { ok: true, value: null };
89
+ if (!stale.ok || (stale.value && !trySet(ring!, null))) {
90
+ throw new WorkosError(
91
+ "keychain",
92
+ "could not update the sign-in in the OS keychain. Unlock it, or run `asteroid logout` and sign in again.",
93
+ );
94
+ }
95
+ writePrivateJson(this.loginFile, login);
96
+ return "file";
97
+ }
98
+
99
+ async clear(): Promise<boolean> {
100
+ const ring = await keyring();
101
+ const current = ring ? tryGet(ring) : { ok: true, value: null };
102
+ const cleared = current.ok && (!current.value || trySet(ring!, null));
103
+ rmSync(this.loginFile, { force: true });
104
+ rmSync(this.tokenFile, { force: true });
105
+ return cleared;
106
+ }
107
+
108
+ async withRefreshLock<T>(work: () => Promise<T>): Promise<T> {
109
+ mkdirSync(this.env.configDir, { recursive: true, mode: 0o700 });
110
+ const lockfilePath = join(this.env.configDir, "refresh.lock");
111
+ let release: () => Promise<void>;
112
+ try {
113
+ release = await lockfile.lock(this.env.configDir, {
114
+ lockfilePath,
115
+ stale: LOCK_STALE_MS,
116
+ retries: { retries: 60, minTimeout: 100, maxTimeout: 500 },
117
+ });
118
+ } catch {
119
+ throw new WorkosError(
120
+ "locked",
121
+ `another asteroid command is refreshing the sign-in. Try again, or delete ${lockfilePath} if none is running.`,
122
+ );
123
+ }
124
+ try {
125
+ return await work();
126
+ } finally {
127
+ await release();
128
+ }
129
+ }
130
+
131
+ cachedToken(orgId: string): CachedToken | undefined {
132
+ const tokens = readJson<Record<string, CachedToken>>(this.tokenFile, {});
133
+ const token = tokens[orgId];
134
+ return token && token.expiresAt - 30_000 > Date.now() ? token : undefined;
135
+ }
136
+
137
+ cacheToken(orgId: string, token: CachedToken) {
138
+ const tokens = readJson<Record<string, CachedToken>>(this.tokenFile, {});
139
+ for (const [id, cached] of Object.entries(tokens)) {
140
+ if (cached.expiresAt <= Date.now()) delete tokens[id];
141
+ }
142
+ tokens[orgId] = token;
143
+ writePrivateJson(this.tokenFile, tokens);
144
+ }
145
+ }