1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import type { PlatformError } from "effect/PlatformError";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12import { UserFacingError } from "../UserFacingError.ts";
13import { writeFileAtomic } from "../Util/AtomicFile.ts";
14import { profileCommandHint } from "../Util/interactive.ts";
15import { AuthError, type AuthProvider } from "./AuthProvider.ts";
16import { withLock } from "./Lock.ts";
17import {
18 configFilePath,
19 credentialsDirPath,
20 profileCredentialsDirPath,
21 profileDirPath,
22 profileProviderFilePath,
23 profilesDirPath,
24 rootDir,
25} from "./Paths.ts";
26
27export {
28 configFilePath,
29 credentialsDirPath,
30 profileCredentialsDirPath,
31 profileDirPath,
32 profileProviderFilePath,
33 profilesDirPath,
34 rootDir,
35} from "./Paths.ts";
36
37/** Config key selecting a directory under `~/.alchemy/profiles`. */
38export const ALCHEMY_PROFILE = Config.String("ALCHEMY_PROFILE");
39
40/** Version of the synthesized in-memory manifest returned by readManifest. */
41export const PROFILE_MANIFEST_VERSION = 3;
42
43/** Stable format identifier for an individual provider profile document. */
44export const PROFILE_FORMAT = "alchemy.profile/v1" as const;
45
46export const DEFAULT_PROFILE_NAME = "default";
47export const DEFAULT_PROFILE_ID = "default";
48
49/**
50 * The minimum contract shared by provider-owned values. Providers refine this
51 * with their own Effect Schema codec. The method is deliberately an open
52 * string: adding an auth method never changes Alchemy's storage schema.
53 */
54export const ProviderConfigSchema = Schema.Record(
55 Schema.String,
56 Schema.Unknown,
57);
58
59export interface ProviderConfig {
60 readonly method?: string;
61 readonly [key: string]: unknown;
62}
63
64/** User-facing, non-secret annotations. Provider schemas may refine it. */
65export const ProfileMetadataSchema = Schema.Record(
66 Schema.String,
67 Schema.Unknown,
68);
69export type ProfileMetadata = typeof ProfileMetadataSchema.Type;
70
71/**
72 * Base on-disk schema. Only this envelope is owned by Alchemy core; the
73 * selected AuthProvider decodes `metadata` and `values` with its own schemas.
74 */
75export const ProviderProfileFileSchema = Schema.StructWithRest(
76 Schema.Struct({
77 format: Schema.Literal(PROFILE_FORMAT),
78 provider: Schema.String,
79 metadata: ProfileMetadataSchema,
80 values: ProviderConfigSchema,
81 }),
82 [Schema.Record(Schema.String, Schema.Unknown)],
83);
84
85export type ProviderProfileFile = typeof ProviderProfileFileSchema.Type;
86
87/** Compose the base envelope with a custom provider's typed schemas. */
88export const makeProviderProfileSchema = <Metadata, Values>(
89 provider: string,
90 metadata: Schema.Codec<Metadata>,
91 values: Schema.Codec<Values>,
92) =>
93 Schema.Struct({
94 format: Schema.Literal(PROFILE_FORMAT),
95 provider: Schema.Literal(provider),
96 metadata,
97 values,
98 });
99
100/**
101 * Aggregate view used by the profile UI. It is synthesized from provider
102 * files and is never persisted as a central manifest.
103 */
104export interface Profile {
105 readonly id: string;
106 readonly providers: Record<string, ProviderConfig>;
107}
108
109export interface ProfileManifest {
110 readonly version: typeof PROFILE_MANIFEST_VERSION;
111 readonly profiles: Record<string, Profile>;
112}
113
114export interface ProfileSelection {
115 readonly name: string;
116 readonly source: "configuration" | "default";
117}
118
119/** Only the released v0 centralized layout is migrated. */
120const V0ManifestSchema = Schema.StructWithRest(
121 Schema.Struct({
122 version: Schema.Literal(0),
123 profiles: Schema.Record(Schema.String, Schema.Unknown),
124 }),
125 [Schema.Record(Schema.String, Schema.Unknown)],
126);
127
128const LEGACY_CREDENTIAL_KEYS: Record<string, string | Record<string, string>> =
129 {
130 AWS: "aws-stored",
131 Axiom: "axiom-stored",
132 Cloudflare: "cloudflare-stored",
133 Fly: "fly-stored",
134 GitHub: "github-stored",
135 Hetzner: "hetzner-stored",
136 Neon: "neon-stored",
137 Planetscale: {
138 stored: "planetscale-stored",
139 oauth: "planetscale-oauth",
140 },
141 Prisma: "prisma-stored",
142 Railway: {
143 stored: "railway-stored",
144 oauth: "railway-oauth",
145 },
146 };
147
148export class ProfileError extends Schema.TaggedError<ProfileError>()(
149 "ProfileError",
150 {
151 message: Schema.String,
152 cause: Schema.optional(Schema.Defect()),
153 },
154) {
155 readonly [UserFacingError] = true;
156}
157
158export class MissingProviderConfig extends Schema.TaggedError<MissingProviderConfig>()(
159 "MissingProviderConfig",
160 {
161 provider: Schema.String,
162 profileName: Schema.String,
163 message: Schema.String,
164 },
165) {}
166
167export const SuppressMissingProviderConfig = Context.Reference<boolean>(
168 "Auth/SuppressMissingProviderConfig",
169 { defaultValue: () => false },
170);
171
172const emptyManifest = (): ProfileManifest => ({
173 version: PROFILE_MANIFEST_VERSION,
174 profiles: {
175 [DEFAULT_PROFILE_NAME]: { id: DEFAULT_PROFILE_ID, providers: {} },
176 },
177});
178
179export const createProfileHint = (name?: string) =>
180 Effect.map(
181 profileCommandHint(`alchemy profile create ${name ?? "<name>"}`),
182 (command) => `Run \`${command}\`.`,
183 );
184
185const profileNotFound = Effect.fn(function* (name: string) {
186 return new ProfileError({
187 message: `Profile '${name}' does not exist. ${yield* createProfileHint(name)}`,
188 });
189});
190
191export const cannotDeleteDefaultProfile = () =>
192 new ProfileError({
193 message: `Cannot delete the built-in '${DEFAULT_PROFILE_NAME}' profile.`,
194 });
195
196export const cannotRenameDefaultProfile = () =>
197 new ProfileError({
198 message: `Cannot rename the built-in '${DEFAULT_PROFILE_NAME}' profile.`,
199 });
200
201const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
202
203export const validateProfileName = (
204 name: string,
205): Effect.Effect<string, ProfileError> =>
206 PROFILE_NAME_PATTERN.test(name)
207 ? Effect.succeed(name)
208 : Effect.fail(
209 new ProfileError({
210 message:
211 `Invalid profile name '${name}'. Profile names must start with an ASCII letter or number, ` +
212 "contain only letters, numbers, '.', '_' or '-', and be at most 64 characters.",
213 }),
214 );
215
216const validateProviderName = (
217 name: string,
218): Effect.Effect<string, ProfileError> =>
219 PROFILE_NAME_PATTERN.test(name)
220 ? Effect.succeed(name)
221 : Effect.fail(
222 new ProfileError({
223 message: `Invalid provider id '${name}'. Provider ids must be safe as filenames.`,
224 }),
225 );
226
227export interface ProfileStoreService {
228 readonly readManifest: Effect.Effect<
229 ProfileManifest,
230 ProfileError | PlatformError
231 >;
232 readonly getProfile: (
233 name: string,
234 ) => Effect.Effect<Profile | undefined, ProfileError | PlatformError>;
235 readonly ensureProfile: (
236 name: string,
237 ) => Effect.Effect<Profile, ProfileError | PlatformError>;
238 readonly createProfile: (
239 name: string,
240 ) => Effect.Effect<void, ProfileError | PlatformError>;
241 readonly renameProfile: (
242 name: string,
243 newName: string,
244 ) => Effect.Effect<void, ProfileError | PlatformError>;
245 readonly current: Effect.Effect<
246 ProfileSelection,
247 ProfileError | PlatformError
248 >;
249 readonly setProviderConfig: (
250 profile: string,
251 provider: string,
252 values: ProviderConfig,
253 ) => Effect.Effect<void, ProfileError | PlatformError>;
254 readonly deleteProviderConfig: (
255 profile: string,
256 provider: string,
257 ) => Effect.Effect<boolean, ProfileError | PlatformError>;
258 readonly deleteProfile: (
259 name: string,
260 ) => Effect.Effect<boolean, ProfileError | PlatformError>;
261 readonly loadProviderConfig: <Config extends { method: string }>(
262 auth: AuthProvider<Config>,
263 profileName: string,
264 ) => Effect.Effect<
265 Config,
266 AuthError | MissingProviderConfig | ProfileError | PlatformError
267 >;
268}
269
270export class ProfileStore extends Context.Service<
271 ProfileStore,
272 ProfileStoreService
273>()("Alchemy::ProfileStore") {}
274
275export const ProfileStoreLive = Layer.effect(
276 ProfileStore,
277 Effect.gen(function* () {
278 const fs = yield* FileSystem.FileSystem;
279 const pathService = yield* Path.Path;
280
281 yield* fs.makeDirectory(profileDirPath(DEFAULT_PROFILE_NAME), {
282 recursive: true,
283 });
284 yield* fs.chmod(profileDirPath(DEFAULT_PROFILE_NAME), 0o700);
285
286 const provideLockServices = <A, E>(
287 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>,
288 ): Effect.Effect<A, E> =>
289 effect.pipe(
290 Effect.provideService(FileSystem.FileSystem, fs),
291 Effect.provideService(Path.Path, pathService),
292 );
293
294 const decodeJson = (file: string) =>
295 fs.readFileString(file).pipe(
296 Effect.flatMap(
297 Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)),
298 ),
299 Effect.mapError(
300 (cause) =>
301 new ProfileError({
302 message: `Could not parse '${file}'.`,
303 cause,
304 }),
305 ),
306 );
307
308 const backupInvalidProfileFile = (file: string, reason: unknown) =>
309 provideLockServices(
310 withLock(
311 "profiles-invalid",
312 Effect.gen(function* () {
313 if (!(yield* fs.exists(file))) return undefined;
314 const stamp = DateTime.formatIso(yield* DateTime.now)
315 .replaceAll(":", "-")
316 .replaceAll(".", "-");
317 const backup = `${file}.invalid-${stamp}.bak`;
318 yield* fs.rename(file, backup);
319 yield* Effect.logWarning(
320 `Invalid profile file '${file}' was backed up to '${backup}' and skipped.`,
321 reason,
322 );
323 return backup;
324 }),
325 ),
326 );
327
328 const writeProviderFile = (
329 profile: string,
330 provider: string,
331 values: ProviderConfig,
332 previous?: ProviderProfileFile,
333 ) =>
334 Effect.gen(function* () {
335 yield* validateProfileName(profile);
336 yield* validateProviderName(provider);
337 const dir = profileDirPath(profile);
338 yield* fs.makeDirectory(dir, { recursive: true });
339 yield* fs.chmod(dir, 0o700);
340 const document: ProviderProfileFile = {
341 format: PROFILE_FORMAT,
342 provider,
343 metadata: previous?.metadata ?? {},
344 values,
345 };
346 yield* writeFileAtomic(
347 fs,
348 profileProviderFilePath(profile, provider),
349 JSON.stringify(document, null, 2),
350 0o600,
351 );
352 });
353
354 const readLegacyProviderValues = (
355 profile: string,
356 provider: string,
357 values: ProviderConfig,
358 ) =>
359 Effect.gen(function* () {
360 // Cloudflare's released OAuth grant is obsolete, but its stored API
361 // token and global API key formats still map exactly to the new
362 // provider-owned values schema.
363 if (provider === "Cloudflare" && values.method !== "stored") {
364 return { method: "oauth" };
365 }
366 const configuredKey = LEGACY_CREDENTIAL_KEYS[provider];
367 const key =
368 typeof configuredKey === "string"
369 ? configuredKey
370 : values.method === undefined
371 ? undefined
372 : configuredKey?.[values.method];
373 if (key === undefined) return values;
374
375 const candidates = [key];
376 // The earliest GitHub sidecar used this shorter filename.
377 if (provider === "GitHub") candidates.push("gh-stored");
378 // The earliest Cloudflare sidecar used this shorter filename.
379 if (provider === "Cloudflare") candidates.push("cf-stored");
380 let credentialFile: string | undefined;
381 for (const candidate of candidates) {
382 const file = pathService.join(
383 profileCredentialsDirPath(profile),
384 `${candidate}.json`,
385 );
386 if (yield* fs.exists(file)) {
387 credentialFile = file;
388 break;
389 }
390 }
391 if (credentialFile === undefined) {
392 return provider === "Cloudflare" ? {} : values;
393 }
394
395 const decoded = yield* Effect.result(
396 decodeJson(credentialFile).pipe(
397 Effect.flatMap(Schema.decodeUnknownEffect(ProviderConfigSchema)),
398 ),
399 );
400 if (Result.isFailure(decoded)) {
401 yield* Effect.logWarning(
402 `Could not decode legacy credentials from '${credentialFile}'; migrating the manifest values and preserving the sidecar in the v0 backup.`,
403 decoded.failure,
404 );
405 return provider === "Cloudflare" ? {} : values;
406 }
407 const credential = decoded.success;
408 const { type: _type, ...inline } = credential;
409 const normalizedInline =
410 provider === "Axiom" && inline.apiToken !== undefined
411 ? (({ apiToken, ...rest }) => ({ ...rest, token: apiToken }))(
412 inline,
413 )
414 : inline;
415 const { storageKey: _storageKey, ...manifestValues } = values;
416 const migrated: ProviderConfig = {
417 ...manifestValues,
418 ...normalizedInline,
419 };
420 if (provider !== "Cloudflare") return migrated;
421 return migrated.credentialType === "apiKey" &&
422 typeof migrated.apiKey === "string" &&
423 typeof migrated.email === "string" &&
424 typeof migrated.accountId === "string"
425 ? migrated
426 : migrated.credentialType === "apiToken" &&
427 typeof migrated.apiToken === "string" &&
428 typeof migrated.accountId === "string"
429 ? migrated
430 : {};
431 });
432
433 /** Expand the released v0 store and preserve every original file. */
434 const migrateCentralManifest = provideLockServices(
435 withLock(
436 "profiles-migrate",
437 Effect.gen(function* () {
438 const legacy = configFilePath();
439 if (!(yield* fs.exists(legacy))) return;
440 const jsonResult = yield* Effect.result(decodeJson(legacy));
441 if (Result.isFailure(jsonResult)) {
442 yield* backupInvalidProfileFile(legacy, jsonResult.failure);
443 return;
444 }
445 const header = Schema.decodeUnknownOption(
446 Schema.Struct({ version: Schema.Number }),
447 )(jsonResult.success);
448 // The short-lived centralized versions were never released and do
449 // not need a compatibility path. Leave them untouched.
450 if (Option.isSome(header) && header.value.version !== 0) return;
451 const storedResult = yield* Effect.result(
452 Schema.decodeUnknownEffect(V0ManifestSchema)(jsonResult.success),
453 );
454 if (Result.isFailure(storedResult)) {
455 yield* backupInvalidProfileFile(legacy, storedResult.failure);
456 return;
457 }
458 const stored = storedResult.success;
459 for (const [name, entry] of Object.entries(stored.profiles)) {
460 const validName = yield* Effect.result(validateProfileName(name));
461 if (Result.isFailure(validName)) {
462 yield* Effect.logWarning(
463 `Skipping invalid v0 profile name '${name}'. It remains available in the v0 backup.`,
464 );
465 continue;
466 }
467 const providersResult = yield* Effect.result(
468 Schema.decodeUnknownEffect(
469 Schema.Record(Schema.String, Schema.Unknown),
470 )(entry),
471 );
472 if (Result.isFailure(providersResult)) {
473 yield* Effect.logWarning(
474 `Skipping invalid v0 profile '${name}'. It remains available in the v0 backup.`,
475 providersResult.failure,
476 );
477 continue;
478 }
479 yield* fs.makeDirectory(profileDirPath(name), { recursive: true });
480 yield* fs.chmod(profileDirPath(name), 0o700);
481 for (const [provider, rawValues] of Object.entries(
482 providersResult.success,
483 )) {
484 const validProvider = yield* Effect.result(
485 validateProviderName(provider),
486 );
487 if (Result.isFailure(validProvider)) {
488 yield* Effect.logWarning(
489 `Skipping invalid v0 provider name '${provider}' in profile '${name}'. It remains available in the v0 backup.`,
490 );
491 continue;
492 }
493 const valuesResult = yield* Effect.result(
494 Schema.decodeUnknownEffect(ProviderConfigSchema)(rawValues),
495 );
496 if (Result.isFailure(valuesResult)) {
497 if (provider === "Cloudflare") {
498 const target = profileProviderFilePath(name, provider);
499 if (!(yield* fs.exists(target))) {
500 yield* writeProviderFile(name, provider, {});
501 }
502 continue;
503 }
504 yield* Effect.logWarning(
505 `Skipping invalid v0 provider '${provider}' in profile '${name}'. It remains available in the v0 backup.`,
506 valuesResult.failure,
507 );
508 continue;
509 }
510 const values = valuesResult.success;
511 if (values.method === "env") continue;
512 // Known legacy sidecars become inline provider values.
513 const migratedValues = yield* readLegacyProviderValues(
514 name,
515 provider,
516 values,
517 );
518 const target = profileProviderFilePath(name, provider);
519 if (!(yield* fs.exists(target))) {
520 yield* writeProviderFile(name, provider, migratedValues);
521 }
522 }
523 }
524 const stamp = DateTime.formatIso(yield* DateTime.now)
525 .replaceAll(":", "-")
526 .replaceAll(".", "-");
527 const backup = pathService.join(rootDir(), `.profiles-v0-${stamp}`);
528 yield* fs.makeDirectory(backup, { recursive: true });
529 yield* fs.chmod(backup, 0o700);
530 yield* fs.rename(legacy, pathService.join(backup, "profiles.json"));
531 const credentials = credentialsDirPath();
532 if (yield* fs.exists(credentials)) {
533 yield* fs.rename(
534 credentials,
535 pathService.join(backup, "credentials"),
536 );
537 }
538 }),
539 ),
540 );
541
542 const readProviderFile = (profile: string, file: string) =>
543 Effect.gen(function* () {
544 const fullPath = pathService.join(profileDirPath(profile), file);
545 const json = yield* decodeJson(fullPath);
546 const document = yield* Schema.decodeUnknownEffect(
547 ProviderProfileFileSchema,
548 )(json).pipe(
549 Effect.mapError(
550 (cause) =>
551 new ProfileError({
552 message: `Invalid provider profile at '${fullPath}'.`,
553 cause,
554 }),
555 ),
556 );
557 const filenameProvider = file.slice(0, -".json".length);
558 if (document.provider.toLowerCase() !== filenameProvider) {
559 return yield* Effect.fail(
560 new ProfileError({
561 message: `Provider '${document.provider}' in '${fullPath}' does not match lowercase filename '${filenameProvider}'.`,
562 }),
563 );
564 }
565 return document;
566 });
567
568 const readManifest = Effect.gen(function* () {
569 yield* migrateCentralManifest;
570 const manifest = emptyManifest();
571 const names = yield* fs
572 .readDirectory(profilesDirPath())
573 .pipe(
574 Effect.catchReason("PlatformError", "NotFound", () =>
575 Effect.succeed<string[]>([]),
576 ),
577 );
578 for (const name of names) {
579 if (!PROFILE_NAME_PATTERN.test(name)) continue;
580 const info = yield* fs.stat(profileDirPath(name));
581 if (info.type !== "Directory") continue;
582 const providers: Record<string, ProviderConfig> = {};
583 const files = yield* fs.readDirectory(profileDirPath(name));
584 for (const file of files.sort()) {
585 if (!file.endsWith(".json")) continue;
586 const read = yield* Effect.result(readProviderFile(name, file));
587 if (Result.isFailure(read)) {
588 yield* backupInvalidProfileFile(
589 pathService.join(profileDirPath(name), file),
590 read.failure,
591 );
592 continue;
593 }
594 providers[read.success.provider] = read.success.values;
595 }
596 manifest.profiles[name] = { id: name, providers };
597 }
598 return manifest;
599 });
600
601 const getProfile = (name: string) =>
602 validateProfileName(name).pipe(
603 Effect.flatMap(() => readManifest),
604 Effect.map((manifest) => manifest.profiles[name]),
605 );
606
607 const ensureProfile = (name: string) =>
608 getProfile(name).pipe(
609 Effect.flatMap((profile) =>
610 profile === undefined
611 ? Effect.flatMap(profileNotFound(name), Effect.fail)
612 : Effect.succeed(profile),
613 ),
614 );
615
616 const createProfile = (name: string) =>
617 validateProfileName(name).pipe(
618 Effect.flatMap(() =>
619 provideLockServices(
620 withLock(
621 "profiles",
622 Effect.gen(function* () {
623 const existing = (yield* readManifest).profiles[name];
624 if (existing !== undefined) {
625 return yield* Effect.fail(
626 new ProfileError({
627 message: `Profile '${name}' already exists.`,
628 }),
629 );
630 }
631 yield* fs.makeDirectory(profileDirPath(name), {
632 recursive: true,
633 });
634 yield* fs.chmod(profileDirPath(name), 0o700);
635 }),
636 ),
637 ),
638 ),
639 );
640
641 const renameProfile = (name: string, newName: string) =>
642 Effect.gen(function* () {
643 if (name === DEFAULT_PROFILE_NAME) {
644 return yield* Effect.fail(cannotRenameDefaultProfile());
645 }
646 yield* validateProfileName(name);
647 yield* validateProfileName(newName);
648 yield* provideLockServices(
649 withLock(
650 "profiles",
651 Effect.gen(function* () {
652 const manifest = yield* readManifest;
653 if (manifest.profiles[name] === undefined) {
654 return yield* Effect.fail(
655 new ProfileError({
656 message: `Profile '${name}' does not exist.`,
657 }),
658 );
659 }
660 if (manifest.profiles[newName] !== undefined) {
661 return yield* Effect.fail(
662 new ProfileError({
663 message: `Profile '${newName}' already exists.`,
664 }),
665 );
666 }
667 yield* fs.rename(profileDirPath(name), profileDirPath(newName));
668 const oldCredentials = profileCredentialsDirPath(name);
669 if (yield* fs.exists(oldCredentials)) {
670 yield* fs.rename(
671 oldCredentials,
672 profileCredentialsDirPath(newName),
673 );
674 }
675 }),
676 ),
677 );
678 });
679
680 const current: Effect.Effect<
681 ProfileSelection,
682 ProfileError | PlatformError
683 > = Effect.gen(function* () {
684 const configured = yield* Config.option(ALCHEMY_PROFILE).pipe(
685 Effect.mapError(
686 (cause) =>
687 new ProfileError({
688 message: "Could not resolve ALCHEMY_PROFILE.",
689 cause,
690 }),
691 ),
692 );
693 if (Option.isSome(configured)) {
694 return {
695 name: yield* validateProfileName(configured.value),
696 source: "configuration" as const,
697 };
698 }
699 return { name: DEFAULT_PROFILE_NAME, source: "default" as const };
700 });
701
702 const setProviderConfig = (
703 profile: string,
704 provider: string,
705 values: ProviderConfig,
706 ) =>
707 Effect.gen(function* () {
708 yield* validateProfileName(profile);
709 yield* validateProviderName(provider);
710 yield* provideLockServices(
711 withLock(
712 "profiles",
713 Effect.gen(function* () {
714 if ((yield* readManifest).profiles[profile] === undefined) {
715 return yield* Effect.flatMap(
716 profileNotFound(profile),
717 Effect.fail,
718 );
719 }
720 const file = profileProviderFilePath(profile, provider);
721 let previous: ProviderProfileFile | undefined;
722 if (yield* fs.exists(file)) {
723 const read = yield* Effect.result(
724 readProviderFile(profile, `${provider.toLowerCase()}.json`),
725 );
726 if (Result.isFailure(read)) {
727 yield* backupInvalidProfileFile(file, read.failure);
728 } else {
729 previous = read.success;
730 }
731 }
732 yield* writeProviderFile(profile, provider, values, previous);
733 }),
734 ),
735 );
736 });
737
738 const deleteProviderConfig = (profile: string, provider: string) =>
739 Effect.gen(function* () {
740 yield* validateProfileName(profile);
741 yield* validateProviderName(provider);
742 return yield* provideLockServices(
743 withLock(
744 "profiles",
745 Effect.gen(function* () {
746 const file = profileProviderFilePath(profile, provider);
747 if (!(yield* fs.exists(file))) return false;
748 yield* fs.remove(file);
749 return true;
750 }),
751 ),
752 );
753 });
754
755 const deleteProfile = (
756 name: string,
757 ): Effect.Effect<boolean, ProfileError | PlatformError> =>
758 Effect.gen(function* () {
759 yield* validateProfileName(name);
760 if (name === DEFAULT_PROFILE_NAME) {
761 return yield* Effect.fail(cannotDeleteDefaultProfile());
762 }
763 return yield* provideLockServices(
764 withLock(
765 "profiles",
766 Effect.gen(function* () {
767 const dir = profileDirPath(name);
768 if (!(yield* fs.exists(dir))) return false;
769 yield* fs.remove(dir, { recursive: true });
770 const credentials = profileCredentialsDirPath(name);
771 if (yield* fs.exists(credentials)) {
772 yield* fs.remove(credentials, { recursive: true });
773 }
774 return true;
775 }),
776 ),
777 );
778 });
779
780 const loadProviderConfig = <Config extends { method: string }>(
781 auth: AuthProvider<Config>,
782 profileName: string,
783 ) =>
784 Effect.gen(function* () {
785 const existing = yield* ensureProfile(profileName);
786 const stored = existing.providers[auth.name];
787 if (stored !== undefined) {
788 return yield* auth.decodeConfig(profileName, stored);
789 }
790 if (yield* SuppressMissingProviderConfig) {
791 return yield* Effect.fail(
792 new MissingProviderConfig({
793 provider: auth.name,
794 profileName,
795 message: `Provider '${auth.name}' is not configured in profile '${profileName}'.`,
796 }),
797 );
798 }
799 const command = yield* profileCommandHint(
800 `alchemy profile edit --profile ${profileName} --add ${auth.name}`,
801 );
802 return yield* Effect.fail(
803 new AuthError({
804 message: `Provider '${auth.name}' is not configured in profile '${profileName}'. Run \`${command}\`.`,
805 }),
806 );
807 });
808
809 return {
810 readManifest,
811 getProfile,
812 ensureProfile,
813 createProfile,
814 renameProfile,
815 current,
816 setProviderConfig,
817 deleteProviderConfig,
818 deleteProfile,
819 loadProviderConfig,
820 } satisfies ProfileStoreService;
821 }),
822);
823
824/** The name of the currently selected profile. */
825export const currentProfileName: Effect.Effect<
826 string,
827 ProfileError | PlatformError,
828 ProfileStore
829> = ProfileStore.use((store) => store.current).pipe(
830 Effect.map((selection) => selection.name),
831);