@love-moon/conductor-sdk 0.2.42 → 0.3.1
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 +52 -0
- package/dist/api/index.d.ts +4 -0
- package/dist/api/index.js +4 -0
- package/dist/api/issues.d.ts +57 -0
- package/dist/api/issues.js +177 -0
- package/dist/api/projects.d.ts +71 -0
- package/dist/api/projects.js +247 -0
- package/dist/api/shared.d.ts +45 -0
- package/dist/api/shared.js +78 -0
- package/dist/api/tasks.d.ts +46 -0
- package/dist/api/tasks.js +118 -0
- package/dist/backend/client.d.ts +36 -0
- package/dist/backend/client.js +101 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +18 -0
- package/dist/context/project_context.d.ts +26 -0
- package/dist/context/project_context.js +64 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ws/client.js +23 -0
- package/package.json +10 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @love-moon/conductor-sdk
|
|
2
|
+
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4e8d4e5: Include `CHANGELOG.md` in published npm tarballs.
|
|
8
|
+
|
|
9
|
+
The `files` array in each package's `package.json` previously only
|
|
10
|
+
listed the build output (`bin`/`src` for the CLI, `dist` for the
|
|
11
|
+
modules). npm's `files` whitelist replaces the default include set,
|
|
12
|
+
and CHANGELOG is not one of the auto-included files (only
|
|
13
|
+
`package.json`, `README*`, `LICENSE*`, and `main` are unconditional).
|
|
14
|
+
|
|
15
|
+
As a result, every release through 0.3.0 published tarballs with no
|
|
16
|
+
CHANGELOG, so a consumer running `npm install` or unpacking the brew
|
|
17
|
+
artifact had no way to see what changed in the version they just
|
|
18
|
+
installed. The repo `cli/CHANGELOG.md` and the GitHub Release body
|
|
19
|
+
remain the canonical source until 0.3.1 ships with this fix.
|
|
20
|
+
|
|
21
|
+
## 0.3.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- be3b3cb: Project list now merges same-name git projects that share a remote URL across daemons into a single card.
|
|
26
|
+
|
|
27
|
+
- `ProjectContext.snapshot()` (SDK) now captures the origin remote URL via `git config --get remote.origin.url` and exposes a `normalizeGitRemoteUrl` helper for callers that need their own equality comparison.
|
|
28
|
+
- The daemon's `validate_project_path` response carries a new `git_remote_url` field. Old daemons that don't send it stay forward-compatible — the web server treats missing values as "unmergeable" so projects from those daemons render standalone until they're refreshed.
|
|
29
|
+
- New web API surface:
|
|
30
|
+
- `PATCH /api/projects { mergeOptOut: true }` opts a project out of auto-grouping (manual split for accidental name collisions).
|
|
31
|
+
- `PATCH /api/projects { refresh: true }` re-runs the daemon validation handshake and back-fills `git_remote_url` for projects created before this feature.
|
|
32
|
+
- `GET /api/issues?project_ids=a,b` fetches issues from multiple projects in one call; responses include `daemonHost` + `projectName` for cross-daemon attribution.
|
|
33
|
+
- Schema: two new optional columns on `projects`: `git_remote_url` (nullable string) and `merge_opt_out` (boolean, default false). Run `pnpm -C web db:push` before upgrading.
|
|
34
|
+
|
|
35
|
+
### Other Changes (retroactively documented)
|
|
36
|
+
|
|
37
|
+
The following changes shipped in `@love-moon/conductor-sdk@0.3.0` but
|
|
38
|
+
were merged without a `changeset` entry. See
|
|
39
|
+
`claw/lessons/arch_release-packages-pnpm-changesets-20260512.md` for
|
|
40
|
+
why this happened and the rule that every PR touching `modules/**`
|
|
41
|
+
must run `npm run changeset`.
|
|
42
|
+
|
|
43
|
+
- SDK helpers and types behind the new CLI `conductor project|issue|task`
|
|
44
|
+
entity commands (RFC 0025). Programs that already depended on SDK
|
|
45
|
+
project/issue/task surfaces gain a few stable convenience entry
|
|
46
|
+
points; callers using only the public client API see no change.
|
|
47
|
+
(`2e10756`)
|
|
48
|
+
- Reconnect: the SDK client survives the daemon-side websocket
|
|
49
|
+
late-send-after-disconnect crash path (companion fix to the daemon
|
|
50
|
+
change in `@love-moon/conductor-cli@0.3.0`). (`a3532cc`)
|
|
51
|
+
- Reconnect: stale `taskAttach` calls from a previously-bound fire
|
|
52
|
+
process no longer establish ghost bindings. (`dc73be9`)
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ProjectsApi, type Project, type CreateProjectInput, type ListProjectsOptions, type ResolveProjectInput, } from './projects.js';
|
|
2
|
+
export { IssuesApi, type Issue, type ListIssuesInput, type CreateIssueInput, type UpdateIssueInput, type UpdateIssueStatusOptions, } from './issues.js';
|
|
3
|
+
export { TasksApi, type Task, type Message, type ListTasksInput, type SendTaskMessageOptions, type ListTaskMessagesOptions, } from './tasks.js';
|
|
4
|
+
export { ProjectNotResolvedError, buildAuditMetadata, isBackendApiError, type ApiClient, type SdkClientOptions, } from './shared.js';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ApiClient, SdkClientOptions } from './shared.js';
|
|
2
|
+
export interface Issue {
|
|
3
|
+
id: string;
|
|
4
|
+
projectId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string | null;
|
|
7
|
+
status: string;
|
|
8
|
+
priority?: string | null;
|
|
9
|
+
position?: number | null;
|
|
10
|
+
metadata?: Record<string, unknown> | null;
|
|
11
|
+
createdAt?: string | null;
|
|
12
|
+
updatedAt?: string | null;
|
|
13
|
+
/** Unmodified server payload, used by callers that need fields we haven't typed. */
|
|
14
|
+
raw: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface ListIssuesInput {
|
|
17
|
+
projectId: string;
|
|
18
|
+
status?: string | string[];
|
|
19
|
+
limit?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface CreateIssueInput {
|
|
22
|
+
projectId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
priority?: string;
|
|
26
|
+
status?: string;
|
|
27
|
+
metadata?: Record<string, unknown>;
|
|
28
|
+
clientRequestId?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface UpdateIssueInput {
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
priority?: string;
|
|
34
|
+
status?: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
export interface UpdateIssueStatusOptions {
|
|
38
|
+
evidence?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Extra metadata merged into the patch; caller's `audit.*` fields win over
|
|
41
|
+
* SDK defaults (see shared.ts). Useful for the CLI to thread its
|
|
42
|
+
* `audit: { actor: "cli", ... }` namespace through without needing a custom
|
|
43
|
+
* call path.
|
|
44
|
+
*/
|
|
45
|
+
metadata?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
export declare class IssuesApi {
|
|
48
|
+
private readonly client;
|
|
49
|
+
private readonly options;
|
|
50
|
+
constructor(client: ApiClient, options?: SdkClientOptions);
|
|
51
|
+
listIssues(input: ListIssuesInput): Promise<Issue[]>;
|
|
52
|
+
getIssue(issueId: string): Promise<Issue>;
|
|
53
|
+
createIssue(input: CreateIssueInput): Promise<Issue>;
|
|
54
|
+
updateIssue(issueId: string, patch: UpdateIssueInput): Promise<Issue>;
|
|
55
|
+
updateIssueStatus(issueId: string, status: string, options?: UpdateIssueStatusOptions): Promise<Issue>;
|
|
56
|
+
deleteIssue(issueId: string): Promise<void>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { buildAuditMetadata } from './shared.js';
|
|
2
|
+
const normalizeIssue = (payload) => {
|
|
3
|
+
const id = payload.id ? String(payload.id) : '';
|
|
4
|
+
if (!id) {
|
|
5
|
+
throw new Error('Issue payload missing id');
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
projectId: String(payload.projectId ?? payload.project_id ?? ''),
|
|
10
|
+
title: String(payload.title ?? ''),
|
|
11
|
+
description: payload.description ?? null,
|
|
12
|
+
status: String(payload.status ?? ''),
|
|
13
|
+
priority: payload.priority ?? null,
|
|
14
|
+
position: typeof payload.position === 'number' ? payload.position : null,
|
|
15
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
16
|
+
? payload.metadata
|
|
17
|
+
: null,
|
|
18
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
19
|
+
updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
|
|
20
|
+
raw: payload,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const extractIssue = (payload) => {
|
|
24
|
+
// The PATCH /issues/[issueId] route returns `{ issue, activeTask, ... }`.
|
|
25
|
+
// Other routes return the issue itself. Normalize both shapes here so
|
|
26
|
+
// facade callers always get the issue back regardless of source endpoint.
|
|
27
|
+
if (payload && typeof payload === 'object' && payload.issue && typeof payload.issue === 'object') {
|
|
28
|
+
return payload.issue;
|
|
29
|
+
}
|
|
30
|
+
return payload;
|
|
31
|
+
};
|
|
32
|
+
const mergeMetadataAtPath = (target, pathParts, value) => {
|
|
33
|
+
if (pathParts.length === 0) {
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
const [head, ...rest] = pathParts;
|
|
37
|
+
const next = { ...target };
|
|
38
|
+
if (rest.length === 0) {
|
|
39
|
+
next[head] = value;
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
const existing = target[head];
|
|
43
|
+
const child = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
44
|
+
? existing
|
|
45
|
+
: {};
|
|
46
|
+
next[head] = mergeMetadataAtPath(child, rest, value);
|
|
47
|
+
return next;
|
|
48
|
+
};
|
|
49
|
+
export class IssuesApi {
|
|
50
|
+
client;
|
|
51
|
+
options;
|
|
52
|
+
constructor(client, options = {}) {
|
|
53
|
+
this.client = client;
|
|
54
|
+
this.options = options;
|
|
55
|
+
}
|
|
56
|
+
async listIssues(input) {
|
|
57
|
+
if (!input.projectId) {
|
|
58
|
+
throw new Error('projectId is required');
|
|
59
|
+
}
|
|
60
|
+
// The server `/api/issues` route accepts a single `status` query string;
|
|
61
|
+
// it doesn't (yet) parse comma-separated lists, so we filter on the
|
|
62
|
+
// client side for the multi-status case to match RFC 0025 §3 wording.
|
|
63
|
+
const statusFilter = Array.isArray(input.status)
|
|
64
|
+
? input.status.map((value) => String(value).trim()).filter(Boolean)
|
|
65
|
+
: input.status
|
|
66
|
+
? [String(input.status).trim()].filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
const issues = await this.client.listIssues({
|
|
69
|
+
projectId: input.projectId,
|
|
70
|
+
status: statusFilter.length === 1 ? statusFilter[0] : undefined,
|
|
71
|
+
});
|
|
72
|
+
let normalized = issues.map((entry) => normalizeIssue(entry));
|
|
73
|
+
if (statusFilter.length > 1) {
|
|
74
|
+
const statusSet = new Set(statusFilter);
|
|
75
|
+
normalized = normalized.filter((issue) => statusSet.has(issue.status));
|
|
76
|
+
}
|
|
77
|
+
if (typeof input.limit === 'number' && Number.isFinite(input.limit)) {
|
|
78
|
+
normalized = normalized.slice(0, Math.max(0, Math.floor(input.limit)));
|
|
79
|
+
}
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
async getIssue(issueId) {
|
|
83
|
+
const trimmed = String(issueId ?? '').trim();
|
|
84
|
+
if (!trimmed) {
|
|
85
|
+
throw new Error('issueId is required');
|
|
86
|
+
}
|
|
87
|
+
const payload = await this.client.getIssue(trimmed);
|
|
88
|
+
return normalizeIssue(extractIssue(payload));
|
|
89
|
+
}
|
|
90
|
+
async createIssue(input) {
|
|
91
|
+
if (!input.projectId) {
|
|
92
|
+
throw new Error('projectId is required');
|
|
93
|
+
}
|
|
94
|
+
if (!input.title) {
|
|
95
|
+
throw new Error('title is required');
|
|
96
|
+
}
|
|
97
|
+
const userMetadata = input.clientRequestId
|
|
98
|
+
? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
|
|
99
|
+
: input.metadata;
|
|
100
|
+
const metadata = buildAuditMetadata(userMetadata, this.options);
|
|
101
|
+
const params = {
|
|
102
|
+
projectId: input.projectId,
|
|
103
|
+
title: input.title,
|
|
104
|
+
metadata,
|
|
105
|
+
};
|
|
106
|
+
if (input.description !== undefined) {
|
|
107
|
+
params.description = input.description;
|
|
108
|
+
}
|
|
109
|
+
if (input.priority !== undefined) {
|
|
110
|
+
params.priority = input.priority;
|
|
111
|
+
}
|
|
112
|
+
if (input.status !== undefined) {
|
|
113
|
+
params.status = input.status;
|
|
114
|
+
}
|
|
115
|
+
const payload = await this.client.createIssue(params);
|
|
116
|
+
return normalizeIssue(extractIssue(payload));
|
|
117
|
+
}
|
|
118
|
+
async updateIssue(issueId, patch) {
|
|
119
|
+
const trimmed = String(issueId ?? '').trim();
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
throw new Error('issueId is required');
|
|
122
|
+
}
|
|
123
|
+
const params = {};
|
|
124
|
+
if (patch.title !== undefined) {
|
|
125
|
+
params.title = patch.title;
|
|
126
|
+
}
|
|
127
|
+
if (patch.description !== undefined) {
|
|
128
|
+
params.description = patch.description;
|
|
129
|
+
}
|
|
130
|
+
if (patch.priority !== undefined) {
|
|
131
|
+
params.priority = patch.priority;
|
|
132
|
+
}
|
|
133
|
+
if (patch.status !== undefined) {
|
|
134
|
+
params.status = patch.status;
|
|
135
|
+
}
|
|
136
|
+
// We always include audit metadata on writes. If the caller passes a
|
|
137
|
+
// `metadata` patch, we merge our audit keys with theirs (caller wins).
|
|
138
|
+
params.metadata = buildAuditMetadata(patch.metadata, this.options);
|
|
139
|
+
const payload = await this.client.patchIssue(trimmed, params);
|
|
140
|
+
return normalizeIssue(extractIssue(payload));
|
|
141
|
+
}
|
|
142
|
+
async updateIssueStatus(issueId, status, options = {}) {
|
|
143
|
+
const callerMetadata = options.metadata && typeof options.metadata === 'object' && !Array.isArray(options.metadata)
|
|
144
|
+
? { ...options.metadata }
|
|
145
|
+
: null;
|
|
146
|
+
if (!options.evidence) {
|
|
147
|
+
return this.updateIssue(issueId, {
|
|
148
|
+
status,
|
|
149
|
+
...(callerMetadata ? { metadata: callerMetadata } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// Pull the existing metadata so we can merge `qa.evidence` without
|
|
153
|
+
// clobbering unrelated keys. The PATCH body fully replaces metadata, so
|
|
154
|
+
// we have to round-trip the current state (matches RFC 0025 §3 wording
|
|
155
|
+
// for `done --evidence`).
|
|
156
|
+
const existing = await this.getIssue(issueId);
|
|
157
|
+
const existingMetadata = existing.metadata && typeof existing.metadata === 'object'
|
|
158
|
+
? existing.metadata
|
|
159
|
+
: {};
|
|
160
|
+
// Merge order: existing DB metadata < caller's patch metadata < qa.evidence.
|
|
161
|
+
// The `audit` key under `callerMetadata` is preserved when present so the
|
|
162
|
+
// CLI's `actor:"cli"` namespace flows through to `updateIssue` (and from
|
|
163
|
+
// there to `buildAuditMetadata`, where caller wins).
|
|
164
|
+
const baseMetadata = callerMetadata
|
|
165
|
+
? { ...existingMetadata, ...callerMetadata }
|
|
166
|
+
: { ...existingMetadata };
|
|
167
|
+
const merged = mergeMetadataAtPath(baseMetadata, ['qa', 'evidence'], options.evidence);
|
|
168
|
+
return this.updateIssue(issueId, { status, metadata: merged });
|
|
169
|
+
}
|
|
170
|
+
async deleteIssue(issueId) {
|
|
171
|
+
const trimmed = String(issueId ?? '').trim();
|
|
172
|
+
if (!trimmed) {
|
|
173
|
+
throw new Error('issueId is required');
|
|
174
|
+
}
|
|
175
|
+
await this.client.deleteIssue(trimmed);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ApiClient, SdkClientOptions } from './shared.js';
|
|
2
|
+
/**
|
|
3
|
+
* Project record as returned by the REST API. We deliberately don't subclass
|
|
4
|
+
* `ProjectSummary` here because the REST surface returns more fields (notably
|
|
5
|
+
* `hidden`, `isDefault`, `metadata`) than the WS-flavored summary, and we
|
|
6
|
+
* want CLI / AI consumers to see the full picture.
|
|
7
|
+
*/
|
|
8
|
+
export interface Project {
|
|
9
|
+
id: string;
|
|
10
|
+
name?: string | null;
|
|
11
|
+
daemonHost?: string | null;
|
|
12
|
+
workspacePath?: string | null;
|
|
13
|
+
repoRoot?: string | null;
|
|
14
|
+
worktreeBranch?: string | null;
|
|
15
|
+
lastCommit?: string | null;
|
|
16
|
+
fileCount?: number | null;
|
|
17
|
+
isDefault: boolean;
|
|
18
|
+
hidden: boolean;
|
|
19
|
+
hiddenAt?: string | null;
|
|
20
|
+
metadata?: Record<string, unknown> | null;
|
|
21
|
+
createdAt?: string | null;
|
|
22
|
+
updatedAt?: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* The unmodified server payload, kept for callers that need fields the
|
|
25
|
+
* typed surface hasn't promoted yet (e.g. `gitRemoteUrl`, `mergeOptOut`).
|
|
26
|
+
*/
|
|
27
|
+
raw: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolveProjectInput {
|
|
30
|
+
id?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
/**
|
|
35
|
+
* Daemon host to use when resolving by `cwd`. Required for the path-match
|
|
36
|
+
* fallback (the server keys workspace bindings on `(daemonHost, path)`).
|
|
37
|
+
* Defaults to `env.CONDUCTOR_DAEMON_NAME` if unset.
|
|
38
|
+
*/
|
|
39
|
+
daemonHost?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface CreateProjectInput {
|
|
42
|
+
name?: string;
|
|
43
|
+
workspacePath?: string;
|
|
44
|
+
daemonHost?: string;
|
|
45
|
+
isDefault?: boolean;
|
|
46
|
+
clientRequestId?: string;
|
|
47
|
+
metadata?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
export interface ListProjectsOptions {
|
|
50
|
+
includeHidden?: boolean;
|
|
51
|
+
}
|
|
52
|
+
export interface ProjectWriteOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Extra metadata merged into the request (caller-supplied audit fields
|
|
55
|
+
* under `metadata.audit` win over the SDK defaults, see shared.ts).
|
|
56
|
+
*/
|
|
57
|
+
metadata?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
export declare class ProjectsApi {
|
|
60
|
+
private readonly client;
|
|
61
|
+
private readonly options;
|
|
62
|
+
constructor(client: ApiClient, options?: SdkClientOptions);
|
|
63
|
+
listProjects(options?: ListProjectsOptions): Promise<Project[]>;
|
|
64
|
+
getProject(idOrName: string): Promise<Project>;
|
|
65
|
+
createProject(input: CreateProjectInput): Promise<Project>;
|
|
66
|
+
setDefaultProject(idOrName: string, options?: ProjectWriteOptions): Promise<Project>;
|
|
67
|
+
setProjectHidden(idOrName: string, hidden: boolean, options?: ProjectWriteOptions): Promise<Project>;
|
|
68
|
+
resolveProject(input?: ResolveProjectInput): Promise<Project>;
|
|
69
|
+
private findUniqueByName;
|
|
70
|
+
private resolveById;
|
|
71
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { BackendApiError } from '../backend/index.js';
|
|
2
|
+
import { ProjectNotResolvedError, buildAuditMetadata, } from './shared.js';
|
|
3
|
+
const stripTrailingSlash = (value) => value.replace(/\/+$/, '');
|
|
4
|
+
/**
|
|
5
|
+
* Heuristic for "this string looks like a project id, not a name".
|
|
6
|
+
*
|
|
7
|
+
* The server uses cuid-like ids (e.g. `clxk1y0fz0000abc123`) and UUIDs. Names
|
|
8
|
+
* are arbitrary strings, but in practice users name their projects with
|
|
9
|
+
* spaces, slashes, accents, etc. — i.e. they almost never match the
|
|
10
|
+
* `^[a-zA-Z0-9_-]{12,}$` shape we treat as an id below. This lets the SDK
|
|
11
|
+
* skip a redundant `getProject(id)` round-trip when the caller (CLI) has
|
|
12
|
+
* already resolved to an id (review L-NEW-1). On a miss we fall back to the
|
|
13
|
+
* full resolve, so the only downside of a false positive is one extra
|
|
14
|
+
* 404 → re-list round trip.
|
|
15
|
+
*/
|
|
16
|
+
const looksLikeProjectId = (value) => {
|
|
17
|
+
if (!value || typeof value !== 'string')
|
|
18
|
+
return false;
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
if (trimmed.length < 12)
|
|
21
|
+
return false;
|
|
22
|
+
return /^[A-Za-z0-9_-]+$/.test(trimmed);
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Local longest-prefix match used by `resolveProject` step 4 when no daemon
|
|
26
|
+
* host is available to delegate to the server-side matcher. We treat each
|
|
27
|
+
* project's `workspacePath` as a prefix and pick the longest one that's a
|
|
28
|
+
* proper directory ancestor of `cwd`. Mirrors the server's
|
|
29
|
+
* match-path/route.ts logic for the simple single-daemon case.
|
|
30
|
+
*/
|
|
31
|
+
const matchProjectByLocalPrefix = (projects, cwd) => {
|
|
32
|
+
const target = stripTrailingSlash(cwd.trim());
|
|
33
|
+
if (!target)
|
|
34
|
+
return null;
|
|
35
|
+
let best = null;
|
|
36
|
+
for (const project of projects) {
|
|
37
|
+
const path = project.workspacePath;
|
|
38
|
+
if (typeof path !== 'string' || !path.trim())
|
|
39
|
+
continue;
|
|
40
|
+
const normalized = stripTrailingSlash(path.trim());
|
|
41
|
+
if (target === normalized || target.startsWith(`${normalized}/`)) {
|
|
42
|
+
if (!best || normalized.length > best.length) {
|
|
43
|
+
best = { project, length: normalized.length };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best?.project ?? null;
|
|
48
|
+
};
|
|
49
|
+
const normalizeProject = (payload) => {
|
|
50
|
+
const id = payload.id ? String(payload.id) : '';
|
|
51
|
+
if (!id) {
|
|
52
|
+
throw new Error('Project payload missing id');
|
|
53
|
+
}
|
|
54
|
+
const hiddenAt = payload.hiddenAt ?? payload.hidden_at ?? null;
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
name: payload.name ?? null,
|
|
58
|
+
daemonHost: payload.daemonHost ?? payload.daemon_host ?? null,
|
|
59
|
+
workspacePath: payload.workspacePath ?? payload.workspace_path ?? null,
|
|
60
|
+
repoRoot: payload.repoRoot ?? payload.repo_root ?? null,
|
|
61
|
+
worktreeBranch: payload.worktreeBranch ?? payload.worktree_branch ?? null,
|
|
62
|
+
lastCommit: payload.lastCommit ?? payload.last_commit ?? null,
|
|
63
|
+
fileCount: payload.fileCount ?? payload.file_count ?? null,
|
|
64
|
+
isDefault: Boolean(payload.isDefault ?? payload.is_default ?? false),
|
|
65
|
+
hidden: Boolean(payload.hidden ?? Boolean(hiddenAt)),
|
|
66
|
+
hiddenAt: hiddenAt ?? null,
|
|
67
|
+
metadata: payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
|
|
68
|
+
? payload.metadata
|
|
69
|
+
: null,
|
|
70
|
+
createdAt: payload.createdAt ?? payload.created_at ?? null,
|
|
71
|
+
updatedAt: payload.updatedAt ?? payload.updated_at ?? null,
|
|
72
|
+
raw: payload,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export class ProjectsApi {
|
|
76
|
+
client;
|
|
77
|
+
options;
|
|
78
|
+
constructor(client, options = {}) {
|
|
79
|
+
this.client = client;
|
|
80
|
+
this.options = options;
|
|
81
|
+
}
|
|
82
|
+
async listProjects(options = {}) {
|
|
83
|
+
const summaries = await this.client.listProjects();
|
|
84
|
+
// `BackendApiClient.listProjects` already pulls /projects, but it loses
|
|
85
|
+
// the `hidden` and `isDefault` fields when collapsing to ProjectSummary.
|
|
86
|
+
// Promote each summary's `asObject()` payload back to a Project so we
|
|
87
|
+
// surface those fields. Hidden projects are filtered out here unless
|
|
88
|
+
// `includeHidden: true` is passed (RFC 0025 §3).
|
|
89
|
+
const promoted = summaries.map((summary) => normalizeProject(
|
|
90
|
+
// ProjectSummary `asObject()` always exists in the backend module.
|
|
91
|
+
summary.asObject ? summary.asObject() : summary));
|
|
92
|
+
if (options.includeHidden) {
|
|
93
|
+
return promoted;
|
|
94
|
+
}
|
|
95
|
+
return promoted.filter((project) => !project.hidden);
|
|
96
|
+
}
|
|
97
|
+
async getProject(idOrName) {
|
|
98
|
+
const trimmed = String(idOrName ?? '').trim();
|
|
99
|
+
if (!trimmed) {
|
|
100
|
+
throw new Error('idOrName is required');
|
|
101
|
+
}
|
|
102
|
+
// First try by id; if 404, fall back to a unique name match against the
|
|
103
|
+
// project list. Splitting these two paths keeps the happy-path single
|
|
104
|
+
// round-trip and only pulls the full list on miss.
|
|
105
|
+
try {
|
|
106
|
+
const payload = await this.client.getProject(trimmed);
|
|
107
|
+
return normalizeProject(payload);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!(error instanceof BackendApiError) || error.statusCode !== 404) {
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return this.findUniqueByName(trimmed);
|
|
115
|
+
}
|
|
116
|
+
async createProject(input) {
|
|
117
|
+
const metadata = buildAuditMetadata(
|
|
118
|
+
// `clientRequestId` is stamped onto metadata per RFC 0025 §5 idempotency
|
|
119
|
+
// spec; the server treats it as the idempotency key.
|
|
120
|
+
input.clientRequestId
|
|
121
|
+
? { clientRequestId: input.clientRequestId, ...(input.metadata ?? {}) }
|
|
122
|
+
: input.metadata, this.options);
|
|
123
|
+
const params = {};
|
|
124
|
+
if (input.name) {
|
|
125
|
+
params.name = input.name;
|
|
126
|
+
}
|
|
127
|
+
if (input.isDefault) {
|
|
128
|
+
params.isDefault = true;
|
|
129
|
+
params.is_default = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Non-default projects need a binding. The server validates with the
|
|
133
|
+
// daemon synchronously when `daemonHost + workspacePath` are present,
|
|
134
|
+
// which is the path AI / CLI will use most of the time.
|
|
135
|
+
if (input.workspacePath) {
|
|
136
|
+
params.workspacePath = input.workspacePath;
|
|
137
|
+
}
|
|
138
|
+
if (input.daemonHost) {
|
|
139
|
+
params.daemonHost = input.daemonHost;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
params.metadata = metadata;
|
|
143
|
+
const summary = await this.client.createProject(params);
|
|
144
|
+
return normalizeProject(summary.asObject ? summary.asObject() : summary);
|
|
145
|
+
}
|
|
146
|
+
async setDefaultProject(idOrName, options = {}) {
|
|
147
|
+
// Short-circuit the pre-flight `getProject` when the caller already has
|
|
148
|
+
// a project id (review L-NEW-1). On a miss the server returns 404 and
|
|
149
|
+
// the caller can retry via `getProject(idOrName)` themselves, but the
|
|
150
|
+
// common CLI path passes the id directly.
|
|
151
|
+
const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
|
|
152
|
+
const metadata = buildAuditMetadata(options.metadata, this.options);
|
|
153
|
+
const payload = await this.client.setDefaultProject(targetId, { metadata });
|
|
154
|
+
return normalizeProject(payload);
|
|
155
|
+
}
|
|
156
|
+
async setProjectHidden(idOrName, hidden, options = {}) {
|
|
157
|
+
const targetId = looksLikeProjectId(idOrName) ? idOrName.trim() : (await this.resolveById(idOrName)).id;
|
|
158
|
+
const metadata = buildAuditMetadata(options.metadata, this.options);
|
|
159
|
+
const payload = await this.client.patchProjectByQuery(targetId, { hidden, metadata });
|
|
160
|
+
return normalizeProject(payload);
|
|
161
|
+
}
|
|
162
|
+
async resolveProject(input = {}) {
|
|
163
|
+
const env = input.env ?? this.options.env ?? process.env;
|
|
164
|
+
// 1. explicit id wins
|
|
165
|
+
if (input.id && input.id.trim()) {
|
|
166
|
+
try {
|
|
167
|
+
const payload = await this.client.getProject(input.id.trim());
|
|
168
|
+
return normalizeProject(payload);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof BackendApiError && error.statusCode === 404) {
|
|
172
|
+
throw new ProjectNotResolvedError(`Project not found: id=${input.id}`, 'not_found');
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// 2. explicit name
|
|
178
|
+
if (input.name && input.name.trim()) {
|
|
179
|
+
return this.findUniqueByName(input.name.trim());
|
|
180
|
+
}
|
|
181
|
+
// 3. env override
|
|
182
|
+
const envId = env.CONDUCTOR_PROJECT_ID;
|
|
183
|
+
if (typeof envId === 'string' && envId.trim()) {
|
|
184
|
+
try {
|
|
185
|
+
const payload = await this.client.getProject(envId.trim());
|
|
186
|
+
return normalizeProject(payload);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error instanceof BackendApiError && error.statusCode === 404) {
|
|
190
|
+
throw new ProjectNotResolvedError(`Project not found from CONDUCTOR_PROJECT_ID=${envId}`, 'not_found');
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 4. cwd path match. We try two routes:
|
|
196
|
+
// a. If a daemon host is known, delegate to the server-side matcher
|
|
197
|
+
// (it understands binding-pending / repoRoot / multi-daemon rules).
|
|
198
|
+
// b. Otherwise, do a local longest-prefix match against the project
|
|
199
|
+
// list. This keeps the CLI usable when the user hasn't set
|
|
200
|
+
// CONDUCTOR_DAEMON_NAME — review finding M1: never silently skip
|
|
201
|
+
// cwd resolution just because a daemon hint is missing.
|
|
202
|
+
const projects = await this.listProjects({ includeHidden: true });
|
|
203
|
+
if (input.cwd && input.cwd.trim()) {
|
|
204
|
+
const daemonHost = input.daemonHost ?? env.CONDUCTOR_DAEMON_NAME ?? env.CONDUCTOR_AGENT_NAME;
|
|
205
|
+
if (daemonHost && daemonHost.trim()) {
|
|
206
|
+
const result = await this.client.matchProjectByPath({
|
|
207
|
+
daemon_host: daemonHost.trim(),
|
|
208
|
+
path: input.cwd,
|
|
209
|
+
});
|
|
210
|
+
if (result.project) {
|
|
211
|
+
return normalizeProject(result.project.asObject());
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const localMatch = matchProjectByLocalPrefix(projects, input.cwd);
|
|
216
|
+
if (localMatch) {
|
|
217
|
+
return localMatch;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// 5. default project — derived from the list we already fetched in (4).
|
|
222
|
+
// Every healthy account has at most one default; we return it when
|
|
223
|
+
// found.
|
|
224
|
+
const defaults = projects.filter((project) => project.isDefault);
|
|
225
|
+
if (defaults.length === 1) {
|
|
226
|
+
return defaults[0];
|
|
227
|
+
}
|
|
228
|
+
if (defaults.length > 1) {
|
|
229
|
+
throw new ProjectNotResolvedError('Multiple default projects exist; specify --project explicitly', 'multiple_matches');
|
|
230
|
+
}
|
|
231
|
+
throw new ProjectNotResolvedError('No project specified and no default project found. Pass --project <id|name>, set CONDUCTOR_PROJECT_ID, cd into a project directory, or configure a default project.', 'no_signal');
|
|
232
|
+
}
|
|
233
|
+
async findUniqueByName(name) {
|
|
234
|
+
const projects = await this.listProjects({ includeHidden: true });
|
|
235
|
+
const matches = projects.filter((project) => project.name === name);
|
|
236
|
+
if (matches.length === 1) {
|
|
237
|
+
return matches[0];
|
|
238
|
+
}
|
|
239
|
+
if (matches.length === 0) {
|
|
240
|
+
throw new ProjectNotResolvedError(`No project found with name "${name}"`, 'not_found');
|
|
241
|
+
}
|
|
242
|
+
throw new ProjectNotResolvedError(`Multiple projects found with name "${name}" (${matches.length}); pass --project <id> to disambiguate`, 'multiple_matches');
|
|
243
|
+
}
|
|
244
|
+
async resolveById(idOrName) {
|
|
245
|
+
return this.getProject(idOrName);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BackendApiClient, BackendApiError } from '../backend/index.js';
|
|
2
|
+
export type ApiClient = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'getProject' | 'updateProject' | 'patchProjectByQuery' | 'setDefaultProject' | 'matchProjectByPath' | 'listIssues' | 'getIssue' | 'createIssue' | 'patchIssue' | 'deleteIssue' | 'listTasks' | 'getTask' | 'listTaskMessages' | 'postTaskMessage'>;
|
|
3
|
+
export interface SdkClientOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Optional override for the SDK version stamped onto audit metadata. Falls
|
|
6
|
+
* back to the package.json version. Tests may want to pin this so output is
|
|
7
|
+
* stable.
|
|
8
|
+
*/
|
|
9
|
+
sdkVersion?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Optional environment provider for `CONDUCTOR_INVOKED_BY` lookup. Defaults
|
|
12
|
+
* to `process.env`. Useful for tests that want to inject env without
|
|
13
|
+
* mutating globals.
|
|
14
|
+
*/
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the metadata payload every SDK write attaches to the request.
|
|
19
|
+
*
|
|
20
|
+
* Audit fields are namespaced under `metadata.audit` (RFC 0025 §5.2 + review
|
|
21
|
+
* findings M3/H1). This avoids two failure modes the original top-level shape
|
|
22
|
+
* had:
|
|
23
|
+
* 1. A caller passing `--metadata-json '{"actor":"system"}'` could spoof the
|
|
24
|
+
* audit actor by colliding with our top-level key.
|
|
25
|
+
* 2. Server-side strippers had to know the full audit-key list to filter.
|
|
26
|
+
*
|
|
27
|
+
* Merge order:
|
|
28
|
+
* - Non-audit user keys are passed through verbatim.
|
|
29
|
+
* - Audit defaults (`actor:"sdk"`, `sdkVersion`, `invokedBy`) are layered
|
|
30
|
+
* UNDER the caller's `audit` (so the CLI's `actor:"cli"` wins).
|
|
31
|
+
* - The caller's top-level `audit` key, if any, takes precedence over the
|
|
32
|
+
* SDK defaults but never replaces all of them — `cliVersion`, etc., are
|
|
33
|
+
* additive.
|
|
34
|
+
*/
|
|
35
|
+
export declare const buildAuditMetadata: (userMetadata: Record<string, unknown> | undefined, options?: SdkClientOptions) => Record<string, unknown>;
|
|
36
|
+
export declare class ProjectNotResolvedError extends Error {
|
|
37
|
+
readonly reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal';
|
|
38
|
+
constructor(message: string, reason: 'not_found' | 'multiple_matches' | 'no_default' | 'no_signal');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Best-effort mapping for HTTP-shaped failures so the CLI exit-code matrix
|
|
42
|
+
* (RFC 0025 §4) has a stable signal. Callers can choose to inspect
|
|
43
|
+
* `BackendApiError.statusCode` directly instead.
|
|
44
|
+
*/
|
|
45
|
+
export declare const isBackendApiError: (value: unknown) => value is BackendApiError;
|