oche-cli 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/dist/index.js +675 -194
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -24,6 +24,28 @@ oche repos
24
24
 
25
25
  Commit messages are written by your Oche server with the model it's configured for (OpenRouter), so the CLI needs no AI key. Your staged diff goes to your server and on to OpenRouter. Pass `-m "message"` to skip AI.
26
26
 
27
+ ## Deployments: Coolify from the terminal
28
+
29
+ When the repo is linked to a Coolify instance, these read and drive it through your Oche server, which holds the Coolify token.
30
+
31
+ ```sh
32
+ oche logs --stage prod # the running container's log: why it's crash-looping
33
+ oche logs --stage prod --failures # just the errors and the tail
34
+ oche logs --app api --build # the build log of the latest deployment instead
35
+ oche deployments # recent deployments per app: status, commit, how long, when
36
+ oche envs # which stages define each variable (names only)
37
+ oche envs --stage prod --key DATABASE_URL
38
+ oche deploy --stage staging # redeploy (--force rebuilds without the cache); asks first
39
+ oche rollback --app api # the images Coolify can go back to
40
+ oche rollback --app api --to a1b2c3d
41
+ oche explain --stage prod # ask your server's AI what broke the last failed deployment
42
+ oche preflight --to prod # missing variables, and whether staging is healthy
43
+ ```
44
+
45
+ Each one picks the app with `--app <name>` (or the start of its uuid) or `--stage prod|staging|dev`; with one app it needs neither, and with several it says which ones there are. `oche deploy` and `oche rollback` ask before they act unless you pass `-y`.
46
+
47
+ `oche envs` never prints values. The server compares names, and whether staging and production hold the same value, and sends only that. Log lines Coolify marks as holding secrets are left out too.
48
+
27
49
  ## Without a server: `oche pr`
28
50
 
29
51
  `oche pr` works from your machine alone. It talks to OpenRouter for the commit message and to GitHub with your own token, so it needs no Oche login.
package/dist/index.js CHANGED
@@ -3371,7 +3371,7 @@ var program = new Command();
3371
3371
  // package.json
3372
3372
  var package_default = {
3373
3373
  name: "oche-cli",
3374
- version: "0.1.2",
3374
+ version: "0.3.0",
3375
3375
  description: "Command-line client for Oche: promote dev \u2192 staging \u2192 main from your terminal.",
3376
3376
  license: "MIT",
3377
3377
  repository: {
@@ -3503,6 +3503,18 @@ var Client = class {
3503
3503
  preflight(r, to) {
3504
3504
  return this.request("GET", `${repoPath(r)}/preflight?to=${to}`);
3505
3505
  }
3506
+ /* ---- npm publishing ---- */
3507
+ npmStatus(r) {
3508
+ return this.request("GET", `${repoPath(r)}/npm`);
3509
+ }
3510
+ /** Opens (or updates) the PR that adds the publish workflow. */
3511
+ npmSetup(r, body) {
3512
+ return this.request("POST", `${repoPath(r)}/npm`, body);
3513
+ }
3514
+ /** Records that npm now trusts the workflow; npm has no API the server could check. */
3515
+ npmTrust(r, body) {
3516
+ return this.request("POST", `${repoPath(r)}/npm/trust`, body);
3517
+ }
3506
3518
  events(repo, limit) {
3507
3519
  const q = new URLSearchParams({ limit: String(limit) });
3508
3520
  if (repo) q.set("repo", `${repo.owner}/${repo.repo}`);
@@ -3563,6 +3575,449 @@ function networkError(e, baseUrl, timeoutMs = TIMEOUT_MS) {
3563
3575
  return new CliError(`Couldn't reach ${baseUrl} (${reason}).`);
3564
3576
  }
3565
3577
 
3578
+ // src/git.ts
3579
+ import { execFile } from "child_process";
3580
+ import { promisify } from "util";
3581
+ var slug = (r) => `${r.owner}/${r.repo}`;
3582
+ var GITHUB_HOST = /^(?:www\.|ssh\.)?github\.com(?:-[\w.-]+)?$/i;
3583
+ var OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
3584
+ var REPO = /^[A-Za-z0-9._-]{1,100}$/;
3585
+ var URL_SCHEMES = /* @__PURE__ */ new Set(["https:", "http:", "ssh:", "git:", "git+ssh:", "ssh+git:"]);
3586
+ function toSlug(path2) {
3587
+ const parts = path2.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/");
3588
+ if (parts.length !== 2) return null;
3589
+ const [owner, repo] = parts;
3590
+ if (!OWNER.test(owner) || !REPO.test(repo) || repo === "." || repo === "..") return null;
3591
+ return { owner, repo };
3592
+ }
3593
+ function parseGitHubRemote(remote) {
3594
+ const s = remote.trim();
3595
+ let host;
3596
+ let path2;
3597
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
3598
+ let u;
3599
+ try {
3600
+ u = new URL(s);
3601
+ } catch {
3602
+ return null;
3603
+ }
3604
+ if (!URL_SCHEMES.has(u.protocol)) return null;
3605
+ host = u.hostname;
3606
+ path2 = decodeURIComponent(u.pathname);
3607
+ } else {
3608
+ const m = /^(?:[^@\s/]+@)?([^:\s/]+):(.+)$/.exec(s);
3609
+ if (!m) return null;
3610
+ host = m[1];
3611
+ path2 = m[2];
3612
+ }
3613
+ if (!GITHUB_HOST.test(host)) return null;
3614
+ return toSlug(path2);
3615
+ }
3616
+ function parseRepoArg(arg) {
3617
+ const s = arg.trim();
3618
+ const found = /^[^/\s:@]+\/[^/\s:@]+$/.test(s) ? toSlug(s) : parseGitHubRemote(s);
3619
+ if (!found) throw new CliError(`Expected owner/repo, got "${arg}".`);
3620
+ return found;
3621
+ }
3622
+ var run = promisify(execFile);
3623
+ async function inferRepo(cwd = process.cwd()) {
3624
+ let url;
3625
+ try {
3626
+ const { stdout } = await run("git", ["remote", "get-url", "origin"], { cwd });
3627
+ url = stdout.trim();
3628
+ } catch (e) {
3629
+ if (e.code === "ENOENT") {
3630
+ throw new CliError("git isn't installed, so the repo can't be inferred. Pass owner/repo.");
3631
+ }
3632
+ throw new CliError('No repo given and no git remote "origin" here. Pass owner/repo.');
3633
+ }
3634
+ const found = parseGitHubRemote(url);
3635
+ if (!found) throw new CliError(`origin (${url}) isn't a GitHub repo. Pass owner/repo.`);
3636
+ return found;
3637
+ }
3638
+ async function resolveRepo(arg) {
3639
+ return arg ? parseRepoArg(arg) : inferRepo();
3640
+ }
3641
+
3642
+ // src/ui.ts
3643
+ import { createInterface } from "readline";
3644
+ import { styleText } from "util";
3645
+ function paint(format, text, stream = process.stdout) {
3646
+ if (process.env.NO_COLOR) return text;
3647
+ return styleText(format, text, { stream, validateStream: true });
3648
+ }
3649
+ var c = {
3650
+ bold: (s) => paint("bold", s),
3651
+ dim: (s) => paint("dim", s),
3652
+ green: (s) => paint("green", s),
3653
+ red: (s) => paint("red", s),
3654
+ yellow: (s) => paint("yellow", s),
3655
+ cyan: (s) => paint("cyan", s)
3656
+ };
3657
+ var print = (line = "") => void process.stdout.write(`${line}
3658
+ `);
3659
+ var printJson = (data) => print(JSON.stringify(data, null, 2));
3660
+ var ok = (msg) => print(`${c.green("\u2713")} ${msg}`);
3661
+ var warn = (msg) => process.stderr.write(`${paint("yellow", "!", process.stderr)} ${msg}
3662
+ `);
3663
+ var fail = (msg) => process.stderr.write(`${paint("red", "\u2717", process.stderr)} ${msg}
3664
+ `);
3665
+ function relativeTime(iso, now = Date.now()) {
3666
+ if (!iso) return "";
3667
+ const then = Date.parse(iso);
3668
+ if (Number.isNaN(then)) return "";
3669
+ const s = Math.max(0, Math.round((now - then) / 1e3));
3670
+ if (s < 45) return "just now";
3671
+ const m = Math.round(s / 60);
3672
+ if (m < 60) return `${m}m ago`;
3673
+ const h = Math.round(m / 60);
3674
+ if (h < 24) return `${h}h ago`;
3675
+ const d = Math.round(h / 24);
3676
+ if (d < 30) return `${d}d ago`;
3677
+ return new Date(then).toISOString().slice(0, 10);
3678
+ }
3679
+ function duration(from, to) {
3680
+ if (!from || !to) return "";
3681
+ const ms = Date.parse(to) - Date.parse(from);
3682
+ if (Number.isNaN(ms) || ms < 0) return "";
3683
+ const s = Math.round(ms / 1e3);
3684
+ if (s < 60) return `${s}s`;
3685
+ const m = Math.floor(s / 60);
3686
+ if (m < 60) return `${m}m ${s % 60}s`;
3687
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
3688
+ }
3689
+ function truncate(s, max) {
3690
+ return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
3691
+ }
3692
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3693
+ function columns(rows, indent = " ") {
3694
+ const plainOf = (cell) => typeof cell === "string" ? cell : cell[0];
3695
+ const styledOf = (cell) => typeof cell === "string" ? cell : cell[1];
3696
+ const widths = [];
3697
+ for (const row of rows) row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, plainOf(cell).length));
3698
+ return rows.map(
3699
+ (row) => indent + row.map((cell, i) => i === row.length - 1 ? styledOf(cell) : styledOf(cell) + " ".repeat((widths[i] ?? 0) - plainOf(cell).length)).join(" ").trimEnd()
3700
+ );
3701
+ }
3702
+ var isInteractive = () => Boolean(process.stdin.isTTY);
3703
+ var reader = null;
3704
+ async function readLine() {
3705
+ if (!reader) {
3706
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
3707
+ reader = { rl, lines: rl[Symbol.asyncIterator]() };
3708
+ }
3709
+ const next = await reader.lines.next();
3710
+ if (next.done) throw new CliError("No input: stdin was closed.");
3711
+ return next.value;
3712
+ }
3713
+ function closeReader() {
3714
+ reader?.rl.close();
3715
+ reader = null;
3716
+ }
3717
+ async function ask(question, fallback) {
3718
+ process.stderr.write(fallback ? `${question} ${paint("dim", `(${fallback})`, process.stderr)}: ` : `${question}: `);
3719
+ const answer = (await readLine()).trim();
3720
+ if (isInteractive()) closeReader();
3721
+ else process.stderr.write("\n");
3722
+ return answer || fallback || "";
3723
+ }
3724
+ async function askHidden(question) {
3725
+ process.stderr.write(`${question}: `);
3726
+ if (!isInteractive()) {
3727
+ const line = (await readLine()).trim();
3728
+ process.stderr.write("\n");
3729
+ return line;
3730
+ }
3731
+ closeReader();
3732
+ const stdin = process.stdin;
3733
+ return new Promise((resolve, reject) => {
3734
+ let buf = "";
3735
+ const finish = (err) => {
3736
+ stdin.off("data", onData);
3737
+ stdin.setRawMode(false);
3738
+ stdin.pause();
3739
+ process.stderr.write("\n");
3740
+ if (err) reject(err);
3741
+ else resolve(buf.trim());
3742
+ };
3743
+ const onData = (chunk) => {
3744
+ const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
3745
+ for (const ch of text) {
3746
+ if (ch === "\r" || ch === "\n") return finish();
3747
+ if (ch === "") return finish(new CliError("Cancelled.", 130));
3748
+ if (ch === "") return buf ? finish() : finish(new CliError("Cancelled.", 130));
3749
+ if (ch === "\x7F" || ch === "\b") buf = buf.slice(0, -1);
3750
+ else if (ch === "") buf = "";
3751
+ else if (ch >= " ") buf += ch;
3752
+ }
3753
+ };
3754
+ stdin.setRawMode(true);
3755
+ stdin.resume();
3756
+ stdin.on("data", onData);
3757
+ });
3758
+ }
3759
+ async function confirm(question, defaultYes = false) {
3760
+ if (!isInteractive()) throw new CliError(`${question} Needs confirmation; pass --yes to run without a prompt.`);
3761
+ const hint = defaultYes ? "Y/n" : "y/N";
3762
+ process.stderr.write(`${question} ${paint("dim", `[${hint}]`, process.stderr)} `);
3763
+ const answer = (await readLine()).trim().toLowerCase();
3764
+ closeReader();
3765
+ if (!answer) return defaultYes;
3766
+ return answer === "y" || answer === "yes";
3767
+ }
3768
+
3769
+ // src/coolify.ts
3770
+ var STAGES = ["dev", "staging", "prod"];
3771
+ var STAGE_LABEL = { dev: "dev", staging: "staging", prod: "production" };
3772
+ var appLabel = (a) => `${a.name} (${a.stage})`;
3773
+ var visible = (lines) => lines.filter((l) => !l.hidden);
3774
+ var note = (text) => void process.stderr.write(`${paint("dim", text, process.stderr)}
3775
+ `);
3776
+ function stateText(app) {
3777
+ const text = app.health ? `${app.state}:${app.health}` : app.state;
3778
+ if (app.state === "running") return app.health === "unhealthy" ? c.yellow(text) : c.green(text);
3779
+ if (app.state === "unknown") return c.dim(text);
3780
+ return c.red(text);
3781
+ }
3782
+ function statusText(status2) {
3783
+ if (status2 === "finished") return c.green(status2);
3784
+ if (status2 === "failed") return c.red(status2);
3785
+ if (status2 === "queued" || status2 === "in_progress") return c.yellow(status2);
3786
+ return c.dim(status2);
3787
+ }
3788
+ async function coolifyApps(api, repo) {
3789
+ const data = await api.deployments(repo);
3790
+ if (!data.instance) {
3791
+ throw new CliError(`${slug(repo)} isn't linked to a Coolify instance. Link one in the dashboard, under the repo's Coolify panel.`);
3792
+ }
3793
+ if (!data.apps.length) {
3794
+ const others = [...data.otherBranches.map((a) => `${a.name} (${a.branch ?? "?"})`), ...data.sameName.map((a) => `${a.name} (${a.repo ?? "?"})`)];
3795
+ throw new CliError(
3796
+ others.length ? `No Coolify app builds ${slug(repo)} from its dev, staging or main branch. Coolify has: ${others.join(", ")}.` : `No Coolify app builds ${slug(repo)}. Set one up in the dashboard, under the repo's Coolify panel.`
3797
+ );
3798
+ }
3799
+ return data;
3800
+ }
3801
+ function matching(apps, opts) {
3802
+ const wanted = opts.app?.trim().toLowerCase();
3803
+ return apps.filter(
3804
+ (a) => (!opts.stage || a.stage === opts.stage) && (!wanted || a.name.toLowerCase() === wanted || a.uuid === wanted || a.uuid.startsWith(wanted))
3805
+ );
3806
+ }
3807
+ var appList = (apps) => apps.map(appLabel).join(", ");
3808
+ function pickApp(data, opts) {
3809
+ const found = matching(data.apps, opts);
3810
+ if (found.length === 1) return found[0];
3811
+ if (!found.length) {
3812
+ const asked = [opts.app ? `--app ${opts.app}` : "", opts.stage ? `--stage ${opts.stage}` : ""].filter(Boolean).join(" ");
3813
+ throw new CliError(`Nothing matches ${asked}. This repo has: ${appList(data.apps)}.`);
3814
+ }
3815
+ throw new CliError(`Several apps match: ${appList(found)}. Pick one with --app <name> or --stage <prod|staging|dev>.`);
3816
+ }
3817
+ var MAX_TAIL = 2e3;
3818
+ async function logsCommand(getClient, repoArg, opts) {
3819
+ const repo = await resolveRepo(repoArg);
3820
+ const api = await getClient();
3821
+ const app = pickApp(await coolifyApps(api, repo), opts);
3822
+ const tail = opts.lines === 0 ? MAX_TAIL : opts.lines;
3823
+ if (!opts.build && !opts.deployment) {
3824
+ const logs = await api.appLogs(repo, app.uuid, tail);
3825
+ const lines2 = visible(logs.lines);
3826
+ if (opts.json) return printJson({ ...logs, lines: lines2 });
3827
+ note(`${appLabel(app)} \xB7 container log \xB7 ${stateText(logs.app)}${app.fqdn ? ` \xB7 ${app.fqdn}` : ""}`);
3828
+ if (!lines2.length) note("Coolify returned no container log. The app may never have started; try --build.");
3829
+ return printLines(opts.failures ? logs.excerpt : lines2.map((l) => l.output), opts.failures ? 0 : tail);
3830
+ }
3831
+ const deploymentUuid = opts.deployment ?? app.deployments[0]?.uuid;
3832
+ if (!deploymentUuid) throw new CliError(`${appLabel(app)} has no deployments on Coolify yet. Start one with \`oche deploy --app ${app.name}\`.`);
3833
+ const detail = await api.deployment(repo, app.uuid, deploymentUuid);
3834
+ const lines = visible(detail.lines);
3835
+ if (opts.json) return printJson({ ...detail, lines });
3836
+ const d = detail.deployment;
3837
+ note(`${appLabel(app)} \xB7 build log \xB7 ${d.status} \xB7 ${d.commit?.slice(0, 7) ?? "?"} \xB7 ${relativeTime(d.updatedAt)} \xB7 ${deploymentUuid}`);
3838
+ if (!lines.length) {
3839
+ note("Coolify returned no build log for this deployment. Check the Coolify API token has read:sensitive, or try a more recent deployment.");
3840
+ return;
3841
+ }
3842
+ printLines(opts.failures ? detail.excerpt : lines.map((l) => l.output), opts.failures ? 0 : opts.lines);
3843
+ }
3844
+ function printLines(lines, tail) {
3845
+ const shown = tail > 0 && lines.length > tail ? lines.slice(-tail) : lines;
3846
+ if (shown.length < lines.length) note(`\u2026showing the last ${plural(shown.length, "line")} of ${lines.length}; -n 0 for all.`);
3847
+ for (const line of shown) print(line);
3848
+ }
3849
+ async function deploymentsCommand(getClient, repoArg, opts) {
3850
+ const repo = await resolveRepo(repoArg);
3851
+ const data = await coolifyApps(await getClient(), repo);
3852
+ const apps = matching(data.apps, opts);
3853
+ if (!apps.length) throw new CliError(`Nothing matches. This repo has: ${appList(data.apps)}.`);
3854
+ if (opts.json) return printJson({ ...data, apps: apps.map((a) => ({ ...a, deployments: a.deployments.slice(0, opts.limit) })) });
3855
+ apps.forEach((app, i) => {
3856
+ if (i) print();
3857
+ const where = app.fqdn ?? (app.branch ? `${app.branch} branch` : "");
3858
+ print(`${c.bold(app.name)} ${STAGE_LABEL[app.stage]} ${stateText(app)}${where ? ` ${c.dim(where)}` : ""}`);
3859
+ const recent = app.deployments.slice(0, opts.limit);
3860
+ if (!recent.length) return print(c.dim(" never deployed"));
3861
+ columns(
3862
+ recent.map((d) => {
3863
+ const sha = d.commit?.slice(0, 7) ?? "";
3864
+ const took = duration(d.createdAt, d.updatedAt);
3865
+ const when = relativeTime(d.updatedAt);
3866
+ return [
3867
+ [d.status, statusText(d.status)],
3868
+ [sha, c.dim(sha)],
3869
+ [took, c.dim(took)],
3870
+ [when, c.dim(when)],
3871
+ [d.trigger, c.dim(d.trigger)],
3872
+ truncate((d.commitMessage ?? "").split("\n")[0] ?? "", 56)
3873
+ ];
3874
+ })
3875
+ ).forEach((l) => print(l));
3876
+ });
3877
+ print(c.dim(`
3878
+ ${data.instance.url}`));
3879
+ }
3880
+ var ISSUE_LABEL = {
3881
+ "missing-in-prod": "missing in production",
3882
+ "missing-in-staging": "missing in staging",
3883
+ "same-secret": "same value in staging and production"
3884
+ };
3885
+ var issueText = (issue) => issue === null ? "" : issue === "missing-in-prod" ? c.red(ISSUE_LABEL[issue]) : c.yellow(ISSUE_LABEL[issue]);
3886
+ async function envsCommand(getClient, repoArg, opts) {
3887
+ const repo = await resolveRepo(repoArg);
3888
+ const { groups } = await (await getClient()).envs(repo);
3889
+ const key = opts.key?.trim().toLowerCase();
3890
+ const kept = groups.filter((g) => !opts.stage || g.apps[opts.stage]).map((g) => ({ ...g, rows: key ? g.rows.filter((r) => r.key.toLowerCase().includes(key)) : g.rows }));
3891
+ if (opts.json) return printJson({ groups: kept });
3892
+ if (!kept.some((g) => g.rows.length)) {
3893
+ const where = opts.stage ? ` on ${STAGE_LABEL[opts.stage]}` : "";
3894
+ print(key ? `No variable matching "${opts.key}"${where} on ${slug(repo)}.` : `No environment variables${where} on ${slug(repo)}'s Coolify apps.`);
3895
+ return;
3896
+ }
3897
+ kept.forEach((g, i) => {
3898
+ if (i) print();
3899
+ printGroup(g, opts.stage, !key);
3900
+ });
3901
+ print(c.dim("\nOche compares names, and whether staging and production hold the same value. It never shows values."));
3902
+ }
3903
+ function printGroup(group, only, totals) {
3904
+ const stages = STAGES.filter((s) => group.apps[s] && (!only || s === only));
3905
+ const where = stages.map((s) => `${s}: ${group.apps[s].name}`).join(" \xB7 ");
3906
+ print(`${c.bold(group.label)} ${c.dim(where)}`);
3907
+ const header = [["VARIABLE", c.dim("VARIABLE")], ...stages.map((s) => [s, c.dim(s)]), ""];
3908
+ const rows = group.rows.map((r) => [
3909
+ r.key,
3910
+ ...stages.map((s) => {
3911
+ if (r.present[s] === void 0) return ["-", c.dim("-")];
3912
+ return r.present[s] ? ["\u2713", c.green("\u2713")] : ["\xB7", c.red("\xB7")];
3913
+ }),
3914
+ issueText(r.issue)
3915
+ ]);
3916
+ columns([header, ...rows]).forEach((l) => print(l));
3917
+ if (!totals) return;
3918
+ const { missingInProd, missingInStaging, sameSecret } = group.counts;
3919
+ const issues = [
3920
+ missingInProd ? c.red(`${missingInProd} missing in production`) : "",
3921
+ missingInStaging ? c.yellow(`${missingInStaging} missing in staging`) : "",
3922
+ sameSecret ? c.yellow(`${sameSecret} shared with staging`) : ""
3923
+ ].filter(Boolean);
3924
+ print(issues.length ? ` ${issues.join(c.dim(" \xB7 "))}` : c.dim(" in step across environments"));
3925
+ }
3926
+ async function deployCommand(getClient, repoArg, opts) {
3927
+ const repo = await resolveRepo(repoArg);
3928
+ const api = await getClient();
3929
+ const app = pickApp(await coolifyApps(api, repo), opts);
3930
+ const what = opts.force ? "Rebuild" : "Redeploy";
3931
+ if (!opts.yes && !await confirm(`${what} ${appLabel(app)} on ${slug(repo)}${opts.force ? ", ignoring the build cache" : ""}?`, app.stage !== "prod")) {
3932
+ return print("Cancelled.");
3933
+ }
3934
+ const { deploymentUuid } = await api.deploy(repo, app.uuid, Boolean(opts.force));
3935
+ if (opts.json) return printJson({ app: { uuid: app.uuid, name: app.name, stage: app.stage }, deploymentUuid });
3936
+ ok(`${what === "Rebuild" ? "Rebuilding" : "Redeploying"} ${appLabel(app)}.`);
3937
+ print(c.dim(` oche logs ${slug(repo)} --app ${app.name}${deploymentUuid ? ` --deployment ${deploymentUuid}` : " --build"} follows the build`));
3938
+ }
3939
+ async function rollbackCommand(getClient, repoArg, opts) {
3940
+ const repo = await resolveRepo(repoArg);
3941
+ const api = await getClient();
3942
+ const app = pickApp(await coolifyApps(api, repo), opts);
3943
+ const options = await api.rollbackOptions(repo, app.uuid);
3944
+ if (!opts.to) {
3945
+ if (opts.json) return printJson(options);
3946
+ print(`${c.bold(app.name)} ${STAGE_LABEL[app.stage]} ${c.dim(`currently ${options.current?.slice(0, 7) ?? "unknown"}`)}`);
3947
+ if (!options.images.length) {
3948
+ print(c.dim(" Coolify has no older images for this app."));
3949
+ return;
3950
+ }
3951
+ columns(
3952
+ options.images.map((i) => {
3953
+ const when = relativeTime(i.deployedAt ?? i.createdAt);
3954
+ return [
3955
+ i.isCurrent ? ["now", c.green("now")] : "",
3956
+ i.tag.slice(0, 7),
3957
+ [when, c.dim(when)],
3958
+ truncate((i.commitMessage ?? "").split("\n")[0] ?? "", 48)
3959
+ ];
3960
+ })
3961
+ ).forEach((l) => print(l));
3962
+ print(c.dim(`
3963
+ Roll back with \`oche rollback ${slug(repo)} --app ${app.name} --to <commit>\`.`));
3964
+ return;
3965
+ }
3966
+ const wanted = opts.to.trim().toLowerCase();
3967
+ const target = options.images.find((i) => i.tag.toLowerCase().startsWith(wanted) || wanted.startsWith(i.tag.toLowerCase()));
3968
+ if (!target) {
3969
+ throw new CliError(`Coolify has no image for ${opts.to} on ${appLabel(app)}. It still has: ${options.images.map((i) => i.tag.slice(0, 7)).join(", ") || "none"}.`);
3970
+ }
3971
+ const subject = truncate((target.commitMessage ?? "").split("\n")[0] ?? "", 48);
3972
+ if (!opts.yes && !await confirm(`Roll ${appLabel(app)} on ${slug(repo)} back to ${target.tag.slice(0, 7)}${subject ? ` (${subject})` : ""}?`)) {
3973
+ return print("Cancelled.");
3974
+ }
3975
+ const { deploymentUuid } = await api.rollback(repo, app.uuid, target.tag);
3976
+ if (opts.json) return printJson({ app: options.app, commit: target.tag, deploymentUuid });
3977
+ ok(`Rolling ${appLabel(app)} back to ${target.tag.slice(0, 7)}.`);
3978
+ print(c.dim(` oche deployments ${slug(repo)} --app ${app.name} shows how it goes`));
3979
+ }
3980
+ var worthExplaining = (deployments) => deployments.find((d) => d.status === "failed") ?? deployments[0];
3981
+ async function explainCommand(getClient, repoArg, opts) {
3982
+ const repo = await resolveRepo(repoArg);
3983
+ const api = await getClient();
3984
+ const app = pickApp(await coolifyApps(api, repo), opts);
3985
+ const chosen = opts.deployment ? null : worthExplaining(app.deployments);
3986
+ const deploymentUuid = opts.deployment ?? chosen?.uuid;
3987
+ if (!deploymentUuid) throw new CliError(`${appLabel(app)} has no deployments on Coolify yet.`);
3988
+ if (chosen && chosen.status !== "failed") note(`The last deployment of ${appLabel(app)} ${chosen.status === "finished" ? "worked" : `is ${chosen.status}`}; explaining it anyway.`);
3989
+ note(`Reading the build log of ${appLabel(app)} and asking the model\u2026`);
3990
+ const { explanation } = await api.explainDeployment(repo, app.uuid, deploymentUuid);
3991
+ if (opts.json) return printJson({ app: { uuid: app.uuid, name: app.name, stage: app.stage }, deployment: deploymentUuid, explanation });
3992
+ print(explanation.trim());
3993
+ }
3994
+ async function preflightCommand(getClient, repoArg, opts) {
3995
+ const repo = await resolveRepo(repoArg);
3996
+ const data = await (await getClient()).preflight(repo, opts.to);
3997
+ if (opts.json) return printJson(data);
3998
+ if (!data.linked) {
3999
+ print(`${slug(repo)} isn't linked to a Coolify instance, so there's nothing to check before promoting.`);
4000
+ return;
4001
+ }
4002
+ print(`${c.bold(`Before promoting to ${STAGE_LABEL[opts.to]}`)} ${c.dim(slug(repo))}`);
4003
+ print();
4004
+ print(c.bold("Environment variables"));
4005
+ if (!data.envIssues.length) print(c.dim(" in step across environments"));
4006
+ for (const issue of data.envIssues) {
4007
+ if (issue.missing.length) print(` ${c.red("\xB7")} ${issue.app}: ${plural(issue.missing.length, "variable")} missing \u2014 ${issue.missing.join(", ")}`);
4008
+ if (issue.shared.length) print(` ${c.yellow("\xB7")} ${issue.app}: shared with staging \u2014 ${issue.shared.join(", ")}`);
4009
+ }
4010
+ const below = data.below;
4011
+ print();
4012
+ print(c.bold(below ? `The ${below.stage} stage` : "The stage below"));
4013
+ if (!below || !below.apps) print(c.dim(" no apps deploying there"));
4014
+ else if (!below.unhealthy.length) print(` ${c.green("\u2713")} ${plural(below.apps, "app")} healthy`);
4015
+ else for (const a of below.unhealthy) print(` ${c.red("\xB7")} ${a.name}: ${a.state}${a.status ? ` \xB7 last deployment ${a.status}` : ""}`);
4016
+ const blocking = data.envIssues.some((i) => i.missing.length) || Boolean(below?.unhealthy.length);
4017
+ print();
4018
+ print(blocking ? c.yellow("Sort these out before promoting, or promote knowing about them.") : c.green("Nothing in the way."));
4019
+ }
4020
+
3566
4021
  // src/config.ts
3567
4022
  import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
3568
4023
  import { homedir } from "os";
@@ -3642,70 +4097,6 @@ async function deleteConfig(path2 = configPath()) {
3642
4097
  return existed;
3643
4098
  }
3644
4099
 
3645
- // src/git.ts
3646
- import { execFile } from "child_process";
3647
- import { promisify } from "util";
3648
- var slug = (r) => `${r.owner}/${r.repo}`;
3649
- var GITHUB_HOST = /^(?:www\.|ssh\.)?github\.com(?:-[\w.-]+)?$/i;
3650
- var OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
3651
- var REPO = /^[A-Za-z0-9._-]{1,100}$/;
3652
- var URL_SCHEMES = /* @__PURE__ */ new Set(["https:", "http:", "ssh:", "git:", "git+ssh:", "ssh+git:"]);
3653
- function toSlug(path2) {
3654
- const parts = path2.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/");
3655
- if (parts.length !== 2) return null;
3656
- const [owner, repo] = parts;
3657
- if (!OWNER.test(owner) || !REPO.test(repo) || repo === "." || repo === "..") return null;
3658
- return { owner, repo };
3659
- }
3660
- function parseGitHubRemote(remote) {
3661
- const s = remote.trim();
3662
- let host;
3663
- let path2;
3664
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
3665
- let u;
3666
- try {
3667
- u = new URL(s);
3668
- } catch {
3669
- return null;
3670
- }
3671
- if (!URL_SCHEMES.has(u.protocol)) return null;
3672
- host = u.hostname;
3673
- path2 = decodeURIComponent(u.pathname);
3674
- } else {
3675
- const m = /^(?:[^@\s/]+@)?([^:\s/]+):(.+)$/.exec(s);
3676
- if (!m) return null;
3677
- host = m[1];
3678
- path2 = m[2];
3679
- }
3680
- if (!GITHUB_HOST.test(host)) return null;
3681
- return toSlug(path2);
3682
- }
3683
- function parseRepoArg(arg) {
3684
- const s = arg.trim();
3685
- const found = /^[^/\s:@]+\/[^/\s:@]+$/.test(s) ? toSlug(s) : parseGitHubRemote(s);
3686
- if (!found) throw new CliError(`Expected owner/repo, got "${arg}".`);
3687
- return found;
3688
- }
3689
- var run = promisify(execFile);
3690
- async function inferRepo(cwd = process.cwd()) {
3691
- let url;
3692
- try {
3693
- const { stdout } = await run("git", ["remote", "get-url", "origin"], { cwd });
3694
- url = stdout.trim();
3695
- } catch (e) {
3696
- if (e.code === "ENOENT") {
3697
- throw new CliError("git isn't installed, so the repo can't be inferred. Pass owner/repo.");
3698
- }
3699
- throw new CliError('No repo given and no git remote "origin" here. Pass owner/repo.');
3700
- }
3701
- const found = parseGitHubRemote(url);
3702
- if (!found) throw new CliError(`origin (${url}) isn't a GitHub repo. Pass owner/repo.`);
3703
- return found;
3704
- }
3705
- async function resolveRepo(arg) {
3706
- return arg ? parseRepoArg(arg) : inferRepo();
3707
- }
3708
-
3709
4100
  // src/promotion.ts
3710
4101
  var DEFAULT_BRANCHES = { prod: "main", staging: "staging", dev: "dev" };
3711
4102
  function toPromotion(target, direct = false) {
@@ -3768,11 +4159,206 @@ function resolveAi(file, env) {
3768
4159
  };
3769
4160
  }
3770
4161
 
3771
- // src/github.ts
3772
- import { execFile as execFile2 } from "child_process";
4162
+ // src/npm.ts
4163
+ import { execFile as execFile2, spawn } from "child_process";
4164
+ import { readFile as readFile3 } from "fs/promises";
3773
4165
  import { promisify as promisify2 } from "util";
4166
+ var WORKFLOW_FILE = "oche-publish.yml";
4167
+ var exec = promisify2(execFile2);
4168
+ async function npmBin() {
4169
+ try {
4170
+ const { stdout } = await exec("npm", ["--version"]);
4171
+ const major = Number(stdout.trim().split(".")[0]);
4172
+ return major >= 12 ? ["npm"] : ["npx", "-y", "npm@12"];
4173
+ } catch {
4174
+ throw new CliError("npm isn't installed.");
4175
+ }
4176
+ }
4177
+ async function npmOut(bin, args) {
4178
+ try {
4179
+ const { stdout } = await exec(bin[0], [...bin.slice(1), ...args]);
4180
+ return stdout.trim();
4181
+ } catch {
4182
+ return null;
4183
+ }
4184
+ }
4185
+ async function npmRead(bin, args) {
4186
+ const quiet = await npmOut(bin, args);
4187
+ if (quiet !== null) return quiet;
4188
+ closeReader();
4189
+ return new Promise((resolve) => {
4190
+ const child = spawn(bin[0], [...bin.slice(1), ...args], { stdio: ["inherit", "pipe", "inherit"] });
4191
+ let out = "";
4192
+ child.stdout.on("data", (d) => {
4193
+ out += d.toString();
4194
+ for (const line of d.toString().split("\n")) if (/https?:\/\/|ENTER/.test(line)) process.stderr.write(`${line}
4195
+ `);
4196
+ });
4197
+ child.on("error", () => resolve(null));
4198
+ child.on("exit", (code) => resolve(code === 0 ? out : null));
4199
+ });
4200
+ }
4201
+ function npmLive(bin, args) {
4202
+ closeReader();
4203
+ return new Promise((resolve, reject) => {
4204
+ const child = spawn(bin[0], [...bin.slice(1), ...args], { stdio: "inherit" });
4205
+ child.on("error", reject);
4206
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new CliError(`npm ${args[0]} failed (exit ${code}).`)));
4207
+ });
4208
+ }
4209
+ async function localPackage() {
4210
+ try {
4211
+ return JSON.parse(await readFile3("package.json", "utf8"));
4212
+ } catch {
4213
+ return null;
4214
+ }
4215
+ }
4216
+ function parseTrustList(out) {
4217
+ const entries = [];
4218
+ let depth = 0;
4219
+ let start = -1;
4220
+ let inString = false;
4221
+ for (let i = 0; i < out.length; i++) {
4222
+ const ch = out[i];
4223
+ if (inString) {
4224
+ if (ch === "\\") i++;
4225
+ else if (ch === '"') inString = false;
4226
+ } else if (ch === '"') inString = true;
4227
+ else if (ch === "{") {
4228
+ if (depth++ === 0) start = i;
4229
+ } else if (ch === "}" && --depth === 0) {
4230
+ try {
4231
+ const v = JSON.parse(out.slice(start, i + 1));
4232
+ if (typeof v.id === "string") entries.push(v);
4233
+ } catch {
4234
+ }
4235
+ }
4236
+ }
4237
+ if (!entries.length) {
4238
+ try {
4239
+ const v = JSON.parse(out);
4240
+ if (Array.isArray(v)) return v.filter((e) => typeof e?.id === "string");
4241
+ } catch {
4242
+ }
4243
+ }
4244
+ return entries;
4245
+ }
4246
+ function printSteps(st) {
4247
+ for (const s of st.steps) {
4248
+ print(` ${s.done ? c.green("\u2713") : c.dim("\u25CB")} ${s.label}${!s.done && s.hint ? c.dim(` ${s.hint}`) : ""}`);
4249
+ }
4250
+ }
4251
+ async function npmStatusCommand(client2, repoArg, opts) {
4252
+ const repo = await resolveRepo(repoArg);
4253
+ const st = await (await client2()).npmStatus(repo);
4254
+ if (opts.json) return printJson(st);
4255
+ const name = st.publishing?.packageName ?? st.detected.name;
4256
+ print(`${c.bold(slug(repo))}${name ? ` ${name}` : ""}`);
4257
+ if (st.blocked && !st.publishing) {
4258
+ print(c.dim(st.blocked));
4259
+ return;
4260
+ }
4261
+ if (st.registry) {
4262
+ print(st.registry.exists ? ` npm: ${c.bold(st.registry.latest ?? "?")}${st.registry.publishedAt ? c.dim(` \xB7 ${relativeTime(st.registry.publishedAt)}`) : ""}` : c.dim(" not on npm yet"));
4263
+ }
4264
+ const run3 = st.publishing?.lastRun;
4265
+ if (run3) {
4266
+ const label = run3.conclusion === "success" ? c.green(run3.conclusion) : run3.conclusion === "failure" ? c.red(run3.conclusion) : run3.conclusion;
4267
+ print(` last run: ${label}${run3.failedStep ? ` at "${run3.failedStep}"` : ""} ${c.dim(`${relativeTime(run3.at)} \xB7 ${run3.url}`)}`);
4268
+ }
4269
+ if (st.publishing?.outdated) warn("The workflow is out of date. `oche npm setup` opens a PR to update it.");
4270
+ print();
4271
+ printSteps(st);
4272
+ if (!st.publishing) print(c.dim(`
4273
+ Run \`oche npm setup ${slug(repo)}\` to start publishing on every release.`));
4274
+ }
4275
+ async function npmSetupCommand(client2, repoArg, opts) {
4276
+ const repo = await resolveRepo(repoArg);
4277
+ const api = await client2();
4278
+ let st = await api.npmStatus(repo);
4279
+ if (st.blocked) throw new CliError(st.blocked);
4280
+ const name = st.publishing?.packageName ?? st.detected.name;
4281
+ const local = await localPackage();
4282
+ const inRepo = local?.name === name;
4283
+ const ask2 = async (q) => opts.yes || isInteractive() && await confirm(q, true);
4284
+ print(`Setting up npm publishing for ${c.bold(name)} from ${c.bold(slug(repo))}.
4285
+ `);
4286
+ const others = st.detected.otherPublishers;
4287
+ let replace = false;
4288
+ if (others.length) {
4289
+ print(`${others.join(", ")} already publishes ${name}.`);
4290
+ replace = !opts.keepExisting && await ask2(`Replace it with Oche's workflow? The PR deletes ${others.join(", ")}.`);
4291
+ if (!replace && !opts.keepExisting) throw new CliError("Stopped. Run again and replace it, or pass --keep-existing to run both.");
4292
+ }
4293
+ const bin = await npmBin();
4294
+ let npmUser = await npmOut(bin, ["whoami"]);
4295
+ if (!npmUser) {
4296
+ if (!await ask2("You're not logged in to npm. Log in now?")) throw new CliError("Log in with `npm login`, then run this again.");
4297
+ await npmLive(bin, ["login"]);
4298
+ npmUser = await npmOut(bin, ["whoami"]);
4299
+ if (!npmUser) throw new CliError("Still not logged in to npm.");
4300
+ }
4301
+ ok(`npm: logged in as ${c.bold(npmUser)}`);
4302
+ if (!st.registry?.exists) {
4303
+ if (!inRepo) throw new CliError(`${name} isn't on npm yet. Run this from the repo's folder so Oche can publish the first version from it.`);
4304
+ print(`${name} isn't on npm yet. The first version has to be published from your machine; Oche takes over after that.`);
4305
+ if (!await ask2(`Build and publish ${name}@${local.version} now?`)) throw new CliError("Publish it once with `npm publish`, then run this again.");
4306
+ await npmLive(bin, ["run", "build", "--if-present"]);
4307
+ await npmLive(bin, ["publish", "--access", "public"]);
4308
+ ok(`Published ${name}@${local.version}`);
4309
+ } else {
4310
+ const owners = await npmOut(bin, ["owner", "ls", name]);
4311
+ if (owners && !owners.split("\n").some((l) => l.split(" ")[0] === npmUser)) {
4312
+ warn(`${npmUser} isn't listed as an owner of ${name}. npm may refuse to change its trust settings.`);
4313
+ }
4314
+ }
4315
+ if (!opts.skipPr && (!st.publishing || st.publishing.outdated || replace)) {
4316
+ try {
4317
+ const res = await api.npmSetup(repo, replace ? { replace: true } : others.length ? { force: true } : {});
4318
+ const what = [res.path, ...res.removes.map((p) => `removes ${p}`)].join(", ");
4319
+ if (res.pr) ok(`${res.pr.merged ? "Merged" : "Opened"} #${res.pr.number}: ${what} ${c.dim(res.pr.url)}`);
4320
+ else ok(`${res.path} is already on dev.`);
4321
+ } catch (e) {
4322
+ if (e instanceof ApiRequestError && e.status === 403) throw new CliError("Only the Oche owner can add the workflow. Ask them to run `oche npm setup`, then run it yourself for the npm side.");
4323
+ throw e;
4324
+ }
4325
+ }
4326
+ const listed = await npmRead(bin, ["trust", "list", name, "--json"]);
4327
+ if (listed === null) throw new CliError(`Couldn't read npm's trusted publishers for ${name}. Run \`npm trust list ${name}\` to see why.`);
4328
+ const trust = parseTrustList(listed);
4329
+ const sameRepo = (t) => t.type === "github" && t.repository?.toLowerCase() === slug(repo).toLowerCase();
4330
+ if (trust.some((t) => sameRepo(t) && t.file === WORKFLOW_FILE)) {
4331
+ ok(`npm already trusts ${WORKFLOW_FILE} in ${slug(repo)}`);
4332
+ } else {
4333
+ print(`
4334
+ npm will trust ${c.bold(`${slug(repo)} \u2192 .github/workflows/${WORKFLOW_FILE}`)} to publish ${name}. npm may ask for 2FA.`);
4335
+ if (!await ask2("Continue?")) throw new CliError("Stopped before changing npm's trust settings.");
4336
+ await npmLive(bin, ["trust", "github", name, "--file", WORKFLOW_FILE, "--repository", slug(repo), "--allow-publish", ...opts.yes ? ["--yes"] : []]);
4337
+ ok("npm trusts the workflow");
4338
+ }
4339
+ for (const t of trust.filter((t2) => sameRepo(t2) && t2.file !== WORKFLOW_FILE)) {
4340
+ print(`
4341
+ npm still trusts ${c.bold(t.file ?? "another workflow")} in ${slug(repo)} to publish ${name}.${others.includes(t.file ?? "") ? " Oche's PR removes that file." : ""}`);
4342
+ if (await ask2(`Revoke it? Only ${WORKFLOW_FILE} will be able to publish.`)) {
4343
+ await npmLive(bin, ["trust", "revoke", name, `--id=${t.id}`]);
4344
+ ok(`Revoked ${t.file}`);
4345
+ } else {
4346
+ print(c.dim(`Kept. Revoke it later with: npm trust revoke ${name} --id=${t.id}`));
4347
+ }
4348
+ }
4349
+ st = await api.npmTrust(repo, { packageName: name, npmUser });
4350
+ print();
4351
+ printSteps(st);
4352
+ const next = st.steps.find((s) => !s.done && s.id !== "published");
4353
+ print(next ? c.dim(`
4354
+ Next: ${next.hint ?? next.label}`) : c.dim("\nEvery push to production now publishes a new version."));
4355
+ }
4356
+
4357
+ // src/github.ts
4358
+ import { execFile as execFile3 } from "child_process";
4359
+ import { promisify as promisify3 } from "util";
3774
4360
  var API = "https://api.github.com";
3775
- var run2 = promisify2(execFile2);
4361
+ var run2 = promisify3(execFile3);
3776
4362
  async function githubToken(env = process.env) {
3777
4363
  const fromEnv = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
3778
4364
  if (fromEnv) return fromEnv;
@@ -3850,16 +4436,16 @@ var GitHub = class {
3850
4436
  };
3851
4437
 
3852
4438
  // src/localgit.ts
3853
- import { execFile as execFile3, spawn } from "child_process";
4439
+ import { execFile as execFile4, spawn as spawn2 } from "child_process";
3854
4440
  import { copyFile, mkdtemp, rm as rm2 } from "fs/promises";
3855
4441
  import { tmpdir } from "os";
3856
4442
  import { join as join3 } from "path";
3857
- import { promisify as promisify3 } from "util";
3858
- var exec = promisify3(execFile3);
4443
+ import { promisify as promisify4 } from "util";
4444
+ var exec2 = promisify4(execFile4);
3859
4445
  var MAX_BUFFER = 64 * 1024 * 1024;
3860
4446
  async function git(args, env) {
3861
4447
  try {
3862
- const { stdout } = await exec("git", args, { maxBuffer: MAX_BUFFER, env: env ? { ...process.env, ...env } : process.env });
4448
+ const { stdout } = await exec2("git", args, { maxBuffer: MAX_BUFFER, env: env ? { ...process.env, ...env } : process.env });
3863
4449
  return stdout;
3864
4450
  } catch (e) {
3865
4451
  const err = e;
@@ -3920,7 +4506,7 @@ var addTracked = () => git(["add", "-u"]);
3920
4506
  var commit = (message) => git(["commit", "-m", message]);
3921
4507
  function commitWithEditor(message) {
3922
4508
  return new Promise((resolve, reject) => {
3923
- const child = spawn("git", ["commit", "-e", "-m", message], { stdio: "inherit" });
4509
+ const child = spawn2("git", ["commit", "-e", "-m", message], { stdio: "inherit" });
3924
4510
  child.on("error", reject);
3925
4511
  child.on("exit", (code) => code === 0 ? resolve() : reject(new CliError("Commit aborted.")));
3926
4512
  });
@@ -3990,123 +4576,6 @@ async function generateCommitMessage(input, opts) {
3990
4576
  return parseMessage(await res.json());
3991
4577
  }
3992
4578
 
3993
- // src/ui.ts
3994
- import { createInterface } from "readline";
3995
- import { styleText } from "util";
3996
- function paint(format, text, stream = process.stdout) {
3997
- if (process.env.NO_COLOR) return text;
3998
- return styleText(format, text, { stream, validateStream: true });
3999
- }
4000
- var c = {
4001
- bold: (s) => paint("bold", s),
4002
- dim: (s) => paint("dim", s),
4003
- green: (s) => paint("green", s),
4004
- red: (s) => paint("red", s),
4005
- yellow: (s) => paint("yellow", s),
4006
- cyan: (s) => paint("cyan", s)
4007
- };
4008
- var print = (line = "") => void process.stdout.write(`${line}
4009
- `);
4010
- var printJson = (data) => print(JSON.stringify(data, null, 2));
4011
- var ok = (msg) => print(`${c.green("\u2713")} ${msg}`);
4012
- var warn = (msg) => process.stderr.write(`${paint("yellow", "!", process.stderr)} ${msg}
4013
- `);
4014
- var fail = (msg) => process.stderr.write(`${paint("red", "\u2717", process.stderr)} ${msg}
4015
- `);
4016
- function relativeTime(iso, now = Date.now()) {
4017
- if (!iso) return "";
4018
- const then = Date.parse(iso);
4019
- if (Number.isNaN(then)) return "";
4020
- const s = Math.max(0, Math.round((now - then) / 1e3));
4021
- if (s < 45) return "just now";
4022
- const m = Math.round(s / 60);
4023
- if (m < 60) return `${m}m ago`;
4024
- const h = Math.round(m / 60);
4025
- if (h < 24) return `${h}h ago`;
4026
- const d = Math.round(h / 24);
4027
- if (d < 30) return `${d}d ago`;
4028
- return new Date(then).toISOString().slice(0, 10);
4029
- }
4030
- function truncate(s, max) {
4031
- return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
4032
- }
4033
- var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
4034
- function columns(rows, indent = " ") {
4035
- const plainOf = (cell) => typeof cell === "string" ? cell : cell[0];
4036
- const styledOf = (cell) => typeof cell === "string" ? cell : cell[1];
4037
- const widths = [];
4038
- for (const row of rows) row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, plainOf(cell).length));
4039
- return rows.map(
4040
- (row) => indent + row.map((cell, i) => i === row.length - 1 ? styledOf(cell) : styledOf(cell) + " ".repeat((widths[i] ?? 0) - plainOf(cell).length)).join(" ").trimEnd()
4041
- );
4042
- }
4043
- var isInteractive = () => Boolean(process.stdin.isTTY);
4044
- var reader = null;
4045
- async function readLine() {
4046
- if (!reader) {
4047
- const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
4048
- reader = { rl, lines: rl[Symbol.asyncIterator]() };
4049
- }
4050
- const next = await reader.lines.next();
4051
- if (next.done) throw new CliError("No input: stdin was closed.");
4052
- return next.value;
4053
- }
4054
- function closeReader() {
4055
- reader?.rl.close();
4056
- reader = null;
4057
- }
4058
- async function ask(question, fallback) {
4059
- process.stderr.write(fallback ? `${question} ${paint("dim", `(${fallback})`, process.stderr)}: ` : `${question}: `);
4060
- const answer = (await readLine()).trim();
4061
- if (isInteractive()) closeReader();
4062
- else process.stderr.write("\n");
4063
- return answer || fallback || "";
4064
- }
4065
- async function askHidden(question) {
4066
- process.stderr.write(`${question}: `);
4067
- if (!isInteractive()) {
4068
- const line = (await readLine()).trim();
4069
- process.stderr.write("\n");
4070
- return line;
4071
- }
4072
- closeReader();
4073
- const stdin = process.stdin;
4074
- return new Promise((resolve, reject) => {
4075
- let buf = "";
4076
- const finish = (err) => {
4077
- stdin.off("data", onData);
4078
- stdin.setRawMode(false);
4079
- stdin.pause();
4080
- process.stderr.write("\n");
4081
- if (err) reject(err);
4082
- else resolve(buf.trim());
4083
- };
4084
- const onData = (chunk) => {
4085
- const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
4086
- for (const ch of text) {
4087
- if (ch === "\r" || ch === "\n") return finish();
4088
- if (ch === "") return finish(new CliError("Cancelled.", 130));
4089
- if (ch === "") return buf ? finish() : finish(new CliError("Cancelled.", 130));
4090
- if (ch === "\x7F" || ch === "\b") buf = buf.slice(0, -1);
4091
- else if (ch === "") buf = "";
4092
- else if (ch >= " ") buf += ch;
4093
- }
4094
- };
4095
- stdin.setRawMode(true);
4096
- stdin.resume();
4097
- stdin.on("data", onData);
4098
- });
4099
- }
4100
- async function confirm(question, defaultYes = false) {
4101
- if (!isInteractive()) throw new CliError(`${question} Needs confirmation; pass --yes to run without a prompt.`);
4102
- const hint = defaultYes ? "Y/n" : "y/N";
4103
- process.stderr.write(`${question} ${paint("dim", `[${hint}]`, process.stderr)} `);
4104
- const answer = (await readLine()).trim().toLowerCase();
4105
- closeReader();
4106
- if (!answer) return defaultYes;
4107
- return answer === "y" || answer === "yes";
4108
- }
4109
-
4110
4579
  // src/ship-run.ts
4111
4580
  var MERGE_HOW = {
4112
4581
  merge: "open a PR and merge",
@@ -4535,8 +5004,8 @@ function nonNegativeInt(value) {
4535
5004
  return n;
4536
5005
  }
4537
5006
  var modeLabel = (repo) => repo.mode === "strict" ? `strict \xB7 ${plural(repo.requiredApprovals, "approval")}` : "fast";
4538
- var BAD_EVENTS = /* @__PURE__ */ new Set(["promotion-conflict", "sync-conflict", "pr-blocked", "direct-push", "deploy-failed", "app-stopped"]);
4539
- var GOOD_EVENTS = /* @__PURE__ */ new Set(["promoted", "synced", "onboarded", "deploy-succeeded"]);
5007
+ var BAD_EVENTS = /* @__PURE__ */ new Set(["promotion-conflict", "sync-conflict", "pr-blocked", "direct-push", "deploy-failed", "app-stopped", "publish-failed"]);
5008
+ var GOOD_EVENTS = /* @__PURE__ */ new Set(["promoted", "synced", "onboarded", "deploy-succeeded", "published"]);
4540
5009
  var newestFirst = (events) => [...events].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id - a.id);
4541
5010
  function eventLines(events, showRepo) {
4542
5011
  return columns(
@@ -4707,8 +5176,8 @@ async function promote(target, repoArg, opts) {
4707
5176
  return;
4708
5177
  case "waiting-review": {
4709
5178
  const a = result.approval;
4710
- const note = a.changesRequested ? ", changes requested" : "";
4711
- print(`#${result.pr.number} ${result.pr.head} \u2192 ${result.pr.base} is waiting for review: ${c.yellow(`${a.approvals}/${a.required} approvals`)}${note}`);
5179
+ const note2 = a.changesRequested ? ", changes requested" : "";
5180
+ print(`#${result.pr.number} ${result.pr.head} \u2192 ${result.pr.base} is waiting for review: ${c.yellow(`${a.approvals}/${a.required} approvals`)}${note2}`);
4712
5181
  print(` \u2192 ${result.pr.url}`);
4713
5182
  return;
4714
5183
  }
@@ -4800,6 +5269,18 @@ function buildProgram() {
4800
5269
  program2.command("commit").description("Write a commit message for what's staged with AI (via your Oche server) and commit").option("-a, --all", "stage changes to tracked files first, like git commit -a").option("-m, --message <message>", "skip AI and use this message").option("--dry-run", "only print the message").option("-y, --yes", "commit without asking").action((opts) => commitCommand(client, opts));
4801
5270
  program2.command("onboard").description("Set up the dev \u2192 staging \u2192 main flow on a repo").argument(repoArg, repoHelp).option("--dry-run", "only show the plan").option("-y, --yes", "don't ask for confirmation").action(onboard);
4802
5271
  program2.command("mode").description("Switch between fast (no review) and strict (approvals required)").argument("<mode>", "fast or strict").argument(repoArg, repoHelp).option("--approvals <n>", "approvals required in strict mode", nonNegativeInt).action(setMode);
5272
+ const stageOption = (description) => new Option("--stage <stage>", description).choices(["prod", "staging", "dev"]);
5273
+ const appOption = new Option("--app <name>", "which Coolify app (its name, or the start of its uuid)");
5274
+ program2.command("logs").description("Container logs for an app, or a deployment's build log with --build").argument(repoArg, repoHelp).addOption(appOption).addOption(stageOption("the app deploying this stage")).option("--deployment <uuid>", "build log of this deployment (implies --build; default: the latest)").option("--build", "the build log of the latest deployment, not the running container").addOption(new Option("-n, --lines <n>", "how many lines from the end; 0 for all").default(200).argParser(nonNegativeInt)).option("--failures", "only the errors and the tail").option("--json", "print raw JSON").action((repo, opts) => logsCommand(client, repo, opts));
5275
+ program2.command("deployments").description("Recent deployments per app, with status, commit and how long each took").argument(repoArg, repoHelp).addOption(appOption).addOption(stageOption("only apps deploying this stage")).addOption(new Option("-n, --limit <n>", "deployments per app").default(5).argParser(positiveInt)).option("--json", "print raw JSON").action((repo, opts) => deploymentsCommand(client, repo, opts));
5276
+ const npm = program2.command("npm").description("Publish this package to npm on every release");
5277
+ npm.command("setup").description("Add the publish workflow and let npm trust it (uses your npm login)").argument(repoArg, repoHelp).option("-y, --yes", "don't ask for confirmation").option("--keep-existing", "keep a workflow that already publishes to npm instead of replacing it").option("--skip-pr", "only do the npm side; don't open the workflow PR").action((repo, opts) => npmSetupCommand(client, repo, opts));
5278
+ npm.command("status", { isDefault: true }).description("Publishing setup, the npm version and the last run").argument(repoArg, repoHelp).option("--json", "print raw JSON").action((repo, opts) => npmStatusCommand(client, repo, opts));
5279
+ program2.command("envs").description("Which stages define each environment variable (names only, never values)").argument(repoArg, repoHelp).addOption(stageOption("only this stage's column")).option("--key <name>", "only variables whose name contains this").option("--json", "print raw JSON").action((repo, opts) => envsCommand(client, repo, opts));
5280
+ program2.command("deploy").description("Redeploy an app on Coolify").argument(repoArg, repoHelp).addOption(appOption).addOption(stageOption("the app deploying this stage")).option("--force", "rebuild without the build cache").option("-y, --yes", "don't ask for confirmation").option("--json", "print raw JSON").action((repo, opts) => deployCommand(client, repo, opts));
5281
+ program2.command("rollback").description("List the images Coolify can roll an app back to, and roll back to one").argument(repoArg, repoHelp).addOption(appOption).addOption(stageOption("the app deploying this stage")).option("--to <commit>", "roll back to this image (a commit SHA from the list)").option("-y, --yes", "don't ask for confirmation").option("--json", "print raw JSON").action((repo, opts) => rollbackCommand(client, repo, opts));
5282
+ program2.command("explain").description("Ask your Oche server's AI what broke a deployment").argument(repoArg, repoHelp).addOption(appOption).addOption(stageOption("the app deploying this stage")).option("--deployment <uuid>", "explain this deployment (default: the latest failed one)").option("--json", "print raw JSON").action((repo, opts) => explainCommand(client, repo, opts));
5283
+ program2.command("preflight").description("What to know before promoting: missing variables and the health of the stage below").argument(repoArg, repoHelp).addOption(new Option("--to <stage>", "the stage being promoted into").choices(["prod", "staging"]).default("prod")).option("--json", "print raw JSON").action((repo, opts) => preflightCommand(client, repo, opts));
4803
5284
  program2.command("log").description("Recent events (all repos when run outside a checkout)").argument(repoArg, repoHelp).addOption(new Option("-n, --limit <n>", "how many events").default(20).argParser(positiveInt)).option("--json", "print raw JSON").action(log);
4804
5285
  return program2;
4805
5286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oche-cli",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line client for Oche: promote dev → staging → main from your terminal.",
5
5
  "license": "MIT",
6
6
  "repository": {