assistant-ui 0.0.124 → 0.0.125

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.
@@ -0,0 +1,74 @@
1
+ import { validateCloudUrl } from "./cloud-url.js";
2
+ //#region src/lib/cloud-remote.ts
3
+ const CLOUD_CLI_RUN_PATH = "/api/cli/run";
4
+ const SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
5
+ const joinCloudArgs = (args) => args.map((arg) => SAFE.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`).join(" ");
6
+ const parseCloudCliResponse = (value) => {
7
+ if (!value || typeof value !== "object") return null;
8
+ const body = value;
9
+ const exitCode = body["exitCode"];
10
+ if (typeof exitCode !== "number" || !Number.isInteger(exitCode) || exitCode < 0 || exitCode > 255) return null;
11
+ for (const key of ["stdout", "stderr"]) if (body[key] !== void 0 && typeof body[key] !== "string") return null;
12
+ const actions = body["actions"];
13
+ if (actions !== void 0 && !(Array.isArray(actions) && actions.every((action) => !!action && typeof action === "object" && typeof action.type === "string"))) return null;
14
+ return body;
15
+ };
16
+ const cliVersion = "0.0.125";
17
+ const sendCloudCommand = async (cloudUrl, args, accessToken) => {
18
+ const url = `${validateCloudUrl(cloudUrl)}${CLOUD_CLI_RUN_PATH}`;
19
+ let response;
20
+ try {
21
+ response = await fetch(url, {
22
+ method: "POST",
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ ...accessToken ? { Authorization: `Bearer ${accessToken}` } : {}
26
+ },
27
+ body: JSON.stringify({
28
+ command: joinCloudArgs(args),
29
+ client: {
30
+ name: "assistant-ui",
31
+ version: cliVersion,
32
+ platform: `${process.platform}-${process.arch}`
33
+ }
34
+ }),
35
+ redirect: "error",
36
+ signal: AbortSignal.timeout(6e4)
37
+ });
38
+ } catch (error) {
39
+ const cause = error.cause;
40
+ throw new Error(`Could not reach ${url}: ${cause?.message || cause?.code || error.message}`);
41
+ }
42
+ const body = parseCloudCliResponse(await response.json().catch(() => null));
43
+ if (!body) throw new Error(`Unexpected response from ${url} (${response.status} ${response.statusText}).`);
44
+ return body;
45
+ };
46
+ const ACTION_HANDLERS = {};
47
+ const runCloudCommand = async (args, deps) => {
48
+ let response;
49
+ try {
50
+ response = await (deps.send ?? sendCloudCommand)(deps.cloudUrl, args, await deps.accessToken().catch((error) => {
51
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
52
+ return null;
53
+ }));
54
+ } catch (error) {
55
+ deps.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
56
+ return 1;
57
+ }
58
+ const unknown = response.actions?.find((action) => !Object.hasOwn(ACTION_HANDLERS, action.type));
59
+ if (unknown) {
60
+ deps.stderr(`This command needs a newer assistant-ui (unsupported action "${unknown.type}"). Update the CLI and try again.\n`);
61
+ return 1;
62
+ }
63
+ if (response.stdout) deps.stdout(response.stdout);
64
+ if (response.stderr) deps.stderr(response.stderr);
65
+ for (const action of response.actions ?? []) {
66
+ const code = await ACTION_HANDLERS[action.type](action);
67
+ if (code !== 0) return code;
68
+ }
69
+ return response.exitCode;
70
+ };
71
+ //#endregion
72
+ export { CLOUD_CLI_RUN_PATH, joinCloudArgs, parseCloudCliResponse, runCloudCommand, sendCloudCommand };
73
+
74
+ //# sourceMappingURL=cloud-remote.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud-remote.js","names":[],"sources":["../../src/lib/cloud-remote.ts"],"sourcesContent":["import { validateCloudUrl } from \"./cloud-url\";\n\nexport const CLOUD_CLI_RUN_PATH = \"/api/cli/run\";\n\nexport type CloudCliAction = { type: string; [key: string]: unknown };\n\nexport type CloudCliResponse = {\n stdout?: string;\n stderr?: string;\n exitCode: number;\n actions?: CloudCliAction[];\n};\n\nconst SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;\n\nexport const joinCloudArgs = (args: readonly string[]): string =>\n args\n .map((arg) => (SAFE.test(arg) ? arg : `'${arg.replaceAll(\"'\", `'\\\\''`)}'`))\n .join(\" \");\n\nexport const parseCloudCliResponse = (\n value: unknown,\n): CloudCliResponse | null => {\n if (!value || typeof value !== \"object\") return null;\n const body = value as Record<string, unknown>;\n const exitCode = body[\"exitCode\"];\n if (\n typeof exitCode !== \"number\" ||\n !Number.isInteger(exitCode) ||\n exitCode < 0 ||\n exitCode > 255\n ) {\n return null;\n }\n for (const key of [\"stdout\", \"stderr\"]) {\n if (body[key] !== undefined && typeof body[key] !== \"string\") return null;\n }\n const actions = body[\"actions\"];\n if (\n actions !== undefined &&\n !(\n Array.isArray(actions) &&\n actions.every(\n (action) =>\n !!action &&\n typeof action === \"object\" &&\n typeof (action as CloudCliAction).type === \"string\",\n )\n )\n ) {\n return null;\n }\n return body as CloudCliResponse;\n};\n\nconst cliVersion: string =\n typeof __AUI_PACKAGE_VERSION__ === \"string\"\n ? __AUI_PACKAGE_VERSION__\n : \"0.0.0\";\n\nexport const sendCloudCommand = async (\n cloudUrl: string,\n args: readonly string[],\n accessToken: string | null,\n): Promise<CloudCliResponse> => {\n const url = `${validateCloudUrl(cloudUrl)}${CLOUD_CLI_RUN_PATH}`;\n let response: Response;\n try {\n response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),\n },\n body: JSON.stringify({\n command: joinCloudArgs(args),\n client: {\n name: \"assistant-ui\",\n version: cliVersion,\n platform: `${process.platform}-${process.arch}`,\n },\n }),\n redirect: \"error\",\n signal: AbortSignal.timeout(60_000),\n });\n } catch (error) {\n const cause = (error as Error).cause as NodeJS.ErrnoException | undefined;\n throw new Error(\n `Could not reach ${url}: ${cause?.message || cause?.code || (error as Error).message}`,\n );\n }\n const body = parseCloudCliResponse(await response.json().catch(() => null));\n if (!body) {\n throw new Error(\n `Unexpected response from ${url} (${response.status} ${response.statusText}).`,\n );\n }\n return body;\n};\n\n// A response naming an action this client lacks fails before printing, so the\n// server can roll a new action out behind the client version it is sent.\nconst ACTION_HANDLERS: Record<\n string,\n (action: CloudCliAction) => Promise<number>\n> = {};\n\nexport const runCloudCommand = async (\n args: readonly string[],\n deps: {\n cloudUrl: string;\n accessToken: () => Promise<string | null>;\n stdout: (text: string) => void;\n stderr: (text: string) => void;\n send?: typeof sendCloudCommand;\n },\n): Promise<number> => {\n let response: CloudCliResponse;\n try {\n response = await (deps.send ?? sendCloudCommand)(\n deps.cloudUrl,\n args,\n await deps.accessToken().catch((error: unknown) => {\n deps.stderr(\n `${error instanceof Error ? error.message : String(error)}\\n`,\n );\n return null;\n }),\n );\n } catch (error) {\n deps.stderr(`${error instanceof Error ? error.message : String(error)}\\n`);\n return 1;\n }\n const unknown = response.actions?.find(\n (action) => !Object.hasOwn(ACTION_HANDLERS, action.type),\n );\n if (unknown) {\n deps.stderr(\n `This command needs a newer assistant-ui (unsupported action \"${unknown.type}\"). Update the CLI and try again.\\n`,\n );\n return 1;\n }\n if (response.stdout) deps.stdout(response.stdout);\n if (response.stderr) deps.stderr(response.stderr);\n for (const action of response.actions ?? []) {\n const code = await ACTION_HANDLERS[action.type]!(action);\n if (code !== 0) return code;\n }\n return response.exitCode;\n};\n"],"mappings":";;AAEA,MAAa,qBAAqB;AAWlC,MAAM,OAAO;AAEb,MAAa,iBAAiB,SAC5B,KACG,KAAK,QAAS,KAAK,KAAK,GAAG,IAAI,MAAM,IAAI,IAAI,WAAW,KAAK,OAAO,EAAE,EAAG,CAAC,CAC1E,KAAK,GAAG;AAEb,MAAa,yBACX,UAC4B;CAC5B,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,MAAM,OAAO;CACb,MAAM,WAAW,KAAK;CACtB,IACE,OAAO,aAAa,YACpB,CAAC,OAAO,UAAU,QAAQ,KAC1B,WAAW,KACX,WAAW,KAEX,OAAO;CAET,KAAK,MAAM,OAAO,CAAC,UAAU,QAAQ,GACnC,IAAI,KAAK,SAAS,KAAA,KAAa,OAAO,KAAK,SAAS,UAAU,OAAO;CAEvE,MAAM,UAAU,KAAK;CACrB,IACE,YAAY,KAAA,KACZ,EACE,MAAM,QAAQ,OAAO,KACrB,QAAQ,OACL,WACC,CAAC,CAAC,UACF,OAAO,WAAW,YAClB,OAAQ,OAA0B,SAAS,QAC/C,IAGF,OAAO;CAET,OAAO;AACT;AAEA,MAAM,aAAA;AAKN,MAAa,mBAAmB,OAC9B,UACA,MACA,gBAC8B;CAC9B,MAAM,MAAM,GAAG,iBAAiB,QAAQ,IAAI;CAC5C,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,QAAQ;GACR,SAAS;IACP,gBAAgB;IAChB,GAAI,cAAc,EAAE,eAAe,UAAU,cAAc,IAAI,CAAC;GAClE;GACA,MAAM,KAAK,UAAU;IACnB,SAAS,cAAc,IAAI;IAC3B,QAAQ;KACN,MAAM;KACN,SAAS;KACT,UAAU,GAAG,QAAQ,SAAS,GAAG,QAAQ;IAC3C;GACF,CAAC;GACD,UAAU;GACV,QAAQ,YAAY,QAAQ,GAAM;EACpC,CAAC;CACH,SAAS,OAAO;EACd,MAAM,QAAS,MAAgB;EAC/B,MAAM,IAAI,MACR,mBAAmB,IAAI,IAAI,OAAO,WAAW,OAAO,QAAS,MAAgB,SAC/E;CACF;CACA,MAAM,OAAO,sBAAsB,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI,CAAC;CAC1E,IAAI,CAAC,MACH,MAAM,IAAI,MACR,4BAA4B,IAAI,IAAI,SAAS,OAAO,GAAG,SAAS,WAAW,GAC7E;CAEF,OAAO;AACT;AAIA,MAAM,kBAGF,CAAC;AAEL,MAAa,kBAAkB,OAC7B,MACA,SAOoB;CACpB,IAAI;CACJ,IAAI;EACF,WAAW,OAAO,KAAK,QAAQ,iBAAA,CAC7B,KAAK,UACL,MACA,MAAM,KAAK,YAAY,CAAC,CAAC,OAAO,UAAmB;GACjD,KAAK,OACH,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,GAC5D;GACA,OAAO;EACT,CAAC,CACH;CACF,SAAS,OAAO;EACd,KAAK,OAAO,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,GAAG;EACzE,OAAO;CACT;CACA,MAAM,UAAU,SAAS,SAAS,MAC/B,WAAW,CAAC,OAAO,OAAO,iBAAiB,OAAO,IAAI,CACzD;CACA,IAAI,SAAS;EACX,KAAK,OACH,gEAAgE,QAAQ,KAAK,oCAC/E;EACA,OAAO;CACT;CACA,IAAI,SAAS,QAAQ,KAAK,OAAO,SAAS,MAAM;CAChD,IAAI,SAAS,QAAQ,KAAK,OAAO,SAAS,MAAM;CAChD,KAAK,MAAM,UAAU,SAAS,WAAW,CAAC,GAAG;EAC3C,MAAM,OAAO,MAAM,gBAAgB,OAAO,KAAK,CAAE,MAAM;EACvD,IAAI,SAAS,GAAG,OAAO;CACzB;CACA,OAAO,SAAS;AAClB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistant-ui",
3
- "version": "0.0.124",
3
+ "version": "0.0.125",
4
4
  "description": "CLI for assistant-ui",
5
5
  "keywords": [
6
6
  "cli",
@@ -969,7 +969,7 @@ function MyComponent() {
969
969
  });
970
970
  });
971
971
 
972
- describe("condition precedence", () => {
972
+ describe("condition semantics", () => {
973
973
  const compileCondition = (jsx: string) => {
974
974
  const output = applyTransform(`
975
975
  import { MessagePrimitive, ComposerPrimitive } from "@assistant-ui/react";
@@ -982,6 +982,40 @@ const view = ${jsx};
982
982
  ) => boolean;
983
983
  };
984
984
 
985
+ it.each(["hasBranches", "lastOrHover"])(
986
+ "keeps %s={false} unconditional",
987
+ (filter) => {
988
+ const condition = compileCondition(
989
+ `<MessagePrimitive.If ${filter}={false} />`,
990
+ );
991
+
992
+ for (const branchCount of [1, 2]) {
993
+ for (const isLast of [false, true]) {
994
+ for (const isHovering of [false, true]) {
995
+ expect(
996
+ condition({ message: { branchCount, isLast, isHovering } }),
997
+ ).toBe(true);
998
+ }
999
+ }
1000
+ }
1001
+ },
1002
+ );
1003
+
1004
+ it.each(["hasBranches", "lastOrHover"])(
1005
+ "keeps the role filter when %s={false}",
1006
+ (filter) => {
1007
+ const condition = compileCondition(
1008
+ `<MessagePrimitive.If ${filter}={false} assistant />`,
1009
+ );
1010
+ const message = { branchCount: 1, isLast: false, isHovering: false };
1011
+
1012
+ expect(condition({ message: { ...message, role: "assistant" } })).toBe(
1013
+ true,
1014
+ );
1015
+ expect(condition({ message: { ...message, role: "user" } })).toBe(false);
1016
+ },
1017
+ );
1018
+
985
1019
  it.each([
986
1020
  {
987
1021
  jsx: "<MessagePrimitive.If speaking={false} />",
@@ -42,8 +42,8 @@ const messagePropMap: Record<
42
42
  expression: `s.message.role ${v === false ? "!==" : "==="} "system"`,
43
43
  negated: false,
44
44
  }),
45
- hasBranches: () => ({
46
- expression: "s.message.branchCount >= 2",
45
+ hasBranches: (v) => ({
46
+ expression: v === false ? "true" : "s.message.branchCount >= 2",
47
47
  negated: false,
48
48
  }),
49
49
  copied: (v) => ({
@@ -54,8 +54,9 @@ const messagePropMap: Record<
54
54
  expression: "s.message.isLast",
55
55
  negated: v === false,
56
56
  }),
57
- lastOrHover: () => ({
58
- expression: "s.message.isHovering || s.message.isLast",
57
+ lastOrHover: (v) => ({
58
+ expression:
59
+ v === false ? "true" : "s.message.isHovering || s.message.isLast",
59
60
  negated: false,
60
61
  }),
61
62
  speaking: (v) => ({
@@ -9,6 +9,9 @@ const {
9
9
  fetchProjects,
10
10
  createProject,
11
11
  cloudAccessToken,
12
+ savedCloudAccessToken,
13
+ runCloudCommand,
14
+ loginToCloud,
12
15
  provisionCloudHarness,
13
16
  scaffold,
14
17
  select,
@@ -18,6 +21,9 @@ const {
18
21
  fetchProjects: vi.fn(),
19
22
  createProject: vi.fn(),
20
23
  cloudAccessToken: vi.fn(),
24
+ savedCloudAccessToken: vi.fn(),
25
+ runCloudCommand: vi.fn(),
26
+ loginToCloud: vi.fn(),
21
27
  provisionCloudHarness: vi.fn(),
22
28
  scaffold: vi.fn(),
23
29
  select: vi.fn(),
@@ -33,6 +39,12 @@ vi.mock("aui-auth/accounts", async (importOriginal) => ({
33
39
  vi.mock("../lib/cloud-auth", async (importOriginal) => ({
34
40
  ...(await importOriginal<typeof import("../lib/cloud-auth")>()),
35
41
  cloudAccessToken,
42
+ savedCloudAccessToken,
43
+ loginToCloud,
44
+ }));
45
+ vi.mock("../lib/cloud-remote", async (importOriginal) => ({
46
+ ...(await importOriginal<typeof import("../lib/cloud-remote")>()),
47
+ runCloudCommand,
36
48
  }));
37
49
  vi.mock("../lib/cloud-provision", async (importOriginal) => ({
38
50
  ...(await importOriginal<typeof import("../lib/cloud-provision")>()),
@@ -236,3 +248,49 @@ describe("cloud setup", () => {
236
248
  ]);
237
249
  });
238
250
  });
251
+
252
+ describe("cloud remote forwarding", () => {
253
+ const forwarded = async (argv: string[]) => {
254
+ runCloudCommand.mockClear();
255
+ runCloudCommand.mockResolvedValue(4);
256
+ await cloud.parseAsync(argv, { from: "user" });
257
+ return runCloudCommand.mock.calls.map(([args]) => args);
258
+ };
259
+
260
+ it("forwards help, unknown subcommands and their flags verbatim", async () => {
261
+ for (const argv of [
262
+ [],
263
+ ["help"],
264
+ ["--help"],
265
+ ["-h"],
266
+ ["help", "projects"],
267
+ ["projects", "list", "--org", "acme", "--json"],
268
+ ["whoami", "--version"],
269
+ ["--org=acme", "projects"],
270
+ ]) {
271
+ await expect(forwarded(argv)).resolves.toEqual([argv]);
272
+ }
273
+ expect(process.exitCode).toBe(4);
274
+ process.exitCode = undefined;
275
+ });
276
+
277
+ it("keeps login local", async () => {
278
+ loginToCloud.mockResolvedValue(undefined);
279
+ await expect(forwarded(["login", "--no-open"])).resolves.toEqual([]);
280
+ expect(loginToCloud).toHaveBeenCalledWith(
281
+ expect.anything(),
282
+ expect.objectContaining({ noOpen: true }),
283
+ );
284
+ });
285
+
286
+ it("sends the saved login without starting a new one", async () => {
287
+ savedCloudAccessToken.mockResolvedValue(null);
288
+ runCloudCommand.mockImplementationOnce(async (_args, options) => {
289
+ await expect(options.accessToken()).resolves.toBeNull();
290
+ return 0;
291
+ });
292
+ await cloud.parseAsync(["whoami"], { from: "user" });
293
+ expect(savedCloudAccessToken).toHaveBeenCalled();
294
+ expect(cloudAccessToken).not.toHaveBeenCalled();
295
+ });
296
+ });
@@ -15,8 +15,10 @@ import {
15
15
  cloudAuthConfig,
16
16
  loginToCloud,
17
17
  logoutFromCloud,
18
+ savedCloudAccessToken,
18
19
  validateCloudUrl,
19
20
  } from "../lib/cloud-auth";
21
+ import { runCloudCommand } from "../lib/cloud-remote";
20
22
  import {
21
23
  readCloudProjectConfig,
22
24
  mergeCloudEnvironment,
@@ -370,9 +372,24 @@ export const setupCloud = async (
370
372
  );
371
373
  };
372
374
 
375
+ const DEFAULT_CLOUD_URL = "https://cloud.assistant-ui.com";
376
+
373
377
  export const cloud = new Command()
374
378
  .name("cloud")
375
- .description("sign in and set up a multiplayer Assistant Cloud chat")
379
+ .description("manage Assistant Cloud (run `assistant-ui cloud help`)")
380
+ .helpOption(false)
381
+ .helpCommand(false)
382
+ .argument("[command...]")
383
+ .allowUnknownOption()
384
+ .allowExcessArguments()
385
+ .action(async (args: string[]) => {
386
+ process.exitCode = await runCloudCommand(args, {
387
+ cloudUrl: process.env.ASSISTANT_UI_CLOUD_URL ?? DEFAULT_CLOUD_URL,
388
+ accessToken: () => savedCloudAccessToken(cloudAuthConfig()),
389
+ stdout: (text) => process.stdout.write(text),
390
+ stderr: (text) => process.stderr.write(text),
391
+ });
392
+ })
376
393
  .addCommand(
377
394
  new Command("login")
378
395
  .description("sign in to Assistant Cloud using your browser")
@@ -418,7 +435,7 @@ export const cloud = new Command()
418
435
  .option(
419
436
  "--api-url <url>",
420
437
  "Assistant Cloud management URL",
421
- process.env.ASSISTANT_UI_CLOUD_URL ?? "https://cloud.assistant-ui.com",
438
+ process.env.ASSISTANT_UI_CLOUD_URL ?? DEFAULT_CLOUD_URL,
422
439
  )
423
440
  .option(
424
441
  "--access-code <code>",
@@ -38,6 +38,8 @@ const ASSISTANT_UI_PACKAGES = [
38
38
  "@assistant-ui/react-lexical",
39
39
  "@assistant-ui/react-syntax-highlighter",
40
40
  "@assistant-ui/react-hook-form",
41
+ "safe-content-frame",
42
+ "tw-shimmer",
41
43
  // Observability & DevTools
42
44
  "@assistant-ui/react-o11y",
43
45
  "@assistant-ui/react-devtools",
@@ -50,6 +52,7 @@ const ECOSYSTEM_PACKAGES = [
50
52
  "next",
51
53
  "vite",
52
54
  "expo",
55
+ "tailwindcss",
53
56
  "ai",
54
57
  "zod",
55
58
  "zustand",
@@ -112,6 +115,24 @@ function getAssistantUiPackageNames(
112
115
  return [...new Set([...ASSISTANT_UI_PACKAGES, ...declaredPackages])];
113
116
  }
114
117
 
118
+ // Adapters peer on the provider SDK they wrap, so the installed packages
119
+ // name the upstream versions a bug report needs.
120
+ function getEcosystemPackageNames(
121
+ packages: PackageInfo[],
122
+ cwd: string,
123
+ ): string[] {
124
+ const upstreamPeers = packages
125
+ .flatMap((pkg) => Object.keys(getPeerDeps(pkg.name, cwd) ?? {}))
126
+ .filter(
127
+ (name) =>
128
+ !name.startsWith("@assistant-ui/") &&
129
+ !ASSISTANT_UI_PACKAGES.includes(name),
130
+ )
131
+ .sort();
132
+
133
+ return [...new Set([...ECOSYSTEM_PACKAGES, ...upstreamPeers])];
134
+ }
135
+
115
136
  function getSpecifiedRange(
116
137
  pkg: string,
117
138
  projectPkg: Record<string, unknown>,
@@ -312,7 +333,11 @@ async function collectInfo(
312
333
  cwd,
313
334
  projectPkg,
314
335
  );
315
- const ecosystem = collectPackages(ECOSYSTEM_PACKAGES, cwd, projectPkg);
336
+ const ecosystem = collectPackages(
337
+ getEcosystemPackageNames(packages, cwd),
338
+ cwd,
339
+ projectPkg,
340
+ );
316
341
  const warnings = collectWarnings(packages, cwd, projectPkg);
317
342
 
318
343
  return {
@@ -230,16 +230,11 @@ export const loginToCloud = async (
230
230
  }
231
231
  };
232
232
 
233
- export const cloudAccessToken = async (
233
+ export const savedCloudAccessToken = async (
234
234
  config: CloudAuthConfig,
235
- options: {
236
- noOpen?: boolean;
237
- setupUrl?: string;
238
- print?: (text: string) => void;
239
- } = {},
240
- ): Promise<string> => {
235
+ ): Promise<string | null> => {
241
236
  const saved = await readCloudCredentials(config);
242
- if (!saved) return (await loginToCloud(config, options)).tokens.accessToken;
237
+ if (!saved) return null;
243
238
  const login = createDeviceLogin({
244
239
  issuer: config.issuer,
245
240
  clientId: config.clientId,
@@ -259,6 +254,17 @@ export const cloudAccessToken = async (
259
254
  }
260
255
  };
261
256
 
257
+ export const cloudAccessToken = async (
258
+ config: CloudAuthConfig,
259
+ options: {
260
+ noOpen?: boolean;
261
+ setupUrl?: string;
262
+ print?: (text: string) => void;
263
+ } = {},
264
+ ): Promise<string> =>
265
+ (await savedCloudAccessToken(config)) ??
266
+ (await loginToCloud(config, options)).tokens.accessToken;
267
+
262
268
  export const logoutFromCloud = async (
263
269
  config: CloudAuthConfig,
264
270
  ): Promise<void> => {
@@ -0,0 +1,156 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ joinCloudArgs,
4
+ parseCloudCliResponse,
5
+ runCloudCommand,
6
+ sendCloudCommand,
7
+ type CloudCliResponse,
8
+ } from "./cloud-remote";
9
+
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ });
13
+
14
+ const harness = (reply: CloudCliResponse, token: string | null = "token") => {
15
+ const output = { stdout: "", stderr: "" };
16
+ const send = vi.fn(async () => reply);
17
+ return {
18
+ output,
19
+ send,
20
+ run: (args: string[]) =>
21
+ runCloudCommand(args, {
22
+ cloudUrl: "https://cloud.example.com",
23
+ accessToken: async () => token,
24
+ stdout: (text) => {
25
+ output.stdout += text;
26
+ },
27
+ stderr: (text) => {
28
+ output.stderr += text;
29
+ },
30
+ send,
31
+ }),
32
+ };
33
+ };
34
+
35
+ describe("cloud remote commands", () => {
36
+ it("quotes arguments so a POSIX shell split gives them back", () => {
37
+ expect(
38
+ joinCloudArgs(["projects", "list", "--org", "my org", "it's", ""]),
39
+ ).toBe(`projects list --org 'my org' 'it'\\''s' ''`);
40
+ });
41
+
42
+ it("prints the envelope and exits with its code", async () => {
43
+ const { run, send, output } = harness({
44
+ stdout: "out\n",
45
+ stderr: "err\n",
46
+ exitCode: 3,
47
+ });
48
+ await expect(run(["whoami"])).resolves.toBe(3);
49
+ expect(send).toHaveBeenCalledWith(
50
+ "https://cloud.example.com",
51
+ ["whoami"],
52
+ "token",
53
+ );
54
+ expect(output).toEqual({ stdout: "out\n", stderr: "err\n" });
55
+ });
56
+
57
+ it("fails an action it does not know before printing anything", async () => {
58
+ const { run, output } = harness({
59
+ stdout: "uploading\n",
60
+ exitCode: 0,
61
+ actions: [{ type: "upload", arg: 2 }],
62
+ });
63
+ await expect(run(["files", "push", "a.txt"])).resolves.toBe(1);
64
+ expect(output.stdout).toBe("");
65
+ expect(output.stderr).toMatch(/newer assistant-ui.*"upload"/);
66
+ });
67
+
68
+ it("prints a failed request as one line and exits 1", async () => {
69
+ const { run, send, output } = harness({ exitCode: 0 });
70
+ send.mockRejectedValueOnce(new Error("Could not reach the cloud"));
71
+ await expect(run(["whoami"])).resolves.toBe(1);
72
+ expect(output.stderr).toBe("Could not reach the cloud\n");
73
+ });
74
+
75
+ it("sends anonymously when the saved login cannot be read", async () => {
76
+ const output = { stdout: "", stderr: "" };
77
+ const send = vi.fn(async () => ({ stdout: "help\n", exitCode: 0 }));
78
+ await expect(
79
+ runCloudCommand(["help"], {
80
+ cloudUrl: "https://cloud.example.com",
81
+ accessToken: async () => {
82
+ throw new Error("Invalid saved login.");
83
+ },
84
+ stdout: (text) => {
85
+ output.stdout += text;
86
+ },
87
+ stderr: (text) => {
88
+ output.stderr += text;
89
+ },
90
+ send,
91
+ }),
92
+ ).resolves.toBe(0);
93
+ expect(send).toHaveBeenCalledWith(
94
+ "https://cloud.example.com",
95
+ ["help"],
96
+ null,
97
+ );
98
+ expect(output).toEqual({
99
+ stdout: "help\n",
100
+ stderr: "Invalid saved login.\n",
101
+ });
102
+ });
103
+
104
+ it("accepts only a well-formed envelope", () => {
105
+ expect(parseCloudCliResponse({ stdout: "x", exitCode: 0 })).toEqual({
106
+ stdout: "x",
107
+ exitCode: 0,
108
+ });
109
+ for (const bad of [
110
+ null,
111
+ "text",
112
+ { stdout: "x" },
113
+ { exitCode: 1.5 },
114
+ { exitCode: 256 },
115
+ { exitCode: -1 },
116
+ { exitCode: 0, stderr: 3 },
117
+ { exitCode: 0, actions: [{}] },
118
+ ]) {
119
+ expect(parseCloudCliResponse(bad)).toBeNull();
120
+ }
121
+ });
122
+
123
+ it("posts the command string, client and bearer token", async () => {
124
+ const fetchMock = vi.fn(async () =>
125
+ Response.json({ stdout: "ok\n", exitCode: 0 }),
126
+ );
127
+ vi.stubGlobal("fetch", fetchMock);
128
+ await expect(
129
+ sendCloudCommand("https://cloud.example.com/", ["help", "a b"], "tok"),
130
+ ).resolves.toEqual({ stdout: "ok\n", exitCode: 0 });
131
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [
132
+ string,
133
+ RequestInit,
134
+ ];
135
+ expect(url).toBe("https://cloud.example.com/api/cli/run");
136
+ expect(init.headers).toMatchObject({ Authorization: "Bearer tok" });
137
+ expect(JSON.parse(init.body as string)).toMatchObject({
138
+ command: "help 'a b'",
139
+ client: { name: "assistant-ui" },
140
+ });
141
+ });
142
+
143
+ it("sends no Authorization header when signed out and rejects a non-envelope", async () => {
144
+ const fetchMock = vi.fn(
145
+ async () => new Response("<html>", { status: 502 }),
146
+ );
147
+ vi.stubGlobal("fetch", fetchMock);
148
+ await expect(
149
+ sendCloudCommand("https://cloud.example.com", ["help"], null),
150
+ ).rejects.toThrow(/Unexpected response .*502/);
151
+ const init = (
152
+ fetchMock.mock.calls[0] as unknown as [string, RequestInit]
153
+ )[1];
154
+ expect(init.headers).not.toHaveProperty("Authorization");
155
+ });
156
+ });