htmlship 0.1.4 → 0.2.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 +13 -1
- package/dist/cli.js +459 -67
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,9 +16,21 @@ npx htmlship delete <slug>
|
|
|
16
16
|
npx htmlship list-mine
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Deploy built apps
|
|
20
|
+
|
|
21
|
+
Build a modern frontend project (React/Vite/etc.) and publish the compiled app as one self-contained page:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx htmlship deploy ./my-app # detect build script, build, inline, publish
|
|
25
|
+
npx htmlship deploy --dry-run # build + inline without publishing
|
|
26
|
+
npx htmlship deploy --build-cmd "vite build" --out dist
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The build runs **locally on your machine** (npm/pnpm/yarn/bun, auto-detected) — never on the server. The output is inlined into a single HTML file (≤ 10 MB) and published with relaxed sandboxing, so the app's JavaScript runs in an isolated, opaque origin (no cookies, no same-origin access, no network egress, no `eval`). The same flow is available to agents through the `deploy_project` MCP tool.
|
|
30
|
+
|
|
19
31
|
## MCP server
|
|
20
32
|
|
|
21
|
-
`htmlship mcp` starts a stdio MCP server with
|
|
33
|
+
`htmlship mcp` starts a stdio MCP server with four tools: `publish_html` (with optional `password`), `deploy_project` (build a local project and publish the compiled app), `fetch_html`, `update_html`.
|
|
22
34
|
|
|
23
35
|
### Claude Desktop / Claude Code / Cursor
|
|
24
36
|
|
package/dist/cli.js
CHANGED
|
@@ -51,22 +51,260 @@ var init_errors = __esm({
|
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// src/build.ts
|
|
55
|
+
import { spawnSync } from "child_process";
|
|
56
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
57
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "path";
|
|
58
|
+
function detectPackageManager(dir) {
|
|
59
|
+
for (const [file, pm] of LOCKFILES) {
|
|
60
|
+
if (existsSync(join(dir, file))) return pm;
|
|
61
|
+
}
|
|
62
|
+
return "npm";
|
|
63
|
+
}
|
|
64
|
+
function detectProject(dir, buildCmdOverride) {
|
|
65
|
+
const pkgPath = join(dir, "package.json");
|
|
66
|
+
if (!existsSync(pkgPath)) {
|
|
67
|
+
throw new HTMLShipError(`no package.json found in ${dir}`);
|
|
68
|
+
}
|
|
69
|
+
let scripts = {};
|
|
70
|
+
try {
|
|
71
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
72
|
+
if (pkg && typeof pkg === "object" && "scripts" in pkg) {
|
|
73
|
+
const s = pkg.scripts;
|
|
74
|
+
if (s && typeof s === "object") scripts = s;
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new HTMLShipError(`failed to read package.json: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
const pm = detectPackageManager(dir);
|
|
80
|
+
if (buildCmdOverride) {
|
|
81
|
+
return { dir, buildScript: "", packageManager: pm };
|
|
82
|
+
}
|
|
83
|
+
const buildScript = scripts["build"] ? "build" : scripts["build:prod"] ? "build:prod" : null;
|
|
84
|
+
if (!buildScript) {
|
|
85
|
+
throw new HTMLShipError(
|
|
86
|
+
'no "build" or "build:prod" script in package.json (use --build-cmd to specify one)'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { dir, buildScript, packageManager: pm };
|
|
90
|
+
}
|
|
91
|
+
function buildCommand(pm, script) {
|
|
92
|
+
return pm === "yarn" ? `yarn ${script}` : `${pm} run ${script}`;
|
|
93
|
+
}
|
|
94
|
+
function installCommand(pm) {
|
|
95
|
+
return `${pm} install`;
|
|
96
|
+
}
|
|
97
|
+
function runBuild(project, opts = {}) {
|
|
98
|
+
const log = opts.log ?? (() => {
|
|
99
|
+
});
|
|
100
|
+
const timeout = opts.timeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS;
|
|
101
|
+
if (opts.install) {
|
|
102
|
+
exec(installCommand(project.packageManager), project.dir, timeout, log);
|
|
103
|
+
}
|
|
104
|
+
const cmd = opts.buildCmd ?? buildCommand(project.packageManager, project.buildScript);
|
|
105
|
+
exec(cmd, project.dir, timeout, log);
|
|
106
|
+
}
|
|
107
|
+
function exec(command, cwd, timeout, log) {
|
|
108
|
+
log(`$ ${command}`);
|
|
109
|
+
const r = spawnSync(command, { cwd, stdio: "inherit", timeout, shell: true });
|
|
110
|
+
if (r.error) {
|
|
111
|
+
const code = r.error.code;
|
|
112
|
+
if (code === "ENOENT") {
|
|
113
|
+
throw new HTMLShipError(`build failed: \`${command}\` \u2014 command not found`);
|
|
114
|
+
}
|
|
115
|
+
throw new HTMLShipError(`build failed: ${r.error.message}`);
|
|
116
|
+
}
|
|
117
|
+
if (r.signal === "SIGTERM") {
|
|
118
|
+
throw new HTMLShipError(`build timed out after ${Math.round(timeout / 1e3)}s: \`${command}\``);
|
|
119
|
+
}
|
|
120
|
+
if (typeof r.status === "number" && r.status !== 0) {
|
|
121
|
+
throw new HTMLShipError(`build exited with code ${r.status}: \`${command}\``);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function resolveOutputDir(dir, override) {
|
|
125
|
+
const candidates = override ? [override] : ["dist", "build"];
|
|
126
|
+
for (const c of candidates) {
|
|
127
|
+
const p = isAbsolute(c) ? c : join(dir, c);
|
|
128
|
+
if (existsSync(p) && statSync(p).isDirectory()) return p;
|
|
129
|
+
}
|
|
130
|
+
throw new HTMLShipError(
|
|
131
|
+
override ? `build output dir not found: ${override}` : "no build output found (looked for dist/ and build/; use --out to specify)"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
function mimeFor(p) {
|
|
135
|
+
return MIME[extname(p).toLowerCase()] ?? "application/octet-stream";
|
|
136
|
+
}
|
|
137
|
+
function isLocalRef(ref) {
|
|
138
|
+
return ref.length > 0 && !/^(https?:)?\/\//i.test(ref) && !ref.startsWith("data:") && !ref.startsWith("#") && !ref.startsWith("mailto:") && !ref.startsWith("blob:");
|
|
139
|
+
}
|
|
140
|
+
function attr(tag, name) {
|
|
141
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
|
|
142
|
+
if (!m) return null;
|
|
143
|
+
return m[2] ?? m[3] ?? m[4] ?? null;
|
|
144
|
+
}
|
|
145
|
+
function resolveLocal(root, baseDir, ref) {
|
|
146
|
+
let clean = ref.split("#")[0].split("?")[0];
|
|
147
|
+
if (!clean) return null;
|
|
148
|
+
let base = baseDir;
|
|
149
|
+
if (clean.startsWith("/")) {
|
|
150
|
+
base = root;
|
|
151
|
+
clean = clean.replace(/^\/+/, "");
|
|
152
|
+
}
|
|
153
|
+
let abs;
|
|
154
|
+
try {
|
|
155
|
+
abs = resolve(base, decodeURIComponent(clean));
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const rel = relative(root, abs);
|
|
160
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
161
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
|
|
162
|
+
return abs;
|
|
163
|
+
}
|
|
164
|
+
function dataUri(file) {
|
|
165
|
+
return `data:${mimeFor(file)};base64,${readFileSync(file).toString("base64")}`;
|
|
166
|
+
}
|
|
167
|
+
function inlineCssUrls(root, cssDir, css, warnings) {
|
|
168
|
+
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (whole, _q, ref) => {
|
|
169
|
+
if (!isLocalRef(ref)) return whole;
|
|
170
|
+
const file = resolveLocal(root, cssDir, ref);
|
|
171
|
+
if (!file) {
|
|
172
|
+
warnings.push(`unresolved css asset: ${ref}`);
|
|
173
|
+
return whole;
|
|
174
|
+
}
|
|
175
|
+
return `url(${dataUri(file)})`;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function inlineHtml(root, htmlFile) {
|
|
179
|
+
if (!existsSync(htmlFile)) {
|
|
180
|
+
throw new HTMLShipError(`entry HTML not found: ${htmlFile}`);
|
|
181
|
+
}
|
|
182
|
+
const htmlDir = dirname(htmlFile);
|
|
183
|
+
const warnings = [];
|
|
184
|
+
let html = readFileSync(htmlFile, "utf8");
|
|
185
|
+
const localFile = (ref) => {
|
|
186
|
+
if (!isLocalRef(ref)) return null;
|
|
187
|
+
const file = resolveLocal(root, htmlDir, ref);
|
|
188
|
+
if (!file) {
|
|
189
|
+
warnings.push(`unresolved asset: ${ref}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return file;
|
|
193
|
+
};
|
|
194
|
+
html = html.replace(/<link\b[^>]*>/gi, (tag) => {
|
|
195
|
+
const rel = attr(tag, "rel")?.toLowerCase() ?? "";
|
|
196
|
+
const href = attr(tag, "href");
|
|
197
|
+
if (!href) return tag;
|
|
198
|
+
if (rel === "stylesheet") {
|
|
199
|
+
const file = localFile(href);
|
|
200
|
+
if (!file) return tag;
|
|
201
|
+
const css = inlineCssUrls(root, dirname(file), readFileSync(file, "utf8"), warnings);
|
|
202
|
+
return `<style>${css}</style>`;
|
|
203
|
+
}
|
|
204
|
+
if (rel === "modulepreload" || rel === "preload" || rel === "prefetch") {
|
|
205
|
+
return isLocalRef(href) ? "" : tag;
|
|
206
|
+
}
|
|
207
|
+
if (rel === "icon" || rel === "shortcut icon" || rel === "apple-touch-icon") {
|
|
208
|
+
const file = localFile(href);
|
|
209
|
+
return file ? tag.replace(href, dataUri(file)) : tag;
|
|
210
|
+
}
|
|
211
|
+
return tag;
|
|
212
|
+
});
|
|
213
|
+
html = html.replace(/<script\b([^>]*)>\s*<\/script>/gi, (tag, attrs) => {
|
|
214
|
+
const src = attr(tag, "src");
|
|
215
|
+
if (!src) return tag;
|
|
216
|
+
const file = localFile(src);
|
|
217
|
+
if (!file) return tag;
|
|
218
|
+
const js = readFileSync(file, "utf8").replace(/<\/script/gi, "<\\/script");
|
|
219
|
+
const typeAttr = /\btype\s*=\s*["']module["']/i.test(attrs) ? ' type="module"' : "";
|
|
220
|
+
return `<script${typeAttr}>${js}</script>`;
|
|
221
|
+
});
|
|
222
|
+
html = html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
223
|
+
const src = attr(tag, "src");
|
|
224
|
+
if (!src || !isLocalRef(src)) return tag;
|
|
225
|
+
const file = localFile(src);
|
|
226
|
+
return file ? tag.replace(src, dataUri(file)) : tag;
|
|
227
|
+
});
|
|
228
|
+
return { html, warnings, bytes: Buffer.byteLength(html, "utf8") };
|
|
229
|
+
}
|
|
230
|
+
function formatBytes(n) {
|
|
231
|
+
if (n < 1024) return `${n} B`;
|
|
232
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
233
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
234
|
+
}
|
|
235
|
+
function buildAndInline(dir, opts = {}) {
|
|
236
|
+
const log = opts.log ?? (() => {
|
|
237
|
+
});
|
|
238
|
+
const project = detectProject(dir, opts.buildCmd);
|
|
239
|
+
log(`project: ${dir}`);
|
|
240
|
+
log(`pkg mgr: ${project.packageManager}`);
|
|
241
|
+
log(`build: ${opts.buildCmd ?? buildCommand(project.packageManager, project.buildScript)}`);
|
|
242
|
+
runBuild(project, {
|
|
243
|
+
install: opts.install,
|
|
244
|
+
buildCmd: opts.buildCmd,
|
|
245
|
+
timeoutMs: opts.timeoutMs,
|
|
246
|
+
log
|
|
247
|
+
});
|
|
248
|
+
const outDir = resolveOutputDir(dir, opts.out);
|
|
249
|
+
const entry = join(outDir, opts.entry ?? "index.html");
|
|
250
|
+
const result = inlineHtml(outDir, entry);
|
|
251
|
+
log(`output: ${outDir}`);
|
|
252
|
+
log(`inlined: ${formatBytes(result.bytes)} single HTML`);
|
|
253
|
+
for (const w of result.warnings) log(`warning: ${w}`);
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
var LOCKFILES, DEFAULT_BUILD_TIMEOUT_MS, MIME;
|
|
257
|
+
var init_build = __esm({
|
|
258
|
+
"src/build.ts"() {
|
|
259
|
+
"use strict";
|
|
260
|
+
init_errors();
|
|
261
|
+
LOCKFILES = [
|
|
262
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
263
|
+
["yarn.lock", "yarn"],
|
|
264
|
+
["bun.lockb", "bun"],
|
|
265
|
+
["bun.lock", "bun"],
|
|
266
|
+
["package-lock.json", "npm"]
|
|
267
|
+
];
|
|
268
|
+
DEFAULT_BUILD_TIMEOUT_MS = 5 * 6e4;
|
|
269
|
+
MIME = {
|
|
270
|
+
".png": "image/png",
|
|
271
|
+
".jpg": "image/jpeg",
|
|
272
|
+
".jpeg": "image/jpeg",
|
|
273
|
+
".gif": "image/gif",
|
|
274
|
+
".svg": "image/svg+xml",
|
|
275
|
+
".webp": "image/webp",
|
|
276
|
+
".avif": "image/avif",
|
|
277
|
+
".ico": "image/x-icon",
|
|
278
|
+
".woff": "font/woff",
|
|
279
|
+
".woff2": "font/woff2",
|
|
280
|
+
".ttf": "font/ttf",
|
|
281
|
+
".otf": "font/otf",
|
|
282
|
+
".css": "text/css",
|
|
283
|
+
".js": "text/javascript",
|
|
284
|
+
".mjs": "text/javascript",
|
|
285
|
+
".json": "application/json"
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
54
290
|
// src/version.ts
|
|
55
291
|
var VERSION;
|
|
56
292
|
var init_version = __esm({
|
|
57
293
|
"src/version.ts"() {
|
|
58
294
|
"use strict";
|
|
59
|
-
VERSION = "0.
|
|
295
|
+
VERSION = "0.2.0";
|
|
60
296
|
}
|
|
61
297
|
});
|
|
62
298
|
|
|
63
299
|
// src/client.ts
|
|
64
|
-
var DEFAULT_API_URL, HTMLShipClient;
|
|
300
|
+
var MAX_PAYLOAD_BYTES, DEFAULT_API_URL, HTMLShipClient;
|
|
65
301
|
var init_client = __esm({
|
|
66
302
|
"src/client.ts"() {
|
|
67
303
|
"use strict";
|
|
304
|
+
init_build();
|
|
68
305
|
init_errors();
|
|
69
306
|
init_version();
|
|
307
|
+
MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
|
70
308
|
DEFAULT_API_URL = "https://api.htmlship.com";
|
|
71
309
|
HTMLShipClient = class {
|
|
72
310
|
baseUrl;
|
|
@@ -92,6 +330,28 @@ var init_client = __esm({
|
|
|
92
330
|
if (options.parentSlug != null) body["parent_slug"] = options.parentSlug;
|
|
93
331
|
return await this.request("POST", "/api/v1/pages", { body });
|
|
94
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Build a local frontend project, inline its output into one self-contained
|
|
335
|
+
* HTML document, and publish it as a relaxed (sandboxed-script) page. The
|
|
336
|
+
* build runs on this machine — never on the server.
|
|
337
|
+
*/
|
|
338
|
+
async deploy(projectDir, options = {}) {
|
|
339
|
+
const { html, bytes } = buildAndInline(projectDir, {
|
|
340
|
+
buildCmd: options.buildCmd,
|
|
341
|
+
out: options.out,
|
|
342
|
+
entry: options.entry,
|
|
343
|
+
install: options.install
|
|
344
|
+
});
|
|
345
|
+
if (bytes > MAX_PAYLOAD_BYTES) {
|
|
346
|
+
throw new HTMLShipError(`inlined page is ${bytes} bytes, exceeds the 10 MB limit`);
|
|
347
|
+
}
|
|
348
|
+
return await this.publish(html, {
|
|
349
|
+
title: options.title ?? null,
|
|
350
|
+
password: options.password ?? null,
|
|
351
|
+
expiresIn: options.expiresIn ?? null,
|
|
352
|
+
sandboxMode: "relaxed"
|
|
353
|
+
});
|
|
354
|
+
}
|
|
95
355
|
async get(slug) {
|
|
96
356
|
return await this.request("GET", `/api/v1/pages/${encodeURIComponent(slug)}`);
|
|
97
357
|
}
|
|
@@ -240,6 +500,52 @@ function buildMcpServer(client) {
|
|
|
240
500
|
}
|
|
241
501
|
}
|
|
242
502
|
);
|
|
503
|
+
server.registerTool(
|
|
504
|
+
"deploy_project",
|
|
505
|
+
{
|
|
506
|
+
description: "Build a local frontend project (npm/pnpm/yarn/bun) and publish the compiled app as one self-contained, script-enabled page. Runs the project's build script ON THIS MACHINE, inlines the output (dist/ or build/) into a single HTML file, and publishes it with relaxed sandboxing so its JavaScript runs in an isolated origin. The owner_key returned is the only credential to update or delete this page later \u2014 save it.",
|
|
507
|
+
inputSchema: {
|
|
508
|
+
dir: z.string().describe("Path to the project directory (must contain package.json)."),
|
|
509
|
+
build_cmd: z.string().optional().describe("Override the build command (default: detected build/build:prod script)."),
|
|
510
|
+
out: z.string().optional().describe("Build output directory (default: auto-detect dist/ or build/)."),
|
|
511
|
+
install: z.boolean().optional().describe("Run dependency install before building."),
|
|
512
|
+
title: z.string().optional().describe("Optional human-readable title."),
|
|
513
|
+
password: z.string().optional().describe("Optional password required before viewing."),
|
|
514
|
+
expires_in: z.number().int().min(1).max(60 * 24 * 7).optional().describe("Optional TTL in minutes (1\u201310080, i.e. up to 7 days).")
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
async ({ dir, build_cmd, out, install, title, password, expires_in }) => {
|
|
518
|
+
try {
|
|
519
|
+
const page = await c.deploy(dir, {
|
|
520
|
+
buildCmd: build_cmd ?? void 0,
|
|
521
|
+
out: out ?? void 0,
|
|
522
|
+
install: install ?? void 0,
|
|
523
|
+
title: title ?? null,
|
|
524
|
+
password: password ?? null,
|
|
525
|
+
expiresIn: expires_in ?? null
|
|
526
|
+
});
|
|
527
|
+
return {
|
|
528
|
+
content: [
|
|
529
|
+
{
|
|
530
|
+
type: "text",
|
|
531
|
+
text: JSON.stringify(
|
|
532
|
+
{
|
|
533
|
+
url: page.url,
|
|
534
|
+
slug: page.slug,
|
|
535
|
+
owner_key: page.owner_key,
|
|
536
|
+
expires_at: page.expires_at
|
|
537
|
+
},
|
|
538
|
+
null,
|
|
539
|
+
2
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
]
|
|
543
|
+
};
|
|
544
|
+
} catch (err) {
|
|
545
|
+
return errorResult("deploy_project", err);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
);
|
|
243
549
|
server.registerTool(
|
|
244
550
|
"fetch_html",
|
|
245
551
|
{
|
|
@@ -328,16 +634,16 @@ init_errors();
|
|
|
328
634
|
import { createInterface } from "readline/promises";
|
|
329
635
|
|
|
330
636
|
// src/keystore.ts
|
|
331
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
637
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync, chmodSync } from "fs";
|
|
332
638
|
import { homedir } from "os";
|
|
333
|
-
import { join } from "path";
|
|
639
|
+
import { join as join2 } from "path";
|
|
334
640
|
function createKeyStore(overrideDir) {
|
|
335
|
-
const dir = overrideDir ?? process.env["HTMLSHIP_KEYS_DIR"] ??
|
|
336
|
-
const file =
|
|
641
|
+
const dir = overrideDir ?? process.env["HTMLSHIP_KEYS_DIR"] ?? join2(homedir(), ".htmlship");
|
|
642
|
+
const file = join2(dir, "keys.json");
|
|
337
643
|
function load() {
|
|
338
|
-
if (!
|
|
644
|
+
if (!existsSync2(file)) return {};
|
|
339
645
|
try {
|
|
340
|
-
const text =
|
|
646
|
+
const text = readFileSync2(file, "utf8");
|
|
341
647
|
const parsed = JSON.parse(text);
|
|
342
648
|
if (parsed && typeof parsed === "object") return parsed;
|
|
343
649
|
return {};
|
|
@@ -346,7 +652,7 @@ function createKeyStore(overrideDir) {
|
|
|
346
652
|
}
|
|
347
653
|
}
|
|
348
654
|
function save(data) {
|
|
349
|
-
if (!
|
|
655
|
+
if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
|
|
350
656
|
const sorted = {};
|
|
351
657
|
for (const key of Object.keys(data).sort()) {
|
|
352
658
|
sorted[key] = data[key];
|
|
@@ -388,23 +694,29 @@ function createKeyStore(overrideDir) {
|
|
|
388
694
|
|
|
389
695
|
// src/commands/delete.ts
|
|
390
696
|
function registerDelete(program) {
|
|
391
|
-
program.command("delete").description("Soft-delete a page.").argument("<slug>", "Page slug").option("--owner-key <key>", "Owner key (defaults to local store)").option("-y, --yes", "Skip confirmation").action(async function(slug, opts) {
|
|
697
|
+
program.command("delete").description("Soft-delete a page.").argument("<slug>", "Page slug").option("--owner-key <key>", "Owner key (defaults to local store, else prompted)").option("-y, --yes", "Skip confirmation").action(async function(slug, opts) {
|
|
392
698
|
const keys = createKeyStore();
|
|
393
|
-
|
|
394
|
-
if (!ownerKey) {
|
|
395
|
-
throw new HTMLShipError(
|
|
396
|
-
`no owner key for '${slug}' in ${keys.file}; pass --owner-key explicitly`
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
if (!opts.yes) {
|
|
699
|
+
let ownerKey = opts.ownerKey ?? keys.lookupOwnerKey(slug);
|
|
700
|
+
if (!opts.yes || !ownerKey) {
|
|
400
701
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
702
|
+
try {
|
|
703
|
+
if (!opts.yes) {
|
|
704
|
+
const answer = await rl.question(`Delete page '${slug}'? [y/N] `);
|
|
705
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
706
|
+
process.stderr.write("Aborted.\n");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!ownerKey) {
|
|
711
|
+
ownerKey = (await rl.question(`owner_key for '${slug}': `)).trim();
|
|
712
|
+
}
|
|
713
|
+
} finally {
|
|
714
|
+
rl.close();
|
|
406
715
|
}
|
|
407
716
|
}
|
|
717
|
+
if (!ownerKey) {
|
|
718
|
+
throw new HTMLShipError("owner_key is required");
|
|
719
|
+
}
|
|
408
720
|
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
409
721
|
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
410
722
|
await client.delete(slug, ownerKey);
|
|
@@ -414,6 +726,117 @@ function registerDelete(program) {
|
|
|
414
726
|
});
|
|
415
727
|
}
|
|
416
728
|
|
|
729
|
+
// src/commands/deploy.ts
|
|
730
|
+
init_build();
|
|
731
|
+
init_client();
|
|
732
|
+
init_errors();
|
|
733
|
+
import { resolve as resolve2 } from "path";
|
|
734
|
+
|
|
735
|
+
// src/io.ts
|
|
736
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
737
|
+
function readHtmlFromSource(source, fileFlag) {
|
|
738
|
+
if (fileFlag) {
|
|
739
|
+
return readFileSync3(fileFlag, "utf8");
|
|
740
|
+
}
|
|
741
|
+
if (source === "-" || source === void 0) {
|
|
742
|
+
if (source === void 0 && process.stdin.isTTY) {
|
|
743
|
+
throw new CliUsageError("Provide a file path or pipe HTML on stdin.");
|
|
744
|
+
}
|
|
745
|
+
return readStdinSync();
|
|
746
|
+
}
|
|
747
|
+
return readFileSync3(source, "utf8");
|
|
748
|
+
}
|
|
749
|
+
var CliUsageError = class extends Error {
|
|
750
|
+
constructor(message) {
|
|
751
|
+
super(message);
|
|
752
|
+
this.name = "CliUsageError";
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
function readStdinSync() {
|
|
756
|
+
try {
|
|
757
|
+
return readFileSync3(0, "utf8");
|
|
758
|
+
} catch (err) {
|
|
759
|
+
throw new CliUsageError(`failed to read stdin: ${err.message}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function tryClipboardCopy(text) {
|
|
763
|
+
try {
|
|
764
|
+
const mod = await import("clipboardy");
|
|
765
|
+
await mod.default.write(text);
|
|
766
|
+
return true;
|
|
767
|
+
} catch {
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/commands/deploy.ts
|
|
773
|
+
var MAX_BYTES = 10 * 1024 * 1024;
|
|
774
|
+
function registerDeploy(program) {
|
|
775
|
+
program.command("deploy").description(
|
|
776
|
+
"Build a frontend project and publish the compiled app as one self-contained page."
|
|
777
|
+
).argument("[dir]", "project directory (default: current directory)", ".").option("--build-cmd <cmd>", "Override the build command (default: detected from package.json)").option("--out <dir>", "Build output directory (default: auto-detect dist/ or build/)").option("--entry <file>", "Entry HTML within the output dir (default: index.html)").option("--install", "Run dependency install before building").option("--dry-run", "Build and inline, but report a summary instead of publishing").option("--title <title>", "Optional title").option("--password <password>", "Password-protect the page").option("--expires-in <minutes>", "Minutes until expiry (1\u201310080, i.e. up to 7 days)").option("--no-clipboard", "Don't copy URL to clipboard").option("-q, --quiet", "Print only the URL").action(async function(dir, opts) {
|
|
778
|
+
const projectDir = resolve2(dir);
|
|
779
|
+
const log = opts.quiet ? () => {
|
|
780
|
+
} : (m) => process.stderr.write(`${m}
|
|
781
|
+
`);
|
|
782
|
+
const { html, bytes } = buildAndInline(projectDir, {
|
|
783
|
+
buildCmd: opts.buildCmd,
|
|
784
|
+
out: opts.out,
|
|
785
|
+
entry: opts.entry,
|
|
786
|
+
install: opts.install,
|
|
787
|
+
log
|
|
788
|
+
});
|
|
789
|
+
if (bytes > MAX_BYTES) {
|
|
790
|
+
throw new HTMLShipError(
|
|
791
|
+
`inlined page is ${formatBytes(bytes)}, exceeds the ${formatBytes(MAX_BYTES)} limit`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (opts.dryRun) {
|
|
795
|
+
log("dry-run: built and inlined OK; not published");
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
799
|
+
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
800
|
+
let page;
|
|
801
|
+
try {
|
|
802
|
+
page = await client.publish(html, {
|
|
803
|
+
title: opts.title ?? null,
|
|
804
|
+
password: opts.password ?? null,
|
|
805
|
+
expiresIn: opts.expiresIn ? Number.parseInt(opts.expiresIn, 10) : null,
|
|
806
|
+
sandboxMode: "relaxed"
|
|
807
|
+
});
|
|
808
|
+
} catch (err) {
|
|
809
|
+
throw new HTMLShipError(`deploy failed: ${err.message}`);
|
|
810
|
+
}
|
|
811
|
+
const keys = createKeyStore();
|
|
812
|
+
keys.remember(page.slug, {
|
|
813
|
+
owner_key: page.owner_key,
|
|
814
|
+
url: page.url,
|
|
815
|
+
title: opts.title ?? null
|
|
816
|
+
});
|
|
817
|
+
if (opts.quiet) {
|
|
818
|
+
process.stdout.write(`${page.url}
|
|
819
|
+
`);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
process.stdout.write(`${page.url}
|
|
823
|
+
`);
|
|
824
|
+
process.stderr.write(`slug: ${page.slug}
|
|
825
|
+
`);
|
|
826
|
+
process.stderr.write(`owner_key: ${page.owner_key} (saved to ${keys.file})
|
|
827
|
+
`);
|
|
828
|
+
process.stderr.write("sandbox: relaxed (scripts run in an isolated origin)\n");
|
|
829
|
+
if (page.expires_at) {
|
|
830
|
+
process.stderr.write(`expires: ${page.expires_at}
|
|
831
|
+
`);
|
|
832
|
+
}
|
|
833
|
+
if (opts.clipboard !== false) {
|
|
834
|
+
const copied = await tryClipboardCopy(page.url);
|
|
835
|
+
if (copied) process.stderr.write("(URL copied to clipboard)\n");
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
417
840
|
// src/commands/get.ts
|
|
418
841
|
init_client();
|
|
419
842
|
init_errors();
|
|
@@ -492,45 +915,6 @@ function registerListMine(program) {
|
|
|
492
915
|
// src/commands/publish.ts
|
|
493
916
|
init_client();
|
|
494
917
|
init_errors();
|
|
495
|
-
|
|
496
|
-
// src/io.ts
|
|
497
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
498
|
-
function readHtmlFromSource(source, fileFlag) {
|
|
499
|
-
if (fileFlag) {
|
|
500
|
-
return readFileSync2(fileFlag, "utf8");
|
|
501
|
-
}
|
|
502
|
-
if (source === "-" || source === void 0) {
|
|
503
|
-
if (source === void 0 && process.stdin.isTTY) {
|
|
504
|
-
throw new CliUsageError("Provide a file path or pipe HTML on stdin.");
|
|
505
|
-
}
|
|
506
|
-
return readStdinSync();
|
|
507
|
-
}
|
|
508
|
-
return readFileSync2(source, "utf8");
|
|
509
|
-
}
|
|
510
|
-
var CliUsageError = class extends Error {
|
|
511
|
-
constructor(message) {
|
|
512
|
-
super(message);
|
|
513
|
-
this.name = "CliUsageError";
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
function readStdinSync() {
|
|
517
|
-
try {
|
|
518
|
-
return readFileSync2(0, "utf8");
|
|
519
|
-
} catch (err) {
|
|
520
|
-
throw new CliUsageError(`failed to read stdin: ${err.message}`);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
async function tryClipboardCopy(text) {
|
|
524
|
-
try {
|
|
525
|
-
const mod = await import("clipboardy");
|
|
526
|
-
await mod.default.write(text);
|
|
527
|
-
return true;
|
|
528
|
-
} catch {
|
|
529
|
-
return false;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// src/commands/publish.ts
|
|
534
918
|
function registerPublish(program) {
|
|
535
919
|
program.command("publish").description("Publish an HTML document and get a URL.").argument("[source]", "file path, '-' for stdin (default: stdin if piped)").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional title").option("--password <password>", "Password-protect the page").option("--expires-in <minutes>", "Minutes until expiry (1\u201310080, i.e. up to 7 days)").option("--no-clipboard", "Don't copy URL to clipboard").option("-q, --quiet", "Print only the URL").action(async function(source, opts) {
|
|
536
920
|
const html = readHtmlFromSource(source, opts.file);
|
|
@@ -567,7 +951,7 @@ function registerPublish(program) {
|
|
|
567
951
|
process.stderr.write(`expires: ${page.expires_at}
|
|
568
952
|
`);
|
|
569
953
|
}
|
|
570
|
-
if (opts.
|
|
954
|
+
if (opts.clipboard !== false) {
|
|
571
955
|
const copied = await tryClipboardCopy(page.url);
|
|
572
956
|
if (copied) process.stderr.write("(URL copied to clipboard)\n");
|
|
573
957
|
}
|
|
@@ -577,15 +961,22 @@ function registerPublish(program) {
|
|
|
577
961
|
// src/commands/update.ts
|
|
578
962
|
init_client();
|
|
579
963
|
init_errors();
|
|
964
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
580
965
|
function registerUpdate(program) {
|
|
581
|
-
program.command("update").description("Replace HTML for an existing page.").argument("<slug>", "Page slug").argument("[source]", "file path, '-' for stdin").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional new title").option("--owner-key <key>", "Owner key (defaults to local store)").action(async function(slug, source, opts) {
|
|
966
|
+
program.command("update").description("Replace HTML for an existing page.").argument("<slug>", "Page slug").argument("[source]", "file path, '-' for stdin").option("-f, --file <path>", "HTML file path").option("--title <title>", "Optional new title").option("--owner-key <key>", "Owner key (defaults to local store, else prompted)").action(async function(slug, source, opts) {
|
|
582
967
|
const html = readHtmlFromSource(source, opts.file);
|
|
583
968
|
const keys = createKeyStore();
|
|
584
|
-
|
|
969
|
+
let ownerKey = opts.ownerKey ?? keys.lookupOwnerKey(slug);
|
|
585
970
|
if (!ownerKey) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
971
|
+
const rl = createInterface2({ input: process.stdin, output: process.stderr });
|
|
972
|
+
try {
|
|
973
|
+
ownerKey = (await rl.question(`owner_key for '${slug}': `)).trim();
|
|
974
|
+
} finally {
|
|
975
|
+
rl.close();
|
|
976
|
+
}
|
|
977
|
+
if (!ownerKey) {
|
|
978
|
+
throw new HTMLShipError("owner_key is required");
|
|
979
|
+
}
|
|
589
980
|
}
|
|
590
981
|
const apiUrl = this.parent?.opts()?.apiUrl;
|
|
591
982
|
const client = new HTMLShipClient({ baseUrl: apiUrl });
|
|
@@ -605,6 +996,7 @@ function buildProgram() {
|
|
|
605
996
|
"API base URL (default: https://api.htmlship.com or $HTMLSHIP_API_URL)"
|
|
606
997
|
);
|
|
607
998
|
registerPublish(program);
|
|
999
|
+
registerDeploy(program);
|
|
608
1000
|
registerGet(program);
|
|
609
1001
|
registerUpdate(program);
|
|
610
1002
|
registerDelete(program);
|