oche-cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/index.js +620 -194
- 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.
|
|
3374
|
+
version: "0.2.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,151 @@ function resolveAi(file, env) {
|
|
|
3768
4159
|
};
|
|
3769
4160
|
}
|
|
3770
4161
|
|
|
3771
|
-
// src/
|
|
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
|
+
function npmLive(bin, args) {
|
|
4186
|
+
closeReader();
|
|
4187
|
+
return new Promise((resolve, reject) => {
|
|
4188
|
+
const child = spawn(bin[0], [...bin.slice(1), ...args], { stdio: "inherit" });
|
|
4189
|
+
child.on("error", reject);
|
|
4190
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(new CliError(`npm ${args[0]} failed (exit ${code}).`)));
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
async function localPackage() {
|
|
4194
|
+
try {
|
|
4195
|
+
return JSON.parse(await readFile3("package.json", "utf8"));
|
|
4196
|
+
} catch {
|
|
4197
|
+
return null;
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
function printSteps(st) {
|
|
4201
|
+
for (const s of st.steps) {
|
|
4202
|
+
print(` ${s.done ? c.green("\u2713") : c.dim("\u25CB")} ${s.label}${!s.done && s.hint ? c.dim(` ${s.hint}`) : ""}`);
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
async function npmStatusCommand(client2, repoArg, opts) {
|
|
4206
|
+
const repo = await resolveRepo(repoArg);
|
|
4207
|
+
const st = await (await client2()).npmStatus(repo);
|
|
4208
|
+
if (opts.json) return printJson(st);
|
|
4209
|
+
const name = st.publishing?.packageName ?? st.detected.name;
|
|
4210
|
+
print(`${c.bold(slug(repo))}${name ? ` ${name}` : ""}`);
|
|
4211
|
+
if (st.blocked && !st.publishing) {
|
|
4212
|
+
print(c.dim(st.blocked));
|
|
4213
|
+
return;
|
|
4214
|
+
}
|
|
4215
|
+
if (st.registry) {
|
|
4216
|
+
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"));
|
|
4217
|
+
}
|
|
4218
|
+
const run3 = st.publishing?.lastRun;
|
|
4219
|
+
if (run3) {
|
|
4220
|
+
const label = run3.conclusion === "success" ? c.green(run3.conclusion) : run3.conclusion === "failure" ? c.red(run3.conclusion) : run3.conclusion;
|
|
4221
|
+
print(` last run: ${label}${run3.failedStep ? ` at "${run3.failedStep}"` : ""} ${c.dim(`${relativeTime(run3.at)} \xB7 ${run3.url}`)}`);
|
|
4222
|
+
}
|
|
4223
|
+
if (st.publishing?.outdated) warn("The workflow is out of date. `oche npm setup` opens a PR to update it.");
|
|
4224
|
+
print();
|
|
4225
|
+
printSteps(st);
|
|
4226
|
+
if (!st.publishing) print(c.dim(`
|
|
4227
|
+
Run \`oche npm setup ${slug(repo)}\` to start publishing on every release.`));
|
|
4228
|
+
}
|
|
4229
|
+
async function npmSetupCommand(client2, repoArg, opts) {
|
|
4230
|
+
const repo = await resolveRepo(repoArg);
|
|
4231
|
+
const api = await client2();
|
|
4232
|
+
let st = await api.npmStatus(repo);
|
|
4233
|
+
if (st.blocked) throw new CliError(st.blocked);
|
|
4234
|
+
const name = st.publishing?.packageName ?? st.detected.name;
|
|
4235
|
+
const local = await localPackage();
|
|
4236
|
+
const inRepo = local?.name === name;
|
|
4237
|
+
const ask2 = async (q) => opts.yes || isInteractive() && await confirm(q, true);
|
|
4238
|
+
print(`Setting up npm publishing for ${c.bold(name)} from ${c.bold(slug(repo))}.
|
|
4239
|
+
`);
|
|
4240
|
+
const others = st.detected.otherPublishers;
|
|
4241
|
+
let replace = false;
|
|
4242
|
+
if (others.length) {
|
|
4243
|
+
print(`${others.join(", ")} already publishes ${name}.`);
|
|
4244
|
+
replace = !opts.keepExisting && await ask2(`Replace it with Oche's workflow? The PR deletes ${others.join(", ")}.`);
|
|
4245
|
+
if (!replace && !opts.keepExisting) throw new CliError("Stopped. Run again and replace it, or pass --keep-existing to run both.");
|
|
4246
|
+
}
|
|
4247
|
+
const bin = await npmBin();
|
|
4248
|
+
let npmUser = await npmOut(bin, ["whoami"]);
|
|
4249
|
+
if (!npmUser) {
|
|
4250
|
+
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.");
|
|
4251
|
+
await npmLive(bin, ["login"]);
|
|
4252
|
+
npmUser = await npmOut(bin, ["whoami"]);
|
|
4253
|
+
if (!npmUser) throw new CliError("Still not logged in to npm.");
|
|
4254
|
+
}
|
|
4255
|
+
ok(`npm: logged in as ${c.bold(npmUser)}`);
|
|
4256
|
+
if (!st.registry?.exists) {
|
|
4257
|
+
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.`);
|
|
4258
|
+
print(`${name} isn't on npm yet. The first version has to be published from your machine; Oche takes over after that.`);
|
|
4259
|
+
if (!await ask2(`Build and publish ${name}@${local.version} now?`)) throw new CliError("Publish it once with `npm publish`, then run this again.");
|
|
4260
|
+
await npmLive(bin, ["run", "build", "--if-present"]);
|
|
4261
|
+
await npmLive(bin, ["publish", "--access", "public"]);
|
|
4262
|
+
ok(`Published ${name}@${local.version}`);
|
|
4263
|
+
} else {
|
|
4264
|
+
const owners = await npmOut(bin, ["owner", "ls", name]);
|
|
4265
|
+
if (owners && !owners.split("\n").some((l) => l.split(" ")[0] === npmUser)) {
|
|
4266
|
+
warn(`${npmUser} isn't listed as an owner of ${name}. npm may refuse to change its trust settings.`);
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
if (!opts.skipPr && (!st.publishing || st.publishing.outdated || replace)) {
|
|
4270
|
+
try {
|
|
4271
|
+
const res = await api.npmSetup(repo, replace ? { replace: true } : others.length ? { force: true } : {});
|
|
4272
|
+
const what = [res.path, ...res.removes.map((p) => `removes ${p}`)].join(", ");
|
|
4273
|
+
if (res.pr) ok(`${res.pr.merged ? "Merged" : "Opened"} #${res.pr.number}: ${what} ${c.dim(res.pr.url)}`);
|
|
4274
|
+
else ok(`${res.path} is already on dev.`);
|
|
4275
|
+
} catch (e) {
|
|
4276
|
+
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.");
|
|
4277
|
+
throw e;
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
const trusted = await npmOut(bin, ["trust", "list", name, "--json"]);
|
|
4281
|
+
const already = Boolean(trusted && trusted.includes(`${repo.owner}/${repo.repo}`) && trusted.includes(WORKFLOW_FILE));
|
|
4282
|
+
if (already) {
|
|
4283
|
+
ok(`npm already trusts ${WORKFLOW_FILE} in ${slug(repo)}`);
|
|
4284
|
+
} else {
|
|
4285
|
+
const old = others.find((f) => trusted && new RegExp(`(^|[^\\w.-])${f.replace(/\./g, "\\.")}`).test(trusted));
|
|
4286
|
+
if (old) print(`
|
|
4287
|
+
npm trusts ${old} to publish ${name} today. This points it at ${WORKFLOW_FILE} instead.`);
|
|
4288
|
+
print(`
|
|
4289
|
+
npm will trust ${c.bold(`${slug(repo)} \u2192 .github/workflows/${WORKFLOW_FILE}`)} to publish ${name}. npm may ask for 2FA.`);
|
|
4290
|
+
if (!await ask2("Continue?")) throw new CliError("Stopped before changing npm's trust settings.");
|
|
4291
|
+
await npmLive(bin, ["trust", "github", name, "--file", WORKFLOW_FILE, "--repository", slug(repo), "--allow-publish", ...opts.yes ? ["--yes"] : []]);
|
|
4292
|
+
ok("npm trusts the workflow");
|
|
4293
|
+
}
|
|
4294
|
+
st = await api.npmTrust(repo, { packageName: name, npmUser });
|
|
4295
|
+
print();
|
|
4296
|
+
printSteps(st);
|
|
4297
|
+
const next = st.steps.find((s) => !s.done && s.id !== "published");
|
|
4298
|
+
print(next ? c.dim(`
|
|
4299
|
+
Next: ${next.hint ?? next.label}`) : c.dim("\nEvery push to production now publishes a new version."));
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
// src/github.ts
|
|
4303
|
+
import { execFile as execFile3 } from "child_process";
|
|
4304
|
+
import { promisify as promisify3 } from "util";
|
|
3774
4305
|
var API = "https://api.github.com";
|
|
3775
|
-
var run2 =
|
|
4306
|
+
var run2 = promisify3(execFile3);
|
|
3776
4307
|
async function githubToken(env = process.env) {
|
|
3777
4308
|
const fromEnv = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
|
|
3778
4309
|
if (fromEnv) return fromEnv;
|
|
@@ -3850,16 +4381,16 @@ var GitHub = class {
|
|
|
3850
4381
|
};
|
|
3851
4382
|
|
|
3852
4383
|
// src/localgit.ts
|
|
3853
|
-
import { execFile as
|
|
4384
|
+
import { execFile as execFile4, spawn as spawn2 } from "child_process";
|
|
3854
4385
|
import { copyFile, mkdtemp, rm as rm2 } from "fs/promises";
|
|
3855
4386
|
import { tmpdir } from "os";
|
|
3856
4387
|
import { join as join3 } from "path";
|
|
3857
|
-
import { promisify as
|
|
3858
|
-
var
|
|
4388
|
+
import { promisify as promisify4 } from "util";
|
|
4389
|
+
var exec2 = promisify4(execFile4);
|
|
3859
4390
|
var MAX_BUFFER = 64 * 1024 * 1024;
|
|
3860
4391
|
async function git(args, env) {
|
|
3861
4392
|
try {
|
|
3862
|
-
const { stdout } = await
|
|
4393
|
+
const { stdout } = await exec2("git", args, { maxBuffer: MAX_BUFFER, env: env ? { ...process.env, ...env } : process.env });
|
|
3863
4394
|
return stdout;
|
|
3864
4395
|
} catch (e) {
|
|
3865
4396
|
const err = e;
|
|
@@ -3920,7 +4451,7 @@ var addTracked = () => git(["add", "-u"]);
|
|
|
3920
4451
|
var commit = (message) => git(["commit", "-m", message]);
|
|
3921
4452
|
function commitWithEditor(message) {
|
|
3922
4453
|
return new Promise((resolve, reject) => {
|
|
3923
|
-
const child =
|
|
4454
|
+
const child = spawn2("git", ["commit", "-e", "-m", message], { stdio: "inherit" });
|
|
3924
4455
|
child.on("error", reject);
|
|
3925
4456
|
child.on("exit", (code) => code === 0 ? resolve() : reject(new CliError("Commit aborted.")));
|
|
3926
4457
|
});
|
|
@@ -3990,123 +4521,6 @@ async function generateCommitMessage(input, opts) {
|
|
|
3990
4521
|
return parseMessage(await res.json());
|
|
3991
4522
|
}
|
|
3992
4523
|
|
|
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
4524
|
// src/ship-run.ts
|
|
4111
4525
|
var MERGE_HOW = {
|
|
4112
4526
|
merge: "open a PR and merge",
|
|
@@ -4535,8 +4949,8 @@ function nonNegativeInt(value) {
|
|
|
4535
4949
|
return n;
|
|
4536
4950
|
}
|
|
4537
4951
|
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"]);
|
|
4952
|
+
var BAD_EVENTS = /* @__PURE__ */ new Set(["promotion-conflict", "sync-conflict", "pr-blocked", "direct-push", "deploy-failed", "app-stopped", "publish-failed"]);
|
|
4953
|
+
var GOOD_EVENTS = /* @__PURE__ */ new Set(["promoted", "synced", "onboarded", "deploy-succeeded", "published"]);
|
|
4540
4954
|
var newestFirst = (events) => [...events].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id - a.id);
|
|
4541
4955
|
function eventLines(events, showRepo) {
|
|
4542
4956
|
return columns(
|
|
@@ -4707,8 +5121,8 @@ async function promote(target, repoArg, opts) {
|
|
|
4707
5121
|
return;
|
|
4708
5122
|
case "waiting-review": {
|
|
4709
5123
|
const a = result.approval;
|
|
4710
|
-
const
|
|
4711
|
-
print(`#${result.pr.number} ${result.pr.head} \u2192 ${result.pr.base} is waiting for review: ${c.yellow(`${a.approvals}/${a.required} approvals`)}${
|
|
5124
|
+
const note2 = a.changesRequested ? ", changes requested" : "";
|
|
5125
|
+
print(`#${result.pr.number} ${result.pr.head} \u2192 ${result.pr.base} is waiting for review: ${c.yellow(`${a.approvals}/${a.required} approvals`)}${note2}`);
|
|
4712
5126
|
print(` \u2192 ${result.pr.url}`);
|
|
4713
5127
|
return;
|
|
4714
5128
|
}
|
|
@@ -4800,6 +5214,18 @@ function buildProgram() {
|
|
|
4800
5214
|
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
5215
|
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
5216
|
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);
|
|
5217
|
+
const stageOption = (description) => new Option("--stage <stage>", description).choices(["prod", "staging", "dev"]);
|
|
5218
|
+
const appOption = new Option("--app <name>", "which Coolify app (its name, or the start of its uuid)");
|
|
5219
|
+
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));
|
|
5220
|
+
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));
|
|
5221
|
+
const npm = program2.command("npm").description("Publish this package to npm on every release");
|
|
5222
|
+
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));
|
|
5223
|
+
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));
|
|
5224
|
+
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));
|
|
5225
|
+
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));
|
|
5226
|
+
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));
|
|
5227
|
+
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));
|
|
5228
|
+
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
5229
|
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
5230
|
return program2;
|
|
4805
5231
|
}
|