assistant-ui 0.0.126 → 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.
- package/README.md +18 -2
- package/dist/codemods/utils/createTransformer.js +1 -2
- package/dist/codemods/utils/resolveBinding.js +1 -2
- package/dist/codemods/v0-11/content-part-to-message-part.js +1 -2
- package/dist/codemods/v0-12/assistant-api-to-aui.js +1 -2
- package/dist/codemods/v0-12/event-names-to-camelcase.js +1 -2
- package/dist/codemods/v0-12/primitive-if-to-aui-if.js +1 -2
- package/dist/codemods/v0-15/aui-accessor-calls-to-properties.js +1 -2
- package/dist/codemods/v0-8/ui-package-split.js +1 -2
- package/dist/codemods/v0-9/edge-package-split.js +1 -2
- package/dist/commands/add.js +1 -2
- package/dist/commands/agent.js +1 -2
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +70 -31
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/create.js +1 -2
- package/dist/commands/doctor.js +1 -2
- package/dist/commands/info.js +1 -2
- package/dist/commands/init.js +1 -2
- package/dist/commands/mcp.js +1 -2
- package/dist/commands/update.js +1 -2
- package/dist/commands/upgrade.js +1 -2
- package/dist/index.js +1 -2
- package/dist/lib/agent-skill.d.ts +2 -2
- package/dist/lib/agent-skill.js +2 -3
- package/dist/lib/agent-skill.js.map +1 -1
- package/dist/lib/cloud-abort.js +1 -2
- package/dist/lib/cloud-auth.d.ts +1 -0
- package/dist/lib/cloud-auth.d.ts.map +1 -1
- package/dist/lib/cloud-auth.js +2 -3
- package/dist/lib/cloud-auth.js.map +1 -1
- package/dist/lib/cloud-config.d.ts +11 -1
- package/dist/lib/cloud-config.d.ts.map +1 -1
- package/dist/lib/cloud-config.js +52 -16
- package/dist/lib/cloud-config.js.map +1 -1
- package/dist/lib/cloud-provision.d.ts.map +1 -1
- package/dist/lib/cloud-provision.js +7 -3
- package/dist/lib/cloud-provision.js.map +1 -1
- package/dist/lib/cloud-remote.d.ts +16 -1
- package/dist/lib/cloud-remote.d.ts.map +1 -1
- package/dist/lib/cloud-remote.js +53 -9
- package/dist/lib/cloud-remote.js.map +1 -1
- package/dist/lib/cloud-setup-login.js +1 -2
- package/dist/lib/cloud-url.js +1 -2
- package/dist/lib/create-project.js +1 -2
- package/dist/lib/handle-cli-error.js +1 -2
- package/dist/lib/install-ai-sdk-lib.js +1 -2
- package/dist/lib/install-edge-lib.js +1 -2
- package/dist/lib/run-spawn.js +1 -2
- package/dist/lib/safe-project-file.d.ts +2 -0
- package/dist/lib/safe-project-file.d.ts.map +1 -0
- package/dist/lib/safe-project-file.js +25 -0
- package/dist/lib/safe-project-file.js.map +1 -0
- package/dist/lib/transform.js +1 -2
- package/dist/lib/upgrade.js +1 -2
- package/dist/lib/utils/config.js +1 -2
- package/dist/lib/utils/download.js +1 -2
- package/dist/lib/utils/file-scanner.js +1 -2
- package/dist/lib/utils/logger.js +1 -2
- package/dist/lib/utils/package-installer.js +1 -2
- package/dist/lib/utils/package-json.js +1 -2
- package/dist/lib/utils/package-manager.js +1 -2
- package/dist/lib/utils/registry.js +1 -2
- package/dist/lib/utils/workspace.js +1 -2
- package/dist/program.js +1 -2
- package/dist/run.js +1 -2
- package/package.json +3 -3
- package/src/commands/cloud.test.ts +156 -0
- package/src/commands/cloud.ts +93 -41
- package/src/commands/mcp.test.ts +123 -0
- package/src/lib/agent-skill.ts +1 -1
- package/src/lib/cloud-auth.ts +1 -1
- package/src/lib/cloud-config.test.ts +139 -1
- package/src/lib/cloud-config.ts +59 -8
- package/src/lib/cloud-provision.test.ts +26 -1
- package/src/lib/cloud-provision.ts +3 -0
- package/src/lib/cloud-remote.test.ts +99 -3
- package/src/lib/cloud-remote.ts +93 -7
- package/src/lib/safe-project-file.ts +31 -0
- package/templates/cloud-harness/components/assistant-ui/elements/thread.aui.tsx +1 -1
- package/templates/cloud-harness/package.json +7 -7
package/src/commands/cloud.ts
CHANGED
|
@@ -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<
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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:
|
|
188
|
+
organization: SetupResource,
|
|
158
189
|
directory: string,
|
|
159
190
|
options: SetupOptions,
|
|
160
|
-
): Promise<
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
});
|
package/src/lib/agent-skill.ts
CHANGED
|
@@ -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 = "
|
|
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: {
|
package/src/lib/cloud-auth.ts
CHANGED
|
@@ -1,14 +1,114 @@
|
|
|
1
|
-
import {
|
|
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
|
});
|
package/src/lib/cloud-config.ts
CHANGED
|
@@ -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
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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 } : {}) };
|