@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,72 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { exec } from './exec.js';
3
+ const SECRETS_BASE = '/run/fleet-secrets';
4
+ function loadEnvFile(path) {
5
+ if (!existsSync(path))
6
+ return {};
7
+ const vars = {};
8
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed || trimmed.startsWith('#'))
11
+ continue;
12
+ const eq = trimmed.indexOf('=');
13
+ if (eq < 1)
14
+ continue;
15
+ const key = trimmed.slice(0, eq);
16
+ let val = trimmed.slice(eq + 1);
17
+ // Strip surrounding quotes
18
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
19
+ val = val.slice(1, -1);
20
+ }
21
+ vars[key] = val;
22
+ }
23
+ return vars;
24
+ }
25
+ export function listContainers() {
26
+ const result = exec('docker ps --format "{{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Image}}"', { timeout: 10_000 });
27
+ if (!result.ok || !result.stdout)
28
+ return [];
29
+ return result.stdout.split('\n').map(line => {
30
+ const [name, rawStatus, ports, image] = line.split('\t');
31
+ const health = rawStatus.includes('(healthy)') ? 'healthy'
32
+ : rawStatus.includes('(unhealthy)') ? 'unhealthy'
33
+ : rawStatus.includes('(health:') ? 'starting'
34
+ : 'none';
35
+ const uptime = rawStatus.replace(/\s*\(.*?\)\s*/g, '').replace(/^Up\s+/, '');
36
+ return { name, status: rawStatus, health, ports: ports ?? '', image: image ?? '', uptime };
37
+ });
38
+ }
39
+ export function getContainersByCompose(composePath, composeFile) {
40
+ const fileFlag = composeFile ? `-f ${composeFile}` : '';
41
+ const result = exec(`docker compose ${fileFlag} ps --format "{{.Names}}"`, { cwd: composePath, timeout: 10_000 });
42
+ if (!result.ok || !result.stdout)
43
+ return [];
44
+ return result.stdout.split('\n').filter(Boolean);
45
+ }
46
+ export function getContainerLogs(container, lines = 100) {
47
+ const result = exec(`docker logs --tail ${lines} ${container} 2>&1`, { timeout: 15_000 });
48
+ return result.ok ? result.stdout : result.stderr || 'No logs available';
49
+ }
50
+ export function composeBuild(composePath, composeFile, appName) {
51
+ const fileFlag = composeFile ? `-f ${composeFile}` : '';
52
+ const env = appName ? loadEnvFile(`${SECRETS_BASE}/${appName}/.env`) : {};
53
+ const result = exec(`docker compose ${fileFlag} build`, { cwd: composePath, timeout: 300_000, env: Object.keys(env).length > 0 ? env : undefined });
54
+ return result.ok;
55
+ }
56
+ export function composeUp(composePath, composeFile) {
57
+ const fileFlag = composeFile ? `-f ${composeFile}` : '';
58
+ const result = exec(`docker compose ${fileFlag} up -d --force-recreate`, { cwd: composePath, timeout: 120_000 });
59
+ return result.ok;
60
+ }
61
+ export function composeDown(composePath, composeFile) {
62
+ const fileFlag = composeFile ? `-f ${composeFile}` : '';
63
+ const result = exec(`docker compose ${fileFlag} down`, { cwd: composePath, timeout: 60_000 });
64
+ return result.ok;
65
+ }
66
+ export function inspectContainer(name) {
67
+ const result = exec(`docker inspect ${name}`, { timeout: 10_000 });
68
+ if (!result.ok)
69
+ return null;
70
+ const parsed = JSON.parse(result.stdout);
71
+ return Array.isArray(parsed) ? parsed[0] : parsed;
72
+ }
@@ -0,0 +1,20 @@
1
+ export declare class FleetError extends Error {
2
+ exitCode: number;
3
+ constructor(message: string, exitCode?: number);
4
+ }
5
+ export declare class AppNotFoundError extends FleetError {
6
+ constructor(app: string);
7
+ }
8
+ export declare class ServiceError extends FleetError {
9
+ service: string;
10
+ constructor(message: string, service: string);
11
+ }
12
+ export declare class SecretsError extends FleetError {
13
+ constructor(message: string);
14
+ }
15
+ export declare class VaultNotInitializedError extends SecretsError {
16
+ constructor();
17
+ }
18
+ export declare class GitError extends FleetError {
19
+ constructor(message: string);
20
+ }
@@ -0,0 +1,40 @@
1
+ export class FleetError extends Error {
2
+ exitCode;
3
+ constructor(message, exitCode = 1) {
4
+ super(message);
5
+ this.exitCode = exitCode;
6
+ this.name = 'FleetError';
7
+ }
8
+ }
9
+ export class AppNotFoundError extends FleetError {
10
+ constructor(app) {
11
+ super(`App not found: ${app}`);
12
+ this.name = 'AppNotFoundError';
13
+ }
14
+ }
15
+ export class ServiceError extends FleetError {
16
+ service;
17
+ constructor(message, service) {
18
+ super(message);
19
+ this.service = service;
20
+ this.name = 'ServiceError';
21
+ }
22
+ }
23
+ export class SecretsError extends FleetError {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'SecretsError';
27
+ }
28
+ }
29
+ export class VaultNotInitializedError extends SecretsError {
30
+ constructor() {
31
+ super('Vault not initialized. Run: fleet secrets init');
32
+ this.name = 'VaultNotInitializedError';
33
+ }
34
+ }
35
+ export class GitError extends FleetError {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'GitError';
39
+ }
40
+ }
@@ -0,0 +1,14 @@
1
+ export interface ExecResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ exitCode: number;
5
+ ok: boolean;
6
+ }
7
+ export declare function exec(cmd: string, opts?: {
8
+ timeout?: number;
9
+ cwd?: string;
10
+ env?: Record<string, string>;
11
+ }): ExecResult;
12
+ export declare function execLive(cmd: string, args: string[], opts?: {
13
+ cwd?: string;
14
+ }): number;
@@ -0,0 +1,30 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ export function exec(cmd, opts = {}) {
3
+ try {
4
+ const stdout = execSync(cmd, {
5
+ timeout: opts.timeout ?? 30_000,
6
+ cwd: opts.cwd,
7
+ env: opts.env ? { ...process.env, ...opts.env } : undefined,
8
+ encoding: 'utf-8',
9
+ stdio: ['pipe', 'pipe', 'pipe'],
10
+ });
11
+ return { stdout: stdout.trim(), stderr: '', exitCode: 0, ok: true };
12
+ }
13
+ catch (err) {
14
+ const e = err;
15
+ return {
16
+ stdout: (e.stdout ?? '').toString().trim(),
17
+ stderr: (e.stderr ?? '').toString().trim(),
18
+ exitCode: e.status ?? 1,
19
+ ok: false,
20
+ };
21
+ }
22
+ }
23
+ export function execLive(cmd, args, opts = {}) {
24
+ const result = spawnSync(cmd, args, {
25
+ cwd: opts.cwd,
26
+ stdio: 'inherit',
27
+ encoding: 'utf-8',
28
+ });
29
+ return result.status ?? 1;
30
+ }
@@ -0,0 +1,11 @@
1
+ import { GitStatus } from './git.js';
2
+ export type OnboardScenario = 'fresh' | 'migrate' | 'no-remote' | 'resume';
3
+ export interface OnboardResult {
4
+ scenario: OnboardScenario;
5
+ steps: string[];
6
+ repoUrl: string;
7
+ branches: string[];
8
+ }
9
+ export declare function detectScenario(status: GitStatus): OnboardScenario;
10
+ export declare function describeOnboardPlan(scenario: OnboardScenario, repoName: string, _status: GitStatus): string[];
11
+ export declare function executeOnboard(scenario: OnboardScenario, cwd: string, repoName: string, appName: string, status: GitStatus): OnboardResult;
@@ -0,0 +1,149 @@
1
+ import { ensureGitignore, gitInit, gitAdd, gitCommit, gitCheckout, gitPush, gitPushAll, gitAddRemote, gitSetRemoteUrl, branchExists, hasCommits, } from './git.js';
2
+ import * as github from './github.js';
3
+ import { load, findApp, save } from './registry.js';
4
+ export function detectScenario(status) {
5
+ if (!status.initialised)
6
+ return 'fresh';
7
+ if (status.remoteUrl && status.remoteUrl.includes('heskethwebdesign/'))
8
+ return 'resume';
9
+ if (status.remoteUrl && status.remoteUrl.includes('wrxck/'))
10
+ return 'migrate';
11
+ if (!status.remoteUrl)
12
+ return 'no-remote';
13
+ return 'fresh';
14
+ }
15
+ export function describeOnboardPlan(scenario, repoName, _status) {
16
+ const repoUrl = `git@github.com:heskethwebdesign/${repoName}.git`;
17
+ const steps = [];
18
+ switch (scenario) {
19
+ case 'fresh':
20
+ steps.push('generate .gitignore');
21
+ steps.push('git init -b main');
22
+ steps.push('git add . && git commit -m "initial commit"');
23
+ steps.push(`create private repo heskethwebdesign/${repoName}`);
24
+ steps.push(`add remote origin ${repoUrl}`);
25
+ steps.push('push main');
26
+ steps.push('create and push develop branch');
27
+ steps.push('protect main and develop branches');
28
+ steps.push('update fleet registry');
29
+ break;
30
+ case 'migrate':
31
+ steps.push('ensure .gitignore exists');
32
+ steps.push(`create private repo heskethwebdesign/${repoName}`);
33
+ steps.push(`git remote set-url origin ${repoUrl}`);
34
+ steps.push('git push --all origin');
35
+ steps.push('ensure develop branch exists');
36
+ steps.push('protect main and develop branches');
37
+ steps.push('update fleet registry');
38
+ break;
39
+ case 'no-remote':
40
+ steps.push('ensure .gitignore exists');
41
+ steps.push('commit any outstanding changes');
42
+ steps.push(`create private repo heskethwebdesign/${repoName}`);
43
+ steps.push(`add remote origin ${repoUrl}`);
44
+ steps.push('git push --all origin');
45
+ steps.push('ensure develop branch exists');
46
+ steps.push('protect main and develop branches');
47
+ steps.push('update fleet registry');
48
+ break;
49
+ case 'resume':
50
+ steps.push('ensure repo exists');
51
+ steps.push('commit any outstanding changes');
52
+ steps.push('push all branches');
53
+ steps.push('ensure develop branch exists');
54
+ steps.push('protect main and develop branches');
55
+ steps.push('update fleet registry');
56
+ break;
57
+ }
58
+ return steps;
59
+ }
60
+ function ensureDevelop(cwd, steps) {
61
+ if (!branchExists(cwd, 'develop')) {
62
+ gitCheckout(cwd, 'develop', true);
63
+ gitPush(cwd, 'develop', true);
64
+ steps.push('created and pushed develop branch');
65
+ }
66
+ else {
67
+ steps.push('develop branch already exists');
68
+ }
69
+ }
70
+ export function executeOnboard(scenario, cwd, repoName, appName, status) {
71
+ const repoUrl = github.getRepoUrl(repoName);
72
+ const steps = [];
73
+ steps.push(ensureGitignore(cwd));
74
+ switch (scenario) {
75
+ case 'fresh': {
76
+ gitInit(cwd);
77
+ steps.push('initialised git repo (main branch)');
78
+ gitAdd(cwd);
79
+ gitCommit(cwd, 'Initial commit');
80
+ steps.push('created initial commit');
81
+ github.createRepo(repoName);
82
+ steps.push(`created private repo heskethwebdesign/${repoName}`);
83
+ gitAddRemote(cwd, 'origin', repoUrl);
84
+ gitPush(cwd, 'main', true);
85
+ steps.push('pushed main to origin');
86
+ gitCheckout(cwd, 'develop', true);
87
+ gitPush(cwd, 'develop', true);
88
+ steps.push('created and pushed develop branch');
89
+ break;
90
+ }
91
+ case 'migrate': {
92
+ github.createRepo(repoName);
93
+ steps.push(`created private repo heskethwebdesign/${repoName}`);
94
+ gitSetRemoteUrl(cwd, repoUrl);
95
+ steps.push(`updated remote to ${repoUrl}`);
96
+ gitPushAll(cwd);
97
+ steps.push('pushed all branches to new remote');
98
+ ensureDevelop(cwd, steps);
99
+ break;
100
+ }
101
+ case 'no-remote': {
102
+ if (!status.clean) {
103
+ gitAdd(cwd);
104
+ gitCommit(cwd, 'Pre-onboard commit');
105
+ steps.push('committed outstanding changes');
106
+ }
107
+ if (!hasCommits(cwd)) {
108
+ gitAdd(cwd);
109
+ gitCommit(cwd, 'Initial commit');
110
+ steps.push('created initial commit');
111
+ }
112
+ github.createRepo(repoName);
113
+ steps.push(`created private repo heskethwebdesign/${repoName}`);
114
+ gitAddRemote(cwd, 'origin', repoUrl);
115
+ gitPushAll(cwd);
116
+ steps.push('added remote and pushed all branches');
117
+ ensureDevelop(cwd, steps);
118
+ break;
119
+ }
120
+ case 'resume': {
121
+ github.createRepo(repoName);
122
+ steps.push('ensured repo exists');
123
+ if (hasCommits(cwd)) {
124
+ gitPushAll(cwd);
125
+ steps.push('pushed existing commits');
126
+ }
127
+ ensureDevelop(cwd, steps);
128
+ break;
129
+ }
130
+ }
131
+ const mainProtected = github.protectBranch(repoName, 'main');
132
+ const devProtected = github.protectBranch(repoName, 'develop');
133
+ if (mainProtected && devProtected) {
134
+ steps.push('protected main and develop branches');
135
+ }
136
+ else {
137
+ steps.push('branch protection skipped (requires github pro for private repos)');
138
+ }
139
+ const reg = load();
140
+ const app = findApp(reg, appName);
141
+ if (app) {
142
+ app.gitRepo = `heskethwebdesign/${repoName}`;
143
+ app.gitRemoteUrl = repoUrl;
144
+ app.gitOnboardedAt = new Date().toISOString();
145
+ save(reg);
146
+ steps.push('updated fleet registry');
147
+ }
148
+ return { scenario, steps, repoUrl, branches: ['main', 'develop'] };
149
+ }
@@ -0,0 +1,36 @@
1
+ export interface GitStatus {
2
+ initialised: boolean;
3
+ branch: string;
4
+ branches: string[];
5
+ remoteName: string;
6
+ remoteUrl: string;
7
+ clean: boolean;
8
+ staged: number;
9
+ modified: number;
10
+ untracked: number;
11
+ ahead: number;
12
+ behind: number;
13
+ }
14
+ export interface GitLogEntry {
15
+ hash: string;
16
+ subject: string;
17
+ date: string;
18
+ }
19
+ export declare function isGitRepo(cwd: string): boolean;
20
+ export declare function hasCommits(cwd: string): boolean;
21
+ export declare function getGitStatus(cwd: string): GitStatus;
22
+ export declare function getLog(cwd: string, count?: number): GitLogEntry[];
23
+ export declare function hasGitignore(cwd: string): boolean;
24
+ export declare function readGitignore(cwd: string): string;
25
+ export declare function branchExists(cwd: string, branch: string): boolean;
26
+ export declare function getProjectRoot(composePath: string): string;
27
+ export declare function gitInit(cwd: string): void;
28
+ export declare function gitAdd(cwd: string, paths?: string[]): void;
29
+ export declare function gitCommit(cwd: string, message: string): void;
30
+ export declare function gitCheckout(cwd: string, branch: string, create?: boolean): void;
31
+ export declare function gitPush(cwd: string, branch: string, setUpstream?: boolean): void;
32
+ export declare function gitPushAll(cwd: string): void;
33
+ export declare function gitSetRemoteUrl(cwd: string, url: string): void;
34
+ export declare function gitAddRemote(cwd: string, name: string, url: string): void;
35
+ export declare function writeGitignore(cwd: string, content: string): void;
36
+ export declare function ensureGitignore(cwd: string): string;
@@ -0,0 +1,155 @@
1
+ import { existsSync, writeFileSync, readFileSync } from 'node:fs';
2
+ import { join, dirname, basename } from 'node:path';
3
+ import { exec } from './exec.js';
4
+ import { GitError } from './errors.js';
5
+ import { detectProjectType, generateGitignore } from '../templates/gitignore.js';
6
+ const SSH_AGENT_SOCK = '/tmp/fleet-ssh-agent.sock';
7
+ if (existsSync(SSH_AGENT_SOCK)) {
8
+ process.env.SSH_AUTH_SOCK = SSH_AGENT_SOCK;
9
+ }
10
+ export function isGitRepo(cwd) {
11
+ return exec('git rev-parse --is-inside-work-tree', { cwd }).ok;
12
+ }
13
+ export function hasCommits(cwd) {
14
+ return exec('git rev-parse HEAD', { cwd }).ok;
15
+ }
16
+ export function getGitStatus(cwd) {
17
+ if (!isGitRepo(cwd)) {
18
+ return {
19
+ initialised: false, branch: '', branches: [], remoteName: '', remoteUrl: '',
20
+ clean: true, staged: 0, modified: 0, untracked: 0, ahead: 0, behind: 0,
21
+ };
22
+ }
23
+ const branch = exec('git rev-parse --abbrev-ref HEAD', { cwd }).stdout || '';
24
+ const branchResult = exec('git branch --list --no-color', { cwd });
25
+ const branches = branchResult.stdout
26
+ .split('\n')
27
+ .map(b => b.replace(/^\*?\s+/, '').trim())
28
+ .filter(Boolean);
29
+ const remoteName = exec('git remote', { cwd }).stdout.split('\n')[0] || '';
30
+ const remoteUrl = remoteName
31
+ ? exec(`git remote get-url ${remoteName}`, { cwd }).stdout
32
+ : '';
33
+ const porcelain = exec('git status --porcelain', { cwd }).stdout;
34
+ const lines = porcelain ? porcelain.split('\n') : [];
35
+ let staged = 0, modified = 0, untracked = 0;
36
+ for (const line of lines) {
37
+ const x = line[0], y = line[1];
38
+ if (x === '?' && y === '?')
39
+ untracked++;
40
+ else if (x !== ' ' && x !== '?')
41
+ staged++;
42
+ if (y !== ' ' && y !== '?')
43
+ modified++;
44
+ }
45
+ let ahead = 0, behind = 0;
46
+ if (remoteName && hasCommits(cwd)) {
47
+ const abResult = exec(`git rev-list --left-right --count HEAD...${remoteName}/${branch}`, { cwd });
48
+ if (abResult.ok) {
49
+ const parts = abResult.stdout.split(/\s+/);
50
+ ahead = parseInt(parts[0], 10) || 0;
51
+ behind = parseInt(parts[1], 10) || 0;
52
+ }
53
+ }
54
+ return {
55
+ initialised: true, branch, branches, remoteName, remoteUrl,
56
+ clean: lines.length === 0, staged, modified, untracked, ahead, behind,
57
+ };
58
+ }
59
+ export function getLog(cwd, count = 10) {
60
+ const result = exec(`git log --oneline --format="%H|%s|%ci" -${count}`, { cwd });
61
+ if (!result.ok)
62
+ return [];
63
+ return result.stdout.split('\n').filter(Boolean).map(line => {
64
+ const [hash, subject, ...dateParts] = line.split('|');
65
+ return { hash, subject, date: dateParts.join('|') };
66
+ });
67
+ }
68
+ export function hasGitignore(cwd) {
69
+ return existsSync(join(cwd, '.gitignore'));
70
+ }
71
+ export function readGitignore(cwd) {
72
+ const p = join(cwd, '.gitignore');
73
+ return existsSync(p) ? readFileSync(p, 'utf-8') : '';
74
+ }
75
+ export function branchExists(cwd, branch) {
76
+ return exec(`git show-ref --verify --quiet refs/heads/${branch}`, { cwd }).ok;
77
+ }
78
+ // walk up from composePath to find git root
79
+ const SUBDIR_NAMES = new Set(['server', 'app', 'backend', 'frontend']);
80
+ export function getProjectRoot(composePath) {
81
+ if (existsSync(join(composePath, '.git')))
82
+ return composePath;
83
+ let dir = composePath;
84
+ for (let i = 0; i < 5; i++) {
85
+ const parent = dirname(dir);
86
+ if (parent === dir)
87
+ break;
88
+ if (existsSync(join(parent, '.git')))
89
+ return parent;
90
+ dir = parent;
91
+ }
92
+ // if current dir is a known subdir name, go up
93
+ dir = composePath;
94
+ for (let i = 0; i < 3; i++) {
95
+ if (SUBDIR_NAMES.has(basename(dir))) {
96
+ dir = dirname(dir);
97
+ }
98
+ else {
99
+ break;
100
+ }
101
+ }
102
+ return dir;
103
+ }
104
+ export function gitInit(cwd) {
105
+ const r = exec('git init -b main', { cwd });
106
+ if (!r.ok)
107
+ throw new GitError(`git init failed: ${r.stderr}`);
108
+ }
109
+ export function gitAdd(cwd, paths = ['.']) {
110
+ const r = exec(`git add ${paths.join(' ')}`, { cwd });
111
+ if (!r.ok)
112
+ throw new GitError(`git add failed: ${r.stderr}`);
113
+ }
114
+ export function gitCommit(cwd, message) {
115
+ const r = exec(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd });
116
+ if (!r.ok)
117
+ throw new GitError(`git commit failed: ${r.stderr}`);
118
+ }
119
+ export function gitCheckout(cwd, branch, create = false) {
120
+ const flag = create ? '-b' : '';
121
+ const r = exec(`git checkout ${flag} ${branch}`, { cwd });
122
+ if (!r.ok)
123
+ throw new GitError(`git checkout failed: ${r.stderr}`);
124
+ }
125
+ export function gitPush(cwd, branch, setUpstream = false) {
126
+ const flag = setUpstream ? '-u origin' : '';
127
+ const r = exec(`git push ${flag} ${branch}`, { cwd, timeout: 60_000 });
128
+ if (!r.ok)
129
+ throw new GitError(`git push failed: ${r.stderr}`);
130
+ }
131
+ export function gitPushAll(cwd) {
132
+ const r = exec('git push --all origin', { cwd, timeout: 60_000 });
133
+ if (!r.ok)
134
+ throw new GitError(`git push --all failed: ${r.stderr}`);
135
+ }
136
+ export function gitSetRemoteUrl(cwd, url) {
137
+ const r = exec(`git remote set-url origin ${url}`, { cwd });
138
+ if (!r.ok)
139
+ throw new GitError(`git remote set-url failed: ${r.stderr}`);
140
+ }
141
+ export function gitAddRemote(cwd, name, url) {
142
+ const r = exec(`git remote add ${name} ${url}`, { cwd });
143
+ if (!r.ok)
144
+ throw new GitError(`git remote add failed: ${r.stderr}`);
145
+ }
146
+ export function writeGitignore(cwd, content) {
147
+ writeFileSync(join(cwd, '.gitignore'), content);
148
+ }
149
+ export function ensureGitignore(cwd) {
150
+ if (hasGitignore(cwd))
151
+ return '.gitignore already exists';
152
+ const type = detectProjectType(cwd);
153
+ writeGitignore(cwd, generateGitignore(type));
154
+ return `generated .gitignore (${type})`;
155
+ }
@@ -0,0 +1,22 @@
1
+ export declare const GITHUB_ORG = "heskethwebdesign";
2
+ export interface PullRequest {
3
+ number: number;
4
+ title: string;
5
+ url: string;
6
+ head: string;
7
+ base: string;
8
+ state: string;
9
+ }
10
+ export declare function isGhAuthenticated(): boolean;
11
+ export declare function requireGhAuth(): void;
12
+ export declare function repoExists(name: string): boolean;
13
+ export declare function createRepo(name: string): void;
14
+ export declare function getRepoUrl(name: string): string;
15
+ export declare function createPullRequest(repo: string, opts: {
16
+ title: string;
17
+ body?: string;
18
+ head: string;
19
+ base: string;
20
+ }): PullRequest;
21
+ export declare function listPullRequests(repo: string, state?: 'open' | 'closed' | 'all'): PullRequest[];
22
+ export declare function protectBranch(repo: string, branch: string): boolean;
@@ -0,0 +1,92 @@
1
+ import { writeFileSync, unlinkSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { exec } from './exec.js';
5
+ import { GitError } from './errors.js';
6
+ export const GITHUB_ORG = 'heskethwebdesign';
7
+ export function isGhAuthenticated() {
8
+ return exec('gh auth status', { timeout: 10_000 }).ok;
9
+ }
10
+ export function requireGhAuth() {
11
+ if (!isGhAuthenticated()) {
12
+ throw new GitError('gh cli not authenticated. Run: gh auth login');
13
+ }
14
+ }
15
+ export function repoExists(name) {
16
+ return exec(`gh repo view ${GITHUB_ORG}/${name} --json name`, { timeout: 15_000 }).ok;
17
+ }
18
+ export function createRepo(name) {
19
+ requireGhAuth();
20
+ if (repoExists(name))
21
+ return;
22
+ const r = exec(`gh repo create ${GITHUB_ORG}/${name} --private`, { timeout: 30_000 });
23
+ if (!r.ok)
24
+ throw new GitError(`failed to create repo: ${r.stderr}`);
25
+ }
26
+ export function getRepoUrl(name) {
27
+ return `git@github.com:${GITHUB_ORG}/${name}.git`;
28
+ }
29
+ export function createPullRequest(repo, opts) {
30
+ requireGhAuth();
31
+ const bodyFlag = opts.body ? `--body "${opts.body.replace(/"/g, '\\"')}"` : '--body ""';
32
+ const r = exec(`gh pr create --repo ${GITHUB_ORG}/${repo} --title "${opts.title.replace(/"/g, '\\"')}" ${bodyFlag} --head ${opts.head} --base ${opts.base} --json number,title,url,headRefName,baseRefName,state`, { timeout: 30_000 });
33
+ if (!r.ok)
34
+ throw new GitError(`failed to create PR: ${r.stderr}`);
35
+ try {
36
+ const data = JSON.parse(r.stdout);
37
+ return {
38
+ number: data.number,
39
+ title: data.title,
40
+ url: data.url,
41
+ head: data.headRefName,
42
+ base: data.baseRefName,
43
+ state: data.state,
44
+ };
45
+ }
46
+ catch {
47
+ // gh pr create outputs the url on success without --json working on create
48
+ const url = r.stdout.trim().split('\n').pop() || '';
49
+ return { number: 0, title: opts.title, url, head: opts.head, base: opts.base, state: 'open' };
50
+ }
51
+ }
52
+ export function listPullRequests(repo, state = 'open') {
53
+ requireGhAuth();
54
+ const r = exec(`gh pr list --repo ${GITHUB_ORG}/${repo} --state ${state} --json number,title,url,headRefName,baseRefName,state`, { timeout: 15_000 });
55
+ if (!r.ok)
56
+ return [];
57
+ try {
58
+ const items = JSON.parse(r.stdout);
59
+ return items.map(d => ({
60
+ number: d.number, title: d.title, url: d.url,
61
+ head: d.headRefName, base: d.baseRefName, state: d.state,
62
+ }));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ export function protectBranch(repo, branch) {
69
+ requireGhAuth();
70
+ const protection = JSON.stringify({
71
+ required_pull_request_reviews: {
72
+ dismiss_stale_reviews: true,
73
+ require_code_owner_reviews: false,
74
+ required_approving_review_count: 1,
75
+ },
76
+ enforce_admins: false,
77
+ required_status_checks: null,
78
+ restrictions: null,
79
+ });
80
+ const tmpFile = join(tmpdir(), `fleet-protect-${repo}-${branch}.json`);
81
+ writeFileSync(tmpFile, protection);
82
+ try {
83
+ const r = exec(`gh api -X PUT repos/${GITHUB_ORG}/${repo}/branches/${branch}/protection --input ${tmpFile}`, { timeout: 15_000 });
84
+ return r.ok;
85
+ }
86
+ finally {
87
+ try {
88
+ unlinkSync(tmpFile);
89
+ }
90
+ catch { }
91
+ }
92
+ }
@@ -0,0 +1,29 @@
1
+ import { type ServiceStatus } from './systemd.js';
2
+ import { type ContainerInfo } from './docker.js';
3
+ import type { AppEntry } from './registry.js';
4
+ export interface HealthResult {
5
+ app: string;
6
+ systemd: {
7
+ ok: boolean;
8
+ state: string;
9
+ };
10
+ containers: ContainerHealth[];
11
+ http: {
12
+ ok: boolean;
13
+ status: number | null;
14
+ error: string | null;
15
+ } | null;
16
+ overall: 'healthy' | 'degraded' | 'down';
17
+ }
18
+ export interface ContainerHealth {
19
+ name: string;
20
+ running: boolean;
21
+ health: string;
22
+ }
23
+ export interface PrefetchedData {
24
+ containers: ContainerInfo[];
25
+ serviceStatus: ServiceStatus | null;
26
+ }
27
+ export declare function checkHealth(app: AppEntry, prefetched?: PrefetchedData): HealthResult;
28
+ export declare function checkHttp(port: number, healthPath?: string): HealthResult['http'];
29
+ export declare function checkAllHealth(apps: AppEntry[]): HealthResult[];