@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.
@@ -1,87 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import {
3
- isPhantomInstalled,
4
- listSecrets,
5
- readConfig,
6
- } from "@ashlr/stack-core";
7
- import { colors, intro, outro, outroError } from "../ui.ts";
8
-
9
- /**
10
- * `stack env show [--env prod]` — pretty-print the effective env-var surface
11
- * for this stack. For each secret the project declares, show whether it's
12
- * present in the Phantom vault (masked) or missing. Designed for debugging
13
- * "why does my app boot without Supabase?" kinds of questions.
14
- */
15
- export const envCommand = defineCommand({
16
- meta: {
17
- name: "env",
18
- description: "Inspect the effective env-var map for this stack.",
19
- },
20
- subCommands: {
21
- show: defineCommand({
22
- meta: { name: "show", description: "Show which secrets are present in Phantom." },
23
- args: {
24
- env: { type: "string", default: "dev", description: "Environment overlay to preview." },
25
- },
26
- async run({ args }) {
27
- intro(`stack env show (${args.env})`);
28
- const phantomOk = await isPhantomInstalled();
29
- if (!phantomOk) {
30
- outroError("Phantom not installed.");
31
- return;
32
- }
33
- const config = await readConfig();
34
- const vaultKeys = new Set(await listSecrets());
35
- console.log();
36
- for (const [name, entry] of Object.entries(config.services)) {
37
- console.log(` ${colors.bold(name)}`);
38
- for (const slot of entry.secrets) {
39
- const present = vaultKeys.has(slot);
40
- const dot = present ? colors.green("●") : colors.red("○");
41
- const value = present ? colors.dim("(phm_…)") : colors.red("missing");
42
- console.log(` ${dot} ${slot.padEnd(30)} ${value}`);
43
- }
44
- }
45
- const declared = new Set(
46
- Object.values(config.services).flatMap((e) => e.secrets),
47
- );
48
- const orphans = [...vaultKeys].filter((k) => !declared.has(k)).sort();
49
- if (orphans.length > 0) {
50
- console.log();
51
- console.log(colors.dim(` orphans in vault (not referenced by any service):`));
52
- for (const k of orphans) console.log(` ${colors.yellow("●")} ${k}`);
53
- }
54
- console.log();
55
- outro(
56
- colors.dim(
57
- `${declared.size} declared · ${vaultKeys.size} in vault · ${orphans.length} orphan(s)`,
58
- ),
59
- );
60
- },
61
- }),
62
- diff: defineCommand({
63
- meta: { name: "diff", description: "Which declared secrets are missing from the vault?" },
64
- async run() {
65
- intro("stack env diff");
66
- if (!(await isPhantomInstalled())) {
67
- outroError("Phantom not installed.");
68
- return;
69
- }
70
- const config = await readConfig();
71
- const vaultKeys = new Set(await listSecrets());
72
- const missing: string[] = [];
73
- for (const entry of Object.values(config.services)) {
74
- for (const slot of entry.secrets) {
75
- if (!vaultKeys.has(slot)) missing.push(slot);
76
- }
77
- }
78
- if (missing.length === 0) {
79
- outro(colors.green("All declared secrets present."));
80
- return;
81
- }
82
- for (const s of missing) console.log(` ${colors.red("○")} ${s}`);
83
- outroError(`${missing.length} secret(s) missing from the vault.`);
84
- },
85
- }),
86
- },
87
- });
@@ -1,54 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { defineCommand } from "citty";
3
- import { assertPhantomInstalled, hasConfig } from "@ashlr/stack-core";
4
- import { colors, intro, outroError } from "../ui.ts";
5
-
6
- /**
7
- * `stack exec -- <cmd>` — run a command with Phantom's proxy active so env
8
- * vars with `phm_` tokens get swapped for real secrets at the network layer.
9
- * Thin wrapper over `phantom exec` so users don't have to context-switch
10
- * between Stack and Phantom for day-to-day work.
11
- */
12
- export const execCommand = defineCommand({
13
- meta: {
14
- name: "exec",
15
- description: "Run a command with Phantom's secret proxy active.",
16
- },
17
- args: {
18
- _: { type: "positional", required: false, description: "Command to run after --" },
19
- },
20
- async run({ rawArgs }) {
21
- intro("stack exec");
22
-
23
- if (!hasConfig()) {
24
- outroError("No .stack.toml found. Run `stack init` first.");
25
- return;
26
- }
27
-
28
- try {
29
- await assertPhantomInstalled();
30
- } catch (err) {
31
- outroError((err as Error).message);
32
- return;
33
- }
34
-
35
- // Forward everything after `--` (or everything after `exec`) to `phantom exec`.
36
- const sep = rawArgs.indexOf("--");
37
- const forwarded = sep >= 0 ? rawArgs.slice(sep + 1) : rawArgs;
38
- if (forwarded.length === 0) {
39
- outroError("Nothing to run. Usage: stack exec -- <command>");
40
- return;
41
- }
42
-
43
- const child = spawn("phantom", ["exec", "--", ...forwarded], { stdio: "inherit" });
44
- child.on("close", (code) => {
45
- if (code !== 0) {
46
- process.stderr.write(colors.dim(`\n[stack exec] phantom exec exited ${code}\n`));
47
- }
48
- process.exitCode = code ?? 0;
49
- });
50
- child.on("error", (err) => {
51
- outroError((err as Error).message);
52
- });
53
- },
54
- });
@@ -1,120 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { defineCommand } from "citty";
5
- import {
6
- addSecret,
7
- assertPhantomInstalled,
8
- emptyConfig,
9
- groupByProvider,
10
- hasConfig,
11
- listProviderNames,
12
- parseEnv,
13
- readConfig,
14
- toServiceEntry,
15
- writeConfig,
16
- } from "@ashlr/stack-core";
17
- import { colors, intro, outro, outroError, prompts } from "../ui.ts";
18
-
19
- /**
20
- * `stack import [--from .env]` — inhale an existing .env, route each secret
21
- * into Phantom, and populate .stack.toml with a best-guess service map.
22
- *
23
- * This is the most important migration command: most users arrive at Stack
24
- * with projects that already have filled-in .env files. They shouldn't have
25
- * to re-paste 20 keys to try the product.
26
- */
27
- export const importCommand = defineCommand({
28
- meta: {
29
- name: "import",
30
- description: "Import an existing .env into Phantom + .stack.toml.",
31
- },
32
- args: {
33
- from: { type: "string", default: ".env", description: "Path to the .env file to import." },
34
- dryRun: {
35
- type: "boolean",
36
- default: false,
37
- description: "Print what would happen without writing anything.",
38
- },
39
- },
40
- async run({ args }) {
41
- intro("stack import");
42
-
43
- const envPath = join(process.cwd(), String(args.from));
44
- if (!existsSync(envPath)) {
45
- outroError(`${envPath} not found.`);
46
- return;
47
- }
48
-
49
- try {
50
- await assertPhantomInstalled();
51
- } catch (err) {
52
- if (!args.dryRun) {
53
- outroError((err as Error).message);
54
- return;
55
- }
56
- prompts.log.warn(colors.yellow("Phantom not installed — dry-run mode will still preview."));
57
- }
58
-
59
- const raw = await readFile(envPath, "utf-8");
60
- const entries = parseEnv(raw);
61
- if (entries.length === 0) {
62
- outro(colors.dim(`${args.from}: nothing to import.`));
63
- return;
64
- }
65
-
66
- const grouped = groupByProvider(entries.map((e) => e.key));
67
- const registered = new Set(listProviderNames());
68
- const detectedProviders = Object.keys(grouped).filter((p) => registered.has(p));
69
- const detectedSecretCount = detectedProviders.reduce(
70
- (acc, p) => acc + grouped[p].length,
71
- 0,
72
- );
73
- const orphanSecrets = entries
74
- .map((e) => e.key)
75
- .filter((name) => !detectedProviders.some((p) => grouped[p].includes(name)));
76
-
77
- console.log();
78
- console.log(
79
- ` ${colors.bold(String(entries.length))} env vars · ${colors.bold(String(detectedProviders.length))} providers detected · ${colors.bold(String(orphanSecrets.length))} unattributed`,
80
- );
81
- for (const provider of detectedProviders) {
82
- console.log(
83
- ` ${colors.green("●")} ${provider.padEnd(14)} ${colors.dim(grouped[provider].join(", "))}`,
84
- );
85
- }
86
- if (orphanSecrets.length > 0) {
87
- console.log(
88
- ` ${colors.dim("·")} ${"(orphans)".padEnd(14)} ${colors.dim(orphanSecrets.join(", "))}`,
89
- );
90
- }
91
- console.log();
92
-
93
- if (args.dryRun) {
94
- outro(colors.dim("dry-run: nothing written."));
95
- return;
96
- }
97
-
98
- // 1. Store every secret in Phantom (provider-matched + orphans alike).
99
- let stored = 0;
100
- for (const { key, value } of entries) {
101
- if (value.length === 0) continue;
102
- await addSecret(key, value);
103
- stored += 1;
104
- }
105
-
106
- // 2. Update .stack.toml — create one if missing, then populate service entries.
107
- const config = hasConfig() ? await readConfig() : emptyConfig();
108
- for (const provider of detectedProviders) {
109
- if (config.services[provider]) continue; // don't clobber pre-existing entries
110
- config.services[provider] = toServiceEntry(provider, grouped[provider]);
111
- }
112
- await writeConfig(config);
113
-
114
- outro(
115
- colors.green(
116
- `Imported ${stored} secret(s); wired ${detectedProviders.length} service(s) in .stack.toml. Run \`stack doctor\` to verify.`,
117
- ),
118
- );
119
- },
120
- });
@@ -1,85 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import {
3
- type ProviderContext,
4
- getProvider,
5
- isPhantomInstalled,
6
- listSecrets,
7
- readConfig,
8
- } from "@ashlr/stack-core";
9
- import { colors, intro, logEvent, outro, outroError, prompts } from "../ui.ts";
10
-
11
- /**
12
- * `stack info <service>` — deep-dive on a configured service. Prints its
13
- * provider, resource id, region, secret slots (with vault presence), MCP
14
- * wiring, dashboard URL, and runs a fresh healthcheck.
15
- */
16
- export const infoCommand = defineCommand({
17
- meta: {
18
- name: "info",
19
- description: "Show everything Stack knows about a configured service.",
20
- },
21
- args: {
22
- service: { type: "positional", required: true, description: "Service name." },
23
- },
24
- async run({ args }) {
25
- intro(`stack info ${args.service}`);
26
- const config = await readConfig();
27
- const entry = config.services[String(args.service)];
28
- if (!entry) {
29
- outroError(`${args.service} is not in this stack. Run \`stack list\`.`);
30
- return;
31
- }
32
-
33
- const provider = await getProvider(entry.provider);
34
- const vaultKeys = (await isPhantomInstalled()) ? new Set(await listSecrets()) : new Set();
35
-
36
- console.log();
37
- console.log(` ${colors.bold(provider.displayName)} ${colors.dim(provider.category)}`);
38
- console.log(` ${colors.dim("resource:")} ${entry.resource_id ?? "(not provisioned)"}`);
39
- if (entry.region) console.log(` ${colors.dim("region:")} ${entry.region}`);
40
- console.log(` ${colors.dim("auth:")} ${provider.authKind}`);
41
- console.log(` ${colors.dim("created:")} ${entry.created_at}${entry.created_by ? colors.dim(` by ${entry.created_by}`) : ""}`);
42
- if (provider.docs) console.log(` ${colors.dim("docs:")} ${provider.docs}`);
43
- const dashboard = provider.dashboardUrl?.(entry);
44
- if (dashboard) console.log(` ${colors.dim("dash:")} ${dashboard}`);
45
- if (entry.mcp) console.log(` ${colors.dim("mcp:")} ${entry.mcp}`);
46
- if (entry.meta && Object.keys(entry.meta).length > 0) {
47
- console.log(` ${colors.dim("meta:")} ${JSON.stringify(entry.meta)}`);
48
- }
49
- console.log();
50
-
51
- console.log(` ${colors.bold("secrets")}`);
52
- for (const slot of entry.secrets) {
53
- const present = vaultKeys.has(slot);
54
- const dot = present ? colors.green("●") : colors.red("○");
55
- const tag = present ? colors.dim("in vault") : colors.red("missing");
56
- console.log(` ${dot} ${slot.padEnd(30)} ${tag}`);
57
- }
58
- console.log();
59
-
60
- if (provider.healthcheck) {
61
- const ctx: ProviderContext = {
62
- cwd: process.cwd(),
63
- interactive: false,
64
- log: logEvent,
65
- };
66
- const spinner = prompts.spinner();
67
- spinner.start("Running healthcheck…");
68
- try {
69
- const status = await provider.healthcheck(ctx, entry);
70
- if (status.kind === "ok") {
71
- spinner.stop(
72
- `${colors.green("●")} healthy${status.latencyMs ? colors.dim(` (${status.latencyMs}ms)`) : ""}`,
73
- );
74
- } else if (status.kind === "warn") {
75
- spinner.stop(`${colors.yellow("●")} warning: ${status.detail}`);
76
- } else {
77
- spinner.stop(`${colors.red("●")} error: ${status.detail}`);
78
- }
79
- } catch (err) {
80
- spinner.stop(`${colors.red("●")} healthcheck threw: ${(err as Error).message}`);
81
- }
82
- }
83
- outro("");
84
- },
85
- });
@@ -1,93 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import {
3
- type StackConfig,
4
- emptyConfig,
5
- hasConfig,
6
- isPhantomInstalled,
7
- listTemplates,
8
- loadTemplate,
9
- resolveConfigPath,
10
- writeConfig,
11
- } from "@ashlr/stack-core";
12
- import { colors, intro, outro, outroError, prompts } from "../ui.ts";
13
-
14
- export const initCommand = defineCommand({
15
- meta: {
16
- name: "init",
17
- description: "Scaffold a .stack.toml in the current directory.",
18
- },
19
- args: {
20
- template: {
21
- type: "string",
22
- description: "Name of a starter template to apply (e.g. nextjs-supabase-posthog).",
23
- },
24
- force: {
25
- type: "boolean",
26
- default: false,
27
- description: "Overwrite an existing .stack.toml.",
28
- },
29
- noInteractive: {
30
- type: "boolean",
31
- default: false,
32
- description: "Skip template picker — always creates a blank .stack.toml.",
33
- },
34
- },
35
- async run({ args }) {
36
- intro("stack init");
37
-
38
- const cwd = process.cwd();
39
- if (hasConfig(cwd) && !args.force) {
40
- outroError(
41
- `${colors.bold(".stack.toml")} already exists at ${resolveConfigPath(cwd)}. Pass --force to overwrite.`,
42
- );
43
- return;
44
- }
45
-
46
- if (!(await isPhantomInstalled())) {
47
- prompts.log.warn(
48
- colors.yellow(
49
- "Phantom Secrets isn't on your PATH. Stack will create .stack.toml anyway, but `stack add` needs Phantom.",
50
- ),
51
- );
52
- prompts.log.info(" Install: brew install ashlrai/phantom/phantom");
53
- }
54
-
55
- let templateName = args.template ? String(args.template) : undefined;
56
-
57
- if (!templateName && !args.noInteractive && process.stdout.isTTY) {
58
- const available = listTemplates();
59
- if (available.length > 0) {
60
- const picked = await prompts.select({
61
- message: "Pick a starter template (or blank):",
62
- options: [
63
- { value: "", label: "Blank — add services later with `stack add`" },
64
- ...available.map((name) => ({ value: name, label: name })),
65
- ],
66
- });
67
- if (prompts.isCancel(picked)) {
68
- outroError("Cancelled.");
69
- return;
70
- }
71
- if (picked) templateName = String(picked);
72
- }
73
- }
74
-
75
- let config: StackConfig;
76
- if (templateName) {
77
- const loaded = await loadTemplate(templateName);
78
- if (!loaded) {
79
- outroError(`Unknown template: ${templateName}`);
80
- return;
81
- }
82
- config = loaded;
83
- config.stack.template = templateName;
84
- } else {
85
- config = emptyConfig();
86
- }
87
-
88
- await writeConfig(config, cwd);
89
- outro(
90
- `Wrote ${colors.bold(".stack.toml")} (${config.stack.project_id})${templateName ? ` from template ${colors.bold(templateName)}` : ""}.`,
91
- );
92
- },
93
- });
@@ -1,27 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import { readConfig } from "@ashlr/stack-core";
3
- import { colors } from "../ui.ts";
4
-
5
- export const listCommand = defineCommand({
6
- meta: { name: "list", description: "List services configured in this stack." },
7
- async run() {
8
- const config = await readConfig();
9
- const entries = Object.entries(config.services);
10
- if (entries.length === 0) {
11
- console.log(colors.dim("No services yet. Try `stack add supabase`."));
12
- return;
13
- }
14
- console.log();
15
- console.log(colors.bold(` ${config.stack.project_id} `) + colors.dim(`(${entries.length} services)`));
16
- console.log();
17
- for (const [name, entry] of entries) {
18
- const mcp = entry.mcp ? colors.cyan(` mcp:${entry.mcp}`) : "";
19
- const region = entry.region ? colors.dim(` @${entry.region}`) : "";
20
- console.log(` ${colors.green("●")} ${colors.bold(name)}${region}${mcp}`);
21
- console.log(
22
- ` ${colors.dim("resource:")} ${entry.resource_id ?? "-"} ${colors.dim("secrets:")} ${entry.secrets.join(", ") || "-"}`,
23
- );
24
- }
25
- console.log();
26
- },
27
- });
@@ -1,28 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import { type ProviderContext, getProvider } from "@ashlr/stack-core";
3
- import { colors, intro, logEvent, outro, outroError } from "../ui.ts";
4
-
5
- export const loginCommand = defineCommand({
6
- meta: {
7
- name: "login",
8
- description: "Refresh OAuth / PAT credentials for a specific provider.",
9
- },
10
- args: {
11
- service: { type: "positional", required: true, description: "Provider name." },
12
- },
13
- async run({ args }) {
14
- intro(`stack login ${args.service}`);
15
- try {
16
- const provider = await getProvider(args.service);
17
- const ctx: ProviderContext = {
18
- cwd: process.cwd(),
19
- interactive: process.stdout.isTTY === true,
20
- log: logEvent,
21
- };
22
- await provider.login(ctx);
23
- outro(colors.green(`${provider.displayName}: signed in.`));
24
- } catch (err) {
25
- outroError((err as Error).message);
26
- }
27
- },
28
- });
@@ -1,32 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { platform } from "node:os";
3
- import { defineCommand } from "citty";
4
- import { getProvider, readConfig } from "@ashlr/stack-core";
5
- import { colors, intro, outro, outroError } from "../ui.ts";
6
-
7
- export const openCommand = defineCommand({
8
- meta: { name: "open", description: "Open a service's dashboard in your browser." },
9
- args: {
10
- service: { type: "positional", required: true, description: "Service name." },
11
- },
12
- async run({ args }) {
13
- intro(`stack open ${args.service}`);
14
- const config = await readConfig();
15
- const entry = config.services[args.service];
16
- if (!entry) {
17
- outroError(`${args.service} is not in this stack.`);
18
- return;
19
- }
20
- const provider = await getProvider(args.service);
21
- const url = provider.dashboardUrl?.(entry);
22
- if (!url) {
23
- outroError(`${provider.displayName} has no dashboard URL configured.`);
24
- return;
25
- }
26
-
27
- const opener =
28
- platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
29
- spawn(opener, [url], { stdio: "ignore", detached: true }).unref();
30
- outro(colors.green(`Opening ${url}`));
31
- },
32
- });
@@ -1,92 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import {
3
- findProjectByName,
4
- listProjects,
5
- registerProject,
6
- unregisterProject,
7
- } from "@ashlr/stack-core";
8
- import { colors, intro, outro, outroError } from "../ui.ts";
9
-
10
- /**
11
- * `stack projects` — work across every Stack-enabled project on this machine.
12
- *
13
- * stack projects list
14
- * stack projects register (register the current cwd)
15
- * stack projects remove <name|path>
16
- * stack projects where <name>
17
- */
18
- export const projectsCommand = defineCommand({
19
- meta: {
20
- name: "projects",
21
- description: "Manage the cross-project registry at ~/.stack/projects.json.",
22
- },
23
- subCommands: {
24
- list: defineCommand({
25
- meta: { name: "list", description: "List every Stack-enabled project on this machine." },
26
- async run() {
27
- intro("stack projects");
28
- const projects = await listProjects();
29
- if (projects.length === 0) {
30
- outro(colors.dim("No projects registered yet. Run `stack init` in one to start."));
31
- return;
32
- }
33
- console.log();
34
- for (const p of projects) {
35
- const template = p.template ? colors.dim(` [${p.template}]`) : "";
36
- const services = p.services.length
37
- ? colors.dim(` · ${p.services.join(", ")}`)
38
- : colors.dim(" · no services");
39
- console.log(
40
- ` ${colors.green("●")} ${colors.bold(p.name)}${template} ${colors.dim(p.project_id)}`,
41
- );
42
- console.log(` ${colors.dim(p.path)}${services}`);
43
- }
44
- console.log();
45
- },
46
- }),
47
- register: defineCommand({
48
- meta: { name: "register", description: "Register the current directory in the registry." },
49
- async run() {
50
- intro("stack projects register");
51
- const entry = await registerProject(process.cwd());
52
- if (!entry) {
53
- outroError("No .stack.toml in the current directory — run `stack init` first.");
54
- return;
55
- }
56
- outro(colors.green(`Registered ${entry.name} (${entry.project_id}).`));
57
- },
58
- }),
59
- remove: defineCommand({
60
- meta: { name: "remove", description: "Remove a project from the registry (does not delete files)." },
61
- args: {
62
- target: { type: "positional", required: true, description: "Project name or absolute path." },
63
- },
64
- async run({ args }) {
65
- intro("stack projects remove");
66
- const target = String(args.target);
67
- const match = target.startsWith("/") ? target : (await findProjectByName(target))?.path;
68
- if (!match) {
69
- outroError(`No registered project matches "${target}".`);
70
- return;
71
- }
72
- await unregisterProject(match);
73
- outro(colors.green(`Removed ${target} from registry.`));
74
- },
75
- }),
76
- where: defineCommand({
77
- meta: { name: "where", description: "Print the path of a registered project." },
78
- args: {
79
- name: { type: "positional", required: true, description: "Project name." },
80
- },
81
- async run({ args }) {
82
- const project = await findProjectByName(String(args.name));
83
- if (!project) {
84
- process.exitCode = 1;
85
- console.error(`No registered project named "${args.name}".`);
86
- return;
87
- }
88
- console.log(project.path);
89
- },
90
- }),
91
- },
92
- });
@@ -1,38 +0,0 @@
1
- import { defineCommand } from "citty";
2
- import { getProvider, listProviderNames } from "@ashlr/stack-core";
3
- import { colors, intro } from "../ui.ts";
4
-
5
- export const providersCommand = defineCommand({
6
- meta: { name: "providers", description: "List every curated provider Stack can wire up." },
7
- async run() {
8
- intro("stack providers");
9
- const names = listProviderNames();
10
- const byCategory: Record<string, Array<{ name: string; displayName: string; auth: string }>> = {};
11
- for (const name of names) {
12
- try {
13
- const p = await getProvider(name);
14
- (byCategory[p.category] ??= []).push({
15
- name: p.name,
16
- displayName: p.displayName,
17
- auth: p.authKind,
18
- });
19
- } catch {
20
- /* ignore load failures — the registry is authoritative */
21
- }
22
- }
23
-
24
- const categories = Object.keys(byCategory).sort();
25
- console.log();
26
- for (const cat of categories) {
27
- console.log(` ${colors.bold(cat)}`);
28
- for (const p of byCategory[cat]) {
29
- console.log(
30
- ` ${colors.cyan("·")} ${p.name.padEnd(12)} ${colors.dim(`(${p.auth})`)} ${p.displayName}`,
31
- );
32
- }
33
- console.log();
34
- }
35
- console.log(colors.dim(` ${names.length} providers total. Add one with: stack add <name>`));
36
- console.log();
37
- },
38
- });