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.
- package/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- 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
|
+
}
|