@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,36 @@
|
|
|
1
|
+
export declare const VAULT_DIR = "/home/matt/fleet/vault";
|
|
2
|
+
export declare const KEY_PATH = "/etc/fleet/age.key";
|
|
3
|
+
export declare const RUNTIME_DIR = "/run/fleet-secrets";
|
|
4
|
+
export interface ManifestEntry {
|
|
5
|
+
type: 'env' | 'secrets-dir';
|
|
6
|
+
encryptedFile: string;
|
|
7
|
+
sourceFile: string;
|
|
8
|
+
files?: string[];
|
|
9
|
+
lastSealedAt: string;
|
|
10
|
+
keyCount: number;
|
|
11
|
+
}
|
|
12
|
+
export interface Manifest {
|
|
13
|
+
version: number;
|
|
14
|
+
apps: Record<string, ManifestEntry>;
|
|
15
|
+
}
|
|
16
|
+
export declare function ensureAge(): void;
|
|
17
|
+
export declare function isInitialized(): boolean;
|
|
18
|
+
export declare function isSealed(): boolean;
|
|
19
|
+
export declare function getPublicKey(): string;
|
|
20
|
+
export declare function initVault(): string;
|
|
21
|
+
export declare function loadManifest(): Manifest;
|
|
22
|
+
export declare function saveManifest(manifest: Manifest): void;
|
|
23
|
+
export declare function backupVaultFile(app: string): string | null;
|
|
24
|
+
export declare function restoreVaultFile(app: string): boolean;
|
|
25
|
+
export declare function removeBackup(app: string): void;
|
|
26
|
+
export declare function ageEncrypt(plaintext: string): string;
|
|
27
|
+
export declare function ageDecrypt(ciphertext: string | Buffer): string;
|
|
28
|
+
export declare function ageDecryptFile(filePath: string): string;
|
|
29
|
+
export declare function sealApp(app: string, envContent: string, sourceFile: string): void;
|
|
30
|
+
export declare function sealDbSecrets(app: string, secretsMap: Record<string, string>, sourceDir: string): void;
|
|
31
|
+
export declare function decryptApp(app: string): string;
|
|
32
|
+
export declare function parseSecretsBundle(bundle: string): Record<string, string>;
|
|
33
|
+
export declare function listSecrets(app: string): Array<{
|
|
34
|
+
key: string;
|
|
35
|
+
maskedValue: string;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, chmodSync, rmSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import { SecretsError, VaultNotInitializedError } from './errors.js';
|
|
5
|
+
export const VAULT_DIR = '/home/matt/fleet/vault';
|
|
6
|
+
export const KEY_PATH = '/etc/fleet/age.key';
|
|
7
|
+
export const RUNTIME_DIR = '/run/fleet-secrets';
|
|
8
|
+
const MANIFEST_PATH = join(VAULT_DIR, 'manifest.json');
|
|
9
|
+
const SECRET_DELIMITER = '---SECRET:';
|
|
10
|
+
export function ensureAge() {
|
|
11
|
+
try {
|
|
12
|
+
execSync('which age', { stdio: 'pipe' });
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new SecretsError('age not found. Install with: apt install age');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isInitialized() {
|
|
19
|
+
return existsSync(KEY_PATH) && existsSync(VAULT_DIR);
|
|
20
|
+
}
|
|
21
|
+
export function isSealed() {
|
|
22
|
+
return !existsSync(RUNTIME_DIR) || readdirSync(RUNTIME_DIR).length === 0;
|
|
23
|
+
}
|
|
24
|
+
function requireInit() {
|
|
25
|
+
if (!isInitialized())
|
|
26
|
+
throw new VaultNotInitializedError();
|
|
27
|
+
}
|
|
28
|
+
export function getPublicKey() {
|
|
29
|
+
requireInit();
|
|
30
|
+
return execSync(`age-keygen -y ${KEY_PATH}`, { encoding: 'utf-8' }).trim();
|
|
31
|
+
}
|
|
32
|
+
export function initVault() {
|
|
33
|
+
ensureAge();
|
|
34
|
+
if (isInitialized())
|
|
35
|
+
throw new SecretsError('Vault already initialized');
|
|
36
|
+
const keyDir = '/etc/fleet';
|
|
37
|
+
if (!existsSync(keyDir)) {
|
|
38
|
+
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
execSync(`age-keygen -o ${KEY_PATH} 2>/dev/null`);
|
|
41
|
+
chmodSync(KEY_PATH, 0o600);
|
|
42
|
+
if (!existsSync(VAULT_DIR)) {
|
|
43
|
+
mkdirSync(VAULT_DIR, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
saveManifest({ version: 1, apps: {} });
|
|
46
|
+
return getPublicKey();
|
|
47
|
+
}
|
|
48
|
+
export function loadManifest() {
|
|
49
|
+
requireInit();
|
|
50
|
+
if (!existsSync(MANIFEST_PATH))
|
|
51
|
+
return { version: 1, apps: {} };
|
|
52
|
+
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
export function saveManifest(manifest) {
|
|
55
|
+
writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
|
|
56
|
+
}
|
|
57
|
+
export function backupVaultFile(app) {
|
|
58
|
+
const manifest = loadManifest();
|
|
59
|
+
const entry = manifest.apps[app];
|
|
60
|
+
if (!entry)
|
|
61
|
+
return null;
|
|
62
|
+
const src = join(VAULT_DIR, entry.encryptedFile);
|
|
63
|
+
if (!existsSync(src))
|
|
64
|
+
return null;
|
|
65
|
+
const bak = src + '.bak';
|
|
66
|
+
copyFileSync(src, bak);
|
|
67
|
+
return bak;
|
|
68
|
+
}
|
|
69
|
+
export function restoreVaultFile(app) {
|
|
70
|
+
const manifest = loadManifest();
|
|
71
|
+
const entry = manifest.apps[app];
|
|
72
|
+
if (!entry)
|
|
73
|
+
return false;
|
|
74
|
+
const src = join(VAULT_DIR, entry.encryptedFile);
|
|
75
|
+
const bak = src + '.bak';
|
|
76
|
+
if (!existsSync(bak))
|
|
77
|
+
return false;
|
|
78
|
+
copyFileSync(bak, src);
|
|
79
|
+
rmSync(bak, { force: true });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
export function removeBackup(app) {
|
|
83
|
+
const manifest = loadManifest();
|
|
84
|
+
const entry = manifest.apps[app];
|
|
85
|
+
if (!entry)
|
|
86
|
+
return;
|
|
87
|
+
const bak = join(VAULT_DIR, entry.encryptedFile) + '.bak';
|
|
88
|
+
rmSync(bak, { force: true });
|
|
89
|
+
}
|
|
90
|
+
export function ageEncrypt(plaintext) {
|
|
91
|
+
const pubkey = getPublicKey();
|
|
92
|
+
return execSync(`age -r ${pubkey} --armor`, {
|
|
93
|
+
input: plaintext,
|
|
94
|
+
encoding: 'utf-8',
|
|
95
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export function ageDecrypt(ciphertext) {
|
|
99
|
+
return execSync(`age -d -i ${KEY_PATH}`, {
|
|
100
|
+
input: ciphertext,
|
|
101
|
+
encoding: 'utf-8',
|
|
102
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function ageDecryptFile(filePath) {
|
|
106
|
+
return execSync(`age -d -i ${KEY_PATH} "${filePath}"`, {
|
|
107
|
+
encoding: 'utf-8',
|
|
108
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function sealApp(app, envContent, sourceFile) {
|
|
112
|
+
requireInit();
|
|
113
|
+
const encrypted = ageEncrypt(envContent);
|
|
114
|
+
const encFile = `${app}.env.age`;
|
|
115
|
+
writeFileSync(join(VAULT_DIR, encFile), encrypted);
|
|
116
|
+
const keyCount = envContent.split('\n').filter(l => l.includes('=') && !l.startsWith('#')).length;
|
|
117
|
+
const manifest = loadManifest();
|
|
118
|
+
manifest.apps[app] = {
|
|
119
|
+
type: 'env',
|
|
120
|
+
encryptedFile: encFile,
|
|
121
|
+
sourceFile,
|
|
122
|
+
lastSealedAt: new Date().toISOString(),
|
|
123
|
+
keyCount,
|
|
124
|
+
};
|
|
125
|
+
saveManifest(manifest);
|
|
126
|
+
}
|
|
127
|
+
export function sealDbSecrets(app, secretsMap, sourceDir) {
|
|
128
|
+
requireInit();
|
|
129
|
+
const filenames = Object.keys(secretsMap).sort();
|
|
130
|
+
const parts = filenames.map(f => `${SECRET_DELIMITER}${f}---\n${secretsMap[f]}`);
|
|
131
|
+
const bundle = parts.join('\n');
|
|
132
|
+
const encrypted = ageEncrypt(bundle);
|
|
133
|
+
const encFile = `${app}.secrets.age`;
|
|
134
|
+
writeFileSync(join(VAULT_DIR, encFile), encrypted);
|
|
135
|
+
const manifest = loadManifest();
|
|
136
|
+
manifest.apps[app] = {
|
|
137
|
+
type: 'secrets-dir',
|
|
138
|
+
encryptedFile: encFile,
|
|
139
|
+
sourceFile: sourceDir,
|
|
140
|
+
files: filenames,
|
|
141
|
+
lastSealedAt: new Date().toISOString(),
|
|
142
|
+
keyCount: filenames.length,
|
|
143
|
+
};
|
|
144
|
+
saveManifest(manifest);
|
|
145
|
+
}
|
|
146
|
+
export function decryptApp(app) {
|
|
147
|
+
requireInit();
|
|
148
|
+
const manifest = loadManifest();
|
|
149
|
+
const entry = manifest.apps[app];
|
|
150
|
+
if (!entry)
|
|
151
|
+
throw new SecretsError(`No secrets found for app: ${app}`);
|
|
152
|
+
return ageDecryptFile(join(VAULT_DIR, entry.encryptedFile));
|
|
153
|
+
}
|
|
154
|
+
export function parseSecretsBundle(bundle) {
|
|
155
|
+
const files = {};
|
|
156
|
+
const parts = bundle.split(SECRET_DELIMITER).filter(p => p.trim());
|
|
157
|
+
for (const part of parts) {
|
|
158
|
+
const delimEnd = part.indexOf('---\n');
|
|
159
|
+
if (delimEnd < 0)
|
|
160
|
+
continue;
|
|
161
|
+
const filename = part.substring(0, delimEnd);
|
|
162
|
+
const content = part.substring(delimEnd + 4);
|
|
163
|
+
files[filename] = content;
|
|
164
|
+
}
|
|
165
|
+
return files;
|
|
166
|
+
}
|
|
167
|
+
function maskValue(val) {
|
|
168
|
+
if (val.length <= 3)
|
|
169
|
+
return '***';
|
|
170
|
+
return val.substring(0, 3) + '***';
|
|
171
|
+
}
|
|
172
|
+
export function listSecrets(app) {
|
|
173
|
+
const plaintext = decryptApp(app);
|
|
174
|
+
const manifest = loadManifest();
|
|
175
|
+
const entry = manifest.apps[app];
|
|
176
|
+
if (entry.type === 'env') {
|
|
177
|
+
return plaintext.split('\n')
|
|
178
|
+
.filter(l => l.includes('=') && !l.startsWith('#') && l.trim())
|
|
179
|
+
.map(line => {
|
|
180
|
+
const eqIdx = line.indexOf('=');
|
|
181
|
+
const key = line.substring(0, eqIdx);
|
|
182
|
+
const val = line.substring(eqIdx + 1);
|
|
183
|
+
return { key, maskedValue: maskValue(val) };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const files = parseSecretsBundle(plaintext);
|
|
187
|
+
return Object.entries(files).map(([filename, content]) => ({
|
|
188
|
+
key: filename,
|
|
189
|
+
maskedValue: maskValue(content.trim()),
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function systemdAvailable(): boolean;
|
|
2
|
+
export interface ServiceStatus {
|
|
3
|
+
name: string;
|
|
4
|
+
active: boolean;
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
state: string;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getServiceStatus(serviceName: string): ServiceStatus;
|
|
10
|
+
export declare function getMultipleServiceStatuses(serviceNames: string[]): Map<string, ServiceStatus>;
|
|
11
|
+
export declare function startService(serviceName: string): boolean;
|
|
12
|
+
export declare function stopService(serviceName: string): boolean;
|
|
13
|
+
export declare function restartService(serviceName: string): boolean;
|
|
14
|
+
export declare function enableService(serviceName: string): boolean;
|
|
15
|
+
export declare function disableService(serviceName: string): boolean;
|
|
16
|
+
export declare function installServiceFile(serviceName: string, content: string): void;
|
|
17
|
+
export declare function readServiceFile(serviceName: string): string | null;
|
|
18
|
+
export declare function discoverServices(): string[];
|
|
19
|
+
export declare function parseServiceFile(content: string): {
|
|
20
|
+
workingDirectory: string;
|
|
21
|
+
composeFile: string | null;
|
|
22
|
+
dependsOnDatabases: boolean;
|
|
23
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { exec } from './exec.js';
|
|
3
|
+
let _systemdAvailable = null;
|
|
4
|
+
export function systemdAvailable() {
|
|
5
|
+
if (_systemdAvailable === null) {
|
|
6
|
+
const result = exec('systemctl is-system-running');
|
|
7
|
+
// Returns "running", "degraded", etc. when systemd is PID 1.
|
|
8
|
+
// Returns "offline" when not booted with systemd.
|
|
9
|
+
_systemdAvailable = result.ok || result.stdout === 'degraded';
|
|
10
|
+
}
|
|
11
|
+
return _systemdAvailable;
|
|
12
|
+
}
|
|
13
|
+
function parseSystemctlShow(output) {
|
|
14
|
+
const props = {};
|
|
15
|
+
for (const line of output.split('\n')) {
|
|
16
|
+
const eq = line.indexOf('=');
|
|
17
|
+
if (eq > 0) {
|
|
18
|
+
props[line.slice(0, eq)] = line.slice(eq + 1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return props;
|
|
22
|
+
}
|
|
23
|
+
export function getServiceStatus(serviceName) {
|
|
24
|
+
const result = exec(`systemctl show ${serviceName}.service --property=ActiveState,UnitFileState,Description --no-pager`);
|
|
25
|
+
const props = parseSystemctlShow(result.stdout);
|
|
26
|
+
return {
|
|
27
|
+
name: serviceName,
|
|
28
|
+
active: props.ActiveState === 'active',
|
|
29
|
+
enabled: props.UnitFileState === 'enabled',
|
|
30
|
+
state: props.ActiveState || 'unknown',
|
|
31
|
+
description: props.Description || '',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function getMultipleServiceStatuses(serviceNames) {
|
|
35
|
+
if (serviceNames.length === 0)
|
|
36
|
+
return new Map();
|
|
37
|
+
const args = serviceNames.map(n => `${n}.service`).join(' ');
|
|
38
|
+
const result = exec(`systemctl show ${args} --property=Id,ActiveState,UnitFileState,Description --no-pager`, { timeout: 15_000 });
|
|
39
|
+
const map = new Map();
|
|
40
|
+
if (!result.stdout)
|
|
41
|
+
return map;
|
|
42
|
+
const blocks = result.stdout.split('\n\n');
|
|
43
|
+
for (const block of blocks) {
|
|
44
|
+
if (!block.trim())
|
|
45
|
+
continue;
|
|
46
|
+
const props = parseSystemctlShow(block);
|
|
47
|
+
const name = (props.Id || '').replace(/\.service$/, '');
|
|
48
|
+
if (name) {
|
|
49
|
+
map.set(name, {
|
|
50
|
+
name,
|
|
51
|
+
active: props.ActiveState === 'active',
|
|
52
|
+
enabled: props.UnitFileState === 'enabled',
|
|
53
|
+
state: props.ActiveState || 'unknown',
|
|
54
|
+
description: props.Description || '',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
export function startService(serviceName) {
|
|
61
|
+
return exec(`systemctl start ${serviceName}.service`, { timeout: 60_000 }).ok;
|
|
62
|
+
}
|
|
63
|
+
export function stopService(serviceName) {
|
|
64
|
+
return exec(`systemctl stop ${serviceName}.service`, { timeout: 60_000 }).ok;
|
|
65
|
+
}
|
|
66
|
+
export function restartService(serviceName) {
|
|
67
|
+
return exec(`systemctl restart ${serviceName}.service`, { timeout: 120_000 }).ok;
|
|
68
|
+
}
|
|
69
|
+
export function enableService(serviceName) {
|
|
70
|
+
return exec(`systemctl enable ${serviceName}.service`).ok;
|
|
71
|
+
}
|
|
72
|
+
export function disableService(serviceName) {
|
|
73
|
+
return exec(`systemctl disable ${serviceName}.service`).ok;
|
|
74
|
+
}
|
|
75
|
+
export function installServiceFile(serviceName, content) {
|
|
76
|
+
const path = `/etc/systemd/system/${serviceName}.service`;
|
|
77
|
+
writeFileSync(path, content);
|
|
78
|
+
exec('systemctl daemon-reload');
|
|
79
|
+
}
|
|
80
|
+
export function readServiceFile(serviceName) {
|
|
81
|
+
const path = `/etc/systemd/system/${serviceName}.service`;
|
|
82
|
+
if (!existsSync(path))
|
|
83
|
+
return null;
|
|
84
|
+
return readFileSync(path, 'utf-8');
|
|
85
|
+
}
|
|
86
|
+
export function discoverServices() {
|
|
87
|
+
const result = exec('systemctl list-units --type=service --state=active --no-legend --no-pager', { timeout: 10_000 });
|
|
88
|
+
if (!result.ok)
|
|
89
|
+
return [];
|
|
90
|
+
return result.stdout.split('\n')
|
|
91
|
+
.map(line => line.trim().split(/\s+/)[0]?.replace('.service', '') ?? '')
|
|
92
|
+
.filter(name => {
|
|
93
|
+
const content = readServiceFile(name);
|
|
94
|
+
return content !== null && content.includes('docker compose');
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export function parseServiceFile(content) {
|
|
98
|
+
const wdMatch = content.match(/WorkingDirectory=(.+)/);
|
|
99
|
+
const composeFileMatch = content.match(/-f\s+(\S+\.ya?ml)/);
|
|
100
|
+
const dbDep = content.includes('docker-databases.service');
|
|
101
|
+
return {
|
|
102
|
+
workingDirectory: wdMatch?.[1] ?? '',
|
|
103
|
+
composeFile: composeFileMatch?.[1] ?? null,
|
|
104
|
+
dependsOnDatabases: dbDep,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from './cli.js';
|
|
3
|
+
import { error } from './ui/output.js';
|
|
4
|
+
import { FleetError } from './core/errors.js';
|
|
5
|
+
const isMcp = process.argv.includes('mcp');
|
|
6
|
+
const isInstallMcp = process.argv.includes('install-mcp');
|
|
7
|
+
if (!isMcp && !isInstallMcp && process.getuid && process.getuid() !== 0) {
|
|
8
|
+
error('fleet must be run as root');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
run(process.argv).catch((err) => {
|
|
12
|
+
if (err instanceof FleetError) {
|
|
13
|
+
error(err.message);
|
|
14
|
+
process.exit(err.exitCode);
|
|
15
|
+
}
|
|
16
|
+
error(err instanceof Error ? err.message : String(err));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { load, findApp } from '../core/registry.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 { AppNotFoundError } from '../core/errors.js';
|
|
7
|
+
function requireApp(name) {
|
|
8
|
+
const reg = load();
|
|
9
|
+
const app = findApp(reg, name);
|
|
10
|
+
if (!app)
|
|
11
|
+
throw new AppNotFoundError(name);
|
|
12
|
+
return app;
|
|
13
|
+
}
|
|
14
|
+
function text(msg) {
|
|
15
|
+
return { content: [{ type: 'text', text: msg }] };
|
|
16
|
+
}
|
|
17
|
+
function onboardHint(app) {
|
|
18
|
+
if (app.gitOnboardedAt)
|
|
19
|
+
return null;
|
|
20
|
+
return `${app.name} is not git-onboarded yet. Run fleet_git_onboard first.`;
|
|
21
|
+
}
|
|
22
|
+
export function registerGitTools(server) {
|
|
23
|
+
server.tool('fleet_git_status', 'Git state for one or all apps: branch, clean/dirty, onboard status', { app: z.string().optional().describe('App name (omit for all apps)') }, async ({ app: appName }) => {
|
|
24
|
+
const reg = load();
|
|
25
|
+
if (appName) {
|
|
26
|
+
const app = findApp(reg, appName);
|
|
27
|
+
if (!app)
|
|
28
|
+
throw new AppNotFoundError(appName);
|
|
29
|
+
const root = getProjectRoot(app.composePath);
|
|
30
|
+
const status = getGitStatus(root);
|
|
31
|
+
return text(JSON.stringify({ app: app.name, root, onboarded: !!app.gitOnboardedAt, ...status }, null, 2));
|
|
32
|
+
}
|
|
33
|
+
const results = reg.apps.map(a => {
|
|
34
|
+
const root = getProjectRoot(a.composePath);
|
|
35
|
+
const status = getGitStatus(root);
|
|
36
|
+
return { app: a.name, onboarded: !!a.gitOnboardedAt, branch: status.branch, clean: status.clean, initialised: status.initialised };
|
|
37
|
+
});
|
|
38
|
+
return text(JSON.stringify(results, null, 2));
|
|
39
|
+
});
|
|
40
|
+
server.tool('fleet_git_onboard', 'Onboard an app to GitHub: create repo, push code, protect branches', {
|
|
41
|
+
app: z.string().describe('App name'),
|
|
42
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
43
|
+
}, async ({ app: appName, dryRun }) => {
|
|
44
|
+
const app = requireApp(appName);
|
|
45
|
+
const root = getProjectRoot(app.composePath);
|
|
46
|
+
const status = getGitStatus(root);
|
|
47
|
+
const scenario = detectScenario(status);
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
const plan = describeOnboardPlan(scenario, app.name, status);
|
|
50
|
+
return text(`Scenario: ${scenario}\nRoot: ${root}\n\nPlan:\n${plan.map((s, i) => `${i + 1}. ${s}`).join('\n')}`);
|
|
51
|
+
}
|
|
52
|
+
const result = executeOnboard(scenario, root, app.name, app.name, status);
|
|
53
|
+
return text(`Onboarded ${app.name} (${result.scenario})\n\nSteps:\n${result.steps.map(s => `- ${s}`).join('\n')}\n\nRepo: ${result.repoUrl}`);
|
|
54
|
+
});
|
|
55
|
+
server.tool('fleet_git_branch', 'Create a feature branch from develop (or other base) and push it', {
|
|
56
|
+
app: z.string().describe('App name'),
|
|
57
|
+
branch: z.string().describe('New branch name'),
|
|
58
|
+
from: z.string().optional().default('develop').describe('Base branch'),
|
|
59
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
60
|
+
}, async ({ app: appName, branch, from, dryRun }) => {
|
|
61
|
+
const app = requireApp(appName);
|
|
62
|
+
const hint = onboardHint(app);
|
|
63
|
+
if (hint)
|
|
64
|
+
return text(hint);
|
|
65
|
+
const root = getProjectRoot(app.composePath);
|
|
66
|
+
if (dryRun)
|
|
67
|
+
return text(`Would checkout ${from}, create branch ${branch}, and push`);
|
|
68
|
+
gitCheckout(root, from);
|
|
69
|
+
gitCheckout(root, branch, true);
|
|
70
|
+
gitPush(root, branch, true);
|
|
71
|
+
return text(`Created and pushed branch ${branch} from ${from}`);
|
|
72
|
+
});
|
|
73
|
+
server.tool('fleet_git_commit', 'Stage all changes and commit', {
|
|
74
|
+
app: z.string().describe('App name'),
|
|
75
|
+
message: z.string().describe('Commit message'),
|
|
76
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
77
|
+
}, async ({ app: appName, message, dryRun }) => {
|
|
78
|
+
const app = requireApp(appName);
|
|
79
|
+
const hint = onboardHint(app);
|
|
80
|
+
if (hint)
|
|
81
|
+
return text(hint);
|
|
82
|
+
const root = getProjectRoot(app.composePath);
|
|
83
|
+
if (dryRun)
|
|
84
|
+
return text(`Would stage all and commit: "${message}"`);
|
|
85
|
+
gitAdd(root);
|
|
86
|
+
gitCommit(root, message);
|
|
87
|
+
return text(`Committed: ${message}`);
|
|
88
|
+
});
|
|
89
|
+
server.tool('fleet_git_push', 'Push current branch to origin', {
|
|
90
|
+
app: z.string().describe('App name'),
|
|
91
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
92
|
+
}, async ({ app: appName, dryRun }) => {
|
|
93
|
+
const app = requireApp(appName);
|
|
94
|
+
const hint = onboardHint(app);
|
|
95
|
+
if (hint)
|
|
96
|
+
return text(hint);
|
|
97
|
+
const root = getProjectRoot(app.composePath);
|
|
98
|
+
const status = getGitStatus(root);
|
|
99
|
+
if (dryRun)
|
|
100
|
+
return text(`Would push branch ${status.branch}`);
|
|
101
|
+
gitPush(root, status.branch, true);
|
|
102
|
+
return text(`Pushed ${status.branch}`);
|
|
103
|
+
});
|
|
104
|
+
server.tool('fleet_git_pr_create', 'Create a pull request on GitHub', {
|
|
105
|
+
app: z.string().describe('App name'),
|
|
106
|
+
title: z.string().describe('PR title'),
|
|
107
|
+
body: z.string().optional().describe('PR description'),
|
|
108
|
+
base: z.string().optional().default('develop').describe('Base branch'),
|
|
109
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
110
|
+
}, async ({ app: appName, title, body, base, dryRun }) => {
|
|
111
|
+
const app = requireApp(appName);
|
|
112
|
+
const hint = onboardHint(app);
|
|
113
|
+
if (hint)
|
|
114
|
+
return text(hint);
|
|
115
|
+
const root = getProjectRoot(app.composePath);
|
|
116
|
+
const status = getGitStatus(root);
|
|
117
|
+
if (dryRun)
|
|
118
|
+
return text(`Would create PR: "${title}" (${status.branch} -> ${base})`);
|
|
119
|
+
const pr = github.createPullRequest(app.name, { title, body, head: status.branch, base });
|
|
120
|
+
return text(`Created PR #${pr.number}: ${pr.url}`);
|
|
121
|
+
});
|
|
122
|
+
server.tool('fleet_git_pr_list', 'List pull requests for an app', {
|
|
123
|
+
app: z.string().describe('App name'),
|
|
124
|
+
state: z.enum(['open', 'closed', 'all']).optional().default('open').describe('PR state filter'),
|
|
125
|
+
}, async ({ app: appName, state }) => {
|
|
126
|
+
const app = requireApp(appName);
|
|
127
|
+
const hint = onboardHint(app);
|
|
128
|
+
if (hint)
|
|
129
|
+
return text(hint);
|
|
130
|
+
const prs = github.listPullRequests(app.name, state);
|
|
131
|
+
return text(JSON.stringify(prs, null, 2));
|
|
132
|
+
});
|
|
133
|
+
server.tool('fleet_git_release', 'Create a release PR from develop to main', {
|
|
134
|
+
app: z.string().describe('App name'),
|
|
135
|
+
title: z.string().optional().describe('PR title (defaults to "Release: <app>")'),
|
|
136
|
+
dryRun: z.boolean().optional().default(false).describe('Preview without making changes'),
|
|
137
|
+
}, async ({ app: appName, title, dryRun }) => {
|
|
138
|
+
const app = requireApp(appName);
|
|
139
|
+
const hint = onboardHint(app);
|
|
140
|
+
if (hint)
|
|
141
|
+
return text(hint);
|
|
142
|
+
const prTitle = title || `Release: ${app.name}`;
|
|
143
|
+
if (dryRun)
|
|
144
|
+
return text(`Would create PR: "${prTitle}" (develop -> main)`);
|
|
145
|
+
const pr = github.createPullRequest(app.name, { title: prTitle, head: 'develop', base: 'main' });
|
|
146
|
+
return text(`Created release PR #${pr.number}: ${pr.url}`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isInitialized } from '../core/secrets.js';
|
|
3
|
+
import { restoreVaultFile } from '../core/secrets.js';
|
|
4
|
+
import { setSecret, getSecret, sealFromRuntime, detectDrift, } from '../core/secrets-ops.js';
|
|
5
|
+
function text(msg) {
|
|
6
|
+
return { content: [{ type: 'text', text: msg }] };
|
|
7
|
+
}
|
|
8
|
+
function requireVault() {
|
|
9
|
+
if (!isInitialized())
|
|
10
|
+
throw new Error('Vault not initialised. Run: fleet secrets init');
|
|
11
|
+
}
|
|
12
|
+
export function registerSecretsTools(server) {
|
|
13
|
+
server.tool('fleet_secrets_set', 'Set a single secret key/value for an app. ' +
|
|
14
|
+
'IMPORTANT: This updates the encrypted vault directly. ' +
|
|
15
|
+
'The change persists across reboots. ' +
|
|
16
|
+
'If the app is running, you may also need to update the runtime env and restart the app.', {
|
|
17
|
+
app: z.string().describe('App name'),
|
|
18
|
+
key: z.string().describe('Secret key name (e.g. DATABASE_URL)'),
|
|
19
|
+
value: z.string().describe('Secret value'),
|
|
20
|
+
}, async ({ app, key, value }) => {
|
|
21
|
+
requireVault();
|
|
22
|
+
setSecret(app, key, value);
|
|
23
|
+
return text(`Set ${key} for ${app} in vault. Run fleet_secrets_unseal + restart the app to apply at runtime.`);
|
|
24
|
+
});
|
|
25
|
+
server.tool('fleet_secrets_get', 'Get a single decrypted secret value from the vault. ' +
|
|
26
|
+
'Returns the value stored in the encrypted vault, NOT the runtime value. ' +
|
|
27
|
+
'Use fleet_secrets_drift to check if runtime differs from vault.', {
|
|
28
|
+
app: z.string().describe('App name'),
|
|
29
|
+
key: z.string().describe('Secret key name'),
|
|
30
|
+
}, async ({ app, key }) => {
|
|
31
|
+
requireVault();
|
|
32
|
+
const val = getSecret(app, key);
|
|
33
|
+
if (val === null)
|
|
34
|
+
return text(`Key not found: ${key}`);
|
|
35
|
+
return text(val);
|
|
36
|
+
});
|
|
37
|
+
server.tool('fleet_secrets_seal', 'Seal runtime secrets back to the encrypted vault. ' +
|
|
38
|
+
'CRITICAL: If you modified environment variables at runtime (e.g. edited .env files in /run/fleet-secrets/), ' +
|
|
39
|
+
'those changes will be LOST on reboot unless you seal them back to the vault with this tool. ' +
|
|
40
|
+
'This re-encrypts the current runtime state into the vault so it persists across reboots.', {
|
|
41
|
+
app: z.string().optional().describe('App name (omit to seal all apps)'),
|
|
42
|
+
}, async ({ app }) => {
|
|
43
|
+
requireVault();
|
|
44
|
+
const sealed = sealFromRuntime(app);
|
|
45
|
+
return text(`Sealed ${sealed.length} app(s): ${sealed.join(', ')}. Changes will now persist across reboots.`);
|
|
46
|
+
});
|
|
47
|
+
server.tool('fleet_secrets_drift', 'Detect drift between vault (encrypted, survives reboot) and runtime (/run/fleet-secrets/, lost on reboot). ' +
|
|
48
|
+
'Shows which keys were added, removed, or changed at runtime but NOT sealed back to the vault. ' +
|
|
49
|
+
'If drift is detected, use fleet_secrets_seal to persist changes, or fleet_secrets_unseal to revert runtime to vault state.', {
|
|
50
|
+
app: z.string().optional().describe('App name (omit to check all apps)'),
|
|
51
|
+
}, async ({ app }) => {
|
|
52
|
+
requireVault();
|
|
53
|
+
const results = detectDrift(app);
|
|
54
|
+
return text(JSON.stringify(results, null, 2));
|
|
55
|
+
});
|
|
56
|
+
server.tool('fleet_secrets_restore', 'Restore vault from backup (.bak file). ' +
|
|
57
|
+
'Backups are created automatically before any seal operation. ' +
|
|
58
|
+
'Use this if a seal operation produced incorrect results and you want to revert to the previous vault state.', {
|
|
59
|
+
app: z.string().describe('App name'),
|
|
60
|
+
}, async ({ app }) => {
|
|
61
|
+
requireVault();
|
|
62
|
+
const ok = restoreVaultFile(app);
|
|
63
|
+
if (!ok)
|
|
64
|
+
return text(`No backup found for ${app}`);
|
|
65
|
+
return text(`Restored vault backup for ${app}. Run fleet_secrets_unseal to apply to runtime.`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startMcpServer(): Promise<void>;
|