hostedge 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +23 -4
  2. package/dist/hostedge.js +1246 -427
  3. package/package.json +11 -11
package/dist/hostedge.js CHANGED
@@ -3,239 +3,6 @@
3
3
  // src/main.ts
4
4
  import { spawn } from "node:child_process";
5
5
 
6
- // src/cli.ts
7
- import { parseArgs } from "node:util";
8
-
9
- // src/commands/auth.ts
10
- import { randomBytes } from "node:crypto";
11
- import { createServer } from "node:http";
12
- import { hostname } from "node:os";
13
-
14
- // src/config.ts
15
- import { mkdir, readFile, writeFile, chmod, rm } from "node:fs/promises";
16
- import { homedir } from "node:os";
17
- import { dirname, join } from "node:path";
18
- var DEFAULT_API_URL = "https://hostedge.app";
19
- function configPath(io2) {
20
- const base = io2.env.HOSTEDGE_CONFIG_DIR ?? join(io2.env.XDG_CONFIG_HOME ?? join(io2.env.HOME ?? homedir(), ".config"), "hostedge");
21
- return join(base, "config.json");
22
- }
23
- async function readJson(path) {
24
- try {
25
- return JSON.parse(await readFile(path, "utf8"));
26
- } catch {
27
- return null;
28
- }
29
- }
30
- async function readConfig(io2) {
31
- return await readJson(configPath(io2)) ?? {};
32
- }
33
- async function writeConfig(io2, config) {
34
- const path = configPath(io2);
35
- await mkdir(dirname(path), { recursive: true, mode: 448 });
36
- await writeFile(path, `${JSON.stringify(config, null, 2)}
37
- `, { mode: 384 });
38
- await chmod(path, 384);
39
- }
40
- async function clearConfig(io2) {
41
- await rm(configPath(io2), { force: true });
42
- }
43
- async function credentials(io2) {
44
- const saved = await readConfig(io2);
45
- return {
46
- apiUrl: (io2.env.HOSTEDGE_API_URL ?? saved.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, ""),
47
- token: io2.env.HOSTEDGE_TOKEN ?? saved.token ?? null
48
- };
49
- }
50
- var linkPath = (dir) => join(dir, ".hostedge", "project.json");
51
- async function readLink(dir) {
52
- const link = await readJson(linkPath(dir));
53
- return link && typeof link.projectId === "string" ? link : null;
54
- }
55
- async function writeLink(dir, link) {
56
- await mkdir(join(dir, ".hostedge"), { recursive: true });
57
- await writeFile(linkPath(dir), `${JSON.stringify(link, null, 2)}
58
- `);
59
- }
60
- async function ensureGitignored(dir, entry) {
61
- const path = join(dir, ".gitignore");
62
- let text = "";
63
- try {
64
- text = await readFile(path, "utf8");
65
- } catch {
66
- }
67
- const lines = text.split(/\r?\n/).map((l) => l.trim());
68
- if (lines.includes(entry) || lines.includes(`/${entry}`)) return false;
69
- await writeFile(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${entry}
70
- `);
71
- return true;
72
- }
73
-
74
- // src/io.ts
75
- var CliError = class extends Error {
76
- constructor(message, exitCode = 1) {
77
- super(message);
78
- this.exitCode = exitCode;
79
- }
80
- exitCode;
81
- };
82
-
83
- // src/api.ts
84
- var ApiError = class extends CliError {
85
- constructor(message, status, code) {
86
- super(message);
87
- this.status = status;
88
- this.code = code;
89
- }
90
- status;
91
- code;
92
- };
93
- function createApi(io2, apiUrl, token) {
94
- async function raw(path, init2 = {}) {
95
- const headers = new Headers(init2.headers);
96
- headers.set("authorization", `Bearer ${token}`);
97
- headers.set("user-agent", "hostedge-cli/0.1.0");
98
- let response;
99
- try {
100
- response = await io2.fetch(`${apiUrl}${path}`, { ...init2, headers });
101
- } catch {
102
- throw new CliError(`Could not reach ${apiUrl}. Check your connection, or HOSTEDGE_API_URL.`);
103
- }
104
- if (!response.ok) {
105
- const body = await response.json().catch(() => ({}));
106
- const fields = body.error?.fields ? ` ${Object.entries(body.error.fields).map(([k, v]) => `${k}: ${v}`).join("; ")}` : "";
107
- const message = response.status === 401 ? `${body.error?.message ?? "Not signed in."} Run \`hostedge login\`.` : `${body.error?.message ?? `HostEdge answered ${response.status}.`}${fields}`;
108
- throw new ApiError(message, response.status, body.error?.code ?? null);
109
- }
110
- return response;
111
- }
112
- return {
113
- apiUrl,
114
- raw,
115
- async request(path, init2 = {}) {
116
- const { json, ...rest } = init2;
117
- let request = rest;
118
- if (json !== void 0) {
119
- const headers = new Headers(rest.headers);
120
- headers.set("content-type", "application/json");
121
- request = { ...rest, method: rest.method ?? "POST", headers, body: JSON.stringify(json) };
122
- }
123
- const response = await raw(path, request);
124
- return await response.json();
125
- }
126
- };
127
- }
128
- async function connect(io2) {
129
- const { apiUrl, token } = await credentials(io2);
130
- if (!token) throw new CliError("Not signed in. Run `hostedge login` (or set HOSTEDGE_TOKEN).");
131
- return createApi(io2, apiUrl, token);
132
- }
133
-
134
- // src/output.ts
135
- function styles(io2) {
136
- const on = io2.isTty && !io2.env.NO_COLOR;
137
- const wrap = (open, close) => (text) => on ? `\x1B[${open}m${text}\x1B[${close}m` : text;
138
- return {
139
- bold: wrap(1, 22),
140
- dim: wrap(2, 22),
141
- red: wrap(31, 39),
142
- green: wrap(32, 39),
143
- yellow: wrap(33, 39),
144
- cyan: wrap(36, 39)
145
- };
146
- }
147
- function formatBytes(bytes) {
148
- if (bytes < 1024) return `${bytes} B`;
149
- const units = ["KB", "MB", "GB"];
150
- let value = bytes / 1024;
151
- let unit = 0;
152
- while (value >= 1024 && unit < units.length - 1) {
153
- value /= 1024;
154
- unit += 1;
155
- }
156
- return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
157
- }
158
-
159
- // src/commands/auth.ts
160
- var LOGIN_TIMEOUT_MS = 5 * 6e4;
161
- var DONE_PAGE = `<!doctype html><meta charset="utf-8"><title>hostedge CLI</title>
162
- <body style="font:16px system-ui;margin:4rem auto;max-width:28rem;color:#16161a;background:#f7f6f2">
163
- <p><strong>The hostedge CLI is signed in.</strong></p><p>You can close this tab and return to your terminal.</p>`;
164
- async function browserLogin(io2, apiUrl) {
165
- const state = randomBytes(24).toString("base64url");
166
- let resolveToken = () => void 0;
167
- const received = new Promise((resolve5) => {
168
- resolveToken = resolve5;
169
- });
170
- const server = createServer((req, res) => {
171
- const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
172
- const token = url2.searchParams.get("token");
173
- if (url2.pathname !== "/callback" || url2.searchParams.get("state") !== state || !token) {
174
- res.writeHead(400, { "content-type": "text/plain" }).end("Unexpected request.");
175
- return;
176
- }
177
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(DONE_PAGE);
178
- resolveToken(token);
179
- });
180
- await new Promise((resolve5) => server.listen(0, "127.0.0.1", resolve5));
181
- const address = server.address();
182
- const port = typeof address === "object" && address ? address.port : 0;
183
- const url = `${apiUrl}/cli/authorize?${new URLSearchParams({ port: String(port), state, host: hostname() })}`;
184
- const s = styles(io2);
185
- const opened = await io2.openUrl(url);
186
- io2.stdout(
187
- opened ? `Opened your browser to authorize the CLI. If nothing happened, visit:
188
- ${s.bold(url)}
189
- ` : `Open this URL in a browser to authorize the CLI:
190
- ${s.bold(url)}
191
- `
192
- );
193
- let timer;
194
- const timeout = new Promise((_, reject) => {
195
- timer = setTimeout(
196
- () => reject(new CliError("Timed out waiting for the browser. Run `hostedge login` again.")),
197
- LOGIN_TIMEOUT_MS
198
- );
199
- });
200
- try {
201
- return await Promise.race([received, timeout]);
202
- } finally {
203
- clearTimeout(timer);
204
- server.close();
205
- }
206
- }
207
- async function login(io2, options) {
208
- const saved = await readConfig(io2);
209
- const apiUrl = (options.api ?? io2.env.HOSTEDGE_API_URL ?? saved.apiUrl ?? DEFAULT_API_URL).replace(/\/+$/, "");
210
- const token = options.token ?? await browserLogin(io2, apiUrl);
211
- const identity = await createApi(io2, apiUrl, token).request("/api/v1/me");
212
- await writeConfig(io2, { apiUrl, token, workspace: identity.workspace.slug });
213
- const s = styles(io2);
214
- io2.stdout(
215
- `${s.green("\u2713")} Signed in as ${s.bold(identity.user.email)} in ${s.bold(identity.workspace.name)}.
216
- `
217
- );
218
- return 0;
219
- }
220
- async function logout(io2) {
221
- await clearConfig(io2);
222
- io2.stdout(
223
- "Signed out on this computer. The token still exists until you revoke it in Settings \u2192 API tokens.\n"
224
- );
225
- return 0;
226
- }
227
- async function whoami(io2) {
228
- const api = await connect(io2);
229
- const me = await api.request("/api/v1/me");
230
- const { apiUrl } = await credentials(io2);
231
- io2.stdout(
232
- `${me.user.email} in ${me.workspace.name} (${me.workspace.slug}) on ${apiUrl}
233
- Scopes: ${me.scopes.join(", ")}
234
- `
235
- );
236
- return 0;
237
- }
238
-
239
6
  // ../../packages/core/src/plans.ts
240
7
  var MB = 1024 * 1024;
241
8
  var GB = 1024 * MB;
@@ -244,6 +11,7 @@ var PLANS = {
244
11
  id: "free",
245
12
  name: "Free",
246
13
  priceMonthlyUsd: 0,
14
+ priceYearlyUsd: 0,
247
15
  limits: {
248
16
  projects: 3,
249
17
  customDomains: 1,
@@ -272,6 +40,7 @@ var PLANS = {
272
40
  id: "starter",
273
41
  name: "Starter",
274
42
  priceMonthlyUsd: 9,
43
+ priceYearlyUsd: 90,
275
44
  limits: {
276
45
  projects: 10,
277
46
  customDomains: 5,
@@ -300,6 +69,7 @@ var PLANS = {
300
69
  id: "pro",
301
70
  name: "Pro",
302
71
  priceMonthlyUsd: 29,
72
+ priceYearlyUsd: 290,
303
73
  limits: {
304
74
  projects: 50,
305
75
  customDomains: 25,
@@ -328,6 +98,7 @@ var PLANS = {
328
98
  id: "agency",
329
99
  name: "Agency",
330
100
  priceMonthlyUsd: 79,
101
+ priceYearlyUsd: 790,
331
102
  limits: {
332
103
  projects: null,
333
104
  customDomains: 100,
@@ -353,6 +124,7 @@ var PLANS = {
353
124
  }
354
125
  }
355
126
  };
127
+ var PLAN_ORDER = ["free", "starter", "pro", "agency"];
356
128
  var MAX_FILE_BYTES = 25 * MB;
357
129
 
358
130
  // ../../packages/core/src/projects/upload.ts
@@ -449,7 +221,10 @@ var BLOCKED_SEGMENTS = /* @__PURE__ */ new Set([
449
221
  "__MACOSX",
450
222
  ".wrangler",
451
223
  ".vercel",
452
- ".netlify"
224
+ ".netlify",
225
+ // Assistant instruction folders (`hostedge init --ai`): not part of the site.
226
+ ".cursor",
227
+ ".claude"
453
228
  ]);
454
229
  var SECRET_NAMES = /^(\.env(\..*)?|.*\.pem|.*\.key|id_rsa.*|\.npmrc\.local|\.dev\.vars.*)$/i;
455
230
  var HASH_PATTERN = /^[0-9a-f]{32}$/;
@@ -555,6 +330,31 @@ function checkManifest(entries, limits) {
555
330
  }
556
331
 
557
332
  // ../../packages/core/src/projects/frameworks.ts
333
+ var FRAMEWORKS = [
334
+ "static",
335
+ "astro",
336
+ "next",
337
+ "nuxt",
338
+ "sveltekit",
339
+ "react-router",
340
+ "next-server",
341
+ "astro-server",
342
+ "sveltekit-server",
343
+ "nuxt-server",
344
+ "react-router-server",
345
+ "vite-react",
346
+ "vite-vue",
347
+ "vite-svelte",
348
+ "vite",
349
+ "angular",
350
+ "create-react-app",
351
+ "gatsby",
352
+ "docusaurus",
353
+ "vitepress",
354
+ "eleventy",
355
+ "hono",
356
+ "other"
357
+ ];
558
358
  var NEXT_CACHE_OUTPUT = "_hostedge/next-cache";
559
359
  var OPEN_NEXT_DEFAULT_CONFIG = `// Written by HostEdge because this project had no open-next.config.ts.
560
360
  // Commit your own to change it: https://opennext.js.org/cloudflare/caching
@@ -1038,43 +838,684 @@ function detectFramework(allPaths, packageJsonText, rootDirectory = "") {
1038
838
  problem: 'Add a "build" script to package.json, or deploy the folder that contains index.html.'
1039
839
  };
1040
840
  }
1041
- if (paths.includes("index.html"))
1042
- return { settings: settingsFor("static"), reason: "Found index.html.", problem: null };
1043
- const nested = paths.find((p) => /^[^/]+\/index\.html$/.test(p));
1044
- if (nested) {
1045
- const dir = nested.split("/")[0] ?? "";
1046
- return {
1047
- settings: settingsFor("static", { outputDirectory: dir }),
1048
- reason: `Found ${nested}.`,
1049
- problem: null
1050
- };
841
+ if (paths.includes("index.html"))
842
+ return { settings: settingsFor("static"), reason: "Found index.html.", problem: null };
843
+ const nested = paths.find((p) => /^[^/]+\/index\.html$/.test(p));
844
+ if (nested) {
845
+ const dir = nested.split("/")[0] ?? "";
846
+ return {
847
+ settings: settingsFor("static", { outputDirectory: dir }),
848
+ reason: `Found ${nested}.`,
849
+ problem: null
850
+ };
851
+ }
852
+ const anyHtml = paths.some((p) => p.endsWith(".html"));
853
+ return {
854
+ settings: settingsFor("static"),
855
+ reason: anyHtml ? "Found HTML files but no index.html." : "No HTML files found.",
856
+ problem: anyHtml ? "Add an index.html so the site has a home page." : "This folder has no index.html. Drop the folder that contains your site\u2019s index.html."
857
+ };
858
+ }
859
+
860
+ // ../../packages/core/src/ai/starter.ts
861
+ var HOSTEDGE_MCP_URL = "https://hostedge.app/api/mcp";
862
+ var CONNECT_WITHOUT_TOKEN = [
863
+ "I have not connected you to HostEdge yet. Before deploying, ask me to do this once:",
864
+ '1. In HostEdge open Settings \u2192 API tokens \u2192 AI assistants and press "Create a token for AI assistants" (shown once).',
865
+ `2. Add the MCP server ${HOSTEDGE_MCP_URL} to your assistant with an Authorization: Bearer header holding that token (Claude Code: claude mcp add --transport http hostedge ${HOSTEDGE_MCP_URL} --header ...; Cursor and others: mcp.json). Most assistants can also connect with just the URL and a sign-in page.`,
866
+ "3. Never paste the token into this chat, a project file or a commit. Keep it in the assistant's MCP settings or in HOSTEDGE_TOKEN."
867
+ ];
868
+
869
+ // ../../packages/core/src/emails/marketing.ts
870
+ var SITE_URL = "https://hostedge.app";
871
+ var LOGO_URL = `${SITE_URL}/brand/hostedge-mark-email.png`;
872
+
873
+ // ../../packages/core/src/ai/kit.ts
874
+ var AI_KIT_DEFAULT_URL = "https://hostedge.app";
875
+ var CLI_EXIT_CODES = [
876
+ { code: 0, name: "ok", meaning: "Success." },
877
+ { code: 1, name: "failure", meaning: "Generic failure (network error, bad input, unexpected)." },
878
+ { code: 2, name: "usage", meaning: "Usage error: unknown flag or command, missing input." },
879
+ {
880
+ code: 3,
881
+ name: "auth",
882
+ meaning: "Not signed in, token invalid, expired, revoked or missing a scope."
883
+ },
884
+ { code: 4, name: "not_found", meaning: "Project, deployment or linked folder not found." },
885
+ { code: 5, name: "deploy_failed", meaning: "The deployment failed or was canceled." },
886
+ { code: 6, name: "limit", meaning: "A plan limit was reached (projects, size, storage \u2026)." }
887
+ ];
888
+ var MB2 = 1024 * 1024;
889
+ var trimUrl = (url) => url.replace(/\/+$/, "");
890
+ var runsOn = (id) => {
891
+ const info = FRAMEWORK_INFO[id];
892
+ if (info.group === "worker") return "Worker";
893
+ return info.server ? "server (Worker)" : "static";
894
+ };
895
+ function frameworkTable() {
896
+ const rows = FRAMEWORKS.map((id) => {
897
+ const info = FRAMEWORK_INFO[id];
898
+ const build = info.defaults.buildCommand ? `\`${info.defaults.buildCommand}\`` : "none";
899
+ const output = info.defaults.outputDirectory ? `\`${info.defaults.outputDirectory}\`` : "the folder";
900
+ return `| \`${id}\` | ${info.name} | ${runsOn(id)} | ${build} | ${output} |`;
901
+ });
902
+ return [
903
+ "| id | Framework | Runs as | Build command | Output |",
904
+ "| --- | --- | --- | --- | --- |",
905
+ ...rows
906
+ ].join("\n");
907
+ }
908
+ var count = (value) => value === null ? "unlimited" : value.toLocaleString("en-US");
909
+ function limitsTable() {
910
+ const header = ["Limit", ...PLAN_ORDER.map((p) => PLANS[p].name)];
911
+ const row = (label, pick) => [
912
+ label,
913
+ ...PLAN_ORDER.map((p) => pick(PLANS[p]))
914
+ ];
915
+ const rows = [
916
+ row("Projects", (p) => count(p.limits.projects)),
917
+ row("Deployment size", (p) => `${p.limits.maxDeploymentBytes / MB2} MB`),
918
+ row("Files per deployment", (p) => count(p.limits.maxFiles)),
919
+ row("Worker CPU per request", (p) => `${p.limits.cpuMs} ms`),
920
+ row("Subrequests per request", (p) => count(p.limits.subrequests)),
921
+ row("Storage resources per project", (p) => count(p.limits.storageResourcesPerProject)),
922
+ row("Requests per month", (p) => count(p.limits.requestsPerMonth))
923
+ ];
924
+ return [header, header.map(() => "---"), ...rows].map((r) => `| ${r.join(" | ")} |`).join("\n");
925
+ }
926
+ var exitCodeTable = () => [
927
+ "| Code | Meaning |",
928
+ "| --- | --- |",
929
+ ...CLI_EXIT_CODES.map((c) => `| ${c.code} | ${c.name}: ${c.meaning} |`)
930
+ ].join("\n");
931
+ function body(appUrl) {
932
+ const mcp = `${appUrl}/api/mcp`;
933
+ return `# HostEdge: how to build and ship here
934
+
935
+ This project is hosted on HostEdge (${appUrl}): static sites and Workers apps on a global edge network. Every deployment gets its own URL; production is \`https://<project>.hostedge.app\`. Follow this file when you write, deploy or debug code for it. Full docs: ${appUrl}/docs (plain-text index: ${appUrl}/llms.txt).
936
+
937
+ ## Quick path
938
+
939
+ 1. Write the app so it builds to a folder of static files, or to a Worker (see "Static vs server").
940
+ 2. Deploy it with the CLI or the MCP server (see "Deploy").
941
+ 3. Wait until the deployment is \`ready\`; if it is \`failed\`, read \`failureReason\` and the build log, fix the code and deploy again.
942
+ 4. Give the person the URL from the result. Never guess a URL.
943
+
944
+ ## Static vs server
945
+
946
+ - **Static**: a folder of HTML, CSS, JavaScript and assets. No server code runs, so there are no bindings and environment variables exist only at build time. Any framework that builds to static files works.
947
+ - **Server (Workers)**: server rendering, API routes and Workers run as Workers. A \`_worker.js\` file at the top of the output folder becomes the Worker; for several modules write a \`_worker.js/\` folder with an \`index.js\` entry. Static files are served next to it through the \`ASSETS\` binding. Frameworks with a Cloudflare adapter (Next.js with OpenNext, Astro, SvelteKit, Nuxt, React Router) write that Worker during the build.
948
+ - Server code has Node.js APIs through \`nodejs_compat\` (\`Buffer\`, \`AsyncLocalStorage\`, \`crypto\`, most \`node:*\`), but no native modules and no child processes. Outbound requests to private networks are blocked. The server bundle must stay under 10 MB gzipped.
949
+ - \`wrangler.jsonc\` bindings are not used. Storage is created on HostEdge (below) and bound by name.
950
+ - Pick static output when the app does not need a server; it is faster and uses no CPU budget.
951
+
952
+ ## Supported frameworks
953
+
954
+ HostEdge detects the framework from \`package.json\` and the lockfile and fills in the build command and output folder (override them in Project > Settings). Anything else: use \`other\` and set the build command and output folder.
955
+
956
+ ${frameworkTable()}
957
+
958
+ Notes: Next.js static export needs \`output: "export"\` (\`next\`); for server features use \`next-server\`. Astro, SvelteKit, Nuxt and React Router become server presets when their Cloudflare adapter is installed. Single-page apps (Vite, Angular, Create React App, React Router SPA) get SPA fallback; others answer unknown paths with \`404.html\`. Builds run on Node.js 22 with the package manager your lockfile names.
959
+
960
+ ## Bindings (databases, key-value, files)
961
+
962
+ Server code reaches storage through \`env\`. Create the storage first, then redeploy so the code is bound to it.
963
+
964
+ | Storage | Built on | Default binding | Code |
965
+ | --- | --- | --- | --- |
966
+ | SQL database | SQLite | \`env.DB\` | \`await env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).all()\` |
967
+ | Key-value store | KV | \`env.KV\` | \`await env.KV.put('flag', 'on', { expirationTtl: 3600 })\` |
968
+ | File storage | Object storage | \`env.FILES\` | \`await env.FILES.put(key, request.body)\` |
969
+
970
+ - Create a database with the MCP tool \`create_database\` (binding defaults to \`DB\`), or in the dashboard under Project > Storage (databases, key-value stores and file storage). Run SQL against it with \`run_sql\`.
971
+ - Binding names use capital letters, digits and underscores, and must not clash with an environment variable of the same project.
972
+ - A static site has no server code, so it cannot use bindings.
973
+ - Previews share production storage unless the person gives previews their own copy in the dashboard.
974
+
975
+ ## Sign-in for the app (HostEdge Auth)
976
+
977
+ When the app needs user accounts, use HostEdge Auth instead of a third-party service. Users are stored in the project's own SQL database.
978
+
979
+ 1. Create a database (binding \`DB\`) and, in the dashboard under Project > Storage > the database > Authentication, choose **Set up authentication** (creates the \`auth_*\` tables; the library also creates them on first use).
980
+ 2. Download \`https://hostedge.app/auth-kit/hostedge-auth.ts\` into the project. One file, no dependencies.
981
+ 3. In the Worker: \`const auth = createAuth(env.DB, { env })\`, then \`const response = await auth.handle(request); if (response) return response\`, and \`await auth.getUser(request)\` for the signed-in \`{ id, email }\` or null. Redeploy.
982
+
983
+ - \`auth.handle\` serves \`POST /auth/sign-up\`, \`/auth/sign-in\`, \`/auth/sign-out\`, \`/auth/magic-link\`, \`/auth/reset-request\`, \`/auth/reset\` (JSON bodies, \`content-type: application/json\`) and \`GET /auth/me\`, \`/auth/verify\`, \`/auth/oauth/github\`, \`/auth/oauth/google\`.
984
+ - Passing \`env\` lets HostEdge send magic-link, reset and confirmation emails (limited per plan per day). To use another sender pass \`sendEmail: async ({ to, kind, url }) => {\u2026}\`. The reset email links to \`/reset-password?token=\u2026\` on the site: build that page and post \`{ token, password }\` to \`/auth/reset\`.
985
+ - Options: \`confirmEmail: true\` to require a confirmed email before password sign-in; \`oauth: { github: { clientId, clientSecret }, google: {\u2026} }\` with the secrets as environment variables; redirect URI \`https://<site>/auth/oauth/<provider>/callback\`.
986
+ - Never write your own password hashing or session handling; do not store passwords yourself.
987
+
988
+ ## Using Postgres instead
989
+
990
+ For heavy apps (thousands of concurrent users, complex queries) the built-in SQLite-based database is not the right fit. The person can use **Connect Postgres** under Project > Storage (Neon, Supabase or any host): it saves \`DATABASE_URL\` as an environment variable in every environment. Read it as \`env.DATABASE_URL\` (for example \`postgres(env.DATABASE_URL, { prepare: false })\`), prefer the host's pooled connection string, and redeploy after it is set.
991
+
992
+ ## Environment variables
993
+
994
+ - Set them with the MCP tool \`set_env_vars\` (\`project\`, \`vars\`, optional \`environments\`: \`production\`, \`preview\`) or in the dashboard. The CLI cannot set them; \`hostedge env pull\` writes them to a git-ignored \`.env.local\` for local work.
995
+ - Workers read them as \`env.NAME\` at runtime; static sites only see them at build time.
996
+ - Values are encrypted and never read back; \`list_env_vars\` returns names only.
997
+ - Deployments keep the values they were built with: **redeploy after changing variables or bindings**.
998
+ - At most 63 variables per project and 5 KB per value. Names starting with \`HOSTEDGE_\` or \`CF_\` are reserved.
999
+
1000
+ ## Deploy
1001
+
1002
+ Check who you are first: \`npx hostedge whoami --json\`.
1003
+
1004
+ ### With the CLI
1005
+
1006
+ Authenticate with an API token from Settings > API tokens: set \`HOSTEDGE_TOKEN\` (preferred; nothing is written to disk), or save it once with \`printf %s "$TOKEN" | npx hostedge login --with-token\`. Interactive \`hostedge login\` opens a browser and is for people.
1007
+
1008
+ \`\`\`sh
1009
+ npx hostedge deploy --wait --json --yes # production; prints {deploymentId,status,url,...}
1010
+ npx hostedge deploy --preview --wait --json # a preview that does not touch production
1011
+ npx hostedge logs --json --since 1h --level error
1012
+ npx hostedge env pull # variables to .env.local
1013
+ \`\`\`
1014
+
1015
+ Always pass \`--json\`: stdout is then one JSON document (NDJSON for \`logs\`) and all human text goes to stderr. \`--yes\` or \`CI=1\` makes it non-interactive; it never waits for input and fails with a clear message instead. \`--quiet\` hides progress. \`--api-url <url>\` or \`HOSTEDGE_API_URL\` points at another HostEdge. The first \`deploy\` in an unlinked folder creates the project and writes \`.hostedge/project.json\` (git-ignored; do not delete it, it links the folder to the project). Files that look like secrets (\`.env*\`, keys) and symbolic links are never uploaded; \`.git\` and \`node_modules\` are skipped.
1016
+
1017
+ Exit codes:
1018
+
1019
+ ${exitCodeTable()}
1020
+
1021
+ \`deploy --wait --json\` on failure still prints JSON with \`"status":"failed"\` and \`"failureReason"\`, and exits 5.
1022
+
1023
+ ### With the MCP server
1024
+
1025
+ Endpoint: \`${mcp}\` (Streamable HTTP, \`Authorization: Bearer <API token>\`, or OAuth when the client supports it). In Claude Code: \`claude mcp add --transport http hostedge ${mcp}\`.
1026
+
1027
+ | Tool | Use |
1028
+ | --- | --- |
1029
+ | \`list_projects\` | Projects with URL and latest deployment |
1030
+ | \`deploy_files\` | Deploy files you provide (\`project\`, \`files[{path,content,encoding}]\`, \`message\`); creates the project if needed; up to 2,000 files and 20 MB per call |
1031
+ | \`deploy_github_repo\` | Deploy a public GitHub repository into a new project |
1032
+ | \`get_deployment\` | Status, URL, \`failureReason\` and the last build log lines |
1033
+ | \`list_deployments\`, \`rollback\` | History; make an earlier ready deployment production |
1034
+ | \`get_logs\`, \`list_errors\` | Runtime logs (\`level\`, \`search\`); open errors grouped by message |
1035
+ | \`set_env_vars\`, \`list_env_vars\` | Write variables (values never read back); list names |
1036
+ | \`create_database\`, \`run_sql\` | A SQLite database bound as \`env.DB\`; run SQL on it |
1037
+ | \`get_analytics\` | Visitors, top pages and sources |
1038
+
1039
+ For sites over 20 MB use the CLI.
1040
+
1041
+ ## Wait until ready
1042
+
1043
+ A deployment goes \`queued\` > \`building\` > \`deploying\` > \`ready\`, or ends \`failed\` or \`canceled\`. \`deploy_files\` returns right away; poll \`get_deployment\` every 2 to 5 seconds until the status is \`ready\`, \`failed\` or \`canceled\` (stop after about 5 minutes and tell the person). With the CLI, \`deploy --wait\` does the polling for you. Only report success when the status is \`ready\`.
1044
+
1045
+ ## Read logs and debug
1046
+
1047
+ - Build problems: the \`failureReason\` and the build log in \`get_deployment\` (CLI: the deploy output).
1048
+ - Runtime problems: \`get_logs\` / \`list_errors\`, or \`hostedge logs --json --level error --since 1h\` (add \`--follow\` to tail). Ranges are \`1h\`, \`24h\` and \`7d\`.
1049
+ - Typical causes: wrong output folder (set it in Project > Settings or pick the right framework), missing environment variable, a binding that was created but not redeployed, a native module in server code.
1050
+
1051
+ ## Roll back
1052
+
1053
+ \`list_deployments\` for the project, pick an earlier deployment whose status is \`ready\`, then \`rollback\` with its \`deploymentId\`. It is instant (no rebuild) and can be undone the same way. The CLI has no rollback command; the dashboard has one too.
1054
+
1055
+ ## Limits
1056
+
1057
+ Plan limits (per workspace unless noted):
1058
+
1059
+ ${limitsTable()}
1060
+
1061
+ A single file may be at most ${MAX_FILE_BYTES / MB2} MB. At the limit, calls fail with a "limit reached" error (CLI exit code 6); tell the person which limit and that upgrading the plan (Settings > Billing) raises it. Do not retry in a loop. Custom domains, cron jobs, forms and uptime monitors are set up in the dashboard.
1062
+
1063
+ ## Rules
1064
+
1065
+ - Never commit secrets (API tokens, \`.env\` files, private keys) and never print or echo \`HOSTEDGE_TOKEN\`. Put secrets in environment variables with \`set_env_vars\`, not in code.
1066
+ - Redeploy after changing environment variables or bindings; they only apply to new deployments.
1067
+ - Prefer the smallest correct setup: static output unless the app needs a server.
1068
+ - Do not invent HostEdge features, flags or endpoints. If this file does not cover it, read ${appUrl}/docs or ${appUrl}/llms.txt.
1069
+ - Do not delete \`.hostedge/project.json\`. Do not deploy from a folder you did not build the app in.
1070
+ - Verify before telling the person it works: status \`ready\`, then fetch the URL.
1071
+ `;
1072
+ }
1073
+ var DESCRIPTION = "Build, deploy, debug and roll back apps on HostEdge (static sites and Workers apps). Use when the task involves hosting, deploying, logs, environment variables, databases or the hostedge CLI/MCP.";
1074
+ function renderAgentsMd(options = {}) {
1075
+ return body(trimUrl(options.appUrl ?? AI_KIT_DEFAULT_URL));
1076
+ }
1077
+ function renderClaudeMd(options = {}) {
1078
+ const appUrl = trimUrl(options.appUrl ?? AI_KIT_DEFAULT_URL);
1079
+ return `# Claude Code instructions
1080
+
1081
+ @AGENTS.md
1082
+
1083
+ This project deploys to HostEdge (${appUrl}). The instructions above explain how to build, deploy, read logs and roll back; the \`hostedge\` skill in \`.claude/skills/hostedge/\` has the same content for on-demand use.
1084
+ `;
1085
+ }
1086
+ function renderCursorRule(options = {}) {
1087
+ return `---
1088
+ description: ${DESCRIPTION}
1089
+ globs:
1090
+ alwaysApply: false
1091
+ ---
1092
+
1093
+ ${body(trimUrl(options.appUrl ?? AI_KIT_DEFAULT_URL))}`;
1094
+ }
1095
+ function renderSkill(options = {}) {
1096
+ return `---
1097
+ name: hostedge
1098
+ description: ${DESCRIPTION}
1099
+ ---
1100
+
1101
+ ${body(trimUrl(options.appUrl ?? AI_KIT_DEFAULT_URL))}`;
1102
+ }
1103
+ function aiKitFiles(options = {}) {
1104
+ return [
1105
+ {
1106
+ id: "agents",
1107
+ path: "AGENTS.md",
1108
+ description: "Instructions for any coding assistant (the shared source)",
1109
+ content: renderAgentsMd(options)
1110
+ },
1111
+ {
1112
+ id: "claude",
1113
+ path: "CLAUDE.md",
1114
+ description: "Claude Code: imports AGENTS.md",
1115
+ content: renderClaudeMd(options)
1116
+ },
1117
+ {
1118
+ id: "cursor",
1119
+ path: ".cursor/rules/hostedge.mdc",
1120
+ description: "Cursor rule",
1121
+ content: renderCursorRule(options)
1122
+ },
1123
+ {
1124
+ id: "skill",
1125
+ path: ".claude/skills/hostedge/SKILL.md",
1126
+ description: "Claude Code skill",
1127
+ content: renderSkill(options)
1128
+ }
1129
+ ];
1130
+ }
1131
+
1132
+ // ../../packages/core/src/client.ts
1133
+ async function contentHash(content) {
1134
+ const buffer = content instanceof Uint8Array ? content : new Uint8Array(content);
1135
+ const digest = new Uint8Array(
1136
+ await crypto.subtle.digest("SHA-256", buffer)
1137
+ );
1138
+ let hex = "";
1139
+ for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
1140
+ return hex.slice(0, 32);
1141
+ }
1142
+
1143
+ // src/cli.ts
1144
+ import { parseArgs } from "node:util";
1145
+
1146
+ // src/commands/ai-kit.ts
1147
+ import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
1148
+ import { dirname, join, resolve } from "node:path";
1149
+
1150
+ // src/io.ts
1151
+ var EXIT = {
1152
+ ok: 0,
1153
+ failure: 1,
1154
+ usage: 2,
1155
+ auth: 3,
1156
+ notFound: 4,
1157
+ deployFailed: 5,
1158
+ limit: 6
1159
+ };
1160
+ function exitName(code) {
1161
+ return CLI_EXIT_CODES.find((c) => c.code === code)?.name ?? "failure";
1162
+ }
1163
+ var CliError = class extends Error {
1164
+ constructor(message, exitCode = EXIT.failure) {
1165
+ super(message);
1166
+ this.exitCode = exitCode;
1167
+ }
1168
+ exitCode;
1169
+ };
1170
+
1171
+ // src/commands/ai-kit.ts
1172
+ function lineDelta(previous, next) {
1173
+ const counts = /* @__PURE__ */ new Map();
1174
+ for (const line of previous.split("\n")) counts.set(line, (counts.get(line) ?? 0) + 1);
1175
+ let added = 0;
1176
+ for (const line of next.split("\n")) {
1177
+ const left = counts.get(line) ?? 0;
1178
+ if (left > 0) counts.set(line, left - 1);
1179
+ else added += 1;
1180
+ }
1181
+ let removed = 0;
1182
+ for (const left of counts.values()) removed += left;
1183
+ return { added, removed };
1184
+ }
1185
+ async function writeOne(dir, file, force) {
1186
+ const target = join(dir, ...file.path.split("/"));
1187
+ const base = { id: file.id, path: file.path };
1188
+ const info = await lstat(target).catch(() => null);
1189
+ if (info?.isSymbolicLink())
1190
+ return { ...base, action: "skipped", added: 0, removed: 0, reason: "it is a symbolic link" };
1191
+ if (info && !info.isFile())
1192
+ return { ...base, action: "skipped", added: 0, removed: 0, reason: "it is not a file" };
1193
+ const previous = info ? await readFile(target, "utf8") : null;
1194
+ if (previous === file.content) return { ...base, action: "unchanged", added: 0, removed: 0 };
1195
+ const delta = previous === null ? { added: file.content.split("\n").length, removed: 0 } : lineDelta(previous, file.content);
1196
+ if (previous !== null && !force)
1197
+ return {
1198
+ ...base,
1199
+ action: "skipped",
1200
+ ...delta,
1201
+ reason: "it already exists with different content (use --force to overwrite)"
1202
+ };
1203
+ try {
1204
+ await mkdir(dirname(target), { recursive: true });
1205
+ await writeFile(target, file.content);
1206
+ } catch (error) {
1207
+ throw new CliError(`Could not write ${file.path}: ${error.message}`, EXIT.failure);
1208
+ }
1209
+ return { ...base, action: previous === null ? "created" : "overwritten", ...delta };
1210
+ }
1211
+ async function writeKit(dir, options) {
1212
+ const info = await stat(dir).catch(() => null);
1213
+ if (!info?.isDirectory()) throw new CliError(`${dir} is not a folder.`, EXIT.usage);
1214
+ const results = [];
1215
+ for (const file of aiKitFiles()) results.push(await writeOne(dir, file, options.force === true));
1216
+ return results;
1217
+ }
1218
+ function formatKit(r, results) {
1219
+ const s = r.s;
1220
+ const lines = results.map((f) => {
1221
+ switch (f.action) {
1222
+ case "created":
1223
+ return `${s.green("+")} ${f.path} ${s.dim(`(created, ${f.added} lines)`)}`;
1224
+ case "overwritten":
1225
+ return `${s.yellow("~")} ${f.path} ${s.dim(`(overwritten: +${f.added} -${f.removed} lines)`)}`;
1226
+ case "unchanged":
1227
+ return `${s.dim("=")} ${f.path} ${s.dim("(already up to date)")}`;
1228
+ default:
1229
+ return `${s.yellow("!")} ${f.path} ${s.dim(`(kept: ${f.reason ?? "skipped"}${f.added || f.removed ? `; HostEdge's version is +${f.added} -${f.removed} lines` : ""})`)}`;
1230
+ }
1231
+ });
1232
+ const kept = results.filter((f) => f.action === "skipped").length;
1233
+ const wrote = results.filter((f) => f.action === "created" || f.action === "overwritten").length;
1234
+ const note = kept > 0 ? `${wrote} written, ${kept} kept. Run again with --force to overwrite the kept files.` : wrote > 0 ? `${wrote} written. Commit them so every assistant on the project reads them.` : "Nothing to change.";
1235
+ return `${lines.join("\n")}
1236
+ ${note}
1237
+ `;
1238
+ }
1239
+ async function aiKit(r, options) {
1240
+ const dir = resolve(r.io.cwd, options.dir ?? ".");
1241
+ const files = await writeKit(dir, { force: options.force });
1242
+ r.result({ dir, files }, formatKit(r, files));
1243
+ return EXIT.ok;
1244
+ }
1245
+
1246
+ // src/commands/auth.ts
1247
+ import { randomBytes } from "node:crypto";
1248
+ import { createServer } from "node:http";
1249
+ import { hostname } from "node:os";
1250
+
1251
+ // src/config.ts
1252
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, chmod, rm } from "node:fs/promises";
1253
+ import { homedir } from "node:os";
1254
+ import { dirname as dirname2, join as join2 } from "node:path";
1255
+ var DEFAULT_API_URL = "https://hostedge.app";
1256
+ function configPath(io2) {
1257
+ const base = io2.env.HOSTEDGE_CONFIG_DIR ?? join2(io2.env.XDG_CONFIG_HOME ?? join2(io2.env.HOME ?? homedir(), ".config"), "hostedge");
1258
+ return join2(base, "config.json");
1259
+ }
1260
+ async function readJson(path) {
1261
+ try {
1262
+ return JSON.parse(await readFile2(path, "utf8"));
1263
+ } catch {
1264
+ return null;
1265
+ }
1266
+ }
1267
+ async function readConfig(io2) {
1268
+ return await readJson(configPath(io2)) ?? {};
1269
+ }
1270
+ async function writeConfig(io2, config) {
1271
+ const path = configPath(io2);
1272
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
1273
+ await writeFile2(path, `${JSON.stringify(config, null, 2)}
1274
+ `, { mode: 384 });
1275
+ await chmod(path, 384);
1276
+ }
1277
+ async function clearConfig(io2) {
1278
+ await rm(configPath(io2), { force: true });
1279
+ }
1280
+ async function credentials(io2) {
1281
+ const saved = await readConfig(io2);
1282
+ const fromEnv = io2.env.HOSTEDGE_TOKEN?.trim();
1283
+ const token = fromEnv || saved.token || null;
1284
+ return {
1285
+ apiUrl: (io2.env.HOSTEDGE_API_URL || saved.apiUrl || DEFAULT_API_URL).replace(/\/+$/, ""),
1286
+ token,
1287
+ source: fromEnv ? "env" : saved.token ? "saved" : null
1288
+ };
1289
+ }
1290
+ var linkPath = (dir) => join2(dir, ".hostedge", "project.json");
1291
+ async function readLink(dir) {
1292
+ const link = await readJson(linkPath(dir));
1293
+ return link && typeof link.projectId === "string" ? link : null;
1294
+ }
1295
+ async function writeLink(dir, link) {
1296
+ await mkdir2(join2(dir, ".hostedge"), { recursive: true });
1297
+ await writeFile2(linkPath(dir), `${JSON.stringify(link, null, 2)}
1298
+ `);
1299
+ }
1300
+ async function ensureGitignored(dir, entry) {
1301
+ const path = join2(dir, ".gitignore");
1302
+ let text = "";
1303
+ try {
1304
+ text = await readFile2(path, "utf8");
1305
+ } catch {
1306
+ }
1307
+ const lines = text.split(/\r?\n/).map((l) => l.trim());
1308
+ if (lines.includes(entry) || lines.includes(`/${entry}`)) return false;
1309
+ await writeFile2(path, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${entry}
1310
+ `);
1311
+ return true;
1312
+ }
1313
+
1314
+ // src/api.ts
1315
+ function exitCodeFor(status, code) {
1316
+ if (status === 401 || status === 403 || code === "unauthorized" || code === "forbidden")
1317
+ return EXIT.auth;
1318
+ if (status === 404 || code === "not_found") return EXIT.notFound;
1319
+ if (status === 402 || code === "limit_reached") return EXIT.limit;
1320
+ return EXIT.failure;
1321
+ }
1322
+ var ApiError = class extends CliError {
1323
+ constructor(message, status, code, retryAfter = null) {
1324
+ super(message, exitCodeFor(status, code));
1325
+ this.status = status;
1326
+ this.code = code;
1327
+ this.retryAfter = retryAfter;
1328
+ }
1329
+ status;
1330
+ code;
1331
+ retryAfter;
1332
+ };
1333
+ function createApi(io2, apiUrl, token) {
1334
+ async function raw(path, init2 = {}) {
1335
+ const headers = new Headers(init2.headers);
1336
+ headers.set("authorization", `Bearer ${token}`);
1337
+ headers.set("user-agent", "hostedge-cli/0.1.2");
1338
+ let response;
1339
+ try {
1340
+ response = await io2.fetch(`${apiUrl}${path}`, { ...init2, headers });
1341
+ } catch {
1342
+ throw new CliError(`Could not reach ${apiUrl}. Check your connection, or HOSTEDGE_API_URL.`);
1343
+ }
1344
+ if (!response.ok) {
1345
+ const body2 = await response.json().catch(() => ({}));
1346
+ const fields = body2.error?.fields ? ` ${Object.entries(body2.error.fields).map(([k, v]) => `${k}: ${v}`).join("; ")}` : "";
1347
+ const message = response.status === 401 ? `${body2.error?.message ?? "Not signed in."} Run \`hostedge login\`.` : `${body2.error?.message ?? `HostEdge answered ${response.status}.`}${fields}`;
1348
+ const wait = Number(response.headers.get("retry-after"));
1349
+ throw new ApiError(
1350
+ message,
1351
+ response.status,
1352
+ body2.error?.code ?? null,
1353
+ Number.isFinite(wait) && wait > 0 ? wait : null
1354
+ );
1355
+ }
1356
+ return response;
1051
1357
  }
1052
- const anyHtml = paths.some((p) => p.endsWith(".html"));
1053
1358
  return {
1054
- settings: settingsFor("static"),
1055
- reason: anyHtml ? "Found HTML files but no index.html." : "No HTML files found.",
1056
- problem: anyHtml ? "Add an index.html so the site has a home page." : "This folder has no index.html. Drop the folder that contains your site\u2019s index.html."
1359
+ apiUrl,
1360
+ raw,
1361
+ async request(path, init2 = {}) {
1362
+ const { json, ...rest } = init2;
1363
+ let request = rest;
1364
+ if (json !== void 0) {
1365
+ const headers = new Headers(rest.headers);
1366
+ headers.set("content-type", "application/json");
1367
+ request = { ...rest, method: rest.method ?? "POST", headers, body: JSON.stringify(json) };
1368
+ }
1369
+ const response = await raw(path, request);
1370
+ return await response.json();
1371
+ }
1057
1372
  };
1058
1373
  }
1374
+ async function connect(io2) {
1375
+ const { apiUrl, token } = await credentials(io2);
1376
+ if (!token)
1377
+ throw new CliError("Not signed in. Run `hostedge login` (or set HOSTEDGE_TOKEN).", EXIT.auth);
1378
+ return createApi(io2, apiUrl, token);
1379
+ }
1059
1380
 
1060
- // ../../packages/core/src/client.ts
1061
- async function contentHash(content) {
1062
- const buffer = content instanceof Uint8Array ? content : new Uint8Array(content);
1063
- const digest = new Uint8Array(
1064
- await crypto.subtle.digest("SHA-256", buffer)
1381
+ // src/commands/auth.ts
1382
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
1383
+ var DONE_PAGE = `<!doctype html><meta charset="utf-8"><title>hostedge CLI</title>
1384
+ <body style="font:16px system-ui;margin:4rem auto;max-width:28rem;color:#16161a;background:#f7f6f2">
1385
+ <p><strong>The hostedge CLI is signed in.</strong></p><p>You can close this tab and return to your terminal.</p>`;
1386
+ async function browserLogin(r, apiUrl) {
1387
+ const { io: io2 } = r;
1388
+ const state = randomBytes(24).toString("base64url");
1389
+ let resolveToken = () => void 0;
1390
+ const received = new Promise((resolve6) => {
1391
+ resolveToken = resolve6;
1392
+ });
1393
+ const server = createServer((req, res) => {
1394
+ const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
1395
+ const token = url2.searchParams.get("token");
1396
+ if (url2.pathname !== "/callback" || url2.searchParams.get("state") !== state || !token) {
1397
+ res.writeHead(400, { "content-type": "text/plain" }).end("Unexpected request.");
1398
+ return;
1399
+ }
1400
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(DONE_PAGE);
1401
+ resolveToken(token);
1402
+ });
1403
+ await new Promise((resolve6) => server.listen(0, "127.0.0.1", resolve6));
1404
+ const address = server.address();
1405
+ const port = typeof address === "object" && address ? address.port : 0;
1406
+ const url = `${apiUrl}/cli/authorize?${new URLSearchParams({ port: String(port), state, host: hostname() })}`;
1407
+ const s = r.s;
1408
+ const opened = await io2.openUrl(url);
1409
+ r.always(
1410
+ opened ? `Opened your browser to authorize the CLI. If nothing happened, visit:
1411
+ ${s.bold(url)}
1412
+ ` : `Open this URL in a browser to authorize the CLI:
1413
+ ${s.bold(url)}
1414
+ `
1065
1415
  );
1066
- let hex = "";
1067
- for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
1068
- return hex.slice(0, 32);
1416
+ let timer;
1417
+ const timeout = new Promise((_, reject) => {
1418
+ timer = setTimeout(
1419
+ () => reject(
1420
+ new CliError("Timed out waiting for the browser. Run `hostedge login` again.", EXIT.auth)
1421
+ ),
1422
+ LOGIN_TIMEOUT_MS
1423
+ );
1424
+ });
1425
+ try {
1426
+ return await Promise.race([received, timeout]);
1427
+ } finally {
1428
+ clearTimeout(timer);
1429
+ server.close();
1430
+ }
1431
+ }
1432
+ async function obtainToken(r, options, apiUrl) {
1433
+ const { io: io2 } = r;
1434
+ if (options.token !== void 0 && options.withToken)
1435
+ throw new CliError("Use either --token or --with-token, not both.", EXIT.usage);
1436
+ if (options.token !== void 0) {
1437
+ const token = options.token.trim();
1438
+ if (!token)
1439
+ throw new CliError("--token needs a value. Prefer --with-token (reads stdin).", EXIT.usage);
1440
+ r.warn(
1441
+ `${r.s.dim("Tip: --token shows in your shell history and process list; prefer --with-token or HOSTEDGE_TOKEN.")}
1442
+ `
1443
+ );
1444
+ return { token, source: "flag" };
1445
+ }
1446
+ if (options.withToken) {
1447
+ if (io2.isStdinTty)
1448
+ throw new CliError(
1449
+ '--with-token reads the token from stdin, but stdin is a terminal. Pipe it in, e.g. `printf %s "$TOKEN" | hostedge login --with-token`.',
1450
+ EXIT.usage
1451
+ );
1452
+ const token = (await io2.readStdin()).trim();
1453
+ if (!token) throw new CliError("No token received on stdin.", EXIT.usage);
1454
+ return { token, source: "stdin" };
1455
+ }
1456
+ const fromEnv = io2.env.HOSTEDGE_TOKEN?.trim();
1457
+ if (fromEnv) return { token: fromEnv, source: "env" };
1458
+ if (!r.interactive)
1459
+ throw new CliError(
1460
+ "Not signed in, and a browser login needs a person. Set HOSTEDGE_TOKEN, or pass --with-token (token on stdin) or --token <token>.",
1461
+ EXIT.auth
1462
+ );
1463
+ return { token: await browserLogin(r, apiUrl), source: "browser" };
1464
+ }
1465
+ async function login(r, options) {
1466
+ const { io: io2 } = r;
1467
+ const saved = await readConfig(io2);
1468
+ const apiUrl = (options.api || io2.env.HOSTEDGE_API_URL || saved.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
1469
+ const { token, source } = await obtainToken(r, options, apiUrl);
1470
+ const identity = await createApi(io2, apiUrl, token).request("/api/v1/me");
1471
+ const saveIt = source !== "env";
1472
+ if (saveIt) await writeConfig(io2, { apiUrl, token, workspace: identity.workspace.slug });
1473
+ const s = r.s;
1474
+ r.result(
1475
+ {
1476
+ signedIn: true,
1477
+ user: identity.user,
1478
+ workspace: identity.workspace,
1479
+ scopes: identity.scopes,
1480
+ apiUrl,
1481
+ tokenSource: source,
1482
+ saved: saveIt
1483
+ },
1484
+ `${s.green("\u2713")} Signed in as ${s.bold(identity.user.email)} in ${s.bold(identity.workspace.name)}.${saveIt ? "" : " (token from HOSTEDGE_TOKEN, not saved)"}
1485
+ `
1486
+ );
1487
+ return EXIT.ok;
1488
+ }
1489
+ async function logout(r) {
1490
+ await clearConfig(r.io);
1491
+ r.result(
1492
+ { signedOut: true },
1493
+ "Signed out on this computer. The token still exists until you revoke it in Settings \u2192 API tokens.\n"
1494
+ );
1495
+ return EXIT.ok;
1496
+ }
1497
+ async function whoami(r) {
1498
+ const api = await connect(r.io);
1499
+ const me = await api.request("/api/v1/me");
1500
+ const { apiUrl, source } = await credentials(r.io);
1501
+ const text = `${me.user.email} in ${me.workspace.name} (${me.workspace.slug}) on ${apiUrl}
1502
+ Scopes: ${me.scopes.join(", ")}
1503
+ `;
1504
+ r.result(
1505
+ { user: me.user, workspace: me.workspace, scopes: me.scopes, apiUrl, tokenSource: source },
1506
+ text,
1507
+ text
1508
+ );
1509
+ return EXIT.ok;
1069
1510
  }
1070
1511
 
1071
1512
  // src/commands/deploy.ts
1072
- import { readFile as readFile3 } from "node:fs/promises";
1073
- import { basename, join as join3, resolve } from "node:path";
1513
+ import { readFile as readFile4 } from "node:fs/promises";
1514
+ import { basename, join as join4, resolve as resolve2 } from "node:path";
1074
1515
 
1075
1516
  // src/files.ts
1076
- import { lstat, readdir, readFile as readFile2 } from "node:fs/promises";
1077
- import { join as join2, relative, sep } from "node:path";
1517
+ import { lstat as lstat2, readdir, readFile as readFile3 } from "node:fs/promises";
1518
+ import { join as join3, relative, sep } from "node:path";
1078
1519
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hostedge", ".next", ".turbo", ".wrangler"]);
1079
1520
  async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1080
1521
  const files = [];
@@ -1083,7 +1524,7 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1083
1524
  const entries = await readdir(dir, { withFileTypes: true });
1084
1525
  entries.sort((a, b) => a.name.localeCompare(b.name));
1085
1526
  for (const entry of entries) {
1086
- const absolute = join2(dir, entry.name);
1527
+ const absolute = join3(dir, entry.name);
1087
1528
  const rel = relative(root, absolute).split(sep).join("/");
1088
1529
  if (entry.isSymbolicLink())
1089
1530
  throw new CliError(
@@ -1103,8 +1544,8 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1103
1544
  }
1104
1545
  if (files.length >= limits.maxFiles)
1105
1546
  throw new CliError(`More than ${limits.maxFiles.toLocaleString("en-US")} files to upload.`);
1106
- const stat2 = await lstat(absolute);
1107
- files.push({ path: normal.path, size: stat2.size, hash: "", absolute });
1547
+ const stat3 = await lstat2(absolute);
1548
+ files.push({ path: normal.path, size: stat3.size, hash: "", absolute });
1108
1549
  }
1109
1550
  }
1110
1551
  try {
@@ -1124,9 +1565,20 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1124
1565
  }
1125
1566
  const upload = files.filter((f) => !secrets.has(f.path));
1126
1567
  if (limits.hash !== false)
1127
- for (const file of upload) file.hash = await contentHash(await readFile2(file.absolute));
1568
+ for (const file of upload) file.hash = await contentHash(await readFile3(file.absolute));
1128
1569
  return { files: upload, skipped, secrets: [...secrets] };
1129
1570
  }
1571
+ async function withRetries(work, retryable, attempts = 4, delayMs = 500) {
1572
+ for (let attempt = 1; ; attempt++) {
1573
+ try {
1574
+ return await work();
1575
+ } catch (error) {
1576
+ if (attempt >= attempts || !retryable(error)) throw error;
1577
+ const wait = typeof delayMs === "function" ? delayMs(error, attempt) : delayMs * 2 ** (attempt - 1);
1578
+ await new Promise((resolve6) => setTimeout(resolve6, wait));
1579
+ }
1580
+ }
1581
+ }
1130
1582
  async function pool(items, limit, work) {
1131
1583
  let next = 0;
1132
1584
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
@@ -1138,14 +1590,40 @@ async function pool(items, limit, work) {
1138
1590
  await Promise.all(runners);
1139
1591
  }
1140
1592
 
1593
+ // src/output.ts
1594
+ function styles(io2, stream = "stdout", noColor = false) {
1595
+ const tty = stream === "stdout" ? io2.isTty : io2.isErrTty;
1596
+ const on = tty && !noColor && !io2.env.NO_COLOR && io2.env.TERM !== "dumb";
1597
+ const wrap = (open, close) => (text) => on ? `\x1B[${open}m${text}\x1B[${close}m` : text;
1598
+ return {
1599
+ bold: wrap(1, 22),
1600
+ dim: wrap(2, 22),
1601
+ red: wrap(31, 39),
1602
+ green: wrap(32, 39),
1603
+ yellow: wrap(33, 39),
1604
+ cyan: wrap(36, 39)
1605
+ };
1606
+ }
1607
+ function formatBytes(bytes) {
1608
+ if (bytes < 1024) return `${bytes} B`;
1609
+ const units = ["KB", "MB", "GB"];
1610
+ let value = bytes / 1024;
1611
+ let unit = 0;
1612
+ while (value >= 1024 && unit < units.length - 1) {
1613
+ value /= 1024;
1614
+ unit += 1;
1615
+ }
1616
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
1617
+ }
1618
+
1141
1619
  // src/sse.ts
1142
- async function* readSse(body) {
1620
+ async function* readSse(body2) {
1143
1621
  const decoder = new TextDecoder();
1144
1622
  let buffer = "";
1145
1623
  let event = "message";
1146
1624
  let data = [];
1147
1625
  let id = null;
1148
- for await (const chunk of body) {
1626
+ for await (const chunk of body2) {
1149
1627
  buffer += decoder.decode(chunk, { stream: true });
1150
1628
  let newline = buffer.search(/\r?\n/);
1151
1629
  while (newline !== -1) {
@@ -1170,9 +1648,37 @@ async function* readSse(body) {
1170
1648
 
1171
1649
  // src/commands/deploy.ts
1172
1650
  var FINISHED = /* @__PURE__ */ new Set(["ready", "failed", "canceled"]);
1173
- var UPLOAD_CONCURRENCY = 8;
1174
- async function follow(io2, api, deploymentId) {
1175
- const s = styles(io2);
1651
+ var UPLOAD_CONCURRENCY = 6;
1652
+ var BATCH_BYTES = 3 * 1024 * 1024;
1653
+ var BATCH_FILES = 100;
1654
+ var UPLOAD_TIMEOUT_MS = 3e4;
1655
+ function batchesOf(files) {
1656
+ const batches = [];
1657
+ let current = [];
1658
+ let bytes = 0;
1659
+ for (const file of files) {
1660
+ if (current.length > 0 && (bytes + file.size > BATCH_BYTES || current.length >= BATCH_FILES)) {
1661
+ batches.push(current);
1662
+ current = [];
1663
+ bytes = 0;
1664
+ }
1665
+ current.push(file);
1666
+ bytes += file.size;
1667
+ }
1668
+ if (current.length > 0) batches.push(current);
1669
+ return batches;
1670
+ }
1671
+ function retryDelay(error, attempt) {
1672
+ if (error instanceof ApiError && error.retryAfter !== null)
1673
+ return Math.min(6e4, (error.retryAfter + 1) * 1e3);
1674
+ return Math.min(6e4, 500 * 2 ** (attempt - 1));
1675
+ }
1676
+ function temporary(error) {
1677
+ if (error instanceof ApiError) return error.status === 429 || error.status >= 500;
1678
+ return error instanceof CliError;
1679
+ }
1680
+ async function follow(r, api, deploymentId) {
1681
+ const s = r.s;
1176
1682
  let cursor = null;
1177
1683
  let last = { status: "queued", failureReason: null };
1178
1684
  for (let attempt = 0; attempt < 20; attempt++) {
@@ -1185,7 +1691,7 @@ async function follow(io2, api, deploymentId) {
1185
1691
  if (event.event === "log") {
1186
1692
  for (const line of JSON.parse(event.data)) {
1187
1693
  const text = line.level === "error" ? s.red(line.msg) : line.level === "warn" ? s.yellow(line.msg) : line.level === "success" ? s.green(line.msg) : line.msg;
1188
- io2.stdout(`${s.dim("\u2502")} ${text}
1694
+ r.say(`${s.dim("\u2502")} ${text}
1189
1695
  `);
1190
1696
  }
1191
1697
  } else if (event.event === "status") {
@@ -1196,25 +1702,34 @@ async function follow(io2, api, deploymentId) {
1196
1702
  }
1197
1703
  }
1198
1704
  if (FINISHED.has(last.status)) return last;
1199
- await io2.sleep(1e3);
1705
+ await r.io.sleep(1e3);
1200
1706
  }
1201
1707
  return last;
1202
1708
  }
1203
- async function deploy(io2, options) {
1709
+ async function lookup(api, deploymentId) {
1710
+ try {
1711
+ const d = await api.request(`/api/v1/deployments/${deploymentId}`);
1712
+ return typeof d.status === "string" ? { status: d.status, failureReason: d.failureReason ?? null } : null;
1713
+ } catch {
1714
+ return null;
1715
+ }
1716
+ }
1717
+ async function deploy(r, options) {
1718
+ const { io: io2 } = r;
1204
1719
  const started = Date.now();
1205
- const dir = resolve(io2.cwd, options.dir ?? ".");
1206
- const s = styles(io2);
1720
+ const dir = resolve2(io2.cwd, options.dir ?? ".");
1721
+ const s = r.s;
1207
1722
  const api = await connect(io2);
1208
1723
  const link = await readLink(dir);
1209
1724
  const { files, skipped, secrets } = await collectFiles(dir);
1210
1725
  if (files.length === 0) throw new CliError(`No files to deploy in ${dir}.`);
1211
1726
  for (const secret of secrets)
1212
- io2.stdout(
1727
+ r.say(
1213
1728
  `${s.dim("\u2013")} Left out ${secret}: it looks like a secret. Set it with environment variables instead.
1214
1729
  `
1215
1730
  );
1216
1731
  if (skipped.length > 0)
1217
- io2.stdout(
1732
+ r.say(
1218
1733
  s.dim(
1219
1734
  `Skipped ${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? ` and ${skipped.length - 5} more` : ""}
1220
1735
  `
@@ -1231,23 +1746,69 @@ async function deploy(io2, options) {
1231
1746
  return true;
1232
1747
  });
1233
1748
  const uploadBytes = toUpload.reduce((sum, f) => sum + f.size, 0);
1234
- io2.stdout(
1749
+ r.say(
1235
1750
  `Uploading ${toUpload.length} of ${files.length} files (${formatBytes(uploadBytes)} of ${formatBytes(total)}; the rest are already on HostEdge)
1236
1751
  `
1237
1752
  );
1238
- await pool(toUpload, UPLOAD_CONCURRENCY, async (file) => {
1239
- const bytes = await readFile3(file.absolute);
1240
- await api.raw(`/api/v1/uploads/${file.hash}`, {
1241
- method: "PUT",
1242
- headers: { "content-type": "application/octet-stream" },
1243
- body: bytes
1244
- });
1753
+ let uploaded = 0;
1754
+ let reported = 0;
1755
+ const step = Math.max(1, Math.ceil(toUpload.length / 10));
1756
+ const progress = (count2) => {
1757
+ uploaded += count2;
1758
+ if (toUpload.length > 20 && uploaded - reported >= step && uploaded < toUpload.length) {
1759
+ reported = uploaded;
1760
+ r.say(`${s.dim(` ${uploaded}/${toUpload.length} uploaded`)}
1761
+ `);
1762
+ }
1763
+ };
1764
+ const send = (path, init2, bytes) => withRetries(
1765
+ () => api.raw(path, {
1766
+ ...init2,
1767
+ // 30 s plus a few more for every MB, so a big file on a slow line can finish.
1768
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS + Math.ceil(bytes / 1048576) * 4e3)
1769
+ }),
1770
+ temporary,
1771
+ 6,
1772
+ retryDelay
1773
+ );
1774
+ let batching = true;
1775
+ await pool(batchesOf(toUpload), UPLOAD_CONCURRENCY, async (batch) => {
1776
+ if (batching && (batch.length > 1 || (batch[0]?.size ?? 0) <= BATCH_BYTES)) {
1777
+ const form = new FormData();
1778
+ for (const file of batch)
1779
+ form.append(file.hash, new Blob([await readFile4(file.absolute)]), file.hash);
1780
+ try {
1781
+ await send(
1782
+ "/api/v1/uploads/batch",
1783
+ { method: "POST", body: form },
1784
+ batch.reduce((sum, file) => sum + file.size, 0)
1785
+ );
1786
+ progress(batch.length);
1787
+ return;
1788
+ } catch (error) {
1789
+ if (!(error instanceof ApiError) || error.status !== 404 && error.status !== 405)
1790
+ throw error;
1791
+ batching = false;
1792
+ }
1793
+ }
1794
+ for (const file of batch) {
1795
+ await send(
1796
+ `/api/v1/uploads/${file.hash}`,
1797
+ {
1798
+ method: "PUT",
1799
+ headers: { "content-type": "application/octet-stream" },
1800
+ body: await readFile4(file.absolute)
1801
+ },
1802
+ file.size
1803
+ );
1804
+ progress(1);
1805
+ }
1245
1806
  });
1246
1807
  let project;
1247
1808
  if (!link) {
1248
1809
  let pkg = null;
1249
1810
  try {
1250
- pkg = await readFile3(join3(dir, "package.json"), "utf8");
1811
+ pkg = await readFile4(join4(dir, "package.json"), "utf8");
1251
1812
  } catch {
1252
1813
  }
1253
1814
  project = {
@@ -1258,12 +1819,13 @@ async function deploy(io2, options) {
1258
1819
  ).settings
1259
1820
  };
1260
1821
  }
1822
+ const environment = options.preview ? "preview" : "production";
1261
1823
  const created = await api.request("/api/v1/deployments", {
1262
1824
  json: {
1263
1825
  projectId: link?.projectId,
1264
1826
  project,
1265
1827
  files: files.map((f) => ({ path: f.path, hash: f.hash, size: f.size })),
1266
- environment: options.preview ? "preview" : "production",
1828
+ environment,
1267
1829
  commitMessage: options.message ?? null
1268
1830
  }
1269
1831
  });
@@ -1275,76 +1837,120 @@ async function deploy(io2, options) {
1275
1837
  workspace: me.workspace.slug
1276
1838
  });
1277
1839
  await ensureGitignored(dir, ".hostedge");
1278
- io2.stdout(
1279
- `${s.green("\u2713")} Created project ${s.bold(created.projectSlug ?? created.projectId)}
1280
- `
1281
- );
1840
+ r.say(`${s.green("\u2713")} Created project ${s.bold(created.projectSlug ?? created.projectId)}
1841
+ `);
1282
1842
  }
1283
- io2.stdout(`Deployment ${s.bold(created.deploymentId)}
1843
+ r.say(`Deployment ${s.bold(created.deploymentId)}
1284
1844
  `);
1845
+ const describe = (outcome) => ({
1846
+ deploymentId: created.deploymentId,
1847
+ status: outcome.status,
1848
+ url: created.url,
1849
+ projectId: created.projectId,
1850
+ environment,
1851
+ failureReason: outcome.failureReason
1852
+ });
1285
1853
  if (options.wait === false) {
1286
- io2.stdout(`Queued. Follow it at ${created.url}
1287
- `);
1288
- return 0;
1854
+ r.result(
1855
+ describe({ status: created.status, failureReason: null }),
1856
+ `Queued. Follow it at ${created.url}
1857
+ `,
1858
+ `${created.url}
1859
+ `
1860
+ );
1861
+ return EXIT.ok;
1289
1862
  }
1290
- const result = await follow(io2, api, created.deploymentId);
1863
+ let result = await follow(r, api, created.deploymentId);
1864
+ if (!FINISHED.has(result.status)) result = await lookup(api, created.deploymentId) ?? result;
1291
1865
  const seconds = ((Date.now() - started) / 1e3).toFixed(1);
1292
1866
  if (result.status === "ready") {
1293
- io2.stdout(`${s.green("\u2713")} Live in ${seconds}s: ${s.bold(created.url)}
1294
- `);
1295
- return 0;
1867
+ r.result(
1868
+ describe(result),
1869
+ `${s.green("\u2713")} Live in ${seconds}s: ${s.bold(created.url)}
1870
+ `,
1871
+ `${created.url}
1872
+ `
1873
+ );
1874
+ return EXIT.ok;
1296
1875
  }
1297
- if (result.status === "canceled") {
1298
- io2.stderr(`Deployment canceled.
1876
+ const canceled = result.status === "canceled";
1877
+ const failed = result.status === "failed";
1878
+ const reason = result.failureReason ?? (canceled ? "Deployment canceled." : failed ? null : `The log stream ended while the deployment was ${result.status}. Check it at ${created.url}.`);
1879
+ const human = canceled ? "Deployment canceled.\n" : `${s.red("\u2717")} Deployment ${failed ? "failed" : `stopped while ${result.status}`}${reason ? `: ${reason}` : ""}
1880
+ `;
1881
+ if (r.json) r.io.stdout(`${JSON.stringify(describe({ ...result, failureReason: reason }))}
1299
1882
  `);
1300
- return 1;
1301
- }
1302
- io2.stderr(
1303
- `${s.red("\u2717")} Deployment ${result.status === "failed" ? "failed" : `stopped while ${result.status}`}${result.failureReason ? `: ${result.failureReason}` : ""}
1304
- `
1305
- );
1306
- return 1;
1883
+ io2.stderr(human);
1884
+ return failed || canceled ? EXIT.deployFailed : EXIT.failure;
1307
1885
  }
1308
1886
 
1309
1887
  // src/commands/env.ts
1310
- import { writeFile as writeFile2 } from "node:fs/promises";
1311
- import { resolve as resolve3 } from "node:path";
1888
+ import { writeFile as writeFile3 } from "node:fs/promises";
1889
+ import { resolve as resolve4 } from "node:path";
1312
1890
 
1313
1891
  // src/commands/logs.ts
1314
- import { resolve as resolve2 } from "node:path";
1892
+ import { resolve as resolve3 } from "node:path";
1315
1893
  var POLL_MS = 2e3;
1894
+ var LEVELS = ["debug", "info", "warn", "error"];
1895
+ var UNIT_MS = { m: 6e4, h: 36e5, d: 864e5 };
1896
+ var RANGES = [
1897
+ { name: "1h", ms: UNIT_MS.h },
1898
+ { name: "24h", ms: 24 * UNIT_MS.h },
1899
+ { name: "7d", ms: 7 * UNIT_MS.d }
1900
+ ];
1316
1901
  async function requireLink(io2, dir = ".") {
1317
- const link = await readLink(resolve2(io2.cwd, dir));
1902
+ const link = await readLink(resolve3(io2.cwd, dir));
1318
1903
  if (!link)
1319
1904
  throw new CliError(
1320
- "This folder isn\u2019t linked to a project. Run `hostedge init` or `hostedge deploy`."
1905
+ "This folder isn\u2019t linked to a project. Run `hostedge init` or `hostedge deploy`.",
1906
+ EXIT.notFound
1321
1907
  );
1322
1908
  return link;
1323
1909
  }
1910
+ function parseSince(text) {
1911
+ const match = /^(\d+)([mhd])$/.exec(text.trim());
1912
+ const ms = match ? Number(match[1]) * UNIT_MS[match[2]] : 0;
1913
+ if (!match || ms <= 0)
1914
+ throw new CliError(
1915
+ `Unknown --since \u201C${text}\u201D. Use a duration like 30m, 1h, 24h or 7d (at most 7d).`,
1916
+ EXIT.usage
1917
+ );
1918
+ const range = RANGES.find((r) => ms <= r.ms);
1919
+ if (!range) throw new CliError(`--since can look back 7 days at most, not \u201C${text}\u201D.`, EXIT.usage);
1920
+ return { range: range.name, windowMs: ms === range.ms ? null : ms };
1921
+ }
1324
1922
  var newer = (a, b) => a.ts > b.ts || a.ts === b.ts && a.id > b.id;
1325
- async function logs(io2, options) {
1923
+ async function logs(r, options) {
1924
+ const { io: io2 } = r;
1925
+ if (options.level && !LEVELS.includes(options.level))
1926
+ throw new CliError(`Unknown --level \u201C${options.level}\u201D. Use ${LEVELS.join(", ")}.`, EXIT.usage);
1927
+ const since = options.since ? parseSince(options.since) : null;
1326
1928
  const link = await requireLink(io2);
1327
1929
  const api = await connect(io2);
1328
- const s = styles(io2);
1930
+ const s = r.s;
1329
1931
  const query = new URLSearchParams();
1330
1932
  if (options.level) query.set("level", options.level);
1331
1933
  if (options.search) query.set("q", options.search);
1332
- query.set("range", options.since ?? (options.follow ? "1h" : "24h"));
1934
+ query.set("range", since?.range ?? (options.follow ? "1h" : "24h"));
1935
+ const cutoff = since?.windowMs ? Date.now() - since.windowMs : 0;
1333
1936
  const print = (row) => {
1334
- const time = new Date(row.ts).toISOString().replace("T", " ").slice(0, 19);
1937
+ const time = new Date(row.ts).toISOString();
1335
1938
  const level = row.level.toUpperCase().padEnd(5);
1336
1939
  const colored = row.level === "error" ? s.red(level) : row.level === "warn" ? s.yellow(level) : s.dim(level);
1337
- io2.stdout(`${s.dim(time)} ${colored} ${row.message}
1338
- `);
1940
+ r.line(
1941
+ { id: row.id, timestamp: time, level: row.level, type: row.type, message: row.message },
1942
+ `${s.dim(time.replace("T", " ").slice(0, 19))} ${colored} ${row.message}
1943
+ `
1944
+ );
1339
1945
  };
1340
1946
  const fetchRows = async () => (await api.request(
1341
1947
  `/api/v1/projects/${link.projectId}/logs?${query.toString()}`
1342
- )).rows;
1948
+ )).rows.filter((row) => row.ts >= cutoff);
1343
1949
  const first = await fetchRows();
1344
1950
  for (const row of [...first].reverse()) print(row);
1345
1951
  if (!options.follow) {
1346
- if (first.length === 0) io2.stdout(s.dim("No logs in this range.\n"));
1347
- return 0;
1952
+ if (first.length === 0) r.say(s.dim("No logs in this range.\n"));
1953
+ return EXIT.ok;
1348
1954
  }
1349
1955
  let cursor = first[0] ? { ts: first[0].ts, id: first[0].id } : { ts: 0, id: "" };
1350
1956
  while (!options.signal?.aborted) {
@@ -1355,11 +1961,12 @@ async function logs(io2, options) {
1355
1961
  const last = rows.at(-1);
1356
1962
  if (last) cursor = { ts: last.ts, id: last.id };
1357
1963
  }
1358
- return 0;
1964
+ return EXIT.ok;
1359
1965
  }
1360
1966
 
1361
1967
  // src/commands/env.ts
1362
- async function envPull(io2, options) {
1968
+ async function envPull(r, options) {
1969
+ const { io: io2 } = r;
1363
1970
  const link = await requireLink(io2);
1364
1971
  const api = await connect(io2);
1365
1972
  const environment = options.environment ?? "development";
@@ -1368,58 +1975,87 @@ async function envPull(io2, options) {
1368
1975
  `/api/v1/projects/${link.projectId}/env?${new URLSearchParams({ environment, format: "dotenv" })}`
1369
1976
  );
1370
1977
  const text = await response.text();
1371
- const path = resolve3(io2.cwd, file);
1372
- await writeFile2(path, text, { mode: 384 });
1373
- const count = text.split("\n").filter(Boolean).length;
1978
+ const path = resolve4(io2.cwd, file);
1979
+ await writeFile3(path, text, { mode: 384 });
1980
+ const count2 = text.split("\n").filter(Boolean).length;
1374
1981
  const ignored = await ensureGitignored(io2.cwd, file);
1375
- const s = styles(io2);
1376
- io2.stdout(
1377
- `${s.green("\u2713")} Wrote ${count} ${environment} variable${count === 1 ? "" : "s"} to ${file}${ignored ? ` (added to .gitignore)` : ""}.
1982
+ const s = r.s;
1983
+ r.result(
1984
+ { environment, file, count: count2, gitignored: ignored },
1985
+ `${s.green("\u2713")} Wrote ${count2} ${environment} variable${count2 === 1 ? "" : "s"} to ${file}${ignored ? ` (added to .gitignore)` : ""}.
1378
1986
  `
1379
1987
  );
1380
- return 0;
1988
+ return EXIT.ok;
1381
1989
  }
1382
1990
 
1383
1991
  // src/commands/init.ts
1384
- import { readFile as readFile4 } from "node:fs/promises";
1385
- import { basename as basename2, join as join4, resolve as resolve4 } from "node:path";
1386
- async function init(io2, options) {
1387
- const dir = resolve4(io2.cwd, options.dir ?? ".");
1388
- const s = styles(io2);
1992
+ import { readFile as readFile5 } from "node:fs/promises";
1993
+ import { basename as basename2, join as join5, resolve as resolve5 } from "node:path";
1994
+ async function init(r, options) {
1995
+ const { io: io2 } = r;
1996
+ const dir = resolve5(io2.cwd, options.dir ?? ".");
1997
+ const s = r.s;
1389
1998
  const existing = await readLink(dir);
1390
- if (existing && !options.force) {
1391
- io2.stdout(
1999
+ let kit = null;
2000
+ if (options.ai) {
2001
+ kit = await writeKit(dir, { force: options.force });
2002
+ r.say(formatKit(r, kit));
2003
+ }
2004
+ const files = kit ? { dir, files: kit } : void 0;
2005
+ const relink = options.force && (!options.ai || options.project || options.name);
2006
+ if (existing && !relink) {
2007
+ r.result(
2008
+ {
2009
+ dir,
2010
+ linked: true,
2011
+ alreadyLinked: true,
2012
+ project: { id: existing.projectId, slug: existing.slug, workspace: existing.workspace },
2013
+ ai: files
2014
+ },
1392
2015
  `Already linked to ${s.bold(existing.slug)}. Use --force to link a different project.
2016
+ `,
2017
+ ""
2018
+ );
2019
+ return EXIT.ok;
2020
+ }
2021
+ if (options.ai && !(await credentials(io2)).token) {
2022
+ r.result(
2023
+ { dir, linked: false, linkSkipped: "not signed in", ai: files },
2024
+ `${s.dim("Not signed in, so the folder is not linked yet. Run `hostedge login` then `hostedge init`.")}
1393
2025
  `
1394
2026
  );
1395
- return 0;
2027
+ return EXIT.ok;
1396
2028
  }
1397
2029
  const api = await connect(io2);
1398
2030
  const me = await api.request("/api/v1/me");
1399
2031
  let project;
2032
+ let framework;
1400
2033
  if (options.project) {
1401
2034
  const { projects } = await api.request("/api/v1/projects");
1402
2035
  const found = projects.find((p) => p.id === options.project || p.slug === options.project);
1403
2036
  if (!found)
1404
2037
  throw new CliError(
1405
- `No project \u201C${options.project}\u201D in ${me.workspace.slug}. Run \`hostedge init\` without --project to create one.`
2038
+ `No project \u201C${options.project}\u201D in ${me.workspace.slug}. Run \`hostedge init\` without --project to create one.`,
2039
+ EXIT.notFound
1406
2040
  );
1407
2041
  project = found;
2042
+ framework = found.framework ?? null;
1408
2043
  } else {
1409
- const { files } = await collectFiles(dir, { maxFiles: 1e5, hash: false });
2044
+ const { files: found } = await collectFiles(dir, { maxFiles: 1e5, hash: false });
1410
2045
  let pkg = null;
1411
2046
  try {
1412
- pkg = await readFile4(join4(dir, "package.json"), "utf8");
2047
+ pkg = await readFile5(join5(dir, "package.json"), "utf8");
1413
2048
  } catch {
1414
2049
  }
1415
2050
  const detection = detectFramework(
1416
- files.map((f) => f.path),
2051
+ found.map((f) => f.path),
1417
2052
  pkg
1418
2053
  );
1419
2054
  const settings = detection.settings;
1420
- io2.stdout(`Detected ${s.bold(FRAMEWORK_INFO[settings.framework].name)}: ${detection.reason}
2055
+ framework = settings.framework;
2056
+ r.say(`Detected ${s.bold(FRAMEWORK_INFO[settings.framework].name)}: ${detection.reason}
1421
2057
  `);
1422
- if (detection.problem) io2.stdout(`${s.yellow("!")} ${detection.problem}
2058
+ if (detection.problem) r.say(`${s.yellow("!")} ${detection.problem}
1423
2059
  `);
1424
2060
  project = await api.request("/api/v1/projects", {
1425
2061
  json: { name: options.name ?? basename2(dir), settings }
@@ -1427,19 +2063,27 @@ async function init(io2, options) {
1427
2063
  }
1428
2064
  await writeLink(dir, { projectId: project.id, slug: project.slug, workspace: me.workspace.slug });
1429
2065
  await ensureGitignored(dir, ".hostedge");
1430
- io2.stdout(
2066
+ r.result(
2067
+ {
2068
+ dir,
2069
+ linked: true,
2070
+ alreadyLinked: false,
2071
+ project: { id: project.id, slug: project.slug, workspace: me.workspace.slug },
2072
+ framework,
2073
+ ai: files
2074
+ },
1431
2075
  `${s.green("\u2713")} Linked ${s.bold(project.slug)}. Run ${s.bold("hostedge deploy")} to publish it.
1432
2076
  `
1433
2077
  );
1434
- return 0;
2078
+ return EXIT.ok;
1435
2079
  }
1436
2080
 
1437
2081
  // src/commands/share.ts
1438
2082
  import { spawn as nodeSpawn } from "node:child_process";
1439
- import { chmod as chmod2, mkdir as mkdir2, rename, rm as rm2, stat, writeFile as writeFile3 } from "node:fs/promises";
2083
+ import { chmod as chmod2, mkdir as mkdir3, rename, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
1440
2084
  import { connect as netConnect } from "node:net";
1441
2085
  import { homedir as homedir2 } from "node:os";
1442
- import { delimiter, join as join5 } from "node:path";
2086
+ import { delimiter, join as join6 } from "node:path";
1443
2087
 
1444
2088
  // ../../node_modules/.pnpm/uqr@0.1.3/node_modules/uqr/dist/index.mjs
1445
2089
  var QrCodeDataType = /* @__PURE__ */ ((QrCodeDataType2) => {
@@ -2142,31 +2786,33 @@ function cloudflaredAsset(platform, arch) {
2142
2786
  if (platform === "win32" && cpu === "amd64") return "cloudflared-windows-amd64.exe";
2143
2787
  return null;
2144
2788
  }
2145
- var exists = async (path) => (await stat(path).catch(() => null))?.isFile() ?? false;
2146
- async function findCloudflared(io2) {
2147
- if (io2.env.HOSTEDGE_CLOUDFLARED) return io2.env.HOSTEDGE_CLOUDFLARED;
2789
+ var exists = async (path) => (await stat2(path).catch(() => null))?.isFile() ?? false;
2790
+ async function findCloudflared(r) {
2791
+ const { io: io2 } = r;
2792
+ const override = io2.env.HOSTEDGE_TUNNEL_HELPER ?? io2.env.HOSTEDGE_CLOUDFLARED;
2793
+ if (override) return override;
2148
2794
  const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
2149
2795
  for (const dir of (io2.env.PATH ?? "").split(delimiter).filter(Boolean))
2150
- if (await exists(join5(dir, exe))) return join5(dir, exe);
2151
- const binDir = join5(io2.env.HOME ?? homedir2(), ".hostedge", "bin");
2152
- const own = join5(binDir, exe);
2796
+ if (await exists(join6(dir, exe))) return join6(dir, exe);
2797
+ const binDir = join6(io2.env.HOME ?? homedir2(), ".hostedge", "bin");
2798
+ const own = join6(binDir, exe);
2153
2799
  if (await exists(own)) return own;
2154
2800
  const asset = cloudflaredAsset(process.platform, process.arch);
2155
2801
  if (!asset)
2156
2802
  throw new CliError(
2157
- "Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and run this again."
2803
+ "Install the open-source tunnel helper (the cloudflared command) and run this again, or set HOSTEDGE_TUNNEL_HELPER to its path."
2158
2804
  );
2159
- io2.stderr("Downloading cloudflared (one time)\u2026\n");
2805
+ r.warn("Downloading the tunnel helper (one time)\u2026\n");
2160
2806
  const response = await io2.fetch(`${RELEASES}/${asset}`);
2161
- if (!response.ok) throw new CliError(`Could not download cloudflared (${response.status}).`);
2162
- await mkdir2(binDir, { recursive: true });
2163
- const tmp = join5(binDir, `${asset}.download`);
2164
- await writeFile3(tmp, new Uint8Array(await response.arrayBuffer()));
2807
+ if (!response.ok) throw new CliError(`Could not download the tunnel helper (${response.status}).`);
2808
+ await mkdir3(binDir, { recursive: true });
2809
+ const tmp = join6(binDir, `${asset}.download`);
2810
+ await writeFile4(tmp, new Uint8Array(await response.arrayBuffer()));
2165
2811
  if (asset.endsWith(".tgz")) {
2166
- await new Promise((resolve5, reject) => {
2812
+ await new Promise((resolve6, reject) => {
2167
2813
  const tar = nodeSpawn("tar", ["-xzf", tmp, "-C", binDir], { stdio: "ignore" });
2168
2814
  tar.on("error", reject);
2169
- tar.on("exit", (code) => code === 0 ? resolve5() : reject(new Error(`tar exited ${code}`)));
2815
+ tar.on("exit", (code) => code === 0 ? resolve6() : reject(new Error(`tar exited ${code}`)));
2170
2816
  });
2171
2817
  await rm2(tmp, { force: true });
2172
2818
  } else {
@@ -2176,27 +2822,31 @@ async function findCloudflared(io2) {
2176
2822
  return own;
2177
2823
  }
2178
2824
  function listening(port) {
2179
- return new Promise((resolve5) => {
2825
+ return new Promise((resolve6) => {
2180
2826
  const socket = netConnect({ host: "127.0.0.1", port });
2181
2827
  socket.once("connect", () => {
2182
2828
  socket.destroy();
2183
- resolve5(true);
2829
+ resolve6(true);
2184
2830
  });
2185
- socket.once("error", () => resolve5(false));
2831
+ socket.once("error", () => resolve6(false));
2186
2832
  socket.setTimeout(1e3, () => {
2187
2833
  socket.destroy();
2188
- resolve5(false);
2834
+ resolve6(false);
2189
2835
  });
2190
2836
  });
2191
2837
  }
2192
2838
  var QUICK_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2193
- async function share(io2, options) {
2194
- const s = styles(io2);
2839
+ async function share(r, options) {
2840
+ const { io: io2 } = r;
2841
+ const s = r.s;
2195
2842
  const port = Number(options.port ?? "3000");
2196
2843
  if (!Number.isInteger(port) || port < 1 || port > 65535)
2197
- throw new CliError("Give the port your dev server runs on, e.g. `hostedge share 5173`.", 2);
2844
+ throw new CliError(
2845
+ "Give the port your dev server runs on, e.g. `hostedge share 5173`.",
2846
+ EXIT.usage
2847
+ );
2198
2848
  if (!await listening(port))
2199
- io2.stderr(
2849
+ r.warn(
2200
2850
  `${s.yellow("Note:")} nothing answers on localhost:${port} yet. Start your dev server; the link works as soon as it does.
2201
2851
  `
2202
2852
  );
@@ -2204,7 +2854,7 @@ async function share(io2, options) {
2204
2854
  let opened = { mode: "quick" };
2205
2855
  const api = token && !options.quick ? createApi(io2, apiUrl, token) : null;
2206
2856
  if (api) opened = await api.request("/api/v1/shares", { json: { port } });
2207
- const binary = await findCloudflared(io2);
2857
+ const binary = await findCloudflared(r);
2208
2858
  const hostHeader = options.hostHeader ?? "localhost";
2209
2859
  const args = opened.mode === "tunnel" && opened.token ? ["tunnel", "--no-autoupdate", "run", "--token", opened.token] : [
2210
2860
  "tunnel",
@@ -2220,7 +2870,9 @@ async function share(io2, options) {
2220
2870
  const tail = [];
2221
2871
  const remember = (chunk) => {
2222
2872
  for (const line of chunk.toString().split("\n").filter((l) => l.trim())) {
2223
- tail.push(line.trim());
2873
+ tail.push(
2874
+ line.trim().replace(/\bcloudflared\b/gi, "the tunnel helper").replace(/\bCloudflare\b/gi, "the platform")
2875
+ );
2224
2876
  if (tail.length > 4) tail.shift();
2225
2877
  }
2226
2878
  };
@@ -2229,14 +2881,24 @@ async function share(io2, options) {
2229
2881
  const announce = (url) => {
2230
2882
  if (progress.announced) return;
2231
2883
  progress.announced = true;
2232
- io2.stdout(`
2884
+ if (r.json)
2885
+ io2.stdout(
2886
+ `${JSON.stringify({ mode: opened.mode, url, expiresAt: opened.share?.expiresAt ?? null })}
2887
+ `
2888
+ );
2889
+ if (r.quiet) {
2890
+ if (!r.json) io2.stdout(`${url}
2891
+ `);
2892
+ return;
2893
+ }
2894
+ r.always(`
2233
2895
  ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2234
2896
 
2235
2897
  `);
2236
- io2.stdout(`${renderUnicodeCompact(url, { border: 1 })}
2898
+ r.always(`${renderUnicodeCompact(url, { border: 1 })}
2237
2899
 
2238
2900
  `);
2239
- io2.stdout(
2901
+ r.always(
2240
2902
  `Scan with your phone, or share the link.${opened.share ? ` It closes when you press Ctrl+C, or at ${new Date(opened.share.expiresAt).toLocaleTimeString()}.` : " Quick links are public and anonymous; they close when you press Ctrl+C."}
2241
2903
  `
2242
2904
  );
@@ -2251,7 +2913,7 @@ ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2251
2913
  child.stderr?.on("data", watch);
2252
2914
  } else {
2253
2915
  if (!api && !options.quick)
2254
- io2.stderr(
2916
+ r.warn(
2255
2917
  `${s.dim("Not signed in: using a quick tunnel. `hostedge login` for a hostedge.app link.")}
2256
2918
  `
2257
2919
  );
@@ -2262,59 +2924,177 @@ ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2262
2924
  child.stdout?.on("data", watch);
2263
2925
  child.stderr?.on("data", watch);
2264
2926
  }
2265
- const exitCode = await new Promise((resolve5) => {
2927
+ const exitCode = await new Promise((resolve6) => {
2266
2928
  const stop = () => {
2267
2929
  child.kill("SIGTERM");
2268
2930
  };
2269
2931
  options.signal?.addEventListener("abort", stop, { once: true });
2270
- child.on("error", () => resolve5(1));
2932
+ child.on("error", () => resolve6(1));
2271
2933
  child.on("exit", (code) => {
2272
2934
  options.signal?.removeEventListener("abort", stop);
2273
- resolve5(options.signal?.aborted ? 0 : code ?? 1);
2935
+ resolve6(options.signal?.aborted ? 0 : code ?? 1);
2274
2936
  });
2275
2937
  });
2276
2938
  if (api && opened.share) {
2277
2939
  await api.request(`/api/v1/shares/${encodeURIComponent(opened.share.id)}`, { method: "DELETE" }).catch(() => void 0);
2278
- io2.stdout(`${s.dim("Share closed.")}
2940
+ r.say(`${s.dim("Share closed.")}
2279
2941
  `);
2280
2942
  }
2281
2943
  if (!progress.announced && exitCode !== 0)
2282
2944
  throw new CliError(
2283
- `cloudflared stopped before the link was ready. Check your connection.${tail.length ? `
2945
+ `The tunnel helper stopped before the link was ready. Check your connection.${tail.length ? `
2284
2946
  ${tail.join("\n")}` : ""}`
2285
2947
  );
2286
2948
  return exitCode;
2287
2949
  }
2288
2950
 
2951
+ // src/report.ts
2952
+ function isCi(env) {
2953
+ const value = env.CI;
2954
+ return value !== void 0 && value !== "" && value !== "0" && value.toLowerCase() !== "false";
2955
+ }
2956
+ var TOKEN_LIKE = /hostedge_[A-Za-z0-9_-]{8,}/g;
2957
+ function redact(text) {
2958
+ return text.replace(TOKEN_LIKE, "hostedge_\u2026");
2959
+ }
2960
+ var Reporter = class {
2961
+ constructor(io2, flags) {
2962
+ this.io = io2;
2963
+ this.flags = flags;
2964
+ }
2965
+ io;
2966
+ flags;
2967
+ get json() {
2968
+ return this.flags.json;
2969
+ }
2970
+ get quiet() {
2971
+ return this.flags.quiet;
2972
+ }
2973
+ /** Where human text goes. */
2974
+ get human() {
2975
+ return this.flags.json ? "stderr" : "stdout";
2976
+ }
2977
+ /** Styles for human text (colors only if that stream is a terminal). */
2978
+ get s() {
2979
+ return styles(this.io, this.human, this.flags.noColor);
2980
+ }
2981
+ /** Whether a person could answer a prompt. False for --yes, CI, or when stdin is piped. */
2982
+ get interactive() {
2983
+ return !this.flags.yes && !isCi(this.io.env) && this.io.isStdinTty;
2984
+ }
2985
+ write(stream, text) {
2986
+ if (stream === "stdout") this.io.stdout(text);
2987
+ else this.io.stderr(text);
2988
+ }
2989
+ /** Progress and decoration for people: hidden by --quiet. */
2990
+ say(text) {
2991
+ if (!this.flags.quiet) this.write(this.human, text);
2992
+ }
2993
+ /** Human output that --quiet keeps (the one line a script wants). */
2994
+ always(text) {
2995
+ this.write(this.human, text);
2996
+ }
2997
+ /** A note for people on stderr (never stdout): hidden by --quiet. */
2998
+ warn(text) {
2999
+ if (!this.flags.quiet) this.io.stderr(text);
3000
+ }
3001
+ /**
3002
+ * The command's result. With --json: one JSON document on stdout, and `human` on stderr
3003
+ * for whoever is watching. Otherwise `human` on stdout, or just `quiet` with --quiet
3004
+ * (the one line a script wants; empty by default).
3005
+ */
3006
+ result(data, human, quiet = "") {
3007
+ if (this.flags.json) {
3008
+ this.io.stdout(`${JSON.stringify(data)}
3009
+ `);
3010
+ if (!this.flags.quiet) this.io.stderr(human);
3011
+ return;
3012
+ }
3013
+ const text = this.flags.quiet ? quiet : human;
3014
+ if (text) this.io.stdout(text);
3015
+ }
3016
+ /** One NDJSON line with --json, a human line otherwise: for streaming commands. */
3017
+ line(data, human) {
3018
+ if (this.flags.json) this.io.stdout(`${JSON.stringify(data)}
3019
+ `);
3020
+ else this.io.stdout(human);
3021
+ }
3022
+ /** Reports a failure: human text on stderr, plus a JSON error envelope on stdout. */
3023
+ fail(message, exitCode) {
3024
+ const clean = redact(message);
3025
+ const red = styles(this.io, "stderr", this.flags.noColor).red;
3026
+ this.io.stderr(`${red("Error:")} ${clean}
3027
+ `);
3028
+ if (this.flags.json)
3029
+ this.io.stdout(
3030
+ `${JSON.stringify({ error: { code: exitName(exitCode), message: clean, exitCode } })}
3031
+ `
3032
+ );
3033
+ }
3034
+ };
3035
+
2289
3036
  // src/cli.ts
2290
- var VERSION = "0.1.0";
3037
+ var VERSION = "0.1.2";
2291
3038
  var HELP = `hostedge \u2014 deploy to HostEdge from your terminal
2292
3039
 
2293
3040
  Usage
2294
- hostedge login [--token <token>] [--api <url>] Sign in (opens your browser)
3041
+ hostedge login [--token <t> | --with-token] [--api-url <url>]
3042
+ Sign in (opens your browser; --with-token reads stdin)
2295
3043
  hostedge logout Forget the saved token
2296
3044
  hostedge whoami Show who and where you are signed in
2297
- hostedge init [dir] [--name <name>] [--project <id|slug>] [--force]
2298
- Link a folder to a project
2299
- hostedge deploy [dir] [--preview] [--message <text>] [--no-wait]
2300
- Upload and deploy a folder
2301
- hostedge logs [--follow] [--level <level>] [--search <text>] [--since 1h|24h|7d]
2302
- Show runtime logs
3045
+ hostedge init [dir] [--name <name>] [--project <id|slug>] [--force] [--ai]
3046
+ Link a folder to a project; --ai also writes
3047
+ AGENTS.md, CLAUDE.md, .cursor/rules and a Claude skill
3048
+ hostedge ai-kit [dir] [--force] Write the assistant instruction files only
3049
+ hostedge deploy [dir] [--preview] [--message <text>] [--wait | --no-wait]
3050
+ Upload and deploy a folder (waits by default)
3051
+ hostedge logs [--follow] [--level <level>] [--search <text>] [--since 30m|1h|24h|7d]
3052
+ Show runtime logs (--json: one JSON object per line)
2303
3053
  hostedge env pull [--environment <name>] [--file <path>]
2304
3054
  Write variables to a .env file
2305
3055
  hostedge share [port] [--quick] [--host-header <host>]
2306
3056
  Public HTTPS link to a local dev server
2307
3057
  (try it on your phone before deploying)
2308
3058
 
3059
+ Options for every command
3060
+ --json Machine-readable output on stdout; progress and text go to stderr
3061
+ -y, --yes Never prompt; with CI=1 or no TTY it never waits for input either
3062
+ -q, --quiet Only results and errors (deploy prints just the URL)
3063
+ --api-url <url> Use another HostEdge for this run (default https://hostedge.app)
3064
+ --no-color Plain text (NO_COLOR is respected too)
3065
+ -h, --help Show this help -v, --version Show the version
3066
+
2309
3067
  Environment
2310
- HOSTEDGE_TOKEN API token (CI); overrides the saved login
2311
- HOSTEDGE_API_URL HostEdge URL (default https://hostedge.app)
2312
- HOSTEDGE_CLOUDFLARED Path to cloudflared (otherwise PATH, then a one-time download)
2313
- NO_COLOR Disable colors
3068
+ HOSTEDGE_TOKEN API token (CI); overrides the saved login. Never printed.
3069
+ HOSTEDGE_API_URL HostEdge URL (default https://hostedge.app)
3070
+ HOSTEDGE_TUNNEL_HELPER Path to the tunnel helper (otherwise PATH, then a one-time download)
3071
+ NO_COLOR Disable colors
3072
+ CI Treated like --yes
3073
+
3074
+ Exit codes
3075
+ ${CLI_EXIT_CODES.map((c) => ` ${c.code} ${c.name.padEnd(14)}${c.meaning}`).join("\n")}
3076
+
3077
+ Agents: \`hostedge deploy --wait --json\` prints {"deploymentId","status","url","failureReason"}
3078
+ and exits 5 when the build fails. Docs: https://hostedge.app/docs/agent-kit
2314
3079
  `;
2315
3080
  var str = (value) => typeof value === "string" ? value : void 0;
2316
- async function run(argv, io2, signal) {
2317
- const s = styles(io2);
3081
+ function apiUrlOf(value) {
3082
+ let url;
3083
+ try {
3084
+ url = new URL(value);
3085
+ } catch {
3086
+ throw new CliError(
3087
+ `--api-url \u201C${value}\u201D is not a URL. Use e.g. https://hostedge.app.`,
3088
+ EXIT.usage
3089
+ );
3090
+ }
3091
+ if (url.protocol !== "https:" && url.protocol !== "http:")
3092
+ throw new CliError(`--api-url must start with https:// or http://, not \u201C${value}\u201D.`, EXIT.usage);
3093
+ return value.replace(/\/+$/, "");
3094
+ }
3095
+ async function run(argv, baseIo, signal) {
3096
+ const wantsJson = argv.includes("--json");
3097
+ const early = new Reporter(baseIo, { json: wantsJson, quiet: false, yes: false, noColor: false });
2318
3098
  let parsed;
2319
3099
  try {
2320
3100
  parsed = parseArgs({
@@ -2324,13 +3104,22 @@ async function run(argv, io2, signal) {
2324
3104
  options: {
2325
3105
  help: { type: "boolean", short: "h" },
2326
3106
  version: { type: "boolean", short: "v" },
3107
+ json: { type: "boolean" },
3108
+ yes: { type: "boolean", short: "y" },
3109
+ quiet: { type: "boolean", short: "q" },
3110
+ "no-color": { type: "boolean" },
3111
+ "api-url": { type: "string" },
2327
3112
  token: { type: "string" },
3113
+ "with-token": { type: "boolean" },
3114
+ // The original spelling of --api-url, still accepted.
2328
3115
  api: { type: "string" },
2329
3116
  name: { type: "string" },
2330
3117
  project: { type: "string" },
2331
3118
  force: { type: "boolean" },
3119
+ ai: { type: "boolean" },
2332
3120
  preview: { type: "boolean" },
2333
3121
  message: { type: "string", short: "m" },
3122
+ wait: { type: "boolean" },
2334
3123
  "no-wait": { type: "boolean" },
2335
3124
  follow: { type: "boolean", short: "f" },
2336
3125
  level: { type: "string" },
@@ -2343,46 +3132,64 @@ async function run(argv, io2, signal) {
2343
3132
  }
2344
3133
  });
2345
3134
  } catch (error) {
2346
- io2.stderr(`${error.message}
2347
- Run \`hostedge --help\` for usage.
2348
- `);
2349
- return 2;
3135
+ early.fail(`${error.message}
3136
+ Run \`hostedge --help\` for usage.`, EXIT.usage);
3137
+ return EXIT.usage;
2350
3138
  }
2351
3139
  const { values, positionals } = parsed;
2352
3140
  const [command, ...rest] = positionals;
2353
3141
  if (values.version) {
2354
- io2.stdout(`${VERSION}
3142
+ if (wantsJson) early.result({ version: VERSION }, "");
3143
+ else baseIo.stdout(`${VERSION}
2355
3144
  `);
2356
- return 0;
3145
+ return EXIT.ok;
2357
3146
  }
2358
3147
  if (values.help || !command) {
2359
- io2.stdout(HELP);
2360
- return command || values.help ? 0 : 2;
3148
+ baseIo.stdout(HELP);
3149
+ return command || values.help ? EXIT.ok : EXIT.usage;
2361
3150
  }
3151
+ let r = early;
2362
3152
  try {
3153
+ const apiOverride = str(values["api-url"]) ?? str(values.api);
3154
+ const io2 = apiOverride ? { ...baseIo, env: { ...baseIo.env, HOSTEDGE_API_URL: apiUrlOf(apiOverride) } } : baseIo;
3155
+ r = new Reporter(io2, {
3156
+ json: values.json === true,
3157
+ quiet: values.quiet === true,
3158
+ yes: values.yes === true,
3159
+ noColor: values["no-color"] === true
3160
+ });
3161
+ if (values.wait && values["no-wait"])
3162
+ throw new CliError("Use either --wait or --no-wait, not both.", EXIT.usage);
2363
3163
  switch (command) {
2364
3164
  case "login":
2365
- return await login(io2, { token: str(values.token), api: str(values.api) });
3165
+ return await login(r, {
3166
+ token: str(values.token),
3167
+ withToken: values["with-token"] === true,
3168
+ api: apiOverride ? apiUrlOf(apiOverride) : void 0
3169
+ });
2366
3170
  case "logout":
2367
- return await logout(io2);
3171
+ return await logout(r);
2368
3172
  case "whoami":
2369
- return await whoami(io2);
3173
+ return await whoami(r);
2370
3174
  case "init":
2371
- return await init(io2, {
3175
+ return await init(r, {
2372
3176
  dir: rest[0],
2373
3177
  name: str(values.name),
2374
3178
  project: str(values.project),
2375
- force: values.force === true
3179
+ force: values.force === true,
3180
+ ai: values.ai === true
2376
3181
  });
3182
+ case "ai-kit":
3183
+ return await aiKit(r, { dir: rest[0], force: values.force === true });
2377
3184
  case "deploy":
2378
- return await deploy(io2, {
3185
+ return await deploy(r, {
2379
3186
  dir: rest[0],
2380
3187
  preview: values.preview === true,
2381
3188
  message: str(values.message),
2382
3189
  wait: values["no-wait"] !== true
2383
3190
  });
2384
3191
  case "logs":
2385
- return await logs(io2, {
3192
+ return await logs(r, {
2386
3193
  follow: values.follow === true,
2387
3194
  level: str(values.level),
2388
3195
  search: str(values.search),
@@ -2390,46 +3197,51 @@ Run \`hostedge --help\` for usage.
2390
3197
  signal
2391
3198
  });
2392
3199
  case "env":
2393
- if (rest[0] !== "pull") {
2394
- io2.stderr("Usage: hostedge env pull [--environment <name>] [--file <path>]\n");
2395
- return 2;
2396
- }
2397
- return await envPull(io2, { environment: str(values.environment), file: str(values.file) });
3200
+ if (rest[0] !== "pull")
3201
+ throw new CliError(
3202
+ "Usage: hostedge env pull [--environment <name>] [--file <path>]",
3203
+ EXIT.usage
3204
+ );
3205
+ return await envPull(r, { environment: str(values.environment), file: str(values.file) });
2398
3206
  case "share":
2399
- return await share(io2, {
3207
+ return await share(r, {
2400
3208
  port: rest[0],
2401
3209
  quick: values.quick === true,
2402
3210
  hostHeader: str(values["host-header"]),
2403
3211
  signal
2404
3212
  });
2405
3213
  default:
2406
- io2.stderr(`Unknown command \u201C${command}\u201D. Run \`hostedge --help\` for usage.
2407
- `);
2408
- return 2;
3214
+ throw new CliError(
3215
+ `Unknown command \u201C${command}\u201D. Run \`hostedge --help\` for usage.`,
3216
+ EXIT.usage
3217
+ );
2409
3218
  }
2410
3219
  } catch (error) {
2411
3220
  if (error instanceof CliError) {
2412
- io2.stderr(`${s.red("Error:")} ${error.message}
2413
- `);
3221
+ r.fail(error.message, error.exitCode);
2414
3222
  return error.exitCode;
2415
3223
  }
2416
- throw error;
3224
+ r.fail(
3225
+ `Unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`,
3226
+ EXIT.failure
3227
+ );
3228
+ return EXIT.failure;
2417
3229
  }
2418
3230
  }
2419
3231
 
2420
3232
  // src/main.ts
2421
3233
  function openUrl(url) {
2422
3234
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
2423
- return new Promise((resolve5) => {
3235
+ return new Promise((resolve6) => {
2424
3236
  try {
2425
3237
  const child = spawn(command, [url], { stdio: "ignore", detached: true });
2426
- child.on("error", () => resolve5(false));
3238
+ child.on("error", () => resolve6(false));
2427
3239
  child.on("spawn", () => {
2428
3240
  child.unref();
2429
- resolve5(true);
3241
+ resolve6(true);
2430
3242
  });
2431
3243
  } catch {
2432
- resolve5(false);
3244
+ resolve6(false);
2433
3245
  }
2434
3246
  });
2435
3247
  }
@@ -2442,7 +3254,14 @@ var io = {
2442
3254
  openUrl,
2443
3255
  // Undefined (not false) when stdout is a pipe.
2444
3256
  isTty: process.stdout.isTTY,
2445
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
3257
+ isErrTty: process.stderr.isTTY,
3258
+ isStdinTty: process.stdin.isTTY,
3259
+ readStdin: async () => {
3260
+ const chunks = [];
3261
+ for await (const chunk of process.stdin) chunks.push(chunk);
3262
+ return Buffer.concat(chunks).toString("utf8");
3263
+ },
3264
+ sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
2446
3265
  };
2447
3266
  var controller = new AbortController();
2448
3267
  process.on("SIGINT", () => {