htmlhost-cli 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +14 -1
- package/src/commands/recipients.mjs +270 -0
package/package.json
CHANGED
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.
|
|
8
|
+
const VERSION = "2.6.1";
|
|
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.multipage?.slug || 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
|
+
}
|