@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.
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +65 -0
- package/src/__tests__/apply.test.ts +80 -0
- package/src/__tests__/recommend.test.ts +109 -0
- package/src/commands/add.ts +163 -0
- package/src/commands/apply.ts +244 -0
- package/src/commands/ci.ts +110 -0
- package/src/commands/clone.ts +113 -0
- package/src/commands/completion.ts +187 -0
- package/src/commands/deps.ts +79 -0
- package/src/commands/doctor.ts +192 -0
- package/src/commands/env.ts +87 -0
- package/src/commands/exec.ts +54 -0
- package/src/commands/import.ts +120 -0
- package/src/commands/info.ts +85 -0
- package/src/commands/init.ts +93 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/login.ts +28 -0
- package/src/commands/open.ts +32 -0
- package/src/commands/projects.ts +92 -0
- package/src/commands/providers.ts +38 -0
- package/src/commands/recommend.ts +302 -0
- package/src/commands/remove.ts +124 -0
- package/src/commands/scan.ts +123 -0
- package/src/commands/status.ts +39 -0
- package/src/commands/sync.ts +37 -0
- package/src/commands/templates.ts +130 -0
- package/src/commands/upgrade.ts +66 -0
- package/src/index.ts +91 -0
- package/src/ui.ts +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ashlr / Mason Wyatt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# @ashlr/stack
|
|
2
|
+
|
|
3
|
+
> The `stack` CLI — one command to provision, wire, and operate every third-party service in your project. 23 providers, MCP-native, Phantom-backed.
|
|
4
|
+
|
|
5
|
+
Part of [Ashlr Stack](https://stack.ashlr.ai). See the monorepo at [ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack).
|
|
6
|
+
|
|
7
|
+
## What it is
|
|
8
|
+
|
|
9
|
+
`stack` collapses the "spin up ten services" phase of a project into a single command. Instead of tab-hopping between Supabase, Vercel, Sentry, Neon, etc. to create projects and paste secrets into `.env`, you run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
stack init --template nextjs-supabase-posthog-sentry
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Stack does the OAuth dance per provider, creates the upstream resource, stores every secret in [Phantom Secrets](https://phantom.ashlr.ai), writes `.env` + `.mcp.json`, and hands you a project ready for `bun dev`.
|
|
16
|
+
|
|
17
|
+
Secrets never live in Stack — only Phantom. `.stack.toml` is safe to commit; per-developer IDs live in the gitignored `.stack.local.toml`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
One-liner (recommended):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# macOS / Linux
|
|
25
|
+
curl -fsSL stack.ashlr.ai/install.sh | bash
|
|
26
|
+
|
|
27
|
+
# Windows (PowerShell)
|
|
28
|
+
irm stack.ashlr.ai/install.ps1 | iex
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install manually from the registry:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun add -g @ashlr/stack ashlr-stack-mcp # preferred
|
|
35
|
+
# or
|
|
36
|
+
npm i -g @ashlr/stack ashlr-stack-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Prerequisites:
|
|
40
|
+
|
|
41
|
+
- [Bun](https://bun.sh) >= 1.2 (or Node.js >= 20)
|
|
42
|
+
- [Phantom Secrets](https://phantom.ashlr.ai) — the installer sets this up for you
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
stack init # interactive template picker
|
|
48
|
+
stack add supabase # OAuth → new project → secrets → .mcp.json
|
|
49
|
+
stack add # no arg = interactive provider picker
|
|
50
|
+
stack doctor # verify every wired service is healthy
|
|
51
|
+
stack doctor --all # run across every registered project
|
|
52
|
+
stack list # show this project's stack at a glance
|
|
53
|
+
stack providers # full catalog of 23 curated providers
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Bring Stack to an existing repo:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
stack scan # detect providers already in the code
|
|
60
|
+
stack scan --auto # scans, then runs `stack add` for each
|
|
61
|
+
stack import # inhale an existing .env into Phantom
|
|
62
|
+
stack clone github.com/org/repo # clone + scan someone else's project
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Run your app with secrets injected by Phantom:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
stack exec -- bun dev
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
22 commands total. A cheat sheet:
|
|
74
|
+
|
|
75
|
+
| Command | What it does |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `stack init [--template]` | Scaffold a new stack from a template |
|
|
78
|
+
| `stack add [service]` | Add a provider (OAuth + provision + write secrets) |
|
|
79
|
+
| `stack remove <service>` | Tear down one service (or `--all` for everything) |
|
|
80
|
+
| `stack list` | Show services wired into this project |
|
|
81
|
+
| `stack info <service>` | Deep-dive on one service (resource, region, docs, MCP name) |
|
|
82
|
+
| `stack doctor [--all] [--fix] [--json]` | Healthcheck; `--fix` re-runs setup, `--json` for CI |
|
|
83
|
+
| `stack scan [--auto]` | Detect providers from package manifests, configs, `.env.example` |
|
|
84
|
+
| `stack import [--from <file>]` | Migrate an existing `.env` into Phantom + `.stack.toml` |
|
|
85
|
+
| `stack clone <github-url>` | git-clone + scan + next-steps |
|
|
86
|
+
| `stack providers` | Browse the full 23-provider catalog |
|
|
87
|
+
| `stack templates` | List starter templates |
|
|
88
|
+
| `stack deps` | ASCII tree of this stack grouped by category |
|
|
89
|
+
| `stack env show` / `stack env diff` | Effective env-var surface vs vault vs config |
|
|
90
|
+
| `stack env set <KEY> <VALUE>` / `stack env unset <KEY>` | Direct secret injection |
|
|
91
|
+
| `stack exec -- <cmd>` | Run a command with Phantom secrets in the environment |
|
|
92
|
+
| `stack projects list|register|remove|where` | Cross-project registry |
|
|
93
|
+
| `stack ci init` | Scaffold a `.github/workflows/stack-ci.yml` |
|
|
94
|
+
| `stack completion <bash\|zsh\|fish>` | Emit a shell-completion script |
|
|
95
|
+
| `stack upgrade` | Check npm for a newer `@ashlr/stack` release |
|
|
96
|
+
|
|
97
|
+
Full reference with flags, subcommands, and examples: **[stack.ashlr.ai/docs/cli](https://stack.ashlr.ai/docs/cli)**.
|
|
98
|
+
|
|
99
|
+
## MCP integration
|
|
100
|
+
|
|
101
|
+
`stack add` writes to your project's `.mcp.json` so Claude Code / Cursor / Windsurf can use the provider's MCP server immediately. See [ashlr-stack-mcp](https://www.npmjs.com/package/ashlr-stack-mcp) for Stack's own MCP server (17 tools + 3 resources) and [stack.ashlr.ai/docs/mcp](https://stack.ashlr.ai/docs/mcp) for wiring docs.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Stack splits config into two files:
|
|
106
|
+
|
|
107
|
+
- `.stack.toml` — **committed**. Service names, secret slots, MCP wirings.
|
|
108
|
+
- `.stack.local.toml` — **gitignored**. `project_id`, `resource_id`, timestamps. Unique per clone.
|
|
109
|
+
|
|
110
|
+
Teammates who clone the repo run `stack doctor --fix` and Stack re-authenticates each service for them.
|
|
111
|
+
|
|
112
|
+
Full schema: [stack.ashlr.ai/docs/config](https://stack.ashlr.ai/docs/config).
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- Docs — [stack.ashlr.ai/docs](https://stack.ashlr.ai/docs)
|
|
117
|
+
- Quickstart — [stack.ashlr.ai/docs/quickstart](https://stack.ashlr.ai/docs/quickstart)
|
|
118
|
+
- Provider catalog — [stack.ashlr.ai/docs/providers](https://stack.ashlr.ai/docs/providers)
|
|
119
|
+
- Repo — [github.com/ashlrai/ashlr-stack](https://github.com/ashlrai/ashlr-stack)
|
|
120
|
+
- Issues — [github.com/ashlrai/ashlr-stack/issues](https://github.com/ashlrai/ashlr-stack/issues)
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT. See [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashlr/stack",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Ashlr Stack CLI — the control plane for your entire dev stack. One command to provision, wire, and operate every third-party service in your project. 23 providers, MCP-native.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Mason Wyatt",
|
|
8
|
+
"url": "https://github.com/ashlrai"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://stack.ashlr.ai",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ashlrai/ashlr-stack.git",
|
|
14
|
+
"directory": "packages/cli"
|
|
15
|
+
},
|
|
16
|
+
"bugs": "https://github.com/ashlrai/ashlr-stack/issues",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ashlr",
|
|
19
|
+
"stack",
|
|
20
|
+
"cli",
|
|
21
|
+
"mcp",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"phantom-secrets",
|
|
24
|
+
"supabase",
|
|
25
|
+
"neon",
|
|
26
|
+
"vercel",
|
|
27
|
+
"cloudflare",
|
|
28
|
+
"openai",
|
|
29
|
+
"anthropic",
|
|
30
|
+
"provisioning",
|
|
31
|
+
"devops",
|
|
32
|
+
"control-plane"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"bin": {
|
|
36
|
+
"stack": "./src/index.ts"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src/**/*",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.2.0",
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"dev": "bun run src/index.ts",
|
|
53
|
+
"typecheck": "bunx tsc --noEmit"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@ashlr/stack-core": "^0.1.1",
|
|
57
|
+
"@clack/prompts": "^0.8.2",
|
|
58
|
+
"citty": "^0.1.6",
|
|
59
|
+
"picocolors": "^1.1.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/bun": "latest",
|
|
63
|
+
"typescript": "^5.7.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* End-to-end test for `stack apply` — specifically the auto-init behaviour.
|
|
10
|
+
* The marketed golden path is `stack recommend --save && stack apply <id>`
|
|
11
|
+
* from a blank directory; that must not explode on a missing .stack.toml.
|
|
12
|
+
*
|
|
13
|
+
* We don't exercise the provider-provisioning side (real HTTP, OAuth, tokens
|
|
14
|
+
* required). `--noWire` + `--noRollback` isolate the init+pipeline behaviour.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const CLI_ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
|
|
18
|
+
|
|
19
|
+
function runCli(args: string[], cwd?: string): { stdout: string; stderr: string; code: number } {
|
|
20
|
+
const result = spawnSync("bun", [CLI_ENTRY, ...args], {
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
cwd,
|
|
23
|
+
env: { ...process.env, NO_COLOR: "1", CI: "1" },
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
stdout: result.stdout ?? "",
|
|
27
|
+
stderr: result.stderr ?? "",
|
|
28
|
+
code: result.status ?? 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const createdDirs: string[] = [];
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
while (createdDirs.length > 0) {
|
|
35
|
+
const dir = createdDirs.pop();
|
|
36
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function mkTmp(): string {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-apply-"));
|
|
42
|
+
createdDirs.push(dir);
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("stack apply", () => {
|
|
47
|
+
it("auto-inits .stack.toml when none exists, then runs against the recipe", () => {
|
|
48
|
+
const dir = mkTmp();
|
|
49
|
+
|
|
50
|
+
// Step 1: seed a recipe via `stack recommend --save`.
|
|
51
|
+
const rec = runCli(["recommend", "postgres database", "--save", "--json"], dir);
|
|
52
|
+
expect(rec.code).toBe(0);
|
|
53
|
+
const payload = JSON.parse(rec.stdout);
|
|
54
|
+
const recipeId: string = payload.recipe.id;
|
|
55
|
+
expect(existsSync(join(dir, ".stack.toml"))).toBe(false);
|
|
56
|
+
|
|
57
|
+
// Step 2: apply it from the same blank dir. Providers will fail at `stack
|
|
58
|
+
// add` because no OAuth/tokens are present, but `apply` must first create
|
|
59
|
+
// .stack.toml via the auto-init path.
|
|
60
|
+
const applied = runCli(["apply", recipeId, "--noWire", "--noRollback"], dir);
|
|
61
|
+
|
|
62
|
+
// Apply may exit non-zero because provider adds fail; that's expected.
|
|
63
|
+
// The invariant we care about: auto-init ran, so .stack.toml now exists.
|
|
64
|
+
expect(existsSync(join(dir, ".stack.toml"))).toBe(true);
|
|
65
|
+
|
|
66
|
+
// The banner breadcrumb should surface so the user understands the
|
|
67
|
+
// auto-init happened implicitly.
|
|
68
|
+
const out = applied.stdout + applied.stderr;
|
|
69
|
+
expect(out).toContain("auto-running");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects a missing recipe id with a clear error", () => {
|
|
73
|
+
const dir = mkTmp();
|
|
74
|
+
const { stdout, stderr, code } = runCli(["apply", "does-not-exist", "--noWire"], dir);
|
|
75
|
+
// Apply exits after pickRecipe fails; the surfaced error must name the
|
|
76
|
+
// recipe so the user knows what to fix.
|
|
77
|
+
expect(code).toBeGreaterThan(0);
|
|
78
|
+
expect(stdout + stderr).toContain("does-not-exist");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Smoke tests for `stack recommend`. Spawns the CLI the same way the MCP
|
|
10
|
+
* server will (bun subprocess) to cover the full JSON contract end-to-end.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const CLI_ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
|
|
14
|
+
|
|
15
|
+
function runCli(args: string[], cwd?: string): { stdout: string; stderr: string; code: number } {
|
|
16
|
+
const result = spawnSync("bun", [CLI_ENTRY, ...args], {
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
cwd,
|
|
19
|
+
env: { ...process.env, NO_COLOR: "1", CI: "1" },
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
stdout: result.stdout ?? "",
|
|
23
|
+
stderr: result.stderr ?? "",
|
|
24
|
+
code: result.status ?? 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const createdDirs: string[] = [];
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (createdDirs.length > 0) {
|
|
31
|
+
const dir = createdDirs.pop();
|
|
32
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function mkTmp(): string {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "stack-recommend-"));
|
|
38
|
+
createdDirs.push(dir);
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("stack recommend --json", () => {
|
|
43
|
+
it("returns structured JSON with hits for a SaaS query", () => {
|
|
44
|
+
const { stdout, code } = runCli(["recommend", "B2B SaaS with auth and payments", "--json"]);
|
|
45
|
+
expect(code).toBe(0);
|
|
46
|
+
const payload = JSON.parse(stdout);
|
|
47
|
+
expect(payload.query).toBe("B2B SaaS with auth and payments");
|
|
48
|
+
expect(Array.isArray(payload.hits)).toBe(true);
|
|
49
|
+
expect(payload.hits.length).toBeGreaterThan(0);
|
|
50
|
+
const hitNames = payload.hits.map((h: { name: string }) => h.name);
|
|
51
|
+
expect(hitNames).toContain("stripe");
|
|
52
|
+
// clerk or supabase should cover the auth side
|
|
53
|
+
expect(hitNames.some((n: string) => ["clerk", "supabase"].includes(n))).toBe(true);
|
|
54
|
+
expect(typeof payload.guidance).toBe("string");
|
|
55
|
+
expect(payload.byCategory).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("narrows to a category filter", () => {
|
|
59
|
+
const { stdout, code } = runCli([
|
|
60
|
+
"recommend",
|
|
61
|
+
"edge compute",
|
|
62
|
+
"--category",
|
|
63
|
+
"Deploy",
|
|
64
|
+
"--json",
|
|
65
|
+
]);
|
|
66
|
+
expect(code).toBe(0);
|
|
67
|
+
const payload = JSON.parse(stdout);
|
|
68
|
+
for (const hit of payload.hits) {
|
|
69
|
+
expect(hit.category).toBe("Deploy");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("with --save persists a recipe under .stack/recipes/", () => {
|
|
74
|
+
const dir = mkTmp();
|
|
75
|
+
const { stdout, code } = runCli(["recommend", "postgres database", "--save", "--json"], dir);
|
|
76
|
+
expect(code).toBe(0);
|
|
77
|
+
const payload = JSON.parse(stdout);
|
|
78
|
+
expect(payload.recipe).toBeDefined();
|
|
79
|
+
expect(payload.recipe.id).toBeDefined();
|
|
80
|
+
expect(payload.recipe.path).toContain(".stack/recipes");
|
|
81
|
+
|
|
82
|
+
const recipesDir = join(dir, ".stack", "recipes");
|
|
83
|
+
const files = readdirSync(recipesDir);
|
|
84
|
+
expect(files.length).toBe(1);
|
|
85
|
+
expect(files[0]).toMatch(/\.toml$/);
|
|
86
|
+
expect(files[0]).toBe(`${payload.recipe.id}.toml`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("without --save does not create .stack/recipes", () => {
|
|
90
|
+
const dir = mkTmp();
|
|
91
|
+
const { code } = runCli(["recommend", "postgres database", "--json"], dir);
|
|
92
|
+
expect(code).toBe(0);
|
|
93
|
+
try {
|
|
94
|
+
const files = readdirSync(join(dir, ".stack", "recipes"));
|
|
95
|
+
expect(files.length).toBe(0);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// ENOENT is the expected happy path — no dir created.
|
|
98
|
+
expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("emits an empty hits array with fallback guidance for a gibberish query", () => {
|
|
103
|
+
const { stdout, code } = runCli(["recommend", "xyzabcxyzabc nonsenseword", "--json"]);
|
|
104
|
+
expect(code).toBe(0);
|
|
105
|
+
const payload = JSON.parse(stdout);
|
|
106
|
+
expect(payload.hits).toEqual([]);
|
|
107
|
+
expect(payload.guidance).toContain("No strong matches");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import {
|
|
3
|
+
addService,
|
|
4
|
+
assertPhantomInstalled,
|
|
5
|
+
getProvider,
|
|
6
|
+
hasConfig,
|
|
7
|
+
listProviderNames,
|
|
8
|
+
} from "@ashlr/stack-core";
|
|
9
|
+
import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
|
|
10
|
+
|
|
11
|
+
export const addCommand = defineCommand({
|
|
12
|
+
meta: {
|
|
13
|
+
name: "add",
|
|
14
|
+
description: "Provision a service and wire its secrets + MCP entry.",
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
service: {
|
|
18
|
+
type: "positional",
|
|
19
|
+
required: false,
|
|
20
|
+
description: "Service name (e.g. supabase, neon, vercel). Omit for interactive picker.",
|
|
21
|
+
},
|
|
22
|
+
use: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Attach to an existing resource by id instead of creating a new one.",
|
|
25
|
+
},
|
|
26
|
+
region: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Region hint for providers that need one (e.g. us-east-1).",
|
|
29
|
+
},
|
|
30
|
+
dryRun: {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
default: false,
|
|
33
|
+
description:
|
|
34
|
+
"Preview what would happen (no network calls, no vault writes, no MCP edits).",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
async run({ args }) {
|
|
38
|
+
if (!hasConfig()) {
|
|
39
|
+
intro("stack add");
|
|
40
|
+
outroError("No .stack.toml found. Run `stack init` first.");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const available = listProviderNames();
|
|
45
|
+
|
|
46
|
+
// Interactive picker when no service argument is provided.
|
|
47
|
+
let service = args.service ? String(args.service) : undefined;
|
|
48
|
+
if (!service) {
|
|
49
|
+
if (!process.stdout.isTTY) {
|
|
50
|
+
intro("stack add");
|
|
51
|
+
outroError("No service specified. Usage: stack add <service>");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const grouped = await groupProvidersForPicker(available);
|
|
55
|
+
const picked = await prompts.select({
|
|
56
|
+
message: "Which service to add?",
|
|
57
|
+
options: grouped,
|
|
58
|
+
});
|
|
59
|
+
if (prompts.isCancel(picked)) {
|
|
60
|
+
intro("stack add");
|
|
61
|
+
outroError("Cancelled.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
service = String(picked);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
intro(`stack add ${service}${args.dryRun ? colors.dim(" (dry-run)") : ""}`);
|
|
68
|
+
|
|
69
|
+
if (!args.dryRun) {
|
|
70
|
+
try {
|
|
71
|
+
await assertPhantomInstalled();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
outroError((err as Error).message);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!available.includes(service)) {
|
|
79
|
+
// Group the catalog by category so the suggestion doesn't wrap into an
|
|
80
|
+
// unreadable one-line comma list on narrow terminals.
|
|
81
|
+
const byCat: Record<string, string[]> = {};
|
|
82
|
+
for (const name of available) {
|
|
83
|
+
try {
|
|
84
|
+
const p = await getProvider(name);
|
|
85
|
+
(byCat[p.category] ??= []).push(p.name);
|
|
86
|
+
} catch {
|
|
87
|
+
/* skip */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const lines = Object.entries(byCat)
|
|
91
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
92
|
+
.map(([cat, names]) => ` ${colors.bold(cat.padEnd(10))} ${names.sort().join(" ")}`);
|
|
93
|
+
console.log();
|
|
94
|
+
console.log(` Unknown provider ${colors.red(`"${service}"`)}.`);
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(colors.dim(" Available providers:"));
|
|
97
|
+
for (const line of lines) console.log(line);
|
|
98
|
+
console.log();
|
|
99
|
+
outroError("Pick one from the catalog above.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Dry-run: describe what the flow would do without executing any step.
|
|
104
|
+
if (args.dryRun) {
|
|
105
|
+
const provider = await getProvider(service);
|
|
106
|
+
console.log();
|
|
107
|
+
console.log(
|
|
108
|
+
` ${colors.bold(provider.displayName)} ${colors.dim(`(${provider.category} · ${provider.authKind})`)}`,
|
|
109
|
+
);
|
|
110
|
+
console.log(` ${colors.dim("1.")} login ${colors.dim("(prompt for PAT or run OAuth)")}`);
|
|
111
|
+
console.log(
|
|
112
|
+
` ${colors.dim("2.")} provision ${args.use ? `attach to existing resource ${colors.dim(String(args.use))}` : colors.dim("create a new upstream resource")}`,
|
|
113
|
+
);
|
|
114
|
+
console.log(` ${colors.dim("3.")} materialize ${colors.dim("fetch credentials for the resource")}`);
|
|
115
|
+
console.log(` ${colors.dim("4.")} persist ${colors.dim("write secrets to Phantom + MCP + .stack.toml")}`);
|
|
116
|
+
console.log();
|
|
117
|
+
outro(colors.dim("dry-run complete — nothing written."));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const spinner = prompts.spinner();
|
|
122
|
+
try {
|
|
123
|
+
spinner.start(`Wiring ${service}…`);
|
|
124
|
+
const result = await addService({
|
|
125
|
+
providerName: service,
|
|
126
|
+
existingResourceId: args.use ? String(args.use) : undefined,
|
|
127
|
+
hints: args.region ? { region: String(args.region) } : undefined,
|
|
128
|
+
interactive: process.stdout.isTTY === true,
|
|
129
|
+
log: (event) => {
|
|
130
|
+
spinner.stop();
|
|
131
|
+
logEvent(event);
|
|
132
|
+
spinner.start(`Wiring ${service}…`);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
spinner.stop(
|
|
136
|
+
`${colors.green("●")} ${result.displayName} (${result.resourceId}) · ${result.secretCount} secrets${
|
|
137
|
+
result.mcpWired ? " · mcp wired" : ""
|
|
138
|
+
}`,
|
|
139
|
+
);
|
|
140
|
+
outro(`${colors.green("✓")} ${service} is live.`);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
spinner.stop(colors.red("Failed."));
|
|
143
|
+
outroError((err as Error).message);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
async function groupProvidersForPicker(names: string[]) {
|
|
149
|
+
const out: Array<{ label: string; value: string; hint?: string }> = [];
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
try {
|
|
152
|
+
const p = await getProvider(name);
|
|
153
|
+
out.push({
|
|
154
|
+
value: p.name,
|
|
155
|
+
label: `${p.displayName} ${colors.dim(`· ${p.category}`)}`,
|
|
156
|
+
hint: p.authKind,
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
/* skip */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out.sort((a, b) => a.label.localeCompare(b.label));
|
|
163
|
+
}
|