oche-cli 0.1.0 → 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/LICENSE +21 -0
- package/README.md +25 -3
- package/dist/index.js +664 -200
- package/package.json +3 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samso9th
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# oche-cli
|
|
2
2
|
|
|
3
|
-
`oche` from your terminal. It talks to your Oche server with a token you create in the dashboard under Settings → CLI tokens.
|
|
3
|
+
`oche` from your terminal. It talks to your Oche server with a token you create in the dashboard under Settings → CLI tokens. Self-hosting? Run `oche login https://your-server` once.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm install
|
|
6
|
+
npm install -g oche-cli # installs the `oche` command
|
|
7
7
|
|
|
8
|
-
oche login
|
|
8
|
+
oche login # paste a CLI token from ship.oche.io/settings
|
|
9
9
|
oche ship # commit what's changed, push, then feature → dev → staging → main
|
|
10
10
|
oche ship staging # stop at staging
|
|
11
11
|
oche ship prod --direct # dev → main, then Oche syncs staging
|
|
@@ -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,8 +3371,9 @@ 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
|
+
license: "MIT",
|
|
3376
3377
|
repository: {
|
|
3377
3378
|
type: "git",
|
|
3378
3379
|
url: "git+https://github.com/Samso9th/oche-cli.git"
|
|
@@ -3470,6 +3471,50 @@ var Client = class {
|
|
|
3470
3471
|
commitMessage(body) {
|
|
3471
3472
|
return this.request("POST", "/v1/ai/commit-message", body, 9e4);
|
|
3472
3473
|
}
|
|
3474
|
+
/* ---- Coolify ---- */
|
|
3475
|
+
deployments(r) {
|
|
3476
|
+
return this.request("GET", `${repoPath(r)}/deployments`);
|
|
3477
|
+
}
|
|
3478
|
+
/** One deployment with its build log, the failure excerpt and a pasteable report. */
|
|
3479
|
+
deployment(r, app, deployment) {
|
|
3480
|
+
return this.request("GET", `${repoPath(r)}/deployments/${encodeURIComponent(app)}/${encodeURIComponent(deployment)}`);
|
|
3481
|
+
}
|
|
3482
|
+
/** The running container's log, not the build log. */
|
|
3483
|
+
appLogs(r, app, lines) {
|
|
3484
|
+
return this.request("GET", `${repoPath(r)}/apps/${encodeURIComponent(app)}/logs?lines=${lines}`);
|
|
3485
|
+
}
|
|
3486
|
+
/** The server asks its AI model what broke, so the CLI needs no AI key. */
|
|
3487
|
+
explainDeployment(r, app, deployment) {
|
|
3488
|
+
const path2 = `${repoPath(r)}/deployments/${encodeURIComponent(app)}/${encodeURIComponent(deployment)}/explain`;
|
|
3489
|
+
return this.request("POST", path2, void 0, 12e4);
|
|
3490
|
+
}
|
|
3491
|
+
deploy(r, app, force) {
|
|
3492
|
+
return this.request("POST", `${repoPath(r)}/apps/${encodeURIComponent(app)}/deploy`, { force });
|
|
3493
|
+
}
|
|
3494
|
+
rollbackOptions(r, app) {
|
|
3495
|
+
return this.request("GET", `${repoPath(r)}/apps/${encodeURIComponent(app)}/rollback`);
|
|
3496
|
+
}
|
|
3497
|
+
rollback(r, app, commit2) {
|
|
3498
|
+
return this.request("POST", `${repoPath(r)}/apps/${encodeURIComponent(app)}/rollback`, { commit: commit2 });
|
|
3499
|
+
}
|
|
3500
|
+
envs(r) {
|
|
3501
|
+
return this.request("GET", `${repoPath(r)}/envs`);
|
|
3502
|
+
}
|
|
3503
|
+
preflight(r, to) {
|
|
3504
|
+
return this.request("GET", `${repoPath(r)}/preflight?to=${to}`);
|
|
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
|
+
}
|
|
3473
3518
|
events(repo, limit) {
|
|
3474
3519
|
const q = new URLSearchParams({ limit: String(limit) });
|
|
3475
3520
|
if (repo) q.set("repo", `${repo.owner}/${repo.repo}`);
|
|
@@ -3530,10 +3575,455 @@ function networkError(e, baseUrl, timeoutMs = TIMEOUT_MS) {
|
|
|
3530
3575
|
return new CliError(`Couldn't reach ${baseUrl} (${reason}).`);
|
|
3531
3576
|
}
|
|
3532
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
|
+
|
|
3533
4021
|
// src/config.ts
|
|
3534
4022
|
import { chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
3535
4023
|
import { homedir } from "os";
|
|
3536
4024
|
import { dirname, join } from "path";
|
|
4025
|
+
var DEFAULT_SERVER_URL = "https://deploy.oche.io";
|
|
4026
|
+
var DEFAULT_TOKEN_PAGE = "https://ship.oche.io/settings";
|
|
3537
4027
|
function configPath(env = process.env) {
|
|
3538
4028
|
const base = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
3539
4029
|
return join(base, "oche", "config.json");
|
|
@@ -3607,70 +4097,6 @@ async function deleteConfig(path2 = configPath()) {
|
|
|
3607
4097
|
return existed;
|
|
3608
4098
|
}
|
|
3609
4099
|
|
|
3610
|
-
// src/git.ts
|
|
3611
|
-
import { execFile } from "child_process";
|
|
3612
|
-
import { promisify } from "util";
|
|
3613
|
-
var slug = (r) => `${r.owner}/${r.repo}`;
|
|
3614
|
-
var GITHUB_HOST = /^(?:www\.|ssh\.)?github\.com(?:-[\w.-]+)?$/i;
|
|
3615
|
-
var OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
3616
|
-
var REPO = /^[A-Za-z0-9._-]{1,100}$/;
|
|
3617
|
-
var URL_SCHEMES = /* @__PURE__ */ new Set(["https:", "http:", "ssh:", "git:", "git+ssh:", "ssh+git:"]);
|
|
3618
|
-
function toSlug(path2) {
|
|
3619
|
-
const parts = path2.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/");
|
|
3620
|
-
if (parts.length !== 2) return null;
|
|
3621
|
-
const [owner, repo] = parts;
|
|
3622
|
-
if (!OWNER.test(owner) || !REPO.test(repo) || repo === "." || repo === "..") return null;
|
|
3623
|
-
return { owner, repo };
|
|
3624
|
-
}
|
|
3625
|
-
function parseGitHubRemote(remote) {
|
|
3626
|
-
const s = remote.trim();
|
|
3627
|
-
let host;
|
|
3628
|
-
let path2;
|
|
3629
|
-
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
|
|
3630
|
-
let u;
|
|
3631
|
-
try {
|
|
3632
|
-
u = new URL(s);
|
|
3633
|
-
} catch {
|
|
3634
|
-
return null;
|
|
3635
|
-
}
|
|
3636
|
-
if (!URL_SCHEMES.has(u.protocol)) return null;
|
|
3637
|
-
host = u.hostname;
|
|
3638
|
-
path2 = decodeURIComponent(u.pathname);
|
|
3639
|
-
} else {
|
|
3640
|
-
const m = /^(?:[^@\s/]+@)?([^:\s/]+):(.+)$/.exec(s);
|
|
3641
|
-
if (!m) return null;
|
|
3642
|
-
host = m[1];
|
|
3643
|
-
path2 = m[2];
|
|
3644
|
-
}
|
|
3645
|
-
if (!GITHUB_HOST.test(host)) return null;
|
|
3646
|
-
return toSlug(path2);
|
|
3647
|
-
}
|
|
3648
|
-
function parseRepoArg(arg) {
|
|
3649
|
-
const s = arg.trim();
|
|
3650
|
-
const found = /^[^/\s:@]+\/[^/\s:@]+$/.test(s) ? toSlug(s) : parseGitHubRemote(s);
|
|
3651
|
-
if (!found) throw new CliError(`Expected owner/repo, got "${arg}".`);
|
|
3652
|
-
return found;
|
|
3653
|
-
}
|
|
3654
|
-
var run = promisify(execFile);
|
|
3655
|
-
async function inferRepo(cwd = process.cwd()) {
|
|
3656
|
-
let url;
|
|
3657
|
-
try {
|
|
3658
|
-
const { stdout } = await run("git", ["remote", "get-url", "origin"], { cwd });
|
|
3659
|
-
url = stdout.trim();
|
|
3660
|
-
} catch (e) {
|
|
3661
|
-
if (e.code === "ENOENT") {
|
|
3662
|
-
throw new CliError("git isn't installed, so the repo can't be inferred. Pass owner/repo.");
|
|
3663
|
-
}
|
|
3664
|
-
throw new CliError('No repo given and no git remote "origin" here. Pass owner/repo.');
|
|
3665
|
-
}
|
|
3666
|
-
const found = parseGitHubRemote(url);
|
|
3667
|
-
if (!found) throw new CliError(`origin (${url}) isn't a GitHub repo. Pass owner/repo.`);
|
|
3668
|
-
return found;
|
|
3669
|
-
}
|
|
3670
|
-
async function resolveRepo(arg) {
|
|
3671
|
-
return arg ? parseRepoArg(arg) : inferRepo();
|
|
3672
|
-
}
|
|
3673
|
-
|
|
3674
4100
|
// src/promotion.ts
|
|
3675
4101
|
var DEFAULT_BRANCHES = { prod: "main", staging: "staging", dev: "dev" };
|
|
3676
4102
|
function toPromotion(target, direct = false) {
|
|
@@ -3733,11 +4159,151 @@ function resolveAi(file, env) {
|
|
|
3733
4159
|
};
|
|
3734
4160
|
}
|
|
3735
4161
|
|
|
3736
|
-
// src/
|
|
3737
|
-
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";
|
|
3738
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";
|
|
3739
4305
|
var API = "https://api.github.com";
|
|
3740
|
-
var run2 =
|
|
4306
|
+
var run2 = promisify3(execFile3);
|
|
3741
4307
|
async function githubToken(env = process.env) {
|
|
3742
4308
|
const fromEnv = env.GH_TOKEN?.trim() || env.GITHUB_TOKEN?.trim();
|
|
3743
4309
|
if (fromEnv) return fromEnv;
|
|
@@ -3815,16 +4381,16 @@ var GitHub = class {
|
|
|
3815
4381
|
};
|
|
3816
4382
|
|
|
3817
4383
|
// src/localgit.ts
|
|
3818
|
-
import { execFile as
|
|
4384
|
+
import { execFile as execFile4, spawn as spawn2 } from "child_process";
|
|
3819
4385
|
import { copyFile, mkdtemp, rm as rm2 } from "fs/promises";
|
|
3820
4386
|
import { tmpdir } from "os";
|
|
3821
4387
|
import { join as join3 } from "path";
|
|
3822
|
-
import { promisify as
|
|
3823
|
-
var
|
|
4388
|
+
import { promisify as promisify4 } from "util";
|
|
4389
|
+
var exec2 = promisify4(execFile4);
|
|
3824
4390
|
var MAX_BUFFER = 64 * 1024 * 1024;
|
|
3825
4391
|
async function git(args, env) {
|
|
3826
4392
|
try {
|
|
3827
|
-
const { stdout } = await
|
|
4393
|
+
const { stdout } = await exec2("git", args, { maxBuffer: MAX_BUFFER, env: env ? { ...process.env, ...env } : process.env });
|
|
3828
4394
|
return stdout;
|
|
3829
4395
|
} catch (e) {
|
|
3830
4396
|
const err = e;
|
|
@@ -3885,7 +4451,7 @@ var addTracked = () => git(["add", "-u"]);
|
|
|
3885
4451
|
var commit = (message) => git(["commit", "-m", message]);
|
|
3886
4452
|
function commitWithEditor(message) {
|
|
3887
4453
|
return new Promise((resolve, reject) => {
|
|
3888
|
-
const child =
|
|
4454
|
+
const child = spawn2("git", ["commit", "-e", "-m", message], { stdio: "inherit" });
|
|
3889
4455
|
child.on("error", reject);
|
|
3890
4456
|
child.on("exit", (code) => code === 0 ? resolve() : reject(new CliError("Commit aborted.")));
|
|
3891
4457
|
});
|
|
@@ -3955,123 +4521,6 @@ async function generateCommitMessage(input, opts) {
|
|
|
3955
4521
|
return parseMessage(await res.json());
|
|
3956
4522
|
}
|
|
3957
4523
|
|
|
3958
|
-
// src/ui.ts
|
|
3959
|
-
import { createInterface } from "readline";
|
|
3960
|
-
import { styleText } from "util";
|
|
3961
|
-
function paint(format, text, stream = process.stdout) {
|
|
3962
|
-
if (process.env.NO_COLOR) return text;
|
|
3963
|
-
return styleText(format, text, { stream, validateStream: true });
|
|
3964
|
-
}
|
|
3965
|
-
var c = {
|
|
3966
|
-
bold: (s) => paint("bold", s),
|
|
3967
|
-
dim: (s) => paint("dim", s),
|
|
3968
|
-
green: (s) => paint("green", s),
|
|
3969
|
-
red: (s) => paint("red", s),
|
|
3970
|
-
yellow: (s) => paint("yellow", s),
|
|
3971
|
-
cyan: (s) => paint("cyan", s)
|
|
3972
|
-
};
|
|
3973
|
-
var print = (line = "") => void process.stdout.write(`${line}
|
|
3974
|
-
`);
|
|
3975
|
-
var printJson = (data) => print(JSON.stringify(data, null, 2));
|
|
3976
|
-
var ok = (msg) => print(`${c.green("\u2713")} ${msg}`);
|
|
3977
|
-
var warn = (msg) => process.stderr.write(`${paint("yellow", "!", process.stderr)} ${msg}
|
|
3978
|
-
`);
|
|
3979
|
-
var fail = (msg) => process.stderr.write(`${paint("red", "\u2717", process.stderr)} ${msg}
|
|
3980
|
-
`);
|
|
3981
|
-
function relativeTime(iso, now = Date.now()) {
|
|
3982
|
-
if (!iso) return "";
|
|
3983
|
-
const then = Date.parse(iso);
|
|
3984
|
-
if (Number.isNaN(then)) return "";
|
|
3985
|
-
const s = Math.max(0, Math.round((now - then) / 1e3));
|
|
3986
|
-
if (s < 45) return "just now";
|
|
3987
|
-
const m = Math.round(s / 60);
|
|
3988
|
-
if (m < 60) return `${m}m ago`;
|
|
3989
|
-
const h = Math.round(m / 60);
|
|
3990
|
-
if (h < 24) return `${h}h ago`;
|
|
3991
|
-
const d = Math.round(h / 24);
|
|
3992
|
-
if (d < 30) return `${d}d ago`;
|
|
3993
|
-
return new Date(then).toISOString().slice(0, 10);
|
|
3994
|
-
}
|
|
3995
|
-
function truncate(s, max) {
|
|
3996
|
-
return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
|
|
3997
|
-
}
|
|
3998
|
-
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
3999
|
-
function columns(rows, indent = " ") {
|
|
4000
|
-
const plainOf = (cell) => typeof cell === "string" ? cell : cell[0];
|
|
4001
|
-
const styledOf = (cell) => typeof cell === "string" ? cell : cell[1];
|
|
4002
|
-
const widths = [];
|
|
4003
|
-
for (const row of rows) row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, plainOf(cell).length));
|
|
4004
|
-
return rows.map(
|
|
4005
|
-
(row) => indent + row.map((cell, i) => i === row.length - 1 ? styledOf(cell) : styledOf(cell) + " ".repeat((widths[i] ?? 0) - plainOf(cell).length)).join(" ").trimEnd()
|
|
4006
|
-
);
|
|
4007
|
-
}
|
|
4008
|
-
var isInteractive = () => Boolean(process.stdin.isTTY);
|
|
4009
|
-
var reader = null;
|
|
4010
|
-
async function readLine() {
|
|
4011
|
-
if (!reader) {
|
|
4012
|
-
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
4013
|
-
reader = { rl, lines: rl[Symbol.asyncIterator]() };
|
|
4014
|
-
}
|
|
4015
|
-
const next = await reader.lines.next();
|
|
4016
|
-
if (next.done) throw new CliError("No input: stdin was closed.");
|
|
4017
|
-
return next.value;
|
|
4018
|
-
}
|
|
4019
|
-
function closeReader() {
|
|
4020
|
-
reader?.rl.close();
|
|
4021
|
-
reader = null;
|
|
4022
|
-
}
|
|
4023
|
-
async function ask(question, fallback) {
|
|
4024
|
-
process.stderr.write(fallback ? `${question} ${paint("dim", `(${fallback})`, process.stderr)}: ` : `${question}: `);
|
|
4025
|
-
const answer = (await readLine()).trim();
|
|
4026
|
-
if (isInteractive()) closeReader();
|
|
4027
|
-
else process.stderr.write("\n");
|
|
4028
|
-
return answer || fallback || "";
|
|
4029
|
-
}
|
|
4030
|
-
async function askHidden(question) {
|
|
4031
|
-
process.stderr.write(`${question}: `);
|
|
4032
|
-
if (!isInteractive()) {
|
|
4033
|
-
const line = (await readLine()).trim();
|
|
4034
|
-
process.stderr.write("\n");
|
|
4035
|
-
return line;
|
|
4036
|
-
}
|
|
4037
|
-
closeReader();
|
|
4038
|
-
const stdin = process.stdin;
|
|
4039
|
-
return new Promise((resolve, reject) => {
|
|
4040
|
-
let buf = "";
|
|
4041
|
-
const finish = (err) => {
|
|
4042
|
-
stdin.off("data", onData);
|
|
4043
|
-
stdin.setRawMode(false);
|
|
4044
|
-
stdin.pause();
|
|
4045
|
-
process.stderr.write("\n");
|
|
4046
|
-
if (err) reject(err);
|
|
4047
|
-
else resolve(buf.trim());
|
|
4048
|
-
};
|
|
4049
|
-
const onData = (chunk) => {
|
|
4050
|
-
const text = chunk.toString().replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
|
|
4051
|
-
for (const ch of text) {
|
|
4052
|
-
if (ch === "\r" || ch === "\n") return finish();
|
|
4053
|
-
if (ch === "") return finish(new CliError("Cancelled.", 130));
|
|
4054
|
-
if (ch === "") return buf ? finish() : finish(new CliError("Cancelled.", 130));
|
|
4055
|
-
if (ch === "\x7F" || ch === "\b") buf = buf.slice(0, -1);
|
|
4056
|
-
else if (ch === "") buf = "";
|
|
4057
|
-
else if (ch >= " ") buf += ch;
|
|
4058
|
-
}
|
|
4059
|
-
};
|
|
4060
|
-
stdin.setRawMode(true);
|
|
4061
|
-
stdin.resume();
|
|
4062
|
-
stdin.on("data", onData);
|
|
4063
|
-
});
|
|
4064
|
-
}
|
|
4065
|
-
async function confirm(question, defaultYes = false) {
|
|
4066
|
-
if (!isInteractive()) throw new CliError(`${question} Needs confirmation; pass --yes to run without a prompt.`);
|
|
4067
|
-
const hint = defaultYes ? "Y/n" : "y/N";
|
|
4068
|
-
process.stderr.write(`${question} ${paint("dim", `[${hint}]`, process.stderr)} `);
|
|
4069
|
-
const answer = (await readLine()).trim().toLowerCase();
|
|
4070
|
-
closeReader();
|
|
4071
|
-
if (!answer) return defaultYes;
|
|
4072
|
-
return answer === "y" || answer === "yes";
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
4524
|
// src/ship-run.ts
|
|
4076
4525
|
var MERGE_HOW = {
|
|
4077
4526
|
merge: "open a PR and merge",
|
|
@@ -4500,8 +4949,8 @@ function nonNegativeInt(value) {
|
|
|
4500
4949
|
return n;
|
|
4501
4950
|
}
|
|
4502
4951
|
var modeLabel = (repo) => repo.mode === "strict" ? `strict \xB7 ${plural(repo.requiredApprovals, "approval")}` : "fast";
|
|
4503
|
-
var BAD_EVENTS = /* @__PURE__ */ new Set(["promotion-conflict", "sync-conflict", "pr-blocked", "direct-push", "deploy-failed", "app-stopped"]);
|
|
4504
|
-
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"]);
|
|
4505
4954
|
var newestFirst = (events) => [...events].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id - a.id);
|
|
4506
4955
|
function eventLines(events, showRepo) {
|
|
4507
4956
|
return columns(
|
|
@@ -4518,10 +4967,13 @@ function eventLines(events, showRepo) {
|
|
|
4518
4967
|
}
|
|
4519
4968
|
async function login(urlArg) {
|
|
4520
4969
|
const saved = await loadConfig().catch(() => null);
|
|
4521
|
-
const
|
|
4522
|
-
const
|
|
4523
|
-
|
|
4524
|
-
|
|
4970
|
+
const url = normalizeUrl(urlArg ?? process.env.OCHE_URL ?? saved?.url ?? DEFAULT_SERVER_URL);
|
|
4971
|
+
const where = url === DEFAULT_SERVER_URL ? DEFAULT_TOKEN_PAGE : "your Oche dashboard under Settings";
|
|
4972
|
+
process.stderr.write(`Logging in to ${c.bold(url)}
|
|
4973
|
+
`);
|
|
4974
|
+
process.stderr.write(c.dim(`Create a CLI token at ${where}, then paste it here.
|
|
4975
|
+
`));
|
|
4976
|
+
const token = await askHidden("CLI token");
|
|
4525
4977
|
if (!token) throw new CliError("No token entered.");
|
|
4526
4978
|
const me = await new Client(url, token, "config").me().catch((e) => {
|
|
4527
4979
|
if (e instanceof ApiRequestError && e.status === 401) {
|
|
@@ -4541,7 +4993,7 @@ async function logout() {
|
|
|
4541
4993
|
const saved = await loadConfig().catch(() => null);
|
|
4542
4994
|
if (await deleteConfig()) {
|
|
4543
4995
|
ok(`Logged out${saved?.url ? ` of ${saved.url}` : ""}.`);
|
|
4544
|
-
print(c.dim("The token keeps working until you revoke it in the dashboard (Settings \u2192
|
|
4996
|
+
print(c.dim("The token keeps working until you revoke it in the dashboard (Settings \u2192 CLI tokens)."));
|
|
4545
4997
|
} else {
|
|
4546
4998
|
print("Not logged in.");
|
|
4547
4999
|
}
|
|
@@ -4669,8 +5121,8 @@ async function promote(target, repoArg, opts) {
|
|
|
4669
5121
|
return;
|
|
4670
5122
|
case "waiting-review": {
|
|
4671
5123
|
const a = result.approval;
|
|
4672
|
-
const
|
|
4673
|
-
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}`);
|
|
4674
5126
|
print(` \u2192 ${result.pr.url}`);
|
|
4675
5127
|
return;
|
|
4676
5128
|
}
|
|
@@ -4751,7 +5203,7 @@ function buildProgram() {
|
|
|
4751
5203
|
const program2 = new Command().name("oche").description("Promote dev \u2192 staging \u2192 main from your terminal.").version(package_default.version, "-v, --version").showSuggestionAfterError();
|
|
4752
5204
|
const repoArg = "[owner/repo]";
|
|
4753
5205
|
const repoHelp = "defaults to the GitHub repo of git remote origin";
|
|
4754
|
-
program2.command("login").description("
|
|
5206
|
+
program2.command("login").description("Sign in with a CLI token from the dashboard").argument("[url]", "Oche server URL (only if you self-host)").action(login);
|
|
4755
5207
|
program2.command("logout").description("Forget the saved login").action(logout);
|
|
4756
5208
|
program2.command("status").description("Branches, pending commits, promotion PRs and recent events").argument(repoArg, repoHelp).option("--json", "print raw JSON").action(status);
|
|
4757
5209
|
program2.command("repos").description("List repos, managed first").option("--json", "print raw JSON").action(repos);
|
|
@@ -4762,6 +5214,18 @@ function buildProgram() {
|
|
|
4762
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));
|
|
4763
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);
|
|
4764
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));
|
|
4765
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);
|
|
4766
5230
|
return program2;
|
|
4767
5231
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oche-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command-line client for Oche: promote dev → staging
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Command-line client for Oche: promote dev → staging → main from your terminal.",
|
|
5
|
+
"license": "MIT",
|
|
5
6
|
"repository": {
|
|
6
7
|
"type": "git",
|
|
7
8
|
"url": "git+https://github.com/Samso9th/oche-cli.git"
|