@ashlr/stack 0.1.1 → 0.3.0

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.
@@ -1,110 +0,0 @@
1
- import { existsSync, mkdirSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
4
- import { defineCommand } from "citty";
5
- import { hasConfig } from "@ashlr/stack-core";
6
- import { colors, intro, outro, outroError } from "../ui.ts";
7
-
8
- const DEFAULT_WORKFLOW = `name: Stack doctor
9
-
10
- # Runs \`stack doctor --json --all\` in CI. Requires two repository secrets:
11
- #
12
- # PHANTOM_VAULT_PASSPHRASE — passphrase for the encrypted-file vault fallback
13
- # (Phantom uses this in CI where keychain isn't available).
14
- # PHANTOM_CLOUD_TOKEN — GitHub OAuth device token for \`phantom cloud pull\`.
15
- #
16
- # Both are created once with \`phantom login\` + \`phantom cloud push\` locally, then
17
- # copied into GitHub \`Settings → Secrets and variables → Actions\`.
18
-
19
- on:
20
- push:
21
- branches: [main]
22
- pull_request:
23
- schedule:
24
- # Nightly drift check — catches when an upstream key gets revoked out of band.
25
- - cron: "0 7 * * *"
26
- workflow_dispatch:
27
-
28
- jobs:
29
- doctor:
30
- runs-on: ubuntu-latest
31
- steps:
32
- - uses: actions/checkout@v4
33
-
34
- - name: Install Phantom Secrets
35
- run: npm install -g phantom-secrets
36
-
37
- - name: Install Stack CLI
38
- run: npm install -g @ashlr/stack
39
-
40
- - name: Pull vault from Phantom Cloud
41
- env:
42
- PHANTOM_CLOUD_TOKEN: \${{ secrets.PHANTOM_CLOUD_TOKEN }}
43
- PHANTOM_VAULT_PASSPHRASE: \${{ secrets.PHANTOM_VAULT_PASSPHRASE }}
44
- run: phantom cloud pull --force
45
-
46
- - name: Run stack doctor
47
- run: stack doctor --json > stack-doctor.json
48
-
49
- - name: Upload doctor report
50
- if: always()
51
- uses: actions/upload-artifact@v4
52
- with:
53
- name: stack-doctor-report
54
- path: stack-doctor.json
55
- `;
56
-
57
- /**
58
- * `stack ci init` — scaffold a ready-to-commit GitHub Actions workflow that
59
- * runs `stack doctor --json` on every push + nightly, backed by Phantom Cloud
60
- * for secret pull. The YAML is opinionated but gives a correct-by-default
61
- * starting point; users tweak from there.
62
- */
63
- export const ciCommand = defineCommand({
64
- meta: {
65
- name: "ci",
66
- description: "Scaffold CI integrations that run `stack doctor` on pushes + nightly.",
67
- },
68
- subCommands: {
69
- init: defineCommand({
70
- meta: {
71
- name: "init",
72
- description: "Write .github/workflows/stack-ci.yml for GitHub Actions.",
73
- },
74
- args: {
75
- force: {
76
- type: "boolean",
77
- default: false,
78
- description: "Overwrite an existing stack-ci.yml.",
79
- },
80
- },
81
- async run({ args }) {
82
- intro("stack ci init");
83
- if (!hasConfig()) {
84
- outroError("No .stack.toml found. Run `stack init` first.");
85
- return;
86
- }
87
- const cwd = process.cwd();
88
- const dir = resolve(join(cwd, ".github", "workflows"));
89
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
90
- const path = join(dir, "stack-ci.yml");
91
- if (existsSync(path) && !args.force) {
92
- outroError(`${path} already exists. Pass --force to overwrite.`);
93
- return;
94
- }
95
- await writeFile(path, DEFAULT_WORKFLOW, "utf-8");
96
- console.log();
97
- console.log(` ${colors.green("●")} wrote ${colors.bold(".github/workflows/stack-ci.yml")}`);
98
- console.log();
99
- console.log(colors.dim(" Next steps:"));
100
- console.log(colors.dim(" 1. Add PHANTOM_VAULT_PASSPHRASE + PHANTOM_CLOUD_TOKEN to repo secrets."));
101
- console.log(colors.dim(" 2. Commit the workflow."));
102
- console.log(
103
- colors.dim(" 3. Watch the first run in Actions — downloads the doctor report as an artifact."),
104
- );
105
- console.log();
106
- outro(colors.green("CI workflow scaffolded."));
107
- },
108
- }),
109
- },
110
- });
@@ -1,113 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { basename, resolve } from "node:path";
4
- import { defineCommand } from "citty";
5
- import { hasConfig, scanSource } from "@ashlr/stack-core";
6
- import { colors, intro, outro, outroError } from "../ui.ts";
7
-
8
- /**
9
- * `stack clone <git-url> [dir]` — git clone the repo, then run `stack scan` in
10
- * the new checkout and surface next-step guidance. Treats committed
11
- * .stack.toml as authoritative when it exists.
12
- */
13
- export const cloneCommand = defineCommand({
14
- meta: {
15
- name: "clone",
16
- description: "Clone a GitHub repo and auto-detect its Stack services.",
17
- },
18
- args: {
19
- url: { type: "positional", required: true, description: "GitHub / git URL to clone." },
20
- dir: { type: "positional", required: false, description: "Optional target directory." },
21
- },
22
- async run({ args }) {
23
- intro(`stack clone ${args.url}`);
24
- const url = String(args.url);
25
-
26
- if (!isAllowedGitUrl(url)) {
27
- outroError(
28
- "Only https:// and git@ URLs are allowed (no file://, no --option prefixes, no local paths).",
29
- );
30
- return;
31
- }
32
-
33
- const target = args.dir ? String(args.dir) : inferDirFromUrl(url);
34
- if (target.startsWith("-") || target.includes("..")) {
35
- outroError(`Refusing to clone into suspicious target path "${target}".`);
36
- return;
37
- }
38
-
39
- if (existsSync(target)) {
40
- outroError(`Target directory "${target}" already exists.`);
41
- return;
42
- }
43
-
44
- // `--` separates options from positional args so a crafted URL can't be
45
- // parsed as a git flag (e.g. --upload-pack=<cmd>).
46
- const result = spawnSync("git", ["clone", "--", url, target], { stdio: "inherit" });
47
- if (result.status !== 0) {
48
- outroError("git clone failed.");
49
- return;
50
- }
51
-
52
- const resolved = resolve(target);
53
- console.log();
54
-
55
- const committed = hasConfig(resolved);
56
- if (committed) {
57
- console.log(
58
- ` ${colors.green("●")} committed ${colors.bold(".stack.toml")} found — this repo is Stack-aware.`,
59
- );
60
- console.log(colors.dim(" Next steps:"));
61
- console.log(colors.dim(` cd ${target}`));
62
- console.log(colors.dim(` stack doctor --fix # verify, re-login for anything missing`));
63
- console.log(colors.dim(` stack exec -- bun dev # run with Phantom's proxy active`));
64
- } else {
65
- const hits = await scanSource(resolved);
66
- const highConfidence = hits.filter((h) => h.confidence === "high");
67
- if (hits.length === 0) {
68
- console.log(colors.dim(" No providers detected. You can still `cd in` and run `stack init`."));
69
- } else {
70
- console.log(
71
- ` ${colors.bold(String(hits.length))} provider(s) detected in source (${highConfidence.length} high-confidence):`,
72
- );
73
- for (const h of hits) {
74
- const dot =
75
- h.confidence === "high"
76
- ? colors.green("●")
77
- : h.confidence === "medium"
78
- ? colors.yellow("●")
79
- : colors.dim("●");
80
- console.log(` ${dot} ${h.provider.padEnd(12)} ${colors.dim(h.signals[0] ?? "")}`);
81
- }
82
- console.log(colors.dim("\n Next steps:"));
83
- console.log(colors.dim(` cd ${target}`));
84
- console.log(colors.dim(` stack scan --auto # offer to wire up each provider`));
85
- }
86
- }
87
-
88
- outro(colors.green(`Cloned to ${target}.`));
89
- },
90
- });
91
-
92
- function inferDirFromUrl(url: string): string {
93
- // Handle https://github.com/org/repo(.git), git@github.com:org/repo.git, etc.
94
- const trimmed = url.replace(/\.git$/i, "").replace(/\/$/, "");
95
- return basename(trimmed);
96
- }
97
-
98
- /**
99
- * Accept https://, http:// (for self-hosted), and git@host:org/repo SSH URLs
100
- * only. Reject file://, ext::, --option-prefixed strings, empty strings, and
101
- * anything else that could cause git to do something other than clone a
102
- * remote repo.
103
- */
104
- function isAllowedGitUrl(url: string): boolean {
105
- if (!url || url.length > 2048) return false;
106
- if (url.startsWith("-")) return false;
107
- if (/^file:/i.test(url)) return false;
108
- if (/^ext::/i.test(url)) return false;
109
- if (/^(https?:\/\/)/i.test(url)) return true;
110
- if (/^git@[\w.-]+:[\w./~-]+$/i.test(url)) return true;
111
- if (/^ssh:\/\//i.test(url)) return true;
112
- return false;
113
- }
@@ -1,187 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import { listProviderNames, listTemplates } from "@ashlr/stack-core";
3
-
4
- /**
5
- * `stack completion <shell>` — emit a shell completion script. Runtime-static
6
- * (we bake the command list + the currently-installed provider / template
7
- * names when it runs). Users re-run this after adding a new shell.
8
- *
9
- * stack completion bash > /etc/bash_completion.d/stack
10
- * stack completion zsh > ~/.local/share/zsh/site-functions/_stack
11
- * stack completion fish > ~/.config/fish/completions/stack.fish
12
- */
13
- export const completionCommand = defineCommand({
14
- meta: {
15
- name: "completion",
16
- description: "Emit shell completion for bash, zsh, or fish.",
17
- },
18
- args: {
19
- shell: {
20
- type: "positional",
21
- required: true,
22
- description: "One of: bash, zsh, fish.",
23
- },
24
- },
25
- async run({ args }) {
26
- const shell = String(args.shell).toLowerCase();
27
- const commands = [
28
- "init",
29
- "import",
30
- "scan",
31
- "clone",
32
- "add",
33
- "remove",
34
- "list",
35
- "info",
36
- "status",
37
- "env",
38
- "deps",
39
- "doctor",
40
- "exec",
41
- "sync",
42
- "open",
43
- "login",
44
- "templates",
45
- "providers",
46
- "projects",
47
- "upgrade",
48
- "completion",
49
- "ci",
50
- ];
51
- const providers = listProviderNames();
52
- const templates = listTemplates();
53
-
54
- if (shell === "bash") process.stdout.write(emitBash(commands, providers, templates));
55
- else if (shell === "zsh") process.stdout.write(emitZsh(commands, providers, templates));
56
- else if (shell === "fish") process.stdout.write(emitFish(commands, providers, templates));
57
- else {
58
- process.stderr.write(`Unsupported shell: ${shell}. Use bash, zsh, or fish.\n`);
59
- process.exit(1);
60
- }
61
- },
62
- });
63
-
64
- function emitBash(commands: string[], providers: string[], templates: string[]): string {
65
- return `# stack completion — bash
66
- _stack_completion() {
67
- local cur prev words cword
68
- _init_completion || return
69
-
70
- if [[ \${cword} -eq 1 ]]; then
71
- COMPREPLY=( $(compgen -W "${commands.join(" ")}" -- "\${cur}") )
72
- return
73
- fi
74
-
75
- case "\${words[1]}" in
76
- add|remove|info|login|open)
77
- COMPREPLY=( $(compgen -W "${providers.join(" ")}" -- "\${cur}") )
78
- ;;
79
- templates)
80
- if [[ \${cword} -eq 2 ]]; then
81
- COMPREPLY=( $(compgen -W "list apply" -- "\${cur}") )
82
- elif [[ "\${words[2]}" == "apply" ]]; then
83
- COMPREPLY=( $(compgen -W "${templates.join(" ")}" -- "\${cur}") )
84
- fi
85
- ;;
86
- sync)
87
- COMPREPLY=( $(compgen -W "--platform vercel railway fly" -- "\${cur}") )
88
- ;;
89
- env)
90
- COMPREPLY=( $(compgen -W "show diff set unset" -- "\${cur}") )
91
- ;;
92
- projects)
93
- COMPREPLY=( $(compgen -W "list register remove where" -- "\${cur}") )
94
- ;;
95
- completion)
96
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
97
- ;;
98
- esac
99
- }
100
- complete -F _stack_completion stack
101
- `;
102
- }
103
-
104
- function emitZsh(commands: string[], providers: string[], templates: string[]): string {
105
- return `#compdef stack
106
- # stack completion — zsh
107
-
108
- _stack() {
109
- local state
110
- _arguments -C \\
111
- '1: :->command' \\
112
- '*:: :->args'
113
-
114
- case \$state in
115
- command)
116
- _values 'stack command' ${commands.map((c) => `"${c}"`).join(" ")}
117
- ;;
118
- args)
119
- case \$words[1] in
120
- add|remove|info|login|open)
121
- _values 'provider' ${providers.map((p) => `"${p}"`).join(" ")}
122
- ;;
123
- templates)
124
- if [[ $CURRENT -eq 2 ]]; then
125
- _values 'subcommand' 'list' 'apply'
126
- elif [[ $words[2] == "apply" ]]; then
127
- _values 'template' ${templates.map((t) => `"${t}"`).join(" ")}
128
- fi
129
- ;;
130
- env)
131
- _values 'subcommand' 'show' 'diff' 'set' 'unset'
132
- ;;
133
- projects)
134
- _values 'subcommand' 'list' 'register' 'remove' 'where'
135
- ;;
136
- completion)
137
- _values 'shell' 'bash' 'zsh' 'fish'
138
- ;;
139
- esac
140
- ;;
141
- esac
142
- }
143
-
144
- _stack
145
- `;
146
- }
147
-
148
- function emitFish(commands: string[], providers: string[], templates: string[]): string {
149
- const lines: string[] = [
150
- "# stack completion — fish",
151
- "complete -c stack -f",
152
- "",
153
- ];
154
- for (const c of commands) {
155
- lines.push(
156
- `complete -c stack -n "__fish_use_subcommand" -a "${c}" -d "stack subcommand"`,
157
- );
158
- }
159
- for (const verb of ["add", "remove", "info", "login", "open"]) {
160
- for (const p of providers) {
161
- lines.push(
162
- `complete -c stack -n "__fish_seen_subcommand_from ${verb}" -a "${p}" -d "provider"`,
163
- );
164
- }
165
- }
166
- for (const sub of ["list", "apply"]) {
167
- lines.push(
168
- `complete -c stack -n "__fish_seen_subcommand_from templates" -a "${sub}" -d "template action"`,
169
- );
170
- }
171
- for (const t of templates) {
172
- lines.push(
173
- `complete -c stack -n "__fish_seen_subcommand_from apply" -a "${t}" -d "starter template"`,
174
- );
175
- }
176
- for (const sub of ["show", "diff", "set", "unset"]) {
177
- lines.push(
178
- `complete -c stack -n "__fish_seen_subcommand_from env" -a "${sub}" -d "env subcommand"`,
179
- );
180
- }
181
- for (const sh of ["bash", "zsh", "fish"]) {
182
- lines.push(
183
- `complete -c stack -n "__fish_seen_subcommand_from completion" -a "${sh}" -d "shell"`,
184
- );
185
- }
186
- return `${lines.join("\n")}\n`;
187
- }
@@ -1,79 +0,0 @@
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
- });
@@ -1,192 +0,0 @@
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
- }