hiraiship 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/dist/index.js +1106 -0
  4. package/package.json +54 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dilane3
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # hiraiship
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The command-line interface for [Hiraiship](https://hiraiship.com): deploy a
4
+ Vite, Rasengan.js, Hono or Futon app and other to its own subdomain.
5
+
6
+ ```bash
7
+ npx hiraiship login
8
+ npx hiraiship deploy
9
+ ```
10
+
11
+ Or install it once:
12
+
13
+ ```bash
14
+ npm install -g hiraiship
15
+ ```
16
+
17
+ ## Commands
18
+
19
+ | Command | What it does |
20
+ | ------------------------ | -------------------------------------------------------- |
21
+ | `hiraiship login` | Log in through the dashboard with a one-time device code |
22
+ | `hiraiship logout` | Revoke and forget the stored login |
23
+ | `hiraiship whoami` | Show the logged-in account and its workspaces |
24
+ | `hiraiship link [dir]` | Link a directory to a project without deploying |
25
+ | `hiraiship deploy [dir]` | Upload a directory, build it and publish it |
26
+
27
+ In CI, set `HIRAISHIP_TOKEN` to a workspace API key instead of logging in.
28
+ `hiraiship <command> --help` lists every option.
29
+
30
+ Requires Node.js 20.12 or later. Documentation: https://docs.hiraiship.com/cli
31
+
32
+ ## License
33
+
34
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,1106 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/deploy.ts
7
+ import { resolve as resolve3 } from "path";
8
+ import { setTimeout as sleep } from "timers/promises";
9
+
10
+ // src/lib/errors.ts
11
+ var CliError = class extends Error {
12
+ constructor(message, exitCode = 1) {
13
+ super(message);
14
+ this.exitCode = exitCode;
15
+ this.name = "CliError";
16
+ }
17
+ exitCode;
18
+ };
19
+ var ApiError = class extends Error {
20
+ constructor(status, code, message) {
21
+ super(message);
22
+ this.status = status;
23
+ this.code = code;
24
+ this.name = "ApiError";
25
+ }
26
+ status;
27
+ code;
28
+ };
29
+
30
+ // src/lib/config.ts
31
+ import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
32
+ import { homedir } from "os";
33
+ import { dirname, join } from "path";
34
+ var DEFAULT_API_URL = "https://api.hiraiship.com";
35
+ function resolveScope(options) {
36
+ return options.scope?.trim() || process.env.HIRAISHIP_SCOPE?.trim() || null;
37
+ }
38
+ function resolveApiUrl(options) {
39
+ const raw = options.apiUrl ?? process.env.HIRAISHIP_API_URL ?? DEFAULT_API_URL;
40
+ return raw.replace(/\/+$/, "");
41
+ }
42
+ function configDir() {
43
+ if (process.platform === "win32" && process.env.APPDATA) {
44
+ return join(process.env.APPDATA, "hiraiship");
45
+ }
46
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "hiraiship");
47
+ }
48
+ function credentialsPath() {
49
+ return join(configDir(), "credentials.json");
50
+ }
51
+ async function readCredentialsFile() {
52
+ try {
53
+ const parsed = JSON.parse(await readFile(credentialsPath(), "utf8"));
54
+ if (parsed?.version === 1 && parsed.credentials && typeof parsed.credentials === "object") {
55
+ return parsed;
56
+ }
57
+ } catch {
58
+ }
59
+ return { version: 1, credentials: {} };
60
+ }
61
+ async function writeCredentialsFile(file) {
62
+ const path = credentialsPath();
63
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
64
+ await writeFile(path, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
65
+ await chmod(path, 384);
66
+ }
67
+ async function getStoredCredential(apiUrl) {
68
+ return (await readCredentialsFile()).credentials[apiUrl];
69
+ }
70
+ async function saveCredential(apiUrl, credential) {
71
+ const file = await readCredentialsFile();
72
+ file.credentials[apiUrl] = credential;
73
+ await writeCredentialsFile(file);
74
+ }
75
+ async function removeCredential(apiUrl) {
76
+ const file = await readCredentialsFile();
77
+ if (!file.credentials[apiUrl]) return false;
78
+ delete file.credentials[apiUrl];
79
+ if (Object.keys(file.credentials).length === 0) {
80
+ await rm(credentialsPath(), { force: true });
81
+ } else {
82
+ await writeCredentialsFile(file);
83
+ }
84
+ return true;
85
+ }
86
+ async function resolveToken(apiUrl, options) {
87
+ if (options.token) return { token: options.token, source: "flag" };
88
+ if (process.env.HIRAISHIP_TOKEN) return { token: process.env.HIRAISHIP_TOKEN, source: "env" };
89
+ const credential = await getStoredCredential(apiUrl);
90
+ return credential ? { token: credential.token, source: "file", credential } : null;
91
+ }
92
+
93
+ // src/lib/api.ts
94
+ var API_VERSION = "/api/v1";
95
+ async function rejectionReason(res) {
96
+ try {
97
+ const body = await res.clone().json();
98
+ return body.code?.startsWith("api_key_") && body.error ? `: ${body.error.replace(/\.$/, "")}` : "";
99
+ } catch {
100
+ return "";
101
+ }
102
+ }
103
+ var ApiClient = class {
104
+ constructor(apiUrl, auth) {
105
+ this.apiUrl = apiUrl;
106
+ this.auth = auth;
107
+ }
108
+ apiUrl;
109
+ auth;
110
+ /**
111
+ * RFC-0041: sent as `X-Hiraiship-Workspace` on every request. Only
112
+ * collection routes (`/projects`, `/workspace/*`) read it; resource
113
+ * routes (`/environments/:id/...`) find their workspace themselves.
114
+ * `null` = the caller's default workspace.
115
+ */
116
+ workspaceSlug = null;
117
+ get token() {
118
+ return this.auth;
119
+ }
120
+ /** RFC-0052: a workspace API key (`hsk_…`) belongs to one workspace, so there's no `X-Hiraiship-Workspace` to send and no login to prompt for. */
121
+ get isApiKey() {
122
+ return this.auth?.token.startsWith("hsk_") ?? false;
123
+ }
124
+ /** A raw authenticated `fetch` against `/api/v1{path}` — `deploy` uses it for the SSE stream. */
125
+ async fetch(path, init = {}) {
126
+ const headers = new Headers(init.headers);
127
+ if (this.auth) headers.set("Authorization", `Bearer ${this.auth.token}`);
128
+ if (this.workspaceSlug && !this.isApiKey && !headers.has("X-Hiraiship-Workspace")) headers.set("X-Hiraiship-Workspace", this.workspaceSlug);
129
+ if (init.body !== void 0 && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
130
+ let res;
131
+ try {
132
+ res = await fetch(`${this.apiUrl}${API_VERSION}${path}`, { ...init, headers });
133
+ } catch (error) {
134
+ throw new CliError(`Could not reach the Hiraiship API at ${this.apiUrl} (${error.cause ?? error.message}).`);
135
+ }
136
+ if (res.status === 401 && this.auth) {
137
+ if (this.auth.source === "file") await removeCredential(this.apiUrl);
138
+ throw new CliError(
139
+ this.auth.source === "file" ? "Session expired or revoked \u2014 run `hiraiship login`." : `The token from ${this.auth.source === "env" ? "HIRAISHIP_TOKEN" : "--token"} was rejected (401)${await rejectionReason(res)}.`
140
+ );
141
+ }
142
+ return res;
143
+ }
144
+ async request(method, path, body) {
145
+ const res = await this.fetch(path, { method, body: body === void 0 ? void 0 : JSON.stringify(body) });
146
+ if (!res.ok) {
147
+ const text2 = await res.text();
148
+ let parsed = null;
149
+ try {
150
+ parsed = JSON.parse(text2);
151
+ } catch {
152
+ }
153
+ throw new ApiError(
154
+ res.status,
155
+ parsed?.error ?? parsed?.code,
156
+ parsed?.error_description ?? parsed?.message ?? parsed?.error ?? (text2 || res.statusText)
157
+ );
158
+ }
159
+ if (res.status === 204) return void 0;
160
+ return await res.json();
161
+ }
162
+ get(path) {
163
+ return this.request("GET", path);
164
+ }
165
+ post(path, body) {
166
+ return this.request("POST", path, body ?? {});
167
+ }
168
+ };
169
+ async function createClient(options) {
170
+ const apiUrl = resolveApiUrl(options);
171
+ const client = new ApiClient(apiUrl, await resolveToken(apiUrl, options));
172
+ client.workspaceSlug = resolveScope(options);
173
+ return client;
174
+ }
175
+ async function createAuthenticatedClient(options) {
176
+ const client = await createClient(options);
177
+ if (!client.token) {
178
+ throw new CliError(`Not logged in to ${client.apiUrl} \u2014 run \`hiraiship login\`.`);
179
+ }
180
+ return client;
181
+ }
182
+
183
+ // src/lib/build-env.ts
184
+ import { readFile as readFile2 } from "fs/promises";
185
+ import { resolve } from "path";
186
+ import { parseEnv } from "util";
187
+ var KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
188
+ var SANDBOX_KEYS = ["PATH", "HOME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"];
189
+ var MAX_BUILD_ENV = 100;
190
+ var MAX_VALUE_LENGTH = 4096;
191
+ function checkKey(key, source) {
192
+ if (!KEY_PATTERN.test(key))
193
+ throw new CliError(
194
+ `Invalid build variable "${key}" (from ${source}): uppercase letters, digits and underscores, not starting with a digit.`
195
+ );
196
+ if (key === "HIRAISHIP" || key.startsWith("HIRAISHIP_"))
197
+ throw new CliError(`${key} (from ${source}) is reserved: HIRAISHIP_* variables are set by Hiraiship itself.`);
198
+ if (SANDBOX_KEYS.includes(key)) throw new CliError(`${key} (from ${source}) is reserved for the build machine.`);
199
+ }
200
+ function parseBuildEnvFlags(entries) {
201
+ const env = {};
202
+ for (const entry of entries) {
203
+ const separator = entry.indexOf("=");
204
+ if (separator <= 0) throw new CliError(`--build-env expects KEY=VALUE, got "${entry}".`);
205
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
206
+ }
207
+ return env;
208
+ }
209
+ async function resolveBuildEnv(dir, options) {
210
+ const env = {};
211
+ const sources = /* @__PURE__ */ new Map();
212
+ if (options.buildEnvFile) {
213
+ const path = resolve(dir, options.buildEnvFile);
214
+ let text2;
215
+ try {
216
+ text2 = await readFile2(path, "utf8");
217
+ } catch (error) {
218
+ throw new CliError(`Couldn't read --build-env-file ${options.buildEnvFile}: ${error.message}`);
219
+ }
220
+ for (const [key, value] of Object.entries(parseEnv(text2))) {
221
+ env[key] = value ?? "";
222
+ sources.set(key, options.buildEnvFile);
223
+ }
224
+ }
225
+ for (const [key, value] of Object.entries(parseBuildEnvFlags(options.buildEnv ?? []))) {
226
+ env[key] = value;
227
+ sources.set(key, "--build-env");
228
+ }
229
+ const keys = Object.keys(env);
230
+ if (keys.length === 0) return void 0;
231
+ if (keys.length > MAX_BUILD_ENV)
232
+ throw new CliError(`At most ${MAX_BUILD_ENV} build variables per deployment, got ${keys.length}.`);
233
+ for (const key of keys) {
234
+ checkKey(key, sources.get(key));
235
+ if (env[key].length > MAX_VALUE_LENGTH)
236
+ throw new CliError(`${key} is longer than ${MAX_VALUE_LENGTH} characters.`);
237
+ }
238
+ return env;
239
+ }
240
+
241
+ // src/lib/git.ts
242
+ import { execFile } from "child_process";
243
+ import { promisify } from "util";
244
+ var run = promisify(execFile);
245
+ async function git(cwd, args) {
246
+ try {
247
+ const { stdout } = await run("git", args, { cwd, timeout: 5e3 });
248
+ return stdout.trim() || void 0;
249
+ } catch {
250
+ return void 0;
251
+ }
252
+ }
253
+ function stripRemoteCredentials(remote) {
254
+ try {
255
+ const url = new URL(remote);
256
+ url.username = "";
257
+ url.password = "";
258
+ return url.toString();
259
+ } catch {
260
+ return remote;
261
+ }
262
+ }
263
+ async function readGitMetadata(dir) {
264
+ const [gitSha, commitMessage, remote] = await Promise.all([
265
+ git(dir, ["rev-parse", "HEAD"]),
266
+ git(dir, ["log", "-1", "--pretty=%s"]),
267
+ git(dir, ["remote", "get-url", "origin"])
268
+ ]);
269
+ return { gitSha, commitMessage, repoUrl: remote ? stripRemoteCredentials(remote) : void 0 };
270
+ }
271
+
272
+ // src/lib/pack.ts
273
+ import { lstat, readFile as readFile3, readdir, realpath } from "fs/promises";
274
+ import { join as join2, relative, sep } from "path";
275
+ import ignore from "ignore";
276
+ import { create as createTar } from "tar";
277
+ var MAX_TARBALL_BYTES = 25 * 1024 * 1024;
278
+ var ALWAYS_EXCLUDED = [".git/", "node_modules/", ".hiraiship/", ".env", ".env.*", "!.env.example"];
279
+ async function readIgnoreFile(path) {
280
+ try {
281
+ return await readFile3(path, "utf8");
282
+ } catch {
283
+ return "";
284
+ }
285
+ }
286
+ async function collectFiles(root) {
287
+ const always = ignore().add(ALWAYS_EXCLUDED);
288
+ const user = ignore().add(await readIgnoreFile(join2(root, ".gitignore"))).add(await readIgnoreFile(join2(root, ".hiraishipignore")));
289
+ const isIgnored = (path) => always.ignores(path) || user.ignores(path);
290
+ const realRoot = await realpath(root);
291
+ const files = [];
292
+ const sizes = /* @__PURE__ */ new Map();
293
+ const walk = async (dir) => {
294
+ const entries = await readdir(dir, { withFileTypes: true });
295
+ for (const entry of entries) {
296
+ const abs = join2(dir, entry.name);
297
+ const rel = relative(root, abs).split(sep).join("/");
298
+ if (entry.isSymbolicLink()) {
299
+ let target;
300
+ try {
301
+ target = await realpath(abs);
302
+ } catch {
303
+ continue;
304
+ }
305
+ if (target !== realRoot && !target.startsWith(realRoot + sep)) continue;
306
+ const stat = await lstat(target);
307
+ if (isIgnored(stat.isDirectory() ? `${rel}/` : rel)) continue;
308
+ files.push(rel);
309
+ continue;
310
+ }
311
+ if (entry.isDirectory()) {
312
+ if (!isIgnored(`${rel}/`)) await walk(abs);
313
+ } else if (entry.isFile() && !isIgnored(rel)) {
314
+ files.push(rel);
315
+ sizes.set(rel, (await lstat(abs)).size);
316
+ }
317
+ }
318
+ };
319
+ await walk(root);
320
+ return { files: files.sort(), sizes };
321
+ }
322
+ async function packDirectory(root) {
323
+ const { files, sizes } = await collectFiles(root);
324
+ if (files.length === 0) {
325
+ throw new Error("Nothing to deploy \u2014 every file in this directory is ignored.");
326
+ }
327
+ const chunks = [];
328
+ const stream = createTar({ cwd: root, gzip: true, portable: true, noDirRecurse: true }, files);
329
+ for await (const chunk of stream) chunks.push(chunk);
330
+ const largest = [...sizes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, size]) => ({ path, size }));
331
+ return { tarball: Buffer.concat(chunks), fileCount: files.length, largest };
332
+ }
333
+
334
+ // src/lib/sse.ts
335
+ async function* readSse(body) {
336
+ const decoder = new TextDecoder();
337
+ let buffer = "";
338
+ let event = "message";
339
+ let data = [];
340
+ let lastEventId = "";
341
+ for await (const chunk of body) {
342
+ buffer += decoder.decode(chunk, { stream: true });
343
+ let newline;
344
+ while ((newline = buffer.indexOf("\n")) !== -1) {
345
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
346
+ buffer = buffer.slice(newline + 1);
347
+ if (line === "") {
348
+ if (data.length > 0) yield { event, data: data.join("\n"), lastEventId };
349
+ event = "message";
350
+ data = [];
351
+ } else if (line.startsWith("event:")) {
352
+ event = line.slice(6).trim();
353
+ } else if (line.startsWith("data:")) {
354
+ data.push(line.slice(5).replace(/^ /, ""));
355
+ } else if (line.startsWith("id:")) {
356
+ const id = line.slice(3).replace(/^ /, "");
357
+ if (!id.includes("\0")) lastEventId = id;
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // src/lib/ui.ts
364
+ import { styleText } from "util";
365
+ var paint = (style) => (text2) => styleText(style, text2);
366
+ var dim = paint("dim");
367
+ var bold = paint("bold");
368
+ var green = paint("green");
369
+ var red = paint("red");
370
+ var yellow = paint("yellow");
371
+ var cyan = paint("cyan");
372
+ var ok = (message) => console.log(`${green("\u2714")} ${message}`);
373
+ var fail = (message) => console.error(`${red("\u2716")} ${message}`);
374
+ var info = (message) => console.log(`${cyan("\u25C6")} ${message}`);
375
+ function formatBytes(bytes) {
376
+ if (bytes < 1024) return `${bytes} B`;
377
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
378
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
379
+ }
380
+ function formatDuration(ms) {
381
+ return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
382
+ }
383
+
384
+ // src/commands/link.ts
385
+ import { readFile as readFile5 } from "fs/promises";
386
+ import { basename, join as join4, resolve as resolve2 } from "path";
387
+ import * as p2 from "@clack/prompts";
388
+
389
+ // src/lib/project-link.ts
390
+ import { appendFile, mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
391
+ import { join as join3 } from "path";
392
+ var LINK_DIR = ".hiraiship";
393
+ async function readProjectLink(dir) {
394
+ try {
395
+ const link2 = JSON.parse(await readFile4(join3(dir, LINK_DIR, "project.json"), "utf8"));
396
+ return link2.projectId && link2.environmentId ? link2 : void 0;
397
+ } catch {
398
+ return void 0;
399
+ }
400
+ }
401
+ async function writeProjectLink(dir, link2) {
402
+ await mkdir2(join3(dir, LINK_DIR), { recursive: true });
403
+ await writeFile2(join3(dir, LINK_DIR, "project.json"), JSON.stringify(link2, null, 2) + "\n");
404
+ await ensureGitignored(dir);
405
+ }
406
+ async function ensureGitignored(dir) {
407
+ const path = join3(dir, ".gitignore");
408
+ let current = "";
409
+ try {
410
+ current = await readFile4(path, "utf8");
411
+ } catch {
412
+ }
413
+ const lines = current.split(/\r?\n/).map((line) => line.trim());
414
+ if (lines.includes(LINK_DIR) || lines.includes(`${LINK_DIR}/`) || lines.includes(`/${LINK_DIR}`)) return;
415
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
416
+ await appendFile(path, `${prefix}${LINK_DIR}
417
+ `);
418
+ }
419
+
420
+ // src/lib/workspaces.ts
421
+ import * as p from "@clack/prompts";
422
+ async function listWorkspaces(client) {
423
+ try {
424
+ return await client.get("/workspaces");
425
+ } catch (error) {
426
+ if (!(error instanceof ApiError) || error.status !== 404) throw error;
427
+ const workspace = await client.get("/workspace/me");
428
+ return [{ id: workspace.id, slug: workspace.slug, name: workspace.name, planId: workspace.planId, role: "owner", permissions: [] }];
429
+ }
430
+ }
431
+ async function chooseWorkspace(client, interactive) {
432
+ const workspaces = await listWorkspaces(client);
433
+ if (client.workspaceSlug) {
434
+ const scoped = workspaces.find((w) => w.slug === client.workspaceSlug);
435
+ if (!scoped) {
436
+ throw new CliError(`You're not a member of a workspace "${client.workspaceSlug}". Yours: ${workspaces.map((w) => w.slug).join(", ")}.`);
437
+ }
438
+ return scoped;
439
+ }
440
+ let chosen = workspaces[0];
441
+ if (workspaces.length > 1 && interactive) {
442
+ const slug = await p.select({
443
+ message: "Which workspace?",
444
+ initialValue: chosen.slug,
445
+ options: workspaces.map((w) => ({ value: w.slug, label: w.name, hint: `${w.slug} \xB7 ${w.role}` }))
446
+ });
447
+ if (p.isCancel(slug)) {
448
+ p.cancel("Cancelled.");
449
+ throw new CliError("Cancelled.", 130);
450
+ }
451
+ chosen = workspaces.find((w) => w.slug === slug);
452
+ }
453
+ client.workspaceSlug = chosen.slug;
454
+ return chosen;
455
+ }
456
+
457
+ // src/commands/link.ts
458
+ function slugify(value) {
459
+ return value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
460
+ }
461
+ async function defaultProjectName(dir) {
462
+ try {
463
+ const pkg = JSON.parse(await readFile5(join4(dir, "package.json"), "utf8"));
464
+ if (pkg.name) return pkg.name.replace(/^@[^/]+\//, "");
465
+ } catch {
466
+ }
467
+ return basename(resolve2(dir));
468
+ }
469
+ async function getBySlug(client, slug) {
470
+ try {
471
+ return await client.get(`/projects/by-slug/${encodeURIComponent(slug)}`);
472
+ } catch (error) {
473
+ if (error instanceof ApiError && (error.status === 404 || error.status === 400)) {
474
+ const where = client.workspaceSlug ? `the workspace "${client.workspaceSlug}"` : "your default workspace";
475
+ throw new CliError(`No project with slug "${slug}" in ${where}. Pass --scope <workspace> if it's in another one.`);
476
+ }
477
+ throw error;
478
+ }
479
+ }
480
+ function cancelled(value) {
481
+ if (p2.isCancel(value)) {
482
+ p2.cancel("Cancelled.");
483
+ return true;
484
+ }
485
+ return false;
486
+ }
487
+ async function promptForProject(client, dir) {
488
+ const { items } = await client.get("/projects?limit=50");
489
+ let choice = "new";
490
+ if (items.length > 0) {
491
+ choice = await p2.select({
492
+ message: "Link this directory to which project?",
493
+ options: [
494
+ { value: "new", label: "Create a new project" },
495
+ ...items.map((project) => ({ value: project.slug, label: project.name, hint: project.slug }))
496
+ ]
497
+ });
498
+ if (cancelled(choice)) throw new CliError("Cancelled.", 130);
499
+ }
500
+ if (choice !== "new") return getBySlug(client, choice);
501
+ const name = await p2.text({
502
+ message: "Project name",
503
+ initialValue: await defaultProjectName(dir),
504
+ validate: (value) => value?.trim() ? void 0 : "Required"
505
+ });
506
+ if (cancelled(name)) throw new CliError("Cancelled.", 130);
507
+ let slug = slugify(name);
508
+ for (; ; ) {
509
+ const input = await p2.text({
510
+ message: "Project slug (becomes its subdomain)",
511
+ initialValue: slug,
512
+ validate: (value) => /^[a-z0-9-]+$/.test(value ?? "") ? void 0 : "Lowercase letters, digits and hyphens only"
513
+ });
514
+ if (cancelled(input)) throw new CliError("Cancelled.", 130);
515
+ slug = input;
516
+ const { available } = await client.get(`/projects/slug-available?slug=${encodeURIComponent(slug)}`);
517
+ if (available) break;
518
+ p2.log.warn(`"${slug}" is already taken \u2014 pick another.`);
519
+ }
520
+ try {
521
+ return await client.post("/projects", { name: name.trim(), slug });
522
+ } catch (error) {
523
+ if (error instanceof ApiError && error.status === 429) throw new CliError(`Project not created: ${error.message}`);
524
+ throw error;
525
+ }
526
+ }
527
+ async function ensureLinked(client, dir, options) {
528
+ const existing = await readProjectLink(dir);
529
+ if (existing && !options.project) {
530
+ if (existing.apiUrl === client.apiUrl) {
531
+ if (existing.workspaceSlug) client.workspaceSlug = existing.workspaceSlug;
532
+ return existing;
533
+ }
534
+ p2.log.warn(`This directory is linked to a project on ${existing.apiUrl}, not ${client.apiUrl} \u2014 linking again.`);
535
+ }
536
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
537
+ if (!options.project && !interactive) {
538
+ throw new CliError("This directory is not linked to a project. Pass --project <slug> (no terminal to prompt in).");
539
+ }
540
+ const workspace = await chooseWorkspace(client, interactive && !options.project);
541
+ const resolved = options.project ? await getBySlug(client, options.project) : await promptForProject(client, dir);
542
+ const link2 = {
543
+ projectId: resolved.project.id,
544
+ environmentId: resolved.environment.id,
545
+ projectSlug: resolved.project.slug,
546
+ apiUrl: client.apiUrl,
547
+ workspaceSlug: workspace.slug
548
+ };
549
+ await writeProjectLink(dir, link2);
550
+ ok(`Linked to ${resolved.project.name} (${resolved.project.slug}) in ${workspace.name}`);
551
+ return link2;
552
+ }
553
+ async function link(dir, options) {
554
+ const client = await createAuthenticatedClient(options);
555
+ const existing = await readProjectLink(dir);
556
+ if (existing && existing.apiUrl === client.apiUrl && !options.project) {
557
+ ok(`Already linked to ${existing.projectSlug}${existing.workspaceSlug ? ` in ${existing.workspaceSlug}` : ""} \u2014 pass --project <slug> to change it.`);
558
+ return;
559
+ }
560
+ await ensureLinked(client, dir, options);
561
+ }
562
+
563
+ // src/commands/deploy.ts
564
+ var BUILD_MACHINES = ["standard-1", "standard-2", "standard-3", "standard-4"];
565
+ function resolveMachine(options) {
566
+ const raw = options.machine?.trim() || process.env.HIRAISHIP_MACHINE?.trim();
567
+ if (!raw) return void 0;
568
+ if (!BUILD_MACHINES.includes(raw)) {
569
+ const source = options.machine?.trim() ? "--machine" : "HIRAISHIP_MACHINE";
570
+ throw new CliError(`Unknown build machine "${raw}" (from ${source}). Valid machines: ${BUILD_MACHINES.join(", ")}.`);
571
+ }
572
+ return raw;
573
+ }
574
+ async function checkMachine(client, machine, workspaceSlug) {
575
+ let quote;
576
+ try {
577
+ quote = await client.get(`/workspace/quote?resource=build_machine&machine=${encodeURIComponent(machine)}`);
578
+ } catch (error) {
579
+ if (error instanceof ApiError && (error.status === 400 || error.status === 404)) return;
580
+ throw error;
581
+ }
582
+ if (quote.outcome !== "quota_exceeded") return;
583
+ const allowed = quote.allowedBuildMachines?.length ? `
584
+ Machines your plan allows: ${quote.allowedBuildMachines.join(", ")}.` : "";
585
+ const where = workspaceSlug ? ` in ${workspaceSlug}` : "";
586
+ throw new CliError(`Build machine ${machine} refused${where}: ${quote.error ?? "not available on this plan."}${allowed}`);
587
+ }
588
+ var STEP_LABELS = {
589
+ preparing: "Preparing",
590
+ installing: "Installing dependencies",
591
+ building: "Building",
592
+ packaging: "Packaging"
593
+ };
594
+ var FAILURE_TAIL = 20;
595
+ var MAX_RECONNECTS = 3;
596
+ var BuildRenderer = class _BuildRenderer {
597
+ constructor(verbose, publishPlanned = false, createdAt = Date.now()) {
598
+ this.verbose = verbose;
599
+ this.publishPlanned = publishPlanned;
600
+ this.createdAt = createdAt;
601
+ }
602
+ verbose;
603
+ publishPlanned;
604
+ createdAt;
605
+ steps = /* @__PURE__ */ new Map();
606
+ logs = /* @__PURE__ */ new Map();
607
+ seenLogIds = /* @__PURE__ */ new Set();
608
+ failedStep;
609
+ /** RFC-0063: the stream's last event id, sent back on a reconnection. */
610
+ lastEventId = "";
611
+ /** RFC-0050: waiting for a free build slot. RFC-0057: or a free build machine. RFC-0069: or for its machine to start (`machine`). */
612
+ queued = null;
613
+ queueTicker;
614
+ lastQueueLine;
615
+ everQueued = false;
616
+ static QUEUE_NOTE_MIN_MS = 5e3;
617
+ /** Still in the queue proper: a build waiting for its machine has left it (RFC-0069). */
618
+ get isQueued() {
619
+ return this.queued !== null && !this.queued.machine;
620
+ }
621
+ /**
622
+ * RFC-0050: `⏳ Queued · position 2 · 0:42`, redrawn in place once a
623
+ * second on a terminal, and printed again only when the position
624
+ * changes when output is piped (no cursor to rewrite a line with).
625
+ */
626
+ onStatus(event) {
627
+ const startingMachine = event.status === "pending" && event.phase === "starting_machine";
628
+ if (event.status === "queued" || startingMachine) {
629
+ this.everQueued = true;
630
+ this.queued = { position: event.queuePosition, reason: event.queueReason ?? null, machine: startingMachine };
631
+ this.drawQueue();
632
+ if (process.stdout.isTTY && !this.queueTicker) {
633
+ this.queueTicker = setInterval(() => this.drawQueue(), 1e3);
634
+ this.queueTicker.unref();
635
+ }
636
+ return;
637
+ }
638
+ this.leaveQueue();
639
+ }
640
+ queueLine() {
641
+ const waited = formatDuration(Date.now() - this.createdAt);
642
+ const position = this.queued?.position ? ` \xB7 position ${this.queued.position}` : "";
643
+ if (this.queued?.machine) return `${yellow("\u23F3")} Queued \xB7 ${waited} ${dim("\u2014 starting a build machine")}`;
644
+ const waitingFor = this.queued?.reason === "capacity" ? "a free build machine" : "a free build slot";
645
+ return `${yellow("\u23F3")} Queued${position} \xB7 ${waited} ${dim(`\u2014 waiting for ${waitingFor}`)}`;
646
+ }
647
+ drawQueue() {
648
+ const line = this.queueLine();
649
+ if (process.stdout.isTTY) {
650
+ process.stdout.write(`\r\x1B[K${line}`);
651
+ this.lastQueueLine = line;
652
+ return;
653
+ }
654
+ const position = this.queued?.position ?? null;
655
+ const key = `position ${position} ${this.queued?.reason ?? ""} ${this.queued?.machine ? "machine" : ""}`;
656
+ if (this.lastQueueLine === key) return;
657
+ this.lastQueueLine = key;
658
+ console.log(line);
659
+ }
660
+ /** The build left the queue (or was cancelled): freeze the line and say how long it waited, when that was worth noting. */
661
+ leaveQueue() {
662
+ if (!this.queued && !this.everQueued) return;
663
+ if (this.queueTicker) clearInterval(this.queueTicker);
664
+ this.queueTicker = void 0;
665
+ const waited = Date.now() - this.createdAt;
666
+ if (this.queued && process.stdout.isTTY) process.stdout.write("\r\x1B[K");
667
+ if (this.queued && waited >= _BuildRenderer.QUEUE_NOTE_MIN_MS) console.log(dim(` Waited ${formatDuration(waited)} before the build started`));
668
+ this.queued = null;
669
+ this.everQueued = false;
670
+ }
671
+ /** RFC-0049: publishing is a step of the flow, so say so up front instead of surprising the user after packaging. */
672
+ printPlan() {
673
+ const steps = [...Object.values(STEP_LABELS), ...this.publishPlanned ? ["Publish"] : []];
674
+ console.log(dim(` ${steps.join(" \u2192 ")}`));
675
+ }
676
+ onStep(event) {
677
+ if (event.status !== "pending" && this.queued?.machine) this.leaveQueue();
678
+ const previous = this.steps.get(event.step);
679
+ if (previous?.status === event.status) return;
680
+ const startedAt = event.startedAt ? Date.parse(event.startedAt) : previous?.startedAt;
681
+ this.steps.set(event.step, { status: event.status, startedAt });
682
+ const label = STEP_LABELS[event.step] ?? event.step;
683
+ const elapsed = startedAt && event.finishedAt ? dim(` (${formatDuration(Date.parse(event.finishedAt) - startedAt)})`) : "";
684
+ if (event.status === "running") {
685
+ console.log(`${yellow("\u25C7")} ${label}\u2026`);
686
+ } else if (event.status === "success") {
687
+ console.log(`${green("\u2714")} ${label}${elapsed}`);
688
+ if (event.step === "packaging") this.notePublishing();
689
+ } else if (event.status === "failed") {
690
+ console.log(`${red("\u2716")} ${label}${elapsed}`);
691
+ this.failedStep = event.step;
692
+ }
693
+ }
694
+ publishingNoted = false;
695
+ notePublishing() {
696
+ if (this.publishingNoted) return;
697
+ this.publishingNoted = true;
698
+ console.log(`${yellow("\u25C7")} Publishing\u2026`);
699
+ }
700
+ /** RFC-0030: the publish outcome, timed server-side when the live `done` event carried a duration. */
701
+ printPublish(deployment, publishDurationMs) {
702
+ if (deployment.status !== "success") return;
703
+ const elapsed = publishDurationMs !== null ? dim(` (${formatDuration(publishDurationMs)})`) : "";
704
+ if (deployment.dispatchStatus === "live") {
705
+ this.notePublishing();
706
+ console.log(`${green("\u2714")} Published${elapsed}`);
707
+ } else if (deployment.dispatchStatus === "error") {
708
+ this.notePublishing();
709
+ console.log(`${red("\u2716")} Publishing failed${elapsed}`);
710
+ } else if (this.publishingNoted) {
711
+ console.log(dim("\u2013 Publishing skipped (not configured on this control-plane)"));
712
+ }
713
+ }
714
+ /**
715
+ * The failed step's last log lines, printed with the final result
716
+ * rather than at the step's `failed` event — its logs can arrive
717
+ * after that event. Nothing to add under `--verbose`.
718
+ */
719
+ printFailureTail() {
720
+ if (this.verbose || !this.failedStep) return;
721
+ const lines = (this.logs.get(this.failedStep) ?? []).filter((line) => line.trim()).slice(-FAILURE_TAIL);
722
+ if (lines.length === 0) return;
723
+ console.log(dim(` Last lines of ${STEP_LABELS[this.failedStep].toLowerCase()}:`));
724
+ for (const line of lines) console.log(dim(` \u2502 ${line}`));
725
+ }
726
+ onLog(event) {
727
+ if (event.id !== null) {
728
+ if (this.seenLogIds.has(event.id)) return;
729
+ this.seenLogIds.add(event.id);
730
+ }
731
+ const lines = this.logs.get(event.step) ?? [];
732
+ lines.push(...event.message.replace(/\n$/, "").split("\n"));
733
+ this.logs.set(event.step, lines.slice(-FAILURE_TAIL * 5));
734
+ if (this.verbose) {
735
+ for (const line of event.message.replace(/\n$/, "").split("\n")) console.log(dim(` \u2502 ${line}`));
736
+ }
737
+ }
738
+ };
739
+ async function streamOnce(client, deploymentId, renderer) {
740
+ const headers = { Accept: "text/event-stream" };
741
+ if (renderer.lastEventId) headers["Last-Event-ID"] = renderer.lastEventId;
742
+ const res = await client.fetch(`/deployments/${deploymentId}/stream`, { headers });
743
+ if (!res.ok || !res.body) throw new Error(`stream responded ${res.status}`);
744
+ for await (const message of readSse(res.body)) {
745
+ if (message.lastEventId) renderer.lastEventId = message.lastEventId;
746
+ const data = JSON.parse(message.data);
747
+ if (message.event === "status") renderer.onStatus(data);
748
+ else if (message.event === "step") renderer.onStep(data);
749
+ else if (message.event === "log") renderer.onLog(data);
750
+ else if (message.event === "done") {
751
+ renderer.leaveQueue();
752
+ return data;
753
+ }
754
+ }
755
+ throw new Error("stream closed before the build finished");
756
+ }
757
+ function publishDurationOf(deployment) {
758
+ if (!deployment.publishStartedAt || !deployment.finishedAt) return null;
759
+ const ms = Date.parse(deployment.finishedAt) - Date.parse(deployment.publishStartedAt);
760
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
761
+ }
762
+ async function waitForTerminal(client, deploymentId) {
763
+ for (; ; ) {
764
+ const deployment = await client.get(`/deployments/${deploymentId}`);
765
+ if (deployment.status !== "queued" && deployment.status !== "pending" && deployment.dispatchStatus !== "pending") return deployment;
766
+ await sleep(3e3);
767
+ }
768
+ }
769
+ var SETTLE_TIMEOUT_MS = 3e3;
770
+ async function settleSteps(client, deployment, renderer) {
771
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
772
+ for (; ; ) {
773
+ const steps = await client.get(`/deployments/${deployment.id}/steps`);
774
+ for (const step of steps) renderer.onStep(step);
775
+ const settled = deployment.status === "success" ? steps.every((step) => step.status === "success") : steps.some((step) => step.status === "failed") && !steps.some((step) => step.status === "running");
776
+ if (settled || Date.now() >= deadline) return;
777
+ await sleep(300);
778
+ }
779
+ }
780
+ async function followDeployment(client, deploymentId, renderer) {
781
+ for (let attempt = 0; attempt <= MAX_RECONNECTS; attempt++) {
782
+ try {
783
+ const done = await streamOnce(client, deploymentId, renderer);
784
+ const deployment2 = await client.get(`/deployments/${deploymentId}`);
785
+ await settleSteps(client, deployment2, renderer);
786
+ renderer.printPublish(deployment2, done.publishDurationMs ?? publishDurationOf(deployment2));
787
+ return deployment2;
788
+ } catch (error) {
789
+ if (error instanceof CliError) throw error;
790
+ if (attempt < MAX_RECONNECTS) {
791
+ console.log(dim(` (lost the log stream: ${error.message} \u2014 reconnecting)`));
792
+ await sleep(1e3 * (attempt + 1));
793
+ }
794
+ }
795
+ }
796
+ console.log(dim(" (log stream unavailable \u2014 polling for the result instead)"));
797
+ const deployment = await waitForTerminal(client, deploymentId);
798
+ renderer.printPublish(deployment, publishDurationOf(deployment));
799
+ return deployment;
800
+ }
801
+ async function deploy(dirArg, options) {
802
+ const dir = resolve3(dirArg ?? ".");
803
+ const client = await createAuthenticatedClient(options);
804
+ const machine = resolveMachine(options);
805
+ const buildEnv = await resolveBuildEnv(dir, options);
806
+ const link2 = await ensureLinked(client, dir, options);
807
+ if (machine) await checkMachine(client, machine, link2.workspaceSlug);
808
+ let packed;
809
+ try {
810
+ packed = await packDirectory(dir);
811
+ } catch (error) {
812
+ throw new CliError(error.message);
813
+ }
814
+ if (packed.tarball.length > MAX_TARBALL_BYTES) {
815
+ const largest = packed.largest.map((file) => ` ${formatBytes(file.size).padStart(9)} ${file.path}`).join("\n");
816
+ throw new CliError(
817
+ `Upload is ${formatBytes(packed.tarball.length)}, above the ${formatBytes(MAX_TARBALL_BYTES)} limit \u2014 add large files to .hiraishipignore.
818
+ Largest files:
819
+ ${largest}`
820
+ );
821
+ }
822
+ info(`Packed ${packed.fileCount} files (${formatBytes(packed.tarball.length)})`);
823
+ const git2 = await readGitMetadata(dir);
824
+ let deployment;
825
+ try {
826
+ deployment = await client.post(`/environments/${link2.environmentId}/deployments`, {
827
+ sourceTarballBase64: packed.tarball.toString("base64"),
828
+ triggerSource: "cli",
829
+ ...git2,
830
+ installCommand: options.installCommand,
831
+ buildCommand: options.buildCommand,
832
+ outputDir: options.outputDir,
833
+ machine,
834
+ buildEnv
835
+ });
836
+ } catch (error) {
837
+ if (error instanceof ApiError && error.status === 404) {
838
+ throw new CliError(
839
+ `The linked project (${link2.projectSlug}) no longer exists, or you're no longer a member of its workspace \u2014 run \`hiraiship link --project <slug>\`.`
840
+ );
841
+ }
842
+ if (error instanceof ApiError && error.status === 403) {
843
+ throw new CliError(`Your role in ${link2.workspaceSlug ?? "this workspace"} can't deploy. Ask an owner or admin to make you a developer.`);
844
+ }
845
+ if (error instanceof ApiError && error.status === 402) throw new CliError(`Deployment refused: ${error.message}`);
846
+ if (error instanceof ApiError) throw new CliError(`Deployment rejected (${error.status}): ${error.message}`);
847
+ throw error;
848
+ }
849
+ const credential = client.token?.credential;
850
+ const dashboardLink = credential ? `${credential.dashboardUrl}/${link2.workspaceSlug ?? credential.workspaceSlug}/${link2.projectSlug}/${deployment.id}` : void 0;
851
+ const builtOn = deployment.buildMachine ?? machine;
852
+ console.log(
853
+ dim(` Deployment ${deployment.id}${git2.gitSha ? ` \xB7 ${git2.gitSha.slice(0, 7)}` : ""}${builtOn ? ` \xB7 ${builtOn}` : ""}`)
854
+ );
855
+ if (options.wait === false) {
856
+ ok(`${deployment.status === "queued" ? `Build queued, waiting for a free ${deployment.queueReason === "capacity" ? "build machine" : "slot"}${deployment.queuePosition ? ` (position ${deployment.queuePosition})` : ""}` : "Build started"}${dashboardLink ? ` \u2192 ${dashboardLink}` : ""}`);
857
+ return;
858
+ }
859
+ const renderer = new BuildRenderer(options.verbose ?? false, deployment.publishPlanned ?? false, Date.parse(deployment.createdAt));
860
+ const onSigint = () => {
861
+ const state = renderer.isQueued ? "The build stays queued and starts when it can." : "The build keeps running.";
862
+ console.log(dim(`
863
+ Detached \u2014 ${state}${dashboardLink ? ` Follow it at ${dashboardLink}` : ""}`));
864
+ process.exit(130);
865
+ };
866
+ process.once("SIGINT", onSigint);
867
+ renderer.printPlan();
868
+ if (deployment.status === "queued") renderer.onStatus({ status: "queued", queuePosition: deployment.queuePosition ?? null, queueReason: deployment.queueReason ?? null });
869
+ let final;
870
+ try {
871
+ final = await followDeployment(client, deployment.id, renderer);
872
+ } finally {
873
+ process.off("SIGINT", onSigint);
874
+ }
875
+ const inspect = dashboardLink ? dim(` Inspect: ${dashboardLink}`) : void 0;
876
+ if (final.status === "failed") {
877
+ renderer.printFailureTail();
878
+ fail(final.error === "canceled" ? "Build canceled while it was queued" : `Build failed${final.error ? `: ${final.error}` : ""}`);
879
+ if (inspect) console.log(inspect);
880
+ process.exitCode = 1;
881
+ } else if (final.dispatchStatus === "error") {
882
+ fail(`Built, but publishing failed${final.dispatchError ? `: ${final.dispatchError}` : ""}`);
883
+ if (inspect) console.log(inspect);
884
+ process.exitCode = 1;
885
+ } else if (final.dispatchStatus === "live") {
886
+ ok(`Deployed \u2192 ${bold(final.deployedUrl ?? "(no default domain)")}`);
887
+ if (inspect) console.log(inspect);
888
+ } else {
889
+ ok("Built (publishing is not configured on this control-plane)");
890
+ if (inspect) console.log(inspect);
891
+ }
892
+ }
893
+
894
+ // src/commands/login.ts
895
+ import open from "open";
896
+
897
+ // src/lib/account.ts
898
+ function fetchApiKeyIdentity(client) {
899
+ return client.get("/api-key/me");
900
+ }
901
+ async function fetchAccount(client) {
902
+ if (client.isApiKey) {
903
+ const key = await fetchApiKeyIdentity(client);
904
+ return { email: `API key "${key.name}"`, workspaceSlug: key.workspace.slug };
905
+ }
906
+ const session = await client.get("/auth/get-session");
907
+ if (!session) {
908
+ throw new CliError("This token is no longer valid (expired or revoked) \u2014 run `hiraiship login`.");
909
+ }
910
+ const workspace = await client.get("/workspace/me");
911
+ return { email: session.user.email, workspaceSlug: workspace.slug };
912
+ }
913
+
914
+ // src/lib/device-flow.ts
915
+ import { setTimeout as sleep2 } from "timers/promises";
916
+ var CLIENT_ID = "hiraiship-cli";
917
+ var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
918
+ function requestDeviceCode(client) {
919
+ return client.post("/auth/device/code", { client_id: CLIENT_ID });
920
+ }
921
+ async function pollForToken(client, code, signal) {
922
+ let intervalMs = Math.max(code.interval, 1) * 1e3;
923
+ const deadline = Date.now() + code.expires_in * 1e3;
924
+ while (Date.now() < deadline) {
925
+ await sleep2(intervalMs, void 0, { signal });
926
+ try {
927
+ const token = await client.post("/auth/device/token", {
928
+ grant_type: DEVICE_CODE_GRANT,
929
+ device_code: code.device_code,
930
+ client_id: CLIENT_ID
931
+ });
932
+ return token.access_token;
933
+ } catch (error) {
934
+ if (!(error instanceof ApiError)) throw error;
935
+ switch (error.code) {
936
+ case "authorization_pending":
937
+ continue;
938
+ case "slow_down":
939
+ intervalMs += 5e3;
940
+ continue;
941
+ case "access_denied":
942
+ throw new CliError("Login denied in the browser.");
943
+ case "expired_token":
944
+ throw new CliError("Code expired \u2014 run `hiraiship login` again.");
945
+ default:
946
+ throw new CliError(`Login failed: ${error.message}`);
947
+ }
948
+ }
949
+ }
950
+ throw new CliError("Code expired \u2014 run `hiraiship login` again.");
951
+ }
952
+
953
+ // src/commands/login.ts
954
+ function formatUserCode(code) {
955
+ return code.length > 4 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
956
+ }
957
+ async function login(options) {
958
+ const apiUrl = resolveApiUrl(options);
959
+ const client = new ApiClient(apiUrl, null);
960
+ const code = await requestDeviceCode(client);
961
+ const dashboardUrl = new URL(code.verification_uri).origin;
962
+ info(`Your one-time code: ${bold(formatUserCode(code.user_code))}`);
963
+ console.log(` Approve it at ${code.verification_uri_complete}`);
964
+ if (options.browser !== false && process.stdout.isTTY) {
965
+ try {
966
+ await open(code.verification_uri_complete);
967
+ console.log(dim(" (opened in your browser \u2014 or visit the URL above)"));
968
+ } catch {
969
+ console.log(dim(" (could not open a browser \u2014 visit the URL above)"));
970
+ }
971
+ }
972
+ console.log(dim(` Waiting for approval\u2026 (expires in ${Math.round(code.expires_in / 60)} min, Ctrl-C to cancel)`));
973
+ const controller = new AbortController();
974
+ const onSigint = () => controller.abort();
975
+ process.once("SIGINT", onSigint);
976
+ let token;
977
+ try {
978
+ token = await pollForToken(client, code, controller.signal);
979
+ } catch (error) {
980
+ if (error.name === "AbortError") throw new CliError("Login cancelled.", 130);
981
+ throw error;
982
+ } finally {
983
+ process.off("SIGINT", onSigint);
984
+ }
985
+ const account = await fetchAccount(new ApiClient(apiUrl, { token, source: "flag" }));
986
+ await saveCredential(apiUrl, { token, ...account, dashboardUrl, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
987
+ ok(`Logged in as ${bold(account.email)} (workspace ${account.workspaceSlug})`);
988
+ }
989
+
990
+ // src/commands/logout.ts
991
+ async function logout(options) {
992
+ const apiUrl = resolveApiUrl(options);
993
+ const credential = await getStoredCredential(apiUrl);
994
+ if (!credential) {
995
+ console.log(`Not logged in to ${apiUrl}.`);
996
+ return;
997
+ }
998
+ try {
999
+ await new ApiClient(apiUrl, { token: credential.token, source: "flag" }).post("/auth/sign-out");
1000
+ } catch (error) {
1001
+ console.log(dim(` (could not revoke the session server-side: ${error.message})`));
1002
+ }
1003
+ await removeCredential(apiUrl);
1004
+ ok(`Logged out from ${apiUrl}`);
1005
+ }
1006
+
1007
+ // src/commands/whoami.ts
1008
+ async function whoami(options) {
1009
+ const client = await createAuthenticatedClient(options);
1010
+ if (client.isApiKey) {
1011
+ const key = await fetchApiKeyIdentity(client);
1012
+ console.log(`${bold(`API key "${key.name}"`)} ${dim(`\xB7 ${key.role} \xB7 ${key.prefix}\u2026 \xB7 ${client.apiUrl}`)}`);
1013
+ console.log(` ${cyan("\u25CF")} ${key.workspace.name} ${dim(key.workspace.slug)}`);
1014
+ console.log(dim(key.expiresAt ? ` expires ${new Date(key.expiresAt).toLocaleDateString()}` : " never expires"));
1015
+ if (options.showToken) console.log(client.token.token);
1016
+ return;
1017
+ }
1018
+ const account = await fetchAccount(client);
1019
+ const workspaces = await listWorkspaces(client);
1020
+ console.log(`${bold(account.email)} ${dim(`\xB7 ${client.apiUrl}`)}`);
1021
+ const current = client.workspaceSlug ?? workspaces[0]?.slug;
1022
+ for (const [index, workspace] of workspaces.entries()) {
1023
+ const marker = workspace.slug === current ? cyan("\u25CF") : dim("\u25CB");
1024
+ const tags = [workspace.role, index === 0 ? "default" : null].filter(Boolean).join(", ");
1025
+ console.log(` ${marker} ${workspace.name} ${dim(`${workspace.slug} \xB7 ${tags}`)}`);
1026
+ }
1027
+ if (options.showToken) {
1028
+ console.log(client.token.token);
1029
+ }
1030
+ }
1031
+
1032
+ // src/lib/banner.ts
1033
+ import { mkdir as mkdir3, readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
1034
+ import { join as join5 } from "path";
1035
+
1036
+ // src/lib/version.ts
1037
+ var CLI_VERSION = true ? "0.1.0" : "0.0.0-dev";
1038
+
1039
+ // src/lib/banner.ts
1040
+ var CHECK_EVERY_MS = 24 * 60 * 60 * 1e3;
1041
+ var CHECK_TIMEOUT_MS = 800;
1042
+ var REGISTRY_URL = "https://registry.npmjs.org/hiraiship/latest";
1043
+ async function printBanner() {
1044
+ const latest = await latestVersion();
1045
+ const update = latest && isNewer(latest, CLI_VERSION) ? latest : null;
1046
+ const plain = ` \u26A1 hiraiship ${CLI_VERSION}${update ? ` (update available ${update})` : ""}`;
1047
+ const title = ` \u26A1 ${bold("hiraiship")} ${CLI_VERSION}${update ? ` ${dim("(update available")} ${green(update)}${dim(")")}` : ""}`;
1048
+ process.stderr.write(`
1049
+ ${title}
1050
+ ${yellow("\u2500".repeat(plain.length + 1))}
1051
+ `);
1052
+ }
1053
+ async function latestVersion() {
1054
+ if (process.env.HIRAISHIP_NO_UPDATE_CHECK || process.env.CI || CLI_VERSION.includes("-dev")) return null;
1055
+ const file = join5(configDir(), "update-check.json");
1056
+ const cached = await readFile6(file, "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
1057
+ if (cached && Date.now() - cached.checkedAt < CHECK_EVERY_MS) return cached.latest;
1058
+ try {
1059
+ const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
1060
+ if (!res.ok) return cached?.latest ?? null;
1061
+ const { version } = await res.json();
1062
+ await mkdir3(configDir(), { recursive: true });
1063
+ await writeFile3(file, JSON.stringify({ checkedAt: Date.now(), latest: version }));
1064
+ return version;
1065
+ } catch {
1066
+ return cached?.latest ?? null;
1067
+ }
1068
+ }
1069
+ function isNewer(candidate, current) {
1070
+ if (candidate.includes("-")) return false;
1071
+ const parse = (v) => v.split("-")[0].split(".").map(Number);
1072
+ const [a, b] = [parse(candidate), parse(current)];
1073
+ for (let i = 0; i < 3; i++) {
1074
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
1075
+ }
1076
+ return false;
1077
+ }
1078
+
1079
+ // src/index.ts
1080
+ var program = new Command().name("hiraiship").description("Deploy apps to Hiraiship.").version(CLI_VERSION).option("--api-url <url>", "control-plane URL (env: HIRAISHIP_API_URL)").option("--token <token>", "auth token, instead of the stored login (env: HIRAISHIP_TOKEN)").option("--scope <workspace>", "workspace slug to act in, instead of your default one (env: HIRAISHIP_SCOPE)").showHelpAfterError().hook("preAction", () => printBanner());
1081
+ var withGlobals = (command, options) => ({ ...command.optsWithGlobals(), ...options });
1082
+ program.command("login").description("Log in through the dashboard with a one-time device code").option("--no-browser", "print the approval URL without opening a browser").action((options, command) => login(withGlobals(command, options)));
1083
+ program.command("logout").description("Revoke and forget the stored login for this control-plane").action((options, command) => logout(withGlobals(command, options)));
1084
+ program.command("whoami").description("Show the logged-in account and its workspaces").option("--show-token", "also print the token (for HIRAISHIP_TOKEN in CI)").action((options, command) => whoami(withGlobals(command, options)));
1085
+ program.command("link").description("Link a directory to a project without deploying").argument("[dir]", "directory to link", ".").option("--project <slug>", "link to this existing project instead of prompting").action((dir, options, command) => link(dir, withGlobals(command, options)));
1086
+ program.command("deploy").description("Upload a directory, build it and publish it").argument("[dir]", "directory to deploy", ".").option("--project <slug>", "deploy to this existing project (links the directory to it)").option("--no-wait", "return once the build is queued instead of following it").option("--verbose", "print every build log line").option("--install-command <command>", "override the install command for this deployment").option("--build-command <command>", "override the build command for this deployment").option("--output-dir <dir>", "override the output directory for this deployment").option(
1087
+ "--machine <machine>",
1088
+ "build machine for this deployment: standard-1, standard-2, standard-3 or standard-4 (env: HIRAISHIP_MACHINE)"
1089
+ ).option(
1090
+ "--build-env <KEY=VALUE>",
1091
+ "build variable for this deployment only, over the project's own (repeatable)",
1092
+ (entry, previous) => [...previous, entry],
1093
+ []
1094
+ ).option("--build-env-file <path>", "read build variables for this deployment from a dotenv file (--build-env wins)").action((dir, options, command) => deploy(dir, withGlobals(command, options)));
1095
+ program.parseAsync().catch((error) => {
1096
+ if (error instanceof CliError) {
1097
+ fail(error.message);
1098
+ process.exit(error.exitCode);
1099
+ }
1100
+ if (error instanceof ApiError) {
1101
+ fail(`API error (${error.status}): ${error.message}`);
1102
+ process.exit(1);
1103
+ }
1104
+ fail(error?.stack ?? String(error));
1105
+ process.exit(1);
1106
+ });
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "hiraiship",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The Hiraiship CLI: log in, then deploy a directory to Hiraiship.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "hiraiship",
8
+ "deploy",
9
+ "cli",
10
+ "cloudflare",
11
+ "workers",
12
+ "vite",
13
+ "hono"
14
+ ],
15
+ "homepage": "https://docs.hiraiship.com/cli",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/dilane3/hiraiship.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": "https://github.com/dilane3/hiraiship/issues",
22
+ "type": "module",
23
+ "bin": {
24
+ "hiraiship": "dist/index.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20.12"
33
+ },
34
+ "dependencies": {
35
+ "@clack/prompts": "^1.8.1",
36
+ "commander": "^15.0.0",
37
+ "ignore": "^7.0.10",
38
+ "open": "^11.0.4",
39
+ "tar": "^7.5.22"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^20.8.6",
43
+ "tsup": "^8.5.1",
44
+ "tsx": "^4.19.0",
45
+ "typescript": "^5.9.3",
46
+ "@hiraiship/types": "0.0.0"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "dev": "tsup --watch",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "tsx --test 'src/**/*.test.ts'"
56
+ }
6
57
  }