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