@termix-ssh/plugin-sdk 1.0.20 → 1.0.22
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/CHANGELOG.md +29 -0
- package/PLUGIN-GUIDE.md +66 -4
- package/cli/commands/build.mjs +55 -36
- package/cli/commands/validate.mjs +4 -0
- package/dist/backend.d.ts +32 -7
- package/dist/frontend.d.ts +7 -0
- package/dist/host/{PluginsTab-ZSMTJ5C5.js → PluginsTab-CM2B33BD.js} +26 -16
- package/dist/host/chunk-7J4QNGFM.js +345 -0
- package/dist/host/{chunk-J3ZQPBDK.js → chunk-HQ6NNFSX.js} +115 -1575
- package/dist/host/chunk-I5F2PSMR.js +1526 -0
- package/dist/host/{chunk-2MU6WNSX.js → chunk-JRV2HIDK.js} +1 -1
- package/dist/host/{chunk-7LEJ3HYF.js → chunk-P26QKKU5.js} +41 -10
- package/dist/host/{chunk-CITUENSU.js → chunk-ROB2SH5Q.js} +77 -49
- package/dist/host/chunk-TK3Y437H.js +53 -0
- package/dist/host/{chunk-EYONBBRJ.js → chunk-VKNGNEF3.js} +34650 -34968
- package/dist/host/core-classes.json +1 -1
- package/dist/host/frame-host.css +2 -0
- package/dist/host/frame-host.js +242 -0
- package/dist/host/{linked-server-OUF3POT7.js → linked-server-J5G457OA.js} +1 -1
- package/dist/host/{rbac-api-LZVENREG.js → rbac-api-PY22W5UY.js} +5 -1
- package/dist/host/testing-host.js +9 -6
- package/dist/host/types/src/types/host-sidebar-preferences.d.ts +2 -0
- package/dist/host/types/src/types/index.d.ts +8 -0
- package/dist/host/types/src/ui/api/look-api.d.ts +2 -0
- package/dist/host/types/src/ui/api/plugins-api.d.ts +3 -2
- package/dist/host/types/src/ui/api/rbac-api.d.ts +19 -1
- package/dist/host/types/src/ui/components/data-view.d.ts +1 -1
- package/dist/host/types/src/ui/lib/app-return.d.ts +13 -0
- package/dist/host/types/src/ui/main-axios.d.ts +1 -1
- package/dist/host/types/src/ui/plugin-host/loader.d.ts +6 -0
- package/dist/host/types/src/ui/plugin-host/sdk-ui.d.ts +1 -1
- package/dist/host/types/src/ui/plugins/plugin-model.d.ts +1 -1
- package/dist/host/types/src/ui/plugins/use-plugins-manager.d.ts +1 -1
- package/dist/host/types/src/ui/shell/tab-registry.d.ts +2 -0
- package/dist/host/ui.js +418 -388
- package/dist/host/{use-permissions-BYCDS5PD.js → use-permissions-EWRI3WLT.js} +1 -1
- package/dist/host-commands.d.ts +9 -6
- package/dist/host-commands.js +38 -11
- package/dist/host-scripts/docker.d.ts +137 -0
- package/dist/host-scripts/docker.js +345 -0
- package/dist/host-scripts/index.d.ts +15 -0
- package/dist/host-scripts/index.js +15 -0
- package/dist/host-scripts/journal.d.ts +45 -0
- package/dist/host-scripts/journal.js +194 -0
- package/dist/host-scripts/metrics.d.ts +134 -0
- package/dist/host-scripts/metrics.js +320 -0
- package/dist/host-scripts/openrc.d.ts +6 -0
- package/dist/host-scripts/openrc.js +90 -0
- package/dist/host-scripts/shell.d.ts +43 -0
- package/dist/host-scripts/shell.js +95 -0
- package/dist/host-scripts/systemd.d.ts +73 -0
- package/dist/host-scripts/systemd.js +320 -0
- package/dist/host-scripts/text.d.ts +23 -0
- package/dist/host-scripts/text.js +111 -0
- package/dist/manifest.d.ts +28 -1
- package/dist/manifest.js +108 -1
- package/package.json +9 -1
- package/schema/manifest.schema.json +42 -0
package/dist/host-commands.d.ts
CHANGED
|
@@ -10,11 +10,20 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export type { Client, ClientChannel } from "ssh2";
|
|
12
12
|
import type { Client } from "ssh2";
|
|
13
|
+
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
|
|
14
|
+
export { EXTRA_PATH_DIRS };
|
|
13
15
|
export interface ExecResult {
|
|
14
16
|
stdout: string;
|
|
15
17
|
stderr: string;
|
|
16
18
|
code: number | null;
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
|
|
22
|
+
* which is what a Chinese-locale Windows console sends (its bytes are almost
|
|
23
|
+
* never valid UTF-8) and keeps it only when it decodes without replacement
|
|
24
|
+
* characters. Anything else falls back to lossy UTF-8, the old behavior.
|
|
25
|
+
*/
|
|
26
|
+
export declare function decodeCommandOutput(buf: Buffer): string;
|
|
18
27
|
/** Runs one non-interactive command over an existing ssh2 client. */
|
|
19
28
|
export declare function execCommand(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
20
29
|
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
|
@@ -26,12 +35,6 @@ export interface PlatformInfo {
|
|
|
26
35
|
hasDocker: boolean;
|
|
27
36
|
osPrettyName: string | null;
|
|
28
37
|
}
|
|
29
|
-
/**
|
|
30
|
-
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
31
|
-
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
32
|
-
* Extend PATH with their common install locations before probing.
|
|
33
|
-
*/
|
|
34
|
-
export declare const EXTRA_PATH_DIRS = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:\"$HOME/.orbstack/bin\"";
|
|
35
38
|
/**
|
|
36
39
|
* Single probe that reports which tooling is available. Each line is
|
|
37
40
|
* "key=value" so the parser is trivial and order-independent.
|
package/dist/host-commands.js
CHANGED
|
@@ -8,6 +8,33 @@
|
|
|
8
8
|
*
|
|
9
9
|
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
|
|
10
10
|
*/
|
|
11
|
+
// Homebrew and OrbStack paths a non-interactive shell misses.
|
|
12
|
+
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
|
|
13
|
+
export { EXTRA_PATH_DIRS };
|
|
14
|
+
/**
|
|
15
|
+
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
|
|
16
|
+
* which is what a Chinese-locale Windows console sends (its bytes are almost
|
|
17
|
+
* never valid UTF-8) and keeps it only when it decodes without replacement
|
|
18
|
+
* characters. Anything else falls back to lossy UTF-8, the old behavior.
|
|
19
|
+
*/
|
|
20
|
+
export function decodeCommandOutput(buf) {
|
|
21
|
+
try {
|
|
22
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buf);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// not valid UTF-8, try the legacy codepage
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
// A Node built with small-ICU has no GBK decoder and throws here.
|
|
29
|
+
const gbk = new TextDecoder("gbk").decode(buf);
|
|
30
|
+
if (!gbk.includes("�"))
|
|
31
|
+
return gbk;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// expected - fall through to lossy UTF-8
|
|
35
|
+
}
|
|
36
|
+
return buf.toString("utf8");
|
|
37
|
+
}
|
|
11
38
|
/** Runs one non-interactive command over an existing ssh2 client. */
|
|
12
39
|
export function execCommand(client, command, timeoutMs = 30000) {
|
|
13
40
|
return new Promise((resolve, reject) => {
|
|
@@ -45,8 +72,10 @@ export function execCommand(client, command, timeoutMs = 30000) {
|
|
|
45
72
|
return;
|
|
46
73
|
}
|
|
47
74
|
stream = _stream;
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
// Chunks are buffered and decoded once on close: a multi-byte character
|
|
76
|
+
// can be split across chunks, and charset detection needs it whole.
|
|
77
|
+
const stdout = [];
|
|
78
|
+
const stderr = [];
|
|
50
79
|
let exitCode = null;
|
|
51
80
|
stream
|
|
52
81
|
.on("close", (code) => {
|
|
@@ -54,11 +83,15 @@ export function execCommand(client, command, timeoutMs = 30000) {
|
|
|
54
83
|
settled = true;
|
|
55
84
|
exitCode = typeof code === "number" ? code : null;
|
|
56
85
|
cleanup();
|
|
57
|
-
resolve({
|
|
86
|
+
resolve({
|
|
87
|
+
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
|
88
|
+
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
|
89
|
+
code: exitCode,
|
|
90
|
+
});
|
|
58
91
|
}
|
|
59
92
|
})
|
|
60
93
|
.on("data", (data) => {
|
|
61
|
-
stdout
|
|
94
|
+
stdout.push(data);
|
|
62
95
|
})
|
|
63
96
|
.on("error", (streamErr) => {
|
|
64
97
|
if (!settled) {
|
|
@@ -70,7 +103,7 @@ export function execCommand(client, command, timeoutMs = 30000) {
|
|
|
70
103
|
if (stream.stderr) {
|
|
71
104
|
stream.stderr
|
|
72
105
|
.on("data", (data) => {
|
|
73
|
-
stderr
|
|
106
|
+
stderr.push(data);
|
|
74
107
|
})
|
|
75
108
|
.on("error", (stderrErr) => {
|
|
76
109
|
if (!settled) {
|
|
@@ -83,12 +116,6 @@ export function execCommand(client, command, timeoutMs = 30000) {
|
|
|
83
116
|
});
|
|
84
117
|
});
|
|
85
118
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
88
|
-
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
89
|
-
* Extend PATH with their common install locations before probing.
|
|
90
|
-
*/
|
|
91
|
-
export const EXTRA_PATH_DIRS = '/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
|
92
119
|
const DOCKER_PROBE = `command -v docker >/dev/null 2>&1` +
|
|
93
120
|
` || [ -S /var/run/docker.sock ]` +
|
|
94
121
|
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type ContainerRuntime = "docker" | "podman";
|
|
2
|
+
export declare function normalizeContainerRuntime(value: unknown): ContainerRuntime;
|
|
3
|
+
export declare function getRuntimeLabel(runtime: ContainerRuntime): string;
|
|
4
|
+
/**
|
|
5
|
+
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
6
|
+
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
7
|
+
*/
|
|
8
|
+
export declare const EXTRA_PATH_DIRS = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:\"$HOME/.orbstack/bin\"";
|
|
9
|
+
export declare function containerCommand(runtime: ContainerRuntime | undefined, args: string, isWindows?: boolean): string;
|
|
10
|
+
export declare const CONTAINER_ID_RE: RegExp;
|
|
11
|
+
export declare const DOCKER_TIMESTAMP_RE: RegExp;
|
|
12
|
+
export declare function isContainerId(value: unknown): value is string;
|
|
13
|
+
export declare const CONTAINER_ACTIONS: readonly ["start", "stop", "restart", "pause", "unpause"];
|
|
14
|
+
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
|
15
|
+
export declare function listFormat(isWindows: boolean): string;
|
|
16
|
+
export declare function statsFormat(isWindows: boolean): string;
|
|
17
|
+
/** One row of `ps --format listFormat()`. */
|
|
18
|
+
export interface ContainerRow {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
image: string;
|
|
22
|
+
status: string;
|
|
23
|
+
state: string;
|
|
24
|
+
ports: string;
|
|
25
|
+
created: string;
|
|
26
|
+
}
|
|
27
|
+
/** CLI arguments, without the runtime, for each container command. */
|
|
28
|
+
export declare const dockerArgs: {
|
|
29
|
+
version: () => string;
|
|
30
|
+
ping: () => string;
|
|
31
|
+
list: (all: boolean, isWindows?: boolean) => string;
|
|
32
|
+
inspect: (id: string) => string;
|
|
33
|
+
stats: (id: string, isWindows?: boolean) => string;
|
|
34
|
+
action: (action: ContainerAction, id: string) => string;
|
|
35
|
+
remove: (id: string, force: boolean) => string;
|
|
36
|
+
logs: (id: string, options?: {
|
|
37
|
+
tail?: number;
|
|
38
|
+
timestamps?: boolean;
|
|
39
|
+
since?: string;
|
|
40
|
+
until?: string;
|
|
41
|
+
}) => string;
|
|
42
|
+
states: () => string;
|
|
43
|
+
composeProjects: () => string;
|
|
44
|
+
};
|
|
45
|
+
/** The version from `docker --version` or `podman --version`. */
|
|
46
|
+
export declare function parseRuntimeVersion(output: string): string;
|
|
47
|
+
export type RuntimeProblem = "NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
|
|
48
|
+
/** Sorts a failed `docker ps` into a reason the UI can explain. */
|
|
49
|
+
export declare function classifyRuntimeError(message: string): RuntimeProblem;
|
|
50
|
+
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
|
51
|
+
export declare function parseContainerList(output: string): Record<string, unknown>[];
|
|
52
|
+
export interface ContainerStats {
|
|
53
|
+
cpu: string;
|
|
54
|
+
memoryUsed: string;
|
|
55
|
+
memoryLimit: string;
|
|
56
|
+
memoryPercent: string;
|
|
57
|
+
netInput: string;
|
|
58
|
+
netOutput: string;
|
|
59
|
+
blockRead: string;
|
|
60
|
+
blockWrite: string;
|
|
61
|
+
pids: string;
|
|
62
|
+
}
|
|
63
|
+
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
|
|
64
|
+
export declare function parseContainerStats(output: string): ContainerStats;
|
|
65
|
+
export interface ContainerState {
|
|
66
|
+
/** Docker's own state word: running, exited, restarting, ... */
|
|
67
|
+
state: string;
|
|
68
|
+
/** Health from the status text, when the image declares a healthcheck. */
|
|
69
|
+
unhealthy: boolean;
|
|
70
|
+
}
|
|
71
|
+
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
|
72
|
+
export declare const PS_STATE_FORMAT = "'{\"name\":\"{{.Names}}\",\"state\":\"{{.State}}\",\"status\":\"{{.Status}}\"}'";
|
|
73
|
+
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
|
74
|
+
export declare function parseContainerStates(output: string): Map<string, ContainerState>;
|
|
75
|
+
/**
|
|
76
|
+
* The events a pair of snapshots implies. A container missing from the
|
|
77
|
+
* previous snapshot counts as newly seen rather than started, so the first
|
|
78
|
+
* poll after a restart does not replay every running container.
|
|
79
|
+
*/
|
|
80
|
+
export declare function diffContainerStates(previous: Map<string, ContainerState>, current: Map<string, ContainerState>): Array<{
|
|
81
|
+
container: string;
|
|
82
|
+
event: DockerEventName;
|
|
83
|
+
}>;
|
|
84
|
+
export interface ContainerCounts {
|
|
85
|
+
running: number;
|
|
86
|
+
stopped: number;
|
|
87
|
+
unhealthy: number;
|
|
88
|
+
total: number;
|
|
89
|
+
}
|
|
90
|
+
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
|
|
91
|
+
export declare function countContainers(output: string): ContainerCounts;
|
|
92
|
+
export declare const STACK_NAME_RE: RegExp;
|
|
93
|
+
export declare const STACK_SERVICE_RE: RegExp;
|
|
94
|
+
export declare const COMPOSE_FILES: string[];
|
|
95
|
+
export declare const STACK_ACTIONS: readonly ["deploy", "redeploy", "pull", "start", "stop", "restart", "pause", "unpause", "down", "destroy"];
|
|
96
|
+
export type StackAction = (typeof STACK_ACTIONS)[number];
|
|
97
|
+
/** Actions that can target one service of a stack. */
|
|
98
|
+
export declare const STACK_SERVICE_ACTIONS: StackAction[];
|
|
99
|
+
export type StackState = "running" | "partial" | "stopped" | "paused" | "not-deployed";
|
|
100
|
+
export interface StackSummary {
|
|
101
|
+
name: string;
|
|
102
|
+
managed: boolean;
|
|
103
|
+
state: StackState;
|
|
104
|
+
running: number;
|
|
105
|
+
total: number;
|
|
106
|
+
composeFile: string | null;
|
|
107
|
+
}
|
|
108
|
+
export interface StackService {
|
|
109
|
+
service: string;
|
|
110
|
+
name: string;
|
|
111
|
+
id: string;
|
|
112
|
+
image: string;
|
|
113
|
+
state: string;
|
|
114
|
+
status: string;
|
|
115
|
+
health: string;
|
|
116
|
+
ports: string;
|
|
117
|
+
}
|
|
118
|
+
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
|
|
119
|
+
export declare function parseJsonLines(output: string): Record<string, unknown>[];
|
|
120
|
+
export declare function parseServices(output: string): StackService[];
|
|
121
|
+
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
|
|
122
|
+
export declare function parseStackStatus(status: string): {
|
|
123
|
+
state: StackState;
|
|
124
|
+
running: number;
|
|
125
|
+
total: number;
|
|
126
|
+
};
|
|
127
|
+
export declare function stackStateFromServices(services: StackService[]): {
|
|
128
|
+
state: StackState;
|
|
129
|
+
running: number;
|
|
130
|
+
total: number;
|
|
131
|
+
};
|
|
132
|
+
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
|
|
133
|
+
export declare function parseComposeProjects(output: string): StackSummary[];
|
|
134
|
+
/** The compose commands one action runs, in order. */
|
|
135
|
+
export declare function composeArgsFor(action: StackAction, service: string | undefined, options: {
|
|
136
|
+
removeVolumes?: boolean;
|
|
137
|
+
}): string[];
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker and Podman command builders and output parsers.
|
|
3
|
+
*
|
|
4
|
+
* Container ids and names are checked with CONTAINER_ID_RE before they go on
|
|
5
|
+
* a command line, so they need no quoting. Anything free form is quoted.
|
|
6
|
+
*/
|
|
7
|
+
import { HostScriptError } from "./text.js";
|
|
8
|
+
export function normalizeContainerRuntime(value) {
|
|
9
|
+
return value === "podman" ? "podman" : "docker";
|
|
10
|
+
}
|
|
11
|
+
export function getRuntimeLabel(runtime) {
|
|
12
|
+
return runtime === "podman" ? "Podman" : "Docker";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
16
|
+
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
17
|
+
*/
|
|
18
|
+
export const EXTRA_PATH_DIRS = '/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
|
19
|
+
export function containerCommand(runtime, args, isWindows = false) {
|
|
20
|
+
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
|
|
21
|
+
// cmd and PowerShell don't understand the POSIX env prefix.
|
|
22
|
+
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
|
|
23
|
+
}
|
|
24
|
+
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
25
|
+
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
|
26
|
+
export function isContainerId(value) {
|
|
27
|
+
return (typeof value === "string" &&
|
|
28
|
+
value.length <= 256 &&
|
|
29
|
+
CONTAINER_ID_RE.test(value));
|
|
30
|
+
}
|
|
31
|
+
function requireContainer(id) {
|
|
32
|
+
if (!isContainerId(id))
|
|
33
|
+
throw new HostScriptError("That is not a valid container id");
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
export const CONTAINER_ACTIONS = [
|
|
37
|
+
"start",
|
|
38
|
+
"stop",
|
|
39
|
+
"restart",
|
|
40
|
+
"pause",
|
|
41
|
+
"unpause",
|
|
42
|
+
];
|
|
43
|
+
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
|
44
|
+
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
|
45
|
+
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
|
46
|
+
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
|
47
|
+
export function listFormat(isWindows) {
|
|
48
|
+
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
|
49
|
+
}
|
|
50
|
+
export function statsFormat(isWindows) {
|
|
51
|
+
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
|
52
|
+
}
|
|
53
|
+
/** CLI arguments, without the runtime, for each container command. */
|
|
54
|
+
export const dockerArgs = {
|
|
55
|
+
version: () => "--version",
|
|
56
|
+
ping: () => "ps",
|
|
57
|
+
list: (all, isWindows = false) => `ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
|
|
58
|
+
inspect: (id) => `inspect ${requireContainer(id)}`,
|
|
59
|
+
stats: (id, isWindows = false) => `stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
|
|
60
|
+
action: (action, id) => {
|
|
61
|
+
if (!CONTAINER_ACTIONS.includes(action))
|
|
62
|
+
throw new HostScriptError("Unknown action");
|
|
63
|
+
return `${action} ${requireContainer(id)}`;
|
|
64
|
+
},
|
|
65
|
+
remove: (id, force) => `rm ${force ? "-f " : ""}${requireContainer(id)}`,
|
|
66
|
+
logs: (id, options = {}) => {
|
|
67
|
+
let args = `logs ${requireContainer(id)}`;
|
|
68
|
+
if (options.tail && options.tail > 0)
|
|
69
|
+
args += ` --tail ${Math.floor(options.tail)}`;
|
|
70
|
+
if (options.timestamps)
|
|
71
|
+
args += " --timestamps";
|
|
72
|
+
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
|
|
73
|
+
args += ` --since ${options.since}`;
|
|
74
|
+
}
|
|
75
|
+
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
|
|
76
|
+
args += ` --until ${options.until}`;
|
|
77
|
+
}
|
|
78
|
+
return `${args} 2>&1`;
|
|
79
|
+
},
|
|
80
|
+
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
|
|
81
|
+
composeProjects: () => "compose ls -a --format json",
|
|
82
|
+
};
|
|
83
|
+
/** The version from `docker --version` or `podman --version`. */
|
|
84
|
+
export function parseRuntimeVersion(output) {
|
|
85
|
+
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
|
|
86
|
+
}
|
|
87
|
+
/** Sorts a failed `docker ps` into a reason the UI can explain. */
|
|
88
|
+
export function classifyRuntimeError(message) {
|
|
89
|
+
if (/command not found|not found: (docker|podman)|No such file or directory/i.test(message)) {
|
|
90
|
+
return "NOT_INSTALLED";
|
|
91
|
+
}
|
|
92
|
+
if (message.includes("Cannot connect to the Docker daemon"))
|
|
93
|
+
return "DAEMON_NOT_RUNNING";
|
|
94
|
+
if (/permission denied/i.test(message))
|
|
95
|
+
return "PERMISSION_DENIED";
|
|
96
|
+
return "DOCKER_ERROR";
|
|
97
|
+
}
|
|
98
|
+
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
|
99
|
+
export function parseContainerList(output) {
|
|
100
|
+
return output
|
|
101
|
+
.split("\n")
|
|
102
|
+
.filter((line) => line.trim())
|
|
103
|
+
.flatMap((line) => {
|
|
104
|
+
try {
|
|
105
|
+
return [JSON.parse(line)];
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
|
|
113
|
+
export function parseContainerStats(output) {
|
|
114
|
+
const raw = JSON.parse(output.trim());
|
|
115
|
+
const split = (value) => {
|
|
116
|
+
const [first, second] = (value ?? "").split(" / ");
|
|
117
|
+
return [first?.trim() || "0B", second?.trim() || "0B"];
|
|
118
|
+
};
|
|
119
|
+
const [memoryUsed, memoryLimit] = split(raw.memory);
|
|
120
|
+
const [netInput, netOutput] = split(raw.netIO);
|
|
121
|
+
const [blockRead, blockWrite] = split(raw.blockIO);
|
|
122
|
+
return {
|
|
123
|
+
cpu: raw.cpu,
|
|
124
|
+
memoryUsed,
|
|
125
|
+
memoryLimit,
|
|
126
|
+
memoryPercent: raw.memoryPercent,
|
|
127
|
+
netInput,
|
|
128
|
+
netOutput,
|
|
129
|
+
blockRead,
|
|
130
|
+
blockWrite,
|
|
131
|
+
pids: raw.pids,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
|
135
|
+
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
|
136
|
+
export function parseContainerStates(output) {
|
|
137
|
+
const states = new Map();
|
|
138
|
+
for (const line of output.split("\n")) {
|
|
139
|
+
const trimmed = line.trim();
|
|
140
|
+
if (!trimmed)
|
|
141
|
+
continue;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(trimmed);
|
|
144
|
+
if (!parsed.name)
|
|
145
|
+
continue;
|
|
146
|
+
states.set(parsed.name, {
|
|
147
|
+
state: (parsed.state ?? "").toLowerCase(),
|
|
148
|
+
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// A partial line is not worth failing the whole poll over.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return states;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The events a pair of snapshots implies. A container missing from the
|
|
159
|
+
* previous snapshot counts as newly seen rather than started, so the first
|
|
160
|
+
* poll after a restart does not replay every running container.
|
|
161
|
+
*/
|
|
162
|
+
export function diffContainerStates(previous, current) {
|
|
163
|
+
const events = [];
|
|
164
|
+
for (const [name, now] of current) {
|
|
165
|
+
const before = previous.get(name);
|
|
166
|
+
if (!before)
|
|
167
|
+
continue;
|
|
168
|
+
if (before.state !== now.state) {
|
|
169
|
+
if (now.state === "exited")
|
|
170
|
+
events.push({ container: name, event: "exited" });
|
|
171
|
+
else if (now.state === "running")
|
|
172
|
+
events.push({ container: name, event: "started" });
|
|
173
|
+
else if (now.state === "restarting") {
|
|
174
|
+
events.push({ container: name, event: "restarting" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// A container can go unhealthy while it stays up.
|
|
178
|
+
if (!before.unhealthy && now.unhealthy) {
|
|
179
|
+
events.push({ container: name, event: "unhealthy" });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return events;
|
|
183
|
+
}
|
|
184
|
+
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
|
|
185
|
+
export function countContainers(output) {
|
|
186
|
+
const states = [...parseContainerStates(output).values()];
|
|
187
|
+
const running = states.filter((s) => s.state === "running").length;
|
|
188
|
+
return {
|
|
189
|
+
running,
|
|
190
|
+
stopped: states.length - running,
|
|
191
|
+
unhealthy: states.filter((s) => s.unhealthy).length,
|
|
192
|
+
total: states.length,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Compose stacks
|
|
196
|
+
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
197
|
+
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
|
|
198
|
+
export const COMPOSE_FILES = [
|
|
199
|
+
"compose.yaml",
|
|
200
|
+
"compose.yml",
|
|
201
|
+
"docker-compose.yaml",
|
|
202
|
+
"docker-compose.yml",
|
|
203
|
+
];
|
|
204
|
+
export const STACK_ACTIONS = [
|
|
205
|
+
"deploy",
|
|
206
|
+
"redeploy",
|
|
207
|
+
"pull",
|
|
208
|
+
"start",
|
|
209
|
+
"stop",
|
|
210
|
+
"restart",
|
|
211
|
+
"pause",
|
|
212
|
+
"unpause",
|
|
213
|
+
"down",
|
|
214
|
+
"destroy",
|
|
215
|
+
];
|
|
216
|
+
/** Actions that can target one service of a stack. */
|
|
217
|
+
export const STACK_SERVICE_ACTIONS = [
|
|
218
|
+
"deploy",
|
|
219
|
+
"pull",
|
|
220
|
+
"start",
|
|
221
|
+
"stop",
|
|
222
|
+
"restart",
|
|
223
|
+
"pause",
|
|
224
|
+
"unpause",
|
|
225
|
+
];
|
|
226
|
+
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
|
|
227
|
+
export function parseJsonLines(output) {
|
|
228
|
+
const text = output.trim();
|
|
229
|
+
if (!text)
|
|
230
|
+
return [];
|
|
231
|
+
if (text.startsWith("[")) {
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(text);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return text.split("\n").flatMap((line) => {
|
|
240
|
+
try {
|
|
241
|
+
return [JSON.parse(line)];
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
249
|
+
export function parseServices(output) {
|
|
250
|
+
return parseJsonLines(output).map((row) => {
|
|
251
|
+
const publishers = Array.isArray(row.Publishers)
|
|
252
|
+
? row.Publishers
|
|
253
|
+
: [];
|
|
254
|
+
const ports = publishers
|
|
255
|
+
.filter((p) => Number(p.PublishedPort) > 0)
|
|
256
|
+
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
|
|
257
|
+
.filter((value, index, all) => all.indexOf(value) === index)
|
|
258
|
+
.join(", ");
|
|
259
|
+
return {
|
|
260
|
+
service: str(row.Service),
|
|
261
|
+
name: str(row.Name),
|
|
262
|
+
id: str(row.ID),
|
|
263
|
+
image: str(row.Image),
|
|
264
|
+
state: str(row.State),
|
|
265
|
+
status: str(row.Status),
|
|
266
|
+
health: str(row.Health),
|
|
267
|
+
ports,
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function stateOf(running, paused, total) {
|
|
272
|
+
if (total === 0)
|
|
273
|
+
return "not-deployed";
|
|
274
|
+
if (paused === total)
|
|
275
|
+
return "paused";
|
|
276
|
+
if (running === total)
|
|
277
|
+
return "running";
|
|
278
|
+
if (running > 0)
|
|
279
|
+
return "partial";
|
|
280
|
+
return "stopped";
|
|
281
|
+
}
|
|
282
|
+
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
|
|
283
|
+
export function parseStackStatus(status) {
|
|
284
|
+
let running = 0;
|
|
285
|
+
let paused = 0;
|
|
286
|
+
let total = 0;
|
|
287
|
+
for (const part of status.split(",")) {
|
|
288
|
+
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
|
|
289
|
+
if (!match)
|
|
290
|
+
continue;
|
|
291
|
+
const count = Number(match[2]);
|
|
292
|
+
total += count;
|
|
293
|
+
if (match[1] === "running")
|
|
294
|
+
running += count;
|
|
295
|
+
if (match[1] === "paused")
|
|
296
|
+
paused += count;
|
|
297
|
+
}
|
|
298
|
+
return { state: stateOf(running, paused, total), running, total };
|
|
299
|
+
}
|
|
300
|
+
export function stackStateFromServices(services) {
|
|
301
|
+
const total = services.length;
|
|
302
|
+
const running = services.filter((s) => s.state === "running").length;
|
|
303
|
+
const paused = services.filter((s) => s.state === "paused").length;
|
|
304
|
+
return { state: stateOf(running, paused, total), running, total };
|
|
305
|
+
}
|
|
306
|
+
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
|
|
307
|
+
export function parseComposeProjects(output) {
|
|
308
|
+
return parseJsonLines(output)
|
|
309
|
+
.flatMap((project) => {
|
|
310
|
+
const name = str(project.Name);
|
|
311
|
+
if (!STACK_NAME_RE.test(name))
|
|
312
|
+
return [];
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
name,
|
|
316
|
+
managed: false,
|
|
317
|
+
...parseStackStatus(str(project.Status)),
|
|
318
|
+
composeFile: str(project.ConfigFiles).split(",")[0] || null,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
})
|
|
322
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
323
|
+
}
|
|
324
|
+
/** The compose commands one action runs, in order. */
|
|
325
|
+
export function composeArgsFor(action, service, options) {
|
|
326
|
+
const target = service ? ` ${service}` : "";
|
|
327
|
+
switch (action) {
|
|
328
|
+
case "deploy":
|
|
329
|
+
return [`up -d --remove-orphans${target}`];
|
|
330
|
+
case "redeploy":
|
|
331
|
+
return [`pull${target}`, `up -d --remove-orphans${target}`];
|
|
332
|
+
case "pull":
|
|
333
|
+
return [`pull${target}`];
|
|
334
|
+
case "start":
|
|
335
|
+
case "stop":
|
|
336
|
+
case "restart":
|
|
337
|
+
case "pause":
|
|
338
|
+
case "unpause":
|
|
339
|
+
return [`${action}${target}`];
|
|
340
|
+
case "down":
|
|
341
|
+
return ["down --remove-orphans"];
|
|
342
|
+
case "destroy":
|
|
343
|
+
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host scripts: command builders and output parsers for Docker, Linux
|
|
3
|
+
* metrics, systemd, the journal and OpenRC.
|
|
4
|
+
*
|
|
5
|
+
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
|
|
6
|
+
* plugin backend and in the mobile app. Run a script by sending it on stdin
|
|
7
|
+
* to the command from `scriptShell()`.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./text.js";
|
|
10
|
+
export * from "./shell.js";
|
|
11
|
+
export * from "./docker.js";
|
|
12
|
+
export * from "./metrics.js";
|
|
13
|
+
export * from "./systemd.js";
|
|
14
|
+
export * from "./journal.js";
|
|
15
|
+
export * from "./openrc.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host scripts: command builders and output parsers for Docker, Linux
|
|
3
|
+
* metrics, systemd, the journal and OpenRC.
|
|
4
|
+
*
|
|
5
|
+
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
|
|
6
|
+
* plugin backend and in the mobile app. Run a script by sending it on stdin
|
|
7
|
+
* to the command from `scriptShell()`.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./text.js";
|
|
10
|
+
export * from "./shell.js";
|
|
11
|
+
export * from "./docker.js";
|
|
12
|
+
export * from "./metrics.js";
|
|
13
|
+
export * from "./systemd.js";
|
|
14
|
+
export * from "./journal.js";
|
|
15
|
+
export * from "./openrc.js";
|