@ashlr/stack 0.1.1 → 0.2.2
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 +33 -27
- package/dist/index.js +16637 -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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashlr/stack",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
3
|
+
"version": "0.2.2",
|
|
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. 39 providers, MCP-native.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Mason Wyatt",
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
],
|
|
34
34
|
"type": "module",
|
|
35
35
|
"bin": {
|
|
36
|
-
"stack": "./
|
|
36
|
+
"stack": "./dist/index.js"
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
|
-
"
|
|
39
|
+
"dist/**/*",
|
|
40
40
|
"README.md",
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
@@ -45,15 +45,16 @@
|
|
|
45
45
|
"registry": "https://registry.npmjs.org/"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
|
-
"bun": ">=1.2.0",
|
|
49
48
|
"node": ">=20"
|
|
50
49
|
},
|
|
51
50
|
"scripts": {
|
|
51
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm && node -e \"const fs=require('fs');const f='dist/index.js';let c=fs.readFileSync(f,'utf8');if(c.startsWith('#!'))c=c.slice(c.indexOf('\\n')+1);fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
|
|
52
|
+
"prepack": "bun run build",
|
|
52
53
|
"dev": "bun run src/index.ts",
|
|
53
54
|
"typecheck": "bunx tsc --noEmit"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
|
56
|
-
"@ashlr/stack-core": "^0.
|
|
57
|
+
"@ashlr/stack-core": "^0.2.2",
|
|
57
58
|
"@clack/prompts": "^0.8.2",
|
|
58
59
|
"citty": "^0.1.6",
|
|
59
60
|
"picocolors": "^1.1.1"
|
|
@@ -1,80 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
});
|
package/src/commands/add.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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
|
-
}
|
package/src/commands/apply.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type Recipe,
|
|
3
|
-
hasConfig,
|
|
4
|
-
listRecipes,
|
|
5
|
-
readConfig,
|
|
6
|
-
readRecipe,
|
|
7
|
-
removeSecret,
|
|
8
|
-
wirePhantomForRecipe,
|
|
9
|
-
writeConfig,
|
|
10
|
-
} from "@ashlr/stack-core";
|
|
11
|
-
import { removeMcpEntry } from "@ashlr/stack-core/mcp-writer";
|
|
12
|
-
import { defineCommand } from "citty";
|
|
13
|
-
import { colors, intro, outro, outroError, prompts } from "../ui.ts";
|
|
14
|
-
import { addCommand } from "./add.ts";
|
|
15
|
-
import { doctorCommand } from "./doctor.ts";
|
|
16
|
-
import { initCommand } from "./init.ts";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* `stack apply [recipe-id]` — replay a frozen Recipe through the add pipeline
|
|
20
|
-
* and pre-wire Phantom envelopes + webhook stubs so the provider-specific
|
|
21
|
-
* flows land real credentials into already-rotating slots.
|
|
22
|
-
*
|
|
23
|
-
* This is the moat: recommendation → TOML → `apply` → provisioned stack,
|
|
24
|
-
* with secret rotation wired from the first call.
|
|
25
|
-
*/
|
|
26
|
-
export const applyCommand = defineCommand({
|
|
27
|
-
meta: {
|
|
28
|
-
name: "apply",
|
|
29
|
-
description: "Apply a saved recipe: provision each provider + pre-wire Phantom rotation.",
|
|
30
|
-
},
|
|
31
|
-
args: {
|
|
32
|
-
recipeId: {
|
|
33
|
-
type: "positional",
|
|
34
|
-
required: false,
|
|
35
|
-
description: "Recipe id (filename stem in .stack/recipes). Omit for picker.",
|
|
36
|
-
},
|
|
37
|
-
noWire: {
|
|
38
|
-
type: "boolean",
|
|
39
|
-
default: false,
|
|
40
|
-
description: "Skip Phantom envelope + webhook pre-wiring (opts out of the moat).",
|
|
41
|
-
},
|
|
42
|
-
noRollback: {
|
|
43
|
-
type: "boolean",
|
|
44
|
-
default: false,
|
|
45
|
-
description:
|
|
46
|
-
"On partial failure, leave successfully-added services in .stack.toml instead of rolling them back.",
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
async run({ args }) {
|
|
50
|
-
intro(`stack apply${args.noWire ? colors.dim(" (no-wire)") : ""}`);
|
|
51
|
-
|
|
52
|
-
// The marketed golden path is `stack recommend --save && stack apply <id>`,
|
|
53
|
-
// which must work from a blank directory. Auto-init if no .stack.toml so
|
|
54
|
-
// the user isn't forced through a template picker they never asked for.
|
|
55
|
-
if (!hasConfig()) {
|
|
56
|
-
console.log(colors.dim(" ○ no .stack.toml — auto-running `stack init --noInteractive`"));
|
|
57
|
-
try {
|
|
58
|
-
await initCommand.run?.({
|
|
59
|
-
args: { noInteractive: true, force: false, _: [] },
|
|
60
|
-
cmd: initCommand,
|
|
61
|
-
rawArgs: ["--noInteractive"],
|
|
62
|
-
data: undefined,
|
|
63
|
-
} as unknown as Parameters<NonNullable<typeof initCommand.run>>[0]);
|
|
64
|
-
} catch (err) {
|
|
65
|
-
outroError(`Auto-init failed: ${(err as Error).message}`);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (!hasConfig()) {
|
|
69
|
-
outroError("Auto-init did not produce a .stack.toml. Run `stack init` manually.");
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const recipe = await pickRecipe(args.recipeId ? String(args.recipeId) : undefined);
|
|
75
|
-
if (!recipe) return; // pickRecipe already surfaced the error.
|
|
76
|
-
|
|
77
|
-
console.log();
|
|
78
|
-
console.log(` ${colors.bold("recipe")} ${recipe.id}`);
|
|
79
|
-
console.log(` ${colors.dim("query")} ${recipe.query}`);
|
|
80
|
-
console.log(` ${colors.dim("providers")} ${recipe.providers.map((p) => p.name).join(", ")}`);
|
|
81
|
-
console.log();
|
|
82
|
-
|
|
83
|
-
// Re-run addCommand for each provider. We intentionally do NOT parallelize:
|
|
84
|
-
// some providers (e.g. Vercel) need cwd state written by earlier steps
|
|
85
|
-
// and interactive prompts don't interleave cleanly.
|
|
86
|
-
const succeeded: string[] = [];
|
|
87
|
-
const failures: Array<{ name: string; message: string }> = [];
|
|
88
|
-
for (const { name } of recipe.providers) {
|
|
89
|
-
try {
|
|
90
|
-
console.log(colors.dim(` › stack add ${name}`));
|
|
91
|
-
// citty's CommandContext type requires every declared arg key; we
|
|
92
|
-
// only set the ones addCommand actually reads, so cast via unknown.
|
|
93
|
-
await addCommand.run?.({
|
|
94
|
-
args: { service: name, dryRun: false, _: [] },
|
|
95
|
-
cmd: addCommand,
|
|
96
|
-
rawArgs: [name],
|
|
97
|
-
data: undefined,
|
|
98
|
-
} as unknown as Parameters<NonNullable<typeof addCommand.run>>[0]);
|
|
99
|
-
succeeded.push(name);
|
|
100
|
-
} catch (err) {
|
|
101
|
-
failures.push({ name, message: (err as Error).message });
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// On partial failure, offer to roll back the successful adds so re-running
|
|
106
|
-
// `stack apply` isn't blocked by SERVICE_ALREADY_ADDED errors. Default to
|
|
107
|
-
// rolling back in non-TTY mode (CI-friendly) unless --noRollback is set.
|
|
108
|
-
if (failures.length > 0 && succeeded.length > 0 && !args.noRollback) {
|
|
109
|
-
const shouldRollback = process.stdout.isTTY
|
|
110
|
-
? await prompts.confirm({
|
|
111
|
-
message: `Recipe partially applied (${succeeded.length} succeeded, ${failures.length} failed). Roll back the successful adds so you can re-run cleanly?`,
|
|
112
|
-
initialValue: true,
|
|
113
|
-
})
|
|
114
|
-
: true;
|
|
115
|
-
if (shouldRollback && !prompts.isCancel(shouldRollback)) {
|
|
116
|
-
await rollbackServices(succeeded);
|
|
117
|
-
} else {
|
|
118
|
-
console.log(
|
|
119
|
-
colors.dim(
|
|
120
|
-
" Keeping partial state. Run `stack doctor --fix` or `stack remove <name>` to clean up.",
|
|
121
|
-
),
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Phantom-wire only runs when at least one provider made it through — no
|
|
127
|
-
// point creating rotation envelopes for services that aren't in the config.
|
|
128
|
-
if (succeeded.length > 0) {
|
|
129
|
-
try {
|
|
130
|
-
const wire = await wirePhantomForRecipe(recipe, { noWire: Boolean(args.noWire) });
|
|
131
|
-
if (!args.noWire) {
|
|
132
|
-
console.log();
|
|
133
|
-
console.log(
|
|
134
|
-
` ${colors.dim("envelopes")} ${wire.envelopes.length} · ${colors.dim("webhooks")} ${wire.webhooks.length}${
|
|
135
|
-
wire.skipped.length > 0 ? ` · ${colors.yellow(`skipped ${wire.skipped.length}`)}` : ""
|
|
136
|
-
}`,
|
|
137
|
-
);
|
|
138
|
-
if (wire.skipped.length > 0) {
|
|
139
|
-
console.log(colors.dim(` (phantom-wire skipped: ${wire.skipped.join(", ")})`));
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
} catch (err) {
|
|
143
|
-
console.log(colors.yellow(` ⚠ phantom-wire failed: ${(err as Error).message}`));
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Verify with doctor — informational; a failing doctor doesn't fail apply.
|
|
148
|
-
try {
|
|
149
|
-
console.log();
|
|
150
|
-
console.log(colors.dim(" › stack doctor"));
|
|
151
|
-
await doctorCommand.run?.({
|
|
152
|
-
args: { fix: false, all: false, json: false, _: [] },
|
|
153
|
-
cmd: doctorCommand,
|
|
154
|
-
rawArgs: [],
|
|
155
|
-
data: undefined,
|
|
156
|
-
} as unknown as Parameters<NonNullable<typeof doctorCommand.run>>[0]);
|
|
157
|
-
} catch {
|
|
158
|
-
/* doctor surfaces its own errors */
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (failures.length > 0) {
|
|
162
|
-
outroError(
|
|
163
|
-
`Some providers failed: ${failures.map((f) => `${f.name}: ${f.message}`).join("; ")}`,
|
|
164
|
-
);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
outro(`${colors.green("✓")} ${recipe.id} applied.`);
|
|
168
|
-
},
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Undo a partial apply. Reads the config, deletes each named service's secrets
|
|
173
|
-
* + MCP entry, and rewrites the config without them. Best-effort: a failure
|
|
174
|
-
* removing one service should not block the others.
|
|
175
|
-
*/
|
|
176
|
-
async function rollbackServices(names: string[]): Promise<void> {
|
|
177
|
-
if (names.length === 0) return;
|
|
178
|
-
console.log();
|
|
179
|
-
console.log(colors.dim(` rolling back ${names.length} partial add(s)…`));
|
|
180
|
-
try {
|
|
181
|
-
const config = await readConfig();
|
|
182
|
-
for (const name of names) {
|
|
183
|
-
const entry = config.services[name];
|
|
184
|
-
if (!entry) continue;
|
|
185
|
-
for (const secret of entry.secrets) {
|
|
186
|
-
try {
|
|
187
|
-
await removeSecret(secret);
|
|
188
|
-
} catch {
|
|
189
|
-
/* secret may already be gone; keep going */
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (entry.mcp) {
|
|
193
|
-
try {
|
|
194
|
-
await removeMcpEntry(entry.mcp);
|
|
195
|
-
} catch {
|
|
196
|
-
/* keep going */
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
delete config.services[name];
|
|
200
|
-
}
|
|
201
|
-
await writeConfig(config);
|
|
202
|
-
console.log(colors.dim(" rollback complete — re-run `stack apply` to retry."));
|
|
203
|
-
} catch (err) {
|
|
204
|
-
console.log(colors.yellow(` ⚠ rollback failed: ${(err as Error).message}`));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function pickRecipe(id?: string): Promise<Recipe | null> {
|
|
209
|
-
if (id) {
|
|
210
|
-
try {
|
|
211
|
-
return await readRecipe(id);
|
|
212
|
-
} catch (err) {
|
|
213
|
-
outroError((err as Error).message);
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const all = await listRecipes();
|
|
219
|
-
if (all.length === 0) {
|
|
220
|
-
outroError(
|
|
221
|
-
"No recipes in .stack/recipes. Run `stack recommend` first, or ask Claude to synthesize one.",
|
|
222
|
-
);
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!process.stdout.isTTY) {
|
|
227
|
-
outroError(`No recipe id given. Available: ${all.map((r) => r.id).join(", ")}.`);
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const picked = await prompts.select({
|
|
232
|
-
message: "Which recipe to apply?",
|
|
233
|
-
options: all.map((r) => ({
|
|
234
|
-
value: r.id,
|
|
235
|
-
label: `${colors.bold(r.id)} ${colors.dim(`· ${r.providers.length} providers`)}`,
|
|
236
|
-
hint: r.query.slice(0, 60),
|
|
237
|
-
})),
|
|
238
|
-
});
|
|
239
|
-
if (prompts.isCancel(picked)) {
|
|
240
|
-
outroError("Cancelled.");
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
return all.find((r) => r.id === String(picked)) ?? null;
|
|
244
|
-
}
|