1import * as Clock from "effect/Clock";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import { Command, Flag } from "effect/cli";
8import * as Argument from "effect/cli/Argument";
9import { readFileSync } from "node:fs";
10import { formatElapsed } from "../../Format.ts";
11
12import { Progress } from "../../../Alchemist/Progress.ts";
13import * as Profiles from "../../../Alchemist/routes/profile.ts";
14import { getEnv } from "../../../Auth/Env.ts";
15import { loadConfigProvider } from "../../../Util/ConfigProvider.ts";
16import * as CliKit from "../../../Cli/CliKit/index.ts";
17import { resolveProfileName } from "../../../Auth/Resolve.ts";
18
19import { exitDeclined, failWithHelp, UserInputError } from "../errors.ts";
20import { config, envFile, profile, yes } from "../flags.ts";
21import { instrumentCommand } from "../instrument.ts";
22import {
23 deleteProfileFlow,
24 editProfileFlow,
25 profileTui,
26 renameProfileFlow,
27 showProfileFlow,
28} from "./flows.ts";
29import { profileHub } from "./hub.ts";
30
31const profileName = Argument.String("name").pipe(
32 Argument.withDescription("Profile name"),
33);
34
35const newProfileName = Argument.String("new-name").pipe(
36 Argument.withDescription("New profile name"),
37 Argument.optional,
38);
39
40const refreshProviders = Flag.String("provider").pipe(
41 Flag.withDescription(
42 "Refresh only this connected provider (repeatable; defaults to all)",
43 ),
44 Flag.atLeast(0),
45);
46
47const showCommand = Command.make(
48 "show",
49 { profile, envFile, main: config },
50 instrumentCommand("profile.show", (a: { profile: string | undefined }) => ({
51 "alchemy.profile": a.profile ?? "",
52 }))(
53 Effect.fn(function* ({ profile, envFile, main }) {
54 const activeProfile = yield* resolveProfileName(envFile, undefined);
55 const profileName = profile ?? activeProfile;
56 yield* showProfileFlow({
57 profileName,
58 activeProfile,
59 envFile,
60 main,
61 });
62 }),
63 ),
64).pipe(
65 Command.withDescription(
66 "Show connected providers, authentication status, and account details",
67 ),
68);
69
70const listCommand = Command.make(
71 "list",
72 { envFile },
73 instrumentCommand("profile.list")(
74 Effect.fn(function* () {
75 const entries = yield* Profiles.list();
76 const cli = yield* CliKit.CliKit;
77 if (cli.terminal.input) {
78 const { profileListNode } = yield* profileTui;
79 yield* cli.output.print(profileListNode(entries));
80 } else {
81 yield* Console.log(
82 [
83 `Profiles (${entries.length})`,
84 ...entries.map((entry) => {
85 const providers = entry.providers
86 .map(({ name, method }) => `${name} (${method})`)
87 .join(", ");
88 return `${entry.active ? "*" : "-"} ${entry.name}${providers === "" ? "" : `: ${providers}`}`;
89 }),
90 ].join("\n"),
91 );
92 }
93 }),
94 ),
95).pipe(Command.withDescription("List profiles and their connected providers"));
96
97const createCommand = Command.make(
98 "create",
99 { name: profileName },
100 instrumentCommand("profile.create", (a: { name: string }) => ({
101 "alchemy.profile": a.name,
102 }))(
103 Effect.fn(function* ({ name }) {
104 yield* Profiles.create({ name });
105 yield* CliKit.accessors.output.success(
106 `Created profile '${name}'. Run \`alchemy profile edit --profile ${name}\` to connect accounts.`,
107 );
108 }),
109 ),
110).pipe(Command.withDescription("Create an empty authentication profile"));
111
112const renameCommand = Command.make(
113 "rename",
114 { name: profileName, newName: newProfileName },
115 instrumentCommand(
116 "profile.rename",
117 (a: { name: string; newName: Option.Option<string> }) => ({
118 "alchemy.profile": a.name,
119 "alchemy.profile.new_name": Option.getOrUndefined(a.newName) ?? "",
120 }),
121 )(
122 Effect.fn(function* ({ name, newName }) {
123 if (Option.isSome(newName)) {
124 yield* Profiles.rename({
125 name,
126 newName: newName.value.trim(),
127 });
128 yield* CliKit.accessors.output.success(
129 `Renamed profile '${name}' to '${newName.value.trim()}'.`,
130 );
131 return;
132 }
133 yield* renameProfileFlow(name, undefined);
134 }),
135 ),
136).pipe(
137 Command.withDescription(
138 "Rename a profile and move all credentials stored for it",
139 ),
140);
141
142const addProviders = Flag.String("add").pipe(
143 Flag.withDescription("Connect a provider to the profile (repeatable)"),
144 Flag.atLeast(0),
145);
146
147const reconfigureProviders = Flag.String("reconfigure").pipe(
148 Flag.withDescription(
149 "Re-run a connected provider's configuration (repeatable)",
150 ),
151 Flag.atLeast(0),
152);
153
154const removeProviders = Flag.String("remove").pipe(
155 Flag.withDescription(
156 "Log out a connected provider and disconnect it (repeatable)",
157 ),
158 Flag.atLeast(0),
159);
160
161const methodFlag = Flag.String("method").pipe(
162 Flag.withDescription(
163 "Configure non-interactively using this method (each provider documents its methods and fields in `alchemy profile edit --help`)",
164 ),
165 Flag.optional,
166 Flag.map(Option.getOrUndefined),
167);
168
169const setFlag = Flag.String("set").pipe(
170 Flag.withDescription(
171 "Field for non-interactive configure: name=value, name=env:VAR, or name=- to read the value from stdin (repeatable)",
172 ),
173 Flag.atLeast(0),
174);
175
176/**
177 * Resolve `--set` entries into concrete values. Three forms keep secrets
178 * out of shell history: a literal, `env:VAR` (read from the environment /
179 * --env-file), and `-` (read from stdin; at most one field may use it).
180 */
181const resolveSetValues = Effect.fn(function* (
182 sets: ReadonlyArray<string>,
183 envFile: Option.Option<string>,
184) {
185 const values: Record<string, string> = {};
186 let stdinUsed = false;
187 const configProvider = yield* loadConfigProvider(envFile);
188 for (const entry of sets) {
189 const separator = entry.indexOf("=");
190 if (separator <= 0) {
191 return yield* Effect.fail(
192 new UserInputError({
193 message: `Invalid --set '${entry}'. Expected name=value, name=env:VAR, or name=-.`,
194 }),
195 );
196 }
197 const key = entry.slice(0, separator);
198 const raw = entry.slice(separator + 1);
199 if (key in values) {
200 return yield* Effect.fail(
201 new UserInputError({ message: `Duplicate --set for '${key}'.` }),
202 );
203 }
204 if (raw === "-") {
205 if (stdinUsed) {
206 return yield* Effect.fail(
207 new UserInputError({
208 message: "Only one --set field may read from stdin.",
209 }),
210 );
211 }
212 if (process.stdin.isTTY) {
213 return yield* Effect.fail(
214 new UserInputError({
215 message: `--set ${key}=- reads from stdin, but stdin is a terminal. Pipe the value in.`,
216 }),
217 );
218 }
219 stdinUsed = true;
220 // Reading fd 0 to EOF is inherently synchronous whole-input
221 // consumption; there is no FileSystem-service surface for stdin.
222 values[key] = yield* Effect.try({
223 try: () => readFileSync(0, "utf8").trim(),
224 catch: (cause) =>
225 new UserInputError({
226 message: `Could not read stdin for --set ${key}=-: ${cause}`,
227 }),
228 });
229 } else if (raw.startsWith("env:")) {
230 const variable = raw.slice(4);
231 const value = yield* getEnv(variable).pipe(
232 Effect.provide(ConfigProvider.layer(configProvider)),
233 Effect.mapError((e) => new UserInputError({ message: e.message })),
234 );
235 if (value === undefined || value.length === 0) {
236 return yield* Effect.fail(
237 new UserInputError({
238 message: `--set ${key}=env:${variable}: '${variable}' is not set.`,
239 }),
240 );
241 }
242 values[key] = value;
243 } else {
244 values[key] = raw;
245 }
246 }
247 return values;
248});
249
250const editCommand = Command.make(
251 "edit",
252 {
253 profile,
254 add: addProviders,
255 reconfigure: reconfigureProviders,
256 remove: removeProviders,
257 method: methodFlag,
258 set: setFlag,
259 envFile,
260 main: config,
261 },
262 instrumentCommand(
263 "profile.edit",
264 (a: {
265 profile: string | undefined;
266 add: ReadonlyArray<string>;
267 reconfigure: ReadonlyArray<string>;
268 remove: ReadonlyArray<string>;
269 }) => ({
270 "alchemy.profile": a.profile ?? "",
271 "alchemy.add": a.add.join(","),
272 "alchemy.re_configure": a.reconfigure.join(","),
273 "alchemy.remove": a.remove.join(","),
274 }),
275 )(
276 Effect.fn(function* ({
277 profile,
278 add,
279 reconfigure,
280 remove,
281 method,
282 set,
283 envFile,
284 main,
285 }) {
286 const selectedProfile = yield* resolveProfileName(envFile, profile);
287 let configureInput:
288 | { method?: string; values: Record<string, string> }
289 | undefined;
290 if (method !== undefined || set.length > 0) {
291 if (add.length + reconfigure.length !== 1 || remove.length > 0) {
292 return yield* Effect.fail(
293 new UserInputError({
294 message:
295 "--method/--set configure exactly one provider: pass a single --add or --reconfigure (and no --remove).",
296 }),
297 );
298 }
299 configureInput = {
300 method,
301 values: yield* resolveSetValues(set, envFile),
302 };
303 }
304 if (configureInput !== undefined) {
305 const provider = (add[0] ?? reconfigure[0])!;
306 yield* Profiles.configure({
307 profile: selectedProfile,
308 provider,
309 entrypoint: main,
310 envFile: Option.getOrUndefined(envFile),
311 action: add.length === 1 ? "add" : "reconfigure",
312 method: configureInput.method ?? "",
313 values: Object.fromEntries(
314 Object.entries(configureInput.values).map(([key, value]) => [
315 key,
316 Redacted.make(value),
317 ]),
318 ),
319 });
320 yield* CliKit.accessors.output.success(
321 `${add.length === 1 ? "Added" : "Updated"} '${provider}' in profile '${selectedProfile}'.`,
322 );
323 return;
324 }
325 if (add.length === 0 && reconfigure.length === 0 && remove.length > 0) {
326 for (const provider of remove) {
327 yield* Profiles.removeProvider({
328 profile: selectedProfile,
329 provider,
330 entrypoint: main,
331 envFile: Option.getOrUndefined(envFile),
332 });
333 yield* CliKit.accessors.output.success(
334 `Removed '${provider}' from profile '${selectedProfile}'.`,
335 );
336 }
337 return;
338 }
339 yield* editProfileFlow({
340 selectedProfile,
341 add,
342 reconfigure,
343 remove,
344 envFile,
345 main,
346 configureInput,
347 });
348 }),
349 ),
350).pipe(
351 Command.withDescription(
352 "Add, reconfigure, or remove provider accounts in a profile",
353 ),
354);
355
356const refreshCommand = Command.make(
357 "refresh",
358 {
359 profile,
360 providers: refreshProviders,
361 envFile,
362 main: config,
363 },
364 instrumentCommand(
365 "profile.refresh",
366 (a: { profile: string | undefined; providers: ReadonlyArray<string> }) => ({
367 "alchemy.profile": a.profile ?? "",
368 "alchemy.providers": a.providers.join(","),
369 }),
370 )(
371 Effect.fn(function* ({ profile, providers, envFile, main }) {
372 const selectedProfile = yield* resolveProfileName(envFile, profile);
373 const refreshStarted = new Map<string, number>();
374 yield* Profiles.refresh({
375 profile: selectedProfile,
376 providers,
377 entrypoint: main,
378 envFile: Option.getOrUndefined(envFile),
379 }).pipe(
380 Effect.provideService(Progress, (event) =>
381 event._tag === "provider.refresh.started"
382 ? Clock.currentTimeMillis.pipe(
383 Effect.tap((now) =>
384 Effect.sync(() => refreshStarted.set(event.provider, now)),
385 ),
386 Effect.andThen(
387 CliKit.accessors.output.info(`Refreshing ${event.provider}`),
388 ),
389 )
390 : event._tag === "provider.refresh.completed"
391 ? Clock.currentTimeMillis.pipe(
392 Effect.flatMap((now) => {
393 const from = refreshStarted.get(event.provider);
394 return CliKit.accessors.output.success(
395 `Refreshed ${event.provider}${from === undefined ? "" : ` (${formatElapsed(now - from)})`}`,
396 );
397 }),
398 )
399 : Effect.void,
400 ),
401 );
402 yield* CliKit.accessors.output.success(
403 `Refreshed profile '${selectedProfile}'.`,
404 );
405 }),
406 ),
407).pipe(
408 Command.withDescription(
409 "Refresh credentials for connected providers without reconfiguring them",
410 ),
411);
412
413const currentCommand = Command.make(
414 "current",
415 { envFile },
416 instrumentCommand("profile.current")(
417 Effect.fn(function* () {
418 const selected = yield* Profiles.current();
419 const source =
420 selected.source === "configuration"
421 ? "ALCHEMY_PROFILE"
422 : selected.source === "default"
423 ? "built-in default"
424 : "command line";
425 const cli = yield* CliKit.CliKit;
426 if (cli.terminal.input) {
427 const { currentProfileNode } = yield* profileTui;
428 yield* cli.output.print(currentProfileNode(selected.name, source));
429 } else {
430 yield* Console.log(`${selected.name} (${source})`);
431 }
432 }),
433 ),
434).pipe(
435 Command.withDescription("Show the effective profile and how it was selected"),
436);
437
438const deleteCommand = Command.make(
439 "delete",
440 { name: profileName, envFile, main: config, yes },
441 instrumentCommand("profile.delete", (a: { name: string; yes: boolean }) => ({
442 "alchemy.profile": a.name,
443 "alchemy.yes": a.yes,
444 }))(
445 Effect.fn(function* ({ name, envFile, main, yes }) {
446 const deleted = yield* deleteProfileFlow({ name, envFile, main, yes });
447 // Declined or nothing-to-delete: exit non-zero so scripts don't read
448 // the run as "profile removed". (The dashboard confirms in its own UI
449 // and calls the shared delete core, so it is unaffected.)
450 if (!deleted) yield* exitDeclined;
451 }),
452 ),
453).pipe(
454 Command.withDescription("Delete a profile and all credentials stored for it"),
455);
456
457export const profileCommand = Command.make(
458 "profile",
459 { envFile, main: config },
460 instrumentCommand("profile")(
461 Effect.fn(function* ({ envFile, main }) {
462 if (!(yield* CliKit.CliKit).terminal.input) {
463 // No terminal to drive the hub — show the subcommand help instead,
464 // which documents the flag-driven equivalents of every hub action.
465 return yield* failWithHelp(["alchemy", "profile"]);
466 }
467 yield* profileHub({ envFile, main });
468 }),
469 ),
470).pipe(
471 Command.withDescription("Manage authentication profiles and accounts"),
472 Command.withSubcommands([
473 createCommand,
474 renameCommand,
475 editCommand,
476 refreshCommand,
477 listCommand,
478 showCommand,
479 currentCommand,
480 deleteCommand,
481 ]),
482);