1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Ref from "effect/Ref";
4import { Progress } from "../../../Alchemist/Progress.ts";
5import * as Profiles from "../../../Alchemist/routes/profile.ts";
6import { importStack } from "../../../Alchemist/Session.ts";
7import { DEFAULT_PROFILE_NAME } from "../../../Auth/Profile.ts";
8import { CliKit } from "../../CliKit/index.ts";
9import { isPromptCancellation } from "../errors.ts";
10
11export const profileHub = Effect.fn(function* (options: {
12 envFile: Option.Option<string>;
13 main: string;
14}) {
15 const envFile = Option.getOrUndefined(options.envFile);
16 const computeEntries = Profiles.list().pipe(
17 Effect.map((profiles) =>
18 profiles.map((profile) => ({
19 name: profile.name,
20 isActive: profile.active,
21 isDefault: profile.name === DEFAULT_PROFILE_NAME,
22 })),
23 ),
24 );
25 const initialEntries = yield* computeEntries;
26 // The dashboard falls back to the last good listing when a reload fails.
27 const lastEntries = yield* Ref.make(initialEntries);
28 const refreshEntries = computeEntries.pipe(
29 Effect.tap((entries) => Ref.set(lastEntries, entries)),
30 );
31 const { runProfileDashboardSession } = yield* Effect.promise(
32 () => import("../../components/view/ProfileDashboard.tsx"),
33 );
34 // Warm the entrypoint module cache before the dashboard mounts: the user's
35 // stack module evaluates synchronously on the main thread and would freeze
36 // the credential spinner mid-frame. Like deploy's planning session, show a
37 // static (non-spinning) row that makes no motion promise. Failures are
38 // ignored here — `loadDetails` re-imports from the cache and surfaces them.
39 const cli = yield* CliKit;
40 yield* Effect.scoped(
41 Effect.gen(function* () {
42 yield* cli.live.progress({
43 label: "Importing stack module",
44 spinning: false,
45 });
46 // let the row paint before synchronous module evaluation starts
47 yield* Effect.sleep("1 millis");
48 yield* importStack(options.main).pipe(Effect.ignore);
49 }),
50 );
51
52 yield* runProfileDashboardSession({
53 entries: initialEntries,
54 selected: initialEntries.find(({ isActive }) => isActive)?.name,
55 loadDetails: (name) =>
56 Effect.gen(function* () {
57 const [profile, providers] = yield* Effect.all([
58 Profiles.get({
59 name,
60 includeProviderStatus: true,
61 entrypoint: options.main,
62 envFile,
63 }),
64 Profiles.providers({
65 profile: name,
66 entrypoint: options.main,
67 envFile,
68 }),
69 ]);
70 return {
71 providers: profile.providers.map((provider) => ({
72 name: provider.name,
73 method: provider.method,
74 status:
75 provider.status === "connected"
76 ? ("configured" as const)
77 : provider.status === "needs-reauth"
78 ? ("reauth" as const)
79 : provider.status === "needs-reconfigure"
80 ? ("reconfigure" as const)
81 : ("error" as const),
82 lines: [
83 ...provider.details.map(({ key, value }) => `${key}: ${value}`),
84 ...(provider.diagnostic ? [provider.diagnostic.message] : []),
85 ],
86 })),
87 available: providers
88 .filter(({ connected }) => !connected)
89 .map(({ name }) => name),
90 };
91 }),
92 execute: (action) =>
93 Effect.gen(function* () {
94 let selected: string | undefined;
95 let message: string;
96 if (action.kind === "create") {
97 yield* Profiles.create({ name: action.name });
98 selected = action.name;
99 message = `Created profile '${action.name}'.`;
100 } else if (action.kind === "rename") {
101 yield* Profiles.rename({
102 name: action.name,
103 newName: action.newName,
104 });
105 selected = action.newName;
106 message = `Renamed '${action.name}' to '${action.newName}'.`;
107 } else {
108 yield* Profiles.deleteProfile({ name: action.name });
109 message = `Deleted '${action.name}' and its credentials.`;
110 }
111 const entries = yield* refreshEntries;
112 return { ok: true, message, entries, selected };
113 }).pipe(
114 Effect.catch((error) =>
115 Effect.map(Ref.get(lastEntries), (entries) => ({
116 ok: false,
117 message: error.message,
118 entries,
119 })),
120 ),
121 ),
122 runFlow: (action, events) =>
123 Effect.gen(function* () {
124 if (action.kind === "refresh") {
125 yield* Profiles.refresh({
126 profile: action.name,
127 providers:
128 action.provider === undefined ? undefined : [action.provider],
129 entrypoint: options.main,
130 envFile,
131 }).pipe(
132 Effect.provideService(Progress, (event) =>
133 event._tag === "provider.refresh.started"
134 ? events.onProviderStart(event.provider)
135 : Effect.void,
136 ),
137 );
138 return {
139 ok: true,
140 message:
141 action.provider === undefined
142 ? "Credentials refreshed."
143 : `${action.provider} credentials refreshed.`,
144 };
145 }
146
147 const outcomes: string[] = [];
148 for (const provider of action.remove) {
149 yield* Profiles.removeProvider({
150 profile: action.name,
151 provider,
152 entrypoint: options.main,
153 envFile,
154 });
155 outcomes.push(`${provider} removed`);
156 }
157 for (const [kind, names] of [
158 ["add", action.add],
159 ["reconfigure", action.reconfigure],
160 ] as const) {
161 for (const provider of names) {
162 yield* Profiles.configure({
163 profile: action.name,
164 provider,
165 entrypoint: options.main,
166 envFile,
167 action: kind,
168 });
169 outcomes.push(
170 `${provider} ${kind === "add" ? "added" : "updated"}`,
171 );
172 }
173 }
174 return {
175 ok: true,
176 message: outcomes.length === 0 ? "No changes." : outcomes.join("; "),
177 };
178 }).pipe(
179 Effect.catch((error) =>
180 Effect.succeed(
181 isPromptCancellation(error)
182 ? { ok: true, message: "Cancelled." }
183 : { ok: false, message: error.message },
184 ),
185 ),
186 ),
187 reloadEntries: refreshEntries.pipe(
188 Effect.catch(() => Ref.get(lastEntries)),
189 ),
190 });
191});