@matthesketh/fleet 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/dist/cli.js +8 -0
- package/dist/commands/deps.d.ts +1 -0
- package/dist/commands/deps.js +223 -0
- package/dist/commands/motd.d.ts +1 -0
- package/dist/commands/motd.js +10 -0
- package/dist/core/deps/actors/pr-creator.d.ts +14 -0
- package/dist/core/deps/actors/pr-creator.js +103 -0
- package/dist/core/deps/cache.d.ts +5 -0
- package/dist/core/deps/cache.js +28 -0
- package/dist/core/deps/collectors/composer.d.ts +12 -0
- package/dist/core/deps/collectors/composer.js +70 -0
- package/dist/core/deps/collectors/docker-image.d.ts +18 -0
- package/dist/core/deps/collectors/docker-image.js +132 -0
- package/dist/core/deps/collectors/docker-running.d.ts +17 -0
- package/dist/core/deps/collectors/docker-running.js +55 -0
- package/dist/core/deps/collectors/eol.d.ts +16 -0
- package/dist/core/deps/collectors/eol.js +139 -0
- package/dist/core/deps/collectors/github-pr.d.ts +8 -0
- package/dist/core/deps/collectors/github-pr.js +40 -0
- package/dist/core/deps/collectors/npm.d.ts +12 -0
- package/dist/core/deps/collectors/npm.js +63 -0
- package/dist/core/deps/collectors/pip.d.ts +15 -0
- package/dist/core/deps/collectors/pip.js +94 -0
- package/dist/core/deps/collectors/vulnerability.d.ts +9 -0
- package/dist/core/deps/collectors/vulnerability.js +102 -0
- package/dist/core/deps/config.d.ts +6 -0
- package/dist/core/deps/config.js +55 -0
- package/dist/core/deps/reporters/cli.d.ts +4 -0
- package/dist/core/deps/reporters/cli.js +123 -0
- package/dist/core/deps/reporters/motd.d.ts +3 -0
- package/dist/core/deps/reporters/motd.js +64 -0
- package/dist/core/deps/reporters/telegram.d.ts +6 -0
- package/dist/core/deps/reporters/telegram.js +106 -0
- package/dist/core/deps/scanner.d.ts +4 -0
- package/dist/core/deps/scanner.js +89 -0
- package/dist/core/deps/severity.d.ts +6 -0
- package/dist/core/deps/severity.js +45 -0
- package/dist/core/deps/types.d.ts +64 -0
- package/dist/core/deps/types.js +1 -0
- package/dist/mcp/deps-tools.d.ts +2 -0
- package/dist/mcp/deps-tools.js +81 -0
- package/dist/mcp/server.js +2 -0
- package/dist/templates/motd.d.ts +1 -0
- package/dist/templates/motd.js +7 -0
- package/dist/tui/components/AppList.js +1 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +4 -5
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +81 -9
- package/dist/tui/state.js +15 -0
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +117 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +13 -0
- package/dist/tui/views/AppDetail.js +41 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +36 -12
- package/dist/tui/views/LogsView.js +14 -9
- package/dist/tui/views/SecretEdit.js +8 -4
- package/dist/tui/views/SecretsView.js +49 -36
- package/package.json +17 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AppEntry } from '../registry.js';
|
|
2
|
+
export type CollectorType = 'npm' | 'composer' | 'pip' | 'docker-image' | 'docker-running' | 'eol' | 'vulnerability' | 'github-pr';
|
|
3
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
4
|
+
export type FindingCategory = 'outdated-dep' | 'image-update' | 'eol-warning' | 'vulnerability' | 'pending-pr';
|
|
5
|
+
export interface Finding {
|
|
6
|
+
appName: string;
|
|
7
|
+
source: CollectorType;
|
|
8
|
+
severity: Severity;
|
|
9
|
+
category: FindingCategory;
|
|
10
|
+
title: string;
|
|
11
|
+
detail: string;
|
|
12
|
+
package?: string;
|
|
13
|
+
currentVersion?: string;
|
|
14
|
+
latestVersion?: string;
|
|
15
|
+
eolDate?: string;
|
|
16
|
+
cveId?: string;
|
|
17
|
+
prUrl?: string;
|
|
18
|
+
fixable: boolean;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ScanError {
|
|
22
|
+
collector: CollectorType;
|
|
23
|
+
appName?: string;
|
|
24
|
+
message: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
export interface IgnoreRule {
|
|
28
|
+
appName?: string;
|
|
29
|
+
package?: string;
|
|
30
|
+
source?: CollectorType;
|
|
31
|
+
reason: string;
|
|
32
|
+
until?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface DepsConfig {
|
|
35
|
+
scanIntervalHours: number;
|
|
36
|
+
concurrency: number;
|
|
37
|
+
notifications: {
|
|
38
|
+
telegram: {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
chatId: string;
|
|
41
|
+
minSeverity: Severity;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
ignore: IgnoreRule[];
|
|
45
|
+
severityOverrides: {
|
|
46
|
+
eolDaysWarning: number;
|
|
47
|
+
majorVersionBehind: Severity;
|
|
48
|
+
minorVersionBehind: Severity;
|
|
49
|
+
patchVersionBehind: Severity;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface DepsCache {
|
|
53
|
+
version: 1;
|
|
54
|
+
lastScan: string;
|
|
55
|
+
scanDurationMs: number;
|
|
56
|
+
findings: Finding[];
|
|
57
|
+
errors: ScanError[];
|
|
58
|
+
config: DepsConfig;
|
|
59
|
+
}
|
|
60
|
+
export interface Collector {
|
|
61
|
+
type: CollectorType;
|
|
62
|
+
detect(appPath: string): boolean;
|
|
63
|
+
collect(app: AppEntry): Promise<Finding[]>;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { load, findApp } from '../core/registry.js';
|
|
3
|
+
import { loadConfig, saveConfig } from '../core/deps/config.js';
|
|
4
|
+
import { loadCache, saveCache } from '../core/deps/cache.js';
|
|
5
|
+
import { runScan } from '../core/deps/scanner.js';
|
|
6
|
+
import { createDepsPr } from '../core/deps/actors/pr-creator.js';
|
|
7
|
+
import { AppNotFoundError } from '../core/errors.js';
|
|
8
|
+
function text(msg) {
|
|
9
|
+
return { content: [{ type: 'text', text: msg }] };
|
|
10
|
+
}
|
|
11
|
+
export function registerDepsTools(server) {
|
|
12
|
+
server.tool('fleet_deps_status', 'Dependency health summary from cache — outdated packages, CVEs, EOL warnings, Docker image updates', async () => {
|
|
13
|
+
const cache = loadCache();
|
|
14
|
+
if (!cache)
|
|
15
|
+
return text('No scan data. Run fleet deps scan first.');
|
|
16
|
+
return text(JSON.stringify(cache, null, 2));
|
|
17
|
+
});
|
|
18
|
+
server.tool('fleet_deps_scan', 'Run a fresh dependency scan across all registered apps', async () => {
|
|
19
|
+
const reg = load();
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const cache = await runScan(reg.apps, config);
|
|
22
|
+
saveCache(cache);
|
|
23
|
+
return text(JSON.stringify({
|
|
24
|
+
findings: cache.findings.length,
|
|
25
|
+
errors: cache.errors.length,
|
|
26
|
+
duration: cache.scanDurationMs,
|
|
27
|
+
apps: reg.apps.length,
|
|
28
|
+
}, null, 2));
|
|
29
|
+
});
|
|
30
|
+
server.tool('fleet_deps_app', 'Dependency findings for a specific app', { app: z.string().describe('App name') }, async ({ app }) => {
|
|
31
|
+
const cache = loadCache();
|
|
32
|
+
if (!cache)
|
|
33
|
+
return text('No scan data. Run fleet deps scan first.');
|
|
34
|
+
const reg = load();
|
|
35
|
+
const entry = findApp(reg, app);
|
|
36
|
+
if (!entry)
|
|
37
|
+
throw new AppNotFoundError(app);
|
|
38
|
+
const findings = cache.findings.filter(f => f.appName === entry.name);
|
|
39
|
+
return text(JSON.stringify(findings, null, 2));
|
|
40
|
+
});
|
|
41
|
+
server.tool('fleet_deps_fix', 'Create a PR with dependency updates for an app (dry-run by default)', {
|
|
42
|
+
app: z.string().describe('App name'),
|
|
43
|
+
dryRun: z.boolean().default(true).describe('Preview changes without creating PR'),
|
|
44
|
+
}, async ({ app, dryRun }) => {
|
|
45
|
+
const reg = load();
|
|
46
|
+
const entry = findApp(reg, app);
|
|
47
|
+
if (!entry)
|
|
48
|
+
throw new AppNotFoundError(app);
|
|
49
|
+
const cache = loadCache();
|
|
50
|
+
if (!cache)
|
|
51
|
+
return text('No scan data. Run fleet deps scan first.');
|
|
52
|
+
const findings = cache.findings.filter(f => f.appName === entry.name && f.fixable);
|
|
53
|
+
const result = createDepsPr(entry, findings, dryRun);
|
|
54
|
+
return text(JSON.stringify(result, null, 2));
|
|
55
|
+
});
|
|
56
|
+
server.tool('fleet_deps_ignore', 'Add an ignore rule for a dependency finding', {
|
|
57
|
+
package: z.string().describe('Package name to ignore'),
|
|
58
|
+
reason: z.string().describe('Why this is being ignored'),
|
|
59
|
+
app: z.string().optional().describe('Limit to specific app'),
|
|
60
|
+
until: z.string().optional().describe('Auto-expire date (YYYY-MM-DD)'),
|
|
61
|
+
}, async (params) => {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
config.ignore.push({
|
|
64
|
+
package: params.package, reason: params.reason,
|
|
65
|
+
...(params.app && { appName: params.app }),
|
|
66
|
+
...(params.until && { until: params.until }),
|
|
67
|
+
});
|
|
68
|
+
saveConfig(config);
|
|
69
|
+
return text(`Ignoring ${params.package}: ${params.reason}`);
|
|
70
|
+
});
|
|
71
|
+
server.tool('fleet_deps_config', 'Get or set dependency monitoring configuration', { key: z.string().optional(), value: z.string().optional() }, async ({ key, value }) => {
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
if (!key)
|
|
74
|
+
return text(JSON.stringify(config, null, 2));
|
|
75
|
+
if (!value)
|
|
76
|
+
return text(JSON.stringify(config[key], null, 2));
|
|
77
|
+
config[key] = value === 'true' ? true : value === 'false' ? false : isNaN(Number(value)) ? value : Number(value);
|
|
78
|
+
saveConfig(config);
|
|
79
|
+
return text(`Set ${key} = ${value}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { unsealAll, getStatus as getSecretsStatus } from '../core/secrets-ops.js
|
|
|
16
16
|
import { validateApp, validateAll } from '../core/secrets-validate.js';
|
|
17
17
|
import { registerGitTools } from './git-tools.js';
|
|
18
18
|
import { registerSecretsTools } from './secrets-tools.js';
|
|
19
|
+
import { registerDepsTools } from './deps-tools.js';
|
|
19
20
|
function requireApp(name) {
|
|
20
21
|
const reg = load();
|
|
21
22
|
const app = findApp(reg, name);
|
|
@@ -174,6 +175,7 @@ export async function startMcpServer() {
|
|
|
174
175
|
});
|
|
175
176
|
registerGitTools(server);
|
|
176
177
|
registerSecretsTools(server);
|
|
178
|
+
registerDepsTools(server);
|
|
177
179
|
const transport = new StdioServerTransport();
|
|
178
180
|
await server.connect(transport);
|
|
179
181
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateMotdScript(): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function generateMotdScript() {
|
|
2
|
+
return `#!/bin/bash
|
|
3
|
+
# Fleet service health check — installed by "fleet motd install"
|
|
4
|
+
# Shows service status on SSH login
|
|
5
|
+
/usr/bin/node /home/matt/fleet/dist/index.js watchdog --motd 2>/dev/null || echo " Fleet: health check failed to run"
|
|
6
|
+
`;
|
|
7
|
+
}
|
|
@@ -25,7 +25,7 @@ export function AppList({ items, onSelect, renderItem }) {
|
|
|
25
25
|
return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => {
|
|
26
26
|
const selected = i === selectedIndex;
|
|
27
27
|
if (renderItem) {
|
|
28
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
|
|
29
29
|
}
|
|
30
30
|
return (_jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: [selected ? '> ' : ' ', item.label ?? item.name] }, item.name));
|
|
31
31
|
}) }));
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { Modal } from '@matthesketh/ink-modal';
|
|
3
4
|
import { useAppState } from '../state.js';
|
|
4
5
|
import { colors } from '../theme.js';
|
|
5
6
|
export function Confirm() {
|
|
6
7
|
const { confirmAction } = useAppState();
|
|
7
|
-
|
|
8
|
-
return null;
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.warning, paddingX: 2, paddingY: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: colors.warning, children: confirmAction.label }), _jsx(Text, { color: colors.muted, children: confirmAction.description }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.success, children: "y" }), " confirm"] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.error, children: "n" }), " cancel"] })] })] }));
|
|
8
|
+
return (_jsx(Modal, { visible: confirmAction !== null, title: confirmAction?.label, borderColor: colors.warning, width: 50, footer: _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.success, children: "y" }), " confirm"] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.error, children: "n" }), " cancel"] })] }), children: _jsx(Text, { color: colors.muted, children: confirmAction?.description ?? '' }) }));
|
|
10
9
|
}
|
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { Tabs } from '@matthesketh/ink-tabs';
|
|
4
|
+
import { Breadcrumb } from '@matthesketh/ink-breadcrumb';
|
|
3
5
|
import { useAppState } from '../state.js';
|
|
4
6
|
import { colors } from '../theme.js';
|
|
5
|
-
const
|
|
6
|
-
{
|
|
7
|
-
{
|
|
8
|
-
{
|
|
7
|
+
const TAB_ITEMS = [
|
|
8
|
+
{ id: 'dashboard', label: 'Dashboard' },
|
|
9
|
+
{ id: 'health', label: 'Health' },
|
|
10
|
+
{ id: 'secrets', label: 'Secrets' },
|
|
9
11
|
];
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const TOP_VIEWS = new Set(['dashboard', 'health', 'secrets']);
|
|
13
|
+
function resolveActiveTab(view, previousView) {
|
|
14
|
+
if (TOP_VIEWS.has(view))
|
|
15
|
+
return view;
|
|
16
|
+
if (view === 'app-detail' || view === 'logs')
|
|
17
|
+
return 'dashboard';
|
|
18
|
+
if (view === 'secret-edit')
|
|
19
|
+
return 'secrets';
|
|
20
|
+
return previousView ?? 'dashboard';
|
|
21
|
+
}
|
|
22
|
+
function buildBreadcrumb(view, selectedApp) {
|
|
23
|
+
switch (view) {
|
|
24
|
+
case 'dashboard':
|
|
25
|
+
return ['Dashboard'];
|
|
26
|
+
case 'health':
|
|
27
|
+
return ['Health'];
|
|
28
|
+
case 'secrets':
|
|
29
|
+
return ['Secrets'];
|
|
30
|
+
case 'app-detail':
|
|
31
|
+
return ['Dashboard', selectedApp ?? '...'];
|
|
32
|
+
case 'logs':
|
|
33
|
+
return ['Dashboard', selectedApp ?? '...', 'Logs'];
|
|
34
|
+
case 'secret-edit':
|
|
35
|
+
return ['Secrets', selectedApp ?? '...', 'Edit'];
|
|
36
|
+
default:
|
|
37
|
+
return ['Dashboard'];
|
|
38
|
+
}
|
|
12
39
|
}
|
|
13
40
|
export function Header({ vaultSealed }) {
|
|
14
|
-
const { currentView, redacted } = useAppState();
|
|
15
|
-
|
|
41
|
+
const { currentView, previousView, selectedApp, redacted } = useAppState();
|
|
42
|
+
const activeTab = resolveActiveTab(currentView, previousView);
|
|
43
|
+
const breadcrumb = buildBreadcrumb(currentView, selectedApp);
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderBottom: true, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, alignItems: "center", children: [_jsx(Text, { bold: true, color: colors.primary, children: "Fleet" }), _jsx(Tabs, { tabs: TAB_ITEMS, activeId: activeTab, accentColor: colors.primary })] }), _jsxs(Box, { gap: 1, children: [redacted && _jsx(Text, { color: "magenta", bold: true, children: "[REDACTED]" }), _jsx(Text, { color: vaultSealed ? colors.warning : colors.success, children: vaultSealed ? '[SEALED]' : '[UNSEALED]' })] })] }), breadcrumb.length > 1 && (_jsx(Box, { paddingX: 1, children: _jsx(Breadcrumb, { path: breadcrumb, activeColor: colors.primary }) }))] }));
|
|
16
45
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { StatusBar } from '@matthesketh/ink-status-bar';
|
|
3
3
|
import { useAppState } from '../state.js';
|
|
4
|
-
import { colors } from '../theme.js';
|
|
5
4
|
const viewHints = {
|
|
6
5
|
dashboard: [
|
|
7
6
|
{ key: 'j/k', label: 'navigate' },
|
|
8
7
|
{ key: 'Enter', label: 'select' },
|
|
9
8
|
{ key: 'Tab', label: 'switch view' },
|
|
9
|
+
{ key: '?', label: 'help' },
|
|
10
10
|
{ key: 'x', label: 'redact' },
|
|
11
11
|
{ key: 'q', label: 'quit' },
|
|
12
12
|
],
|
|
@@ -31,7 +31,6 @@ const viewHints = {
|
|
|
31
31
|
{ key: 'a', label: 'add' },
|
|
32
32
|
{ key: 'd', label: 'delete' },
|
|
33
33
|
{ key: 'r', label: 'reveal' },
|
|
34
|
-
{ key: 'x', label: 'redact' },
|
|
35
34
|
{ key: 'Esc', label: 'back' },
|
|
36
35
|
{ key: 'q', label: 'quit' },
|
|
37
36
|
],
|
|
@@ -51,5 +50,5 @@ export function KeyHint() {
|
|
|
51
50
|
const hints = confirmAction
|
|
52
51
|
? [{ key: 'y', label: 'confirm' }, { key: 'n', label: 'cancel' }]
|
|
53
52
|
: viewHints[currentView] ?? [];
|
|
54
|
-
return
|
|
53
|
+
return _jsx(StatusBar, { items: hints });
|
|
55
54
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useTerminalSize, useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useTerminalSize, useAvailableHeight } from '@matthesketh/ink-viewport';
|
package/dist/tui/router.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useReducer, useState, useEffect } from 'react';
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useReducer, useState, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
|
|
5
|
+
import { Viewport } from '@matthesketh/ink-viewport';
|
|
6
|
+
import { ToastProvider } from '@matthesketh/ink-toast';
|
|
7
|
+
import { ToastContainer } from '@matthesketh/ink-toast';
|
|
8
|
+
import { KeyBindingHelp } from '@matthesketh/ink-keybinding-help';
|
|
9
|
+
import { reducer, initialState, AppStateContext, AppDispatchContext, nextTopView } from './state.js';
|
|
6
10
|
import { Header } from './components/Header.js';
|
|
7
11
|
import { KeyHint } from './components/KeyHint.js';
|
|
8
12
|
import { Confirm } from './components/Confirm.js';
|
|
@@ -13,6 +17,35 @@ import { SecretEdit } from './views/SecretEdit.js';
|
|
|
13
17
|
import { HealthView } from './views/HealthView.js';
|
|
14
18
|
import { LogsView } from './views/LogsView.js';
|
|
15
19
|
import { isSealed, isInitialized } from '../core/secrets.js';
|
|
20
|
+
const HELP_GROUPS = [
|
|
21
|
+
{
|
|
22
|
+
title: 'Navigation',
|
|
23
|
+
bindings: [
|
|
24
|
+
{ key: 'j/k', description: 'move up/down' },
|
|
25
|
+
{ key: 'Enter', description: 'select / confirm' },
|
|
26
|
+
{ key: 'Tab', description: 'switch view' },
|
|
27
|
+
{ key: 'Esc', description: 'go back' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Actions',
|
|
32
|
+
bindings: [
|
|
33
|
+
{ key: 'x', description: 'toggle redaction' },
|
|
34
|
+
{ key: 'f', description: 'follow logs' },
|
|
35
|
+
{ key: 'q', description: 'quit' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: 'Secrets',
|
|
40
|
+
bindings: [
|
|
41
|
+
{ key: 'u', description: 'unseal vault' },
|
|
42
|
+
{ key: 'l', description: 'seal vault' },
|
|
43
|
+
{ key: 'a', description: 'add secret' },
|
|
44
|
+
{ key: 'd', description: 'delete secret' },
|
|
45
|
+
{ key: 'r', description: 'reveal / hide' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
16
49
|
function ViewRouter() {
|
|
17
50
|
const state = React.useContext(AppStateContext);
|
|
18
51
|
switch (state.currentView) {
|
|
@@ -32,13 +65,11 @@ function ViewRouter() {
|
|
|
32
65
|
return _jsx(Dashboard, {});
|
|
33
66
|
}
|
|
34
67
|
}
|
|
35
|
-
|
|
36
|
-
useKeyboard();
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
68
|
+
const CHROME_ROWS = 6;
|
|
39
69
|
export function App() {
|
|
40
70
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
41
71
|
const [vaultSealed, setVaultSealed] = useState(true);
|
|
72
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
42
73
|
useEffect(() => {
|
|
43
74
|
try {
|
|
44
75
|
if (isInitialized()) {
|
|
@@ -61,5 +92,46 @@ export function App() {
|
|
|
61
92
|
}, 5000);
|
|
62
93
|
return () => clearInterval(interval);
|
|
63
94
|
}, []);
|
|
64
|
-
|
|
95
|
+
const globalHandler = useCallback((input, key) => {
|
|
96
|
+
if (showHelp) {
|
|
97
|
+
setShowHelp(false);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (state.confirmAction) {
|
|
101
|
+
if (input === 'y' || input === 'Y') {
|
|
102
|
+
state.confirmAction.onConfirm();
|
|
103
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
104
|
+
}
|
|
105
|
+
else if (input === 'n' || input === 'N' || key.escape) {
|
|
106
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (input === '?' && state.currentView !== 'secret-edit') {
|
|
111
|
+
setShowHelp(true);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (input === 'q' && state.currentView !== 'secret-edit') {
|
|
115
|
+
process.exit(0);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (input === 'x' && state.currentView !== 'secret-edit') {
|
|
119
|
+
dispatch({ type: 'TOGGLE_REDACT' });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (key.tab) {
|
|
123
|
+
const topViews = ['dashboard', 'health', 'secrets'];
|
|
124
|
+
const base = topViews.includes(state.currentView)
|
|
125
|
+
? state.currentView
|
|
126
|
+
: state.previousView ?? 'dashboard';
|
|
127
|
+
dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (key.escape && state.previousView) {
|
|
131
|
+
dispatch({ type: 'GO_BACK' });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}, [state.confirmAction, state.currentView, state.previousView, showHelp]);
|
|
136
|
+
return (_jsx(AppStateContext.Provider, { value: state, children: _jsx(AppDispatchContext.Provider, { value: dispatch, children: _jsx(ToastProvider, { children: _jsx(InputDispatcher, { globalHandler: globalHandler, children: _jsxs(Viewport, { chrome: CHROME_ROWS, children: [_jsx(Header, { vaultSealed: vaultSealed }), _jsx(Box, { flexGrow: 1, flexDirection: "column", children: showHelp ? (_jsx(KeyBindingHelp, { groups: HELP_GROUPS, title: "Fleet TUI \u2014 Keyboard Shortcuts" })) : (_jsxs(_Fragment, { 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(ToastContainer, {}), _jsx(KeyHint, {})] }) }) }) }) }));
|
|
65
137
|
}
|
package/dist/tui/state.js
CHANGED
|
@@ -9,6 +9,11 @@ export const initialState = {
|
|
|
9
9
|
loading: false,
|
|
10
10
|
error: null,
|
|
11
11
|
confirmAction: null,
|
|
12
|
+
dashboardIndex: 0,
|
|
13
|
+
healthIndex: 0,
|
|
14
|
+
secretsIndex: 0,
|
|
15
|
+
secretsSubView: 'app-list',
|
|
16
|
+
appDetailIndex: 0,
|
|
12
17
|
};
|
|
13
18
|
export function reducer(state, action) {
|
|
14
19
|
switch (action.type) {
|
|
@@ -26,6 +31,7 @@ export function reducer(state, action) {
|
|
|
26
31
|
currentView: state.previousView ?? 'dashboard',
|
|
27
32
|
previousView: null,
|
|
28
33
|
selectedSecret: null,
|
|
34
|
+
secretsSubView: 'app-list',
|
|
29
35
|
error: null,
|
|
30
36
|
confirmAction: null,
|
|
31
37
|
};
|
|
@@ -43,6 +49,15 @@ export function reducer(state, action) {
|
|
|
43
49
|
return { ...state, confirmAction: action.action };
|
|
44
50
|
case 'CANCEL_CONFIRM':
|
|
45
51
|
return { ...state, confirmAction: null };
|
|
52
|
+
case 'SET_INDEX': {
|
|
53
|
+
const key = `${action.view}Index`;
|
|
54
|
+
if (key in state) {
|
|
55
|
+
return { ...state, [key]: action.index };
|
|
56
|
+
}
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
case 'SET_SECRETS_SUBVIEW':
|
|
60
|
+
return { ...state, secretsSubView: action.subView, secretsIndex: 0 };
|
|
46
61
|
default:
|
|
47
62
|
return state;
|
|
48
63
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { TestApp } from './test-app.js';
|
|
5
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const apps = ['app-alpha', 'app-bravo', 'app-charlie'];
|
|
7
|
+
describe('no-flicker guarantees', () => {
|
|
8
|
+
it('tab switch produces no intermediate frames', async () => {
|
|
9
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
10
|
+
await delay(100);
|
|
11
|
+
const beforeCount = frames.length;
|
|
12
|
+
stdin.write('\t');
|
|
13
|
+
await delay(50);
|
|
14
|
+
// grab only the frames produced by this action
|
|
15
|
+
const newFrames = frames.slice(beforeCount);
|
|
16
|
+
// every new frame must show the destination view, never a blank or half-state
|
|
17
|
+
for (const frame of newFrames) {
|
|
18
|
+
expect(frame).toContain('view:');
|
|
19
|
+
// should not show a frame with view:dashboard after we switched to health
|
|
20
|
+
expect(frame).toContain('view:health');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
it('enter on dashboard does not flash dashboard before showing detail', async () => {
|
|
24
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
25
|
+
await delay(100);
|
|
26
|
+
stdin.write('j');
|
|
27
|
+
await delay(50);
|
|
28
|
+
const beforeCount = frames.length;
|
|
29
|
+
stdin.write('\r');
|
|
30
|
+
await delay(50);
|
|
31
|
+
const newFrames = frames.slice(beforeCount);
|
|
32
|
+
// should have at least one frame with the detail view
|
|
33
|
+
const detailFrames = newFrames.filter(f => f.includes('view:app-detail'));
|
|
34
|
+
expect(detailFrames.length).toBeGreaterThan(0);
|
|
35
|
+
// no frame should show view:dashboard after the enter press
|
|
36
|
+
// (which would indicate SELECT_APP rendered before NAVIGATE)
|
|
37
|
+
for (const frame of newFrames) {
|
|
38
|
+
if (frame.includes('view:')) {
|
|
39
|
+
expect(frame).toContain('view:app-detail');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it('arrow key navigation produces exactly one visual change', async () => {
|
|
44
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
45
|
+
await delay(100);
|
|
46
|
+
const beforeCount = frames.length;
|
|
47
|
+
stdin.write('\x1B[B'); // down arrow
|
|
48
|
+
await delay(50);
|
|
49
|
+
const newFrames = frames.slice(beforeCount);
|
|
50
|
+
// all new frames should show the cursor on app-bravo
|
|
51
|
+
for (const frame of newFrames) {
|
|
52
|
+
expect(frame).toContain('> app-bravo');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('escape from sub-view does not flash intermediate state', async () => {
|
|
56
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
57
|
+
await delay(100);
|
|
58
|
+
// go to detail
|
|
59
|
+
stdin.write('\r');
|
|
60
|
+
await delay(50);
|
|
61
|
+
expect(frames[frames.length - 1]).toContain('view:app-detail');
|
|
62
|
+
const beforeCount = frames.length;
|
|
63
|
+
stdin.write('\x1B');
|
|
64
|
+
await delay(50);
|
|
65
|
+
const newFrames = frames.slice(beforeCount);
|
|
66
|
+
for (const frame of newFrames) {
|
|
67
|
+
expect(frame).toContain('view:dashboard');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('rapid key presses do not produce garbled frames', async () => {
|
|
71
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
72
|
+
await delay(100);
|
|
73
|
+
// rapid j-j-j
|
|
74
|
+
stdin.write('j');
|
|
75
|
+
stdin.write('j');
|
|
76
|
+
stdin.write('j');
|
|
77
|
+
await delay(100);
|
|
78
|
+
const lastFrame = frames[frames.length - 1];
|
|
79
|
+
// should be clamped at the last item
|
|
80
|
+
expect(lastFrame).toContain('> app-charlie');
|
|
81
|
+
// should still show the view label
|
|
82
|
+
expect(lastFrame).toContain('view:dashboard');
|
|
83
|
+
});
|
|
84
|
+
it('help overlay toggle produces no blank frames', async () => {
|
|
85
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
86
|
+
await delay(100);
|
|
87
|
+
const beforeCount = frames.length;
|
|
88
|
+
stdin.write('?');
|
|
89
|
+
await delay(50);
|
|
90
|
+
const helpFrames = frames.slice(beforeCount);
|
|
91
|
+
for (const frame of helpFrames) {
|
|
92
|
+
expect(frame).toContain('view:dashboard');
|
|
93
|
+
expect(frame).toContain('help-overlay');
|
|
94
|
+
}
|
|
95
|
+
const beforeDismiss = frames.length;
|
|
96
|
+
stdin.write('x');
|
|
97
|
+
await delay(50);
|
|
98
|
+
const dismissFrames = frames.slice(beforeDismiss);
|
|
99
|
+
for (const frame of dismissFrames) {
|
|
100
|
+
expect(frame).toContain('view:dashboard');
|
|
101
|
+
// help should be gone
|
|
102
|
+
expect(frame).not.toContain('help-overlay');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|