hostedge 0.1.0 → 0.1.1

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 +1180 -426
  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,676 @@ 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) {
1324
+ super(message, exitCodeFor(status, code));
1325
+ this.status = status;
1326
+ this.code = code;
1327
+ }
1328
+ status;
1329
+ code;
1330
+ };
1331
+ function createApi(io2, apiUrl, token) {
1332
+ async function raw(path, init2 = {}) {
1333
+ const headers = new Headers(init2.headers);
1334
+ headers.set("authorization", `Bearer ${token}`);
1335
+ headers.set("user-agent", "hostedge-cli/0.1.1");
1336
+ let response;
1337
+ try {
1338
+ response = await io2.fetch(`${apiUrl}${path}`, { ...init2, headers });
1339
+ } catch {
1340
+ throw new CliError(`Could not reach ${apiUrl}. Check your connection, or HOSTEDGE_API_URL.`);
1341
+ }
1342
+ if (!response.ok) {
1343
+ const body2 = await response.json().catch(() => ({}));
1344
+ const fields = body2.error?.fields ? ` ${Object.entries(body2.error.fields).map(([k, v]) => `${k}: ${v}`).join("; ")}` : "";
1345
+ const message = response.status === 401 ? `${body2.error?.message ?? "Not signed in."} Run \`hostedge login\`.` : `${body2.error?.message ?? `HostEdge answered ${response.status}.`}${fields}`;
1346
+ throw new ApiError(message, response.status, body2.error?.code ?? null);
1347
+ }
1348
+ return response;
1051
1349
  }
1052
- const anyHtml = paths.some((p) => p.endsWith(".html"));
1053
1350
  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."
1351
+ apiUrl,
1352
+ raw,
1353
+ async request(path, init2 = {}) {
1354
+ const { json, ...rest } = init2;
1355
+ let request = rest;
1356
+ if (json !== void 0) {
1357
+ const headers = new Headers(rest.headers);
1358
+ headers.set("content-type", "application/json");
1359
+ request = { ...rest, method: rest.method ?? "POST", headers, body: JSON.stringify(json) };
1360
+ }
1361
+ const response = await raw(path, request);
1362
+ return await response.json();
1363
+ }
1057
1364
  };
1058
1365
  }
1366
+ async function connect(io2) {
1367
+ const { apiUrl, token } = await credentials(io2);
1368
+ if (!token)
1369
+ throw new CliError("Not signed in. Run `hostedge login` (or set HOSTEDGE_TOKEN).", EXIT.auth);
1370
+ return createApi(io2, apiUrl, token);
1371
+ }
1059
1372
 
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)
1373
+ // src/commands/auth.ts
1374
+ var LOGIN_TIMEOUT_MS = 5 * 6e4;
1375
+ var DONE_PAGE = `<!doctype html><meta charset="utf-8"><title>hostedge CLI</title>
1376
+ <body style="font:16px system-ui;margin:4rem auto;max-width:28rem;color:#16161a;background:#f7f6f2">
1377
+ <p><strong>The hostedge CLI is signed in.</strong></p><p>You can close this tab and return to your terminal.</p>`;
1378
+ async function browserLogin(r, apiUrl) {
1379
+ const { io: io2 } = r;
1380
+ const state = randomBytes(24).toString("base64url");
1381
+ let resolveToken = () => void 0;
1382
+ const received = new Promise((resolve6) => {
1383
+ resolveToken = resolve6;
1384
+ });
1385
+ const server = createServer((req, res) => {
1386
+ const url2 = new URL(req.url ?? "/", "http://127.0.0.1");
1387
+ const token = url2.searchParams.get("token");
1388
+ if (url2.pathname !== "/callback" || url2.searchParams.get("state") !== state || !token) {
1389
+ res.writeHead(400, { "content-type": "text/plain" }).end("Unexpected request.");
1390
+ return;
1391
+ }
1392
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(DONE_PAGE);
1393
+ resolveToken(token);
1394
+ });
1395
+ await new Promise((resolve6) => server.listen(0, "127.0.0.1", resolve6));
1396
+ const address = server.address();
1397
+ const port = typeof address === "object" && address ? address.port : 0;
1398
+ const url = `${apiUrl}/cli/authorize?${new URLSearchParams({ port: String(port), state, host: hostname() })}`;
1399
+ const s = r.s;
1400
+ const opened = await io2.openUrl(url);
1401
+ r.always(
1402
+ opened ? `Opened your browser to authorize the CLI. If nothing happened, visit:
1403
+ ${s.bold(url)}
1404
+ ` : `Open this URL in a browser to authorize the CLI:
1405
+ ${s.bold(url)}
1406
+ `
1065
1407
  );
1066
- let hex = "";
1067
- for (const byte of digest) hex += byte.toString(16).padStart(2, "0");
1068
- return hex.slice(0, 32);
1408
+ let timer;
1409
+ const timeout = new Promise((_, reject) => {
1410
+ timer = setTimeout(
1411
+ () => reject(
1412
+ new CliError("Timed out waiting for the browser. Run `hostedge login` again.", EXIT.auth)
1413
+ ),
1414
+ LOGIN_TIMEOUT_MS
1415
+ );
1416
+ });
1417
+ try {
1418
+ return await Promise.race([received, timeout]);
1419
+ } finally {
1420
+ clearTimeout(timer);
1421
+ server.close();
1422
+ }
1423
+ }
1424
+ async function obtainToken(r, options, apiUrl) {
1425
+ const { io: io2 } = r;
1426
+ if (options.token !== void 0 && options.withToken)
1427
+ throw new CliError("Use either --token or --with-token, not both.", EXIT.usage);
1428
+ if (options.token !== void 0) {
1429
+ const token = options.token.trim();
1430
+ if (!token)
1431
+ throw new CliError("--token needs a value. Prefer --with-token (reads stdin).", EXIT.usage);
1432
+ r.warn(
1433
+ `${r.s.dim("Tip: --token shows in your shell history and process list; prefer --with-token or HOSTEDGE_TOKEN.")}
1434
+ `
1435
+ );
1436
+ return { token, source: "flag" };
1437
+ }
1438
+ if (options.withToken) {
1439
+ if (io2.isStdinTty)
1440
+ throw new CliError(
1441
+ '--with-token reads the token from stdin, but stdin is a terminal. Pipe it in, e.g. `printf %s "$TOKEN" | hostedge login --with-token`.',
1442
+ EXIT.usage
1443
+ );
1444
+ const token = (await io2.readStdin()).trim();
1445
+ if (!token) throw new CliError("No token received on stdin.", EXIT.usage);
1446
+ return { token, source: "stdin" };
1447
+ }
1448
+ const fromEnv = io2.env.HOSTEDGE_TOKEN?.trim();
1449
+ if (fromEnv) return { token: fromEnv, source: "env" };
1450
+ if (!r.interactive)
1451
+ throw new CliError(
1452
+ "Not signed in, and a browser login needs a person. Set HOSTEDGE_TOKEN, or pass --with-token (token on stdin) or --token <token>.",
1453
+ EXIT.auth
1454
+ );
1455
+ return { token: await browserLogin(r, apiUrl), source: "browser" };
1456
+ }
1457
+ async function login(r, options) {
1458
+ const { io: io2 } = r;
1459
+ const saved = await readConfig(io2);
1460
+ const apiUrl = (options.api || io2.env.HOSTEDGE_API_URL || saved.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
1461
+ const { token, source } = await obtainToken(r, options, apiUrl);
1462
+ const identity = await createApi(io2, apiUrl, token).request("/api/v1/me");
1463
+ const saveIt = source !== "env";
1464
+ if (saveIt) await writeConfig(io2, { apiUrl, token, workspace: identity.workspace.slug });
1465
+ const s = r.s;
1466
+ r.result(
1467
+ {
1468
+ signedIn: true,
1469
+ user: identity.user,
1470
+ workspace: identity.workspace,
1471
+ scopes: identity.scopes,
1472
+ apiUrl,
1473
+ tokenSource: source,
1474
+ saved: saveIt
1475
+ },
1476
+ `${s.green("\u2713")} Signed in as ${s.bold(identity.user.email)} in ${s.bold(identity.workspace.name)}.${saveIt ? "" : " (token from HOSTEDGE_TOKEN, not saved)"}
1477
+ `
1478
+ );
1479
+ return EXIT.ok;
1480
+ }
1481
+ async function logout(r) {
1482
+ await clearConfig(r.io);
1483
+ r.result(
1484
+ { signedOut: true },
1485
+ "Signed out on this computer. The token still exists until you revoke it in Settings \u2192 API tokens.\n"
1486
+ );
1487
+ return EXIT.ok;
1488
+ }
1489
+ async function whoami(r) {
1490
+ const api = await connect(r.io);
1491
+ const me = await api.request("/api/v1/me");
1492
+ const { apiUrl, source } = await credentials(r.io);
1493
+ const text = `${me.user.email} in ${me.workspace.name} (${me.workspace.slug}) on ${apiUrl}
1494
+ Scopes: ${me.scopes.join(", ")}
1495
+ `;
1496
+ r.result(
1497
+ { user: me.user, workspace: me.workspace, scopes: me.scopes, apiUrl, tokenSource: source },
1498
+ text,
1499
+ text
1500
+ );
1501
+ return EXIT.ok;
1069
1502
  }
1070
1503
 
1071
1504
  // src/commands/deploy.ts
1072
- import { readFile as readFile3 } from "node:fs/promises";
1073
- import { basename, join as join3, resolve } from "node:path";
1505
+ import { readFile as readFile4 } from "node:fs/promises";
1506
+ import { basename, join as join4, resolve as resolve2 } from "node:path";
1074
1507
 
1075
1508
  // src/files.ts
1076
- import { lstat, readdir, readFile as readFile2 } from "node:fs/promises";
1077
- import { join as join2, relative, sep } from "node:path";
1509
+ import { lstat as lstat2, readdir, readFile as readFile3 } from "node:fs/promises";
1510
+ import { join as join3, relative, sep } from "node:path";
1078
1511
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hostedge", ".next", ".turbo", ".wrangler"]);
1079
1512
  async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1080
1513
  const files = [];
@@ -1083,7 +1516,7 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1083
1516
  const entries = await readdir(dir, { withFileTypes: true });
1084
1517
  entries.sort((a, b) => a.name.localeCompare(b.name));
1085
1518
  for (const entry of entries) {
1086
- const absolute = join2(dir, entry.name);
1519
+ const absolute = join3(dir, entry.name);
1087
1520
  const rel = relative(root, absolute).split(sep).join("/");
1088
1521
  if (entry.isSymbolicLink())
1089
1522
  throw new CliError(
@@ -1103,8 +1536,8 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1103
1536
  }
1104
1537
  if (files.length >= limits.maxFiles)
1105
1538
  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 });
1539
+ const stat3 = await lstat2(absolute);
1540
+ files.push({ path: normal.path, size: stat3.size, hash: "", absolute });
1108
1541
  }
1109
1542
  }
1110
1543
  try {
@@ -1124,9 +1557,19 @@ async function collectFiles(root, limits = { maxFiles: 1e5 }) {
1124
1557
  }
1125
1558
  const upload = files.filter((f) => !secrets.has(f.path));
1126
1559
  if (limits.hash !== false)
1127
- for (const file of upload) file.hash = await contentHash(await readFile2(file.absolute));
1560
+ for (const file of upload) file.hash = await contentHash(await readFile3(file.absolute));
1128
1561
  return { files: upload, skipped, secrets: [...secrets] };
1129
1562
  }
1563
+ async function withRetries(work, retryable, attempts = 4, delayMs = 500) {
1564
+ for (let attempt = 1; ; attempt++) {
1565
+ try {
1566
+ return await work();
1567
+ } catch (error) {
1568
+ if (attempt >= attempts || !retryable(error)) throw error;
1569
+ await new Promise((resolve6) => setTimeout(resolve6, delayMs * 2 ** (attempt - 1)));
1570
+ }
1571
+ }
1572
+ }
1130
1573
  async function pool(items, limit, work) {
1131
1574
  let next = 0;
1132
1575
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
@@ -1138,14 +1581,40 @@ async function pool(items, limit, work) {
1138
1581
  await Promise.all(runners);
1139
1582
  }
1140
1583
 
1584
+ // src/output.ts
1585
+ function styles(io2, stream = "stdout", noColor = false) {
1586
+ const tty = stream === "stdout" ? io2.isTty : io2.isErrTty;
1587
+ const on = tty && !noColor && !io2.env.NO_COLOR && io2.env.TERM !== "dumb";
1588
+ const wrap = (open, close) => (text) => on ? `\x1B[${open}m${text}\x1B[${close}m` : text;
1589
+ return {
1590
+ bold: wrap(1, 22),
1591
+ dim: wrap(2, 22),
1592
+ red: wrap(31, 39),
1593
+ green: wrap(32, 39),
1594
+ yellow: wrap(33, 39),
1595
+ cyan: wrap(36, 39)
1596
+ };
1597
+ }
1598
+ function formatBytes(bytes) {
1599
+ if (bytes < 1024) return `${bytes} B`;
1600
+ const units = ["KB", "MB", "GB"];
1601
+ let value = bytes / 1024;
1602
+ let unit = 0;
1603
+ while (value >= 1024 && unit < units.length - 1) {
1604
+ value /= 1024;
1605
+ unit += 1;
1606
+ }
1607
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
1608
+ }
1609
+
1141
1610
  // src/sse.ts
1142
- async function* readSse(body) {
1611
+ async function* readSse(body2) {
1143
1612
  const decoder = new TextDecoder();
1144
1613
  let buffer = "";
1145
1614
  let event = "message";
1146
1615
  let data = [];
1147
1616
  let id = null;
1148
- for await (const chunk of body) {
1617
+ for await (const chunk of body2) {
1149
1618
  buffer += decoder.decode(chunk, { stream: true });
1150
1619
  let newline = buffer.search(/\r?\n/);
1151
1620
  while (newline !== -1) {
@@ -1170,9 +1639,14 @@ async function* readSse(body) {
1170
1639
 
1171
1640
  // src/commands/deploy.ts
1172
1641
  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);
1642
+ var UPLOAD_CONCURRENCY = 16;
1643
+ var UPLOAD_TIMEOUT_MS = 3e4;
1644
+ function temporary(error) {
1645
+ if (error instanceof ApiError) return error.status === 429 || error.status >= 500;
1646
+ return error instanceof CliError;
1647
+ }
1648
+ async function follow(r, api, deploymentId) {
1649
+ const s = r.s;
1176
1650
  let cursor = null;
1177
1651
  let last = { status: "queued", failureReason: null };
1178
1652
  for (let attempt = 0; attempt < 20; attempt++) {
@@ -1185,7 +1659,7 @@ async function follow(io2, api, deploymentId) {
1185
1659
  if (event.event === "log") {
1186
1660
  for (const line of JSON.parse(event.data)) {
1187
1661
  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}
1662
+ r.say(`${s.dim("\u2502")} ${text}
1189
1663
  `);
1190
1664
  }
1191
1665
  } else if (event.event === "status") {
@@ -1196,25 +1670,34 @@ async function follow(io2, api, deploymentId) {
1196
1670
  }
1197
1671
  }
1198
1672
  if (FINISHED.has(last.status)) return last;
1199
- await io2.sleep(1e3);
1673
+ await r.io.sleep(1e3);
1200
1674
  }
1201
1675
  return last;
1202
1676
  }
1203
- async function deploy(io2, options) {
1677
+ async function lookup(api, deploymentId) {
1678
+ try {
1679
+ const d = await api.request(`/api/v1/deployments/${deploymentId}`);
1680
+ return typeof d.status === "string" ? { status: d.status, failureReason: d.failureReason ?? null } : null;
1681
+ } catch {
1682
+ return null;
1683
+ }
1684
+ }
1685
+ async function deploy(r, options) {
1686
+ const { io: io2 } = r;
1204
1687
  const started = Date.now();
1205
- const dir = resolve(io2.cwd, options.dir ?? ".");
1206
- const s = styles(io2);
1688
+ const dir = resolve2(io2.cwd, options.dir ?? ".");
1689
+ const s = r.s;
1207
1690
  const api = await connect(io2);
1208
1691
  const link = await readLink(dir);
1209
1692
  const { files, skipped, secrets } = await collectFiles(dir);
1210
1693
  if (files.length === 0) throw new CliError(`No files to deploy in ${dir}.`);
1211
1694
  for (const secret of secrets)
1212
- io2.stdout(
1695
+ r.say(
1213
1696
  `${s.dim("\u2013")} Left out ${secret}: it looks like a secret. Set it with environment variables instead.
1214
1697
  `
1215
1698
  );
1216
1699
  if (skipped.length > 0)
1217
- io2.stdout(
1700
+ r.say(
1218
1701
  s.dim(
1219
1702
  `Skipped ${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? ` and ${skipped.length - 5} more` : ""}
1220
1703
  `
@@ -1231,23 +1714,36 @@ async function deploy(io2, options) {
1231
1714
  return true;
1232
1715
  });
1233
1716
  const uploadBytes = toUpload.reduce((sum, f) => sum + f.size, 0);
1234
- io2.stdout(
1717
+ r.say(
1235
1718
  `Uploading ${toUpload.length} of ${files.length} files (${formatBytes(uploadBytes)} of ${formatBytes(total)}; the rest are already on HostEdge)
1236
1719
  `
1237
1720
  );
1721
+ let uploaded = 0;
1722
+ let reported = 0;
1723
+ const step = Math.max(1, Math.ceil(toUpload.length / 10));
1238
1724
  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
- });
1725
+ const bytes = await readFile4(file.absolute);
1726
+ await withRetries(
1727
+ () => api.raw(`/api/v1/uploads/${file.hash}`, {
1728
+ method: "PUT",
1729
+ headers: { "content-type": "application/octet-stream" },
1730
+ body: bytes,
1731
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
1732
+ }),
1733
+ temporary
1734
+ );
1735
+ uploaded++;
1736
+ if (toUpload.length > 20 && uploaded - reported >= step && uploaded < toUpload.length) {
1737
+ reported = uploaded;
1738
+ r.say(`${s.dim(` ${uploaded}/${toUpload.length} uploaded`)}
1739
+ `);
1740
+ }
1245
1741
  });
1246
1742
  let project;
1247
1743
  if (!link) {
1248
1744
  let pkg = null;
1249
1745
  try {
1250
- pkg = await readFile3(join3(dir, "package.json"), "utf8");
1746
+ pkg = await readFile4(join4(dir, "package.json"), "utf8");
1251
1747
  } catch {
1252
1748
  }
1253
1749
  project = {
@@ -1258,12 +1754,13 @@ async function deploy(io2, options) {
1258
1754
  ).settings
1259
1755
  };
1260
1756
  }
1757
+ const environment = options.preview ? "preview" : "production";
1261
1758
  const created = await api.request("/api/v1/deployments", {
1262
1759
  json: {
1263
1760
  projectId: link?.projectId,
1264
1761
  project,
1265
1762
  files: files.map((f) => ({ path: f.path, hash: f.hash, size: f.size })),
1266
- environment: options.preview ? "preview" : "production",
1763
+ environment,
1267
1764
  commitMessage: options.message ?? null
1268
1765
  }
1269
1766
  });
@@ -1275,76 +1772,120 @@ async function deploy(io2, options) {
1275
1772
  workspace: me.workspace.slug
1276
1773
  });
1277
1774
  await ensureGitignored(dir, ".hostedge");
1278
- io2.stdout(
1279
- `${s.green("\u2713")} Created project ${s.bold(created.projectSlug ?? created.projectId)}
1280
- `
1281
- );
1775
+ r.say(`${s.green("\u2713")} Created project ${s.bold(created.projectSlug ?? created.projectId)}
1776
+ `);
1282
1777
  }
1283
- io2.stdout(`Deployment ${s.bold(created.deploymentId)}
1778
+ r.say(`Deployment ${s.bold(created.deploymentId)}
1284
1779
  `);
1780
+ const describe = (outcome) => ({
1781
+ deploymentId: created.deploymentId,
1782
+ status: outcome.status,
1783
+ url: created.url,
1784
+ projectId: created.projectId,
1785
+ environment,
1786
+ failureReason: outcome.failureReason
1787
+ });
1285
1788
  if (options.wait === false) {
1286
- io2.stdout(`Queued. Follow it at ${created.url}
1287
- `);
1288
- return 0;
1789
+ r.result(
1790
+ describe({ status: created.status, failureReason: null }),
1791
+ `Queued. Follow it at ${created.url}
1792
+ `,
1793
+ `${created.url}
1794
+ `
1795
+ );
1796
+ return EXIT.ok;
1289
1797
  }
1290
- const result = await follow(io2, api, created.deploymentId);
1798
+ let result = await follow(r, api, created.deploymentId);
1799
+ if (!FINISHED.has(result.status)) result = await lookup(api, created.deploymentId) ?? result;
1291
1800
  const seconds = ((Date.now() - started) / 1e3).toFixed(1);
1292
1801
  if (result.status === "ready") {
1293
- io2.stdout(`${s.green("\u2713")} Live in ${seconds}s: ${s.bold(created.url)}
1294
- `);
1295
- return 0;
1802
+ r.result(
1803
+ describe(result),
1804
+ `${s.green("\u2713")} Live in ${seconds}s: ${s.bold(created.url)}
1805
+ `,
1806
+ `${created.url}
1807
+ `
1808
+ );
1809
+ return EXIT.ok;
1296
1810
  }
1297
- if (result.status === "canceled") {
1298
- io2.stderr(`Deployment canceled.
1811
+ const canceled = result.status === "canceled";
1812
+ const failed = result.status === "failed";
1813
+ const reason = result.failureReason ?? (canceled ? "Deployment canceled." : failed ? null : `The log stream ended while the deployment was ${result.status}. Check it at ${created.url}.`);
1814
+ const human = canceled ? "Deployment canceled.\n" : `${s.red("\u2717")} Deployment ${failed ? "failed" : `stopped while ${result.status}`}${reason ? `: ${reason}` : ""}
1815
+ `;
1816
+ if (r.json) r.io.stdout(`${JSON.stringify(describe({ ...result, failureReason: reason }))}
1299
1817
  `);
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;
1818
+ io2.stderr(human);
1819
+ return failed || canceled ? EXIT.deployFailed : EXIT.failure;
1307
1820
  }
1308
1821
 
1309
1822
  // src/commands/env.ts
1310
- import { writeFile as writeFile2 } from "node:fs/promises";
1311
- import { resolve as resolve3 } from "node:path";
1823
+ import { writeFile as writeFile3 } from "node:fs/promises";
1824
+ import { resolve as resolve4 } from "node:path";
1312
1825
 
1313
1826
  // src/commands/logs.ts
1314
- import { resolve as resolve2 } from "node:path";
1827
+ import { resolve as resolve3 } from "node:path";
1315
1828
  var POLL_MS = 2e3;
1829
+ var LEVELS = ["debug", "info", "warn", "error"];
1830
+ var UNIT_MS = { m: 6e4, h: 36e5, d: 864e5 };
1831
+ var RANGES = [
1832
+ { name: "1h", ms: UNIT_MS.h },
1833
+ { name: "24h", ms: 24 * UNIT_MS.h },
1834
+ { name: "7d", ms: 7 * UNIT_MS.d }
1835
+ ];
1316
1836
  async function requireLink(io2, dir = ".") {
1317
- const link = await readLink(resolve2(io2.cwd, dir));
1837
+ const link = await readLink(resolve3(io2.cwd, dir));
1318
1838
  if (!link)
1319
1839
  throw new CliError(
1320
- "This folder isn\u2019t linked to a project. Run `hostedge init` or `hostedge deploy`."
1840
+ "This folder isn\u2019t linked to a project. Run `hostedge init` or `hostedge deploy`.",
1841
+ EXIT.notFound
1321
1842
  );
1322
1843
  return link;
1323
1844
  }
1845
+ function parseSince(text) {
1846
+ const match = /^(\d+)([mhd])$/.exec(text.trim());
1847
+ const ms = match ? Number(match[1]) * UNIT_MS[match[2]] : 0;
1848
+ if (!match || ms <= 0)
1849
+ throw new CliError(
1850
+ `Unknown --since \u201C${text}\u201D. Use a duration like 30m, 1h, 24h or 7d (at most 7d).`,
1851
+ EXIT.usage
1852
+ );
1853
+ const range = RANGES.find((r) => ms <= r.ms);
1854
+ if (!range) throw new CliError(`--since can look back 7 days at most, not \u201C${text}\u201D.`, EXIT.usage);
1855
+ return { range: range.name, windowMs: ms === range.ms ? null : ms };
1856
+ }
1324
1857
  var newer = (a, b) => a.ts > b.ts || a.ts === b.ts && a.id > b.id;
1325
- async function logs(io2, options) {
1858
+ async function logs(r, options) {
1859
+ const { io: io2 } = r;
1860
+ if (options.level && !LEVELS.includes(options.level))
1861
+ throw new CliError(`Unknown --level \u201C${options.level}\u201D. Use ${LEVELS.join(", ")}.`, EXIT.usage);
1862
+ const since = options.since ? parseSince(options.since) : null;
1326
1863
  const link = await requireLink(io2);
1327
1864
  const api = await connect(io2);
1328
- const s = styles(io2);
1865
+ const s = r.s;
1329
1866
  const query = new URLSearchParams();
1330
1867
  if (options.level) query.set("level", options.level);
1331
1868
  if (options.search) query.set("q", options.search);
1332
- query.set("range", options.since ?? (options.follow ? "1h" : "24h"));
1869
+ query.set("range", since?.range ?? (options.follow ? "1h" : "24h"));
1870
+ const cutoff = since?.windowMs ? Date.now() - since.windowMs : 0;
1333
1871
  const print = (row) => {
1334
- const time = new Date(row.ts).toISOString().replace("T", " ").slice(0, 19);
1872
+ const time = new Date(row.ts).toISOString();
1335
1873
  const level = row.level.toUpperCase().padEnd(5);
1336
1874
  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
- `);
1875
+ r.line(
1876
+ { id: row.id, timestamp: time, level: row.level, type: row.type, message: row.message },
1877
+ `${s.dim(time.replace("T", " ").slice(0, 19))} ${colored} ${row.message}
1878
+ `
1879
+ );
1339
1880
  };
1340
1881
  const fetchRows = async () => (await api.request(
1341
1882
  `/api/v1/projects/${link.projectId}/logs?${query.toString()}`
1342
- )).rows;
1883
+ )).rows.filter((row) => row.ts >= cutoff);
1343
1884
  const first = await fetchRows();
1344
1885
  for (const row of [...first].reverse()) print(row);
1345
1886
  if (!options.follow) {
1346
- if (first.length === 0) io2.stdout(s.dim("No logs in this range.\n"));
1347
- return 0;
1887
+ if (first.length === 0) r.say(s.dim("No logs in this range.\n"));
1888
+ return EXIT.ok;
1348
1889
  }
1349
1890
  let cursor = first[0] ? { ts: first[0].ts, id: first[0].id } : { ts: 0, id: "" };
1350
1891
  while (!options.signal?.aborted) {
@@ -1355,11 +1896,12 @@ async function logs(io2, options) {
1355
1896
  const last = rows.at(-1);
1356
1897
  if (last) cursor = { ts: last.ts, id: last.id };
1357
1898
  }
1358
- return 0;
1899
+ return EXIT.ok;
1359
1900
  }
1360
1901
 
1361
1902
  // src/commands/env.ts
1362
- async function envPull(io2, options) {
1903
+ async function envPull(r, options) {
1904
+ const { io: io2 } = r;
1363
1905
  const link = await requireLink(io2);
1364
1906
  const api = await connect(io2);
1365
1907
  const environment = options.environment ?? "development";
@@ -1368,58 +1910,87 @@ async function envPull(io2, options) {
1368
1910
  `/api/v1/projects/${link.projectId}/env?${new URLSearchParams({ environment, format: "dotenv" })}`
1369
1911
  );
1370
1912
  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;
1913
+ const path = resolve4(io2.cwd, file);
1914
+ await writeFile3(path, text, { mode: 384 });
1915
+ const count2 = text.split("\n").filter(Boolean).length;
1374
1916
  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)` : ""}.
1917
+ const s = r.s;
1918
+ r.result(
1919
+ { environment, file, count: count2, gitignored: ignored },
1920
+ `${s.green("\u2713")} Wrote ${count2} ${environment} variable${count2 === 1 ? "" : "s"} to ${file}${ignored ? ` (added to .gitignore)` : ""}.
1378
1921
  `
1379
1922
  );
1380
- return 0;
1923
+ return EXIT.ok;
1381
1924
  }
1382
1925
 
1383
1926
  // 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);
1927
+ import { readFile as readFile5 } from "node:fs/promises";
1928
+ import { basename as basename2, join as join5, resolve as resolve5 } from "node:path";
1929
+ async function init(r, options) {
1930
+ const { io: io2 } = r;
1931
+ const dir = resolve5(io2.cwd, options.dir ?? ".");
1932
+ const s = r.s;
1389
1933
  const existing = await readLink(dir);
1390
- if (existing && !options.force) {
1391
- io2.stdout(
1934
+ let kit = null;
1935
+ if (options.ai) {
1936
+ kit = await writeKit(dir, { force: options.force });
1937
+ r.say(formatKit(r, kit));
1938
+ }
1939
+ const files = kit ? { dir, files: kit } : void 0;
1940
+ const relink = options.force && (!options.ai || options.project || options.name);
1941
+ if (existing && !relink) {
1942
+ r.result(
1943
+ {
1944
+ dir,
1945
+ linked: true,
1946
+ alreadyLinked: true,
1947
+ project: { id: existing.projectId, slug: existing.slug, workspace: existing.workspace },
1948
+ ai: files
1949
+ },
1392
1950
  `Already linked to ${s.bold(existing.slug)}. Use --force to link a different project.
1951
+ `,
1952
+ ""
1953
+ );
1954
+ return EXIT.ok;
1955
+ }
1956
+ if (options.ai && !(await credentials(io2)).token) {
1957
+ r.result(
1958
+ { dir, linked: false, linkSkipped: "not signed in", ai: files },
1959
+ `${s.dim("Not signed in, so the folder is not linked yet. Run `hostedge login` then `hostedge init`.")}
1393
1960
  `
1394
1961
  );
1395
- return 0;
1962
+ return EXIT.ok;
1396
1963
  }
1397
1964
  const api = await connect(io2);
1398
1965
  const me = await api.request("/api/v1/me");
1399
1966
  let project;
1967
+ let framework;
1400
1968
  if (options.project) {
1401
1969
  const { projects } = await api.request("/api/v1/projects");
1402
1970
  const found = projects.find((p) => p.id === options.project || p.slug === options.project);
1403
1971
  if (!found)
1404
1972
  throw new CliError(
1405
- `No project \u201C${options.project}\u201D in ${me.workspace.slug}. Run \`hostedge init\` without --project to create one.`
1973
+ `No project \u201C${options.project}\u201D in ${me.workspace.slug}. Run \`hostedge init\` without --project to create one.`,
1974
+ EXIT.notFound
1406
1975
  );
1407
1976
  project = found;
1977
+ framework = found.framework ?? null;
1408
1978
  } else {
1409
- const { files } = await collectFiles(dir, { maxFiles: 1e5, hash: false });
1979
+ const { files: found } = await collectFiles(dir, { maxFiles: 1e5, hash: false });
1410
1980
  let pkg = null;
1411
1981
  try {
1412
- pkg = await readFile4(join4(dir, "package.json"), "utf8");
1982
+ pkg = await readFile5(join5(dir, "package.json"), "utf8");
1413
1983
  } catch {
1414
1984
  }
1415
1985
  const detection = detectFramework(
1416
- files.map((f) => f.path),
1986
+ found.map((f) => f.path),
1417
1987
  pkg
1418
1988
  );
1419
1989
  const settings = detection.settings;
1420
- io2.stdout(`Detected ${s.bold(FRAMEWORK_INFO[settings.framework].name)}: ${detection.reason}
1990
+ framework = settings.framework;
1991
+ r.say(`Detected ${s.bold(FRAMEWORK_INFO[settings.framework].name)}: ${detection.reason}
1421
1992
  `);
1422
- if (detection.problem) io2.stdout(`${s.yellow("!")} ${detection.problem}
1993
+ if (detection.problem) r.say(`${s.yellow("!")} ${detection.problem}
1423
1994
  `);
1424
1995
  project = await api.request("/api/v1/projects", {
1425
1996
  json: { name: options.name ?? basename2(dir), settings }
@@ -1427,19 +1998,27 @@ async function init(io2, options) {
1427
1998
  }
1428
1999
  await writeLink(dir, { projectId: project.id, slug: project.slug, workspace: me.workspace.slug });
1429
2000
  await ensureGitignored(dir, ".hostedge");
1430
- io2.stdout(
2001
+ r.result(
2002
+ {
2003
+ dir,
2004
+ linked: true,
2005
+ alreadyLinked: false,
2006
+ project: { id: project.id, slug: project.slug, workspace: me.workspace.slug },
2007
+ framework,
2008
+ ai: files
2009
+ },
1431
2010
  `${s.green("\u2713")} Linked ${s.bold(project.slug)}. Run ${s.bold("hostedge deploy")} to publish it.
1432
2011
  `
1433
2012
  );
1434
- return 0;
2013
+ return EXIT.ok;
1435
2014
  }
1436
2015
 
1437
2016
  // src/commands/share.ts
1438
2017
  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";
2018
+ import { chmod as chmod2, mkdir as mkdir3, rename, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
1440
2019
  import { connect as netConnect } from "node:net";
1441
2020
  import { homedir as homedir2 } from "node:os";
1442
- import { delimiter, join as join5 } from "node:path";
2021
+ import { delimiter, join as join6 } from "node:path";
1443
2022
 
1444
2023
  // ../../node_modules/.pnpm/uqr@0.1.3/node_modules/uqr/dist/index.mjs
1445
2024
  var QrCodeDataType = /* @__PURE__ */ ((QrCodeDataType2) => {
@@ -2142,31 +2721,33 @@ function cloudflaredAsset(platform, arch) {
2142
2721
  if (platform === "win32" && cpu === "amd64") return "cloudflared-windows-amd64.exe";
2143
2722
  return null;
2144
2723
  }
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;
2724
+ var exists = async (path) => (await stat2(path).catch(() => null))?.isFile() ?? false;
2725
+ async function findCloudflared(r) {
2726
+ const { io: io2 } = r;
2727
+ const override = io2.env.HOSTEDGE_TUNNEL_HELPER ?? io2.env.HOSTEDGE_CLOUDFLARED;
2728
+ if (override) return override;
2148
2729
  const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
2149
2730
  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);
2731
+ if (await exists(join6(dir, exe))) return join6(dir, exe);
2732
+ const binDir = join6(io2.env.HOME ?? homedir2(), ".hostedge", "bin");
2733
+ const own = join6(binDir, exe);
2153
2734
  if (await exists(own)) return own;
2154
2735
  const asset = cloudflaredAsset(process.platform, process.arch);
2155
2736
  if (!asset)
2156
2737
  throw new CliError(
2157
- "Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) and run this again."
2738
+ "Install the open-source tunnel helper (the cloudflared command) and run this again, or set HOSTEDGE_TUNNEL_HELPER to its path."
2158
2739
  );
2159
- io2.stderr("Downloading cloudflared (one time)\u2026\n");
2740
+ r.warn("Downloading the tunnel helper (one time)\u2026\n");
2160
2741
  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()));
2742
+ if (!response.ok) throw new CliError(`Could not download the tunnel helper (${response.status}).`);
2743
+ await mkdir3(binDir, { recursive: true });
2744
+ const tmp = join6(binDir, `${asset}.download`);
2745
+ await writeFile4(tmp, new Uint8Array(await response.arrayBuffer()));
2165
2746
  if (asset.endsWith(".tgz")) {
2166
- await new Promise((resolve5, reject) => {
2747
+ await new Promise((resolve6, reject) => {
2167
2748
  const tar = nodeSpawn("tar", ["-xzf", tmp, "-C", binDir], { stdio: "ignore" });
2168
2749
  tar.on("error", reject);
2169
- tar.on("exit", (code) => code === 0 ? resolve5() : reject(new Error(`tar exited ${code}`)));
2750
+ tar.on("exit", (code) => code === 0 ? resolve6() : reject(new Error(`tar exited ${code}`)));
2170
2751
  });
2171
2752
  await rm2(tmp, { force: true });
2172
2753
  } else {
@@ -2176,27 +2757,31 @@ async function findCloudflared(io2) {
2176
2757
  return own;
2177
2758
  }
2178
2759
  function listening(port) {
2179
- return new Promise((resolve5) => {
2760
+ return new Promise((resolve6) => {
2180
2761
  const socket = netConnect({ host: "127.0.0.1", port });
2181
2762
  socket.once("connect", () => {
2182
2763
  socket.destroy();
2183
- resolve5(true);
2764
+ resolve6(true);
2184
2765
  });
2185
- socket.once("error", () => resolve5(false));
2766
+ socket.once("error", () => resolve6(false));
2186
2767
  socket.setTimeout(1e3, () => {
2187
2768
  socket.destroy();
2188
- resolve5(false);
2769
+ resolve6(false);
2189
2770
  });
2190
2771
  });
2191
2772
  }
2192
2773
  var QUICK_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2193
- async function share(io2, options) {
2194
- const s = styles(io2);
2774
+ async function share(r, options) {
2775
+ const { io: io2 } = r;
2776
+ const s = r.s;
2195
2777
  const port = Number(options.port ?? "3000");
2196
2778
  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);
2779
+ throw new CliError(
2780
+ "Give the port your dev server runs on, e.g. `hostedge share 5173`.",
2781
+ EXIT.usage
2782
+ );
2198
2783
  if (!await listening(port))
2199
- io2.stderr(
2784
+ r.warn(
2200
2785
  `${s.yellow("Note:")} nothing answers on localhost:${port} yet. Start your dev server; the link works as soon as it does.
2201
2786
  `
2202
2787
  );
@@ -2204,7 +2789,7 @@ async function share(io2, options) {
2204
2789
  let opened = { mode: "quick" };
2205
2790
  const api = token && !options.quick ? createApi(io2, apiUrl, token) : null;
2206
2791
  if (api) opened = await api.request("/api/v1/shares", { json: { port } });
2207
- const binary = await findCloudflared(io2);
2792
+ const binary = await findCloudflared(r);
2208
2793
  const hostHeader = options.hostHeader ?? "localhost";
2209
2794
  const args = opened.mode === "tunnel" && opened.token ? ["tunnel", "--no-autoupdate", "run", "--token", opened.token] : [
2210
2795
  "tunnel",
@@ -2220,7 +2805,9 @@ async function share(io2, options) {
2220
2805
  const tail = [];
2221
2806
  const remember = (chunk) => {
2222
2807
  for (const line of chunk.toString().split("\n").filter((l) => l.trim())) {
2223
- tail.push(line.trim());
2808
+ tail.push(
2809
+ line.trim().replace(/\bcloudflared\b/gi, "the tunnel helper").replace(/\bCloudflare\b/gi, "the platform")
2810
+ );
2224
2811
  if (tail.length > 4) tail.shift();
2225
2812
  }
2226
2813
  };
@@ -2229,14 +2816,24 @@ async function share(io2, options) {
2229
2816
  const announce = (url) => {
2230
2817
  if (progress.announced) return;
2231
2818
  progress.announced = true;
2232
- io2.stdout(`
2819
+ if (r.json)
2820
+ io2.stdout(
2821
+ `${JSON.stringify({ mode: opened.mode, url, expiresAt: opened.share?.expiresAt ?? null })}
2822
+ `
2823
+ );
2824
+ if (r.quiet) {
2825
+ if (!r.json) io2.stdout(`${url}
2826
+ `);
2827
+ return;
2828
+ }
2829
+ r.always(`
2233
2830
  ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2234
2831
 
2235
2832
  `);
2236
- io2.stdout(`${renderUnicodeCompact(url, { border: 1 })}
2833
+ r.always(`${renderUnicodeCompact(url, { border: 1 })}
2237
2834
 
2238
2835
  `);
2239
- io2.stdout(
2836
+ r.always(
2240
2837
  `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
2838
  `
2242
2839
  );
@@ -2251,7 +2848,7 @@ ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2251
2848
  child.stderr?.on("data", watch);
2252
2849
  } else {
2253
2850
  if (!api && !options.quick)
2254
- io2.stderr(
2851
+ r.warn(
2255
2852
  `${s.dim("Not signed in: using a quick tunnel. `hostedge login` for a hostedge.app link.")}
2256
2853
  `
2257
2854
  );
@@ -2262,59 +2859,177 @@ ${s.bold("Your localhost is live:")} ${s.cyan(url)}
2262
2859
  child.stdout?.on("data", watch);
2263
2860
  child.stderr?.on("data", watch);
2264
2861
  }
2265
- const exitCode = await new Promise((resolve5) => {
2862
+ const exitCode = await new Promise((resolve6) => {
2266
2863
  const stop = () => {
2267
2864
  child.kill("SIGTERM");
2268
2865
  };
2269
2866
  options.signal?.addEventListener("abort", stop, { once: true });
2270
- child.on("error", () => resolve5(1));
2867
+ child.on("error", () => resolve6(1));
2271
2868
  child.on("exit", (code) => {
2272
2869
  options.signal?.removeEventListener("abort", stop);
2273
- resolve5(options.signal?.aborted ? 0 : code ?? 1);
2870
+ resolve6(options.signal?.aborted ? 0 : code ?? 1);
2274
2871
  });
2275
2872
  });
2276
2873
  if (api && opened.share) {
2277
2874
  await api.request(`/api/v1/shares/${encodeURIComponent(opened.share.id)}`, { method: "DELETE" }).catch(() => void 0);
2278
- io2.stdout(`${s.dim("Share closed.")}
2875
+ r.say(`${s.dim("Share closed.")}
2279
2876
  `);
2280
2877
  }
2281
2878
  if (!progress.announced && exitCode !== 0)
2282
2879
  throw new CliError(
2283
- `cloudflared stopped before the link was ready. Check your connection.${tail.length ? `
2880
+ `The tunnel helper stopped before the link was ready. Check your connection.${tail.length ? `
2284
2881
  ${tail.join("\n")}` : ""}`
2285
2882
  );
2286
2883
  return exitCode;
2287
2884
  }
2288
2885
 
2886
+ // src/report.ts
2887
+ function isCi(env) {
2888
+ const value = env.CI;
2889
+ return value !== void 0 && value !== "" && value !== "0" && value.toLowerCase() !== "false";
2890
+ }
2891
+ var TOKEN_LIKE = /hostedge_[A-Za-z0-9_-]{8,}/g;
2892
+ function redact(text) {
2893
+ return text.replace(TOKEN_LIKE, "hostedge_\u2026");
2894
+ }
2895
+ var Reporter = class {
2896
+ constructor(io2, flags) {
2897
+ this.io = io2;
2898
+ this.flags = flags;
2899
+ }
2900
+ io;
2901
+ flags;
2902
+ get json() {
2903
+ return this.flags.json;
2904
+ }
2905
+ get quiet() {
2906
+ return this.flags.quiet;
2907
+ }
2908
+ /** Where human text goes. */
2909
+ get human() {
2910
+ return this.flags.json ? "stderr" : "stdout";
2911
+ }
2912
+ /** Styles for human text (colors only if that stream is a terminal). */
2913
+ get s() {
2914
+ return styles(this.io, this.human, this.flags.noColor);
2915
+ }
2916
+ /** Whether a person could answer a prompt. False for --yes, CI, or when stdin is piped. */
2917
+ get interactive() {
2918
+ return !this.flags.yes && !isCi(this.io.env) && this.io.isStdinTty;
2919
+ }
2920
+ write(stream, text) {
2921
+ if (stream === "stdout") this.io.stdout(text);
2922
+ else this.io.stderr(text);
2923
+ }
2924
+ /** Progress and decoration for people: hidden by --quiet. */
2925
+ say(text) {
2926
+ if (!this.flags.quiet) this.write(this.human, text);
2927
+ }
2928
+ /** Human output that --quiet keeps (the one line a script wants). */
2929
+ always(text) {
2930
+ this.write(this.human, text);
2931
+ }
2932
+ /** A note for people on stderr (never stdout): hidden by --quiet. */
2933
+ warn(text) {
2934
+ if (!this.flags.quiet) this.io.stderr(text);
2935
+ }
2936
+ /**
2937
+ * The command's result. With --json: one JSON document on stdout, and `human` on stderr
2938
+ * for whoever is watching. Otherwise `human` on stdout, or just `quiet` with --quiet
2939
+ * (the one line a script wants; empty by default).
2940
+ */
2941
+ result(data, human, quiet = "") {
2942
+ if (this.flags.json) {
2943
+ this.io.stdout(`${JSON.stringify(data)}
2944
+ `);
2945
+ if (!this.flags.quiet) this.io.stderr(human);
2946
+ return;
2947
+ }
2948
+ const text = this.flags.quiet ? quiet : human;
2949
+ if (text) this.io.stdout(text);
2950
+ }
2951
+ /** One NDJSON line with --json, a human line otherwise: for streaming commands. */
2952
+ line(data, human) {
2953
+ if (this.flags.json) this.io.stdout(`${JSON.stringify(data)}
2954
+ `);
2955
+ else this.io.stdout(human);
2956
+ }
2957
+ /** Reports a failure: human text on stderr, plus a JSON error envelope on stdout. */
2958
+ fail(message, exitCode) {
2959
+ const clean = redact(message);
2960
+ const red = styles(this.io, "stderr", this.flags.noColor).red;
2961
+ this.io.stderr(`${red("Error:")} ${clean}
2962
+ `);
2963
+ if (this.flags.json)
2964
+ this.io.stdout(
2965
+ `${JSON.stringify({ error: { code: exitName(exitCode), message: clean, exitCode } })}
2966
+ `
2967
+ );
2968
+ }
2969
+ };
2970
+
2289
2971
  // src/cli.ts
2290
- var VERSION = "0.1.0";
2972
+ var VERSION = "0.1.1";
2291
2973
  var HELP = `hostedge \u2014 deploy to HostEdge from your terminal
2292
2974
 
2293
2975
  Usage
2294
- hostedge login [--token <token>] [--api <url>] Sign in (opens your browser)
2976
+ hostedge login [--token <t> | --with-token] [--api-url <url>]
2977
+ Sign in (opens your browser; --with-token reads stdin)
2295
2978
  hostedge logout Forget the saved token
2296
2979
  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
2980
+ hostedge init [dir] [--name <name>] [--project <id|slug>] [--force] [--ai]
2981
+ Link a folder to a project; --ai also writes
2982
+ AGENTS.md, CLAUDE.md, .cursor/rules and a Claude skill
2983
+ hostedge ai-kit [dir] [--force] Write the assistant instruction files only
2984
+ hostedge deploy [dir] [--preview] [--message <text>] [--wait | --no-wait]
2985
+ Upload and deploy a folder (waits by default)
2986
+ hostedge logs [--follow] [--level <level>] [--search <text>] [--since 30m|1h|24h|7d]
2987
+ Show runtime logs (--json: one JSON object per line)
2303
2988
  hostedge env pull [--environment <name>] [--file <path>]
2304
2989
  Write variables to a .env file
2305
2990
  hostedge share [port] [--quick] [--host-header <host>]
2306
2991
  Public HTTPS link to a local dev server
2307
2992
  (try it on your phone before deploying)
2308
2993
 
2994
+ Options for every command
2995
+ --json Machine-readable output on stdout; progress and text go to stderr
2996
+ -y, --yes Never prompt; with CI=1 or no TTY it never waits for input either
2997
+ -q, --quiet Only results and errors (deploy prints just the URL)
2998
+ --api-url <url> Use another HostEdge for this run (default https://hostedge.app)
2999
+ --no-color Plain text (NO_COLOR is respected too)
3000
+ -h, --help Show this help -v, --version Show the version
3001
+
2309
3002
  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
3003
+ HOSTEDGE_TOKEN API token (CI); overrides the saved login. Never printed.
3004
+ HOSTEDGE_API_URL HostEdge URL (default https://hostedge.app)
3005
+ HOSTEDGE_TUNNEL_HELPER Path to the tunnel helper (otherwise PATH, then a one-time download)
3006
+ NO_COLOR Disable colors
3007
+ CI Treated like --yes
3008
+
3009
+ Exit codes
3010
+ ${CLI_EXIT_CODES.map((c) => ` ${c.code} ${c.name.padEnd(14)}${c.meaning}`).join("\n")}
3011
+
3012
+ Agents: \`hostedge deploy --wait --json\` prints {"deploymentId","status","url","failureReason"}
3013
+ and exits 5 when the build fails. Docs: https://hostedge.app/docs/agent-kit
2314
3014
  `;
2315
3015
  var str = (value) => typeof value === "string" ? value : void 0;
2316
- async function run(argv, io2, signal) {
2317
- const s = styles(io2);
3016
+ function apiUrlOf(value) {
3017
+ let url;
3018
+ try {
3019
+ url = new URL(value);
3020
+ } catch {
3021
+ throw new CliError(
3022
+ `--api-url \u201C${value}\u201D is not a URL. Use e.g. https://hostedge.app.`,
3023
+ EXIT.usage
3024
+ );
3025
+ }
3026
+ if (url.protocol !== "https:" && url.protocol !== "http:")
3027
+ throw new CliError(`--api-url must start with https:// or http://, not \u201C${value}\u201D.`, EXIT.usage);
3028
+ return value.replace(/\/+$/, "");
3029
+ }
3030
+ async function run(argv, baseIo, signal) {
3031
+ const wantsJson = argv.includes("--json");
3032
+ const early = new Reporter(baseIo, { json: wantsJson, quiet: false, yes: false, noColor: false });
2318
3033
  let parsed;
2319
3034
  try {
2320
3035
  parsed = parseArgs({
@@ -2324,13 +3039,22 @@ async function run(argv, io2, signal) {
2324
3039
  options: {
2325
3040
  help: { type: "boolean", short: "h" },
2326
3041
  version: { type: "boolean", short: "v" },
3042
+ json: { type: "boolean" },
3043
+ yes: { type: "boolean", short: "y" },
3044
+ quiet: { type: "boolean", short: "q" },
3045
+ "no-color": { type: "boolean" },
3046
+ "api-url": { type: "string" },
2327
3047
  token: { type: "string" },
3048
+ "with-token": { type: "boolean" },
3049
+ // The original spelling of --api-url, still accepted.
2328
3050
  api: { type: "string" },
2329
3051
  name: { type: "string" },
2330
3052
  project: { type: "string" },
2331
3053
  force: { type: "boolean" },
3054
+ ai: { type: "boolean" },
2332
3055
  preview: { type: "boolean" },
2333
3056
  message: { type: "string", short: "m" },
3057
+ wait: { type: "boolean" },
2334
3058
  "no-wait": { type: "boolean" },
2335
3059
  follow: { type: "boolean", short: "f" },
2336
3060
  level: { type: "string" },
@@ -2343,46 +3067,64 @@ async function run(argv, io2, signal) {
2343
3067
  }
2344
3068
  });
2345
3069
  } catch (error) {
2346
- io2.stderr(`${error.message}
2347
- Run \`hostedge --help\` for usage.
2348
- `);
2349
- return 2;
3070
+ early.fail(`${error.message}
3071
+ Run \`hostedge --help\` for usage.`, EXIT.usage);
3072
+ return EXIT.usage;
2350
3073
  }
2351
3074
  const { values, positionals } = parsed;
2352
3075
  const [command, ...rest] = positionals;
2353
3076
  if (values.version) {
2354
- io2.stdout(`${VERSION}
3077
+ if (wantsJson) early.result({ version: VERSION }, "");
3078
+ else baseIo.stdout(`${VERSION}
2355
3079
  `);
2356
- return 0;
3080
+ return EXIT.ok;
2357
3081
  }
2358
3082
  if (values.help || !command) {
2359
- io2.stdout(HELP);
2360
- return command || values.help ? 0 : 2;
3083
+ baseIo.stdout(HELP);
3084
+ return command || values.help ? EXIT.ok : EXIT.usage;
2361
3085
  }
3086
+ let r = early;
2362
3087
  try {
3088
+ const apiOverride = str(values["api-url"]) ?? str(values.api);
3089
+ const io2 = apiOverride ? { ...baseIo, env: { ...baseIo.env, HOSTEDGE_API_URL: apiUrlOf(apiOverride) } } : baseIo;
3090
+ r = new Reporter(io2, {
3091
+ json: values.json === true,
3092
+ quiet: values.quiet === true,
3093
+ yes: values.yes === true,
3094
+ noColor: values["no-color"] === true
3095
+ });
3096
+ if (values.wait && values["no-wait"])
3097
+ throw new CliError("Use either --wait or --no-wait, not both.", EXIT.usage);
2363
3098
  switch (command) {
2364
3099
  case "login":
2365
- return await login(io2, { token: str(values.token), api: str(values.api) });
3100
+ return await login(r, {
3101
+ token: str(values.token),
3102
+ withToken: values["with-token"] === true,
3103
+ api: apiOverride ? apiUrlOf(apiOverride) : void 0
3104
+ });
2366
3105
  case "logout":
2367
- return await logout(io2);
3106
+ return await logout(r);
2368
3107
  case "whoami":
2369
- return await whoami(io2);
3108
+ return await whoami(r);
2370
3109
  case "init":
2371
- return await init(io2, {
3110
+ return await init(r, {
2372
3111
  dir: rest[0],
2373
3112
  name: str(values.name),
2374
3113
  project: str(values.project),
2375
- force: values.force === true
3114
+ force: values.force === true,
3115
+ ai: values.ai === true
2376
3116
  });
3117
+ case "ai-kit":
3118
+ return await aiKit(r, { dir: rest[0], force: values.force === true });
2377
3119
  case "deploy":
2378
- return await deploy(io2, {
3120
+ return await deploy(r, {
2379
3121
  dir: rest[0],
2380
3122
  preview: values.preview === true,
2381
3123
  message: str(values.message),
2382
3124
  wait: values["no-wait"] !== true
2383
3125
  });
2384
3126
  case "logs":
2385
- return await logs(io2, {
3127
+ return await logs(r, {
2386
3128
  follow: values.follow === true,
2387
3129
  level: str(values.level),
2388
3130
  search: str(values.search),
@@ -2390,46 +3132,51 @@ Run \`hostedge --help\` for usage.
2390
3132
  signal
2391
3133
  });
2392
3134
  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) });
3135
+ if (rest[0] !== "pull")
3136
+ throw new CliError(
3137
+ "Usage: hostedge env pull [--environment <name>] [--file <path>]",
3138
+ EXIT.usage
3139
+ );
3140
+ return await envPull(r, { environment: str(values.environment), file: str(values.file) });
2398
3141
  case "share":
2399
- return await share(io2, {
3142
+ return await share(r, {
2400
3143
  port: rest[0],
2401
3144
  quick: values.quick === true,
2402
3145
  hostHeader: str(values["host-header"]),
2403
3146
  signal
2404
3147
  });
2405
3148
  default:
2406
- io2.stderr(`Unknown command \u201C${command}\u201D. Run \`hostedge --help\` for usage.
2407
- `);
2408
- return 2;
3149
+ throw new CliError(
3150
+ `Unknown command \u201C${command}\u201D. Run \`hostedge --help\` for usage.`,
3151
+ EXIT.usage
3152
+ );
2409
3153
  }
2410
3154
  } catch (error) {
2411
3155
  if (error instanceof CliError) {
2412
- io2.stderr(`${s.red("Error:")} ${error.message}
2413
- `);
3156
+ r.fail(error.message, error.exitCode);
2414
3157
  return error.exitCode;
2415
3158
  }
2416
- throw error;
3159
+ r.fail(
3160
+ `Unexpected error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`,
3161
+ EXIT.failure
3162
+ );
3163
+ return EXIT.failure;
2417
3164
  }
2418
3165
  }
2419
3166
 
2420
3167
  // src/main.ts
2421
3168
  function openUrl(url) {
2422
3169
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
2423
- return new Promise((resolve5) => {
3170
+ return new Promise((resolve6) => {
2424
3171
  try {
2425
3172
  const child = spawn(command, [url], { stdio: "ignore", detached: true });
2426
- child.on("error", () => resolve5(false));
3173
+ child.on("error", () => resolve6(false));
2427
3174
  child.on("spawn", () => {
2428
3175
  child.unref();
2429
- resolve5(true);
3176
+ resolve6(true);
2430
3177
  });
2431
3178
  } catch {
2432
- resolve5(false);
3179
+ resolve6(false);
2433
3180
  }
2434
3181
  });
2435
3182
  }
@@ -2442,7 +3189,14 @@ var io = {
2442
3189
  openUrl,
2443
3190
  // Undefined (not false) when stdout is a pipe.
2444
3191
  isTty: process.stdout.isTTY,
2445
- sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms))
3192
+ isErrTty: process.stderr.isTTY,
3193
+ isStdinTty: process.stdin.isTTY,
3194
+ readStdin: async () => {
3195
+ const chunks = [];
3196
+ for await (const chunk of process.stdin) chunks.push(chunk);
3197
+ return Buffer.concat(chunks).toString("utf8");
3198
+ },
3199
+ sleep: (ms) => new Promise((resolve6) => setTimeout(resolve6, ms))
2446
3200
  };
2447
3201
  var controller = new AbortController();
2448
3202
  process.on("SIGINT", () => {