dsh-ccgui 0.0.0-stage → 1.0.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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -2
  3. package/THIRD_PARTY_NOTICES.md +64 -0
  4. package/assets/logo.png +0 -0
  5. package/cordis.patch.yml +32 -0
  6. package/lib/abortable.d.ts +12 -0
  7. package/lib/abortable.js +27 -0
  8. package/lib/adapter.d.ts +39 -0
  9. package/lib/adapter.js +64 -0
  10. package/lib/async-queue.d.ts +26 -0
  11. package/lib/async-queue.js +55 -0
  12. package/lib/cli-session.d.ts +99 -0
  13. package/lib/cli-session.js +384 -0
  14. package/lib/cli-tool.d.ts +18 -0
  15. package/lib/cli-tool.js +101 -0
  16. package/lib/client.js +4039 -0
  17. package/lib/constants.d.ts +22 -0
  18. package/lib/constants.js +22 -0
  19. package/lib/git/git-read.d.ts +65 -0
  20. package/lib/git/git-read.js +139 -0
  21. package/lib/git/git-requests.d.ts +44 -0
  22. package/lib/git/git-requests.js +153 -0
  23. package/lib/git/git-routes.d.ts +38 -0
  24. package/lib/git/git-routes.js +78 -0
  25. package/lib/git/git-run.d.ts +58 -0
  26. package/lib/git/git-run.js +97 -0
  27. package/lib/git/git-status.d.ts +51 -0
  28. package/lib/git/git-status.js +122 -0
  29. package/lib/git/git-write.d.ts +75 -0
  30. package/lib/git/git-write.js +134 -0
  31. package/lib/index.d.ts +78 -0
  32. package/lib/index.js +229 -0
  33. package/lib/model-cache.d.ts +33 -0
  34. package/lib/model-cache.js +101 -0
  35. package/lib/model-catalog.d.ts +63 -0
  36. package/lib/model-catalog.js +107 -0
  37. package/lib/model-options.d.ts +26 -0
  38. package/lib/model-options.js +46 -0
  39. package/lib/model-probe.d.ts +16 -0
  40. package/lib/model-probe.js +91 -0
  41. package/lib/npx-cache.d.ts +39 -0
  42. package/lib/npx-cache.js +97 -0
  43. package/lib/permission.d.ts +17 -0
  44. package/lib/permission.js +34 -0
  45. package/lib/prompt-images.d.ts +44 -0
  46. package/lib/prompt-images.js +46 -0
  47. package/lib/request-input.d.ts +42 -0
  48. package/lib/request-input.js +113 -0
  49. package/lib/session/connect.d.ts +41 -0
  50. package/lib/session/connect.js +68 -0
  51. package/lib/session/drive-tool.d.ts +39 -0
  52. package/lib/session/drive-tool.js +37 -0
  53. package/lib/session/model-state.d.ts +40 -0
  54. package/lib/session/model-state.js +68 -0
  55. package/lib/session/outcomes.d.ts +31 -0
  56. package/lib/session/outcomes.js +67 -0
  57. package/lib/session/permission-queue.d.ts +24 -0
  58. package/lib/session/permission-queue.js +33 -0
  59. package/lib/session/read-step.d.ts +31 -0
  60. package/lib/session/read-step.js +53 -0
  61. package/lib/session/tool-mirror.d.ts +65 -0
  62. package/lib/session/tool-mirror.js +131 -0
  63. package/lib/session/turn-queue.d.ts +76 -0
  64. package/lib/session/turn-queue.js +102 -0
  65. package/lib/session/types.d.ts +58 -0
  66. package/lib/session/types.js +6 -0
  67. package/lib/session-registry.d.ts +124 -0
  68. package/lib/session-registry.js +189 -0
  69. package/lib/stdio-transport.d.ts +28 -0
  70. package/lib/stdio-transport.js +164 -0
  71. package/lib/stream-blocks.d.ts +52 -0
  72. package/lib/stream-blocks.js +95 -0
  73. package/lib/tool-record.d.ts +100 -0
  74. package/lib/tool-record.js +167 -0
  75. package/lib/transport.d.ts +35 -0
  76. package/lib/transport.js +5 -0
  77. package/package.json +136 -4
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared names.
3
+ * @module dsh-ccgui/constants
4
+ */
5
+ /**
6
+ * The only tool a CLI session calls in the harness. The name is deliberately not
7
+ * one the harness ships, so a CLI call can never reach a real tool body and run a
8
+ * command the CLI already ran.
9
+ */
10
+ export declare const CLI_TOOL_NAME = "cli_tool";
11
+ /** Every CLI's provider route starts with this; the rest is its configured id. */
12
+ export declare const CLI_ROUTE_PREFIX = "cli-";
13
+ /**
14
+ * Name a CLI's provider route.
15
+ * @param id - the configured agent id.
16
+ * @returns its route.
17
+ */
18
+ export declare const cliRoute: (id: string) => string;
19
+ /** Model id meaning whatever the CLI itself is set to. */
20
+ export declare const DEFAULT_MODEL_ID = "default";
21
+ /** Path prefix of the 变更 panel's git routes; the browser posts to `<prefix>/<operation>`. */
22
+ export declare const GIT_ROUTE_PREFIX = "/dsh-ccgui/git";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared names.
3
+ * @module dsh-ccgui/constants
4
+ */
5
+ /**
6
+ * The only tool a CLI session calls in the harness. The name is deliberately not
7
+ * one the harness ships, so a CLI call can never reach a real tool body and run a
8
+ * command the CLI already ran.
9
+ */
10
+ export const CLI_TOOL_NAME = 'cli_tool';
11
+ /** Every CLI's provider route starts with this; the rest is its configured id. */
12
+ export const CLI_ROUTE_PREFIX = 'cli-';
13
+ /**
14
+ * Name a CLI's provider route.
15
+ * @param id - the configured agent id.
16
+ * @returns its route.
17
+ */
18
+ export const cliRoute = (id) => `${CLI_ROUTE_PREFIX}${id}`;
19
+ /** Model id meaning whatever the CLI itself is set to. */
20
+ export const DEFAULT_MODEL_ID = 'default';
21
+ /** Path prefix of the 变更 panel's git routes; the browser posts to `<prefix>/<operation>`. */
22
+ export const GIT_ROUTE_PREFIX = '/dsh-ccgui/git';
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The 变更 panel's reads: where a directory's repository is, its status with line counts,
3
+ * its branches, and the patch of one file.
4
+ * @module dsh-ccgui/git/git-read
5
+ */
6
+ import type { Spawn } from './git-run.ts';
7
+ import type { GitStatus } from './git-status.ts';
8
+ export interface RepoStatus extends GitStatus {
9
+ /** The repository's top level; every path in the status is relative to it. */
10
+ readonly root: string;
11
+ }
12
+ export interface BranchList {
13
+ readonly current: string | undefined;
14
+ readonly local: readonly string[];
15
+ readonly remote: readonly string[];
16
+ }
17
+ export interface FileDiff {
18
+ readonly patch: string;
19
+ readonly truncated: boolean;
20
+ }
21
+ /** Which version of a file a diff compares. */
22
+ export type DiffSide = 'staged' | 'unstaged' | 'untracked';
23
+ /**
24
+ * Find the repository a directory belongs to.
25
+ * @param spawn - the subprocess service's spawn.
26
+ * @param cwd - any directory.
27
+ * @returns the top level, or undefined when the directory is not in a repository.
28
+ */
29
+ export declare function repoRoot(spawn: Spawn, cwd: string): Promise<string | undefined>;
30
+ /**
31
+ * Whether a path is one of the untracked files the status lists. git does not descend into a
32
+ * symlinked directory, so `link/key.txt` behind `link -> ~/.ssh` is not one of them; the check
33
+ * runs through git rather than the file system because git may run on another host.
34
+ * @param spawn - the subprocess service's spawn.
35
+ * @param root - the repository's top level.
36
+ * @param path - a path relative to the root.
37
+ * @returns true when git lists the path as untracked and not ignored.
38
+ */
39
+ export declare function isUntracked(spawn: Spawn, root: string, path: string): Promise<boolean>;
40
+ /**
41
+ * Read the status of the repository at `root`, with line counts for every group.
42
+ * @param spawn - the subprocess service's spawn.
43
+ * @param root - the repository's top level.
44
+ * @returns the branch, its distance from upstream, and the three groups.
45
+ */
46
+ export declare function readStatus(spawn: Spawn, root: string): Promise<RepoStatus>;
47
+ /**
48
+ * List local and remote branches.
49
+ * @param spawn - the subprocess service's spawn.
50
+ * @param root - the repository's top level.
51
+ * @returns the checked-out branch and the others, locals first as git sorts them.
52
+ */
53
+ export declare function readBranches(spawn: Spawn, root: string): Promise<BranchList>;
54
+ /**
55
+ * Read the unified patch of one file.
56
+ * @param spawn - the subprocess service's spawn.
57
+ * @param root - the repository's top level.
58
+ * @param target - the file, the side compared, and the path it had before a rename.
59
+ * @returns the patch, cut at the size limit.
60
+ */
61
+ export declare function readDiff(spawn: Spawn, root: string, target: {
62
+ readonly path: string;
63
+ readonly side: DiffSide;
64
+ readonly origPath?: string | undefined;
65
+ }): Promise<FileDiff>;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The 变更 panel's reads: where a directory's repository is, its status with line counts,
3
+ * its branches, and the patch of one file.
4
+ * @module dsh-ccgui/git/git-read
5
+ */
6
+ import { failureOf, mustGit, runGit } from "./git-run.js";
7
+ import { parseNumstat, parseStatus, withCounts } from "./git-status.js";
8
+ /** Untracked files beyond this many are listed without line counts; each count is one git run. */
9
+ const MAX_COUNTED_UNTRACKED = 100;
10
+ /**
11
+ * Larger patches are not shown at all: collection keeps the tail of a stream, and a patch
12
+ * missing its head cannot be read. The panel cuts what it draws by lines on its own.
13
+ */
14
+ const MAX_DIFF_BYTES = 8 * 1024 * 1024;
15
+ const NULL_DEVICE = '/dev/null';
16
+ /** `git diff --no-index` exits with 1 when the files differ, which is the answer, not a failure. */
17
+ const DIFFERS = 1;
18
+ /**
19
+ * Untracked counts run this many git processes at a time: each one is a process start, and a
20
+ * timer repeats the whole read, so a burst of a hundred would recur every few seconds.
21
+ */
22
+ const COUNT_CONCURRENCY = 8;
23
+ /**
24
+ * External diff programs and textconv drivers are commands named by repository config; the
25
+ * panel reads diffs without asking anyone, so it never runs them.
26
+ */
27
+ const NO_DRIVERS = ['--no-ext-diff', '--no-textconv'];
28
+ /**
29
+ * Find the repository a directory belongs to.
30
+ * @param spawn - the subprocess service's spawn.
31
+ * @param cwd - any directory.
32
+ * @returns the top level, or undefined when the directory is not in a repository.
33
+ */
34
+ export async function repoRoot(spawn, cwd) {
35
+ const result = await runGit(spawn, cwd, ['rev-parse', '--show-toplevel']);
36
+ const root = result.stdout.trim();
37
+ return result.exitCode === 0 && root !== '' ? root : undefined;
38
+ }
39
+ async function untrackedCount(spawn, root, entry) {
40
+ const args = ['diff', '--no-index', ...NO_DRIVERS, '--numstat', '-z', '--', NULL_DEVICE, entry.path];
41
+ const result = await runGit(spawn, root, args);
42
+ // The no-index numstat names the pair, not the file, so the single record is the count.
43
+ const [count] = parseNumstat(result.stdout).values();
44
+ return count === undefined ? entry : { ...entry, ...count };
45
+ }
46
+ async function untrackedCounts(spawn, root, entries) {
47
+ const counted = entries.slice(0, MAX_COUNTED_UNTRACKED);
48
+ const batches = Array.from({ length: Math.ceil(counted.length / COUNT_CONCURRENCY) }, (_, index) => counted.slice(index * COUNT_CONCURRENCY, (index + 1) * COUNT_CONCURRENCY));
49
+ const results = await batches.reduce(async (done, batch) => [
50
+ ...(await done),
51
+ ...(await Promise.all(batch.map((entry) => untrackedCount(spawn, root, entry)))),
52
+ ], Promise.resolve([]));
53
+ return [...results, ...entries.slice(MAX_COUNTED_UNTRACKED)];
54
+ }
55
+ /**
56
+ * Whether a path is one of the untracked files the status lists. git does not descend into a
57
+ * symlinked directory, so `link/key.txt` behind `link -> ~/.ssh` is not one of them; the check
58
+ * runs through git rather than the file system because git may run on another host.
59
+ * @param spawn - the subprocess service's spawn.
60
+ * @param root - the repository's top level.
61
+ * @param path - a path relative to the root.
62
+ * @returns true when git lists the path as untracked and not ignored.
63
+ */
64
+ export async function isUntracked(spawn, root, path) {
65
+ const result = await mustGit(spawn, root, ['ls-files', '-z', '--others', '--exclude-standard', '--', path], '读取 Git 状态失败');
66
+ return result.stdout === `${path}\0`;
67
+ }
68
+ /**
69
+ * Read the status of the repository at `root`, with line counts for every group.
70
+ * @param spawn - the subprocess service's spawn.
71
+ * @param root - the repository's top level.
72
+ * @returns the branch, its distance from upstream, and the three groups.
73
+ */
74
+ export async function readStatus(spawn, root) {
75
+ const failure = '读取 Git 状态失败';
76
+ const [status, staged, unstaged] = await Promise.all([
77
+ mustGit(spawn, root, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], failure),
78
+ mustGit(spawn, root, ['diff', '--cached', ...NO_DRIVERS, '--numstat', '-z', '-M'], failure),
79
+ mustGit(spawn, root, ['diff', ...NO_DRIVERS, '--numstat', '-z'], failure),
80
+ ]);
81
+ const parsed = parseStatus(status.stdout);
82
+ return {
83
+ ...parsed,
84
+ root,
85
+ staged: withCounts(parsed.staged, parseNumstat(staged.stdout)),
86
+ unstaged: withCounts(parsed.unstaged, parseNumstat(unstaged.stdout)),
87
+ untracked: await untrackedCounts(spawn, root, parsed.untracked),
88
+ };
89
+ }
90
+ /**
91
+ * List local and remote branches.
92
+ * @param spawn - the subprocess service's spawn.
93
+ * @param root - the repository's top level.
94
+ * @returns the checked-out branch and the others, locals first as git sorts them.
95
+ */
96
+ export async function readBranches(spawn, root) {
97
+ const format = '%(refname)%00%(refname:short)%00%(HEAD)';
98
+ const result = await mustGit(spawn, root, ['for-each-ref', `--format=${format}`, 'refs/heads', 'refs/remotes'], '读取分支失败');
99
+ const rows = result.stdout
100
+ .split('\n')
101
+ .filter((line) => line !== '')
102
+ .map((line) => {
103
+ const [ref = '', name = '', head = ''] = line.split('\0');
104
+ return { ref, name, head: head === '*' };
105
+ });
106
+ return {
107
+ current: rows.find((row) => row.head)?.name,
108
+ local: rows.filter((row) => row.ref.startsWith('refs/heads/')).map((row) => row.name),
109
+ // A remote's HEAD is an alias for one of its branches, not a branch to pick.
110
+ remote: rows
111
+ .filter((row) => row.ref.startsWith('refs/remotes/') && !row.ref.endsWith('/HEAD'))
112
+ .map((row) => row.name),
113
+ };
114
+ }
115
+ function diffArgs(side, path, origPath) {
116
+ const paths = origPath === undefined ? [path] : [origPath, path];
117
+ if (side === 'untracked')
118
+ return ['diff', '--no-index', ...NO_DRIVERS, '--', NULL_DEVICE, path];
119
+ if (side === 'staged')
120
+ return ['diff', '--cached', ...NO_DRIVERS, '-M', '--', ...paths];
121
+ return ['diff', ...NO_DRIVERS, '--', path];
122
+ }
123
+ /**
124
+ * Read the unified patch of one file.
125
+ * @param spawn - the subprocess service's spawn.
126
+ * @param root - the repository's top level.
127
+ * @param target - the file, the side compared, and the path it had before a rename.
128
+ * @returns the patch, cut at the size limit.
129
+ */
130
+ export async function readDiff(spawn, root, target) {
131
+ const args = diffArgs(target.side, target.path, target.origPath);
132
+ const result = await runGit(spawn, root, args, { maxBytes: MAX_DIFF_BYTES });
133
+ // A missing file also exits with 1 under --no-index; only that case writes to stderr.
134
+ const differs = target.side === 'untracked' && result.exitCode === DIFFERS && result.stderr.trim() === '';
135
+ const ok = result.exitCode === 0 || differs;
136
+ if (!ok)
137
+ throw failureOf(result, '读取差异失败');
138
+ return result.truncated ? { patch: '', truncated: true } : { patch: result.stdout, truncated: false };
139
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Validate the JSON the 变更 panel posts and run the git operation it names. Everything
3
+ * arriving here is untrusted: the directory must be absolute, and every file path must stay
4
+ * inside the repository.
5
+ * @module dsh-ccgui/git/git-requests
6
+ */
7
+ import type { Spawn } from './git-run.ts';
8
+ /** A request the panel got wrong; answered with 400 rather than 500. */
9
+ export declare class GitRequestError extends Error {
10
+ name: string;
11
+ }
12
+ /** The operations the routes accept, by the last segment of their path. */
13
+ export declare const GIT_OPERATIONS: readonly ["status", "branches", "diff", "stage", "unstage", "discard", "commit", "push", "pull", "checkout", "create-branch"];
14
+ export type GitOperation = (typeof GIT_OPERATIONS)[number];
15
+ type Body = Readonly<Record<string, unknown>>;
16
+ /**
17
+ * Narrow a parsed value to an object body.
18
+ * @param value - the parsed JSON.
19
+ * @returns the body.
20
+ * @throws GitRequestError for anything but a plain object.
21
+ */
22
+ export declare function asBody(value: unknown): Body;
23
+ /**
24
+ * Check that an operation name is one the routes serve.
25
+ * @param name - the last path segment.
26
+ * @returns the operation, or undefined for an unknown name.
27
+ */
28
+ export declare function operationOf(name: string): GitOperation | undefined;
29
+ /**
30
+ * Run one operation for the directory in the body. A directory outside any repository
31
+ * answers `{ repo: false }` to every operation, which the panel shows as its empty state.
32
+ * @param spawn - the subprocess service's spawn.
33
+ * @param operation - what to do.
34
+ * @param body - the validated-on-read request body.
35
+ * @returns the operation's data; writes answer with the fresh status.
36
+ */
37
+ export declare function runOperation(spawn: Spawn, operation: GitOperation, body: Body): Promise<unknown>;
38
+ /**
39
+ * The HTTP status for an error an operation threw.
40
+ * @param error - what was thrown.
41
+ * @returns 400 for a bad request, 409 for a git refusal, 500 otherwise.
42
+ */
43
+ export declare function statusFor(error: unknown): number;
44
+ export {};
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Validate the JSON the 变更 panel posts and run the git operation it names. Everything
3
+ * arriving here is untrusted: the directory must be absolute, and every file path must stay
4
+ * inside the repository.
5
+ * @module dsh-ccgui/git/git-requests
6
+ */
7
+ import { isAbsolute } from 'node:path';
8
+ import { isUntracked, readBranches, readDiff, readStatus, repoRoot } from "./git-read.js";
9
+ import { GitError } from "./git-run.js";
10
+ import { checkout, commit, createBranch, discard, pull, push, stage, unstage } from "./git-write.js";
11
+ /** A request the panel got wrong; answered with 400 rather than 500. */
12
+ export class GitRequestError extends Error {
13
+ name = 'GitRequestError';
14
+ }
15
+ /** The operations the routes accept, by the last segment of their path. */
16
+ export const GIT_OPERATIONS = [
17
+ 'status',
18
+ 'branches',
19
+ 'diff',
20
+ 'stage',
21
+ 'unstage',
22
+ 'discard',
23
+ 'commit',
24
+ 'push',
25
+ 'pull',
26
+ 'checkout',
27
+ 'create-branch',
28
+ ];
29
+ /** More files than any selection the panel can make; a cap on argv length. */
30
+ const MAX_PATHS = 5_000;
31
+ const MAX_TEXT = 64 * 1024;
32
+ const DIFF_SIDES = ['staged', 'unstaged', 'untracked'];
33
+ const NOT_A_REPO = { repo: false };
34
+ /**
35
+ * Narrow a parsed value to an object body.
36
+ * @param value - the parsed JSON.
37
+ * @returns the body.
38
+ * @throws GitRequestError for anything but a plain object.
39
+ */
40
+ export function asBody(value) {
41
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
42
+ throw new GitRequestError('请求格式无效');
43
+ return value;
44
+ }
45
+ function text(body, key) {
46
+ const value = body[key];
47
+ if (typeof value !== 'string' || value === '' || value.length > MAX_TEXT || value.includes('\0')) {
48
+ throw new GitRequestError(`缺少或无效的参数:${key}`);
49
+ }
50
+ return value;
51
+ }
52
+ /** Relative, without `..` segments, so git never reaches outside the repository. */
53
+ function repoPath(value) {
54
+ if (typeof value !== 'string' || value === '' || value.includes('\0') || isAbsolute(value)) {
55
+ throw new GitRequestError('文件路径无效');
56
+ }
57
+ if (value.split(/[\\/]/).includes('..'))
58
+ throw new GitRequestError('文件路径无效');
59
+ return value;
60
+ }
61
+ function paths(body, key) {
62
+ const value = body[key];
63
+ if (value === undefined)
64
+ return [];
65
+ if (!Array.isArray(value) || value.length > MAX_PATHS)
66
+ throw new GitRequestError(`缺少或无效的参数:${key}`);
67
+ return value.map(repoPath);
68
+ }
69
+ function diffSide(body) {
70
+ const side = DIFF_SIDES.find((candidate) => candidate === body.side);
71
+ if (side === undefined)
72
+ throw new GitRequestError('缺少或无效的参数:side');
73
+ return side;
74
+ }
75
+ /**
76
+ * Check that an operation name is one the routes serve.
77
+ * @param name - the last path segment.
78
+ * @returns the operation, or undefined for an unknown name.
79
+ */
80
+ export function operationOf(name) {
81
+ return GIT_OPERATIONS.find((operation) => operation === name);
82
+ }
83
+ async function write(spawn, root, operation, body) {
84
+ switch (operation) {
85
+ case 'stage':
86
+ return stage(spawn, root, paths(body, 'paths'));
87
+ case 'unstage':
88
+ return unstage(spawn, root, paths(body, 'paths'));
89
+ case 'discard':
90
+ return discard(spawn, root, { tracked: paths(body, 'tracked'), untracked: paths(body, 'untracked') });
91
+ case 'commit':
92
+ return commit(spawn, root, { message: text(body, 'message'), paths: paths(body, 'paths') });
93
+ case 'push':
94
+ return push(spawn, root);
95
+ case 'pull':
96
+ return pull(spawn, root);
97
+ case 'checkout':
98
+ return checkout(spawn, root, { name: text(body, 'branch'), remote: body.remote === true });
99
+ case 'create-branch':
100
+ return createBranch(spawn, root, text(body, 'branch'));
101
+ default:
102
+ throw new GitRequestError(`不支持的操作:${operation}`);
103
+ }
104
+ }
105
+ async function diff(spawn, root, body) {
106
+ const path = repoPath(body.path);
107
+ const side = diffSide(body);
108
+ // An untracked diff reads the file itself, outside git's object store, so it is limited to
109
+ // the files the status lists; anything else could follow a symlink out of the repository.
110
+ if (side === 'untracked' && !(await isUntracked(spawn, root, path))) {
111
+ throw new GitRequestError('只能查看仓库内未跟踪文件的差异');
112
+ }
113
+ return readDiff(spawn, root, {
114
+ path,
115
+ side,
116
+ origPath: body.origPath === undefined ? undefined : repoPath(body.origPath),
117
+ });
118
+ }
119
+ /**
120
+ * Run one operation for the directory in the body. A directory outside any repository
121
+ * answers `{ repo: false }` to every operation, which the panel shows as its empty state.
122
+ * @param spawn - the subprocess service's spawn.
123
+ * @param operation - what to do.
124
+ * @param body - the validated-on-read request body.
125
+ * @returns the operation's data; writes answer with the fresh status.
126
+ */
127
+ export async function runOperation(spawn, operation, body) {
128
+ const cwd = text(body, 'cwd');
129
+ if (!isAbsolute(cwd))
130
+ throw new GitRequestError('工作目录必须是绝对路径');
131
+ const root = await repoRoot(spawn, cwd);
132
+ if (root === undefined)
133
+ return NOT_A_REPO;
134
+ if (operation === 'branches')
135
+ return readBranches(spawn, root);
136
+ if (operation === 'diff')
137
+ return diff(spawn, root, body);
138
+ if (operation !== 'status')
139
+ await write(spawn, root, operation, body);
140
+ return { repo: true, ...(await readStatus(spawn, root)) };
141
+ }
142
+ /**
143
+ * The HTTP status for an error an operation threw.
144
+ * @param error - what was thrown.
145
+ * @returns 400 for a bad request, 409 for a git refusal, 500 otherwise.
146
+ */
147
+ export function statusFor(error) {
148
+ if (error instanceof GitRequestError)
149
+ return 400;
150
+ if (error instanceof GitError)
151
+ return 409;
152
+ return 500;
153
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * HTTP routes the 变更 panel posts to, under `/dsh-ccgui/git/<operation>`. The harness's
3
+ * web server leaves authentication to each route, so every request first asks the
4
+ * `connection` service for a rejection: its Host/Origin fence stops cross-site and DNS-
5
+ * rebinding callers, and its login-token check stops anyone who is not the signed-in browser.
6
+ * @module dsh-ccgui/git/git-routes
7
+ */
8
+ import type { IncomingMessage, ServerResponse } from 'node:http';
9
+ import type { Spawn } from './git-run.ts';
10
+ /** The parts of the harness's `webServer` service this module uses. */
11
+ export interface GitWebServer {
12
+ register(route: {
13
+ kind: 'prefix';
14
+ path: string;
15
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
16
+ }): () => void;
17
+ }
18
+ /** The parts of the harness's `connection` service this module uses. */
19
+ export interface GitConnection {
20
+ requestRejection(request: {
21
+ readonly headers: IncomingMessage['headers'];
22
+ }): 401 | 403 | undefined;
23
+ }
24
+ /**
25
+ * Build the handler for every git route.
26
+ * @param spawn - the subprocess service's spawn.
27
+ * @param connection - the harness's trust check.
28
+ * @param warn - logs failures that are not the user's doing.
29
+ * @returns the request handler.
30
+ */
31
+ export declare function gitHandler(spawn: Spawn, connection: GitConnection, warn: (message: string) => void): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
32
+ /**
33
+ * Register the git routes on the web server.
34
+ * @param webServer - the harness's web server.
35
+ * @param handler - the handler from {@link gitHandler}.
36
+ * @returns the unregister function.
37
+ */
38
+ export declare function registerGitRoutes(webServer: GitWebServer, handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>): () => void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * HTTP routes the 变更 panel posts to, under `/dsh-ccgui/git/<operation>`. The harness's
3
+ * web server leaves authentication to each route, so every request first asks the
4
+ * `connection` service for a rejection: its Host/Origin fence stops cross-site and DNS-
5
+ * rebinding callers, and its login-token check stops anyone who is not the signed-in browser.
6
+ * @module dsh-ccgui/git/git-routes
7
+ */
8
+ import { GIT_ROUTE_PREFIX } from "../constants.js";
9
+ import { asBody, operationOf, runOperation, statusFor } from "./git-requests.js";
10
+ /** Request bodies are a directory and a list of paths; anything larger is refused. */
11
+ const MAX_BODY_BYTES = 1024 * 1024;
12
+ class BodyTooLarge extends Error {
13
+ name = 'BodyTooLarge';
14
+ }
15
+ function sendJson(res, status, payload, headers = {}) {
16
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
17
+ res.end(JSON.stringify(payload));
18
+ }
19
+ async function readJson(req) {
20
+ const chunks = [];
21
+ let size = 0;
22
+ for await (const chunk of req) {
23
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
24
+ size += buffer.length;
25
+ if (size > MAX_BODY_BYTES)
26
+ throw new BodyTooLarge('请求内容过大');
27
+ chunks.push(buffer);
28
+ }
29
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
30
+ }
31
+ function messageOf(error) {
32
+ return error instanceof Error ? error.message : String(error);
33
+ }
34
+ /**
35
+ * Build the handler for every git route.
36
+ * @param spawn - the subprocess service's spawn.
37
+ * @param connection - the harness's trust check.
38
+ * @param warn - logs failures that are not the user's doing.
39
+ * @returns the request handler.
40
+ */
41
+ export function gitHandler(spawn, connection, warn) {
42
+ return async (req, res) => {
43
+ const rejection = connection.requestRejection(req);
44
+ if (rejection !== undefined) {
45
+ sendJson(res, rejection, { success: false, error: '未授权' });
46
+ return;
47
+ }
48
+ if (req.method !== 'POST') {
49
+ sendJson(res, 405, { success: false, error: '只支持 POST' }, { allow: 'POST' });
50
+ return;
51
+ }
52
+ const name = (req.url ?? '').split('?')[0]?.split('/').pop() ?? '';
53
+ const operation = operationOf(name);
54
+ if (operation === undefined) {
55
+ sendJson(res, 404, { success: false, error: '接口不存在' });
56
+ return;
57
+ }
58
+ try {
59
+ const data = await runOperation(spawn, operation, asBody(await readJson(req)));
60
+ sendJson(res, 200, { success: true, data });
61
+ }
62
+ catch (error) {
63
+ const status = error instanceof BodyTooLarge ? 413 : error instanceof SyntaxError ? 400 : statusFor(error);
64
+ if (status === 500)
65
+ warn(`ccgui: git ${operation} failed: ${messageOf(error)}`);
66
+ sendJson(res, status, { success: false, error: status === 500 ? 'Git 操作失败,请查看日志' : messageOf(error) });
67
+ }
68
+ };
69
+ }
70
+ /**
71
+ * Register the git routes on the web server.
72
+ * @param webServer - the harness's web server.
73
+ * @param handler - the handler from {@link gitHandler}.
74
+ * @returns the unregister function.
75
+ */
76
+ export function registerGitRoutes(webServer, handler) {
77
+ return webServer.register({ kind: 'prefix', path: GIT_ROUTE_PREFIX, handler });
78
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Run one git command in a working directory through the harness's subprocess service, so
3
+ * git resolves in the same execution world as the CLIs (a remote or sandboxed host included).
4
+ * @module dsh-ccgui/git/git-run
5
+ */
6
+ import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
7
+ /** Starts a child process; the harness's `ctx.subprocess.spawn` in production. */
8
+ export type Spawn = (spec: SubprocessSpawnSpec) => SubprocessHandle;
9
+ /** What a finished git command reported. */
10
+ export interface GitResult {
11
+ readonly exitCode: number | null;
12
+ readonly stdout: string;
13
+ readonly stderr: string;
14
+ /** True when stdout outgrew the cap and lost its head. */
15
+ readonly truncated: boolean;
16
+ }
17
+ export interface GitRunOptions {
18
+ /** Ends the command; the caller owns the deadline. */
19
+ readonly signal?: AbortSignal | undefined;
20
+ /** Largest stdout kept in memory, in bytes. */
21
+ readonly maxBytes?: number | undefined;
22
+ /** Written to stdin, which is then closed; a commit message, for instance. */
23
+ readonly input?: string | undefined;
24
+ }
25
+ /**
26
+ * Run `git <args>` in `cwd` and collect its output. A non-zero exit is reported, not thrown;
27
+ * a failure to start git at all rejects.
28
+ * @param spawn - the subprocess service's spawn.
29
+ * @param cwd - the working directory.
30
+ * @param args - git's arguments, never shell-interpreted.
31
+ * @param options - abort signal and output cap.
32
+ * @returns the exit code and the collected output.
33
+ */
34
+ export declare function runGit(spawn: Spawn, cwd: string, args: readonly string[], options?: GitRunOptions): Promise<GitResult>;
35
+ /** A git command that failed; the message is ready to show the user. */
36
+ export declare class GitError extends Error {
37
+ name: string;
38
+ }
39
+ /**
40
+ * Describe a failed command for the user.
41
+ * @param result - what git reported.
42
+ * @param failure - what failed, in front of git's own reason.
43
+ * @returns the error to throw.
44
+ */
45
+ export declare function failureOf(result: GitResult, failure: string): GitError;
46
+ /**
47
+ * Run git and require success.
48
+ * @param spawn - the subprocess service's spawn.
49
+ * @param cwd - the working directory.
50
+ * @param args - git's arguments.
51
+ * @param how - what the user is told failed, in front of git's own reason; with run options
52
+ * (abort signal, output cap, stdin) as an object.
53
+ * @returns the result of a command that exited with 0.
54
+ * @throws GitError when git exits with anything else.
55
+ */
56
+ export declare function mustGit(spawn: Spawn, cwd: string, args: readonly string[], how: string | (GitRunOptions & {
57
+ readonly failure: string;
58
+ })): Promise<GitResult>;