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
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ SOJA PROPRIETARY SOFTWARE LICENSE
2
+
3
+ Copyright (c) 2026 Enmauel.biz (GitHub: Mike28108). All rights reserved.
4
+
5
+ This package is proprietary. The copyright holder grants the recipient a
6
+ limited, non-exclusive, non-transferable license to install and run the
7
+ unmodified SOJA CLI package for its intended purpose. No other rights are
8
+ granted. In particular, redistribution, sublicensing, publication of modified
9
+ versions, and use of the source code to create a competing product require
10
+ prior written permission from the copyright holder.
11
+
12
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
13
+ IMPLIED, TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. IN NO EVENT SHALL
14
+ THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY
15
+ ARISING FROM THE SOFTWARE OR ITS USE.
16
+
17
+ For licensing inquiries, contact the copyright holder through the project
18
+ repository.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # SOJA
2
+
3
+ ```
4
+ ▄█▀▀▀▀▀▀ ▄█▀▀▀▀█▄ ██ ▄█▀▀▀▀█▄
5
+ ▀▀▀▀▀█▄ ██ ██ ██ ██▄▄▄▄██
6
+ ▄▄▄▄▄▄█▀ ▀█▄▄▄▄█▀ ▀█▄▄▄▄█▀ ██ ██ ▄▄▄
7
+ ```
8
+
9
+ **Software Operations & Job Assistant** · v1.4.0 · by Enmauel.biz
10
+
11
+ > No dashboards. No browser. No bullshit. Just work.
12
+
13
+ SOJA es un gestor de tareas para equipos de desarrollo que vive en la terminal. Convierte las solicitudes que llegan por WhatsApp, reuniones o mensajes en tareas técnicas trazables en segundos, sin salir de la shell.
14
+
15
+ - **Terminal-native, con aspecto de aplicación:** interfaz TUI con React + Ink, con paneles, barra lateral, ventanas flotantes, tema claro/oscuro y mouse; todo se maneja también con el teclado y atajos estilo Vim.
16
+ - **Rápido:** para crear una task basta el título.
17
+ - **Trazable:** cada cambio queda en el timeline de la task, junto a los comentarios.
18
+ - **Local-first:** tus datos viven en SQLite en tu máquina. En equipo, `soja login` conecta con el servidor de SOJA y sigue funcionando sin conexión.
19
+ - **Chat del equipo** (modo remoto): canales en tiempo real junto al trabajo, con menciones, referencias `SOJA-12` y tasks creadas desde un mensaje. `#` lo abre.
20
+ - **Git-aware:** branch, commit, push, PR y merge desde la task, con log en vivo y errores con sugerencias. Ve el estado de cada PR y su CI en GitHub y hace el merge sin salir de la terminal. Usa tus credenciales de Git y `gh`.
21
+ - **Scriptable:** una CLI no interactiva que usa los mismos servicios que la interfaz.
22
+
23
+ ## Instalar
24
+
25
+ Requiere **Node.js 24+**:
26
+
27
+ ```bash
28
+ npm install --global soja-cli
29
+ soja
30
+ ```
31
+
32
+ `soja update` instala versiones nuevas (SOJA avisa cuando hay una).
33
+
34
+ ## Acceso al servicio
35
+
36
+ SOJA funciona de dos maneras:
37
+
38
+ - **Modo local:** no necesita cuenta ni solicitud. Instalas, ejecutas `soja` y empiezas; tus datos se quedan en tu máquina.
39
+ - **Modo en equipo** (servidor de SOJA): workspaces compartidos, sincronización entre máquinas y chat del equipo.
40
+
41
+ **En esta primera etapa, el acceso al modo en equipo es por solicitud:**
42
+
43
+ 1. Inicia sesión con GitHub: `soja login` (o desde la pantalla de bienvenida de `soja`).
44
+ 2. Completa la solicitud: nombre, fecha de nacimiento, país y una carta breve (hasta 100 caracteres) sobre por qué te interesa SOJA.
45
+ 3. La revisamos en un plazo aproximado de **24 a 48 horas** y se aprueba si encaja con esta etapa del proyecto.
46
+
47
+ Mientras tanto SOJA sigue en modo local, sin perder nada de lo que hagas. Consulta el estado con `soja access status`. Cuando se apruebe, vuelve a ejecutar `soja login` para elegir o crear tu workspace; `soja import-local` sube a ese workspace lo que hiciste en modo local. Si una solicitud se rechaza, puedes enviar otra con `soja access request`.
48
+
49
+ Puedes borrar tu cuenta en cualquier momento con `soja account delete`.
50
+
51
+ SOJA es software propietario. La instalación permite usar el paquete sin conceder derechos de redistribución o modificación; consulta [LICENSE](LICENSE). El aviso de privacidad está en [docs/PRIVACY.md](docs/PRIVACY.md).
52
+
53
+ ## Desde el código
54
+
55
+ El código es público para que puedas revisarlo y verificar el paquete de npm (se publica con *provenance* desde este repositorio). Las contribuciones están limitadas a colaboradores aprobados; consulta [CONTRIBUTING.md](CONTRIBUTING.md). La configuración de publicación está en [docs/NPM_PUBLISH.md](docs/NPM_PUBLISH.md).
56
+
57
+
58
+ ```bash
59
+ git clone https://github.com/Mike28108/soja-cli.git
60
+ cd soja-cli
61
+ npm install
62
+ npm run dev # la primera vez te hace 3 preguntas y listo
63
+ ```
64
+
65
+ ¿Quieres verlo con datos? `npm run db:seed` y luego `npm run dev`.
66
+
67
+ Para tener el comando `soja` en tu sistema:
68
+
69
+ ```bash
70
+ npm run build && npm link
71
+ ```
72
+
73
+ ## Uso en 30 segundos
74
+
75
+ | Tecla | Acción |
76
+ | --- | --- |
77
+ | `j` / `k` · `enter` · `esc` | moverse · abrir · volver |
78
+ | `n` | nueva task |
79
+ | `/` | buscar |
80
+ | `:` o `Ctrl+K` | command palette |
81
+ | `h` / `l` o `1`–`7` | cambiar filtro |
82
+ | `s` · `p` · `a` · `c` · `e` · `x` | estado · prioridad · asignar · comentar · editar · done |
83
+ | `?` · `q` | ayuda · salir |
84
+
85
+ ```bash
86
+ soja task create "Fix Stripe webhook" --project enroll --priority high --requester Finance
87
+ soja task list --all
88
+ soja folders add ~/workspace/products # carpetas donde viven tus repos
89
+ soja project link ENROLL enrollbridge # o en la TUI: p → r → elegir de la lista
90
+ soja start SOJA-12 # crea/cambia a su branch Git y la pasa a In Progress
91
+ soja commit SOJA-12 -m "Fix" --all
92
+ soja pr SOJA-12 # push + pull request con gh
93
+ soja merge SOJA-12 --delete --done
94
+ ```
95
+
96
+ ## Documentación
97
+
98
+ - 📘 **[docs/SOJA.md](docs/SOJA.md)**: la documentación completa (conceptos, interfaz, atajos, CLI, configuración, modelo de datos, arquitectura y guía de desarrollo).
99
+ - 📝 **[CHANGELOG.md](CHANGELOG.md)**: los cambios de cada versión.
100
+ - 🗺️ **[ROADMAP.md](ROADMAP.md)**: hitos publicados y próximos.
101
+
102
+ ## Desarrollo
103
+
104
+ ```bash
105
+ npm run dev # ejecutar desde el código fuente
106
+ npm test # tests (Vitest)
107
+ npm run lint # typecheck + ESLint
108
+ npm run build # compilar a dist/
109
+ npm run db:seed # datos demo
110
+ npm run db:reset # borrar los datos locales (pide confirmación)
111
+ ```
112
+
113
+ Stack: TypeScript · React · Ink · `node:sqlite` · Drizzle ORM · Zod · Vitest.
114
+
115
+ ---
116
+
117
+ © Enmauel.biz. Software propietario: ver [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ /** The tabs of the task list, in order. */
2
+ export const TASK_FILTERS = ['mine', 'all', 'todo', 'in_progress', 'review', 'blocked', 'done'];
3
+ export const FILTER_LABELS = {
4
+ mine: 'My Tasks',
5
+ all: 'All Tasks',
6
+ todo: 'Todo',
7
+ in_progress: 'In Progress',
8
+ review: 'Review',
9
+ blocked: 'Blocked',
10
+ done: 'Done',
11
+ archived: 'Archived',
12
+ };
13
+ const OPEN_STATUSES = ['backlog', 'todo', 'in_progress', 'review', 'blocked'];
14
+ /** Turns a named filter into a repository query. Status filters span the whole workspace. */
15
+ export function filterToQuery(session, filter) {
16
+ const workspaceId = session.workspace.id;
17
+ switch (filter) {
18
+ case 'mine':
19
+ return { workspaceId, assigneeId: session.user.id, statuses: OPEN_STATUSES };
20
+ case 'all':
21
+ return { workspaceId, statuses: OPEN_STATUSES };
22
+ case 'archived':
23
+ return { workspaceId, archived: 'only' };
24
+ default:
25
+ return { workspaceId, statuses: [filter] };
26
+ }
27
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { ValidationError } from '../domain/errors.js';
4
+ import { expandHome, tildify } from '../utils/text.js';
5
+ /** The top-level folder of the Git repository containing `path` (`~` allowed), or a friendly error. */
6
+ export async function resolveRepositoryRoot(git, path) {
7
+ const absolute = resolve(expandHome(path.trim()));
8
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
9
+ throw new ValidationError(`${tildify(absolute)} does not exist or is not a folder.`);
10
+ }
11
+ const root = await git.repositoryRoot(absolute);
12
+ if (!root) {
13
+ throw new ValidationError(`${tildify(absolute)} is not inside a Git repository.`, {
14
+ hint: 'Run `git init` there first, or point to the repository folder.',
15
+ });
16
+ }
17
+ return root;
18
+ }
@@ -0,0 +1,93 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { NotFoundError, ValidationError } from '../../domain/errors.js';
4
+ const AUTO_EVERY_MS = 24 * 60 * 60 * 1000;
5
+ const AUTO_KEEP = 7;
6
+ const NAME = /^soja-(.+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:-\d{3})?)-(auto|manual|before-restore)\.db$/;
7
+ /**
8
+ * Copies of the database SOJA is using. In remote mode that is the replica,
9
+ * which matters for the changes still waiting to be sent.
10
+ */
11
+ export class BackupService {
12
+ source;
13
+ dir;
14
+ clock;
15
+ constructor(source, dir, clock = () => new Date()) {
16
+ this.source = source;
17
+ this.dir = dir;
18
+ this.clock = clock;
19
+ }
20
+ create(kind = 'manual') {
21
+ mkdirSync(this.dir, { recursive: true });
22
+ const path = join(this.dir, fileName(this.source.label, this.clock(), kind));
23
+ this.source.backupTo(path);
24
+ return describe(path);
25
+ }
26
+ /** Newest first, only this database's copies. */
27
+ list() {
28
+ if (!existsSync(this.dir))
29
+ return [];
30
+ return readdirSync(this.dir)
31
+ .filter((name) => NAME.exec(name)?.[1] === safeLabel(this.source.label))
32
+ .map((name) => describe(join(this.dir, name)))
33
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
34
+ }
35
+ /** A daily copy when SOJA opens; keeps the last seven. Returns the copy made, if any. */
36
+ auto() {
37
+ const autos = this.list().filter((backup) => backup.kind === 'auto');
38
+ const latest = autos[0];
39
+ if (latest && this.clock().getTime() - latest.createdAt.getTime() < AUTO_EVERY_MS)
40
+ return null;
41
+ const made = this.create('auto');
42
+ for (const old of [made, ...autos].slice(AUTO_KEEP))
43
+ rmSync(old.path, { force: true });
44
+ return made;
45
+ }
46
+ find(name) {
47
+ const backup = this.list().find((candidate) => candidate.name === name || candidate.name === `${name}.db`);
48
+ if (!backup)
49
+ throw new NotFoundError(`There is no backup called ${name}.`, { hint: 'See them with `soja backup list`.' });
50
+ return backup;
51
+ }
52
+ }
53
+ /**
54
+ * Puts a backup in place of `databaseFile`, keeping a copy of the current
55
+ * state first. SOJA must not have the database open (run it from a command
56
+ * that did not open one).
57
+ */
58
+ export function restoreBackup(backup, databaseFile, dir, label, clock = () => new Date()) {
59
+ if (!existsSync(backup.path))
60
+ throw new ValidationError(`${backup.name} no longer exists.`);
61
+ let saved = null;
62
+ if (existsSync(databaseFile)) {
63
+ const path = join(dir, fileName(label, clock(), 'before-restore'));
64
+ copyFileSync(databaseFile, path);
65
+ saved = describe(path);
66
+ }
67
+ // The write-ahead log belongs to the database being replaced.
68
+ for (const suffix of ['-wal', '-shm'])
69
+ rmSync(`${databaseFile}${suffix}`, { force: true });
70
+ mkdirSync(join(databaseFile, '..'), { recursive: true });
71
+ copyFileSync(backup.path, databaseFile);
72
+ return saved;
73
+ }
74
+ function fileName(label, at, kind) {
75
+ return `soja-${safeLabel(label)}-${at.toISOString().replace(/[:.]/g, '-').replace(/Z$/, '')}-${kind}.db`;
76
+ }
77
+ function safeLabel(label) {
78
+ return label.replace(/[^a-z0-9.]+/gi, '_');
79
+ }
80
+ function describe(path) {
81
+ const name = basename(path);
82
+ const match = NAME.exec(name);
83
+ const stamp = match?.[2] ?? '';
84
+ const [date = '', time = ''] = stamp.split('T');
85
+ const [hours = '00', minutes = '00', seconds = '00', millis = '000'] = time.split('-');
86
+ return {
87
+ name,
88
+ path,
89
+ createdAt: new Date(`${date}T${hours}:${minutes}:${seconds}.${millis}Z`),
90
+ bytes: statSync(path).size,
91
+ kind: match?.[3] ?? 'manual',
92
+ };
93
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { ConflictError, NotFoundError, SojaError, ValidationError } from '../../domain/errors.js';
4
+ import { expandHome, tildify } from '../../utils/text.js';
5
+ /**
6
+ * Parent folders ("where my repositories live") and their subfolders, for
7
+ * picking a project repository from a list instead of typing paths.
8
+ * Everything here is local to this machine.
9
+ */
10
+ export class FolderService {
11
+ config;
12
+ constructor(config) {
13
+ this.config = config;
14
+ }
15
+ list() {
16
+ return this.config.load()?.parentFolders ?? [];
17
+ }
18
+ add(path) {
19
+ if (!path.trim())
20
+ throw new ValidationError('Type the path of a folder.');
21
+ const absolute = resolve(expandHome(path.trim()));
22
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
23
+ throw new ValidationError(`${tildify(absolute)} does not exist or is not a folder.`);
24
+ }
25
+ const folders = this.list();
26
+ if (folders.includes(absolute))
27
+ throw new ConflictError(`${tildify(absolute)} is already a parent folder.`);
28
+ this.save([...folders, absolute]);
29
+ return this.list();
30
+ }
31
+ /** Removes by exact path (`~` allowed) or by folder name when that is unambiguous. */
32
+ remove(pathOrName) {
33
+ const target = this.find(pathOrName);
34
+ this.save(this.list().filter((folder) => folder !== target));
35
+ return this.list();
36
+ }
37
+ /** Every parent folder with its immediate subfolders, like `ls -1` (hidden folders skipped). */
38
+ browse() {
39
+ return this.list().map((path) => {
40
+ let names;
41
+ try {
42
+ names = readdirSync(path, { withFileTypes: true })
43
+ .filter((entry) => !entry.name.startsWith('.') && isDirectory(path, entry.name, entry.isDirectory(), entry.isSymbolicLink()))
44
+ .map((entry) => entry.name)
45
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
46
+ }
47
+ catch {
48
+ return { path, available: false, children: [] };
49
+ }
50
+ return {
51
+ path,
52
+ available: true,
53
+ children: names.map((name) => {
54
+ const full = join(path, name);
55
+ return { name, path: safeRealpath(full), isGitRepository: existsSync(join(full, '.git')) };
56
+ }),
57
+ };
58
+ });
59
+ }
60
+ /**
61
+ * Resolves a subfolder by name across all parent folders, e.g.
62
+ * `enrollbridge` → `~/workspace/products/enrollbridge`.
63
+ */
64
+ findChild(name) {
65
+ const matches = this.browse().flatMap((parent) => parent.children.filter((child) => child.name === name.trim()));
66
+ const [first, second] = matches;
67
+ if (!first) {
68
+ throw new NotFoundError(`No folder called “${name}” inside your parent folders.`, {
69
+ hint: this.list().length ? 'List them with `soja folders list`.' : 'Add one with `soja folders add <path>`.',
70
+ });
71
+ }
72
+ if (second) {
73
+ throw new ConflictError(`“${name}” exists in more than one parent folder.`, {
74
+ hint: `Use the full path: ${matches.map((match) => tildify(match.path)).join(' or ')}`,
75
+ });
76
+ }
77
+ return first;
78
+ }
79
+ find(pathOrName) {
80
+ const folders = this.list();
81
+ const absolute = resolve(expandHome(pathOrName.trim()));
82
+ if (folders.includes(absolute))
83
+ return absolute;
84
+ const byName = folders.filter((folder) => basename(folder) === pathOrName.trim());
85
+ if (byName.length === 1 && byName[0])
86
+ return byName[0];
87
+ throw new NotFoundError(`${pathOrName} is not one of your parent folders.`, { hint: 'List them with `soja folders list`.' });
88
+ }
89
+ save(parentFolders) {
90
+ const current = this.config.load();
91
+ if (!current) {
92
+ throw new SojaError('SOJA is not set up yet.', { hint: 'Run `soja` once to create your user and workspace.' });
93
+ }
94
+ this.config.save({ ...current, parentFolders });
95
+ }
96
+ }
97
+ function isDirectory(parent, name, isDir, isLink) {
98
+ if (isDir)
99
+ return true;
100
+ if (!isLink)
101
+ return false;
102
+ try {
103
+ return statSync(join(parent, name)).isDirectory();
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function safeRealpath(path) {
110
+ try {
111
+ return realpathSync(path);
112
+ }
113
+ catch {
114
+ return path;
115
+ }
116
+ }