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,108 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { ValidationError } from '../../domain/errors.js';
|
|
3
|
+
import { symbols } from '../../ui/theme/theme.js';
|
|
4
|
+
import { bold, color, dim, print, success } from '../output.js';
|
|
5
|
+
import { withSession } from '../runtime.js';
|
|
6
|
+
import { parseCommand, requireArg } from './args.js';
|
|
7
|
+
import { tildify } from '../../utils/text.js';
|
|
8
|
+
export async function projectCommand(args) {
|
|
9
|
+
const [sub, ...rest] = args;
|
|
10
|
+
if (sub === undefined || sub === 'list' || sub === 'ls')
|
|
11
|
+
return list();
|
|
12
|
+
if (sub === 'create' || sub === 'new' || sub === 'add')
|
|
13
|
+
return create(rest);
|
|
14
|
+
if (sub === 'link')
|
|
15
|
+
return link(rest);
|
|
16
|
+
if (sub === 'unlink')
|
|
17
|
+
return unlink(rest);
|
|
18
|
+
if (sub === 'edit')
|
|
19
|
+
return edit(rest);
|
|
20
|
+
throw new ValidationError(`Unknown project command “${sub}”.`, { hint: 'Try: list, create, edit, link, unlink.' });
|
|
21
|
+
}
|
|
22
|
+
async function list() {
|
|
23
|
+
await withSession(async (services, session) => {
|
|
24
|
+
const projects = await services.projects.list(session);
|
|
25
|
+
print(`${bold('PROJECTS')} ${dim(session.workspace.name)}`);
|
|
26
|
+
print();
|
|
27
|
+
if (projects.length === 0) {
|
|
28
|
+
print(dim(' No projects yet. Add one with `soja project create <name>`.'));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const width = Math.max(...projects.map((project) => project.name.length)) + 3;
|
|
32
|
+
for (const project of projects) {
|
|
33
|
+
const extra = [
|
|
34
|
+
project.counts.in_progress ? color('yellow', `${project.counts.in_progress} in progress`) : '',
|
|
35
|
+
project.counts.review ? color('cyan', `${project.counts.review} review`) : '',
|
|
36
|
+
project.counts.blocked ? color('red', `${project.counts.blocked} blocked`) : '',
|
|
37
|
+
].filter(Boolean);
|
|
38
|
+
print(` ${dim(project.key.padEnd(8))}${project.name.padEnd(width)}${`${project.active} active`.padEnd(11)}${extra.join(dim(` ${symbols.dot} `))}`);
|
|
39
|
+
if (project.repositoryPath)
|
|
40
|
+
print(dim(` ${' '.repeat(8)}${tildify(project.repositoryPath)}`));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function create(args) {
|
|
45
|
+
const { values, positionals } = parseCommand(args, {
|
|
46
|
+
key: { type: 'string', short: 'k' },
|
|
47
|
+
description: { type: 'string', short: 'd' },
|
|
48
|
+
'repo-path': { type: 'string' },
|
|
49
|
+
'repo-url': { type: 'string' },
|
|
50
|
+
});
|
|
51
|
+
const name = requireArg(positionals.join(' ').trim(), 'project name', 'soja project create EnrollBridge --key ENROLL');
|
|
52
|
+
await withSession(async (services, session) => {
|
|
53
|
+
const project = await services.projects.create(session, {
|
|
54
|
+
name,
|
|
55
|
+
...(values.key ? { key: values.key } : {}),
|
|
56
|
+
description: values.description ?? null,
|
|
57
|
+
repositoryPath: values['repo-path'] ?? null,
|
|
58
|
+
repositoryUrl: values['repo-url'] ?? null,
|
|
59
|
+
});
|
|
60
|
+
success(`Created project ${bold(project.name)} ${dim(project.key)}`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function link(args) {
|
|
64
|
+
const { positionals } = parseCommand(args, {});
|
|
65
|
+
const key = requireArg(positionals[0], 'project', 'soja project link ENROLL [path]');
|
|
66
|
+
const target = positionals[1];
|
|
67
|
+
await withSession(async (services, session) => {
|
|
68
|
+
const project = await services.projects.resolve(session, key);
|
|
69
|
+
// A bare name that is not a folder here means a subfolder of the parent folders.
|
|
70
|
+
const path = target === undefined
|
|
71
|
+
? process.cwd()
|
|
72
|
+
: !target.includes('/') && !existsSync(target)
|
|
73
|
+
? services.folders.findChild(target).path
|
|
74
|
+
: target;
|
|
75
|
+
const linked = await services.projects.linkRepository(session, project, path);
|
|
76
|
+
success(`Linked ${bold(linked.name)} to ${tildify(linked.repositoryPath ?? path)}`);
|
|
77
|
+
if (linked.repositoryUrl)
|
|
78
|
+
print(dim(` ${linked.repositoryUrl}`));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function unlink(args) {
|
|
82
|
+
const { positionals } = parseCommand(args, {});
|
|
83
|
+
const key = requireArg(positionals[0], 'project', 'soja project unlink ENROLL');
|
|
84
|
+
await withSession(async (services, session) => {
|
|
85
|
+
const project = await services.projects.unlinkRepository(session, await services.projects.resolve(session, key));
|
|
86
|
+
success(`${bold(project.name)} is no longer linked to a repository`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** `soja project edit <key> [--name] [--description] [--url]`: an empty value clears description or URL. */
|
|
90
|
+
async function edit(args) {
|
|
91
|
+
const { values, positionals } = parseCommand(args, {
|
|
92
|
+
name: { type: 'string' },
|
|
93
|
+
description: { type: 'string', short: 'd' },
|
|
94
|
+
url: { type: 'string' },
|
|
95
|
+
});
|
|
96
|
+
const key = requireArg(positionals[0], 'project', 'soja project edit ENROLL --name "…" --description "…" --url "…"');
|
|
97
|
+
if (values.name === undefined && values.description === undefined && values.url === undefined) {
|
|
98
|
+
throw new ValidationError('Nothing to change.', { hint: 'Pass --name, --description or --url.' });
|
|
99
|
+
}
|
|
100
|
+
await withSession(async (services, session) => {
|
|
101
|
+
const project = await services.projects.update(session, await services.projects.resolve(session, key), {
|
|
102
|
+
...(values.name !== undefined ? { name: values.name } : {}),
|
|
103
|
+
...(values.description !== undefined ? { description: values.description } : {}),
|
|
104
|
+
...(values.url !== undefined ? { repositoryUrl: values.url } : {}),
|
|
105
|
+
});
|
|
106
|
+
success(`${bold(project.key)} ${project.name}${project.description ? dim(` ${project.description}`) : ''}`);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { statusStyles, symbols } from '../../ui/theme/theme.js';
|
|
2
|
+
import { bold, color, dim, print, success, token } from '../output.js';
|
|
3
|
+
import { withSession } from '../runtime.js';
|
|
4
|
+
import { tildify } from '../../utils/text.js';
|
|
5
|
+
import { parseCommand, requireArg } from './args.js';
|
|
6
|
+
/** `soja start <id>`: the Git flow on top of `soja task start`. */
|
|
7
|
+
export async function startCommand(args) {
|
|
8
|
+
const { values, positionals } = parseCommand(args, {
|
|
9
|
+
from: { type: 'string', short: 'f' },
|
|
10
|
+
link: { type: 'boolean', short: 'l' },
|
|
11
|
+
});
|
|
12
|
+
const ref = requireArg(positionals[0], 'task ID', 'soja start SOJA-12 [--from main] [--link]');
|
|
13
|
+
await withSession(async (services, session) => {
|
|
14
|
+
const result = await services.git.start(session, ref, {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
from: values.from,
|
|
17
|
+
link: values.link ?? false,
|
|
18
|
+
});
|
|
19
|
+
const status = statusStyles[result.task.status];
|
|
20
|
+
success(`${bold(result.task.ref)} ${symbols.arrow} ${token(status, status.label)} ${dim(result.task.title)}`);
|
|
21
|
+
const where = dim(`in ${tildify(result.root)}`);
|
|
22
|
+
const lines = {
|
|
23
|
+
created: `created ${color('green', result.branch)}${result.base ? dim(` from ${result.base}`) : ''} ${where}`,
|
|
24
|
+
recreated: `recreated ${color('green', result.branch)}${result.base ? dim(` from ${result.base}`) : ''} ${where}`,
|
|
25
|
+
switched: `switched to ${color('green', result.branch)} ${where}`,
|
|
26
|
+
current: `already on ${color('green', result.branch)} ${where}`,
|
|
27
|
+
};
|
|
28
|
+
print(` ${lines[result.action]}`);
|
|
29
|
+
if (result.carried > 0)
|
|
30
|
+
print(dim(` ${result.carried} uncommitted change${result.carried === 1 ? '' : 's'} came along`));
|
|
31
|
+
if (result.linked)
|
|
32
|
+
print(dim(` linked ${result.task.project?.name ?? 'the project'} to this repository`));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ValidationError } from '../../domain/errors.js';
|
|
2
|
+
import { formatRelative } from '../../utils/time.js';
|
|
3
|
+
import { bold, color, dim, print, success } from '../output.js';
|
|
4
|
+
import { withServices } from '../runtime.js';
|
|
5
|
+
import { parseCommand } from './args.js';
|
|
6
|
+
/** `soja sync [--dismiss]`: sync now, then show what needs attention (remote mode). */
|
|
7
|
+
export async function syncCommand(args) {
|
|
8
|
+
const { values } = parseCommand(args, { dismiss: { type: 'boolean' } });
|
|
9
|
+
await withServices(async (services) => {
|
|
10
|
+
const sync = services.sync;
|
|
11
|
+
if (!sync)
|
|
12
|
+
throw new ValidationError('SOJA is in local mode; there is nothing to sync.', { hint: '`soja login --server <url>` to work with a team.' });
|
|
13
|
+
const session = await services.session.current();
|
|
14
|
+
if (!session)
|
|
15
|
+
return;
|
|
16
|
+
const workspaceId = session.workspace.id;
|
|
17
|
+
const report = await sync.syncNow(workspaceId);
|
|
18
|
+
const status = await sync.status(workspaceId);
|
|
19
|
+
if (!report.online)
|
|
20
|
+
print(color('yellow', `Offline. ${status.pending} change${status.pending === 1 ? '' : 's'} queued; they are sent when you reconnect.`));
|
|
21
|
+
else if (report.error)
|
|
22
|
+
print(color('yellow', `Synced partially: ${report.error}`));
|
|
23
|
+
else
|
|
24
|
+
success(`Synced ${bold(session.workspace.name)} ${dim(`· sent ${report.pushed}, received ${report.pulledTasks} task update${report.pulledTasks === 1 ? '' : 's'}${report.pulledMessages ? ` and ${report.pulledMessages} message${report.pulledMessages === 1 ? '' : 's'}` : ''}`)}`);
|
|
25
|
+
if (status.lastSyncAt)
|
|
26
|
+
print(dim(` last sync ${formatRelative(status.lastSyncAt)}`));
|
|
27
|
+
const notices = await sync.notices(workspaceId);
|
|
28
|
+
if (notices.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
print();
|
|
31
|
+
print(bold('Needs your attention'));
|
|
32
|
+
for (const notice of notices) {
|
|
33
|
+
print(` ${notice.kind === 'conflict' ? color('yellow', '!') : color('red', '✕')} ${notice.message}`);
|
|
34
|
+
if (notice.kind === 'conflict' && notice.field) {
|
|
35
|
+
print(dim(` restore it: soja task show ${notice.taskRef}, then edit ${notice.field}; or dismiss with \`soja sync --dismiss\``));
|
|
36
|
+
}
|
|
37
|
+
if (values.dismiss)
|
|
38
|
+
await sync.dismissNotice(notice.id);
|
|
39
|
+
}
|
|
40
|
+
if (values.dismiss)
|
|
41
|
+
print(dim(' Dismissed.'));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { FILTER_LABELS } from '../../application/filters.js';
|
|
3
|
+
import { ValidationError } from '../../domain/errors.js';
|
|
4
|
+
import { activeCount, countStatuses, TASK_PRIORITIES, TASK_STATUSES, TASK_TYPES, TYPE_LABELS } from '../../domain/task.js';
|
|
5
|
+
import { priorityStyles, statusStyles, symbols } from '../../ui/theme/theme.js';
|
|
6
|
+
import { clampLines, wrapText } from '../../utils/text.js';
|
|
7
|
+
import { formatRelative, formatStamp } from '../../utils/time.js';
|
|
8
|
+
import { tildify } from '../../utils/text.js';
|
|
9
|
+
import { bold, color, dim, print, success, taskLine, token } from '../output.js';
|
|
10
|
+
import { editInEditor } from '../../utils/editor.js';
|
|
11
|
+
import { confirmTyped } from '../prompt.js';
|
|
12
|
+
import { withSession } from '../runtime.js';
|
|
13
|
+
import { oneOf, parseCommand, requireArg } from './args.js';
|
|
14
|
+
export async function taskCommand(args) {
|
|
15
|
+
const [sub, ...rest] = args;
|
|
16
|
+
switch (sub) {
|
|
17
|
+
case undefined:
|
|
18
|
+
case 'list':
|
|
19
|
+
case 'ls':
|
|
20
|
+
return list(rest);
|
|
21
|
+
case 'create':
|
|
22
|
+
case 'new':
|
|
23
|
+
case 'add':
|
|
24
|
+
return create(rest);
|
|
25
|
+
case 'show':
|
|
26
|
+
case 'view':
|
|
27
|
+
return show(rest);
|
|
28
|
+
case 'done':
|
|
29
|
+
case 'start':
|
|
30
|
+
case 'reopen':
|
|
31
|
+
return transition(sub, rest);
|
|
32
|
+
case 'archive':
|
|
33
|
+
case 'restore':
|
|
34
|
+
return archive(sub, rest);
|
|
35
|
+
case 'delete':
|
|
36
|
+
return remove(rest);
|
|
37
|
+
case 'comment':
|
|
38
|
+
return comment(rest);
|
|
39
|
+
case 'describe':
|
|
40
|
+
return describe(rest);
|
|
41
|
+
default:
|
|
42
|
+
throw new ValidationError(`Unknown task command “${sub}”.`, { hint: 'Try: list, create, show, start, done, reopen, comment, describe, archive, restore, delete.' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function list(args) {
|
|
46
|
+
const { values } = parseCommand(args, {
|
|
47
|
+
all: { type: 'boolean', short: 'A' },
|
|
48
|
+
status: { type: 'string', short: 's' },
|
|
49
|
+
project: { type: 'string', short: 'p' },
|
|
50
|
+
archived: { type: 'boolean' },
|
|
51
|
+
});
|
|
52
|
+
const status = oneOf(values.status, ['todo', 'in_progress', 'review', 'blocked', 'done'], 'status');
|
|
53
|
+
const filter = values.archived ? 'archived' : (status ?? (values.all ? 'all' : 'mine'));
|
|
54
|
+
await withSession(async (services, session) => {
|
|
55
|
+
await reportMerges(services, session);
|
|
56
|
+
const project = values.project ? await services.projects.resolve(session, values.project) : null;
|
|
57
|
+
const tasks = await services.tasks.list(session, filter, project ? { projectId: project.id } : {});
|
|
58
|
+
const heading = filter === 'mine' ? 'MY WORK' : FILTER_LABELS[filter].toUpperCase();
|
|
59
|
+
print(`${bold(heading)} ${dim([session.workspace.name, project?.name].filter(Boolean).join(' / '))}`);
|
|
60
|
+
print();
|
|
61
|
+
if (tasks.length === 0) {
|
|
62
|
+
print(dim(filter === 'mine' ? " No tasks assigned. You're free." : ' Nothing here.'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const task of tasks)
|
|
66
|
+
print(taskLine(task, { showAssignee: filter !== 'mine' }));
|
|
67
|
+
print();
|
|
68
|
+
const counts = countStatuses(tasks);
|
|
69
|
+
print(dim(filter === 'done' || filter === 'archived'
|
|
70
|
+
? `${tasks.length} ${filter}`
|
|
71
|
+
: `${activeCount(counts)} active ${symbols.dot} ${counts.in_progress} in progress ${symbols.dot} ${counts.review} review ${symbols.dot} ${counts.blocked} blocked`));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function create(args) {
|
|
75
|
+
const { values, positionals } = parseCommand(args, {
|
|
76
|
+
project: { type: 'string', short: 'p' },
|
|
77
|
+
type: { type: 'string', short: 't' },
|
|
78
|
+
priority: { type: 'string', short: 'P' },
|
|
79
|
+
status: { type: 'string', short: 's' },
|
|
80
|
+
assignee: { type: 'string', short: 'a' },
|
|
81
|
+
requester: { type: 'string', short: 'r' },
|
|
82
|
+
description: { type: 'string', short: 'd' },
|
|
83
|
+
});
|
|
84
|
+
const type = oneOf(values.type, TASK_TYPES, 'type');
|
|
85
|
+
const priority = oneOf(values.priority, TASK_PRIORITIES, 'priority');
|
|
86
|
+
const status = oneOf(values.status, TASK_STATUSES, 'status');
|
|
87
|
+
const title = positionals.join(' ').trim() || (await askTitle());
|
|
88
|
+
await withSession(async (services, session) => {
|
|
89
|
+
// Inside a linked repository, its project is the default.
|
|
90
|
+
const project = values.project
|
|
91
|
+
? await services.projects.resolve(session, values.project)
|
|
92
|
+
: await services.projects.findByRepository(session, process.cwd());
|
|
93
|
+
const projectId = project?.id ?? null;
|
|
94
|
+
const assigneeId = await resolveAssignee(services, session, values.assignee);
|
|
95
|
+
const created = await services.tasks.create(session, {
|
|
96
|
+
title,
|
|
97
|
+
projectId,
|
|
98
|
+
...(type ? { type } : {}),
|
|
99
|
+
...(priority ? { priority } : {}),
|
|
100
|
+
...(status ? { status } : {}),
|
|
101
|
+
...(assigneeId !== undefined ? { assigneeId } : {}),
|
|
102
|
+
requester: values.requester ?? null,
|
|
103
|
+
description: values.description ?? null,
|
|
104
|
+
});
|
|
105
|
+
// Remote mode: send it now so the real number shows when online (offline it stays SOJA-?n).
|
|
106
|
+
if (services.sync)
|
|
107
|
+
await services.sync.syncNow(session.workspace.id);
|
|
108
|
+
const task = services.sync ? await services.tasks.get(session, created) : created;
|
|
109
|
+
success(`Created ${bold(task.ref)} ${task.title}`);
|
|
110
|
+
const details = [
|
|
111
|
+
task.project?.name,
|
|
112
|
+
token(priorityStyles[task.priority]),
|
|
113
|
+
task.assignee ? `@${task.assignee.username}` : 'unassigned',
|
|
114
|
+
task.requester ? `for ${task.requester}` : undefined,
|
|
115
|
+
];
|
|
116
|
+
print(dim(` ${details.filter(Boolean).join(` ${symbols.dot} `)}`));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function resolveAssignee(services, session, value) {
|
|
120
|
+
if (value === undefined)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (['none', 'nobody', '-'].includes(value.toLowerCase()))
|
|
123
|
+
return null;
|
|
124
|
+
if (['me', '@me'].includes(value.toLowerCase()))
|
|
125
|
+
return session.user.id;
|
|
126
|
+
return (await services.workspaces.findMember(session, value)).id;
|
|
127
|
+
}
|
|
128
|
+
async function askTitle() {
|
|
129
|
+
if (!process.stdin.isTTY) {
|
|
130
|
+
throw new ValidationError('A task needs a title.', { hint: 'Usage: soja task create "Fix Stripe webhook"' });
|
|
131
|
+
}
|
|
132
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
133
|
+
try {
|
|
134
|
+
return (await readline.question(`${bold('Title')} ${color('green', '›')} `)).trim();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
readline.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function show(args) {
|
|
141
|
+
const { positionals } = parseCommand(args, {});
|
|
142
|
+
const ref = requireArg(positionals[0], 'task ID', 'soja task show SOJA-12');
|
|
143
|
+
await withSession(async (services, session) => {
|
|
144
|
+
await reportMerges(services, session, ref);
|
|
145
|
+
const task = await services.tasks.get(session, ref);
|
|
146
|
+
printDetails(task);
|
|
147
|
+
printGit(task.ref, await services.git.inspect(session, task, process.cwd()));
|
|
148
|
+
if (task.branch) {
|
|
149
|
+
const pr = await services.git.pullRequest(session, task, process.cwd());
|
|
150
|
+
if (pr.status === 'found') {
|
|
151
|
+
print(` PR ${bold(`#${pr.pr.number}`)} ${pr.pr.state} ${dim(pr.pr.url)} ${dim(`details: soja pr status ${task.ref}`)}`);
|
|
152
|
+
}
|
|
153
|
+
else if (pr.status === 'unavailable')
|
|
154
|
+
print(dim(` PR: ${pr.reason}`));
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function printDetails(task) {
|
|
159
|
+
const width = Math.min(process.stdout.columns || 80, 100) - 2;
|
|
160
|
+
const status = statusStyles[task.status];
|
|
161
|
+
print(`${color('green', bold(task.ref))} ${dim(TYPE_LABELS[task.type])}`);
|
|
162
|
+
print(bold(task.title));
|
|
163
|
+
print(`${token(status, `${status.glyph} ${status.label}`)} ${token(priorityStyles[task.priority])}`);
|
|
164
|
+
print();
|
|
165
|
+
const field = (label, value, hint) => print(`${dim(label.padEnd(14))}${value ?? dim('—')}${hint ? dim(` ${hint}`) : ''}`);
|
|
166
|
+
field('Project', task.project?.name);
|
|
167
|
+
field('Assignee', task.assignee ? `@${task.assignee.username}` : null, task.assignee?.displayName);
|
|
168
|
+
field('Requested by', task.requester);
|
|
169
|
+
field('Branch', task.branch, task.branch ? undefined : `suggested ${task.suggestedBranch}`);
|
|
170
|
+
field('Created', formatStamp(task.createdAt), task.creator ? `by @${task.creator.username}` : undefined);
|
|
171
|
+
print();
|
|
172
|
+
if (task.description)
|
|
173
|
+
for (const line of wrapText(task.description, width - 2))
|
|
174
|
+
print(` ${line}`);
|
|
175
|
+
else
|
|
176
|
+
print(dim(' No description.'));
|
|
177
|
+
print();
|
|
178
|
+
print(dim(bold('ACTIVITY')));
|
|
179
|
+
const actorWidth = Math.min(18, Math.max(8, ...task.timeline.map((entry) => (entry.actor?.username.length ?? 0) + 1)) + 2);
|
|
180
|
+
for (const entry of task.timeline) {
|
|
181
|
+
const prefix = `${dim(formatStamp(entry.at).padEnd(7))}${(entry.actor ? `@${entry.actor.username}` : '').padEnd(actorWidth)}`;
|
|
182
|
+
if (entry.kind === 'event')
|
|
183
|
+
print(`${prefix}${dim(entry.text)}`);
|
|
184
|
+
else {
|
|
185
|
+
const [first, ...more] = clampLines(wrapText(entry.body, Math.max(20, width - 9 - actorWidth)), 6);
|
|
186
|
+
print(`${prefix}${color('green', symbols.comment)} ${first ?? ''}`);
|
|
187
|
+
for (const line of more)
|
|
188
|
+
print(`${' '.repeat(7 + actorWidth + 2)}${line}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Closes tasks whose branch was merged outside SOJA and says so before the output. */
|
|
193
|
+
async function reportMerges(services, session, only) {
|
|
194
|
+
const results = await services.git.detectMerges(session, process.cwd(), only ? { only } : {}).catch(() => []);
|
|
195
|
+
for (const result of results) {
|
|
196
|
+
if (result.kind === 'merged')
|
|
197
|
+
success(`${bold(result.task.ref)} was merged into ${result.into} outside SOJA ${symbols.arrow} Done`);
|
|
198
|
+
else
|
|
199
|
+
print(dim(`${result.task.ref}: branch ${result.branch} was deleted with no merge found`));
|
|
200
|
+
}
|
|
201
|
+
if (results.length)
|
|
202
|
+
print();
|
|
203
|
+
}
|
|
204
|
+
function printGit(ref, state) {
|
|
205
|
+
print();
|
|
206
|
+
print(dim(bold('GIT')));
|
|
207
|
+
if (state.status === 'unavailable') {
|
|
208
|
+
print(dim(` ${state.reason}${state.hint ? ` ${state.hint}` : ''}`));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const where = dim(`in ${tildify(state.root)}`);
|
|
212
|
+
if (!state.branchExists) {
|
|
213
|
+
print(` ${dim(state.recorded ? 'branch deleted, no merge found:' : 'not started:')} ${state.branch} ${where}`);
|
|
214
|
+
print(dim(` run \`soja start ${ref}\` to create it${state.recorded ? ', or forget it from the Git menu (g)' : ''}`));
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const checkout = state.checkedOut
|
|
218
|
+
? color('green', `${symbols.active} checked out`)
|
|
219
|
+
: dim(`not checked out (on ${state.currentBranch ?? 'detached HEAD'})`);
|
|
220
|
+
print(` ${state.branch} ${checkout} ${where}`);
|
|
221
|
+
}
|
|
222
|
+
if (state.uncommitted > 0)
|
|
223
|
+
print(dim(` ${state.uncommitted} uncommitted change${state.uncommitted === 1 ? '' : 's'}`));
|
|
224
|
+
for (const commit of state.commits.slice(0, 5)) {
|
|
225
|
+
print(` ${color('yellow', commit.shortHash)} ${commit.subject} ${dim(`${commit.author}, ${formatRelative(commit.date)}`)}`);
|
|
226
|
+
}
|
|
227
|
+
if (state.commits.length > 5)
|
|
228
|
+
print(dim(` ${symbols.ellipsis} ${state.commits.length - 5} more`));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Text for a command: the arguments, `-` for stdin, or the editor when
|
|
232
|
+
* nothing is given in a terminal. Null means the editor was closed without saving.
|
|
233
|
+
*/
|
|
234
|
+
async function textFrom(words, editor) {
|
|
235
|
+
if (words.length === 1 && words[0] === '-') {
|
|
236
|
+
const chunks = [];
|
|
237
|
+
for await (const chunk of process.stdin)
|
|
238
|
+
chunks.push(chunk);
|
|
239
|
+
return Buffer.concat(chunks).toString('utf8').replace(/\s+$/, '');
|
|
240
|
+
}
|
|
241
|
+
if (words.length > 0)
|
|
242
|
+
return words.join(' ');
|
|
243
|
+
if (!process.stdin.isTTY)
|
|
244
|
+
throw new ValidationError('Nothing to write.', { hint: 'Pass the text, or - to read it from stdin.' });
|
|
245
|
+
return editInEditor(editor.initial, { name: editor.name });
|
|
246
|
+
}
|
|
247
|
+
/** `soja task comment <id> [text|-]`: without text, opens $EDITOR. */
|
|
248
|
+
async function comment(args) {
|
|
249
|
+
const { positionals } = parseCommand(args, {});
|
|
250
|
+
const ref = requireArg(positionals[0], 'task ID', 'soja task comment SOJA-12 "text" (or - for stdin, or nothing for $EDITOR)');
|
|
251
|
+
const text = await textFrom(positionals.slice(1), { initial: '', name: `${ref}-comment.md` });
|
|
252
|
+
if (text === null || !text.trim())
|
|
253
|
+
return print(dim('Nothing written. No comment added.'));
|
|
254
|
+
await withSession(async (services, session) => {
|
|
255
|
+
await services.tasks.comment(session, ref, text);
|
|
256
|
+
const task = await services.tasks.get(session, ref);
|
|
257
|
+
success(`Commented on ${bold(task.ref)}`);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** `soja task describe <id> [text|-]`: replaces the description; without text, edits it in $EDITOR. */
|
|
261
|
+
async function describe(args) {
|
|
262
|
+
const { positionals } = parseCommand(args, {});
|
|
263
|
+
const ref = requireArg(positionals[0], 'task ID', 'soja task describe SOJA-12 "text" (or - for stdin, or nothing for $EDITOR)');
|
|
264
|
+
await withSession(async (services, session) => {
|
|
265
|
+
const task = await services.tasks.get(session, ref);
|
|
266
|
+
const text = await textFrom(positionals.slice(1), { initial: task.description ?? '', name: `${task.ref}.md` });
|
|
267
|
+
if (text === null)
|
|
268
|
+
return print(dim('The editor closed without saving. Nothing changed.'));
|
|
269
|
+
if (text === (task.description ?? ''))
|
|
270
|
+
return print(dim('Description unchanged.'));
|
|
271
|
+
await services.tasks.update(session, task, { description: text });
|
|
272
|
+
success(`${bold(task.ref)} description ${text ? 'saved' : 'cleared'}`);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
async function archive(action, args) {
|
|
276
|
+
const { positionals } = parseCommand(args, {});
|
|
277
|
+
const ref = requireArg(positionals[0], 'task ID', `soja task ${action} SOJA-12`);
|
|
278
|
+
await withSession(async (services, session) => {
|
|
279
|
+
const task = action === 'archive' ? await services.tasks.archive(session, ref) : await services.tasks.restore(session, ref);
|
|
280
|
+
success(action === 'archive' ? `${bold(task.ref)} archived ${dim('· soja task list --archived, or soja task restore')}` : `${bold(task.ref)} is back in the lists`);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** `soja task delete <id> [--yes]`: owners only; you type the ID to confirm. */
|
|
284
|
+
async function remove(args) {
|
|
285
|
+
const { values, positionals } = parseCommand(args, { yes: { type: 'boolean', short: 'y' } });
|
|
286
|
+
const ref = requireArg(positionals[0], 'task ID', 'soja task delete SOJA-12 [--yes]');
|
|
287
|
+
await withSession(async (services, session) => {
|
|
288
|
+
const task = await services.tasks.get(session, ref);
|
|
289
|
+
print(`${bold(task.ref)} ${task.title}`);
|
|
290
|
+
print(dim(' Its comments and timeline are deleted too, for the whole team. `soja task archive` keeps it restorable.'));
|
|
291
|
+
if (!(await confirmTyped('Delete it permanently?', task.ref, values.yes)))
|
|
292
|
+
return print(dim('Cancelled.'));
|
|
293
|
+
await services.tasks.remove(session, task);
|
|
294
|
+
success(`${bold(task.ref)} deleted`);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
async function transition(action, args) {
|
|
298
|
+
const { positionals } = parseCommand(args, {});
|
|
299
|
+
const ref = requireArg(positionals[0], 'task ID', `soja task ${action} SOJA-12`);
|
|
300
|
+
await withSession(async (services, session) => {
|
|
301
|
+
const task = action === 'done'
|
|
302
|
+
? await services.tasks.complete(session, ref)
|
|
303
|
+
: action === 'start'
|
|
304
|
+
? await services.tasks.start(session, ref)
|
|
305
|
+
: await services.tasks.reopen(session, ref);
|
|
306
|
+
const status = statusStyles[task.status];
|
|
307
|
+
success(`${bold(task.ref)} ${symbols.arrow} ${token(status, status.label)} ${dim(task.title)}`);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import { SojaError } from '../../domain/errors.js';
|
|
4
|
+
import { bootstrap, createUpdater } from '../../bootstrap.js';
|
|
5
|
+
import { detectThemeMode } from '../../ui/theme/detect.js';
|
|
6
|
+
import { setThemeMode } from '../../ui/theme/theme.js';
|
|
7
|
+
import { App } from '../../ui/App.js';
|
|
8
|
+
import { FileConfigStore } from '../../config/config.js';
|
|
9
|
+
import { CredentialStore } from '../../config/credentials.js';
|
|
10
|
+
import { resolvePaths } from '../../config/paths.js';
|
|
11
|
+
export async function runInterface(options = {}) {
|
|
12
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
13
|
+
throw new SojaError("SOJA's interface needs an interactive terminal.", {
|
|
14
|
+
hint: 'For scripts, use the commands in `soja --help`.',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const paths = resolvePaths();
|
|
18
|
+
const configStore = new FileConfigStore(paths.configFile);
|
|
19
|
+
const savedConfig = configStore.load();
|
|
20
|
+
const hasRemoteToken = savedConfig?.remote ? Boolean(new CredentialStore(paths.credentialsFile).token(savedConfig.remote.apiUrl)) : true;
|
|
21
|
+
const runtime = await bootstrap({ forceLocal: savedConfig?.mode === 'remote' && !hasRemoteToken });
|
|
22
|
+
let reopenInCurrentMode = false;
|
|
23
|
+
// A daily copy of the database in use, kept for a week. Never blocks opening SOJA.
|
|
24
|
+
try {
|
|
25
|
+
runtime.services.backups.auto();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Best effort: `soja backup` reports problems when asked directly.
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const updates = createUpdater();
|
|
32
|
+
// Light or dark palette, from the terminal's own background (SOJA_THEME overrides).
|
|
33
|
+
setThemeMode(await detectThemeMode());
|
|
34
|
+
const instance = render(_jsx(App, { services: runtime.services, welcomeOnLocal: savedConfig?.mode === 'remote' && !hasRemoteToken, updates: () => updates.cachedCheck(), mouse: process.env.SOJA_MOUSE !== '0', ...(options.route ? { initialRoute: options.route } : {}) }), { alternateScreen: true, exitOnCtrlC: true });
|
|
35
|
+
await instance.waitUntilExit();
|
|
36
|
+
reopenInCurrentMode = new FileConfigStore(runtime.paths.configFile).load()?.mode !== runtime.services.environment.mode;
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
runtime.close();
|
|
40
|
+
}
|
|
41
|
+
if (reopenInCurrentMode)
|
|
42
|
+
await runInterface(options);
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createUpdater } from '../../bootstrap.js';
|
|
2
|
+
import { bold, color, dim, print, success } from '../output.js';
|
|
3
|
+
import { confirm } from '../prompt.js';
|
|
4
|
+
import { parseCommand } from './args.js';
|
|
5
|
+
/** `soja update [--check] [--yes]`: installs the newest release with gh and npm. */
|
|
6
|
+
export async function updateCommand(args) {
|
|
7
|
+
const { values } = parseCommand(args, { check: { type: 'boolean' }, yes: { type: 'boolean', short: 'y' } });
|
|
8
|
+
const updater = createUpdater();
|
|
9
|
+
const found = await updater.check();
|
|
10
|
+
if (!found.available)
|
|
11
|
+
return success(`SOJA ${bold(`v${found.current}`)} is the latest version.`);
|
|
12
|
+
print(`${color('green', `SOJA v${found.latest}`)} is available ${dim(`(you have v${found.current})`)}`);
|
|
13
|
+
print(dim(` Package details: npm view soja-cli@${found.latest}`));
|
|
14
|
+
if (values.check)
|
|
15
|
+
return;
|
|
16
|
+
if (!(await confirm('Install it now?', values.yes)))
|
|
17
|
+
return print(dim('Cancelled.'));
|
|
18
|
+
await updater.install(found.latest);
|
|
19
|
+
success(`Installed SOJA v${found.latest}. Your data and settings stay as they are.`);
|
|
20
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { SojaError, ValidationError } from '../../domain/errors.js';
|
|
2
|
+
import { symbols } from '../../ui/theme/theme.js';
|
|
3
|
+
import { bold, color, dim, print, success } from '../output.js';
|
|
4
|
+
import { withServices, withSession } from '../runtime.js';
|
|
5
|
+
import { parseCommand, requireArg } from './args.js';
|
|
6
|
+
export async function workspaceCommand(args) {
|
|
7
|
+
const [sub, ...rest] = args;
|
|
8
|
+
if (sub === undefined || sub === 'list' || sub === 'ls')
|
|
9
|
+
return list();
|
|
10
|
+
if (sub === 'use' || sub === 'switch')
|
|
11
|
+
return switchWorkspace(rest);
|
|
12
|
+
if (sub === 'create' || sub === 'new')
|
|
13
|
+
return create(rest);
|
|
14
|
+
if (sub === 'add' || sub === 'add-member')
|
|
15
|
+
return addMember(rest);
|
|
16
|
+
throw new ValidationError(`Unknown workspace command “${sub}”.`, { hint: 'Try: list, create <name>, add <username>, use <workspace>.' });
|
|
17
|
+
}
|
|
18
|
+
async function list() {
|
|
19
|
+
await withSession(async (services, session) => {
|
|
20
|
+
for (const workspace of await services.workspaces.list(session.user)) {
|
|
21
|
+
const current = workspace.id === session.workspace.id;
|
|
22
|
+
print(`${current ? color('green', symbols.active) : ' '} ${current ? bold(workspace.name) : workspace.name} ${dim(`${workspace.slug} ${symbols.dot} ${workspace.role}`)}`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export async function switchWorkspace(args) {
|
|
27
|
+
const { positionals } = parseCommand(args, {});
|
|
28
|
+
const target = requireArg(positionals.join(' ').trim(), 'workspace', 'soja use bravos-development');
|
|
29
|
+
await withSession(async (services, session) => {
|
|
30
|
+
const next = await services.workspaces.switchTo(session.user, target);
|
|
31
|
+
success(`Now in ${bold(next.workspace.name)}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function create(args) {
|
|
35
|
+
const { positionals } = parseCommand(args, {});
|
|
36
|
+
const name = requireArg(positionals.join(' ').trim(), 'workspace name', 'soja workspace create "Bravos Development"');
|
|
37
|
+
await withServices(async (services) => {
|
|
38
|
+
// Not `current()`: in remote mode you may have no workspace yet, but you are signed in.
|
|
39
|
+
const user = await services.session.user();
|
|
40
|
+
if (!user)
|
|
41
|
+
throw new SojaError('SOJA is not set up yet.', { hint: 'Run `soja` once, or `soja login --server <url>`.' });
|
|
42
|
+
const workspace = await services.workspaces.create(user, { name });
|
|
43
|
+
await services.workspaces.switchTo(user, workspace.id);
|
|
44
|
+
success(`Created ${bold(workspace.name)} ${dim(workspace.slug)} ${dim('· now active')}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Adds a developer to the active workspace (in remote mode: someone who already ran `soja login`). */
|
|
48
|
+
async function addMember(args) {
|
|
49
|
+
const { values, positionals } = parseCommand(args, { name: { type: 'string', short: 'n' } });
|
|
50
|
+
const username = requireArg(positionals[0], 'username', 'soja workspace add angel [--name "Angel"]');
|
|
51
|
+
await withSession(async (services, session) => {
|
|
52
|
+
const member = await services.workspaces.addMember(session, { username, ...(values.name ? { displayName: values.name } : {}) });
|
|
53
|
+
success(`@${member.username} joined ${bold(session.workspace.name)}`);
|
|
54
|
+
});
|
|
55
|
+
}
|