@matthesketh/fleet 1.1.0 → 1.6.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 (217) hide show
  1. package/README.md +183 -251
  2. package/dist/adapters/detector/index.d.ts +8 -0
  3. package/dist/adapters/detector/index.js +54 -0
  4. package/dist/adapters/notifier/index.d.ts +2 -0
  5. package/dist/adapters/notifier/index.js +2 -0
  6. package/dist/adapters/notifier/stdout.d.ts +2 -0
  7. package/dist/adapters/notifier/stdout.js +8 -0
  8. package/dist/adapters/notifier/webhook.d.ts +9 -0
  9. package/dist/adapters/notifier/webhook.js +38 -0
  10. package/dist/adapters/runner/claude-cli.d.ts +7 -0
  11. package/dist/adapters/runner/claude-cli.js +231 -0
  12. package/dist/adapters/runner/mcp-call.d.ts +8 -0
  13. package/dist/adapters/runner/mcp-call.js +82 -0
  14. package/dist/adapters/runner/shell.d.ts +2 -0
  15. package/dist/adapters/runner/shell.js +103 -0
  16. package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
  17. package/dist/adapters/scheduler/systemd-timer.js +149 -0
  18. package/dist/adapters/signals/ci-status.d.ts +2 -0
  19. package/dist/adapters/signals/ci-status.js +79 -0
  20. package/dist/adapters/signals/container-up.d.ts +5 -0
  21. package/dist/adapters/signals/container-up.js +54 -0
  22. package/dist/adapters/signals/git-clean.d.ts +2 -0
  23. package/dist/adapters/signals/git-clean.js +55 -0
  24. package/dist/adapters/signals/index.d.ts +6 -0
  25. package/dist/adapters/signals/index.js +7 -0
  26. package/dist/adapters/types.d.ts +52 -0
  27. package/dist/adapters/types.js +1 -0
  28. package/dist/cli.js +43 -2
  29. package/dist/commands/add.js +0 -6
  30. package/dist/commands/boot-start.d.ts +1 -0
  31. package/dist/commands/boot-start.js +51 -0
  32. package/dist/commands/deploy.js +13 -0
  33. package/dist/commands/deps.js +5 -0
  34. package/dist/commands/egress.d.ts +1 -0
  35. package/dist/commands/egress.js +106 -0
  36. package/dist/commands/freeze.d.ts +4 -0
  37. package/dist/commands/freeze.js +64 -0
  38. package/dist/commands/logs.d.ts +1 -1
  39. package/dist/commands/logs.js +237 -8
  40. package/dist/commands/patch-systemd.d.ts +1 -0
  41. package/dist/commands/patch-systemd.js +126 -0
  42. package/dist/commands/rollback.d.ts +1 -0
  43. package/dist/commands/rollback.js +58 -0
  44. package/dist/commands/routine-run.d.ts +1 -0
  45. package/dist/commands/routine-run.js +122 -0
  46. package/dist/commands/routines.d.ts +1 -0
  47. package/dist/commands/routines.js +25 -0
  48. package/dist/commands/secrets.js +449 -16
  49. package/dist/commands/status.js +7 -3
  50. package/dist/commands/watchdog.d.ts +1 -1
  51. package/dist/commands/watchdog.js +16 -40
  52. package/dist/core/boot-refresh.d.ts +57 -0
  53. package/dist/core/boot-refresh.js +116 -0
  54. package/dist/core/deps/actors/pr-creator.js +11 -9
  55. package/dist/core/deps/collectors/docker-running.js +2 -2
  56. package/dist/core/deps/collectors/github-pr.js +5 -2
  57. package/dist/core/deps/collectors/npm.js +10 -5
  58. package/dist/core/deps/collectors/vulnerability.js +10 -6
  59. package/dist/core/deps/reporters/motd.js +1 -1
  60. package/dist/core/deps/reporters/telegram.js +2 -29
  61. package/dist/core/docker.js +45 -15
  62. package/dist/core/egress.d.ts +41 -0
  63. package/dist/core/egress.js +161 -0
  64. package/dist/core/exec.d.ts +7 -1
  65. package/dist/core/exec.js +25 -17
  66. package/dist/core/git.d.ts +1 -0
  67. package/dist/core/git.js +36 -23
  68. package/dist/core/github.js +27 -8
  69. package/dist/core/health.d.ts +3 -0
  70. package/dist/core/health.js +15 -3
  71. package/dist/core/logs-multi.d.ts +73 -0
  72. package/dist/core/logs-multi.js +163 -0
  73. package/dist/core/logs-policy.d.ts +55 -0
  74. package/dist/core/logs-policy.js +148 -0
  75. package/dist/core/nginx.js +8 -4
  76. package/dist/core/notify.d.ts +15 -0
  77. package/dist/core/notify.js +55 -0
  78. package/dist/core/registry.d.ts +25 -0
  79. package/dist/core/registry.js +57 -10
  80. package/dist/core/routines/cost-queries.d.ts +24 -0
  81. package/dist/core/routines/cost-queries.js +65 -0
  82. package/dist/core/routines/db.d.ts +9 -0
  83. package/dist/core/routines/db.js +126 -0
  84. package/dist/core/routines/defaults.d.ts +2 -0
  85. package/dist/core/routines/defaults.js +72 -0
  86. package/dist/core/routines/engine.d.ts +59 -0
  87. package/dist/core/routines/engine.js +175 -0
  88. package/dist/core/routines/incidents.d.ts +13 -0
  89. package/dist/core/routines/incidents.js +35 -0
  90. package/dist/core/routines/schema.d.ts +418 -0
  91. package/dist/core/routines/schema.js +113 -0
  92. package/dist/core/routines/signals-collector.d.ts +35 -0
  93. package/dist/core/routines/signals-collector.js +114 -0
  94. package/dist/core/routines/store.d.ts +316 -0
  95. package/dist/core/routines/store.js +99 -0
  96. package/dist/core/routines/test-utils.d.ts +2 -0
  97. package/dist/core/routines/test-utils.js +13 -0
  98. package/dist/core/secrets-audit.d.ts +21 -0
  99. package/dist/core/secrets-audit.js +60 -0
  100. package/dist/core/secrets-metadata.d.ts +39 -0
  101. package/dist/core/secrets-metadata.js +82 -0
  102. package/dist/core/secrets-motd.d.ts +20 -0
  103. package/dist/core/secrets-motd.js +72 -0
  104. package/dist/core/secrets-ops.d.ts +3 -1
  105. package/dist/core/secrets-ops.js +78 -13
  106. package/dist/core/secrets-providers.d.ts +50 -0
  107. package/dist/core/secrets-providers.js +291 -0
  108. package/dist/core/secrets-rotation.d.ts +52 -0
  109. package/dist/core/secrets-rotation.js +165 -0
  110. package/dist/core/secrets-snapshots.d.ts +26 -0
  111. package/dist/core/secrets-snapshots.js +95 -0
  112. package/dist/core/secrets-validate.js +2 -1
  113. package/dist/core/secrets.d.ts +12 -1
  114. package/dist/core/secrets.js +35 -24
  115. package/dist/core/self-update.d.ts +41 -0
  116. package/dist/core/self-update.js +73 -0
  117. package/dist/core/systemd.js +29 -12
  118. package/dist/core/telegram.d.ts +6 -0
  119. package/dist/core/telegram.js +32 -0
  120. package/dist/core/validate.d.ts +7 -0
  121. package/dist/core/validate.js +42 -0
  122. package/dist/index.js +0 -4
  123. package/dist/mcp/deps-tools.js +9 -1
  124. package/dist/mcp/git-tools.js +4 -4
  125. package/dist/mcp/server.js +193 -8
  126. package/dist/templates/systemd.js +3 -3
  127. package/dist/templates/unseal.js +5 -1
  128. package/dist/tui/components/Confirm.js +3 -4
  129. package/dist/tui/components/Header.js +37 -8
  130. package/dist/tui/components/KeyHint.js +14 -5
  131. package/dist/tui/exec-bridge.js +26 -12
  132. package/dist/tui/hooks/use-fleet-data.js +5 -2
  133. package/dist/tui/hooks/use-health.js +5 -2
  134. package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
  135. package/dist/tui/hooks/use-terminal-size.js +1 -0
  136. package/dist/tui/router.js +133 -8
  137. package/dist/tui/routines/RoutinesApp.d.ts +8 -0
  138. package/dist/tui/routines/RoutinesApp.js +277 -0
  139. package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
  140. package/dist/tui/routines/components/AlertsPanel.js +22 -0
  141. package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
  142. package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
  143. package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
  144. package/dist/tui/routines/components/CommandPalette.js +21 -0
  145. package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
  146. package/dist/tui/routines/components/LiveRunPanel.js +107 -0
  147. package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
  148. package/dist/tui/routines/components/RoutineForm.js +254 -0
  149. package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
  150. package/dist/tui/routines/components/SignalsGrid.js +34 -0
  151. package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
  152. package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
  153. package/dist/tui/routines/format.d.ts +7 -0
  154. package/dist/tui/routines/format.js +51 -0
  155. package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
  156. package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
  157. package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
  158. package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
  159. package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
  160. package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
  161. package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
  162. package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
  163. package/dist/tui/routines/hooks/use-security.d.ts +33 -0
  164. package/dist/tui/routines/hooks/use-security.js +110 -0
  165. package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
  166. package/dist/tui/routines/hooks/use-signals.js +60 -0
  167. package/dist/tui/routines/runtime.d.ts +20 -0
  168. package/dist/tui/routines/runtime.js +40 -0
  169. package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
  170. package/dist/tui/routines/tabs/CostTab.js +24 -0
  171. package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
  172. package/dist/tui/routines/tabs/DashboardTab.js +10 -0
  173. package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
  174. package/dist/tui/routines/tabs/GitTab.js +39 -0
  175. package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
  176. package/dist/tui/routines/tabs/LogsTab.js +58 -0
  177. package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
  178. package/dist/tui/routines/tabs/OpsTab.js +34 -0
  179. package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
  180. package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
  181. package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
  182. package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
  183. package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
  184. package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
  185. package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
  186. package/dist/tui/routines/tabs/SecurityTab.js +31 -0
  187. package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
  188. package/dist/tui/routines/tabs/SettingsTab.js +61 -0
  189. package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
  190. package/dist/tui/routines/tabs/TimelineTab.js +26 -0
  191. package/dist/tui/state.js +16 -1
  192. package/dist/tui/tests/flicker.test.d.ts +1 -0
  193. package/dist/tui/tests/flicker.test.js +105 -0
  194. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  195. package/dist/tui/tests/keyboard-integration.test.js +120 -0
  196. package/dist/tui/tests/test-app.d.ts +4 -0
  197. package/dist/tui/tests/test-app.js +79 -0
  198. package/dist/tui/types.d.ts +14 -1
  199. package/dist/tui/views/AppDetail.js +40 -26
  200. package/dist/tui/views/Dashboard.js +34 -9
  201. package/dist/tui/views/HealthView.js +42 -12
  202. package/dist/tui/views/LogsView.js +38 -10
  203. package/dist/tui/views/MultiLogsView.d.ts +2 -0
  204. package/dist/tui/views/MultiLogsView.js +165 -0
  205. package/dist/tui/views/SecretEdit.js +18 -7
  206. package/dist/tui/views/SecretsView.js +55 -39
  207. package/dist/ui/prompt.d.ts +52 -0
  208. package/dist/ui/prompt.js +169 -0
  209. package/package.json +33 -5
  210. package/dist/commands/motd.d.ts +0 -1
  211. package/dist/commands/motd.js +0 -10
  212. package/dist/templates/motd.d.ts +0 -1
  213. package/dist/templates/motd.js +0 -7
  214. package/dist/tui/components/AppList.d.ts +0 -12
  215. package/dist/tui/components/AppList.js +0 -32
  216. package/dist/tui/hooks/use-keyboard.d.ts +0 -1
  217. package/dist/tui/hooks/use-keyboard.js +0 -44
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { formatRelative, truncate } from '../../../tui/routines/format.js';
5
+ import { useGitFleet } from '../../../tui/routines/hooks/use-git-fleet.js';
6
+ function prAgeColor(updatedAt) {
7
+ const ageDays = (Date.now() - new Date(updatedAt).getTime()) / 86_400_000;
8
+ if (ageDays >= 14)
9
+ return 'red';
10
+ if (ageDays >= 7)
11
+ return 'yellow';
12
+ return 'gray';
13
+ }
14
+ function reviewBadge(decision) {
15
+ if (decision === 'APPROVED')
16
+ return _jsx(Text, { color: "green", children: "approved" });
17
+ if (decision === 'CHANGES_REQUESTED')
18
+ return _jsx(Text, { color: "yellow", children: "changes" });
19
+ if (decision === 'REVIEW_REQUIRED')
20
+ return _jsx(Text, { color: "cyan", children: "review" });
21
+ return _jsx(Text, { color: "gray", children: "\u2014" });
22
+ }
23
+ function PrRow({ pr }) {
24
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(Text, { children: truncate(pr.repo, 18) }) }), _jsx(Box, { width: 8, children: _jsxs(Text, { color: pr.isDraft ? 'gray' : 'cyan', children: ["#", pr.number, pr.isDraft ? ' d' : ''] }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: truncate(pr.title, 55) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { color: "gray", children: pr.author }) }), _jsx(Box, { width: 12, children: reviewBadge(pr.reviewDecision) }), _jsx(Box, { width: 14, children: _jsx(Text, { color: prAgeColor(pr.updatedAt), children: formatRelative(pr.updatedAt) }) })] }));
25
+ }
26
+ function BranchRow({ bs }) {
27
+ const diverged = bs.ahead > 0 || bs.behind > 0;
28
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(Text, { children: truncate(bs.repo, 18) }) }), _jsx(Box, { width: 18, children: _jsx(Text, { children: bs.branch }) }), _jsx(Box, { width: 10, children: _jsxs(Text, { color: diverged ? 'yellow' : 'gray', children: ["\u2191", bs.ahead, " \u2193", bs.behind] }) }), _jsx(Box, { width: 16, children: _jsx(Text, { color: bs.clean ? 'green' : 'yellow', children: bs.clean ? 'clean' : `${bs.dirtyCount} dirty` }) }), _jsx(Box, { width: 14, children: _jsxs(Text, { color: bs.releasePending > 0 ? 'magenta' : 'gray', children: [bs.releasePending, " to release"] }) })] }));
29
+ }
30
+ export function GitTab({ apps }) {
31
+ const snap = useGitFleet(apps);
32
+ const prsToReview = snap.prs.filter(p => !p.isDraft && p.reviewDecision === 'REVIEW_REQUIRED');
33
+ const prsApproved = snap.prs.filter(p => p.reviewDecision === 'APPROVED');
34
+ const prsChanges = snap.prs.filter(p => p.reviewDecision === 'CHANGES_REQUESTED');
35
+ const prsDraft = snap.prs.filter(p => p.isDraft);
36
+ const stalePrs = snap.prs.filter(p => (Date.now() - new Date(p.updatedAt).getTime()) / 86_400_000 >= 7);
37
+ const releaseReady = snap.branchStates.filter(b => b.releasePending > 0);
38
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Git across the fleet" }), _jsxs(Text, { color: "gray", children: [apps.length, " repos"] }), _jsxs(Text, { color: "cyan", children: [snap.prs.length, " open PRs"] }), _jsxs(Text, { color: "yellow", children: [stalePrs.length, " stale"] }), _jsxs(Text, { color: "magenta", children: [releaseReady.length, " ready to release"] }), snap.loading && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " refreshing"] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Review queue (", prsToReview.length, ")"] }), prsToReview.length === 0 && _jsx(Text, { color: "gray", children: " nothing awaiting review" }), prsToReview.slice(0, 6).map(pr => _jsx(PrRow, { pr: pr }, `${pr.repo}-${pr.number}`)), prsApproved.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: "green", children: ["Approved (", prsApproved.length, ")"] }), prsApproved.slice(0, 4).map(pr => _jsx(PrRow, { pr: pr }, `${pr.repo}-${pr.number}`))] })), prsChanges.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Changes requested (", prsChanges.length, ")"] }), prsChanges.slice(0, 4).map(pr => _jsx(PrRow, { pr: pr }, `${pr.repo}-${pr.number}`))] })), prsDraft.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, color: "gray", children: ["Drafts (", prsDraft.length, ")"] }), prsDraft.slice(0, 4).map(pr => _jsx(PrRow, { pr: pr }, `${pr.repo}-${pr.number}`))] }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Branch state" }), snap.branchStates.length === 0 && _jsx(Text, { color: "gray", children: " no git repos detected" }), snap.branchStates.map(bs => _jsx(BranchRow, { bs: bs }, bs.repo))] }), releaseReady.length > 0 && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, children: [_jsx(Text, { bold: true, color: "magenta", children: "Release planner" }), releaseReady.map(bs => (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(Text, { children: truncate(bs.repo, 18) }) }), _jsxs(Text, { color: "magenta", children: [bs.releasePending, " commits on develop unshipped to main"] })] }, bs.repo)))] })), snap.errors.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "errors" }), snap.errors.map(e => _jsxs(Text, { color: "red", children: [" \u00B7 ", e.repo, ": ", e.message] }, e.repo))] }))] }));
39
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from '../../../core/registry.js';
3
+ export interface LogsTabProps {
4
+ apps: AppEntry[];
5
+ }
6
+ export declare function LogsTab({ apps }: LogsTabProps): React.JSX.Element;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import Spinner from 'ink-spinner';
5
+ import { LogViewer } from '@matthesketh/ink-log-viewer';
6
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
7
+ import { truncate } from '../../../tui/routines/format.js';
8
+ import { useLogsStream } from '../../../tui/routines/hooks/use-logs-stream.js';
9
+ export function LogsTab({ apps }) {
10
+ const services = useMemo(() => {
11
+ const list = apps
12
+ .filter(a => a.serviceName)
13
+ .map(a => ({ name: a.serviceName, displayName: a.displayName || a.name }));
14
+ return [{ name: 'docker-databases', displayName: 'docker-databases (shared)' }, ...list];
15
+ }, [apps]);
16
+ const [selectedIdx, setSelectedIdx] = useState(0);
17
+ const [source, setSource] = useState({ kind: 'none' });
18
+ const [filter, setFilter] = useState('');
19
+ const opts = source.kind === 'service'
20
+ ? { command: 'journalctl', args: ['-u', source.name, '-f', '-n', '200', '--no-pager'] }
21
+ : source.kind === 'container'
22
+ ? { command: 'docker', args: ['logs', '-f', '--tail', '200', source.containerId] }
23
+ : null;
24
+ const stream = useLogsStream(opts);
25
+ useRegisterHandler((input, key) => {
26
+ if (input === 'j' || key.downArrow) {
27
+ setSelectedIdx(i => Math.min(i + 1, services.length - 1));
28
+ return true;
29
+ }
30
+ if (input === 'k' || key.upArrow) {
31
+ setSelectedIdx(i => Math.max(i - 1, 0));
32
+ return true;
33
+ }
34
+ if (key.return && services[selectedIdx]) {
35
+ setSource({ kind: 'service', name: services[selectedIdx].name });
36
+ return true;
37
+ }
38
+ if (input === 'w') {
39
+ setFilter(f => f === 'warn' ? '' : 'warn');
40
+ return true;
41
+ }
42
+ if (input === 'x') {
43
+ setFilter(f => f === 'error' ? '' : 'error');
44
+ return true;
45
+ }
46
+ if (input === 'c') {
47
+ setFilter('');
48
+ return true;
49
+ }
50
+ if (key.escape && source.kind !== 'none') {
51
+ setSource({ kind: 'none' });
52
+ return true;
53
+ }
54
+ return false;
55
+ });
56
+ const filterPattern = filter ? filter : undefined;
57
+ return (_jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: 34, children: [_jsx(Text, { bold: true, children: "Services" }), services.length === 0 && _jsx(Text, { color: "gray", children: " no services" }), services.map((s, i) => (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: i === selectedIdx ? 'cyan' : undefined, children: i === selectedIdx ? '▶' : ' ' }) }), _jsx(Text, { color: source.kind === 'service' && source.name === s.name ? 'green' : undefined, bold: i === selectedIdx, children: truncate(s.displayName, 28) })] }, s.name))), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: "gray", children: "j/k pick \u00B7 Enter tail \u00B7 w warn \u00B7 x error \u00B7 c clear \u00B7 Esc stop" }) })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: stream.running ? 'cyan' : 'gray', paddingX: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: source.kind === 'none' ? 'pick a service' : `tail · ${source.kind === 'service' ? source.name : source.containerId}` }), stream.running && _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " live"] }), filter && _jsxs(Text, { color: "yellow", children: ["filter: ", filter] }), _jsxs(Text, { color: "gray", children: [stream.lines.length, " lines"] })] }), stream.error && _jsxs(Text, { color: "red", children: ["\u2716 ", stream.error] }), stream.lines.length === 0 && !stream.error && source.kind !== 'none' && (_jsx(Text, { color: "gray", children: " (waiting for output\u2026)" })), stream.lines.length > 0 && (_jsx(LogViewer, { lines: stream.lines, height: 20, autoScroll: true, filter: filterPattern, showLevel: true }))] })] }));
58
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from '../../../core/registry.js';
3
+ export interface OpsTabProps {
4
+ apps: AppEntry[];
5
+ }
6
+ export declare function OpsTab({ apps }: OpsTabProps): React.JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { formatRelative, truncate } from '../../../tui/routines/format.js';
5
+ import { useOpsFleet } from '../../../tui/routines/hooks/use-ops-fleet.js';
6
+ function serviceColor(repo) {
7
+ if (!repo.service)
8
+ return 'gray';
9
+ if (!repo.service.active)
10
+ return 'red';
11
+ if (repo.totalContainers === 0)
12
+ return 'yellow';
13
+ return repo.runningContainers === repo.totalContainers ? 'green' : 'yellow';
14
+ }
15
+ function RepoOpsRow({ repo }) {
16
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { children: truncate(repo.name, 20) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { color: serviceColor(repo), children: repo.service ? (repo.service.active ? 'active' : repo.service.state) : '—' }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: repo.service?.enabled ? 'green' : 'gray', children: repo.service?.enabled ? 'enabled' : '—' }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: repo.totalContainers > 0
17
+ ? `${repo.runningContainers}/${repo.totalContainers}`
18
+ : _jsx(Text, { color: "gray", children: "\u2014" }) }) })] }));
19
+ }
20
+ function diskColor(pct) {
21
+ if (pct == null)
22
+ return 'gray';
23
+ if (pct >= 90)
24
+ return 'red';
25
+ if (pct >= 75)
26
+ return 'yellow';
27
+ return 'green';
28
+ }
29
+ export function OpsTab({ apps }) {
30
+ const snap = useOpsFleet(apps);
31
+ const downServices = snap.repos.filter(r => r.service && !r.service.active).length;
32
+ const stoppedContainers = snap.repos.filter(r => r.totalContainers > 0 && r.runningContainers < r.totalContainers).length;
33
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Ops overview" }), _jsxs(Text, { color: downServices > 0 ? 'red' : 'green', children: [downServices, " services down"] }), _jsxs(Text, { color: stoppedContainers > 0 ? 'yellow' : 'green', children: [stoppedContainers, " repos with stopped containers"] }), snap.loading ? (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " refreshing"] })) : (_jsxs(Text, { color: "gray", children: ["updated ", formatRelative(new Date(snap.refreshedAt).toISOString())] }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Infrastructure" }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " docker-databases" }) }), _jsx(Text, { color: snap.dockerDatabasesActive ? 'green' : 'red', children: snap.dockerDatabasesActive ? 'active' : snap.dockerDatabasesActive === false ? 'down' : 'unknown' })] }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " nginx" }) }), _jsx(Text, { color: snap.nginxOk ? 'green' : snap.nginxOk === false ? 'red' : 'gray', children: snap.nginxOk ? 'config valid' : snap.nginxOk === false ? 'config BROKEN' : '—' }), snap.nginxSites != null && _jsxs(Text, { color: "gray", children: [" \u00B7 ", snap.nginxSites, " sites"] })] }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " /home disk" }) }), _jsx(Text, { color: diskColor(snap.diskPercent), children: snap.diskPercent != null ? `${snap.diskPercent}% used` : '—' })] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { bold: true, children: "APP" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { bold: true, children: "SERVICE" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { bold: true, children: "AUTOSTART" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { bold: true, children: "CONTAINERS" }) })] }), snap.repos.length === 0 && _jsx(Text, { color: "gray", children: " no apps" }), snap.repos.map(r => _jsx(RepoOpsRow, { repo: r }, r.name))] }), _jsx(Text, { color: "gray", children: "fleet-native actions live outside this tab: `fleet restart` / `fleet deploy` / `fleet nginx`" })] }));
34
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from '../../../core/registry.js';
3
+ export interface RepoDetailViewProps {
4
+ app: AppEntry;
5
+ }
6
+ export declare function RepoDetailView({ app }: RepoDetailViewProps): React.JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { formatRelative, truncate } from '../../../tui/routines/format.js';
5
+ import { useRepoDetail } from '../../../tui/routines/hooks/use-repo-detail.js';
6
+ function StatValue({ label, value, color }) {
7
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 14, children: _jsx(Text, { color: "gray", children: label }) }), _jsx(Text, { color: color, children: value })] }));
8
+ }
9
+ export function RepoDetailView({ app }) {
10
+ const snap = useRepoDetail(app);
11
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: app.name }), _jsx(Text, { color: "gray", children: app.type }), app.domains.length > 0 && _jsx(Text, { color: "yellow", children: app.domains.join(', ') }), snap.loading ? (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " refreshing"] })) : (_jsxs(Text, { color: "gray", children: ["updated ", formatRelative(new Date(snap.refreshedAt).toISOString())] }))] }), snap.error && _jsxs(Text, { color: "red", children: ["\u2716 ", snap.error] }), _jsxs(Box, { flexDirection: "row", gap: 4, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: 48, children: [_jsx(Text, { bold: true, children: "Git" }), snap.git === null || !snap.git.initialised ? (_jsx(Text, { color: "gray", children: " not a git repo" })) : (_jsxs(_Fragment, { children: [_jsx(StatValue, { label: "branch", value: snap.git.branch || '—' }), _jsx(StatValue, { label: "ahead/behind", value: `${snap.git.ahead} / ${snap.git.behind}`, color: snap.git.ahead > 0 || snap.git.behind > 0 ? 'yellow' : 'green' }), _jsx(StatValue, { label: "working tree", value: snap.git.clean ? 'clean' : `${snap.git.modified + snap.git.staged + snap.git.untracked} dirty`, color: snap.git.clean ? 'green' : 'yellow' }), _jsx(StatValue, { label: "remote", value: truncate(snap.git.remoteUrl || '—', 32) }), snap.lastCommit && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Last commit" }), _jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [" ", snap.lastCommit.hash, " "] }), _jsx(Text, { children: truncate(snap.lastCommit.subject, 28) })] }), _jsx(Box, { children: _jsxs(Text, { color: "gray", children: [" ", snap.lastCommit.author, " \u00B7 ", formatRelative(snap.lastCommit.date)] }) })] }))] }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: 48, children: [_jsx(Text, { bold: true, children: "Service" }), snap.service === null ? (_jsx(Text, { color: "gray", children: " no systemd unit" })) : (_jsxs(_Fragment, { children: [_jsx(StatValue, { label: "unit", value: snap.service.name }), _jsx(StatValue, { label: "active", value: snap.service.active ? 'active' : snap.service.state, color: snap.service.active ? 'green' : 'red' }), _jsx(StatValue, { label: "enabled", value: snap.service.enabled ? 'yes' : 'no', color: snap.service.enabled ? 'green' : 'yellow' })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: "Containers" }) }), snap.runningContainers === null ? (_jsx(Text, { color: "gray", children: " docker unavailable" })) : snap.totalContainers === 0 ? (_jsx(Text, { color: "yellow", children: " no containers for project" })) : (_jsx(StatValue, { label: "state", value: `${snap.runningContainers}/${snap.totalContainers} running`, color: snap.runningContainers === snap.totalContainers ? 'green' : snap.runningContainers > 0 ? 'yellow' : 'red' }))] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Open PRs ", snap.openPrs ? `(${snap.openPrs.length})` : ''] }), snap.openPrs === null ? (_jsx(Text, { color: "gray", children: " gh unavailable or not a repo" })) : snap.openPrs.length === 0 ? (_jsx(Text, { color: "green", children: " no open PRs" })) : (snap.openPrs.slice(0, 8).map(pr => (_jsxs(Box, { children: [_jsx(Box, { width: 6, children: _jsxs(Text, { color: "cyan", children: ["#", pr.number] }) }), pr.isDraft && _jsx(Box, { width: 8, children: _jsx(Text, { color: "gray", children: "draft" }) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: truncate(pr.title, 60) }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: "gray", children: pr.author }) }), _jsx(Text, { color: "gray", children: formatRelative(pr.updatedAt) })] }, pr.number))))] }), _jsx(Text, { color: "gray", children: "actions: r restart \u00B7 s shell \u00B7 l logs \u00B7 a run nightly-audit \u00B7 Esc back" })] }));
12
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { RoutineEngine } from '../../../core/routines/engine.js';
3
+ import type { Routine } from '../../../core/routines/schema.js';
4
+ export interface RoutinesTabProps {
5
+ engine: RoutineEngine;
6
+ routines: Routine[];
7
+ selectedIndex: number;
8
+ detailOpen: boolean;
9
+ }
10
+ export declare function RoutinesTab({ engine, routines, selectedIndex, detailOpen, }: RoutinesTabProps): React.JSX.Element;
@@ -0,0 +1,58 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { formatDuration, formatRelative, formatUsd, truncate } from '../format.js';
5
+ function summarise(engine, routine) {
6
+ const recent = engine.recentRuns(routine.id, 10);
7
+ const lastRun = recent[0] ?? null;
8
+ let streakSuccess = 0;
9
+ for (const r of recent) {
10
+ if (r.status === 'ok')
11
+ streakSuccess++;
12
+ else
13
+ break;
14
+ }
15
+ const costAgg = engine.costSinceDays(routine.id, 30);
16
+ return { routine, recent, lastRun, streakSuccess, totalUsd: costAgg.usd };
17
+ }
18
+ function statusColor(status) {
19
+ switch (status) {
20
+ case 'ok': return 'green';
21
+ case 'failed': return 'red';
22
+ case 'timeout': return 'yellow';
23
+ case 'aborted': return 'gray';
24
+ case 'running': return 'cyan';
25
+ default: return 'gray';
26
+ }
27
+ }
28
+ function ScheduleBadge({ routine }) {
29
+ if (routine.schedule.kind === 'manual') {
30
+ return _jsx(Text, { color: "gray", children: "manual" });
31
+ }
32
+ return _jsx(Text, { color: "cyan", children: routine.schedule.onCalendar });
33
+ }
34
+ function RoutineListRow({ row, selected, }) {
35
+ const { routine, lastRun, streakSuccess } = row;
36
+ const targetCount = routine.targets.length || (routine.perTarget ? 0 : 1);
37
+ const targetLabel = routine.perTarget
38
+ ? routine.targets.length > 0 ? `${routine.targets.length}×` : 'all×'
39
+ : 'singleton';
40
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: selected ? 'cyan' : undefined, children: selected ? '▶' : ' ' }) }), _jsx(Box, { width: 2, children: routine.enabled ? _jsx(Text, { color: "green", children: "\u25CF" }) : _jsx(Text, { color: "gray", children: "\u25CB" }) }), _jsx(Box, { width: 22, children: _jsx(Text, { bold: selected, children: truncate(routine.id, 20) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: _jsx(Text, { color: statusColor(lastRun?.status ?? null), children: lastRun?.status ?? '—' }) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { children: streakSuccess > 0 ? _jsxs(Text, { color: "green", children: ["\u00D7", streakSuccess] }) : _jsx(Text, { color: "gray", children: "\u2014" }) }) }), _jsx(Box, { width: 16, children: _jsx(Text, { color: "gray", children: formatRelative(lastRun?.startedAt ?? null) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: "gray", children: formatDuration(lastRun?.durationMs ?? null) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: "gray", children: formatUsd(row.totalUsd) }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: "gray", children: targetLabel }) }), _jsx(ScheduleBadge, { routine: routine })] }));
41
+ }
42
+ function RoutineListHeader() {
43
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { children: " " }) }), _jsx(Box, { width: 2, children: _jsx(Text, { bold: true, children: "ON" }) }), _jsx(Box, { width: 22, children: _jsx(Text, { bold: true, children: "ID" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { bold: true, children: "LAST" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "STREAK" }) }), _jsx(Box, { width: 16, children: _jsx(Text, { bold: true, children: "WHEN" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "DUR" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "30d $" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { bold: true, children: "TARGETS" }) }), _jsx(Text, { bold: true, children: "SCHEDULE" })] }));
44
+ }
45
+ function RecentRunsPanel({ runs }) {
46
+ if (runs.length === 0)
47
+ return _jsx(Text, { color: "gray", children: " no runs yet" });
48
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { bold: true, children: "WHEN" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { bold: true, children: "STATUS" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "DUR" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "EXIT" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { bold: true, children: "USD" }) }), _jsx(Box, { width: 12, children: _jsx(Text, { bold: true, children: "TOKENS" }) }), _jsx(Text, { bold: true, children: "ERROR" })] }), runs.map(r => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { color: "gray", children: formatRelative(r.startedAt) }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: statusColor(r.status), children: r.status }) }), _jsx(Box, { width: 10, children: _jsx(Text, { children: formatDuration(r.durationMs) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { children: r.exitCode ?? '—' }) }), _jsx(Box, { width: 10, children: _jsx(Text, { children: formatUsd(r.usd) }) }), _jsx(Box, { width: 12, children: _jsx(Text, { children: r.inputTokens != null ? `${(r.inputTokens + (r.outputTokens ?? 0)).toLocaleString()}` : '—' }) }), _jsx(Text, { color: "red", children: truncate(r.error ?? '', 40) })] }, r.runId)))] }));
49
+ }
50
+ function RoutineDetail({ row }) {
51
+ const { routine, recent } = row;
52
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, gap: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: routine.id }), _jsx(Text, { children: routine.name }), routine.description && _jsx(Text, { color: "gray", children: routine.description })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Schedule" }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " kind: " }), _jsx(Text, { children: routine.schedule.kind })] }), routine.schedule.kind === 'calendar' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " when: " }), _jsx(Text, { children: routine.schedule.onCalendar })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " jitter: " }), _jsxs(Text, { children: [routine.schedule.randomizedDelaySec, "s"] })] })] }))] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Task" }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " runner: " }), _jsx(Text, { children: routine.task.kind })] }), routine.task.kind === 'claude-cli' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " tokens: " }), _jsx(Text, { children: routine.task.tokenCap.toLocaleString() })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " max USD: " }), _jsx(Text, { children: formatUsd(routine.task.maxUsd) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " timeout: " }), _jsx(Text, { children: formatDuration(routine.task.wallClockMs) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " prompt: " }), _jsx(Text, { children: truncate(routine.task.prompt, 80) })] })] })), routine.task.kind === 'shell' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " argv: " }), _jsx(Text, { children: truncate(routine.task.argv.join(' '), 80) })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " timeout: " }), _jsx(Text, { children: formatDuration(routine.task.wallClockMs) })] })] })), routine.task.kind === 'mcp-call' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " tool: " }), _jsx(Text, { children: routine.task.tool })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: " timeout: " }), _jsx(Text, { children: formatDuration(routine.task.wallClockMs) })] })] }))] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent runs" }), _jsx(RecentRunsPanel, { runs: recent })] })] }));
53
+ }
54
+ export function RoutinesTab({ engine, routines, selectedIndex, detailOpen, }) {
55
+ const rows = useMemo(() => routines.map(r => summarise(engine, r)), [engine, routines]);
56
+ const selected = rows[selectedIndex];
57
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { bold: true, children: ["Routines (", rows.length, ")"] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(RoutineListHeader, {}), rows.length === 0 && _jsx(Text, { color: "gray", children: " no routines yet" }), rows.map((row, i) => (_jsx(RoutineListRow, { row: row, selected: i === selectedIndex }, row.routine.id)))] }), detailOpen && selected && _jsx(RoutineDetail, { row: selected }), !detailOpen && selected && (_jsx(Text, { color: "gray", children: "press Enter for detail \u00B7 r run now \u00B7 e toggle enabled" }))] }));
58
+ }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function ScaffoldTab(): React.JSX.Element;
@@ -0,0 +1,127 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
6
+ const FIELDS = [
7
+ { id: 'name', label: 'app name (kebab-case)' },
8
+ { id: 'composePath', label: 'compose path' },
9
+ { id: 'port', label: 'public port' },
10
+ { id: 'domain', label: 'primary domain' },
11
+ { id: 'usesSharedDb', label: 'joins databases network?', toggle: true },
12
+ { id: 'nonRootUser', label: 'Dockerfile USER non-root?', toggle: true },
13
+ ];
14
+ function buildPlan(draft) {
15
+ const errors = [];
16
+ if (!/^[a-z][a-z0-9-]{1,62}$/.test(draft.name))
17
+ errors.push('app name: lowercase + dashes only');
18
+ if (!draft.composePath.startsWith('/'))
19
+ errors.push('compose path: absolute');
20
+ if (!/^\d{2,5}$/.test(draft.port))
21
+ errors.push('port: 2–5 digit integer');
22
+ if (errors.length > 0)
23
+ return { ok: false, errors, commands: [] };
24
+ const unit = `/etc/systemd/system/${draft.name}.service`;
25
+ const commands = [
26
+ `# 1. Scaffold the systemd unit`,
27
+ `sudo tee ${unit} > /dev/null <<'UNIT'`,
28
+ `[Unit]`,
29
+ `Description=${draft.name} Docker Service`,
30
+ `Requires=docker.service`,
31
+ `After=docker.service network-online.target${draft.usesSharedDb ? ' docker-databases.service' : ''}`,
32
+ `Wants=network-online.target`,
33
+ draft.usesSharedDb ? `Requires=docker-databases.service` : '',
34
+ ``,
35
+ `[Service]`,
36
+ `Type=oneshot`,
37
+ `RemainAfterExit=yes`,
38
+ `WorkingDirectory=${draft.composePath}`,
39
+ `ExecStartPre=-/usr/bin/docker compose down`,
40
+ `ExecStart=/usr/bin/docker compose up -d --force-recreate`,
41
+ `ExecStop=/usr/bin/docker compose down`,
42
+ `ExecReload=/usr/bin/docker compose restart`,
43
+ `TimeoutStartSec=300`,
44
+ `Restart=on-failure`,
45
+ `RestartSec=10`,
46
+ ``,
47
+ `[Install]`,
48
+ `WantedBy=multi-user.target`,
49
+ `UNIT`,
50
+ ``,
51
+ `# 2. Daemon-reload + enable`,
52
+ `sudo systemctl daemon-reload`,
53
+ `sudo systemctl enable --now ${draft.name}`,
54
+ ``,
55
+ `# 3. Register with fleet (adds to registry.json + detects compose/ports)`,
56
+ `fleet add ${draft.composePath}`,
57
+ ``,
58
+ `# 4. Nginx reverse proxy for ${draft.domain}`,
59
+ `fleet nginx add ${draft.domain} --port ${draft.port} --type spa`,
60
+ ];
61
+ if (draft.usesSharedDb) {
62
+ commands.push('', `# 5. Ensure databases network is reachable`, `docker network inspect databases > /dev/null || docker network create databases`);
63
+ }
64
+ if (draft.nonRootUser) {
65
+ commands.push('', `# 6. Guardian whitelist check (/runc must be whitelisted for non-root containers)`, `grep -q '^/runc$' /etc/guardian/whitelist || echo '/runc' | sudo tee -a /etc/guardian/whitelist`, `sudo systemctl reload guardiand || true`);
66
+ }
67
+ return { ok: true, errors: [], commands: commands.filter(c => c !== undefined) };
68
+ }
69
+ export function ScaffoldTab() {
70
+ const [draft, setDraft] = useState({
71
+ name: '',
72
+ composePath: '/home/matt/',
73
+ port: '3000',
74
+ domain: '',
75
+ usesSharedDb: true,
76
+ nonRootUser: false,
77
+ });
78
+ const [cursor, setCursor] = useState(0);
79
+ const [editing, setEditing] = useState(false);
80
+ const [textValue, setTextValue] = useState(() => String(draft[FIELDS[0].id]));
81
+ const [plan, setPlan] = useState(null);
82
+ const currentField = FIELDS[cursor];
83
+ useRegisterHandler((input, key) => {
84
+ if (editing && !currentField.toggle)
85
+ return false;
86
+ if (input === 'g') {
87
+ setPlan(buildPlan(draft));
88
+ return true;
89
+ }
90
+ if (currentField.toggle && (input === ' ' || key.return)) {
91
+ setDraft(d => ({ ...d, [currentField.id]: !d[currentField.id] }));
92
+ return true;
93
+ }
94
+ if (input === 'j' || key.downArrow) {
95
+ const next = Math.min(cursor + 1, FIELDS.length - 1);
96
+ setCursor(next);
97
+ if (!FIELDS[next].toggle)
98
+ setTextValue(String(draft[FIELDS[next].id] ?? ''));
99
+ setEditing(false);
100
+ return true;
101
+ }
102
+ if (input === 'k' || key.upArrow) {
103
+ const next = Math.max(cursor - 1, 0);
104
+ setCursor(next);
105
+ if (!FIELDS[next].toggle)
106
+ setTextValue(String(draft[FIELDS[next].id] ?? ''));
107
+ setEditing(false);
108
+ return true;
109
+ }
110
+ if (input === 'e' && !currentField.toggle) {
111
+ setEditing(true);
112
+ return true;
113
+ }
114
+ return false;
115
+ });
116
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "New app scaffold" }), _jsx(Text, { color: "gray", children: "answer the prompts, press `g` to generate the deployment commands. Nothing is applied automatically." }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: FIELDS.map((f, i) => {
117
+ const selected = i === cursor;
118
+ const value = draft[f.id];
119
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 2, children: _jsx(Text, { color: selected ? 'cyan' : undefined, children: selected ? '▶' : ' ' }) }), _jsx(Box, { width: 32, children: _jsx(Text, { color: selected ? 'cyan' : 'gray', children: f.label }) }), f.toggle ? (_jsx(Text, { color: value ? 'green' : 'gray', children: value ? 'yes' : 'no' })) : editing && selected ? (_jsx(TextInput, { value: textValue, onChange: setTextValue, onSubmit: () => {
120
+ setDraft(prev => ({ ...prev, [f.id]: textValue }));
121
+ setEditing(false);
122
+ } })) : (_jsx(Text, { children: String(value) }))] }, f.id));
123
+ }) }), _jsx(Text, { color: "gray", children: "j/k move \u00B7 e edit \u00B7 space toggle \u00B7 g generate plan" }), plan && !plan.ok && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { color: "red", bold: true, children: "errors" }), plan.errors.map((e, i) => _jsxs(Text, { color: "red", children: [" \u00B7 ", e] }, i))] })), plan?.ok && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "deployment commands" }), plan.commands.map((line, i) => {
124
+ const isComment = line.startsWith('#');
125
+ return (_jsx(Text, { color: isComment ? 'gray' : undefined, children: line }, i));
126
+ })] }))] }));
127
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { AppEntry } from '../../../core/registry.js';
3
+ export interface SecurityTabProps {
4
+ apps: AppEntry[];
5
+ }
6
+ export declare function SecurityTab({ apps }: SecurityTabProps): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { formatRelative, truncate } from '../../../tui/routines/format.js';
5
+ import { useSecurity } from '../../../tui/routines/hooks/use-security.js';
6
+ function certColor(c) {
7
+ if (c.daysUntil == null)
8
+ return 'gray';
9
+ if (c.daysUntil <= 7)
10
+ return 'red';
11
+ if (c.daysUntil <= 30)
12
+ return 'yellow';
13
+ return 'green';
14
+ }
15
+ function ageColor(days) {
16
+ if (days == null)
17
+ return 'gray';
18
+ if (days >= 180)
19
+ return 'red';
20
+ if (days >= 90)
21
+ return 'yellow';
22
+ return 'green';
23
+ }
24
+ export function SecurityTab({ apps }) {
25
+ const snap = useSecurity(apps);
26
+ const expiringSoon = snap.certs.filter(c => c.daysUntil != null && c.daysUntil <= 30).length;
27
+ const rotationsDue = snap.secretAges.filter(s => s.ageDays != null && s.ageDays >= 90).length;
28
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Security overview" }), _jsxs(Text, { color: expiringSoon > 0 ? 'yellow' : 'green', children: [expiringSoon, " certs expiring \u226430d"] }), _jsxs(Text, { color: rotationsDue > 0 ? 'yellow' : 'green', children: [rotationsDue, " secrets overdue for rotation"] }), snap.loading ? (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " refreshing"] })) : (_jsxs(Text, { color: "gray", children: ["updated ", formatRelative(new Date(snap.refreshedAt).toISOString())] }))] }), _jsxs(Box, { flexDirection: "row", gap: 4, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: 48, children: [_jsx(Text, { bold: true, children: "Guardian" }), !snap.guardian ? (_jsx(Text, { color: "gray", children: " \u2014" })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " binary" }) }), _jsx(Text, { color: snap.guardian.binaryInstalled ? 'green' : 'red', children: snap.guardian.binaryInstalled ? 'installed' : 'missing' })] }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " whitelist" }) }), _jsx(Text, { color: snap.guardian.whitelistExists ? 'green' : 'red', children: snap.guardian.whitelistExists
29
+ ? `${snap.guardian.whitelistLines ?? '?'} entries`
30
+ : 'missing' })] }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " /runc whitelisted" }) }), _jsx(Text, { color: snap.guardian.runcWhitelisted ? 'green' : 'red', children: snap.guardian.runcWhitelisted ? 'yes' : snap.guardian.runcWhitelisted === false ? 'NO — containers at risk' : '—' })] })] }))] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, width: 48, children: [_jsx(Text, { bold: true, children: "SSH agent" }), !snap.ssh ? (_jsx(Text, { color: "gray", children: " \u2014" })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " socket" }) }), _jsx(Text, { color: snap.ssh.socketExists ? 'green' : 'red', children: snap.ssh.socketExists ? '/tmp/fleet-ssh-agent.sock' : 'not present' })] }), _jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " key loaded" }) }), _jsx(Text, { color: snap.ssh.keyLoaded ? 'green' : 'red', children: snap.ssh.keyLoaded ? 'yes' : snap.ssh.keyLoaded === false ? 'NO — git push will fail' : '—' })] }), snap.ssh.keyFingerprint && (_jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: " fingerprint" }) }), _jsx(Text, { children: truncate(snap.ssh.keyFingerprint, 28) })] }))] }))] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["TLS certificates (", snap.certs.length, ")"] }), snap.certs.length === 0 && _jsx(Text, { color: "gray", children: " no domains to check" }), snap.certs.slice(0, 10).map(c => (_jsxs(Box, { children: [_jsx(Box, { width: 32, children: _jsx(Text, { children: truncate(c.domain, 30) }) }), _jsx(Box, { width: 16, children: _jsx(Text, { color: certColor(c), children: c.daysUntil == null ? 'no cert found' : `${c.daysUntil}d` }) }), _jsx(Text, { color: "gray", children: c.expiresAt ? formatRelative(c.expiresAt) : '' })] }, c.domain))), snap.certs.length > 10 && _jsxs(Text, { color: "gray", children: [" +", snap.certs.length - 10, " more\u2026"] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Secret rotation age (", snap.secretAges.length, ")"] }), snap.secretAges.length === 0 && _jsx(Text, { color: "gray", children: " no managed secrets" }), snap.secretAges.slice(0, 10).map(s => (_jsxs(Box, { children: [_jsx(Box, { width: 22, children: _jsx(Text, { children: truncate(s.app, 20) }) }), _jsx(Box, { width: 16, children: _jsx(Text, { color: ageColor(s.ageDays), children: s.ageDays != null ? `${s.ageDays}d old` : (s.error ?? '—') }) })] }, s.app)))] })] }));
31
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import type { RoutinesRuntime } from '../../../tui/routines/runtime.js';
3
+ export interface SettingsTabProps {
4
+ runtime: RoutinesRuntime;
5
+ }
6
+ export declare function SettingsTab({ runtime }: SettingsTabProps): React.JSX.Element;
@@ -0,0 +1,61 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { dbPath } from '../../../core/routines/db.js';
4
+ const KEY_GROUPS = [
5
+ {
6
+ title: 'Navigation',
7
+ bindings: [
8
+ { key: '1..8', label: 'jump to numbered tab' },
9
+ { key: 'j / k or ↓ / ↑', label: 'move cursor' },
10
+ { key: 'Enter', label: 'drill in / select' },
11
+ { key: 'Esc', label: 'back / cancel modal' },
12
+ { key: 'p or Ctrl+K', label: 'command palette' },
13
+ { key: 'q', label: 'quit' },
14
+ ],
15
+ },
16
+ {
17
+ title: 'Routines',
18
+ bindings: [
19
+ { key: 'n', label: 'new routine' },
20
+ { key: 'e', label: 'edit selected' },
21
+ { key: 'd', label: 'delete selected (y/n confirm)' },
22
+ { key: 't', label: 'toggle enabled' },
23
+ { key: 'r', label: 'run now (opens live panel)' },
24
+ ],
25
+ },
26
+ {
27
+ title: 'Dashboard / Ops',
28
+ bindings: [
29
+ { key: 'r', label: 'force refresh signals' },
30
+ { key: 'Enter', label: 'drill into repo detail' },
31
+ ],
32
+ },
33
+ {
34
+ title: 'Logs',
35
+ bindings: [
36
+ { key: 'j / k', label: 'pick service' },
37
+ { key: 'Enter', label: 'tail selected' },
38
+ { key: 'w', label: 'toggle warn filter' },
39
+ { key: 'x', label: 'toggle error filter' },
40
+ { key: 'c', label: 'clear filter' },
41
+ { key: 'Esc', label: 'stop tail' },
42
+ ],
43
+ },
44
+ {
45
+ title: 'Live-run panel',
46
+ bindings: [
47
+ { key: 'a', label: 'abort running task' },
48
+ { key: 'Esc / Enter / q', label: 'close after end' },
49
+ ],
50
+ },
51
+ ];
52
+ function Row({ label, value, color }) {
53
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 26, children: _jsxs(Text, { color: "gray", children: [" ", label] }) }), _jsx(Text, { color: color, children: value })] }));
54
+ }
55
+ export function SettingsTab({ runtime }) {
56
+ const routinesCount = runtime.store.list().length;
57
+ const enabledCount = runtime.store.list().filter(r => r.enabled).length;
58
+ const storePath = runtime.store.storePath();
59
+ const databasePath = dbPath();
60
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Settings & reference" }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Runtime" }), _jsx(Row, { label: "fleet version", value: "1.4.0" }), _jsx(Row, { label: "ink", value: "5.2.1" }), _jsx(Row, { label: "react", value: "18.3.1" }), _jsx(Row, { label: "routines loaded", value: `${routinesCount} (${enabledCount} enabled)` }), _jsx(Row, { label: "defaults seeded", value: runtime.seeded.seeded > 0 ? `${runtime.seeded.seeded} new` : 'already in place', color: runtime.seeded.seeded > 0 ? 'magenta' : 'gray' })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Paths" }), _jsx(Row, { label: "routines.json", value: storePath }), _jsx(Row, { label: "fleet.db", value: databasePath }), _jsx(Row, { label: "unit template dir", value: "/etc/systemd/system" }), _jsx(Row, { label: "mutex / config dir", value: "/var/lib/fleet/locks \u00B7 /var/lib/fleet/claude-configs" })] }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Adapters enabled" }), _jsx(Row, { label: "scheduler", value: "systemd-timer", color: "green" }), _jsx(Row, { label: "runners", value: "shell \u00B7 claude-cli \u00B7 mcp-call", color: "green" }), _jsx(Row, { label: "notifiers", value: "stdout", color: "green" }), _jsx(Row, { label: "signals", value: "git-clean \u00B7 container-up \u00B7 ci-status", color: "green" })] }), KEY_GROUPS.map(group => (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: group.title }), group.bindings.map(b => (_jsxs(Box, { children: [_jsx(Box, { width: 26, children: _jsxs(Text, { color: "cyan", children: [" ", b.key] }) }), _jsx(Text, { color: "gray", children: b.label })] }, b.key)))] }, group.title)))] }));
61
+ }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import type { RoutineEngine } from '../../../core/routines/engine.js';
3
+ export interface TimelineTabProps {
4
+ engine: RoutineEngine;
5
+ sinceDays?: number;
6
+ }
7
+ export declare function TimelineTab({ engine, sinceDays }: TimelineTabProps): React.JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { Timeline } from '@matthesketh/ink-timeline';
5
+ import { loadIncidents } from '../../../core/routines/incidents.js';
6
+ import { formatRelative, truncate } from '../../../tui/routines/format.js';
7
+ const KIND_META = {
8
+ 'routine-failed': { typeLabel: 'FAIL', typeColor: 'red' },
9
+ 'routine-timeout': { typeLabel: 'TIMEOUT', typeColor: 'yellow' },
10
+ 'signal-error': { typeLabel: 'ERROR', typeColor: 'red' },
11
+ 'signal-warn': { typeLabel: 'WARN', typeColor: 'yellow' },
12
+ };
13
+ export function TimelineTab({ engine, sinceDays = 7 }) {
14
+ const incidents = useMemo(() => loadIncidents(engine.db, { sinceDays, limit: 50 }), [engine.db, sinceDays]);
15
+ const events = incidents.map(i => ({
16
+ time: new Date(i.at),
17
+ type: KIND_META[i.kind].typeLabel,
18
+ typeColor: KIND_META[i.kind].typeColor,
19
+ title: i.subject,
20
+ description: truncate(i.detail || '—', 100),
21
+ }));
22
+ const failCount = incidents.filter(i => i.kind === 'routine-failed' || i.kind === 'routine-timeout').length;
23
+ const warnCount = incidents.filter(i => i.kind === 'signal-warn').length;
24
+ const errCount = incidents.filter(i => i.kind === 'signal-error').length;
25
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: "Incident timeline" }), _jsxs(Text, { color: "gray", children: ["last ", sinceDays, "d"] }), _jsxs(Text, { color: "red", children: [failCount, " routine failures"] }), _jsxs(Text, { color: "red", children: [errCount, " signal errors"] }), _jsxs(Text, { color: "yellow", children: [warnCount, " signal warns"] })] }), events.length === 0 ? (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, children: _jsx(Text, { color: "green", children: " nothing to report \u2014 all clear" }) })) : (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(Timeline, { events: events, maxVisible: 20, showRelativeTime: true }) })), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Raw stream" }), incidents.slice(0, 20).map((i, idx) => (_jsxs(Box, { children: [_jsx(Box, { width: 18, children: _jsx(Text, { color: "gray", children: formatRelative(i.at) }) }), _jsx(Box, { width: 12, children: _jsx(Text, { color: KIND_META[i.kind].typeColor, children: KIND_META[i.kind].typeLabel }) }), _jsx(Box, { width: 30, children: _jsx(Text, { children: truncate(i.subject, 28) }) }), _jsx(Text, { color: "gray", children: truncate(i.detail, 60) })] }, idx)))] })] }));
26
+ }
package/dist/tui/state.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext } from 'react';
2
- const TOP_VIEWS = ['dashboard', 'health', 'secrets'];
2
+ const TOP_VIEWS = ['dashboard', 'health', 'secrets', 'logs-multi'];
3
3
  export const initialState = {
4
4
  currentView: 'dashboard',
5
5
  previousView: null,
@@ -9,6 +9,11 @@ export const initialState = {
9
9
  loading: false,
10
10
  error: null,
11
11
  confirmAction: null,
12
+ dashboardIndex: 0,
13
+ healthIndex: 0,
14
+ secretsIndex: 0,
15
+ secretsSubView: 'app-list',
16
+ appDetailIndex: 0,
12
17
  };
13
18
  export function reducer(state, action) {
14
19
  switch (action.type) {
@@ -26,6 +31,7 @@ export function reducer(state, action) {
26
31
  currentView: state.previousView ?? 'dashboard',
27
32
  previousView: null,
28
33
  selectedSecret: null,
34
+ secretsSubView: 'app-list',
29
35
  error: null,
30
36
  confirmAction: null,
31
37
  };
@@ -43,6 +49,15 @@ export function reducer(state, action) {
43
49
  return { ...state, confirmAction: action.action };
44
50
  case 'CANCEL_CONFIRM':
45
51
  return { ...state, confirmAction: null };
52
+ case 'SET_INDEX': {
53
+ const key = `${action.view}Index`;
54
+ if (key in state) {
55
+ return { ...state, [key]: action.index };
56
+ }
57
+ return state;
58
+ }
59
+ case 'SET_SECRETS_SUBVIEW':
60
+ return { ...state, secretsSubView: action.subView, secretsIndex: 0 };
46
61
  default:
47
62
  return state;
48
63
  }
@@ -0,0 +1 @@
1
+ export {};