pi-onedev-toolkit 0.1.1 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-08-26
4
+
5
+ ### Security and correctness
6
+
7
+ - Remove attachment download until TOD can enforce same-origin credentials, redirect safety, bounded downloads, and non-destructive destinations.
8
+ - Treat killed TOD processes as aborts or timeouts instead of successful calls.
9
+ - Reject unknown tool parameters and standardize all multiword parameters on `snake_case`.
10
+ - Fix Cobra boolean arguments for issue confidentiality and PR auto-merge, including explicit `false`, and require a branch in branch build mode.
11
+ - Isolate local build runs with a temporary Git index and disclose/approve build-spec upgrades.
12
+
13
+ ### Agent and user experience
14
+
15
+ - Split the always-active readiness/setup tool (`onedev_context`) from the domain activator (`onedev_tools`), and remove prompt metadata from deferred domain schemas.
16
+ - Add explicit readiness states, lazy authenticated health checks, coalesced context probes, and caller-isolated cancellation.
17
+ - Replace agent-supplied confirmation booleans with real Pi UI approvals scoped once, per session, or persistently by server/project/operation.
18
+ - Add code-comment reply/resolve/unresolve actions and unit-test report support with TOD capability detection.
19
+ - Add bounded, session-scoped one-shot watches for build completion and pull-request attention.
20
+
21
+ ### Quality
22
+
23
+ - Add execution-level, lifecycle, approval-storage, local-index, context, and watch regression tests.
24
+ - Add Bun checks, Node 22.19/24 import smoke tests, and an opt-in read-only live OneDev smoke workflow.
25
+
3
26
  ## 0.1.1
4
27
 
5
28
  - Add `/od-settings` to enable or disable the OneDev project status in the TUI footer.
@@ -8,7 +31,7 @@
8
31
  ## 0.1.0
9
32
 
10
33
  - Initial release.
11
- - `onedev_tools` domain activator with context, login, and attachment-download actions.
34
+ - `onedev_tools` domain activator with context, login, and attachment-download actions (download removed under Unreleased for credential safety).
12
35
  - `onedev_issue`, `onedev_pull`, `onedev_build` tools wrapping the TOD CLI.
13
36
  - `/od-context` and `/od-health` commands; session status line with the inferred project.
14
37
  - `onedev-issue-to-pr` and `onedev-pr-review` skills.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pi-onedev-toolkit
2
2
 
3
- Lightweight [OneDev](https://onedev.io/) integration for [Pi](https://pi.dev/), built on the official [TOD CLI](https://code.onedev.io/onedev/tod): repo-aware issue, pull request, and build tools plus issue-to-PR and PR-review skills.
3
+ Lightweight [OneDev](https://onedev.io/) integration for [Pi](https://pi.dev/), built on the official [TOD CLI](https://code.onedev.io/onedev/tod): repo-aware issue, pull request, build, and one-shot watch tools plus issue-to-PR and PR-review skills.
4
4
 
5
- The agent never shells out to `tod` itself and never sees tokens. The extension resolves the OneDev project from the repository's git remotes once per session, and the tools speak OneDev terms (`123`, `PROJ-123`, `project#123` all resolve server-side).
5
+ The agent never sees tokens. The extension invokes `tod` with structured arguments, resolves the OneDev project from the repository's git remotes, and keeps unavailable domain schemas out of the default prompt. Model-facing multiword parameters use strict `snake_case`; unknown parameters are rejected instead of being silently ignored.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -22,18 +22,26 @@ pi install npm:pi-onedev-toolkit
22
22
  pi install git:github.com/alpertarhan/pi-onedev-toolkit
23
23
  ```
24
24
 
25
- Then run `/od-health` once. When the current directory is a OneDev repository, the status line shows the active project. Use `/od-settings` to enable or disable it for the current session branch.
25
+ When the current directory is a OneDev repository, the status line shows the active project. `onedev_context` action `setup` explains missing TOD configuration, credentials, or project remotes; `/od-health` performs an authenticated re-check. Use `/od-settings` to enable or disable the footer for the current session branch.
26
26
 
27
27
  ## Tools
28
28
 
29
29
  | Tool | Domains | Covers |
30
30
  | --- | --- | --- |
31
- | `onedev_tools` (always active) | — | Activate domains; session context; login name; download issue/PR attachments |
31
+ | `onedev_context` (always active) | — | Readiness, authenticated health, login, and setup guidance |
32
+ | `onedev_tools` (always active) | — | Activate only the issue, pull, build, and watch domains needed now |
32
33
  | `onedev_issue` | issue | Query/get/comment issues, change fields/labels/state, log work, issue branches |
33
- | `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, approve, request changes, guarded merge/discard |
34
- | `onedev_build` | build | Build queries, logs, code-problem reports, changes since last success, run jobs (incl. uncommitted local changes), build-spec validation |
34
+ | `onedev_pull` | pull | PR metadata/comments/code comments/labels/builds/patch, create/edit, reply/resolve review comments, approve, request changes, merge/discard |
35
+ | `onedev_build` | build | Build queries, logs, code-problem and unit-test reports, changes since success, jobs, build-spec validation/upgrades |
36
+ | `onedev_watch` | watch | One-shot build-result and pull-attention notifications; list/stop active session watches |
35
37
 
36
- Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list light. `merge` and `discard` additionally require `confirm: true`.
38
+ Lazy by design: domain tools activate only through `onedev_tools`, keeping the default tool list and system prompt light. Activation performs the first authenticated check and returns the server, project, and login context in the same response.
39
+
40
+ Remote and local mutations use Pi's real interactive approval UI. An approval can apply once, for the session, or persist for the exact server/project/operation in `~/.pi/agent/onedev-toolkit.json`; remove that file to revoke all saved approvals. Non-interactive sessions reject unapproved mutations. `build run` in local mode uses an isolated temporary Git index so TOD cannot leave the user's index staged; `check_spec` is treated as a mutation because TOD may upgrade `.onedev-buildspec.yml`.
41
+
42
+ Watches are session-scoped and one-shot. They use one timer, bounded polling, expiration and backoff, and wake the agent with metadata only. The agent retrieves logs, comments, or build details with the corresponding read tool. At most ten watches may be active; session changes and shutdown clear them.
43
+
44
+ Attachment download is intentionally not exposed: current TOD releases may send the OneDev bearer token to an absolute external Markdown URL and do not provide a safe download-size/overwrite boundary.
37
45
 
38
46
  ## Commands
39
47
 
@@ -41,6 +49,12 @@ Lazy by design: domain tools activate only through `onedev_tools`, keeping the d
41
49
  - `/od-health` — re-check tod configuration and server reachability
42
50
  - `/od-settings` — enable or disable the OneDev footer status in the TUI
43
51
 
52
+ ## Compatibility notes
53
+
54
+ - Update TOD regularly from its upstream install script. `get_unit_test_report` reports an actionable upgrade error when the installed TOD predates that command.
55
+ - Plain references require the current repository to have a OneDev remote from which TOD can infer the project. The toolkit reports `no_project` rather than pretending a qualified reference can bypass TOD's resolver.
56
+ - Tool calls use `snake_case`, for example `for_code_review`, `target_branch`, `report_name`, and `interval_seconds`.
57
+
44
58
  ## Skills
45
59
 
46
60
  - `onedev-issue-to-pr` — gather issue context, branch, implement, present for review, then push and open the PR
@@ -51,6 +65,7 @@ Lazy by design: domain tools activate only through `onedev_tools`, keeping the d
51
65
  ```bash
52
66
  bun install
53
67
  bun run check # typecheck + vitest
68
+ bun run scripts/smoke.ts # optional read-only live smoke; see CONTRIBUTING.md
54
69
  ```
55
70
 
56
71
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-onedev-toolkit",
3
- "version": "0.1.1",
4
- "description": "Lightweight OneDev integration for Pi via the TOD CLI: repo-aware issue, pull request, and build tools",
3
+ "version": "0.2.0",
4
+ "description": "Lightweight OneDev integration for Pi via TOD: repo-aware issue, pull request, build, and watch tools",
5
5
  "type": "module",
6
6
  "author": {
7
7
  "name": "Alper Tarhan",
@@ -55,13 +55,17 @@
55
55
  "devDependencies": {
56
56
  "@earendil-works/pi-ai": "0.84.1",
57
57
  "@earendil-works/pi-coding-agent": "0.84.1",
58
- "@types/node": "^20.17.0",
58
+ "@types/node": "^22.19.0",
59
+ "@types/proper-lockfile": "4.1.4",
59
60
  "typebox": "1.3.12",
60
61
  "typescript": "^7.0.2",
61
62
  "vitest": "^4.1.10"
62
63
  },
63
64
  "engines": {
64
- "node": ">=20.3"
65
+ "node": ">=22.19.0"
65
66
  },
66
- "packageManager": "bun@1.3.14"
67
+ "packageManager": "bun@1.3.14",
68
+ "dependencies": {
69
+ "proper-lockfile": "4.1.2"
70
+ }
67
71
  }
@@ -7,7 +7,7 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
7
7
 
8
8
  ## Prerequisites
9
9
 
10
- - Call `onedev_tools` with domains `["issue", "pull"]` first; note your login name from the returned context.
10
+ - Call `onedev_tools` with domains `["issue", "pull"]` first; note your login name from the returned context. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
11
11
  - The current repository belongs to the issue's project (plain refs like `123` resolve against it).
12
12
 
13
13
  ## Workflow
@@ -24,10 +24,9 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
24
24
  - `onedev_issue` `get` (title, description, fields, submitter)
25
25
  - `onedev_issue` `get_comments`
26
26
  - Match your login name against submitter/comment authors to understand your role.
27
- - Download embedded resources (screenshots, logs) with `onedev_tools` action
28
- `download`, passing the URL exactly as it appears in the markdown.
29
27
  - If a failed build is part of the issue, gather evidence with `onedev_build`
30
- (`get`, `get_log`) before planning changes. Follow `Dependency build … failed`
28
+ after activating the `build` domain (`get`, `get_log`, and a named
29
+ `get_unit_test_report` when useful) before planning changes. Follow `Dependency build … failed`
31
30
  chains: same-commit dependency failures are the real target; different-commit
32
31
  ones mean the failure is inherited.
33
32
 
@@ -48,8 +47,14 @@ description: Implement a OneDev issue end to end - gather issue context, branch,
48
47
  is inferred from the branch; state it in the description if not.
49
48
  - Post the drafted comment with `onedev_issue` `add_comment` if one was prepared.
50
49
 
50
+ 7. **For long-running CI**, activate `watch` and use `onedev_watch` `watch_build`
51
+ after obtaining a build reference. The notification is one-shot and metadata-only;
52
+ inspect the build with `onedev_build` when it fires.
53
+
51
54
  ## Notes
52
55
 
53
56
  - Write comment text in Markdown.
54
57
  - Reference entities as `#123` (current project), `project#123`, or `PROJ-123`.
55
58
  - If blocked mid-workflow, report the command and error, and stop.
59
+ - Toolkit mutations still open Pi's interactive approval UI. Do not try to bypass
60
+ it with a confirmation parameter.
@@ -7,7 +7,7 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
7
7
 
8
8
  ## Prerequisites
9
9
 
10
- - Call `onedev_tools` with domains `["pull", "issue", "build"]`; note your login name.
10
+ - Call `onedev_tools` with domains `["pull", "issue", "build"]`; note your login name. If activation reports a readiness problem, use `onedev_context` `setup` and stop until it is resolved.
11
11
  - The current repository belongs to the PR's source or target project.
12
12
 
13
13
  ## Gather context (never review from the patch alone)
@@ -27,7 +27,6 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
27
27
  for what the PR should accomplish.
28
28
  - `onedev_pull` `get_builds` — investigate every `FAILED` build with
29
29
  `onedev_build` `get` / `get_log` before reviewing code.
30
- - Download embedded resources via `onedev_tools` `download`.
31
30
  - Match your login name against authors to understand your role
32
31
  (pending reviewer, prior commenter, outsider).
33
32
 
@@ -39,7 +38,8 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
39
38
 
40
39
  2. **Triage prior discussion:** resolve addressed code comments, reply where new
41
40
  information is needed, leave unrelated unresolved ones alone. Never modify
42
- another user's code comments.
41
+ another user's code comments. Use `reply_code_comment`, `resolve_code_comment`,
42
+ or `unresolve_code_comment` with the comment `id`.
43
43
 
44
44
  3. **Draft findings:**
45
45
  - Line-anchored findings → `onedev_pull` `add_code_comment` (`file`, `from_line`,
@@ -53,13 +53,19 @@ description: Review a OneDev pull request - gather PR context, builds, and fixed
53
53
 
54
54
  5. **On explicit request only:**
55
55
  - Post findings/comments/review outcome.
56
- - Merge: `merge` requires `confirm: true`. For squash merges, compose the commit
57
- message from PR title/description + fixed issues, satisfying
58
- `commit_message_requirement` for the target project/branch.
56
+ - Merge: the toolkit asks for real interactive approval. For squash merges,
57
+ compose the commit message from PR title/description + fixed issues,
58
+ satisfying `commit_message_requirement` for the target project/branch.
59
59
  - Resolving merge conflicts: fetch the target branch, merge with the PR's
60
60
  strategy without committing, resolve, verify clean `git status`, commit.
61
61
  For rebase-based strategies, ask for direction before rewriting commits.
62
62
 
63
+ 6. **When waiting on updates**, activate `watch` and use `onedev_watch`
64
+ `watch_pull_attention`. It wakes once for a head/state/review/comment/build
65
+ signal change and includes no comment bodies; re-read the PR after it fires.
66
+
63
67
  ## Notes
64
68
 
65
69
  - Comment text is Markdown; entity refs are `#123`, `project#123`, or `PROJ-123`.
70
+ - Toolkit mutations still open Pi's interactive approval UI. Do not add or invent
71
+ a confirmation parameter.
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { lock } from "proper-lockfile";
5
+
6
+ interface StoredConfig {
7
+ allowedMutations?: string[];
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ async function readText(path: string): Promise<string | undefined> {
12
+ try {
13
+ return await readFile(path, "utf8");
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ function parseConfig(text: string | undefined, path: string): StoredConfig {
21
+ if (text === undefined) return {};
22
+ let parsed: unknown;
23
+ try {
24
+ parsed = JSON.parse(text);
25
+ } catch (error) {
26
+ throw new Error(
27
+ `invalid JSON in OneDev toolkit config ${path}: ${error instanceof Error ? error.message : String(error)}`,
28
+ );
29
+ }
30
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
31
+ throw new Error(`OneDev toolkit config is not a JSON object: ${path}`);
32
+ }
33
+ return parsed as StoredConfig;
34
+ }
35
+
36
+ function allowedMutations(config: StoredConfig): string[] {
37
+ if (!Array.isArray(config.allowedMutations)) return [];
38
+ return config.allowedMutations.filter(
39
+ (value): value is string => typeof value === "string",
40
+ );
41
+ }
42
+
43
+ export async function readAllowedMutationScopes(path: string): Promise<string[]> {
44
+ return allowedMutations(parseConfig(await readText(path), path));
45
+ }
46
+
47
+ async function removeIfPresent(path: string): Promise<void> {
48
+ try {
49
+ await rm(path);
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
52
+ }
53
+ }
54
+
55
+ export async function saveAllowedMutationScope(
56
+ path: string,
57
+ scope: string,
58
+ ): Promise<void> {
59
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
60
+ let compromised: Error | undefined;
61
+ const release = await lock(path, {
62
+ realpath: false,
63
+ stale: 10_000,
64
+ update: 5_000,
65
+ retries: {
66
+ retries: 200,
67
+ factor: 1.1,
68
+ minTimeout: 10,
69
+ maxTimeout: 100,
70
+ randomize: true,
71
+ },
72
+ onCompromised: (error) => {
73
+ compromised = error;
74
+ },
75
+ });
76
+ try {
77
+ const original = await readText(path);
78
+ const config = parseConfig(original, path);
79
+ const allowed = allowedMutations(config);
80
+ if (!allowed.includes(scope)) allowed.push(scope);
81
+ config.allowedMutations = allowed;
82
+
83
+ const temporary = resolve(
84
+ dirname(path),
85
+ `.${basename(path)}.${randomUUID()}.tmp`,
86
+ );
87
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {
88
+ encoding: "utf8",
89
+ flag: "wx",
90
+ mode: 0o600,
91
+ });
92
+ try {
93
+ if (compromised) throw compromised;
94
+ await rename(temporary, path);
95
+ } finally {
96
+ await removeIfPresent(temporary);
97
+ }
98
+ } finally {
99
+ try {
100
+ await release();
101
+ } catch {
102
+ // A compromised lock is handled before rename; cleanup failure must not kill Pi.
103
+ }
104
+ }
105
+ }
package/src/context.ts CHANGED
@@ -1,34 +1,79 @@
1
1
  import type { CommandExecutor } from "./tod.js";
2
2
  import { runTod } from "./tod.js";
3
3
 
4
+ export type OneDevContextStatus =
5
+ | "ready"
6
+ | "not_configured"
7
+ | "no_project"
8
+ | "auth_failed"
9
+ | "unavailable";
10
+
4
11
  export interface OneDevSessionContext {
5
12
  readonly cwd: string;
6
- readonly status: "ready" | "unavailable";
13
+ readonly status: OneDevContextStatus;
7
14
  readonly serverUrl?: string;
8
- /** Project path inferred from the repository's git remotes (e.g. "group/myproject"). */
15
+ /** Project path inferred from the repository's git remotes. */
9
16
  readonly project?: string;
10
17
  readonly loginName?: string;
11
- /** Human-readable setup guidance when status is "unavailable". */
12
18
  readonly problem?: string;
13
19
  }
14
20
 
21
+ export interface ContextRefreshOptions {
22
+ verifyAuth?: boolean;
23
+ force?: boolean;
24
+ signal?: AbortSignal;
25
+ }
26
+
15
27
  export const TOD_INSTALL_HINT =
16
28
  "tod is not installed. Install it (curl -fsSL https://code.onedev.io/onedev/tod/~raw/main/install.sh | bash), then run `tod config set`.";
17
29
 
18
- function unavailable(cwd: string, problem: string): OneDevSessionContext {
19
- return { cwd, status: "unavailable", problem };
30
+ function failed(
31
+ cwd: string,
32
+ status: Exclude<OneDevContextStatus, "ready">,
33
+ problem: string,
34
+ serverUrl?: string,
35
+ project?: string,
36
+ ): OneDevSessionContext {
37
+ return { cwd, status, problem, serverUrl, project };
38
+ }
39
+
40
+ async function authenticateContext(
41
+ exec: CommandExecutor,
42
+ context: OneDevSessionContext,
43
+ signal?: AbortSignal,
44
+ ): Promise<OneDevSessionContext> {
45
+ if (
46
+ context.status !== "ready" ||
47
+ !context.serverUrl ||
48
+ !context.project
49
+ ) {
50
+ return context;
51
+ }
52
+ try {
53
+ const login = await runTod(exec, ["get-login-name"], {
54
+ cwd: context.cwd,
55
+ timeoutMs: 15_000,
56
+ signal,
57
+ maxOutputBytes: 2_000,
58
+ });
59
+ return { ...context, loginName: login.text.trim() || undefined };
60
+ } catch (cause) {
61
+ return failed(
62
+ context.cwd,
63
+ "auth_failed",
64
+ `OneDev rejected the server or access token (${cause instanceof Error ? cause.message : String(cause)}). Run \`tod config set\` to fix credentials.`,
65
+ context.serverUrl,
66
+ context.project,
67
+ );
68
+ }
20
69
  }
21
70
 
22
- /**
23
- * Probe the local OneDev setup once per session:
24
- * 1. `tod config get server-url` — tod present and configured
25
- * 2. `tod get-login-name` — server reachable and access token accepted
26
- * 3. `tod project current` — repo-aware project context (optional)
27
- */
71
+ /** Probe local configuration/project first; authenticate only on explicit use. */
28
72
  export async function probeOneDevContext(
29
73
  exec: CommandExecutor,
30
74
  cwd: string,
31
75
  signal?: AbortSignal,
76
+ verifyAuth = false,
32
77
  ): Promise<OneDevSessionContext> {
33
78
  let serverUrl: string;
34
79
  try {
@@ -41,57 +86,178 @@ export async function probeOneDevContext(
41
86
  serverUrl = configured.text.trim();
42
87
  } catch (cause) {
43
88
  const message = cause instanceof Error ? cause.message : String(cause);
44
- return unavailable(
89
+ return failed(
45
90
  cwd,
91
+ "unavailable",
46
92
  message.startsWith("tod is not usable") ? TOD_INSTALL_HINT : message,
47
93
  );
48
94
  }
49
95
  if (!serverUrl) {
50
- return unavailable(
96
+ return failed(
51
97
  cwd,
52
- "tod is installed but not configured. Run `tod config set` (server-url and access-token), or set ONEDEV_SERVER_URL and ONEDEV_ACCESS_TOKEN.",
98
+ "not_configured",
99
+ "tod is installed but not configured. Run `tod config set` for server-url and access-token, or set ONEDEV_SERVER_URL and ONEDEV_ACCESS_TOKEN.",
53
100
  );
54
101
  }
55
102
 
56
- let loginName: string | undefined;
103
+ let project: string;
57
104
  try {
58
- const login = await runTod(exec, ["get-login-name"], {
105
+ const current = await runTod(exec, ["project", "current"], {
59
106
  cwd,
60
- timeoutMs: 15_000,
107
+ timeoutMs: 10_000,
61
108
  signal,
62
109
  maxOutputBytes: 2_000,
63
110
  });
64
- loginName = login.text.trim() || undefined;
111
+ project = current.text.trim();
65
112
  } catch (cause) {
66
- return unavailable(
113
+ return failed(
67
114
  cwd,
68
- `OneDev server or access token rejected (${cause instanceof Error ? cause.message : String(cause)}). Run \`tod config set\` to fix credentials.`,
115
+ "no_project",
116
+ `No OneDev project can be inferred from this repository's remotes (${cause instanceof Error ? cause.message : String(cause)}). Run Pi from a OneDev clone or add a OneDev remote.`,
117
+ serverUrl,
69
118
  );
70
119
  }
71
-
72
- let project: string | undefined;
73
- try {
74
- const current = await runTod(exec, ["project", "current"], {
120
+ if (!project) {
121
+ return failed(
75
122
  cwd,
76
- timeoutMs: 15_000,
77
- signal,
78
- maxOutputBytes: 2_000,
79
- });
80
- project = current.text.trim() || undefined;
81
- } catch {
82
- // Not a OneDev repository: fine. Tools accept explicit projects/qualified refs.
123
+ "no_project",
124
+ "No OneDev project can be inferred from this repository's remotes. Run Pi from a OneDev clone or add a OneDev remote.",
125
+ serverUrl,
126
+ );
127
+ }
128
+
129
+ if (!verifyAuth) return { cwd, status: "ready", serverUrl, project };
130
+ return authenticateContext(
131
+ exec,
132
+ { cwd, status: "ready", serverUrl, project },
133
+ signal,
134
+ );
135
+ }
136
+
137
+ function waitForCaller<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
138
+ if (!signal) return promise;
139
+ if (signal.aborted)
140
+ return Promise.reject(
141
+ signal.reason instanceof Error ? signal.reason : new Error("cancelled"),
142
+ );
143
+ return new Promise<T>((resolve, reject) => {
144
+ const abort = (): void =>
145
+ reject(
146
+ signal.reason instanceof Error ? signal.reason : new Error("cancelled"),
147
+ );
148
+ signal.addEventListener("abort", abort, { once: true });
149
+ promise.then(
150
+ (value) => {
151
+ signal.removeEventListener("abort", abort);
152
+ resolve(value);
153
+ },
154
+ (error) => {
155
+ signal.removeEventListener("abort", abort);
156
+ reject(error);
157
+ },
158
+ );
159
+ });
160
+ }
161
+
162
+ /** Coalesces probes while isolating individual caller cancellation. */
163
+ export class OneDevContextManager {
164
+ #cwd: string | undefined;
165
+ #current: OneDevSessionContext | undefined;
166
+ #pending:
167
+ | {
168
+ controller: AbortController;
169
+ promise: Promise<OneDevSessionContext>;
170
+ }
171
+ | undefined;
172
+
173
+ constructor(readonly exec: CommandExecutor) {}
174
+
175
+ current(): OneDevSessionContext {
176
+ if (!this.#current) throw new Error("OneDev extension is still initializing");
177
+ return this.#current;
178
+ }
179
+
180
+ reset(cwd: string): void {
181
+ this.#pending?.controller.abort(new Error("OneDev session context reset"));
182
+ this.#pending = undefined;
183
+ this.#current = undefined;
184
+ this.#cwd = cwd;
83
185
  }
84
186
 
85
- return { cwd, status: "ready", serverUrl, loginName, project };
187
+ clear(): void {
188
+ this.#pending?.controller.abort(new Error("OneDev session ended"));
189
+ this.#pending = undefined;
190
+ this.#current = undefined;
191
+ this.#cwd = undefined;
192
+ }
193
+
194
+ async refresh(
195
+ options: ContextRefreshOptions = {},
196
+ ): Promise<OneDevSessionContext> {
197
+ if (!this.#cwd) throw new Error("OneDev extension is still initializing");
198
+ const verifyAuth = options.verifyAuth === true;
199
+ if (
200
+ !options.force &&
201
+ this.#current &&
202
+ (!verifyAuth || this.#current.loginName)
203
+ ) {
204
+ return this.#current;
205
+ }
206
+
207
+ if (this.#pending) {
208
+ const context = await waitForCaller(this.#pending.promise, options.signal);
209
+ if (!verifyAuth || context.loginName || context.status !== "ready") {
210
+ return context;
211
+ }
212
+ }
213
+
214
+ const controller = new AbortController();
215
+ const cached = this.#current;
216
+ const canAuthenticateCached =
217
+ verifyAuth &&
218
+ !options.force &&
219
+ cached?.status === "ready" &&
220
+ !cached.loginName;
221
+ const promise = (canAuthenticateCached
222
+ ? authenticateContext(this.exec, cached, controller.signal)
223
+ : probeOneDevContext(
224
+ this.exec,
225
+ this.#cwd,
226
+ controller.signal,
227
+ verifyAuth,
228
+ )).then((context) => {
229
+ if (!controller.signal.aborted) this.#current = context;
230
+ return context;
231
+ });
232
+ const pending = { controller, promise };
233
+ this.#pending = pending;
234
+ const clearPending = (): void => {
235
+ if (this.#pending === pending) this.#pending = undefined;
236
+ };
237
+ void promise.then(clearPending, clearPending);
238
+ return waitForCaller(promise, options.signal);
239
+ }
86
240
  }
87
241
 
88
242
  export function formatContext(context: OneDevSessionContext): string {
89
- if (context.status === "unavailable") {
90
- return `OneDev unavailable: ${context.problem ?? "unknown problem"}`;
243
+ if (context.status !== "ready") {
244
+ return `OneDev ${context.status.replace("_", " ")}: ${context.problem ?? "unknown problem"}`;
91
245
  }
92
- const parts = [`server ${context.serverUrl}`];
93
- if (context.project) parts.push(`project ${context.project}`);
94
- else parts.push("no OneDev project inferred from git remotes");
95
- if (context.loginName) parts.push(`user ${context.loginName}`);
246
+ const parts = [`server ${context.serverUrl}`, `project ${context.project}`];
247
+ parts.push(context.loginName ? `user ${context.loginName}` : "auth not checked yet");
96
248
  return `OneDev ready: ${parts.join(" · ")}`;
97
249
  }
250
+
251
+ export function setupGuidance(context: OneDevSessionContext): string {
252
+ switch (context.status) {
253
+ case "ready":
254
+ return `${formatContext(context)}\nNo setup action is required.`;
255
+ case "unavailable":
256
+ case "not_configured":
257
+ case "no_project":
258
+ case "auth_failed":
259
+ return `${formatContext(context)}\n${context.problem ?? TOD_INSTALL_HINT}`;
260
+ default:
261
+ throw new Error(`unsupported OneDev context status: ${String(context.status)}`);
262
+ }
263
+ }