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,103 @@
1
+ #!/usr/bin/env node
2
+ import { ValidationError } from '../domain/errors.js';
3
+ import { toDisplayError } from '../utils/errors.js';
4
+ import { deleteAccountCommand, loginCommand, logoutCommand, modeCommand, whoamiCommand } from './commands/account.js';
5
+ import { accessCommand } from './commands/access.js';
6
+ import { devCommand } from './commands/dev.js';
7
+ import { foldersCommand } from './commands/folders.js';
8
+ import { backupCommand } from './commands/backup.js';
9
+ import { updateCommand } from './commands/update.js';
10
+ import { chatCommand } from './commands/chat.js';
11
+ import { importLocalCommand } from './commands/import.js';
12
+ import { branchCommand, commitCommand, mergeCommand, pullRequestCommand, pushCommand } from './commands/git-ops.js';
13
+ import { printHelp, printVersion } from './commands/info.js';
14
+ import { projectCommand } from './commands/project.js';
15
+ import { taskCommand } from './commands/task.js';
16
+ import { startCommand } from './commands/start.js';
17
+ import { syncCommand } from './commands/sync.js';
18
+ import { runInterface } from './commands/tui.js';
19
+ import { switchWorkspace, workspaceCommand } from './commands/workspace.js';
20
+ import { printError } from './output.js';
21
+ async function main(argv) {
22
+ const [command, ...rest] = argv;
23
+ switch (command) {
24
+ case undefined:
25
+ return runInterface();
26
+ case '-h':
27
+ case '--help':
28
+ case 'help':
29
+ return printHelp();
30
+ case '-v':
31
+ case '--version':
32
+ case 'version':
33
+ return printVersion();
34
+ case 'task':
35
+ case 'tasks':
36
+ case 't':
37
+ return taskCommand(rest);
38
+ case 'project':
39
+ case 'projects':
40
+ return projectCommand(rest);
41
+ case 'workspace':
42
+ case 'workspaces':
43
+ return workspaceCommand(rest);
44
+ case 'use':
45
+ return switchWorkspace(rest);
46
+ case 'start':
47
+ return startCommand(rest);
48
+ case 'folders':
49
+ case 'folder':
50
+ return foldersCommand(rest);
51
+ case 'commit':
52
+ return commitCommand(rest);
53
+ case 'merge':
54
+ return mergeCommand(rest);
55
+ case 'branch':
56
+ return branchCommand(rest);
57
+ case 'push':
58
+ return pushCommand(rest);
59
+ case 'pr':
60
+ return pullRequestCommand(rest);
61
+ case 'dev':
62
+ return devCommand(rest);
63
+ case 'login':
64
+ return loginCommand(rest);
65
+ case 'access':
66
+ return accessCommand(rest);
67
+ case 'logout':
68
+ return logoutCommand();
69
+ case 'account':
70
+ if (rest[0] === 'delete')
71
+ return deleteAccountCommand(rest.slice(1));
72
+ throw new ValidationError('Use `soja account delete [--yes]`.');
73
+ case 'whoami':
74
+ return whoamiCommand();
75
+ case 'mode':
76
+ return modeCommand(rest);
77
+ case 'sync':
78
+ return syncCommand(rest);
79
+ case 'chat':
80
+ return chatCommand(rest);
81
+ case 'import-local':
82
+ return importLocalCommand(rest);
83
+ case 'backup':
84
+ case 'backups':
85
+ return backupCommand(rest);
86
+ case 'update':
87
+ case 'upgrade':
88
+ return updateCommand(rest);
89
+ default:
90
+ throw new ValidationError(`Unknown command “${command}”.`, { hint: 'See `soja --help`.' });
91
+ }
92
+ }
93
+ // `soja task list | head` closes the pipe early; that is not an error. Stop
94
+ // writing but let the command finish: in remote mode it still has to sync.
95
+ process.stdout.on('error', (error) => {
96
+ if (error.code !== 'EPIPE')
97
+ throw error;
98
+ process.stdout.write = (() => true);
99
+ });
100
+ main(process.argv.slice(2)).catch((error) => {
101
+ printError(toDisplayError(error));
102
+ process.exitCode = 1;
103
+ });
@@ -0,0 +1,56 @@
1
+ import { styleText } from 'node:util';
2
+ import { priorityStyles, statusStyles, symbols } from '../ui/theme/theme.js';
3
+ /** Styles text for stdout. Plain automatically when piped or when NO_COLOR is set. */
4
+ export function paint(format, text, stream = process.stdout) {
5
+ return styleText(format, text, { stream });
6
+ }
7
+ export const bold = (text) => paint('bold', text);
8
+ export const dim = (text) => paint('dim', text);
9
+ export const color = (name, text) => paint(name, text);
10
+ export function token(style, text = style.label) {
11
+ const formats = [];
12
+ if (style.color)
13
+ formats.push(style.color);
14
+ if (style.bold)
15
+ formats.push('bold');
16
+ if (style.dim)
17
+ formats.push('dim');
18
+ return formats.length ? paint(formats, text) : text;
19
+ }
20
+ export function print(line = '') {
21
+ process.stdout.write(`${line}\n`);
22
+ }
23
+ export function success(message) {
24
+ print(`${color('green', symbols.check)} ${message}`);
25
+ }
26
+ export function printError(error) {
27
+ const stream = process.stderr;
28
+ stream.write(`${paint('red', `${symbols.cross} ${error.message}`, stream)}\n`);
29
+ if (error.hint)
30
+ stream.write(`${paint('dim', ` ${error.hint}`, stream)}\n`);
31
+ if (error.debug)
32
+ stream.write(`\n${paint('dim', error.debug, stream)}\n`);
33
+ }
34
+ const pad = (text, width) => (text.length >= width ? `${text.slice(0, width - 1)}${symbols.ellipsis}` : text.padEnd(width));
35
+ /** One aligned line per task. Narrow terminals drop project and status labels. */
36
+ export function taskLine(task, options = {}, columns = process.stdout.columns || 100) {
37
+ const priority = priorityStyles[task.priority];
38
+ const status = statusStyles[task.status];
39
+ const parts = [dim(pad(task.ref, 10)), token(priority, pad(priority.label, 8))];
40
+ let used = 18;
41
+ if (columns >= 80) {
42
+ parts.push(pad(task.project?.name ?? '—', 15), token(status, pad(`${status.glyph} ${status.label}`, 15)));
43
+ used += 30;
44
+ }
45
+ else {
46
+ parts.push(token(status, `${status.glyph} `));
47
+ used += 2;
48
+ }
49
+ if (columns >= 100 && options.showAssignee !== false) {
50
+ parts.push(dim(pad(task.assignee ? `@${task.assignee.username}` : '—', 11)));
51
+ used += 11;
52
+ }
53
+ const room = Math.max(12, columns - used - 2);
54
+ parts.push(task.title.length > room ? `${task.title.slice(0, room - 1)}${symbols.ellipsis}` : task.title);
55
+ return ` ${parts.join('')}`;
56
+ }
@@ -0,0 +1,28 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { ValidationError } from '../domain/errors.js';
3
+ import { dim } from './output.js';
4
+ /** Yes/no on the terminal; `--yes` skips it, and without a terminal it refuses. */
5
+ export async function confirm(question, yes) {
6
+ if (yes)
7
+ return true;
8
+ const answer = await ask(`${question} ${dim('[y/N]')} `);
9
+ return /^y(es)?$/i.test(answer.trim());
10
+ }
11
+ /** For what cannot be undone: the answer must be exactly `expected`. */
12
+ export async function confirmTyped(question, expected, yes) {
13
+ if (yes)
14
+ return true;
15
+ const answer = await ask(`${question} ${dim(`Type ${expected} to confirm:`)} `);
16
+ return answer.trim().toUpperCase() === expected.toUpperCase();
17
+ }
18
+ async function ask(prompt) {
19
+ if (!process.stdin.isTTY)
20
+ throw new ValidationError('Refusing to continue without confirmation.', { hint: 'Pass --yes.' });
21
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
22
+ try {
23
+ return await readline.question(prompt);
24
+ }
25
+ finally {
26
+ readline.close();
27
+ }
28
+ }
@@ -0,0 +1,43 @@
1
+ import { bootstrap } from '../bootstrap.js';
2
+ import { SojaError } from '../domain/errors.js';
3
+ import { paint } from './output.js';
4
+ /** Opens the local database for one command and always closes it. */
5
+ export async function withServices(work) {
6
+ const runtime = await bootstrap();
7
+ try {
8
+ return await work(runtime.services);
9
+ }
10
+ finally {
11
+ runtime.close();
12
+ }
13
+ }
14
+ export async function withSession(work) {
15
+ return withServices(async (services) => {
16
+ const session = await services.session.current();
17
+ if (!session) {
18
+ throw new SojaError('SOJA is not set up yet.', { hint: 'Run `soja` once to create your user and workspace.' });
19
+ }
20
+ // Remote mode: pull before (fresh data) and push after (your changes). Offline still works.
21
+ const sync = services.sync;
22
+ if (sync)
23
+ await sync.syncNow(session.workspace.id);
24
+ const result = await work(services, session);
25
+ if (sync) {
26
+ const report = await sync.syncNow(session.workspace.id);
27
+ const status = await sync.status(session.workspace.id);
28
+ if (!report.online)
29
+ printStatus(`offline · ${plural(status.pending, 'change')} queued, sent when you reconnect`);
30
+ else if (status.pending > 0)
31
+ printStatus(`${plural(status.pending, 'change')} not synced yet: ${report.error ?? 'will retry'}`);
32
+ if (report.conflicts || report.rejected || report.chatRejected)
33
+ printStatus('some changes need your attention: run `soja sync`');
34
+ }
35
+ return result;
36
+ });
37
+ }
38
+ function printStatus(text) {
39
+ process.stderr.write(`${paint('dim', `⇅ ${text}`, process.stderr)}\n`);
40
+ }
41
+ function plural(count, noun) {
42
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
43
+ }
@@ -0,0 +1,103 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { ConfigError } from '../domain/errors.js';
5
+ const remoteSchema = z.object({
6
+ /** SOJA server, e.g. https://api.soja.dev (the token lives in credentials.json). */
7
+ apiUrl: z.string().url(),
8
+ /** Signed-in user on that server, so SOJA can open offline from the replica. */
9
+ userId: z.string().min(1).optional(),
10
+ /** Active workspace on the server. */
11
+ workspaceId: z.string().min(1).optional(),
12
+ /** Imports of local data already done from this machine (`soja import-local`), to warn before repeating. */
13
+ imports: z.array(z.string()).optional(),
14
+ });
15
+ /**
16
+ * Both modes live side by side so switching never loses the other one:
17
+ * `local` uses userId/workspaceId against SQLite; `remote` uses the
18
+ * `remote` block against a SOJA server.
19
+ */
20
+ export const configSchema = z
21
+ .object({
22
+ mode: z.enum(['local', 'remote']).default('local'),
23
+ userId: z.string().min(1).optional(),
24
+ workspaceId: z.string().min(1).optional(),
25
+ /**
26
+ * Machine-specific folders that contain project repositories (e.g.
27
+ * ~/workspace/products, ~/workspace/services). Their subfolders feed the
28
+ * repository picker.
29
+ */
30
+ parentFolders: z.array(z.string().min(1)).default([]),
31
+ remote: remoteSchema.optional(),
32
+ })
33
+ .refine((config) => config.mode !== 'remote' || config.remote !== undefined, {
34
+ error: 'Remote mode needs a server.',
35
+ path: ['remote'],
36
+ });
37
+ export class FileConfigStore {
38
+ file;
39
+ constructor(file) {
40
+ this.file = file;
41
+ }
42
+ load() {
43
+ let raw;
44
+ try {
45
+ raw = readFileSync(this.file, 'utf8');
46
+ }
47
+ catch (error) {
48
+ if (isErrnoException(error) && error.code === 'ENOENT')
49
+ return null;
50
+ throw new ConfigError(`Could not read ${this.file}.`, { cause: error });
51
+ }
52
+ let json;
53
+ try {
54
+ json = JSON.parse(raw);
55
+ }
56
+ catch (error) {
57
+ throw new ConfigError(`${this.file} is not valid JSON.`, {
58
+ hint: 'Fix the file or delete it to run setup again.',
59
+ cause: error,
60
+ });
61
+ }
62
+ const parsed = configSchema.safeParse(json);
63
+ if (!parsed.success) {
64
+ const mode = typeof json === 'object' && json !== null && 'mode' in json ? json.mode : undefined;
65
+ if (mode === 'remote') {
66
+ throw new ConfigError('Remote mode is set but no server is configured.', {
67
+ hint: 'Run `soja login --server <url>`, or `soja mode local`.',
68
+ });
69
+ }
70
+ throw new ConfigError(`${this.file} has an unexpected shape.`, {
71
+ hint: 'Delete it to run setup again.',
72
+ cause: parsed.error,
73
+ });
74
+ }
75
+ return parsed.data;
76
+ }
77
+ save(config) {
78
+ try {
79
+ mkdirSync(dirname(this.file), { recursive: true });
80
+ const temp = `${this.file}.${process.pid}.tmp`;
81
+ writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
82
+ renameSync(temp, this.file);
83
+ }
84
+ catch (error) {
85
+ throw new ConfigError(`Could not write ${this.file}.`, { cause: error });
86
+ }
87
+ }
88
+ }
89
+ export class MemoryConfigStore {
90
+ config;
91
+ constructor(config = null) {
92
+ this.config = config;
93
+ }
94
+ load() {
95
+ return this.config;
96
+ }
97
+ save(config) {
98
+ this.config = config;
99
+ }
100
+ }
101
+ function isErrnoException(error) {
102
+ return error instanceof Error && 'code' in error;
103
+ }
@@ -0,0 +1,68 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { ConfigError } from '../domain/errors.js';
5
+ const credentialsSchema = z.object({
6
+ /** Server URL → session token. */
7
+ servers: z.record(z.string(), z.object({ token: z.string().min(1), username: z.string() })).default({}),
8
+ });
9
+ /**
10
+ * Session tokens for SOJA servers, kept apart from config.json and readable
11
+ * only by you (mode 0600). SOJA never stores Git or GitHub credentials.
12
+ */
13
+ export class CredentialStore {
14
+ file;
15
+ constructor(file) {
16
+ this.file = file;
17
+ }
18
+ token(apiUrl) {
19
+ return this.read().servers[normalize(apiUrl)]?.token ?? null;
20
+ }
21
+ save(apiUrl, token, username) {
22
+ const credentials = this.read();
23
+ credentials.servers[normalize(apiUrl)] = { token, username };
24
+ this.write(credentials);
25
+ }
26
+ remove(apiUrl) {
27
+ const credentials = this.read();
28
+ const key = normalize(apiUrl);
29
+ this.write({ servers: Object.fromEntries(Object.entries(credentials.servers).filter(([server]) => server !== key)) });
30
+ }
31
+ read() {
32
+ let raw;
33
+ try {
34
+ raw = readFileSync(this.file, 'utf8');
35
+ }
36
+ catch {
37
+ return { servers: {} };
38
+ }
39
+ const parsed = credentialsSchema.safeParse(JSON.parse(raw));
40
+ if (!parsed.success)
41
+ throw new ConfigError(`${this.file} has an unexpected shape.`, { hint: 'Delete it and run `soja login` again.' });
42
+ return parsed.data;
43
+ }
44
+ write(credentials) {
45
+ try {
46
+ mkdirSync(dirname(this.file), { recursive: true });
47
+ const temp = `${this.file}.${process.pid}.tmp`;
48
+ writeFileSync(temp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
49
+ chmodSync(temp, 0o600);
50
+ renameSync(temp, this.file);
51
+ }
52
+ catch (error) {
53
+ throw new ConfigError(`Could not write ${this.file}.`, { cause: error });
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * The server's base URL. Accepts what people paste after testing it with curl,
59
+ * e.g. `https://host/v1/health` or `https://host/`, and returns `https://host`.
60
+ */
61
+ export function normalize(apiUrl) {
62
+ const trimmed = apiUrl.trim();
63
+ if (!/^https?:\/\//i.test(trimmed))
64
+ return trimmed.replace(/\/+$/, '');
65
+ const url = new URL(trimmed);
66
+ const base = url.pathname.replace(/\/v1(\/.*)?$/, '').replace(/\/+$/, '');
67
+ return `${url.protocol}//${url.host}${base}`;
68
+ }
@@ -0,0 +1,18 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /** XDG base directories, with the spec's fallbacks when the variables are unset or relative. */
4
+ export function resolvePaths(env = process.env, home = homedir()) {
5
+ const xdg = (name, fallback) => {
6
+ const value = env[name];
7
+ return value && value.startsWith('/') ? value : join(home, fallback);
8
+ };
9
+ const dataDir = join(xdg('XDG_DATA_HOME', '.local/share'), 'soja');
10
+ const configDir = join(xdg('XDG_CONFIG_HOME', '.config'), 'soja');
11
+ return {
12
+ dataDir,
13
+ configDir,
14
+ databaseFile: join(dataDir, 'soja.db'),
15
+ configFile: join(configDir, 'config.json'),
16
+ credentialsFile: join(configDir, 'credentials.json'),
17
+ };
18
+ }
@@ -0,0 +1,21 @@
1
+ /** Public production SOJA API. This is configuration, not a secret. */
2
+ export const DEFAULT_SERVER_URL = 'https://soja-backend-production.up.railway.app';
3
+ /** Do not let a configured public server receive SOJA bearer credentials over cleartext HTTP. */
4
+ export function isSecureServerUrl(value) {
5
+ try {
6
+ const url = new URL(value);
7
+ if (url.username || url.password)
8
+ return false;
9
+ if (url.protocol === 'https:')
10
+ return true;
11
+ if (url.protocol !== 'http:')
12
+ return false;
13
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
14
+ const octets = hostname.split('.');
15
+ const loopbackIpv4 = octets.length === 4 && octets[0] === '127' && octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255);
16
+ return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1' || loopbackIpv4;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+ // package.json sits two levels above both src/config and dist/config.
4
+ const packageJson = z
5
+ .object({ version: z.string() })
6
+ .parse(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')));
7
+ export const APP_VERSION = packageJson.version;
@@ -0,0 +1,33 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { asc, eq, sql } from 'drizzle-orm';
3
+ import { taskActivity } from '../../database/schema.js';
4
+ export class LocalActivityRepository {
5
+ db;
6
+ clock;
7
+ constructor(db, clock) {
8
+ this.db = db;
9
+ this.clock = clock;
10
+ }
11
+ async record(taskId, userId, event) {
12
+ const [row] = await this.db
13
+ .insert(taskActivity)
14
+ .values({ id: randomUUID(), taskId, userId, type: event.type, metadata: event.metadata, createdAt: this.clock() })
15
+ .returning();
16
+ if (!row)
17
+ throw new Error('Expected the database to return a row');
18
+ return toActivity(row);
19
+ }
20
+ async listByTask(taskId) {
21
+ const rows = await this.db
22
+ .select()
23
+ .from(taskActivity)
24
+ .where(eq(taskActivity.taskId, taskId))
25
+ .orderBy(asc(taskActivity.createdAt), asc(sql `rowid`));
26
+ return rows.map(toActivity);
27
+ }
28
+ }
29
+ // `type` and `metadata` are written together from an ActivityEvent, so the
30
+ // row pair is always one of the union members; TypeScript cannot see that.
31
+ function toActivity(row) {
32
+ return row;
33
+ }
@@ -0,0 +1 @@
1
+ export const systemClock = () => new Date();
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { asc, eq, sql } from 'drizzle-orm';
3
+ import { taskComments } from '../../database/schema.js';
4
+ import { must } from './users.js';
5
+ export class LocalCommentRepository {
6
+ db;
7
+ clock;
8
+ constructor(db, clock) {
9
+ this.db = db;
10
+ this.clock = clock;
11
+ }
12
+ async create(input) {
13
+ const now = this.clock();
14
+ const [comment] = await this.db
15
+ .insert(taskComments)
16
+ .values({ ...input, id: input.id ?? randomUUID(), createdAt: now, updatedAt: now })
17
+ .returning();
18
+ return must(comment);
19
+ }
20
+ async listByTask(taskId) {
21
+ return this.db
22
+ .select()
23
+ .from(taskComments)
24
+ .where(eq(taskComments.taskId, taskId))
25
+ .orderBy(asc(taskComments.createdAt), asc(sql `rowid`));
26
+ }
27
+ }
@@ -0,0 +1,48 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { LocalActivityRepository } from './activity.js';
3
+ import { systemClock } from './clock.js';
4
+ import { LocalCommentRepository } from './comments.js';
5
+ import { LocalProjectRepository } from './projects.js';
6
+ import { LocalTaskRepository } from './tasks.js';
7
+ import { LocalUserRepository } from './users.js';
8
+ import { LocalWorkspaceRepository } from './workspaces.js';
9
+ export function createLocalRepositories(handle, clock = systemClock) {
10
+ const { db } = handle;
11
+ return {
12
+ users: new LocalUserRepository(db, clock),
13
+ workspaces: new LocalWorkspaceRepository(db, clock),
14
+ projects: new LocalProjectRepository(db, clock),
15
+ tasks: new LocalTaskRepository(db, clock),
16
+ comments: new LocalCommentRepository(db, clock),
17
+ activity: new LocalActivityRepository(db, clock),
18
+ transaction: createTransactionRunner(handle),
19
+ };
20
+ }
21
+ /**
22
+ * One SQLite connection is shared by every repository, so transactions are
23
+ * serialized. Nested calls (a service calling another inside a transaction)
24
+ * join the outer transaction instead of deadlocking on the queue.
25
+ */
26
+ function createTransactionRunner(handle) {
27
+ const context = new AsyncLocalStorage();
28
+ let queue = Promise.resolve();
29
+ return (work) => {
30
+ if (context.getStore())
31
+ return work();
32
+ const run = async () => {
33
+ handle.exec('BEGIN IMMEDIATE');
34
+ try {
35
+ const result = await context.run(true, work);
36
+ handle.exec('COMMIT');
37
+ return result;
38
+ }
39
+ catch (error) {
40
+ handle.exec('ROLLBACK');
41
+ throw error;
42
+ }
43
+ };
44
+ const result = queue.then(run, run);
45
+ queue = result.catch(() => undefined);
46
+ return result;
47
+ };
48
+ }
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, asc, eq } from 'drizzle-orm';
3
+ import { projects } from '../../database/schema.js';
4
+ import { NotFoundError } from '../../domain/errors.js';
5
+ import { must } from './users.js';
6
+ export class LocalProjectRepository {
7
+ db;
8
+ clock;
9
+ constructor(db, clock) {
10
+ this.db = db;
11
+ this.clock = clock;
12
+ }
13
+ async findById(id) {
14
+ return (await this.db.select().from(projects).where(eq(projects.id, id)).get()) ?? null;
15
+ }
16
+ async findByKey(workspaceId, key) {
17
+ const row = await this.db
18
+ .select()
19
+ .from(projects)
20
+ .where(and(eq(projects.workspaceId, workspaceId), eq(projects.key, key)))
21
+ .get();
22
+ return row ?? null;
23
+ }
24
+ async listByWorkspace(workspaceId) {
25
+ return this.db.select().from(projects).where(eq(projects.workspaceId, workspaceId)).orderBy(asc(projects.name));
26
+ }
27
+ async create(input) {
28
+ const now = this.clock();
29
+ const [project] = await this.db
30
+ .insert(projects)
31
+ .values({
32
+ id: randomUUID(),
33
+ description: null,
34
+ repositoryUrl: null,
35
+ repositoryPath: null,
36
+ ...input,
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ })
40
+ .returning();
41
+ return must(project);
42
+ }
43
+ async update(id, patch) {
44
+ const [project] = await this.db
45
+ .update(projects)
46
+ .set({ ...patch, updatedAt: this.clock() })
47
+ .where(eq(projects.id, id))
48
+ .returning();
49
+ if (!project)
50
+ throw new NotFoundError('That project no longer exists.');
51
+ return project;
52
+ }
53
+ }