@matthesketh/fleet 1.1.0 → 1.6.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 (217) 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 +43 -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/logs.d.ts +1 -1
  39. package/dist/commands/logs.js +237 -8
  40. package/dist/commands/patch-systemd.d.ts +1 -0
  41. package/dist/commands/patch-systemd.js +126 -0
  42. package/dist/commands/rollback.d.ts +1 -0
  43. package/dist/commands/rollback.js +58 -0
  44. package/dist/commands/routine-run.d.ts +1 -0
  45. package/dist/commands/routine-run.js +122 -0
  46. package/dist/commands/routines.d.ts +1 -0
  47. package/dist/commands/routines.js +25 -0
  48. package/dist/commands/secrets.js +449 -16
  49. package/dist/commands/status.js +7 -3
  50. package/dist/commands/watchdog.d.ts +1 -1
  51. package/dist/commands/watchdog.js +16 -40
  52. package/dist/core/boot-refresh.d.ts +57 -0
  53. package/dist/core/boot-refresh.js +116 -0
  54. package/dist/core/deps/actors/pr-creator.js +11 -9
  55. package/dist/core/deps/collectors/docker-running.js +2 -2
  56. package/dist/core/deps/collectors/github-pr.js +5 -2
  57. package/dist/core/deps/collectors/npm.js +10 -5
  58. package/dist/core/deps/collectors/vulnerability.js +10 -6
  59. package/dist/core/deps/reporters/motd.js +1 -1
  60. package/dist/core/deps/reporters/telegram.js +2 -29
  61. package/dist/core/docker.js +45 -15
  62. package/dist/core/egress.d.ts +41 -0
  63. package/dist/core/egress.js +161 -0
  64. package/dist/core/exec.d.ts +7 -1
  65. package/dist/core/exec.js +25 -17
  66. package/dist/core/git.d.ts +1 -0
  67. package/dist/core/git.js +36 -23
  68. package/dist/core/github.js +27 -8
  69. package/dist/core/health.d.ts +3 -0
  70. package/dist/core/health.js +15 -3
  71. package/dist/core/logs-multi.d.ts +73 -0
  72. package/dist/core/logs-multi.js +163 -0
  73. package/dist/core/logs-policy.d.ts +55 -0
  74. package/dist/core/logs-policy.js +148 -0
  75. package/dist/core/nginx.js +8 -4
  76. package/dist/core/notify.d.ts +15 -0
  77. package/dist/core/notify.js +55 -0
  78. package/dist/core/registry.d.ts +25 -0
  79. package/dist/core/registry.js +57 -10
  80. package/dist/core/routines/cost-queries.d.ts +24 -0
  81. package/dist/core/routines/cost-queries.js +65 -0
  82. package/dist/core/routines/db.d.ts +9 -0
  83. package/dist/core/routines/db.js +126 -0
  84. package/dist/core/routines/defaults.d.ts +2 -0
  85. package/dist/core/routines/defaults.js +72 -0
  86. package/dist/core/routines/engine.d.ts +59 -0
  87. package/dist/core/routines/engine.js +175 -0
  88. package/dist/core/routines/incidents.d.ts +13 -0
  89. package/dist/core/routines/incidents.js +35 -0
  90. package/dist/core/routines/schema.d.ts +418 -0
  91. package/dist/core/routines/schema.js +113 -0
  92. package/dist/core/routines/signals-collector.d.ts +35 -0
  93. package/dist/core/routines/signals-collector.js +114 -0
  94. package/dist/core/routines/store.d.ts +316 -0
  95. package/dist/core/routines/store.js +99 -0
  96. package/dist/core/routines/test-utils.d.ts +2 -0
  97. package/dist/core/routines/test-utils.js +13 -0
  98. package/dist/core/secrets-audit.d.ts +21 -0
  99. package/dist/core/secrets-audit.js +60 -0
  100. package/dist/core/secrets-metadata.d.ts +39 -0
  101. package/dist/core/secrets-metadata.js +82 -0
  102. package/dist/core/secrets-motd.d.ts +20 -0
  103. package/dist/core/secrets-motd.js +72 -0
  104. package/dist/core/secrets-ops.d.ts +3 -1
  105. package/dist/core/secrets-ops.js +78 -13
  106. package/dist/core/secrets-providers.d.ts +50 -0
  107. package/dist/core/secrets-providers.js +291 -0
  108. package/dist/core/secrets-rotation.d.ts +52 -0
  109. package/dist/core/secrets-rotation.js +165 -0
  110. package/dist/core/secrets-snapshots.d.ts +26 -0
  111. package/dist/core/secrets-snapshots.js +95 -0
  112. package/dist/core/secrets-validate.js +2 -1
  113. package/dist/core/secrets.d.ts +12 -1
  114. package/dist/core/secrets.js +35 -24
  115. package/dist/core/self-update.d.ts +41 -0
  116. package/dist/core/self-update.js +73 -0
  117. package/dist/core/systemd.js +29 -12
  118. package/dist/core/telegram.d.ts +6 -0
  119. package/dist/core/telegram.js +32 -0
  120. package/dist/core/validate.d.ts +7 -0
  121. package/dist/core/validate.js +42 -0
  122. package/dist/index.js +0 -4
  123. package/dist/mcp/deps-tools.js +9 -1
  124. package/dist/mcp/git-tools.js +4 -4
  125. package/dist/mcp/server.js +193 -8
  126. package/dist/templates/systemd.js +3 -3
  127. package/dist/templates/unseal.js +5 -1
  128. package/dist/tui/components/Confirm.js +3 -4
  129. package/dist/tui/components/Header.js +37 -8
  130. package/dist/tui/components/KeyHint.js +14 -5
  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/hooks/use-terminal-size.d.ts +1 -0
  135. package/dist/tui/hooks/use-terminal-size.js +1 -0
  136. package/dist/tui/router.js +133 -8
  137. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  138. package/dist/tui/routines/RoutinesApp.js +277 -0
  139. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  140. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  141. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  142. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  143. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  144. package/dist/tui/routines/components/CommandPalette.js +21 -0
  145. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  146. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  147. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  148. package/dist/tui/routines/components/RoutineForm.js +254 -0
  149. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  150. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  151. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  152. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  153. package/dist/tui/routines/format.d.ts +7 -0
  154. package/dist/tui/routines/format.js +51 -0
  155. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  156. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  157. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  158. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  159. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  160. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  161. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  162. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  163. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  164. package/dist/tui/routines/hooks/use-security.js +110 -0
  165. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  166. package/dist/tui/routines/hooks/use-signals.js +60 -0
  167. package/dist/tui/routines/runtime.d.ts +20 -0
  168. package/dist/tui/routines/runtime.js +40 -0
  169. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  170. package/dist/tui/routines/tabs/CostTab.js +24 -0
  171. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  172. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  173. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/GitTab.js +39 -0
  175. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  177. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  178. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  179. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  180. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  181. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  182. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  183. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  184. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  185. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  187. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  188. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  189. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  190. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  191. package/dist/tui/state.js +16 -1
  192. package/dist/tui/tests/flicker.test.d.ts +1 -0
  193. package/dist/tui/tests/flicker.test.js +105 -0
  194. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  195. package/dist/tui/tests/keyboard-integration.test.js +120 -0
  196. package/dist/tui/tests/test-app.d.ts +4 -0
  197. package/dist/tui/tests/test-app.js +79 -0
  198. package/dist/tui/types.d.ts +14 -1
  199. package/dist/tui/views/AppDetail.js +40 -26
  200. package/dist/tui/views/Dashboard.js +34 -9
  201. package/dist/tui/views/HealthView.js +42 -12
  202. package/dist/tui/views/LogsView.js +38 -10
  203. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  204. package/dist/tui/views/MultiLogsView.js +165 -0
  205. package/dist/tui/views/SecretEdit.js +18 -7
  206. package/dist/tui/views/SecretsView.js +55 -39
  207. package/dist/ui/prompt.d.ts +52 -0
  208. package/dist/ui/prompt.js +169 -0
  209. package/package.json +33 -5
  210. package/dist/commands/motd.d.ts +0 -1
  211. package/dist/commands/motd.js +0 -10
  212. package/dist/templates/motd.d.ts +0 -1
  213. package/dist/templates/motd.js +0 -7
  214. package/dist/tui/components/AppList.d.ts +0 -12
  215. package/dist/tui/components/AppList.js +0 -32
  216. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  217. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -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>;
@@ -0,0 +1,122 @@
1
+ import { load } from '../core/registry.js';
2
+ import { createRuntime } from '../tui/routines/runtime.js';
3
+ import { error } from '../ui/output.js';
4
+ function parseArgs(argv) {
5
+ const out = {};
6
+ for (let i = 0; i < argv.length; i++) {
7
+ const arg = argv[i];
8
+ switch (arg) {
9
+ case '--id':
10
+ out.id = argv[++i];
11
+ break;
12
+ case '--target':
13
+ out.target = argv[++i];
14
+ break;
15
+ case '--trigger': {
16
+ const v = argv[++i];
17
+ if (v === 'manual' || v === 'scheduled' || v === 'api')
18
+ out.trigger = v;
19
+ break;
20
+ }
21
+ case '--json':
22
+ out.json = true;
23
+ break;
24
+ case '-h':
25
+ case '--help':
26
+ out.help = true;
27
+ break;
28
+ default:
29
+ break;
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ const HELP = `fleet routine-run - execute a registered routine
35
+
36
+ Usage: fleet routine-run --id <routine-id> [options]
37
+
38
+ Options:
39
+ --id <id> Required. The routine id to run.
40
+ --target <repo> Optional. Run scoped to a single registered repo.
41
+ --trigger <source> manual (default) | scheduled | api
42
+ --json Emit events as JSON lines to stdout
43
+ -h, --help Show this help
44
+
45
+ Exit codes:
46
+ 0 routine ended with status=ok
47
+ 1 routine ended with status=failed or timeout or aborted
48
+ 2 invocation error (unknown id, bad args)
49
+
50
+ Intended entrypoint for systemd-timer-generated units. Persists every
51
+ event to sqlite and exits with the run's status.
52
+ `;
53
+ export async function routineRunCommand(argv) {
54
+ const args = parseArgs(argv);
55
+ if (args.help || !args.id) {
56
+ process.stdout.write(HELP);
57
+ process.exit(args.help ? 0 : 2);
58
+ return;
59
+ }
60
+ const runtime = createRuntime({ seedDefaults: false });
61
+ const registry = load();
62
+ const repoPath = args.target
63
+ ? registry.apps.find(a => a.name === args.target)?.composePath ?? null
64
+ : null;
65
+ if (args.target && !repoPath) {
66
+ error(`unknown target repo: ${args.target}`);
67
+ runtime.close();
68
+ process.exit(2);
69
+ return;
70
+ }
71
+ const routine = runtime.store.get(args.id);
72
+ if (!routine) {
73
+ error(`routine not found: ${args.id}`);
74
+ runtime.close();
75
+ process.exit(2);
76
+ return;
77
+ }
78
+ const ac = new AbortController();
79
+ const onSignal = () => ac.abort();
80
+ process.on('SIGINT', onSignal);
81
+ process.on('SIGTERM', onSignal);
82
+ let finalStatus = null;
83
+ try {
84
+ for await (const ev of runtime.engine.runOnce(args.id, { repo: args.target ?? null, repoPath }, args.trigger ?? 'manual', ac.signal)) {
85
+ if (args.json) {
86
+ process.stdout.write(`${JSON.stringify(ev)}\n`);
87
+ continue;
88
+ }
89
+ switch (ev.kind) {
90
+ case 'start':
91
+ process.stdout.write(`▶ ${args.id}${ev.target ? ` · ${ev.target}` : ''}\n`);
92
+ break;
93
+ case 'stdout':
94
+ process.stdout.write(ev.chunk);
95
+ break;
96
+ case 'stderr':
97
+ process.stderr.write(ev.chunk);
98
+ break;
99
+ case 'tool-call':
100
+ process.stdout.write(` ↳ ${ev.name}${ev.argsPreview ? ` ${ev.argsPreview}` : ''}\n`);
101
+ break;
102
+ case 'cost':
103
+ process.stdout.write(` $${ev.usd.toFixed(4)} in=${ev.inputTokens} out=${ev.outputTokens}\n`);
104
+ break;
105
+ case 'end':
106
+ finalStatus = ev.status;
107
+ process.stdout.write(`◼ ${ev.status} exit=${ev.exitCode} (${ev.durationMs}ms)${ev.error ? ` — ${ev.error}` : ''}\n`);
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ catch (err) {
113
+ error(`run failed: ${err.message}`);
114
+ runtime.close();
115
+ process.exit(1);
116
+ return;
117
+ }
118
+ process.off('SIGINT', onSignal);
119
+ process.off('SIGTERM', onSignal);
120
+ runtime.close();
121
+ process.exit(finalStatus === 'ok' ? 0 : 1);
122
+ }
@@ -0,0 +1 @@
1
+ export declare function routinesCommand(_args: string[]): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render, Box, Text } from 'ink';
3
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
4
+ import { Viewport } from '@matthesketh/ink-viewport';
5
+ import { ToastProvider, ToastContainer } from '@matthesketh/ink-toast';
6
+ import { load } from '../core/registry.js';
7
+ import { RoutinesApp } from '../tui/routines/RoutinesApp.js';
8
+ import { createRuntime } from '../tui/routines/runtime.js';
9
+ function Shell({ runtime, registry }) {
10
+ const globalHandler = (input, _key) => {
11
+ if (input === 'q') {
12
+ runtime.close();
13
+ process.exit(0);
14
+ }
15
+ return false;
16
+ };
17
+ return (_jsx(ToastProvider, { children: _jsx(InputDispatcher, { globalHandler: globalHandler, children: _jsx(Viewport, { chrome: 2, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { paddingX: 1, paddingY: 0, children: [_jsx(Text, { bold: true, color: "cyan", children: "fleet routines" }), _jsx(Text, { color: "gray", children: " \u00B7 q to quit" })] }), _jsx(RoutinesApp, { runtime: runtime, registry: registry }), _jsx(ToastContainer, {})] }) }) }) }));
18
+ }
19
+ export async function routinesCommand(_args) {
20
+ const runtime = createRuntime();
21
+ const registry = load();
22
+ const { waitUntilExit } = render(_jsx(Shell, { runtime: runtime, registry: registry }));
23
+ await waitUntilExit();
24
+ runtime.close();
25
+ }