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,83 @@
1
+ import { PRIORITY_LABELS, STATUS_LABELS, TYPE_LABELS } from '../domain/task.js';
2
+ /** Merges activity and comments into one chronological timeline. */
3
+ export function buildTimeline(activity, comments, lookups) {
4
+ const user = (id) => (id ? (lookups.users.get(id) ?? null) : null);
5
+ const entries = [];
6
+ for (const item of activity) {
7
+ // The comment itself is a richer entry than "added a comment".
8
+ if (item.type === 'comment_added')
9
+ continue;
10
+ entries.push({ kind: 'event', id: item.id, at: item.createdAt, actor: user(item.userId), text: describe(item, lookups), event: { type: item.type, metadata: item.metadata } });
11
+ }
12
+ for (const comment of comments) {
13
+ entries.push({ kind: 'comment', id: comment.id, at: comment.createdAt, actor: user(comment.userId), body: comment.body });
14
+ }
15
+ // Stable sort keeps insertion order for entries written in the same millisecond.
16
+ return entries.sort((a, b) => a.at.getTime() - b.at.getTime());
17
+ }
18
+ export function describe(item, lookups) {
19
+ const who = (id) => (id ? `@${lookups.users.get(id)?.username ?? 'someone'}` : 'nobody');
20
+ const project = (id) => (id ? (lookups.projects.get(id)?.name ?? 'a removed project') : null);
21
+ switch (item.type) {
22
+ case 'task_created':
23
+ return 'created the task';
24
+ case 'status_changed':
25
+ return `moved ${STATUS_LABELS[item.metadata.from]} → ${STATUS_LABELS[item.metadata.to]}`;
26
+ case 'task_completed':
27
+ return `completed it · ${STATUS_LABELS[item.metadata.from]} → Done`;
28
+ case 'task_reopened':
29
+ return `reopened it · ${STATUS_LABELS[item.metadata.from]} → ${STATUS_LABELS[item.metadata.to]}`;
30
+ case 'priority_changed':
31
+ return `set priority ${PRIORITY_LABELS[item.metadata.from]} → ${PRIORITY_LABELS[item.metadata.to]}`;
32
+ case 'assigned':
33
+ return item.metadata.from ? `reassigned ${who(item.metadata.from)} → ${who(item.metadata.to)}` : `assigned ${who(item.metadata.to)}`;
34
+ case 'unassigned':
35
+ return `unassigned ${who(item.metadata.from)}`;
36
+ case 'project_changed': {
37
+ const to = project(item.metadata.to);
38
+ return to ? `moved it to ${to}` : 'removed it from its project';
39
+ }
40
+ case 'comment_added':
41
+ return 'added a comment';
42
+ case 'git_committed':
43
+ return `committed ${item.metadata.hash.slice(0, 7)} “${item.metadata.subject}”`;
44
+ case 'git_merged':
45
+ return `merged ${item.metadata.branch} into ${item.metadata.into}`;
46
+ case 'git_branch_deleted':
47
+ return `deleted branch ${item.metadata.branch}${item.metadata.merged ? '' : ' (unmerged)'}`;
48
+ case 'git_pushed':
49
+ return `pushed ${item.metadata.branch} to ${item.metadata.remote}`;
50
+ case 'git_merge_detected':
51
+ return `merge into ${item.metadata.into} detected (done outside SOJA)`;
52
+ case 'pr_opened':
53
+ return `opened a pull request ${item.metadata.url}`;
54
+ case 'task_archived':
55
+ return 'archived the task';
56
+ case 'task_unarchived':
57
+ return 'restored the task from the archive';
58
+ case 'pr_merged':
59
+ return item.metadata.via === 'soja'
60
+ ? `merged PR #${item.metadata.number} into ${item.metadata.into} on GitHub`
61
+ : `PR #${item.metadata.number} was merged into ${item.metadata.into} on GitHub`;
62
+ case 'pr_checks_failed':
63
+ return `checks failed on PR #${item.metadata.number} (${item.metadata.sha.slice(0, 7)}): ${item.metadata.checks.join(', ') || 'see GitHub'}`;
64
+ case 'task_updated': {
65
+ const { field, from, to } = item.metadata;
66
+ switch (field) {
67
+ case 'title':
68
+ return `renamed it to “${to ?? ''}”`;
69
+ case 'description':
70
+ return to ? 'edited the description' : 'cleared the description';
71
+ case 'type':
72
+ return `changed type ${typeLabel(from)} → ${typeLabel(to)}`;
73
+ case 'requester':
74
+ return to ? `set requester to ${to}` : 'cleared the requester';
75
+ case 'branch':
76
+ return to ? `linked branch ${to}` : 'unlinked the branch';
77
+ }
78
+ }
79
+ }
80
+ }
81
+ function typeLabel(value) {
82
+ return value && value in TYPE_LABELS ? TYPE_LABELS[value] : (value ?? '—');
83
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { ValidationError } from '../domain/errors.js';
3
+ /** Trimmed optional text: blank becomes null, absent stays undefined. */
4
+ export const optionalText = (max) => z
5
+ .string()
6
+ .trim()
7
+ .max(max, { error: `Keep it under ${max} characters.` })
8
+ .nullable()
9
+ .optional()
10
+ .transform((value) => (value === undefined ? undefined : value || null));
11
+ export const usernameSchema = z
12
+ .string()
13
+ .trim()
14
+ .toLowerCase()
15
+ .regex(/^[a-z0-9][a-z0-9._-]{0,31}$/, {
16
+ error: 'Usernames use lowercase letters, numbers, dots, dashes or underscores (max 32).',
17
+ });
18
+ export const displayNameSchema = z
19
+ .string()
20
+ .trim()
21
+ .min(1, { error: 'A name is required.' })
22
+ .max(60, { error: 'Keep the name under 60 characters.' });
23
+ export function parseInput(schema, input) {
24
+ const result = schema.safeParse(input);
25
+ if (result.success)
26
+ return result.data;
27
+ const issue = result.error.issues[0];
28
+ const field = issue?.path.length ? `${issue.path.join('.')}: ` : '';
29
+ throw new ValidationError(`${field}${issue?.message ?? 'Invalid input.'}`);
30
+ }
@@ -0,0 +1,91 @@
1
+ import { FileConfigStore } from './config/config.js';
2
+ import { resolvePaths } from './config/paths.js';
3
+ import { createServices } from './application/services/index.js';
4
+ import { createLocalRepositories } from './data/local/index.js';
5
+ import { openDatabase } from './database/client.js';
6
+ import { runMigrations } from './database/migrate.js';
7
+ import { CliGit } from './git/cli-git.js';
8
+ import { GitConsole } from './git/console.js';
9
+ import { CredentialStore } from './config/credentials.js';
10
+ import { ApiClient } from './data/remote/api-client.js';
11
+ import { createReplicaServices, replicaFile } from './data/sync/index.js';
12
+ import { SojaError } from './domain/errors.js';
13
+ import { BackupService } from './application/services/backup-service.js';
14
+ import { UpdateService } from './application/services/update-service.js';
15
+ import { NpmReleases } from './git/releases.js';
16
+ import { APP_VERSION } from './ui/branding/brand.js';
17
+ import { join } from 'node:path';
18
+ /**
19
+ * Composition root: the only place that knows the data source is local
20
+ * SQLite. A remote mode would branch here and hand the same services a
21
+ * different set of repositories.
22
+ */
23
+ export async function bootstrap(options = {}) {
24
+ const paths = options.paths ?? resolvePaths();
25
+ const config = options.config ?? new FileConfigStore(paths.configFile);
26
+ const gitConsole = new GitConsole();
27
+ const git = options.git ?? new CliGit(gitConsole);
28
+ const loaded = config.load();
29
+ if (loaded?.mode === 'remote' && loaded.remote && !options.forceLocal) {
30
+ const credentials = options.credentials ?? new CredentialStore(paths.credentialsFile);
31
+ const token = credentials.token(loaded.remote.apiUrl);
32
+ if (!token) {
33
+ throw new SojaError(`Not signed in to ${loaded.remote.apiUrl}.`, {
34
+ hint: `Run \`soja login --server ${loaded.remote.apiUrl}\`, or \`soja mode local\` to work locally.`,
35
+ });
36
+ }
37
+ const api = new ApiClient(loaded.remote.apiUrl, token, options.fetch);
38
+ // The replica is plain SQLite with the local schema plus sync tables.
39
+ const replica = openDatabase(options.replicaFile ?? replicaFile(paths.dataDir, loaded.remote.apiUrl), { foreignKeys: false });
40
+ try {
41
+ await runMigrations(replica);
42
+ }
43
+ catch (error) {
44
+ replica.close();
45
+ throw error;
46
+ }
47
+ const services = createReplicaServices(api, replica, config, git, gitConsole, options.WebSocket, paths.databaseFile, new BackupService({ label: new URL(loaded.remote.apiUrl).host, backupTo: (file) => replica.backupTo(file) }, backupDir(paths)));
48
+ return {
49
+ services,
50
+ paths,
51
+ close: () => {
52
+ services.sync.stop();
53
+ replica.close();
54
+ },
55
+ };
56
+ }
57
+ const handle = openDatabase(paths.databaseFile);
58
+ try {
59
+ await runMigrations(handle);
60
+ }
61
+ catch (error) {
62
+ handle.close();
63
+ throw error;
64
+ }
65
+ const repos = createLocalRepositories(handle, options.clock);
66
+ const services = createServices(repos, config, {
67
+ backups: new BackupService({ label: 'local', backupTo: (file) => handle.backupTo(file) }, backupDir(paths), options.clock),
68
+ git,
69
+ gitConsole,
70
+ ...(options.clock ? { clock: options.clock } : {}),
71
+ });
72
+ return { services, paths, close: () => handle.close() };
73
+ }
74
+ export function backupDir(paths) {
75
+ return join(paths.dataDir, 'backups');
76
+ }
77
+ /**
78
+ * The database the current mode uses, for restoring a backup without opening
79
+ * it: the local one, or the replica of the configured server.
80
+ */
81
+ export function activeDatabase(paths = resolvePaths(), config = new FileConfigStore(paths.configFile)) {
82
+ const loaded = config.load();
83
+ if (loaded?.mode === 'remote' && loaded.remote) {
84
+ return { file: replicaFile(paths.dataDir, loaded.remote.apiUrl), label: new URL(loaded.remote.apiUrl).host };
85
+ }
86
+ return { file: paths.databaseFile, label: 'local' };
87
+ }
88
+ /** Newer SOJA versions, from its GitHub releases (checked at most daily for notices). */
89
+ export function createUpdater(paths = resolvePaths()) {
90
+ return new UpdateService(new NpmReleases(), APP_VERSION, join(paths.dataDir, 'update-check.json'));
91
+ }
@@ -0,0 +1,60 @@
1
+ import { FileConfigStore } from '../../config/config.js';
2
+ import { CredentialStore } from '../../config/credentials.js';
3
+ import { resolvePaths } from '../../config/paths.js';
4
+ import { ApiClient } from '../../data/remote/api-client.js';
5
+ import { requestAccess } from './account.js';
6
+ import { SojaError, ValidationError } from '../../domain/errors.js';
7
+ import { bold, dim, print, success } from '../output.js';
8
+ import { parseCommand } from './args.js';
9
+ const stores = () => {
10
+ const paths = resolvePaths();
11
+ return { config: new FileConfigStore(paths.configFile), credentials: new CredentialStore(paths.credentialsFile) };
12
+ };
13
+ export async function accessCommand(args) {
14
+ const { positionals } = parseCommand(args, {});
15
+ const action = positionals[0] ?? 'status';
16
+ const { config, credentials } = stores();
17
+ const settings = config.load()?.remote;
18
+ const token = settings ? credentials.token(settings.apiUrl) : null;
19
+ if (!settings || !token)
20
+ throw new SojaError('No hay una sesión GitHub de SOJA guardada.', { hint: 'Ejecuta `soja login --server <url>`.' });
21
+ const api = new ApiClient(settings.apiUrl, token);
22
+ if (action === 'status') {
23
+ const [state, profile] = await Promise.all([
24
+ api.get('/v1/access-request'),
25
+ api.get('/v1/profile'),
26
+ ]);
27
+ print(`Acceso Online: ${bold(state.status)}${profile.user.isCeo ? ` ${bold('CEO de SOJA')}` : ''}`);
28
+ if (state.request)
29
+ print(dim(`Solicitud enviada: ${state.request.submittedAt}`));
30
+ return;
31
+ }
32
+ if (action === 'request') {
33
+ const { user } = await api.get('/v1/profile');
34
+ await requestAccess(api, user);
35
+ return;
36
+ }
37
+ if (action === 'approvals') {
38
+ const result = await api.get('/v1/admin/access-requests');
39
+ if (!result.requests.length) {
40
+ print('No hay solicitudes pendientes.');
41
+ return;
42
+ }
43
+ for (const request of result.requests) {
44
+ print(`${bold(`@${request.username}`)} · ${request.displayName} · ${request.countryCode ?? 'sin país'}`);
45
+ print(` ${request.letter} ${dim(`(${request.submittedAt})`)}`);
46
+ print(` ID: ${request.id}`);
47
+ }
48
+ print(dim('Usa `soja access approve <id>` o `soja access reject <id>`.'));
49
+ return;
50
+ }
51
+ if (action === 'approve' || action === 'reject') {
52
+ const id = positionals[1];
53
+ if (!id)
54
+ throw new ValidationError(`Indica el ID: soja access ${action} <id>`);
55
+ const result = await api.post(`/v1/admin/access-requests/${id}/${action}`);
56
+ success(`Solicitud ${result.status}.`);
57
+ return;
58
+ }
59
+ throw new ValidationError('Usa `soja access status|request|approvals|approve <id>|reject <id>`.');
60
+ }
@@ -0,0 +1,203 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { FileConfigStore } from '../../config/config.js';
5
+ import { CredentialStore, normalize } 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 { symbols } from '../../ui/theme/theme.js';
11
+ import { bold, color, dim, print, success } from '../output.js';
12
+ import { withSession } from '../runtime.js';
13
+ import { parseCommand } from './args.js';
14
+ import { confirmTyped } from '../prompt.js';
15
+ async function ask(question) {
16
+ if (!stdin.isTTY)
17
+ throw new ValidationError('El alta de acceso necesita una terminal interactiva.');
18
+ const rl = createInterface({ input: stdin, output: stdout });
19
+ try {
20
+ return (await rl.question(question)).trim();
21
+ }
22
+ finally {
23
+ rl.close();
24
+ }
25
+ }
26
+ export async function requestAccess(api, user) {
27
+ const current = await api.get('/v1/access-request');
28
+ const countries = await api.get('/v1/auth/countries');
29
+ const displayName = await ask(`Nombre [${current.profile?.displayName ?? user.displayName}]: `) || current.profile?.displayName || user.displayName;
30
+ const dateOfBirth = await ask('Fecha de nacimiento (AAAA-MM-DD): ');
31
+ const letter = await ask('¿Por qué te interesa SOJA? (máximo 100 caracteres): ');
32
+ if (Array.from(letter).length > 100)
33
+ throw new ValidationError('La carta no puede superar 100 caracteres.');
34
+ const search = await ask('País (escribe para filtrar): ');
35
+ const matches = countries.countries.filter((country) => country.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
36
+ if (!matches.length)
37
+ throw new ValidationError('No encontré países con ese texto. Ejecuta de nuevo `soja login`.');
38
+ matches.slice(0, 12).forEach((country, index) => print(` ${index + 1}. ${country.flag} ${country.name} (${country.code})`));
39
+ const selected = Number(await ask('Elige el número del país: '));
40
+ const country = matches[selected - 1];
41
+ if (!country || selected > 12)
42
+ throw new ValidationError('Selecciona uno de los países mostrados.');
43
+ const result = await api.post('/v1/access-request', { displayName, dateOfBirth, countryCode: country.code, letter });
44
+ print(result.status === 'approved' ? 'Acceso aprobado.' : 'Solicitud enviada. Tu sesión queda limitada hasta que el CEO la apruebe.');
45
+ }
46
+ const stores = () => {
47
+ const paths = resolvePaths();
48
+ return { config: new FileConfigStore(paths.configFile), credentials: new CredentialStore(paths.credentialsFile) };
49
+ };
50
+ const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
51
+ /** `soja login --server <url>`: GitHub device flow through the SOJA server; switches to remote mode. */
52
+ export async function loginCommand(args) {
53
+ const { values } = parseCommand(args, { server: { type: 'string', short: 's' } });
54
+ const { config, credentials } = stores();
55
+ const current = config.load();
56
+ const server = normalize(values.server ?? current?.remote?.apiUrl ?? DEFAULT_SERVER_URL);
57
+ if (!isSecureServerUrl(server)) {
58
+ throw new ValidationError('La URL del servidor SOJA debe usar HTTPS.', { hint: 'HTTP solo se permite en localhost para desarrollo. Usa `soja login --server <url>` para indicar otro servidor.' });
59
+ }
60
+ const api = new ApiClient(server, null);
61
+ const health = await api.get('/v1/health').catch((error) => {
62
+ throw new SojaError(`${server} does not look like a SOJA server.`, {
63
+ hint: 'Use the base URL of your soja-backend, e.g. https://soja-backend-production.up.railway.app',
64
+ cause: error,
65
+ });
66
+ });
67
+ if (health?.status !== 'ok') {
68
+ throw new SojaError(`${server} does not look like a SOJA server.`, { hint: 'Its /v1/health did not answer {"status":"ok"}.' });
69
+ }
70
+ const start = await api.post('/v1/auth/device');
71
+ print(`${bold('Sign in with GitHub')} ${dim(`(SOJA server ${health.version} at ${server})`)}`);
72
+ print();
73
+ print(` 1. Open ${color('cyan', start.verificationUri)}`);
74
+ print(` 2. Enter ${bold(color('green', start.userCode))}`);
75
+ print();
76
+ print(dim('Waiting for approval… (ctrl+c to cancel)'));
77
+ let interval = start.interval;
78
+ const deadline = Date.now() + start.expiresIn * 1000;
79
+ while (Date.now() < deadline) {
80
+ await sleep(interval);
81
+ const poll = await api.postWithStatus('/v1/auth/device/token', {
82
+ deviceCode: start.deviceCode,
83
+ });
84
+ if (poll.status === 202) {
85
+ if (poll.body.status === 'slow_down')
86
+ interval += 5;
87
+ continue;
88
+ }
89
+ if (!poll.body.token || !poll.body.user)
90
+ throw new SojaError('The server did not return a session.');
91
+ const signedInUser = poll.body.user;
92
+ const accountApi = new ApiClient(server, poll.body.token);
93
+ const account = await accountApi.get('/v1/me');
94
+ if (account.user.accessStatus !== 'approved') {
95
+ credentials.save(server, poll.body.token, signedInUser.username);
96
+ config.save({ parentFolders: [], ...current, mode: 'local', remote: { apiUrl: server, userId: signedInUser.id } });
97
+ print(`Sesión GitHub iniciada como ${bold(`@${signedInUser.username}`)}. Estado: ${account.user.accessStatus ?? 'pending'}.`);
98
+ if (account.user.accessStatus !== 'rejected')
99
+ await requestAccess(accountApi, signedInUser);
100
+ print(dim('SOJA permanece en modo local hasta la aprobación. Consulta `soja access status`.'));
101
+ return;
102
+ }
103
+ const sameServer = current?.remote?.apiUrl === server;
104
+ credentials.save(server, poll.body.token, signedInUser.username);
105
+ config.save({
106
+ parentFolders: [],
107
+ ...current,
108
+ mode: 'remote',
109
+ remote: {
110
+ apiUrl: server,
111
+ userId: signedInUser.id,
112
+ ...(sameServer && current?.remote?.workspaceId ? { workspaceId: current.remote.workspaceId } : {}),
113
+ },
114
+ });
115
+ success(`Signed in as ${bold(`@${signedInUser.username}`)} ${dim(`on ${server}`)}`);
116
+ const { workspaces } = account;
117
+ if (workspaces.length > 1) {
118
+ workspaces.forEach((workspace, index) => print(` ${index + 1}. ${workspace.name}`));
119
+ const choice = Number(await ask('Selecciona el workspace para entrar: '));
120
+ const workspace = workspaces[choice - 1];
121
+ if (!workspace)
122
+ throw new ValidationError('Selecciona un workspace válido.');
123
+ const saved = config.load();
124
+ if (!saved?.remote)
125
+ throw new SojaError('No se pudo guardar el workspace seleccionado.');
126
+ config.save({ ...saved, remote: { ...saved.remote, workspaceId: workspace.id } });
127
+ }
128
+ if (workspaces.length === 0)
129
+ print(dim(' No workspaces yet. Create one: soja workspace create "Bravos Development"'));
130
+ else
131
+ print(dim(` Workspaces: ${workspaces.map((workspace) => workspace.name).join(', ')}`));
132
+ print(dim(' SOJA now uses this server. `soja mode local` switches back to your local data.'));
133
+ // Local work can come along (it is only read, never moved).
134
+ if (existsSync(resolvePaths().databaseFile))
135
+ print(dim(' To bring your local projects and tasks to the team: soja import-local'));
136
+ return;
137
+ }
138
+ throw new SojaError('The login code expired before it was approved.', { hint: 'Run `soja login` again.' });
139
+ }
140
+ /** `soja logout`: revokes the session on the server and returns to local mode. */
141
+ export async function logoutCommand() {
142
+ const { config, credentials } = stores();
143
+ const current = config.load();
144
+ const server = current?.remote?.apiUrl;
145
+ if (!server) {
146
+ print(dim('Not signed in to any SOJA server.'));
147
+ return;
148
+ }
149
+ const token = credentials.token(server);
150
+ if (token)
151
+ await new ApiClient(server, token).post('/v1/auth/logout').catch(() => undefined);
152
+ credentials.remove(server);
153
+ if (current)
154
+ config.save({ ...current, mode: 'local' });
155
+ success(`Signed out of ${server}. SOJA is back in local mode.`);
156
+ }
157
+ /** `soja account delete`: revokes sessions and anonymizes shared authorship. */
158
+ export async function deleteAccountCommand(args) {
159
+ const { values } = parseCommand(args, { yes: { type: 'boolean', short: 'y' } });
160
+ const { config, credentials } = stores();
161
+ const current = config.load();
162
+ const server = current?.remote?.apiUrl;
163
+ const token = server ? credentials.token(server) : null;
164
+ if (!server || !token)
165
+ throw new SojaError('No hay una cuenta SOJA Online activa para borrar.', { hint: 'Inicia sesión primero con `soja login`.' });
166
+ if (!(await confirmTyped('Esto revoca todas tus sesiones y anonimiza tu perfil en el contenido compartido.', 'BORRAR', values.yes)))
167
+ return print(dim('Cancelado.'));
168
+ await new ApiClient(server, token).delete('/v1/account');
169
+ credentials.remove(server);
170
+ if (current)
171
+ config.save({ ...current, mode: 'local' });
172
+ success('Cuenta eliminada. El contenido compartido se conserva con autoría anonimizada.');
173
+ }
174
+ /** `soja mode [local|remote]`: shows or switches where SOJA reads and writes. */
175
+ export async function modeCommand(args) {
176
+ const { positionals } = parseCommand(args, {});
177
+ const { config, credentials } = stores();
178
+ const current = config.load();
179
+ const target = positionals[0];
180
+ if (!target) {
181
+ print(current?.mode === 'remote' ? `remote ${dim(current.remote?.apiUrl ?? '')}` : 'local');
182
+ return;
183
+ }
184
+ if (target !== 'local' && target !== 'remote')
185
+ throw new ValidationError('Use `soja mode local` or `soja mode remote`.');
186
+ if (target === 'remote' && (!current?.remote || !credentials.token(current.remote.apiUrl))) {
187
+ throw new SojaError('No SOJA server to switch to.', { hint: 'Run `soja login --server <url>` first.' });
188
+ }
189
+ if (!current)
190
+ throw new SojaError('SOJA is not set up yet.', { hint: 'Run `soja` once, or `soja login --server <url>`.' });
191
+ config.save({ ...current, mode: target });
192
+ success(`SOJA is in ${bold(target)} mode${target === 'remote' ? dim(` (${current.remote?.apiUrl})`) : ''}.`);
193
+ }
194
+ /** `soja whoami` */
195
+ export async function whoamiCommand() {
196
+ await withSession(async (services, session) => {
197
+ const where = services.environment.mode === 'remote' ? `remote ${dim(services.environment.server)}` : 'local';
198
+ print(`${bold(`@${session.user.username}`)} ${dim(session.user.displayName)}`);
199
+ print(` ${dim('workspace')} ${session.workspace.name}`);
200
+ print(` ${dim('mode')} ${where}`);
201
+ print(dim(` ${symbols.dot} switch with \`soja mode local|remote\``));
202
+ });
203
+ }
@@ -0,0 +1,27 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { ValidationError } from '../../domain/errors.js';
3
+ /** Strict option parsing with friendly errors instead of Node's stack traces. */
4
+ export function parseCommand(args, options) {
5
+ try {
6
+ return parseArgs({ args, options, allowPositionals: true, strict: true });
7
+ }
8
+ catch (error) {
9
+ const message = error instanceof Error ? error.message.split(/(?<=\.) /)[0] : 'Invalid arguments.';
10
+ throw new ValidationError(message ?? 'Invalid arguments.', { hint: 'See `soja --help`.' });
11
+ }
12
+ }
13
+ export function requireArg(value, what, usage) {
14
+ if (!value)
15
+ throw new ValidationError(`Missing ${what}.`, { hint: `Usage: ${usage}` });
16
+ return value;
17
+ }
18
+ export function oneOf(value, allowed, what) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ const normalized = value.trim().toLowerCase().replace(/[-\s]/g, '_');
22
+ const alias = normalized === 'med' ? 'medium' : normalized;
23
+ const match = allowed.find((candidate) => candidate === alias);
24
+ if (!match)
25
+ throw new ValidationError(`Unknown ${what} “${value}”.`, { hint: `Use one of: ${allowed.join(', ')}.` });
26
+ return match;
27
+ }
@@ -0,0 +1,70 @@
1
+ import { BackupService, restoreBackup } from '../../application/services/backup-service.js';
2
+ import { activeDatabase, backupDir } from '../../bootstrap.js';
3
+ import { resolvePaths } from '../../config/paths.js';
4
+ import { ValidationError } from '../../domain/errors.js';
5
+ import { tildify } from '../../utils/text.js';
6
+ import { formatRelative } from '../../utils/time.js';
7
+ import { bold, color, dim, print, success } from '../output.js';
8
+ import { confirm } from '../prompt.js';
9
+ import { withServices } from '../runtime.js';
10
+ import { parseCommand, requireArg } from './args.js';
11
+ /** `soja backup [list | restore <name>]`: copies of the database in use. */
12
+ export async function backupCommand(args) {
13
+ const [sub, ...rest] = args;
14
+ if (sub === undefined || sub === 'now' || sub === 'create')
15
+ return create();
16
+ if (sub === 'list' || sub === 'ls')
17
+ return list();
18
+ if (sub === 'restore')
19
+ return restore(rest);
20
+ throw new ValidationError(`Unknown backup command “${sub}”.`, { hint: 'Try: soja backup, soja backup list, soja backup restore <name>.' });
21
+ }
22
+ async function create() {
23
+ await withServices(async (services) => {
24
+ const backup = services.backups.create('manual');
25
+ success(`Backup saved ${dim(`${tildify(backup.path)} · ${size(backup.bytes)}`)}`);
26
+ });
27
+ }
28
+ async function list() {
29
+ const backups = lister().list();
30
+ if (backups.length === 0)
31
+ return print(dim('No backups yet. SOJA makes one a day when you open it; `soja backup` makes one now.'));
32
+ for (const backup of backups)
33
+ print(line(backup));
34
+ print(dim(` in ${tildify(backupDir(resolvePaths()))}`));
35
+ }
36
+ async function restore(args) {
37
+ const { values, positionals } = parseCommand(args, { yes: { type: 'boolean', short: 'y' } });
38
+ const name = requireArg(positionals[0], 'backup name', 'soja backup restore <name> (names: soja backup list)');
39
+ const paths = resolvePaths();
40
+ const active = activeDatabase(paths);
41
+ const backup = lister().find(name);
42
+ print(`${bold('Restore')} ${backup.name} ${dim(`(${formatRelative(backup.createdAt)})`)}`);
43
+ print(dim(` It replaces ${tildify(active.file)}. The current state is saved first as a before-restore backup.`));
44
+ if (active.label !== 'local')
45
+ print(dim(' In remote mode this is the replica: the next sync brings back what the server has, and keeps your queued changes.'));
46
+ if (!(await confirm('Restore it?', values.yes)))
47
+ return print(dim('Cancelled.'));
48
+ const saved = restoreBackup(backup, active.file, backupDir(paths), active.label);
49
+ success(`Restored ${backup.name}`);
50
+ if (saved)
51
+ print(dim(` Previous state kept as ${saved.name}`));
52
+ }
53
+ /** Lists without opening the database (restoring needs it closed). */
54
+ function lister() {
55
+ const paths = resolvePaths();
56
+ const active = activeDatabase(paths);
57
+ return new BackupService({
58
+ label: active.label,
59
+ backupTo: () => {
60
+ throw new Error('not used');
61
+ },
62
+ }, backupDir(paths));
63
+ }
64
+ function line(backup) {
65
+ const kind = backup.kind === 'auto' ? dim('daily') : backup.kind === 'manual' ? color('green', 'manual') : color('yellow', 'before restore');
66
+ return ` ${backup.name.padEnd(58)} ${kind.padEnd(16)} ${dim(`${formatRelative(backup.createdAt)} · ${size(backup.bytes)}`)}`;
67
+ }
68
+ function size(bytes) {
69
+ return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
70
+ }