htmlhost-cli 2.14.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,6 +173,37 @@ 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
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.14.0",
3
+ "version": "2.15.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.14.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
@@ -18,6 +18,9 @@ const HELP = `
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
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
26
  ${cyan("htmlhost unpublish")} [slug] Take a site offline (owner, Admin, Editor)
@@ -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
+ }
@@ -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
+ }