@kaynooo/cli-docker 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/README.md +2 -1
- package/dist/dc-cli.js +16 -1
- package/dist/dx-cli.js +201 -30
- package/package.json +1 -1
- package/src/dx.ts +117 -24
- package/src/history.ts +48 -0
- package/src/picker.ts +67 -0
- package/src/stack.ts +24 -0
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@ bun install -g @kaynooo/cli-docker
|
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| `dc <compose args>` | `docker compose` on the nearest compose file (or `.env` setting `COMPOSE_FILE`) up to the repository root |
|
|
14
14
|
| `dc <project> <compose args>` | the same on a stack that came up from other files (`-f docker-compose.prod.yaml --env-file .env.prod`), found through the labels compose put on its containers |
|
|
15
|
-
| `dx` |
|
|
15
|
+
| `dx` | picks a service with fzf: enter opens its shell (or brings it up), ctrl-l follows its logs, ctrl-r restarts, ctrl-x stops, ctrl-b rebuilds; the ones acted on last come first. Without fzf or a terminal it lists them with their state, ports and image (`--json` for the full record) |
|
|
16
|
+
| `dx -` | the last action again |
|
|
16
17
|
| `dx <service> [action]` | `shell` (the default, `up` for a stopped service), `logs`, `restart`, `up`, `stop` or `build` |
|
|
17
18
|
|
|
18
19
|
`dc` takes no option of its own, every word reaches compose as typed. With several stacks that only have containers here and no name, it asks with `pick` when that is installed and a terminal is attached, and otherwise lists the projects. When it picked the stack through its containers it prints the compose command it stands for on stderr.
|
package/dist/dc-cli.js
CHANGED
|
@@ -745,7 +745,7 @@ var {Glob } = globalThis.Bun;
|
|
|
745
745
|
var SESSION_FILES = new Glob("*.json");
|
|
746
746
|
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
747
747
|
// package.json
|
|
748
|
-
var version = "0.
|
|
748
|
+
var version = "0.2.0";
|
|
749
749
|
|
|
750
750
|
// src/dc.ts
|
|
751
751
|
import process8 from "process";
|
|
@@ -890,6 +890,21 @@ async function commandFor(service, action) {
|
|
|
890
890
|
function defaultAction(service) {
|
|
891
891
|
return service.state === "running" ? "shell" : "up";
|
|
892
892
|
}
|
|
893
|
+
function stateColor(state) {
|
|
894
|
+
if (state === "running")
|
|
895
|
+
return "green";
|
|
896
|
+
if (state === "exited" || state === "dead")
|
|
897
|
+
return "red";
|
|
898
|
+
return state === "none" ? "dim" : "yellow";
|
|
899
|
+
}
|
|
900
|
+
async function serviceNamed(dir, name, project) {
|
|
901
|
+
const [recorded] = project === "" ? [] : await labels(project);
|
|
902
|
+
const stack = recorded === undefined ? stackAt(dir) : labelledStack(dir, recorded);
|
|
903
|
+
return (await services(stack)).find((candidate) => candidate.name === name);
|
|
904
|
+
}
|
|
905
|
+
async function logsOf(stack, name) {
|
|
906
|
+
return output(stack, ["logs", "--tail=30", "--no-log-prefix", name]);
|
|
907
|
+
}
|
|
893
908
|
|
|
894
909
|
// src/dc.ts
|
|
895
910
|
var COMMANDS = new Set(["alpha", "attach", "bridge", "build", "commit", "config", "cp", "create", "down", "events", "exec", "export", "help", "images", "kill", "logs", "ls", "pause", "port", "ps", "publish", "pull", "push", "restart", "rm", "run", "scale", "start", "stats", "stop", "top", "unpause", "up", "version", "volumes", "wait", "watch"]);
|
package/dist/dx-cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @bun
|
|
3
3
|
|
|
4
4
|
// src/dx-cli.ts
|
|
5
|
-
import
|
|
5
|
+
import process11 from "process";
|
|
6
6
|
|
|
7
7
|
// ../kit/src/args.ts
|
|
8
8
|
import process4 from "process";
|
|
@@ -745,14 +745,50 @@ var {Glob } = globalThis.Bun;
|
|
|
745
745
|
var SESSION_FILES = new Glob("*.json");
|
|
746
746
|
var ACCOUNT_DIRS = new Glob("*/sessions");
|
|
747
747
|
// package.json
|
|
748
|
-
var version = "0.
|
|
748
|
+
var version = "0.2.0";
|
|
749
749
|
|
|
750
750
|
// src/dx.ts
|
|
751
|
+
import process10 from "process";
|
|
752
|
+
|
|
753
|
+
// src/history.ts
|
|
754
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
|
|
755
|
+
import { homedir as homedir2 } from "os";
|
|
756
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
751
757
|
import process8 from "process";
|
|
758
|
+
var REMEMBERED = 20;
|
|
759
|
+
function historyFile() {
|
|
760
|
+
return join2(process8.env.XDG_CACHE_HOME ?? join2(homedir2(), ".cache"), "dx", "history.json");
|
|
761
|
+
}
|
|
762
|
+
function history() {
|
|
763
|
+
try {
|
|
764
|
+
const file = historyFile();
|
|
765
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf-8")) : {};
|
|
766
|
+
} catch {
|
|
767
|
+
return {};
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function runs() {
|
|
771
|
+
return history()[process8.cwd()] ?? [];
|
|
772
|
+
}
|
|
773
|
+
function record(run) {
|
|
774
|
+
const all = history();
|
|
775
|
+
const file = historyFile();
|
|
776
|
+
all[process8.cwd()] = [run, ...(all[process8.cwd()] ?? []).filter((earlier) => earlier.id !== run.id)].slice(0, REMEMBERED);
|
|
777
|
+
mkdirSync2(dirname2(file), { recursive: true });
|
|
778
|
+
writeFileSync(file, JSON.stringify(all));
|
|
779
|
+
}
|
|
780
|
+
function byRecency(items, previous, idOf) {
|
|
781
|
+
const rank = new Map(previous.map((run, index) => [run.id, index]));
|
|
782
|
+
const recent = items.filter((item) => rank.has(idOf(item))).sort((a, b) => rank.get(idOf(a)) - rank.get(idOf(b)));
|
|
783
|
+
return [...recent, ...items.filter((item) => !rank.has(idOf(item)))];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/picker.ts
|
|
787
|
+
import process9 from "process";
|
|
752
788
|
|
|
753
789
|
// src/stack.ts
|
|
754
|
-
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
755
|
-
import { dirname as
|
|
790
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "fs";
|
|
791
|
+
import { dirname as dirname3, join as join3, relative, resolve } from "path";
|
|
756
792
|
var LABEL_FORMAT = ["project", "project.config_files", "project.environment_file", "project.working_dir"].map((label) => `{{.Label "com.docker.compose.${label}"}}`).join("\t");
|
|
757
793
|
var COMPOSE_FILE_VAR = /^\s*(?:export\s+)?COMPOSE_FILE\s*=\s*\S/m;
|
|
758
794
|
var COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
|
|
@@ -766,10 +802,10 @@ function children(dir = ".") {
|
|
|
766
802
|
}
|
|
767
803
|
function up(dir) {
|
|
768
804
|
const dirs = [];
|
|
769
|
-
for (let current = dir;; current =
|
|
805
|
+
for (let current = dir;; current = join3(current, "..")) {
|
|
770
806
|
dirs.push(current);
|
|
771
807
|
const absolute = resolve(current);
|
|
772
|
-
if (
|
|
808
|
+
if (existsSync2(join3(current, ".git")) || dirname3(absolute) === absolute)
|
|
773
809
|
return dirs;
|
|
774
810
|
}
|
|
775
811
|
}
|
|
@@ -777,10 +813,10 @@ function stackAt(dir) {
|
|
|
777
813
|
return { dir, run: from(dir, ["docker", "compose"]), list: { argv: ["docker", "compose"], cwd: resolve(dir), env: process.env } };
|
|
778
814
|
}
|
|
779
815
|
function holdsStack(dir) {
|
|
780
|
-
if (COMPOSE_FILES.some((name) =>
|
|
816
|
+
if (COMPOSE_FILES.some((name) => existsSync2(join3(dir, name))))
|
|
781
817
|
return true;
|
|
782
818
|
try {
|
|
783
|
-
return COMPOSE_FILE_VAR.test(
|
|
819
|
+
return COMPOSE_FILE_VAR.test(readFileSync2(join3(dir, ".env"), "utf-8"));
|
|
784
820
|
} catch {
|
|
785
821
|
return false;
|
|
786
822
|
}
|
|
@@ -809,7 +845,7 @@ async function labels(project) {
|
|
|
809
845
|
const proc = Bun.spawn(["docker", "ps", "--all", ...filter, "--format", LABEL_FORMAT], { stdout: "pipe", stderr: "ignore" });
|
|
810
846
|
if (await proc.exited !== 0)
|
|
811
847
|
return [];
|
|
812
|
-
return labelled(await new Response(proc.stdout).text()).filter((stack) => [...stack.files, ...stack.envFiles].every((file) =>
|
|
848
|
+
return labelled(await new Response(proc.stdout).text()).filter((stack) => [...stack.files, ...stack.envFiles].every((file) => existsSync2(file)));
|
|
813
849
|
}
|
|
814
850
|
function labelledStack(dir, { project, files, envFiles, workdir }) {
|
|
815
851
|
const options = (paths) => ["-p", project, ...files.flatMap((file) => ["-f", paths(file)]), ...envFiles.flatMap((file) => ["--env-file", paths(file)])];
|
|
@@ -890,26 +926,92 @@ async function commandFor(service, action) {
|
|
|
890
926
|
function defaultAction(service) {
|
|
891
927
|
return service.state === "running" ? "shell" : "up";
|
|
892
928
|
}
|
|
929
|
+
function stateColor(state) {
|
|
930
|
+
if (state === "running")
|
|
931
|
+
return "green";
|
|
932
|
+
if (state === "exited" || state === "dead")
|
|
933
|
+
return "red";
|
|
934
|
+
return state === "none" ? "dim" : "yellow";
|
|
935
|
+
}
|
|
936
|
+
async function serviceNamed(dir, name, project) {
|
|
937
|
+
const [recorded] = project === "" ? [] : await labels(project);
|
|
938
|
+
const stack = recorded === undefined ? stackAt(dir) : labelledStack(dir, recorded);
|
|
939
|
+
return (await services(stack)).find((candidate) => candidate.name === name);
|
|
940
|
+
}
|
|
941
|
+
async function logsOf(stack, name) {
|
|
942
|
+
return output(stack, ["logs", "--tail=30", "--no-log-prefix", name]);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// src/picker.ts
|
|
946
|
+
var KEYS = { "ctrl-l": "logs", "ctrl-r": "restart", "ctrl-x": "stop", "ctrl-b": "build" };
|
|
947
|
+
var KEY_LABELS = { "ctrl-l": "Logs", "ctrl-r": "Restart", "ctrl-x": "Stop", "ctrl-b": "Rebuild" };
|
|
948
|
+
function row(service) {
|
|
949
|
+
const colors = paint(process9.stderr);
|
|
950
|
+
const dir = service.stack.dir;
|
|
951
|
+
const project = service.stack.project;
|
|
952
|
+
const name = project === undefined ? service.name : `${colors.dim(`${project}:`)}${service.name}`;
|
|
953
|
+
return {
|
|
954
|
+
hidden: [dir, service.name, key(service), project ?? ""],
|
|
955
|
+
cells: [dir === "." ? name : `${colors.dim(`${dir}/`)}${name}`, colors[stateColor(service.state)](service.state), colors.cyan(service.ports), colors.dim(service.image)]
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function table(rows) {
|
|
959
|
+
const widths = rows[0]?.cells.map((_, index) => Math.max(...rows.map((each) => Bun.stringWidth(each.cells[index])))) ?? [];
|
|
960
|
+
return rows.map((each) => [
|
|
961
|
+
...each.hidden,
|
|
962
|
+
...each.cells.map((cell, index) => index === each.cells.length - 1 ? cell.trimEnd() : `${cell}${" ".repeat(widths[index] + 1 - Bun.stringWidth(cell))}`)
|
|
963
|
+
].join("\t")).join(`
|
|
964
|
+
`);
|
|
965
|
+
}
|
|
966
|
+
function pickService(services) {
|
|
967
|
+
const proc = Bun.spawnSync([
|
|
968
|
+
"fzf",
|
|
969
|
+
"--prompt",
|
|
970
|
+
"dx> ",
|
|
971
|
+
"--header",
|
|
972
|
+
"enter shell or up, ctrl-l logs, ctrl-r restart, ctrl-x stop, ctrl-b rebuild",
|
|
973
|
+
"--expect",
|
|
974
|
+
Object.keys(KEYS).join(","),
|
|
975
|
+
"--ansi",
|
|
976
|
+
"--reverse",
|
|
977
|
+
"--height",
|
|
978
|
+
String(Math.max(15, Math.ceil((services.length + 4) / 0.6))),
|
|
979
|
+
"--delimiter",
|
|
980
|
+
"\t",
|
|
981
|
+
"--with-nth",
|
|
982
|
+
"5..",
|
|
983
|
+
"--nth",
|
|
984
|
+
"1",
|
|
985
|
+
"--tabstop",
|
|
986
|
+
"1",
|
|
987
|
+
"--tiebreak",
|
|
988
|
+
"chunk,index",
|
|
989
|
+
"--preview",
|
|
990
|
+
`${process9.execPath} ${process9.argv[1]} --show {1} {2} {4}`,
|
|
991
|
+
"--preview-window",
|
|
992
|
+
"down,40%"
|
|
993
|
+
], { stdin: new TextEncoder().encode(table(services.map(row))), stdout: "pipe", stderr: "inherit" });
|
|
994
|
+
const [pressed = "", line] = proc.stdout.toString().split(`
|
|
995
|
+
`);
|
|
996
|
+
const keyed = line?.split("\t")[2];
|
|
997
|
+
const service = services.find((candidate) => key(candidate) === keyed);
|
|
998
|
+
return service === undefined ? undefined : { service, key: pressed };
|
|
999
|
+
}
|
|
893
1000
|
|
|
894
1001
|
// src/dx.ts
|
|
895
1002
|
var usage2 = `dx [service] [action]
|
|
1003
|
+
dx -
|
|
896
1004
|
|
|
897
|
-
Without a service,
|
|
898
|
-
|
|
1005
|
+
Without a service, picks one with fzf when it is installed and a terminal is attached: enter opens its shell (or brings a stopped service up), ctrl-l follows its logs, ctrl-r restarts, ctrl-x stops and ctrl-b rebuilds it. The services last acted on come first, per folder. Without fzf or a terminal it lists them instead.
|
|
1006
|
+
\`dx -\` does the last action again, \`dx <service> [action]\` acts without the picker: \`shell\` (bash when the container has it), \`logs\` (follows the last 200 lines), \`restart\`, \`up\`, \`stop\` or \`build\` (up with a rebuild).
|
|
1007
|
+
The services are the stack of this folder or its nearest parent, the ones of the child folders (keyed \`api/php\`), and stacks found only through their containers (keyed \`<project>:php\`).
|
|
899
1008
|
|
|
900
1009
|
Exits with the code of the command it ran, 1 when there is no compose service here or none by that name.`;
|
|
901
1010
|
var options = {
|
|
902
1011
|
json: jsonOption("[{ key, project, name, state, status, ports, image }]")
|
|
903
1012
|
};
|
|
904
|
-
function
|
|
905
|
-
|
|
906
|
-
return "green";
|
|
907
|
-
if (state === "exited" || state === "dead")
|
|
908
|
-
return "red";
|
|
909
|
-
return state === "none" ? "dim" : "yellow";
|
|
910
|
-
}
|
|
911
|
-
function table(services) {
|
|
912
|
-
const colors = paint(process8.stdout);
|
|
1013
|
+
function table2(services) {
|
|
1014
|
+
const colors = paint(process10.stdout);
|
|
913
1015
|
const keys = services.map(key);
|
|
914
1016
|
const width = Math.max(...keys.map((name) => name.length));
|
|
915
1017
|
const states = Math.max(...services.map((service) => service.state.length));
|
|
@@ -928,25 +1030,94 @@ function find(services, named) {
|
|
|
928
1030
|
const nearest = closest(named, services.map(key));
|
|
929
1031
|
return fail(`no service named ${named}${nearest === undefined ? "" : `, did you mean ${nearest}?`}`, 1);
|
|
930
1032
|
}
|
|
1033
|
+
function listed2(services) {
|
|
1034
|
+
return byRecency(services, runs(), key).map((service) => ({
|
|
1035
|
+
label: key(service),
|
|
1036
|
+
hint: [service.state, service.ports, service.image].filter(Boolean).join(" \xB7 "),
|
|
1037
|
+
typed: key(service),
|
|
1038
|
+
preview: ["--show", service.stack.dir, service.name, service.stack.project ?? ""],
|
|
1039
|
+
actions: Object.entries(KEYS).map(([pressed, action]) => ({ label: KEY_LABELS[pressed] ?? action, key: pressed, typed: `${key(service)} ${action}` }))
|
|
1040
|
+
}));
|
|
1041
|
+
}
|
|
1042
|
+
async function hidden(flag, rest, services) {
|
|
1043
|
+
if (flag === "--list") {
|
|
1044
|
+
console.log(JSON.stringify(listed2(services)));
|
|
1045
|
+
return 0;
|
|
1046
|
+
}
|
|
1047
|
+
if (flag === "--names") {
|
|
1048
|
+
console.log(services.map((service) => `${key(service)} ${service.state}`).join(`
|
|
1049
|
+
`));
|
|
1050
|
+
return 0;
|
|
1051
|
+
}
|
|
1052
|
+
if (flag === "--show") {
|
|
1053
|
+
const [dir = ".", name = "", project = ""] = rest;
|
|
1054
|
+
const service = await serviceNamed(dir, name, project);
|
|
1055
|
+
if (service === undefined)
|
|
1056
|
+
return 0;
|
|
1057
|
+
const colors = paint(process10.stdout);
|
|
1058
|
+
console.log(`${colors[stateColor(service.state)](service.status || service.state)} ${colors.dim(service.image)}${service.ports ? ` ${colors.cyan(service.ports)}` : ""}
|
|
1059
|
+
`);
|
|
1060
|
+
console.log(await logsOf(service.stack, name));
|
|
1061
|
+
return 0;
|
|
1062
|
+
}
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
931
1065
|
async function dx(args) {
|
|
932
|
-
const
|
|
1066
|
+
const [flag = ""] = args;
|
|
1067
|
+
if (flag === "--show" || flag === "--list" || flag === "--names") {
|
|
1068
|
+
const services = flag === "--show" ? [] : await allServices();
|
|
1069
|
+
if (services.length === 0 && flag !== "--show")
|
|
1070
|
+
fail("no compose service here", 1);
|
|
1071
|
+
return await hidden(flag, args.slice(1), services) ?? 0;
|
|
1072
|
+
}
|
|
1073
|
+
const emit = flag === "--emit";
|
|
1074
|
+
const parsed = parse(emit ? args.slice(1) : args, usage2, options, { replaces: "docker compose exec <service> bash, logs -f and restart typed per service", examples: ["dx", ["dx php", "a shell in php, or php brought up when stopped"], ["dx php logs", "follow its logs"], ["dx -", "the last action again"]] });
|
|
933
1075
|
const [named, asked] = parsed.positionals;
|
|
934
1076
|
const services = await allServices();
|
|
935
1077
|
if (services.length === 0)
|
|
936
1078
|
fail("no compose service here", 1);
|
|
1079
|
+
let service;
|
|
1080
|
+
let action;
|
|
937
1081
|
if (named === undefined) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1082
|
+
const interactive = process10.stdin.isTTY && process10.stderr.isTTY && parsed.values.json !== true;
|
|
1083
|
+
if (!interactive || Bun.which("fzf") === null) {
|
|
1084
|
+
if (emit)
|
|
1085
|
+
fail("the picker needs a terminal and fzf", 1);
|
|
1086
|
+
if (interactive)
|
|
1087
|
+
console.error("fzf is not installed, listing the services instead");
|
|
1088
|
+
if (parsed.values.json === true)
|
|
1089
|
+
printJson(services.map((each) => ({ key: key(each), project: each.project, name: each.name, state: each.state, status: each.status, ports: each.ports, image: each.image })));
|
|
1090
|
+
else
|
|
1091
|
+
console.log(table2(services));
|
|
1092
|
+
return 0;
|
|
1093
|
+
}
|
|
1094
|
+
const picked = pickService(byRecency(services, runs(), key));
|
|
1095
|
+
if (picked === undefined)
|
|
1096
|
+
return 1;
|
|
1097
|
+
service = picked.service;
|
|
1098
|
+
action = KEYS[picked.key];
|
|
1099
|
+
} else if (named === "-") {
|
|
1100
|
+
const last = runs()[0];
|
|
1101
|
+
service = last && services.find((candidate) => key(candidate) === last.id);
|
|
1102
|
+
if (service === undefined)
|
|
1103
|
+
fail(last ? `${last.id} is gone` : "nothing done here yet", 1);
|
|
1104
|
+
action = last.args[0];
|
|
1105
|
+
} else {
|
|
1106
|
+
service = find(services, named);
|
|
1107
|
+
if (asked !== undefined && !ACTIONS.includes(asked))
|
|
1108
|
+
fail(`no action ${asked}, one of ${ACTIONS.join(", ")}`);
|
|
1109
|
+
action = asked;
|
|
1110
|
+
}
|
|
1111
|
+
const chosen = action ?? defaultAction(service);
|
|
1112
|
+
const command = await commandFor(service, chosen);
|
|
1113
|
+
record({ id: key(service), args: [chosen] });
|
|
1114
|
+
if (emit) {
|
|
1115
|
+
console.log(["run", action === undefined ? key(service) : `${key(service)} ${action}`, "", ...command.map((word) => `=${word}`)].join(`
|
|
1116
|
+
`));
|
|
942
1117
|
return 0;
|
|
943
1118
|
}
|
|
944
|
-
const service = find(services, named);
|
|
945
|
-
if (asked !== undefined && !ACTIONS.includes(asked))
|
|
946
|
-
fail(`no action ${asked}, one of ${ACTIONS.join(", ")}`);
|
|
947
|
-
const command = await commandFor(service, asked ?? defaultAction(service));
|
|
948
1119
|
return Bun.spawn(command, { stdio: ["inherit", "inherit", "inherit"] }).exited;
|
|
949
1120
|
}
|
|
950
1121
|
|
|
951
1122
|
// src/dx-cli.ts
|
|
952
|
-
|
|
1123
|
+
process11.exitCode = await runMain({ name: "dx", version, run: dx });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/cli-docker",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "dc and dx: docker compose on the stack found here, including one only its containers tell of, and a compose service's shell, logs, restart, stop or rebuild.",
|
|
6
6
|
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
package/src/dx.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { Action, Service } from './stack'
|
|
2
2
|
import process from 'node:process'
|
|
3
3
|
import { closest, fail, jsonOption, paint, parse, printJson } from '@kaynooo/cli-kit'
|
|
4
|
-
import {
|
|
4
|
+
import { byRecency, record, runs } from './history'
|
|
5
|
+
import { KEY_LABELS, KEYS, pickService } from './picker'
|
|
6
|
+
import { ACTIONS, allServices, commandFor, defaultAction, key, logsOf, serviceNamed, stateColor } from './stack'
|
|
5
7
|
|
|
6
8
|
const usage = `dx [service] [action]
|
|
9
|
+
dx -
|
|
7
10
|
|
|
8
|
-
Without a service,
|
|
9
|
-
|
|
11
|
+
Without a service, picks one with fzf when it is installed and a terminal is attached: enter opens its shell (or brings a stopped service up), ctrl-l follows its logs, ctrl-r restarts, ctrl-x stops and ctrl-b rebuilds it. The services last acted on come first, per folder. Without fzf or a terminal it lists them instead.
|
|
12
|
+
\`dx -\` does the last action again, \`dx <service> [action]\` acts without the picker: \`shell\` (bash when the container has it), \`logs\` (follows the last 200 lines), \`restart\`, \`up\`, \`stop\` or \`build\` (up with a rebuild).
|
|
13
|
+
The services are the stack of this folder or its nearest parent, the ones of the child folders (keyed \`api/php\`), and stacks found only through their containers (keyed \`<project>:php\`).
|
|
10
14
|
|
|
11
15
|
Exits with the code of the command it ran, 1 when there is no compose service here or none by that name.`
|
|
12
16
|
|
|
@@ -14,17 +18,6 @@ const options = {
|
|
|
14
18
|
json: jsonOption('[{ key, project, name, state, status, ports, image }]'),
|
|
15
19
|
} as const
|
|
16
20
|
|
|
17
|
-
// Up in green, down in red, never created dimmed, anything on its way, restarting or paused, in yellow.
|
|
18
|
-
function stateColor(state: string): 'green' | 'red' | 'dim' | 'yellow' {
|
|
19
|
-
if (state === 'running')
|
|
20
|
-
return 'green'
|
|
21
|
-
|
|
22
|
-
if (state === 'exited' || state === 'dead')
|
|
23
|
-
return 'red'
|
|
24
|
-
|
|
25
|
-
return state === 'none' ? 'dim' : 'yellow'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
21
|
function table(services: Service[]): string {
|
|
29
22
|
const colors = paint(process.stdout)
|
|
30
23
|
const keys = services.map(key)
|
|
@@ -51,29 +44,129 @@ function find(services: Service[], named: string): Service {
|
|
|
51
44
|
return fail(`no service named ${named}${nearest === undefined ? '' : `, did you mean ${nearest}?`}`, 1)
|
|
52
45
|
}
|
|
53
46
|
|
|
47
|
+
// The rows term lists in place of the picker: what it types back for enter and for each key.
|
|
48
|
+
function listed(services: Service[]): unknown[] {
|
|
49
|
+
return byRecency(services, runs(), key).map(service => ({
|
|
50
|
+
label: key(service),
|
|
51
|
+
hint: [service.state, service.ports, service.image].filter(Boolean).join(' · '),
|
|
52
|
+
typed: key(service),
|
|
53
|
+
preview: ['--show', service.stack.dir, service.name, service.stack.project ?? ''],
|
|
54
|
+
actions: Object.entries(KEYS).map(([pressed, action]) => ({ label: KEY_LABELS[pressed] ?? action, key: pressed, typed: `${key(service)} ${action}` })),
|
|
55
|
+
}))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The hidden flags term and the shell completions call, each printing and nothing more.
|
|
59
|
+
async function hidden(flag: string, rest: string[], services: Service[]): Promise<number | undefined> {
|
|
60
|
+
if (flag === '--list') {
|
|
61
|
+
console.log(JSON.stringify(listed(services)))
|
|
62
|
+
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// With the state as the description fish shows beside each name.
|
|
67
|
+
if (flag === '--names') {
|
|
68
|
+
console.log(services.map(service => `${key(service)}\t${service.state}`).join('\n'))
|
|
69
|
+
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (flag === '--show') {
|
|
74
|
+
const [dir = '.', name = '', project = ''] = rest
|
|
75
|
+
const service = await serviceNamed(dir, name, project)
|
|
76
|
+
|
|
77
|
+
if (service === undefined)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
const colors = paint(process.stdout)
|
|
81
|
+
|
|
82
|
+
console.log(`${colors[stateColor(service.state)](service.status || service.state)} ${colors.dim(service.image)}${service.ports ? ` ${colors.cyan(service.ports)}` : ''}\n`)
|
|
83
|
+
console.log(await logsOf(service.stack, name))
|
|
84
|
+
|
|
85
|
+
return 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
export async function dx(args: string[]): Promise<number> {
|
|
55
|
-
const
|
|
92
|
+
const [flag = ''] = args
|
|
93
|
+
|
|
94
|
+
if (flag === '--show' || flag === '--list' || flag === '--names') {
|
|
95
|
+
const services = flag === '--show' ? [] : await allServices()
|
|
96
|
+
|
|
97
|
+
if (services.length === 0 && flag !== '--show')
|
|
98
|
+
fail('no compose service here', 1)
|
|
99
|
+
|
|
100
|
+
return await hidden(flag, args.slice(1), services) ?? 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The protocol the tmux picker reads instead of running the command: its mode, the words typed back, and the command's words.
|
|
104
|
+
const emit = flag === '--emit'
|
|
105
|
+
const parsed = parse(emit ? args.slice(1) : args, usage, options, { replaces: 'docker compose exec <service> bash, logs -f and restart typed per service', examples: ['dx', ['dx php', 'a shell in php, or php brought up when stopped'], ['dx php logs', 'follow its logs'], ['dx -', 'the last action again']] })
|
|
56
106
|
const [named, asked] = parsed.positionals
|
|
57
107
|
const services = await allServices()
|
|
58
108
|
|
|
59
109
|
if (services.length === 0)
|
|
60
110
|
fail('no compose service here', 1)
|
|
61
111
|
|
|
112
|
+
let service: Service | undefined
|
|
113
|
+
let action: Action | undefined
|
|
114
|
+
|
|
62
115
|
if (named === undefined) {
|
|
63
|
-
|
|
64
|
-
printJson(services.map(service => ({ key: key(service), project: service.project, name: service.name, state: service.state, status: service.status, ports: service.ports, image: service.image })))
|
|
65
|
-
else
|
|
66
|
-
console.log(table(services))
|
|
116
|
+
const interactive = process.stdin.isTTY && process.stderr.isTTY && parsed.values.json !== true
|
|
67
117
|
|
|
68
|
-
|
|
118
|
+
if (!interactive || Bun.which('fzf') === null) {
|
|
119
|
+
if (emit)
|
|
120
|
+
fail('the picker needs a terminal and fzf', 1)
|
|
121
|
+
|
|
122
|
+
if (interactive)
|
|
123
|
+
console.error('fzf is not installed, listing the services instead')
|
|
124
|
+
|
|
125
|
+
if (parsed.values.json === true)
|
|
126
|
+
printJson(services.map(each => ({ key: key(each), project: each.project, name: each.name, state: each.state, status: each.status, ports: each.ports, image: each.image })))
|
|
127
|
+
else
|
|
128
|
+
console.log(table(services))
|
|
129
|
+
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const picked = pickService(byRecency(services, runs(), key))
|
|
134
|
+
|
|
135
|
+
if (picked === undefined)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
service = picked.service
|
|
139
|
+
action = KEYS[picked.key]
|
|
140
|
+
}
|
|
141
|
+
else if (named === '-') {
|
|
142
|
+
const last = runs()[0]
|
|
143
|
+
|
|
144
|
+
service = last && services.find(candidate => key(candidate) === last.id)
|
|
145
|
+
|
|
146
|
+
if (service === undefined)
|
|
147
|
+
fail(last ? `${last.id} is gone` : 'nothing done here yet', 1)
|
|
148
|
+
|
|
149
|
+
action = last!.args[0] as Action | undefined
|
|
69
150
|
}
|
|
151
|
+
else {
|
|
152
|
+
service = find(services, named)
|
|
70
153
|
|
|
71
|
-
|
|
154
|
+
if (asked !== undefined && !ACTIONS.includes(asked as Action))
|
|
155
|
+
fail(`no action ${asked}, one of ${ACTIONS.join(', ')}`)
|
|
72
156
|
|
|
73
|
-
|
|
74
|
-
|
|
157
|
+
action = asked as Action | undefined
|
|
158
|
+
}
|
|
75
159
|
|
|
76
|
-
const
|
|
160
|
+
const chosen = action ?? defaultAction(service)
|
|
161
|
+
const command = await commandFor(service, chosen)
|
|
162
|
+
|
|
163
|
+
record({ id: key(service), args: [chosen] })
|
|
164
|
+
|
|
165
|
+
if (emit) {
|
|
166
|
+
console.log(['run', action === undefined ? key(service) : `${key(service)} ${action}`, '', ...command.map(word => `=${word}`)].join('\n'))
|
|
167
|
+
|
|
168
|
+
return 0
|
|
169
|
+
}
|
|
77
170
|
|
|
78
171
|
return Bun.spawn(command, { stdio: ['inherit', 'inherit', 'inherit'] }).exited
|
|
79
172
|
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
|
|
6
|
+
export interface Run {
|
|
7
|
+
id: string
|
|
8
|
+
args: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REMEMBERED = 20
|
|
12
|
+
|
|
13
|
+
function historyFile(): string {
|
|
14
|
+
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'dx', 'history.json')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Keyed by folder, a broken or missing file reading as nothing run yet.
|
|
18
|
+
function history(): Record<string, Run[]> {
|
|
19
|
+
try {
|
|
20
|
+
const file = historyFile()
|
|
21
|
+
|
|
22
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf-8')) as Record<string, Run[]> : {}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return {}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function runs(): Run[] {
|
|
30
|
+
return history()[process.cwd()] ?? []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function record(run: Run): void {
|
|
34
|
+
const all = history()
|
|
35
|
+
const file = historyFile()
|
|
36
|
+
|
|
37
|
+
all[process.cwd()] = [run, ...(all[process.cwd()] ?? []).filter(earlier => earlier.id !== run.id)].slice(0, REMEMBERED)
|
|
38
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
39
|
+
writeFileSync(file, JSON.stringify(all))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The ones run last come first, most recent at the top, the rest keeping their order.
|
|
43
|
+
export function byRecency<T>(items: T[], previous: Run[], idOf: (item: T) => string): T[] {
|
|
44
|
+
const rank = new Map(previous.map((run, index) => [run.id, index]))
|
|
45
|
+
const recent = items.filter(item => rank.has(idOf(item))).sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!)
|
|
46
|
+
|
|
47
|
+
return [...recent, ...items.filter(item => !rank.has(idOf(item)))]
|
|
48
|
+
}
|
package/src/picker.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Action, Service } from './stack'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
import { paint } from '@kaynooo/cli-kit'
|
|
4
|
+
import { key, stateColor } from './stack'
|
|
5
|
+
|
|
6
|
+
export const KEYS: Record<string, Action> = { 'ctrl-l': 'logs', 'ctrl-r': 'restart', 'ctrl-x': 'stop', 'ctrl-b': 'build' }
|
|
7
|
+
export const KEY_LABELS: Record<string, string> = { 'ctrl-l': 'Logs', 'ctrl-r': 'Restart', 'ctrl-x': 'Stop', 'ctrl-b': 'Rebuild' }
|
|
8
|
+
|
|
9
|
+
// The fields fzf hands back after the visible ones, tab separated: folder, name, key, compose project.
|
|
10
|
+
function row(service: Service): { hidden: string[], cells: string[] } {
|
|
11
|
+
const colors = paint(process.stderr)
|
|
12
|
+
const dir = service.stack.dir
|
|
13
|
+
const project = service.stack.project
|
|
14
|
+
const name = project === undefined ? service.name : `${colors.dim(`${project}:`)}${service.name}`
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
hidden: [dir, service.name, key(service), project ?? ''],
|
|
18
|
+
cells: [dir === '.' ? name : `${colors.dim(`${dir}/`)}${name}`, colors[stateColor(service.state)](service.state), colors.cyan(service.ports), colors.dim(service.image)],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Every cell but the last padded to its column, one space short of the two between columns since a tab renders as one.
|
|
23
|
+
function table(rows: { hidden: string[], cells: string[] }[]): string {
|
|
24
|
+
const widths = rows[0]?.cells.map((_, index) => Math.max(...rows.map(each => Bun.stringWidth(each.cells[index]!)))) ?? []
|
|
25
|
+
|
|
26
|
+
return rows.map(each => [
|
|
27
|
+
...each.hidden,
|
|
28
|
+
...each.cells.map((cell, index) => (index === each.cells.length - 1 ? cell.trimEnd() : `${cell}${' '.repeat(widths[index]! + 1 - Bun.stringWidth(cell))}`)),
|
|
29
|
+
].join('\t')).join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The service picked with fzf and the key that picked it, enter's being empty; undefined on escape.
|
|
33
|
+
export function pickService(services: Service[]): { service: Service, key: string } | undefined {
|
|
34
|
+
const proc = Bun.spawnSync([
|
|
35
|
+
'fzf',
|
|
36
|
+
'--prompt',
|
|
37
|
+
'dx> ',
|
|
38
|
+
'--header',
|
|
39
|
+
'enter shell or up, ctrl-l logs, ctrl-r restart, ctrl-x stop, ctrl-b rebuild',
|
|
40
|
+
'--expect',
|
|
41
|
+
Object.keys(KEYS).join(','),
|
|
42
|
+
'--ansi',
|
|
43
|
+
'--reverse',
|
|
44
|
+
'--height',
|
|
45
|
+
String(Math.max(15, Math.ceil((services.length + 4) / 0.6))),
|
|
46
|
+
'--delimiter',
|
|
47
|
+
'\t',
|
|
48
|
+
'--with-nth',
|
|
49
|
+
'5..',
|
|
50
|
+
'--nth',
|
|
51
|
+
'1',
|
|
52
|
+
'--tabstop',
|
|
53
|
+
'1',
|
|
54
|
+
'--tiebreak',
|
|
55
|
+
'chunk,index',
|
|
56
|
+
'--preview',
|
|
57
|
+
`${process.execPath} ${process.argv[1]} --show {1} {2} {4}`,
|
|
58
|
+
'--preview-window',
|
|
59
|
+
'down,40%',
|
|
60
|
+
], { stdin: new TextEncoder().encode(table(services.map(row))), stdout: 'pipe', stderr: 'inherit' })
|
|
61
|
+
|
|
62
|
+
const [pressed = '', line] = proc.stdout.toString().split('\n')
|
|
63
|
+
const keyed = line?.split('\t')[2]
|
|
64
|
+
const service = services.find(candidate => key(candidate) === keyed)
|
|
65
|
+
|
|
66
|
+
return service === undefined ? undefined : { service, key: pressed }
|
|
67
|
+
}
|
package/src/stack.ts
CHANGED
|
@@ -239,3 +239,27 @@ export async function commandFor(service: Service, action: Action): Promise<stri
|
|
|
239
239
|
export function defaultAction(service: Service): Action {
|
|
240
240
|
return service.state === 'running' ? 'shell' : 'up'
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// Up in green, down in red, never created dimmed, anything on its way, restarting or paused, in yellow.
|
|
244
|
+
export function stateColor(state: string): 'green' | 'red' | 'dim' | 'yellow' {
|
|
245
|
+
if (state === 'running')
|
|
246
|
+
return 'green'
|
|
247
|
+
|
|
248
|
+
if (state === 'exited' || state === 'dead')
|
|
249
|
+
return 'red'
|
|
250
|
+
|
|
251
|
+
return state === 'none' ? 'dim' : 'yellow'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A service looked up again from the folder, project and name the picker's preview was given.
|
|
255
|
+
export async function serviceNamed(dir: string, name: string, project: string): Promise<Service | undefined> {
|
|
256
|
+
const [recorded] = project === '' ? [] : await labels(project)
|
|
257
|
+
const stack = recorded === undefined ? stackAt(dir) : labelledStack(dir, recorded)
|
|
258
|
+
|
|
259
|
+
return (await services(stack)).find(candidate => candidate.name === name)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Compose's own words for its output of one service, the last lines of its logs.
|
|
263
|
+
export async function logsOf(stack: Stack, name: string): Promise<string> {
|
|
264
|
+
return output(stack, ['logs', '--tail=30', '--no-log-prefix', name])
|
|
265
|
+
}
|