assistant-ui 0.0.119 → 0.0.121

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.
Files changed (92) hide show
  1. package/README.md +60 -1
  2. package/dist/commands/cloud.d.ts +25 -0
  3. package/dist/commands/cloud.d.ts.map +1 -0
  4. package/dist/commands/cloud.js +194 -0
  5. package/dist/commands/cloud.js.map +1 -0
  6. package/dist/commands/create.d.ts.map +1 -1
  7. package/dist/commands/create.js +8 -0
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/info.js +1 -1
  10. package/dist/lib/agent-skill.d.ts +2 -2
  11. package/dist/lib/agent-skill.js +1 -1
  12. package/dist/lib/agent-skill.js.map +1 -1
  13. package/dist/lib/cloud-abort.d.ts +2 -0
  14. package/dist/lib/cloud-abort.d.ts.map +1 -0
  15. package/dist/lib/cloud-abort.js +13 -0
  16. package/dist/lib/cloud-abort.js.map +1 -0
  17. package/dist/lib/cloud-auth.d.ts +28 -0
  18. package/dist/lib/cloud-auth.d.ts.map +1 -0
  19. package/dist/lib/cloud-auth.js +169 -0
  20. package/dist/lib/cloud-auth.js.map +1 -0
  21. package/dist/lib/cloud-config.d.ts +21 -0
  22. package/dist/lib/cloud-config.d.ts.map +1 -0
  23. package/dist/lib/cloud-config.js +76 -0
  24. package/dist/lib/cloud-config.js.map +1 -0
  25. package/dist/lib/cloud-provision.d.ts +20 -0
  26. package/dist/lib/cloud-provision.d.ts.map +1 -0
  27. package/dist/lib/cloud-provision.js +55 -0
  28. package/dist/lib/cloud-provision.js.map +1 -0
  29. package/dist/lib/cloud-setup-login.d.ts +9 -0
  30. package/dist/lib/cloud-setup-login.d.ts.map +1 -0
  31. package/dist/lib/cloud-setup-login.js +137 -0
  32. package/dist/lib/cloud-setup-login.js.map +1 -0
  33. package/dist/lib/cloud-url.d.ts +2 -0
  34. package/dist/lib/cloud-url.d.ts.map +1 -0
  35. package/dist/lib/cloud-url.js +14 -0
  36. package/dist/lib/cloud-url.js.map +1 -0
  37. package/dist/lib/transform.js +1 -1
  38. package/dist/program.d.ts.map +1 -1
  39. package/dist/program.js +2 -1
  40. package/dist/program.js.map +1 -1
  41. package/package.json +17 -13
  42. package/src/commands/cloud.test.ts +238 -0
  43. package/src/commands/cloud.ts +455 -0
  44. package/src/commands/create.ts +8 -0
  45. package/src/lib/agent-skill.ts +1 -1
  46. package/src/lib/cloud-abort.ts +12 -0
  47. package/src/lib/cloud-auth.test.ts +500 -0
  48. package/src/lib/cloud-auth.ts +273 -0
  49. package/src/lib/cloud-config.test.ts +82 -0
  50. package/src/lib/cloud-config.ts +138 -0
  51. package/src/lib/cloud-provision.test.ts +132 -0
  52. package/src/lib/cloud-provision.ts +93 -0
  53. package/src/lib/cloud-setup-login.test.ts +361 -0
  54. package/src/lib/cloud-setup-login.ts +240 -0
  55. package/src/lib/cloud-url.ts +19 -0
  56. package/src/program.ts +2 -0
  57. package/templates/cloud-harness/.assistant-ui/template.json +3 -0
  58. package/templates/cloud-harness/.env.example +1 -0
  59. package/templates/cloud-harness/.gitignore +42 -0
  60. package/templates/cloud-harness/README.md +28 -0
  61. package/templates/cloud-harness/app/api/chat/route.ts +13 -0
  62. package/templates/cloud-harness/app/api/credential/route.ts +14 -0
  63. package/templates/cloud-harness/app/assistant.tsx +83 -0
  64. package/templates/cloud-harness/app/favicon.ico +0 -0
  65. package/templates/cloud-harness/app/globals.css +139 -0
  66. package/templates/cloud-harness/app/layout.tsx +37 -0
  67. package/templates/cloud-harness/app/page.tsx +5 -0
  68. package/templates/cloud-harness/components/assistant-ui/elements/attachment.aui.tsx +279 -0
  69. package/templates/cloud-harness/components/assistant-ui/elements/markdown-text.tsx +238 -0
  70. package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +521 -0
  71. package/templates/cloud-harness/components/assistant-ui/elements/tool-fallback.aui.tsx +1153 -0
  72. package/templates/cloud-harness/components/assistant-ui/elements/tooltip-icon-button.tsx +49 -0
  73. package/templates/cloud-harness/components/ui/avatar.tsx +109 -0
  74. package/templates/cloud-harness/components/ui/button.tsx +58 -0
  75. package/templates/cloud-harness/components/ui/collapsible.tsx +21 -0
  76. package/templates/cloud-harness/components/ui/dialog.tsx +159 -0
  77. package/templates/cloud-harness/components/ui/textarea.tsx +18 -0
  78. package/templates/cloud-harness/components/ui/tooltip.tsx +69 -0
  79. package/templates/cloud-harness/components.json +24 -0
  80. package/templates/cloud-harness/hooks/use-attachment-src.ts +46 -0
  81. package/templates/cloud-harness/hooks/use-copy-to-clipboard.ts +54 -0
  82. package/templates/cloud-harness/lib/utils.ts +1 -0
  83. package/templates/cloud-harness/next.config.ts +6 -0
  84. package/templates/cloud-harness/package.json +51 -0
  85. package/templates/cloud-harness/postcss.config.mjs +5 -0
  86. package/templates/cloud-harness/tsconfig.json +33 -0
  87. package/templates/cloud-harness/vendor/README.md +13 -0
  88. package/templates/cloud-harness/vendor/assistant-ui-react-harness-sdk-0.0.2.tgz +0 -0
  89. package/templates/cloud-harness/vendor/harness-sdk-0.3.1.tgz +0 -0
  90. package/templates/cloud-harness/vendor/provenance.json +38 -0
  91. package/templates/cloud-harness/vendor/statewire-0.19.3.tgz +0 -0
  92. /package/{LICENSE → templates/cloud-harness/vendor/LICENSE} +0 -0
@@ -0,0 +1,273 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { renameSync, statSync, unlinkSync } from "node:fs";
4
+ import {
5
+ chmod,
6
+ link,
7
+ mkdir,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import {
16
+ createDeviceLogin,
17
+ isAccessDenied,
18
+ type DeviceCredentials,
19
+ } from "aui-auth/device";
20
+ import { abortable } from "./cloud-abort";
21
+ import { validateCloudUrl } from "./cloud-url";
22
+
23
+ export { validateCloudUrl } from "./cloud-url";
24
+
25
+ export type CloudAuthConfig = {
26
+ issuer: string;
27
+ clientId: string;
28
+ credentialsFile: string;
29
+ };
30
+
31
+ type SavedCredentials = DeviceCredentials & {
32
+ issuer: string;
33
+ clientId: string;
34
+ };
35
+
36
+ export const cloudAuthConfig = (): CloudAuthConfig => ({
37
+ issuer: validateCloudUrl(
38
+ process.env.ASSISTANT_UI_ACCOUNTS_URL ??
39
+ "https://accounts.assistant-ui.com",
40
+ ),
41
+ clientId: process.env.ASSISTANT_UI_OIDC_CLIENT_ID ?? "assistant-ui-cli",
42
+ credentialsFile: path.join(
43
+ process.env.ASSISTANT_UI_CONFIG_DIR ??
44
+ path.join(os.homedir(), ".assistant-ui"),
45
+ "credentials.json",
46
+ ),
47
+ });
48
+
49
+ export const readCloudCredentials = async (
50
+ config: CloudAuthConfig,
51
+ options: { matchConfig?: boolean } = {},
52
+ ): Promise<SavedCredentials | null> => {
53
+ let contents: string;
54
+ try {
55
+ contents = await readFile(config.credentialsFile, "utf8");
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
58
+ throw error;
59
+ }
60
+ let saved: SavedCredentials;
61
+ try {
62
+ saved = JSON.parse(contents) as SavedCredentials;
63
+ } catch {
64
+ throw new Error("Invalid saved login. Run assistant-ui cloud login again.");
65
+ }
66
+ if (!saved || typeof saved !== "object")
67
+ throw new Error("Invalid saved login. Run assistant-ui cloud login again.");
68
+ if (
69
+ options.matchConfig !== false &&
70
+ (saved.issuer !== config.issuer || saved.clientId !== config.clientId)
71
+ ) {
72
+ return null;
73
+ }
74
+ if (
75
+ !saved.tokens?.accessToken ||
76
+ !saved.user?.id ||
77
+ typeof saved.tokens.accessTokenExpiresAt !== "number"
78
+ ) {
79
+ throw new Error("Invalid saved login. Run assistant-ui cloud login again.");
80
+ }
81
+ return saved;
82
+ };
83
+
84
+ export const saveCloudCredentials = async (
85
+ config: CloudAuthConfig,
86
+ credentials: DeviceCredentials,
87
+ signal?: AbortSignal,
88
+ ): Promise<void> => {
89
+ await mkdir(path.dirname(config.credentialsFile), {
90
+ recursive: true,
91
+ mode: 0o700,
92
+ });
93
+ const temporary = `${config.credentialsFile}.${randomUUID()}.tmp`;
94
+ const backup = `${temporary}.previous`;
95
+ let previous = false;
96
+ let replacement: ReturnType<typeof statSync> | undefined;
97
+ let renamed = false;
98
+ try {
99
+ try {
100
+ await link(config.credentialsFile, backup);
101
+ previous = true;
102
+ } catch (error) {
103
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
104
+ }
105
+ await writeFile(
106
+ temporary,
107
+ `${JSON.stringify({ ...credentials, issuer: config.issuer, clientId: config.clientId }, null, 2)}\n`,
108
+ { mode: 0o600, flag: "wx" },
109
+ );
110
+ await chmod(temporary, 0o600);
111
+ replacement = statSync(temporary);
112
+ signal?.throwIfAborted();
113
+ await rename(temporary, config.credentialsFile);
114
+ renamed = true;
115
+ signal?.throwIfAborted();
116
+ } catch (error) {
117
+ if (renamed && replacement) {
118
+ let current: ReturnType<typeof statSync> | undefined;
119
+ try {
120
+ current = statSync(config.credentialsFile);
121
+ } catch (failure) {
122
+ if ((failure as NodeJS.ErrnoException).code !== "ENOENT") throw failure;
123
+ }
124
+ if (current?.dev === replacement.dev && current.ino === replacement.ino) {
125
+ if (previous) renameSync(backup, config.credentialsFile);
126
+ else unlinkSync(config.credentialsFile);
127
+ }
128
+ }
129
+ throw error;
130
+ } finally {
131
+ await rm(temporary, { force: true });
132
+ await rm(backup, { force: true });
133
+ }
134
+ };
135
+
136
+ const openBrowser = (url: string): void => {
137
+ const command =
138
+ process.platform === "darwin"
139
+ ? "open"
140
+ : process.platform === "win32"
141
+ ? "rundll32"
142
+ : "xdg-open";
143
+ const args =
144
+ process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
145
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
146
+ child.on("error", () => {});
147
+ child.unref();
148
+ };
149
+
150
+ export const loginToCloud = async (
151
+ config: CloudAuthConfig,
152
+ options: {
153
+ noOpen?: boolean;
154
+ setupUrl?: string;
155
+ print?: (text: string) => void;
156
+ } = {},
157
+ ): Promise<DeviceCredentials> => {
158
+ if (
159
+ options.setupUrl &&
160
+ !["assistant-ui-cli", "assistant-ui-cli-dev"].includes(config.clientId)
161
+ ) {
162
+ throw new Error(
163
+ "Wizard sign-in requires the assistant-ui CLI OAuth client.",
164
+ );
165
+ }
166
+ const print = options.print ?? console.log;
167
+ const setup = options.setupUrl
168
+ ? await (
169
+ await import("./cloud-setup-login")
170
+ ).connectCloudLoginSetup(options.setupUrl)
171
+ : undefined;
172
+ const signal = AbortSignal.any([
173
+ AbortSignal.timeout(10 * 60 * 1000),
174
+ ...(setup ? [setup.signal] : []),
175
+ ]);
176
+ const login = createDeviceLogin({
177
+ issuer: config.issuer,
178
+ clientId: config.clientId,
179
+ fetch: (input, init) => {
180
+ const url = new URL(input instanceof Request ? input.url : String(input));
181
+ return fetch(
182
+ input,
183
+ url.pathname === "/api/auth/device/code"
184
+ ? {
185
+ ...init,
186
+ signal: AbortSignal.any([
187
+ signal,
188
+ ...(init?.signal ? [init.signal] : []),
189
+ ]),
190
+ }
191
+ : init,
192
+ );
193
+ },
194
+ });
195
+ let credentials: DeviceCredentials | undefined;
196
+ let committed = false;
197
+ try {
198
+ signal.throwIfAborted();
199
+ const authorization = await abortable(login.start(), signal);
200
+ signal.throwIfAborted();
201
+ const approvalUrl =
202
+ (await setup?.publish(authorization, config.issuer)) ??
203
+ authorization.verificationUriComplete;
204
+ print(`Confirm code ${authorization.userCode} at ${approvalUrl}`);
205
+ if (!options.noOpen && !setup)
206
+ openBrowser(authorization.verificationUriComplete);
207
+ credentials = await login.wait(authorization, { signal });
208
+ signal.throwIfAborted();
209
+ await saveCloudCredentials(config, credentials, signal);
210
+ committed = true;
211
+ await setup?.complete("signed-in").catch(() => {
212
+ print("Signed in locally. The setup connection could not be updated.");
213
+ });
214
+ print(`Signed in as ${credentials.user.email || credentials.user.id}.`);
215
+ return credentials;
216
+ } catch (error) {
217
+ if (credentials && !committed) {
218
+ await login.revoke(credentials).catch(() => {
219
+ print(
220
+ "The cancelled login could not be revoked. End it from your Accounts Sessions page.",
221
+ );
222
+ });
223
+ }
224
+ await setup
225
+ ?.complete(signal.aborted ? "cancelled" : "failed")
226
+ .catch(() => {});
227
+ throw error;
228
+ } finally {
229
+ await setup?.dispose();
230
+ }
231
+ };
232
+
233
+ export const cloudAccessToken = async (
234
+ config: CloudAuthConfig,
235
+ options: {
236
+ noOpen?: boolean;
237
+ setupUrl?: string;
238
+ print?: (text: string) => void;
239
+ } = {},
240
+ ): Promise<string> => {
241
+ const saved = await readCloudCredentials(config);
242
+ if (!saved) return (await loginToCloud(config, options)).tokens.accessToken;
243
+ const login = createDeviceLogin({
244
+ issuer: config.issuer,
245
+ clientId: config.clientId,
246
+ });
247
+ try {
248
+ const credentials = await login.ensureFresh(saved);
249
+ if (credentials !== saved) await saveCloudCredentials(config, credentials);
250
+ return credentials.tokens.accessToken;
251
+ } catch (error) {
252
+ if (isAccessDenied(error)) {
253
+ await rm(config.credentialsFile, { force: true });
254
+ throw new Error(
255
+ "Your login expired. Run assistant-ui cloud login again.",
256
+ );
257
+ }
258
+ throw error;
259
+ }
260
+ };
261
+
262
+ export const logoutFromCloud = async (
263
+ config: CloudAuthConfig,
264
+ ): Promise<void> => {
265
+ const saved = await readCloudCredentials(config, { matchConfig: false });
266
+ if (saved) {
267
+ await createDeviceLogin({
268
+ issuer: validateCloudUrl(saved.issuer),
269
+ clientId: saved.clientId,
270
+ }).revoke(saved);
271
+ }
272
+ await rm(config.credentialsFile, { force: true });
273
+ };
@@ -0,0 +1,82 @@
1
+ import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ mergeCloudEnvironment,
7
+ readCloudProjectConfig,
8
+ writeCloudProjectConfig,
9
+ } from "./cloud-config";
10
+
11
+ describe("cloud environment", () => {
12
+ it("preserves custom settings and repeated configuration", () => {
13
+ const original = "# My settings\nOPENAI_API_KEY=my-existing-key\n";
14
+ const merged = mergeCloudEnvironment(original, {
15
+ ASSISTANT_API_KEY: "secret",
16
+ });
17
+ expect(merged).toContain(original);
18
+ expect(mergeCloudEnvironment(merged, { ASSISTANT_API_KEY: "secret" })).toBe(
19
+ merged,
20
+ );
21
+ });
22
+
23
+ it("refuses to overwrite an existing configuration", () => {
24
+ expect(() =>
25
+ mergeCloudEnvironment("ASSISTANT_API_KEY=existing\n", {
26
+ ASSISTANT_API_KEY: "replacement",
27
+ }),
28
+ ).toThrow("already configured");
29
+ });
30
+
31
+ it("encodes multiline values and quotes", () => {
32
+ expect(mergeCloudEnvironment("", { KEY: 'hello\nworld"' })).toBe(
33
+ 'KEY="hello\\nworld\\\""\n',
34
+ );
35
+ });
36
+
37
+ it("keeps API keys in an ignored private server environment file", async () => {
38
+ const directory = await mkdtemp(
39
+ path.join(os.tmpdir(), "assistant-ui-cloud-"),
40
+ );
41
+ try {
42
+ await writeFile(
43
+ path.join(directory, ".env.local"),
44
+ "OPENAI_API_KEY=existing-provider-key\n",
45
+ );
46
+ const config = {
47
+ organizationId: "org",
48
+ organizationSlug: "team",
49
+ projectId: "project",
50
+ projectSlug: "chat",
51
+ harnessId: "harness",
52
+ harnessOrigin: "https://harness.example.com",
53
+ apiOrigin: "https://project.example.com",
54
+ workspaceId: "hackathon",
55
+ backendUrl: "http://localhost:3000/api/chat",
56
+ };
57
+ await writeCloudProjectConfig(directory, config, "private-cloud-key");
58
+ expect(await readCloudProjectConfig(directory)).toEqual(config);
59
+ expect(
60
+ await readFile(
61
+ path.join(directory, ".assistant-ui", "cloud.json"),
62
+ "utf8",
63
+ ),
64
+ ).not.toContain("private-cloud-key");
65
+ expect(
66
+ await readFile(path.join(directory, ".env.local"), "utf8"),
67
+ ).toContain("OPENAI_API_KEY=existing-provider-key");
68
+ expect(
69
+ await readFile(path.join(directory, ".env.local"), "utf8"),
70
+ ).toContain('ASSISTANT_API_KEY="private-cloud-key"');
71
+ expect(
72
+ await readFile(path.join(directory, ".gitignore"), "utf8"),
73
+ ).toContain(".env.local");
74
+ if (process.platform !== "win32")
75
+ expect(
76
+ (await stat(path.join(directory, ".env.local"))).mode & 0o777,
77
+ ).toBe(0o600);
78
+ } finally {
79
+ await rm(directory, { recursive: true, force: true });
80
+ }
81
+ });
82
+ });
@@ -0,0 +1,138 @@
1
+ import {
2
+ mkdir,
3
+ readFile,
4
+ writeFile,
5
+ link,
6
+ rename,
7
+ rm,
8
+ stat,
9
+ } from "node:fs/promises";
10
+ import { randomUUID } from "node:crypto";
11
+ import path from "node:path";
12
+
13
+ export type CloudProjectConfig = {
14
+ organizationId: string;
15
+ organizationSlug: string;
16
+ projectId: string;
17
+ projectSlug: string;
18
+ harnessId: string;
19
+ harnessOrigin: string;
20
+ apiOrigin: string;
21
+ workspaceId: string;
22
+ backendUrl: string;
23
+ };
24
+
25
+ export const writeCloudFile = async (
26
+ file: string,
27
+ contents: string,
28
+ options: { exclusive?: boolean; mode?: number } = {},
29
+ ): Promise<void> => {
30
+ await mkdir(path.dirname(file), { recursive: true });
31
+ const temporary = `${file}.${randomUUID()}.tmp`;
32
+ try {
33
+ await writeFile(temporary, contents, {
34
+ flag: "wx",
35
+ mode: options.mode ?? 0o600,
36
+ });
37
+ if (options.exclusive) await link(temporary, file);
38
+ else await rename(temporary, file);
39
+ } finally {
40
+ await rm(temporary, { force: true });
41
+ }
42
+ };
43
+
44
+ export const cloudProjectConfigPath = (directory: string): string =>
45
+ path.join(directory, ".assistant-ui", "cloud.json");
46
+
47
+ export const readCloudProjectConfig = async (
48
+ directory: string,
49
+ ): Promise<CloudProjectConfig | null> => {
50
+ try {
51
+ return JSON.parse(
52
+ await readFile(cloudProjectConfigPath(directory), "utf8"),
53
+ ) as CloudProjectConfig;
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
56
+ throw error;
57
+ }
58
+ };
59
+
60
+ export const readOptionalFile = async (file: string): Promise<string> => {
61
+ try {
62
+ return await readFile(file, "utf8");
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
65
+ throw error;
66
+ }
67
+ };
68
+
69
+ export const mergeCloudEnvironment = (
70
+ contents: string,
71
+ values: Record<string, string>,
72
+ ): string => {
73
+ const lines = contents.split(/\r?\n/);
74
+ const pending = new Map(Object.entries(values));
75
+ for (const line of lines) {
76
+ const match = line.match(
77
+ /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/,
78
+ );
79
+ if (!match) continue;
80
+ const key = match[1]!;
81
+ const expected = values[key];
82
+ if (expected === undefined) continue;
83
+ const existing = match[2]!.trim();
84
+ const quoted = JSON.stringify(expected);
85
+ if (
86
+ existing !== expected &&
87
+ existing !== quoted &&
88
+ existing !== `'${expected}'`
89
+ ) {
90
+ throw new Error(
91
+ `${key} is already configured in .env.local. Keep your existing configuration or use a new directory.`,
92
+ );
93
+ }
94
+ pending.delete(key);
95
+ }
96
+ if (pending.size === 0) return contents;
97
+ const additions = [...pending].map(
98
+ ([key, value]) => `${key}=${JSON.stringify(value)}`,
99
+ );
100
+ return `${contents}${contents && !contents.endsWith("\n") ? "\n" : ""}${additions.join("\n")}\n`;
101
+ };
102
+
103
+ export const writeCloudProjectConfig = async (
104
+ directory: string,
105
+ config: CloudProjectConfig,
106
+ apiKey: string,
107
+ ): Promise<void> => {
108
+ const envFile = path.join(directory, ".env.local");
109
+ const contents = await readOptionalFile(envFile);
110
+ const environment = mergeCloudEnvironment(contents, {
111
+ ASSISTANT_API_KEY: apiKey,
112
+ NEXT_PUBLIC_ASSISTANT_BASE_URL: config.apiOrigin,
113
+ NEXT_PUBLIC_ASSISTANT_HARNESS_URL: config.harnessOrigin,
114
+ NEXT_PUBLIC_ASSISTANT_WORKSPACE_ID: config.workspaceId,
115
+ });
116
+ const gitignoreFile = path.join(directory, ".gitignore");
117
+ const gitignore = await readOptionalFile(gitignoreFile);
118
+ const ignored = gitignore.split(/\r?\n/).map((line) => line.trim());
119
+ if (!ignored.includes(".env.local") && !ignored.includes(".env*.local")) {
120
+ await writeCloudFile(
121
+ gitignoreFile,
122
+ `${gitignore}${gitignore && !gitignore.endsWith("\n") ? "\n" : ""}.env.local\n`,
123
+ {
124
+ mode: await stat(gitignoreFile)
125
+ .then((info) => info.mode & 0o777)
126
+ .catch((error: NodeJS.ErrnoException) => {
127
+ if (error.code === "ENOENT") return 0o644;
128
+ throw error;
129
+ }),
130
+ },
131
+ );
132
+ }
133
+ await writeCloudFile(envFile, environment);
134
+ await writeCloudFile(
135
+ cloudProjectConfigPath(directory),
136
+ `${JSON.stringify(config, null, 2)}\n`,
137
+ );
138
+ };
@@ -0,0 +1,132 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ provisionCloudHarness,
7
+ resolveProvisionRequest,
8
+ } from "./cloud-provision";
9
+
10
+ const directories: string[] = [];
11
+ afterEach(async () => {
12
+ vi.unstubAllGlobals();
13
+ await Promise.all(
14
+ directories
15
+ .splice(0)
16
+ .map((directory) => rm(directory, { recursive: true, force: true })),
17
+ );
18
+ });
19
+
20
+ const directory = async () => {
21
+ const dir = await mkdtemp(path.join(os.tmpdir(), "assistant-ui-provision-"));
22
+ directories.push(dir);
23
+ return dir;
24
+ };
25
+
26
+ const input = {
27
+ org_id: "org-1",
28
+ project_id: "project-1",
29
+ harness_name: "Team chat",
30
+ backend_urls: ["http://localhost:3000/api/chat"],
31
+ allow_localhost: true,
32
+ };
33
+
34
+ describe("cloud provisioning", () => {
35
+ it("replays the same identity after a failed request without persisting an access code", async () => {
36
+ const dir = await directory();
37
+ const first = await resolveProvisionRequest(
38
+ dir,
39
+ input,
40
+ "private-access-code",
41
+ );
42
+ const second = await resolveProvisionRequest(dir, input);
43
+ expect(second.idempotency_key).toBe(first.idempotency_key);
44
+ expect(
45
+ await readFile(path.join(dir, ".assistant-ui", "provision.json"), "utf8"),
46
+ ).not.toContain("private-access-code");
47
+ });
48
+
49
+ it("refuses to reuse a pending setup identity for another project", async () => {
50
+ const dir = await directory();
51
+ await resolveProvisionRequest(dir, input);
52
+ await expect(
53
+ resolveProvisionRequest(dir, { ...input, project_id: "different" }),
54
+ ).rejects.toThrow("pending setup");
55
+ });
56
+
57
+ it("sends the OAuth token only in the authorization header", async () => {
58
+ const request = { ...input, idempotency_key: "request-id" };
59
+ const result = {
60
+ project_id: input.project_id,
61
+ workspace_id: "workspace",
62
+ harness_id: "harness",
63
+ harness_origin: "https://harness.example.com",
64
+ api_origin: "https://project.example.com",
65
+ api_key: "server-key",
66
+ };
67
+ const fetch = vi.fn().mockResolvedValue(Response.json(result));
68
+ vi.stubGlobal("fetch", fetch);
69
+ expect(
70
+ await provisionCloudHarness(
71
+ "https://cloud.example.com/",
72
+ "private-token",
73
+ request,
74
+ ),
75
+ ).toEqual(result);
76
+ expect(fetch).toHaveBeenCalledWith(
77
+ "https://cloud.example.com/api/cli/provision",
78
+ expect.objectContaining({
79
+ headers: expect.objectContaining({
80
+ Authorization: "Bearer private-token",
81
+ }),
82
+ body: JSON.stringify(request),
83
+ }),
84
+ );
85
+ expect(fetch.mock.calls[0]![0]).not.toContain("private-token");
86
+ expect(fetch.mock.calls[0]![1].body).not.toContain("private-token");
87
+ });
88
+
89
+ it("rejects provisioning data that belongs to a different project", async () => {
90
+ vi.stubGlobal(
91
+ "fetch",
92
+ vi.fn().mockResolvedValue(
93
+ Response.json({
94
+ project_id: "other",
95
+ workspace_id: "workspace",
96
+ harness_id: "harness",
97
+ harness_origin: "https://harness.example.com",
98
+ api_origin: "https://project.example.com",
99
+ api_key: "server-key",
100
+ }),
101
+ ),
102
+ );
103
+ await expect(
104
+ provisionCloudHarness("https://cloud.example.com", "token", {
105
+ ...input,
106
+ idempotency_key: "id",
107
+ }),
108
+ ).rejects.toThrow("incomplete setup response");
109
+ });
110
+
111
+ it("rejects malformed credentials instead of writing invalid app settings", async () => {
112
+ vi.stubGlobal(
113
+ "fetch",
114
+ vi.fn().mockResolvedValue(
115
+ Response.json({
116
+ project_id: input.project_id,
117
+ workspace_id: "workspace",
118
+ harness_id: "harness",
119
+ harness_origin: "https://harness.example.com",
120
+ api_origin: "https://project.example.com",
121
+ api_key: 123,
122
+ }),
123
+ ),
124
+ );
125
+ await expect(
126
+ provisionCloudHarness("https://cloud.example.com", "token", {
127
+ ...input,
128
+ idempotency_key: "id",
129
+ }),
130
+ ).rejects.toThrow("incomplete setup response");
131
+ });
132
+ });