@host0/cli 0.0.0-stage → 0.1.0
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 +72 -2
- package/dist/host0.mjs +1751 -0
- package/package.json +35 -4
package/dist/host0.mjs
ADDED
|
@@ -0,0 +1,1751 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
// src/errors.ts
|
|
8
|
+
var CliError = class extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
constructor(message, exitCode = 1) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
var Exit = class extends Error {
|
|
16
|
+
exitCode;
|
|
17
|
+
constructor(exitCode) {
|
|
18
|
+
super(`exit ${exitCode}`);
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function die(message) {
|
|
23
|
+
throw new CliError(message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/interrupt.ts
|
|
27
|
+
var controller = new AbortController();
|
|
28
|
+
var signalExitCode = 0;
|
|
29
|
+
function interrupt(exitCode) {
|
|
30
|
+
if (signalExitCode) return;
|
|
31
|
+
signalExitCode = exitCode;
|
|
32
|
+
controller.abort();
|
|
33
|
+
}
|
|
34
|
+
var interruptSignal = controller.signal;
|
|
35
|
+
function interruptedExitCode() {
|
|
36
|
+
return signalExitCode;
|
|
37
|
+
}
|
|
38
|
+
var Interrupted = class extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super("interrupted");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/io.ts
|
|
45
|
+
function out(line) {
|
|
46
|
+
if (interruptedExitCode()) return;
|
|
47
|
+
process.stdout.write(line + "\n");
|
|
48
|
+
}
|
|
49
|
+
function err(line) {
|
|
50
|
+
process.stderr.write(line + "\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/net.ts
|
|
54
|
+
var NET_ATTEMPTS = 4;
|
|
55
|
+
var NEVER_REACHED = /* @__PURE__ */ new Set([
|
|
56
|
+
"ENOTFOUND",
|
|
57
|
+
"EAI_AGAIN",
|
|
58
|
+
"ECONNREFUSED",
|
|
59
|
+
"EHOSTUNREACH",
|
|
60
|
+
"ENETUNREACH",
|
|
61
|
+
"EADDRNOTAVAIL",
|
|
62
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
63
|
+
"EPROTO"
|
|
64
|
+
]);
|
|
65
|
+
var MAYBE_REACHED = /* @__PURE__ */ new Set([
|
|
66
|
+
"ECONNRESET",
|
|
67
|
+
"EPIPE",
|
|
68
|
+
"ETIMEDOUT",
|
|
69
|
+
"ECONNABORTED",
|
|
70
|
+
"UND_ERR_SOCKET",
|
|
71
|
+
"UND_ERR_CLOSED",
|
|
72
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
73
|
+
"UND_ERR_BODY_TIMEOUT"
|
|
74
|
+
]);
|
|
75
|
+
var CERT_ERROR = /^(CERT_|UNABLE_TO_|DEPTH_ZERO_|SELF_SIGNED_|ERR_TLS_CERT_)/;
|
|
76
|
+
function classify(code) {
|
|
77
|
+
if (CERT_ERROR.test(code)) return "fatal";
|
|
78
|
+
if (NEVER_REACHED.has(code) || /^ERR_(SSL|TLS)_/.test(code)) return "never";
|
|
79
|
+
if (MAYBE_REACHED.has(code)) return "maybe";
|
|
80
|
+
return "fatal";
|
|
81
|
+
}
|
|
82
|
+
function isNetworkCode(code) {
|
|
83
|
+
return NEVER_REACHED.has(code) || MAYBE_REACHED.has(code) || CERT_ERROR.test(code) || /^ERR_(SSL|TLS)_/.test(code);
|
|
84
|
+
}
|
|
85
|
+
function causeChain(e) {
|
|
86
|
+
const codes = [];
|
|
87
|
+
const messages = [];
|
|
88
|
+
let cur = e;
|
|
89
|
+
for (let depth = 0; depth < 6 && cur && typeof cur === "object"; depth++) {
|
|
90
|
+
const o = cur;
|
|
91
|
+
if (typeof o.code === "string" && o.code) codes.push(o.code);
|
|
92
|
+
if (typeof o.message === "string") messages.push(o.message);
|
|
93
|
+
if (Array.isArray(o.errors) && o.errors.length > 0) {
|
|
94
|
+
const first = o.errors[0];
|
|
95
|
+
if (typeof first?.code === "string") codes.push(first.code);
|
|
96
|
+
if (typeof first?.message === "string") messages.push(first.message);
|
|
97
|
+
}
|
|
98
|
+
cur = o.cause;
|
|
99
|
+
}
|
|
100
|
+
return { codes, messages };
|
|
101
|
+
}
|
|
102
|
+
function redact(text) {
|
|
103
|
+
return text.replace(/Bearer\s+[^\s"']+/gi, "Bearer [redacted]").replace(/h0_[A-Za-z0-9_-]+/g, "h0_[redacted]");
|
|
104
|
+
}
|
|
105
|
+
function describeFailure(e) {
|
|
106
|
+
const { codes, messages } = causeChain(e);
|
|
107
|
+
const code = codes.find(isNetworkCode);
|
|
108
|
+
if (!code) return { code: codes[0] ?? "", reach: "fatal", network: false };
|
|
109
|
+
if ((code === "ECONNRESET" || code === "EPIPE") && messages.some((m) => /before secure TLS connection was established/i.test(m))) {
|
|
110
|
+
return { code, reach: "never", network: true };
|
|
111
|
+
}
|
|
112
|
+
return { code, reach: classify(code), network: true };
|
|
113
|
+
}
|
|
114
|
+
function hostOf(url) {
|
|
115
|
+
try {
|
|
116
|
+
return new URL(url).host;
|
|
117
|
+
} catch {
|
|
118
|
+
return "host0";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
var NetError = class extends Error {
|
|
122
|
+
code;
|
|
123
|
+
host;
|
|
124
|
+
constructor(message, code, host) {
|
|
125
|
+
super(message);
|
|
126
|
+
this.code = code;
|
|
127
|
+
this.host = host;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
function sleep(ms) {
|
|
131
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
132
|
+
}
|
|
133
|
+
function baseDelayMs() {
|
|
134
|
+
const raw = Number(process.env.HOST0_RETRY_BASE_MS);
|
|
135
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 1e3;
|
|
136
|
+
}
|
|
137
|
+
async function request(url, opts) {
|
|
138
|
+
const host = hostOf(url);
|
|
139
|
+
const signal = opts.ignoreInterrupt ? void 0 : interruptSignal;
|
|
140
|
+
let delay = baseDelayMs();
|
|
141
|
+
for (let attempt = 1; ; attempt++) {
|
|
142
|
+
if (signal?.aborted) throw new Interrupted();
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch(url, {
|
|
145
|
+
method: opts.method,
|
|
146
|
+
headers: opts.headers,
|
|
147
|
+
body: opts.body,
|
|
148
|
+
// curl without -L: a redirect is an answer, never a hop that could
|
|
149
|
+
// carry the bearer key to another host.
|
|
150
|
+
redirect: "manual",
|
|
151
|
+
signal
|
|
152
|
+
});
|
|
153
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
154
|
+
return { status: res.status, body, text: body.toString("utf8") };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (signal?.aborted) throw new Interrupted();
|
|
157
|
+
const failure = describeFailure(e);
|
|
158
|
+
if (!failure.network) {
|
|
159
|
+
const reason = redact(e instanceof Error ? e.message : String(e));
|
|
160
|
+
throw new NetError(`request to ${host} failed: ${reason}`, failure.code, host);
|
|
161
|
+
}
|
|
162
|
+
const { code, reach } = failure;
|
|
163
|
+
const retry = reach === "never" || reach === "maybe" && opts.idempotent;
|
|
164
|
+
if (retry && attempt < NET_ATTEMPTS) {
|
|
165
|
+
err(`host0: network hiccup reaching ${host} (${code}) \u2014 retrying ${attempt + 1}/${NET_ATTEMPTS}`);
|
|
166
|
+
await sleep(delay);
|
|
167
|
+
delay *= 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (retry) {
|
|
171
|
+
err(
|
|
172
|
+
`host0: could not reach ${host} after ${NET_ATTEMPTS} attempts (${code}) \u2014 if this keeps happening, the network may be blocking ${host}: ask the user to allow it.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
throw new NetError(`could not reach ${host} (${code})`, code, host);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// src/paths.ts
|
|
181
|
+
import { homedir } from "node:os";
|
|
182
|
+
import { join } from "node:path";
|
|
183
|
+
function configDir() {
|
|
184
|
+
return process.env.HOST0_HOME || join(homedir(), ".host0");
|
|
185
|
+
}
|
|
186
|
+
function credentialsFile() {
|
|
187
|
+
return join(configDir(), "credentials");
|
|
188
|
+
}
|
|
189
|
+
function deviceFile() {
|
|
190
|
+
return join(configDir(), "device.json");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/api.ts
|
|
194
|
+
function alt(a, b) {
|
|
195
|
+
return a === void 0 || a === null || a === false ? b : a;
|
|
196
|
+
}
|
|
197
|
+
function parseJson(text) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(text);
|
|
200
|
+
} catch {
|
|
201
|
+
return void 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function jqRaw(value) {
|
|
205
|
+
if (value === void 0 || value === null) return "null";
|
|
206
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
function unauthorizedHint(ctx, style) {
|
|
209
|
+
const fix = `host0 login to sign in again (the new key replaces ${credentialsFile()} once the user approves), then ${style === "contract" ? "publish again" : "try again"}.`;
|
|
210
|
+
switch (ctx.apiKeySource) {
|
|
211
|
+
case "env":
|
|
212
|
+
return `Unset HOST0_API_KEY, then run ${fix}`;
|
|
213
|
+
case "flag":
|
|
214
|
+
return `Drop --api-key, then run ${fix}`;
|
|
215
|
+
default:
|
|
216
|
+
return `Run ${fix}`;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function checkApiError(ctx, text, what, style) {
|
|
220
|
+
const body = parseJson(text);
|
|
221
|
+
if (body === void 0 || body === null || body === false) {
|
|
222
|
+
die(`${what}: unexpected non-JSON response: ${text.slice(0, 300)}`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof body !== "object" || Array.isArray(body) || !("error" in body)) return body;
|
|
225
|
+
const message = jqRaw(alt(body.message, body.error));
|
|
226
|
+
if (Array.isArray(body.problems)) {
|
|
227
|
+
for (const p of body.problems) {
|
|
228
|
+
err(` - ${typeof p === "string" ? p : jqRaw(alt(p?.message, p))}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (body.error === "unauthorized") {
|
|
232
|
+
err(`host0: the API key from ${ctx.apiKeySource} was rejected \u2014 it may have been revoked. ${unauthorizedHint(ctx, style)}`);
|
|
233
|
+
}
|
|
234
|
+
const upgrade = typeof body.upgrade_url === "string" ? body.upgrade_url : "";
|
|
235
|
+
if (style === "contract") {
|
|
236
|
+
err(`publish_result.error=${jqRaw(body.error)}`);
|
|
237
|
+
if (upgrade) err(`publish_result.upgrade_url=${upgrade}`);
|
|
238
|
+
} else if (upgrade && !message.includes(upgrade)) {
|
|
239
|
+
err(`host0: upgrade to Pro: ${upgrade}`);
|
|
240
|
+
}
|
|
241
|
+
die(`${what}: ${message}`);
|
|
242
|
+
}
|
|
243
|
+
function apiHeaders(ctx, withBody) {
|
|
244
|
+
const headers = {
|
|
245
|
+
authorization: `Bearer ${ctx.apiKey}`,
|
|
246
|
+
"x-host0-client": ctx.clientHeader
|
|
247
|
+
};
|
|
248
|
+
if (withBody) headers["content-type"] = "application/json";
|
|
249
|
+
return headers;
|
|
250
|
+
}
|
|
251
|
+
function apiRequest(ctx, method, path, body) {
|
|
252
|
+
const withBody = body !== void 0;
|
|
253
|
+
return request(`${ctx.baseUrl}${path}`, {
|
|
254
|
+
method,
|
|
255
|
+
headers: apiHeaders(ctx, withBody),
|
|
256
|
+
body: withBody ? JSON.stringify(body) : void 0,
|
|
257
|
+
idempotent: method !== "POST"
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
async function apiJson(ctx, method, path, body, what, style) {
|
|
261
|
+
let res;
|
|
262
|
+
try {
|
|
263
|
+
res = await apiRequest(ctx, method, path, body);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
if (e instanceof NetError) die(`${what}: ${e.message}`);
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
return checkApiError(ctx, res.text, what, style);
|
|
269
|
+
}
|
|
270
|
+
async function apiProbe(ctx, path) {
|
|
271
|
+
try {
|
|
272
|
+
const res = await apiRequest(ctx, "GET", path);
|
|
273
|
+
return { status: res.status, body: parseJson(res.text) };
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e instanceof NetError) return { status: 0, body: void 0 };
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/agent.ts
|
|
281
|
+
import { statSync } from "node:fs";
|
|
282
|
+
|
|
283
|
+
// src/version.ts
|
|
284
|
+
var VERSION = "0.1.0";
|
|
285
|
+
|
|
286
|
+
// src/agent.ts
|
|
287
|
+
function isDir(path) {
|
|
288
|
+
try {
|
|
289
|
+
return statSync(path).isDirectory();
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function detectAgent(env = process.env, devinDir = "/opt/.devin") {
|
|
295
|
+
if (env.AI_AGENT) return env.AI_AGENT;
|
|
296
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE) return env.CLAUDE_CODE_IS_COWORK ? "claude-cowork" : "claude-code";
|
|
297
|
+
if (env.CODEX_SANDBOX || env.CODEX_CI || env.CODEX_THREAD_ID) return "codex";
|
|
298
|
+
if (env.GEMINI_CLI) return "gemini-cli";
|
|
299
|
+
if (env.CURSOR_AGENT || env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") return "cursor-cli";
|
|
300
|
+
if (env.ANTIGRAVITY_AGENT) return "antigravity";
|
|
301
|
+
if (env.AUGMENT_AGENT) return "augment-cli";
|
|
302
|
+
if (env.OPENCODE_CLIENT) return "opencode";
|
|
303
|
+
if (env.COPILOT_MODEL || env.COPILOT_ALLOW_ALL || env.COPILOT_GITHUB_TOKEN) return "github-copilot";
|
|
304
|
+
if (env.REPL_ID) return "replit";
|
|
305
|
+
if (isDir(devinDir)) return "devin";
|
|
306
|
+
if (env.CURSOR_TRACE_ID) return "cursor";
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
function sanitizeAgent(raw) {
|
|
310
|
+
let v = raw.replace(/[A-Z]/g, (c) => c.toLowerCase()).replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-");
|
|
311
|
+
if (v.startsWith("-")) v = v.slice(1);
|
|
312
|
+
v = v.slice(0, 40);
|
|
313
|
+
if (v.endsWith("-")) v = v.slice(0, -1);
|
|
314
|
+
return v;
|
|
315
|
+
}
|
|
316
|
+
function resolveAgent(flag, env = process.env) {
|
|
317
|
+
for (const candidate of [flag, env.HOST0_CLIENT, detectAgent(env)]) {
|
|
318
|
+
if (!candidate) continue;
|
|
319
|
+
const name = sanitizeAgent(candidate);
|
|
320
|
+
if (name) return name;
|
|
321
|
+
}
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
function clientHeaderValue(agent) {
|
|
325
|
+
return `${agent || "unknown"}/cli-${VERSION}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/credentials.ts
|
|
329
|
+
import { randomBytes } from "node:crypto";
|
|
330
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
331
|
+
import { dirname } from "node:path";
|
|
332
|
+
function readCredentialsFile() {
|
|
333
|
+
const file = credentialsFile();
|
|
334
|
+
if (!existsSync(file)) return "";
|
|
335
|
+
return readFileSync(file, "utf8").replace(/\s+/g, "");
|
|
336
|
+
}
|
|
337
|
+
function checked(key, source) {
|
|
338
|
+
if (!/^[\x21-\x7e]+$/.test(key)) {
|
|
339
|
+
die(`the API key from ${source} contains invalid characters \u2014 copy it again (an API key is h0_ followed by letters, digits, - and _)`);
|
|
340
|
+
}
|
|
341
|
+
return { key, source };
|
|
342
|
+
}
|
|
343
|
+
function resolveApiKey(flag) {
|
|
344
|
+
const fromFlag = flag?.trim();
|
|
345
|
+
if (fromFlag) return checked(fromFlag, "flag");
|
|
346
|
+
const fromEnv = process.env.HOST0_API_KEY?.trim();
|
|
347
|
+
if (fromEnv) return checked(fromEnv, "env");
|
|
348
|
+
const stored = readCredentialsFile();
|
|
349
|
+
if (stored) return checked(stored, "credentials");
|
|
350
|
+
return { key: "", source: "none" };
|
|
351
|
+
}
|
|
352
|
+
function writePrivateFile(file, content) {
|
|
353
|
+
mkdirSync(dirname(file), { recursive: true, mode: 448 });
|
|
354
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}`;
|
|
355
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
356
|
+
chmodSync(tmp, 384);
|
|
357
|
+
renameSync(tmp, file);
|
|
358
|
+
}
|
|
359
|
+
function saveApiKey(key) {
|
|
360
|
+
writePrivateFile(credentialsFile(), key);
|
|
361
|
+
}
|
|
362
|
+
function deleteCredentials() {
|
|
363
|
+
rmSync(credentialsFile(), { force: true });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/context.ts
|
|
367
|
+
var DEFAULT_BASE_URL = "https://host0.ai";
|
|
368
|
+
function urlOrigin(url) {
|
|
369
|
+
try {
|
|
370
|
+
return new URL(url).origin;
|
|
371
|
+
} catch {
|
|
372
|
+
return "null";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function buildContext(flags) {
|
|
376
|
+
const baseUrl = (flags["base-url"] || process.env.HOST0_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
377
|
+
if (!/^https?:\/\/[^/]+/.test(baseUrl) || urlOrigin(baseUrl) === "null") {
|
|
378
|
+
die(`invalid --base-url: ${baseUrl} (e.g. --base-url https://host0.ai)`);
|
|
379
|
+
}
|
|
380
|
+
const { key, source } = resolveApiKey(flags["api-key"]);
|
|
381
|
+
const agent = resolveAgent(flags.client);
|
|
382
|
+
return {
|
|
383
|
+
baseUrl,
|
|
384
|
+
baseOrigin: urlOrigin(baseUrl),
|
|
385
|
+
allowNonDefaultBaseUrl: flags["allow-nonhost0-base-url"] === true,
|
|
386
|
+
apiKey: key,
|
|
387
|
+
apiKeySource: source,
|
|
388
|
+
agent,
|
|
389
|
+
clientHeader: clientHeaderValue(agent),
|
|
390
|
+
json: flags.json === true
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function guardBaseUrl(ctx, sendsKey) {
|
|
394
|
+
if (ctx.baseUrl === DEFAULT_BASE_URL || ctx.allowNonDefaultBaseUrl) return;
|
|
395
|
+
if (sendsKey && ctx.apiKey) {
|
|
396
|
+
die("refusing to send API key to non-default base URL; pass --allow-nonhost0-base-url to override");
|
|
397
|
+
}
|
|
398
|
+
die("refusing to sign in against a non-default base URL; pass --allow-nonhost0-base-url to override");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/commands/common.ts
|
|
402
|
+
function requireKey(ctx) {
|
|
403
|
+
if (!ctx.apiKey) {
|
|
404
|
+
die(
|
|
405
|
+
"not signed in \u2014 run host0 login (the user approves a link + code in their browser, nothing personal is typed here), or pass --api-key / set HOST0_API_KEY."
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
guardBaseUrl(ctx, true);
|
|
409
|
+
}
|
|
410
|
+
async function call(ctx, method, path, body, what) {
|
|
411
|
+
try {
|
|
412
|
+
const res = await apiRequest(ctx, method, path, body);
|
|
413
|
+
return { json: checkApiError(ctx, res.text, what, "control"), text: res.text };
|
|
414
|
+
} catch (e) {
|
|
415
|
+
if (e instanceof NetError) die(`${what}: ${e.message}`);
|
|
416
|
+
throw e;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function printRaw(text) {
|
|
420
|
+
out(text.endsWith("\n") ? text.slice(0, -1) : text);
|
|
421
|
+
}
|
|
422
|
+
function seg(ref) {
|
|
423
|
+
return encodeURIComponent(ref);
|
|
424
|
+
}
|
|
425
|
+
function when(iso) {
|
|
426
|
+
return typeof iso === "string" && iso.length >= 16 ? iso.slice(0, 16).replace("T", " ") : "";
|
|
427
|
+
}
|
|
428
|
+
function bytes(n) {
|
|
429
|
+
const v = typeof n === "number" ? n : 0;
|
|
430
|
+
if (v < 1024) return `${v} B`;
|
|
431
|
+
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
|
|
432
|
+
return `${(v / 1024 / 1024).toFixed(1)} MB`;
|
|
433
|
+
}
|
|
434
|
+
var ACCESS_LABELS = {
|
|
435
|
+
private: "private (you + invited emails)",
|
|
436
|
+
link: "anyone with the link (never indexed)",
|
|
437
|
+
public: "public on the web (indexable)"
|
|
438
|
+
};
|
|
439
|
+
function table(rows) {
|
|
440
|
+
const widths = [];
|
|
441
|
+
for (const row of rows) row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length));
|
|
442
|
+
for (const row of rows) {
|
|
443
|
+
out(row.map((cell, i) => i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd());
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function fields(pairs) {
|
|
447
|
+
const width = Math.max(...pairs.map(([k]) => k.length)) + 1;
|
|
448
|
+
for (const [k, v] of pairs) out(`${`${k}:`.padEnd(width)} ${v}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/commands/api.ts
|
|
452
|
+
async function apiCommand(ctx, given, method, data) {
|
|
453
|
+
let rawPath = given;
|
|
454
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(given)) {
|
|
455
|
+
if (urlOrigin(given) !== ctx.baseOrigin) {
|
|
456
|
+
die(`refusing to send the API key to ${urlOrigin(given)} \u2014 host0 api only calls the base url (${ctx.baseOrigin}); pass a path like /api/v1/apps`);
|
|
457
|
+
}
|
|
458
|
+
const u = new URL(given);
|
|
459
|
+
rawPath = u.pathname + u.search;
|
|
460
|
+
}
|
|
461
|
+
if (!rawPath.startsWith("/")) {
|
|
462
|
+
die(`pass a path on the base url, e.g. host0 api /api/v1/apps \u2014 got ${given} (the key is never sent to another host)`);
|
|
463
|
+
}
|
|
464
|
+
const path = rawPath.startsWith("/api/") ? rawPath : `/api/v1${rawPath}`;
|
|
465
|
+
const verb = (method || (data !== void 0 ? "POST" : "GET")).toUpperCase();
|
|
466
|
+
if (!/^[A-Z]+$/.test(verb)) die(`invalid method: ${method}`);
|
|
467
|
+
if (data !== void 0) {
|
|
468
|
+
try {
|
|
469
|
+
JSON.parse(data);
|
|
470
|
+
} catch {
|
|
471
|
+
die(`-d must be JSON, e.g. -d '{"visibility": "link"}'`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
requireKey(ctx);
|
|
475
|
+
let res;
|
|
476
|
+
try {
|
|
477
|
+
res = await request(`${ctx.baseUrl}${path}`, {
|
|
478
|
+
method: verb,
|
|
479
|
+
headers: apiHeaders(ctx, data !== void 0),
|
|
480
|
+
body: data,
|
|
481
|
+
idempotent: verb !== "POST"
|
|
482
|
+
});
|
|
483
|
+
} catch (e) {
|
|
484
|
+
if (e instanceof NetError) die(`${verb} ${path}: ${e.message}`);
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
487
|
+
if (res.text !== "") printRaw(res.text);
|
|
488
|
+
if (res.status < 200 || res.status >= 300) die(`${verb} ${path}: HTTP ${res.status}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/device.ts
|
|
492
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
|
|
493
|
+
var now = () => Math.floor(Date.now() / 1e3);
|
|
494
|
+
function loadPendingLogin(baseUrl) {
|
|
495
|
+
const file = deviceFile();
|
|
496
|
+
if (!existsSync2(file)) return null;
|
|
497
|
+
const saved = parseJson(readFileSync2(file, "utf8"));
|
|
498
|
+
if (!saved || typeof saved !== "object" || saved.base_url !== baseUrl || !saved.device_code) return null;
|
|
499
|
+
const interval = saved.interval ?? 5;
|
|
500
|
+
const expiresAt = saved.expires_at ?? 0;
|
|
501
|
+
if (!Number.isInteger(interval) || interval < 0 || !Number.isInteger(expiresAt) || expiresAt < 0) return null;
|
|
502
|
+
return {
|
|
503
|
+
base_url: saved.base_url,
|
|
504
|
+
device_code: String(saved.device_code),
|
|
505
|
+
user_code: String(saved.user_code ?? ""),
|
|
506
|
+
verification_uri: String(saved.verification_uri ?? ""),
|
|
507
|
+
verification_uri_complete: String(saved.verification_uri_complete ?? ""),
|
|
508
|
+
interval,
|
|
509
|
+
expires_at: expiresAt
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function isExpired(login2) {
|
|
513
|
+
return now() >= login2.expires_at;
|
|
514
|
+
}
|
|
515
|
+
function clearPendingLogin() {
|
|
516
|
+
rmSync2(deviceFile(), { force: true });
|
|
517
|
+
}
|
|
518
|
+
function printPendingLogin(login2, opts) {
|
|
519
|
+
const left = Math.max(0, login2.expires_at - now());
|
|
520
|
+
const minutes = Math.floor((left + 59) / 60);
|
|
521
|
+
const next = opts.mode === "rerun" ? `then run this same command again \u2014 it waits up to ${opts.waitSeconds}s for the approval and carries on.` : `then run host0 login --wait \u2014 it waits up to ${opts.waitSeconds}s for the approval and saves the key.`;
|
|
522
|
+
err("host0: sign-in needed \u2014 approve this agent in your browser (no email or password is typed here).");
|
|
523
|
+
err("");
|
|
524
|
+
err(` Open: ${login2.verification_uri_complete}`);
|
|
525
|
+
err(` Code: ${login2.user_code} (the page shows the same code \u2014 check it matches)`);
|
|
526
|
+
err(` Expires in ${minutes} minutes.`);
|
|
527
|
+
err("");
|
|
528
|
+
err(`Next: show the link and code to the user, ${next}`);
|
|
529
|
+
err("");
|
|
530
|
+
err("login_result.status=pending");
|
|
531
|
+
err(`login_result.verification_url=${login2.verification_uri_complete}`);
|
|
532
|
+
err(`login_result.user_code=${login2.user_code}`);
|
|
533
|
+
err(`login_result.expires_in=${left}`);
|
|
534
|
+
}
|
|
535
|
+
function deviceHeaders(ctx) {
|
|
536
|
+
return { "x-host0-client": ctx.clientHeader, "content-type": "application/json" };
|
|
537
|
+
}
|
|
538
|
+
async function startLogin(ctx, opts, note) {
|
|
539
|
+
if (note) err(note);
|
|
540
|
+
let text;
|
|
541
|
+
try {
|
|
542
|
+
const res = await request(`${ctx.baseUrl}/api/v1/auth/device`, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: deviceHeaders(ctx),
|
|
545
|
+
body: "{}",
|
|
546
|
+
idempotent: false
|
|
547
|
+
});
|
|
548
|
+
text = res.text;
|
|
549
|
+
} catch (e) {
|
|
550
|
+
if (e instanceof NetError) die(`start sign-in: ${e.message}`);
|
|
551
|
+
throw e;
|
|
552
|
+
}
|
|
553
|
+
const resp = checkApiError(ctx, text, "start sign-in", "contract");
|
|
554
|
+
const deviceCode = typeof resp.device_code === "string" ? resp.device_code : "";
|
|
555
|
+
const userCode = typeof resp.user_code === "string" ? resp.user_code : "";
|
|
556
|
+
if (!deviceCode || !userCode) die(`start sign-in: unexpected response: ${text}`);
|
|
557
|
+
const login2 = {
|
|
558
|
+
base_url: ctx.baseUrl,
|
|
559
|
+
device_code: deviceCode,
|
|
560
|
+
user_code: userCode,
|
|
561
|
+
verification_uri: String(resp.verification_uri ?? ""),
|
|
562
|
+
verification_uri_complete: String(resp.verification_uri_complete ?? ""),
|
|
563
|
+
interval: typeof resp.interval === "number" ? resp.interval : 5,
|
|
564
|
+
expires_at: now() + (typeof resp.expires_in === "number" ? resp.expires_in : 600)
|
|
565
|
+
};
|
|
566
|
+
writePrivateFile(deviceFile(), JSON.stringify(login2, null, 2) + "\n");
|
|
567
|
+
printPendingLogin(login2, opts);
|
|
568
|
+
throw new Exit(3);
|
|
569
|
+
}
|
|
570
|
+
function sleep2(seconds) {
|
|
571
|
+
return new Promise((resolve3) => setTimeout(resolve3, seconds * 1e3));
|
|
572
|
+
}
|
|
573
|
+
async function waitForApproval(ctx, login2, opts) {
|
|
574
|
+
const deadline = now() + opts.waitSeconds;
|
|
575
|
+
let interval = login2.interval;
|
|
576
|
+
err(`Waiting for approval of code ${login2.user_code} (up to ${opts.waitSeconds}s)\u2026`);
|
|
577
|
+
for (; ; ) {
|
|
578
|
+
let res = null;
|
|
579
|
+
try {
|
|
580
|
+
res = await request(`${ctx.baseUrl}/api/v1/auth/device/token`, {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: deviceHeaders(ctx),
|
|
583
|
+
body: JSON.stringify({ device_code: login2.device_code }),
|
|
584
|
+
idempotent: true
|
|
585
|
+
});
|
|
586
|
+
} catch (e) {
|
|
587
|
+
if (!(e instanceof NetError)) throw e;
|
|
588
|
+
err(`warning: could not reach ${ctx.baseUrl} to check the sign-in \u2014 trying again.`);
|
|
589
|
+
}
|
|
590
|
+
if (res) {
|
|
591
|
+
const body = parseJson(res.text);
|
|
592
|
+
const key = typeof body?.api_key === "string" ? body.api_key : "";
|
|
593
|
+
const code = typeof body?.error === "string" ? body.error : "";
|
|
594
|
+
if (res.status === 200 && key) {
|
|
595
|
+
const email = typeof body.email === "string" ? body.email : "";
|
|
596
|
+
const prefix = typeof body.key_prefix === "string" ? body.key_prefix : "";
|
|
597
|
+
saveApiKey(key);
|
|
598
|
+
clearPendingLogin();
|
|
599
|
+
err(`Signed in as ${email || "your account"} \u2014 API key saved to ${credentialsFile()}.`);
|
|
600
|
+
err("login_result.status=approved");
|
|
601
|
+
err(`login_result.email=${email}`);
|
|
602
|
+
err(`login_result.key_prefix=${prefix}`);
|
|
603
|
+
err(`login_result.credentials_file=${credentialsFile()}`);
|
|
604
|
+
return key;
|
|
605
|
+
}
|
|
606
|
+
switch (code) {
|
|
607
|
+
case "authorization_pending":
|
|
608
|
+
break;
|
|
609
|
+
case "slow_down":
|
|
610
|
+
interval = Number.isInteger(body.interval) && body.interval >= 0 ? body.interval : 10;
|
|
611
|
+
break;
|
|
612
|
+
case "access_denied":
|
|
613
|
+
clearPendingLogin();
|
|
614
|
+
err("login_result.status=denied");
|
|
615
|
+
die("the sign-in was denied in the browser \u2014 run again to start a new one only if the user asks.");
|
|
616
|
+
case "expired_token":
|
|
617
|
+
clearPendingLogin();
|
|
618
|
+
return startLogin(ctx, opts, "The previous code expired \u2014 here is a new one.");
|
|
619
|
+
case "invalid_device_code":
|
|
620
|
+
clearPendingLogin();
|
|
621
|
+
return startLogin(ctx, opts, "The previous sign-in is no longer valid \u2014 here is a new one.");
|
|
622
|
+
default:
|
|
623
|
+
checkApiError(ctx, res.text, "sign-in", "contract");
|
|
624
|
+
die(`sign-in: unexpected response (HTTP ${res.status}): ${res.text.slice(0, 300)}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (now() + interval > deadline) {
|
|
628
|
+
printPendingLogin({ ...login2, interval }, opts);
|
|
629
|
+
throw new Exit(3);
|
|
630
|
+
}
|
|
631
|
+
await sleep2(interval);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function ensureApiKey(ctx, opts) {
|
|
635
|
+
if (ctx.apiKey) return;
|
|
636
|
+
if (opts.noLogin) {
|
|
637
|
+
die(
|
|
638
|
+
"no API key found (--no-login). Run host0 login to sign in \u2014 the user approves a link + code in their browser, nothing personal is typed here \u2014 or pass --api-key / set HOST0_API_KEY."
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
const pending = loadPendingLogin(ctx.baseUrl);
|
|
642
|
+
if (!pending) await startLogin(ctx, opts);
|
|
643
|
+
else if (isExpired(pending)) {
|
|
644
|
+
clearPendingLogin();
|
|
645
|
+
await startLogin(ctx, opts, "the previous code expired \u2014 here is a new one.");
|
|
646
|
+
}
|
|
647
|
+
ctx.apiKey = await waitForApproval(ctx, pending, opts);
|
|
648
|
+
ctx.apiKeySource = "device";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// src/commands/account.ts
|
|
652
|
+
async function login(ctx, opts) {
|
|
653
|
+
guardBaseUrl(ctx, false);
|
|
654
|
+
const loginOpts = { mode: "login", waitSeconds: opts.waitSeconds };
|
|
655
|
+
if (!opts.wait) {
|
|
656
|
+
const pending2 = loadPendingLogin(ctx.baseUrl);
|
|
657
|
+
if (pending2 && !isExpired(pending2)) {
|
|
658
|
+
printPendingLogin(pending2, loginOpts);
|
|
659
|
+
throw new Exit(3);
|
|
660
|
+
}
|
|
661
|
+
await startLogin(ctx, loginOpts);
|
|
662
|
+
}
|
|
663
|
+
const pending = loadPendingLogin(ctx.baseUrl);
|
|
664
|
+
if (!pending) die("no sign-in in progress \u2014 run host0 login first");
|
|
665
|
+
if (isExpired(pending)) {
|
|
666
|
+
clearPendingLogin();
|
|
667
|
+
await startLogin(ctx, loginOpts, "The previous code expired \u2014 here is a new one.");
|
|
668
|
+
}
|
|
669
|
+
await waitForApproval(ctx, pending, loginOpts);
|
|
670
|
+
}
|
|
671
|
+
async function logout(ctx) {
|
|
672
|
+
const file = credentialsFile();
|
|
673
|
+
const key = readCredentialsFile();
|
|
674
|
+
if (!key) {
|
|
675
|
+
out(`Not signed in \u2014 no API key in ${file}.`);
|
|
676
|
+
} else {
|
|
677
|
+
const keyCtx = { ...ctx, apiKey: key, apiKeySource: "credentials" };
|
|
678
|
+
guardBaseUrl(keyCtx, true);
|
|
679
|
+
let revoked = "";
|
|
680
|
+
try {
|
|
681
|
+
const list = await apiRequest(keyCtx, "GET", "/api/v1/keys");
|
|
682
|
+
const body = parseJson(list.text);
|
|
683
|
+
if (list.status !== 200 || !Array.isArray(body?.keys)) throw new Error(body?.message ?? `HTTP ${list.status}`);
|
|
684
|
+
const mine = body.keys.filter(
|
|
685
|
+
(k) => typeof k?.key_prefix === "string" && k.key_prefix !== "" && key.startsWith(k.key_prefix) && !k.revoked_at
|
|
686
|
+
);
|
|
687
|
+
if (mine.length !== 1) {
|
|
688
|
+
throw new Error(mine.length === 0 ? "it isn't in this account's key list" : "more than one key has its prefix");
|
|
689
|
+
}
|
|
690
|
+
const del = await apiRequest(keyCtx, "DELETE", `/api/v1/keys/${encodeURIComponent(mine[0].key_id)}`);
|
|
691
|
+
if (del.status < 200 || del.status >= 300) throw new Error(parseJson(del.text)?.message ?? `HTTP ${del.status}`);
|
|
692
|
+
revoked = mine[0].key_prefix;
|
|
693
|
+
} catch (e) {
|
|
694
|
+
err(
|
|
695
|
+
`warning: could not revoke the key on the server (${e instanceof Error ? e.message : String(e)}) \u2014 deleting the local copy anyway; revoke it in the dashboard if it may have leaked.`
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
deleteCredentials();
|
|
699
|
+
out(revoked ? `Signed out \u2014 revoked ${revoked}\u2026 and deleted ${file}.` : `Signed out \u2014 deleted ${file}.`);
|
|
700
|
+
}
|
|
701
|
+
if (ctx.apiKeySource === "env") {
|
|
702
|
+
err("note: the API key from env (HOST0_API_KEY) was not revoked \u2014 unset HOST0_API_KEY to stop using it.");
|
|
703
|
+
} else if (ctx.apiKeySource === "flag") {
|
|
704
|
+
err("note: the API key from flag (--api-key) was not revoked \u2014 revoke it in the dashboard if you no longer need it.");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function whoami(ctx) {
|
|
708
|
+
requireKey(ctx);
|
|
709
|
+
const { json, text } = await call(ctx, "GET", "/api/v1/account", void 0, "account");
|
|
710
|
+
if (ctx.json) return printRaw(text);
|
|
711
|
+
const plan = json.plan ?? {};
|
|
712
|
+
let label;
|
|
713
|
+
if (!plan.billing_enabled) label = "beta (free during the beta)";
|
|
714
|
+
else if (plan.id === "pro") {
|
|
715
|
+
label = "Pro";
|
|
716
|
+
if (plan.status) label += ` \xB7 ${plan.status}`;
|
|
717
|
+
if (plan.ends_at) label += ` \xB7 ends ${String(plan.ends_at).slice(0, 10)}`;
|
|
718
|
+
else if (plan.renews_at) label += ` \xB7 renews ${String(plan.renews_at).slice(0, 10)}`;
|
|
719
|
+
} else label = "Free";
|
|
720
|
+
out(json.email ?? "(no email)");
|
|
721
|
+
out(`plan: ${label}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/commands/apps.ts
|
|
725
|
+
import { createInterface } from "node:readline/promises";
|
|
726
|
+
|
|
727
|
+
// src/commands/deploy.ts
|
|
728
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
729
|
+
import { join as join3 } from "node:path";
|
|
730
|
+
|
|
731
|
+
// src/exit.ts
|
|
732
|
+
var hooks = [];
|
|
733
|
+
var running = null;
|
|
734
|
+
function onFailure(hook) {
|
|
735
|
+
hooks.push(hook);
|
|
736
|
+
}
|
|
737
|
+
function runFailureHooks() {
|
|
738
|
+
running ??= (async () => {
|
|
739
|
+
for (const hook of hooks) {
|
|
740
|
+
try {
|
|
741
|
+
await hook();
|
|
742
|
+
} catch {
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
})();
|
|
746
|
+
return running;
|
|
747
|
+
}
|
|
748
|
+
function flush(stream) {
|
|
749
|
+
return new Promise((resolve3) => stream.write("", () => resolve3()));
|
|
750
|
+
}
|
|
751
|
+
async function exitProcess(code) {
|
|
752
|
+
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
|
753
|
+
process.exit(code);
|
|
754
|
+
}
|
|
755
|
+
function installSignalHandlers() {
|
|
756
|
+
const handle = (code) => () => {
|
|
757
|
+
interrupt(code);
|
|
758
|
+
void runFailureHooks().then(() => exitProcess(code));
|
|
759
|
+
};
|
|
760
|
+
process.on("SIGINT", handle(130));
|
|
761
|
+
process.on("SIGTERM", handle(143));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/state.ts
|
|
765
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
766
|
+
import { dirname as dirname2 } from "node:path";
|
|
767
|
+
function readState(file) {
|
|
768
|
+
if (!existsSync3(file)) return { apps: {} };
|
|
769
|
+
const state = parseJson(readFileSync3(file, "utf8"));
|
|
770
|
+
if (!state || typeof state !== "object" || Array.isArray(state)) {
|
|
771
|
+
err(`warning: ${file} isn't valid JSON \u2014 ignoring it (it is rewritten after this publish).`);
|
|
772
|
+
return { apps: {} };
|
|
773
|
+
}
|
|
774
|
+
return state;
|
|
775
|
+
}
|
|
776
|
+
function appsOf(state) {
|
|
777
|
+
const apps = state?.apps;
|
|
778
|
+
return apps && typeof apps === "object" && !Array.isArray(apps) ? apps : {};
|
|
779
|
+
}
|
|
780
|
+
var orNull = (v, fallback) => v === void 0 || v === null || v === false ? fallback : v;
|
|
781
|
+
function stateCandidates(state, baseUrl, source) {
|
|
782
|
+
const list = Object.entries(appsOf(state)).filter(([, v]) => v && typeof v === "object").filter(([, v]) => orNull(v.base_url, baseUrl) === baseUrl).filter(([, v]) => orNull(v.source, ".") === source).map(([slug, v]) => ({ slug, app_id: String(orNull(v.app_id, "")), updated_at: String(orNull(v.updated_at, "")) })).filter((c) => c.app_id !== "");
|
|
783
|
+
list.sort((a, b) => a.updated_at < b.updated_at ? -1 : a.updated_at > b.updated_at ? 1 : 0);
|
|
784
|
+
return list.reverse().map(({ app_id, slug }) => ({ app_id, slug }));
|
|
785
|
+
}
|
|
786
|
+
function utcStamp(date = /* @__PURE__ */ new Date()) {
|
|
787
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
788
|
+
}
|
|
789
|
+
function writeStateEntry(file, entry, staleSlugs = []) {
|
|
790
|
+
const state = readState(file);
|
|
791
|
+
const apps = { ...appsOf(state) };
|
|
792
|
+
for (const stale of staleSlugs) delete apps[stale];
|
|
793
|
+
const kept = Object.fromEntries(Object.entries(apps).filter(([, v]) => v?.app_id !== entry.app_id));
|
|
794
|
+
kept[entry.slug] = entry;
|
|
795
|
+
state.apps = kept;
|
|
796
|
+
mkdirSync2(dirname2(file), { recursive: true });
|
|
797
|
+
writeFileSync2(file, JSON.stringify(state, null, 2) + "\n");
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/walk.ts
|
|
801
|
+
import { createHash } from "node:crypto";
|
|
802
|
+
import { createReadStream, readdirSync, statSync as statSync2 } from "node:fs";
|
|
803
|
+
import { basename, dirname as dirname3, join as join2, resolve } from "node:path";
|
|
804
|
+
|
|
805
|
+
// src/content-type.ts
|
|
806
|
+
var TYPES = {
|
|
807
|
+
html: "text/html; charset=utf-8",
|
|
808
|
+
htm: "text/html; charset=utf-8",
|
|
809
|
+
css: "text/css; charset=utf-8",
|
|
810
|
+
js: "text/javascript; charset=utf-8",
|
|
811
|
+
mjs: "text/javascript; charset=utf-8",
|
|
812
|
+
json: "application/json",
|
|
813
|
+
map: "application/json",
|
|
814
|
+
svg: "image/svg+xml",
|
|
815
|
+
png: "image/png",
|
|
816
|
+
jpg: "image/jpeg",
|
|
817
|
+
jpeg: "image/jpeg",
|
|
818
|
+
gif: "image/gif",
|
|
819
|
+
webp: "image/webp",
|
|
820
|
+
ico: "image/x-icon",
|
|
821
|
+
txt: "text/plain; charset=utf-8",
|
|
822
|
+
md: "text/plain; charset=utf-8",
|
|
823
|
+
markdown: "text/plain; charset=utf-8",
|
|
824
|
+
xml: "application/xml",
|
|
825
|
+
woff: "font/woff",
|
|
826
|
+
woff2: "font/woff2",
|
|
827
|
+
wasm: "application/wasm",
|
|
828
|
+
webmanifest: "application/manifest+json",
|
|
829
|
+
pdf: "application/pdf",
|
|
830
|
+
avif: "image/avif",
|
|
831
|
+
mp4: "video/mp4",
|
|
832
|
+
webm: "video/webm",
|
|
833
|
+
mp3: "audio/mpeg",
|
|
834
|
+
csv: "text/csv; charset=utf-8"
|
|
835
|
+
};
|
|
836
|
+
function guessContentType(path) {
|
|
837
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
838
|
+
return Object.prototype.hasOwnProperty.call(TYPES, ext) ? TYPES[ext] : "application/octet-stream";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/walk.ts
|
|
842
|
+
var MAX_LOCAL_FILE_BYTES = 2 * 1024 * 1024 * 1024 - 1;
|
|
843
|
+
function sizeLabel(bytes2) {
|
|
844
|
+
if (bytes2 >= 1024 ** 3) return `${(bytes2 / 1024 ** 3).toFixed(1)} GB`;
|
|
845
|
+
return `${(bytes2 / 1024 ** 2).toFixed(1)} MB`;
|
|
846
|
+
}
|
|
847
|
+
async function sha256File(abs) {
|
|
848
|
+
const hash = createHash("sha256");
|
|
849
|
+
let size = 0;
|
|
850
|
+
for await (const chunk of createReadStream(abs)) {
|
|
851
|
+
size += chunk.length;
|
|
852
|
+
hash.update(chunk);
|
|
853
|
+
}
|
|
854
|
+
return { size, sha256: hash.digest("hex") };
|
|
855
|
+
}
|
|
856
|
+
async function localFile(path, abs) {
|
|
857
|
+
const size = statSync2(abs).size;
|
|
858
|
+
if (size > MAX_LOCAL_FILE_BYTES) {
|
|
859
|
+
die(`${path} is ${sizeLabel(size)} \u2014 too big to publish; host0's largest plan takes 50 MB per file. Leave it out (move it outside the folder), or host it elsewhere and link to it.`);
|
|
860
|
+
}
|
|
861
|
+
return { path, abs, ...await sha256File(abs), content_type: guessContentType(abs) };
|
|
862
|
+
}
|
|
863
|
+
function walk(root, rel, into) {
|
|
864
|
+
for (const entry of readdirSync(join2(root, rel), { withFileTypes: true })) {
|
|
865
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
866
|
+
if (entry.isDirectory()) {
|
|
867
|
+
if (!entry.name.startsWith(".")) walk(root, childRel, into);
|
|
868
|
+
} else if (entry.isFile() && entry.name !== ".DS_Store") {
|
|
869
|
+
into.push(childRel);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function resolveTarget(target) {
|
|
874
|
+
let stat;
|
|
875
|
+
try {
|
|
876
|
+
stat = statSync2(target);
|
|
877
|
+
} catch {
|
|
878
|
+
stat = null;
|
|
879
|
+
}
|
|
880
|
+
const abs = resolve(target);
|
|
881
|
+
if (stat?.isDirectory()) {
|
|
882
|
+
return { kind: "dir", given: target, abs, stateDir: join2(abs, ".host0"), stateSource: ".", defaultName: basename(abs) };
|
|
883
|
+
}
|
|
884
|
+
if (stat?.isFile()) {
|
|
885
|
+
const name = basename(abs);
|
|
886
|
+
const dot = name.lastIndexOf(".");
|
|
887
|
+
const stem = dot >= 0 ? name.slice(0, dot) : name;
|
|
888
|
+
return { kind: "file", given: target, abs, stateDir: join2(dirname3(abs), ".host0"), stateSource: name, defaultName: stem || name };
|
|
889
|
+
}
|
|
890
|
+
die(`not a file or directory: ${target} \u2014 publish the directory that contains index.html, or a single file`);
|
|
891
|
+
}
|
|
892
|
+
async function collectFiles(target) {
|
|
893
|
+
if (target.kind === "file") return [await localFile(target.stateSource, target.abs)];
|
|
894
|
+
const rels = [];
|
|
895
|
+
walk(target.abs, "", rels);
|
|
896
|
+
rels.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
897
|
+
if (rels.length === 0) {
|
|
898
|
+
die(`no files found in ${target.given} (resolved from ${process.cwd()}) \u2014 pass the folder that contains index.html, or a single file`);
|
|
899
|
+
}
|
|
900
|
+
const files = [];
|
|
901
|
+
for (const rel of rels) files.push(await localFile(rel, join2(target.abs, rel)));
|
|
902
|
+
return files;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// src/commands/deploy.ts
|
|
906
|
+
var VISIBILITIES = ["private", "link", "public"];
|
|
907
|
+
var str = (v) => v === void 0 || v === null ? "" : jqRaw(v);
|
|
908
|
+
async function deploy(ctx, opts) {
|
|
909
|
+
const target = resolveTarget(opts.target);
|
|
910
|
+
if (opts.visibility && !VISIBILITIES.includes(opts.visibility)) {
|
|
911
|
+
die(`invalid --visibility: ${opts.visibility} (must be private, link, or public)`);
|
|
912
|
+
}
|
|
913
|
+
guardBaseUrl(ctx, true);
|
|
914
|
+
await ensureApiKey(ctx, { mode: "rerun", waitSeconds: opts.waitSeconds, noLogin: opts.noLogin });
|
|
915
|
+
const stateFile = join3(target.stateDir, "state.json");
|
|
916
|
+
const files = await collectFiles(target);
|
|
917
|
+
let action = "update";
|
|
918
|
+
let appId = opts.appId ?? "";
|
|
919
|
+
let appSlug = "";
|
|
920
|
+
let appVisibility = "";
|
|
921
|
+
const staleSlugs = [];
|
|
922
|
+
let createdApp = null;
|
|
923
|
+
if (appId) {
|
|
924
|
+
const app = await apiJson(ctx, "GET", `/api/v1/apps/${seg(appId)}`, void 0, "app lookup", "contract");
|
|
925
|
+
appSlug = str(app.slug);
|
|
926
|
+
appVisibility = str(app.visibility);
|
|
927
|
+
} else if (opts.slug) {
|
|
928
|
+
const list = await apiJson(ctx, "GET", "/api/v1/apps", void 0, "app lookup", "contract");
|
|
929
|
+
const app = (Array.isArray(list.apps) ? list.apps : []).find((a) => a?.slug === opts.slug);
|
|
930
|
+
if (!app?.app_id) {
|
|
931
|
+
die(
|
|
932
|
+
`no app with slug '${opts.slug}' in this account \u2014 drop --slug to create a new app, or list yours with: host0 ls`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
appId = str(app.app_id);
|
|
936
|
+
appSlug = opts.slug;
|
|
937
|
+
appVisibility = str(app.visibility);
|
|
938
|
+
} else {
|
|
939
|
+
for (const cand of stateCandidates(readState(stateFile), ctx.baseUrl, target.stateSource)) {
|
|
940
|
+
const probe = await apiProbe(ctx, `/api/v1/apps/${seg(cand.app_id)}`);
|
|
941
|
+
if (probe.status === 200 && probe.body && typeof probe.body === "object" && !("error" in probe.body)) {
|
|
942
|
+
appId = cand.app_id;
|
|
943
|
+
appSlug = str(probe.body.slug);
|
|
944
|
+
appVisibility = str(probe.body.visibility);
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
const code = probe.status === 0 ? "000" : String(probe.status);
|
|
948
|
+
if ([401, 403, 404, 410].includes(probe.status)) {
|
|
949
|
+
err(
|
|
950
|
+
`warning: cached app "${cand.slug}" (${cand.app_id}) is not available to this API key (HTTP ${code}) \u2014 dropping it from ${stateFile}`
|
|
951
|
+
);
|
|
952
|
+
staleSlugs.push(cand.slug);
|
|
953
|
+
} else {
|
|
954
|
+
die(
|
|
955
|
+
`app lookup for cached app "${cand.slug}" failed (HTTP ${code}) \u2014 not creating a duplicate; retry, or name the app explicitly with --slug/--app-id`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (!appId) {
|
|
960
|
+
if (staleSlugs.length > 0) {
|
|
961
|
+
err(
|
|
962
|
+
`warning: no app cached in ${stateFile} is usable with this API key \u2014 creating a new app. If you meant to update an existing one, stop and re-run with --slug <slug>.`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
action = "create";
|
|
966
|
+
const appName = opts.name || target.defaultName;
|
|
967
|
+
err(`Creating app "${appName}"...`);
|
|
968
|
+
const created = await apiJson(ctx, "POST", "/api/v1/apps", { name: appName }, "create app", "contract");
|
|
969
|
+
appId = str(created.app_id);
|
|
970
|
+
appSlug = str(created.slug);
|
|
971
|
+
appVisibility = str(created.visibility);
|
|
972
|
+
if (!appId || appId === "null") die(`create app: unexpected response: ${JSON.stringify(created)}`);
|
|
973
|
+
createdApp = { id: appId, slug: appSlug };
|
|
974
|
+
onFailure(async () => {
|
|
975
|
+
if (createdApp) await deleteOrphan(ctx, createdApp);
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
err(`Creating deployment (${files.length} files)...`);
|
|
980
|
+
const manifest = files.map((f) => ({ path: f.path, size: f.size, content_type: f.content_type, sha256: f.sha256 }));
|
|
981
|
+
const deployBody = opts.message ? { files: manifest, message: opts.message } : { files: manifest };
|
|
982
|
+
const dep = await apiJson(ctx, "POST", `/api/v1/apps/${seg(appId)}/deployments`, deployBody, "create deployment", "contract");
|
|
983
|
+
const deploymentId = str(dep.deployment_id);
|
|
984
|
+
const finalizeUrl = str(dep.upload?.finalize_url);
|
|
985
|
+
const targets = Array.isArray(dep.upload?.targets) ? dep.upload.targets : [];
|
|
986
|
+
const skipped = Array.isArray(dep.upload?.skipped) ? dep.upload.skipped.length : 0;
|
|
987
|
+
if (!deploymentId || deploymentId === "null") die(`create deployment: unexpected response: ${JSON.stringify(dep)}`);
|
|
988
|
+
if (createdApp) createdApp.deploymentId = deploymentId;
|
|
989
|
+
const minted = [
|
|
990
|
+
...targets.map((t) => str(t?.url)).filter((u) => /^[a-z]+:\/\/[^/]+\/api\//.test(u)),
|
|
991
|
+
finalizeUrl
|
|
992
|
+
].filter((u) => u !== "" && u !== "null");
|
|
993
|
+
for (const url of minted) {
|
|
994
|
+
const origin = urlOrigin(url);
|
|
995
|
+
if (origin !== ctx.baseOrigin) {
|
|
996
|
+
die(
|
|
997
|
+
`upload targets are on ${origin} but --base-url is ${ctx.baseOrigin} \u2014 the server mints these URLs from its configured platform origin. Re-run with --base-url ${origin} (or set H0_PLATFORM_ORIGIN on the server to match).`
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
err(
|
|
1002
|
+
skipped > 0 ? `Uploading ${targets.length} files (${skipped} unchanged, skipped)...` : `Uploading ${targets.length} files...`
|
|
1003
|
+
);
|
|
1004
|
+
const byPath = new Map(files.map((f) => [f.path, f]));
|
|
1005
|
+
for (const t of targets) {
|
|
1006
|
+
const path = str(t?.path);
|
|
1007
|
+
const url = str(t?.url);
|
|
1008
|
+
const local = byPath.get(path);
|
|
1009
|
+
if (!local) die(`missing local file for upload target: ${path}`);
|
|
1010
|
+
const headers = {};
|
|
1011
|
+
for (const [k, v] of Object.entries(t?.headers ?? {})) headers[k] = jqRaw(v);
|
|
1012
|
+
if (urlOrigin(url) === ctx.baseOrigin) headers.authorization = `Bearer ${ctx.apiKey}`;
|
|
1013
|
+
let bytes2;
|
|
1014
|
+
try {
|
|
1015
|
+
bytes2 = readFileSync4(local.abs);
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
const code = e.code;
|
|
1018
|
+
if (code === "ENOENT") die(`missing local file for upload target: ${path}`);
|
|
1019
|
+
die(`could not read ${path} (${code ?? (e instanceof Error ? e.message : String(e))}) \u2014 check the file, then publish again`);
|
|
1020
|
+
}
|
|
1021
|
+
let text;
|
|
1022
|
+
try {
|
|
1023
|
+
text = (await request(url, { method: "PUT", headers, body: bytes2, idempotent: true })).text;
|
|
1024
|
+
} catch (e) {
|
|
1025
|
+
if (e instanceof NetError) die(`upload ${path}: ${e.message}`);
|
|
1026
|
+
throw e;
|
|
1027
|
+
}
|
|
1028
|
+
checkApiError(ctx, text, `upload ${path}`, "contract");
|
|
1029
|
+
}
|
|
1030
|
+
err("Finalizing...");
|
|
1031
|
+
let finText;
|
|
1032
|
+
try {
|
|
1033
|
+
finText = (await request(finalizeUrl, {
|
|
1034
|
+
method: "POST",
|
|
1035
|
+
headers: { authorization: `Bearer ${ctx.apiKey}`, "x-host0-client": ctx.clientHeader, "content-type": "application/json" },
|
|
1036
|
+
body: "{}",
|
|
1037
|
+
idempotent: true
|
|
1038
|
+
})).text;
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
if (e instanceof NetError) die(`finalize: ${e.message}`);
|
|
1041
|
+
throw e;
|
|
1042
|
+
}
|
|
1043
|
+
let fin = safeParse(finText);
|
|
1044
|
+
if (fin?.error === "not_pending") {
|
|
1045
|
+
try {
|
|
1046
|
+
const status = await apiRequest(ctx, "GET", `/api/v1/deployments/${seg(deploymentId)}`);
|
|
1047
|
+
const parsed = safeParse(status.text);
|
|
1048
|
+
if (parsed?.live === true) {
|
|
1049
|
+
finText = status.text;
|
|
1050
|
+
fin = parsed;
|
|
1051
|
+
}
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
if (!(e instanceof NetError)) throw e;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
fin = checkApiError(ctx, finText, "finalize", "contract");
|
|
1057
|
+
const liveUrl = str(fin.url);
|
|
1058
|
+
if (!liveUrl || liveUrl === "null") die(`finalize: unexpected response: ${finText}`);
|
|
1059
|
+
const liveVersion = fin.version === void 0 || fin.version === null ? "" : str(fin.version);
|
|
1060
|
+
const previousVersion = fin.previous_version === void 0 || fin.previous_version === null ? "" : str(fin.previous_version);
|
|
1061
|
+
createdApp = null;
|
|
1062
|
+
writeStateEntry(
|
|
1063
|
+
stateFile,
|
|
1064
|
+
{ app_id: appId, slug: appSlug, url: liveUrl, base_url: ctx.baseUrl, source: target.stateSource, updated_at: utcStamp() },
|
|
1065
|
+
staleSlugs
|
|
1066
|
+
);
|
|
1067
|
+
if (opts.visibility) {
|
|
1068
|
+
err(`Setting visibility to ${opts.visibility}...`);
|
|
1069
|
+
const vis = await apiJson(ctx, "PATCH", `/api/v1/apps/${seg(appId)}`, { visibility: opts.visibility }, "set visibility", "contract");
|
|
1070
|
+
appVisibility = str(vis.visibility);
|
|
1071
|
+
}
|
|
1072
|
+
out(liveUrl);
|
|
1073
|
+
err("");
|
|
1074
|
+
err(`publish_result.url=${liveUrl}`);
|
|
1075
|
+
err(`publish_result.app_id=${appId}`);
|
|
1076
|
+
err(`publish_result.slug=${appSlug}`);
|
|
1077
|
+
err(`publish_result.action=${action}`);
|
|
1078
|
+
err(`publish_result.deployment_id=${deploymentId}`);
|
|
1079
|
+
err(`publish_result.version=${liveVersion}`);
|
|
1080
|
+
err(`publish_result.previous_version=${previousVersion}`);
|
|
1081
|
+
err(`publish_result.visibility=${appVisibility}`);
|
|
1082
|
+
err(`publish_result.api_key_source=${ctx.apiKeySource}`);
|
|
1083
|
+
err(`publish_result.files_total=${files.length}`);
|
|
1084
|
+
err(`publish_result.files_uploaded=${targets.length}`);
|
|
1085
|
+
err(`publish_result.files_skipped=${skipped}`);
|
|
1086
|
+
err(`publish_result.state_pruned=${staleSlugs.length}`);
|
|
1087
|
+
}
|
|
1088
|
+
function safeParse(text) {
|
|
1089
|
+
try {
|
|
1090
|
+
return JSON.parse(text);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return void 0;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function deleteOrphan(ctx, app) {
|
|
1096
|
+
const headers = { authorization: `Bearer ${ctx.apiKey}`, "x-host0-client": ctx.clientHeader };
|
|
1097
|
+
const failed = (warning) => {
|
|
1098
|
+
err(warning);
|
|
1099
|
+
err("publish_result.cleanup=failed");
|
|
1100
|
+
err(`publish_result.cleanup_slug=${app.slug}`);
|
|
1101
|
+
};
|
|
1102
|
+
if (app.deploymentId) {
|
|
1103
|
+
let live;
|
|
1104
|
+
try {
|
|
1105
|
+
const res = await request(`${ctx.baseUrl}/api/v1/deployments/${seg(app.deploymentId)}`, {
|
|
1106
|
+
method: "GET",
|
|
1107
|
+
headers,
|
|
1108
|
+
idempotent: true,
|
|
1109
|
+
ignoreInterrupt: true
|
|
1110
|
+
});
|
|
1111
|
+
const body = safeParse(res.text);
|
|
1112
|
+
if (res.status === 200 && body && typeof body.live === "boolean") live = body.live;
|
|
1113
|
+
} catch {
|
|
1114
|
+
live = void 0;
|
|
1115
|
+
}
|
|
1116
|
+
if (live === void 0) {
|
|
1117
|
+
return failed(
|
|
1118
|
+
`warning: could not check whether the app this run created (${app.slug}, ${app.id}) went live \u2014 not deleting it; check your dashboard and delete it there if it isn't live.`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
if (live) {
|
|
1122
|
+
return failed(`warning: the app this run created (${app.slug}) went live after all \u2014 keeping it.`);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
let status = 0;
|
|
1126
|
+
try {
|
|
1127
|
+
status = (await request(`${ctx.baseUrl}/api/v1/apps/${seg(app.id)}`, {
|
|
1128
|
+
method: "DELETE",
|
|
1129
|
+
headers,
|
|
1130
|
+
// publish.sh's cleanup is a `once` call: only never-reached failures retry
|
|
1131
|
+
idempotent: false,
|
|
1132
|
+
ignoreInterrupt: true
|
|
1133
|
+
})).status;
|
|
1134
|
+
} catch {
|
|
1135
|
+
status = 0;
|
|
1136
|
+
}
|
|
1137
|
+
if (status >= 200 && status < 300) {
|
|
1138
|
+
err(`Cleaning up: deleted the app this run created (${app.slug}) \u2014 it never went live.`);
|
|
1139
|
+
err("publish_result.cleanup=deleted");
|
|
1140
|
+
err(`publish_result.cleanup_slug=${app.slug}`);
|
|
1141
|
+
} else {
|
|
1142
|
+
const code = status === 0 ? "000" : String(status);
|
|
1143
|
+
failed(`warning: could not delete the app this run created (${app.slug}, ${app.id}) \u2014 delete it from your dashboard (HTTP ${code}).`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/commands/apps.ts
|
|
1148
|
+
async function ls(ctx) {
|
|
1149
|
+
requireKey(ctx);
|
|
1150
|
+
const { json, text } = await call(ctx, "GET", "/api/v1/apps", void 0, "list apps");
|
|
1151
|
+
if (ctx.json) return printRaw(text);
|
|
1152
|
+
const apps = Array.isArray(json.apps) ? json.apps : [];
|
|
1153
|
+
if (apps.length === 0) return out("No apps yet \u2014 deploy one with: host0 deploy <dir|file>");
|
|
1154
|
+
table([
|
|
1155
|
+
["Slug", "Name", "Access", "Last deploy", "URL"],
|
|
1156
|
+
...apps.map((a) => [
|
|
1157
|
+
String(a.slug ?? ""),
|
|
1158
|
+
String(a.name ?? ""),
|
|
1159
|
+
String(a.visibility ?? ""),
|
|
1160
|
+
a.live_since ? when(a.live_since) : "never",
|
|
1161
|
+
String(a.url ?? "")
|
|
1162
|
+
])
|
|
1163
|
+
]);
|
|
1164
|
+
}
|
|
1165
|
+
async function inspect(ctx, app) {
|
|
1166
|
+
requireKey(ctx);
|
|
1167
|
+
const { json: a, text } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}`, void 0, "app lookup");
|
|
1168
|
+
if (ctx.json) return printRaw(text);
|
|
1169
|
+
const shares = Array.isArray(a.shares) ? a.shares : [];
|
|
1170
|
+
fields([
|
|
1171
|
+
["Name", String(a.name ?? "")],
|
|
1172
|
+
["URL", String(a.url ?? "")],
|
|
1173
|
+
["Access", ACCESS_LABELS[a.visibility] ?? String(a.visibility ?? "")],
|
|
1174
|
+
["Live version", a.live_version ? `v${a.live_version} (since ${when(a.live_since)})` : "not live yet"],
|
|
1175
|
+
["Owner", String(a.owner_email ?? "")],
|
|
1176
|
+
["Shared with", shares.length > 0 ? shares.join(", ") : "nobody"],
|
|
1177
|
+
["App ID", String(a.app_id ?? "")],
|
|
1178
|
+
["Created", when(a.created_at)]
|
|
1179
|
+
]);
|
|
1180
|
+
}
|
|
1181
|
+
async function visibility(ctx, app, mode) {
|
|
1182
|
+
if (!VISIBILITIES.includes(mode)) {
|
|
1183
|
+
die(`invalid visibility: ${mode} (must be private, link, or public)`);
|
|
1184
|
+
}
|
|
1185
|
+
requireKey(ctx);
|
|
1186
|
+
const { json, text } = await call(ctx, "PATCH", `/api/v1/apps/${seg(app)}`, { visibility: mode }, "set visibility");
|
|
1187
|
+
if (ctx.json) return printRaw(text);
|
|
1188
|
+
out(`${json.slug} is now ${ACCESS_LABELS[json.visibility] ?? json.visibility} \u2014 ${json.url}`);
|
|
1189
|
+
}
|
|
1190
|
+
async function rename(ctx, app, to) {
|
|
1191
|
+
if (to.slug === void 0 && to.name === void 0) die("rename needs --slug <new-slug> and/or --name <display name>");
|
|
1192
|
+
requireKey(ctx);
|
|
1193
|
+
const body = {};
|
|
1194
|
+
if (to.slug !== void 0) body.slug = to.slug;
|
|
1195
|
+
if (to.name !== void 0) body.name = to.name;
|
|
1196
|
+
const { json, text } = await call(ctx, "PATCH", `/api/v1/apps/${seg(app)}`, body, "rename");
|
|
1197
|
+
if (ctx.json) return printRaw(text);
|
|
1198
|
+
out(`Renamed: ${json.name} \u2014 ${json.url}`);
|
|
1199
|
+
if (json.old_slug) out(`The old URL (${json.old_slug}) redirects here for 30 days and stays reserved for you.`);
|
|
1200
|
+
}
|
|
1201
|
+
async function share(ctx, app, emails) {
|
|
1202
|
+
if (emails.length === 0) die("share needs at least one email: host0 share <app> <email\u2026>");
|
|
1203
|
+
requireKey(ctx);
|
|
1204
|
+
const { json, text } = await call(ctx, "POST", `/api/v1/apps/${seg(app)}/shares`, { emails }, "share");
|
|
1205
|
+
if (ctx.json) return printRaw(text);
|
|
1206
|
+
out(`Shared ${json.slug} with:`);
|
|
1207
|
+
const delivery = Array.isArray(json.invite_delivery) ? json.invite_delivery : [];
|
|
1208
|
+
for (const d of delivery) out(` ${d.email} invite: ${d.status}`);
|
|
1209
|
+
const shares = Array.isArray(json.shares) ? json.shares : [];
|
|
1210
|
+
out(`Everyone with access: ${shares.length > 0 ? shares.join(", ") : "nobody"}`);
|
|
1211
|
+
}
|
|
1212
|
+
async function unshare(ctx, app, email) {
|
|
1213
|
+
requireKey(ctx);
|
|
1214
|
+
const { text } = await call(ctx, "DELETE", `/api/v1/apps/${seg(app)}/shares/${seg(email)}`, void 0, "unshare");
|
|
1215
|
+
if (ctx.json) return printRaw(text);
|
|
1216
|
+
out(`Removed ${email} from ${app} \u2014 their access ends now.`);
|
|
1217
|
+
}
|
|
1218
|
+
async function confirm(question) {
|
|
1219
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1220
|
+
try {
|
|
1221
|
+
return /^y(es)?$/i.test((await rl.question(question)).trim());
|
|
1222
|
+
} finally {
|
|
1223
|
+
rl.close();
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
async function rm(ctx, app, yes) {
|
|
1227
|
+
requireKey(ctx);
|
|
1228
|
+
if (!yes) {
|
|
1229
|
+
if (!process.stdin.isTTY || ctx.agent) {
|
|
1230
|
+
die(`refusing to delete ${app} without confirmation \u2014 pass --yes (it deletes the app, its versions, shares and records for good)`);
|
|
1231
|
+
}
|
|
1232
|
+
const { json: a } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}`, void 0, "app lookup");
|
|
1233
|
+
if (!await confirm(`Delete ${a.slug} (${a.url})? [y/N] `)) die("not deleted");
|
|
1234
|
+
}
|
|
1235
|
+
const { json, text } = await call(ctx, "DELETE", `/api/v1/apps/${seg(app)}`, void 0, "delete app");
|
|
1236
|
+
if (ctx.json) return printRaw(text);
|
|
1237
|
+
out(`Deleted ${json.slug}.`);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// src/commands/data.ts
|
|
1241
|
+
function top(title, rows) {
|
|
1242
|
+
if (!Array.isArray(rows) || rows.length === 0) return;
|
|
1243
|
+
out("");
|
|
1244
|
+
out(`${title}:`);
|
|
1245
|
+
table(rows.slice(0, 5).map((r) => [` ${r.key === "" ? "(none)" : r.key}`, `${r.visitors} visitors`, `${r.pageviews} views`]));
|
|
1246
|
+
}
|
|
1247
|
+
async function analytics(ctx, app, period) {
|
|
1248
|
+
requireKey(ctx);
|
|
1249
|
+
const query = period ? `?period=${encodeURIComponent(period)}` : "";
|
|
1250
|
+
const { json: a, text } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}/analytics${query}`, void 0, "analytics");
|
|
1251
|
+
if (ctx.json) return printRaw(text);
|
|
1252
|
+
if (a.locked) {
|
|
1253
|
+
out(`${a.slug}: ${a.visits_this_month} visits this month (since ${a.month})`);
|
|
1254
|
+
if (a.message) out(a.message);
|
|
1255
|
+
if (a.upgrade_url) out(`Upgrade: ${a.upgrade_url}`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
out(`${a.slug} \xB7 last ${a.period}`);
|
|
1259
|
+
out(`Visitors: ${a.totals?.visitors ?? 0} (previous ${a.period}: ${a.previous?.visitors ?? 0})`);
|
|
1260
|
+
out(`Page views: ${a.totals?.pageviews ?? 0} (previous ${a.period}: ${a.previous?.pageviews ?? 0})`);
|
|
1261
|
+
top("Pages", a.pages);
|
|
1262
|
+
top("Referrers", (a.referrers ?? []).map((r) => ({ ...r, key: r.key === "" ? "(direct)" : r.key })));
|
|
1263
|
+
top("Countries", a.countries);
|
|
1264
|
+
top("Devices", a.devices);
|
|
1265
|
+
}
|
|
1266
|
+
async function records(ctx, app, collection, exportAll) {
|
|
1267
|
+
requireKey(ctx);
|
|
1268
|
+
if (!collection) {
|
|
1269
|
+
if (exportAll) die("--export needs a collection: host0 records <app> <collection> --export");
|
|
1270
|
+
const { json: json2, text: text2 } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}/records`, void 0, "records");
|
|
1271
|
+
if (ctx.json) return printRaw(text2);
|
|
1272
|
+
const declared = Array.isArray(json2.collections) ? json2.collections : [];
|
|
1273
|
+
const retained = Array.isArray(json2.retained) ? json2.retained : [];
|
|
1274
|
+
if (declared.length === 0 && retained.length === 0) {
|
|
1275
|
+
return out(`${json2.slug} has no shared data \u2014 declare collections in an h0.json to add some.`);
|
|
1276
|
+
}
|
|
1277
|
+
table([
|
|
1278
|
+
["Collection", "Records", "Latest", "Fields"],
|
|
1279
|
+
...declared.map((c) => [
|
|
1280
|
+
String(c.collection),
|
|
1281
|
+
String(c.count ?? 0),
|
|
1282
|
+
when(c.latest),
|
|
1283
|
+
Object.entries(c.fields ?? {}).map(([k, t]) => `${k}:${t}`).join(", ")
|
|
1284
|
+
])
|
|
1285
|
+
]);
|
|
1286
|
+
if (retained.length > 0) {
|
|
1287
|
+
out("");
|
|
1288
|
+
out(
|
|
1289
|
+
`Kept from collections the live version no longer declares: ${retained.map((c) => `${c.collection} (${c.count})`).join(", ")}`
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const path = `/api/v1/apps/${seg(app)}/records/${seg(collection)}`;
|
|
1295
|
+
if (exportAll) {
|
|
1296
|
+
const { text: text2 } = await call(ctx, "GET", `${path}?format=export`, void 0, "export");
|
|
1297
|
+
return printRaw(text2);
|
|
1298
|
+
}
|
|
1299
|
+
const { json, text } = await call(ctx, "GET", path, void 0, "records");
|
|
1300
|
+
if (ctx.json) return printRaw(text);
|
|
1301
|
+
const list = Array.isArray(json.records) ? json.records : [];
|
|
1302
|
+
if (list.length === 0) {
|
|
1303
|
+
const { json: all } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}/records`, void 0, "records");
|
|
1304
|
+
const known = [...all.collections ?? [], ...all.retained ?? []].some((c) => c?.collection === collection);
|
|
1305
|
+
if (!known) die(`no collection named ${collection} in ${all.slug} \u2014 host0 records ${app} lists them`);
|
|
1306
|
+
return out(`${collection} has no records.`);
|
|
1307
|
+
}
|
|
1308
|
+
for (const r of list) {
|
|
1309
|
+
out(`${r.id} ${when(r.created_at)} ${r.created_by_email ?? "anonymous"} ${JSON.stringify(r.data)}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/commands/pull.ts
|
|
1314
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1315
|
+
import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync3, readdirSync as readdirSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1316
|
+
import { dirname as dirname4, join as join4, resolve as resolve2 } from "node:path";
|
|
1317
|
+
var str2 = (v) => v === void 0 || v === null ? "" : jqRaw(v);
|
|
1318
|
+
function isSafeRelativePath(path) {
|
|
1319
|
+
const parts = `/${path}/`.split(/[\\/]/).slice(1, -1);
|
|
1320
|
+
return path !== "" && parts[0] !== ".host0" && !parts.some((p) => p === "" || p === "." || p === "..");
|
|
1321
|
+
}
|
|
1322
|
+
function isSymlink(path) {
|
|
1323
|
+
try {
|
|
1324
|
+
return lstatSync(path).isSymbolicLink();
|
|
1325
|
+
} catch {
|
|
1326
|
+
return false;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
async function pull(ctx, opts) {
|
|
1330
|
+
guardBaseUrl(ctx, true);
|
|
1331
|
+
await ensureApiKey(ctx, { mode: "rerun", waitSeconds: opts.waitSeconds, noLogin: opts.noLogin });
|
|
1332
|
+
err(`Listing the live files of ${opts.app}...`);
|
|
1333
|
+
const live = await apiJson(ctx, "GET", `/api/v1/apps/${seg(opts.app)}/files`, void 0, "list files", "contract");
|
|
1334
|
+
const appId = str2(live.app_id);
|
|
1335
|
+
const appSlug = str2(live.slug);
|
|
1336
|
+
const liveUrl = str2(live.url);
|
|
1337
|
+
const liveVersion = str2(live.version);
|
|
1338
|
+
const dest = opts.dir || appSlug;
|
|
1339
|
+
if (existsSync4(dest) && !statSync3(dest).isDirectory()) {
|
|
1340
|
+
die(`${dest} is a file \u2014 pass an empty or new folder to pull into`);
|
|
1341
|
+
}
|
|
1342
|
+
if (existsSync4(dest) && readdirSync2(dest).some((name) => name !== ".host0")) {
|
|
1343
|
+
die(
|
|
1344
|
+
`${dest} isn't empty \u2014 pull into a new folder (e.g. host0 pull ${appSlug} ./${appSlug}-live) so nothing local is overwritten`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
const files = Array.isArray(live.files) ? live.files : [];
|
|
1348
|
+
for (const f of files) {
|
|
1349
|
+
if (!isSafeRelativePath(str2(f?.path))) die(`refusing an unsafe path from the api: ${str2(f?.path)}`);
|
|
1350
|
+
}
|
|
1351
|
+
mkdirSync3(dest, { recursive: true });
|
|
1352
|
+
const destAbs = resolve2(dest);
|
|
1353
|
+
const stateDir = join4(destAbs, ".host0");
|
|
1354
|
+
if (isSymlink(stateDir) || existsSync4(stateDir) && !statSync3(stateDir).isDirectory()) {
|
|
1355
|
+
die(`${stateDir} isn't a plain folder \u2014 remove it, or pull into a new folder`);
|
|
1356
|
+
}
|
|
1357
|
+
let count = 0;
|
|
1358
|
+
for (const f of files) {
|
|
1359
|
+
const path = str2(f.path);
|
|
1360
|
+
const url = str2(f.download_url);
|
|
1361
|
+
const headers = urlOrigin(url) === ctx.baseOrigin ? { authorization: `Bearer ${ctx.apiKey}`, "x-host0-client": ctx.clientHeader } : {};
|
|
1362
|
+
let bytes2;
|
|
1363
|
+
try {
|
|
1364
|
+
const res = await request(url, { method: "GET", headers, idempotent: true });
|
|
1365
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`HTTP ${res.status}`);
|
|
1366
|
+
bytes2 = res.body;
|
|
1367
|
+
} catch {
|
|
1368
|
+
die(`could not download ${path}`);
|
|
1369
|
+
}
|
|
1370
|
+
const target = join4(destAbs, path);
|
|
1371
|
+
mkdirSync3(dirname4(target), { recursive: true });
|
|
1372
|
+
writeFileSync3(target, bytes2);
|
|
1373
|
+
const want = str2(f.sha256);
|
|
1374
|
+
if (want) {
|
|
1375
|
+
const got = createHash2("sha256").update(bytes2).digest("hex");
|
|
1376
|
+
if (got !== want) die(`${path} downloaded with the wrong hash (${got}, expected ${want}) \u2014 run host0 pull again`);
|
|
1377
|
+
}
|
|
1378
|
+
count++;
|
|
1379
|
+
}
|
|
1380
|
+
writeStateEntry(join4(destAbs, ".host0", "state.json"), {
|
|
1381
|
+
app_id: appId,
|
|
1382
|
+
slug: appSlug,
|
|
1383
|
+
url: liveUrl,
|
|
1384
|
+
base_url: ctx.baseUrl,
|
|
1385
|
+
source: ".",
|
|
1386
|
+
updated_at: utcStamp()
|
|
1387
|
+
});
|
|
1388
|
+
out(destAbs);
|
|
1389
|
+
err("");
|
|
1390
|
+
err(`pull_result.dir=${destAbs}`);
|
|
1391
|
+
err(`pull_result.app_id=${appId}`);
|
|
1392
|
+
err(`pull_result.slug=${appSlug}`);
|
|
1393
|
+
err(`pull_result.url=${liveUrl}`);
|
|
1394
|
+
err(`pull_result.version=${liveVersion}`);
|
|
1395
|
+
err(`pull_result.files=${count}`);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// src/commands/versions.ts
|
|
1399
|
+
async function versions(ctx, app) {
|
|
1400
|
+
requireKey(ctx);
|
|
1401
|
+
const { json, text } = await call(ctx, "GET", `/api/v1/apps/${seg(app)}/versions`, void 0, "list versions");
|
|
1402
|
+
if (ctx.json) return printRaw(text);
|
|
1403
|
+
const list = Array.isArray(json.versions) ? json.versions : [];
|
|
1404
|
+
if (list.length === 0) return out(`${json.slug} has no versions yet \u2014 deploy it first.`);
|
|
1405
|
+
table([
|
|
1406
|
+
["Version", "Live", "Published", "Files", "Size", "Note"],
|
|
1407
|
+
...list.map((v) => [
|
|
1408
|
+
`v${v.version}`,
|
|
1409
|
+
v.live ? "live" : "",
|
|
1410
|
+
when(v.created_at),
|
|
1411
|
+
String(v.file_count ?? ""),
|
|
1412
|
+
bytes(v.total_bytes),
|
|
1413
|
+
[v.message ?? "", v.restored_from_version ? `(restored from v${v.restored_from_version})` : ""].filter(Boolean).join(" ")
|
|
1414
|
+
])
|
|
1415
|
+
]);
|
|
1416
|
+
out("");
|
|
1417
|
+
out(
|
|
1418
|
+
json.retention_days ? `Versions replaced in the last ${json.retention_days} days are kept \u2014 restore one with: host0 rollback ${json.slug} <version>` : "Only the live version is kept on this plan."
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
async function rollback(ctx, app, version, message) {
|
|
1422
|
+
requireKey(ctx);
|
|
1423
|
+
const body = message !== void 0 ? { message } : {};
|
|
1424
|
+
const { json, text } = await call(
|
|
1425
|
+
ctx,
|
|
1426
|
+
"POST",
|
|
1427
|
+
`/api/v1/apps/${seg(app)}/versions/${seg(version)}/restore`,
|
|
1428
|
+
body,
|
|
1429
|
+
"restore"
|
|
1430
|
+
);
|
|
1431
|
+
if (ctx.json) return printRaw(text);
|
|
1432
|
+
if (json.already_live) out(`v${json.version} is already live \u2014 ${json.url}`);
|
|
1433
|
+
else out(`Restored v${json.restored_from_version} as v${json.version} \u2014 ${json.url}`);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/cli.ts
|
|
1437
|
+
var GLOBAL_OPTIONS = {
|
|
1438
|
+
"base-url": { type: "string" },
|
|
1439
|
+
"allow-nonhost0-base-url": { type: "boolean" },
|
|
1440
|
+
"api-key": { type: "string" },
|
|
1441
|
+
client: { type: "string" },
|
|
1442
|
+
json: { type: "boolean" },
|
|
1443
|
+
help: { type: "boolean", short: "h" },
|
|
1444
|
+
version: { type: "boolean", short: "v" }
|
|
1445
|
+
};
|
|
1446
|
+
var LOGIN_WAIT = {
|
|
1447
|
+
"no-login": { type: "boolean" },
|
|
1448
|
+
timeout: { type: "string" },
|
|
1449
|
+
// publish.sh's spelling of --timeout
|
|
1450
|
+
wait: { type: "string" }
|
|
1451
|
+
};
|
|
1452
|
+
var COMMANDS = {
|
|
1453
|
+
deploy: {
|
|
1454
|
+
usage: "host0 deploy <dir|file> [options]",
|
|
1455
|
+
summary: "Publish a folder or one file and print its live URL",
|
|
1456
|
+
options: {
|
|
1457
|
+
name: { type: "string" },
|
|
1458
|
+
slug: { type: "string" },
|
|
1459
|
+
"app-id": { type: "string" },
|
|
1460
|
+
visibility: { type: "string" },
|
|
1461
|
+
message: { type: "string" },
|
|
1462
|
+
...LOGIN_WAIT
|
|
1463
|
+
},
|
|
1464
|
+
help: `A directory's contents become the site root; a single file (a PDF, an image,
|
|
1465
|
+
one HTML page) is served at the app's root URL. The folder remembers its app in
|
|
1466
|
+
.host0/state.json (shared with publish.sh), so the next deploy is a new version
|
|
1467
|
+
of the same app.
|
|
1468
|
+
|
|
1469
|
+
With no API key it starts a device login: it prints a link + code and exits 3 \u2014
|
|
1470
|
+
show them to the user, then run the same command again.
|
|
1471
|
+
|
|
1472
|
+
Options:
|
|
1473
|
+
--name <text> Display name when creating (default: the folder or file name)
|
|
1474
|
+
--slug <slug> Update the existing app with this slug
|
|
1475
|
+
--app-id <uuid> Update the existing app with this id
|
|
1476
|
+
--visibility <mode> After publishing: private (default: you + invited emails) |
|
|
1477
|
+
link (anyone with the URL, never indexed) | public (indexable)
|
|
1478
|
+
--message <text> A note for this version (\u2264 200 characters)
|
|
1479
|
+
--no-login With no API key, fail instead of starting a login
|
|
1480
|
+
--timeout <seconds> How long to wait for a login approval (default 90)`
|
|
1481
|
+
},
|
|
1482
|
+
login: {
|
|
1483
|
+
usage: "host0 login [--wait] [--timeout <seconds>]",
|
|
1484
|
+
summary: "Sign in with a link + code the user approves in their browser",
|
|
1485
|
+
options: { wait: { type: "boolean" }, timeout: { type: "string" } },
|
|
1486
|
+
help: `Starts a device login (or re-shows the pending one) and exits 3: show the link
|
|
1487
|
+
and code to the user. Then \`host0 login --wait\` waits for the approval and saves
|
|
1488
|
+
the key to ~/.host0/credentials (exit 0; 3 = still pending; 1 = denied).`
|
|
1489
|
+
},
|
|
1490
|
+
logout: {
|
|
1491
|
+
usage: "host0 logout",
|
|
1492
|
+
summary: "Revoke the stored API key and delete ~/.host0/credentials",
|
|
1493
|
+
options: {}
|
|
1494
|
+
},
|
|
1495
|
+
whoami: { usage: "host0 whoami", summary: "Show the signed-in account and plan", options: {} },
|
|
1496
|
+
ls: { usage: "host0 ls", summary: "List your apps", options: {} },
|
|
1497
|
+
inspect: { usage: "host0 inspect <app>", summary: "Show one app: URL, access, live version, shares", options: {} },
|
|
1498
|
+
pull: {
|
|
1499
|
+
usage: "host0 pull <app> [dir]",
|
|
1500
|
+
summary: "Download an app's live files into a new folder to edit them",
|
|
1501
|
+
options: { slug: { type: "string" }, "app-id": { type: "string" }, ...LOGIN_WAIT },
|
|
1502
|
+
help: `Downloads every live file (sha256-checked) into [dir] (default: ./<slug>; it
|
|
1503
|
+
must be empty or new) and sets the folder up so \`host0 deploy <dir>\` publishes
|
|
1504
|
+
the app's next version.`
|
|
1505
|
+
},
|
|
1506
|
+
versions: { usage: "host0 versions <app>", summary: "List an app's versions", options: {} },
|
|
1507
|
+
rollback: {
|
|
1508
|
+
usage: "host0 rollback <app> <version> [--message <text>]",
|
|
1509
|
+
summary: "Put an earlier version live again, as a new version",
|
|
1510
|
+
options: { message: { type: "string" } }
|
|
1511
|
+
},
|
|
1512
|
+
visibility: {
|
|
1513
|
+
usage: "host0 visibility <app> <private|link|public>",
|
|
1514
|
+
summary: "Change who can open an app",
|
|
1515
|
+
options: {}
|
|
1516
|
+
},
|
|
1517
|
+
rename: {
|
|
1518
|
+
usage: "host0 rename <app> [--slug <new-slug>] [--name <display name>]",
|
|
1519
|
+
summary: "Rename an app's URL and/or display name",
|
|
1520
|
+
options: { slug: { type: "string" }, name: { type: "string" } }
|
|
1521
|
+
},
|
|
1522
|
+
share: { usage: "host0 share <app> <email\u2026>", summary: "Invite people by email", options: {} },
|
|
1523
|
+
unshare: { usage: "host0 unshare <app> <email>", summary: "Remove someone's access", options: {} },
|
|
1524
|
+
rm: {
|
|
1525
|
+
usage: "host0 rm <app> --yes",
|
|
1526
|
+
summary: "Delete an app for good",
|
|
1527
|
+
options: { yes: { type: "boolean", short: "y" } }
|
|
1528
|
+
},
|
|
1529
|
+
analytics: {
|
|
1530
|
+
usage: "host0 analytics <app> [--period 24h|7d|30d|90d|365d]",
|
|
1531
|
+
summary: "Visitors and page views",
|
|
1532
|
+
options: { period: { type: "string" } }
|
|
1533
|
+
},
|
|
1534
|
+
records: {
|
|
1535
|
+
usage: "host0 records <app> [collection] [--export]",
|
|
1536
|
+
summary: "An app's shared data: its collections, or one collection's records",
|
|
1537
|
+
options: { export: { type: "boolean" } }
|
|
1538
|
+
},
|
|
1539
|
+
api: {
|
|
1540
|
+
usage: "host0 api <path> [-X <method>] [-d <json>]",
|
|
1541
|
+
summary: "An authenticated request to the host0 API (/api/v1/\u2026)",
|
|
1542
|
+
options: { method: { type: "string", short: "X" }, data: { type: "string", short: "d" } }
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
var ALIASES = { publish: "deploy", restore: "rollback" };
|
|
1546
|
+
function mainUsage() {
|
|
1547
|
+
const short = (c) => c.usage.replace(/^host0 /, "");
|
|
1548
|
+
const width = Math.max(...Object.values(COMMANDS).map((c) => short(c).length));
|
|
1549
|
+
return `host0 ${VERSION} \u2014 hosting for whatever your agent creates
|
|
1550
|
+
|
|
1551
|
+
Usage: host0 <command> [options]
|
|
1552
|
+
host0 <dir|file> Same as host0 deploy <dir|file>
|
|
1553
|
+
|
|
1554
|
+
Commands:
|
|
1555
|
+
${Object.values(COMMANDS).map((c) => ` ${short(c).padEnd(width)} ${c.summary}`).join("\n")}
|
|
1556
|
+
|
|
1557
|
+
Aliases: publish = deploy, restore = rollback. <app> is a slug or an app id.
|
|
1558
|
+
|
|
1559
|
+
Global options:
|
|
1560
|
+
--json Print the API's JSON (control commands)
|
|
1561
|
+
--api-key <key> API key (default: $HOST0_API_KEY, then ~/.host0/credentials)
|
|
1562
|
+
--base-url <url> API base (default: https://host0.ai, or $HOST0_BASE_URL)
|
|
1563
|
+
--allow-nonhost0-base-url Allow sending the API key to a non-default base URL
|
|
1564
|
+
--client <name> Agent name for attribution (default: $HOST0_CLIENT or detected)
|
|
1565
|
+
-h, --help Show help (host0 <command> --help for a command)
|
|
1566
|
+
-v, --version Show the version
|
|
1567
|
+
|
|
1568
|
+
deploy and pull print one result on stdout (the URL, the folder) and
|
|
1569
|
+
publish_result.* / pull_result.* / login_result.* lines on stderr \u2014 the same
|
|
1570
|
+
contract as the host0 skill's publish.sh.
|
|
1571
|
+
Exit codes: 0 ok \xB7 1 error \xB7 3 waiting for the user to approve a sign-in`;
|
|
1572
|
+
}
|
|
1573
|
+
function commandUsage(name) {
|
|
1574
|
+
const spec = COMMANDS[name];
|
|
1575
|
+
return `Usage: ${spec.usage}
|
|
1576
|
+
|
|
1577
|
+
${spec.summary}.${spec.help ? `
|
|
1578
|
+
|
|
1579
|
+
${spec.help}` : ""}`;
|
|
1580
|
+
}
|
|
1581
|
+
function commandIndex(argv) {
|
|
1582
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1583
|
+
const a = argv[i];
|
|
1584
|
+
if (a === "--") return i + 1 < argv.length ? i + 1 : -1;
|
|
1585
|
+
if (!a.startsWith("-") || a === "-") return i;
|
|
1586
|
+
const name = a.replace(/^--?/, "");
|
|
1587
|
+
if (!a.includes("=") && GLOBAL_OPTIONS[name]?.type === "string") i++;
|
|
1588
|
+
}
|
|
1589
|
+
return -1;
|
|
1590
|
+
}
|
|
1591
|
+
function parse(argv, options) {
|
|
1592
|
+
try {
|
|
1593
|
+
const { values, positionals } = parseArgs({ args: argv, options, allowPositionals: true, strict: true });
|
|
1594
|
+
return { values, positionals };
|
|
1595
|
+
} catch (e) {
|
|
1596
|
+
const code = e.code;
|
|
1597
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1598
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
1599
|
+
die(`unknown option: ${/'([^']+)'/.exec(message)?.[1] ?? message}`);
|
|
1600
|
+
}
|
|
1601
|
+
if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
|
|
1602
|
+
const missing = /^Option '(-\S+?)(?:, (--\S+))?(?: <value>)?' argument missing/.exec(message);
|
|
1603
|
+
if (missing) die(`missing value for ${missing[2] ?? missing[1]}`);
|
|
1604
|
+
}
|
|
1605
|
+
die(message.charAt(0).toLowerCase() + message.slice(1));
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function waitSeconds(values, flag) {
|
|
1609
|
+
const fromFlag = typeof values.timeout === "string" ? values.timeout : typeof values.wait === "string" ? values.wait : void 0;
|
|
1610
|
+
const raw = fromFlag ?? process.env.HOST0_LOGIN_WAIT_SECONDS ?? "90";
|
|
1611
|
+
const name = typeof values.timeout === "string" ? "timeout" : typeof values.wait === "string" ? "wait" : flag;
|
|
1612
|
+
if (!/^[0-9]+$/.test(raw)) die(`invalid --${name}: ${raw} (seconds, e.g. --${name} 90)`);
|
|
1613
|
+
return Number(raw);
|
|
1614
|
+
}
|
|
1615
|
+
function need(positionals, count, max, usage) {
|
|
1616
|
+
if (positionals.length < count) die(`usage: ${usage}`);
|
|
1617
|
+
if (positionals.length > max) die(`unexpected argument: ${positionals[max]}`);
|
|
1618
|
+
}
|
|
1619
|
+
async function run(argv) {
|
|
1620
|
+
const at = commandIndex(argv);
|
|
1621
|
+
const word = at >= 0 ? argv[at] : void 0;
|
|
1622
|
+
let command;
|
|
1623
|
+
let rest = argv;
|
|
1624
|
+
if (word !== void 0) {
|
|
1625
|
+
if (COMMANDS[word]) command = word;
|
|
1626
|
+
else if (ALIASES[word]) command = ALIASES[word];
|
|
1627
|
+
else if (word === "help") {
|
|
1628
|
+
const topic = argv[at + 1];
|
|
1629
|
+
const named = topic && (COMMANDS[topic] ? topic : ALIASES[topic]);
|
|
1630
|
+
return out(named ? commandUsage(named) : mainUsage());
|
|
1631
|
+
} else if (existsSync5(word) || /[/.\\]/.test(word)) command = "deploy";
|
|
1632
|
+
else die(`unknown command: ${word} \u2014 run host0 --help`);
|
|
1633
|
+
rest = command === "deploy" && !COMMANDS[word] && !ALIASES[word] ? argv : [...argv.slice(0, at), ...argv.slice(at + 1)];
|
|
1634
|
+
}
|
|
1635
|
+
const spec = command ? COMMANDS[command] : void 0;
|
|
1636
|
+
const { values, positionals } = parse(rest, { ...GLOBAL_OPTIONS, ...spec?.options ?? {} });
|
|
1637
|
+
if (values.version) return out(VERSION);
|
|
1638
|
+
if (!command || !spec) return out(mainUsage());
|
|
1639
|
+
if (values.help) return out(commandUsage(command));
|
|
1640
|
+
const v = (k) => typeof values[k] === "string" ? values[k] : void 0;
|
|
1641
|
+
const flag = (k) => values[k] === true;
|
|
1642
|
+
const p = positionals;
|
|
1643
|
+
switch (command) {
|
|
1644
|
+
case "deploy": {
|
|
1645
|
+
need(p, 1, 1, spec.usage);
|
|
1646
|
+
const wait = waitSeconds(values, "wait");
|
|
1647
|
+
const ctx = buildContext(values);
|
|
1648
|
+
return deploy(ctx, {
|
|
1649
|
+
target: p[0],
|
|
1650
|
+
name: v("name"),
|
|
1651
|
+
slug: v("slug"),
|
|
1652
|
+
appId: v("app-id"),
|
|
1653
|
+
visibility: v("visibility"),
|
|
1654
|
+
message: v("message"),
|
|
1655
|
+
noLogin: flag("no-login"),
|
|
1656
|
+
waitSeconds: wait
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
case "pull": {
|
|
1660
|
+
const byFlag = v("slug") ?? v("app-id");
|
|
1661
|
+
need(p, byFlag ? 0 : 1, byFlag ? 1 : 2, spec.usage);
|
|
1662
|
+
const wait = waitSeconds(values, "wait");
|
|
1663
|
+
const ctx = buildContext(values);
|
|
1664
|
+
return pull(ctx, {
|
|
1665
|
+
app: byFlag ?? p[0],
|
|
1666
|
+
dir: byFlag ? p[0] : p[1],
|
|
1667
|
+
noLogin: flag("no-login"),
|
|
1668
|
+
waitSeconds: wait
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
case "login": {
|
|
1672
|
+
need(p, 0, 0, spec.usage);
|
|
1673
|
+
const wait = waitSeconds(values, "timeout");
|
|
1674
|
+
return login(buildContext(values), { wait: flag("wait"), waitSeconds: wait });
|
|
1675
|
+
}
|
|
1676
|
+
case "logout":
|
|
1677
|
+
need(p, 0, 0, spec.usage);
|
|
1678
|
+
return logout(buildContext(values));
|
|
1679
|
+
case "whoami":
|
|
1680
|
+
need(p, 0, 0, spec.usage);
|
|
1681
|
+
return whoami(buildContext(values));
|
|
1682
|
+
case "ls":
|
|
1683
|
+
need(p, 0, 0, spec.usage);
|
|
1684
|
+
return ls(buildContext(values));
|
|
1685
|
+
case "inspect":
|
|
1686
|
+
need(p, 1, 1, spec.usage);
|
|
1687
|
+
return inspect(buildContext(values), p[0]);
|
|
1688
|
+
case "versions":
|
|
1689
|
+
need(p, 1, 1, spec.usage);
|
|
1690
|
+
return versions(buildContext(values), p[0]);
|
|
1691
|
+
case "rollback":
|
|
1692
|
+
need(p, 2, 2, spec.usage);
|
|
1693
|
+
return rollback(buildContext(values), p[0], p[1], v("message"));
|
|
1694
|
+
case "visibility":
|
|
1695
|
+
need(p, 2, 2, spec.usage);
|
|
1696
|
+
return visibility(buildContext(values), p[0], p[1]);
|
|
1697
|
+
case "rename":
|
|
1698
|
+
need(p, 1, 1, spec.usage);
|
|
1699
|
+
return rename(buildContext(values), p[0], { slug: v("slug"), name: v("name") });
|
|
1700
|
+
case "share":
|
|
1701
|
+
need(p, 2, Infinity, spec.usage);
|
|
1702
|
+
return share(buildContext(values), p[0], p.slice(1));
|
|
1703
|
+
case "unshare":
|
|
1704
|
+
need(p, 2, 2, spec.usage);
|
|
1705
|
+
return unshare(buildContext(values), p[0], p[1]);
|
|
1706
|
+
case "rm":
|
|
1707
|
+
need(p, 1, 1, spec.usage);
|
|
1708
|
+
return rm(buildContext(values), p[0], flag("yes"));
|
|
1709
|
+
case "analytics":
|
|
1710
|
+
need(p, 1, 1, spec.usage);
|
|
1711
|
+
return analytics(buildContext(values), p[0], v("period"));
|
|
1712
|
+
case "records":
|
|
1713
|
+
need(p, 1, 2, spec.usage);
|
|
1714
|
+
return records(buildContext(values), p[0], p[1], flag("export"));
|
|
1715
|
+
case "api":
|
|
1716
|
+
need(p, 1, 1, spec.usage);
|
|
1717
|
+
return apiCommand(buildContext(values), p[0], v("method"), v("data"));
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// src/main.ts
|
|
1722
|
+
var emitWarning = process.emitWarning.bind(process);
|
|
1723
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
1724
|
+
const text = typeof warning === "string" ? warning : warning?.message;
|
|
1725
|
+
if (/Fetch API is an experimental feature/.test(String(text))) return;
|
|
1726
|
+
return emitWarning(warning, ...rest);
|
|
1727
|
+
});
|
|
1728
|
+
async function main(argv) {
|
|
1729
|
+
installSignalHandlers();
|
|
1730
|
+
process.stdout.on("error", (e) => {
|
|
1731
|
+
if (e.code === "EPIPE") process.exit(0);
|
|
1732
|
+
});
|
|
1733
|
+
let code = 0;
|
|
1734
|
+
try {
|
|
1735
|
+
await run(argv);
|
|
1736
|
+
} catch (e) {
|
|
1737
|
+
code = 1;
|
|
1738
|
+
if (interruptedExitCode()) {
|
|
1739
|
+
} else if (e instanceof Exit) code = e.exitCode;
|
|
1740
|
+
else if (e instanceof CliError) {
|
|
1741
|
+
err(`error: ${e.message}`);
|
|
1742
|
+
code = e.exitCode;
|
|
1743
|
+
} else {
|
|
1744
|
+
err(`error: ${e instanceof Error ? e.message : String(e)}`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
if (interruptedExitCode()) code = interruptedExitCode();
|
|
1748
|
+
if (code !== 0) await runFailureHooks();
|
|
1749
|
+
return code;
|
|
1750
|
+
}
|
|
1751
|
+
void main(process.argv.slice(2)).then(exitProcess);
|