soja-cli 1.4.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 (189) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +117 -0
  3. package/dist/application/chat.js +1 -0
  4. package/dist/application/filters.js +27 -0
  5. package/dist/application/import.js +1 -0
  6. package/dist/application/ports.js +1 -0
  7. package/dist/application/repository-root.js +18 -0
  8. package/dist/application/services/backup-service.js +93 -0
  9. package/dist/application/services/folder-service.js +116 -0
  10. package/dist/application/services/git-workflow-service.js +593 -0
  11. package/dist/application/services/index.js +24 -0
  12. package/dist/application/services/project-service.js +129 -0
  13. package/dist/application/services/session-service.js +52 -0
  14. package/dist/application/services/task-service.js +350 -0
  15. package/dist/application/services/update-service.js +60 -0
  16. package/dist/application/services/workspace-service.js +81 -0
  17. package/dist/application/timeline.js +83 -0
  18. package/dist/application/types.js +1 -0
  19. package/dist/application/validation.js +30 -0
  20. package/dist/bootstrap.js +91 -0
  21. package/dist/cli/commands/access.js +60 -0
  22. package/dist/cli/commands/account.js +203 -0
  23. package/dist/cli/commands/args.js +27 -0
  24. package/dist/cli/commands/backup.js +70 -0
  25. package/dist/cli/commands/chat.js +133 -0
  26. package/dist/cli/commands/dev.js +59 -0
  27. package/dist/cli/commands/folders.js +59 -0
  28. package/dist/cli/commands/git-ops.js +200 -0
  29. package/dist/cli/commands/import.js +48 -0
  30. package/dist/cli/commands/info.js +82 -0
  31. package/dist/cli/commands/project.js +108 -0
  32. package/dist/cli/commands/start.js +34 -0
  33. package/dist/cli/commands/sync.js +43 -0
  34. package/dist/cli/commands/task.js +309 -0
  35. package/dist/cli/commands/tui.js +43 -0
  36. package/dist/cli/commands/update.js +20 -0
  37. package/dist/cli/commands/workspace.js +55 -0
  38. package/dist/cli/index.js +103 -0
  39. package/dist/cli/output.js +56 -0
  40. package/dist/cli/prompt.js +28 -0
  41. package/dist/cli/runtime.js +43 -0
  42. package/dist/config/config.js +103 -0
  43. package/dist/config/credentials.js +68 -0
  44. package/dist/config/paths.js +18 -0
  45. package/dist/config/server.js +21 -0
  46. package/dist/config/version.js +7 -0
  47. package/dist/data/local/activity.js +33 -0
  48. package/dist/data/local/clock.js +1 -0
  49. package/dist/data/local/comments.js +27 -0
  50. package/dist/data/local/index.js +48 -0
  51. package/dist/data/local/projects.js +53 -0
  52. package/dist/data/local/tasks.js +104 -0
  53. package/dist/data/local/users.js +38 -0
  54. package/dist/data/local/workspaces.js +55 -0
  55. package/dist/data/remote/api-client.js +103 -0
  56. package/dist/data/remote/live.js +93 -0
  57. package/dist/data/repositories.js +1 -0
  58. package/dist/data/sync/chat.js +242 -0
  59. package/dist/data/sync/engine.js +459 -0
  60. package/dist/data/sync/importer.js +156 -0
  61. package/dist/data/sync/index.js +109 -0
  62. package/dist/data/sync/services.js +263 -0
  63. package/dist/data/sync/store.js +200 -0
  64. package/dist/database/client.js +76 -0
  65. package/dist/database/migrate.js +27 -0
  66. package/dist/database/migrations/0000_init.sql +100 -0
  67. package/dist/database/migrations/0001_git-operations.sql +19 -0
  68. package/dist/database/migrations/0002_merge-detection.sql +19 -0
  69. package/dist/database/migrations/0003_sync-replica.sql +40 -0
  70. package/dist/database/migrations/0004_chat-replica.sql +52 -0
  71. package/dist/database/migrations/0005_pr-events.sql +18 -0
  72. package/dist/database/migrations/0006_archive.sql +20 -0
  73. package/dist/database/migrations/meta/0000_snapshot.json +710 -0
  74. package/dist/database/migrations/meta/0001_snapshot.json +717 -0
  75. package/dist/database/migrations/meta/0002_snapshot.json +724 -0
  76. package/dist/database/migrations/meta/0003_snapshot.json +972 -0
  77. package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
  78. package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
  79. package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
  80. package/dist/database/migrations/meta/_journal.json +55 -0
  81. package/dist/database/schema.js +170 -0
  82. package/dist/dev/seed.js +84 -0
  83. package/dist/domain/activity.js +22 -0
  84. package/dist/domain/chat.js +21 -0
  85. package/dist/domain/entities.js +1 -0
  86. package/dist/domain/errors.js +23 -0
  87. package/dist/domain/naming.js +33 -0
  88. package/dist/domain/task.js +140 -0
  89. package/dist/domain/workflow.js +23 -0
  90. package/dist/git/cli-git.js +363 -0
  91. package/dist/git/console.js +37 -0
  92. package/dist/git/diagnose.js +145 -0
  93. package/dist/git/merge-evidence.js +23 -0
  94. package/dist/git/releases.js +95 -0
  95. package/dist/git/types.js +16 -0
  96. package/dist/ui/App.js +71 -0
  97. package/dist/ui/Shell.js +211 -0
  98. package/dist/ui/app-state.js +213 -0
  99. package/dist/ui/branding/CompactLogo.js +8 -0
  100. package/dist/ui/branding/Logo.js +22 -0
  101. package/dist/ui/branding/Splash.js +21 -0
  102. package/dist/ui/branding/brand.js +23 -0
  103. package/dist/ui/chrome/Sidebar.js +50 -0
  104. package/dist/ui/chrome/StatusBar.js +26 -0
  105. package/dist/ui/chrome/Toast.js +11 -0
  106. package/dist/ui/chrome/TopBar.js +10 -0
  107. package/dist/ui/chrome/context.js +33 -0
  108. package/dist/ui/components/ConsoleLines.js +16 -0
  109. package/dist/ui/components/EmptyState.js +8 -0
  110. package/dist/ui/components/Labels.js +25 -0
  111. package/dist/ui/components/PullRequestLabel.js +27 -0
  112. package/dist/ui/components/TaskTable.js +41 -0
  113. package/dist/ui/components/TextField.js +11 -0
  114. package/dist/ui/components/TextInput.js +29 -0
  115. package/dist/ui/components/task-columns.js +27 -0
  116. package/dist/ui/copy.js +23 -0
  117. package/dist/ui/hooks/use-commands.js +58 -0
  118. package/dist/ui/hooks/use-flows.js +254 -0
  119. package/dist/ui/hooks/use-layout.js +36 -0
  120. package/dist/ui/hooks/use-list.js +28 -0
  121. package/dist/ui/hooks/use-measure.js +22 -0
  122. package/dist/ui/hooks/use-query.js +29 -0
  123. package/dist/ui/hooks/use-task-actions.js +450 -0
  124. package/dist/ui/hooks/use-terminal-size.js +19 -0
  125. package/dist/ui/input/KeyProvider.js +101 -0
  126. package/dist/ui/input/dispatcher.js +26 -0
  127. package/dist/ui/input/list-navigation.js +38 -0
  128. package/dist/ui/input/mouse.js +78 -0
  129. package/dist/ui/input/text-editing.js +62 -0
  130. package/dist/ui/kit/Badge.js +8 -0
  131. package/dist/ui/kit/Button.js +13 -0
  132. package/dist/ui/kit/Clickable.js +14 -0
  133. package/dist/ui/kit/Gauge.js +9 -0
  134. package/dist/ui/kit/Keycap.js +7 -0
  135. package/dist/ui/kit/Panel.js +30 -0
  136. package/dist/ui/kit/ScrollBar.js +14 -0
  137. package/dist/ui/kit/Spinner.js +9 -0
  138. package/dist/ui/kit/Tabs.js +12 -0
  139. package/dist/ui/mascot/AgentScene.js +30 -0
  140. package/dist/ui/mascot/Mascot.js +26 -0
  141. package/dist/ui/mascot/ansi.js +25 -0
  142. package/dist/ui/mascot/engine.js +146 -0
  143. package/dist/ui/mascot/index.js +6 -0
  144. package/dist/ui/mascot/palette.js +18 -0
  145. package/dist/ui/mascot/scenes/bugHunt.js +115 -0
  146. package/dist/ui/mascot/scenes/celebrate.js +76 -0
  147. package/dist/ui/mascot/scenes/cleaning.js +156 -0
  148. package/dist/ui/mascot/scenes/clockIn.js +105 -0
  149. package/dist/ui/mascot/scenes/deadline.js +125 -0
  150. package/dist/ui/mascot/scenes/empty.js +149 -0
  151. package/dist/ui/mascot/scenes/fire.js +138 -0
  152. package/dist/ui/mascot/scenes/index.js +25 -0
  153. package/dist/ui/mascot/scenes/planning.js +73 -0
  154. package/dist/ui/mascot/scenes/resting.js +90 -0
  155. package/dist/ui/mascot/scenes/syncing.js +116 -0
  156. package/dist/ui/mascot/scenes/working.js +193 -0
  157. package/dist/ui/mascot/selection.js +32 -0
  158. package/dist/ui/mascot/states.js +25 -0
  159. package/dist/ui/navigation/routes.js +17 -0
  160. package/dist/ui/overlays/CommitOverlay.js +93 -0
  161. package/dist/ui/overlays/ConfirmOverlay.js +42 -0
  162. package/dist/ui/overlays/GitLogOverlay.js +22 -0
  163. package/dist/ui/overlays/GitRunOverlay.js +94 -0
  164. package/dist/ui/overlays/NewTaskOverlay.js +117 -0
  165. package/dist/ui/overlays/OverlayFrame.js +35 -0
  166. package/dist/ui/overlays/PickerOverlay.js +72 -0
  167. package/dist/ui/overlays/PromptOverlay.js +51 -0
  168. package/dist/ui/overlays/SearchOverlay.js +49 -0
  169. package/dist/ui/overlays/options.js +29 -0
  170. package/dist/ui/overlays/types.js +1 -0
  171. package/dist/ui/screens/AccessScreen.js +91 -0
  172. package/dist/ui/screens/ChatScreen.js +450 -0
  173. package/dist/ui/screens/HelpScreen.js +99 -0
  174. package/dist/ui/screens/ProjectsScreen.js +66 -0
  175. package/dist/ui/screens/ScreenFrame.js +14 -0
  176. package/dist/ui/screens/SetupScreen.js +79 -0
  177. package/dist/ui/screens/TaskListScreen.js +152 -0
  178. package/dist/ui/screens/TaskScreen.js +193 -0
  179. package/dist/ui/screens/WelcomeScreen.js +254 -0
  180. package/dist/ui/screens/WorkspacesScreen.js +64 -0
  181. package/dist/ui/theme/detect.js +59 -0
  182. package/dist/ui/theme/theme.js +135 -0
  183. package/dist/utils/editor.js +37 -0
  184. package/dist/utils/errors.js +23 -0
  185. package/dist/utils/text.js +60 -0
  186. package/dist/utils/time.js +25 -0
  187. package/docs/NPM_PUBLISH.md +35 -0
  188. package/docs/PRIVACY.md +38 -0
  189. package/package.json +74 -0
@@ -0,0 +1,254 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useApp } from 'ink';
3
+ import { useMemo, useState } from 'react';
4
+ import { FileConfigStore } from '../../config/config.js';
5
+ import { CredentialStore } from '../../config/credentials.js';
6
+ import { resolvePaths } from '../../config/paths.js';
7
+ import { DEFAULT_SERVER_URL, isSecureServerUrl } from '../../config/server.js';
8
+ import { ApiClient } from '../../data/remote/api-client.js';
9
+ import { SojaError, ValidationError } from '../../domain/errors.js';
10
+ import { toDisplayError } from '../../utils/errors.js';
11
+ import { Splash } from '../branding/Splash.js';
12
+ import { Button } from '../kit/Button.js';
13
+ import { Panel } from '../kit/Panel.js';
14
+ import { Spinner } from '../kit/Spinner.js';
15
+ import { TextField } from '../components/TextField.js';
16
+ import { Layer } from '../input/dispatcher.js';
17
+ import { useKeys } from '../input/KeyProvider.js';
18
+ import { useTerminalSize } from '../hooks/use-terminal-size.js';
19
+ import { palette } from '../theme/theme.js';
20
+ const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
21
+ /** First-run choice between private local data and the GitHub-backed team service. */
22
+ export function WelcomeScreen({ onLocal }) {
23
+ const { exit } = useApp();
24
+ const { columns, rows } = useTerminalSize();
25
+ const paths = resolvePaths();
26
+ const config = useMemo(() => new FileConfigStore(paths.configFile), [paths.configFile]);
27
+ const credentials = useMemo(() => new CredentialStore(paths.credentialsFile), [paths.credentialsFile]);
28
+ const [stage, setStage] = useState('choose');
29
+ const server = config.load()?.remote?.apiUrl ?? DEFAULT_SERVER_URL;
30
+ const [message, setMessage] = useState('');
31
+ const [error, setError] = useState('');
32
+ const [busy, setBusy] = useState(false);
33
+ const [device, setDevice] = useState(null);
34
+ const [user, setUser] = useState(null);
35
+ const [countries, setCountries] = useState([]);
36
+ const [field, setField] = useState(0);
37
+ const [values, setValues] = useState(['', '', '', '']);
38
+ const [selectedCountry, setSelectedCountry] = useState(null);
39
+ const [countryCursor, setCountryCursor] = useState(0);
40
+ const [workspaceChoices, setWorkspaceChoices] = useState([]);
41
+ const [workspaceName, setWorkspaceName] = useState('');
42
+ const filtered = useMemo(() => countries.filter((country) => country.name.toLocaleLowerCase().includes((values[3] ?? '').toLocaleLowerCase())).slice(0, 10), [countries, values]);
43
+ const chooseLocal = () => {
44
+ const current = config.load();
45
+ if (current?.mode === 'remote')
46
+ config.save({ ...current, mode: 'local' });
47
+ onLocal();
48
+ };
49
+ const signIn = async () => {
50
+ setBusy(true);
51
+ setError('');
52
+ setStage('waiting');
53
+ try {
54
+ const base = server.trim().replace(/\/$/, '');
55
+ if (!isSecureServerUrl(base))
56
+ throw new ValidationError('El servidor SOJA debe usar HTTPS. Solo se permite HTTP en localhost durante el desarrollo.');
57
+ const api = new ApiClient(base, null);
58
+ const started = await api.post('/v1/auth/device');
59
+ setDevice({ uri: started.verificationUri, code: started.userCode });
60
+ const deadline = Date.now() + started.expiresIn * 1000;
61
+ let interval = started.interval;
62
+ while (Date.now() < deadline) {
63
+ await sleep(interval);
64
+ const poll = await api.postWithStatus('/v1/auth/device/token', { deviceCode: started.deviceCode });
65
+ if (poll.status === 202) {
66
+ if (poll.body.status === 'slow_down')
67
+ interval += 5;
68
+ continue;
69
+ }
70
+ if (!poll.body.token || !poll.body.user)
71
+ throw new SojaError('El backend no devolvió una sesión.');
72
+ const accountApi = new ApiClient(base, poll.body.token);
73
+ const account = await accountApi.get('/v1/me');
74
+ credentials.save(base, poll.body.token, poll.body.user.username);
75
+ setUser(account.user);
76
+ if (account.user.accessStatus === 'approved') {
77
+ if (account.workspaces.length > 1) {
78
+ setWorkspaceChoices(account.workspaces);
79
+ config.save({ parentFolders: [], ...config.load(), mode: 'local', remote: { apiUrl: base, userId: account.user.id } });
80
+ setStage('workspace');
81
+ setBusy(false);
82
+ return;
83
+ }
84
+ if (account.workspaces.length === 0) {
85
+ config.save({ parentFolders: [], ...config.load(), mode: 'local', remote: { apiUrl: base, userId: account.user.id } });
86
+ setStage('newworkspace');
87
+ setBusy(false);
88
+ return;
89
+ }
90
+ const workspaceId = account.workspaces[0]?.id;
91
+ config.save({ parentFolders: [], ...config.load(), mode: 'remote', remote: { apiUrl: base, userId: account.user.id, ...(workspaceId ? { workspaceId } : {}) } });
92
+ setMessage('Acceso aprobado. Abriendo SOJA Online…');
93
+ setTimeout(() => exit(), 500);
94
+ return;
95
+ }
96
+ if (account.user.accessStatus === 'rejected') {
97
+ setMessage('La solicitud anterior fue rechazada. Puedes presentar una nueva.');
98
+ }
99
+ const countryResult = await accountApi.get('/v1/auth/countries');
100
+ setCountries(countryResult.countries);
101
+ setValues([account.user.displayName, '', '', '']);
102
+ setStage('profile');
103
+ setBusy(false);
104
+ return;
105
+ }
106
+ throw new SojaError('El código de GitHub expiró; vuelve a iniciar sesión.');
107
+ }
108
+ catch (failure) {
109
+ setError(toDisplayError(failure).message);
110
+ setStage('error');
111
+ setBusy(false);
112
+ }
113
+ };
114
+ const selectWorkspace = (index) => {
115
+ const workspace = workspaceChoices[index];
116
+ if (!workspace || !user)
117
+ return;
118
+ const base = server.trim().replace(/\/$/, '');
119
+ config.save({ parentFolders: [], ...config.load(), mode: 'remote', remote: { apiUrl: base, userId: user.id, workspaceId: workspace.id } });
120
+ setMessage(`Workspace ${workspace.name} seleccionado. Abriendo SOJA Online…`);
121
+ setTimeout(() => exit(), 500);
122
+ };
123
+ const createWorkspace = async () => {
124
+ if (!user || !workspaceName.trim()) {
125
+ setError('Escribe un nombre para tu workspace.');
126
+ return;
127
+ }
128
+ const base = server.trim().replace(/\/$/, '');
129
+ const token = credentials.token(base);
130
+ if (!token) {
131
+ setError('No encontré la sesión guardada.');
132
+ return;
133
+ }
134
+ setBusy(true);
135
+ setError('');
136
+ try {
137
+ const created = await new ApiClient(base, token).post('/v1/workspaces', { name: workspaceName.trim() });
138
+ config.save({ parentFolders: [], ...config.load(), mode: 'remote', remote: { apiUrl: base, userId: user.id, workspaceId: created.id } });
139
+ setTimeout(() => exit(), 400);
140
+ }
141
+ catch (failure) {
142
+ setError(toDisplayError(failure).message);
143
+ setBusy(false);
144
+ }
145
+ };
146
+ const submitProfile = async () => {
147
+ if (!user || !selectedCountry) {
148
+ setError('Selecciona un país de la lista.');
149
+ return;
150
+ }
151
+ if (Array.from(values[2] ?? '').length > 100) {
152
+ setError('La carta no puede superar 100 caracteres.');
153
+ return;
154
+ }
155
+ const base = server.trim().replace(/\/$/, '');
156
+ const token = credentials.token(base);
157
+ if (!token) {
158
+ setError('No encontré la sesión guardada. Ejecuta `soja login`.');
159
+ return;
160
+ }
161
+ setBusy(true);
162
+ setError('');
163
+ try {
164
+ await new ApiClient(base, token).post('/v1/access-request', { displayName: values[0], dateOfBirth: values[1], letter: values[2], countryCode: selectedCountry.code });
165
+ const current = config.load();
166
+ config.save({ parentFolders: [], ...current, mode: 'local', remote: { apiUrl: base, userId: user.id } });
167
+ setMessage('Solicitud enviada. SOJA queda en modo local hasta que el CEO apruebe tu acceso.');
168
+ setStage('error');
169
+ setBusy(false);
170
+ }
171
+ catch (failure) {
172
+ setError(toDisplayError(failure).message);
173
+ setBusy(false);
174
+ }
175
+ };
176
+ useKeys(Layer.screen, (input, key) => {
177
+ if (stage === 'choose') {
178
+ if (input === '1' || input.toLowerCase() === 'l') {
179
+ chooseLocal();
180
+ return true;
181
+ }
182
+ if (input === '2' || input.toLowerCase() === 'g') {
183
+ void signIn();
184
+ return true;
185
+ }
186
+ }
187
+ if (stage === 'error' && key.escape) {
188
+ setStage('choose');
189
+ setError('');
190
+ setMessage('');
191
+ return true;
192
+ }
193
+ if (stage === 'workspace') {
194
+ if (key.upArrow) {
195
+ setCountryCursor((n) => Math.max(0, n - 1));
196
+ return true;
197
+ }
198
+ if (key.downArrow) {
199
+ setCountryCursor((n) => Math.min(workspaceChoices.length - 1, n + 1));
200
+ return true;
201
+ }
202
+ if (key.return) {
203
+ selectWorkspace(countryCursor);
204
+ return true;
205
+ }
206
+ if (/^[1-9]$/.test(input)) {
207
+ selectWorkspace(Number(input) - 1);
208
+ return true;
209
+ }
210
+ }
211
+ if (stage === 'newworkspace') {
212
+ if (key.return) {
213
+ void createWorkspace();
214
+ return true;
215
+ }
216
+ if (key.escape) {
217
+ setStage('choose');
218
+ return true;
219
+ }
220
+ }
221
+ if (stage === 'profile') {
222
+ if (field === 3 && key.upArrow) {
223
+ setCountryCursor((n) => Math.max(0, n - 1));
224
+ return true;
225
+ }
226
+ if (field === 3 && key.downArrow) {
227
+ setCountryCursor((n) => Math.min(filtered.length - 1, n + 1));
228
+ return true;
229
+ }
230
+ if (field === 3 && key.return && filtered[countryCursor]) {
231
+ setSelectedCountry(filtered[countryCursor] ?? null);
232
+ setField(4);
233
+ return true;
234
+ }
235
+ if (key.return) {
236
+ if (field < 3)
237
+ setField(field + 1);
238
+ else if (field === 4)
239
+ void submitProfile();
240
+ return true;
241
+ }
242
+ if (key.escape) {
243
+ if (field > 0)
244
+ setField(field - 1);
245
+ else
246
+ setStage('choose');
247
+ return true;
248
+ }
249
+ }
250
+ return false;
251
+ }, true);
252
+ const width = Math.max(36, Math.min(76, columns - 4));
253
+ return (_jsx(Box, { width: columns, height: rows, alignItems: "center", justifyContent: "center", children: _jsxs(Box, { flexDirection: "column", width: width, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Splash, { compact: true }) }), stage === 'choose' ? _jsxs(Panel, { title: "Welcome to SOJA", focused: true, width: width, children: [_jsx(Text, { color: palette.text, children: "Choose how you want to work." }), _jsx(Box, { marginTop: 1, children: _jsx(Button, { label: "Sign in with GitHub", variant: "primary", layer: Layer.screen, onPress: () => void signIn() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Button, { label: "Local mode", layer: Layer.screen, onPress: chooseLocal }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.muted, children: "Local mode keeps tasks on this machine and has no team chat." }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.faint, children: "g GitHub \u00B7 l local \u00B7 enter select" }) })] }) : null, stage === 'waiting' ? _jsxs(Panel, { title: "Sign in with GitHub", focused: true, width: width, children: [device ? _jsxs(_Fragment, { children: [_jsxs(Text, { children: ["Open ", device.uri] }), _jsxs(Text, { bold: true, color: palette.accent, children: ["Enter code: ", device.code] })] }) : _jsx(Text, { children: "Connecting to SOJA\u2026" }), _jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Waiting for GitHub authorization\u2026" }) })] }) : null, stage === 'workspace' ? _jsxs(Panel, { title: "Choose a workspace", focused: true, width: width, children: [_jsx(Text, { color: palette.muted, children: "Your account belongs to multiple workspaces." }), workspaceChoices.map((workspace, index) => _jsx(Text, { color: index === countryCursor ? palette.accent : palette.text, children: `${index === countryCursor ? '›' : ' '} ${index + 1}. ${workspace.name}` }, workspace.id)), _jsx(Text, { color: palette.faint, children: "\u2191/\u2193 move \u00B7 enter or 1\u20139 select" })] }) : null, stage === 'newworkspace' ? _jsxs(Panel, { title: "Create your first workspace", focused: true, width: width, children: [_jsx(Text, { color: palette.muted, children: "Your account is approved. Name the workspace you own." }), _jsx(TextField, { value: workspaceName, onChange: setWorkspaceName, placeholder: "My team", width: width - 6 }), error ? _jsx(Text, { color: palette.danger, children: error }) : null, _jsx(Text, { color: palette.faint, children: "enter create \u00B7 esc back" })] }) : null, stage === 'profile' ? _jsxs(Panel, { title: "Request SOJA access", aside: `${field + 1}/5`, focused: true, width: width, children: [_jsx(Text, { color: palette.muted, children: "Your session is restricted until CEO approval." }), field === 0 ? _jsxs(_Fragment, { children: [_jsx(Text, { children: "Name" }), _jsx(TextField, { value: values[0] ?? '', onChange: (v) => setValues((old) => [v, old[1] ?? '', old[2] ?? '', old[3] ?? '']), placeholder: "Full name", width: width - 6 })] }) : null, field === 1 ? _jsxs(_Fragment, { children: [_jsx(Text, { children: "Date of birth (YYYY-MM-DD)" }), _jsx(TextField, { value: values[1] ?? '', onChange: (v) => setValues((old) => [old[0] ?? '', v, old[2] ?? '', old[3] ?? '']), placeholder: "2000-01-31", width: width - 6 })] }) : null, field === 2 ? _jsxs(_Fragment, { children: [_jsxs(Text, { children: ["Why are you interested in SOJA? (max 100 characters \u00B7 ", Array.from(values[2] ?? '').length, "/100)"] }), _jsx(TextField, { value: values[2] ?? '', onChange: (v) => setValues((old) => [old[0] ?? '', old[1] ?? '', Array.from(v).slice(0, 100).join(''), old[3] ?? '']), placeholder: "I want to\u2026", width: width - 6 })] }) : null, field === 3 ? _jsxs(_Fragment, { children: [_jsx(Text, { children: "Country \u00B7 type to search, \u2191/\u2193 and enter to select" }), _jsx(TextField, { value: values[3] ?? '', onChange: (v) => { setCountryCursor(0); setSelectedCountry(null); setValues((old) => [old[0] ?? '', old[1] ?? '', old[2] ?? '', v]); }, placeholder: "Search countries", width: width - 6 }), filtered.map((country, index) => _jsx(Text, { color: index === countryCursor ? palette.accent : palette.muted, children: `${index === countryCursor ? '›' : ' '} ${country.flag} ${country.name}` }, country.code))] }) : null, field === 4 ? _jsxs(Text, { children: ["Country selected: ", selectedCountry?.flag, " ", selectedCountry?.name] }) : null, error ? _jsx(Text, { color: palette.danger, children: error }) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: palette.faint, children: ["enter next \u00B7 esc back ", busy ? '· saving…' : ''] }) })] }) : null, stage === 'error' ? _jsxs(Panel, { title: "SOJA account", focused: true, width: width, children: [error ? _jsx(Text, { color: palette.danger, children: error }) : null, message ? _jsx(Text, { color: palette.text, children: message }) : null, _jsx(Box, { marginTop: 1, children: _jsx(Button, { label: "Back", layer: Layer.screen, onPress: () => { setStage('choose'); setError(''); setMessage(''); } }) })] }) : null, stage === 'profile' && error ? _jsx(Text, { color: palette.danger, children: error }) : null] }) }));
254
+ }
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useAppState } from '../app-state.js';
4
+ import { useFlows } from '../hooks/use-flows.js';
5
+ import { useLayout } from '../hooks/use-layout.js';
6
+ import { useList } from '../hooks/use-list.js';
7
+ import { useQuery } from '../hooks/use-query.js';
8
+ import { Layer } from '../input/dispatcher.js';
9
+ import { useKeys } from '../input/KeyProvider.js';
10
+ import { palette, symbols } from '../theme/theme.js';
11
+ import { Badge } from '../kit/Badge.js';
12
+ import { Clickable } from '../kit/Clickable.js';
13
+ import { Panel } from '../kit/Panel.js';
14
+ import { ScreenFrame } from './ScreenFrame.js';
15
+ export function WorkspacesScreen({ active }) {
16
+ const { services, session } = useAppState();
17
+ const { height, width } = useLayout();
18
+ const data = useQuery(async () => {
19
+ const [workspaces, members] = await Promise.all([
20
+ services.workspaces.list(session.user),
21
+ services.workspaces.members(session),
22
+ ]);
23
+ return { workspaces, members };
24
+ }, `workspaces:${session.workspace.id}`);
25
+ const workspaces = data.data?.workspaces ?? [];
26
+ const members = data.data?.members ?? [];
27
+ const listRows = Math.max(3, Math.min(workspaces.length, height - 6 - Math.min(members.length, 8)));
28
+ const list = useList(workspaces.length, listRows, true, Math.max(0, workspaces.findIndex((w) => w.id === session.workspace.id)));
29
+ const flows = useFlows();
30
+ useKeys(Layer.screen, (input, key) => {
31
+ if (list.handleKey(input, key))
32
+ return true;
33
+ if (key.ctrl || key.meta)
34
+ return false;
35
+ const workspace = workspaces[list.index];
36
+ if (key.return && workspace)
37
+ void flows.switchWorkspace(workspace.id);
38
+ else if (input === 'n')
39
+ flows.newWorkspace();
40
+ else if (input === 'a')
41
+ flows.addDeveloper();
42
+ else
43
+ return false;
44
+ return true;
45
+ }, active);
46
+ const wide = width >= 80;
47
+ return (_jsx(ScreenFrame, { title: "Workspaces", aside: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`, hints: [
48
+ ['↑↓', 'move'],
49
+ ['enter', 'switch'],
50
+ ['n', 'new workspace'],
51
+ ['a', 'add developer'],
52
+ ['esc', 'back'],
53
+ ], children: _jsxs(Box, { flexDirection: wide ? 'row' : 'column', gap: 1, children: [_jsx(Panel, { title: "Your workspaces", flexGrow: 1, children: workspaces.slice(list.offset, list.offset + listRows).map((workspace, position) => {
54
+ const index = list.offset + position;
55
+ const selected = index === list.index;
56
+ const current = workspace.id === session.workspace.id;
57
+ const bg = selected ? { backgroundColor: palette.selection } : {};
58
+ return (_jsx(Clickable, { active: active, onClick: () => (selected ? void flows.switchWorkspace(workspace.id) : list.select(index)), children: _jsxs(Box, { gap: 1, flexGrow: 1, ...bg, children: [_jsx(Text, { color: palette.accent, children: selected ? symbols.pointer : ' ' }), _jsx(Text, { color: palette.text, bold: selected, wrap: "truncate-end", children: workspace.name }), _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: `${workspace.slug} ${symbols.dot} ${workspace.role}` }), current ? _jsx(Badge, { tone: "success", children: "current" }) : null] }) }, workspace.id));
59
+ }) }), _jsxs(Panel, { title: `Developers in ${session.workspace.name}`, aside: `${members.length}`, width: wide ? Math.floor(width * 0.45) : undefined, flexShrink: 0, children: [members.slice(0, 10).map((member) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { backgroundColor: palette.neutralSoft, color: palette.accent, bold: true, children: ` ${initials(member.displayName)} ` }), _jsx(Text, { color: palette.text, children: `@${member.username}` }), _jsx(Text, { color: palette.muted, wrap: "truncate-end", children: member.displayName }), member.role === 'owner' ? _jsx(Badge, { tone: "info", children: "owner" }) : null] }, member.id))), members.length > 10 ? _jsx(Text, { color: palette.faint, children: `${symbols.ellipsis} and ${members.length - 10} more` }) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: palette.faint, children: "a adds a developer" }) })] })] }) }));
60
+ }
61
+ function initials(name) {
62
+ const parts = name.trim().split(/\s+/);
63
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
64
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Chooses the light or dark palette: `SOJA_THEME`, then `COLORFGBG` (set by
3
+ * some terminals), then asking the terminal for its background color
4
+ * (OSC 11, answered by kitty, WezTerm, iTerm2, GNOME Terminal, Alacritty,
5
+ * Windows Terminal…). Dark when nothing answers.
6
+ */
7
+ export async function detectThemeMode(env = process.env, io = { stdin: process.stdin, stdout: process.stdout }) {
8
+ const forced = env.SOJA_THEME?.toLowerCase();
9
+ if (forced === 'light' || forced === 'dark')
10
+ return forced;
11
+ const fromEnv = modeFromColorFgBg(env.COLORFGBG);
12
+ if (fromEnv)
13
+ return fromEnv;
14
+ const background = await queryBackground(io).catch(() => null);
15
+ return background ?? 'dark';
16
+ }
17
+ /** `COLORFGBG="15;0"`: foreground;background as ANSI indexes (7 and 15 are light). */
18
+ export function modeFromColorFgBg(value) {
19
+ const background = value?.split(';').at(-1);
20
+ if (background === undefined || background === '' || Number.isNaN(Number(background)))
21
+ return null;
22
+ return ['7', '15'].includes(background) ? 'light' : 'dark';
23
+ }
24
+ /** `rgb:ffff/ffff/ffff` (from OSC 11) → light or dark by perceived luminance. */
25
+ export function modeFromOscReply(reply) {
26
+ const match = /rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(reply);
27
+ if (!match)
28
+ return null;
29
+ const [r, g, b] = match.slice(1, 4).map((hex) => Number.parseInt(hex, 16) / (16 ** hex.length - 1));
30
+ const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
31
+ return luminance > 0.5 ? 'light' : 'dark';
32
+ }
33
+ function queryBackground({ stdin, stdout }, timeoutMs = 150) {
34
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function')
35
+ return Promise.resolve(null);
36
+ return new Promise((resolve) => {
37
+ let reply = '';
38
+ const wasRaw = stdin.isRaw;
39
+ const finish = (result) => {
40
+ clearTimeout(timer);
41
+ stdin.off('data', onData);
42
+ stdin.setRawMode(wasRaw);
43
+ stdin.pause();
44
+ resolve(result);
45
+ };
46
+ const onData = (chunk) => {
47
+ reply += chunk.toString('latin1');
48
+ const mode = modeFromOscReply(reply);
49
+ // The reply ends with BEL or ST; a terminal that does not know OSC 11 sends nothing.
50
+ if (mode || reply.includes('\u0007') || reply.includes('\u001b\\'))
51
+ finish(mode);
52
+ };
53
+ const timer = setTimeout(() => finish(null), timeoutMs);
54
+ stdin.setRawMode(true);
55
+ stdin.on('data', onData);
56
+ stdin.resume();
57
+ stdout.write('\x1b]11;?\x1b\\');
58
+ });
59
+ }
@@ -0,0 +1,135 @@
1
+ import { PRIORITY_LABELS, STATUS_LABELS } from '../../domain/task.js';
2
+ const DARK = {
3
+ accent: '#8CC66F',
4
+ onAccent: '#14180F',
5
+ text: '#E6E3DA',
6
+ muted: '#9C998E',
7
+ faint: '#66645C',
8
+ border: '#3B3D35',
9
+ borderFocus: '#8CC66F',
10
+ bar: '#23261F',
11
+ barText: '#C9C6BB',
12
+ surface: '#2A2D25',
13
+ selection: '#33402C',
14
+ danger: '#EF6B5F',
15
+ warning: '#E6B450',
16
+ success: '#8CC66F',
17
+ info: '#62B6D9',
18
+ merged: '#B894E6',
19
+ dangerSoft: '#4A2622',
20
+ warningSoft: '#453A1E',
21
+ successSoft: '#2F4125',
22
+ infoSoft: '#1F3A47',
23
+ mergedSoft: '#3A2D4A',
24
+ neutralSoft: '#34362F',
25
+ };
26
+ const LIGHT = {
27
+ accent: '#3F8A3F',
28
+ onAccent: '#FFFFFF',
29
+ text: '#26251F',
30
+ muted: '#6B685E',
31
+ faint: '#A29F94',
32
+ border: '#D5D2C6',
33
+ borderFocus: '#3F8A3F',
34
+ bar: '#ECEAE2',
35
+ barText: '#3E3C35',
36
+ surface: '#F6F5EF',
37
+ selection: '#DDEBD3',
38
+ danger: '#C43D32',
39
+ warning: '#A06A00',
40
+ success: '#3F8A3F',
41
+ info: '#1F76A0',
42
+ merged: '#7A4FB8',
43
+ dangerSoft: '#F6DCD8',
44
+ warningSoft: '#F3E6C4',
45
+ successSoft: '#DDEBD3',
46
+ infoSoft: '#D6E9F2',
47
+ mergedSoft: '#E8DDF6',
48
+ neutralSoft: '#E6E4DC',
49
+ };
50
+ /** The active interface palette. Filled in place by `setThemeMode` before the interface renders. */
51
+ export const palette = { ...DARK };
52
+ let mode = 'dark';
53
+ export function setThemeMode(next) {
54
+ mode = next;
55
+ Object.assign(palette, next === 'light' ? LIGHT : DARK);
56
+ }
57
+ export function themeMode() {
58
+ return mode;
59
+ }
60
+ /** Foreground and soft background of a tone, for badges. */
61
+ export function toneColors(tone) {
62
+ switch (tone) {
63
+ case 'danger':
64
+ return { fg: palette.danger, bg: palette.dangerSoft };
65
+ case 'warning':
66
+ return { fg: palette.warning, bg: palette.warningSoft };
67
+ case 'success':
68
+ return { fg: palette.success, bg: palette.successSoft };
69
+ case 'info':
70
+ return { fg: palette.info, bg: palette.infoSoft };
71
+ case 'merged':
72
+ return { fg: palette.merged, bg: palette.mergedSoft };
73
+ case 'neutral':
74
+ return { fg: palette.muted, bg: palette.neutralSoft };
75
+ }
76
+ }
77
+ export const statusTone = {
78
+ backlog: 'neutral',
79
+ todo: 'neutral',
80
+ in_progress: 'warning',
81
+ review: 'info',
82
+ blocked: 'danger',
83
+ done: 'success',
84
+ cancelled: 'neutral',
85
+ };
86
+ export const priorityTone = {
87
+ urgent: 'danger',
88
+ high: 'danger',
89
+ medium: 'warning',
90
+ low: 'neutral',
91
+ none: 'neutral',
92
+ };
93
+ /** Soy green, from young leaf to ripe pod. */
94
+ export const brandGradient = ['#D7E86A', '#A9D46F', '#7DBE6E', '#58A56B'];
95
+ export const symbols = {
96
+ pointer: '▌',
97
+ cursor: '▁',
98
+ dot: '·',
99
+ arrow: '→',
100
+ check: '✓',
101
+ cross: '✕',
102
+ active: '●',
103
+ rule: '─',
104
+ comment: '›',
105
+ ellipsis: '…',
106
+ /** Chat: a message waiting to be sent, a quoted reply, unread messages. */
107
+ pending: '⋯',
108
+ reply: '↳',
109
+ unread: '✉',
110
+ /** Checks still running. */
111
+ running: '◌',
112
+ pullRequest: '#',
113
+ chevron: '›',
114
+ link: '⇄',
115
+ info: 'ℹ',
116
+ /** Filled bar for gauges and scroll thumbs; light shade for their track. */
117
+ full: '█',
118
+ shade: '░',
119
+ };
120
+ export const priorityStyles = {
121
+ urgent: { label: PRIORITY_LABELS.urgent, color: 'redBright', bold: true },
122
+ high: { label: PRIORITY_LABELS.high, color: 'red' },
123
+ medium: { label: PRIORITY_LABELS.medium, color: 'yellow' },
124
+ low: { label: PRIORITY_LABELS.low, dim: true },
125
+ none: { label: '—', dim: true },
126
+ };
127
+ export const statusStyles = {
128
+ backlog: { label: STATUS_LABELS.backlog, glyph: '◌', dim: true },
129
+ todo: { label: STATUS_LABELS.todo, glyph: '○' },
130
+ in_progress: { label: STATUS_LABELS.in_progress, glyph: '◐', color: 'yellow' },
131
+ review: { label: STATUS_LABELS.review, glyph: '◎', color: 'cyan' },
132
+ blocked: { label: STATUS_LABELS.blocked, glyph: '⊘', color: 'red' },
133
+ done: { label: STATUS_LABELS.done, glyph: '✓', color: 'green' },
134
+ cancelled: { label: STATUS_LABELS.cancelled, glyph: '✕', dim: true },
135
+ };
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { SojaError } from '../domain/errors.js';
6
+ /** The user's editor: $VISUAL, then $EDITOR, then vi. May include arguments (`code --wait`). */
7
+ export function preferredEditor(env = process.env) {
8
+ return env.VISUAL?.trim() || env.EDITOR?.trim() || 'vi';
9
+ }
10
+ /**
11
+ * Opens `initial` in the user's editor, attached to the terminal, and
12
+ * returns the saved text (trailing newlines trimmed). Resolves to null when
13
+ * the editor exits with an error, so nothing is changed. The caller must
14
+ * have released the terminal (the TUI suspends itself first).
15
+ */
16
+ export async function editInEditor(initial, options) {
17
+ const dir = mkdtempSync(join(tmpdir(), 'soja-edit-'));
18
+ const file = join(dir, options.name);
19
+ try {
20
+ writeFileSync(file, initial.endsWith('\n') || !initial ? initial : `${initial}\n`, { mode: 0o600 });
21
+ const command = options.editor ?? preferredEditor();
22
+ const code = await new Promise((resolve, reject) => {
23
+ // Through the shell, so "code --wait" and similar editor settings work as in Git.
24
+ const child = spawn(`${command} "$SOJA_EDIT_FILE"`, { shell: true, stdio: 'inherit', env: { ...process.env, SOJA_EDIT_FILE: file } });
25
+ child.on('error', reject);
26
+ child.on('close', (exit) => resolve(exit ?? 1));
27
+ }).catch((error) => {
28
+ throw new SojaError(`Could not open your editor (${command}).`, { hint: 'Set $EDITOR, e.g. `export EDITOR=nano`.', cause: error });
29
+ });
30
+ if (code !== 0)
31
+ return null;
32
+ return readFileSync(file, 'utf8').replace(/\s+$/, '');
33
+ }
34
+ finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ }
@@ -0,0 +1,23 @@
1
+ import { SojaError } from '../domain/errors.js';
2
+ export function isDebug(env = process.env) {
3
+ return Boolean(env.SOJA_DEBUG && env.SOJA_DEBUG !== '0');
4
+ }
5
+ /** Turns anything thrown into something safe to show a person. */
6
+ export function toDisplayError(error, debug = isDebug()) {
7
+ const details = debug ? describeChain(error) : undefined;
8
+ if (error instanceof SojaError)
9
+ return { message: error.message, hint: error.hint, debug: details };
10
+ return {
11
+ message: 'Something went wrong.',
12
+ hint: debug ? undefined : 'Run again with SOJA_DEBUG=1 for details.',
13
+ debug: details,
14
+ };
15
+ }
16
+ function describeChain(error) {
17
+ const parts = [];
18
+ for (let current = error, depth = 0; current && depth < 5; depth += 1) {
19
+ parts.push(current instanceof Error ? (current.stack ?? current.message) : String(current));
20
+ current = current instanceof Error ? current.cause : undefined;
21
+ }
22
+ return parts.join('\n\ncaused by: ');
23
+ }
@@ -0,0 +1,60 @@
1
+ import { homedir } from 'node:os';
2
+ /** Greedy word wrap by character count. Long words are hard-split. */
3
+ export function wrapText(text, width) {
4
+ const size = Math.max(1, width);
5
+ const lines = [];
6
+ for (const paragraph of text.split(/\r?\n/)) {
7
+ let line = '';
8
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
9
+ for (let chunk = word; chunk; chunk = chunk.slice(size)) {
10
+ const piece = chunk.slice(0, size);
11
+ if (!line)
12
+ line = piece;
13
+ else if (line.length + 1 + piece.length <= size)
14
+ line += ` ${piece}`;
15
+ else {
16
+ lines.push(line);
17
+ line = piece;
18
+ }
19
+ }
20
+ }
21
+ lines.push(line);
22
+ }
23
+ return lines;
24
+ }
25
+ /** Keeps at most `max` lines, marking the cut with an ellipsis. */
26
+ export function clampLines(lines, max) {
27
+ if (lines.length <= max)
28
+ return [...lines];
29
+ const kept = lines.slice(0, Math.max(1, max));
30
+ kept[kept.length - 1] = `${kept[kept.length - 1] ?? ''}…`;
31
+ return kept;
32
+ }
33
+ export function truncate(text, width) {
34
+ if (text.length <= width)
35
+ return text;
36
+ return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
37
+ }
38
+ /** Expands a leading `~` (typed in the interface, where no shell does it). */
39
+ export function expandHome(path, home = homedir()) {
40
+ return path === '~' || path.startsWith('~/') ? `${home}${path.slice(1)}` : path;
41
+ }
42
+ /** Shortens paths under the home directory to `~/…`. */
43
+ export function tildify(path, home = homedir()) {
44
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
45
+ }
46
+ /**
47
+ * C0/C1 control characters except tab and newline (these carry ANSI escape
48
+ * sequences: colors, cursor moves, window titles, clipboard writes), plus the
49
+ * Unicode bidi controls used to disguise text ("Trojan Source").
50
+ */
51
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
52
+ const UNSAFE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u202A-\u202E\u2066-\u2069]/g;
53
+ /**
54
+ * Makes text written by someone else safe to print in a terminal. Applied to
55
+ * everything that comes from a SOJA server and to Git output from other
56
+ * people's commits.
57
+ */
58
+ export function terminalSafe(text) {
59
+ return text.replace(/\r\n?/g, '\n').replace(UNSAFE, '');
60
+ }