@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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/data/registry.example.json +13 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +113 -0
  6. package/dist/commands/add.d.ts +1 -0
  7. package/dist/commands/add.js +95 -0
  8. package/dist/commands/deploy.d.ts +1 -0
  9. package/dist/commands/deploy.js +53 -0
  10. package/dist/commands/git.d.ts +1 -0
  11. package/dist/commands/git.js +278 -0
  12. package/dist/commands/health.d.ts +1 -0
  13. package/dist/commands/health.js +60 -0
  14. package/dist/commands/init.d.ts +1 -0
  15. package/dist/commands/init.js +157 -0
  16. package/dist/commands/install-mcp.d.ts +1 -0
  17. package/dist/commands/install-mcp.js +55 -0
  18. package/dist/commands/list.d.ts +1 -0
  19. package/dist/commands/list.js +20 -0
  20. package/dist/commands/logs.d.ts +1 -0
  21. package/dist/commands/logs.js +32 -0
  22. package/dist/commands/nginx.d.ts +1 -0
  23. package/dist/commands/nginx.js +94 -0
  24. package/dist/commands/remove.d.ts +1 -0
  25. package/dist/commands/remove.js +28 -0
  26. package/dist/commands/restart.d.ts +1 -0
  27. package/dist/commands/restart.js +22 -0
  28. package/dist/commands/secrets.d.ts +1 -0
  29. package/dist/commands/secrets.js +268 -0
  30. package/dist/commands/start.d.ts +1 -0
  31. package/dist/commands/start.js +22 -0
  32. package/dist/commands/status.d.ts +14 -0
  33. package/dist/commands/status.js +70 -0
  34. package/dist/commands/stop.d.ts +1 -0
  35. package/dist/commands/stop.js +22 -0
  36. package/dist/commands/watchdog.d.ts +1 -0
  37. package/dist/commands/watchdog.js +100 -0
  38. package/dist/core/docker.d.ts +15 -0
  39. package/dist/core/docker.js +72 -0
  40. package/dist/core/errors.d.ts +20 -0
  41. package/dist/core/errors.js +40 -0
  42. package/dist/core/exec.d.ts +14 -0
  43. package/dist/core/exec.js +30 -0
  44. package/dist/core/git-onboard.d.ts +11 -0
  45. package/dist/core/git-onboard.js +149 -0
  46. package/dist/core/git.d.ts +36 -0
  47. package/dist/core/git.js +155 -0
  48. package/dist/core/github.d.ts +22 -0
  49. package/dist/core/github.js +92 -0
  50. package/dist/core/health.d.ts +29 -0
  51. package/dist/core/health.js +56 -0
  52. package/dist/core/nginx.d.ts +17 -0
  53. package/dist/core/nginx.js +59 -0
  54. package/dist/core/registry.d.ts +38 -0
  55. package/dist/core/registry.js +47 -0
  56. package/dist/core/secrets-ops.d.ts +37 -0
  57. package/dist/core/secrets-ops.js +331 -0
  58. package/dist/core/secrets-validate.d.ts +8 -0
  59. package/dist/core/secrets-validate.js +81 -0
  60. package/dist/core/secrets.d.ts +36 -0
  61. package/dist/core/secrets.js +191 -0
  62. package/dist/core/systemd.d.ts +23 -0
  63. package/dist/core/systemd.js +106 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +18 -0
  66. package/dist/mcp/git-tools.d.ts +2 -0
  67. package/dist/mcp/git-tools.js +148 -0
  68. package/dist/mcp/secrets-tools.d.ts +2 -0
  69. package/dist/mcp/secrets-tools.js +67 -0
  70. package/dist/mcp/server.d.ts +1 -0
  71. package/dist/mcp/server.js +179 -0
  72. package/dist/templates/gitignore.d.ts +3 -0
  73. package/dist/templates/gitignore.js +89 -0
  74. package/dist/templates/nginx.d.ts +8 -0
  75. package/dist/templates/nginx.js +111 -0
  76. package/dist/templates/systemd.d.ts +9 -0
  77. package/dist/templates/systemd.js +26 -0
  78. package/dist/templates/unseal.d.ts +1 -0
  79. package/dist/templates/unseal.js +22 -0
  80. package/dist/tui/app.d.ts +1 -0
  81. package/dist/tui/app.js +9 -0
  82. package/dist/tui/components/AppList.d.ts +12 -0
  83. package/dist/tui/components/AppList.js +32 -0
  84. package/dist/tui/components/Confirm.d.ts +2 -0
  85. package/dist/tui/components/Confirm.js +10 -0
  86. package/dist/tui/components/Header.d.ts +6 -0
  87. package/dist/tui/components/Header.js +16 -0
  88. package/dist/tui/components/KeyHint.d.ts +2 -0
  89. package/dist/tui/components/KeyHint.js +55 -0
  90. package/dist/tui/components/StatusBadge.d.ts +7 -0
  91. package/dist/tui/components/StatusBadge.js +8 -0
  92. package/dist/tui/exec-bridge.d.ts +11 -0
  93. package/dist/tui/exec-bridge.js +57 -0
  94. package/dist/tui/hooks/use-fleet-data.d.ts +9 -0
  95. package/dist/tui/hooks/use-fleet-data.js +30 -0
  96. package/dist/tui/hooks/use-health.d.ts +9 -0
  97. package/dist/tui/hooks/use-health.js +29 -0
  98. package/dist/tui/hooks/use-interval.d.ts +1 -0
  99. package/dist/tui/hooks/use-interval.js +13 -0
  100. package/dist/tui/hooks/use-keyboard.d.ts +1 -0
  101. package/dist/tui/hooks/use-keyboard.js +44 -0
  102. package/dist/tui/hooks/use-secrets.d.ts +47 -0
  103. package/dist/tui/hooks/use-secrets.js +152 -0
  104. package/dist/tui/router.d.ts +2 -0
  105. package/dist/tui/router.js +65 -0
  106. package/dist/tui/state.d.ts +12 -0
  107. package/dist/tui/state.js +83 -0
  108. package/dist/tui/theme.d.ts +11 -0
  109. package/dist/tui/theme.js +23 -0
  110. package/dist/tui/types.d.ts +41 -0
  111. package/dist/tui/types.js +1 -0
  112. package/dist/tui/views/AppDetail.d.ts +2 -0
  113. package/dist/tui/views/AppDetail.js +72 -0
  114. package/dist/tui/views/Dashboard.d.ts +2 -0
  115. package/dist/tui/views/Dashboard.js +29 -0
  116. package/dist/tui/views/HealthView.d.ts +2 -0
  117. package/dist/tui/views/HealthView.js +28 -0
  118. package/dist/tui/views/LogsView.d.ts +2 -0
  119. package/dist/tui/views/LogsView.js +71 -0
  120. package/dist/tui/views/SecretEdit.d.ts +2 -0
  121. package/dist/tui/views/SecretEdit.js +53 -0
  122. package/dist/tui/views/SecretsView.d.ts +2 -0
  123. package/dist/tui/views/SecretsView.js +108 -0
  124. package/dist/ui/confirm.d.ts +1 -0
  125. package/dist/ui/confirm.js +15 -0
  126. package/dist/ui/output.d.ts +27 -0
  127. package/dist/ui/output.js +61 -0
  128. 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,2 @@
1
+ import React from 'react';
2
+ export declare function Dashboard(): React.JSX.Element;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare function HealthView(): React.JSX.Element;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare function LogsView(): React.JSX.Element;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare function SecretEdit(): React.JSX.Element;
@@ -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,2 @@
1
+ import React from 'react';
2
+ export declare function SecretsView(): React.JSX.Element;
@@ -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
+ }