@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
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import {
|
|
3
|
-
PROVIDER_CATEGORIES,
|
|
4
|
-
getInferenceBackend,
|
|
5
|
-
NoInferenceBackendError,
|
|
6
|
-
recipeFromRetrieval,
|
|
7
|
-
retrieve,
|
|
8
|
-
retrieveByCategory,
|
|
9
|
-
writeRecipe,
|
|
10
|
-
type Recipe,
|
|
11
|
-
type RetrievalHit,
|
|
12
|
-
} from "@ashlr/stack-core";
|
|
13
|
-
import { colors, intro, outro } from "../ui.ts";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* `stack recommend` — free-text → curated providers.
|
|
17
|
-
*
|
|
18
|
-
* Three execution paths:
|
|
19
|
-
* 1. Default — pure BM25 retrieval. Instant, zero-IO, no LLM. The shape
|
|
20
|
-
* Claude consumes via the `stack_recommend` MCP tool.
|
|
21
|
-
* 2. `--save` — also freezes the result to `.stack/recipes/<id>.toml` so
|
|
22
|
-
* the user can follow up with `stack apply <id>`.
|
|
23
|
-
* 3. `--synth` — pipes the retrieval candidates into the local SLM backend
|
|
24
|
-
* (LM Studio / Ollama via OpenAI-compatible HTTP) and swaps the
|
|
25
|
-
* retrieval-only rationales for model-authored ones. No-ops gracefully
|
|
26
|
-
* when neither endpoint is reachable.
|
|
27
|
-
*
|
|
28
|
-
* Output modes:
|
|
29
|
-
* - default: human-readable list, grouped by category
|
|
30
|
-
* - --json: machine-readable blob (used by the MCP server + site /api)
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
interface RecommendOutputHit {
|
|
34
|
-
name: string;
|
|
35
|
-
displayName: string;
|
|
36
|
-
category: string;
|
|
37
|
-
authKind: string;
|
|
38
|
-
secrets: string[];
|
|
39
|
-
blurb: string;
|
|
40
|
-
score: number;
|
|
41
|
-
matched: string[];
|
|
42
|
-
rationale?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface RecommendOutput {
|
|
46
|
-
query: string;
|
|
47
|
-
hits: RecommendOutputHit[];
|
|
48
|
-
byCategory: Record<string, Array<{ name: string; score: number }>>;
|
|
49
|
-
guidance: string;
|
|
50
|
-
recipe?: { id: string; path: string };
|
|
51
|
-
inference?: { mode: "synth" | "mcp-delegated"; backend: string; note?: string };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function toOutputHit(
|
|
55
|
-
hit: RetrievalHit,
|
|
56
|
-
rationale?: string,
|
|
57
|
-
): RecommendOutputHit {
|
|
58
|
-
return {
|
|
59
|
-
name: hit.provider.name,
|
|
60
|
-
displayName: hit.provider.displayName,
|
|
61
|
-
category: hit.provider.category,
|
|
62
|
-
authKind: hit.provider.authKind,
|
|
63
|
-
secrets: hit.provider.secrets,
|
|
64
|
-
blurb: hit.provider.blurb,
|
|
65
|
-
score: Number(hit.score.toFixed(3)),
|
|
66
|
-
matched: hit.matched,
|
|
67
|
-
rationale,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function buildGuidance(hits: RetrievalHit[], categories: string[]): string {
|
|
72
|
-
if (hits.length === 0) {
|
|
73
|
-
return "No strong matches. Try describing the concrete capability you need (e.g. 'postgres database', 'stripe subscriptions', 'deploy frontend').";
|
|
74
|
-
}
|
|
75
|
-
const covered = new Set(hits.map((h) => h.provider.category));
|
|
76
|
-
const missing = categories.filter(
|
|
77
|
-
(c) => !covered.has(c as (typeof PROVIDER_CATEGORIES)[number]),
|
|
78
|
-
);
|
|
79
|
-
const topByCat = Object.entries(
|
|
80
|
-
hits.reduce<Record<string, RetrievalHit>>((acc, h) => {
|
|
81
|
-
if (!acc[h.provider.category] || acc[h.provider.category].score < h.score) {
|
|
82
|
-
acc[h.provider.category] = h;
|
|
83
|
-
}
|
|
84
|
-
return acc;
|
|
85
|
-
}, {}),
|
|
86
|
-
)
|
|
87
|
-
.sort((a, b) => b[1].score - a[1].score)
|
|
88
|
-
.map(([cat, h]) => `${cat}→${h.provider.name}`);
|
|
89
|
-
const parts = [`Top pick per category: ${topByCat.join(", ")}.`];
|
|
90
|
-
if (missing.length > 0) {
|
|
91
|
-
parts.push(
|
|
92
|
-
`No matches surfaced for: ${missing.join(", ")}. Add terms for those categories if the user needs them.`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
parts.push(
|
|
96
|
-
`Apply a chosen set with: stack add ${hits.slice(0, 3).map((h) => h.provider.name).join(" ")} (or supply your own list).`,
|
|
97
|
-
);
|
|
98
|
-
return parts.join(" ");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function buildCatalogContext(hits: RetrievalHit[]): string {
|
|
102
|
-
return hits
|
|
103
|
-
.map(
|
|
104
|
-
(h) =>
|
|
105
|
-
`- ${h.provider.name} (${h.provider.category}): ${h.provider.blurb} [secrets: ${h.provider.secrets.join(", ")}]`,
|
|
106
|
-
)
|
|
107
|
-
.join("\n");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function trySynth(
|
|
111
|
-
query: string,
|
|
112
|
-
hits: RetrievalHit[],
|
|
113
|
-
): Promise<{ rationales: Map<string, string>; note?: string; backend: string; mode: "synth" | "mcp-delegated" } | null> {
|
|
114
|
-
try {
|
|
115
|
-
const backend = await getInferenceBackend({ preferLocal: true });
|
|
116
|
-
const result = await backend.infer({
|
|
117
|
-
query,
|
|
118
|
-
catalogContext: buildCatalogContext(hits),
|
|
119
|
-
});
|
|
120
|
-
const rationales = new Map<string, string>();
|
|
121
|
-
for (const p of result.recipe?.providers ?? []) {
|
|
122
|
-
if (p.rationale) rationales.set(p.name, p.rationale);
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
rationales,
|
|
126
|
-
note: result.recipe?.notes,
|
|
127
|
-
backend: backend.name,
|
|
128
|
-
mode: result.mode,
|
|
129
|
-
};
|
|
130
|
-
} catch (err) {
|
|
131
|
-
if (err instanceof NoInferenceBackendError) return null;
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export const recommendCommand = defineCommand({
|
|
137
|
-
meta: {
|
|
138
|
-
name: "recommend",
|
|
139
|
-
description: "Pick the right providers for what you're building (AI-assisted).",
|
|
140
|
-
},
|
|
141
|
-
args: {
|
|
142
|
-
query: {
|
|
143
|
-
type: "positional",
|
|
144
|
-
description: "Free-text description of the project (in quotes).",
|
|
145
|
-
required: false,
|
|
146
|
-
},
|
|
147
|
-
k: {
|
|
148
|
-
type: "string",
|
|
149
|
-
description: "Max results to return (default 6).",
|
|
150
|
-
required: false,
|
|
151
|
-
},
|
|
152
|
-
category: {
|
|
153
|
-
type: "string",
|
|
154
|
-
description: "Restrict to a single category (e.g. Database, Auth).",
|
|
155
|
-
required: false,
|
|
156
|
-
},
|
|
157
|
-
json: {
|
|
158
|
-
type: "boolean",
|
|
159
|
-
description: "Machine-readable JSON output.",
|
|
160
|
-
required: false,
|
|
161
|
-
},
|
|
162
|
-
save: {
|
|
163
|
-
type: "boolean",
|
|
164
|
-
description:
|
|
165
|
-
"Persist the result as .stack/recipes/<id>.toml so you can run `stack apply <id>`.",
|
|
166
|
-
required: false,
|
|
167
|
-
},
|
|
168
|
-
synth: {
|
|
169
|
-
type: "boolean",
|
|
170
|
-
description:
|
|
171
|
-
"Call the local SLM (LM Studio / Ollama) to synthesize rationales. Silently falls back to retrieval-only when no endpoint is reachable.",
|
|
172
|
-
required: false,
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
async run({ args }) {
|
|
176
|
-
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
177
|
-
if (!query) {
|
|
178
|
-
if (args.json) {
|
|
179
|
-
console.log(
|
|
180
|
-
JSON.stringify(
|
|
181
|
-
{
|
|
182
|
-
query: "",
|
|
183
|
-
hits: [],
|
|
184
|
-
byCategory: {},
|
|
185
|
-
guidance: "Provide a query: stack recommend \"what you're building\".",
|
|
186
|
-
} satisfies RecommendOutput,
|
|
187
|
-
null,
|
|
188
|
-
2,
|
|
189
|
-
),
|
|
190
|
-
);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
intro("stack recommend");
|
|
194
|
-
console.log(
|
|
195
|
-
colors.dim(
|
|
196
|
-
" Usage: stack recommend \"B2B SaaS with auth, AI, and payments\"\n",
|
|
197
|
-
),
|
|
198
|
-
);
|
|
199
|
-
outro("No query — nothing to recommend.");
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const k = Number.parseInt(String(args.k ?? "6"), 10);
|
|
204
|
-
const categories = args.category ? [String(args.category)] : undefined;
|
|
205
|
-
const hits = retrieve(query, {
|
|
206
|
-
k: Number.isFinite(k) && k > 0 ? k : 6,
|
|
207
|
-
categories: categories as RetrievalHit["provider"]["category"][] | undefined,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
let synthOutcome: Awaited<ReturnType<typeof trySynth>> = null;
|
|
211
|
-
if (args.synth && hits.length > 0) {
|
|
212
|
-
synthOutcome = await trySynth(query, hits);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const outputHits: RecommendOutputHit[] = hits.map((h) =>
|
|
216
|
-
toOutputHit(h, synthOutcome?.rationales.get(h.provider.name)),
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
let recipeInfo: RecommendOutput["recipe"];
|
|
220
|
-
if (args.save && hits.length > 0) {
|
|
221
|
-
const guidance = buildGuidance(hits, [...PROVIDER_CATEGORIES]);
|
|
222
|
-
const recipe: Recipe = recipeFromRetrieval(query, hits, guidance);
|
|
223
|
-
// Prefer synth-authored rationales over raw matched-term strings.
|
|
224
|
-
if (synthOutcome) {
|
|
225
|
-
recipe.providers = recipe.providers.map((p) => ({
|
|
226
|
-
...p,
|
|
227
|
-
rationale: synthOutcome!.rationales.get(p.name) ?? p.rationale,
|
|
228
|
-
}));
|
|
229
|
-
}
|
|
230
|
-
const path = await writeRecipe(recipe);
|
|
231
|
-
recipeInfo = { id: recipe.id, path };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const payload: RecommendOutput = {
|
|
235
|
-
query,
|
|
236
|
-
hits: outputHits,
|
|
237
|
-
byCategory: Object.fromEntries(
|
|
238
|
-
Object.entries(retrieveByCategory(query, { k: 20 })).map(([cat, catHits]) => [
|
|
239
|
-
cat,
|
|
240
|
-
catHits.map((h) => ({
|
|
241
|
-
name: h.provider.name,
|
|
242
|
-
score: Number(h.score.toFixed(3)),
|
|
243
|
-
})),
|
|
244
|
-
]),
|
|
245
|
-
),
|
|
246
|
-
guidance: buildGuidance(hits, [...PROVIDER_CATEGORIES]),
|
|
247
|
-
recipe: recipeInfo,
|
|
248
|
-
inference: synthOutcome
|
|
249
|
-
? { mode: synthOutcome.mode, backend: synthOutcome.backend, note: synthOutcome.note }
|
|
250
|
-
: undefined,
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
if (args.json) {
|
|
254
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
intro("stack recommend");
|
|
259
|
-
console.log(colors.dim(` query: ${query}\n`));
|
|
260
|
-
if (hits.length === 0) {
|
|
261
|
-
console.log(colors.yellow(" No strong matches.") + "\n");
|
|
262
|
-
console.log(colors.dim(` ${payload.guidance}\n`));
|
|
263
|
-
outro("Try a more specific query.");
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
for (const hit of outputHits) {
|
|
267
|
-
const scoreTag = colors.dim(`(${hit.score.toFixed(2)})`);
|
|
268
|
-
console.log(
|
|
269
|
-
` ${colors.green("●")} ${colors.bold(hit.displayName)} ${colors.cyan(hit.category)} ${scoreTag}`,
|
|
270
|
-
);
|
|
271
|
-
console.log(` ${colors.dim(hit.blurb)}`);
|
|
272
|
-
if (hit.rationale) {
|
|
273
|
-
console.log(` ${colors.dim("why:")} ${hit.rationale}`);
|
|
274
|
-
}
|
|
275
|
-
console.log(
|
|
276
|
-
` ${colors.dim("add with:")} ${colors.reset("stack add ")}${hit.name}`,
|
|
277
|
-
);
|
|
278
|
-
console.log();
|
|
279
|
-
}
|
|
280
|
-
if (synthOutcome?.note) {
|
|
281
|
-
console.log(colors.dim(` notes: ${synthOutcome.note}\n`));
|
|
282
|
-
}
|
|
283
|
-
console.log(colors.dim(` ${payload.guidance}`));
|
|
284
|
-
if (recipeInfo) {
|
|
285
|
-
console.log();
|
|
286
|
-
console.log(
|
|
287
|
-
colors.dim(" Saved recipe:") +
|
|
288
|
-
` ${colors.bold(recipeInfo.id)} ` +
|
|
289
|
-
colors.dim(`(${recipeInfo.path})`),
|
|
290
|
-
);
|
|
291
|
-
console.log(
|
|
292
|
-
colors.dim(" Apply with: ") + colors.reset(`stack apply ${recipeInfo.id}`),
|
|
293
|
-
);
|
|
294
|
-
} else if (!args.json) {
|
|
295
|
-
console.log();
|
|
296
|
-
console.log(
|
|
297
|
-
colors.dim(" Tip: rerun with `--save` to freeze as a recipe for `stack apply`."),
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
outro("Tell Claude or run `stack add <name>` to continue.");
|
|
301
|
-
},
|
|
302
|
-
});
|
package/src/commands/remove.ts
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { readConfig, removeSecret, writeConfig } from "@ashlr/stack-core";
|
|
3
|
-
import { removeMcpEntry } from "@ashlr/stack-core/mcp-writer";
|
|
4
|
-
import { colors, intro, outro, outroError, prompts } from "../ui.ts";
|
|
5
|
-
|
|
6
|
-
export const removeCommand = defineCommand({
|
|
7
|
-
meta: {
|
|
8
|
-
name: "remove",
|
|
9
|
-
description: "Remove a service from the stack (vault entries and MCP config).",
|
|
10
|
-
},
|
|
11
|
-
args: {
|
|
12
|
-
service: {
|
|
13
|
-
type: "positional",
|
|
14
|
-
required: false,
|
|
15
|
-
description: "Service name. Omit with --all to remove every service.",
|
|
16
|
-
},
|
|
17
|
-
all: {
|
|
18
|
-
type: "boolean",
|
|
19
|
-
default: false,
|
|
20
|
-
description: "Remove every service in this stack (with confirmation).",
|
|
21
|
-
},
|
|
22
|
-
keepRemote: {
|
|
23
|
-
type: "boolean",
|
|
24
|
-
default: false,
|
|
25
|
-
description: "Leave the provider-side resource untouched (don't deprovision).",
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
async run({ args }) {
|
|
29
|
-
if (args.all) return runRemoveAll(Boolean(args.keepRemote));
|
|
30
|
-
if (!args.service) {
|
|
31
|
-
intro("stack remove");
|
|
32
|
-
outroError("Missing service name. Pass --all to remove every service.");
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
return runRemoveOne(String(args.service), Boolean(args.keepRemote));
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
async function runRemoveOne(serviceName: string, keepRemote: boolean): Promise<void> {
|
|
40
|
-
intro(`stack remove ${serviceName}`);
|
|
41
|
-
const config = await readConfig();
|
|
42
|
-
const entry = config.services[serviceName];
|
|
43
|
-
if (!entry) {
|
|
44
|
-
outroError(`${serviceName} is not in this stack.`);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const confirmed = process.stdout.isTTY
|
|
49
|
-
? await prompts.confirm({
|
|
50
|
-
message: `Remove ${serviceName} from .stack.toml, delete its ${entry.secrets.length} secret(s), and ${keepRemote ? "keep" : "deprovision"} the upstream resource?`,
|
|
51
|
-
initialValue: false,
|
|
52
|
-
})
|
|
53
|
-
: true;
|
|
54
|
-
if (!confirmed || prompts.isCancel(confirmed)) {
|
|
55
|
-
outroError("Cancelled.");
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
for (const secret of entry.secrets) {
|
|
60
|
-
await removeSecret(secret);
|
|
61
|
-
}
|
|
62
|
-
if (entry.mcp) await removeMcpEntry(entry.mcp);
|
|
63
|
-
delete config.services[serviceName];
|
|
64
|
-
await writeConfig(config);
|
|
65
|
-
|
|
66
|
-
outro(colors.green(`Removed ${serviceName}.`));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function runRemoveAll(keepRemote: boolean): Promise<void> {
|
|
70
|
-
intro("stack remove --all");
|
|
71
|
-
const config = await readConfig();
|
|
72
|
-
const serviceNames = Object.keys(config.services);
|
|
73
|
-
if (serviceNames.length === 0) {
|
|
74
|
-
outro(colors.dim("No services to remove."));
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const totalSecrets = Object.values(config.services).reduce(
|
|
79
|
-
(acc, e) => acc + e.secrets.length,
|
|
80
|
-
0,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
console.log();
|
|
84
|
-
console.log(` ${colors.bold("About to remove:")}`);
|
|
85
|
-
for (const name of serviceNames) {
|
|
86
|
-
const entry = config.services[name];
|
|
87
|
-
console.log(
|
|
88
|
-
` ${colors.red("●")} ${name.padEnd(14)} ${colors.dim(`${entry.secrets.length} secret(s)`)}`,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
console.log();
|
|
92
|
-
|
|
93
|
-
// Two-step confirmation — large blast radius.
|
|
94
|
-
if (process.stdout.isTTY) {
|
|
95
|
-
const confirmed = await prompts.confirm({
|
|
96
|
-
message: `Delete ${serviceNames.length} service(s), ${totalSecrets} secret(s), and strip MCP entries? ${keepRemote ? "(keeping upstream)" : "(deprovisioning upstream)"}`,
|
|
97
|
-
initialValue: false,
|
|
98
|
-
});
|
|
99
|
-
if (!confirmed || prompts.isCancel(confirmed)) {
|
|
100
|
-
outroError("Cancelled.");
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const typed = await prompts.text({
|
|
104
|
-
message: `Type ${colors.bold("remove all")} to confirm:`,
|
|
105
|
-
validate(val) {
|
|
106
|
-
return val === "remove all" ? undefined : "Type exactly: remove all";
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
if (prompts.isCancel(typed)) {
|
|
110
|
-
outroError("Cancelled.");
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
for (const name of serviceNames) {
|
|
116
|
-
const entry = config.services[name];
|
|
117
|
-
for (const secret of entry.secrets) await removeSecret(secret);
|
|
118
|
-
if (entry.mcp) await removeMcpEntry(entry.mcp);
|
|
119
|
-
delete config.services[name];
|
|
120
|
-
}
|
|
121
|
-
await writeConfig(config);
|
|
122
|
-
|
|
123
|
-
outro(colors.green(`Removed ${serviceNames.length} service(s), ${totalSecrets} secret(s).`));
|
|
124
|
-
}
|
package/src/commands/scan.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import {
|
|
3
|
-
addService,
|
|
4
|
-
assertPhantomInstalled,
|
|
5
|
-
emptyConfig,
|
|
6
|
-
hasConfig,
|
|
7
|
-
listProviderNames,
|
|
8
|
-
readConfig,
|
|
9
|
-
scanSource,
|
|
10
|
-
writeConfig,
|
|
11
|
-
} from "@ashlr/stack-core";
|
|
12
|
-
import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* `stack scan` — look at the current repo's source (package.json, config files,
|
|
16
|
-
* etc.) and report which curated providers the code already uses. With
|
|
17
|
-
* `--auto`, Stack will seed a .stack.toml (if missing) and offer to run
|
|
18
|
-
* `stack add` for each detected provider.
|
|
19
|
-
*/
|
|
20
|
-
export const scanCommand = defineCommand({
|
|
21
|
-
meta: {
|
|
22
|
-
name: "scan",
|
|
23
|
-
description: "Detect providers used by this repo (package.json, config files, .env.example).",
|
|
24
|
-
},
|
|
25
|
-
args: {
|
|
26
|
-
path: { type: "string", default: ".", description: "Directory to scan." },
|
|
27
|
-
auto: {
|
|
28
|
-
type: "boolean",
|
|
29
|
-
default: false,
|
|
30
|
-
description: "After scanning, offer to run `stack add` for each detected provider.",
|
|
31
|
-
},
|
|
32
|
-
confidence: {
|
|
33
|
-
type: "string",
|
|
34
|
-
default: "medium",
|
|
35
|
-
description: "Minimum confidence to surface: low | medium | high.",
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
async run({ args }) {
|
|
39
|
-
intro("stack scan");
|
|
40
|
-
const cwd = String(args.path);
|
|
41
|
-
const hits = await scanSource(cwd);
|
|
42
|
-
const minimum = String(args.confidence).toLowerCase() as "low" | "medium" | "high";
|
|
43
|
-
const order: Record<string, number> = { low: 0, medium: 1, high: 2 };
|
|
44
|
-
const filtered = hits.filter((h) => order[h.confidence] >= order[minimum]);
|
|
45
|
-
|
|
46
|
-
if (filtered.length === 0) {
|
|
47
|
-
outro(colors.dim("No providers detected. Run `stack scan --confidence low` to see weaker signals."));
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
console.log();
|
|
52
|
-
console.log(
|
|
53
|
-
` ${colors.bold(String(filtered.length))} provider(s) detected in ${colors.dim(cwd)}:`,
|
|
54
|
-
);
|
|
55
|
-
for (const h of filtered) {
|
|
56
|
-
const dot =
|
|
57
|
-
h.confidence === "high"
|
|
58
|
-
? colors.green("●")
|
|
59
|
-
: h.confidence === "medium"
|
|
60
|
-
? colors.yellow("●")
|
|
61
|
-
: colors.dim("●");
|
|
62
|
-
console.log(` ${dot} ${h.provider.padEnd(12)} ${colors.dim(h.signals.slice(0, 3).join(" "))}`);
|
|
63
|
-
}
|
|
64
|
-
console.log();
|
|
65
|
-
|
|
66
|
-
if (!args.auto) {
|
|
67
|
-
const suggestion = filtered.map((h) => `stack add ${h.provider}`).join("\n ");
|
|
68
|
-
console.log(colors.dim(" Next steps:"));
|
|
69
|
-
console.log(colors.dim(` ${suggestion}\n`));
|
|
70
|
-
outro(colors.dim("scan complete — nothing written."));
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// --auto path: seed config if missing, offer each addition interactively.
|
|
75
|
-
try {
|
|
76
|
-
await assertPhantomInstalled();
|
|
77
|
-
} catch (err) {
|
|
78
|
-
outroError((err as Error).message);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (!hasConfig(cwd)) {
|
|
83
|
-
await writeConfig(emptyConfig("from-scan"), cwd);
|
|
84
|
-
prompts.log.info(colors.dim("Seeded a fresh .stack.toml."));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const config = await readConfig(cwd);
|
|
88
|
-
const registered = new Set(listProviderNames());
|
|
89
|
-
|
|
90
|
-
for (const h of filtered) {
|
|
91
|
-
if (!registered.has(h.provider)) continue;
|
|
92
|
-
if (config.services[h.provider]) {
|
|
93
|
-
prompts.log.info(colors.dim(`${h.provider}: already in stack, skipping`));
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
const shouldAdd = await prompts.confirm({
|
|
97
|
-
message: `Add ${h.provider}? (${h.signals.slice(0, 2).join(", ")})`,
|
|
98
|
-
initialValue: true,
|
|
99
|
-
});
|
|
100
|
-
if (prompts.isCancel(shouldAdd) || !shouldAdd) continue;
|
|
101
|
-
|
|
102
|
-
const spinner = prompts.spinner();
|
|
103
|
-
spinner.start(`Adding ${h.provider}…`);
|
|
104
|
-
try {
|
|
105
|
-
const result = await addService({
|
|
106
|
-
providerName: h.provider,
|
|
107
|
-
cwd,
|
|
108
|
-
interactive: process.stdout.isTTY === true,
|
|
109
|
-
log: (event) => {
|
|
110
|
-
spinner.stop();
|
|
111
|
-
logEvent(event);
|
|
112
|
-
spinner.start(`Adding ${h.provider}…`);
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
spinner.stop(`${colors.green("●")} ${h.provider} → ${result.displayName}`);
|
|
116
|
-
} catch (err) {
|
|
117
|
-
spinner.stop(colors.red(`✗ ${h.provider}: ${(err as Error).message}`));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
outro(colors.green("scan complete."));
|
|
122
|
-
},
|
|
123
|
-
});
|
package/src/commands/status.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { hasConfig, isPhantomInstalled, listSecrets, readConfig } from "@ashlr/stack-core";
|
|
3
|
-
import { colors } from "../ui.ts";
|
|
4
|
-
|
|
5
|
-
export const statusCommand = defineCommand({
|
|
6
|
-
meta: { name: "status", description: "Show stack health at a glance." },
|
|
7
|
-
async run() {
|
|
8
|
-
const hasStack = hasConfig();
|
|
9
|
-
const phantomOk = await isPhantomInstalled();
|
|
10
|
-
const config = hasStack ? await readConfig() : undefined;
|
|
11
|
-
const vaultKeys = phantomOk ? await listSecrets().catch(() => []) : [];
|
|
12
|
-
|
|
13
|
-
console.log();
|
|
14
|
-
console.log(colors.bold(" Ashlr Stack"));
|
|
15
|
-
console.log();
|
|
16
|
-
console.log(
|
|
17
|
-
` ${statusDot(hasStack)} .stack.toml ${hasStack ? colors.dim(config?.stack.project_id) : colors.dim("missing — run `stack init`")}`,
|
|
18
|
-
);
|
|
19
|
-
console.log(
|
|
20
|
-
` ${statusDot(phantomOk)} phantom ${phantomOk ? colors.dim(`${vaultKeys.length} secrets in vault`) : colors.dim("not installed")}`,
|
|
21
|
-
);
|
|
22
|
-
console.log(
|
|
23
|
-
` ${statusDot(hasStack && Object.keys(config?.services ?? {}).length > 0)} services ${colors.dim(`${Object.keys(config?.services ?? {}).length} configured`)}`,
|
|
24
|
-
);
|
|
25
|
-
console.log();
|
|
26
|
-
if (config) {
|
|
27
|
-
for (const [name, entry] of Object.entries(config.services)) {
|
|
28
|
-
console.log(
|
|
29
|
-
` ${colors.cyan("·")} ${name.padEnd(14)} ${colors.dim(entry.resource_id ?? "-")}`,
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
console.log();
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
function statusDot(ok: boolean): string {
|
|
38
|
-
return ok ? colors.green("●") : colors.red("●");
|
|
39
|
-
}
|
package/src/commands/sync.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "citty";
|
|
2
|
-
import { readConfig, syncToPlatform } from "@ashlr/stack-core";
|
|
3
|
-
import { colors, intro, outro, outroError } from "../ui.ts";
|
|
4
|
-
|
|
5
|
-
const SUPPORTED = ["vercel", "railway", "fly"] as const;
|
|
6
|
-
type Platform = (typeof SUPPORTED)[number];
|
|
7
|
-
|
|
8
|
-
export const syncCommand = defineCommand({
|
|
9
|
-
meta: {
|
|
10
|
-
name: "sync",
|
|
11
|
-
description: "Push secrets to a deployment platform (via phantom sync).",
|
|
12
|
-
},
|
|
13
|
-
args: {
|
|
14
|
-
platform: {
|
|
15
|
-
type: "string",
|
|
16
|
-
required: true,
|
|
17
|
-
description: `One of: ${SUPPORTED.join(", ")}`,
|
|
18
|
-
},
|
|
19
|
-
},
|
|
20
|
-
async run({ args }) {
|
|
21
|
-
intro(`stack sync ${args.platform}`);
|
|
22
|
-
if (!SUPPORTED.includes(args.platform as Platform)) {
|
|
23
|
-
outroError(`Unsupported platform. Use: ${SUPPORTED.join(", ")}`);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
const config = await readConfig();
|
|
27
|
-
const entry = config.services[args.platform];
|
|
28
|
-
if (!entry?.resource_id) {
|
|
29
|
-
outroError(
|
|
30
|
-
`No ${args.platform} project found in .stack.toml. Run \`stack add ${args.platform}\` first.`,
|
|
31
|
-
);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
await syncToPlatform(args.platform as Platform, entry.resource_id);
|
|
35
|
-
outro(colors.green(`Secrets synced to ${args.platform}.`));
|
|
36
|
-
},
|
|
37
|
-
});
|