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