@kill-switch/cli 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.
Files changed (43) hide show
  1. package/README.md +49 -4
  2. package/dist/commands/accounts.d.ts +2 -1
  3. package/dist/commands/accounts.js +44 -27
  4. package/dist/commands/activity.d.ts +3 -0
  5. package/dist/commands/activity.js +80 -0
  6. package/dist/commands/agent-guard.d.ts +10 -0
  7. package/dist/commands/agent-guard.js +175 -0
  8. package/dist/commands/alerts.d.ts +2 -1
  9. package/dist/commands/alerts.js +112 -12
  10. package/dist/commands/analytics.d.ts +2 -1
  11. package/dist/commands/analytics.js +36 -14
  12. package/dist/commands/auth.d.ts +2 -1
  13. package/dist/commands/auth.js +127 -14
  14. package/dist/commands/check.d.ts +2 -1
  15. package/dist/commands/check.js +28 -15
  16. package/dist/commands/kill.d.ts +2 -1
  17. package/dist/commands/kill.js +52 -37
  18. package/dist/commands/onboard.d.ts +2 -17
  19. package/dist/commands/onboard.js +273 -61
  20. package/dist/commands/orgs.d.ts +3 -0
  21. package/dist/commands/orgs.js +192 -0
  22. package/dist/commands/providers.d.ts +3 -0
  23. package/dist/commands/providers.js +82 -0
  24. package/dist/commands/rules.d.ts +2 -1
  25. package/dist/commands/rules.js +51 -28
  26. package/dist/commands/shield.d.ts +2 -1
  27. package/dist/commands/shield.js +36 -17
  28. package/dist/commands/status.d.ts +3 -0
  29. package/dist/commands/status.js +100 -0
  30. package/dist/commands/watch.d.ts +3 -0
  31. package/dist/commands/watch.js +68 -0
  32. package/dist/device-flow.d.ts +33 -0
  33. package/dist/device-flow.js +91 -0
  34. package/dist/index.js +38 -11
  35. package/dist/output.d.ts +26 -4
  36. package/dist/output.js +101 -12
  37. package/dist/prompts.d.ts +13 -0
  38. package/dist/prompts.js +24 -0
  39. package/dist/types.d.ts +2 -0
  40. package/dist/types.js +1 -0
  41. package/dist/version.d.ts +1 -0
  42. package/dist/version.js +4 -0
  43. package/package.json +29 -7
@@ -1,6 +1,5 @@
1
- import { apiRequest } from "../api-client.js";
2
- import { outputJson, formatTable, outputError } from "../output.js";
3
- export function registerAlertCommands(program) {
1
+ import { outputJson, formatTable, handleError, spinner, success } from "../output.js";
2
+ export function registerAlertCommands(program, createClient) {
4
3
  const alerts = program.command("alerts").description("Manage alert channels");
5
4
  alerts
6
5
  .command("list")
@@ -9,22 +8,120 @@ export function registerAlertCommands(program) {
9
8
  .action(async () => {
10
9
  const json = program.opts().json;
11
10
  try {
12
- const data = await apiRequest("/alerts/channels");
13
- const channels = data.channels || data;
11
+ const client = createClient();
12
+ const channels = await client.alerts.channels();
14
13
  if (json) {
15
14
  outputJson(channels);
16
15
  }
17
16
  else {
18
- formatTable(Array.isArray(channels) ? channels : [], [
17
+ formatTable(channels, [
19
18
  { key: "type", header: "Type" },
20
19
  { key: "name", header: "Name" },
21
20
  { key: "enabled", header: "Enabled" },
21
+ { key: "configPreview", header: "Config" },
22
22
  ]);
23
23
  }
24
24
  }
25
25
  catch (err) {
26
- outputError(err.message, json);
27
- process.exit(1);
26
+ handleError(err, json);
27
+ }
28
+ });
29
+ alerts
30
+ .command("add")
31
+ .description("Add an alert channel")
32
+ .requiredOption("--type <type>", "Channel type: pagerduty | slack | discord | webhook | email | github")
33
+ .option("--name <name>", "Display name for this channel")
34
+ .option("--routing-key <key>", "PagerDuty Events API v2 routing key")
35
+ .option("--webhook-url <url>", "Webhook URL (Slack, Discord, or custom webhook)")
36
+ .option("--email <address>", "Email address")
37
+ // GitHub options
38
+ .option("--token <token>", "GitHub Personal Access Token (repo + workflow scopes)")
39
+ .option("--repo-owner <owner>", "GitHub repository owner / org")
40
+ .option("--repo-name <name>", "GitHub repository name")
41
+ .option("--workflow <file>", "Workflow filename (default: kill-switch-remediate.yml)")
42
+ .option("--branch <ref>", "Branch to dispatch on (default: main)")
43
+ .action(async (opts) => {
44
+ const json = program.opts().json;
45
+ const s = json ? null : spinner("Adding alert channel...").start();
46
+ try {
47
+ const type = opts.type;
48
+ const validTypes = ["pagerduty", "slack", "discord", "webhook", "email", "github"];
49
+ if (!validTypes.includes(type)) {
50
+ throw new Error(`Unknown type "${type}". Valid types: ${validTypes.join(", ")}`);
51
+ }
52
+ // Build config and validate required fields per type
53
+ const config = {};
54
+ if (type === "pagerduty") {
55
+ if (!opts.routingKey)
56
+ throw new Error("--routing-key is required for pagerduty");
57
+ config.routingKey = opts.routingKey;
58
+ }
59
+ else if (type === "slack" || type === "discord" || type === "webhook") {
60
+ if (!opts.webhookUrl)
61
+ throw new Error(`--webhook-url is required for ${type}`);
62
+ config.webhookUrl = opts.webhookUrl;
63
+ }
64
+ else if (type === "email") {
65
+ if (!opts.email)
66
+ throw new Error("--email is required for email");
67
+ config.email = opts.email;
68
+ }
69
+ else if (type === "github") {
70
+ if (!opts.token)
71
+ throw new Error("--token is required for github");
72
+ if (!opts.repoOwner)
73
+ throw new Error("--repo-owner is required for github");
74
+ if (!opts.repoName)
75
+ throw new Error("--repo-name is required for github");
76
+ config.githubToken = opts.token;
77
+ config.repoOwner = opts.repoOwner;
78
+ config.repoName = opts.repoName;
79
+ config.workflowFile = opts.workflow || "kill-switch-remediate.yml";
80
+ config.branchRef = opts.branch || "main";
81
+ }
82
+ const channel = {
83
+ type,
84
+ name: opts.name || type.charAt(0).toUpperCase() + type.slice(1),
85
+ enabled: true,
86
+ config,
87
+ };
88
+ const client = createClient();
89
+ const result = await client.alerts.addChannel(channel);
90
+ s?.stop();
91
+ if (json) {
92
+ outputJson(result);
93
+ }
94
+ else {
95
+ success(`Channel added. You now have ${result.channelCount} alert channel(s).`);
96
+ }
97
+ }
98
+ catch (err) {
99
+ s?.stop();
100
+ handleError(err, json);
101
+ }
102
+ });
103
+ alerts
104
+ .command("remove")
105
+ .alias("rm")
106
+ .description("Remove an alert channel by name")
107
+ .argument("<name>", "Channel name to remove")
108
+ .action(async (name) => {
109
+ const json = program.opts().json;
110
+ const s = json ? null : spinner(`Removing channel "${name}"...`).start();
111
+ try {
112
+ const client = createClient();
113
+ const result = await client.alerts.removeChannel(name);
114
+ s?.stop();
115
+ if (json) {
116
+ outputJson(result);
117
+ }
118
+ else {
119
+ success(`Channel removed. ${result.channelCount} channel(s) remaining.`);
120
+ }
121
+ }
122
+ catch (err) {
123
+ s?.stop();
124
+ handleError(err, json);
28
125
  }
29
126
  });
30
127
  alerts
@@ -32,18 +129,21 @@ export function registerAlertCommands(program) {
32
129
  .description("Send a test alert to all channels")
33
130
  .action(async () => {
34
131
  const json = program.opts().json;
132
+ const s = json ? null : spinner("Sending test alert...").start();
35
133
  try {
36
- const data = await apiRequest("/alerts/test", { method: "POST" });
134
+ const client = createClient();
135
+ const data = await client.alerts.test();
136
+ s?.stop();
37
137
  if (json) {
38
138
  outputJson(data);
39
139
  }
40
140
  else {
41
- console.log("Test alert sent to all configured channels.");
141
+ success(`Test alert sent to ${data.channelsSent} channel(s).`);
42
142
  }
43
143
  }
44
144
  catch (err) {
45
- outputError(err.message, json);
46
- process.exit(1);
145
+ s?.stop();
146
+ handleError(err, json);
47
147
  }
48
148
  });
49
149
  }
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerAnalyticsCommands(program: Command): void;
2
+ import type { ClientFactory } from "../types.js";
3
+ export declare function registerAnalyticsCommands(program: Command, createClient: ClientFactory): void;
@@ -1,35 +1,57 @@
1
- import { apiRequest } from "../api-client.js";
2
- import { outputJson, formatTable, outputError } from "../output.js";
3
- export function registerAnalyticsCommands(program) {
1
+ import { outputJson, formatTable, handleError, spinner, colors as c } from "../output.js";
2
+ export function registerAnalyticsCommands(program, createClient) {
4
3
  program
5
4
  .command("analytics")
6
5
  .description("FinOps analytics overview")
7
6
  .option("--days <n>", "Days to analyze", "30")
8
7
  .action(async (opts) => {
9
8
  const json = program.opts().json;
9
+ const s = json ? null : spinner("Loading analytics...").start();
10
10
  try {
11
- const data = await apiRequest(`/analytics/overview?days=${opts.days}`);
11
+ const client = createClient();
12
+ const data = await client.analytics.overview();
13
+ s?.stop();
12
14
  if (json) {
13
15
  outputJson(data);
14
16
  }
15
17
  else {
16
- console.log(`Analytics (last ${opts.days} days)\n`);
17
- if (data.dailyCosts) {
18
+ console.log(c.bold(`\nAnalytics Overview\n`));
19
+ // Summary stats
20
+ const totalSpend = data.totalSpendPeriod ?? 0;
21
+ const avgDaily = data.avgDailyCost ?? 0;
22
+ const projected = data.projectedMonthlyCost ?? 0;
23
+ const savings = data.savingsEstimate ?? 0;
24
+ const actions = data.killSwitchActions ?? 0;
25
+ console.log(` ${c.bold("Total spend:")} $${totalSpend.toFixed(2)}`);
26
+ console.log(` ${c.bold("Avg daily cost:")} $${avgDaily.toFixed(2)}`);
27
+ console.log(` ${c.bold("Projected monthly:")} $${projected.toFixed(2)}`);
28
+ console.log(` ${c.bold("Savings estimate:")} ${c.green("$" + savings.toFixed(2))}`);
29
+ console.log(` ${c.bold("Kill switch actions:")} ${actions > 0 ? c.yellow(String(actions)) : c.dim("0")}`);
30
+ // Last 7 days cost table
31
+ if (data.dailyCosts?.length) {
32
+ console.log(c.bold("\nLast 7 Days:\n"));
18
33
  formatTable(data.dailyCosts.slice(-7), [
19
- { key: "date", header: "Date" },
20
- { key: "totalUsd", header: "Cost (USD)" },
21
- { key: "violations", header: "Violations" },
22
- { key: "actions", header: "Actions" },
34
+ { key: "date", header: "Date", width: 12 },
35
+ { key: "cost", header: "Cost (USD)", width: 12 },
36
+ { key: "services", header: "Services", width: 10 },
37
+ { key: "violations", header: "Violations", width: 12 },
23
38
  ]);
24
39
  }
25
- if (data.totalSavingsUsd !== undefined) {
26
- console.log(`\nEstimated savings: $${data.totalSavingsUsd}`);
40
+ // Per-account breakdown
41
+ if (data.accountBreakdown?.length) {
42
+ console.log(c.bold("\nAccount Breakdown:\n"));
43
+ formatTable(data.accountBreakdown, [
44
+ { key: "provider", header: "Provider", width: 14 },
45
+ { key: "totalCost", header: "Total Cost", width: 12 },
46
+ { key: "avgDailyCost", header: "Avg Daily", width: 12 },
47
+ ]);
27
48
  }
49
+ console.log();
28
50
  }
29
51
  }
30
52
  catch (err) {
31
- outputError(err.message, json);
32
- process.exit(1);
53
+ s?.stop();
54
+ handleError(err, json);
33
55
  }
34
56
  });
35
57
  }
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerAuthCommands(program: Command): void;
2
+ import type { ClientFactory } from "../types.js";
3
+ export declare function registerAuthCommands(program: Command, createClient: ClientFactory): void;
@@ -1,34 +1,146 @@
1
+ import { KillSwitchClient } from "@kill-switch/sdk";
1
2
  import { saveConfig, deleteConfig, resolveApiKey, resolveApiUrl } from "../config.js";
2
- import { apiRequest } from "../api-client.js";
3
- import { outputJson, formatObject, outputError } from "../output.js";
4
- export function registerAuthCommands(program) {
3
+ import { outputJson, formatObject, outputError, handleError, spinner, success } from "../output.js";
4
+ import { ask } from "../prompts.js";
5
+ import { execFile } from "child_process";
6
+ import { CLI_VERSION } from "../version.js";
7
+ import { runDeviceFlow, defaultDeviceFlowDeps } from "../device-flow.js";
8
+ function openBrowser(url) {
9
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
10
+ execFile(cmd, [url], () => { });
11
+ }
12
+ // Wires the device-flow helper to this command's spinner + console output.
13
+ // Kept here (not in device-flow.ts) so the helper stays free of CLI-specific
14
+ // presentation concerns and remains pure for unit testing.
15
+ function deviceFlowWithSpinner() {
16
+ let sp = null;
17
+ return defaultDeviceFlowDeps({
18
+ log: (line) => console.log(line),
19
+ spinnerStart: (label) => { sp = spinner(label).start(); },
20
+ spinnerStop: () => { sp?.stop(); sp = null; },
21
+ });
22
+ }
23
+ export function registerAuthCommands(program, createClient) {
5
24
  const auth = program.command("auth").description("Manage authentication");
25
+ auth
26
+ .command("setup")
27
+ .description("Authenticate via your browser (opens a one-time approval page)")
28
+ .option("--manual", "Manual flow: open Settings page, paste the key yourself")
29
+ .action(async (opts) => {
30
+ const json = program.opts().json;
31
+ const existing = resolveApiKey();
32
+ if (existing) {
33
+ try {
34
+ const client = createClient();
35
+ const result = await client.account.me();
36
+ if (!json) {
37
+ console.log(`Already authenticated as ${result.name || result._id}.`);
38
+ const proceed = await ask("Re-authenticate anyway? (y/N): ");
39
+ if (proceed.toLowerCase() !== "y")
40
+ return;
41
+ }
42
+ }
43
+ catch {
44
+ // Key invalid, proceed
45
+ }
46
+ }
47
+ const apiUrl = resolveApiUrl();
48
+ // Manual flow \u2014 original copy/paste path for offline / scripted setups
49
+ if (opts.manual) {
50
+ if (!json) {
51
+ console.log("\n\u26a1 Kill Switch CLI Setup (manual)\n");
52
+ console.log("1. Sign in at app.kill-switch.net");
53
+ console.log("2. Go to Settings > API Keys");
54
+ console.log("3. Click 'Create API Key'");
55
+ console.log("4. Copy the key and paste it below\n");
56
+ }
57
+ openBrowser("https://app.kill-switch.net/settings");
58
+ const key = await ask("Paste your API key (ks_live_...): ");
59
+ if (!key.startsWith("ks_")) {
60
+ outputError("API key must start with 'ks_'.", json);
61
+ process.exit(1);
62
+ }
63
+ try {
64
+ const client = new KillSwitchClient({ apiKey: key, baseUrl: apiUrl });
65
+ const result = await client.account.me();
66
+ saveConfig({ apiKey: key, apiUrl });
67
+ if (json)
68
+ outputJson({ authenticated: true, account: result.name || result._id });
69
+ else {
70
+ success(`Authenticated as ${result.name || result._id}`);
71
+ console.log("API key saved to ~/.kill-switch/config.json");
72
+ }
73
+ }
74
+ catch (err) {
75
+ handleError(err, json);
76
+ }
77
+ return;
78
+ }
79
+ // Device flow \u2014 default path
80
+ try {
81
+ const apiKey = await runDeviceFlow(apiUrl, CLI_VERSION, json, deviceFlowWithSpinner());
82
+ const client = new KillSwitchClient({ apiKey, baseUrl: apiUrl });
83
+ const result = await client.account.me();
84
+ saveConfig({ apiKey, apiUrl });
85
+ if (json) {
86
+ outputJson({ authenticated: true, account: result.name || result._id });
87
+ }
88
+ else {
89
+ success(`Authenticated as ${result.name || result._id}`);
90
+ console.log("API key saved to ~/.kill-switch/config.json\n");
91
+ console.log("Next: ks onboard --help-provider mongodb");
92
+ }
93
+ }
94
+ catch (err) {
95
+ handleError(err, json);
96
+ }
97
+ });
6
98
  auth
7
99
  .command("login")
8
- .description("Authenticate with an API key")
9
- .requiredOption("--api-key <key>", "Personal API key (starts with ks_)")
100
+ .description("Authenticate (browser device flow by default, or pass --api-key for direct)")
101
+ .option("--api-key <key>", "Personal API key (starts with ks_) — skips browser flow")
10
102
  .action(async (opts) => {
11
103
  const json = program.opts().json;
12
- const key = opts.apiKey;
104
+ const apiUrl = resolveApiUrl();
105
+ let key = opts.apiKey;
106
+ // Device flow when no --api-key. JSON mode requires --api-key (no browser).
107
+ if (!key) {
108
+ if (json) {
109
+ outputError("--api-key is required in JSON mode", json);
110
+ process.exit(1);
111
+ }
112
+ try {
113
+ key = await runDeviceFlow(apiUrl, CLI_VERSION, json, deviceFlowWithSpinner());
114
+ }
115
+ catch (err) {
116
+ handleError(err, json);
117
+ return;
118
+ }
119
+ }
13
120
  if (!key.startsWith("ks_")) {
14
- outputError("API key must start with 'ks_'. Create one at app.kill-switch.net.", json);
121
+ outputError("API key must start with 'ks_'. Create one at app.kill-switch.net or run: ks auth setup", json);
15
122
  process.exit(1);
16
123
  }
17
- // Validate the key by calling the API
124
+ const s = json ? null : spinner("Validating API key...").start();
18
125
  try {
19
- const result = await apiRequest("/accounts/me", { apiKey: key });
20
- saveConfig({ apiKey: key, apiUrl: resolveApiUrl() });
126
+ const client = new KillSwitchClient({
127
+ apiKey: key,
128
+ baseUrl: apiUrl,
129
+ });
130
+ const result = await client.account.me();
131
+ s?.stop();
132
+ saveConfig({ apiKey: key, apiUrl });
21
133
  if (json) {
22
134
  outputJson({ authenticated: true, account: result.name || result._id });
23
135
  }
24
136
  else {
25
- console.log(`Authenticated as ${result.name || result._id}`);
137
+ success(`Authenticated as ${result.name || result._id}`);
26
138
  console.log("API key saved to ~/.kill-switch/config.json");
27
139
  }
28
140
  }
29
141
  catch (err) {
30
- outputError(`Authentication failed: ${err.message}`, json);
31
- process.exit(2);
142
+ s?.stop();
143
+ handleError(err, json);
32
144
  }
33
145
  });
34
146
  auth
@@ -60,7 +172,8 @@ export function registerAuthCommands(program) {
60
172
  return;
61
173
  }
62
174
  try {
63
- const result = await apiRequest("/accounts/me");
175
+ const client = createClient();
176
+ const result = await client.account.me();
64
177
  if (json) {
65
178
  outputJson({ authenticated: true, ...result });
66
179
  }
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerCheckCommands(program: Command): void;
2
+ import type { ClientFactory } from "../types.js";
3
+ export declare function registerCheckCommands(program: Command, createClient: ClientFactory): void;
@@ -1,37 +1,50 @@
1
- import { apiRequest } from "../api-client.js";
2
- import { outputJson, formatTable, outputError } from "../output.js";
3
- export function registerCheckCommands(program) {
1
+ import { outputJson, formatTable, handleError, spinner, colors as c } from "../output.js";
2
+ export function registerCheckCommands(program, createClient) {
4
3
  program
5
4
  .command("check")
6
5
  .description("Run monitoring check on all connected accounts")
7
6
  .action(async () => {
8
7
  const json = program.opts().json;
8
+ const s = json ? null : spinner("Running monitoring check...").start();
9
9
  try {
10
- const data = await apiRequest("/check", { method: "POST" });
10
+ const client = createClient();
11
+ const data = await client.monitoring.checkAll();
12
+ s?.stop();
11
13
  if (json) {
12
14
  outputJson(data);
13
15
  }
14
16
  else {
15
17
  const results = data.results || [];
16
- console.log(`Checked ${results.length} account(s)\n`);
18
+ const totalViolations = results.reduce((n, r) => n + (r.violations?.length || 0), 0);
19
+ console.log(`Checked ${c.bold(String(results.length))} account(s) — ${totalViolations === 0 ? c.green("all clear") : c.red(totalViolations + " violation(s)")}\n`);
17
20
  for (const r of results) {
18
- console.log(`${r.provider || "unknown"}: ${r.name || r.cloudAccountId}`);
21
+ const statusMark = r.status === "violation" ? c.red("✗") : r.status === "error" ? c.yellow("⚠") : c.green("✓");
22
+ console.log(`${statusMark} ${c.bold((r.provider || "unknown").padEnd(12))} ${r.name || r.cloudAccountId}`);
19
23
  if (r.violations?.length) {
20
- formatTable(r.violations, [
21
- { key: "metric", header: "Metric" },
22
- { key: "value", header: "Value" },
23
- { key: "threshold", header: "Threshold" },
24
+ // Compute multiplier for each violation inline
25
+ const rows = r.violations.map((v) => ({
26
+ ...v,
27
+ multiplier: v.threshold > 0 ? `${Math.round(v.currentValue / v.threshold)}x` : "",
28
+ }));
29
+ formatTable(rows, [
30
+ { key: "serviceName", header: "Service", width: 28 },
31
+ { key: "metricName", header: "Metric", width: 22 },
32
+ { key: "currentValue", header: "Current", width: 14 },
33
+ { key: "threshold", header: "Threshold", width: 12 },
34
+ { key: "multiplier", header: "Over", width: 8 },
35
+ { key: "severity", header: "Severity", width: 10 },
24
36
  ]);
37
+ if (r.actionsTaken?.length) {
38
+ console.log(c.dim(` Actions: ${r.actionsTaken.join(", ")}`));
39
+ }
25
40
  }
26
- else {
27
- console.log(" All clear\n");
28
- }
41
+ console.log();
29
42
  }
30
43
  }
31
44
  }
32
45
  catch (err) {
33
- outputError(err.message, json);
34
- process.exit(1);
46
+ s?.stop();
47
+ handleError(err, json);
35
48
  }
36
49
  });
37
50
  }
@@ -1,2 +1,3 @@
1
1
  import { Command } from "commander";
2
- export declare function registerKillCommands(program: Command): void;
2
+ import type { ClientFactory } from "../types.js";
3
+ export declare function registerKillCommands(program: Command, createClient: ClientFactory): void;