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,129 @@
1
+ import { z } from 'zod';
2
+ import { ConflictError, NotFoundError } from '../../domain/errors.js';
3
+ import { deriveProjectKey, makeUnique } from '../../domain/naming.js';
4
+ import { activeCount, emptyStatusCounts } from '../../domain/task.js';
5
+ import { GitError } from '../../git/types.js';
6
+ import { resolveRepositoryRoot } from '../repository-root.js';
7
+ import { optionalText, parseInput } from '../validation.js';
8
+ const projectKeySchema = z
9
+ .string()
10
+ .trim()
11
+ .toUpperCase()
12
+ .regex(/^[A-Z][A-Z0-9]{1,9}$/, { error: 'Keys are 2–10 letters or digits and start with a letter (e.g. ENROLL).' });
13
+ const createProjectSchema = z.object({
14
+ name: z.string().trim().min(1, { error: 'Name the project.' }).max(60),
15
+ key: projectKeySchema.optional(),
16
+ description: optionalText(500),
17
+ repositoryUrl: optionalText(500),
18
+ repositoryPath: optionalText(500),
19
+ });
20
+ const updateProjectSchema = z.object({
21
+ name: z.string().trim().min(1, { error: 'Name the project.' }).max(60).optional(),
22
+ description: optionalText(500),
23
+ repositoryUrl: optionalText(500),
24
+ });
25
+ export class ProjectService {
26
+ repos;
27
+ git;
28
+ constructor(repos, git) {
29
+ this.repos = repos;
30
+ this.git = git;
31
+ }
32
+ async list(session) {
33
+ const [projects, rows] = await Promise.all([
34
+ this.repos.projects.listByWorkspace(session.workspace.id),
35
+ this.repos.tasks.countByProjectAndStatus(session.workspace.id),
36
+ ]);
37
+ return projects.map((project) => {
38
+ const counts = emptyStatusCounts();
39
+ for (const row of rows)
40
+ if (row.projectId === project.id)
41
+ counts[row.status] += row.count;
42
+ return { ...project, counts, active: activeCount(counts) };
43
+ });
44
+ }
45
+ async create(session, input) {
46
+ const { name, key, ...rest } = parseInput(createProjectSchema, input);
47
+ return this.repos.transaction(async () => {
48
+ const existing = await this.repos.projects.listByWorkspace(session.workspace.id);
49
+ if (existing.some((project) => project.name.toLowerCase() === name.toLowerCase())) {
50
+ throw new ConflictError(`${name} already exists in ${session.workspace.name}.`);
51
+ }
52
+ const keys = new Set(existing.map((project) => project.key));
53
+ if (key && keys.has(key))
54
+ throw new ConflictError(`The key ${key} is already used by another project.`);
55
+ return this.repos.projects.create({
56
+ workspaceId: session.workspace.id,
57
+ name,
58
+ key: key ?? makeUnique(deriveProjectKey(name), (candidate) => keys.has(candidate)),
59
+ description: rest.description ?? null,
60
+ repositoryUrl: rest.repositoryUrl ?? null,
61
+ repositoryPath: rest.repositoryPath ?? null,
62
+ });
63
+ });
64
+ }
65
+ async update(session, project, changes) {
66
+ const data = parseInput(updateProjectSchema, changes);
67
+ await this.get(session, project.id);
68
+ if (data.name !== undefined) {
69
+ const name = data.name;
70
+ const others = (await this.repos.projects.listByWorkspace(session.workspace.id)).filter((other) => other.id !== project.id);
71
+ if (others.some((other) => other.name.toLowerCase() === name.toLowerCase())) {
72
+ throw new ConflictError(`${name} already exists in ${session.workspace.name}.`);
73
+ }
74
+ }
75
+ const patch = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
76
+ return Object.keys(patch).length ? this.repos.projects.update(project.id, patch) : project;
77
+ }
78
+ async get(session, id) {
79
+ const project = await this.repos.projects.findById(id);
80
+ if (!project || project.workspaceId !== session.workspace.id) {
81
+ throw new NotFoundError('That project does not exist in this workspace.');
82
+ }
83
+ return project;
84
+ }
85
+ /**
86
+ * Links a project to the local Git repository containing `path` (the
87
+ * top-level directory is stored). Fills the repository URL from `origin`
88
+ * when the project does not have one yet.
89
+ */
90
+ async linkRepository(session, project, path) {
91
+ const root = await resolveRepositoryRoot(this.git, path);
92
+ await this.get(session, project.id);
93
+ const repositoryUrl = project.repositoryUrl ?? (await this.git.originUrl(root));
94
+ return this.repos.projects.update(project.id, { repositoryPath: root, repositoryUrl });
95
+ }
96
+ async unlinkRepository(session, project) {
97
+ await this.get(session, project.id);
98
+ return this.repos.projects.update(project.id, { repositoryPath: null });
99
+ }
100
+ /** The project linked to the repository containing `path`, if any. Never throws for non-repositories. */
101
+ async findByRepository(session, path) {
102
+ let root;
103
+ try {
104
+ root = await this.git.repositoryRoot(path);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof GitError)
108
+ return null;
109
+ throw error;
110
+ }
111
+ if (!root)
112
+ return null;
113
+ const projects = await this.repos.projects.listByWorkspace(session.workspace.id);
114
+ return projects.find((project) => project.repositoryPath === root) ?? null;
115
+ }
116
+ /** Finds a project by key (`ENROLL`) or name (`EnrollBridge`), case-insensitively. */
117
+ async resolve(session, keyOrName) {
118
+ const needle = keyOrName.trim().toLowerCase();
119
+ const projects = await this.repos.projects.listByWorkspace(session.workspace.id);
120
+ const project = projects.find((candidate) => candidate.key.toLowerCase() === needle) ??
121
+ projects.find((candidate) => candidate.name.toLowerCase() === needle);
122
+ if (!project) {
123
+ throw new NotFoundError(`No project called “${keyOrName}” in ${session.workspace.name}.`, {
124
+ hint: 'List them with `soja project list`.',
125
+ });
126
+ }
127
+ return project;
128
+ }
129
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { displayNameSchema, parseInput, usernameSchema } from '../validation.js';
3
+ const setupSchema = z.object({
4
+ displayName: displayNameSchema,
5
+ username: usernameSchema,
6
+ workspaceName: z.string().trim().min(1, { error: 'Name the workspace.' }).max(60),
7
+ });
8
+ export class SessionService {
9
+ repos;
10
+ config;
11
+ workspaces;
12
+ constructor(repos, config, workspaces) {
13
+ this.repos = repos;
14
+ this.config = config;
15
+ this.workspaces = workspaces;
16
+ }
17
+ /** The configured user, even without a usable workspace; null before setup. */
18
+ async user() {
19
+ const config = this.config.load();
20
+ return config?.userId ? this.repos.users.findById(config.userId) : null;
21
+ }
22
+ /** The configured user and workspace, or null when setup is needed. */
23
+ async current() {
24
+ const config = this.config.load();
25
+ if (!config?.userId || !config.workspaceId)
26
+ return null;
27
+ const [user, workspace] = await Promise.all([
28
+ this.repos.users.findById(config.userId),
29
+ this.repos.workspaces.findById(config.workspaceId),
30
+ ]);
31
+ if (!user)
32
+ return null;
33
+ if (workspace && (await this.repos.workspaces.findMember(workspace.id, user.id))) {
34
+ return { user, workspace };
35
+ }
36
+ // The active workspace vanished (db reset, left it): fall back to any other.
37
+ const [fallback] = await this.repos.workspaces.listForUser(user.id);
38
+ return fallback ? this.workspaces.switchTo(user, fallback.id) : null;
39
+ }
40
+ /**
41
+ * First run: creates the local user and first workspace, then persists
42
+ * them as the active session. Re-running setup against an existing
43
+ * database reuses a matching username instead of failing.
44
+ */
45
+ async setup(input) {
46
+ const { displayName, username, workspaceName } = parseInput(setupSchema, input);
47
+ const user = (await this.repos.users.findByUsername(username)) ?? (await this.repos.users.create({ username, displayName }));
48
+ const existing = (await this.repos.workspaces.listForUser(user.id)).find((workspace) => workspace.name.toLowerCase() === workspaceName.toLowerCase());
49
+ const workspace = existing ?? (await this.workspaces.create(user, { name: workspaceName }));
50
+ return this.workspaces.switchTo(user, workspace.id);
51
+ }
52
+ }
@@ -0,0 +1,350 @@
1
+ import { z } from 'zod';
2
+ import { NotFoundError, ValidationError } from '../../domain/errors.js';
3
+ import { compareByUrgency, formatTaskRef, isClosed, parseTaskRef, suggestBranchName, TASK_PRIORITIES, TASK_STATUSES, TASK_TYPES, } from '../../domain/task.js';
4
+ import { planStatusChange } from '../../domain/workflow.js';
5
+ import { filterToQuery } from '../filters.js';
6
+ import { buildTimeline } from '../timeline.js';
7
+ import { optionalText, parseInput } from '../validation.js';
8
+ const titleSchema = z
9
+ .string()
10
+ .trim()
11
+ .min(1, { error: 'A task needs a title.' })
12
+ .max(200, { error: 'Keep the title under 200 characters.' });
13
+ const createTaskSchema = z.object({
14
+ title: titleSchema,
15
+ description: optionalText(10_000),
16
+ projectId: z.string().nullable().optional(),
17
+ type: z.enum(TASK_TYPES).default('feature'),
18
+ priority: z.enum(TASK_PRIORITIES).default('medium'),
19
+ status: z.enum(TASK_STATUSES).default('todo'),
20
+ /** Omitted means "me"; null means unassigned. */
21
+ assigneeId: z.string().nullable().optional(),
22
+ requester: optionalText(80),
23
+ });
24
+ const changesSchema = z.object({
25
+ title: titleSchema.optional(),
26
+ description: optionalText(10_000),
27
+ projectId: z.string().nullable().optional(),
28
+ type: z.enum(TASK_TYPES).optional(),
29
+ priority: z.enum(TASK_PRIORITIES).optional(),
30
+ status: z.enum(TASK_STATUSES).optional(),
31
+ assigneeId: z.string().nullable().optional(),
32
+ requester: optionalText(80),
33
+ branch: optionalText(250),
34
+ /** Bookkeeping for merges; changes are not shown in the timeline. */
35
+ baseBranch: optionalText(250),
36
+ branchStart: optionalText(64),
37
+ /** Archive (hide from lists) or restore. */
38
+ archived: z.boolean().optional(),
39
+ });
40
+ const commentSchema = z
41
+ .string()
42
+ .trim()
43
+ .min(1, { error: 'The comment is empty.' })
44
+ .max(10_000, { error: 'Keep comments under 10,000 characters.' });
45
+ export class TaskService {
46
+ repos;
47
+ clock;
48
+ constructor(repos, clock = () => new Date()) {
49
+ this.repos = repos;
50
+ this.clock = clock;
51
+ }
52
+ async list(session, filter, options = {}) {
53
+ const query = { ...filterToQuery(session, filter), ...options };
54
+ const tasks = await this.repos.tasks.list(query);
55
+ return this.present(session, tasks.sort(compareByUrgency));
56
+ }
57
+ /** Title substring or task number (`SOJA-12`, `12`), across open and closed tasks. */
58
+ async search(session, text, limit = 50) {
59
+ const trimmed = text.trim();
60
+ if (!trimmed)
61
+ return [];
62
+ const exact = parseTaskRef(trimmed);
63
+ const tasks = await this.repos.tasks.list({
64
+ workspaceId: session.workspace.id,
65
+ search: { text: trimmed, number: exact },
66
+ // Search is how archived tasks are found again.
67
+ archived: 'include',
68
+ limit,
69
+ });
70
+ // An exact ID hit goes first; the rest keep newest-first order.
71
+ tasks.sort((a, b) => Number(b.number === exact) - Number(a.number === exact));
72
+ return this.present(session, tasks);
73
+ }
74
+ async get(session, target) {
75
+ const task = await this.resolve(session, target);
76
+ const [view, activity, comments, projects] = await Promise.all([
77
+ this.presentOne(session, task),
78
+ this.repos.activity.listByTask(task.id),
79
+ this.repos.comments.listByTask(task.id),
80
+ this.repos.projects.listByWorkspace(session.workspace.id),
81
+ ]);
82
+ const userIds = new Set([task.creatorId]);
83
+ for (const item of activity) {
84
+ if (item.userId)
85
+ userIds.add(item.userId);
86
+ if (item.type === 'assigned') {
87
+ userIds.add(item.metadata.to);
88
+ if (item.metadata.from)
89
+ userIds.add(item.metadata.from);
90
+ }
91
+ if (item.type === 'unassigned')
92
+ userIds.add(item.metadata.from);
93
+ }
94
+ for (const comment of comments)
95
+ userIds.add(comment.userId);
96
+ const users = new Map((await this.repos.users.findByIds([...userIds])).map((user) => [user.id, toUserRef(user)]));
97
+ return {
98
+ ...view,
99
+ creator: users.get(task.creatorId) ?? null,
100
+ timeline: buildTimeline(activity, comments, {
101
+ users,
102
+ projects: new Map(projects.map((project) => [project.id, toProjectRef(project)])),
103
+ }),
104
+ suggestedBranch: suggestBranchName(task),
105
+ };
106
+ }
107
+ async create(session, input, options = {}) {
108
+ const data = parseInput(createTaskSchema, input);
109
+ const assigneeId = data.assigneeId === undefined ? session.user.id : data.assigneeId;
110
+ const projectId = data.projectId ?? null;
111
+ await this.assertProject(session, projectId);
112
+ await this.assertMember(session, assigneeId);
113
+ const now = this.clock();
114
+ const task = await this.repos.transaction(async () => {
115
+ const created = await this.repos.tasks.create({
116
+ ...(options.id ? { id: options.id } : {}),
117
+ ...(options.number !== undefined ? { number: options.number } : {}),
118
+ workspaceId: session.workspace.id,
119
+ projectId,
120
+ title: data.title,
121
+ description: data.description ?? null,
122
+ type: data.type,
123
+ priority: data.priority,
124
+ status: data.status,
125
+ assigneeId,
126
+ creatorId: session.user.id,
127
+ requester: data.requester ?? null,
128
+ branch: null,
129
+ baseBranch: null,
130
+ branchStart: null,
131
+ startedAt: data.status === 'in_progress' ? now : null,
132
+ completedAt: data.status === 'done' ? now : null,
133
+ });
134
+ await this.repos.activity.record(created.id, session.user.id, { type: 'task_created', metadata: {} });
135
+ return created;
136
+ });
137
+ return this.presentOne(session, task);
138
+ }
139
+ /**
140
+ * Applies any combination of edits. Each real change is written as its own
141
+ * activity entry, so the timeline reads like a log of decisions.
142
+ */
143
+ async update(session, target, changes) {
144
+ const data = parseInput(changesSchema, changes);
145
+ const task = await this.resolve(session, target);
146
+ if (data.projectId !== undefined)
147
+ await this.assertProject(session, data.projectId);
148
+ if (data.assigneeId !== undefined)
149
+ await this.assertMember(session, data.assigneeId);
150
+ const patch = {};
151
+ const events = [];
152
+ const changed = (next, current) => next !== undefined && next !== current;
153
+ if (changed(data.title, task.title)) {
154
+ patch.title = data.title;
155
+ events.push({ type: 'task_updated', metadata: { field: 'title', from: task.title, to: data.title } });
156
+ }
157
+ if (changed(data.description, task.description)) {
158
+ patch.description = data.description;
159
+ // Descriptions can be long; the timeline only records that it changed.
160
+ const to = data.description ? 'edited' : null;
161
+ events.push({ type: 'task_updated', metadata: { field: 'description', from: null, to } });
162
+ }
163
+ if (changed(data.requester, task.requester)) {
164
+ patch.requester = data.requester;
165
+ events.push({ type: 'task_updated', metadata: { field: 'requester', from: task.requester, to: data.requester } });
166
+ }
167
+ if (changed(data.branch, task.branch)) {
168
+ patch.branch = data.branch;
169
+ events.push({ type: 'task_updated', metadata: { field: 'branch', from: task.branch, to: data.branch } });
170
+ }
171
+ if (changed(data.baseBranch, task.baseBranch))
172
+ patch.baseBranch = data.baseBranch;
173
+ if (changed(data.branchStart, task.branchStart))
174
+ patch.branchStart = data.branchStart;
175
+ if (changed(data.type, task.type)) {
176
+ patch.type = data.type;
177
+ events.push({ type: 'task_updated', metadata: { field: 'type', from: task.type, to: data.type } });
178
+ }
179
+ if (changed(data.priority, task.priority)) {
180
+ patch.priority = data.priority;
181
+ events.push({ type: 'priority_changed', metadata: { from: task.priority, to: data.priority } });
182
+ }
183
+ if (changed(data.projectId, task.projectId)) {
184
+ patch.projectId = data.projectId;
185
+ events.push({ type: 'project_changed', metadata: { from: task.projectId, to: data.projectId } });
186
+ }
187
+ if (changed(data.assigneeId, task.assigneeId)) {
188
+ patch.assigneeId = data.assigneeId;
189
+ if (data.assigneeId)
190
+ events.push({ type: 'assigned', metadata: { from: task.assigneeId, to: data.assigneeId } });
191
+ else if (task.assigneeId)
192
+ events.push({ type: 'unassigned', metadata: { from: task.assigneeId } });
193
+ }
194
+ if (data.archived !== undefined && data.archived !== (task.archivedAt !== null)) {
195
+ patch.archivedAt = data.archived ? this.clock() : null;
196
+ events.push({ type: data.archived ? 'task_archived' : 'task_unarchived', metadata: {} });
197
+ }
198
+ if (data.status) {
199
+ const change = planStatusChange(task, data.status, this.clock());
200
+ if (change) {
201
+ Object.assign(patch, change.patch);
202
+ events.push(change.event);
203
+ }
204
+ }
205
+ if (events.length === 0) {
206
+ if (Object.keys(patch).length === 0)
207
+ return this.presentOne(session, task);
208
+ return this.presentOne(session, await this.repos.tasks.update(task.id, patch));
209
+ }
210
+ const updated = await this.repos.transaction(async () => {
211
+ const result = await this.repos.tasks.update(task.id, patch);
212
+ for (const event of events)
213
+ await this.repos.activity.record(task.id, session.user.id, event);
214
+ return result;
215
+ });
216
+ return this.presentOne(session, updated);
217
+ }
218
+ /**
219
+ * Take the task: assign it to me and move it to In Progress. The Git flow
220
+ * (`soja start`) reuses this and also records the branch, in one transaction.
221
+ */
222
+ start(session, target, extra = {}) {
223
+ return this.update(session, target, { assigneeId: session.user.id, status: 'in_progress', ...extra });
224
+ }
225
+ archive(session, target) {
226
+ return this.update(session, target, { archived: true });
227
+ }
228
+ restore(session, target) {
229
+ return this.update(session, target, { archived: false });
230
+ }
231
+ /**
232
+ * Deletes the task with its comments and timeline, for good. Owners only;
233
+ * archiving is the reversible alternative.
234
+ */
235
+ async remove(session, target) {
236
+ const task = await this.resolve(session, target);
237
+ const member = await this.repos.workspaces.findMember(session.workspace.id, session.user.id);
238
+ if (member?.role !== 'owner') {
239
+ throw new ValidationError('Only workspace owners can delete tasks.', { hint: 'Archive it instead: it leaves the lists and can be restored.' });
240
+ }
241
+ await this.repos.transaction(() => this.repos.tasks.delete(task.id));
242
+ return { id: task.id, number: task.number };
243
+ }
244
+ complete(session, target) {
245
+ return this.update(session, target, { status: 'done' });
246
+ }
247
+ async reopen(session, target) {
248
+ const task = await this.resolve(session, target);
249
+ if (!isClosed(task.status)) {
250
+ throw new ValidationError(`${formatTaskRef(task.number)} is already open.`);
251
+ }
252
+ return this.update(session, task, { status: 'todo' });
253
+ }
254
+ async comment(session, target, body, options = {}) {
255
+ const text = parseInput(commentSchema, body);
256
+ const task = await this.resolve(session, target);
257
+ await this.repos.transaction(async () => {
258
+ const comment = await this.repos.comments.create({ ...(options.id ? { id: options.id } : {}), taskId: task.id, userId: session.user.id, body: text });
259
+ await this.repos.activity.record(task.id, session.user.id, {
260
+ type: 'comment_added',
261
+ metadata: { commentId: comment.id },
262
+ });
263
+ // Commenting counts as touching the task.
264
+ await this.repos.tasks.update(task.id, {});
265
+ });
266
+ }
267
+ /**
268
+ * Records a Git fact that happened on this machine (commit, merge, push…),
269
+ * optionally updating the task's branch bookkeeping, in one transaction.
270
+ */
271
+ async recordGitEvent(session, target, event, fields = {}) {
272
+ const task = await this.resolve(session, target);
273
+ const updated = await this.repos.transaction(async () => {
274
+ const result = await this.repos.tasks.update(task.id, fields);
275
+ await this.repos.activity.record(task.id, session.user.id, event);
276
+ return result;
277
+ });
278
+ return this.presentOne(session, updated);
279
+ }
280
+ /** Requesters used before in this workspace, most frequent first. Feeds autocomplete. */
281
+ async knownRequesters(session) {
282
+ const tasks = await this.repos.tasks.list({ workspaceId: session.workspace.id });
283
+ const counts = new Map();
284
+ for (const task of tasks)
285
+ if (task.requester)
286
+ counts.set(task.requester, (counts.get(task.requester) ?? 0) + 1);
287
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
288
+ }
289
+ async resolve(session, target) {
290
+ if (typeof target === 'object') {
291
+ const task = await this.repos.tasks.findById(target.id);
292
+ if (task && task.workspaceId === session.workspace.id)
293
+ return task;
294
+ throw new NotFoundError('That task no longer exists.');
295
+ }
296
+ const number = typeof target === 'number' ? target : parseTaskRef(target);
297
+ if (number === null) {
298
+ throw new ValidationError(`“${String(target)}” is not a task ID.`, { hint: 'Use something like SOJA-12.' });
299
+ }
300
+ const task = await this.repos.tasks.findByNumber(session.workspace.id, number);
301
+ if (!task)
302
+ throw new NotFoundError(`${formatTaskRef(number)} does not exist in ${session.workspace.name}.`);
303
+ return task;
304
+ }
305
+ async assertProject(session, projectId) {
306
+ if (projectId === null)
307
+ return;
308
+ const project = await this.repos.projects.findById(projectId);
309
+ if (!project || project.workspaceId !== session.workspace.id) {
310
+ throw new NotFoundError('That project does not exist in this workspace.');
311
+ }
312
+ }
313
+ async assertMember(session, userId) {
314
+ if (userId === null)
315
+ return;
316
+ if (!(await this.repos.workspaces.findMember(session.workspace.id, userId))) {
317
+ throw new ValidationError(`Only members of ${session.workspace.name} can be assigned.`);
318
+ }
319
+ }
320
+ async presentOne(session, task) {
321
+ return (await this.presenter(session, [task]))(task);
322
+ }
323
+ async present(session, tasks) {
324
+ if (tasks.length === 0)
325
+ return [];
326
+ return tasks.map(await this.presenter(session, tasks));
327
+ }
328
+ /** Loads the projects and assignees `tasks` reference, and returns a mapper to views. */
329
+ async presenter(session, tasks) {
330
+ const assigneeIds = [...new Set(tasks.flatMap((task) => (task.assigneeId ? [task.assigneeId] : [])))];
331
+ const [projects, users] = await Promise.all([
332
+ this.repos.projects.listByWorkspace(session.workspace.id),
333
+ this.repos.users.findByIds(assigneeIds),
334
+ ]);
335
+ const projectById = new Map(projects.map((project) => [project.id, toProjectRef(project)]));
336
+ const userById = new Map(users.map((user) => [user.id, toUserRef(user)]));
337
+ return (task) => ({
338
+ ...task,
339
+ ref: formatTaskRef(task.number),
340
+ project: task.projectId ? (projectById.get(task.projectId) ?? null) : null,
341
+ assignee: task.assigneeId ? (userById.get(task.assigneeId) ?? null) : null,
342
+ });
343
+ }
344
+ }
345
+ function toUserRef(user) {
346
+ return { id: user.id, username: user.username, displayName: user.displayName };
347
+ }
348
+ function toProjectRef(project) {
349
+ return { id: project.id, name: project.name, key: project.key };
350
+ }
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
4
+ /** Newer versions of SOJA from npm. Checks at most once a day unless asked. */
5
+ export class UpdateService {
6
+ releases;
7
+ current;
8
+ cacheFile;
9
+ clock;
10
+ constructor(releases, current, cacheFile, clock = () => new Date()) {
11
+ this.releases = releases;
12
+ this.current = current;
13
+ this.cacheFile = cacheFile;
14
+ this.clock = clock;
15
+ }
16
+ /** Fresh from GitHub. */
17
+ async check() {
18
+ const latest = (await this.releases.latest()).replace(/^v/, '');
19
+ this.remember(latest);
20
+ return { current: this.current, latest, available: isNewer(latest, this.current) };
21
+ }
22
+ /** For a quiet notice: uses yesterday's answer when it is recent, and never throws. */
23
+ async cachedCheck() {
24
+ try {
25
+ const cached = JSON.parse(readFileSync(this.cacheFile, 'utf8'));
26
+ if (this.clock().getTime() - new Date(cached.checkedAt).getTime() < CHECK_EVERY_MS) {
27
+ return { current: this.current, latest: cached.latest, available: isNewer(cached.latest, this.current) };
28
+ }
29
+ }
30
+ catch {
31
+ // No cache yet, or unreadable: ask npm.
32
+ }
33
+ return this.check().catch(() => null);
34
+ }
35
+ /** Downloads and installs `latest` (the terminal is attached for npm). */
36
+ async install(latest) {
37
+ const file = await this.releases.download(latest);
38
+ await this.releases.install(file);
39
+ }
40
+ remember(latest) {
41
+ try {
42
+ mkdirSync(dirname(this.cacheFile), { recursive: true });
43
+ writeFileSync(this.cacheFile, JSON.stringify({ checkedAt: this.clock().toISOString(), latest }));
44
+ }
45
+ catch {
46
+ // The cache only saves a request.
47
+ }
48
+ }
49
+ }
50
+ /** `1.10.0` > `1.9.2`. Pre-release suffixes are ignored. */
51
+ export function isNewer(candidate, current) {
52
+ const parts = (version) => version.split('-')[0]?.split('.').map((part) => Number(part) || 0) ?? [];
53
+ const [a, b] = [parts(candidate), parts(current)];
54
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
55
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
56
+ if (difference !== 0)
57
+ return difference > 0;
58
+ }
59
+ return false;
60
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import { ConflictError, NotFoundError } from '../../domain/errors.js';
3
+ import { makeUnique, slugify } from '../../domain/naming.js';
4
+ import { displayNameSchema, optionalText, parseInput, usernameSchema } from '../validation.js';
5
+ const createWorkspaceSchema = z.object({
6
+ name: z.string().trim().min(1, { error: 'Name the workspace.' }).max(60),
7
+ description: optionalText(500),
8
+ });
9
+ const addMemberSchema = z.object({
10
+ username: usernameSchema,
11
+ displayName: displayNameSchema.optional(),
12
+ });
13
+ export class WorkspaceService {
14
+ repos;
15
+ config;
16
+ constructor(repos, config) {
17
+ this.repos = repos;
18
+ this.config = config;
19
+ }
20
+ list(user) {
21
+ return this.repos.workspaces.listForUser(user.id);
22
+ }
23
+ async create(owner, input) {
24
+ const { name, description } = parseInput(createWorkspaceSchema, input);
25
+ return this.repos.transaction(async () => {
26
+ const slug = await uniqueSlug(slugify(name) || 'workspace', (candidate) => this.repos.workspaces.findBySlug(candidate));
27
+ const workspace = await this.repos.workspaces.create({ name, slug, description: description ?? null });
28
+ await this.repos.workspaces.addMember({ workspaceId: workspace.id, userId: owner.id, role: 'owner' });
29
+ return workspace;
30
+ });
31
+ }
32
+ /** Makes a workspace active and remembers it in the config file. Accepts an id or a slug. */
33
+ async switchTo(user, idOrSlug) {
34
+ const workspace = (await this.repos.workspaces.findById(idOrSlug)) ?? (await this.repos.workspaces.findBySlug(slugify(idOrSlug)));
35
+ if (!workspace || !(await this.repos.workspaces.findMember(workspace.id, user.id))) {
36
+ throw new NotFoundError(`You are not a member of a workspace called “${idOrSlug}”.`, {
37
+ hint: 'List yours with `soja workspace list`.',
38
+ });
39
+ }
40
+ // Keep machine settings (parent folders, remote server) when switching.
41
+ const current = this.config.load();
42
+ this.config.save({ parentFolders: [], ...current, mode: 'local', userId: user.id, workspaceId: workspace.id });
43
+ return { user, workspace };
44
+ }
45
+ async members(session) {
46
+ const members = await this.repos.workspaces.listMembers(session.workspace.id);
47
+ return members.map(({ id, username, displayName, role }) => ({ id, username, displayName, role }));
48
+ }
49
+ async findMember(session, username) {
50
+ const normalized = username.trim().replace(/^@/, '').toLowerCase();
51
+ const member = (await this.members(session)).find((candidate) => candidate.username === normalized);
52
+ if (!member) {
53
+ throw new NotFoundError(`@${normalized} is not a member of ${session.workspace.name}.`);
54
+ }
55
+ return member;
56
+ }
57
+ /**
58
+ * Adds a developer to the workspace. In local mode developers are plain
59
+ * records (no login), so an unknown username creates one.
60
+ */
61
+ async addMember(session, input) {
62
+ const { username, displayName } = parseInput(addMemberSchema, input);
63
+ return this.repos.transaction(async () => {
64
+ const user = (await this.repos.users.findByUsername(username)) ??
65
+ (await this.repos.users.create({ username, displayName: displayName ?? username }));
66
+ if (await this.repos.workspaces.findMember(session.workspace.id, user.id)) {
67
+ throw new ConflictError(`@${username} is already in ${session.workspace.name}.`);
68
+ }
69
+ await this.repos.workspaces.addMember({ workspaceId: session.workspace.id, userId: user.id, role: 'member' });
70
+ return { id: user.id, username: user.username, displayName: user.displayName, role: 'member' };
71
+ });
72
+ }
73
+ }
74
+ async function uniqueSlug(base, find) {
75
+ const taken = new Set();
76
+ for (let candidate = base;; candidate = makeUnique(base, (value) => taken.has(value), '-')) {
77
+ if (!(await find(candidate)))
78
+ return candidate;
79
+ taken.add(candidate);
80
+ }
81
+ }