gzero-cli 0.1.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/dist/index.js +601 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# gzero-cli
|
|
2
|
+
|
|
3
|
+
The GroundZero command line: deploy UIs and APIs, manage env vars, domains, and
|
|
4
|
+
billing from your terminal or an AI agent. Built on [`@gzero/sdk`](../sdk).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
Requires Node.js 20+.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g gzero-cli
|
|
12
|
+
# or run without installing
|
|
13
|
+
npx gzero-cli --help
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Works on Linux, macOS, and Windows.
|
|
17
|
+
|
|
18
|
+
## Sign in
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
gzero login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
This runs a device-code login (OAuth 2.0 device authorization):
|
|
25
|
+
|
|
26
|
+
1. The CLI prints a short code and opens your browser to `/activate`.
|
|
27
|
+
2. Sign in with GitHub (if you aren't already) and confirm the code.
|
|
28
|
+
3. The CLI receives a long-lived API token and saves it locally.
|
|
29
|
+
|
|
30
|
+
The token is minted only after you approve in the browser, and it is never shown
|
|
31
|
+
in the browser itself.
|
|
32
|
+
|
|
33
|
+
### Where the token is stored
|
|
34
|
+
|
|
35
|
+
| OS | Path |
|
|
36
|
+
|----|------|
|
|
37
|
+
| Linux | `$XDG_CONFIG_HOME/gzero/config.json` or `~/.config/gzero/config.json` |
|
|
38
|
+
| macOS | `~/.config/gzero/config.json` |
|
|
39
|
+
| Windows | `%APPDATA%\gzero\config.json` (e.g. `C:\Users\you\AppData\Roaming\gzero\config.json`) |
|
|
40
|
+
|
|
41
|
+
`gzero login` prints the exact path after saving. The file is written with
|
|
42
|
+
`0600` permissions on Unix.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
gzero login Device-code sign in; saves an API token
|
|
48
|
+
gzero logout Revoke the current token and clear local config
|
|
49
|
+
gzero whoami Show the signed-in account
|
|
50
|
+
gzero projects (alias: ls) List your projects
|
|
51
|
+
gzero deploy [project] Trigger a deploy and follow it to completion
|
|
52
|
+
gzero status [project] Active deploy, readiness, and env status
|
|
53
|
+
gzero logs [project] Print the latest deploy's build log
|
|
54
|
+
gzero env list [project] List env var keys
|
|
55
|
+
gzero env set <project> <k> <v> Set an env var
|
|
56
|
+
gzero domains add <project> <host> Attach a custom domain
|
|
57
|
+
gzero billing Show plan, usage, and available add-ons
|
|
58
|
+
gzero billing upgrade Open Stripe checkout (subscribe)
|
|
59
|
+
gzero billing addon <id> Open Stripe checkout for an add-on
|
|
60
|
+
gzero billing portal Open the Stripe billing portal
|
|
61
|
+
gzero mcp Print config to connect an AI agent to the hosted MCP
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`[project]` accepts a project id, slug, or name. If omitted, the CLI uses your
|
|
65
|
+
only project or prompts you to pick one.
|
|
66
|
+
|
|
67
|
+
Payments open a Stripe-hosted URL in your browser; card details are never handled
|
|
68
|
+
by the CLI.
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
Precedence: command-line flags, then environment variables, then the saved config.
|
|
73
|
+
|
|
74
|
+
| Setting | Flag | Env var |
|
|
75
|
+
|---------|------|---------|
|
|
76
|
+
| API base URL | `--api-url <url>` | `GZERO_API_URL` |
|
|
77
|
+
| API token | `--token <token>` | `GZERO_TOKEN` |
|
|
78
|
+
|
|
79
|
+
The default API URL is `https://api.gzer0.app`. For local development against a
|
|
80
|
+
dev server, point it at your local API:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
GZERO_API_URL=http://localhost:3000 gzero login
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`GZERO_TOKEN` is handy for CI and other headless callers.
|
|
87
|
+
|
|
88
|
+
## Connect an AI agent (MCP)
|
|
89
|
+
|
|
90
|
+
The MCP server is hosted by GroundZero, so there is nothing extra to install.
|
|
91
|
+
Run:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
gzero mcp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It prints a ready-to-paste client config pointing at `https://mcp.gzer0.app/mcp`.
|
|
98
|
+
Add it to your AI client (for example Cursor's `~/.cursor/mcp.json`); on first
|
|
99
|
+
use the client opens a browser to sign in with GitHub. See [`@gzero/mcp`](../mcp)
|
|
100
|
+
for details.
|
|
101
|
+
|
|
102
|
+
## Examples
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Deploy a specific branch to production and wait for it to go live
|
|
106
|
+
gzero deploy my-app --branch main --env production
|
|
107
|
+
|
|
108
|
+
# Set an env var, then redeploy
|
|
109
|
+
gzero env set my-app DATABASE_URL "postgres://..."
|
|
110
|
+
gzero deploy my-app
|
|
111
|
+
|
|
112
|
+
# Attach a custom domain
|
|
113
|
+
gzero domains add my-app app.example.com
|
|
114
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
// ../sdk/dist/index.js
|
|
29
|
+
var require_dist = __commonJS({
|
|
30
|
+
"../sdk/dist/index.js"(exports) {
|
|
31
|
+
"use strict";
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.GzeroApiError = exports.DEFAULT_BASE_URL = void 0;
|
|
34
|
+
exports.createClient = createClient2;
|
|
35
|
+
exports.DEFAULT_BASE_URL = "https://api.gzer0.app";
|
|
36
|
+
var GzeroApiError2 = class extends Error {
|
|
37
|
+
status;
|
|
38
|
+
code;
|
|
39
|
+
constructor(message, status, code) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.name = "GzeroApiError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
exports.GzeroApiError = GzeroApiError2;
|
|
47
|
+
function normalizeBaseUrl(raw) {
|
|
48
|
+
const value = (raw ?? exports.DEFAULT_BASE_URL).trim().replace(/\/+$/, "");
|
|
49
|
+
return value.endsWith("/api") ? value.slice(0, -"/api".length) : value;
|
|
50
|
+
}
|
|
51
|
+
function createClient2(options = {}) {
|
|
52
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
53
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
54
|
+
let token = options.token;
|
|
55
|
+
if (!doFetch) {
|
|
56
|
+
throw new Error("No fetch implementation available; pass options.fetch on Node < 18.");
|
|
57
|
+
}
|
|
58
|
+
async function request(path, init) {
|
|
59
|
+
const headers = {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
...init?.headers
|
|
62
|
+
};
|
|
63
|
+
if (token)
|
|
64
|
+
headers.Authorization = `Bearer ${token}`;
|
|
65
|
+
const res = await doFetch(`${baseUrl}/api${path}`, { ...init, headers });
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const body = await res.json().catch(() => ({}));
|
|
68
|
+
const detail = body.blockers?.map((b) => b.message).join("; ");
|
|
69
|
+
throw new GzeroApiError2(detail ?? body.error ?? `API ${res.status}: ${path}`, res.status, body.error);
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 204)
|
|
72
|
+
return void 0;
|
|
73
|
+
return await res.json();
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
/** Set/replace the bearer token used for subsequent requests. */
|
|
77
|
+
setToken(next) {
|
|
78
|
+
token = next;
|
|
79
|
+
},
|
|
80
|
+
get baseUrl() {
|
|
81
|
+
return baseUrl;
|
|
82
|
+
},
|
|
83
|
+
// --- Device-code login (RFC 8628) ---
|
|
84
|
+
device: {
|
|
85
|
+
/**
|
|
86
|
+
* Begin a device-code login. `client` tags the minted token (cli/mcp/
|
|
87
|
+
* expressots) so it's identifiable in the operator console.
|
|
88
|
+
*/
|
|
89
|
+
start: (client2) => request("/auth/device/start", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: JSON.stringify(client2 ? { client: client2 } : {})
|
|
92
|
+
}),
|
|
93
|
+
/** One poll attempt. Resolves the RFC state; never throws on pending/slow_down. */
|
|
94
|
+
async poll(deviceCode) {
|
|
95
|
+
const res = await doFetch(`${baseUrl}/api/auth/device/token`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify({ deviceCode })
|
|
99
|
+
});
|
|
100
|
+
const body = await res.json().catch(() => ({}));
|
|
101
|
+
if (res.ok && body.access_token) {
|
|
102
|
+
return {
|
|
103
|
+
status: "success",
|
|
104
|
+
accessToken: body.access_token,
|
|
105
|
+
tokenType: body.token_type ?? "Bearer"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { status: body.error ?? "invalid_request" };
|
|
109
|
+
},
|
|
110
|
+
revoke: () => request("/auth/device/revoke", { method: "POST" })
|
|
111
|
+
},
|
|
112
|
+
// --- Identity ---
|
|
113
|
+
me: () => request("/auth/me"),
|
|
114
|
+
// --- Repos ---
|
|
115
|
+
listRepos: (opts) => request(`/github/repos${opts?.refresh ? "?refresh=true" : ""}`),
|
|
116
|
+
// --- Scanner ---
|
|
117
|
+
scanRepo: (input) => request("/scanner/scan", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: JSON.stringify(input)
|
|
120
|
+
}),
|
|
121
|
+
// --- Products ---
|
|
122
|
+
listProducts: () => request("/products?include=services"),
|
|
123
|
+
getProduct: (productId) => request(`/products/${productId}`),
|
|
124
|
+
createProduct: (name, description) => request("/products", {
|
|
125
|
+
method: "POST",
|
|
126
|
+
body: JSON.stringify({ name, description })
|
|
127
|
+
}),
|
|
128
|
+
// --- Projects ---
|
|
129
|
+
listProjects: () => request("/projects"),
|
|
130
|
+
getProjectStatus: (projectId) => request(`/projects/${projectId}/status`),
|
|
131
|
+
getReadiness: (projectId) => request(`/projects/${projectId}/readiness`),
|
|
132
|
+
listBranches: (projectId) => request(`/projects/${projectId}/branches`),
|
|
133
|
+
createProject: (input) => request("/projects", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify(input)
|
|
136
|
+
}),
|
|
137
|
+
deploy: (projectId, opts = {}) => request(`/projects/${projectId}/deploy`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
acknowledgeWarnings: opts.acknowledgeWarnings ?? false,
|
|
141
|
+
branch: opts.branch,
|
|
142
|
+
environment: opts.environment
|
|
143
|
+
})
|
|
144
|
+
}),
|
|
145
|
+
listDeploys: (projectId) => request(`/projects/${projectId}/deploys`),
|
|
146
|
+
getDeploy: (deployId) => request(`/deploys/${deployId}`),
|
|
147
|
+
rollback: (projectId, deployId) => request(`/projects/${projectId}/rollback`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
body: JSON.stringify({ deployId })
|
|
150
|
+
}),
|
|
151
|
+
promote: (projectId, deployId) => request(`/projects/${projectId}/promote`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
body: JSON.stringify({ deployId })
|
|
154
|
+
}),
|
|
155
|
+
// --- Env vars ---
|
|
156
|
+
listEnvVars: (projectId) => request(`/projects/${projectId}/env-vars`),
|
|
157
|
+
upsertEnvVar: (projectId, key, value) => request(`/projects/${projectId}/env-vars`, {
|
|
158
|
+
method: "PUT",
|
|
159
|
+
body: JSON.stringify({ key, value })
|
|
160
|
+
}),
|
|
161
|
+
bulkUpsertEnvVars: (projectId, entries) => request(`/projects/${projectId}/env-vars/bulk`, {
|
|
162
|
+
method: "PUT",
|
|
163
|
+
body: JSON.stringify({ entries })
|
|
164
|
+
}),
|
|
165
|
+
deleteEnvVar: (projectId, key) => request(`/projects/${projectId}/env-vars/${encodeURIComponent(key)}`, {
|
|
166
|
+
method: "DELETE"
|
|
167
|
+
}),
|
|
168
|
+
// --- Domains ---
|
|
169
|
+
listDomains: (projectId) => request(`/projects/${projectId}/domains`),
|
|
170
|
+
addDomain: (projectId, hostname) => request(`/projects/${projectId}/domains`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
body: JSON.stringify({ hostname })
|
|
173
|
+
}),
|
|
174
|
+
verifyDomain: (projectId, domainId) => request(`/projects/${projectId}/domains/${domainId}/verify`, {
|
|
175
|
+
method: "POST"
|
|
176
|
+
}),
|
|
177
|
+
removeDomain: (projectId, domainId) => request(`/projects/${projectId}/domains/${domainId}`, { method: "DELETE" }),
|
|
178
|
+
// --- Billing ---
|
|
179
|
+
getBillingStatus: () => request("/billing/status"),
|
|
180
|
+
createCheckout: () => request("/billing/checkout", { method: "POST" }),
|
|
181
|
+
createAddonCheckout: (addonId, productId) => request("/billing/addons/checkout", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
body: JSON.stringify({ addonId, productId })
|
|
184
|
+
}),
|
|
185
|
+
createBillingPortal: () => request("/billing/portal", { method: "POST" }),
|
|
186
|
+
listInvoices: () => request("/billing/invoices")
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// src/index.ts
|
|
193
|
+
var import_sdk = __toESM(require_dist(), 1);
|
|
194
|
+
import { spawn } from "child_process";
|
|
195
|
+
import { Command } from "commander";
|
|
196
|
+
import * as p from "@clack/prompts";
|
|
197
|
+
import pc from "picocolors";
|
|
198
|
+
|
|
199
|
+
// src/config.ts
|
|
200
|
+
import { homedir } from "os";
|
|
201
|
+
import { join } from "path";
|
|
202
|
+
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
203
|
+
function configDir() {
|
|
204
|
+
if (process.platform === "win32") {
|
|
205
|
+
const appData = process.env.APPDATA?.trim();
|
|
206
|
+
if (appData) return join(appData, "gzero");
|
|
207
|
+
}
|
|
208
|
+
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
209
|
+
return join(base, "gzero");
|
|
210
|
+
}
|
|
211
|
+
function configPath() {
|
|
212
|
+
return join(configDir(), "config.json");
|
|
213
|
+
}
|
|
214
|
+
async function loadConfig() {
|
|
215
|
+
try {
|
|
216
|
+
const raw = await readFile(configPath(), "utf8");
|
|
217
|
+
return JSON.parse(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function saveConfig(config) {
|
|
223
|
+
await mkdir(configDir(), { recursive: true, mode: 448 });
|
|
224
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}
|
|
225
|
+
`, { mode: 384 });
|
|
226
|
+
}
|
|
227
|
+
async function clearConfig() {
|
|
228
|
+
try {
|
|
229
|
+
await rm(configPath());
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function resolveSettings(flags2) {
|
|
234
|
+
const file = await loadConfig();
|
|
235
|
+
return {
|
|
236
|
+
baseUrl: flags2.apiUrl ?? process.env.GZERO_API_URL ?? file.baseUrl,
|
|
237
|
+
token: flags2.token ?? process.env.GZERO_TOKEN ?? file.token,
|
|
238
|
+
fromFile: file
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/index.ts
|
|
243
|
+
function openBrowser(url) {
|
|
244
|
+
const platform = process.platform;
|
|
245
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
246
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
247
|
+
try {
|
|
248
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
249
|
+
child.on("error", () => void 0);
|
|
250
|
+
child.unref();
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function die(message) {
|
|
255
|
+
p.cancel(message);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
async function client(flags2, requireAuth = true) {
|
|
259
|
+
const settings = await resolveSettings(flags2);
|
|
260
|
+
if (requireAuth && !settings.token) {
|
|
261
|
+
die("Not signed in. Run `gzero login` first.");
|
|
262
|
+
}
|
|
263
|
+
return (0, import_sdk.createClient)({ baseUrl: settings.baseUrl, token: settings.token });
|
|
264
|
+
}
|
|
265
|
+
function fail(err) {
|
|
266
|
+
if (err instanceof import_sdk.GzeroApiError) die(err.message);
|
|
267
|
+
die(err instanceof Error ? err.message : String(err));
|
|
268
|
+
}
|
|
269
|
+
async function resolveProject(c, ref) {
|
|
270
|
+
let projects;
|
|
271
|
+
try {
|
|
272
|
+
projects = await c.listProjects();
|
|
273
|
+
} catch (err) {
|
|
274
|
+
fail(err);
|
|
275
|
+
}
|
|
276
|
+
if (projects.length === 0) die("No projects yet. Create one in the dashboard, then deploy.");
|
|
277
|
+
if (ref) {
|
|
278
|
+
const match = projects.find(
|
|
279
|
+
(proj) => proj.id === ref || proj.slug === ref || proj.name === ref
|
|
280
|
+
);
|
|
281
|
+
if (!match) die(`No project matching "${ref}".`);
|
|
282
|
+
return match;
|
|
283
|
+
}
|
|
284
|
+
if (projects.length === 1) return projects[0];
|
|
285
|
+
const picked = await p.select({
|
|
286
|
+
message: "Select a project",
|
|
287
|
+
options: projects.map((proj) => ({
|
|
288
|
+
value: proj.id,
|
|
289
|
+
label: `${proj.name} ${pc.dim(`(${proj.slug})`)}`
|
|
290
|
+
}))
|
|
291
|
+
});
|
|
292
|
+
if (p.isCancel(picked)) die("Cancelled.");
|
|
293
|
+
return projects.find((proj) => proj.id === picked);
|
|
294
|
+
}
|
|
295
|
+
var program = new Command();
|
|
296
|
+
program.name("gzero").description("GroundZero: deploy UIs and APIs from the terminal or an AI agent.").version("0.1.0-preview.1").option("--api-url <url>", "API base URL (default: config or GZERO_API_URL)").option("--token <token>", "API token (default: config or GZERO_TOKEN)");
|
|
297
|
+
function flags(command) {
|
|
298
|
+
return command.optsWithGlobals();
|
|
299
|
+
}
|
|
300
|
+
program.command("login").description("Sign in via the browser (device-code flow) and save an API token.").action(async (_opts, command) => {
|
|
301
|
+
const gf = flags(command);
|
|
302
|
+
p.intro(pc.bgCyan(pc.black(" GroundZero login ")));
|
|
303
|
+
const settings = await resolveSettings(gf);
|
|
304
|
+
const baseUrl = settings.baseUrl ?? import_sdk.DEFAULT_BASE_URL;
|
|
305
|
+
const c = (0, import_sdk.createClient)({ baseUrl });
|
|
306
|
+
const s = p.spinner();
|
|
307
|
+
s.start("Requesting device code");
|
|
308
|
+
let start;
|
|
309
|
+
try {
|
|
310
|
+
start = await c.device.start("cli");
|
|
311
|
+
} catch (err) {
|
|
312
|
+
s.stop("Failed to start login");
|
|
313
|
+
fail(err);
|
|
314
|
+
}
|
|
315
|
+
s.stop("Device code ready");
|
|
316
|
+
p.note(
|
|
317
|
+
`${pc.bold("Your code:")} ${pc.cyan(start.user_code)}
|
|
318
|
+
|
|
319
|
+
Open this URL to authorize:
|
|
320
|
+
${pc.underline(start.verification_uri_complete)}`,
|
|
321
|
+
"Authorize this device"
|
|
322
|
+
);
|
|
323
|
+
openBrowser(start.verification_uri_complete);
|
|
324
|
+
const waiting = p.spinner();
|
|
325
|
+
waiting.start("Waiting for authorization in the browser");
|
|
326
|
+
const deadline = Date.now() + start.expires_in * 1e3;
|
|
327
|
+
let intervalMs = Math.max(start.interval, 1) * 1e3;
|
|
328
|
+
let token = null;
|
|
329
|
+
while (Date.now() < deadline) {
|
|
330
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
331
|
+
const poll = await c.device.poll(start.device_code);
|
|
332
|
+
if (poll.status === "success") {
|
|
333
|
+
token = poll.accessToken;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
if (poll.status === "slow_down") {
|
|
337
|
+
intervalMs += 2e3;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (poll.status === "authorization_pending") continue;
|
|
341
|
+
waiting.stop("Authorization failed");
|
|
342
|
+
die(
|
|
343
|
+
poll.status === "expired_token" ? "The code expired. Run `gzero login` again." : "Authorization was denied."
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (!token) {
|
|
347
|
+
waiting.stop("Timed out");
|
|
348
|
+
die("Login timed out. Run `gzero login` again.");
|
|
349
|
+
}
|
|
350
|
+
waiting.stop("Authorized");
|
|
351
|
+
await saveConfig({ baseUrl, token });
|
|
352
|
+
c.setToken(token);
|
|
353
|
+
p.note(pc.dim(configPath()), "Credentials saved to");
|
|
354
|
+
try {
|
|
355
|
+
const me = await c.me();
|
|
356
|
+
p.outro(`Signed in as ${pc.green(me.githubLogin ?? me.name ?? "your account")}.`);
|
|
357
|
+
} catch {
|
|
358
|
+
p.outro("Signed in.");
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
program.command("logout").description("Revoke the current token and remove local credentials.").action(async (_opts, command) => {
|
|
362
|
+
const gf = flags(command);
|
|
363
|
+
const settings = await resolveSettings(gf);
|
|
364
|
+
if (settings.token) {
|
|
365
|
+
const c = (0, import_sdk.createClient)({ baseUrl: settings.baseUrl, token: settings.token });
|
|
366
|
+
try {
|
|
367
|
+
await c.device.revoke();
|
|
368
|
+
} catch {
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
await clearConfig();
|
|
372
|
+
p.outro("Signed out.");
|
|
373
|
+
});
|
|
374
|
+
program.command("whoami").description("Show the signed-in account.").action(async (_opts, command) => {
|
|
375
|
+
const c = await client(flags(command));
|
|
376
|
+
try {
|
|
377
|
+
const me = await c.me();
|
|
378
|
+
console.log(`${pc.bold(me.githubLogin ?? me.name ?? me.id)} ${pc.dim(me.email ?? "")}`);
|
|
379
|
+
console.log(pc.dim(`plan: ${me.plan}${me.isAdmin ? " (admin)" : ""}`));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
fail(err);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
program.command("projects").alias("ls").description("List your projects.").action(async (_opts, command) => {
|
|
385
|
+
const c = await client(flags(command));
|
|
386
|
+
try {
|
|
387
|
+
const projects = await c.listProjects();
|
|
388
|
+
if (projects.length === 0) {
|
|
389
|
+
console.log(pc.dim("No projects yet."));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const proj of projects) {
|
|
393
|
+
const status = proj.activeDeployStatus ?? "-";
|
|
394
|
+
const url = proj.activeDeployUrl ?? "";
|
|
395
|
+
console.log(
|
|
396
|
+
`${pc.bold(proj.name)} ${pc.dim(`(${proj.slug})`)} ${statusColor(status)} ${pc.cyan(url)}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
fail(err);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
function statusColor(status) {
|
|
404
|
+
if (status === "live") return pc.green(status);
|
|
405
|
+
if (status === "failed") return pc.red(status);
|
|
406
|
+
if (status === "building" || status === "deploying" || status === "pending")
|
|
407
|
+
return pc.yellow(status);
|
|
408
|
+
return pc.dim(status);
|
|
409
|
+
}
|
|
410
|
+
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) => {
|
|
411
|
+
const c = await client(flags(command));
|
|
412
|
+
const project = await resolveProject(c, projectRef);
|
|
413
|
+
const s = p.spinner();
|
|
414
|
+
s.start(`Deploying ${project.name}`);
|
|
415
|
+
let deploy;
|
|
416
|
+
try {
|
|
417
|
+
deploy = await c.deploy(project.id, {
|
|
418
|
+
acknowledgeWarnings: Boolean(opts.yes),
|
|
419
|
+
branch: opts.branch,
|
|
420
|
+
environment: opts.env
|
|
421
|
+
});
|
|
422
|
+
} catch (err) {
|
|
423
|
+
s.stop("Deploy failed to start");
|
|
424
|
+
if (err instanceof import_sdk.GzeroApiError) {
|
|
425
|
+
die(`${err.message}
|
|
426
|
+
${pc.dim("Re-run with --yes to acknowledge warnings.")}`);
|
|
427
|
+
}
|
|
428
|
+
fail(err);
|
|
429
|
+
}
|
|
430
|
+
if (opts.follow === false) {
|
|
431
|
+
s.stop(`Deploy queued (${deploy.id}).`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const terminal = /* @__PURE__ */ new Set(["live", "failed", "rolled_back"]);
|
|
435
|
+
const deadline = Date.now() + 15 * 60 * 1e3;
|
|
436
|
+
let current = deploy;
|
|
437
|
+
while (!terminal.has(current.status) && Date.now() < deadline) {
|
|
438
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
439
|
+
try {
|
|
440
|
+
current = await c.getDeploy(deploy.id);
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
s.message(`${project.name}: ${current.phase ?? current.status}`);
|
|
444
|
+
}
|
|
445
|
+
if (current.status === "live") {
|
|
446
|
+
s.stop(pc.green(`Live: ${current.url ?? project.slug}`));
|
|
447
|
+
} else if (current.status === "failed") {
|
|
448
|
+
s.stop(pc.red("Deploy failed"));
|
|
449
|
+
if (current.buildLog) console.log(pc.dim(current.buildLog.slice(-2e3)));
|
|
450
|
+
process.exit(1);
|
|
451
|
+
} else {
|
|
452
|
+
s.stop(`Deploy status: ${current.status}`);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
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) => {
|
|
456
|
+
const c = await client(flags(command));
|
|
457
|
+
const project = await resolveProject(c, projectRef);
|
|
458
|
+
try {
|
|
459
|
+
const status = await c.getProjectStatus(project.id);
|
|
460
|
+
const active = status.activeDeploy;
|
|
461
|
+
console.log(pc.bold(project.name) + pc.dim(` (${project.slug})`));
|
|
462
|
+
console.log(` status: ${active ? statusColor(active.status) : pc.dim("no deploys")}`);
|
|
463
|
+
if (active?.url) console.log(` url: ${pc.cyan(active.url)}`);
|
|
464
|
+
console.log(` env vars: ${status.envConfigured ? pc.green("configured") : pc.yellow("missing")}`);
|
|
465
|
+
console.log(` ready: ${status.readiness.canDeploy ? pc.green("yes") : pc.red("no")}`);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
fail(err);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
program.command("logs").argument("[project]", "project id, slug, or name").description("Print the build log of the latest deploy.").action(async (projectRef, _opts, command) => {
|
|
471
|
+
const c = await client(flags(command));
|
|
472
|
+
const project = await resolveProject(c, projectRef);
|
|
473
|
+
try {
|
|
474
|
+
const deploys = await c.listDeploys(project.id);
|
|
475
|
+
const latest = deploys[0];
|
|
476
|
+
if (!latest) die("No deploys yet.");
|
|
477
|
+
const full = await c.getDeploy(latest.id);
|
|
478
|
+
console.log(pc.dim(`# ${project.name} deploy ${full.id} (${full.status})`));
|
|
479
|
+
console.log(full.buildLog ?? pc.dim("(no build log)"));
|
|
480
|
+
} catch (err) {
|
|
481
|
+
fail(err);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
var env = program.command("env").description("Manage project environment variables.");
|
|
485
|
+
env.command("list").argument("[project]", "project id, slug, or name").description("List env var keys for a project.").action(async (projectRef, _opts, command) => {
|
|
486
|
+
const c = await client(flags(command));
|
|
487
|
+
const project = await resolveProject(c, projectRef);
|
|
488
|
+
try {
|
|
489
|
+
const vars = await c.listEnvVars(project.id);
|
|
490
|
+
if (vars.length === 0) {
|
|
491
|
+
console.log(pc.dim("No env vars set."));
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
for (const v of vars) {
|
|
495
|
+
console.log(`${v.key} ${v.configured ? pc.green("set") : pc.yellow("empty")}`);
|
|
496
|
+
}
|
|
497
|
+
} catch (err) {
|
|
498
|
+
fail(err);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
env.command("set").argument("<project>", "project id, slug, or name").argument("<key>", "variable name").argument("<value>", "variable value").description("Set (or update) an environment variable.").action(async (projectRef, key, value, _opts, command) => {
|
|
502
|
+
const c = await client(flags(command));
|
|
503
|
+
const project = await resolveProject(c, projectRef);
|
|
504
|
+
try {
|
|
505
|
+
await c.upsertEnvVar(project.id, key, value);
|
|
506
|
+
p.outro(`Set ${pc.bold(key)} on ${project.name}.`);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
fail(err);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
var domains = program.command("domains").description("Manage custom domains.");
|
|
512
|
+
domains.command("add").argument("<project>", "project id, slug, or name").argument("<hostname>", "custom domain, e.g. app.example.com").description("Attach a custom domain to a project.").action(async (projectRef, hostname, _opts, command) => {
|
|
513
|
+
const c = await client(flags(command));
|
|
514
|
+
const project = await resolveProject(c, projectRef);
|
|
515
|
+
try {
|
|
516
|
+
const domain = await c.addDomain(project.id, hostname);
|
|
517
|
+
p.outro(
|
|
518
|
+
`Added ${pc.bold(hostname)}. Point a CNAME to ${pc.cyan(domain.cnameTarget)}, then verify in the dashboard.`
|
|
519
|
+
);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
fail(err);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
var billing = program.command("billing").description("View plan and start payments.");
|
|
525
|
+
billing.command("status", { isDefault: true }).description("Show current plan, usage, and available add-ons.").action(async (_opts, command) => {
|
|
526
|
+
const c = await client(flags(command));
|
|
527
|
+
try {
|
|
528
|
+
const status = await c.getBillingStatus();
|
|
529
|
+
console.log(`${pc.bold("Plan:")} ${status.planDisplayName} ${pc.dim(`(${status.subscriptionStatus})`)}`);
|
|
530
|
+
console.log(`${pc.bold("Stacks:")} ${status.limits.stacksLabel}`);
|
|
531
|
+
console.log(`${pc.bold("Traffic:")} ${status.limits.trafficLabel}`);
|
|
532
|
+
if (status.availableAddons.length > 0) {
|
|
533
|
+
console.log(pc.bold("\nAvailable add-ons:"));
|
|
534
|
+
for (const a of status.availableAddons) {
|
|
535
|
+
console.log(
|
|
536
|
+
` ${pc.cyan(a.id)} ${a.displayName} ${pc.dim(`$${(a.priceCents / 100).toFixed(2)}/mo`)}`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
console.log(pc.dim("\nRun `gzero billing addon <id>` to purchase."));
|
|
540
|
+
}
|
|
541
|
+
} catch (err) {
|
|
542
|
+
fail(err);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
billing.command("upgrade").description("Open Stripe checkout to subscribe to the Solo plan.").action(async (_opts, command) => {
|
|
546
|
+
const c = await client(flags(command));
|
|
547
|
+
try {
|
|
548
|
+
const { url } = await c.createCheckout();
|
|
549
|
+
p.note(pc.underline(url), "Complete payment in your browser");
|
|
550
|
+
openBrowser(url);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
fail(err);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
billing.command("addon").argument("<addonId>", "add-on id (see `gzero billing`)").option("--product <productId>", "product to attach the add-on to").description("Open Stripe checkout for an add-on.").action(async (addonId, opts, command) => {
|
|
556
|
+
const c = await client(flags(command));
|
|
557
|
+
try {
|
|
558
|
+
const { url } = await c.createAddonCheckout(addonId, opts.product);
|
|
559
|
+
p.note(pc.underline(url), "Complete payment in your browser");
|
|
560
|
+
openBrowser(url);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
fail(err);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
billing.command("portal").description("Open the Stripe billing portal to manage your subscription.").action(async (_opts, command) => {
|
|
566
|
+
const c = await client(flags(command));
|
|
567
|
+
try {
|
|
568
|
+
const { url } = await c.createBillingPortal();
|
|
569
|
+
p.note(pc.underline(url), "Manage billing in your browser");
|
|
570
|
+
openBrowser(url);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
fail(err);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
function mcpEndpoint(baseUrl) {
|
|
576
|
+
if (process.env.GZERO_MCP_URL) return process.env.GZERO_MCP_URL;
|
|
577
|
+
try {
|
|
578
|
+
const u = new URL(baseUrl);
|
|
579
|
+
if (u.hostname.startsWith("api.")) u.hostname = `mcp.${u.hostname.slice(4)}`;
|
|
580
|
+
return `${u.origin}/mcp`;
|
|
581
|
+
} catch {
|
|
582
|
+
return `${baseUrl.replace(/\/+$/, "")}/mcp`;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
program.command("mcp").description("Print config to connect an AI client to the hosted GroundZero MCP server.").action(async (_opts, command) => {
|
|
586
|
+
const settings = await resolveSettings(flags(command));
|
|
587
|
+
const url = mcpEndpoint(settings.baseUrl ?? import_sdk.DEFAULT_BASE_URL);
|
|
588
|
+
const config = JSON.stringify({ mcpServers: { gzero: { url } } }, null, 2);
|
|
589
|
+
p.intro(pc.bgCyan(pc.black(" GroundZero MCP ")));
|
|
590
|
+
p.note(
|
|
591
|
+
`The MCP server is hosted at ${pc.cyan(url)}.
|
|
592
|
+
Add it to your AI client (Cursor: ${pc.dim("~/.cursor/mcp.json")}) and it will
|
|
593
|
+
open a browser to sign in with GitHub the first time. Nothing to install.`,
|
|
594
|
+
"Hosted MCP"
|
|
595
|
+
);
|
|
596
|
+
p.note(config, "Client config");
|
|
597
|
+
p.outro("Paste the config above, then reload your AI client.");
|
|
598
|
+
});
|
|
599
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
600
|
+
fail(err);
|
|
601
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gzero-cli",
|
|
3
|
+
"version": "0.1.0-preview.1",
|
|
4
|
+
"description": "GroundZero command-line interface: deploy UIs and APIs, manage env, domains, and billing.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gzero": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"tag": "preview"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup && chmod +x dist/index.js",
|
|
19
|
+
"dev": "tsup --watch"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@clack/prompts": "^0.11.0",
|
|
26
|
+
"commander": "^14.0.1",
|
|
27
|
+
"picocolors": "^1.1.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@gzero/sdk": "*",
|
|
31
|
+
"@types/node": "25.0.3",
|
|
32
|
+
"tsup": "^8",
|
|
33
|
+
"typescript": "5.9.3"
|
|
34
|
+
}
|
|
35
|
+
}
|