@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,144 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { error, info, success } from '../ui/output.js';
6
+ const SCRIPTS = [
7
+ { name: 'notify', mode: 0o700 },
8
+ { name: 'fleet-guard', mode: 0o750, group: 'fleet-guard' },
9
+ { name: 'fleet-guard-execute', mode: 0o750, group: 'fleet-guard' },
10
+ { name: 'cf-audit-monitor', mode: 0o700 },
11
+ { name: 'cf-snapshot', mode: 0o700 },
12
+ { name: 'dns-drift-watch', mode: 0o750, group: 'fleet-guard' },
13
+ { name: 'cert-expiry-watch', mode: 0o750, group: 'fleet-guard' },
14
+ ];
15
+ const TARGET_BIN = '/usr/local/sbin';
16
+ const STATE_DIR = '/var/lib/fleet-guard';
17
+ const LOG_DIR = '/var/log/fleet-guard';
18
+ const SNAP_DIR = '/var/lib/cf-snapshots';
19
+ const CRON_TARGET = '/etc/cron.d/cf-protect';
20
+ function scriptsDir() {
21
+ // dist/commands/guard.js -> ../../scripts/guard relative to compiled file
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ return join(here, '..', '..', 'scripts', 'guard');
24
+ }
25
+ function requireRoot() {
26
+ if (process.getuid && process.getuid() !== 0) {
27
+ throw new Error('this command needs root. try: sudo fleet guard install');
28
+ }
29
+ }
30
+ function run(cmd, args) {
31
+ const r = spawnSync(cmd, args, { stdio: 'inherit' });
32
+ if (r.status !== 0)
33
+ throw new Error(`${cmd} ${args.join(' ')} failed`);
34
+ }
35
+ function ensureUser() {
36
+ const r = spawnSync('id', ['fleet-guard'], { stdio: 'ignore' });
37
+ if (r.status === 0)
38
+ return;
39
+ run('useradd', ['--system', '--no-create-home', '--shell', '/usr/sbin/nologin', 'fleet-guard']);
40
+ info('created system user fleet-guard');
41
+ }
42
+ function ensureDir(path, mode, group) {
43
+ if (!existsSync(path))
44
+ mkdirSync(path, { recursive: true });
45
+ chmodSync(path, mode);
46
+ if (group)
47
+ run('chgrp', ['-R', group, path]);
48
+ }
49
+ function installScripts() {
50
+ const src = scriptsDir();
51
+ if (!existsSync(src)) {
52
+ throw new Error(`scripts not bundled at ${src} — broken install`);
53
+ }
54
+ for (const s of SCRIPTS) {
55
+ const from = join(src, s.name);
56
+ const to = join(TARGET_BIN, s.name);
57
+ if (!existsSync(from))
58
+ throw new Error(`missing bundled script: ${from}`);
59
+ copyFileSync(from, to);
60
+ chmodSync(to, s.mode);
61
+ if (s.group)
62
+ run('chown', [`root:${s.group}`, to]);
63
+ info(`installed ${to}`);
64
+ }
65
+ }
66
+ function installCron() {
67
+ const cron = `# fleet guard — auto-installed, edit with care
68
+ SHELL=/bin/bash
69
+ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
70
+
71
+ */15 * * * * root /usr/local/sbin/cf-audit-monitor >> /var/log/cf-audit-monitor.log 2>&1
72
+ */30 * * * * root /usr/local/sbin/cf-snapshot >> /var/log/cf-snapshot.log 2>&1
73
+ */30 * * * * root /usr/local/sbin/dns-drift-watch >> /var/log/dns-drift-watch.log 2>&1
74
+ 17 4 * * * root /usr/local/sbin/cert-expiry-watch >> /var/log/cert-expiry-watch.log 2>&1
75
+ * * * * * fleet-guard /usr/local/sbin/fleet-guard execute >> /var/log/fleet-guard/execute.log 2>&1
76
+ `;
77
+ writeFileSync(CRON_TARGET, cron, { mode: 0o644 });
78
+ info(`installed cron at ${CRON_TARGET}`);
79
+ }
80
+ function installCommand() {
81
+ requireRoot();
82
+ ensureUser();
83
+ ensureDir(STATE_DIR, 0o700, 'fleet-guard');
84
+ ensureDir(join(STATE_DIR, 'pending'), 0o700, 'fleet-guard');
85
+ ensureDir(join(STATE_DIR, 'approved'), 0o700, 'fleet-guard');
86
+ ensureDir(join(STATE_DIR, 'processed'), 0o700, 'fleet-guard');
87
+ ensureDir(LOG_DIR, 0o700, 'fleet-guard');
88
+ ensureDir(SNAP_DIR, 0o700);
89
+ installScripts();
90
+ installCron();
91
+ success('fleet guard installed.');
92
+ info('next steps:');
93
+ info(' 1. seed creds at /etc/fleet/guard.cf.json (cloudflare api key + email + accountId)');
94
+ info(' 2. ensure /etc/fleet/notify.json has telegram and/or bluebubbles adapters');
95
+ info(' 3. add /approve, /reject, /guard commands to fleet-bot (PR #60 in fleet repo)');
96
+ }
97
+ function delegate(verb, args) {
98
+ // every other verb just shells out to the host /usr/local/sbin/fleet-guard cli
99
+ // so we have a single source of truth for the queue logic.
100
+ const r = spawnSync('/usr/local/sbin/fleet-guard', [verb, ...args], { stdio: 'inherit' });
101
+ return r.status ?? 1;
102
+ }
103
+ function helpText() {
104
+ return [
105
+ 'fleet guard <subcommand>',
106
+ '',
107
+ 'subcommands:',
108
+ ' install install scripts, user, cron, dirs (root)',
109
+ ' status show queue counts + pending tokens',
110
+ ' list [pending|approved|processed] list records',
111
+ ' hold <kind> <summary> [--payload] create a pending action',
112
+ ' approve <token> approve a pending action',
113
+ ' reject <token> reject a pending action',
114
+ ' show <token> dump one record',
115
+ ' execute run all approved actions',
116
+ ].join('\n');
117
+ }
118
+ export function guardCommand(args) {
119
+ const [sub, ...rest] = args;
120
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
121
+ info(helpText());
122
+ return;
123
+ }
124
+ if (sub === 'install') {
125
+ try {
126
+ installCommand();
127
+ }
128
+ catch (e) {
129
+ error(e.message);
130
+ process.exit(1);
131
+ }
132
+ return;
133
+ }
134
+ const passthrough = new Set(['status', 'list', 'hold', 'approve', 'reject', 'show', 'execute']);
135
+ if (passthrough.has(sub)) {
136
+ const code = delegate(sub, rest);
137
+ if (code !== 0)
138
+ process.exit(code);
139
+ return;
140
+ }
141
+ error(`unknown subcommand: ${sub}`);
142
+ console.log(helpText());
143
+ process.exit(2);
144
+ }
@@ -1 +1 @@
1
- export declare function logsCommand(args: string[]): void;
1
+ export declare function logsCommand(args: string[]): void | Promise<void>;
@@ -2,31 +2,260 @@ import { load, findApp } from '../core/registry.js';
2
2
  import { getContainerLogs } from '../core/docker.js';
3
3
  import { execLive } from '../core/exec.js';
4
4
  import { AppNotFoundError } from '../core/errors.js';
5
- import { error } from '../ui/output.js';
5
+ import { c, error, heading, info, success, table, warn } from '../ui/output.js';
6
+ import { confirm } from '../ui/confirm.js';
7
+ import { prompt } from '../ui/prompt.js';
8
+ import { effectivePolicy, writeComposeOverride, getLogStatus, pruneLogs, readContainerLogs, } from '../core/logs-policy.js';
9
+ import { startMultiTail, resolveSources } from '../core/logs-multi.js';
6
10
  export function logsCommand(args) {
11
+ const sub = args[0];
12
+ if (sub === 'setup')
13
+ return logsSetup(args.slice(1));
14
+ if (sub === 'status')
15
+ return logsStatus(args.slice(1));
16
+ if (sub === 'prune')
17
+ return logsPrune(args.slice(1));
18
+ // --all / --apps / --containers route to the multi-source tail.
19
+ if (args.includes('--all') || args.includes('--apps') || args.includes('--containers')) {
20
+ return logsMulti(args);
21
+ }
22
+ // Default: single-app tail / follow — synchronous so existing test
23
+ // expectations and process.exit semantics work unchanged.
24
+ return logsTail(args);
25
+ }
26
+ // ── Slice 1: multi-source CLI tail ─────────────────────────────────────────
27
+ const SOURCE_COLORS = [c.cyan, c.green, c.yellow, c.magenta, c.blue, c.red];
28
+ const sourceColorCache = new Map();
29
+ function colorForSource(name) {
30
+ let cached = sourceColorCache.get(name);
31
+ if (cached)
32
+ return cached;
33
+ // Stable hash → colour assignment so the same source always gets the same colour.
34
+ let h = 0;
35
+ for (let i = 0; i < name.length; i++)
36
+ h = (h * 31 + name.charCodeAt(i)) | 0;
37
+ cached = SOURCE_COLORS[Math.abs(h) % SOURCE_COLORS.length];
38
+ sourceColorCache.set(name, cached);
39
+ return cached;
40
+ }
41
+ function logsMulti(args) {
42
+ const all = args.includes('--all');
43
+ const follow = args.includes('-f') || args.includes('--follow');
44
+ const valOf = (flag) => {
45
+ const i = args.indexOf(flag);
46
+ return i >= 0 ? args[i + 1] : undefined;
47
+ };
48
+ const appsCsv = valOf('--apps');
49
+ const containersCsv = valOf('--containers');
50
+ const since = valOf('--since');
51
+ const grep = valOf('--grep');
52
+ const level = valOf('--level');
53
+ const tail = parseInt(valOf('--tail') ?? valOf('-n') ?? '50', 10) || 50;
54
+ if (!all && !appsCsv && !containersCsv) {
55
+ error('Usage: fleet logs --all [-f] [--since 15m] [--grep err] [--level warn]');
56
+ error(' fleet logs --apps macpool,shiftfaced [-f]');
57
+ error(' fleet logs --containers "*-postgres" [-f]');
58
+ process.exit(1);
59
+ }
60
+ const reg = load();
61
+ const sources = resolveSources(reg.apps, {
62
+ apps: appsCsv ? appsCsv.split(',').map(s => s.trim()).filter(Boolean) : undefined,
63
+ containers: containersCsv ? containersCsv.split(',').map(s => s.trim()).filter(Boolean) : undefined,
64
+ });
65
+ if (sources.length === 0) {
66
+ error('No matching containers found.');
67
+ process.exit(1);
68
+ }
69
+ // Width-align the prefix so lines stack readably.
70
+ const maxLabelLen = Math.max(...sources.map(s => `${s.app}/${s.container}`.length));
71
+ const onLine = (l) => {
72
+ const label = `${l.app}/${l.container}`.padEnd(maxLabelLen);
73
+ const colour = colorForSource(`${l.app}/${l.container}`);
74
+ process.stdout.write(`${colour}${label}${c.reset} ${l.text}\n`);
75
+ };
76
+ return new Promise(resolve => {
77
+ const handle = startMultiTail(sources, { tail, since, grep, level, follow }, onLine);
78
+ const shutdown = async (signal) => {
79
+ process.removeListener('SIGINT', sigintHandler);
80
+ process.removeListener('SIGTERM', sigtermHandler);
81
+ await handle.stop();
82
+ if (signal !== 'exit')
83
+ process.stderr.write(`\nStopped (${signal})\n`);
84
+ resolve();
85
+ };
86
+ const sigintHandler = () => { void shutdown('SIGINT'); };
87
+ const sigtermHandler = () => { void shutdown('SIGTERM'); };
88
+ process.on('SIGINT', sigintHandler);
89
+ process.on('SIGTERM', sigtermHandler);
90
+ // Non-follow: poll until all tailers have closed, then resolve.
91
+ if (!follow) {
92
+ const tick = () => {
93
+ if (handle.active() === 0) {
94
+ void shutdown('exit');
95
+ return;
96
+ }
97
+ setTimeout(tick, 100);
98
+ };
99
+ setTimeout(tick, 100);
100
+ }
101
+ });
102
+ }
103
+ function logsTail(args) {
7
104
  const follow = args.includes('-f') || args.includes('--follow');
8
105
  const nIdx = args.indexOf('-n');
9
106
  const lines = nIdx >= 0 ? parseInt(args[nIdx + 1], 10) || 100 : 100;
10
- const appName = args.find(a => !a.startsWith('-') && (nIdx < 0 || args.indexOf(a) !== nIdx + 1));
107
+ const cIdx = args.indexOf('-c');
108
+ const containerArg = cIdx >= 0 ? args[cIdx + 1] : undefined;
109
+ const sinceIdx = args.indexOf('--since');
110
+ const since = sinceIdx >= 0 ? args[sinceIdx + 1] : undefined;
111
+ const grepIdx = args.indexOf('--grep');
112
+ const grep = grepIdx >= 0 ? args[grepIdx + 1] : undefined;
113
+ const levelIdx = args.indexOf('--level');
114
+ const level = levelIdx >= 0 ? args[levelIdx + 1] : undefined;
115
+ const skipIndices = new Set();
116
+ for (const i of [nIdx, cIdx, sinceIdx, grepIdx, levelIdx]) {
117
+ if (i >= 0) {
118
+ skipIndices.add(i);
119
+ skipIndices.add(i + 1);
120
+ }
121
+ }
122
+ const appName = args.find((a, i) => !a.startsWith('-') && !skipIndices.has(i));
11
123
  if (!appName) {
12
- error('Usage: fleet logs <app> [-f] [-n <lines>]');
124
+ error('Usage: fleet logs <app> [-f] [-n <lines>] [-c <container>]');
125
+ error(' Subcommands: setup [--all] | status [<app>] | prune <app>');
126
+ error(' Tail filters: --since <Nm|Nh> | --grep <text> | --level info|warn|error');
13
127
  process.exit(1);
14
128
  }
15
129
  const reg = load();
16
130
  const app = findApp(reg, appName);
17
131
  if (!app)
18
132
  throw new AppNotFoundError(appName);
19
- const container = app.containers[0];
20
- if (!container) {
133
+ if (app.containers.length === 0) {
21
134
  error(`No containers registered for ${app.name}`);
22
135
  process.exit(1);
23
136
  }
137
+ let container = containerArg ?? app.containers[0];
138
+ if (containerArg && !app.containers.includes(containerArg)) {
139
+ error(`Container "${containerArg}" not found in ${app.name}. Available:`);
140
+ for (const ct of app.containers)
141
+ process.stderr.write(` - ${ct}\n`);
142
+ process.exit(1);
143
+ }
24
144
  if (follow) {
25
- const code = execLive('docker', ['logs', '-f', '--tail', lines.toString(), container]);
145
+ // For follow mode we delegate to native docker filtering would buffer.
146
+ const dockerArgs = ['logs', '-f', '--tail', lines.toString()];
147
+ if (since)
148
+ dockerArgs.push('--since', since);
149
+ dockerArgs.push(container);
150
+ const code = execLive('docker', dockerArgs);
26
151
  process.exit(code);
27
152
  }
153
+ // Non-follow: use the policy-aware reader so --level / --grep / size cap apply.
154
+ if (since || grep || level) {
155
+ const sinceMinutes = since ? parseSinceMinutes(since) : undefined;
156
+ const result = readContainerLogs(container, { lines, level, sinceMinutes, grep });
157
+ process.stdout.write(result.text + '\n');
158
+ if (result.truncated) {
159
+ warn('Output truncated at 200KB. Narrow with --since/--grep/--level/-n.');
160
+ }
161
+ return;
162
+ }
163
+ // Plain tail: existing fast path.
164
+ const output = getContainerLogs(container, lines);
165
+ process.stdout.write(output + '\n');
166
+ }
167
+ function parseSinceMinutes(s) {
168
+ const m = s.match(/^(\d+)([mhd])?$/);
169
+ if (!m)
170
+ return 60;
171
+ const n = parseInt(m[1], 10);
172
+ const unit = m[2] ?? 'm';
173
+ return unit === 'h' ? n * 60 : unit === 'd' ? n * 1440 : n;
174
+ }
175
+ async function logsSetup(args) {
176
+ const all = args.includes('--all');
177
+ const yes = args.includes('-y') || args.includes('--yes');
178
+ const reg = load();
179
+ const apps = all ? reg.apps : (() => {
180
+ const name = args.find(a => !a.startsWith('-'));
181
+ if (!name) {
182
+ error('Usage: fleet logs setup <app> OR fleet logs setup --all');
183
+ process.exit(1);
184
+ }
185
+ const a = findApp(reg, name);
186
+ if (!a)
187
+ throw new AppNotFoundError(name);
188
+ return [a];
189
+ })();
190
+ let policy;
191
+ if (all || yes) {
192
+ policy = { retentionDays: 7, maxSizeMB: 100, level: 'info' };
193
+ info(`Applying default policy: ${policy.maxSizeMB}MB / ${policy.retentionDays}d / ${policy.level}`);
194
+ }
28
195
  else {
29
- const output = getContainerLogs(container, lines);
30
- process.stdout.write(output + '\n');
196
+ const ret = await prompt('Retention days', '7');
197
+ const size = await prompt('Max size MB per container', '100');
198
+ const lvl = await prompt('Min level (debug|info|warn|error)', 'info');
199
+ policy = {
200
+ retentionDays: parseInt(ret, 10) || 7,
201
+ maxSizeMB: parseInt(size, 10) || 100,
202
+ level: lvl ?? 'info',
203
+ };
204
+ }
205
+ for (const app of apps) {
206
+ const path = writeComposeOverride(app, policy);
207
+ success(`${app.name}: wrote ${path}`);
208
+ }
209
+ info('To activate: include the override in your compose start command,');
210
+ info(' e.g. `docker compose -f docker-compose.yml -f .fleet/logging.override.yml up -d`');
211
+ info('Or have fleet patch the systemd unit (see: fleet patch-systemd).');
212
+ }
213
+ function logsStatus(args) {
214
+ const json = args.includes('--json');
215
+ const appName = args.find(a => !a.startsWith('-'));
216
+ const reg = load();
217
+ const apps = appName ? [findApp(reg, appName)].filter(Boolean) : reg.apps;
218
+ const rows = [];
219
+ const data = [];
220
+ for (const app of apps) {
221
+ if (!app)
222
+ continue;
223
+ const policy = effectivePolicy(app);
224
+ const status = getLogStatus(app);
225
+ for (const s of status) {
226
+ data.push({ ...s, policy });
227
+ const sizeStr = s.totalBytes != null ? `${(s.totalBytes / 1024 / 1024).toFixed(1)}M` : '?';
228
+ const policyStr = `${policy.maxSizeMB}M/${policy.retentionDays}d/${policy.level}`;
229
+ const ind = s.policyApplied ? `${c.green}*${c.reset}` : `${c.yellow}!${c.reset}`;
230
+ rows.push([app.name, s.container, s.driver, sizeStr, policyStr, ind]);
231
+ }
232
+ }
233
+ if (json) {
234
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
235
+ return;
236
+ }
237
+ heading(`Log status (${rows.length} containers)`);
238
+ table(['APP', 'CONTAINER', 'DRIVER', 'SIZE', 'POLICY', 'CONFIGURED'], rows);
239
+ process.stdout.write('\n');
240
+ info('* = override file present, ! = using docker defaults (unbounded by default)');
241
+ }
242
+ async function logsPrune(args) {
243
+ const yes = args.includes('-y') || args.includes('--yes');
244
+ const appName = args.find(a => !a.startsWith('-'));
245
+ if (!appName) {
246
+ error('Usage: fleet logs prune <app>');
247
+ process.exit(1);
248
+ }
249
+ const reg = load();
250
+ const app = findApp(reg, appName);
251
+ if (!app)
252
+ throw new AppNotFoundError(appName);
253
+ const policy = effectivePolicy(app);
254
+ warn(`Will vacuum journald to ${policy.retentionDays}d and truncate any json-file logs > 5x the policy max.`);
255
+ if (!yes && !await confirm('Proceed?', false)) {
256
+ info('Cancelled');
257
+ return;
31
258
  }
259
+ const freed = pruneLogs(app, policy);
260
+ success(`Freed approximately ${(freed / 1024 / 1024).toFixed(1)}MB from json-file logs (journald vacuum applied separately).`);
32
261
  }
@@ -0,0 +1 @@
1
+ export declare function patchSystemdCommand(args: string[]): void;
@@ -0,0 +1,126 @@
1
+ import { copyFileSync, existsSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { load } from '../core/registry.js';
3
+ import { readServiceFile } from '../core/systemd.js';
4
+ import { execSafe } from '../core/exec.js';
5
+ import { success, warn, info, error } from '../ui/output.js';
6
+ const SERVICE_DIR = '/etc/systemd/system';
7
+ export function patchSystemdCommand(args) {
8
+ if (args.includes('--rollback'))
9
+ return rollback();
10
+ const reg = load();
11
+ const dbServiceName = reg.infrastructure.databases.serviceName;
12
+ const appServiceNames = reg.apps.map(a => a.serviceName);
13
+ // dedupe by service name with infra (rewriteExecStart=false) winning. a stale
14
+ // registry can list docker-databases under both reg.apps and infrastructure;
15
+ // without this guard the apps entry would rewrite ExecStart on the shared
16
+ // databases service, defeating the safety carve-out.
17
+ const targetMap = new Map();
18
+ for (const name of appServiceNames) {
19
+ targetMap.set(name, { name, rewriteExecStart: true });
20
+ }
21
+ targetMap.set(dbServiceName, { name: dbServiceName, rewriteExecStart: false });
22
+ const targets = Array.from(targetMap.values());
23
+ info(`Patching ${targets.length} service(s)...`);
24
+ let patched = 0;
25
+ let skipped = 0;
26
+ for (const { name, rewriteExecStart } of targets) {
27
+ const path = `${SERVICE_DIR}/${name}.service`;
28
+ const content = readServiceFile(name);
29
+ if (content === null) {
30
+ warn(`${name}: no service file found, skipping`);
31
+ skipped++;
32
+ continue;
33
+ }
34
+ let updated = content;
35
+ let changed = false;
36
+ // Existing behavior: add StartLimitBurst if missing (applies to ALL services including databases)
37
+ if (!updated.includes('StartLimitBurst=')) {
38
+ updated = updated.replace(/(\[Service\])/, '$1\nStartLimitBurst=5\nStartLimitIntervalSec=300');
39
+ changed = true;
40
+ }
41
+ // ExecStart + TimeoutStartSec rewrite ONLY for app services — databases has no git repo
42
+ if (rewriteExecStart) {
43
+ const expectedExecStart = `ExecStart=/usr/bin/env fleet boot-start ${name}`;
44
+ if (!updated.includes(expectedExecStart)) {
45
+ updated = updated.replace(/^ExecStart=.*$/m, expectedExecStart);
46
+ changed = true;
47
+ }
48
+ // Ensure TimeoutStartSec=900
49
+ if (!updated.includes('TimeoutStartSec=900')) {
50
+ if (/^TimeoutStartSec=\d+/m.test(updated)) {
51
+ updated = updated.replace(/^TimeoutStartSec=\d+.*$/m, 'TimeoutStartSec=900');
52
+ }
53
+ else {
54
+ updated = updated.replace(/(\[Service\])/, '$1\nTimeoutStartSec=900');
55
+ }
56
+ changed = true;
57
+ }
58
+ }
59
+ if (!changed) {
60
+ info(`${name}: already patched, skipping`);
61
+ skipped++;
62
+ continue;
63
+ }
64
+ // Backup original before overwrite
65
+ try {
66
+ copyFileSync(path, `${path}.bak`);
67
+ }
68
+ catch (err) {
69
+ warn(`${name}: failed to create .bak (${err instanceof Error ? err.message : String(err)}); skipping for safety`);
70
+ skipped++;
71
+ continue;
72
+ }
73
+ writeFileSync(path, updated);
74
+ success(`${name}: patched`);
75
+ patched++;
76
+ }
77
+ if (patched === 0) {
78
+ info('No services needed patching');
79
+ return;
80
+ }
81
+ info('Running systemctl daemon-reload...');
82
+ const result = execSafe('systemctl', ['daemon-reload']);
83
+ if (result.ok) {
84
+ success(`Done — patched ${patched} service(s), skipped ${skipped}`);
85
+ }
86
+ else {
87
+ warn(`daemon-reload failed: ${result.stderr}`);
88
+ }
89
+ }
90
+ function rollback() {
91
+ const reg = load();
92
+ const serviceNames = [
93
+ ...reg.apps.map(a => a.serviceName),
94
+ reg.infrastructure.databases.serviceName,
95
+ ];
96
+ let restored = 0;
97
+ let missing = 0;
98
+ for (const name of serviceNames) {
99
+ const path = `${SERVICE_DIR}/${name}.service`;
100
+ const bak = `${path}.bak`;
101
+ if (!existsSync(bak)) {
102
+ missing++;
103
+ continue;
104
+ }
105
+ try {
106
+ renameSync(bak, path);
107
+ success(`${name}: restored from .bak`);
108
+ restored++;
109
+ }
110
+ catch (err) {
111
+ error(`${name}: failed to restore: ${err instanceof Error ? err.message : String(err)}`);
112
+ }
113
+ }
114
+ if (restored === 0) {
115
+ info('No .bak files found to restore');
116
+ return;
117
+ }
118
+ info('Running systemctl daemon-reload...');
119
+ const result = execSafe('systemctl', ['daemon-reload']);
120
+ if (result.ok) {
121
+ success(`Done — restored ${restored}, missing ${missing}`);
122
+ }
123
+ else {
124
+ warn(`daemon-reload failed: ${result.stderr}`);
125
+ }
126
+ }
@@ -0,0 +1 @@
1
+ export declare function rollbackCommand(args: string[]): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { load, findApp } from '../core/registry.js';
2
+ import { execSafe } from '../core/exec.js';
3
+ import { restartService } from '../core/systemd.js';
4
+ function log(msg) {
5
+ process.stdout.write(`[rollback] ${msg}\n`);
6
+ }
7
+ function logErr(msg) {
8
+ process.stderr.write(`[rollback] ${msg}\n`);
9
+ }
10
+ function resolveImageName(composePath, composeFile) {
11
+ const args = ['compose', ...(composeFile ? ['-f', composeFile] : []), 'config', '--images'];
12
+ const r = execSafe('docker', args, { cwd: composePath, timeout: 15_000 });
13
+ if (!r.ok)
14
+ return null;
15
+ return r.stdout.split('\n').filter(Boolean)[0] ?? null;
16
+ }
17
+ function splitImageBase(image) {
18
+ const lastColon = image.lastIndexOf(':');
19
+ if (lastColon <= 0)
20
+ return image;
21
+ return image.slice(0, lastColon);
22
+ }
23
+ export async function rollbackCommand(args) {
24
+ const appName = args[0];
25
+ if (!appName) {
26
+ logErr('Usage: fleet rollback <app>');
27
+ process.exit(1);
28
+ }
29
+ const reg = load();
30
+ const app = findApp(reg, appName);
31
+ if (!app) {
32
+ logErr(`app not found: ${appName}`);
33
+ process.exit(1);
34
+ }
35
+ const image = resolveImageName(app.composePath, app.composeFile);
36
+ if (!image) {
37
+ logErr(`could not resolve image name for ${app.name}`);
38
+ process.exit(1);
39
+ }
40
+ const base = splitImageBase(image);
41
+ const previous = `${base}:fleet-previous`;
42
+ const latest = image;
43
+ if (!execSafe('docker', ['image', 'inspect', previous], { timeout: 10_000 }).ok) {
44
+ logErr(`no previous image found (${previous}) — nothing to roll back to`);
45
+ process.exit(1);
46
+ }
47
+ const tag = execSafe('docker', ['tag', previous, latest], { timeout: 10_000 });
48
+ if (!tag.ok) {
49
+ logErr(`docker tag failed: ${tag.stderr || `exit ${tag.exitCode}`}`);
50
+ process.exit(1);
51
+ }
52
+ const ok = restartService(app.serviceName);
53
+ if (!ok) {
54
+ logErr(`tag restored but service restart failed for ${app.serviceName}`);
55
+ process.exit(1);
56
+ }
57
+ log(`rolled back ${app.name} to ${previous}`);
58
+ }
@@ -0,0 +1 @@
1
+ export declare function routineRunCommand(argv: string[]): Promise<void>;