soja-cli 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, count, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
|
|
3
|
+
import { taskActivity, taskComments, tasks, workspaces } from '../../database/schema.js';
|
|
4
|
+
import { NotFoundError } from '../../domain/errors.js';
|
|
5
|
+
import { must } from './users.js';
|
|
6
|
+
export class LocalTaskRepository {
|
|
7
|
+
db;
|
|
8
|
+
clock;
|
|
9
|
+
constructor(db, clock) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
this.clock = clock;
|
|
12
|
+
}
|
|
13
|
+
async create(input) {
|
|
14
|
+
const now = this.clock();
|
|
15
|
+
// Computing the number inside the INSERT keeps it atomic in SQLite.
|
|
16
|
+
// Provisional (negative) numbers never count towards the next real one, and
|
|
17
|
+
// numbers of deleted tasks are never handed out again.
|
|
18
|
+
const nextNumber = sql `(SELECT MAX(COALESCE(MAX(${tasks.number}), 0), COALESCE((SELECT ${workspaces.lastDeletedNumber} FROM ${workspaces} WHERE ${workspaces.id} = ${input.workspaceId}), 0)) + 1 FROM ${tasks} WHERE ${tasks.workspaceId} = ${input.workspaceId})`;
|
|
19
|
+
const [task] = await this.db
|
|
20
|
+
.insert(tasks)
|
|
21
|
+
.values({
|
|
22
|
+
startedAt: null,
|
|
23
|
+
completedAt: null,
|
|
24
|
+
archivedAt: null,
|
|
25
|
+
...input,
|
|
26
|
+
id: input.id ?? randomUUID(),
|
|
27
|
+
number: input.number ?? nextNumber,
|
|
28
|
+
createdAt: now,
|
|
29
|
+
updatedAt: now,
|
|
30
|
+
})
|
|
31
|
+
.returning();
|
|
32
|
+
return must(task);
|
|
33
|
+
}
|
|
34
|
+
async findById(id) {
|
|
35
|
+
return (await this.db.select().from(tasks).where(eq(tasks.id, id)).get()) ?? null;
|
|
36
|
+
}
|
|
37
|
+
async findByNumber(workspaceId, number) {
|
|
38
|
+
const row = await this.db
|
|
39
|
+
.select()
|
|
40
|
+
.from(tasks)
|
|
41
|
+
.where(and(eq(tasks.workspaceId, workspaceId), eq(tasks.number, number)))
|
|
42
|
+
.get();
|
|
43
|
+
return row ?? null;
|
|
44
|
+
}
|
|
45
|
+
async list(query) {
|
|
46
|
+
const conditions = [eq(tasks.workspaceId, query.workspaceId)];
|
|
47
|
+
if (query.assigneeId)
|
|
48
|
+
conditions.push(eq(tasks.assigneeId, query.assigneeId));
|
|
49
|
+
if (query.projectId)
|
|
50
|
+
conditions.push(eq(tasks.projectId, query.projectId));
|
|
51
|
+
if (query.statuses)
|
|
52
|
+
conditions.push(inArray(tasks.status, [...query.statuses]));
|
|
53
|
+
const archived = query.archived ?? 'exclude';
|
|
54
|
+
if (archived === 'exclude')
|
|
55
|
+
conditions.push(isNull(tasks.archivedAt));
|
|
56
|
+
else if (archived === 'only')
|
|
57
|
+
conditions.push(isNotNull(tasks.archivedAt));
|
|
58
|
+
if (query.search) {
|
|
59
|
+
const pattern = `%${escapeLike(query.search.text)}%`;
|
|
60
|
+
const byTitle = sql `${tasks.title} LIKE ${pattern} ESCAPE '\\'`;
|
|
61
|
+
const byNumber = query.search.number === null ? undefined : eq(tasks.number, query.search.number);
|
|
62
|
+
conditions.push(or(byTitle, byNumber) ?? byTitle);
|
|
63
|
+
}
|
|
64
|
+
const statement = this.db
|
|
65
|
+
.select()
|
|
66
|
+
.from(tasks)
|
|
67
|
+
.where(and(...conditions))
|
|
68
|
+
.orderBy(desc(tasks.number));
|
|
69
|
+
return query.limit ? statement.limit(query.limit) : statement;
|
|
70
|
+
}
|
|
71
|
+
async update(id, patch) {
|
|
72
|
+
const [task] = await this.db
|
|
73
|
+
.update(tasks)
|
|
74
|
+
.set({ ...patch, updatedAt: this.clock() })
|
|
75
|
+
.where(eq(tasks.id, id))
|
|
76
|
+
.returning();
|
|
77
|
+
if (!task)
|
|
78
|
+
throw new NotFoundError('That task no longer exists.');
|
|
79
|
+
return task;
|
|
80
|
+
}
|
|
81
|
+
async countByProjectAndStatus(workspaceId) {
|
|
82
|
+
return this.db
|
|
83
|
+
.select({ projectId: tasks.projectId, status: tasks.status, count: count() })
|
|
84
|
+
.from(tasks)
|
|
85
|
+
.where(and(eq(tasks.workspaceId, workspaceId), isNull(tasks.archivedAt)))
|
|
86
|
+
.groupBy(tasks.projectId, tasks.status);
|
|
87
|
+
}
|
|
88
|
+
async delete(id) {
|
|
89
|
+
const task = await this.findById(id);
|
|
90
|
+
if (task && task.number > 0) {
|
|
91
|
+
await this.db
|
|
92
|
+
.update(workspaces)
|
|
93
|
+
.set({ lastDeletedNumber: sql `MAX(${workspaces.lastDeletedNumber}, ${task.number})` })
|
|
94
|
+
.where(eq(workspaces.id, task.workspaceId));
|
|
95
|
+
}
|
|
96
|
+
// Explicit, not only ON DELETE CASCADE: the remote replica runs without foreign keys.
|
|
97
|
+
await this.db.delete(taskActivity).where(eq(taskActivity.taskId, id));
|
|
98
|
+
await this.db.delete(taskComments).where(eq(taskComments.taskId, id));
|
|
99
|
+
await this.db.delete(tasks).where(eq(tasks.id, id));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function escapeLike(value) {
|
|
103
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { eq, inArray } from 'drizzle-orm';
|
|
3
|
+
import { users } from '../../database/schema.js';
|
|
4
|
+
export class LocalUserRepository {
|
|
5
|
+
db;
|
|
6
|
+
clock;
|
|
7
|
+
constructor(db, clock) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.clock = clock;
|
|
10
|
+
}
|
|
11
|
+
async findById(id) {
|
|
12
|
+
return (await this.db.select().from(users).where(eq(users.id, id)).get()) ?? null;
|
|
13
|
+
}
|
|
14
|
+
async findByUsername(username) {
|
|
15
|
+
return (await this.db.select().from(users).where(eq(users.username, username)).get()) ?? null;
|
|
16
|
+
}
|
|
17
|
+
async findByIds(ids) {
|
|
18
|
+
if (ids.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
return this.db
|
|
21
|
+
.select()
|
|
22
|
+
.from(users)
|
|
23
|
+
.where(inArray(users.id, [...ids]));
|
|
24
|
+
}
|
|
25
|
+
async create(input) {
|
|
26
|
+
const now = this.clock();
|
|
27
|
+
const [user] = await this.db
|
|
28
|
+
.insert(users)
|
|
29
|
+
.values({ id: randomUUID(), email: null, ...input, createdAt: now, updatedAt: now })
|
|
30
|
+
.returning();
|
|
31
|
+
return must(user);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function must(row) {
|
|
35
|
+
if (row === undefined)
|
|
36
|
+
throw new Error('Expected the database to return a row');
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, eq } from 'drizzle-orm';
|
|
3
|
+
import { users, workspaceMembers, workspaces } from '../../database/schema.js';
|
|
4
|
+
import { must } from './users.js';
|
|
5
|
+
export class LocalWorkspaceRepository {
|
|
6
|
+
db;
|
|
7
|
+
clock;
|
|
8
|
+
constructor(db, clock) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.clock = clock;
|
|
11
|
+
}
|
|
12
|
+
async findById(id) {
|
|
13
|
+
return (await this.db.select().from(workspaces).where(eq(workspaces.id, id)).get()) ?? null;
|
|
14
|
+
}
|
|
15
|
+
async findBySlug(slug) {
|
|
16
|
+
return (await this.db.select().from(workspaces).where(eq(workspaces.slug, slug)).get()) ?? null;
|
|
17
|
+
}
|
|
18
|
+
async create(input) {
|
|
19
|
+
const now = this.clock();
|
|
20
|
+
const [workspace] = await this.db
|
|
21
|
+
.insert(workspaces)
|
|
22
|
+
.values({ id: randomUUID(), description: null, ...input, createdAt: now, updatedAt: now })
|
|
23
|
+
.returning();
|
|
24
|
+
return must(workspace);
|
|
25
|
+
}
|
|
26
|
+
async listForUser(userId) {
|
|
27
|
+
const rows = await this.db
|
|
28
|
+
.select({ workspace: workspaces, role: workspaceMembers.role })
|
|
29
|
+
.from(workspaceMembers)
|
|
30
|
+
.innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId))
|
|
31
|
+
.where(eq(workspaceMembers.userId, userId))
|
|
32
|
+
.orderBy(asc(workspaces.name));
|
|
33
|
+
return rows.map((row) => ({ ...row.workspace, role: row.role }));
|
|
34
|
+
}
|
|
35
|
+
async addMember(member) {
|
|
36
|
+
await this.db.insert(workspaceMembers).values(member);
|
|
37
|
+
}
|
|
38
|
+
async findMember(workspaceId, userId) {
|
|
39
|
+
const row = await this.db
|
|
40
|
+
.select()
|
|
41
|
+
.from(workspaceMembers)
|
|
42
|
+
.where(and(eq(workspaceMembers.workspaceId, workspaceId), eq(workspaceMembers.userId, userId)))
|
|
43
|
+
.get();
|
|
44
|
+
return row ?? null;
|
|
45
|
+
}
|
|
46
|
+
async listMembers(workspaceId) {
|
|
47
|
+
const rows = await this.db
|
|
48
|
+
.select({ user: users, role: workspaceMembers.role })
|
|
49
|
+
.from(workspaceMembers)
|
|
50
|
+
.innerJoin(users, eq(users.id, workspaceMembers.userId))
|
|
51
|
+
.where(eq(workspaceMembers.workspaceId, workspaceId))
|
|
52
|
+
.orderBy(asc(users.username));
|
|
53
|
+
return rows.map((row) => ({ ...row.user, role: row.role }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ConflictError, NotFoundError, SojaError, ValidationError } from '../../domain/errors.js';
|
|
2
|
+
import { isSecureServerUrl } from '../../config/server.js';
|
|
3
|
+
import { terminalSafe } from '../../utils/text.js';
|
|
4
|
+
/** The server could not be reached at all (as opposed to answering with an error). */
|
|
5
|
+
export class OfflineError extends SojaError {
|
|
6
|
+
}
|
|
7
|
+
/** The server rejected the session token. */
|
|
8
|
+
export class SessionExpiredError extends SojaError {
|
|
9
|
+
}
|
|
10
|
+
/** `…At` fields arrive as ISO strings; the rest of SOJA works with Dates. */
|
|
11
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
12
|
+
/**
|
|
13
|
+
* Every string from the server is made terminal-safe here, the single door
|
|
14
|
+
* remote data comes through (other people's titles, comments, names…), and
|
|
15
|
+
* `…At` fields become Dates.
|
|
16
|
+
*/
|
|
17
|
+
function reviveDates(key, value) {
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
return value;
|
|
20
|
+
if (key.endsWith('At') && ISO_DATE.test(value))
|
|
21
|
+
return new Date(value);
|
|
22
|
+
return terminalSafe(value);
|
|
23
|
+
}
|
|
24
|
+
/** Parses JSON from the server the same way the HTTP client does (safe text, Dates). */
|
|
25
|
+
export function parseServerJson(text) {
|
|
26
|
+
return JSON.parse(text, reviveDates);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* JSON over HTTPS to a SOJA server. Server errors become the same error
|
|
30
|
+
* classes local mode throws, so the interface shows them the same way.
|
|
31
|
+
*/
|
|
32
|
+
export class ApiClient {
|
|
33
|
+
baseUrl;
|
|
34
|
+
token;
|
|
35
|
+
fetcher;
|
|
36
|
+
constructor(baseUrl, token, fetcher = fetch) {
|
|
37
|
+
this.baseUrl = baseUrl;
|
|
38
|
+
this.token = token;
|
|
39
|
+
this.fetcher = fetcher;
|
|
40
|
+
if (!isSecureServerUrl(baseUrl)) {
|
|
41
|
+
throw new ValidationError('SOJA servers must use HTTPS (HTTP is allowed only on localhost for development).');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
get(path) {
|
|
45
|
+
return this.request('GET', path);
|
|
46
|
+
}
|
|
47
|
+
post(path, body) {
|
|
48
|
+
return this.request('POST', path, body ?? {});
|
|
49
|
+
}
|
|
50
|
+
patch(path, body) {
|
|
51
|
+
return this.request('PATCH', path, body);
|
|
52
|
+
}
|
|
53
|
+
delete(path) {
|
|
54
|
+
return this.request('DELETE', path);
|
|
55
|
+
}
|
|
56
|
+
/** Like `post`, but also returns the HTTP status (for the login poll's 202). */
|
|
57
|
+
async postWithStatus(path, body) {
|
|
58
|
+
const response = await this.send('POST', path, body);
|
|
59
|
+
return { status: response.status, body: await this.parse(response) };
|
|
60
|
+
}
|
|
61
|
+
async request(method, path, body) {
|
|
62
|
+
return this.parse(await this.send(method, path, body));
|
|
63
|
+
}
|
|
64
|
+
async send(method, path, body) {
|
|
65
|
+
try {
|
|
66
|
+
return await this.fetcher(`${this.baseUrl}${path}`, {
|
|
67
|
+
method,
|
|
68
|
+
headers: {
|
|
69
|
+
Accept: 'application/json',
|
|
70
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
71
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
72
|
+
},
|
|
73
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
74
|
+
signal: AbortSignal.timeout(20_000),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new OfflineError(`Could not reach the SOJA server at ${this.baseUrl}.`, {
|
|
79
|
+
hint: 'Check your connection. Remote mode needs the server (offline work arrives in v0.4); `soja mode local` works offline.',
|
|
80
|
+
cause: error,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async parse(response) {
|
|
85
|
+
const text = await response.text();
|
|
86
|
+
const data = text ? JSON.parse(text, reviveDates) : null;
|
|
87
|
+
if (response.ok)
|
|
88
|
+
return data;
|
|
89
|
+
const error = data?.error;
|
|
90
|
+
const message = error?.message ?? `The SOJA server answered ${response.status}.`;
|
|
91
|
+
const options = { ...(error?.hint ? { hint: error.hint } : {}) };
|
|
92
|
+
if (response.status === 401) {
|
|
93
|
+
throw new SessionExpiredError(message, { hint: error?.hint ?? 'Run `soja login`.' });
|
|
94
|
+
}
|
|
95
|
+
if (response.status === 400)
|
|
96
|
+
throw new ValidationError(message, options);
|
|
97
|
+
if (response.status === 404)
|
|
98
|
+
throw new NotFoundError(message, options);
|
|
99
|
+
if (response.status === 409)
|
|
100
|
+
throw new ConflictError(message, options);
|
|
101
|
+
throw new SojaError(message, options);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parseServerJson } from './api-client.js';
|
|
2
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
3
|
+
/** Closes that mean retrying cannot help (bad token or no access). */
|
|
4
|
+
const FATAL_CLOSE = new Set([4401, 4403]);
|
|
5
|
+
/**
|
|
6
|
+
* The real-time channel to the SOJA server (`/v1/live`). It only notifies:
|
|
7
|
+
* messages still travel as sync operations, so nothing is lost while it is
|
|
8
|
+
* down. Reconnects with exponential backoff (1 s → 30 s).
|
|
9
|
+
*/
|
|
10
|
+
export class LiveConnection {
|
|
11
|
+
options;
|
|
12
|
+
socket = null;
|
|
13
|
+
timer = null;
|
|
14
|
+
attempts = 0;
|
|
15
|
+
stopped = false;
|
|
16
|
+
ready = false;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
get connected() {
|
|
21
|
+
return this.ready;
|
|
22
|
+
}
|
|
23
|
+
start() {
|
|
24
|
+
this.stopped = false;
|
|
25
|
+
this.connect();
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
this.stopped = true;
|
|
29
|
+
if (this.timer)
|
|
30
|
+
clearTimeout(this.timer);
|
|
31
|
+
this.timer = null;
|
|
32
|
+
this.ready = false;
|
|
33
|
+
this.socket?.close(1000);
|
|
34
|
+
this.socket = null;
|
|
35
|
+
}
|
|
36
|
+
connect() {
|
|
37
|
+
if (this.stopped)
|
|
38
|
+
return;
|
|
39
|
+
const url = new URL('/v1/live', this.options.apiUrl);
|
|
40
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
41
|
+
const Socket = this.options.WebSocket ?? globalThis.WebSocket;
|
|
42
|
+
let socket;
|
|
43
|
+
try {
|
|
44
|
+
socket = new Socket(url);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
this.retry();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.socket = socket;
|
|
51
|
+
socket.addEventListener('open', () => {
|
|
52
|
+
// The token goes in the first message, never in the URL (URLs end up in logs).
|
|
53
|
+
socket.send(JSON.stringify({ type: 'auth', token: this.options.token, workspaceId: this.options.workspaceId }));
|
|
54
|
+
});
|
|
55
|
+
socket.addEventListener('message', (message) => {
|
|
56
|
+
let event;
|
|
57
|
+
try {
|
|
58
|
+
event = parseServerJson(String(message.data));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (event.type === 'ready') {
|
|
64
|
+
this.ready = true;
|
|
65
|
+
this.attempts = 0;
|
|
66
|
+
this.options.onReady();
|
|
67
|
+
}
|
|
68
|
+
else if (this.ready && typeof event.type === 'string') {
|
|
69
|
+
this.options.onEvent(event);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.addEventListener('close', (event) => {
|
|
73
|
+
this.ready = false;
|
|
74
|
+
if (this.socket === socket)
|
|
75
|
+
this.socket = null;
|
|
76
|
+
if (!FATAL_CLOSE.has(event.code))
|
|
77
|
+
this.retry();
|
|
78
|
+
});
|
|
79
|
+
// An 'error' is always followed by 'close', which schedules the retry.
|
|
80
|
+
socket.addEventListener('error', () => undefined);
|
|
81
|
+
}
|
|
82
|
+
retry() {
|
|
83
|
+
if (this.stopped || this.timer)
|
|
84
|
+
return;
|
|
85
|
+
const delay = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** this.attempts);
|
|
86
|
+
this.attempts += 1;
|
|
87
|
+
this.timer = setTimeout(() => {
|
|
88
|
+
this.timer = null;
|
|
89
|
+
this.connect();
|
|
90
|
+
}, delay);
|
|
91
|
+
this.timer.unref();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { CHANNEL_NAME, MAX_MESSAGE_LENGTH, mentions, normalizeChannelName, taskRefsIn } from '../../domain/chat.js';
|
|
3
|
+
import { NotFoundError, SojaError, ValidationError } from '../../domain/errors.js';
|
|
4
|
+
import { formatTaskRef } from '../../domain/task.js';
|
|
5
|
+
import { terminalSafe } from '../../utils/text.js';
|
|
6
|
+
import { OfflineError } from '../remote/api-client.js';
|
|
7
|
+
const TITLE_MAX = 120;
|
|
8
|
+
/**
|
|
9
|
+
* Chat in remote mode: messages live in the replica, so the chat opens and
|
|
10
|
+
* reads offline; sends, edits, deletes and read marks are queued operations
|
|
11
|
+
* the next sync delivers (right away when online).
|
|
12
|
+
*/
|
|
13
|
+
export class ReplicaChatService {
|
|
14
|
+
context;
|
|
15
|
+
tasks;
|
|
16
|
+
constructor(context, tasks) {
|
|
17
|
+
this.context = context;
|
|
18
|
+
this.tasks = tasks;
|
|
19
|
+
}
|
|
20
|
+
async channels(session) {
|
|
21
|
+
const [channels, reads] = await Promise.all([this.context.store.channels(session.workspace.id), this.context.store.reads()]);
|
|
22
|
+
const summaries = await Promise.all(channels.map((channel) => this.summarize(session, channel, reads.get(channel.id) ?? 0)));
|
|
23
|
+
// #general first, archived last, the rest by name.
|
|
24
|
+
return summaries.sort((a, b) => Number(a.archivedAt !== null) - Number(b.archivedAt !== null) ||
|
|
25
|
+
Number(b.name === 'general') - Number(a.name === 'general') ||
|
|
26
|
+
a.name.localeCompare(b.name));
|
|
27
|
+
}
|
|
28
|
+
async totals(session) {
|
|
29
|
+
const channels = await this.channels(session);
|
|
30
|
+
return channels.reduce((sum, channel) => ({ unread: sum.unread + channel.unread, mentions: sum.mentions + channel.mentions }), { unread: 0, mentions: 0 });
|
|
31
|
+
}
|
|
32
|
+
async channel(session, target) {
|
|
33
|
+
const name = normalizeChannelName(target);
|
|
34
|
+
const channel = (await this.channels(session)).find((candidate) => candidate.id === target || candidate.name === name);
|
|
35
|
+
if (!channel) {
|
|
36
|
+
throw new NotFoundError(`There is no #${name} channel.`, { hint: 'List them with `soja chat channels`; a sync may bring new ones.' });
|
|
37
|
+
}
|
|
38
|
+
return channel;
|
|
39
|
+
}
|
|
40
|
+
async messages(session, channelId, limit = 200) {
|
|
41
|
+
const rows = ordered(await this.context.store.messagesIn(channelId));
|
|
42
|
+
return this.views(session, rows.slice(-limit), rows);
|
|
43
|
+
}
|
|
44
|
+
async send(session, channel, body, options = {}) {
|
|
45
|
+
const target = await this.channel(session, channel);
|
|
46
|
+
if (target.archivedAt)
|
|
47
|
+
throw new ValidationError(`#${target.name} is archived.`, { hint: 'Write in another channel.' });
|
|
48
|
+
const text = cleanBody(body);
|
|
49
|
+
const replyToId = options.replyToId ?? null;
|
|
50
|
+
if (replyToId) {
|
|
51
|
+
const original = await this.context.store.findMessage(replyToId);
|
|
52
|
+
if (!original || original.channelId !== target.id)
|
|
53
|
+
throw new ValidationError('You can only reply to a message in the same channel.');
|
|
54
|
+
}
|
|
55
|
+
const message = {
|
|
56
|
+
id: randomUUID(),
|
|
57
|
+
seq: null,
|
|
58
|
+
workspaceId: session.workspace.id,
|
|
59
|
+
channelId: target.id,
|
|
60
|
+
authorId: session.user.id,
|
|
61
|
+
body: text,
|
|
62
|
+
replyToId,
|
|
63
|
+
createdAt: new Date(),
|
|
64
|
+
editedAt: null,
|
|
65
|
+
deletedAt: null,
|
|
66
|
+
};
|
|
67
|
+
await this.context.store.upsertMessage(message);
|
|
68
|
+
await this.enqueue(session, 'message.send', { messageId: message.id, channelId: target.id, body: text, ...(replyToId ? { replyToId } : {}) });
|
|
69
|
+
const [view] = await this.views(session, [message], [message]);
|
|
70
|
+
if (!view)
|
|
71
|
+
throw new SojaError('The message could not be prepared.');
|
|
72
|
+
return view;
|
|
73
|
+
}
|
|
74
|
+
async edit(session, messageId, body) {
|
|
75
|
+
const message = await this.own(session, messageId, 'edit');
|
|
76
|
+
const text = cleanBody(body);
|
|
77
|
+
if (text === message.body)
|
|
78
|
+
return;
|
|
79
|
+
await this.context.store.patchMessage(messageId, { body: text, editedAt: new Date() });
|
|
80
|
+
await this.enqueue(session, 'message.edit', { messageId, body: text });
|
|
81
|
+
}
|
|
82
|
+
async remove(session, messageId) {
|
|
83
|
+
await this.own(session, messageId, 'delete');
|
|
84
|
+
await this.context.store.patchMessage(messageId, { body: '', deletedAt: new Date() });
|
|
85
|
+
await this.enqueue(session, 'message.delete', { messageId });
|
|
86
|
+
}
|
|
87
|
+
async markRead(session, channelId) {
|
|
88
|
+
const seqs = (await this.context.store.messagesIn(channelId)).map((message) => message.seq ?? 0);
|
|
89
|
+
const latest = Math.max(0, ...seqs);
|
|
90
|
+
const current = (await this.context.store.reads()).get(channelId) ?? 0;
|
|
91
|
+
if (latest <= current)
|
|
92
|
+
return;
|
|
93
|
+
await this.context.store.markRead(channelId, latest);
|
|
94
|
+
// Only the newest mark matters: replace one still waiting in the queue.
|
|
95
|
+
for (const op of await this.context.store.pending(session.workspace.id)) {
|
|
96
|
+
if (op.type === 'channel.read' && op.payload['channelId'] === channelId && op.attempts === 0)
|
|
97
|
+
await this.context.store.dropQueued(op.opId);
|
|
98
|
+
}
|
|
99
|
+
await this.enqueue(session, 'channel.read', { channelId, lastReadSeq: latest });
|
|
100
|
+
}
|
|
101
|
+
async createChannel(session, name, topic = null) {
|
|
102
|
+
const clean = normalizeChannelName(name);
|
|
103
|
+
if (!CHANNEL_NAME.test(clean))
|
|
104
|
+
throw new ValidationError('Channel names use lowercase letters, numbers and dashes (max 40).');
|
|
105
|
+
const channel = await this.online('Creating a channel', () => this.context.api.post(`/v1/workspaces/${session.workspace.id}/channels`, { name: clean, ...(topic ? { topic: terminalSafe(topic) } : {}) }));
|
|
106
|
+
await this.context.store.upsertChannel(channel);
|
|
107
|
+
return this.summarize(session, channel, 0);
|
|
108
|
+
}
|
|
109
|
+
async updateChannel(session, channelId, changes) {
|
|
110
|
+
const payload = {
|
|
111
|
+
...(changes.topic !== undefined ? { topic: changes.topic === null ? null : terminalSafe(changes.topic).trim() || null } : {}),
|
|
112
|
+
...(changes.archived !== undefined ? { archived: changes.archived } : {}),
|
|
113
|
+
};
|
|
114
|
+
const channel = await this.online('Changing a channel', () => this.context.api.patch(`/v1/workspaces/${session.workspace.id}/channels/${channelId}`, payload));
|
|
115
|
+
await this.context.store.upsertChannel(channel);
|
|
116
|
+
return this.channel(session, channel.id);
|
|
117
|
+
}
|
|
118
|
+
async loadOlder(session, channelId) {
|
|
119
|
+
const known = (await this.context.store.messagesIn(channelId)).flatMap((message) => (message.seq === null ? [] : [message.seq]));
|
|
120
|
+
const before = known.length ? Math.min(...known) : null;
|
|
121
|
+
const older = await this.online('Loading older messages', () => this.context.api.get(`/v1/workspaces/${session.workspace.id}/channels/${channelId}/messages?limit=100${before === null ? '' : `&before=${before}`}`));
|
|
122
|
+
for (const message of older)
|
|
123
|
+
await this.context.store.upsertMessage(message);
|
|
124
|
+
return older.length;
|
|
125
|
+
}
|
|
126
|
+
async mentionsOfTask(session, taskNumber, limit = 5) {
|
|
127
|
+
const ref = formatTaskRef(taskNumber);
|
|
128
|
+
const rows = (await this.context.store.messagesContaining(session.workspace.id, ref)).filter((message) => message.deletedAt === null && taskRefsIn(message.body).includes(taskNumber));
|
|
129
|
+
return this.views(session, ordered(rows).reverse().slice(0, limit), rows);
|
|
130
|
+
}
|
|
131
|
+
async taskFromMessage(session, messageId) {
|
|
132
|
+
const message = await this.context.store.findMessage(messageId);
|
|
133
|
+
if (!message || message.deletedAt)
|
|
134
|
+
throw new NotFoundError('That message no longer exists.');
|
|
135
|
+
const channel = await this.context.store.findChannel(message.channelId);
|
|
136
|
+
const [author] = message.authorId ? await this.context.repos.users.findByIds([message.authorId]) : [];
|
|
137
|
+
const firstLine = message.body.split('\n').find((line) => line.trim()) ?? message.body;
|
|
138
|
+
const title = firstLine.trim().length > TITLE_MAX ? `${firstLine.trim().slice(0, TITLE_MAX - 1)}…` : firstLine.trim();
|
|
139
|
+
const quote = message.body
|
|
140
|
+
.split('\n')
|
|
141
|
+
.map((line) => `> ${line}`)
|
|
142
|
+
.join('\n');
|
|
143
|
+
const task = await this.tasks.create(session, {
|
|
144
|
+
title,
|
|
145
|
+
description: `${author ? `@${author.username}` : 'Someone'} in #${channel?.name ?? 'chat'}:\n${quote}`,
|
|
146
|
+
...(author ? { requester: `@${author.username}` } : {}),
|
|
147
|
+
});
|
|
148
|
+
// Queued after the task, so the reply reaches the server with its real number.
|
|
149
|
+
if (channel && !channel.archivedAt)
|
|
150
|
+
await this.send(session, channel.id, `${symbolArrow} ${task.ref} ${task.title}`, { replyToId: message.id });
|
|
151
|
+
return task;
|
|
152
|
+
}
|
|
153
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
154
|
+
async summarize(session, channel, lastReadSeq) {
|
|
155
|
+
const unread = (await this.context.store.messagesIn(channel.id)).filter((message) => message.seq !== null && message.seq > lastReadSeq && message.authorId !== session.user.id && message.deletedAt === null);
|
|
156
|
+
return {
|
|
157
|
+
...channel,
|
|
158
|
+
lastReadSeq,
|
|
159
|
+
unread: unread.length,
|
|
160
|
+
mentions: unread.filter((message) => mentions(message.body, session.user.username)).length,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Adds authors, the quoted message, and flags. `pool` holds messages a reply may quote. */
|
|
164
|
+
async views(session, rows, pool) {
|
|
165
|
+
const pending = new Set();
|
|
166
|
+
for (const op of await this.context.store.pending(session.workspace.id)) {
|
|
167
|
+
if (op.type === 'message.send' || op.type === 'message.edit' || op.type === 'message.delete')
|
|
168
|
+
pending.add(String(op.payload['messageId']));
|
|
169
|
+
}
|
|
170
|
+
const byId = new Map(pool.map((message) => [message.id, message]));
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
if (row.replyToId && !byId.has(row.replyToId)) {
|
|
173
|
+
const quoted = await this.context.store.findMessage(row.replyToId);
|
|
174
|
+
if (quoted)
|
|
175
|
+
byId.set(quoted.id, quoted);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const authorIds = new Set();
|
|
179
|
+
for (const message of [...rows, ...byId.values()])
|
|
180
|
+
if (message.authorId)
|
|
181
|
+
authorIds.add(message.authorId);
|
|
182
|
+
const users = new Map((await this.context.repos.users.findByIds([...authorIds])).map((user) => [user.id, { id: user.id, username: user.username, displayName: user.displayName }]));
|
|
183
|
+
return rows.map((message) => {
|
|
184
|
+
const quoted = message.replyToId ? byId.get(message.replyToId) : undefined;
|
|
185
|
+
return {
|
|
186
|
+
...message,
|
|
187
|
+
author: message.authorId ? (users.get(message.authorId) ?? null) : null,
|
|
188
|
+
pending: pending.has(message.id),
|
|
189
|
+
mine: message.authorId === session.user.id,
|
|
190
|
+
mentionsMe: message.authorId !== session.user.id && mentions(message.body, session.user.username),
|
|
191
|
+
replyTo: quoted
|
|
192
|
+
? { id: quoted.id, author: quoted.authorId ? (users.get(quoted.authorId) ?? null) : null, body: quoted.body, deleted: quoted.deletedAt !== null }
|
|
193
|
+
: null,
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
async own(session, messageId, action) {
|
|
198
|
+
const message = await this.context.store.findMessage(messageId);
|
|
199
|
+
if (!message || message.deletedAt)
|
|
200
|
+
throw new NotFoundError('That message no longer exists.');
|
|
201
|
+
if (message.authorId !== session.user.id)
|
|
202
|
+
throw new ValidationError(`You can only ${action} your own messages.`);
|
|
203
|
+
return message;
|
|
204
|
+
}
|
|
205
|
+
async enqueue(session, type, payload) {
|
|
206
|
+
await this.context.store.enqueue({ opId: randomUUID(), workspaceId: session.workspace.id, type, taskId: null, payload, base: null, occurredAt: new Date() }, []);
|
|
207
|
+
this.context.onWrite();
|
|
208
|
+
}
|
|
209
|
+
async online(what, work) {
|
|
210
|
+
try {
|
|
211
|
+
return await work();
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error instanceof OfflineError) {
|
|
215
|
+
throw new SojaError(`${what} needs a connection to the SOJA server.`, { hint: 'Reading and sending messages work offline.', cause: error });
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const symbolArrow = '→';
|
|
222
|
+
/** Server order, with messages still waiting to be sent at the end in the order they were written. */
|
|
223
|
+
function ordered(rows) {
|
|
224
|
+
return [...rows].sort((a, b) => {
|
|
225
|
+
if (a.seq !== null && b.seq !== null)
|
|
226
|
+
return a.seq - b.seq;
|
|
227
|
+
if (a.seq !== null)
|
|
228
|
+
return -1;
|
|
229
|
+
if (b.seq !== null)
|
|
230
|
+
return 1;
|
|
231
|
+
return a.createdAt.getTime() - b.createdAt.getTime();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Terminal-safe, trimmed, and within the server's limit. */
|
|
235
|
+
function cleanBody(body) {
|
|
236
|
+
const text = terminalSafe(body).trim();
|
|
237
|
+
if (!text)
|
|
238
|
+
throw new ValidationError('The message is empty.');
|
|
239
|
+
if (text.length > MAX_MESSAGE_LENGTH)
|
|
240
|
+
throw new ValidationError(`Keep messages under ${MAX_MESSAGE_LENGTH} characters.`);
|
|
241
|
+
return text;
|
|
242
|
+
}
|