@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,179 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { getStatusData } from '../commands/status.js';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { load, findApp, save, addApp } from '../core/registry.js';
|
|
7
|
+
import { startService, stopService, restartService } from '../core/systemd.js';
|
|
8
|
+
import { getContainerLogs, getContainersByCompose } from '../core/docker.js';
|
|
9
|
+
import { checkHealth, checkAllHealth } from '../core/health.js';
|
|
10
|
+
import { listSites, installConfig, testConfig, reload, removeConfig } from '../core/nginx.js';
|
|
11
|
+
import { generateNginxConfig } from '../templates/nginx.js';
|
|
12
|
+
import { composeBuild } from '../core/docker.js';
|
|
13
|
+
import { AppNotFoundError } from '../core/errors.js';
|
|
14
|
+
import { loadManifest, listSecrets, isInitialized } from '../core/secrets.js';
|
|
15
|
+
import { unsealAll, getStatus as getSecretsStatus } from '../core/secrets-ops.js';
|
|
16
|
+
import { validateApp, validateAll } from '../core/secrets-validate.js';
|
|
17
|
+
import { registerGitTools } from './git-tools.js';
|
|
18
|
+
import { registerSecretsTools } from './secrets-tools.js';
|
|
19
|
+
function requireApp(name) {
|
|
20
|
+
const reg = load();
|
|
21
|
+
const app = findApp(reg, name);
|
|
22
|
+
if (!app)
|
|
23
|
+
throw new AppNotFoundError(name);
|
|
24
|
+
return app;
|
|
25
|
+
}
|
|
26
|
+
function text(msg) {
|
|
27
|
+
return { content: [{ type: 'text', text: msg }] };
|
|
28
|
+
}
|
|
29
|
+
export async function startMcpServer() {
|
|
30
|
+
const server = new McpServer({
|
|
31
|
+
name: 'fleet',
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
});
|
|
34
|
+
server.tool('fleet_status', 'Dashboard data for all apps: systemd state, containers, health', async () => {
|
|
35
|
+
const data = getStatusData();
|
|
36
|
+
return text(JSON.stringify(data, null, 2));
|
|
37
|
+
});
|
|
38
|
+
server.tool('fleet_list', 'List all registered apps with their configuration', async () => {
|
|
39
|
+
const reg = load();
|
|
40
|
+
return text(JSON.stringify(reg.apps, null, 2));
|
|
41
|
+
});
|
|
42
|
+
server.tool('fleet_start', 'Start an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
|
|
43
|
+
const entry = requireApp(app);
|
|
44
|
+
const ok = startService(entry.serviceName);
|
|
45
|
+
return text(ok ? `Started ${entry.name}` : `Failed to start ${entry.name}`);
|
|
46
|
+
});
|
|
47
|
+
server.tool('fleet_stop', 'Stop an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
|
|
48
|
+
const entry = requireApp(app);
|
|
49
|
+
const ok = stopService(entry.serviceName);
|
|
50
|
+
return text(ok ? `Stopped ${entry.name}` : `Failed to stop ${entry.name}`);
|
|
51
|
+
});
|
|
52
|
+
server.tool('fleet_restart', 'Restart an app via systemctl', { app: z.string().describe('App name') }, async ({ app }) => {
|
|
53
|
+
const entry = requireApp(app);
|
|
54
|
+
const ok = restartService(entry.serviceName);
|
|
55
|
+
return text(ok ? `Restarted ${entry.name}` : `Failed to restart ${entry.name}`);
|
|
56
|
+
});
|
|
57
|
+
server.tool('fleet_logs', 'Get recent container logs for an app', {
|
|
58
|
+
app: z.string().describe('App name'),
|
|
59
|
+
lines: z.number().optional().default(100).describe('Number of log lines'),
|
|
60
|
+
}, async ({ app, lines }) => {
|
|
61
|
+
const entry = requireApp(app);
|
|
62
|
+
const container = entry.containers[0];
|
|
63
|
+
if (!container)
|
|
64
|
+
return text('No containers registered');
|
|
65
|
+
const logs = getContainerLogs(container, lines);
|
|
66
|
+
return text(logs);
|
|
67
|
+
});
|
|
68
|
+
server.tool('fleet_health', 'Run health checks for one or all apps', { app: z.string().optional().describe('App name (omit for all apps)') }, async ({ app }) => {
|
|
69
|
+
const reg = load();
|
|
70
|
+
if (app) {
|
|
71
|
+
const entry = findApp(reg, app);
|
|
72
|
+
if (!entry)
|
|
73
|
+
throw new AppNotFoundError(app);
|
|
74
|
+
const result = checkHealth(entry);
|
|
75
|
+
return text(JSON.stringify(result, null, 2));
|
|
76
|
+
}
|
|
77
|
+
const results = checkAllHealth(reg.apps);
|
|
78
|
+
return text(JSON.stringify(results, null, 2));
|
|
79
|
+
});
|
|
80
|
+
server.tool('fleet_deploy', 'Deploy an app: build and restart', { app: z.string().describe('App name') }, async ({ app }) => {
|
|
81
|
+
const entry = requireApp(app);
|
|
82
|
+
const buildOk = composeBuild(entry.composePath, entry.composeFile, entry.name);
|
|
83
|
+
if (!buildOk)
|
|
84
|
+
return text(`Build failed for ${entry.name}`);
|
|
85
|
+
const ok = restartService(entry.serviceName);
|
|
86
|
+
return text(ok ? `Deployed ${entry.name}` : `Deploy failed for ${entry.name}`);
|
|
87
|
+
});
|
|
88
|
+
server.tool('fleet_nginx_add', 'Create an nginx config for a domain', {
|
|
89
|
+
domain: z.string().describe('Domain name'),
|
|
90
|
+
port: z.number().describe('Backend port'),
|
|
91
|
+
type: z.enum(['proxy', 'spa', 'nextjs']).optional().default('proxy').describe('Config type'),
|
|
92
|
+
}, async ({ domain, port, type }) => {
|
|
93
|
+
const config = generateNginxConfig({ domain, port, type });
|
|
94
|
+
installConfig(domain, config);
|
|
95
|
+
const test = testConfig();
|
|
96
|
+
if (!test.ok) {
|
|
97
|
+
removeConfig(domain);
|
|
98
|
+
return text(`Config test failed: ${test.output}`);
|
|
99
|
+
}
|
|
100
|
+
reload();
|
|
101
|
+
return text(`Created and activated nginx config for ${domain}`);
|
|
102
|
+
});
|
|
103
|
+
server.tool('fleet_nginx_list', 'List all nginx site configs', async () => {
|
|
104
|
+
const sites = listSites();
|
|
105
|
+
return text(JSON.stringify(sites, null, 2));
|
|
106
|
+
});
|
|
107
|
+
server.tool('fleet_secrets_status', 'Show vault initialisation state, sealed/unsealed, counts. The vault is the encrypted source of truth that survives reboots. Runtime (/run/fleet-secrets/) is the decrypted copy used by apps — it is lost on reboot.', async () => {
|
|
108
|
+
const status = getSecretsStatus();
|
|
109
|
+
return text(JSON.stringify(status, null, 2));
|
|
110
|
+
});
|
|
111
|
+
server.tool('fleet_secrets_list', 'List managed secrets for an app (masked values). Shows vault contents — use fleet_secrets_drift to check if runtime differs.', { app: z.string().optional().describe('App name (omit for all apps)') }, async ({ app }) => {
|
|
112
|
+
if (!isInitialized())
|
|
113
|
+
return text('Vault not initialised');
|
|
114
|
+
if (app) {
|
|
115
|
+
const secrets = listSecrets(app);
|
|
116
|
+
return text(JSON.stringify(secrets, null, 2));
|
|
117
|
+
}
|
|
118
|
+
const manifest = loadManifest();
|
|
119
|
+
return text(JSON.stringify(manifest.apps, null, 2));
|
|
120
|
+
});
|
|
121
|
+
server.tool('fleet_secrets_unseal', 'Decrypt vault to /run/fleet-secrets/. WARNING: This overwrites any runtime changes that were not sealed back to the vault. Use fleet_secrets_drift first to check for unsaved changes.', async () => {
|
|
122
|
+
if (!isInitialized())
|
|
123
|
+
return text('Vault not initialised');
|
|
124
|
+
unsealAll();
|
|
125
|
+
return text('Unsealed all secrets to /run/fleet-secrets/');
|
|
126
|
+
});
|
|
127
|
+
server.tool('fleet_secrets_validate', 'Validate compose secrets match vault. Returns missing/extra secrets per app. This checks that docker-compose secret references have matching entries in the vault.', { app: z.string().optional().describe('App name (omit for all apps)') }, async ({ app }) => {
|
|
128
|
+
if (!isInitialized())
|
|
129
|
+
return text('Vault not initialised');
|
|
130
|
+
const results = app ? [validateApp(app)] : validateAll();
|
|
131
|
+
return text(JSON.stringify(results, null, 2));
|
|
132
|
+
});
|
|
133
|
+
server.tool('fleet_register', 'Register a new app in the fleet registry', {
|
|
134
|
+
name: z.string().describe('App name (kebab-case identifier)'),
|
|
135
|
+
composePath: z.string().describe('Absolute path to docker-compose directory'),
|
|
136
|
+
displayName: z.string().optional().describe('Human-friendly name'),
|
|
137
|
+
composeFile: z.string().optional().describe('Custom compose filename'),
|
|
138
|
+
serviceName: z.string().optional().describe('Systemd service name'),
|
|
139
|
+
domains: z.array(z.string()).optional().default([]).describe('Domain names'),
|
|
140
|
+
port: z.number().optional().describe('Backend port'),
|
|
141
|
+
type: z.enum(['proxy', 'spa', 'nextjs', 'service']).optional().default('service').describe('App type'),
|
|
142
|
+
containers: z.array(z.string()).optional().describe('Container names (auto-detected if omitted)'),
|
|
143
|
+
usesSharedDb: z.boolean().optional().default(false).describe('Uses shared database'),
|
|
144
|
+
dependsOnDatabases: z.boolean().optional().default(false).describe('Depends on docker-databases'),
|
|
145
|
+
}, async (params) => {
|
|
146
|
+
if (!existsSync(params.composePath)) {
|
|
147
|
+
return text(`Error: composePath does not exist: ${params.composePath}`);
|
|
148
|
+
}
|
|
149
|
+
const reg = load();
|
|
150
|
+
const existing = findApp(reg, params.name);
|
|
151
|
+
let containers = params.containers;
|
|
152
|
+
if (!containers || containers.length === 0) {
|
|
153
|
+
containers = getContainersByCompose(params.composePath, params.composeFile ?? null);
|
|
154
|
+
if (containers.length === 0)
|
|
155
|
+
containers = [params.name];
|
|
156
|
+
}
|
|
157
|
+
const entry = {
|
|
158
|
+
name: params.name,
|
|
159
|
+
displayName: params.displayName ?? params.name,
|
|
160
|
+
composePath: params.composePath,
|
|
161
|
+
composeFile: params.composeFile ?? null,
|
|
162
|
+
serviceName: params.serviceName ?? params.name,
|
|
163
|
+
domains: params.domains,
|
|
164
|
+
port: params.port ?? null,
|
|
165
|
+
type: params.type,
|
|
166
|
+
containers,
|
|
167
|
+
usesSharedDb: params.usesSharedDb,
|
|
168
|
+
dependsOnDatabases: params.dependsOnDatabases,
|
|
169
|
+
registeredAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
save(addApp(reg, entry));
|
|
172
|
+
const action = existing ? 'Updated' : 'Registered';
|
|
173
|
+
return text(`${action} app "${params.name}":\n${JSON.stringify(entry, null, 2)}`);
|
|
174
|
+
});
|
|
175
|
+
registerGitTools(server);
|
|
176
|
+
registerSecretsTools(server);
|
|
177
|
+
const transport = new StdioServerTransport();
|
|
178
|
+
await server.connect(transport);
|
|
179
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function detectProjectType(dir) {
|
|
4
|
+
if (existsSync(join(dir, 'next.config.js')) || existsSync(join(dir, 'next.config.mjs')) || existsSync(join(dir, 'next.config.ts'))) {
|
|
5
|
+
return 'nextjs';
|
|
6
|
+
}
|
|
7
|
+
if (existsSync(join(dir, 'package.json')))
|
|
8
|
+
return 'node';
|
|
9
|
+
if (existsSync(join(dir, 'go.mod')))
|
|
10
|
+
return 'go';
|
|
11
|
+
if (existsSync(join(dir, 'composer.json')))
|
|
12
|
+
return 'php';
|
|
13
|
+
return 'generic';
|
|
14
|
+
}
|
|
15
|
+
const COMMON = `# ===== SECRETS - NEVER COMMIT =====
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
*.pem
|
|
20
|
+
*.key
|
|
21
|
+
|
|
22
|
+
# OS / editors
|
|
23
|
+
.DS_Store
|
|
24
|
+
Thumbs.db
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
*.swp
|
|
28
|
+
*.swo
|
|
29
|
+
*~
|
|
30
|
+
|
|
31
|
+
# Logs
|
|
32
|
+
*.log
|
|
33
|
+
npm-debug.log*
|
|
34
|
+
yarn-debug.log*
|
|
35
|
+
yarn-error.log*
|
|
36
|
+
|
|
37
|
+
# Docker overrides
|
|
38
|
+
docker-compose.override.yml
|
|
39
|
+
`;
|
|
40
|
+
const NODE = `# Node
|
|
41
|
+
node_modules/
|
|
42
|
+
dist/
|
|
43
|
+
build/
|
|
44
|
+
coverage/
|
|
45
|
+
.npm
|
|
46
|
+
`;
|
|
47
|
+
const NEXTJS = `# Node
|
|
48
|
+
node_modules/
|
|
49
|
+
dist/
|
|
50
|
+
build/
|
|
51
|
+
coverage/
|
|
52
|
+
.npm
|
|
53
|
+
|
|
54
|
+
# Next.js
|
|
55
|
+
.next/
|
|
56
|
+
out/
|
|
57
|
+
*.tsbuildinfo
|
|
58
|
+
`;
|
|
59
|
+
const GO = `# Go
|
|
60
|
+
bin/
|
|
61
|
+
vendor/
|
|
62
|
+
*.exe
|
|
63
|
+
`;
|
|
64
|
+
const PHP = `# PHP
|
|
65
|
+
/vendor/
|
|
66
|
+
composer.phar
|
|
67
|
+
`;
|
|
68
|
+
const FOOTER = `
|
|
69
|
+
# ===== REMINDER: check for secrets before committing =====
|
|
70
|
+
`;
|
|
71
|
+
export function generateGitignore(type) {
|
|
72
|
+
let content = COMMON;
|
|
73
|
+
switch (type) {
|
|
74
|
+
case 'nextjs':
|
|
75
|
+
content += NEXTJS;
|
|
76
|
+
break;
|
|
77
|
+
case 'node':
|
|
78
|
+
content += NODE;
|
|
79
|
+
break;
|
|
80
|
+
case 'go':
|
|
81
|
+
content += GO;
|
|
82
|
+
break;
|
|
83
|
+
case 'php':
|
|
84
|
+
content += PHP;
|
|
85
|
+
break;
|
|
86
|
+
case 'generic': break;
|
|
87
|
+
}
|
|
88
|
+
return content + FOOTER;
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export function generateNginxConfig(opts) {
|
|
2
|
+
const { domain, port, type } = opts;
|
|
3
|
+
const apiPrefix = opts.apiPrefix ?? '/api';
|
|
4
|
+
const securityHeaders = ` add_header X-Content-Type-Options "nosniff" always;
|
|
5
|
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
6
|
+
add_header X-XSS-Protection "1; mode=block" always;
|
|
7
|
+
add_header Referrer-Policy "no-referrer-when-downgrade" always;`;
|
|
8
|
+
const proxyHeaders = ` proxy_http_version 1.1;
|
|
9
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
10
|
+
proxy_set_header Connection 'upgrade';
|
|
11
|
+
proxy_set_header Host $host;
|
|
12
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
13
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
14
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
15
|
+
proxy_cache_bypass $http_upgrade;`;
|
|
16
|
+
if (type === 'proxy') {
|
|
17
|
+
return proxyTemplate(domain, port, securityHeaders, proxyHeaders);
|
|
18
|
+
}
|
|
19
|
+
if (type === 'nextjs') {
|
|
20
|
+
return nextjsTemplate(domain, port, securityHeaders, proxyHeaders);
|
|
21
|
+
}
|
|
22
|
+
return spaTemplate(domain, port, apiPrefix, securityHeaders, proxyHeaders);
|
|
23
|
+
}
|
|
24
|
+
function proxyTemplate(domain, port, security, proxy) {
|
|
25
|
+
return `server {
|
|
26
|
+
server_name ${domain} www.${domain};
|
|
27
|
+
|
|
28
|
+
${security}
|
|
29
|
+
|
|
30
|
+
location / {
|
|
31
|
+
proxy_pass http://127.0.0.1:${port};
|
|
32
|
+
${proxy}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
listen 80;
|
|
36
|
+
listen [::]:80;
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
function spaTemplate(domain, port, apiPrefix, security, proxy) {
|
|
41
|
+
return `server {
|
|
42
|
+
server_name ${domain} www.${domain};
|
|
43
|
+
|
|
44
|
+
${security}
|
|
45
|
+
|
|
46
|
+
# Gzip
|
|
47
|
+
gzip on;
|
|
48
|
+
gzip_vary on;
|
|
49
|
+
gzip_min_length 1024;
|
|
50
|
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
|
51
|
+
|
|
52
|
+
# API proxy
|
|
53
|
+
location ${apiPrefix}/ {
|
|
54
|
+
proxy_pass http://127.0.0.1:${port};
|
|
55
|
+
${proxy}
|
|
56
|
+
proxy_read_timeout 60s;
|
|
57
|
+
proxy_connect_timeout 60s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Sitemap and robots
|
|
61
|
+
location = /sitemap.xml {
|
|
62
|
+
proxy_pass http://127.0.0.1:${port};
|
|
63
|
+
${proxy}
|
|
64
|
+
}
|
|
65
|
+
location = /robots.txt {
|
|
66
|
+
proxy_pass http://127.0.0.1:${port};
|
|
67
|
+
${proxy}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Static assets
|
|
71
|
+
location ~* \\.(?:css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
72
|
+
expires 1y;
|
|
73
|
+
add_header Cache-Control "public, immutable";
|
|
74
|
+
try_files $uri =404;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# SPA fallback
|
|
78
|
+
location / {
|
|
79
|
+
try_files $uri $uri/ /index.html;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
listen 80;
|
|
83
|
+
listen [::]:80;
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
function nextjsTemplate(domain, port, security, proxy) {
|
|
88
|
+
return `server {
|
|
89
|
+
server_name ${domain} www.${domain};
|
|
90
|
+
|
|
91
|
+
${security}
|
|
92
|
+
|
|
93
|
+
# Next.js static assets
|
|
94
|
+
location /_next/static/ {
|
|
95
|
+
proxy_pass http://127.0.0.1:${port};
|
|
96
|
+
${proxy}
|
|
97
|
+
expires 1y;
|
|
98
|
+
add_header Cache-Control "public, immutable";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# All traffic to Next.js
|
|
102
|
+
location / {
|
|
103
|
+
proxy_pass http://127.0.0.1:${port};
|
|
104
|
+
${proxy}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
listen 80;
|
|
108
|
+
listen [::]:80;
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function generateServiceFile(opts) {
|
|
2
|
+
const fileFlag = opts.composeFile ? ` -f ${opts.composeFile}` : '';
|
|
3
|
+
const dbDep = opts.dependsOnDatabases ? ' docker-databases.service' : '';
|
|
4
|
+
return `[Unit]
|
|
5
|
+
Description=${opts.description}
|
|
6
|
+
Requires=docker.service${dbDep}
|
|
7
|
+
After=docker.service${dbDep} network-online.target
|
|
8
|
+
Wants=network-online.target
|
|
9
|
+
|
|
10
|
+
[Service]
|
|
11
|
+
Type=oneshot
|
|
12
|
+
RemainAfterExit=yes
|
|
13
|
+
WorkingDirectory=${opts.workingDirectory}
|
|
14
|
+
ExecStartPre=-/usr/bin/docker compose${fileFlag} down
|
|
15
|
+
ExecStart=/usr/bin/docker compose${fileFlag} up -d --force-recreate
|
|
16
|
+
ExecStop=/usr/bin/docker compose${fileFlag} down --timeout 30
|
|
17
|
+
ExecReload=/usr/bin/docker compose${fileFlag} restart
|
|
18
|
+
TimeoutStartSec=300
|
|
19
|
+
TimeoutStopSec=60
|
|
20
|
+
Restart=on-failure
|
|
21
|
+
RestartSec=10
|
|
22
|
+
|
|
23
|
+
[Install]
|
|
24
|
+
WantedBy=multi-user.target
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateUnsealService(): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { load } from '../core/registry.js';
|
|
2
|
+
export function generateUnsealService() {
|
|
3
|
+
const reg = load();
|
|
4
|
+
const serviceNames = reg.apps.map(a => a.serviceName + '.service');
|
|
5
|
+
const dbService = reg.infrastructure.databases.serviceName + '.service';
|
|
6
|
+
const allServices = [dbService, ...serviceNames].join(' ');
|
|
7
|
+
return `[Unit]
|
|
8
|
+
Description=Fleet Secrets Unseal
|
|
9
|
+
After=local-fs.target
|
|
10
|
+
Before=${allServices}
|
|
11
|
+
|
|
12
|
+
[Service]
|
|
13
|
+
Type=oneshot
|
|
14
|
+
RemainAfterExit=yes
|
|
15
|
+
ExecStart=/usr/bin/node /home/matt/fleet/dist/index.js secrets unseal
|
|
16
|
+
ExecStop=/bin/rm -rf /run/fleet-secrets
|
|
17
|
+
TimeoutStartSec=30
|
|
18
|
+
|
|
19
|
+
[Install]
|
|
20
|
+
WantedBy=multi-user.target
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function launchTui(): void;
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface AppListItem {
|
|
3
|
+
name: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
}
|
|
6
|
+
interface AppListProps {
|
|
7
|
+
items: AppListItem[];
|
|
8
|
+
onSelect: (item: AppListItem) => void;
|
|
9
|
+
renderItem?: (item: AppListItem, selected: boolean) => React.JSX.Element;
|
|
10
|
+
}
|
|
11
|
+
export declare function AppList({ items, onSelect, renderItem }: AppListProps): React.JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
export function AppList({ items, onSelect, renderItem }) {
|
|
6
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
7
|
+
useInput((input, key) => {
|
|
8
|
+
if (items.length === 0)
|
|
9
|
+
return;
|
|
10
|
+
if (input === 'j' || key.downArrow) {
|
|
11
|
+
setSelectedIndex(prev => Math.min(prev + 1, items.length - 1));
|
|
12
|
+
}
|
|
13
|
+
else if (input === 'k' || key.upArrow) {
|
|
14
|
+
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
|
15
|
+
}
|
|
16
|
+
else if (key.return) {
|
|
17
|
+
if (items[selectedIndex]) {
|
|
18
|
+
onSelect(items[selectedIndex]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
if (items.length === 0) {
|
|
23
|
+
return _jsx(Text, { color: colors.muted, children: "No items" });
|
|
24
|
+
}
|
|
25
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => {
|
|
26
|
+
const selected = i === selectedIndex;
|
|
27
|
+
if (renderItem) {
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
|
|
29
|
+
}
|
|
30
|
+
return (_jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: [selected ? '> ' : ' ', item.label ?? item.name] }, item.name));
|
|
31
|
+
}) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useAppState } from '../state.js';
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
export function Confirm() {
|
|
6
|
+
const { confirmAction } = useAppState();
|
|
7
|
+
if (!confirmAction)
|
|
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"] })] })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useAppState } from '../state.js';
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
const TABS = [
|
|
6
|
+
{ view: 'dashboard', label: 'Dashboard' },
|
|
7
|
+
{ view: 'health', label: 'Health' },
|
|
8
|
+
{ view: 'secrets', label: 'Secrets' },
|
|
9
|
+
];
|
|
10
|
+
function VaultIndicator({ sealed }) {
|
|
11
|
+
return (_jsx(Text, { color: sealed ? colors.warning : colors.success, children: sealed ? '[SEALED]' : '[UNSEALED]' }));
|
|
12
|
+
}
|
|
13
|
+
export function Header({ vaultSealed }) {
|
|
14
|
+
const { currentView, redacted } = useAppState();
|
|
15
|
+
return (_jsxs(Box, { borderStyle: "single", borderBottom: true, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: "Fleet" }), _jsx(Text, { color: colors.muted, children: "|" }), TABS.map(tab => (_jsx(Text, { bold: currentView === tab.view || currentView === 'app-detail' && tab.view === 'dashboard' || currentView === 'secret-edit' && tab.view === 'secrets' || currentView === 'logs' && tab.view === 'dashboard', color: currentView === tab.view ? colors.primary : colors.muted, children: tab.label }, tab.view)))] }), _jsxs(Box, { gap: 1, children: [redacted && _jsx(Text, { color: "magenta", bold: true, children: "[REDACTED]" }), _jsx(VaultIndicator, { sealed: vaultSealed })] })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useAppState } from '../state.js';
|
|
4
|
+
import { colors } from '../theme.js';
|
|
5
|
+
const viewHints = {
|
|
6
|
+
dashboard: [
|
|
7
|
+
{ key: 'j/k', label: 'navigate' },
|
|
8
|
+
{ key: 'Enter', label: 'select' },
|
|
9
|
+
{ key: 'Tab', label: 'switch view' },
|
|
10
|
+
{ key: 'x', label: 'redact' },
|
|
11
|
+
{ key: 'q', label: 'quit' },
|
|
12
|
+
],
|
|
13
|
+
'app-detail': [
|
|
14
|
+
{ key: 'j/k', label: 'navigate' },
|
|
15
|
+
{ key: 'Enter', label: 'run action' },
|
|
16
|
+
{ key: 'x', label: 'redact' },
|
|
17
|
+
{ key: 'Esc', label: 'back' },
|
|
18
|
+
{ key: 'q', label: 'quit' },
|
|
19
|
+
],
|
|
20
|
+
health: [
|
|
21
|
+
{ key: 'j/k', label: 'navigate' },
|
|
22
|
+
{ key: 'Tab', label: 'switch view' },
|
|
23
|
+
{ key: 'x', label: 'redact' },
|
|
24
|
+
{ key: 'q', label: 'quit' },
|
|
25
|
+
],
|
|
26
|
+
secrets: [
|
|
27
|
+
{ key: 'j/k', label: 'navigate' },
|
|
28
|
+
{ key: 'Enter', label: 'select' },
|
|
29
|
+
{ key: 'u', label: 'unseal' },
|
|
30
|
+
{ key: 'l', label: 'seal' },
|
|
31
|
+
{ key: 'a', label: 'add' },
|
|
32
|
+
{ key: 'd', label: 'delete' },
|
|
33
|
+
{ key: 'r', label: 'reveal' },
|
|
34
|
+
{ key: 'x', label: 'redact' },
|
|
35
|
+
{ key: 'Esc', label: 'back' },
|
|
36
|
+
{ key: 'q', label: 'quit' },
|
|
37
|
+
],
|
|
38
|
+
'secret-edit': [
|
|
39
|
+
{ key: 'Enter', label: 'save' },
|
|
40
|
+
{ key: 'Esc', label: 'cancel' },
|
|
41
|
+
],
|
|
42
|
+
logs: [
|
|
43
|
+
{ key: 'f', label: 'follow' },
|
|
44
|
+
{ key: 'x', label: 'redact' },
|
|
45
|
+
{ key: 'Esc', label: 'back' },
|
|
46
|
+
{ key: 'q', label: 'quit' },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
export function KeyHint() {
|
|
50
|
+
const { currentView, confirmAction } = useAppState();
|
|
51
|
+
const hints = confirmAction
|
|
52
|
+
? [{ key: 'y', label: 'confirm' }, { key: 'n', label: 'cancel' }]
|
|
53
|
+
: viewHints[currentView] ?? [];
|
|
54
|
+
return (_jsx(Box, { borderStyle: "single", borderTop: true, paddingX: 1, gap: 2, children: hints.map(hint => (_jsxs(Box, { gap: 0, children: [_jsx(Text, { bold: true, color: colors.primary, children: hint.key }), _jsxs(Text, { color: colors.muted, children: [" ", hint.label] })] }, hint.key))) }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { statusColor, healthColor } from '../theme.js';
|
|
4
|
+
export function StatusBadge({ value, type = 'health' }) {
|
|
5
|
+
const colorMap = type === 'systemd' ? statusColor : healthColor;
|
|
6
|
+
const color = colorMap[value] ?? 'gray';
|
|
7
|
+
return _jsx(Text, { color: color, children: value });
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface CommandResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
output: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function runFleetCommand(args: string[]): Promise<CommandResult>;
|
|
6
|
+
export declare function runFleetJson<T>(args: string[]): Promise<T | null>;
|
|
7
|
+
export interface StreamHandle {
|
|
8
|
+
kill: () => void;
|
|
9
|
+
onData: (cb: (line: string) => void) => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function streamFleetCommand(args: string[]): StreamHandle;
|