htmlhost-cli 2.6.0 → 2.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "htmlhost-cli",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Deploy HTML files from the terminal — htmlhost.co CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,5 +26,8 @@
26
26
  "repository": {
27
27
  "type": "git",
28
28
  "url": "git+https://github.com/htmlhost/cli.git"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test test/"
29
32
  }
30
33
  }
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.6.0";
8
+ const VERSION = "2.7.0";
9
9
 
10
10
  const HELP = `
11
11
  ${bold("htmlhost")} ${dim(`v${VERSION}`)} — deploy HTML from the terminal
@@ -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 { writeLink, directorySite } from "../link.mjs";
13
14
  import {
14
15
  ok,
15
16
  err,
@@ -24,7 +25,6 @@ import {
24
25
  formatBytes,
25
26
  } from "../ui.mjs";
26
27
 
27
- const LINK_FILE = ".htmlhost";
28
28
 
29
29
  /**
30
30
  * Map a page path to a local file path.
@@ -138,32 +138,16 @@ export async function clone(args) {
138
138
  }
139
139
 
140
140
  // --- Write .htmlhost link file ---
141
- const isMultiPage = pageCount > 1;
142
- const linkData = isMultiPage
143
- ? {
144
- multipage: {
145
- slug: cleanSlug,
146
- url: `${cleanSlug}.htmlhost.co`,
147
- pageCount,
148
- },
149
- sync: {
150
- version: data.version,
151
- updatedAt: data.updatedAt,
152
- pulledAt: new Date().toISOString(),
153
- },
154
- }
155
- : {
156
- slug: cleanSlug,
157
- url: `${cleanSlug}.htmlhost.co`,
158
- sync: {
159
- version: data.version,
160
- updatedAt: data.updatedAt,
161
- pulledAt: new Date().toISOString(),
162
- },
163
- };
164
-
165
- const linkPath = join(destDir, LINK_FILE);
166
- writeFileSync(linkPath, JSON.stringify(linkData, null, 2) + "\n", "utf8");
141
+ // A clone is always the whole site as a folder, so it links in directory
142
+ // mode whatever the page count — `deploy` then updates this same site.
143
+ writeLink(destDir, {
144
+ site: directorySite(cleanSlug, pageCount),
145
+ sync: {
146
+ version: data.version,
147
+ updatedAt: data.updatedAt,
148
+ pulledAt: new Date().toISOString(),
149
+ },
150
+ });
167
151
 
168
152
  // Teach the user's AI coding tool how htmlhost works. Best-effort: a
169
153
  // failed download mustn't fail the clone.
@@ -1,13 +1,12 @@
1
- import { readFileSync, writeFileSync, statSync, existsSync, readdirSync, mkdirSync } from "node:fs";
2
- import { basename, resolve, dirname, join, extname } from "node:path";
1
+ import { readFileSync, statSync, readdirSync } from "node:fs";
2
+ import { basename, resolve, dirname, join } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
  import { createHash } from "node:crypto";
5
5
  import { post } from "../api.mjs";
6
6
  import { ok, err, info, warn, cyan, dim, bold, yellow, green, formatBytes, mimeFromExt } from "../ui.mjs";
7
7
  import { checkRemoteChanges } from "./pull.mjs";
8
8
  import { fetchAgentGuide, hasAgentGuide, writeAgentGuide } from "./agents.mjs";
9
-
10
- const LINK_FILE = ".htmlhost";
9
+ import { readLink, writeLink, directorySite, directoryDeploySlug, fileDeploySlug } from "../link.mjs";
11
10
 
12
11
  /**
13
12
  * Upload a file to a presigned URL with automatic retry.
@@ -92,27 +91,6 @@ function shouldIgnore(name) {
92
91
  return DEFAULT_IGNORE.includes(name);
93
92
  }
94
93
 
95
- /**
96
- * Read the project link from .htmlhost
97
- */
98
- function readLink(dir) {
99
- const linkPath = join(dir, LINK_FILE);
100
- if (!existsSync(linkPath)) return null;
101
- try {
102
- return JSON.parse(readFileSync(linkPath, "utf8"));
103
- } catch {
104
- return null;
105
- }
106
- }
107
-
108
- /**
109
- * Save project link to .htmlhost
110
- */
111
- function writeLink(dir, data) {
112
- const linkPath = join(dir, LINK_FILE);
113
- writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
114
- }
115
-
116
94
  /**
117
95
  * Simple y/N confirmation prompt.
118
96
  */
@@ -158,11 +136,12 @@ function promptChoice(question, options) {
158
136
  * - Use --new to force fresh deploys
159
137
  */
160
138
  export async function deploy(args, { json = false } = {}) {
161
- let target = args.find((a) => !a.startsWith("--"));
139
+ const { positionals, values } = parseArgs(args, ["--ttl", "--title", "--slug"]);
140
+ const target = positionals[0];
162
141
 
163
- const ttl = getFlag(args, "--ttl");
164
- const title = getFlag(args, "--title");
165
- const slug = getFlag(args, "--slug");
142
+ const ttl = values["--ttl"] ?? null;
143
+ const title = values["--title"] ?? null;
144
+ const slug = values["--slug"] ?? null;
166
145
  const forceNew = args.includes("--new");
167
146
  const skipAssets = args.includes("--no-assets");
168
147
  const skipPullCheck = args.includes("--no-pull-check");
@@ -199,18 +178,19 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
199
178
 
200
179
  if (!slug && !forceNew) {
201
180
  const link = readLink(projectDir);
181
+ const linkedSlug = fileDeploySlug(link);
202
182
 
203
- if (link?.slug && !link?.multipage) {
204
- // Single-file format
205
- if (link.onRedeploy === "overwrite") {
206
- slug = link.slug;
183
+ if (linkedSlug) {
184
+ const onRedeploy = link.site.onRedeploy;
185
+ if (onRedeploy === "overwrite") {
186
+ slug = linkedSlug;
207
187
  isLinked = true;
208
188
  info(`Linked to ${cyan(slug)} ${dim("(auto-overwrite)")}`);
209
- } else if (link.onRedeploy === "new") {
189
+ } else if (onRedeploy === "new") {
210
190
  info(`Creating new site ${dim("(auto-new)")}`);
211
191
  } else {
212
192
  const choice = await promptChoice(
213
- `This project is linked to ${cyan(bold(link.slug + ".htmlhost.co"))}`,
193
+ `This project is linked to ${cyan(bold(linkedSlug + ".htmlhost.co"))}`,
214
194
  [
215
195
  "Overwrite existing site",
216
196
  "Create a new site instead",
@@ -222,7 +202,7 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
222
202
 
223
203
  switch (choice) {
224
204
  case 0:
225
- slug = link.slug;
205
+ slug = linkedSlug;
226
206
  isLinked = true;
227
207
  break;
228
208
  case 1:
@@ -233,13 +213,13 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
233
213
  process.exit(0);
234
214
  break;
235
215
  case 3:
236
- slug = link.slug;
216
+ slug = linkedSlug;
237
217
  isLinked = true;
238
- writeLink(projectDir, { ...link, onRedeploy: "overwrite" });
218
+ writeLink(projectDir, { ...link, site: { ...link.site, onRedeploy: "overwrite" } });
239
219
  info(`Saved preference: ${dim("always overwrite")}`);
240
220
  break;
241
221
  case 4:
242
- writeLink(projectDir, { ...link, onRedeploy: "new" });
222
+ writeLink(projectDir, { ...link, site: { ...link.site, onRedeploy: "new" } });
243
223
  info(`Saved preference: ${dim("always create new")}`);
244
224
  break;
245
225
  }
@@ -293,11 +273,16 @@ async function deploySingleFile(file, { ttl, title, forceNew, skipAssets, slug,
293
273
  const data = await post("/api/sites", body);
294
274
 
295
275
  // Save link
296
- const existingLink = readLink(projectDir);
276
+ const existingLink = readLink(projectDir) || {};
277
+ const onRedeploy = existingLink.site?.mode === "file" ? existingLink.site.onRedeploy : undefined;
297
278
  writeLink(projectDir, {
298
- slug: data.slug,
299
- url: data.url,
300
- ...(existingLink?.onRedeploy ? { onRedeploy: existingLink.onRedeploy } : {}),
279
+ ...existingLink,
280
+ site: {
281
+ slug: data.slug,
282
+ url: data.url,
283
+ mode: "file",
284
+ ...(onRedeploy ? { onRedeploy } : {}),
285
+ },
301
286
  sync: {
302
287
  version: data.version,
303
288
  updatedAt: new Date().toISOString(),
@@ -358,7 +343,8 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
358
343
 
359
344
  // Read existing link data
360
345
  const link = readLink(dirPath);
361
- let existingSlug = explicitSlug || (!forceNew && link?.multipage?.slug) || null;
346
+ const linkedSlug = forceNew ? null : directoryDeploySlug(link);
347
+ let existingSlug = explicitSlug || linkedSlug || null;
362
348
 
363
349
  // Calculate total size
364
350
  const totalSize = allFiles.reduce((sum, f) => sum + f.size, 0);
@@ -490,13 +476,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
490
476
  // Write .htmlhost early so a crash mid-upload doesn't orphan the site
491
477
  if (generatedSlug && !existingSlug) {
492
478
  existingSlug = generatedSlug;
493
- writeLink(dirPath, {
494
- multipage: {
495
- slug: existingSlug,
496
- url: `${existingSlug}.htmlhost.co`,
497
- pageCount: pagePayloads.length,
498
- },
499
- });
479
+ writeLink(dirPath, { site: directorySite(existingSlug, pagePayloads.length) });
500
480
  }
501
481
 
502
482
  for (const u of uploadUrls) {
@@ -529,7 +509,7 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
529
509
  if (existingSlug) {
530
510
  body.slug = existingSlug;
531
511
  // We pass isNewSite only if the generated slug wasn't originally provided
532
- if (!explicitSlug && !(!forceNew && link?.multipage?.slug)) {
512
+ if (!explicitSlug && !linkedSlug) {
533
513
  body.isNewSite = true;
534
514
  }
535
515
  }
@@ -540,11 +520,8 @@ async function deployDirectory(dirPath, { ttl, title, slug: explicitSlug, forceN
540
520
 
541
521
  // Save link
542
522
  writeLink(dirPath, {
543
- multipage: {
544
- slug: data.slug,
545
- url: data.url,
546
- pageCount: data.pageCount || pagePayloads.length,
547
- },
523
+ ...(link || {}),
524
+ site: { ...directorySite(data.slug, data.pageCount || pagePayloads.length), url: data.url },
548
525
  sync: {
549
526
  version: data.version,
550
527
  updatedAt: new Date().toISOString(),
@@ -690,8 +667,24 @@ function findAllFiles(baseDir, currentDir) {
690
667
  return results;
691
668
  }
692
669
 
693
- function getFlag(args, flag) {
694
- const idx = args.indexOf(flag);
695
- if (idx === -1 || idx >= args.length - 1) return null;
696
- return args[idx + 1];
670
+ /**
671
+ * Split args into positionals and flag values. Each flag in `valueFlags`
672
+ * consumes the next arg (or takes `--flag=value`), so `deploy --ttl 1d`
673
+ * doesn't mistake "1d" for the deploy target.
674
+ */
675
+ export function parseArgs(args, valueFlags) {
676
+ const positionals = [];
677
+ const values = {};
678
+ for (let i = 0; i < args.length; i++) {
679
+ const a = args[i];
680
+ const eq = a.indexOf("=");
681
+ const name = eq === -1 ? a : a.slice(0, eq);
682
+ if (valueFlags.includes(name)) {
683
+ if (eq !== -1) values[name] = a.slice(eq + 1);
684
+ else if (i + 1 < args.length) values[name] = args[++i];
685
+ } else if (!a.startsWith("-")) {
686
+ positionals.push(a);
687
+ }
688
+ }
689
+ return { positionals, values };
697
690
  }
@@ -8,6 +8,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "no
8
8
  import { join, dirname, resolve, basename } from "node:path";
9
9
  import { createHash } from "node:crypto";
10
10
  import { get, downloadBuffer } from "../api.mjs";
11
+ import { readLink, writeLink } from "../link.mjs";
11
12
  import {
12
13
  ok,
13
14
  err,
@@ -22,29 +23,6 @@ import {
22
23
  formatBytes,
23
24
  } from "../ui.mjs";
24
25
 
25
- const LINK_FILE = ".htmlhost";
26
-
27
- /**
28
- * Read the project link from .htmlhost
29
- */
30
- function readLink(dir) {
31
- const linkPath = join(dir, LINK_FILE);
32
- if (!existsSync(linkPath)) return null;
33
- try {
34
- return JSON.parse(readFileSync(linkPath, "utf8"));
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- /**
41
- * Save project link to .htmlhost
42
- */
43
- function writeLink(dir, data) {
44
- const linkPath = join(dir, LINK_FILE);
45
- writeFileSync(linkPath, JSON.stringify(data, null, 2) + "\n", "utf8");
46
- }
47
-
48
26
  /**
49
27
  * Map a page path back to a local file path.
50
28
  * "/" → "index.html", "/about" → "about.html", "/blog" → "blog/index.html" or "blog.html"
@@ -89,11 +67,7 @@ export async function pull(args) {
89
67
  // Resolve slug
90
68
  let slug = slugArg;
91
69
  if (!slug) {
92
- if (link?.multipage?.slug) {
93
- slug = link.multipage.slug;
94
- } else if (link?.slug) {
95
- slug = link.slug;
96
- }
70
+ slug = link?.site?.slug;
97
71
  }
98
72
 
99
73
  if (!slug) {
@@ -1,22 +1,10 @@
1
1
  import { get, post } from "../api.mjs";
2
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";
3
+ import { readLink } from "../link.mjs";
5
4
 
6
- const LINK_FILE = ".htmlhost";
7
-
8
- /**
9
- * Read the slug from .htmlhost in the given (or current) directory.
10
- */
5
+ /** The slug linked by .htmlhost in the given (or current) directory. */
11
6
  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.slug || null;
17
- } catch {
18
- return null;
19
- }
7
+ return readLink(dir)?.site?.slug || null;
20
8
  }
21
9
 
22
10
  /**
package/src/link.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The .htmlhost link file — ties a local directory to a hosted site.
3
+ *
4
+ * {
5
+ * "site": {
6
+ * "slug": "my-site",
7
+ * "url": "my-site.htmlhost.co",
8
+ * "mode": "directory", // how the site is deployed from here
9
+ * "pageCount": 1, // directory mode only, informational
10
+ * "onRedeploy": "overwrite" // file mode only, remembered prompt choice
11
+ * },
12
+ * "sync": { "version": 3, "updatedAt": "…", "pulledAt": "…", "deployedAt": "…" },
13
+ * "assets": { "img/a.png": "https://…" } // single-file asset upload cache
14
+ * }
15
+ *
16
+ * `mode` records the workflow, not the page count: "directory" means the whole
17
+ * folder is the site (any number of pages plus assets — what `clone` and
18
+ * `deploy <dir>` produce); "file" means one HTML file deployed on its own.
19
+ *
20
+ * Older CLIs wrote `{ multipage: {slug, url, pageCount} }` for directory sites
21
+ * and a top-level `{ slug, url, onRedeploy }` otherwise — including for cloned
22
+ * one-page sites, so a legacy top-level link is not proof of a single-file
23
+ * deploy. `readLink` accepts both and always returns the current shape;
24
+ * `writeLink` only writes the current shape.
25
+ */
26
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ export const LINK_FILE = ".htmlhost";
30
+
31
+ /** Normalize parsed link JSON (current or legacy) to the current shape, or null. */
32
+ export function normalizeLink(raw) {
33
+ if (!raw || typeof raw !== "object") return null;
34
+ let site = null;
35
+ if (raw.site?.slug) {
36
+ site = { ...raw.site, mode: raw.site.mode === "file" ? "file" : "directory" };
37
+ } else if (raw.multipage?.slug) {
38
+ site = { ...raw.multipage, mode: "directory" };
39
+ } else if (raw.slug) {
40
+ site = {
41
+ slug: raw.slug,
42
+ url: raw.url || `${raw.slug}.htmlhost.co`,
43
+ mode: "file",
44
+ ...(raw.onRedeploy ? { onRedeploy: raw.onRedeploy } : {}),
45
+ };
46
+ }
47
+ const link = {};
48
+ if (site) link.site = site;
49
+ if (raw.sync) link.sync = raw.sync;
50
+ if (raw.assets) link.assets = raw.assets;
51
+ return link;
52
+ }
53
+
54
+ /** Read and normalize `dir/.htmlhost`, or null when absent/unreadable. */
55
+ export function readLink(dir) {
56
+ const linkPath = join(dir, LINK_FILE);
57
+ if (!existsSync(linkPath)) return null;
58
+ try {
59
+ return normalizeLink(JSON.parse(readFileSync(linkPath, "utf8")));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** Write `dir/.htmlhost` in the current shape. */
66
+ export function writeLink(dir, link) {
67
+ const out = {};
68
+ if (link.site) out.site = link.site;
69
+ if (link.sync) out.sync = link.sync;
70
+ if (link.assets) out.assets = link.assets;
71
+ writeFileSync(join(dir, LINK_FILE), JSON.stringify(out, null, 2) + "\n", "utf8");
72
+ }
73
+
74
+ /** Site metadata for a directory-mode link. */
75
+ export function directorySite(slug, pageCount) {
76
+ return { slug, url: `${slug}.htmlhost.co`, mode: "directory", pageCount };
77
+ }
78
+
79
+ /**
80
+ * The slug a directory deploy should update. Any linked site qualifies: a
81
+ * directory deploy of a folder linked to a single-file site promotes it to a
82
+ * directory site (after the usual overwrite confirmation), matching how the
83
+ * platform grows a one-page project into a multi-file one.
84
+ */
85
+ export function directoryDeploySlug(link) {
86
+ return link?.site?.slug || null;
87
+ }
88
+
89
+ /**
90
+ * The slug a single-file deploy may offer to overwrite. Only file-mode links:
91
+ * deploying one file over a directory site would replace all its pages.
92
+ */
93
+ export function fileDeploySlug(link) {
94
+ return link?.site?.mode === "file" ? link.site.slug : null;
95
+ }