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 +33 -2
- package/package.json +2 -2
- package/src/cli.mjs +11 -3
- package/src/commands/clone.mjs +5 -0
- package/src/commands/keys.mjs +145 -0
- package/src/commands/list.mjs +13 -5
- package/src/commands/pull.mjs +7 -0
- package/src/keys-guide.mjs +35 -0
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
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Deploy HTML files from the terminal
|
|
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.
|
|
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
|
|
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 });
|
package/src/commands/clone.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/list.mjs
CHANGED
|
@@ -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
|
|
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("(
|
|
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
|
+
}
|
package/src/commands/pull.mjs
CHANGED
|
@@ -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
|
+
}
|