gzero-cli 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 (3) hide show
  1. package/README.md +41 -20
  2. package/dist/index.js +464 -30
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -37,33 +37,45 @@ Works on Linux, macOS, and Windows.
37
37
 
38
38
  ## Quick start
39
39
 
40
- Push your code to GitHub, then go from zero to live in two commands:
40
+ One command gets you live. Run it in your project directory:
41
41
 
42
42
  ```bash
43
- gzero login # device-code sign in via your browser
44
- gzero init # pick a repo, auto-detect the build, create + deploy
43
+ gzero login # device-code sign in via your browser (one time)
44
+ cd my-app
45
+ gzero deploy # publish to GitHub if needed, scan, create, and ship
45
46
  ```
46
47
 
47
- `gzero init` walks you through it: choose a GitHub repo, pick web app or API,
48
- attach it to a product (existing or new), and it scans the repo to detect the
49
- framework, build command, and output folder for you. At the end it offers to
50
- deploy.
48
+ `gzero deploy` (short alias: `g0 deploy`) is context-aware and does the right
49
+ thing for where you are:
51
50
 
52
- Already have projects? Day-to-day it's just:
51
+ - **Linked directory** (a `.gzero/project.json` file exists): redeploys that project.
52
+ - **Git repo with a GitHub remote:** scans, creates the project, and deploys.
53
+ - **Local repo, not on GitHub yet:** offers to create a GitHub repo on your
54
+ account and push your code there first, then continues.
55
+ - **Not a git repo:** falls back to picking one of your GitHub repositories.
56
+
57
+ `init` vs `deploy`, in one line: **init sets up and links, deploy ships.** Use
58
+ `gzero init` to create and link a project without deploying yet.
53
59
 
54
60
  ```bash
55
61
  gzero deploy # deploy a project and watch it go live
56
- gzero status # check readiness, URL, and env vars
62
+ gzero status # check hosting mode, readiness, URL, and env vars
57
63
  ```
58
64
 
59
- `gzero deploy` on a brand-new account with no projects drops straight into the
60
- `init` flow, then deploys. Run `gzero` with no arguments (or `gzero --help`) to
61
- see the full command list.
65
+ Run `gzero` with no arguments (or `gzero --help`) to see the full command list.
62
66
 
63
67
  > **Note**
64
- > GroundZero builds and deploys from GitHub, so your code needs to be pushed to
65
- > a GitHub repo first (the same as the dashboard). The CLI does not upload local
66
- > files.
68
+ > GroundZero builds from GitHub, so your code has to live in a GitHub repo. The
69
+ > CLI can create that repo and push for you (reusing your GitHub sign-in, no
70
+ > extra tokens), so a purely local project can go live without leaving the
71
+ > terminal.
72
+
73
+ ### Static vs server-rendered (SSR)
74
+
75
+ When a scan detects a server-rendered app (Next, Nuxt, SvelteKit, ...), the CLI
76
+ asks how to host it: a **static site** (free preview, cheap to go live) or a
77
+ **server-rendered app** running a Node server (the all-in $9/mo plan: server,
78
+ custom domain, SSL). Switch anytime with `gzero ssr enable` / `gzero ssr disable`.
67
79
 
68
80
  ## Sign in
69
81
 
@@ -98,14 +110,16 @@ in the browser itself.
98
110
  | `gzero login` | Device-code sign in; saves an API token |
99
111
  | `gzero logout` | Revoke the current token and clear local config |
100
112
  | `gzero whoami` | Show the signed-in account |
101
- | `gzero init [repo]` | Create a project from a GitHub repo (interactive), then optionally deploy |
113
+ | `gzero deploy [project]` | Get the current project live: publish to GitHub if needed, scan, create, and deploy (or redeploy the linked/named project) |
114
+ | `gzero init [repo]` | Set up and link a project (from local code or a GitHub repo) without deploying |
102
115
  | `gzero projects` *(alias `ls`)* | List your projects |
103
- | `gzero deploy [project]` | Trigger a deploy and follow it to completion (bootstraps `init` on an empty account) |
104
- | `gzero status [project]` | Active deploy, readiness, and env status |
116
+ | `gzero status [project]` | Active deploy, hosting mode, readiness, and env status |
105
117
  | `gzero logs [project]` | Print the latest deploy's build log |
106
118
  | `gzero open [project]` | Open the project's live URL in your browser |
107
119
  | `gzero rollback [project]` | Roll back to a previous successful deploy |
108
- | `gzero promote [project]` | Promote a deploy to production |
120
+ | `gzero promote [project]` | Promote a live deploy to production |
121
+ | `gzero ssr enable [project]` | Switch a web project to server-rendered hosting (paid) |
122
+ | `gzero ssr disable [project]` | Switch a server-rendered project back to static |
109
123
  | `gzero env list [project]` | List env var keys |
110
124
  | `gzero env set <project> <key> <value>` | Set an env var |
111
125
  | `gzero env rm <project> <key>` | Delete an env var |
@@ -167,9 +181,16 @@ for details.
167
181
  ## Examples
168
182
 
169
183
  ```bash
170
- # Create a project from a specific repo and deploy it
184
+ # Take a purely local project live: creates a GitHub repo, pushes, then deploys
185
+ cd my-app && gzero deploy
186
+
187
+ # Create and link a project from a specific repo without deploying
171
188
  gzero init acme/my-app
172
189
 
190
+ # Host a server-rendered app on the Node runtime, then deploy it
191
+ gzero ssr enable my-app
192
+ gzero deploy my-app
193
+
173
194
  # Deploy a specific branch to production and wait for it to go live
174
195
  gzero deploy my-app --branch main --env production
175
196
 
package/dist/index.js CHANGED
@@ -31,9 +31,29 @@ var require_deploy_readiness = __commonJS({
31
31
  "use strict";
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
33
  exports.evaluateDeployReadiness = evaluateDeployReadiness;
34
+ function staticSwitchHint(framework) {
35
+ switch (framework) {
36
+ case "next-static":
37
+ return "add `output: 'export'` to next.config (no SSR, ISR, or API routes)";
38
+ case "nuxt-static":
39
+ return "build with `nuxt generate` (or set `nitro: { preset: 'static' }`)";
40
+ case "astro-static":
41
+ return "set `output: 'static'` and remove the SSR adapter";
42
+ case "sveltekit-static":
43
+ return "use `@sveltejs/adapter-static` (no SSR or server endpoints)";
44
+ case "vite-react":
45
+ case "vite-vue":
46
+ case "vite-svelte":
47
+ return "ship a client-only or prerendered build (no Nitro server routes)";
48
+ default:
49
+ return "configure a static build for your framework";
50
+ }
51
+ }
34
52
  function evaluateDeployReadiness(input) {
35
53
  const blockers = [];
36
54
  const warnings = [];
55
+ const detectedRenderMode = input.checks?.some((c) => c.id === "render-mode") ? "ssr" : "static";
56
+ const renderMode = input.renderModeOverride ?? detectedRenderMode;
37
57
  if (!input.framework || input.framework === "unknown") {
38
58
  blockers.push({
39
59
  id: "framework",
@@ -50,7 +70,7 @@ var require_deploy_readiness = __commonJS({
50
70
  recommendation: 'Add a "build" script to package.json or run scan again.'
51
71
  });
52
72
  }
53
- if (!input.outputDir && input.framework !== "plain-html") {
73
+ if (!input.outputDir && input.framework !== "plain-html" && renderMode !== "ssr") {
54
74
  blockers.push({
55
75
  id: "output",
56
76
  level: "error",
@@ -58,6 +78,14 @@ var require_deploy_readiness = __commonJS({
58
78
  recommendation: "Run scan again to detect the build output folder."
59
79
  });
60
80
  }
81
+ if (renderMode === "ssr" && input.serviceType !== void 0 && input.serviceType !== "ssr_web") {
82
+ blockers.push({
83
+ id: "ssr-hosting",
84
+ level: "error",
85
+ message: "This app renders on a server (SSR). Choose Server-rendered (SSR) hosting to deploy it.",
86
+ recommendation: `Pick Server-rendered (SSR) in Hosting mode to continue ($9/mo all-in: server, domain, and SSL). To host as a static site instead, ${staticSwitchHint(input.framework)}, then re-scan.`
87
+ });
88
+ }
61
89
  if (input.secretsFound) {
62
90
  warnings.push({
63
91
  id: "secrets",
@@ -81,6 +109,8 @@ var require_deploy_readiness = __commonJS({
81
109
  if (check.id.startsWith("env-"))
82
110
  continue;
83
111
  const isFrameworkConfigError = check.id === "framework-config" && check.level === "error";
112
+ if (isFrameworkConfigError && renderMode === "ssr")
113
+ continue;
84
114
  if (check.level !== "warning" && !isFrameworkConfigError)
85
115
  continue;
86
116
  warnings.push({
@@ -95,7 +125,8 @@ var require_deploy_readiness = __commonJS({
95
125
  canDeploy: blockers.length === 0,
96
126
  blockers,
97
127
  warnings,
98
- technicalNotes: input.technicalNotes ?? []
128
+ technicalNotes: input.technicalNotes ?? [],
129
+ renderMode
99
130
  };
100
131
  }
101
132
  }
@@ -283,6 +314,15 @@ var require_dist2 = __commonJS({
283
314
  me: () => request("/auth/me"),
284
315
  // --- Repos ---
285
316
  listRepos: (opts) => request(`/github/repos${opts?.refresh ? "?refresh=true" : ""}`),
317
+ /**
318
+ * Create a new GitHub repository on the signed-in user's account (using their
319
+ * linked OAuth token, server-side). Returns a short-lived tokenized `pushUrl`
320
+ * so a client can push local commits once; callers must not persist it.
321
+ */
322
+ createRepo: (input) => request("/github/repos", {
323
+ method: "POST",
324
+ body: JSON.stringify(input)
325
+ }),
286
326
  // --- Scanner ---
287
327
  scanRepo: (input) => request("/scanner/scan", {
288
328
  method: "POST",
@@ -327,6 +367,22 @@ var require_dist2 = __commonJS({
327
367
  method: "POST",
328
368
  body: JSON.stringify({ deployId })
329
369
  }),
370
+ /** Switch a web project to server-rendered mode (consumes SSR capacity). */
371
+ enableSsr: (projectId, startCommand) => request(`/projects/${projectId}/ssr`, {
372
+ method: "POST",
373
+ body: JSON.stringify({ startCommand: startCommand ?? null })
374
+ }),
375
+ /** Switch a server-rendered project back to static hosting. */
376
+ disableSsr: (projectId) => request(`/projects/${projectId}/ssr`, { method: "DELETE" }),
377
+ /**
378
+ * Re-point a project to a different GitHub repo. Clears artifacts, cache, and
379
+ * deploy history from the old repo and resets the build config; a rescan and
380
+ * redeploy are required afterwards.
381
+ */
382
+ changeRepo: (projectId, repoFullName, defaultBranch) => request(`/projects/${projectId}/repo`, {
383
+ method: "POST",
384
+ body: JSON.stringify({ repoFullName, defaultBranch: defaultBranch ?? null })
385
+ }),
330
386
  // --- Env vars ---
331
387
  listEnvVars: (projectId) => request(`/projects/${projectId}/env-vars`),
332
388
  upsertEnvVar: (projectId, key, value) => request(`/projects/${projectId}/env-vars`, {
@@ -353,9 +409,9 @@ var require_dist2 = __commonJS({
353
409
  // --- Billing ---
354
410
  getBillingStatus: () => request("/billing/status"),
355
411
  createCheckout: () => request("/billing/checkout", { method: "POST" }),
356
- createAddonCheckout: (addonId, productId) => request("/billing/addons/checkout", {
412
+ createAddonCheckout: (addonId, productId, returnPath) => request("/billing/addons/checkout", {
357
413
  method: "POST",
358
- body: JSON.stringify({ addonId, productId })
414
+ body: JSON.stringify({ addonId, productId, returnPath })
359
415
  }),
360
416
  createBillingPortal: () => request("/billing/portal", { method: "POST" }),
361
417
  listInvoices: () => request("/billing/invoices")
@@ -409,6 +465,23 @@ async function clearConfig() {
409
465
  } catch {
410
466
  }
411
467
  }
468
+ function projectLinkPath(dir) {
469
+ return join(dir, ".gzero", "project.json");
470
+ }
471
+ async function loadProjectLink(dir) {
472
+ try {
473
+ const raw = await readFile(projectLinkPath(dir), "utf8");
474
+ const parsed = JSON.parse(raw);
475
+ return parsed.projectId ? parsed : null;
476
+ } catch {
477
+ return null;
478
+ }
479
+ }
480
+ async function saveProjectLink(dir, link) {
481
+ await mkdir(join(dir, ".gzero"), { recursive: true });
482
+ await writeFile(projectLinkPath(dir), `${JSON.stringify(link, null, 2)}
483
+ `);
484
+ }
412
485
  async function resolveSettings(flags2) {
413
486
  const file = await loadConfig();
414
487
  return {
@@ -558,6 +631,84 @@ async function resolveProject(c, ref) {
558
631
  var import_sdk2 = __toESM(require_dist2(), 1);
559
632
  import * as p2 from "@clack/prompts";
560
633
  import pc3 from "picocolors";
634
+
635
+ // src/git.ts
636
+ import { spawn as spawn2 } from "child_process";
637
+ import { basename } from "path";
638
+ function runGit(args, cwd) {
639
+ return new Promise((resolve) => {
640
+ let stdout = "";
641
+ let stderr = "";
642
+ let child;
643
+ try {
644
+ child = spawn2("git", args, { cwd });
645
+ } catch {
646
+ resolve({ code: 127, stdout: "", stderr: "git not found" });
647
+ return;
648
+ }
649
+ child.stdout.on("data", (d) => stdout += d.toString());
650
+ child.stderr.on("data", (d) => stderr += d.toString());
651
+ child.on("error", () => resolve({ code: 127, stdout, stderr: "git not found" }));
652
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
653
+ });
654
+ }
655
+ async function gitAvailable() {
656
+ const res = await runGit(["--version"], process.cwd());
657
+ return res.code === 0;
658
+ }
659
+ function parseGithubRemote(url) {
660
+ if (!url) return null;
661
+ const trimmed = url.trim();
662
+ const ssh = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/.exec(trimmed);
663
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
664
+ const https = /^https?:\/\/(?:[^@]+@)?github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(trimmed);
665
+ if (https) return `${https[1]}/${https[2]}`;
666
+ return null;
667
+ }
668
+ async function inspectGit(cwd) {
669
+ const inside = await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
670
+ if (inside.code !== 0 || inside.stdout.trim() !== "true") {
671
+ return {
672
+ isRepo: false,
673
+ root: null,
674
+ branch: null,
675
+ hasCommits: false,
676
+ originUrl: null,
677
+ githubRepo: null,
678
+ dirty: false
679
+ };
680
+ }
681
+ const [root, branch, head, origin, status] = await Promise.all([
682
+ runGit(["rev-parse", "--show-toplevel"], cwd),
683
+ runGit(["symbolic-ref", "--short", "HEAD"], cwd),
684
+ runGit(["rev-parse", "HEAD"], cwd),
685
+ runGit(["remote", "get-url", "origin"], cwd),
686
+ runGit(["status", "--porcelain"], cwd)
687
+ ]);
688
+ const originUrl = origin.code === 0 ? origin.stdout.trim() || null : null;
689
+ return {
690
+ isRepo: true,
691
+ root: root.code === 0 ? root.stdout.trim() : null,
692
+ branch: branch.code === 0 ? branch.stdout.trim() || null : null,
693
+ hasCommits: head.code === 0,
694
+ originUrl,
695
+ githubRepo: parseGithubRemote(originUrl),
696
+ dirty: status.code === 0 && status.stdout.trim().length > 0
697
+ };
698
+ }
699
+ function suggestRepoName(cwd, root) {
700
+ const dir = basename(root ?? cwd) || "app";
701
+ return dir.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
702
+ }
703
+ async function hasGitIdentity(cwd) {
704
+ const [name, email] = await Promise.all([
705
+ runGit(["config", "user.name"], cwd),
706
+ runGit(["config", "user.email"], cwd)
707
+ ]);
708
+ return name.code === 0 && name.stdout.trim() !== "" && email.code === 0 && email.stdout.trim() !== "";
709
+ }
710
+
711
+ // src/create.ts
561
712
  var MANUAL = "__manual__";
562
713
  var CREATE_PRODUCT = "__create__";
563
714
  var REPO_ROOT = ".";
@@ -565,6 +716,21 @@ function guard(value) {
565
716
  if (p2.isCancel(value)) die("Cancelled.");
566
717
  return value;
567
718
  }
719
+ function redactSecret(text2, pushUrl) {
720
+ let out = text2;
721
+ if (pushUrl) out = out.split(pushUrl).join("https://github.com");
722
+ const token = /x-access-token:([^@/\s]+)@/.exec(pushUrl)?.[1];
723
+ if (token) out = out.split(token).join("***");
724
+ return out.replace(/(https?:\/\/)[^@/\s]+:[^@/\s]+@/g, "$1");
725
+ }
726
+ async function lookupDefaultBranch(c, repoFullName) {
727
+ try {
728
+ const { repos } = await c.listRepos();
729
+ return repos.find((r) => r.fullName === repoFullName)?.defaultBranch ?? null;
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
568
734
  async function pickRepo(c) {
569
735
  const s = p2.spinner();
570
736
  s.start("Loading your GitHub repositories");
@@ -680,19 +846,36 @@ function scanSummary(scan, repoFullName, branch) {
680
846
  `${pc3.dim("branch")} ${branch}`,
681
847
  `${pc3.dim("framework")} ${scan.framework}`
682
848
  ];
849
+ if (scan.renderMode) lines.push(`${pc3.dim("render")} ${scan.renderMode === "ssr" ? "server-rendered (SSR)" : "static"}`);
683
850
  if (scan.scanPath && scan.scanPath !== REPO_ROOT) lines.push(`${pc3.dim("path")} ${scan.scanPath}`);
684
851
  if (scan.buildCommand) lines.push(`${pc3.dim("build")} ${scan.buildCommand}`);
685
852
  if (scan.outputDir) lines.push(`${pc3.dim("output")} ${scan.outputDir}`);
853
+ if (scan.renderMode === "ssr" && scan.startCommand)
854
+ lines.push(`${pc3.dim("start")} ${scan.startCommand}`);
686
855
  if (scan.requiredEnvVars.length > 0)
687
856
  lines.push(`${pc3.dim("env vars")} ${scan.requiredEnvVars.join(", ")}`);
688
857
  return lines.join("\n");
689
858
  }
859
+ function staticExportHint(framework) {
860
+ switch (framework) {
861
+ case "next-static":
862
+ return "Add `output: 'export'` to next.config.js (static export), then re-scan.";
863
+ case "nuxt-static":
864
+ return "Use `nuxt generate` (or nitro preset 'static') to prerender, then re-scan.";
865
+ case "astro-static":
866
+ return "Set `output: 'static'` in astro.config, then re-scan.";
867
+ case "sveltekit-static":
868
+ return "Switch to @sveltejs/adapter-static, then re-scan.";
869
+ default:
870
+ return "Configure a static export for this framework, then re-scan.";
871
+ }
872
+ }
690
873
  async function interactiveCreate(c, opts = {}) {
691
874
  let repoFullName;
692
875
  let defaultBranch;
693
876
  if (opts.repo) {
694
877
  repoFullName = opts.repo;
695
- defaultBranch = "main";
878
+ defaultBranch = opts.defaultBranch ?? await lookupDefaultBranch(c, opts.repo) ?? "main";
696
879
  } else {
697
880
  ({ repoFullName, defaultBranch } = await pickRepo(c));
698
881
  }
@@ -700,7 +883,7 @@ async function interactiveCreate(c, opts = {}) {
700
883
  await p2.select({
701
884
  message: "What are you deploying?",
702
885
  options: [
703
- { value: "web", label: "Web app", hint: "static site (React, Vue, Astro, Svelte, ...)" },
886
+ { value: "web", label: "Web app", hint: "React, Vue, Astro, Svelte, Next, Nuxt, ..." },
704
887
  { value: "api", label: "API", hint: "ExpressoTS backend" }
705
888
  ]
706
889
  })
@@ -721,6 +904,37 @@ async function interactiveCreate(c, opts = {}) {
721
904
  }
722
905
  }
723
906
  p2.note(scanSummary(scan, repoFullName, defaultBranch), "Detected configuration");
907
+ let hostingMode = null;
908
+ let buildCommand = scan.buildCommand;
909
+ let outputDir = scan.outputDir;
910
+ let configOverrides = {};
911
+ if (kind === "web" && scan.renderMode === "ssr") {
912
+ hostingMode = guard(
913
+ await p2.select({
914
+ message: "This app renders on a server. How do you want to host it?",
915
+ options: [
916
+ { value: "ssr", label: "Server-rendered (SSR)", hint: "$9/mo all-in: server, domain, SSL" },
917
+ {
918
+ value: "static",
919
+ label: "Static site",
920
+ hint: scan.staticAlternative ? "build a static export" : "needs a static export config"
921
+ }
922
+ ],
923
+ initialValue: "ssr"
924
+ })
925
+ );
926
+ if (hostingMode === "static") {
927
+ configOverrides = { renderMode: "static" };
928
+ if (scan.staticAlternative) {
929
+ buildCommand = scan.staticAlternative.buildCommand;
930
+ outputDir = scan.staticAlternative.outputDir;
931
+ } else {
932
+ p2.note(staticExportHint(scan.framework), "Static export needed");
933
+ }
934
+ } else {
935
+ configOverrides = { renderMode: "ssr" };
936
+ }
937
+ }
724
938
  const s = p2.spinner();
725
939
  s.start(kind === "api" ? "Creating API service" : "Creating project");
726
940
  let project;
@@ -735,17 +949,31 @@ async function interactiveCreate(c, opts = {}) {
735
949
  defaultBranch
736
950
  });
737
951
  } else {
952
+ const detectedConfig = {
953
+ framework: scan.framework,
954
+ buildCommand: scan.buildCommand,
955
+ outputDir: scan.outputDir,
956
+ scanPath: scan.scanPath ?? REPO_ROOT,
957
+ startCommand: scan.startCommand ?? null,
958
+ installCommand: scan.installCommand ?? "npm install",
959
+ renderMode: scan.renderMode,
960
+ staticAlternative: scan.staticAlternative ?? null
961
+ };
738
962
  project = await c.createProject({
739
963
  repoFullName,
740
964
  productId,
741
965
  defaultBranch,
742
966
  framework: scan.framework,
743
967
  scanPath: scan.scanPath ?? REPO_ROOT,
744
- buildCommand: scan.buildCommand,
745
- outputDir: scan.outputDir,
968
+ buildCommand,
969
+ outputDir,
746
970
  spaFallback: scan.spaFallback,
747
971
  requiredEnvVars: scan.requiredEnvVars,
748
- secretsFound: scan.secretsFound
972
+ secretsFound: scan.secretsFound,
973
+ lastScanChecks: scan.checks,
974
+ startCommand: scan.startCommand ?? null,
975
+ detectedConfig,
976
+ configOverrides
749
977
  });
750
978
  }
751
979
  } catch (err) {
@@ -753,7 +981,143 @@ async function interactiveCreate(c, opts = {}) {
753
981
  fail(err);
754
982
  }
755
983
  s.stop(`${symbol.ok} Created ${brand.violet(project.name)} ${pc3.dim(`(${project.slug})`)}`);
756
- return { project, scan };
984
+ let ssrPending = false;
985
+ if (hostingMode === "ssr") {
986
+ const outcome = await upgradeToSsr(c, project, scan.startCommand ?? null);
987
+ ssrPending = outcome === "checkout";
988
+ }
989
+ return { project, scan, ssrPending };
990
+ }
991
+ async function upgradeToSsr(c, project, startCommand) {
992
+ let billing2;
993
+ try {
994
+ billing2 = await c.getBillingStatus();
995
+ } catch (err) {
996
+ fail(err);
997
+ }
998
+ const hasSpareCapacity = billing2.ssrRuntime.capacity > billing2.ssrRuntime.used;
999
+ if (hasSpareCapacity) {
1000
+ const s = p2.spinner();
1001
+ s.start("Enabling server-rendered hosting");
1002
+ try {
1003
+ await c.enableSsr(project.id, startCommand);
1004
+ s.stop(`${symbol.ok} Server-rendered hosting enabled.`);
1005
+ } catch (err) {
1006
+ s.stop("Could not enable server rendering");
1007
+ fail(err);
1008
+ }
1009
+ return "enabled";
1010
+ }
1011
+ const addonId = billing2.availableAddons.find((a) => a.type === "ssr")?.id;
1012
+ if (!addonId) {
1013
+ p2.note("Server rendering isn't available on your account yet.", "Server rendering");
1014
+ return "unavailable";
1015
+ }
1016
+ try {
1017
+ const { url } = await c.createAddonCheckout(addonId);
1018
+ p2.note(
1019
+ `${pc3.underline(url)}
1020
+
1021
+ ${pc3.dim("This is the all-in $9/mo plan (server, domain, SSL).")}`,
1022
+ "Complete payment to enable server rendering"
1023
+ );
1024
+ openBrowser(url);
1025
+ } catch (err) {
1026
+ fail(err);
1027
+ }
1028
+ return "checkout";
1029
+ }
1030
+ async function createRepoAndPush(c, cwd, git) {
1031
+ if (!await gitAvailable()) die("git is required to create and push a repository.");
1032
+ const suggested = suggestRepoName(cwd, git.root);
1033
+ p2.note(
1034
+ `This project isn't on GitHub yet. GroundZero deploys from GitHub, so we'll
1035
+ create a repo on your account and push your code there.`,
1036
+ "Publish to GitHub"
1037
+ );
1038
+ const name = guard(
1039
+ await p2.text({
1040
+ message: "New GitHub repository name",
1041
+ defaultValue: suggested,
1042
+ placeholder: suggested,
1043
+ validate: (v) => /^[A-Za-z0-9._-]+$/.test(v.trim()) ? void 0 : "Use letters, numbers, '.', '-', '_' only."
1044
+ })
1045
+ );
1046
+ const repoName = name.trim() || suggested;
1047
+ const isPrivate = guard(
1048
+ await p2.confirm({ message: "Make the repository private?", initialValue: true })
1049
+ );
1050
+ const create = p2.spinner();
1051
+ create.start(`Creating ${repoName} on GitHub`);
1052
+ let repo;
1053
+ try {
1054
+ repo = await c.createRepo({ name: repoName, private: isPrivate });
1055
+ } catch (err) {
1056
+ create.stop("Could not create repository");
1057
+ fail(err);
1058
+ }
1059
+ create.stop(`${symbol.ok} Created ${brand.violet(repo.fullName)}`);
1060
+ const root = git.root ?? cwd;
1061
+ const branch = git.branch ?? "main";
1062
+ if (!git.hasCommits) {
1063
+ await runGit(["add", "-A"], root);
1064
+ const identityArgs = await hasGitIdentity(root) ? [] : ["-c", "user.name=GroundZero", "-c", "user.email=cli@gzer0.app"];
1065
+ const commit = await runGit([...identityArgs, "commit", "-m", "Initial commit"], root);
1066
+ if (commit.code !== 0) {
1067
+ die(`Could not create an initial commit: ${commit.stderr.trim() || "commit failed"}`);
1068
+ }
1069
+ }
1070
+ const push = p2.spinner();
1071
+ push.start(`Pushing ${branch} to ${repo.fullName}`);
1072
+ const cleanUrl = `https://github.com/${repo.fullName}.git`;
1073
+ const remoteName = git.originUrl ? "gzero" : "origin";
1074
+ await runGit(["remote", "remove", remoteName], root);
1075
+ await runGit(["remote", "add", remoteName, cleanUrl], root);
1076
+ const pushed = await runGit(["push", repo.pushUrl, `HEAD:refs/heads/${branch}`], root);
1077
+ if (pushed.code !== 0) {
1078
+ push.stop("Push failed");
1079
+ const detail = redactSecret(pushed.stderr, repo.pushUrl).trim();
1080
+ die(`git push failed: ${detail || "unknown error"}`);
1081
+ }
1082
+ push.stop(
1083
+ `${symbol.ok} Pushed ${branch} to ${brand.violet(repo.fullName)}${remoteName === "gzero" ? pc3.dim(" (remote 'gzero')") : ""}`
1084
+ );
1085
+ return { repoFullName: repo.fullName, branch };
1086
+ }
1087
+ async function onboardProject(c, opts) {
1088
+ if (opts.repo) {
1089
+ return interactiveCreate(c, { repo: opts.repo });
1090
+ }
1091
+ const git = await inspectGit(opts.cwd);
1092
+ if (!git.isRepo) {
1093
+ let projects;
1094
+ try {
1095
+ projects = await c.listProjects();
1096
+ } catch (err) {
1097
+ fail(err);
1098
+ }
1099
+ if (projects.length > 0) {
1100
+ const project = await resolveProject(c);
1101
+ return { project, existing: true };
1102
+ }
1103
+ return interactiveCreate(c);
1104
+ }
1105
+ if (git.githubRepo) {
1106
+ let projects;
1107
+ try {
1108
+ projects = await c.listProjects();
1109
+ } catch (err) {
1110
+ fail(err);
1111
+ }
1112
+ const match = projects.find((proj) => proj.repoFullName === git.githubRepo);
1113
+ if (match) {
1114
+ p2.note(`Using the existing project for ${git.githubRepo}.`, "Linked");
1115
+ return { project: match, existing: true };
1116
+ }
1117
+ return interactiveCreate(c, { repo: git.githubRepo, defaultBranch: git.branch ?? void 0 });
1118
+ }
1119
+ const pushed = await createRepoAndPush(c, opts.cwd, git);
1120
+ return interactiveCreate(c, { repo: pushed.repoFullName, defaultBranch: pushed.branch });
757
1121
  }
758
1122
  async function deployProject(c, project, opts = {}) {
759
1123
  const s = p2.spinner();
@@ -798,7 +1162,7 @@ ${pc3.dim("Re-run with --yes to acknowledge warnings.")}`);
798
1162
  s.stop(`${symbol.warn} Deploy status: ${current.status}`);
799
1163
  }
800
1164
  }
801
- async function offerDeploy(c, project, opts) {
1165
+ async function reportReadiness(c, project) {
802
1166
  let hardBlockers = [];
803
1167
  try {
804
1168
  const readiness = await c.getReadiness(project.id);
@@ -816,6 +1180,10 @@ ${pc3.dim("Set them with")} gzero env set ${project.slug} KEY value`,
816
1180
  }
817
1181
  } catch {
818
1182
  }
1183
+ return { hardBlockers };
1184
+ }
1185
+ async function offerDeploy(c, project, opts) {
1186
+ const { hardBlockers } = await reportReadiness(c, project);
819
1187
  if (hardBlockers.length > 0) {
820
1188
  p2.note(hardBlockers.map((b) => `${symbol.err} ${b.message}`).join("\n"), "Fix before deploying");
821
1189
  p2.outro(`Resolve the blockers above, then run ${brand.violet(`gzero deploy ${project.slug}`)}.`);
@@ -829,12 +1197,25 @@ ${pc3.dim("Set them with")} gzero env set ${project.slug} KEY value`,
829
1197
  p2.outro(`Run ${brand.violet(`gzero deploy ${project.slug}`)} when you're ready.`);
830
1198
  return;
831
1199
  }
832
- await deployProject(c, project, { yes: true });
1200
+ await deployProject(c, project, { yes: true, ...opts.deploy });
833
1201
  p2.outro("Done.");
834
1202
  }
1203
+ async function finishSetup(c, project, opts = {}) {
1204
+ if (opts.ssrPending) {
1205
+ p2.outro(
1206
+ `Finish the server-rendering upgrade in your browser, then run ${brand.violet(`gzero deploy ${project.slug}`)}.`
1207
+ );
1208
+ return;
1209
+ }
1210
+ const { hardBlockers } = await reportReadiness(c, project);
1211
+ if (hardBlockers.length > 0) {
1212
+ p2.note(hardBlockers.map((b) => `${symbol.err} ${b.message}`).join("\n"), "Fix before deploying");
1213
+ }
1214
+ p2.outro(`Setup complete. Run ${brand.violet(`gzero deploy ${project.slug}`)} to ship.`);
1215
+ }
835
1216
 
836
1217
  // src/index.ts
837
- var VERSION = "0.1.0";
1218
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
838
1219
  var program = new Command();
839
1220
  program.name("gzero").description("GroundZero: deploy UIs and APIs from the terminal or an AI agent.").version(VERSION, "-v, --version", "Print the CLI version.").option("--api-url <url>", "API base URL (default: config or GZERO_API_URL)").option("--token <token>", "API token (default: config or GZERO_TOKEN)").configureHelp({
840
1221
  styleTitle: (s) => pc4.bold(s),
@@ -955,36 +1336,52 @@ program.command("projects").alias("ls").description("List your projects.").actio
955
1336
  fail(err);
956
1337
  }
957
1338
  });
958
- program.command("init").argument("[repo]", "GitHub repository (owner/repo) to skip the picker").description("Create a project from a GitHub repo (interactive) and optionally deploy.").action(async (repo, _opts, command) => {
1339
+ program.command("init").argument("[repo]", "GitHub repository (owner/repo) to skip detection").description("Set up a project (from local code or a GitHub repo) and link this directory. Does not deploy.").action(async (repo, _opts, command) => {
959
1340
  const c = await client(flags(command));
960
1341
  p3.intro(lockup("init"));
961
- const { project } = await interactiveCreate(c, { repo });
962
- await offerDeploy(c, project, { prompt: true });
1342
+ const cwd = process.cwd();
1343
+ const { project, ssrPending } = await onboardProject(c, { cwd, repo });
1344
+ await saveProjectLink(cwd, { projectId: project.id, repoFullName: project.repoFullName });
1345
+ await finishSetup(c, project, { ssrPending });
963
1346
  });
964
- program.command("deploy").argument("[project]", "project id, slug, or name").option("-b, --branch <branch>", "branch to deploy").option("-e, --env <environment>", "environment (production or preview)").option("-y, --yes", "acknowledge readiness warnings and deploy anyway").option("--no-follow", "return immediately instead of streaming status").description("Trigger a deploy and follow it to completion.").action(async (projectRef, opts, command) => {
1347
+ program.command("deploy").argument("[project]", "project id, slug, or name").option("-b, --branch <branch>", "branch to deploy").option("-e, --env <environment>", "environment (production or preview)").option("-y, --yes", "acknowledge readiness warnings and deploy anyway").option("--no-follow", "return immediately instead of streaming status").description("Ship the current project to a live URL (sets it up first if needed).").action(async (projectRef, opts, command) => {
965
1348
  const c = await client(flags(command));
966
- if (!projectRef) {
1349
+ const cwd = process.cwd();
1350
+ const deployOpts = {
1351
+ yes: Boolean(opts.yes),
1352
+ branch: opts.branch,
1353
+ env: opts.env,
1354
+ follow: opts.follow
1355
+ };
1356
+ if (projectRef) {
1357
+ const project2 = await resolveProject(c, projectRef);
1358
+ await deployProject(c, project2, deployOpts);
1359
+ return;
1360
+ }
1361
+ const link = await loadProjectLink(cwd);
1362
+ if (link) {
967
1363
  let projects;
968
1364
  try {
969
1365
  projects = await c.listProjects();
970
1366
  } catch (err) {
971
1367
  fail(err);
972
1368
  }
973
- if (projects.length === 0) {
974
- p3.intro(lockup("deploy"));
975
- p3.note("You don't have any projects yet. Let's create one.", "Setup");
976
- const { project: project2 } = await interactiveCreate(c);
977
- await offerDeploy(c, project2, { prompt: false });
1369
+ const linked = projects.find((proj) => proj.id === link.projectId);
1370
+ if (linked) {
1371
+ await deployProject(c, linked, deployOpts);
978
1372
  return;
979
1373
  }
980
1374
  }
981
- const project = await resolveProject(c, projectRef);
982
- await deployProject(c, project, {
983
- yes: Boolean(opts.yes),
984
- branch: opts.branch,
985
- env: opts.env,
986
- follow: opts.follow
987
- });
1375
+ p3.intro(lockup("deploy"));
1376
+ const { project, ssrPending } = await onboardProject(c, { cwd });
1377
+ await saveProjectLink(cwd, { projectId: project.id, repoFullName: project.repoFullName });
1378
+ if (ssrPending) {
1379
+ p3.outro(
1380
+ `Finish the server-rendering upgrade in your browser, then run ${brand.violet(`gzero deploy ${project.slug}`)}.`
1381
+ );
1382
+ return;
1383
+ }
1384
+ await offerDeploy(c, project, { prompt: false, deploy: deployOpts });
988
1385
  });
989
1386
  program.command("status").argument("[project]", "project id, slug, or name").description("Show the active deploy and readiness for a project.").action(async (projectRef, _opts, command) => {
990
1387
  const c = await client(flags(command));
@@ -992,11 +1389,21 @@ program.command("status").argument("[project]", "project id, slug, or name").des
992
1389
  try {
993
1390
  const status = await c.getProjectStatus(project.id);
994
1391
  const active = status.activeDeploy;
1392
+ const hosting = project.serviceType === "ssr_web" ? "server-rendered" : "static";
995
1393
  console.log(`${symbol.bullet} ${pc4.bold(project.name)}${pc4.dim(` (${project.slug})`)}`);
996
1394
  console.log(` status: ${active ? statusColor(active.status) : pc4.dim("no deploys")}`);
997
1395
  if (active?.url) console.log(` url: ${pc4.cyan(active.url)}`);
1396
+ console.log(` hosting: ${hosting}`);
998
1397
  console.log(` env vars: ${status.envConfigured ? pc4.green("configured") : pc4.yellow("missing")}`);
999
1398
  console.log(` ready: ${status.readiness.canDeploy ? pc4.green("yes") : pc4.red("no")}`);
1399
+ const ssrBlocker = status.readiness.blockers.find((b) => b.id === "ssr-hosting");
1400
+ if (ssrBlocker) {
1401
+ console.log(
1402
+ `
1403
+ ${symbol.warn} ${ssrBlocker.message}
1404
+ ${pc4.dim(`Run`)} gzero ssr enable ${project.slug} ${pc4.dim("to host it as a server-rendered app.")}`
1405
+ );
1406
+ }
1000
1407
  } catch (err) {
1001
1408
  fail(err);
1002
1409
  }
@@ -1077,6 +1484,33 @@ program.command("promote").argument("[project]", "project id, slug, or name").de
1077
1484
  fail(err);
1078
1485
  }
1079
1486
  });
1487
+ var ssr = program.command("ssr").description("Manage server-rendered (SSR) hosting.");
1488
+ ssr.command("enable").argument("[project]", "project id, slug, or name").option("--start-command <cmd>", 'Node start command for the server (e.g. "next start")').description("Switch a web project to server-rendered hosting (paid).").action(async (projectRef, opts, command) => {
1489
+ const c = await client(flags(command));
1490
+ const project = await resolveProject(c, projectRef);
1491
+ p3.intro(lockup("ssr"));
1492
+ const outcome = await upgradeToSsr(c, project, opts.startCommand ?? null);
1493
+ if (outcome === "enabled") {
1494
+ p3.outro(`Server rendering is on. Run ${brand.violet(`gzero deploy ${project.slug}`)} to build it.`);
1495
+ } else if (outcome === "checkout") {
1496
+ p3.outro(`Complete payment in your browser, then run ${brand.violet(`gzero deploy ${project.slug}`)}.`);
1497
+ } else {
1498
+ p3.outro("Server rendering is not available on your account.");
1499
+ }
1500
+ });
1501
+ ssr.command("disable").argument("[project]", "project id, slug, or name").description("Switch a server-rendered project back to static hosting.").action(async (projectRef, _opts, command) => {
1502
+ const c = await client(flags(command));
1503
+ const project = await resolveProject(c, projectRef);
1504
+ const s = p3.spinner();
1505
+ s.start("Switching to static hosting");
1506
+ try {
1507
+ await c.disableSsr(project.id);
1508
+ s.stop(`${symbol.ok} ${brand.violet(project.name)} is now a static site.`);
1509
+ } catch (err) {
1510
+ s.stop("Could not disable server rendering");
1511
+ fail(err);
1512
+ }
1513
+ });
1080
1514
  var env = program.command("env").description("Manage project environment variables.");
1081
1515
  env.command("list").argument("[project]", "project id, slug, or name").description("List env var keys for a project.").action(async (projectRef, _opts, command) => {
1082
1516
  const c = await client(flags(command));
package/package.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "name": "gzero-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "GroundZero command-line interface: deploy UIs and APIs, manage env, domains, and billing.",
5
- "license": "SEE LICENSE IN LICENSE",
5
+ "license": "LICENSE.md",
6
6
  "homepage": "https://gzer0.app/docs",
7
7
  "type": "module",
8
8
  "bin": {
9
- "gzero": "dist/index.js"
9
+ "gzero": "dist/index.js",
10
+ "g0": "dist/index.js"
10
11
  },
11
12
  "files": [
12
13
  "dist",
13
14
  "README.md",
14
- "LICENSE"
15
+ "LICENSE.md"
15
16
  ],
16
17
  "publishConfig": {
17
18
  "tag": "latest"