ship-em 0.1.0 → 0.2.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/dist/index.js +2552 -696
  2. package/dist/lib.d.ts +252 -0
  3. package/dist/lib.js +1923 -0
  4. package/package.json +23 -4
package/dist/index.js CHANGED
@@ -9,14 +9,15 @@ import chalk4 from "chalk";
9
9
  import axios2 from "axios";
10
10
  import FormData2 from "form-data";
11
11
  import { createReadStream } from "fs";
12
- import { rmSync, existsSync as existsSync5, appendFileSync, writeFileSync as writeFileSync2, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
13
- import { join as join6 } from "path";
12
+ import { rmSync, existsSync as existsSync6, appendFileSync, writeFileSync as writeFileSync4, readFileSync as readFileSync7, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
13
+ import { join as join8 } from "path";
14
14
  import { tmpdir } from "os";
15
15
  import { create as tarCreate } from "tar";
16
16
 
17
17
  // src/ui/terminal.ts
18
18
  import chalk from "chalk";
19
19
  import ora from "ora";
20
+ import { execSync } from "child_process";
20
21
  var brand = {
21
22
  blue: chalk.hex("#3B82F6"),
22
23
  brightBlue: chalk.hex("#60A5FA"),
@@ -197,8 +198,31 @@ var ui = {
197
198
  );
198
199
  console.log("");
199
200
  },
201
+ // Copy text to clipboard (best-effort)
202
+ copyToClipboard(text) {
203
+ try {
204
+ if (process.platform === "darwin") {
205
+ execSync("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
206
+ return true;
207
+ } else if (process.platform === "linux") {
208
+ try {
209
+ execSync("xclip -selection clipboard", { input: text, stdio: ["pipe", "ignore", "ignore"] });
210
+ return true;
211
+ } catch {
212
+ try {
213
+ execSync("xsel --clipboard --input", { input: text, stdio: ["pipe", "ignore", "ignore"] });
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ }
220
+ } catch {
221
+ }
222
+ return false;
223
+ },
200
224
  // Deploy celebration box — shown on success
201
- deployBox(appName, url, elapsedSec, fileCount, totalBytes) {
225
+ deployBox(appName, url, elapsedSec, fileCount, totalBytes, isAnonymous = false) {
202
226
  const statsLine = `Deployed in ${elapsedSec.toFixed(1)}s \xB7 ${fileCount} files \xB7 ${ui.formatBytes(totalBytes)}`;
203
227
  const content = [
204
228
  ["\u{1F680} Shipped!", brand.green.bold("\u{1F680} Shipped!")],
@@ -229,14 +253,25 @@ var ui = {
229
253
  }
230
254
  console.log(` ${brand.blue("\u2502")}${" ".repeat(innerWidth)}${brand.blue("\u2502")}`);
231
255
  console.log(` ${brand.blue("\u2570" + "\u2500".repeat(innerWidth) + "\u256F")}`);
256
+ const copied = ui.copyToClipboard(url);
257
+ console.log("");
258
+ console.log(` ${brand.bold("Share it:")}`);
259
+ const tweetText = encodeURIComponent(`I just shipped ${appName} live in ${elapsedSec.toFixed(0)}s with @shipem_dev
260
+
261
+ ${url}`);
262
+ console.log(` ${brand.gray("Twitter")} \u2192 ${brand.brightBlue.underline(`https://twitter.com/intent/tweet?text=${tweetText}`)}`);
263
+ console.log(` ${brand.gray("Copy URL")} \u2192 ${copied ? brand.green("Copied to clipboard!") : brand.dim(url)}`);
232
264
  console.log("");
265
+ if (isAnonymous) {
266
+ console.log(` ${brand.bold("Keep it:")} Run ${chalk.cyan("shipem login")} to claim this deploy`);
267
+ console.log(` and get a custom domain.`);
268
+ console.log("");
269
+ }
233
270
  console.log(` ${brand.bold("Next steps:")}`);
234
271
  console.log(` ${brand.gray("Update")} \u2192 ${chalk.cyan("npx shipem")}`);
235
272
  console.log(` ${brand.gray("Status")} \u2192 ${chalk.cyan("npx shipem status")}`);
236
273
  console.log(` ${brand.gray("Remove")} \u2192 ${chalk.cyan("npx shipem down")}`);
237
274
  console.log("");
238
- console.log(` ${brand.dim("Tip: Share your app! Just send the URL.")}`);
239
- console.log("");
240
275
  },
241
276
  // Format bytes
242
277
  formatBytes(bytes) {
@@ -244,6 +279,97 @@ var ui = {
244
279
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
245
280
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
246
281
  },
282
+ // Progress bar
283
+ progressBar(current, total, width = 30) {
284
+ const ratio = Math.min(current / total, 1);
285
+ const filled = Math.round(width * ratio);
286
+ const empty = width - filled;
287
+ const bar = brand.blue("\u2588".repeat(filled)) + brand.gray("\u2591".repeat(empty));
288
+ const pct = Math.round(ratio * 100);
289
+ return `${bar} ${pct}%`;
290
+ },
291
+ // Phase timing display
292
+ phaseTimeline(phases) {
293
+ const parts = [];
294
+ for (const phase of phases) {
295
+ const sec = (phase.durationMs / 1e3).toFixed(1);
296
+ parts.push(`${phase.name} (${sec}s)`);
297
+ }
298
+ console.log(` ${brand.dim(parts.join(" \u2192 "))}`);
299
+ },
300
+ // Deploy celebration box — enhanced with timing
301
+ deployBoxEnhanced(appName, url, phases, fileCount, totalBytes, totalElapsedSec, isAnonymous = false) {
302
+ const statsLine = `Deployed in ${totalElapsedSec.toFixed(1)}s \xB7 ${fileCount} files \xB7 ${ui.formatBytes(totalBytes)}`;
303
+ const content = [
304
+ ["\u{1F680} Shipped!", brand.green.bold("\u{1F680} Shipped!")],
305
+ null,
306
+ [`${appName} is live`, `${brand.white.bold(appName)} is live`],
307
+ null,
308
+ [`\u2192 ${url}`, `${brand.blue("\u2192")} ${brand.brightBlue.underline(url)}`],
309
+ null,
310
+ [statsLine, brand.dim(statsLine)]
311
+ ];
312
+ const maxPlain = Math.max(
313
+ ...content.filter((item) => item !== null).map(([p]) => visibleLen(p))
314
+ );
315
+ const innerWidth = maxPlain + 6;
316
+ console.log("");
317
+ console.log(` ${brand.blue("\u256D" + "\u2500".repeat(innerWidth) + "\u256E")}`);
318
+ console.log(` ${brand.blue("\u2502")}${" ".repeat(innerWidth)}${brand.blue("\u2502")}`);
319
+ for (const item of content) {
320
+ if (!item) {
321
+ console.log(` ${brand.blue("\u2502")}${" ".repeat(innerWidth)}${brand.blue("\u2502")}`);
322
+ } else {
323
+ const [plain, colored] = item;
324
+ const rightPad = innerWidth - 3 - visibleLen(plain);
325
+ console.log(
326
+ ` ${brand.blue("\u2502")} ${colored}${" ".repeat(Math.max(0, rightPad))}${brand.blue("\u2502")}`
327
+ );
328
+ }
329
+ }
330
+ console.log(` ${brand.blue("\u2502")}${" ".repeat(innerWidth)}${brand.blue("\u2502")}`);
331
+ console.log(` ${brand.blue("\u2570" + "\u2500".repeat(innerWidth) + "\u256F")}`);
332
+ if (phases.length > 0) {
333
+ console.log("");
334
+ ui.phaseTimeline(phases);
335
+ }
336
+ const copied = ui.copyToClipboard(url);
337
+ console.log("");
338
+ console.log(` ${brand.bold("Share it:")}`);
339
+ const tweetText = encodeURIComponent(`I just shipped ${appName} live in ${totalElapsedSec.toFixed(0)}s with @shipem_dev
340
+
341
+ ${url}`);
342
+ console.log(` ${brand.gray("Twitter")} \u2192 ${brand.brightBlue.underline(`https://twitter.com/intent/tweet?text=${tweetText}`)}`);
343
+ console.log(` ${brand.gray("Copy URL")} \u2192 ${copied ? brand.green("Copied to clipboard!") : brand.dim(url)}`);
344
+ console.log("");
345
+ if (isAnonymous) {
346
+ console.log(` ${brand.bold("Keep it:")} Run ${chalk.cyan("shipem login")} to claim this deploy`);
347
+ console.log(` and get a custom domain.`);
348
+ console.log("");
349
+ }
350
+ console.log(` ${brand.bold("Next steps:")}`);
351
+ console.log(` ${brand.gray("Update")} \u2192 ${chalk.cyan("npx shipem")}`);
352
+ console.log(` ${brand.gray("Status")} \u2192 ${chalk.cyan("npx shipem status")}`);
353
+ console.log(` ${brand.gray("Remove")} \u2192 ${chalk.cyan("npx shipem down")}`);
354
+ console.log("");
355
+ },
356
+ // Rich status display for deploy history
357
+ deployHistory(deploys) {
358
+ if (deploys.length === 0) return;
359
+ console.log(` ${brand.bold("Recent deploys:")}`);
360
+ const show = deploys.slice(0, 5);
361
+ for (let i = 0; i < show.length; i++) {
362
+ const d = show[i];
363
+ const date = new Date(d.timestamp);
364
+ const dateStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
365
+ const timeStr = date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
366
+ const connector = i === show.length - 1 ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500";
367
+ console.log(
368
+ ` ${brand.gray(connector)} ${dateStr}, ${timeStr} ${brand.green("\u2713")} ${d.duration.toFixed(1)}s ${d.files} files ${d.size}`
369
+ );
370
+ }
371
+ console.log("");
372
+ },
247
373
  // Colorize framework name
248
374
  framework(name) {
249
375
  const colors = {
@@ -256,14 +382,28 @@ var ui = {
256
382
  remix: "#818cf8",
257
383
  nuxt: "#00DC82",
258
384
  gatsby: "#663399",
259
- flask: "#22c55e",
260
- fastapi: "#009688",
261
- django: "#22c55e",
262
- express: "#fbbf24",
263
385
  hono: "#E36002"
264
386
  };
265
387
  const color = colors[name] ?? "#3B82F6";
266
388
  return chalk.hex(color).bold(name);
389
+ },
390
+ // Framework icon
391
+ frameworkIcon(name) {
392
+ const icons = {
393
+ nextjs: "\u25B2",
394
+ "vite-react": "\u269B",
395
+ "vite-vue": "\u{1F7E2}",
396
+ "vite-svelte": "\u{1F525}",
397
+ astro: "\u{1F680}",
398
+ sveltekit: "\u{1F525}",
399
+ remix: "\u{1F4BF}",
400
+ nuxt: "\u{1F49A}",
401
+ gatsby: "\u{1F7E3}",
402
+ hono: "\u{1F525}",
403
+ "create-react-app": "\u269B",
404
+ "static-html": "\u{1F4C4}"
405
+ };
406
+ return icons[name] ?? "\u{1F4E6}";
267
407
  }
268
408
  };
269
409
 
@@ -358,6 +498,12 @@ function getNodeVersion(pkg, cwd) {
358
498
  return "20";
359
499
  }
360
500
  function scanProject(cwd = process.cwd()) {
501
+ const result = scanProjectInternal(cwd);
502
+ const monorepo = detectMonorepo(cwd);
503
+ if (monorepo) result.monorepo = monorepo;
504
+ return result;
505
+ }
506
+ function scanProjectInternal(cwd) {
361
507
  const files = listTopLevelFiles(cwd);
362
508
  const dirs = listTopLevelDirs(cwd);
363
509
  const allEntries = [...files, ...dirs];
@@ -534,21 +680,6 @@ function scanProject(cwd = process.cwd()) {
534
680
  files: allEntries
535
681
  };
536
682
  }
537
- if (deps["express"]) {
538
- return {
539
- framework: "express",
540
- buildCommand: pkg.scripts?.build ?? "npm run build",
541
- outputDirectory: "dist",
542
- installCommand: detectPackageManager(cwd),
543
- serverType: "server",
544
- deployTarget: "flyio",
545
- nodeVersion,
546
- envVars,
547
- projectName,
548
- confidence: 0.88,
549
- files: allEntries
550
- };
551
- }
552
683
  if (pkg.scripts?.build) {
553
684
  return {
554
685
  framework: "unknown",
@@ -565,41 +696,9 @@ function scanProject(cwd = process.cwd()) {
565
696
  };
566
697
  }
567
698
  }
568
- if (hasFile("requirements.txt") || hasFile("pyproject.toml") || hasFile("Pipfile")) {
569
- const projectName = cwd.split("/").pop() ?? "my-app";
570
- const envVars = detectEnvVarsFromFiles(cwd);
571
- let framework = "unknown";
572
- let buildCommand = "pip install -r requirements.txt";
573
- const requirements = readFile(join(cwd, "requirements.txt")) ?? "";
574
- const pyproject = readFile(join(cwd, "pyproject.toml")) ?? "";
575
- const combined = requirements + pyproject;
576
- if (combined.includes("fastapi")) {
577
- framework = "fastapi";
578
- buildCommand = "pip install -r requirements.txt";
579
- } else if (combined.includes("flask")) {
580
- framework = "flask";
581
- buildCommand = "pip install -r requirements.txt";
582
- } else if (combined.includes("django")) {
583
- framework = "django";
584
- buildCommand = "pip install -r requirements.txt && python manage.py collectstatic --noinput";
585
- }
586
- return {
587
- framework,
588
- buildCommand,
589
- outputDirectory: ".",
590
- installCommand: "pip install -r requirements.txt",
591
- serverType: "server",
592
- deployTarget: "flyio",
593
- pythonVersion: detectPythonVersion(cwd),
594
- envVars,
595
- projectName,
596
- confidence: 0.85,
597
- files: allEntries
598
- };
599
- }
600
699
  if (hasFile("index.html")) {
601
700
  const projectName = cwd.split("/").pop() ?? "my-app";
602
- return {
701
+ const result = {
603
702
  framework: "static-html",
604
703
  buildCommand: "",
605
704
  outputDirectory: ".",
@@ -611,6 +710,7 @@ function scanProject(cwd = process.cwd()) {
611
710
  confidence: 0.9,
612
711
  files: allEntries
613
712
  };
713
+ return result;
614
714
  }
615
715
  return buildUnknown(cwd, files);
616
716
  }
@@ -634,16 +734,6 @@ function detectPackageManager(cwd) {
634
734
  if (existsSync(join(cwd, "bun.lockb"))) return "bun install";
635
735
  return "npm install";
636
736
  }
637
- function detectPythonVersion(cwd) {
638
- const pythonVersion = readFile(join(cwd, ".python-version"));
639
- if (pythonVersion) return pythonVersion.trim();
640
- const pyproject = readFile(join(cwd, "pyproject.toml"));
641
- if (pyproject) {
642
- const match = pyproject.match(/python\s*=\s*["']([^"']+)["']/);
643
- if (match) return match[1].replace(/[^0-9.]/g, "");
644
- }
645
- return "3.11";
646
- }
647
737
  function mergeEnvVars(base, extra) {
648
738
  const seen = new Set(base.map((v) => v.name));
649
739
  const merged = [...base];
@@ -655,325 +745,57 @@ function mergeEnvVars(base, extra) {
655
745
  }
656
746
  return merged;
657
747
  }
658
- function collectFileSamples(cwd, maxFiles = 20) {
659
- const samples = {};
660
- const importantFiles = [
661
- "package.json",
662
- "tsconfig.json",
663
- "vite.config.ts",
664
- "vite.config.js",
665
- "next.config.js",
666
- "next.config.ts",
667
- "astro.config.mjs",
668
- "svelte.config.js",
669
- "nuxt.config.ts",
670
- "remix.config.js",
671
- "requirements.txt",
672
- "pyproject.toml",
673
- "Pipfile",
674
- "Dockerfile",
675
- "docker-compose.yml",
676
- ".env.example",
677
- ".env.local.example",
678
- "wrangler.toml",
679
- "fly.toml"
680
- ];
681
- for (const file of importantFiles) {
682
- const content = readFile(join(cwd, file));
683
- if (content) {
684
- samples[file] = content.slice(0, 2e3);
685
- if (Object.keys(samples).length >= maxFiles) break;
686
- }
687
- }
688
- const topLevel = readdirSync(cwd).slice(0, 30);
689
- samples["[directory listing]"] = topLevel.join("\n");
690
- return samples;
691
- }
692
-
693
- // src/detect/ai-detect.ts
694
- import Anthropic from "@anthropic-ai/sdk";
695
- import { z } from "zod";
696
-
697
- // src/config.ts
698
- import Conf from "conf";
699
- import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2 } from "fs";
700
- import { join as join2 } from "path";
701
- var SHIPEM_API_URL = process.env.SHIPEM_API_URL ?? "https://api.shipem.dev";
702
- var globalConf = new Conf({
703
- projectName: "shipem",
704
- schema: {
705
- cloudflare: {
706
- type: "object",
707
- properties: {
708
- apiToken: { type: "string" },
709
- accountId: { type: "string" }
710
- }
711
- },
712
- anthropicApiKey: { type: "string" },
713
- sessionToken: { type: "string" }
748
+ function detectMonorepo(cwd) {
749
+ const hasFile = (name) => existsSync(join(cwd, name));
750
+ if (hasFile("turbo.json")) {
751
+ const packages = findWorkspacePackages(cwd);
752
+ return { type: "turbo", packages };
714
753
  }
715
- });
716
- function getCloudflareCredentials() {
717
- const creds = globalConf.get("cloudflare");
718
- const token = process.env.CLOUDFLARE_API_TOKEN ?? creds?.apiToken;
719
- const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? creds?.accountId;
720
- if (token && accountId) {
721
- return { apiToken: token, accountId };
754
+ if (hasFile("nx.json")) {
755
+ const packages = findWorkspacePackages(cwd);
756
+ return { type: "nx", packages };
722
757
  }
723
- return null;
724
- }
725
- function getAnthropicApiKey() {
726
- return process.env.ANTHROPIC_API_KEY ?? globalConf.get("anthropicApiKey") ?? null;
727
- }
728
- function getSessionToken() {
729
- return globalConf.get("sessionToken") ?? null;
730
- }
731
- function setSessionToken(token) {
732
- globalConf.set("sessionToken", token);
733
- }
734
- function clearSessionToken() {
735
- globalConf.delete("sessionToken");
736
- }
737
- var SHIPIT_CONFIG_FILE = "shipem.json";
738
- function readProjectConfig(cwd = process.cwd()) {
739
- const configPath = join2(cwd, SHIPIT_CONFIG_FILE);
740
- if (!existsSync2(configPath)) {
741
- return {};
758
+ if (hasFile("pnpm-workspace.yaml")) {
759
+ const packages = findWorkspacePackages(cwd);
760
+ return { type: "pnpm-workspaces", packages };
742
761
  }
743
762
  try {
744
- const raw = readFileSync2(configPath, "utf-8");
745
- const config = JSON.parse(raw);
746
- warnIfConfigContainsSecrets(config, configPath);
747
- return config;
763
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
764
+ if (pkg.workspaces) {
765
+ const packages = findWorkspacePackages(cwd);
766
+ if (hasFile("yarn.lock")) {
767
+ return { type: "yarn-workspaces", packages };
768
+ }
769
+ return { type: "npm-workspaces", packages };
770
+ }
748
771
  } catch {
749
- return {};
750
- }
751
- }
752
- function writeProjectConfig(config, cwd = process.cwd()) {
753
- const configPath = join2(cwd, SHIPIT_CONFIG_FILE);
754
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
755
- }
756
- var SECRET_KEY_PATTERN = /token|secret|key|password|credential|api_?key/i;
757
- function warnIfConfigContainsSecrets(config, configPath) {
758
- const envVars = config.project?.envVars ?? [];
759
- const populated = envVars.filter((v) => v.value && SECRET_KEY_PATTERN.test(v.name));
760
- if (populated.length > 0) {
761
- const names = populated.map((v) => v.name).join(", ");
762
- process.stderr.write(
763
- `
764
- [Shipem] Warning: ${configPath} contains values for sensitive env vars (${names}).
765
- Ensure shipem.json is listed in .gitignore to avoid committing credentials.
766
-
767
- `
768
- );
769
772
  }
773
+ return void 0;
770
774
  }
771
-
772
- // src/detect/ai-detect.ts
773
- var AIDetectionSchema = z.object({
774
- framework: z.string().describe("The detected framework or stack"),
775
- frameworkId: z.enum([
776
- "nextjs",
777
- "vite-react",
778
- "vite-vue",
779
- "vite-svelte",
780
- "astro",
781
- "sveltekit",
782
- "remix",
783
- "nuxt",
784
- "gatsby",
785
- "create-react-app",
786
- "flask",
787
- "fastapi",
788
- "django",
789
- "express",
790
- "hono",
791
- "static-html",
792
- "unknown"
793
- ]),
794
- buildCommand: z.string().describe("Command to build the project"),
795
- outputDirectory: z.string().describe("Directory containing built output"),
796
- installCommand: z.string().describe("Command to install dependencies"),
797
- serverType: z.enum(["static", "serverless", "server"]),
798
- deployTarget: z.enum(["cloudflare-pages", "cloudflare-workers", "flyio"]),
799
- nodeVersion: z.string().optional(),
800
- pythonVersion: z.string().optional(),
801
- envVars: z.array(z.object({
802
- name: z.string(),
803
- description: z.string(),
804
- required: z.boolean(),
805
- autoProvision: z.boolean().optional()
806
- })),
807
- notes: z.string().describe("Brief explanation of detection decisions"),
808
- confidence: z.number().min(0).max(1)
809
- });
810
- async function aiDetectProject(fileSamples, heuristicResult) {
811
- const apiKey = getAnthropicApiKey();
812
- if (!apiKey) {
813
- return null;
814
- }
815
- const client = new Anthropic({ apiKey });
816
- const fileContent = Object.entries(fileSamples).map(([name, content]) => `### ${name}
817
- \`\`\`
818
- ${content}
819
- \`\`\``).join("\n\n");
820
- const heuristicContext = heuristicResult ? `
821
- Heuristic detection suggests: ${JSON.stringify(heuristicResult, null, 2)}
822
- ` : "";
823
- const prompt = `You are analyzing a software project to determine how to deploy it.
824
-
825
- ${heuristicContext}
826
-
827
- Here are the key project files:
828
-
829
- ${fileContent}
830
-
831
- Based on these files, determine:
832
- 1. The exact framework/stack being used
833
- 2. The correct build command
834
- 3. The output directory after build
835
- 4. Required environment variables (look carefully at the code for API keys, database URLs, etc.)
836
- 5. Whether this is a static site, serverless, or needs a persistent server
837
- 6. The best deploy target (cloudflare-pages for static/JAMstack, cloudflare-workers for edge/serverless, flyio for full-stack servers)
838
-
839
- Be thorough about environment variables - check imports, API clients, and configuration files for any external services being used.
840
-
841
- Call the analyze_project tool with your findings.`;
842
- const toolInputSchema = {
843
- type: "object",
844
- properties: {
845
- framework: { type: "string", description: "The detected framework or stack" },
846
- frameworkId: {
847
- type: "string",
848
- enum: [
849
- "nextjs",
850
- "vite-react",
851
- "vite-vue",
852
- "vite-svelte",
853
- "astro",
854
- "sveltekit",
855
- "remix",
856
- "nuxt",
857
- "gatsby",
858
- "create-react-app",
859
- "flask",
860
- "fastapi",
861
- "django",
862
- "express",
863
- "hono",
864
- "static-html",
865
- "unknown"
866
- ]
867
- },
868
- buildCommand: { type: "string", description: "Command to build the project" },
869
- outputDirectory: { type: "string", description: "Directory containing built output" },
870
- installCommand: { type: "string", description: "Command to install dependencies" },
871
- serverType: { type: "string", enum: ["static", "serverless", "server"] },
872
- deployTarget: { type: "string", enum: ["cloudflare-pages", "cloudflare-workers", "flyio"] },
873
- nodeVersion: { type: "string" },
874
- pythonVersion: { type: "string" },
875
- envVars: {
876
- type: "array",
877
- items: {
878
- type: "object",
879
- properties: {
880
- name: { type: "string" },
881
- description: { type: "string" },
882
- required: { type: "boolean" },
883
- autoProvision: { type: "boolean" }
884
- },
885
- required: ["name", "description", "required"]
886
- }
887
- },
888
- notes: { type: "string", description: "Brief explanation of detection decisions" },
889
- confidence: { type: "number", minimum: 0, maximum: 1 }
890
- },
891
- required: [
892
- "framework",
893
- "frameworkId",
894
- "buildCommand",
895
- "outputDirectory",
896
- "installCommand",
897
- "serverType",
898
- "deployTarget",
899
- "envVars",
900
- "notes",
901
- "confidence"
902
- ]
903
- };
904
- try {
905
- const response = await client.messages.create({
906
- model: "claude-sonnet-4-20250514",
907
- max_tokens: 4096,
908
- tools: [
909
- {
910
- name: "analyze_project",
911
- description: "Analyze a software project to determine its framework, build config, and deployment configuration",
912
- input_schema: toolInputSchema
775
+ function findWorkspacePackages(cwd) {
776
+ const packages = [];
777
+ const commonDirs = ["packages", "apps", "projects", "services"];
778
+ for (const dir of commonDirs) {
779
+ const dirPath = join(cwd, dir);
780
+ if (existsSync(dirPath)) {
781
+ try {
782
+ const entries = readdirSync(dirPath, { withFileTypes: true });
783
+ for (const entry of entries) {
784
+ if (entry.isDirectory() && existsSync(join(dirPath, entry.name, "package.json"))) {
785
+ packages.push(entry.name);
786
+ }
913
787
  }
914
- ],
915
- tool_choice: { type: "tool", name: "analyze_project" },
916
- messages: [{ role: "user", content: prompt }]
917
- });
918
- let toolInput = null;
919
- for (const block of response.content) {
920
- if (block.type === "tool_use" && block.name === "analyze_project") {
921
- toolInput = block.input;
922
- break;
788
+ } catch {
923
789
  }
924
790
  }
925
- if (!toolInput) {
926
- return null;
927
- }
928
- const validated = AIDetectionSchema.parse(toolInput);
929
- return {
930
- framework: validated.frameworkId,
931
- buildCommand: validated.buildCommand,
932
- outputDirectory: validated.outputDirectory,
933
- installCommand: validated.installCommand,
934
- serverType: validated.serverType,
935
- deployTarget: validated.deployTarget,
936
- nodeVersion: validated.nodeVersion,
937
- pythonVersion: validated.pythonVersion,
938
- envVars: validated.envVars,
939
- notes: validated.notes,
940
- confidence: validated.confidence
941
- };
942
- } catch (err) {
943
- return null;
944
- }
945
- }
946
- function mergeDetectionResults(heuristic, aiResult) {
947
- if (!aiResult || aiResult.confidence < 0.7) {
948
- return heuristic;
949
- }
950
- const seen = new Set(heuristic.envVars.map((v) => v.name));
951
- const mergedEnvVars = [...heuristic.envVars];
952
- for (const v of aiResult.envVars) {
953
- if (!seen.has(v.name)) {
954
- seen.add(v.name);
955
- mergedEnvVars.push(v);
956
- }
957
791
  }
958
- return {
959
- framework: aiResult.framework !== "unknown" ? aiResult.framework : heuristic.framework,
960
- buildCommand: aiResult.buildCommand || heuristic.buildCommand,
961
- outputDirectory: aiResult.outputDirectory || heuristic.outputDirectory,
962
- installCommand: aiResult.installCommand || heuristic.installCommand,
963
- serverType: aiResult.serverType,
964
- deployTarget: aiResult.deployTarget,
965
- nodeVersion: aiResult.nodeVersion ?? heuristic.nodeVersion,
966
- pythonVersion: aiResult.pythonVersion ?? heuristic.pythonVersion,
967
- envVars: mergedEnvVars,
968
- notes: aiResult.notes,
969
- confidence: Math.max(heuristic.confidence, aiResult.confidence)
970
- };
792
+ return packages;
971
793
  }
972
794
 
973
795
  // src/build/builder.ts
974
796
  import { execa } from "execa";
975
- import { existsSync as existsSync3 } from "fs";
976
- import { join as join3 } from "path";
797
+ import { existsSync as existsSync2 } from "fs";
798
+ import { join as join2 } from "path";
977
799
  import chalk2 from "chalk";
978
800
  async function buildProject(config, cwd = process.cwd()) {
979
801
  const start = Date.now();
@@ -1091,8 +913,8 @@ async function buildProject(config, cwd = process.cwd()) {
1091
913
  }
1092
914
  console.log("");
1093
915
  }
1094
- const outputPath = join3(cwd, config.outputDirectory);
1095
- if (config.buildCommand && !existsSync3(outputPath)) {
916
+ const outputPath = join2(cwd, config.outputDirectory);
917
+ if (config.buildCommand && !existsSync2(outputPath)) {
1096
918
  return {
1097
919
  success: false,
1098
920
  outputDirectory: config.outputDirectory,
@@ -1120,66 +942,178 @@ function parseErrorMessage(raw, outputLines) {
1120
942
  return lines.slice(-5).join("\n");
1121
943
  }
1122
944
 
1123
- // src/commands/login.ts
1124
- import { createServer } from "http";
1125
- import { randomBytes } from "crypto";
1126
- import open from "open";
1127
- var CALLBACK_PORT_START = 9999;
1128
- var MAX_PORT_ATTEMPTS = 10;
1129
- async function loginCommand(opts = {}) {
1130
- if (!opts.skipBanner) ui.banner();
1131
- ui.section("Logging in to Shipem...");
1132
- ui.br();
1133
- const cliState = randomBytes(16).toString("hex");
1134
- let resolveToken;
1135
- let rejectToken;
1136
- const tokenPromise = new Promise((resolve, reject) => {
1137
- resolveToken = resolve;
1138
- rejectToken = reject;
1139
- });
1140
- let hasHandled = false;
1141
- const server = createServer((req, res) => {
1142
- if (!req.url) return;
1143
- if (hasHandled) {
1144
- res.writeHead(200, { "Content-Type": "text/html" });
1145
- res.end('<html><body style="font-family:sans-serif;padding:40px">Login already completed. You can close this tab.</body></html>');
1146
- return;
1147
- }
1148
- const url = new URL(req.url, `http://127.0.0.1:${actualPort}`);
1149
- const token2 = url.searchParams.get("token");
1150
- const login = url.searchParams.get("login");
1151
- const returnedState = url.searchParams.get("state");
1152
- if (returnedState !== cliState) {
1153
- hasHandled = true;
1154
- res.writeHead(400, { "Content-Type": "text/html" });
1155
- res.end('<html><body style="font-family:sans-serif;padding:40px;color:red">State mismatch \u2014 possible CSRF. Please try logging in again.</body></html>');
1156
- rejectToken(new Error("State mismatch in OAuth callback \u2014 possible CSRF attack"));
1157
- return;
1158
- }
1159
- if (token2) {
1160
- hasHandled = true;
1161
- const html = `<!DOCTYPE html>
1162
- <html>
1163
- <head><title>Shipem \u2014 Logged in!</title>
1164
- <style>
1165
- body { font-family: -apple-system, sans-serif; text-align: center; padding: 60px; background: #0f172a; color: #e2e8f0; }
1166
- h2 { color: #22c55e; font-size: 2rem; margin-bottom: 12px; }
1167
- p { color: #94a3b8; font-size: 1.1rem; }
1168
- </style>
1169
- </head>
1170
- <body>
1171
- <h2>\u2705 Logged in${login ? ` as @${login}` : ""}!</h2>
1172
- <p>You can close this tab and return to your terminal.</p>
1173
- </body>
1174
- </html>`;
1175
- res.writeHead(200, { "Content-Type": "text/html" });
1176
- res.end(html);
1177
- resolveToken(token2);
1178
- } else {
945
+ // src/config.ts
946
+ import Conf from "conf";
947
+ import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3 } from "fs";
948
+ import { join as join3 } from "path";
949
+ var SHIPEM_API_URL = process.env.SHIPEM_API_URL ?? "https://api.shipem.dev";
950
+ var globalConf = new Conf({
951
+ projectName: "shipem",
952
+ schema: {
953
+ cloudflare: {
954
+ type: "object",
955
+ properties: {
956
+ apiToken: { type: "string" },
957
+ accountId: { type: "string" }
958
+ }
959
+ },
960
+ sessionToken: { type: "string" }
961
+ }
962
+ });
963
+ function getCloudflareCredentials() {
964
+ const creds = globalConf.get("cloudflare");
965
+ const token = process.env.CLOUDFLARE_API_TOKEN ?? creds?.apiToken;
966
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? creds?.accountId;
967
+ if (token && accountId) {
968
+ return { apiToken: token, accountId };
969
+ }
970
+ return null;
971
+ }
972
+ function getSessionToken() {
973
+ return globalConf.get("sessionToken") ?? null;
974
+ }
975
+ function setSessionToken(token) {
976
+ globalConf.set("sessionToken", token);
977
+ }
978
+ function clearSessionToken() {
979
+ globalConf.delete("sessionToken");
980
+ }
981
+ var SHIPIT_CONFIG_FILE = "shipem.json";
982
+ function readProjectConfig(cwd = process.cwd()) {
983
+ const configPath = join3(cwd, SHIPIT_CONFIG_FILE);
984
+ if (!existsSync3(configPath)) {
985
+ return {};
986
+ }
987
+ try {
988
+ const raw = readFileSync2(configPath, "utf-8");
989
+ const config = JSON.parse(raw);
990
+ warnIfConfigContainsSecrets(config, configPath);
991
+ return config;
992
+ } catch {
993
+ return {};
994
+ }
995
+ }
996
+ function writeProjectConfig(config, cwd = process.cwd()) {
997
+ const configPath = join3(cwd, SHIPIT_CONFIG_FILE);
998
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
999
+ }
1000
+ var SECRET_KEY_PATTERN = /token|secret|key|password|credential|api_?key/i;
1001
+ function warnIfConfigContainsSecrets(config, configPath) {
1002
+ const envVars = config.project?.envVars ?? [];
1003
+ const populated = envVars.filter((v) => v.value && SECRET_KEY_PATTERN.test(v.name));
1004
+ if (populated.length > 0) {
1005
+ const names = populated.map((v) => v.name).join(", ");
1006
+ process.stderr.write(
1007
+ `
1008
+ [Shipem] Warning: ${configPath} contains values for sensitive env vars (${names}).
1009
+ Ensure shipem.json is listed in .gitignore to avoid committing credentials.
1010
+
1011
+ `
1012
+ );
1013
+ }
1014
+ }
1015
+
1016
+ // src/commands/login.ts
1017
+ import { createServer } from "http";
1018
+ import { randomBytes } from "crypto";
1019
+ import open from "open";
1020
+
1021
+ // src/errors.ts
1022
+ var ShipemError = class extends Error {
1023
+ /** Machine-readable error code for programmatic consumers (--json mode). */
1024
+ code;
1025
+ /** Suggested exit code: 1 = user error, 2 = system/infra error. */
1026
+ exitCode;
1027
+ constructor(message, opts = { code: "ERR_UNKNOWN" }) {
1028
+ super(message, { cause: opts.cause });
1029
+ this.name = "ShipemError";
1030
+ this.code = opts.code;
1031
+ this.exitCode = opts.exitCode ?? 1;
1032
+ }
1033
+ };
1034
+ var NetworkError = class extends ShipemError {
1035
+ constructor(message, opts) {
1036
+ super(message, { code: "ERR_NETWORK", exitCode: 2, cause: opts?.cause });
1037
+ this.name = "NetworkError";
1038
+ }
1039
+ };
1040
+ var AuthError = class extends ShipemError {
1041
+ constructor(message, opts) {
1042
+ super(message, { code: "ERR_AUTH", exitCode: 1, cause: opts?.cause });
1043
+ this.name = "AuthError";
1044
+ }
1045
+ };
1046
+ var DeployError = class extends ShipemError {
1047
+ constructor(message, opts) {
1048
+ super(message, { code: "ERR_DEPLOY", exitCode: 2, cause: opts?.cause });
1049
+ this.name = "DeployError";
1050
+ }
1051
+ };
1052
+ var ConfigError = class extends ShipemError {
1053
+ constructor(message, opts) {
1054
+ super(message, { code: "ERR_CONFIG", exitCode: 1, cause: opts?.cause });
1055
+ this.name = "ConfigError";
1056
+ }
1057
+ };
1058
+
1059
+ // src/commands/login.ts
1060
+ var CALLBACK_PORT_START = 9999;
1061
+ var MAX_PORT_ATTEMPTS = 10;
1062
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
1063
+ async function loginCommand(opts = {}) {
1064
+ if (!opts.skipBanner) ui.banner();
1065
+ ui.section("Logging in to Shipem...");
1066
+ ui.br();
1067
+ const cliState = randomBytes(16).toString("hex");
1068
+ let resolveToken;
1069
+ let rejectToken;
1070
+ const tokenPromise = new Promise((resolve, reject) => {
1071
+ resolveToken = resolve;
1072
+ rejectToken = reject;
1073
+ });
1074
+ let hasHandled = false;
1075
+ const server = createServer((req, res) => {
1076
+ if (!req.url) return;
1077
+ if (hasHandled) {
1078
+ res.writeHead(200, { "Content-Type": "text/html" });
1079
+ res.end('<html><body style="font-family:sans-serif;padding:40px">Login already completed. You can close this tab.</body></html>');
1080
+ return;
1081
+ }
1082
+ const url = new URL(req.url, `http://127.0.0.1:${actualPort}`);
1083
+ const token2 = url.searchParams.get("token");
1084
+ const login = url.searchParams.get("login");
1085
+ const returnedState = url.searchParams.get("state");
1086
+ if (returnedState !== cliState) {
1087
+ hasHandled = true;
1088
+ res.writeHead(400, { "Content-Type": "text/html" });
1089
+ res.end('<html><body style="font-family:sans-serif;padding:40px;color:red">State mismatch \u2014 possible CSRF. Please try logging in again.</body></html>');
1090
+ rejectToken(new AuthError("State mismatch in OAuth callback \u2014 possible CSRF attack"));
1091
+ return;
1092
+ }
1093
+ if (token2) {
1094
+ hasHandled = true;
1095
+ const html = `<!DOCTYPE html>
1096
+ <html>
1097
+ <head><title>Shipem \u2014 Logged in!</title>
1098
+ <style>
1099
+ body { font-family: -apple-system, sans-serif; text-align: center; padding: 60px; background: #0f172a; color: #e2e8f0; }
1100
+ h2 { color: #22c55e; font-size: 2rem; margin-bottom: 12px; }
1101
+ p { color: #94a3b8; font-size: 1.1rem; }
1102
+ </style>
1103
+ </head>
1104
+ <body>
1105
+ <h2>\u2705 Logged in${login ? ` as @${login}` : ""}!</h2>
1106
+ <p>You can close this tab and return to your terminal.</p>
1107
+ </body>
1108
+ </html>`;
1109
+ res.writeHead(200, { "Content-Type": "text/html" });
1110
+ res.end(html);
1111
+ resolveToken(token2);
1112
+ } else {
1179
1113
  hasHandled = true;
1180
1114
  res.writeHead(400, { "Content-Type": "text/html" });
1181
1115
  res.end('<html><body style="font-family:sans-serif;padding:40px">Authentication failed. Please try again.</body></html>');
1182
- rejectToken(new Error("Authentication failed \u2014 no token received"));
1116
+ rejectToken(new AuthError("Authentication failed \u2014 no token received"));
1183
1117
  }
1184
1118
  });
1185
1119
  let actualPort = CALLBACK_PORT_START;
@@ -1201,7 +1135,7 @@ async function loginCommand(opts = {}) {
1201
1135
  }
1202
1136
  }
1203
1137
  if (!bound) {
1204
- throw new Error(`Could not bind to any port in range ${CALLBACK_PORT_START}\u2013${CALLBACK_PORT_START + MAX_PORT_ATTEMPTS - 1}. Please free up a port and try again.`);
1138
+ throw new NetworkError(`Could not bind to any port in range ${CALLBACK_PORT_START}\u2013${CALLBACK_PORT_START + MAX_PORT_ATTEMPTS - 1}. Please free up a port and try again.`);
1205
1139
  }
1206
1140
  const callbackUrl = `http://localhost:${actualPort}`;
1207
1141
  const loginUrl = `${SHIPEM_API_URL}/auth/github?cli_callback=${encodeURIComponent(callbackUrl)}&cli_state=${cliState}`;
@@ -1210,13 +1144,12 @@ async function loginCommand(opts = {}) {
1210
1144
  ui.dim(loginUrl);
1211
1145
  ui.br();
1212
1146
  await open(loginUrl);
1213
- const timeoutMs = 5 * 60 * 1e3;
1214
1147
  let token;
1215
1148
  try {
1216
1149
  token = await Promise.race([
1217
1150
  tokenPromise,
1218
1151
  new Promise(
1219
- (_, reject) => setTimeout(() => reject(new Error("Login timed out after 5 minutes. Please try again.")), timeoutMs)
1152
+ (_, reject) => setTimeout(() => reject(new AuthError("Login timed out after 5 minutes. Please try again.")), LOGIN_TIMEOUT_MS)
1220
1153
  )
1221
1154
  ]);
1222
1155
  } finally {
@@ -1228,15 +1161,339 @@ async function loginCommand(opts = {}) {
1228
1161
  ui.br();
1229
1162
  }
1230
1163
 
1164
+ // src/commands/fix.ts
1165
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, readdirSync as readdirSync2 } from "fs";
1166
+ import { join as join4 } from "path";
1167
+ import { execa as execa2 } from "execa";
1168
+ function findMissingModules(errorOutput) {
1169
+ const modules = /* @__PURE__ */ new Set();
1170
+ const cannotFind = errorOutput.matchAll(/Cannot find module ['"]([^'"]+)['"]/g);
1171
+ for (const m of cannotFind) {
1172
+ modules.add(extractPkgName(m[1]));
1173
+ }
1174
+ const moduleNotFound = errorOutput.matchAll(/Module not found.*?['"]([^'"]+)['"]/g);
1175
+ for (const m of moduleNotFound) {
1176
+ modules.add(extractPkgName(m[1]));
1177
+ }
1178
+ const cannotFindPkg = errorOutput.matchAll(/Cannot find package ['"]([^'"]+)['"]/g);
1179
+ for (const m of cannotFindPkg) {
1180
+ modules.add(extractPkgName(m[1]));
1181
+ }
1182
+ const couldNotResolve = errorOutput.matchAll(/Could not resolve ["']([^"']+)["']/g);
1183
+ for (const m of couldNotResolve) {
1184
+ if (!m[1].startsWith(".") && !m[1].startsWith("/")) {
1185
+ modules.add(extractPkgName(m[1]));
1186
+ }
1187
+ }
1188
+ return [...modules].filter((m) => m && !m.startsWith(".") && !m.startsWith("/"));
1189
+ }
1190
+ function extractPkgName(importPath) {
1191
+ if (importPath.startsWith("@")) {
1192
+ const parts = importPath.split("/");
1193
+ return parts.slice(0, 2).join("/");
1194
+ }
1195
+ return importPath.split("/")[0];
1196
+ }
1197
+ function findMissingPeerDeps(errorOutput) {
1198
+ const peers = /* @__PURE__ */ new Set();
1199
+ const peerMatches = errorOutput.matchAll(
1200
+ /(?:requires|missing) (?:a )?peer (?:dependency|dep)[^"']*['"]([^'"]+)['"]/gi
1201
+ );
1202
+ for (const m of peerMatches) {
1203
+ peers.add(extractPkgName(m[1]));
1204
+ }
1205
+ const warnMatches = errorOutput.matchAll(
1206
+ /peerDepend[^"']*['"]([^'"]+)['"].*should be/gi
1207
+ );
1208
+ for (const m of warnMatches) {
1209
+ peers.add(extractPkgName(m[1]));
1210
+ }
1211
+ return [...peers];
1212
+ }
1213
+ function detectTsConfigFixes(errorOutput, cwd) {
1214
+ const tsErrors = errorOutput.match(/TS\d+/g) ?? [];
1215
+ if (tsErrors.length === 0) return null;
1216
+ const fixes = {};
1217
+ const descriptions = [];
1218
+ if (tsErrors.includes("TS7006") || tsErrors.includes("TS7005")) {
1219
+ fixes.noImplicitAny = false;
1220
+ descriptions.push("Disable noImplicitAny");
1221
+ }
1222
+ if (tsErrors.includes("TS2307")) {
1223
+ fixes.moduleResolution = "bundler";
1224
+ descriptions.push("Set moduleResolution to bundler");
1225
+ }
1226
+ if (tsErrors.includes("TS2769") || tsErrors.includes("TS2345") || tsErrors.includes("TS2322")) {
1227
+ fixes.strict = false;
1228
+ descriptions.push("Disable strict mode");
1229
+ }
1230
+ if (tsErrors.includes("TS18028") || tsErrors.includes("TS1259") || tsErrors.includes("TS1479")) {
1231
+ fixes.esModuleInterop = true;
1232
+ fixes.allowSyntheticDefaultImports = true;
1233
+ descriptions.push("Enable ESM interop");
1234
+ }
1235
+ if (descriptions.length === 0) return null;
1236
+ return { fixes, description: descriptions.join(", ") };
1237
+ }
1238
+ function applyTsConfigFixes(cwd, fixes) {
1239
+ const tsconfigPath = join4(cwd, "tsconfig.json");
1240
+ if (!existsSync4(tsconfigPath)) return false;
1241
+ try {
1242
+ const content = readFileSync3(tsconfigPath, "utf-8");
1243
+ const stripped = content.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
1244
+ const tsconfig = JSON.parse(stripped);
1245
+ if (!tsconfig.compilerOptions) tsconfig.compilerOptions = {};
1246
+ Object.assign(tsconfig.compilerOptions, fixes);
1247
+ writeFileSync2(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n", "utf-8");
1248
+ return true;
1249
+ } catch {
1250
+ return false;
1251
+ }
1252
+ }
1253
+ function findMissingEnvVars(cwd) {
1254
+ const referenced = /* @__PURE__ */ new Set();
1255
+ const patterns = [
1256
+ /process\.env\.([A-Z_][A-Z0-9_]*)/g,
1257
+ /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g
1258
+ ];
1259
+ function scanDir(dir, depth) {
1260
+ if (depth > 4) return;
1261
+ try {
1262
+ const entries = readdirSync2(dir, { withFileTypes: true });
1263
+ for (const entry of entries) {
1264
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === ".next") continue;
1265
+ const fullPath = join4(dir, entry.name);
1266
+ if (entry.isDirectory()) {
1267
+ scanDir(fullPath, depth + 1);
1268
+ } else if (/\.(ts|tsx|js|jsx|mjs|mts|vue|svelte|astro)$/.test(entry.name)) {
1269
+ try {
1270
+ const content = readFileSync3(fullPath, "utf-8");
1271
+ for (const pat of patterns) {
1272
+ pat.lastIndex = 0;
1273
+ let match;
1274
+ while ((match = pat.exec(content)) !== null) {
1275
+ referenced.add(match[1]);
1276
+ }
1277
+ }
1278
+ } catch {
1279
+ }
1280
+ }
1281
+ }
1282
+ } catch {
1283
+ }
1284
+ }
1285
+ scanDir(cwd, 0);
1286
+ const ignoredVars = /* @__PURE__ */ new Set(["NODE_ENV", "PORT", "HOST", "CI", "HOME", "PWD", "PATH", "TERM"]);
1287
+ const missing = [];
1288
+ for (const v of referenced) {
1289
+ if (ignoredVars.has(v)) continue;
1290
+ if (!process.env[v]) missing.push(v);
1291
+ }
1292
+ return missing;
1293
+ }
1294
+ function generateEnvExample(cwd, vars) {
1295
+ if (vars.length === 0) return false;
1296
+ const envExamplePath = join4(cwd, ".env.example");
1297
+ if (existsSync4(envExamplePath)) return false;
1298
+ const content = vars.map((v) => `${v}=`).join("\n") + "\n";
1299
+ writeFileSync2(envExamplePath, content, "utf-8");
1300
+ return true;
1301
+ }
1302
+ function detectFrameworkConfigFixes(errorOutput, cwd, framework) {
1303
+ const fixes = [];
1304
+ if (framework === "nextjs") {
1305
+ const nextConfigPath = existsSync4(join4(cwd, "next.config.mjs")) ? join4(cwd, "next.config.mjs") : existsSync4(join4(cwd, "next.config.js")) ? join4(cwd, "next.config.js") : null;
1306
+ if (nextConfigPath && errorOutput.includes("output")) {
1307
+ try {
1308
+ const content = readFileSync3(nextConfigPath, "utf-8");
1309
+ if (!content.includes("output")) {
1310
+ fixes.push(`Add output: 'export' to ${nextConfigPath.split("/").pop()}`);
1311
+ }
1312
+ } catch {
1313
+ }
1314
+ }
1315
+ }
1316
+ if ((framework === "vite-react" || framework === "vite-vue" || framework === "vite-svelte") && errorOutput.includes("plugin")) {
1317
+ const pkgPath = join4(cwd, "package.json");
1318
+ try {
1319
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
1320
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1321
+ if (framework === "vite-react" && !deps["@vitejs/plugin-react"]) {
1322
+ fixes.push("Install @vitejs/plugin-react");
1323
+ }
1324
+ if (framework === "vite-vue" && !deps["@vitejs/plugin-vue"]) {
1325
+ fixes.push("Install @vitejs/plugin-vue");
1326
+ }
1327
+ } catch {
1328
+ }
1329
+ }
1330
+ return fixes;
1331
+ }
1332
+ async function runFixHeuristics(errorOutput, cwd, config) {
1333
+ const actions = [];
1334
+ let anyFixApplied = false;
1335
+ const missingModules = findMissingModules(errorOutput);
1336
+ if (missingModules.length > 0) {
1337
+ const installCmd = config.installCommand.split(" ")[0] ?? "npm";
1338
+ for (const mod of missingModules) {
1339
+ const spinner = ui.spinner(`Installing missing dependency: ${mod}`);
1340
+ try {
1341
+ await execa2(installCmd, ["add", mod], { cwd, timeout: 6e4 });
1342
+ spinner.succeed(`Installed ${mod}`);
1343
+ actions.push(`Installed missing dependency: ${mod}`);
1344
+ anyFixApplied = true;
1345
+ } catch {
1346
+ spinner.fail(`Failed to install ${mod}`);
1347
+ actions.push(`Failed to install ${mod}`);
1348
+ }
1349
+ }
1350
+ }
1351
+ const missingPeers = findMissingPeerDeps(errorOutput);
1352
+ if (missingPeers.length > 0) {
1353
+ const installCmd = config.installCommand.split(" ")[0] ?? "npm";
1354
+ for (const peer of missingPeers) {
1355
+ if (missingModules.includes(peer)) continue;
1356
+ const spinner = ui.spinner(`Installing peer dependency: ${peer}`);
1357
+ try {
1358
+ await execa2(installCmd, ["add", peer], { cwd, timeout: 6e4 });
1359
+ spinner.succeed(`Installed peer dep: ${peer}`);
1360
+ actions.push(`Installed peer dependency: ${peer}`);
1361
+ anyFixApplied = true;
1362
+ } catch {
1363
+ spinner.fail(`Failed to install peer dep: ${peer}`);
1364
+ actions.push(`Failed to install ${peer}`);
1365
+ }
1366
+ }
1367
+ }
1368
+ const tsConfigFixes = detectTsConfigFixes(errorOutput, cwd);
1369
+ if (tsConfigFixes) {
1370
+ const applied = applyTsConfigFixes(cwd, tsConfigFixes.fixes);
1371
+ if (applied) {
1372
+ actions.push(`Updated tsconfig.json: ${tsConfigFixes.description}`);
1373
+ anyFixApplied = true;
1374
+ }
1375
+ }
1376
+ const missingEnvVars = findMissingEnvVars(cwd);
1377
+ if (missingEnvVars.length > 0) {
1378
+ const generated = generateEnvExample(cwd, missingEnvVars);
1379
+ if (generated) {
1380
+ actions.push(`Generated .env.example with ${missingEnvVars.length} vars: ${missingEnvVars.join(", ")}`);
1381
+ anyFixApplied = true;
1382
+ }
1383
+ }
1384
+ const frameworkFixes = detectFrameworkConfigFixes(errorOutput, cwd, config.framework);
1385
+ for (const fix of frameworkFixes) {
1386
+ actions.push(`Suggestion: ${fix}`);
1387
+ }
1388
+ return { actions, fixed: anyFixApplied };
1389
+ }
1390
+ async function fixCommand(options = {}) {
1391
+ const cwd = process.cwd();
1392
+ ui.section("Shipem Fix \u2014 Auto-repair build issues");
1393
+ ui.br();
1394
+ const scanSpinner = ui.spinner("Scanning project...");
1395
+ const detection = scanProject(cwd);
1396
+ scanSpinner.succeed(`Detected: ${ui.framework(detection.framework)}`);
1397
+ if (!detection.buildCommand) {
1398
+ ui.warn("No build command detected. Nothing to fix.");
1399
+ return;
1400
+ }
1401
+ const projectConfig = {
1402
+ name: detection.projectName,
1403
+ framework: detection.framework,
1404
+ buildCommand: detection.buildCommand,
1405
+ outputDirectory: detection.outputDirectory,
1406
+ installCommand: detection.installCommand,
1407
+ serverType: detection.serverType,
1408
+ nodeVersion: detection.nodeVersion,
1409
+ envVars: detection.envVars,
1410
+ deployTarget: detection.deployTarget,
1411
+ detectedAt: (/* @__PURE__ */ new Date()).toISOString()
1412
+ };
1413
+ ui.br();
1414
+ ui.section("Running build...");
1415
+ ui.br();
1416
+ let buildResult = await buildProject(projectConfig, cwd);
1417
+ if (buildResult.success) {
1418
+ ui.br();
1419
+ ui.success("Build already passes! No fixes needed.");
1420
+ ui.br();
1421
+ return;
1422
+ }
1423
+ ui.br();
1424
+ ui.section("Analyzing build errors...");
1425
+ ui.br();
1426
+ const errorOutput = buildResult.error ?? "";
1427
+ const { actions, fixed } = await runFixHeuristics(errorOutput, cwd, projectConfig);
1428
+ ui.br();
1429
+ if (actions.length > 0) {
1430
+ ui.section("Actions taken:");
1431
+ for (const action of actions) {
1432
+ ui.info(action);
1433
+ }
1434
+ ui.br();
1435
+ }
1436
+ if (!fixed) {
1437
+ ui.warn("Could not automatically fix the build errors.");
1438
+ ui.br();
1439
+ ui.dim("Suggestions:");
1440
+ ui.dim(" 1. Paste the error into your AI coding tool for a fix");
1441
+ ui.dim(" 2. Check the error output above for clues");
1442
+ ui.dim(" 3. Run `npx shipem --verbose` for detailed output");
1443
+ ui.br();
1444
+ return;
1445
+ }
1446
+ ui.section("Re-running build after fixes...");
1447
+ ui.br();
1448
+ buildResult = await buildProject(projectConfig, cwd);
1449
+ if (buildResult.success) {
1450
+ ui.br();
1451
+ ui.success("Fixed! Build passes now.");
1452
+ ui.dim("Run `npx shipem` to deploy.");
1453
+ ui.br();
1454
+ } else {
1455
+ ui.br();
1456
+ ui.warn("Build still failing after fixes.");
1457
+ ui.br();
1458
+ ui.dim("What was tried:");
1459
+ for (const action of actions) {
1460
+ ui.dim(` - ${action}`);
1461
+ }
1462
+ ui.br();
1463
+ ui.dim("Tip: Paste the error into your AI coding tool \u2014 it can usually fix it.");
1464
+ ui.br();
1465
+ }
1466
+ }
1467
+ async function tryInlineFix(errorOutput, cwd, config) {
1468
+ ui.br();
1469
+ ui.section("Attempting auto-fix...");
1470
+ ui.br();
1471
+ const { actions, fixed } = await runFixHeuristics(errorOutput, cwd, config);
1472
+ if (actions.length > 0) {
1473
+ for (const action of actions) {
1474
+ ui.info(action);
1475
+ }
1476
+ ui.br();
1477
+ }
1478
+ if (!fixed) {
1479
+ ui.warn("Could not automatically fix the issue.");
1480
+ return null;
1481
+ }
1482
+ ui.section("Re-building after fix...");
1483
+ ui.br();
1484
+ const buildResult = await buildProject(config, cwd);
1485
+ return buildResult;
1486
+ }
1487
+
1231
1488
  // src/deploy/cloudflare.ts
1232
1489
  import axios from "axios";
1233
1490
  import { createHash } from "crypto";
1234
- import { readdirSync as readdirSync2, statSync as statSync2, readFileSync as readFileSync4 } from "fs";
1235
- import { join as join5, relative } from "path";
1491
+ import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync5 } from "fs";
1492
+ import { join as join6, relative } from "path";
1236
1493
 
1237
1494
  // src/deploy/exclude.ts
1238
- import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
1239
- import { join as join4 } from "path";
1495
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
1496
+ import { join as join5 } from "path";
1240
1497
  import chalk3 from "chalk";
1241
1498
  var DEFAULT_PATTERNS = [
1242
1499
  ".git",
@@ -1288,9 +1545,9 @@ function matchesIgnoreLine(relPath, line) {
1288
1545
  return false;
1289
1546
  }
1290
1547
  function loadIgnoreLines(filePath) {
1291
- if (!existsSync4(filePath)) return [];
1548
+ if (!existsSync5(filePath)) return [];
1292
1549
  try {
1293
- return readFileSync3(filePath, "utf-8").split("\n");
1550
+ return readFileSync4(filePath, "utf-8").split("\n");
1294
1551
  } catch {
1295
1552
  return [];
1296
1553
  }
@@ -1303,11 +1560,11 @@ function shouldExclude(filePath, projectRoot) {
1303
1560
  }
1304
1561
  if (projectRoot) {
1305
1562
  const relPath = normalized;
1306
- const shipemIgnoreLines = loadIgnoreLines(join4(projectRoot, ".shipemignore"));
1563
+ const shipemIgnoreLines = loadIgnoreLines(join5(projectRoot, ".shipemignore"));
1307
1564
  for (const line of shipemIgnoreLines) {
1308
1565
  if (matchesIgnoreLine(relPath, line)) return true;
1309
1566
  }
1310
- const gitIgnoreLines = loadIgnoreLines(join4(projectRoot, ".gitignore"));
1567
+ const gitIgnoreLines = loadIgnoreLines(join5(projectRoot, ".gitignore"));
1311
1568
  for (const line of gitIgnoreLines) {
1312
1569
  if (matchesIgnoreLine(relPath, line)) return true;
1313
1570
  }
@@ -1317,7 +1574,7 @@ function shouldExclude(filePath, projectRoot) {
1317
1574
  function warnIfEnvFilesInOutputDir(outputDir) {
1318
1575
  const envFiles = [".env", ".env.local", ".env.production"];
1319
1576
  for (const name of envFiles) {
1320
- if (existsSync4(join4(outputDir, name))) {
1577
+ if (existsSync5(join5(outputDir, name))) {
1321
1578
  process.stderr.write(
1322
1579
  chalk3.yellow(`
1323
1580
  \u26A0 Warning: ${name} found in output directory \u2014 it will be excluded from deployment.
@@ -1329,6 +1586,11 @@ function warnIfEnvFilesInOutputDir(outputDir) {
1329
1586
 
1330
1587
  // src/deploy/cloudflare.ts
1331
1588
  var CF_API_BASE = "https://api.cloudflare.com/client/v4";
1589
+ var CF_UPLOAD_ENDPOINT = "https://upload.storageapi.cloudflare.com/pages-uploads";
1590
+ var CF_PROJECT_NAME_MAX_LENGTH = 28;
1591
+ var CF_REQUEST_TIMEOUT_MS = 6e4;
1592
+ var DEPLOY_POLL_INTERVAL_MS = 3e3;
1593
+ var DEPLOY_TIMEOUT_MS = 3 * 60 * 1e3;
1332
1594
  var CloudflarePages = class {
1333
1595
  client;
1334
1596
  accountId;
@@ -1340,7 +1602,7 @@ var CloudflarePages = class {
1340
1602
  Authorization: `Bearer ${apiToken}`,
1341
1603
  "Content-Type": "application/json"
1342
1604
  },
1343
- timeout: 6e4
1605
+ timeout: CF_REQUEST_TIMEOUT_MS
1344
1606
  });
1345
1607
  }
1346
1608
  async getOrCreateProject(projectName, config) {
@@ -1354,7 +1616,7 @@ var CloudflarePages = class {
1354
1616
  } catch (err) {
1355
1617
  const status = err.response?.status;
1356
1618
  if (status !== 404) {
1357
- throw new Error(`Failed to check project: ${extractCFError(err)}`);
1619
+ throw new DeployError(`Failed to check project: ${extractCFError(err)}`);
1358
1620
  }
1359
1621
  }
1360
1622
  try {
@@ -1381,11 +1643,11 @@ var CloudflarePages = class {
1381
1643
  }
1382
1644
  );
1383
1645
  if (!res.data.success) {
1384
- throw new Error(res.data.errors[0]?.message ?? "Unknown error creating project");
1646
+ throw new DeployError(res.data.errors[0]?.message ?? "Unknown error creating project");
1385
1647
  }
1386
1648
  return res.data.result;
1387
1649
  } catch (err) {
1388
- throw new Error(`Failed to create Pages project: ${extractCFError(err)}`);
1650
+ throw new DeployError(`Failed to create Pages project: ${extractCFError(err)}`, { cause: err });
1389
1651
  }
1390
1652
  }
1391
1653
  async setEnvironmentVariables(projectName, envVars) {
@@ -1410,16 +1672,16 @@ var CloudflarePages = class {
1410
1672
  }
1411
1673
  );
1412
1674
  } catch (err) {
1413
- throw new Error(`Failed to set environment variables: ${extractCFError(err)}`);
1675
+ throw new DeployError(`Failed to set environment variables: ${extractCFError(err)}`, { cause: err });
1414
1676
  }
1415
1677
  }
1416
1678
  async deployDirectory(projectName, outputDir, cwd = process.cwd()) {
1417
- const fullOutputPath = join5(cwd, outputDir);
1679
+ const fullOutputPath = join6(cwd, outputDir);
1418
1680
  const filePaths = collectFiles(fullOutputPath);
1419
1681
  let totalBytes = 0;
1420
1682
  const fileMap = /* @__PURE__ */ new Map();
1421
1683
  for (const filePath of filePaths) {
1422
- const content = readFileSync4(filePath);
1684
+ const content = readFileSync5(filePath);
1423
1685
  const hash = createHash("sha256").update(content).digest("hex");
1424
1686
  const urlPath = "/" + relative(fullOutputPath, filePath).replace(/\\/g, "/");
1425
1687
  fileMap.set(urlPath, { hash, content });
@@ -1442,11 +1704,11 @@ var CloudflarePages = class {
1442
1704
  const res = await this.client.post(
1443
1705
  `/accounts/${this.accountId}/pages/projects/${projectName}/deployments`,
1444
1706
  { files: manifest, branch: "main" },
1445
- { timeout: 6e4 }
1707
+ { timeout: CF_REQUEST_TIMEOUT_MS }
1446
1708
  );
1447
1709
  if (!res.data.success) {
1448
1710
  deploySpinner.fail("Deployment creation failed");
1449
- throw new Error(res.data.errors[0]?.message ?? "Deployment failed");
1711
+ throw new DeployError(res.data.errors[0]?.message ?? "Deployment failed");
1450
1712
  }
1451
1713
  jwt = res.data.result.jwt;
1452
1714
  requiredFiles = res.data.result.required_files ?? [];
@@ -1454,7 +1716,7 @@ var CloudflarePages = class {
1454
1716
  deploySpinner.succeed("Deployment created");
1455
1717
  } catch (err) {
1456
1718
  deploySpinner.fail("Deployment creation failed");
1457
- throw new Error(`Deployment failed: ${extractCFError(err)}`);
1719
+ throw new DeployError(`Deployment failed: ${extractCFError(err)}`, { cause: err });
1458
1720
  }
1459
1721
  if (requiredFiles.length > 0) {
1460
1722
  const uploadSpinner = ui.spinner(
@@ -1467,19 +1729,19 @@ var CloudflarePages = class {
1467
1729
  try {
1468
1730
  const formData = new FormData();
1469
1731
  formData.append(hash, new Blob([content], { type: "application/octet-stream" }), hash);
1470
- await fetch("https://upload.storageapi.cloudflare.com/pages-uploads", {
1732
+ await fetch(CF_UPLOAD_ENDPOINT, {
1471
1733
  method: "POST",
1472
1734
  headers: { Authorization: `Bearer ${jwt}` },
1473
1735
  body: formData
1474
1736
  }).then(async (res) => {
1475
1737
  if (!res.ok) {
1476
1738
  const text = await res.text().catch(() => res.statusText);
1477
- throw new Error(`Upload failed (${res.status}): ${text}`);
1739
+ throw new DeployError(`Upload failed (${res.status}): ${text}`);
1478
1740
  }
1479
1741
  });
1480
1742
  } catch (err) {
1481
1743
  uploadSpinner.fail("Upload failed");
1482
- throw new Error(`Failed to upload file (hash ${hash}): ${err instanceof Error ? err.message : String(err)}`);
1744
+ throw new DeployError(`Failed to upload file (hash ${hash}): ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1483
1745
  }
1484
1746
  uploaded++;
1485
1747
  uploadSpinner.text = `Uploading files... ${uploaded}/${requiredFiles.length}`;
@@ -1489,37 +1751,113 @@ var CloudflarePages = class {
1489
1751
  ui.success("Live!");
1490
1752
  return { deployment, fileCount, totalBytes };
1491
1753
  }
1492
- async waitForDeployment(projectName, deploymentId, timeoutMs = 3 * 60 * 1e3) {
1493
- const deploySpinner = ui.spinner("Deploying to Cloudflare Pages");
1494
- const start = Date.now();
1495
- while (Date.now() - start < timeoutMs) {
1496
- await sleep(3e3);
1497
- try {
1498
- const res = await this.client.get(
1499
- `/accounts/${this.accountId}/pages/projects/${projectName}/deployments/${deploymentId}`
1500
- );
1501
- if (!res.data.success) continue;
1502
- const deployment = res.data.result;
1503
- const stage = deployment.latest_stage;
1504
- deploySpinner.text = `Deploying... (${stage?.name ?? "initializing"})`;
1754
+ async deployBranch(projectName, outputDir, branch, cwd = process.cwd()) {
1755
+ const fullOutputPath = join6(cwd, outputDir);
1756
+ const filePaths = collectFiles(fullOutputPath);
1757
+ let totalBytes = 0;
1758
+ const fileMap = /* @__PURE__ */ new Map();
1759
+ for (const filePath of filePaths) {
1760
+ const content = readFileSync5(filePath);
1761
+ const hash = createHash("sha256").update(content).digest("hex");
1762
+ const urlPath = "/" + relative(fullOutputPath, filePath).replace(/\\/g, "/");
1763
+ fileMap.set(urlPath, { hash, content });
1764
+ totalBytes += content.length;
1765
+ }
1766
+ const hashToContent = /* @__PURE__ */ new Map();
1767
+ for (const { hash, content } of fileMap.values()) {
1768
+ if (!hashToContent.has(hash)) hashToContent.set(hash, content);
1769
+ }
1770
+ const fileCount = filePaths.length;
1771
+ const manifest = {};
1772
+ for (const [urlPath, { hash }] of fileMap) {
1773
+ manifest[urlPath] = hash;
1774
+ }
1775
+ const deploySpinner = ui.spinner(`Creating preview deployment (branch: ${branch})...`);
1776
+ let jwt;
1777
+ let requiredFiles;
1778
+ let deployment;
1779
+ try {
1780
+ const res = await this.client.post(
1781
+ `/accounts/${this.accountId}/pages/projects/${projectName}/deployments`,
1782
+ { files: manifest, branch },
1783
+ { timeout: CF_REQUEST_TIMEOUT_MS }
1784
+ );
1785
+ if (!res.data.success) {
1786
+ deploySpinner.fail("Preview deployment creation failed");
1787
+ throw new DeployError(res.data.errors[0]?.message ?? "Preview deployment failed");
1788
+ }
1789
+ jwt = res.data.result.jwt;
1790
+ requiredFiles = res.data.result.required_files ?? [];
1791
+ deployment = res.data.result;
1792
+ deploySpinner.succeed("Preview deployment created");
1793
+ } catch (err) {
1794
+ deploySpinner.fail("Preview deployment creation failed");
1795
+ throw new DeployError(`Preview deployment failed: ${extractCFError(err)}`, { cause: err });
1796
+ }
1797
+ if (requiredFiles.length > 0) {
1798
+ const uploadSpinner = ui.spinner(
1799
+ `Uploading ${requiredFiles.length} files (${ui.formatBytes(totalBytes)})`
1800
+ );
1801
+ let uploaded = 0;
1802
+ for (const hash of requiredFiles) {
1803
+ const content = hashToContent.get(hash);
1804
+ if (!content) continue;
1805
+ try {
1806
+ const formData = new FormData();
1807
+ formData.append(hash, new Blob([content], { type: "application/octet-stream" }), hash);
1808
+ await fetch(CF_UPLOAD_ENDPOINT, {
1809
+ method: "POST",
1810
+ headers: { Authorization: `Bearer ${jwt}` },
1811
+ body: formData
1812
+ }).then(async (res) => {
1813
+ if (!res.ok) {
1814
+ const text = await res.text().catch(() => res.statusText);
1815
+ throw new DeployError(`Upload failed (${res.status}): ${text}`);
1816
+ }
1817
+ });
1818
+ } catch (err) {
1819
+ uploadSpinner.fail("Upload failed");
1820
+ throw new DeployError(`Failed to upload file (hash ${hash}): ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1821
+ }
1822
+ uploaded++;
1823
+ uploadSpinner.text = `Uploading files... ${uploaded}/${requiredFiles.length}`;
1824
+ }
1825
+ uploadSpinner.succeed(`Upload complete \u2014 ${fileCount} files (${ui.formatBytes(totalBytes)})`);
1826
+ }
1827
+ ui.success("Preview deployed!");
1828
+ return { deployment, fileCount, totalBytes };
1829
+ }
1830
+ async waitForDeployment(projectName, deploymentId, timeoutMs = DEPLOY_TIMEOUT_MS) {
1831
+ const deploySpinner = ui.spinner("Deploying to Cloudflare Pages");
1832
+ const start = Date.now();
1833
+ while (Date.now() - start < timeoutMs) {
1834
+ await sleep(DEPLOY_POLL_INTERVAL_MS);
1835
+ try {
1836
+ const res = await this.client.get(
1837
+ `/accounts/${this.accountId}/pages/projects/${projectName}/deployments/${deploymentId}`
1838
+ );
1839
+ if (!res.data.success) continue;
1840
+ const deployment = res.data.result;
1841
+ const stage = deployment.latest_stage;
1842
+ deploySpinner.text = `Deploying... (${stage?.name ?? "initializing"})`;
1505
1843
  if (stage?.status === "success") {
1506
1844
  deploySpinner.succeed("Deployed successfully");
1507
1845
  return deployment;
1508
1846
  }
1509
1847
  if (stage?.status === "failure") {
1510
1848
  deploySpinner.fail("Deployment failed");
1511
- throw new Error(
1849
+ throw new DeployError(
1512
1850
  `Deployment failed at stage '${stage.name}'. Check the Cloudflare Pages dashboard for details.`
1513
1851
  );
1514
1852
  }
1515
1853
  } catch (err) {
1516
- if (err instanceof Error && err.message.includes("Deployment failed")) {
1854
+ if (err instanceof DeployError) {
1517
1855
  throw err;
1518
1856
  }
1519
1857
  }
1520
1858
  }
1521
1859
  deploySpinner.fail("Deployment timed out");
1522
- throw new Error("Deployment timed out. Check the Cloudflare Pages dashboard for status.");
1860
+ throw new DeployError("Deployment timed out. Check the Cloudflare Pages dashboard for status.");
1523
1861
  }
1524
1862
  async getDeploymentStatus(projectName, deploymentId) {
1525
1863
  try {
@@ -1548,7 +1886,7 @@ var CloudflarePages = class {
1548
1886
  `/accounts/${this.accountId}/pages/projects/${projectName}`
1549
1887
  );
1550
1888
  } catch (err) {
1551
- throw new Error(`Failed to delete project: ${extractCFError(err)}`);
1889
+ throw new DeployError(`Failed to delete project: ${extractCFError(err)}`, { cause: err });
1552
1890
  }
1553
1891
  }
1554
1892
  getProjectUrl(projectName) {
@@ -1572,9 +1910,9 @@ var CloudflarePages = class {
1572
1910
  function collectFiles(dir) {
1573
1911
  const results = [];
1574
1912
  function walk(currentDir) {
1575
- const entries = readdirSync2(currentDir);
1913
+ const entries = readdirSync3(currentDir);
1576
1914
  for (const entry of entries) {
1577
- const fullPath = join5(currentDir, entry);
1915
+ const fullPath = join6(currentDir, entry);
1578
1916
  if (shouldExclude(fullPath)) continue;
1579
1917
  if (entry.startsWith(".") && entry !== ".well-known") continue;
1580
1918
  const stat = statSync2(fullPath);
@@ -1601,7 +1939,35 @@ function sleep(ms) {
1601
1939
  return new Promise((resolve) => setTimeout(resolve, ms));
1602
1940
  }
1603
1941
  function sanitizeProjectName(name) {
1604
- return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 28);
1942
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, CF_PROJECT_NAME_MAX_LENGTH);
1943
+ }
1944
+
1945
+ // src/deploy/badge.ts
1946
+ import { readdirSync as readdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
1947
+ import { join as join7 } from "path";
1948
+ var BADGE_HTML = `<!-- Shipped with Shipem -->
1949
+ <div id="shipem-badge" style="position:fixed;bottom:12px;right:12px;z-index:9999;font-family:-apple-system,BlinkMacSystemFont,sans-serif;font-size:12px;background:rgba(13,17,23,0.9);color:#94A3B8;padding:6px 12px;border-radius:20px;border:1px solid rgba(59,130,246,0.3);text-decoration:none;display:flex;align-items:center;gap:6px;backdrop-filter:blur(8px);transition:opacity 0.2s"><a href="https://shipem.dev" target="_blank" rel="noopener" style="color:#94A3B8;text-decoration:none;display:flex;align-items:center;gap:6px">Shipped with <span style="color:#3B82F6">\u26A1</span> Shipem</a></div>`;
1950
+ function injectBadge(outputDir) {
1951
+ let injected = 0;
1952
+ try {
1953
+ const files = readdirSync4(outputDir).filter((f) => f.endsWith(".html"));
1954
+ for (const file of files) {
1955
+ const filePath = join7(outputDir, file);
1956
+ const content = readFileSync6(filePath, "utf-8");
1957
+ if (content.includes("shipem-badge")) continue;
1958
+ if (content.includes("</body>")) {
1959
+ writeFileSync3(filePath, content.replace("</body>", `${BADGE_HTML}
1960
+ </body>`), "utf-8");
1961
+ injected++;
1962
+ } else if (content.includes("</html>")) {
1963
+ writeFileSync3(filePath, content.replace("</html>", `${BADGE_HTML}
1964
+ </html>`), "utf-8");
1965
+ injected++;
1966
+ }
1967
+ }
1968
+ } catch {
1969
+ }
1970
+ return injected;
1605
1971
  }
1606
1972
 
1607
1973
  // src/commands/deploy.ts
@@ -1619,6 +1985,10 @@ var ENV_VAR_HELP = {
1619
1985
  STRIPE_PUBLISHABLE_KEY: { desc: "Stripe publishable key", url: "https://dashboard.stripe.com/apikeys" },
1620
1986
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: { desc: "Stripe publishable key (public)", url: "https://dashboard.stripe.com/apikeys" }
1621
1987
  };
1988
+ var SOURCE_FILE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", "dist", "build", "out", ".turbo", "coverage"]);
1989
+ var DEPLOY_TIMEOUT_MS2 = 5 * 60 * 1e3;
1990
+ var LOW_CONFIDENCE_THRESHOLD = 0.7;
1991
+ var NOT_A_PROJECT_THRESHOLD = 0.4;
1622
1992
  async function apiRequest(config) {
1623
1993
  try {
1624
1994
  return await axios2(config);
@@ -1626,26 +1996,25 @@ async function apiRequest(config) {
1626
1996
  if (axios2.isAxiosError(err)) {
1627
1997
  const code = err.code;
1628
1998
  if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT") {
1629
- throw new Error("Cannot reach Shipem servers. Check your internet connection and try again.");
1999
+ throw new NetworkError("Cannot reach Shipem servers. Check your internet connection and try again.", { cause: err });
1630
2000
  }
1631
2001
  const status = err.response?.status;
1632
2002
  if (status === 401) throw err;
1633
- if (status === 500) throw new Error("Shipem servers are having issues. Try again in a moment.");
1634
- if (status === 429) throw new Error("Too many requests. Please wait before deploying again.");
1635
- if (status === 413) throw new Error("Project is too large to deploy (max 100 MB).");
2003
+ if (status === 500) throw new DeployError("Shipem servers are having issues. Try again in a moment.", { cause: err });
2004
+ if (status === 429) throw new DeployError("Too many requests. Please wait before deploying again.", { cause: err });
2005
+ if (status === 413) throw new DeployError("Project is too large to deploy (max 100 MB).", { cause: err });
1636
2006
  }
1637
2007
  throw err;
1638
2008
  }
1639
2009
  }
1640
2010
  function countSourceFiles(dir) {
1641
- const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", "dist", "build", "out", ".turbo", "coverage"]);
1642
2011
  let count = 0;
1643
2012
  function walk(d) {
1644
2013
  try {
1645
- const entries = readdirSync3(d, { withFileTypes: true });
2014
+ const entries = readdirSync5(d, { withFileTypes: true });
1646
2015
  for (const entry of entries) {
1647
- if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
1648
- if (entry.isDirectory()) walk(join6(d, entry.name));
2016
+ if (SOURCE_FILE_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
2017
+ if (entry.isDirectory()) walk(join8(d, entry.name));
1649
2018
  else count++;
1650
2019
  }
1651
2020
  } catch {
@@ -1659,9 +2028,9 @@ function countOutputFiles(dir) {
1659
2028
  let totalBytes = 0;
1660
2029
  function walk(d) {
1661
2030
  try {
1662
- const entries = readdirSync3(d, { withFileTypes: true });
2031
+ const entries = readdirSync5(d, { withFileTypes: true });
1663
2032
  for (const entry of entries) {
1664
- const fullPath = join6(d, entry.name);
2033
+ const fullPath = join8(d, entry.name);
1665
2034
  if (entry.isDirectory()) walk(fullPath);
1666
2035
  else {
1667
2036
  fileCount++;
@@ -1677,8 +2046,55 @@ function countOutputFiles(dir) {
1677
2046
  walk(dir);
1678
2047
  return { fileCount, totalBytes };
1679
2048
  }
2049
+ function showBuildErrorHelp(errMsg) {
2050
+ const moduleMatch = errMsg.match(/Cannot find module ['"]([^'"]+)['"]/);
2051
+ const missingDepMatch = errMsg.match(/Module not found.*['"]([^'"]+)['"]/);
2052
+ const tsErrorMatch = errMsg.match(/TS\d+:/);
2053
+ const syntaxMatch = errMsg.match(/SyntaxError/);
2054
+ const envMatch = errMsg.match(/process\.env\.([A-Z_][A-Z0-9_]*)/);
2055
+ if (moduleMatch || missingDepMatch) {
2056
+ const modName = moduleMatch?.[1] ?? missingDepMatch?.[1] ?? "unknown";
2057
+ const pkgName = modName.startsWith("@") ? modName.split("/").slice(0, 2).join("/") : modName.split("/")[0];
2058
+ ui.friendlyError(
2059
+ "Missing dependency",
2060
+ `Module '${modName}' not found`,
2061
+ `Run: npm install ${pkgName}`,
2062
+ "AI tools sometimes reference packages that aren't installed. Install the missing package and try again."
2063
+ );
2064
+ } else if (tsErrorMatch) {
2065
+ ui.friendlyError(
2066
+ "TypeScript error",
2067
+ errMsg.split("\n")[0] ?? errMsg,
2068
+ "Run: npx tsc --noEmit to see all type errors",
2069
+ "AI-generated code sometimes has type errors. Check the error above and fix the types."
2070
+ );
2071
+ } else if (syntaxMatch) {
2072
+ ui.friendlyError(
2073
+ "Syntax error in your code",
2074
+ errMsg.split("\n")[0] ?? errMsg,
2075
+ "Check the file mentioned above for syntax issues",
2076
+ "AI tools occasionally generate invalid syntax. Look for missing brackets, commas, or quotes."
2077
+ );
2078
+ } else if (envMatch) {
2079
+ ui.friendlyError(
2080
+ "Missing environment variable",
2081
+ `Your code references ${envMatch[1]} but it's not set`,
2082
+ `Set it: export ${envMatch[1]}=your_value`,
2083
+ "Or add it to a .env file in your project root."
2084
+ );
2085
+ } else {
2086
+ ui.friendlyError(
2087
+ "Your app failed to build",
2088
+ errMsg.split("\n")[0] ?? errMsg,
2089
+ "Fix the error above, then run `npx shipem` again",
2090
+ "Tip: If your AI tool generated this code, paste the error back to it for a fix."
2091
+ );
2092
+ }
2093
+ }
1680
2094
  async function deployCommand(options) {
1681
2095
  const startTime = Date.now();
2096
+ const phases = [];
2097
+ let phaseStart = Date.now();
1682
2098
  const cwd = process.cwd();
1683
2099
  const existingConfig = readProjectConfig(cwd);
1684
2100
  const isRedeploy = !!existingConfig.project && !!existingConfig.deployment;
@@ -1692,27 +2108,15 @@ async function deployCommand(options) {
1692
2108
  } else {
1693
2109
  ui.banner();
1694
2110
  }
2111
+ const isAnonymous = !getSessionToken() && !getCloudflareCredentials();
1695
2112
  if (isFirstRun) {
1696
2113
  console.log(` ${chalk4.white.bold("Welcome to Shipem!")} \u2728`);
1697
2114
  console.log("");
1698
- console.log(` ${chalk4.dim("One-time setup (takes 30 seconds):")}`);
1699
- console.log(` ${chalk4.dim("1. Sign in with GitHub")}`);
1700
- console.log(` ${chalk4.dim("2. We handle everything else")}`);
2115
+ console.log(` ${chalk4.dim("No account needed. Deploying your app now...")}`);
1701
2116
  console.log("");
1702
2117
  }
1703
2118
  let sessionToken = getSessionToken();
1704
- if (!sessionToken) {
1705
- await loginCommand({ skipBanner: true });
1706
- sessionToken = getSessionToken();
1707
- if (!sessionToken) {
1708
- ui.fatal("Login failed. Please try again with: shipem login");
1709
- }
1710
- console.log("");
1711
- console.log(` ${chalk4.hex("#22C55E")("\u2713")} ${chalk4.bold("Signed in!")}`);
1712
- console.log("");
1713
- console.log(` Now deploying your app...`);
1714
- console.log("");
1715
- }
2119
+ phaseStart = Date.now();
1716
2120
  ui.section("Scanning project...");
1717
2121
  ui.br();
1718
2122
  let projectConfig;
@@ -1721,41 +2125,40 @@ async function deployCommand(options) {
1721
2125
  ui.success(`Using saved config: ${ui.framework(projectConfig.framework)}`);
1722
2126
  } else {
1723
2127
  const scanSpinner = ui.spinner("Scanning project files");
1724
- const heuristicResult = scanProject(cwd);
2128
+ let heuristicResult = scanProject(cwd);
1725
2129
  scanSpinner.succeed("Project files scanned");
1726
- const sourceFileCount = countSourceFiles(cwd);
1727
- let finalDetection;
1728
- const anthropicKey = getAnthropicApiKey();
1729
- const heuristicAsAnalysis = {
1730
- framework: heuristicResult.framework,
1731
- buildCommand: heuristicResult.buildCommand,
1732
- outputDirectory: heuristicResult.outputDirectory,
1733
- installCommand: heuristicResult.installCommand,
1734
- serverType: heuristicResult.serverType,
1735
- deployTarget: heuristicResult.deployTarget,
1736
- nodeVersion: heuristicResult.nodeVersion,
1737
- pythonVersion: heuristicResult.pythonVersion,
1738
- envVars: heuristicResult.envVars,
1739
- notes: "",
1740
- confidence: heuristicResult.confidence
1741
- };
1742
- if (anthropicKey) {
1743
- const aiSpinner = ui.spinner("Analyzing with Claude AI");
1744
- try {
1745
- const fileSamples = collectFileSamples(cwd);
1746
- const aiResult = await aiDetectProject(fileSamples, heuristicAsAnalysis);
1747
- finalDetection = mergeDetectionResults(heuristicAsAnalysis, aiResult);
1748
- aiSpinner.succeed("AI analysis complete");
1749
- if (aiResult?.notes) {
1750
- ui.dim(aiResult.notes);
2130
+ if (heuristicResult.monorepo && heuristicResult.monorepo.packages.length > 0) {
2131
+ const mono = heuristicResult.monorepo;
2132
+ ui.info(`Monorepo detected (${mono.type}) \u2014 ${mono.packages.length} packages found`);
2133
+ ui.br();
2134
+ let packageName = options.package;
2135
+ if (!packageName && !options.yes) {
2136
+ const { selected } = await inquirer.prompt([
2137
+ {
2138
+ type: "list",
2139
+ name: "selected",
2140
+ message: "Which package to deploy?",
2141
+ choices: mono.packages.map((p) => ({ name: p, value: p }))
2142
+ }
2143
+ ]);
2144
+ packageName = selected;
2145
+ } else if (!packageName) {
2146
+ packageName = mono.packages[0];
2147
+ }
2148
+ if (packageName) {
2149
+ const commonDirs = ["packages", "apps", "projects", "services"];
2150
+ for (const dir of commonDirs) {
2151
+ const pkgDir = join8(cwd, dir, packageName);
2152
+ if (existsSync6(pkgDir)) {
2153
+ ui.info(`Deploying package: ${chalk4.cyan(packageName)} (${dir}/${packageName})`);
2154
+ heuristicResult = scanProject(pkgDir);
2155
+ break;
2156
+ }
1751
2157
  }
1752
- } catch {
1753
- aiSpinner.warn("AI analysis failed, using heuristic detection");
1754
- finalDetection = heuristicAsAnalysis;
1755
2158
  }
1756
- } else {
1757
- finalDetection = heuristicAsAnalysis;
1758
2159
  }
2160
+ const sourceFileCount = countSourceFiles(cwd);
2161
+ const finalDetection = heuristicResult;
1759
2162
  const projectName = options.name ?? heuristicResult.projectName;
1760
2163
  ui.projectAnalysis({
1761
2164
  name: projectName,
@@ -1766,11 +2169,11 @@ async function deployCommand(options) {
1766
2169
  deployTarget: finalDetection.deployTarget,
1767
2170
  envVarCount: finalDetection.envVars.length
1768
2171
  });
1769
- if (finalDetection.confidence < 0.7 && !options.yes) {
2172
+ if (finalDetection.confidence < LOW_CONFIDENCE_THRESHOLD && !options.yes) {
1770
2173
  ui.warn(`Low confidence detection (${Math.round(finalDetection.confidence * 100)}%). Please verify the settings.`);
1771
2174
  ui.br();
1772
2175
  }
1773
- if (finalDetection.confidence < 0.4 && !existsSync5(join6(cwd, "package.json")) && !existsSync5(join6(cwd, "requirements.txt")) && !existsSync5(join6(cwd, "index.html"))) {
2176
+ if (finalDetection.confidence < NOT_A_PROJECT_THRESHOLD && !existsSync6(join8(cwd, "package.json")) && !existsSync6(join8(cwd, "requirements.txt")) && !existsSync6(join8(cwd, "index.html"))) {
1774
2177
  ui.friendlyError(
1775
2178
  "This does not look like a project",
1776
2179
  "No package.json, requirements.txt, or index.html found",
@@ -1858,66 +2261,80 @@ async function deployCommand(options) {
1858
2261
  }
1859
2262
  }
1860
2263
  }
1861
- if (!options.skipBuild) {
2264
+ phases.push({ name: "Scan", durationMs: Date.now() - phaseStart });
2265
+ if (!options.skipBuild && !options.turbo) {
2266
+ phaseStart = Date.now();
1862
2267
  ui.section("Building...");
1863
2268
  ui.br();
1864
2269
  let buildResult = await buildProject(projectConfig, cwd);
1865
2270
  if (!buildResult.success) {
1866
2271
  ui.br();
1867
2272
  const errMsg = buildResult.error ?? "Unknown build error";
1868
- const moduleMatch = errMsg.match(/Cannot find module ['"]([^'"]+)['"]/);
1869
- if (moduleMatch) {
1870
- ui.friendlyError(
1871
- "Your app failed to build",
1872
- `Module '${moduleMatch[1]}' not found`,
1873
- 'Run "npm install" in your project, then try again'
1874
- );
1875
- } else {
1876
- ui.friendlyError(
1877
- "Your app failed to build",
1878
- errMsg.split("\n")[0] ?? errMsg,
1879
- "Fix the error above, then run `npx shipem` again"
1880
- );
1881
- }
2273
+ showBuildErrorHelp(errMsg);
1882
2274
  if (options.yes) {
1883
- process.exit(1);
1884
- }
1885
- const { action } = await inquirer.prompt([
1886
- {
1887
- type: "list",
1888
- name: "action",
1889
- message: "Build failed. What would you like to do?",
1890
- choices: [
1891
- { name: "Try again", value: "retry" },
1892
- { name: "Deploy without building (use existing output directory)", value: "skip" },
1893
- { name: "Quit", value: "quit" }
1894
- ]
2275
+ const fixResult = await tryInlineFix(errMsg, cwd, projectConfig);
2276
+ if (fixResult?.success) {
2277
+ ui.success("Fixed! Continuing deploy...");
2278
+ ui.br();
2279
+ } else {
2280
+ process.exit(1);
1895
2281
  }
1896
- ]);
1897
- if (action === "quit") {
1898
- ui.br();
1899
- process.exit(1);
1900
- }
1901
- if (action === "retry") {
1902
- ui.br();
1903
- ui.section("Building (retry)...");
1904
- ui.br();
1905
- buildResult = await buildProject(projectConfig, cwd);
1906
- if (!buildResult.success) {
2282
+ } else {
2283
+ const { action } = await inquirer.prompt([
2284
+ {
2285
+ type: "list",
2286
+ name: "action",
2287
+ message: "Build failed. What would you like to do?",
2288
+ choices: [
2289
+ { name: "Auto-fix and retry (recommended)", value: "fix" },
2290
+ { name: "Try again without fixing", value: "retry" },
2291
+ { name: "Deploy without building (use existing output directory)", value: "skip" },
2292
+ { name: "Quit", value: "quit" }
2293
+ ]
2294
+ }
2295
+ ]);
2296
+ if (action === "quit") {
1907
2297
  ui.br();
1908
- ui.friendlyError(
1909
- "Your app failed to build again",
1910
- buildResult.error?.split("\n")[0] ?? "Unknown build error",
1911
- "Fix the error above, then run `npx shipem` again"
1912
- );
1913
2298
  process.exit(1);
1914
2299
  }
2300
+ if (action === "fix") {
2301
+ const fixResult = await tryInlineFix(errMsg, cwd, projectConfig);
2302
+ if (fixResult?.success) {
2303
+ ui.success("Fixed! Continuing deploy...");
2304
+ ui.br();
2305
+ } else {
2306
+ ui.br();
2307
+ ui.dim("Tip: Paste this error into your AI coding tool \u2014 it can usually fix it.");
2308
+ process.exit(1);
2309
+ }
2310
+ } else if (action === "retry") {
2311
+ ui.br();
2312
+ ui.section("Building (retry)...");
2313
+ ui.br();
2314
+ buildResult = await buildProject(projectConfig, cwd);
2315
+ if (!buildResult.success) {
2316
+ ui.br();
2317
+ showBuildErrorHelp(buildResult.error ?? "Unknown build error");
2318
+ ui.dim("Tip: Paste this error into your AI coding tool \u2014 it can usually fix it.");
2319
+ process.exit(1);
2320
+ }
2321
+ }
1915
2322
  }
1916
2323
  ui.br();
1917
2324
  } else {
1918
2325
  ui.br();
1919
2326
  }
2327
+ phases.push({ name: "Build", durationMs: Date.now() - phaseStart });
1920
2328
  }
2329
+ if (isAnonymous) {
2330
+ const outputPath = join8(cwd, projectConfig.outputDirectory);
2331
+ const badgeCount = injectBadge(outputPath);
2332
+ if (badgeCount > 0) {
2333
+ ui.dim(`Added "Shipped with Shipem" badge to ${badgeCount} HTML file${badgeCount > 1 ? "s" : ""}`);
2334
+ ui.br();
2335
+ }
2336
+ }
2337
+ phaseStart = Date.now();
1921
2338
  ui.section("Deploying...");
1922
2339
  ui.br();
1923
2340
  let liveUrl;
@@ -1928,12 +2345,9 @@ async function deployCommand(options) {
1928
2345
  const useDirectDeploy = options.direct === true || cfCreds !== null;
1929
2346
  if (useDirectDeploy) {
1930
2347
  if (!cfCreds) {
1931
- ui.friendlyError(
1932
- "Direct deploy requires Cloudflare credentials",
1933
- "CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set",
1934
- "Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables, then try again"
2348
+ throw new ConfigError(
2349
+ "Direct deploy requires Cloudflare credentials. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables."
1935
2350
  );
1936
- process.exit(1);
1937
2351
  }
1938
2352
  ui.dim("Direct Cloudflare deployment (using your credentials)...");
1939
2353
  ui.br();
@@ -1951,18 +2365,13 @@ async function deployCommand(options) {
1951
2365
  liveUrl = finalDeployment.url || cf.getProjectUrl(cfProjectName);
1952
2366
  projectId = cfProjectName;
1953
2367
  } catch (err) {
1954
- const msg = err instanceof Error ? err.message : String(err);
1955
- ui.friendlyError(
1956
- "Deployment failed",
1957
- msg,
1958
- "Check your Cloudflare credentials and try again"
1959
- );
1960
- process.exit(1);
2368
+ if (err instanceof DeployError) throw err;
2369
+ throw new DeployError(err instanceof Error ? err.message : String(err), { cause: err });
1961
2370
  }
1962
- } else {
1963
- const tarPath = join6(tmpdir(), `shipem-${Date.now()}.tar.gz`);
2371
+ } else if (sessionToken) {
2372
+ const tarPath = join8(tmpdir(), `shipem-${Date.now()}.tar.gz`);
1964
2373
  try {
1965
- const outputPath = join6(cwd, projectConfig.outputDirectory);
2374
+ const outputPath = join8(cwd, projectConfig.outputDirectory);
1966
2375
  warnIfEnvFilesInOutputDir(outputPath);
1967
2376
  const stats = countOutputFiles(outputPath);
1968
2377
  deployFileCount = stats.fileCount;
@@ -2003,7 +2412,7 @@ async function deployCommand(options) {
2003
2412
  Authorization: `Bearer ${sessionToken}`
2004
2413
  },
2005
2414
  maxBodyLength: Infinity,
2006
- timeout: 5 * 60 * 1e3,
2415
+ timeout: DEPLOY_TIMEOUT_MS2,
2007
2416
  onUploadProgress: (evt) => {
2008
2417
  if (!activatingSpinner && evt.total && evt.loaded >= evt.total) {
2009
2418
  const uploadSec = ((Date.now() - uploadStart) / 1e3).toFixed(1);
@@ -2025,7 +2434,7 @@ async function deployCommand(options) {
2025
2434
  await loginCommand({ skipBanner: true });
2026
2435
  sessionToken = getSessionToken();
2027
2436
  if (!sessionToken) {
2028
- throw new Error("Login failed after session expiry. Please run: shipem login");
2437
+ throw new AuthError("Login failed after session expiry. Please run: shipem login");
2029
2438
  }
2030
2439
  const retryForm = new FormData2();
2031
2440
  retryForm.append("config", JSON.stringify({
@@ -2047,7 +2456,7 @@ async function deployCommand(options) {
2047
2456
  response = await apiRequest(deployConfig);
2048
2457
  } catch (retryErr) {
2049
2458
  if (axios2.isAxiosError(retryErr) && retryErr.response?.status === 401) {
2050
- throw new Error("Authentication failed after re-login. Please run: shipem login");
2459
+ throw new AuthError("Authentication failed after re-login. Please run: shipem login");
2051
2460
  }
2052
2461
  throw retryErr;
2053
2462
  }
@@ -2063,21 +2472,105 @@ async function deployCommand(options) {
2063
2472
  liveUrl = response.data.url;
2064
2473
  projectId = response.data.projectId;
2065
2474
  } catch (err) {
2475
+ if (err instanceof AuthError || err instanceof NetworkError || err instanceof DeployError) {
2476
+ throw err;
2477
+ }
2066
2478
  const msg = err instanceof Error ? err.message : String(err);
2067
- if (msg.includes("Cannot reach")) {
2068
- ui.friendlyError(
2069
- "Cannot reach Shipem servers",
2070
- "Network connection failed",
2071
- "Check your internet and try again"
2072
- );
2073
- } else {
2074
- ui.friendlyError(
2075
- "Deployment failed",
2076
- msg,
2077
- "Check your connection and try again with: npx shipem"
2078
- );
2479
+ throw new DeployError(`Deployment failed: ${msg}`, { cause: err });
2480
+ } finally {
2481
+ try {
2482
+ rmSync(tarPath);
2483
+ } catch {
2079
2484
  }
2080
- process.exit(1);
2485
+ }
2486
+ } else {
2487
+ ui.br();
2488
+ ui.warn("No Cloudflare credentials found and not logged in.");
2489
+ ui.br();
2490
+ console.log(` ${chalk4.bold("To deploy, choose one option:")}`);
2491
+ console.log("");
2492
+ console.log(` ${chalk4.cyan("Option 1:")} Set Cloudflare credentials (free account)`);
2493
+ console.log(` ${chalk4.dim("export CLOUDFLARE_API_TOKEN=your_token")}`);
2494
+ console.log(` ${chalk4.dim("export CLOUDFLARE_ACCOUNT_ID=your_account_id")}`);
2495
+ console.log(` ${chalk4.dim("Get these from: https://dash.cloudflare.com/profile/api-tokens")}`);
2496
+ console.log("");
2497
+ console.log(` ${chalk4.cyan("Option 2:")} Log in to Shipem`);
2498
+ console.log(` ${chalk4.dim("npx shipem login")}`);
2499
+ console.log("");
2500
+ const { action } = await inquirer.prompt([
2501
+ {
2502
+ type: "list",
2503
+ name: "action",
2504
+ message: "What would you like to do?",
2505
+ choices: [
2506
+ { name: "Log in with GitHub (sets up deployment)", value: "login" },
2507
+ { name: "Quit (I'll set up Cloudflare credentials)", value: "quit" }
2508
+ ]
2509
+ }
2510
+ ]);
2511
+ if (action === "quit") {
2512
+ process.exit(0);
2513
+ }
2514
+ await loginCommand({ skipBanner: true });
2515
+ sessionToken = getSessionToken();
2516
+ if (!sessionToken) {
2517
+ throw new AuthError("Login failed. Please try again with: shipem login");
2518
+ }
2519
+ console.log("");
2520
+ console.log(` ${chalk4.hex("#22C55E")("\u2713")} ${chalk4.bold("Signed in!")}`);
2521
+ console.log("");
2522
+ const tarPath = join8(tmpdir(), `shipem-${Date.now()}.tar.gz`);
2523
+ try {
2524
+ const outputPath = join8(cwd, projectConfig.outputDirectory);
2525
+ warnIfEnvFilesInOutputDir(outputPath);
2526
+ const stats = countOutputFiles(outputPath);
2527
+ deployFileCount = stats.fileCount;
2528
+ deployTotalBytes = stats.totalBytes;
2529
+ const packagingSpinner = ui.spinner("Packaging files...");
2530
+ await tarCreate(
2531
+ {
2532
+ gzip: true,
2533
+ file: tarPath,
2534
+ cwd: outputPath,
2535
+ filter: (filePath) => !shouldExclude(filePath, cwd)
2536
+ },
2537
+ ["."]
2538
+ );
2539
+ packagingSpinner.succeed(
2540
+ `Packaged ${deployFileCount} files (${ui.formatBytes(deployTotalBytes)})`
2541
+ );
2542
+ const uploadSpinner = ui.spinner("Uploading to cloud...");
2543
+ const form = new FormData2();
2544
+ form.append("config", JSON.stringify({
2545
+ name: projectConfig.name,
2546
+ framework: projectConfig.framework,
2547
+ deployTarget: projectConfig.deployTarget,
2548
+ outputDirectory: projectConfig.outputDirectory
2549
+ }));
2550
+ form.append("artifacts", createReadStream(tarPath), {
2551
+ filename: "artifacts.tar.gz",
2552
+ contentType: "application/gzip"
2553
+ });
2554
+ const response = await apiRequest({
2555
+ method: "post",
2556
+ url: `${SHIPEM_API_URL}/projects/deploy`,
2557
+ data: form,
2558
+ headers: {
2559
+ ...form.getHeaders(),
2560
+ Authorization: `Bearer ${sessionToken}`
2561
+ },
2562
+ maxBodyLength: Infinity,
2563
+ timeout: DEPLOY_TIMEOUT_MS2
2564
+ });
2565
+ uploadSpinner.succeed("Deployed successfully");
2566
+ liveUrl = response.data.url;
2567
+ projectId = response.data.projectId;
2568
+ } catch (err) {
2569
+ if (err instanceof AuthError || err instanceof NetworkError || err instanceof DeployError) {
2570
+ throw err;
2571
+ }
2572
+ const msg = err instanceof Error ? err.message : String(err);
2573
+ throw new DeployError(`Deployment failed: ${msg}`, { cause: err });
2081
2574
  } finally {
2082
2575
  try {
2083
2576
  rmSync(tarPath);
@@ -2085,6 +2578,8 @@ async function deployCommand(options) {
2085
2578
  }
2086
2579
  }
2087
2580
  }
2581
+ phases.push({ name: "Upload", durationMs: Date.now() - phaseStart });
2582
+ const elapsedSec = (Date.now() - startTime) / 1e3;
2088
2583
  const configToSave = {
2089
2584
  ...projectConfig,
2090
2585
  envVars: projectConfig.envVars.map(({ value: _stripped, ...rest }) => rest)
@@ -2097,61 +2592,747 @@ async function deployCommand(options) {
2097
2592
  deployedAt: (/* @__PURE__ */ new Date()).toISOString(),
2098
2593
  status: "success"
2099
2594
  };
2100
- writeProjectConfig({ project: configToSave, deployment: deploymentState }, cwd);
2101
- const gitignorePath = join6(cwd, ".gitignore");
2102
- if (existsSync5(gitignorePath)) {
2103
- const gitignoreContent = readFileSync5(gitignorePath, "utf-8");
2595
+ const deployRecord = {
2596
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2597
+ url: liveUrl,
2598
+ duration: elapsedSec,
2599
+ files: deployFileCount,
2600
+ size: ui.formatBytes(deployTotalBytes),
2601
+ framework: projectConfig.framework
2602
+ };
2603
+ const existingHistory = existingConfig.deployments ?? [];
2604
+ const updatedHistory = [deployRecord, ...existingHistory].slice(0, 20);
2605
+ writeProjectConfig({
2606
+ project: configToSave,
2607
+ deployment: deploymentState,
2608
+ deployments: updatedHistory
2609
+ }, cwd);
2610
+ const gitignorePath = join8(cwd, ".gitignore");
2611
+ if (existsSync6(gitignorePath)) {
2612
+ const gitignoreContent = readFileSync7(gitignorePath, "utf-8");
2104
2613
  const lines = gitignoreContent.split("\n").map((l) => l.trim());
2105
2614
  if (!lines.includes("shipem.json")) {
2106
2615
  appendFileSync(gitignorePath, "\n# Shipem config\nshipem.json\n");
2107
2616
  }
2108
2617
  } else {
2109
- writeFileSync2(gitignorePath, "# Shipem config\nshipem.json\n");
2618
+ writeFileSync4(gitignorePath, "# Shipem config\nshipem.json\n");
2110
2619
  }
2111
- const elapsedSec = (Date.now() - startTime) / 1e3;
2112
- ui.deployBox(projectConfig.name, liveUrl, elapsedSec, deployFileCount, deployTotalBytes);
2620
+ ui.deployBoxEnhanced(
2621
+ projectConfig.name,
2622
+ liveUrl,
2623
+ phases,
2624
+ deployFileCount,
2625
+ deployTotalBytes,
2626
+ elapsedSec,
2627
+ isAnonymous
2628
+ );
2113
2629
  }
2114
2630
 
2115
- // src/commands/status.ts
2116
- import chalk5 from "chalk";
2117
- async function statusCommand() {
2118
- const cwd = process.cwd();
2119
- const config = readProjectConfig(cwd);
2120
- if (!config.deployment) {
2121
- ui.warn("No deployment found in this directory.");
2122
- ui.dim("Run `shipem` to deploy your app.");
2123
- process.exit(0);
2631
+ // src/commands/env.ts
2632
+ import { existsSync as existsSync7, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync5 } from "fs";
2633
+ import { join as join9 } from "path";
2634
+ var SERVICE_LINKS = {
2635
+ SUPABASE_URL: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2636
+ SUPABASE_ANON_KEY: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2637
+ NEXT_PUBLIC_SUPABASE_URL: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2638
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2639
+ VITE_SUPABASE_URL: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2640
+ VITE_SUPABASE_ANON_KEY: { name: "Supabase", url: "https://app.supabase.com/project/_/settings/api" },
2641
+ OPENAI_API_KEY: { name: "OpenAI", url: "https://platform.openai.com/api-keys" },
2642
+ ANTHROPIC_API_KEY: { name: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
2643
+ STRIPE_SECRET_KEY: { name: "Stripe", url: "https://dashboard.stripe.com/apikeys" },
2644
+ STRIPE_PUBLISHABLE_KEY: { name: "Stripe", url: "https://dashboard.stripe.com/apikeys" },
2645
+ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: { name: "Stripe", url: "https://dashboard.stripe.com/apikeys" },
2646
+ FIREBASE_API_KEY: { name: "Firebase", url: "https://console.firebase.google.com/project/_/settings/general" },
2647
+ NEXT_PUBLIC_FIREBASE_API_KEY: { name: "Firebase", url: "https://console.firebase.google.com/project/_/settings/general" },
2648
+ FIREBASE_PROJECT_ID: { name: "Firebase", url: "https://console.firebase.google.com/project/_/settings/general" },
2649
+ DATABASE_URL: { name: "Database", url: "" },
2650
+ RESEND_API_KEY: { name: "Resend", url: "https://resend.com/api-keys" },
2651
+ CLERK_SECRET_KEY: { name: "Clerk", url: "https://dashboard.clerk.com/last-active?path=api-keys" },
2652
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: { name: "Clerk", url: "https://dashboard.clerk.com/last-active?path=api-keys" }
2653
+ };
2654
+ function scanSourceForEnvVars(cwd) {
2655
+ const found = /* @__PURE__ */ new Set();
2656
+ const patterns = [
2657
+ /process\.env\.([A-Z_][A-Z0-9_]*)/g,
2658
+ /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
2659
+ /(?:NEXT_PUBLIC_|VITE_|NUXT_|GATSBY_)([A-Z0-9_]*)/g
2660
+ ];
2661
+ function scanDir(dir, depth) {
2662
+ if (depth > 5) return;
2663
+ try {
2664
+ const entries = readdirSync6(dir, { withFileTypes: true });
2665
+ for (const entry of entries) {
2666
+ if (["node_modules", ".git", "dist", ".next", "build", ".svelte-kit", ".output", ".turbo", "coverage"].includes(entry.name)) continue;
2667
+ const fullPath = join9(dir, entry.name);
2668
+ if (entry.isDirectory()) {
2669
+ scanDir(fullPath, depth + 1);
2670
+ } else if (/\.(ts|tsx|js|jsx|mjs|mts|vue|svelte|astro)$/.test(entry.name)) {
2671
+ try {
2672
+ const content = readFileSync8(fullPath, "utf-8");
2673
+ for (const pat of patterns) {
2674
+ pat.lastIndex = 0;
2675
+ let match;
2676
+ while ((match = pat.exec(content)) !== null) {
2677
+ if (pat.source.includes("NEXT_PUBLIC_") && !match[0].includes("process.env") && !match[0].includes("import.meta.env")) {
2678
+ found.add(match[0]);
2679
+ } else {
2680
+ found.add(match[1]);
2681
+ }
2682
+ }
2683
+ }
2684
+ } catch {
2685
+ }
2686
+ }
2687
+ }
2688
+ } catch {
2689
+ }
2124
2690
  }
2125
- const { deployment, project } = config;
2126
- ui.section("Deployment Status");
2127
- ui.br();
2128
- ui.kv("App", deployment.projectName);
2129
- ui.kv("URL", chalk5.blue.underline(deployment.url));
2130
- ui.kv("Deployed", new Date(deployment.deployedAt).toLocaleString());
2131
- ui.kv("Target", deployment.deployTarget);
2132
- ui.br();
2133
- if ((deployment.deployTarget === "cloudflare-pages" || deployment.deployTarget === "cloudflare-workers") && deployment.cloudflareProjectName) {
2134
- const cfCreds = getCloudflareCredentials();
2135
- if (cfCreds) {
2136
- const spinner = ui.spinner("Fetching live status from Cloudflare");
2137
- const cf = new CloudflarePages(cfCreds.apiToken, cfCreds.accountId);
2138
- try {
2139
- const status = await cf.getDeploymentStatus(
2140
- deployment.cloudflareProjectName,
2141
- deployment.deploymentId
2691
+ scanDir(cwd, 0);
2692
+ const ignore = /* @__PURE__ */ new Set(["NODE_ENV", "PORT", "HOST", "CI", "HOME", "PWD", "PATH", "TERM", "USER", "SHELL", "LANG", "TZ"]);
2693
+ return [...found].filter((v) => !ignore.has(v)).sort();
2694
+ }
2695
+ function readEnvFile(cwd) {
2696
+ const vars = /* @__PURE__ */ new Set();
2697
+ const envFiles = [".env", ".env.local", ".env.development", ".env.production"];
2698
+ for (const file of envFiles) {
2699
+ const content = readFile2(join9(cwd, file));
2700
+ if (content) {
2701
+ for (const line of content.split("\n")) {
2702
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
2703
+ if (match) vars.add(match[1]);
2704
+ }
2705
+ }
2706
+ }
2707
+ return vars;
2708
+ }
2709
+ function readFile2(path) {
2710
+ try {
2711
+ return readFileSync8(path, "utf-8");
2712
+ } catch {
2713
+ return null;
2714
+ }
2715
+ }
2716
+ async function envCommand() {
2717
+ const cwd = process.cwd();
2718
+ console.log("");
2719
+ console.log(` ${brand.blue.bold("\u{1F511} Environment Variable Intelligence")}`);
2720
+ console.log("");
2721
+ const spinner = ui.spinner("Scanning source code for environment variables...");
2722
+ const sourceVars = scanSourceForEnvVars(cwd);
2723
+ const envFileVars = readEnvFile(cwd);
2724
+ spinner.succeed(`Found ${sourceVars.length} env vars referenced in code`);
2725
+ console.log("");
2726
+ if (sourceVars.length === 0) {
2727
+ ui.success("No environment variables detected in source code.");
2728
+ ui.br();
2729
+ return;
2730
+ }
2731
+ const present = [];
2732
+ const missing = [];
2733
+ const inEnv = [];
2734
+ for (const v of sourceVars) {
2735
+ if (process.env[v]) {
2736
+ inEnv.push(v);
2737
+ } else if (envFileVars.has(v)) {
2738
+ present.push(v);
2739
+ } else {
2740
+ missing.push(v);
2741
+ }
2742
+ }
2743
+ if (present.length > 0) {
2744
+ console.log(` ${brand.green("\u2713")} ${brand.bold("Set in .env file:")}`);
2745
+ for (const v of present) {
2746
+ console.log(` ${brand.green("\xB7")} ${v}`);
2747
+ }
2748
+ console.log("");
2749
+ }
2750
+ if (inEnv.length > 0) {
2751
+ console.log(` ${brand.green("\u2713")} ${brand.bold("Set in environment:")}`);
2752
+ for (const v of inEnv) {
2753
+ console.log(` ${brand.green("\xB7")} ${v}`);
2754
+ }
2755
+ console.log("");
2756
+ }
2757
+ if (missing.length > 0) {
2758
+ console.log(` ${brand.yellow("\u26A0")} ${brand.bold("Missing (not set):")}`);
2759
+ for (const v of missing) {
2760
+ const service = SERVICE_LINKS[v];
2761
+ if (service && service.url) {
2762
+ console.log(` ${brand.red("\xB7")} ${v} ${brand.dim("\u2192")} ${brand.brightBlue.underline(service.url)}`);
2763
+ } else {
2764
+ console.log(` ${brand.red("\xB7")} ${v}`);
2765
+ }
2766
+ }
2767
+ console.log("");
2768
+ }
2769
+ const envExamplePath = join9(cwd, ".env.example");
2770
+ if (!existsSync7(envExamplePath) && sourceVars.length > 0) {
2771
+ const content = sourceVars.map((v) => {
2772
+ const service = SERVICE_LINKS[v];
2773
+ const comment = service ? ` # ${service.name}` : "";
2774
+ return `${v}=${comment}`;
2775
+ }).join("\n") + "\n";
2776
+ writeFileSync5(envExamplePath, content, "utf-8");
2777
+ ui.success("Generated .env.example from detected variables");
2778
+ console.log("");
2779
+ } else if (existsSync7(envExamplePath)) {
2780
+ ui.dim(".env.example already exists");
2781
+ console.log("");
2782
+ }
2783
+ const total = sourceVars.length;
2784
+ const setCount = present.length + inEnv.length;
2785
+ if (missing.length === 0) {
2786
+ ui.success(`All ${total} environment variables are set.`);
2787
+ } else {
2788
+ ui.warn(`${missing.length} of ${total} env vars are missing. Your build may fail.`);
2789
+ ui.dim("Set them in a .env file or export them in your shell.");
2790
+ }
2791
+ console.log("");
2792
+ }
2793
+
2794
+ // src/commands/init.ts
2795
+ import inquirer2 from "inquirer";
2796
+ import { execa as execa3 } from "execa";
2797
+ import { existsSync as existsSync8, writeFileSync as writeFileSync6, mkdirSync } from "fs";
2798
+ import { join as join10 } from "path";
2799
+ import chalk6 from "chalk";
2800
+
2801
+ // src/commands/templates.ts
2802
+ import chalk5 from "chalk";
2803
+ var TEMPLATES = [
2804
+ {
2805
+ name: "astro-blog",
2806
+ description: "Astro blog with Markdown/MDX support and RSS feed",
2807
+ framework: "astro",
2808
+ createCmd: ["npm", "create", "astro@latest", "--", "--template", "blog"],
2809
+ postScaffold: ["npx", "astro", "add", "mdx", "--yes"]
2810
+ },
2811
+ {
2812
+ name: "nextjs-saas",
2813
+ description: "Next.js SaaS starter with App Router, Tailwind CSS, and TypeScript",
2814
+ framework: "nextjs",
2815
+ createCmd: ["npx", "create-next-app@latest", "--", "--ts", "--app", "--tailwind", "--eslint", "--src-dir", "--no-import-alias"]
2816
+ },
2817
+ {
2818
+ name: "vite-react",
2819
+ description: "Vite + React + TypeScript \u2014 fast SPA starter",
2820
+ framework: "vite-react",
2821
+ createCmd: ["npm", "create", "vite@latest", "--", "--template", "react-ts"]
2822
+ },
2823
+ {
2824
+ name: "sveltekit-app",
2825
+ description: "SvelteKit full-stack app with TypeScript",
2826
+ framework: "sveltekit",
2827
+ createCmd: ["npm", "create", "svelte@latest"]
2828
+ },
2829
+ {
2830
+ name: "static-portfolio",
2831
+ description: "Minimal static HTML/CSS portfolio \u2014 no build step required",
2832
+ framework: "static-html",
2833
+ createCmd: []
2834
+ // No create tool — we scaffold manually
2835
+ }
2836
+ ];
2837
+ function getTemplate(name) {
2838
+ return TEMPLATES.find((t) => t.name.toLowerCase() === name.toLowerCase());
2839
+ }
2840
+ function getTemplateNames() {
2841
+ return TEMPLATES.map((t) => t.name);
2842
+ }
2843
+ async function templatesCommand() {
2844
+ ui.banner();
2845
+ console.log(` ${brand.bold("Available templates")}`);
2846
+ console.log(` ${brand.dim("Use with: shipem init --template <name>")}`);
2847
+ console.log("");
2848
+ const maxNameLen = Math.max(...TEMPLATES.map((t) => t.name.length));
2849
+ for (const tpl of TEMPLATES) {
2850
+ const padding = " ".repeat(maxNameLen - tpl.name.length);
2851
+ console.log(
2852
+ ` ${chalk5.cyan(tpl.name)}${padding} ${brand.dim("\u2014")} ${tpl.description}`
2853
+ );
2854
+ console.log(
2855
+ ` ${" ".repeat(maxNameLen)} ${brand.dim(`Framework: ${tpl.framework}`)}`
2856
+ );
2857
+ console.log("");
2858
+ }
2859
+ console.log(` ${brand.bold("Quick start:")}`);
2860
+ console.log(` ${chalk5.cyan("npx shipem init --template astro-blog")}`);
2861
+ console.log(` ${chalk5.cyan("npx shipem init --template nextjs-saas")}`);
2862
+ console.log("");
2863
+ }
2864
+
2865
+ // src/commands/init.ts
2866
+ var FRAMEWORKS = [
2867
+ {
2868
+ name: "Astro (static sites, blogs, portfolios)",
2869
+ value: "astro",
2870
+ createCmd: ["npm", "create", "astro@latest", "--", "--template", "basics"],
2871
+ keywords: ["portfolio", "blog", "personal", "marketing", "docs", "documentation", "static"]
2872
+ },
2873
+ {
2874
+ name: "Next.js (full-stack React, dashboards, SaaS)",
2875
+ value: "nextjs",
2876
+ createCmd: ["npx", "create-next-app@latest", "--", "--ts", "--app", "--tailwind", "--eslint", "--src-dir", "--no-import-alias"],
2877
+ keywords: ["dashboard", "saas", "app", "application", "admin", "full-stack", "fullstack", "e-commerce", "ecommerce"]
2878
+ },
2879
+ {
2880
+ name: "Vite + React (SPAs, landing pages)",
2881
+ value: "vite-react",
2882
+ createCmd: ["npm", "create", "vite@latest", "--", "--template", "react-ts"],
2883
+ keywords: ["landing", "page", "spa", "single", "react", "frontend", "form", "calculator"]
2884
+ },
2885
+ {
2886
+ name: "Vite + Vue (SPAs with Vue)",
2887
+ value: "vite-vue",
2888
+ createCmd: ["npm", "create", "vite@latest", "--", "--template", "vue-ts"],
2889
+ keywords: ["vue"]
2890
+ },
2891
+ {
2892
+ name: "SvelteKit (Svelte full-stack)",
2893
+ value: "sveltekit",
2894
+ createCmd: ["npm", "create", "svelte@latest"],
2895
+ keywords: ["svelte"]
2896
+ }
2897
+ ];
2898
+ function matchFramework(description) {
2899
+ const lower = description.toLowerCase();
2900
+ let bestScore = 0;
2901
+ let best = FRAMEWORKS[0];
2902
+ for (const fw of FRAMEWORKS) {
2903
+ let score = 0;
2904
+ for (const keyword of fw.keywords) {
2905
+ if (lower.includes(keyword)) score += 1;
2906
+ }
2907
+ if (score > bestScore) {
2908
+ bestScore = score;
2909
+ best = fw;
2910
+ }
2911
+ }
2912
+ return best;
2913
+ }
2914
+ function generateProjectName(description) {
2915
+ return description.toLowerCase().replace(/[^a-z0-9\s]/g, "").trim().split(/\s+/).slice(0, 3).join("-").slice(0, 30) || "my-app";
2916
+ }
2917
+ async function initCommand(options = {}) {
2918
+ ui.banner();
2919
+ console.log(` ${brand.bold("Create a new project")}`);
2920
+ console.log(` ${brand.dim("From idea to live website in 2 commands")}`);
2921
+ console.log("");
2922
+ if (options.template) {
2923
+ const tpl = getTemplate(options.template);
2924
+ if (!tpl) {
2925
+ ui.error(`Unknown template: ${options.template}`);
2926
+ ui.br();
2927
+ ui.info("Available templates:");
2928
+ for (const name of getTemplateNames()) {
2929
+ ui.dim(` ${name}`);
2930
+ }
2931
+ ui.br();
2932
+ ui.dim("Run `shipem templates` to see descriptions.");
2933
+ ui.br();
2934
+ process.exit(1);
2935
+ }
2936
+ await scaffoldFromTemplate(tpl, options);
2937
+ return;
2938
+ }
2939
+ const { description } = await inquirer2.prompt([
2940
+ {
2941
+ type: "input",
2942
+ name: "description",
2943
+ message: "What are you building? (describe in plain English)",
2944
+ validate: (v) => v.trim().length > 0 || "Please describe your project"
2945
+ }
2946
+ ]);
2947
+ const matched = matchFramework(description);
2948
+ const projectName = generateProjectName(description);
2949
+ console.log("");
2950
+ ui.info(`Matched: ${ui.framework(matched.value)} \u2014 ${matched.name.split("(")[0].trim()}`);
2951
+ if (!options.yes) {
2952
+ const { framework } = await inquirer2.prompt([
2953
+ {
2954
+ type: "list",
2955
+ name: "framework",
2956
+ message: "Use this framework?",
2957
+ choices: [
2958
+ { name: `${matched.name} (recommended)`, value: matched.value },
2959
+ ...FRAMEWORKS.filter((f) => f.value !== matched.value).map((f) => ({
2960
+ name: f.name,
2961
+ value: f.value
2962
+ }))
2963
+ ]
2964
+ }
2965
+ ]);
2966
+ const selected = FRAMEWORKS.find((f) => f.value === framework) ?? matched;
2967
+ if (selected.value !== matched.value) {
2968
+ Object.assign(matched, selected);
2969
+ }
2970
+ }
2971
+ console.log("");
2972
+ const scaffoldSpinner = ui.spinner(`Scaffolding with ${matched.value}...`);
2973
+ try {
2974
+ const cmd = [...matched.createCmd];
2975
+ if (matched.value === "astro") {
2976
+ cmd.push(projectName);
2977
+ } else if (matched.value === "nextjs") {
2978
+ const dashIdx = cmd.indexOf("--");
2979
+ cmd.splice(dashIdx, 0, projectName);
2980
+ } else {
2981
+ const dashIdx = cmd.indexOf("--");
2982
+ if (dashIdx >= 0) {
2983
+ cmd.splice(dashIdx, 0, projectName);
2984
+ } else {
2985
+ cmd.push(projectName);
2986
+ }
2987
+ }
2988
+ await execa3(cmd[0], cmd.slice(1), {
2989
+ cwd: process.cwd(),
2990
+ timeout: 12e4,
2991
+ stdio: "pipe"
2992
+ });
2993
+ scaffoldSpinner.succeed(`Created ${projectName}/`);
2994
+ } catch (err) {
2995
+ scaffoldSpinner.fail("Scaffolding failed");
2996
+ const msg = err instanceof Error ? err.message : String(err);
2997
+ ui.dim(msg);
2998
+ ui.br();
2999
+ ui.info("Creating minimal project instead...");
3000
+ createMinimalProject(projectName, description);
3001
+ }
3002
+ const projectDir = join10(process.cwd(), projectName);
3003
+ if (existsSync8(projectDir)) {
3004
+ writeFileSync6(
3005
+ join10(projectDir, "shipem.json"),
3006
+ JSON.stringify({ project: { name: projectName, framework: matched.value } }, null, 2) + "\n",
3007
+ "utf-8"
3008
+ );
3009
+ }
3010
+ console.log("");
3011
+ console.log(` ${brand.green.bold("Ready!")} Now run:`);
3012
+ console.log("");
3013
+ console.log(` ${chalk6.cyan(`cd ${projectName} && npx shipem`)}`);
3014
+ console.log("");
3015
+ }
3016
+ async function scaffoldFromTemplate(tpl, options) {
3017
+ let projectName;
3018
+ if (options.yes) {
3019
+ projectName = `my-${tpl.name}`;
3020
+ } else {
3021
+ const { name } = await inquirer2.prompt([
3022
+ {
3023
+ type: "input",
3024
+ name: "name",
3025
+ message: "Project name?",
3026
+ default: `my-${tpl.name}`,
3027
+ validate: (v) => v.trim().length > 0 || "Please enter a project name"
3028
+ }
3029
+ ]);
3030
+ projectName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 30) || `my-${tpl.name}`;
3031
+ }
3032
+ ui.info(`Template: ${chalk6.cyan(tpl.name)} \u2014 ${tpl.description}`);
3033
+ ui.br();
3034
+ if (tpl.createCmd.length === 0) {
3035
+ const scaffoldSpinner = ui.spinner(`Creating ${projectName}/...`);
3036
+ createStaticPortfolio(projectName);
3037
+ scaffoldSpinner.succeed(`Created ${projectName}/`);
3038
+ } else {
3039
+ const scaffoldSpinner = ui.spinner(`Scaffolding ${tpl.name}...`);
3040
+ try {
3041
+ const cmd = [...tpl.createCmd];
3042
+ if (tpl.framework === "astro") {
3043
+ cmd.push(projectName);
3044
+ } else if (tpl.framework === "nextjs") {
3045
+ const dashIdx = cmd.indexOf("--");
3046
+ cmd.splice(dashIdx, 0, projectName);
3047
+ } else {
3048
+ const dashIdx = cmd.indexOf("--");
3049
+ if (dashIdx >= 0) {
3050
+ cmd.splice(dashIdx, 0, projectName);
3051
+ } else {
3052
+ cmd.push(projectName);
3053
+ }
3054
+ }
3055
+ await execa3(cmd[0], cmd.slice(1), {
3056
+ cwd: process.cwd(),
3057
+ timeout: 12e4,
3058
+ stdio: "pipe"
3059
+ });
3060
+ scaffoldSpinner.succeed(`Created ${projectName}/`);
3061
+ if (tpl.postScaffold && tpl.postScaffold.length > 0) {
3062
+ const postSpinner = ui.spinner("Running post-scaffold setup...");
3063
+ try {
3064
+ await execa3(tpl.postScaffold[0], tpl.postScaffold.slice(1), {
3065
+ cwd: join10(process.cwd(), projectName),
3066
+ timeout: 12e4,
3067
+ stdio: "pipe"
3068
+ });
3069
+ postSpinner.succeed("Post-scaffold setup complete");
3070
+ } catch {
3071
+ postSpinner.fail("Post-scaffold setup failed (non-critical)");
3072
+ }
3073
+ }
3074
+ } catch (err) {
3075
+ scaffoldSpinner.fail("Scaffolding failed");
3076
+ const msg = err instanceof Error ? err.message : String(err);
3077
+ ui.dim(msg);
3078
+ ui.br();
3079
+ ui.info("Creating minimal project instead...");
3080
+ createMinimalProject(projectName, tpl.description);
3081
+ }
3082
+ }
3083
+ const projectDir = join10(process.cwd(), projectName);
3084
+ if (existsSync8(projectDir)) {
3085
+ writeFileSync6(
3086
+ join10(projectDir, "shipem.json"),
3087
+ JSON.stringify({ project: { name: projectName, framework: tpl.framework } }, null, 2) + "\n",
3088
+ "utf-8"
3089
+ );
3090
+ }
3091
+ console.log("");
3092
+ console.log(` ${brand.green.bold("Ready!")} Now run:`);
3093
+ console.log("");
3094
+ console.log(` ${chalk6.cyan(`cd ${projectName} && npx shipem`)}`);
3095
+ console.log("");
3096
+ }
3097
+ function createMinimalProject(projectName, description) {
3098
+ const dir = join10(process.cwd(), projectName);
3099
+ if (!existsSync8(dir)) {
3100
+ mkdirSync(dir, { recursive: true });
3101
+ writeFileSync6(join10(dir, "index.html"), `<!DOCTYPE html>
3102
+ <html lang="en">
3103
+ <head>
3104
+ <meta charset="UTF-8">
3105
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3106
+ <title>${description}</title>
3107
+ <style>
3108
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3109
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #0a0a0a; color: #ededed; }
3110
+ h1 { font-size: 2.5rem; margin-bottom: 1rem; }
3111
+ p { color: #888; }
3112
+ </style>
3113
+ </head>
3114
+ <body>
3115
+ <div style="text-align: center">
3116
+ <h1>${description}</h1>
3117
+ <p>Built with Shipem</p>
3118
+ </div>
3119
+ </body>
3120
+ </html>`, "utf-8");
3121
+ ui.success(`Created ${projectName}/ with minimal HTML`);
3122
+ }
3123
+ }
3124
+ function createStaticPortfolio(projectName) {
3125
+ const dir = join10(process.cwd(), projectName);
3126
+ mkdirSync(dir, { recursive: true });
3127
+ writeFileSync6(join10(dir, "index.html"), `<!DOCTYPE html>
3128
+ <html lang="en">
3129
+ <head>
3130
+ <meta charset="UTF-8">
3131
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3132
+ <title>My Portfolio</title>
3133
+ <link rel="stylesheet" href="style.css">
3134
+ </head>
3135
+ <body>
3136
+ <header>
3137
+ <h1>Hello, I'm <span class="accent">Your Name</span></h1>
3138
+ <p>Developer &middot; Designer &middot; Creator</p>
3139
+ </header>
3140
+ <main>
3141
+ <section class="projects">
3142
+ <h2>Projects</h2>
3143
+ <div class="grid">
3144
+ <div class="card">
3145
+ <h3>Project One</h3>
3146
+ <p>A brief description of this project.</p>
3147
+ </div>
3148
+ <div class="card">
3149
+ <h3>Project Two</h3>
3150
+ <p>A brief description of this project.</p>
3151
+ </div>
3152
+ <div class="card">
3153
+ <h3>Project Three</h3>
3154
+ <p>A brief description of this project.</p>
3155
+ </div>
3156
+ </div>
3157
+ </section>
3158
+ </main>
3159
+ <footer>
3160
+ <p>Shipped with Shipem</p>
3161
+ </footer>
3162
+ </body>
3163
+ </html>`, "utf-8");
3164
+ writeFileSync6(join10(dir, "style.css"), `* { margin: 0; padding: 0; box-sizing: border-box; }
3165
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #ededed; min-height: 100vh; }
3166
+ header { text-align: center; padding: 4rem 1rem 2rem; }
3167
+ header h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
3168
+ .accent { color: #3B82F6; }
3169
+ header p { color: #888; font-size: 1.1rem; }
3170
+ main { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
3171
+ h2 { margin-bottom: 1.5rem; color: #ccc; }
3172
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; }
3173
+ .card { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 1.5rem; }
3174
+ .card h3 { margin-bottom: 0.5rem; }
3175
+ .card p { color: #888; font-size: 0.9rem; }
3176
+ footer { text-align: center; padding: 3rem 1rem; color: #555; font-size: 0.85rem; }
3177
+ `, "utf-8");
3178
+ }
3179
+
3180
+ // src/commands/dev.ts
3181
+ import { execa as execa4 } from "execa";
3182
+ import chalk7 from "chalk";
3183
+ var DEV_COMMANDS = {
3184
+ nextjs: ["npx", "next", "dev"],
3185
+ astro: ["npx", "astro", "dev"],
3186
+ sveltekit: ["npx", "vite", "dev"],
3187
+ nuxt: ["npx", "nuxi", "dev"],
3188
+ remix: ["npx", "remix", "dev"],
3189
+ gatsby: ["npx", "gatsby", "develop"],
3190
+ "vite-react": ["npx", "vite"],
3191
+ "vite-vue": ["npx", "vite"],
3192
+ "vite-svelte": ["npx", "vite"],
3193
+ "create-react-app": ["npx", "react-scripts", "start"]
3194
+ };
3195
+ async function devCommand() {
3196
+ const cwd = process.cwd();
3197
+ ui.section("Shipem Dev");
3198
+ ui.br();
3199
+ const detection = scanProject(cwd);
3200
+ const devCmd = DEV_COMMANDS[detection.framework];
3201
+ if (!devCmd) {
3202
+ ui.warn(`No dev server known for framework: ${detection.framework}`);
3203
+ ui.dim("Start your dev server manually, then press [d] to deploy.");
3204
+ ui.br();
3205
+ return;
3206
+ }
3207
+ ui.success(`Detected: ${ui.framework(detection.framework)}`);
3208
+ ui.info(`Starting: ${devCmd.join(" ")}`);
3209
+ ui.br();
3210
+ let devProcess = null;
3211
+ try {
3212
+ devProcess = execa4(devCmd[0], devCmd.slice(1), {
3213
+ cwd,
3214
+ stdio: ["pipe", "pipe", "pipe"],
3215
+ env: { ...process.env, FORCE_COLOR: "1" }
3216
+ });
3217
+ devProcess.stdout?.on("data", (chunk) => {
3218
+ process.stdout.write(chunk);
3219
+ });
3220
+ devProcess.stderr?.on("data", (chunk) => {
3221
+ process.stderr.write(chunk);
3222
+ });
3223
+ console.log("");
3224
+ console.log(` ${brand.blue("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}`);
3225
+ console.log(` ${brand.bold("Shipem Dev")} Press ${chalk7.cyan("[d]")} to deploy Press ${chalk7.cyan("[q]")} to quit`);
3226
+ console.log(` ${brand.blue("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500")}`);
3227
+ console.log("");
3228
+ if (process.stdin.isTTY) {
3229
+ process.stdin.setRawMode(true);
3230
+ process.stdin.resume();
3231
+ process.stdin.setEncoding("utf-8");
3232
+ process.stdin.on("data", async (key) => {
3233
+ if (key === "d" || key === "D") {
3234
+ console.log("");
3235
+ ui.section("Deploying...");
3236
+ ui.br();
3237
+ if (devProcess) {
3238
+ devProcess.kill("SIGTERM");
3239
+ }
3240
+ try {
3241
+ await deployCommand({ yes: true, skipBuild: false });
3242
+ } catch (err) {
3243
+ ui.error(err instanceof Error ? err.message : "Deploy failed");
3244
+ }
3245
+ console.log("");
3246
+ ui.dim("Dev server stopped after deploy. Run `shipem dev` again to restart.");
3247
+ process.exit(0);
3248
+ }
3249
+ if (key === "q" || key === "Q" || key === "") {
3250
+ console.log("");
3251
+ ui.info("Shutting down dev server...");
3252
+ if (devProcess) {
3253
+ devProcess.kill("SIGTERM");
3254
+ }
3255
+ process.exit(0);
3256
+ }
3257
+ });
3258
+ }
3259
+ await devProcess;
3260
+ } catch (err) {
3261
+ if (devProcess?.killed) return;
3262
+ const msg = err instanceof Error ? err.message : String(err);
3263
+ if (!msg.includes("SIGTERM")) {
3264
+ ui.error(`Dev server failed: ${msg}`);
3265
+ }
3266
+ }
3267
+ }
3268
+
3269
+ // src/commands/status.ts
3270
+ import chalk8 from "chalk";
3271
+ function timeAgo2(isoStr) {
3272
+ const diff = Date.now() - new Date(isoStr).getTime();
3273
+ const mins = Math.floor(diff / 6e4);
3274
+ if (mins < 1) return "just now";
3275
+ if (mins === 1) return "1 minute ago";
3276
+ if (mins < 60) return `${mins} minutes ago`;
3277
+ const hrs = Math.floor(mins / 60);
3278
+ if (hrs === 1) return "1 hour ago";
3279
+ if (hrs < 24) return `${hrs} hours ago`;
3280
+ const days = Math.floor(hrs / 24);
3281
+ return days === 1 ? "yesterday" : `${days} days ago`;
3282
+ }
3283
+ async function statusCommand() {
3284
+ const cwd = process.cwd();
3285
+ const config = readProjectConfig(cwd);
3286
+ if (!config.deployment) {
3287
+ ui.warn("No deployment found in this directory.");
3288
+ ui.dim("Run `shipem` to deploy your app.");
3289
+ process.exit(0);
3290
+ }
3291
+ const { deployment } = config;
3292
+ const project = config.project;
3293
+ const deploys = config.deployments ?? [];
3294
+ console.log("");
3295
+ console.log(` ${brand.blue.bold("\u26A1 Shipem Status")}`);
3296
+ console.log("");
3297
+ ui.kv("Project", chalk8.bold(deployment.projectName));
3298
+ if (project) {
3299
+ ui.kv("Framework", `${ui.frameworkIcon(project.framework)} ${ui.framework(project.framework)}`);
3300
+ }
3301
+ const ago = timeAgo2(deployment.deployedAt);
3302
+ const lastDeploy = deploys[0];
3303
+ if (lastDeploy) {
3304
+ ui.kv("Last deploy", `${ago} (${lastDeploy.duration.toFixed(1)}s)`);
3305
+ } else {
3306
+ ui.kv("Last deploy", ago);
3307
+ }
3308
+ ui.kv("URL", chalk8.blue.underline(deployment.url));
3309
+ ui.kv("Deploys", `${deploys.length} total`);
3310
+ console.log("");
3311
+ if (deploys.length > 0) {
3312
+ ui.deployHistory(deploys);
3313
+ }
3314
+ if ((deployment.deployTarget === "cloudflare-pages" || deployment.deployTarget === "cloudflare-workers") && deployment.cloudflareProjectName) {
3315
+ const cfCreds = getCloudflareCredentials();
3316
+ if (cfCreds) {
3317
+ const spinner = ui.spinner("Fetching live status from Cloudflare");
3318
+ const cf = new CloudflarePages(cfCreds.apiToken, cfCreds.accountId);
3319
+ try {
3320
+ const status = await cf.getDeploymentStatus(
3321
+ deployment.cloudflareProjectName,
3322
+ deployment.deploymentId
2142
3323
  );
2143
3324
  if (status) {
2144
3325
  spinner.succeed("Status fetched");
2145
3326
  ui.kv("Deployment ID", status.id);
2146
3327
  ui.kv(
2147
3328
  "Status",
2148
- status.latest_stage?.status === "success" ? chalk5.green("\u2713 Active") : chalk5.yellow(status.latest_stage?.status ?? "unknown")
3329
+ status.latest_stage?.status === "success" ? chalk8.green("\u2713 Active") : chalk8.yellow(status.latest_stage?.status ?? "unknown")
2149
3330
  );
2150
3331
  ui.br();
2151
3332
  if (status.stages && status.stages.length > 0) {
2152
3333
  ui.section("Build stages:");
2153
3334
  for (const stage of status.stages) {
2154
- const icon = stage.status === "success" ? chalk5.green("\u2713") : stage.status === "failure" ? chalk5.red("\u2717") : chalk5.gray("\xB7");
3335
+ const icon = stage.status === "success" ? chalk8.green("\u2713") : stage.status === "failure" ? chalk8.red("\u2717") : chalk8.gray("\xB7");
2155
3336
  ui.info(`${icon} ${stage.name}`);
2156
3337
  }
2157
3338
  ui.br();
@@ -2175,7 +3356,7 @@ async function statusCommand() {
2175
3356
  }
2176
3357
 
2177
3358
  // src/commands/logs.ts
2178
- import chalk6 from "chalk";
3359
+ import chalk9 from "chalk";
2179
3360
  async function logsCommand(options) {
2180
3361
  const cwd = process.cwd();
2181
3362
  const config = readProjectConfig(cwd);
@@ -2187,18 +3368,14 @@ async function logsCommand(options) {
2187
3368
  const { deployment } = config;
2188
3369
  if (deployment.deployTarget !== "cloudflare-pages" && deployment.deployTarget !== "cloudflare-workers") {
2189
3370
  ui.warn("Log streaming is only available for Cloudflare Pages deployments.");
2190
- ui.dim(`For Fly.io apps, run: flyctl logs --app ${deployment.projectName}`);
2191
3371
  process.exit(0);
2192
3372
  }
2193
3373
  if (!deployment.cloudflareProjectName) {
2194
- ui.fatal("Missing Cloudflare project name in deployment config.");
3374
+ throw new ConfigError("Missing Cloudflare project name in deployment config.");
2195
3375
  }
2196
3376
  const cfCreds = getCloudflareCredentials();
2197
3377
  if (!cfCreds) {
2198
- ui.fatal(
2199
- "Cloudflare credentials not found.",
2200
- "Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables."
2201
- );
3378
+ throw new ConfigError("Cloudflare credentials not found. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.");
2202
3379
  }
2203
3380
  ui.section("Deployment Logs");
2204
3381
  ui.br();
@@ -2220,11 +3397,11 @@ async function logsCommand(options) {
2220
3397
  const displayLogs = logs.slice(-linesToShow);
2221
3398
  for (const line of displayLogs) {
2222
3399
  if (line.includes("ERROR") || line.includes("error") || line.includes("FAIL")) {
2223
- console.log(chalk6.red(` ${line}`));
3400
+ console.log(chalk9.red(` ${line}`));
2224
3401
  } else if (line.includes("WARN") || line.includes("warn")) {
2225
- console.log(chalk6.yellow(` ${line}`));
3402
+ console.log(chalk9.yellow(` ${line}`));
2226
3403
  } else {
2227
- console.log(chalk6.gray(` ${line}`));
3404
+ console.log(chalk9.gray(` ${line}`));
2228
3405
  }
2229
3406
  }
2230
3407
  }
@@ -2236,7 +3413,7 @@ async function logsCommand(options) {
2236
3413
  }
2237
3414
 
2238
3415
  // src/commands/down.ts
2239
- import inquirer2 from "inquirer";
3416
+ import inquirer3 from "inquirer";
2240
3417
  import axios3 from "axios";
2241
3418
  async function downCommand(options) {
2242
3419
  const cwd = process.cwd();
@@ -2253,7 +3430,7 @@ async function downCommand(options) {
2253
3430
  ui.kv("URL", deployment.url);
2254
3431
  ui.br();
2255
3432
  if (!options.yes) {
2256
- const { confirmed } = await inquirer2.prompt([
3433
+ const { confirmed } = await inquirer3.prompt([
2257
3434
  {
2258
3435
  type: "confirm",
2259
3436
  name: "confirmed",
@@ -2270,10 +3447,7 @@ async function downCommand(options) {
2270
3447
  if ((deployment.deployTarget === "cloudflare-pages" || deployment.deployTarget === "cloudflare-workers") && deployment.cloudflareProjectName) {
2271
3448
  const cfCreds = getCloudflareCredentials();
2272
3449
  if (!cfCreds) {
2273
- ui.fatal(
2274
- "Cloudflare credentials not found.",
2275
- "Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables."
2276
- );
3450
+ throw new ConfigError("Cloudflare credentials not found. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.");
2277
3451
  }
2278
3452
  const spinner = ui.spinner(`Deleting ${deployment.projectName} from Cloudflare Pages`);
2279
3453
  const cf = new CloudflarePages(cfCreds.apiToken, cfCreds.accountId);
@@ -2282,13 +3456,12 @@ async function downCommand(options) {
2282
3456
  spinner.succeed("Deployment deleted from Cloudflare Pages");
2283
3457
  } catch (err) {
2284
3458
  spinner.fail("Failed to delete deployment");
2285
- ui.error(err instanceof Error ? err.message : "Unknown error");
2286
- process.exit(1);
3459
+ throw err instanceof DeployError ? err : new DeployError(err instanceof Error ? err.message : "Unknown error", { cause: err });
2287
3460
  }
2288
3461
  } else if ((deployment.deployTarget === "cloudflare-pages" || deployment.deployTarget === "cloudflare-workers") && !deployment.cloudflareProjectName) {
2289
3462
  const token = getSessionToken();
2290
3463
  if (!token) {
2291
- ui.fatal("Not logged in. Run `shipem login` first.");
3464
+ throw new AuthError("Not logged in. Run `shipem login` first.");
2292
3465
  }
2293
3466
  const spinner = ui.spinner(`Deleting ${deployment.projectName} from Shipem`);
2294
3467
  try {
@@ -2298,16 +3471,11 @@ async function downCommand(options) {
2298
3471
  spinner.succeed("Deployment deleted");
2299
3472
  } catch (err) {
2300
3473
  spinner.fail("Failed to delete deployment");
2301
- ui.error(err instanceof Error ? err.message : "Unknown error");
2302
- process.exit(1);
3474
+ if (axios3.isAxiosError(err) && err.code && ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT"].includes(err.code)) {
3475
+ throw new NetworkError("Cannot reach Shipem servers. Check your internet connection.", { cause: err });
3476
+ }
3477
+ throw new DeployError(err instanceof Error ? err.message : "Unknown error", { cause: err });
2303
3478
  }
2304
- } else if (deployment.deployTarget === "flyio") {
2305
- ui.warn("Fly.io apps cannot be deleted automatically. Manual cleanup required:");
2306
- ui.info(`flyctl apps destroy ${deployment.projectName} --yes`);
2307
- ui.br();
2308
- ui.warn("Local deployment config has NOT been cleared \u2014 re-run after manual deletion.");
2309
- ui.br();
2310
- return;
2311
3479
  }
2312
3480
  writeProjectConfig({ project: config.project }, cwd);
2313
3481
  ui.br();
@@ -2316,8 +3484,481 @@ async function downCommand(options) {
2316
3484
  ui.br();
2317
3485
  }
2318
3486
 
2319
- // src/commands/logout.ts
3487
+ // src/commands/watch.ts
3488
+ import { watch as fsWatch } from "fs";
3489
+ import { join as join11, relative as relative2 } from "path";
3490
+ import { readdirSync as readdirSync7 } from "fs";
3491
+ import chalk10 from "chalk";
3492
+ var IGNORE_PATTERNS = /* @__PURE__ */ new Set([
3493
+ "node_modules",
3494
+ ".git",
3495
+ "dist",
3496
+ "build",
3497
+ ".shipem",
3498
+ "shipem.json",
3499
+ ".next",
3500
+ ".nuxt",
3501
+ ".output",
3502
+ ".svelte-kit",
3503
+ ".turbo",
3504
+ "coverage",
3505
+ ".DS_Store",
3506
+ ".env",
3507
+ ".env.local"
3508
+ ]);
3509
+ var DEBOUNCE_MS = 2e3;
3510
+ function timestamp() {
3511
+ const d = /* @__PURE__ */ new Date();
3512
+ return d.toLocaleTimeString("en-US", { hour12: false });
3513
+ }
3514
+ function collectDirs(dir) {
3515
+ const dirs = [dir];
3516
+ try {
3517
+ const entries = readdirSync7(dir, { withFileTypes: true });
3518
+ for (const entry of entries) {
3519
+ if (!entry.isDirectory()) continue;
3520
+ if (IGNORE_PATTERNS.has(entry.name) || entry.name.startsWith(".")) continue;
3521
+ dirs.push(...collectDirs(join11(dir, entry.name)));
3522
+ }
3523
+ } catch {
3524
+ }
3525
+ return dirs;
3526
+ }
3527
+ async function watchCommand() {
3528
+ const cwd = process.cwd();
3529
+ ui.banner();
3530
+ console.log(` ${brand.blue.bold("\u26A1")} ${brand.bold("Watching")} for changes...`);
3531
+ console.log(` Auto-deploy on save. Press ${chalk10.cyan("[q]")} to stop, ${chalk10.cyan("[d]")} to force deploy.`);
3532
+ console.log("");
3533
+ let debounceTimer = null;
3534
+ let isDeploying = false;
3535
+ let lastChangedFile = "";
3536
+ const watchers = [];
3537
+ const triggerDeploy = async (file) => {
3538
+ if (isDeploying) return;
3539
+ isDeploying = true;
3540
+ console.log(` ${brand.dim(`[${timestamp()}]`)} ${brand.blue("Deploying...")}`);
3541
+ const startTime = Date.now();
3542
+ try {
3543
+ await deployCommand({ yes: true, skipBuild: false });
3544
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
3545
+ console.log(` ${brand.dim(`[${timestamp()}]`)} ${brand.green("\u2713")} Deployed (${elapsed}s)`);
3546
+ } catch (err) {
3547
+ console.log(` ${brand.dim(`[${timestamp()}]`)} ${brand.red("\u2717")} Deploy failed: ${err instanceof Error ? err.message : String(err)}`);
3548
+ }
3549
+ isDeploying = false;
3550
+ console.log("");
3551
+ console.log(` Watching for changes... Press ${chalk10.cyan("[q]")} to stop.`);
3552
+ console.log("");
3553
+ };
3554
+ const onFileChange = (dir, filename) => {
3555
+ if (!filename) return;
3556
+ if (IGNORE_PATTERNS.has(filename)) return;
3557
+ if (isDeploying) return;
3558
+ const relPath = relative2(cwd, join11(dir, filename));
3559
+ lastChangedFile = relPath;
3560
+ console.log(` ${brand.dim(`[${timestamp()}]`)} File changed: ${chalk10.cyan(relPath)}`);
3561
+ if (debounceTimer) clearTimeout(debounceTimer);
3562
+ debounceTimer = setTimeout(() => {
3563
+ void triggerDeploy(lastChangedFile);
3564
+ }, DEBOUNCE_MS);
3565
+ };
3566
+ const dirs = collectDirs(cwd);
3567
+ for (const dir of dirs) {
3568
+ try {
3569
+ const watcher = fsWatch(dir, { persistent: true }, (_event, filename) => {
3570
+ onFileChange(dir, filename);
3571
+ });
3572
+ watchers.push(watcher);
3573
+ } catch {
3574
+ }
3575
+ }
3576
+ if (process.stdin.isTTY) {
3577
+ process.stdin.setRawMode(true);
3578
+ process.stdin.resume();
3579
+ process.stdin.setEncoding("utf-8");
3580
+ process.stdin.on("data", (key) => {
3581
+ if (key === "q" || key === "Q" || key === "") {
3582
+ console.log("");
3583
+ ui.info("Stopped watching.");
3584
+ for (const w of watchers) w.close();
3585
+ if (debounceTimer) clearTimeout(debounceTimer);
3586
+ process.exit(0);
3587
+ }
3588
+ if (key === "d" || key === "D") {
3589
+ if (debounceTimer) clearTimeout(debounceTimer);
3590
+ console.log(` ${brand.dim(`[${timestamp()}]`)} Force deploy triggered`);
3591
+ void triggerDeploy("manual");
3592
+ }
3593
+ });
3594
+ }
3595
+ await new Promise(() => {
3596
+ });
3597
+ }
3598
+
3599
+ // src/commands/history.ts
3600
+ import chalk11 from "chalk";
3601
+
3602
+ // src/memory/index.ts
3603
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync2, existsSync as existsSync9 } from "fs";
3604
+ import { join as join12 } from "path";
3605
+ import { homedir } from "os";
3606
+ var SHIPEM_HOME = join12(homedir(), ".shipem");
3607
+ var MEMORY_PATH = join12(SHIPEM_HOME, "memory.json");
3608
+ var PROJECTS_PATH = join12(SHIPEM_HOME, "projects.json");
3609
+ var FIXES_PATH = join12(SHIPEM_HOME, "fixes.json");
3610
+ var CONFIG_PATH = join12(SHIPEM_HOME, "config.json");
3611
+ function ensureDir() {
3612
+ if (!existsSync9(SHIPEM_HOME)) {
3613
+ mkdirSync2(SHIPEM_HOME, { recursive: true });
3614
+ }
3615
+ }
3616
+ function readJson2(path, fallback) {
3617
+ try {
3618
+ if (!existsSync9(path)) return fallback;
3619
+ return JSON.parse(readFileSync9(path, "utf-8"));
3620
+ } catch {
3621
+ return fallback;
3622
+ }
3623
+ }
3624
+ function writeJson(path, data) {
3625
+ ensureDir();
3626
+ writeFileSync7(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
3627
+ }
3628
+ function getMemory() {
3629
+ return readJson2(MEMORY_PATH, {
3630
+ totalDeploys: 0,
3631
+ totalFixes: 0,
3632
+ firstUsed: (/* @__PURE__ */ new Date()).toISOString()
3633
+ });
3634
+ }
3635
+ function getProjects() {
3636
+ return readJson2(PROJECTS_PATH, []);
3637
+ }
3638
+ function getGlobalConfig() {
3639
+ return readJson2(CONFIG_PATH, {});
3640
+ }
3641
+ function setWebhook(url) {
3642
+ const config = getGlobalConfig();
3643
+ if (url) {
3644
+ config.notifications = { ...config.notifications, webhook: url };
3645
+ } else {
3646
+ if (config.notifications) delete config.notifications.webhook;
3647
+ }
3648
+ writeJson(CONFIG_PATH, config);
3649
+ }
3650
+ function getWebhook() {
3651
+ return getGlobalConfig().notifications?.webhook;
3652
+ }
3653
+
3654
+ // src/commands/history.ts
3655
+ async function historyCommand() {
3656
+ const projects = getProjects();
3657
+ const memory = getMemory();
3658
+ ui.section("Shipem History \u2014 All Projects");
3659
+ ui.br();
3660
+ if (projects.length === 0) {
3661
+ ui.dim("No projects deployed yet. Run `shipem deploy` to get started.");
3662
+ ui.br();
3663
+ return;
3664
+ }
3665
+ console.log(` ${brand.dim("Total deploys:")} ${chalk11.cyan(String(memory.totalDeploys))} ${brand.dim("Total fixes:")} ${chalk11.cyan(String(memory.totalFixes))} ${brand.dim("Since:")} ${chalk11.cyan(new Date(memory.firstUsed).toLocaleDateString())}`);
3666
+ ui.br();
3667
+ for (const project of projects) {
3668
+ const avgTime = project.deployCount > 0 ? (project.totalDeployTime / project.deployCount).toFixed(1) : "0.0";
3669
+ const lastDeploy = new Date(project.lastDeploy);
3670
+ const dateStr = lastDeploy.toLocaleDateString("en-US", { month: "short", day: "numeric" });
3671
+ const timeStr = lastDeploy.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
3672
+ console.log(` ${brand.bold(project.name)} ${brand.dim(`(${project.framework})`)}`);
3673
+ console.log(` ${brand.dim("URL:")} ${brand.brightBlue.underline(project.url)}`);
3674
+ console.log(` ${brand.dim("Deploys:")} ${chalk11.cyan(String(project.deployCount))} ${brand.dim("Avg:")} ${chalk11.cyan(avgTime + "s")} ${brand.dim("Last:")} ${dateStr} ${timeStr}`);
3675
+ console.log(` ${brand.dim("Path:")} ${project.path}`);
3676
+ ui.br();
3677
+ }
3678
+ }
3679
+
3680
+ // src/commands/config.ts
3681
+ async function configCommand(action, key, value) {
3682
+ if (!action || action === "show") {
3683
+ const config = getGlobalConfig();
3684
+ ui.section("Shipem Global Config");
3685
+ ui.br();
3686
+ ui.kv("Webhook", config.notifications?.webhook ?? "(not set)");
3687
+ ui.br();
3688
+ return;
3689
+ }
3690
+ if (action === "set") {
3691
+ if (!key) {
3692
+ ui.error("Usage: shipem config set <key> <value>");
3693
+ ui.dim("Available keys: webhook");
3694
+ ui.br();
3695
+ return;
3696
+ }
3697
+ if (key === "webhook") {
3698
+ if (!value || value === "off") {
3699
+ setWebhook(null);
3700
+ ui.success("Webhook notifications disabled.");
3701
+ } else {
3702
+ setWebhook(value);
3703
+ ui.success(`Webhook set to: ${value}`);
3704
+ }
3705
+ ui.br();
3706
+ return;
3707
+ }
3708
+ ui.error(`Unknown config key: ${key}`);
3709
+ ui.dim("Available keys: webhook");
3710
+ ui.br();
3711
+ return;
3712
+ }
3713
+ ui.error(`Unknown action: ${action}`);
3714
+ ui.dim("Usage: shipem config [show|set] [key] [value]");
3715
+ ui.br();
3716
+ }
3717
+
3718
+ // src/commands/monitor.ts
3719
+ import axios5 from "axios";
3720
+ import chalk12 from "chalk";
3721
+ import { writeFileSync as writeFileSync8, readFileSync as readFileSync10, existsSync as existsSync10, unlinkSync } from "fs";
3722
+ import { join as join13 } from "path";
3723
+ import { tmpdir as tmpdir2 } from "os";
3724
+
3725
+ // src/notifications/index.ts
2320
3726
  import axios4 from "axios";
3727
+ async function sendStatusNotification(message, webhookOverride) {
3728
+ const webhook = webhookOverride ?? getWebhook();
3729
+ if (!webhook) return false;
3730
+ try {
3731
+ await axios4.post(webhook, { text: message }, { timeout: 5e3 });
3732
+ return true;
3733
+ } catch {
3734
+ return false;
3735
+ }
3736
+ }
3737
+
3738
+ // src/commands/monitor.ts
3739
+ var PID_FILE = join13(tmpdir2(), "shipem-monitor.pid");
3740
+ var DEFAULT_INTERVAL_MS = 5 * 60 * 1e3;
3741
+ async function checkSite(name, url) {
3742
+ const start = Date.now();
3743
+ try {
3744
+ const resp = await axios5.head(url, { timeout: 1e4, maxRedirects: 5 });
3745
+ return {
3746
+ name,
3747
+ url,
3748
+ status: resp.status,
3749
+ responseTime: Date.now() - start,
3750
+ up: resp.status >= 200 && resp.status < 400
3751
+ };
3752
+ } catch (err) {
3753
+ const elapsed = Date.now() - start;
3754
+ if (axios5.isAxiosError(err) && err.response) {
3755
+ return {
3756
+ name,
3757
+ url,
3758
+ status: err.response.status,
3759
+ responseTime: elapsed,
3760
+ up: false,
3761
+ error: `HTTP ${err.response.status}`
3762
+ };
3763
+ }
3764
+ return {
3765
+ name,
3766
+ url,
3767
+ status: null,
3768
+ responseTime: elapsed,
3769
+ up: false,
3770
+ error: err instanceof Error ? err.message : "Unknown error"
3771
+ };
3772
+ }
3773
+ }
3774
+ function displayResults(results) {
3775
+ console.clear();
3776
+ ui.section("Shipem Monitor");
3777
+ ui.br();
3778
+ if (results.length === 0) {
3779
+ ui.dim("No projects to monitor. Deploy a project first.");
3780
+ ui.br();
3781
+ return;
3782
+ }
3783
+ const maxName = Math.max(...results.map((r) => r.name.length));
3784
+ for (const r of results) {
3785
+ const nameCol = r.name.padEnd(maxName + 2);
3786
+ const timeCol = `${r.responseTime}ms`.padStart(7);
3787
+ if (r.up) {
3788
+ console.log(` ${brand.green("\u2713")} ${brand.bold(nameCol)} ${brand.dim(r.url.padEnd(40))} ${brand.green(`${r.status} OK`)} ${brand.dim(`(${timeCol})`)}`);
3789
+ } else {
3790
+ const statusText = r.error ?? `${r.status}`;
3791
+ console.log(` ${brand.red("\u2717")} ${brand.bold(nameCol)} ${brand.dim(r.url.padEnd(40))} ${brand.red(statusText)} ${brand.red("\u2190 DOWN!")}`);
3792
+ }
3793
+ }
3794
+ ui.br();
3795
+ }
3796
+ async function monitorCommand(options = {}) {
3797
+ if (options.stop) {
3798
+ if (existsSync10(PID_FILE)) {
3799
+ try {
3800
+ const pid = parseInt(readFileSync10(PID_FILE, "utf-8").trim(), 10);
3801
+ process.kill(pid, "SIGTERM");
3802
+ unlinkSync(PID_FILE);
3803
+ ui.success("Monitor daemon stopped.");
3804
+ } catch {
3805
+ ui.warn("Could not stop monitor daemon. It may have already exited.");
3806
+ try {
3807
+ unlinkSync(PID_FILE);
3808
+ } catch {
3809
+ }
3810
+ }
3811
+ } else {
3812
+ ui.info("No monitor daemon running.");
3813
+ }
3814
+ ui.br();
3815
+ return;
3816
+ }
3817
+ const projects = getProjects();
3818
+ if (projects.length === 0) {
3819
+ ui.section("Shipem Monitor");
3820
+ ui.br();
3821
+ ui.dim("No projects found. Deploy a project first, then run `shipem monitor`.");
3822
+ ui.br();
3823
+ return;
3824
+ }
3825
+ const intervalMs = options.interval ? parseInt(options.interval, 10) * 60 * 1e3 : DEFAULT_INTERVAL_MS;
3826
+ if (options.daemon) {
3827
+ writePidFile();
3828
+ ui.success(`Monitor daemon started (PID ${process.pid}). Checking every ${intervalMs / 6e4} minutes.`);
3829
+ ui.dim(`Stop with: shipem monitor stop`);
3830
+ ui.br();
3831
+ }
3832
+ const previousUp = /* @__PURE__ */ new Map();
3833
+ const runCheck = async () => {
3834
+ const results = await Promise.all(
3835
+ projects.map((p) => checkSite(p.name, p.url))
3836
+ );
3837
+ if (!options.daemon) {
3838
+ displayResults(results);
3839
+ const intervalMin = intervalMs / 6e4;
3840
+ console.log(` ${brand.dim(`Checking every ${intervalMin} minutes. Press ${chalk12.cyan("[q]")} to stop.`)}`);
3841
+ ui.br();
3842
+ }
3843
+ for (const r of results) {
3844
+ const wasUp = previousUp.get(r.name);
3845
+ if (wasUp !== void 0) {
3846
+ if (wasUp && !r.up) {
3847
+ await sendStatusNotification(`\u{1F534} ${r.name} is DOWN! ${r.url} \u2014 ${r.error ?? "unreachable"}`);
3848
+ } else if (!wasUp && r.up) {
3849
+ await sendStatusNotification(`\u{1F7E2} ${r.name} is back UP! ${r.url} \u2014 ${r.responseTime}ms`);
3850
+ }
3851
+ }
3852
+ previousUp.set(r.name, r.up);
3853
+ }
3854
+ };
3855
+ await runCheck();
3856
+ const timer = setInterval(() => void runCheck(), intervalMs);
3857
+ if (!options.daemon && process.stdin.isTTY) {
3858
+ process.stdin.setRawMode(true);
3859
+ process.stdin.resume();
3860
+ process.stdin.setEncoding("utf-8");
3861
+ process.stdin.on("data", (key) => {
3862
+ if (key === "q" || key === "Q" || key === "") {
3863
+ clearInterval(timer);
3864
+ console.log("");
3865
+ ui.info("Monitor stopped.");
3866
+ process.exit(0);
3867
+ }
3868
+ });
3869
+ }
3870
+ await new Promise(() => {
3871
+ });
3872
+ }
3873
+ function writePidFile() {
3874
+ writeFileSync8(PID_FILE, String(process.pid), "utf-8");
3875
+ }
3876
+
3877
+ // src/commands/hooks.ts
3878
+ import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync2, chmodSync, mkdirSync as mkdirSync3 } from "fs";
3879
+ import { join as join14 } from "path";
3880
+ var HOOK_MARKER = "# shipem-auto-deploy";
3881
+ var HOOK_CONTENT = `#!/bin/sh
3882
+ ${HOOK_MARKER}
3883
+ # Auto-deploy on commit \u2014 installed by shipem hooks install
3884
+ # Run shipem deploy in background so it doesn't block the commit
3885
+ nohup shipem deploy --yes --quiet > /dev/null 2>&1 &
3886
+ `;
3887
+ function getHookPath(cwd) {
3888
+ return join14(cwd, ".git", "hooks", "post-commit");
3889
+ }
3890
+ function isShipemHook(path) {
3891
+ if (!existsSync11(path)) return false;
3892
+ const content = readFileSync11(path, "utf-8");
3893
+ return content.includes(HOOK_MARKER);
3894
+ }
3895
+ async function hooksCommand(action) {
3896
+ const cwd = process.cwd();
3897
+ const gitDir = join14(cwd, ".git");
3898
+ if (!existsSync11(gitDir)) {
3899
+ ui.error("Not a git repository. Run `git init` first.");
3900
+ ui.br();
3901
+ return;
3902
+ }
3903
+ const hooksDir = join14(gitDir, "hooks");
3904
+ const hookPath = getHookPath(cwd);
3905
+ if (!action || action === "status") {
3906
+ ui.section("Git Hooks");
3907
+ ui.br();
3908
+ if (isShipemHook(hookPath)) {
3909
+ ui.success("post-commit hook is installed (auto-deploy on commit)");
3910
+ ui.dim(`Remove with: shipem hooks remove`);
3911
+ } else if (existsSync11(hookPath)) {
3912
+ ui.info("A post-commit hook exists but was not installed by Shipem.");
3913
+ ui.dim(`Path: ${hookPath}`);
3914
+ } else {
3915
+ ui.info("No post-commit hook installed.");
3916
+ ui.dim(`Install with: shipem hooks install`);
3917
+ }
3918
+ ui.br();
3919
+ return;
3920
+ }
3921
+ if (action === "install") {
3922
+ if (existsSync11(hookPath) && !isShipemHook(hookPath)) {
3923
+ ui.warn("A post-commit hook already exists and was not created by Shipem.");
3924
+ ui.dim(`Path: ${hookPath}`);
3925
+ ui.dim("Remove it manually or back it up before installing.");
3926
+ ui.br();
3927
+ return;
3928
+ }
3929
+ if (!existsSync11(hooksDir)) {
3930
+ mkdirSync3(hooksDir, { recursive: true });
3931
+ }
3932
+ writeFileSync9(hookPath, HOOK_CONTENT, "utf-8");
3933
+ chmodSync(hookPath, 493);
3934
+ ui.success("Installed post-commit hook \u2192 auto-deploy on commit");
3935
+ ui.dim(`Remove with: shipem hooks remove`);
3936
+ ui.br();
3937
+ return;
3938
+ }
3939
+ if (action === "remove") {
3940
+ if (!existsSync11(hookPath)) {
3941
+ ui.info("No post-commit hook to remove.");
3942
+ ui.br();
3943
+ return;
3944
+ }
3945
+ if (!isShipemHook(hookPath)) {
3946
+ ui.warn("The post-commit hook was not installed by Shipem. Not removing.");
3947
+ ui.br();
3948
+ return;
3949
+ }
3950
+ unlinkSync2(hookPath);
3951
+ ui.success("Removed post-commit hook.");
3952
+ ui.br();
3953
+ return;
3954
+ }
3955
+ ui.error(`Unknown action: ${action}`);
3956
+ ui.dim("Usage: shipem hooks [install|remove|status]");
3957
+ ui.br();
3958
+ }
3959
+
3960
+ // src/commands/logout.ts
3961
+ import axios6 from "axios";
2321
3962
  async function logoutCommand() {
2322
3963
  const token = getSessionToken();
2323
3964
  if (!token) {
@@ -2326,7 +3967,7 @@ async function logoutCommand() {
2326
3967
  return;
2327
3968
  }
2328
3969
  try {
2329
- await axios4.post(`${SHIPEM_API_URL}/auth/logout`, { token }, { timeout: 5e3 });
3970
+ await axios6.post(`${SHIPEM_API_URL}/auth/logout`, { token }, { timeout: 5e3 });
2330
3971
  } catch {
2331
3972
  }
2332
3973
  clearSessionToken();
@@ -2334,20 +3975,145 @@ async function logoutCommand() {
2334
3975
  ui.br();
2335
3976
  }
2336
3977
 
3978
+ // src/commands/preview.ts
3979
+ import { execa as execa5 } from "execa";
3980
+ import chalk13 from "chalk";
3981
+ async function getCurrentBranch() {
3982
+ try {
3983
+ const { stdout } = await execa5("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
3984
+ return stdout.trim();
3985
+ } catch {
3986
+ throw new ConfigError(
3987
+ "Could not detect git branch. Make sure you are inside a git repository."
3988
+ );
3989
+ }
3990
+ }
3991
+ function sanitizeBranchName(branch) {
3992
+ return branch.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 28);
3993
+ }
3994
+ async function previewCommand(options = {}) {
3995
+ const startTime = Date.now();
3996
+ const cwd = process.cwd();
3997
+ ui.banner();
3998
+ console.log(` ${brand.bold("Preview deployment")}`);
3999
+ console.log(` ${brand.dim("Deploy a preview from your current branch")}`);
4000
+ console.log("");
4001
+ const branch = await getCurrentBranch();
4002
+ if (branch === "main" || branch === "master") {
4003
+ ui.warn(`You are on '${branch}'. Previews are meant for feature branches.`);
4004
+ ui.dim("Use `shipem deploy` for production deployments.");
4005
+ ui.br();
4006
+ }
4007
+ ui.info(`Branch: ${chalk13.cyan(branch)}`);
4008
+ ui.br();
4009
+ const existingConfig = readProjectConfig(cwd);
4010
+ if (!existingConfig.project) {
4011
+ throw new ConfigError(
4012
+ "No shipem.json found. Run `shipem deploy` first to detect and configure your project."
4013
+ );
4014
+ }
4015
+ const projectConfig = existingConfig.project;
4016
+ const cfCreds = getCloudflareCredentials();
4017
+ if (!cfCreds) {
4018
+ throw new ConfigError(
4019
+ "Preview deployments require Cloudflare credentials.\n Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.\n Get these from: https://dash.cloudflare.com/profile/api-tokens"
4020
+ );
4021
+ }
4022
+ if (!options.skipBuild) {
4023
+ ui.section("Building...");
4024
+ ui.br();
4025
+ const buildResult = await buildProject(projectConfig, cwd);
4026
+ if (!buildResult.success) {
4027
+ throw new DeployError(
4028
+ `Build failed: ${buildResult.error ?? "Unknown error"}. Fix build errors and try again.`
4029
+ );
4030
+ }
4031
+ ui.br();
4032
+ }
4033
+ ui.section("Deploying preview...");
4034
+ ui.br();
4035
+ const cf = new CloudflarePages(cfCreds.apiToken, cfCreds.accountId);
4036
+ const cfProjectName = sanitizeProjectName(projectConfig.name);
4037
+ await cf.getOrCreateProject(cfProjectName, projectConfig);
4038
+ const result = await cf.deployBranch(
4039
+ cfProjectName,
4040
+ projectConfig.outputDirectory,
4041
+ branch,
4042
+ cwd
4043
+ );
4044
+ const finalDeployment = await cf.waitForDeployment(cfProjectName, result.deployment.id);
4045
+ const branchSlug = sanitizeBranchName(branch);
4046
+ const previewUrl = finalDeployment.url || `https://${branchSlug}.${cfProjectName}.pages.dev`;
4047
+ const previewRecord = {
4048
+ branch,
4049
+ url: previewUrl,
4050
+ deploymentId: result.deployment.id,
4051
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4052
+ status: "success"
4053
+ };
4054
+ const existingPreviews = existingConfig.previews ?? [];
4055
+ const updatedPreviews = [
4056
+ previewRecord,
4057
+ ...existingPreviews.filter((p) => p.branch !== branch)
4058
+ ].slice(0, 20);
4059
+ writeProjectConfig(
4060
+ { ...existingConfig, previews: updatedPreviews },
4061
+ cwd
4062
+ );
4063
+ const elapsedSec = (Date.now() - startTime) / 1e3;
4064
+ console.log("");
4065
+ console.log(` ${brand.green.bold("Preview live!")}`);
4066
+ console.log("");
4067
+ console.log(` ${brand.gray("Branch:")} ${chalk13.cyan(branch)}`);
4068
+ console.log(` ${brand.gray("URL:")} ${brand.brightBlue.underline(previewUrl)}`);
4069
+ console.log(` ${brand.gray("Files:")} ${result.fileCount} files (${ui.formatBytes(result.totalBytes)})`);
4070
+ console.log(` ${brand.gray("Time:")} ${elapsedSec.toFixed(1)}s`);
4071
+ console.log("");
4072
+ const copied = ui.copyToClipboard(previewUrl);
4073
+ if (copied) {
4074
+ ui.success("Preview URL copied to clipboard!");
4075
+ }
4076
+ console.log("");
4077
+ console.log(` ${brand.bold("Next steps:")}`);
4078
+ console.log(` ${brand.gray("Share")} \u2192 Send the preview URL to teammates`);
4079
+ console.log(` ${brand.gray("Update")} \u2192 ${chalk13.cyan("npx shipem preview")} (re-deploy this branch)`);
4080
+ console.log(` ${brand.gray("Promote")} \u2192 Merge to main and ${chalk13.cyan("npx shipem deploy")}`);
4081
+ console.log("");
4082
+ }
4083
+
2337
4084
  // src/index.ts
2338
- import { readFileSync as readFileSync6 } from "fs";
4085
+ import { readFileSync as readFileSync12 } from "fs";
2339
4086
  import { fileURLToPath } from "url";
2340
- import { dirname, join as join7 } from "path";
4087
+ import { dirname, join as join15 } from "path";
2341
4088
  var __filename2 = fileURLToPath(import.meta.url);
2342
4089
  var __dirname2 = dirname(__filename2);
2343
4090
  var version = "0.1.0";
2344
4091
  try {
2345
4092
  const pkg = JSON.parse(
2346
- readFileSync6(join7(__dirname2, "../package.json"), "utf-8")
4093
+ readFileSync12(join15(__dirname2, "../package.json"), "utf-8")
2347
4094
  );
2348
4095
  version = pkg.version;
2349
4096
  } catch {
2350
4097
  }
4098
+ var globalVerbose = false;
4099
+ var globalJson = false;
4100
+ function handleError(err, fallbackMsg) {
4101
+ if (globalJson) {
4102
+ const code = err instanceof ShipemError ? err.code : "ERR_UNKNOWN";
4103
+ const message = err instanceof Error ? err.message : fallbackMsg;
4104
+ console.log(JSON.stringify({ success: false, error: message, code }));
4105
+ process.exit(err instanceof ShipemError ? err.exitCode : 2);
4106
+ }
4107
+ ui.br();
4108
+ if (err instanceof ShipemError) {
4109
+ ui.error(err.message);
4110
+ if (globalVerbose || process.env.DEBUG) console.error(err);
4111
+ process.exit(err.exitCode);
4112
+ }
4113
+ ui.error(err instanceof Error ? err.message : fallbackMsg);
4114
+ if (globalVerbose || process.env.DEBUG) console.error(err);
4115
+ process.exit(2);
4116
+ }
2351
4117
  var program = new Command();
2352
4118
  program.name("shipem").description(
2353
4119
  "One-command deployment for apps built by AI coding tools.\n\nYour AI built it. We'll ship it."
@@ -2356,61 +4122,136 @@ program.name("shipem").description(
2356
4122
  return "";
2357
4123
  }).addHelpText("after", `
2358
4124
  Quick start:
2359
- npx shipem Deploy your app
2360
- npx shipem login Authenticate with GitHub
2361
- npx shipem status Check deployment status
2362
- npx shipem down Take your app offline
4125
+ npx shipem Deploy your app
4126
+ npx shipem preview Deploy a preview from your branch
4127
+ npx shipem watch Watch for changes and auto-deploy
4128
+ npx shipem init Bootstrap a new project
4129
+ npx shipem init -t <t> Init from a template
4130
+ npx shipem templates List available templates
4131
+ npx shipem fix Auto-fix build errors
4132
+ npx shipem env Check environment variables
4133
+ npx shipem dev Start dev server with deploy shortcut
4134
+ npx shipem status Check deployment status
4135
+ npx shipem history Show deploy history across projects
4136
+ npx shipem monitor Monitor site uptime
4137
+ npx shipem hooks Manage git deploy hooks
4138
+ npx shipem config Manage global settings
4139
+ npx shipem login Authenticate with GitHub
4140
+ npx shipem down Take your app offline
2363
4141
  `);
2364
- program.command("deploy", { isDefault: true }).description("Detect and deploy your app (default command)").option("-y, --yes", "Skip all prompts and use defaults").option("-n, --name <name>", "Override app name").option("--skip-build", "Skip the build step (deploy existing output directory)").option("--direct", "Deploy directly using your Cloudflare credentials (bypass Shipem API)").action(async (options) => {
4142
+ program.command("deploy", { isDefault: true }).description("Detect and deploy your app (default command)").option("-y, --yes", "Skip all prompts and use defaults").option("-n, --name <name>", "Override app name").option("--skip-build", "Skip the build step (deploy existing output directory)").option("--turbo", "Skip the build step (alias for --skip-build)").option("--package <name>", "Deploy a specific package from a monorepo").option("--direct", "Deploy directly using your Cloudflare credentials (bypass Shipem API)").option("--verbose", "Show detailed debug output").option("--json", "Output machine-readable JSON instead of human-friendly text").option("--notify [url]", "Send deploy notification to webhook").option("--quiet", "Minimal output (just URL on success)").action(async (options) => {
4143
+ globalVerbose = options.verbose ?? false;
4144
+ globalJson = options.json ?? false;
2365
4145
  try {
2366
4146
  await deployCommand(options);
2367
4147
  } catch (err) {
2368
- ui.br();
2369
- ui.error(err instanceof Error ? err.message : "An unexpected error occurred.");
2370
- if (process.env.DEBUG) {
2371
- console.error(err);
2372
- }
2373
- process.exit(1);
4148
+ handleError(err, "An unexpected error occurred.");
4149
+ }
4150
+ });
4151
+ program.command("fix").description("Auto-fix common build errors (missing deps, TypeScript issues, etc.)").option("--verbose", "Show detailed debug output").action(async (options) => {
4152
+ globalVerbose = options.verbose ?? false;
4153
+ try {
4154
+ await fixCommand(options);
4155
+ } catch (err) {
4156
+ handleError(err, "Fix command failed.");
4157
+ }
4158
+ });
4159
+ program.command("env").description("Scan and manage environment variables").action(async () => {
4160
+ try {
4161
+ await envCommand();
4162
+ } catch (err) {
4163
+ handleError(err, "Env command failed.");
4164
+ }
4165
+ });
4166
+ program.command("init").description("Bootstrap a new project from a description or template").option("-y, --yes", "Skip prompts and use defaults").option("-t, --template <name>", "Use a built-in template (run `shipem templates` to list)").action(async (options) => {
4167
+ try {
4168
+ await initCommand(options);
4169
+ } catch (err) {
4170
+ handleError(err, "Init command failed.");
4171
+ }
4172
+ });
4173
+ program.command("dev").description("Start dev server with deploy shortcut").action(async () => {
4174
+ try {
4175
+ await devCommand();
4176
+ } catch (err) {
4177
+ handleError(err, "Dev command failed.");
2374
4178
  }
2375
4179
  });
2376
4180
  program.command("login").description("Log in to Shipem via GitHub").action(async () => {
2377
4181
  try {
2378
4182
  await loginCommand();
2379
4183
  } catch (err) {
2380
- ui.error(err instanceof Error ? err.message : "Login failed.");
2381
- process.exit(1);
4184
+ handleError(err, "Login failed.");
2382
4185
  }
2383
4186
  });
2384
4187
  program.command("logout").description("Log out of Shipem").action(async () => {
2385
4188
  try {
2386
4189
  await logoutCommand();
2387
4190
  } catch (err) {
2388
- ui.error(err instanceof Error ? err.message : "Logout failed.");
2389
- process.exit(1);
4191
+ handleError(err, "Logout failed.");
2390
4192
  }
2391
4193
  });
2392
4194
  program.command("status").description("Check deployment status").action(async () => {
2393
4195
  try {
2394
4196
  await statusCommand();
2395
4197
  } catch (err) {
2396
- ui.error(err instanceof Error ? err.message : "An unexpected error occurred.");
2397
- process.exit(1);
4198
+ handleError(err, "An unexpected error occurred.");
2398
4199
  }
2399
4200
  });
2400
4201
  program.command("logs").description("View recent deployment logs").option("-n, --lines <number>", "Number of log lines to show", "100").action(async (options) => {
2401
4202
  try {
2402
4203
  await logsCommand({ lines: options.lines ? parseInt(options.lines, 10) : void 0 });
2403
4204
  } catch (err) {
2404
- ui.error(err instanceof Error ? err.message : "An unexpected error occurred.");
2405
- process.exit(1);
4205
+ handleError(err, "An unexpected error occurred.");
2406
4206
  }
2407
4207
  });
2408
4208
  program.command("down").description("Take down a deployment").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
2409
4209
  try {
2410
4210
  await downCommand(options);
2411
4211
  } catch (err) {
2412
- ui.error(err instanceof Error ? err.message : "An unexpected error occurred.");
2413
- process.exit(1);
4212
+ handleError(err, "An unexpected error occurred.");
4213
+ }
4214
+ });
4215
+ program.command("history").description("Show deploy history across all projects").action(async () => {
4216
+ try {
4217
+ await historyCommand();
4218
+ } catch (err) {
4219
+ handleError(err, "History command failed.");
4220
+ }
4221
+ });
4222
+ program.command("config [action] [key] [value]").description("Manage global Shipem settings").action(async (action, key, value) => {
4223
+ try {
4224
+ await configCommand(action, key, value);
4225
+ } catch (err) {
4226
+ handleError(err, "Config command failed.");
4227
+ }
4228
+ });
4229
+ program.command("watch").description("Watch for file changes and auto-deploy").action(async () => {
4230
+ try {
4231
+ await watchCommand();
4232
+ } catch (err) {
4233
+ handleError(err, "Watch command failed.");
4234
+ }
4235
+ });
4236
+ program.command("hooks [action]").description("Manage git hooks for auto-deploy (install|remove|status)").action(async (action) => {
4237
+ try {
4238
+ await hooksCommand(action);
4239
+ } catch (err) {
4240
+ handleError(err, "Hooks command failed.");
4241
+ }
4242
+ });
4243
+ var monitorCmd = program.command("monitor").description("Monitor deployed sites for uptime").option("--interval <minutes>", "Check interval in minutes (default: 5)").option("--daemon", "Run as background daemon").action(async (options) => {
4244
+ try {
4245
+ await monitorCommand(options);
4246
+ } catch (err) {
4247
+ handleError(err, "Monitor command failed.");
4248
+ }
4249
+ });
4250
+ monitorCmd.command("stop").description("Stop the monitor daemon").action(async () => {
4251
+ try {
4252
+ await monitorCommand({ stop: true });
4253
+ } catch (err) {
4254
+ handleError(err, "Monitor stop failed.");
2414
4255
  }
2415
4256
  });
2416
4257
  var domainsCmd = program.command("domains").description("Manage custom domains (coming soon)");
@@ -2419,6 +4260,21 @@ domainsCmd.command("add <domain>").description("Add a custom domain to your depl
2419
4260
  ui.dim("Follow our changelog at https://shipem.dev/changelog for updates.");
2420
4261
  ui.br();
2421
4262
  });
4263
+ program.command("preview").description("Deploy a preview URL from the current git branch").option("--skip-build", "Skip the build step").option("--verbose", "Show detailed debug output").action(async (options) => {
4264
+ globalVerbose = options.verbose ?? false;
4265
+ try {
4266
+ await previewCommand(options);
4267
+ } catch (err) {
4268
+ handleError(err, "Preview command failed.");
4269
+ }
4270
+ });
4271
+ program.command("templates").description("List available project templates for `shipem init --template`").action(async () => {
4272
+ try {
4273
+ await templatesCommand();
4274
+ } catch (err) {
4275
+ handleError(err, "Templates command failed.");
4276
+ }
4277
+ });
2422
4278
  program.on("command:*", (cmds) => {
2423
4279
  ui.error(`Unknown command: ${cmds[0]}`);
2424
4280
  ui.dim("Run `shipem --help` to see available commands.");