@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,57 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const FLEET_BIN = join(__dirname, '..', '..', 'dist', 'index.js');
|
|
6
|
+
export function runFleetCommand(args) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const child = execFile('node', [FLEET_BIN, ...args], {
|
|
9
|
+
encoding: 'utf-8',
|
|
10
|
+
timeout: 30_000,
|
|
11
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
12
|
+
}, (err, stdout, stderr) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
resolve({ ok: false, output: stderr || err.message });
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
resolve({ ok: true, output: stdout });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function runFleetJson(args) {
|
|
23
|
+
return runFleetCommand([...args, '--json']).then(result => {
|
|
24
|
+
if (!result.ok)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(result.output);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function streamFleetCommand(args) {
|
|
35
|
+
const child = execFile('node', [FLEET_BIN, ...args], {
|
|
36
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
const callbacks = [];
|
|
39
|
+
child.stdout?.on('data', (chunk) => {
|
|
40
|
+
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
for (const cb of callbacks)
|
|
43
|
+
cb(line);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
child.stderr?.on('data', (chunk) => {
|
|
47
|
+
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
for (const cb of callbacks)
|
|
50
|
+
cb(line);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
kill: () => child.kill(),
|
|
55
|
+
onData: (cb) => callbacks.push(cb),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StatusData } from '../../commands/status.js';
|
|
2
|
+
interface FleetData {
|
|
3
|
+
status: StatusData | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
refresh: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function useFleetData(autoRefreshMs?: number): FleetData;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { runFleetJson } from '../exec-bridge.js';
|
|
3
|
+
import { useInterval } from './use-interval.js';
|
|
4
|
+
export function useFleetData(autoRefreshMs = 10_000) {
|
|
5
|
+
const [status, setStatus] = useState(null);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
const initialised = useRef(false);
|
|
9
|
+
const refresh = useCallback(() => {
|
|
10
|
+
// Only show loading spinner on the very first fetch
|
|
11
|
+
if (!initialised.current)
|
|
12
|
+
setLoading(true);
|
|
13
|
+
runFleetJson(['status']).then(data => {
|
|
14
|
+
initialised.current = true;
|
|
15
|
+
if (data) {
|
|
16
|
+
setStatus(data);
|
|
17
|
+
setError(null);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
setError('Failed to fetch status');
|
|
21
|
+
}
|
|
22
|
+
setLoading(false);
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
refresh();
|
|
27
|
+
}, [refresh]);
|
|
28
|
+
useInterval(refresh, autoRefreshMs);
|
|
29
|
+
return { status, loading, error, refresh };
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HealthResult } from '../../core/health.js';
|
|
2
|
+
interface HealthData {
|
|
3
|
+
results: HealthResult[];
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
refresh: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function useHealth(autoRefreshMs?: number): HealthData;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { runFleetJson } from '../exec-bridge.js';
|
|
3
|
+
import { useInterval } from './use-interval.js';
|
|
4
|
+
export function useHealth(autoRefreshMs = 15_000) {
|
|
5
|
+
const [results, setResults] = useState([]);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
const initialised = useRef(false);
|
|
9
|
+
const refresh = useCallback(() => {
|
|
10
|
+
if (!initialised.current)
|
|
11
|
+
setLoading(true);
|
|
12
|
+
runFleetJson(['health']).then(data => {
|
|
13
|
+
initialised.current = true;
|
|
14
|
+
if (data) {
|
|
15
|
+
setResults(data);
|
|
16
|
+
setError(null);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
setError('Failed to fetch health data');
|
|
20
|
+
}
|
|
21
|
+
setLoading(false);
|
|
22
|
+
});
|
|
23
|
+
}, []);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
refresh();
|
|
26
|
+
}, [refresh]);
|
|
27
|
+
useInterval(refresh, autoRefreshMs);
|
|
28
|
+
return { results, loading, error, refresh };
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useInterval(callback: () => void, delayMs: number | null): void;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
export function useInterval(callback, delayMs) {
|
|
3
|
+
const savedCallback = useRef(callback);
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
savedCallback.current = callback;
|
|
6
|
+
}, [callback]);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (delayMs === null)
|
|
9
|
+
return;
|
|
10
|
+
const id = setInterval(() => savedCallback.current(), delayMs);
|
|
11
|
+
return () => clearInterval(id);
|
|
12
|
+
}, [delayMs]);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useKeyboard(): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useInput } from 'ink';
|
|
2
|
+
import { useAppState, useAppDispatch, nextTopView } from '../state.js';
|
|
3
|
+
export function useKeyboard() {
|
|
4
|
+
const state = useAppState();
|
|
5
|
+
const dispatch = useAppDispatch();
|
|
6
|
+
useInput((input, key) => {
|
|
7
|
+
// Confirm dialog takes priority
|
|
8
|
+
if (state.confirmAction) {
|
|
9
|
+
if (input === 'y' || input === 'Y') {
|
|
10
|
+
state.confirmAction.onConfirm();
|
|
11
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
12
|
+
}
|
|
13
|
+
else if (input === 'n' || input === 'N' || key.escape) {
|
|
14
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
// Redact toggle (not in text-input views)
|
|
19
|
+
if (input === 'x' && state.currentView !== 'secret-edit') {
|
|
20
|
+
dispatch({ type: 'TOGGLE_REDACT' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Quit
|
|
24
|
+
if (input === 'q') {
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
// Tab cycles top-level views (only from top-level views)
|
|
28
|
+
if (key.tab) {
|
|
29
|
+
const topViews = ['dashboard', 'health', 'secrets'];
|
|
30
|
+
const base = topViews.includes(state.currentView)
|
|
31
|
+
? state.currentView
|
|
32
|
+
: state.previousView ?? 'dashboard';
|
|
33
|
+
dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Escape goes back
|
|
37
|
+
if (key.escape) {
|
|
38
|
+
if (state.previousView) {
|
|
39
|
+
dispatch({ type: 'GO_BACK' });
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
interface SecretItem {
|
|
2
|
+
key: string;
|
|
3
|
+
maskedValue: string;
|
|
4
|
+
}
|
|
5
|
+
interface AppSecretInfo {
|
|
6
|
+
app: string;
|
|
7
|
+
type: string;
|
|
8
|
+
keyCount: number;
|
|
9
|
+
lastSealedAt: string;
|
|
10
|
+
}
|
|
11
|
+
interface SecretsState {
|
|
12
|
+
initialized: boolean;
|
|
13
|
+
sealed: boolean;
|
|
14
|
+
apps: AppSecretInfo[];
|
|
15
|
+
secrets: SecretItem[];
|
|
16
|
+
revealedValues: Record<string, string>;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
}
|
|
20
|
+
interface SecretsActions {
|
|
21
|
+
refresh: () => void;
|
|
22
|
+
loadAppSecrets: (app: string) => void;
|
|
23
|
+
saveSecret: (app: string, key: string, value: string) => {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
};
|
|
27
|
+
deleteSecret: (app: string, key: string) => {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
};
|
|
31
|
+
revealSecret: (app: string, key: string) => void;
|
|
32
|
+
hideSecret: (key: string) => void;
|
|
33
|
+
unseal: () => {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
seal: () => {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
41
|
+
importEnv: (app: string, path: string) => {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
error?: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export declare function useSecrets(): SecretsState & SecretsActions;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { isInitialized, isSealed, loadManifest, listSecrets, decryptApp, sealApp } from '../../core/secrets.js';
|
|
3
|
+
import { setSecret, getSecret, unsealAll, sealFromRuntime, importEnvFile } from '../../core/secrets-ops.js';
|
|
4
|
+
export function useSecrets() {
|
|
5
|
+
const [state, setState] = useState({
|
|
6
|
+
initialized: false,
|
|
7
|
+
sealed: true,
|
|
8
|
+
apps: [],
|
|
9
|
+
secrets: [],
|
|
10
|
+
revealedValues: {},
|
|
11
|
+
loading: false,
|
|
12
|
+
error: null,
|
|
13
|
+
});
|
|
14
|
+
const refresh = useCallback(() => {
|
|
15
|
+
try {
|
|
16
|
+
const init = isInitialized();
|
|
17
|
+
if (!init) {
|
|
18
|
+
setState(prev => ({ ...prev, initialized: false, sealed: true, apps: [] }));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const sealed = isSealed();
|
|
22
|
+
const manifest = loadManifest();
|
|
23
|
+
const apps = Object.entries(manifest.apps).map(([app, entry]) => ({
|
|
24
|
+
app,
|
|
25
|
+
type: entry.type,
|
|
26
|
+
keyCount: entry.keyCount,
|
|
27
|
+
lastSealedAt: entry.lastSealedAt,
|
|
28
|
+
}));
|
|
29
|
+
setState(prev => ({ ...prev, initialized: true, sealed, apps, error: null }));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
setState(prev => ({
|
|
33
|
+
...prev,
|
|
34
|
+
error: err instanceof Error ? err.message : 'Failed to load secrets state',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}, []);
|
|
38
|
+
const loadAppSecrets = useCallback((app) => {
|
|
39
|
+
try {
|
|
40
|
+
const items = listSecrets(app);
|
|
41
|
+
setState(prev => ({ ...prev, secrets: items, revealedValues: {}, error: null }));
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
setState(prev => ({
|
|
45
|
+
...prev,
|
|
46
|
+
secrets: [],
|
|
47
|
+
error: err instanceof Error ? err.message : 'Failed to load secrets',
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
const saveSecret = useCallback((app, key, value) => {
|
|
52
|
+
try {
|
|
53
|
+
setSecret(app, key, value);
|
|
54
|
+
// Re-unseal to update runtime
|
|
55
|
+
try {
|
|
56
|
+
unsealAll();
|
|
57
|
+
}
|
|
58
|
+
catch { /* runtime may not exist yet */ }
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to save secret' };
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
const deleteSecret = useCallback((app, key) => {
|
|
66
|
+
try {
|
|
67
|
+
const plaintext = decryptApp(app);
|
|
68
|
+
const manifest = loadManifest();
|
|
69
|
+
const entry = manifest.apps[app];
|
|
70
|
+
if (entry.type === 'env') {
|
|
71
|
+
const lines = plaintext.split('\n').filter((line) => {
|
|
72
|
+
const eqIdx = line.indexOf('=');
|
|
73
|
+
return !(eqIdx > 0 && line.substring(0, eqIdx) === key);
|
|
74
|
+
});
|
|
75
|
+
sealApp(app, lines.join('\n'), entry.sourceFile);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
unsealAll();
|
|
79
|
+
}
|
|
80
|
+
catch { /* runtime may not exist */ }
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to delete secret' };
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
const revealSecret = useCallback((app, key) => {
|
|
88
|
+
try {
|
|
89
|
+
const value = getSecret(app, key);
|
|
90
|
+
if (value !== null) {
|
|
91
|
+
setState(prev => ({
|
|
92
|
+
...prev,
|
|
93
|
+
revealedValues: { ...prev.revealedValues, [key]: value },
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore reveal errors
|
|
99
|
+
}
|
|
100
|
+
}, []);
|
|
101
|
+
const hideSecret = useCallback((key) => {
|
|
102
|
+
setState(prev => {
|
|
103
|
+
const { [key]: _, ...rest } = prev.revealedValues;
|
|
104
|
+
return { ...prev, revealedValues: rest };
|
|
105
|
+
});
|
|
106
|
+
}, []);
|
|
107
|
+
const unseal = useCallback(() => {
|
|
108
|
+
try {
|
|
109
|
+
unsealAll();
|
|
110
|
+
setState(prev => ({ ...prev, sealed: false }));
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to unseal' };
|
|
115
|
+
}
|
|
116
|
+
}, []);
|
|
117
|
+
const seal = useCallback(() => {
|
|
118
|
+
try {
|
|
119
|
+
sealFromRuntime();
|
|
120
|
+
setState(prev => ({ ...prev, sealed: true }));
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to seal' };
|
|
125
|
+
}
|
|
126
|
+
}, []);
|
|
127
|
+
const importEnv = useCallback((app, path) => {
|
|
128
|
+
try {
|
|
129
|
+
importEnvFile(app, path);
|
|
130
|
+
try {
|
|
131
|
+
unsealAll();
|
|
132
|
+
}
|
|
133
|
+
catch { /* ok */ }
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to import' };
|
|
138
|
+
}
|
|
139
|
+
}, []);
|
|
140
|
+
return {
|
|
141
|
+
...state,
|
|
142
|
+
refresh,
|
|
143
|
+
loadAppSecrets,
|
|
144
|
+
saveSecret,
|
|
145
|
+
deleteSecret,
|
|
146
|
+
revealSecret,
|
|
147
|
+
hideSecret,
|
|
148
|
+
unseal,
|
|
149
|
+
seal,
|
|
150
|
+
importEnv,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useReducer, useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { reducer, initialState, AppStateContext, AppDispatchContext } from './state.js';
|
|
5
|
+
import { useKeyboard } from './hooks/use-keyboard.js';
|
|
6
|
+
import { Header } from './components/Header.js';
|
|
7
|
+
import { KeyHint } from './components/KeyHint.js';
|
|
8
|
+
import { Confirm } from './components/Confirm.js';
|
|
9
|
+
import { Dashboard } from './views/Dashboard.js';
|
|
10
|
+
import { AppDetail } from './views/AppDetail.js';
|
|
11
|
+
import { SecretsView } from './views/SecretsView.js';
|
|
12
|
+
import { SecretEdit } from './views/SecretEdit.js';
|
|
13
|
+
import { HealthView } from './views/HealthView.js';
|
|
14
|
+
import { LogsView } from './views/LogsView.js';
|
|
15
|
+
import { isSealed, isInitialized } from '../core/secrets.js';
|
|
16
|
+
function ViewRouter() {
|
|
17
|
+
const state = React.useContext(AppStateContext);
|
|
18
|
+
switch (state.currentView) {
|
|
19
|
+
case 'dashboard':
|
|
20
|
+
return _jsx(Dashboard, {});
|
|
21
|
+
case 'app-detail':
|
|
22
|
+
return _jsx(AppDetail, {});
|
|
23
|
+
case 'health':
|
|
24
|
+
return _jsx(HealthView, {});
|
|
25
|
+
case 'secrets':
|
|
26
|
+
return _jsx(SecretsView, {});
|
|
27
|
+
case 'secret-edit':
|
|
28
|
+
return _jsx(SecretEdit, {});
|
|
29
|
+
case 'logs':
|
|
30
|
+
return _jsx(LogsView, {});
|
|
31
|
+
default:
|
|
32
|
+
return _jsx(Dashboard, {});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function KeyboardHandler() {
|
|
36
|
+
useKeyboard();
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export function App() {
|
|
40
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
41
|
+
const [vaultSealed, setVaultSealed] = useState(true);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
try {
|
|
44
|
+
if (isInitialized()) {
|
|
45
|
+
setVaultSealed(isSealed());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// vault may not be set up
|
|
50
|
+
}
|
|
51
|
+
const interval = setInterval(() => {
|
|
52
|
+
try {
|
|
53
|
+
if (isInitialized()) {
|
|
54
|
+
const sealed = isSealed();
|
|
55
|
+
setVaultSealed(prev => prev === sealed ? prev : sealed);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// ignore
|
|
60
|
+
}
|
|
61
|
+
}, 5000);
|
|
62
|
+
return () => clearInterval(interval);
|
|
63
|
+
}, []);
|
|
64
|
+
return (_jsx(AppStateContext.Provider, { value: state, children: _jsxs(AppDispatchContext.Provider, { value: dispatch, children: [_jsx(KeyboardHandler, {}), _jsxs(Box, { flexDirection: "column", height: process.stdout.rows || 24, children: [_jsx(Header, { vaultSealed: vaultSealed }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsx(ViewRouter, {}), _jsx(Confirm, {}), state.error && (_jsx(Box, { paddingX: 1, children: _jsx(Box, { borderStyle: "round", borderColor: "red", paddingX: 1, children: _jsx(Text, { color: "red", children: state.error }) }) }))] }), _jsx(KeyHint, {})] })] }) }));
|
|
65
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Dispatch } from 'react';
|
|
2
|
+
import type { TuiState, Action, View } from './types.js';
|
|
3
|
+
export declare const initialState: TuiState;
|
|
4
|
+
export declare function reducer(state: TuiState, action: Action): TuiState;
|
|
5
|
+
export declare function nextTopView(current: View): View;
|
|
6
|
+
export declare function redactName(name: string): string;
|
|
7
|
+
export declare function useRedact(): (name: string) => string;
|
|
8
|
+
export declare const AppStateContext: import("react").Context<TuiState>;
|
|
9
|
+
export declare const AppDispatchContext: import("react").Context<Dispatch<Action>>;
|
|
10
|
+
export declare function useAppState(): TuiState;
|
|
11
|
+
export declare function useAppDispatch(): Dispatch<Action>;
|
|
12
|
+
export declare function useTui(): [TuiState, Dispatch<Action>];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
const TOP_VIEWS = ['dashboard', 'health', 'secrets'];
|
|
3
|
+
export const initialState = {
|
|
4
|
+
currentView: 'dashboard',
|
|
5
|
+
previousView: null,
|
|
6
|
+
selectedApp: null,
|
|
7
|
+
selectedSecret: null,
|
|
8
|
+
redacted: false,
|
|
9
|
+
loading: false,
|
|
10
|
+
error: null,
|
|
11
|
+
confirmAction: null,
|
|
12
|
+
};
|
|
13
|
+
export function reducer(state, action) {
|
|
14
|
+
switch (action.type) {
|
|
15
|
+
case 'NAVIGATE':
|
|
16
|
+
return {
|
|
17
|
+
...state,
|
|
18
|
+
previousView: state.currentView,
|
|
19
|
+
currentView: action.view,
|
|
20
|
+
error: null,
|
|
21
|
+
confirmAction: null,
|
|
22
|
+
};
|
|
23
|
+
case 'GO_BACK':
|
|
24
|
+
return {
|
|
25
|
+
...state,
|
|
26
|
+
currentView: state.previousView ?? 'dashboard',
|
|
27
|
+
previousView: null,
|
|
28
|
+
selectedSecret: null,
|
|
29
|
+
error: null,
|
|
30
|
+
confirmAction: null,
|
|
31
|
+
};
|
|
32
|
+
case 'SELECT_APP':
|
|
33
|
+
return { ...state, selectedApp: action.app };
|
|
34
|
+
case 'SELECT_SECRET':
|
|
35
|
+
return { ...state, selectedSecret: action.key };
|
|
36
|
+
case 'SET_LOADING':
|
|
37
|
+
return { ...state, loading: action.loading };
|
|
38
|
+
case 'SET_ERROR':
|
|
39
|
+
return { ...state, error: action.error };
|
|
40
|
+
case 'TOGGLE_REDACT':
|
|
41
|
+
return { ...state, redacted: !state.redacted };
|
|
42
|
+
case 'CONFIRM':
|
|
43
|
+
return { ...state, confirmAction: action.action };
|
|
44
|
+
case 'CANCEL_CONFIRM':
|
|
45
|
+
return { ...state, confirmAction: null };
|
|
46
|
+
default:
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function nextTopView(current) {
|
|
51
|
+
const idx = TOP_VIEWS.indexOf(current);
|
|
52
|
+
if (idx === -1)
|
|
53
|
+
return 'dashboard';
|
|
54
|
+
return TOP_VIEWS[(idx + 1) % TOP_VIEWS.length];
|
|
55
|
+
}
|
|
56
|
+
// Redact utility — stable mapping of real names to "app-01", "app-02", etc.
|
|
57
|
+
const _redactMap = new Map();
|
|
58
|
+
let _redactCounter = 0;
|
|
59
|
+
export function redactName(name) {
|
|
60
|
+
let label = _redactMap.get(name);
|
|
61
|
+
if (!label) {
|
|
62
|
+
label = `app-${String(++_redactCounter).padStart(2, '0')}`;
|
|
63
|
+
_redactMap.set(name, label);
|
|
64
|
+
}
|
|
65
|
+
return label;
|
|
66
|
+
}
|
|
67
|
+
export function useRedact() {
|
|
68
|
+
const { redacted } = useAppState();
|
|
69
|
+
if (!redacted)
|
|
70
|
+
return (n) => n;
|
|
71
|
+
return redactName;
|
|
72
|
+
}
|
|
73
|
+
export const AppStateContext = createContext(initialState);
|
|
74
|
+
export const AppDispatchContext = createContext(() => { });
|
|
75
|
+
export function useAppState() {
|
|
76
|
+
return useContext(AppStateContext);
|
|
77
|
+
}
|
|
78
|
+
export function useAppDispatch() {
|
|
79
|
+
return useContext(AppDispatchContext);
|
|
80
|
+
}
|
|
81
|
+
export function useTui() {
|
|
82
|
+
return [useAppState(), useAppDispatch()];
|
|
83
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const colors: {
|
|
2
|
+
readonly primary: "cyan";
|
|
3
|
+
readonly success: "green";
|
|
4
|
+
readonly warning: "yellow";
|
|
5
|
+
readonly error: "red";
|
|
6
|
+
readonly info: "blue";
|
|
7
|
+
readonly muted: "gray";
|
|
8
|
+
readonly text: "white";
|
|
9
|
+
};
|
|
10
|
+
export declare const statusColor: Record<string, string>;
|
|
11
|
+
export declare const healthColor: Record<string, string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
primary: 'cyan',
|
|
3
|
+
success: 'green',
|
|
4
|
+
warning: 'yellow',
|
|
5
|
+
error: 'red',
|
|
6
|
+
info: 'blue',
|
|
7
|
+
muted: 'gray',
|
|
8
|
+
text: 'white',
|
|
9
|
+
};
|
|
10
|
+
export const statusColor = {
|
|
11
|
+
active: 'green',
|
|
12
|
+
inactive: 'red',
|
|
13
|
+
failed: 'red',
|
|
14
|
+
activating: 'yellow',
|
|
15
|
+
deactivating: 'yellow',
|
|
16
|
+
'n/a': 'gray',
|
|
17
|
+
};
|
|
18
|
+
export const healthColor = {
|
|
19
|
+
healthy: 'green',
|
|
20
|
+
degraded: 'yellow',
|
|
21
|
+
down: 'red',
|
|
22
|
+
unknown: 'gray',
|
|
23
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type View = 'dashboard' | 'app-detail' | 'health' | 'secrets' | 'secret-edit' | 'logs';
|
|
2
|
+
export interface TuiState {
|
|
3
|
+
currentView: View;
|
|
4
|
+
previousView: View | null;
|
|
5
|
+
selectedApp: string | null;
|
|
6
|
+
selectedSecret: string | null;
|
|
7
|
+
redacted: boolean;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string | null;
|
|
10
|
+
confirmAction: ConfirmAction | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ConfirmAction {
|
|
13
|
+
label: string;
|
|
14
|
+
description: string;
|
|
15
|
+
onConfirm: () => void;
|
|
16
|
+
}
|
|
17
|
+
export type Action = {
|
|
18
|
+
type: 'NAVIGATE';
|
|
19
|
+
view: View;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'GO_BACK';
|
|
22
|
+
} | {
|
|
23
|
+
type: 'SELECT_APP';
|
|
24
|
+
app: string;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'SELECT_SECRET';
|
|
27
|
+
key: string | null;
|
|
28
|
+
} | {
|
|
29
|
+
type: 'SET_LOADING';
|
|
30
|
+
loading: boolean;
|
|
31
|
+
} | {
|
|
32
|
+
type: 'SET_ERROR';
|
|
33
|
+
error: string | null;
|
|
34
|
+
} | {
|
|
35
|
+
type: 'TOGGLE_REDACT';
|
|
36
|
+
} | {
|
|
37
|
+
type: 'CONFIRM';
|
|
38
|
+
action: ConfirmAction;
|
|
39
|
+
} | {
|
|
40
|
+
type: 'CANCEL_CONFIRM';
|
|
41
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|