hollware 1.0.0-rc.0 → 1.0.0-rc.2
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/dist/{args-CljWh-kZ.mjs → args-D42wi6n2.mjs} +1 -1
- package/dist/{d1-BGChD8_u.mjs → d1-B6dc_ugC.mjs} +5 -70
- package/dist/{deploy-8_Nvbr3b.mjs → deploy-sYjaZ0d4.mjs} +62 -12
- package/dist/dev-B2gk-YHQ.mjs +191 -0
- package/dist/doctor-DjRkQKrh.mjs +135 -0
- package/dist/errors-DJX_jiSP.mjs +18 -0
- package/dist/{export-DprcsjU_.mjs → export-HsextQcU.mjs} +87 -6
- package/dist/{format-BpRYEsGB.mjs → format-Dvk_h5ys.mjs} +1 -18
- package/dist/{import-D9LRrYtb.mjs → import-BpIvnM-l.mjs} +49 -8
- package/dist/import-CsbL7X6d.mjs +1 -0
- package/dist/index.d.mts +38 -0
- package/dist/index.mjs +111 -12
- package/dist/{init-cS_QgC4n.mjs → init-A7Un-Ng_.mjs} +84 -14
- package/dist/{invite-BvW1xuxU.mjs → invite-CsGI2Gut.mjs} +21 -8
- package/dist/jsonc-CA2ezIB6.mjs +68 -0
- package/dist/mcp-config-BU2YmmGE.mjs +46 -0
- package/dist/mcp-config-CDOUrEpU.mjs +52 -0
- package/dist/migrate-C15IVa7w.mjs +1 -0
- package/dist/{migrate-BqdgcoRJ.mjs → migrate-Dx-imcel.mjs} +4 -4
- package/dist/{public-url-B2BXf_pj.mjs → public-url-d_yOsEMO.mjs} +2 -2
- package/dist/{token-CVPHV1K-.mjs → token-BxAQXSQO.mjs} +39 -8
- package/dist/token-DG5H7VPC.mjs +1 -0
- package/dist/token-output-DTn4X40H.mjs +68 -0
- package/dist/{wrangler-BLIfwbcu.mjs → wrangler-C0QYL8dC.mjs} +18 -8
- package/package.json +2 -2
- package/dist/doctor-BT_bTF1M.mjs +0 -113
- package/dist/entropy-5uxirwFD.mjs +0 -27
- package/dist/import-JNLtBmyD.mjs +0 -1
- package/dist/migrate-BleH6hXz.mjs +0 -1
- package/dist/token-B0hS__kw.mjs +0 -1
|
@@ -1,76 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
|
|
2
|
+
import { t as printable } from "./format-Dvk_h5ys.mjs";
|
|
3
|
+
import { n as failureReason } from "./wrangler-C0QYL8dC.mjs";
|
|
4
|
+
import { t as parseJsonc } from "./jsonc-CA2ezIB6.mjs";
|
|
3
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
6
|
import { join } from "node:path";
|
|
5
7
|
import * as z from "zod";
|
|
6
8
|
import { SCOPES, SITE_KEY_MAX_LENGTH, isId, isScope, isSiteKey } from "@hollware/core";
|
|
7
|
-
//#region src/jsonc.ts
|
|
8
|
-
/**
|
|
9
|
-
* Reads JSON with comments, the format of `wrangler.jsonc`: `//` and `/* *\/` comments and a
|
|
10
|
-
* comma before a closing bracket are dropped, and a comment marker inside a string is left
|
|
11
|
-
* alone. It does nothing else: whatever `JSON.parse` rejects after that is an error.
|
|
12
|
-
*/
|
|
13
|
-
function parseJsonc(text) {
|
|
14
|
-
return JSON.parse(stripJsonc(text));
|
|
15
|
-
}
|
|
16
|
-
function stripJsonc(text) {
|
|
17
|
-
const source = text.startsWith("") ? text.slice(1) : text;
|
|
18
|
-
let out = "";
|
|
19
|
-
let mode = "code";
|
|
20
|
-
for (let i = 0; i < source.length; i += 1) {
|
|
21
|
-
const char = source.charAt(i);
|
|
22
|
-
const next = source.charAt(i + 1);
|
|
23
|
-
if (mode === "string") {
|
|
24
|
-
out += char;
|
|
25
|
-
if (char === "\\") {
|
|
26
|
-
out += next;
|
|
27
|
-
i += 1;
|
|
28
|
-
} else if (char === "\"") mode = "code";
|
|
29
|
-
} else if (mode === "line-comment") {
|
|
30
|
-
if (char === "\n") {
|
|
31
|
-
out += char;
|
|
32
|
-
mode = "code";
|
|
33
|
-
}
|
|
34
|
-
} else if (mode === "block-comment") {
|
|
35
|
-
if (char === "*" && next === "/") {
|
|
36
|
-
i += 1;
|
|
37
|
-
mode = "code";
|
|
38
|
-
} else if (char === "\n") out += char;
|
|
39
|
-
} else if (char === "\"") {
|
|
40
|
-
out += char;
|
|
41
|
-
mode = "string";
|
|
42
|
-
} else if (char === "/" && next === "/") {
|
|
43
|
-
i += 1;
|
|
44
|
-
mode = "line-comment";
|
|
45
|
-
} else if (char === "/" && next === "*") {
|
|
46
|
-
i += 1;
|
|
47
|
-
mode = "block-comment";
|
|
48
|
-
} else out += char;
|
|
49
|
-
}
|
|
50
|
-
if (mode === "block-comment") throw new SyntaxError("a /* comment is never closed");
|
|
51
|
-
return dropTrailingCommas(out);
|
|
52
|
-
}
|
|
53
|
-
/** Drops a comma that only whitespace separates from a closing bracket, outside strings. */
|
|
54
|
-
function dropTrailingCommas(code) {
|
|
55
|
-
let out = "";
|
|
56
|
-
let inString = false;
|
|
57
|
-
for (let i = 0; i < code.length; i += 1) {
|
|
58
|
-
const char = code.charAt(i);
|
|
59
|
-
if (inString) {
|
|
60
|
-
out += char;
|
|
61
|
-
if (char === "\\") {
|
|
62
|
-
out += code.charAt(i + 1);
|
|
63
|
-
i += 1;
|
|
64
|
-
} else if (char === "\"") inString = false;
|
|
65
|
-
} else if (char === "\"") {
|
|
66
|
-
out += char;
|
|
67
|
-
inString = true;
|
|
68
|
-
} else if (char === "," && /^\s*[}\]]/.test(code.slice(i + 1))) continue;
|
|
69
|
-
else out += char;
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
|
-
//#endregion
|
|
74
9
|
//#region src/sql.ts
|
|
75
10
|
/**
|
|
76
11
|
* Everything the CLI says to the database. A value a person types is checked first and then
|
|
@@ -322,4 +257,4 @@ function jsonOf(stdout) {
|
|
|
322
257
|
} catch {}
|
|
323
258
|
}
|
|
324
259
|
//#endregion
|
|
325
|
-
export {
|
|
260
|
+
export { parseSiteKey as _, runFile as a, sitesAndOwnerQuery as b, insertInvitation as c, ownerOfSiteQuery as d, ownerQuery as f, parseScopes as g, parseOwnerName as h, resolveDatabase as i, insertToken as l, parseLabel as m, describeDatabase as n, anotherSiteScript as o, parseExpiresInDays as p, query as r, initScript as s, chooseTarget as t, listTokens as u, parseTokenId as v, sitesQuery as x, revokeToken as y };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { t as parseFlags } from "./args-
|
|
4
|
-
import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-
|
|
5
|
-
import { t as publicUrlOf } from "./public-url-
|
|
6
|
-
import { i as migrate } from "./migrate-
|
|
1
|
+
import { t as CliError } from "./errors-DJX_jiSP.mjs";
|
|
2
|
+
import { i as stripAnsi, n as failureReason } from "./wrangler-C0QYL8dC.mjs";
|
|
3
|
+
import { t as parseFlags } from "./args-D42wi6n2.mjs";
|
|
4
|
+
import { f as ownerQuery, i as resolveDatabase, r as query } from "./d1-B6dc_ugC.mjs";
|
|
5
|
+
import { t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
|
|
6
|
+
import { i as migrate } from "./migrate-Dx-imcel.mjs";
|
|
7
7
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
//#region src/commands/deploy.ts
|
|
@@ -16,13 +16,30 @@ const DEPLOY_USAGE = [
|
|
|
16
16
|
"",
|
|
17
17
|
"HOLLWARE_PUBLIC_URL must be the public URL of the Worker. While it is a localhost URL, the",
|
|
18
18
|
"workers.dev URL that wrangler prints is written into wrangler.jsonc, and the Worker is deployed",
|
|
19
|
-
"again with it.
|
|
19
|
+
"again with it. A URL on a domain of your own (not workers.dev) also gets a route in",
|
|
20
|
+
"wrangler.jsonc that serves the Worker there as a custom domain: Cloudflare makes its DNS record",
|
|
21
|
+
"and its certificate, in a zone of the same account. Run \"wrangler login\" first.",
|
|
20
22
|
"",
|
|
21
23
|
"Options:",
|
|
22
24
|
" --url <url> The public URL of the Worker, such as https://cms.example.com (default: the",
|
|
23
25
|
" HOLLWARE_PUBLIC_URL of wrangler.jsonc, or the workers.dev URL)",
|
|
24
26
|
" --help Show this help"
|
|
25
27
|
].join("\n");
|
|
28
|
+
/** "Run wrangler login" only when wrangler says so: another failure has nothing to do with it. */
|
|
29
|
+
function loginHint(result) {
|
|
30
|
+
return /not logged in|log ?in|authenticat|oauth|api token/i.test(`${result.stdout}\n${result.stderr}`) ? " Is wrangler logged in (\"wrangler login\")?" : "";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `wrangler types` after deploy rewrote wrangler.jsonc: the types of the Worker hold the value of
|
|
34
|
+
* HOLLWARE_PUBLIC_URL, and `wrangler types --check` fails on the old one. A project without the
|
|
35
|
+
* generated file has nothing to keep current.
|
|
36
|
+
*/
|
|
37
|
+
async function regenerateTypes(io) {
|
|
38
|
+
if (!existsSync(join(io.cwd, "worker-configuration.d.ts"))) return;
|
|
39
|
+
const typed = await io.wrangler(["types"]);
|
|
40
|
+
if (typed.code !== 0) throw new CliError(`wrangler could not write the types of the Worker: ${failureReason(typed)}`);
|
|
41
|
+
io.out("Wrote worker-configuration.d.ts again, for the new wrangler.jsonc.");
|
|
42
|
+
}
|
|
26
43
|
/** The workers.dev URL that `wrangler deploy` prints, or `undefined`. */
|
|
27
44
|
function workersDevUrlOf(stdout) {
|
|
28
45
|
const match = /https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev(?![\w.-])/i.exec(stripAnsi(stdout));
|
|
@@ -33,6 +50,24 @@ function isLoopback(url) {
|
|
|
33
50
|
const { hostname } = new URL(url);
|
|
34
51
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
35
52
|
}
|
|
53
|
+
/** The host of a public URL on a domain of its own: neither this machine nor workers.dev. */
|
|
54
|
+
function customDomainOf(url) {
|
|
55
|
+
if (isLoopback(url)) return void 0;
|
|
56
|
+
const { hostname } = new URL(url);
|
|
57
|
+
return /(?:^|\.)workers\.dev$/i.test(hostname) ? void 0 : hostname;
|
|
58
|
+
}
|
|
59
|
+
const ROUTES_KEY = /^\s*"routes"\s*:/m;
|
|
60
|
+
/** The first key of the starter's wrangler.jsonc, at the top level of the object. */
|
|
61
|
+
const TOP_LEVEL_NAME = /^ {2}"name"\s*:\s*"[^"\n]*",[^\n]*\n/m;
|
|
62
|
+
/**
|
|
63
|
+
* `wrangler.jsonc` with a route that serves the Worker at `host` as a custom domain, after its
|
|
64
|
+
* `name`, and everything else as it was. A config with routes of its own is left as it is.
|
|
65
|
+
*/
|
|
66
|
+
function withCustomDomain(text, host) {
|
|
67
|
+
if (ROUTES_KEY.test(text)) return text;
|
|
68
|
+
if (!TOP_LEVEL_NAME.test(text)) throw new CliError(`wrangler.jsonc has no "name" at its top. Add "routes": [{ "pattern": "${host}", "custom_domain": true }] to it.`);
|
|
69
|
+
return text.replace(TOP_LEVEL_NAME, (line) => `${line} // The custom domain of the Worker: Cloudflare makes its DNS record and its certificate.\n "routes": [{ "pattern": ${JSON.stringify(host)}, "custom_domain": true }],\n`);
|
|
70
|
+
}
|
|
36
71
|
const PUBLIC_URL_VALUE = /("HOLLWARE_PUBLIC_URL"\s*:\s*)"[^"\n]*"/;
|
|
37
72
|
/**
|
|
38
73
|
* `wrangler.jsonc` with `HOLLWARE_PUBLIC_URL` set to `url`, and everything else as it was: its
|
|
@@ -44,7 +79,7 @@ function withPublicUrl(text, url) {
|
|
|
44
79
|
}
|
|
45
80
|
async function wranglerDeploy(io) {
|
|
46
81
|
const deployed = await io.wrangler(["deploy"]);
|
|
47
|
-
if (deployed.code !== 0) throw new CliError(`wrangler could not deploy: ${failureReason(deployed)
|
|
82
|
+
if (deployed.code !== 0) throw new CliError(`wrangler could not deploy: ${failureReason(deployed).replace(/\.$/, "")}.${loginHint(deployed)}`);
|
|
48
83
|
return deployed.stdout;
|
|
49
84
|
}
|
|
50
85
|
async function deploy(argv, io) {
|
|
@@ -65,14 +100,29 @@ async function deploy(argv, io) {
|
|
|
65
100
|
const configured = publicUrlOf(io.cwd, void 0);
|
|
66
101
|
const asked = values.url === void 0 ? void 0 : publicUrlOf(io.cwd, values.url);
|
|
67
102
|
const database = resolveDatabase(io.cwd, void 0);
|
|
103
|
+
const known = asked ?? (configured !== void 0 && !isLoopback(configured) ? configured : void 0);
|
|
104
|
+
const domain = known === void 0 ? void 0 : customDomainOf(known);
|
|
105
|
+
let written = configured;
|
|
106
|
+
if (known !== void 0 && domain !== void 0) {
|
|
107
|
+
const before = readFileSync(file, "utf8");
|
|
108
|
+
const withUrl = known === configured ? before : withPublicUrl(before, known);
|
|
109
|
+
const after = withCustomDomain(withUrl, domain);
|
|
110
|
+
if (after === withUrl && !withUrl.includes(JSON.stringify(domain))) io.out(`wrangler.jsonc has routes of its own and none for ${domain}: add { "pattern": "${domain}", "custom_domain": true } to them.`);
|
|
111
|
+
if (after !== before) {
|
|
112
|
+
writeFileSync(file, after);
|
|
113
|
+
io.out(`Serving the Worker at ${domain}: set HOLLWARE_PUBLIC_URL and its route in wrangler.jsonc.`);
|
|
114
|
+
await regenerateTypes(io);
|
|
115
|
+
}
|
|
116
|
+
written = known;
|
|
117
|
+
}
|
|
68
118
|
io.out("Deploying with wrangler ...");
|
|
69
119
|
const printed = await wranglerDeploy(io);
|
|
70
|
-
|
|
71
|
-
url ??= workersDevUrlOf(printed);
|
|
120
|
+
const url = known ?? workersDevUrlOf(printed);
|
|
72
121
|
if (url === void 0) throw new CliError("wrangler deployed the Worker but printed no workers.dev URL. Run \"hollware deploy --url <the public URL of the Worker>\".");
|
|
73
|
-
if (url !==
|
|
122
|
+
if (url !== written) {
|
|
74
123
|
writeFileSync(file, withPublicUrl(readFileSync(file, "utf8"), url));
|
|
75
124
|
io.out(`Set HOLLWARE_PUBLIC_URL to ${url} in wrangler.jsonc. Deploying again with it ...`);
|
|
125
|
+
await regenerateTypes(io);
|
|
76
126
|
await wranglerDeploy(io);
|
|
77
127
|
}
|
|
78
128
|
await ensureMasterSecret(io);
|
|
@@ -88,7 +138,7 @@ async function deploy(argv, io) {
|
|
|
88
138
|
` MCP ${url}/mcp`,
|
|
89
139
|
` Admin ${url}/admin/`,
|
|
90
140
|
"",
|
|
91
|
-
"Commit wrangler.jsonc: it holds the
|
|
141
|
+
"Commit wrangler.jsonc: it holds the public URL, and any id wrangler wrote into it.",
|
|
92
142
|
...owners.length === 0 ? [
|
|
93
143
|
"",
|
|
94
144
|
"Next: create your site and its owner in the remote database. It prints a token for your",
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { n as UsageError, t as CliError } from "./errors-DJX_jiSP.mjs";
|
|
2
|
+
import { r as findBin } from "./wrangler-C0QYL8dC.mjs";
|
|
3
|
+
import { t as parseFlags } from "./args-D42wi6n2.mjs";
|
|
4
|
+
import { t as publicUrlOf } from "./public-url-d_yOsEMO.mjs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
//#region src/commands/dev.ts
|
|
8
|
+
const DEV_USAGE = [
|
|
9
|
+
"Usage: hollware dev [--url <url>] [--timeout <seconds>] [--json]",
|
|
10
|
+
" hollware dev --detach [--url <url>] [--timeout <seconds>] [--json]",
|
|
11
|
+
" hollware dev --stop [--json]",
|
|
12
|
+
"",
|
|
13
|
+
"Starts the project's local server, the way its \"dev\" script does (the project's own vp dev),",
|
|
14
|
+
"waits until GET <url>/health answers, and prints \"Hollware is ready at <url>\". It stays in the",
|
|
15
|
+
"foreground until SIGINT or SIGTERM, then stops the server and everything it started.",
|
|
16
|
+
"With --detach it starts the server in the background, writes its process id and URL to",
|
|
17
|
+
`.wrangler/hollware-dev.json, prints the ready line and exits 0. "hollware dev --stop" stops what`,
|
|
18
|
+
"that file names.",
|
|
19
|
+
"Once \"hollware deploy\" has written the public URL into wrangler.jsonc, the local server still",
|
|
20
|
+
"answers at localhost: the vite.config.ts of the project sets HOLLWARE_PUBLIC_URL to it in",
|
|
21
|
+
"development, and hollware dev waits for it there.",
|
|
22
|
+
"",
|
|
23
|
+
"Options:",
|
|
24
|
+
" --url <url> Where the server listens (default: HOLLWARE_PUBLIC_URL of wrangler.jsonc",
|
|
25
|
+
" when it is a localhost URL, else http://localhost:5199)",
|
|
26
|
+
" --timeout <seconds> How long to wait for /health, 1 to 600 (default: 60)",
|
|
27
|
+
" --detach Start it in the background and exit",
|
|
28
|
+
" --stop Stop the server that --detach started",
|
|
29
|
+
" --json One JSON object on stdout instead of the line",
|
|
30
|
+
" --help Show this help"
|
|
31
|
+
].join("\n");
|
|
32
|
+
/** The file that names the server `--detach` started, relative to the project. */
|
|
33
|
+
const DEV_STATE_FILE = join(".wrangler", "hollware-dev.json");
|
|
34
|
+
const DEFAULT_URL = "http://localhost:5199";
|
|
35
|
+
const POLL_MS = 250;
|
|
36
|
+
const GRACE_MS = 5e3;
|
|
37
|
+
async function dev(argv, io) {
|
|
38
|
+
const { values } = parseFlags({
|
|
39
|
+
args: [...argv],
|
|
40
|
+
allowPositionals: false,
|
|
41
|
+
options: {
|
|
42
|
+
url: { type: "string" },
|
|
43
|
+
timeout: { type: "string" },
|
|
44
|
+
detach: { type: "boolean" },
|
|
45
|
+
stop: { type: "boolean" },
|
|
46
|
+
json: { type: "boolean" },
|
|
47
|
+
help: { type: "boolean" }
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
if (values.help === true) {
|
|
51
|
+
io.out(DEV_USAGE);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
const host = io.dev;
|
|
55
|
+
if (host === void 0) throw new CliError("this environment cannot run a local server.");
|
|
56
|
+
const json = values.json === true;
|
|
57
|
+
if (values.stop === true) {
|
|
58
|
+
if (values.detach === true || values.url !== void 0 || values.timeout !== void 0) throw new UsageError("--stop goes alone: it stops what --detach started.");
|
|
59
|
+
return stop(io, host, json);
|
|
60
|
+
}
|
|
61
|
+
const timeout = timeoutOf(values.timeout);
|
|
62
|
+
const configured = publicUrlOf(io.cwd, void 0);
|
|
63
|
+
const url = values.url !== void 0 ? publicUrlOf(io.cwd, values.url) ?? DEFAULT_URL : configured !== void 0 && isLoopback(configured) ? configured : DEFAULT_URL;
|
|
64
|
+
const bin = findBin(io.cwd, "vp");
|
|
65
|
+
if (bin === void 0) throw new CliError(`vp is not installed in ${io.cwd} or any directory above it. Install the dependencies of the project (for example "pnpm install") and run hollware dev from the project directory.`);
|
|
66
|
+
const detach = values.detach === true;
|
|
67
|
+
const state = readState(io.cwd);
|
|
68
|
+
if (state !== void 0 && host.isAlive(state.pid)) throw new CliError(`a server started by --detach runs already (process ${state.pid}, ${state.url}). Stop it with "hollware dev --stop".`);
|
|
69
|
+
const child = host.spawn(bin, ["dev"], io.cwd, { detached: detach });
|
|
70
|
+
let interrupted = false;
|
|
71
|
+
let signalled = () => void 0;
|
|
72
|
+
const asked = new Promise((resolve) => {
|
|
73
|
+
signalled = () => resolve("signal");
|
|
74
|
+
});
|
|
75
|
+
const off = host.onSignal(() => {
|
|
76
|
+
interrupted = true;
|
|
77
|
+
signalled();
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const ready = await waitUntilHealthy(host, child, url, timeout, () => interrupted);
|
|
81
|
+
if (ready !== "ready") {
|
|
82
|
+
await terminate(host, child);
|
|
83
|
+
if (ready === "interrupted") return 0;
|
|
84
|
+
throw new CliError(ready === "exited" ? `the dev server stopped before it was ready.${detail(child)}` : `the dev server did not answer ${url}/health within ${timeout} seconds.${detail(child)}`);
|
|
85
|
+
}
|
|
86
|
+
announce(io, json, {
|
|
87
|
+
ready: true,
|
|
88
|
+
url,
|
|
89
|
+
pid: child.pid,
|
|
90
|
+
detached: detach
|
|
91
|
+
});
|
|
92
|
+
if (detach) {
|
|
93
|
+
const file = join(io.cwd, DEV_STATE_FILE);
|
|
94
|
+
mkdirSync(join(io.cwd, ".wrangler"), { recursive: true });
|
|
95
|
+
const record = {
|
|
96
|
+
pid: child.pid,
|
|
97
|
+
url,
|
|
98
|
+
startedAt: io.now().toISOString()
|
|
99
|
+
};
|
|
100
|
+
writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
if (await Promise.race([asked, child.exited.then(() => "exited")]) === "exited") throw new CliError(`the dev server stopped by itself.${detail(child)}`);
|
|
104
|
+
await terminate(host, child);
|
|
105
|
+
return 0;
|
|
106
|
+
} finally {
|
|
107
|
+
off();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Whether a URL is this machine's. */
|
|
111
|
+
function isLoopback(url) {
|
|
112
|
+
const { hostname } = new URL(url);
|
|
113
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
114
|
+
}
|
|
115
|
+
async function stop(io, host, json) {
|
|
116
|
+
const state = readState(io.cwd);
|
|
117
|
+
if (state === void 0) {
|
|
118
|
+
if (json) io.out(JSON.stringify({ stopped: false }));
|
|
119
|
+
else io.out(`No detached server to stop: ${DEV_STATE_FILE} does not exist.`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
const wasAlive = host.isAlive(state.pid);
|
|
123
|
+
if (wasAlive) {
|
|
124
|
+
host.killGroup(state.pid, "SIGTERM");
|
|
125
|
+
const until = GRACE_MS / POLL_MS;
|
|
126
|
+
for (let waited = 0; host.isAlive(state.pid) && waited < until; waited += 1) await host.sleep(POLL_MS);
|
|
127
|
+
host.killGroup(state.pid, "SIGKILL");
|
|
128
|
+
}
|
|
129
|
+
rmSync(join(io.cwd, DEV_STATE_FILE), { force: true });
|
|
130
|
+
if (json) io.out(JSON.stringify({
|
|
131
|
+
stopped: wasAlive,
|
|
132
|
+
pid: state.pid,
|
|
133
|
+
url: state.url
|
|
134
|
+
}));
|
|
135
|
+
else io.out(wasAlive ? `Stopped the server at ${state.url} (process ${state.pid}).` : `The server at ${state.url} (process ${state.pid}) was not running; removed ${DEV_STATE_FILE}.`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
function announce(io, json, what) {
|
|
139
|
+
io.out(json ? JSON.stringify(what) : `Hollware is ready at ${what.url}`);
|
|
140
|
+
}
|
|
141
|
+
function timeoutOf(value) {
|
|
142
|
+
if (value === void 0) return 60;
|
|
143
|
+
const seconds = /^\d+$/.test(value) ? Number(value) : NaN;
|
|
144
|
+
if (!(seconds >= 1 && seconds <= 600)) throw new UsageError(`--timeout is a whole number of seconds from 1 to 600, not "${value}".`);
|
|
145
|
+
return seconds;
|
|
146
|
+
}
|
|
147
|
+
/** Polls /health until it answers, the server ends, a signal comes or the time is up. */
|
|
148
|
+
async function waitUntilHealthy(host, child, url, seconds, interrupted) {
|
|
149
|
+
let ended = false;
|
|
150
|
+
child.exited.then(() => {
|
|
151
|
+
ended = true;
|
|
152
|
+
});
|
|
153
|
+
for (let waited = 0; waited <= seconds * 1e3; waited += POLL_MS) {
|
|
154
|
+
if (interrupted()) return "interrupted";
|
|
155
|
+
if (ended) return "exited";
|
|
156
|
+
if (await host.healthy(url)) return "ready";
|
|
157
|
+
await host.sleep(POLL_MS);
|
|
158
|
+
}
|
|
159
|
+
return "timeout";
|
|
160
|
+
}
|
|
161
|
+
/** Stops the server: SIGTERM to its group, SIGKILL when it has not ended after a grace period. */
|
|
162
|
+
async function terminate(host, child) {
|
|
163
|
+
host.killGroup(child.pid, "SIGTERM");
|
|
164
|
+
const clear = new AbortController();
|
|
165
|
+
const ended = await Promise.race([child.exited.then(() => true), host.sleep(GRACE_MS, clear.signal).then(() => false)]);
|
|
166
|
+
clear.abort();
|
|
167
|
+
if (!ended) {
|
|
168
|
+
host.killGroup(child.pid, "SIGKILL");
|
|
169
|
+
await child.exited;
|
|
170
|
+
}
|
|
171
|
+
await host.sleep(POLL_MS);
|
|
172
|
+
host.killGroup(child.pid, "SIGKILL");
|
|
173
|
+
}
|
|
174
|
+
function detail(child) {
|
|
175
|
+
const tail = child.tail().trim();
|
|
176
|
+
return tail === "" ? "" : `\n${tail}`;
|
|
177
|
+
}
|
|
178
|
+
function readState(cwd) {
|
|
179
|
+
const file = join(cwd, DEV_STATE_FILE);
|
|
180
|
+
if (!existsSync(file)) return void 0;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
183
|
+
if (typeof parsed === "object" && parsed !== null && "pid" in parsed && "url" in parsed && typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 1 && typeof parsed.url === "string") return {
|
|
184
|
+
pid: parsed.pid,
|
|
185
|
+
url: parsed.url,
|
|
186
|
+
startedAt: "startedAt" in parsed && typeof parsed.startedAt === "string" ? parsed.startedAt : ""
|
|
187
|
+
};
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
export { dev };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { t as CliError } from "./errors-DJX_jiSP.mjs";
|
|
2
|
+
import { n as failureReason } from "./wrangler-C0QYL8dC.mjs";
|
|
3
|
+
import { t as parseFlags } from "./args-D42wi6n2.mjs";
|
|
4
|
+
import { t as parseJsonc } from "./jsonc-CA2ezIB6.mjs";
|
|
5
|
+
import { f as ownerQuery, r as query, t as chooseTarget } from "./d1-B6dc_ugC.mjs";
|
|
6
|
+
import { a as parsePendingMigrations } from "./migrate-Dx-imcel.mjs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
//#region src/commands/doctor.ts
|
|
10
|
+
const DOCTOR_USAGE = [
|
|
11
|
+
"Usage: hollware doctor [--local | --remote] [--json]",
|
|
12
|
+
"",
|
|
13
|
+
"Checks the project in the current directory: its wrangler.jsonc, the bindings and flags",
|
|
14
|
+
"Hollware needs, the built files of the admin, the migrations of the database and its owner.",
|
|
15
|
+
"Each problem comes with what to do about it. Exits with code 1 when something is wrong.",
|
|
16
|
+
"",
|
|
17
|
+
"Options:",
|
|
18
|
+
" --local Check the local database (the default)",
|
|
19
|
+
" --remote Check the database in Cloudflare",
|
|
20
|
+
" --json One JSON object on stdout: { ok, checks: [{ name, ok, message, fix }] }; the exit code",
|
|
21
|
+
" is the same (fix is \"\" for a check that passed)",
|
|
22
|
+
" --help Show this help"
|
|
23
|
+
].join("\n");
|
|
24
|
+
/** The flags of `compatibility_flags` that Hollware runs with (ADR-018, ADR-026). */
|
|
25
|
+
const REQUIRED_FLAGS = [
|
|
26
|
+
"no_nodejs_compat",
|
|
27
|
+
"no_nodejs_compat_v2",
|
|
28
|
+
"global_fetch_strictly_public"
|
|
29
|
+
];
|
|
30
|
+
const pass = (name, what) => ({
|
|
31
|
+
name,
|
|
32
|
+
ok: true,
|
|
33
|
+
what
|
|
34
|
+
});
|
|
35
|
+
const fail = (name, what, fix) => ({
|
|
36
|
+
name,
|
|
37
|
+
ok: false,
|
|
38
|
+
what,
|
|
39
|
+
fix
|
|
40
|
+
});
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function records(value) {
|
|
45
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
46
|
+
}
|
|
47
|
+
/** What `wrangler.jsonc` says, checked without reaching Cloudflare or the database. */
|
|
48
|
+
function checkConfig(cwd, config) {
|
|
49
|
+
const findings = [];
|
|
50
|
+
const flags = Array.isArray(config["compatibility_flags"]) ? config["compatibility_flags"] : [];
|
|
51
|
+
const missing = REQUIRED_FLAGS.filter((flag) => !flags.includes(flag));
|
|
52
|
+
findings.push(missing.length === 0 ? pass("compatibility-flags", `compatibility_flags has ${REQUIRED_FLAGS.join(", ")}`) : fail("compatibility-flags", `compatibility_flags lacks ${missing.join(", ")}`, `Add ${missing.map((flag) => `"${flag}"`).join(", ")} to compatibility_flags in wrangler.jsonc.`));
|
|
53
|
+
const url = (isRecord(config["vars"]) ? config["vars"] : {})["HOLLWARE_PUBLIC_URL"];
|
|
54
|
+
const urlOk = typeof url === "string" && URL.canParse(url) && /^https?:$/.test(new URL(url).protocol);
|
|
55
|
+
findings.push(urlOk ? pass("public-url", `HOLLWARE_PUBLIC_URL is ${new URL(url).origin}`) : fail("public-url", "HOLLWARE_PUBLIC_URL is missing or is no http or https URL", "Set vars.HOLLWARE_PUBLIC_URL in wrangler.jsonc to the public URL of the Worker, such as https://cms.example.com."));
|
|
56
|
+
const db = records(config["d1_databases"]).find((entry) => entry["binding"] === "DB");
|
|
57
|
+
const migrationsDir = db !== void 0 && typeof db["migrations_dir"] === "string" ? join(cwd, db["migrations_dir"]) : void 0;
|
|
58
|
+
const migrations = migrationsDir !== void 0 && existsSync(migrationsDir) ? readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")) : [];
|
|
59
|
+
findings.push(db === void 0 ? fail("d1-binding", `there is no D1 binding "DB"`, "Add it to d1_databases in wrangler.jsonc, as the starter has it.") : migrations.length === 0 ? fail("d1-binding", `the D1 binding "DB" has no migrations in its migrations_dir`, "Set migrations_dir to \"node_modules/@hollware/server/migrations\", and install the dependencies.") : pass("d1-binding", `the D1 binding "DB" has ${migrations.length} migrations`));
|
|
60
|
+
const kv = records(config["kv_namespaces"]).some((entry) => entry["binding"] === "OAUTH_KV");
|
|
61
|
+
findings.push(kv ? pass("kv-binding", "the KV binding \"OAUTH_KV\" is there") : fail("kv-binding", "there is no KV binding \"OAUTH_KV\", which the OAuth server needs", "Add { \"binding\": \"OAUTH_KV\" } to kv_namespaces in wrangler.jsonc."));
|
|
62
|
+
const media = records(config["r2_buckets"]).some((entry) => entry["binding"] === "MEDIA");
|
|
63
|
+
findings.push(media ? pass("media-bucket", "the R2 bucket \"MEDIA\" keeps the images of the sites") : fail("media-bucket", "there is no R2 bucket \"MEDIA\", so upload_asset answers unavailable", "Add { \"binding\": \"MEDIA\", \"bucket_name\": \"<name>-media\" } to r2_buckets in wrangler.jsonc; hollware deploy creates the bucket."));
|
|
64
|
+
const images = isRecord(config["images"]) && config["images"]["binding"] === "IMAGES";
|
|
65
|
+
findings.push(pass("images-binding", images ? "the Images binding \"IMAGES\" converts each image once, by the media setting of its site" : "there is no Images binding \"IMAGES\": images are kept as they come, unconverted (add \"images\": { \"binding\": \"IMAGES\" } to convert them)"));
|
|
66
|
+
const assets = isRecord(config["assets"]) ? config["assets"] : void 0;
|
|
67
|
+
const directory = typeof assets?.["directory"] === "string" ? assets["directory"] : void 0;
|
|
68
|
+
const admin = directory !== void 0 && existsSync(join(cwd, directory, "admin"));
|
|
69
|
+
findings.push(admin ? pass("admin-assets", "the admin is built, in the static assets") : fail("admin-assets", "the static assets do not hold the admin", "Install the dependencies; assets.directory of wrangler.jsonc must be \"node_modules/@hollware/admin/dist/client\"."));
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
/** What the database says: the migrations it lacks and whether it has an owner. */
|
|
73
|
+
async function checkDatabase(io, target) {
|
|
74
|
+
const listed = await io.wrangler([
|
|
75
|
+
"d1",
|
|
76
|
+
"migrations",
|
|
77
|
+
"list",
|
|
78
|
+
"DB",
|
|
79
|
+
`--${target}`
|
|
80
|
+
]);
|
|
81
|
+
if (listed.code !== 0) return [fail("migrations-list", `wrangler could not list the migrations of the ${target} database: ${failureReason(listed)}`, target === "remote" ? "Run \"wrangler login\", then \"hollware deploy\"." : "Check wrangler.jsonc.")];
|
|
82
|
+
const pending = parsePendingMigrations(listed.stdout);
|
|
83
|
+
if (pending.length > 0) return [fail("migrations", `the ${target} database lacks ${pending.length} ${pending.length === 1 ? "migration" : "migrations"}`, `Run "hollware migrate --${target}".`)];
|
|
84
|
+
const [owners = []] = await query(io.wrangler, "DB", target, ownerQuery());
|
|
85
|
+
return [pass("migrations", `the ${target} database has every migration`), owners.length > 0 ? pass("owner", `the ${target} database has an owner`) : fail("owner", `the ${target} database has no owner yet`, `Run "hollware init --site <key> --${target}", with the key your site is to have, such as my-site.`)];
|
|
86
|
+
}
|
|
87
|
+
async function doctor(argv, io) {
|
|
88
|
+
const { values } = parseFlags({
|
|
89
|
+
args: [...argv],
|
|
90
|
+
allowPositionals: false,
|
|
91
|
+
options: {
|
|
92
|
+
local: { type: "boolean" },
|
|
93
|
+
remote: { type: "boolean" },
|
|
94
|
+
json: { type: "boolean" },
|
|
95
|
+
help: { type: "boolean" }
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
if (values.help === true) {
|
|
99
|
+
io.out(DOCTOR_USAGE);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
const target = chooseTarget(values);
|
|
103
|
+
const file = join(io.cwd, "wrangler.jsonc");
|
|
104
|
+
if (!existsSync(file)) throw new CliError(`${file} is missing: run hollware doctor in the directory of the project.`);
|
|
105
|
+
let config;
|
|
106
|
+
try {
|
|
107
|
+
config = parseJsonc(readFileSync(file, "utf8"));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new CliError(`wrangler.jsonc is not valid JSONC: ${String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
if (!isRecord(config)) throw new CliError("wrangler.jsonc holds no object.");
|
|
112
|
+
const findings = [...checkConfig(io.cwd, config), ...await checkDatabase(io, target)];
|
|
113
|
+
const failed = findings.filter((finding) => !finding.ok).length;
|
|
114
|
+
if (values.json === true) {
|
|
115
|
+
io.out(JSON.stringify({
|
|
116
|
+
ok: failed === 0,
|
|
117
|
+
checks: findings.map((finding) => ({
|
|
118
|
+
name: finding.name,
|
|
119
|
+
ok: finding.ok,
|
|
120
|
+
message: finding.what,
|
|
121
|
+
fix: finding.fix ?? ""
|
|
122
|
+
}))
|
|
123
|
+
}));
|
|
124
|
+
return failed === 0 ? 0 : 1;
|
|
125
|
+
}
|
|
126
|
+
io.out(findings.map((finding) => finding.ok ? `ok ${finding.what}` : `FAIL ${finding.what}\n ${finding.fix ?? ""}`).join("\n"));
|
|
127
|
+
if (failed === 0) {
|
|
128
|
+
io.out("Everything Hollware needs is in place.");
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
io.err(`hollware doctor: ${failed} ${failed === 1 ? "problem" : "problems"} to fix.`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
export { doctor };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* An error the person at the keyboard can act on. `main` prints its message on stderr, behind
|
|
4
|
+
* the name of the command, and exits with code 1. Anything that is not a `CliError` is a bug or
|
|
5
|
+
* a broken install, and escapes with its stack trace.
|
|
6
|
+
*/
|
|
7
|
+
var CliError = class extends Error {
|
|
8
|
+
name = "CliError";
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* A mistake in the arguments: an unknown option, a missing or a malformed value. `main` adds a
|
|
12
|
+
* pointer to the help of the command after the message.
|
|
13
|
+
*/
|
|
14
|
+
var UsageError = class extends CliError {
|
|
15
|
+
name = "UsageError";
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
18
|
+
export { UsageError as n, CliError as t };
|