| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import * as Ref from "effect/Ref"; |
| 4 | import { Progress } from "../../../Alchemist/Progress.ts"; |
| 5 | import * as Profiles from "../../../Alchemist/routes/profile.ts"; |
| 6 | import { importStack } from "../../../Alchemist/Session.ts"; |
| 7 | import { DEFAULT_PROFILE_NAME } from "../../../Auth/Profile.ts"; |
| 8 | import { CliKit } from "../../CliKit/index.ts"; |
| 9 | import { isPromptCancellation } from "../errors.ts"; |
| 10 | |
| 11 | export 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 | }); |