lapsed 0.0.0-stage → 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/lapsed.js +1137 -0
  4. package/package.json +33 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Laskarzewski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # lapsed
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Command-line client for [Lapsed](https://lapsed.dev), SSL certificate and domain expiry monitoring.
4
+
5
+ Each command calls the public API, which the dashboard also uses. Everything available in the dashboard is available here, and everything here is available through the API.
6
+
7
+ ## Install
8
+
9
+ Requires Node.js 22 or later. `npx` downloads the CLI on first use:
10
+
11
+ ```
12
+ npx lapsed login
13
+ ```
14
+
15
+ To install it globally, run `npm install -g lapsed`, then use `lapsed …`.
16
+
17
+ ## Quickstart
18
+
19
+ ```
20
+ npx lapsed login
21
+ npx lapsed add example.com api.example.com
22
+ npx lapsed channels add https://hooks.slack.com/services/…
23
+ npx lapsed channels test
24
+ ```
25
+
26
+ 1. **`login`** prints a code and opens a browser. After you approve the code in the browser, the CLI stores an API key in the config directory:
27
+ - `~/.config/lapsed/config.json` on Linux;
28
+ - `~/Library/Application Support/lapsed/` on macOS;
29
+ - `%APPDATA%\lapsed\` on Windows.
30
+
31
+ The file is readable only by your user.
32
+ 2. **`add`** checks each hostname immediately and prints the result.
33
+ 3. **`channels test`** sends a test alert to every channel.
34
+
35
+ ## Commands
36
+
37
+ | Command | What it does |
38
+ |---|---|
39
+ | `login` | Log in through your browser |
40
+ | `logout` | Revoke the key from `login` and forget it |
41
+ | `whoami` | Your account, balance and watch count |
42
+ | `add <input...> [--kind host\|domain]` | Watch hostnames (`host`: certificate and domain expiry) or domains (`domain`: registration expiry only) |
43
+ | `remove <watch...>` | Stop watching (by input or watch id) |
44
+ | `list` | Your watches |
45
+ | `status [watch]` | Open problems, for every watch or for one in detail |
46
+ | `pin <watch>` / `unpin <watch>` | Choose which watches are your free ones |
47
+ | `optin <watch> <event...>` / `optout …` | Turn on events that are off by default (e.g. `cert.changed`) |
48
+ | `channels add <email or URL>` | Add an email address, webhook, Slack or Discord URL. Webhooks show their signing secret once |
49
+ | `channels list` / `channels remove <id>` | List or remove channels |
50
+ | `channels test [id]` | Send a test alert to one channel, or to all of them |
51
+ | `channels rotate-secret <id>` | Replace a webhook's signing secret |
52
+ | `events [--limit N] [--cursor C]` | Recent events across your watches |
53
+ | `deliveries [id] [--failed] [--limit N] [--cursor C]` | The delivery log, or one delivery with every attempt |
54
+ | `resend <delivery-id>` | Send a delivery again |
55
+ | `balance` | Balance, refundable credit and recent activity |
56
+ | `refund [--yes]` | Refund unused paid credit to the original payment method, with no fee |
57
+ | `topup [5\|20]` | Opens a checkout for a $5 (default) or $20 credit pack |
58
+ | `keys create <name>` / `keys list` / `keys revoke <id>` | API keys for scripts and CI |
59
+ | `account delete [--confirm <email>] [--forfeit-refund]` | Delete the account. Shows what happens to the balance first; unused paid credit is refunded |
60
+
61
+ Run `lapsed <command> --help` for a command's usage.
62
+
63
+ ## Scripts and CI
64
+
65
+ - **`LAPSED_API_KEY`:** a key from `lapsed keys create`. It is used instead of the stored login.
66
+ - **`LAPSED_API_URL`:** overrides the API address (default `https://lapsed.dev`).
67
+ - **`--json`:** stdout carries exactly one JSON document. Errors go to stderr as `{"error":{"code":"…","message":"…"}}`.
68
+
69
+ `--json` output is stable and follows the same versioning policy as webhooks. It is the API's response body, unchanged, except for these commands:
70
+
71
+ | Command | `--json` output |
72
+ |---|---|
73
+ | `add` | `{"data":[{"input","ok":true,"created":bool,"watch":{…}} \| {"input","ok":false,"error":{"code","message"}} \| {"input","ok":false,"skipped":true}]}` |
74
+ | `remove` | `{"removed":["wch_…"]}` |
75
+ | `channels remove` | `{"removed":"chn_…"}` |
76
+ | `channels test` (no id) | `{"data":[{"channelId","deliveryId"}]}` |
77
+ | `keys revoke` | `{"revoked":"key_…"}` |
78
+ | `login` | `{"accountId","email"}` (the code and URL go to stderr) |
79
+ | `logout` | `{"loggedOut":true,"keyRevoked":bool}` |
80
+
81
+ ## Exit codes
82
+
83
+ | Code | Meaning |
84
+ |---|---|
85
+ | 0 | Success |
86
+ | 1 | Error: network, server error, or an unexpected response (check `LAPSED_API_URL`) |
87
+ | 2 | Usage: bad arguments, or the API refused the input (`400`) |
88
+ | 3 | Not logged in or not allowed (`401`, `403`), or the login was denied or expired |
89
+ | 4 | Not found (`404`), or no watch matches what you typed |
90
+ | 5 | Needs a top-up (`402`) or a higher watch limit (`403 watch_limit`) |
91
+ | 6 | Rate limited (`429`); the message says when to retry |
92
+ | 7 | `status --fail-on-problem` was given and a watch has an open problem |
93
+
94
+ `add` with several inputs continues past an invalid one and exits with the first failure's code. A `402`, `403` or `429` stops it, and the remaining inputs are reported as `skipped`.
95
+
96
+ ## Documentation
97
+
98
+ The [CLI reference](https://lapsed.dev/docs/cli/) lists every command and option. The [API reference](https://lapsed.dev/docs/) covers the API the CLI calls.
package/dist/lapsed.js ADDED
@@ -0,0 +1,1137 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/main.ts
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import { homedir, hostname } from "node:os";
7
+ import { createInterface } from "node:readline/promises";
8
+
9
+ // ../../../packages/core-cli/src/errors.ts
10
+ var EXIT = { ok: 0, error: 1, usage: 2, auth: 3, notFound: 4, payment: 5, rateLimited: 6, problem: 7 };
11
+ var CliError = class extends Error {
12
+ exitCode;
13
+ code;
14
+ constructor(exitCode, code, message) {
15
+ super(message);
16
+ this.exitCode = exitCode;
17
+ this.code = code;
18
+ this.name = "CliError";
19
+ }
20
+ };
21
+ function exitCodeFor(status2, code) {
22
+ if (status2 === 400) return EXIT.usage;
23
+ if (status2 === 401) return EXIT.auth;
24
+ if (status2 === 403) return code === "watch_limit" ? EXIT.payment : EXIT.auth;
25
+ if (status2 === 402) return EXIT.payment;
26
+ if (status2 === 404) return EXIT.notFound;
27
+ if (status2 === 429) return EXIT.rateLimited;
28
+ return EXIT.error;
29
+ }
30
+
31
+ // ../../../packages/core-cli/src/money.ts
32
+ var WHOLE = /^\$?(\d+)(?:\.(\d{1,6}))?$/;
33
+ var FRACTION_ONLY = /^\$?\.(\d{1,6})$/;
34
+ function parseUsd(input, opts = {}) {
35
+ const bad = () => new CliError(EXIT.usage, "bad_amount", `"${input}" is not a valid dollar amount. Use a value such as 5 or 4.50 (at most 6 decimal places).`);
36
+ const w = WHOLE.exec(input);
37
+ const f = w ? null : FRACTION_ONLY.exec(input);
38
+ if (!w && !f) throw bad();
39
+ const [whole, frac] = w ? [w[1], w[2] ?? ""] : ["0", f[1]];
40
+ const micros = Number(whole) * 1e6 + Number(frac.padEnd(6, "0"));
41
+ if (!Number.isSafeInteger(micros) || whole.length > 9) throw bad();
42
+ if (opts.positive && micros <= 0) throw new CliError(EXIT.usage, "bad_amount", "The amount must be more than $0.");
43
+ return micros;
44
+ }
45
+ function formatUsd(m) {
46
+ const cents = Math.trunc(Math.abs(m) / 1e4);
47
+ const sign = m < 0 && cents > 0 ? "-" : "";
48
+ return `${sign}$${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
49
+ }
50
+
51
+ // ../../../packages/core-cli/src/config.ts
52
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
53
+ import { posix, win32 } from "node:path";
54
+ function configDir(name, env, platform, home) {
55
+ if (platform === "win32") return win32.join(env.APPDATA || win32.join(home, "AppData", "Roaming"), name);
56
+ if (platform === "darwin") return posix.join(home, "Library", "Application Support", name);
57
+ return posix.join(env.XDG_CONFIG_HOME || posix.join(home, ".config"), name);
58
+ }
59
+ var ConfigStore = class {
60
+ file;
61
+ dir;
62
+ warn;
63
+ constructor(dir, warn = () => {
64
+ }) {
65
+ this.dir = dir;
66
+ this.warn = warn;
67
+ this.file = `${dir}/config.json`;
68
+ }
69
+ read() {
70
+ let text;
71
+ try {
72
+ text = readFileSync(this.file, "utf8");
73
+ } catch (e) {
74
+ if (e.code === "ENOENT") return {};
75
+ this.warn(`Could not read ${this.file}: ${e.message}`);
76
+ return {};
77
+ }
78
+ try {
79
+ const raw = JSON.parse(text);
80
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("not an object");
81
+ const key = raw.key;
82
+ return typeof key === "string" && key ? { key } : {};
83
+ } catch {
84
+ this.warn(`${this.file} is damaged; treating you as logged out. Run login again.`);
85
+ return {};
86
+ }
87
+ }
88
+ write(c) {
89
+ mkdirSync(this.dir, { recursive: true, mode: 448 });
90
+ chmodSync(this.dir, 448);
91
+ writeFileSync(this.file, `${JSON.stringify(c, null, 2)}
92
+ `, { mode: 384 });
93
+ chmodSync(this.file, 384);
94
+ }
95
+ clear() {
96
+ rmSync(this.file, { force: true });
97
+ }
98
+ };
99
+
100
+ // ../../../packages/core-cli/src/client.ts
101
+ var NEVER_SENT = /* @__PURE__ */ new Set(["UND_ERR_CONNECT_TIMEOUT", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH"]);
102
+ var ApiClient = class {
103
+ baseUrl;
104
+ o;
105
+ constructor(o) {
106
+ this.o = o;
107
+ this.baseUrl = o.baseUrl.replace(/\/+$/, "");
108
+ }
109
+ async request(method, path, opts = {}) {
110
+ const url = new URL(this.baseUrl + path);
111
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== void 0) url.searchParams.set(k, v);
112
+ const headers = { "user-agent": this.o.userAgent, accept: "application/json", ...opts.headers };
113
+ const auth = opts.auth ?? "key";
114
+ if (auth === "key" && this.o.key) headers.authorization = `Bearer ${this.o.key}`;
115
+ if (auth === "admin" && this.o.adminKey) headers["x-admin-key"] = this.o.adminKey;
116
+ if (opts.body !== void 0) headers["content-type"] = "application/json";
117
+ const init = { method, headers, ...opts.body !== void 0 ? { body: JSON.stringify(opts.body) } : {} };
118
+ let res;
119
+ try {
120
+ res = await this.o.fetch(url.toString(), init).catch((e) => {
121
+ if (NEVER_SENT.has(String(e.cause?.code))) return this.o.fetch(url.toString(), init);
122
+ throw e;
123
+ });
124
+ } catch (e) {
125
+ const cause = e.cause?.message ?? e.message;
126
+ throw new CliError(EXIT.error, "network", `Could not reach ${this.baseUrl}: ${cause}`);
127
+ }
128
+ const text = await res.text();
129
+ let body;
130
+ let parsed = true;
131
+ try {
132
+ body = text ? JSON.parse(text) : void 0;
133
+ } catch {
134
+ parsed = false;
135
+ }
136
+ const notApi = () => new CliError(EXIT.error, "bad_response", `HTTP ${res.status} from ${url.toString()}: not an API response. Check ${this.o.apiUrlEnv}.`);
137
+ if (res.ok) {
138
+ if (!parsed) throw notApi();
139
+ return { status: res.status, body };
140
+ }
141
+ const error = parsed ? body?.error : void 0;
142
+ if (!error || typeof error.code !== "string" || typeof error.message !== "string") throw notApi();
143
+ const retry = res.status === 429 ? res.headers.get("retry-after") : null;
144
+ throw new CliError(exitCodeFor(res.status, error.code), error.code, retry ? `${error.message} (retry in ${retry}s)` : error.message);
145
+ }
146
+ };
147
+
148
+ // ../../../packages/core-cli/src/output.ts
149
+ function table(rows) {
150
+ const widths = [];
151
+ for (const row of rows) row.forEach((cell, i) => {
152
+ widths[i] = Math.max(widths[i] ?? 0, cell.length);
153
+ });
154
+ return rows.map((row) => row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
155
+ }
156
+ function balanceBanner(product, status2, balanceMicros) {
157
+ if (status2 === "low") return `Balance is low (${formatUsd(balanceMicros)}). Top up: ${product.name} topup`;
158
+ if (status2 === "paused") return `Paid watches are paused until you top up: ${product.name} topup`;
159
+ return null;
160
+ }
161
+ function when(iso) {
162
+ return iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : "never";
163
+ }
164
+
165
+ // ../../../packages/core-cli/src/cli.ts
166
+ import { parseArgs } from "node:util";
167
+ var GLOBAL_HELP = [" --json Machine-readable output (stable; see README)", " -h, --help Help for a command", " -v, --version Print the version"];
168
+ function defineCli(product, commands) {
169
+ const P = product.envPrefix;
170
+ commands = commands.map((c) => ({ ...c, usage: c.usage.replaceAll("<name>", product.name) }));
171
+ const byName = new Map(commands.map((c) => [c.name, c]));
172
+ const listing = (cmds) => {
173
+ const width = Math.max(...cmds.map((c) => c.name.length));
174
+ return cmds.map((c) => ` ${c.name.padEnd(width)} ${c.summary}`).join("\n");
175
+ };
176
+ const mainHelp = () => [
177
+ `${product.displayName} CLI ${product.version}`,
178
+ "",
179
+ `Usage: ${product.name} <command> [options]`,
180
+ "",
181
+ "Commands:",
182
+ listing(commands.filter((c) => !c.hidden)),
183
+ "",
184
+ "Options:",
185
+ ...GLOBAL_HELP,
186
+ "",
187
+ `Environment: ${P}_API_KEY (use this key instead of login), ${P}_API_URL (default ${product.defaultApiUrl})`,
188
+ "",
189
+ "Examples:",
190
+ ...product.examples.map((e) => ` ${e}`)
191
+ ].join("\n");
192
+ return async (argv, ctx2) => {
193
+ const io = ctx2.io;
194
+ const dashdash = argv.indexOf("--");
195
+ const head = dashdash === -1 ? argv : argv.slice(0, dashdash);
196
+ const tail = dashdash === -1 ? [] : argv.slice(dashdash);
197
+ const json = head.includes("--json");
198
+ const help = head.includes("--help") || head.includes("-h");
199
+ const args = [...head.filter((a) => a !== "--json" && a !== "--help" && a !== "-h"), ...tail];
200
+ const fail = (e) => {
201
+ if (e instanceof CliError) {
202
+ io.err(json ? JSON.stringify({ error: { code: e.code, message: e.message } }) : e.message);
203
+ return e.exitCode;
204
+ }
205
+ const message = e instanceof Error ? e.message : String(e);
206
+ io.err(json ? JSON.stringify({ error: { code: "unexpected", message } }) : `Unexpected error: ${message}`);
207
+ return EXIT.error;
208
+ };
209
+ try {
210
+ if (args[0] === "--version" || args[0] === "-v") {
211
+ io.out(product.version);
212
+ return EXIT.ok;
213
+ }
214
+ if (args.length === 0 || args[0] === "help") {
215
+ const target = args[1] && (byName.get(`${args[1]} ${args[2] ?? ""}`.trim()) ?? byName.get(args[1]));
216
+ io.out(target ? `${target.summary}
217
+
218
+ Usage: ${target.usage}` : mainHelp());
219
+ return args.length === 0 && !help ? EXIT.usage : EXIT.ok;
220
+ }
221
+ let cmd;
222
+ let words = Math.min(3, args.length);
223
+ for (; words > 0 && !cmd; words--) cmd = byName.get(args.slice(0, words).join(" "));
224
+ const rest = args.slice(words + 1);
225
+ if (!cmd) {
226
+ const group = commands.filter((c) => c.name.startsWith(`${args[0]} `));
227
+ if (group.length > 0) {
228
+ io.out(`Usage: ${product.name} ${args[0]} <command>
229
+
230
+ Commands:
231
+ ${listing(group)}`);
232
+ return help ? EXIT.ok : EXIT.usage;
233
+ }
234
+ throw new CliError(EXIT.usage, "usage", `Unknown command "${args[0]}". See: ${product.name} help`);
235
+ }
236
+ if (help) {
237
+ io.out(`${cmd.summary}
238
+
239
+ Usage: ${cmd.usage}`);
240
+ return EXIT.ok;
241
+ }
242
+ let parsed;
243
+ try {
244
+ parsed = parseArgs({ args: rest, options: cmd.options ?? {}, allowPositionals: true, strict: true });
245
+ } catch (e) {
246
+ throw new CliError(EXIT.usage, "usage", `${e.message}
247
+ Usage: ${cmd.usage}`);
248
+ }
249
+ const config = new ConfigStore(configDir(product.name, ctx2.env, ctx2.platform, ctx2.homedir), (m) => io.err(m));
250
+ const envKey = ctx2.env[`${P}_API_KEY`];
251
+ const stored = config.read().key;
252
+ const key = envKey ? { value: envKey, source: "env" } : stored ? { value: stored, source: "config" } : null;
253
+ const apiUrl = (ctx2.env[`${P}_API_URL`] || product.defaultApiUrl).replace(/\/+$/, "");
254
+ if (!/^https?:\/\/[^/]+/.test(apiUrl)) throw new CliError(EXIT.usage, "usage", `${P}_API_URL must be an http(s) URL.`);
255
+ const base = { baseUrl: apiUrl, fetch: ctx2.fetch, userAgent: `${product.name}-cli/${product.version} node/${process.versions.node}`, apiUrlEnv: `${P}_API_URL` };
256
+ const rc = {
257
+ ...ctx2,
258
+ product,
259
+ json,
260
+ config,
261
+ apiUrl,
262
+ key,
263
+ client: (o = {}) => {
264
+ const k = o.key ?? key?.value;
265
+ if (!k && !o.anonymous) throw new CliError(EXIT.auth, "not_logged_in", `Not logged in. Run: ${product.name} login (or set ${P}_API_KEY).`);
266
+ return new ApiClient({ ...base, ...k ? { key: k } : {} });
267
+ },
268
+ adminClient: async () => {
269
+ let adminKey = ctx2.env[`${P}_ADMIN_KEY`];
270
+ if (!adminKey && io.isTty && !json) adminKey = (await ctx2.prompt("Admin key: ", { hidden: true })).trim();
271
+ if (!adminKey) throw new CliError(EXIT.usage, "no_admin_key", `Set ${P}_ADMIN_KEY.`);
272
+ return new ApiClient({ ...base, adminKey });
273
+ }
274
+ };
275
+ return await cmd.run(rc, parsed);
276
+ } catch (e) {
277
+ return fail(e);
278
+ }
279
+ };
280
+ }
281
+
282
+ // ../../../packages/core-cli/src/commands/auth.ts
283
+ var SLOW_DOWN_MS = 5e3;
284
+ async function login(c) {
285
+ const P = c.product.envPrefix;
286
+ const anon = c.client({ anonymous: true });
287
+ const { body: s } = await anon.request("POST", "/api/auth/device", { auth: "none", body: { label: c.hostname.slice(0, 40) || "unnamed device" } });
288
+ const say = (line) => c.json ? c.io.err(line) : c.io.out(line);
289
+ say(`Your code: ${s.user_code}`);
290
+ say(`Approve it at ${s.verification_uri}`);
291
+ if (c.io.isTty && !c.json) await c.openUrl(s.verification_uri);
292
+ say("Waiting for approval\u2026");
293
+ const deadline = c.now().getTime() + s.expires_in * 1e3;
294
+ let interval = s.interval * 1e3;
295
+ let networkErrors = 0;
296
+ let key = null;
297
+ while (key === null) {
298
+ if (c.now().getTime() + interval > deadline) throw new CliError(EXIT.auth, "expired_token", `The login request expired. Run: ${c.product.name} login`);
299
+ await c.sleep(interval);
300
+ try {
301
+ key = (await anon.request("POST", "/api/auth/device/token", { auth: "none", body: { device_code: s.device_code } })).body.key;
302
+ networkErrors = 0;
303
+ } catch (e) {
304
+ if (!(e instanceof CliError)) throw e;
305
+ if (e.code === "network" && ++networkErrors < 2) continue;
306
+ networkErrors = 0;
307
+ if (e.code === "authorization_pending") continue;
308
+ if (e.code === "slow_down") {
309
+ interval += SLOW_DOWN_MS;
310
+ continue;
311
+ }
312
+ if (e.code === "access_denied" || e.code === "expired_token") throw new CliError(EXIT.auth, e.code, e.message);
313
+ throw e;
314
+ }
315
+ }
316
+ c.config.write({ key });
317
+ if (c.env[`${P}_API_KEY`]) c.io.err(`${P}_API_KEY is set and will be used instead of this login.`);
318
+ const { body: me } = await c.client({ key }).request("GET", "/api/me");
319
+ if (c.json) c.io.out(JSON.stringify({ accountId: me.accountId, email: me.email }, null, 2));
320
+ else c.io.out(`Logged in as ${me.email ?? me.accountId}`);
321
+ return EXIT.ok;
322
+ }
323
+ async function logout(c) {
324
+ const P = c.product.envPrefix;
325
+ const envNote = () => {
326
+ if (c.env[`${P}_API_KEY`]) c.io.err(`${P}_API_KEY is still set; logout does not revoke or unset it.`);
327
+ };
328
+ const stored = c.config.read().key;
329
+ if (!stored) {
330
+ if (c.json) c.io.out(JSON.stringify({ loggedOut: true, keyRevoked: false }, null, 2));
331
+ else c.io.out("Not logged in.");
332
+ envNote();
333
+ return EXIT.ok;
334
+ }
335
+ const client = c.client({ key: stored });
336
+ let failure = null;
337
+ let revoked = false;
338
+ try {
339
+ const { body: me } = await client.request("GET", "/api/me");
340
+ if (me.keyId) {
341
+ await client.request("DELETE", `/api/keys/${encodeURIComponent(me.keyId)}`);
342
+ revoked = true;
343
+ }
344
+ } catch (e) {
345
+ if (!(e instanceof CliError)) throw e;
346
+ if (e.exitCode !== EXIT.auth) failure = e;
347
+ }
348
+ c.config.clear();
349
+ envNote();
350
+ if (failure) {
351
+ c.io.err(`Could not revoke the key on the server: ${failure.message} It was removed locally; revoke it with: ${c.product.name} keys revoke`);
352
+ if (c.json) c.io.out(JSON.stringify({ loggedOut: true, keyRevoked: false }, null, 2));
353
+ return EXIT.error;
354
+ }
355
+ if (c.json) c.io.out(JSON.stringify({ loggedOut: true, keyRevoked: revoked }, null, 2));
356
+ else c.io.out(revoked ? "Logged out. The key was revoked." : "Logged out.");
357
+ return EXIT.ok;
358
+ }
359
+ async function whoami(c) {
360
+ const { body: me } = await c.client().request("GET", "/api/me");
361
+ if (c.json) {
362
+ c.io.out(JSON.stringify(me, null, 2));
363
+ return EXIT.ok;
364
+ }
365
+ const lines = [
366
+ `${me.email ?? "(no email)"} (${me.accountId})`,
367
+ `Balance: ${formatUsd(me.balanceMicros)}${me.status === "ok" ? "" : ` (${me.status})`}`,
368
+ `Watches: ${me.watches.count} of ${me.watches.max} (${me.watches.free} free)`,
369
+ `Using: ${c.key?.source === "env" ? `${c.product.envPrefix}_API_KEY` : "the key from login"}`
370
+ ];
371
+ const banner = balanceBanner(c.product, me.status, me.balanceMicros);
372
+ if (banner) lines.push("", banner);
373
+ c.io.out(lines.join("\n"));
374
+ return EXIT.ok;
375
+ }
376
+ var authCommands = [
377
+ { name: "login", summary: "Log in through your browser", usage: "<name> login", run: login },
378
+ { name: "logout", summary: "Revoke and forget the key from login", usage: "<name> logout", run: logout },
379
+ { name: "whoami", summary: "Show your account, balance and watch count", usage: "<name> whoami", run: whoami }
380
+ ];
381
+
382
+ // ../../../packages/core-cli/src/commands/watches.ts
383
+ function resolveWatch(watches, arg, product) {
384
+ const byId = watches.find((w) => w.id === arg);
385
+ if (byId) return byId;
386
+ const matches = watches.filter((w) => w.input.toLowerCase() === arg.toLowerCase());
387
+ if (matches.length === 0) throw new CliError(EXIT.notFound, "not_found", `No watch matches "${arg}". See: ${product.name} list`);
388
+ if (matches.length > 1) {
389
+ throw new CliError(EXIT.usage, "ambiguous", `"${arg}" is watched more than once. Use its id: ${matches.map((w) => `${w.id} (${w.kind})`).join(", ")}`);
390
+ }
391
+ return matches[0];
392
+ }
393
+ var listWatches = async (c) => (await c.client().request("GET", "/api/watches")).body;
394
+ var MARK = { critical: "!!", warning: "!", info: "\xB7", resolved: "\u2713" };
395
+ function details(w, withMessages) {
396
+ const lines = [];
397
+ for (const p of w.problems) {
398
+ lines.push(` ${MARK[p.severity] ?? "!"} ${p.title}`);
399
+ if (withMessages) lines.push(` ${p.message}`);
400
+ }
401
+ if (withMessages) for (const n of w.notices) lines.push(` \xB7 ${n.message}`);
402
+ return lines;
403
+ }
404
+ async function add(c, a) {
405
+ const kind = a.values.kind;
406
+ if (kind !== void 0 && !c.product.watchKinds.includes(kind)) {
407
+ throw new CliError(EXIT.usage, "usage", `--kind must be one of: ${c.product.watchKinds.join(", ")}.`);
408
+ }
409
+ if (a.positionals.length === 0) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} add <input...> [--kind ${c.product.watchKinds.join("|")}]`);
410
+ const client = c.client();
411
+ const results = [];
412
+ let exit = EXIT.ok;
413
+ let stopped = false;
414
+ for (const input of a.positionals) {
415
+ if (stopped) {
416
+ results.push({ input, ok: false, skipped: true });
417
+ continue;
418
+ }
419
+ try {
420
+ const r = await client.request("POST", "/api/watches", { body: kind ? { input, kind } : { input } });
421
+ results.push({ input, ok: true, created: r.status === 201, watch: r.body });
422
+ } catch (e) {
423
+ if (!(e instanceof CliError)) throw e;
424
+ results.push({ input, ok: false, error: { code: e.code, message: e.message } });
425
+ if (exit === EXIT.ok) exit = e.exitCode;
426
+ if (e.exitCode === EXIT.payment || e.exitCode === EXIT.rateLimited || e.code === "network") stopped = true;
427
+ }
428
+ }
429
+ if (c.json) {
430
+ c.io.out(JSON.stringify({ data: results }, null, 2));
431
+ return exit;
432
+ }
433
+ const lines = [];
434
+ for (const r of results) {
435
+ if (r.ok) lines.push(`${r.input}: ${r.created ? "added" : "already watched"} (${r.watch.status}, ${r.watch.billing})`, ...details(r.watch, true));
436
+ else if ("skipped" in r) lines.push(`${r.input}: skipped`);
437
+ else lines.push(`${r.input}: ${r.error.message}`);
438
+ }
439
+ c.io.out(lines.join("\n"));
440
+ return exit;
441
+ }
442
+ async function remove(c, a) {
443
+ if (a.positionals.length === 0) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} remove <watch...>`);
444
+ const { data } = await listWatches(c);
445
+ const targets = a.positionals.map((arg) => resolveWatch(data, arg, c.product));
446
+ const client = c.client();
447
+ for (const w of targets) await client.request("DELETE", `/api/watches/${encodeURIComponent(w.id)}`);
448
+ if (c.json) c.io.out(JSON.stringify({ removed: targets.map((w) => w.id) }, null, 2));
449
+ else c.io.out(targets.map((w) => `Removed ${w.input}`).join("\n"));
450
+ return EXIT.ok;
451
+ }
452
+ async function list(c) {
453
+ const client = c.client();
454
+ const [body, me] = await Promise.all([
455
+ client.request("GET", "/api/watches").then((r) => r.body),
456
+ client.request("GET", "/api/me").then((r) => r.body)
457
+ ]);
458
+ if (c.json) {
459
+ c.io.out(JSON.stringify(body, null, 2));
460
+ return EXIT.ok;
461
+ }
462
+ const lines = body.data.length === 0 ? [`No watches yet. Add one: ${c.product.name} add ${c.product.watchKinds[0] === "host" ? "example.com" : "<input>"}`] : [table([["WATCH", "KIND", "STATUS", "BILLING", "LAST CHECKED"], ...body.data.map((w) => [w.input, w.kind, w.status, w.billing + (w.pinnedFree ? " (pinned)" : ""), when(w.lastCheckedAt)])])];
463
+ const banner = balanceBanner(c.product, me.status, me.balanceMicros);
464
+ if (banner) lines.push("", banner);
465
+ c.io.out(lines.join("\n"));
466
+ return EXIT.ok;
467
+ }
468
+ async function status(c, a) {
469
+ const body = await listWatches(c);
470
+ const exit = (ws) => a.values["fail-on-problem"] && ws.some((w2) => w2.status === "problem") ? EXIT.problem : EXIT.ok;
471
+ if (!a.positionals[0]) {
472
+ if (c.json) {
473
+ c.io.out(JSON.stringify(body, null, 2));
474
+ return exit(body.data);
475
+ }
476
+ const lines = body.data.flatMap((w2) => [`${w2.input} ${w2.status}`, ...details(w2, false)]);
477
+ c.io.out(lines.length ? lines.join("\n") : "No watches yet.");
478
+ return exit(body.data);
479
+ }
480
+ const found = resolveWatch(body.data, a.positionals[0], c.product);
481
+ const { body: w } = await c.client().request("GET", `/api/watches/${encodeURIComponent(found.id)}`);
482
+ if (c.json) {
483
+ c.io.out(JSON.stringify(w, null, 2));
484
+ return exit([w]);
485
+ }
486
+ c.io.out([
487
+ `${w.input} (${w.kind}, ${w.id})`,
488
+ `Status: ${w.status} Billing: ${w.billing}${w.pinnedFree ? " (pinned)" : ""} Last checked: ${when(w.lastCheckedAt)}`,
489
+ ...w.optionalEvents.length ? [`Optional events: ${w.optionalEvents.join(", ")}`] : [],
490
+ ...details(w, true)
491
+ ].join("\n"));
492
+ return exit([w]);
493
+ }
494
+ async function patch(c, arg, change, done) {
495
+ if (!arg) throw new CliError(EXIT.usage, "usage", "Specify a watch by its input or id.");
496
+ const { data } = await listWatches(c);
497
+ const w = resolveWatch(data, arg, c.product);
498
+ const { body } = await c.client().request("PATCH", `/api/watches/${encodeURIComponent(w.id)}`, { body: change(w) });
499
+ c.io.out(c.json ? JSON.stringify(body, null, 2) : done(body));
500
+ return EXIT.ok;
501
+ }
502
+ var events = (a, verb) => {
503
+ const list2 = a.positionals.slice(1);
504
+ if (list2.length === 0) throw new CliError(EXIT.usage, "usage", `Name at least one event to ${verb}.`);
505
+ return list2;
506
+ };
507
+ var watchCommands = [
508
+ { name: "add", summary: "Watch one or more targets (each is checked immediately)", usage: "<name> add <input...> [--kind <kind>]", options: { kind: { type: "string" } }, run: add },
509
+ { name: "remove", summary: "Stop watching", usage: "<name> remove <watch...>", run: remove },
510
+ { name: "list", summary: "List your watches", usage: "<name> list", run: list },
511
+ { name: "status", summary: "Open problems, for all watches or one", usage: "<name> status [watch] [--fail-on-problem]", options: { "fail-on-problem": { type: "boolean" } }, run: status },
512
+ { name: "pin", summary: "Count a watch as one of your free watches", usage: "<name> pin <watch>", run: (c, a) => patch(c, a.positionals[0], () => ({ pinnedFree: true }), (w) => `Pinned ${w.input} (${w.billing}).`) },
513
+ { name: "unpin", summary: "Unpin a watch", usage: "<name> unpin <watch>", run: (c, a) => patch(c, a.positionals[0], () => ({ pinnedFree: false }), (w) => `Unpinned ${w.input} (${w.billing}).`) },
514
+ {
515
+ name: "optin",
516
+ summary: "Turn on events that are off by default",
517
+ usage: "<name> optin <watch> <event...>",
518
+ run: (c, a) => {
519
+ const add2 = events(a, "turn on");
520
+ return patch(c, a.positionals[0], (w) => ({ optionalEvents: [.../* @__PURE__ */ new Set([...w.optionalEvents, ...add2])] }), (w) => `${w.input}: optional events ${w.optionalEvents.join(", ") || "none"}.`);
521
+ }
522
+ },
523
+ {
524
+ name: "optout",
525
+ summary: "Turn off optional events",
526
+ usage: "<name> optout <watch> <event...>",
527
+ run: (c, a) => {
528
+ const drop = new Set(events(a, "turn off"));
529
+ return patch(c, a.positionals[0], (w) => ({ optionalEvents: w.optionalEvents.filter((e) => !drop.has(e)) }), (w) => `${w.input}: optional events ${w.optionalEvents.join(", ") || "none"}.`);
530
+ }
531
+ }
532
+ ];
533
+
534
+ // ../../../packages/core-cli/src/args.ts
535
+ function limitArg(values, fallback = 20) {
536
+ const raw = values.limit;
537
+ if (raw === void 0) return String(fallback);
538
+ const n = Number(raw);
539
+ if (typeof raw !== "string" || !/^\d+$/.test(raw) || n < 1 || n > 100) throw new CliError(EXIT.usage, "usage", "--limit must be a whole number from 1 to 100.");
540
+ return raw;
541
+ }
542
+ function required(positionals, what, usage) {
543
+ const v = positionals[0];
544
+ if (!v) throw new CliError(EXIT.usage, "usage", `Missing ${what}.
545
+ Usage: ${usage}`);
546
+ return v;
547
+ }
548
+ var pageOptions = { limit: { type: "string" }, cursor: { type: "string" } };
549
+
550
+ // ../../../packages/core-cli/src/commands/channels.ts
551
+ var enc = encodeURIComponent;
552
+ var SAVE_SECRET = "This secret is shown only once. Use it to verify webhook signatures (Standard Webhooks).";
553
+ function showWithSecret(c, ch, verb) {
554
+ if (c.json) {
555
+ c.io.out(JSON.stringify(ch, null, 2));
556
+ return EXIT.ok;
557
+ }
558
+ const lines = [`${verb} ${ch.kind} channel ${ch.id} \u2192 ${ch.target} (${ch.status})`];
559
+ if (ch.secret) lines.push(`Signing secret: ${ch.secret}`, SAVE_SECRET);
560
+ if (ch.status === "pending") lines.push(`Confirmation sent to ${ch.target}. The channel receives alerts after the link in that email is opened.`);
561
+ c.io.out(lines.join("\n"));
562
+ return EXIT.ok;
563
+ }
564
+ async function test(c, id) {
565
+ const client = c.client();
566
+ const name = c.product.name;
567
+ if (id) {
568
+ const { body: body2 } = await client.request("POST", `/api/channels/${enc(id)}/test`);
569
+ c.io.out(c.json ? JSON.stringify(body2, null, 2) : `Test sent to ${id}. View it with: ${name} deliveries ${body2.deliveryId}`);
570
+ return EXIT.ok;
571
+ }
572
+ const { body } = await client.request("GET", "/api/channels");
573
+ const targets = body.data.filter((ch) => ch.status === "active" || ch.status === "failing");
574
+ if (targets.length === 0) throw new CliError(EXIT.notFound, "not_found", `No channels to test. Add one: ${name} channels add <email or URL>`);
575
+ const sent = [];
576
+ for (const ch of targets) {
577
+ const r = await client.request("POST", `/api/channels/${enc(ch.id)}/test`);
578
+ sent.push({ channelId: ch.id, deliveryId: r.body.deliveryId });
579
+ }
580
+ if (c.json) c.io.out(JSON.stringify({ data: sent }, null, 2));
581
+ else c.io.out([...sent.map((s) => `Test sent to ${s.channelId}: ${name} deliveries ${s.deliveryId}`), `View them with: ${name} deliveries`].join("\n"));
582
+ return EXIT.ok;
583
+ }
584
+ var channelCommands = [
585
+ {
586
+ name: "channels add",
587
+ summary: "Add an email address, webhook, Slack or Discord URL",
588
+ usage: "<name> channels add <email or https URL>",
589
+ run: async (c, a) => {
590
+ const target = required(a.positionals, "the email address or URL", `${c.product.name} channels add <email or https URL>`);
591
+ const { body } = await c.client().request("POST", "/api/channels", { body: { target } });
592
+ return showWithSecret(c, body, "Added");
593
+ }
594
+ },
595
+ {
596
+ name: "channels list",
597
+ summary: "List your channels",
598
+ usage: "<name> channels list",
599
+ run: async (c) => {
600
+ const { body } = await c.client().request("GET", "/api/channels");
601
+ if (c.json) c.io.out(JSON.stringify(body, null, 2));
602
+ else c.io.out(body.data.length ? table([["ID", "KIND", "TARGET", "STATUS"], ...body.data.map((ch) => [ch.id, ch.kind, ch.target, ch.status === "disabled" && ch.disabledReason ? `disabled (${ch.disabledReason})` : ch.status])]) : `No channels yet. Add one: ${c.product.name} channels add <email or URL>`);
603
+ return EXIT.ok;
604
+ }
605
+ },
606
+ {
607
+ name: "channels remove",
608
+ summary: "Remove a channel",
609
+ usage: "<name> channels remove <channel-id>",
610
+ run: async (c, a) => {
611
+ const id = required(a.positionals, "the channel id", `${c.product.name} channels remove <channel-id>`);
612
+ await c.client().request("DELETE", `/api/channels/${enc(id)}`);
613
+ c.io.out(c.json ? JSON.stringify({ removed: id }, null, 2) : `Removed ${id}`);
614
+ return EXIT.ok;
615
+ }
616
+ },
617
+ {
618
+ name: "channels resend",
619
+ summary: "Resend the confirmation email for a pending email channel",
620
+ usage: "<name> channels resend <channel-id>",
621
+ run: async (c, a) => {
622
+ const id = required(a.positionals, "the channel id", `${c.product.name} channels resend <channel-id>`);
623
+ const { body } = await c.client().request("POST", `/api/channels/${enc(id)}/resend-confirmation`);
624
+ c.io.out(c.json ? JSON.stringify(body, null, 2) : "Confirmation sent.");
625
+ return EXIT.ok;
626
+ }
627
+ },
628
+ { name: "channels test", summary: "Send a test alert (to one channel, or all)", usage: "<name> channels test [channel-id]", run: (c, a) => test(c, a.positionals[0]) },
629
+ {
630
+ name: "channels rotate-secret",
631
+ summary: "Replace a webhook's signing secret",
632
+ usage: "<name> channels rotate-secret <channel-id>",
633
+ run: async (c, a) => {
634
+ const id = required(a.positionals, "the channel id", `${c.product.name} channels rotate-secret <channel-id>`);
635
+ const { body } = await c.client().request("POST", `/api/channels/${enc(id)}/rotate-secret`);
636
+ return showWithSecret(c, body, "Rotated");
637
+ }
638
+ }
639
+ ];
640
+
641
+ // ../../../packages/core-cli/src/commands/activity.ts
642
+ var enc2 = encodeURIComponent;
643
+ var oneLine = (s, max = 120) => {
644
+ const t = s.replace(/\s+/g, " ").trim();
645
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
646
+ };
647
+ function printPage(c, page, rows, empty, more) {
648
+ if (c.json) {
649
+ c.io.out(JSON.stringify(page, null, 2));
650
+ return EXIT.ok;
651
+ }
652
+ const lines = page.data.length ? [table(rows())] : [empty];
653
+ if (page.nextCursor) lines.push("", `More: ${more(page.nextCursor)}`);
654
+ c.io.out(lines.join("\n"));
655
+ return EXIT.ok;
656
+ }
657
+ async function deliveryDetail(c, id) {
658
+ const { body: d } = await c.client().request("GET", `/api/deliveries/${enc2(id)}`);
659
+ if (c.json) {
660
+ c.io.out(JSON.stringify(d, null, 2));
661
+ return EXIT.ok;
662
+ }
663
+ const lines = [
664
+ `${d.id}: ${d.eventType} \u2192 ${d.channelId}`,
665
+ `State: ${d.state}, ${d.attempts} attempt${d.attempts === 1 ? "" : "s"}${d.nextAttemptAt ? `, next ${when(d.nextAttemptAt)}` : ""}`,
666
+ ...d.attemptLog.map((a) => ` #${a.attempt} ${when(a.at)} ${a.status ?? a.error ?? "no response"} ${a.latencyMs}ms${a.responseSnippet ? ` ${oneLine(a.responseSnippet)}` : ""}`)
667
+ ];
668
+ if (d.state === "failed") lines.push("", `Resend it with: ${c.product.name} resend ${d.id}`);
669
+ c.io.out(lines.join("\n"));
670
+ return EXIT.ok;
671
+ }
672
+ var activityCommands = [
673
+ {
674
+ name: "events",
675
+ summary: "Recent events (state changes) across your watches",
676
+ usage: "<name> events [--limit N] [--cursor C]",
677
+ options: pageOptions,
678
+ run: async (c, a) => {
679
+ const { body } = await c.client().request("GET", "/api/events", { query: { limit: limitArg(a.values), cursor: a.values.cursor } });
680
+ return printPage(
681
+ c,
682
+ body,
683
+ () => [["WHEN", "EVENT", "TARGET"], ...body.data.map((e) => [when(e.createdAt), e.type + (e.alreadyOpen ? " (catch-up)" : ""), e.target])],
684
+ "No events yet.",
685
+ (cur) => `${c.product.name} events --cursor ${cur}`
686
+ );
687
+ }
688
+ },
689
+ {
690
+ name: "deliveries",
691
+ summary: "The delivery log (or one delivery with its attempts)",
692
+ usage: "<name> deliveries [delivery-id] [--failed] [--limit N] [--cursor C]",
693
+ options: { ...pageOptions, failed: { type: "boolean" } },
694
+ run: async (c, a) => {
695
+ if (a.positionals[0]) return deliveryDetail(c, a.positionals[0]);
696
+ const failed = a.values.failed === true;
697
+ const { body } = await c.client().request("GET", "/api/deliveries", {
698
+ query: { limit: limitArg(a.values), cursor: a.values.cursor, failed: failed ? "true" : void 0 }
699
+ });
700
+ return printPage(
701
+ c,
702
+ body,
703
+ () => [["WHEN", "EVENT", "CHANNEL", "STATE", "ATTEMPTS", "ID"], ...body.data.map((d) => [when(d.createdAt), d.eventType, d.channelId, d.state, String(d.attempts), d.id])],
704
+ failed ? "No failed deliveries." : "No deliveries yet.",
705
+ (cur) => `${c.product.name} deliveries${failed ? " --failed" : ""} --cursor ${cur}`
706
+ );
707
+ }
708
+ },
709
+ {
710
+ name: "resend",
711
+ summary: "Send a delivery again",
712
+ usage: "<name> resend <delivery-id>",
713
+ run: async (c, a) => {
714
+ const id = required(a.positionals, "the delivery id", `${c.product.name} resend <delivery-id>`);
715
+ const { body } = await c.client().request("POST", `/api/deliveries/${enc2(id)}/resend`);
716
+ c.io.out(c.json ? JSON.stringify(body, null, 2) : `Queued. View it with: ${c.product.name} deliveries ${body.deliveryId}`);
717
+ return EXIT.ok;
718
+ }
719
+ }
720
+ ];
721
+
722
+ // ../../../packages/core-cli/src/commands/account.ts
723
+ var count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
724
+ var same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
725
+ function money(p) {
726
+ if (p.balanceMicros < 0) return [`Your balance of ${formatUsd(p.balanceMicros)} will be written off.`];
727
+ if (p.balanceMicros === 0) return [];
728
+ const balance = `Your balance is ${formatUsd(p.balanceMicros)}.`;
729
+ if (p.refundableMicros <= 0) return [`${balance} It is not refundable and will be forfeited.`];
730
+ if (p.refundMode === "review") {
731
+ return [balance, `Refunds for this email address are reviewed by support. Request a refund with "refund" before deleting the account; otherwise ${formatUsd(p.forfeitedMicros)} is forfeited.`];
732
+ }
733
+ return [
734
+ balance,
735
+ `${formatUsd(p.refundableMicros)} of unused paid credit will be refunded to your original payment method.`,
736
+ ...p.forfeitedMicros >= 1e4 ? [`The other ${formatUsd(p.forfeitedMicros)} is not refundable and will be forfeited.`] : []
737
+ ];
738
+ }
739
+ async function confirmation(c, p, given) {
740
+ const fail = () => new CliError(EXIT.usage, "usage", "The confirmation text does not match. The account was not deleted.");
741
+ if (typeof given === "string") {
742
+ if (!same(given, p.confirm)) throw fail();
743
+ return given.trim();
744
+ }
745
+ if (!c.io.isTty || c.json) throw new CliError(EXIT.usage, "usage", `Add --confirm ${p.confirm} to delete without a prompt.`);
746
+ const typed = await c.prompt(`Type ${p.confirm} to delete this account: `);
747
+ if (!same(typed, p.confirm)) throw fail();
748
+ return p.confirm;
749
+ }
750
+ var accountCommands = [
751
+ {
752
+ name: "account delete",
753
+ summary: "Delete your account (shows what happens to your balance first)",
754
+ usage: "<name> account delete [--confirm <email>] [--forfeit-refund]",
755
+ options: { confirm: { type: "string" }, "forfeit-refund": { type: "boolean" } },
756
+ run: async (c, a) => {
757
+ const client = c.client();
758
+ const { body: p } = await client.request("GET", "/api/account/deletion");
759
+ if (!c.json) {
760
+ c.io.out([
761
+ `${count(p.watches, "watch", "watches")}, ${count(p.channels, "channel")}, ${count(p.keys, "API key")}, and your delivery history will be removed. Alerts stop immediately. This cannot be undone.`,
762
+ ...money(p)
763
+ ].join("\n"));
764
+ }
765
+ const forfeit = a.values["forfeit-refund"] === true;
766
+ if (p.refundMode === "review" && p.refundableMicros > 0 && !forfeit) {
767
+ throw new CliError(EXIT.usage, "refund_needs_review", `Request a refund first with: ${c.product.name} refund. To delete and forfeit the refundable credit, add --forfeit-refund.`);
768
+ }
769
+ const confirm = await confirmation(c, p, a.values.confirm);
770
+ const { body } = await client.request("POST", "/api/account/delete", { body: forfeit ? { confirm, forfeitRefund: true } : { confirm } });
771
+ if (c.key?.source === "config") c.config.clear();
772
+ if (c.json) {
773
+ c.io.out(JSON.stringify(body, null, 2));
774
+ } else {
775
+ c.io.out(["Account deleted.", ...body.refundedMicros > 0 ? [`${formatUsd(body.refundedMicros)} is being refunded to your original payment method.`] : []].join(" "));
776
+ }
777
+ const P = c.product.envPrefix;
778
+ if (c.env[`${P}_API_KEY`]) c.io.err(`${P}_API_KEY no longer works and can be unset.`);
779
+ return EXIT.ok;
780
+ }
781
+ }
782
+ ];
783
+
784
+ // ../../../packages/core-cli/src/commands/keys.ts
785
+ var keyCommands = [
786
+ {
787
+ name: "keys create",
788
+ summary: "Create an API key for scripts and CI",
789
+ usage: "<name> keys create <name>",
790
+ run: async (c, a) => {
791
+ const name = a.positionals.join(" ").trim();
792
+ if (!name) throw new CliError(EXIT.usage, "usage", `Missing the key's name.
793
+ Usage: ${c.product.name} keys create <name>`);
794
+ const { body } = await c.client().request("POST", "/api/keys", { body: { name } });
795
+ if (c.json) c.io.out(JSON.stringify(body, null, 2));
796
+ else c.io.out([`Created key ${body.keyId} (${body.name}):`, ` ${body.key}`, `This key is shown only once. Set it as ${c.product.envPrefix}_API_KEY.`].join("\n"));
797
+ return EXIT.ok;
798
+ }
799
+ },
800
+ {
801
+ name: "keys list",
802
+ summary: "List your API keys (never their secrets)",
803
+ usage: "<name> keys list",
804
+ run: async (c) => {
805
+ const { body } = await c.client().request("GET", "/api/keys");
806
+ if (c.json) c.io.out(JSON.stringify(body, null, 2));
807
+ else c.io.out(body.data.length ? table([["ID", "NAME", "KEY", "CREATED"], ...body.data.map((k) => [k.keyId, k.name, k.display, when(k.createdAt)])]) : "No API keys.");
808
+ return EXIT.ok;
809
+ }
810
+ },
811
+ {
812
+ name: "keys revoke",
813
+ summary: "Revoke an API key",
814
+ usage: "<name> keys revoke <key-id>",
815
+ run: async (c, a) => {
816
+ const id = required(a.positionals, "the key id", `${c.product.name} keys revoke <key-id>`);
817
+ await c.client().request("DELETE", `/api/keys/${encodeURIComponent(id)}`);
818
+ c.io.out(c.json ? JSON.stringify({ revoked: id }, null, 2) : `Revoked ${id}`);
819
+ return EXIT.ok;
820
+ }
821
+ }
822
+ ];
823
+
824
+ // ../../../packages/core-cli/src/commands/billing.ts
825
+ var PACKS = ["5", "20"];
826
+ var SHOWN_ENTRIES = 10;
827
+ var billingCommands = [
828
+ {
829
+ name: "balance",
830
+ summary: "Balance, refundable credit and recent activity",
831
+ usage: "<name> balance",
832
+ run: async (c) => {
833
+ const { body: b } = await c.client().request("GET", "/api/billing");
834
+ if (c.json) {
835
+ c.io.out(JSON.stringify(b, null, 2));
836
+ return EXIT.ok;
837
+ }
838
+ const lines = [
839
+ `Balance: ${formatUsd(b.balanceMicros)}${b.status === "ok" ? "" : ` (${b.status})`}`,
840
+ `Refundable: ${formatUsd(b.refundableMicros)} (unused paid credit)`,
841
+ ...b.refundMode === "review" ? ["Refunds: reviewed by support"] : [],
842
+ `Automatic top-up: ${b.autoTopUp === "coming_soon" ? "not yet available" : b.autoTopUp}`
843
+ ];
844
+ if (b.entries.length) {
845
+ lines.push("", "Recent activity:", table([["WHEN", "TYPE", "AMOUNT", "REASON"], ...b.entries.slice(0, SHOWN_ENTRIES).map((e) => [when(e.createdAt), e.type, formatUsd(e.amountMicros), e.reason ?? ""])]));
846
+ }
847
+ const banner = balanceBanner(c.product, b.status, b.balanceMicros);
848
+ if (banner) lines.push("", banner);
849
+ c.io.out(lines.join("\n"));
850
+ return EXIT.ok;
851
+ }
852
+ },
853
+ {
854
+ name: "refund",
855
+ summary: "Refund unused paid credit to your payment method",
856
+ usage: "<name> refund [--yes]",
857
+ options: { yes: { type: "boolean" } },
858
+ run: async (c, a) => {
859
+ const client = c.client();
860
+ const { body: b } = await client.request("GET", "/api/billing");
861
+ if (b.refundableMicros <= 0) throw new CliError(EXIT.notFound, "nothing_refundable", "No credit is refundable. Unused paid credit is refundable; used credit and granted credit are not.");
862
+ if (a.values.yes !== true) {
863
+ if (!c.io.isTty || c.json) throw new CliError(EXIT.usage, "usage", "Add --yes to refund without a prompt.");
864
+ const question = b.refundMode === "review" ? `Refunds for this email address are reviewed by support. Request a review of a ${formatUsd(b.refundableMicros)} refund? [y/N] ` : `Refund ${formatUsd(b.refundableMicros)} to your original payment method? [y/N] `;
865
+ const answer = (await c.prompt(question)).trim().toLowerCase();
866
+ if (answer !== "y" && answer !== "yes") {
867
+ c.io.out("Cancelled.");
868
+ return EXIT.ok;
869
+ }
870
+ }
871
+ const { body } = await client.request("POST", "/api/billing/refund", { body: {} });
872
+ if (c.json) {
873
+ c.io.out(JSON.stringify(body, null, 2));
874
+ return EXIT.ok;
875
+ }
876
+ c.io.out(body.status === "refunded" ? `Refunded ${formatUsd(body.refundedMicros)} to your original payment method. Refunds can take a few days to appear.` : "Refund request recorded. Refunds for this email address are reviewed by support; the result is sent by email.");
877
+ return EXIT.ok;
878
+ }
879
+ },
880
+ {
881
+ name: "topup",
882
+ summary: "Buy credit: opens a checkout ($5 or $20)",
883
+ usage: "<name> topup [5|20]",
884
+ run: async (c, a) => {
885
+ const pack = a.positionals[0] ?? "5";
886
+ if (!PACKS.includes(pack)) throw new CliError(EXIT.usage, "usage", `Choose a pack: ${PACKS.join(" or ")} (dollars).`);
887
+ const { body } = await c.client().request("POST", "/api/billing/checkout", { body: { pack } });
888
+ if (c.json) {
889
+ c.io.out(JSON.stringify(body, null, 2));
890
+ return EXIT.ok;
891
+ }
892
+ c.io.out(`Checkout for the $${pack} pack: ${body.url}`);
893
+ if (c.io.isTty) await c.openUrl(body.url);
894
+ return EXIT.ok;
895
+ }
896
+ }
897
+ ];
898
+
899
+ // ../../../packages/core-cli/src/commands/admin.ts
900
+ var enc3 = encodeURIComponent;
901
+ var GRANT_ATTEMPTS = 3;
902
+ var adminCommands = [
903
+ {
904
+ name: "admin grant",
905
+ summary: "Give an account free credit",
906
+ usage: "<name> admin grant <account> <amount> <reason...> [--idempotency-key K]",
907
+ hidden: true,
908
+ options: { "idempotency-key": { type: "string" } },
909
+ run: async (c, a) => {
910
+ const [account, amount, ...words] = a.positionals;
911
+ const reason = words.join(" ").trim();
912
+ if (!account || !amount || !reason) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} admin grant <account> <amount> <reason...>`);
913
+ const amountMicros = parseUsd(amount, { positive: true });
914
+ const client = await c.adminClient();
915
+ const key = a.values["idempotency-key"] ?? `grant-${c.randomId()}`;
916
+ let r;
917
+ for (let attempt = 1; !r; attempt++) {
918
+ try {
919
+ r = await client.request("POST", `/api/admin/accounts/${enc3(account)}/grant`, { auth: "admin", headers: { "idempotency-key": key }, body: { amountMicros, reason } });
920
+ } catch (e) {
921
+ if (!(e instanceof CliError) || e.exitCode !== EXIT.error || attempt >= GRANT_ATTEMPTS) throw e;
922
+ await c.sleep(1e3 * attempt);
923
+ }
924
+ }
925
+ if (c.json) c.io.out(JSON.stringify(r.body, null, 2));
926
+ else c.io.out(`Granted ${formatUsd(amountMicros)} to ${account}. Balance: ${formatUsd(r.body.balanceMicros)}${r.body.replayed ? " (already applied)" : ""}`);
927
+ return EXIT.ok;
928
+ }
929
+ },
930
+ {
931
+ name: "admin refund",
932
+ summary: "Refund an account's unused paid credit (no self-serve limit)",
933
+ usage: "<name> admin refund <account> [amount] [--yes]",
934
+ hidden: true,
935
+ options: { yes: { type: "boolean" } },
936
+ run: async (c, a) => {
937
+ const [account, amount] = a.positionals;
938
+ if (!account) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} admin refund <account> [amount] [--yes]`);
939
+ const client = await c.adminClient();
940
+ const path = `/api/admin/accounts/${enc3(account)}/refund`;
941
+ const { body: preview } = await client.request("GET", path, { auth: "admin" });
942
+ if (preview.refundableMicros <= 0) {
943
+ c.io.out(c.json ? JSON.stringify(preview, null, 2) : `Nothing to refund for ${account}.`);
944
+ return EXIT.ok;
945
+ }
946
+ const want = amount === void 0 ? preview.refundableMicros : parseUsd(amount, { positive: true });
947
+ if (want > preview.refundableMicros) throw new CliError(EXIT.usage, "too_much", `At most ${formatUsd(preview.refundableMicros)} is refundable.`);
948
+ if (!c.json) c.io.out(`Refundable: ${formatUsd(preview.refundableMicros)}`);
949
+ if (a.values.yes !== true) {
950
+ if (!c.io.isTty || c.json) throw new CliError(EXIT.usage, "usage", "Add --yes to refund without a prompt.");
951
+ const answer = (await c.prompt(`Refund ${formatUsd(want)} to ${account}? [y/N] `)).trim().toLowerCase();
952
+ if (answer !== "y" && answer !== "yes") {
953
+ c.io.out("Cancelled.");
954
+ return EXIT.ok;
955
+ }
956
+ }
957
+ const { body } = await client.request("POST", path, { auth: "admin", body: amount === void 0 ? {} : { amountMicros: want } });
958
+ if (c.json) {
959
+ c.io.out(JSON.stringify(body, null, 2));
960
+ return EXIT.ok;
961
+ }
962
+ c.io.out([
963
+ `Refunded ${formatUsd(body.refundedMicros)} to ${account} (${body.refunds.map((r) => `${r.refundId} for ${r.orderId}`).join(", ") || "no refunds"}).`,
964
+ ...body.refusedMicros > 0 ? [`Polar refused ${formatUsd(body.refusedMicros)}; it stays on the account as credit.`] : []
965
+ ].join("\n"));
966
+ return EXIT.ok;
967
+ }
968
+ },
969
+ {
970
+ name: "admin refunds pending",
971
+ summary: "Refund requests waiting for review",
972
+ usage: "<name> admin refunds pending",
973
+ hidden: true,
974
+ run: async (c) => {
975
+ const client = await c.adminClient();
976
+ const { body } = await client.request("GET", "/api/admin/refund-reviews", { auth: "admin" });
977
+ if (c.json) {
978
+ c.io.out(JSON.stringify(body, null, 2));
979
+ return EXIT.ok;
980
+ }
981
+ c.io.out(body.data.length ? table([["ACCOUNT", "EMAIL", "REFUNDABLE", "REQUESTED"], ...body.data.map((r) => [r.accountId, r.email, formatUsd(r.refundableMicros), r.requestedAt])]) : "No refund requests are waiting for review.");
982
+ return EXIT.ok;
983
+ }
984
+ },
985
+ {
986
+ name: "admin cap",
987
+ summary: "Set an account's watch limit",
988
+ usage: "<name> admin cap <account> <n>",
989
+ hidden: true,
990
+ run: async (c, a) => {
991
+ const [account, n] = a.positionals;
992
+ if (!account || !n || !/^\d+$/.test(n) || Number(n) < 1) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} admin cap <account> <n> (n a whole number, at least 1)`);
993
+ const client = await c.adminClient();
994
+ const { body } = await client.request("POST", `/api/admin/accounts/${enc3(account)}/cap`, { auth: "admin", body: { maxWatches: Number(n) } });
995
+ c.io.out(c.json ? JSON.stringify(body, null, 2) : `${account} can now have ${body.maxWatches} watches.`);
996
+ return EXIT.ok;
997
+ }
998
+ },
999
+ {
1000
+ name: "admin account show",
1001
+ summary: "Look up an account by email",
1002
+ usage: "<name> admin account show <email>",
1003
+ hidden: true,
1004
+ run: async (c, a) => {
1005
+ const email = a.positionals[0];
1006
+ if (!email) throw new CliError(EXIT.usage, "usage", `Usage: ${c.product.name} admin account show <email>`);
1007
+ const client = await c.adminClient();
1008
+ const { body: s } = await client.request(
1009
+ "GET",
1010
+ "/api/admin/accounts",
1011
+ { auth: "admin", query: { email } }
1012
+ );
1013
+ if (c.json) {
1014
+ c.io.out(JSON.stringify(s, null, 2));
1015
+ return EXIT.ok;
1016
+ }
1017
+ c.io.out([
1018
+ `${s.email} (${s.accountId})`,
1019
+ `Balance: ${formatUsd(s.balanceMicros)} (${s.status})`,
1020
+ `Watches: ${s.watches.count} of ${s.watches.max} (${s.watches.free} free)`,
1021
+ `API keys: ${s.keys}`
1022
+ ].join("\n"));
1023
+ return EXIT.ok;
1024
+ }
1025
+ }
1026
+ ];
1027
+
1028
+ // ../../../packages/core-cli/src/index.ts
1029
+ var allCommands = [
1030
+ ...authCommands,
1031
+ ...watchCommands,
1032
+ ...channelCommands,
1033
+ ...activityCommands,
1034
+ ...keyCommands,
1035
+ ...accountCommands,
1036
+ ...billingCommands,
1037
+ ...adminCommands
1038
+ ];
1039
+
1040
+ // src/product.ts
1041
+ var lapsedCli = {
1042
+ name: "lapsed",
1043
+ displayName: "Lapsed",
1044
+ envPrefix: "LAPSED",
1045
+ defaultApiUrl: "https://lapsed.dev",
1046
+ version: true ? "0.1.0" : "0.0.0-dev",
1047
+ watchKinds: ["host", "domain"],
1048
+ examples: [
1049
+ "lapsed login",
1050
+ "lapsed add example.com api.example.com",
1051
+ "lapsed channels add https://hooks.slack.com/services/\u2026",
1052
+ "lapsed channels test"
1053
+ ]
1054
+ };
1055
+
1056
+ // src/main.ts
1057
+ function openUrl(url) {
1058
+ if (!/^https?:\/\//.test(url)) return Promise.resolve(false);
1059
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1060
+ return new Promise((resolve) => {
1061
+ try {
1062
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1063
+ child.once("error", () => resolve(false));
1064
+ child.once("spawn", () => {
1065
+ child.unref();
1066
+ resolve(true);
1067
+ });
1068
+ } catch {
1069
+ resolve(false);
1070
+ }
1071
+ });
1072
+ }
1073
+ async function prompt(question, opts = {}) {
1074
+ const stdin = process.stdin;
1075
+ if (!opts.hidden || !stdin.isTTY) {
1076
+ const rl = createInterface({ input: stdin, output: process.stderr });
1077
+ try {
1078
+ return await rl.question(question);
1079
+ } finally {
1080
+ rl.close();
1081
+ }
1082
+ }
1083
+ process.stderr.write(question);
1084
+ stdin.setRawMode(true);
1085
+ stdin.resume();
1086
+ stdin.setEncoding("utf8");
1087
+ return new Promise((resolve) => {
1088
+ let value = "";
1089
+ const onData = (chunk) => {
1090
+ for (const ch of chunk) {
1091
+ if (ch === "\r" || ch === "\n") {
1092
+ done();
1093
+ return;
1094
+ }
1095
+ if (ch === "") {
1096
+ process.stderr.write("\n");
1097
+ process.exit(130);
1098
+ }
1099
+ if (ch === "\x7F" || ch === "\b") value = value.slice(0, -1);
1100
+ else value += ch;
1101
+ }
1102
+ };
1103
+ const done = () => {
1104
+ stdin.off("data", onData);
1105
+ stdin.setRawMode(false);
1106
+ stdin.pause();
1107
+ process.stderr.write("\n");
1108
+ resolve(value);
1109
+ };
1110
+ stdin.on("data", onData);
1111
+ });
1112
+ }
1113
+ var ctx = {
1114
+ env: process.env,
1115
+ io: {
1116
+ out: (s) => {
1117
+ process.stdout.write(`${s}
1118
+ `);
1119
+ },
1120
+ err: (s) => {
1121
+ process.stderr.write(`${s}
1122
+ `);
1123
+ },
1124
+ isTty: Boolean(process.stdout.isTTY && process.stdin.isTTY),
1125
+ color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
1126
+ },
1127
+ fetch: globalThis.fetch,
1128
+ platform: process.platform,
1129
+ homedir: homedir(),
1130
+ hostname: hostname(),
1131
+ openUrl,
1132
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1133
+ now: () => /* @__PURE__ */ new Date(),
1134
+ prompt,
1135
+ randomId: () => randomUUID()
1136
+ };
1137
+ process.exitCode = await defineCli(lapsedCli, allCommands)(process.argv.slice(2), ctx);
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "lapsed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Know before your SSL certificates and domains lapse. The Lapsed CLI.",
5
+ "license": "MIT",
6
+ "homepage": "https://lapsed.dev",
7
+ "keywords": [
8
+ "ssl",
9
+ "tls",
10
+ "certificate",
11
+ "domain",
12
+ "expiry",
13
+ "monitoring",
14
+ "cli"
15
+ ],
16
+ "type": "module",
17
+ "bin": {
18
+ "lapsed": "dist/lapsed.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "build": "node scripts/build.ts",
30
+ "typecheck": "tsc --noEmit -p ."
31
+ },
32
+ "devDependencies": {
33
+ "@mw/core-cli": "workspace:*"
34
+ }
35
+ }