assistant-ui 0.0.125 → 0.0.127

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 (84) hide show
  1. package/README.md +18 -2
  2. package/dist/codemods/utils/createTransformer.js +1 -2
  3. package/dist/codemods/utils/resolveBinding.js +1 -2
  4. package/dist/codemods/v0-11/content-part-to-message-part.js +1 -2
  5. package/dist/codemods/v0-12/assistant-api-to-aui.js +1 -2
  6. package/dist/codemods/v0-12/event-names-to-camelcase.js +1 -2
  7. package/dist/codemods/v0-12/primitive-if-to-aui-if.js +1 -2
  8. package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js +1 -2
  9. package/dist/codemods/v0-8/ui-package-split.js +1 -2
  10. package/dist/codemods/v0-9/edge-package-split.js +1 -2
  11. package/dist/commands/add.js +1 -2
  12. package/dist/commands/agent.js +1 -2
  13. package/dist/commands/cloud.d.ts.map +1 -1
  14. package/dist/commands/cloud.js +70 -31
  15. package/dist/commands/cloud.js.map +1 -1
  16. package/dist/commands/create.js +1 -2
  17. package/dist/commands/doctor.js +1 -2
  18. package/dist/commands/info.js +1 -2
  19. package/dist/commands/init.js +1 -2
  20. package/dist/commands/mcp.js +1 -2
  21. package/dist/commands/update.js +1 -2
  22. package/dist/commands/upgrade.js +1 -2
  23. package/dist/index.js +1 -2
  24. package/dist/lib/agent-skill.d.ts +2 -2
  25. package/dist/lib/agent-skill.js +2 -3
  26. package/dist/lib/agent-skill.js.map +1 -1
  27. package/dist/lib/cloud-abort.js +1 -2
  28. package/dist/lib/cloud-auth.d.ts +1 -0
  29. package/dist/lib/cloud-auth.d.ts.map +1 -1
  30. package/dist/lib/cloud-auth.js +2 -3
  31. package/dist/lib/cloud-auth.js.map +1 -1
  32. package/dist/lib/cloud-config.d.ts +11 -1
  33. package/dist/lib/cloud-config.d.ts.map +1 -1
  34. package/dist/lib/cloud-config.js +52 -16
  35. package/dist/lib/cloud-config.js.map +1 -1
  36. package/dist/lib/cloud-provision.d.ts.map +1 -1
  37. package/dist/lib/cloud-provision.js +7 -3
  38. package/dist/lib/cloud-provision.js.map +1 -1
  39. package/dist/lib/cloud-remote.d.ts +16 -1
  40. package/dist/lib/cloud-remote.d.ts.map +1 -1
  41. package/dist/lib/cloud-remote.js +53 -9
  42. package/dist/lib/cloud-remote.js.map +1 -1
  43. package/dist/lib/cloud-setup-login.js +1 -2
  44. package/dist/lib/cloud-url.js +1 -2
  45. package/dist/lib/create-project.js +1 -2
  46. package/dist/lib/handle-cli-error.js +1 -2
  47. package/dist/lib/install-ai-sdk-lib.js +1 -2
  48. package/dist/lib/install-edge-lib.js +1 -2
  49. package/dist/lib/run-spawn.js +1 -2
  50. package/dist/lib/safe-project-file.d.ts +2 -0
  51. package/dist/lib/safe-project-file.d.ts.map +1 -0
  52. package/dist/lib/safe-project-file.js +25 -0
  53. package/dist/lib/safe-project-file.js.map +1 -0
  54. package/dist/lib/transform.js +1 -2
  55. package/dist/lib/upgrade.js +1 -2
  56. package/dist/lib/utils/config.js +1 -2
  57. package/dist/lib/utils/download.js +1 -2
  58. package/dist/lib/utils/file-scanner.js +1 -2
  59. package/dist/lib/utils/logger.js +1 -2
  60. package/dist/lib/utils/package-installer.js +1 -2
  61. package/dist/lib/utils/package-json.js +1 -2
  62. package/dist/lib/utils/package-manager.js +1 -2
  63. package/dist/lib/utils/registry.js +1 -2
  64. package/dist/lib/utils/workspace.js +1 -2
  65. package/dist/program.js +1 -2
  66. package/dist/run.js +1 -2
  67. package/package.json +7 -7
  68. package/src/commands/cloud.test.ts +156 -0
  69. package/src/commands/cloud.ts +93 -41
  70. package/src/commands/mcp.test.ts +123 -0
  71. package/src/lib/agent-skill.ts +1 -1
  72. package/src/lib/cloud-auth.ts +1 -1
  73. package/src/lib/cloud-config.test.ts +139 -1
  74. package/src/lib/cloud-config.ts +59 -8
  75. package/src/lib/cloud-provision.test.ts +26 -1
  76. package/src/lib/cloud-provision.ts +3 -0
  77. package/src/lib/cloud-remote.test.ts +99 -3
  78. package/src/lib/cloud-remote.ts +93 -7
  79. package/src/lib/safe-project-file.ts +31 -0
  80. package/templates/cloud-harness/components/assistant-ui/elements/attachment-state.ts +59 -0
  81. package/templates/cloud-harness/components/assistant-ui/elements/attachment.aui.tsx +11 -44
  82. package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +1 -1
  83. package/templates/cloud-harness/package.json +12 -12
  84. package/templates/cloud-harness/.gitignore +0 -42
@@ -1,12 +1,6 @@
1
1
  import { Command, Option } from "commander";
2
2
  import * as p from "@clack/prompts";
3
- import {
4
- createProject,
5
- fetchOrganizations,
6
- fetchProjects,
7
- type AccountsOrganization,
8
- type AccountsProject,
9
- } from "aui-auth/accounts";
3
+ import { fetchOrganizations, fetchProjects } from "aui-auth/accounts";
10
4
  import { access, readFile } from "node:fs/promises";
11
5
  import path from "node:path";
12
6
  import { fileURLToPath } from "node:url";
@@ -15,11 +9,13 @@ import {
15
9
  cloudAuthConfig,
16
10
  loginToCloud,
17
11
  logoutFromCloud,
12
+ openBrowser,
18
13
  savedCloudAccessToken,
19
14
  validateCloudUrl,
20
15
  } from "../lib/cloud-auth";
21
- import { runCloudCommand } from "../lib/cloud-remote";
16
+ import { runCloudCommand, sendCloudCommand } from "../lib/cloud-remote";
22
17
  import {
18
+ findCloudProjectDefaults,
23
19
  readCloudProjectConfig,
24
20
  mergeCloudEnvironment,
25
21
  readOptionalFile,
@@ -94,6 +90,44 @@ const selectResource = async <
94
90
  return resources.find((resource) => resource.id === id)!;
95
91
  };
96
92
 
93
+ type SetupResource = { id: string; slug: string; name: string };
94
+
95
+ /**
96
+ * Runs a Cloud command for setup and returns its `--json` output, so setup
97
+ * creates organizations and projects with the same checks and audit rows as
98
+ * `assistant-ui cloud orgs create` and `projects create`.
99
+ */
100
+ const cloudJson = async <T>(
101
+ apiUrl: string,
102
+ token: string,
103
+ args: string[],
104
+ ): Promise<{ ok: true; value: T } | { ok: false; message: string }> => {
105
+ const response = await sendCloudCommand(apiUrl, [...args, "--json"], token);
106
+ if (response.exitCode !== 0)
107
+ return {
108
+ ok: false,
109
+ message: (response.stderr ?? "").trim() || `${args.join(" ")} failed.`,
110
+ };
111
+ let value: unknown;
112
+ try {
113
+ value = JSON.parse(response.stdout ?? "");
114
+ } catch {
115
+ value = null;
116
+ }
117
+ const resource = value as Partial<SetupResource> | null;
118
+ if (
119
+ !resource ||
120
+ !(["id", "slug", "name"] as const).every(
121
+ (key) => typeof resource[key] === "string" && resource[key].trim() !== "",
122
+ )
123
+ )
124
+ return {
125
+ ok: false,
126
+ message: `Unexpected response from \`${args.join(" ")} --json\`. Update assistant-ui and try again.`,
127
+ };
128
+ return { ok: true, value: value as T };
129
+ };
130
+
97
131
  const projectSlug = (value: string): string =>
98
132
  value
99
133
  .toLowerCase()
@@ -104,7 +138,7 @@ const organizationForSetup = async (
104
138
  issuer: string,
105
139
  token: string,
106
140
  options: SetupOptions,
107
- ): Promise<AccountsOrganization> => {
141
+ ): Promise<SetupResource> => {
108
142
  const organizations = await fetchOrganizations({ issuer }, token);
109
143
  if (!options.newOrg) {
110
144
  const existing = await selectResource(
@@ -136,28 +170,25 @@ const organizationForSetup = async (
136
170
  (organization) => organization.slug === slug,
137
171
  );
138
172
  if (existing) return existing;
139
- const response = await fetch(`${issuer}/api/oidc/organizations`, {
140
- method: "POST",
141
- headers: {
142
- Authorization: `Bearer ${token}`,
143
- "Content-Type": "application/json",
144
- },
145
- body: JSON.stringify({ name: options.name ?? slug, slug }),
146
- signal: AbortSignal.timeout(30_000),
147
- redirect: "error",
148
- });
149
- if (!response.ok)
150
- throw new Error(`Could not create organization (${response.status}).`);
151
- return (await response.json()) as AccountsOrganization;
173
+ const created = await cloudJson<SetupResource>(options.apiUrl, token, [
174
+ "orgs",
175
+ "create",
176
+ slug,
177
+ "--name",
178
+ options.name ?? slug,
179
+ ]);
180
+ if (!created.ok)
181
+ throw new Error(`Could not create organization: ${created.message}`);
182
+ return created.value;
152
183
  };
153
184
 
154
185
  const projectForSetup = async (
155
186
  issuer: string,
156
187
  token: string,
157
- organization: AccountsOrganization,
188
+ organization: SetupResource,
158
189
  directory: string,
159
190
  options: SetupOptions,
160
- ): Promise<AccountsProject> => {
191
+ ): Promise<SetupResource> => {
161
192
  const projects = await fetchProjects({ issuer }, token, {
162
193
  id: organization.id,
163
194
  });
@@ -173,23 +204,25 @@ const projectForSetup = async (
173
204
  const slug = options.newProject ?? projectSlug(path.basename(directory));
174
205
  if (!slug)
175
206
  throw new Error("Choose a project slug with --new-project <slug>.");
176
- const created = await createProject(
177
- { issuer },
178
- token,
179
- { id: organization.id },
180
- {
181
- name: options.name ?? path.basename(directory),
182
- slug,
183
- },
184
- );
185
- if ("reason" in created) {
186
- const existing = projects.find((project) => project.slug === slug);
187
- if (existing) return existing;
188
- throw new Error(
189
- `Project slug "${slug}" already exists. Select it with --project or choose another slug.`,
190
- );
207
+ const existing = projects.find((project) => project.slug === slug);
208
+ if (existing) return existing;
209
+ const created = await cloudJson<SetupResource>(options.apiUrl, token, [
210
+ "projects",
211
+ "create",
212
+ slug,
213
+ "--name",
214
+ options.name ?? path.basename(directory),
215
+ "--org",
216
+ organization.id,
217
+ ]);
218
+ if (!created.ok) {
219
+ if (/already exists/.test(created.message))
220
+ throw new Error(
221
+ `Project slug "${slug}" already exists. Select it with --project or choose another slug.`,
222
+ );
223
+ throw new Error(`Could not create project: ${created.message}`);
191
224
  }
192
- return created;
225
+ return created.value;
193
226
  };
194
227
 
195
228
  const scaffoldCloudProject = async (
@@ -379,13 +412,32 @@ export const cloud = new Command()
379
412
  .description("manage Assistant Cloud (run `assistant-ui cloud help`)")
380
413
  .helpOption(false)
381
414
  .helpCommand(false)
415
+ // `cloud login --no-open` is login's own flag; `cloud <remote> --no-open`
416
+ // is this one; Commander forwards tokens after `--` without the separator.
417
+ .enablePositionalOptions()
418
+ .option("--no-open", "print dashboard links without opening a browser")
382
419
  .argument("[command...]")
383
420
  .allowUnknownOption()
384
421
  .allowExcessArguments()
385
- .action(async (args: string[]) => {
422
+ .action(async (args: string[], options: { open?: boolean }) => {
423
+ // The server's help knows the local commands only by name; their flags
424
+ // live here, so `help <local command>` is answered on this machine.
425
+ const local =
426
+ args[0] === "help" && args.length === 2
427
+ ? cloud.commands.find((command) => command.name() === args[1])
428
+ : undefined;
429
+ if (local) {
430
+ process.stdout.write(local.helpInformation());
431
+ return;
432
+ }
386
433
  process.exitCode = await runCloudCommand(args, {
387
434
  cloudUrl: process.env.ASSISTANT_UI_CLOUD_URL ?? DEFAULT_CLOUD_URL,
388
435
  accessToken: () => savedCloudAccessToken(cloudAuthConfig()),
436
+ defaults: () => findCloudProjectDefaults(process.cwd()),
437
+ interactive:
438
+ !!process.stdin.isTTY && !!process.stderr.isTTY && !process.env.CI,
439
+ noOpen: options.open === false,
440
+ openUrl: openBrowser,
389
441
  stdout: (text) => process.stdout.write(text),
390
442
  stderr: (text) => process.stderr.write(text),
391
443
  });
@@ -0,0 +1,123 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { mcp } from "./mcp";
6
+
7
+ const directories: string[] = [];
8
+ afterEach(() => {
9
+ for (const directory of directories.splice(0)) {
10
+ fs.rmSync(directory, { recursive: true, force: true });
11
+ }
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ const fixture = () => {
16
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "assistant-ui-mcp-"));
17
+ directories.push(directory);
18
+ vi.spyOn(process, "cwd").mockReturnValue(directory);
19
+ vi.spyOn(console, "log").mockImplementation(() => {});
20
+ vi.spyOn(console, "error").mockImplementation(() => {});
21
+ vi.spyOn(process, "exit").mockImplementation(() => {
22
+ throw new Error("exit");
23
+ });
24
+ return directory;
25
+ };
26
+
27
+ describe("mcp project configuration", () => {
28
+ it.each([
29
+ [
30
+ "--cursor",
31
+ ".cursor",
32
+ "mcpServers",
33
+ { url: "https://www.assistant-ui.com/mcp" },
34
+ ],
35
+ [
36
+ "--vscode",
37
+ ".vscode",
38
+ "servers",
39
+ { type: "http", url: "https://www.assistant-ui.com/mcp" },
40
+ ],
41
+ ])(
42
+ "writes %s configuration through a linked directory",
43
+ async (flag, name, key, server) => {
44
+ const directory = fixture();
45
+ const shared = fs.mkdtempSync(
46
+ path.join(os.tmpdir(), "assistant-ui-mcp-shared-"),
47
+ );
48
+ directories.push(shared);
49
+ fs.symlinkSync(shared, path.join(directory, name), "dir");
50
+ await mcp.parseAsync([flag], { from: "user" });
51
+ expect(
52
+ JSON.parse(fs.readFileSync(path.join(shared, "mcp.json"), "utf8")),
53
+ ).toEqual({
54
+ [key]: { "assistant-ui": server },
55
+ });
56
+ },
57
+ );
58
+
59
+ it("writes regular and missing project configuration files", async () => {
60
+ const directory = fixture();
61
+ await mcp.parseAsync(["--cursor"], { from: "user" });
62
+ const file = path.join(directory, ".cursor", "mcp.json");
63
+ expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
64
+ mcpServers: {
65
+ "assistant-ui": { url: "https://www.assistant-ui.com/mcp" },
66
+ },
67
+ });
68
+ fs.writeFileSync(file, '{"other":true}\n');
69
+ await mcp.parseAsync(["--cursor"], { from: "user" });
70
+ expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
71
+ other: true,
72
+ mcpServers: {
73
+ "assistant-ui": { url: "https://www.assistant-ui.com/mcp" },
74
+ },
75
+ });
76
+ });
77
+
78
+ it("writes a home configuration through its existing directory link", async () => {
79
+ fixture();
80
+ const home = fs.mkdtempSync(
81
+ path.join(os.tmpdir(), "assistant-ui-mcp-home-"),
82
+ );
83
+ const configDir = fs.mkdtempSync(
84
+ path.join(os.tmpdir(), "assistant-ui-mcp-config-"),
85
+ );
86
+ directories.push(home, configDir);
87
+ vi.spyOn(os, "homedir").mockReturnValue(home);
88
+ fs.symlinkSync(configDir, path.join(home, ".codeium"), "dir");
89
+ fs.mkdirSync(path.join(configDir, "windsurf"));
90
+ await mcp.parseAsync(["--windsurf"], { from: "user" });
91
+ const file = path.join(configDir, "windsurf", "mcp_config.json");
92
+ expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({
93
+ mcpServers: {
94
+ "assistant-ui": { serverUrl: "https://www.assistant-ui.com/mcp" },
95
+ },
96
+ });
97
+ });
98
+
99
+ it("writes a linked home configuration file through its link", async () => {
100
+ fixture();
101
+ const home = fs.mkdtempSync(
102
+ path.join(os.tmpdir(), "assistant-ui-mcp-home-"),
103
+ );
104
+ const dotfiles = fs.mkdtempSync(
105
+ path.join(os.tmpdir(), "assistant-ui-mcp-dotfiles-"),
106
+ );
107
+ directories.push(home, dotfiles);
108
+ vi.spyOn(os, "homedir").mockReturnValue(home);
109
+ const target = path.join(dotfiles, "mcp_config.json");
110
+ fs.writeFileSync(target, '{"other":true}\n');
111
+ fs.mkdirSync(path.join(home, ".codeium", "windsurf"), { recursive: true });
112
+ const file = path.join(home, ".codeium", "windsurf", "mcp_config.json");
113
+ fs.symlinkSync(target, file);
114
+ await mcp.parseAsync(["--windsurf"], { from: "user" });
115
+ expect(fs.lstatSync(file).isSymbolicLink()).toBe(true);
116
+ expect(JSON.parse(fs.readFileSync(target, "utf8"))).toEqual({
117
+ other: true,
118
+ mcpServers: {
119
+ "assistant-ui": { serverUrl: "https://www.assistant-ui.com/mcp" },
120
+ },
121
+ });
122
+ });
123
+ });
@@ -5,7 +5,7 @@ import { withStagedDownload } from "./utils/download";
5
5
  import { dlxCommand, type PackageManagerName } from "./utils/package-manager";
6
6
 
7
7
  export const SKILLS_PACKAGE = "assistant-ui/skills";
8
- export const SKILLS_COMMIT = "1ba8e9105c504fe4ac2591e432783872d6b3c8ba";
8
+ export const SKILLS_COMMIT = "b0609e66baff7e19f21053d5492464dcc22ec9c3";
9
9
  export const SKILLS_PLUGIN_SOURCE = `gh:${SKILLS_PACKAGE}/assistant-ui#${SKILLS_COMMIT}`;
10
10
 
11
11
  export function resolveSkillsInstall(params: {
@@ -133,7 +133,7 @@ export const saveCloudCredentials = async (
133
133
  }
134
134
  };
135
135
 
136
- const openBrowser = (url: string): void => {
136
+ export const openBrowser = (url: string): void => {
137
137
  const command =
138
138
  process.platform === "darwin"
139
139
  ? "open"
@@ -1,14 +1,114 @@
1
- import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import {
2
+ chmod,
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ rm,
7
+ stat,
8
+ symlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
2
11
  import os from "node:os";
3
12
  import path from "node:path";
4
13
  import { describe, expect, it } from "vitest";
5
14
  import {
15
+ findCloudProjectDefaults,
6
16
  mergeCloudEnvironment,
7
17
  readCloudProjectConfig,
18
+ writeCloudFile,
8
19
  writeCloudProjectConfig,
20
+ type CloudProjectConfig,
9
21
  } from "./cloud-config";
10
22
 
11
23
  describe("cloud environment", () => {
24
+ const tempDir = async () => {
25
+ const directory = await mkdtemp(
26
+ path.join(os.tmpdir(), "assistant-ui-cloud-"),
27
+ );
28
+ return directory;
29
+ };
30
+
31
+ it("refuses a symbolic link and preserves its target", async () => {
32
+ const directory = await tempDir();
33
+ try {
34
+ const target = path.join(directory, "target");
35
+ const file = path.join(directory, ".env.local");
36
+ await writeFile(target, "original\n");
37
+ await chmod(target, 0o644);
38
+ await symlink(target, file);
39
+ await expect(
40
+ writeCloudFile(file, "replacement\n", { projectRoot: directory }),
41
+ ).rejects.toThrow(`${file} is a symbolic link`);
42
+ expect(await readFile(target, "utf8")).toBe("original\n");
43
+ expect((await stat(target)).mode & 0o777).toBe(0o644);
44
+ } finally {
45
+ await rm(directory, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ it("refuses a parent directory linked outside the project", async () => {
50
+ const directory = await tempDir();
51
+ const outside = await tempDir();
52
+ try {
53
+ await symlink(outside, path.join(directory, ".assistant-ui"), "dir");
54
+ await expect(
55
+ writeCloudFile(
56
+ path.join(directory, ".assistant-ui", "cloud.json"),
57
+ "secret",
58
+ { projectRoot: directory },
59
+ ),
60
+ ).rejects.toThrow("outside the project");
61
+ await expect(
62
+ readFile(path.join(outside, "cloud.json"), "utf8"),
63
+ ).rejects.toMatchObject({ code: "ENOENT" });
64
+ } finally {
65
+ await rm(directory, { recursive: true, force: true });
66
+ await rm(outside, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ it("writes regular and missing files", async () => {
71
+ const directory = await tempDir();
72
+ try {
73
+ const file = path.join(directory, ".assistant-ui", "cloud.json");
74
+ await mkdir(path.dirname(file));
75
+ await writeCloudFile(file, "first\n", { projectRoot: directory });
76
+ await writeCloudFile(file, "second\n", { projectRoot: directory });
77
+ expect(await readFile(file, "utf8")).toBe("second\n");
78
+ } finally {
79
+ await rm(directory, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ it("refuses a linked environment file before reading cloud configuration", async () => {
84
+ const directory = await tempDir();
85
+ try {
86
+ const target = path.join(directory, "target");
87
+ const file = path.join(directory, ".env.local");
88
+ await writeFile(target, "ORIGINAL=1\n");
89
+ await chmod(target, 0o644);
90
+ await symlink(target, file);
91
+ const config: CloudProjectConfig = {
92
+ organizationId: "org",
93
+ organizationSlug: "team",
94
+ projectId: "project",
95
+ projectSlug: "chat",
96
+ harnessId: "harness",
97
+ harnessOrigin: "https://harness.example.com",
98
+ apiOrigin: "https://project.example.com",
99
+ workspaceId: "workspace",
100
+ backendUrl: "http://localhost:3000/api/chat",
101
+ };
102
+ await expect(
103
+ writeCloudProjectConfig(directory, config, "private-key"),
104
+ ).rejects.toThrow(`${file} is a symbolic link`);
105
+ expect(await readFile(target, "utf8")).toBe("ORIGINAL=1\n");
106
+ expect((await stat(target)).mode & 0o777).toBe(0o644);
107
+ } finally {
108
+ await rm(directory, { recursive: true, force: true });
109
+ }
110
+ });
111
+
12
112
  it("preserves custom settings and repeated configuration", () => {
13
113
  const original = "# My settings\nOPENAI_API_KEY=my-existing-key\n";
14
114
  const merged = mergeCloudEnvironment(original, {
@@ -79,4 +179,42 @@ describe("cloud environment", () => {
79
179
  await rm(directory, { recursive: true, force: true });
80
180
  }
81
181
  });
182
+
183
+ it("finds the nearest cloud.json's org and project for remote commands", async () => {
184
+ const root = await tempDir();
185
+ try {
186
+ const nested = path.join(root, "app", "src", "deep");
187
+ await mkdir(nested, { recursive: true });
188
+ await mkdir(path.join(root, "app", ".assistant-ui"));
189
+ await writeFile(
190
+ path.join(root, "app", ".assistant-ui", "cloud.json"),
191
+ JSON.stringify({ organizationId: "org_1", projectId: "proj_1" }),
192
+ );
193
+ await mkdir(path.join(root, ".assistant-ui"));
194
+ await writeFile(
195
+ path.join(root, ".assistant-ui", "cloud.json"),
196
+ JSON.stringify({
197
+ organizationId: "org_outer",
198
+ projectId: "proj_outer",
199
+ }),
200
+ );
201
+ await expect(findCloudProjectDefaults(nested)).resolves.toEqual({
202
+ org: "org_1",
203
+ project: "proj_1",
204
+ });
205
+ await expect(findCloudProjectDefaults(root)).resolves.toEqual({
206
+ org: "org_outer",
207
+ project: "proj_outer",
208
+ });
209
+ await rm(path.join(root, ".assistant-ui"), { recursive: true });
210
+ await expect(findCloudProjectDefaults(root)).resolves.toBeNull();
211
+ await writeFile(
212
+ path.join(root, "app", ".assistant-ui", "cloud.json"),
213
+ "{not json",
214
+ );
215
+ await expect(findCloudProjectDefaults(nested)).resolves.toBeNull();
216
+ } finally {
217
+ await rm(root, { recursive: true, force: true });
218
+ }
219
+ });
82
220
  });
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  mkdir,
3
+ open,
3
4
  readFile,
4
- writeFile,
5
5
  link,
6
6
  rename,
7
7
  rm,
8
8
  stat,
9
9
  } from "node:fs/promises";
10
+ import { constants } from "node:fs";
10
11
  import { randomUUID } from "node:crypto";
11
12
  import path from "node:path";
13
+ import { assertSafeProjectFile } from "./safe-project-file";
12
14
 
13
15
  export type CloudProjectConfig = {
14
16
  organizationId: string;
@@ -25,15 +27,28 @@ export type CloudProjectConfig = {
25
27
  export const writeCloudFile = async (
26
28
  file: string,
27
29
  contents: string,
28
- options: { exclusive?: boolean; mode?: number } = {},
30
+ options: { exclusive?: boolean; mode?: number; projectRoot: string },
29
31
  ): Promise<void> => {
32
+ const { projectRoot } = options;
33
+ assertSafeProjectFile(file, projectRoot);
30
34
  await mkdir(path.dirname(file), { recursive: true });
35
+ assertSafeProjectFile(file, projectRoot);
31
36
  const temporary = `${file}.${randomUUID()}.tmp`;
32
37
  try {
33
- await writeFile(temporary, contents, {
34
- flag: "wx",
35
- mode: options.mode ?? 0o600,
36
- });
38
+ const handle = await open(
39
+ temporary,
40
+ constants.O_WRONLY |
41
+ constants.O_CREAT |
42
+ constants.O_EXCL |
43
+ constants.O_NOFOLLOW,
44
+ options.mode ?? 0o600,
45
+ );
46
+ try {
47
+ await handle.writeFile(contents);
48
+ } finally {
49
+ await handle.close();
50
+ }
51
+ assertSafeProjectFile(file, projectRoot);
37
52
  if (options.exclusive) await link(temporary, file);
38
53
  else await rename(temporary, file);
39
54
  } finally {
@@ -57,6 +72,37 @@ export const readCloudProjectConfig = async (
57
72
  }
58
73
  };
59
74
 
75
+ /**
76
+ * The org and project `setup` configured for this directory or the nearest
77
+ * parent with an `.assistant-ui/cloud.json`, for remote commands to use when
78
+ * no --org or --project is given. A missing or unreadable file means none.
79
+ */
80
+ export const findCloudProjectDefaults = async (
81
+ directory: string,
82
+ ): Promise<{ org?: string; project?: string } | null> => {
83
+ for (let current = path.resolve(directory); ;) {
84
+ try {
85
+ const config = JSON.parse(
86
+ await readFile(cloudProjectConfigPath(current), "utf8"),
87
+ ) as Partial<CloudProjectConfig> | null;
88
+ const org =
89
+ typeof config?.organizationId === "string"
90
+ ? config.organizationId
91
+ : undefined;
92
+ const project =
93
+ typeof config?.projectId === "string" ? config.projectId : undefined;
94
+ return org || project
95
+ ? { ...(org && { org }), ...(project && { project }) }
96
+ : null;
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null;
99
+ }
100
+ const parent = path.dirname(current);
101
+ if (parent === current) return null;
102
+ current = parent;
103
+ }
104
+ };
105
+
60
106
  export const readOptionalFile = async (file: string): Promise<string> => {
61
107
  try {
62
108
  return await readFile(file, "utf8");
@@ -106,6 +152,10 @@ export const writeCloudProjectConfig = async (
106
152
  apiKey: string,
107
153
  ): Promise<void> => {
108
154
  const envFile = path.join(directory, ".env.local");
155
+ const gitignoreFile = path.join(directory, ".gitignore");
156
+ assertSafeProjectFile(envFile, directory);
157
+ assertSafeProjectFile(gitignoreFile, directory);
158
+ assertSafeProjectFile(cloudProjectConfigPath(directory), directory);
109
159
  const contents = await readOptionalFile(envFile);
110
160
  const environment = mergeCloudEnvironment(contents, {
111
161
  ASSISTANT_API_KEY: apiKey,
@@ -113,7 +163,6 @@ export const writeCloudProjectConfig = async (
113
163
  NEXT_PUBLIC_ASSISTANT_HARNESS_URL: config.harnessOrigin,
114
164
  NEXT_PUBLIC_ASSISTANT_WORKSPACE_ID: config.workspaceId,
115
165
  });
116
- const gitignoreFile = path.join(directory, ".gitignore");
117
166
  const gitignore = await readOptionalFile(gitignoreFile);
118
167
  const ignored = gitignore.split(/\r?\n/).map((line) => line.trim());
119
168
  if (!ignored.includes(".env.local") && !ignored.includes(".env*.local")) {
@@ -121,6 +170,7 @@ export const writeCloudProjectConfig = async (
121
170
  gitignoreFile,
122
171
  `${gitignore}${gitignore && !gitignore.endsWith("\n") ? "\n" : ""}.env.local\n`,
123
172
  {
173
+ projectRoot: directory,
124
174
  mode: await stat(gitignoreFile)
125
175
  .then((info) => info.mode & 0o777)
126
176
  .catch((error: NodeJS.ErrnoException) => {
@@ -130,9 +180,10 @@ export const writeCloudProjectConfig = async (
130
180
  },
131
181
  );
132
182
  }
133
- await writeCloudFile(envFile, environment);
183
+ await writeCloudFile(envFile, environment, { projectRoot: directory });
134
184
  await writeCloudFile(
135
185
  cloudProjectConfigPath(directory),
136
186
  `${JSON.stringify(config, null, 2)}\n`,
187
+ { projectRoot: directory },
137
188
  );
138
189
  };
@@ -1,4 +1,13 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
1
+ import {
2
+ chmod,
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ rm,
7
+ stat,
8
+ symlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
2
11
  import os from "node:os";
3
12
  import path from "node:path";
4
13
  import { afterEach, describe, expect, it, vi } from "vitest";
@@ -32,6 +41,22 @@ const input = {
32
41
  };
33
42
 
34
43
  describe("cloud provisioning", () => {
44
+ it("refuses a linked pending request without changing its target", async () => {
45
+ const dir = await directory();
46
+ const configDir = path.join(dir, ".assistant-ui");
47
+ await mkdir(configDir);
48
+ const target = path.join(dir, "target.json");
49
+ const file = path.join(configDir, "provision.json");
50
+ await writeFile(target, "original\n");
51
+ await chmod(target, 0o644);
52
+ await symlink(target, file);
53
+ await expect(resolveProvisionRequest(dir, input)).rejects.toThrow(
54
+ `${file} is a symbolic link`,
55
+ );
56
+ expect(await readFile(target, "utf8")).toBe("original\n");
57
+ expect((await stat(target)).mode & 0o777).toBe(0o644);
58
+ });
59
+
35
60
  it("replays the same identity after a failed request without persisting an access code", async () => {
36
61
  const dir = await directory();
37
62
  const first = await resolveProvisionRequest(
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { readOptionalFile, writeCloudFile } from "./cloud-config";
4
+ import { assertSafeProjectFile } from "./safe-project-file";
4
5
  import { validateCloudUrl } from "./cloud-auth";
5
6
 
6
7
  export type CloudProvisionRequest = {
@@ -28,6 +29,7 @@ export const resolveProvisionRequest = async (
28
29
  accessCode?: string,
29
30
  ): Promise<CloudProvisionRequest> => {
30
31
  const file = path.join(directory, ".assistant-ui", "provision.json");
32
+ assertSafeProjectFile(file, directory);
31
33
  const previous = await readOptionalFile(file);
32
34
  let request: CloudProvisionRequest;
33
35
  if (previous) {
@@ -42,6 +44,7 @@ export const resolveProvisionRequest = async (
42
44
  request = { ...input, idempotency_key: randomUUID() };
43
45
  await writeCloudFile(file, `${JSON.stringify(request, null, 2)}\n`, {
44
46
  exclusive: true,
47
+ projectRoot: directory,
45
48
  });
46
49
  }
47
50
  return { ...request, ...(accessCode ? { access_code: accessCode } : {}) };