@aictiq/cli 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/index.js +3262 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var ExitCode = {
|
|
5
|
+
Ok: 0,
|
|
6
|
+
Error: 1,
|
|
7
|
+
Validation: 2,
|
|
8
|
+
Conflict: 3,
|
|
9
|
+
NotFound: 4,
|
|
10
|
+
Auth: 5
|
|
11
|
+
};
|
|
12
|
+
var CliError = class extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
problem;
|
|
15
|
+
constructor(message3, exitCode = ExitCode.Error, problem) {
|
|
16
|
+
super(message3);
|
|
17
|
+
this.name = "CliError";
|
|
18
|
+
this.exitCode = exitCode;
|
|
19
|
+
this.problem = problem;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function exitCodeForStatus(status) {
|
|
23
|
+
if (status === 401) return ExitCode.Auth;
|
|
24
|
+
if (status === 403) return ExitCode.Auth;
|
|
25
|
+
if (status === 404) return ExitCode.NotFound;
|
|
26
|
+
if (status === 409) return ExitCode.Conflict;
|
|
27
|
+
if (status >= 400 && status < 500) return ExitCode.Validation;
|
|
28
|
+
return ExitCode.Error;
|
|
29
|
+
}
|
|
30
|
+
function formatProblem(problem, fallback) {
|
|
31
|
+
const lines = [problem?.title?.trim() || fallback];
|
|
32
|
+
if (problem?.detail && problem.detail !== problem.title) lines.push(problem.detail);
|
|
33
|
+
for (const [field, messages] of Object.entries(problem?.errors ?? {})) {
|
|
34
|
+
for (const message3 of messages) lines.push(` ${field}: ${message3}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/program.ts
|
|
40
|
+
import { Command as Command10 } from "commander";
|
|
41
|
+
|
|
42
|
+
// src/commands/attachment.ts
|
|
43
|
+
import { writeFile } from "fs/promises";
|
|
44
|
+
import { Command } from "commander";
|
|
45
|
+
|
|
46
|
+
// src/api/client.ts
|
|
47
|
+
var AictiqClient = class {
|
|
48
|
+
baseUrl;
|
|
49
|
+
token;
|
|
50
|
+
doFetch;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
53
|
+
this.token = options.token;
|
|
54
|
+
this.doFetch = options.fetch ?? fetch;
|
|
55
|
+
}
|
|
56
|
+
async request(method, path, options = {}) {
|
|
57
|
+
const url = new URL(this.baseUrl + expand(path, options.path ?? {}));
|
|
58
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
59
|
+
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
60
|
+
}
|
|
61
|
+
const headers = {
|
|
62
|
+
Authorization: `Bearer ${this.token}`,
|
|
63
|
+
Accept: "application/json",
|
|
64
|
+
"User-Agent": "aictiq-cli"
|
|
65
|
+
};
|
|
66
|
+
if (options.body !== void 0) headers["Content-Type"] = "application/json";
|
|
67
|
+
let response;
|
|
68
|
+
try {
|
|
69
|
+
response = await this.doFetch(url, {
|
|
70
|
+
method: method.toUpperCase(),
|
|
71
|
+
headers,
|
|
72
|
+
...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
|
|
73
|
+
});
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
throw new CliError(
|
|
76
|
+
`Cannot reach ${this.baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
77
|
+
ExitCode.Error
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (response.status === 204) return void 0;
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
const payload = text.length === 0 ? void 0 : safeJson(text);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const problem = isProblem(payload) ? payload : void 0;
|
|
85
|
+
throw new CliError(
|
|
86
|
+
formatProblem(problem, `${response.status} ${response.statusText}`),
|
|
87
|
+
exitCodeForStatus(response.status),
|
|
88
|
+
problem
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return payload;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Downloads an authenticated response without trying to interpret it as JSON. Attachments
|
|
95
|
+
* deliberately use the normal API route (rather than a presigned object-store URL), so a
|
|
96
|
+
* PAT's organization and project visibility checks remain in force.
|
|
97
|
+
*/
|
|
98
|
+
async download(path, options = {}) {
|
|
99
|
+
const url = new URL(this.baseUrl + expand(path, options.path ?? {}));
|
|
100
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
101
|
+
if (value !== void 0 && value !== "") url.searchParams.set(key, String(value));
|
|
102
|
+
}
|
|
103
|
+
let response;
|
|
104
|
+
try {
|
|
105
|
+
response = await this.doFetch(url, {
|
|
106
|
+
method: "GET",
|
|
107
|
+
headers: {
|
|
108
|
+
Authorization: `Bearer ${this.token}`,
|
|
109
|
+
Accept: "*/*",
|
|
110
|
+
"User-Agent": "aictiq-cli"
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
throw new CliError(
|
|
115
|
+
`Cannot reach ${this.baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
116
|
+
ExitCode.Error
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const text = await response.text();
|
|
121
|
+
const payload = text.length === 0 ? void 0 : safeJson(text);
|
|
122
|
+
const problem = isProblem(payload) ? payload : void 0;
|
|
123
|
+
throw new CliError(
|
|
124
|
+
formatProblem(problem, `${response.status} ${response.statusText}`),
|
|
125
|
+
exitCodeForStatus(response.status),
|
|
126
|
+
problem
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
function expand(template, values) {
|
|
133
|
+
return template.replace(/\{(\w+)\}/g, (_match, name) => {
|
|
134
|
+
const value = values[name];
|
|
135
|
+
if (value === void 0) throw new Error(`Missing path parameter "${name}" for ${template}`);
|
|
136
|
+
return encodeURIComponent(value);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function safeJson(text) {
|
|
140
|
+
try {
|
|
141
|
+
return JSON.parse(text);
|
|
142
|
+
} catch {
|
|
143
|
+
return { title: text.slice(0, 500) };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isProblem(value) {
|
|
147
|
+
return typeof value === "object" && value !== null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/config.ts
|
|
151
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
152
|
+
import { homedir } from "os";
|
|
153
|
+
import { dirname, join } from "path";
|
|
154
|
+
function configPath(env = process.env) {
|
|
155
|
+
const base = env.AICTIQ_CONFIG_HOME ?? env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
156
|
+
return join(base, "aictiq", "config.json");
|
|
157
|
+
}
|
|
158
|
+
function readConfig(path = configPath()) {
|
|
159
|
+
if (!existsSync(path)) return {};
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
162
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
163
|
+
const { url, token, org } = parsed;
|
|
164
|
+
return {
|
|
165
|
+
...typeof url === "string" ? { url } : {},
|
|
166
|
+
...typeof token === "string" ? { token } : {},
|
|
167
|
+
...typeof org === "string" ? { org } : {}
|
|
168
|
+
};
|
|
169
|
+
} catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function writeConfig(config, path = configPath()) {
|
|
174
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
175
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}
|
|
176
|
+
`, { mode: 384 });
|
|
177
|
+
chmodSync(path, 384);
|
|
178
|
+
}
|
|
179
|
+
function clearConfig(path = configPath()) {
|
|
180
|
+
rmSync(path, { force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/context.ts
|
|
184
|
+
function resolveSettings(options, env = process.env, stored = readConfig()) {
|
|
185
|
+
return {
|
|
186
|
+
url: options.url ?? env.AICTIQ_URL ?? stored.url,
|
|
187
|
+
token: options.token ?? env.AICTIQ_TOKEN ?? stored.token,
|
|
188
|
+
org: options.org ?? env.AICTIQ_ORG ?? stored.org
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function createContext(options) {
|
|
192
|
+
const settings = resolveSettings(options);
|
|
193
|
+
if (!settings.url) {
|
|
194
|
+
throw new CliError("No Aictiq URL. Run `aictiq auth login --url <url>`.", ExitCode.Auth);
|
|
195
|
+
}
|
|
196
|
+
if (!settings.token) {
|
|
197
|
+
throw new CliError("No personal access token. Run `aictiq auth login`.", ExitCode.Auth);
|
|
198
|
+
}
|
|
199
|
+
const client = new AictiqClient({ baseUrl: settings.url, token: settings.token });
|
|
200
|
+
let resolved;
|
|
201
|
+
return {
|
|
202
|
+
client,
|
|
203
|
+
json: options.json === true,
|
|
204
|
+
org() {
|
|
205
|
+
if (settings.org) return Promise.resolve(settings.org);
|
|
206
|
+
resolved ??= resolveOrg(client);
|
|
207
|
+
return resolved;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
async function resolveOrg(client) {
|
|
212
|
+
const orgs = await client.request(
|
|
213
|
+
"get",
|
|
214
|
+
"/api/v1/orgs"
|
|
215
|
+
);
|
|
216
|
+
if (orgs.length === 0) {
|
|
217
|
+
throw new CliError("This account is not a member of any organization.", ExitCode.NotFound);
|
|
218
|
+
}
|
|
219
|
+
if (orgs.length > 1) {
|
|
220
|
+
const slugs = orgs.map((o) => o.slug).join(", ");
|
|
221
|
+
throw new CliError(
|
|
222
|
+
`This token can see several organizations (${slugs}). Pass --org, set AICTIQ_ORG, or run \`aictiq auth login --org <slug>\`.`,
|
|
223
|
+
ExitCode.Validation
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return orgs[0].slug;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/commands/attachment.ts
|
|
230
|
+
function attachmentCommand(globals) {
|
|
231
|
+
const attachment = new Command("attachment").description("Download an attachment");
|
|
232
|
+
attachment.command("get").description("Write an attachment to stdout, or to --output").argument("<id>", "Attachment UUID").option("-o, --output <path>", "Write to this path instead of stdout").action(async (id, options) => {
|
|
233
|
+
const ctx = createContext(globals());
|
|
234
|
+
const bytes = await ctx.client.download(
|
|
235
|
+
"/api/v1/orgs/{orgSlug}/attachments/{attachmentId}/download",
|
|
236
|
+
{ path: { orgSlug: await ctx.org(), attachmentId: id } }
|
|
237
|
+
);
|
|
238
|
+
if (options.output) {
|
|
239
|
+
await writeFile(options.output, bytes, { mode: 384 });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
process.stdout.write(bytes);
|
|
243
|
+
});
|
|
244
|
+
return attachment;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/commands/auth.ts
|
|
248
|
+
import { Command as Command2 } from "commander";
|
|
249
|
+
|
|
250
|
+
// src/output.ts
|
|
251
|
+
function renderTable(rows, columns) {
|
|
252
|
+
const cells = rows.map((row) => columns.map((column) => column.value(row) ?? ""));
|
|
253
|
+
const widths = columns.map(
|
|
254
|
+
(column, index) => Math.max(column.header.length, ...cells.map((row) => row[index]?.length ?? 0), 0)
|
|
255
|
+
);
|
|
256
|
+
const line = (values) => values.map(
|
|
257
|
+
(value, index) => index === values.length - 1 ? columns[index]?.align === "right" ? value.padStart(widths[index]) : value : columns[index]?.align === "right" ? value.padStart(widths[index]) : value.padEnd(widths[index])
|
|
258
|
+
).join(" ").trimEnd();
|
|
259
|
+
return [line(columns.map((c) => c.header)), ...cells.map(line)].join("\n");
|
|
260
|
+
}
|
|
261
|
+
function renderFields(fields) {
|
|
262
|
+
const present = fields.filter((f) => f[1] !== void 0 && f[1] !== "");
|
|
263
|
+
const width = Math.max(0, ...present.map(([label]) => label.length));
|
|
264
|
+
return present.map(([label, value]) => `${label.padEnd(width)} ${value}`).join("\n");
|
|
265
|
+
}
|
|
266
|
+
function print(text) {
|
|
267
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
268
|
+
`);
|
|
269
|
+
}
|
|
270
|
+
function printJson(value) {
|
|
271
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
274
|
+
function formatDate(value) {
|
|
275
|
+
if (!value) return "";
|
|
276
|
+
const date = new Date(value);
|
|
277
|
+
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString().slice(0, 10);
|
|
278
|
+
}
|
|
279
|
+
function formatNumber(value) {
|
|
280
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
281
|
+
}
|
|
282
|
+
function formatDateTime(value) {
|
|
283
|
+
if (!value) return "";
|
|
284
|
+
const date = new Date(value);
|
|
285
|
+
return Number.isNaN(date.getTime()) ? String(value) : `${date.toISOString().slice(0, 19).replace("T", " ")}Z`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/prompt.ts
|
|
289
|
+
import { createInterface } from "readline";
|
|
290
|
+
async function promptSecret(question) {
|
|
291
|
+
if (!process.stdin.isTTY) {
|
|
292
|
+
const chunks = [];
|
|
293
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
294
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
295
|
+
}
|
|
296
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
297
|
+
try {
|
|
298
|
+
process.stdout.write(question);
|
|
299
|
+
const muted = rl;
|
|
300
|
+
const original = muted.output.write.bind(muted.output);
|
|
301
|
+
muted.output.write = (() => true);
|
|
302
|
+
const answer = await new Promise((resolve3) => rl.question("", resolve3));
|
|
303
|
+
muted.output.write = original;
|
|
304
|
+
process.stdout.write("\n");
|
|
305
|
+
return answer.trim();
|
|
306
|
+
} finally {
|
|
307
|
+
rl.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/commands/auth.ts
|
|
312
|
+
function authCommand(globals) {
|
|
313
|
+
const auth = new Command2("auth").description(
|
|
314
|
+
"Sign in to a Aictiq instance and inspect the session"
|
|
315
|
+
);
|
|
316
|
+
auth.command("login").description("Store a personal access token for an instance").option("--url <url>", "Base URL of the Aictiq instance").option("--token <token>", "Personal access token (aiq_\u2026); read from stdin when omitted").option("--org <slug>", "Default organization slug for scoped commands").action(async (options) => {
|
|
317
|
+
const global = globals();
|
|
318
|
+
const url = options.url ?? global.url ?? process.env.AICTIQ_URL ?? readConfig().url;
|
|
319
|
+
if (!url)
|
|
320
|
+
throw new CliError(
|
|
321
|
+
"Pass --url with the address of your Aictiq instance.",
|
|
322
|
+
ExitCode.Validation
|
|
323
|
+
);
|
|
324
|
+
const token = options.token ?? global.token ?? process.env.AICTIQ_TOKEN ?? await promptSecret("Personal access token: ");
|
|
325
|
+
if (!token) throw new CliError("No token supplied.", ExitCode.Validation);
|
|
326
|
+
const client = new AictiqClient({ baseUrl: url, token });
|
|
327
|
+
const session = await client.request(
|
|
328
|
+
"get",
|
|
329
|
+
"/api/v1/auth/session"
|
|
330
|
+
);
|
|
331
|
+
const orgs = await client.request(
|
|
332
|
+
"get",
|
|
333
|
+
"/api/v1/orgs"
|
|
334
|
+
);
|
|
335
|
+
const org = options.org ?? global.org ?? (orgs.length === 1 ? orgs[0].slug : void 0);
|
|
336
|
+
writeConfig({ url: url.replace(/\/+$/, ""), token, ...org ? { org } : {} });
|
|
337
|
+
if (global.json) {
|
|
338
|
+
printJson({
|
|
339
|
+
url,
|
|
340
|
+
org: org ?? null,
|
|
341
|
+
user: session,
|
|
342
|
+
organizations: orgs,
|
|
343
|
+
configPath: configPath()
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
print(`Signed in to ${url} as ${session.email}.`);
|
|
348
|
+
if (org) print(`Default organization: ${org}`);
|
|
349
|
+
else if (orgs.length > 1)
|
|
350
|
+
print(`Several organizations are visible; pass --org next time or set AICTIQ_ORG.`);
|
|
351
|
+
print(`Token stored in ${configPath()} (0600).`);
|
|
352
|
+
});
|
|
353
|
+
auth.command("status").description("Show the configured instance, identity and organizations").action(async () => {
|
|
354
|
+
const global = globals();
|
|
355
|
+
const settings = resolveSettings(global);
|
|
356
|
+
if (!settings.url || !settings.token) {
|
|
357
|
+
if (global.json) {
|
|
358
|
+
printJson({ authenticated: false, url: settings.url ?? null, configPath: configPath() });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
throw new CliError("Not signed in. Run `aictiq auth login --url <url>`.", ExitCode.Auth);
|
|
362
|
+
}
|
|
363
|
+
const client = new AictiqClient({ baseUrl: settings.url, token: settings.token });
|
|
364
|
+
const session = await client.request(
|
|
365
|
+
"get",
|
|
366
|
+
"/api/v1/auth/session"
|
|
367
|
+
);
|
|
368
|
+
const orgs = await client.request(
|
|
369
|
+
"get",
|
|
370
|
+
"/api/v1/orgs"
|
|
371
|
+
);
|
|
372
|
+
if (global.json) {
|
|
373
|
+
printJson({
|
|
374
|
+
authenticated: true,
|
|
375
|
+
url: settings.url,
|
|
376
|
+
org: settings.org ?? null,
|
|
377
|
+
user: session,
|
|
378
|
+
organizations: orgs,
|
|
379
|
+
configPath: configPath()
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
print(
|
|
384
|
+
renderFields([
|
|
385
|
+
["URL", settings.url],
|
|
386
|
+
["User", `${session.email} (${session.id})`],
|
|
387
|
+
["Organization", settings.org ?? "(not set)"],
|
|
388
|
+
["Config", configPath()]
|
|
389
|
+
])
|
|
390
|
+
);
|
|
391
|
+
if (orgs.length > 0) {
|
|
392
|
+
print("");
|
|
393
|
+
print(
|
|
394
|
+
renderTable(orgs, [
|
|
395
|
+
{ header: "SLUG", value: (o) => o.slug },
|
|
396
|
+
{ header: "NAME", value: (o) => o.name },
|
|
397
|
+
{ header: "ROLE", value: (o) => o.role }
|
|
398
|
+
])
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
auth.command("logout").description("Delete the stored token").action(() => {
|
|
403
|
+
clearConfig();
|
|
404
|
+
if (globals().json) printJson({ removed: configPath() });
|
|
405
|
+
else print(`Removed ${configPath()}.`);
|
|
406
|
+
});
|
|
407
|
+
return auth;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/commands/item.ts
|
|
411
|
+
import { Command as Command3, Option } from "commander";
|
|
412
|
+
|
|
413
|
+
// src/resolve.ts
|
|
414
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
415
|
+
function projectKeyOf(itemKey) {
|
|
416
|
+
const normalized = itemKey.trim().toUpperCase();
|
|
417
|
+
const dash = normalized.lastIndexOf("-");
|
|
418
|
+
if (dash <= 0) {
|
|
419
|
+
throw new CliError(
|
|
420
|
+
`"${itemKey}" is not a work item key (expected e.g. ACME-123).`,
|
|
421
|
+
ExitCode.Validation
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
return normalized.slice(0, dash);
|
|
425
|
+
}
|
|
426
|
+
function normalizeItemKey(itemKey) {
|
|
427
|
+
return itemKey.trim().toUpperCase();
|
|
428
|
+
}
|
|
429
|
+
async function getWorkflow(ctx, projectKey) {
|
|
430
|
+
const workflows = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/workflows", {
|
|
431
|
+
path: { orgSlug: await ctx.org(), projectKey }
|
|
432
|
+
});
|
|
433
|
+
const workflow = workflows.find((w) => w.isDefault) ?? workflows[0];
|
|
434
|
+
if (!workflow) throw new CliError(`Project ${projectKey} has no workflow.`, ExitCode.NotFound);
|
|
435
|
+
return workflow;
|
|
436
|
+
}
|
|
437
|
+
function findState(workflow, name) {
|
|
438
|
+
const wanted = name.trim().toLowerCase();
|
|
439
|
+
const state = workflow.states.find((s) => s.name.toLowerCase() === wanted);
|
|
440
|
+
if (!state) {
|
|
441
|
+
const names = workflow.states.map((s) => s.name).join(", ");
|
|
442
|
+
throw new CliError(`No workflow state named "${name}". States: ${names}.`, ExitCode.Validation);
|
|
443
|
+
}
|
|
444
|
+
return state;
|
|
445
|
+
}
|
|
446
|
+
async function getLabels(ctx, projectKey) {
|
|
447
|
+
return ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/labels", {
|
|
448
|
+
path: { orgSlug: await ctx.org(), projectKey }
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
async function getTeams(ctx, projectKey) {
|
|
452
|
+
return ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/teams", {
|
|
453
|
+
path: { orgSlug: await ctx.org(), projectKey }
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
async function getItem(ctx, itemKey) {
|
|
457
|
+
return ctx.client.request(
|
|
458
|
+
"get",
|
|
459
|
+
"/api/v1/orgs/{orgSlug}/items/{itemKey}",
|
|
460
|
+
{ path: { orgSlug: await ctx.org(), itemKey: normalizeItemKey(itemKey) } }
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
function resolveLabelIds(spec, available, current) {
|
|
464
|
+
const tokens = spec.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
465
|
+
const adjusting = tokens.some((t) => t.startsWith("+") || t.startsWith("-"));
|
|
466
|
+
const replacing = tokens.some((t) => !t.startsWith("+") && !t.startsWith("-"));
|
|
467
|
+
if (adjusting && replacing) {
|
|
468
|
+
throw new CliError(
|
|
469
|
+
"Use either a plain label list (replaces all) or +/- adjustments, not both.",
|
|
470
|
+
ExitCode.Validation
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const byName = new Map(available.map((label) => [label.name.toLowerCase(), label]));
|
|
474
|
+
const lookup = (name) => {
|
|
475
|
+
const label = byName.get(name.toLowerCase());
|
|
476
|
+
if (!label) {
|
|
477
|
+
throw new CliError(
|
|
478
|
+
`No label "${name}" in this project. Labels: ${available.map((l) => l.name).join(", ") || "(none)"}.`,
|
|
479
|
+
ExitCode.Validation
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return label.id;
|
|
483
|
+
};
|
|
484
|
+
if (!adjusting) return tokens.map(lookup);
|
|
485
|
+
const ids = new Set(current.map((label) => label.id));
|
|
486
|
+
for (const token of tokens) {
|
|
487
|
+
const id = lookup(token.slice(1));
|
|
488
|
+
if (token.startsWith("+")) ids.add(id);
|
|
489
|
+
else ids.delete(id);
|
|
490
|
+
}
|
|
491
|
+
return [...ids];
|
|
492
|
+
}
|
|
493
|
+
async function readBody(message3, file) {
|
|
494
|
+
if (message3 !== void 0 && file !== void 0) {
|
|
495
|
+
throw new CliError("Pass either -m or --body-file, not both.", ExitCode.Validation);
|
|
496
|
+
}
|
|
497
|
+
if (message3 !== void 0) return message3;
|
|
498
|
+
if (file === void 0) return void 0;
|
|
499
|
+
if (file === "-") {
|
|
500
|
+
const chunks = [];
|
|
501
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
502
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
return readFileSync2(file, "utf8");
|
|
506
|
+
} catch (cause) {
|
|
507
|
+
throw new CliError(
|
|
508
|
+
`Cannot read ${file}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
509
|
+
ExitCode.Validation
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function branchName(item, maxWords = 6) {
|
|
514
|
+
const slug = item.title.toLowerCase().normalize("NFKD").replace(/[^\da-z]+/g, " ").trim().split(/\s+/).filter((word) => word.length > 0).slice(0, maxWords).join("-");
|
|
515
|
+
return slug ? `${item.key.toLowerCase()}-${slug}` : item.key.toLowerCase();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/commands/item.ts
|
|
519
|
+
var types = ["epic", "feature", "story", "task", "bug"];
|
|
520
|
+
var priorities = ["none", "low", "medium", "high", "urgent"];
|
|
521
|
+
function itemCommand(globals) {
|
|
522
|
+
const item = new Command3("item").description("Work items");
|
|
523
|
+
item.command("list").description("List work items in a project").requiredOption("-p, --project <key>", "Project key, e.g. ACME").option(
|
|
524
|
+
"-f, --filter <expression>",
|
|
525
|
+
'Item filter, e.g. "state:active assignee:@me label:backend"'
|
|
526
|
+
).option("-q, --query <text>", "Full-text search within the project").option("-s, --sort <field>", "Sort expression accepted by the API").option("--page <n>", "Page number", "1").option("--page-size <n>", "Items per page (max 100)", "25").action(
|
|
527
|
+
async (options) => {
|
|
528
|
+
const ctx = createContext(globals());
|
|
529
|
+
const page = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/items", {
|
|
530
|
+
path: { orgSlug: await ctx.org(), projectKey: options.project.toUpperCase() },
|
|
531
|
+
query: {
|
|
532
|
+
filter: options.filter,
|
|
533
|
+
q: options.query,
|
|
534
|
+
sort: options.sort,
|
|
535
|
+
page: options.page,
|
|
536
|
+
pageSize: options.pageSize
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
if (ctx.json) return printJson(page);
|
|
540
|
+
if (page.items.length === 0) return print("No items match.");
|
|
541
|
+
print(
|
|
542
|
+
renderTable(page.items, [
|
|
543
|
+
{ header: "KEY", value: (i) => i.key },
|
|
544
|
+
{ header: "TYPE", value: (i) => i.type },
|
|
545
|
+
{ header: "STATE", value: (i) => i.stateCategory },
|
|
546
|
+
{ header: "PRIORITY", value: (i) => i.priority },
|
|
547
|
+
{ header: "PTS", value: (i) => formatNumber(i.points), align: "right" },
|
|
548
|
+
{ header: "TITLE", value: (i) => i.title }
|
|
549
|
+
])
|
|
550
|
+
);
|
|
551
|
+
print("");
|
|
552
|
+
print(`Page ${page.page} of ${Math.max(1, page.totalPages)} \xB7 ${page.totalCount} items`);
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
item.command("view").description("Show one work item").argument("<key>", "Item key, e.g. ACME-123").option("--comments", "Include the comment thread").option("--links", "Include external links").action(async (key, options) => {
|
|
556
|
+
const ctx = createContext(globals());
|
|
557
|
+
const orgSlug = await ctx.org();
|
|
558
|
+
const itemKey = normalizeItemKey(key);
|
|
559
|
+
const found = await getItem(ctx, itemKey);
|
|
560
|
+
const comments = options.comments ? await listComments(ctx, orgSlug, itemKey) : void 0;
|
|
561
|
+
const links = options.links ? await listLinks(ctx, orgSlug, itemKey) : void 0;
|
|
562
|
+
if (ctx.json) {
|
|
563
|
+
return printJson({
|
|
564
|
+
...found,
|
|
565
|
+
...comments ? { comments } : {},
|
|
566
|
+
...links ? { links } : {}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const state = await stateName(ctx, found);
|
|
570
|
+
print(`${found.key} ${found.title}`);
|
|
571
|
+
print("");
|
|
572
|
+
print(
|
|
573
|
+
renderFields([
|
|
574
|
+
["Type", found.type],
|
|
575
|
+
["State", `${state} (${found.stateCategory})`],
|
|
576
|
+
["Priority", found.priority],
|
|
577
|
+
["Assignee", found.assigneeId ?? "(unassigned)"],
|
|
578
|
+
["Claimed by", found.claimedBy ?? void 0],
|
|
579
|
+
["Points", formatNumber(found.points)],
|
|
580
|
+
["Remaining hours", formatNumber(found.remainingHours)],
|
|
581
|
+
["Labels", found.labels.map((l) => l.name).join(", ")],
|
|
582
|
+
["Blocked", found.blocked ? "yes" : ""],
|
|
583
|
+
["Updated", formatDate(found.updatedAt)],
|
|
584
|
+
["Version", String(found.version)]
|
|
585
|
+
])
|
|
586
|
+
);
|
|
587
|
+
if (found.descriptionMarkdown.trim().length > 0) {
|
|
588
|
+
print("");
|
|
589
|
+
print(found.descriptionMarkdown.trim());
|
|
590
|
+
}
|
|
591
|
+
if (links && links.length > 0) {
|
|
592
|
+
print("");
|
|
593
|
+
print(
|
|
594
|
+
renderTable(links, [
|
|
595
|
+
{ header: "KIND", value: (l) => l.kind },
|
|
596
|
+
{ header: "URL", value: (l) => l.url },
|
|
597
|
+
{ header: "TITLE", value: (l) => l.title ?? "" }
|
|
598
|
+
])
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
if (comments) {
|
|
602
|
+
print("");
|
|
603
|
+
for (const comment of comments) {
|
|
604
|
+
if (comment.deletedAt) continue;
|
|
605
|
+
print(
|
|
606
|
+
`--- ${comment.author.displayName}${comment.author.isAgent ? " [agent]" : ""} \xB7 ${formatDate(comment.createdAt)}`
|
|
607
|
+
);
|
|
608
|
+
print(comment.bodyMarkdown.trim());
|
|
609
|
+
print("");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
item.command("create").description("Create a work item").requiredOption("-p, --project <key>", "Project key, e.g. ACME").addOption(new Option("-t, --type <type>", "Item type").choices(types).default("task")).requiredOption("--title <title>", "Item title").option("-m, --body <markdown>", "Description in Markdown").option("--body-file <path>", "Read the description from a file, or - for stdin").addOption(new Option("--priority <priority>", "Priority").choices(priorities)).option("--parent <key>", "Parent item key, e.g. ACME-100").option("--team <key>", "Team key or name").option("--labels <names>", "Comma-separated label names").option("--points <n>", "Story points").option("--estimate <hours>", "Original estimate in hours").action(
|
|
614
|
+
async (options) => {
|
|
615
|
+
const ctx = createContext(globals());
|
|
616
|
+
const projectKey = options.project.toUpperCase();
|
|
617
|
+
const description = await readBody(options.body, options.bodyFile);
|
|
618
|
+
const parent = options.parent ? await getItem(ctx, options.parent) : void 0;
|
|
619
|
+
const teamId = options.team ? await resolveTeamId(ctx, projectKey, options.team) : void 0;
|
|
620
|
+
const labelIds = options.labels ? resolveLabelIds(options.labels, await getLabels(ctx, projectKey), []) : void 0;
|
|
621
|
+
const created = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/items", {
|
|
622
|
+
path: { orgSlug: await ctx.org(), projectKey },
|
|
623
|
+
body: {
|
|
624
|
+
type: options.type,
|
|
625
|
+
title: options.title,
|
|
626
|
+
...description === void 0 ? {} : { descriptionMarkdown: description },
|
|
627
|
+
...options.priority ? { priority: options.priority } : {},
|
|
628
|
+
...parent ? { parentId: parent.id } : {},
|
|
629
|
+
...teamId ? { teamId } : {},
|
|
630
|
+
...labelIds ? { labelIds } : {},
|
|
631
|
+
...options.points ? { points: number(options.points, "points") } : {},
|
|
632
|
+
...options.estimate ? { estimateHours: number(options.estimate, "estimate") } : {}
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
if (ctx.json) return printJson(created);
|
|
636
|
+
print(`${created.key} ${created.title}`);
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
item.command("update").description("Update a work item").argument("<key>", "Item key, e.g. ACME-123").option("--title <title>", "New title").option("-m, --body <markdown>", "New description in Markdown").option("--body-file <path>", "Read the description from a file, or - for stdin").addOption(new Option("--priority <priority>", "Priority").choices(priorities)).addOption(new Option("--type <type>", "Item type").choices(types)).option("--assignee <userId>", 'Assignee user id, or "none" to unassign').option("--labels <names>", "Label names: a plain list replaces, +name/-name adjusts").option("--points <n>", "Story points").option("--estimate <hours>", "Original estimate in hours").option("--remaining <hours>", "Remaining hours").option("--version <n>", "Expected version; read from the item when omitted").action(
|
|
640
|
+
async (key, options) => {
|
|
641
|
+
const ctx = createContext(globals());
|
|
642
|
+
const itemKey = normalizeItemKey(key);
|
|
643
|
+
const current = await getItem(ctx, itemKey);
|
|
644
|
+
const description = await readBody(options.body, options.bodyFile);
|
|
645
|
+
const labelIds = options.labels ? resolveLabelIds(
|
|
646
|
+
options.labels,
|
|
647
|
+
await getLabels(ctx, projectKeyOf(itemKey)),
|
|
648
|
+
current.labels
|
|
649
|
+
) : void 0;
|
|
650
|
+
const updated = await ctx.client.request("patch", "/api/v1/orgs/{orgSlug}/items/{itemKey}", {
|
|
651
|
+
path: { orgSlug: await ctx.org(), itemKey },
|
|
652
|
+
body: {
|
|
653
|
+
// PATCH is an absolute assignment for the nullable fields - an omitted
|
|
654
|
+
// `parentId` unparents the item, an omitted `assigneeId` unassigns it. So the
|
|
655
|
+
// body starts from what the item currently holds and the flags overwrite it;
|
|
656
|
+
// otherwise `--priority high` would quietly detach a story from its feature.
|
|
657
|
+
type: current.type,
|
|
658
|
+
title: current.title,
|
|
659
|
+
descriptionMarkdown: current.descriptionMarkdown,
|
|
660
|
+
stateId: current.stateId,
|
|
661
|
+
priority: current.priority,
|
|
662
|
+
assigneeId: current.assigneeId,
|
|
663
|
+
teamId: current.teamId,
|
|
664
|
+
parentId: current.parentId,
|
|
665
|
+
points: current.points,
|
|
666
|
+
estimateHours: current.estimateHours,
|
|
667
|
+
remainingHours: current.remainingHours,
|
|
668
|
+
completedHours: current.completedHours,
|
|
669
|
+
dueDate: current.dueDate,
|
|
670
|
+
version: options.version ? number(options.version, "version") : current.version,
|
|
671
|
+
...options.title === void 0 ? {} : { title: options.title },
|
|
672
|
+
...description === void 0 ? {} : { descriptionMarkdown: description },
|
|
673
|
+
...options.priority ? { priority: options.priority } : {},
|
|
674
|
+
...options.type ? { type: options.type } : {},
|
|
675
|
+
...options.assignee === void 0 ? {} : { assigneeId: options.assignee === "none" ? null : options.assignee },
|
|
676
|
+
...labelIds ? { labelIds } : {},
|
|
677
|
+
...options.points ? { points: number(options.points, "points") } : {},
|
|
678
|
+
...options.estimate ? { estimateHours: number(options.estimate, "estimate") } : {},
|
|
679
|
+
...options.remaining ? { remainingHours: number(options.remaining, "remaining") } : {}
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
if (ctx.json) return printJson(updated);
|
|
683
|
+
print(`${updated.key} updated (version ${updated.version}).`);
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
item.command("move").description("Move an item: to another workflow state, sprint, parent or backlog rank").argument("<key>", "Item key, e.g. ACME-123").option("--state <name>", 'Workflow state to transition to, e.g. "In Review"').option("--sprint <sprintId>", 'Sprint id, or "none" to return it to the backlog').option("--parent <key>", "New parent item key").option("--after <key>", "Rank it directly after this item").option("--before <key>", "Rank it directly before this item").option("--version <n>", "Expected version; read from the item when omitted").action(
|
|
687
|
+
async (key, options) => {
|
|
688
|
+
const ctx = createContext(globals());
|
|
689
|
+
const orgSlug = await ctx.org();
|
|
690
|
+
const itemKey = normalizeItemKey(key);
|
|
691
|
+
const current = await getItem(ctx, itemKey);
|
|
692
|
+
const version2 = options.version ? number(options.version, "version") : current.version;
|
|
693
|
+
const ranking = options.sprint !== void 0 || options.parent !== void 0 || options.after !== void 0 || options.before !== void 0;
|
|
694
|
+
if (!options.state && !ranking) {
|
|
695
|
+
throw new CliError(
|
|
696
|
+
"Pass --state, or one of --sprint/--parent/--after/--before.",
|
|
697
|
+
ExitCode.Validation
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
let result = current;
|
|
701
|
+
if (options.state) {
|
|
702
|
+
const workflow = await getWorkflow(ctx, projectKeyOf(itemKey));
|
|
703
|
+
const state = findState(workflow, options.state);
|
|
704
|
+
result = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/transition", {
|
|
705
|
+
path: { orgSlug, itemKey },
|
|
706
|
+
body: { toStateId: state.id, version: version2 }
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (ranking) {
|
|
710
|
+
result = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/move", {
|
|
711
|
+
path: { orgSlug, itemKey },
|
|
712
|
+
body: {
|
|
713
|
+
// The transition above already spent the version it read.
|
|
714
|
+
version: options.state ? result.version : version2,
|
|
715
|
+
...options.sprint === void 0 ? {} : options.sprint === "none" ? { removeSprint: true } : { sprintId: options.sprint },
|
|
716
|
+
...options.parent === void 0 ? {} : { parentKey: normalizeItemKey(options.parent) },
|
|
717
|
+
...options.after === void 0 ? {} : { afterKey: normalizeItemKey(options.after) },
|
|
718
|
+
...options.before === void 0 ? {} : { beforeKey: normalizeItemKey(options.before) }
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
if (ctx.json) return printJson(result);
|
|
723
|
+
print(`${result.key} moved (version ${result.version}).`);
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
item.command("claim").description("Claim an item for the authenticated identity (compare-and-swap)").argument("<key>", "Item key, e.g. ACME-123").option("--version <n>", "Expected version; read from the item when omitted").action(async (key, options) => {
|
|
727
|
+
const ctx = createContext(globals());
|
|
728
|
+
const itemKey = normalizeItemKey(key);
|
|
729
|
+
const version2 = options.version ? number(options.version, "version") : (await getItem(ctx, itemKey)).version;
|
|
730
|
+
const result = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/claim", {
|
|
731
|
+
path: { orgSlug: await ctx.org(), itemKey },
|
|
732
|
+
body: { version: version2 }
|
|
733
|
+
});
|
|
734
|
+
if (ctx.json) return printJson(result);
|
|
735
|
+
print(`${itemKey} claimed.`);
|
|
736
|
+
});
|
|
737
|
+
item.command("release").description("Release a claim you hold").argument("<key>", "Item key, e.g. ACME-123").action(async (key) => {
|
|
738
|
+
const ctx = createContext(globals());
|
|
739
|
+
const itemKey = normalizeItemKey(key);
|
|
740
|
+
await ctx.client.request(
|
|
741
|
+
"post",
|
|
742
|
+
"/api/v1/orgs/{orgSlug}/items/{itemKey}/release",
|
|
743
|
+
{ path: { orgSlug: await ctx.org(), itemKey } }
|
|
744
|
+
);
|
|
745
|
+
if (ctx.json) return printJson({ key: itemKey, released: true });
|
|
746
|
+
print(`${itemKey} released.`);
|
|
747
|
+
});
|
|
748
|
+
item.command("heartbeat").description("Refresh the claim heartbeat so the claim does not go stale").argument("<key>", "Item key, e.g. ACME-123").action(async (key) => {
|
|
749
|
+
const ctx = createContext(globals());
|
|
750
|
+
const itemKey = normalizeItemKey(key);
|
|
751
|
+
await ctx.client.request(
|
|
752
|
+
"post",
|
|
753
|
+
"/api/v1/orgs/{orgSlug}/items/{itemKey}/heartbeat",
|
|
754
|
+
{ path: { orgSlug: await ctx.org(), itemKey } }
|
|
755
|
+
);
|
|
756
|
+
if (ctx.json) return printJson({ key: itemKey, heartbeat: (/* @__PURE__ */ new Date()).toISOString() });
|
|
757
|
+
print(`${itemKey} heartbeat sent.`);
|
|
758
|
+
});
|
|
759
|
+
item.command("comment").description("Add a comment to an item").argument("<key>", "Item key, e.g. ACME-123").option("-m, --body <markdown>", "Comment body in Markdown").option("--body-file <path>", "Read the body from a file, or - for stdin").action(async (key, options) => {
|
|
760
|
+
const ctx = createContext(globals());
|
|
761
|
+
const body = await readBody(options.body, options.bodyFile);
|
|
762
|
+
if (!body || body.trim().length === 0) {
|
|
763
|
+
throw new CliError('Pass -m "text" or --body-file <path>.', ExitCode.Validation);
|
|
764
|
+
}
|
|
765
|
+
const comment = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/comments", {
|
|
766
|
+
path: { orgSlug: await ctx.org(), itemKey: normalizeItemKey(key) },
|
|
767
|
+
body: { bodyMarkdown: body }
|
|
768
|
+
});
|
|
769
|
+
if (ctx.json) return printJson(comment);
|
|
770
|
+
print(`Comment added to ${normalizeItemKey(key)}.`);
|
|
771
|
+
});
|
|
772
|
+
item.command("subtask").description("Create a child item under an existing one").argument("<key>", "Parent item key, e.g. ACME-123").requiredOption("--title <title>", "Subtask title").addOption(new Option("-t, --type <type>", "Item type").choices(types).default("task")).option("-m, --body <markdown>", "Description in Markdown").option("--body-file <path>", "Read the description from a file, or - for stdin").option("--estimate <hours>", "Original estimate in hours").action(
|
|
773
|
+
async (key, options) => {
|
|
774
|
+
const ctx = createContext(globals());
|
|
775
|
+
const parentKey = normalizeItemKey(key);
|
|
776
|
+
const parent = await getItem(ctx, parentKey);
|
|
777
|
+
const description = await readBody(options.body, options.bodyFile);
|
|
778
|
+
const created = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/items", {
|
|
779
|
+
path: { orgSlug: await ctx.org(), projectKey: projectKeyOf(parentKey) },
|
|
780
|
+
body: {
|
|
781
|
+
type: options.type,
|
|
782
|
+
title: options.title,
|
|
783
|
+
parentId: parent.id,
|
|
784
|
+
...parent.teamId ? { teamId: parent.teamId } : {},
|
|
785
|
+
...description === void 0 ? {} : { descriptionMarkdown: description },
|
|
786
|
+
...options.estimate ? { estimateHours: number(options.estimate, "estimate") } : {}
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
if (ctx.json) return printJson(created);
|
|
790
|
+
print(`${created.key} ${created.title}`);
|
|
791
|
+
}
|
|
792
|
+
);
|
|
793
|
+
item.command("link").description("Attach an external link to an item").argument("<key>", "Item key, e.g. ACME-123").option("--url <url>", "Any public URL").option("--pr <url>", "Pull request URL (an alias for --url)").action(async (key, options) => {
|
|
794
|
+
const ctx = createContext(globals());
|
|
795
|
+
const url = options.url ?? options.pr;
|
|
796
|
+
if (!url) throw new CliError("Pass --url <url> or --pr <url>.", ExitCode.Validation);
|
|
797
|
+
const link = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/links", {
|
|
798
|
+
path: { orgSlug: await ctx.org(), itemKey: normalizeItemKey(key) },
|
|
799
|
+
body: { url }
|
|
800
|
+
});
|
|
801
|
+
if (ctx.json) return printJson(link);
|
|
802
|
+
print(`Linked ${link.url} to ${normalizeItemKey(key)}.`);
|
|
803
|
+
});
|
|
804
|
+
item.command("branch").description("Print the branch name convention for an item").argument("<key>", "Item key, e.g. ACME-123").action(async (key) => {
|
|
805
|
+
const ctx = createContext(globals());
|
|
806
|
+
const found = await getItem(ctx, key);
|
|
807
|
+
const branch = branchName(found);
|
|
808
|
+
if (ctx.json) return printJson({ key: found.key, branch });
|
|
809
|
+
print(branch);
|
|
810
|
+
});
|
|
811
|
+
return item;
|
|
812
|
+
}
|
|
813
|
+
async function listComments(ctx, orgSlug, itemKey) {
|
|
814
|
+
const page = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/items/{itemKey}/comments", {
|
|
815
|
+
path: { orgSlug, itemKey },
|
|
816
|
+
query: { pageSize: 100 }
|
|
817
|
+
});
|
|
818
|
+
return page.items;
|
|
819
|
+
}
|
|
820
|
+
async function listLinks(ctx, orgSlug, itemKey) {
|
|
821
|
+
return ctx.client.request(
|
|
822
|
+
"get",
|
|
823
|
+
"/api/v1/orgs/{orgSlug}/items/{itemKey}/links",
|
|
824
|
+
{ path: { orgSlug, itemKey } }
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
async function stateName(ctx, item) {
|
|
828
|
+
const workflow = await getWorkflow(ctx, projectKeyOf(item.key));
|
|
829
|
+
return workflow.states.find((s) => s.id === item.stateId)?.name ?? item.stateCategory;
|
|
830
|
+
}
|
|
831
|
+
async function resolveTeamId(ctx, projectKey, wanted) {
|
|
832
|
+
const teams = await getTeams(ctx, projectKey);
|
|
833
|
+
const needle = wanted.trim().toLowerCase();
|
|
834
|
+
const team = teams.find(
|
|
835
|
+
(t) => t.key.toLowerCase() === needle || t.name.toLowerCase() === needle || t.id === wanted
|
|
836
|
+
);
|
|
837
|
+
if (!team) {
|
|
838
|
+
throw new CliError(
|
|
839
|
+
`No team "${wanted}" in ${projectKey}. Teams: ${teams.map((t) => t.key).join(", ") || "(none)"}.`,
|
|
840
|
+
ExitCode.Validation
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
return team.id;
|
|
844
|
+
}
|
|
845
|
+
function number(raw, field) {
|
|
846
|
+
const parsed = Number(raw);
|
|
847
|
+
if (!Number.isFinite(parsed)) {
|
|
848
|
+
throw new CliError(`--${field} must be a number, got "${raw}".`, ExitCode.Validation);
|
|
849
|
+
}
|
|
850
|
+
return parsed;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/commands/mcp.ts
|
|
854
|
+
import { Command as Command4 } from "commander";
|
|
855
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
856
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
857
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
858
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
859
|
+
import {
|
|
860
|
+
CallToolRequestSchema,
|
|
861
|
+
GetPromptRequestSchema,
|
|
862
|
+
ListPromptsRequestSchema,
|
|
863
|
+
ListResourceTemplatesRequestSchema,
|
|
864
|
+
ListResourcesRequestSchema,
|
|
865
|
+
ListToolsRequestSchema,
|
|
866
|
+
ReadResourceRequestSchema
|
|
867
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
868
|
+
|
|
869
|
+
// src/version.ts
|
|
870
|
+
import { createRequire } from "module";
|
|
871
|
+
var manifest = createRequire(import.meta.url)("../package.json");
|
|
872
|
+
var version = manifest.version ?? "0.0.0";
|
|
873
|
+
|
|
874
|
+
// src/commands/mcp.ts
|
|
875
|
+
function mcpCommand(globals) {
|
|
876
|
+
return new Command4("mcp").description("Run a stdio MCP server bridged to this instance (for Claude Code and friends)").action(async () => {
|
|
877
|
+
const settings = resolveSettings(globals());
|
|
878
|
+
if (!settings.url || !settings.token) {
|
|
879
|
+
throw new CliError(
|
|
880
|
+
"No instance configured. Run `aictiq auth login --url <url>` or set AICTIQ_URL and AICTIQ_TOKEN.",
|
|
881
|
+
ExitCode.Auth
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
const upstream = new Client({ name: "aictiq-cli", version }, { capabilities: {} });
|
|
885
|
+
try {
|
|
886
|
+
await upstream.connect(
|
|
887
|
+
new StreamableHTTPClientTransport(
|
|
888
|
+
new URL("/mcp", `${settings.url.replace(/\/+$/, "")}/`),
|
|
889
|
+
{ requestInit: { headers: { Authorization: `Bearer ${settings.token}` } } }
|
|
890
|
+
)
|
|
891
|
+
);
|
|
892
|
+
} catch (cause) {
|
|
893
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
894
|
+
throw new CliError(
|
|
895
|
+
detail.includes("403") || detail.includes("401") ? `${settings.url} refused the token for /mcp. It needs the "mcp" scope and must be bound to one organization - create it under Settings \u2192 Access tokens with an organization selected.` : `Cannot open an MCP session at ${settings.url}/mcp: ${detail}`,
|
|
896
|
+
ExitCode.Auth
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
const capabilities = upstream.getServerCapabilities() ?? {};
|
|
900
|
+
const server = new Server(
|
|
901
|
+
{ name: "aictiq", version },
|
|
902
|
+
{
|
|
903
|
+
capabilities: {
|
|
904
|
+
...capabilities.tools ? { tools: capabilities.tools } : {},
|
|
905
|
+
...capabilities.resources ? { resources: capabilities.resources } : {},
|
|
906
|
+
...capabilities.prompts ? { prompts: capabilities.prompts } : {}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
);
|
|
910
|
+
if (capabilities.tools) {
|
|
911
|
+
server.setRequestHandler(
|
|
912
|
+
ListToolsRequestSchema,
|
|
913
|
+
(request) => upstream.listTools(request.params)
|
|
914
|
+
);
|
|
915
|
+
server.setRequestHandler(
|
|
916
|
+
CallToolRequestSchema,
|
|
917
|
+
(request) => upstream.callTool(request.params)
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
if (capabilities.resources) {
|
|
921
|
+
server.setRequestHandler(
|
|
922
|
+
ListResourcesRequestSchema,
|
|
923
|
+
(request) => upstream.listResources(request.params)
|
|
924
|
+
);
|
|
925
|
+
server.setRequestHandler(
|
|
926
|
+
ListResourceTemplatesRequestSchema,
|
|
927
|
+
(request) => upstream.listResourceTemplates(request.params)
|
|
928
|
+
);
|
|
929
|
+
server.setRequestHandler(
|
|
930
|
+
ReadResourceRequestSchema,
|
|
931
|
+
(request) => upstream.readResource(request.params)
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
if (capabilities.prompts) {
|
|
935
|
+
server.setRequestHandler(
|
|
936
|
+
ListPromptsRequestSchema,
|
|
937
|
+
(request) => upstream.listPrompts(request.params)
|
|
938
|
+
);
|
|
939
|
+
server.setRequestHandler(
|
|
940
|
+
GetPromptRequestSchema,
|
|
941
|
+
(request) => upstream.getPrompt(request.params)
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
await server.connect(new StdioServerTransport());
|
|
945
|
+
const shutdown = async () => {
|
|
946
|
+
await Promise.allSettled([server.close(), upstream.close()]);
|
|
947
|
+
process.exit(0);
|
|
948
|
+
};
|
|
949
|
+
process.on("SIGINT", () => void shutdown());
|
|
950
|
+
process.on("SIGTERM", () => void shutdown());
|
|
951
|
+
await new Promise((resolve3) => process.stdin.once("close", resolve3));
|
|
952
|
+
await shutdown();
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/commands/project.ts
|
|
957
|
+
import { Command as Command5 } from "commander";
|
|
958
|
+
function projectCommand(globals) {
|
|
959
|
+
const project = new Command5("project").description("Projects in the organization");
|
|
960
|
+
project.command("list").description("List the projects you can see").option("--archived", "Include archived projects").action(async (options) => {
|
|
961
|
+
const ctx = createContext(globals());
|
|
962
|
+
const projects = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects", {
|
|
963
|
+
path: { orgSlug: await ctx.org() },
|
|
964
|
+
query: { includeArchived: options.archived === true }
|
|
965
|
+
});
|
|
966
|
+
if (ctx.json) return printJson(projects);
|
|
967
|
+
if (projects.length === 0) return print("No projects.");
|
|
968
|
+
print(
|
|
969
|
+
renderTable(projects, [
|
|
970
|
+
{ header: "KEY", value: (p) => p.key },
|
|
971
|
+
{ header: "NAME", value: (p) => p.name },
|
|
972
|
+
{ header: "ROLE", value: (p) => p.role },
|
|
973
|
+
{ header: "VISIBILITY", value: (p) => p.visibility },
|
|
974
|
+
{ header: "STATUS", value: (p) => p.isArchived ? "archived" : "active" }
|
|
975
|
+
])
|
|
976
|
+
);
|
|
977
|
+
});
|
|
978
|
+
return project;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/commands/run.ts
|
|
982
|
+
import { Command as Command6 } from "commander";
|
|
983
|
+
var statuses = [
|
|
984
|
+
"queued",
|
|
985
|
+
"assigned",
|
|
986
|
+
"running",
|
|
987
|
+
"succeeded",
|
|
988
|
+
"failed",
|
|
989
|
+
"cancelled",
|
|
990
|
+
"timedOut"
|
|
991
|
+
];
|
|
992
|
+
var terminal = /* @__PURE__ */ new Set(["succeeded", "failed", "cancelled", "timedOut"]);
|
|
993
|
+
var logPageSize = 200;
|
|
994
|
+
var truncatedSeq = 2147483647;
|
|
995
|
+
var defaultPollMs = 2e3;
|
|
996
|
+
function pollIntervalMs(env = process.env) {
|
|
997
|
+
const raw = env.AICTIQ_RUN_POLL_MS;
|
|
998
|
+
if (raw === void 0 || raw === "") return defaultPollMs;
|
|
999
|
+
const parsed = Number(raw);
|
|
1000
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultPollMs;
|
|
1001
|
+
}
|
|
1002
|
+
function runCommand(globals) {
|
|
1003
|
+
const run = new Command6("run").description("Factory runs: hand an item to an agent and watch it");
|
|
1004
|
+
run.command("start").description("Dispatch a run for a work item").argument("<key>", "Work item key, e.g. ACME-123").option("--playbook <nameOrId>", "One of the project's playbooks; the default when omitted").option(
|
|
1005
|
+
"--agent <nameOrId>",
|
|
1006
|
+
"An agent of the project; the project's default agent when omitted"
|
|
1007
|
+
).action(async (key, options) => {
|
|
1008
|
+
const ctx = createContext(globals());
|
|
1009
|
+
const orgSlug = await ctx.org();
|
|
1010
|
+
const itemKey = normalizeItemKey(key);
|
|
1011
|
+
const playbookId = options.playbook ? await resolvePlaybookId(ctx, projectKeyOf(itemKey), options.playbook) : void 0;
|
|
1012
|
+
const agentId = options.agent ? await resolveAgentId(ctx, options.agent) : void 0;
|
|
1013
|
+
const started = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/items/{itemKey}/runs", {
|
|
1014
|
+
path: { orgSlug, itemKey },
|
|
1015
|
+
body: {
|
|
1016
|
+
...playbookId === void 0 ? {} : { playbookId },
|
|
1017
|
+
...agentId === void 0 ? {} : { agentId }
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
if (ctx.json) return printJson(started);
|
|
1021
|
+
print(
|
|
1022
|
+
renderFields([
|
|
1023
|
+
["Run", started.id],
|
|
1024
|
+
["Item", started.itemKey],
|
|
1025
|
+
["Status", started.status],
|
|
1026
|
+
["Agent", started.agentName ?? started.agentId],
|
|
1027
|
+
["Playbook", started.playbookName ?? started.playbookId]
|
|
1028
|
+
])
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
run.command("list").description("List runs, newest first").option("-p, --project <key>", "Only runs in this project").option("--status <status>", `One of ${statuses.join(", ")}`).option("--agent <id>", "Only runs by this agent (user id)").option("--item <key>", "Only runs for this item").option("--page <n>", "Page number", "1").option("--page-size <n>", "Runs per page (max 100)", "25").action(
|
|
1032
|
+
async (options) => {
|
|
1033
|
+
const ctx = createContext(globals());
|
|
1034
|
+
if (options.status !== void 0 && !statuses.includes(options.status)) {
|
|
1035
|
+
throw new CliError(
|
|
1036
|
+
`Unknown run status "${options.status}". Statuses: ${statuses.join(", ")}.`,
|
|
1037
|
+
ExitCode.Validation
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const page = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/runs", {
|
|
1041
|
+
path: { orgSlug: await ctx.org() },
|
|
1042
|
+
query: {
|
|
1043
|
+
project: options.project?.toUpperCase(),
|
|
1044
|
+
status: options.status,
|
|
1045
|
+
agent: options.agent,
|
|
1046
|
+
item: options.item === void 0 ? void 0 : normalizeItemKey(options.item),
|
|
1047
|
+
page: options.page,
|
|
1048
|
+
pageSize: options.pageSize
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
if (ctx.json) return printJson(page);
|
|
1052
|
+
if (page.items.length === 0) return print("No runs match.");
|
|
1053
|
+
print(
|
|
1054
|
+
renderTable(page.items, [
|
|
1055
|
+
{ header: "ID", value: (r) => r.id },
|
|
1056
|
+
{ header: "ITEM", value: (r) => r.itemKey },
|
|
1057
|
+
{ header: "STATUS", value: (r) => r.status },
|
|
1058
|
+
{ header: "AGENT", value: (r) => r.agentName ?? r.agentId },
|
|
1059
|
+
{ header: "PLAYBOOK", value: (r) => r.playbookName ?? r.playbookId },
|
|
1060
|
+
{ header: "QUEUED", value: (r) => formatDateTime(r.queuedAt) },
|
|
1061
|
+
{ header: "FINISHED", value: (r) => formatDateTime(r.finishedAt) },
|
|
1062
|
+
{ header: "PR", value: (r) => r.pullRequestUrl ?? "" }
|
|
1063
|
+
])
|
|
1064
|
+
);
|
|
1065
|
+
print("");
|
|
1066
|
+
print(`Page ${page.page} of ${Math.max(1, page.totalPages)} \xB7 ${page.totalCount} runs`);
|
|
1067
|
+
}
|
|
1068
|
+
);
|
|
1069
|
+
run.command("view").description("Show a run").argument("<runId>", "Run id").action(async (runId) => {
|
|
1070
|
+
const ctx = createContext(globals());
|
|
1071
|
+
const found = await getRun(ctx, await ctx.org(), runId);
|
|
1072
|
+
if (ctx.json) return printJson(found);
|
|
1073
|
+
const cost = found.costUsd === null && found.inputTokens === null && found.outputTokens === null ? void 0 : [
|
|
1074
|
+
found.costUsd === null ? void 0 : `$${found.costUsd.toFixed(4)}`,
|
|
1075
|
+
found.inputTokens === null && found.outputTokens === null ? void 0 : `${formatNumber(found.inputTokens) || "0"} in / ${formatNumber(found.outputTokens) || "0"} out tokens`
|
|
1076
|
+
].filter((part) => part !== void 0).join(" \xB7 ");
|
|
1077
|
+
print(
|
|
1078
|
+
renderFields([
|
|
1079
|
+
["Run", found.id],
|
|
1080
|
+
["Item", found.itemKey],
|
|
1081
|
+
["Status", found.status],
|
|
1082
|
+
["Agent", found.agentName ?? found.agentId],
|
|
1083
|
+
["Playbook", found.playbookName ?? found.playbookId],
|
|
1084
|
+
["Runner", found.runnerName ?? found.runnerId ?? void 0],
|
|
1085
|
+
["Harness", found.harness],
|
|
1086
|
+
[
|
|
1087
|
+
"Requested by",
|
|
1088
|
+
found.requestedBy ?? (found.ruleId ? `rule: ${found.ruleName ?? "(deleted)"}` : void 0)
|
|
1089
|
+
],
|
|
1090
|
+
["Max minutes", String(found.maxMinutes)],
|
|
1091
|
+
["Queued", formatDateTime(found.queuedAt)],
|
|
1092
|
+
["Assigned", formatDateTime(found.assignedAt)],
|
|
1093
|
+
["Started", formatDateTime(found.startedAt)],
|
|
1094
|
+
["Finished", formatDateTime(found.finishedAt)],
|
|
1095
|
+
["Last heartbeat", formatDateTime(found.lastHeartbeatAt)],
|
|
1096
|
+
["Cancel requested", found.cancelRequested ? "yes" : void 0],
|
|
1097
|
+
["Outcome", found.outcomeSummary ?? void 0],
|
|
1098
|
+
["PR", found.pullRequestUrl ?? void 0],
|
|
1099
|
+
["Exit code", found.exitCode === null ? void 0 : String(found.exitCode)],
|
|
1100
|
+
["Cost", cost],
|
|
1101
|
+
["Failure", found.failureReason ?? void 0]
|
|
1102
|
+
])
|
|
1103
|
+
);
|
|
1104
|
+
});
|
|
1105
|
+
run.command("logs").description("Print a run's log; --follow keeps polling until the run finishes").argument("<runId>", "Run id").option("-f, --follow", "Poll for new output every 2 s until the run is finished").action(async (runId, options) => {
|
|
1106
|
+
const ctx = createContext(globals());
|
|
1107
|
+
await streamLog(ctx, await ctx.org(), runId, options.follow === true, pollIntervalMs());
|
|
1108
|
+
});
|
|
1109
|
+
run.command("cancel").description("Ask a run to stop; the runner ends the harness and the run records cancelled").argument("<runId>", "Run id").action(async (runId) => {
|
|
1110
|
+
const ctx = createContext(globals());
|
|
1111
|
+
await ctx.client.request(
|
|
1112
|
+
"post",
|
|
1113
|
+
"/api/v1/orgs/{orgSlug}/runs/{runId}/cancel",
|
|
1114
|
+
{ path: { orgSlug: await ctx.org(), runId } }
|
|
1115
|
+
);
|
|
1116
|
+
if (ctx.json) return printJson({ id: runId, cancelRequested: true });
|
|
1117
|
+
print(`Cancel requested for run ${runId}.`);
|
|
1118
|
+
});
|
|
1119
|
+
return run;
|
|
1120
|
+
}
|
|
1121
|
+
async function getRun(ctx, orgSlug, runId) {
|
|
1122
|
+
return ctx.client.request(
|
|
1123
|
+
"get",
|
|
1124
|
+
"/api/v1/orgs/{orgSlug}/runs/{runId}",
|
|
1125
|
+
{ path: { orgSlug, runId } }
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
async function streamLog(ctx, orgSlug, runId, follow, pollMs) {
|
|
1129
|
+
let after = -1;
|
|
1130
|
+
let notedTruncation = false;
|
|
1131
|
+
for (; ; ) {
|
|
1132
|
+
const finished = follow ? terminal.has((await getRun(ctx, orgSlug, runId)).status) : true;
|
|
1133
|
+
for (; ; ) {
|
|
1134
|
+
const page = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/runs/{runId}/log", {
|
|
1135
|
+
path: { orgSlug, runId },
|
|
1136
|
+
query: { after, pageSize: logPageSize }
|
|
1137
|
+
});
|
|
1138
|
+
for (const entry of page.items) {
|
|
1139
|
+
after = Math.max(after, entry.seq);
|
|
1140
|
+
if (entry.seq !== truncatedSeq) print(formatLogEntry(entry));
|
|
1141
|
+
}
|
|
1142
|
+
if (page.truncated && !notedTruncation) {
|
|
1143
|
+
notedTruncation = true;
|
|
1144
|
+
print("(log truncated)");
|
|
1145
|
+
}
|
|
1146
|
+
if (page.items.length < logPageSize) break;
|
|
1147
|
+
}
|
|
1148
|
+
if (finished) return;
|
|
1149
|
+
await new Promise((resolve3) => setTimeout(resolve3, pollMs));
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function formatLogEntry(entry) {
|
|
1153
|
+
const text = entry.text.endsWith("\n") ? entry.text.slice(0, -1) : entry.text;
|
|
1154
|
+
if (entry.stream === "stdout") return text;
|
|
1155
|
+
const prefix = entry.stream === "stderr" ? "[stderr] " : "[event] ";
|
|
1156
|
+
return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
1157
|
+
}
|
|
1158
|
+
var uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1159
|
+
async function resolvePlaybookId(ctx, projectKey, playbook) {
|
|
1160
|
+
if (uuid.test(playbook)) return playbook;
|
|
1161
|
+
const playbooks = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/playbooks", {
|
|
1162
|
+
path: { orgSlug: await ctx.org(), projectKey }
|
|
1163
|
+
});
|
|
1164
|
+
const wanted = playbook.trim().toLowerCase();
|
|
1165
|
+
const matches = playbooks.filter((p) => p.name.toLowerCase() === wanted);
|
|
1166
|
+
if (matches.length === 1) return matches[0].id;
|
|
1167
|
+
if (matches.length === 0) {
|
|
1168
|
+
throw new CliError(
|
|
1169
|
+
`No playbook "${playbook}" in ${projectKey}. Playbooks: ${playbooks.map((p) => p.name).join(", ") || "(none)"}.`,
|
|
1170
|
+
ExitCode.Validation
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
throw new CliError(
|
|
1174
|
+
`Several playbooks in ${projectKey} are named "${playbook}"; pass one by id: ${matches.map((p) => p.id).join(", ")}.`,
|
|
1175
|
+
ExitCode.Validation
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
async function resolveAgentId(ctx, agent) {
|
|
1179
|
+
if (uuid.test(agent)) return agent;
|
|
1180
|
+
const agents = await ctx.client.request(
|
|
1181
|
+
"get",
|
|
1182
|
+
"/api/v1/orgs/{orgSlug}/agents",
|
|
1183
|
+
{ path: { orgSlug: await ctx.org() } }
|
|
1184
|
+
);
|
|
1185
|
+
const byId = agents.find((a) => a.userId === agent);
|
|
1186
|
+
if (byId) return byId.userId;
|
|
1187
|
+
const wanted = agent.trim().toLowerCase();
|
|
1188
|
+
const matches = agents.filter((a) => a.displayName.toLowerCase() === wanted);
|
|
1189
|
+
if (matches.length === 1) return matches[0].userId;
|
|
1190
|
+
if (matches.length === 0) {
|
|
1191
|
+
throw new CliError(
|
|
1192
|
+
`No agent "${agent}" in this organization. Agents: ${agents.map((a) => a.displayName).join(", ") || "(none)"}.`,
|
|
1193
|
+
ExitCode.Validation
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1196
|
+
throw new CliError(
|
|
1197
|
+
`Several agents are named "${agent}"; pass one by id: ${matches.map((a) => a.userId).join(", ")}.`,
|
|
1198
|
+
ExitCode.Validation
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/commands/runner.ts
|
|
1203
|
+
import { Command as Command7 } from "commander";
|
|
1204
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1205
|
+
import { arch, platform } from "os";
|
|
1206
|
+
import { resolve as resolve2 } from "path";
|
|
1207
|
+
|
|
1208
|
+
// src/runner/client.ts
|
|
1209
|
+
var RunnerHttpError = class extends Error {
|
|
1210
|
+
status;
|
|
1211
|
+
problem;
|
|
1212
|
+
constructor(status, problem, message3) {
|
|
1213
|
+
super(message3);
|
|
1214
|
+
this.name = "RunnerHttpError";
|
|
1215
|
+
this.status = status;
|
|
1216
|
+
this.problem = problem;
|
|
1217
|
+
}
|
|
1218
|
+
/** A disabled, deleted or rotated runner: retrying cannot help, so the process must stop. */
|
|
1219
|
+
get revoked() {
|
|
1220
|
+
return this.status === 401;
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
var RunnerNetworkError = class extends Error {
|
|
1224
|
+
constructor(message3) {
|
|
1225
|
+
super(message3);
|
|
1226
|
+
this.name = "RunnerNetworkError";
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
var RunnerClient = class {
|
|
1230
|
+
baseUrl;
|
|
1231
|
+
token;
|
|
1232
|
+
doFetch;
|
|
1233
|
+
constructor(options) {
|
|
1234
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
1235
|
+
this.token = options.token;
|
|
1236
|
+
this.doFetch = options.fetch ?? fetch;
|
|
1237
|
+
}
|
|
1238
|
+
hello(capabilities) {
|
|
1239
|
+
return this.send("/runner/hello", { capabilities });
|
|
1240
|
+
}
|
|
1241
|
+
async heartbeat(capabilities) {
|
|
1242
|
+
await this.send("/runner/heartbeat", { capabilities });
|
|
1243
|
+
}
|
|
1244
|
+
/** Long-polls; null when the server had nothing within its poll timeout. */
|
|
1245
|
+
async claim(harnesses2, slots, signal2) {
|
|
1246
|
+
return await this.send("/runner/runs/claim", { harnesses: harnesses2, slots }, signal2) ?? null;
|
|
1247
|
+
}
|
|
1248
|
+
async started(runId) {
|
|
1249
|
+
await this.send(`/runner/runs/${runId}/started`);
|
|
1250
|
+
}
|
|
1251
|
+
async log(runId, chunks) {
|
|
1252
|
+
await this.send(`/runner/runs/${runId}/log`, { chunks });
|
|
1253
|
+
}
|
|
1254
|
+
async runHeartbeat(runId) {
|
|
1255
|
+
return this.send(`/runner/runs/${runId}/heartbeat`);
|
|
1256
|
+
}
|
|
1257
|
+
async finish(runId, report) {
|
|
1258
|
+
await this.send(`/runner/runs/${runId}/finish`, report);
|
|
1259
|
+
}
|
|
1260
|
+
async repoToken(runId) {
|
|
1261
|
+
const response = await this.send(`/runner/runs/${runId}/repo-token`);
|
|
1262
|
+
return response?.token ?? null;
|
|
1263
|
+
}
|
|
1264
|
+
/** Lists committed attachments on an item, including attachments posted in its comments. */
|
|
1265
|
+
listAttachments(run) {
|
|
1266
|
+
return this.get(
|
|
1267
|
+
`/orgs/${encodeURIComponent(run.organizationSlug)}/items/${encodeURIComponent(run.itemKey)}/attachments?include=comments`,
|
|
1268
|
+
run.agentToken
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
/** Downloads attachment bytes using the short-lived agent credential, never the runner secret. */
|
|
1272
|
+
async downloadAttachment(run, attachmentId) {
|
|
1273
|
+
const path = `/orgs/${encodeURIComponent(run.organizationSlug)}/attachments/${encodeURIComponent(attachmentId)}/download`;
|
|
1274
|
+
const response = await this.request(path, "GET", void 0, void 0, run.agentToken);
|
|
1275
|
+
if (!response.ok) throw await this.error(path, response);
|
|
1276
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Keeps the item's claim fresh with the agent's own token. Without it the claim goes
|
|
1280
|
+
* stale after `Claims:StaleAfterMinutes` and another agent may take the item mid-run.
|
|
1281
|
+
*/
|
|
1282
|
+
async itemHeartbeat(run) {
|
|
1283
|
+
await this.send(
|
|
1284
|
+
`/orgs/${encodeURIComponent(run.organizationSlug)}/items/${encodeURIComponent(run.itemKey)}/heartbeat`,
|
|
1285
|
+
void 0,
|
|
1286
|
+
void 0,
|
|
1287
|
+
run.agentToken
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
async send(path, body, signal2, token = this.token) {
|
|
1291
|
+
const response = await this.request(path, "POST", body, signal2, token);
|
|
1292
|
+
if (response.status === 204) return void 0;
|
|
1293
|
+
if (!response.ok) throw await this.error(path, response);
|
|
1294
|
+
const text = await response.text();
|
|
1295
|
+
let payload;
|
|
1296
|
+
try {
|
|
1297
|
+
payload = text.length === 0 ? void 0 : JSON.parse(text);
|
|
1298
|
+
} catch {
|
|
1299
|
+
payload = void 0;
|
|
1300
|
+
}
|
|
1301
|
+
return payload;
|
|
1302
|
+
}
|
|
1303
|
+
async get(path, token) {
|
|
1304
|
+
const response = await this.request(path, "GET", void 0, void 0, token);
|
|
1305
|
+
if (!response.ok) throw await this.error(path, response);
|
|
1306
|
+
return await response.json();
|
|
1307
|
+
}
|
|
1308
|
+
async request(path, method, body, signal2, token = this.token) {
|
|
1309
|
+
try {
|
|
1310
|
+
return await this.doFetch(`${this.baseUrl}/api/v1${path}`, {
|
|
1311
|
+
method,
|
|
1312
|
+
headers: {
|
|
1313
|
+
Authorization: `Bearer ${token}`,
|
|
1314
|
+
Accept: "application/json",
|
|
1315
|
+
"User-Agent": "aictiq-runner",
|
|
1316
|
+
...body === void 0 ? {} : { "Content-Type": "application/json" }
|
|
1317
|
+
},
|
|
1318
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
1319
|
+
...signal2 ? { signal: signal2 } : {}
|
|
1320
|
+
});
|
|
1321
|
+
} catch (cause) {
|
|
1322
|
+
if (signal2?.aborted) throw cause;
|
|
1323
|
+
throw new RunnerNetworkError(`Cannot reach ${this.baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async error(path, response) {
|
|
1327
|
+
if (response.status >= 502 && response.status <= 504) return new RunnerNetworkError(`${this.baseUrl} answered ${response.status}`);
|
|
1328
|
+
const text = await response.text();
|
|
1329
|
+
let payload;
|
|
1330
|
+
try {
|
|
1331
|
+
payload = text.length === 0 ? void 0 : JSON.parse(text);
|
|
1332
|
+
} catch {
|
|
1333
|
+
payload = void 0;
|
|
1334
|
+
}
|
|
1335
|
+
const problem = typeof payload === "object" && payload !== null ? payload : void 0;
|
|
1336
|
+
return new RunnerHttpError(response.status, problem, `${path}: ${response.status} ${problem?.title ?? response.statusText}${problem?.detail ? ` - ${problem.detail}` : ""}`);
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
|
|
1340
|
+
// src/runner/config.ts
|
|
1341
|
+
import { chmodSync as chmodSync3, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1342
|
+
import { homedir as homedir3 } from "os";
|
|
1343
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
1344
|
+
|
|
1345
|
+
// src/runner/workspace.ts
|
|
1346
|
+
import { spawn } from "child_process";
|
|
1347
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1348
|
+
import { homedir as homedir2 } from "os";
|
|
1349
|
+
import { isAbsolute, join as join2, relative, resolve } from "path";
|
|
1350
|
+
|
|
1351
|
+
// src/runner/types.ts
|
|
1352
|
+
var RunFailure = class extends Error {
|
|
1353
|
+
reason;
|
|
1354
|
+
constructor(reason, message3) {
|
|
1355
|
+
super(message3);
|
|
1356
|
+
this.name = "RunFailure";
|
|
1357
|
+
this.reason = reason;
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
// src/runner/workspace.ts
|
|
1362
|
+
var DefaultAttachmentMaxCount = 25;
|
|
1363
|
+
var DefaultAttachmentMaxBytes = 25 * 1024 * 1024;
|
|
1364
|
+
function defaultWorkspaceRoot(env = process.env) {
|
|
1365
|
+
const base = env.XDG_DATA_HOME ?? join2(homedir2(), ".local", "share");
|
|
1366
|
+
return join2(base, "aictiq", "runner");
|
|
1367
|
+
}
|
|
1368
|
+
var TOKEN_ENV = "AICTIQ_GIT_TOKEN";
|
|
1369
|
+
var CREDENTIAL_HELPER = `!f() { test "$1" = get || return 0; echo username=x-access-token; echo "password=$${TOKEN_ENV}"; }; f`;
|
|
1370
|
+
var ASKPASS_SCRIPT = `#!/bin/sh
|
|
1371
|
+
case "$1" in
|
|
1372
|
+
*[Uu]sername*) echo x-access-token ;;
|
|
1373
|
+
*) echo "$${TOKEN_ENV}" ;;
|
|
1374
|
+
esac
|
|
1375
|
+
`;
|
|
1376
|
+
async function provisionWorkspace(run, options) {
|
|
1377
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(run.runId)) {
|
|
1378
|
+
throw new RunFailure(
|
|
1379
|
+
"workspace-failed",
|
|
1380
|
+
`Refusing to create a workspace for run id "${run.runId}".`
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
const runDir = join2(options.root, run.runId);
|
|
1384
|
+
const checkout = join2(runDir, "repo");
|
|
1385
|
+
const promptFile = join2(runDir, "prompt.md");
|
|
1386
|
+
const mcpConfigFile = join2(runDir, "mcp.json");
|
|
1387
|
+
const attachmentsDir = join2(runDir, "attachments");
|
|
1388
|
+
const env = options.env ?? process.env;
|
|
1389
|
+
let source = null;
|
|
1390
|
+
const cleanup = async () => {
|
|
1391
|
+
if (options.keep) return;
|
|
1392
|
+
if (source !== null) {
|
|
1393
|
+
await git(["worktree", "remove", "--force", checkout], { cwd: source, env }).catch(
|
|
1394
|
+
() => void 0
|
|
1395
|
+
);
|
|
1396
|
+
await git(["worktree", "prune"], { cwd: source, env }).catch(() => void 0);
|
|
1397
|
+
}
|
|
1398
|
+
try {
|
|
1399
|
+
rmSync2(runDir, { recursive: true, force: true });
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
mkdirSync2(options.root, { recursive: true, mode: 448 });
|
|
1404
|
+
rmSync2(runDir, { recursive: true, force: true });
|
|
1405
|
+
mkdirSync2(runDir, { mode: 448 });
|
|
1406
|
+
chmodSync2(runDir, 448);
|
|
1407
|
+
try {
|
|
1408
|
+
const mcpConfig = {
|
|
1409
|
+
mcpServers: { aictiq: { command: options.mcpServer.command, args: options.mcpServer.args } }
|
|
1410
|
+
};
|
|
1411
|
+
writePrivate(mcpConfigFile, `${JSON.stringify(mcpConfig, null, 2)}
|
|
1412
|
+
`);
|
|
1413
|
+
await assertRefName(run.branchName, "branch", env);
|
|
1414
|
+
await assertRefName(run.defaultBranch, "default branch", env);
|
|
1415
|
+
let harnessEnv = {};
|
|
1416
|
+
if (run.repo.source === "local") {
|
|
1417
|
+
source = await provisionLocal(run, checkout, options, env);
|
|
1418
|
+
} else {
|
|
1419
|
+
harnessEnv = await provisionGithub(run, runDir, checkout, options, env);
|
|
1420
|
+
}
|
|
1421
|
+
const prompt = await provisionAttachments(run, attachmentsDir, options);
|
|
1422
|
+
writePrivate(promptFile, prompt);
|
|
1423
|
+
return {
|
|
1424
|
+
runDir,
|
|
1425
|
+
checkout,
|
|
1426
|
+
branch: run.branchName,
|
|
1427
|
+
promptFile,
|
|
1428
|
+
mcpConfigFile,
|
|
1429
|
+
attachmentsDir,
|
|
1430
|
+
prompt,
|
|
1431
|
+
env: harnessEnv,
|
|
1432
|
+
cleanup
|
|
1433
|
+
};
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
await cleanup();
|
|
1436
|
+
throw error;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
async function provisionAttachments(run, attachmentsDir, options) {
|
|
1440
|
+
if (!options.attachments) return run.prompt;
|
|
1441
|
+
const maxCount = limit(options.attachmentMaxCount, DefaultAttachmentMaxCount);
|
|
1442
|
+
const maxBytes = limit(options.attachmentMaxBytes, DefaultAttachmentMaxBytes);
|
|
1443
|
+
if (maxCount === 0 || maxBytes === 0) {
|
|
1444
|
+
options.event("Attachment provisioning is disabled by this runner configuration");
|
|
1445
|
+
return `${run.prompt}
|
|
1446
|
+
|
|
1447
|
+
## Attachments
|
|
1448
|
+
|
|
1449
|
+
Attachment provisioning is disabled on this runner. Use the attachment download URL from Aictiq if needed.
|
|
1450
|
+
`;
|
|
1451
|
+
}
|
|
1452
|
+
let attachments;
|
|
1453
|
+
try {
|
|
1454
|
+
attachments = await options.attachments.list();
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
options.event(`Could not list item attachments: ${message(error)}`);
|
|
1457
|
+
return run.prompt;
|
|
1458
|
+
}
|
|
1459
|
+
if (attachments.length === 0) return run.prompt;
|
|
1460
|
+
mkdirSync2(attachmentsDir, { recursive: true, mode: 448 });
|
|
1461
|
+
chmodSync2(attachmentsDir, 448);
|
|
1462
|
+
let total = 0;
|
|
1463
|
+
const names = /* @__PURE__ */ new Set();
|
|
1464
|
+
const provisioned = [];
|
|
1465
|
+
for (const attachment of attachments) {
|
|
1466
|
+
if (provisioned.length >= maxCount) {
|
|
1467
|
+
options.event(`Skipped attachment "${attachment.fileName}" (limit: ${maxCount} files)`);
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
if (!Number.isSafeInteger(attachment.sizeBytes) || attachment.sizeBytes < 0 || total + attachment.sizeBytes > maxBytes) {
|
|
1471
|
+
options.event(`Skipped attachment "${attachment.fileName}" (would exceed ${formatBytes(maxBytes)} total)`);
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
const bytes = await options.attachments.download(attachment.id);
|
|
1476
|
+
if (bytes.byteLength > attachment.sizeBytes || total + bytes.byteLength > maxBytes) {
|
|
1477
|
+
options.event(`Skipped attachment "${attachment.fileName}" (download exceeds ${formatBytes(maxBytes)} total)`);
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
const name = uniqueFileName(sanitiseFileName(attachment.fileName, attachment.id), names);
|
|
1481
|
+
writePrivate(join2(attachmentsDir, name), bytes);
|
|
1482
|
+
total += bytes.byteLength;
|
|
1483
|
+
provisioned.push({ ...attachment, localName: name });
|
|
1484
|
+
} catch (error) {
|
|
1485
|
+
options.event(`Failed to download attachment "${attachment.fileName}": ${message(error)}`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if (provisioned.length === 0) return run.prompt;
|
|
1489
|
+
options.event(`Provisioned ${provisioned.length} attachment${provisioned.length === 1 ? "" : "s"}: ${provisioned.map((x) => x.localName).join(", ")}`);
|
|
1490
|
+
const inventory = provisioned.map((attachment) => {
|
|
1491
|
+
const source = attachment.commentId ? `comment ${attachment.commentId}` : "item description";
|
|
1492
|
+
const url = `/api/v1/orgs/${encodeURIComponent(run.organizationSlug)}/attachments/${encodeURIComponent(attachment.id)}/download`;
|
|
1493
|
+
return `- ${join2(attachmentsDir, attachment.localName)} - original: ${attachment.fileName}; ${attachment.contentType}; ${formatBytes(attachment.sizeBytes)}; from ${source}; replaces ${url}`;
|
|
1494
|
+
}).join("\n");
|
|
1495
|
+
const images = provisioned.filter((attachment) => attachment.contentType.startsWith("image/"));
|
|
1496
|
+
return `${run.prompt}
|
|
1497
|
+
|
|
1498
|
+
## Attachments
|
|
1499
|
+
|
|
1500
|
+
These files are outside the git checkout at \`${attachmentsDir}\`; do not add or commit them. ${images.length > 0 ? "Look at these images before planning." : ""}
|
|
1501
|
+
|
|
1502
|
+
${inventory}
|
|
1503
|
+
`;
|
|
1504
|
+
}
|
|
1505
|
+
function limit(value, fallback) {
|
|
1506
|
+
return Number.isSafeInteger(value) && value !== void 0 && value >= 0 ? value : fallback;
|
|
1507
|
+
}
|
|
1508
|
+
function sanitiseFileName(value, id) {
|
|
1509
|
+
const clean = value.replace(/[\\/\p{Cc}]/gu, "_").trim().replace(/^\.+$/, "");
|
|
1510
|
+
return (clean || `attachment-${id}`).slice(0, 200);
|
|
1511
|
+
}
|
|
1512
|
+
function uniqueFileName(value, existing) {
|
|
1513
|
+
const extension = value.lastIndexOf(".") > 0 ? value.slice(value.lastIndexOf(".")) : "";
|
|
1514
|
+
const stem = extension ? value.slice(0, -extension.length) : value;
|
|
1515
|
+
let candidate = value;
|
|
1516
|
+
let sequence = 2;
|
|
1517
|
+
while (existing.has(candidate.toLocaleLowerCase())) candidate = `${stem}-${sequence++}${extension}`;
|
|
1518
|
+
existing.add(candidate.toLocaleLowerCase());
|
|
1519
|
+
return candidate;
|
|
1520
|
+
}
|
|
1521
|
+
function formatBytes(value) {
|
|
1522
|
+
return value >= 1024 * 1024 ? `${(value / (1024 * 1024)).toFixed(1)} MiB` : `${value} bytes`;
|
|
1523
|
+
}
|
|
1524
|
+
function resolveLocalRepository(run, options) {
|
|
1525
|
+
const mapped = options.repositories[run.projectKey];
|
|
1526
|
+
if (mapped !== void 0) return { path: mapped, root: null };
|
|
1527
|
+
const hint = run.repo.localPathHint?.trim();
|
|
1528
|
+
const roots = options.repoRoots ?? [];
|
|
1529
|
+
if (hint) {
|
|
1530
|
+
const expanded = expandHome(hint);
|
|
1531
|
+
if (isAbsolute(expanded)) {
|
|
1532
|
+
const real = realpathOrSelf(expanded);
|
|
1533
|
+
for (const configured of roots) {
|
|
1534
|
+
const expandedRoot = expandHome(configured);
|
|
1535
|
+
if (!isAbsolute(expandedRoot)) continue;
|
|
1536
|
+
const root = realpathOrSelf(expandedRoot);
|
|
1537
|
+
if (isWithin(real, root)) return { path: real, root };
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const example = `("${run.projectKey}": "/path/to/repository")`;
|
|
1542
|
+
const detail = !hint ? ` Add it to the \`workspaces\` map in runner.json ${example}, or set the project's path hint and list its parent directory in \`repoRoots\`.` : roots.length === 0 ? ` The project suggests ${hint}. Add its parent directory to \`repoRoots\` in runner.json (\`aictiq runner root <path>\`), or map it in \`workspaces\` ${example}.` : ` The project suggests ${hint}, which is not under any of this runner's repoRoots (${roots.join(", ")}). Add a root that contains it, or map it in \`workspaces\` ${example}.`;
|
|
1543
|
+
throw new RunFailure(
|
|
1544
|
+
"no-local-repository",
|
|
1545
|
+
`No local repository is mapped for project ${run.projectKey}.${detail}`
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
function expandHome(path) {
|
|
1549
|
+
if (path === "~") return homedir2();
|
|
1550
|
+
return path.startsWith("~/") ? join2(homedir2(), path.slice(2)) : path;
|
|
1551
|
+
}
|
|
1552
|
+
function realpathOrSelf(path) {
|
|
1553
|
+
const normalised = resolve(path);
|
|
1554
|
+
try {
|
|
1555
|
+
return realpathSync(normalised);
|
|
1556
|
+
} catch {
|
|
1557
|
+
return normalised;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
function isWithin(path, root) {
|
|
1561
|
+
const rel = relative(root, path);
|
|
1562
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
1563
|
+
}
|
|
1564
|
+
function message(error) {
|
|
1565
|
+
return error instanceof Error ? error.message : String(error);
|
|
1566
|
+
}
|
|
1567
|
+
async function provisionLocal(run, checkout, options, env) {
|
|
1568
|
+
const { path: mapped, root } = resolveLocalRepository(run, options);
|
|
1569
|
+
const origin = root === null ? "mapped for project" : "the path hint for project";
|
|
1570
|
+
const toplevel = await git(["rev-parse", "--show-toplevel"], {
|
|
1571
|
+
cwd: mapped,
|
|
1572
|
+
env,
|
|
1573
|
+
signal: options.signal
|
|
1574
|
+
}).catch((error) => {
|
|
1575
|
+
if (isAbort(error)) throw error;
|
|
1576
|
+
throw new RunFailure(
|
|
1577
|
+
"no-local-repository",
|
|
1578
|
+
`${mapped}, ${origin} ${run.projectKey}, is not a git repository.`
|
|
1579
|
+
);
|
|
1580
|
+
});
|
|
1581
|
+
if (root !== null && !isWithin(realpathOrSelf(toplevel.trim()), root)) {
|
|
1582
|
+
throw new RunFailure(
|
|
1583
|
+
"no-local-repository",
|
|
1584
|
+
`${mapped}, the path hint for project ${run.projectKey}, is not a git repository under ${root}.`
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
const repo = toplevel.trim();
|
|
1588
|
+
const call = (args) => gitStep(args, { cwd: repo, env, signal: options.signal });
|
|
1589
|
+
const hasOrigin = await git(["remote", "get-url", "origin"], {
|
|
1590
|
+
cwd: repo,
|
|
1591
|
+
env,
|
|
1592
|
+
signal: options.signal
|
|
1593
|
+
}).then(
|
|
1594
|
+
() => true,
|
|
1595
|
+
(error) => {
|
|
1596
|
+
if (isAbort(error)) throw error;
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
);
|
|
1600
|
+
let base = run.defaultBranch;
|
|
1601
|
+
if (hasOrigin) {
|
|
1602
|
+
options.event(`Fetching origin in ${repo}`);
|
|
1603
|
+
await call(["fetch", "origin"]);
|
|
1604
|
+
base = `origin/${run.defaultBranch}`;
|
|
1605
|
+
}
|
|
1606
|
+
const branchExists = await git(
|
|
1607
|
+
["rev-parse", "--verify", "--quiet", `refs/heads/${run.branchName}`],
|
|
1608
|
+
{
|
|
1609
|
+
cwd: repo,
|
|
1610
|
+
env,
|
|
1611
|
+
signal: options.signal
|
|
1612
|
+
}
|
|
1613
|
+
).then(
|
|
1614
|
+
() => true,
|
|
1615
|
+
(error) => {
|
|
1616
|
+
if (isAbort(error)) throw error;
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
);
|
|
1620
|
+
if (branchExists) {
|
|
1621
|
+
await call(["worktree", "add", checkout, run.branchName]);
|
|
1622
|
+
options.event(`Created worktree ${checkout} on existing branch ${run.branchName}`);
|
|
1623
|
+
} else {
|
|
1624
|
+
await call(["worktree", "add", "--no-track", "-b", run.branchName, checkout, base]);
|
|
1625
|
+
options.event(`Created worktree ${checkout} on branch ${run.branchName} from ${base}`);
|
|
1626
|
+
}
|
|
1627
|
+
return repo;
|
|
1628
|
+
}
|
|
1629
|
+
async function provisionGithub(run, runDir, checkout, options, env) {
|
|
1630
|
+
const full = run.repo.repoFullName;
|
|
1631
|
+
if (!full || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(full)) {
|
|
1632
|
+
throw new RunFailure(
|
|
1633
|
+
"workspace-failed",
|
|
1634
|
+
`The run names no valid GitHub repository (${full ?? "none"}).`
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
const token = run.repo.cloneToken ?? await options.refreshCloneToken?.() ?? null;
|
|
1638
|
+
if (!token) {
|
|
1639
|
+
throw new RunFailure(
|
|
1640
|
+
"repository-token-unavailable",
|
|
1641
|
+
`No GitHub installation token is available to clone ${full}. Check that the GitHub App is still installed on the repository.`
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
const askpass = join2(runDir, "askpass.sh");
|
|
1645
|
+
writeFileSync2(askpass, ASKPASS_SCRIPT, { mode: 448 });
|
|
1646
|
+
chmodSync2(askpass, 448);
|
|
1647
|
+
const secretEnv = { GIT_ASKPASS: askpass, [TOKEN_ENV]: token };
|
|
1648
|
+
const cloneEnv = { ...env, ...secretEnv };
|
|
1649
|
+
const base = (options.githubBaseUrl ?? "https://github.com").replace(/\/+$/, "");
|
|
1650
|
+
const url = `${base}/${full}.git`;
|
|
1651
|
+
await gitStep(
|
|
1652
|
+
// An empty credential.helper resets the user's own helpers, so a global `store` helper
|
|
1653
|
+
// cannot write the token to ~/.git-credentials after the clone succeeds.
|
|
1654
|
+
[
|
|
1655
|
+
"-c",
|
|
1656
|
+
"credential.helper=",
|
|
1657
|
+
"clone",
|
|
1658
|
+
"--depth",
|
|
1659
|
+
"50",
|
|
1660
|
+
"--branch",
|
|
1661
|
+
run.defaultBranch,
|
|
1662
|
+
url,
|
|
1663
|
+
checkout
|
|
1664
|
+
],
|
|
1665
|
+
{ cwd: runDir, env: cloneEnv, signal: options.signal },
|
|
1666
|
+
token
|
|
1667
|
+
);
|
|
1668
|
+
options.event(`Cloned ${full} (depth 50)`);
|
|
1669
|
+
const call = (args) => gitStep(args, { cwd: checkout, env, signal: options.signal }, token);
|
|
1670
|
+
await call(["checkout", "-b", run.branchName]);
|
|
1671
|
+
options.event(`Created branch ${run.branchName} from ${run.defaultBranch}`);
|
|
1672
|
+
await call(["config", "--local", "--replace-all", "credential.helper", ""]);
|
|
1673
|
+
await call(["config", "--local", "--add", "credential.helper", CREDENTIAL_HELPER]);
|
|
1674
|
+
return { [TOKEN_ENV]: token };
|
|
1675
|
+
}
|
|
1676
|
+
function writePrivate(path, content) {
|
|
1677
|
+
writeFileSync2(path, content, { mode: 384 });
|
|
1678
|
+
chmodSync2(path, 384);
|
|
1679
|
+
}
|
|
1680
|
+
async function assertRefName(name, what, env) {
|
|
1681
|
+
const valid = await git(["check-ref-format", "--branch", name], { cwd: process.cwd(), env }).then(
|
|
1682
|
+
() => true,
|
|
1683
|
+
() => false
|
|
1684
|
+
);
|
|
1685
|
+
if (!valid)
|
|
1686
|
+
throw new RunFailure(
|
|
1687
|
+
"workspace-failed",
|
|
1688
|
+
`The run's ${what} "${name}" is not a valid git branch name.`
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
var GitError = class extends Error {
|
|
1692
|
+
stderr;
|
|
1693
|
+
constructor(args, code, stderr) {
|
|
1694
|
+
super(
|
|
1695
|
+
`git ${args[0] === "-c" ? args[2] ?? "" : args[0] ?? ""} exited with ${code ?? "a signal"}`
|
|
1696
|
+
);
|
|
1697
|
+
this.stderr = stderr;
|
|
1698
|
+
}
|
|
1699
|
+
};
|
|
1700
|
+
async function gitStep(args, options, secret) {
|
|
1701
|
+
try {
|
|
1702
|
+
return await git(args, options);
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
if (error instanceof GitError) {
|
|
1705
|
+
const tail = error.stderr.trim().split("\n").slice(-20).join("\n");
|
|
1706
|
+
let message3 = tail ? `${error.message}:
|
|
1707
|
+
${tail}` : error.message;
|
|
1708
|
+
if (secret) message3 = message3.replaceAll(secret, "***");
|
|
1709
|
+
throw new RunFailure("workspace-failed", message3);
|
|
1710
|
+
}
|
|
1711
|
+
throw error;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
function git(args, options) {
|
|
1715
|
+
return new Promise((resolve3, reject) => {
|
|
1716
|
+
const child = spawn("git", args, {
|
|
1717
|
+
cwd: options.cwd,
|
|
1718
|
+
env: { ...options.env, GIT_TERMINAL_PROMPT: "0" },
|
|
1719
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1720
|
+
signal: options.signal
|
|
1721
|
+
});
|
|
1722
|
+
let stdout = "";
|
|
1723
|
+
let stderr = "";
|
|
1724
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => stdout += chunk);
|
|
1725
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
1726
|
+
stderr = (stderr + chunk).slice(-16384);
|
|
1727
|
+
});
|
|
1728
|
+
child.on("error", reject);
|
|
1729
|
+
child.on("close", (code) => {
|
|
1730
|
+
if (code === 0) resolve3(stdout);
|
|
1731
|
+
else reject(new GitError(args, code, stderr));
|
|
1732
|
+
});
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
function isAbort(error) {
|
|
1736
|
+
return error instanceof Error && error.name === "AbortError";
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/runner/config.ts
|
|
1740
|
+
function runnerConfigPath(env = process.env) {
|
|
1741
|
+
const base = env.AICTIQ_CONFIG_HOME ?? env.XDG_CONFIG_HOME ?? join3(homedir3(), ".config");
|
|
1742
|
+
return join3(base, "aictiq", "runner.json");
|
|
1743
|
+
}
|
|
1744
|
+
function readRunnerConfig(path = runnerConfigPath()) {
|
|
1745
|
+
if (!existsSync2(path)) return null;
|
|
1746
|
+
let parsed;
|
|
1747
|
+
try {
|
|
1748
|
+
parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
1749
|
+
} catch {
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1753
|
+
const { url, token, name, workspaces, repoRoots, attachments } = parsed;
|
|
1754
|
+
if (typeof url !== "string" || typeof token !== "string") return null;
|
|
1755
|
+
const mapped = {};
|
|
1756
|
+
if (typeof workspaces === "object" && workspaces !== null) {
|
|
1757
|
+
for (const [key, value] of Object.entries(workspaces)) {
|
|
1758
|
+
if (typeof value === "string") mapped[key] = value;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
const roots = Array.isArray(repoRoots) ? repoRoots.filter((root) => typeof root === "string" && root.trim() !== "") : [];
|
|
1762
|
+
const rawLimits = typeof attachments === "object" && attachments !== null ? attachments : {};
|
|
1763
|
+
return {
|
|
1764
|
+
url,
|
|
1765
|
+
token,
|
|
1766
|
+
...typeof name === "string" ? { name } : {},
|
|
1767
|
+
workspaces: mapped,
|
|
1768
|
+
repoRoots: roots,
|
|
1769
|
+
attachments: {
|
|
1770
|
+
maxCount: validLimit(rawLimits.maxCount, DefaultAttachmentMaxCount),
|
|
1771
|
+
maxBytes: validLimit(rawLimits.maxBytes, DefaultAttachmentMaxBytes)
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function writeRunnerConfig(config, path = runnerConfigPath()) {
|
|
1776
|
+
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1777
|
+
writeFileSync3(path, `${JSON.stringify(config, null, 2)}
|
|
1778
|
+
`, { mode: 384 });
|
|
1779
|
+
chmodSync3(path, 384);
|
|
1780
|
+
}
|
|
1781
|
+
function validLimit(value, fallback) {
|
|
1782
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// src/runner/execute.ts
|
|
1786
|
+
import { execFile } from "child_process";
|
|
1787
|
+
import { stripVTControlCharacters } from "util";
|
|
1788
|
+
|
|
1789
|
+
// src/runner/log.ts
|
|
1790
|
+
var MaxChunkChars = 65536;
|
|
1791
|
+
var MaxChunksPerBatch = 512;
|
|
1792
|
+
function redact(text, secrets) {
|
|
1793
|
+
let result = text;
|
|
1794
|
+
for (const secret of [...secrets].filter((s) => s.length >= 8).sort((a, b) => b.length - a.length)) {
|
|
1795
|
+
result = result.split(secret).join("[redacted]");
|
|
1796
|
+
}
|
|
1797
|
+
return result;
|
|
1798
|
+
}
|
|
1799
|
+
var LogStreamer = class {
|
|
1800
|
+
options;
|
|
1801
|
+
queue = [];
|
|
1802
|
+
seq = 0;
|
|
1803
|
+
timer;
|
|
1804
|
+
sending = Promise.resolve();
|
|
1805
|
+
capped = false;
|
|
1806
|
+
closed = false;
|
|
1807
|
+
closing = false;
|
|
1808
|
+
constructor(options) {
|
|
1809
|
+
this.options = options;
|
|
1810
|
+
}
|
|
1811
|
+
/** True once the server refused the log for exceeding its cap; later lines are dropped. */
|
|
1812
|
+
get truncated() {
|
|
1813
|
+
return this.capped;
|
|
1814
|
+
}
|
|
1815
|
+
push(stream, line) {
|
|
1816
|
+
if (this.capped || this.closed) return;
|
|
1817
|
+
const text = redact(line, this.options.secrets);
|
|
1818
|
+
const at = (this.options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1819
|
+
for (const piece of splitByBytes(text, Math.min(this.options.maxBatchBytes, MaxChunkChars))) {
|
|
1820
|
+
this.queue.push({ seq: this.seq++, stream, text: piece, at });
|
|
1821
|
+
}
|
|
1822
|
+
if (this.queuedBytes() >= this.options.maxBatchBytes) {
|
|
1823
|
+
void this.flush();
|
|
1824
|
+
} else {
|
|
1825
|
+
this.timer ??= setTimeout(() => {
|
|
1826
|
+
this.timer = void 0;
|
|
1827
|
+
void this.flush();
|
|
1828
|
+
}, this.options.flushIntervalMs ?? 1e3);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/** Sends everything queued so far; resolves once it is stored or the log is capped. */
|
|
1832
|
+
flush() {
|
|
1833
|
+
if (this.timer) {
|
|
1834
|
+
clearTimeout(this.timer);
|
|
1835
|
+
this.timer = void 0;
|
|
1836
|
+
}
|
|
1837
|
+
this.sending = this.sending.then(() => this.drain());
|
|
1838
|
+
return this.sending;
|
|
1839
|
+
}
|
|
1840
|
+
/** Flushes and stops accepting lines. */
|
|
1841
|
+
async close() {
|
|
1842
|
+
this.closing = true;
|
|
1843
|
+
await this.flush();
|
|
1844
|
+
this.closed = true;
|
|
1845
|
+
}
|
|
1846
|
+
async drain() {
|
|
1847
|
+
while (this.queue.length > 0 && !this.capped) {
|
|
1848
|
+
const batch = this.takeBatch();
|
|
1849
|
+
let attempt = 0;
|
|
1850
|
+
while (true) {
|
|
1851
|
+
try {
|
|
1852
|
+
await this.options.send(batch);
|
|
1853
|
+
this.queue.splice(0, batch.length);
|
|
1854
|
+
break;
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
if (error instanceof RunnerHttpError && error.status === 413) {
|
|
1857
|
+
this.capped = true;
|
|
1858
|
+
this.queue.length = 0;
|
|
1859
|
+
this.options.local?.(
|
|
1860
|
+
"The instance capped this run log; further output is kept out of it."
|
|
1861
|
+
);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (error instanceof RunnerHttpError) {
|
|
1865
|
+
this.queue.length = 0;
|
|
1866
|
+
this.capped = true;
|
|
1867
|
+
this.options.local?.(`The instance refused the run log: ${error.message}`);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
attempt++;
|
|
1871
|
+
if (attempt > 8 && this.closing) {
|
|
1872
|
+
this.queue.length = 0;
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
await delay(this.options.retryDelayMs?.(attempt) ?? Math.min(3e4, 500 * 2 ** attempt));
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
takeBatch() {
|
|
1881
|
+
const batch = [];
|
|
1882
|
+
let bytes = 0;
|
|
1883
|
+
for (const chunk of this.queue) {
|
|
1884
|
+
const size = Buffer.byteLength(chunk.text, "utf8");
|
|
1885
|
+
if (batch.length > 0 && (bytes + size > this.options.maxBatchBytes || batch.length >= MaxChunksPerBatch))
|
|
1886
|
+
break;
|
|
1887
|
+
batch.push(chunk);
|
|
1888
|
+
bytes += size;
|
|
1889
|
+
}
|
|
1890
|
+
return batch;
|
|
1891
|
+
}
|
|
1892
|
+
queuedBytes() {
|
|
1893
|
+
let bytes = 0;
|
|
1894
|
+
for (const chunk of this.queue) bytes += Buffer.byteLength(chunk.text, "utf8");
|
|
1895
|
+
return bytes;
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
function splitByBytes(text, maxBytes) {
|
|
1899
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return [text];
|
|
1900
|
+
const pieces = [];
|
|
1901
|
+
let current = "";
|
|
1902
|
+
let bytes = 0;
|
|
1903
|
+
for (const char of text) {
|
|
1904
|
+
const size = Buffer.byteLength(char, "utf8");
|
|
1905
|
+
if (bytes + size > maxBytes) {
|
|
1906
|
+
pieces.push(current);
|
|
1907
|
+
current = "";
|
|
1908
|
+
bytes = 0;
|
|
1909
|
+
}
|
|
1910
|
+
current += char;
|
|
1911
|
+
bytes += size;
|
|
1912
|
+
}
|
|
1913
|
+
pieces.push(current);
|
|
1914
|
+
return pieces;
|
|
1915
|
+
}
|
|
1916
|
+
function delay(ms) {
|
|
1917
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// src/runner/process.ts
|
|
1921
|
+
import { spawn as spawn2 } from "child_process";
|
|
1922
|
+
import { StringDecoder } from "string_decoder";
|
|
1923
|
+
function spawnSupervised(options) {
|
|
1924
|
+
const child = spawn2(options.command, options.args, {
|
|
1925
|
+
cwd: options.cwd,
|
|
1926
|
+
env: options.env,
|
|
1927
|
+
detached: process.platform !== "win32",
|
|
1928
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1929
|
+
});
|
|
1930
|
+
const exited = new Promise((resolve3) => {
|
|
1931
|
+
let settled = false;
|
|
1932
|
+
const settle = (code) => {
|
|
1933
|
+
if (settled) return;
|
|
1934
|
+
settled = true;
|
|
1935
|
+
resolve3(code);
|
|
1936
|
+
};
|
|
1937
|
+
child.on("error", (error) => {
|
|
1938
|
+
options.onLine("stderr", `Could not start ${options.command}: ${error.message}`);
|
|
1939
|
+
settle(error.code === "ENOENT" ? 127 : 1);
|
|
1940
|
+
});
|
|
1941
|
+
child.on("close", (code) => {
|
|
1942
|
+
settle(code);
|
|
1943
|
+
signal(child, "SIGKILL");
|
|
1944
|
+
});
|
|
1945
|
+
child.on("exit", (code) => {
|
|
1946
|
+
setTimeout(() => {
|
|
1947
|
+
settle(code);
|
|
1948
|
+
signal(child, "SIGKILL");
|
|
1949
|
+
}, 2e3);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
splitLines(child, "stdout", options.onLine);
|
|
1953
|
+
splitLines(child, "stderr", options.onLine);
|
|
1954
|
+
child.stdin?.on("error", () => {
|
|
1955
|
+
});
|
|
1956
|
+
child.stdin?.end(options.stdin ?? "");
|
|
1957
|
+
return {
|
|
1958
|
+
pid: child.pid,
|
|
1959
|
+
exited,
|
|
1960
|
+
async stop(graceMs = 1e4) {
|
|
1961
|
+
signal(child, "SIGTERM");
|
|
1962
|
+
const timer = setTimeout(() => signal(child, "SIGKILL"), graceMs);
|
|
1963
|
+
try {
|
|
1964
|
+
return await exited;
|
|
1965
|
+
} finally {
|
|
1966
|
+
clearTimeout(timer);
|
|
1967
|
+
signal(child, "SIGKILL");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
function signal(child, name) {
|
|
1973
|
+
if (child.pid === void 0) return;
|
|
1974
|
+
try {
|
|
1975
|
+
if (process.platform === "win32") child.kill(name);
|
|
1976
|
+
else process.kill(-child.pid, name);
|
|
1977
|
+
} catch {
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
function splitLines(child, stream, onLine) {
|
|
1981
|
+
const source = child[stream];
|
|
1982
|
+
if (!source) return;
|
|
1983
|
+
const decoder = new StringDecoder("utf8");
|
|
1984
|
+
let buffer = "";
|
|
1985
|
+
source.on("data", (data) => {
|
|
1986
|
+
buffer += decoder.write(data);
|
|
1987
|
+
let index;
|
|
1988
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
1989
|
+
onLine(stream, buffer.slice(0, index).replace(/\r$/, ""));
|
|
1990
|
+
buffer = buffer.slice(index + 1);
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
source.on("end", () => {
|
|
1994
|
+
buffer += decoder.end();
|
|
1995
|
+
if (buffer.length > 0) onLine(stream, buffer);
|
|
1996
|
+
buffer = "";
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
// src/runner/execute.ts
|
|
2001
|
+
var MaxSummary = 4e3;
|
|
2002
|
+
var MaxFailureReason = 500;
|
|
2003
|
+
var PullRequestUrl = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g;
|
|
2004
|
+
async function executeRun(run, options) {
|
|
2005
|
+
const { client } = options;
|
|
2006
|
+
const local = options.local ?? (() => {
|
|
2007
|
+
});
|
|
2008
|
+
const log = new LogStreamer({
|
|
2009
|
+
send: (chunks) => client.log(run.runId, chunks),
|
|
2010
|
+
secrets: [run.agentToken, options.runnerToken, run.repo.cloneToken ?? ""],
|
|
2011
|
+
maxBatchBytes: options.hello.maxLogBatchBytes,
|
|
2012
|
+
flushIntervalMs: options.flushIntervalMs ?? 1e3,
|
|
2013
|
+
local: (message3) => local(`${run.itemKey}: ${message3}`)
|
|
2014
|
+
});
|
|
2015
|
+
const event = (line) => log.push("event", line);
|
|
2016
|
+
let harness;
|
|
2017
|
+
let stopReason;
|
|
2018
|
+
const abort = new AbortController();
|
|
2019
|
+
const stop = (reason) => {
|
|
2020
|
+
if (stopReason) return;
|
|
2021
|
+
stopReason = reason;
|
|
2022
|
+
abort.abort();
|
|
2023
|
+
void harness?.stop(options.graceMs ?? 1e4);
|
|
2024
|
+
};
|
|
2025
|
+
const onShutdown = () => stop("shutdown");
|
|
2026
|
+
options.shutdown?.addEventListener("abort", onShutdown, { once: true });
|
|
2027
|
+
if (options.shutdown?.aborted) onShutdown();
|
|
2028
|
+
let workspace;
|
|
2029
|
+
let revoked;
|
|
2030
|
+
let heartbeat;
|
|
2031
|
+
let beating = Promise.resolve();
|
|
2032
|
+
let deadline;
|
|
2033
|
+
const report = await (async () => {
|
|
2034
|
+
try {
|
|
2035
|
+
await client.started(run.runId);
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
if (error instanceof RunnerHttpError && (error.status === 409 || error.status === 404)) {
|
|
2038
|
+
local(`${run.itemKey}: the instance closed run ${run.runId} before it started`);
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
throw error;
|
|
2042
|
+
}
|
|
2043
|
+
event(`Runner picked up ${run.itemKey} with ${run.harness}`);
|
|
2044
|
+
const beat = async () => {
|
|
2045
|
+
try {
|
|
2046
|
+
const { cancelRequested } = await client.runHeartbeat(run.runId);
|
|
2047
|
+
if (cancelRequested) {
|
|
2048
|
+
event("Cancel requested; stopping the harness");
|
|
2049
|
+
stop("cancelled");
|
|
2050
|
+
}
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
if (error instanceof RunnerHttpError && error.revoked) {
|
|
2053
|
+
revoked ??= error;
|
|
2054
|
+
stop("closed");
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (error instanceof RunnerHttpError && (error.status === 409 || error.status === 404)) {
|
|
2058
|
+
local(`${run.itemKey}: the instance closed run ${run.runId}; stopping it here`);
|
|
2059
|
+
stop("closed");
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
local(`${run.itemKey}: run heartbeat failed: ${message2(error)}`);
|
|
2063
|
+
}
|
|
2064
|
+
try {
|
|
2065
|
+
await client.itemHeartbeat(run);
|
|
2066
|
+
} catch (error) {
|
|
2067
|
+
local(`${run.itemKey}: item heartbeat failed: ${message2(error)}`);
|
|
2068
|
+
}
|
|
2069
|
+
};
|
|
2070
|
+
const tick = () => {
|
|
2071
|
+
beating = beat();
|
|
2072
|
+
};
|
|
2073
|
+
tick();
|
|
2074
|
+
heartbeat = setInterval(
|
|
2075
|
+
tick,
|
|
2076
|
+
options.heartbeatMs ?? run.heartbeatIntervalSeconds * 1e3
|
|
2077
|
+
);
|
|
2078
|
+
deadline = setTimeout(() => {
|
|
2079
|
+
event(`The run reached its limit of ${run.maxMinutes} minutes; stopping the harness`);
|
|
2080
|
+
stop("timed-out");
|
|
2081
|
+
}, run.maxMinutes * 6e4);
|
|
2082
|
+
const adapter = options.adapters[run.harness];
|
|
2083
|
+
const info = adapter ? await adapter.available() : null;
|
|
2084
|
+
if (!adapter || !info) {
|
|
2085
|
+
event(`Harness "${run.harness}" is not installed on this runner`);
|
|
2086
|
+
return failed(
|
|
2087
|
+
"harness-unavailable",
|
|
2088
|
+
`This runner has no working "${run.harness}" on its PATH.`
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
event(`Using ${run.harness} ${info.version ?? ""}`.trim());
|
|
2092
|
+
try {
|
|
2093
|
+
workspace = await (options.provision ?? provisionWorkspace)(run, {
|
|
2094
|
+
...options.workspace,
|
|
2095
|
+
event,
|
|
2096
|
+
refreshCloneToken: () => client.repoToken(run.runId),
|
|
2097
|
+
attachments: {
|
|
2098
|
+
list: () => client.listAttachments(run),
|
|
2099
|
+
download: (attachmentId) => client.downloadAttachment(run, attachmentId)
|
|
2100
|
+
},
|
|
2101
|
+
signal: abort.signal
|
|
2102
|
+
});
|
|
2103
|
+
} catch (error) {
|
|
2104
|
+
if (stopReason) return stopped(null, []);
|
|
2105
|
+
if (error instanceof RunFailure) {
|
|
2106
|
+
event(error.message);
|
|
2107
|
+
return failed(error.reason, error.message);
|
|
2108
|
+
}
|
|
2109
|
+
event(`Workspace provisioning failed: ${message2(error)}`);
|
|
2110
|
+
return failed("workspace-failed", message2(error));
|
|
2111
|
+
}
|
|
2112
|
+
if (stopReason) return stopped(null, []);
|
|
2113
|
+
const invocation = adapter.invocation({
|
|
2114
|
+
prompt: workspace.prompt,
|
|
2115
|
+
promptFile: workspace.promptFile,
|
|
2116
|
+
workspace: workspace.checkout,
|
|
2117
|
+
attachmentsDir: workspace.attachmentsDir,
|
|
2118
|
+
mcpConfigFile: workspace.mcpConfigFile,
|
|
2119
|
+
mcpServer: options.workspace.mcpServer
|
|
2120
|
+
});
|
|
2121
|
+
const env = {
|
|
2122
|
+
...process.env,
|
|
2123
|
+
...invocation.env,
|
|
2124
|
+
...workspace.env,
|
|
2125
|
+
// The agent reaches the instance as itself, never as the runner: `aictiq mcp` and the
|
|
2126
|
+
// CLI both read these, and they exist only in this process tree.
|
|
2127
|
+
AICTIQ_URL: run.aictiqUrl ?? client.baseUrl,
|
|
2128
|
+
AICTIQ_TOKEN: run.agentToken,
|
|
2129
|
+
AICTIQ_ORG: run.organizationSlug,
|
|
2130
|
+
AICTIQ_ITEM: run.itemKey,
|
|
2131
|
+
AICTIQ_RUN: run.runId
|
|
2132
|
+
};
|
|
2133
|
+
const lastLines = [];
|
|
2134
|
+
let lastResult = null;
|
|
2135
|
+
let pullRequestUrl = null;
|
|
2136
|
+
let costUsd;
|
|
2137
|
+
let inputTokens;
|
|
2138
|
+
let outputTokens;
|
|
2139
|
+
const remember = (line) => {
|
|
2140
|
+
lastLines.push(line);
|
|
2141
|
+
if (lastLines.length > 20) lastLines.shift();
|
|
2142
|
+
};
|
|
2143
|
+
event(`Starting ${invocation.command} in ${workspace.checkout}`);
|
|
2144
|
+
harness = spawnSupervised({
|
|
2145
|
+
command: invocation.command,
|
|
2146
|
+
args: invocation.args,
|
|
2147
|
+
cwd: workspace.checkout,
|
|
2148
|
+
env,
|
|
2149
|
+
...invocation.stdin === void 0 ? {} : { stdin: invocation.stdin },
|
|
2150
|
+
onLine: (stream, raw) => {
|
|
2151
|
+
const line = stripVTControlCharacters(raw);
|
|
2152
|
+
for (const match of line.matchAll(PullRequestUrl)) pullRequestUrl = match[0];
|
|
2153
|
+
if (stream === "stderr") {
|
|
2154
|
+
log.push("stderr", line);
|
|
2155
|
+
remember(line);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const parsed = adapter.parse(line);
|
|
2159
|
+
const tally = (total, value) => value === void 0 ? total : parsed.accumulate ? (total ?? 0) + value : value;
|
|
2160
|
+
costUsd = tally(costUsd, parsed.costUsd);
|
|
2161
|
+
inputTokens = tally(inputTokens, parsed.inputTokens);
|
|
2162
|
+
outputTokens = tally(outputTokens, parsed.outputTokens);
|
|
2163
|
+
if (parsed.result !== void 0) lastResult = parsed.result;
|
|
2164
|
+
if (parsed.log !== null) {
|
|
2165
|
+
log.push("stdout", parsed.log);
|
|
2166
|
+
remember(parsed.log);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
if (stopReason) void harness.stop(options.graceMs ?? 1e4);
|
|
2171
|
+
const exitCode = await harness.exited;
|
|
2172
|
+
event(exitCode === null ? "Harness was killed" : `Harness exited with code ${exitCode}`);
|
|
2173
|
+
const usage = { costUsd, inputTokens, outputTokens, exitCode };
|
|
2174
|
+
if (stopReason) return stopped(exitCode, lastLines, usage);
|
|
2175
|
+
const verdict = adapter.outcome(exitCode, lastLines, lastResult);
|
|
2176
|
+
if (verdict.outcome === "succeeded" && !pullRequestUrl) {
|
|
2177
|
+
pullRequestUrl = await (options.findPullRequest ?? findPullRequest)(
|
|
2178
|
+
workspace.checkout,
|
|
2179
|
+
run.branchName,
|
|
2180
|
+
env
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
if (pullRequestUrl) event(`Pull request: ${pullRequestUrl}`);
|
|
2184
|
+
return {
|
|
2185
|
+
outcome: verdict.outcome,
|
|
2186
|
+
exitCode,
|
|
2187
|
+
summary: truncate(verdict.summary, MaxSummary),
|
|
2188
|
+
pullRequestUrl,
|
|
2189
|
+
failureReason: verdict.outcome === "failed" ? truncate(verdict.failureReason ?? "harness-failed", MaxFailureReason) : null,
|
|
2190
|
+
costUsd: costUsd ?? null,
|
|
2191
|
+
inputTokens: inputTokens ?? null,
|
|
2192
|
+
outputTokens: outputTokens ?? null
|
|
2193
|
+
};
|
|
2194
|
+
})().catch((error) => {
|
|
2195
|
+
if (error instanceof RunnerHttpError && error.revoked) throw error;
|
|
2196
|
+
local(`${run.itemKey}: ${message2(error)}`);
|
|
2197
|
+
event(`Runner error: ${message2(error)}`);
|
|
2198
|
+
return failed("runner-error", message2(error));
|
|
2199
|
+
}).finally(() => {
|
|
2200
|
+
clearInterval(heartbeat);
|
|
2201
|
+
clearTimeout(deadline);
|
|
2202
|
+
options.shutdown?.removeEventListener("abort", onShutdown);
|
|
2203
|
+
});
|
|
2204
|
+
if (report?.outcome === "failed" && report.failureReason)
|
|
2205
|
+
event(`Run failed: ${report.failureReason}`);
|
|
2206
|
+
await beating;
|
|
2207
|
+
await log.close();
|
|
2208
|
+
await workspace?.cleanup();
|
|
2209
|
+
if (revoked) throw revoked;
|
|
2210
|
+
if (!report) return null;
|
|
2211
|
+
return await sendFinish(client, run, report, local) ? report : null;
|
|
2212
|
+
function failed(reason, detail) {
|
|
2213
|
+
return {
|
|
2214
|
+
outcome: "failed",
|
|
2215
|
+
failureReason: truncate(reason, MaxFailureReason),
|
|
2216
|
+
summary: truncate(detail, MaxSummary)
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
function stopped(exitCode, lines, usage = {}) {
|
|
2220
|
+
const tail = lines.length > 0 ? lines.join("\n") : null;
|
|
2221
|
+
switch (stopReason) {
|
|
2222
|
+
case "closed":
|
|
2223
|
+
return null;
|
|
2224
|
+
case "timed-out":
|
|
2225
|
+
return {
|
|
2226
|
+
...usage,
|
|
2227
|
+
outcome: "failed",
|
|
2228
|
+
exitCode,
|
|
2229
|
+
failureReason: "timed-out",
|
|
2230
|
+
summary: truncate(tail, MaxSummary)
|
|
2231
|
+
};
|
|
2232
|
+
case "shutdown":
|
|
2233
|
+
return {
|
|
2234
|
+
...usage,
|
|
2235
|
+
outcome: "cancelled",
|
|
2236
|
+
exitCode,
|
|
2237
|
+
failureReason: "runner-shutdown",
|
|
2238
|
+
summary: "The runner was stopped before the run finished."
|
|
2239
|
+
};
|
|
2240
|
+
default:
|
|
2241
|
+
return { ...usage, outcome: "cancelled", exitCode, summary: truncate(tail, MaxSummary) };
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
async function sendFinish(client, run, report, local) {
|
|
2246
|
+
for (let attempt = 0; ; attempt++) {
|
|
2247
|
+
try {
|
|
2248
|
+
await client.finish(run.runId, report);
|
|
2249
|
+
local(
|
|
2250
|
+
`${run.itemKey}: ${report.outcome}${report.failureReason ? ` (${report.failureReason})` : ""}`
|
|
2251
|
+
);
|
|
2252
|
+
return true;
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
if (error instanceof RunnerNetworkError && attempt < 30) {
|
|
2255
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.min(3e4, 1e3 * 2 ** attempt)));
|
|
2256
|
+
continue;
|
|
2257
|
+
}
|
|
2258
|
+
if (error instanceof RunnerHttpError && error.revoked) throw error;
|
|
2259
|
+
local(`${run.itemKey}: could not report the outcome: ${message2(error)}`);
|
|
2260
|
+
return false;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
function findPullRequest(checkout, branch, env) {
|
|
2265
|
+
return new Promise((resolve3) => {
|
|
2266
|
+
execFile(
|
|
2267
|
+
"gh",
|
|
2268
|
+
["pr", "view", branch, "--json", "url", "--jq", ".url"],
|
|
2269
|
+
{ cwd: checkout, env, timeout: 15e3 },
|
|
2270
|
+
(error, stdout) => {
|
|
2271
|
+
const url = error ? "" : stdout.trim();
|
|
2272
|
+
resolve3(/^https:\/\/\S+\/pull\/\d+$/.test(url) ? url : null);
|
|
2273
|
+
}
|
|
2274
|
+
);
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
function truncate(value, max) {
|
|
2278
|
+
if (!value) return null;
|
|
2279
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
|
|
2280
|
+
}
|
|
2281
|
+
function message2(error) {
|
|
2282
|
+
return error instanceof Error ? error.message : String(error);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
// src/runner/harness/claude.ts
|
|
2286
|
+
import { execFile as execFile2 } from "child_process";
|
|
2287
|
+
var limit2 = (value, length) => value.length > length ? `${value.slice(0, Math.max(0, length - 1))}\u2026` : value;
|
|
2288
|
+
var recordOf = (value) => typeof value === "object" && value !== null ? value : null;
|
|
2289
|
+
function versionOf(command, env = process.env) {
|
|
2290
|
+
return new Promise((resolve3) => {
|
|
2291
|
+
execFile2(command, ["--version"], { env, timeout: 5e3 }, (error, stdout, stderr) => {
|
|
2292
|
+
if (error) return resolve3(null);
|
|
2293
|
+
const output = String(stdout || stderr).trim();
|
|
2294
|
+
const firstLine = output.split(/\r?\n/)[0]?.slice(0, 100) ?? "";
|
|
2295
|
+
resolve3(firstLine || null);
|
|
2296
|
+
});
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
function failedOutcome(exitCode, lastLines) {
|
|
2300
|
+
return {
|
|
2301
|
+
outcome: "failed",
|
|
2302
|
+
failureReason: exitCode === null ? "harness-killed" : `harness-exit-${exitCode}`,
|
|
2303
|
+
summary: limit2(lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
var claude = {
|
|
2307
|
+
name: "claude",
|
|
2308
|
+
async available(env) {
|
|
2309
|
+
const version2 = await versionOf("claude", env);
|
|
2310
|
+
return version2 === null ? null : { name: "claude", version: version2 };
|
|
2311
|
+
},
|
|
2312
|
+
invocation(context) {
|
|
2313
|
+
return {
|
|
2314
|
+
command: "claude",
|
|
2315
|
+
args: [
|
|
2316
|
+
"-p",
|
|
2317
|
+
"--output-format",
|
|
2318
|
+
"stream-json",
|
|
2319
|
+
"--verbose",
|
|
2320
|
+
"--permission-mode",
|
|
2321
|
+
"bypassPermissions",
|
|
2322
|
+
"--mcp-config",
|
|
2323
|
+
context.mcpConfigFile,
|
|
2324
|
+
// Claude's working directory remains the checkout. Attachments are deliberately
|
|
2325
|
+
// beside it, so grant this one read-only run directory explicitly.
|
|
2326
|
+
"--add-dir",
|
|
2327
|
+
context.attachmentsDir
|
|
2328
|
+
],
|
|
2329
|
+
// Claude's -p mode consumes stdin when there is no positional prompt.
|
|
2330
|
+
stdin: context.prompt
|
|
2331
|
+
};
|
|
2332
|
+
},
|
|
2333
|
+
parse(line) {
|
|
2334
|
+
let event;
|
|
2335
|
+
try {
|
|
2336
|
+
const parsed = JSON.parse(line);
|
|
2337
|
+
event = recordOf(parsed) ?? (() => {
|
|
2338
|
+
throw new Error("not an object");
|
|
2339
|
+
})();
|
|
2340
|
+
} catch {
|
|
2341
|
+
return { log: line };
|
|
2342
|
+
}
|
|
2343
|
+
if (event.type === "system" && event.subtype === "init") {
|
|
2344
|
+
return { log: `[init] model ${typeof event.model === "string" ? event.model : "unknown"}` };
|
|
2345
|
+
}
|
|
2346
|
+
if (event.type === "assistant") {
|
|
2347
|
+
const message3 = recordOf(event.message);
|
|
2348
|
+
const content = Array.isArray(message3?.content) ? message3.content : [];
|
|
2349
|
+
const logs = content.flatMap((block) => {
|
|
2350
|
+
const part = recordOf(block);
|
|
2351
|
+
if (!part || part.type === "thinking") return [];
|
|
2352
|
+
if (part.type === "text" && typeof part.text === "string") return [part.text];
|
|
2353
|
+
if (part.type === "tool_use") {
|
|
2354
|
+
const name = typeof part.name === "string" ? part.name : "tool";
|
|
2355
|
+
const input = limit2(JSON.stringify(part.input ?? {}), 200);
|
|
2356
|
+
return [`\u2192 ${name} ${input}`];
|
|
2357
|
+
}
|
|
2358
|
+
return [];
|
|
2359
|
+
});
|
|
2360
|
+
return { log: logs.length > 0 ? logs.join("\n") : null };
|
|
2361
|
+
}
|
|
2362
|
+
if (event.type === "user") {
|
|
2363
|
+
const message3 = recordOf(event.message);
|
|
2364
|
+
const content = Array.isArray(message3?.content) ? message3.content[0] : message3?.content;
|
|
2365
|
+
const result = recordOf(content);
|
|
2366
|
+
if (result?.type !== "tool_result") return { log: null };
|
|
2367
|
+
const text = typeof result.content === "string" ? result.content : JSON.stringify(result.content ?? "");
|
|
2368
|
+
const error = result.is_error === true ? "[error] " : "";
|
|
2369
|
+
return { log: `\u2190 ${error}${limit2(text.split(/\r?\n/)[0] ?? "", 200)}` };
|
|
2370
|
+
}
|
|
2371
|
+
if (event.type === "result") {
|
|
2372
|
+
const usage = recordOf(event.usage);
|
|
2373
|
+
const input = numberOf(usage?.input_tokens) + numberOf(usage?.cache_creation_input_tokens) + numberOf(usage?.cache_read_input_tokens);
|
|
2374
|
+
return {
|
|
2375
|
+
// The marker is what `outcome` reads: the runner keeps the logged lines, not the raw JSON.
|
|
2376
|
+
log: `[result] ${typeof event.subtype === "string" ? event.subtype : "unknown"}${event.is_error === true ? " (error)" : ""} in ${numberOf(event.duration_ms)}ms`,
|
|
2377
|
+
costUsd: numberOrUndefined(event.total_cost_usd),
|
|
2378
|
+
inputTokens: input || void 0,
|
|
2379
|
+
outputTokens: numberOrUndefined(usage?.output_tokens),
|
|
2380
|
+
result: typeof event.result === "string" ? event.result : void 0
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
return { log: null };
|
|
2384
|
+
},
|
|
2385
|
+
outcome(exitCode, lastLines, lastResult) {
|
|
2386
|
+
if (exitCode !== 0) return failedOutcome(exitCode, lastLines);
|
|
2387
|
+
if (lastLines.some((line) => /^\[result\] .*\(error\)/.test(line))) {
|
|
2388
|
+
return { ...failedOutcome(exitCode, lastLines), failureReason: "harness-error" };
|
|
2389
|
+
}
|
|
2390
|
+
return {
|
|
2391
|
+
outcome: "succeeded",
|
|
2392
|
+
failureReason: null,
|
|
2393
|
+
summary: limit2(lastResult ?? lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2394
|
+
};
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
function numberOf(value) {
|
|
2398
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2399
|
+
}
|
|
2400
|
+
function numberOrUndefined(value) {
|
|
2401
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// src/runner/harness/codex.ts
|
|
2405
|
+
var limit3 = (value, length) => value.length > length ? `${value.slice(0, Math.max(0, length - 1))}\u2026` : value;
|
|
2406
|
+
var recordOf2 = (value) => typeof value === "object" && value !== null ? value : null;
|
|
2407
|
+
var MCP_ENV = ["AICTIQ_URL", "AICTIQ_TOKEN", "AICTIQ_ORG", "AICTIQ_ITEM", "AICTIQ_RUN"];
|
|
2408
|
+
var codex = {
|
|
2409
|
+
name: "codex",
|
|
2410
|
+
async available(env) {
|
|
2411
|
+
const version2 = await versionOf("codex", env);
|
|
2412
|
+
return version2 === null ? null : { name: "codex", version: version2 };
|
|
2413
|
+
},
|
|
2414
|
+
invocation(context) {
|
|
2415
|
+
return {
|
|
2416
|
+
command: "codex",
|
|
2417
|
+
args: [
|
|
2418
|
+
"exec",
|
|
2419
|
+
"--json",
|
|
2420
|
+
// 0.154.0 removed --full-auto. The runner is the ticket's explicit trust boundary:
|
|
2421
|
+
// the agent needs unrestricted network access for the MCP server and git push.
|
|
2422
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
2423
|
+
// This mode also permits the absolute attachment paths named in prompt.md outside
|
|
2424
|
+
// the checkout. The runner itself remains the trust boundary for those files.
|
|
2425
|
+
"--skip-git-repo-check",
|
|
2426
|
+
"-C",
|
|
2427
|
+
context.workspace,
|
|
2428
|
+
"-c",
|
|
2429
|
+
`mcp_servers.aictiq.command=${JSON.stringify(context.mcpServer.command)}`,
|
|
2430
|
+
"-c",
|
|
2431
|
+
`mcp_servers.aictiq.args=${JSON.stringify(context.mcpServer.args)}`,
|
|
2432
|
+
// Codex starts MCP servers with a scrubbed environment; without this the bridge
|
|
2433
|
+
// never sees the run's credential and the handshake fails. Names only - the values
|
|
2434
|
+
// stay in the process environment, out of argv.
|
|
2435
|
+
"-c",
|
|
2436
|
+
`mcp_servers.aictiq.env_vars=${JSON.stringify(MCP_ENV)}`,
|
|
2437
|
+
"-"
|
|
2438
|
+
],
|
|
2439
|
+
stdin: context.prompt
|
|
2440
|
+
};
|
|
2441
|
+
},
|
|
2442
|
+
parse(line) {
|
|
2443
|
+
let event;
|
|
2444
|
+
try {
|
|
2445
|
+
const parsed = JSON.parse(line);
|
|
2446
|
+
event = recordOf2(parsed) ?? (() => {
|
|
2447
|
+
throw new Error("not an object");
|
|
2448
|
+
})();
|
|
2449
|
+
} catch {
|
|
2450
|
+
return { log: line };
|
|
2451
|
+
}
|
|
2452
|
+
const item = recordOf2(event.item);
|
|
2453
|
+
if ((event.type === "item.completed" || event.type === "item.started") && item) {
|
|
2454
|
+
if (item.type === "agent_message" && typeof item.text === "string") {
|
|
2455
|
+
return { log: item.text, result: item.text };
|
|
2456
|
+
}
|
|
2457
|
+
if (item.type === "command_execution") {
|
|
2458
|
+
if (event.type === "item.completed") return { log: null };
|
|
2459
|
+
const command = typeof item.command === "string" ? item.command : JSON.stringify(item.command ?? "");
|
|
2460
|
+
return { log: `\u2192 command ${limit3(command, 200)}` };
|
|
2461
|
+
}
|
|
2462
|
+
if (item.type === "file_change") {
|
|
2463
|
+
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
2464
|
+
const paths = changes.map((change) => recordOf2(change)?.path).filter((path) => typeof path === "string");
|
|
2465
|
+
return { log: paths.length > 0 ? `\u2192 file_change ${limit3(paths.join(", "), 200)}` : null };
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
const usage = usageOf(event);
|
|
2469
|
+
if (usage) {
|
|
2470
|
+
const input = numberOf2(usage.input_tokens) + numberOf2(usage.cached_input_tokens) + numberOf2(usage.cache_write_input_tokens);
|
|
2471
|
+
return {
|
|
2472
|
+
log: null,
|
|
2473
|
+
inputTokens: input || void 0,
|
|
2474
|
+
outputTokens: numberOrUndefined2(usage.output_tokens)
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
return { log: null };
|
|
2478
|
+
},
|
|
2479
|
+
outcome(exitCode, lastLines, lastResult) {
|
|
2480
|
+
if (exitCode !== 0) return failedOutcome2(exitCode, lastLines);
|
|
2481
|
+
return {
|
|
2482
|
+
outcome: "succeeded",
|
|
2483
|
+
failureReason: null,
|
|
2484
|
+
summary: limit3(lastResult ?? lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
function usageOf(event) {
|
|
2489
|
+
if (event.type === "turn.completed") return recordOf2(event.usage);
|
|
2490
|
+
if (event.type === "token_count") return recordOf2(event.usage) ?? recordOf2(event.info);
|
|
2491
|
+
const payload = recordOf2(event.payload);
|
|
2492
|
+
if (payload?.type === "token_count") {
|
|
2493
|
+
return recordOf2(payload.usage) ?? recordOf2(recordOf2(payload.info)?.total_token_usage);
|
|
2494
|
+
}
|
|
2495
|
+
return null;
|
|
2496
|
+
}
|
|
2497
|
+
function failedOutcome2(exitCode, lastLines) {
|
|
2498
|
+
return {
|
|
2499
|
+
outcome: "failed",
|
|
2500
|
+
failureReason: exitCode === null ? "harness-killed" : `harness-exit-${exitCode}`,
|
|
2501
|
+
summary: limit3(lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
function numberOf2(value) {
|
|
2505
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2506
|
+
}
|
|
2507
|
+
function numberOrUndefined2(value) {
|
|
2508
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// src/runner/harness/opencode.ts
|
|
2512
|
+
var limit4 = (value, length) => value.length > length ? `${value.slice(0, Math.max(0, length - 1))}\u2026` : value;
|
|
2513
|
+
var recordOf3 = (value) => typeof value === "object" && value !== null ? value : null;
|
|
2514
|
+
var opencode = {
|
|
2515
|
+
name: "opencode",
|
|
2516
|
+
async available(env) {
|
|
2517
|
+
const version2 = await versionOf("opencode", env);
|
|
2518
|
+
return version2 === null ? null : { name: "opencode", version: version2 };
|
|
2519
|
+
},
|
|
2520
|
+
invocation(context) {
|
|
2521
|
+
return {
|
|
2522
|
+
command: "opencode",
|
|
2523
|
+
// `--file` is a variadic option: anything after it is taken as another file to
|
|
2524
|
+
// attach, so the message goes first and the prompt file last.
|
|
2525
|
+
args: [
|
|
2526
|
+
"run",
|
|
2527
|
+
"Follow the instructions in the attached file.",
|
|
2528
|
+
"--format",
|
|
2529
|
+
"json",
|
|
2530
|
+
"--dir",
|
|
2531
|
+
context.workspace,
|
|
2532
|
+
"--file",
|
|
2533
|
+
context.promptFile,
|
|
2534
|
+
context.attachmentsDir
|
|
2535
|
+
],
|
|
2536
|
+
env: {
|
|
2537
|
+
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
2538
|
+
$schema: "https://opencode.ai/config.json",
|
|
2539
|
+
mcp: {
|
|
2540
|
+
aictiq: {
|
|
2541
|
+
type: "local",
|
|
2542
|
+
command: [context.mcpServer.command, ...context.mcpServer.args],
|
|
2543
|
+
enabled: true
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
})
|
|
2547
|
+
}
|
|
2548
|
+
};
|
|
2549
|
+
},
|
|
2550
|
+
parse(line) {
|
|
2551
|
+
let event;
|
|
2552
|
+
try {
|
|
2553
|
+
const parsed = JSON.parse(line);
|
|
2554
|
+
event = recordOf3(parsed) ?? (() => {
|
|
2555
|
+
throw new Error("not an object");
|
|
2556
|
+
})();
|
|
2557
|
+
} catch {
|
|
2558
|
+
return { log: line };
|
|
2559
|
+
}
|
|
2560
|
+
const part = recordOf3(event.part);
|
|
2561
|
+
if (!part) return { log: null };
|
|
2562
|
+
if (event.type === "text" && typeof part.text === "string") {
|
|
2563
|
+
return { log: part.text, result: part.text };
|
|
2564
|
+
}
|
|
2565
|
+
if (event.type === "tool_use") {
|
|
2566
|
+
const tool = typeof part.tool === "string" ? part.tool : typeof part.name === "string" ? part.name : "tool";
|
|
2567
|
+
const state = recordOf3(part.state);
|
|
2568
|
+
return { log: `\u2192 ${tool} ${limit4(JSON.stringify(state?.input ?? part.input ?? {}), 200)}` };
|
|
2569
|
+
}
|
|
2570
|
+
if (event.type === "step_finish") {
|
|
2571
|
+
const tokens = recordOf3(part.tokens);
|
|
2572
|
+
const cache = recordOf3(tokens?.cache);
|
|
2573
|
+
return {
|
|
2574
|
+
log: null,
|
|
2575
|
+
accumulate: true,
|
|
2576
|
+
costUsd: numberOrUndefined3(part.cost),
|
|
2577
|
+
inputTokens: sumOf(tokens?.input, cache?.read, cache?.write),
|
|
2578
|
+
outputTokens: sumOf(tokens?.output, tokens?.reasoning)
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
return { log: null };
|
|
2582
|
+
},
|
|
2583
|
+
outcome(exitCode, lastLines, lastResult) {
|
|
2584
|
+
if (exitCode !== 0) return failedOutcome3(exitCode, lastLines);
|
|
2585
|
+
return {
|
|
2586
|
+
outcome: "succeeded",
|
|
2587
|
+
failureReason: null,
|
|
2588
|
+
summary: limit4(lastResult ?? lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
function failedOutcome3(exitCode, lastLines) {
|
|
2593
|
+
return {
|
|
2594
|
+
outcome: "failed",
|
|
2595
|
+
failureReason: exitCode === null ? "harness-killed" : `harness-exit-${exitCode}`,
|
|
2596
|
+
summary: limit4(lastLines.slice(-20).join("\n"), 4e3) || null
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
function sumOf(...values) {
|
|
2600
|
+
const numbers = values.filter(
|
|
2601
|
+
(value) => typeof value === "number" && Number.isFinite(value)
|
|
2602
|
+
);
|
|
2603
|
+
return numbers.length === 0 ? void 0 : numbers.reduce((total, value) => total + value, 0);
|
|
2604
|
+
}
|
|
2605
|
+
function numberOrUndefined3(value) {
|
|
2606
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// src/runner/harness/index.ts
|
|
2610
|
+
var harnesses = { claude, codex, opencode };
|
|
2611
|
+
async function probeHarnesses(env) {
|
|
2612
|
+
const found = await Promise.all(Object.values(harnesses).map((harness) => harness.available(env)));
|
|
2613
|
+
return found.filter((info) => info !== null);
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// src/runner/loop.ts
|
|
2617
|
+
var KnownHarnesses = ["claude", "codex", "opencode"];
|
|
2618
|
+
var RunnerRevokedError = class extends Error {
|
|
2619
|
+
constructor(message3) {
|
|
2620
|
+
super(message3);
|
|
2621
|
+
this.name = "RunnerRevokedError";
|
|
2622
|
+
}
|
|
2623
|
+
};
|
|
2624
|
+
var RunnerLoop = class {
|
|
2625
|
+
options;
|
|
2626
|
+
active = /* @__PURE__ */ new Set();
|
|
2627
|
+
claimAbort = new AbortController();
|
|
2628
|
+
shutdown = new AbortController();
|
|
2629
|
+
stopping = false;
|
|
2630
|
+
revoked;
|
|
2631
|
+
wake;
|
|
2632
|
+
constructor(options) {
|
|
2633
|
+
this.options = options;
|
|
2634
|
+
}
|
|
2635
|
+
get inFlight() {
|
|
2636
|
+
return this.active.size;
|
|
2637
|
+
}
|
|
2638
|
+
stop() {
|
|
2639
|
+
this.stopping = true;
|
|
2640
|
+
this.claimAbort.abort();
|
|
2641
|
+
this.wake?.();
|
|
2642
|
+
}
|
|
2643
|
+
abort() {
|
|
2644
|
+
this.stop();
|
|
2645
|
+
this.shutdown.abort();
|
|
2646
|
+
}
|
|
2647
|
+
/** Resolves when the loop has stopped and every run it took is reported. */
|
|
2648
|
+
async run() {
|
|
2649
|
+
const { client, local } = this.options;
|
|
2650
|
+
let capabilities = await this.options.probe();
|
|
2651
|
+
const hello = await this.retrying(() => client.hello(capabilities));
|
|
2652
|
+
if (!hello) return;
|
|
2653
|
+
local(
|
|
2654
|
+
`Runner "${hello.name}" connected to ${client.baseUrl} (${hello.organizationSlug}); harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none detected"}`
|
|
2655
|
+
);
|
|
2656
|
+
const heartbeat = setInterval(() => {
|
|
2657
|
+
void (async () => {
|
|
2658
|
+
try {
|
|
2659
|
+
capabilities = await this.options.probe();
|
|
2660
|
+
await client.heartbeat(capabilities);
|
|
2661
|
+
} catch (error) {
|
|
2662
|
+
if (error instanceof RunnerHttpError && error.revoked) this.revoke(error);
|
|
2663
|
+
else local(`Heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2664
|
+
}
|
|
2665
|
+
})();
|
|
2666
|
+
}, hello.heartbeatIntervalSeconds * 1e3);
|
|
2667
|
+
try {
|
|
2668
|
+
let attempt = 0;
|
|
2669
|
+
while (!this.stopping) {
|
|
2670
|
+
const free = this.options.parallel - this.active.size;
|
|
2671
|
+
if (free <= 0) {
|
|
2672
|
+
await this.waitForSlot();
|
|
2673
|
+
continue;
|
|
2674
|
+
}
|
|
2675
|
+
const detected = capabilities.harnesses.map((h) => h.name);
|
|
2676
|
+
const offered = detected.length > 0 ? detected : [...KnownHarnesses];
|
|
2677
|
+
let claimed;
|
|
2678
|
+
try {
|
|
2679
|
+
claimed = await client.claim(offered, free, this.claimAbort.signal);
|
|
2680
|
+
attempt = 0;
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
if (this.stopping) break;
|
|
2683
|
+
if (error instanceof RunnerHttpError && error.revoked) {
|
|
2684
|
+
this.revoke(error);
|
|
2685
|
+
break;
|
|
2686
|
+
}
|
|
2687
|
+
attempt++;
|
|
2688
|
+
const wait = this.options.backoffMs?.(attempt) ?? Math.min(6e4, 1e3 * 2 ** (attempt - 1));
|
|
2689
|
+
local(
|
|
2690
|
+
`Claim failed (${error instanceof Error ? error.message : String(error)}); retrying in ${Math.round(wait / 1e3)}s`
|
|
2691
|
+
);
|
|
2692
|
+
await this.sleep(wait);
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
if (!claimed) continue;
|
|
2696
|
+
local(`Claimed ${claimed.itemKey} (run ${claimed.runId}, ${claimed.harness})`);
|
|
2697
|
+
const execution = this.options.execute(claimed, hello, this.shutdown.signal).then(() => void 0).catch((error) => {
|
|
2698
|
+
if (error instanceof RunnerHttpError && error.revoked) this.revoke(error);
|
|
2699
|
+
else
|
|
2700
|
+
local(
|
|
2701
|
+
`Run ${claimed.runId} ended with an error: ${error instanceof Error ? error.message : String(error)}`
|
|
2702
|
+
);
|
|
2703
|
+
}).finally(() => {
|
|
2704
|
+
this.active.delete(execution);
|
|
2705
|
+
this.wake?.();
|
|
2706
|
+
});
|
|
2707
|
+
this.active.add(execution);
|
|
2708
|
+
}
|
|
2709
|
+
if (this.active.size > 0) local(`Waiting for ${this.active.size} run(s) to finish\u2026`);
|
|
2710
|
+
await Promise.all([...this.active]);
|
|
2711
|
+
} finally {
|
|
2712
|
+
clearInterval(heartbeat);
|
|
2713
|
+
}
|
|
2714
|
+
if (this.revoked) throw this.revoked;
|
|
2715
|
+
}
|
|
2716
|
+
revoke(error) {
|
|
2717
|
+
this.revoked ??= new RunnerRevokedError(
|
|
2718
|
+
error.problem?.type?.endsWith("token-revoked") ? "This runner was disabled, deleted or rotated. Register it again with `aictiq runner register`." : `The instance refused the runner secret (${error.message}). Register it again with \`aictiq runner register\`.`
|
|
2719
|
+
);
|
|
2720
|
+
this.abort();
|
|
2721
|
+
}
|
|
2722
|
+
/** Retries network failures until stopped; a refusal is thrown. */
|
|
2723
|
+
async retrying(action) {
|
|
2724
|
+
for (let attempt = 1; !this.stopping; attempt++) {
|
|
2725
|
+
try {
|
|
2726
|
+
return await action();
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
if (error instanceof RunnerHttpError && error.revoked) {
|
|
2729
|
+
this.revoke(error);
|
|
2730
|
+
throw this.revoked;
|
|
2731
|
+
}
|
|
2732
|
+
if (!(error instanceof RunnerNetworkError)) throw error;
|
|
2733
|
+
const wait = this.options.backoffMs?.(attempt) ?? Math.min(6e4, 1e3 * 2 ** (attempt - 1));
|
|
2734
|
+
this.options.local(`${error.message}; retrying in ${Math.round(wait / 1e3)}s`);
|
|
2735
|
+
await this.sleep(wait);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return null;
|
|
2739
|
+
}
|
|
2740
|
+
waitForSlot() {
|
|
2741
|
+
return new Promise((resolve3) => {
|
|
2742
|
+
this.wake = () => {
|
|
2743
|
+
this.wake = void 0;
|
|
2744
|
+
resolve3();
|
|
2745
|
+
};
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
sleep(ms) {
|
|
2749
|
+
return new Promise((resolve3) => {
|
|
2750
|
+
const timer = setTimeout(done, ms);
|
|
2751
|
+
this.wake = done;
|
|
2752
|
+
function done() {
|
|
2753
|
+
clearTimeout(timer);
|
|
2754
|
+
resolve3();
|
|
2755
|
+
}
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
|
|
2760
|
+
// src/commands/runner.ts
|
|
2761
|
+
function runnerCommand(globals) {
|
|
2762
|
+
const runner = new Command7("runner").description(
|
|
2763
|
+
"Run agent work dispatched from a Aictiq instance on this machine"
|
|
2764
|
+
);
|
|
2765
|
+
runner.command("register").description("Store a runner secret (jrn_\u2026) from Factory \u2192 Runners and check it").option("--url <url>", "Base URL of the Aictiq instance").option("--token <token>", "Runner secret; read from stdin when omitted").option("--name <name>", "A local label for this runner (the instance keeps its own name)").action(async (options) => {
|
|
2766
|
+
const global = globals();
|
|
2767
|
+
const rawUrl = options.url ?? global.url;
|
|
2768
|
+
if (!rawUrl) {
|
|
2769
|
+
throw new CliError(
|
|
2770
|
+
"Pass --url with the address of your Aictiq instance.",
|
|
2771
|
+
ExitCode.Validation
|
|
2772
|
+
);
|
|
2773
|
+
}
|
|
2774
|
+
const token = options.token ?? global.token ?? process.env.AICTIQ_RUNNER_TOKEN ?? await promptSecret("Runner secret: ");
|
|
2775
|
+
if (!token?.startsWith("jrn_")) {
|
|
2776
|
+
throw new CliError(
|
|
2777
|
+
"A runner secret starts with jrn_. Create one under Factory \u2192 Runners.",
|
|
2778
|
+
ExitCode.Validation
|
|
2779
|
+
);
|
|
2780
|
+
}
|
|
2781
|
+
const url = rawUrl.replace(/\/+$/, "");
|
|
2782
|
+
const capabilities = await probe(1);
|
|
2783
|
+
const hello = await new RunnerClient({ baseUrl: url, token }).hello(capabilities).catch((error) => {
|
|
2784
|
+
throw asCliError(error);
|
|
2785
|
+
});
|
|
2786
|
+
const existing = readRunnerConfig();
|
|
2787
|
+
const config = {
|
|
2788
|
+
url,
|
|
2789
|
+
token,
|
|
2790
|
+
...options.name ? { name: options.name } : existing?.name ? { name: existing.name } : {},
|
|
2791
|
+
// Re-registering (a rotated secret) keeps the repository map someone built up.
|
|
2792
|
+
workspaces: existing?.workspaces ?? {},
|
|
2793
|
+
repoRoots: existing?.repoRoots ?? [],
|
|
2794
|
+
attachments: existing?.attachments ?? { maxCount: DefaultAttachmentMaxCount, maxBytes: DefaultAttachmentMaxBytes }
|
|
2795
|
+
};
|
|
2796
|
+
writeRunnerConfig(config);
|
|
2797
|
+
if (globals().json) {
|
|
2798
|
+
printJson({ url, runner: hello, capabilities, configPath: runnerConfigPath() });
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
print(
|
|
2802
|
+
`Registered runner "${hello.name}" with ${url} (${hello.organizationSlug}).
|
|
2803
|
+
Harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none found on PATH"}
|
|
2804
|
+
Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
2805
|
+
);
|
|
2806
|
+
});
|
|
2807
|
+
runner.command("map").description(
|
|
2808
|
+
"Map a project key to a local clone, for projects whose repository source is local"
|
|
2809
|
+
).argument("<projectKey>", "Project key, e.g. ACME").argument("[path]", "Path of the clone (default: the current directory); omit with --remove").option("--remove", "Remove the mapping").action((projectKey, path, options) => {
|
|
2810
|
+
const config = requireConfig();
|
|
2811
|
+
const key = projectKey.toUpperCase();
|
|
2812
|
+
if (options.remove) {
|
|
2813
|
+
delete config.workspaces[key];
|
|
2814
|
+
} else {
|
|
2815
|
+
const absolute = resolve2(path ?? ".");
|
|
2816
|
+
if (!existsSync3(absolute))
|
|
2817
|
+
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
2818
|
+
config.workspaces[key] = absolute;
|
|
2819
|
+
}
|
|
2820
|
+
writeRunnerConfig(config);
|
|
2821
|
+
print(
|
|
2822
|
+
options.remove ? `Removed the mapping for ${key}.` : `${key} \u2192 ${config.workspaces[key]}`
|
|
2823
|
+
);
|
|
2824
|
+
});
|
|
2825
|
+
runner.command("root").description(
|
|
2826
|
+
"Trust the web UI's path hint for local projects whose clone is under this directory"
|
|
2827
|
+
).argument("[path]", "Directory that holds your clones (default: the current directory)").option("--remove", "Stop trusting path hints under this directory").action((path, options) => {
|
|
2828
|
+
const config = requireConfig();
|
|
2829
|
+
const absolute = resolve2(path ?? ".");
|
|
2830
|
+
if (options.remove) {
|
|
2831
|
+
config.repoRoots = config.repoRoots.filter((root) => resolve2(root) !== absolute);
|
|
2832
|
+
} else {
|
|
2833
|
+
if (!existsSync3(absolute))
|
|
2834
|
+
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
2835
|
+
if (!config.repoRoots.some((root) => resolve2(root) === absolute))
|
|
2836
|
+
config.repoRoots.push(absolute);
|
|
2837
|
+
}
|
|
2838
|
+
writeRunnerConfig(config);
|
|
2839
|
+
print(
|
|
2840
|
+
options.remove ? `Path hints under ${absolute} are no longer used.` : `Path hints under ${absolute} are used for projects without a mapping.`
|
|
2841
|
+
);
|
|
2842
|
+
});
|
|
2843
|
+
runner.command("status").description("Show the registration, detected harnesses and mapped repositories").action(async () => {
|
|
2844
|
+
const config = requireConfig();
|
|
2845
|
+
const capabilities = await probe(1);
|
|
2846
|
+
let registration;
|
|
2847
|
+
let registrationText;
|
|
2848
|
+
try {
|
|
2849
|
+
const hello = await new RunnerClient({ baseUrl: config.url, token: config.token }).hello(
|
|
2850
|
+
capabilities
|
|
2851
|
+
);
|
|
2852
|
+
registration = hello;
|
|
2853
|
+
registrationText = `ok - "${hello.name}" in ${hello.organizationSlug}`;
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
registration = { error: error instanceof Error ? error.message : String(error) };
|
|
2856
|
+
registrationText = `failing - ${error instanceof Error ? error.message : String(error)}`;
|
|
2857
|
+
if (error instanceof RunnerHttpError && error.revoked) process.exitCode = ExitCode.Auth;
|
|
2858
|
+
}
|
|
2859
|
+
if (globals().json) {
|
|
2860
|
+
printJson({ url: config.url, registration, capabilities, workspaces: config.workspaces, repoRoots: config.repoRoots, attachments: config.attachments });
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
print(
|
|
2864
|
+
renderFields([
|
|
2865
|
+
["Instance", config.url],
|
|
2866
|
+
["Registration", registrationText],
|
|
2867
|
+
["Config", runnerConfigPath()],
|
|
2868
|
+
["Workspaces", defaultWorkspaceRoot()],
|
|
2869
|
+
["Attachments", `${config.attachments.maxCount} files / ${formatBytes2(config.attachments.maxBytes)} per run`],
|
|
2870
|
+
["CLI", `${version} (${capabilities.os}/${capabilities.arch})`]
|
|
2871
|
+
])
|
|
2872
|
+
);
|
|
2873
|
+
print("");
|
|
2874
|
+
print(
|
|
2875
|
+
capabilities.harnesses.length === 0 ? "No harness found on PATH (claude, codex, opencode)." : renderTable(capabilities.harnesses, [
|
|
2876
|
+
{ header: "HARNESS", value: (h) => h.name },
|
|
2877
|
+
{ header: "VERSION", value: (h) => h.version ?? "" }
|
|
2878
|
+
])
|
|
2879
|
+
);
|
|
2880
|
+
print("");
|
|
2881
|
+
const mapped = Object.entries(config.workspaces);
|
|
2882
|
+
print(
|
|
2883
|
+
mapped.length === 0 ? "No local repositories mapped (`aictiq runner map <KEY> <path>`)." : renderTable(mapped, [
|
|
2884
|
+
{ header: "PROJECT", value: ([key]) => key },
|
|
2885
|
+
{ header: "REPOSITORY", value: ([, path]) => path }
|
|
2886
|
+
])
|
|
2887
|
+
);
|
|
2888
|
+
print("");
|
|
2889
|
+
print(
|
|
2890
|
+
config.repoRoots.length === 0 ? "No repository roots; path hints from the web UI are not used (`aictiq runner root <path>`)." : renderTable(config.repoRoots, [{ header: "REPOSITORY ROOT", value: (root) => root }])
|
|
2891
|
+
);
|
|
2892
|
+
});
|
|
2893
|
+
runner.command("start").description("Poll for runs and execute them until interrupted").option("--parallel <n>", "Runs executed at the same time", "1").option("--keep-workspaces", "Leave each run\u2019s checkout on disk after it finishes").option("--workspace-root <path>", "Where run checkouts are created").action(
|
|
2894
|
+
async (options) => {
|
|
2895
|
+
const config = requireConfig();
|
|
2896
|
+
const parallel = Number.parseInt(options.parallel, 10);
|
|
2897
|
+
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
2898
|
+
throw new CliError("--parallel is between 1 and 16.", ExitCode.Validation);
|
|
2899
|
+
}
|
|
2900
|
+
const client = new RunnerClient({ baseUrl: config.url, token: config.token });
|
|
2901
|
+
const local = (message3) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${message3}
|
|
2902
|
+
`);
|
|
2903
|
+
const workspaceRoot = resolve2(options.workspaceRoot ?? defaultWorkspaceRoot());
|
|
2904
|
+
const loop = new RunnerLoop({
|
|
2905
|
+
client,
|
|
2906
|
+
parallel,
|
|
2907
|
+
probe: () => probe(parallel),
|
|
2908
|
+
local,
|
|
2909
|
+
execute: (run, hello, shutdown) => {
|
|
2910
|
+
const current = readRunnerConfig() ?? config;
|
|
2911
|
+
return executeRun(run, {
|
|
2912
|
+
client,
|
|
2913
|
+
hello,
|
|
2914
|
+
adapters: harnesses,
|
|
2915
|
+
runnerToken: config.token,
|
|
2916
|
+
shutdown,
|
|
2917
|
+
local,
|
|
2918
|
+
workspace: {
|
|
2919
|
+
root: workspaceRoot,
|
|
2920
|
+
repositories: current.workspaces,
|
|
2921
|
+
repoRoots: current.repoRoots,
|
|
2922
|
+
keep: options.keepWorkspaces === true,
|
|
2923
|
+
mcpServer: mcpServerCommand(),
|
|
2924
|
+
attachmentMaxCount: current.attachments.maxCount,
|
|
2925
|
+
attachmentMaxBytes: current.attachments.maxBytes
|
|
2926
|
+
}
|
|
2927
|
+
});
|
|
2928
|
+
}
|
|
2929
|
+
});
|
|
2930
|
+
let interrupts = 0;
|
|
2931
|
+
const onSignal = (signal2) => {
|
|
2932
|
+
interrupts++;
|
|
2933
|
+
if (interrupts === 1 && loop.inFlight > 0) {
|
|
2934
|
+
local(
|
|
2935
|
+
`${signal2}: finishing ${loop.inFlight} run(s) in flight; send it again to cancel them`
|
|
2936
|
+
);
|
|
2937
|
+
loop.stop();
|
|
2938
|
+
} else if (interrupts === 1) {
|
|
2939
|
+
loop.stop();
|
|
2940
|
+
} else {
|
|
2941
|
+
local(`${signal2}: cancelling runs in flight`);
|
|
2942
|
+
loop.abort();
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
process.on("SIGINT", onSignal);
|
|
2946
|
+
process.on("SIGTERM", onSignal);
|
|
2947
|
+
try {
|
|
2948
|
+
await loop.run();
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
throw asCliError(error);
|
|
2951
|
+
} finally {
|
|
2952
|
+
process.off("SIGINT", onSignal);
|
|
2953
|
+
process.off("SIGTERM", onSignal);
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
);
|
|
2957
|
+
runner.command("install-service").description("Print a systemd user unit that keeps `aictiq runner start` running").option("--parallel <n>", "Runs executed at the same time", "1").action((options) => {
|
|
2958
|
+
print(systemdUnit(process.execPath, cliEntry(), options.parallel));
|
|
2959
|
+
});
|
|
2960
|
+
return runner;
|
|
2961
|
+
}
|
|
2962
|
+
function systemdUnit(node, entry, parallel) {
|
|
2963
|
+
return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
|
|
2964
|
+
# systemctl --user daemon-reload
|
|
2965
|
+
# systemctl --user enable --now aictiq-runner
|
|
2966
|
+
# loginctl enable-linger "$USER" # keep it running after you log out
|
|
2967
|
+
#
|
|
2968
|
+
# The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2969
|
+
# credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
|
|
2970
|
+
# TimeoutStopSec bounds how long systemd waits before it cancels them.
|
|
2971
|
+
[Unit]
|
|
2972
|
+
Description=Aictiq runner
|
|
2973
|
+
After=network-online.target
|
|
2974
|
+
Wants=network-online.target
|
|
2975
|
+
|
|
2976
|
+
[Service]
|
|
2977
|
+
Type=simple
|
|
2978
|
+
ExecStart=${node} ${entry} runner start --parallel ${parallel}
|
|
2979
|
+
Restart=on-failure
|
|
2980
|
+
RestartSec=10
|
|
2981
|
+
# Exit 5 means the secret was revoked; restarting cannot fix that.
|
|
2982
|
+
RestartPreventExitStatus=5
|
|
2983
|
+
KillMode=mixed
|
|
2984
|
+
TimeoutStopSec=15min
|
|
2985
|
+
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
2986
|
+
|
|
2987
|
+
[Install]
|
|
2988
|
+
WantedBy=default.target
|
|
2989
|
+
`;
|
|
2990
|
+
}
|
|
2991
|
+
async function probe(maxParallel) {
|
|
2992
|
+
return {
|
|
2993
|
+
v: 1,
|
|
2994
|
+
harnesses: await probeHarnesses(),
|
|
2995
|
+
os: platform(),
|
|
2996
|
+
arch: arch(),
|
|
2997
|
+
cliVersion: version,
|
|
2998
|
+
maxParallel
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
function cliEntry() {
|
|
3002
|
+
return resolve2(process.argv[1] ?? "aictiq");
|
|
3003
|
+
}
|
|
3004
|
+
function mcpServerCommand() {
|
|
3005
|
+
return { command: process.execPath, args: [cliEntry(), "mcp"] };
|
|
3006
|
+
}
|
|
3007
|
+
function formatBytes2(value) {
|
|
3008
|
+
return value >= 1024 * 1024 ? `${(value / (1024 * 1024)).toFixed(1)} MiB` : `${value} bytes`;
|
|
3009
|
+
}
|
|
3010
|
+
function requireConfig() {
|
|
3011
|
+
const config = readRunnerConfig();
|
|
3012
|
+
if (!config) {
|
|
3013
|
+
throw new CliError(
|
|
3014
|
+
`This machine is not registered as a runner (${runnerConfigPath()}). Run \`aictiq runner register --url <url> --token <jrn_\u2026>\`.`,
|
|
3015
|
+
ExitCode.Auth
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
return config;
|
|
3019
|
+
}
|
|
3020
|
+
function asCliError(error) {
|
|
3021
|
+
if (error instanceof RunnerRevokedError) return new CliError(error.message, ExitCode.Auth);
|
|
3022
|
+
if (error instanceof RunnerHttpError) {
|
|
3023
|
+
return new CliError(
|
|
3024
|
+
error.status === 401 ? `The instance refused the runner secret: ${error.message}` : error.message,
|
|
3025
|
+
error.status === 401 ? ExitCode.Auth : ExitCode.Error
|
|
3026
|
+
);
|
|
3027
|
+
}
|
|
3028
|
+
return error;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
// src/commands/sprint.ts
|
|
3032
|
+
import { Command as Command8 } from "commander";
|
|
3033
|
+
function sprintCommand(globals) {
|
|
3034
|
+
const sprint = new Command8("sprint").description("Sprints");
|
|
3035
|
+
sprint.command("list").description("List a team's sprints, newest first").requiredOption("-t, --team <key>", "Team key, name or id").option("-p, --project <key>", "Project key; required when the team is named, not an id").action(async (options) => {
|
|
3036
|
+
const ctx = createContext(globals());
|
|
3037
|
+
const teamId = await resolveTeamId2(ctx, options.team, options.project);
|
|
3038
|
+
const sprints = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/teams/{teamId}/sprints", {
|
|
3039
|
+
path: { orgSlug: await ctx.org(), teamId }
|
|
3040
|
+
});
|
|
3041
|
+
if (ctx.json) return printJson(sprints);
|
|
3042
|
+
if (sprints.length === 0) return print("No sprints.");
|
|
3043
|
+
print(
|
|
3044
|
+
renderTable(sprints, [
|
|
3045
|
+
{ header: "ID", value: (s) => s.id },
|
|
3046
|
+
{ header: "NAME", value: (s) => s.name },
|
|
3047
|
+
{ header: "STATE", value: (s) => s.state },
|
|
3048
|
+
{ header: "STARTS", value: (s) => s.startsOn },
|
|
3049
|
+
{ header: "ENDS", value: (s) => s.endsOn },
|
|
3050
|
+
{
|
|
3051
|
+
header: "DONE",
|
|
3052
|
+
value: (s) => `${s.progress.completedItems}/${s.progress.totalItems}`,
|
|
3053
|
+
align: "right"
|
|
3054
|
+
}
|
|
3055
|
+
])
|
|
3056
|
+
);
|
|
3057
|
+
});
|
|
3058
|
+
sprint.command("view").description("Show a sprint and its taskboard").argument("<sprintId>", "Sprint id").option("--taskboard", "Include the taskboard rows").action(async (sprintId, options) => {
|
|
3059
|
+
const ctx = createContext(globals());
|
|
3060
|
+
const orgSlug = await ctx.org();
|
|
3061
|
+
const found = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/sprints/{sprintId}", { path: { orgSlug, sprintId } });
|
|
3062
|
+
const taskboard = options.taskboard ? await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/sprints/{sprintId}/taskboard", {
|
|
3063
|
+
path: { orgSlug, sprintId }
|
|
3064
|
+
}) : void 0;
|
|
3065
|
+
if (ctx.json) return printJson({ ...found, ...taskboard ? { taskboard } : {} });
|
|
3066
|
+
print(
|
|
3067
|
+
renderFields([
|
|
3068
|
+
["Sprint", found.name],
|
|
3069
|
+
["Goal", found.goal],
|
|
3070
|
+
["State", found.state],
|
|
3071
|
+
["Dates", `${found.startsOn} \u2192 ${found.endsOn}`],
|
|
3072
|
+
["Days left", String(found.daysLeft)],
|
|
3073
|
+
["Items", `${found.progress.completedItems}/${found.progress.totalItems}`],
|
|
3074
|
+
[
|
|
3075
|
+
"Points",
|
|
3076
|
+
`${formatNumber(found.progress.pointsDone)}/${formatNumber(found.progress.pointsTotal)}`
|
|
3077
|
+
],
|
|
3078
|
+
["Remaining hours", formatNumber(found.progress.remainingHours)]
|
|
3079
|
+
])
|
|
3080
|
+
);
|
|
3081
|
+
if (taskboard) {
|
|
3082
|
+
for (const row of taskboard.rows) {
|
|
3083
|
+
print("");
|
|
3084
|
+
print(`${row.parentKey ?? "(no parent)"} ${row.name}`);
|
|
3085
|
+
for (const cell of row.cells) {
|
|
3086
|
+
for (const task of cell.tasks) {
|
|
3087
|
+
print(` [${cell.name}] ${task.key} ${task.title}`);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
});
|
|
3093
|
+
return sprint;
|
|
3094
|
+
}
|
|
3095
|
+
async function resolveTeamId2(ctx, team, projectKey) {
|
|
3096
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(team)) return team;
|
|
3097
|
+
if (!projectKey) {
|
|
3098
|
+
throw new CliError(
|
|
3099
|
+
"Pass -p <projectKey> alongside a team key or name, or give the team id.",
|
|
3100
|
+
ExitCode.Validation
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
const teams = await getTeams(ctx, projectKey.toUpperCase());
|
|
3104
|
+
const needle = team.trim().toLowerCase();
|
|
3105
|
+
const found = teams.find((t) => t.key.toLowerCase() === needle || t.name.toLowerCase() === needle);
|
|
3106
|
+
if (!found) {
|
|
3107
|
+
throw new CliError(
|
|
3108
|
+
`No team "${team}" in ${projectKey}. Teams: ${teams.map((t) => t.key).join(", ") || "(none)"}.`,
|
|
3109
|
+
ExitCode.Validation
|
|
3110
|
+
);
|
|
3111
|
+
}
|
|
3112
|
+
return found.id;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// src/commands/wiki.ts
|
|
3116
|
+
import { Command as Command9 } from "commander";
|
|
3117
|
+
function wikiCommand(globals) {
|
|
3118
|
+
const wiki = new Command9("wiki").description("Project wiki pages");
|
|
3119
|
+
wiki.command("tree").description("List the page tree of a project").requiredOption("-p, --project <key>", "Project key, e.g. ACME").action(async (options) => {
|
|
3120
|
+
const ctx = createContext(globals());
|
|
3121
|
+
const pages = await fetchTree(ctx, options.project.toUpperCase());
|
|
3122
|
+
if (ctx.json) return printJson(pages);
|
|
3123
|
+
if (pages.length === 0) return print("No wiki pages.");
|
|
3124
|
+
const paths = pathsOf(pages);
|
|
3125
|
+
print(
|
|
3126
|
+
renderTable(pages, [
|
|
3127
|
+
{ header: "PATH", value: (p) => paths.get(p.id) ?? p.slug },
|
|
3128
|
+
{ header: "TITLE", value: (p) => p.title }
|
|
3129
|
+
])
|
|
3130
|
+
);
|
|
3131
|
+
});
|
|
3132
|
+
wiki.command("get").description("Print a wiki page as Markdown").argument("<project>", "Project key, e.g. ACME").argument("<path>", "Slug path, e.g. specs/login").action(async (project, path) => {
|
|
3133
|
+
const ctx = createContext(globals());
|
|
3134
|
+
const pages = await fetchTree(ctx, project.toUpperCase());
|
|
3135
|
+
const page = findByPath(pages, path);
|
|
3136
|
+
if (!page) throw new CliError(`No wiki page at "${path}" in ${project}.`, ExitCode.NotFound);
|
|
3137
|
+
const full = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/wiki/pages/{pageId}", {
|
|
3138
|
+
path: { orgSlug: await ctx.org(), pageId: page.id }
|
|
3139
|
+
});
|
|
3140
|
+
if (ctx.json) return printJson(full);
|
|
3141
|
+
print(full.contentMarkdown);
|
|
3142
|
+
});
|
|
3143
|
+
wiki.command("put").description("Create or replace a wiki page from Markdown").argument("<project>", "Project key, e.g. ACME").argument("<path>", "Slug path, e.g. specs/login").option("--file <path>", "Markdown file, or - for stdin", "-").option("-m, --body <markdown>", "Markdown body given inline").option("--title <title>", "Page title; defaults to the last path segment").option("--summary <text>", "Revision summary recorded in the history").action(
|
|
3144
|
+
async (project, path, options) => {
|
|
3145
|
+
const ctx = createContext(globals());
|
|
3146
|
+
const orgSlug = await ctx.org();
|
|
3147
|
+
const projectKey = project.toUpperCase();
|
|
3148
|
+
const content = await readBody(
|
|
3149
|
+
options.body,
|
|
3150
|
+
options.body ? void 0 : options.file ?? "-"
|
|
3151
|
+
);
|
|
3152
|
+
if (content === void 0 || content.trim().length === 0) {
|
|
3153
|
+
throw new CliError("The page body is empty.", ExitCode.Validation);
|
|
3154
|
+
}
|
|
3155
|
+
const pages = await fetchTree(ctx, projectKey);
|
|
3156
|
+
const existing = findByPath(pages, path);
|
|
3157
|
+
if (existing) {
|
|
3158
|
+
const current = await ctx.client.request("get", "/api/v1/orgs/{orgSlug}/wiki/pages/{pageId}", {
|
|
3159
|
+
path: { orgSlug, pageId: existing.id }
|
|
3160
|
+
});
|
|
3161
|
+
const updated = await ctx.client.request("patch", "/api/v1/orgs/{orgSlug}/wiki/pages/{pageId}", {
|
|
3162
|
+
path: { orgSlug, pageId: existing.id },
|
|
3163
|
+
body: {
|
|
3164
|
+
contentMd: content,
|
|
3165
|
+
version: current.version,
|
|
3166
|
+
...options.title ? { title: options.title } : {},
|
|
3167
|
+
...options.summary ? { summary: options.summary } : {}
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
if (ctx.json) return printJson(updated);
|
|
3171
|
+
return print(`Updated ${path} (revision ${updated.revisionNumber}).`);
|
|
3172
|
+
}
|
|
3173
|
+
const segments = path.split("/").filter((s) => s.length > 0);
|
|
3174
|
+
const parentPath = segments.slice(0, -1).join("/");
|
|
3175
|
+
const parent = parentPath ? findByPath(pages, parentPath) : void 0;
|
|
3176
|
+
if (parentPath && !parent) {
|
|
3177
|
+
throw new CliError(
|
|
3178
|
+
`The parent page "${parentPath}" does not exist; create it first.`,
|
|
3179
|
+
ExitCode.NotFound
|
|
3180
|
+
);
|
|
3181
|
+
}
|
|
3182
|
+
const created = await ctx.client.request("post", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/wiki/pages", {
|
|
3183
|
+
path: { orgSlug, projectKey },
|
|
3184
|
+
body: {
|
|
3185
|
+
title: options.title ?? titleFrom(segments.at(-1) ?? path),
|
|
3186
|
+
contentMd: content,
|
|
3187
|
+
...parent ? { parentId: parent.id } : {}
|
|
3188
|
+
}
|
|
3189
|
+
});
|
|
3190
|
+
if (ctx.json) return printJson(created);
|
|
3191
|
+
print(`Created ${created.slug} (${created.title}).`);
|
|
3192
|
+
}
|
|
3193
|
+
);
|
|
3194
|
+
return wiki;
|
|
3195
|
+
}
|
|
3196
|
+
async function fetchTree(ctx, projectKey) {
|
|
3197
|
+
return ctx.client.request("get", "/api/v1/orgs/{orgSlug}/projects/{projectKey}/wiki/tree", {
|
|
3198
|
+
path: { orgSlug: await ctx.org(), projectKey }
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
function pathsOf(pages) {
|
|
3202
|
+
const byId = new Map(pages.map((page) => [page.id, page]));
|
|
3203
|
+
const paths = /* @__PURE__ */ new Map();
|
|
3204
|
+
const walk = (page) => {
|
|
3205
|
+
const cached = paths.get(page.id);
|
|
3206
|
+
if (cached) return cached;
|
|
3207
|
+
const parent = page.parentId ? byId.get(page.parentId) : void 0;
|
|
3208
|
+
const path = parent ? `${walk(parent)}/${page.slug}` : page.slug;
|
|
3209
|
+
paths.set(page.id, path);
|
|
3210
|
+
return path;
|
|
3211
|
+
};
|
|
3212
|
+
for (const page of pages) walk(page);
|
|
3213
|
+
return paths;
|
|
3214
|
+
}
|
|
3215
|
+
function findByPath(pages, path) {
|
|
3216
|
+
const wanted = path.split("/").filter((s) => s.length > 0).join("/").toLowerCase();
|
|
3217
|
+
const paths = pathsOf(pages);
|
|
3218
|
+
return pages.find((page) => paths.get(page.id)?.toLowerCase() === wanted);
|
|
3219
|
+
}
|
|
3220
|
+
function titleFrom(slug) {
|
|
3221
|
+
const words = slug.replace(/[-_]+/g, " ").trim();
|
|
3222
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
// src/program.ts
|
|
3226
|
+
function createProgram() {
|
|
3227
|
+
const program = new Command10("aictiq").description("Aictiq from the command line, for people and for agents").version(version, "-v, --version").option("--url <url>", "Aictiq base URL (overrides AICTIQ_URL and the config file)").option("--token <token>", "Personal access token (overrides AICTIQ_TOKEN)").option("--org <slug>", "Organization slug (overrides AICTIQ_ORG)").option("--json", "Print machine-readable JSON instead of a table").showHelpAfterError();
|
|
3228
|
+
const globals = () => program.opts();
|
|
3229
|
+
program.addCommand(authCommand(globals));
|
|
3230
|
+
program.addCommand(attachmentCommand(globals));
|
|
3231
|
+
program.addCommand(projectCommand(globals));
|
|
3232
|
+
program.addCommand(itemCommand(globals));
|
|
3233
|
+
program.addCommand(sprintCommand(globals));
|
|
3234
|
+
program.addCommand(wikiCommand(globals));
|
|
3235
|
+
program.addCommand(runCommand(globals));
|
|
3236
|
+
program.addCommand(mcpCommand(globals));
|
|
3237
|
+
program.addCommand(runnerCommand(globals));
|
|
3238
|
+
return program;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// src/index.ts
|
|
3242
|
+
async function main() {
|
|
3243
|
+
const program = createProgram();
|
|
3244
|
+
program.exitOverride((error) => {
|
|
3245
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
3246
|
+
process.exit(ExitCode.Ok);
|
|
3247
|
+
}
|
|
3248
|
+
process.exit(error.exitCode === 0 ? ExitCode.Ok : ExitCode.Validation);
|
|
3249
|
+
});
|
|
3250
|
+
await program.parseAsync(process.argv);
|
|
3251
|
+
}
|
|
3252
|
+
main().catch((error) => {
|
|
3253
|
+
if (error instanceof CliError) {
|
|
3254
|
+
process.stderr.write(`${error.message}
|
|
3255
|
+
`);
|
|
3256
|
+
process.exit(error.exitCode);
|
|
3257
|
+
}
|
|
3258
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
3259
|
+
`);
|
|
3260
|
+
process.exit(ExitCode.Error);
|
|
3261
|
+
});
|
|
3262
|
+
//# sourceMappingURL=index.js.map
|