@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.
- package/LICENSE +1 -1
- package/README.md +64 -28
- package/dist/index.js +19918 -0
- package/package.json +7 -6
- package/src/__tests__/apply.test.ts +0 -80
- package/src/__tests__/recommend.test.ts +0 -109
- package/src/commands/add.ts +0 -163
- package/src/commands/apply.ts +0 -244
- package/src/commands/ci.ts +0 -110
- package/src/commands/clone.ts +0 -113
- package/src/commands/completion.ts +0 -187
- package/src/commands/deps.ts +0 -79
- package/src/commands/doctor.ts +0 -192
- package/src/commands/env.ts +0 -87
- package/src/commands/exec.ts +0 -54
- package/src/commands/import.ts +0 -120
- package/src/commands/info.ts +0 -85
- package/src/commands/init.ts +0 -93
- package/src/commands/list.ts +0 -27
- package/src/commands/login.ts +0 -28
- package/src/commands/open.ts +0 -32
- package/src/commands/projects.ts +0 -92
- package/src/commands/providers.ts +0 -38
- package/src/commands/recommend.ts +0 -302
- package/src/commands/remove.ts +0 -124
- package/src/commands/scan.ts +0 -123
- package/src/commands/status.ts +0 -39
- package/src/commands/sync.ts +0 -37
- package/src/commands/templates.ts +0 -130
- package/src/commands/upgrade.ts +0 -66
- package/src/index.ts +0 -91
- package/src/ui.ts +0 -39
package/src/commands/ci.ts
DELETED
|
@@ -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
|
-
});
|
package/src/commands/clone.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/deps.ts
DELETED
|
@@ -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
|
-
});
|
package/src/commands/doctor.ts
DELETED
|
@@ -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
|
-
}
|