htmlhost-cli 2.13.0 → 2.15.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/README.md CHANGED
@@ -173,8 +173,39 @@ htmlhost agents --no-claude # don't touch CLAUDE.md
173
173
 
174
174
  The same guide is at https://htmlhost.co/agents.md.
175
175
 
176
+ ### `htmlhost keys [slug] [--json]`
177
+ List what the site's owner set up for building with services and data:
178
+
179
+ - **Keys** (the site's **Keys** tab): every key's name and kind, and the value of public keys. A secret key's value is never downloaded.
180
+ - **Secure calls**: how a page uses a secret key, by name: `POST /__hh/call/<name>` with the fields listed.
181
+ - **Collections** (the **Data** tab): the site's own data, with each collection's address, fields and read/add rules. Records aren't listed.
182
+
183
+ ```bash
184
+ htmlhost keys # in a linked project folder
185
+ htmlhost keys my-shop # any site you can edit
186
+ htmlhost keys --json # { slug, keys, calls, collections }
187
+ ```
188
+
189
+ `pull` and `clone` also write the list into the project's `AGENTS.md` (its own block, separate from `htmlhost agents`), so your AI coding tool can build with them by name.
190
+
191
+ ### `htmlhost keys set NAME [VALUE] [options]`
192
+ Save a key to the linked site (or `--slug SLUG`): adds it, or replaces the value of a key with that name.
193
+
194
+ ```bash
195
+ htmlhost keys set STRIPE_SECRET_KEY # asks for the value without showing it
196
+ echo "$KEY" | htmlhost keys set STRIPE_SECRET_KEY # or from stdin
197
+ htmlhost keys set GA_MEASUREMENT_ID G-ABC123 --public --note "Analytics"
198
+ ```
199
+
200
+ Without `--public` or `--secret`, htmlhost works the kind out from the value (`sk_…` secret, `pk_…` public, and similar) or the name; when it can't, the CLI asks (or, without a terminal, tells your AI tool to ask you). Saving a secret-looking value as public needs `--confirm-public`. The site's owner or an Admin only, on Pro. Nothing printed contains a secret's value.
201
+
202
+ ### `htmlhost keys remove NAME`
203
+ Remove a key from the linked site (or `--slug SLUG`). Refused while a secure call uses it.
204
+
205
+ Secure calls and collections are set up in the dashboard. Guides: [payments](https://htmlhost.co/guides/accept-payments-on-your-website), [API keys](https://htmlhost.co/guides/use-api-keys-safely-on-your-website), [collections](https://htmlhost.co/guides/add-a-database-to-your-website).
206
+
176
207
  ### `htmlhost list`
177
- List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄. Sites shared with you as a collaborator follow under **Shared with you**, with their owner: `clone`, `deploy` and `versions` work on them with your own sign-in; only the owner can delete them.
208
+ List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄. Sites shared with you follow under **Shared with you**, with their owner and your role. Admins and Editors can `clone`, `deploy` and roll them back with their own sign-in; Contributors can `clone` and `deploy --draft`, then ask someone who can publish; Viewers only look. Only the owner can delete a site.
178
209
 
179
210
  ### `htmlhost open <slug>`
180
211
  Open a deployed site in the default browser.
@@ -227,7 +258,7 @@ Free publishing is limited. Subscribe to Pro to continue creating unlimited site
227
258
 
228
259
  ## Unpublished sites and drafts
229
260
 
230
- Unpublished websites remain private until publishing is explicitly confirmed. Collaborators can publish with confirmation; only owners can unpublish. The server also cancels deployments still running when someone unpublishes.
261
+ Unpublished websites remain private until publishing is explicitly confirmed. Admins and Editors on a shared site can publish with confirmation and unpublish; Contributors save drafts only. The server also cancels deployments still running when someone unpublishes.
231
262
 
232
263
  - `htmlhost deploy --draft` saves files without changing publication.
233
264
  - `htmlhost deploy --publish` explicitly makes an unpublished website public. The same flag applies to `rollback`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.13.0",
4
- "description": "Deploy HTML files from the terminal \u2014 htmlhost.co CLI",
3
+ "version": "2.15.0",
4
+ "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "htmlhost": "bin/htmlhost.mjs"
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.13.0";
8
+ const VERSION = "2.15.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -17,10 +17,13 @@ const HELP = `
17
17
  ${cyan("htmlhost pull")} [slug] [opts] Pull remote changes to local
18
18
  ${cyan("htmlhost clone")} <slug> [dir] Clone a site to a local directory
19
19
  ${cyan("htmlhost agents")} [dir] Teach your AI coding tool htmlhost (AGENTS.md)
20
- ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients (owner)
20
+ ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients (owner, Admin)
21
+ ${cyan("htmlhost keys")} [slug] List a site's keys, secure calls and collections
22
+ ${cyan("htmlhost keys set")} NAME [value] Save a key (kind worked out, or --public/--secret)
23
+ ${cyan("htmlhost keys remove")} NAME Remove a key
21
24
  ${cyan("htmlhost list")} List your sites and ones shared with you
22
25
  ${cyan("htmlhost versions")} [slug] List a site's published versions
23
- ${cyan("htmlhost unpublish")} [slug] Take a site offline (owner only)
26
+ ${cyan("htmlhost unpublish")} [slug] Take a site offline (owner, Admin, Editor)
24
27
  ${cyan("htmlhost rollback")} [slug] [ver] Make an earlier version live again (Pro)
25
28
  ${cyan("htmlhost delete")} <slug> Delete a site (owner)
26
29
  ${cyan("htmlhost login")} Sign in (approve in your browser)
@@ -142,6 +145,11 @@ export async function run(argv) {
142
145
  await whoami();
143
146
  break;
144
147
  }
148
+ case "keys": {
149
+ const { keys } = await import("./commands/keys.mjs");
150
+ await keys(args, { json: jsonMode });
151
+ break;
152
+ }
145
153
  case "unpublish": {
146
154
  const { unpublish } = await import("./commands/unpublish.mjs");
147
155
  await unpublish(args, { json: jsonMode });
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from
10
10
  import { join, dirname, resolve } from "node:path";
11
11
  import { get, downloadBuffer } from "../api.mjs";
12
12
  import { fetchAgentGuide, writeAgentGuide } from "./agents.mjs";
13
+ import { writeKeysGuide } from "../keys-guide.mjs";
13
14
  import { writeLink, directorySite } from "../link.mjs";
14
15
  import {
15
16
  ok,
@@ -166,6 +167,9 @@ export async function clone(args) {
166
167
  .then((guide) => writeAgentGuide(destDir, guide).length > 0)
167
168
  .catch(() => false);
168
169
 
170
+ // The site's keys (names, and public values) for the same tools.
171
+ const keysWritten = !!data.keysGuide && writeKeysGuide(destDir, data.keysGuide);
172
+
169
173
  // --- Done ---
170
174
  console.log("");
171
175
  console.log(` ${green("━".repeat(40))}`);
@@ -177,5 +181,6 @@ export async function clone(args) {
177
181
  console.log(` ${dim("Deploy changes:")} ${cyan("htmlhost deploy")}`);
178
182
  console.log(` ${dim("Pull updates:")} ${cyan("htmlhost pull")}`);
179
183
  if (agentsWritten) console.log(` ${dim("AI agent guide:")} ${cyan("AGENTS.md")} ${dim("(not deployed)")}`);
184
+ if (keysWritten) console.log(` ${dim("Site keys:")} ${cyan("AGENTS.md")} ${dim(`(${data.keys.length} listed; secret values are never downloaded)`)}`);
180
185
  console.log("");
181
186
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * htmlhost keys [slug] [--json]
3
+ * htmlhost keys set NAME [VALUE] [--public|--secret] [--note TEXT] [--slug SLUG] [--confirm-public]
4
+ * htmlhost keys remove NAME [--slug SLUG]
5
+ *
6
+ * List a site's keys (the dashboard's Keys tab): every key's name and kind,
7
+ * the value of public keys only, its secure calls and collections. `set` adds
8
+ * or replaces a key; without --public/--secret htmlhost works the kind out
9
+ * from the value, and when it can't, asks (in a terminal) or says to ask the
10
+ * user (for an agent). A value left out is read from stdin, or typed hidden,
11
+ * so it stays out of shell history. Answers never print a secret's value.
12
+ */
13
+ import { resolve } from "node:path";
14
+ import { get, post, del } from "../api.mjs";
15
+ import { readLink } from "../link.mjs";
16
+ import { writeKeysGuide } from "../keys-guide.mjs";
17
+ import { bold, cyan, dim, info, ok, prompt, confirm } from "../ui.mjs";
18
+
19
+ const flag = (args, name) => {
20
+ const i = args.indexOf(name);
21
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
22
+ };
23
+ /** Positional arguments, leaving out flags and the values of flags that take one. */
24
+ function positionals(args) {
25
+ const out = [];
26
+ for (let i = 0; i < args.length; i++) {
27
+ if (["--note", "--slug"].includes(args[i])) { i++; continue; }
28
+ if (!args[i].startsWith("-")) out.push(args[i]);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ async function readStdin() {
34
+ const chunks = [];
35
+ for await (const c of process.stdin) chunks.push(c);
36
+ return Buffer.concat(chunks).toString("utf8").trim();
37
+ }
38
+
39
+ /** htmlhost keys set NAME [VALUE] … */
40
+ async function setKey(args, slug, { json }) {
41
+ const [, name, given] = positionals(args);
42
+ if (!name) throw new Error("Usage: htmlhost keys set NAME [VALUE] [--public|--secret] [--note TEXT]");
43
+ const interactive = process.stdin.isTTY && !json;
44
+ let value = given;
45
+ if (!value) value = interactive ? await prompt(` Value for ${name}: `, { hidden: true }) : await readStdin();
46
+ if (!value) throw new Error(`No value given for ${name}.`);
47
+ let kind = args.includes("--secret") ? "secret" : args.includes("--public") ? "public" : undefined;
48
+ let confirmPublic = args.includes("--confirm-public");
49
+ const note = flag(args, "--note");
50
+ const send = () => post(`/api/sites/${encodeURIComponent(slug)}/keys`, { name, value, kind, note, confirmPublic });
51
+
52
+ let data;
53
+ for (;;) {
54
+ try {
55
+ data = await send();
56
+ break;
57
+ } catch (e) {
58
+ if (e.code === "KEY_KIND_UNKNOWN" && interactive && !kind) {
59
+ const answer = (await prompt(` Is ${name} public (safe to show in pages) or secret? [public/secret]: `)).trim().toLowerCase();
60
+ if (!["public", "secret"].includes(answer)) throw new Error("Nothing saved. Run it again with --public or --secret.");
61
+ kind = answer;
62
+ continue;
63
+ }
64
+ if (e.code === "KEY_LOOKS_SECRET" && interactive && !confirmPublic) {
65
+ if (!(await confirm(` ${name} looks like a secret key. Save it as public anyway, readable by every visitor?`))) {
66
+ kind = "secret";
67
+ } else confirmPublic = true;
68
+ continue;
69
+ }
70
+ if (e.code === "KEY_KIND_UNKNOWN") throw new Error(`${e.message} Then run: htmlhost keys set ${name} --public or --secret`);
71
+ if (e.code === "KEY_LOOKS_SECRET") throw new Error(`${e.message} (CLI: add --secret, or --confirm-public once the user confirms.)`);
72
+ throw e;
73
+ }
74
+ }
75
+ if (json) return console.log(JSON.stringify({ slug, ...data.key, created: data.created }));
76
+ ok(`${data.created ? "Added" : "Updated"} ${bold(data.key.name)} on ${cyan(slug)} as ${data.key.kind === "secret" ? "a secret key" : "a public key"}.`);
77
+ console.log(` ${dim(data.key.kind === "secret" ? "Its value is never shown again. Pages use it through a secure call." : "Pages may use its value as given.")}`);
78
+ }
79
+
80
+ /** htmlhost keys remove NAME */
81
+ async function removeKey(args, slug, { json }) {
82
+ const [, name] = positionals(args);
83
+ if (!name) throw new Error("Usage: htmlhost keys remove NAME");
84
+ const data = await del(`/api/sites/${encodeURIComponent(slug)}/keys?name=${encodeURIComponent(name)}`);
85
+ if (json) return console.log(JSON.stringify({ slug, name, removed: data.removed }));
86
+ if (data.removed) ok(`Removed ${bold(name)} from ${cyan(slug)}.`);
87
+ else info(`${cyan(slug)} has no key named ${name}.`);
88
+ }
89
+
90
+ export async function keys(args, { json = false } = {}) {
91
+ const dir = resolve(".");
92
+ const link = readLink(dir);
93
+ const sub = args[0];
94
+ if (sub === "set" || sub === "remove") {
95
+ const target = (flag(args, "--slug") || link?.site?.slug)?.replace(/\.htmlhost\.co$/, "");
96
+ if (!target) throw new Error(`Run it in a linked project folder, or add --slug SLUG.`);
97
+ return sub === "set" ? setKey(args, target, { json }) : removeKey(args, target, { json });
98
+ }
99
+ const slug = (args.find((a) => !a.startsWith("-")) || link?.site?.slug)?.replace(/\.htmlhost\.co$/, "");
100
+ if (!slug) throw new Error("Usage: htmlhost keys <slug>");
101
+
102
+ const data = await get(`/api/sites/pull?slug=${encodeURIComponent(slug)}&meta=true`);
103
+ const list = data.keys || [];
104
+ const calls = data.calls || [];
105
+ const collections = data.collections || [];
106
+ // In the site's own folder, keep AGENTS.md's list current too.
107
+ if (link?.site?.slug === slug) writeKeysGuide(dir, data.keysGuide || "");
108
+
109
+ if (json) {
110
+ console.log(JSON.stringify({ slug, keys: list, calls, collections }));
111
+ return;
112
+ }
113
+ console.log("");
114
+ if (collections.length) {
115
+ console.log(` ${bold("Collections")} ${dim("(the site's own data; records aren't listed)")}`);
116
+ for (const c of collections) {
117
+ const fields = c.fields.map((f) => `${f.name}${f.required ? "" : "?"}`).join(", ");
118
+ console.log(` ${bold(c.name)} ${c.path} ${dim("read:")} ${c.read === "public" ? "anyone" : "site only"} ${dim("add:")} ${c.add === "public" ? "anyone" : "site only"} ${dim(fields)}`);
119
+ }
120
+ console.log("");
121
+ }
122
+ if (!list.length) {
123
+ info(`${cyan(slug)} has no keys yet.`);
124
+ console.log(` ${dim("Add them in the dashboard:")} ${cyan(`https://htmlhost.co/dashboard/sites/${slug}/keys`)}`);
125
+ console.log("");
126
+ return;
127
+ }
128
+ const width = Math.max(...list.map((k) => k.name.length));
129
+ for (const k of list) {
130
+ const value = k.kind === "public" ? k.value : dim("(secret, not shown)");
131
+ console.log(` ${bold(k.name.padEnd(width))} ${dim(k.kind.padEnd(6))} ${value}${k.note ? ` ${dim(k.note)}` : ""}`);
132
+ }
133
+ if (calls.length) {
134
+ console.log("");
135
+ console.log(` ${bold("Secure calls")} ${dim("(how pages use a secret key)")}`);
136
+ for (const c of calls) {
137
+ const fields = c.fields.map((f) => `${f.name}${f.required ? "" : "?"}`).join(", ");
138
+ console.log(` ${bold(c.name)}${c.enabled ? "" : dim(" (off)")} ${dim("POST")} ${c.path}${fields ? ` ${dim("send")} ${fields}` : ""}`);
139
+ }
140
+ }
141
+ console.log("");
142
+ console.log(` ${dim("A public key's value can be written into pages. A secret key's never can: pages use it through a secure call.")}`);
143
+ console.log(` ${dim("Add one:")} ${cyan("htmlhost keys set NAME")} ${dim("· or in the dashboard:")} ${cyan(`https://htmlhost.co/dashboard/sites/${slug}/keys`)}`);
144
+ console.log("");
145
+ }
@@ -2,8 +2,8 @@ import { get } from "../api.mjs";
2
2
  import { dim, cyan, bold, formatBytes, yellow } from "../ui.mjs";
3
3
 
4
4
  /**
5
- * htmlhost list — show all sites: your own, then the ones shared with you
6
- * (you can clone, deploy and restore those, not delete them).
5
+ * htmlhost list — show all sites: your own, then the ones shared with you,
6
+ * with your role on each (what you can do follows it; only owners delete).
7
7
  */
8
8
  export async function list({ json = false } = {}) {
9
9
  const data = await get("/api/sites");
@@ -54,11 +54,14 @@ export async function list({ json = false } = {}) {
54
54
  if (shared.length) {
55
55
  const sharedSlugW = Math.max(14, ...shared.map((s) => s.slug.length)) + 2;
56
56
  console.log("");
57
- console.log(` ${bold("Shared with you")} ${dim("(clone, deploy and restore; only the owner can delete)")}`);
58
- console.log(` ${dim(pad("SLUG", sharedSlugW) + pad("TITLE", 30) + pad("STATUS", 12) + "OWNER")}`);
57
+ console.log(` ${bold("Shared with you")} ${dim("(what you can do follows your role; only the owner can delete)")}`);
58
+ console.log(` ${dim(pad("SLUG", sharedSlugW) + pad("TITLE", 30) + pad("STATUS", 12) + pad("ROLE", 13) + "OWNER")}`);
59
59
  for (const s of shared) {
60
60
  const title = truncate(s.title && s.title !== s.slug ? s.title : "", 28);
61
- console.log(` ${pad(cyan(s.slug), sharedSlugW)}${pad(dim(title), 30)}${pad(s.isPublished === false ? "Draft" : "Published", 12)}${dim(s.owner || "")}`);
61
+ console.log(` ${pad(cyan(s.slug), sharedSlugW)}${pad(dim(title), 30)}${pad(s.isPublished === false ? "Draft" : "Published", 12)}${pad(roleLabel(s.role), 13)}${dim(s.owner || "")}`);
62
+ }
63
+ if (shared.some((s) => s.canPublish === false && s.canEdit !== false)) {
64
+ console.log(` ${dim("Contributors save drafts (htmlhost deploy --draft) and ask an Owner, Admin or Editor to publish.")}`);
62
65
  }
63
66
  }
64
67
 
@@ -91,3 +94,8 @@ function relTime(iso) {
91
94
  if (days === 1) return "tomorrow";
92
95
  return `${days}d`;
93
96
  }
97
+
98
+ /** The server's role name, as people read it ("contributor" → "Contributor"). Older servers send "editor". */
99
+ function roleLabel(role) {
100
+ return typeof role === "string" && role ? role[0].toUpperCase() + role.slice(1) : "Editor";
101
+ }
@@ -9,6 +9,7 @@ import { join, dirname, resolve, basename } from "node:path";
9
9
  import { createHash } from "node:crypto";
10
10
  import { get, downloadBuffer } from "../api.mjs";
11
11
  import { readLink, writeLink } from "../link.mjs";
12
+ import { writeKeysGuide } from "../keys-guide.mjs";
12
13
  import {
13
14
  ok,
14
15
  err,
@@ -201,6 +202,12 @@ export async function pull(args) {
201
202
  }
202
203
  }
203
204
 
205
+ // --- The site's keys, for AI tools working in this folder (AGENTS.md) ---
206
+ // Only in a project that's already set up for them or has keys to list.
207
+ if (!dryRun && typeof data.keysGuide === "string" && writeKeysGuide(projectDir, data.keysGuide)) {
208
+ info(`${cyan("AGENTS.md")} ${dim(data.keysGuide ? `now lists this site's ${data.keys?.length ?? ""} key${data.keys?.length === 1 ? "" : "s"}` : "no longer lists keys (the site has none)")}`);
209
+ }
210
+
204
211
  // --- Show results ---
205
212
  if (changes.length === 0) {
206
213
  ok("Already up to date — no changes to pull.");
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The site's keys in AGENTS.md, so an AI coding tool working in the folder
3
+ * knows which keys the site has: every key's name, and the values of public
4
+ * keys only (the server never sends a secret's value).
5
+ *
6
+ * A marked block of its own, separate from the `htmlhost agents` guide block,
7
+ * so refreshing either leaves the other alone. `pull` and `clone` write it
8
+ * from the server's `keysGuide`; a site without keys has no block.
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const START = "<!-- htmlhost:keys:start — this site's keys, written by `htmlhost pull`; edits inside this block are replaced -->";
14
+ const END = "<!-- htmlhost:keys:end -->";
15
+ const BLOCK_RE = /\n*<!-- htmlhost:keys:start[^>]*-->[\s\S]*?<!-- htmlhost:keys:end -->\n?/;
16
+
17
+ /** AGENTS.md's text with the keys block set to `guide` (removed when empty). Pure. */
18
+ export function withKeysBlock(before, guide) {
19
+ const text = (guide || "").trim();
20
+ const current = before ?? "";
21
+ if (!text) return BLOCK_RE.test(current) ? current.replace(BLOCK_RE, () => "\n") : current;
22
+ const block = `${START}\n${text}\n${END}`;
23
+ if (BLOCK_RE.test(current)) return current.replace(BLOCK_RE, () => `${current.search(BLOCK_RE) === 0 ? "" : "\n\n"}${block}\n`);
24
+ return current.trim() ? `${current.replace(/\s*$/, "")}\n\n${block}\n` : `${block}\n`;
25
+ }
26
+
27
+ /** Write (or remove) the block in `dir`'s AGENTS.md. Returns whether the file changed. */
28
+ export function writeKeysGuide(dir, guide) {
29
+ const path = join(dir, "AGENTS.md");
30
+ const before = existsSync(path) ? readFileSync(path, "utf8") : null;
31
+ const after = withKeysBlock(before, guide);
32
+ if (after === (before ?? "")) return false;
33
+ writeFileSync(path, after, "utf8");
34
+ return true;
35
+ }