@light-cloud/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/dist/index.js +4090 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4090 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/lib/errors.ts
|
|
7
|
+
var EXIT = {
|
|
8
|
+
OK: 0,
|
|
9
|
+
ERROR: 1,
|
|
10
|
+
USAGE: 2,
|
|
11
|
+
AUTH: 3,
|
|
12
|
+
NOT_FOUND: 4,
|
|
13
|
+
FAILED: 5,
|
|
14
|
+
CANCELLED: 130
|
|
15
|
+
};
|
|
16
|
+
var CliError = class extends Error {
|
|
17
|
+
hint;
|
|
18
|
+
exitCode;
|
|
19
|
+
code;
|
|
20
|
+
constructor(message, options = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "CliError";
|
|
23
|
+
this.hint = options.hint;
|
|
24
|
+
this.exitCode = options.exitCode ?? EXIT.ERROR;
|
|
25
|
+
this.code = options.code ?? "ERROR";
|
|
26
|
+
if (options.cause !== void 0) {
|
|
27
|
+
this.cause = options.cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var CancelledError = class extends CliError {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("Cancelled.", { exitCode: EXIT.CANCELLED, code: "CANCELLED" });
|
|
34
|
+
this.name = "CancelledError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var ApiError = class extends CliError {
|
|
38
|
+
status;
|
|
39
|
+
constructor(status, code, message, hint) {
|
|
40
|
+
super(message, {
|
|
41
|
+
code,
|
|
42
|
+
hint,
|
|
43
|
+
exitCode: status === 401 ? EXIT.AUTH : status === 404 ? EXIT.NOT_FOUND : EXIT.ERROR
|
|
44
|
+
});
|
|
45
|
+
this.name = "ApiError";
|
|
46
|
+
this.status = status;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var notLoggedIn = () => new CliError("You are not signed in to Light Cloud.", {
|
|
50
|
+
hint: "Run `lc login`, or set LIGHT_CLOUD_API_KEY for unattended use.",
|
|
51
|
+
exitCode: EXIT.AUTH,
|
|
52
|
+
code: "UNAUTHENTICATED"
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// src/lib/open-browser.ts
|
|
56
|
+
import { spawn } from "child_process";
|
|
57
|
+
function openBrowser(url) {
|
|
58
|
+
try {
|
|
59
|
+
const child = process.platform === "darwin" ? spawn("open", [url], { detached: true, stdio: "ignore" }) : process.platform === "win32" ? spawn("cmd", ["/c", "start", "", url.replace(/&/g, "^&")], { detached: true, stdio: "ignore" }) : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
60
|
+
child.on("error", () => void 0);
|
|
61
|
+
child.unref();
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/lib/ui/output.ts
|
|
69
|
+
import pc from "picocolors";
|
|
70
|
+
var state = {
|
|
71
|
+
json: false,
|
|
72
|
+
color: pc.isColorSupported,
|
|
73
|
+
quiet: false
|
|
74
|
+
};
|
|
75
|
+
var palette = pc.createColors(state.color);
|
|
76
|
+
function configureOutput(options) {
|
|
77
|
+
Object.assign(state, options);
|
|
78
|
+
palette = pc.createColors(state.color);
|
|
79
|
+
}
|
|
80
|
+
var isJson = () => state.json;
|
|
81
|
+
var isInteractive = () => !state.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
82
|
+
var c = {
|
|
83
|
+
bold: (s) => palette.bold(s),
|
|
84
|
+
dim: (s) => palette.dim(s),
|
|
85
|
+
italic: (s) => palette.italic(s),
|
|
86
|
+
underline: (s) => palette.underline(s),
|
|
87
|
+
red: (s) => palette.red(s),
|
|
88
|
+
green: (s) => palette.green(s),
|
|
89
|
+
yellow: (s) => palette.yellow(s),
|
|
90
|
+
blue: (s) => palette.blue(s),
|
|
91
|
+
magenta: (s) => palette.magenta(s),
|
|
92
|
+
cyan: (s) => palette.cyan(s),
|
|
93
|
+
gray: (s) => palette.gray(s),
|
|
94
|
+
white: (s) => palette.white(s),
|
|
95
|
+
bgRed: (s) => palette.bgRed(s),
|
|
96
|
+
inverse: (s) => palette.inverse(s),
|
|
97
|
+
/** Brand accent. */
|
|
98
|
+
accent: (s) => palette.yellow(s)
|
|
99
|
+
};
|
|
100
|
+
var sym = {
|
|
101
|
+
ok: "\u2714",
|
|
102
|
+
fail: "\u2716",
|
|
103
|
+
warn: "\u25B2",
|
|
104
|
+
info: "\u2139",
|
|
105
|
+
dot: "\u25CF",
|
|
106
|
+
ring: "\u25CB",
|
|
107
|
+
arrow: "\u2192",
|
|
108
|
+
bullet: "\xB7",
|
|
109
|
+
bar: "\u2502",
|
|
110
|
+
star: "\u2605"
|
|
111
|
+
};
|
|
112
|
+
function out(line = "") {
|
|
113
|
+
if (state.json) return;
|
|
114
|
+
process.stdout.write(line + "\n");
|
|
115
|
+
}
|
|
116
|
+
function err(line = "") {
|
|
117
|
+
process.stderr.write(line + "\n");
|
|
118
|
+
}
|
|
119
|
+
function printJson(value) {
|
|
120
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
121
|
+
}
|
|
122
|
+
function emit(value, human) {
|
|
123
|
+
if (state.json) {
|
|
124
|
+
printJson(value);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
human();
|
|
128
|
+
}
|
|
129
|
+
var log = {
|
|
130
|
+
info: (message) => {
|
|
131
|
+
if (state.json || state.quiet) return;
|
|
132
|
+
out(`${c.blue(sym.info)} ${message}`);
|
|
133
|
+
},
|
|
134
|
+
success: (message) => {
|
|
135
|
+
if (state.json || state.quiet) return;
|
|
136
|
+
out(`${c.green(sym.ok)} ${message}`);
|
|
137
|
+
},
|
|
138
|
+
warn: (message) => {
|
|
139
|
+
if (state.json) return;
|
|
140
|
+
err(`${c.yellow(sym.warn)} ${message}`);
|
|
141
|
+
},
|
|
142
|
+
error: (message, hint) => {
|
|
143
|
+
err(`${c.red(sym.fail)} ${message}`);
|
|
144
|
+
if (hint) err(` ${c.dim(hint)}`);
|
|
145
|
+
},
|
|
146
|
+
step: (message) => {
|
|
147
|
+
if (state.json || state.quiet) return;
|
|
148
|
+
out(`${c.dim(sym.bar)} ${message}`);
|
|
149
|
+
},
|
|
150
|
+
blank: () => out()
|
|
151
|
+
};
|
|
152
|
+
function statusBadge(status) {
|
|
153
|
+
const value = (status || "unknown").toLowerCase();
|
|
154
|
+
switch (value) {
|
|
155
|
+
case "deployed":
|
|
156
|
+
case "ready":
|
|
157
|
+
case "healthy":
|
|
158
|
+
case "active":
|
|
159
|
+
case "completed":
|
|
160
|
+
case "success":
|
|
161
|
+
return c.green(`${sym.dot} ${value}`);
|
|
162
|
+
case "deploying":
|
|
163
|
+
case "building":
|
|
164
|
+
case "provisioning":
|
|
165
|
+
case "queued":
|
|
166
|
+
case "pending":
|
|
167
|
+
case "in_progress":
|
|
168
|
+
case "pending_verification":
|
|
169
|
+
return c.yellow(`${sym.ring} ${value}`);
|
|
170
|
+
case "failed":
|
|
171
|
+
case "delete_failed":
|
|
172
|
+
case "error":
|
|
173
|
+
case "degraded":
|
|
174
|
+
return c.red(`${sym.fail} ${value}`);
|
|
175
|
+
case "deleting":
|
|
176
|
+
case "deleted":
|
|
177
|
+
return c.gray(`${sym.ring} ${value}`);
|
|
178
|
+
default:
|
|
179
|
+
return c.gray(`${sym.ring} ${value}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function severityBadge(severity) {
|
|
183
|
+
const s = (severity || "DEFAULT").toUpperCase().padEnd(8);
|
|
184
|
+
switch (severity?.toUpperCase()) {
|
|
185
|
+
case "ERROR":
|
|
186
|
+
case "CRITICAL":
|
|
187
|
+
case "ALERT":
|
|
188
|
+
case "EMERGENCY":
|
|
189
|
+
return c.red(s);
|
|
190
|
+
case "WARNING":
|
|
191
|
+
return c.yellow(s);
|
|
192
|
+
case "INFO":
|
|
193
|
+
case "NOTICE":
|
|
194
|
+
return c.blue(s);
|
|
195
|
+
case "DEBUG":
|
|
196
|
+
return c.gray(s);
|
|
197
|
+
default:
|
|
198
|
+
return c.dim(s);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function relativeTime(value) {
|
|
202
|
+
if (!value) return c.dim("never");
|
|
203
|
+
const date = typeof value === "string" ? new Date(value) : value;
|
|
204
|
+
if (Number.isNaN(date.getTime())) return c.dim("unknown");
|
|
205
|
+
const seconds = Math.round((Date.now() - date.getTime()) / 1e3);
|
|
206
|
+
const abs = Math.abs(seconds);
|
|
207
|
+
const suffix = seconds >= 0 ? "ago" : "from now";
|
|
208
|
+
if (abs < 45) return "just now";
|
|
209
|
+
if (abs < 3600) return `${Math.round(abs / 60)}m ${suffix}`;
|
|
210
|
+
if (abs < 86400) return `${Math.round(abs / 3600)}h ${suffix}`;
|
|
211
|
+
if (abs < 86400 * 30) return `${Math.round(abs / 86400)}d ${suffix}`;
|
|
212
|
+
return date.toISOString().slice(0, 10);
|
|
213
|
+
}
|
|
214
|
+
function formatDuration(ms) {
|
|
215
|
+
if (ms < 1e3) return `${Math.max(0, Math.round(ms))}ms`;
|
|
216
|
+
const seconds = ms / 1e3;
|
|
217
|
+
if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
218
|
+
const minutes = Math.floor(seconds / 60);
|
|
219
|
+
const rest = Math.round(seconds % 60);
|
|
220
|
+
if (minutes < 60) return `${minutes}m ${rest}s`;
|
|
221
|
+
const hours = Math.floor(minutes / 60);
|
|
222
|
+
return `${hours}h ${minutes % 60}m`;
|
|
223
|
+
}
|
|
224
|
+
function formatBytes(bytes) {
|
|
225
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
226
|
+
const units = ["KB", "MB", "GB"];
|
|
227
|
+
let value = bytes / 1024;
|
|
228
|
+
let unit = 0;
|
|
229
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
230
|
+
value /= 1024;
|
|
231
|
+
unit++;
|
|
232
|
+
}
|
|
233
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
234
|
+
}
|
|
235
|
+
function shortId(id, length = 8) {
|
|
236
|
+
if (!id) return "";
|
|
237
|
+
return id.length > length ? id.slice(0, length) : id;
|
|
238
|
+
}
|
|
239
|
+
function truncate(value, width) {
|
|
240
|
+
if (visibleLength(value) <= width) return value;
|
|
241
|
+
if (width <= 1) return "\u2026";
|
|
242
|
+
let result = "";
|
|
243
|
+
let used = 0;
|
|
244
|
+
for (const char of stripAnsi(value)) {
|
|
245
|
+
if (used + 1 > width - 1) break;
|
|
246
|
+
result += char;
|
|
247
|
+
used += 1;
|
|
248
|
+
}
|
|
249
|
+
return result + "\u2026";
|
|
250
|
+
}
|
|
251
|
+
var ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
|
|
252
|
+
function stripAnsi(value) {
|
|
253
|
+
return value.replace(ANSI_PATTERN, "");
|
|
254
|
+
}
|
|
255
|
+
function visibleLength(value) {
|
|
256
|
+
return [...stripAnsi(value)].length;
|
|
257
|
+
}
|
|
258
|
+
function pad(value, width, align = "left") {
|
|
259
|
+
const gap = Math.max(0, width - visibleLength(value));
|
|
260
|
+
return align === "right" ? " ".repeat(gap) + value : value + " ".repeat(gap);
|
|
261
|
+
}
|
|
262
|
+
function renderTable(rows, columns, options = {}) {
|
|
263
|
+
const indent = options.indent ?? " ";
|
|
264
|
+
const terminalWidth = process.stdout.columns || 120;
|
|
265
|
+
const cells = rows.map(
|
|
266
|
+
(row) => columns.map((column) => {
|
|
267
|
+
const value = column.cell(row) ?? "";
|
|
268
|
+
const width = column.maxWidth ?? Math.max(20, Math.floor(terminalWidth / 2));
|
|
269
|
+
return truncate(value, width);
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
const widths = columns.map(
|
|
273
|
+
(column, index) => Math.max(visibleLength(column.header), ...cells.map((cell) => visibleLength(cell[index] ?? "")))
|
|
274
|
+
);
|
|
275
|
+
const lines = [];
|
|
276
|
+
lines.push(
|
|
277
|
+
indent + columns.map((column, index) => c.dim(pad(column.header.toUpperCase(), widths[index] ?? 0, column.align))).join(" ")
|
|
278
|
+
);
|
|
279
|
+
for (const row of cells) {
|
|
280
|
+
lines.push(indent + row.map((cell, index) => pad(cell, widths[index] ?? 0, columns[index]?.align)).join(" "));
|
|
281
|
+
}
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
}
|
|
284
|
+
function printTable(rows, columns, options) {
|
|
285
|
+
out(renderTable(rows, columns, options));
|
|
286
|
+
}
|
|
287
|
+
function renderDetails(pairs, options = {}) {
|
|
288
|
+
const indent = options.indent ?? " ";
|
|
289
|
+
const visible = pairs.filter(([, value]) => value !== void 0 && value !== null && value !== "");
|
|
290
|
+
const width = Math.max(...visible.map(([label]) => label.length), 0);
|
|
291
|
+
return visible.map(([label, value]) => `${indent}${c.dim(label.padEnd(width))} ${value}`).join("\n");
|
|
292
|
+
}
|
|
293
|
+
function printDetails(pairs, options) {
|
|
294
|
+
out(renderDetails(pairs, options));
|
|
295
|
+
}
|
|
296
|
+
function heading(text3) {
|
|
297
|
+
out(c.bold(text3));
|
|
298
|
+
}
|
|
299
|
+
function link(url) {
|
|
300
|
+
return c.cyan(c.underline(url));
|
|
301
|
+
}
|
|
302
|
+
function maskSecret(value) {
|
|
303
|
+
if (value.length <= 6) return "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
304
|
+
return `${value.slice(0, 3)}${"\u2022".repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`;
|
|
305
|
+
}
|
|
306
|
+
function brand() {
|
|
307
|
+
return `${c.accent("\u2601")} ${c.bold("Light Cloud")}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// src/lib/ui/prompts.ts
|
|
311
|
+
import * as clack from "@clack/prompts";
|
|
312
|
+
function guard(value) {
|
|
313
|
+
if (clack.isCancel(value)) throw new CancelledError();
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
function intro2(title) {
|
|
317
|
+
if (!isInteractive()) return;
|
|
318
|
+
clack.intro(title);
|
|
319
|
+
}
|
|
320
|
+
function outro2(message) {
|
|
321
|
+
if (isJson()) return;
|
|
322
|
+
if (!isInteractive()) {
|
|
323
|
+
out(message);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
clack.outro(message);
|
|
327
|
+
}
|
|
328
|
+
function note2(message, title) {
|
|
329
|
+
if (isJson()) return;
|
|
330
|
+
if (!isInteractive()) {
|
|
331
|
+
if (title) out(c.bold(title));
|
|
332
|
+
out(message);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
clack.note(message, title);
|
|
336
|
+
}
|
|
337
|
+
function logStep(message) {
|
|
338
|
+
if (isJson()) return;
|
|
339
|
+
if (!isInteractive()) {
|
|
340
|
+
out(message);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
clack.log.step(message);
|
|
344
|
+
}
|
|
345
|
+
async function select2(message, options, config) {
|
|
346
|
+
if (!isInteractive()) {
|
|
347
|
+
throw new CliError(`${message} \u2014 no terminal to ask in.`, {
|
|
348
|
+
hint: `Pass ${config.flag} to choose without a prompt.`,
|
|
349
|
+
exitCode: EXIT.USAGE,
|
|
350
|
+
code: "NON_INTERACTIVE"
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (options.length === 0) {
|
|
354
|
+
throw new CliError(`${message} \u2014 nothing to choose from.`, { exitCode: EXIT.NOT_FOUND });
|
|
355
|
+
}
|
|
356
|
+
const value = await clack.select({
|
|
357
|
+
message,
|
|
358
|
+
options: options.map((option) => ({ value: option.value, label: option.label, hint: option.hint })),
|
|
359
|
+
initialValue: config.initialValue,
|
|
360
|
+
maxItems: 12
|
|
361
|
+
});
|
|
362
|
+
return guard(value);
|
|
363
|
+
}
|
|
364
|
+
async function confirm2(message, config) {
|
|
365
|
+
if (config.yes) return true;
|
|
366
|
+
if (!isInteractive()) {
|
|
367
|
+
throw new CliError(`${message} \u2014 no terminal to confirm in.`, {
|
|
368
|
+
hint: `Pass ${config.flag ?? "--yes"} to confirm without a prompt.`,
|
|
369
|
+
exitCode: EXIT.USAGE,
|
|
370
|
+
code: "NON_INTERACTIVE"
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const value = await clack.confirm({ message, initialValue: config.initialValue ?? false });
|
|
374
|
+
return guard(value);
|
|
375
|
+
}
|
|
376
|
+
async function text2(message, config) {
|
|
377
|
+
if (!isInteractive()) {
|
|
378
|
+
throw new CliError(`${message} \u2014 no terminal to ask in.`, {
|
|
379
|
+
hint: `Pass ${config.flag} to provide the value.`,
|
|
380
|
+
exitCode: EXIT.USAGE,
|
|
381
|
+
code: "NON_INTERACTIVE"
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
const value = await clack.text({
|
|
385
|
+
message,
|
|
386
|
+
placeholder: config.placeholder,
|
|
387
|
+
initialValue: config.initialValue,
|
|
388
|
+
validate: config.validate ? (input) => {
|
|
389
|
+
const result = config.validate?.(String(input ?? ""));
|
|
390
|
+
return result ? result : void 0;
|
|
391
|
+
} : void 0
|
|
392
|
+
});
|
|
393
|
+
return guard(value);
|
|
394
|
+
}
|
|
395
|
+
function spinner2() {
|
|
396
|
+
if (isInteractive()) {
|
|
397
|
+
const s = clack.spinner();
|
|
398
|
+
return {
|
|
399
|
+
start: (message) => s.start(message),
|
|
400
|
+
message: (message) => s.message(message),
|
|
401
|
+
stop: (message, code) => {
|
|
402
|
+
if (code === 1) s.error(message);
|
|
403
|
+
else if (code === 2) s.cancel(message);
|
|
404
|
+
else s.stop(message);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
let current = "";
|
|
409
|
+
return {
|
|
410
|
+
start: (message) => {
|
|
411
|
+
current = message;
|
|
412
|
+
if (!isJson()) out(`${c.dim("\u2026")} ${message}`);
|
|
413
|
+
},
|
|
414
|
+
message: (message) => {
|
|
415
|
+
if (message !== current) {
|
|
416
|
+
current = message;
|
|
417
|
+
if (!isJson()) out(`${c.dim("\u2026")} ${message}`);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
stop: (message, code) => {
|
|
421
|
+
if (isJson() || !message) return;
|
|
422
|
+
const glyph = code === 1 ? c.red("\u2716") : code === 2 ? c.yellow("\u25B2") : c.green("\u2714");
|
|
423
|
+
out(`${glyph} ${message}`);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/lib/auth/credentials.ts
|
|
429
|
+
import * as fs from "fs";
|
|
430
|
+
import * as os from "os";
|
|
431
|
+
import * as path from "path";
|
|
432
|
+
var CONFIG_DIR = path.join(os.homedir(), ".lightcloud");
|
|
433
|
+
var CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
|
|
434
|
+
function credentialsPath() {
|
|
435
|
+
return CREDENTIALS_FILE;
|
|
436
|
+
}
|
|
437
|
+
function ensureConfigDir() {
|
|
438
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
439
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function readCredentials() {
|
|
443
|
+
try {
|
|
444
|
+
if (!fs.existsSync(CREDENTIALS_FILE)) return {};
|
|
445
|
+
const parsed = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, "utf-8"));
|
|
446
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
447
|
+
} catch {
|
|
448
|
+
return {};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function writeCredentials(credentials) {
|
|
452
|
+
ensureConfigDir();
|
|
453
|
+
const clean = {};
|
|
454
|
+
for (const [key, value] of Object.entries(credentials)) {
|
|
455
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
456
|
+
clean[key] = value;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(clean, null, 2) + "\n", {
|
|
460
|
+
mode: 384
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function updateCredentials(patch) {
|
|
464
|
+
writeCredentials({ ...readCredentials(), ...patch });
|
|
465
|
+
}
|
|
466
|
+
function clearCredentials() {
|
|
467
|
+
try {
|
|
468
|
+
if (fs.existsSync(CREDENTIALS_FILE)) fs.unlinkSync(CREDENTIALS_FILE);
|
|
469
|
+
} catch {
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function resolveAuth() {
|
|
473
|
+
const fromEnv = process.env.LIGHT_CLOUD_API_KEY?.trim();
|
|
474
|
+
if (fromEnv) return { source: "env-api-key", token: fromEnv };
|
|
475
|
+
const stored = readCredentials();
|
|
476
|
+
if (stored.apiKey) return { source: "stored-api-key", token: stored.apiKey };
|
|
477
|
+
if (stored.accessToken) return { source: "session", token: stored.accessToken };
|
|
478
|
+
return { source: "none", token: null };
|
|
479
|
+
}
|
|
480
|
+
function isApiKey(token) {
|
|
481
|
+
return typeof token === "string" && token.startsWith("lc_");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/lib/version.ts
|
|
485
|
+
import { createRequire } from "module";
|
|
486
|
+
var require2 = createRequire(import.meta.url);
|
|
487
|
+
function cliVersion() {
|
|
488
|
+
try {
|
|
489
|
+
const pkg = require2("../package.json");
|
|
490
|
+
if (pkg.version) return pkg.version;
|
|
491
|
+
} catch {
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const pkg = require2("../../package.json");
|
|
495
|
+
if (pkg.version) return pkg.version;
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
return "0.0.0";
|
|
499
|
+
}
|
|
500
|
+
var USER_AGENT = `light-cloud-cli/${cliVersion()} (${process.platform}; node ${process.versions.node})`;
|
|
501
|
+
|
|
502
|
+
// src/lib/api/client.ts
|
|
503
|
+
var NEXT_STEP_HINTS = {
|
|
504
|
+
"choose-plan": "Change plan with `lc billing plans` then `lc billing plan use <id>`.",
|
|
505
|
+
"add-payment-method": "Add a card with `lc billing card add`."
|
|
506
|
+
};
|
|
507
|
+
var ApiClient = class {
|
|
508
|
+
endpoints;
|
|
509
|
+
refreshing = null;
|
|
510
|
+
constructor(endpoints) {
|
|
511
|
+
this.endpoints = endpoints;
|
|
512
|
+
}
|
|
513
|
+
authSource() {
|
|
514
|
+
return resolveAuth().source;
|
|
515
|
+
}
|
|
516
|
+
/** True when a credential is present. Whether it still works is for the API to say. */
|
|
517
|
+
hasCredentials() {
|
|
518
|
+
return resolveAuth().token !== null;
|
|
519
|
+
}
|
|
520
|
+
usingApiKey() {
|
|
521
|
+
return isApiKey(resolveAuth().token);
|
|
522
|
+
}
|
|
523
|
+
async get(path10, options = {}) {
|
|
524
|
+
return this.json("GET", path10, options);
|
|
525
|
+
}
|
|
526
|
+
async post(path10, body, options = {}) {
|
|
527
|
+
return this.json("POST", path10, { ...options, body });
|
|
528
|
+
}
|
|
529
|
+
async put(path10, body, options = {}) {
|
|
530
|
+
return this.json("PUT", path10, { ...options, body });
|
|
531
|
+
}
|
|
532
|
+
async delete(path10, body, options = {}) {
|
|
533
|
+
return this.json("DELETE", path10, { ...options, body });
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A raw response for streaming bodies (SSE log tails, database dumps).
|
|
537
|
+
* The caller owns the body; errors are still raised as ApiError.
|
|
538
|
+
*/
|
|
539
|
+
async stream(path10, options = {}) {
|
|
540
|
+
const { method = "GET", ...rest } = options;
|
|
541
|
+
const response = await this.send(method, path10, {
|
|
542
|
+
...rest,
|
|
543
|
+
headers: { Accept: "text/event-stream, application/octet-stream, */*", ...rest.headers }
|
|
544
|
+
});
|
|
545
|
+
if (!response.ok) throw await this.toApiError(response);
|
|
546
|
+
return response;
|
|
547
|
+
}
|
|
548
|
+
async json(method, path10, options) {
|
|
549
|
+
const response = await this.send(method, path10, options);
|
|
550
|
+
if (!response.ok) throw await this.toApiError(response);
|
|
551
|
+
if (response.status === 204) return void 0;
|
|
552
|
+
const text3 = await response.text();
|
|
553
|
+
if (!text3) return void 0;
|
|
554
|
+
try {
|
|
555
|
+
return JSON.parse(text3);
|
|
556
|
+
} catch {
|
|
557
|
+
throw new CliError(`The API returned something that is not JSON (${response.status}).`, {
|
|
558
|
+
hint: `Check that ${this.endpoints.apiUrl} is a Light Cloud API URL.`
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async send(method, path10, options, retried = false) {
|
|
563
|
+
const url = this.buildUrl(path10, options.query);
|
|
564
|
+
const headers = {
|
|
565
|
+
Accept: "application/json",
|
|
566
|
+
"User-Agent": USER_AGENT,
|
|
567
|
+
"X-Client-Type": "cli",
|
|
568
|
+
...options.headers
|
|
569
|
+
};
|
|
570
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
571
|
+
if (!options.anonymous) {
|
|
572
|
+
const auth = resolveAuth();
|
|
573
|
+
if (!auth.token) throw notLoggedIn();
|
|
574
|
+
headers.Authorization = `Bearer ${auth.token}`;
|
|
575
|
+
}
|
|
576
|
+
let response;
|
|
577
|
+
try {
|
|
578
|
+
response = await fetch(url, {
|
|
579
|
+
method,
|
|
580
|
+
headers,
|
|
581
|
+
body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
|
|
582
|
+
signal: options.signal
|
|
583
|
+
});
|
|
584
|
+
} catch (error) {
|
|
585
|
+
if (options.signal?.aborted) throw error;
|
|
586
|
+
const reason = error instanceof Error ? error.cause?.message || error.message : String(error);
|
|
587
|
+
throw new CliError(`Could not reach ${this.endpoints.apiUrl} (${reason}).`, {
|
|
588
|
+
hint: "Check your network connection, or set --api-url if you use a different endpoint.",
|
|
589
|
+
code: "NETWORK",
|
|
590
|
+
cause: error
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (response.status === 401 && !options.anonymous && !retried && !this.usingApiKey()) {
|
|
594
|
+
const refreshed = await this.refreshSession();
|
|
595
|
+
if (refreshed) return this.send(method, path10, options, true);
|
|
596
|
+
}
|
|
597
|
+
return response;
|
|
598
|
+
}
|
|
599
|
+
buildUrl(path10, query) {
|
|
600
|
+
const url = new URL(path10.startsWith("http") ? path10 : `${this.endpoints.apiUrl}${path10}`);
|
|
601
|
+
if (query) {
|
|
602
|
+
for (const [key, value] of Object.entries(query)) {
|
|
603
|
+
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return url.toString();
|
|
607
|
+
}
|
|
608
|
+
async refreshSession() {
|
|
609
|
+
if (!this.refreshing) {
|
|
610
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
611
|
+
this.refreshing = null;
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return this.refreshing;
|
|
615
|
+
}
|
|
616
|
+
async doRefresh() {
|
|
617
|
+
const { refreshToken } = readCredentials();
|
|
618
|
+
if (!refreshToken) return false;
|
|
619
|
+
try {
|
|
620
|
+
const response = await fetch(`${this.endpoints.apiUrl}/api/auth/refresh`, {
|
|
621
|
+
method: "POST",
|
|
622
|
+
headers: {
|
|
623
|
+
"Content-Type": "application/json",
|
|
624
|
+
"User-Agent": USER_AGENT,
|
|
625
|
+
"X-Client-Type": "cli"
|
|
626
|
+
},
|
|
627
|
+
body: JSON.stringify({ refreshToken })
|
|
628
|
+
});
|
|
629
|
+
if (!response.ok) return false;
|
|
630
|
+
const data = await response.json();
|
|
631
|
+
const accessToken = data.token || data.accessToken;
|
|
632
|
+
if (!accessToken) return false;
|
|
633
|
+
updateCredentials({ accessToken, refreshToken: data.refreshToken || refreshToken });
|
|
634
|
+
return true;
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async toApiError(response) {
|
|
640
|
+
let body = {};
|
|
641
|
+
try {
|
|
642
|
+
body = await response.json();
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
const message = body.message || body.error || response.statusText || `HTTP ${response.status}`;
|
|
646
|
+
const code = body.code || `HTTP_${response.status}`;
|
|
647
|
+
if (response.status === 401) {
|
|
648
|
+
const hint = this.usingApiKey() ? "The API key was rejected. It may be revoked, expired, or from another environment." : "Your session has expired. Run `lc login` to sign in again.";
|
|
649
|
+
return new ApiError(401, code, "Authentication failed.", hint);
|
|
650
|
+
}
|
|
651
|
+
const nextStep = body.nextStep ? NEXT_STEP_HINTS[body.nextStep] : void 0;
|
|
652
|
+
if (response.status === 402) {
|
|
653
|
+
return new ApiError(402, code, message, nextStep ?? "This needs a paid plan. Manage plans with `lc billing plans`.");
|
|
654
|
+
}
|
|
655
|
+
if (response.status === 403) {
|
|
656
|
+
return new ApiError(
|
|
657
|
+
403,
|
|
658
|
+
code,
|
|
659
|
+
message,
|
|
660
|
+
nextStep ?? "Your role in this workspace does not allow it, or the resource belongs to another workspace."
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (response.status === 429) {
|
|
664
|
+
return new ApiError(429, code, "Too many requests.", "Wait a moment and try again.");
|
|
665
|
+
}
|
|
666
|
+
return new ApiError(response.status, code, message, nextStep);
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// src/lib/api/light-cloud.ts
|
|
671
|
+
var LightCloudApi = class {
|
|
672
|
+
constructor(client) {
|
|
673
|
+
this.client = client;
|
|
674
|
+
}
|
|
675
|
+
client;
|
|
676
|
+
// ---- account -------------------------------------------------------------
|
|
677
|
+
async profile() {
|
|
678
|
+
const data = await this.client.get("/api/auth/profile");
|
|
679
|
+
return "user" in data ? data.user : data;
|
|
680
|
+
}
|
|
681
|
+
async platformConfig() {
|
|
682
|
+
return this.client.get("/api/config/platform");
|
|
683
|
+
}
|
|
684
|
+
// ---- applications --------------------------------------------------------
|
|
685
|
+
async listApplications(organisationId, filter, limit = 100) {
|
|
686
|
+
const page = await this.client.post("/api/applications", {
|
|
687
|
+
targetOrganisationId: organisationId,
|
|
688
|
+
page: 1,
|
|
689
|
+
limit,
|
|
690
|
+
filter: filter || void 0
|
|
691
|
+
});
|
|
692
|
+
return page.items;
|
|
693
|
+
}
|
|
694
|
+
async getApplication(organisationId, applicationId) {
|
|
695
|
+
return this.client.post("/api/applications/get", {
|
|
696
|
+
targetOrganisationId: organisationId,
|
|
697
|
+
applicationId
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
async applicationStatus(organisationId, applicationId) {
|
|
701
|
+
return this.client.post("/api/applications/status", {
|
|
702
|
+
targetOrganisationId: organisationId,
|
|
703
|
+
applicationId
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
async createApplication(request) {
|
|
707
|
+
return this.client.post("/api/applications/create", request);
|
|
708
|
+
}
|
|
709
|
+
async createFromUpload(request) {
|
|
710
|
+
return this.client.post("/api/applications/create-from-upload", request);
|
|
711
|
+
}
|
|
712
|
+
async deployApplication(organisationId, applicationId, uploadId) {
|
|
713
|
+
return this.client.post("/api/applications/deploy", {
|
|
714
|
+
targetOrganisationId: organisationId,
|
|
715
|
+
applicationId,
|
|
716
|
+
uploadId
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async deleteApplication(organisationId, applicationId) {
|
|
720
|
+
await this.client.post("/api/applications/delete", { targetOrganisationId: organisationId, applicationId });
|
|
721
|
+
}
|
|
722
|
+
async renameApplication(organisationId, applicationId, name) {
|
|
723
|
+
return this.client.post("/api/applications/rename", {
|
|
724
|
+
targetOrganisationId: organisationId,
|
|
725
|
+
applicationId,
|
|
726
|
+
name
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
async detectFramework(input) {
|
|
730
|
+
return this.client.post("/api/applications/detect-framework", {
|
|
731
|
+
targetOrganisationId: input.organisationId,
|
|
732
|
+
organisationId: input.organisationId,
|
|
733
|
+
owner: input.owner,
|
|
734
|
+
repo: input.repo,
|
|
735
|
+
branch: input.branch,
|
|
736
|
+
rootDirectory: input.rootDirectory,
|
|
737
|
+
gitProvider: input.gitProvider
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
// ---- environments --------------------------------------------------------
|
|
741
|
+
async listEnvironments(organisationId, applicationId) {
|
|
742
|
+
return this.client.post("/api/environments", {
|
|
743
|
+
targetOrganisationId: organisationId,
|
|
744
|
+
applicationId
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async getEnvironment(organisationId, environmentId) {
|
|
748
|
+
return this.client.post("/api/environments/get", {
|
|
749
|
+
targetOrganisationId: organisationId,
|
|
750
|
+
environmentId
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
async environmentStatus(organisationId, environmentId) {
|
|
754
|
+
return this.client.post("/api/environments/status", {
|
|
755
|
+
targetOrganisationId: organisationId,
|
|
756
|
+
environmentId
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
async createEnvironment(request) {
|
|
760
|
+
return this.client.post("/api/environments/create", request);
|
|
761
|
+
}
|
|
762
|
+
async updateEnvironment(request) {
|
|
763
|
+
return this.client.post("/api/environments/update", request);
|
|
764
|
+
}
|
|
765
|
+
async deployEnvironment(organisationId, environmentId, uploadId) {
|
|
766
|
+
return this.client.post("/api/environments/deploy", {
|
|
767
|
+
targetOrganisationId: organisationId,
|
|
768
|
+
environmentId,
|
|
769
|
+
uploadId
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
async deleteEnvironment(organisationId, environmentId) {
|
|
773
|
+
await this.client.post("/api/environments/delete", { targetOrganisationId: organisationId, environmentId });
|
|
774
|
+
}
|
|
775
|
+
async scaleEnvironment(organisationId, environmentId, scale) {
|
|
776
|
+
return this.client.post("/api/environments/scale", {
|
|
777
|
+
targetOrganisationId: organisationId,
|
|
778
|
+
environmentId,
|
|
779
|
+
...scale
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
async fetchLogs(organisationId, environmentId, filters) {
|
|
783
|
+
return this.client.post("/api/environments/logs", {
|
|
784
|
+
targetOrganisationId: organisationId,
|
|
785
|
+
environmentId,
|
|
786
|
+
filters
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
/** Server-sent events; the caller reads `response.body`. */
|
|
790
|
+
async streamLogs(organisationId, environmentId, severity, signal) {
|
|
791
|
+
return this.client.stream(`/api/environments/${organisationId}/${environmentId}/logs/stream`, {
|
|
792
|
+
query: { severity: severity?.length ? severity.join(",") : void 0 },
|
|
793
|
+
signal
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
// ---- domains -------------------------------------------------------------
|
|
797
|
+
async addDomain(organisationId, environmentId, domain) {
|
|
798
|
+
return this.client.post("/api/environments/add-domain", {
|
|
799
|
+
targetOrganisationId: organisationId,
|
|
800
|
+
environmentId,
|
|
801
|
+
domain
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
async checkDomain(organisationId, environmentId) {
|
|
805
|
+
return this.client.post("/api/environments/check-domain", {
|
|
806
|
+
targetOrganisationId: organisationId,
|
|
807
|
+
environmentId
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
async retryDomain(organisationId, environmentId) {
|
|
811
|
+
return this.client.post("/api/environments/retry-domain", {
|
|
812
|
+
targetOrganisationId: organisationId,
|
|
813
|
+
environmentId
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
async removeDomain(organisationId, environmentId) {
|
|
817
|
+
return this.client.post("/api/applications/remove-domain", {
|
|
818
|
+
targetOrganisationId: organisationId,
|
|
819
|
+
environmentId
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
// ---- deployments ---------------------------------------------------------
|
|
823
|
+
async listDeployments(organisationId, environmentId, limit = 20, offset = 0) {
|
|
824
|
+
return this.client.post("/api/deployments", {
|
|
825
|
+
targetOrganisationId: organisationId,
|
|
826
|
+
environmentId,
|
|
827
|
+
limit,
|
|
828
|
+
offset
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
async getDeployment(organisationId, deploymentId) {
|
|
832
|
+
return this.client.post("/api/deployments/get", {
|
|
833
|
+
targetOrganisationId: organisationId,
|
|
834
|
+
deploymentId
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
async rollback(organisationId, environmentId, deploymentId) {
|
|
838
|
+
return this.client.post("/api/deployments/rollback", {
|
|
839
|
+
targetOrganisationId: organisationId,
|
|
840
|
+
environmentId,
|
|
841
|
+
deploymentId
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
// ---- databases -----------------------------------------------------------
|
|
845
|
+
async listDatabases(organisationId) {
|
|
846
|
+
const data = await this.client.post("/api/databases", {
|
|
847
|
+
targetOrganisationId: organisationId
|
|
848
|
+
});
|
|
849
|
+
return data.databases;
|
|
850
|
+
}
|
|
851
|
+
async getDatabase(organisationId, databaseId) {
|
|
852
|
+
return this.client.post("/api/databases/get", {
|
|
853
|
+
targetOrganisationId: organisationId,
|
|
854
|
+
databaseId
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async databaseStatus(organisationId, databaseId) {
|
|
858
|
+
return this.client.post("/api/databases/status", {
|
|
859
|
+
targetOrganisationId: organisationId,
|
|
860
|
+
databaseId
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
async createDatabase(request) {
|
|
864
|
+
return this.client.post("/api/databases/create", request);
|
|
865
|
+
}
|
|
866
|
+
async deleteDatabase(organisationId, databaseId) {
|
|
867
|
+
await this.client.post("/api/databases/delete", { targetOrganisationId: organisationId, databaseId });
|
|
868
|
+
}
|
|
869
|
+
async connectionDetails(organisationId, databaseId) {
|
|
870
|
+
return this.client.post("/api/databases/connection-string", {
|
|
871
|
+
targetOrganisationId: organisationId,
|
|
872
|
+
databaseId
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
async rotatePassword(organisationId, databaseId, newPassword) {
|
|
876
|
+
return this.client.post("/api/databases/rotate-password", {
|
|
877
|
+
targetOrganisationId: organisationId,
|
|
878
|
+
databaseId,
|
|
879
|
+
newPassword
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
/** A gzip stream of the dump; the caller writes `response.body` to disk. */
|
|
883
|
+
async dumpDatabase(organisationId, databaseId) {
|
|
884
|
+
return this.client.stream("/api/databases/dump", {
|
|
885
|
+
method: "POST",
|
|
886
|
+
body: { targetOrganisationId: organisationId, databaseId }
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
// ---- git providers -------------------------------------------------------
|
|
890
|
+
// ---- Billing ----
|
|
891
|
+
async ownerBillingSummary() {
|
|
892
|
+
return this.client.post("/api/billing/owner-summary", {});
|
|
893
|
+
}
|
|
894
|
+
async plans(organisationId) {
|
|
895
|
+
return this.client.post("/api/billing/plans", { targetOrganisationId: organisationId });
|
|
896
|
+
}
|
|
897
|
+
async choosePlan(organisationId, planId) {
|
|
898
|
+
return this.client.post("/api/billing/choose-plan", { targetOrganisationId: organisationId, planId });
|
|
899
|
+
}
|
|
900
|
+
async createCheckoutSession(organisationId) {
|
|
901
|
+
return this.client.post("/api/billing/checkout-session", { targetOrganisationId: organisationId, client: "cli" });
|
|
902
|
+
}
|
|
903
|
+
async checkoutSessionStatus(organisationId, sessionId) {
|
|
904
|
+
return this.client.post("/api/billing/checkout-session/status", { targetOrganisationId: organisationId, sessionId });
|
|
905
|
+
}
|
|
906
|
+
async removePaymentMethod(organisationId) {
|
|
907
|
+
await this.client.post("/api/billing/payment-method/remove", { targetOrganisationId: organisationId });
|
|
908
|
+
}
|
|
909
|
+
async listRepositories(organisationId) {
|
|
910
|
+
return this.client.get(`/api/github-app/organisation/${organisationId}/repositories`);
|
|
911
|
+
}
|
|
912
|
+
async listBranches(organisationId, owner, repo) {
|
|
913
|
+
return this.client.get(
|
|
914
|
+
`/api/github-app/organisation/${organisationId}/repositories/${owner}/${repo}/branches`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
async githubInstallationStatus(organisationId, owner, repo) {
|
|
918
|
+
return this.client.get("/api/github-app/installation-status", {
|
|
919
|
+
query: { organisationId, owner, repo }
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async githubInstallUrl() {
|
|
923
|
+
return this.client.get("/api/github-app/install");
|
|
924
|
+
}
|
|
925
|
+
// ---- uploads -------------------------------------------------------------
|
|
926
|
+
async requestUpload(organisationId, fileSize) {
|
|
927
|
+
return this.client.post("/api/upload/request-url", {
|
|
928
|
+
targetOrganisationId: organisationId,
|
|
929
|
+
fileName: "source.zip",
|
|
930
|
+
contentType: "application/zip",
|
|
931
|
+
fileSize
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
async completeUpload(organisationId, uploadId, detection) {
|
|
935
|
+
return this.client.post("/api/upload/complete", {
|
|
936
|
+
targetOrganisationId: organisationId,
|
|
937
|
+
uploadId,
|
|
938
|
+
...detection
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// src/lib/config/global-config.ts
|
|
944
|
+
import * as fs2 from "fs";
|
|
945
|
+
import * as path2 from "path";
|
|
946
|
+
var CONFIG_FILE = path2.join(CONFIG_DIR, "config.json");
|
|
947
|
+
var DEFAULT_API_URL = "https://api.light-cloud.com";
|
|
948
|
+
var DEFAULT_CONSOLE_URL = "https://console.light-cloud.com";
|
|
949
|
+
function globalConfigPath() {
|
|
950
|
+
return CONFIG_FILE;
|
|
951
|
+
}
|
|
952
|
+
function readGlobalConfig() {
|
|
953
|
+
try {
|
|
954
|
+
if (!fs2.existsSync(CONFIG_FILE)) return {};
|
|
955
|
+
const parsed = JSON.parse(fs2.readFileSync(CONFIG_FILE, "utf-8"));
|
|
956
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
957
|
+
} catch {
|
|
958
|
+
return {};
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function writeGlobalConfig(config) {
|
|
962
|
+
if (!fs2.existsSync(CONFIG_DIR)) {
|
|
963
|
+
fs2.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
964
|
+
}
|
|
965
|
+
const clean = {};
|
|
966
|
+
for (const [key, value] of Object.entries(config)) {
|
|
967
|
+
if (value !== void 0 && value !== null && value !== "") clean[key] = value;
|
|
968
|
+
}
|
|
969
|
+
fs2.writeFileSync(CONFIG_FILE, JSON.stringify(clean, null, 2) + "\n", { mode: 384 });
|
|
970
|
+
}
|
|
971
|
+
function updateGlobalConfig(patch) {
|
|
972
|
+
const next = { ...readGlobalConfig(), ...patch };
|
|
973
|
+
writeGlobalConfig(next);
|
|
974
|
+
return next;
|
|
975
|
+
}
|
|
976
|
+
var trimSlash = (url) => url.replace(/\/+$/, "");
|
|
977
|
+
function resolveEndpoints(overrides = {}) {
|
|
978
|
+
const config = readGlobalConfig();
|
|
979
|
+
const apiUrl = trimSlash(
|
|
980
|
+
overrides.apiUrl || process.env.LIGHT_CLOUD_API_URL || config.apiUrl || DEFAULT_API_URL
|
|
981
|
+
);
|
|
982
|
+
const consoleUrl = trimSlash(
|
|
983
|
+
process.env.LIGHT_CLOUD_CONSOLE_URL || config.consoleUrl || inferConsoleUrl(apiUrl)
|
|
984
|
+
);
|
|
985
|
+
return { apiUrl, consoleUrl, socketUrl: apiUrl.replace(/\/api$/, "") };
|
|
986
|
+
}
|
|
987
|
+
function inferConsoleUrl(apiUrl) {
|
|
988
|
+
try {
|
|
989
|
+
const parsed = new URL(apiUrl);
|
|
990
|
+
if (parsed.hostname.startsWith("api.")) {
|
|
991
|
+
parsed.hostname = "console." + parsed.hostname.slice("api.".length);
|
|
992
|
+
parsed.pathname = "/";
|
|
993
|
+
return trimSlash(parsed.toString());
|
|
994
|
+
}
|
|
995
|
+
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
|
|
996
|
+
return "http://localhost:5173";
|
|
997
|
+
}
|
|
998
|
+
} catch {
|
|
999
|
+
}
|
|
1000
|
+
return DEFAULT_CONSOLE_URL;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// src/lib/config/project-config.ts
|
|
1004
|
+
import * as fs3 from "fs";
|
|
1005
|
+
import * as path3 from "path";
|
|
1006
|
+
var PROJECT_CONFIG_FILENAME = ".lightcloud";
|
|
1007
|
+
function findProjectConfig(startDir = process.cwd()) {
|
|
1008
|
+
let dir = path3.resolve(startDir);
|
|
1009
|
+
for (; ; ) {
|
|
1010
|
+
const candidate = path3.join(dir, PROJECT_CONFIG_FILENAME);
|
|
1011
|
+
const config = readProjectConfigFile(candidate);
|
|
1012
|
+
if (config) return { config, path: candidate, directory: dir };
|
|
1013
|
+
if (fs3.existsSync(path3.join(dir, ".git"))) return null;
|
|
1014
|
+
const parent = path3.dirname(dir);
|
|
1015
|
+
if (parent === dir) return null;
|
|
1016
|
+
dir = parent;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function readProjectConfig(directory = process.cwd()) {
|
|
1020
|
+
return readProjectConfigFile(path3.join(directory, PROJECT_CONFIG_FILENAME));
|
|
1021
|
+
}
|
|
1022
|
+
function readProjectConfigFile(file) {
|
|
1023
|
+
try {
|
|
1024
|
+
if (!fs3.existsSync(file)) return null;
|
|
1025
|
+
const parsed = JSON.parse(fs3.readFileSync(file, "utf-8"));
|
|
1026
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function writeProjectConfig(config, directory = process.cwd()) {
|
|
1032
|
+
const file = path3.join(directory, PROJECT_CONFIG_FILENAME);
|
|
1033
|
+
const merged = { ...readProjectConfig(directory) ?? {}, ...config };
|
|
1034
|
+
for (const key of Object.keys(merged)) {
|
|
1035
|
+
if (merged[key] === void 0) delete merged[key];
|
|
1036
|
+
}
|
|
1037
|
+
fs3.writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
1038
|
+
return file;
|
|
1039
|
+
}
|
|
1040
|
+
function deleteProjectConfig(directory = process.cwd()) {
|
|
1041
|
+
const file = path3.join(directory, PROJECT_CONFIG_FILENAME);
|
|
1042
|
+
if (!fs3.existsSync(file)) return false;
|
|
1043
|
+
fs3.unlinkSync(file);
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// src/lib/context.ts
|
|
1048
|
+
var API_KEY_ORG_PLACEHOLDER = "api-key";
|
|
1049
|
+
var looksLikeId = (value) => /^[a-z0-9]{20,}$/i.test(value) || /^[0-9a-f-]{36}$/i.test(value);
|
|
1050
|
+
var Context = class {
|
|
1051
|
+
api;
|
|
1052
|
+
endpoints;
|
|
1053
|
+
yes;
|
|
1054
|
+
json;
|
|
1055
|
+
cwd;
|
|
1056
|
+
project;
|
|
1057
|
+
orgFlag;
|
|
1058
|
+
profileCache = null;
|
|
1059
|
+
orgCache = null;
|
|
1060
|
+
constructor(options) {
|
|
1061
|
+
this.endpoints = resolveEndpoints({ apiUrl: options.apiUrl });
|
|
1062
|
+
this.api = new LightCloudApi(new ApiClient(this.endpoints));
|
|
1063
|
+
this.yes = Boolean(options.yes);
|
|
1064
|
+
this.json = Boolean(options.json);
|
|
1065
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
1066
|
+
this.project = findProjectConfig(this.cwd);
|
|
1067
|
+
this.orgFlag = options.org?.trim() || void 0;
|
|
1068
|
+
}
|
|
1069
|
+
get usingApiKey() {
|
|
1070
|
+
return isApiKey(resolveAuth().token);
|
|
1071
|
+
}
|
|
1072
|
+
requireAuth() {
|
|
1073
|
+
if (!this.api.client.hasCredentials()) throw notLoggedIn();
|
|
1074
|
+
}
|
|
1075
|
+
async profile() {
|
|
1076
|
+
if (this.profileCache) return this.profileCache;
|
|
1077
|
+
this.requireAuth();
|
|
1078
|
+
this.profileCache = await this.api.profile();
|
|
1079
|
+
return this.profileCache;
|
|
1080
|
+
}
|
|
1081
|
+
// ---- workspace -----------------------------------------------------------
|
|
1082
|
+
async resolveOrg(options = {}) {
|
|
1083
|
+
if (this.orgCache) return this.orgCache;
|
|
1084
|
+
this.requireAuth();
|
|
1085
|
+
if (this.usingApiKey) {
|
|
1086
|
+
this.orgCache = await this.resolveOrgForApiKey();
|
|
1087
|
+
return this.orgCache;
|
|
1088
|
+
}
|
|
1089
|
+
const profile = await this.profile();
|
|
1090
|
+
const orgs = profile.organisations ?? [];
|
|
1091
|
+
if (this.orgFlag) {
|
|
1092
|
+
const match = findOrg(orgs, this.orgFlag);
|
|
1093
|
+
if (!match) {
|
|
1094
|
+
throw new CliError(`No workspace called "${this.orgFlag}" on this account.`, {
|
|
1095
|
+
hint: `Your workspaces: ${orgs.map((org2) => org2.name).join(", ") || "none"}. Run \`lc orgs\` to list them.`,
|
|
1096
|
+
exitCode: EXIT.NOT_FOUND
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
this.orgCache = toRef(match);
|
|
1100
|
+
return this.orgCache;
|
|
1101
|
+
}
|
|
1102
|
+
const linked = this.project?.config.organisationId;
|
|
1103
|
+
if (linked) {
|
|
1104
|
+
const match = orgs.find((org2) => org2.id === linked);
|
|
1105
|
+
if (match) {
|
|
1106
|
+
this.orgCache = toRef(match);
|
|
1107
|
+
return this.orgCache;
|
|
1108
|
+
}
|
|
1109
|
+
log.warn(`The linked workspace in ${c.bold(".lightcloud")} is not one you belong to; ignoring it.`);
|
|
1110
|
+
}
|
|
1111
|
+
const saved = readGlobalConfig().defaultOrganisationId;
|
|
1112
|
+
if (saved) {
|
|
1113
|
+
const match = orgs.find((org2) => org2.id === saved);
|
|
1114
|
+
if (match) {
|
|
1115
|
+
this.orgCache = toRef(match);
|
|
1116
|
+
return this.orgCache;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (orgs.length === 1) {
|
|
1120
|
+
this.orgCache = toRef(orgs[0]);
|
|
1121
|
+
return this.orgCache;
|
|
1122
|
+
}
|
|
1123
|
+
if (orgs.length === 0) {
|
|
1124
|
+
throw new CliError("This account has no workspaces yet.", {
|
|
1125
|
+
hint: `Create one in the console: ${this.endpoints.consoleUrl}`,
|
|
1126
|
+
exitCode: EXIT.NOT_FOUND
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
if (options.interactive === false || !isInteractive()) {
|
|
1130
|
+
throw new CliError("Several workspaces on this account; which one?", {
|
|
1131
|
+
hint: `Pass --org <name>, or set a default with \`lc org use <name>\`. Workspaces: ${orgs.map((org2) => org2.name).join(", ")}.`,
|
|
1132
|
+
exitCode: EXIT.USAGE
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const chosen = await select2(
|
|
1136
|
+
"Which workspace?",
|
|
1137
|
+
orgs.map((org2) => ({ value: org2.id, label: org2.name, hint: org2.role })),
|
|
1138
|
+
{ flag: "--org <name>" }
|
|
1139
|
+
);
|
|
1140
|
+
const org = orgs.find((entry) => entry.id === chosen);
|
|
1141
|
+
const remember = await confirm2(`Use ${c.bold(org.name)} by default from now on?`, { yes: false, initialValue: true });
|
|
1142
|
+
if (remember) updateGlobalConfig({ defaultOrganisationId: org.id, defaultOrganisationName: org.name });
|
|
1143
|
+
this.orgCache = toRef(org);
|
|
1144
|
+
return this.orgCache;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* An API key is bound to one workspace and the profile endpoint does not
|
|
1148
|
+
* serve keys, so the id comes from the flag or the link file — or from the
|
|
1149
|
+
* first resource the key can see, since every resource names its workspace.
|
|
1150
|
+
*/
|
|
1151
|
+
async resolveOrgForApiKey() {
|
|
1152
|
+
const known = this.orgFlag || this.project?.config.organisationId || readGlobalConfig().defaultOrganisationId;
|
|
1153
|
+
if (known) return { id: known };
|
|
1154
|
+
try {
|
|
1155
|
+
const apps = await this.api.listApplications(API_KEY_ORG_PLACEHOLDER, void 0, 1);
|
|
1156
|
+
if (apps[0]) return { id: apps[0].organisation_id };
|
|
1157
|
+
const dbs = await this.api.listDatabases(API_KEY_ORG_PLACEHOLDER);
|
|
1158
|
+
if (dbs[0]) return { id: dbs[0].organisation_id };
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
if (error instanceof ApiError && error.status === 403) {
|
|
1161
|
+
throw new CliError("The API key does not belong to the workspace this folder is linked to.", {
|
|
1162
|
+
hint: "Use a key created in that workspace, or pass --org <id>.",
|
|
1163
|
+
exitCode: EXIT.AUTH
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
return { id: API_KEY_ORG_PLACEHOLDER };
|
|
1169
|
+
}
|
|
1170
|
+
// ---- applications --------------------------------------------------------
|
|
1171
|
+
async resolveApp(ref, options = {}) {
|
|
1172
|
+
const org = await this.resolveOrg();
|
|
1173
|
+
if (ref) {
|
|
1174
|
+
if (looksLikeId(ref)) {
|
|
1175
|
+
try {
|
|
1176
|
+
return await this.api.getApplication(org.id, ref);
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
if (!(error instanceof ApiError && (error.status === 404 || error.status === 400))) throw error;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
const apps2 = await this.api.listApplications(org.id, ref);
|
|
1182
|
+
const match = pickByName(apps2, ref, (app) => [app.name, app.slug]);
|
|
1183
|
+
if (match.kind === "one") return this.api.getApplication(org.id, match.item.id);
|
|
1184
|
+
if (match.kind === "many") {
|
|
1185
|
+
throw new CliError(`"${ref}" matches several apps: ${match.items.map((app) => app.name).join(", ")}.`, {
|
|
1186
|
+
hint: "Use the full name or the id.",
|
|
1187
|
+
exitCode: EXIT.USAGE
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
throw new CliError(`No app called "${ref}" in ${org.name ?? "this workspace"}.`, {
|
|
1191
|
+
hint: "Run `lc apps` to list them.",
|
|
1192
|
+
exitCode: EXIT.NOT_FOUND
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
const linked = this.project?.config.applicationId;
|
|
1196
|
+
if (linked) {
|
|
1197
|
+
try {
|
|
1198
|
+
return await this.api.getApplication(org.id, linked);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
if (error instanceof ApiError && (error.status === 404 || error.status === 400)) {
|
|
1201
|
+
log.warn(`The app linked in ${c.bold(".lightcloud")} no longer exists.`);
|
|
1202
|
+
} else {
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const apps = await this.api.listApplications(org.id);
|
|
1208
|
+
if (apps.length === 0) {
|
|
1209
|
+
throw new CliError(`No apps in ${org.name ?? "this workspace"} yet.`, {
|
|
1210
|
+
hint: "Create one with `lc init` in a project folder.",
|
|
1211
|
+
exitCode: EXIT.NOT_FOUND
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
if (options.interactive === false || !isInteractive()) {
|
|
1215
|
+
throw new CliError("Which app?", {
|
|
1216
|
+
hint: "Pass --app <name>, or link this folder with `lc init`.",
|
|
1217
|
+
exitCode: EXIT.USAGE
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
const chosen = await select2(
|
|
1221
|
+
"Which app?",
|
|
1222
|
+
apps.map((app) => ({ value: app.id, label: app.name, hint: `${app.framework} \xB7 ${app.status}` })),
|
|
1223
|
+
{ flag: "--app <name>" }
|
|
1224
|
+
);
|
|
1225
|
+
return this.api.getApplication(org.id, chosen);
|
|
1226
|
+
}
|
|
1227
|
+
// ---- environments --------------------------------------------------------
|
|
1228
|
+
async resolveEnv(app, ref, options = {}) {
|
|
1229
|
+
const org = await this.resolveOrg();
|
|
1230
|
+
const envs = app.environments ?? await this.api.listEnvironments(org.id, app.id);
|
|
1231
|
+
if (ref) {
|
|
1232
|
+
const byId = envs.find((env) => env.id === ref);
|
|
1233
|
+
if (byId) return byId;
|
|
1234
|
+
const match = pickByName(envs, ref, (env) => [env.name, env.github_branch]);
|
|
1235
|
+
if (match.kind === "one") return match.item;
|
|
1236
|
+
if (match.kind === "many") {
|
|
1237
|
+
throw new CliError(`"${ref}" matches several environments: ${match.items.map((env) => env.name).join(", ")}.`, {
|
|
1238
|
+
exitCode: EXIT.USAGE
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
throw new CliError(`No environment "${ref}" on ${app.name}.`, {
|
|
1242
|
+
hint: `Environments: ${envs.map((env) => env.name).join(", ") || "none"}.`,
|
|
1243
|
+
exitCode: EXIT.NOT_FOUND
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
const linked = this.project?.config.environmentId;
|
|
1247
|
+
if (linked && this.project?.config.applicationId === app.id) {
|
|
1248
|
+
const match = envs.find((env) => env.id === linked);
|
|
1249
|
+
if (match) return match;
|
|
1250
|
+
}
|
|
1251
|
+
if (envs.length === 0) {
|
|
1252
|
+
throw new CliError(`${app.name} has no environments.`, {
|
|
1253
|
+
hint: "Create one with `lc env create <name> --branch <branch>`.",
|
|
1254
|
+
exitCode: EXIT.NOT_FOUND
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
const production = envs.find((env) => env.is_production);
|
|
1258
|
+
if (production) return production;
|
|
1259
|
+
if (envs.length === 1) return envs[0];
|
|
1260
|
+
if (options.interactive === false || !isInteractive()) {
|
|
1261
|
+
throw new CliError("Which environment?", {
|
|
1262
|
+
hint: `Pass --env <name>. Environments: ${envs.map((env) => env.name).join(", ")}.`,
|
|
1263
|
+
exitCode: EXIT.USAGE
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
const chosen = await select2(
|
|
1267
|
+
"Which environment?",
|
|
1268
|
+
envs.map((env) => ({ value: env.id, label: env.name, hint: `${env.github_branch} \xB7 ${env.status}` })),
|
|
1269
|
+
{ flag: "--env <name>" }
|
|
1270
|
+
);
|
|
1271
|
+
return envs.find((env) => env.id === chosen);
|
|
1272
|
+
}
|
|
1273
|
+
// ---- databases -----------------------------------------------------------
|
|
1274
|
+
async resolveDb(ref, options = {}) {
|
|
1275
|
+
const org = await this.resolveOrg();
|
|
1276
|
+
const dbs = await this.api.listDatabases(org.id);
|
|
1277
|
+
if (ref) {
|
|
1278
|
+
const byId = dbs.find((db) => db.id === ref);
|
|
1279
|
+
if (byId) return byId;
|
|
1280
|
+
const match = pickByName(dbs, ref, (db) => [db.name, db.slug, db.database_name ?? ""]);
|
|
1281
|
+
if (match.kind === "one") return match.item;
|
|
1282
|
+
if (match.kind === "many") {
|
|
1283
|
+
throw new CliError(`"${ref}" matches several databases: ${match.items.map((db) => db.name).join(", ")}.`, { exitCode: EXIT.USAGE });
|
|
1284
|
+
}
|
|
1285
|
+
throw new CliError(`No database called "${ref}".`, { hint: "Run `lc dbs` to list them.", exitCode: EXIT.NOT_FOUND });
|
|
1286
|
+
}
|
|
1287
|
+
if (dbs.length === 0) {
|
|
1288
|
+
throw new CliError("No databases in this workspace yet.", { hint: "Create one with `lc db create`.", exitCode: EXIT.NOT_FOUND });
|
|
1289
|
+
}
|
|
1290
|
+
if (dbs.length === 1) return dbs[0];
|
|
1291
|
+
if (options.interactive === false || !isInteractive()) {
|
|
1292
|
+
throw new CliError("Which database?", { hint: "Pass the database name as an argument.", exitCode: EXIT.USAGE });
|
|
1293
|
+
}
|
|
1294
|
+
const chosen = await select2(
|
|
1295
|
+
"Which database?",
|
|
1296
|
+
dbs.map((db) => ({ value: db.id, label: db.name, hint: `${db.database_type} \xB7 ${db.tier} \xB7 ${db.status}` })),
|
|
1297
|
+
{ flag: "<name>" }
|
|
1298
|
+
);
|
|
1299
|
+
return dbs.find((db) => db.id === chosen);
|
|
1300
|
+
}
|
|
1301
|
+
// ---- links ---------------------------------------------------------------
|
|
1302
|
+
consoleUrl(path10 = "") {
|
|
1303
|
+
return `${this.endpoints.consoleUrl}${path10}`;
|
|
1304
|
+
}
|
|
1305
|
+
appConsoleUrl(app) {
|
|
1306
|
+
return this.consoleUrl(`/applications/${app.id}`);
|
|
1307
|
+
}
|
|
1308
|
+
dbConsoleUrl(db) {
|
|
1309
|
+
return this.consoleUrl(`/databases/${db.id}`);
|
|
1310
|
+
}
|
|
1311
|
+
};
|
|
1312
|
+
function toRef(org) {
|
|
1313
|
+
return { id: org.id, name: org.name, role: org.role };
|
|
1314
|
+
}
|
|
1315
|
+
function findOrg(orgs, ref) {
|
|
1316
|
+
const needle = ref.trim().toLowerCase();
|
|
1317
|
+
return orgs.find((org) => org.id === ref) ?? orgs.find((org) => org.name.toLowerCase() === needle) ?? (() => {
|
|
1318
|
+
const partial = orgs.filter((org) => org.name.toLowerCase().includes(needle));
|
|
1319
|
+
return partial.length === 1 ? partial[0] : void 0;
|
|
1320
|
+
})();
|
|
1321
|
+
}
|
|
1322
|
+
function pickByName(items, ref, names) {
|
|
1323
|
+
const needle = ref.trim().toLowerCase();
|
|
1324
|
+
const exact = items.filter((item) => names(item).some((name) => name.toLowerCase() === needle));
|
|
1325
|
+
if (exact.length === 1) return { kind: "one", item: exact[0] };
|
|
1326
|
+
if (exact.length > 1) return { kind: "many", items: exact };
|
|
1327
|
+
const prefix = items.filter((item) => names(item).some((name) => name.toLowerCase().startsWith(needle)));
|
|
1328
|
+
if (prefix.length === 1) return { kind: "one", item: prefix[0] };
|
|
1329
|
+
if (prefix.length > 1) return { kind: "many", items: prefix };
|
|
1330
|
+
const partial = items.filter((item) => names(item).some((name) => name.toLowerCase().includes(needle)));
|
|
1331
|
+
if (partial.length === 1) return { kind: "one", item: partial[0] };
|
|
1332
|
+
if (partial.length > 1) return { kind: "many", items: partial };
|
|
1333
|
+
return { kind: "none" };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// src/lib/realtime/socket.ts
|
|
1337
|
+
import { io } from "socket.io-client";
|
|
1338
|
+
function connectLive(socketUrl, token, options = {}) {
|
|
1339
|
+
return new Promise((resolve6) => {
|
|
1340
|
+
const socket = io(socketUrl, {
|
|
1341
|
+
transports: ["websocket", "polling"],
|
|
1342
|
+
auth: token ? { token } : void 0,
|
|
1343
|
+
reconnection: true,
|
|
1344
|
+
reconnectionAttempts: 5,
|
|
1345
|
+
reconnectionDelay: 1e3,
|
|
1346
|
+
timeout: options.timeoutMs ?? 8e3
|
|
1347
|
+
});
|
|
1348
|
+
const subscriptions = [];
|
|
1349
|
+
let settled = false;
|
|
1350
|
+
const connection = {
|
|
1351
|
+
socket,
|
|
1352
|
+
subscribe(kind, id) {
|
|
1353
|
+
subscriptions.push({ kind, id });
|
|
1354
|
+
if (socket.connected) socket.emit("subscribe", { type: kind, id });
|
|
1355
|
+
},
|
|
1356
|
+
onEvent(event, handler) {
|
|
1357
|
+
socket.on(event, handler);
|
|
1358
|
+
return () => socket.off(event, handler);
|
|
1359
|
+
},
|
|
1360
|
+
close() {
|
|
1361
|
+
socket.removeAllListeners();
|
|
1362
|
+
socket.disconnect();
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
socket.on("connect", () => {
|
|
1366
|
+
for (const sub of subscriptions) socket.emit("subscribe", { type: sub.kind, id: sub.id });
|
|
1367
|
+
if (!settled) {
|
|
1368
|
+
settled = true;
|
|
1369
|
+
resolve6(connection);
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
socket.on("connect_error", () => {
|
|
1373
|
+
if (!settled) {
|
|
1374
|
+
settled = true;
|
|
1375
|
+
socket.disconnect();
|
|
1376
|
+
resolve6(null);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
const timer = setTimeout(() => {
|
|
1380
|
+
if (!settled) {
|
|
1381
|
+
settled = true;
|
|
1382
|
+
socket.disconnect();
|
|
1383
|
+
resolve6(null);
|
|
1384
|
+
}
|
|
1385
|
+
}, (options.timeoutMs ?? 8e3) + 500);
|
|
1386
|
+
timer.unref();
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/lib/realtime/watch.ts
|
|
1391
|
+
var TERMINAL = {
|
|
1392
|
+
environment: /* @__PURE__ */ new Set(["deployed", "failed", "delete_failed", "deleted"]),
|
|
1393
|
+
application: /* @__PURE__ */ new Set(["deployed", "failed", "delete_failed", "deleted", "healthy", "degraded"]),
|
|
1394
|
+
database: /* @__PURE__ */ new Set(["ready", "failed", "delete_failed", "deleted"])
|
|
1395
|
+
};
|
|
1396
|
+
var SUCCESS = /* @__PURE__ */ new Set(["deployed", "ready", "healthy", "deleted"]);
|
|
1397
|
+
var StepRenderer = class {
|
|
1398
|
+
constructor(silent) {
|
|
1399
|
+
this.silent = silent;
|
|
1400
|
+
}
|
|
1401
|
+
silent;
|
|
1402
|
+
printed = /* @__PURE__ */ new Map();
|
|
1403
|
+
startedAt = /* @__PURE__ */ new Map();
|
|
1404
|
+
spin = null;
|
|
1405
|
+
activeIndex = null;
|
|
1406
|
+
render(steps) {
|
|
1407
|
+
if (this.silent || isJson() || !steps) return;
|
|
1408
|
+
steps.forEach((step, index) => {
|
|
1409
|
+
const previous = this.printed.get(index);
|
|
1410
|
+
if (previous === step.status) return;
|
|
1411
|
+
if (step.status === "started") {
|
|
1412
|
+
this.startedAt.set(index, Date.parse(step.timestamp) || Date.now());
|
|
1413
|
+
this.stopSpinner();
|
|
1414
|
+
this.spin = spinner2();
|
|
1415
|
+
this.spin.start(step.step + (step.message && step.message !== step.step ? c.dim(` ${step.message}`) : ""));
|
|
1416
|
+
this.activeIndex = index;
|
|
1417
|
+
} else {
|
|
1418
|
+
const began = this.startedAt.get(index);
|
|
1419
|
+
const finished = Date.parse(step.timestamp) || Date.now();
|
|
1420
|
+
const took = began ? c.dim(` ${formatDuration(finished - began)}`) : "";
|
|
1421
|
+
const line = step.status === "completed" ? `${step.step}${took}` : `${step.step}${step.message ? c.red(` ${step.message}`) : ""}`;
|
|
1422
|
+
if (this.activeIndex === index && this.spin) {
|
|
1423
|
+
this.spin.stop(line, step.status === "completed" ? 0 : 1);
|
|
1424
|
+
this.spin = null;
|
|
1425
|
+
this.activeIndex = null;
|
|
1426
|
+
} else {
|
|
1427
|
+
out(`${step.status === "completed" ? c.green(sym.ok) : c.red(sym.fail)} ${line}`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
this.printed.set(index, step.status);
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
finish(message, ok) {
|
|
1434
|
+
if (this.silent || isJson()) return;
|
|
1435
|
+
if (this.spin) {
|
|
1436
|
+
this.spin.stop(message, ok ? 0 : 1);
|
|
1437
|
+
this.spin = null;
|
|
1438
|
+
} else {
|
|
1439
|
+
out(`${ok ? c.green(sym.ok) : c.red(sym.fail)} ${message}`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
stopSpinner() {
|
|
1443
|
+
if (this.spin && this.activeIndex !== null) {
|
|
1444
|
+
this.spin.stop(c.dim("\u2026"), 0);
|
|
1445
|
+
this.spin = null;
|
|
1446
|
+
this.activeIndex = null;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
async function fetchStatus(api, target) {
|
|
1451
|
+
switch (target.kind) {
|
|
1452
|
+
case "environment":
|
|
1453
|
+
return api.environmentStatus(target.organisationId, target.id);
|
|
1454
|
+
case "application":
|
|
1455
|
+
return api.applicationStatus(target.organisationId, target.id);
|
|
1456
|
+
case "database":
|
|
1457
|
+
return api.databaseStatus(target.organisationId, target.id);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
async function watchResource(api, target, options = {}) {
|
|
1461
|
+
const timeoutMs = options.timeoutMs ?? 25 * 60 * 1e3;
|
|
1462
|
+
const pollIntervalMs = options.pollIntervalMs ?? 4e3;
|
|
1463
|
+
const startedAt = options.startedAt ?? Date.now();
|
|
1464
|
+
const renderer = new StepRenderer(Boolean(options.silent));
|
|
1465
|
+
const terminal = TERMINAL[target.kind];
|
|
1466
|
+
let lastSeenAt = 0;
|
|
1467
|
+
const live = await connectLive(api.client.endpoints.socketUrl, tokenForSocket());
|
|
1468
|
+
if (live) live.subscribe(target.kind, target.id);
|
|
1469
|
+
return new Promise((resolve6, reject) => {
|
|
1470
|
+
let finished = false;
|
|
1471
|
+
let pollTimer = null;
|
|
1472
|
+
let polling = false;
|
|
1473
|
+
const done = (update) => {
|
|
1474
|
+
if (finished) return;
|
|
1475
|
+
finished = true;
|
|
1476
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
1477
|
+
clearTimeout(deadline);
|
|
1478
|
+
live?.close();
|
|
1479
|
+
const ok = SUCCESS.has(update.status);
|
|
1480
|
+
resolve6({ status: update.status, update, ok, elapsedMs: Date.now() - startedAt });
|
|
1481
|
+
};
|
|
1482
|
+
const fail = (error) => {
|
|
1483
|
+
if (finished) return;
|
|
1484
|
+
finished = true;
|
|
1485
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
1486
|
+
clearTimeout(deadline);
|
|
1487
|
+
live?.close();
|
|
1488
|
+
reject(error);
|
|
1489
|
+
};
|
|
1490
|
+
const apply = (update) => {
|
|
1491
|
+
lastSeenAt = Date.now();
|
|
1492
|
+
renderer.render(update.deployment_logs);
|
|
1493
|
+
if (terminal.has(update.status)) done(update);
|
|
1494
|
+
};
|
|
1495
|
+
const deadline = setTimeout(() => {
|
|
1496
|
+
fail(
|
|
1497
|
+
new CliError(`Still running after ${formatDuration(timeoutMs)}; stopped waiting.`, {
|
|
1498
|
+
hint: "The run continues on Light Cloud. Check it with `lc status` or in the console.",
|
|
1499
|
+
exitCode: EXIT.FAILED
|
|
1500
|
+
})
|
|
1501
|
+
);
|
|
1502
|
+
}, timeoutMs);
|
|
1503
|
+
const eventName = `${target.kind}:update`;
|
|
1504
|
+
live?.onEvent(eventName, (data) => {
|
|
1505
|
+
const id = data.environmentId || data.databaseId || data.applicationId;
|
|
1506
|
+
if (id && id !== target.id) return;
|
|
1507
|
+
apply(data);
|
|
1508
|
+
});
|
|
1509
|
+
const poll = async (force = false) => {
|
|
1510
|
+
if (polling || finished) return;
|
|
1511
|
+
polling = true;
|
|
1512
|
+
try {
|
|
1513
|
+
if (force || !live || Date.now() - lastSeenAt > pollIntervalMs * 2) {
|
|
1514
|
+
apply(await fetchStatus(api, target));
|
|
1515
|
+
}
|
|
1516
|
+
} catch (error) {
|
|
1517
|
+
if (error instanceof CliError && error.exitCode === EXIT.AUTH) fail(error);
|
|
1518
|
+
} finally {
|
|
1519
|
+
polling = false;
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
void poll(true);
|
|
1523
|
+
pollTimer = setInterval(() => void poll(), pollIntervalMs);
|
|
1524
|
+
}).then((result) => {
|
|
1525
|
+
const took = formatDuration(result.elapsedMs);
|
|
1526
|
+
if (result.ok) {
|
|
1527
|
+
const url = result.update.deployed_url || result.update.connection_host;
|
|
1528
|
+
renderer.finish(`${verbFor(target.kind, true)} in ${took}${url ? ` ${c.cyan(url)}` : ""}`, true);
|
|
1529
|
+
} else {
|
|
1530
|
+
renderer.finish(`${verbFor(target.kind, false)} after ${took}${result.update.deployment_error ? ` ${c.red(result.update.deployment_error)}` : ""}`, false);
|
|
1531
|
+
}
|
|
1532
|
+
return result;
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
function verbFor(kind, ok) {
|
|
1536
|
+
if (kind === "database") return ok ? "Database ready" : "Provisioning failed";
|
|
1537
|
+
return ok ? "Deployed" : "Deployment failed";
|
|
1538
|
+
}
|
|
1539
|
+
function tokenForSocket() {
|
|
1540
|
+
return resolveAuth().token;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/lib/upload/packager.ts
|
|
1544
|
+
import * as fs4 from "fs";
|
|
1545
|
+
import * as path4 from "path";
|
|
1546
|
+
import archiver from "archiver";
|
|
1547
|
+
|
|
1548
|
+
// src/lib/upload/excludes.ts
|
|
1549
|
+
var DEFAULT_EXCLUDES = [
|
|
1550
|
+
".git",
|
|
1551
|
+
".git/**",
|
|
1552
|
+
".svn",
|
|
1553
|
+
".svn/**",
|
|
1554
|
+
".hg",
|
|
1555
|
+
".hg/**",
|
|
1556
|
+
"node_modules",
|
|
1557
|
+
"node_modules/**",
|
|
1558
|
+
"vendor",
|
|
1559
|
+
"vendor/**",
|
|
1560
|
+
"bower_components",
|
|
1561
|
+
"bower_components/**",
|
|
1562
|
+
"__pycache__",
|
|
1563
|
+
"__pycache__/**",
|
|
1564
|
+
"*.pyc",
|
|
1565
|
+
"*.pyo",
|
|
1566
|
+
"*.pyd",
|
|
1567
|
+
".Python",
|
|
1568
|
+
"venv",
|
|
1569
|
+
"venv/**",
|
|
1570
|
+
".venv",
|
|
1571
|
+
".venv/**",
|
|
1572
|
+
"env",
|
|
1573
|
+
"env/**",
|
|
1574
|
+
".env",
|
|
1575
|
+
"pip-wheel-metadata",
|
|
1576
|
+
"*.egg-info",
|
|
1577
|
+
"*.egg-info/**",
|
|
1578
|
+
"dist",
|
|
1579
|
+
"dist/**",
|
|
1580
|
+
"build",
|
|
1581
|
+
"build/**",
|
|
1582
|
+
"out",
|
|
1583
|
+
"out/**",
|
|
1584
|
+
".next",
|
|
1585
|
+
".next/**",
|
|
1586
|
+
".nuxt",
|
|
1587
|
+
".nuxt/**",
|
|
1588
|
+
".svelte-kit",
|
|
1589
|
+
".svelte-kit/**",
|
|
1590
|
+
".cache",
|
|
1591
|
+
".cache/**",
|
|
1592
|
+
".parcel-cache",
|
|
1593
|
+
".parcel-cache/**",
|
|
1594
|
+
".turbo",
|
|
1595
|
+
".turbo/**",
|
|
1596
|
+
".idea",
|
|
1597
|
+
".idea/**",
|
|
1598
|
+
".vscode",
|
|
1599
|
+
".vscode/**",
|
|
1600
|
+
"*.swp",
|
|
1601
|
+
"*.swo",
|
|
1602
|
+
"*~",
|
|
1603
|
+
".project",
|
|
1604
|
+
".classpath",
|
|
1605
|
+
".settings",
|
|
1606
|
+
".settings/**",
|
|
1607
|
+
".DS_Store",
|
|
1608
|
+
"Thumbs.db",
|
|
1609
|
+
"desktop.ini",
|
|
1610
|
+
"*.log",
|
|
1611
|
+
"logs",
|
|
1612
|
+
"logs/**",
|
|
1613
|
+
"npm-debug.log*",
|
|
1614
|
+
"yarn-debug.log*",
|
|
1615
|
+
"yarn-error.log*",
|
|
1616
|
+
"coverage",
|
|
1617
|
+
"coverage/**",
|
|
1618
|
+
".nyc_output",
|
|
1619
|
+
".nyc_output/**",
|
|
1620
|
+
"htmlcov",
|
|
1621
|
+
"htmlcov/**",
|
|
1622
|
+
"tmp",
|
|
1623
|
+
"tmp/**",
|
|
1624
|
+
"temp",
|
|
1625
|
+
"temp/**",
|
|
1626
|
+
".tmp",
|
|
1627
|
+
".tmp/**",
|
|
1628
|
+
"*.zip",
|
|
1629
|
+
"*.tar",
|
|
1630
|
+
"*.tar.gz",
|
|
1631
|
+
"*.tgz",
|
|
1632
|
+
"*.rar",
|
|
1633
|
+
"*.7z"
|
|
1634
|
+
];
|
|
1635
|
+
function parseGitignore(content) {
|
|
1636
|
+
const patterns = [];
|
|
1637
|
+
for (const raw of content.split("\n")) {
|
|
1638
|
+
const line = raw.trim();
|
|
1639
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
1640
|
+
patterns.push(line);
|
|
1641
|
+
}
|
|
1642
|
+
return patterns;
|
|
1643
|
+
}
|
|
1644
|
+
function gitignoreToGlob(pattern) {
|
|
1645
|
+
let normalized = pattern.replace(/^\//, "");
|
|
1646
|
+
const patterns = [];
|
|
1647
|
+
if (normalized.endsWith("/")) {
|
|
1648
|
+
normalized = normalized.slice(0, -1);
|
|
1649
|
+
patterns.push(normalized, `${normalized}/**`);
|
|
1650
|
+
} else {
|
|
1651
|
+
patterns.push(normalized, `${normalized}/**`);
|
|
1652
|
+
}
|
|
1653
|
+
if (!normalized.includes("/")) {
|
|
1654
|
+
patterns.push(`**/${normalized}`, `**/${normalized}/**`);
|
|
1655
|
+
}
|
|
1656
|
+
return patterns;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// src/lib/upload/packager.ts
|
|
1660
|
+
function buildExcludePatterns(directory, additionalExcludes = []) {
|
|
1661
|
+
const patterns = [...DEFAULT_EXCLUDES, ...additionalExcludes];
|
|
1662
|
+
const gitignore = path4.join(directory, ".gitignore");
|
|
1663
|
+
if (fs4.existsSync(gitignore)) {
|
|
1664
|
+
try {
|
|
1665
|
+
for (const pattern of parseGitignore(fs4.readFileSync(gitignore, "utf-8"))) {
|
|
1666
|
+
patterns.push(...gitignoreToGlob(pattern));
|
|
1667
|
+
}
|
|
1668
|
+
} catch {
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return [...new Set(patterns)].filter((pattern) => pattern !== ".lightcloud");
|
|
1672
|
+
}
|
|
1673
|
+
function packageSource(options = {}) {
|
|
1674
|
+
const directory = path4.resolve(options.directory ?? process.cwd());
|
|
1675
|
+
const excludes = buildExcludePatterns(directory, options.additionalExcludes);
|
|
1676
|
+
return new Promise((resolve6, reject) => {
|
|
1677
|
+
const chunks = [];
|
|
1678
|
+
let fileCount = 0;
|
|
1679
|
+
let totalSize = 0;
|
|
1680
|
+
const archive = archiver("zip", { zlib: { level: 6 } });
|
|
1681
|
+
archive.on("data", (chunk) => chunks.push(chunk));
|
|
1682
|
+
archive.on("entry", (entry) => {
|
|
1683
|
+
if (entry.stats?.isFile()) {
|
|
1684
|
+
fileCount += 1;
|
|
1685
|
+
totalSize += entry.stats.size;
|
|
1686
|
+
options.onProgress?.(fileCount, totalSize);
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
archive.on("warning", (error) => {
|
|
1690
|
+
if (error.code !== "ENOENT") reject(error);
|
|
1691
|
+
});
|
|
1692
|
+
archive.on("error", reject);
|
|
1693
|
+
archive.on("end", () => resolve6({ buffer: Buffer.concat(chunks), fileCount, totalSize }));
|
|
1694
|
+
archive.glob("**/*", { cwd: directory, ignore: excludes, dot: true, nodir: true, follow: false });
|
|
1695
|
+
archive.finalize().catch(reject);
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// src/commands/shared.ts
|
|
1700
|
+
function contextFrom(command) {
|
|
1701
|
+
const options = command.optsWithGlobals();
|
|
1702
|
+
return new Context(options);
|
|
1703
|
+
}
|
|
1704
|
+
function sourceLabel(app) {
|
|
1705
|
+
if (app.git_provider === "upload" || app.github_repo_url?.startsWith("upload://")) return "upload";
|
|
1706
|
+
const host = app.git_provider === "gitlab" ? "gitlab.com" : app.git_provider === "bitbucket" ? "bitbucket.org" : "github.com";
|
|
1707
|
+
if (app.github_repo_owner && app.github_repo_name) return `${host}/${app.github_repo_owner}/${app.github_repo_name}`;
|
|
1708
|
+
return app.github_repo_url || app.git_provider || "unknown";
|
|
1709
|
+
}
|
|
1710
|
+
function appUrl(app, env) {
|
|
1711
|
+
return env?.deployed_url || app.deployed_url || app.environments?.find((e) => e.is_production)?.deployed_url || void 0;
|
|
1712
|
+
}
|
|
1713
|
+
function envName(env) {
|
|
1714
|
+
return env.is_production ? `${env.name} ${c.accent(sym.star)}` : env.name;
|
|
1715
|
+
}
|
|
1716
|
+
function printAppsTable(apps) {
|
|
1717
|
+
printTable(apps, [
|
|
1718
|
+
{ header: "App", cell: (app) => c.bold(app.name) },
|
|
1719
|
+
{ header: "Status", cell: (app) => statusBadge(app.status) },
|
|
1720
|
+
{ header: "Type", cell: (app) => `${app.deployment_type} ${c.dim(sym.bullet)} ${app.framework}` },
|
|
1721
|
+
{ header: "Envs", cell: (app) => String(app.environments?.length ?? 0), align: "right" },
|
|
1722
|
+
{ header: "URL", cell: (app) => appUrl(app) ? link(appUrl(app)) : c.dim("\u2014"), maxWidth: 60 },
|
|
1723
|
+
{ header: "Updated", cell: (app) => c.dim(relativeTime(app.updated_at)) }
|
|
1724
|
+
]);
|
|
1725
|
+
}
|
|
1726
|
+
function printEnvsTable(envs) {
|
|
1727
|
+
printTable(envs, [
|
|
1728
|
+
{ header: "Environment", cell: envName },
|
|
1729
|
+
{ header: "Status", cell: (env) => statusBadge(env.status) },
|
|
1730
|
+
{ header: "Branch", cell: (env) => env.github_branch || c.dim("\u2014") },
|
|
1731
|
+
{ header: "URL", cell: (env) => env.deployed_url ? link(env.deployed_url) : c.dim("\u2014"), maxWidth: 60 },
|
|
1732
|
+
{ header: "Domain", cell: (env) => env.custom_domain ? `${env.custom_domain} ${c.dim(env.custom_domain_status ?? "")}` : c.dim("\u2014") },
|
|
1733
|
+
{ header: "Deployed", cell: (env) => c.dim(relativeTime(env.last_deployed_at)) }
|
|
1734
|
+
]);
|
|
1735
|
+
}
|
|
1736
|
+
function printDeploymentsTable(deployments) {
|
|
1737
|
+
printTable(deployments, [
|
|
1738
|
+
{ header: "ID", cell: (d) => c.dim(shortId(d.id, 10)) },
|
|
1739
|
+
{ header: "Status", cell: (d) => statusBadge(d.status) },
|
|
1740
|
+
{ header: "Commit", cell: (d) => d.commit_sha ? `${c.accent(d.commit_sha.slice(0, 7))} ${d.commit_message?.split("\n")[0] ?? ""}` : c.dim("\u2014"), maxWidth: 56 },
|
|
1741
|
+
{ header: "By", cell: (d) => d.deployed_by_name || c.dim("\u2014") },
|
|
1742
|
+
{ header: "Took", cell: (d) => d.duration_seconds != null ? formatDuration(d.duration_seconds * 1e3) : c.dim("\u2014"), align: "right" },
|
|
1743
|
+
{ header: "Started", cell: (d) => c.dim(relativeTime(d.started_at)) },
|
|
1744
|
+
{ header: "", cell: (d) => d.is_current ? c.green("current") : d.rollback_eligible ? c.dim("rollback ok") : "" }
|
|
1745
|
+
]);
|
|
1746
|
+
}
|
|
1747
|
+
function printDbsTable(dbs) {
|
|
1748
|
+
printTable(dbs, [
|
|
1749
|
+
{ header: "Database", cell: (db) => c.bold(db.name) },
|
|
1750
|
+
{ header: "Status", cell: (db) => statusBadge(db.status) },
|
|
1751
|
+
{ header: "Engine", cell: (db) => db.database_type },
|
|
1752
|
+
{ header: "Tier", cell: (db) => db.tier },
|
|
1753
|
+
{ header: "Region", cell: (db) => db.region },
|
|
1754
|
+
{ header: "Host", cell: (db) => db.connection_hostname || db.connection_host || c.dim("\u2014") },
|
|
1755
|
+
{ header: "Created", cell: (db) => c.dim(relativeTime(db.created_at)) }
|
|
1756
|
+
]);
|
|
1757
|
+
}
|
|
1758
|
+
async function runDeploy(ctx, options) {
|
|
1759
|
+
const org = await ctx.resolveOrg();
|
|
1760
|
+
const { app, env } = options;
|
|
1761
|
+
const queued = env ? await ctx.api.deployEnvironment(org.id, env.id, options.uploadId) : await ctx.api.deployApplication(org.id, app.id, options.uploadId);
|
|
1762
|
+
if (!isJson()) {
|
|
1763
|
+
out(`${c.green(sym.ok)} Deployment queued ${c.dim(`${app.name}${env ? ` / ${env.name}` : ""}`)}`);
|
|
1764
|
+
}
|
|
1765
|
+
if (!options.watch) return { queued };
|
|
1766
|
+
const watched = await watchResource(ctx.api, {
|
|
1767
|
+
kind: env ? "environment" : "application",
|
|
1768
|
+
id: env ? env.id : app.id,
|
|
1769
|
+
organisationId: org.id
|
|
1770
|
+
});
|
|
1771
|
+
if (!watched.ok) {
|
|
1772
|
+
throw new CliError(watched.update.deployment_error || `Deployment ended with status "${watched.status}".`, {
|
|
1773
|
+
hint: `See the full log with \`lc deployments${env ? ` --env ${env.name}` : ""}\` or in the console: ${ctx.appConsoleUrl(app)}`,
|
|
1774
|
+
exitCode: EXIT.FAILED,
|
|
1775
|
+
code: "DEPLOY_FAILED"
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
return { queued, watched };
|
|
1779
|
+
}
|
|
1780
|
+
async function uploadSource(ctx, directory, detection) {
|
|
1781
|
+
const org = await ctx.resolveOrg();
|
|
1782
|
+
const spin = spinner2();
|
|
1783
|
+
spin.start("Packaging source");
|
|
1784
|
+
const pack = await packageSource({
|
|
1785
|
+
directory,
|
|
1786
|
+
onProgress: (files) => {
|
|
1787
|
+
if (files % 250 === 0) spin.message(`Packaging source ${c.dim(`${files} files`)}`);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
spin.message(`Packaged ${pack.fileCount} files ${c.dim(`(${formatBytes(pack.totalSize)} \u2192 ${formatBytes(pack.buffer.length)})`)}`);
|
|
1791
|
+
if (pack.fileCount === 0) {
|
|
1792
|
+
spin.stop("Nothing to upload", 1);
|
|
1793
|
+
throw new CliError(`No files to upload in ${directory}.`, {
|
|
1794
|
+
hint: "Everything matched an ignore rule. Check .gitignore and run from the project root.",
|
|
1795
|
+
exitCode: EXIT.USAGE
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
const session = await ctx.api.requestUpload(org.id, pack.buffer.length);
|
|
1799
|
+
if (pack.buffer.length > session.maxSize) {
|
|
1800
|
+
spin.stop("Archive too large", 1);
|
|
1801
|
+
throw new CliError(`The source archive is ${formatBytes(pack.buffer.length)}; the limit is ${formatBytes(session.maxSize)}.`, {
|
|
1802
|
+
hint: "Exclude build output and large assets via .gitignore, or deploy from a git repository instead.",
|
|
1803
|
+
exitCode: EXIT.USAGE
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
spin.message(`Uploading ${formatBytes(pack.buffer.length)}`);
|
|
1807
|
+
const put = await fetch(session.signedUrl, {
|
|
1808
|
+
method: "PUT",
|
|
1809
|
+
headers: { "Content-Type": "application/zip" },
|
|
1810
|
+
body: new Uint8Array(pack.buffer)
|
|
1811
|
+
});
|
|
1812
|
+
if (!put.ok) {
|
|
1813
|
+
spin.stop("Upload failed", 1);
|
|
1814
|
+
throw new CliError(`Upload rejected by storage (${put.status} ${put.statusText}).`, { hint: "Try again in a moment." });
|
|
1815
|
+
}
|
|
1816
|
+
await ctx.api.completeUpload(org.id, session.uploadId, {
|
|
1817
|
+
detectedFramework: detection.framework,
|
|
1818
|
+
detectedRuntime: detection.runtime,
|
|
1819
|
+
detectedDeploymentType: detection.deploymentType,
|
|
1820
|
+
detectedBuildCommand: detection.buildCommand,
|
|
1821
|
+
detectedOutputDirectory: detection.outputDirectory
|
|
1822
|
+
});
|
|
1823
|
+
spin.stop(`Uploaded ${pack.fileCount} files ${c.dim(`(${formatBytes(pack.buffer.length)})`)}`);
|
|
1824
|
+
return { uploadId: session.uploadId, fileCount: pack.fileCount, totalSize: pack.totalSize, archiveSize: pack.buffer.length };
|
|
1825
|
+
}
|
|
1826
|
+
function describeDetection(detection) {
|
|
1827
|
+
const parts = [];
|
|
1828
|
+
parts.push(detection.frameworkLabel ? c.bold(detection.frameworkLabel) : c.yellow("unknown framework"));
|
|
1829
|
+
parts.push(detection.deploymentType === "static" ? "static site" : "container");
|
|
1830
|
+
if (detection.packageManager) parts.push(detection.packageManager);
|
|
1831
|
+
if (detection.notes.length) parts.push(c.dim(detection.notes.join(", ")));
|
|
1832
|
+
return parts.join(` ${c.dim(sym.bullet)} `);
|
|
1833
|
+
}
|
|
1834
|
+
function warnIfDirty(isDirty, branch) {
|
|
1835
|
+
if (isDirty) log.warn(`Uncommitted changes on ${c.bold(branch ?? "this branch")} will be included in the upload.`);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/commands/apps.ts
|
|
1839
|
+
function registerAppCommands(program2) {
|
|
1840
|
+
program2.command("apps [filter]").alias("ls").alias("list").description("List apps in the workspace").action(async (filter, _options, command) => {
|
|
1841
|
+
const ctx = contextFrom(command);
|
|
1842
|
+
const org = await ctx.resolveOrg();
|
|
1843
|
+
const apps = await ctx.api.listApplications(org.id, filter);
|
|
1844
|
+
const others = ctx.usingApiKey ? [] : ((await ctx.profile()).organisations ?? []).filter((o) => o.id !== org.id);
|
|
1845
|
+
emit(apps, () => {
|
|
1846
|
+
if (apps.length === 0) {
|
|
1847
|
+
log.info(filter ? `No apps matching "${filter}".` : `No apps in ${c.bold(org.name ?? "this workspace")} yet. Run ${c.bold("lc init")} in a project folder to create one.`);
|
|
1848
|
+
if (!filter && others.length) {
|
|
1849
|
+
out(c.dim(` Other workspaces: ${others.map((o) => o.name).join(", ")} \u2192 lc apps --org <name>, or lc org use <name> to switch.`));
|
|
1850
|
+
}
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
heading(`${org.name ?? "Workspace"} ${c.dim(`\xB7 ${apps.length} app${apps.length === 1 ? "" : "s"}`)}`);
|
|
1854
|
+
out();
|
|
1855
|
+
printAppsTable(apps);
|
|
1856
|
+
const failing = apps.filter((app) => app.status === "failed" || app.environments?.some((env) => env.status === "failed"));
|
|
1857
|
+
if (failing.length) {
|
|
1858
|
+
out();
|
|
1859
|
+
log.warn(`${failing.length} app${failing.length === 1 ? "" : "s"} with a failed deployment: ${failing.map((app) => app.name).join(", ")}. Try ${c.bold("lc logs <app>")}.`);
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
});
|
|
1863
|
+
program2.command("status [app]").alias("app").description("Show an app with its environments and latest deployment").option("-a, --app <app>", "app name or id (alternative to the positional)").action(async (positional, options, command) => {
|
|
1864
|
+
const ctx = contextFrom(command);
|
|
1865
|
+
const app = await ctx.resolveApp(positional ?? options.app);
|
|
1866
|
+
const org = await ctx.resolveOrg();
|
|
1867
|
+
const envs = app.environments ?? await ctx.api.listEnvironments(org.id, app.id);
|
|
1868
|
+
emit({ ...app, environments: envs }, () => printAppStatus(ctx.appConsoleUrl(app), app, envs));
|
|
1869
|
+
});
|
|
1870
|
+
program2.command("open [app]").description("Open the deployed app (or the console page) in your browser").option("-e, --env <env>", "environment to open").option("--console", "open the app in the Light Cloud console instead").action(async (positional, options, command) => {
|
|
1871
|
+
const ctx = contextFrom(command);
|
|
1872
|
+
const app = await ctx.resolveApp(positional);
|
|
1873
|
+
let url;
|
|
1874
|
+
if (options.console) {
|
|
1875
|
+
url = ctx.appConsoleUrl(app);
|
|
1876
|
+
} else {
|
|
1877
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
1878
|
+
url = env.deployed_url || appUrl(app, env);
|
|
1879
|
+
if (!url) {
|
|
1880
|
+
throw new CliError(`${app.name} / ${env.name} has no URL yet.`, {
|
|
1881
|
+
hint: "Deploy it first with `lc deploy`, or open the console with `lc open --console`.",
|
|
1882
|
+
exitCode: EXIT.NOT_FOUND
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
const opened = openBrowser(url);
|
|
1887
|
+
emit({ url, opened }, () => opened ? log.success(`Opened ${link(url)}`) : out(url));
|
|
1888
|
+
});
|
|
1889
|
+
program2.command("delete <app>").alias("rm").description("Delete an app and every environment in it").action(async (ref, _options, command) => {
|
|
1890
|
+
const ctx = contextFrom(command);
|
|
1891
|
+
const app = await ctx.resolveApp(ref);
|
|
1892
|
+
const org = await ctx.resolveOrg();
|
|
1893
|
+
const envCount = app.environments?.length ?? 0;
|
|
1894
|
+
const ok = await confirm2(
|
|
1895
|
+
`Delete ${c.bold(app.name)}${envCount ? ` and its ${envCount} environment${envCount === 1 ? "" : "s"}` : ""}? This cannot be undone.`,
|
|
1896
|
+
{ yes: ctx.yes }
|
|
1897
|
+
);
|
|
1898
|
+
if (!ok) return;
|
|
1899
|
+
await ctx.api.deleteApplication(org.id, app.id);
|
|
1900
|
+
emit({ ok: true, id: app.id, name: app.name }, () => log.success(`Deleting ${c.bold(app.name)}. Resources are torn down in the background.`));
|
|
1901
|
+
});
|
|
1902
|
+
program2.command("rename <app> <name>").description("Rename an app").action(async (ref, name, _options, command) => {
|
|
1903
|
+
const ctx = contextFrom(command);
|
|
1904
|
+
const app = await ctx.resolveApp(ref);
|
|
1905
|
+
const org = await ctx.resolveOrg();
|
|
1906
|
+
const updated = await ctx.api.renameApplication(org.id, app.id, name.trim());
|
|
1907
|
+
emit(updated, () => log.success(`${c.bold(app.name)} is now ${c.bold(updated.name ?? name)}.`));
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
function printAppStatus(consoleUrl, app, envs) {
|
|
1911
|
+
if (isJson()) return;
|
|
1912
|
+
const url = appUrl(app);
|
|
1913
|
+
heading(`${app.name} ${statusBadge(app.status)}`);
|
|
1914
|
+
printDetails([
|
|
1915
|
+
["Type", `${app.deployment_type} ${c.dim(sym.bullet)} ${app.framework}${app.runtime ? c.dim(` (${app.runtime})`) : ""}`],
|
|
1916
|
+
["Source", `${sourceLabel(app)}${app.github_branch ? c.dim(` ${app.github_branch}`) : ""}${app.root_directory ? c.dim(` /${app.root_directory}`) : ""}`],
|
|
1917
|
+
["URL", url ? link(url) : void 0],
|
|
1918
|
+
["Domain", app.custom_domain ? `${app.custom_domain} ${c.dim(app.custom_domain_status ?? "")}` : void 0],
|
|
1919
|
+
["Auto-deploy", app.auto_deploy_branches === void 0 ? void 0 : app.auto_deploy_branches ? "on push" : "off"],
|
|
1920
|
+
["Last deploy", relativeTime(app.last_deployed_at)],
|
|
1921
|
+
["Console", c.dim(consoleUrl)],
|
|
1922
|
+
["ID", c.dim(app.id)]
|
|
1923
|
+
]);
|
|
1924
|
+
if (app.status === "failed" && app.deployment_error) {
|
|
1925
|
+
out();
|
|
1926
|
+
out(` ${c.red(sym.fail)} ${app.deployment_error}`);
|
|
1927
|
+
}
|
|
1928
|
+
out();
|
|
1929
|
+
if (envs.length === 0) {
|
|
1930
|
+
log.info("No environments.");
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
printEnvsTable(envs);
|
|
1934
|
+
const failing = envs.filter((env) => env.status === "failed");
|
|
1935
|
+
if (failing.length) {
|
|
1936
|
+
out();
|
|
1937
|
+
for (const env of failing) {
|
|
1938
|
+
out(` ${c.red(sym.fail)} ${envName(env)} failed${env.last_deployed_at ? c.dim(` (${relativeTime(env.last_deployed_at)})`) : ""} \u2014 ${c.dim(`lc deployments --env ${env.name}`)}`);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// src/lib/auth/browser-login.ts
|
|
1944
|
+
import * as crypto from "crypto";
|
|
1945
|
+
import * as http from "http";
|
|
1946
|
+
var SIGN_IN_CANCELLED = "SIGN_IN_CANCELLED";
|
|
1947
|
+
function startLoginFlow(consoleUrl, options = {}) {
|
|
1948
|
+
const state2 = crypto.randomBytes(16).toString("hex");
|
|
1949
|
+
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
|
|
1950
|
+
return new Promise((resolveFlow, rejectFlow) => {
|
|
1951
|
+
let settle = null;
|
|
1952
|
+
const tokens = new Promise((resolve6, reject) => {
|
|
1953
|
+
settle = { resolve: resolve6, reject };
|
|
1954
|
+
});
|
|
1955
|
+
const server = http.createServer((req, res) => {
|
|
1956
|
+
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
1957
|
+
if (url.pathname !== "/callback") {
|
|
1958
|
+
res.writeHead(404).end("Not found");
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
const error = url.searchParams.get("error");
|
|
1962
|
+
const token = url.searchParams.get("token");
|
|
1963
|
+
const refreshToken = url.searchParams.get("refreshToken") || void 0;
|
|
1964
|
+
const returnedState = url.searchParams.get("state");
|
|
1965
|
+
const finish = (kind, message) => {
|
|
1966
|
+
const target = new URL(`${consoleUrl}/auth/cli`);
|
|
1967
|
+
target.searchParams.set("done", kind);
|
|
1968
|
+
target.searchParams.set("client", "cli");
|
|
1969
|
+
if (message) target.searchParams.set("message", message);
|
|
1970
|
+
res.writeHead(302, { Location: target.toString() });
|
|
1971
|
+
res.end();
|
|
1972
|
+
server.close();
|
|
1973
|
+
};
|
|
1974
|
+
if (error === "cancelled") {
|
|
1975
|
+
finish("cancelled");
|
|
1976
|
+
settle?.reject(new CliError("Sign-in cancelled in the browser.", { exitCode: EXIT.CANCELLED, code: SIGN_IN_CANCELLED }));
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
if (error) {
|
|
1980
|
+
finish("error", error);
|
|
1981
|
+
settle?.reject(new CliError(`Sign-in failed: ${error}`, { exitCode: EXIT.AUTH }));
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (returnedState !== state2) {
|
|
1985
|
+
finish("error", "This sign-in link does not match the one the CLI is waiting for.");
|
|
1986
|
+
settle?.reject(new CliError("Sign-in state mismatch. Start again with `lc login`.", { exitCode: EXIT.AUTH }));
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
if (!token) {
|
|
1990
|
+
finish("error", "No token was received from the console.");
|
|
1991
|
+
settle?.reject(new CliError("The console sent no token back.", { exitCode: EXIT.AUTH }));
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
finish("success");
|
|
1995
|
+
settle?.resolve({ accessToken: token, refreshToken });
|
|
1996
|
+
});
|
|
1997
|
+
server.on("error", (error) => rejectFlow(new CliError(`Could not start the sign-in listener: ${error.message}`)));
|
|
1998
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1999
|
+
const { port } = server.address();
|
|
2000
|
+
const callback = `http://127.0.0.1:${port}/callback`;
|
|
2001
|
+
const url = `${consoleUrl}/auth/cli?callback=${encodeURIComponent(callback)}&state=${state2}`;
|
|
2002
|
+
const timer = setTimeout(() => {
|
|
2003
|
+
server.close();
|
|
2004
|
+
settle?.reject(new CliError("Sign-in timed out after 5 minutes.", { hint: "Run `lc login` again.", exitCode: EXIT.AUTH }));
|
|
2005
|
+
}, timeoutMs);
|
|
2006
|
+
timer.unref();
|
|
2007
|
+
resolveFlow({
|
|
2008
|
+
url,
|
|
2009
|
+
tokens: tokens.finally(() => clearTimeout(timer)),
|
|
2010
|
+
cancel: () => {
|
|
2011
|
+
clearTimeout(timer);
|
|
2012
|
+
server.close();
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
});
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// src/lib/auth/device-login.ts
|
|
2020
|
+
import * as os2 from "os";
|
|
2021
|
+
var DEVICE_SIGN_IN_DENIED = "DEVICE_SIGN_IN_DENIED";
|
|
2022
|
+
var DEVICE_SIGN_IN_EXPIRED = "DEVICE_SIGN_IN_EXPIRED";
|
|
2023
|
+
var DEVICE_SIGN_IN_UNAVAILABLE = "DEVICE_SIGN_IN_UNAVAILABLE";
|
|
2024
|
+
async function startDeviceFlow(api, email) {
|
|
2025
|
+
let started;
|
|
2026
|
+
try {
|
|
2027
|
+
started = await api.post(
|
|
2028
|
+
"/api/auth/device/start",
|
|
2029
|
+
{ email, client: "cli", clientName: `Light Cloud CLI on ${os2.hostname()}` },
|
|
2030
|
+
{ anonymous: true }
|
|
2031
|
+
);
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
2034
|
+
throw new CliError("Device sign-in is not enabled on this Light Cloud environment.", {
|
|
2035
|
+
hint: "Run `lc login` without --device to sign in through a browser.",
|
|
2036
|
+
code: DEVICE_SIGN_IN_UNAVAILABLE,
|
|
2037
|
+
exitCode: EXIT.ERROR
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
throw error;
|
|
2041
|
+
}
|
|
2042
|
+
const expiresAt = Date.now() + started.expiresIn * 1e3;
|
|
2043
|
+
let cancelled = false;
|
|
2044
|
+
let timer = null;
|
|
2045
|
+
const tokens = new Promise((resolve6, reject) => {
|
|
2046
|
+
let delay = Math.max(started.interval, 3) * 1e3;
|
|
2047
|
+
const poll = async () => {
|
|
2048
|
+
if (cancelled) return;
|
|
2049
|
+
if (Date.now() > expiresAt) {
|
|
2050
|
+
reject(new CliError("The code expired before it was approved.", { code: DEVICE_SIGN_IN_EXPIRED, hint: "Run `lc login` again for a new one." }));
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
let answer = null;
|
|
2054
|
+
try {
|
|
2055
|
+
answer = await api.post("/api/auth/device/poll", { deviceCode: started.deviceCode }, { anonymous: true });
|
|
2056
|
+
} catch {
|
|
2057
|
+
}
|
|
2058
|
+
if (answer) {
|
|
2059
|
+
switch (answer.status) {
|
|
2060
|
+
case "approved":
|
|
2061
|
+
resolve6({ accessToken: answer.token, refreshToken: answer.refreshToken });
|
|
2062
|
+
return;
|
|
2063
|
+
case "access_denied":
|
|
2064
|
+
reject(new CliError("Sign-in was refused in the browser.", { code: DEVICE_SIGN_IN_DENIED }));
|
|
2065
|
+
return;
|
|
2066
|
+
case "expired_token":
|
|
2067
|
+
reject(new CliError("The code expired before it was approved.", { code: DEVICE_SIGN_IN_EXPIRED, hint: "Run `lc login` again for a new one." }));
|
|
2068
|
+
return;
|
|
2069
|
+
case "slow_down":
|
|
2070
|
+
delay += 5e3;
|
|
2071
|
+
break;
|
|
2072
|
+
default:
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
timer = setTimeout(poll, delay);
|
|
2077
|
+
};
|
|
2078
|
+
timer = setTimeout(poll, delay);
|
|
2079
|
+
});
|
|
2080
|
+
return {
|
|
2081
|
+
userCode: started.userCode,
|
|
2082
|
+
verificationUrl: started.verificationUrl,
|
|
2083
|
+
newAccount: started.newAccount,
|
|
2084
|
+
emailSent: started.emailSent,
|
|
2085
|
+
expiresAt,
|
|
2086
|
+
tokens,
|
|
2087
|
+
cancel() {
|
|
2088
|
+
cancelled = true;
|
|
2089
|
+
if (timer) clearTimeout(timer);
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// src/commands/auth.ts
|
|
2095
|
+
async function browserSignIn(consoleUrl, openIt) {
|
|
2096
|
+
const flow = await startLoginFlow(consoleUrl);
|
|
2097
|
+
const opened = openIt && isInteractive() ? openBrowser(flow.url) : false;
|
|
2098
|
+
if (opened) {
|
|
2099
|
+
out(`${c.dim("\u2502")} Browser opened. If nothing happened, open this link:`);
|
|
2100
|
+
} else {
|
|
2101
|
+
out(`${c.dim("\u2502")} Open this link in your browser to sign in:`);
|
|
2102
|
+
}
|
|
2103
|
+
out(`${c.dim("\u2502")} ${link(flow.url)}`);
|
|
2104
|
+
const spin = spinner2();
|
|
2105
|
+
spin.start("Waiting for you to sign in\u2026");
|
|
2106
|
+
try {
|
|
2107
|
+
const tokens = await flow.tokens;
|
|
2108
|
+
spin.stop("Signed in");
|
|
2109
|
+
return tokens;
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
if (error instanceof CliError && error.code === SIGN_IN_CANCELLED) {
|
|
2112
|
+
spin.stop("Sign-in cancelled in the browser", 2);
|
|
2113
|
+
throw new CancelledError();
|
|
2114
|
+
}
|
|
2115
|
+
spin.stop("Sign-in did not complete", 1);
|
|
2116
|
+
throw error;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
async function deviceSignIn(client, emailFlag) {
|
|
2120
|
+
const email = emailFlag?.trim().toLowerCase() || (await text2("Email address", {
|
|
2121
|
+
flag: "--email <address>",
|
|
2122
|
+
placeholder: "you@example.com",
|
|
2123
|
+
validate: (value) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value.trim()) ? void 0 : "Enter an email address"
|
|
2124
|
+
})).trim().toLowerCase();
|
|
2125
|
+
const flow = await startDeviceFlow(client, email);
|
|
2126
|
+
const minutes = Math.max(1, Math.round((flow.expiresAt - Date.now()) / 6e4));
|
|
2127
|
+
if (flow.newAccount) {
|
|
2128
|
+
out(`${c.dim("\u2502")} No account exists for ${c.bold(email)} yet \u2014 approving creates one (free plan).`);
|
|
2129
|
+
out(`${c.dim("\u2502")} Open the link in the email we sent to ${email}, then enter the code:`);
|
|
2130
|
+
} else {
|
|
2131
|
+
out(`${c.dim("\u2502")} Open ${link(flow.verificationUrl)} on any device, sign in, and enter the code:`);
|
|
2132
|
+
}
|
|
2133
|
+
out(`${c.dim("\u2502")}`);
|
|
2134
|
+
out(`${c.dim("\u2502")} ${c.bold(flow.userCode)}`);
|
|
2135
|
+
out(`${c.dim("\u2502")}`);
|
|
2136
|
+
out(`${c.dim("\u2502")} ${c.dim(`The code expires in ${minutes} minutes.`)}`);
|
|
2137
|
+
if (!flow.emailSent) {
|
|
2138
|
+
log.warn("The confirmation email could not be sent; an existing account can still approve by signing in on the page above.");
|
|
2139
|
+
}
|
|
2140
|
+
const spin = spinner2();
|
|
2141
|
+
spin.start("Waiting for the code to be approved\u2026");
|
|
2142
|
+
try {
|
|
2143
|
+
const tokens = await flow.tokens;
|
|
2144
|
+
spin.stop(flow.newAccount ? "Account created and signed in" : "Signed in");
|
|
2145
|
+
return tokens;
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
if (error instanceof CliError && error.code === DEVICE_SIGN_IN_DENIED) {
|
|
2148
|
+
spin.stop("Sign-in refused in the browser", 2);
|
|
2149
|
+
throw new CancelledError();
|
|
2150
|
+
}
|
|
2151
|
+
spin.stop("Sign-in did not complete", 1);
|
|
2152
|
+
throw error;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
function registerAuthCommands(program2) {
|
|
2156
|
+
program2.command("login").description("Sign in to Light Cloud (opens your browser, or a code to type on another device)").option("--api-key <key>", "sign in with an API key instead of a browser session").option("--no-browser", "print the sign-in URL instead of opening a browser").option("--device", "sign in with a short code from any device (no browser needed here); creates the account if the email has none").option("--email <address>", "email for --device sign-in").action(async (options, command) => {
|
|
2157
|
+
const ctx = contextFrom(command);
|
|
2158
|
+
if (options.apiKey) {
|
|
2159
|
+
if (!isApiKey(options.apiKey)) {
|
|
2160
|
+
throw new CliError("That does not look like a Light Cloud API key.", {
|
|
2161
|
+
hint: "Keys start with `lc_`. Create one in the console under Organisation settings \u2192 API keys.",
|
|
2162
|
+
exitCode: EXIT.USAGE
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
writeCredentials({ ...readCredentials(), apiKey: options.apiKey, accessToken: void 0, refreshToken: void 0 });
|
|
2166
|
+
try {
|
|
2167
|
+
await ctx.api.listApplications("api-key", void 0, 1);
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
clearCredentials();
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
emit({ ok: true, method: "api-key" }, () => log.success(`API key saved to ${c.dim(credentialsPath())}.`));
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (process.env.LIGHT_CLOUD_API_KEY) {
|
|
2176
|
+
log.warn("LIGHT_CLOUD_API_KEY is set in this shell and takes precedence over a browser session.");
|
|
2177
|
+
}
|
|
2178
|
+
if (ctx.api.client.hasCredentials() && !ctx.usingApiKey) {
|
|
2179
|
+
try {
|
|
2180
|
+
const profile2 = await ctx.api.profile();
|
|
2181
|
+
emit({ ok: true, alreadySignedIn: true, email: profile2.email }, () => {
|
|
2182
|
+
log.info(`Already signed in as ${c.bold(profile2.email)}. Run \`lc logout\` first to switch accounts.`);
|
|
2183
|
+
});
|
|
2184
|
+
return;
|
|
2185
|
+
} catch {
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
intro2(`${brand()} sign-in`);
|
|
2189
|
+
const useDevice = options.device || Boolean(options.email) || !process.env.DISPLAY && process.platform === "linux" && !options.browser;
|
|
2190
|
+
const tokens = useDevice ? await deviceSignIn(ctx.api.client, options.email) : await browserSignIn(ctx.endpoints.consoleUrl, options.browser);
|
|
2191
|
+
writeCredentials({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
|
|
2192
|
+
const profile = await ctx.api.profile();
|
|
2193
|
+
const orgs = profile.organisations ?? [];
|
|
2194
|
+
logStep(`Signed in as ${c.bold(profile.email)}`);
|
|
2195
|
+
const saved = readGlobalConfig().defaultOrganisationId;
|
|
2196
|
+
let defaultOrg = orgs.find((org) => org.id === saved);
|
|
2197
|
+
if (!defaultOrg && orgs.length === 1) defaultOrg = orgs[0];
|
|
2198
|
+
if (!defaultOrg && orgs.length > 1 && isInteractive()) {
|
|
2199
|
+
const chosen = await select2(
|
|
2200
|
+
"Which workspace should lc use by default?",
|
|
2201
|
+
orgs.map((org) => ({ value: org.id, label: org.name, hint: org.role })),
|
|
2202
|
+
{ flag: "lc org use <name>" }
|
|
2203
|
+
);
|
|
2204
|
+
defaultOrg = orgs.find((org) => org.id === chosen);
|
|
2205
|
+
}
|
|
2206
|
+
if (defaultOrg) {
|
|
2207
|
+
updateGlobalConfig({ defaultOrganisationId: defaultOrg.id, defaultOrganisationName: defaultOrg.name });
|
|
2208
|
+
}
|
|
2209
|
+
emit({ ok: true, method: useDevice ? "device" : "browser", email: profile.email, organisations: orgs, defaultOrganisationId: defaultOrg?.id ?? null }, () => {
|
|
2210
|
+
if (orgs.length > 1) {
|
|
2211
|
+
logStep(`Workspaces${defaultOrg ? c.dim(` (default: ${defaultOrg.name}; change with lc org use <name>)`) : ""}`);
|
|
2212
|
+
for (const org of orgs) {
|
|
2213
|
+
const marker = org.id === defaultOrg?.id ? c.accent(" \u2714 default") : "";
|
|
2214
|
+
out(`${c.dim("\u2502")} ${c.dim("\xB7")} ${org.name} ${c.dim(`(${org.role})`)}${marker}`);
|
|
2215
|
+
}
|
|
2216
|
+
} else if (defaultOrg) {
|
|
2217
|
+
logStep(`Workspace ${c.bold(defaultOrg.name)} ${c.dim(`(${defaultOrg.role})`)}`);
|
|
2218
|
+
}
|
|
2219
|
+
if (!defaultOrg && orgs.length > 1) {
|
|
2220
|
+
out(`${c.dim("\u2502")} Pick one with: ${orgs.map((org) => c.bold(`lc org use ${quoteArg(org.name)}`)).join(c.dim(" or "))}`);
|
|
2221
|
+
}
|
|
2222
|
+
outro2(`Ready. Try ${c.bold("lc apps")} or ${c.bold("lc init")} in a project folder.`);
|
|
2223
|
+
});
|
|
2224
|
+
});
|
|
2225
|
+
program2.command("logout").description("Sign out and forget stored credentials").action(async () => {
|
|
2226
|
+
const had = readCredentials();
|
|
2227
|
+
clearCredentials();
|
|
2228
|
+
emit({ ok: true }, () => {
|
|
2229
|
+
if (had.accessToken || had.apiKey) log.success("Signed out. Credentials removed.");
|
|
2230
|
+
else log.info("You were not signed in.");
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
program2.command("whoami").description("Show the signed-in account and its workspaces").action(async (_options, command) => {
|
|
2234
|
+
const ctx = contextFrom(command);
|
|
2235
|
+
const auth = resolveAuth();
|
|
2236
|
+
if (!auth.token) {
|
|
2237
|
+
throw new CliError("Not signed in.", { hint: "Run `lc login`.", exitCode: EXIT.AUTH });
|
|
2238
|
+
}
|
|
2239
|
+
if (isApiKey(auth.token)) {
|
|
2240
|
+
const org = await ctx.resolveOrg();
|
|
2241
|
+
emit({ method: auth.source, organisationId: org.id, apiUrl: ctx.endpoints.apiUrl }, () => {
|
|
2242
|
+
printDetails([
|
|
2243
|
+
["Signed in with", `API key ${c.dim(`(${auth.source === "env-api-key" ? "LIGHT_CLOUD_API_KEY" : credentialsPath()})`)}`],
|
|
2244
|
+
["Workspace", org.id],
|
|
2245
|
+
["API", ctx.endpoints.apiUrl]
|
|
2246
|
+
]);
|
|
2247
|
+
});
|
|
2248
|
+
return;
|
|
2249
|
+
}
|
|
2250
|
+
let profile;
|
|
2251
|
+
try {
|
|
2252
|
+
profile = await ctx.api.profile();
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
2255
|
+
throw new CliError("Your session has expired.", { hint: "Run `lc login` to sign in again.", exitCode: EXIT.AUTH });
|
|
2256
|
+
}
|
|
2257
|
+
throw error;
|
|
2258
|
+
}
|
|
2259
|
+
const defaultOrg = readGlobalConfig().defaultOrganisationId;
|
|
2260
|
+
const name = [profile.first_name, profile.last_name].filter(Boolean).join(" ");
|
|
2261
|
+
emit({ ...profile, defaultOrganisationId: defaultOrg, apiUrl: ctx.endpoints.apiUrl }, () => {
|
|
2262
|
+
printDetails([
|
|
2263
|
+
["Account", `${c.bold(profile.email)}${name ? c.dim(` ${name}`) : ""}`],
|
|
2264
|
+
["API", ctx.endpoints.apiUrl],
|
|
2265
|
+
["Linked app", ctx.project ? `${ctx.project.config.applicationName ?? ctx.project.config.applicationId} ${c.dim(ctx.project.path)}` : void 0]
|
|
2266
|
+
]);
|
|
2267
|
+
out();
|
|
2268
|
+
printTable(profile.organisations ?? [], [
|
|
2269
|
+
{ header: "Workspace", cell: (org) => `${org.name}${org.id === defaultOrg ? c.accent(" (default)") : ""}` },
|
|
2270
|
+
{ header: "Role", cell: (org) => org.role },
|
|
2271
|
+
{ header: "ID", cell: (org) => c.dim(org.id) }
|
|
2272
|
+
]);
|
|
2273
|
+
});
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
function quoteArg(value) {
|
|
2277
|
+
return /^[A-Za-z0-9_.-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// src/commands/billing.ts
|
|
2281
|
+
var money = (value) => `$${value.toFixed(2)}`;
|
|
2282
|
+
function registerBillingCommands(program2) {
|
|
2283
|
+
const billing = program2.command("billing").description("Plan, usage pool and payment card for a workspace");
|
|
2284
|
+
billing.command("show", { isDefault: true }).description("Plan, card on file and usage pool this cycle").action(async (_options, command) => {
|
|
2285
|
+
const ctx = contextFrom(command);
|
|
2286
|
+
const org = await ctx.resolveOrg();
|
|
2287
|
+
const [{ data: plans }, summary] = await Promise.all([
|
|
2288
|
+
ctx.api.plans(org.id),
|
|
2289
|
+
ctx.api.ownerBillingSummary().catch(() => null)
|
|
2290
|
+
]);
|
|
2291
|
+
const current = plans.plans.find((plan2) => plan2.id === (plans.currentPlanId ?? "hobby"));
|
|
2292
|
+
const card2 = summary?.data.payment_method ?? null;
|
|
2293
|
+
emit({ organisation: org, plan: current ?? null, pendingPlanId: plans.pendingPlanId, card: card2, pool: plans.pool, hardStopped: plans.hardStopped }, () => {
|
|
2294
|
+
heading(org.name ?? org.id);
|
|
2295
|
+
printDetails([
|
|
2296
|
+
["Plan", current ? `${c.bold(current.name)} ${c.dim(`(${current.id})`)} \xB7 ${money(current.price)}/month` : plans.currentPlanId ?? "free"],
|
|
2297
|
+
["Pending", plans.pendingPlanId ? `${plans.pendingPlanId} at next cycle` : void 0],
|
|
2298
|
+
["Card", card2 ? `${card2.brand} \u2022\u2022\u2022\u2022 ${card2.last4}` : `none ${c.dim("(lc billing card add)")}`],
|
|
2299
|
+
["Pool", `${money(plans.pool.spent)} of ${money(plans.pool.total)} used (${Math.round(plans.pool.pct)}%)${plans.pool.overage > 0 ? `, overage ${money(plans.pool.overage)}` : ""}`],
|
|
2300
|
+
["Next invoice", summary?.data.billing_cycle.next_billing_date?.slice(0, 10)]
|
|
2301
|
+
]);
|
|
2302
|
+
if (plans.hardStopped) {
|
|
2303
|
+
out();
|
|
2304
|
+
log.warn("Free-plan pool used up \u2014 projects are paused until an upgrade (lc billing plan use <id>) or the next cycle.");
|
|
2305
|
+
}
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
billing.command("plans").description("Plans this workspace can be on").action(async (_options, command) => {
|
|
2309
|
+
const ctx = contextFrom(command);
|
|
2310
|
+
const org = await ctx.resolveOrg();
|
|
2311
|
+
const { data } = await ctx.api.plans(org.id);
|
|
2312
|
+
const currentId = data.currentPlanId ?? "hobby";
|
|
2313
|
+
emit(data.plans, () => {
|
|
2314
|
+
printTable(data.plans, [
|
|
2315
|
+
{ header: "Plan", cell: (plan2) => `${c.bold(plan2.name)}${plan2.id === currentId ? c.accent(" (current)") : ""}` },
|
|
2316
|
+
{ header: "ID", cell: (plan2) => plan2.id },
|
|
2317
|
+
{ header: "Price", cell: (plan2) => `${money(plan2.price)}/mo` },
|
|
2318
|
+
{ header: "Includes", cell: (plan2) => summariseEntitlements(plan2.entitlements) }
|
|
2319
|
+
]);
|
|
2320
|
+
out();
|
|
2321
|
+
out(c.dim(" Switch: lc billing plan use <id> \xB7 paid plans need a card: lc billing card add"));
|
|
2322
|
+
});
|
|
2323
|
+
});
|
|
2324
|
+
const plan = billing.command("plan").description("Change plan");
|
|
2325
|
+
plan.command("use [plan]").description("Put the workspace on a plan (no id: pick from a list)").option("-y, --yes", "skip the confirmation").action(async (ref, options, command) => {
|
|
2326
|
+
const ctx = contextFrom(command);
|
|
2327
|
+
const org = await ctx.resolveOrg();
|
|
2328
|
+
const { data } = await ctx.api.plans(org.id);
|
|
2329
|
+
const currentId = data.currentPlanId ?? "hobby";
|
|
2330
|
+
let target = ref ? data.plans.find((p) => p.id === ref.toLowerCase() || p.name.toLowerCase() === ref.toLowerCase()) : void 0;
|
|
2331
|
+
if (ref && !target) {
|
|
2332
|
+
throw new CliError(`No plan called "${ref}".`, { hint: `Plans: ${data.plans.map((p) => p.id).join(", ")}.`, exitCode: EXIT.NOT_FOUND });
|
|
2333
|
+
}
|
|
2334
|
+
if (!target) {
|
|
2335
|
+
const chosen = await select2(
|
|
2336
|
+
"Which plan?",
|
|
2337
|
+
data.plans.map((p) => ({ value: p.id, label: `${p.name} \u2014 ${money(p.price)}/mo`, hint: p.id === currentId ? "current" : void 0 })),
|
|
2338
|
+
{ flag: "lc billing plan use <id>", initialValue: currentId }
|
|
2339
|
+
);
|
|
2340
|
+
target = data.plans.find((p) => p.id === chosen);
|
|
2341
|
+
}
|
|
2342
|
+
const current = data.plans.find((p) => p.id === currentId);
|
|
2343
|
+
if (target.price > (current?.price ?? 0)) {
|
|
2344
|
+
const charge = (current?.price ?? 0) <= 0 ? target.price : void 0;
|
|
2345
|
+
const ok = await confirm2(
|
|
2346
|
+
`Switch ${org.name ?? "this workspace"} to ${target.name} (${money(target.price)}/month)?${charge !== void 0 ? ` The card on file is charged ${money(charge)} now.` : ""}`,
|
|
2347
|
+
{ yes: Boolean(options.yes), flag: "--yes" }
|
|
2348
|
+
);
|
|
2349
|
+
if (!ok) throw new CancelledError();
|
|
2350
|
+
}
|
|
2351
|
+
let result;
|
|
2352
|
+
try {
|
|
2353
|
+
result = await ctx.api.choosePlan(org.id, target.id);
|
|
2354
|
+
} catch (error) {
|
|
2355
|
+
if (error instanceof ApiError && error.code === "PAYMENT_METHOD_REQUIRED") {
|
|
2356
|
+
throw new CliError("A card is needed for a paid plan.", { hint: `Run ${c.bold("lc billing card add")}, then this command again.`, exitCode: EXIT.ERROR, code: error.code });
|
|
2357
|
+
}
|
|
2358
|
+
throw error;
|
|
2359
|
+
}
|
|
2360
|
+
emit(result.data, () => {
|
|
2361
|
+
if (result.data.pendingPlanId) {
|
|
2362
|
+
log.success(`Downgrade scheduled: ${result.data.planId} until ${result.data.effectiveAt?.slice(0, 10) ?? "the next cycle"}, then ${result.data.pendingPlanId}.`);
|
|
2363
|
+
} else {
|
|
2364
|
+
const charge = result.data.proratedCharge > 0 ? ` Charged ${money(result.data.proratedCharge)} (${result.data.chargeStatus}).` : "";
|
|
2365
|
+
log.success(`${org.name ?? "Workspace"} is now on ${c.bold(result.data.planId)}.${charge}`);
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
});
|
|
2369
|
+
const card = billing.command("card").description("Payment card");
|
|
2370
|
+
card.command("show", { isDefault: true }).description("The card on file").action(async (_options, command) => {
|
|
2371
|
+
const ctx = contextFrom(command);
|
|
2372
|
+
const summary = await ctx.api.ownerBillingSummary();
|
|
2373
|
+
const method = summary.data.payment_method;
|
|
2374
|
+
emit({ card: method }, () => {
|
|
2375
|
+
if (method) log.info(`${method.brand} \u2022\u2022\u2022\u2022 ${method.last4}`);
|
|
2376
|
+
else log.info(`No card on file. Add one with ${c.bold("lc billing card add")}.`);
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
card.command("add").description("Save a card through a Stripe-hosted page (link you can open on any device)").option("--no-browser", "print the link instead of opening a browser").option("--plan <id>", "switch to this plan once the card is saved").action(async (options, command) => {
|
|
2380
|
+
const ctx = contextFrom(command);
|
|
2381
|
+
const org = await ctx.resolveOrg();
|
|
2382
|
+
let session;
|
|
2383
|
+
try {
|
|
2384
|
+
session = (await ctx.api.createCheckoutSession(org.id)).data;
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
2387
|
+
throw new CliError("Hosted card setup is not enabled on this Light Cloud environment.", {
|
|
2388
|
+
hint: "Add a card in the console under Billing \u2192 General.",
|
|
2389
|
+
exitCode: EXIT.ERROR
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
throw error;
|
|
2393
|
+
}
|
|
2394
|
+
const opened = options.browser && isInteractive() ? openBrowser(session.url) : false;
|
|
2395
|
+
out(`${c.dim("\u2502")} ${opened ? "Browser opened. If nothing happened, open this link" : "Open this link on any device to save a card"} ${c.dim("(Stripe-hosted; the card never passes through lc)")}:`);
|
|
2396
|
+
out(`${c.dim("\u2502")} ${link(session.url)}`);
|
|
2397
|
+
const spin = spinner2();
|
|
2398
|
+
spin.start("Waiting for the card to be saved\u2026");
|
|
2399
|
+
const deadline = new Date(session.expiresAt).getTime();
|
|
2400
|
+
let status = "open";
|
|
2401
|
+
let saved = null;
|
|
2402
|
+
while (Date.now() < deadline) {
|
|
2403
|
+
await new Promise((resolve6) => setTimeout(resolve6, 4e3));
|
|
2404
|
+
try {
|
|
2405
|
+
const answer = (await ctx.api.checkoutSessionStatus(org.id, session.sessionId)).data;
|
|
2406
|
+
status = answer.status;
|
|
2407
|
+
if (status === "complete") {
|
|
2408
|
+
saved = answer.paymentMethod;
|
|
2409
|
+
break;
|
|
2410
|
+
}
|
|
2411
|
+
if (status === "expired") break;
|
|
2412
|
+
} catch {
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
if (status !== "complete") {
|
|
2416
|
+
spin.stop("No card saved", 1);
|
|
2417
|
+
throw new CliError(status === "expired" ? "The card setup link expired." : "The card setup link timed out.", { hint: "Run `lc billing card add` again for a new link." });
|
|
2418
|
+
}
|
|
2419
|
+
spin.stop(`Card saved${saved ? `: ${saved.brand} \u2022\u2022\u2022\u2022 ${saved.last4}` : ""}`);
|
|
2420
|
+
let planResult = null;
|
|
2421
|
+
if (options.plan) {
|
|
2422
|
+
planResult = (await ctx.api.choosePlan(org.id, options.plan)).data;
|
|
2423
|
+
log.success(`${org.name ?? "Workspace"} is now on ${c.bold(planResult.planId)}${planResult.proratedCharge > 0 ? ` (charged ${money(planResult.proratedCharge)})` : ""}.`);
|
|
2424
|
+
}
|
|
2425
|
+
emit({ ok: true, card: saved, plan: planResult }, () => {
|
|
2426
|
+
if (!options.plan) out(c.dim(" Pick a plan with: lc billing plan use <id>"));
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
card.command("remove").description("Remove the card on file").option("-y, --yes", "skip the confirmation").action(async (options, command) => {
|
|
2430
|
+
const ctx = contextFrom(command);
|
|
2431
|
+
const org = await ctx.resolveOrg();
|
|
2432
|
+
const ok = await confirm2("Remove the card on file? Paid plans cannot be charged without one.", { yes: Boolean(options.yes), flag: "--yes" });
|
|
2433
|
+
if (!ok) throw new CancelledError();
|
|
2434
|
+
await ctx.api.removePaymentMethod(org.id);
|
|
2435
|
+
emit({ ok: true }, () => log.success("Card removed."));
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
function summariseEntitlements(entitlements) {
|
|
2439
|
+
if (!entitlements) return c.dim("\u2014");
|
|
2440
|
+
const parts = [];
|
|
2441
|
+
for (const [key, value] of Object.entries(entitlements)) {
|
|
2442
|
+
if (Array.isArray(value)) parts.push(`${key}: ${value.join("/")}`);
|
|
2443
|
+
else if (typeof value === "boolean") {
|
|
2444
|
+
if (value) parts.push(key);
|
|
2445
|
+
} else if (value !== null && typeof value !== "object") parts.push(`${key}: ${String(value)}`);
|
|
2446
|
+
}
|
|
2447
|
+
return parts.length ? parts.join(", ") : c.dim("\u2014");
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// src/commands/config.ts
|
|
2451
|
+
function registerConfigCommands(program2) {
|
|
2452
|
+
const config = program2.command("config").description("CLI settings (API endpoint, defaults)");
|
|
2453
|
+
config.command("show", { isDefault: true }).description("Show effective settings and where they come from").action(async () => {
|
|
2454
|
+
const stored = readGlobalConfig();
|
|
2455
|
+
const endpoints = resolveEndpoints();
|
|
2456
|
+
const auth = resolveAuth();
|
|
2457
|
+
const project = findProjectConfig();
|
|
2458
|
+
emit(
|
|
2459
|
+
{ endpoints, auth: auth.source, config: stored, configPath: globalConfigPath(), project: project?.config ?? null, projectPath: project?.path ?? null },
|
|
2460
|
+
() => {
|
|
2461
|
+
printDetails([
|
|
2462
|
+
["API URL", `${endpoints.apiUrl}${process.env.LIGHT_CLOUD_API_URL ? c.dim(" (LIGHT_CLOUD_API_URL)") : stored.apiUrl ? c.dim(" (config)") : c.dim(" (default)")}`],
|
|
2463
|
+
["Console URL", endpoints.consoleUrl],
|
|
2464
|
+
["Credentials", `${auth.source === "none" ? c.yellow("none") : auth.source} ${c.dim(credentialsPath())}`],
|
|
2465
|
+
["Default workspace", stored.defaultOrganisationName ? `${stored.defaultOrganisationName} ${c.dim(stored.defaultOrganisationId ?? "")}` : c.dim("not set")],
|
|
2466
|
+
["Settings file", c.dim(globalConfigPath())],
|
|
2467
|
+
["Linked app", project ? `${project.config.applicationName ?? project.config.applicationId ?? "?"} ${c.dim(project.path)}` : c.dim("none in this folder")]
|
|
2468
|
+
]);
|
|
2469
|
+
}
|
|
2470
|
+
);
|
|
2471
|
+
});
|
|
2472
|
+
config.command("set <key> <value>").description("Set a value: api-url, console-url").action(async (key, value) => {
|
|
2473
|
+
const normalised = key.toLowerCase().replace(/_/g, "-");
|
|
2474
|
+
if (normalised === "api-url" || normalised === "apiurl") {
|
|
2475
|
+
assertUrl(value);
|
|
2476
|
+
updateGlobalConfig({ apiUrl: value.replace(/\/+$/, "") });
|
|
2477
|
+
} else if (normalised === "console-url" || normalised === "consoleurl") {
|
|
2478
|
+
assertUrl(value);
|
|
2479
|
+
updateGlobalConfig({ consoleUrl: value.replace(/\/+$/, "") });
|
|
2480
|
+
} else {
|
|
2481
|
+
throw new CliError(`Unknown setting "${key}".`, { hint: "Settings: api-url, console-url.", exitCode: EXIT.USAGE });
|
|
2482
|
+
}
|
|
2483
|
+
emit({ ok: true, key: normalised, value }, () => log.success(`${normalised} = ${value}`));
|
|
2484
|
+
});
|
|
2485
|
+
config.command("unset <key>").description("Remove a setting and fall back to the default").action(async (key) => {
|
|
2486
|
+
const stored = readGlobalConfig();
|
|
2487
|
+
const normalised = key.toLowerCase().replace(/_/g, "-");
|
|
2488
|
+
if (normalised === "api-url") delete stored.apiUrl;
|
|
2489
|
+
else if (normalised === "console-url") delete stored.consoleUrl;
|
|
2490
|
+
else if (normalised === "default-org" || normalised === "org") {
|
|
2491
|
+
delete stored.defaultOrganisationId;
|
|
2492
|
+
delete stored.defaultOrganisationName;
|
|
2493
|
+
} else throw new CliError(`Unknown setting "${key}".`, { hint: "Settings: api-url, console-url, default-org.", exitCode: EXIT.USAGE });
|
|
2494
|
+
writeGlobalConfig(stored);
|
|
2495
|
+
emit({ ok: true, key: normalised }, () => log.success(`${normalised} cleared.`));
|
|
2496
|
+
});
|
|
2497
|
+
program2.command("unlink").description("Remove the .lightcloud link file from this folder").action(async () => {
|
|
2498
|
+
const project = findProjectConfig();
|
|
2499
|
+
if (!project) {
|
|
2500
|
+
emit({ ok: true, removed: false }, () => log.info("This folder is not linked to an app."));
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
deleteProjectConfig(project.directory);
|
|
2504
|
+
emit({ ok: true, removed: true, path: project.path }, () => log.success(`Removed ${c.dim(project.path)}. The app itself is untouched.`));
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
function assertUrl(value) {
|
|
2508
|
+
try {
|
|
2509
|
+
const url = new URL(value);
|
|
2510
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
2511
|
+
} catch {
|
|
2512
|
+
throw new CliError(`"${value}" is not an http(s) URL.`, { exitCode: EXIT.USAGE });
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// src/commands/dbs.ts
|
|
2517
|
+
import * as fs5 from "fs";
|
|
2518
|
+
import * as path5 from "path";
|
|
2519
|
+
import { Readable, Transform } from "stream";
|
|
2520
|
+
import { pipeline } from "stream/promises";
|
|
2521
|
+
function registerDbCommands(program2) {
|
|
2522
|
+
program2.command("dbs").alias("databases").description("List databases in the workspace").action(async (_options, command) => {
|
|
2523
|
+
const ctx = contextFrom(command);
|
|
2524
|
+
const org = await ctx.resolveOrg();
|
|
2525
|
+
const dbs = await ctx.api.listDatabases(org.id);
|
|
2526
|
+
emit(dbs, () => {
|
|
2527
|
+
if (dbs.length === 0) {
|
|
2528
|
+
log.info(`No databases yet. Create one with ${c.bold("lc db create")}.`);
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
heading(`${org.name ?? "Workspace"} ${c.dim(`\xB7 ${dbs.length} database${dbs.length === 1 ? "" : "s"}`)}`);
|
|
2532
|
+
out();
|
|
2533
|
+
printDbsTable(dbs);
|
|
2534
|
+
});
|
|
2535
|
+
});
|
|
2536
|
+
const db = program2.command("db").description("Manage one database");
|
|
2537
|
+
db.command("get [name]").alias("show").description("Show a database").action(async (ref, _options, command) => {
|
|
2538
|
+
const ctx = contextFrom(command);
|
|
2539
|
+
const database = await ctx.resolveDb(ref);
|
|
2540
|
+
emit(database, () => printDb(database, ctx.dbConsoleUrl(database)));
|
|
2541
|
+
});
|
|
2542
|
+
db.command("create [name]").description("Create a database").option("--engine <type>", "postgresql or mysql").option("--tier <tier>", "machine tier id (see `lc db tiers`)").option("--region <region>", "region id").option("--storage <gb>", "storage in GB").option("--ha", "high availability").option("--no-watch", "do not wait for provisioning").action(async (name, options, command) => {
|
|
2543
|
+
const ctx = contextFrom(command);
|
|
2544
|
+
const org = await ctx.resolveOrg();
|
|
2545
|
+
intro2(`${brand()} database`);
|
|
2546
|
+
const config = await ctx.api.platformConfig().catch(() => null);
|
|
2547
|
+
const types = (config?.database?.types ?? []).filter((t) => t.available);
|
|
2548
|
+
const tiers = (config?.database?.machineTypes ?? []).filter((t) => t.available);
|
|
2549
|
+
const regions = (config?.database?.regions ?? []).filter((r) => r.available);
|
|
2550
|
+
const chosenName = name ?? await text2("Database name", { flag: "<name>", placeholder: "my-app-db", validate: (v) => v.trim().length < 2 ? "At least 2 characters." : void 0 });
|
|
2551
|
+
const engine = options.engine ?? (types.length ? await select2("Engine", types.map((t) => ({ value: t.id, label: t.label })), { flag: "--engine <type>", initialValue: "postgresql" }) : "postgresql");
|
|
2552
|
+
const tier = options.tier ?? (tiers.length ? await select2(
|
|
2553
|
+
"Tier",
|
|
2554
|
+
tiers.map((t) => ({ value: t.id, label: t.label, hint: [t.ram, t.price != null ? `$${t.price}/mo` : void 0, t.isSharedPool ? "shared pool" : void 0].filter(Boolean).join(" \xB7 ") })),
|
|
2555
|
+
{ flag: "--tier <tier>" }
|
|
2556
|
+
) : void 0);
|
|
2557
|
+
const tierEntry = tiers.find((t) => t.id === tier);
|
|
2558
|
+
const regionChoices = tierEntry?.isSharedPool && config?.database?.sharedPoolRegions?.length ? regions.filter((r) => config.database.sharedPoolRegions.includes(r.id)) : regions;
|
|
2559
|
+
const region = options.region ?? (regionChoices.length ? await select2("Region", regionChoices.map((r) => ({ value: r.id, label: r.label })), { flag: "--region <region>" }) : void 0);
|
|
2560
|
+
const spin = spinner2();
|
|
2561
|
+
spin.start(`Creating ${chosenName}`);
|
|
2562
|
+
const created = await ctx.api.createDatabase({
|
|
2563
|
+
targetOrganisationId: org.id,
|
|
2564
|
+
name: chosenName,
|
|
2565
|
+
databaseType: engine,
|
|
2566
|
+
tier,
|
|
2567
|
+
region,
|
|
2568
|
+
storageGb: options.storage ? Number(options.storage) : void 0,
|
|
2569
|
+
haEnabled: options.ha
|
|
2570
|
+
});
|
|
2571
|
+
spin.stop(`Created ${c.bold(created.name)} ${c.dim(created.id)}`);
|
|
2572
|
+
let final = created;
|
|
2573
|
+
if (options.watch) {
|
|
2574
|
+
const result = await watchResource(ctx.api, { kind: "database", id: created.id, organisationId: org.id });
|
|
2575
|
+
if (!result.ok) {
|
|
2576
|
+
throw new CliError(result.update.deployment_error || "Provisioning failed.", { hint: `Console: ${ctx.dbConsoleUrl(created)}`, exitCode: EXIT.FAILED });
|
|
2577
|
+
}
|
|
2578
|
+
final = await ctx.api.getDatabase(org.id, created.id);
|
|
2579
|
+
}
|
|
2580
|
+
emit(final, () => outro2(options.watch ? `Connection details: ${c.bold(`lc db url ${final.name}`)}` : `Provisioning in the background; check with ${c.bold(`lc db get ${final.name}`)}.`));
|
|
2581
|
+
});
|
|
2582
|
+
db.command("tiers").description("List database tiers, engines and regions").action(async (_options, command) => {
|
|
2583
|
+
const ctx = contextFrom(command);
|
|
2584
|
+
const config = await ctx.api.platformConfig();
|
|
2585
|
+
const database = config.database ?? {};
|
|
2586
|
+
emit(database, () => {
|
|
2587
|
+
out(c.bold("Engines"));
|
|
2588
|
+
for (const t of database.types ?? []) out(` ${t.id.padEnd(12)} ${t.label}${t.available ? "" : c.dim(" (unavailable)")}`);
|
|
2589
|
+
out();
|
|
2590
|
+
out(c.bold("Tiers"));
|
|
2591
|
+
for (const t of database.machineTypes ?? []) {
|
|
2592
|
+
out(` ${t.id.padEnd(16)} ${String(t.label).padEnd(10)} ${c.dim([t.ram, t.vCPUs ? `${t.vCPUs} vCPU` : void 0, t.price != null ? `$${t.price}/mo` : void 0, t.isSharedPool ? "shared pool" : void 0].filter(Boolean).join(" \xB7 "))}${t.available ? "" : c.dim(" (unavailable)")}`);
|
|
2593
|
+
}
|
|
2594
|
+
out();
|
|
2595
|
+
out(c.bold("Regions"));
|
|
2596
|
+
for (const r of database.regions ?? []) out(` ${r.id.padEnd(20)} ${r.label}${r.available ? "" : c.dim(" (unavailable)")}`);
|
|
2597
|
+
});
|
|
2598
|
+
});
|
|
2599
|
+
db.command("delete <name>").alias("rm").description("Delete a database and all its data").action(async (ref, _options, command) => {
|
|
2600
|
+
const ctx = contextFrom(command);
|
|
2601
|
+
const database = await ctx.resolveDb(ref);
|
|
2602
|
+
const org = await ctx.resolveOrg();
|
|
2603
|
+
const ok = await confirm2(`Delete ${c.bold(database.name)} and ALL its data? This cannot be undone.`, { yes: ctx.yes });
|
|
2604
|
+
if (!ok) return;
|
|
2605
|
+
await ctx.api.deleteDatabase(org.id, database.id);
|
|
2606
|
+
emit({ ok: true, id: database.id, name: database.name }, () => log.success(`Deleting ${c.bold(database.name)}.`));
|
|
2607
|
+
});
|
|
2608
|
+
db.command("url [name]").alias("connection-string").description("Print the connection string (secret!)").option("--details", "print host, port, user and password separately").action(async (ref, options, command) => {
|
|
2609
|
+
const ctx = contextFrom(command);
|
|
2610
|
+
const database = await ctx.resolveDb(ref);
|
|
2611
|
+
const org = await ctx.resolveOrg();
|
|
2612
|
+
const details = await ctx.api.connectionDetails(org.id, database.id);
|
|
2613
|
+
emit(details, () => {
|
|
2614
|
+
if (options.details) {
|
|
2615
|
+
printDetails([
|
|
2616
|
+
["Host", details.host],
|
|
2617
|
+
["Port", String(details.port)],
|
|
2618
|
+
["Database", details.database],
|
|
2619
|
+
["User", details.user],
|
|
2620
|
+
["Password", details.password],
|
|
2621
|
+
["SSL", details.sslMode ?? void 0]
|
|
2622
|
+
]);
|
|
2623
|
+
} else {
|
|
2624
|
+
process.stdout.write(details.connectionString + "\n");
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
2628
|
+
db.command("rotate-password [name]").description("Generate a new admin password (apps using the old one must be updated)").option("--password <value>", "use this password instead of a generated one").action(async (ref, options, command) => {
|
|
2629
|
+
const ctx = contextFrom(command);
|
|
2630
|
+
const database = await ctx.resolveDb(ref);
|
|
2631
|
+
const org = await ctx.resolveOrg();
|
|
2632
|
+
const ok = await confirm2(`Rotate the admin password of ${c.bold(database.name)}? Existing connections using the old password will break.`, { yes: ctx.yes });
|
|
2633
|
+
if (!ok) return;
|
|
2634
|
+
const result = await ctx.api.rotatePassword(org.id, database.id, options.password);
|
|
2635
|
+
emit(result, () => {
|
|
2636
|
+
log.success("Password rotated.");
|
|
2637
|
+
out(` ${c.bold("New password")} ${result.password}`);
|
|
2638
|
+
out(c.dim(" Update DATABASE_URL on every app that uses this database (lc env vars set \u2026)."));
|
|
2639
|
+
});
|
|
2640
|
+
});
|
|
2641
|
+
db.command("dump [name]").description("Download a compressed SQL dump").option("-o, --output <file>", "file to write (default: <name>-<date>.sql.gz)").action(async (ref, options, command) => {
|
|
2642
|
+
const ctx = contextFrom(command);
|
|
2643
|
+
const database = await ctx.resolveDb(ref);
|
|
2644
|
+
const org = await ctx.resolveOrg();
|
|
2645
|
+
const response = await ctx.api.dumpDatabase(org.id, database.id);
|
|
2646
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
2647
|
+
const suggested = disposition.match(/filename="([^"]+)"/)?.[1] ?? `${database.slug || database.name}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.sql.gz`;
|
|
2648
|
+
const target = path5.resolve(options.output ?? suggested);
|
|
2649
|
+
if (!response.body) throw new CliError("The API returned an empty dump.", { exitCode: EXIT.FAILED });
|
|
2650
|
+
const spin = spinner2();
|
|
2651
|
+
spin.start(`Downloading dump of ${database.name}`);
|
|
2652
|
+
let bytes = 0;
|
|
2653
|
+
const counter = new Transform({
|
|
2654
|
+
transform(chunk, _encoding, callback) {
|
|
2655
|
+
bytes += chunk.length;
|
|
2656
|
+
if (bytes % (5 * 1024 * 1024) < chunk.length) spin.message(`Downloading dump of ${database.name} ${c.dim(formatBytes(bytes))}`);
|
|
2657
|
+
callback(null, chunk);
|
|
2658
|
+
}
|
|
2659
|
+
});
|
|
2660
|
+
await pipeline(Readable.fromWeb(response.body), counter, fs5.createWriteStream(target));
|
|
2661
|
+
spin.stop(`Saved ${c.bold(target)} ${c.dim(`(${formatBytes(bytes)})`)}`);
|
|
2662
|
+
emit({ ok: true, file: target, bytes }, () => void 0);
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
function printDb(db, consoleUrl) {
|
|
2666
|
+
heading(`${db.name} ${statusBadge(db.status)}`);
|
|
2667
|
+
printDetails([
|
|
2668
|
+
["Engine", `${db.database_type}${db.engine ? c.dim(` ${db.engine}`) : ""}`],
|
|
2669
|
+
["Tier", `${db.tier} ${c.dim(sym.bullet)} ${db.storage_gb} GB${db.ha_enabled ? ` ${c.dim(sym.bullet)} HA` : ""}`],
|
|
2670
|
+
["Region", db.region],
|
|
2671
|
+
["Host", db.connection_hostname || db.connection_host || void 0],
|
|
2672
|
+
["Port", db.connection_port != null ? String(db.connection_port) : void 0],
|
|
2673
|
+
["Database", db.database_name ?? void 0],
|
|
2674
|
+
["User", db.admin_user ? maskSecret(db.admin_user) : void 0],
|
|
2675
|
+
["Ready", relativeTime(db.ready_at)],
|
|
2676
|
+
["Console", c.dim(consoleUrl)],
|
|
2677
|
+
["ID", c.dim(db.id)]
|
|
2678
|
+
]);
|
|
2679
|
+
if (db.status === "failed" && db.deployment_error) {
|
|
2680
|
+
out();
|
|
2681
|
+
out(` ${c.red(sym.fail)} ${db.deployment_error}`);
|
|
2682
|
+
}
|
|
2683
|
+
if (!isJson() && db.status === "ready") {
|
|
2684
|
+
out();
|
|
2685
|
+
out(c.dim(` lc db url ${db.name} for the connection string`));
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// src/commands/deploy.ts
|
|
2690
|
+
import * as path8 from "path";
|
|
2691
|
+
|
|
2692
|
+
// src/lib/detection/detect.ts
|
|
2693
|
+
import * as fs6 from "fs";
|
|
2694
|
+
import * as path6 from "path";
|
|
2695
|
+
|
|
2696
|
+
// src/lib/detection/catalogue.ts
|
|
2697
|
+
var FRAMEWORKS = [
|
|
2698
|
+
{ id: "nextjs", label: "Next.js", category: "fullstack", runtime: "nodejs", deploymentType: "container", buildScript: "build", outputDirectory: "out", defaultPort: 3e3, available: true, detection: { npmDeps: ["next"], priority: 100 } },
|
|
2699
|
+
{ id: "nuxt", label: "Nuxt", category: "fullstack", runtime: "nodejs", deploymentType: "container", buildScript: "build", outputDirectory: ".output/public", defaultPort: 3e3, available: true, detection: { npmDeps: ["nuxt", "nuxt3"], priority: 100 } },
|
|
2700
|
+
{ id: "sveltekit", label: "SvelteKit", category: "fullstack", runtime: "nodejs", deploymentType: "container", buildScript: "build", outputDirectory: "build", defaultPort: 3e3, available: true, detection: { npmDeps: ["@sveltejs/kit"], priority: 100 } },
|
|
2701
|
+
{ id: "remix", label: "Remix", category: "fullstack", runtime: "nodejs", deploymentType: "container", buildScript: "build", defaultPort: 3e3, available: true, detection: { npmDeps: ["@remix-run/dev", "@remix-run/node", "@remix-run/serve"], priority: 100 } },
|
|
2702
|
+
{ id: "astro", label: "Astro", category: "fullstack", runtime: "nodejs", deploymentType: "static", buildScript: "build", outputDirectory: "dist", defaultPort: 4321, available: true, detection: { npmDeps: ["astro"], priority: 100 } },
|
|
2703
|
+
{ id: "django", label: "Django", category: "fullstack", runtime: "python", deploymentType: "container", defaultPort: 8e3, available: true, detection: { rootFiles: ["manage.py"], manifest: { file: "requirements.txt", needles: ["django"] }, priority: 90 } },
|
|
2704
|
+
{ id: "rails", label: "Ruby on Rails", category: "fullstack", runtime: "ruby", deploymentType: "container", defaultPort: 3e3, available: true, detection: { manifest: { file: "Gemfile", needles: ["rails"] }, priority: 90 } },
|
|
2705
|
+
{ id: "laravel", label: "Laravel", category: "fullstack", runtime: "php", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["artisan"], manifest: { file: "composer.json", needles: ["laravel/framework"] }, priority: 90 } },
|
|
2706
|
+
{ id: "symfony", label: "Symfony", category: "fullstack", runtime: "php", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "composer.json", needles: ["symfony/framework-bundle", "symfony/symfony"] }, priority: 90 } },
|
|
2707
|
+
{ id: "wordpress", label: "WordPress", category: "fullstack", runtime: "php", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["wp-config.php", "wp-config-sample.php", "wp-load.php", "wp-settings.php", "wp-content", "wp-includes", "wp-admin"], manifest: { file: "composer.json", needles: ["johnpbloch/wordpress", "roots/wordpress", "wpackagist-"] }, priority: 95 } },
|
|
2708
|
+
{ id: "blazor", label: "Blazor", category: "fullstack", runtime: "dotnet", deploymentType: "container", defaultPort: 8080, available: true },
|
|
2709
|
+
{ id: "wasp", label: "Wasp", category: "fullstack", runtime: "nodejs", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: [".wasproot", "main.wasp", "main.wasp.ts"], npmDeps: ["wasp", "@wasp.sh/spec"], priority: 110 } },
|
|
2710
|
+
{ id: "react", label: "React", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "dist", available: true, detection: { npmDeps: ["react"], priority: 50 } },
|
|
2711
|
+
{ id: "vue", label: "Vue", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "dist", available: true, detection: { npmDeps: ["vue"], priority: 60 } },
|
|
2712
|
+
{ id: "angular", label: "Angular", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "dist/browser", available: true, detection: { npmDeps: ["@angular/core"], priority: 70 } },
|
|
2713
|
+
{ id: "svelte", label: "Svelte", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "dist", available: true, detection: { npmDeps: ["svelte"], priority: 60 } },
|
|
2714
|
+
{ id: "solid", label: "SolidJS", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "dist", available: true, detection: { npmDeps: ["solid-js"], priority: 70 } },
|
|
2715
|
+
{ id: "qwik", label: "Qwik", category: "frontend", runtime: "nodejs", deploymentType: "static", buildScript: "build", outputDirectory: "dist", defaultPort: 3e3, available: true, detection: { npmDeps: ["@builder.io/qwik"], priority: 70 } },
|
|
2716
|
+
{ id: "nestjs", label: "NestJS", category: "backend", runtime: "nodejs", deploymentType: "container", buildScript: "build", defaultPort: 3e3, available: true, detection: { npmProdDeps: ["@nestjs/core"], priority: 90 } },
|
|
2717
|
+
{ id: "adonisjs", label: "AdonisJS", category: "backend", runtime: "nodejs", deploymentType: "container", buildScript: "build", defaultPort: 3333, available: true, detection: { npmProdDeps: ["@adonisjs/core"], priority: 90 } },
|
|
2718
|
+
{ id: "hono", label: "Hono", category: "backend", runtime: "nodejs", deploymentType: "container", defaultPort: 3e3, available: true, detection: { npmProdDeps: ["hono"], priority: 85 } },
|
|
2719
|
+
{ id: "fastify", label: "Fastify", category: "backend", runtime: "nodejs", deploymentType: "container", defaultPort: 3e3, available: true, detection: { npmProdDeps: ["fastify"], priority: 80 } },
|
|
2720
|
+
{ id: "express", label: "Express", category: "backend", runtime: "nodejs", deploymentType: "container", defaultPort: 8080, available: true, detection: { npmProdDeps: ["express", "koa", "@hapi/hapi", "hapi"], priority: 80 } },
|
|
2721
|
+
{ id: "nodejs", label: "Node.js", category: "backend", runtime: "nodejs", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["package.json"], priority: 10 } },
|
|
2722
|
+
{ id: "flask", label: "Flask", category: "backend", runtime: "python", deploymentType: "container", defaultPort: 8e3, available: true, detection: { manifest: { file: "requirements.txt", needles: ["flask"] }, priority: 85 } },
|
|
2723
|
+
{ id: "fastapi", label: "FastAPI", category: "backend", runtime: "python", deploymentType: "container", defaultPort: 8e3, available: true, detection: { manifest: { file: "requirements.txt", needles: ["fastapi"] }, priority: 86 } },
|
|
2724
|
+
{ id: "python", label: "Python", category: "backend", runtime: "python", deploymentType: "container", defaultPort: 8e3, available: true, detection: { rootFiles: ["requirements.txt", "pyproject.toml", "setup.py"], priority: 10 } },
|
|
2725
|
+
{ id: "gin", label: "Gin", category: "backend", runtime: "go", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "go.mod", needles: ["github.com/gin-gonic/gin"] }, priority: 85 } },
|
|
2726
|
+
{ id: "echo", label: "Echo", category: "backend", runtime: "go", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "go.mod", needles: ["github.com/labstack/echo"] }, priority: 85 } },
|
|
2727
|
+
{ id: "fiber", label: "Fiber", category: "backend", runtime: "go", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "go.mod", needles: ["github.com/gofiber/fiber"] }, priority: 85 } },
|
|
2728
|
+
{ id: "go", label: "Go", category: "backend", runtime: "go", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["go.mod"], priority: 10 } },
|
|
2729
|
+
{ id: "springboot", label: "Spring Boot", category: "backend", runtime: "java", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "pom.xml", needles: ["spring-boot"] }, priority: 85 } },
|
|
2730
|
+
{ id: "quarkus", label: "Quarkus", category: "backend", runtime: "java", deploymentType: "container", defaultPort: 8080, available: true, detection: { manifest: { file: "pom.xml", needles: ["quarkus"] }, priority: 85 } },
|
|
2731
|
+
{ id: "java", label: "Java", category: "backend", runtime: "java", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["pom.xml", "build.gradle", "build.gradle.kts"], priority: 10 } },
|
|
2732
|
+
{ id: "sinatra", label: "Sinatra", category: "backend", runtime: "ruby", deploymentType: "container", defaultPort: 3e3, available: true, detection: { manifest: { file: "Gemfile", needles: ["sinatra"] }, priority: 85 } },
|
|
2733
|
+
{ id: "ruby", label: "Ruby", category: "backend", runtime: "ruby", deploymentType: "container", defaultPort: 3e3, available: true, detection: { rootFiles: ["Gemfile", "config.ru"], priority: 10 } },
|
|
2734
|
+
{ id: "aspnet", label: "ASP.NET", category: "backend", runtime: "dotnet", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFileExtensions: [".csproj", ".sln", ".fsproj"], priority: 10 } },
|
|
2735
|
+
{ id: "php", label: "PHP", category: "backend", runtime: "php", deploymentType: "container", defaultPort: 8080, available: true, detection: { rootFiles: ["composer.json", "index.php"], priority: 10 } },
|
|
2736
|
+
{ id: "gatsby", label: "Gatsby", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "public", available: true, detection: { npmDeps: ["gatsby"], priority: 95 } },
|
|
2737
|
+
{ id: "docusaurus", label: "Docusaurus", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "build", available: true, detection: { npmDeps: ["@docusaurus/core"], priority: 95 } },
|
|
2738
|
+
{ id: "eleventy", label: "Eleventy", category: "frontend", runtime: null, deploymentType: "static", buildScript: "build", outputDirectory: "_site", available: true, detection: { npmDeps: ["@11ty/eleventy"], rootFiles: [".eleventy.js", "eleventy.config.js"], priority: 95 } },
|
|
2739
|
+
{ id: "html", label: "Static HTML", category: "frontend", runtime: null, deploymentType: "static", outputDirectory: ".", available: true, detection: { rootFiles: ["index.html"], priority: 5 } },
|
|
2740
|
+
{ id: "hugo", label: "Hugo", category: "frontend", runtime: null, deploymentType: "static", outputDirectory: "public", available: false, detection: { rootFiles: ["hugo.toml", "hugo.yaml", "hugo.json", "config.toml"], priority: 95 } },
|
|
2741
|
+
{ id: "jekyll", label: "Jekyll", category: "frontend", runtime: null, deploymentType: "static", outputDirectory: "_site", available: false, detection: { rootFiles: ["_config.yml"], priority: 95 } },
|
|
2742
|
+
{ id: "custom", label: "Dockerfile", category: "backend", runtime: "custom", deploymentType: "container", defaultPort: 8080, available: true }
|
|
2743
|
+
];
|
|
2744
|
+
function getFrameworkById(id) {
|
|
2745
|
+
return FRAMEWORKS.find((framework) => framework.id === id);
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// src/lib/detection/detect.ts
|
|
2749
|
+
var MANIFEST_FILES = [
|
|
2750
|
+
"package.json",
|
|
2751
|
+
"requirements.txt",
|
|
2752
|
+
"pyproject.toml",
|
|
2753
|
+
"go.mod",
|
|
2754
|
+
"pom.xml",
|
|
2755
|
+
"build.gradle",
|
|
2756
|
+
"Gemfile",
|
|
2757
|
+
"composer.json"
|
|
2758
|
+
];
|
|
2759
|
+
var ENV_FILES = [".env", ".env.local", ".env.example", ".env.development", ".env.production"];
|
|
2760
|
+
function readIfExists(file) {
|
|
2761
|
+
try {
|
|
2762
|
+
return fs6.readFileSync(file, "utf-8");
|
|
2763
|
+
} catch {
|
|
2764
|
+
return void 0;
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
function gatherSignals(directory) {
|
|
2768
|
+
let rootFiles = [];
|
|
2769
|
+
try {
|
|
2770
|
+
rootFiles = fs6.readdirSync(directory);
|
|
2771
|
+
} catch {
|
|
2772
|
+
rootFiles = [];
|
|
2773
|
+
}
|
|
2774
|
+
const manifests = {};
|
|
2775
|
+
for (const name of MANIFEST_FILES) {
|
|
2776
|
+
if (rootFiles.includes(name)) {
|
|
2777
|
+
const contents = readIfExists(path6.join(directory, name));
|
|
2778
|
+
if (contents !== void 0) manifests[name] = contents;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
if (!manifests["pom.xml"] && rootFiles.includes("build.gradle.kts")) {
|
|
2782
|
+
manifests["pom.xml"] = readIfExists(path6.join(directory, "build.gradle.kts"));
|
|
2783
|
+
}
|
|
2784
|
+
if (!manifests["pom.xml"] && manifests["build.gradle"]) manifests["pom.xml"] = manifests["build.gradle"];
|
|
2785
|
+
if (!manifests["requirements.txt"] && manifests["pyproject.toml"]) {
|
|
2786
|
+
manifests["requirements.txt"] = manifests["pyproject.toml"];
|
|
2787
|
+
}
|
|
2788
|
+
let packageJson = null;
|
|
2789
|
+
if (manifests["package.json"]) {
|
|
2790
|
+
try {
|
|
2791
|
+
packageJson = JSON.parse(manifests["package.json"]);
|
|
2792
|
+
} catch {
|
|
2793
|
+
packageJson = null;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
const prod = Object.keys(packageJson?.dependencies ?? {});
|
|
2797
|
+
const dev = Object.keys(packageJson?.devDependencies ?? {});
|
|
2798
|
+
return {
|
|
2799
|
+
rootFiles,
|
|
2800
|
+
npmDependencies: [.../* @__PURE__ */ new Set([...prod, ...dev])],
|
|
2801
|
+
npmProdDependencies: prod,
|
|
2802
|
+
manifests,
|
|
2803
|
+
packageJson
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
var WHOLE_NAME_MANIFESTS = /* @__PURE__ */ new Set(["Gemfile", "requirements.txt"]);
|
|
2807
|
+
function manifestNeedleMatches(file, haystack, needle) {
|
|
2808
|
+
const lowered = needle.toLowerCase();
|
|
2809
|
+
if (!WHOLE_NAME_MANIFESTS.has(file)) return haystack.includes(lowered);
|
|
2810
|
+
const escaped = lowered.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2811
|
+
return new RegExp(`(^|[^a-z0-9_-])${escaped}($|[^a-z0-9_-])`, "m").test(haystack);
|
|
2812
|
+
}
|
|
2813
|
+
function matchesDetection(detection, signals) {
|
|
2814
|
+
if (detection.npmDeps?.some((dep) => signals.npmDependencies.includes(dep))) return true;
|
|
2815
|
+
if (detection.npmProdDeps?.some((dep) => signals.npmProdDependencies.includes(dep))) return true;
|
|
2816
|
+
if (detection.rootFiles?.some((file) => signals.rootFiles.includes(file))) return true;
|
|
2817
|
+
if (detection.rootFileExtensions?.some((ext) => signals.rootFiles.some((file) => file.toLowerCase().endsWith(ext)))) {
|
|
2818
|
+
return true;
|
|
2819
|
+
}
|
|
2820
|
+
if (detection.manifest) {
|
|
2821
|
+
const contents = signals.manifests[detection.manifest.file];
|
|
2822
|
+
if (contents) {
|
|
2823
|
+
const haystack = contents.toLowerCase();
|
|
2824
|
+
if (detection.manifest.needles.some((needle) => manifestNeedleMatches(detection.manifest.file, haystack, needle))) {
|
|
2825
|
+
return true;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
return false;
|
|
2830
|
+
}
|
|
2831
|
+
function matchFramework(signals) {
|
|
2832
|
+
let best = null;
|
|
2833
|
+
let bestPriority = -1;
|
|
2834
|
+
for (const framework of FRAMEWORKS) {
|
|
2835
|
+
if (!framework.detection) continue;
|
|
2836
|
+
if (!matchesDetection(framework.detection, signals)) continue;
|
|
2837
|
+
if (framework.detection.priority > bestPriority) {
|
|
2838
|
+
best = framework;
|
|
2839
|
+
bestPriority = framework.detection.priority;
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return best;
|
|
2843
|
+
}
|
|
2844
|
+
function resolvePackageManager(rootFiles, packageJson) {
|
|
2845
|
+
const declared = packageJson?.packageManager?.split("@")[0];
|
|
2846
|
+
if (declared === "pnpm" || declared === "yarn" || declared === "bun" || declared === "npm") return declared;
|
|
2847
|
+
if (rootFiles.includes("pnpm-lock.yaml")) return "pnpm";
|
|
2848
|
+
if (rootFiles.includes("yarn.lock")) return "yarn";
|
|
2849
|
+
if (rootFiles.includes("bun.lockb") || rootFiles.includes("bun.lock")) return "bun";
|
|
2850
|
+
return "npm";
|
|
2851
|
+
}
|
|
2852
|
+
function installCommandFor(pm, rootFiles) {
|
|
2853
|
+
switch (pm) {
|
|
2854
|
+
case "pnpm":
|
|
2855
|
+
return "pnpm install --frozen-lockfile";
|
|
2856
|
+
case "yarn":
|
|
2857
|
+
return "yarn install --frozen-lockfile";
|
|
2858
|
+
case "bun":
|
|
2859
|
+
return "bun install";
|
|
2860
|
+
default:
|
|
2861
|
+
return rootFiles.includes("package-lock.json") ? "npm ci" : "npm install";
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
function runScript(pm, script) {
|
|
2865
|
+
if (pm === "npm" && script === "start") return "npm start";
|
|
2866
|
+
return `${pm} run ${script}`;
|
|
2867
|
+
}
|
|
2868
|
+
function readFirst(directory, candidates) {
|
|
2869
|
+
for (const candidate of candidates) {
|
|
2870
|
+
const contents = readIfExists(path6.join(directory, candidate));
|
|
2871
|
+
if (contents !== void 0) return contents;
|
|
2872
|
+
}
|
|
2873
|
+
return void 0;
|
|
2874
|
+
}
|
|
2875
|
+
function refineDeployment(framework, directory, signals) {
|
|
2876
|
+
const base = {
|
|
2877
|
+
deploymentType: framework.deploymentType,
|
|
2878
|
+
outputDirectory: framework.outputDirectory,
|
|
2879
|
+
containerPort: framework.defaultPort,
|
|
2880
|
+
notes: []
|
|
2881
|
+
};
|
|
2882
|
+
switch (framework.id) {
|
|
2883
|
+
case "nextjs": {
|
|
2884
|
+
const config = readFirst(directory, ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts", "next.config.cjs"]);
|
|
2885
|
+
const output = config?.match(/output\s*:\s*['"`](\w+)['"`]/)?.[1];
|
|
2886
|
+
const distDir = config?.match(/distDir\s*:\s*['"`]([^'"`]+)['"`]/)?.[1];
|
|
2887
|
+
if (output === "export") {
|
|
2888
|
+
return { deploymentType: "static", outputDirectory: distDir || "out", notes: ["output: export"] };
|
|
2889
|
+
}
|
|
2890
|
+
return {
|
|
2891
|
+
deploymentType: "container",
|
|
2892
|
+
containerPort: 3e3,
|
|
2893
|
+
notes: [output === "standalone" ? "output: standalone" : "SSR mode"]
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
case "nuxt": {
|
|
2897
|
+
const config = readFirst(directory, ["nuxt.config.ts", "nuxt.config.js", "nuxt.config.mjs"]);
|
|
2898
|
+
const prerendered = config ? /ssr\s*:\s*false|preset\s*:\s*['"`]static['"`]|prerender\s*:\s*\{[^}]*routes/.test(config) : false;
|
|
2899
|
+
if (prerendered) {
|
|
2900
|
+
return { deploymentType: "static", outputDirectory: ".output/public", notes: ["prerendered"] };
|
|
2901
|
+
}
|
|
2902
|
+
return { deploymentType: "container", containerPort: 3e3, notes: ["SSR mode"] };
|
|
2903
|
+
}
|
|
2904
|
+
case "astro": {
|
|
2905
|
+
const config = readFirst(directory, ["astro.config.mjs", "astro.config.ts", "astro.config.js", "astro.config.cjs"]);
|
|
2906
|
+
const serverOutput = config ? /output\s*:\s*['"`](server|hybrid)['"`]/.test(config) : false;
|
|
2907
|
+
const hasNodeAdapter = signals.npmDependencies.includes("@astrojs/node");
|
|
2908
|
+
if (serverOutput || hasNodeAdapter) {
|
|
2909
|
+
return { deploymentType: "container", containerPort: 4321, notes: ["server output"] };
|
|
2910
|
+
}
|
|
2911
|
+
return { deploymentType: "static", outputDirectory: "dist", notes: ["static output"] };
|
|
2912
|
+
}
|
|
2913
|
+
case "sveltekit": {
|
|
2914
|
+
if (signals.npmDependencies.includes("@sveltejs/adapter-static")) {
|
|
2915
|
+
return { deploymentType: "static", outputDirectory: "build", notes: ["adapter-static"] };
|
|
2916
|
+
}
|
|
2917
|
+
return { deploymentType: "container", containerPort: 3e3, notes: ["adapter-node"] };
|
|
2918
|
+
}
|
|
2919
|
+
case "react": {
|
|
2920
|
+
if (!signals.npmDependencies.includes("vite") && signals.npmDependencies.includes("react-scripts")) {
|
|
2921
|
+
return { ...base, outputDirectory: "build", notes: ["create-react-app"] };
|
|
2922
|
+
}
|
|
2923
|
+
return base;
|
|
2924
|
+
}
|
|
2925
|
+
case "angular": {
|
|
2926
|
+
const angularJson = readIfExists(path6.join(directory, "angular.json"));
|
|
2927
|
+
const outputPath = angularJson?.match(/"outputPath"\s*:\s*"([^"]+)"/)?.[1];
|
|
2928
|
+
if (outputPath) return { ...base, outputDirectory: outputPath.endsWith("/browser") ? outputPath : `${outputPath}/browser`, notes: [] };
|
|
2929
|
+
return base;
|
|
2930
|
+
}
|
|
2931
|
+
default:
|
|
2932
|
+
return base;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
function detectLocalProject(directory = process.cwd()) {
|
|
2936
|
+
const signals = gatherSignals(directory);
|
|
2937
|
+
const hasDockerfile = signals.rootFiles.includes("Dockerfile");
|
|
2938
|
+
const envFiles = ENV_FILES.filter((file) => signals.rootFiles.includes(file));
|
|
2939
|
+
const matched = matchFramework(signals);
|
|
2940
|
+
if (!matched) {
|
|
2941
|
+
if (hasDockerfile) {
|
|
2942
|
+
const custom = getFrameworkById("custom");
|
|
2943
|
+
return {
|
|
2944
|
+
framework: custom.id,
|
|
2945
|
+
frameworkLabel: custom.label,
|
|
2946
|
+
runtime: "custom",
|
|
2947
|
+
deploymentType: "container",
|
|
2948
|
+
containerPort: custom.defaultPort,
|
|
2949
|
+
hasDockerfile,
|
|
2950
|
+
envFiles,
|
|
2951
|
+
confidence: "medium",
|
|
2952
|
+
notes: ["Dockerfile at root"]
|
|
2953
|
+
};
|
|
2954
|
+
}
|
|
2955
|
+
return { deploymentType: "static", hasDockerfile, envFiles, confidence: "low", notes: [] };
|
|
2956
|
+
}
|
|
2957
|
+
const refinement = refineDeployment(matched, directory, signals);
|
|
2958
|
+
const reported = refinement.framework && getFrameworkById(refinement.framework) || matched;
|
|
2959
|
+
const isNode = reported.runtime === "nodejs" || reported.runtime === null;
|
|
2960
|
+
const scripts = signals.packageJson?.scripts ?? {};
|
|
2961
|
+
const result = {
|
|
2962
|
+
framework: reported.id,
|
|
2963
|
+
frameworkLabel: reported.label,
|
|
2964
|
+
runtime: reported.runtime ?? void 0,
|
|
2965
|
+
deploymentType: refinement.deploymentType,
|
|
2966
|
+
outputDirectory: refinement.deploymentType === "static" ? refinement.outputDirectory : void 0,
|
|
2967
|
+
containerPort: refinement.deploymentType === "container" ? refinement.containerPort : void 0,
|
|
2968
|
+
hasDockerfile,
|
|
2969
|
+
envFiles,
|
|
2970
|
+
nodeVersion: signals.packageJson?.engines?.node,
|
|
2971
|
+
confidence: matched.detection && matched.detection.priority >= 50 ? "high" : "medium",
|
|
2972
|
+
notes: [...refinement.notes]
|
|
2973
|
+
};
|
|
2974
|
+
if (isNode && signals.packageJson) {
|
|
2975
|
+
const pm = resolvePackageManager(signals.rootFiles, signals.packageJson);
|
|
2976
|
+
result.packageManager = pm;
|
|
2977
|
+
result.installCommand = installCommandFor(pm, signals.rootFiles);
|
|
2978
|
+
const buildScript = reported.buildScript && scripts[reported.buildScript] ? reported.buildScript : scripts.build ? "build" : void 0;
|
|
2979
|
+
if (buildScript) result.buildCommand = runScript(pm, buildScript);
|
|
2980
|
+
if (scripts.start && refinement.deploymentType === "container") result.startCommand = runScript(pm, "start");
|
|
2981
|
+
}
|
|
2982
|
+
if (result.deploymentType === "static" && !result.buildCommand && reported.id !== "html" && isNode) {
|
|
2983
|
+
result.notes.push("no build script found");
|
|
2984
|
+
}
|
|
2985
|
+
if (hasDockerfile && result.deploymentType === "container") {
|
|
2986
|
+
result.notes.push("Dockerfile at root");
|
|
2987
|
+
}
|
|
2988
|
+
return result;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
// src/lib/detection/git.ts
|
|
2992
|
+
import { execFileSync } from "child_process";
|
|
2993
|
+
import * as fs7 from "fs";
|
|
2994
|
+
import * as path7 from "path";
|
|
2995
|
+
function git(args, cwd) {
|
|
2996
|
+
try {
|
|
2997
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2998
|
+
} catch {
|
|
2999
|
+
return void 0;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
function detectGit(directory = process.cwd()) {
|
|
3003
|
+
const root = git(["rev-parse", "--show-toplevel"], directory);
|
|
3004
|
+
if (!root) {
|
|
3005
|
+
return { hasGit: fs7.existsSync(path7.join(directory, ".git")) };
|
|
3006
|
+
}
|
|
3007
|
+
const info = { hasGit: true, root };
|
|
3008
|
+
info.remoteUrl = git(["remote", "get-url", "origin"], directory) || void 0;
|
|
3009
|
+
if (info.remoteUrl) Object.assign(info, parseRemote(info.remoteUrl));
|
|
3010
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], directory);
|
|
3011
|
+
info.branch = branch && branch !== "HEAD" ? branch : void 0;
|
|
3012
|
+
info.commit = git(["rev-parse", "--short", "HEAD"], directory) || void 0;
|
|
3013
|
+
const status = git(["status", "--porcelain"], directory);
|
|
3014
|
+
info.isDirty = status === void 0 ? void 0 : status.length > 0;
|
|
3015
|
+
return info;
|
|
3016
|
+
}
|
|
3017
|
+
function parseRemote(url) {
|
|
3018
|
+
const match = url.match(/^(?:https?|ssh):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) || url.match(/^(?:[^@]+@)?([^:/]+):(.+?)(?:\.git)?\/?$/);
|
|
3019
|
+
if (!match) return {};
|
|
3020
|
+
const host = match[1].toLowerCase();
|
|
3021
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
3022
|
+
if (segments.length < 2) return { host };
|
|
3023
|
+
const repo = segments[segments.length - 1];
|
|
3024
|
+
const owner = segments.slice(0, -1).join("/");
|
|
3025
|
+
let provider;
|
|
3026
|
+
if (host === "github.com" || host.endsWith(".github.com")) provider = "github";
|
|
3027
|
+
else if (host === "gitlab.com" || host.includes("gitlab")) provider = "gitlab";
|
|
3028
|
+
else if (host === "bitbucket.org" || host.includes("bitbucket")) provider = "bitbucket";
|
|
3029
|
+
return { host, owner, repo, provider };
|
|
3030
|
+
}
|
|
3031
|
+
function webUrlFor(info) {
|
|
3032
|
+
if (!info.host || !info.owner || !info.repo) return void 0;
|
|
3033
|
+
return `https://${info.host}/${info.owner}/${info.repo}`;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// src/commands/deploy.ts
|
|
3037
|
+
function registerDeployCommand(program2) {
|
|
3038
|
+
program2.command("deploy [app]").description("Deploy an app (the linked app by default) and follow the build").option("-e, --env <env>", "environment to deploy (default: production)").option("-u, --upload", "upload the local folder as the source instead of pulling from git").option("-d, --dir <path>", "folder to upload (with --upload)", ".").option("--no-watch", "queue the deployment and return immediately").action(async (positional, options, command) => {
|
|
3039
|
+
const ctx = contextFrom(command);
|
|
3040
|
+
const app = await ctx.resolveApp(positional);
|
|
3041
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3042
|
+
const isUploadApp = app.git_provider === "upload" || app.github_repo_url?.startsWith("upload://");
|
|
3043
|
+
intro2(`${brand()} deploy`);
|
|
3044
|
+
if (!isJson()) {
|
|
3045
|
+
out(`${c.dim("\u2502")} ${c.bold(app.name)} ${c.dim(sym.arrow)} ${env.name}${env.is_production ? c.accent(` ${sym.star}`) : ""}`);
|
|
3046
|
+
}
|
|
3047
|
+
let uploadId;
|
|
3048
|
+
if (options.upload) {
|
|
3049
|
+
uploadId = await uploadForDeploy(ctx, options.dir);
|
|
3050
|
+
} else if (isUploadApp) {
|
|
3051
|
+
if (!isJson()) out(`${c.dim("\u2502")} Source: last upload ${c.dim("(pass --upload to send this folder)")}`);
|
|
3052
|
+
} else if (!isJson()) {
|
|
3053
|
+
out(`${c.dim("\u2502")} Source: ${sourceLabel(app)} ${c.dim(`@ ${env.github_branch}`)}`);
|
|
3054
|
+
}
|
|
3055
|
+
if (!isJson()) out(c.dim("\u2502"));
|
|
3056
|
+
const result = await runDeploy(ctx, { app, env, uploadId, watch: options.watch });
|
|
3057
|
+
const url = result.watched?.update.deployed_url || env.deployed_url;
|
|
3058
|
+
emit(
|
|
3059
|
+
{
|
|
3060
|
+
ok: true,
|
|
3061
|
+
application: { id: app.id, name: app.name },
|
|
3062
|
+
environment: { id: env.id, name: env.name },
|
|
3063
|
+
uploadId,
|
|
3064
|
+
status: result.watched?.status ?? "queued",
|
|
3065
|
+
url,
|
|
3066
|
+
durationMs: result.watched?.elapsedMs
|
|
3067
|
+
},
|
|
3068
|
+
() => {
|
|
3069
|
+
if (!options.watch) {
|
|
3070
|
+
outro2(`Queued. Follow it with ${c.bold(`lc status ${app.name}`)} or ${c.bold("lc logs -f")}.`);
|
|
3071
|
+
} else {
|
|
3072
|
+
outro2(url ? `Live at ${link(url)}` : "Done.");
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
);
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
async function uploadForDeploy(ctx, dir) {
|
|
3079
|
+
const directory = path8.resolve(dir);
|
|
3080
|
+
const detection = detectLocalProject(directory);
|
|
3081
|
+
const git2 = detectGit(directory);
|
|
3082
|
+
if (!isJson()) out(`${c.dim("\u2502")} Source: upload ${c.dim(directory)} ${describeDetection(detection)}`);
|
|
3083
|
+
warnIfDirty(git2.isDirty, git2.branch);
|
|
3084
|
+
if (!detection.framework && !detection.hasDockerfile) {
|
|
3085
|
+
throw new CliError(`Could not tell what kind of project ${directory} is.`, {
|
|
3086
|
+
hint: "Run from the project root (where package.json, requirements.txt, go.mod or a Dockerfile lives).",
|
|
3087
|
+
exitCode: EXIT.USAGE
|
|
3088
|
+
});
|
|
3089
|
+
}
|
|
3090
|
+
const upload = await uploadSource(ctx, directory, detection);
|
|
3091
|
+
log.step(c.dim(`upload ${upload.uploadId}`));
|
|
3092
|
+
return upload.uploadId;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// src/commands/deployments.ts
|
|
3096
|
+
function registerDeploymentCommands(program2) {
|
|
3097
|
+
program2.command("deployments [app]").alias("history").description("Deployment history of an environment").option("-e, --env <env>", "environment (default: production)").option("-n, --limit <n>", "how many to show (max 20)", "10").action(async (positional, options, command) => {
|
|
3098
|
+
const ctx = contextFrom(command);
|
|
3099
|
+
const app = await ctx.resolveApp(positional);
|
|
3100
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3101
|
+
const org = await ctx.resolveOrg();
|
|
3102
|
+
const page = await ctx.api.listDeployments(org.id, env.id, Math.min(20, Math.max(1, Number(options.limit) || 10)));
|
|
3103
|
+
emit(page, () => {
|
|
3104
|
+
heading(`${app.name} / ${env.name} ${c.dim(`\xB7 ${page.total} deployment${page.total === 1 ? "" : "s"}`)}`);
|
|
3105
|
+
out();
|
|
3106
|
+
if (page.deployments.length === 0) {
|
|
3107
|
+
log.info("No deployments yet.");
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
printDeploymentsTable(page.deployments);
|
|
3111
|
+
out();
|
|
3112
|
+
out(c.dim(` lc deployment <id> for the build log \xB7 lc rollback to go back`));
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
3115
|
+
program2.command("deployment <id>").description("Show one deployment with its build log").action(async (id, _options, command) => {
|
|
3116
|
+
const ctx = contextFrom(command);
|
|
3117
|
+
const org = await ctx.resolveOrg();
|
|
3118
|
+
const deployment = await ctx.api.getDeployment(org.id, id);
|
|
3119
|
+
emit(deployment, () => printDeployment(deployment));
|
|
3120
|
+
});
|
|
3121
|
+
program2.command("rollback [deployment]").description("Roll an environment back to an earlier deployment (no rebuild)").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").option("--no-watch", "do not wait for the rollback to finish").action(async (ref, options, command) => {
|
|
3122
|
+
const ctx = contextFrom(command);
|
|
3123
|
+
const app = await ctx.resolveApp(options.app);
|
|
3124
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3125
|
+
const org = await ctx.resolveOrg();
|
|
3126
|
+
const page = await ctx.api.listDeployments(org.id, env.id, 20);
|
|
3127
|
+
const eligible = page.deployments.filter((d) => d.rollback_eligible && !d.is_current);
|
|
3128
|
+
let target;
|
|
3129
|
+
if (ref) {
|
|
3130
|
+
target = page.deployments.find((d) => d.id === ref || d.id.startsWith(ref));
|
|
3131
|
+
if (!target) throw new CliError(`No deployment "${ref}" on ${app.name} / ${env.name}.`, { hint: "Run `lc deployments` to list them.", exitCode: EXIT.NOT_FOUND });
|
|
3132
|
+
if (!target.rollback_eligible) throw new CliError(`Deployment ${shortId(target.id, 10)} cannot be rolled back to.`, { hint: "Only successful deployments within the rollback window are eligible.", exitCode: EXIT.USAGE });
|
|
3133
|
+
if (target.is_current) throw new CliError("That deployment is already live.", { exitCode: EXIT.USAGE });
|
|
3134
|
+
} else {
|
|
3135
|
+
if (eligible.length === 0) throw new CliError(`Nothing to roll back to on ${app.name} / ${env.name}.`, { exitCode: EXIT.NOT_FOUND });
|
|
3136
|
+
const id = await select2(
|
|
3137
|
+
"Roll back to",
|
|
3138
|
+
eligible.map((d) => ({
|
|
3139
|
+
value: d.id,
|
|
3140
|
+
label: `${d.commit_sha ? d.commit_sha.slice(0, 7) : shortId(d.id, 10)} ${d.commit_message?.split("\n")[0] ?? ""}`.trim(),
|
|
3141
|
+
hint: `${relativeTime(d.started_at)} \xB7 ${d.deployed_by_name}`
|
|
3142
|
+
})),
|
|
3143
|
+
{ flag: "<deployment id>" }
|
|
3144
|
+
);
|
|
3145
|
+
target = eligible.find((d) => d.id === id);
|
|
3146
|
+
}
|
|
3147
|
+
const ok = await confirm2(
|
|
3148
|
+
`Roll ${c.bold(`${app.name} / ${env.name}`)} back to ${c.accent(target.commit_sha?.slice(0, 7) ?? shortId(target.id, 10))} ${c.dim(`(${relativeTime(target.started_at)})`)}?`,
|
|
3149
|
+
{ yes: ctx.yes, initialValue: true }
|
|
3150
|
+
);
|
|
3151
|
+
if (!ok) return;
|
|
3152
|
+
const result = await ctx.api.rollback(org.id, env.id, target.id);
|
|
3153
|
+
if (!isJson()) out(`${c.green(sym.ok)} Rollback queued ${c.dim(shortId(result.deployment?.id, 10))}`);
|
|
3154
|
+
let final = result;
|
|
3155
|
+
if (options.watch) {
|
|
3156
|
+
const watched = await watchResource(ctx.api, { kind: "environment", id: env.id, organisationId: org.id });
|
|
3157
|
+
if (!watched.ok) {
|
|
3158
|
+
throw new CliError(watched.update.deployment_error || "Rollback failed.", { exitCode: EXIT.FAILED });
|
|
3159
|
+
}
|
|
3160
|
+
final = { deployment: { ...result.deployment, status: watched.status, deployed_url: watched.update.deployed_url } };
|
|
3161
|
+
}
|
|
3162
|
+
emit({ ok: true, ...final }, () => void 0);
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
function printDeployment(deployment) {
|
|
3166
|
+
heading(`Deployment ${shortId(deployment.id, 10)} ${statusBadge(deployment.status)}`);
|
|
3167
|
+
printDetails([
|
|
3168
|
+
["App", deployment.application_name ? `${deployment.application_name} / ${deployment.environment_name ?? ""}` : void 0],
|
|
3169
|
+
["Commit", deployment.commit_sha ? `${c.accent(deployment.commit_sha.slice(0, 7))} ${deployment.commit_message?.split("\n")[0] ?? ""}${deployment.commit_author ? c.dim(` ${deployment.commit_author}`) : ""}` : void 0],
|
|
3170
|
+
["By", deployment.deployed_by_name],
|
|
3171
|
+
["Started", `${relativeTime(deployment.started_at)} ${c.dim(deployment.started_at)}`],
|
|
3172
|
+
["Duration", deployment.duration_seconds != null ? formatDuration(deployment.duration_seconds * 1e3) : void 0],
|
|
3173
|
+
["URL", deployment.deployed_url ? link(deployment.deployed_url) : void 0],
|
|
3174
|
+
["Rollback", deployment.is_current ? "current" : deployment.rollback_eligible ? "eligible" : "not eligible"],
|
|
3175
|
+
["Error", deployment.deployment_error ? c.red(deployment.deployment_error) : void 0]
|
|
3176
|
+
]);
|
|
3177
|
+
if (deployment.deployment_logs?.length) {
|
|
3178
|
+
out();
|
|
3179
|
+
const renderer = new StepRenderer(false);
|
|
3180
|
+
renderer.render(deployment.deployment_logs);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
// src/commands/domains.ts
|
|
3185
|
+
function registerDomainCommands(program2) {
|
|
3186
|
+
const domains = program2.command("domains").alias("domain").description("Custom domains for an environment");
|
|
3187
|
+
domains.command("show", { isDefault: true }).description("Show the custom domain and its DNS status").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (options, command) => {
|
|
3188
|
+
const ctx = contextFrom(command);
|
|
3189
|
+
const app = await ctx.resolveApp(options.app);
|
|
3190
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3191
|
+
const org = await ctx.resolveOrg();
|
|
3192
|
+
const full = await ctx.api.getEnvironment(org.id, env.id);
|
|
3193
|
+
emit({ domain: full.custom_domain, status: full.custom_domain_status, dns: full.custom_domain_dns, url: full.deployed_url }, () => {
|
|
3194
|
+
heading(`${app.name} / ${env.name}`);
|
|
3195
|
+
if (!full.custom_domain) {
|
|
3196
|
+
log.info(`No custom domain. Add one with ${c.bold("lc domains add example.com")}.`);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
printDetails([
|
|
3200
|
+
["Domain", `${c.bold(full.custom_domain)} ${statusBadge(full.custom_domain_status ?? "unknown")}`],
|
|
3201
|
+
["Serves", full.deployed_url ?? void 0]
|
|
3202
|
+
]);
|
|
3203
|
+
if (full.custom_domain_dns?.length) {
|
|
3204
|
+
out();
|
|
3205
|
+
printDns(full.custom_domain_dns);
|
|
3206
|
+
}
|
|
3207
|
+
});
|
|
3208
|
+
});
|
|
3209
|
+
domains.command("add <domain>").description("Attach a custom domain (prints the DNS records to create)").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (domain, options, command) => {
|
|
3210
|
+
const ctx = contextFrom(command);
|
|
3211
|
+
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
3212
|
+
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(clean)) throw new CliError(`"${domain}" is not a domain name.`, { exitCode: EXIT.USAGE });
|
|
3213
|
+
const app = await ctx.resolveApp(options.app);
|
|
3214
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3215
|
+
const org = await ctx.resolveOrg();
|
|
3216
|
+
const result = await ctx.api.addDomain(org.id, env.id, clean);
|
|
3217
|
+
emit(result, () => printDomainResult(result, clean));
|
|
3218
|
+
});
|
|
3219
|
+
domains.command("check").description("Re-check DNS for the custom domain").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (options, command) => {
|
|
3220
|
+
const ctx = contextFrom(command);
|
|
3221
|
+
const app = await ctx.resolveApp(options.app);
|
|
3222
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3223
|
+
const org = await ctx.resolveOrg();
|
|
3224
|
+
const result = await ctx.api.checkDomain(org.id, env.id);
|
|
3225
|
+
emit(result, () => printDomainResult(result, env.custom_domain ?? result.domain));
|
|
3226
|
+
});
|
|
3227
|
+
domains.command("retry").description("Retry certificate issuance after fixing DNS").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (options, command) => {
|
|
3228
|
+
const ctx = contextFrom(command);
|
|
3229
|
+
const app = await ctx.resolveApp(options.app);
|
|
3230
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3231
|
+
const org = await ctx.resolveOrg();
|
|
3232
|
+
const result = await ctx.api.retryDomain(org.id, env.id);
|
|
3233
|
+
emit(result, () => printDomainResult(result, env.custom_domain ?? result.domain));
|
|
3234
|
+
});
|
|
3235
|
+
domains.command("remove").alias("rm").description("Detach the custom domain").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (options, command) => {
|
|
3236
|
+
const ctx = contextFrom(command);
|
|
3237
|
+
const app = await ctx.resolveApp(options.app);
|
|
3238
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3239
|
+
const org = await ctx.resolveOrg();
|
|
3240
|
+
if (!env.custom_domain) throw new CliError(`${app.name} / ${env.name} has no custom domain.`, { exitCode: EXIT.NOT_FOUND });
|
|
3241
|
+
const ok = await confirm2(`Remove ${c.bold(env.custom_domain)} from ${app.name} / ${env.name}?`, { yes: ctx.yes });
|
|
3242
|
+
if (!ok) return;
|
|
3243
|
+
const result = await ctx.api.removeDomain(org.id, env.id);
|
|
3244
|
+
emit({ ok: true, ...result }, () => log.success(`Removed ${c.bold(env.custom_domain)}. The app stays reachable at its light-cloud.io address.`));
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
function printDomainResult(result, domain) {
|
|
3248
|
+
const status = result.status ?? "unknown";
|
|
3249
|
+
if (status === "active") log.success(`${c.bold(domain)} is active.`);
|
|
3250
|
+
else log.info(`${c.bold(domain)} ${statusBadge(status)}`);
|
|
3251
|
+
if (result.message && status !== "active") out(` ${c.dim(result.message)}`);
|
|
3252
|
+
for (const issue of result.issues ?? []) log.warn(` ${issue}`);
|
|
3253
|
+
if (result.dnsRecords?.length) {
|
|
3254
|
+
out();
|
|
3255
|
+
printDns(result.dnsRecords);
|
|
3256
|
+
out();
|
|
3257
|
+
out(c.dim(" Create these records at your DNS provider, then run `lc domains check`."));
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
function printDns(records) {
|
|
3261
|
+
printTable(records, [
|
|
3262
|
+
{ header: "Type", cell: (r) => c.bold(r.type) },
|
|
3263
|
+
{ header: "Name", cell: (r) => r.name },
|
|
3264
|
+
{ header: "Value", cell: (r) => r.value, maxWidth: 80 }
|
|
3265
|
+
]);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
// src/commands/envs.ts
|
|
3269
|
+
import * as fs8 from "fs";
|
|
3270
|
+
function registerEnvCommands(program2) {
|
|
3271
|
+
program2.command("envs [app]").description("List environments of an app").action(async (positional, _options, command) => {
|
|
3272
|
+
const ctx = contextFrom(command);
|
|
3273
|
+
const app = await ctx.resolveApp(positional);
|
|
3274
|
+
const org = await ctx.resolveOrg();
|
|
3275
|
+
const envs = await ctx.api.listEnvironments(org.id, app.id);
|
|
3276
|
+
emit(envs, () => {
|
|
3277
|
+
heading(`${app.name} ${c.dim(`\xB7 ${envs.length} environment${envs.length === 1 ? "" : "s"}`)}`);
|
|
3278
|
+
out();
|
|
3279
|
+
printEnvsTable(envs);
|
|
3280
|
+
});
|
|
3281
|
+
});
|
|
3282
|
+
const env = program2.command("env").description("Manage one environment (create, delete, scale, vars)");
|
|
3283
|
+
env.command("get [env]").description("Show one environment in detail").option("-a, --app <app>", "app name or id").action(async (ref, options, command) => {
|
|
3284
|
+
const ctx = contextFrom(command);
|
|
3285
|
+
const app = await ctx.resolveApp(options.app);
|
|
3286
|
+
const target = await ctx.resolveEnv(app, ref);
|
|
3287
|
+
const org = await ctx.resolveOrg();
|
|
3288
|
+
const full = await ctx.api.getEnvironment(org.id, target.id);
|
|
3289
|
+
emit(full, () => printEnvDetails(full));
|
|
3290
|
+
});
|
|
3291
|
+
env.command("create <name>").description("Create an environment from a branch").option("-a, --app <app>", "app name or id").option("-b, --branch <branch>", "git branch to deploy (default: the app branch)").option("--production", "mark it as the production environment").option("--no-auto-deploy", "do not deploy automatically on push").option("--deploy", "deploy right after creating").option("--memory <size>", "container memory, e.g. 512Mi").option("--min <n>", "minimum instances").option("--max <n>", "maximum instances").action(async (name, options, command) => {
|
|
3292
|
+
const ctx = contextFrom(command);
|
|
3293
|
+
const app = await ctx.resolveApp(options.app);
|
|
3294
|
+
const org = await ctx.resolveOrg();
|
|
3295
|
+
const created = await ctx.api.createEnvironment({
|
|
3296
|
+
targetOrganisationId: org.id,
|
|
3297
|
+
applicationId: app.id,
|
|
3298
|
+
name,
|
|
3299
|
+
githubBranch: options.branch ?? app.github_branch,
|
|
3300
|
+
isProduction: options.production,
|
|
3301
|
+
autoDeploy: options.autoDeploy,
|
|
3302
|
+
memory: options.memory,
|
|
3303
|
+
minInstances: options.min !== void 0 ? Number(options.min) : void 0,
|
|
3304
|
+
maxInstances: options.max !== void 0 ? Number(options.max) : void 0
|
|
3305
|
+
});
|
|
3306
|
+
log.success(`Created ${c.bold(created.name)} ${c.dim(`(${created.github_branch})`)} on ${app.name}.`);
|
|
3307
|
+
if (options.deploy) {
|
|
3308
|
+
await runDeploy(ctx, { app, env: created, watch: true });
|
|
3309
|
+
}
|
|
3310
|
+
emit(created, () => {
|
|
3311
|
+
if (!options.deploy) log.info(`Deploy it with ${c.bold(`lc deploy --env ${created.name}`)}.`);
|
|
3312
|
+
});
|
|
3313
|
+
});
|
|
3314
|
+
env.command("delete <env>").alias("rm").description("Delete an environment").option("-a, --app <app>", "app name or id").action(async (ref, options, command) => {
|
|
3315
|
+
const ctx = contextFrom(command);
|
|
3316
|
+
const app = await ctx.resolveApp(options.app);
|
|
3317
|
+
const target = await ctx.resolveEnv(app, ref);
|
|
3318
|
+
const org = await ctx.resolveOrg();
|
|
3319
|
+
if (target.is_production) {
|
|
3320
|
+
log.warn("This is the production environment.");
|
|
3321
|
+
}
|
|
3322
|
+
const ok = await confirm2(`Delete ${c.bold(`${app.name} / ${target.name}`)}? This cannot be undone.`, { yes: ctx.yes });
|
|
3323
|
+
if (!ok) return;
|
|
3324
|
+
await ctx.api.deleteEnvironment(org.id, target.id);
|
|
3325
|
+
emit({ ok: true, id: target.id, name: target.name }, () => log.success(`Deleting ${c.bold(target.name)}.`));
|
|
3326
|
+
});
|
|
3327
|
+
env.command("scale [env]").description("Set instance limits (container apps)").option("-a, --app <app>", "app name or id").option("--min <n>", "minimum instances (1 keeps it always on)").option("--max <n>", "maximum instances").action(async (ref, options, command) => {
|
|
3328
|
+
const ctx = contextFrom(command);
|
|
3329
|
+
if (options.min === void 0 && options.max === void 0) {
|
|
3330
|
+
throw new CliError("Nothing to change.", { hint: "Pass --min <n> and/or --max <n>.", exitCode: EXIT.USAGE });
|
|
3331
|
+
}
|
|
3332
|
+
const app = await ctx.resolveApp(options.app);
|
|
3333
|
+
const target = await ctx.resolveEnv(app, ref);
|
|
3334
|
+
const org = await ctx.resolveOrg();
|
|
3335
|
+
const result = await ctx.api.scaleEnvironment(org.id, target.id, {
|
|
3336
|
+
minInstances: options.min !== void 0 ? Number(options.min) : void 0,
|
|
3337
|
+
maxInstances: options.max !== void 0 ? Number(options.max) : void 0
|
|
3338
|
+
});
|
|
3339
|
+
emit(result, () => {
|
|
3340
|
+
const applied = result.applied ?? {};
|
|
3341
|
+
log.success(`${app.name} / ${target.name}: min ${applied.minInstances ?? result.environment?.min_instances ?? "\u2014"}, max ${applied.maxInstances ?? result.environment?.max_instances ?? "\u2014"}.`);
|
|
3342
|
+
if (result.clamped) log.warn("The minimum was clamped by your plan.");
|
|
3343
|
+
if (result.dedicated_addon_notice) log.info(result.dedicated_addon_notice);
|
|
3344
|
+
});
|
|
3345
|
+
});
|
|
3346
|
+
const vars = env.command("vars").description("Environment variables");
|
|
3347
|
+
vars.command("list", { isDefault: true }).description("List variables (values masked unless --reveal)").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").option("--reveal", "print values in clear").action(async (options, command) => {
|
|
3348
|
+
const ctx = contextFrom(command);
|
|
3349
|
+
const { target, values } = await loadVars(ctx, options);
|
|
3350
|
+
emit(values, () => {
|
|
3351
|
+
const keys = Object.keys(values).sort();
|
|
3352
|
+
if (keys.length === 0) {
|
|
3353
|
+
log.info(`No variables on ${target.name}. Add one with ${c.bold("lc env vars set KEY=value")}.`);
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
heading(`${target.name} ${c.dim(`\xB7 ${keys.length} variable${keys.length === 1 ? "" : "s"}`)}`);
|
|
3357
|
+
const width = Math.max(...keys.map((key) => key.length));
|
|
3358
|
+
for (const key of keys) {
|
|
3359
|
+
out(` ${c.bold(key.padEnd(width))} ${options.reveal ? values[key] : c.dim(maskSecret(values[key] ?? ""))}`);
|
|
3360
|
+
}
|
|
3361
|
+
if (!options.reveal) out(c.dim(" (values masked; --reveal to show)"));
|
|
3362
|
+
});
|
|
3363
|
+
});
|
|
3364
|
+
vars.command("set <pairs...>").description("Set variables: KEY=value [KEY2=value2 \u2026]").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").option("--redeploy", "deploy after saving so the change takes effect").action(async (pairs, options, command) => {
|
|
3365
|
+
const ctx = contextFrom(command);
|
|
3366
|
+
const updates = parsePairs(pairs);
|
|
3367
|
+
const { app, target, values } = await loadVars(ctx, options);
|
|
3368
|
+
const next = { ...values, ...updates };
|
|
3369
|
+
await saveVars(ctx, target, next);
|
|
3370
|
+
emit({ ok: true, set: Object.keys(updates), total: Object.keys(next).length }, () => {
|
|
3371
|
+
log.success(`Set ${Object.keys(updates).map((key) => c.bold(key)).join(", ")} on ${app.name} / ${target.name}.`);
|
|
3372
|
+
});
|
|
3373
|
+
await maybeRedeploy(ctx, app, target, options.redeploy);
|
|
3374
|
+
});
|
|
3375
|
+
vars.command("unset <keys...>").description("Remove variables").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").option("--redeploy", "deploy after saving").action(async (keys, options, command) => {
|
|
3376
|
+
const ctx = contextFrom(command);
|
|
3377
|
+
const { app, target, values } = await loadVars(ctx, options);
|
|
3378
|
+
const next = { ...values };
|
|
3379
|
+
const missing = keys.filter((key) => !(key in next));
|
|
3380
|
+
for (const key of keys) delete next[key];
|
|
3381
|
+
await saveVars(ctx, target, next);
|
|
3382
|
+
emit({ ok: true, unset: keys.filter((key) => key in values), missing }, () => {
|
|
3383
|
+
if (missing.length) log.warn(`Not set: ${missing.join(", ")}.`);
|
|
3384
|
+
const removed = keys.filter((key) => key in values);
|
|
3385
|
+
if (removed.length) log.success(`Removed ${removed.map((key) => c.bold(key)).join(", ")} from ${app.name} / ${target.name}.`);
|
|
3386
|
+
});
|
|
3387
|
+
await maybeRedeploy(ctx, app, target, options.redeploy);
|
|
3388
|
+
});
|
|
3389
|
+
vars.command("import <file>").description("Load variables from a .env file (merges over existing ones)").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").option("--replace", "replace all variables instead of merging").option("--redeploy", "deploy after saving").action(async (file, options, command) => {
|
|
3390
|
+
const ctx = contextFrom(command);
|
|
3391
|
+
let content;
|
|
3392
|
+
try {
|
|
3393
|
+
content = fs8.readFileSync(file, "utf-8");
|
|
3394
|
+
} catch {
|
|
3395
|
+
throw new CliError(`Cannot read ${file}.`, { exitCode: EXIT.USAGE });
|
|
3396
|
+
}
|
|
3397
|
+
const parsed = parseDotenv(content);
|
|
3398
|
+
if (Object.keys(parsed).length === 0) {
|
|
3399
|
+
throw new CliError(`${file} contains no KEY=value lines.`, { exitCode: EXIT.USAGE });
|
|
3400
|
+
}
|
|
3401
|
+
const { app, target, values } = await loadVars(ctx, options);
|
|
3402
|
+
const next = options.replace ? parsed : { ...values, ...parsed };
|
|
3403
|
+
await saveVars(ctx, target, next);
|
|
3404
|
+
emit({ ok: true, imported: Object.keys(parsed), total: Object.keys(next).length }, () => {
|
|
3405
|
+
log.success(`Imported ${Object.keys(parsed).length} variable${Object.keys(parsed).length === 1 ? "" : "s"} from ${file} into ${app.name} / ${target.name}.`);
|
|
3406
|
+
});
|
|
3407
|
+
await maybeRedeploy(ctx, app, target, options.redeploy);
|
|
3408
|
+
});
|
|
3409
|
+
vars.command("export").description("Print variables in .env format (redirect to a file)").option("-a, --app <app>", "app name or id").option("-e, --env <env>", "environment (default: production)").action(async (options, command) => {
|
|
3410
|
+
const ctx = contextFrom(command);
|
|
3411
|
+
const { values } = await loadVars(ctx, options);
|
|
3412
|
+
emit(values, () => {
|
|
3413
|
+
for (const key of Object.keys(values).sort()) {
|
|
3414
|
+
process.stdout.write(`${key}=${quoteDotenv(values[key] ?? "")}
|
|
3415
|
+
`);
|
|
3416
|
+
}
|
|
3417
|
+
});
|
|
3418
|
+
});
|
|
3419
|
+
}
|
|
3420
|
+
async function loadVars(ctx, options) {
|
|
3421
|
+
const app = await ctx.resolveApp(options.app);
|
|
3422
|
+
const target = await ctx.resolveEnv(app, options.env);
|
|
3423
|
+
const org = await ctx.resolveOrg();
|
|
3424
|
+
const full = await ctx.api.getEnvironment(org.id, target.id);
|
|
3425
|
+
const values = {};
|
|
3426
|
+
for (const [key, value] of Object.entries(full.environment_vars ?? {})) {
|
|
3427
|
+
values[key] = value == null ? "" : String(value);
|
|
3428
|
+
}
|
|
3429
|
+
return { app, target: full, values };
|
|
3430
|
+
}
|
|
3431
|
+
async function saveVars(ctx, target, values) {
|
|
3432
|
+
const org = await ctx.resolveOrg();
|
|
3433
|
+
await ctx.api.updateEnvironment({ targetOrganisationId: org.id, environmentId: target.id, environmentVars: values });
|
|
3434
|
+
}
|
|
3435
|
+
async function maybeRedeploy(ctx, app, target, redeploy) {
|
|
3436
|
+
if (redeploy) {
|
|
3437
|
+
await runDeploy(ctx, { app, env: target, watch: true });
|
|
3438
|
+
} else {
|
|
3439
|
+
log.info(`Changes apply on the next deploy ${c.dim(`(lc deploy --env ${target.name}, or pass --redeploy)`)}.`);
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
function parsePairs(pairs) {
|
|
3443
|
+
const result = {};
|
|
3444
|
+
for (const pair of pairs) {
|
|
3445
|
+
const index = pair.indexOf("=");
|
|
3446
|
+
if (index <= 0) {
|
|
3447
|
+
throw new CliError(`"${pair}" is not KEY=value.`, { exitCode: EXIT.USAGE });
|
|
3448
|
+
}
|
|
3449
|
+
const key = pair.slice(0, index).trim();
|
|
3450
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
3451
|
+
throw new CliError(`"${key}" is not a valid variable name.`, { hint: "Letters, digits and underscores; cannot start with a digit.", exitCode: EXIT.USAGE });
|
|
3452
|
+
}
|
|
3453
|
+
result[key] = pair.slice(index + 1);
|
|
3454
|
+
}
|
|
3455
|
+
return result;
|
|
3456
|
+
}
|
|
3457
|
+
function parseDotenv(content) {
|
|
3458
|
+
const result = {};
|
|
3459
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
3460
|
+
const line = raw.trim();
|
|
3461
|
+
if (!line || line.startsWith("#")) continue;
|
|
3462
|
+
const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
3463
|
+
if (!match) continue;
|
|
3464
|
+
let value = match[2] ?? "";
|
|
3465
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
3466
|
+
const quote = value[0];
|
|
3467
|
+
value = value.slice(1, -1);
|
|
3468
|
+
if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
|
3469
|
+
} else {
|
|
3470
|
+
const comment = value.indexOf(" #");
|
|
3471
|
+
if (comment !== -1) value = value.slice(0, comment);
|
|
3472
|
+
value = value.trim();
|
|
3473
|
+
}
|
|
3474
|
+
result[match[1]] = value;
|
|
3475
|
+
}
|
|
3476
|
+
return result;
|
|
3477
|
+
}
|
|
3478
|
+
function quoteDotenv(value) {
|
|
3479
|
+
if (/^[A-Za-z0-9_./:@-]*$/.test(value)) return value;
|
|
3480
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
|
3481
|
+
}
|
|
3482
|
+
function printEnvDetails(env) {
|
|
3483
|
+
heading(`${envName(env)} ${statusBadge(env.status)}`);
|
|
3484
|
+
printDetails([
|
|
3485
|
+
["Branch", env.github_branch],
|
|
3486
|
+
["URL", env.deployed_url ? link(env.deployed_url) : void 0],
|
|
3487
|
+
["Domain", env.custom_domain ? `${env.custom_domain} ${c.dim(env.custom_domain_status ?? "")}` : void 0],
|
|
3488
|
+
["Auto-deploy", env.auto_deploy ? "on push" : "off"],
|
|
3489
|
+
["Build", env.build_command ?? void 0],
|
|
3490
|
+
["Output", env.output_directory ?? void 0],
|
|
3491
|
+
["Port", env.container_port != null ? String(env.container_port) : void 0],
|
|
3492
|
+
["Memory", env.memory ?? void 0],
|
|
3493
|
+
["Instances", env.min_instances != null || env.max_instances != null ? `${env.min_instances ?? 0} ${c.dim(sym.arrow)} ${env.max_instances ?? "?"}` : void 0],
|
|
3494
|
+
["Password", env.password_enabled ? "protected" : void 0],
|
|
3495
|
+
["Region", env.cloud_run_region ?? void 0],
|
|
3496
|
+
["Variables", `${Object.keys(env.environment_vars ?? {}).length}`],
|
|
3497
|
+
["Last deploy", relativeTime(env.last_deployed_at)],
|
|
3498
|
+
["ID", c.dim(env.id)]
|
|
3499
|
+
]);
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
// src/commands/init.ts
|
|
3503
|
+
import * as path9 from "path";
|
|
3504
|
+
function registerInitCommands(program2) {
|
|
3505
|
+
program2.command("init").description("Link this folder to a Light Cloud app (existing or new)").option("-a, --app <app>", "link to this existing app without asking").option("-e, --env <env>", "default environment for `lc deploy` in this folder").option("-d, --dir <path>", "project folder", ".").action(async (options, command) => {
|
|
3506
|
+
const ctx = contextFrom(command);
|
|
3507
|
+
const directory = path9.resolve(options.dir);
|
|
3508
|
+
ctx.requireAuth();
|
|
3509
|
+
intro2(`${brand()} init`);
|
|
3510
|
+
const org = await ctx.resolveOrg();
|
|
3511
|
+
const detection = detectLocalProject(directory);
|
|
3512
|
+
const git2 = detectGit(directory);
|
|
3513
|
+
if (!isJson()) {
|
|
3514
|
+
out(`${c.dim("\u2502")} Folder ${directory}`);
|
|
3515
|
+
out(`${c.dim("\u2502")} Detected ${describeDetection(detection)}`);
|
|
3516
|
+
if (git2.remoteUrl) out(`${c.dim("\u2502")} Git ${git2.provider ?? git2.host ?? "remote"} ${c.dim(git2.remoteUrl)}${git2.branch ? c.dim(` @ ${git2.branch}`) : ""}`);
|
|
3517
|
+
out(`${c.dim("\u2502")} Workspace ${org.name ?? org.id}`);
|
|
3518
|
+
out(c.dim("\u2502"));
|
|
3519
|
+
}
|
|
3520
|
+
let app;
|
|
3521
|
+
if (options.app) {
|
|
3522
|
+
app = await ctx.resolveApp(options.app);
|
|
3523
|
+
} else {
|
|
3524
|
+
app = await chooseOrCreate(ctx, directory, detection, git2);
|
|
3525
|
+
}
|
|
3526
|
+
const envs = app.environments ?? await ctx.api.listEnvironments(org.id, app.id);
|
|
3527
|
+
let env;
|
|
3528
|
+
if (options.env) env = await ctx.resolveEnv(app, options.env);
|
|
3529
|
+
else if (git2.branch) env = envs.find((e) => e.github_branch === git2.branch) ?? envs.find((e) => e.is_production);
|
|
3530
|
+
else env = envs.find((e) => e.is_production);
|
|
3531
|
+
const file = writeProjectConfig(
|
|
3532
|
+
{
|
|
3533
|
+
organisationId: org.id,
|
|
3534
|
+
applicationId: app.id,
|
|
3535
|
+
applicationName: app.name,
|
|
3536
|
+
environmentId: env?.id,
|
|
3537
|
+
framework: app.framework,
|
|
3538
|
+
deploymentType: app.deployment_type
|
|
3539
|
+
},
|
|
3540
|
+
directory
|
|
3541
|
+
);
|
|
3542
|
+
emit({ ok: true, path: file, application: { id: app.id, name: app.name }, environment: env ? { id: env.id, name: env.name } : null }, () => {
|
|
3543
|
+
note2(
|
|
3544
|
+
[
|
|
3545
|
+
`${c.bold("lc deploy")} deploy ${env ? env.name : "the production environment"}`,
|
|
3546
|
+
`${c.bold("lc status")} see environments and URLs`,
|
|
3547
|
+
`${c.bold("lc logs -f")} tail runtime logs`,
|
|
3548
|
+
`${c.bold("lc env vars set")} KEY=value`
|
|
3549
|
+
].join("\n"),
|
|
3550
|
+
"Next"
|
|
3551
|
+
);
|
|
3552
|
+
outro2(`Linked ${c.bold(app.name)} ${c.dim(sym.arrow)} ${c.dim(file)}`);
|
|
3553
|
+
});
|
|
3554
|
+
});
|
|
3555
|
+
program2.command("create").description("Create an app from a git repository or a local folder (non-interactive friendly)").option("-n, --name <name>", "app name (default: folder or repository name)").option("-r, --repo <url>", "GitHub repository URL (default: the origin remote)").option("-b, --branch <branch>", "branch to deploy (default: current branch or main)").option("-u, --upload", "upload the folder instead of connecting a repository").option("-d, --dir <path>", "project folder", ".").option("-f, --framework <id>", "framework id (see `lc frameworks`)").option("-t, --type <type>", "deployment type: static or container").option("--build <command>", "build command").option("--output <dir>", "output directory for static sites").option("--port <port>", "container port").option("--root <dir>", "monorepo root directory inside the repository").option("--no-deploy", "create without deploying").option("--no-watch", "do not follow the first deployment").option("--no-link", "do not write a .lightcloud file").action(async (options, command) => {
|
|
3556
|
+
const ctx = contextFrom(command);
|
|
3557
|
+
const directory = path9.resolve(options.dir);
|
|
3558
|
+
ctx.requireAuth();
|
|
3559
|
+
intro2(`${brand()} create`);
|
|
3560
|
+
const app = await createApp(ctx, directory, options);
|
|
3561
|
+
if (options.link) {
|
|
3562
|
+
writeProjectConfig(
|
|
3563
|
+
{ organisationId: app.organisation_id, applicationId: app.id, applicationName: app.name, framework: app.framework, deploymentType: app.deployment_type },
|
|
3564
|
+
directory
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
let url = app.deployed_url ?? void 0;
|
|
3568
|
+
let status = app.status;
|
|
3569
|
+
if (options.deploy && options.watch) {
|
|
3570
|
+
const env = (app.environments ?? []).find((e) => e.is_production) ?? app.environments?.[0];
|
|
3571
|
+
const org = await ctx.resolveOrg();
|
|
3572
|
+
const result = await watchResource(ctx.api, env ? { kind: "environment", id: env.id, organisationId: org.id } : { kind: "application", id: app.id, organisationId: org.id });
|
|
3573
|
+
url = result.update.deployed_url ?? url;
|
|
3574
|
+
status = result.status;
|
|
3575
|
+
if (!result.ok) {
|
|
3576
|
+
throw new CliError(result.update.deployment_error || "The first deployment failed.", {
|
|
3577
|
+
hint: `Fix the build and run \`lc deploy\`. Console: ${ctx.appConsoleUrl(app)}`,
|
|
3578
|
+
exitCode: EXIT.FAILED
|
|
3579
|
+
});
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
emit({ ok: true, application: app, status, url }, () => outro2(url ? `Live at ${link(url)}` : `Created ${c.bold(app.name)}.`));
|
|
3583
|
+
});
|
|
3584
|
+
program2.command("frameworks").description("List framework ids the platform understands").action(async () => {
|
|
3585
|
+
emit(FRAMEWORKS, () => {
|
|
3586
|
+
for (const category of ["fullstack", "frontend", "backend"]) {
|
|
3587
|
+
out(c.bold(category));
|
|
3588
|
+
for (const framework of FRAMEWORKS.filter((f) => f.category === category)) {
|
|
3589
|
+
out(` ${framework.id.padEnd(12)} ${framework.label}${framework.available ? "" : c.dim(" (not yet available)")}`);
|
|
3590
|
+
}
|
|
3591
|
+
out();
|
|
3592
|
+
}
|
|
3593
|
+
});
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
3596
|
+
async function chooseOrCreate(ctx, directory, detection, git2) {
|
|
3597
|
+
const org = await ctx.resolveOrg();
|
|
3598
|
+
const apps = await ctx.api.listApplications(org.id);
|
|
3599
|
+
const suggested = git2.owner && git2.repo ? apps.find((app) => app.github_repo_owner?.toLowerCase() === git2.owner.toLowerCase() && app.github_repo_name?.toLowerCase() === git2.repo.toLowerCase()) : void 0;
|
|
3600
|
+
if (!isInteractive()) {
|
|
3601
|
+
if (suggested) return ctx.api.getApplication(org.id, suggested.id);
|
|
3602
|
+
throw new CliError("No terminal to ask in.", {
|
|
3603
|
+
hint: "Pass --app <name> to link an existing app, or use `lc create` for a new one.",
|
|
3604
|
+
exitCode: EXIT.USAGE
|
|
3605
|
+
});
|
|
3606
|
+
}
|
|
3607
|
+
const choices = [];
|
|
3608
|
+
if (suggested) choices.push({ value: "suggested", label: `Link ${suggested.name}`, hint: `already connected to ${git2.owner}/${git2.repo}` });
|
|
3609
|
+
choices.push({ value: "new", label: "Create a new app", hint: git2.provider === "github" ? `from ${git2.owner}/${git2.repo}` : "upload this folder" });
|
|
3610
|
+
if (apps.length) choices.push({ value: "existing", label: "Link an existing app", hint: `${apps.length} in ${org.name ?? "workspace"}` });
|
|
3611
|
+
const choice = await select2("What would you like to do?", choices, { flag: "--app <name>" });
|
|
3612
|
+
if (choice === "suggested") return ctx.api.getApplication(org.id, suggested.id);
|
|
3613
|
+
if (choice === "existing") {
|
|
3614
|
+
const id = await select2(
|
|
3615
|
+
"Which app?",
|
|
3616
|
+
apps.map((app) => ({ value: app.id, label: app.name, hint: `${app.framework} \xB7 ${app.status}` })),
|
|
3617
|
+
{ flag: "--app <name>" }
|
|
3618
|
+
);
|
|
3619
|
+
return ctx.api.getApplication(org.id, id);
|
|
3620
|
+
}
|
|
3621
|
+
return createApp(ctx, directory, {
|
|
3622
|
+
dir: directory,
|
|
3623
|
+
deploy: true,
|
|
3624
|
+
watch: true,
|
|
3625
|
+
link: false,
|
|
3626
|
+
upload: git2.provider !== "github" ? true : void 0
|
|
3627
|
+
}, { detection, git: git2, interactive: true });
|
|
3628
|
+
}
|
|
3629
|
+
async function createApp(ctx, directory, options, extra = {}) {
|
|
3630
|
+
const org = await ctx.resolveOrg();
|
|
3631
|
+
const detection = extra.detection ?? detectLocalProject(directory);
|
|
3632
|
+
const git2 = extra.git ?? detectGit(directory);
|
|
3633
|
+
const interactive = extra.interactive ?? isInteractive();
|
|
3634
|
+
let source;
|
|
3635
|
+
let repoUrl = options.repo;
|
|
3636
|
+
if (options.upload) source = "upload";
|
|
3637
|
+
else if (repoUrl) source = "github";
|
|
3638
|
+
else if (git2.provider === "github" && git2.owner && git2.repo) {
|
|
3639
|
+
repoUrl = webUrlFor(git2);
|
|
3640
|
+
source = interactive ? await select2(
|
|
3641
|
+
"Deploy from?",
|
|
3642
|
+
[
|
|
3643
|
+
{ value: "github", label: `GitHub ${git2.owner}/${git2.repo}`, hint: "auto-deploys on push" },
|
|
3644
|
+
{ value: "upload", label: "Upload this folder", hint: "no git connection" }
|
|
3645
|
+
],
|
|
3646
|
+
{ flag: "--repo <url> or --upload" }
|
|
3647
|
+
) : "github";
|
|
3648
|
+
} else {
|
|
3649
|
+
if (git2.provider && git2.provider !== "github") {
|
|
3650
|
+
log.warn(`${git2.provider} repositories are connected from the console (${ctx.consoleUrl("/new")}); uploading the folder instead.`);
|
|
3651
|
+
}
|
|
3652
|
+
source = "upload";
|
|
3653
|
+
}
|
|
3654
|
+
let framework = options.framework ?? detection.framework;
|
|
3655
|
+
let deploymentType = options.type ?? detection.deploymentType;
|
|
3656
|
+
let buildCommand = options.build ?? detection.buildCommand;
|
|
3657
|
+
let outputDirectory = options.output ?? detection.outputDirectory;
|
|
3658
|
+
let containerPort = options.port ? Number(options.port) : detection.containerPort;
|
|
3659
|
+
if (options.framework && !getFrameworkById(options.framework)) {
|
|
3660
|
+
throw new CliError(`Unknown framework "${options.framework}".`, { hint: "Run `lc frameworks` for the list.", exitCode: EXIT.USAGE });
|
|
3661
|
+
}
|
|
3662
|
+
if (options.type && options.type !== "static" && options.type !== "container") {
|
|
3663
|
+
throw new CliError("--type must be static or container.", { exitCode: EXIT.USAGE });
|
|
3664
|
+
}
|
|
3665
|
+
if (source === "github" && repoUrl) {
|
|
3666
|
+
const parsed = parseGithubUrl(repoUrl);
|
|
3667
|
+
if (!parsed) throw new CliError(`"${repoUrl}" is not a GitHub repository URL.`, { exitCode: EXIT.USAGE });
|
|
3668
|
+
const branch = options.branch ?? git2.branch ?? "main";
|
|
3669
|
+
const access = await ctx.api.githubInstallationStatus(org.id, parsed.owner, parsed.repo).catch(() => null);
|
|
3670
|
+
if (access && (!access.installed || access.repoAccess === false)) {
|
|
3671
|
+
const install = await ctx.api.githubInstallUrl().catch(() => null);
|
|
3672
|
+
const hint = install?.url ? `Install the Light Cloud GitHub App for ${parsed.owner}: ${install.url}` : `Connect GitHub in the console: ${ctx.consoleUrl("/new")}`;
|
|
3673
|
+
if (interactive && install?.url) {
|
|
3674
|
+
const go = await confirm2(`Light Cloud cannot read ${parsed.owner}/${parsed.repo} yet. Open the GitHub App install page?`, { yes: false, initialValue: true });
|
|
3675
|
+
if (go) openBrowser(install.url);
|
|
3676
|
+
}
|
|
3677
|
+
throw new CliError(`The Light Cloud GitHub App has no access to ${parsed.owner}/${parsed.repo}.`, { hint, exitCode: EXIT.AUTH });
|
|
3678
|
+
}
|
|
3679
|
+
const spin = spinner2();
|
|
3680
|
+
spin.start(`Inspecting ${parsed.owner}/${parsed.repo}@${branch}`);
|
|
3681
|
+
let remote = null;
|
|
3682
|
+
try {
|
|
3683
|
+
remote = await ctx.api.detectFramework({ organisationId: org.id, owner: parsed.owner, repo: parsed.repo, branch, rootDirectory: options.root });
|
|
3684
|
+
spin.stop(`${remote.framework ? getFrameworkById(remote.framework)?.label ?? remote.framework : "Unknown framework"} ${c.dim(sym.bullet)} ${remote.deploymentType}${remote.detectedFiles?.length ? c.dim(` ${remote.detectedFiles.slice(-1)[0]}`) : ""}`);
|
|
3685
|
+
} catch {
|
|
3686
|
+
spin.stop("Could not inspect the repository; using local detection", 2);
|
|
3687
|
+
}
|
|
3688
|
+
if (remote) {
|
|
3689
|
+
framework = options.framework ?? remote.framework ?? framework;
|
|
3690
|
+
deploymentType = options.type ?? remote.deploymentType;
|
|
3691
|
+
buildCommand = options.build ?? remote.buildCommand ?? buildCommand;
|
|
3692
|
+
outputDirectory = options.output ?? remote.outputDirectory ?? outputDirectory;
|
|
3693
|
+
containerPort = options.port ? Number(options.port) : remote.containerPort ?? containerPort;
|
|
3694
|
+
if (remote.configWarning) log.warn(remote.configWarning);
|
|
3695
|
+
}
|
|
3696
|
+
const name2 = await pickName(options.name ?? parsed.repo, interactive);
|
|
3697
|
+
if (interactive) {
|
|
3698
|
+
({ framework, deploymentType } = await confirmKind(framework, deploymentType, interactive));
|
|
3699
|
+
}
|
|
3700
|
+
if (!framework) throw new CliError("Could not detect the framework.", { hint: "Pass --framework <id> (see `lc frameworks`).", exitCode: EXIT.USAGE });
|
|
3701
|
+
const definition2 = getFrameworkById(framework);
|
|
3702
|
+
const spinCreate2 = spinner2();
|
|
3703
|
+
spinCreate2.start(`Creating ${name2}`);
|
|
3704
|
+
const app2 = await ctx.api.createApplication({
|
|
3705
|
+
targetOrganisationId: org.id,
|
|
3706
|
+
name: name2,
|
|
3707
|
+
githubRepoUrl: `https://github.com/${parsed.owner}/${parsed.repo}`,
|
|
3708
|
+
githubBranch: branch,
|
|
3709
|
+
isPrivate: false,
|
|
3710
|
+
gitProvider: "github",
|
|
3711
|
+
deploymentType,
|
|
3712
|
+
framework,
|
|
3713
|
+
runtime: definition2?.runtime ?? void 0,
|
|
3714
|
+
buildCommand,
|
|
3715
|
+
outputDirectory: deploymentType === "static" ? outputDirectory : void 0,
|
|
3716
|
+
containerPort: deploymentType === "container" ? containerPort : void 0,
|
|
3717
|
+
rootDirectory: options.root,
|
|
3718
|
+
autoDeployOnPush: true
|
|
3719
|
+
});
|
|
3720
|
+
spinCreate2.stop(`Created ${c.bold(app2.name)} ${c.dim(app2.id)}`);
|
|
3721
|
+
if (!options.deploy) log.info("Created without deploying; run `lc deploy` when ready.");
|
|
3722
|
+
return app2;
|
|
3723
|
+
}
|
|
3724
|
+
warnIfDirty(git2.isDirty, git2.branch);
|
|
3725
|
+
if (!framework && !detection.hasDockerfile) {
|
|
3726
|
+
if (interactive) {
|
|
3727
|
+
framework = await select2(
|
|
3728
|
+
"What kind of project is this?",
|
|
3729
|
+
FRAMEWORKS.filter((f) => f.available).map((f) => ({ value: f.id, label: f.label, hint: f.deploymentType })),
|
|
3730
|
+
{ flag: "--framework <id>" }
|
|
3731
|
+
);
|
|
3732
|
+
deploymentType = options.type ?? getFrameworkById(framework)?.deploymentType ?? deploymentType;
|
|
3733
|
+
} else {
|
|
3734
|
+
throw new CliError(`Could not tell what kind of project ${directory} is.`, { hint: "Pass --framework <id> (see `lc frameworks`).", exitCode: EXIT.USAGE });
|
|
3735
|
+
}
|
|
3736
|
+
} else if (interactive) {
|
|
3737
|
+
({ framework, deploymentType } = await confirmKind(framework, deploymentType, interactive));
|
|
3738
|
+
}
|
|
3739
|
+
const name = await pickName(options.name ?? path9.basename(directory), interactive);
|
|
3740
|
+
const definition = framework ? getFrameworkById(framework) : void 0;
|
|
3741
|
+
const upload = await uploadSource(ctx, directory, { ...detection, framework, deploymentType, buildCommand, outputDirectory });
|
|
3742
|
+
const spinCreate = spinner2();
|
|
3743
|
+
spinCreate.start(`Creating ${name}`);
|
|
3744
|
+
const app = await ctx.api.createFromUpload({
|
|
3745
|
+
targetOrganisationId: org.id,
|
|
3746
|
+
name,
|
|
3747
|
+
uploadId: upload.uploadId,
|
|
3748
|
+
deploymentType,
|
|
3749
|
+
framework: framework ?? (detection.hasDockerfile ? "custom" : void 0),
|
|
3750
|
+
runtime: definition?.runtime ?? (detection.hasDockerfile ? "custom" : void 0),
|
|
3751
|
+
buildCommand,
|
|
3752
|
+
outputDirectory: deploymentType === "static" ? outputDirectory : void 0,
|
|
3753
|
+
containerPort: deploymentType === "container" ? containerPort : void 0
|
|
3754
|
+
});
|
|
3755
|
+
spinCreate.stop(`Created ${c.bold(app.name)} ${c.dim(app.id)}`);
|
|
3756
|
+
if (options.deploy && app.status && !["queued", "deploying", "pending"].includes(app.status)) {
|
|
3757
|
+
await runDeploy(ctx, { app, env: (app.environments ?? []).find((e) => e.is_production) ?? null, watch: false });
|
|
3758
|
+
}
|
|
3759
|
+
return app;
|
|
3760
|
+
}
|
|
3761
|
+
async function pickName(suggested, interactive) {
|
|
3762
|
+
const cleaned = suggested.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
3763
|
+
if (!interactive) return cleaned;
|
|
3764
|
+
return text2("App name", {
|
|
3765
|
+
flag: "--name <name>",
|
|
3766
|
+
initialValue: cleaned,
|
|
3767
|
+
validate: (value) => value.trim().length < 2 ? "At least 2 characters." : /^[a-z0-9][a-z0-9-]*$/.test(value.trim()) ? void 0 : "Lowercase letters, digits and dashes only."
|
|
3768
|
+
});
|
|
3769
|
+
}
|
|
3770
|
+
async function confirmKind(framework, deploymentType, interactive) {
|
|
3771
|
+
if (!interactive) return { framework, deploymentType };
|
|
3772
|
+
const label = framework ? getFrameworkById(framework)?.label ?? framework : "unknown";
|
|
3773
|
+
const keep = await confirm2(`Deploy as ${c.bold(label)} ${c.dim(sym.bullet)} ${deploymentType}?`, { yes: false, initialValue: true });
|
|
3774
|
+
if (keep) return { framework, deploymentType };
|
|
3775
|
+
const chosen = await select2(
|
|
3776
|
+
"Framework",
|
|
3777
|
+
FRAMEWORKS.filter((f) => f.available).map((f) => ({ value: f.id, label: f.label, hint: f.category })),
|
|
3778
|
+
{ flag: "--framework <id>", initialValue: framework }
|
|
3779
|
+
);
|
|
3780
|
+
const definition = getFrameworkById(chosen);
|
|
3781
|
+
const type = definition.category === "fullstack" || definition.id === "html" ? await select2(
|
|
3782
|
+
"Deployment type",
|
|
3783
|
+
[
|
|
3784
|
+
{ value: "container", label: "Container", hint: "server-rendered, APIs" },
|
|
3785
|
+
{ value: "static", label: "Static site", hint: "prebuilt HTML/JS on a CDN" }
|
|
3786
|
+
],
|
|
3787
|
+
{ flag: "--type <type>", initialValue: definition.deploymentType }
|
|
3788
|
+
) : definition.deploymentType;
|
|
3789
|
+
return { framework: chosen, deploymentType: type };
|
|
3790
|
+
}
|
|
3791
|
+
function parseGithubUrl(value) {
|
|
3792
|
+
const match = value.trim().match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?\/?$/i) || value.trim().match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i);
|
|
3793
|
+
if (!match) {
|
|
3794
|
+
const short = value.trim().match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
|
|
3795
|
+
return short ? { owner: short[1], repo: short[2] } : null;
|
|
3796
|
+
}
|
|
3797
|
+
return { owner: match[1], repo: match[2] };
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
// src/lib/realtime/log-stream.ts
|
|
3801
|
+
async function* readSse(response, signal) {
|
|
3802
|
+
if (!response.body) return;
|
|
3803
|
+
const reader = response.body.getReader();
|
|
3804
|
+
const decoder = new TextDecoder();
|
|
3805
|
+
let buffer = "";
|
|
3806
|
+
try {
|
|
3807
|
+
for (; ; ) {
|
|
3808
|
+
if (signal?.aborted) return;
|
|
3809
|
+
const { value, done } = await reader.read();
|
|
3810
|
+
if (done) return;
|
|
3811
|
+
buffer += decoder.decode(value, { stream: true });
|
|
3812
|
+
let boundary = buffer.indexOf("\n\n");
|
|
3813
|
+
while (boundary !== -1) {
|
|
3814
|
+
const frame = buffer.slice(0, boundary);
|
|
3815
|
+
buffer = buffer.slice(boundary + 2);
|
|
3816
|
+
const parsed = parseFrame(frame);
|
|
3817
|
+
if (parsed) yield parsed;
|
|
3818
|
+
boundary = buffer.indexOf("\n\n");
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
} finally {
|
|
3822
|
+
reader.cancel().catch(() => void 0);
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3825
|
+
function parseFrame(frame) {
|
|
3826
|
+
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
3827
|
+
if (!data) return null;
|
|
3828
|
+
try {
|
|
3829
|
+
return JSON.parse(data);
|
|
3830
|
+
} catch {
|
|
3831
|
+
return null;
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
// src/commands/logs.ts
|
|
3836
|
+
var SEVERITIES = ["DEFAULT", "DEBUG", "INFO", "NOTICE", "WARNING", "ERROR", "CRITICAL", "ALERT", "EMERGENCY"];
|
|
3837
|
+
function registerLogsCommand(program2) {
|
|
3838
|
+
program2.command("logs [app]").description("Show runtime logs of an environment; -f to follow").option("-e, --env <env>", "environment (default: production)").option("-f, --follow", "keep streaming new log lines").option("-n, --lines <n>", "how many recent lines to show first", "100").option("--since <duration>", "how far back to look, e.g. 30m, 2h, 1d", "1h").option("-s, --severity <levels>", "comma-separated: ERROR,WARNING,INFO,DEBUG \u2026").option("--min-severity <level>", "this level and above, e.g. WARNING").option("--search <text>", "only lines containing this text").option("--no-timestamps", "hide timestamps").action(async (positional, options, command) => {
|
|
3839
|
+
const ctx = contextFrom(command);
|
|
3840
|
+
const app = await ctx.resolveApp(positional);
|
|
3841
|
+
const env = await ctx.resolveEnv(app, options.env);
|
|
3842
|
+
const org = await ctx.resolveOrg();
|
|
3843
|
+
if (app.deployment_type === "static" || !env.cloud_run_service) {
|
|
3844
|
+
throw new CliError(`${app.name} / ${env.name} is a static site; it has no runtime logs.`, {
|
|
3845
|
+
hint: "Deployment logs are under `lc deployments`.",
|
|
3846
|
+
exitCode: EXIT.USAGE
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
const severity = parseSeverity(options);
|
|
3850
|
+
const lines = Math.max(1, Math.min(1e3, Number(options.lines) || 100));
|
|
3851
|
+
const since = new Date(Date.now() - parseDuration(options.since)).toISOString();
|
|
3852
|
+
const page = await ctx.api.fetchLogs(org.id, env.id, {
|
|
3853
|
+
startTime: since,
|
|
3854
|
+
severity,
|
|
3855
|
+
textSearch: options.search,
|
|
3856
|
+
pageSize: lines
|
|
3857
|
+
});
|
|
3858
|
+
const entries = [...page.logs ?? []].sort((a, b) => a.timestamp.localeCompare(b.timestamp)).slice(-lines);
|
|
3859
|
+
if (isJson() && !options.follow) {
|
|
3860
|
+
printJson(entries);
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
if (entries.length === 0 && !options.follow) {
|
|
3864
|
+
log.info(`No logs on ${app.name} / ${env.name} in the last ${options.since}${severity ? ` at ${severity.join(",")}` : ""}.`);
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
for (const entry of entries) printEntry(entry, options.timestamps);
|
|
3868
|
+
if (!options.follow) return;
|
|
3869
|
+
const controller = new AbortController();
|
|
3870
|
+
const stop = () => controller.abort();
|
|
3871
|
+
process.once("SIGINT", stop);
|
|
3872
|
+
process.once("SIGTERM", stop);
|
|
3873
|
+
if (!isJson()) out(c.dim(`\u2500\u2500 following ${app.name} / ${env.name} \xB7 ctrl-c to stop \u2500\u2500`));
|
|
3874
|
+
const seen = new Set(entries.map((entry) => entry.insertId));
|
|
3875
|
+
const search = options.search?.toLowerCase();
|
|
3876
|
+
try {
|
|
3877
|
+
const response = await ctx.api.streamLogs(org.id, env.id, severity, controller.signal);
|
|
3878
|
+
for await (const update of readSse(response, controller.signal)) {
|
|
3879
|
+
if (update.type === "error") {
|
|
3880
|
+
log.warn(update.error ?? "stream error");
|
|
3881
|
+
continue;
|
|
3882
|
+
}
|
|
3883
|
+
if (update.type !== "log" || !update.entry) continue;
|
|
3884
|
+
if (seen.has(update.entry.insertId)) continue;
|
|
3885
|
+
seen.add(update.entry.insertId);
|
|
3886
|
+
if (search && !entryText(update.entry).toLowerCase().includes(search)) continue;
|
|
3887
|
+
if (isJson()) printJson(update.entry);
|
|
3888
|
+
else printEntry(update.entry, options.timestamps);
|
|
3889
|
+
}
|
|
3890
|
+
} catch (error) {
|
|
3891
|
+
if (!controller.signal.aborted) throw error;
|
|
3892
|
+
} finally {
|
|
3893
|
+
process.off("SIGINT", stop);
|
|
3894
|
+
process.off("SIGTERM", stop);
|
|
3895
|
+
}
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
function parseSeverity(options) {
|
|
3899
|
+
if (options.severity) {
|
|
3900
|
+
const levels = options.severity.split(",").map((level) => level.trim().toUpperCase());
|
|
3901
|
+
const bad = levels.filter((level) => !SEVERITIES.includes(level));
|
|
3902
|
+
if (bad.length) throw new CliError(`Unknown severity: ${bad.join(", ")}.`, { hint: `Use: ${SEVERITIES.join(", ")}.`, exitCode: EXIT.USAGE });
|
|
3903
|
+
return levels;
|
|
3904
|
+
}
|
|
3905
|
+
if (options.minSeverity) {
|
|
3906
|
+
const level = options.minSeverity.trim().toUpperCase();
|
|
3907
|
+
const index = SEVERITIES.indexOf(level);
|
|
3908
|
+
if (index === -1) throw new CliError(`Unknown severity "${options.minSeverity}".`, { hint: `Use: ${SEVERITIES.join(", ")}.`, exitCode: EXIT.USAGE });
|
|
3909
|
+
return SEVERITIES.slice(index);
|
|
3910
|
+
}
|
|
3911
|
+
return void 0;
|
|
3912
|
+
}
|
|
3913
|
+
function parseDuration(value) {
|
|
3914
|
+
const match = value.trim().match(/^(\d+)\s*(s|m|h|d|w)?$/i);
|
|
3915
|
+
if (!match) throw new CliError(`"${value}" is not a duration.`, { hint: "Examples: 90s, 30m, 2h, 1d.", exitCode: EXIT.USAGE });
|
|
3916
|
+
const amount = Number(match[1]);
|
|
3917
|
+
const unit = (match[2] ?? "m").toLowerCase();
|
|
3918
|
+
const factor = unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : unit === "d" ? 864e5 : 6048e5;
|
|
3919
|
+
return amount * factor;
|
|
3920
|
+
}
|
|
3921
|
+
function entryText(entry) {
|
|
3922
|
+
if (entry.textPayload) return entry.textPayload;
|
|
3923
|
+
if (entry.httpRequest) return `${entry.httpRequest.requestMethod} ${entry.httpRequest.requestUrl}`;
|
|
3924
|
+
if (entry.jsonPayload) {
|
|
3925
|
+
const payload = entry.jsonPayload;
|
|
3926
|
+
const message = payload.message ?? payload.msg ?? payload.error;
|
|
3927
|
+
return typeof message === "string" ? message : JSON.stringify(payload);
|
|
3928
|
+
}
|
|
3929
|
+
return "";
|
|
3930
|
+
}
|
|
3931
|
+
function formatEntry(entry, withTimestamp = true) {
|
|
3932
|
+
const time = withTimestamp ? c.dim(formatTime(entry.timestamp)) + " " : "";
|
|
3933
|
+
const badge = severityBadge(entry.severity);
|
|
3934
|
+
if (entry.httpRequest) {
|
|
3935
|
+
const { requestMethod, requestUrl, status, latency } = entry.httpRequest;
|
|
3936
|
+
let pathname = requestUrl;
|
|
3937
|
+
try {
|
|
3938
|
+
const parsed = new URL(requestUrl);
|
|
3939
|
+
pathname = parsed.pathname + parsed.search;
|
|
3940
|
+
} catch {
|
|
3941
|
+
}
|
|
3942
|
+
const code = status >= 500 ? c.red(String(status)) : status >= 400 ? c.yellow(String(status)) : c.green(String(status));
|
|
3943
|
+
const ms = latency ? c.dim(latency.replace(/^([\d.]+)s$/, (_, s) => `${Math.round(Number(s) * 1e3)}ms`)) : "";
|
|
3944
|
+
const text4 = entry.textPayload ? ` ${entry.textPayload}` : "";
|
|
3945
|
+
return `${time}${badge} ${c.bold(requestMethod.padEnd(6))} ${pathname} ${code} ${ms}${text4}`;
|
|
3946
|
+
}
|
|
3947
|
+
const text3 = entryText(entry);
|
|
3948
|
+
return `${time}${badge} ${text3.replace(/\s+$/, "")}`;
|
|
3949
|
+
}
|
|
3950
|
+
function printEntry(entry, withTimestamp) {
|
|
3951
|
+
out(formatEntry(entry, withTimestamp));
|
|
3952
|
+
}
|
|
3953
|
+
function formatTime(iso) {
|
|
3954
|
+
const date = new Date(iso);
|
|
3955
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
3956
|
+
const pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
3957
|
+
return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad2(date.getMilliseconds(), 3)}`;
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
// src/commands/orgs.ts
|
|
3961
|
+
function registerOrgCommands(program2) {
|
|
3962
|
+
program2.command("orgs").alias("workspaces").description("List the workspaces you belong to").action(async (_options, command) => {
|
|
3963
|
+
const ctx = contextFrom(command);
|
|
3964
|
+
const profile = await ctx.profile();
|
|
3965
|
+
const defaultOrg = readGlobalConfig().defaultOrganisationId;
|
|
3966
|
+
const orgs = profile.organisations ?? [];
|
|
3967
|
+
emit(orgs, () => {
|
|
3968
|
+
printTable(orgs, [
|
|
3969
|
+
{ header: "Workspace", cell: (org2) => `${c.bold(org2.name)}${org2.id === defaultOrg ? c.accent(" (default)") : ""}` },
|
|
3970
|
+
{ header: "Role", cell: (org2) => org2.role },
|
|
3971
|
+
{ header: "ID", cell: (org2) => c.dim(org2.id) }
|
|
3972
|
+
]);
|
|
3973
|
+
if (orgs.length > 1) {
|
|
3974
|
+
out();
|
|
3975
|
+
out(c.dim(" Switch default: lc org use <name> \xB7 one command only: lc <command> --org <name>"));
|
|
3976
|
+
}
|
|
3977
|
+
});
|
|
3978
|
+
});
|
|
3979
|
+
const org = program2.command("org").description("Workspace settings");
|
|
3980
|
+
org.command("use [workspace]").alias("switch").description("Set the default workspace (no name: pick from a list)").action(async (ref, _options, command) => {
|
|
3981
|
+
const ctx = contextFrom(command);
|
|
3982
|
+
const profile = await ctx.profile();
|
|
3983
|
+
const orgs = profile.organisations ?? [];
|
|
3984
|
+
const current = readGlobalConfig().defaultOrganisationId;
|
|
3985
|
+
let match = ref ? findOrg(orgs, ref) : void 0;
|
|
3986
|
+
if (ref && !match) {
|
|
3987
|
+
throw new CliError(`No workspace called "${ref}".`, {
|
|
3988
|
+
hint: `Your workspaces: ${orgs.map((org2) => org2.name).join(", ")}.`,
|
|
3989
|
+
exitCode: EXIT.NOT_FOUND
|
|
3990
|
+
});
|
|
3991
|
+
}
|
|
3992
|
+
if (!match) {
|
|
3993
|
+
if (orgs.length === 0) throw new CliError("This account has no workspaces yet.", { exitCode: EXIT.NOT_FOUND });
|
|
3994
|
+
const chosen = await select2(
|
|
3995
|
+
"Which workspace should lc use by default?",
|
|
3996
|
+
orgs.map((org2) => ({ value: org2.id, label: org2.name, hint: org2.id === current ? `${org2.role} \xB7 current default` : org2.role })),
|
|
3997
|
+
{ flag: "lc org use <name>", initialValue: current ?? void 0 }
|
|
3998
|
+
);
|
|
3999
|
+
match = orgs.find((org2) => org2.id === chosen);
|
|
4000
|
+
}
|
|
4001
|
+
updateGlobalConfig({ defaultOrganisationId: match.id, defaultOrganisationName: match.name });
|
|
4002
|
+
emit({ ok: true, organisation: match }, () => {
|
|
4003
|
+
log.success(`Default workspace is now ${c.bold(match.name)}.`);
|
|
4004
|
+
const others = orgs.filter((org2) => org2.id !== match.id);
|
|
4005
|
+
if (others.length) log.info(c.dim(`One-off: add --org <name> to any command. Others: ${others.map((org2) => org2.name).join(", ")}.`));
|
|
4006
|
+
});
|
|
4007
|
+
});
|
|
4008
|
+
org.command("current").description("Show which workspace commands will use here").action(async (_options, command) => {
|
|
4009
|
+
const ctx = contextFrom(command);
|
|
4010
|
+
const current = await ctx.resolveOrg({ interactive: false });
|
|
4011
|
+
emit(current, () => {
|
|
4012
|
+
const via = ctx.project?.config.organisationId === current.id ? "linked folder" : readGlobalConfig().defaultOrganisationId === current.id ? "default" : "only workspace";
|
|
4013
|
+
log.info(`${c.bold(current.name ?? current.id)} ${c.dim(`(${via})`)}`);
|
|
4014
|
+
});
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
// src/index.ts
|
|
4019
|
+
var program = new Command();
|
|
4020
|
+
program.name("lc").description(`${c.bold("Light Cloud")} from the terminal: deploy, watch, inspect.`).version(cliVersion(), "-v, --version", "print the CLI version").option("--json", "machine-readable output (one JSON document on stdout)").option("-o, --org <workspace>", "workspace name or id").option("-y, --yes", "answer yes to confirmations").option("--api-url <url>", "Light Cloud API endpoint (or LIGHT_CLOUD_API_URL)").option("--no-color", "disable colours").showHelpAfterError("(add --help for usage)").showSuggestionAfterError(true).configureHelp({ sortSubcommands: false, subcommandTerm: (cmd) => cmd.name() + (cmd.alias() ? `|${cmd.alias()}` : "") }).addHelpText(
|
|
4021
|
+
"after",
|
|
4022
|
+
`
|
|
4023
|
+
${c.bold("Getting started")}
|
|
4024
|
+
$ lc login sign in (opens the browser)
|
|
4025
|
+
$ lc init link this folder to an app, or create one
|
|
4026
|
+
$ lc deploy deploy and follow the build
|
|
4027
|
+
$ lc logs -f tail runtime logs
|
|
4028
|
+
|
|
4029
|
+
${c.bold("Everyday")}
|
|
4030
|
+
$ lc apps what is deployed
|
|
4031
|
+
$ lc status my-app environments, URLs, last deploy
|
|
4032
|
+
$ lc env vars set KEY=v --redeploy
|
|
4033
|
+
$ lc rollback go back to a previous deployment
|
|
4034
|
+
$ lc db url my-db connection string
|
|
4035
|
+
|
|
4036
|
+
${c.dim("CI: set LIGHT_CLOUD_API_KEY and run from a folder with a .lightcloud file.")}
|
|
4037
|
+
${c.dim("Docs: https://docs.light-cloud.com/cli")}`
|
|
4038
|
+
);
|
|
4039
|
+
program.hook("preAction", (thisCommand) => {
|
|
4040
|
+
const options = thisCommand.optsWithGlobals();
|
|
4041
|
+
const noColor = options.color === false || Boolean(process.env.NO_COLOR);
|
|
4042
|
+
configureOutput(noColor ? { json: Boolean(options.json), color: false } : { json: Boolean(options.json) });
|
|
4043
|
+
});
|
|
4044
|
+
registerAuthCommands(program);
|
|
4045
|
+
registerOrgCommands(program);
|
|
4046
|
+
registerInitCommands(program);
|
|
4047
|
+
registerAppCommands(program);
|
|
4048
|
+
registerDeployCommand(program);
|
|
4049
|
+
registerEnvCommands(program);
|
|
4050
|
+
registerLogsCommand(program);
|
|
4051
|
+
registerDeploymentCommands(program);
|
|
4052
|
+
registerDomainCommands(program);
|
|
4053
|
+
registerDbCommands(program);
|
|
4054
|
+
registerBillingCommands(program);
|
|
4055
|
+
registerConfigCommands(program);
|
|
4056
|
+
program.exitOverride();
|
|
4057
|
+
async function main() {
|
|
4058
|
+
try {
|
|
4059
|
+
await program.parseAsync(process.argv);
|
|
4060
|
+
} catch (error) {
|
|
4061
|
+
handleError(error);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
function handleError(error) {
|
|
4065
|
+
if (error instanceof CommanderError) {
|
|
4066
|
+
process.exit(error.exitCode);
|
|
4067
|
+
}
|
|
4068
|
+
if (error instanceof CancelledError) {
|
|
4069
|
+
if (!isJson()) err(c.dim("Cancelled."));
|
|
4070
|
+
process.exit(EXIT.CANCELLED);
|
|
4071
|
+
}
|
|
4072
|
+
if (error instanceof CliError) {
|
|
4073
|
+
if (isJson()) {
|
|
4074
|
+
printJson({ error: { code: error.code, message: error.message, hint: error.hint, status: error instanceof ApiError ? error.status : void 0 } });
|
|
4075
|
+
} else {
|
|
4076
|
+
err(`${c.red("\u2716")} ${error.message}`);
|
|
4077
|
+
if (error.hint) err(` ${c.dim(error.hint)}`);
|
|
4078
|
+
}
|
|
4079
|
+
process.exit(error.exitCode);
|
|
4080
|
+
}
|
|
4081
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4082
|
+
if (isJson()) printJson({ error: { code: "UNEXPECTED", message } });
|
|
4083
|
+
else {
|
|
4084
|
+
err(`${c.red("\u2716")} ${message}`);
|
|
4085
|
+
if (process.env.LC_DEBUG && error instanceof Error && error.stack) err(c.dim(error.stack));
|
|
4086
|
+
else err(c.dim(" Set LC_DEBUG=1 for a stack trace."));
|
|
4087
|
+
}
|
|
4088
|
+
process.exit(EXIT.ERROR);
|
|
4089
|
+
}
|
|
4090
|
+
void main();
|