You need to sign in or sign up before continuing.
@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
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
}
|