opctl 0.1.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/README.md +71 -0
- package/dist/cli.js +640 -0
- package/dist/cli.js.map +1 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# opctl
|
|
2
|
+
|
|
3
|
+
`opctl` is a small local Node.js + TypeScript CLI bridge for OpenProject API v3. It uses the current user's personal API token and is read-only by default.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm install
|
|
9
|
+
cp .env.example .env
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Export variables in your shell; `opctl` intentionally does not read `.env` files.
|
|
13
|
+
|
|
14
|
+
Required:
|
|
15
|
+
|
|
16
|
+
- `OPENPROJECT_URL`: OpenProject instance URL, optionally including an instance path prefix.
|
|
17
|
+
- `OPENPROJECT_TOKEN`: personal OpenProject API token.
|
|
18
|
+
|
|
19
|
+
Optional:
|
|
20
|
+
|
|
21
|
+
- `OPENPROJECT_AUTH_MODE`: `bearer` (default) or `basic`. Basic auth uses username `apikey` and the token as password.
|
|
22
|
+
- `OPENPROJECT_DEFAULT_PROJECT`: project identifier/id used by `wp search` when `--project` is omitted.
|
|
23
|
+
- `OPENPROJECT_ALLOW_WRITE`: must be exactly `1` to allow write-capable commands.
|
|
24
|
+
|
|
25
|
+
## Commands
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm run dev -- me
|
|
29
|
+
npm run dev -- api-root
|
|
30
|
+
npm run dev -- projects --page-size 20
|
|
31
|
+
npm run dev -- wp get 123 --json
|
|
32
|
+
npm run dev -- wp search --project my-project --subject "pump" --assignee-me
|
|
33
|
+
npm run dev -- wp mine --project my-project
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Write-capable command:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
OPENPROJECT_ALLOW_WRITE=1 npm run dev -- wp comment 123 "Investigating" --dry-run
|
|
40
|
+
OPENPROJECT_ALLOW_WRITE=1 npm run dev -- wp comment 123 "Investigating"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`wp comment` fetches the work package first and posts only when a documented HAL comment action link is present. It fails safely instead of guessing a mutation URL.
|
|
44
|
+
|
|
45
|
+
## OpenAPI
|
|
46
|
+
|
|
47
|
+
The repository commits `openapi/openproject.json` and generated types in `src/generated/openproject.ts`. The committed spec is an auditable public OpenProject baseline; users should refresh it against their own instance when local API shape matters.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
OPENPROJECT_URL=https://openproject.example.com OPENPROJECT_TOKEN=... npm run openapi:update
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`npm run openapi:pull` downloads only `/api/v3/spec.json`, uses a timeout, and prints only host, output path, title, and version. It never prints the token or authorization header.
|
|
54
|
+
|
|
55
|
+
## Build and verification
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm run typecheck
|
|
59
|
+
npm run test
|
|
60
|
+
npm run build
|
|
61
|
+
node dist/cli.js --help
|
|
62
|
+
node dist/cli.js wp --help
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Safety model
|
|
66
|
+
|
|
67
|
+
- No token or `Authorization` header is printed by normal errors, JSON output, spec pulling, or tests.
|
|
68
|
+
- `.env` files are ignored and not loaded by the CLI.
|
|
69
|
+
- OpenProject writes are blocked unless `OPENPROJECT_ALLOW_WRITE=1` exactly.
|
|
70
|
+
- Every write-capable command supports `--dry-run` and avoids mutation in dry-run mode.
|
|
71
|
+
- No destructive commands are implemented: no delete, close, archive, move, or bulk edit.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import createClient from "openapi-fetch";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
//#region src/client/auth.ts
|
|
7
|
+
function createAuthorizationHeader(mode, token) {
|
|
8
|
+
if (mode === "basic") return `Basic ${Buffer.from(`apikey:${token}`, "utf8").toString("base64")}`;
|
|
9
|
+
return `Bearer ${token}`;
|
|
10
|
+
}
|
|
11
|
+
function redactSecrets(value, token) {
|
|
12
|
+
let redacted = value.replace(/Authorization:\s*(Bearer|Basic)\s+[^\s,}]+/gi, "Authorization: <redacted>");
|
|
13
|
+
redacted = redacted.replace(/"Authorization"\s*:\s*"[^"]+"/gi, "\"Authorization\":\"<redacted>\"");
|
|
14
|
+
if (token && token !== "") redacted = redacted.split(token).join("<redacted>");
|
|
15
|
+
return redacted;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/client/errors.ts
|
|
19
|
+
var EXIT_CODES = {
|
|
20
|
+
success: 0,
|
|
21
|
+
general: 1,
|
|
22
|
+
config: 2,
|
|
23
|
+
auth: 3,
|
|
24
|
+
notFound: 4,
|
|
25
|
+
validation: 5,
|
|
26
|
+
writeBlocked: 6,
|
|
27
|
+
network: 7,
|
|
28
|
+
openapi: 8
|
|
29
|
+
};
|
|
30
|
+
var OpctlError = class extends Error {
|
|
31
|
+
exitCode;
|
|
32
|
+
details;
|
|
33
|
+
constructor(message, exitCode = EXIT_CODES.general, details) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "OpctlError";
|
|
36
|
+
this.exitCode = exitCode;
|
|
37
|
+
this.details = details;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var ConfigurationError = class extends OpctlError {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message, EXIT_CODES.config);
|
|
43
|
+
this.name = "ConfigurationError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var WriteBlockedError = class extends OpctlError {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("OpenProject write blocked: set OPENPROJECT_ALLOW_WRITE=1 to enable write commands", EXIT_CODES.writeBlocked);
|
|
49
|
+
this.name = "WriteBlockedError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var NetworkError = class extends OpctlError {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message, EXIT_CODES.network);
|
|
55
|
+
this.name = "NetworkError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var OpenApiGenerationError = class extends OpctlError {
|
|
59
|
+
constructor(message) {
|
|
60
|
+
super(message, EXIT_CODES.openapi);
|
|
61
|
+
this.name = "OpenApiGenerationError";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var OpenProjectHttpError = class extends OpctlError {
|
|
65
|
+
status;
|
|
66
|
+
responseBody;
|
|
67
|
+
constructor(status, responseBody) {
|
|
68
|
+
super(httpStatusMessage(status, responseBody), exitCodeForStatus(status), responseBody);
|
|
69
|
+
this.name = "OpenProjectHttpError";
|
|
70
|
+
this.status = status;
|
|
71
|
+
this.responseBody = responseBody;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
function exitCodeForStatus(status) {
|
|
75
|
+
if (status === 401 || status === 403) return EXIT_CODES.auth;
|
|
76
|
+
if (status === 404) return EXIT_CODES.notFound;
|
|
77
|
+
if (status === 422) return EXIT_CODES.validation;
|
|
78
|
+
return EXIT_CODES.general;
|
|
79
|
+
}
|
|
80
|
+
function httpStatusMessage(status, body) {
|
|
81
|
+
if (status === 401) return "authentication failed";
|
|
82
|
+
if (status === 403) return "authenticated OpenProject user lacks permission";
|
|
83
|
+
if (status === 404) return "resource not found or not visible to this user";
|
|
84
|
+
if (status === 409) return "possible stale lockVersion or concurrent modification";
|
|
85
|
+
if (status === 422) return `validation failed${validationDetail(body)}`;
|
|
86
|
+
return `OpenProject request failed with HTTP ${status}`;
|
|
87
|
+
}
|
|
88
|
+
function validationDetail(body) {
|
|
89
|
+
if (!body || typeof body !== "object") return "";
|
|
90
|
+
const message = "message" in body && typeof body.message === "string" ? body.message : void 0;
|
|
91
|
+
const errorIdentifier = "errorIdentifier" in body && typeof body.errorIdentifier === "string" ? body.errorIdentifier : void 0;
|
|
92
|
+
const errors = "_embedded" in body ? body._embedded : void 0;
|
|
93
|
+
const parts = [
|
|
94
|
+
message,
|
|
95
|
+
errorIdentifier,
|
|
96
|
+
typeof errors === "object" && errors !== null ? JSON.stringify(errors) : void 0
|
|
97
|
+
].filter(Boolean);
|
|
98
|
+
return parts.length === 0 ? "" : `: ${parts.join("; ")}`;
|
|
99
|
+
}
|
|
100
|
+
function toOpctlError(error) {
|
|
101
|
+
if (error instanceof OpctlError) return error;
|
|
102
|
+
if (error instanceof Error) return new OpctlError(error.message);
|
|
103
|
+
return new OpctlError("unexpected failure");
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/output/json.ts
|
|
107
|
+
function stableJson(value, token) {
|
|
108
|
+
return `${redactSecrets(JSON.stringify(sortForJson(value), null, 2), token)}\n`;
|
|
109
|
+
}
|
|
110
|
+
function sortForJson(value) {
|
|
111
|
+
if (Array.isArray(value)) return value.map(sortForJson);
|
|
112
|
+
if (!value || typeof value !== "object") return value;
|
|
113
|
+
const sorted = {};
|
|
114
|
+
for (const key of Object.keys(value).sort()) sorted[key] = sortForJson(value[key]);
|
|
115
|
+
return sorted;
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/client/hal.ts
|
|
119
|
+
function asObject(value) {
|
|
120
|
+
return value && typeof value === "object" ? value : void 0;
|
|
121
|
+
}
|
|
122
|
+
function getLink(resource, name) {
|
|
123
|
+
const raw = asObject(asObject(resource)?._links)?.[name];
|
|
124
|
+
const link = Array.isArray(raw) ? asObject(raw[0]) : asObject(raw);
|
|
125
|
+
if (!link) return void 0;
|
|
126
|
+
const href = typeof link.href === "string" ? link.href : void 0;
|
|
127
|
+
const title = typeof link.title === "string" ? link.title : void 0;
|
|
128
|
+
const method = typeof link.method === "string" ? link.method : void 0;
|
|
129
|
+
if (!href && !title && !method) return void 0;
|
|
130
|
+
return {
|
|
131
|
+
...href ? { href } : {},
|
|
132
|
+
...title ? { title } : {},
|
|
133
|
+
...method ? { method } : {}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function requireLinkHref(resource, name) {
|
|
137
|
+
return getLink(resource, name)?.href;
|
|
138
|
+
}
|
|
139
|
+
function collectionElements(resource) {
|
|
140
|
+
const elements = asObject(asObject(resource)?._embedded)?.elements;
|
|
141
|
+
return Array.isArray(elements) ? elements : [];
|
|
142
|
+
}
|
|
143
|
+
function collectionTotal(resource) {
|
|
144
|
+
const total = asObject(resource)?.total;
|
|
145
|
+
return typeof total === "number" ? total : void 0;
|
|
146
|
+
}
|
|
147
|
+
function normalizeUser(resource) {
|
|
148
|
+
const object = asObject(resource) ?? {};
|
|
149
|
+
return {
|
|
150
|
+
id: numberField(object.id),
|
|
151
|
+
name: stringField(object.name),
|
|
152
|
+
login: stringField(object.login),
|
|
153
|
+
email: stringField(object.email),
|
|
154
|
+
href: getLink(object, "self")?.href
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function normalizeProject(resource) {
|
|
158
|
+
const object = asObject(resource) ?? {};
|
|
159
|
+
return {
|
|
160
|
+
id: numberField(object.id),
|
|
161
|
+
identifier: stringField(object.identifier),
|
|
162
|
+
name: stringField(object.name),
|
|
163
|
+
href: getLink(object, "self")?.href
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function normalizeWorkPackageSummary(resource) {
|
|
167
|
+
const object = asObject(resource) ?? {};
|
|
168
|
+
return {
|
|
169
|
+
id: numberField(object.id),
|
|
170
|
+
subject: stringField(object.subject),
|
|
171
|
+
status: getLink(object, "status")?.title,
|
|
172
|
+
assignee: getLink(object, "assignee")?.title,
|
|
173
|
+
project: getLink(object, "project")?.title,
|
|
174
|
+
type: getLink(object, "type")?.title,
|
|
175
|
+
href: getLink(object, "self")?.href
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function normalizeWorkPackageDetail(resource) {
|
|
179
|
+
const object = asObject(resource) ?? {};
|
|
180
|
+
return {
|
|
181
|
+
...normalizeWorkPackageSummary(object),
|
|
182
|
+
description: extractDescription(object.description),
|
|
183
|
+
lockVersion: numberField(object.lockVersion),
|
|
184
|
+
actions: actionLinks(object)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function actionLinks(resource) {
|
|
188
|
+
const links = asObject(asObject(resource)?._links) ?? {};
|
|
189
|
+
const actions = {};
|
|
190
|
+
for (const [name, value] of Object.entries(links)) {
|
|
191
|
+
const link = Array.isArray(value) ? asObject(value[0]) : asObject(value);
|
|
192
|
+
if (!link) continue;
|
|
193
|
+
if (!(typeof link.method === "string" ? link.method : void 0) && !name.startsWith("add") && !name.includes("update") && !name.includes("delete")) continue;
|
|
194
|
+
const summary = getLink(resource, name);
|
|
195
|
+
if (summary) actions[name] = summary;
|
|
196
|
+
}
|
|
197
|
+
return actions;
|
|
198
|
+
}
|
|
199
|
+
function extractDescription(value) {
|
|
200
|
+
if (typeof value === "string") return value;
|
|
201
|
+
const object = asObject(value);
|
|
202
|
+
const raw = typeof object?.raw === "string" ? object.raw : void 0;
|
|
203
|
+
const html = typeof object?.html === "string" ? object.html : void 0;
|
|
204
|
+
return raw ?? (html ? html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() : void 0);
|
|
205
|
+
}
|
|
206
|
+
function stringField(value) {
|
|
207
|
+
return typeof value === "string" ? value : void 0;
|
|
208
|
+
}
|
|
209
|
+
function numberField(value) {
|
|
210
|
+
return typeof value === "number" ? value : void 0;
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/output/text.ts
|
|
214
|
+
function renderKeyValue(value) {
|
|
215
|
+
return `${Object.entries(value).filter(([, item]) => item !== void 0).map(([key, item]) => `${key}: ${String(item)}`).join("\n")}\n`;
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/client/pagination.ts
|
|
219
|
+
function normalizePageSize(value, fallback = 25) {
|
|
220
|
+
if (value === void 0 || value === null || value === "") return fallback;
|
|
221
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
222
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) throw new Error("page size must be an integer between 1 and 100");
|
|
223
|
+
return parsed;
|
|
224
|
+
}
|
|
225
|
+
function normalizeCollection(resource, mapper) {
|
|
226
|
+
const elements = collectionElements(resource).map(mapper);
|
|
227
|
+
const total = collectionTotal(resource);
|
|
228
|
+
return {
|
|
229
|
+
elements,
|
|
230
|
+
...total === void 0 ? {} : { total },
|
|
231
|
+
count: elements.length
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/client/openProjectClient.ts
|
|
236
|
+
var OpenProjectClient = class {
|
|
237
|
+
config;
|
|
238
|
+
fetchImpl;
|
|
239
|
+
timeoutMs;
|
|
240
|
+
typedClient;
|
|
241
|
+
constructor(options) {
|
|
242
|
+
this.config = options.config;
|
|
243
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
244
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
245
|
+
this.typedClient = createClient({ baseUrl: `${this.config.baseUrl}/api/v3` });
|
|
246
|
+
this.typedClient;
|
|
247
|
+
}
|
|
248
|
+
async getApiRoot() {
|
|
249
|
+
return this.request("GET", "/api/v3");
|
|
250
|
+
}
|
|
251
|
+
async getMe() {
|
|
252
|
+
return normalizeUser(await this.request("GET", "/api/v3/users/me"));
|
|
253
|
+
}
|
|
254
|
+
async listProjects(options = {}) {
|
|
255
|
+
const params = new URLSearchParams({ pageSize: String(normalizePageSize(options.pageSize)) });
|
|
256
|
+
return normalizeCollection(await this.request("GET", `/api/v3/projects?${params}`), normalizeProject);
|
|
257
|
+
}
|
|
258
|
+
async getWorkPackageRaw(id) {
|
|
259
|
+
return this.request("GET", `/api/v3/work_packages/${encodeURIComponent(String(id))}`);
|
|
260
|
+
}
|
|
261
|
+
async getWorkPackage(id) {
|
|
262
|
+
return normalizeWorkPackageDetail(await this.getWorkPackageRaw(id));
|
|
263
|
+
}
|
|
264
|
+
async searchWorkPackages(options) {
|
|
265
|
+
const effectiveProject = options.project ?? this.config.defaultProject;
|
|
266
|
+
const basePath = effectiveProject ? `/api/v3/projects/${encodeURIComponent(effectiveProject)}/work_packages` : "/api/v3/work_packages";
|
|
267
|
+
const params = new URLSearchParams({ pageSize: String(normalizePageSize(options.pageSize)) });
|
|
268
|
+
const filters = buildWorkPackageFilters(options);
|
|
269
|
+
if (filters.length > 0) params.set("filters", JSON.stringify(filters));
|
|
270
|
+
return normalizeCollection(await this.request("GET", `${basePath}?${params}`), normalizeWorkPackageSummary);
|
|
271
|
+
}
|
|
272
|
+
async mine(options) {
|
|
273
|
+
await this.getMe();
|
|
274
|
+
return this.searchWorkPackages({
|
|
275
|
+
...options,
|
|
276
|
+
assigneeMe: true,
|
|
277
|
+
open: true
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async commentWorkPackage(id, message, dryRun) {
|
|
281
|
+
if (!this.config.allowWrite) throw new WriteBlockedError();
|
|
282
|
+
if (message.trim() === "") throw new OpctlError("comment message must not be empty", EXIT_CODES.validation);
|
|
283
|
+
const raw = await this.getWorkPackageRaw(id);
|
|
284
|
+
const detail = normalizeWorkPackageDetail(raw);
|
|
285
|
+
const commentHref = findCommentHref(raw);
|
|
286
|
+
if (!commentHref) throw new OpctlError("commenting this work package is unsupported by the current OpenProject response/spec; no documented comment action link was found", EXIT_CODES.validation);
|
|
287
|
+
const payload = { comment: { raw: message } };
|
|
288
|
+
if (dryRun) return {
|
|
289
|
+
id,
|
|
290
|
+
subject: detail.subject,
|
|
291
|
+
status: "dry-run",
|
|
292
|
+
request: {
|
|
293
|
+
method: "POST",
|
|
294
|
+
path: commentHref,
|
|
295
|
+
payload
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const response = await this.request("POST", commentHref, payload);
|
|
299
|
+
return {
|
|
300
|
+
id,
|
|
301
|
+
subject: detail.subject,
|
|
302
|
+
status: "comment posted",
|
|
303
|
+
link: getLink(response, "self")?.href ?? requireLinkHref(raw, "self")
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async request(method, pathOrHref, body) {
|
|
307
|
+
const url = pathOrHref.startsWith("http://") || pathOrHref.startsWith("https://") ? pathOrHref : `${this.config.baseUrl}${pathOrHref.startsWith("/") ? "" : "/"}${pathOrHref}`;
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
310
|
+
try {
|
|
311
|
+
const response = await this.fetchImpl(url, {
|
|
312
|
+
method,
|
|
313
|
+
signal: controller.signal,
|
|
314
|
+
headers: {
|
|
315
|
+
Accept: "application/hal+json, application/json",
|
|
316
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" },
|
|
317
|
+
Authorization: createAuthorizationHeader(this.config.authMode, this.config.token)
|
|
318
|
+
},
|
|
319
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
320
|
+
});
|
|
321
|
+
const parsed = await parseResponse(response);
|
|
322
|
+
if (!response.ok) throw new OpenProjectHttpError(response.status, parsed);
|
|
323
|
+
return parsed;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error instanceof OpenProjectHttpError || error instanceof OpctlError) throw error;
|
|
326
|
+
if (error instanceof Error && error.name === "AbortError") throw new NetworkError("OpenProject request timed out");
|
|
327
|
+
throw new NetworkError(error instanceof Error ? error.message : "OpenProject network request failed");
|
|
328
|
+
} finally {
|
|
329
|
+
clearTimeout(timeout);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
function buildWorkPackageFilters(options) {
|
|
334
|
+
const filters = [];
|
|
335
|
+
if (options.subject && options.subject.trim() !== "") filters.push({ subject: {
|
|
336
|
+
operator: "~",
|
|
337
|
+
values: [options.subject]
|
|
338
|
+
} });
|
|
339
|
+
if (options.assigneeMe) filters.push({ assignee: {
|
|
340
|
+
operator: "=",
|
|
341
|
+
values: ["me"]
|
|
342
|
+
} });
|
|
343
|
+
if (options.open) filters.push({ status: {
|
|
344
|
+
operator: "o",
|
|
345
|
+
values: []
|
|
346
|
+
} });
|
|
347
|
+
if (options.status && options.status.trim() !== "") if (options.status === "open") filters.push({ status: {
|
|
348
|
+
operator: "o",
|
|
349
|
+
values: []
|
|
350
|
+
} });
|
|
351
|
+
else filters.push({ status: {
|
|
352
|
+
operator: "=",
|
|
353
|
+
values: [options.status]
|
|
354
|
+
} });
|
|
355
|
+
return filters;
|
|
356
|
+
}
|
|
357
|
+
function findCommentHref(resource) {
|
|
358
|
+
for (const name of [
|
|
359
|
+
"addComment",
|
|
360
|
+
"addCommentImmediately",
|
|
361
|
+
"comment",
|
|
362
|
+
"addWorkPackageComment"
|
|
363
|
+
]) {
|
|
364
|
+
const link = getLink(resource, name);
|
|
365
|
+
if (link?.href && (!link.method || link.method.toUpperCase() === "POST")) return link.href;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function parseResponse(response) {
|
|
369
|
+
if (response.status === 204) return void 0;
|
|
370
|
+
const text = await response.text();
|
|
371
|
+
if (text.trim() === "") return void 0;
|
|
372
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
373
|
+
if (contentType.includes("json") || contentType.includes("hal")) try {
|
|
374
|
+
return JSON.parse(text);
|
|
375
|
+
} catch {
|
|
376
|
+
return { message: "OpenProject returned invalid JSON" };
|
|
377
|
+
}
|
|
378
|
+
return { message: text };
|
|
379
|
+
}
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/config.ts
|
|
382
|
+
function loadConfig(env = process.env) {
|
|
383
|
+
const rawUrl = env.OPENPROJECT_URL;
|
|
384
|
+
const rawToken = env.OPENPROJECT_TOKEN;
|
|
385
|
+
if (!rawUrl || rawUrl.trim() === "") throw new ConfigurationError("OPENPROJECT_URL is required");
|
|
386
|
+
if (!rawToken || rawToken.trim() === "") throw new ConfigurationError("OPENPROJECT_TOKEN is required");
|
|
387
|
+
const authMode = parseAuthMode$1(env.OPENPROJECT_AUTH_MODE);
|
|
388
|
+
const defaultProject = cleanOptional(env.OPENPROJECT_DEFAULT_PROJECT);
|
|
389
|
+
return {
|
|
390
|
+
baseUrl: normalizeBaseUrl(rawUrl),
|
|
391
|
+
token: rawToken,
|
|
392
|
+
authMode,
|
|
393
|
+
allowWrite: env.OPENPROJECT_ALLOW_WRITE === "1",
|
|
394
|
+
...defaultProject ? { defaultProject } : {}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function normalizeBaseUrl(rawUrl) {
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = new URL(rawUrl.trim());
|
|
401
|
+
} catch {
|
|
402
|
+
throw new ConfigurationError("OPENPROJECT_URL must be an absolute URL");
|
|
403
|
+
}
|
|
404
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new ConfigurationError("OPENPROJECT_URL must use http or https");
|
|
405
|
+
parsed.hash = "";
|
|
406
|
+
parsed.search = "";
|
|
407
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
408
|
+
}
|
|
409
|
+
function parseAuthMode$1(raw) {
|
|
410
|
+
if (!raw || raw.trim() === "") return "bearer";
|
|
411
|
+
const normalized = raw.trim().toLowerCase();
|
|
412
|
+
if (normalized === "bearer" || normalized === "basic") return normalized;
|
|
413
|
+
throw new ConfigurationError("OPENPROJECT_AUTH_MODE must be bearer or basic");
|
|
414
|
+
}
|
|
415
|
+
function cleanOptional(raw) {
|
|
416
|
+
if (!raw) return void 0;
|
|
417
|
+
const value = raw.trim();
|
|
418
|
+
return value === "" ? void 0 : value;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/commands/context.ts
|
|
422
|
+
function createClient$1(context) {
|
|
423
|
+
return new OpenProjectClient({
|
|
424
|
+
config: loadConfig(context.env),
|
|
425
|
+
...context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function writeOutput(context, value, json, renderText) {
|
|
429
|
+
context.stdout.write(json ? stableJson(value, context.env.OPENPROJECT_TOKEN) : renderText());
|
|
430
|
+
}
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/commands/apiRoot.ts
|
|
433
|
+
function registerApiRoot(program, context) {
|
|
434
|
+
program.command("api-root").description("Show compact OpenProject API root links").option("--json", "emit JSON").action(async (options) => {
|
|
435
|
+
const links = compactLinks(await createClient$1(context).getApiRoot());
|
|
436
|
+
context.stdout.write(options.json ? stableJson(links, context.env.OPENPROJECT_TOKEN) : renderKeyValue(links));
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function compactLinks(root) {
|
|
440
|
+
const links = asObject(asObject(root)?._links) ?? {};
|
|
441
|
+
const output = {};
|
|
442
|
+
for (const [name, raw] of Object.entries(links)) {
|
|
443
|
+
const link = Array.isArray(raw) ? asObject(raw[0]) : asObject(raw);
|
|
444
|
+
if (typeof link?.href === "string") output[name] = link.href;
|
|
445
|
+
}
|
|
446
|
+
return output;
|
|
447
|
+
}
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/commands/me.ts
|
|
450
|
+
function registerMe(program, context) {
|
|
451
|
+
program.command("me").description("Show the authenticated OpenProject user").option("--json", "emit JSON").action(async (options) => {
|
|
452
|
+
const me = await createClient$1(context).getMe();
|
|
453
|
+
writeOutput(context, me, Boolean(options.json), () => renderKeyValue(me));
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/output/table.ts
|
|
458
|
+
function renderTable(rows, columns) {
|
|
459
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
|
|
460
|
+
return `${[
|
|
461
|
+
columns.map((column, index) => column.padEnd(widths[index] ?? column.length)).join(" "),
|
|
462
|
+
widths.map((width) => "-".repeat(width)).join(" "),
|
|
463
|
+
...rows.map((row) => columns.map((column, index) => cell(row[column]).padEnd(widths[index] ?? 0)).join(" "))
|
|
464
|
+
].join("\n")}\n`;
|
|
465
|
+
}
|
|
466
|
+
function cell(value) {
|
|
467
|
+
if (value === void 0 || value === null) return "";
|
|
468
|
+
return String(value);
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/commands/projects.ts
|
|
472
|
+
function registerProjects(program, context) {
|
|
473
|
+
program.command("projects").description("List visible OpenProject projects").option("--json", "emit JSON").option("--page-size <n>", "page size", Number).action(async (options) => {
|
|
474
|
+
const projects = await createClient$1(context).listProjects(options.pageSize === void 0 ? {} : { pageSize: options.pageSize });
|
|
475
|
+
writeOutput(context, projects, Boolean(options.json), () => renderTable(projects.elements, [
|
|
476
|
+
"id",
|
|
477
|
+
"identifier",
|
|
478
|
+
"name",
|
|
479
|
+
"href"
|
|
480
|
+
]));
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region scripts/pull-openapi-spec.ts
|
|
485
|
+
async function pullOpenApiSpec(options = {}) {
|
|
486
|
+
const env = options.env ?? process.env;
|
|
487
|
+
const outputPath = options.outputPath ?? "openapi/openproject.json";
|
|
488
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
489
|
+
const rawUrl = env.OPENPROJECT_URL;
|
|
490
|
+
if (!rawUrl || rawUrl.trim() === "") throw new OpenApiGenerationError("OPENPROJECT_URL is required to pull the OpenProject spec");
|
|
491
|
+
const baseUrl = normalizeBaseUrl(rawUrl);
|
|
492
|
+
const authMode = parseAuthMode(env.OPENPROJECT_AUTH_MODE);
|
|
493
|
+
const headers = { Accept: "application/json" };
|
|
494
|
+
if (env.OPENPROJECT_TOKEN && env.OPENPROJECT_TOKEN.trim() !== "") headers.Authorization = createAuthorizationHeader(authMode, env.OPENPROJECT_TOKEN);
|
|
495
|
+
const specUrl = `${baseUrl}/api/v3/spec.json`;
|
|
496
|
+
const controller = new AbortController();
|
|
497
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
|
|
498
|
+
let response;
|
|
499
|
+
try {
|
|
500
|
+
response = await fetchImpl(specUrl, {
|
|
501
|
+
headers,
|
|
502
|
+
signal: controller.signal
|
|
503
|
+
});
|
|
504
|
+
} catch (error) {
|
|
505
|
+
throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: ${error instanceof Error ? redactSecrets(error.message, env.OPENPROJECT_TOKEN) : "network error"}`);
|
|
506
|
+
} finally {
|
|
507
|
+
clearTimeout(timeout);
|
|
508
|
+
}
|
|
509
|
+
if (!response.ok) throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: HTTP ${response.status}`);
|
|
510
|
+
const text = await response.text();
|
|
511
|
+
let spec;
|
|
512
|
+
try {
|
|
513
|
+
spec = JSON.parse(text);
|
|
514
|
+
} catch {
|
|
515
|
+
throw new OpenApiGenerationError("OpenProject spec response was not valid JSON; spec.yml is not supported by this downloader");
|
|
516
|
+
}
|
|
517
|
+
if (!spec || typeof spec !== "object") throw new OpenApiGenerationError("OpenProject spec response was not an object");
|
|
518
|
+
const info = "info" in spec && typeof spec.info === "object" && spec.info !== null ? spec.info : void 0;
|
|
519
|
+
const title = info && "title" in info && typeof info.title === "string" ? info.title : "OpenProject API";
|
|
520
|
+
const version = info && "version" in info && typeof info.version === "string" ? info.version : "unknown";
|
|
521
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
522
|
+
await writeFile(outputPath, `${JSON.stringify(spec, null, 2)}\n`, "utf8");
|
|
523
|
+
const result = {
|
|
524
|
+
sourceHost: new URL(baseUrl).host,
|
|
525
|
+
outputPath,
|
|
526
|
+
title,
|
|
527
|
+
version
|
|
528
|
+
};
|
|
529
|
+
options.stdout?.write(`Downloaded OpenProject spec from ${result.sourceHost} to ${result.outputPath} (${result.title} ${result.version})\n`);
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
function parseAuthMode(raw) {
|
|
533
|
+
const normalized = raw?.trim().toLowerCase() ?? "";
|
|
534
|
+
if (normalized === "" || normalized === "bearer") return "bearer";
|
|
535
|
+
if (normalized === "basic") return "basic";
|
|
536
|
+
throw new OpenApiGenerationError("OPENPROJECT_AUTH_MODE must be bearer or basic");
|
|
537
|
+
}
|
|
538
|
+
if (import.meta.url === `file://${process.argv[1]}`) pullOpenApiSpec({ stdout: process.stdout }).catch((error) => {
|
|
539
|
+
const message = error instanceof Error ? error.message : "failed to pull OpenProject spec";
|
|
540
|
+
process.stderr.write(`${redactSecrets(message, process.env.OPENPROJECT_TOKEN)}\n`);
|
|
541
|
+
process.exitCode = 8;
|
|
542
|
+
});
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region src/commands/spec.ts
|
|
545
|
+
function registerSpec(program, context) {
|
|
546
|
+
program.command("spec").description("OpenAPI spec utilities").command("pull").description("Download OpenProject /api/v3/spec.json safely").action(async () => {
|
|
547
|
+
await pullOpenApiSpec({
|
|
548
|
+
env: context.env,
|
|
549
|
+
stdout: context.stdout,
|
|
550
|
+
...context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/commands/workPackages.ts
|
|
556
|
+
function registerWorkPackages(program, context) {
|
|
557
|
+
const wp = program.command("wp").description("Work package commands");
|
|
558
|
+
wp.command("get").description("Get one work package").argument("<id>", "work package id").option("--json", "emit normalized JSON").option("--raw-json", "emit raw OpenProject JSON").action(async (id, options) => {
|
|
559
|
+
const numericId = parseId(id);
|
|
560
|
+
const client = createClient$1(context);
|
|
561
|
+
if (options.rawJson) {
|
|
562
|
+
context.stdout.write(stableJson(await client.getWorkPackageRaw(numericId), context.env.OPENPROJECT_TOKEN));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const workPackage = await client.getWorkPackage(numericId);
|
|
566
|
+
writeOutput(context, workPackage, Boolean(options.json), () => renderKeyValue(workPackage));
|
|
567
|
+
});
|
|
568
|
+
wp.command("search").description("Search work packages").option("--json", "emit JSON").option("--project <identifier-or-id>", "project identifier or id").option("--subject <text>", "subject contains text").option("--assignee-me", "filter to current user").option("--status <id-or-open>", "status id, or open").option("--page-size <n>", "page size", Number).action(async (options) => {
|
|
569
|
+
const result = await createClient$1(context).searchWorkPackages(options);
|
|
570
|
+
writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [
|
|
571
|
+
"id",
|
|
572
|
+
"subject",
|
|
573
|
+
"status",
|
|
574
|
+
"assignee",
|
|
575
|
+
"project",
|
|
576
|
+
"href"
|
|
577
|
+
]));
|
|
578
|
+
});
|
|
579
|
+
wp.command("mine").description("List open work packages assigned to the authenticated user").option("--json", "emit JSON").option("--project <identifier-or-id>", "project identifier or id").option("--page-size <n>", "page size", Number).action(async (options) => {
|
|
580
|
+
const result = await createClient$1(context).mine(options);
|
|
581
|
+
writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [
|
|
582
|
+
"id",
|
|
583
|
+
"subject",
|
|
584
|
+
"status",
|
|
585
|
+
"assignee",
|
|
586
|
+
"project",
|
|
587
|
+
"href"
|
|
588
|
+
]));
|
|
589
|
+
});
|
|
590
|
+
wp.command("comment").description("Add a comment to a work package; requires OPENPROJECT_ALLOW_WRITE=1").argument("<id>", "work package id").argument("<message>", "comment message").option("--dry-run", "print intended mutation without posting").option("--json", "emit JSON").action(async (id, message, options) => {
|
|
591
|
+
const result = await createClient$1(context).commentWorkPackage(parseId(id), message, Boolean(options.dryRun));
|
|
592
|
+
writeOutput(context, result, Boolean(options.json), () => renderKeyValue(result));
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
function parseId(id) {
|
|
596
|
+
const parsed = Number(id);
|
|
597
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new OpctlError("work package id must be a positive integer", EXIT_CODES.validation);
|
|
598
|
+
return parsed;
|
|
599
|
+
}
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/cli.ts
|
|
602
|
+
function buildProgram(context) {
|
|
603
|
+
const program = new Command();
|
|
604
|
+
program.name("opctl").description("Conservative local CLI bridge for OpenProject API v3").version("0.1.0").showHelpAfterError().configureOutput({
|
|
605
|
+
writeOut: (text) => context.stdout.write(text),
|
|
606
|
+
writeErr: (text) => context.stderr.write(text)
|
|
607
|
+
});
|
|
608
|
+
registerMe(program, context);
|
|
609
|
+
registerApiRoot(program, context);
|
|
610
|
+
registerProjects(program, context);
|
|
611
|
+
registerWorkPackages(program, context);
|
|
612
|
+
registerSpec(program, context);
|
|
613
|
+
return program;
|
|
614
|
+
}
|
|
615
|
+
async function run(argv, context) {
|
|
616
|
+
try {
|
|
617
|
+
await buildProgram(context).parseAsync(argv, { from: "node" });
|
|
618
|
+
return EXIT_CODES.success;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const opctlError = toOpctlError(error);
|
|
621
|
+
if (argv.includes("--json")) context.stderr.write(stableJson({
|
|
622
|
+
error: opctlError.message,
|
|
623
|
+
exitCode: opctlError.exitCode
|
|
624
|
+
}, context.env.OPENPROJECT_TOKEN));
|
|
625
|
+
else context.stderr.write(`${redactSecrets(opctlError.message, context.env.OPENPROJECT_TOKEN)}\n`);
|
|
626
|
+
return opctlError.exitCode;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
630
|
+
const exitCode = await run(process.argv, {
|
|
631
|
+
stdout: process.stdout,
|
|
632
|
+
stderr: process.stderr,
|
|
633
|
+
env: process.env
|
|
634
|
+
});
|
|
635
|
+
process.exitCode = exitCode;
|
|
636
|
+
}
|
|
637
|
+
//#endregion
|
|
638
|
+
export { buildProgram, run };
|
|
639
|
+
|
|
640
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/client/auth.ts","../src/client/errors.ts","../src/output/json.ts","../src/client/hal.ts","../src/output/text.ts","../src/client/pagination.ts","../src/client/openProjectClient.ts","../src/config.ts","../src/commands/context.ts","../src/commands/apiRoot.ts","../src/commands/me.ts","../src/output/table.ts","../src/commands/projects.ts","../scripts/pull-openapi-spec.ts","../src/commands/spec.ts","../src/commands/workPackages.ts","../src/cli.ts"],"sourcesContent":["import type { AuthMode } from \"../config.js\";\n\nexport function createAuthorizationHeader(mode: AuthMode, token: string): string {\n if (mode === \"basic\") {\n return `Basic ${Buffer.from(`apikey:${token}`, \"utf8\").toString(\"base64\")}`;\n }\n return `Bearer ${token}`;\n}\n\nexport function redactSecrets(value: string, token?: string): string {\n let redacted = value.replace(/Authorization:\\s*(Bearer|Basic)\\s+[^\\s,}]+/gi, \"Authorization: <redacted>\");\n redacted = redacted.replace(/\"Authorization\"\\s*:\\s*\"[^\"]+\"/gi, '\"Authorization\":\"<redacted>\"');\n if (token && token !== \"\") redacted = redacted.split(token).join(\"<redacted>\");\n return redacted;\n}\n","export const EXIT_CODES = {\n success: 0,\n general: 1,\n config: 2,\n auth: 3,\n notFound: 4,\n validation: 5,\n writeBlocked: 6,\n network: 7,\n openapi: 8,\n} as const;\n\nexport type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];\n\nexport class OpctlError extends Error {\n public readonly exitCode: ExitCode;\n public readonly details: unknown;\n\n public constructor(message: string, exitCode: ExitCode = EXIT_CODES.general, details?: unknown) {\n super(message);\n this.name = \"OpctlError\";\n this.exitCode = exitCode;\n this.details = details;\n }\n}\n\nexport class ConfigurationError extends OpctlError {\n public constructor(message: string) {\n super(message, EXIT_CODES.config);\n this.name = \"ConfigurationError\";\n }\n}\n\nexport class WriteBlockedError extends OpctlError {\n public constructor() {\n super(\"OpenProject write blocked: set OPENPROJECT_ALLOW_WRITE=1 to enable write commands\", EXIT_CODES.writeBlocked);\n this.name = \"WriteBlockedError\";\n }\n}\n\nexport class NetworkError extends OpctlError {\n public constructor(message: string) {\n super(message, EXIT_CODES.network);\n this.name = \"NetworkError\";\n }\n}\n\nexport class OpenApiGenerationError extends OpctlError {\n public constructor(message: string) {\n super(message, EXIT_CODES.openapi);\n this.name = \"OpenApiGenerationError\";\n }\n}\n\nexport class OpenProjectHttpError extends OpctlError {\n public readonly status: number;\n public readonly responseBody: unknown;\n\n public constructor(status: number, responseBody: unknown) {\n super(httpStatusMessage(status, responseBody), exitCodeForStatus(status), responseBody);\n this.name = \"OpenProjectHttpError\";\n this.status = status;\n this.responseBody = responseBody;\n }\n}\n\nexport function exitCodeForStatus(status: number): ExitCode {\n if (status === 401 || status === 403) return EXIT_CODES.auth;\n if (status === 404) return EXIT_CODES.notFound;\n if (status === 422) return EXIT_CODES.validation;\n return EXIT_CODES.general;\n}\n\nexport function httpStatusMessage(status: number, body: unknown): string {\n if (status === 401) return \"authentication failed\";\n if (status === 403) return \"authenticated OpenProject user lacks permission\";\n if (status === 404) return \"resource not found or not visible to this user\";\n if (status === 409) return \"possible stale lockVersion or concurrent modification\";\n if (status === 422) return `validation failed${validationDetail(body)}`;\n return `OpenProject request failed with HTTP ${status}`;\n}\n\nfunction validationDetail(body: unknown): string {\n if (!body || typeof body !== \"object\") return \"\";\n const message = \"message\" in body && typeof body.message === \"string\" ? body.message : undefined;\n const errorIdentifier = \"errorIdentifier\" in body && typeof body.errorIdentifier === \"string\" ? body.errorIdentifier : undefined;\n const errors = \"_embedded\" in body ? body._embedded : undefined;\n const parts = [message, errorIdentifier, typeof errors === \"object\" && errors !== null ? JSON.stringify(errors) : undefined].filter(Boolean);\n return parts.length === 0 ? \"\" : `: ${parts.join(\"; \")}`;\n}\n\nexport function toOpctlError(error: unknown): OpctlError {\n if (error instanceof OpctlError) return error;\n if (error instanceof Error) return new OpctlError(error.message);\n return new OpctlError(\"unexpected failure\");\n}\n","import { redactSecrets } from \"../client/auth.js\";\n\nexport function stableJson(value: unknown, token?: string): string {\n return `${redactSecrets(JSON.stringify(sortForJson(value), null, 2), token)}\\n`;\n}\n\nfunction sortForJson(value: unknown): unknown {\n if (Array.isArray(value)) return value.map(sortForJson);\n if (!value || typeof value !== \"object\") return value;\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(value).sort()) sorted[key] = sortForJson((value as Record<string, unknown>)[key]);\n return sorted;\n}\n","import type { LinkSummary, ProjectSummary, UserSummary, WorkPackageDetail, WorkPackageSummary } from \"../types/domain.js\";\n\ntype HalObject = Record<string, unknown>;\n\nexport function asObject(value: unknown): HalObject | undefined {\n return value && typeof value === \"object\" ? (value as HalObject) : undefined;\n}\n\nexport function getLink(resource: unknown, name: string): LinkSummary | undefined {\n const object = asObject(resource);\n const links = asObject(object?._links);\n const raw = links?.[name];\n const link = Array.isArray(raw) ? asObject(raw[0]) : asObject(raw);\n if (!link) return undefined;\n const href = typeof link.href === \"string\" ? link.href : undefined;\n const title = typeof link.title === \"string\" ? link.title : undefined;\n const method = typeof link.method === \"string\" ? link.method : undefined;\n if (!href && !title && !method) return undefined;\n return { ...(href ? { href } : {}), ...(title ? { title } : {}), ...(method ? { method } : {}) };\n}\n\nexport function requireLinkHref(resource: unknown, name: string): string | undefined {\n return getLink(resource, name)?.href;\n}\n\nexport function collectionElements(resource: unknown): unknown[] {\n const embedded = asObject(asObject(resource)?._embedded);\n const elements = embedded?.elements;\n return Array.isArray(elements) ? elements : [];\n}\n\nexport function collectionTotal(resource: unknown): number | undefined {\n const total = asObject(resource)?.total;\n return typeof total === \"number\" ? total : undefined;\n}\n\nexport function normalizeUser(resource: unknown): UserSummary {\n const object = asObject(resource) ?? {};\n return {\n id: numberField(object.id),\n name: stringField(object.name),\n login: stringField(object.login),\n email: stringField(object.email),\n href: getLink(object, \"self\")?.href,\n };\n}\n\nexport function normalizeProject(resource: unknown): ProjectSummary {\n const object = asObject(resource) ?? {};\n return {\n id: numberField(object.id),\n identifier: stringField(object.identifier),\n name: stringField(object.name),\n href: getLink(object, \"self\")?.href,\n };\n}\n\nexport function normalizeWorkPackageSummary(resource: unknown): WorkPackageSummary {\n const object = asObject(resource) ?? {};\n return {\n id: numberField(object.id),\n subject: stringField(object.subject),\n status: getLink(object, \"status\")?.title,\n assignee: getLink(object, \"assignee\")?.title,\n project: getLink(object, \"project\")?.title,\n type: getLink(object, \"type\")?.title,\n href: getLink(object, \"self\")?.href,\n };\n}\n\nexport function normalizeWorkPackageDetail(resource: unknown): WorkPackageDetail {\n const object = asObject(resource) ?? {};\n return {\n ...normalizeWorkPackageSummary(object),\n description: extractDescription(object.description),\n lockVersion: numberField(object.lockVersion),\n actions: actionLinks(object),\n };\n}\n\nexport function actionLinks(resource: unknown): Record<string, LinkSummary> {\n const links = asObject(asObject(resource)?._links) ?? {};\n const actions: Record<string, LinkSummary> = {};\n for (const [name, value] of Object.entries(links)) {\n const link = Array.isArray(value) ? asObject(value[0]) : asObject(value);\n if (!link) continue;\n const method = typeof link.method === \"string\" ? link.method : undefined;\n if (!method && !name.startsWith(\"add\") && !name.includes(\"update\") && !name.includes(\"delete\")) continue;\n const summary = getLink(resource, name);\n if (summary) actions[name] = summary;\n }\n return actions;\n}\n\nexport function extractDescription(value: unknown): string | undefined {\n if (typeof value === \"string\") return value;\n const object = asObject(value);\n const raw = typeof object?.raw === \"string\" ? object.raw : undefined;\n const html = typeof object?.html === \"string\" ? object.html : undefined;\n return raw ?? (html ? html.replace(/<[^>]+>/g, \" \").replace(/\\s+/g, \" \").trim() : undefined);\n}\n\nfunction stringField(value: unknown): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\nfunction numberField(value: unknown): number | undefined {\n return typeof value === \"number\" ? value : undefined;\n}\n","export function renderKeyValue(value: Record<string, unknown>): string {\n return `${Object.entries(value)\n .filter(([, item]) => item !== undefined)\n .map(([key, item]) => `${key}: ${String(item)}`)\n .join(\"\\n\")}\\n`;\n}\n","import { collectionElements, collectionTotal } from \"./hal.js\";\n\nexport interface PageOptions {\n readonly pageSize?: number;\n}\n\nexport interface NormalizedCollection<T> {\n readonly elements: readonly T[];\n readonly total?: number;\n readonly count: number;\n}\n\nexport function normalizePageSize(value: unknown, fallback = 25): number {\n if (value === undefined || value === null || value === \"\") return fallback;\n const parsed = typeof value === \"number\" ? value : Number(value);\n if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) throw new Error(\"page size must be an integer between 1 and 100\");\n return parsed;\n}\n\nexport function normalizeCollection<T>(resource: unknown, mapper: (value: unknown) => T): NormalizedCollection<T> {\n const elements = collectionElements(resource).map(mapper);\n const total = collectionTotal(resource);\n return { elements, ...(total === undefined ? {} : { total }), count: elements.length };\n}\n","import createClient, { type Client } from \"openapi-fetch\";\nimport { createAuthorizationHeader } from \"./auth.js\";\nimport { OpenProjectHttpError, NetworkError, WriteBlockedError, OpctlError, EXIT_CODES } from \"./errors.js\";\nimport { normalizeCollection, normalizePageSize, type NormalizedCollection } from \"./pagination.js\";\nimport { getLink, normalizeProject, normalizeUser, normalizeWorkPackageDetail, normalizeWorkPackageSummary, requireLinkHref } from \"./hal.js\";\nimport type { OpctlConfig } from \"../config.js\";\nimport type { paths } from \"../generated/openproject.js\";\nimport type { CommentResult, ProjectSummary, UserSummary, WorkPackageDetail, WorkPackageSummary } from \"../types/domain.js\";\n\nexport interface SearchWorkPackagesOptions {\n readonly project?: string;\n readonly subject?: string;\n readonly assigneeMe?: boolean;\n readonly status?: string;\n readonly open?: boolean;\n readonly pageSize?: number;\n}\n\nexport interface OpenProjectClientOptions {\n readonly config: OpctlConfig;\n readonly fetchImpl?: typeof fetch;\n readonly timeoutMs?: number;\n}\n\nexport class OpenProjectClient {\n private readonly config: OpctlConfig;\n private readonly fetchImpl: typeof fetch;\n private readonly timeoutMs: number;\n private readonly typedClient: Client<paths>;\n\n public constructor(options: OpenProjectClientOptions) {\n this.config = options.config;\n this.fetchImpl = options.fetchImpl ?? fetch;\n this.timeoutMs = options.timeoutMs ?? 30_000;\n this.typedClient = createClient<paths>({ baseUrl: `${this.config.baseUrl}/api/v3` });\n void this.typedClient;\n }\n\n public async getApiRoot(): Promise<unknown> {\n return this.request(\"GET\", \"/api/v3\");\n }\n\n public async getMe(): Promise<UserSummary> {\n return normalizeUser(await this.request(\"GET\", \"/api/v3/users/me\"));\n }\n\n public async listProjects(options: { readonly pageSize?: number } = {}): Promise<NormalizedCollection<ProjectSummary>> {\n const params = new URLSearchParams({ pageSize: String(normalizePageSize(options.pageSize)) });\n return normalizeCollection(await this.request(\"GET\", `/api/v3/projects?${params}`), normalizeProject);\n }\n\n public async getWorkPackageRaw(id: number): Promise<unknown> {\n return this.request(\"GET\", `/api/v3/work_packages/${encodeURIComponent(String(id))}`);\n }\n\n public async getWorkPackage(id: number): Promise<WorkPackageDetail> {\n return normalizeWorkPackageDetail(await this.getWorkPackageRaw(id));\n }\n\n public async searchWorkPackages(options: SearchWorkPackagesOptions): Promise<NormalizedCollection<WorkPackageSummary>> {\n const effectiveProject = options.project ?? this.config.defaultProject;\n const basePath = effectiveProject\n ? `/api/v3/projects/${encodeURIComponent(effectiveProject)}/work_packages`\n : \"/api/v3/work_packages\";\n const params = new URLSearchParams({ pageSize: String(normalizePageSize(options.pageSize)) });\n const filters = buildWorkPackageFilters(options);\n if (filters.length > 0) params.set(\"filters\", JSON.stringify(filters));\n return normalizeCollection(await this.request(\"GET\", `${basePath}?${params}`), normalizeWorkPackageSummary);\n }\n\n public async mine(options: Omit<SearchWorkPackagesOptions, \"assigneeMe\" | \"open\">): Promise<NormalizedCollection<WorkPackageSummary>> {\n await this.getMe();\n return this.searchWorkPackages({ ...options, assigneeMe: true, open: true });\n }\n\n public async commentWorkPackage(id: number, message: string, dryRun: boolean): Promise<CommentResult> {\n if (!this.config.allowWrite) throw new WriteBlockedError();\n if (message.trim() === \"\") throw new OpctlError(\"comment message must not be empty\", EXIT_CODES.validation);\n const raw = await this.getWorkPackageRaw(id);\n const detail = normalizeWorkPackageDetail(raw);\n const commentHref = findCommentHref(raw);\n if (!commentHref) {\n throw new OpctlError(\"commenting this work package is unsupported by the current OpenProject response/spec; no documented comment action link was found\", EXIT_CODES.validation);\n }\n const payload = { comment: { raw: message } };\n if (dryRun) {\n return { id, subject: detail.subject, status: \"dry-run\", request: { method: \"POST\", path: commentHref, payload } };\n }\n const response = await this.request(\"POST\", commentHref, payload);\n return { id, subject: detail.subject, status: \"comment posted\", link: getLink(response, \"self\")?.href ?? requireLinkHref(raw, \"self\") };\n }\n\n private async request(method: \"GET\" | \"POST\" | \"PATCH\", pathOrHref: string, body?: unknown): Promise<unknown> {\n const url = pathOrHref.startsWith(\"http://\") || pathOrHref.startsWith(\"https://\")\n ? pathOrHref\n : `${this.config.baseUrl}${pathOrHref.startsWith(\"/\") ? \"\" : \"/\"}${pathOrHref}`;\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), this.timeoutMs);\n try {\n const response = await this.fetchImpl(url, {\n method,\n signal: controller.signal,\n headers: {\n Accept: \"application/hal+json, application/json\",\n ...(body === undefined ? {} : { \"Content-Type\": \"application/json\" }),\n Authorization: createAuthorizationHeader(this.config.authMode, this.config.token),\n },\n ...(body === undefined ? {} : { body: JSON.stringify(body) }),\n });\n const parsed = await parseResponse(response);\n if (!response.ok) throw new OpenProjectHttpError(response.status, parsed);\n return parsed;\n } catch (error) {\n if (error instanceof OpenProjectHttpError || error instanceof OpctlError) throw error;\n if (error instanceof Error && error.name === \"AbortError\") throw new NetworkError(\"OpenProject request timed out\");\n throw new NetworkError(error instanceof Error ? error.message : \"OpenProject network request failed\");\n } finally {\n clearTimeout(timeout);\n }\n }\n}\n\nexport function buildWorkPackageFilters(options: Pick<SearchWorkPackagesOptions, \"subject\" | \"assigneeMe\" | \"status\" | \"open\">): unknown[] {\n const filters: unknown[] = [];\n if (options.subject && options.subject.trim() !== \"\") filters.push({ subject: { operator: \"~\", values: [options.subject] } });\n if (options.assigneeMe) filters.push({ assignee: { operator: \"=\", values: [\"me\"] } });\n if (options.open) filters.push({ status: { operator: \"o\", values: [] } });\n if (options.status && options.status.trim() !== \"\") {\n if (options.status === \"open\") filters.push({ status: { operator: \"o\", values: [] } });\n else filters.push({ status: { operator: \"=\", values: [options.status] } });\n }\n return filters;\n}\n\nfunction findCommentHref(resource: unknown): string | undefined {\n for (const name of [\"addComment\", \"addCommentImmediately\", \"comment\", \"addWorkPackageComment\"]) {\n const link = getLink(resource, name);\n if (link?.href && (!link.method || link.method.toUpperCase() === \"POST\")) return link.href;\n }\n return undefined;\n}\n\nasync function parseResponse(response: Response): Promise<unknown> {\n if (response.status === 204) return undefined;\n const text = await response.text();\n if (text.trim() === \"\") return undefined;\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"json\") || contentType.includes(\"hal\")) {\n try {\n return JSON.parse(text);\n } catch {\n return { message: \"OpenProject returned invalid JSON\" };\n }\n }\n return { message: text };\n}\n","import { ConfigurationError } from \"./client/errors.js\";\n\nexport type AuthMode = \"bearer\" | \"basic\";\n\nexport interface OpctlConfig {\n readonly baseUrl: string;\n readonly token: string;\n readonly authMode: AuthMode;\n readonly allowWrite: boolean;\n readonly defaultProject?: string;\n}\n\nexport interface EnvReader {\n readonly OPENPROJECT_URL?: string;\n readonly OPENPROJECT_TOKEN?: string;\n readonly OPENPROJECT_AUTH_MODE?: string;\n readonly OPENPROJECT_ALLOW_WRITE?: string;\n readonly OPENPROJECT_DEFAULT_PROJECT?: string;\n}\n\nexport function loadConfig(env: EnvReader = process.env): OpctlConfig {\n const rawUrl = env.OPENPROJECT_URL;\n const rawToken = env.OPENPROJECT_TOKEN;\n if (!rawUrl || rawUrl.trim() === \"\") throw new ConfigurationError(\"OPENPROJECT_URL is required\");\n if (!rawToken || rawToken.trim() === \"\") throw new ConfigurationError(\"OPENPROJECT_TOKEN is required\");\n\n const authMode = parseAuthMode(env.OPENPROJECT_AUTH_MODE);\n const defaultProject = cleanOptional(env.OPENPROJECT_DEFAULT_PROJECT);\n return {\n baseUrl: normalizeBaseUrl(rawUrl),\n token: rawToken,\n authMode,\n allowWrite: env.OPENPROJECT_ALLOW_WRITE === \"1\",\n ...(defaultProject ? { defaultProject } : {}),\n };\n}\n\nexport function normalizeBaseUrl(rawUrl: string): string {\n let parsed: URL;\n try {\n parsed = new URL(rawUrl.trim());\n } catch {\n throw new ConfigurationError(\"OPENPROJECT_URL must be an absolute URL\");\n }\n if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n throw new ConfigurationError(\"OPENPROJECT_URL must use http or https\");\n }\n parsed.hash = \"\";\n parsed.search = \"\";\n const withoutTrailing = parsed.toString().replace(/\\/+$/, \"\");\n return withoutTrailing;\n}\n\nfunction parseAuthMode(raw: string | undefined): AuthMode {\n if (!raw || raw.trim() === \"\") return \"bearer\";\n const normalized = raw.trim().toLowerCase();\n if (normalized === \"bearer\" || normalized === \"basic\") return normalized;\n throw new ConfigurationError(\"OPENPROJECT_AUTH_MODE must be bearer or basic\");\n}\n\nfunction cleanOptional(raw: string | undefined): string | undefined {\n if (!raw) return undefined;\n const value = raw.trim();\n return value === \"\" ? undefined : value;\n}\n","import type { Command } from \"commander\";\nimport { OpenProjectClient } from \"../client/openProjectClient.js\";\nimport { loadConfig } from \"../config.js\";\nimport { stableJson } from \"../output/json.js\";\n\nexport interface CommandContext {\n readonly stdout: Pick<NodeJS.WriteStream, \"write\">;\n readonly stderr: Pick<NodeJS.WriteStream, \"write\">;\n readonly env: NodeJS.ProcessEnv;\n readonly fetchImpl?: typeof fetch;\n}\n\nexport function createClient(context: CommandContext): OpenProjectClient {\n return new OpenProjectClient({\n config: loadConfig(context.env),\n ...(context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}),\n });\n}\n\nexport function writeOutput(context: CommandContext, value: unknown, json: boolean, renderText: () => string): void {\n context.stdout.write(json ? stableJson(value, context.env.OPENPROJECT_TOKEN) : renderText());\n}\n\nexport function booleanOption(command: Command, name: string): boolean {\n return Boolean(command.opts<Record<string, unknown>>()[name]);\n}\n","import type { Command } from \"commander\";\nimport { asObject } from \"../client/hal.js\";\nimport { stableJson } from \"../output/json.js\";\nimport { renderKeyValue } from \"../output/text.js\";\nimport { createClient, type CommandContext } from \"./context.js\";\n\nexport function registerApiRoot(program: Command, context: CommandContext): void {\n program\n .command(\"api-root\")\n .description(\"Show compact OpenProject API root links\")\n .option(\"--json\", \"emit JSON\")\n .action(async (options: { json?: boolean }) => {\n const root = await createClient(context).getApiRoot();\n const links = compactLinks(root);\n context.stdout.write(options.json ? stableJson(links, context.env.OPENPROJECT_TOKEN) : renderKeyValue(links));\n });\n}\n\nexport function compactLinks(root: unknown): Record<string, string> {\n const links = asObject(asObject(root)?._links) ?? {};\n const output: Record<string, string> = {};\n for (const [name, raw] of Object.entries(links)) {\n const link = Array.isArray(raw) ? asObject(raw[0]) : asObject(raw);\n if (typeof link?.href === \"string\") output[name] = link.href;\n }\n return output;\n}\n","import type { Command } from \"commander\";\nimport { createClient, type CommandContext, writeOutput } from \"./context.js\";\nimport { renderKeyValue } from \"../output/text.js\";\n\nexport function registerMe(program: Command, context: CommandContext): void {\n program\n .command(\"me\")\n .description(\"Show the authenticated OpenProject user\")\n .option(\"--json\", \"emit JSON\")\n .action(async (options: { json?: boolean }) => {\n const me = await createClient(context).getMe();\n writeOutput(context, me, Boolean(options.json), () => renderKeyValue(me as unknown as Record<string, unknown>));\n });\n}\n","export function renderTable(rows: readonly object[], columns: readonly string[]): string {\n const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell((row as Record<string, unknown>)[column]).length)));\n const header = columns.map((column, index) => column.padEnd(widths[index] ?? column.length)).join(\" \");\n const divider = widths.map((width) => \"-\".repeat(width)).join(\" \");\n const body = rows.map((row) => columns.map((column, index) => cell((row as Record<string, unknown>)[column]).padEnd(widths[index] ?? 0)).join(\" \"));\n return `${[header, divider, ...body].join(\"\\n\")}\\n`;\n}\n\nfunction cell(value: unknown): string {\n if (value === undefined || value === null) return \"\";\n return String(value);\n}\n","import type { Command } from \"commander\";\nimport { renderTable } from \"../output/table.js\";\nimport { createClient, type CommandContext, writeOutput } from \"./context.js\";\n\nexport function registerProjects(program: Command, context: CommandContext): void {\n program\n .command(\"projects\")\n .description(\"List visible OpenProject projects\")\n .option(\"--json\", \"emit JSON\")\n .option(\"--page-size <n>\", \"page size\", Number)\n .action(async (options: { json?: boolean; pageSize?: number }) => {\n const projects = await createClient(context).listProjects(options.pageSize === undefined ? {} : { pageSize: options.pageSize });\n writeOutput(context, projects, Boolean(options.json), () => renderTable(projects.elements, [\"id\", \"identifier\", \"name\", \"href\"]));\n });\n}\n","import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport { createAuthorizationHeader, redactSecrets } from \"../src/client/auth.js\";\nimport { OpenApiGenerationError } from \"../src/client/errors.js\";\nimport { normalizeBaseUrl, type AuthMode } from \"../src/config.js\";\n\nexport interface PullSpecOptions {\n readonly env?: NodeJS.ProcessEnv;\n readonly fetchImpl?: typeof fetch;\n readonly outputPath?: string;\n readonly timeoutMs?: number;\n readonly stdout?: Pick<typeof process.stdout, \"write\">;\n}\n\nexport interface PullSpecResult {\n readonly sourceHost: string;\n readonly outputPath: string;\n readonly title: string;\n readonly version: string;\n}\n\nexport async function pullOpenApiSpec(options: PullSpecOptions = {}): Promise<PullSpecResult> {\n const env = options.env ?? process.env;\n const outputPath = options.outputPath ?? \"openapi/openproject.json\";\n const fetchImpl = options.fetchImpl ?? fetch;\n const rawUrl = env.OPENPROJECT_URL;\n if (!rawUrl || rawUrl.trim() === \"\") throw new OpenApiGenerationError(\"OPENPROJECT_URL is required to pull the OpenProject spec\");\n const baseUrl = normalizeBaseUrl(rawUrl);\n const authMode = parseAuthMode(env.OPENPROJECT_AUTH_MODE);\n const headers: Record<string, string> = { Accept: \"application/json\" };\n if (env.OPENPROJECT_TOKEN && env.OPENPROJECT_TOKEN.trim() !== \"\") {\n headers.Authorization = createAuthorizationHeader(authMode, env.OPENPROJECT_TOKEN);\n }\n\n const specUrl = `${baseUrl}/api/v3/spec.json`;\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);\n let response: Response;\n try {\n response = await fetchImpl(specUrl, { headers, signal: controller.signal });\n } catch (error) {\n throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: ${error instanceof Error ? redactSecrets(error.message, env.OPENPROJECT_TOKEN) : \"network error\"}`);\n } finally {\n clearTimeout(timeout);\n }\n\n if (!response.ok) {\n throw new OpenApiGenerationError(`failed to download OpenProject spec from ${new URL(baseUrl).host}: HTTP ${response.status}`);\n }\n\n const text = await response.text();\n let spec: unknown;\n try {\n spec = JSON.parse(text);\n } catch {\n throw new OpenApiGenerationError(\"OpenProject spec response was not valid JSON; spec.yml is not supported by this downloader\");\n }\n if (!spec || typeof spec !== \"object\") throw new OpenApiGenerationError(\"OpenProject spec response was not an object\");\n const info = \"info\" in spec && typeof spec.info === \"object\" && spec.info !== null ? spec.info : undefined;\n const title = info && \"title\" in info && typeof info.title === \"string\" ? info.title : \"OpenProject API\";\n const version = info && \"version\" in info && typeof info.version === \"string\" ? info.version : \"unknown\";\n\n await mkdir(dirname(outputPath), { recursive: true });\n await writeFile(outputPath, `${JSON.stringify(spec, null, 2)}\\n`, \"utf8\");\n const result = { sourceHost: new URL(baseUrl).host, outputPath, title, version };\n options.stdout?.write(`Downloaded OpenProject spec from ${result.sourceHost} to ${result.outputPath} (${result.title} ${result.version})\\n`);\n return result;\n}\n\nfunction parseAuthMode(raw: string | undefined): AuthMode {\n const normalized = raw?.trim().toLowerCase() ?? \"\";\n if (normalized === \"\" || normalized === \"bearer\") return \"bearer\";\n if (normalized === \"basic\") return \"basic\";\n throw new OpenApiGenerationError(\"OPENPROJECT_AUTH_MODE must be bearer or basic\");\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n pullOpenApiSpec({ stdout: process.stdout }).catch((error: unknown) => {\n const message = error instanceof Error ? error.message : \"failed to pull OpenProject spec\";\n process.stderr.write(`${redactSecrets(message, process.env.OPENPROJECT_TOKEN)}\\n`);\n process.exitCode = 8;\n });\n}\n","import type { Command } from \"commander\";\nimport { pullOpenApiSpec } from \"../../scripts/pull-openapi-spec.js\";\nimport type { CommandContext } from \"./context.js\";\n\nexport function registerSpec(program: Command, context: CommandContext): void {\n const spec = program.command(\"spec\").description(\"OpenAPI spec utilities\");\n spec.command(\"pull\")\n .description(\"Download OpenProject /api/v3/spec.json safely\")\n .action(async () => {\n await pullOpenApiSpec({ env: context.env, stdout: context.stdout, ...(context.fetchImpl ? { fetchImpl: context.fetchImpl } : {}) });\n });\n}\n","import type { Command } from \"commander\";\nimport { OpctlError, EXIT_CODES } from \"../client/errors.js\";\nimport { stableJson } from \"../output/json.js\";\nimport { renderKeyValue } from \"../output/text.js\";\nimport { renderTable } from \"../output/table.js\";\nimport { createClient, type CommandContext, writeOutput } from \"./context.js\";\n\ninterface SearchOptions {\n readonly json?: boolean;\n readonly project?: string;\n readonly subject?: string;\n readonly assigneeMe?: boolean;\n readonly status?: string;\n readonly pageSize?: number;\n}\n\nexport function registerWorkPackages(program: Command, context: CommandContext): void {\n const wp = program.command(\"wp\").description(\"Work package commands\");\n\n wp.command(\"get\")\n .description(\"Get one work package\")\n .argument(\"<id>\", \"work package id\")\n .option(\"--json\", \"emit normalized JSON\")\n .option(\"--raw-json\", \"emit raw OpenProject JSON\")\n .action(async (id: string, options: { json?: boolean; rawJson?: boolean }) => {\n const numericId = parseId(id);\n const client = createClient(context);\n if (options.rawJson) {\n context.stdout.write(stableJson(await client.getWorkPackageRaw(numericId), context.env.OPENPROJECT_TOKEN));\n return;\n }\n const workPackage = await client.getWorkPackage(numericId);\n writeOutput(context, workPackage, Boolean(options.json), () => renderKeyValue(workPackage as unknown as Record<string, unknown>));\n });\n\n wp.command(\"search\")\n .description(\"Search work packages\")\n .option(\"--json\", \"emit JSON\")\n .option(\"--project <identifier-or-id>\", \"project identifier or id\")\n .option(\"--subject <text>\", \"subject contains text\")\n .option(\"--assignee-me\", \"filter to current user\")\n .option(\"--status <id-or-open>\", \"status id, or open\")\n .option(\"--page-size <n>\", \"page size\", Number)\n .action(async (options: SearchOptions) => {\n const result = await createClient(context).searchWorkPackages(options);\n writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [\"id\", \"subject\", \"status\", \"assignee\", \"project\", \"href\"]));\n });\n\n wp.command(\"mine\")\n .description(\"List open work packages assigned to the authenticated user\")\n .option(\"--json\", \"emit JSON\")\n .option(\"--project <identifier-or-id>\", \"project identifier or id\")\n .option(\"--page-size <n>\", \"page size\", Number)\n .action(async (options: Pick<SearchOptions, \"json\" | \"project\" | \"pageSize\">) => {\n const result = await createClient(context).mine(options);\n writeOutput(context, result, Boolean(options.json), () => renderTable(result.elements, [\"id\", \"subject\", \"status\", \"assignee\", \"project\", \"href\"]));\n });\n\n wp.command(\"comment\")\n .description(\"Add a comment to a work package; requires OPENPROJECT_ALLOW_WRITE=1\")\n .argument(\"<id>\", \"work package id\")\n .argument(\"<message>\", \"comment message\")\n .option(\"--dry-run\", \"print intended mutation without posting\")\n .option(\"--json\", \"emit JSON\")\n .action(async (id: string, message: string, options: { dryRun?: boolean; json?: boolean }) => {\n const result = await createClient(context).commentWorkPackage(parseId(id), message, Boolean(options.dryRun));\n writeOutput(context, result, Boolean(options.json), () => renderKeyValue(result as unknown as Record<string, unknown>));\n });\n}\n\nfunction parseId(id: string): number {\n const parsed = Number(id);\n if (!Number.isInteger(parsed) || parsed < 1) throw new OpctlError(\"work package id must be a positive integer\", EXIT_CODES.validation);\n return parsed;\n}\n","#!/usr/bin/env node\nimport { Command } from \"commander\";\nimport { redactSecrets } from \"./client/auth.js\";\nimport { EXIT_CODES, toOpctlError } from \"./client/errors.js\";\nimport { stableJson } from \"./output/json.js\";\nimport { registerApiRoot } from \"./commands/apiRoot.js\";\nimport { registerMe } from \"./commands/me.js\";\nimport { registerProjects } from \"./commands/projects.js\";\nimport { registerSpec } from \"./commands/spec.js\";\nimport { registerWorkPackages } from \"./commands/workPackages.js\";\nimport type { CommandContext } from \"./commands/context.js\";\n\nexport function buildProgram(context: CommandContext): Command {\n const program = new Command();\n program\n .name(\"opctl\")\n .description(\"Conservative local CLI bridge for OpenProject API v3\")\n .version(\"0.1.0\")\n .showHelpAfterError()\n .configureOutput({\n writeOut: (text) => context.stdout.write(text),\n writeErr: (text) => context.stderr.write(text),\n });\n registerMe(program, context);\n registerApiRoot(program, context);\n registerProjects(program, context);\n registerWorkPackages(program, context);\n registerSpec(program, context);\n return program;\n}\n\nexport async function run(argv: readonly string[], context: CommandContext): Promise<number> {\n try {\n await buildProgram(context).parseAsync(argv, { from: \"node\" });\n return EXIT_CODES.success;\n } catch (error) {\n const opctlError = toOpctlError(error);\n const wantsJson = argv.includes(\"--json\");\n if (wantsJson) context.stderr.write(stableJson({ error: opctlError.message, exitCode: opctlError.exitCode }, context.env.OPENPROJECT_TOKEN));\n else context.stderr.write(`${redactSecrets(opctlError.message, context.env.OPENPROJECT_TOKEN)}\\n`);\n return opctlError.exitCode;\n }\n}\n\nif (import.meta.url === `file://${process.argv[1]}`) {\n const exitCode = await run(process.argv, { stdout: process.stdout, stderr: process.stderr, env: process.env });\n process.exitCode = exitCode;\n}\n"],"mappings":";;;;;;AAEA,SAAgB,0BAA0B,MAAgB,OAAuB;CAC/E,IAAI,SAAS,SACX,OAAO,SAAS,OAAO,KAAK,UAAU,SAAS,MAAM,EAAE,SAAS,QAAQ;CAE1E,OAAO,UAAU;AACnB;AAEA,SAAgB,cAAc,OAAe,OAAwB;CACnE,IAAI,WAAW,MAAM,QAAQ,gDAAgD,2BAA2B;CACxG,WAAW,SAAS,QAAQ,mCAAmC,kCAA8B;CAC7F,IAAI,SAAS,UAAU,IAAI,WAAW,SAAS,MAAM,KAAK,EAAE,KAAK,YAAY;CAC7E,OAAO;AACT;;;ACdA,IAAa,aAAa;CACxB,SAAS;CACT,SAAS;CACT,QAAQ;CACR,MAAM;CACN,UAAU;CACV,YAAY;CACZ,cAAc;CACd,SAAS;CACT,SAAS;AACX;AAIA,IAAa,aAAb,cAAgC,MAAM;CACpC;CACA;CAEA,YAAmB,SAAiB,WAAqB,WAAW,SAAS,SAAmB;EAC9F,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,WAAW;EAChB,KAAK,UAAU;CACjB;AACF;AAEA,IAAa,qBAAb,cAAwC,WAAW;CACjD,YAAmB,SAAiB;EAClC,MAAM,SAAS,WAAW,MAAM;EAChC,KAAK,OAAO;CACd;AACF;AAEA,IAAa,oBAAb,cAAuC,WAAW;CAChD,cAAqB;EACnB,MAAM,qFAAqF,WAAW,YAAY;EAClH,KAAK,OAAO;CACd;AACF;AAEA,IAAa,eAAb,cAAkC,WAAW;CAC3C,YAAmB,SAAiB;EAClC,MAAM,SAAS,WAAW,OAAO;EACjC,KAAK,OAAO;CACd;AACF;AAEA,IAAa,yBAAb,cAA4C,WAAW;CACrD,YAAmB,SAAiB;EAClC,MAAM,SAAS,WAAW,OAAO;EACjC,KAAK,OAAO;CACd;AACF;AAEA,IAAa,uBAAb,cAA0C,WAAW;CACnD;CACA;CAEA,YAAmB,QAAgB,cAAuB;EACxD,MAAM,kBAAkB,QAAQ,YAAY,GAAG,kBAAkB,MAAM,GAAG,YAAY;EACtF,KAAK,OAAO;EACZ,KAAK,SAAS;EACd,KAAK,eAAe;CACtB;AACF;AAEA,SAAgB,kBAAkB,QAA0B;CAC1D,IAAI,WAAW,OAAO,WAAW,KAAK,OAAO,WAAW;CACxD,IAAI,WAAW,KAAK,OAAO,WAAW;CACtC,IAAI,WAAW,KAAK,OAAO,WAAW;CACtC,OAAO,WAAW;AACpB;AAEA,SAAgB,kBAAkB,QAAgB,MAAuB;CACvE,IAAI,WAAW,KAAK,OAAO;CAC3B,IAAI,WAAW,KAAK,OAAO;CAC3B,IAAI,WAAW,KAAK,OAAO;CAC3B,IAAI,WAAW,KAAK,OAAO;CAC3B,IAAI,WAAW,KAAK,OAAO,oBAAoB,iBAAiB,IAAI;CACpE,OAAO,wCAAwC;AACjD;AAEA,SAAS,iBAAiB,MAAuB;CAC/C,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU,OAAO;CAC9C,MAAM,UAAU,aAAa,QAAQ,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU;CACvF,MAAM,kBAAkB,qBAAqB,QAAQ,OAAO,KAAK,oBAAoB,WAAW,KAAK,kBAAkB;CACvH,MAAM,SAAS,eAAe,OAAO,KAAK,YAAY;CACtD,MAAM,QAAQ;EAAC;EAAS;EAAiB,OAAO,WAAW,YAAY,WAAW,OAAO,KAAK,UAAU,MAAM,IAAI;CAAS,EAAE,OAAO,OAAO;CAC3I,OAAO,MAAM,WAAW,IAAI,KAAK,KAAK,MAAM,KAAK,IAAI;AACvD;AAEA,SAAgB,aAAa,OAA4B;CACvD,IAAI,iBAAiB,YAAY,OAAO;CACxC,IAAI,iBAAiB,OAAO,OAAO,IAAI,WAAW,MAAM,OAAO;CAC/D,OAAO,IAAI,WAAW,oBAAoB;AAC5C;;;AC7FA,SAAgB,WAAW,OAAgB,OAAwB;CACjE,OAAO,GAAG,cAAc,KAAK,UAAU,YAAY,KAAK,GAAG,MAAM,CAAC,GAAG,KAAK,EAAE;AAC9E;AAEA,SAAS,YAAY,OAAyB;CAC5C,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO,MAAM,IAAI,WAAW;CACtD,IAAI,CAAC,SAAS,OAAO,UAAU,UAAU,OAAO;CAChD,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,EAAE,KAAK,GAAG,OAAO,OAAO,YAAa,MAAkC,IAAI;CAC9G,OAAO;AACT;;;ACRA,SAAgB,SAAS,OAAuC;CAC9D,OAAO,SAAS,OAAO,UAAU,WAAY,QAAsB;AACrE;AAEA,SAAgB,QAAQ,UAAmB,MAAuC;CAGhF,MAAM,MADQ,SADC,SAAS,QACD,GAAQ,MACnB,IAAQ;CACpB,MAAM,OAAO,MAAM,QAAQ,GAAG,IAAI,SAAS,IAAI,EAAE,IAAI,SAAS,GAAG;CACjE,IAAI,CAAC,MAAM,OAAO;CAClB,MAAM,OAAO,OAAO,KAAK,SAAS,WAAW,KAAK,OAAO;CACzD,MAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;CAC5D,MAAM,SAAS,OAAO,KAAK,WAAW,WAAW,KAAK,SAAS;CAC/D,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,QAAQ,OAAO;CACvC,OAAO;EAAE,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;EAAI,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;EAAI,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;CAAG;AACjG;AAEA,SAAgB,gBAAgB,UAAmB,MAAkC;CACnF,OAAO,QAAQ,UAAU,IAAI,GAAG;AAClC;AAEA,SAAgB,mBAAmB,UAA8B;CAE/D,MAAM,WADW,SAAS,SAAS,QAAQ,GAAG,SAC7B,GAAU;CAC3B,OAAO,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC;AAC/C;AAEA,SAAgB,gBAAgB,UAAuC;CACrE,MAAM,QAAQ,SAAS,QAAQ,GAAG;CAClC,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;AAEA,SAAgB,cAAc,UAAgC;CAC5D,MAAM,SAAS,SAAS,QAAQ,KAAK,CAAC;CACtC,OAAO;EACL,IAAI,YAAY,OAAO,EAAE;EACzB,MAAM,YAAY,OAAO,IAAI;EAC7B,OAAO,YAAY,OAAO,KAAK;EAC/B,OAAO,YAAY,OAAO,KAAK;EAC/B,MAAM,QAAQ,QAAQ,MAAM,GAAG;CACjC;AACF;AAEA,SAAgB,iBAAiB,UAAmC;CAClE,MAAM,SAAS,SAAS,QAAQ,KAAK,CAAC;CACtC,OAAO;EACL,IAAI,YAAY,OAAO,EAAE;EACzB,YAAY,YAAY,OAAO,UAAU;EACzC,MAAM,YAAY,OAAO,IAAI;EAC7B,MAAM,QAAQ,QAAQ,MAAM,GAAG;CACjC;AACF;AAEA,SAAgB,4BAA4B,UAAuC;CACjF,MAAM,SAAS,SAAS,QAAQ,KAAK,CAAC;CACtC,OAAO;EACL,IAAI,YAAY,OAAO,EAAE;EACzB,SAAS,YAAY,OAAO,OAAO;EACnC,QAAQ,QAAQ,QAAQ,QAAQ,GAAG;EACnC,UAAU,QAAQ,QAAQ,UAAU,GAAG;EACvC,SAAS,QAAQ,QAAQ,SAAS,GAAG;EACrC,MAAM,QAAQ,QAAQ,MAAM,GAAG;EAC/B,MAAM,QAAQ,QAAQ,MAAM,GAAG;CACjC;AACF;AAEA,SAAgB,2BAA2B,UAAsC;CAC/E,MAAM,SAAS,SAAS,QAAQ,KAAK,CAAC;CACtC,OAAO;EACL,GAAG,4BAA4B,MAAM;EACrC,aAAa,mBAAmB,OAAO,WAAW;EAClD,aAAa,YAAY,OAAO,WAAW;EAC3C,SAAS,YAAY,MAAM;CAC7B;AACF;AAEA,SAAgB,YAAY,UAAgD;CAC1E,MAAM,QAAQ,SAAS,SAAS,QAAQ,GAAG,MAAM,KAAK,CAAC;CACvD,MAAM,UAAuC,CAAC;CAC9C,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,GAAG;EACjD,MAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,SAAS,MAAM,EAAE,IAAI,SAAS,KAAK;EACvE,IAAI,CAAC,MAAM;EAEX,IAAI,EADW,OAAO,KAAK,WAAW,WAAW,KAAK,SAAS,WAChD,CAAC,KAAK,WAAW,KAAK,KAAK,CAAC,KAAK,SAAS,QAAQ,KAAK,CAAC,KAAK,SAAS,QAAQ,GAAG;EAChG,MAAM,UAAU,QAAQ,UAAU,IAAI;EACtC,IAAI,SAAS,QAAQ,QAAQ;CAC/B;CACA,OAAO;AACT;AAEA,SAAgB,mBAAmB,OAAoC;CACrE,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,SAAS,SAAS,KAAK;CAC7B,MAAM,MAAM,OAAO,QAAQ,QAAQ,WAAW,OAAO,MAAM;CAC3D,MAAM,OAAO,OAAO,QAAQ,SAAS,WAAW,OAAO,OAAO;CAC9D,OAAO,QAAQ,OAAO,KAAK,QAAQ,YAAY,GAAG,EAAE,QAAQ,QAAQ,GAAG,EAAE,KAAK,IAAI;AACpF;AAEA,SAAS,YAAY,OAAoC;CACvD,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;AAEA,SAAS,YAAY,OAAoC;CACvD,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;;;AC5GA,SAAgB,eAAe,OAAwC;CACrE,OAAO,GAAG,OAAO,QAAQ,KAAK,EAC3B,QAAQ,GAAG,UAAU,SAAS,MAAS,EACvC,KAAK,CAAC,KAAK,UAAU,GAAG,IAAI,IAAI,OAAO,IAAI,GAAG,EAC9C,KAAK,IAAI,EAAE;AAChB;;;ACOA,SAAgB,kBAAkB,OAAgB,WAAW,IAAY;CACvE,IAAI,UAAU,UAAa,UAAU,QAAQ,UAAU,IAAI,OAAO;CAClE,MAAM,SAAS,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK;CAC/D,IAAI,CAAC,OAAO,UAAU,MAAM,KAAK,SAAS,KAAK,SAAS,KAAK,MAAM,IAAI,MAAM,gDAAgD;CAC7H,OAAO;AACT;AAEA,SAAgB,oBAAuB,UAAmB,QAAwD;CAChH,MAAM,WAAW,mBAAmB,QAAQ,EAAE,IAAI,MAAM;CACxD,MAAM,QAAQ,gBAAgB,QAAQ;CACtC,OAAO;EAAE;EAAU,GAAI,UAAU,SAAY,CAAC,IAAI,EAAE,MAAM;EAAI,OAAO,SAAS;CAAO;AACvF;;;ACCA,IAAa,oBAAb,MAA+B;CAC7B;CACA;CACA;CACA;CAEA,YAAmB,SAAmC;EACpD,KAAK,SAAS,QAAQ;EACtB,KAAK,YAAY,QAAQ,aAAa;EACtC,KAAK,YAAY,QAAQ,aAAa;EACtC,KAAK,cAAc,aAAoB,EAAE,SAAS,GAAG,KAAK,OAAO,QAAQ,SAAS,CAAC;EACnF,AAAK,KAAK;CACZ;CAEA,MAAa,aAA+B;EAC1C,OAAO,KAAK,QAAQ,OAAO,SAAS;CACtC;CAEA,MAAa,QAA8B;EACzC,OAAO,cAAc,MAAM,KAAK,QAAQ,OAAO,kBAAkB,CAAC;CACpE;CAEA,MAAa,aAAa,UAA0C,CAAC,GAAkD;EACrH,MAAM,SAAS,IAAI,gBAAgB,EAAE,UAAU,OAAO,kBAAkB,QAAQ,QAAQ,CAAC,EAAE,CAAC;EAC5F,OAAO,oBAAoB,MAAM,KAAK,QAAQ,OAAO,oBAAoB,QAAQ,GAAG,gBAAgB;CACtG;CAEA,MAAa,kBAAkB,IAA8B;EAC3D,OAAO,KAAK,QAAQ,OAAO,yBAAyB,mBAAmB,OAAO,EAAE,CAAC,GAAG;CACtF;CAEA,MAAa,eAAe,IAAwC;EAClE,OAAO,2BAA2B,MAAM,KAAK,kBAAkB,EAAE,CAAC;CACpE;CAEA,MAAa,mBAAmB,SAAuF;EACrH,MAAM,mBAAmB,QAAQ,WAAW,KAAK,OAAO;EACxD,MAAM,WAAW,mBACb,oBAAoB,mBAAmB,gBAAgB,EAAE,kBACzD;EACJ,MAAM,SAAS,IAAI,gBAAgB,EAAE,UAAU,OAAO,kBAAkB,QAAQ,QAAQ,CAAC,EAAE,CAAC;EAC5F,MAAM,UAAU,wBAAwB,OAAO;EAC/C,IAAI,QAAQ,SAAS,GAAG,OAAO,IAAI,WAAW,KAAK,UAAU,OAAO,CAAC;EACrE,OAAO,oBAAoB,MAAM,KAAK,QAAQ,OAAO,GAAG,SAAS,GAAG,QAAQ,GAAG,2BAA2B;CAC5G;CAEA,MAAa,KAAK,SAAoH;EACpI,MAAM,KAAK,MAAM;EACjB,OAAO,KAAK,mBAAmB;GAAE,GAAG;GAAS,YAAY;GAAM,MAAM;EAAK,CAAC;CAC7E;CAEA,MAAa,mBAAmB,IAAY,SAAiB,QAAyC;EACpG,IAAI,CAAC,KAAK,OAAO,YAAY,MAAM,IAAI,kBAAkB;EACzD,IAAI,QAAQ,KAAK,MAAM,IAAI,MAAM,IAAI,WAAW,qCAAqC,WAAW,UAAU;EAC1G,MAAM,MAAM,MAAM,KAAK,kBAAkB,EAAE;EAC3C,MAAM,SAAS,2BAA2B,GAAG;EAC7C,MAAM,cAAc,gBAAgB,GAAG;EACvC,IAAI,CAAC,aACH,MAAM,IAAI,WAAW,qIAAqI,WAAW,UAAU;EAEjL,MAAM,UAAU,EAAE,SAAS,EAAE,KAAK,QAAQ,EAAE;EAC5C,IAAI,QACF,OAAO;GAAE;GAAI,SAAS,OAAO;GAAS,QAAQ;GAAW,SAAS;IAAE,QAAQ;IAAQ,MAAM;IAAa;GAAQ;EAAE;EAEnH,MAAM,WAAW,MAAM,KAAK,QAAQ,QAAQ,aAAa,OAAO;EAChE,OAAO;GAAE;GAAI,SAAS,OAAO;GAAS,QAAQ;GAAkB,MAAM,QAAQ,UAAU,MAAM,GAAG,QAAQ,gBAAgB,KAAK,MAAM;EAAE;CACxI;CAEA,MAAc,QAAQ,QAAkC,YAAoB,MAAkC;EAC5G,MAAM,MAAM,WAAW,WAAW,SAAS,KAAK,WAAW,WAAW,UAAU,IAC5E,aACA,GAAG,KAAK,OAAO,UAAU,WAAW,WAAW,GAAG,IAAI,KAAK,MAAM;EACrE,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,KAAK,SAAS;EACnE,IAAI;GACF,MAAM,WAAW,MAAM,KAAK,UAAU,KAAK;IACzC;IACA,QAAQ,WAAW;IACnB,SAAS;KACP,QAAQ;KACR,GAAI,SAAS,SAAY,CAAC,IAAI,EAAE,gBAAgB,mBAAmB;KACnE,eAAe,0BAA0B,KAAK,OAAO,UAAU,KAAK,OAAO,KAAK;IAClF;IACA,GAAI,SAAS,SAAY,CAAC,IAAI,EAAE,MAAM,KAAK,UAAU,IAAI,EAAE;GAC7D,CAAC;GACD,MAAM,SAAS,MAAM,cAAc,QAAQ;GAC3C,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,qBAAqB,SAAS,QAAQ,MAAM;GACxE,OAAO;EACT,SAAS,OAAO;GACd,IAAI,iBAAiB,wBAAwB,iBAAiB,YAAY,MAAM;GAChF,IAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc,MAAM,IAAI,aAAa,+BAA+B;GACjH,MAAM,IAAI,aAAa,iBAAiB,QAAQ,MAAM,UAAU,oCAAoC;EACtG,UAAU;GACR,aAAa,OAAO;EACtB;CACF;AACF;AAEA,SAAgB,wBAAwB,SAAmG;CACzI,MAAM,UAAqB,CAAC;CAC5B,IAAI,QAAQ,WAAW,QAAQ,QAAQ,KAAK,MAAM,IAAI,QAAQ,KAAK,EAAE,SAAS;EAAE,UAAU;EAAK,QAAQ,CAAC,QAAQ,OAAO;CAAE,EAAE,CAAC;CAC5H,IAAI,QAAQ,YAAY,QAAQ,KAAK,EAAE,UAAU;EAAE,UAAU;EAAK,QAAQ,CAAC,IAAI;CAAE,EAAE,CAAC;CACpF,IAAI,QAAQ,MAAM,QAAQ,KAAK,EAAE,QAAQ;EAAE,UAAU;EAAK,QAAQ,CAAC;CAAE,EAAE,CAAC;CACxE,IAAI,QAAQ,UAAU,QAAQ,OAAO,KAAK,MAAM,IAC9C,IAAI,QAAQ,WAAW,QAAQ,QAAQ,KAAK,EAAE,QAAQ;EAAE,UAAU;EAAK,QAAQ,CAAC;CAAE,EAAE,CAAC;MAChF,QAAQ,KAAK,EAAE,QAAQ;EAAE,UAAU;EAAK,QAAQ,CAAC,QAAQ,MAAM;CAAE,EAAE,CAAC;CAE3E,OAAO;AACT;AAEA,SAAS,gBAAgB,UAAuC;CAC9D,KAAK,MAAM,QAAQ;EAAC;EAAc;EAAyB;EAAW;CAAuB,GAAG;EAC9F,MAAM,OAAO,QAAQ,UAAU,IAAI;EACnC,IAAI,MAAM,SAAS,CAAC,KAAK,UAAU,KAAK,OAAO,YAAY,MAAM,SAAS,OAAO,KAAK;CACxF;AAEF;AAEA,eAAe,cAAc,UAAsC;CACjE,IAAI,SAAS,WAAW,KAAK,OAAO;CACpC,MAAM,OAAO,MAAM,SAAS,KAAK;CACjC,IAAI,KAAK,KAAK,MAAM,IAAI,OAAO;CAC/B,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc,KAAK;CAC5D,IAAI,YAAY,SAAS,MAAM,KAAK,YAAY,SAAS,KAAK,GAC5D,IAAI;EACF,OAAO,KAAK,MAAM,IAAI;CACxB,QAAQ;EACN,OAAO,EAAE,SAAS,oCAAoC;CACxD;CAEF,OAAO,EAAE,SAAS,KAAK;AACzB;;;ACvIA,SAAgB,WAAW,MAAiB,QAAQ,KAAkB;CACpE,MAAM,SAAS,IAAI;CACnB,MAAM,WAAW,IAAI;CACrB,IAAI,CAAC,UAAU,OAAO,KAAK,MAAM,IAAI,MAAM,IAAI,mBAAmB,6BAA6B;CAC/F,IAAI,CAAC,YAAY,SAAS,KAAK,MAAM,IAAI,MAAM,IAAI,mBAAmB,+BAA+B;CAErG,MAAM,WAAW,gBAAc,IAAI,qBAAqB;CACxD,MAAM,iBAAiB,cAAc,IAAI,2BAA2B;CACpE,OAAO;EACL,SAAS,iBAAiB,MAAM;EAChC,OAAO;EACP;EACA,YAAY,IAAI,4BAA4B;EAC5C,GAAI,iBAAiB,EAAE,eAAe,IAAI,CAAC;CAC7C;AACF;AAEA,SAAgB,iBAAiB,QAAwB;CACvD,IAAI;CACJ,IAAI;EACF,SAAS,IAAI,IAAI,OAAO,KAAK,CAAC;CAChC,QAAQ;EACN,MAAM,IAAI,mBAAmB,yCAAyC;CACxE;CACA,IAAI,OAAO,aAAa,YAAY,OAAO,aAAa,SACtD,MAAM,IAAI,mBAAmB,wCAAwC;CAEvE,OAAO,OAAO;CACd,OAAO,SAAS;CAEhB,OADwB,OAAO,SAAS,EAAE,QAAQ,QAAQ,EACnD;AACT;AAEA,SAAS,gBAAc,KAAmC;CACxD,IAAI,CAAC,OAAO,IAAI,KAAK,MAAM,IAAI,OAAO;CACtC,MAAM,aAAa,IAAI,KAAK,EAAE,YAAY;CAC1C,IAAI,eAAe,YAAY,eAAe,SAAS,OAAO;CAC9D,MAAM,IAAI,mBAAmB,+CAA+C;AAC9E;AAEA,SAAS,cAAc,KAA6C;CAClE,IAAI,CAAC,KAAK,OAAO;CACjB,MAAM,QAAQ,IAAI,KAAK;CACvB,OAAO,UAAU,KAAK,SAAY;AACpC;;;ACpDA,SAAgB,eAAa,SAA4C;CACvE,OAAO,IAAI,kBAAkB;EAC3B,QAAQ,WAAW,QAAQ,GAAG;EAC9B,GAAI,QAAQ,YAAY,EAAE,WAAW,QAAQ,UAAU,IAAI,CAAC;CAC9D,CAAC;AACH;AAEA,SAAgB,YAAY,SAAyB,OAAgB,MAAe,YAAgC;CAClH,QAAQ,OAAO,MAAM,OAAO,WAAW,OAAO,QAAQ,IAAI,iBAAiB,IAAI,WAAW,CAAC;AAC7F;;;ACfA,SAAgB,gBAAgB,SAAkB,SAA+B;CAC/E,QACG,QAAQ,UAAU,EAClB,YAAY,yCAAyC,EACrD,OAAO,UAAU,WAAW,EAC5B,OAAO,OAAO,YAAgC;EAE7C,MAAM,QAAQ,aAAa,MADR,eAAa,OAAO,EAAE,WAAW,CACrB;EAC/B,QAAQ,OAAO,MAAM,QAAQ,OAAO,WAAW,OAAO,QAAQ,IAAI,iBAAiB,IAAI,eAAe,KAAK,CAAC;CAC9G,CAAC;AACL;AAEA,SAAgB,aAAa,MAAuC;CAClE,MAAM,QAAQ,SAAS,SAAS,IAAI,GAAG,MAAM,KAAK,CAAC;CACnD,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,CAAC,MAAM,QAAQ,OAAO,QAAQ,KAAK,GAAG;EAC/C,MAAM,OAAO,MAAM,QAAQ,GAAG,IAAI,SAAS,IAAI,EAAE,IAAI,SAAS,GAAG;EACjE,IAAI,OAAO,MAAM,SAAS,UAAU,OAAO,QAAQ,KAAK;CAC1D;CACA,OAAO;AACT;;;ACtBA,SAAgB,WAAW,SAAkB,SAA+B;CAC1E,QACG,QAAQ,IAAI,EACZ,YAAY,yCAAyC,EACrD,OAAO,UAAU,WAAW,EAC5B,OAAO,OAAO,YAAgC;EAC7C,MAAM,KAAK,MAAM,eAAa,OAAO,EAAE,MAAM;EAC7C,YAAY,SAAS,IAAI,QAAQ,QAAQ,IAAI,SAAS,eAAe,EAAwC,CAAC;CAChH,CAAC;AACL;;;ACbA,SAAgB,YAAY,MAAyB,SAAoC;CACvF,MAAM,SAAS,QAAQ,KAAK,WAAW,KAAK,IAAI,OAAO,QAAQ,GAAG,KAAK,KAAK,QAAQ,KAAM,IAAgC,OAAO,EAAE,MAAM,CAAC,CAAC;CAI3I,OAAO,GAAG;EAHK,QAAQ,KAAK,QAAQ,UAAU,OAAO,OAAO,OAAO,UAAU,OAAO,MAAM,CAAC,EAAE,KAAK,IAGvF;EAFK,OAAO,KAAK,UAAU,IAAI,OAAO,KAAK,CAAC,EAAE,KAAK,IAE3C;EAAS,GADf,KAAK,KAAK,QAAQ,QAAQ,KAAK,QAAQ,UAAU,KAAM,IAAgC,OAAO,EAAE,OAAO,OAAO,UAAU,CAAC,CAAC,EAAE,KAAK,IAAI,CACnH;CAAI,EAAE,KAAK,IAAI,EAAE;AAClD;AAEA,SAAS,KAAK,OAAwB;CACpC,IAAI,UAAU,UAAa,UAAU,MAAM,OAAO;CAClD,OAAO,OAAO,KAAK;AACrB;;;ACPA,SAAgB,iBAAiB,SAAkB,SAA+B;CAChF,QACG,QAAQ,UAAU,EAClB,YAAY,mCAAmC,EAC/C,OAAO,UAAU,WAAW,EAC5B,OAAO,mBAAmB,aAAa,MAAM,EAC7C,OAAO,OAAO,YAAmD;EAChE,MAAM,WAAW,MAAM,eAAa,OAAO,EAAE,aAAa,QAAQ,aAAa,SAAY,CAAC,IAAI,EAAE,UAAU,QAAQ,SAAS,CAAC;EAC9H,YAAY,SAAS,UAAU,QAAQ,QAAQ,IAAI,SAAS,YAAY,SAAS,UAAU;GAAC;GAAM;GAAc;GAAQ;EAAM,CAAC,CAAC;CAClI,CAAC;AACL;;;ACOA,eAAsB,gBAAgB,UAA2B,CAAC,GAA4B;CAC5F,MAAM,MAAM,QAAQ,OAAO,QAAQ;CACnC,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,SAAS,IAAI;CACnB,IAAI,CAAC,UAAU,OAAO,KAAK,MAAM,IAAI,MAAM,IAAI,uBAAuB,0DAA0D;CAChI,MAAM,UAAU,iBAAiB,MAAM;CACvC,MAAM,WAAW,cAAc,IAAI,qBAAqB;CACxD,MAAM,UAAkC,EAAE,QAAQ,mBAAmB;CACrE,IAAI,IAAI,qBAAqB,IAAI,kBAAkB,KAAK,MAAM,IAC5D,QAAQ,gBAAgB,0BAA0B,UAAU,IAAI,iBAAiB;CAGnF,MAAM,UAAU,GAAG,QAAQ;CAC3B,MAAM,aAAa,IAAI,gBAAgB;CACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,QAAQ,aAAa,IAAM;CAChF,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,UAAU,SAAS;GAAE;GAAS,QAAQ,WAAW;EAAO,CAAC;CAC5E,SAAS,OAAO;EACd,MAAM,IAAI,uBAAuB,4CAA4C,IAAI,IAAI,OAAO,EAAE,KAAK,IAAI,iBAAiB,QAAQ,cAAc,MAAM,SAAS,IAAI,iBAAiB,IAAI,iBAAiB;CACzM,UAAU;EACR,aAAa,OAAO;CACtB;CAEA,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,uBAAuB,4CAA4C,IAAI,IAAI,OAAO,EAAE,KAAK,SAAS,SAAS,QAAQ;CAG/H,MAAM,OAAO,MAAM,SAAS,KAAK;CACjC,IAAI;CACJ,IAAI;EACF,OAAO,KAAK,MAAM,IAAI;CACxB,QAAQ;EACN,MAAM,IAAI,uBAAuB,4FAA4F;CAC/H;CACA,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU,MAAM,IAAI,uBAAuB,6CAA6C;CACrH,MAAM,OAAO,UAAU,QAAQ,OAAO,KAAK,SAAS,YAAY,KAAK,SAAS,OAAO,KAAK,OAAO;CACjG,MAAM,QAAQ,QAAQ,WAAW,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ;CACvF,MAAM,UAAU,QAAQ,aAAa,QAAQ,OAAO,KAAK,YAAY,WAAW,KAAK,UAAU;CAE/F,MAAM,MAAM,QAAQ,UAAU,GAAG,EAAE,WAAW,KAAK,CAAC;CACpD,MAAM,UAAU,YAAY,GAAG,KAAK,UAAU,MAAM,MAAM,CAAC,EAAE,KAAK,MAAM;CACxE,MAAM,SAAS;EAAE,YAAY,IAAI,IAAI,OAAO,EAAE;EAAM;EAAY;EAAO;CAAQ;CAC/E,QAAQ,QAAQ,MAAM,oCAAoC,OAAO,WAAW,MAAM,OAAO,WAAW,IAAI,OAAO,MAAM,GAAG,OAAO,QAAQ,IAAI;CAC3I,OAAO;AACT;AAEA,SAAS,cAAc,KAAmC;CACxD,MAAM,aAAa,KAAK,KAAK,EAAE,YAAY,KAAK;CAChD,IAAI,eAAe,MAAM,eAAe,UAAU,OAAO;CACzD,IAAI,eAAe,SAAS,OAAO;CACnC,MAAM,IAAI,uBAAuB,+CAA+C;AAClF;AAEA,IAAI,OAAO,KAAK,QAAQ,UAAU,QAAQ,KAAK,MAC7C,gBAAgB,EAAE,QAAQ,QAAQ,OAAO,CAAC,EAAE,OAAO,UAAmB;CACpE,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;CACzD,QAAQ,OAAO,MAAM,GAAG,cAAc,SAAS,QAAQ,IAAI,iBAAiB,EAAE,GAAG;CACjF,QAAQ,WAAW;AACrB,CAAC;;;AC7EH,SAAgB,aAAa,SAAkB,SAA+B;CAE5E,AADa,QAAQ,QAAQ,MAAM,EAAE,YAAY,wBACjD,EAAK,QAAQ,MAAM,EAChB,YAAY,+CAA+C,EAC3D,OAAO,YAAY;EAClB,MAAM,gBAAgB;GAAE,KAAK,QAAQ;GAAK,QAAQ,QAAQ;GAAQ,GAAI,QAAQ,YAAY,EAAE,WAAW,QAAQ,UAAU,IAAI,CAAC;EAAG,CAAC;CACpI,CAAC;AACL;;;ACKA,SAAgB,qBAAqB,SAAkB,SAA+B;CACpF,MAAM,KAAK,QAAQ,QAAQ,IAAI,EAAE,YAAY,uBAAuB;CAEpE,GAAG,QAAQ,KAAK,EACb,YAAY,sBAAsB,EAClC,SAAS,QAAQ,iBAAiB,EAClC,OAAO,UAAU,sBAAsB,EACvC,OAAO,cAAc,2BAA2B,EAChD,OAAO,OAAO,IAAY,YAAmD;EAC5E,MAAM,YAAY,QAAQ,EAAE;EAC5B,MAAM,SAAS,eAAa,OAAO;EACnC,IAAI,QAAQ,SAAS;GACnB,QAAQ,OAAO,MAAM,WAAW,MAAM,OAAO,kBAAkB,SAAS,GAAG,QAAQ,IAAI,iBAAiB,CAAC;GACzG;EACF;EACA,MAAM,cAAc,MAAM,OAAO,eAAe,SAAS;EACzD,YAAY,SAAS,aAAa,QAAQ,QAAQ,IAAI,SAAS,eAAe,WAAiD,CAAC;CAClI,CAAC;CAEH,GAAG,QAAQ,QAAQ,EAChB,YAAY,sBAAsB,EAClC,OAAO,UAAU,WAAW,EAC5B,OAAO,gCAAgC,0BAA0B,EACjE,OAAO,oBAAoB,uBAAuB,EAClD,OAAO,iBAAiB,wBAAwB,EAChD,OAAO,yBAAyB,oBAAoB,EACpD,OAAO,mBAAmB,aAAa,MAAM,EAC7C,OAAO,OAAO,YAA2B;EACxC,MAAM,SAAS,MAAM,eAAa,OAAO,EAAE,mBAAmB,OAAO;EACrE,YAAY,SAAS,QAAQ,QAAQ,QAAQ,IAAI,SAAS,YAAY,OAAO,UAAU;GAAC;GAAM;GAAW;GAAU;GAAY;GAAW;EAAM,CAAC,CAAC;CACpJ,CAAC;CAEH,GAAG,QAAQ,MAAM,EACd,YAAY,4DAA4D,EACxE,OAAO,UAAU,WAAW,EAC5B,OAAO,gCAAgC,0BAA0B,EACjE,OAAO,mBAAmB,aAAa,MAAM,EAC7C,OAAO,OAAO,YAAkE;EAC/E,MAAM,SAAS,MAAM,eAAa,OAAO,EAAE,KAAK,OAAO;EACvD,YAAY,SAAS,QAAQ,QAAQ,QAAQ,IAAI,SAAS,YAAY,OAAO,UAAU;GAAC;GAAM;GAAW;GAAU;GAAY;GAAW;EAAM,CAAC,CAAC;CACpJ,CAAC;CAEH,GAAG,QAAQ,SAAS,EACjB,YAAY,qEAAqE,EACjF,SAAS,QAAQ,iBAAiB,EAClC,SAAS,aAAa,iBAAiB,EACvC,OAAO,aAAa,yCAAyC,EAC7D,OAAO,UAAU,WAAW,EAC5B,OAAO,OAAO,IAAY,SAAiB,YAAkD;EAC5F,MAAM,SAAS,MAAM,eAAa,OAAO,EAAE,mBAAmB,QAAQ,EAAE,GAAG,SAAS,QAAQ,QAAQ,MAAM,CAAC;EAC3G,YAAY,SAAS,QAAQ,QAAQ,QAAQ,IAAI,SAAS,eAAe,MAA4C,CAAC;CACxH,CAAC;AACL;AAEA,SAAS,QAAQ,IAAoB;CACnC,MAAM,SAAS,OAAO,EAAE;CACxB,IAAI,CAAC,OAAO,UAAU,MAAM,KAAK,SAAS,GAAG,MAAM,IAAI,WAAW,8CAA8C,WAAW,UAAU;CACrI,OAAO;AACT;;;AC9DA,SAAgB,aAAa,SAAkC;CAC7D,MAAM,UAAU,IAAI,QAAQ;CAC5B,QACG,KAAK,OAAO,EACZ,YAAY,sDAAsD,EAClE,QAAQ,OAAO,EACf,mBAAmB,EACnB,gBAAgB;EACf,WAAW,SAAS,QAAQ,OAAO,MAAM,IAAI;EAC7C,WAAW,SAAS,QAAQ,OAAO,MAAM,IAAI;CAC/C,CAAC;CACH,WAAW,SAAS,OAAO;CAC3B,gBAAgB,SAAS,OAAO;CAChC,iBAAiB,SAAS,OAAO;CACjC,qBAAqB,SAAS,OAAO;CACrC,aAAa,SAAS,OAAO;CAC7B,OAAO;AACT;AAEA,eAAsB,IAAI,MAAyB,SAA0C;CAC3F,IAAI;EACF,MAAM,aAAa,OAAO,EAAE,WAAW,MAAM,EAAE,MAAM,OAAO,CAAC;EAC7D,OAAO,WAAW;CACpB,SAAS,OAAO;EACd,MAAM,aAAa,aAAa,KAAK;EAErC,IADkB,KAAK,SAAS,QAC5B,GAAW,QAAQ,OAAO,MAAM,WAAW;GAAE,OAAO,WAAW;GAAS,UAAU,WAAW;EAAS,GAAG,QAAQ,IAAI,iBAAiB,CAAC;OACtI,QAAQ,OAAO,MAAM,GAAG,cAAc,WAAW,SAAS,QAAQ,IAAI,iBAAiB,EAAE,GAAG;EACjG,OAAO,WAAW;CACpB;AACF;AAEA,IAAI,OAAO,KAAK,QAAQ,UAAU,QAAQ,KAAK,MAAM;CACnD,MAAM,WAAW,MAAM,IAAI,QAAQ,MAAM;EAAE,QAAQ,QAAQ;EAAQ,QAAQ,QAAQ;EAAQ,KAAK,QAAQ;CAAI,CAAC;CAC7G,QAAQ,WAAW;AACrB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opctl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Conservative local CLI bridge for OpenProject API v3",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opctl": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"dev": "tsx src/cli.ts",
|
|
14
|
+
"build": "npm run typecheck && vite build",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"openapi:pull": "tsx scripts/pull-openapi-spec.ts",
|
|
18
|
+
"openapi:generate": "tsx scripts/generate-openapi-types.ts",
|
|
19
|
+
"openapi:update": "npm run openapi:pull && npm run openapi:generate"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"openproject",
|
|
23
|
+
"cli"
|
|
24
|
+
],
|
|
25
|
+
"author": "",
|
|
26
|
+
"license": "ISC",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "latest",
|
|
29
|
+
"openapi-fetch": "latest"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "latest",
|
|
33
|
+
"openapi-typescript": "latest",
|
|
34
|
+
"tsx": "latest",
|
|
35
|
+
"typescript": "latest",
|
|
36
|
+
"vite": "latest",
|
|
37
|
+
"vitest": "latest",
|
|
38
|
+
"yaml": "^2.9.0"
|
|
39
|
+
},
|
|
40
|
+
"packageManager": "pnpm@11.1.3"
|
|
41
|
+
}
|