@ashlr/stack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import { defineCommand } from "citty";
2
+ import { getProvider, readConfig } from "@ashlr/stack-core";
3
+ import { colors, intro, outro } from "../ui.ts";
4
+
5
+ /**
6
+ * `stack deps` — render an ASCII dependency graph of the services Stack has
7
+ * wired up for the current project, grouped by category and annotated with
8
+ * their secret slots. Good for onboarding a new developer ("here's what the
9
+ * stack looks like") and for sanity-checking templates.
10
+ */
11
+ export const depsCommand = defineCommand({
12
+ meta: {
13
+ name: "deps",
14
+ description: "Show the service dependency graph for this stack.",
15
+ },
16
+ async run() {
17
+ intro("stack deps");
18
+ const config = await readConfig();
19
+ const services = Object.entries(config.services);
20
+ if (services.length === 0) {
21
+ outro(colors.dim("No services yet."));
22
+ return;
23
+ }
24
+
25
+ // Group by category.
26
+ const byCategory: Record<string, Array<{ name: string; displayName: string; secrets: string[]; mcp?: string }>> = {};
27
+ for (const [name, entry] of services) {
28
+ try {
29
+ const p = await getProvider(entry.provider);
30
+ (byCategory[p.category] ??= []).push({
31
+ name,
32
+ displayName: p.displayName,
33
+ secrets: entry.secrets,
34
+ mcp: entry.mcp,
35
+ });
36
+ } catch {
37
+ /* skip */
38
+ }
39
+ }
40
+
41
+ console.log();
42
+ console.log(` ${colors.bold(config.stack.project_id)}`);
43
+ if (config.stack.template) {
44
+ console.log(` ${colors.dim("template:")} ${config.stack.template}`);
45
+ }
46
+ console.log();
47
+
48
+ const categories = Object.keys(byCategory).sort();
49
+ for (let i = 0; i < categories.length; i += 1) {
50
+ const cat = categories[i];
51
+ const isLast = i === categories.length - 1;
52
+ const branch = isLast ? "└──" : "├──";
53
+ const cont = isLast ? " " : "│ ";
54
+ console.log(` ${branch} ${colors.bold(cat)}`);
55
+ const items = byCategory[cat];
56
+ for (let j = 0; j < items.length; j += 1) {
57
+ const item = items[j];
58
+ const subLast = j === items.length - 1;
59
+ const subBranch = subLast ? "└──" : "├──";
60
+ const subCont = subLast ? " " : "│ ";
61
+ const mcpTag = item.mcp ? colors.cyan(` mcp:${item.mcp}`) : "";
62
+ console.log(` ${cont} ${subBranch} ${colors.green("●")} ${item.displayName}${mcpTag}`);
63
+ for (const secret of item.secrets) {
64
+ console.log(` ${cont} ${subCont} ${colors.dim("·")} ${colors.dim(secret)}`);
65
+ }
66
+ }
67
+ if (!isLast) console.log(` │`);
68
+ }
69
+ console.log();
70
+
71
+ const totalSecrets = services.reduce((acc, [, e]) => acc + e.secrets.length, 0);
72
+ const totalMcps = services.filter(([, e]) => e.mcp).length;
73
+ outro(
74
+ colors.dim(
75
+ `${services.length} services · ${totalSecrets} secrets · ${totalMcps} MCP server(s)`,
76
+ ),
77
+ );
78
+ },
79
+ });
@@ -0,0 +1,192 @@
1
+ import { defineCommand } from "citty";
2
+ import {
3
+ type ProviderContext,
4
+ addService,
5
+ getProvider,
6
+ isPhantomInstalled,
7
+ listProjects,
8
+ readConfig,
9
+ } from "@ashlr/stack-core";
10
+ import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
11
+
12
+ interface DoctorReport {
13
+ project: string;
14
+ path: string;
15
+ services: Array<{
16
+ name: string;
17
+ status: "ok" | "warn" | "error" | "unchecked";
18
+ detail?: string;
19
+ latencyMs?: number;
20
+ }>;
21
+ }
22
+
23
+ export const doctorCommand = defineCommand({
24
+ meta: {
25
+ name: "doctor",
26
+ description: "Verify every service is reachable and credentials are valid.",
27
+ },
28
+ args: {
29
+ fix: {
30
+ type: "boolean",
31
+ default: false,
32
+ description: "Attempt to auto-remediate by re-running `stack add` for failing services.",
33
+ },
34
+ all: {
35
+ type: "boolean",
36
+ default: false,
37
+ description: "Run doctor across every registered project on this machine.",
38
+ },
39
+ json: {
40
+ type: "boolean",
41
+ default: false,
42
+ description: "Emit machine-readable JSON (exit 0/1 on pass/fail). CI-friendly.",
43
+ },
44
+ },
45
+ async run({ args }) {
46
+ const json = Boolean(args.json);
47
+ if (!json) intro("stack doctor");
48
+
49
+ if (!(await isPhantomInstalled())) {
50
+ const msg = "Phantom is not installed. Stack cannot verify secrets without it.";
51
+ if (json) {
52
+ process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ outroError(msg);
57
+ return;
58
+ }
59
+
60
+ const scope: Array<{ path: string; name: string }> = args.all
61
+ ? (await listProjects()).map((p) => ({ path: p.path, name: p.name }))
62
+ : [{ path: process.cwd(), name: "cwd" }];
63
+
64
+ if (scope.length === 0) {
65
+ if (json) {
66
+ process.stdout.write(`${JSON.stringify({ reports: [] })}\n`);
67
+ return;
68
+ }
69
+ outro(colors.dim("No registered projects yet."));
70
+ return;
71
+ }
72
+
73
+ const reports: DoctorReport[] = [];
74
+ let anyFailed = false;
75
+ for (const project of scope) {
76
+ if (!json && args.all) {
77
+ console.log(`\n${colors.bold(project.name)} ${colors.dim(project.path)}`);
78
+ }
79
+ const report = await runDoctor(project.path, project.name, Boolean(args.fix), json);
80
+ reports.push(report);
81
+ if (report.services.some((s) => s.status === "error")) anyFailed = true;
82
+ }
83
+
84
+ if (json) {
85
+ process.stdout.write(`${JSON.stringify({ reports }, null, 2)}\n`);
86
+ process.exitCode = anyFailed ? 1 : 0;
87
+ return;
88
+ }
89
+
90
+ if (anyFailed)
91
+ outroError("One or more services failed. Re-run with --fix to attempt remediation.");
92
+ else outro(colors.green("All services healthy."));
93
+ },
94
+ });
95
+
96
+ async function runDoctor(
97
+ cwd: string,
98
+ projectName: string,
99
+ fix: boolean,
100
+ json: boolean,
101
+ ): Promise<DoctorReport> {
102
+ const report: DoctorReport = { project: projectName, path: cwd, services: [] };
103
+ const config = await readConfig(cwd).catch(() => undefined);
104
+ if (!config) {
105
+ if (!json) console.log(colors.dim(" (no .stack.toml)"));
106
+ return report;
107
+ }
108
+ const services = Object.entries(config.services);
109
+ if (services.length === 0) {
110
+ if (!json) console.log(colors.dim(" (no services configured)"));
111
+ return report;
112
+ }
113
+
114
+ const ctx: ProviderContext = {
115
+ cwd,
116
+ interactive: !json && process.stdout.isTTY === true,
117
+ log: json ? () => {} : logEvent,
118
+ };
119
+
120
+ const failingNames: string[] = [];
121
+ for (const [name, entry] of services) {
122
+ const spinner = json ? undefined : prompts.spinner();
123
+ spinner?.start(`Checking ${name}…`);
124
+ try {
125
+ const provider = await getProvider(name);
126
+ if (!provider.healthcheck) {
127
+ report.services.push({ name, status: "unchecked" });
128
+ spinner?.stop(colors.dim(` ${name}: no healthcheck implemented`));
129
+ continue;
130
+ }
131
+ const status = await provider.healthcheck(ctx, entry);
132
+ if (status.kind === "ok") {
133
+ report.services.push({ name, status: "ok", latencyMs: status.latencyMs });
134
+ spinner?.stop(
135
+ ` ${colors.green("●")} ${name}${status.latencyMs ? colors.dim(` (${status.latencyMs}ms)`) : ""}`,
136
+ );
137
+ } else if (status.kind === "warn") {
138
+ report.services.push({ name, status: "warn", detail: status.detail });
139
+ spinner?.stop(` ${colors.yellow("●")} ${name}: ${status.detail}`);
140
+ } else {
141
+ failingNames.push(name);
142
+ report.services.push({ name, status: "error", detail: status.detail });
143
+ spinner?.stop(` ${colors.red("●")} ${name}: ${status.detail}`);
144
+ }
145
+ } catch (err) {
146
+ failingNames.push(name);
147
+ report.services.push({ name, status: "error", detail: (err as Error).message });
148
+ spinner?.stop(` ${colors.red("●")} ${name}: ${(err as Error).message}`);
149
+ }
150
+ }
151
+
152
+ if (fix && failingNames.length > 0 && !json) {
153
+ console.log();
154
+ prompts.log.info(colors.bold(" Attempting remediation…"));
155
+ for (const name of failingNames) {
156
+ const entry = config.services[name];
157
+ // Confirm before re-running, because `addService` may trigger a browser
158
+ // OAuth flow or provision new upstream resources. Doing that silently
159
+ // during a routine `doctor --fix` surprises users.
160
+ const confirmed = process.stdout.isTTY
161
+ ? await prompts.confirm({
162
+ message: `Re-run setup for ${name}? (may open browser / create new resource)`,
163
+ initialValue: true,
164
+ })
165
+ : true;
166
+ if (!confirmed || prompts.isCancel(confirmed)) {
167
+ console.log(` ${colors.dim("·")} ${name}: skipped`);
168
+ continue;
169
+ }
170
+ const spinner = prompts.spinner();
171
+ spinner.start(` Re-running ${name}…`);
172
+ try {
173
+ await addService({
174
+ providerName: name,
175
+ cwd,
176
+ existingResourceId: entry.resource_id,
177
+ interactive: process.stdout.isTTY === true,
178
+ log: (event) => {
179
+ spinner.stop();
180
+ logEvent(event);
181
+ spinner.start(` Re-running ${name}…`);
182
+ },
183
+ });
184
+ spinner.stop(` ${colors.green("●")} ${name} re-wired.`);
185
+ } catch (err) {
186
+ spinner.stop(` ${colors.red("●")} ${name}: ${(err as Error).message}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ return report;
192
+ }
@@ -0,0 +1,87 @@
1
+ import { defineCommand } from "citty";
2
+ import {
3
+ isPhantomInstalled,
4
+ listSecrets,
5
+ readConfig,
6
+ } from "@ashlr/stack-core";
7
+ import { colors, intro, outro, outroError } from "../ui.ts";
8
+
9
+ /**
10
+ * `stack env show [--env prod]` — pretty-print the effective env-var surface
11
+ * for this stack. For each secret the project declares, show whether it's
12
+ * present in the Phantom vault (masked) or missing. Designed for debugging
13
+ * "why does my app boot without Supabase?" kinds of questions.
14
+ */
15
+ export const envCommand = defineCommand({
16
+ meta: {
17
+ name: "env",
18
+ description: "Inspect the effective env-var map for this stack.",
19
+ },
20
+ subCommands: {
21
+ show: defineCommand({
22
+ meta: { name: "show", description: "Show which secrets are present in Phantom." },
23
+ args: {
24
+ env: { type: "string", default: "dev", description: "Environment overlay to preview." },
25
+ },
26
+ async run({ args }) {
27
+ intro(`stack env show (${args.env})`);
28
+ const phantomOk = await isPhantomInstalled();
29
+ if (!phantomOk) {
30
+ outroError("Phantom not installed.");
31
+ return;
32
+ }
33
+ const config = await readConfig();
34
+ const vaultKeys = new Set(await listSecrets());
35
+ console.log();
36
+ for (const [name, entry] of Object.entries(config.services)) {
37
+ console.log(` ${colors.bold(name)}`);
38
+ for (const slot of entry.secrets) {
39
+ const present = vaultKeys.has(slot);
40
+ const dot = present ? colors.green("●") : colors.red("○");
41
+ const value = present ? colors.dim("(phm_…)") : colors.red("missing");
42
+ console.log(` ${dot} ${slot.padEnd(30)} ${value}`);
43
+ }
44
+ }
45
+ const declared = new Set(
46
+ Object.values(config.services).flatMap((e) => e.secrets),
47
+ );
48
+ const orphans = [...vaultKeys].filter((k) => !declared.has(k)).sort();
49
+ if (orphans.length > 0) {
50
+ console.log();
51
+ console.log(colors.dim(` orphans in vault (not referenced by any service):`));
52
+ for (const k of orphans) console.log(` ${colors.yellow("●")} ${k}`);
53
+ }
54
+ console.log();
55
+ outro(
56
+ colors.dim(
57
+ `${declared.size} declared · ${vaultKeys.size} in vault · ${orphans.length} orphan(s)`,
58
+ ),
59
+ );
60
+ },
61
+ }),
62
+ diff: defineCommand({
63
+ meta: { name: "diff", description: "Which declared secrets are missing from the vault?" },
64
+ async run() {
65
+ intro("stack env diff");
66
+ if (!(await isPhantomInstalled())) {
67
+ outroError("Phantom not installed.");
68
+ return;
69
+ }
70
+ const config = await readConfig();
71
+ const vaultKeys = new Set(await listSecrets());
72
+ const missing: string[] = [];
73
+ for (const entry of Object.values(config.services)) {
74
+ for (const slot of entry.secrets) {
75
+ if (!vaultKeys.has(slot)) missing.push(slot);
76
+ }
77
+ }
78
+ if (missing.length === 0) {
79
+ outro(colors.green("All declared secrets present."));
80
+ return;
81
+ }
82
+ for (const s of missing) console.log(` ${colors.red("○")} ${s}`);
83
+ outroError(`${missing.length} secret(s) missing from the vault.`);
84
+ },
85
+ }),
86
+ },
87
+ });
@@ -0,0 +1,54 @@
1
+ import { spawn } from "node:child_process";
2
+ import { defineCommand } from "citty";
3
+ import { assertPhantomInstalled, hasConfig } from "@ashlr/stack-core";
4
+ import { colors, intro, outroError } from "../ui.ts";
5
+
6
+ /**
7
+ * `stack exec -- <cmd>` — run a command with Phantom's proxy active so env
8
+ * vars with `phm_` tokens get swapped for real secrets at the network layer.
9
+ * Thin wrapper over `phantom exec` so users don't have to context-switch
10
+ * between Stack and Phantom for day-to-day work.
11
+ */
12
+ export const execCommand = defineCommand({
13
+ meta: {
14
+ name: "exec",
15
+ description: "Run a command with Phantom's secret proxy active.",
16
+ },
17
+ args: {
18
+ _: { type: "positional", required: false, description: "Command to run after --" },
19
+ },
20
+ async run({ rawArgs }) {
21
+ intro("stack exec");
22
+
23
+ if (!hasConfig()) {
24
+ outroError("No .stack.toml found. Run `stack init` first.");
25
+ return;
26
+ }
27
+
28
+ try {
29
+ await assertPhantomInstalled();
30
+ } catch (err) {
31
+ outroError((err as Error).message);
32
+ return;
33
+ }
34
+
35
+ // Forward everything after `--` (or everything after `exec`) to `phantom exec`.
36
+ const sep = rawArgs.indexOf("--");
37
+ const forwarded = sep >= 0 ? rawArgs.slice(sep + 1) : rawArgs;
38
+ if (forwarded.length === 0) {
39
+ outroError("Nothing to run. Usage: stack exec -- <command>");
40
+ return;
41
+ }
42
+
43
+ const child = spawn("phantom", ["exec", "--", ...forwarded], { stdio: "inherit" });
44
+ child.on("close", (code) => {
45
+ if (code !== 0) {
46
+ process.stderr.write(colors.dim(`\n[stack exec] phantom exec exited ${code}\n`));
47
+ }
48
+ process.exitCode = code ?? 0;
49
+ });
50
+ child.on("error", (err) => {
51
+ outroError((err as Error).message);
52
+ });
53
+ },
54
+ });
@@ -0,0 +1,120 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { defineCommand } from "citty";
5
+ import {
6
+ addSecret,
7
+ assertPhantomInstalled,
8
+ emptyConfig,
9
+ groupByProvider,
10
+ hasConfig,
11
+ listProviderNames,
12
+ parseEnv,
13
+ readConfig,
14
+ toServiceEntry,
15
+ writeConfig,
16
+ } from "@ashlr/stack-core";
17
+ import { colors, intro, outro, outroError, prompts } from "../ui.ts";
18
+
19
+ /**
20
+ * `stack import [--from .env]` — inhale an existing .env, route each secret
21
+ * into Phantom, and populate .stack.toml with a best-guess service map.
22
+ *
23
+ * This is the most important migration command: most users arrive at Stack
24
+ * with projects that already have filled-in .env files. They shouldn't have
25
+ * to re-paste 20 keys to try the product.
26
+ */
27
+ export const importCommand = defineCommand({
28
+ meta: {
29
+ name: "import",
30
+ description: "Import an existing .env into Phantom + .stack.toml.",
31
+ },
32
+ args: {
33
+ from: { type: "string", default: ".env", description: "Path to the .env file to import." },
34
+ dryRun: {
35
+ type: "boolean",
36
+ default: false,
37
+ description: "Print what would happen without writing anything.",
38
+ },
39
+ },
40
+ async run({ args }) {
41
+ intro("stack import");
42
+
43
+ const envPath = join(process.cwd(), String(args.from));
44
+ if (!existsSync(envPath)) {
45
+ outroError(`${envPath} not found.`);
46
+ return;
47
+ }
48
+
49
+ try {
50
+ await assertPhantomInstalled();
51
+ } catch (err) {
52
+ if (!args.dryRun) {
53
+ outroError((err as Error).message);
54
+ return;
55
+ }
56
+ prompts.log.warn(colors.yellow("Phantom not installed — dry-run mode will still preview."));
57
+ }
58
+
59
+ const raw = await readFile(envPath, "utf-8");
60
+ const entries = parseEnv(raw);
61
+ if (entries.length === 0) {
62
+ outro(colors.dim(`${args.from}: nothing to import.`));
63
+ return;
64
+ }
65
+
66
+ const grouped = groupByProvider(entries.map((e) => e.key));
67
+ const registered = new Set(listProviderNames());
68
+ const detectedProviders = Object.keys(grouped).filter((p) => registered.has(p));
69
+ const detectedSecretCount = detectedProviders.reduce(
70
+ (acc, p) => acc + grouped[p].length,
71
+ 0,
72
+ );
73
+ const orphanSecrets = entries
74
+ .map((e) => e.key)
75
+ .filter((name) => !detectedProviders.some((p) => grouped[p].includes(name)));
76
+
77
+ console.log();
78
+ console.log(
79
+ ` ${colors.bold(String(entries.length))} env vars · ${colors.bold(String(detectedProviders.length))} providers detected · ${colors.bold(String(orphanSecrets.length))} unattributed`,
80
+ );
81
+ for (const provider of detectedProviders) {
82
+ console.log(
83
+ ` ${colors.green("●")} ${provider.padEnd(14)} ${colors.dim(grouped[provider].join(", "))}`,
84
+ );
85
+ }
86
+ if (orphanSecrets.length > 0) {
87
+ console.log(
88
+ ` ${colors.dim("·")} ${"(orphans)".padEnd(14)} ${colors.dim(orphanSecrets.join(", "))}`,
89
+ );
90
+ }
91
+ console.log();
92
+
93
+ if (args.dryRun) {
94
+ outro(colors.dim("dry-run: nothing written."));
95
+ return;
96
+ }
97
+
98
+ // 1. Store every secret in Phantom (provider-matched + orphans alike).
99
+ let stored = 0;
100
+ for (const { key, value } of entries) {
101
+ if (value.length === 0) continue;
102
+ await addSecret(key, value);
103
+ stored += 1;
104
+ }
105
+
106
+ // 2. Update .stack.toml — create one if missing, then populate service entries.
107
+ const config = hasConfig() ? await readConfig() : emptyConfig();
108
+ for (const provider of detectedProviders) {
109
+ if (config.services[provider]) continue; // don't clobber pre-existing entries
110
+ config.services[provider] = toServiceEntry(provider, grouped[provider]);
111
+ }
112
+ await writeConfig(config);
113
+
114
+ outro(
115
+ colors.green(
116
+ `Imported ${stored} secret(s); wired ${detectedProviders.length} service(s) in .stack.toml. Run \`stack doctor\` to verify.`,
117
+ ),
118
+ );
119
+ },
120
+ });
@@ -0,0 +1,85 @@
1
+ import { defineCommand } from "citty";
2
+ import {
3
+ type ProviderContext,
4
+ getProvider,
5
+ isPhantomInstalled,
6
+ listSecrets,
7
+ readConfig,
8
+ } from "@ashlr/stack-core";
9
+ import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
10
+
11
+ /**
12
+ * `stack info <service>` — deep-dive on a configured service. Prints its
13
+ * provider, resource id, region, secret slots (with vault presence), MCP
14
+ * wiring, dashboard URL, and runs a fresh healthcheck.
15
+ */
16
+ export const infoCommand = defineCommand({
17
+ meta: {
18
+ name: "info",
19
+ description: "Show everything Stack knows about a configured service.",
20
+ },
21
+ args: {
22
+ service: { type: "positional", required: true, description: "Service name." },
23
+ },
24
+ async run({ args }) {
25
+ intro(`stack info ${args.service}`);
26
+ const config = await readConfig();
27
+ const entry = config.services[String(args.service)];
28
+ if (!entry) {
29
+ outroError(`${args.service} is not in this stack. Run \`stack list\`.`);
30
+ return;
31
+ }
32
+
33
+ const provider = await getProvider(entry.provider);
34
+ const vaultKeys = (await isPhantomInstalled()) ? new Set(await listSecrets()) : new Set();
35
+
36
+ console.log();
37
+ console.log(` ${colors.bold(provider.displayName)} ${colors.dim(provider.category)}`);
38
+ console.log(` ${colors.dim("resource:")} ${entry.resource_id ?? "(not provisioned)"}`);
39
+ if (entry.region) console.log(` ${colors.dim("region:")} ${entry.region}`);
40
+ console.log(` ${colors.dim("auth:")} ${provider.authKind}`);
41
+ console.log(` ${colors.dim("created:")} ${entry.created_at}${entry.created_by ? colors.dim(` by ${entry.created_by}`) : ""}`);
42
+ if (provider.docs) console.log(` ${colors.dim("docs:")} ${provider.docs}`);
43
+ const dashboard = provider.dashboardUrl?.(entry);
44
+ if (dashboard) console.log(` ${colors.dim("dash:")} ${dashboard}`);
45
+ if (entry.mcp) console.log(` ${colors.dim("mcp:")} ${entry.mcp}`);
46
+ if (entry.meta && Object.keys(entry.meta).length > 0) {
47
+ console.log(` ${colors.dim("meta:")} ${JSON.stringify(entry.meta)}`);
48
+ }
49
+ console.log();
50
+
51
+ console.log(` ${colors.bold("secrets")}`);
52
+ for (const slot of entry.secrets) {
53
+ const present = vaultKeys.has(slot);
54
+ const dot = present ? colors.green("●") : colors.red("○");
55
+ const tag = present ? colors.dim("in vault") : colors.red("missing");
56
+ console.log(` ${dot} ${slot.padEnd(30)} ${tag}`);
57
+ }
58
+ console.log();
59
+
60
+ if (provider.healthcheck) {
61
+ const ctx: ProviderContext = {
62
+ cwd: process.cwd(),
63
+ interactive: false,
64
+ log: logEvent,
65
+ };
66
+ const spinner = prompts.spinner();
67
+ spinner.start("Running healthcheck…");
68
+ try {
69
+ const status = await provider.healthcheck(ctx, entry);
70
+ if (status.kind === "ok") {
71
+ spinner.stop(
72
+ `${colors.green("●")} healthy${status.latencyMs ? colors.dim(` (${status.latencyMs}ms)`) : ""}`,
73
+ );
74
+ } else if (status.kind === "warn") {
75
+ spinner.stop(`${colors.yellow("●")} warning: ${status.detail}`);
76
+ } else {
77
+ spinner.stop(`${colors.red("●")} error: ${status.detail}`);
78
+ }
79
+ } catch (err) {
80
+ spinner.stop(`${colors.red("●")} healthcheck threw: ${(err as Error).message}`);
81
+ }
82
+ }
83
+ outro("");
84
+ },
85
+ });