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,133 @@
1
+ import { ValidationError } from '../../domain/errors.js';
2
+ import { symbols } from '../../ui/theme/theme.js';
3
+ import { formatStamp } from '../../utils/time.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 { runInterface } from './tui.js';
8
+ /** `soja chat …` (remote mode): the chat from scripts and hooks, or the interface on a channel. */
9
+ export async function chatCommand(args) {
10
+ const [sub, ...rest] = args;
11
+ switch (sub) {
12
+ case 'send':
13
+ return send(rest);
14
+ case 'log':
15
+ return log(rest);
16
+ case 'channels':
17
+ case 'ls':
18
+ return channels();
19
+ case 'new':
20
+ case 'create':
21
+ return create(rest);
22
+ case 'topic':
23
+ return topic(rest);
24
+ case 'archive':
25
+ case 'unarchive':
26
+ return archive(sub === 'archive', rest);
27
+ case undefined:
28
+ return runInterface({ route: { name: 'chat' } });
29
+ default:
30
+ if (sub.startsWith('#') || !sub.startsWith('-'))
31
+ return runInterface({ route: { name: 'chat', channel: sub } });
32
+ throw new ValidationError(`Unknown chat command “${sub}”.`, { hint: 'Try: send, log, channels, new, topic, archive, unarchive, or `soja chat #channel`.' });
33
+ }
34
+ }
35
+ function chatOf(services) {
36
+ if (!services.chat) {
37
+ throw new ValidationError('The chat needs a team: SOJA is in local mode.', { hint: '`soja login --server <url>` to work with a SOJA server.' });
38
+ }
39
+ return services.chat;
40
+ }
41
+ async function send(args) {
42
+ const { positionals } = parseCommand(args, {});
43
+ const usage = 'soja chat send #channel "message" (use - to read the message from stdin)';
44
+ const channel = requireArg(positionals[0], 'channel', usage);
45
+ let text = positionals.slice(1).join(' ');
46
+ if (text === '-')
47
+ text = await readStdin();
48
+ requireArg(text.trim() || undefined, 'message', usage);
49
+ await withSession(async (services, session) => {
50
+ const message = await chatOf(services).send(session, channel, text);
51
+ const target = await chatOf(services).channel(session, message.channelId);
52
+ success(`Sent to ${bold(`#${target.name}`)}`);
53
+ });
54
+ }
55
+ async function log(args) {
56
+ const { values, positionals } = parseCommand(args, { n: { type: 'string', short: 'n' } });
57
+ const count = values.n === undefined ? 20 : Number(values.n);
58
+ if (!Number.isInteger(count) || count < 1)
59
+ throw new ValidationError('-n takes a positive number.');
60
+ await withSession(async (services, session) => {
61
+ const chat = chatOf(services);
62
+ const channel = await chat.channel(session, positionals[0] ?? 'general');
63
+ const messages = await chat.messages(session, channel.id, count);
64
+ print(`${bold(`#${channel.name}`)}${channel.topic ? dim(` ${channel.topic}`) : ''}`);
65
+ if (messages.length === 0)
66
+ print(dim(' No messages yet.'));
67
+ const now = new Date();
68
+ for (const message of messages)
69
+ printMessage(message, now);
70
+ });
71
+ }
72
+ function printMessage(message, now) {
73
+ const who = message.author ? `@${message.author.username}` : 'someone';
74
+ const stamp = dim(formatStamp(message.createdAt, now).padEnd(7));
75
+ if (message.replyTo) {
76
+ const quoted = message.replyTo.deleted ? 'deleted message' : (message.replyTo.body.split('\n')[0] ?? '');
77
+ print(`${' '.repeat(8)}${dim(`${symbols.reply} ${message.replyTo.author ? `@${message.replyTo.author.username}: ` : ''}${quoted.slice(0, 60)}`)}`);
78
+ }
79
+ const [first = '', ...more] = message.deletedAt ? ['message deleted'] : message.body.split('\n');
80
+ const body = message.deletedAt ? dim(first) : message.mentionsMe ? color('yellow', first) : first;
81
+ const flags = `${message.pending ? dim(` ${symbols.pending}`) : ''}${message.editedAt && !message.deletedAt ? dim(' (edited)') : ''}`;
82
+ print(`${stamp} ${bold(who)} ${body}${flags}`);
83
+ for (const line of more)
84
+ print(`${' '.repeat(8)}${line}`);
85
+ }
86
+ async function channels() {
87
+ await withSession(async (services, session) => {
88
+ const list = await chatOf(services).channels(session);
89
+ for (const channel of list) {
90
+ const badges = [
91
+ channel.unread ? color('green', `${channel.unread} unread`) : '',
92
+ channel.mentions ? color('yellow', `@${channel.mentions}`) : '',
93
+ channel.archivedAt ? dim('archived') : '',
94
+ ].filter(Boolean);
95
+ print(` ${`#${channel.name}`.padEnd(24)}${badges.join(' ')}${channel.topic ? ` ${dim(channel.topic)}` : ''}`);
96
+ }
97
+ });
98
+ }
99
+ async function create(args) {
100
+ const { values, positionals } = parseCommand(args, { topic: { type: 'string' } });
101
+ const name = requireArg(positionals[0], 'channel name', 'soja chat new <name> [--topic "…"]');
102
+ await withSession(async (services, session) => {
103
+ const channel = await chatOf(services).createChannel(session, name, values.topic ?? null);
104
+ success(`Created ${bold(`#${channel.name}`)}`);
105
+ });
106
+ }
107
+ async function topic(args) {
108
+ const { positionals } = parseCommand(args, {});
109
+ const channel = requireArg(positionals[0], 'channel', 'soja chat topic <channel> "text" (empty text clears it)');
110
+ const text = positionals.slice(1).join(' ').trim();
111
+ await withSession(async (services, session) => {
112
+ const chat = chatOf(services);
113
+ const target = await chat.channel(session, channel);
114
+ const updated = await chat.updateChannel(session, target.id, { topic: text || null });
115
+ success(`${bold(`#${updated.name}`)} ${updated.topic ? dim(updated.topic) : dim('no topic')}`);
116
+ });
117
+ }
118
+ async function archive(archived, args) {
119
+ const { positionals } = parseCommand(args, {});
120
+ const channel = requireArg(positionals[0], 'channel', `soja chat ${archived ? 'archive' : 'unarchive'} <channel>`);
121
+ await withSession(async (services, session) => {
122
+ const chat = chatOf(services);
123
+ const target = await chat.channel(session, channel);
124
+ const updated = await chat.updateChannel(session, target.id, { archived });
125
+ success(`${bold(`#${updated.name}`)} ${archived ? 'archived: it keeps its history and takes no new messages' : 'restored'}`);
126
+ });
127
+ }
128
+ async function readStdin() {
129
+ const chunks = [];
130
+ for await (const chunk of process.stdin)
131
+ chunks.push(chunk);
132
+ return Buffer.concat(chunks).toString('utf8');
133
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, rmSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { resolvePaths } from '../../config/paths.js';
4
+ import { ValidationError } from '../../domain/errors.js';
5
+ import { seedDemoData } from '../../dev/seed.js';
6
+ import { bold, dim, print, success } from '../output.js';
7
+ import { withServices } from '../runtime.js';
8
+ import { parseCommand } from './args.js';
9
+ /** Developer tooling behind `npm run db:*`. Not part of the everyday CLI. */
10
+ export async function devCommand(args) {
11
+ const [sub, ...rest] = args;
12
+ switch (sub) {
13
+ case 'migrate':
14
+ // Opening the database applies pending migrations.
15
+ await withServices(async () => undefined);
16
+ success(`Database is up to date ${dim(resolvePaths().databaseFile)}`);
17
+ return;
18
+ case 'seed':
19
+ await withServices(async (services) => {
20
+ if (services.environment.mode === 'remote') {
21
+ throw new ValidationError('Demo data is only for local mode.', { hint: 'Run `soja mode local` first.' });
22
+ }
23
+ const result = await seedDemoData(services);
24
+ success(`Seeded ${bold(result.session.workspace.name)}: ${result.projects} projects, ${result.tasks} tasks`);
25
+ print(dim(` You are @${result.session.user.username}. Run \`npm run dev\` to look around.`));
26
+ });
27
+ return;
28
+ case 'reset':
29
+ return reset(rest);
30
+ default:
31
+ throw new ValidationError(`Unknown dev command “${sub ?? ''}”.`, { hint: 'Try: migrate, seed, reset.' });
32
+ }
33
+ }
34
+ async function reset(args) {
35
+ const { values } = parseCommand(args, { yes: { type: 'boolean', short: 'y' } });
36
+ const paths = resolvePaths();
37
+ const targets = [paths.databaseFile, `${paths.databaseFile}-wal`, `${paths.databaseFile}-shm`, paths.configFile].filter((file) => existsSync(file));
38
+ if (targets.length === 0) {
39
+ print(dim('Nothing to reset.'));
40
+ return;
41
+ }
42
+ print(bold('This permanently deletes:'));
43
+ for (const file of targets)
44
+ print(` ${file}`);
45
+ if (!values.yes) {
46
+ if (!process.stdin.isTTY)
47
+ throw new ValidationError('Refusing to reset without confirmation.', { hint: 'Pass --yes.' });
48
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
49
+ const answer = await readline.question('Type "reset" to continue: ');
50
+ readline.close();
51
+ if (answer.trim() !== 'reset') {
52
+ print(dim('Cancelled. Nothing was deleted.'));
53
+ return;
54
+ }
55
+ }
56
+ for (const file of targets)
57
+ rmSync(file, { force: true });
58
+ success('Reset. The next `soja` starts from first-run setup.');
59
+ }
@@ -0,0 +1,59 @@
1
+ import { ValidationError } from '../../domain/errors.js';
2
+ import { symbols } from '../../ui/theme/theme.js';
3
+ import { tildify } from '../../utils/text.js';
4
+ import { bold, color, dim, print, success } from '../output.js';
5
+ import { withServices, withSession } from '../runtime.js';
6
+ import { parseCommand, requireArg } from './args.js';
7
+ /** `soja folders`: parent folders that contain your repositories. */
8
+ export async function foldersCommand(args) {
9
+ const [sub, ...rest] = args;
10
+ if (sub === undefined || sub === 'list' || sub === 'ls')
11
+ return list();
12
+ if (sub === 'add')
13
+ return add(rest);
14
+ if (sub === 'remove' || sub === 'rm')
15
+ return remove(rest);
16
+ throw new ValidationError(`Unknown folders command “${sub}”.`, { hint: 'Try: list, add <path>, remove <path>.' });
17
+ }
18
+ /** Parent folders and their subfolders, like `ls -1`, marking repositories and linked projects. */
19
+ async function list() {
20
+ await withSession(async (services, session) => {
21
+ const parents = services.folders.browse();
22
+ if (parents.length === 0) {
23
+ print(dim('No parent folders yet. Add one with `soja folders add ~/workspace/products`.'));
24
+ return;
25
+ }
26
+ const linked = new Map((await services.projects.list(session)).flatMap((project) => project.repositoryPath ? [[project.repositoryPath, project.key]] : []));
27
+ for (const [index, parent] of parents.entries()) {
28
+ if (index > 0)
29
+ print();
30
+ print(`${bold(tildify(parent.path))}${parent.available ? '' : dim(' (not found)')}`);
31
+ for (const child of parent.children) {
32
+ const key = linked.get(child.path);
33
+ const mark = child.isGitRepository ? color('green', symbols.active) : dim(symbols.dot);
34
+ print(` ${mark} ${child.isGitRepository ? child.name : dim(child.name)}${key ? dim(` ${symbols.arrow} ${key}`) : ''}`);
35
+ }
36
+ if (parent.available && parent.children.length === 0)
37
+ print(dim(' (empty)'));
38
+ }
39
+ print();
40
+ print(dim(`${color('green', symbols.active)} Git repository ${symbols.arrow} linked project`));
41
+ });
42
+ }
43
+ async function add(args) {
44
+ const { positionals } = parseCommand(args, {});
45
+ const path = requireArg(positionals.join(' '), 'folder', 'soja folders add ~/workspace/products');
46
+ await withServices(async (services) => {
47
+ const folders = services.folders.add(path);
48
+ success(`Parent folder added ${dim(`(${folders.length} total)`)}`);
49
+ print(dim(' See its subfolders with `soja folders list`.'));
50
+ });
51
+ }
52
+ async function remove(args) {
53
+ const { positionals } = parseCommand(args, {});
54
+ const path = requireArg(positionals.join(' '), 'folder', 'soja folders remove ~/workspace/products');
55
+ await withServices(async (services) => {
56
+ services.folders.remove(path);
57
+ success('Parent folder removed. Nothing was deleted from disk.');
58
+ });
59
+ }
@@ -0,0 +1,200 @@
1
+ import { pullRequestWarnings } from '../../application/services/index.js';
2
+ import { ValidationError } from '../../domain/errors.js';
3
+ import { GitError } from '../../git/types.js';
4
+ import { symbols } from '../../ui/theme/theme.js';
5
+ import { bold, color, dim, paint, print, success } from '../output.js';
6
+ import { confirm } from '../prompt.js';
7
+ import { withSession } from '../runtime.js';
8
+ import { parseCommand, requireArg } from './args.js';
9
+ /**
10
+ * Runs `work` while mirroring the Git console to stderr, so the commands and
11
+ * their output show up live in the terminal. GitErrors get their suggestions.
12
+ */
13
+ async function withGit(work) {
14
+ return withSession(async (services, session) => {
15
+ let shown = services.gitConsole.lastId;
16
+ const stop = services.gitConsole.subscribe(() => {
17
+ for (const line of services.gitConsole.since(shown)) {
18
+ const text = line.kind === 'command' ? paint('bold', line.text, process.stderr) : paint('dim', ` ${line.text}`, process.stderr);
19
+ if (line.kind !== 'success' && line.kind !== 'error')
20
+ process.stderr.write(`${text}\n`);
21
+ shown = line.id;
22
+ }
23
+ });
24
+ try {
25
+ return await work(services, session);
26
+ }
27
+ catch (error) {
28
+ if (error instanceof GitError && error.suggestions.length > 1) {
29
+ // printError shows the first suggestion as the hint; list the rest too.
30
+ for (const suggestion of error.suggestions.slice(1))
31
+ process.stderr.write(`${paint('dim', ` ${symbols.arrow} ${suggestion}`, process.stderr)}\n`);
32
+ }
33
+ throw error;
34
+ }
35
+ finally {
36
+ stop();
37
+ }
38
+ });
39
+ }
40
+ const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
41
+ /** `soja commit <id> -m <message> [files…] [--all]` */
42
+ export async function commitCommand(args) {
43
+ const { values, positionals } = parseCommand(args, {
44
+ message: { type: 'string', short: 'm' },
45
+ all: { type: 'boolean', short: 'a' },
46
+ });
47
+ const ref = requireArg(positionals[0], 'task ID', 'soja commit SOJA-12 -m "Fix webhook" [files…|--all]');
48
+ const message = requireArg(values.message, 'commit message', 'soja commit SOJA-12 -m "Fix webhook" --all');
49
+ await withGit(async (services, session) => {
50
+ let paths = positionals.slice(1);
51
+ if (paths.length === 0) {
52
+ const { files } = await services.git.workingState(session, ref, process.cwd());
53
+ if (!values.all) {
54
+ throw new ValidationError('Choose the files to commit, or pass --all.', {
55
+ hint: files.length ? `Changed: ${files.map((file) => file.path).join(', ')}` : 'There are no changes.',
56
+ });
57
+ }
58
+ paths = files.map((file) => file.path);
59
+ }
60
+ const result = await services.git.commit(session, ref, { cwd: process.cwd(), message, paths });
61
+ success(`Committed ${color('yellow', result.hash.slice(0, 7))} ${result.subject} ${dim(`(${result.files} file${result.files === 1 ? '' : 's'})`)}`);
62
+ });
63
+ }
64
+ /** `soja merge <id> [--delete] [--done] [--yes]` */
65
+ export async function mergeCommand(args) {
66
+ const { values, positionals } = parseCommand(args, {
67
+ delete: { type: 'boolean', short: 'd' },
68
+ done: { type: 'boolean' },
69
+ yes: { type: 'boolean', short: 'y' },
70
+ });
71
+ const ref = requireArg(positionals[0], 'task ID', 'soja merge SOJA-12 [--delete] [--done]');
72
+ await withGit(async (services, session) => {
73
+ const cwd = process.cwd();
74
+ const plan = await services.git.mergePlan(session, ref, cwd);
75
+ if (!(await confirm(`Merge ${bold(plan.branch)} into ${bold(plan.into)}?`, values.yes)))
76
+ return print(dim('Cancelled.'));
77
+ const result = await services.git.merge(session, ref, { cwd });
78
+ success(`Merged ${result.branch} into ${result.into} ${dim(result.hash.slice(0, 7))}`);
79
+ if (values.delete) {
80
+ const deleted = await services.git.deleteBranch(session, ref, { cwd });
81
+ success(`Deleted ${deleted.branch}`);
82
+ }
83
+ if (values.done) {
84
+ const task = await services.tasks.complete(session, ref);
85
+ success(`${task.ref} ${symbols.arrow} Done`);
86
+ }
87
+ });
88
+ }
89
+ /** `soja branch delete <id> [--force] [--yes]` */
90
+ export async function branchCommand(args) {
91
+ const [sub, ...rest] = args;
92
+ if (sub !== 'delete' && sub !== 'rm') {
93
+ throw new ValidationError(`Unknown branch command “${sub ?? ''}”.`, { hint: 'Try: soja branch delete SOJA-12' });
94
+ }
95
+ const { values, positionals } = parseCommand(rest, {
96
+ force: { type: 'boolean', short: 'f' },
97
+ yes: { type: 'boolean', short: 'y' },
98
+ });
99
+ const ref = requireArg(positionals[0], 'task ID', 'soja branch delete SOJA-12 [--force]');
100
+ await withGit(async (services, session) => {
101
+ const task = await services.tasks.get(session, ref);
102
+ if (!task.branch)
103
+ throw new GitError(`${task.ref} has no branch.`);
104
+ const question = values.force
105
+ ? `Delete ${bold(task.branch)} even if it is NOT merged? Its unmerged commits will be lost.`
106
+ : `Delete ${bold(task.branch)}?`;
107
+ if (!(await confirm(question, values.yes)))
108
+ return print(dim('Cancelled.'));
109
+ const result = await services.git.deleteBranch(session, ref, { cwd: process.cwd(), force: values.force ?? false });
110
+ success(`Deleted ${result.branch}${result.switchedTo ? dim(` (switched to ${result.switchedTo})`) : ''}${result.merged ? '' : dim(' (was not merged)')}`);
111
+ }).catch((error) => {
112
+ if (error instanceof GitError && error.code === 'not_merged') {
113
+ throw new GitError(error.message, { code: error.code, suggestions: ['Merge it first (`soja merge`), or pass --force to delete it anyway.'] });
114
+ }
115
+ throw error;
116
+ });
117
+ }
118
+ /** `soja push <id>` */
119
+ export async function pushCommand(args) {
120
+ const { positionals } = parseCommand(args, {});
121
+ const ref = requireArg(positionals[0], 'task ID', 'soja push SOJA-12');
122
+ await withGit(async (services, session) => {
123
+ // Attached to the terminal, Git can ask for credentials itself.
124
+ const result = await services.git.push(session, ref, { cwd: process.cwd(), interactive: interactive() });
125
+ success(`Pushed ${result.branch} to origin`);
126
+ });
127
+ }
128
+ /** `soja pr <id> [--yes]` */
129
+ export async function pullRequestCommand(args) {
130
+ const [sub, ...rest] = args;
131
+ if (sub === 'status')
132
+ return pullRequestStatus(rest);
133
+ if (sub === 'merge')
134
+ return mergePullRequest(rest);
135
+ const { values, positionals } = parseCommand(args, { yes: { type: 'boolean', short: 'y' } });
136
+ const ref = requireArg(positionals[0], 'task ID', 'soja pr SOJA-12');
137
+ await withGit(async (services, session) => {
138
+ const task = await services.tasks.get(session, ref);
139
+ if (!(await confirm(`Push ${bold(task.branch ?? task.ref)} and open a pull request?`, values.yes)))
140
+ return print(dim('Cancelled.'));
141
+ const result = await services.git.openPullRequest(session, ref, { cwd: process.cwd(), interactive: interactive() });
142
+ success(`Pull request ${result.branch} ${symbols.arrow} ${result.base}`);
143
+ print(` ${result.url}`);
144
+ });
145
+ }
146
+ /** `soja pr status <id>`: the task's pull request as GitHub sees it (through gh). */
147
+ async function pullRequestStatus(args) {
148
+ const { positionals } = parseCommand(args, {});
149
+ const ref = requireArg(positionals[0], 'task ID', 'soja pr status SOJA-12');
150
+ await withGit(async (services, session) => {
151
+ const state = await services.git.pullRequest(session, ref, process.cwd());
152
+ if (state.status === 'unavailable')
153
+ throw new GitError(state.reason, state.hint ? { hint: state.hint } : {});
154
+ if (state.status === 'none')
155
+ return print(dim(state.reason));
156
+ const { pr } = state;
157
+ const stateText = pr.state === 'merged' ? color('magenta', 'merged') : pr.state === 'open' ? color('cyan', pr.draft ? 'draft' : 'open') : dim('closed');
158
+ print(`${bold(`#${pr.number}`)} ${stateText} ${dim(`${pr.head} ${symbols.arrow} ${pr.base}`)} ${pr.title}`);
159
+ print(` ${pr.url}`);
160
+ if (pr.state !== 'open')
161
+ return;
162
+ const review = pr.review === 'approved' ? color('green', 'approved') : pr.review === 'changes_requested' ? color('red', 'changes requested') : dim(pr.review ? 'review required' : 'no review rules');
163
+ print(` review ${review}`);
164
+ const { checks } = pr;
165
+ const checkText = checks.total === 0
166
+ ? dim('none')
167
+ : checks.failed > 0
168
+ ? color('red', `${symbols.cross} ${checks.failed} failing: ${checks.failing.join(', ')}`)
169
+ : checks.pending > 0
170
+ ? color('yellow', `${symbols.running} ${checks.passed}/${checks.total} passed, ${checks.pending} running`)
171
+ : color('green', `${symbols.check} ${checks.passed}/${checks.total} passed`);
172
+ print(` checks ${checkText}`);
173
+ if (pr.mergeable === 'conflicting')
174
+ print(` ${color('red', 'conflicts with the base')}`);
175
+ // Learn what the team should see too (merged elsewhere, new CI failure).
176
+ await services.git.followPullRequests(session, process.cwd(), { only: ref });
177
+ });
178
+ }
179
+ /** `soja pr merge <id> [--delete-branch] [-y]`: merges the PR on GitHub and closes the task. */
180
+ async function mergePullRequest(args) {
181
+ const { values, positionals } = parseCommand(args, { yes: { type: 'boolean', short: 'y' }, 'delete-branch': { type: 'boolean', short: 'd' } });
182
+ const ref = requireArg(positionals[0], 'task ID', 'soja pr merge SOJA-12 [--delete-branch]');
183
+ await withGit(async (services, session) => {
184
+ const state = await services.git.pullRequest(session, ref, process.cwd());
185
+ if (state.status === 'unavailable')
186
+ throw new GitError(state.reason, state.hint ? { hint: state.hint } : {});
187
+ if (state.status === 'none')
188
+ throw new GitError(`${ref}: ${state.reason}`, { hint: `Open one with \`soja pr ${ref}\`.` });
189
+ const { pr } = state;
190
+ const warnings = pullRequestWarnings(pr);
191
+ if (warnings.length)
192
+ print(color('yellow', `! ${warnings.join(', ')}`));
193
+ const deleteBranch = values['delete-branch'] ?? false;
194
+ const question = `Merge PR ${bold(`#${pr.number}`)} (${pr.head} ${symbols.arrow} ${pr.base}) on GitHub${deleteBranch ? ' and delete the branch' : ''}?`;
195
+ if (!(await confirm(question, values.yes)))
196
+ return print(dim('Cancelled.'));
197
+ const result = await services.git.mergePullRequest(session, ref, { cwd: process.cwd(), deleteBranch });
198
+ success(`Merged PR #${result.pr.number} into ${result.pr.base}. ${result.task.ref} is Done.`);
199
+ });
200
+ }
@@ -0,0 +1,48 @@
1
+ import { 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 { confirm } from '../prompt.js';
5
+ import { withSession } from '../runtime.js';
6
+ import { parseCommand } from './args.js';
7
+ /** `soja import-local [--from <slug>] [--yes]`: brings local-mode history into the server workspace. */
8
+ export async function importLocalCommand(args) {
9
+ const { values } = parseCommand(args, { from: { type: 'string' }, yes: { type: 'boolean', short: 'y' } });
10
+ await withSession(async (services, session) => {
11
+ const importer = services.importer;
12
+ if (!importer) {
13
+ throw new ValidationError('Importing sends your local data to a SOJA server, and SOJA is in local mode.', {
14
+ hint: 'Sign in first: `soja login --server <url>`.',
15
+ });
16
+ }
17
+ const options = values.from ? { from: values.from } : {};
18
+ const preview = await importer.preview(session, options);
19
+ print(`${bold('Import')} ${preview.from.name} ${dim(`(local)`)} ${symbols.arrow} ${bold(session.workspace.name)} ${dim(services.environment.mode === 'remote' ? `on ${new URL(services.environment.server).host}` : '')}`);
20
+ print(` ${preview.projects} projects ${symbols.dot} ${preview.tasks} tasks ${symbols.dot} ${preview.comments} comments ${symbols.dot} ${preview.activity} timeline events`);
21
+ print(dim(` Developers matched by username: ${preview.developers.map((name) => `@${name}`).join(', ') || 'none'}`));
22
+ print(dim(' Your local data is only read; it stays as it is.'));
23
+ if (preview.tasks === 0 && preview.projects === 0)
24
+ return print(dim('Nothing to import.'));
25
+ if (preview.alreadyImported)
26
+ print(color('yellow', '! Already imported from this machine: importing again changes nothing.'));
27
+ if (!(await confirm('Import now?', values.yes)))
28
+ return print(dim('Cancelled.'));
29
+ const outcome = await importer.run(session, options);
30
+ if (preview.alreadyImported)
31
+ return success('Already imported earlier: nothing changed.');
32
+ success(`Imported ${outcome.tasks} tasks, ${outcome.projects} new projects, ${outcome.comments} comments and ${outcome.activity} timeline events`);
33
+ const renumbered = Object.entries(outcome.renumbered);
34
+ if (renumbered.length) {
35
+ print(dim(` Numbers changed because ${session.workspace.name} already had tasks:`));
36
+ for (const [from, to] of renumbered.slice(0, 20))
37
+ print(` SOJA-${from} ${symbols.arrow} ${bold(`SOJA-${to}`)}`);
38
+ if (renumbered.length > 20)
39
+ print(dim(` ${symbols.ellipsis} and ${renumbered.length - 20} more`));
40
+ }
41
+ if (outcome.unmatchedUsers.length) {
42
+ print(color('yellow', `! Not members of ${session.workspace.name}: ${outcome.unmatchedUsers.map((name) => `@${name}`).join(', ')}`));
43
+ print(dim(' Their tasks and comments are attributed to you, and their assignments were left empty. Add them with `soja workspace add <username>`.'));
44
+ }
45
+ if (outcome.linkedFolders)
46
+ print(dim(` ${outcome.linkedFolders} project folder link${outcome.linkedFolders === 1 ? '' : 's'} kept on this machine.`));
47
+ });
48
+ }
@@ -0,0 +1,82 @@
1
+ import { createUpdater } from '../../bootstrap.js';
2
+ import { APP_AUTHOR, APP_DESCRIPTION, APP_NAME, APP_SLOGAN, APP_VERSION } from '../../ui/branding/brand.js';
3
+ import { bold, color, dim, print } from '../output.js';
4
+ export async function printVersion() {
5
+ print(`${bold(APP_NAME)} ${color('green', `v${APP_VERSION}`)}`);
6
+ print(APP_DESCRIPTION);
7
+ print(dim(APP_AUTHOR));
8
+ const update = await createUpdater().cachedCheck();
9
+ if (update?.available)
10
+ print(color('yellow', `v${update.latest} is available: soja update`));
11
+ }
12
+ const COMMANDS = [
13
+ ['task list', 'Your open tasks (--all, --status <s>, --project <key>)'],
14
+ ['task create <title>', 'New task (--project, --type, --priority, --assignee, --requester)'],
15
+ ['task show <id>', 'Details, activity and comments'],
16
+ ['task start <id>', 'Assign to yourself and move to In Progress (no Git)'],
17
+ ['task done <id>', 'Mark as Done'],
18
+ ['task reopen <id>', 'Move a closed task back to Todo'],
19
+ ['task comment <id> [text|-]', 'Comment; without text opens $EDITOR, - reads stdin'],
20
+ ['task describe <id> [text|-]', 'Replace the description; without text edits it in $EDITOR'],
21
+ ['task archive|restore <id>', 'Hide a task from lists, or bring it back (list --archived)'],
22
+ ['task delete <id> [--yes]', 'Delete a task for good (owners; type the ID to confirm)'],
23
+ ['project list', 'Projects with open work'],
24
+ ['project create <name>', 'New project (--key, --repo-path, --repo-url)'],
25
+ ['project edit <key>', 'Change name (--name), --description or --url'],
26
+ ['project link <key> [path|name]', 'Link a project to a local Git repository (default: here)'],
27
+ ['project unlink <key>', 'Forget the linked repository'],
28
+ ['start <id>', 'Task start + create/switch its Git branch (--from <ref>, --link)'],
29
+ ['commit <id> -m <msg> [files|--all]', 'Commit on the task branch; adds (SOJA-12) to the message'],
30
+ ['merge <id> [--delete] [--done]', 'Merge the task branch into its base (asks first)'],
31
+ ['branch delete <id> [--force]', 'Delete the task branch (asks first)'],
32
+ ['push <id>', 'Push the task branch to origin'],
33
+ ['pr <id>', 'Push and open a pull request with the GitHub CLI (asks first)'],
34
+ ['pr status <id>', 'The task PR on GitHub: state, review and checks'],
35
+ ['pr merge <id> [--delete-branch]', 'Merge the PR on GitHub and mark the task Done (asks first)'],
36
+ ['login --server <url>', 'Sign in with GitHub; new accounts request Online access'],
37
+ ['access status|request', 'Check or submit your SOJA Online access request'],
38
+ ['access approvals', 'Review pending requests (CEO only)'],
39
+ ['access approve|reject <id>', 'Decide an access request (CEO only)'],
40
+ ['logout', 'Sign out and go back to local mode'],
41
+ ['account delete [--yes]', 'Delete the Online account and anonymize shared authorship'],
42
+ ['mode [local|remote]', 'Show or switch where SOJA keeps data'],
43
+ ['whoami', 'Current user, workspace and mode'],
44
+ ['import-local [--from <slug>]', 'Bring local-mode projects, tasks and history to the team server (owners)'],
45
+ ['sync [--dismiss]', 'Sync now (remote mode); shows conflicts and rejected changes'],
46
+ ['chat [#channel]', 'Open the team chat (remote mode)'],
47
+ ['chat send #channel <text|->', 'Send a message; - reads it from stdin (scripts, hooks)'],
48
+ ['chat log [#channel] [-n 20]', 'Latest messages of a channel'],
49
+ ['chat channels', 'Channels with unread messages and mentions'],
50
+ ['chat new <name> [--topic]', 'Create a channel'],
51
+ ['chat topic <channel> <text>', 'Set a channel topic (empty clears it)'],
52
+ ['chat archive|unarchive <channel>', 'Archive a channel or bring it back (owners)'],
53
+ ['update [--check]', 'Install the newest SOJA release from npm'],
54
+ ['backup [list | restore <name>]', 'Copy the database now, list copies (one a day is automatic), or restore one'],
55
+ ['folders [list]', 'Parent folders and their subfolders, like ls -1'],
56
+ ['folders add|remove <path>', 'Register or forget a folder that contains repositories'],
57
+ ['workspace list', 'Your workspaces'],
58
+ ['workspace create <name>', 'New workspace (you are the owner)'],
59
+ ['workspace add <username>', 'Add a developer to the active workspace'],
60
+ ['use <workspace>', 'Switch the active workspace'],
61
+ ];
62
+ export function printHelp() {
63
+ print(`${bold(APP_NAME)}${color('green', '▁')} ${dim(`v${APP_VERSION}`)}`);
64
+ print(APP_DESCRIPTION);
65
+ print();
66
+ print(dim(APP_SLOGAN));
67
+ print();
68
+ print(bold('Usage'));
69
+ print(` soja ${dim('Open the interface')}`);
70
+ print(` soja <command> [options]`);
71
+ print();
72
+ print(bold('Commands'));
73
+ const width = Math.max(...COMMANDS.map(([command]) => command.length)) + 3;
74
+ for (const [command, description] of COMMANDS)
75
+ print(` ${command.padEnd(width)}${dim(description)}`);
76
+ print();
77
+ print(bold('Options'));
78
+ print(` -h, --help${' '.repeat(width - 10)}${dim('Show this help')}`);
79
+ print(` -v, --version${' '.repeat(width - 13)}${dim('Show the version')}`);
80
+ print();
81
+ print(dim(`Task IDs look like SOJA-12. Debug output: SOJA_DEBUG=1 soja …`));
82
+ }