htmlhost-cli 2.11.0 → 2.13.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
@@ -174,7 +174,7 @@ htmlhost agents --no-claude # don't touch CLAUDE.md
174
174
  The same guide is at https://htmlhost.co/agents.md.
175
175
 
176
176
  ### `htmlhost list`
177
- List all your sites with titles, sizes, TTL, and expiry. Multi-page sites are marked with 📄.
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.
178
178
 
179
179
  ### `htmlhost open <slug>`
180
180
  Open a deployed site in the default browser.
@@ -223,3 +223,14 @@ MIT
223
223
  ### Free publishing allowance
224
224
 
225
225
  Free publishing is limited. Subscribe to Pro to continue creating unlimited sites at https://htmlhost.co/upgrade. See Pro benefits at https://htmlhost.co/pricing. Before your first free CLI deployment, sign in at https://htmlhost.co/new to complete browser verification. If verification is unavailable, contact hey@htmlhost.co for review.
226
+
227
+
228
+ ## Unpublished sites and drafts
229
+
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.
231
+
232
+ - `htmlhost deploy --draft` saves files without changing publication.
233
+ - `htmlhost deploy --publish` explicitly makes an unpublished website public. The same flag applies to `rollback`.
234
+ - Interactive deploys ask a separate, default-No question when the website is unpublished. `--force` and `--json` never grant this permission. Agents must obtain the user's consent before using `--publish`.
235
+ - `htmlhost unpublish <slug>` takes the website offline while retaining files and history. Non-interactive use requires `--yes` after owner confirmation.
236
+ - `list` labels owned and shared drafts. `pull` / `clone` retrieve working files, including saved drafts, by default; `--published` selects the last deployed files and `--draft` requires a draft.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.11.0",
4
- "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
3
+ "version": "2.13.0",
4
+ "description": "Deploy HTML files from the terminal \u2014 htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "htmlhost": "bin/htmlhost.mjs"
@@ -28,6 +28,6 @@
28
28
  "url": "git+https://github.com/htmlhost/cli.git"
29
29
  },
30
30
  "scripts": {
31
- "test": "node --test test/"
31
+ "test": "node --experimental-test-module-mocks --test test/"
32
32
  }
33
33
  }
package/src/api.mjs CHANGED
@@ -5,9 +5,10 @@ import { getToken, getApi } from "./config.mjs";
5
5
  import { dim, yellow } from "./ui.mjs";
6
6
 
7
7
  export class ApiError extends Error {
8
- constructor(message, status) {
8
+ constructor(message, status, code) {
9
9
  super(message);
10
10
  this.status = status;
11
+ this.code = code;
11
12
  }
12
13
  }
13
14
 
@@ -19,9 +20,9 @@ const BASE_DELAY_MS = 500;
19
20
  * Retries on network errors ("fetch failed", ECONNRESET, etc.)
20
21
  * and 5xx server errors. Does NOT retry on 4xx client errors.
21
22
  */
22
- async function withRetry(fn, label = "request") {
23
+ async function withRetry(fn, label = "request", attempts = MAX_RETRIES) {
23
24
  let lastError;
24
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
25
+ for (let attempt = 1; attempt <= attempts; attempt++) {
25
26
  try {
26
27
  return await fn();
27
28
  } catch (e) {
@@ -30,13 +31,13 @@ async function withRetry(fn, label = "request") {
30
31
  !(e instanceof ApiError) ||
31
32
  (e.status >= 500 && e.status < 600);
32
33
 
33
- if (!isNetworkError || attempt === MAX_RETRIES) {
34
+ if (!isNetworkError || attempt === attempts) {
34
35
  throw e;
35
36
  }
36
37
 
37
38
  const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
38
39
  console.log(
39
- ` ${yellow("↻")} ${label} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${delay}ms… ${dim(e.message)}`
40
+ ` ${yellow("↻")} ${label} failed (attempt ${attempt}/${attempts}), retrying in ${delay}ms… ${dim(e.message)}`
40
41
  );
41
42
  await new Promise((r) => setTimeout(r, delay));
42
43
  }
@@ -60,7 +61,7 @@ export async function get(path) {
60
61
  headers: headers(),
61
62
  });
62
63
  const data = await res.json();
63
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
64
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status, data.code);
64
65
  return data;
65
66
  }, `GET ${path}`);
66
67
  }
@@ -73,9 +74,9 @@ export async function post(path, body) {
73
74
  body: JSON.stringify(body),
74
75
  });
75
76
  const data = await res.json();
76
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
77
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status, data.code);
77
78
  return data;
78
- }, `POST ${path}`);
79
+ }, `POST ${path}`, body?.confirmPublish === true ? 1 : MAX_RETRIES);
79
80
  }
80
81
 
81
82
  export async function del(path) {
@@ -85,7 +86,7 @@ export async function del(path) {
85
86
  headers: headers(),
86
87
  });
87
88
  const data = await res.json();
88
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
89
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status, data.code);
89
90
  return data;
90
91
  }, `DELETE ${path}`);
91
92
  }
@@ -108,7 +109,7 @@ export async function uploadFile(path, filePath, fileName, mimeType) {
108
109
  body: form,
109
110
  });
110
111
  const data = await res.json();
111
- if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status);
112
+ if (!res.ok) throw new ApiError(data.error || `HTTP ${res.status}`, res.status, data.code);
112
113
  return data;
113
114
  }, `upload ${fileName}`);
114
115
  }
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.11.0";
8
+ const VERSION = "2.13.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -17,11 +17,12 @@ 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
21
- ${cyan("htmlhost list")} List your sites
20
+ ${cyan("htmlhost recipients")} [slug] [cmd] Manage form notification recipients (owner)
21
+ ${cyan("htmlhost list")} List your sites and ones shared with you
22
22
  ${cyan("htmlhost versions")} [slug] List a site's published versions
23
+ ${cyan("htmlhost unpublish")} [slug] Take a site offline (owner only)
23
24
  ${cyan("htmlhost rollback")} [slug] [ver] Make an earlier version live again (Pro)
24
- ${cyan("htmlhost delete")} <slug> Delete a site
25
+ ${cyan("htmlhost delete")} <slug> Delete a site (owner)
25
26
  ${cyan("htmlhost login")} Sign in (approve in your browser)
26
27
  ${cyan("htmlhost whoami")} Show current user
27
28
  ${cyan("htmlhost logout")} Remove saved token
@@ -33,6 +34,8 @@ const HELP = `
33
34
  --title <title> Set the site title
34
35
  --new Force a new site (ignore .htmlhost link)
35
36
  --no-assets Skip auto-uploading local assets (single-file only)
37
+ --draft Save as a draft; leave publication unchanged
38
+ --publish Explicitly make an unpublished site public
36
39
  --no-pull-check Skip remote change detection before deploy
37
40
 
38
41
  ${bold("Upload options:")}
@@ -139,6 +142,11 @@ export async function run(argv) {
139
142
  await whoami();
140
143
  break;
141
144
  }
145
+ case "unpublish": {
146
+ const { unpublish } = await import("./commands/unpublish.mjs");
147
+ await unpublish(args, { json: jsonMode });
148
+ break;
149
+ }
142
150
  case "deploy": {
143
151
  const { deploy } = await import("./commands/deploy.mjs");
144
152
  await deploy(args, { json: jsonMode });
@@ -44,6 +44,8 @@ export async function clone(args) {
44
44
  const positionals = args.filter((a) => !a.startsWith("--"));
45
45
  const slug = positionals[0];
46
46
  const targetDir = positionals[1]; // optional — defaults to slug name
47
+ const source = args.includes("--published") ? "published" : args.includes("--draft") ? "draft" : "working";
48
+ if (args.includes("--published") && args.includes("--draft")) throw new Error("Choose --published or --draft, not both.");
47
49
  const force = args.includes("--force") || args.includes("-f");
48
50
 
49
51
  if (!slug) {
@@ -86,12 +88,14 @@ export async function clone(args) {
86
88
  // Fetch remote content
87
89
  let data;
88
90
  try {
89
- data = await get(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}`);
91
+ data = await get(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&source=${source}`);
90
92
  } catch (e) {
91
93
  err(`Failed to fetch site: ${e.message}`);
92
94
  process.exit(1);
93
95
  }
94
96
 
97
+ info(`${data.source === "draft" ? "Saved draft" : "Last published files"} · ${data.isPublished === false ? "website is unpublished" : "website is published"}`);
98
+
95
99
  const pageCount = (data.pages || []).length;
96
100
  const assetCount = (data.assets || []).length;
97
101
  const totalFiles = pageCount + assetCount;
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
2
- import { basename, resolve, dirname, join } from "node:path";
2
+ import { basename, resolve, dirname, join, sep } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { createHash } from "node:crypto";
5
+ import { postPublication } from "../publication.mjs";
5
6
  import { post } from "../api.mjs";
6
7
  import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
7
8
  import { checkRemoteChanges } from "./pull.mjs";
@@ -145,6 +146,9 @@ export async function deploy(args, { json = false } = {}) {
145
146
  const ttl = values["--ttl"] ?? null;
146
147
  const title = values["--title"] ?? null;
147
148
  const slug = values["--slug"] ?? null;
149
+ const confirmPublish = args.includes("--publish");
150
+ const isDraft = args.includes("--draft");
151
+ if (confirmPublish && isDraft) throw new Error("Choose --draft or --publish, not both.");
148
152
  const forceNew = args.includes("--new");
149
153
  const skipAssets = args.includes("--no-assets");
150
154
  const skipPullCheck = args.includes("--no-pull-check");
@@ -162,17 +166,17 @@ export async function deploy(args, { json = false } = {}) {
162
166
 
163
167
  // --- Directory deploy (default) ---
164
168
  if (stat.isDirectory()) {
165
- return deployDirectory(targetPath, { ttl, title, slug, forceNew, json, skipPullCheck });
169
+ return deployDirectory(targetPath, { ttl, title, slug, forceNew, json, skipPullCheck, confirmPublish, isDraft });
166
170
  }
167
171
 
168
172
  // --- Single-file deploy ---
169
- await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck });
173
+ await deploySingleFile(target || "index.html", { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck, confirmPublish, isDraft });
170
174
  }
171
175
 
172
176
  /**
173
177
  * Deploy a single HTML file (existing behavior preserved).
174
178
  */
175
- async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck }) {
179
+ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug, json, skipPullCheck, confirmPublish, isDraft }) {
176
180
  const filePath = resolve(file);
177
181
  const projectDir = dirname(filePath);
178
182
  const fileName = basename(filePath);
@@ -268,12 +272,12 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
268
272
  if (aborted) return;
269
273
  }
270
274
 
271
- const body = { html };
275
+ const body = { html, confirmPublish, isDraft };
272
276
  if (ttl) body.ttl = ttl;
273
277
  if (slug) body.slug = slug;
274
278
  if (title) body.title = title;
275
279
 
276
- const data = await post("/api/sites", body);
280
+ const data = await postPublication("/api/sites", body, { json });
277
281
 
278
282
  // Save link
279
283
  const existingLink = readLink(projectDir) || {};
@@ -299,7 +303,7 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
299
303
  }
300
304
 
301
305
  console.log("");
302
- ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
306
+ ok(isDraft ? `${bold("Draft saved")} · ${data.isPublished === false ? "not public" : "live site unchanged"}` : `${bold("Live")} at ${cyan(`https://${data.url}`)}`);
303
307
  if (data.version > 1) {
304
308
  console.log(` ${dim(`Version ${data.version} · ${data.ttl} TTL`)}`);
305
309
  } else {
@@ -321,7 +325,7 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
321
325
  * Non-HTML files (CSS, JS, images, fonts) are uploaded via the media endpoint
322
326
  * and included as pages with their hosted URLs.
323
327
  */
324
- async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json, skipPullCheck }) {
328
+ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceNew, json, skipPullCheck, confirmPublish, isDraft }) {
325
329
  // Find all files recursively
326
330
  const allFiles = findAllFiles(dirPath, dirPath);
327
331
 
@@ -519,7 +523,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
519
523
  console.log("");
520
524
  info(existingSlug ? `Deploying to ${cyan(existingSlug)}…` : "Deploying new site…");
521
525
 
522
- const body = { pages: pagePayloads, assets: assetPayloads };
526
+ const body = { pages: pagePayloads, assets: assetPayloads, confirmPublish, isDraft };
523
527
  if (redirectsText !== null) body.redirects = redirectsText;
524
528
  if (ttl) body.ttl = ttl;
525
529
  if (existingSlug) {
@@ -532,7 +536,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
532
536
  if (title) body.title = title;
533
537
 
534
538
  try {
535
- const data = await post("/api/sites", body);
539
+ const data = await postPublication("/api/sites", body, { json });
536
540
 
537
541
  // Save link
538
542
  writeLink(dirPath, {
@@ -554,11 +558,11 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
554
558
  const ac = assetPayloads.length;
555
559
  console.log("");
556
560
  console.log(` ${green("━".repeat(40))}`);
557
- ok(`${bold("Live")} at ${cyan(`https://${data.url}`)}`);
561
+ ok(isDraft ? `${bold("Draft saved")} · ${data.isPublished === false ? "not public" : "live site unchanged"}` : `${bold("Live")} at ${cyan(`https://${data.url}`)}`);
558
562
  console.log(` ${dim(`${pc} page${pc !== 1 ? "s" : ""} · ${ac} asset${ac !== 1 ? "s" : ""} · ${data.ttl} TTL`)}`);
559
563
  console.log("");
560
564
 
561
- for (const { path: pagePath } of pagePayloads) {
565
+ for (const { path: pagePath } of (isDraft ? [] : pagePayloads)) {
562
566
  const urlPath = pagePath === "/" ? "" : pagePath;
563
567
  console.log(` ${cyan(`https://${data.url}${urlPath}`)}`);
564
568
  }
@@ -666,7 +670,9 @@ export function findAllFiles(baseDir, currentDir) {
666
670
  if (entry.isDirectory()) {
667
671
  results.push(...findAllFiles(baseDir, fullPath));
668
672
  } else if (entry.isFile()) {
669
- const relativePath = fullPath.slice(baseDir.length + 1);
673
+ // Always with "/": on Windows the path has "\", which would publish
674
+ // assets\logo.png, an address no browser can ask for.
675
+ const relativePath = fullPath.slice(baseDir.length + 1).split(sep).join("/");
670
676
  let size;
671
677
  try {
672
678
  size = statSync(fullPath).size;
@@ -2,7 +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.
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).
6
7
  */
7
8
  export async function list({ json = false } = {}) {
8
9
  const data = await get("/api/sites");
@@ -13,8 +14,9 @@ export async function list({ json = false } = {}) {
13
14
  }
14
15
 
15
16
  const sites = data.sites || [];
17
+ const shared = data.shared || [];
16
18
 
17
- if (sites.length === 0) {
19
+ if (sites.length === 0 && shared.length === 0) {
18
20
  console.log("");
19
21
  console.log(` ${dim("No sites yet.")} Deploy one with: ${cyan("htmlhost deploy index.html")}`);
20
22
  console.log("");
@@ -33,6 +35,7 @@ export async function list({ json = false } = {}) {
33
35
  pad("TITLE", clampedTitleW),
34
36
  pad("SIZE", 10),
35
37
  pad("TTL", 8),
38
+ pad("STATUS", 12),
36
39
  pad("EXPIRES", 14),
37
40
  ].join("");
38
41
  console.log(` ${dim(hdr)}`);
@@ -44,10 +47,21 @@ export async function list({ json = false } = {}) {
44
47
  const multiIndicator = s.isMultiPage ? "📄 " : "";
45
48
  const title = truncate(s.title && s.title !== s.slug ? s.title : "", clampedTitleW - 2);
46
49
  console.log(
47
- ` ${pad(cyan(multiIndicator + s.slug), slugW + (multiIndicator ? 2 : 0))}${pad(dim(title), clampedTitleW)}${pad(s.size, 10)}${pad(s.ttl, 8)}${expiresColor(expires)}`
50
+ ` ${pad(cyan(multiIndicator + s.slug), slugW + (multiIndicator ? 2 : 0))}${pad(dim(title), clampedTitleW)}${pad(s.size, 10)}${pad(s.ttl, 8)}${pad(s.isPublished === false ? "Draft" : "Published", 12)}${expiresColor(expires)}`
48
51
  );
49
52
  }
50
53
 
54
+ if (shared.length) {
55
+ const sharedSlugW = Math.max(14, ...shared.map((s) => s.slug.length)) + 2;
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")}`);
59
+ for (const s of shared) {
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 || "")}`);
62
+ }
63
+ }
64
+
51
65
  console.log("");
52
66
  console.log(
53
67
  ` ${bold(String(sites.length))} sites · ${formatBytes(data.usage.totalBytes)} used` +
@@ -58,6 +58,8 @@ function diffFile(localPath, remoteContent) {
58
58
  */
59
59
  export async function pull(args) {
60
60
  const slugArg = args.find((a) => !a.startsWith("--"));
61
+ const source = args.includes("--published") ? "published" : args.includes("--draft") ? "draft" : "working";
62
+ if (args.includes("--published") && args.includes("--draft")) throw new Error("Choose --published or --draft, not both.");
61
63
  const force = args.includes("--force") || args.includes("-f");
62
64
  const dryRun = args.includes("--dry-run");
63
65
 
@@ -90,12 +92,14 @@ export async function pull(args) {
90
92
  // Fetch remote content
91
93
  let data;
92
94
  try {
93
- data = await get(`/api/sites/pull?slug=${encodeURIComponent(slug)}`);
95
+ data = await get(`/api/sites/pull?slug=${encodeURIComponent(slug)}&source=${source}`);
94
96
  } catch (e) {
95
97
  err(`Failed to fetch site: ${e.message}`);
96
98
  process.exit(1);
97
99
  }
98
100
 
101
+ info(`${data.source === "draft" ? "Saved draft" : "Last published files"} · ${data.isPublished === false ? "website is unpublished" : "website is published"}`);
102
+
99
103
  console.log(` ${dim(`Version ${data.version} · Updated ${relTime(data.updatedAt)}`)}`);
100
104
  console.log("");
101
105
 
@@ -0,0 +1,15 @@
1
+ import { post } from "../api.mjs";
2
+ import { readLink } from "../link.mjs";
3
+ import { confirm, ok } from "../ui.mjs";
4
+
5
+ export async function unpublish(args, { json = false } = {}) {
6
+ const slug = (args.find(a => !a.startsWith("-")) || readLink(process.cwd())?.site?.slug)?.replace(/\.htmlhost\.co$/, "");
7
+ if (!slug) throw new Error("Usage: htmlhost unpublish <slug> [--yes]");
8
+ if (!args.includes("--yes")) {
9
+ if (json || !process.stdin.isTTY) throw new Error("Unpublishing takes the website offline. Re-run with --yes after confirming with the owner.");
10
+ if (!await confirm(`Take ${slug}.htmlhost.co and its connected domains offline? Files will be kept as a draft.`)) return;
11
+ }
12
+ const result = await post(`/api/sites/${encodeURIComponent(slug)}/unpublish`, {});
13
+ if (json) console.log(JSON.stringify(result));
14
+ else ok(`${slug} is unpublished. Files, drafts and history are kept.`);
15
+ }
@@ -1,3 +1,4 @@
1
+ import { postPublication } from "../publication.mjs";
1
2
  import { get, post } from "../api.mjs";
2
3
  import { ok, err, info, warn, dim, cyan, bold, confirm, formatBytes } from "../ui.mjs";
3
4
  import { readLink } from "../link.mjs";
@@ -117,7 +118,7 @@ export async function rollback(args, { json = false } = {}) {
117
118
  }
118
119
  }
119
120
 
120
- const result = await post(`/api/sites/${encodeURIComponent(data.slug)}/versions/${target.version}/restore`, {});
121
+ const result = await postPublication(`/api/sites/${encodeURIComponent(data.slug)}/versions/${target.version}/restore`, { confirmPublish: args.includes("--publish") }, { json });
121
122
  if (json) {
122
123
  console.log(JSON.stringify(result, null, 2));
123
124
  return;
@@ -0,0 +1,21 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { post } from "./api.mjs";
3
+
4
+ /** Never let --force, --json or a general deployment confirmation publish a draft. */
5
+ export async function postPublication(path, body, { json = false } = {}) {
6
+ try {
7
+ return await post(path, body);
8
+ } catch (error) {
9
+ if (error.code !== "PUBLICATION_CONFIRMATION_REQUIRED") throw error;
10
+ if (json || !process.stdin.isTTY || !process.stdout.isTTY) {
11
+ error.message += " Ask the user first, then re-run with --publish to confirm making it public; use deploy --draft to keep it private.";
12
+ throw error;
13
+ }
14
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
15
+ let answer;
16
+ try { answer = await prompt.question("This website is unpublished. Make it publicly accessible now? (y/N) "); }
17
+ finally { prompt.close(); }
18
+ if (!/^y(es)?$/i.test(answer.trim())) throw new Error("Publishing cancelled. The website remains unpublished.");
19
+ return post(path, { ...body, confirmPublish: true });
20
+ }
21
+ }