@matthesketh/fleet 1.2.0 → 1.7.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 (218) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +46 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/guard.d.ts +1 -0
  39. package/dist/commands/guard.js +144 -0
  40. package/dist/commands/logs.d.ts +1 -1
  41. package/dist/commands/logs.js +237 -8
  42. package/dist/commands/patch-systemd.d.ts +1 -0
  43. package/dist/commands/patch-systemd.js +126 -0
  44. package/dist/commands/rollback.d.ts +1 -0
  45. package/dist/commands/rollback.js +58 -0
  46. package/dist/commands/routine-run.d.ts +1 -0
  47. package/dist/commands/routine-run.js +122 -0
  48. package/dist/commands/routines.d.ts +1 -0
  49. package/dist/commands/routines.js +25 -0
  50. package/dist/commands/secrets.js +449 -16
  51. package/dist/commands/status.js +7 -3
  52. package/dist/commands/watchdog.d.ts +1 -1
  53. package/dist/commands/watchdog.js +16 -40
  54. package/dist/core/boot-refresh.d.ts +57 -0
  55. package/dist/core/boot-refresh.js +116 -0
  56. package/dist/core/deps/actors/pr-creator.js +11 -9
  57. package/dist/core/deps/collectors/docker-running.js +2 -2
  58. package/dist/core/deps/collectors/github-pr.js +5 -2
  59. package/dist/core/deps/collectors/npm.js +10 -5
  60. package/dist/core/deps/collectors/vulnerability.js +10 -6
  61. package/dist/core/deps/reporters/motd.js +1 -1
  62. package/dist/core/deps/reporters/telegram.js +2 -29
  63. package/dist/core/docker.js +45 -15
  64. package/dist/core/egress.d.ts +41 -0
  65. package/dist/core/egress.js +161 -0
  66. package/dist/core/exec.d.ts +7 -1
  67. package/dist/core/exec.js +25 -17
  68. package/dist/core/git.d.ts +1 -0
  69. package/dist/core/git.js +36 -23
  70. package/dist/core/github.js +27 -8
  71. package/dist/core/health.d.ts +3 -0
  72. package/dist/core/health.js +15 -3
  73. package/dist/core/logs-multi.d.ts +73 -0
  74. package/dist/core/logs-multi.js +163 -0
  75. package/dist/core/logs-policy.d.ts +55 -0
  76. package/dist/core/logs-policy.js +148 -0
  77. package/dist/core/nginx.js +8 -4
  78. package/dist/core/notify.d.ts +15 -0
  79. package/dist/core/notify.js +55 -0
  80. package/dist/core/registry.d.ts +25 -0
  81. package/dist/core/registry.js +57 -10
  82. package/dist/core/routines/cost-queries.d.ts +24 -0
  83. package/dist/core/routines/cost-queries.js +65 -0
  84. package/dist/core/routines/db.d.ts +9 -0
  85. package/dist/core/routines/db.js +126 -0
  86. package/dist/core/routines/defaults.d.ts +2 -0
  87. package/dist/core/routines/defaults.js +72 -0
  88. package/dist/core/routines/engine.d.ts +59 -0
  89. package/dist/core/routines/engine.js +175 -0
  90. package/dist/core/routines/incidents.d.ts +13 -0
  91. package/dist/core/routines/incidents.js +35 -0
  92. package/dist/core/routines/schema.d.ts +418 -0
  93. package/dist/core/routines/schema.js +113 -0
  94. package/dist/core/routines/signals-collector.d.ts +35 -0
  95. package/dist/core/routines/signals-collector.js +114 -0
  96. package/dist/core/routines/store.d.ts +316 -0
  97. package/dist/core/routines/store.js +99 -0
  98. package/dist/core/routines/test-utils.d.ts +2 -0
  99. package/dist/core/routines/test-utils.js +13 -0
  100. package/dist/core/secrets-audit.d.ts +21 -0
  101. package/dist/core/secrets-audit.js +60 -0
  102. package/dist/core/secrets-metadata.d.ts +39 -0
  103. package/dist/core/secrets-metadata.js +82 -0
  104. package/dist/core/secrets-motd.d.ts +20 -0
  105. package/dist/core/secrets-motd.js +72 -0
  106. package/dist/core/secrets-ops.d.ts +3 -1
  107. package/dist/core/secrets-ops.js +78 -13
  108. package/dist/core/secrets-providers.d.ts +50 -0
  109. package/dist/core/secrets-providers.js +291 -0
  110. package/dist/core/secrets-rotation.d.ts +52 -0
  111. package/dist/core/secrets-rotation.js +165 -0
  112. package/dist/core/secrets-snapshots.d.ts +26 -0
  113. package/dist/core/secrets-snapshots.js +95 -0
  114. package/dist/core/secrets-validate.js +2 -1
  115. package/dist/core/secrets.d.ts +12 -1
  116. package/dist/core/secrets.js +35 -24
  117. package/dist/core/self-update.d.ts +41 -0
  118. package/dist/core/self-update.js +73 -0
  119. package/dist/core/systemd.js +29 -12
  120. package/dist/core/telegram.d.ts +6 -0
  121. package/dist/core/telegram.js +32 -0
  122. package/dist/core/validate.d.ts +7 -0
  123. package/dist/core/validate.js +42 -0
  124. package/dist/index.js +0 -4
  125. package/dist/mcp/deps-tools.js +9 -1
  126. package/dist/mcp/git-tools.js +4 -4
  127. package/dist/mcp/server.js +193 -8
  128. package/dist/templates/systemd.js +3 -3
  129. package/dist/templates/unseal.js +5 -1
  130. package/dist/tui/components/KeyHint.js +10 -0
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/router.js +60 -7
  135. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  136. package/dist/tui/routines/RoutinesApp.js +277 -0
  137. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  138. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  139. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  140. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  141. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  142. package/dist/tui/routines/components/CommandPalette.js +21 -0
  143. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  144. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  145. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  146. package/dist/tui/routines/components/RoutineForm.js +254 -0
  147. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  148. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  149. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  150. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  151. package/dist/tui/routines/format.d.ts +7 -0
  152. package/dist/tui/routines/format.js +51 -0
  153. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  154. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  155. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  156. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  157. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  158. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  159. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  160. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  161. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  162. package/dist/tui/routines/hooks/use-security.js +110 -0
  163. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  164. package/dist/tui/routines/hooks/use-signals.js +60 -0
  165. package/dist/tui/routines/runtime.d.ts +20 -0
  166. package/dist/tui/routines/runtime.js +40 -0
  167. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  168. package/dist/tui/routines/tabs/CostTab.js +24 -0
  169. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  170. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  171. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  172. package/dist/tui/routines/tabs/GitTab.js +39 -0
  173. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  175. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  177. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  178. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  179. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  180. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  181. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  182. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  183. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  184. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  185. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  187. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  188. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  189. package/dist/tui/state.js +1 -1
  190. package/dist/tui/tests/keyboard-integration.test.js +3 -0
  191. package/dist/tui/tests/test-app.js +1 -1
  192. package/dist/tui/types.d.ts +2 -2
  193. package/dist/tui/views/AppDetail.js +3 -4
  194. package/dist/tui/views/HealthView.js +7 -1
  195. package/dist/tui/views/LogsView.js +24 -1
  196. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  197. package/dist/tui/views/MultiLogsView.js +165 -0
  198. package/dist/tui/views/SecretEdit.js +10 -3
  199. package/dist/tui/views/SecretsView.js +6 -3
  200. package/dist/ui/prompt.d.ts +52 -0
  201. package/dist/ui/prompt.js +169 -0
  202. package/package.json +34 -21
  203. package/scripts/guard/cert-expiry-watch +109 -0
  204. package/scripts/guard/cf-audit-monitor +169 -0
  205. package/scripts/guard/cf-snapshot +124 -0
  206. package/scripts/guard/cron.d-cf-protect +11 -0
  207. package/scripts/guard/dns-drift-watch +138 -0
  208. package/scripts/guard/fleet-guard +282 -0
  209. package/scripts/guard/fleet-guard-execute +197 -0
  210. package/scripts/guard/notify +108 -0
  211. package/dist/commands/motd.d.ts +0 -1
  212. package/dist/commands/motd.js +0 -10
  213. package/dist/templates/motd.d.ts +0 -1
  214. package/dist/templates/motd.js +0 -7
  215. package/dist/tui/components/AppList.d.ts +0 -12
  216. package/dist/tui/components/AppList.js +0 -32
  217. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  218. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,32 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
3
+ export function loadTelegramConfig() {
4
+ if (!existsSync(TELEGRAM_CONFIG_PATH))
5
+ return null;
6
+ try {
7
+ const raw = JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
8
+ if (!raw.botToken || !raw.chatId)
9
+ return null;
10
+ return { botToken: String(raw.botToken), chatId: String(raw.chatId) };
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ export async function sendTelegram(config, message) {
17
+ try {
18
+ const res = await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({
22
+ chat_id: config.chatId,
23
+ text: message,
24
+ parse_mode: 'HTML',
25
+ }),
26
+ });
27
+ return res.ok;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ export declare function assertAppName(name: string): void;
2
+ export declare function assertServiceName(name: string): void;
3
+ export declare function assertDomain(domain: string): void;
4
+ export declare function assertBranch(branch: string): void;
5
+ export declare function assertHealthPath(path: string): void;
6
+ export declare function assertFilePath(path: string): void;
7
+ export declare function assertSecretKey(key: string): void;
@@ -0,0 +1,42 @@
1
+ const APP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
2
+ const DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/;
3
+ const BRANCH_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/;
4
+ const HEALTH_PATH_RE = /^\/[a-zA-Z0-9/_.-]*$/;
5
+ const SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9@._-]*$/;
6
+ // Secret keys must be valid env var names (alphanumeric + underscore, no leading digit)
7
+ const SECRET_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
8
+ function assert(value, label, pattern) {
9
+ if (!pattern.test(value)) {
10
+ throw new Error(`Invalid ${label}: "${value}" does not match ${pattern}`);
11
+ }
12
+ }
13
+ function assertNoTraversal(value, label) {
14
+ if (value.includes('\0'))
15
+ throw new Error(`Invalid ${label}: contains null byte`);
16
+ const parts = value.replace(/\\/g, '/').split('/');
17
+ if (parts.includes('..'))
18
+ throw new Error(`Invalid ${label}: contains path traversal`);
19
+ }
20
+ export function assertAppName(name) {
21
+ assert(name, 'app name', APP_NAME_RE);
22
+ }
23
+ export function assertServiceName(name) {
24
+ assert(name, 'service name', SERVICE_NAME_RE);
25
+ }
26
+ export function assertDomain(domain) {
27
+ assert(domain, 'domain', DOMAIN_RE);
28
+ }
29
+ export function assertBranch(branch) {
30
+ assert(branch, 'branch name', BRANCH_RE);
31
+ assertNoTraversal(branch, 'branch name');
32
+ }
33
+ export function assertHealthPath(path) {
34
+ assert(path, 'health path', HEALTH_PATH_RE);
35
+ assertNoTraversal(path, 'health path');
36
+ }
37
+ export function assertFilePath(path) {
38
+ assertNoTraversal(path, 'file path');
39
+ }
40
+ export function assertSecretKey(key) {
41
+ assert(key, 'secret key', SECRET_KEY_RE);
42
+ }
package/dist/index.js CHANGED
@@ -4,10 +4,6 @@ import { error } from './ui/output.js';
4
4
  import { FleetError } from './core/errors.js';
5
5
  const isMcp = process.argv.includes('mcp');
6
6
  const isInstallMcp = process.argv.includes('install-mcp');
7
- if (!isMcp && !isInstallMcp && process.getuid && process.getuid() !== 0) {
8
- error('fleet must be run as root');
9
- process.exit(1);
10
- }
11
7
  run(process.argv).catch((err) => {
12
8
  if (err instanceof FleetError) {
13
9
  error(err.message);
@@ -68,12 +68,20 @@ export function registerDepsTools(server) {
68
68
  saveConfig(config);
69
69
  return text(`Ignoring ${params.package}: ${params.reason}`);
70
70
  });
71
+ const ALLOWED_CONFIG_KEYS = new Set([
72
+ 'scanIntervalHours', 'concurrency',
73
+ ]);
71
74
  server.tool('fleet_deps_config', 'Get or set dependency monitoring configuration', { key: z.string().optional(), value: z.string().optional() }, async ({ key, value }) => {
72
75
  const config = loadConfig();
73
76
  if (!key)
74
77
  return text(JSON.stringify(config, null, 2));
75
- if (!value)
78
+ if (!value) {
79
+ if (!ALLOWED_CONFIG_KEYS.has(key))
80
+ return text(`Unknown config key: ${key}`);
76
81
  return text(JSON.stringify(config[key], null, 2));
82
+ }
83
+ if (!ALLOWED_CONFIG_KEYS.has(key))
84
+ return text(`Cannot set key: ${key}. Allowed: ${[...ALLOWED_CONFIG_KEYS].join(', ')}`);
77
85
  config[key] = value === 'true' ? true : value === 'false' ? false : isNaN(Number(value)) ? value : Number(value);
78
86
  saveConfig(config);
79
87
  return text(`Set ${key} = ${value}`);
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { load, findApp } from '../core/registry.js';
3
- import { getGitStatus, getProjectRoot, gitAdd, gitCommit, gitCheckout, gitPush } from '../core/git.js';
3
+ import { getGitStatus, getProjectRoot, gitAddTracked, gitCommit, gitCheckout, gitPush } from '../core/git.js';
4
4
  import { detectScenario, describeOnboardPlan, executeOnboard } from '../core/git-onboard.js';
5
5
  import * as github from '../core/github.js';
6
6
  import { AppNotFoundError } from '../core/errors.js';
@@ -70,7 +70,7 @@ export function registerGitTools(server) {
70
70
  gitPush(root, branch, true);
71
71
  return text(`Created and pushed branch ${branch} from ${from}`);
72
72
  });
73
- server.tool('fleet_git_commit', 'Stage all changes and commit', {
73
+ server.tool('fleet_git_commit', 'Stage tracked file changes and commit', {
74
74
  app: z.string().describe('App name'),
75
75
  message: z.string().describe('Commit message'),
76
76
  dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
@@ -81,8 +81,8 @@ export function registerGitTools(server) {
81
81
  return text(hint);
82
82
  const root = getProjectRoot(app.composePath);
83
83
  if (dryRun)
84
- return text(`Would stage all and commit: "${message}"`);
85
- gitAdd(root);
84
+ return text(`Would stage tracked changes and commit: "${message}"`);
85
+ gitAddTracked(root);
86
86
  gitCommit(root, message);
87
87
  return text(`Committed: ${message}`);
88
88
  });
@@ -1,8 +1,10 @@
1
- import { z } from 'zod';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { z } from 'zod';
4
7
  import { getStatusData } from '../commands/status.js';
5
- import { existsSync } from 'node:fs';
6
8
  import { load, findApp, save, addApp } from '../core/registry.js';
7
9
  import { startService, stopService, restartService } from '../core/systemd.js';
8
10
  import { getContainerLogs, getContainersByCompose } from '../core/docker.js';
@@ -10,12 +12,17 @@ import { checkHealth, checkAllHealth } from '../core/health.js';
10
12
  import { listSites, installConfig, testConfig, reload, removeConfig } from '../core/nginx.js';
11
13
  import { generateNginxConfig } from '../templates/nginx.js';
12
14
  import { composeBuild } from '../core/docker.js';
15
+ import { execSafe } from '../core/exec.js';
13
16
  import { AppNotFoundError } from '../core/errors.js';
17
+ import { assertAppName, assertServiceName, assertFilePath, assertDomain } from '../core/validate.js';
14
18
  import { loadManifest, listSecrets, isInitialized } from '../core/secrets.js';
15
19
  import { unsealAll, getStatus as getSecretsStatus } from '../core/secrets-ops.js';
16
20
  import { validateApp, validateAll } from '../core/secrets-validate.js';
21
+ import { freezeApp, unfreezeApp } from '../commands/freeze.js';
17
22
  import { registerGitTools } from './git-tools.js';
18
23
  import { registerSecretsTools } from './secrets-tools.js';
24
+ import { readContainerLogs, getLogStatus, effectivePolicy } from '../core/logs-policy.js';
25
+ import { snapshotEgress } from '../core/egress.js';
19
26
  import { registerDepsTools } from './deps-tools.js';
20
27
  function requireApp(name) {
21
28
  const reg = load();
@@ -28,9 +35,11 @@ function text(msg) {
28
35
  return { content: [{ type: 'text', text: msg }] };
29
36
  }
30
37
  export async function startMcpServer() {
38
+ const __dirname = dirname(fileURLToPath(import.meta.url));
39
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
31
40
  const server = new McpServer({
32
41
  name: 'fleet',
33
- version: '1.0.0',
42
+ version: pkg.version,
34
43
  });
35
44
  server.tool('fleet_status', 'Dashboard data for all apps: systemd state, containers, health', async () => {
36
45
  const data = getStatusData();
@@ -55,17 +64,138 @@ export async function startMcpServer() {
55
64
  const ok = restartService(entry.serviceName);
56
65
  return text(ok ? `Restarted ${entry.name}` : `Failed to restart ${entry.name}`);
57
66
  });
58
- server.tool('fleet_logs', 'Get recent container logs for an app', {
67
+ server.tool('fleet_logs', 'DEPRECATED — prefer fleet_logs_recent (token-conservative defaults) or fleet_logs_summary. Get recent container logs for an app.', {
59
68
  app: z.string().describe('App name'),
69
+ container: z.string().optional().describe('Container name (omit to list available containers, or get logs from first)'),
60
70
  lines: z.number().optional().default(100).describe('Number of log lines'),
61
- }, async ({ app, lines }) => {
71
+ }, async ({ app, container, lines }) => {
62
72
  const entry = requireApp(app);
63
- const container = entry.containers[0];
64
- if (!container)
73
+ if (entry.containers.length === 0)
65
74
  return text('No containers registered');
66
- const logs = getContainerLogs(container, lines);
75
+ if (!container && entry.containers.length > 1) {
76
+ return text(`${entry.name} has ${entry.containers.length} containers. Specify one:\n` +
77
+ entry.containers.map(c => ` - ${c}`).join('\n') +
78
+ `\n\nOr omit container to get logs from: ${entry.containers[0]}`);
79
+ }
80
+ const target = container ?? entry.containers[0];
81
+ if (!entry.containers.includes(target)) {
82
+ return text(`Container "${target}" not found in ${entry.name}. Available:\n` +
83
+ entry.containers.map(c => ` - ${c}`).join('\n'));
84
+ }
85
+ const logs = getContainerLogs(target, lines);
67
86
  return text(logs);
68
87
  });
88
+ // ── New token-conservative log tools ─────────────────────────────────────
89
+ server.tool('fleet_logs_recent', 'Get recent log lines for an app, filtered to a level and bounded in size. Defaults are SMALL (50 lines, last 15 minutes, warn+) — broaden only if needed. Returns {text, truncated, suggestion}.', {
90
+ app: z.string().describe('App name'),
91
+ container: z.string().optional().describe('Container (defaults to first)'),
92
+ lines: z.number().optional().default(50).describe('Tail N lines (default 50)'),
93
+ level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('warn').describe('Min level (default warn — drops debug/info noise)'),
94
+ sinceMinutes: z.number().optional().default(15).describe('Look back this many minutes (default 15)'),
95
+ grep: z.string().optional().describe('Substring filter applied after level'),
96
+ }, async ({ app, container, lines, level, sinceMinutes, grep }) => {
97
+ const entry = requireApp(app);
98
+ if (entry.containers.length === 0)
99
+ return text('No containers registered');
100
+ const target = container ?? entry.containers[0];
101
+ if (!entry.containers.includes(target)) {
102
+ return text(`Container "${target}" not in ${entry.name}. Have: ${entry.containers.join(', ')}`);
103
+ }
104
+ const result = readContainerLogs(target, { lines, level, sinceMinutes, grep, maxBytes: 200_000 });
105
+ const suffix = result.truncated
106
+ ? '\n\n[truncated at 200KB — narrow with smaller lines/sinceMinutes or add grep]'
107
+ : '';
108
+ return text(result.text + suffix);
109
+ });
110
+ server.tool('fleet_logs_summary', 'Cheap aggregate: counts of log lines by level + the top 10 distinct error/warning messages over a window. Use as a first pass before fleet_logs_recent.', {
111
+ app: z.string().describe('App name'),
112
+ container: z.string().optional().describe('Container (defaults to first)'),
113
+ sinceMinutes: z.number().optional().default(60).describe('Window in minutes (default 60)'),
114
+ }, async ({ app, container, sinceMinutes }) => {
115
+ const entry = requireApp(app);
116
+ const target = container ?? entry.containers[0];
117
+ if (!entry.containers.includes(target)) {
118
+ return text(`Container "${target}" not in ${entry.name}. Have: ${entry.containers.join(', ')}`);
119
+ }
120
+ const all = readContainerLogs(target, { lines: 5000, sinceMinutes, maxBytes: 5_000_000 });
121
+ const lines = all.text.split('\n').filter(l => l.trim());
122
+ const counts = { error: 0, warn: 0, info: 0, debug: 0, other: 0 };
123
+ const errMsgs = new Map();
124
+ for (const ln of lines) {
125
+ if (/error|err\b|fatal|critical|exception|panic/i.test(ln)) {
126
+ counts.error++;
127
+ // canonicalise: drop timestamps, IDs
128
+ const norm = ln.replace(/\b[0-9a-f]{8,}\b/gi, '<id>')
129
+ .replace(/\b\d{4}-\d{2}-\d{2}T[\d:.]+Z?\b/g, '<ts>')
130
+ .replace(/\d+/g, 'N')
131
+ .slice(0, 200);
132
+ errMsgs.set(norm, (errMsgs.get(norm) ?? 0) + 1);
133
+ }
134
+ else if (/warn|warning/i.test(ln))
135
+ counts.warn++;
136
+ else if (/\binfo\b/i.test(ln))
137
+ counts.info++;
138
+ else if (/\bdebug|trace|verbose\b/i.test(ln))
139
+ counts.debug++;
140
+ else
141
+ counts.other++;
142
+ }
143
+ const top = [...errMsgs.entries()]
144
+ .sort((a, b) => b[1] - a[1])
145
+ .slice(0, 10)
146
+ .map(([msg, n]) => ` ${n.toString().padStart(4)} × ${msg}`);
147
+ const lines2 = [
148
+ `Container: ${target} Window: ${sinceMinutes}m Total: ${lines.length} lines`,
149
+ `By level: ${counts.error} error, ${counts.warn} warn, ${counts.info} info, ${counts.debug} debug, ${counts.other} other`,
150
+ '',
151
+ top.length ? 'Top distinct error/warn messages:' : 'No error/warn messages in window.',
152
+ ...top,
153
+ ];
154
+ return text(lines2.join('\n'));
155
+ });
156
+ server.tool('fleet_logs_search', 'Bounded grep across recent container logs. Returns matching lines with 0 lines of context, capped at max_results. Cheaper than fleet_logs_recent + manual filtering.', {
157
+ app: z.string().describe('App name'),
158
+ container: z.string().optional().describe('Container (defaults to first)'),
159
+ query: z.string().describe('Substring or regex'),
160
+ sinceMinutes: z.number().optional().default(60).describe('Window in minutes (default 60)'),
161
+ maxResults: z.number().optional().default(20).describe('Cap results (default 20)'),
162
+ }, async ({ app, container, query, sinceMinutes, maxResults }) => {
163
+ const entry = requireApp(app);
164
+ const target = container ?? entry.containers[0];
165
+ if (!entry.containers.includes(target)) {
166
+ return text(`Container "${target}" not in ${entry.name}. Have: ${entry.containers.join(', ')}`);
167
+ }
168
+ const result = readContainerLogs(target, { lines: 5000, sinceMinutes, grep: query, maxBytes: 1_000_000 });
169
+ const matches = result.text.split('\n').filter(l => l.trim());
170
+ const slice = matches.slice(0, maxResults);
171
+ const note = matches.length > maxResults
172
+ ? `\n\n[${matches.length - maxResults} more matches — narrow query or shorten window]`
173
+ : '';
174
+ return text(slice.join('\n') + note);
175
+ });
176
+ server.tool('fleet_egress_snapshot', 'Snapshot the current outbound TCP flows for an app and report which destinations are NOT in the configured allowlist. Use to seed allowlists or audit unexpected egress. v1 is observe-only — it never blocks traffic.', { app: z.string().describe('App name') }, async ({ app }) => {
177
+ const entry = requireApp(app);
178
+ const snap = snapshotEgress(entry);
179
+ return text(JSON.stringify({
180
+ takenAt: snap.takenAt,
181
+ app: snap.app,
182
+ uniqueRemotes: snap.uniqueRemotes,
183
+ violations: snap.violations,
184
+ flowCount: snap.flows.length,
185
+ }, null, 2));
186
+ });
187
+ server.tool('fleet_logs_status', 'Per-container log driver, current size, and policy applied. Use to check which apps need fleet logs setup.', { app: z.string().optional().describe('App name (omit for all)') }, async ({ app }) => {
188
+ const reg = load();
189
+ const apps = app ? [findApp(reg, app)].filter(Boolean) : reg.apps;
190
+ const out = [];
191
+ for (const a of apps) {
192
+ const policy = effectivePolicy(a);
193
+ const status = getLogStatus(a);
194
+ for (const s of status)
195
+ out.push({ ...s, sizeMB: s.totalBytes != null ? +(s.totalBytes / 1024 / 1024).toFixed(2) : null, policy });
196
+ }
197
+ return text(JSON.stringify(out, null, 2));
198
+ });
69
199
  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 }) => {
70
200
  const reg = load();
71
201
  if (app) {
@@ -91,6 +221,13 @@ export async function startMcpServer() {
91
221
  port: z.number().describe('Backend port'),
92
222
  type: z.enum(['proxy', 'spa', 'nextjs']).optional().default('proxy').describe('Config type'),
93
223
  }, async ({ domain, port, type }) => {
224
+ const DANGEROUS_PORTS = [5432, 3306, 27017, 6379, 9000];
225
+ if (port < 1024 || port > 65535) {
226
+ return text(`Invalid port ${port}: must be in range 1024-65535`);
227
+ }
228
+ if (DANGEROUS_PORTS.includes(port)) {
229
+ return text(`Port ${port} is not allowed (reserved for internal services)`);
230
+ }
94
231
  const config = generateNginxConfig({ domain, port, type });
95
232
  installConfig(domain, config);
96
233
  const test = testConfig();
@@ -144,6 +281,19 @@ export async function startMcpServer() {
144
281
  usesSharedDb: z.boolean().optional().default(false).describe('Uses shared database'),
145
282
  dependsOnDatabases: z.boolean().optional().default(false).describe('Depends on docker-databases'),
146
283
  }, async (params) => {
284
+ try {
285
+ assertAppName(params.name);
286
+ assertFilePath(params.composePath);
287
+ if (params.serviceName)
288
+ assertServiceName(params.serviceName);
289
+ if (params.composeFile)
290
+ assertFilePath(params.composeFile);
291
+ for (const d of (params.domains ?? []))
292
+ assertDomain(d);
293
+ }
294
+ catch (err) {
295
+ return text(`Validation error: ${err.message}`);
296
+ }
147
297
  if (!existsSync(params.composePath)) {
148
298
  return text(`Error: composePath does not exist: ${params.composePath}`);
149
299
  }
@@ -173,6 +323,41 @@ export async function startMcpServer() {
173
323
  const action = existing ? 'Updated' : 'Registered';
174
324
  return text(`${action} app "${params.name}":\n${JSON.stringify(entry, null, 2)}`);
175
325
  });
326
+ server.tool('fleet_freeze', 'Freeze a crash-looping service: stop it, disable it, and mark it frozen in the registry. Requires manual unfreezing.', {
327
+ app: z.string().describe('App name'),
328
+ reason: z.string().optional().describe('Reason for freezing'),
329
+ }, async ({ app, reason }) => {
330
+ freezeApp(app, reason);
331
+ return text(`Frozen ${app}${reason ? `: ${reason}` : ''}`);
332
+ });
333
+ server.tool('fleet_unfreeze', 'Unfreeze a frozen service: clear frozen state, enable and start the service.', { app: z.string().describe('App name') }, async ({ app }) => {
334
+ unfreezeApp(app);
335
+ return text(`Unfrozen ${app} — service enabled and started`);
336
+ });
337
+ server.tool('fleet_rollback', 'Roll back an app to its previous image (tagged <repo>:fleet-previous before the last build) and restart the service. Use this when a recent deploy or boot-refresh produced a broken image.', { app: z.string().describe('App name') }, async ({ app }) => {
338
+ const entry = requireApp(app);
339
+ // Resolve current image name via compose config
340
+ const config = execSafe('docker', ['compose', ...(entry.composeFile ? ['-f', entry.composeFile] : []), 'config', '--images'], { cwd: entry.composePath, timeout: 15_000 });
341
+ if (!config.ok)
342
+ return text(`Could not resolve image name for ${entry.name}: ${config.stderr}`);
343
+ const latest = config.stdout.split('\n').filter(Boolean)[0];
344
+ if (!latest)
345
+ return text(`Could not resolve image name for ${entry.name}`);
346
+ // Compute previous tag via lastIndexOf (handles registry:port/repo:tag)
347
+ const lastColon = latest.lastIndexOf(':');
348
+ const base = lastColon > 0 ? latest.slice(0, lastColon) : latest;
349
+ const previous = `${base}:fleet-previous`;
350
+ if (!execSafe('docker', ['image', 'inspect', previous], { timeout: 10_000 }).ok) {
351
+ return text(`No previous image found (${previous}) — nothing to roll back to`);
352
+ }
353
+ const tag = execSafe('docker', ['tag', previous, latest], { timeout: 10_000 });
354
+ if (!tag.ok)
355
+ return text(`docker tag failed: ${tag.stderr}`);
356
+ const ok = restartService(entry.serviceName);
357
+ return text(ok
358
+ ? `Rolled back ${entry.name} to ${previous}`
359
+ : `Tag flipped but service restart failed for ${entry.serviceName}`);
360
+ });
176
361
  registerGitTools(server);
177
362
  registerSecretsTools(server);
178
363
  registerDepsTools(server);
@@ -1,5 +1,5 @@
1
1
  export function generateServiceFile(opts) {
2
- const fileFlag = opts.composeFile ? ` -f ${opts.composeFile}` : '';
2
+ const fileFlag = opts.composeFile ? ` -f "${opts.composeFile}"` : '';
3
3
  const dbDep = opts.dependsOnDatabases ? ' docker-databases.service' : '';
4
4
  return `[Unit]
5
5
  Description=${opts.description}
@@ -12,10 +12,10 @@ Type=oneshot
12
12
  RemainAfterExit=yes
13
13
  WorkingDirectory=${opts.workingDirectory}
14
14
  ExecStartPre=-/usr/bin/docker compose${fileFlag} down
15
- ExecStart=/usr/bin/docker compose${fileFlag} up -d --force-recreate
15
+ ExecStart=/usr/bin/env fleet boot-start ${opts.serviceName}
16
16
  ExecStop=/usr/bin/docker compose${fileFlag} down --timeout 30
17
17
  ExecReload=/usr/bin/docker compose${fileFlag} restart
18
- TimeoutStartSec=300
18
+ TimeoutStartSec=900
19
19
  TimeoutStopSec=60
20
20
  Restart=on-failure
21
21
  RestartSec=10
@@ -1,4 +1,8 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
1
3
  import { load } from '../core/registry.js';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ const fleetBin = join(__dirname, '..', '..', 'dist', 'index.js');
2
6
  export function generateUnsealService() {
3
7
  const reg = load();
4
8
  const serviceNames = reg.apps.map(a => a.serviceName + '.service');
@@ -12,7 +16,7 @@ Before=${allServices}
12
16
  [Service]
13
17
  Type=oneshot
14
18
  RemainAfterExit=yes
15
- ExecStart=/usr/bin/node /home/matt/fleet/dist/index.js secrets unseal
19
+ ExecStart=/usr/bin/node ${fleetBin} secrets unseal
16
20
  ExecStop=/bin/rm -rf /run/fleet-secrets
17
21
  TimeoutStartSec=30
18
22
 
@@ -44,6 +44,16 @@ const viewHints = {
44
44
  { key: 'Esc', label: 'back' },
45
45
  { key: 'q', label: 'quit' },
46
46
  ],
47
+ 'logs-multi': [
48
+ { key: 'Tab', label: 'switch focus' },
49
+ { key: 'j/k', label: 'pick' },
50
+ { key: 'Space', label: 'toggle' },
51
+ { key: 'a', label: 'all/none' },
52
+ { key: 'p', label: 'pause' },
53
+ { key: 'c', label: 'clear' },
54
+ { key: 'L', label: 'level' },
55
+ { key: 'q', label: 'quit' },
56
+ ],
47
57
  };
48
58
  export function KeyHint() {
49
59
  const { currentView, confirmAction } = useAppState();
@@ -1,4 +1,4 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -32,26 +32,40 @@ export function runFleetJson(args) {
32
32
  });
33
33
  }
34
34
  export function streamFleetCommand(args) {
35
- const child = execFile('node', [FLEET_BIN, ...args], {
36
- maxBuffer: 10 * 1024 * 1024,
35
+ const child = spawn('node', [FLEET_BIN, ...args], {
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
37
  });
38
38
  const callbacks = [];
39
- child.stdout?.on('data', (chunk) => {
40
- const lines = chunk.toString().split('\n').filter(Boolean);
41
- for (const line of lines) {
39
+ const buffered = [];
40
+ const MAX_BUFFER = 1000;
41
+ function dispatch(line) {
42
+ if (callbacks.length === 0) {
43
+ if (buffered.length >= MAX_BUFFER)
44
+ buffered.shift();
45
+ buffered.push(line);
46
+ }
47
+ else {
42
48
  for (const cb of callbacks)
43
49
  cb(line);
44
50
  }
51
+ }
52
+ child.stdout.on('data', (chunk) => {
53
+ for (const line of chunk.toString().split('\n').filter(Boolean)) {
54
+ dispatch(line);
55
+ }
45
56
  });
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);
57
+ child.stderr.on('data', (chunk) => {
58
+ for (const line of chunk.toString().split('\n').filter(Boolean)) {
59
+ dispatch(line);
51
60
  }
52
61
  });
53
62
  return {
54
63
  kill: () => child.kill(),
55
- onData: (cb) => callbacks.push(cb),
64
+ onData: (cb) => {
65
+ callbacks.push(cb);
66
+ for (const line of buffered)
67
+ cb(line);
68
+ buffered.length = 0;
69
+ },
56
70
  };
57
71
  }
@@ -1,8 +1,11 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { useStableState } from '@matthesketh/ink-stable-state';
2
3
  import { runFleetJson } from '../exec-bridge.js';
3
4
  import { useInterval } from './use-interval.js';
4
5
  export function useFleetData(autoRefreshMs = 10_000) {
5
- const [status, setStatus] = useState(null);
6
+ // useStableState short-circuits setStatus when the polled payload is
7
+ // structurally equal to the previous one — no flicker on identical refreshes.
8
+ const [status, setStatus] = useStableState(null);
6
9
  const [loading, setLoading] = useState(true);
7
10
  const [error, setError] = useState(null);
8
11
  const initialised = useRef(false);
@@ -21,7 +24,7 @@ export function useFleetData(autoRefreshMs = 10_000) {
21
24
  }
22
25
  setLoading(false);
23
26
  });
24
- }, []);
27
+ }, [setStatus]);
25
28
  useEffect(() => {
26
29
  refresh();
27
30
  }, [refresh]);
@@ -1,8 +1,11 @@
1
1
  import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { useStableState } from '@matthesketh/ink-stable-state';
2
3
  import { runFleetJson } from '../exec-bridge.js';
3
4
  import { useInterval } from './use-interval.js';
4
5
  export function useHealth(autoRefreshMs = 15_000) {
5
- const [results, setResults] = useState([]);
6
+ // useStableState short-circuits setState when the new payload is structurally
7
+ // equal to the previous one — no flicker on identical poll cycles.
8
+ const [results, setResults] = useStableState([]);
6
9
  const [loading, setLoading] = useState(true);
7
10
  const [error, setError] = useState(null);
8
11
  const initialised = useRef(false);
@@ -20,7 +23,7 @@ export function useHealth(autoRefreshMs = 15_000) {
20
23
  }
21
24
  setLoading(false);
22
25
  });
23
- }, []);
26
+ }, [setResults]);
24
27
  useEffect(() => {
25
28
  refresh();
26
29
  }, [refresh]);