@matthesketh/fleet 1.8.1 → 1.11.1

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 (230) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -0,0 +1,11 @@
1
+ import { FleetError } from './errors.js';
2
+ /** returns a required env var, or throws a clear error. use for secrets,
3
+ * keys and credential paths where a silent default would be dangerous. */
4
+ export function requireEnv(name) {
5
+ const value = process.env[name];
6
+ if (value === undefined || value === '') {
7
+ throw new FleetError(`required environment variable ${name} is not set — ` +
8
+ `it has no safe default and must be provided explicitly`);
9
+ }
10
+ return value;
11
+ }
@@ -9,6 +9,7 @@ export declare function execSafe(cmd: string, args: string[], opts?: {
9
9
  cwd?: string;
10
10
  env?: Record<string, string>;
11
11
  input?: string;
12
+ maxBuffer?: number;
12
13
  }): ExecResult;
13
14
  export declare function execGit(args: string[], opts: {
14
15
  cwd: string;
package/dist/core/exec.js CHANGED
@@ -7,6 +7,10 @@ export function execSafe(cmd, args, opts = {}) {
7
7
  encoding: 'utf-8',
8
8
  stdio: 'pipe',
9
9
  input: opts.input,
10
+ // node's default is 1mb. restic --json on a long-running snapshot emits
11
+ // hundreds of progress lines that easily blow past that; bump to 256mb
12
+ // so a multi-hour run can't hit ENOBUFS just from status chatter.
13
+ maxBuffer: opts.maxBuffer ?? 256 * 1024 * 1024,
10
14
  });
11
15
  if (result.error) {
12
16
  return {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Inter-process lock around a state-file path. Uses proper-lockfile (the same
3
+ * dependency the claude-cli runner uses for its mutex), which creates a
4
+ * <path>.lock directory atomically via mkdir(2).
5
+ *
6
+ * The wrapped path itself does not need to exist yet — `realpath: false`
7
+ * tells proper-lockfile to skip the realpath check, so we can lock around a
8
+ * registry/manifest file that hasn't been written for the first time. The
9
+ * parent directory of <path> must exist (we ensureDir below) so the .lock
10
+ * mkdir can succeed.
11
+ *
12
+ * Important: this lock is NOT reentrant. Callers should wrap the outermost
13
+ * read-modify-write boundary (e.g. a CLI command, an MCP tool handler, a
14
+ * cron entry) and let inner helpers do plain unlocked reads/writes; the lock
15
+ * bounds the whole RMW. Locking inside helpers that the outer caller already
16
+ * locked will deadlock.
17
+ */
18
+ export declare function withFileLock<T>(path: string, fn: () => Promise<T> | T): Promise<T>;
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import lockfile from 'proper-lockfile';
4
+ /**
5
+ * Inter-process lock around a state-file path. Uses proper-lockfile (the same
6
+ * dependency the claude-cli runner uses for its mutex), which creates a
7
+ * <path>.lock directory atomically via mkdir(2).
8
+ *
9
+ * The wrapped path itself does not need to exist yet — `realpath: false`
10
+ * tells proper-lockfile to skip the realpath check, so we can lock around a
11
+ * registry/manifest file that hasn't been written for the first time. The
12
+ * parent directory of <path> must exist (we ensureDir below) so the .lock
13
+ * mkdir can succeed.
14
+ *
15
+ * Important: this lock is NOT reentrant. Callers should wrap the outermost
16
+ * read-modify-write boundary (e.g. a CLI command, an MCP tool handler, a
17
+ * cron entry) and let inner helpers do plain unlocked reads/writes; the lock
18
+ * bounds the whole RMW. Locking inside helpers that the outer caller already
19
+ * locked will deadlock.
20
+ */
21
+ export async function withFileLock(path, fn) {
22
+ const dir = dirname(path);
23
+ if (!existsSync(dir))
24
+ mkdirSync(dir, { recursive: true });
25
+ const release = await lockfile.lock(path, {
26
+ // Inter-process contention is normally microseconds (one process writes,
27
+ // releases). Retry up to ~5s of backoff so a slow disk / paused process
28
+ // doesn't immediately error out the second caller.
29
+ retries: { retries: 5, factor: 1.5, minTimeout: 50, maxTimeout: 500 },
30
+ // If a process crashes mid-lock, the .lock dir's mtime stops being
31
+ // refreshed. Anyone waiting longer than `stale` ms treats the lock as
32
+ // abandoned and steals it. 30s is generous for the kinds of operations
33
+ // that touch the registry/manifest (a write is < 100ms typically).
34
+ stale: 30_000,
35
+ // Allow locking paths that don't exist on disk yet (first-write case).
36
+ realpath: false,
37
+ });
38
+ try {
39
+ return await fn();
40
+ }
41
+ finally {
42
+ await release();
43
+ }
44
+ }
@@ -4,23 +4,20 @@ import { load, findApp, save } from './registry.js';
4
4
  export function detectScenario(status) {
5
5
  if (!status.initialised)
6
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
7
  if (!status.remoteUrl)
12
8
  return 'no-remote';
13
- return 'fresh';
9
+ // a remote already on the configured org is a resume; any other org is a migrate.
10
+ return status.remoteUrl.includes(`${github.githubOrg()}/`) ? 'resume' : 'migrate';
14
11
  }
15
12
  export function describeOnboardPlan(scenario, repoName, _status) {
16
- const repoUrl = `git@github.com:heskethwebdesign/${repoName}.git`;
13
+ const repoUrl = `git@github.com:${github.githubOrg()}/${repoName}.git`;
17
14
  const steps = [];
18
15
  switch (scenario) {
19
16
  case 'fresh':
20
17
  steps.push('generate .gitignore');
21
18
  steps.push('git init -b main');
22
19
  steps.push('git add . && git commit -m "initial commit"');
23
- steps.push(`create private repo heskethwebdesign/${repoName}`);
20
+ steps.push(`create private repo ${github.githubOrg()}/${repoName}`);
24
21
  steps.push(`add remote origin ${repoUrl}`);
25
22
  steps.push('push main');
26
23
  steps.push('create and push develop branch');
@@ -29,7 +26,7 @@ export function describeOnboardPlan(scenario, repoName, _status) {
29
26
  break;
30
27
  case 'migrate':
31
28
  steps.push('ensure .gitignore exists');
32
- steps.push(`create private repo heskethwebdesign/${repoName}`);
29
+ steps.push(`create private repo ${github.githubOrg()}/${repoName}`);
33
30
  steps.push(`git remote set-url origin ${repoUrl}`);
34
31
  steps.push('git push --all origin');
35
32
  steps.push('ensure develop branch exists');
@@ -39,7 +36,7 @@ export function describeOnboardPlan(scenario, repoName, _status) {
39
36
  case 'no-remote':
40
37
  steps.push('ensure .gitignore exists');
41
38
  steps.push('commit any outstanding changes');
42
- steps.push(`create private repo heskethwebdesign/${repoName}`);
39
+ steps.push(`create private repo ${github.githubOrg()}/${repoName}`);
43
40
  steps.push(`add remote origin ${repoUrl}`);
44
41
  steps.push('git push --all origin');
45
42
  steps.push('ensure develop branch exists');
@@ -79,7 +76,7 @@ export function executeOnboard(scenario, cwd, repoName, appName, status) {
79
76
  gitCommit(cwd, 'Initial commit');
80
77
  steps.push('created initial commit');
81
78
  github.createRepo(repoName);
82
- steps.push(`created private repo heskethwebdesign/${repoName}`);
79
+ steps.push(`created private repo ${github.githubOrg()}/${repoName}`);
83
80
  gitAddRemote(cwd, 'origin', repoUrl);
84
81
  gitPush(cwd, 'main', true);
85
82
  steps.push('pushed main to origin');
@@ -90,7 +87,7 @@ export function executeOnboard(scenario, cwd, repoName, appName, status) {
90
87
  }
91
88
  case 'migrate': {
92
89
  github.createRepo(repoName);
93
- steps.push(`created private repo heskethwebdesign/${repoName}`);
90
+ steps.push(`created private repo ${github.githubOrg()}/${repoName}`);
94
91
  gitSetRemoteUrl(cwd, repoUrl);
95
92
  steps.push(`updated remote to ${repoUrl}`);
96
93
  gitPushAll(cwd);
@@ -110,7 +107,7 @@ export function executeOnboard(scenario, cwd, repoName, appName, status) {
110
107
  steps.push('created initial commit');
111
108
  }
112
109
  github.createRepo(repoName);
113
- steps.push(`created private repo heskethwebdesign/${repoName}`);
110
+ steps.push(`created private repo ${github.githubOrg()}/${repoName}`);
114
111
  gitAddRemote(cwd, 'origin', repoUrl);
115
112
  gitPushAll(cwd);
116
113
  steps.push('added remote and pushed all branches');
@@ -139,7 +136,7 @@ export function executeOnboard(scenario, cwd, repoName, appName, status) {
139
136
  const reg = load();
140
137
  const app = findApp(reg, appName);
141
138
  if (app) {
142
- app.gitRepo = `heskethwebdesign/${repoName}`;
139
+ app.gitRepo = `${github.githubOrg()}/${repoName}`;
143
140
  app.gitRemoteUrl = repoUrl;
144
141
  app.gitOnboardedAt = new Date().toISOString();
145
142
  save(reg);
@@ -1,4 +1,6 @@
1
- export declare const GITHUB_ORG = "heskethwebdesign";
1
+ /** the GitHub org this fleet instance publishes to — from operator config,
2
+ * with no default: guessing another operator's org is never correct. */
3
+ export declare function githubOrg(): string;
2
4
  export interface PullRequest {
3
5
  number: number;
4
6
  title: string;
@@ -3,8 +3,11 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { execSafe } from './exec.js';
5
5
  import { GitError } from './errors.js';
6
+ import { loadOperator } from './operator.js';
6
7
  import { assertAppName } from './validate.js';
7
- export const GITHUB_ORG = 'heskethwebdesign';
8
+ /** the GitHub org this fleet instance publishes to — from operator config,
9
+ * with no default: guessing another operator's org is never correct. */
10
+ export function githubOrg() { return loadOperator().githubOrg; }
8
11
  export function isGhAuthenticated() {
9
12
  return execSafe('gh', ['auth', 'status'], { timeout: 10_000 }).ok;
10
13
  }
@@ -15,25 +18,25 @@ export function requireGhAuth() {
15
18
  }
16
19
  export function repoExists(name) {
17
20
  assertAppName(name);
18
- return execSafe('gh', ['repo', 'view', `${GITHUB_ORG}/${name}`, '--json', 'name'], { timeout: 15_000 }).ok;
21
+ return execSafe('gh', ['repo', 'view', `${githubOrg()}/${name}`, '--json', 'name'], { timeout: 15_000 }).ok;
19
22
  }
20
23
  export function createRepo(name) {
21
24
  requireGhAuth();
22
25
  assertAppName(name);
23
26
  if (repoExists(name))
24
27
  return;
25
- const r = execSafe('gh', ['repo', 'create', `${GITHUB_ORG}/${name}`, '--private'], { timeout: 30_000 });
28
+ const r = execSafe('gh', ['repo', 'create', `${githubOrg()}/${name}`, '--private'], { timeout: 30_000 });
26
29
  if (!r.ok)
27
30
  throw new GitError(`failed to create repo: ${r.stderr}`);
28
31
  }
29
32
  export function getRepoUrl(name) {
30
- return `git@github.com:${GITHUB_ORG}/${name}.git`;
33
+ return `git@github.com:${githubOrg()}/${name}.git`;
31
34
  }
32
35
  export function createPullRequest(repo, opts) {
33
36
  requireGhAuth();
34
37
  const r = execSafe('gh', [
35
38
  'pr', 'create',
36
- '--repo', `${GITHUB_ORG}/${repo}`,
39
+ '--repo', `${githubOrg()}/${repo}`,
37
40
  '--title', opts.title,
38
41
  '--body', opts.body ?? '',
39
42
  '--head', opts.head,
@@ -63,7 +66,7 @@ export function listPullRequests(repo, state = 'open') {
63
66
  requireGhAuth();
64
67
  const r = execSafe('gh', [
65
68
  'pr', 'list',
66
- '--repo', `${GITHUB_ORG}/${repo}`,
69
+ '--repo', `${githubOrg()}/${repo}`,
67
70
  '--state', state,
68
71
  '--json', 'number,title,url,headRefName,baseRefName,state',
69
72
  ], { timeout: 15_000 });
@@ -97,7 +100,7 @@ export function protectBranch(repo, branch) {
97
100
  try {
98
101
  const r = execSafe('gh', [
99
102
  'api', '-X', 'PUT',
100
- `repos/${GITHUB_ORG}/${repo}/branches/${branch}/protection`,
103
+ `repos/${githubOrg()}/${repo}/branches/${branch}/protection`,
101
104
  '--input', tmpFile,
102
105
  ], { timeout: 15_000 });
103
106
  return r.ok;
@@ -18,6 +18,11 @@ export declare function effectivePolicy(app: AppEntry): LogPolicy;
18
18
  */
19
19
  export declare function buildComposeOverride(app: AppEntry, policy: LogPolicy): string;
20
20
  export declare function overridePath(app: AppEntry): string;
21
+ /** ensure the app repo's .gitignore covers .fleet/ so the auto-generated
22
+ * override file doesn't get committed accidentally. no-op when there's no
23
+ * .gitignore (operator may be using a different vcs or none at all) and
24
+ * idempotent — never appends a duplicate entry. */
25
+ export declare function ensureFleetGitignored(composePath: string): void;
21
26
  export declare function writeComposeOverride(app: AppEntry, policy: LogPolicy): string;
22
27
  export interface LogStatus {
23
28
  app: string;
@@ -3,7 +3,7 @@
3
3
  * compose override file (.fleet/logging.override.yml) per app, plus journald
4
4
  * vacuum policy. Conservative defaults applied when unset.
5
5
  */
6
- import { existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { execSafe } from './exec.js';
9
9
  export const DEFAULT_POLICY = {
@@ -50,12 +50,31 @@ export function buildComposeOverride(app, policy) {
50
50
  export function overridePath(app) {
51
51
  return join(app.composePath, '.fleet', 'logging.override.yml');
52
52
  }
53
+ /** ensure the app repo's .gitignore covers .fleet/ so the auto-generated
54
+ * override file doesn't get committed accidentally. no-op when there's no
55
+ * .gitignore (operator may be using a different vcs or none at all) and
56
+ * idempotent — never appends a duplicate entry. */
57
+ export function ensureFleetGitignored(composePath) {
58
+ const giPath = join(composePath, '.gitignore');
59
+ if (!existsSync(giPath))
60
+ return;
61
+ const current = readFileSync(giPath, 'utf-8');
62
+ // accept any of: .fleet, .fleet/, /.fleet, /.fleet/ — operators write all
63
+ // four forms.
64
+ if (/^\s*\/?\.fleet\/?\s*$/m.test(current))
65
+ return;
66
+ const append = current.endsWith('\n') ? '' : '\n';
67
+ writeFileSync(giPath, `${current}${append}\n# auto-added by fleet logs setup\n.fleet/\n`);
68
+ }
53
69
  export function writeComposeOverride(app, policy) {
54
70
  const path = overridePath(app);
55
71
  const dir = dirname(path);
56
72
  if (!existsSync(dir))
57
73
  mkdirSync(dir, { recursive: true });
58
74
  writeFileSync(path, buildComposeOverride(app, policy));
75
+ // the override lands inside the operator's app repo. add .fleet/ to the
76
+ // app's .gitignore (if there is one) so it doesn't get committed.
77
+ ensureFleetGitignored(app.composePath);
59
78
  return path;
60
79
  }
61
80
  /** Best-effort docker-side log status. Uses `docker inspect` for driver + size. */
@@ -0,0 +1,21 @@
1
+ export interface OperatorConfig {
2
+ username: string;
3
+ homeDir: string;
4
+ domain: string;
5
+ githubOrg: string;
6
+ }
7
+ export type OperatorField = keyof OperatorConfig;
8
+ export declare const OPERATOR_FIELDS: readonly OperatorField[];
9
+ /** test-only: clears the memoised config. */
10
+ export declare function _resetOperatorCache(): void;
11
+ /** path the operator config is read from / written to. exported so the
12
+ * fleet config command can print where it lives. */
13
+ export declare function operatorPath(): string;
14
+ /** loads operator identity from data/operator.json (gitignored, instance-local).
15
+ * throws if the file is missing or incomplete — there is no safe default,
16
+ * and guessing another operator's identity is never correct. */
17
+ export declare function loadOperator(): OperatorConfig;
18
+ /** persist the operator config to disk and clear the memoised copy so the
19
+ * next loadOperator() picks up the new values. atomic write via .tmp +
20
+ * rename so a crash mid-write never leaves a partial file behind. */
21
+ export declare function saveOperator(cfg: OperatorConfig): void;
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { FleetError } from './errors.js';
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+ export const OPERATOR_FIELDS = ['username', 'homeDir', 'domain', 'githubOrg'];
7
+ const FIELDS = OPERATOR_FIELDS;
8
+ let cache = null;
9
+ /** test-only: clears the memoised config. */
10
+ export function _resetOperatorCache() { cache = null; }
11
+ /** path the operator config is read from / written to. exported so the
12
+ * fleet config command can print where it lives. */
13
+ export function operatorPath() {
14
+ return process.env.FLEET_OPERATOR_PATH ?? join(here, '..', '..', 'data', 'operator.json');
15
+ }
16
+ /** loads operator identity from data/operator.json (gitignored, instance-local).
17
+ * throws if the file is missing or incomplete — there is no safe default,
18
+ * and guessing another operator's identity is never correct. */
19
+ export function loadOperator() {
20
+ if (cache)
21
+ return cache;
22
+ const path = operatorPath();
23
+ if (!existsSync(path)) {
24
+ throw new FleetError(`operator config not found at ${path} — ` +
25
+ `copy data/operator.example.json to data/operator.json and fill it in`);
26
+ }
27
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
28
+ for (const field of FIELDS) {
29
+ if (!raw[field])
30
+ throw new FleetError(`operator config ${path} is missing field: ${field}`);
31
+ }
32
+ cache = raw;
33
+ return cache;
34
+ }
35
+ /** persist the operator config to disk and clear the memoised copy so the
36
+ * next loadOperator() picks up the new values. atomic write via .tmp +
37
+ * rename so a crash mid-write never leaves a partial file behind. */
38
+ export function saveOperator(cfg) {
39
+ for (const field of FIELDS) {
40
+ if (typeof cfg[field] !== 'string' || cfg[field].length === 0) {
41
+ throw new FleetError(`operator config: ${field} must be a non-empty string`);
42
+ }
43
+ }
44
+ const path = operatorPath();
45
+ const dir = dirname(path);
46
+ if (!existsSync(dir))
47
+ mkdirSync(dir, { recursive: true });
48
+ const tmp = path + '.tmp';
49
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
50
+ // rename is atomic on the same filesystem; covers the crash-mid-write race
51
+ // a plain writeFileSync exposes.
52
+ renameSync(tmp, path);
53
+ cache = null;
54
+ }
@@ -61,3 +61,21 @@ export declare function findApp(reg: Registry, name: string): AppEntry | undefin
61
61
  export declare function addApp(reg: Registry, app: AppEntry): Registry;
62
62
  export declare function removeApp(reg: Registry, name: string): Registry;
63
63
  export declare function registryPath(): string;
64
+ /**
65
+ * Run a read-modify-write transaction against the registry under an
66
+ * inter-process lock. The lock is held for the full load → mutate → save
67
+ * cycle, so concurrent CLI / cron / systemd / bot invocations don't lose
68
+ * each other's updates.
69
+ *
70
+ * The mutator may return a different Registry object (e.g. one returned by
71
+ * `addApp` / `removeApp`, which mutate in place but also return the registry
72
+ * for chaining) or simply mutate the input and return it. The returned value
73
+ * is what gets persisted.
74
+ *
75
+ * Returns void: callers needing the post-save state should re-load. Keeping
76
+ * this side-effecting matches how `load()` + `save()` are used today.
77
+ *
78
+ * Important: do not call this from inside another `withRegistry` block on the
79
+ * same process — proper-lockfile is not reentrant and will deadlock.
80
+ */
81
+ export declare function withRegistry(fn: (reg: Registry) => Registry | Promise<Registry>): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync, mkdirSync, copyFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { withFileLock } from './file-lock.js';
4
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
6
  function resolveRegistryPath() {
6
7
  return process.env.FLEET_REGISTRY_PATH
@@ -92,3 +93,28 @@ export function removeApp(reg, name) {
92
93
  export function registryPath() {
93
94
  return resolveRegistryPath();
94
95
  }
96
+ /**
97
+ * Run a read-modify-write transaction against the registry under an
98
+ * inter-process lock. The lock is held for the full load → mutate → save
99
+ * cycle, so concurrent CLI / cron / systemd / bot invocations don't lose
100
+ * each other's updates.
101
+ *
102
+ * The mutator may return a different Registry object (e.g. one returned by
103
+ * `addApp` / `removeApp`, which mutate in place but also return the registry
104
+ * for chaining) or simply mutate the input and return it. The returned value
105
+ * is what gets persisted.
106
+ *
107
+ * Returns void: callers needing the post-save state should re-load. Keeping
108
+ * this side-effecting matches how `load()` + `save()` are used today.
109
+ *
110
+ * Important: do not call this from inside another `withRegistry` block on the
111
+ * same process — proper-lockfile is not reentrant and will deadlock.
112
+ */
113
+ export async function withRegistry(fn) {
114
+ const path = resolveRegistryPath();
115
+ await withFileLock(path, async () => {
116
+ const reg = load();
117
+ const next = await fn(reg);
118
+ save(next);
119
+ });
120
+ }
@@ -204,15 +204,14 @@ export declare const RoutineSchema: z.ZodObject<{
204
204
  createdAt: z.ZodOptional<z.ZodString>;
205
205
  updatedAt: z.ZodOptional<z.ZodString>;
206
206
  }, "strip", z.ZodTypeAny, {
207
- enabled: boolean;
208
207
  name: string;
208
+ enabled: boolean;
209
209
  id: string;
210
210
  notify: {
211
211
  config: Record<string, unknown>;
212
212
  kind: "email" | "stdout" | "webhook" | "slack";
213
213
  on: "always" | "failure" | "success";
214
214
  }[];
215
- description: string;
216
215
  schedule: {
217
216
  kind: "manual";
218
217
  } | {
@@ -221,6 +220,8 @@ export declare const RoutineSchema: z.ZodObject<{
221
220
  randomizedDelaySec: number;
222
221
  persistent: boolean;
223
222
  };
223
+ tags: string[];
224
+ description: string;
224
225
  targets: string[];
225
226
  perTarget: boolean;
226
227
  task: {
@@ -244,7 +245,6 @@ export declare const RoutineSchema: z.ZodObject<{
244
245
  tool: string;
245
246
  args: Record<string, unknown>;
246
247
  };
247
- tags: string[];
248
248
  updatedAt?: string | undefined;
249
249
  createdAt?: string | undefined;
250
250
  }, {
@@ -286,10 +286,10 @@ export declare const RoutineSchema: z.ZodObject<{
286
286
  config?: Record<string, unknown> | undefined;
287
287
  on?: "always" | "failure" | "success" | undefined;
288
288
  }[] | undefined;
289
+ tags?: string[] | undefined;
289
290
  description?: string | undefined;
290
291
  targets?: string[] | undefined;
291
292
  perTarget?: boolean | undefined;
292
- tags?: string[] | undefined;
293
293
  createdAt?: string | undefined;
294
294
  }>;
295
295
  export type Routine = z.infer<typeof RoutineSchema>;
@@ -303,13 +303,13 @@ export declare const RunEventSchema: z.ZodDiscriminatedUnion<"kind", [z.ZodObjec
303
303
  }, "strip", z.ZodTypeAny, {
304
304
  at: string;
305
305
  kind: "start";
306
- routineId: string;
307
306
  target: string | null;
307
+ routineId: string;
308
308
  }, {
309
309
  at: string;
310
310
  kind: "start";
311
- routineId: string;
312
311
  target: string | null;
312
+ routineId: string;
313
313
  }>, z.ZodObject<{
314
314
  kind: z.ZodLiteral<"stdout">;
315
315
  chunk: z.ZodString;
@@ -370,14 +370,14 @@ export declare const RunEventSchema: z.ZodDiscriminatedUnion<"kind", [z.ZodObjec
370
370
  error: z.ZodOptional<z.ZodString>;
371
371
  }, "strip", z.ZodTypeAny, {
372
372
  at: string;
373
- status: "timeout" | "ok" | "failed" | "aborted" | "queued" | "running";
373
+ status: "aborted" | "timeout" | "ok" | "queued" | "running" | "failed";
374
374
  kind: "end";
375
375
  exitCode: number;
376
376
  durationMs: number;
377
377
  error?: string | undefined;
378
378
  }, {
379
379
  at: string;
380
- status: "timeout" | "ok" | "failed" | "aborted" | "queued" | "running";
380
+ status: "aborted" | "timeout" | "ok" | "queued" | "running" | "failed";
381
381
  kind: "end";
382
382
  exitCode: number;
383
383
  durationMs: number;
@@ -397,16 +397,16 @@ export declare const SignalSchema: z.ZodObject<{
397
397
  collectedAt: z.ZodString;
398
398
  ttlMs: z.ZodNumber;
399
399
  }, "strip", z.ZodTypeAny, {
400
- detail: string;
401
- kind: "git-clean" | "git-ahead" | "git-behind" | "open-prs" | "pr-age-max" | "deps-outdated" | "deps-vulns" | "build-ok" | "tests-ok" | "env-schema-ok" | "container-up" | "ci-status" | "cache-age";
402
400
  value: string | number | boolean | null;
401
+ kind: "git-clean" | "git-ahead" | "git-behind" | "open-prs" | "pr-age-max" | "deps-outdated" | "deps-vulns" | "build-ok" | "tests-ok" | "env-schema-ok" | "container-up" | "ci-status" | "cache-age";
402
+ detail: string;
403
403
  repo: string;
404
404
  state: "warn" | "error" | "unknown" | "ok";
405
405
  collectedAt: string;
406
406
  ttlMs: number;
407
407
  }, {
408
- kind: "git-clean" | "git-ahead" | "git-behind" | "open-prs" | "pr-age-max" | "deps-outdated" | "deps-vulns" | "build-ok" | "tests-ok" | "env-schema-ok" | "container-up" | "ci-status" | "cache-age";
409
408
  value: string | number | boolean | null;
409
+ kind: "git-clean" | "git-ahead" | "git-behind" | "open-prs" | "pr-age-max" | "deps-outdated" | "deps-vulns" | "build-ok" | "tests-ok" | "env-schema-ok" | "container-up" | "ci-status" | "cache-age";
410
410
  repo: string;
411
411
  state: "warn" | "error" | "unknown" | "ok";
412
412
  collectedAt: string;
@@ -1,6 +1,17 @@
1
1
  import { z } from 'zod';
2
2
  const ROUTINE_ID_REGEX = /^[a-z][a-z0-9-]{0,62}$/;
3
3
  const NO_SHELL_META = /^[^`$;&|><\n\r\\"]*$/;
4
+ // Printable ASCII only — no newlines, no control chars. Routine names and
5
+ // descriptions are interpolated into systemd unit files; a newline would let
6
+ // a hostile name inject directives like [Service]/User=root/ExecStart=…
7
+ // that the systemd loader then runs as root after daemon-reload. Keep this
8
+ // strict — widen explicitly if a future feature truly needs more.
9
+ const ROUTINE_TEXT_REGEX = /^[\x20-\x7E]+$/;
10
+ // systemd's documented OnCalendar grammar: weekdays, dates, times — all
11
+ // printable ASCII without quotes, semicolons, control chars or newlines.
12
+ // Same injection concern as above. Widen if "~" (last weekday) etc. is
13
+ // actually needed.
14
+ const ON_CALENDAR_REGEX = /^[A-Za-z0-9*\-/:., \t]+$/;
4
15
  const DEFAULT_WALLCLOCK_MS = 15 * 60 * 1000;
5
16
  const DEFAULT_TOKEN_CAP = 100_000;
6
17
  const DEFAULT_MAX_USD = 5;
@@ -38,15 +49,15 @@ export const RoutineScheduleSchema = z.union([
38
49
  z.object({ kind: z.literal('manual') }),
39
50
  z.object({
40
51
  kind: z.literal('calendar'),
41
- onCalendar: z.string().min(1).max(200),
52
+ onCalendar: z.string().min(1).max(200).regex(ON_CALENDAR_REGEX, 'systemd OnCalendar tokens only (letters, digits, *-/:., space, tab)'),
42
53
  randomizedDelaySec: z.number().int().nonnegative().max(3600).default(0),
43
54
  persistent: z.boolean().default(true),
44
55
  }),
45
56
  ]);
46
57
  export const RoutineSchema = z.object({
47
58
  id: z.string().regex(ROUTINE_ID_REGEX, 'lowercase alphanumeric and dashes only'),
48
- name: z.string().min(1).max(100),
49
- description: z.string().max(2000).default(''),
59
+ name: z.string().min(1).max(100).regex(ROUTINE_TEXT_REGEX, 'printable ASCII only, no newlines or control chars'),
60
+ description: z.string().max(2000).regex(/^[\x20-\x7E]*$/, 'printable ASCII only, no newlines or control chars').default(''),
50
61
  schedule: RoutineScheduleSchema,
51
62
  enabled: z.boolean().default(true),
52
63
  targets: z.array(z.string().min(1)).default([]),