@matthesketh/fleet 1.0.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 +318 -0
- package/data/registry.example.json +13 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +113 -0
- package/dist/commands/add.d.ts +1 -0
- package/dist/commands/add.js +95 -0
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.js +53 -0
- package/dist/commands/git.d.ts +1 -0
- package/dist/commands/git.js +278 -0
- package/dist/commands/health.d.ts +1 -0
- package/dist/commands/health.js +60 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/install-mcp.d.ts +1 -0
- package/dist/commands/install-mcp.js +55 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.js +32 -0
- package/dist/commands/nginx.d.ts +1 -0
- package/dist/commands/nginx.js +94 -0
- package/dist/commands/remove.d.ts +1 -0
- package/dist/commands/remove.js +28 -0
- package/dist/commands/restart.d.ts +1 -0
- package/dist/commands/restart.js +22 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +268 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +22 -0
- package/dist/commands/status.d.ts +14 -0
- package/dist/commands/status.js +70 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +22 -0
- package/dist/commands/watchdog.d.ts +1 -0
- package/dist/commands/watchdog.js +100 -0
- package/dist/core/docker.d.ts +15 -0
- package/dist/core/docker.js +72 -0
- package/dist/core/errors.d.ts +20 -0
- package/dist/core/errors.js +40 -0
- package/dist/core/exec.d.ts +14 -0
- package/dist/core/exec.js +30 -0
- package/dist/core/git-onboard.d.ts +11 -0
- package/dist/core/git-onboard.js +149 -0
- package/dist/core/git.d.ts +36 -0
- package/dist/core/git.js +155 -0
- package/dist/core/github.d.ts +22 -0
- package/dist/core/github.js +92 -0
- package/dist/core/health.d.ts +29 -0
- package/dist/core/health.js +56 -0
- package/dist/core/nginx.d.ts +17 -0
- package/dist/core/nginx.js +59 -0
- package/dist/core/registry.d.ts +38 -0
- package/dist/core/registry.js +47 -0
- package/dist/core/secrets-ops.d.ts +37 -0
- package/dist/core/secrets-ops.js +331 -0
- package/dist/core/secrets-validate.d.ts +8 -0
- package/dist/core/secrets-validate.js +81 -0
- package/dist/core/secrets.d.ts +36 -0
- package/dist/core/secrets.js +191 -0
- package/dist/core/systemd.d.ts +23 -0
- package/dist/core/systemd.js +106 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/mcp/git-tools.d.ts +2 -0
- package/dist/mcp/git-tools.js +148 -0
- package/dist/mcp/secrets-tools.d.ts +2 -0
- package/dist/mcp/secrets-tools.js +67 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +179 -0
- package/dist/templates/gitignore.d.ts +3 -0
- package/dist/templates/gitignore.js +89 -0
- package/dist/templates/nginx.d.ts +8 -0
- package/dist/templates/nginx.js +111 -0
- package/dist/templates/systemd.d.ts +9 -0
- package/dist/templates/systemd.js +26 -0
- package/dist/templates/unseal.d.ts +1 -0
- package/dist/templates/unseal.js +22 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +9 -0
- package/dist/tui/components/AppList.d.ts +12 -0
- package/dist/tui/components/AppList.js +32 -0
- package/dist/tui/components/Confirm.d.ts +2 -0
- package/dist/tui/components/Confirm.js +10 -0
- package/dist/tui/components/Header.d.ts +6 -0
- package/dist/tui/components/Header.js +16 -0
- package/dist/tui/components/KeyHint.d.ts +2 -0
- package/dist/tui/components/KeyHint.js +55 -0
- package/dist/tui/components/StatusBadge.d.ts +7 -0
- package/dist/tui/components/StatusBadge.js +8 -0
- package/dist/tui/exec-bridge.d.ts +11 -0
- package/dist/tui/exec-bridge.js +57 -0
- package/dist/tui/hooks/use-fleet-data.d.ts +9 -0
- package/dist/tui/hooks/use-fleet-data.js +30 -0
- package/dist/tui/hooks/use-health.d.ts +9 -0
- package/dist/tui/hooks/use-health.js +29 -0
- package/dist/tui/hooks/use-interval.d.ts +1 -0
- package/dist/tui/hooks/use-interval.js +13 -0
- package/dist/tui/hooks/use-keyboard.d.ts +1 -0
- package/dist/tui/hooks/use-keyboard.js +44 -0
- package/dist/tui/hooks/use-secrets.d.ts +47 -0
- package/dist/tui/hooks/use-secrets.js +152 -0
- package/dist/tui/router.d.ts +2 -0
- package/dist/tui/router.js +65 -0
- package/dist/tui/state.d.ts +12 -0
- package/dist/tui/state.js +83 -0
- package/dist/tui/theme.d.ts +11 -0
- package/dist/tui/theme.js +23 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/views/AppDetail.d.ts +2 -0
- package/dist/tui/views/AppDetail.js +72 -0
- package/dist/tui/views/Dashboard.d.ts +2 -0
- package/dist/tui/views/Dashboard.js +29 -0
- package/dist/tui/views/HealthView.d.ts +2 -0
- package/dist/tui/views/HealthView.js +28 -0
- package/dist/tui/views/LogsView.d.ts +2 -0
- package/dist/tui/views/LogsView.js +71 -0
- package/dist/tui/views/SecretEdit.d.ts +2 -0
- package/dist/tui/views/SecretEdit.js +53 -0
- package/dist/tui/views/SecretsView.d.ts +2 -0
- package/dist/tui/views/SecretsView.js +108 -0
- package/dist/ui/confirm.d.ts +1 -0
- package/dist/ui/confirm.js +15 -0
- package/dist/ui/output.d.ts +27 -0
- package/dist/ui/output.js +61 -0
- package/package.json +64 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
6
|
+
import { runFleetCommand } from '../exec-bridge.js';
|
|
7
|
+
import { colors } from '../theme.js';
|
|
8
|
+
import { load, findApp } from '../../core/registry.js';
|
|
9
|
+
const ACTIONS = [
|
|
10
|
+
{ key: '1', label: 'Start', command: ['start'] },
|
|
11
|
+
{ key: '2', label: 'Stop', command: ['stop'], destructive: true },
|
|
12
|
+
{ key: '3', label: 'Restart', command: ['restart'] },
|
|
13
|
+
{ key: '4', label: 'Deploy', command: ['deploy'], destructive: true },
|
|
14
|
+
{ key: '5', label: 'Logs', command: ['logs'] },
|
|
15
|
+
];
|
|
16
|
+
export function AppDetail() {
|
|
17
|
+
const { selectedApp, redacted } = useAppState();
|
|
18
|
+
const dispatch = useAppDispatch();
|
|
19
|
+
const redact = useRedact();
|
|
20
|
+
const [actionIndex, setActionIndex] = useState(0);
|
|
21
|
+
const [running, setRunning] = useState(false);
|
|
22
|
+
const [result, setResult] = useState(null);
|
|
23
|
+
const reg = load();
|
|
24
|
+
const app = selectedApp ? findApp(reg, selectedApp) : undefined;
|
|
25
|
+
useInput((input, key) => {
|
|
26
|
+
if (running)
|
|
27
|
+
return;
|
|
28
|
+
if (input === 'j' || key.downArrow) {
|
|
29
|
+
setActionIndex(prev => Math.min(prev + 1, ACTIONS.length - 1));
|
|
30
|
+
}
|
|
31
|
+
else if (input === 'k' || key.upArrow) {
|
|
32
|
+
setActionIndex(prev => Math.max(prev - 1, 0));
|
|
33
|
+
}
|
|
34
|
+
else if (key.return) {
|
|
35
|
+
const action = ACTIONS[actionIndex];
|
|
36
|
+
if (action.command[0] === 'logs') {
|
|
37
|
+
dispatch({ type: 'NAVIGATE', view: 'logs' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (action.destructive) {
|
|
41
|
+
dispatch({
|
|
42
|
+
type: 'CONFIRM',
|
|
43
|
+
action: {
|
|
44
|
+
label: `${action.label} ${selectedApp}?`,
|
|
45
|
+
description: `This will ${action.label.toLowerCase()} the ${selectedApp} service.`,
|
|
46
|
+
onConfirm: () => executeAction(action),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
executeAction(action);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
function executeAction(action) {
|
|
56
|
+
if (!selectedApp)
|
|
57
|
+
return;
|
|
58
|
+
setRunning(true);
|
|
59
|
+
setResult(null);
|
|
60
|
+
runFleetCommand([...action.command, selectedApp]).then(res => {
|
|
61
|
+
setResult(res);
|
|
62
|
+
setRunning(false);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!app) {
|
|
66
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["App not found: ", selectedApp] }) }));
|
|
67
|
+
}
|
|
68
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(app.displayName || app.name) }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Type: " }), app.type] }), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Service: " }), redacted ? '***' : app.serviceName] }), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Compose: " }), redacted ? '***' : app.composePath] }), app.domains.length > 0 && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Domains: " }), redacted ? '***' : app.domains.join(', ')] })), app.port && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Port: " }), app.port] })), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Containers:" }), " ", redacted ? '***' : app.containers.join(', ')] }), app.gitRepo && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Git: " }), redacted ? '***' : app.gitRepo] }))] }), _jsx(Text, { bold: true, children: "Actions" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: ACTIONS.map((action, i) => {
|
|
69
|
+
const selected = i === actionIndex;
|
|
70
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: ["[", action.key, "] ", action.label] }), action.destructive && _jsx(Text, { color: colors.warning, children: " !" })] }, action.key));
|
|
71
|
+
}) }), running && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Running..."] }) })), result && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: result.ok ? colors.success : colors.error, children: result.ok ? 'Done' : 'Failed' }), result.output && (_jsx(Text, { color: colors.muted, children: result.output.trim().slice(0, 500) }))] }))] }));
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { useAppDispatch, useRedact } from '../state.js';
|
|
5
|
+
import { useFleetData } from '../hooks/use-fleet-data.js';
|
|
6
|
+
import { AppList } from '../components/AppList.js';
|
|
7
|
+
import { StatusBadge } from '../components/StatusBadge.js';
|
|
8
|
+
import { colors } from '../theme.js';
|
|
9
|
+
export function Dashboard() {
|
|
10
|
+
const dispatch = useAppDispatch();
|
|
11
|
+
const { status, loading, error } = useFleetData();
|
|
12
|
+
const redact = useRedact();
|
|
13
|
+
if (loading && !status) {
|
|
14
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Loading fleet status..."] }) }));
|
|
15
|
+
}
|
|
16
|
+
if (error && !status) {
|
|
17
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) }));
|
|
18
|
+
}
|
|
19
|
+
if (!status)
|
|
20
|
+
return _jsx(Text, { color: colors.muted, children: "No data" });
|
|
21
|
+
const items = status.apps.map(app => ({ ...app, name: app.name }));
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: [status.totalApps, " apps"] }), _jsxs(Text, { color: colors.success, children: [status.healthy, " healthy"] }), status.unhealthy > 0 && (_jsxs(Text, { color: colors.error, children: [status.unhealthy, " unhealthy"] })), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: [" ", 'APP'.padEnd(24), 'SYSTEMD'.padEnd(14), 'CONTAINERS'.padEnd(14), "HEALTH"] }) }), _jsx(AppList, { items: items, onSelect: (item) => {
|
|
23
|
+
dispatch({ type: 'SELECT_APP', app: item.name });
|
|
24
|
+
dispatch({ type: 'NAVIGATE', view: 'app-detail' });
|
|
25
|
+
}, renderItem: (item, selected) => {
|
|
26
|
+
const app = status.apps.find(a => a.name === item.name);
|
|
27
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: redact(app.name).padEnd(22) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 14, children: _jsx(StatusBadge, { value: app.systemd, type: "systemd" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.containers }) }), _jsx(StatusBadge, { value: app.health, type: "health" })] }));
|
|
28
|
+
} })] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { useHealth } from '../hooks/use-health.js';
|
|
5
|
+
import { StatusBadge } from '../components/StatusBadge.js';
|
|
6
|
+
import { useRedact } from '../state.js';
|
|
7
|
+
import { colors } from '../theme.js';
|
|
8
|
+
export function HealthView() {
|
|
9
|
+
const { results, loading, error } = useHealth();
|
|
10
|
+
const redact = useRedact();
|
|
11
|
+
if (loading && results.length === 0) {
|
|
12
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Running health checks..."] }) }));
|
|
13
|
+
}
|
|
14
|
+
if (error && results.length === 0) {
|
|
15
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) }));
|
|
16
|
+
}
|
|
17
|
+
const healthy = results.filter(r => r.overall === 'healthy').length;
|
|
18
|
+
const degraded = results.filter(r => r.overall === 'degraded').length;
|
|
19
|
+
const down = results.filter(r => r.overall === 'down').length;
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Health Monitor" }), _jsxs(Text, { color: colors.success, children: [healthy, " healthy"] }), degraded > 0 && _jsxs(Text, { color: colors.warning, children: [degraded, " degraded"] }), down > 0 && _jsxs(Text, { color: colors.error, children: [down, " down"] }), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsxs(Text, { bold: true, children: [' APP'.padEnd(26), 'SYSTEMD'.padEnd(12), 'CONTAINERS'.padEnd(20), 'HTTP'.padEnd(10), "OVERALL"] }), results.map(result => {
|
|
21
|
+
const runningCount = result.containers.filter(c => c.running).length;
|
|
22
|
+
const containerStr = `${runningCount}/${result.containers.length}`;
|
|
23
|
+
const httpStr = result.http
|
|
24
|
+
? result.http.ok ? `${result.http.status}` : `err`
|
|
25
|
+
: 'n/a';
|
|
26
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { children: [' ', redact(result.app).padEnd(24)] }), _jsx(Box, { width: 12, children: _jsx(StatusBadge, { value: result.systemd.state, type: "systemd" }) }), _jsx(Text, { children: containerStr.padEnd(20) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: result.http?.ok ? colors.success : result.http ? colors.error : colors.muted, children: httpStr }) }), _jsx(StatusBadge, { value: result.overall, type: "health" })] }, result.app));
|
|
27
|
+
})] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
6
|
+
import { runFleetCommand, streamFleetCommand } from '../exec-bridge.js';
|
|
7
|
+
import { colors } from '../theme.js';
|
|
8
|
+
const MAX_LINES = 100;
|
|
9
|
+
export function LogsView() {
|
|
10
|
+
const { selectedApp } = useAppState();
|
|
11
|
+
const dispatch = useAppDispatch();
|
|
12
|
+
const redact = useRedact();
|
|
13
|
+
const [lines, setLines] = useState([]);
|
|
14
|
+
const [following, setFollowing] = useState(false);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
const streamRef = useRef(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!selectedApp)
|
|
19
|
+
return;
|
|
20
|
+
setLoading(true);
|
|
21
|
+
runFleetCommand(['logs', selectedApp]).then(result => {
|
|
22
|
+
if (result.ok) {
|
|
23
|
+
setLines(result.output.split('\n').slice(-MAX_LINES));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
setLines([`Error: ${result.output}`]);
|
|
27
|
+
}
|
|
28
|
+
setLoading(false);
|
|
29
|
+
});
|
|
30
|
+
return () => {
|
|
31
|
+
if (streamRef.current) {
|
|
32
|
+
streamRef.current.kill();
|
|
33
|
+
streamRef.current = null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}, [selectedApp]);
|
|
37
|
+
useInput((input, key) => {
|
|
38
|
+
if (input === 'f') {
|
|
39
|
+
if (following) {
|
|
40
|
+
// Stop following
|
|
41
|
+
if (streamRef.current) {
|
|
42
|
+
streamRef.current.kill();
|
|
43
|
+
streamRef.current = null;
|
|
44
|
+
}
|
|
45
|
+
setFollowing(false);
|
|
46
|
+
}
|
|
47
|
+
else if (selectedApp) {
|
|
48
|
+
// Start following
|
|
49
|
+
setFollowing(true);
|
|
50
|
+
const handle = streamFleetCommand(['logs', selectedApp, '-f']);
|
|
51
|
+
streamRef.current = handle;
|
|
52
|
+
handle.onData((line) => {
|
|
53
|
+
setLines(prev => [...prev.slice(-MAX_LINES + 1), line]);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (key.escape) {
|
|
58
|
+
if (streamRef.current) {
|
|
59
|
+
streamRef.current.kill();
|
|
60
|
+
streamRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
dispatch({ type: 'GO_BACK' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
if (loading) {
|
|
66
|
+
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Loading logs for ", selectedApp, "..."] }) }));
|
|
67
|
+
}
|
|
68
|
+
// Show last N lines that fit in terminal
|
|
69
|
+
const visibleLines = lines.slice(-30);
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, color: colors.primary, children: ["Logs: ", redact(selectedApp ?? '')] }), following && (_jsxs(Text, { color: colors.success, children: [_jsx(Spinner, { type: "dots" }), " following"] }))] }), _jsx(Box, { flexDirection: "column", children: visibleLines.map((line, i) => (_jsx(Text, { wrap: "truncate", children: line }, i))) })] }));
|
|
71
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { useAppState, useAppDispatch } from '../state.js';
|
|
6
|
+
import { useSecrets } from '../hooks/use-secrets.js';
|
|
7
|
+
import { getSecret as getCoreSecret } from '../../core/secrets-ops.js';
|
|
8
|
+
import { colors } from '../theme.js';
|
|
9
|
+
export function SecretEdit() {
|
|
10
|
+
const { selectedApp, selectedSecret } = useAppState();
|
|
11
|
+
const dispatch = useAppDispatch();
|
|
12
|
+
const secrets = useSecrets();
|
|
13
|
+
const isNew = selectedSecret === null;
|
|
14
|
+
const [keyName, setKeyName] = useState(selectedSecret ?? '');
|
|
15
|
+
const [value, setValue] = useState('');
|
|
16
|
+
const [phase, setPhase] = useState(isNew ? 'key' : 'value');
|
|
17
|
+
const [status, setStatus] = useState(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!isNew && selectedApp && selectedSecret) {
|
|
20
|
+
try {
|
|
21
|
+
const existing = getCoreSecret(selectedApp, selectedSecret);
|
|
22
|
+
if (existing)
|
|
23
|
+
setValue(existing);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
const save = () => {
|
|
31
|
+
if (!selectedApp || !keyName)
|
|
32
|
+
return;
|
|
33
|
+
const result = secrets.saveSecret(selectedApp, keyName, value);
|
|
34
|
+
if (result.ok) {
|
|
35
|
+
setStatus('Saved and re-sealed');
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
dispatch({ type: 'GO_BACK' });
|
|
38
|
+
}, 500);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
setStatus(`Error: ${result.error}`);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
useInput((input, key) => {
|
|
45
|
+
if (key.escape) {
|
|
46
|
+
dispatch({ type: 'GO_BACK' });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: colors.primary, children: [isNew ? 'Add Secret' : 'Edit Secret', " - ", selectedApp] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Key: " }), isNew && phase === 'key' ? (_jsx(TextInput, { value: keyName, onChange: setKeyName, onSubmit: () => {
|
|
50
|
+
if (keyName)
|
|
51
|
+
setPhase('value');
|
|
52
|
+
} })) : (_jsx(Text, { bold: true, children: keyName }))] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Value: " }), phase === 'value' ? (_jsx(TextInput, { value: value, onChange: setValue, onSubmit: save })) : (_jsx(Text, { color: colors.muted, children: "(press Enter on key first)" }))] })] }), status && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: status.startsWith('Error') ? colors.error : colors.success, children: status }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "Enter to save | Esc to cancel" }) })] }));
|
|
53
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
5
|
+
import { useSecrets } from '../hooks/use-secrets.js';
|
|
6
|
+
import { colors } from '../theme.js';
|
|
7
|
+
export function SecretsView() {
|
|
8
|
+
const { selectedApp } = useAppState();
|
|
9
|
+
const dispatch = useAppDispatch();
|
|
10
|
+
const redact = useRedact();
|
|
11
|
+
const secrets = useSecrets();
|
|
12
|
+
const [subView, setSubView] = useState('app-list');
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
secrets.refresh();
|
|
16
|
+
}, []);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (subView === 'secret-list' && selectedApp) {
|
|
19
|
+
secrets.loadAppSecrets(selectedApp);
|
|
20
|
+
}
|
|
21
|
+
}, [subView, selectedApp]);
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if (subView === 'app-list') {
|
|
24
|
+
if (input === 'j' || key.downArrow) {
|
|
25
|
+
setSelectedIndex(prev => Math.min(prev + 1, secrets.apps.length - 1));
|
|
26
|
+
}
|
|
27
|
+
else if (input === 'k' || key.upArrow) {
|
|
28
|
+
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
29
|
+
}
|
|
30
|
+
else if (key.return && secrets.apps[selectedIndex]) {
|
|
31
|
+
dispatch({ type: 'SELECT_APP', app: secrets.apps[selectedIndex].app });
|
|
32
|
+
setSubView('secret-list');
|
|
33
|
+
setSelectedIndex(0);
|
|
34
|
+
}
|
|
35
|
+
else if (input === 'u') {
|
|
36
|
+
const result = secrets.unseal();
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
dispatch({ type: 'SET_ERROR', error: result.error ?? 'Unseal failed' });
|
|
39
|
+
}
|
|
40
|
+
secrets.refresh();
|
|
41
|
+
}
|
|
42
|
+
else if (input === 'l') {
|
|
43
|
+
const result = secrets.seal();
|
|
44
|
+
if (!result.ok) {
|
|
45
|
+
dispatch({ type: 'SET_ERROR', error: result.error ?? 'Seal failed' });
|
|
46
|
+
}
|
|
47
|
+
secrets.refresh();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (subView === 'secret-list') {
|
|
51
|
+
if (input === 'j' || key.downArrow) {
|
|
52
|
+
setSelectedIndex(prev => Math.min(prev + 1, secrets.secrets.length - 1));
|
|
53
|
+
}
|
|
54
|
+
else if (input === 'k' || key.upArrow) {
|
|
55
|
+
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
56
|
+
}
|
|
57
|
+
else if (key.return && secrets.secrets[selectedIndex] && selectedApp) {
|
|
58
|
+
dispatch({ type: 'SELECT_SECRET', key: secrets.secrets[selectedIndex].key });
|
|
59
|
+
dispatch({ type: 'NAVIGATE', view: 'secret-edit' });
|
|
60
|
+
}
|
|
61
|
+
else if (key.escape) {
|
|
62
|
+
setSubView('app-list');
|
|
63
|
+
setSelectedIndex(0);
|
|
64
|
+
}
|
|
65
|
+
else if (input === 'a' && selectedApp) {
|
|
66
|
+
dispatch({ type: 'SELECT_SECRET', key: null });
|
|
67
|
+
dispatch({ type: 'NAVIGATE', view: 'secret-edit' });
|
|
68
|
+
}
|
|
69
|
+
else if (input === 'd' && selectedApp && secrets.secrets[selectedIndex]) {
|
|
70
|
+
const secretKey = secrets.secrets[selectedIndex].key;
|
|
71
|
+
dispatch({
|
|
72
|
+
type: 'CONFIRM',
|
|
73
|
+
action: {
|
|
74
|
+
label: `Delete secret "${secretKey}"?`,
|
|
75
|
+
description: `This will remove ${secretKey} from ${redact(selectedApp)}'s vault.`,
|
|
76
|
+
onConfirm: () => {
|
|
77
|
+
const result = secrets.deleteSecret(selectedApp, secretKey);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
secrets.loadAppSecrets(selectedApp);
|
|
80
|
+
secrets.refresh();
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
dispatch({ type: 'SET_ERROR', error: result.error ?? 'Delete failed' });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else if (input === 'r' && selectedApp && secrets.secrets[selectedIndex]) {
|
|
90
|
+
const secretKey = secrets.secrets[selectedIndex].key;
|
|
91
|
+
if (secrets.revealedValues[secretKey]) {
|
|
92
|
+
secrets.hideSecret(secretKey);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
secrets.revealSecret(selectedApp, secretKey);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, paddingX: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Vault:" }), !secrets.initialized ? (_jsx(Text, { color: colors.error, children: "Not initialized" })) : secrets.sealed ? (_jsx(Text, { color: colors.warning, bold: true, children: "SEALED" })) : (_jsx(Text, { color: colors.success, bold: true, children: "UNSEALED" })), _jsxs(Text, { color: colors.muted, children: [secrets.apps.length, " apps | ", secrets.apps.reduce((sum, a) => sum + a.keyCount, 0), " keys"] })] }), secrets.error && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.error, children: secrets.error }) })), subView === 'app-list' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Apps with secrets:" }), secrets.apps.length === 0 ? (_jsx(Text, { color: colors.muted, children: " No secrets managed" })) : (secrets.apps.map((app, i) => {
|
|
101
|
+
const selected = i === selectedIndex;
|
|
102
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: redact(app.app).padEnd(24) }), _jsx(Text, { color: colors.muted, children: app.type.padEnd(14) }), _jsxs(Text, { children: [String(app.keyCount).padEnd(8), " keys"] })] }, app.app));
|
|
103
|
+
}))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(selectedApp ?? '') }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: secrets.secrets.length === 0 ? (_jsx(Text, { color: colors.muted, children: " No secrets found" })) : (secrets.secrets.map((secret, i) => {
|
|
104
|
+
const selected = i === selectedIndex;
|
|
105
|
+
const revealed = secrets.revealedValues[secret.key];
|
|
106
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: secret.key.padEnd(30) }), _jsx(Text, { color: revealed ? colors.warning : colors.muted, children: revealed ?? secret.maskedValue })] }, secret.key));
|
|
107
|
+
})) })] }))] }));
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function confirm(message: string, defaultYes?: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
export async function confirm(message, defaultYes = false) {
|
|
3
|
+
const hint = defaultYes ? '[Y/n]' : '[y/N]';
|
|
4
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
5
|
+
return new Promise(resolve => {
|
|
6
|
+
rl.question(`${message} ${hint} `, answer => {
|
|
7
|
+
rl.close();
|
|
8
|
+
const a = answer.trim().toLowerCase();
|
|
9
|
+
if (a === '')
|
|
10
|
+
resolve(defaultYes);
|
|
11
|
+
else
|
|
12
|
+
resolve(a === 'y' || a === 'yes');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
reset: string;
|
|
3
|
+
bold: string;
|
|
4
|
+
dim: string;
|
|
5
|
+
red: string;
|
|
6
|
+
green: string;
|
|
7
|
+
yellow: string;
|
|
8
|
+
blue: string;
|
|
9
|
+
magenta: string;
|
|
10
|
+
cyan: string;
|
|
11
|
+
white: string;
|
|
12
|
+
gray: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const icon: {
|
|
15
|
+
ok: string;
|
|
16
|
+
warn: string;
|
|
17
|
+
err: string;
|
|
18
|
+
info: string;
|
|
19
|
+
arrow: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function heading(text: string): void;
|
|
22
|
+
export declare function success(text: string): void;
|
|
23
|
+
export declare function warn(text: string): void;
|
|
24
|
+
export declare function error(text: string): void;
|
|
25
|
+
export declare function info(text: string): void;
|
|
26
|
+
export declare function dim(text: string): string;
|
|
27
|
+
export declare function table(headers: string[], rows: string[][]): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs';
|
|
2
|
+
const ESC = '\x1b[';
|
|
3
|
+
export const c = {
|
|
4
|
+
reset: `${ESC}0m`,
|
|
5
|
+
bold: `${ESC}1m`,
|
|
6
|
+
dim: `${ESC}2m`,
|
|
7
|
+
red: `${ESC}31m`,
|
|
8
|
+
green: `${ESC}32m`,
|
|
9
|
+
yellow: `${ESC}33m`,
|
|
10
|
+
blue: `${ESC}34m`,
|
|
11
|
+
magenta: `${ESC}35m`,
|
|
12
|
+
cyan: `${ESC}36m`,
|
|
13
|
+
white: `${ESC}37m`,
|
|
14
|
+
gray: `${ESC}90m`,
|
|
15
|
+
};
|
|
16
|
+
export const icon = {
|
|
17
|
+
ok: `${c.green}*${c.reset}`,
|
|
18
|
+
warn: `${c.yellow}!${c.reset}`,
|
|
19
|
+
err: `${c.red}x${c.reset}`,
|
|
20
|
+
info: `${c.blue}-${c.reset}`,
|
|
21
|
+
arrow: `${c.cyan}>${c.reset}`,
|
|
22
|
+
};
|
|
23
|
+
function write(fd, text) {
|
|
24
|
+
writeSync(fd, text + '\n');
|
|
25
|
+
}
|
|
26
|
+
export function heading(text) {
|
|
27
|
+
write(1, `\n${c.bold}${c.cyan}${text}${c.reset}`);
|
|
28
|
+
}
|
|
29
|
+
export function success(text) {
|
|
30
|
+
write(1, `${icon.ok} ${text}`);
|
|
31
|
+
}
|
|
32
|
+
export function warn(text) {
|
|
33
|
+
write(1, `${icon.warn} ${c.yellow}${text}${c.reset}`);
|
|
34
|
+
}
|
|
35
|
+
export function error(text) {
|
|
36
|
+
write(2, `${icon.err} ${c.red}${text}${c.reset}`);
|
|
37
|
+
}
|
|
38
|
+
export function info(text) {
|
|
39
|
+
write(1, `${icon.info} ${text}`);
|
|
40
|
+
}
|
|
41
|
+
export function dim(text) {
|
|
42
|
+
return `${c.dim}${text}${c.reset}`;
|
|
43
|
+
}
|
|
44
|
+
export function table(headers, rows) {
|
|
45
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => stripAnsi(r[i] ?? '').length)));
|
|
46
|
+
const header = headers.map((h, i) => h.padEnd(widths[i])).join(' ');
|
|
47
|
+
const sep = widths.map(w => '-'.repeat(w)).join('--');
|
|
48
|
+
write(1, ` ${c.bold}${header}${c.reset}`);
|
|
49
|
+
write(1, ` ${c.dim}${sep}${c.reset}`);
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
const line = row.map((cell, i) => {
|
|
52
|
+
const stripped = stripAnsi(cell);
|
|
53
|
+
const pad = widths[i] - stripped.length;
|
|
54
|
+
return cell + ' '.repeat(Math.max(0, pad));
|
|
55
|
+
}).join(' ');
|
|
56
|
+
write(1, ` ${line}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function stripAnsi(str) {
|
|
60
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
61
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@matthesketh/fleet",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Docker production management CLI + MCP server for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"fleet": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"data/registry.example.json",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"docker",
|
|
24
|
+
"docker-compose",
|
|
25
|
+
"systemd",
|
|
26
|
+
"production",
|
|
27
|
+
"deployment",
|
|
28
|
+
"mcp",
|
|
29
|
+
"claude",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"server-management",
|
|
32
|
+
"secrets",
|
|
33
|
+
"nginx",
|
|
34
|
+
"devops"
|
|
35
|
+
],
|
|
36
|
+
"author": "Matt Hesketh <matthew@matthewhesketh.com>",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/wrxck/fleet.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/wrxck/fleet",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/wrxck/fleet/issues"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "1.8.0",
|
|
51
|
+
"ink": "^5.2.1",
|
|
52
|
+
"ink-spinner": "^5.0.0",
|
|
53
|
+
"ink-text-input": "^6.0.0",
|
|
54
|
+
"react": "^18.3.1",
|
|
55
|
+
"zod": "^3.24.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "20.17.0",
|
|
59
|
+
"@types/react": "^18.3.28",
|
|
60
|
+
"tsx": "4.19.2",
|
|
61
|
+
"typescript": "5.6.3",
|
|
62
|
+
"vitest": "4.0.18"
|
|
63
|
+
}
|
|
64
|
+
}
|