@lofenta/cli 0.0.0-stage → 0.5.2

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/src/index.js ADDED
@@ -0,0 +1,2309 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs")
4
+ const os = require("node:os")
5
+ const path = require("node:path")
6
+ const { spawnSync } = require("node:child_process")
7
+ const readlineCore = require("node:readline")
8
+ const readline = require("node:readline/promises")
9
+ const { stdin, stdout, stderr, exit } = require("node:process")
10
+
11
+ const PKG = (() => {
12
+ try { return require("../package.json") } catch { return {} }
13
+ })()
14
+ const VERSION = PKG.version || "0.0.0"
15
+
16
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "lofenta")
17
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json")
18
+ const DEFAULT_BASE_URL = process.env.LOFENTA_BASE_URL || "https://lofenta.com"
19
+ const ANSI = {
20
+ reset: "\x1b[0m",
21
+ bold: "\x1b[1m",
22
+ dim: "\x1b[2m",
23
+ blue: "\x1b[38;5;39m",
24
+ white: "\x1b[97m",
25
+ inverse: "\x1b[7m",
26
+ green: "\x1b[32m",
27
+ yellow: "\x1b[33m",
28
+ red: "\x1b[31m",
29
+ }
30
+ const BRAND_MARK = [
31
+ " ==",
32
+ " ++++",
33
+ " ++++++",
34
+ " ++++++++",
35
+ " +++++++++",
36
+ " +++++++++++",
37
+ " ++++++++++++ ",
38
+ " ++++++++++++ ",
39
+ " ***+++++++++ ",
40
+ " ********++++ ",
41
+ " *********** ++",
42
+ " ************ +++",
43
+ "************ ***++",
44
+ "********** *******",
45
+ "******** *********",
46
+ "****** ",
47
+ "**** *************",
48
+ "#* ************** ",
49
+ " ************** ",
50
+ " #************* ",
51
+ ]
52
+ function ensureConfigDir() {
53
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
54
+ }
55
+
56
+ function loadConfig() {
57
+ try {
58
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"))
59
+ // Recover from a stale local-dev base URL left in a config file: any
60
+ // localhost/loopback origin falls back to the public default.
61
+ if (typeof config.baseUrl === "string" &&
62
+ /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\/?$/i.test(config.baseUrl)) {
63
+ config.baseUrl = DEFAULT_BASE_URL
64
+ }
65
+ return config
66
+ } catch {
67
+ return {}
68
+ }
69
+ }
70
+
71
+ function saveConfig(config) {
72
+ ensureConfigDir()
73
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8")
74
+ }
75
+
76
+ function tokenize(input) {
77
+ return Array.from(input.matchAll(/"[^"]*"|'[^']*'|\S+/g), (match) => {
78
+ const token = match[0]
79
+ if ((token.startsWith("\"") && token.endsWith("\"")) || (token.startsWith("'") && token.endsWith("'"))) {
80
+ return token.slice(1, -1)
81
+ }
82
+ return token
83
+ })
84
+ }
85
+
86
+ function parseArgv(argv) {
87
+ const args = [...argv]
88
+ let command = null
89
+
90
+ if (!args.length) {
91
+ return { command, positionals: [], flags: {} }
92
+ }
93
+
94
+ const first = args[0]
95
+ if (first === "--deploy") {
96
+ command = "deploy"
97
+ args.shift()
98
+ } else if (first.startsWith("/")) {
99
+ command = first.slice(1)
100
+ args.shift()
101
+ } else if (!first.startsWith("-")) {
102
+ command = first
103
+ args.shift()
104
+ }
105
+
106
+ const flags = {}
107
+ const positionals = []
108
+
109
+ for (let i = 0; i < args.length; i += 1) {
110
+ const token = args[i]
111
+
112
+ if (token.startsWith("--")) {
113
+ const normalized = token.slice(2)
114
+ const eq = normalized.indexOf("=")
115
+ if (eq !== -1) {
116
+ flags[normalized.slice(0, eq)] = normalized.slice(eq + 1)
117
+ continue
118
+ }
119
+
120
+ const next = args[i + 1]
121
+ if (next && !next.startsWith("-")) {
122
+ flags[normalized] = next
123
+ i += 1
124
+ } else {
125
+ flags[normalized] = true
126
+ }
127
+ continue
128
+ }
129
+
130
+ positionals.push(token)
131
+ }
132
+
133
+ return { command, positionals, flags }
134
+ }
135
+
136
+ function sleep(ms) {
137
+ return new Promise((resolve) => setTimeout(resolve, ms))
138
+ }
139
+
140
+ function paint(text, color) {
141
+ return `${color}${text}${ANSI.reset}`
142
+ }
143
+
144
+ function clearScreen() {
145
+ stdout.write("\x1b[2J\x1b[H")
146
+ }
147
+
148
+ function renderBrand() {
149
+ return BRAND_MARK.map((line) => paint(line, ANSI.blue)).join("\n")
150
+ }
151
+
152
+ function buildBrandHeader(title, subtitle, config = loadConfig()) {
153
+ let out = `${renderBrand()}\n\n`
154
+ out += `${paint(title, `${ANSI.bold}${ANSI.white}`)}\n`
155
+ if (subtitle) out += `${paint(subtitle, ANSI.dim)}\n`
156
+ out += `${paint(`Base URL: ${config.baseUrl || DEFAULT_BASE_URL} Auth: ${config.apiKey ? "configured" : "missing"}`, ANSI.dim)}\n\n`
157
+ return out
158
+ }
159
+
160
+ function printBrandHeader(title, subtitle, config = loadConfig()) {
161
+ clearScreen()
162
+ stdout.write(buildBrandHeader(title, subtitle, config))
163
+ }
164
+
165
+ async function askQuestion(prompt) {
166
+ const rl = readline.createInterface({ input: stdin, output: stdout })
167
+ const answer = await rl.question(prompt)
168
+ rl.close()
169
+ return answer.trim()
170
+ }
171
+
172
+ async function waitForEnter(message = "Press Enter to continue.") {
173
+ await askQuestion(`${message} `)
174
+ }
175
+
176
+ function inferBaseUrl(config, flags) {
177
+ return flags["base-url"] || config.baseUrl || DEFAULT_BASE_URL
178
+ }
179
+
180
+ function inferApiKey(config, flags) {
181
+ return flags["api-key"] || process.env.LOFENTA_API_KEY || config.apiKey || ""
182
+ }
183
+
184
+ function formatConnectionError(error, baseUrl) {
185
+ if (!error) return `Could not reach Lofenta at ${baseUrl}.`
186
+ const message = String(error.message || error)
187
+ if (message === "fetch failed") {
188
+ return `Could not reach Lofenta at ${baseUrl}. Change the base URL in Config or set LOFENTA_BASE_URL.`
189
+ }
190
+ return message
191
+ }
192
+
193
+ async function request(baseUrl, pathname, options = {}) {
194
+ const url = new URL(pathname, baseUrl)
195
+ const headers = { Accept: "application/json", ...(options.headers || {}) }
196
+
197
+ if (options.apiKey) {
198
+ headers.Authorization = `Bearer ${options.apiKey}`
199
+ }
200
+
201
+ if (options.body !== undefined) {
202
+ headers["Content-Type"] = "application/json"
203
+ }
204
+
205
+ const res = await fetch(url, {
206
+ method: options.method || "GET",
207
+ headers,
208
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
209
+ })
210
+
211
+ const json = await res.json().catch(() => null)
212
+
213
+ if (!res.ok || !json?.ok) {
214
+ const message = json?.message || `Request failed with status ${res.status}`
215
+ const error = new Error(message)
216
+ error.status = res.status
217
+ throw error
218
+ }
219
+
220
+ return json.data
221
+ }
222
+
223
+ // Streaming SSE consumer for /api/cli/projects/:id/logs. Calls `onAppend(text)`,
224
+ // `onReplace(text)`, `onStatus(status)`, `onEnd(reason)`. Resolves when the stream
225
+ // closes naturally or the server sends `event: end`.
226
+ async function streamSse(baseUrl, pathname, options = {}, handlers = {}) {
227
+ const url = new URL(pathname, baseUrl)
228
+ const headers = { Accept: "text/event-stream" }
229
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`
230
+ const res = await fetch(url, { method: "GET", headers })
231
+ if (!res.ok) {
232
+ const text = await res.text().catch(() => "")
233
+ throw new Error(`stream failed (${res.status}): ${text.slice(0, 120)}`)
234
+ }
235
+ const reader = res.body?.getReader()
236
+ if (!reader) throw new Error("stream had no body")
237
+ const decoder = new TextDecoder()
238
+ let buf = ""
239
+ while (true) {
240
+ const { value, done } = await reader.read()
241
+ if (done) break
242
+ buf += decoder.decode(value, { stream: true })
243
+ // SSE frames are separated by blank lines
244
+ let idx
245
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
246
+ const frame = buf.slice(0, idx)
247
+ buf = buf.slice(idx + 2)
248
+ let event = "message"
249
+ const dataLines = []
250
+ for (const line of frame.split("\n")) {
251
+ if (line.startsWith(":")) continue
252
+ if (line.startsWith("event: ")) event = line.slice(7).trim()
253
+ else if (line.startsWith("data: ")) dataLines.push(line.slice(6))
254
+ }
255
+ const data = dataLines.join("\n")
256
+ if (event === "append") handlers.onAppend?.(data)
257
+ else if (event === "replace") handlers.onReplace?.(data)
258
+ else if (event === "status") handlers.onStatus?.(data)
259
+ else if (event === "end") { handlers.onEnd?.(data); return }
260
+ else if (event === "error") handlers.onError?.(data)
261
+ // Generic catch-all so callers can opt into custom event names (e.g. "line" from tail).
262
+ handlers.onEvent?.(event, data)
263
+ }
264
+ }
265
+ }
266
+
267
+ function readJson(file) {
268
+ try {
269
+ return JSON.parse(fs.readFileSync(file, "utf8"))
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+
275
+ function hasFile(cwd, file) {
276
+ return fs.existsSync(path.join(cwd, file))
277
+ }
278
+
279
+ function detectFramework(cwd) {
280
+ const packageJson = readJson(path.join(cwd, "package.json"))
281
+ const composerJson = readJson(path.join(cwd, "composer.json"))
282
+ const deps = new Set([
283
+ ...Object.keys(packageJson?.dependencies || {}),
284
+ ...Object.keys(packageJson?.devDependencies || {}),
285
+ ])
286
+
287
+ if (deps.has("next")) return "nextjs"
288
+ if (deps.has("strapi")) return "strapi"
289
+ if (deps.has("@directus/sdk") || deps.has("directus")) return "directus"
290
+ if (deps.has("n8n")) return "n8n"
291
+ if (deps.has("ghost")) return "ghost"
292
+ if (hasFile(cwd, "Dockerfile")) return "docker"
293
+
294
+ const composerText = composerJson ? JSON.stringify(composerJson).toLowerCase() : ""
295
+ if (composerText.includes("laravel/framework")) return "laravel"
296
+ if (composerText.includes("wordpress")) return "wordpress"
297
+ if (composerJson) return "php"
298
+
299
+ if (hasFile(cwd, "pyproject.toml") || hasFile(cwd, "requirements.txt")) return "python"
300
+ if (packageJson) return "nodejs"
301
+ if (hasFile(cwd, "index.html")) return "static"
302
+ return "auto"
303
+ }
304
+
305
+ function runGit(cwd, args) {
306
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" })
307
+ if (result.status !== 0) return ""
308
+ return result.stdout.trim()
309
+ }
310
+
311
+ function normalizeRepo(remote) {
312
+ if (!remote) return null
313
+
314
+ const trimmed = remote.trim()
315
+ const githubHttps = trimmed.match(/^https?:\/\/github\.com\/(.+?)(?:\.git)?$/i)
316
+ if (githubHttps) return githubHttps[1]
317
+
318
+ const githubSsh = trimmed.match(/^git@github\.com:(.+?)(?:\.git)?$/i)
319
+ if (githubSsh) return githubSsh[1]
320
+
321
+ return trimmed
322
+ }
323
+
324
+ function detectProject(cwd) {
325
+ const framework = detectFramework(cwd)
326
+ const repo = normalizeRepo(runGit(cwd, ["remote", "get-url", "origin"]))
327
+ const branch = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) || "main"
328
+ const name = path.basename(cwd)
329
+
330
+ return { cwd, name, framework, repo, branch }
331
+ }
332
+
333
+ function printHelp() {
334
+ stdout.write(`
335
+ Lofenta CLI v${VERSION}
336
+
337
+ Interactive:
338
+ lofenta Open the interactive menu
339
+ /login /deploy /whoami /logout /config Slash commands inside the menu
340
+ /exit Leave the menu
341
+
342
+ Auth:
343
+ lofenta login [--api-key <key>] [--base-url <url>] [--link] [--code]
344
+ lofenta whoami [--base-url <url>] [--api-key <key>]
345
+ lofenta logout
346
+ lofenta doctor [--path <dir>] [--json]
347
+ lofenta config Print CLI config path + values
348
+
349
+ Projects:
350
+ lofenta projects List your projects with last deploy status
351
+ lofenta project <id|name> Show one project + recent deployments + domains
352
+ lofenta status [project] One-line status (uses linked project if omitted)
353
+ lofenta link [id|name] Link the current directory to a project (.lofenta file)
354
+ lofenta unlink Remove the .lofenta link
355
+ lofenta open [project] [--url] Open the dashboard in a browser (--url opens the live URL)
356
+ lofenta templates List available project templates
357
+ lofenta orgs List teams you belong to
358
+ lofenta delete <project> [--yes] Permanently delete a project (typed confirmation)
359
+
360
+ Deploys:
361
+ lofenta init Interactive wizard: pick template (paginated), name, deploy
362
+ lofenta deploy [template] [--name <n>] [--framework <f>] [--repo o/r] [--branch <b>] [--domain <d>] [--path <dir>] [--json]
363
+ lofenta redeploy [project] Trigger a fresh deploy from the last known commit
364
+ lofenta restart [project] Restart the running container (no rebuild)
365
+ lofenta rollback [project] [--deployment <id>] Roll back to the previous live deployment
366
+ lofenta deployments [project] [--limit <N>] List recent deployments
367
+ lofenta logs [project] [--deployment <id>] Live tail deployment log (SSE)
368
+ lofenta tail [project] [--lines N] Live container stdout/stderr (SSE)
369
+ lofenta exec [project] -- <cmd> Run a one-shot command inside the running container
370
+ lofenta run [project] -- <cmd> Run a LOCAL command with the project env injected
371
+ lofenta stop [project] Stop the running container (start it again later)
372
+ lofenta start [project] Start a previously stopped container
373
+ lofenta watch [project] [--interval 2] Refresh project status every N seconds
374
+
375
+ Env & secrets:
376
+ lofenta env ls <project> [--show] List env vars (values masked by default)
377
+ lofenta env set <project> KEY=VAL [...] Add or overwrite env vars
378
+ lofenta env rm <project> KEY [...] Remove env vars
379
+ lofenta env pull <project> [.env] Dump env vars to a local file
380
+ lofenta env import <project> [path/to/.env] Bulk import a .env file (skips comments)
381
+ lofenta secrets <ls|set|rm|pull|import> ... Alias for env
382
+
383
+ Domains:
384
+ lofenta domains ls <project> List custom domains + DNS records needed
385
+ lofenta domains add <project> <domain> Attach a new custom domain
386
+ lofenta domains rm <project> <domain> Detach a custom domain
387
+
388
+ Data & infra:
389
+ lofenta snapshots ls <project> List database snapshots (Postgres/MariaDB only)
390
+ lofenta snapshots create <project> [label] Take a new snapshot
391
+ lofenta snapshots restore <project> <id> Restore from a snapshot
392
+ lofenta services <project> List linked internal services
393
+ lofenta volumes <project> List named Docker volumes
394
+ lofenta db connect [project] Interactive database shell (one statement per line)
395
+ lofenta db query <project> "<statement>" Run one statement and print the result
396
+
397
+ Account:
398
+ lofenta plan Current plan summary
399
+ lofenta usage Plan limits, what you've used, what's left
400
+ lofenta billing Open the billing page in a browser
401
+ lofenta completion <bash|zsh|fish> Shell tab-completion script (source it from your rc)
402
+
403
+ Deploy parameters:
404
+ --name Project name in Lofenta. Defaults to the current directory name.
405
+ --framework Force a detected stack such as nextjs, laravel, nodejs, python, docker, static.
406
+ --template Alias for --framework.
407
+ --repo Override the detected Git remote. GitHub remotes are normalized to owner/repo.
408
+ --branch Override the detected branch.
409
+ --domain Attach or update the project domain.
410
+ --org Deploy into a team (slug or id) instead of your personal account.
411
+ --path Deploy from a different directory.
412
+ --base-url Lofenta app URL. Defaults to LOFENTA_BASE_URL or ${DEFAULT_BASE_URL}
413
+ --api-key Use an API key without storing it.
414
+ --json Print machine-readable output.
415
+
416
+ Login modes:
417
+ lofenta login
418
+ Starts the browser/device login flow and shows both a URL and a short code.
419
+
420
+ lofenta login --code
421
+ Same flow, optimized for a second screen or another browser.
422
+
423
+ lofenta login --link
424
+ Same flow, but you can just open the generated approval link directly.
425
+
426
+ lofenta login --api-key <key>
427
+ Stores a long-lived API key for unattended scripts and VPS usage.
428
+ `)
429
+ }
430
+
431
+ function printJson(value) {
432
+ stdout.write(`${JSON.stringify(value, null, 2)}\n`)
433
+ }
434
+
435
+ async function ensureBaseUrl(config, flags, interactive) {
436
+ const explicit = inferBaseUrl(config, flags)
437
+ if (explicit) return explicit
438
+ if (!interactive) throw new Error("No base URL configured. Pass --base-url or set LOFENTA_BASE_URL.")
439
+ const input = await askQuestion("Lofenta base URL: ")
440
+ if (!input) throw new Error("Base URL is required.")
441
+ return input
442
+ }
443
+
444
+ async function handleApiKeyLogin(config, flags, interactive) {
445
+ let apiKey = flags["api-key"]
446
+
447
+ if (!apiKey && interactive) {
448
+ apiKey = await askQuestion("API key: ")
449
+ }
450
+
451
+ if (!apiKey) {
452
+ throw new Error("Missing API key. Pass --api-key or generate one in Dashboard > Settings > CLI access.")
453
+ }
454
+
455
+ const baseUrl = await ensureBaseUrl(config, flags, interactive)
456
+ let data
457
+ try {
458
+ data = await request(baseUrl, "/api/cli/me", { apiKey })
459
+ } catch (error) {
460
+ throw new Error(formatConnectionError(error, baseUrl))
461
+ }
462
+
463
+ saveConfig({ ...config, baseUrl, apiKey })
464
+
465
+ stdout.write(`Saved API key for ${data.user.email}\n`)
466
+ }
467
+
468
+ async function handleDeviceLogin(config, flags, interactive, displayMode = "link") {
469
+ const baseUrl = await ensureBaseUrl(config, flags, interactive)
470
+ let flow
471
+
472
+ try {
473
+ flow = await request(baseUrl, "/api/cli/device/start", { method: "POST", body: {} })
474
+ } catch (error) {
475
+ throw new Error(formatConnectionError(error, baseUrl))
476
+ }
477
+
478
+ printBrandHeader("Login", "Authorize this CLI session in your browser.", config)
479
+ stdout.write(`${paint("Authorize Lofenta CLI", `${ANSI.bold}${ANSI.white}`)}\n`)
480
+ stdout.write(`${paint("Open the browser page below and confirm the request for this exact device code.", ANSI.dim)}\n\n`)
481
+
482
+ if (displayMode === "code") {
483
+ stdout.write(`Open ${flow.verificationUri}\n`)
484
+ stdout.write(`Code: ${paint(flow.userCode, `${ANSI.bold}${ANSI.blue}`)}\n\n`)
485
+ stdout.write(`Direct link: ${flow.verificationUriComplete}\n\n`)
486
+ } else {
487
+ stdout.write(`Open this link:\n${flow.verificationUriComplete}\n\n`)
488
+ stdout.write(`Or open ${flow.verificationUri} and enter code ${paint(flow.userCode, `${ANSI.bold}${ANSI.blue}`)}\n\n`)
489
+ }
490
+
491
+ stdout.write("Waiting for authorization")
492
+
493
+ while (true) {
494
+ await sleep((flow.interval || 2) * 1000)
495
+
496
+ try {
497
+ const result = await request(baseUrl, "/api/cli/device/poll", {
498
+ method: "POST",
499
+ body: { deviceCode: flow.deviceCode },
500
+ })
501
+
502
+ if (result.status === "approved" && result.apiKey) {
503
+ stdout.write("\n")
504
+ saveConfig({ ...config, baseUrl, apiKey: result.apiKey })
505
+ stdout.write(`Authorized as ${result.user.email}\n`)
506
+ return
507
+ }
508
+
509
+ stdout.write(".")
510
+ } catch (error) {
511
+ stdout.write("\n")
512
+ throw error
513
+ }
514
+ }
515
+ }
516
+
517
+ async function handleWhoAmI(config, flags) {
518
+ const baseUrl = inferBaseUrl(config, flags)
519
+ const apiKey = inferApiKey(config, flags)
520
+
521
+ if (!apiKey) {
522
+ throw new Error("Not logged in. Run lofenta login or pass --api-key.")
523
+ }
524
+
525
+ let data
526
+ try {
527
+ data = await request(baseUrl, "/api/cli/me", { apiKey })
528
+ } catch (error) {
529
+ throw new Error(formatConnectionError(error, baseUrl))
530
+ }
531
+ stdout.write(`${data.user.email}\n`)
532
+ }
533
+
534
+ async function handleLogout(config) {
535
+ saveConfig({ ...config, apiKey: "" })
536
+ stdout.write("Logged out of Lofenta CLI\n")
537
+ }
538
+
539
+ async function handleDoctor(config, flags) {
540
+ const cwd = path.resolve(flags.path || process.cwd())
541
+ const detected = detectProject(cwd)
542
+ const payload = {
543
+ configPath: CONFIG_PATH,
544
+ baseUrl: inferBaseUrl(config, flags),
545
+ hasApiKey: Boolean(inferApiKey(config, flags)),
546
+ detected,
547
+ }
548
+
549
+ if (flags.json) {
550
+ printJson(payload)
551
+ return
552
+ }
553
+
554
+ stdout.write(`Config: ${payload.configPath}\n`)
555
+ stdout.write(`Base URL: ${payload.baseUrl}\n`)
556
+ stdout.write(`API key: ${payload.hasApiKey ? "configured" : "missing"}\n`)
557
+ stdout.write(`Directory: ${detected.cwd}\n`)
558
+ stdout.write(`Name: ${detected.name}\n`)
559
+ stdout.write(`Framework: ${detected.framework}\n`)
560
+ stdout.write(`Repo: ${detected.repo || "not detected"}\n`)
561
+ stdout.write(`Branch: ${detected.branch}\n`)
562
+ }
563
+
564
+ async function requireAuthed(config, flags) {
565
+ const baseUrl = inferBaseUrl(config, flags)
566
+ const apiKey = inferApiKey(config, flags)
567
+ if (!apiKey) throw new Error("Not logged in. Run `lofenta login` first or pass --api-key.")
568
+ return { baseUrl, apiKey }
569
+ }
570
+
571
+ function formatRel(iso) {
572
+ if (!iso) return "?"
573
+ const diff = Date.now() - new Date(iso).getTime()
574
+ const sec = Math.max(0, Math.floor(diff / 1000))
575
+ if (sec < 60) return `${sec}s ago`
576
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`
577
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`
578
+ return `${Math.floor(sec / 86400)}d ago`
579
+ }
580
+
581
+ async function handleProjectsList(config, flags) {
582
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
583
+ const orgFilter = flags.org ? `?org=${encodeURIComponent(String(flags.org))}` : ""
584
+ const data = await request(baseUrl, `/api/cli/projects${orgFilter}`, { apiKey })
585
+ if (flags.json) {
586
+ printJson(data)
587
+ return
588
+ }
589
+ if (data.length === 0) {
590
+ stdout.write("No projects yet. Run `lofenta deploy` in a project directory to create one.\n")
591
+ return
592
+ }
593
+ const hasOrg = data.some((p) => p.org)
594
+ const head = hasOrg
595
+ ? ["NAME", "TEAM", "STATUS", "LAST DEPLOY", "BRANCH", "UPDATED"]
596
+ : ["NAME", "STATUS", "LAST DEPLOY", "BRANCH", "UPDATED"]
597
+ const rows = data.map((p) => {
598
+ const base = [
599
+ p.status,
600
+ p.lastDeploy ? `${p.lastDeploy.status}` : "(none)",
601
+ p.gitBranch || "?",
602
+ formatRel(p.updatedAt),
603
+ ]
604
+ return hasOrg ? [p.name, p.org ? p.org.slug : "-", ...base] : [p.name, ...base]
605
+ })
606
+ printTable(head, rows)
607
+ }
608
+
609
+ async function handleOrgs(config, flags) {
610
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
611
+ const data = await request(baseUrl, "/api/cli/orgs", { apiKey })
612
+ if (flags.json) { printJson(data); return }
613
+ if (!data.length) { stdout.write("You are not a member of any team.\n"); return }
614
+ printTable(
615
+ ["SLUG", "NAME", "ROLE", "PROJECTS"],
616
+ data.map((o) => [o.slug, o.name, o.role, String(o.projects)]),
617
+ )
618
+ }
619
+
620
+ async function handleProjectInfo(config, flags, positionals) {
621
+ const idOrName = positionals[0]
622
+ if (!idOrName) throw new Error("Usage: lofenta project <id|name>")
623
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
624
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(idOrName)}`, { apiKey })
625
+ if (flags.json) {
626
+ printJson(data)
627
+ return
628
+ }
629
+ stdout.write(`${paint(data.name, `${ANSI.bold}${ANSI.white}`)} ${paint(data.id, ANSI.dim)}\n`)
630
+ stdout.write(`Status: ${data.status}\n`)
631
+ if (data.subdomain) stdout.write(`Subdomain: ${data.subdomain}.lofenta.com\n`)
632
+ if (data.previewUrl) stdout.write(`URL: ${data.previewUrl}\n`)
633
+ stdout.write(`Repo: ${data.gitRepo || "(local upload / template)"}\n`)
634
+ stdout.write(`Branch: ${data.gitBranch || "?"}\n`)
635
+ if (data.domains.length > 0) {
636
+ stdout.write(`\nDomains:\n`)
637
+ for (const d of data.domains) {
638
+ stdout.write(` ${d.domain} ssl=${d.sslStatus} verified=${d.verified}\n`)
639
+ }
640
+ }
641
+ if (data.deployments.length > 0) {
642
+ stdout.write(`\nRecent deployments:\n`)
643
+ for (const d of data.deployments) {
644
+ stdout.write(` ${d.status.padEnd(11)} ${formatRel(d.createdAt).padEnd(10)} ${d.commitMessage || ""}\n`)
645
+ }
646
+ }
647
+ }
648
+
649
+ async function handleEnv(config, flags, positionals) {
650
+ const sub = (positionals[0] || "ls").toLowerCase()
651
+ if (sub === "import" || sub === "load") {
652
+ return handleEnvImport(config, flags, positionals.slice(1))
653
+ }
654
+ const projectArg = positionals[1]
655
+ if (!projectArg) throw new Error("Usage: lofenta env <ls|set|rm|pull|import> <project> [args]")
656
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
657
+ const projectPath = `/api/cli/projects/${encodeURIComponent(projectArg)}/env`
658
+
659
+ if (sub === "ls" || sub === "list") {
660
+ const data = await request(baseUrl, projectPath, { apiKey })
661
+ if (flags.json) { printJson(data); return }
662
+ if (data.length === 0) { stdout.write("No env vars set.\n"); return }
663
+ const show = flags.show
664
+ for (const v of data) {
665
+ const value = show ? v.value : "********"
666
+ stdout.write(`${v.key}=${value}\n`)
667
+ }
668
+ if (!show) stdout.write(paint("\n(values masked; pass --show to reveal)\n", ANSI.dim))
669
+ return
670
+ }
671
+
672
+ if (sub === "set") {
673
+ const pairs = positionals.slice(2)
674
+ if (pairs.length === 0) throw new Error("Usage: lofenta env set <project> KEY=VAL [KEY2=VAL2 ...]")
675
+ const set = []
676
+ for (const p of pairs) {
677
+ const eq = p.indexOf("=")
678
+ if (eq < 1) throw new Error(`expected KEY=VAL, got "${p}"`)
679
+ set.push({ key: p.slice(0, eq), value: p.slice(eq + 1) })
680
+ }
681
+ const data = await request(baseUrl, projectPath, { method: "PATCH", apiKey, body: { set } })
682
+ stdout.write(`Set ${data.added + data.updated} keys (added ${data.added}, updated ${data.updated}). Total now: ${data.total}\n`)
683
+ return
684
+ }
685
+
686
+ if (sub === "rm" || sub === "del" || sub === "delete") {
687
+ const keys = positionals.slice(2)
688
+ if (keys.length === 0) throw new Error("Usage: lofenta env rm <project> KEY [KEY2 ...]")
689
+ const data = await request(baseUrl, projectPath, { method: "PATCH", apiKey, body: { remove: keys } })
690
+ stdout.write(`Removed ${data.removed} keys. Total now: ${data.total}\n`)
691
+ return
692
+ }
693
+
694
+ if (sub === "pull") {
695
+ const dest = positionals[2] || ".env"
696
+ const data = await request(baseUrl, projectPath, { apiKey })
697
+ const lines = data.map((v) => `${v.key}=${v.value}`)
698
+ fs.writeFileSync(dest, lines.join("\n") + "\n", "utf8")
699
+ stdout.write(`Wrote ${data.length} keys to ${dest}\n`)
700
+ return
701
+ }
702
+
703
+ throw new Error(`Unknown env subcommand: ${sub}`)
704
+ }
705
+
706
+ async function handleDomains(config, flags, positionals) {
707
+ const sub = (positionals[0] || "ls").toLowerCase()
708
+ if (sub === "rm" || sub === "remove" || sub === "delete") {
709
+ return handleDomainRm(config, flags, positionals.slice(1))
710
+ }
711
+ const projectArg = positionals[1]
712
+ if (!projectArg) throw new Error("Usage: lofenta domains <ls|add|rm> <project> [domain]")
713
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
714
+ const projectPath = `/api/cli/projects/${encodeURIComponent(projectArg)}/domains`
715
+
716
+ if (sub === "ls" || sub === "list") {
717
+ const data = await request(baseUrl, projectPath, { apiKey })
718
+ if (flags.json) { printJson(data); return }
719
+ if (data.length === 0) { stdout.write("No custom domains.\n"); return }
720
+ for (const d of data) {
721
+ stdout.write(`${d.domain} ssl=${d.sslStatus} verified=${d.verified}\n`)
722
+ if (d.cfOwnershipName && !d.verified) {
723
+ stdout.write(` TXT ${d.cfOwnershipName} -> ${d.cfOwnershipValue}\n`)
724
+ }
725
+ if (d.cfCnameTarget && !d.verified) {
726
+ stdout.write(` CNAME -> ${d.cfCnameTarget}\n`)
727
+ }
728
+ }
729
+ return
730
+ }
731
+
732
+ if (sub === "add") {
733
+ const domain = positionals[2]
734
+ if (!domain) throw new Error("Usage: lofenta domains add <project> <domain>")
735
+ const data = await request(baseUrl, projectPath, { method: "POST", apiKey, body: { domain } })
736
+ if (data.alreadyAttached) {
737
+ stdout.write(`Domain ${data.domain} was already attached.\n`)
738
+ } else {
739
+ stdout.write(`Added ${data.domain}. Check \`lofenta domains ls ${projectArg}\` for the DNS values to set.\n`)
740
+ }
741
+ return
742
+ }
743
+
744
+ throw new Error(`Unknown domains subcommand: ${sub}`)
745
+ }
746
+
747
+ async function handleLogs(config, flags, positionals) {
748
+ const projectArg = positionals[0]
749
+ if (!projectArg) throw new Error("Usage: lofenta logs <project> [--deployment <id>]")
750
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
751
+ let path = `/api/cli/projects/${encodeURIComponent(projectArg)}/logs`
752
+ if (flags.deployment) path += `?deploymentId=${encodeURIComponent(flags.deployment)}`
753
+ stdout.write(paint(`Streaming logs from ${baseUrl}${path}\n\n`, ANSI.dim))
754
+ await streamSse(baseUrl, path, { apiKey }, {
755
+ onAppend: (txt) => stdout.write(txt),
756
+ onReplace: (txt) => { clearScreen(); stdout.write(txt) },
757
+ onStatus: (s) => stdout.write(paint(`\n[status: ${s}]\n`, ANSI.dim)),
758
+ onEnd: (reason) => stdout.write(paint(`\n[stream ended: ${reason}]\n`, ANSI.dim)),
759
+ onError: (msg) => stderr.write(paint(`\n[error: ${msg}]\n`, ANSI.dim)),
760
+ })
761
+ }
762
+
763
+ function printTable(head, rows) {
764
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)))
765
+ const fmt = (cells) => cells.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" ")
766
+ stdout.write(paint(fmt(head), `${ANSI.bold}${ANSI.white}`) + "\n")
767
+ for (const r of rows) stdout.write(fmt(r) + "\n")
768
+ }
769
+
770
+ function renderUsageBar(used, limit, width = 22) {
771
+ // ASCII bar, colored by pressure: green < 70%, yellow 70-89%, red 90%+.
772
+ if (limit === null || limit === undefined || limit <= 0) return paint("(unlimited)", ANSI.dim)
773
+ const ratio = Math.min(1, used / limit)
774
+ const pct = Math.round(ratio * 100)
775
+ const filled = Math.round(ratio * width)
776
+ const color = pct >= 90 ? ANSI.red : pct >= 70 ? ANSI.yellow : ANSI.green
777
+ const bar = `${color}${"#".repeat(filled)}${ANSI.reset}${ANSI.dim}${"-".repeat(width - filled)}${ANSI.reset}`
778
+ return `[${bar}] ${String(pct).padStart(3)}%`
779
+ }
780
+
781
+ // ---------- v0.4: exec / tail / env import / domains rm / usage / watch / snapshots / services / volumes / completion / init ----------
782
+
783
+ const FETCH_DEFAULT_TIMEOUT_MS = 60_000
784
+
785
+ async function rawRequest(baseUrl, pathname, options = {}) {
786
+ // Variant of request() that returns the full body (not json.data). Some new endpoints
787
+ // surface metadata alongside data; this lets the handler decide what to print.
788
+ const url = new URL(pathname, baseUrl)
789
+ const headers = { Accept: "application/json", ...(options.headers || {}) }
790
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`
791
+ if (options.body !== undefined) headers["Content-Type"] = "application/json"
792
+ const res = await fetch(url, {
793
+ method: options.method || "GET",
794
+ headers,
795
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
796
+ })
797
+ const json = await res.json().catch(() => null)
798
+ if (!res.ok || !json?.ok) {
799
+ const message = json?.message || `Request failed with status ${res.status}`
800
+ const error = new Error(message)
801
+ error.status = res.status
802
+ throw error
803
+ }
804
+ return json
805
+ }
806
+
807
+ function parseDotEnv(content) {
808
+ // Minimal .env parser: strips quotes, skips blank/# comment lines, supports inline #
809
+ // comments only when preceded by whitespace (KEY=foo # comment), and handles
810
+ // `export FOO=bar` for compatibility with shell-style files.
811
+ const out = []
812
+ for (const raw of String(content).split(/\r?\n/)) {
813
+ let line = raw.trim()
814
+ if (!line || line.startsWith("#")) continue
815
+ if (line.startsWith("export ")) line = line.slice(7)
816
+ const eq = line.indexOf("=")
817
+ if (eq === -1) continue
818
+ const key = line.slice(0, eq).trim()
819
+ let value = line.slice(eq + 1).trim()
820
+ if (!key) continue
821
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
822
+ value = value.slice(1, -1)
823
+ } else {
824
+ const hashIdx = value.search(/\s#/)
825
+ if (hashIdx >= 0) value = value.slice(0, hashIdx).trim()
826
+ }
827
+ out.push({ key, value })
828
+ }
829
+ return out
830
+ }
831
+
832
+ async function handleExec(config, flags, positionals, passthrough = []) {
833
+ // Positionals: [project] cmd... (project optional if linked)
834
+ // Or: -- cmd... with project from --project or .lofenta link
835
+ let projectArg, commandTokens
836
+ const linked = readLinkedProject()
837
+ if (passthrough.length > 0) {
838
+ // `lofenta exec [project] -- cmd...`: the -- tail is the exact command.
839
+ projectArg = positionals[0] || linked
840
+ commandTokens = passthrough
841
+ } else if (linked && positionals.length > 0) {
842
+ projectArg = positionals[0]
843
+ // If positionals[0] doesn't look like a project name (contains slash, equals etc),
844
+ // assume it's the start of the command and use the linked project.
845
+ const looksLikeCommand = /[\s\/=]/.test(projectArg) || /^-/.test(projectArg)
846
+ if (looksLikeCommand) {
847
+ commandTokens = positionals
848
+ projectArg = linked
849
+ } else {
850
+ commandTokens = positionals.slice(1)
851
+ }
852
+ } else if (linked && positionals.length === 0) {
853
+ projectArg = linked
854
+ commandTokens = []
855
+ } else {
856
+ projectArg = positionals[0]
857
+ commandTokens = positionals.slice(1)
858
+ }
859
+ if (!projectArg) throw new Error("Usage: lofenta exec <project> -- <cmd> (or omit project in a linked directory)")
860
+ const command = commandTokens.join(" ").trim()
861
+ if (!command) throw new Error("Provide a command after --. Example: lofenta exec my-app -- ls /app")
862
+
863
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
864
+ const result = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/exec`, {
865
+ method: "POST",
866
+ apiKey,
867
+ body: { command, ...(flags.user ? { user: String(flags.user) } : {}) },
868
+ })
869
+ if (result.stdout) stdout.write(result.stdout)
870
+ if (result.stderr) stderr.write(result.stderr)
871
+ if (result.truncated) stderr.write(paint("\n[output truncated at 256 KiB]\n", ANSI.dim))
872
+ if (typeof result.exitCode === "number" && result.exitCode !== 0) {
873
+ stderr.write(paint(`\n[exit ${result.exitCode}]\n`, ANSI.dim))
874
+ process.exitCode = result.exitCode
875
+ }
876
+ }
877
+
878
+ async function handleRun(config, flags, positionals, passthrough = []) {
879
+ // lofenta run [project] -- <cmd...>: run a LOCAL command with the project's env
880
+ // vars injected. Project env wins over the local shell env on key conflicts.
881
+ const projectArg = positionals[0] || flags.project || readLinkedProject()
882
+ if (!projectArg) throw new Error("Usage: lofenta run [project] -- <cmd> (or link a project first)")
883
+ if (passthrough.length === 0) throw new Error("Provide a command after --. Example: lofenta run -- npm run dev")
884
+
885
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
886
+ const vars = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/env`, { apiKey })
887
+ const injected = {}
888
+ for (const v of vars) injected[v.key] = v.value
889
+ stdout.write(paint(`Running with ${vars.length} env vars from ${projectArg}\n`, ANSI.dim))
890
+
891
+ const { spawn } = require("node:child_process")
892
+ const child = spawn(passthrough[0], passthrough.slice(1), {
893
+ stdio: "inherit",
894
+ env: { ...process.env, ...injected },
895
+ })
896
+ child.on("error", (err) => {
897
+ stderr.write(`${err.message || String(err)}\n`)
898
+ process.exit(1)
899
+ })
900
+ child.on("exit", (code, signal) => {
901
+ process.exit(signal ? 1 : (code ?? 0))
902
+ })
903
+ // Keep the CLI alive until the child exits (process.exit above ends it).
904
+ await new Promise(() => {})
905
+ }
906
+
907
+ async function handleDb(config, flags, positionals) {
908
+ // db query [project] <statement...> | db connect [project]
909
+ const sub = (positionals[0] || "connect").toLowerCase()
910
+ const rest = positionals.slice(1)
911
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
912
+
913
+ const runStatement = (projectArg, statement) =>
914
+ request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/db`, {
915
+ method: "POST",
916
+ apiKey,
917
+ body: { statement },
918
+ })
919
+
920
+ if (sub === "query" || sub === "q") {
921
+ // Project = first arg if more follow, else linked; statement = the rest joined.
922
+ let projectArg, statement
923
+ if (rest.length >= 2) { projectArg = rest[0]; statement = rest.slice(1).join(" ") }
924
+ else { projectArg = readLinkedProject(); statement = rest.join(" ") }
925
+ if (!projectArg || !statement.trim()) throw new Error('Usage: lofenta db query <project> "<statement>"')
926
+ const data = await runStatement(projectArg, statement)
927
+ if (flags.json) { printJson(data); return }
928
+ if (data.stdout) stdout.write(data.stdout)
929
+ if (data.stderr) stderr.write(data.stderr)
930
+ if (data.truncated) stderr.write(paint("\n[output truncated at 256 KiB]\n", ANSI.dim))
931
+ if (typeof data.exitCode === "number" && data.exitCode !== 0) process.exitCode = data.exitCode
932
+ return
933
+ }
934
+
935
+ if (sub === "connect" || sub === "shell") {
936
+ const projectArg = rest[0] || readLinkedProject()
937
+ if (!projectArg) throw new Error("Usage: lofenta db connect <project>")
938
+ // Probe to resolve the engine and fail fast on missing DB / access problems.
939
+ // Redis and Mongo reject the SQL but still return ok payloads with the engine.
940
+ let engine = "db"
941
+ try {
942
+ const probe = await runStatement(projectArg, "select 1")
943
+ engine = probe.engine || "db"
944
+ } catch (err) {
945
+ throw new Error(err.message || String(err))
946
+ }
947
+ stdout.write(paint(`Connected to ${projectArg} (${engine}). One statement per line. \\q or Ctrl+D to quit.\n`, ANSI.dim))
948
+ const rl = readline.createInterface({ input: stdin, output: stdout })
949
+ try {
950
+ while (true) {
951
+ let line
952
+ try { line = await rl.question(`${engine}> `) } catch { break }
953
+ line = line.trim()
954
+ if (!line) continue
955
+ if (line === "\\q" || line === "exit" || line === "quit") break
956
+ try {
957
+ const data = await runStatement(projectArg, line)
958
+ if (data.stdout) stdout.write(data.stdout)
959
+ if (data.stderr) stderr.write(data.stderr)
960
+ if (data.truncated) stderr.write(paint("[output truncated at 256 KiB]\n", ANSI.dim))
961
+ } catch (err) {
962
+ stderr.write(`${err.message || String(err)}\n`)
963
+ }
964
+ }
965
+ } finally {
966
+ rl.close()
967
+ }
968
+ return
969
+ }
970
+
971
+ throw new Error(`Unknown db subcommand: ${sub}. Use connect or query.`)
972
+ }
973
+
974
+ async function handleStopStart(config, flags, positionals, action) {
975
+ const projectArg = resolveProjectArg(positionals, flags)
976
+ if (!projectArg) throw new Error(`Usage: lofenta ${action} <project>`)
977
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
978
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/${action}`, {
979
+ method: "POST",
980
+ apiKey,
981
+ body: {},
982
+ })
983
+ stdout.write(`${paint(data.name, `${ANSI.bold}${ANSI.white}`)} is now ${data.status}.\n`)
984
+ }
985
+
986
+ async function handleDelete(config, flags, positionals) {
987
+ const projectArg = positionals[0]
988
+ if (!projectArg) throw new Error("Usage: lofenta delete <project> [--yes]")
989
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
990
+ const project = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}`, { apiKey })
991
+
992
+ stdout.write(`${paint("This permanently deletes:", `${ANSI.bold}${ANSI.white}`)} ${project.name} (${project.id})\n`)
993
+ stdout.write("Containers, volumes, domains, env vars, deploy history. No undo.\n")
994
+ if (!flags.yes) {
995
+ const answer = await askQuestion(`Type the project name (${project.name}) to confirm: `)
996
+ if (answer !== project.name) throw new Error("Name did not match. Nothing was deleted.")
997
+ }
998
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(project.id)}`, {
999
+ method: "DELETE",
1000
+ apiKey,
1001
+ })
1002
+ stdout.write(`Deleted ${paint(data.name, `${ANSI.bold}${ANSI.white}`)}.\n`)
1003
+ }
1004
+
1005
+ async function handleTail(config, flags, positionals) {
1006
+ const projectArg = resolveProjectArg(positionals, flags)
1007
+ if (!projectArg) throw new Error("Usage: lofenta tail <project> [--lines N]")
1008
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1009
+ const lines = flags.lines ? Number(flags.lines) : 200
1010
+ const path = `/api/cli/projects/${encodeURIComponent(projectArg)}/tail?lines=${encodeURIComponent(String(lines))}`
1011
+ stdout.write(paint(`Tailing container for ${projectArg}. Ctrl+C to stop.\n\n`, ANSI.dim))
1012
+ // Reuse the SSE consumer but parse `line` events instead of append/replace.
1013
+ await streamSse(baseUrl, path, { apiKey }, {
1014
+ onAppend: () => {},
1015
+ onReplace: () => {},
1016
+ onStatus: () => {},
1017
+ onEnd: (reason) => stdout.write(paint(`\n[stream ended: ${reason}]\n`, ANSI.dim)),
1018
+ onError: (msg) => stderr.write(paint(`\n[error: ${msg}]\n`, ANSI.dim)),
1019
+ onEvent: (eventName, data) => {
1020
+ if (eventName === "line") stdout.write(`${data}\n`)
1021
+ },
1022
+ })
1023
+ }
1024
+
1025
+ async function handleEnvImport(config, flags, positionals) {
1026
+ // positionals[0] = project, positionals[1] = file (default .env)
1027
+ const projectArg = positionals[0] || resolveProjectArg([], flags)
1028
+ if (!projectArg) throw new Error("Usage: lofenta env import <project> [path/to/.env]")
1029
+ const filePath = positionals[1] || ".env"
1030
+ const abs = path.resolve(filePath)
1031
+ if (!fs.existsSync(abs)) throw new Error(`File not found: ${abs}`)
1032
+ const content = fs.readFileSync(abs, "utf8")
1033
+ const parsed = parseDotEnv(content)
1034
+ if (parsed.length === 0) throw new Error("No env vars parsed from file.")
1035
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1036
+ stdout.write(`Importing ${parsed.length} env vars from ${paint(abs, ANSI.dim)}\n`)
1037
+ const result = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/env`, {
1038
+ method: "PATCH",
1039
+ apiKey,
1040
+ body: { set: parsed },
1041
+ })
1042
+ stdout.write(`${paint(String(result.added), `${ANSI.bold}${ANSI.white}`)} added, ${paint(String(result.updated), `${ANSI.bold}${ANSI.white}`)} updated. Total now ${result.total}.\n`)
1043
+ }
1044
+
1045
+ async function handleDomainRm(config, flags, positionals) {
1046
+ // positionals[0] = project, positionals[1] = domain (or only positionals[0] = domain if linked)
1047
+ const linked = readLinkedProject()
1048
+ let projectArg, domain
1049
+ if (positionals.length >= 2) {
1050
+ projectArg = positionals[0]
1051
+ domain = positionals[1]
1052
+ } else if (linked && positionals.length === 1) {
1053
+ projectArg = linked
1054
+ domain = positionals[0]
1055
+ } else {
1056
+ throw new Error("Usage: lofenta domains rm <project> <domain>")
1057
+ }
1058
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1059
+ await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/domains?domain=${encodeURIComponent(domain)}`, {
1060
+ method: "DELETE",
1061
+ apiKey,
1062
+ })
1063
+ stdout.write(`Detached ${paint(domain, `${ANSI.bold}${ANSI.white}`)} from ${projectArg}\n`)
1064
+ }
1065
+
1066
+ async function handleUsage(config, flags) {
1067
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1068
+ const data = await request(baseUrl, "/api/cli/usage", { apiKey })
1069
+ if (flags.json) { printJson(data); return }
1070
+ const { plan, usage } = data
1071
+ const mib = (b) => `${(b / 1024 / 1024).toFixed(1)} MiB`
1072
+ const gib = (g) => `${g} GiB`
1073
+ stdout.write(`${paint(plan.name + " plan", `${ANSI.bold}${ANSI.white}`)} ${paint("$" + plan.monthlyPrice + "/mo", ANSI.dim)}\n\n`)
1074
+ // Bars are rendered by hand instead of printTable: the ANSI color codes inside
1075
+ // the bar would break printTable's column width math.
1076
+ const line = (label, used, limit) => {
1077
+ const counts = limit === null || limit === undefined ? `${used} / unlimited` : `${used} / ${limit}`
1078
+ stdout.write(`${label.padEnd(28)} ${counts.padEnd(16)} ${renderUsageBar(used, limit)}\n`)
1079
+ }
1080
+ line("Services", usage.services, plan.limits.services)
1081
+ line("Custom domains", usage.customDomains, plan.limits.customDomains)
1082
+ line("Build minutes (month)", usage.buildMinutesThisMonth, plan.limits.buildMinutes)
1083
+ stdout.write(`${"Projects".padEnd(28)} ${String(usage.projectCount)}\n`)
1084
+ stdout.write(`${"Total storage".padEnd(28)} ${mib(usage.totalStorageBytes)} ${paint(`(per-project cap ${gib(plan.storageQuotaGiB)})`, ANSI.dim)}\n`)
1085
+ }
1086
+
1087
+ async function handleWatch(config, flags, positionals) {
1088
+ const projectArg = resolveProjectArg(positionals, flags)
1089
+ if (!projectArg) throw new Error("Usage: lofenta watch <project> [--interval 2]")
1090
+ const interval = Math.max(1, Number(flags.interval ?? 2)) * 1000
1091
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1092
+ stdout.write(paint(`Watching ${projectArg} every ${interval / 1000}s. Ctrl+C to stop.\n`, ANSI.dim))
1093
+ let stopped = false
1094
+ process.on("SIGINT", () => { stopped = true })
1095
+ while (!stopped) {
1096
+ try {
1097
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}`, { apiKey })
1098
+ clearScreen()
1099
+ const last = data.deployments[0]
1100
+ const head = data.previewUrl || (data.subdomain ? `https://${data.subdomain}.lofenta.app` : "(no URL)")
1101
+ stdout.write(`${paint(data.name, `${ANSI.bold}${ANSI.white}`)} ${paint(data.id, ANSI.dim)}\n`)
1102
+ stdout.write(`Status: ${data.status}\n`)
1103
+ stdout.write(`URL: ${head}\n`)
1104
+ if (last) stdout.write(`Last: ${last.status} ${formatRel(last.createdAt)} ${last.commitMessage ? `· ${last.commitMessage}` : ""}\n`)
1105
+ stdout.write(paint(`\nrefreshed ${new Date().toLocaleTimeString()} · Ctrl+C to stop\n`, ANSI.dim))
1106
+ } catch (err) {
1107
+ stderr.write(`watch: ${err.message || String(err)}\n`)
1108
+ }
1109
+ await sleep(interval)
1110
+ }
1111
+ }
1112
+
1113
+ async function handleSnapshots(config, flags, positionals) {
1114
+ const sub = (positionals[0] || "ls").toLowerCase()
1115
+ // Allow `snapshots ls/create/restore [project]` with project as 2nd arg or linked.
1116
+ let projectArg = positionals[1]
1117
+ if (!projectArg) projectArg = readLinkedProject()
1118
+ if (!projectArg) throw new Error("Usage: lofenta snapshots <ls|create|restore> <project> [snapshotId]")
1119
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1120
+ const base = `/api/cli/projects/${encodeURIComponent(projectArg)}/snapshots`
1121
+ if (sub === "ls" || sub === "list") {
1122
+ const data = await request(baseUrl, base, { apiKey })
1123
+ if (flags.json) { printJson(data); return }
1124
+ if (data.length === 0) { stdout.write("No snapshots yet.\n"); return }
1125
+ const rows = data.map((s) => [
1126
+ s.id.slice(0, 8),
1127
+ s.engine,
1128
+ s.label,
1129
+ `${(s.sizeBytes / 1024 / 1024).toFixed(1)} MiB`,
1130
+ formatRel(s.createdAt),
1131
+ ])
1132
+ printTable(["ID", "ENGINE", "LABEL", "SIZE", "WHEN"], rows)
1133
+ return
1134
+ }
1135
+ if (sub === "create" || sub === "new") {
1136
+ const label = flags.label || positionals[2] || ""
1137
+ const data = await request(baseUrl, base, { method: "POST", apiKey, body: { label } })
1138
+ stdout.write(`Created snapshot ${paint(data.id.slice(0, 8), `${ANSI.bold}${ANSI.white}`)} (${data.label}) · ${(data.sizeBytes / 1024 / 1024).toFixed(1)} MiB\n`)
1139
+ return
1140
+ }
1141
+ if (sub === "restore") {
1142
+ const snapshotId = positionals[2] || flags.snapshot
1143
+ if (!snapshotId) throw new Error("Usage: lofenta snapshots restore <project> <snapshotId>")
1144
+ const data = await request(baseUrl, `${base}/${encodeURIComponent(snapshotId)}/restore`, {
1145
+ method: "POST",
1146
+ apiKey,
1147
+ body: {},
1148
+ })
1149
+ stdout.write(`Restored snapshot ${paint(snapshotId.slice(0, 8), `${ANSI.bold}${ANSI.white}`)} (${data.label})\n`)
1150
+ return
1151
+ }
1152
+ throw new Error(`Unknown snapshots subcommand: ${sub}`)
1153
+ }
1154
+
1155
+ async function handleServices(config, flags, positionals) {
1156
+ const projectArg = resolveProjectArg(positionals, flags)
1157
+ if (!projectArg) throw new Error("Usage: lofenta services <project>")
1158
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1159
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/services`, { apiKey })
1160
+ if (flags.json) { printJson(data); return }
1161
+ if (!data.links.length) {
1162
+ stdout.write("No linked services.\n")
1163
+ } else {
1164
+ const rows = data.links.map((l) => [l.serviceProject?.name ?? l.serviceProjectId, l.serviceProject?.templateId ?? "?", l.alias ?? "-"])
1165
+ printTable(["SERVICE", "ENGINE", "ALIAS"], rows)
1166
+ }
1167
+ if (data.available?.length) {
1168
+ stdout.write(paint(`\nAvailable to link: ${data.available.map((s) => s.name).join(", ")}\n`, ANSI.dim))
1169
+ }
1170
+ }
1171
+
1172
+ async function handleVolumes(config, flags, positionals) {
1173
+ const projectArg = resolveProjectArg(positionals, flags)
1174
+ if (!projectArg) throw new Error("Usage: lofenta volumes <project>")
1175
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1176
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/volumes`, { apiKey })
1177
+ if (flags.json) { printJson(data); return }
1178
+ if (!data.volumes.length) { stdout.write("No volumes.\n"); return }
1179
+ const rows = data.volumes.map((v) => [v.name, v.mountPath, `${((v.sizeBytes ?? 0) / 1024 / 1024).toFixed(1)} MiB`])
1180
+ printTable(["NAME", "MOUNT", "SIZE"], rows)
1181
+ }
1182
+
1183
+ async function handlePlan(config, flags) {
1184
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1185
+ const data = await request(baseUrl, "/api/cli/usage", { apiKey })
1186
+ if (flags.json) { printJson(data); return }
1187
+ stdout.write(`${paint(data.plan.name, `${ANSI.bold}${ANSI.white}`)} plan ${paint("$" + data.plan.monthlyPrice + "/mo", ANSI.dim)}\n`)
1188
+ stdout.write(`Storage per project: ${data.plan.storageQuotaGiB} GiB\n`)
1189
+ stdout.write(`Deploy history kept: ${data.plan.deployHistory ?? "unlimited"}\n`)
1190
+ stdout.write(`Log retention: ${data.plan.logRetentionDays ?? "ephemeral"} days\n`)
1191
+ stdout.write(paint(`\nRun lofenta usage to see consumption details.\n`, ANSI.dim))
1192
+ }
1193
+
1194
+ async function handleBilling(config, flags) {
1195
+ const baseUrl = inferBaseUrl(config, flags)
1196
+ if (!baseUrl) throw new Error("No base URL configured.")
1197
+ const url = `${baseUrl.replace(/\/$/, "")}/dashboard/settings`
1198
+ stdout.write(`Opening ${paint(url, ANSI.dim)}\n`)
1199
+ if (!openInBrowser(url)) {
1200
+ stdout.write(paint("(could not launch a browser; copy the URL above)\n", ANSI.dim))
1201
+ }
1202
+ }
1203
+
1204
+ async function handleCompletion(config, flags, positionals) {
1205
+ // Print a shell completion script for the requested shell. Source it (e.g.
1206
+ // `source <(lofenta completion bash)`) to get tab completion on subcommands.
1207
+ const sh = (positionals[0] || flags.shell || "bash").toLowerCase()
1208
+ const COMMANDS = [
1209
+ "login", "logout", "whoami", "doctor", "config",
1210
+ "projects", "project", "status", "deploy", "redeploy", "restart", "rollback", "deployments",
1211
+ "logs", "tail", "exec", "run", "open", "link", "unlink",
1212
+ "env", "secrets", "domains", "templates", "orgs",
1213
+ "usage", "plan", "billing", "watch",
1214
+ "snapshots", "services", "volumes", "db",
1215
+ "stop", "start", "delete",
1216
+ "completion", "help", "version", "exit",
1217
+ ]
1218
+ if (sh === "bash") {
1219
+ stdout.write(`# bash completion for lofenta
1220
+ _lofenta_complete() {
1221
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
1222
+ if [ "\$COMP_CWORD" = "1" ]; then
1223
+ COMPREPLY=( \$(compgen -W "${COMMANDS.join(" ")}" -- "\$cur") )
1224
+ fi
1225
+ }
1226
+ complete -F _lofenta_complete lofenta
1227
+ `)
1228
+ return
1229
+ }
1230
+ if (sh === "zsh") {
1231
+ stdout.write(`#compdef lofenta
1232
+ _lofenta() { _arguments -C "1: :(${COMMANDS.join(" ")})" "*::arg:->args" }
1233
+ compdef _lofenta lofenta
1234
+ `)
1235
+ return
1236
+ }
1237
+ if (sh === "fish") {
1238
+ for (const cmd of COMMANDS) {
1239
+ stdout.write(`complete -c lofenta -n "__fish_use_subcommand" -a "${cmd}"\n`)
1240
+ }
1241
+ return
1242
+ }
1243
+ throw new Error(`Unsupported shell: ${sh}. Pick one of bash, zsh, fish.`)
1244
+ }
1245
+
1246
+ async function handleInit(config, flags) {
1247
+ // Interactive: pick template (paginated) → pick name → optional domain → optional .env
1248
+ // → deploy. Falls back to non-interactive when stdin is not a TTY by demanding
1249
+ // --template + --name.
1250
+ const interactive = stdin.isTTY && stdout.isTTY
1251
+ if (!interactive) {
1252
+ if (!flags.template || !flags.name) throw new Error("Non-interactive init requires --template and --name.")
1253
+ await handleDeploy(config, { template: flags.template, name: flags.name, domain: flags.domain }, [])
1254
+ return
1255
+ }
1256
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1257
+ const templates = await request(baseUrl, "/api/cli/templates", { apiKey })
1258
+ if (!templates.length) throw new Error("No templates available.")
1259
+ const PLAN_ORDER = { hobby: 0, developer: 1, team: 2 }
1260
+ templates.sort((a, b) => {
1261
+ if ((b.featured ? 1 : 0) !== (a.featured ? 1 : 0)) return (b.featured ? 1 : 0) - (a.featured ? 1 : 0)
1262
+ const pa = PLAN_ORDER[a.requiredPlan] ?? 0
1263
+ const pb = PLAN_ORDER[b.requiredPlan] ?? 0
1264
+ if (pa !== pb) return pa - pb
1265
+ return a.name.localeCompare(b.name)
1266
+ })
1267
+ const items = templates.map((t) => ({
1268
+ label: `${t.name.padEnd(22)} ${paint(t.id, ANSI.dim)}`,
1269
+ hint: t.description,
1270
+ value: t.id,
1271
+ }))
1272
+ const choice = await selectMenu({
1273
+ title: "lofenta init",
1274
+ subtitle: "Pick a template. ↑/↓ select · ←/→ page · Enter pick · q cancel.",
1275
+ items,
1276
+ config,
1277
+ pageSize: 6,
1278
+ })
1279
+ if (!choice || choice === "exit") return
1280
+ const defaultName = path.basename(process.cwd()).replace(/[^a-z0-9-]/gi, "-").toLowerCase() || "my-app"
1281
+ const name = (await askQuestion(`Project name (default: ${defaultName}): `)) || defaultName
1282
+ const domain = await askQuestion("Custom domain (optional, blank to skip): ")
1283
+ await handleDeploy(config, { template: choice, name, ...(domain ? { domain } : {}) }, [])
1284
+ }
1285
+
1286
+ const LINK_FILE = ".lofenta"
1287
+
1288
+ function findLinkFile(startDir) {
1289
+ let dir = path.resolve(startDir || process.cwd())
1290
+ while (true) {
1291
+ const candidate = path.join(dir, LINK_FILE)
1292
+ if (fs.existsSync(candidate)) return candidate
1293
+ const parent = path.dirname(dir)
1294
+ if (parent === dir) return null
1295
+ dir = parent
1296
+ }
1297
+ }
1298
+
1299
+ function readLinkedProject() {
1300
+ const file = findLinkFile()
1301
+ if (!file) return null
1302
+ try {
1303
+ const data = JSON.parse(fs.readFileSync(file, "utf8"))
1304
+ return typeof data?.project === "string" ? data.project : null
1305
+ } catch {
1306
+ return null
1307
+ }
1308
+ }
1309
+
1310
+ function writeLinkFile(projectIdOrName) {
1311
+ const dir = process.cwd()
1312
+ const file = path.join(dir, LINK_FILE)
1313
+ fs.writeFileSync(
1314
+ file,
1315
+ `${JSON.stringify({ project: projectIdOrName, linkedAt: new Date().toISOString() }, null, 2)}\n`,
1316
+ "utf8",
1317
+ )
1318
+ return file
1319
+ }
1320
+
1321
+ function resolveProjectArg(positionals, flags) {
1322
+ const explicit = positionals[0] || flags.project
1323
+ if (explicit) return explicit
1324
+ const linked = readLinkedProject()
1325
+ if (linked) return linked
1326
+ return null
1327
+ }
1328
+
1329
+ function openInBrowser(url) {
1330
+ const platform = process.platform
1331
+ let cmd
1332
+ let args
1333
+ if (platform === "darwin") { cmd = "open"; args = [url] }
1334
+ else if (platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", url] }
1335
+ else { cmd = "xdg-open"; args = [url] }
1336
+ const r = spawnSync(cmd, args, { stdio: "ignore", detached: true })
1337
+ return r.status === 0 || r.status === null
1338
+ }
1339
+
1340
+ async function handleLink(config, flags, positionals) {
1341
+ const idOrName = positionals[0] || flags.project
1342
+ if (!idOrName) throw new Error("Usage: lofenta link <id|name>")
1343
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1344
+ // Verify the project exists & belongs to us before writing the link file.
1345
+ const project = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(idOrName)}`, { apiKey })
1346
+ const file = writeLinkFile(project.name || idOrName)
1347
+ stdout.write(`Linked ${paint(process.cwd(), ANSI.dim)} to ${paint(project.name, `${ANSI.bold}${ANSI.white}`)}\n`)
1348
+ stdout.write(`${paint(file, ANSI.dim)}\n`)
1349
+ }
1350
+
1351
+ async function handleUnlink() {
1352
+ const file = findLinkFile()
1353
+ if (!file) {
1354
+ stdout.write("No .lofenta link found in this directory tree.\n")
1355
+ return
1356
+ }
1357
+ fs.unlinkSync(file)
1358
+ stdout.write(`Removed ${paint(file, ANSI.dim)}\n`)
1359
+ }
1360
+
1361
+ async function handleStatus(config, flags, positionals) {
1362
+ const projectArg = resolveProjectArg(positionals, flags)
1363
+ if (!projectArg) throw new Error("Usage: lofenta status <id|name> (or run inside a `lofenta link`ed directory)")
1364
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1365
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}`, { apiKey })
1366
+ if (flags.json) { printJson(data); return }
1367
+ const head = data.previewUrl || (data.subdomain ? `https://${data.subdomain}.lofenta.app` : "(no URL yet)")
1368
+ const last = data.deployments[0]
1369
+ stdout.write(`${paint(data.name, `${ANSI.bold}${ANSI.white}`)} ${paint(data.id, ANSI.dim)}\n`)
1370
+ stdout.write(`Status: ${data.status}\n`)
1371
+ stdout.write(`URL: ${head}\n`)
1372
+ if (last) stdout.write(`Last: ${last.status} ${formatRel(last.createdAt)} ${last.commitMessage ? `· ${last.commitMessage}` : ""}\n`)
1373
+ if (data.domains.length) {
1374
+ stdout.write(`Domains:\n`)
1375
+ for (const d of data.domains) stdout.write(` ${d.domain} ssl=${d.sslStatus}\n`)
1376
+ }
1377
+ }
1378
+
1379
+ async function handleOpen(config, flags, positionals) {
1380
+ const projectArg = resolveProjectArg(positionals, flags)
1381
+ if (!projectArg) throw new Error("Usage: lofenta open <id|name> (or run inside a `lofenta link`ed directory)")
1382
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1383
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}`, { apiKey })
1384
+ const targetUrl = flags.url
1385
+ ? (data.previewUrl || (data.subdomain ? `https://${data.subdomain}.lofenta.app` : null))
1386
+ : `${baseUrl.replace(/\/$/, "")}/dashboard/projects/${data.id}`
1387
+ if (!targetUrl) throw new Error("No live URL for this project yet. Try without --url to open the dashboard.")
1388
+ stdout.write(`Opening ${paint(targetUrl, ANSI.dim)}\n`)
1389
+ if (!openInBrowser(targetUrl)) {
1390
+ stdout.write(paint("(could not launch a browser; copy the URL above)\n", ANSI.dim))
1391
+ }
1392
+ }
1393
+
1394
+ async function handleRestart(config, flags, positionals) {
1395
+ const projectArg = resolveProjectArg(positionals, flags)
1396
+ if (!projectArg) throw new Error("Usage: lofenta restart <id|name>")
1397
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1398
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/restart`, {
1399
+ method: "POST",
1400
+ apiKey,
1401
+ body: {},
1402
+ })
1403
+ stdout.write(`Restarted ${paint(data.project.name, `${ANSI.bold}${ANSI.white}`)}`)
1404
+ if (data.portChanged) stdout.write(paint(" (host port rebound)", ANSI.dim))
1405
+ stdout.write("\n")
1406
+ }
1407
+
1408
+ async function handleRedeploy(config, flags, positionals) {
1409
+ const projectArg = resolveProjectArg(positionals, flags)
1410
+ if (!projectArg) throw new Error("Usage: lofenta redeploy <id|name>")
1411
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1412
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/redeploy`, {
1413
+ method: "POST",
1414
+ apiKey,
1415
+ body: {},
1416
+ })
1417
+ const mode = data.mode === "in_flight" ? "already in flight" : "queued"
1418
+ stdout.write(`Deployment ${paint(data.deployment.id, ANSI.dim)} ${mode}\n`)
1419
+ stdout.write(paint(`Tail with: lofenta logs ${projectArg}\n`, ANSI.dim))
1420
+ }
1421
+
1422
+ async function handleRollback(config, flags, positionals) {
1423
+ const projectArg = resolveProjectArg(positionals, flags)
1424
+ if (!projectArg) throw new Error("Usage: lofenta rollback <id|name> [--deployment <id>]")
1425
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1426
+ const body = flags.deployment ? { deploymentId: flags.deployment } : {}
1427
+ const data = await request(baseUrl, `/api/cli/projects/${encodeURIComponent(projectArg)}/rollback`, {
1428
+ method: "POST",
1429
+ apiKey,
1430
+ body,
1431
+ })
1432
+ stdout.write(`Rolling back to ${paint(data.target.commitSha.slice(0, 7), `${ANSI.bold}${ANSI.white}`)}`)
1433
+ if (data.target.commitMessage) stdout.write(paint(` ${data.target.commitMessage}`, ANSI.dim))
1434
+ stdout.write(`\nDeployment: ${paint(data.deployment.id, ANSI.dim)}\n`)
1435
+ stdout.write(paint(`Tail with: lofenta logs ${projectArg}\n`, ANSI.dim))
1436
+ }
1437
+
1438
+ async function handleDeploymentsList(config, flags, positionals) {
1439
+ const projectArg = resolveProjectArg(positionals, flags)
1440
+ if (!projectArg) throw new Error("Usage: lofenta deployments <id|name>")
1441
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1442
+ let url = `/api/cli/projects/${encodeURIComponent(projectArg)}/deployments`
1443
+ if (flags.limit) url += `?limit=${encodeURIComponent(flags.limit)}`
1444
+ const data = await request(baseUrl, url, { apiKey })
1445
+ if (flags.json) { printJson(data); return }
1446
+ if (data.deployments.length === 0) {
1447
+ stdout.write("No deployments yet.\n")
1448
+ return
1449
+ }
1450
+ const limit = flags.limit ? Math.min(Number(flags.limit) || 20, data.deployments.length) : data.deployments.length
1451
+ const rows = data.deployments.slice(0, limit).map((d) => [
1452
+ d.id.slice(0, 8),
1453
+ d.status,
1454
+ d.commitSha ? d.commitSha.slice(0, 7) : "-",
1455
+ formatRel(d.createdAt),
1456
+ (d.commitMessage || "").slice(0, 50),
1457
+ ])
1458
+ printTable(["ID", "STATUS", "COMMIT", "WHEN", "MESSAGE"], rows)
1459
+ }
1460
+
1461
+ async function handleTemplates(config, flags) {
1462
+ const { baseUrl, apiKey } = await requireAuthed(config, flags)
1463
+ const data = await request(baseUrl, `/api/cli/templates`, { apiKey })
1464
+ if (flags.json) { printJson(data); return }
1465
+ if (data.length === 0) { stdout.write("No templates available.\n"); return }
1466
+ const rows = data.map((t) => [
1467
+ t.id,
1468
+ t.name,
1469
+ t.kind,
1470
+ t.requiredPlan,
1471
+ t.featured ? "yes" : "",
1472
+ ])
1473
+ printTable(["ID", "NAME", "KIND", "MIN PLAN", "FEATURED"], rows)
1474
+ }
1475
+
1476
+ async function handleDeploy(config, flags, positionals) {
1477
+ const cwd = path.resolve(flags.path || process.cwd())
1478
+ const detected = detectProject(cwd)
1479
+ const baseUrl = inferBaseUrl(config, flags)
1480
+ const apiKey = inferApiKey(config, flags)
1481
+
1482
+ if (!apiKey) {
1483
+ throw new Error("Not logged in. Run lofenta login first or pass --api-key.")
1484
+ }
1485
+
1486
+ const explicitFramework = positionals[0] || flags.framework || flags.template
1487
+ const payload = {
1488
+ name: flags.name || detected.name,
1489
+ framework: explicitFramework || detected.framework,
1490
+ repo: flags.repo || detected.repo,
1491
+ branch: flags.branch || detected.branch,
1492
+ domain: flags.domain || null,
1493
+ org: flags.org ? String(flags.org) : null,
1494
+ }
1495
+
1496
+ let data
1497
+ try {
1498
+ data = await request(baseUrl, "/api/cli/deploy", {
1499
+ method: "POST",
1500
+ apiKey,
1501
+ body: payload,
1502
+ })
1503
+ } catch (error) {
1504
+ throw new Error(formatConnectionError(error, baseUrl))
1505
+ }
1506
+
1507
+ if (flags.json) {
1508
+ printJson({
1509
+ request: payload,
1510
+ response: data,
1511
+ })
1512
+ return
1513
+ }
1514
+
1515
+ stdout.write(`${data.mode === "created" ? "Created" : "Redeployed"} project ${data.project.name}\n`)
1516
+ stdout.write(`Framework: ${payload.framework}\n`)
1517
+ stdout.write(`Repo: ${payload.repo || "template/manual"}\n`)
1518
+ stdout.write(`Branch: ${payload.branch}\n`)
1519
+ stdout.write(`Deployment: ${data.deployment.id}\n`)
1520
+ stdout.write(`Dashboard: ${data.dashboardUrl}\n`)
1521
+ }
1522
+
1523
+ async function selectMenu({ title, subtitle, items, config = loadConfig(), pageSize = 0 }) {
1524
+ // Pagination auto-sizes to the terminal height so long lists never scroll off the
1525
+ // top (e.g. an account with many projects). An explicit pageSize (the template
1526
+ // browser) still wins. Left/Right walk pages, Up/Down walks the current page; the
1527
+ // selected index is global, so changing page lands on the first item of the new page.
1528
+ const termRows = stdout.rows && stdout.rows > 0 ? stdout.rows : 24
1529
+ const headerRows = BRAND_MARK.length + 6 // brand art + title/subtitle/base-url + footer
1530
+ const effectivePageSize = pageSize > 0 ? pageSize : Math.max(4, termRows - headerRows)
1531
+ const usingPages = items.length > effectivePageSize
1532
+ const pages = usingPages ? Math.ceil(items.length / effectivePageSize) : 1
1533
+ let selected = 0
1534
+ let page = 0
1535
+ const visibleRange = () => {
1536
+ if (!usingPages) return [0, items.length]
1537
+ const start = page * effectivePageSize
1538
+ return [start, Math.min(start + effectivePageSize, items.length)]
1539
+ }
1540
+
1541
+ return new Promise((resolve) => {
1542
+ readlineCore.emitKeypressEvents(stdin)
1543
+ stdin.resume()
1544
+ if (stdin.isTTY) stdin.setRawMode(true)
1545
+ // Hide the cursor while the menu owns the screen; a cursor hopping around
1546
+ // during repaints is half of the perceived flicker.
1547
+ stdout.write("\x1b[?25l")
1548
+
1549
+ // Flicker-free repaint: clear the screen only on the FIRST frame. Every
1550
+ // following frame jumps the cursor home (ESC[H) and overwrites in place,
1551
+ // erasing each line's leftovers (ESC[K) and anything below the frame
1552
+ // (ESC[J), all in ONE stdout.write. The old full-clear-per-keypress
1553
+ // (ESC[2J + ~30 separate writes) blanked the terminal between clear and
1554
+ // repaint, which read as glitching while navigating.
1555
+ let firstFrame = true
1556
+ const render = () => {
1557
+ let body = buildBrandHeader(title, subtitle, config)
1558
+ const [start, end] = visibleRange()
1559
+ for (let i = start; i < end; i += 1) {
1560
+ const item = items[i]
1561
+ const prefix = i === selected ? paint("> ", `${ANSI.bold}${ANSI.blue}`) : " "
1562
+ const label = i === selected
1563
+ ? paint(item.label, `${ANSI.inverse}${ANSI.bold}`)
1564
+ : paint(item.label, ANSI.white)
1565
+ const hint = item.hint ? paint(` ${item.hint}`, ANSI.dim) : ""
1566
+ body += `${prefix}${label}${hint}\n`
1567
+ }
1568
+ const footer = usingPages
1569
+ ? `Page ${page + 1}/${pages} · ↑/↓ select · ←/→ page · Enter pick · / command mode · q cancel`
1570
+ : "Use arrow keys and Enter. Press / for command mode."
1571
+ body += `\n${paint(footer, ANSI.dim)}\n`
1572
+ const frame = body.split("\n").join("\x1b[K\n") + "\x1b[J"
1573
+ stdout.write((firstFrame ? "\x1b[2J\x1b[H" : "\x1b[H") + frame)
1574
+ firstFrame = false
1575
+ }
1576
+
1577
+ const cleanup = () => {
1578
+ stdin.off("keypress", onKeypress)
1579
+ if (stdin.isTTY) stdin.setRawMode(false)
1580
+ stdin.pause()
1581
+ stdout.write("\x1b[?25h")
1582
+ }
1583
+
1584
+ const finish = (value) => {
1585
+ cleanup()
1586
+ resolve(value)
1587
+ }
1588
+
1589
+ const onKeypress = (str, key = {}) => {
1590
+ if (key.ctrl && key.name === "c") {
1591
+ finish("exit")
1592
+ return
1593
+ }
1594
+
1595
+ if (key.name === "up") {
1596
+ if (!usingPages) {
1597
+ selected = (selected - 1 + items.length) % items.length
1598
+ } else {
1599
+ const [start, end] = visibleRange()
1600
+ // Wrap inside the current page so the user doesn't accidentally jump pages.
1601
+ selected = selected - 1 < start ? end - 1 : selected - 1
1602
+ }
1603
+ render()
1604
+ return
1605
+ }
1606
+
1607
+ if (key.name === "down") {
1608
+ if (!usingPages) {
1609
+ selected = (selected + 1) % items.length
1610
+ } else {
1611
+ const [start, end] = visibleRange()
1612
+ selected = selected + 1 >= end ? start : selected + 1
1613
+ }
1614
+ render()
1615
+ return
1616
+ }
1617
+
1618
+ if (usingPages && key.name === "right") {
1619
+ page = (page + 1) % pages
1620
+ selected = page * effectivePageSize
1621
+ render()
1622
+ return
1623
+ }
1624
+ if (usingPages && key.name === "left") {
1625
+ page = (page - 1 + pages) % pages
1626
+ selected = page * effectivePageSize
1627
+ render()
1628
+ return
1629
+ }
1630
+
1631
+ if (key.name === "return") {
1632
+ finish(items[selected].value)
1633
+ return
1634
+ }
1635
+
1636
+ if (str === "/") {
1637
+ finish("__command__")
1638
+ return
1639
+ }
1640
+
1641
+ if (key.name === "escape" || str === "q") {
1642
+ finish("exit")
1643
+ }
1644
+ }
1645
+
1646
+ stdin.on("keypress", onKeypress)
1647
+ render()
1648
+ })
1649
+ }
1650
+
1651
+ async function interactiveLoginMenu(config) {
1652
+ if (config.apiKey) {
1653
+ throw new Error("Already logged in. Run /logout first if you want to replace this CLI session.")
1654
+ }
1655
+
1656
+ const choice = await selectMenu({
1657
+ title: "Login",
1658
+ subtitle: "Choose how this terminal should connect to your Lofenta account.",
1659
+ items: [
1660
+ { label: "Continue in browser", hint: "Open the full approval link in a browser", value: "link" },
1661
+ { label: "Use device code", hint: "Open the short URL and confirm with a code", value: "code" },
1662
+ { label: "Use API key", hint: "Paste a long-lived API key from dashboard settings", value: "api-key" },
1663
+ { label: "Back", hint: "Return to the main menu", value: "back" },
1664
+ ],
1665
+ config,
1666
+ })
1667
+
1668
+ if (choice === "exit" || choice === "back") return choice
1669
+
1670
+ if (choice === "api-key") {
1671
+ printBrandHeader("Login", "Paste a long-lived API key from Dashboard > Settings > CLI access.", config)
1672
+ await handleApiKeyLogin(config, {}, true)
1673
+ return null
1674
+ }
1675
+
1676
+ await handleDeviceLogin(config, {}, true, choice)
1677
+ return null
1678
+ }
1679
+
1680
+ async function dispatch(parsed, config, context = {}) {
1681
+ const command = (parsed.command || "").toLowerCase()
1682
+ const interactive = Boolean(context.interactive)
1683
+
1684
+ if (!command || command === "help") {
1685
+ printHelp()
1686
+ return
1687
+ }
1688
+
1689
+ if (command === "version") {
1690
+ stdout.write(`${VERSION}\n`)
1691
+ return
1692
+ }
1693
+
1694
+ if (command === "login") {
1695
+ if (config.apiKey) {
1696
+ throw new Error("Already logged in. Run lofenta logout first if you want to authenticate again.")
1697
+ }
1698
+
1699
+ if (parsed.flags["api-key"]) {
1700
+ await handleApiKeyLogin(config, parsed.flags, interactive)
1701
+ return
1702
+ }
1703
+
1704
+ if (interactive && !parsed.flags.code && !parsed.flags.link) {
1705
+ return interactiveLoginMenu(config)
1706
+ }
1707
+
1708
+ await handleDeviceLogin(config, parsed.flags, interactive, parsed.flags.code ? "code" : "link")
1709
+ return
1710
+ }
1711
+
1712
+ if (command === "whoami") {
1713
+ await handleWhoAmI(config, parsed.flags)
1714
+ return
1715
+ }
1716
+
1717
+ if (command === "logout") {
1718
+ await handleLogout(config)
1719
+ return
1720
+ }
1721
+
1722
+ if (command === "doctor") {
1723
+ await handleDoctor(config, parsed.flags)
1724
+ return
1725
+ }
1726
+
1727
+ if (command === "config") {
1728
+ printJson({ configPath: CONFIG_PATH, config })
1729
+ return
1730
+ }
1731
+
1732
+ if (command === "deploy") {
1733
+ await handleDeploy(config, parsed.flags, parsed.positionals)
1734
+ return
1735
+ }
1736
+
1737
+ if (command === "projects") {
1738
+ await handleProjectsList(config, parsed.flags)
1739
+ return
1740
+ }
1741
+ if (command === "project") {
1742
+ await handleProjectInfo(config, parsed.flags, parsed.positionals)
1743
+ return
1744
+ }
1745
+ if (command === "env") {
1746
+ await handleEnv(config, parsed.flags, parsed.positionals)
1747
+ return
1748
+ }
1749
+ if (command === "domains" || command === "domain") {
1750
+ await handleDomains(config, parsed.flags, parsed.positionals)
1751
+ return
1752
+ }
1753
+ if (command === "logs" || command === "log") {
1754
+ await handleLogs(config, parsed.flags, parsed.positionals)
1755
+ return
1756
+ }
1757
+
1758
+ if (command === "secrets" || command === "secret") {
1759
+ await handleEnv(config, parsed.flags, parsed.positionals)
1760
+ return
1761
+ }
1762
+ if (command === "templates" || command === "template") {
1763
+ await handleTemplates(config, parsed.flags)
1764
+ return
1765
+ }
1766
+ if (command === "link") {
1767
+ await handleLink(config, parsed.flags, parsed.positionals)
1768
+ return
1769
+ }
1770
+ if (command === "unlink") {
1771
+ await handleUnlink()
1772
+ return
1773
+ }
1774
+ if (command === "status") {
1775
+ await handleStatus(config, parsed.flags, parsed.positionals)
1776
+ return
1777
+ }
1778
+ if (command === "open") {
1779
+ await handleOpen(config, parsed.flags, parsed.positionals)
1780
+ return
1781
+ }
1782
+ if (command === "restart") {
1783
+ await handleRestart(config, parsed.flags, parsed.positionals)
1784
+ return
1785
+ }
1786
+ if (command === "redeploy") {
1787
+ await handleRedeploy(config, parsed.flags, parsed.positionals)
1788
+ return
1789
+ }
1790
+ if (command === "rollback") {
1791
+ await handleRollback(config, parsed.flags, parsed.positionals)
1792
+ return
1793
+ }
1794
+ if (command === "deployments" || command === "deployment") {
1795
+ await handleDeploymentsList(config, parsed.flags, parsed.positionals)
1796
+ return
1797
+ }
1798
+
1799
+ if (command === "exec") {
1800
+ await handleExec(config, parsed.flags, parsed.positionals, parsed.passthrough || [])
1801
+ return
1802
+ }
1803
+ if (command === "run") {
1804
+ await handleRun(config, parsed.flags, parsed.positionals, parsed.passthrough || [])
1805
+ return
1806
+ }
1807
+ if (command === "tail") {
1808
+ await handleTail(config, parsed.flags, parsed.positionals)
1809
+ return
1810
+ }
1811
+
1812
+ if (command === "usage") {
1813
+ await handleUsage(config, parsed.flags)
1814
+ return
1815
+ }
1816
+ if (command === "plan") {
1817
+ await handlePlan(config, parsed.flags)
1818
+ return
1819
+ }
1820
+ if (command === "billing") {
1821
+ await handleBilling(config, parsed.flags)
1822
+ return
1823
+ }
1824
+ if (command === "watch") {
1825
+ await handleWatch(config, parsed.flags, parsed.positionals)
1826
+ return
1827
+ }
1828
+ if (command === "snapshots" || command === "snapshot") {
1829
+ await handleSnapshots(config, parsed.flags, parsed.positionals)
1830
+ return
1831
+ }
1832
+ if (command === "orgs" || command === "org" || command === "teams") {
1833
+ await handleOrgs(config, parsed.flags)
1834
+ return
1835
+ }
1836
+ if (command === "db" || command === "database") {
1837
+ await handleDb(config, parsed.flags, parsed.positionals)
1838
+ return
1839
+ }
1840
+ if (command === "stop") {
1841
+ await handleStopStart(config, parsed.flags, parsed.positionals, "stop")
1842
+ return
1843
+ }
1844
+ if (command === "start") {
1845
+ await handleStopStart(config, parsed.flags, parsed.positionals, "start")
1846
+ return
1847
+ }
1848
+ if (command === "delete" || command === "remove") {
1849
+ await handleDelete(config, parsed.flags, parsed.positionals)
1850
+ return
1851
+ }
1852
+ if (command === "services" || command === "service") {
1853
+ await handleServices(config, parsed.flags, parsed.positionals)
1854
+ return
1855
+ }
1856
+ if (command === "volumes" || command === "volume") {
1857
+ await handleVolumes(config, parsed.flags, parsed.positionals)
1858
+ return
1859
+ }
1860
+ if (command === "completion") {
1861
+ await handleCompletion(config, parsed.flags, parsed.positionals)
1862
+ return
1863
+ }
1864
+ if (command === "init") {
1865
+ await handleInit(config, parsed.flags)
1866
+ return
1867
+ }
1868
+
1869
+ if (command === "exit" || command === "quit") {
1870
+ return "exit"
1871
+ }
1872
+
1873
+ throw new Error(`Unknown command: ${command}`)
1874
+ }
1875
+
1876
+ async function runCommandMode() {
1877
+ printBrandHeader("Command mode", "Type a slash command like /login, /deploy, or /whoami.")
1878
+ const line = await askQuestion("lofenta> ")
1879
+ if (!line) return null
1880
+ const tokens = tokenize(line)
1881
+ const dd = tokens.indexOf("--")
1882
+ const parsed = parseArgv(dd === -1 ? tokens : tokens.slice(0, dd))
1883
+ parsed.passthrough = dd === -1 ? [] : tokens.slice(dd + 1)
1884
+ return dispatch(parsed, loadConfig(), { interactive: true })
1885
+ }
1886
+
1887
+ async function runInteractiveAction(title, subtitle, action) {
1888
+ printBrandHeader(title, subtitle)
1889
+ try {
1890
+ const result = await action()
1891
+ if (result === "exit") return "exit"
1892
+ } catch (error) {
1893
+ stderr.write(`${error.message || String(error)}\n`)
1894
+ }
1895
+ await waitForEnter()
1896
+ return null
1897
+ }
1898
+
1899
+ // Paginated template browser launched from the interactive menu. Fetches the catalog
1900
+ // once, renders 6 per page in a paginated selectMenu, and on pick prompts for a project
1901
+ // name then dispatches to handleDeploy(template, name). Used by the Templates menu item.
1902
+ async function runTemplateBrowser(config) {
1903
+ let templates
1904
+ try {
1905
+ const { baseUrl, apiKey } = await requireAuthed(config, {})
1906
+ templates = await request(baseUrl, "/api/cli/templates", { apiKey })
1907
+ } catch (err) {
1908
+ return runInteractiveAction("Templates", "Could not fetch templates.", async () => {
1909
+ stderr.write(`${err.message || String(err)}\n`)
1910
+ })
1911
+ }
1912
+ if (!templates.length) {
1913
+ return runInteractiveAction("Templates", "No templates available.", async () => {})
1914
+ }
1915
+
1916
+ // Sort: featured first, then required-plan ascending, then alpha. Keeps the most
1917
+ // useful options on page 1.
1918
+ const PLAN_ORDER = { hobby: 0, developer: 1, team: 2 }
1919
+ templates.sort((a, b) => {
1920
+ if ((b.featured ? 1 : 0) !== (a.featured ? 1 : 0)) return (b.featured ? 1 : 0) - (a.featured ? 1 : 0)
1921
+ const pa = PLAN_ORDER[a.requiredPlan] ?? 0
1922
+ const pb = PLAN_ORDER[b.requiredPlan] ?? 0
1923
+ if (pa !== pb) return pa - pb
1924
+ return a.name.localeCompare(b.name)
1925
+ })
1926
+
1927
+ const items = templates.map((t) => {
1928
+ const tags = []
1929
+ if (t.featured) tags.push("featured")
1930
+ if (t.requiredPlan && t.requiredPlan !== "hobby") tags.push(t.requiredPlan)
1931
+ if (t.requires?.length) tags.push(`needs: ${t.requires.join(",")}`)
1932
+ const tagSuffix = tags.length ? ` [${tags.join(" · ")}]` : ""
1933
+ return {
1934
+ label: `${t.name.padEnd(22)} ${paint(t.id, ANSI.dim)}`,
1935
+ hint: `${t.description}${tagSuffix}`,
1936
+ value: t.id,
1937
+ }
1938
+ })
1939
+
1940
+ const choice = await selectMenu({
1941
+ title: "Templates",
1942
+ subtitle: "Pick one to deploy as a new project. ↑/↓ select · ←/→ page · Enter pick · q cancel.",
1943
+ items,
1944
+ config,
1945
+ pageSize: 6,
1946
+ })
1947
+ if (!choice || choice === "exit") return null
1948
+ if (choice === "__command__") {
1949
+ return runInteractiveAction("Command mode", "Type a slash command or regular CLI command.", runCommandMode)
1950
+ }
1951
+
1952
+ const picked = templates.find((t) => t.id === choice)
1953
+ return runInteractiveAction(
1954
+ `Deploy ${picked?.name ?? choice}`,
1955
+ `Creating a new project from the ${picked?.name ?? choice} template.`,
1956
+ async () => {
1957
+ const defaultName = path.basename(process.cwd()).replace(/[^a-z0-9-]/gi, "-").toLowerCase() || "my-app"
1958
+ const name = (await askQuestion(`Project name (default: ${defaultName}): `)) || defaultName
1959
+ await handleDeploy(config, { template: choice, name }, [])
1960
+ },
1961
+ )
1962
+ }
1963
+
1964
+ // Pick a project for interactive actions: linked .lofenta wins, otherwise fetch the list
1965
+ // and let the user choose. Returns the project name string or null when the user backs out.
1966
+ async function promptProject(config, { actionLabel = "Pick a project" } = {}) {
1967
+ const linked = readLinkedProject()
1968
+ if (linked) return linked
1969
+ const { baseUrl, apiKey } = await requireAuthed(config, {})
1970
+ let projects
1971
+ try {
1972
+ projects = await request(baseUrl, "/api/cli/projects", { apiKey })
1973
+ } catch (err) {
1974
+ stderr.write(`${err.message || String(err)}\n`)
1975
+ return null
1976
+ }
1977
+ if (!projects.length) {
1978
+ stdout.write("No projects yet. Run a deploy from your repo first.\n")
1979
+ return null
1980
+ }
1981
+ const choice = await selectMenu({
1982
+ title: actionLabel,
1983
+ subtitle: "Use up/down + Enter. Press / for command mode, q to cancel.",
1984
+ items: projects.map((p) => ({
1985
+ label: p.name,
1986
+ hint: `${p.status} ${p.lastDeploy?.status ?? "no deploys"}`,
1987
+ value: p.name,
1988
+ })),
1989
+ config,
1990
+ })
1991
+ if (!choice || choice === "exit" || choice === "__command__") return null
1992
+ return choice
1993
+ }
1994
+
1995
+ async function startShell() {
1996
+ while (true) {
1997
+ const config = loadConfig()
1998
+ const isAuthenticated = Boolean(config.apiKey)
1999
+ const menuItems = isAuthenticated
2000
+ ? [
2001
+ { label: "New project (init)", hint: "Pick a template, name it, deploy it", value: "init" },
2002
+ { label: "Status", hint: "One-line snapshot of a project", value: "status" },
2003
+ { label: "Deploy current directory", hint: "Auto-detect the app here and create / redeploy", value: "deploy" },
2004
+ { label: "Redeploy", hint: "Trigger a fresh build for an existing project", value: "redeploy" },
2005
+ { label: "Restart container", hint: "Bounce the running container without rebuilding", value: "restart" },
2006
+ { label: "Rollback", hint: "Revert to the previous successful deployment", value: "rollback" },
2007
+ { label: "Tail container", hint: "Live stdout/stderr from the running container", value: "tail" },
2008
+ { label: "Deploy log", hint: "Live deploy log via SSE", value: "logs" },
2009
+ { label: "Run command (exec)", hint: "One-shot command in the running container", value: "exec" },
2010
+ { label: "Open in browser", hint: "Dashboard page (add --url for the live site)", value: "open" },
2011
+ { label: "Watch", hint: "Refresh project status every 2s", value: "watch" },
2012
+ { label: "Snapshots", hint: "List / create / restore database snapshots", value: "snapshots" },
2013
+ { label: "Services", hint: "Linked internal Postgres/Redis/MariaDB", value: "services" },
2014
+ { label: "Volumes", hint: "Named Docker volumes attached", value: "volumes" },
2015
+ { label: "Projects", hint: "List all your projects", value: "projects" },
2016
+ { label: "Deployments", hint: "Recent deployments for a project", value: "deployments" },
2017
+ { label: "Templates", hint: "Browse + deploy from catalog (paginated)", value: "templates" },
2018
+ { label: "Usage", hint: "Plan limits, what you've used, what's left", value: "usage" },
2019
+ { label: "Plan", hint: "Current plan summary", value: "plan" },
2020
+ { label: "Billing", hint: "Open the billing page in a browser", value: "billing" },
2021
+ { label: "Link this directory", hint: `Save a ${LINK_FILE} so commands omit the project arg`, value: "link" },
2022
+ { label: "Unlink this directory", hint: `Remove the ${LINK_FILE} file`, value: "unlink" },
2023
+ { label: "Who am I", hint: "Show the currently connected account", value: "whoami" },
2024
+ { label: "Logout", hint: "Remove the saved API key from this machine", value: "logout" },
2025
+ { label: "Config", hint: "View saved base URL and auth state", value: "config" },
2026
+ { label: "Help", hint: "Show command reference and flags", value: "help" },
2027
+ { label: "Exit", hint: "Close the CLI", value: "exit" },
2028
+ ]
2029
+ : [
2030
+ { label: "Login", hint: "Connect with browser approval, device code, or API key", value: "login" },
2031
+ { label: "Deploy current project", hint: "Auto-detect the app in the current directory", value: "deploy" },
2032
+ { label: "Config", hint: "View saved base URL and auth state", value: "config" },
2033
+ { label: "Help", hint: "Show command reference and flags", value: "help" },
2034
+ { label: "Exit", hint: "Close the CLI", value: "exit" },
2035
+ ]
2036
+
2037
+ const choice = await selectMenu({
2038
+ title: "LOFENTA CLI",
2039
+ subtitle: "Manage your projects without leaving the terminal.",
2040
+ items: menuItems,
2041
+ config,
2042
+ })
2043
+
2044
+ if (choice === "exit") break
2045
+
2046
+ if (choice === "__command__") {
2047
+ const result = await runInteractiveAction("Command mode", "Type a slash command or regular CLI command.", runCommandMode)
2048
+ if (result === "exit") break
2049
+ continue
2050
+ }
2051
+
2052
+ if (choice === "login") {
2053
+ const result = await runInteractiveAction("Login", "Choose how this terminal should connect to Lofenta.", async () => interactiveLoginMenu(config))
2054
+ if (result === "exit") break
2055
+ continue
2056
+ }
2057
+
2058
+ if (choice === "deploy") {
2059
+ await runInteractiveAction("Deploy", "Lofenta will inspect this directory and deploy the detected app.", async () => {
2060
+ await handleDeploy(config, {}, [])
2061
+ })
2062
+ continue
2063
+ }
2064
+
2065
+ if (choice === "status") {
2066
+ const project = await promptProject(config, { actionLabel: "Status of which project?" })
2067
+ if (!project) continue
2068
+ await runInteractiveAction("Status", `Snapshot for ${project}.`, async () => {
2069
+ await handleStatus(config, {}, [project])
2070
+ })
2071
+ continue
2072
+ }
2073
+
2074
+ if (choice === "redeploy") {
2075
+ const project = await promptProject(config, { actionLabel: "Redeploy which project?" })
2076
+ if (!project) continue
2077
+ await runInteractiveAction("Redeploy", `Triggering a fresh deploy for ${project}.`, async () => {
2078
+ await handleRedeploy(config, {}, [project])
2079
+ })
2080
+ continue
2081
+ }
2082
+
2083
+ if (choice === "restart") {
2084
+ const project = await promptProject(config, { actionLabel: "Restart which project's container?" })
2085
+ if (!project) continue
2086
+ await runInteractiveAction("Restart", `Bouncing the running container for ${project}.`, async () => {
2087
+ await handleRestart(config, {}, [project])
2088
+ })
2089
+ continue
2090
+ }
2091
+
2092
+ if (choice === "rollback") {
2093
+ const project = await promptProject(config, { actionLabel: "Rollback which project?" })
2094
+ if (!project) continue
2095
+ await runInteractiveAction("Rollback", `Reverting ${project} to its previous live deployment.`, async () => {
2096
+ await handleRollback(config, {}, [project])
2097
+ })
2098
+ continue
2099
+ }
2100
+
2101
+ if (choice === "logs") {
2102
+ const project = await promptProject(config, { actionLabel: "Tail logs for which project?" })
2103
+ if (!project) continue
2104
+ await runInteractiveAction("Logs", `Streaming logs for ${project}. Ctrl+C to stop.`, async () => {
2105
+ await handleLogs(config, {}, [project])
2106
+ })
2107
+ continue
2108
+ }
2109
+
2110
+ if (choice === "open") {
2111
+ const project = await promptProject(config, { actionLabel: "Open which project?" })
2112
+ if (!project) continue
2113
+ await runInteractiveAction("Open", `Launching dashboard for ${project}.`, async () => {
2114
+ await handleOpen(config, {}, [project])
2115
+ })
2116
+ continue
2117
+ }
2118
+
2119
+ if (choice === "projects") {
2120
+ await runInteractiveAction("Projects", "All projects on your account.", async () => {
2121
+ await handleProjectsList(config, {})
2122
+ })
2123
+ continue
2124
+ }
2125
+
2126
+ if (choice === "deployments") {
2127
+ const project = await promptProject(config, { actionLabel: "Deployments for which project?" })
2128
+ if (!project) continue
2129
+ await runInteractiveAction("Deployments", `Recent deployments for ${project}.`, async () => {
2130
+ await handleDeploymentsList(config, {}, [project])
2131
+ })
2132
+ continue
2133
+ }
2134
+
2135
+ if (choice === "templates") {
2136
+ const result = await runTemplateBrowser(config)
2137
+ if (result === "exit") break
2138
+ continue
2139
+ }
2140
+
2141
+ if (choice === "link") {
2142
+ const project = await promptProject(config, { actionLabel: "Link this directory to which project?" })
2143
+ if (!project) continue
2144
+ await runInteractiveAction("Link", `Saving ${LINK_FILE} pointing at ${project}.`, async () => {
2145
+ await handleLink(config, {}, [project])
2146
+ })
2147
+ continue
2148
+ }
2149
+
2150
+ if (choice === "unlink") {
2151
+ await runInteractiveAction("Unlink", "Removing the .lofenta link file.", async () => {
2152
+ await handleUnlink()
2153
+ })
2154
+ continue
2155
+ }
2156
+
2157
+ if (choice === "init") {
2158
+ await runInteractiveAction("New project", "Pick a template and deploy.", async () => {
2159
+ await handleInit(config, {})
2160
+ })
2161
+ continue
2162
+ }
2163
+
2164
+ if (choice === "tail") {
2165
+ const project = await promptProject(config, { actionLabel: "Tail container for which project?" })
2166
+ if (!project) continue
2167
+ await runInteractiveAction("Tail", `Streaming container output for ${project}. Ctrl+C to stop.`, async () => {
2168
+ await handleTail(config, {}, [project])
2169
+ })
2170
+ continue
2171
+ }
2172
+
2173
+ if (choice === "exec") {
2174
+ const project = await promptProject(config, { actionLabel: "Run a command in which project?" })
2175
+ if (!project) continue
2176
+ await runInteractiveAction("Exec", `One-shot command inside ${project}.`, async () => {
2177
+ const cmd = await askQuestion("Command to run: ")
2178
+ if (!cmd) return
2179
+ await handleExec(config, {}, [project, ...tokenize(cmd)])
2180
+ })
2181
+ continue
2182
+ }
2183
+
2184
+ if (choice === "watch") {
2185
+ const project = await promptProject(config, { actionLabel: "Watch which project?" })
2186
+ if (!project) continue
2187
+ await runInteractiveAction("Watch", `Refreshing ${project}. Ctrl+C to stop.`, async () => {
2188
+ await handleWatch(config, {}, [project])
2189
+ })
2190
+ continue
2191
+ }
2192
+
2193
+ if (choice === "snapshots") {
2194
+ const project = await promptProject(config, { actionLabel: "Snapshots of which project?" })
2195
+ if (!project) continue
2196
+ await runInteractiveAction("Snapshots", `Database snapshots for ${project}.`, async () => {
2197
+ await handleSnapshots(config, {}, ["ls", project])
2198
+ })
2199
+ continue
2200
+ }
2201
+
2202
+ if (choice === "services") {
2203
+ const project = await promptProject(config, { actionLabel: "Services for which project?" })
2204
+ if (!project) continue
2205
+ await runInteractiveAction("Services", `Linked services for ${project}.`, async () => {
2206
+ await handleServices(config, {}, [project])
2207
+ })
2208
+ continue
2209
+ }
2210
+
2211
+ if (choice === "volumes") {
2212
+ const project = await promptProject(config, { actionLabel: "Volumes for which project?" })
2213
+ if (!project) continue
2214
+ await runInteractiveAction("Volumes", `Docker volumes attached to ${project}.`, async () => {
2215
+ await handleVolumes(config, {}, [project])
2216
+ })
2217
+ continue
2218
+ }
2219
+
2220
+ if (choice === "usage") {
2221
+ await runInteractiveAction("Usage", "Plan limits, what you've used, what's left.", async () => {
2222
+ await handleUsage(config, {})
2223
+ })
2224
+ continue
2225
+ }
2226
+
2227
+ if (choice === "plan") {
2228
+ await runInteractiveAction("Plan", "Current plan summary.", async () => {
2229
+ await handlePlan(config, {})
2230
+ })
2231
+ continue
2232
+ }
2233
+
2234
+ if (choice === "billing") {
2235
+ await runInteractiveAction("Billing", "Opening the billing page in a browser.", async () => {
2236
+ await handleBilling(config, {})
2237
+ })
2238
+ continue
2239
+ }
2240
+
2241
+ if (choice === "whoami") {
2242
+ await runInteractiveAction("Who am I", "Current authenticated account.", async () => {
2243
+ await handleWhoAmI(config, {})
2244
+ })
2245
+ continue
2246
+ }
2247
+
2248
+ if (choice === "logout") {
2249
+ await runInteractiveAction("Logout", "Remove the saved local login for this machine.", async () => {
2250
+ await handleLogout(config)
2251
+ })
2252
+ continue
2253
+ }
2254
+
2255
+ if (choice === "config") {
2256
+ await runInteractiveAction("Config", "Saved local CLI configuration.", async () => {
2257
+ printJson({ configPath: CONFIG_PATH, config })
2258
+ })
2259
+ continue
2260
+ }
2261
+
2262
+ if (choice === "help") {
2263
+ await runInteractiveAction("Help", "Command reference and supported flags.", async () => {
2264
+ printHelp()
2265
+ })
2266
+ }
2267
+ }
2268
+ }
2269
+
2270
+ async function main() {
2271
+ // Everything after a bare -- is an untouched passthrough command (run/exec).
2272
+ // Splitting BEFORE parseArgv also fixes the old bug where parseArgv treated
2273
+ // "--" as an empty flag and swallowed the next token of the command.
2274
+ const raw = process.argv.slice(2)
2275
+
2276
+ // Global flags handled before command parsing so `lofenta --version` / `-v` and
2277
+ // `lofenta --help` / `-h` work even in a TTY (where a bare invocation opens the menu).
2278
+ if (raw[0] === "--version" || raw[0] === "-v" || raw[0] === "version") {
2279
+ stdout.write(`${VERSION}\n`)
2280
+ return
2281
+ }
2282
+ if (raw[0] === "--help" || raw[0] === "-h") {
2283
+ printHelp()
2284
+ return
2285
+ }
2286
+
2287
+ const dd = raw.indexOf("--")
2288
+ const parsed = parseArgv(dd === -1 ? raw : raw.slice(0, dd))
2289
+ parsed.passthrough = dd === -1 ? [] : raw.slice(dd + 1)
2290
+
2291
+ if (!parsed.command) {
2292
+ if (stdin.isTTY && stdout.isTTY) {
2293
+ await startShell()
2294
+ exit(0)
2295
+ }
2296
+
2297
+ printHelp()
2298
+ return
2299
+ }
2300
+
2301
+ try {
2302
+ await dispatch(parsed, loadConfig(), { interactive: false })
2303
+ } catch (error) {
2304
+ stderr.write(`${error.message || String(error)}\n`)
2305
+ exit(1)
2306
+ }
2307
+ }
2308
+
2309
+ main()