1import { AuthProviders } from "@/Auth/AuthProvider.ts";
2import {
3 PROFILE_FORMAT,
4 profileProviderFilePath,
5 ProfileStore,
6 ProfileStoreLive,
7} from "@/Auth/Profile.ts";
8import { GitHubAuthConfigSchema } from "@/GitHub/AuthProvider.ts";
9import * as NodeServices from "@effect/platform-node/NodeServices";
10import { expect, it } from "alchemy-test";
11import * as ConfigProvider from "effect/ConfigProvider";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Schema from "effect/Schema";
16import path from "pathe";
17
18const FIXTURE_HOME = path.join(import.meta.dirname, "fixtures/v0-home");
19
20const testLayer = () =>
21 ProfileStoreLive.pipe(
22 Layer.provideMerge(
23 Layer.mergeAll(
24 Layer.succeed(AuthProviders, {}),
25 ConfigProvider.layer(ConfigProvider.fromUnknown({})),
26 NodeServices.layer,
27 ),
28 ),
29 );
30
31/**
32 * Point `ALCHEMY_HOME` at a scoped temp directory seeded from a fixture
33 * tree, so store operations never touch the developer's real `~/.alchemy`.
34 * Tests using this must be `exclusive` — the env var is process-global.
35 */
36const withFixtureHome = <A, E, R>(
37 fixture: string | undefined,
38 effect: (home: string) => Effect.Effect<A, E, R>,
39) =>
40 Effect.gen(function* () {
41 const fs = yield* FileSystem.FileSystem;
42 const dir = yield* fs.makeTempDirectoryScoped({
43 prefix: "alchemy-migrate-",
44 });
45 if (fixture !== undefined) {
46 yield* fs.copyFile(
47 path.join(fixture, "profiles.json"),
48 path.join(dir, "profiles.json"),
49 );
50 yield* fs.copy(
51 path.join(fixture, "credentials"),
52 path.join(dir, "credentials"),
53 );
54 }
55 const previous = process.env.ALCHEMY_HOME;
56 yield* Effect.acquireRelease(
57 Effect.sync(() => {
58 process.env.ALCHEMY_HOME = dir;
59 }),
60 () =>
61 Effect.sync(() => {
62 if (previous === undefined) delete process.env.ALCHEMY_HOME;
63 else process.env.ALCHEMY_HOME = previous;
64 }),
65 );
66 return yield* effect(dir).pipe(Effect.provide(testLayer()));
67 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
68
69it.live(
70 "migrates a full v0 store: entries kept, credentials dropped into a backup",
71 () =>
72 withFixtureHome(FIXTURE_HOME, (home) =>
73 Effect.gen(function* () {
74 const fs = yield* FileSystem.FileSystem;
75 const store = yield* ProfileStore;
76
77 // The first read performs the on-disk migration.
78 const manifest = yield* store.readManifest;
79
80 // Every profile and provider entry survives (ids backfilled from the
81 // profile name), except legacy `method: "env"` entries.
82 expect(Object.keys(manifest.profiles).sort()).toEqual([
83 "default",
84 "work",
85 ]);
86 expect(manifest.profiles.default?.id).toBe("default");
87 expect(manifest.profiles.work?.id).toBe("work");
88 expect(manifest.profiles.default?.providers.Cloudflare).toEqual({
89 method: "oauth",
90 });
91 expect(manifest.profiles.default?.providers.AWS?.method).toBe("sso");
92 expect(manifest.profiles.default?.providers.GitHub).toEqual({
93 method: "gh-cli",
94 });
95 expect(
96 Schema.decodeUnknownSync(GitHubAuthConfigSchema)(
97 manifest.profiles.default?.providers.GitHub,
98 ),
99 ).toEqual({ method: "gh-cli" });
100 expect(manifest.profiles.default?.providers.Neon).toBeUndefined();
101 expect(manifest.profiles.work?.providers.GitHub?.method).toBe("stored");
102 expect(manifest.profiles.work?.providers.Axiom?.method).toBe("stored");
103 expect(manifest.profiles.work?.providers.Cloudflare).toEqual({
104 method: "stored",
105 credentialType: "apiKey",
106 apiKey: "v0-cf-global-api-key",
107 email: "worker@example.com",
108 accountId: "0123456789abcdef0123456789abcdef",
109 });
110 expect(manifest.profiles.work?.providers.GitHub?.token).toBe(
111 "ghp_v0token",
112 );
113 expect(manifest.profiles.work?.providers.Axiom).toEqual({
114 method: "stored",
115 token: "xaat-v0-token",
116 apiBaseUrl: "https://api.axiom.co",
117 });
118
119 // Each provider is now its own document. Compatible provider values
120 // survive. Cloudflare OAuth is deliberately emptied because its old
121 // grant is obsolete; stored API key credentials remain compatible.
122 const aws = JSON.parse(
123 yield* fs.readFileString(profileProviderFilePath("default", "AWS")),
124 );
125 expect(aws).toEqual({
126 format: PROFILE_FORMAT,
127 provider: "AWS",
128 metadata: {},
129 values: { method: "sso", ssoProfile: "dev" },
130 });
131 const oauthCloudflare = JSON.parse(
132 yield* fs.readFileString(
133 profileProviderFilePath("default", "Cloudflare"),
134 ),
135 );
136 expect(oauthCloudflare.values).toEqual({ method: "oauth" });
137 const storedCloudflare = JSON.parse(
138 yield* fs.readFileString(
139 profileProviderFilePath("work", "Cloudflare"),
140 ),
141 );
142 expect(storedCloudflare.values).toEqual(
143 manifest.profiles.work?.providers.Cloudflare,
144 );
145 expect(
146 (yield* fs.readDirectory(path.join(home, "profiles/default"))).sort(),
147 ).toEqual(["aws.json", "cloudflare.json", "github.json"]);
148 expect(
149 yield* fs.exists(profileProviderFilePath("default", "Neon")),
150 ).toBe(false);
151 expect(yield* fs.exists(path.join(home, "profiles.json"))).toBe(false);
152
153 // The original manifest and every credential file are preserved in
154 // a timestamped backup for manual recovery.
155 const backups = (yield* fs.readDirectory(home)).filter((entry) =>
156 entry.startsWith(".profiles-v0-"),
157 );
158 expect(backups).toHaveLength(1);
159 const backupDir = path.join(home, backups[0]!);
160 const backedUp = JSON.parse(
161 yield* fs.readFileString(path.join(backupDir, "profiles.json")),
162 );
163 expect(backedUp.version).toBe(0);
164 // v0 profiles are flat provider maps (no `providers` wrapper).
165 expect(backedUp.profiles.default.Neon.method).toBe("env");
166 const oauth = JSON.parse(
167 yield* fs.readFileString(
168 path.join(backupDir, "credentials/default/cf-oauth.json"),
169 ),
170 );
171 expect(oauth.refresh).toBe("v0-refresh-token");
172 for (const file of [
173 "credentials/default/cloudflare-state-store.json",
174 "credentials/work/gh-stored.json",
175 "credentials/work/axiom-stored.json",
176 "credentials/work/cf-stored.json",
177 ]) {
178 expect(yield* fs.exists(path.join(backupDir, file))).toBe(true);
179 }
180
181 // The live credential store is gone; all obsolete sidecars live only
182 // in the recoverable v0 backup.
183 expect(yield* fs.exists(path.join(home, "credentials"))).toBe(false);
184
185 // Idempotent: a second read is a plain current-version read — no new
186 // backup dir, and the existing one is untouched.
187 const again = yield* store.readManifest;
188 expect(again.profiles.work?.providers.GitHub?.method).toBe("stored");
189 expect(again.profiles.work?.providers.Cloudflare).toEqual(
190 manifest.profiles.work?.providers.Cloudflare,
191 );
192 expect(
193 (yield* fs.readDirectory(home)).filter((entry) =>
194 entry.startsWith(".profiles-v0-"),
195 ),
196 ).toHaveLength(1);
197 expect(
198 JSON.parse(
199 yield* fs.readFileString(path.join(backupDir, "profiles.json")),
200 ).version,
201 ).toBe(0);
202 }),
203 ),
204 { tags: ["unit", "provider:github", "local"], exclusive: true },
205);
206
207it.live(
208 "leaves the directory-backed store untouched",
209 () =>
210 withFixtureHome(undefined, (home) =>
211 Effect.gen(function* () {
212 const fs = yield* FileSystem.FileSystem;
213 const providerFile = profileProviderFilePath("work", "Neon");
214 yield* fs.makeDirectory(path.dirname(providerFile), {
215 recursive: true,
216 });
217 yield* fs.writeFileString(
218 providerFile,
219 JSON.stringify({
220 format: PROFILE_FORMAT,
221 provider: "Neon",
222 metadata: { label: "production" },
223 values: { method: "stored", apiKey: "current-key" },
224 }),
225 );
226
227 const store = yield* ProfileStore;
228 const manifest = yield* store.readManifest;
229
230 expect(manifest.profiles.work?.id).toBe("work");
231 expect(manifest.profiles.work?.providers.Neon).toEqual({
232 method: "stored",
233 apiKey: "current-key",
234 });
235 expect(
236 (yield* fs.readDirectory(home)).filter((entry) =>
237 entry.startsWith(".profiles-v0-"),
238 ),
239 ).toHaveLength(0);
240 expect(
241 JSON.parse(yield* fs.readFileString(providerFile)).metadata,
242 ).toEqual({ label: "production" });
243 }),
244 ),
245 { tags: ["unit", "local"], exclusive: true },
246);
247
248it.live(
249 "salvages valid v0 providers when another entry or sidecar is invalid",
250 () =>
251 withFixtureHome(FIXTURE_HOME, (home) =>
252 Effect.gen(function* () {
253 const fs = yield* FileSystem.FileSystem;
254 const legacyFile = path.join(home, "profiles.json");
255 const legacy = JSON.parse(yield* fs.readFileString(legacyFile));
256 legacy.profiles.work.Broken = "not-an-object";
257 yield* fs.writeFileString(legacyFile, JSON.stringify(legacy));
258 yield* fs.writeFileString(
259 path.join(home, "credentials/work/axiom-stored.json"),
260 "{not-json",
261 );
262
263 const manifest = yield* (yield* ProfileStore).readManifest;
264 expect(manifest.profiles.default?.providers.AWS).toEqual({
265 method: "sso",
266 ssoProfile: "dev",
267 });
268 expect(manifest.profiles.work?.providers.GitHub?.token).toBe(
269 "ghp_v0token",
270 );
271 expect(manifest.profiles.work?.providers.Axiom).toEqual({
272 method: "stored",
273 });
274 expect(manifest.profiles.work?.providers.Broken).toBeUndefined();
275
276 const backup = (yield* fs.readDirectory(home)).find((entry) =>
277 entry.startsWith(".profiles-v0-"),
278 );
279 expect(backup).toBeDefined();
280 expect(
281 yield* fs.readFileString(
282 path.join(home, backup!, "credentials/work/axiom-stored.json"),
283 ),
284 ).toBe("{not-json");
285 }),
286 ),
287 { tags: ["unit", "local"], exclusive: true },
288);
289
290it.live(
291 "inlines every recognized v0 credential sidecar",
292 () =>
293 withFixtureHome(undefined, (home) =>
294 Effect.gen(function* () {
295 const fs = yield* FileSystem.FileSystem;
296 const staticCredentials = {
297 "aws-stored": {
298 accessKeyId: "AKIAFIXTURE",
299 secretAccessKey: "aws-secret",
300 region: "us-east-1",
301 },
302 "axiom-stored": {
303 type: "apiToken",
304 apiToken: "axiom-token",
305 orgId: "axiom-org",
306 },
307 "fly-stored": { apiKey: "fly-token" },
308 "github-stored": { type: "pat", token: "github-token" },
309 "hetzner-stored": { token: "hetzner-token" },
310 "neon-stored": { apiKey: "neon-key" },
311 "planetscale-stored": {
312 type: "apiToken",
313 tokenId: "ps-token-id",
314 token: "ps-token",
315 organization: "ps-org",
316 },
317 "prisma-stored": { serviceToken: "prisma-token" },
318 "railway-stored": { type: "token", token: "railway-token" },
319 };
320 const oauthCredentials = {
321 "planetscale-oauth": {
322 type: "oauth",
323 clientId: "ps-client",
324 access: "ps-access",
325 refresh: "ps-refresh",
326 expires: 4_102_444_800_000,
327 scopes: ["read_organizations"],
328 },
329 "railway-oauth": { type: "token", token: "railway-oauth-token" },
330 };
331 yield* fs.writeFileString(
332 path.join(home, "profiles.json"),
333 JSON.stringify({
334 version: 0,
335 profiles: {
336 static: {
337 AWS: { method: "stored" },
338 Axiom: { method: "stored" },
339 Fly: { method: "stored" },
340 GitHub: { method: "stored" },
341 Hetzner: { method: "stored" },
342 Neon: { method: "stored" },
343 Planetscale: { method: "stored" },
344 Prisma: { method: "stored" },
345 Railway: { method: "stored" },
346 },
347 oauth: {
348 Planetscale: { method: "oauth", organization: "ps-org" },
349 Railway: { method: "oauth" },
350 },
351 },
352 }),
353 );
354 for (const [profile, credentials] of Object.entries({
355 static: staticCredentials,
356 oauth: oauthCredentials,
357 })) {
358 const dir = path.join(home, "credentials", profile);
359 yield* fs.makeDirectory(dir, { recursive: true });
360 for (const [key, values] of Object.entries(credentials)) {
361 yield* fs.writeFileString(
362 path.join(dir, `${key}.json`),
363 JSON.stringify(values),
364 );
365 }
366 }
367
368 const manifest = yield* (yield* ProfileStore).readManifest;
369 expect(manifest.profiles.static?.providers).toEqual({
370 AWS: {
371 method: "stored",
372 accessKeyId: "AKIAFIXTURE",
373 secretAccessKey: "aws-secret",
374 region: "us-east-1",
375 },
376 Axiom: {
377 method: "stored",
378 token: "axiom-token",
379 orgId: "axiom-org",
380 },
381 Fly: { method: "stored", apiKey: "fly-token" },
382 GitHub: { method: "stored", token: "github-token" },
383 Hetzner: { method: "stored", token: "hetzner-token" },
384 Neon: { method: "stored", apiKey: "neon-key" },
385 Planetscale: {
386 method: "stored",
387 tokenId: "ps-token-id",
388 token: "ps-token",
389 organization: "ps-org",
390 },
391 Prisma: { method: "stored", serviceToken: "prisma-token" },
392 Railway: { method: "stored", token: "railway-token" },
393 });
394 expect(manifest.profiles.oauth?.providers).toEqual({
395 Planetscale: {
396 method: "oauth",
397 organization: "ps-org",
398 clientId: "ps-client",
399 access: "ps-access",
400 refresh: "ps-refresh",
401 expires: 4_102_444_800_000,
402 scopes: ["read_organizations"],
403 },
404 Railway: { method: "oauth", token: "railway-oauth-token" },
405 });
406 }),
407 ),
408 { tags: ["unit", "local"], exclusive: true },
409);