@vellumai/cli 0.11.2 → 0.11.3-staging.2

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 (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -0,0 +1,232 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { guardianTokenPath } from "../config";
7
+ import { unpairAssistant } from "../unpair";
8
+
9
+ let tmpDir: string;
10
+ let lockfilePath: string;
11
+ let configDir: string;
12
+
13
+ beforeEach(() => {
14
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vellum-unpair-"));
15
+ lockfilePath = path.join(tmpDir, "lockfile.json");
16
+ configDir = path.join(tmpDir, "config");
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ const writeLockfile = (data: Record<string, unknown>): void => {
24
+ fs.writeFileSync(lockfilePath, JSON.stringify(data));
25
+ };
26
+
27
+ const readLockfileFromDisk = (): Record<string, unknown> =>
28
+ JSON.parse(fs.readFileSync(lockfilePath, "utf-8")) as Record<string, unknown>;
29
+
30
+ const seedGuardianToken = (assistantId: string): string => {
31
+ const tokenPath = guardianTokenPath(configDir, assistantId);
32
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
33
+ fs.writeFileSync(tokenPath, JSON.stringify({ accessToken: "tok" }));
34
+ return tokenPath;
35
+ };
36
+
37
+ describe("unpairAssistant", () => {
38
+ test("removes the paired entry, deletes its guardian token, and reassigns the active pointer", () => {
39
+ writeLockfile({
40
+ assistants: [
41
+ { assistantId: "paired-1", cloud: "paired", runtimeUrl: "https://h" },
42
+ { assistantId: "local-1", cloud: "local", futureField: "keep-me" },
43
+ ],
44
+ activeAssistant: "paired-1",
45
+ });
46
+ const tokenPath = seedGuardianToken("paired-1");
47
+
48
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
49
+
50
+ expect(result.ok).toBe(true);
51
+ if (!result.ok) {
52
+ return;
53
+ }
54
+ // Active falls back to the first remaining entry, matching the CLI's
55
+ // removeAssistantEntry semantics.
56
+ expect(result.lockfile.activeAssistant).toBe("local-1");
57
+ expect(result.lockfile.assistants.map((a) => a.assistantId)).toEqual([
58
+ "local-1",
59
+ ]);
60
+ expect(fs.existsSync(tokenPath)).toBe(false);
61
+
62
+ // Unknown fields on the remaining entries survive the rewrite.
63
+ const onDisk = readLockfileFromDisk();
64
+ expect(onDisk.assistants).toEqual([
65
+ { assistantId: "local-1", cloud: "local", futureField: "keep-me" },
66
+ ]);
67
+ expect(onDisk.activeAssistant).toBe("local-1");
68
+ });
69
+
70
+ test("removing the sole (active) entry leaves no active pointer", () => {
71
+ writeLockfile({
72
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
73
+ activeAssistant: "paired-1",
74
+ });
75
+
76
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
77
+
78
+ expect(result.ok).toBe(true);
79
+ if (!result.ok) {
80
+ return;
81
+ }
82
+ expect(result.lockfile.activeAssistant).toBeNull();
83
+ expect(result.lockfile.assistants).toEqual([]);
84
+ });
85
+
86
+ test("reassigns the active pointer past tolerated malformed entries", () => {
87
+ writeLockfile({
88
+ assistants: [
89
+ { assistantId: "paired-1", cloud: "paired" },
90
+ { notAnAssistant: true },
91
+ { assistantId: "local-1", cloud: "local" },
92
+ ],
93
+ activeAssistant: "paired-1",
94
+ });
95
+
96
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
97
+
98
+ expect(result.ok).toBe(true);
99
+ if (!result.ok) {
100
+ return;
101
+ }
102
+ expect(result.lockfile.activeAssistant).toBe("local-1");
103
+ });
104
+
105
+ test("a failed token delete aborts before touching the lockfile", () => {
106
+ writeLockfile({
107
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
108
+ activeAssistant: "paired-1",
109
+ });
110
+ // A non-empty directory at the token path makes rmSync (non-recursive)
111
+ // throw, standing in for EPERM-style failures.
112
+ const tokenPath = guardianTokenPath(configDir, "paired-1");
113
+ fs.mkdirSync(path.join(tokenPath, "oops"), { recursive: true });
114
+
115
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
116
+
117
+ expect(result.ok).toBe(false);
118
+ if (result.ok) {
119
+ return;
120
+ }
121
+ expect(result.status).toBe(500);
122
+ expect(result.error).toContain("guardian token");
123
+ // The entry is untouched, so the unpair can be retried.
124
+ expect(readLockfileFromDisk().assistants).toEqual([
125
+ { assistantId: "paired-1", cloud: "paired" },
126
+ ]);
127
+ expect(readLockfileFromDisk().activeAssistant).toBe("paired-1");
128
+ });
129
+
130
+ test("restores the guardian token when the lockfile write fails", () => {
131
+ writeLockfile({
132
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
133
+ activeAssistant: null,
134
+ });
135
+ const tokenPath = seedGuardianToken("paired-1");
136
+ // A read-only lockfile directory makes the tmp-file write fail after the
137
+ // token has already been deleted.
138
+ fs.chmodSync(tmpDir, 0o500);
139
+ try {
140
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
141
+
142
+ expect(result.ok).toBe(false);
143
+ expect(fs.existsSync(tokenPath)).toBe(true);
144
+ expect(fs.readFileSync(tokenPath, "utf-8")).toBe(
145
+ JSON.stringify({ accessToken: "tok" }),
146
+ );
147
+ } finally {
148
+ fs.chmodSync(tmpDir, 0o700);
149
+ }
150
+ });
151
+
152
+ test("leaves the active pointer alone when it names another assistant", () => {
153
+ writeLockfile({
154
+ assistants: [
155
+ { assistantId: "paired-1", cloud: "paired" },
156
+ { assistantId: "local-1", cloud: "local" },
157
+ ],
158
+ activeAssistant: "local-1",
159
+ });
160
+
161
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
162
+
163
+ expect(result.ok).toBe(true);
164
+ if (!result.ok) {
165
+ return;
166
+ }
167
+ expect(result.lockfile.activeAssistant).toBe("local-1");
168
+ });
169
+
170
+ test("succeeds when the guardian token file is already gone", () => {
171
+ writeLockfile({
172
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
173
+ activeAssistant: null,
174
+ });
175
+
176
+ const result = unpairAssistant([lockfilePath], configDir, "paired-1");
177
+
178
+ expect(result.ok).toBe(true);
179
+ });
180
+
181
+ test("refuses an unknown assistant without touching disk", () => {
182
+ writeLockfile({
183
+ assistants: [{ assistantId: "paired-1", cloud: "paired" }],
184
+ activeAssistant: "paired-1",
185
+ });
186
+
187
+ const result = unpairAssistant([lockfilePath], configDir, "nope");
188
+
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ status: 404,
192
+ error: "No such assistant",
193
+ });
194
+ expect(readLockfileFromDisk().activeAssistant).toBe("paired-1");
195
+ });
196
+
197
+ test("refuses a non-paired assistant and leaves its entry and token alone", () => {
198
+ writeLockfile({
199
+ assistants: [{ assistantId: "local-1", cloud: "local" }],
200
+ activeAssistant: "local-1",
201
+ });
202
+ const tokenPath = seedGuardianToken("local-1");
203
+
204
+ const result = unpairAssistant([lockfilePath], configDir, "local-1");
205
+
206
+ expect(result.ok).toBe(false);
207
+ if (result.ok) {
208
+ return;
209
+ }
210
+ expect(result.status).toBe(400);
211
+ expect(result.error).toContain("paired");
212
+ expect(fs.existsSync(tokenPath)).toBe(true);
213
+ expect(readLockfileFromDisk().assistants).toEqual([
214
+ { assistantId: "local-1", cloud: "local" },
215
+ ]);
216
+ });
217
+
218
+ test("refuses a cloudless entry (defaults to local) rather than treating it as paired", () => {
219
+ writeLockfile({
220
+ assistants: [{ assistantId: "bare-1" }],
221
+ activeAssistant: null,
222
+ });
223
+
224
+ const result = unpairAssistant([lockfilePath], configDir, "bare-1");
225
+
226
+ expect(result.ok).toBe(false);
227
+ if (result.ok) {
228
+ return;
229
+ }
230
+ expect(result.status).toBe(400);
231
+ });
232
+ });
@@ -1,9 +1,15 @@
1
1
  import os from "node:os";
2
- import path from "node:path";
3
2
 
4
3
  import { SEEDS } from "@vellumai/environments";
5
4
 
6
5
  import { resolveEnvironmentName } from "./environment";
6
+ import {
7
+ assertSafePathSegment,
8
+ joinLocalPath,
9
+ resolveConfigHomes,
10
+ resolveDataHome,
11
+ type LocalPathOptions,
12
+ } from "./paths";
7
13
 
8
14
  const PRODUCTION_ENVIRONMENT_NAME = "production";
9
15
 
@@ -21,13 +27,14 @@ export interface LocalEndpointConfig {
21
27
  */
22
28
  export function resolveLocalConfigFromEnv(
23
29
  env: Record<string, string | undefined>,
30
+ options: LocalPathOptions = {},
24
31
  ): LocalEndpointConfig {
25
- const vellumEnv = resolveEnvironmentName(env);
32
+ const vellumEnv = resolveEnvironmentName(env, options);
26
33
  const seed = SEEDS[vellumEnv] ?? SEEDS[PRODUCTION_ENVIRONMENT_NAME]!;
27
34
 
28
35
  return {
29
- lockfilePaths: resolveLockfilePaths(env),
30
- configDir: resolveConfigDir(env),
36
+ lockfilePaths: resolveLockfilePaths(env, options),
37
+ configDir: resolveConfigDir(env, options),
31
38
  webUrl: env.VELLUM_WEB_URL || seed.webUrl,
32
39
  platformUrl: env.VELLUM_PLATFORM_URL || seed.platformUrl,
33
40
  };
@@ -35,34 +42,125 @@ export function resolveLocalConfigFromEnv(
35
42
 
36
43
  export function resolveLockfilePaths(
37
44
  env: Record<string, string | undefined>,
45
+ options: LocalPathOptions = {},
38
46
  ): string[] {
39
- const vellumEnv = resolveEnvironmentName(env);
40
- const lockfileDir = env.VELLUM_LOCKFILE_DIR;
47
+ const vellumEnv = resolveEnvironmentName(env, options);
48
+ const lockfileDir = Object.hasOwn(options, "lockfileDirOverride")
49
+ ? options.lockfileDirOverride
50
+ : env.VELLUM_LOCKFILE_DIR?.trim();
51
+
52
+ if ((options.platform ?? process.platform) === "win32") {
53
+ const canonicalDir = lockfileDir ?? resolveConfigDir(env, options);
54
+ const canonical = joinLocalPath(options, canonicalDir, "lockfile.json");
55
+ const legacyDir = lockfileDir ?? options.homeDir ?? os.homedir();
56
+ const legacy =
57
+ vellumEnv === PRODUCTION_ENVIRONMENT_NAME
58
+ ? [".vellum.lock.json", ".vellum.lockfile.json"].map((name) =>
59
+ joinLocalPath(options, legacyDir, name),
60
+ )
61
+ : [
62
+ joinLocalPath(
63
+ options,
64
+ lockfileDir ?? resolveConfigDirPaths(env, options).at(-1)!,
65
+ "lockfile.json",
66
+ ),
67
+ ];
68
+ return [
69
+ canonical,
70
+ ...legacy.filter((candidate) => candidate !== canonical),
71
+ ];
72
+ }
41
73
 
42
74
  if (vellumEnv === PRODUCTION_ENVIRONMENT_NAME) {
43
- const dir = lockfileDir ?? os.homedir();
75
+ const dir = lockfileDir ?? options.homeDir ?? os.homedir();
44
76
  return [
45
- path.join(dir, ".vellum.lock.json"),
46
- path.join(dir, ".vellum.lockfile.json"),
77
+ joinLocalPath(options, dir, ".vellum.lock.json"),
78
+ joinLocalPath(options, dir, ".vellum.lockfile.json"),
47
79
  ];
48
80
  }
49
81
 
50
- const xdgConfigHome =
51
- env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
52
- const dir = lockfileDir ?? path.join(xdgConfigHome, `vellum-${vellumEnv}`);
53
- return [path.join(dir, "lockfile.json")];
82
+ const dir = lockfileDir ?? resolveConfigDir(env, options);
83
+ return [joinLocalPath(options, dir, "lockfile.json")];
54
84
  }
55
85
 
56
86
  export function resolveConfigDir(
57
87
  env: Record<string, string | undefined>,
88
+ options: LocalPathOptions = {},
58
89
  ): string {
59
- const vellumEnv = resolveEnvironmentName(env);
60
- const xdgConfigHome =
61
- env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
62
- if (vellumEnv === PRODUCTION_ENVIRONMENT_NAME) {
63
- return path.join(xdgConfigHome, "vellum");
90
+ return resolveConfigDirPaths(env, options)[0]!;
91
+ }
92
+
93
+ export function resolveConfigDirPaths(
94
+ env: Record<string, string | undefined>,
95
+ options: LocalPathOptions = {},
96
+ ): string[] {
97
+ if (options.configDirOverride) {
98
+ return [options.configDirOverride];
64
99
  }
65
- return path.join(xdgConfigHome, `vellum-${vellumEnv}`);
100
+ const vellumEnv = resolveEnvironmentName(env, options);
101
+ return resolveConfigHomes(env, options).map((home) =>
102
+ joinLocalPath(options, home, environmentDirectoryName(vellumEnv, options)),
103
+ );
104
+ }
105
+
106
+ function environmentDirectoryName(
107
+ vellumEnv: string,
108
+ options: LocalPathOptions,
109
+ ): string {
110
+ assertSafePathSegment(vellumEnv, "environment name", options);
111
+ return vellumEnv === PRODUCTION_ENVIRONMENT_NAME
112
+ ? "vellum"
113
+ : `vellum-${vellumEnv}`;
114
+ }
115
+
116
+ function resolveDataDir(
117
+ env: Record<string, string | undefined>,
118
+ options: LocalPathOptions,
119
+ ): string {
120
+ const vellumEnv = resolveEnvironmentName(env, options);
121
+ return joinLocalPath(
122
+ options,
123
+ resolveDataHome(env, options),
124
+ environmentDirectoryName(vellumEnv, options),
125
+ );
126
+ }
127
+
128
+ export function resolveRuntimeDir(
129
+ env: Record<string, string | undefined>,
130
+ options: LocalPathOptions = {},
131
+ ): string {
132
+ return resolveDataDir(env, options);
133
+ }
134
+
135
+ export function resolveLogDir(
136
+ env: Record<string, string | undefined>,
137
+ options: LocalPathOptions = {},
138
+ ): string {
139
+ const root =
140
+ (options.platform ?? process.platform) === "win32"
141
+ ? resolveDataDir(env, options)
142
+ : resolveConfigDir(env, options);
143
+ return joinLocalPath(options, root, "logs");
144
+ }
145
+
146
+ export function resolveAssistantsDir(
147
+ env: Record<string, string | undefined>,
148
+ options: LocalPathOptions = {},
149
+ ): string {
150
+ return joinLocalPath(options, resolveDataDir(env, options), "assistants");
151
+ }
152
+
153
+ export function resolveInstanceDir(
154
+ env: Record<string, string | undefined>,
155
+ assistantId: string,
156
+ options: LocalPathOptions = {},
157
+ ): string {
158
+ assertSafePathSegment(assistantId, "assistant ID", options);
159
+ return joinLocalPath(
160
+ options,
161
+ resolveAssistantsDir(env, options),
162
+ assistantId,
163
+ );
66
164
  }
67
165
 
68
166
  /**
@@ -74,6 +172,14 @@ export function resolveConfigDir(
74
172
  export function guardianTokenPath(
75
173
  configDir: string,
76
174
  assistantId: string,
175
+ options: LocalPathOptions = {},
77
176
  ): string {
78
- return path.join(configDir, "assistants", assistantId, "guardian-token.json");
177
+ assertSafePathSegment(assistantId, "assistant ID", options);
178
+ return joinLocalPath(
179
+ options,
180
+ configDir,
181
+ "assistants",
182
+ assistantId,
183
+ "guardian-token.json",
184
+ );
79
185
  }
@@ -1,21 +1,33 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
2
+
3
+ import {
4
+ joinLocalPath,
5
+ resolveConfigHomes,
6
+ type LocalPathOptions,
7
+ } from "./paths";
4
8
 
5
9
  const PRODUCTION_ENVIRONMENT_NAME = "production";
6
10
 
7
11
  /**
8
12
  * Location of the persisted default-environment file, written by
9
13
  * `vellum env set`. It lives at a fixed, environment-agnostic path so it can
10
- * be read before the environment is known. Honors `XDG_CONFIG_HOME`, falling
11
- * back to `~/.config`.
14
+ * be read before the environment is known. Uses AppData on Windows and the
15
+ * XDG config home on macOS and Linux.
12
16
  */
17
+ export function defaultEnvironmentFilePaths(
18
+ env: Record<string, string | undefined>,
19
+ options: LocalPathOptions = {},
20
+ ): string[] {
21
+ return resolveConfigHomes(env, options).map((home) =>
22
+ joinLocalPath(options, home, "vellum", "environment"),
23
+ );
24
+ }
25
+
13
26
  export function defaultEnvironmentFilePath(
14
27
  env: Record<string, string | undefined>,
28
+ options: LocalPathOptions = {},
15
29
  ): string {
16
- const xdgConfigHome =
17
- env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config");
18
- return path.join(xdgConfigHome, "vellum", "environment");
30
+ return defaultEnvironmentFilePaths(env, options)[0]!;
19
31
  }
20
32
 
21
33
  /**
@@ -24,15 +36,22 @@ export function defaultEnvironmentFilePath(
24
36
  */
25
37
  export function readDefaultEnvironment(
26
38
  env: Record<string, string | undefined>,
39
+ options: LocalPathOptions = {},
27
40
  ): string | undefined {
28
- const filePath = defaultEnvironmentFilePath(env);
29
- try {
30
- if (!existsSync(filePath)) return undefined;
31
- const content = readFileSync(filePath, "utf-8").trim();
32
- return content.length > 0 ? content : undefined;
33
- } catch {
34
- return undefined;
41
+ for (const filePath of defaultEnvironmentFilePaths(env, options)) {
42
+ try {
43
+ if (!existsSync(filePath)) {
44
+ continue;
45
+ }
46
+ const content = readFileSync(filePath, "utf-8").trim();
47
+ if (content.length > 0) {
48
+ return content;
49
+ }
50
+ } catch {
51
+ // Try the next compatible location.
52
+ }
35
53
  }
54
+ return undefined;
36
55
  }
37
56
 
38
57
  /**
@@ -53,10 +72,12 @@ export function readDefaultEnvironment(
53
72
  */
54
73
  export function resolveEnvironmentName(
55
74
  env: Record<string, string | undefined>,
75
+ options: LocalPathOptions = {},
56
76
  ): string {
57
77
  return (
78
+ options.environmentName ||
58
79
  env.VELLUM_ENVIRONMENT?.trim() ||
59
- readDefaultEnvironment(env) ||
80
+ readDefaultEnvironment(env, options) ||
60
81
  PRODUCTION_ENVIRONMENT_NAME
61
82
  );
62
83
  }