@matthesketh/fleet 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +318 -0
  3. package/data/registry.example.json +13 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +113 -0
  6. package/dist/commands/add.d.ts +1 -0
  7. package/dist/commands/add.js +95 -0
  8. package/dist/commands/deploy.d.ts +1 -0
  9. package/dist/commands/deploy.js +53 -0
  10. package/dist/commands/git.d.ts +1 -0
  11. package/dist/commands/git.js +278 -0
  12. package/dist/commands/health.d.ts +1 -0
  13. package/dist/commands/health.js +60 -0
  14. package/dist/commands/init.d.ts +1 -0
  15. package/dist/commands/init.js +157 -0
  16. package/dist/commands/install-mcp.d.ts +1 -0
  17. package/dist/commands/install-mcp.js +55 -0
  18. package/dist/commands/list.d.ts +1 -0
  19. package/dist/commands/list.js +20 -0
  20. package/dist/commands/logs.d.ts +1 -0
  21. package/dist/commands/logs.js +32 -0
  22. package/dist/commands/nginx.d.ts +1 -0
  23. package/dist/commands/nginx.js +94 -0
  24. package/dist/commands/remove.d.ts +1 -0
  25. package/dist/commands/remove.js +28 -0
  26. package/dist/commands/restart.d.ts +1 -0
  27. package/dist/commands/restart.js +22 -0
  28. package/dist/commands/secrets.d.ts +1 -0
  29. package/dist/commands/secrets.js +268 -0
  30. package/dist/commands/start.d.ts +1 -0
  31. package/dist/commands/start.js +22 -0
  32. package/dist/commands/status.d.ts +14 -0
  33. package/dist/commands/status.js +70 -0
  34. package/dist/commands/stop.d.ts +1 -0
  35. package/dist/commands/stop.js +22 -0
  36. package/dist/commands/watchdog.d.ts +1 -0
  37. package/dist/commands/watchdog.js +100 -0
  38. package/dist/core/docker.d.ts +15 -0
  39. package/dist/core/docker.js +72 -0
  40. package/dist/core/errors.d.ts +20 -0
  41. package/dist/core/errors.js +40 -0
  42. package/dist/core/exec.d.ts +14 -0
  43. package/dist/core/exec.js +30 -0
  44. package/dist/core/git-onboard.d.ts +11 -0
  45. package/dist/core/git-onboard.js +149 -0
  46. package/dist/core/git.d.ts +36 -0
  47. package/dist/core/git.js +155 -0
  48. package/dist/core/github.d.ts +22 -0
  49. package/dist/core/github.js +92 -0
  50. package/dist/core/health.d.ts +29 -0
  51. package/dist/core/health.js +56 -0
  52. package/dist/core/nginx.d.ts +17 -0
  53. package/dist/core/nginx.js +59 -0
  54. package/dist/core/registry.d.ts +38 -0
  55. package/dist/core/registry.js +47 -0
  56. package/dist/core/secrets-ops.d.ts +37 -0
  57. package/dist/core/secrets-ops.js +331 -0
  58. package/dist/core/secrets-validate.d.ts +8 -0
  59. package/dist/core/secrets-validate.js +81 -0
  60. package/dist/core/secrets.d.ts +36 -0
  61. package/dist/core/secrets.js +191 -0
  62. package/dist/core/systemd.d.ts +23 -0
  63. package/dist/core/systemd.js +106 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +18 -0
  66. package/dist/mcp/git-tools.d.ts +2 -0
  67. package/dist/mcp/git-tools.js +148 -0
  68. package/dist/mcp/secrets-tools.d.ts +2 -0
  69. package/dist/mcp/secrets-tools.js +67 -0
  70. package/dist/mcp/server.d.ts +1 -0
  71. package/dist/mcp/server.js +179 -0
  72. package/dist/templates/gitignore.d.ts +3 -0
  73. package/dist/templates/gitignore.js +89 -0
  74. package/dist/templates/nginx.d.ts +8 -0
  75. package/dist/templates/nginx.js +111 -0
  76. package/dist/templates/systemd.d.ts +9 -0
  77. package/dist/templates/systemd.js +26 -0
  78. package/dist/templates/unseal.d.ts +1 -0
  79. package/dist/templates/unseal.js +22 -0
  80. package/dist/tui/app.d.ts +1 -0
  81. package/dist/tui/app.js +9 -0
  82. package/dist/tui/components/AppList.d.ts +12 -0
  83. package/dist/tui/components/AppList.js +32 -0
  84. package/dist/tui/components/Confirm.d.ts +2 -0
  85. package/dist/tui/components/Confirm.js +10 -0
  86. package/dist/tui/components/Header.d.ts +6 -0
  87. package/dist/tui/components/Header.js +16 -0
  88. package/dist/tui/components/KeyHint.d.ts +2 -0
  89. package/dist/tui/components/KeyHint.js +55 -0
  90. package/dist/tui/components/StatusBadge.d.ts +7 -0
  91. package/dist/tui/components/StatusBadge.js +8 -0
  92. package/dist/tui/exec-bridge.d.ts +11 -0
  93. package/dist/tui/exec-bridge.js +57 -0
  94. package/dist/tui/hooks/use-fleet-data.d.ts +9 -0
  95. package/dist/tui/hooks/use-fleet-data.js +30 -0
  96. package/dist/tui/hooks/use-health.d.ts +9 -0
  97. package/dist/tui/hooks/use-health.js +29 -0
  98. package/dist/tui/hooks/use-interval.d.ts +1 -0
  99. package/dist/tui/hooks/use-interval.js +13 -0
  100. package/dist/tui/hooks/use-keyboard.d.ts +1 -0
  101. package/dist/tui/hooks/use-keyboard.js +44 -0
  102. package/dist/tui/hooks/use-secrets.d.ts +47 -0
  103. package/dist/tui/hooks/use-secrets.js +152 -0
  104. package/dist/tui/router.d.ts +2 -0
  105. package/dist/tui/router.js +65 -0
  106. package/dist/tui/state.d.ts +12 -0
  107. package/dist/tui/state.js +83 -0
  108. package/dist/tui/theme.d.ts +11 -0
  109. package/dist/tui/theme.js +23 -0
  110. package/dist/tui/types.d.ts +41 -0
  111. package/dist/tui/types.js +1 -0
  112. package/dist/tui/views/AppDetail.d.ts +2 -0
  113. package/dist/tui/views/AppDetail.js +72 -0
  114. package/dist/tui/views/Dashboard.d.ts +2 -0
  115. package/dist/tui/views/Dashboard.js +29 -0
  116. package/dist/tui/views/HealthView.d.ts +2 -0
  117. package/dist/tui/views/HealthView.js +28 -0
  118. package/dist/tui/views/LogsView.d.ts +2 -0
  119. package/dist/tui/views/LogsView.js +71 -0
  120. package/dist/tui/views/SecretEdit.d.ts +2 -0
  121. package/dist/tui/views/SecretEdit.js +53 -0
  122. package/dist/tui/views/SecretsView.d.ts +2 -0
  123. package/dist/tui/views/SecretsView.js +108 -0
  124. package/dist/ui/confirm.d.ts +1 -0
  125. package/dist/ui/confirm.js +15 -0
  126. package/dist/ui/output.d.ts +27 -0
  127. package/dist/ui/output.js +61 -0
  128. package/package.json +64 -0
@@ -0,0 +1,94 @@
1
+ import * as nginxCore from '../core/nginx.js';
2
+ import { generateNginxConfig } from '../templates/nginx.js';
3
+ import { FleetError } from '../core/errors.js';
4
+ import { c, heading, table, success, error, info, warn } from '../ui/output.js';
5
+ import { confirm } from '../ui/confirm.js';
6
+ export async function nginxCommand(args) {
7
+ const sub = args[0];
8
+ const rest = args.slice(1);
9
+ switch (sub) {
10
+ case 'add': return nginxAdd(rest);
11
+ case 'remove': return nginxRemove(rest);
12
+ case 'list': return nginxList(rest);
13
+ default:
14
+ error('Usage: fleet nginx <add|remove|list>');
15
+ process.exit(1);
16
+ }
17
+ }
18
+ async function nginxAdd(args) {
19
+ const dryRun = args.includes('--dry-run');
20
+ const yes = args.includes('-y') || args.includes('--yes');
21
+ const domain = args.find(a => !a.startsWith('-'));
22
+ const portIdx = args.indexOf('--port');
23
+ const port = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : null;
24
+ const typeIdx = args.indexOf('--type');
25
+ const type = (typeIdx >= 0 ? args[typeIdx + 1] : 'proxy');
26
+ if (!domain || !port) {
27
+ error('Usage: fleet nginx add <domain> --port <port> [--type proxy|spa|nextjs]');
28
+ process.exit(1);
29
+ }
30
+ const existing = nginxCore.readConfig(domain);
31
+ if (existing) {
32
+ throw new FleetError(`Config already exists for ${domain}`);
33
+ }
34
+ const config = generateNginxConfig({ domain, port, type });
35
+ if (dryRun) {
36
+ info('Generated config:');
37
+ process.stdout.write(config + '\n');
38
+ warn('Dry run - no changes made');
39
+ return;
40
+ }
41
+ nginxCore.installConfig(domain, config);
42
+ info(`Installed ${domain}.conf`);
43
+ const test = nginxCore.testConfig();
44
+ if (!test.ok) {
45
+ error(`Nginx config test failed: ${test.output}`);
46
+ nginxCore.removeConfig(domain);
47
+ error('Config removed due to test failure');
48
+ process.exit(1);
49
+ }
50
+ success('Nginx config test passed');
51
+ if (nginxCore.reload()) {
52
+ success(`Nginx reloaded - ${domain} is live`);
53
+ }
54
+ else {
55
+ warn('Failed to reload nginx - reload manually');
56
+ }
57
+ info(`Run certbot to add SSL: certbot --nginx -d ${domain} -d www.${domain}`);
58
+ }
59
+ async function nginxRemove(args) {
60
+ const yes = args.includes('-y') || args.includes('--yes');
61
+ const domain = args.find(a => !a.startsWith('-'));
62
+ if (!domain) {
63
+ error('Usage: fleet nginx remove <domain>');
64
+ process.exit(1);
65
+ }
66
+ if (!yes && !await confirm(`Remove nginx config for ${domain}?`)) {
67
+ info('Cancelled');
68
+ return;
69
+ }
70
+ if (nginxCore.removeConfig(domain)) {
71
+ success(`Removed ${domain}.conf`);
72
+ nginxCore.reload();
73
+ success('Nginx reloaded');
74
+ }
75
+ else {
76
+ error(`Config not found for ${domain}`);
77
+ }
78
+ }
79
+ function nginxList(args) {
80
+ const json = args.includes('--json');
81
+ const sites = nginxCore.listSites();
82
+ if (json) {
83
+ process.stdout.write(JSON.stringify(sites, null, 2) + '\n');
84
+ return;
85
+ }
86
+ heading(`Nginx Sites (${sites.length})`);
87
+ const rows = sites.map(s => [
88
+ `${c.bold}${s.domain}${c.reset}`,
89
+ s.enabled ? `${c.green}enabled${c.reset}` : `${c.dim}disabled${c.reset}`,
90
+ s.ssl ? `${c.green}ssl${c.reset}` : `${c.dim}no ssl${c.reset}`,
91
+ ]);
92
+ table(['DOMAIN', 'STATUS', 'SSL'], rows);
93
+ process.stdout.write('\n');
94
+ }
@@ -0,0 +1 @@
1
+ export declare function removeCommand(args: string[]): Promise<void>;
@@ -0,0 +1,28 @@
1
+ import { load, save, findApp, removeApp } from '../core/registry.js';
2
+ import { stopService, disableService } from '../core/systemd.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { success, error, info, warn } from '../ui/output.js';
5
+ import { confirm } from '../ui/confirm.js';
6
+ export async function removeCommand(args) {
7
+ const yes = args.includes('-y') || args.includes('--yes');
8
+ const appName = args.find(a => !a.startsWith('-'));
9
+ if (!appName) {
10
+ error('Usage: fleet remove <app>');
11
+ process.exit(1);
12
+ }
13
+ const reg = load();
14
+ const app = findApp(reg, appName);
15
+ if (!app)
16
+ throw new AppNotFoundError(appName);
17
+ if (!yes && !await confirm(`Remove ${app.name}? This will stop and disable the service.`)) {
18
+ info('Cancelled');
19
+ return;
20
+ }
21
+ info(`Stopping ${app.serviceName}...`);
22
+ stopService(app.serviceName);
23
+ info(`Disabling ${app.serviceName}...`);
24
+ disableService(app.serviceName);
25
+ save(removeApp(reg, app.name));
26
+ success(`Removed ${app.name} from registry`);
27
+ warn('Service file not deleted - remove manually if needed');
28
+ }
@@ -0,0 +1 @@
1
+ export declare function restartCommand(args: string[]): void;
@@ -0,0 +1,22 @@
1
+ import { load, findApp } from '../core/registry.js';
2
+ import { restartService } from '../core/systemd.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { success, error } from '../ui/output.js';
5
+ export function restartCommand(args) {
6
+ const appName = args[0];
7
+ if (!appName) {
8
+ error('Usage: fleet restart <app>');
9
+ process.exit(1);
10
+ }
11
+ const reg = load();
12
+ const app = findApp(reg, appName);
13
+ if (!app)
14
+ throw new AppNotFoundError(appName);
15
+ if (restartService(app.serviceName)) {
16
+ success(`Restarted ${app.name}`);
17
+ }
18
+ else {
19
+ error(`Failed to restart ${app.name}`);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export declare function secretsCommand(args: string[]): Promise<void>;
@@ -0,0 +1,268 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import { SecretsError } from '../core/errors.js';
5
+ import { load, findApp } from '../core/registry.js';
6
+ import { initVault, loadManifest, listSecrets } from '../core/secrets.js';
7
+ import { setSecret, getSecret, importEnvFile, importDbSecrets, exportApp, unsealAll, sealFromRuntime, rotateKey, getStatus, detectDrift, } from '../core/secrets-ops.js';
8
+ import { restoreVaultFile } from '../core/secrets.js';
9
+ import { generateUnsealService } from '../templates/unseal.js';
10
+ import { validateApp, validateAll } from '../core/secrets-validate.js';
11
+ import { confirm } from '../ui/confirm.js';
12
+ import { c, heading, table, success, error, info, warn } from '../ui/output.js';
13
+ const DB_SECRETS_DIR = '/home/matt/docker-databases/secrets';
14
+ export async function secretsCommand(args) {
15
+ const sub = args[0];
16
+ const rest = args.slice(1);
17
+ switch (sub) {
18
+ case 'init': return secretsInit();
19
+ case 'list': return secretsList(rest);
20
+ case 'set': return secretsSet(rest);
21
+ case 'get': return secretsGet(rest);
22
+ case 'import': return secretsImport(rest);
23
+ case 'export': return secretsExport(rest);
24
+ case 'seal': return secretsSeal(rest);
25
+ case 'unseal': return secretsUnseal();
26
+ case 'rotate': return secretsRotate(rest);
27
+ case 'validate': return secretsValidate(rest);
28
+ case 'status': return secretsStatus(rest);
29
+ case 'drift': return secretsDrift(rest);
30
+ case 'restore': return secretsRestore(rest);
31
+ case 'seal-runtime': return secretsSeal(rest);
32
+ default:
33
+ error('Usage: fleet secrets <init|list|set|get|import|export|seal|unseal|rotate|validate|status|drift|restore>');
34
+ process.exit(1);
35
+ }
36
+ }
37
+ function secretsInit() {
38
+ const pubkey = initVault();
39
+ success(`Vault initialised`);
40
+ info(`Public key: ${pubkey}`);
41
+ const serviceContent = generateUnsealService();
42
+ const servicePath = '/etc/systemd/system/fleet-unseal.service';
43
+ writeFileSync(servicePath, serviceContent);
44
+ execSync('systemctl daemon-reload');
45
+ execSync('systemctl enable fleet-unseal');
46
+ success('Installed fleet-unseal.service');
47
+ }
48
+ function secretsList(args) {
49
+ const json = args.includes('--json');
50
+ const appName = args.find(a => !a.startsWith('-'));
51
+ if (appName) {
52
+ const secrets = listSecrets(appName);
53
+ if (json) {
54
+ process.stdout.write(JSON.stringify(secrets, null, 2) + '\n');
55
+ return;
56
+ }
57
+ heading(`Secrets: ${appName} (${secrets.length})`);
58
+ const rows = secrets.map(s => [s.key, `${c.dim}${s.maskedValue}${c.reset}`]);
59
+ table(['KEY', 'VALUE'], rows);
60
+ process.stdout.write('\n');
61
+ return;
62
+ }
63
+ const manifest = loadManifest();
64
+ const entries = Object.entries(manifest.apps);
65
+ if (json) {
66
+ process.stdout.write(JSON.stringify(manifest.apps, null, 2) + '\n');
67
+ return;
68
+ }
69
+ heading(`Managed Secrets (${entries.length} apps)`);
70
+ const rows = entries.map(([name, entry]) => [
71
+ `${c.bold}${name}${c.reset}`,
72
+ entry.type,
73
+ String(entry.keyCount),
74
+ entry.lastSealedAt.substring(0, 19).replace('T', ' '),
75
+ ]);
76
+ table(['APP', 'TYPE', 'KEYS', 'LAST SEALED'], rows);
77
+ process.stdout.write('\n');
78
+ }
79
+ function secretsSet(args) {
80
+ const [app, key, ...valueParts] = args;
81
+ const value = valueParts.join(' ');
82
+ if (!app || !key || !value) {
83
+ error('Usage: fleet secrets set <app> <KEY> <VALUE>');
84
+ process.exit(1);
85
+ }
86
+ setSecret(app, key, value);
87
+ success(`Set ${key} for ${app}`);
88
+ }
89
+ function secretsGet(args) {
90
+ const [app, key] = args;
91
+ if (!app || !key) {
92
+ error('Usage: fleet secrets get <app> <KEY>');
93
+ process.exit(1);
94
+ }
95
+ const val = getSecret(app, key);
96
+ if (val === null) {
97
+ error(`Key not found: ${key}`);
98
+ process.exit(1);
99
+ }
100
+ process.stdout.write(val + '\n');
101
+ }
102
+ function secretsImport(args) {
103
+ const app = args.find(a => !a.startsWith('-'));
104
+ const pathArg = args[1] && !args[1].startsWith('-') ? args[1] : null;
105
+ if (!app) {
106
+ error('Usage: fleet secrets import <app> [path]');
107
+ process.exit(1);
108
+ }
109
+ if (app === 'docker-databases') {
110
+ const dir = pathArg || DB_SECRETS_DIR;
111
+ const count = importDbSecrets(app, dir);
112
+ success(`Imported ${count} secret files from ${dir}`);
113
+ return;
114
+ }
115
+ const reg = load();
116
+ const entry = findApp(reg, app);
117
+ let envPath;
118
+ if (pathArg) {
119
+ envPath = pathArg;
120
+ }
121
+ else if (entry) {
122
+ envPath = join(entry.composePath, '.env');
123
+ }
124
+ else {
125
+ throw new SecretsError(`App not in registry and no path given: ${app}`);
126
+ }
127
+ const count = importEnvFile(app, envPath);
128
+ success(`Imported ${count} keys from ${envPath}`);
129
+ }
130
+ function secretsExport(args) {
131
+ const app = args[0];
132
+ if (!app) {
133
+ error('Usage: fleet secrets export <app>');
134
+ process.exit(1);
135
+ }
136
+ process.stdout.write(exportApp(app));
137
+ }
138
+ function secretsUnseal() {
139
+ unsealAll();
140
+ const manifest = loadManifest();
141
+ const count = Object.keys(manifest.apps).length;
142
+ success(`Unsealed ${count} apps to /run/fleet-secrets/`);
143
+ }
144
+ function secretsSeal(args) {
145
+ const app = args.find(a => !a.startsWith('-')) || undefined;
146
+ const sealed = sealFromRuntime(app);
147
+ for (const a of sealed) {
148
+ success(`Sealed ${a}`);
149
+ }
150
+ }
151
+ async function secretsRotate(args) {
152
+ const yes = args.includes('-y') || args.includes('--yes');
153
+ if (!yes && !await confirm('Rotate age key? This will re-encrypt all secrets.')) {
154
+ info('Cancelled');
155
+ return;
156
+ }
157
+ const result = rotateKey();
158
+ success(`Key rotated`);
159
+ info(`Old: ${result.oldPubkey}`);
160
+ info(`New: ${result.newPubkey}`);
161
+ info(`Re-encrypted ${result.appsRotated.length} apps`);
162
+ warn('Run "fleet secrets unseal" to update runtime secrets');
163
+ }
164
+ function secretsValidate(args) {
165
+ const json = args.includes('--json');
166
+ const appName = args.find(a => !a.startsWith('-'));
167
+ const results = appName ? [validateApp(appName)] : validateAll();
168
+ if (json) {
169
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
170
+ return;
171
+ }
172
+ heading('Secrets Validation');
173
+ let failures = 0;
174
+ for (const r of results) {
175
+ if (r.missing.length === 0 && r.extra.length === 0) {
176
+ if (r.ok) {
177
+ info(`${c.green}ok${c.reset} ${r.app}`);
178
+ }
179
+ continue;
180
+ }
181
+ if (r.missing.length > 0) {
182
+ failures++;
183
+ error(`${r.app}: missing from vault: ${r.missing.join(', ')}`);
184
+ }
185
+ if (r.extra.length > 0) {
186
+ warn(`${r.app}: extra in vault (not in compose): ${r.extra.join(', ')}`);
187
+ }
188
+ }
189
+ process.stdout.write('\n');
190
+ if (failures > 0) {
191
+ error(`${failures} app(s) have missing secrets`);
192
+ process.exit(1);
193
+ }
194
+ success('All secrets validated');
195
+ }
196
+ function secretsStatus(args) {
197
+ const json = args.includes('--json');
198
+ const status = getStatus();
199
+ if (json) {
200
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
201
+ return;
202
+ }
203
+ heading('Secrets Status');
204
+ const stateLabel = status.initialized
205
+ ? `${c.green}initialised${c.reset}`
206
+ : `${c.red}not initialised${c.reset}`;
207
+ const sealLabel = status.sealed
208
+ ? `${c.yellow}sealed${c.reset}`
209
+ : `${c.green}unsealed${c.reset}`;
210
+ info(`Vault: ${stateLabel}`);
211
+ info(`State: ${sealLabel}`);
212
+ info(`Key: ${status.keyPath}`);
213
+ info(`Vault: ${status.vaultDir}`);
214
+ info(`Runtime: ${status.runtimeDir}`);
215
+ info(`Apps: ${status.appCount} | Keys: ${status.totalKeys}`);
216
+ process.stdout.write('\n');
217
+ }
218
+ function secretsDrift(args) {
219
+ const json = args.includes('--json');
220
+ const appName = args.find(a => !a.startsWith('-')) || undefined;
221
+ const results = detectDrift(appName);
222
+ if (json) {
223
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
224
+ return;
225
+ }
226
+ heading('Vault / Runtime Drift');
227
+ let hasDrift = false;
228
+ for (const r of results) {
229
+ if (r.status === 'in-sync') {
230
+ info(`${c.green}in-sync${c.reset} ${r.app}`);
231
+ continue;
232
+ }
233
+ if (r.status === 'missing-runtime') {
234
+ warn(`${r.app}: no runtime secrets (sealed or never unsealed)`);
235
+ continue;
236
+ }
237
+ hasDrift = true;
238
+ error(`${r.app}: drifted`);
239
+ if (r.addedKeys.length > 0)
240
+ info(` added at runtime: ${r.addedKeys.join(', ')}`);
241
+ if (r.removedKeys.length > 0)
242
+ info(` removed at runtime: ${r.removedKeys.join(', ')}`);
243
+ if (r.changedKeys.length > 0)
244
+ info(` changed at runtime: ${r.changedKeys.join(', ')}`);
245
+ }
246
+ process.stdout.write('\n');
247
+ if (hasDrift) {
248
+ warn('Run "fleet secrets seal" to persist runtime changes to vault');
249
+ warn('Run "fleet secrets unseal" to revert runtime to vault state');
250
+ }
251
+ else {
252
+ success('No drift detected');
253
+ }
254
+ }
255
+ function secretsRestore(args) {
256
+ const app = args.find(a => !a.startsWith('-'));
257
+ if (!app) {
258
+ error('Usage: fleet secrets restore <app>');
259
+ process.exit(1);
260
+ }
261
+ const ok = restoreVaultFile(app);
262
+ if (!ok) {
263
+ error(`No backup found for ${app}`);
264
+ process.exit(1);
265
+ }
266
+ success(`Restored vault backup for ${app}`);
267
+ info('Run "fleet secrets unseal" to apply to runtime');
268
+ }
@@ -0,0 +1 @@
1
+ export declare function startCommand(args: string[]): void;
@@ -0,0 +1,22 @@
1
+ import { load, findApp } from '../core/registry.js';
2
+ import { startService } from '../core/systemd.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { success, error } from '../ui/output.js';
5
+ export function startCommand(args) {
6
+ const appName = args[0];
7
+ if (!appName) {
8
+ error('Usage: fleet start <app>');
9
+ process.exit(1);
10
+ }
11
+ const reg = load();
12
+ const app = findApp(reg, appName);
13
+ if (!app)
14
+ throw new AppNotFoundError(appName);
15
+ if (startService(app.serviceName)) {
16
+ success(`Started ${app.name}`);
17
+ }
18
+ else {
19
+ error(`Failed to start ${app.name}`);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -0,0 +1,14 @@
1
+ export interface StatusData {
2
+ apps: Array<{
3
+ name: string;
4
+ service: string;
5
+ systemd: string;
6
+ containers: string;
7
+ health: string;
8
+ }>;
9
+ totalApps: number;
10
+ healthy: number;
11
+ unhealthy: number;
12
+ }
13
+ export declare function getStatusData(): StatusData;
14
+ export declare function statusCommand(args: string[]): void;
@@ -0,0 +1,70 @@
1
+ import { load } from '../core/registry.js';
2
+ import { getMultipleServiceStatuses, systemdAvailable } from '../core/systemd.js';
3
+ import { listContainers } from '../core/docker.js';
4
+ import { c, icon, heading, table, info } from '../ui/output.js';
5
+ export function getStatusData() {
6
+ const reg = load();
7
+ const containers = listContainers();
8
+ const hasSystemd = systemdAvailable();
9
+ const serviceStatuses = hasSystemd
10
+ ? getMultipleServiceStatuses(reg.apps.map(a => a.serviceName))
11
+ : new Map();
12
+ const apps = reg.apps.map(app => {
13
+ const svc = serviceStatuses.get(app.serviceName) ?? null;
14
+ const appContainers = containers.filter(ct => app.containers.some(name => ct.name === name));
15
+ const allHealthy = appContainers.length > 0 &&
16
+ appContainers.every(ct => ct.health === 'healthy' || ct.health === 'none');
17
+ const allRunning = appContainers.every(ct => ct.status.startsWith('Up'));
18
+ let health;
19
+ if (svc && !svc.active) {
20
+ // systemd says service is not active — it's down
21
+ health = 'down';
22
+ }
23
+ else if (appContainers.length === 0) {
24
+ health = 'unknown';
25
+ }
26
+ else if (allHealthy && allRunning) {
27
+ health = 'healthy';
28
+ }
29
+ else {
30
+ health = 'degraded';
31
+ }
32
+ return {
33
+ name: app.name,
34
+ service: app.serviceName,
35
+ systemd: svc?.state ?? 'n/a',
36
+ containers: `${appContainers.filter(ct => ct.status.startsWith('Up')).length}/${app.containers.length}`,
37
+ health,
38
+ };
39
+ });
40
+ return {
41
+ apps,
42
+ totalApps: apps.length,
43
+ healthy: apps.filter(a => a.health === 'healthy').length,
44
+ unhealthy: apps.filter(a => a.health !== 'healthy').length,
45
+ };
46
+ }
47
+ export function statusCommand(args) {
48
+ const json = args.includes('--json');
49
+ const data = getStatusData();
50
+ if (json) {
51
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
52
+ return;
53
+ }
54
+ heading('Fleet Dashboard');
55
+ info(`${data.totalApps} apps | ${c.green}${data.healthy} healthy${c.reset} | ${data.unhealthy > 0 ? c.red : c.dim}${data.unhealthy} unhealthy${c.reset}`);
56
+ const rows = data.apps.map(app => {
57
+ const healthIcon = app.health === 'healthy' ? icon.ok
58
+ : app.health === 'degraded' ? icon.warn
59
+ : icon.err;
60
+ const systemdColor = app.systemd === 'active' ? c.green : c.red;
61
+ return [
62
+ `${c.bold}${app.name}${c.reset}`,
63
+ `${systemdColor}${app.systemd}${c.reset}`,
64
+ app.containers,
65
+ `${healthIcon} ${app.health}`,
66
+ ];
67
+ });
68
+ table(['APP', 'SYSTEMD', 'CONTAINERS', 'HEALTH'], rows);
69
+ process.stdout.write('\n');
70
+ }
@@ -0,0 +1 @@
1
+ export declare function stopCommand(args: string[]): void;
@@ -0,0 +1,22 @@
1
+ import { load, findApp } from '../core/registry.js';
2
+ import { stopService } from '../core/systemd.js';
3
+ import { AppNotFoundError } from '../core/errors.js';
4
+ import { success, error } from '../ui/output.js';
5
+ export function stopCommand(args) {
6
+ const appName = args[0];
7
+ if (!appName) {
8
+ error('Usage: fleet stop <app>');
9
+ process.exit(1);
10
+ }
11
+ const reg = load();
12
+ const app = findApp(reg, appName);
13
+ if (!app)
14
+ throw new AppNotFoundError(appName);
15
+ if (stopService(app.serviceName)) {
16
+ success(`Stopped ${app.name}`);
17
+ }
18
+ else {
19
+ error(`Failed to stop ${app.name}`);
20
+ process.exit(1);
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export declare function watchdogCommand(_args: string[]): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { load } from '../core/registry.js';
3
+ import { checkAllHealth } from '../core/health.js';
4
+ import { getServiceStatus } from '../core/systemd.js';
5
+ import { error, success, warn } from '../ui/output.js';
6
+ const TELEGRAM_CONFIG_PATH = '/etc/fleet/telegram.json';
7
+ function loadTelegramConfig() {
8
+ if (!existsSync(TELEGRAM_CONFIG_PATH))
9
+ return null;
10
+ try {
11
+ return JSON.parse(readFileSync(TELEGRAM_CONFIG_PATH, 'utf-8'));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ async function sendTelegram(config, message) {
18
+ try {
19
+ const url = `https://api.telegram.org/bot${config.botToken}/sendMessage`;
20
+ const res = await fetch(url, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({
24
+ chat_id: config.chatId,
25
+ text: message,
26
+ parse_mode: 'HTML',
27
+ }),
28
+ });
29
+ return res.ok;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ function getHostname() {
36
+ try {
37
+ return readFileSync('/etc/hostname', 'utf-8').trim();
38
+ }
39
+ catch {
40
+ return 'unknown';
41
+ }
42
+ }
43
+ export async function watchdogCommand(_args) {
44
+ const failures = [];
45
+ const hostname = getHostname();
46
+ // check docker-databases systemd status
47
+ const dbStatus = getServiceStatus('docker-databases');
48
+ if (!dbStatus.active) {
49
+ failures.push(`docker-databases: systemd ${dbStatus.state}`);
50
+ }
51
+ // check all registered apps
52
+ const reg = load();
53
+ const results = checkAllHealth(reg.apps);
54
+ for (const r of results) {
55
+ if (r.overall === 'down') {
56
+ failures.push(`${r.app}: down (systemd: ${r.systemd.state})`);
57
+ }
58
+ else if (r.overall === 'degraded') {
59
+ const reasons = [];
60
+ if (!r.systemd.ok)
61
+ reasons.push(`systemd: ${r.systemd.state}`);
62
+ const deadContainers = r.containers.filter(c => !c.running).map(c => c.name);
63
+ if (deadContainers.length > 0)
64
+ reasons.push(`containers down: ${deadContainers.join(', ')}`);
65
+ if (r.http && !r.http.ok)
66
+ reasons.push('http check failed');
67
+ failures.push(`${r.app}: degraded (${reasons.join('; ')})`);
68
+ }
69
+ }
70
+ if (failures.length === 0) {
71
+ success(`All ${results.length + 1} services healthy`);
72
+ return;
73
+ }
74
+ const summary = `${failures.length} service(s) unhealthy`;
75
+ warn(summary);
76
+ for (const f of failures) {
77
+ error(` ${f}`);
78
+ }
79
+ // send telegram alert
80
+ const config = loadTelegramConfig();
81
+ if (!config) {
82
+ warn('No telegram config at /etc/fleet/telegram.json — alert not sent');
83
+ process.exit(1);
84
+ }
85
+ const message = [
86
+ `<b>fleet watchdog alert</b>`,
87
+ `<b>host:</b> ${hostname}`,
88
+ `<b>failures:</b> ${failures.length}`,
89
+ '',
90
+ ...failures.map(f => `- ${f}`),
91
+ ].join('\n');
92
+ const sent = await sendTelegram(config, message);
93
+ if (sent) {
94
+ success('Telegram alert sent');
95
+ }
96
+ else {
97
+ error('Failed to send Telegram alert');
98
+ }
99
+ process.exit(1);
100
+ }
@@ -0,0 +1,15 @@
1
+ export interface ContainerInfo {
2
+ name: string;
3
+ status: string;
4
+ health: string;
5
+ ports: string;
6
+ image: string;
7
+ uptime: string;
8
+ }
9
+ export declare function listContainers(): ContainerInfo[];
10
+ export declare function getContainersByCompose(composePath: string, composeFile: string | null): string[];
11
+ export declare function getContainerLogs(container: string, lines?: number): string;
12
+ export declare function composeBuild(composePath: string, composeFile: string | null, appName?: string): boolean;
13
+ export declare function composeUp(composePath: string, composeFile: string | null): boolean;
14
+ export declare function composeDown(composePath: string, composeFile: string | null): boolean;
15
+ export declare function inspectContainer(name: string): Record<string, unknown> | null;