@cloudgrid-io/cli 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2094 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/shared-services.yaml +14 -0
- package/templates/CLOUDGRID.md +94 -0
- package/templates/dockerfiles/cron.Dockerfile +7 -0
- package/templates/dockerfiles/nextjs.Dockerfile +17 -0
- package/templates/dockerfiles/node.Dockerfile +8 -0
- package/templates/dockerfiles/python.Dockerfile +8 -0
- package/templates/dockerfiles/static.Dockerfile +4 -0
- package/templates/dockerfiles/static.nginx.conf +12 -0
- package/templates/dockerfiles/typescript.Dockerfile +10 -0
- package/templates/samples/full-stack.yaml +11 -0
- package/templates/samples/multi-service.yaml +14 -0
- package/templates/samples/node-api.yaml +7 -0
- package/templates/samples/python-worker.yaml +9 -0
- package/templates/samples/static-site.yaml +5 -0
- package/templates/stubs/cron.stub.js +14 -0
- package/templates/stubs/cron.stub.package.json +9 -0
- package/templates/stubs/nextjs.stub/next.config.js +5 -0
- package/templates/stubs/nextjs.stub/package.json +15 -0
- package/templates/stubs/nextjs.stub/src/app/health/route.js +3 -0
- package/templates/stubs/nextjs.stub/src/app/layout.js +4 -0
- package/templates/stubs/nextjs.stub/src/app/page.js +3 -0
- package/templates/stubs/node.stub.js +13 -0
- package/templates/stubs/node.stub.package.json +12 -0
- package/templates/stubs/python.stub.py +16 -0
- package/templates/stubs/python.stub.requirements.txt +2 -0
- package/templates/stubs/static.stub/index.html +8 -0
- package/templates/stubs/typescript.stub.package.json +19 -0
- package/templates/stubs/typescript.stub.ts +14 -0
- package/templates/stubs/typescript.stub.tsconfig.json +12 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2094 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command21 } from "commander";
|
|
5
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
|
+
import { dirname as dirname2, join as join9 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/utils.ts
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
import ora from "ora";
|
|
13
|
+
|
|
14
|
+
// ../shared/src/errors.ts
|
|
15
|
+
var CloudGridError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CloudGridError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function die(msg) {
|
|
22
|
+
throw new CloudGridError(msg);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ../shared/src/paths.ts
|
|
26
|
+
import { existsSync } from "fs";
|
|
27
|
+
import { dirname, join } from "path";
|
|
28
|
+
import { fileURLToPath } from "url";
|
|
29
|
+
function findPackageRoot() {
|
|
30
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
while (!existsSync(join(dir, "package.json"))) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) throw new Error("Cannot find package root");
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
var PKG_ROOT = findPackageRoot();
|
|
39
|
+
var TEMPLATES_DIR = join(PKG_ROOT, "templates");
|
|
40
|
+
var SHARED_SERVICES_PATH = join(PKG_ROOT, "shared-services.yaml");
|
|
41
|
+
|
|
42
|
+
// src/utils.ts
|
|
43
|
+
var _verbose = false;
|
|
44
|
+
function setVerbose(v) {
|
|
45
|
+
_verbose = v;
|
|
46
|
+
}
|
|
47
|
+
function isVerbose() {
|
|
48
|
+
return _verbose;
|
|
49
|
+
}
|
|
50
|
+
async function exec(cmd, args, opts) {
|
|
51
|
+
return execa(cmd, args, { ...opts, stdio: opts?.stdio ?? "pipe" });
|
|
52
|
+
}
|
|
53
|
+
var log = {
|
|
54
|
+
info: (msg) => console.log(chalk.blue(" " + msg)),
|
|
55
|
+
success: (msg) => console.log(chalk.green(" " + msg)),
|
|
56
|
+
warn: (msg) => console.log(chalk.yellow(" " + msg)),
|
|
57
|
+
error: (msg) => console.error(chalk.red("ERROR: " + msg)),
|
|
58
|
+
step: (msg) => console.log(chalk.gray(" " + msg))
|
|
59
|
+
};
|
|
60
|
+
function spinner(text) {
|
|
61
|
+
return ora({ text, color: "cyan" });
|
|
62
|
+
}
|
|
63
|
+
function die2(msg) {
|
|
64
|
+
log.error(msg);
|
|
65
|
+
throw new CloudGridError(msg);
|
|
66
|
+
}
|
|
67
|
+
function parseEnvFile(content) {
|
|
68
|
+
const values = {};
|
|
69
|
+
for (const line of content.split("\n")) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
72
|
+
const stripped = trimmed.startsWith("export ") ? trimmed.slice(7) : trimmed;
|
|
73
|
+
const eq = stripped.indexOf("=");
|
|
74
|
+
if (eq < 1) continue;
|
|
75
|
+
let val = stripped.slice(eq + 1);
|
|
76
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
77
|
+
val = val.slice(1, -1);
|
|
78
|
+
}
|
|
79
|
+
values[stripped.slice(0, eq)] = val;
|
|
80
|
+
}
|
|
81
|
+
return values;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/commands/init.ts
|
|
85
|
+
import { Command } from "commander";
|
|
86
|
+
import inquirer from "inquirer";
|
|
87
|
+
|
|
88
|
+
// src/config.ts
|
|
89
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync as existsSync2 } from "fs";
|
|
90
|
+
import { join as join2 } from "path";
|
|
91
|
+
import { homedir } from "os";
|
|
92
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
93
|
+
|
|
94
|
+
// src/api-client.ts
|
|
95
|
+
import * as net from "net";
|
|
96
|
+
import WebSocket from "ws";
|
|
97
|
+
var ApiError = class extends Error {
|
|
98
|
+
constructor(status, code, message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.status = status;
|
|
101
|
+
this.code = code;
|
|
102
|
+
this.name = "ApiError";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
var CloudGridApiClient = class {
|
|
106
|
+
baseUrl;
|
|
107
|
+
jwt;
|
|
108
|
+
constructor(apiUrl, jwt) {
|
|
109
|
+
this.baseUrl = apiUrl.replace(/\/+$/, "");
|
|
110
|
+
this.jwt = jwt;
|
|
111
|
+
}
|
|
112
|
+
// ── HTTP helpers ────────────────────────────────────────────
|
|
113
|
+
async request(method, path, body) {
|
|
114
|
+
const url = `${this.baseUrl}${path}`;
|
|
115
|
+
const headers = {
|
|
116
|
+
"authorization": `Bearer ${this.jwt}`
|
|
117
|
+
};
|
|
118
|
+
if (body !== void 0) {
|
|
119
|
+
headers["Content-Type"] = "application/json";
|
|
120
|
+
}
|
|
121
|
+
if (isVerbose()) {
|
|
122
|
+
console.error(` [verbose] ${method} ${url}`);
|
|
123
|
+
}
|
|
124
|
+
const res = await fetch(url, {
|
|
125
|
+
method,
|
|
126
|
+
headers,
|
|
127
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
128
|
+
});
|
|
129
|
+
if (isVerbose()) {
|
|
130
|
+
console.error(` [verbose] ${res.status} ${res.statusText}`);
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
let errBody;
|
|
134
|
+
try {
|
|
135
|
+
errBody = await res.json();
|
|
136
|
+
} catch {
|
|
137
|
+
errBody = { error: res.statusText, code: "UNKNOWN" };
|
|
138
|
+
}
|
|
139
|
+
throw new ApiError(res.status, errBody.code, errBody.error);
|
|
140
|
+
}
|
|
141
|
+
if (res.status === 204) return void 0;
|
|
142
|
+
return await res.json();
|
|
143
|
+
}
|
|
144
|
+
// ── Public API methods ──────────────────────────────────────
|
|
145
|
+
async build(name, services, cloudgridYaml, tag) {
|
|
146
|
+
return this.request("POST", `/apps/${name}/build`, {
|
|
147
|
+
cloudgrid_yaml: cloudgridYaml,
|
|
148
|
+
services,
|
|
149
|
+
tag
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async deploy(name, images, cloudgridYaml) {
|
|
153
|
+
return this.request("POST", `/apps/${name}/deploy`, {
|
|
154
|
+
images,
|
|
155
|
+
cloudgrid_yaml: cloudgridYaml
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async list() {
|
|
159
|
+
return this.request("GET", "/apps");
|
|
160
|
+
}
|
|
161
|
+
async status(name) {
|
|
162
|
+
return this.request("GET", `/apps/${name}`);
|
|
163
|
+
}
|
|
164
|
+
async logs(name, opts) {
|
|
165
|
+
const params = new URLSearchParams();
|
|
166
|
+
if (opts?.service) params.set("service", opts.service);
|
|
167
|
+
if (opts?.tail) params.set("tail", String(opts.tail));
|
|
168
|
+
const qs = params.toString();
|
|
169
|
+
const path = `/apps/${name}/logs${qs ? `?${qs}` : ""}`;
|
|
170
|
+
const result = await this.request("GET", path);
|
|
171
|
+
return result.logs;
|
|
172
|
+
}
|
|
173
|
+
async remove(name) {
|
|
174
|
+
await this.request("DELETE", `/apps/${name}`);
|
|
175
|
+
}
|
|
176
|
+
async listRepos() {
|
|
177
|
+
return this.request("GET", "/github/repos");
|
|
178
|
+
}
|
|
179
|
+
async connect(name, repo, branch) {
|
|
180
|
+
return this.request("POST", `/apps/${name}/connect`, { repo, branch });
|
|
181
|
+
}
|
|
182
|
+
async disconnect(name) {
|
|
183
|
+
return this.request("DELETE", `/apps/${name}/connect`);
|
|
184
|
+
}
|
|
185
|
+
async listBuilds(name) {
|
|
186
|
+
return this.request("GET", `/apps/${name}/builds`);
|
|
187
|
+
}
|
|
188
|
+
async listSecrets(name) {
|
|
189
|
+
return this.request("GET", `/apps/${name}/secrets`);
|
|
190
|
+
}
|
|
191
|
+
async setSecrets(name, values) {
|
|
192
|
+
return this.request("POST", `/apps/${name}/secrets`, { values });
|
|
193
|
+
}
|
|
194
|
+
async getSecret(name, key) {
|
|
195
|
+
return this.request("GET", `/apps/${name}/secrets/${encodeURIComponent(key)}`);
|
|
196
|
+
}
|
|
197
|
+
async removeSecret(name, key) {
|
|
198
|
+
return this.request("DELETE", `/apps/${name}/secrets/${encodeURIComponent(key)}`);
|
|
199
|
+
}
|
|
200
|
+
async listEnv(name) {
|
|
201
|
+
return this.request("GET", `/apps/${name}/env`);
|
|
202
|
+
}
|
|
203
|
+
async setEnv(name, values) {
|
|
204
|
+
return this.request("POST", `/apps/${name}/env`, { values });
|
|
205
|
+
}
|
|
206
|
+
async removeEnv(name, key) {
|
|
207
|
+
return this.request("DELETE", `/apps/${name}/env/${encodeURIComponent(key)}`);
|
|
208
|
+
}
|
|
209
|
+
async listEvents(name) {
|
|
210
|
+
return this.request("GET", `/apps/${name}/events`);
|
|
211
|
+
}
|
|
212
|
+
async getUsage(name) {
|
|
213
|
+
return this.request("GET", `/apps/${name}/usage`);
|
|
214
|
+
}
|
|
215
|
+
// ── WebSocket tunnel ────────────────────────────────────────
|
|
216
|
+
/**
|
|
217
|
+
* Opens a local TCP server on `localPort`. For each incoming connection,
|
|
218
|
+
* opens a WebSocket to the API tunnel endpoint and bridges data
|
|
219
|
+
* bidirectionally between the local TCP socket and the WebSocket.
|
|
220
|
+
*
|
|
221
|
+
* Returns the TCP server so the caller can close it.
|
|
222
|
+
*/
|
|
223
|
+
openTunnel(service, localPort) {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const server = net.createServer((tcpSocket) => {
|
|
226
|
+
const wsProto = this.baseUrl.startsWith("https") ? "wss" : "ws";
|
|
227
|
+
const host = this.baseUrl.replace(/^https?:\/\//, "");
|
|
228
|
+
const wsUrl = `${wsProto}://${host}/tunnel/${service}`;
|
|
229
|
+
const wsHeaders = {
|
|
230
|
+
"authorization": `Bearer ${this.jwt}`
|
|
231
|
+
};
|
|
232
|
+
const ws = new WebSocket(wsUrl, { headers: wsHeaders });
|
|
233
|
+
ws.binaryType = "nodebuffer";
|
|
234
|
+
ws.on("open", () => {
|
|
235
|
+
tcpSocket.on("data", (data) => {
|
|
236
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
237
|
+
ws.send(data);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
ws.on("message", (data) => {
|
|
241
|
+
if (!tcpSocket.destroyed) {
|
|
242
|
+
tcpSocket.write(data);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
ws.on("close", () => {
|
|
247
|
+
if (!tcpSocket.destroyed) tcpSocket.destroy();
|
|
248
|
+
});
|
|
249
|
+
ws.on("error", () => {
|
|
250
|
+
if (!tcpSocket.destroyed) tcpSocket.destroy();
|
|
251
|
+
});
|
|
252
|
+
tcpSocket.on("close", () => {
|
|
253
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
254
|
+
ws.close();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
tcpSocket.on("error", () => {
|
|
258
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
259
|
+
ws.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
server.on("error", reject);
|
|
264
|
+
server.listen(localPort, "127.0.0.1", () => {
|
|
265
|
+
server.removeListener("error", reject);
|
|
266
|
+
server.on("error", (err) => {
|
|
267
|
+
console.error(`Tunnel server error: ${err.message}`);
|
|
268
|
+
});
|
|
269
|
+
resolve(server);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// src/config.ts
|
|
276
|
+
var CONFIG_DIR = join2(homedir(), ".cloudgrid");
|
|
277
|
+
var CONFIG_PATH = join2(CONFIG_DIR, "config.yaml");
|
|
278
|
+
var APPS_DIR = join2(CONFIG_DIR, "apps");
|
|
279
|
+
function getAppsDir() {
|
|
280
|
+
return APPS_DIR;
|
|
281
|
+
}
|
|
282
|
+
function configExists() {
|
|
283
|
+
return existsSync2(CONFIG_PATH);
|
|
284
|
+
}
|
|
285
|
+
function loadConfig() {
|
|
286
|
+
if (!existsSync2(CONFIG_PATH)) {
|
|
287
|
+
throw new Error("No CLI config found. Run: cloudgrid init");
|
|
288
|
+
}
|
|
289
|
+
return parseYaml(readFileSync(CONFIG_PATH, "utf-8"));
|
|
290
|
+
}
|
|
291
|
+
function saveConfig(config) {
|
|
292
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
293
|
+
writeFileSync(CONFIG_PATH, stringifyYaml(config));
|
|
294
|
+
}
|
|
295
|
+
function detectConfig() {
|
|
296
|
+
return {
|
|
297
|
+
api_url: "https://api.cloudgrid.io",
|
|
298
|
+
domain: "cloudgrid.io"
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function ensureAppsDir() {
|
|
302
|
+
mkdirSync(APPS_DIR, { recursive: true });
|
|
303
|
+
}
|
|
304
|
+
function createApiClient(config) {
|
|
305
|
+
const cfg = config || loadConfig();
|
|
306
|
+
if (!cfg.api_url) throw new Error("api_url not set. Run: cloudgrid login");
|
|
307
|
+
if (!cfg.jwt) throw new Error("Not authenticated. Run: cloudgrid login");
|
|
308
|
+
return new CloudGridApiClient(cfg.api_url, cfg.jwt);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/commands/init.ts
|
|
312
|
+
var initCommand = new Command("init").description("Configure API URL (for non-default setups)").action(async () => {
|
|
313
|
+
const detected = detectConfig();
|
|
314
|
+
const answers = await inquirer.prompt([
|
|
315
|
+
{
|
|
316
|
+
type: "input",
|
|
317
|
+
name: "api_url",
|
|
318
|
+
message: "Cloud Grid API URL:",
|
|
319
|
+
default: detected.api_url || "https://api.cloudgrid.io"
|
|
320
|
+
}
|
|
321
|
+
]);
|
|
322
|
+
const existing = configExists() ? loadConfig() : {};
|
|
323
|
+
saveConfig({ ...existing, ...answers });
|
|
324
|
+
log.success("Config saved. Run `cloudgrid login` to authenticate.");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// src/commands/create.ts
|
|
328
|
+
import { Command as Command2 } from "commander";
|
|
329
|
+
import inquirer2 from "inquirer";
|
|
330
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, existsSync as existsSync6, copyFileSync as copyFileSync3 } from "fs";
|
|
331
|
+
import { join as join6 } from "path";
|
|
332
|
+
import { stringify as stringifyYaml2 } from "yaml";
|
|
333
|
+
|
|
334
|
+
// ../shared/src/generator/index.ts
|
|
335
|
+
import { readFileSync as readFileSync3, existsSync as existsSync5 } from "fs";
|
|
336
|
+
import { join as join5 } from "path";
|
|
337
|
+
import { parse as parseYaml2 } from "yaml";
|
|
338
|
+
|
|
339
|
+
// ../shared/src/validate.ts
|
|
340
|
+
var NAME_REGEX = /^[a-z][a-z0-9-]{0,40}[a-z0-9]$/;
|
|
341
|
+
var RESERVED_NAMES = [
|
|
342
|
+
"default",
|
|
343
|
+
"kube-system",
|
|
344
|
+
"kube-public",
|
|
345
|
+
"kube-node-lease",
|
|
346
|
+
"n8n",
|
|
347
|
+
"sharedgrid",
|
|
348
|
+
"sharedgrid-dev",
|
|
349
|
+
"cloudgrid",
|
|
350
|
+
"cloudgrid-system",
|
|
351
|
+
"istio-system",
|
|
352
|
+
"cert-manager",
|
|
353
|
+
"ingress-nginx",
|
|
354
|
+
"monitoring"
|
|
355
|
+
];
|
|
356
|
+
var VALID_TYPES = ["node", "nextjs", "python", "static", "cron"];
|
|
357
|
+
var VALID_TIMEZONES = ["UTC", "EST", "PST"];
|
|
358
|
+
var VALID_GCP_SERVICES = ["bigquery", "pubsub", "storage"];
|
|
359
|
+
function validateName(name, label) {
|
|
360
|
+
if (!NAME_REGEX.test(name)) {
|
|
361
|
+
die(`Invalid ${label}: ${name} (must be 2-42 chars, lowercase alphanumeric + hyphens, no trailing hyphen)`);
|
|
362
|
+
}
|
|
363
|
+
if (label === "app name" && RESERVED_NAMES.includes(name)) {
|
|
364
|
+
die(`Reserved ${label}: ${name} (conflicts with system namespace)`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function validateConfig(config) {
|
|
368
|
+
validateName(config.name, "app name");
|
|
369
|
+
if (!config.services || Object.keys(config.services).length === 0) {
|
|
370
|
+
die("No services defined in cloudgrid.yaml");
|
|
371
|
+
}
|
|
372
|
+
const paths = /* @__PURE__ */ new Set();
|
|
373
|
+
for (const [name, svc] of Object.entries(config.services)) {
|
|
374
|
+
validateName(name, "service name");
|
|
375
|
+
if (!VALID_TYPES.includes(svc.type)) {
|
|
376
|
+
die(`Service '${name}' has invalid type '${svc.type}'. Must be ${VALID_TYPES.join("|")}`);
|
|
377
|
+
}
|
|
378
|
+
if (svc.lang !== void 0) {
|
|
379
|
+
if (svc.lang !== "javascript" && svc.lang !== "typescript") {
|
|
380
|
+
die(`Service '${name}' has invalid lang '${svc.lang}'. Must be javascript|typescript`);
|
|
381
|
+
}
|
|
382
|
+
if (svc.type !== "node") {
|
|
383
|
+
die(`Service '${name}' uses lang but type is '${svc.type}'. lang is only valid for type: node`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (svc.path !== void 0) {
|
|
387
|
+
if (!svc.path.startsWith("/")) {
|
|
388
|
+
die(`Service '${name}' path '${svc.path}' must start with /`);
|
|
389
|
+
}
|
|
390
|
+
if (paths.has(svc.path)) {
|
|
391
|
+
die(`Duplicate path '${svc.path}'`);
|
|
392
|
+
}
|
|
393
|
+
paths.add(svc.path);
|
|
394
|
+
}
|
|
395
|
+
if (svc.type === "cron") {
|
|
396
|
+
if (!svc.schedule) {
|
|
397
|
+
die(`Service '${name}' is type 'cron' but missing required 'schedule' field`);
|
|
398
|
+
}
|
|
399
|
+
if (svc.path !== void 0) {
|
|
400
|
+
die(`Service '${name}' is type 'cron' and must not have a 'path' field`);
|
|
401
|
+
}
|
|
402
|
+
if (svc.timezone !== void 0 && !VALID_TIMEZONES.includes(svc.timezone)) {
|
|
403
|
+
die(`Service '${name}' has invalid timezone '${svc.timezone}'. Must be ${VALID_TIMEZONES.join("|")}`);
|
|
404
|
+
}
|
|
405
|
+
if (svc.run !== void 0 && svc.run !== "job" && !svc.run.startsWith("http://") && !svc.run.startsWith("https://")) {
|
|
406
|
+
die(`Service '${name}' has invalid run '${svc.run}'. Must be 'job', 'http://...', or 'https://...'`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function parseRequires(requires, registry) {
|
|
412
|
+
const result = { shared: [], privateRedis: false };
|
|
413
|
+
if (!requires) return result;
|
|
414
|
+
let hasSharedRedis = false;
|
|
415
|
+
for (const entry of requires) {
|
|
416
|
+
if (typeof entry === "string") {
|
|
417
|
+
if (!registry[entry]) {
|
|
418
|
+
die(`Unknown shared service: ${entry} (not in shared-services.yaml)`);
|
|
419
|
+
}
|
|
420
|
+
result.shared.push(entry);
|
|
421
|
+
if (entry === "redis") hasSharedRedis = true;
|
|
422
|
+
} else if (typeof entry === "object" && entry !== null) {
|
|
423
|
+
const key = Object.keys(entry)[0];
|
|
424
|
+
if (key === "gcp" && isGcpRequires(entry)) {
|
|
425
|
+
const gcpEntry = entry;
|
|
426
|
+
if (!Array.isArray(gcpEntry.gcp.services) || gcpEntry.gcp.services.length === 0) {
|
|
427
|
+
die("gcp.services must be a non-empty array");
|
|
428
|
+
}
|
|
429
|
+
for (const svc of gcpEntry.gcp.services) {
|
|
430
|
+
if (!VALID_GCP_SERVICES.includes(svc)) {
|
|
431
|
+
die(`Invalid GCP service: '${svc}'. Must be one of: ${VALID_GCP_SERVICES.join(", ")}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
result.gcp = { services: [...gcpEntry.gcp.services] };
|
|
435
|
+
} else if (key === "redis" && entry[key] === "private") {
|
|
436
|
+
result.privateRedis = true;
|
|
437
|
+
} else {
|
|
438
|
+
const val = entry[key];
|
|
439
|
+
die(`Unsupported requires modifier: ${key}: ${val} (only 'redis: private' and 'gcp: { services: [...] }' are supported)`);
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
die(`Invalid requires entry: ${JSON.stringify(entry)}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (hasSharedRedis && result.privateRedis) {
|
|
446
|
+
die("Cannot use both shared and private Redis. Choose one.");
|
|
447
|
+
}
|
|
448
|
+
return result;
|
|
449
|
+
}
|
|
450
|
+
function isGcpRequires(entry) {
|
|
451
|
+
return typeof entry === "object" && entry !== null && "gcp" in entry && typeof entry.gcp === "object" && entry.gcp !== null && Array.isArray(entry.gcp.services);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ../shared/src/utils.ts
|
|
455
|
+
function upperSnake(name) {
|
|
456
|
+
return name.replace(/-/g, "_").toUpperCase();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ../shared/src/generator/dockerfile.ts
|
|
460
|
+
import { existsSync as existsSync3, copyFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
461
|
+
import { join as join3 } from "path";
|
|
462
|
+
var defaultLogger = {
|
|
463
|
+
step: () => {
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
function generateDockerfiles(opts) {
|
|
467
|
+
const { appDir, config, logger = defaultLogger } = opts;
|
|
468
|
+
for (const [svcName, svc] of Object.entries(config.services)) {
|
|
469
|
+
if (svc.type === "cron" && svc.run && svc.run !== "job") {
|
|
470
|
+
logger.step(`Dockerfile: services/${svcName}/ skipped (cron HTTP mode)`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const svcDir = join3(appDir, "services", svcName);
|
|
474
|
+
mkdirSync2(svcDir, { recursive: true });
|
|
475
|
+
const dockerfilePath = join3(svcDir, "Dockerfile");
|
|
476
|
+
if (existsSync3(dockerfilePath)) {
|
|
477
|
+
logger.step(`Dockerfile: services/${svcName}/Dockerfile exists, skipping`);
|
|
478
|
+
} else {
|
|
479
|
+
const templateName = svc.lang === "typescript" ? "typescript" : svc.type;
|
|
480
|
+
const templatePath = join3(TEMPLATES_DIR, "dockerfiles", `${templateName}.Dockerfile`);
|
|
481
|
+
if (!existsSync3(templatePath)) {
|
|
482
|
+
die(`Dockerfile template not found: ${templatePath}. Is @cloudgrid/cli installed correctly?`);
|
|
483
|
+
}
|
|
484
|
+
copyFileSync(templatePath, dockerfilePath);
|
|
485
|
+
logger.step(`Dockerfile: services/${svcName}/Dockerfile (${svc.type})`);
|
|
486
|
+
}
|
|
487
|
+
if (svc.type === "static") {
|
|
488
|
+
const nginxPath = join3(svcDir, "nginx.conf");
|
|
489
|
+
if (!existsSync3(nginxPath)) {
|
|
490
|
+
const templatePath = join3(TEMPLATES_DIR, "dockerfiles", "static.nginx.conf");
|
|
491
|
+
if (existsSync3(templatePath)) {
|
|
492
|
+
copyFileSync(templatePath, nginxPath);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ../shared/src/generator/stubs.ts
|
|
500
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, readdirSync, statSync, copyFileSync as copyFileSync2 } from "fs";
|
|
501
|
+
import { join as join4 } from "path";
|
|
502
|
+
var defaultLogger2 = {
|
|
503
|
+
step: () => {
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
function replacePlaceholders(content, appName, svcName) {
|
|
507
|
+
return content.replace(/__APP_NAME__/g, appName).replace(/__SERVICE_NAME__/g, svcName);
|
|
508
|
+
}
|
|
509
|
+
function copyDirRecursive(src, dest, appName, svcName) {
|
|
510
|
+
mkdirSync3(dest, { recursive: true });
|
|
511
|
+
const entries = readdirSync(src);
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const srcPath = join4(src, entry);
|
|
514
|
+
const destPath = join4(dest, entry);
|
|
515
|
+
const stat = statSync(srcPath);
|
|
516
|
+
if (stat.isDirectory()) {
|
|
517
|
+
copyDirRecursive(srcPath, destPath, appName, svcName);
|
|
518
|
+
} else {
|
|
519
|
+
if (/\.(js|json|ts|tsx|md|html)$/.test(entry)) {
|
|
520
|
+
const content = readFileSync2(srcPath, "utf-8");
|
|
521
|
+
writeFileSync2(destPath, replacePlaceholders(content, appName, svcName));
|
|
522
|
+
} else {
|
|
523
|
+
copyFileSync2(srcPath, destPath);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function generateStubs(opts) {
|
|
529
|
+
const { appDir, config, logger = defaultLogger2 } = opts;
|
|
530
|
+
const stubsDir = join4(TEMPLATES_DIR, "stubs");
|
|
531
|
+
for (const [svcName, svc] of Object.entries(config.services)) {
|
|
532
|
+
const svcDir = join4(appDir, "services", svcName);
|
|
533
|
+
mkdirSync3(svcDir, { recursive: true });
|
|
534
|
+
switch (svc.type) {
|
|
535
|
+
case "node": {
|
|
536
|
+
if (!existsSync4(join4(svcDir, "src")) && !existsSync4(join4(svcDir, "package.json"))) {
|
|
537
|
+
mkdirSync3(join4(svcDir, "src"), { recursive: true });
|
|
538
|
+
if (svc.lang === "typescript") {
|
|
539
|
+
const pkg2 = readFileSync2(join4(stubsDir, "typescript.stub.package.json"), "utf-8");
|
|
540
|
+
writeFileSync2(join4(svcDir, "package.json"), replacePlaceholders(pkg2, config.name, svcName));
|
|
541
|
+
const tsconfig = readFileSync2(join4(stubsDir, "typescript.stub.tsconfig.json"), "utf-8");
|
|
542
|
+
writeFileSync2(join4(svcDir, "tsconfig.json"), tsconfig);
|
|
543
|
+
const src = readFileSync2(join4(stubsDir, "typescript.stub.ts"), "utf-8");
|
|
544
|
+
writeFileSync2(join4(svcDir, "src/index.ts"), replacePlaceholders(src, config.name, svcName));
|
|
545
|
+
logger.step(`Stub: services/${svcName}/ (typescript)`);
|
|
546
|
+
} else {
|
|
547
|
+
const pkg2 = readFileSync2(join4(stubsDir, "node.stub.package.json"), "utf-8");
|
|
548
|
+
writeFileSync2(join4(svcDir, "package.json"), replacePlaceholders(pkg2, config.name, svcName));
|
|
549
|
+
const src = readFileSync2(join4(stubsDir, "node.stub.js"), "utf-8");
|
|
550
|
+
writeFileSync2(join4(svcDir, "src/index.js"), replacePlaceholders(src, config.name, svcName));
|
|
551
|
+
logger.step(`Stub: services/${svcName}/ (node)`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
case "nextjs": {
|
|
557
|
+
if (!existsSync4(join4(svcDir, "src")) && !existsSync4(join4(svcDir, "package.json"))) {
|
|
558
|
+
const nextjsStubDir = join4(stubsDir, "nextjs.stub");
|
|
559
|
+
if (existsSync4(nextjsStubDir)) {
|
|
560
|
+
copyDirRecursive(nextjsStubDir, svcDir, config.name, svcName);
|
|
561
|
+
}
|
|
562
|
+
logger.step(`Stub: services/${svcName}/ (nextjs)`);
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
case "python": {
|
|
567
|
+
if (!existsSync4(join4(svcDir, "src")) && !existsSync4(join4(svcDir, "requirements.txt"))) {
|
|
568
|
+
mkdirSync3(join4(svcDir, "src"), { recursive: true });
|
|
569
|
+
const reqs = readFileSync2(join4(stubsDir, "python.stub.requirements.txt"), "utf-8");
|
|
570
|
+
writeFileSync2(join4(svcDir, "requirements.txt"), reqs);
|
|
571
|
+
const src = readFileSync2(join4(stubsDir, "python.stub.py"), "utf-8");
|
|
572
|
+
writeFileSync2(join4(svcDir, "src/main.py"), replacePlaceholders(src, config.name, svcName));
|
|
573
|
+
logger.step(`Stub: services/${svcName}/ (python)`);
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
case "static": {
|
|
578
|
+
if (!existsSync4(join4(svcDir, "public"))) {
|
|
579
|
+
mkdirSync3(join4(svcDir, "public"), { recursive: true });
|
|
580
|
+
const staticStubDir = join4(stubsDir, "static.stub");
|
|
581
|
+
if (existsSync4(join4(staticStubDir, "index.html"))) {
|
|
582
|
+
const html = readFileSync2(join4(staticStubDir, "index.html"), "utf-8");
|
|
583
|
+
writeFileSync2(join4(svcDir, "public/index.html"), replacePlaceholders(html, config.name, svcName));
|
|
584
|
+
}
|
|
585
|
+
logger.step(`Stub: services/${svcName}/ (static)`);
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case "cron": {
|
|
590
|
+
const runMode = svc.run || "job";
|
|
591
|
+
if (runMode === "job" && !existsSync4(join4(svcDir, "src")) && !existsSync4(join4(svcDir, "package.json"))) {
|
|
592
|
+
mkdirSync3(join4(svcDir, "src"), { recursive: true });
|
|
593
|
+
const pkg2 = readFileSync2(join4(stubsDir, "cron.stub.package.json"), "utf-8");
|
|
594
|
+
writeFileSync2(join4(svcDir, "package.json"), replacePlaceholders(pkg2, config.name, svcName));
|
|
595
|
+
const src = readFileSync2(join4(stubsDir, "cron.stub.js"), "utf-8");
|
|
596
|
+
writeFileSync2(join4(svcDir, "src/job.js"), replacePlaceholders(src, config.name, svcName));
|
|
597
|
+
logger.step(`Stub: services/${svcName}/ (cron)`);
|
|
598
|
+
}
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ../shared/src/generator/ports.ts
|
|
606
|
+
import * as net2 from "net";
|
|
607
|
+
var BASE_PORTS = {
|
|
608
|
+
nextjs: 3e3,
|
|
609
|
+
node: 5e3,
|
|
610
|
+
python: 5e3,
|
|
611
|
+
static: 8e3
|
|
612
|
+
};
|
|
613
|
+
function isPortFree(port) {
|
|
614
|
+
return new Promise((resolve) => {
|
|
615
|
+
const server = net2.createServer();
|
|
616
|
+
server.once("error", () => resolve(false));
|
|
617
|
+
server.once("listening", () => {
|
|
618
|
+
server.close(() => resolve(true));
|
|
619
|
+
});
|
|
620
|
+
server.listen(port, "0.0.0.0");
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
async function computePortAssignments(config) {
|
|
624
|
+
const used = /* @__PURE__ */ new Set();
|
|
625
|
+
const portMap = /* @__PURE__ */ new Map();
|
|
626
|
+
for (const [name, svc] of Object.entries(config.services)) {
|
|
627
|
+
if (svc.type === "cron") continue;
|
|
628
|
+
let port = BASE_PORTS[svc.type] || 8080;
|
|
629
|
+
while (used.has(port) || !await isPortFree(port)) port++;
|
|
630
|
+
used.add(port);
|
|
631
|
+
portMap.set(name, port);
|
|
632
|
+
}
|
|
633
|
+
const requires = config.requires || [];
|
|
634
|
+
const hasMongodb = requires.some((r) => r === "mongodb");
|
|
635
|
+
const hasRedis = requires.some(
|
|
636
|
+
(r) => r === "redis" || typeof r === "object" && Object.keys(r)[0] === "redis"
|
|
637
|
+
// includes redis: private
|
|
638
|
+
);
|
|
639
|
+
if (hasMongodb) {
|
|
640
|
+
let port = 27017;
|
|
641
|
+
while (used.has(port) || !await isPortFree(port)) port++;
|
|
642
|
+
used.add(port);
|
|
643
|
+
portMap.set("_tunnel_mongodb", port);
|
|
644
|
+
}
|
|
645
|
+
if (hasRedis) {
|
|
646
|
+
let port = 6379;
|
|
647
|
+
while (used.has(port) || !await isPortFree(port)) port++;
|
|
648
|
+
used.add(port);
|
|
649
|
+
portMap.set("_tunnel_redis", port);
|
|
650
|
+
}
|
|
651
|
+
return portMap;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ../shared/src/generator/dev-env.ts
|
|
655
|
+
function computeDevEnv(opts) {
|
|
656
|
+
const { config, portMap, requires, sharedServices, mode, token, apiUrl } = opts;
|
|
657
|
+
const svcNames = Object.keys(config.services);
|
|
658
|
+
const result = /* @__PURE__ */ new Map();
|
|
659
|
+
const mongoPort = portMap.get("_tunnel_mongodb") || 27017;
|
|
660
|
+
const redisPort = portMap.get("_tunnel_redis") || 6379;
|
|
661
|
+
for (const svcName of svcNames) {
|
|
662
|
+
const svc = config.services[svcName];
|
|
663
|
+
const env = {};
|
|
664
|
+
const assignedPort = portMap.get(svcName);
|
|
665
|
+
env.PORT = mode === "native" ? String(assignedPort ?? "0") : "8080";
|
|
666
|
+
env.APP_NAME = config.name;
|
|
667
|
+
env.SERVICE_NAME = svcName;
|
|
668
|
+
env.NODE_ENV = "development";
|
|
669
|
+
const baseApiUrl = apiUrl || "https://api.cloudgrid.io";
|
|
670
|
+
env.AI_GATEWAY_URL = `${baseApiUrl}/ai-dev`;
|
|
671
|
+
if (token) {
|
|
672
|
+
env.CLOUDGRID_TOKEN = token;
|
|
673
|
+
}
|
|
674
|
+
for (const other of svcNames) {
|
|
675
|
+
if (config.services[other].type === "cron") continue;
|
|
676
|
+
const upper = upperSnake(other);
|
|
677
|
+
if (mode === "native") {
|
|
678
|
+
env[`${upper}_URL`] = `http://localhost:${portMap.get(other)}`;
|
|
679
|
+
} else {
|
|
680
|
+
env[`${upper}_URL`] = `http://${other}:8080`;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const host = mode === "native" ? "localhost" : "host.docker.internal";
|
|
684
|
+
for (const req of requires.shared) {
|
|
685
|
+
const svcDef = sharedServices[req];
|
|
686
|
+
if (req === "mongodb") {
|
|
687
|
+
env[svcDef.env_var] = `mongodb://${host}:${mongoPort}/${config.name}?directConnection=true`;
|
|
688
|
+
} else if (req === "redis") {
|
|
689
|
+
env[svcDef.env_var] = `redis://${host}:${redisPort}`;
|
|
690
|
+
} else if (req === "n8n") {
|
|
691
|
+
env[svcDef.env_var] = svcDef.local;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (requires.privateRedis) {
|
|
695
|
+
env.REDIS_URL = `redis://${host}:${redisPort}`;
|
|
696
|
+
}
|
|
697
|
+
if (svc.env) {
|
|
698
|
+
for (const [key, val] of Object.entries(svc.env)) {
|
|
699
|
+
env[key] = val;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
result.set(svcName, env);
|
|
703
|
+
}
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ../shared/src/generator/index.ts
|
|
708
|
+
function loadCloudGridYaml(appDir) {
|
|
709
|
+
const yamlPath = join5(appDir, "cloudgrid.yaml");
|
|
710
|
+
if (!existsSync5(yamlPath)) {
|
|
711
|
+
die(`No cloudgrid.yaml found at ${yamlPath}`);
|
|
712
|
+
}
|
|
713
|
+
return parseYaml2(readFileSync3(yamlPath, "utf-8"));
|
|
714
|
+
}
|
|
715
|
+
function loadSharedServices() {
|
|
716
|
+
return parseYaml2(readFileSync3(SHARED_SERVICES_PATH, "utf-8"));
|
|
717
|
+
}
|
|
718
|
+
async function generate(appDir, opts) {
|
|
719
|
+
const config = loadCloudGridYaml(appDir);
|
|
720
|
+
validateConfig(config);
|
|
721
|
+
const sharedServices = loadSharedServices();
|
|
722
|
+
const requires = parseRequires(config.requires, sharedServices);
|
|
723
|
+
generateDockerfiles({ appDir, config });
|
|
724
|
+
generateStubs({ appDir, config });
|
|
725
|
+
return { config, requires, sharedServices };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/commands/create.ts
|
|
729
|
+
var SAMPLES = [
|
|
730
|
+
{ name: "Full-stack dashboard (Next.js + API + MongoDB)", value: "full-stack" },
|
|
731
|
+
{ name: "Static site (HTML/React/Vue)", value: "static-site" },
|
|
732
|
+
{ name: "Node.js API with MongoDB", value: "node-api" },
|
|
733
|
+
{ name: "Python microservice with Redis", value: "python-worker" },
|
|
734
|
+
{ name: "Multi-service with n8n integration", value: "multi-service" },
|
|
735
|
+
{ name: "Blank", value: "blank" }
|
|
736
|
+
];
|
|
737
|
+
function validateAppName(name) {
|
|
738
|
+
validateName(name, "app name");
|
|
739
|
+
}
|
|
740
|
+
var createCommand = new Command2("create").argument("[name]", "App name").option("--interactive", "Use interactive wizard instead of template picker").option("--dir <path>", "Target directory").description("Create a new Cloud Grid app").action(async (nameArg, opts) => {
|
|
741
|
+
let name = nameArg;
|
|
742
|
+
if (!name) {
|
|
743
|
+
const answer = await inquirer2.prompt([
|
|
744
|
+
{ type: "input", name: "name", message: "App name:" }
|
|
745
|
+
]);
|
|
746
|
+
name = answer.name;
|
|
747
|
+
}
|
|
748
|
+
validateAppName(name);
|
|
749
|
+
let appDir;
|
|
750
|
+
if (opts.dir) {
|
|
751
|
+
appDir = opts.dir;
|
|
752
|
+
} else {
|
|
753
|
+
let isInGitRepo = false;
|
|
754
|
+
try {
|
|
755
|
+
const { execaSync } = await import("execa");
|
|
756
|
+
execaSync("git", ["rev-parse", "--show-toplevel"], { stdio: "pipe" });
|
|
757
|
+
isInGitRepo = true;
|
|
758
|
+
} catch {
|
|
759
|
+
isInGitRepo = false;
|
|
760
|
+
}
|
|
761
|
+
if (isInGitRepo) {
|
|
762
|
+
appDir = join6(process.cwd(), name);
|
|
763
|
+
} else {
|
|
764
|
+
ensureAppsDir();
|
|
765
|
+
appDir = join6(getAppsDir(), name);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (existsSync6(appDir)) {
|
|
769
|
+
die2(`Directory already exists: ${appDir}`);
|
|
770
|
+
}
|
|
771
|
+
mkdirSync4(appDir, { recursive: true });
|
|
772
|
+
if (opts.interactive) {
|
|
773
|
+
await interactiveWizard(name, appDir);
|
|
774
|
+
} else {
|
|
775
|
+
await templatePicker(name, appDir);
|
|
776
|
+
}
|
|
777
|
+
log.step("Generating files...");
|
|
778
|
+
if (configExists()) {
|
|
779
|
+
const config = loadConfig();
|
|
780
|
+
await generate(appDir, {
|
|
781
|
+
environment: "local",
|
|
782
|
+
registry: config.registry,
|
|
783
|
+
domain: `${name}.${config.domain}`
|
|
784
|
+
});
|
|
785
|
+
} else {
|
|
786
|
+
log.warn("No CLI config found. Skipping file generation. Run: cloudgrid init");
|
|
787
|
+
}
|
|
788
|
+
const cloudgridMdSrc = join6(TEMPLATES_DIR, "CLOUDGRID.md");
|
|
789
|
+
if (existsSync6(cloudgridMdSrc)) {
|
|
790
|
+
copyFileSync3(cloudgridMdSrc, join6(appDir, "CLOUDGRID.md"));
|
|
791
|
+
}
|
|
792
|
+
writeFileSync3(join6(appDir, ".gitignore"), [
|
|
793
|
+
"node_modules/",
|
|
794
|
+
".next/",
|
|
795
|
+
".cloudgrid-dev.lock",
|
|
796
|
+
""
|
|
797
|
+
].join("\n"));
|
|
798
|
+
console.log("");
|
|
799
|
+
log.success(`Created ${name} at ${appDir}`);
|
|
800
|
+
console.log("");
|
|
801
|
+
console.log(" Next steps:");
|
|
802
|
+
console.log(` cd ${appDir}`);
|
|
803
|
+
console.log(" cloudgrid dev # start local development");
|
|
804
|
+
console.log(" cloudgrid deploy # deploy to production");
|
|
805
|
+
console.log("");
|
|
806
|
+
});
|
|
807
|
+
async function templatePicker(name, appDir) {
|
|
808
|
+
const { template } = await inquirer2.prompt([
|
|
809
|
+
{ type: "list", name: "template", message: "Pick a starting template:", choices: SAMPLES }
|
|
810
|
+
]);
|
|
811
|
+
if (template === "blank") {
|
|
812
|
+
const yaml = `name: ${name}
|
|
813
|
+
|
|
814
|
+
services:
|
|
815
|
+
app:
|
|
816
|
+
type: node
|
|
817
|
+
path: /
|
|
818
|
+
`;
|
|
819
|
+
writeFileSync3(join6(appDir, "cloudgrid.yaml"), yaml);
|
|
820
|
+
} else {
|
|
821
|
+
const samplePath = join6(TEMPLATES_DIR, "samples", `${template}.yaml`);
|
|
822
|
+
if (existsSync6(samplePath)) {
|
|
823
|
+
let content = readFileSync4(samplePath, "utf-8");
|
|
824
|
+
content = content.replace(/__APP_NAME__/g, name);
|
|
825
|
+
writeFileSync3(join6(appDir, "cloudgrid.yaml"), content);
|
|
826
|
+
} else {
|
|
827
|
+
log.warn(`Template ${template} not found, using blank template`);
|
|
828
|
+
const yaml = `name: ${name}
|
|
829
|
+
|
|
830
|
+
services:
|
|
831
|
+
app:
|
|
832
|
+
type: node
|
|
833
|
+
path: /
|
|
834
|
+
`;
|
|
835
|
+
writeFileSync3(join6(appDir, "cloudgrid.yaml"), yaml);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
async function interactiveWizard(name, appDir) {
|
|
840
|
+
const services = {};
|
|
841
|
+
const requires = [];
|
|
842
|
+
let addMore = true;
|
|
843
|
+
while (addMore) {
|
|
844
|
+
const svc = await inquirer2.prompt([
|
|
845
|
+
{ type: "input", name: "name", message: "Service name:" },
|
|
846
|
+
{ type: "list", name: "type", message: "Service type:", choices: ["nextjs", "node", "python", "static", "cron"] },
|
|
847
|
+
{ type: "input", name: "path", message: "Public path (or enter to skip):", default: "", when: (a) => a.type !== "cron" },
|
|
848
|
+
{ type: "input", name: "schedule", message: 'Cron schedule (e.g. "0 2 * * *"):', when: (a) => a.type === "cron" },
|
|
849
|
+
{ type: "list", name: "runMode", message: "Run mode:", choices: [
|
|
850
|
+
{ name: "Run code (src/job.js)", value: "job" },
|
|
851
|
+
{ name: "Call HTTP URL", value: "http" }
|
|
852
|
+
], when: (a) => a.type === "cron" },
|
|
853
|
+
{ type: "input", name: "runUrl", message: "HTTP URL to call:", when: (a) => a.type === "cron" && a.runMode === "http" }
|
|
854
|
+
]);
|
|
855
|
+
services[svc.name] = { type: svc.type };
|
|
856
|
+
if (svc.type === "node") {
|
|
857
|
+
const { lang } = await inquirer2.prompt([
|
|
858
|
+
{
|
|
859
|
+
type: "list",
|
|
860
|
+
name: "lang",
|
|
861
|
+
message: "Language:",
|
|
862
|
+
choices: [
|
|
863
|
+
{ name: "JavaScript (default)", value: "javascript" },
|
|
864
|
+
{ name: "TypeScript", value: "typescript" }
|
|
865
|
+
]
|
|
866
|
+
}
|
|
867
|
+
]);
|
|
868
|
+
if (lang === "typescript") {
|
|
869
|
+
services[svc.name].lang = "typescript";
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (svc.path) services[svc.name].path = svc.path;
|
|
873
|
+
if (svc.type === "cron") {
|
|
874
|
+
if (svc.schedule) services[svc.name].schedule = svc.schedule;
|
|
875
|
+
if (svc.runMode === "http" && svc.runUrl) {
|
|
876
|
+
services[svc.name].run = svc.runUrl;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const { more } = await inquirer2.prompt([
|
|
880
|
+
{ type: "confirm", name: "more", message: "Add another service?", default: false }
|
|
881
|
+
]);
|
|
882
|
+
addMore = more;
|
|
883
|
+
}
|
|
884
|
+
const { sharedSvcs } = await inquirer2.prompt([
|
|
885
|
+
{
|
|
886
|
+
type: "checkbox",
|
|
887
|
+
name: "sharedSvcs",
|
|
888
|
+
message: "Shared services:",
|
|
889
|
+
choices: [
|
|
890
|
+
{ name: "MongoDB", value: "mongodb" },
|
|
891
|
+
{ name: "Redis (shared)", value: "redis" },
|
|
892
|
+
{ name: "Redis (private, app-scoped)", value: "redis-private" },
|
|
893
|
+
{ name: "n8n webhooks", value: "n8n" }
|
|
894
|
+
]
|
|
895
|
+
}
|
|
896
|
+
]);
|
|
897
|
+
for (const svc of sharedSvcs) {
|
|
898
|
+
if (svc === "redis-private") requires.push({ redis: "private" });
|
|
899
|
+
else requires.push(svc);
|
|
900
|
+
}
|
|
901
|
+
const yaml = {
|
|
902
|
+
name,
|
|
903
|
+
services,
|
|
904
|
+
...requires.length > 0 ? { requires } : {}
|
|
905
|
+
};
|
|
906
|
+
writeFileSync3(join6(appDir, "cloudgrid.yaml"), stringifyYaml2(yaml));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// src/commands/dev.ts
|
|
910
|
+
import { Command as Command3 } from "commander";
|
|
911
|
+
import { existsSync as existsSync7, writeFileSync as writeFileSync5, readFileSync as readFileSync5, unlinkSync } from "fs";
|
|
912
|
+
import { join as join8 } from "path";
|
|
913
|
+
import { tmpdir } from "os";
|
|
914
|
+
import { createInterface } from "readline";
|
|
915
|
+
import { execa as execa2 } from "execa";
|
|
916
|
+
import chalk2 from "chalk";
|
|
917
|
+
|
|
918
|
+
// ../shared/src/generator/docker-compose.ts
|
|
919
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
920
|
+
import { join as join7 } from "path";
|
|
921
|
+
function generateDockerCompose(opts) {
|
|
922
|
+
const { outputPath, appDir, config, requires, sharedServices, portMap, token, apiUrl } = opts;
|
|
923
|
+
const svcNames = Object.keys(config.services);
|
|
924
|
+
const mongoPort = portMap.get("_tunnel_mongodb") || 27017;
|
|
925
|
+
const redisPort = portMap.get("_tunnel_redis") || 6379;
|
|
926
|
+
let yaml = "services:\n";
|
|
927
|
+
for (const svcName of svcNames) {
|
|
928
|
+
const svc = config.services[svcName];
|
|
929
|
+
if (svc.type === "cron") continue;
|
|
930
|
+
const localPort = portMap.get(svcName);
|
|
931
|
+
const containerPort = svc.type === "static" ? 80 : 8080;
|
|
932
|
+
yaml += ` ${svcName}:
|
|
933
|
+
`;
|
|
934
|
+
yaml += ` build: ${join7(appDir, "services", svcName)}
|
|
935
|
+
`;
|
|
936
|
+
yaml += ` ports:
|
|
937
|
+
`;
|
|
938
|
+
yaml += ` - "${localPort}:${containerPort}"
|
|
939
|
+
`;
|
|
940
|
+
if (svc.lang === "typescript") {
|
|
941
|
+
yaml += ` command: ["npx", "tsx", "watch", "src/index.ts"]
|
|
942
|
+
`;
|
|
943
|
+
}
|
|
944
|
+
if (svc.type === "static") {
|
|
945
|
+
yaml += ` volumes:
|
|
946
|
+
`;
|
|
947
|
+
yaml += ` - ${join7(appDir, "services", svcName, "public")}:/usr/share/nginx/html
|
|
948
|
+
`;
|
|
949
|
+
} else {
|
|
950
|
+
yaml += ` volumes:
|
|
951
|
+
`;
|
|
952
|
+
yaml += ` - ${join7(appDir, "services", svcName, "src")}:/app/src
|
|
953
|
+
`;
|
|
954
|
+
}
|
|
955
|
+
yaml += ` environment:
|
|
956
|
+
`;
|
|
957
|
+
yaml += ` PORT: "8080"
|
|
958
|
+
`;
|
|
959
|
+
yaml += ` APP_NAME: "${config.name}"
|
|
960
|
+
`;
|
|
961
|
+
yaml += ` SERVICE_NAME: "${svcName}"
|
|
962
|
+
`;
|
|
963
|
+
yaml += ` NODE_ENV: development
|
|
964
|
+
`;
|
|
965
|
+
if (apiUrl && token) {
|
|
966
|
+
const aiDevUrl = `${apiUrl.replace(/\/$/, "")}/ai-dev`;
|
|
967
|
+
yaml += ` AI_GATEWAY_URL: "${aiDevUrl}"
|
|
968
|
+
`;
|
|
969
|
+
yaml += ` CLOUDGRID_TOKEN: "${token}"
|
|
970
|
+
`;
|
|
971
|
+
}
|
|
972
|
+
for (const other of svcNames) {
|
|
973
|
+
const upper = upperSnake(other);
|
|
974
|
+
yaml += ` ${upper}_URL: "http://${other}:8080"
|
|
975
|
+
`;
|
|
976
|
+
}
|
|
977
|
+
for (const req of requires.shared) {
|
|
978
|
+
const svcDef = sharedServices[req];
|
|
979
|
+
if (req === "mongodb") {
|
|
980
|
+
yaml += ` ${svcDef.env_var}: "mongodb://host.docker.internal:${mongoPort}/${config.name}?directConnection=true"
|
|
981
|
+
`;
|
|
982
|
+
} else if (req === "redis") {
|
|
983
|
+
yaml += ` ${svcDef.env_var}: "redis://host.docker.internal:${redisPort}"
|
|
984
|
+
`;
|
|
985
|
+
} else if (req === "n8n") {
|
|
986
|
+
yaml += ` ${svcDef.env_var}: "${svcDef.local}"
|
|
987
|
+
`;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (requires.privateRedis) {
|
|
991
|
+
yaml += ` REDIS_URL: "redis://host.docker.internal:${redisPort}"
|
|
992
|
+
`;
|
|
993
|
+
}
|
|
994
|
+
if (svc.env) {
|
|
995
|
+
for (const [key, val] of Object.entries(svc.env)) {
|
|
996
|
+
yaml += ` ${key}: "${val}"
|
|
997
|
+
`;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
writeFileSync4(outputPath, yaml);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// src/commands/dev.ts
|
|
1005
|
+
var LOCK_FILE = ".cloudgrid-dev.lock";
|
|
1006
|
+
var SERVICE_COLORS = [chalk2.cyan, chalk2.magenta, chalk2.yellow, chalk2.green, chalk2.blue, chalk2.red];
|
|
1007
|
+
var RUNNERS = {
|
|
1008
|
+
node: () => ({ cmd: "node", args: ["--watch", "src/index.js"] }),
|
|
1009
|
+
nextjs: (port) => ({ cmd: "npx", args: ["next", "dev", "-p", String(port)] }),
|
|
1010
|
+
python: () => ({ cmd: "python", args: ["src/main.py"] }),
|
|
1011
|
+
static: (port) => ({ cmd: "npx", args: ["serve", "public/", "-l", String(port)] })
|
|
1012
|
+
};
|
|
1013
|
+
function checkDevLock(appDir) {
|
|
1014
|
+
const lockPath = join8(appDir, LOCK_FILE);
|
|
1015
|
+
if (!existsSync7(lockPath)) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const lock = JSON.parse(readFileSync5(lockPath, "utf-8"));
|
|
1018
|
+
try {
|
|
1019
|
+
process.kill(lock.pid, 0);
|
|
1020
|
+
die2(`Dev already running for this app (PID ${lock.pid}). Stop it first (Ctrl+C) or use --force`);
|
|
1021
|
+
} catch {
|
|
1022
|
+
unlinkSync(lockPath);
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
try {
|
|
1026
|
+
unlinkSync(lockPath);
|
|
1027
|
+
} catch {
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function writeDevLock(appDir, ports) {
|
|
1032
|
+
writeFileSync5(join8(appDir, LOCK_FILE), JSON.stringify({
|
|
1033
|
+
pid: process.pid,
|
|
1034
|
+
ports: Object.fromEntries(ports),
|
|
1035
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1036
|
+
}, null, 2));
|
|
1037
|
+
}
|
|
1038
|
+
function removeDevLock(appDir) {
|
|
1039
|
+
try {
|
|
1040
|
+
unlinkSync(join8(appDir, LOCK_FILE));
|
|
1041
|
+
} catch {
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
function prefixStream(stream, name, color) {
|
|
1045
|
+
const rl = createInterface({ input: stream });
|
|
1046
|
+
rl.on("line", (line) => {
|
|
1047
|
+
console.log(`${color(`[${name}]`)} ${line}`);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
var devCommand = new Command3("dev").description("Start local development").option("--compose", "Use docker-compose instead of native processes").option("--force", "Ignore existing dev lock").action(async (opts) => {
|
|
1051
|
+
const appDir = process.cwd();
|
|
1052
|
+
if (!existsSync7(join8(appDir, "cloudgrid.yaml"))) {
|
|
1053
|
+
die2("No cloudgrid.yaml found. Run: cloudgrid create <name>");
|
|
1054
|
+
}
|
|
1055
|
+
if (!opts.force) checkDevLock(appDir);
|
|
1056
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1057
|
+
const cliConfig = loadConfig();
|
|
1058
|
+
if (!cliConfig.jwt) {
|
|
1059
|
+
log.warn("Not logged in \u2014 AI features will not work in dev mode. Run: cloudgrid login");
|
|
1060
|
+
}
|
|
1061
|
+
const apiClient = createApiClient(cliConfig);
|
|
1062
|
+
const config = loadCloudGridYaml(appDir);
|
|
1063
|
+
const sharedServices = loadSharedServices();
|
|
1064
|
+
const requires = parseRequires(config.requires, sharedServices);
|
|
1065
|
+
const s1 = spinner("Checking available ports...");
|
|
1066
|
+
s1.start();
|
|
1067
|
+
const portMap = await computePortAssignments(config);
|
|
1068
|
+
s1.succeed("Ports assigned");
|
|
1069
|
+
writeDevLock(appDir, portMap);
|
|
1070
|
+
const tunnels = [];
|
|
1071
|
+
const mongoPort = portMap.get("_tunnel_mongodb");
|
|
1072
|
+
const redisPort = portMap.get("_tunnel_redis");
|
|
1073
|
+
if (mongoPort || redisPort) {
|
|
1074
|
+
log.info("Opening tunnels via Cloud Grid API...");
|
|
1075
|
+
}
|
|
1076
|
+
if (mongoPort) {
|
|
1077
|
+
try {
|
|
1078
|
+
const t = await apiClient.openTunnel("mongodb", mongoPort);
|
|
1079
|
+
tunnels.push(t);
|
|
1080
|
+
log.step(`MongoDB: localhost:${mongoPort}`);
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
log.warn(`MongoDB tunnel failed: ${err.message}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (redisPort) {
|
|
1086
|
+
try {
|
|
1087
|
+
const t = await apiClient.openTunnel("redis", redisPort);
|
|
1088
|
+
tunnels.push(t);
|
|
1089
|
+
log.step(`Redis: localhost:${redisPort}`);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
log.warn(`Redis tunnel failed: ${err.message}`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
const children = [];
|
|
1095
|
+
let tmpComposePath = null;
|
|
1096
|
+
let cleaned = false;
|
|
1097
|
+
const cleanup = () => {
|
|
1098
|
+
if (cleaned) return;
|
|
1099
|
+
cleaned = true;
|
|
1100
|
+
for (const child of children) {
|
|
1101
|
+
try {
|
|
1102
|
+
child.kill();
|
|
1103
|
+
} catch {
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
for (const tunnel of tunnels) {
|
|
1107
|
+
try {
|
|
1108
|
+
if ("closeAllConnections" in tunnel) tunnel.closeAllConnections();
|
|
1109
|
+
tunnel.close();
|
|
1110
|
+
} catch {
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (tmpComposePath) {
|
|
1114
|
+
try {
|
|
1115
|
+
unlinkSync(tmpComposePath);
|
|
1116
|
+
} catch {
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
removeDevLock(appDir);
|
|
1120
|
+
};
|
|
1121
|
+
process.once("SIGTERM", () => {
|
|
1122
|
+
cleanup();
|
|
1123
|
+
process.exit(0);
|
|
1124
|
+
});
|
|
1125
|
+
process.once("SIGINT", () => {
|
|
1126
|
+
cleanup();
|
|
1127
|
+
process.exit(0);
|
|
1128
|
+
});
|
|
1129
|
+
if (opts.compose) {
|
|
1130
|
+
tmpComposePath = join8(tmpdir(), `cloudgrid-${config.name}-compose.yaml`);
|
|
1131
|
+
log.info("Starting docker-compose...");
|
|
1132
|
+
generateDockerCompose({
|
|
1133
|
+
outputPath: tmpComposePath,
|
|
1134
|
+
appDir,
|
|
1135
|
+
config,
|
|
1136
|
+
requires,
|
|
1137
|
+
sharedServices,
|
|
1138
|
+
portMap,
|
|
1139
|
+
token: cliConfig.jwt,
|
|
1140
|
+
apiUrl: cliConfig.api_url
|
|
1141
|
+
});
|
|
1142
|
+
console.log("");
|
|
1143
|
+
const svcNames = Object.keys(config.services);
|
|
1144
|
+
for (const name of svcNames) {
|
|
1145
|
+
log.info(`${name}: http://localhost:${portMap.get(name)}`);
|
|
1146
|
+
}
|
|
1147
|
+
console.log("");
|
|
1148
|
+
try {
|
|
1149
|
+
await exec("docker", ["compose", "-f", tmpComposePath, "up", "--build"], { stdio: "inherit" });
|
|
1150
|
+
} finally {
|
|
1151
|
+
cleanup();
|
|
1152
|
+
}
|
|
1153
|
+
} else {
|
|
1154
|
+
const envMap = computeDevEnv({ config, portMap, requires, sharedServices, mode: "native", token: cliConfig.jwt, apiUrl: cliConfig.api_url });
|
|
1155
|
+
const svcNames = Object.keys(config.services);
|
|
1156
|
+
for (const svcName of svcNames) {
|
|
1157
|
+
const svc = config.services[svcName];
|
|
1158
|
+
if (svc.type !== "cron") continue;
|
|
1159
|
+
const runMode = svc.run || "job";
|
|
1160
|
+
const color = SERVICE_COLORS[svcNames.indexOf(svcName) % SERVICE_COLORS.length];
|
|
1161
|
+
if (runMode === "job") {
|
|
1162
|
+
const svcDir = join8(appDir, "services", svcName);
|
|
1163
|
+
const svcEnv = envMap.get(svcName) || {};
|
|
1164
|
+
log.info(`${color(svcName)}: Running cron job once...`);
|
|
1165
|
+
try {
|
|
1166
|
+
const result = await execa2("node", ["src/job.js"], {
|
|
1167
|
+
cwd: svcDir,
|
|
1168
|
+
env: { ...process.env, ...svcEnv },
|
|
1169
|
+
stdio: "pipe"
|
|
1170
|
+
});
|
|
1171
|
+
if (result.stdout) {
|
|
1172
|
+
for (const line of result.stdout.split("\n")) {
|
|
1173
|
+
console.log(`${color(`[${svcName}]`)} ${line}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
} catch (err) {
|
|
1177
|
+
if (err.stdout) {
|
|
1178
|
+
for (const line of err.stdout.split("\n")) {
|
|
1179
|
+
console.log(`${color(`[${svcName}]`)} ${line}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (err.stderr) {
|
|
1183
|
+
for (const line of err.stderr.split("\n")) {
|
|
1184
|
+
console.log(`${color(`[${svcName}]`)} ${line}`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
log.info(`${color(svcName)}: Job completed. It will run on schedule in production.`);
|
|
1189
|
+
} else {
|
|
1190
|
+
log.info(`${color(svcName)}: Cron '${svcName}' triggers ${runMode} on schedule '${svc.schedule}'. In dev, call the URL manually.`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
for (const svcName of svcNames) {
|
|
1194
|
+
const svcDir = join8(appDir, "services", svcName);
|
|
1195
|
+
const svcType = config.services[svcName].type;
|
|
1196
|
+
if (svcType === "cron") continue;
|
|
1197
|
+
if ((svcType === "node" || svcType === "nextjs") && !existsSync7(join8(svcDir, "node_modules"))) {
|
|
1198
|
+
const si = spinner(`Installing dependencies for ${svcName}...`);
|
|
1199
|
+
si.start();
|
|
1200
|
+
try {
|
|
1201
|
+
await exec("npm", ["install"], { cwd: svcDir, stdio: "pipe" });
|
|
1202
|
+
si.succeed(`Dependencies installed for ${svcName}`);
|
|
1203
|
+
} catch {
|
|
1204
|
+
si.fail(`Failed to install dependencies for ${svcName}`);
|
|
1205
|
+
cleanup();
|
|
1206
|
+
die2(`npm install failed for ${svcName}. Fix the issue and retry.`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
console.log("");
|
|
1211
|
+
for (let i = 0; i < svcNames.length; i++) {
|
|
1212
|
+
const name = svcNames[i];
|
|
1213
|
+
if (config.services[name].type === "cron") continue;
|
|
1214
|
+
const port = portMap.get(name);
|
|
1215
|
+
const color = SERVICE_COLORS[i % SERVICE_COLORS.length];
|
|
1216
|
+
log.info(`${color(name)}: http://localhost:${port}`);
|
|
1217
|
+
}
|
|
1218
|
+
console.log("");
|
|
1219
|
+
log.info("Hot reload enabled. Press Ctrl+C to stop.");
|
|
1220
|
+
console.log("");
|
|
1221
|
+
for (let i = 0; i < svcNames.length; i++) {
|
|
1222
|
+
const svcName = svcNames[i];
|
|
1223
|
+
const svc = config.services[svcName];
|
|
1224
|
+
if (svc.type === "cron") continue;
|
|
1225
|
+
const port = portMap.get(svcName);
|
|
1226
|
+
const svcDir = join8(appDir, "services", svcName);
|
|
1227
|
+
const svcEnv = envMap.get(svcName);
|
|
1228
|
+
let runner = RUNNERS[svc.type];
|
|
1229
|
+
const color = SERVICE_COLORS[i % SERVICE_COLORS.length];
|
|
1230
|
+
if (svc.lang === "typescript") {
|
|
1231
|
+
runner = () => ({ cmd: "npx", args: ["tsx", "watch", "src/index.ts"] });
|
|
1232
|
+
}
|
|
1233
|
+
if (!runner) {
|
|
1234
|
+
log.warn(`Unknown service type: ${svc.type} for ${svcName}`);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const { cmd, args } = runner(port);
|
|
1238
|
+
const child = execa2(cmd, args, {
|
|
1239
|
+
cwd: svcDir,
|
|
1240
|
+
env: { ...process.env, ...svcEnv },
|
|
1241
|
+
stdio: "pipe"
|
|
1242
|
+
});
|
|
1243
|
+
if (child.stdout) prefixStream(child.stdout, svcName, color);
|
|
1244
|
+
if (child.stderr) prefixStream(child.stderr, svcName, color);
|
|
1245
|
+
child.catch(() => {
|
|
1246
|
+
});
|
|
1247
|
+
children.push(child);
|
|
1248
|
+
}
|
|
1249
|
+
await new Promise(() => {
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// src/commands/deploy.ts
|
|
1255
|
+
import { Command as Command4 } from "commander";
|
|
1256
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
|
|
1257
|
+
import { execSync } from "child_process";
|
|
1258
|
+
var deployCommand = new Command4("deploy").description("Deploy app to production").action(async () => {
|
|
1259
|
+
if (!existsSync8("cloudgrid.yaml")) die2("No cloudgrid.yaml in current directory");
|
|
1260
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1261
|
+
const cliConfig = loadConfig();
|
|
1262
|
+
const config = loadCloudGridYaml(process.cwd());
|
|
1263
|
+
const apiClient = createApiClient(cliConfig);
|
|
1264
|
+
console.log(`
|
|
1265
|
+
Deploying ${config.name} to production...
|
|
1266
|
+
`);
|
|
1267
|
+
await serverBuild(config, apiClient);
|
|
1268
|
+
});
|
|
1269
|
+
async function serverBuild(config, apiClient) {
|
|
1270
|
+
const cloudgridYaml = readFileSync6("cloudgrid.yaml", "utf-8");
|
|
1271
|
+
const services = {};
|
|
1272
|
+
for (const [svcName, svc] of Object.entries(config.services)) {
|
|
1273
|
+
if (svc.type === "cron" && svc.run && (svc.run.startsWith("http://") || svc.run.startsWith("https://"))) {
|
|
1274
|
+
log.step(`${svcName}: HTTP cron \u2014 no build needed`);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const svcDir = `services/${svcName}`;
|
|
1278
|
+
if (!existsSync8(svcDir)) {
|
|
1279
|
+
die2(`Service directory not found: ${svcDir}`);
|
|
1280
|
+
}
|
|
1281
|
+
const s = spinner(`Packaging ${svcName}...`);
|
|
1282
|
+
s.start();
|
|
1283
|
+
const excludes = ["node_modules", ".git", ".next", "dist", "__pycache__", ".venv"].map((d) => `--exclude=${d}`).join(" ");
|
|
1284
|
+
const tarball = execSync(
|
|
1285
|
+
`tar -czf - ${excludes} -C ${svcDir} .`,
|
|
1286
|
+
{ maxBuffer: 100 * 1024 * 1024 }
|
|
1287
|
+
);
|
|
1288
|
+
const sizeMB = (tarball.length / 1024 / 1024).toFixed(1);
|
|
1289
|
+
if (tarball.length > 80 * 1024 * 1024) {
|
|
1290
|
+
s.warn(`${svcName}: ${sizeMB}MB \u2014 consider adding .dockerignore to exclude unnecessary files`);
|
|
1291
|
+
} else {
|
|
1292
|
+
s.succeed(`${svcName}: packaged (${sizeMB}MB)`);
|
|
1293
|
+
}
|
|
1294
|
+
services[svcName] = tarball.toString("base64");
|
|
1295
|
+
}
|
|
1296
|
+
if (Object.keys(services).length === 0) {
|
|
1297
|
+
log.step("No services to build");
|
|
1298
|
+
}
|
|
1299
|
+
const s2 = spinner("Building and deploying via Cloud Grid API...");
|
|
1300
|
+
s2.start();
|
|
1301
|
+
try {
|
|
1302
|
+
const result = await apiClient.build(config.name, services, cloudgridYaml);
|
|
1303
|
+
s2.succeed("Deployed");
|
|
1304
|
+
for (const svc of result.services) {
|
|
1305
|
+
const status = svc.ready ? " ready" : " pending";
|
|
1306
|
+
log.step(`${svc.name}: ${status}`);
|
|
1307
|
+
}
|
|
1308
|
+
console.log(`
|
|
1309
|
+
Live at ${result.url}
|
|
1310
|
+
`);
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
s2.fail("Deploy failed");
|
|
1313
|
+
if (err instanceof ApiError) {
|
|
1314
|
+
if (err.code === "BUILD_FAILED") die2(`Build failed: ${err.message}`);
|
|
1315
|
+
if (err.code === "DEPLOY_IN_PROGRESS") die2("Another deploy is in progress. Try again shortly.");
|
|
1316
|
+
if (err.code === "DEPLOY_TIMEOUT") {
|
|
1317
|
+
log.warn("Deploy timed out. Check status with: cloudgrid status");
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
die2(`API error (${err.code}): ${err.message}`);
|
|
1321
|
+
}
|
|
1322
|
+
throw err;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// src/commands/list.ts
|
|
1327
|
+
import { Command as Command5 } from "commander";
|
|
1328
|
+
import chalk3 from "chalk";
|
|
1329
|
+
var listCommand = new Command5("list").description("Show all deployed apps").action(async () => {
|
|
1330
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1331
|
+
const apiClient = createApiClient(loadConfig());
|
|
1332
|
+
const { apps } = await apiClient.list();
|
|
1333
|
+
if (apps.length === 0) {
|
|
1334
|
+
console.log("No apps deployed.");
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
console.log("");
|
|
1338
|
+
console.log(chalk3.bold("NAME".padEnd(20) + "DOMAIN".padEnd(35) + "DEPLOYED"));
|
|
1339
|
+
for (const app of apps) {
|
|
1340
|
+
const ago = timeAgo(app.deployed_at);
|
|
1341
|
+
console.log(`${app.name.padEnd(20)}${app.domain.padEnd(35)}${ago} by ${app.deployed_by}`);
|
|
1342
|
+
}
|
|
1343
|
+
console.log("");
|
|
1344
|
+
});
|
|
1345
|
+
function timeAgo(iso) {
|
|
1346
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
1347
|
+
const mins = Math.floor(ms / 6e4);
|
|
1348
|
+
if (mins < 60) return `${mins}m ago`;
|
|
1349
|
+
const hrs = Math.floor(mins / 60);
|
|
1350
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
1351
|
+
return `${Math.floor(hrs / 24)}d ago`;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// src/commands/remove.ts
|
|
1355
|
+
import { Command as Command6 } from "commander";
|
|
1356
|
+
import inquirer3 from "inquirer";
|
|
1357
|
+
var removeCommand = new Command6("remove").argument("<name>", "App name to remove").description("Remove an app from the cluster").action(async (name) => {
|
|
1358
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1359
|
+
const { confirm } = await inquirer3.prompt([
|
|
1360
|
+
{
|
|
1361
|
+
type: "confirm",
|
|
1362
|
+
name: "confirm",
|
|
1363
|
+
message: `Remove ${name}? This deletes all pods, services, and data.`,
|
|
1364
|
+
default: false
|
|
1365
|
+
}
|
|
1366
|
+
]);
|
|
1367
|
+
if (!confirm) return;
|
|
1368
|
+
const apiClient = createApiClient(loadConfig());
|
|
1369
|
+
const s = spinner(`Removing ${name}...`);
|
|
1370
|
+
s.start();
|
|
1371
|
+
try {
|
|
1372
|
+
await apiClient.remove(name);
|
|
1373
|
+
s.succeed(`${name} removed.`);
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
s.fail("Remove failed");
|
|
1376
|
+
if (err instanceof ApiError && err.code === "NOT_FOUND") {
|
|
1377
|
+
die2(`App "${name}" not found`);
|
|
1378
|
+
}
|
|
1379
|
+
if (err instanceof ApiError) {
|
|
1380
|
+
die2(`API error (${err.code}): ${err.message}`);
|
|
1381
|
+
}
|
|
1382
|
+
throw err;
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// src/commands/logs.ts
|
|
1387
|
+
import { Command as Command7 } from "commander";
|
|
1388
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1389
|
+
var logsCommand = new Command7("logs").argument("[name]", "App name (auto-detected from cloudgrid.yaml if in app dir)").option("-s, --service <service>", "Filter by service name").option("-t, --tail <lines>", "Number of lines to show", "100").description("Show app logs").action(async (name, opts) => {
|
|
1390
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1391
|
+
const appName = name || (existsSync9("cloudgrid.yaml") ? loadCloudGridYaml(process.cwd()).name : null);
|
|
1392
|
+
if (!appName) {
|
|
1393
|
+
die2("Provide app name or run from app directory");
|
|
1394
|
+
}
|
|
1395
|
+
const apiClient = createApiClient(loadConfig());
|
|
1396
|
+
try {
|
|
1397
|
+
const logs = await apiClient.logs(appName, {
|
|
1398
|
+
service: opts.service,
|
|
1399
|
+
tail: opts.tail ? parseInt(opts.tail, 10) : 100
|
|
1400
|
+
});
|
|
1401
|
+
if (logs) {
|
|
1402
|
+
process.stdout.write(logs);
|
|
1403
|
+
if (!logs.endsWith("\n")) process.stdout.write("\n");
|
|
1404
|
+
} else {
|
|
1405
|
+
console.log("No logs available.");
|
|
1406
|
+
}
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
if (err instanceof ApiError && err.code === "NOT_FOUND") {
|
|
1409
|
+
die2(`App "${appName}" not found`);
|
|
1410
|
+
}
|
|
1411
|
+
throw err;
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
// src/commands/status.ts
|
|
1416
|
+
import { Command as Command8 } from "commander";
|
|
1417
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1418
|
+
import chalk4 from "chalk";
|
|
1419
|
+
var statusCommand = new Command8("status").argument("[name]", "App name (auto-detected from cloudgrid.yaml if in app dir)").description("Show app status").action(async (name) => {
|
|
1420
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1421
|
+
const appName = name || (existsSync10("cloudgrid.yaml") ? loadCloudGridYaml(process.cwd()).name : null);
|
|
1422
|
+
if (!appName) {
|
|
1423
|
+
die2("Provide app name or run from app directory");
|
|
1424
|
+
}
|
|
1425
|
+
const apiClient = createApiClient(loadConfig());
|
|
1426
|
+
try {
|
|
1427
|
+
const status = await apiClient.status(appName);
|
|
1428
|
+
console.log("");
|
|
1429
|
+
console.log(chalk4.bold(`App: ${status.name}`));
|
|
1430
|
+
console.log(`Domain: ${status.domain}`);
|
|
1431
|
+
console.log("");
|
|
1432
|
+
console.log(chalk4.bold("PODS"));
|
|
1433
|
+
console.log("NAME".padEnd(40) + "STATUS".padEnd(12) + "READY".padEnd(8) + "RESTARTS");
|
|
1434
|
+
for (const pod of status.pods) {
|
|
1435
|
+
const readyStr = pod.ready ? "Yes" : "No";
|
|
1436
|
+
console.log(
|
|
1437
|
+
`${pod.name.padEnd(40)}${pod.status.padEnd(12)}${readyStr.padEnd(8)}${pod.restarts}`
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
console.log("");
|
|
1441
|
+
if (status.services.length > 0) {
|
|
1442
|
+
console.log(chalk4.bold("SERVICES"));
|
|
1443
|
+
console.log("NAME".padEnd(30) + "TYPE".padEnd(15) + "PORTS");
|
|
1444
|
+
for (const svc of status.services) {
|
|
1445
|
+
const portsStr = Array.isArray(svc.ports) ? svc.ports.map((p) => `${p.port}\u2192${p.targetPort}`).join(", ") : String(svc.ports);
|
|
1446
|
+
console.log(`${svc.name.padEnd(30)}${svc.type.padEnd(15)}${portsStr}`);
|
|
1447
|
+
}
|
|
1448
|
+
console.log("");
|
|
1449
|
+
}
|
|
1450
|
+
if (status.cronJobs && status.cronJobs.length > 0) {
|
|
1451
|
+
console.log(chalk4.bold("CRON JOBS"));
|
|
1452
|
+
console.log(
|
|
1453
|
+
"NAME".padEnd(20) + "SCHEDULE".padEnd(18) + "LAST RUN".padEnd(14) + "NEXT RUN".padEnd(14) + "STATUS".padEnd(16) + "TIMEZONE"
|
|
1454
|
+
);
|
|
1455
|
+
for (const cj of status.cronJobs) {
|
|
1456
|
+
console.log(
|
|
1457
|
+
`${(cj.name || "").padEnd(20)}${(cj.schedule || "").padEnd(18)}${(cj.lastRun || "-").padEnd(14)}${(cj.nextRun || "-").padEnd(14)}${(cj.status || "-").padEnd(16)}${cj.timezone || "UTC"}`
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
console.log("");
|
|
1461
|
+
}
|
|
1462
|
+
if (status.ingress) {
|
|
1463
|
+
console.log(chalk4.bold("INGRESS"));
|
|
1464
|
+
const hosts = status.ingress.hosts?.join(", ") || status.ingress.host || "unknown";
|
|
1465
|
+
console.log(`Host: ${hosts} IP: ${status.ingress.ip}`);
|
|
1466
|
+
console.log("");
|
|
1467
|
+
}
|
|
1468
|
+
try {
|
|
1469
|
+
const [envResult, secretsResult] = await Promise.all([
|
|
1470
|
+
apiClient.listEnv(appName),
|
|
1471
|
+
apiClient.listSecrets(appName)
|
|
1472
|
+
]);
|
|
1473
|
+
const envCount = envResult.env?.length || 0;
|
|
1474
|
+
const secretsCount = secretsResult.secrets?.length || 0;
|
|
1475
|
+
if (envCount > 0 || secretsCount > 0) {
|
|
1476
|
+
console.log(`
|
|
1477
|
+
ENV: ${envCount} vars | SECRETS: ${secretsCount} keys`);
|
|
1478
|
+
}
|
|
1479
|
+
} catch {
|
|
1480
|
+
}
|
|
1481
|
+
try {
|
|
1482
|
+
const eventsResult = await apiClient.listEvents(appName);
|
|
1483
|
+
const events = eventsResult.events || [];
|
|
1484
|
+
if (events.length > 0) {
|
|
1485
|
+
const last = events[0];
|
|
1486
|
+
const icon = last.status === "success" ? "\u2713" : last.status === "failed" ? "\u2717" : "\u23F3";
|
|
1487
|
+
console.log("\n LAST DEPLOY");
|
|
1488
|
+
console.log(` ${icon} ${last.status} ${last.sha ? last.sha.slice(0, 7) : "N/A"} by ${last.triggered_by} (${last.source})`);
|
|
1489
|
+
if (last.status === "failed" && last.error) {
|
|
1490
|
+
console.log(` Error: ${last.error.slice(0, 100)}`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
} catch {
|
|
1494
|
+
}
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
if (err instanceof ApiError && err.code === "NOT_FOUND") {
|
|
1497
|
+
die2(`App "${appName}" not found`);
|
|
1498
|
+
}
|
|
1499
|
+
throw err;
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
// src/commands/doctor.ts
|
|
1504
|
+
import { Command as Command9 } from "commander";
|
|
1505
|
+
import { execSync as execSync2 } from "child_process";
|
|
1506
|
+
import * as net3 from "net";
|
|
1507
|
+
import chalk5 from "chalk";
|
|
1508
|
+
var doctorCommand = new Command9("doctor").description("Run diagnostic checks").action(async () => {
|
|
1509
|
+
console.log(`
|
|
1510
|
+
${chalk5.bold("Cloud Grid Doctor")}
|
|
1511
|
+
`);
|
|
1512
|
+
const results = [];
|
|
1513
|
+
results.push(checkNode());
|
|
1514
|
+
results.push(await checkDocker());
|
|
1515
|
+
const configResult = checkConfig();
|
|
1516
|
+
results.push(configResult.result);
|
|
1517
|
+
if (configResult.config) {
|
|
1518
|
+
results.push(await checkApiReachable(configResult.config));
|
|
1519
|
+
results.push(await checkApiAuth(configResult.config));
|
|
1520
|
+
}
|
|
1521
|
+
const ports = [3e3, 5e3, 27017, 6379];
|
|
1522
|
+
for (const port of ports) {
|
|
1523
|
+
results.push(await checkPort(port));
|
|
1524
|
+
}
|
|
1525
|
+
const passed = results.filter((r) => r.ok).length;
|
|
1526
|
+
const total = results.length;
|
|
1527
|
+
for (const r of results) {
|
|
1528
|
+
const icon = r.ok ? chalk5.green("\u2713") : chalk5.red("\u2717");
|
|
1529
|
+
console.log(` ${icon} ${r.label}: ${r.detail}`);
|
|
1530
|
+
}
|
|
1531
|
+
const warnings = total - passed;
|
|
1532
|
+
console.log(
|
|
1533
|
+
`
|
|
1534
|
+
${passed}/${total} checks passed.${warnings > 0 ? ` ${warnings} warning${warnings > 1 ? "s" : ""}.` : ""}
|
|
1535
|
+
`
|
|
1536
|
+
);
|
|
1537
|
+
});
|
|
1538
|
+
function checkNode() {
|
|
1539
|
+
const version = process.version;
|
|
1540
|
+
const major = parseInt(version.slice(1), 10);
|
|
1541
|
+
return {
|
|
1542
|
+
label: "Node.js",
|
|
1543
|
+
ok: major >= 20,
|
|
1544
|
+
detail: major >= 20 ? version : `${version} (need 20+)`
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
async function checkDocker() {
|
|
1548
|
+
try {
|
|
1549
|
+
execSync2("docker info", { stdio: "pipe" });
|
|
1550
|
+
return { label: "Docker", ok: true, detail: "running" };
|
|
1551
|
+
} catch {
|
|
1552
|
+
return { label: "Docker", ok: false, detail: "not running or not installed" };
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function checkConfig() {
|
|
1556
|
+
if (!configExists()) {
|
|
1557
|
+
return {
|
|
1558
|
+
result: { label: "CLI config", ok: false, detail: "~/.cloudgrid/config.yaml not found" },
|
|
1559
|
+
config: null
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
const config = loadConfig();
|
|
1564
|
+
if (!config.api_url || !config.jwt) {
|
|
1565
|
+
return {
|
|
1566
|
+
result: { label: "CLI config", ok: false, detail: "missing api_url or jwt" },
|
|
1567
|
+
config: null
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
return {
|
|
1571
|
+
result: { label: "CLI config", ok: true, detail: "~/.cloudgrid/config.yaml found" },
|
|
1572
|
+
config
|
|
1573
|
+
};
|
|
1574
|
+
} catch {
|
|
1575
|
+
return {
|
|
1576
|
+
result: { label: "CLI config", ok: false, detail: "failed to parse config" },
|
|
1577
|
+
config: null
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
async function checkApiReachable(config) {
|
|
1582
|
+
try {
|
|
1583
|
+
const url = `${config.api_url.replace(/\/+$/, "")}/healthz`;
|
|
1584
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
1585
|
+
return {
|
|
1586
|
+
label: "API",
|
|
1587
|
+
ok: res.ok,
|
|
1588
|
+
detail: res.ok ? `${config.api_url} reachable (${res.status} OK)` : `${config.api_url} returned ${res.status}`
|
|
1589
|
+
};
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
return { label: "API", ok: false, detail: `${config.api_url} unreachable (${err.message})` };
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
async function checkApiAuth(config) {
|
|
1595
|
+
try {
|
|
1596
|
+
const url = `${config.api_url.replace(/\/+$/, "")}/apps`;
|
|
1597
|
+
const res = await fetch(url, {
|
|
1598
|
+
headers: { "authorization": `Bearer ${config.jwt}` },
|
|
1599
|
+
signal: AbortSignal.timeout(5e3)
|
|
1600
|
+
});
|
|
1601
|
+
return {
|
|
1602
|
+
label: "API auth",
|
|
1603
|
+
ok: res.ok,
|
|
1604
|
+
detail: res.ok ? "valid" : `rejected (${res.status})`
|
|
1605
|
+
};
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
return { label: "API auth", ok: false, detail: `failed (${err.message})` };
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
function checkPort(port) {
|
|
1611
|
+
return new Promise((resolve) => {
|
|
1612
|
+
const server = net3.createServer();
|
|
1613
|
+
server.once("error", (err) => {
|
|
1614
|
+
if (err.code === "EADDRINUSE") {
|
|
1615
|
+
resolve({ label: `Port ${port}`, ok: false, detail: "in use" });
|
|
1616
|
+
} else {
|
|
1617
|
+
resolve({ label: `Port ${port}`, ok: false, detail: err.message });
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
server.once("listening", () => {
|
|
1621
|
+
server.close(() => {
|
|
1622
|
+
resolve({ label: `Port ${port}`, ok: true, detail: "free" });
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
server.listen(port, "127.0.0.1");
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// src/commands/connect.ts
|
|
1630
|
+
import { Command as Command10 } from "commander";
|
|
1631
|
+
import { existsSync as existsSync11 } from "fs";
|
|
1632
|
+
import inquirer4 from "inquirer";
|
|
1633
|
+
var connectCommand = new Command10("connect").description("Connect app to a GitHub repo for auto-deploy on push").action(async () => {
|
|
1634
|
+
if (!existsSync11("cloudgrid.yaml")) die2("No cloudgrid.yaml in current directory");
|
|
1635
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1636
|
+
const cliConfig = loadConfig();
|
|
1637
|
+
const config = loadCloudGridYaml(process.cwd());
|
|
1638
|
+
const apiClient = createApiClient(cliConfig);
|
|
1639
|
+
let repos;
|
|
1640
|
+
try {
|
|
1641
|
+
const result = await apiClient.listRepos();
|
|
1642
|
+
repos = result.repos;
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
die2(`Failed to list repos: ${err.message}`);
|
|
1645
|
+
}
|
|
1646
|
+
if (!repos || repos.length === 0) {
|
|
1647
|
+
die2("No repos found. Check that the Cloud Grid GitHub App is installed on your org.");
|
|
1648
|
+
}
|
|
1649
|
+
const { repo } = await inquirer4.prompt([{
|
|
1650
|
+
type: "list",
|
|
1651
|
+
name: "repo",
|
|
1652
|
+
message: "Select repository:",
|
|
1653
|
+
choices: repos.map((r) => r.full_name)
|
|
1654
|
+
}]);
|
|
1655
|
+
const selectedRepo = repos.find((r) => r.full_name === repo);
|
|
1656
|
+
const { branch } = await inquirer4.prompt([{
|
|
1657
|
+
type: "input",
|
|
1658
|
+
name: "branch",
|
|
1659
|
+
message: "Branch to deploy:",
|
|
1660
|
+
default: selectedRepo?.default_branch || "main"
|
|
1661
|
+
}]);
|
|
1662
|
+
try {
|
|
1663
|
+
await apiClient.connect(config.name, repo, branch);
|
|
1664
|
+
log.success(`Connected ${repo} (${branch}) \u2192 ${config.name}.cloudgrid.io`);
|
|
1665
|
+
log.step("Push to this branch to auto-deploy.");
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
die2(`Failed to connect: ${err.message}`);
|
|
1668
|
+
}
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
// src/commands/disconnect.ts
|
|
1672
|
+
import { Command as Command11 } from "commander";
|
|
1673
|
+
import { existsSync as existsSync12 } from "fs";
|
|
1674
|
+
var disconnectCommand = new Command11("disconnect").description("Disconnect app from GitHub repo (stop auto-deploy)").action(async () => {
|
|
1675
|
+
if (!existsSync12("cloudgrid.yaml")) die2("No cloudgrid.yaml in current directory");
|
|
1676
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1677
|
+
const cliConfig = loadConfig();
|
|
1678
|
+
const config = loadCloudGridYaml(process.cwd());
|
|
1679
|
+
const apiClient = createApiClient(cliConfig);
|
|
1680
|
+
try {
|
|
1681
|
+
await apiClient.disconnect(config.name);
|
|
1682
|
+
log.success(`Disconnected ${config.name} from GitHub. Auto-deploy disabled.`);
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
die2(`Failed to disconnect: ${err.message}`);
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
// src/commands/builds.ts
|
|
1689
|
+
import { Command as Command12 } from "commander";
|
|
1690
|
+
import { existsSync as existsSync13 } from "fs";
|
|
1691
|
+
var buildsCommand = new Command12("builds").description("Show build history").argument("[name]", "App name (defaults to current directory)").option("--logs", "Show build logs for latest build").action(async (name, opts) => {
|
|
1692
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1693
|
+
const cliConfig = loadConfig();
|
|
1694
|
+
const apiClient = createApiClient(cliConfig);
|
|
1695
|
+
const appName = name || (existsSync13("cloudgrid.yaml") ? loadCloudGridYaml(process.cwd()).name : null);
|
|
1696
|
+
if (!appName) die2("Specify app name or run from a directory with cloudgrid.yaml");
|
|
1697
|
+
try {
|
|
1698
|
+
const result = await apiClient.listBuilds(appName);
|
|
1699
|
+
const builds = result.builds || [];
|
|
1700
|
+
if (builds.length === 0) {
|
|
1701
|
+
log.info("No builds yet.");
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
console.log("\n BUILDS");
|
|
1705
|
+
console.log(" " + "-".repeat(60));
|
|
1706
|
+
for (const b of builds) {
|
|
1707
|
+
const icon = b.status === "SUCCESS" ? "\u2713" : "\u2717";
|
|
1708
|
+
console.log(` ${icon} ${b.sha || "N/A"} ${b.status} ${b.duration_s || "?"}s ${b.started_at || ""}`);
|
|
1709
|
+
}
|
|
1710
|
+
console.log("");
|
|
1711
|
+
} catch (err) {
|
|
1712
|
+
die2(`Failed to list builds: ${err.message}`);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
// src/commands/open.ts
|
|
1717
|
+
import { Command as Command13 } from "commander";
|
|
1718
|
+
import { existsSync as existsSync14 } from "fs";
|
|
1719
|
+
import { spawn } from "child_process";
|
|
1720
|
+
var openCommand = new Command13("open").description("Open app in browser").argument("[name]", "App name (defaults to current directory)").action(async (name) => {
|
|
1721
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1722
|
+
const appName = name || (existsSync14("cloudgrid.yaml") ? loadCloudGridYaml(process.cwd()).name : null);
|
|
1723
|
+
if (!appName) die2("Specify app name or run from a directory with cloudgrid.yaml");
|
|
1724
|
+
const cliConfig = loadConfig();
|
|
1725
|
+
const apiClient = createApiClient(cliConfig);
|
|
1726
|
+
try {
|
|
1727
|
+
const status = await apiClient.status(appName);
|
|
1728
|
+
const domain = status.domain || `${appName}.cloudgrid.io`;
|
|
1729
|
+
const url = `https://${domain}`;
|
|
1730
|
+
const platform = process.platform;
|
|
1731
|
+
if (platform === "darwin" || platform === "linux" || platform === "win32") {
|
|
1732
|
+
const opener = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : "start";
|
|
1733
|
+
spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
1734
|
+
} else {
|
|
1735
|
+
log.info(`Open: ${url}`);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
log.success(`Opened ${url}`);
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
die2(`Failed to open app: ${err.message}`);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
// src/commands/ssh.ts
|
|
1745
|
+
import { Command as Command14 } from "commander";
|
|
1746
|
+
import { WebSocket as WebSocket2 } from "ws";
|
|
1747
|
+
var sshCommand = new Command14("ssh").description("Open a shell in a running pod").argument("<name>", "App name").option("-s, --service <service>", "Service name (defaults to first)").action(async (name, opts) => {
|
|
1748
|
+
if (!configExists()) die2("No CLI config. Run: cloudgrid init");
|
|
1749
|
+
const config = loadConfig();
|
|
1750
|
+
const baseUrl = config.api_url.replace(/^http/, "ws");
|
|
1751
|
+
const serviceParam = opts.service ? `?service=${encodeURIComponent(opts.service)}` : "";
|
|
1752
|
+
const wsUrl = `${baseUrl}/apps/${name}/exec${serviceParam}`;
|
|
1753
|
+
const headers = {};
|
|
1754
|
+
if (config.jwt) {
|
|
1755
|
+
headers["authorization"] = `Bearer ${config.jwt}`;
|
|
1756
|
+
}
|
|
1757
|
+
const ws = new WebSocket2(wsUrl, { headers });
|
|
1758
|
+
ws.on("open", () => {
|
|
1759
|
+
log.info(`Connected to ${name}${opts.service ? `/${opts.service}` : ""}. Type 'exit' to disconnect.
|
|
1760
|
+
`);
|
|
1761
|
+
if (process.stdin.isTTY) {
|
|
1762
|
+
process.stdin.setRawMode(true);
|
|
1763
|
+
}
|
|
1764
|
+
process.stdin.resume();
|
|
1765
|
+
process.stdin.on("data", (data) => {
|
|
1766
|
+
if (ws.readyState === ws.OPEN) ws.send(data);
|
|
1767
|
+
});
|
|
1768
|
+
});
|
|
1769
|
+
ws.on("message", (data) => {
|
|
1770
|
+
process.stdout.write(data);
|
|
1771
|
+
});
|
|
1772
|
+
ws.on("close", () => {
|
|
1773
|
+
if (process.stdin.isTTY) {
|
|
1774
|
+
process.stdin.setRawMode(false);
|
|
1775
|
+
}
|
|
1776
|
+
process.stdin.pause();
|
|
1777
|
+
console.log("\nDisconnected.");
|
|
1778
|
+
process.exit(0);
|
|
1779
|
+
});
|
|
1780
|
+
ws.on("error", (err) => {
|
|
1781
|
+
die2(`Connection failed: ${err.message}`);
|
|
1782
|
+
});
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
// src/commands/login.ts
|
|
1786
|
+
import { Command as Command15 } from "commander";
|
|
1787
|
+
import { randomUUID } from "crypto";
|
|
1788
|
+
import { execSync as execSync3 } from "child_process";
|
|
1789
|
+
var loginCommand = new Command15("login").description("Authenticate with Google").action(async () => {
|
|
1790
|
+
let config;
|
|
1791
|
+
if (configExists()) {
|
|
1792
|
+
config = loadConfig();
|
|
1793
|
+
} else {
|
|
1794
|
+
const defaults = detectConfig();
|
|
1795
|
+
config = {
|
|
1796
|
+
api_url: defaults.api_url || "https://api.cloudgrid.io",
|
|
1797
|
+
registry: defaults.registry || "",
|
|
1798
|
+
domain: defaults.domain || "cloudgrid.io"
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
const apiUrl = config.api_url;
|
|
1802
|
+
const sessionCode = randomUUID();
|
|
1803
|
+
const loginUrl = `${apiUrl}/auth/login?code=${sessionCode}`;
|
|
1804
|
+
log.info("Opening browser for Google authentication...");
|
|
1805
|
+
try {
|
|
1806
|
+
const platform = process.platform;
|
|
1807
|
+
if (platform === "darwin") execSync3(`open "${loginUrl}"`);
|
|
1808
|
+
else if (platform === "linux") execSync3(`xdg-open "${loginUrl}"`);
|
|
1809
|
+
else if (platform === "win32") execSync3(`start "${loginUrl}"`);
|
|
1810
|
+
else log.info(`Open this URL: ${loginUrl}`);
|
|
1811
|
+
} catch {
|
|
1812
|
+
log.info(`Open this URL: ${loginUrl}`);
|
|
1813
|
+
}
|
|
1814
|
+
log.info("Waiting for authentication...");
|
|
1815
|
+
const maxAttempts = 150;
|
|
1816
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1817
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1818
|
+
try {
|
|
1819
|
+
const res = await fetch(`${apiUrl}/auth/status?code=${sessionCode}`);
|
|
1820
|
+
const data = await res.json();
|
|
1821
|
+
if (data.status === "authenticated" && data.jwt) {
|
|
1822
|
+
const fullConfig = { ...config, jwt: data.jwt };
|
|
1823
|
+
saveConfig(fullConfig);
|
|
1824
|
+
const payload = JSON.parse(
|
|
1825
|
+
Buffer.from(data.jwt.split(".")[1], "base64").toString()
|
|
1826
|
+
);
|
|
1827
|
+
log.success(`Logged in as ${payload.email} (${payload.role})`);
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
if (data.status === "expired") {
|
|
1831
|
+
die2("Authentication timed out. Run `cloudgrid login` to try again.");
|
|
1832
|
+
}
|
|
1833
|
+
} catch {
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
die2("Authentication timed out. Run `cloudgrid login` to try again.");
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
// src/commands/logout.ts
|
|
1840
|
+
import { Command as Command16 } from "commander";
|
|
1841
|
+
var logoutCommand = new Command16("logout").description("Log out of Cloud Grid").action(async () => {
|
|
1842
|
+
if (!configExists()) die2("Not logged in.");
|
|
1843
|
+
const config = loadConfig();
|
|
1844
|
+
delete config.jwt;
|
|
1845
|
+
saveConfig(config);
|
|
1846
|
+
log.success("Logged out. Run `cloudgrid login` to authenticate.");
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// src/commands/admin.ts
|
|
1850
|
+
import { Command as Command17 } from "commander";
|
|
1851
|
+
var adminCommand = new Command17("admin").description("Admin commands (invite, users, revoke)");
|
|
1852
|
+
adminCommand.command("invite <email>").description("Invite a user").requiredOption("--role <role>", "Role: admin, developer, or viewer").action(async (email, opts) => {
|
|
1853
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1854
|
+
const apiClient = createApiClient();
|
|
1855
|
+
try {
|
|
1856
|
+
await apiClient.request("POST", "/admin/invite", { email, role: opts.role });
|
|
1857
|
+
log.success(`Invited ${email} as ${opts.role}`);
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
die2(`Failed to invite: ${err.message}`);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
adminCommand.command("users").description("List all users").action(async () => {
|
|
1863
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1864
|
+
const apiClient = createApiClient();
|
|
1865
|
+
try {
|
|
1866
|
+
const result = await apiClient.request("GET", "/admin/users");
|
|
1867
|
+
console.log("\n USERS");
|
|
1868
|
+
console.log(" " + "-".repeat(60));
|
|
1869
|
+
for (const u of result.users) {
|
|
1870
|
+
const status = u.last_login ? u.role : `${u.role} (pending)`;
|
|
1871
|
+
console.log(` ${u.email.padEnd(30)} ${status.padEnd(20)} ${u.created_at?.slice(0, 10) || ""}`);
|
|
1872
|
+
}
|
|
1873
|
+
console.log("");
|
|
1874
|
+
} catch (err) {
|
|
1875
|
+
die2(`Failed to list users: ${err.message}`);
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1878
|
+
adminCommand.command("revoke <email>").description("Revoke user access").action(async (email) => {
|
|
1879
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1880
|
+
const apiClient = createApiClient();
|
|
1881
|
+
try {
|
|
1882
|
+
await apiClient.request("DELETE", `/admin/users/${encodeURIComponent(email)}`);
|
|
1883
|
+
log.success(`Revoked access for ${email}`);
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
die2(`Failed to revoke: ${err.message}`);
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
// src/commands/secrets.ts
|
|
1890
|
+
import { Command as Command18 } from "commander";
|
|
1891
|
+
import { existsSync as existsSync15, readFileSync as readFileSync7 } from "fs";
|
|
1892
|
+
var secretsCommand = new Command18("secrets").description("Manage app secrets");
|
|
1893
|
+
secretsCommand.command("set <name> [pairs...]").description("Set secrets (KEY=VALUE)").action(async (name, pairs) => {
|
|
1894
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1895
|
+
if (!pairs.length) die2("Specify at least one KEY=VALUE pair");
|
|
1896
|
+
const values = {};
|
|
1897
|
+
for (const pair of pairs) {
|
|
1898
|
+
const eq = pair.indexOf("=");
|
|
1899
|
+
if (eq < 1) die2(`Invalid format: '${pair}'. Use KEY=VALUE`);
|
|
1900
|
+
values[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1901
|
+
}
|
|
1902
|
+
try {
|
|
1903
|
+
const result = await createApiClient().setSecrets(name, values);
|
|
1904
|
+
log.success(`Set ${result.keys.length} secret(s). Pods restarting.`);
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
die2(`Failed: ${err.message}`);
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
secretsCommand.command("list <name>").description("List secret names").action(async (name) => {
|
|
1910
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1911
|
+
try {
|
|
1912
|
+
const result = await createApiClient().listSecrets(name);
|
|
1913
|
+
if (result.secrets.length === 0) {
|
|
1914
|
+
log.info("No secrets set.");
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
console.log("\n SECRETS");
|
|
1918
|
+
console.log(" " + "-".repeat(40));
|
|
1919
|
+
for (const s of result.secrets) console.log(` ${s.key}`);
|
|
1920
|
+
console.log("");
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
die2(`Failed: ${err.message}`);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
secretsCommand.command("get <name> <key>").description("Show partial reveal of a secret").action(async (name, key) => {
|
|
1926
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1927
|
+
try {
|
|
1928
|
+
const result = await createApiClient().getSecret(name, key);
|
|
1929
|
+
console.log(`
|
|
1930
|
+
${result.key}: ${result.preview}
|
|
1931
|
+
`);
|
|
1932
|
+
} catch (err) {
|
|
1933
|
+
die2(`Failed: ${err.message}`);
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
secretsCommand.command("remove <name> <key>").description("Remove a secret").action(async (name, key) => {
|
|
1937
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1938
|
+
try {
|
|
1939
|
+
await createApiClient().removeSecret(name, key);
|
|
1940
|
+
log.success(`Removed secret '${key}'. Pods restarting.`);
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
die2(`Failed: ${err.message}`);
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
secretsCommand.command("import <name> <file>").description("Import secrets from .env file").action(async (name, file) => {
|
|
1946
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1947
|
+
if (!existsSync15(file)) die2(`File not found: ${file}`);
|
|
1948
|
+
const values = parseEnvFile(readFileSync7(file, "utf-8"));
|
|
1949
|
+
if (Object.keys(values).length === 0) die2("No valid KEY=VALUE pairs found");
|
|
1950
|
+
try {
|
|
1951
|
+
const result = await createApiClient().setSecrets(name, values);
|
|
1952
|
+
log.success(`Imported ${result.keys.length} secret(s). Pods restarting.`);
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
die2(`Failed: ${err.message}`);
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
// src/commands/env-cmd.ts
|
|
1959
|
+
import { Command as Command19 } from "commander";
|
|
1960
|
+
import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
|
|
1961
|
+
var envCommand = new Command19("env").description("Manage app environment variables");
|
|
1962
|
+
envCommand.command("set <name> [pairs...]").description("Set env vars (KEY=VALUE)").action(async (name, pairs) => {
|
|
1963
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1964
|
+
if (!pairs.length) die2("Specify at least one KEY=VALUE pair");
|
|
1965
|
+
const values = {};
|
|
1966
|
+
for (const pair of pairs) {
|
|
1967
|
+
const eq = pair.indexOf("=");
|
|
1968
|
+
if (eq < 1) die2(`Invalid format: '${pair}'. Use KEY=VALUE`);
|
|
1969
|
+
values[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1970
|
+
}
|
|
1971
|
+
try {
|
|
1972
|
+
const result = await createApiClient().setEnv(name, values);
|
|
1973
|
+
log.success(`Set ${result.keys.length} env var(s). Pods restarting.`);
|
|
1974
|
+
} catch (err) {
|
|
1975
|
+
die2(`Failed: ${err.message}`);
|
|
1976
|
+
}
|
|
1977
|
+
});
|
|
1978
|
+
envCommand.command("list <name>").description("List env vars (key=value)").action(async (name) => {
|
|
1979
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1980
|
+
try {
|
|
1981
|
+
const result = await createApiClient().listEnv(name);
|
|
1982
|
+
if (result.env.length === 0) {
|
|
1983
|
+
log.info("No env vars set.");
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
console.log("\n ENV");
|
|
1987
|
+
console.log(" " + "-".repeat(50));
|
|
1988
|
+
for (const e of result.env) console.log(` ${e.key}=${e.value}`);
|
|
1989
|
+
console.log("");
|
|
1990
|
+
} catch (err) {
|
|
1991
|
+
die2(`Failed: ${err.message}`);
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
envCommand.command("remove <name> <key>").description("Remove an env var").action(async (name, key) => {
|
|
1995
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
1996
|
+
try {
|
|
1997
|
+
await createApiClient().removeEnv(name, key);
|
|
1998
|
+
log.success(`Removed '${key}'. Pods restarting.`);
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
die2(`Failed: ${err.message}`);
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
envCommand.command("import <name> <file>").description("Import env vars from .env file").action(async (name, file) => {
|
|
2004
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
2005
|
+
if (!existsSync16(file)) die2(`File not found: ${file}`);
|
|
2006
|
+
const values = parseEnvFile(readFileSync8(file, "utf-8"));
|
|
2007
|
+
if (Object.keys(values).length === 0) die2("No valid KEY=VALUE pairs found");
|
|
2008
|
+
try {
|
|
2009
|
+
const result = await createApiClient().setEnv(name, values);
|
|
2010
|
+
log.success(`Imported ${result.keys.length} env var(s). Pods restarting.`);
|
|
2011
|
+
} catch (err) {
|
|
2012
|
+
die2(`Failed: ${err.message}`);
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
// src/commands/usage.ts
|
|
2017
|
+
import { Command as Command20 } from "commander";
|
|
2018
|
+
import { existsSync as existsSync17 } from "fs";
|
|
2019
|
+
var usageCommand = new Command20("usage").description("Show AI usage for an app").argument("[name]", "App name (defaults to current directory)").action(async (name) => {
|
|
2020
|
+
if (!configExists()) die2("Run: cloudgrid login");
|
|
2021
|
+
const appName = name || (existsSync17("cloudgrid.yaml") ? loadCloudGridYaml(process.cwd()).name : null);
|
|
2022
|
+
if (!appName) die2("Specify app name or run from a directory with cloudgrid.yaml");
|
|
2023
|
+
const apiClient = createApiClient();
|
|
2024
|
+
try {
|
|
2025
|
+
const usage = await apiClient.getUsage(appName);
|
|
2026
|
+
const models = usage.models || {};
|
|
2027
|
+
const month = usage.month || (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
|
|
2028
|
+
console.log(`
|
|
2029
|
+
AI USAGE \u2014 ${appName} (${month})
|
|
2030
|
+
`);
|
|
2031
|
+
if (!usage.total_requests || usage.total_requests === 0) {
|
|
2032
|
+
log.info("No AI usage this month.");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
console.log(" MODEL REQUESTS TOKENS EST. COST");
|
|
2036
|
+
console.log(" " + "-".repeat(58));
|
|
2037
|
+
for (const [model, data] of Object.entries(models)) {
|
|
2038
|
+
const tokens = (data.input_tokens || 0) + (data.output_tokens || 0);
|
|
2039
|
+
const cost = (data.estimated_cost_usd || 0).toFixed(2);
|
|
2040
|
+
console.log(` ${model.padEnd(20)} ${String(data.requests || 0).padEnd(10)} ${String(tokens).padEnd(12)} $${cost}`);
|
|
2041
|
+
}
|
|
2042
|
+
const totalCost = Object.values(models).reduce((sum, m) => sum + (m.estimated_cost_usd || 0), 0);
|
|
2043
|
+
console.log(" " + "-".repeat(58));
|
|
2044
|
+
console.log(` Total: ${usage.total_requests} requests | ${usage.total_tokens} tokens | $${totalCost.toFixed(2)}
|
|
2045
|
+
`);
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
die2(`Failed: ${err.message}`);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
// src/index.ts
|
|
2052
|
+
var __dirname = dirname2(fileURLToPath2(import.meta.url));
|
|
2053
|
+
var pkg = JSON.parse(readFileSync9(join9(__dirname, "../package.json"), "utf-8"));
|
|
2054
|
+
var program = new Command21();
|
|
2055
|
+
program.name("cloudgrid").description("Deploy apps with a single YAML file").version(pkg.version).option("-v, --verbose", "Show detailed output");
|
|
2056
|
+
program.hook("preAction", (_thisCommand, _actionCommand) => {
|
|
2057
|
+
const opts = program.opts();
|
|
2058
|
+
if (opts.verbose) setVerbose(true);
|
|
2059
|
+
});
|
|
2060
|
+
program.addCommand(initCommand);
|
|
2061
|
+
program.addCommand(createCommand);
|
|
2062
|
+
program.addCommand(devCommand);
|
|
2063
|
+
program.addCommand(deployCommand);
|
|
2064
|
+
program.addCommand(listCommand);
|
|
2065
|
+
program.addCommand(removeCommand);
|
|
2066
|
+
program.addCommand(logsCommand);
|
|
2067
|
+
program.addCommand(statusCommand);
|
|
2068
|
+
program.addCommand(doctorCommand);
|
|
2069
|
+
program.addCommand(connectCommand);
|
|
2070
|
+
program.addCommand(disconnectCommand);
|
|
2071
|
+
program.addCommand(buildsCommand);
|
|
2072
|
+
program.addCommand(openCommand);
|
|
2073
|
+
program.addCommand(sshCommand);
|
|
2074
|
+
program.addCommand(loginCommand);
|
|
2075
|
+
program.addCommand(logoutCommand);
|
|
2076
|
+
program.addCommand(adminCommand);
|
|
2077
|
+
program.addCommand(secretsCommand);
|
|
2078
|
+
program.addCommand(envCommand);
|
|
2079
|
+
program.addCommand(usageCommand);
|
|
2080
|
+
if (process.argv.includes("--verbose") || process.argv.includes("-v")) {
|
|
2081
|
+
setVerbose(true);
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
await program.parseAsync();
|
|
2085
|
+
} catch (e) {
|
|
2086
|
+
if (e instanceof CloudGridError) process.exit(1);
|
|
2087
|
+
if (isVerbose()) {
|
|
2088
|
+
console.error(e);
|
|
2089
|
+
} else if (e instanceof Error) {
|
|
2090
|
+
console.error(`Error: ${e.message}`);
|
|
2091
|
+
}
|
|
2092
|
+
process.exit(1);
|
|
2093
|
+
}
|
|
2094
|
+
//# sourceMappingURL=index.js.map
|