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,363 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { diagnose } from './diagnose.js';
|
|
3
|
+
import { terminalSafe } from '../utils/text.js';
|
|
4
|
+
import { GitError, } from './types.js';
|
|
5
|
+
const FIELD = '\x1f';
|
|
6
|
+
const RECORD = '\x1e';
|
|
7
|
+
const LOG_FORMAT = `--format=%H${FIELD}%h${FIELD}%s${FIELD}%an${FIELD}%at${RECORD}`;
|
|
8
|
+
/** Never block waiting for credentials unless running interactively. */
|
|
9
|
+
const NON_INTERACTIVE = {
|
|
10
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
11
|
+
GCM_INTERACTIVE: 'never',
|
|
12
|
+
GH_PROMPT_DISABLED: '1',
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* GitClient backed by the `git` and `gh` executables. Arguments are passed as
|
|
16
|
+
* an array (no shell). Output is forced to English so failures can be
|
|
17
|
+
* recognized; changing commands stream to the live console.
|
|
18
|
+
*/
|
|
19
|
+
export class CliGit {
|
|
20
|
+
console;
|
|
21
|
+
binaries;
|
|
22
|
+
constructor(console, binaries = { git: 'git', gh: 'gh' }) {
|
|
23
|
+
this.console = console;
|
|
24
|
+
this.binaries = binaries;
|
|
25
|
+
}
|
|
26
|
+
// ── Read-only ───────────────────────────────────────────────────────────
|
|
27
|
+
async repositoryRoot(path) {
|
|
28
|
+
const result = await this.git(['-C', path, 'rev-parse', '--show-toplevel'], { allowFailure: true });
|
|
29
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
30
|
+
}
|
|
31
|
+
async currentBranch(root) {
|
|
32
|
+
const result = await this.git(['-C', root, 'symbolic-ref', '--quiet', '--short', 'HEAD'], { allowFailure: true });
|
|
33
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
34
|
+
}
|
|
35
|
+
async branchExists(root, branch) {
|
|
36
|
+
const result = await this.git(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
37
|
+
allowFailure: true,
|
|
38
|
+
});
|
|
39
|
+
return result.code === 0;
|
|
40
|
+
}
|
|
41
|
+
async isValidBranchName(root, branch) {
|
|
42
|
+
const result = await this.git(['-C', root, 'check-ref-format', '--branch', branch], { allowFailure: true });
|
|
43
|
+
return result.code === 0;
|
|
44
|
+
}
|
|
45
|
+
async uncommittedChanges(root) {
|
|
46
|
+
return (await this.changedFiles(root)).length;
|
|
47
|
+
}
|
|
48
|
+
async commitsOnlyOn(root, branch, limit) {
|
|
49
|
+
// With --branches, --exclude patterns are matched without the refs/heads/ prefix.
|
|
50
|
+
const result = await this.git(['-C', root, 'log', LOG_FORMAT, `--max-count=${limit}`, `refs/heads/${branch}`, '--not', `--exclude=${branch}`, '--branches'], { allowFailure: true });
|
|
51
|
+
return result.code === 0 ? parseLog(result.stdout) : [];
|
|
52
|
+
}
|
|
53
|
+
async commitsMatching(root, pattern, limit) {
|
|
54
|
+
const result = await this.git(['-C', root, 'log', LOG_FORMAT, `--max-count=${limit}`, '--branches', '--regexp-ignore-case', '--extended-regexp', `--grep=${pattern}`], { allowFailure: true });
|
|
55
|
+
return result.code === 0 ? parseLog(result.stdout) : [];
|
|
56
|
+
}
|
|
57
|
+
async originUrl(root) {
|
|
58
|
+
const result = await this.git(['-C', root, 'remote', 'get-url', 'origin'], { allowFailure: true });
|
|
59
|
+
return result.code === 0 ? result.stdout.trim() || null : null;
|
|
60
|
+
}
|
|
61
|
+
async changedFiles(root) {
|
|
62
|
+
const result = await this.git(['-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
63
|
+
return parseStatus(result.stdout);
|
|
64
|
+
}
|
|
65
|
+
async isMerging(root) {
|
|
66
|
+
const result = await this.git(['-C', root, 'rev-parse', '--quiet', '--verify', 'MERGE_HEAD'], { allowFailure: true });
|
|
67
|
+
return result.code === 0;
|
|
68
|
+
}
|
|
69
|
+
async isMergedInto(root, branch, into) {
|
|
70
|
+
const result = await this.git(['-C', root, 'merge-base', '--is-ancestor', `refs/heads/${branch}`, `refs/heads/${into}`], {
|
|
71
|
+
allowFailure: true,
|
|
72
|
+
});
|
|
73
|
+
return result.code === 0;
|
|
74
|
+
}
|
|
75
|
+
async resolveCommit(root, ref) {
|
|
76
|
+
const result = await this.git(['-C', root, 'rev-parse', '--quiet', '--verify', `${ref}^{commit}`], { allowFailure: true });
|
|
77
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
78
|
+
}
|
|
79
|
+
async countCommits(root, from, to) {
|
|
80
|
+
const result = await this.git(['-C', root, 'rev-list', '--count', `${from}..${to}`], { allowFailure: true });
|
|
81
|
+
return result.code === 0 ? Number(result.stdout.trim()) || 0 : 0;
|
|
82
|
+
}
|
|
83
|
+
async recentCommits(root, branch, options) {
|
|
84
|
+
const range = options.since ? `${options.since}..refs/heads/${branch}` : `refs/heads/${branch}`;
|
|
85
|
+
const result = await this.git(['-C', root, 'log', `--format=%H${FIELD}%P${FIELD}%B${RECORD}`, `--max-count=${options.limit}`, range], { allowFailure: true });
|
|
86
|
+
if (result.code !== 0)
|
|
87
|
+
return [];
|
|
88
|
+
return result.stdout
|
|
89
|
+
.split(RECORD)
|
|
90
|
+
.map((record) => record.replace(/^\n+/, ''))
|
|
91
|
+
.filter((record) => record.trim())
|
|
92
|
+
.flatMap((record) => {
|
|
93
|
+
const [hash, parents, message] = record.split(FIELD);
|
|
94
|
+
return hash ? [{ hash: hash.trim(), parents: (parents ?? '').split(' ').filter(Boolean), message: (message ?? '').trim() }] : [];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async defaultBranch(root) {
|
|
98
|
+
const head = await this.git(['-C', root, 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
|
|
99
|
+
allowFailure: true,
|
|
100
|
+
});
|
|
101
|
+
const remoteDefault = head.code === 0 ? head.stdout.trim().replace(/^origin\//, '') : null;
|
|
102
|
+
for (const candidate of [remoteDefault, 'main', 'master']) {
|
|
103
|
+
if (candidate && (await this.branchExists(root, candidate)))
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
// ── Changing the repository (streamed) ─────────────────────────────────
|
|
109
|
+
async createBranch(root, branch, from) {
|
|
110
|
+
await this.git(['-C', root, 'switch', '--create', branch, ...(from ? [from] : [])], {
|
|
111
|
+
stream: true,
|
|
112
|
+
failure: `Could not create ${branch}.`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async switchBranch(root, branch) {
|
|
116
|
+
await this.git(['-C', root, 'switch', branch], { stream: true, failure: `Could not switch to ${branch}.` });
|
|
117
|
+
}
|
|
118
|
+
async commit(root, message, files) {
|
|
119
|
+
// `add -A` records new, modified and deleted files. The old side of a rename
|
|
120
|
+
// no longer exists anywhere, so it only goes to the commit pathspec, where
|
|
121
|
+
// `--only` includes its removal and ignores anything else already staged.
|
|
122
|
+
const current = files.map((file) => file.path);
|
|
123
|
+
const all = [...new Set(files.flatMap((file) => (file.previousPath ? [file.path, file.previousPath] : [file.path])))];
|
|
124
|
+
await this.git(['-C', root, 'add', '--all', '--', ...current], { stream: true, failure: 'Could not stage the files.' });
|
|
125
|
+
await this.git(['-C', root, 'commit', '--only', '-m', message, '--', ...all], {
|
|
126
|
+
stream: true,
|
|
127
|
+
failure: 'Could not commit.',
|
|
128
|
+
});
|
|
129
|
+
return (await this.git(['-C', root, 'rev-parse', 'HEAD'])).stdout.trim();
|
|
130
|
+
}
|
|
131
|
+
async merge(root, branch, into, message) {
|
|
132
|
+
await this.switchBranch(root, into);
|
|
133
|
+
await this.git(['-C', root, 'merge', '--no-ff', '--no-edit', '-m', message, branch], {
|
|
134
|
+
stream: true,
|
|
135
|
+
failure: `Could not merge ${branch} into ${into}.`,
|
|
136
|
+
});
|
|
137
|
+
return (await this.git(['-C', root, 'rev-parse', 'HEAD'])).stdout.trim();
|
|
138
|
+
}
|
|
139
|
+
async abortMerge(root) {
|
|
140
|
+
await this.git(['-C', root, 'merge', '--abort'], { stream: true, failure: 'Could not abort the merge.' });
|
|
141
|
+
}
|
|
142
|
+
async deleteBranch(root, branch, force) {
|
|
143
|
+
await this.git(['-C', root, 'branch', force ? '-D' : '-d', branch], {
|
|
144
|
+
stream: true,
|
|
145
|
+
failure: `Could not delete ${branch}.`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async push(root, branch, options = {}) {
|
|
149
|
+
const args = ['-C', root, 'push', '--set-upstream', 'origin', branch];
|
|
150
|
+
if (options.interactive) {
|
|
151
|
+
await this.attached(this.binaries.git, args, `Could not push ${branch}.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
await this.git(args, { stream: true, failure: `Could not push ${branch}.` });
|
|
155
|
+
}
|
|
156
|
+
async createPullRequest(root, request, options = {}) {
|
|
157
|
+
const args = ['pr', 'create', '--base', request.base, '--head', request.head, '--title', request.title, '--body', request.body];
|
|
158
|
+
if (options.interactive) {
|
|
159
|
+
await this.attached(this.binaries.gh, args, 'Could not open the pull request.', root);
|
|
160
|
+
const view = await this.run(this.binaries.gh, ['pr', 'view', request.head, '--json', 'url', '--jq', '.url'], {
|
|
161
|
+
cwd: root,
|
|
162
|
+
allowFailure: true,
|
|
163
|
+
});
|
|
164
|
+
return view.stdout.trim();
|
|
165
|
+
}
|
|
166
|
+
const result = await this.run(this.binaries.gh, args, { cwd: root, stream: true, failure: 'Could not open the pull request.' });
|
|
167
|
+
return result.stdout.match(/https?:\/\/\S+/)?.[0] ?? result.stdout.trim();
|
|
168
|
+
}
|
|
169
|
+
async loginGitHub() {
|
|
170
|
+
await this.attached(this.binaries.gh, ['auth', 'login'], 'GitHub login did not finish.');
|
|
171
|
+
}
|
|
172
|
+
async pullRequestFor(root, branch) {
|
|
173
|
+
// `gh pr view <branch>` only finds open ones; the list also finds merged and closed.
|
|
174
|
+
const result = await this.run(this.binaries.gh, ['pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', PR_FIELDS], { cwd: root, failure: 'Could not read the pull request from GitHub.' });
|
|
175
|
+
return parsePullRequests(result.stdout)[0] ?? null;
|
|
176
|
+
}
|
|
177
|
+
async pullRequests(root, limit) {
|
|
178
|
+
const result = await this.run(this.binaries.gh, ['pr', 'list', '--state', 'all', '--limit', String(limit), '--json', PR_FIELDS], { cwd: root, failure: 'Could not read pull requests from GitHub.' });
|
|
179
|
+
return parsePullRequests(result.stdout);
|
|
180
|
+
}
|
|
181
|
+
async mergePullRequest(root, number, options) {
|
|
182
|
+
const args = ['pr', 'merge', String(number), '--merge', ...(options.deleteBranch ? ['--delete-branch'] : [])];
|
|
183
|
+
await this.run(this.binaries.gh, args, { cwd: root, stream: true, failure: `Could not merge pull request #${number}.` });
|
|
184
|
+
}
|
|
185
|
+
// ── Process plumbing ────────────────────────────────────────────────────
|
|
186
|
+
git(args, options = {}) {
|
|
187
|
+
return this.run(this.binaries.git, args, options);
|
|
188
|
+
}
|
|
189
|
+
run(binary, args, options = {}) {
|
|
190
|
+
if (options.stream)
|
|
191
|
+
this.console?.write('command', `$ ${describeCommand(binary, args)}`);
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
let stdout = '';
|
|
194
|
+
let stderr = '';
|
|
195
|
+
const child = spawn(binary, args, {
|
|
196
|
+
cwd: options.cwd,
|
|
197
|
+
env: { ...process.env, ...NON_INTERACTIVE, LC_ALL: 'C', LANG: 'C', ...options.env },
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
+
});
|
|
200
|
+
const timer = setTimeout(() => child.kill('SIGTERM'), 120_000);
|
|
201
|
+
child.stdout.on('data', (chunk) => {
|
|
202
|
+
const text = chunk.toString();
|
|
203
|
+
stdout += text;
|
|
204
|
+
if (options.stream)
|
|
205
|
+
this.console?.write('stdout', text);
|
|
206
|
+
});
|
|
207
|
+
child.stderr.on('data', (chunk) => {
|
|
208
|
+
const text = chunk.toString();
|
|
209
|
+
stderr += text;
|
|
210
|
+
if (options.stream)
|
|
211
|
+
this.console?.write('stderr', text);
|
|
212
|
+
});
|
|
213
|
+
child.on('error', (error) => {
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
reject(missingBinary(binary, error));
|
|
216
|
+
});
|
|
217
|
+
child.on('close', (exitCode) => {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
const code = exitCode ?? 1;
|
|
220
|
+
if (code !== 0 && !options.allowFailure) {
|
|
221
|
+
const found = diagnose(`${stderr}\n${stdout}`, options.failure ?? `${binary} failed.`);
|
|
222
|
+
if (options.stream)
|
|
223
|
+
this.console?.write('error', found.message);
|
|
224
|
+
reject(new GitError(found.message, { code: found.code, suggestions: found.suggestions }));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
resolve({ code, stdout, stderr });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Runs attached to the real terminal, so Git, ssh or gh can prompt for
|
|
233
|
+
* credentials. Output goes straight to the terminal, not to the console.
|
|
234
|
+
*/
|
|
235
|
+
attached(binary, args, failure, cwd) {
|
|
236
|
+
this.console?.write('command', `$ ${describeCommand(binary, args)} (interactive)`);
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const child = spawn(binary, args, { cwd, stdio: 'inherit', env: process.env });
|
|
239
|
+
child.on('error', (error) => reject(missingBinary(binary, error)));
|
|
240
|
+
child.on('close', (code) => {
|
|
241
|
+
if (code === 0) {
|
|
242
|
+
this.console?.write('info', 'Finished in the terminal.');
|
|
243
|
+
resolve();
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
this.console?.write('error', `${failure} (exit ${code ?? 'signal'})`);
|
|
247
|
+
reject(new GitError(failure, { suggestions: ['Check the messages printed in the terminal.'] }));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** `git -C /repo commit -m x` → `git commit -m x` (the repository is implied). */
|
|
254
|
+
function describeCommand(binary, args) {
|
|
255
|
+
const shown = args.filter((_, index) => args[index] !== '-C' && args[index - 1] !== '-C');
|
|
256
|
+
return [binary.split('/').at(-1) ?? binary, ...shown.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(' ');
|
|
257
|
+
}
|
|
258
|
+
function missingBinary(binary, error) {
|
|
259
|
+
if (error.code !== 'ENOENT')
|
|
260
|
+
return error;
|
|
261
|
+
return binary.endsWith('gh')
|
|
262
|
+
? new GitError('The GitHub CLI (gh) is not installed.', {
|
|
263
|
+
code: 'gh_missing',
|
|
264
|
+
suggestions: ['Install it from https://cli.github.com, then run `gh auth login`.'],
|
|
265
|
+
})
|
|
266
|
+
: new GitError('Git is not installed or not on your PATH.', { code: 'not_installed', cause: error });
|
|
267
|
+
}
|
|
268
|
+
const KINDS = {
|
|
269
|
+
M: 'modified',
|
|
270
|
+
A: 'added',
|
|
271
|
+
D: 'deleted',
|
|
272
|
+
R: 'renamed',
|
|
273
|
+
C: 'copied',
|
|
274
|
+
T: 'typechange',
|
|
275
|
+
};
|
|
276
|
+
/** Parses `git status --porcelain=v1 -z`. */
|
|
277
|
+
export function parseStatus(output) {
|
|
278
|
+
const entries = output.split('\0');
|
|
279
|
+
const files = [];
|
|
280
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
281
|
+
const entry = entries[index];
|
|
282
|
+
if (!entry || entry.length < 4)
|
|
283
|
+
continue;
|
|
284
|
+
const x = entry[0] ?? ' ';
|
|
285
|
+
const y = entry[1] ?? ' ';
|
|
286
|
+
const path = entry.slice(3);
|
|
287
|
+
if (x === '?' && y === '?') {
|
|
288
|
+
files.push({ path, kind: 'untracked', staged: false });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const conflicted = x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D');
|
|
292
|
+
const code = x !== ' ' ? x : y;
|
|
293
|
+
const file = { path, kind: conflicted ? 'conflicted' : (KINDS[code] ?? 'modified'), staged: x !== ' ' && x !== '?' };
|
|
294
|
+
if (x === 'R' || x === 'C') {
|
|
295
|
+
// In -z mode the original path follows as its own entry.
|
|
296
|
+
const previous = entries[index + 1];
|
|
297
|
+
if (previous)
|
|
298
|
+
file.previousPath = previous;
|
|
299
|
+
index += 1;
|
|
300
|
+
}
|
|
301
|
+
files.push(file);
|
|
302
|
+
}
|
|
303
|
+
return files;
|
|
304
|
+
}
|
|
305
|
+
function parseLog(output) {
|
|
306
|
+
return output
|
|
307
|
+
.split(RECORD)
|
|
308
|
+
.map((record) => record.trim())
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
.flatMap((record) => {
|
|
311
|
+
const [hash, shortHash, subject, author, timestamp] = record.split(FIELD);
|
|
312
|
+
if (!hash || !shortHash || subject === undefined || !author || !timestamp)
|
|
313
|
+
return [];
|
|
314
|
+
// Commits pulled from others can carry escape sequences in their messages.
|
|
315
|
+
return [{ hash, shortHash, subject: terminalSafe(subject), author: terminalSafe(author), date: new Date(Number(timestamp) * 1000) }];
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const PR_FIELDS = 'number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,reviewDecision,mergeable,statusCheckRollup,mergedAt';
|
|
319
|
+
const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
|
|
320
|
+
const FAILED = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
|
|
321
|
+
/** Parses `gh pr list --json …`. Text from GitHub is made terminal-safe (anyone can title a PR). */
|
|
322
|
+
export function parsePullRequests(output) {
|
|
323
|
+
const rows = JSON.parse(output || '[]');
|
|
324
|
+
return rows.map((row) => ({
|
|
325
|
+
number: row.number,
|
|
326
|
+
url: terminalSafe(row.url),
|
|
327
|
+
title: terminalSafe(row.title),
|
|
328
|
+
state: row.state === 'MERGED' ? 'merged' : row.state === 'CLOSED' ? 'closed' : 'open',
|
|
329
|
+
draft: row.isDraft ?? false,
|
|
330
|
+
base: terminalSafe(row.baseRefName),
|
|
331
|
+
head: terminalSafe(row.headRefName),
|
|
332
|
+
headSha: row.headRefOid ?? '',
|
|
333
|
+
review: reviewState(row.reviewDecision),
|
|
334
|
+
mergeable: row.mergeable === 'MERGEABLE' ? 'mergeable' : row.mergeable === 'CONFLICTING' ? 'conflicting' : 'unknown',
|
|
335
|
+
checks: summarizeChecks(row.statusCheckRollup ?? []),
|
|
336
|
+
mergedAt: row.mergedAt ? new Date(row.mergedAt) : null,
|
|
337
|
+
}));
|
|
338
|
+
}
|
|
339
|
+
function reviewState(decision) {
|
|
340
|
+
if (decision === 'APPROVED')
|
|
341
|
+
return 'approved';
|
|
342
|
+
if (decision === 'CHANGES_REQUESTED')
|
|
343
|
+
return 'changes_requested';
|
|
344
|
+
if (decision === 'REVIEW_REQUIRED')
|
|
345
|
+
return 'review_required';
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
function summarizeChecks(checks) {
|
|
349
|
+
const summary = { total: checks.length, passed: 0, failed: 0, pending: 0, failing: [] };
|
|
350
|
+
for (const check of checks) {
|
|
351
|
+
// Check runs report a conclusion once completed; commit statuses report a state.
|
|
352
|
+
const outcome = check.__typename === 'StatusContext' || check.state ? check.state : check.status === 'COMPLETED' ? check.conclusion : 'PENDING';
|
|
353
|
+
if (outcome && PASSED.has(outcome))
|
|
354
|
+
summary.passed += 1;
|
|
355
|
+
else if (outcome && FAILED.has(outcome)) {
|
|
356
|
+
summary.failed += 1;
|
|
357
|
+
summary.failing.push(terminalSafe(check.name ?? check.context ?? 'check'));
|
|
358
|
+
}
|
|
359
|
+
else
|
|
360
|
+
summary.pending += 1;
|
|
361
|
+
}
|
|
362
|
+
return summary;
|
|
363
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { terminalSafe } from '../utils/text.js';
|
|
2
|
+
/**
|
|
3
|
+
* Live record of what SOJA runs in Git. Mutating commands and their output
|
|
4
|
+
* stream here line by line; the interface subscribes to show it as it happens.
|
|
5
|
+
* Keeps the last `capacity` lines in memory (never written to disk).
|
|
6
|
+
*/
|
|
7
|
+
export class GitConsole {
|
|
8
|
+
capacity;
|
|
9
|
+
lines = [];
|
|
10
|
+
listeners = new Set();
|
|
11
|
+
counter = 0;
|
|
12
|
+
constructor(capacity = 500) {
|
|
13
|
+
this.capacity = capacity;
|
|
14
|
+
}
|
|
15
|
+
write(kind, text) {
|
|
16
|
+
for (const part of terminalSafe(text.replace(/\r/g, '\n')).split('\n')) {
|
|
17
|
+
if (!part.trim())
|
|
18
|
+
continue;
|
|
19
|
+
this.lines.push({ id: (this.counter += 1), kind, text: part, at: new Date() });
|
|
20
|
+
}
|
|
21
|
+
if (this.lines.length > this.capacity)
|
|
22
|
+
this.lines = this.lines.slice(-this.capacity);
|
|
23
|
+
for (const listener of this.listeners)
|
|
24
|
+
listener();
|
|
25
|
+
}
|
|
26
|
+
/** Lines written after `afterId` (0 for all). */
|
|
27
|
+
since(afterId = 0) {
|
|
28
|
+
return this.lines.filter((line) => line.id > afterId);
|
|
29
|
+
}
|
|
30
|
+
get lastId() {
|
|
31
|
+
return this.counter;
|
|
32
|
+
}
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
this.listeners.add(listener);
|
|
35
|
+
return () => this.listeners.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { terminalSafe } from '../utils/text.js';
|
|
2
|
+
const RULES = [
|
|
3
|
+
{
|
|
4
|
+
test: /CONFLICT|Automatic merge failed|fix conflicts/i,
|
|
5
|
+
diagnose: () => ({
|
|
6
|
+
code: 'merge_conflict',
|
|
7
|
+
message: 'The merge has conflicts.',
|
|
8
|
+
suggestions: [
|
|
9
|
+
'Resolve the conflicted files in your editor, then `git add` them and `git commit`.',
|
|
10
|
+
'Or abort the merge to go back to how things were (SOJA: a, or `git merge --abort`).',
|
|
11
|
+
],
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
test: /not fully merged/i,
|
|
16
|
+
diagnose: () => ({
|
|
17
|
+
code: 'not_merged',
|
|
18
|
+
message: 'The branch has commits that are not merged anywhere.',
|
|
19
|
+
suggestions: ['Merge it first, or confirm deleting it anyway (those commits will be lost).'],
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
test: /nothing to commit|no changes added to commit|nothing added to commit/i,
|
|
24
|
+
diagnose: () => ({ code: 'nothing_to_commit', message: 'There is nothing to commit.', suggestions: ['Change some files first.'] }),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
test: /would be overwritten|commit your changes or stash them|Please commit or stash/i,
|
|
28
|
+
diagnose: () => ({
|
|
29
|
+
code: 'dirty_worktree',
|
|
30
|
+
message: 'You have uncommitted changes that Git would overwrite.',
|
|
31
|
+
suggestions: ['Commit them from SOJA (C), or run `git stash` and try again.'],
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
test: /Please tell me who you are|Author identity unknown|empty ident name/i,
|
|
36
|
+
diagnose: () => ({
|
|
37
|
+
code: 'identity_unknown',
|
|
38
|
+
message: 'Git does not know who you are.',
|
|
39
|
+
suggestions: ['git config --global user.name "Your Name"', 'git config --global user.email "you@example.com"'],
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: /Authentication failed|could not read Username|terminal prompts disabled|Permission denied \(publickey|Host key verification failed|invalid username or password|returned error: 403/i,
|
|
44
|
+
diagnose: () => ({
|
|
45
|
+
code: 'auth_required',
|
|
46
|
+
message: 'Git needs your credentials for the remote.',
|
|
47
|
+
suggestions: [
|
|
48
|
+
'Retry interactively: SOJA pauses so Git can ask for them (i).',
|
|
49
|
+
'Or set up an SSH key or a credential helper, e.g. `gh auth setup-git`.',
|
|
50
|
+
],
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
test: /Could not resolve host|unable to access|Network is unreachable|Connection timed out|Connection refused|Could not read from remote repository/i,
|
|
55
|
+
diagnose: () => ({
|
|
56
|
+
code: 'offline',
|
|
57
|
+
message: 'Could not reach the remote.',
|
|
58
|
+
suggestions: ['Check your connection. Commit, merge and branches keep working offline.'],
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
test: /\[rejected\]|non-fast-forward|fetch first|failed to push some refs/i,
|
|
63
|
+
diagnose: () => ({
|
|
64
|
+
code: 'rejected',
|
|
65
|
+
message: 'The remote has commits you do not have.',
|
|
66
|
+
suggestions: ['Run `git pull --rebase` on this branch, then push again.'],
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
test: /none of the git remotes configured for this repository point to a known GitHub host|no git remotes found/i,
|
|
71
|
+
diagnose: () => ({
|
|
72
|
+
code: 'not_github',
|
|
73
|
+
message: 'This repository is not on GitHub.',
|
|
74
|
+
suggestions: ['Pull requests and checks need an `origin` on GitHub. Local Git keeps working.'],
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
test: /error connecting to|dial tcp|i\/o timeout|no such host/i,
|
|
79
|
+
diagnose: () => ({
|
|
80
|
+
code: 'offline',
|
|
81
|
+
message: 'Could not reach GitHub.',
|
|
82
|
+
suggestions: ['Check your connection. Commit, merge and branches keep working offline.'],
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
test: /required status check|Required status checks|review is required|At least \d+ approving review|base branch policy prohibits the merge/i,
|
|
87
|
+
diagnose: () => ({
|
|
88
|
+
code: 'blocked',
|
|
89
|
+
message: 'The repository rules do not allow merging this pull request yet.',
|
|
90
|
+
suggestions: ['It needs passing checks or approvals first. See the pull request on GitHub.'],
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
test: /cannot be cleanly created|merge conflict between base and head/i,
|
|
95
|
+
diagnose: () => ({
|
|
96
|
+
code: 'merge_conflict',
|
|
97
|
+
message: 'GitHub cannot merge this pull request: it conflicts with its base.',
|
|
98
|
+
suggestions: ['Merge the base into the branch locally, fix the conflicts, commit and push.'],
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
test: /'origin' does not appear to be a git repository|No such remote|No configured push destination/i,
|
|
103
|
+
diagnose: () => ({
|
|
104
|
+
code: 'no_remote',
|
|
105
|
+
message: 'This repository has no `origin` remote.',
|
|
106
|
+
suggestions: ['Add one: `git remote add origin <url>`.'],
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
test: /a pull request for branch .* already exists/i,
|
|
111
|
+
diagnose: (output) => ({
|
|
112
|
+
code: 'pr_exists',
|
|
113
|
+
message: 'A pull request for this branch already exists.',
|
|
114
|
+
suggestions: [output.match(/https?:\/\/\S+/)?.[0] ?? 'See it with `gh pr view --web`.'],
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
test: /gh auth login|not logged in|authentication token|HTTP 401/i,
|
|
119
|
+
diagnose: () => ({
|
|
120
|
+
code: 'gh_auth',
|
|
121
|
+
message: 'The GitHub CLI is not logged in.',
|
|
122
|
+
suggestions: ['Log in now: SOJA pauses and runs `gh auth login` (i).'],
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
/** Turns Git's (or gh's) output into a friendly message and next steps. */
|
|
127
|
+
export function diagnose(raw, fallback) {
|
|
128
|
+
const output = terminalSafe(raw);
|
|
129
|
+
for (const rule of RULES)
|
|
130
|
+
if (rule.test.test(output))
|
|
131
|
+
return rule.diagnose(output);
|
|
132
|
+
const line = output
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((text) => text.trim())
|
|
135
|
+
.find((text) => text && !text.startsWith('hint:'));
|
|
136
|
+
const detail = line?.replace(/^(fatal|error):\s*/i, '');
|
|
137
|
+
return {
|
|
138
|
+
code: 'failed',
|
|
139
|
+
message: detail ? `${fallback} ${capitalize(detail)}` : fallback,
|
|
140
|
+
suggestions: ['See the Git log above for the full output.'],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function capitalize(text) {
|
|
144
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
145
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the commit in a base branch that shows `branch` was merged into it,
|
|
3
|
+
* even after the branch was deleted:
|
|
4
|
+
* - a merge commit naming the branch or the task ("Merge branch 'fix/SOJA-12-…'",
|
|
5
|
+
* "Merge pull request #5 from you/fix/SOJA-12-…", "Merge SOJA-12: …"), or
|
|
6
|
+
* - a GitHub squash merge ("… (SOJA-12) (#5)"), which SOJA's PR titles produce.
|
|
7
|
+
* Plain commits that merely mention the task on the base branch do not count.
|
|
8
|
+
*/
|
|
9
|
+
export function findMergeEvidence(commits, target) {
|
|
10
|
+
const mentionsTask = new RegExp(`${escapeRegex(target.ref)}([^0-9]|$)`, 'i');
|
|
11
|
+
for (const commit of commits) {
|
|
12
|
+
const isMerge = commit.parents.length > 1;
|
|
13
|
+
if (isMerge && (commit.message.includes(target.branch) || mentionsTask.test(commit.message)))
|
|
14
|
+
return commit.hash;
|
|
15
|
+
const subject = commit.message.split('\n')[0] ?? '';
|
|
16
|
+
if (!isMerge && /\(#\d+\)\s*$/.test(subject) && mentionsTask.test(subject))
|
|
17
|
+
return commit.hash;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function escapeRegex(text) {
|
|
22
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { GitError } from './types.js';
|
|
6
|
+
export class GhReleases {
|
|
7
|
+
repository;
|
|
8
|
+
gh;
|
|
9
|
+
constructor(repository, gh = 'gh') {
|
|
10
|
+
this.repository = repository;
|
|
11
|
+
this.gh = gh;
|
|
12
|
+
}
|
|
13
|
+
async latest() {
|
|
14
|
+
const output = await run(this.gh, ['release', 'view', '--repo', this.repository, '--json', 'tagName', '--jq', '.tagName']);
|
|
15
|
+
return output.trim();
|
|
16
|
+
}
|
|
17
|
+
async download(tag) {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'soja-update-'));
|
|
19
|
+
await run(this.gh, ['release', 'download', tag, '--repo', this.repository, '--pattern', 'soja-cli-*.tgz', '--dir', dir]);
|
|
20
|
+
const file = readdirSync(dir).find((name) => name.endsWith('.tgz'));
|
|
21
|
+
if (!file)
|
|
22
|
+
throw new GitError(`Release ${tag} has no package to install.`, { suggestions: ['It may still be building; try again in a few minutes.'] });
|
|
23
|
+
return join(dir, file);
|
|
24
|
+
}
|
|
25
|
+
install(packagePath) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn('npm', ['install', '--global', packagePath], { stdio: 'inherit' });
|
|
28
|
+
child.on('error', reject);
|
|
29
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new GitError('npm could not install the update.', { suggestions: ['Check the npm messages above (a global prefix may need permissions).'] })));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Public release metadata and tarballs from npm; no GitHub account is needed to update. */
|
|
34
|
+
export class NpmReleases {
|
|
35
|
+
packageName;
|
|
36
|
+
constructor(packageName = 'soja-cli') {
|
|
37
|
+
this.packageName = packageName;
|
|
38
|
+
}
|
|
39
|
+
async latest() {
|
|
40
|
+
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(this.packageName)}/latest`, {
|
|
41
|
+
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
|
42
|
+
signal: AbortSignal.timeout(10_000),
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw new GitError(`npm registry returned ${response.status} while checking for updates.`);
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
if (typeof data.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(data.version))
|
|
48
|
+
throw new GitError('npm registry returned an invalid SOJA version.');
|
|
49
|
+
return data.version;
|
|
50
|
+
}
|
|
51
|
+
async download(tag) {
|
|
52
|
+
const version = tag.replace(/^v/, '');
|
|
53
|
+
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(version))
|
|
54
|
+
throw new GitError('Invalid SOJA update version.');
|
|
55
|
+
const dir = mkdtempSync(join(tmpdir(), 'soja-update-'));
|
|
56
|
+
const output = await run('npm', ['pack', `${this.packageName}@${version}`, '--json', '--pack-destination', dir]);
|
|
57
|
+
let filename;
|
|
58
|
+
try {
|
|
59
|
+
filename = JSON.parse(output)[0]?.filename;
|
|
60
|
+
}
|
|
61
|
+
catch { /* handled below */ }
|
|
62
|
+
if (!filename || filename.includes('/') || filename.includes('\\') || !filename.endsWith('.tgz') || !readdirSync(dir).includes(filename)) {
|
|
63
|
+
throw new GitError(`npm did not provide a valid package for SOJA ${version}.`);
|
|
64
|
+
}
|
|
65
|
+
return join(dir, filename);
|
|
66
|
+
}
|
|
67
|
+
install(packagePath) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const child = spawn('npm', ['install', '--global', packagePath], { stdio: 'inherit' });
|
|
70
|
+
child.on('error', reject);
|
|
71
|
+
child.on('close', (code) => code === 0 ? resolve() : reject(new GitError('npm could not install the update.', { suggestions: ['Check the npm messages above (a global prefix may need permissions).'] })));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function run(binary, args) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let stdout = '';
|
|
78
|
+
let stderr = '';
|
|
79
|
+
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GH_PROMPT_DISABLED: '1' } });
|
|
80
|
+
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
|
|
81
|
+
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
|
82
|
+
child.on('error', (error) => reject(error.code === 'ENOENT'
|
|
83
|
+
? new GitError(binary === 'gh' ? 'The GitHub CLI (gh) is not installed.' : `${binary} is not installed.`, { code: binary === 'gh' ? 'gh_missing' : 'failed', suggestions: binary === 'gh' ? ['Install it from https://cli.github.com, then run `gh auth login`.'] : [] })
|
|
84
|
+
: error));
|
|
85
|
+
child.on('close', (code) => {
|
|
86
|
+
if (code === 0)
|
|
87
|
+
return resolve(stdout);
|
|
88
|
+
const auth = binary === 'gh' && /gh auth login|not logged in|HTTP 401|HTTP 404/i.test(stderr);
|
|
89
|
+
reject(new GitError(auth ? 'GitHub did not let SOJA read its releases.' : `${binary} failed: ${stderr.trim().split('\n')[0] ?? ''}`, {
|
|
90
|
+
code: auth ? 'gh_auth' : 'failed',
|
|
91
|
+
suggestions: auth ? ['Run `gh auth login` with an account that can see the SOJA repository.'] : [],
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|