@matthesketh/fleet 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/data/registry.example.json +13 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +113 -0
- package/dist/commands/add.d.ts +1 -0
- package/dist/commands/add.js +95 -0
- package/dist/commands/deploy.d.ts +1 -0
- package/dist/commands/deploy.js +53 -0
- package/dist/commands/git.d.ts +1 -0
- package/dist/commands/git.js +278 -0
- package/dist/commands/health.d.ts +1 -0
- package/dist/commands/health.js +60 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/install-mcp.d.ts +1 -0
- package/dist/commands/install-mcp.js +55 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/logs.d.ts +1 -0
- package/dist/commands/logs.js +32 -0
- package/dist/commands/nginx.d.ts +1 -0
- package/dist/commands/nginx.js +94 -0
- package/dist/commands/remove.d.ts +1 -0
- package/dist/commands/remove.js +28 -0
- package/dist/commands/restart.d.ts +1 -0
- package/dist/commands/restart.js +22 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +268 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +22 -0
- package/dist/commands/status.d.ts +14 -0
- package/dist/commands/status.js +70 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +22 -0
- package/dist/commands/watchdog.d.ts +1 -0
- package/dist/commands/watchdog.js +100 -0
- package/dist/core/docker.d.ts +15 -0
- package/dist/core/docker.js +72 -0
- package/dist/core/errors.d.ts +20 -0
- package/dist/core/errors.js +40 -0
- package/dist/core/exec.d.ts +14 -0
- package/dist/core/exec.js +30 -0
- package/dist/core/git-onboard.d.ts +11 -0
- package/dist/core/git-onboard.js +149 -0
- package/dist/core/git.d.ts +36 -0
- package/dist/core/git.js +155 -0
- package/dist/core/github.d.ts +22 -0
- package/dist/core/github.js +92 -0
- package/dist/core/health.d.ts +29 -0
- package/dist/core/health.js +56 -0
- package/dist/core/nginx.d.ts +17 -0
- package/dist/core/nginx.js +59 -0
- package/dist/core/registry.d.ts +38 -0
- package/dist/core/registry.js +47 -0
- package/dist/core/secrets-ops.d.ts +37 -0
- package/dist/core/secrets-ops.js +331 -0
- package/dist/core/secrets-validate.d.ts +8 -0
- package/dist/core/secrets-validate.js +81 -0
- package/dist/core/secrets.d.ts +36 -0
- package/dist/core/secrets.js +191 -0
- package/dist/core/systemd.d.ts +23 -0
- package/dist/core/systemd.js +106 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/mcp/git-tools.d.ts +2 -0
- package/dist/mcp/git-tools.js +148 -0
- package/dist/mcp/secrets-tools.d.ts +2 -0
- package/dist/mcp/secrets-tools.js +67 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +179 -0
- package/dist/templates/gitignore.d.ts +3 -0
- package/dist/templates/gitignore.js +89 -0
- package/dist/templates/nginx.d.ts +8 -0
- package/dist/templates/nginx.js +111 -0
- package/dist/templates/systemd.d.ts +9 -0
- package/dist/templates/systemd.js +26 -0
- package/dist/templates/unseal.d.ts +1 -0
- package/dist/templates/unseal.js +22 -0
- package/dist/tui/app.d.ts +1 -0
- package/dist/tui/app.js +9 -0
- package/dist/tui/components/AppList.d.ts +12 -0
- package/dist/tui/components/AppList.js +32 -0
- package/dist/tui/components/Confirm.d.ts +2 -0
- package/dist/tui/components/Confirm.js +10 -0
- package/dist/tui/components/Header.d.ts +6 -0
- package/dist/tui/components/Header.js +16 -0
- package/dist/tui/components/KeyHint.d.ts +2 -0
- package/dist/tui/components/KeyHint.js +55 -0
- package/dist/tui/components/StatusBadge.d.ts +7 -0
- package/dist/tui/components/StatusBadge.js +8 -0
- package/dist/tui/exec-bridge.d.ts +11 -0
- package/dist/tui/exec-bridge.js +57 -0
- package/dist/tui/hooks/use-fleet-data.d.ts +9 -0
- package/dist/tui/hooks/use-fleet-data.js +30 -0
- package/dist/tui/hooks/use-health.d.ts +9 -0
- package/dist/tui/hooks/use-health.js +29 -0
- package/dist/tui/hooks/use-interval.d.ts +1 -0
- package/dist/tui/hooks/use-interval.js +13 -0
- package/dist/tui/hooks/use-keyboard.d.ts +1 -0
- package/dist/tui/hooks/use-keyboard.js +44 -0
- package/dist/tui/hooks/use-secrets.d.ts +47 -0
- package/dist/tui/hooks/use-secrets.js +152 -0
- package/dist/tui/router.d.ts +2 -0
- package/dist/tui/router.js +65 -0
- package/dist/tui/state.d.ts +12 -0
- package/dist/tui/state.js +83 -0
- package/dist/tui/theme.d.ts +11 -0
- package/dist/tui/theme.js +23 -0
- package/dist/tui/types.d.ts +41 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/views/AppDetail.d.ts +2 -0
- package/dist/tui/views/AppDetail.js +72 -0
- package/dist/tui/views/Dashboard.d.ts +2 -0
- package/dist/tui/views/Dashboard.js +29 -0
- package/dist/tui/views/HealthView.d.ts +2 -0
- package/dist/tui/views/HealthView.js +28 -0
- package/dist/tui/views/LogsView.d.ts +2 -0
- package/dist/tui/views/LogsView.js +71 -0
- package/dist/tui/views/SecretEdit.d.ts +2 -0
- package/dist/tui/views/SecretEdit.js +53 -0
- package/dist/tui/views/SecretsView.d.ts +2 -0
- package/dist/tui/views/SecretsView.js +108 -0
- package/dist/ui/confirm.d.ts +1 -0
- package/dist/ui/confirm.js +15 -0
- package/dist/ui/output.d.ts +27 -0
- package/dist/ui/output.js +61 -0
- package/package.json +64 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { load, findApp } from '../core/registry.js';
|
|
2
|
+
import { AppNotFoundError } from '../core/errors.js';
|
|
3
|
+
import { getGitStatus, getProjectRoot, gitAdd, gitCommit, gitCheckout, gitPush, } from '../core/git.js';
|
|
4
|
+
import { detectScenario, describeOnboardPlan, executeOnboard } from '../core/git-onboard.js';
|
|
5
|
+
import * as github from '../core/github.js';
|
|
6
|
+
import { confirm } from '../ui/confirm.js';
|
|
7
|
+
import { c, heading, table, success, error, info, warn } from '../ui/output.js';
|
|
8
|
+
function requireApp(name) {
|
|
9
|
+
const reg = load();
|
|
10
|
+
const app = findApp(reg, name);
|
|
11
|
+
if (!app)
|
|
12
|
+
throw new AppNotFoundError(name);
|
|
13
|
+
return { reg, app };
|
|
14
|
+
}
|
|
15
|
+
function root(composePath) {
|
|
16
|
+
return getProjectRoot(composePath);
|
|
17
|
+
}
|
|
18
|
+
export async function gitCommand(args) {
|
|
19
|
+
const sub = args[0];
|
|
20
|
+
const rest = args.slice(1);
|
|
21
|
+
switch (sub) {
|
|
22
|
+
case 'status': return gitStatusCmd(rest);
|
|
23
|
+
case 'onboard': return gitOnboardCmd(rest);
|
|
24
|
+
case 'onboard-all': return gitOnboardAllCmd(rest);
|
|
25
|
+
case 'branch': return gitBranchCmd(rest);
|
|
26
|
+
case 'commit': return gitCommitCmd(rest);
|
|
27
|
+
case 'push': return gitPushCmd(rest);
|
|
28
|
+
case 'pr': return gitPrCmd(rest);
|
|
29
|
+
case 'release': return gitReleaseCmd(rest);
|
|
30
|
+
default:
|
|
31
|
+
error('Usage: fleet git <status|onboard|onboard-all|branch|commit|push|pr|release>');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function gitStatusCmd(args) {
|
|
36
|
+
const json = args.includes('--json');
|
|
37
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
38
|
+
const reg = load();
|
|
39
|
+
if (appName) {
|
|
40
|
+
const app = findApp(reg, appName);
|
|
41
|
+
if (!app)
|
|
42
|
+
throw new AppNotFoundError(appName);
|
|
43
|
+
const r = root(app.composePath);
|
|
44
|
+
const status = getGitStatus(r);
|
|
45
|
+
if (json) {
|
|
46
|
+
process.stdout.write(JSON.stringify({ app: app.name, root: r, ...status }, null, 2) + '\n');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
heading(`Git: ${app.name}`);
|
|
50
|
+
info(`root: ${r}`);
|
|
51
|
+
info(`initialised: ${status.initialised}`);
|
|
52
|
+
if (status.initialised) {
|
|
53
|
+
info(`branch: ${status.branch} | branches: ${status.branches.join(', ')}`);
|
|
54
|
+
info(`remote: ${status.remoteUrl || 'none'}`);
|
|
55
|
+
info(`clean: ${status.clean}`);
|
|
56
|
+
if (!status.clean)
|
|
57
|
+
info(`staged: ${status.staged} modified: ${status.modified} untracked: ${status.untracked}`);
|
|
58
|
+
if (status.ahead || status.behind)
|
|
59
|
+
info(`ahead: ${status.ahead} behind: ${status.behind}`);
|
|
60
|
+
}
|
|
61
|
+
info(`onboarded: ${app.gitOnboardedAt || 'no'}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const results = reg.apps.map(app => ({
|
|
65
|
+
name: app.name,
|
|
66
|
+
status: getGitStatus(root(app.composePath)),
|
|
67
|
+
onboarded: app.gitOnboardedAt,
|
|
68
|
+
}));
|
|
69
|
+
if (json) {
|
|
70
|
+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
heading(`Git Status (${results.length} apps)`);
|
|
74
|
+
const rows = results.map(r => {
|
|
75
|
+
const s = r.status;
|
|
76
|
+
const state = !s.initialised
|
|
77
|
+
? `${c.red}no git${c.reset}`
|
|
78
|
+
: s.clean ? `${c.green}clean${c.reset}` : `${c.yellow}dirty${c.reset}`;
|
|
79
|
+
const ob = r.onboarded ? `${c.green}yes${c.reset}` : `${c.dim}no${c.reset}`;
|
|
80
|
+
return [`${c.bold}${r.name}${c.reset}`, s.branch || '-', state, ob];
|
|
81
|
+
});
|
|
82
|
+
table(['APP', 'BRANCH', 'STATE', 'ONBOARDED'], rows);
|
|
83
|
+
process.stdout.write('\n');
|
|
84
|
+
}
|
|
85
|
+
async function gitOnboardCmd(args) {
|
|
86
|
+
const dryRun = args.includes('--dry-run');
|
|
87
|
+
const yes = args.includes('-y') || args.includes('--yes');
|
|
88
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
89
|
+
if (!appName) {
|
|
90
|
+
error('Usage: fleet git onboard <app> [--dry-run] [-y]');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const { app } = requireApp(appName);
|
|
94
|
+
const r = root(app.composePath);
|
|
95
|
+
const status = getGitStatus(r);
|
|
96
|
+
const scenario = detectScenario(status);
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
heading(`Onboard plan: ${app.name} (${scenario})`);
|
|
99
|
+
info(`root: ${r}`);
|
|
100
|
+
describeOnboardPlan(scenario, app.name, status).forEach((s, i) => info(`${i + 1}. ${s}`));
|
|
101
|
+
warn('dry run - no changes made');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!yes && !await confirm(`Onboard ${app.name} (${scenario})? This will create a GitHub repo and push code.`)) {
|
|
105
|
+
info('cancelled');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const result = executeOnboard(scenario, r, app.name, app.name, status);
|
|
109
|
+
heading(`Onboarded: ${app.name}`);
|
|
110
|
+
result.steps.forEach(s => success(s));
|
|
111
|
+
info(`repo: ${result.repoUrl}`);
|
|
112
|
+
}
|
|
113
|
+
async function gitOnboardAllCmd(args) {
|
|
114
|
+
const dryRun = args.includes('--dry-run');
|
|
115
|
+
const yes = args.includes('-y') || args.includes('--yes');
|
|
116
|
+
const reg = load();
|
|
117
|
+
const todo = reg.apps.filter(a => !a.gitOnboardedAt);
|
|
118
|
+
if (todo.length === 0) {
|
|
119
|
+
info('all apps already onboarded');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
heading(`Onboard ${todo.length} apps`);
|
|
123
|
+
for (const app of todo) {
|
|
124
|
+
const r = root(app.composePath);
|
|
125
|
+
const status = getGitStatus(r);
|
|
126
|
+
const scenario = detectScenario(status);
|
|
127
|
+
if (dryRun) {
|
|
128
|
+
info(`\n${c.bold}${app.name}${c.reset} (${scenario})`);
|
|
129
|
+
describeOnboardPlan(scenario, app.name, status).forEach((s, i) => info(` ${i + 1}. ${s}`));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!yes && !await confirm(`Onboard ${app.name} (${scenario})?`)) {
|
|
133
|
+
warn(`skipped ${app.name}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const result = executeOnboard(scenario, r, app.name, app.name, status);
|
|
138
|
+
success(`${app.name}: onboarded (${result.scenario})`);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
error(`${app.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (dryRun)
|
|
145
|
+
warn('\ndry run - no changes made');
|
|
146
|
+
}
|
|
147
|
+
function gitBranchCmd(args) {
|
|
148
|
+
const dryRun = args.includes('--dry-run');
|
|
149
|
+
const positional = args.filter(a => !a.startsWith('-'));
|
|
150
|
+
const appName = positional[0];
|
|
151
|
+
const branchName = positional[1];
|
|
152
|
+
const fromIdx = args.indexOf('--from');
|
|
153
|
+
const from = fromIdx >= 0 ? args[fromIdx + 1] : 'develop';
|
|
154
|
+
if (!appName || !branchName) {
|
|
155
|
+
error('Usage: fleet git branch <app> <name> [--from develop]');
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const { app } = requireApp(appName);
|
|
159
|
+
const r = root(app.composePath);
|
|
160
|
+
if (dryRun) {
|
|
161
|
+
info(`would checkout ${from}, create branch ${branchName}, and push`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
gitCheckout(r, from);
|
|
165
|
+
gitCheckout(r, branchName, true);
|
|
166
|
+
gitPush(r, branchName, true);
|
|
167
|
+
success(`created and pushed branch ${branchName} from ${from}`);
|
|
168
|
+
}
|
|
169
|
+
function gitCommitCmd(args) {
|
|
170
|
+
const dryRun = args.includes('--dry-run');
|
|
171
|
+
const appName = args.find(a => !a.startsWith('-') && a !== '-m');
|
|
172
|
+
const msgIdx = args.indexOf('-m');
|
|
173
|
+
const message = msgIdx >= 0 ? args[msgIdx + 1] : '';
|
|
174
|
+
if (!appName || !message) {
|
|
175
|
+
error('Usage: fleet git commit <app> -m "message"');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const { app } = requireApp(appName);
|
|
179
|
+
const r = root(app.composePath);
|
|
180
|
+
if (dryRun) {
|
|
181
|
+
info(`would stage all and commit: "${message}"`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
gitAdd(r);
|
|
185
|
+
gitCommit(r, message);
|
|
186
|
+
success(`committed: ${message}`);
|
|
187
|
+
}
|
|
188
|
+
function gitPushCmd(args) {
|
|
189
|
+
const dryRun = args.includes('--dry-run');
|
|
190
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
191
|
+
if (!appName) {
|
|
192
|
+
error('Usage: fleet git push <app>');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
const { app } = requireApp(appName);
|
|
196
|
+
const r = root(app.composePath);
|
|
197
|
+
const status = getGitStatus(r);
|
|
198
|
+
if (dryRun) {
|
|
199
|
+
info(`would push branch ${status.branch}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
gitPush(r, status.branch, true);
|
|
203
|
+
success(`pushed ${status.branch}`);
|
|
204
|
+
}
|
|
205
|
+
function gitPrCmd(args) {
|
|
206
|
+
const sub = args[0];
|
|
207
|
+
const rest = args.slice(1);
|
|
208
|
+
switch (sub) {
|
|
209
|
+
case 'create': return gitPrCreateCmd(rest);
|
|
210
|
+
case 'list': return gitPrListCmd(rest);
|
|
211
|
+
default:
|
|
212
|
+
error('Usage: fleet git pr <create|list>');
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function gitPrCreateCmd(args) {
|
|
217
|
+
const dryRun = args.includes('--dry-run');
|
|
218
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
219
|
+
const titleIdx = args.indexOf('--title');
|
|
220
|
+
const title = titleIdx >= 0 ? args[titleIdx + 1] : '';
|
|
221
|
+
const baseIdx = args.indexOf('--base');
|
|
222
|
+
const base = baseIdx >= 0 ? args[baseIdx + 1] : 'develop';
|
|
223
|
+
if (!appName || !title) {
|
|
224
|
+
error('Usage: fleet git pr create <app> --title "..." [--base develop]');
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
const { app } = requireApp(appName);
|
|
228
|
+
const r = root(app.composePath);
|
|
229
|
+
const status = getGitStatus(r);
|
|
230
|
+
if (dryRun) {
|
|
231
|
+
info(`would create PR: "${title}" (${status.branch} -> ${base})`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const pr = github.createPullRequest(app.name, { title, head: status.branch, base });
|
|
235
|
+
success(`created PR #${pr.number}: ${pr.url}`);
|
|
236
|
+
}
|
|
237
|
+
function gitPrListCmd(args) {
|
|
238
|
+
const json = args.includes('--json');
|
|
239
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
240
|
+
if (!appName) {
|
|
241
|
+
error('Usage: fleet git pr list <app> [--json]');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
const { app } = requireApp(appName);
|
|
245
|
+
const prs = github.listPullRequests(app.name);
|
|
246
|
+
if (json) {
|
|
247
|
+
process.stdout.write(JSON.stringify(prs, null, 2) + '\n');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (prs.length === 0) {
|
|
251
|
+
info('no open pull requests');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
heading(`Pull Requests: ${app.name} (${prs.length} open)`);
|
|
255
|
+
const rows = prs.map(pr => [
|
|
256
|
+
`${c.bold}#${pr.number}${c.reset}`, pr.title, `${pr.head} -> ${pr.base}`, pr.url,
|
|
257
|
+
]);
|
|
258
|
+
table(['PR', 'TITLE', 'BRANCHES', 'URL'], rows);
|
|
259
|
+
process.stdout.write('\n');
|
|
260
|
+
}
|
|
261
|
+
function gitReleaseCmd(args) {
|
|
262
|
+
const dryRun = args.includes('--dry-run');
|
|
263
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
264
|
+
const titleIdx = args.indexOf('--title');
|
|
265
|
+
const title = titleIdx >= 0 ? args[titleIdx + 1] : '';
|
|
266
|
+
if (!appName) {
|
|
267
|
+
error('Usage: fleet git release <app> [--title "..."]');
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const { app } = requireApp(appName);
|
|
271
|
+
const prTitle = title || `Release: ${app.name}`;
|
|
272
|
+
if (dryRun) {
|
|
273
|
+
info(`would create PR: "${prTitle}" (develop -> main)`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const pr = github.createPullRequest(app.name, { title: prTitle, head: 'develop', base: 'main' });
|
|
277
|
+
success(`created release PR #${pr.number}: ${pr.url}`);
|
|
278
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function healthCommand(args: string[]): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { load, findApp } from '../core/registry.js';
|
|
2
|
+
import { checkHealth, checkAllHealth } from '../core/health.js';
|
|
3
|
+
import { AppNotFoundError } from '../core/errors.js';
|
|
4
|
+
import { c, icon, heading, table } from '../ui/output.js';
|
|
5
|
+
export function healthCommand(args) {
|
|
6
|
+
const json = args.includes('--json');
|
|
7
|
+
const appName = args.find(a => !a.startsWith('-'));
|
|
8
|
+
const reg = load();
|
|
9
|
+
if (appName) {
|
|
10
|
+
const app = findApp(reg, appName);
|
|
11
|
+
if (!app)
|
|
12
|
+
throw new AppNotFoundError(appName);
|
|
13
|
+
const result = checkHealth(app);
|
|
14
|
+
if (json) {
|
|
15
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
heading(`Health: ${app.name}`);
|
|
19
|
+
const sIcon = result.systemd.ok ? icon.ok : icon.err;
|
|
20
|
+
process.stdout.write(` Systemd: ${sIcon} ${result.systemd.state}\n`);
|
|
21
|
+
for (const ct of result.containers) {
|
|
22
|
+
const cIcon = ct.running ? icon.ok : icon.err;
|
|
23
|
+
process.stdout.write(` Container: ${cIcon} ${ct.name} (${ct.health})\n`);
|
|
24
|
+
}
|
|
25
|
+
if (result.http) {
|
|
26
|
+
const hIcon = result.http.ok ? icon.ok : icon.err;
|
|
27
|
+
const detail = result.http.ok ? `${result.http.status}` : (result.http.error ?? 'failed');
|
|
28
|
+
process.stdout.write(` HTTP: ${hIcon} ${detail}\n`);
|
|
29
|
+
}
|
|
30
|
+
const oColor = result.overall === 'healthy' ? c.green
|
|
31
|
+
: result.overall === 'degraded' ? c.yellow : c.red;
|
|
32
|
+
process.stdout.write(` Overall: ${oColor}${result.overall}${c.reset}\n\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const results = checkAllHealth(reg.apps);
|
|
36
|
+
if (json) {
|
|
37
|
+
process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
heading('Health Check');
|
|
41
|
+
const rows = results.map(r => {
|
|
42
|
+
const oIcon = r.overall === 'healthy' ? icon.ok
|
|
43
|
+
: r.overall === 'degraded' ? icon.warn : icon.err;
|
|
44
|
+
const sIcon = r.systemd.ok ? icon.ok : icon.err;
|
|
45
|
+
const cOk = r.containers.filter(ct => ct.running).length;
|
|
46
|
+
const cTotal = r.containers.length;
|
|
47
|
+
const httpStatus = r.http
|
|
48
|
+
? (r.http.ok ? `${icon.ok} ${r.http.status}` : `${icon.err} fail`)
|
|
49
|
+
: `${c.dim}-${c.reset}`;
|
|
50
|
+
return [
|
|
51
|
+
`${c.bold}${r.app}${c.reset}`,
|
|
52
|
+
`${sIcon} ${r.systemd.state}`,
|
|
53
|
+
`${cOk}/${cTotal}`,
|
|
54
|
+
httpStatus,
|
|
55
|
+
`${oIcon} ${r.overall}`,
|
|
56
|
+
];
|
|
57
|
+
});
|
|
58
|
+
table(['APP', 'SYSTEMD', 'CONTAINERS', 'HTTP', 'OVERALL'], rows);
|
|
59
|
+
process.stdout.write('\n');
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initCommand(args: string[]): void;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { load, save } from '../core/registry.js';
|
|
3
|
+
import { discoverServices, parseServiceFile, readServiceFile } from '../core/systemd.js';
|
|
4
|
+
import { listContainers, getContainersByCompose } from '../core/docker.js';
|
|
5
|
+
import { listSites, readConfig, extractPortFromConfig, extractDomainsFromConfig } from '../core/nginx.js';
|
|
6
|
+
import { heading, success, info } from '../ui/output.js';
|
|
7
|
+
const SKIP_SERVICES = ['docker-databases'];
|
|
8
|
+
export function initCommand(args) {
|
|
9
|
+
const json = args.includes('--json');
|
|
10
|
+
heading('Fleet Init - Auto-discovering apps');
|
|
11
|
+
const reg = load();
|
|
12
|
+
const services = discoverServices();
|
|
13
|
+
const containers = listContainers();
|
|
14
|
+
const sites = listSites();
|
|
15
|
+
info(`Found ${services.length} docker compose services`);
|
|
16
|
+
info(`Found ${containers.length} running containers`);
|
|
17
|
+
info(`Found ${sites.length} nginx sites`);
|
|
18
|
+
let added = 0;
|
|
19
|
+
for (const serviceName of services) {
|
|
20
|
+
if (SKIP_SERVICES.includes(serviceName))
|
|
21
|
+
continue;
|
|
22
|
+
const content = readServiceFile(serviceName);
|
|
23
|
+
if (!content)
|
|
24
|
+
continue;
|
|
25
|
+
const parsed = parseServiceFile(content);
|
|
26
|
+
if (!parsed.workingDirectory)
|
|
27
|
+
continue;
|
|
28
|
+
const composePath = parsed.workingDirectory;
|
|
29
|
+
const composeFile = parsed.composeFile;
|
|
30
|
+
const composeContainers = getContainersByCompose(composePath, composeFile);
|
|
31
|
+
const port = detectPort(composePath, composeFile, composeContainers, containers);
|
|
32
|
+
const domains = detectDomains(serviceName, sites, port);
|
|
33
|
+
const usesSharedDb = detectSharedDb(composePath, composeFile);
|
|
34
|
+
const type = detectType(composePath, composeFile, domains);
|
|
35
|
+
const displayName = detectDisplayName(serviceName, content);
|
|
36
|
+
const app = {
|
|
37
|
+
name: serviceName,
|
|
38
|
+
displayName,
|
|
39
|
+
composePath,
|
|
40
|
+
composeFile,
|
|
41
|
+
serviceName,
|
|
42
|
+
domains,
|
|
43
|
+
port,
|
|
44
|
+
usesSharedDb,
|
|
45
|
+
type,
|
|
46
|
+
containers: composeContainers.length > 0 ? composeContainers : [serviceName],
|
|
47
|
+
dependsOnDatabases: parsed.dependsOnDatabases,
|
|
48
|
+
registeredAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
const existing = reg.apps.findIndex(a => a.name === serviceName);
|
|
51
|
+
if (existing >= 0) {
|
|
52
|
+
const prev = reg.apps[existing];
|
|
53
|
+
if (prev.healthPath)
|
|
54
|
+
app.healthPath = prev.healthPath;
|
|
55
|
+
if (prev.gitRepo)
|
|
56
|
+
app.gitRepo = prev.gitRepo;
|
|
57
|
+
if (prev.gitRemoteUrl)
|
|
58
|
+
app.gitRemoteUrl = prev.gitRemoteUrl;
|
|
59
|
+
if (prev.gitOnboardedAt)
|
|
60
|
+
app.gitOnboardedAt = prev.gitOnboardedAt;
|
|
61
|
+
if (prev.secretsManaged)
|
|
62
|
+
app.secretsManaged = prev.secretsManaged;
|
|
63
|
+
reg.apps[existing] = app;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
reg.apps.push(app);
|
|
67
|
+
}
|
|
68
|
+
added++;
|
|
69
|
+
success(`${serviceName} (${composePath})`);
|
|
70
|
+
}
|
|
71
|
+
save(reg);
|
|
72
|
+
if (json) {
|
|
73
|
+
process.stdout.write(JSON.stringify(reg, null, 2) + '\n');
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
info(`Registered ${added} apps`);
|
|
77
|
+
success('Init complete');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function detectPort(composePath, composeFile, composeContainers, allContainers) {
|
|
81
|
+
const file = composeFile ?? 'docker-compose.yml';
|
|
82
|
+
const fullPath = `${composePath}/${file}`;
|
|
83
|
+
if (existsSync(fullPath)) {
|
|
84
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
85
|
+
const portsRe = new RegExp('ports:\\s*\\n\\s*-\\s*"?(\\d+)' + ':(\\d+)"?');
|
|
86
|
+
const portMatch = content.match(portsRe);
|
|
87
|
+
if (portMatch) {
|
|
88
|
+
return parseInt(portMatch[1], 10);
|
|
89
|
+
}
|
|
90
|
+
const localhostMatch = content.match(/127\.0\.0\.1:(\d+):/);
|
|
91
|
+
if (localhostMatch) {
|
|
92
|
+
return parseInt(localhostMatch[1], 10);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const name of composeContainers) {
|
|
96
|
+
const ct = allContainers.find(c => c.name === name);
|
|
97
|
+
if (ct?.ports) {
|
|
98
|
+
const portMatch = ct.ports.match(/(?:0\.0\.0\.0|127\.0\.0\.1):(\d+)->/);
|
|
99
|
+
if (portMatch)
|
|
100
|
+
return parseInt(portMatch[1], 10);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function detectDomains(serviceName, sites, port) {
|
|
106
|
+
for (const site of sites) {
|
|
107
|
+
const config = readConfig(site.domain);
|
|
108
|
+
if (!config)
|
|
109
|
+
continue;
|
|
110
|
+
if (port) {
|
|
111
|
+
const configPort = extractPortFromConfig(config);
|
|
112
|
+
if (configPort === port) {
|
|
113
|
+
return extractDomainsFromConfig(config);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (config.toLowerCase().includes(serviceName)) {
|
|
117
|
+
return extractDomainsFromConfig(config);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
function detectSharedDb(composePath, composeFile) {
|
|
123
|
+
const file = composeFile ?? 'docker-compose.yml';
|
|
124
|
+
const fullPath = `${composePath}/${file}`;
|
|
125
|
+
if (!existsSync(fullPath))
|
|
126
|
+
return false;
|
|
127
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
128
|
+
return content.includes('databases') && content.includes('external: true');
|
|
129
|
+
}
|
|
130
|
+
function detectType(composePath, composeFile, domains) {
|
|
131
|
+
const file = composeFile ?? 'docker-compose.yml';
|
|
132
|
+
const fullPath = `${composePath}/${file}`;
|
|
133
|
+
if (existsSync(fullPath)) {
|
|
134
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
135
|
+
if (content.includes('next') || content.includes('Next'))
|
|
136
|
+
return 'nextjs';
|
|
137
|
+
}
|
|
138
|
+
if (domains.length > 0) {
|
|
139
|
+
for (const domain of domains) {
|
|
140
|
+
const config = readConfig(domain);
|
|
141
|
+
if (!config)
|
|
142
|
+
continue;
|
|
143
|
+
if (config.includes('try_files') && config.includes('index.html'))
|
|
144
|
+
return 'spa';
|
|
145
|
+
if (config.includes('_next/'))
|
|
146
|
+
return 'nextjs';
|
|
147
|
+
}
|
|
148
|
+
return 'proxy';
|
|
149
|
+
}
|
|
150
|
+
return 'service';
|
|
151
|
+
}
|
|
152
|
+
function detectDisplayName(serviceName, serviceContent) {
|
|
153
|
+
const descMatch = serviceContent.match(/Description=(.+?)(?:\s+Docker| Service| Container)/);
|
|
154
|
+
if (descMatch)
|
|
155
|
+
return descMatch[1].trim();
|
|
156
|
+
return serviceName;
|
|
157
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function installMcpCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { success, info, warn } from '../ui/output.js';
|
|
4
|
+
const FLEET_DIST = resolve(join(import.meta.dirname, '..', '..', 'dist', 'index.js'));
|
|
5
|
+
function getClaudeConfigPath() {
|
|
6
|
+
const home = process.env.HOME || process.env.USERPROFILE || '/root';
|
|
7
|
+
return join(home, '.claude.json');
|
|
8
|
+
}
|
|
9
|
+
function loadConfig(path) {
|
|
10
|
+
if (!existsSync(path))
|
|
11
|
+
return {};
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function installMcpCommand(args) {
|
|
20
|
+
const uninstall = args.includes('--uninstall');
|
|
21
|
+
const configPath = getClaudeConfigPath();
|
|
22
|
+
const config = loadConfig(configPath);
|
|
23
|
+
if (uninstall) {
|
|
24
|
+
if (config.mcpServers?.fleet) {
|
|
25
|
+
delete config.mcpServers.fleet;
|
|
26
|
+
if (Object.keys(config.mcpServers).length === 0) {
|
|
27
|
+
delete config.mcpServers;
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
30
|
+
success('Removed fleet MCP server from Claude Code');
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
info('fleet MCP server not configured — nothing to remove');
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!existsSync(FLEET_DIST)) {
|
|
38
|
+
warn('dist/index.js not found — run "npm run build" first');
|
|
39
|
+
}
|
|
40
|
+
config.mcpServers = config.mcpServers || {};
|
|
41
|
+
const existed = !!config.mcpServers.fleet;
|
|
42
|
+
config.mcpServers.fleet = {
|
|
43
|
+
command: 'node',
|
|
44
|
+
args: [FLEET_DIST, 'mcp'],
|
|
45
|
+
};
|
|
46
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
47
|
+
if (existed) {
|
|
48
|
+
success('Updated fleet MCP server in Claude Code');
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
success('Installed fleet MCP server to Claude Code');
|
|
52
|
+
}
|
|
53
|
+
info(`Config: ${configPath}`);
|
|
54
|
+
info(`Server: node ${FLEET_DIST} mcp`);
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listCommand(args: string[]): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { load } from '../core/registry.js';
|
|
2
|
+
import { c, heading, table } from '../ui/output.js';
|
|
3
|
+
export function listCommand(args) {
|
|
4
|
+
const json = args.includes('--json');
|
|
5
|
+
const reg = load();
|
|
6
|
+
if (json) {
|
|
7
|
+
process.stdout.write(JSON.stringify(reg.apps, null, 2) + '\n');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
heading(`Registered Apps (${reg.apps.length})`);
|
|
11
|
+
const rows = reg.apps.map(app => [
|
|
12
|
+
`${c.bold}${app.name}${c.reset}`,
|
|
13
|
+
app.serviceName,
|
|
14
|
+
app.port?.toString() ?? '-',
|
|
15
|
+
app.type,
|
|
16
|
+
app.domains.join(', ') || '-',
|
|
17
|
+
]);
|
|
18
|
+
table(['NAME', 'SERVICE', 'PORT', 'TYPE', 'DOMAINS'], rows);
|
|
19
|
+
process.stdout.write('\n');
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logsCommand(args: string[]): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { load, findApp } from '../core/registry.js';
|
|
2
|
+
import { getContainerLogs } from '../core/docker.js';
|
|
3
|
+
import { execLive } from '../core/exec.js';
|
|
4
|
+
import { AppNotFoundError } from '../core/errors.js';
|
|
5
|
+
import { error } from '../ui/output.js';
|
|
6
|
+
export function logsCommand(args) {
|
|
7
|
+
const follow = args.includes('-f') || args.includes('--follow');
|
|
8
|
+
const nIdx = args.indexOf('-n');
|
|
9
|
+
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));
|
|
11
|
+
if (!appName) {
|
|
12
|
+
error('Usage: fleet logs <app> [-f] [-n <lines>]');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const reg = load();
|
|
16
|
+
const app = findApp(reg, appName);
|
|
17
|
+
if (!app)
|
|
18
|
+
throw new AppNotFoundError(appName);
|
|
19
|
+
const container = app.containers[0];
|
|
20
|
+
if (!container) {
|
|
21
|
+
error(`No containers registered for ${app.name}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
if (follow) {
|
|
25
|
+
const code = execLive('docker', ['logs', '-f', '--tail', lines.toString(), container]);
|
|
26
|
+
process.exit(code);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const output = getContainerLogs(container, lines);
|
|
30
|
+
process.stdout.write(output + '\n');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function nginxCommand(args: string[]): Promise<void>;
|