@asteroidai/cli 1.9.27 → 1.9.30
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/asteroid.js +2 -2
- package/dist/core.js +1 -1
- package/package.json +3 -8
- package/src/account.ts +0 -501
- package/src/args.ts +0 -39
- package/src/auth/credentials.ts +0 -179
- package/src/auth/store.ts +0 -253
- package/src/auth/workos.ts +0 -171
- package/src/commands.ts +0 -13
- package/src/config.ts +0 -125
- package/src/core.ts +0 -41
- package/src/generated/commands.ts +0 -4772
- package/src/http.ts +0 -115
- package/src/main.ts +0 -49
- package/src/output.test.ts +0 -71
- package/src/output.ts +0 -176
- package/src/program.test.ts +0 -155
- package/src/program.ts +0 -293
- package/src/reference.ts +0 -195
- package/src/request.test.ts +0 -164
- package/src/request.ts +0 -252
- package/src/run.test.ts +0 -651
- package/src/run.ts +0 -235
- package/src/sync/activities.ts +0 -80
- package/src/sync/commands.ts +0 -63
- package/src/sync/execution-pull.ts +0 -273
- package/src/sync/node-files.ts +0 -47
- package/src/sync/paths.ts +0 -45
- package/src/sync/run.ts +0 -136
- package/src/sync/sync.test.ts +0 -502
- package/src/sync/types.ts +0 -58
- package/src/sync/workflow-pull.ts +0 -185
- package/src/sync/workflow-push.ts +0 -161
- package/src/types.ts +0 -46
- package/src/ui.ts +0 -35
package/src/run.test.ts
DELETED
|
@@ -1,651 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { CredentialStore } from "./auth/store";
|
|
7
|
-
import { WorkosError } from "./auth/workos";
|
|
8
|
-
import { ALL_COMMANDS } from "./commands";
|
|
9
|
-
import { readEnv, saveConfig } from "./config";
|
|
10
|
-
import { renderReference } from "./reference";
|
|
11
|
-
import { type Deps, run } from "./run";
|
|
12
|
-
|
|
13
|
-
interface Call {
|
|
14
|
-
url: string;
|
|
15
|
-
init: RequestInit;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function jwt(claims: Record<string, unknown>): string {
|
|
19
|
-
const part = (value: unknown) =>
|
|
20
|
-
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
21
|
-
return `${part({ alg: "none" })}.${part(claims)}.sig`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function harness(
|
|
25
|
-
env: Record<string, string>,
|
|
26
|
-
respond: (call: Call) => Response,
|
|
27
|
-
) {
|
|
28
|
-
const configDir = mkdtempSync(join(tmpdir(), "asteroid-cli-"));
|
|
29
|
-
const calls: Call[] = [];
|
|
30
|
-
const out: string[] = [];
|
|
31
|
-
const err: string[] = [];
|
|
32
|
-
const deps: Deps = {
|
|
33
|
-
env: readEnv({ ASTEROID_CONFIG_DIR: configDir, ...env }),
|
|
34
|
-
cwd: configDir,
|
|
35
|
-
fetch: (async (url: string, init: RequestInit) => {
|
|
36
|
-
const call = { url: String(url), init };
|
|
37
|
-
calls.push(call);
|
|
38
|
-
return respond(call);
|
|
39
|
-
}) as typeof fetch,
|
|
40
|
-
stdout: (text) => out.push(text),
|
|
41
|
-
stderr: (text) => err.push(text),
|
|
42
|
-
writeBytes: () => undefined,
|
|
43
|
-
};
|
|
44
|
-
return { deps, calls, out, err, configDir };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const json = (body: unknown, status = 200) =>
|
|
48
|
-
new Response(JSON.stringify(body), {
|
|
49
|
-
status,
|
|
50
|
-
headers: { "content-type": "application/json" },
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
process.env.ASTEROID_NO_KEYCHAIN = "1";
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("run", () => {
|
|
58
|
-
it("sends the API key and prints a table", async () => {
|
|
59
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
60
|
-
json({
|
|
61
|
-
items: [{ id: "w1", name: "Fill form" }],
|
|
62
|
-
page: 1,
|
|
63
|
-
pageSize: 50,
|
|
64
|
-
total: 1,
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
const code = await run(["workflow", "list"], h.deps);
|
|
68
|
-
expect(code).toBe(0);
|
|
69
|
-
expect(h.calls[0].url).toBe(
|
|
70
|
-
"https://odyssey.asteroid.ai/agents/v2/workflows",
|
|
71
|
-
);
|
|
72
|
-
expect(
|
|
73
|
-
(h.calls[0].init.headers as Record<string, string>)["X-Api-Key"],
|
|
74
|
-
).toBe("sk_test");
|
|
75
|
-
expect(h.out.join("")).toContain("Fill form");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("prints the problem detail and exits 1 on an API error", async () => {
|
|
79
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
80
|
-
json({ detail: "Execution not found", status: 404 }, 404),
|
|
81
|
-
);
|
|
82
|
-
const code = await run(["execution", "get", "e1"], h.deps);
|
|
83
|
-
expect(code).toBe(1);
|
|
84
|
-
expect(h.err.join("")).toContain("404: Execution not found");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("exits 2 on a usage error without calling the API", async () => {
|
|
88
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
|
|
89
|
-
expect(await run(["workflow", "get"], h.deps)).toBe(2);
|
|
90
|
-
expect(await run(["nope"], h.deps)).toBe(2);
|
|
91
|
-
expect(h.calls).toHaveLength(0);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("prints raw JSON with --json and names the org on a write", async () => {
|
|
95
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
96
|
-
json({ id: "w1" }),
|
|
97
|
-
);
|
|
98
|
-
await run(["workflow", "rename", "w1", "--name", "New", "--json"], h.deps);
|
|
99
|
-
expect(JSON.parse(h.out.join(""))).toEqual({ id: "w1" });
|
|
100
|
-
expect(h.err.join("")).toContain("Acting in the API key's organization.");
|
|
101
|
-
expect(JSON.parse(String(h.calls[0].init.body))).toEqual({ name: "New" });
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("mints an org token from the stored sign-in and caches it", async () => {
|
|
105
|
-
const access = jwt({
|
|
106
|
-
exp: Date.now() / 1000 + 300,
|
|
107
|
-
org_id: "org_A",
|
|
108
|
-
org: { id: "uuid-a", name: "Acme" },
|
|
109
|
-
});
|
|
110
|
-
const h = harness({}, (call) => {
|
|
111
|
-
if (call.url.includes("workos")) {
|
|
112
|
-
expect(
|
|
113
|
-
Object.fromEntries(call.init.body as URLSearchParams),
|
|
114
|
-
).toMatchObject({
|
|
115
|
-
grant_type: "refresh_token",
|
|
116
|
-
refresh_token: "r1",
|
|
117
|
-
organization_id: "org_A",
|
|
118
|
-
});
|
|
119
|
-
return json({
|
|
120
|
-
access_token: access,
|
|
121
|
-
refresh_token: "r2",
|
|
122
|
-
organization_id: "org_A",
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
return json({ items: [], page: 1, pageSize: 50, total: 0 });
|
|
126
|
-
});
|
|
127
|
-
h.deps.env.workosApiUrl = "https://workos.test";
|
|
128
|
-
const store = new CredentialStore(h.deps.env);
|
|
129
|
-
await store.writeLogin({ refreshToken: "r1" });
|
|
130
|
-
saveConfig(h.deps.env, { org: { workosId: "org_A" } });
|
|
131
|
-
|
|
132
|
-
const codes = await Promise.all([
|
|
133
|
-
run(["workflow", "list"], h.deps),
|
|
134
|
-
run(["workflow", "list"], h.deps),
|
|
135
|
-
]);
|
|
136
|
-
expect(codes).toEqual([0, 0]);
|
|
137
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(0);
|
|
138
|
-
|
|
139
|
-
const workosCalls = h.calls.filter((c) => c.url.includes("workos"));
|
|
140
|
-
expect(workosCalls).toHaveLength(1);
|
|
141
|
-
expect(
|
|
142
|
-
(h.calls.at(-1)!.init.headers as Record<string, string>).Authorization,
|
|
143
|
-
).toBe(`Bearer ${access}`);
|
|
144
|
-
expect((await store.readLogin())?.refreshToken).toBe("r2");
|
|
145
|
-
const config = JSON.parse(
|
|
146
|
-
readFileSync(join(h.configDir, "config.json"), "utf8"),
|
|
147
|
-
);
|
|
148
|
-
expect(config.org).toEqual({
|
|
149
|
-
workosId: "org_A",
|
|
150
|
-
id: "uuid-a",
|
|
151
|
-
name: "Acme",
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("asks for an org when none is set", async () => {
|
|
156
|
-
const h = harness({}, () => json({}));
|
|
157
|
-
await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
|
|
158
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(1);
|
|
159
|
-
expect(h.err.join("")).toContain("asteroid use <org>");
|
|
160
|
-
expect(h.calls).toHaveLength(0);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("sends an SSO or MFA org back to login", async () => {
|
|
164
|
-
const h = harness({}, () =>
|
|
165
|
-
json({ error: "sso_required", error_description: "SSO required" }, 400),
|
|
166
|
-
);
|
|
167
|
-
h.deps.env.workosApiUrl = "https://workos.test";
|
|
168
|
-
await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
|
|
169
|
-
saveConfig(h.deps.env, { org: { workosId: "org_A", name: "Acme" } });
|
|
170
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(1);
|
|
171
|
-
expect(h.err.join("")).toContain("requires SSO or MFA");
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("asks to sign in when there is no credential", async () => {
|
|
175
|
-
const h = harness({}, () => json({}));
|
|
176
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(1);
|
|
177
|
-
expect(h.err.join("")).toContain("asteroid login");
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
describe("guards", () => {
|
|
182
|
-
it("refuses --org with an API key", async () => {
|
|
183
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
|
|
184
|
-
expect(await run(["workflow", "list", "--org", "Acme"], h.deps)).toBe(2);
|
|
185
|
-
expect(h.calls).toHaveLength(0);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("tells a signed-in user that the API key overrides use", async () => {
|
|
189
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
|
|
190
|
-
await new CredentialStore(h.deps.env).writeLogin({ refreshToken: "r1" });
|
|
191
|
-
expect(await run(["use", "org_A"], h.deps)).toBe(2);
|
|
192
|
-
expect(h.err.join("")).toContain("ignores `asteroid login`");
|
|
193
|
-
expect(h.err.join("")).toContain("unset ASTEROID_API_KEY");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("names the API key when the API rejects it", async () => {
|
|
197
|
-
const h = harness({ ASTEROID_API_KEY: "sk_bad" }, () =>
|
|
198
|
-
json({ detail: "invalid API key" }, 401),
|
|
199
|
-
);
|
|
200
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(1);
|
|
201
|
-
expect(h.err.join("")).toContain(
|
|
202
|
-
"401: ASTEROID_API_KEY was rejected: invalid API key.",
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("rejects extra arguments and unknown flags on account commands", async () => {
|
|
207
|
-
const h = harness({}, () => json({}));
|
|
208
|
-
expect(await run(["use", "a", "b"], h.deps)).toBe(2);
|
|
209
|
-
expect(await run(["logout", "--typo", "x"], h.deps)).toBe(2);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("drops the API key when a download redirects to another host", async () => {
|
|
213
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
214
|
-
call.url.startsWith("https://odyssey")
|
|
215
|
-
? new Response(null, {
|
|
216
|
-
status: 303,
|
|
217
|
-
headers: { location: "https://storage.test/file" },
|
|
218
|
-
})
|
|
219
|
-
: new Response("bytes", { status: 200 }),
|
|
220
|
-
);
|
|
221
|
-
expect(
|
|
222
|
-
await run(["execution", "file", "download", "e1", "f1"], h.deps),
|
|
223
|
-
).toBe(0);
|
|
224
|
-
const download = h.calls[1];
|
|
225
|
-
expect(download.url).toBe("https://storage.test/file");
|
|
226
|
-
expect(
|
|
227
|
-
(download.init.headers as Record<string, string>)["X-Api-Key"],
|
|
228
|
-
).toBeUndefined();
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("does not blame the API key for an error after a cross-host redirect", async () => {
|
|
232
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
233
|
-
call.url.startsWith("https://odyssey")
|
|
234
|
-
? new Response(null, {
|
|
235
|
-
status: 303,
|
|
236
|
-
headers: { location: "https://storage.test/file" },
|
|
237
|
-
})
|
|
238
|
-
: new Response("expired", { status: 403 }),
|
|
239
|
-
);
|
|
240
|
-
expect(
|
|
241
|
-
await run(["execution", "file", "download", "e1", "f1"], h.deps),
|
|
242
|
-
).toBe(1);
|
|
243
|
-
expect(h.err.join("")).toContain("403: expired");
|
|
244
|
-
expect(h.err.join("")).not.toContain("ASTEROID_API_KEY");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it("keeps the method and body on a 307", async () => {
|
|
248
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
249
|
-
call.url.endsWith("/validate")
|
|
250
|
-
? new Response(null, {
|
|
251
|
-
status: 307,
|
|
252
|
-
headers: { location: "/agents/v2/schema/validate2" },
|
|
253
|
-
})
|
|
254
|
-
: json({ valid: true }),
|
|
255
|
-
);
|
|
256
|
-
await run(["schema", "validate", "--schema", "{}"], h.deps);
|
|
257
|
-
expect(h.calls[1].init.method).toBe("POST");
|
|
258
|
-
expect(h.calls[1].init.body).toBe('{"schema":{}}');
|
|
259
|
-
expect(
|
|
260
|
-
(h.calls[1].init.headers as Record<string, string>)["X-Api-Key"],
|
|
261
|
-
).toBe("sk_test");
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("renders nested object lists as a table", async () => {
|
|
265
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
266
|
-
json({ tools: [{ name: "go_to_url", capability: "web" }] }),
|
|
267
|
-
);
|
|
268
|
-
await run(["tool", "list"], h.deps);
|
|
269
|
-
expect(h.out.join("")).toContain("go_to_url");
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe("cell width", () => {
|
|
274
|
-
const long = "x".repeat(80);
|
|
275
|
-
const reply = () => json({ items: [{ id: "w-1", name: long }] });
|
|
276
|
-
|
|
277
|
-
it("keeps cells whole when stdout is not a terminal", async () => {
|
|
278
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, reply);
|
|
279
|
-
await run(["workflow", "list"], h.deps);
|
|
280
|
-
expect(h.out.join("")).toContain(long);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it("cuts long cells at a terminal", async () => {
|
|
284
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, reply);
|
|
285
|
-
h.deps.stdoutIsTTY = true;
|
|
286
|
-
await run(["workflow", "list"], h.deps);
|
|
287
|
-
const out = h.out.join("");
|
|
288
|
-
expect(out).toContain(`${"x".repeat(59)}…`);
|
|
289
|
-
expect(out).not.toContain(long);
|
|
290
|
-
});
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
describe("terminal safety", () => {
|
|
294
|
-
it("refuses to print a download to a terminal", async () => {
|
|
295
|
-
const h = harness(
|
|
296
|
-
{ ASTEROID_API_KEY: "sk_test" },
|
|
297
|
-
() => new Response("bytes"),
|
|
298
|
-
);
|
|
299
|
-
h.deps.stdoutIsTTY = true;
|
|
300
|
-
expect(await run(["execution", "recording", "e1"], h.deps)).toBe(2);
|
|
301
|
-
expect(h.err.join("")).toContain("--output");
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it("cleans control characters out of API errors", async () => {
|
|
305
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
306
|
-
json({ detail: "bad\u001b[2Jthing" }, 400),
|
|
307
|
-
);
|
|
308
|
-
await run(["execution", "get", "e1"], h.deps);
|
|
309
|
-
expect(h.err.join("")).toContain("badthing");
|
|
310
|
-
expect(h.err.join("")).not.toContain("\u001b");
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
it("fills a body organization from an API key's whoami", async () => {
|
|
314
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
315
|
-
call.url.endsWith("/users/v2/whoami")
|
|
316
|
-
? json({ organizationId: "org-from-key" })
|
|
317
|
-
: json({ id: "w1" }),
|
|
318
|
-
);
|
|
319
|
-
expect(await run(["workflow", "create", "--name", "W"], h.deps)).toBe(0);
|
|
320
|
-
expect(JSON.parse(String(h.calls.at(-1)!.init.body))).toMatchObject({
|
|
321
|
-
organizationId: "org-from-key",
|
|
322
|
-
name: "W",
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
describe("files --out", () => {
|
|
328
|
-
it("writes a pinned version into the folder it is given", async () => {
|
|
329
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
|
|
330
|
-
json({
|
|
331
|
-
rev: 1,
|
|
332
|
-
version: 2,
|
|
333
|
-
versionId: "v-2",
|
|
334
|
-
workflowName: "Fill form",
|
|
335
|
-
files: [
|
|
336
|
-
{
|
|
337
|
-
path: "settings.yaml",
|
|
338
|
-
kind: "structural",
|
|
339
|
-
size: 3,
|
|
340
|
-
content: "a: 1",
|
|
341
|
-
},
|
|
342
|
-
],
|
|
343
|
-
}),
|
|
344
|
-
);
|
|
345
|
-
const dir = join(h.configDir, "pulled");
|
|
346
|
-
|
|
347
|
-
const code = await run(
|
|
348
|
-
["workflow", "version", "pull", "w1", "v-2", "--out", dir],
|
|
349
|
-
h.deps,
|
|
350
|
-
);
|
|
351
|
-
|
|
352
|
-
expect(code).toBe(0);
|
|
353
|
-
expect(h.calls[0].url).toBe(
|
|
354
|
-
"https://odyssey.asteroid.ai/agents/v2/workflows/w1/versions/v-2/files?contents=all",
|
|
355
|
-
);
|
|
356
|
-
expect(readFileSync(join(dir, "settings.yaml"), "utf8")).toBe("a: 1");
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
describe("workflow pull and push", () => {
|
|
361
|
-
const tree = {
|
|
362
|
-
rev: 1,
|
|
363
|
-
version: 2,
|
|
364
|
-
versionId: "v-2",
|
|
365
|
-
workflowName: "Fill form",
|
|
366
|
-
files: [
|
|
367
|
-
{ path: "settings.yaml", kind: "structural", size: 4, content: "a: 1" },
|
|
368
|
-
],
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
it("pulls into ./<workflow-name>/head and pushes from there with no arguments", async () => {
|
|
372
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
373
|
-
call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
expect(await run(["workflow", "pull", "w1"], h.deps)).toBe(0);
|
|
377
|
-
const head = join(h.configDir, "Fill_form", "head");
|
|
378
|
-
expect(readFileSync(join(head, "settings.yaml"), "utf8")).toBe("a: 1");
|
|
379
|
-
|
|
380
|
-
writeFileSync(join(head, "settings.yaml"), "a: 2");
|
|
381
|
-
const code = await run(["workflow", "push"], { ...h.deps, cwd: head });
|
|
382
|
-
|
|
383
|
-
expect(code).toBe(0);
|
|
384
|
-
const patch = h.calls.find((call) => call.init.method === "PATCH")!;
|
|
385
|
-
expect(patch.url).toBe(
|
|
386
|
-
"https://odyssey.asteroid.ai/agents/v2/workflows/w1/head/files",
|
|
387
|
-
);
|
|
388
|
-
expect(JSON.parse(String(patch.init.body))).toMatchObject({ baseRev: 2 });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("pushes the folder --from names, taking the workflow from its manifest", async () => {
|
|
392
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
|
|
393
|
-
call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
|
|
394
|
-
);
|
|
395
|
-
await run(["workflow", "pull", "w9"], h.deps);
|
|
396
|
-
const head = join(h.configDir, "Fill_form", "head");
|
|
397
|
-
writeFileSync(join(head, "settings.yaml"), "a: 3");
|
|
398
|
-
|
|
399
|
-
const code = await run(["workflow", "push", "--from", head], h.deps);
|
|
400
|
-
|
|
401
|
-
expect(code).toBe(0);
|
|
402
|
-
const patch = h.calls.find((call) => call.init.method === "PATCH")!;
|
|
403
|
-
expect(patch.url).toBe(
|
|
404
|
-
"https://odyssey.asteroid.ai/agents/v2/workflows/w9/head/files",
|
|
405
|
-
);
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
describe("workflow pull with a filter", () => {
|
|
410
|
-
it("refuses a partial tree, which would delete the files it leaves out", async () => {
|
|
411
|
-
const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
|
|
412
|
-
|
|
413
|
-
const code = await run(
|
|
414
|
-
["workflow", "pull", "w1", "--paths", "a.md"],
|
|
415
|
-
h.deps,
|
|
416
|
-
);
|
|
417
|
-
|
|
418
|
-
expect(code).toBe(2);
|
|
419
|
-
expect(h.calls).toEqual([]);
|
|
420
|
-
expect(h.err.join("")).toContain("cannot take --paths");
|
|
421
|
-
});
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
describe("staff access", () => {
|
|
425
|
-
const own = { workosId: "org_OWN", id: "uuid-own", name: "Asteroid" };
|
|
426
|
-
const signIn = jwt({ exp: Date.now() / 1000 + 300, org_id: "org_OWN" });
|
|
427
|
-
const minted = {
|
|
428
|
-
token: "staff-token-1",
|
|
429
|
-
grantId: "grant-1",
|
|
430
|
-
organizationId: "uuid-acme",
|
|
431
|
-
organizationName: "Acme",
|
|
432
|
-
workosOrganizationId: "org_ACME",
|
|
433
|
-
reason: "SUP-412",
|
|
434
|
-
expiresAt: new Date(Date.now() + 8 * 3600_000).toISOString(),
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
async function signedIn(
|
|
438
|
-
respond: (call: Call) => Response,
|
|
439
|
-
email = "ed@asteroid.ai",
|
|
440
|
-
) {
|
|
441
|
-
const h = harness({}, respond);
|
|
442
|
-
h.deps.env.workosApiUrl = "https://workos.test";
|
|
443
|
-
const store = new CredentialStore(h.deps.env);
|
|
444
|
-
await store.writeLogin({ refreshToken: "r1", email });
|
|
445
|
-
store.cacheToken("org_OWN", {
|
|
446
|
-
accessToken: signIn,
|
|
447
|
-
expiresAt: Date.now() + 300_000,
|
|
448
|
-
});
|
|
449
|
-
saveConfig(h.deps.env, { org: own, orgs: [own] });
|
|
450
|
-
return { ...h, store };
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const auth = (call: Call) =>
|
|
454
|
-
(call.init.headers as Record<string, string>).Authorization;
|
|
455
|
-
const config = (dir: string) =>
|
|
456
|
-
JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
|
|
457
|
-
|
|
458
|
-
it("mints a token with the sign-in, then acts in the org with it", async () => {
|
|
459
|
-
const h = await signedIn((call) => {
|
|
460
|
-
if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
|
|
461
|
-
return json({ id: "w1", name: "New" }, 201);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
expect(await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps)).toBe(
|
|
465
|
-
0,
|
|
466
|
-
);
|
|
467
|
-
const mint = h.calls[0];
|
|
468
|
-
expect(mint.init.method).toBe("POST");
|
|
469
|
-
expect(auth(mint)).toBe(`Bearer ${signIn}`);
|
|
470
|
-
expect(JSON.parse(String(mint.init.body))).toEqual({
|
|
471
|
-
organization: "uuid-acme",
|
|
472
|
-
reason: "SUP-412",
|
|
473
|
-
});
|
|
474
|
-
expect(h.err.join("")).toContain("Staff access to Acme (uuid-acme)");
|
|
475
|
-
expect(h.err.join("")).toContain("audited");
|
|
476
|
-
expect(config(h.configDir).org).toEqual({
|
|
477
|
-
workosId: "org_ACME",
|
|
478
|
-
id: "uuid-acme",
|
|
479
|
-
name: "Acme",
|
|
480
|
-
});
|
|
481
|
-
expect(config(h.configDir).orgs).toEqual([own]);
|
|
482
|
-
|
|
483
|
-
expect(await run(["workflow", "create", "--name", "New"], h.deps)).toBe(0);
|
|
484
|
-
expect(auth(h.calls.at(-1)!)).toBe("Bearer staff-token-1");
|
|
485
|
-
expect(h.err.join("")).toContain(
|
|
486
|
-
"Acting in Acme (uuid-acme) with staff access (SUP-412).",
|
|
487
|
-
);
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it("asks for a reason before it mints anything", async () => {
|
|
491
|
-
const h = await signedIn(() => json(minted, 201));
|
|
492
|
-
expect(await run(["use", "uuid-acme"], h.deps)).toBe(2);
|
|
493
|
-
expect(h.err.join("")).toContain("--reason <why>");
|
|
494
|
-
expect(h.calls).toHaveLength(0);
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it("shows a customer no staff hint", async () => {
|
|
498
|
-
const h = await signedIn(
|
|
499
|
-
(call) =>
|
|
500
|
-
json(
|
|
501
|
-
{
|
|
502
|
-
error: "organization_membership_not_found",
|
|
503
|
-
error_description: "not a member",
|
|
504
|
-
},
|
|
505
|
-
400,
|
|
506
|
-
),
|
|
507
|
-
"pat@customer.com",
|
|
508
|
-
);
|
|
509
|
-
expect(await run(["use", "uuid-acme"], h.deps)).toBe(2);
|
|
510
|
-
expect(await run(["use", "org_ACME"], h.deps)).toBe(2);
|
|
511
|
-
expect(await run(["workflow", "list", "--org", "nope"], h.deps)).toBe(1);
|
|
512
|
-
const err = h.err.join("");
|
|
513
|
-
expect(err).toContain("you are not a member of org_ACME.");
|
|
514
|
-
expect(err).toContain("unknown organization nope.");
|
|
515
|
-
expect(err).not.toMatch(/staff|--reason|audited/);
|
|
516
|
-
expect(h.calls.some((c) => c.url.includes("staff-tokens"))).toBe(false);
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
it("takes the staff path when the refresh says you are not a member", async () => {
|
|
520
|
-
const h = await signedIn((call) => {
|
|
521
|
-
if (call.url.includes("workos"))
|
|
522
|
-
return json(
|
|
523
|
-
{
|
|
524
|
-
error: "organization_membership_not_found",
|
|
525
|
-
error_description: "not a member",
|
|
526
|
-
},
|
|
527
|
-
400,
|
|
528
|
-
);
|
|
529
|
-
return json(minted, 201);
|
|
530
|
-
});
|
|
531
|
-
expect(await run(["use", "org_ACME"], h.deps)).toBe(2);
|
|
532
|
-
expect(h.err.join("")).toContain("you are not a member of org_ACME");
|
|
533
|
-
|
|
534
|
-
expect(await run(["use", "org_ACME", "--reason", "SUP-412"], h.deps)).toBe(
|
|
535
|
-
0,
|
|
536
|
-
);
|
|
537
|
-
expect(h.calls.at(-1)!.url).toContain("/users/v2/staff-tokens");
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it("tells a non-staff person that staff access is for staff", async () => {
|
|
541
|
-
const h = await signedIn(() => json({ error: "forbidden" }, 403));
|
|
542
|
-
expect(await run(["use", "uuid-acme", "--reason", "x"], h.deps)).toBe(1);
|
|
543
|
-
expect(h.err.join("")).toContain("staff access is for Asteroid staff only");
|
|
544
|
-
expect(await h.store.staffTokens()).toEqual([]);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("uses a held staff token for one command with --org", async () => {
|
|
548
|
-
const h = await signedIn(() => json({ items: [] }));
|
|
549
|
-
await h.store.saveStaffToken({
|
|
550
|
-
...minted,
|
|
551
|
-
org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
|
|
552
|
-
expiresAt: Date.now() + 3600_000,
|
|
553
|
-
});
|
|
554
|
-
expect(await run(["workflow", "list", "--org", "Acme"], h.deps)).toBe(0);
|
|
555
|
-
expect(auth(h.calls[0])).toBe("Bearer staff-token-1");
|
|
556
|
-
expect(await run(["workflow", "list"], h.deps)).toBe(0);
|
|
557
|
-
expect(auth(h.calls[1])).toBe(`Bearer ${signIn}`);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
// resolveAuth treats a token within 30 s of its expiry as expired.
|
|
561
|
-
it("refuses a staff token in its last 30 s and says how to renew it", async () => {
|
|
562
|
-
const h = await signedIn(() => json({ items: [] }));
|
|
563
|
-
await h.store.saveStaffToken({
|
|
564
|
-
...minted,
|
|
565
|
-
org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
|
|
566
|
-
expiresAt: Date.now() + 10_000,
|
|
567
|
-
});
|
|
568
|
-
expect(await run(["workflow", "list", "--org", "org_ACME"], h.deps)).toBe(
|
|
569
|
-
1,
|
|
570
|
-
);
|
|
571
|
-
expect(h.err.join("")).toContain("has expired");
|
|
572
|
-
expect(h.calls).toHaveLength(0);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it("revokes every staff token on --exit and goes back to your org", async () => {
|
|
576
|
-
const h = await signedIn((call) => {
|
|
577
|
-
if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
|
|
578
|
-
return new Response(null, { status: 204 });
|
|
579
|
-
});
|
|
580
|
-
expect(await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps)).toBe(
|
|
581
|
-
0,
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
expect(await run(["use", "--exit"], h.deps)).toBe(0);
|
|
585
|
-
const revoke = h.calls.at(-1)!;
|
|
586
|
-
expect(revoke.init.method).toBe("DELETE");
|
|
587
|
-
expect(revoke.url).toBe(
|
|
588
|
-
"https://odyssey.asteroid.ai/users/v2/staff-tokens/grant-1",
|
|
589
|
-
);
|
|
590
|
-
expect(auth(revoke)).toBe(`Bearer ${signIn}`);
|
|
591
|
-
expect(await h.store.staffTokens()).toEqual([]);
|
|
592
|
-
expect(config(h.configDir).org).toEqual(own);
|
|
593
|
-
expect(h.err.join("")).toContain("Now using Asteroid (uuid-own)");
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
it("keeps a staff token on --exit when the revoke fails", async () => {
|
|
597
|
-
const h = await signedIn(() => json({ message: "down" }, 500));
|
|
598
|
-
await h.store.saveStaffToken({
|
|
599
|
-
...minted,
|
|
600
|
-
org: { workosId: "org_ACME", id: "uuid-acme", name: "Acme" },
|
|
601
|
-
expiresAt: Date.now() + 3600_000,
|
|
602
|
-
});
|
|
603
|
-
expect(await run(["use", "--exit"], h.deps)).toBe(1);
|
|
604
|
-
expect(await h.store.staffTokens()).toHaveLength(1);
|
|
605
|
-
expect(h.err.join("")).toContain("Run `asteroid use --exit` again");
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
it("revokes a minted token it could not save", async () => {
|
|
609
|
-
const h = await signedIn((call) => {
|
|
610
|
-
if (call.url.endsWith("/users/v2/staff-tokens")) return json(minted, 201);
|
|
611
|
-
return new Response(null, { status: 204 });
|
|
612
|
-
});
|
|
613
|
-
const save = vi
|
|
614
|
-
.spyOn(CredentialStore.prototype, "saveStaffToken")
|
|
615
|
-
.mockRejectedValue(new WorkosError("keychain", "keychain locked"));
|
|
616
|
-
try {
|
|
617
|
-
expect(
|
|
618
|
-
await run(["use", "uuid-acme", "--reason", "SUP-412"], h.deps),
|
|
619
|
-
).toBe(1);
|
|
620
|
-
} finally {
|
|
621
|
-
save.mockRestore();
|
|
622
|
-
}
|
|
623
|
-
const revoke = h.calls.at(-1)!;
|
|
624
|
-
expect(revoke.init.method).toBe("DELETE");
|
|
625
|
-
expect(revoke.url).toContain("/users/v2/staff-tokens/grant-1");
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
it("refuses an option as the reason", async () => {
|
|
629
|
-
const h = await signedIn(() => json(minted, 201));
|
|
630
|
-
expect(await run(["use", "uuid-acme", "--reason", "--exit"], h.deps)).toBe(
|
|
631
|
-
2,
|
|
632
|
-
);
|
|
633
|
-
expect(h.calls).toHaveLength(0);
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
it("rejects an org with --exit", async () => {
|
|
637
|
-
const h = await signedIn(() => json({}));
|
|
638
|
-
expect(await run(["use", "uuid-acme", "--exit"], h.deps)).toBe(2);
|
|
639
|
-
expect(h.calls).toHaveLength(0);
|
|
640
|
-
});
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
describe("reference", () => {
|
|
644
|
-
it("matches the generated agent-docs page", () => {
|
|
645
|
-
const page = readFileSync(
|
|
646
|
-
new URL("../../../docs/cli/reference.mdx", import.meta.url),
|
|
647
|
-
"utf8",
|
|
648
|
-
);
|
|
649
|
-
expect(page).toBe(renderReference(ALL_COMMANDS));
|
|
650
|
-
});
|
|
651
|
-
});
|