@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,40 @@
1
+ /** build the templated systemd unit for fleet-secrets-agent@%i.service.
2
+ * the vault path comes from the caller — production code passes whatever
3
+ * FLEET_VAULT_DIR or the repo-local default resolves to, so this template
4
+ * never carries an operator-specific assumption. */
5
+ export function generateAgentUnit(vaultPath) {
6
+ return [
7
+ '[Unit]',
8
+ 'Description=Fleet Secrets Agent for %i',
9
+ 'After=network.target',
10
+ 'PartOf=docker-%i.service',
11
+ '',
12
+ '[Service]',
13
+ 'Type=notify',
14
+ 'DynamicUser=yes',
15
+ 'RuntimeDirectory=fleet-secrets',
16
+ 'RuntimeDirectoryPreserve=yes',
17
+ 'LoadCredentialEncrypted=age-key:/etc/fleet/credentials/%i.cred',
18
+ `ExecStart=/usr/local/bin/fleet-agent --app %i --vault ${vaultPath} --socket /run/fleet-secrets/%i.sock`,
19
+ 'Restart=on-failure',
20
+ 'RestartSec=2',
21
+ '',
22
+ '# hardening',
23
+ 'ProtectSystem=strict',
24
+ 'ProtectHome=read-only',
25
+ `ReadOnlyPaths=${vaultPath}`,
26
+ 'PrivateTmp=yes',
27
+ 'NoNewPrivileges=yes',
28
+ 'ProtectKernelTunables=yes',
29
+ 'ProtectKernelModules=yes',
30
+ 'ProtectControlGroups=yes',
31
+ 'RestrictAddressFamilies=AF_UNIX',
32
+ 'RestrictNamespaces=yes',
33
+ 'SystemCallFilter=@system-service',
34
+ 'SystemCallFilter=~@privileged @resources @mount',
35
+ '',
36
+ '[Install]',
37
+ 'WantedBy=multi-user.target',
38
+ '',
39
+ ].join('\n');
40
+ }
@@ -0,0 +1,2 @@
1
+ export declare function addAgentDependency(content: string, app: string): string;
2
+ export declare function removeAgentDependency(content: string, app: string): string;
@@ -0,0 +1,46 @@
1
+ function findUnitSection(content) {
2
+ const lines = content.split('\n');
3
+ let start = -1;
4
+ let end = -1;
5
+ for (let i = 0; i < lines.length; i++) {
6
+ if (lines[i].trim() === '[Unit]') {
7
+ start = i;
8
+ continue;
9
+ }
10
+ if (start >= 0 && lines[i].startsWith('[') && lines[i].endsWith(']')) {
11
+ end = i;
12
+ break;
13
+ }
14
+ }
15
+ if (start < 0)
16
+ throw new Error('no [Unit] section found');
17
+ if (end < 0)
18
+ end = lines.length;
19
+ return { start, end };
20
+ }
21
+ export function addAgentDependency(content, app) {
22
+ const lines = content.split('\n');
23
+ const requires = `Requires=fleet-secrets-agent@${app}.service`;
24
+ const after = `After=fleet-secrets-agent@${app}.service`;
25
+ if (lines.includes(requires) && lines.includes(after))
26
+ return content;
27
+ const section = findUnitSection(content);
28
+ let insertAt = section.end;
29
+ while (insertAt > section.start + 1 && lines[insertAt - 1].trim() === '')
30
+ insertAt--;
31
+ const toInsert = [];
32
+ if (!lines.includes(requires))
33
+ toInsert.push(requires);
34
+ if (!lines.includes(after))
35
+ toInsert.push(after);
36
+ lines.splice(insertAt, 0, ...toInsert);
37
+ return lines.join('\n');
38
+ }
39
+ export function removeAgentDependency(content, app) {
40
+ const requires = `Requires=fleet-secrets-agent@${app}.service`;
41
+ const after = `After=fleet-secrets-agent@${app}.service`;
42
+ return content
43
+ .split('\n')
44
+ .filter(l => l !== requires && l !== after)
45
+ .join('\n');
46
+ }
@@ -0,0 +1,2 @@
1
+ export declare function migrateComposeToV2(yamlContent: string, app: string, service: string): string;
2
+ export declare function revertComposeFromV2(yamlContent: string, app: string, service: string): string;
@@ -0,0 +1,156 @@
1
+ import { parseDocument, Scalar, isMap, isSeq, isScalar } from 'yaml';
2
+ const FLEET_ENV_KEY = 'FLEET_SECRETS_SOCKET';
3
+ const FLEET_ENV_VAL = '/run/fleet.sock';
4
+ function v1EnvFile(app) {
5
+ return `/run/fleet-secrets/${app}/.env`;
6
+ }
7
+ function v2SocketMount(app) {
8
+ return `/run/fleet-secrets/${app}.sock:/run/fleet.sock:ro`;
9
+ }
10
+ function getService(doc, service) {
11
+ const svc = doc.getIn(['services', service], true);
12
+ if (!svc || !isMap(svc)) {
13
+ throw new Error(`service '${service}' not found in compose file`);
14
+ }
15
+ return svc;
16
+ }
17
+ function removeEnvFileEntry(svc, app) {
18
+ const v1Path = v1EnvFile(app);
19
+ const raw = svc.get('env_file', true);
20
+ if (raw === undefined || raw === null)
21
+ return;
22
+ if (isScalar(raw)) {
23
+ if (raw.value === v1Path) {
24
+ svc.delete('env_file');
25
+ }
26
+ return;
27
+ }
28
+ if (isSeq(raw)) {
29
+ const seq = raw;
30
+ const idx = seq.items.findIndex(item => isScalar(item) && item.value === v1Path);
31
+ if (idx !== -1) {
32
+ seq.items.splice(idx, 1);
33
+ }
34
+ if (seq.items.length === 0) {
35
+ svc.delete('env_file');
36
+ }
37
+ return;
38
+ }
39
+ if (typeof raw === 'string' && raw === v1Path) {
40
+ svc.delete('env_file');
41
+ }
42
+ }
43
+ function ensureEnvVar(svc) {
44
+ const envRaw = svc.get('environment', true);
45
+ if (!envRaw) {
46
+ svc.set('environment', { [FLEET_ENV_KEY]: FLEET_ENV_VAL });
47
+ return;
48
+ }
49
+ if (isMap(envRaw)) {
50
+ const env = envRaw;
51
+ if (!env.has(FLEET_ENV_KEY)) {
52
+ env.set(FLEET_ENV_KEY, FLEET_ENV_VAL);
53
+ }
54
+ return;
55
+ }
56
+ if (isSeq(envRaw)) {
57
+ const seq = envRaw;
58
+ const kvPrefix = `${FLEET_ENV_KEY}=`;
59
+ const already = seq.items.some(item => isScalar(item) && typeof item.value === 'string' && item.value.startsWith(kvPrefix));
60
+ if (!already) {
61
+ seq.add(`${FLEET_ENV_KEY}=${FLEET_ENV_VAL}`);
62
+ }
63
+ return;
64
+ }
65
+ }
66
+ function removeEnvVar(svc) {
67
+ const envRaw = svc.get('environment', true);
68
+ if (!envRaw)
69
+ return;
70
+ if (isMap(envRaw)) {
71
+ const env = envRaw;
72
+ env.delete(FLEET_ENV_KEY);
73
+ if (env.items.length === 0) {
74
+ svc.delete('environment');
75
+ }
76
+ return;
77
+ }
78
+ if (isSeq(envRaw)) {
79
+ const seq = envRaw;
80
+ const kvPrefix = `${FLEET_ENV_KEY}=`;
81
+ const idx = seq.items.findIndex(item => isScalar(item) && typeof item.value === 'string' && item.value.startsWith(kvPrefix));
82
+ if (idx !== -1) {
83
+ seq.items.splice(idx, 1);
84
+ }
85
+ if (seq.items.length === 0) {
86
+ svc.delete('environment');
87
+ }
88
+ }
89
+ }
90
+ function ensureSocketMount(svc, app) {
91
+ const mount = v2SocketMount(app);
92
+ const volRaw = svc.get('volumes', true);
93
+ if (!volRaw) {
94
+ svc.set('volumes', [mount]);
95
+ return;
96
+ }
97
+ if (isSeq(volRaw)) {
98
+ const seq = volRaw;
99
+ const already = seq.items.some(item => isScalar(item) && item.value === mount);
100
+ if (!already) {
101
+ seq.add(mount);
102
+ }
103
+ return;
104
+ }
105
+ }
106
+ function removeSocketMount(svc, app) {
107
+ const mount = v2SocketMount(app);
108
+ const volRaw = svc.get('volumes', true);
109
+ if (!volRaw || !isSeq(volRaw))
110
+ return;
111
+ const seq = volRaw;
112
+ const idx = seq.items.findIndex(item => isScalar(item) && item.value === mount);
113
+ if (idx !== -1) {
114
+ seq.items.splice(idx, 1);
115
+ }
116
+ if (seq.items.length === 0) {
117
+ svc.delete('volumes');
118
+ }
119
+ }
120
+ function restoreEnvFile(svc, app) {
121
+ const v1Path = v1EnvFile(app);
122
+ const raw = svc.get('env_file', true);
123
+ if (!raw) {
124
+ svc.set('env_file', v1Path);
125
+ return;
126
+ }
127
+ if (isScalar(raw)) {
128
+ if (raw.value !== v1Path) {
129
+ svc.set('env_file', v1Path);
130
+ }
131
+ return;
132
+ }
133
+ if (isSeq(raw)) {
134
+ const seq = raw;
135
+ const already = seq.items.some(item => isScalar(item) && item.value === v1Path);
136
+ if (!already) {
137
+ seq.items.unshift(new Scalar(v1Path));
138
+ }
139
+ }
140
+ }
141
+ export function migrateComposeToV2(yamlContent, app, service) {
142
+ const doc = parseDocument(yamlContent);
143
+ const svc = getService(doc, service);
144
+ removeEnvFileEntry(svc, app);
145
+ ensureEnvVar(svc);
146
+ ensureSocketMount(svc, app);
147
+ return doc.toString();
148
+ }
149
+ export function revertComposeFromV2(yamlContent, app, service) {
150
+ const doc = parseDocument(yamlContent);
151
+ const svc = getService(doc, service);
152
+ removeSocketMount(svc, app);
153
+ removeEnvVar(svc);
154
+ restoreEnvFile(svc, app);
155
+ return doc.toString();
156
+ }
@@ -1,4 +1,15 @@
1
+ import { assertDomain, assertHealthPath } from '../core/validate.js';
1
2
  export function generateNginxConfig(opts) {
3
+ // defence-in-depth: callers are expected to validate already, but a
4
+ // missed call must not produce a config that injects directives via
5
+ // ${domain} (server_name interpolation) or ${port} (proxy_pass).
6
+ // mirrors the assertComposeFile pattern in src/templates/systemd.ts.
7
+ assertDomain(opts.domain);
8
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) {
9
+ throw new Error(`invalid port: ${opts.port}`);
10
+ }
11
+ if (opts.apiPrefix !== undefined)
12
+ assertHealthPath(opts.apiPrefix);
2
13
  const { domain, port, type } = opts;
3
14
  const apiPrefix = opts.apiPrefix ?? '/api';
4
15
  const securityHeaders = ` add_header X-Content-Type-Options "nosniff" always;
@@ -1,4 +1,10 @@
1
+ import { assertComposeFile } from '../core/validate.js';
1
2
  export function generateServiceFile(opts) {
3
+ // Defence-in-depth: even if a caller skipped upstream validation, refuse to
4
+ // emit a unit file with a composeFile value that could break out of the
5
+ // quoted -f argument and inject extra docker-compose flags or shell.
6
+ if (opts.composeFile)
7
+ assertComposeFile(opts.composeFile);
2
8
  const fileFlag = opts.composeFile ? ` -f "${opts.composeFile}"` : '';
3
9
  const dbDep = opts.dependsOnDatabases ? ' docker-databases.service' : '';
4
10
  return `[Unit]
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { z } from 'zod';
3
+ export declare function ArgForm(props: {
4
+ schema: z.ZodObject<z.ZodRawShape>;
5
+ onSubmit: (values: Record<string, unknown>) => void;
6
+ onCancel: () => void;
7
+ }): React.JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { z } from 'zod';
5
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
+ import { colors } from '../theme.js';
7
+ function describeField(name, schema) {
8
+ let s = schema;
9
+ while (s instanceof z.ZodOptional || s instanceof z.ZodDefault) {
10
+ s = s._def.innerType;
11
+ }
12
+ if (s instanceof z.ZodBoolean)
13
+ return { name, kind: 'boolean' };
14
+ if (s instanceof z.ZodNumber)
15
+ return { name, kind: 'number' };
16
+ if (s instanceof z.ZodEnum)
17
+ return { name, kind: 'enum', options: s._def.values };
18
+ return { name, kind: 'string' };
19
+ }
20
+ export function ArgForm(props) {
21
+ const fields = Object.entries(props.schema.shape).map(([n, s]) => describeField(n, s));
22
+ const [cursor, setCursor] = useState(0);
23
+ const [values, setValues] = useState(() => Object.fromEntries(fields.map(f => [f.name, f.kind === 'boolean' ? false : ''])));
24
+ const handler = (input, key) => {
25
+ if (key.escape) {
26
+ props.onCancel();
27
+ return true;
28
+ }
29
+ if (key.return) {
30
+ props.onSubmit(values);
31
+ return true;
32
+ }
33
+ if (key.downArrow) {
34
+ setCursor(c => Math.min(c + 1, fields.length - 1));
35
+ return true;
36
+ }
37
+ if (key.upArrow) {
38
+ setCursor(c => Math.max(c - 1, 0));
39
+ return true;
40
+ }
41
+ const field = fields[cursor];
42
+ if (!field)
43
+ return false;
44
+ if (field.kind === 'boolean') {
45
+ if (input === ' ') {
46
+ setValues(v => ({ ...v, [field.name]: !v[field.name] }));
47
+ return true;
48
+ }
49
+ }
50
+ else if (key.backspace || key.delete) {
51
+ setValues(v => ({ ...v, [field.name]: String(v[field.name] ?? '').slice(0, -1) }));
52
+ return true;
53
+ }
54
+ else if (input && !key.ctrl && !key.meta) {
55
+ setValues(v => ({ ...v, [field.name]: String(v[field.name] ?? '') + input }));
56
+ return true;
57
+ }
58
+ return false;
59
+ };
60
+ useRegisterHandler(handler);
61
+ return (_jsxs(Box, { flexDirection: "column", children: [fields.map((f, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: i === cursor ? colors.primary : colors.muted, children: [i === cursor ? '> ' : ' ', f.name, ":", ' '] }), _jsx(Text, { children: f.kind === 'boolean'
62
+ ? (values[f.name] ? '[x]' : '[ ]')
63
+ : String(values[f.name] ?? '') })] }, f.name))), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.muted, children: "\u2191\u2193 field \u00B7 space toggle \u00B7 enter run \u00B7 esc cancel" }) })] }));
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { z } from 'zod';
5
+ import { ArgForm } from './ArgForm.js';
6
+ describe('ArgForm', () => {
7
+ it('renders one labelled field per schema property', () => {
8
+ const schema = z.object({ app: z.string(), force: z.boolean().default(false) });
9
+ const { lastFrame } = render(_jsx(ArgForm, { schema: schema, onSubmit: () => { }, onCancel: () => { } }));
10
+ const frame = lastFrame() ?? '';
11
+ expect(frame).toContain('app');
12
+ expect(frame).toContain('force');
13
+ });
14
+ it('shows a toggle hint for boolean fields', () => {
15
+ const schema = z.object({ force: z.boolean().default(false) });
16
+ const { lastFrame } = render(_jsx(ArgForm, { schema: schema, onSubmit: () => { }, onCancel: () => { } }));
17
+ expect(lastFrame() ?? '').toMatch(/false|off|\[ \]/i);
18
+ });
19
+ });
@@ -54,6 +54,11 @@ const viewHints = {
54
54
  { key: 'L', label: 'level' },
55
55
  { key: 'q', label: 'quit' },
56
56
  ],
57
+ 'command-palette': [
58
+ { key: '↑/↓', label: 'navigate' },
59
+ { key: 'Enter', label: 'run' },
60
+ { key: 'Esc', label: 'close' },
61
+ ],
57
62
  };
58
63
  export function KeyHint() {
59
64
  const { currentView, confirmAction } = useAppState();
@@ -20,28 +20,28 @@ interface SecretsState {
20
20
  interface SecretsActions {
21
21
  refresh: () => void;
22
22
  loadAppSecrets: (app: string) => void;
23
- saveSecret: (app: string, key: string, value: string) => {
23
+ saveSecret: (app: string, key: string, value: string) => Promise<{
24
24
  ok: boolean;
25
25
  error?: string;
26
- };
27
- deleteSecret: (app: string, key: string) => {
26
+ }>;
27
+ deleteSecret: (app: string, key: string) => Promise<{
28
28
  ok: boolean;
29
29
  error?: string;
30
- };
30
+ }>;
31
31
  revealSecret: (app: string, key: string) => void;
32
32
  hideSecret: (key: string) => void;
33
33
  unseal: () => {
34
34
  ok: boolean;
35
35
  error?: string;
36
36
  };
37
- seal: () => {
37
+ seal: () => Promise<{
38
38
  ok: boolean;
39
39
  error?: string;
40
- };
41
- importEnv: (app: string, path: string) => {
40
+ }>;
41
+ importEnv: (app: string, path: string) => Promise<{
42
42
  ok: boolean;
43
43
  error?: string;
44
- };
44
+ }>;
45
45
  }
46
46
  export declare function useSecrets(): SecretsState & SecretsActions;
47
47
  export {};
@@ -48,9 +48,9 @@ export function useSecrets() {
48
48
  }));
49
49
  }
50
50
  }, []);
51
- const saveSecret = useCallback((app, key, value) => {
51
+ const saveSecret = useCallback(async (app, key, value) => {
52
52
  try {
53
- setSecret(app, key, value);
53
+ await setSecret(app, key, value);
54
54
  // Re-unseal to update runtime
55
55
  try {
56
56
  unsealAll();
@@ -62,7 +62,7 @@ export function useSecrets() {
62
62
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to save secret' };
63
63
  }
64
64
  }, []);
65
- const deleteSecret = useCallback((app, key) => {
65
+ const deleteSecret = useCallback(async (app, key) => {
66
66
  try {
67
67
  const plaintext = decryptApp(app);
68
68
  const manifest = loadManifest();
@@ -114,9 +114,9 @@ export function useSecrets() {
114
114
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to unseal' };
115
115
  }
116
116
  }, []);
117
- const seal = useCallback(() => {
117
+ const seal = useCallback(async () => {
118
118
  try {
119
- sealFromRuntime();
119
+ await sealFromRuntime();
120
120
  setState(prev => ({ ...prev, sealed: true }));
121
121
  return { ok: true };
122
122
  }
@@ -124,9 +124,9 @@ export function useSecrets() {
124
124
  return { ok: false, error: err instanceof Error ? err.message : 'Failed to seal' };
125
125
  }
126
126
  }, []);
127
- const importEnv = useCallback((app, path) => {
127
+ const importEnv = useCallback(async (app, path) => {
128
128
  try {
129
- importEnvFile(app, path);
129
+ await importEnvFile(app, path);
130
130
  try {
131
131
  unsealAll();
132
132
  }
@@ -1,2 +1,3 @@
1
1
  import React from 'react';
2
+ export declare function ViewRouter(): React.JSX.Element;
2
3
  export declare function App(): React.JSX.Element;
@@ -17,6 +17,7 @@ import { SecretsView } from './views/SecretsView.js';
17
17
  import { SecretEdit } from './views/SecretEdit.js';
18
18
  import { HealthView } from './views/HealthView.js';
19
19
  import { LogsView } from './views/LogsView.js';
20
+ import { CommandPalette } from './views/CommandPalette.js';
20
21
  import { isSealed, isInitialized } from '../core/secrets.js';
21
22
  const HELP_GROUPS = [
22
23
  {
@@ -47,8 +48,9 @@ const HELP_GROUPS = [
47
48
  ],
48
49
  },
49
50
  ];
50
- function ViewRouter() {
51
+ export function ViewRouter() {
51
52
  const state = React.useContext(AppStateContext);
53
+ const dispatch = React.useContext(AppDispatchContext);
52
54
  switch (state.currentView) {
53
55
  case 'dashboard':
54
56
  return _jsx(Dashboard, {});
@@ -62,6 +64,8 @@ function ViewRouter() {
62
64
  return _jsx(SecretEdit, {});
63
65
  case 'logs':
64
66
  return _jsx(LogsView, {});
67
+ case 'command-palette':
68
+ return (_jsx(CommandPalette, { onClose: () => dispatch({ type: 'GO_BACK' }), onOpenView: view => dispatch({ type: 'NAVIGATE', view: view }) }));
65
69
  default:
66
70
  return _jsx(Dashboard, {});
67
71
  }
@@ -75,7 +79,10 @@ function UpdateBanner({ info, inProgress }) {
75
79
  }
76
80
  const ahead = info.behind;
77
81
  const subject = info.latestSubject ? ` — ${info.latestSubject}` : '';
78
- return (_jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { color: "cyan", children: ["\u2191 Update available: ", ahead, " commit", ahead === 1 ? '' : 's', " ahead", subject, ". Press "] }), _jsx(Text, { color: "cyan", bold: true, children: "U" }), _jsx(Text, { color: "cyan", children: " to install." })] }) }));
82
+ // channel label only surfaces on prerelease so the stable case stays
83
+ // visually identical to what operators have seen for several releases.
84
+ const channelLabel = info.channel === 'prerelease' ? ' (prerelease)' : '';
85
+ return (_jsx(Box, { paddingX: 1, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { color: "cyan", children: ["\u2191 Update available", channelLabel, ": ", ahead, " commit", ahead === 1 ? '' : 's', " ahead", subject, ". Press "] }), _jsx(Text, { color: "cyan", bold: true, children: "U" }), _jsx(Text, { color: "cyan", children: " to install." })] }) }));
79
86
  }
80
87
  export function App() {
81
88
  const [state, dispatch] = useReducer(reducer, initialState);
@@ -141,27 +148,37 @@ export function App() {
141
148
  }
142
149
  return true;
143
150
  }
144
- if (input === '?' && state.currentView !== 'secret-edit') {
151
+ // command-palette and secret-edit capture raw text input — the global
152
+ // single-key shortcuts must not fire while either is open.
153
+ const isInputView = state.currentView === 'secret-edit' || state.currentView === 'command-palette';
154
+ if (input === '?' && !isInputView) {
145
155
  setShowHelp(true);
146
156
  return true;
147
157
  }
148
- if (input === 'q' && state.currentView !== 'secret-edit') {
158
+ if (input === ':' && !isInputView) {
159
+ dispatch({ type: 'NAVIGATE', view: 'command-palette' });
160
+ return true;
161
+ }
162
+ if (input === 'q' && !isInputView) {
149
163
  process.exit(0);
150
164
  return true;
151
165
  }
152
- if (input === 'x' && state.currentView !== 'secret-edit') {
166
+ if (input === 'x' && !isInputView) {
153
167
  dispatch({ type: 'TOGGLE_REDACT' });
154
168
  return true;
155
169
  }
156
170
  // U → apply pending update. Only fires when one is actually available.
157
- if ((input === 'U' || input === 'u') && state.currentView !== 'secret-edit') {
171
+ if ((input === 'U' || input === 'u') && !isInputView) {
158
172
  const info = updateInfoRef.current;
159
173
  if (info?.available && !updateInProgressRef.current) {
160
174
  setUpdateInProgress(true);
161
175
  applyUpdate().then(result => {
162
176
  setUpdateInProgress(false);
163
177
  if (result.ok) {
164
- setUpdateInfo({ available: false, behind: 0, latestSubject: '', branch: info.branch });
178
+ setUpdateInfo({
179
+ available: false, behind: 0, latestSubject: '',
180
+ branch: info.branch, remoteBranch: info.remoteBranch, channel: info.channel,
181
+ });
165
182
  }
166
183
  // Result reported via UpdateBanner below.
167
184
  App.__lastUpdateOutput = result.output;
@@ -172,7 +189,7 @@ export function App() {
172
189
  return true;
173
190
  }
174
191
  }
175
- if (key.tab) {
192
+ if (key.tab && state.currentView !== 'command-palette') {
176
193
  const topViews = ['dashboard', 'health', 'secrets', 'logs-multi'];
177
194
  const base = topViews.includes(state.currentView)
178
195
  ? state.currentView
@@ -180,7 +197,7 @@ export function App() {
180
197
  dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
181
198
  return true;
182
199
  }
183
- if (key.escape && state.previousView) {
200
+ if (key.escape && state.previousView && state.currentView !== 'command-palette') {
184
201
  dispatch({ type: 'GO_BACK' });
185
202
  return true;
186
203
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
5
+ import { ViewRouter } from './router.js';
6
+ import { AppStateContext, AppDispatchContext, initialState } from './state.js';
7
+ describe('command palette routing', () => {
8
+ it('renders the command palette for the command-palette view', async () => {
9
+ const { lastFrame } = render(_jsx(InputDispatcher, { globalHandler: () => false, children: _jsx(AppStateContext.Provider, { value: { ...initialState, currentView: 'command-palette' }, children: _jsx(AppDispatchContext.Provider, { value: () => { }, children: _jsx(ViewRouter, {}) }) }) }));
10
+ await new Promise(r => setTimeout(r, 30));
11
+ expect(lastFrame() ?? '').toContain('Command palette');
12
+ });
13
+ });
@@ -21,9 +21,9 @@ describe('SignalsGrid', () => {
21
21
  expect(frame).toContain('CI');
22
22
  });
23
23
  it('renders a row with repo name when signals present', () => {
24
- const rows = [{ repo: 'abmanandvan', signals: [mkSignal('git-clean', 'ok')] }];
24
+ const rows = [{ repo: 'movers-co', signals: [mkSignal('git-clean', 'ok')] }];
25
25
  const { lastFrame } = render(_jsx(SignalsGrid, { rows: rows, selectedIndex: 0, kinds: ['git-clean'] }));
26
- expect(lastFrame()).toContain('abmanandvan');
26
+ expect(lastFrame()).toContain('movers-co');
27
27
  });
28
28
  it('shows empty-state message with no repos', () => {
29
29
  const { lastFrame } = render(_jsx(SignalsGrid, { rows: [], selectedIndex: 0, kinds: ['git-clean'] }));
@@ -69,7 +69,7 @@ function buildPlan(draft) {
69
69
  export function ScaffoldTab() {
70
70
  const [draft, setDraft] = useState({
71
71
  name: '',
72
- composePath: '/home/matt/',
72
+ composePath: '/home/operator/',
73
73
  port: '3000',
74
74
  domain: '',
75
75
  usesSharedDb: true,
@@ -0,0 +1 @@
1
+ export {};