htmlhost-cli 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { bold, dim, cyan, err } from "./ui.mjs";
5
5
  import { ApiError } from "./api.mjs";
6
6
  import { startUpdateCheck, applyUpdate } from "./update.mjs";
7
7
 
8
- const VERSION = "2.5.0";
8
+ const VERSION = "2.6.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -16,6 +16,7 @@ const HELP = `
16
16
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
17
17
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
18
18
  ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
19
+ ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients
19
20
  ${cyan("htmlhost list")} List your sites
20
21
  ${cyan("htmlhost delete")} <slug> Delete a site
21
22
  ${cyan("htmlhost login")} Authenticate with an API token
@@ -44,6 +45,13 @@ const HELP = `
44
45
  --print Print the guide instead of writing files
45
46
  --no-claude Don't add the @AGENTS.md import to CLAUDE.md
46
47
 
48
+ ${bold("Recipients subcommands:")}
49
+ ${cyan("recipients")} [slug] List recipients
50
+ ${cyan("recipients add")} [slug] <email> Invite a new recipient
51
+ ${cyan("recipients remove")} [slug] <ref> Remove a recipient (email or id)
52
+ ${cyan("recipients resend")} [slug] <ref> Resend a pending invitation
53
+ --force, -f Skip confirmation on remove
54
+
47
55
  ${bold("Delete options:")}
48
56
  --force, -f Skip confirmation prompt
49
57
 
@@ -138,6 +146,11 @@ export async function run(argv) {
138
146
  await agents(args);
139
147
  break;
140
148
  }
149
+ case "recipients": {
150
+ const { recipients } = await import("./commands/recipients.mjs");
151
+ await recipients(args, { json: jsonMode });
152
+ break;
153
+ }
141
154
  case "list":
142
155
  case "ls": {
143
156
  const { list } = await import("./commands/list.mjs");
@@ -0,0 +1,270 @@
1
+ import { get, post } from "../api.mjs";
2
+ import { ok, err, info, dim, cyan, bold, yellow, confirm } from "../ui.mjs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const LINK_FILE = ".htmlhost";
7
+
8
+ /**
9
+ * Read the slug from .htmlhost in the given (or current) directory.
10
+ */
11
+ function readSlugFromLink(dir = process.cwd()) {
12
+ const linkPath = join(dir, LINK_FILE);
13
+ if (!existsSync(linkPath)) return null;
14
+ try {
15
+ const data = JSON.parse(readFileSync(linkPath, "utf8"));
16
+ return data.slug || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Resolve the slug: explicit argument first, then .htmlhost fallback.
24
+ */
25
+ function resolveSlug(args) {
26
+ const positional = args.filter((a) => !a.startsWith("--"));
27
+ const explicit = positional[0];
28
+ if (explicit) return explicit;
29
+ const linked = readSlugFromLink();
30
+ if (linked) return linked;
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Resolve an email-or-id argument to a recipient id by fetching the list.
36
+ * Returns { id, email } or null if not found.
37
+ */
38
+ async function resolveRecipient(slug, ref) {
39
+ // Already a UUID?
40
+ if (/^[0-9a-f-]{36}$/i.test(ref)) {
41
+ return { id: ref, email: null };
42
+ }
43
+ // Treat as email — look it up
44
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/recipients`);
45
+ const match = data.recipients?.find((r) => r.email === ref.toLowerCase());
46
+ if (!match) return null;
47
+ return { id: match.id, email: match.email };
48
+ }
49
+
50
+ function statusLabel(status) {
51
+ switch (status) {
52
+ case "confirmed": return cyan("confirmed");
53
+ case "pending": return yellow("pending");
54
+ case "unsubscribed": return dim("unsubscribed");
55
+ default: return dim(status);
56
+ }
57
+ }
58
+
59
+ function pad(str, width) {
60
+ const s = String(str);
61
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, "");
62
+ return s + " ".repeat(Math.max(0, width - visible.length));
63
+ }
64
+
65
+ function relDate(iso) {
66
+ if (!iso) return "";
67
+ const d = new Date(iso);
68
+ return d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
69
+ }
70
+
71
+ /**
72
+ * htmlhost recipients [slug] [subcommand] [args] [--json] [--force]
73
+ */
74
+ export async function recipients(args, { json = false } = {}) {
75
+ // Parse subcommand: the first non-flag arg might be a slug or a subcommand
76
+ const positional = args.filter((a) => !a.startsWith("--"));
77
+ const force = args.includes("--force") || args.includes("-f");
78
+
79
+ // Detect subcommand pattern
80
+ const subcommands = ["add", "remove", "resend"];
81
+ let subcommand = null;
82
+ let slug = null;
83
+ let subArgs = [];
84
+
85
+ if (subcommands.includes(positional[0])) {
86
+ // `htmlhost recipients add [slug] <email>` — subcommand first, slug from .htmlhost or second arg
87
+ subcommand = positional[0];
88
+ // Remaining positional args: could be [slug, email] or just [email]
89
+ subArgs = positional.slice(1);
90
+ } else if (positional.length >= 2 && subcommands.includes(positional[1])) {
91
+ // `htmlhost recipients <slug> add <email>` — slug first, then subcommand
92
+ slug = positional[0];
93
+ subcommand = positional[1];
94
+ subArgs = positional.slice(2);
95
+ } else {
96
+ // `htmlhost recipients [slug]` — list mode
97
+ slug = resolveSlug(args);
98
+ }
99
+
100
+ // For subcommands, resolve slug if not already set
101
+ if (subcommand && !slug) {
102
+ // subArgs might start with a slug if there are enough args
103
+ if (subcommand === "add" && subArgs.length >= 2) {
104
+ // `recipients add <slug> <email>`
105
+ slug = subArgs[0];
106
+ subArgs = subArgs.slice(1);
107
+ } else if ((subcommand === "remove" || subcommand === "resend") && subArgs.length >= 2) {
108
+ slug = subArgs[0];
109
+ subArgs = subArgs.slice(1);
110
+ } else {
111
+ // Try .htmlhost
112
+ slug = readSlugFromLink();
113
+ }
114
+ }
115
+
116
+ if (!slug) {
117
+ err("No site specified. Pass a slug or run from a directory with a .htmlhost file.");
118
+ console.log(` ${dim("Usage:")} ${cyan("htmlhost recipients")} ${dim("[slug]")}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ if (!subcommand) {
123
+ return listCmd(slug, json);
124
+ }
125
+
126
+ switch (subcommand) {
127
+ case "add": return addCmd(slug, subArgs, json);
128
+ case "remove": return removeCmd(slug, subArgs, json, force);
129
+ case "resend": return resendCmd(slug, subArgs, json);
130
+ default:
131
+ err(`Unknown subcommand: ${subcommand}`);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ async function listCmd(slug, json) {
137
+ const data = await get(`/api/sites/${encodeURIComponent(slug)}/recipients`);
138
+
139
+ if (json) {
140
+ console.log(JSON.stringify(data));
141
+ return;
142
+ }
143
+
144
+ console.log("");
145
+ info(`Recipients for ${cyan(bold(slug + ".htmlhost.co"))}`);
146
+ console.log("");
147
+
148
+ // Owner row
149
+ const ownerStatus = data.notifyOwner ? cyan("receiving") : dim("off");
150
+ console.log(` ${pad(data.ownerEmail, 36)} ${pad(ownerStatus, 18)} ${dim("owner")}`);
151
+
152
+ if (!data.recipients || data.recipients.length === 0) {
153
+ console.log("");
154
+ console.log(` ${dim("No extra recipients.")}`);
155
+ console.log(` ${dim("Add one with:")} ${cyan(`htmlhost recipients add ${slug} name@example.com`)}`);
156
+ console.log("");
157
+ return;
158
+ }
159
+
160
+ console.log(` ${dim("─".repeat(60))}`);
161
+
162
+ for (const r of data.recipients) {
163
+ const status = statusLabel(r.status);
164
+ const forms = r.forms ? dim(`[${r.forms.join(", ")}]`) : "";
165
+ const invited = dim(relDate(r.invitedAt));
166
+ console.log(` ${pad(r.email, 36)} ${pad(status, 18)} ${invited} ${forms}`);
167
+ }
168
+
169
+ const active = data.recipients.filter((r) => r.status !== "unsubscribed").length;
170
+ console.log("");
171
+ console.log(` ${bold(String(active))}/${data.limit} recipient slots used ${dim(`(${data.isPro ? "pro" : "free"})`)}`);
172
+ console.log("");
173
+ }
174
+
175
+ async function addCmd(slug, subArgs, json) {
176
+ const email = subArgs[0];
177
+ if (!email) {
178
+ err("Usage: htmlhost recipients add [slug] <email>");
179
+ process.exit(1);
180
+ }
181
+
182
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
183
+ action: "add",
184
+ email,
185
+ });
186
+
187
+ if (json) {
188
+ console.log(JSON.stringify(res));
189
+ return;
190
+ }
191
+
192
+ if (res.ok) {
193
+ ok(res.message || `Added ${email}.`);
194
+ } else {
195
+ err(res.error || "Failed to add recipient.");
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ async function removeCmd(slug, subArgs, json, force) {
201
+ const ref = subArgs[0];
202
+ if (!ref) {
203
+ err("Usage: htmlhost recipients remove [slug] <email|id>");
204
+ process.exit(1);
205
+ }
206
+
207
+ const resolved = await resolveRecipient(slug, ref);
208
+ if (!resolved) {
209
+ err(`Recipient not found: ${ref}`);
210
+ process.exit(1);
211
+ }
212
+
213
+ const label = resolved.email || ref;
214
+
215
+ if (!force) {
216
+ const yes = await confirm(` Remove ${cyan(bold(label))} from ${cyan(slug)}?`);
217
+ if (!yes) {
218
+ console.log(" Cancelled.");
219
+ return;
220
+ }
221
+ }
222
+
223
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
224
+ action: "remove",
225
+ id: resolved.id,
226
+ });
227
+
228
+ if (json) {
229
+ console.log(JSON.stringify(res));
230
+ return;
231
+ }
232
+
233
+ if (res.ok) {
234
+ ok(`Removed ${label}.`);
235
+ } else {
236
+ err(res.error || "Failed to remove recipient.");
237
+ process.exit(1);
238
+ }
239
+ }
240
+
241
+ async function resendCmd(slug, subArgs, json) {
242
+ const ref = subArgs[0];
243
+ if (!ref) {
244
+ err("Usage: htmlhost recipients resend [slug] <email|id>");
245
+ process.exit(1);
246
+ }
247
+
248
+ const resolved = await resolveRecipient(slug, ref);
249
+ if (!resolved) {
250
+ err(`Recipient not found: ${ref}`);
251
+ process.exit(1);
252
+ }
253
+
254
+ const res = await post(`/api/sites/${encodeURIComponent(slug)}/recipients`, {
255
+ action: "resend",
256
+ id: resolved.id,
257
+ });
258
+
259
+ if (json) {
260
+ console.log(JSON.stringify(res));
261
+ return;
262
+ }
263
+
264
+ if (res.ok) {
265
+ ok(res.message || `Invitation re-sent.`);
266
+ } else {
267
+ err(res.error || "Failed to resend invitation.");
268
+ process.exit(1);
269
+ }
270
+ }