@outerlayer/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +202 -0
  3. package/README.md +320 -0
  4. package/dist/build-info.json +1 -0
  5. package/dist/chunk-4H56Y7O6.js +589 -0
  6. package/dist/chunk-A3WLZX2F.js +120 -0
  7. package/dist/chunk-A6NNKRJU.js +650 -0
  8. package/dist/chunk-D77LS3UI.js +42 -0
  9. package/dist/chunk-DEDV5V4U.js +87324 -0
  10. package/dist/chunk-ECSYRPCC.js +334 -0
  11. package/dist/chunk-IWIUYLDR.js +472 -0
  12. package/dist/chunk-JJP7YLMN.js +25 -0
  13. package/dist/chunk-KFYJV2ZG.js +7978 -0
  14. package/dist/chunk-OZ7C3XUE.js +34 -0
  15. package/dist/chunk-RCQXYLMO.js +96 -0
  16. package/dist/chunk-TFUIDMOB.js +34 -0
  17. package/dist/chunk-VNVZDWO3.js +10 -0
  18. package/dist/chunk-VU34RWDU.js +339 -0
  19. package/dist/chunk-WQ6VGRGZ.js +150 -0
  20. package/dist/chunk-XN2XZFTK.js +165 -0
  21. package/dist/chunk-YMTXR7PJ.js +200 -0
  22. package/dist/chunk-YRYMNBGQ.js +54 -0
  23. package/dist/chunk-Z5GEFMRW.js +45 -0
  24. package/dist/chunk-ZJSNLVCE.js +58 -0
  25. package/dist/cli-3JYGL2OB.js +5311 -0
  26. package/dist/config-POF7DEQW.js +7 -0
  27. package/dist/context-materialize-J6CRQ7O7.js +589 -0
  28. package/dist/emit-artifact-cmd-23F4YKQ3.js +339 -0
  29. package/dist/emit-cmd-TWSYYEZI.js +208 -0
  30. package/dist/emit-commit-credit-cmd-2GX5M2BR.js +121 -0
  31. package/dist/emit-finding-cmd-6K7ERPKG.js +235 -0
  32. package/dist/emit-result-cmd-NDFHMJX7.js +225 -0
  33. package/dist/hook-fast-CBW4ZETX.js +8 -0
  34. package/dist/hook-wrap-fast-36WGE6EX.js +8 -0
  35. package/dist/import-capture-cmd-EUMBGIV3.js +176 -0
  36. package/dist/import-ruler-cmd-7LH2QOMG.js +173 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +25 -0
  39. package/dist/init-PTBITAUO.js +103 -0
  40. package/dist/login-cmd-IRX6LZT7.js +62 -0
  41. package/dist/logs-TRNPQM42.js +63 -0
  42. package/dist/loop-NFSOBUUJ.js +646 -0
  43. package/dist/mcp-install-cmd-FDQH6SEN.js +75 -0
  44. package/dist/mcp-serve-cmd-57EZZOTL.js +123 -0
  45. package/dist/paths-D2VGWWFI.js +6 -0
  46. package/dist/pidfile-PTW76F56.js +8 -0
  47. package/dist/status-WGOJTXZF.js +94 -0
  48. package/dist/statusline-fast-Z5U5CEC6.js +8 -0
  49. package/dist/sync-cmd-C5UGI4SF.js +15 -0
  50. package/dist/watch-V3K4PESQ.js +70 -0
  51. package/dist/work-claim-cmd-FQDWQVT2.js +98 -0
  52. package/dist/work-cmd-CW26EZZO.js +16 -0
  53. package/dist/work-launch-YNNCKIH3.js +6 -0
  54. package/dist/work-pr-cmd-W2QNMWXP.js +115 -0
  55. package/package.json +58 -0
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { readFileSync } from 'fs';
4
+ import { execFileSync } from 'child_process';
5
+
6
+ createRequire(import.meta.url);
7
+ function startTimeOf(pid) {
8
+ if (process.platform === "linux") return startTimeOfLinux(pid);
9
+ return startTimeOfPs(pid);
10
+ }
11
+ function startTimeOfLinux(pid) {
12
+ try {
13
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
14
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim();
15
+ const fields = afterComm.split(/\s+/);
16
+ const starttime = fields[19];
17
+ return starttime ?? null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ function startTimeOfPs(pid) {
23
+ try {
24
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
25
+ return out.length > 0 ? out : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ function isSameProcess(pid, start) {
31
+ return startTimeOf(pid) === start;
32
+ }
33
+
34
+ export { isSameProcess, startTimeOf };
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { mkdirSync, appendFileSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ createRequire(import.meta.url);
7
+
8
+ // src/tracker-number.ts
9
+ var MAX_TRACKER_NUMBER = Number.MAX_SAFE_INTEGER;
10
+ function parseTrackerNumber(raw) {
11
+ if (raw === void 0) return void 0;
12
+ const text = typeof raw === "number" ? String(raw) : raw;
13
+ if (!/^\d+$/.test(text)) return void 0;
14
+ const n = Number(text);
15
+ if (!Number.isSafeInteger(n) || n <= 0) return void 0;
16
+ return n;
17
+ }
18
+ function trackerNumberError(flag, raw) {
19
+ return `invalid ${flag} "${raw}" \u2014 expected a positive integer no larger than ${MAX_TRACKER_NUMBER}`;
20
+ }
21
+
22
+ // src/work-launch.ts
23
+ var HOW_TO_CREATE_AN_ITEM = "outerlayer work add";
24
+ function parseWorkItemEnv(raw) {
25
+ if (raw === void 0) return { reason: "OUTERLAYER_WORK is not set" };
26
+ const value = raw.trim();
27
+ if (value === "" || value.toLowerCase() === "on") {
28
+ return {
29
+ reason: `OUTERLAYER_WORK=${JSON.stringify(raw)} does not name a work item \u2014 create one with \`${HOW_TO_CREATE_AN_ITEM}\` and pass its number`
30
+ };
31
+ }
32
+ const number = parseTrackerNumber(value);
33
+ if (number === void 0) {
34
+ return {
35
+ reason: `OUTERLAYER_WORK="${value}" does not name a work item \u2014 it must be a bare positive integer, the number \`${HOW_TO_CREATE_AN_ITEM}\` prints when the item is created`
36
+ };
37
+ }
38
+ return { number };
39
+ }
40
+ function launchesPath(home) {
41
+ return join(home, ".outerlayer", "spool", "launches.jsonl");
42
+ }
43
+ function appendLaunchRecord(input, home) {
44
+ mkdirSync(join(home, ".outerlayer", "spool"), { recursive: true });
45
+ const record = { sessionId: input.sessionId, item: input.item, t: input.t ?? (/* @__PURE__ */ new Date()).toISOString() };
46
+ appendFileSync(launchesPath(home), `${JSON.stringify(record)}
47
+ `);
48
+ }
49
+ var readLaunchSpool = (path) => {
50
+ try {
51
+ return { kind: "read", text: readFileSync(path, "utf8") };
52
+ } catch (err) {
53
+ const code = err.code;
54
+ if (code === "ENOENT") return { kind: "absent" };
55
+ return { kind: "unreadable", reason: err instanceof Error ? err.message : String(err) };
56
+ }
57
+ };
58
+ function launchStatusIsDecisive(state) {
59
+ return state.unreadable === void 0 && state.malformedLines === void 0;
60
+ }
61
+ function launchedItemForSession(sessionId, home, readImpl = readLaunchSpool) {
62
+ const read = readImpl(launchesPath(home));
63
+ if (read.kind !== "read") return void 0;
64
+ let found;
65
+ for (const line of read.text.split("\n")) {
66
+ try {
67
+ const record = JSON.parse(line);
68
+ if (record.sessionId === sessionId && typeof record.item === "number") {
69
+ found = record.item;
70
+ }
71
+ } catch {
72
+ }
73
+ }
74
+ return found;
75
+ }
76
+ function readLaunchSpoolState(home, readImpl = readLaunchSpool) {
77
+ const ids = /* @__PURE__ */ new Set();
78
+ const read = readImpl(launchesPath(home));
79
+ if (read.kind === "unreadable") return { ids, unreadable: read.reason };
80
+ if (read.kind === "absent") return { ids };
81
+ let malformedLines = 0;
82
+ for (const line of read.text.split("\n")) {
83
+ const text = line.trim();
84
+ if (!text) continue;
85
+ try {
86
+ const record = JSON.parse(text);
87
+ if (typeof record.sessionId === "string" && record.sessionId !== "") ids.add(record.sessionId);
88
+ else malformedLines += 1;
89
+ } catch {
90
+ malformedLines += 1;
91
+ }
92
+ }
93
+ return { ids, ...malformedLines > 0 ? { malformedLines } : {} };
94
+ }
95
+
96
+ export { appendLaunchRecord, launchStatusIsDecisive, launchedItemForSession, launchesPath, parseTrackerNumber, parseWorkItemEnv, readLaunchSpoolState, trackerNumberError };
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
4
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
5
+ import { join } from 'path';
6
+
7
+ createRequire(import.meta.url);
8
+ function cloudConfigPath(home = homeFromEnv()) {
9
+ return join(home, ".outerlayer", "config.json");
10
+ }
11
+ function readCloudConfig(home = homeFromEnv()) {
12
+ const path = cloudConfigPath(home);
13
+ if (!existsSync(path)) return {};
14
+ try {
15
+ return JSON.parse(readFileSync(path, "utf8"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+ function writeCloudConfig(config, home = homeFromEnv()) {
21
+ const path = cloudConfigPath(home);
22
+ const merged = { ...readCloudConfig(home) };
23
+ for (const [key, value] of Object.entries(config)) {
24
+ if (value !== void 0) merged[key] = value;
25
+ }
26
+ mkdirSync(join(path, ".."), { recursive: true });
27
+ writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", { mode: 384 });
28
+ try {
29
+ chmodSync(path, 384);
30
+ } catch {
31
+ }
32
+ }
33
+
34
+ export { cloudConfigPath, readCloudConfig, writeCloudConfig };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { homedir } from 'os';
4
+
5
+ createRequire(import.meta.url);
6
+ function homeFromEnv(env = process.env) {
7
+ return env.HOME || homedir();
8
+ }
9
+
10
+ export { homeFromEnv };
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { resolveRepoIdentity, detectActiveSession } from './chunk-DEDV5V4U.js';
4
+ import { parseTrackerNumber, trackerNumberError } from './chunk-RCQXYLMO.js';
5
+ import { readCloudConfig } from './chunk-TFUIDMOB.js';
6
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
7
+
8
+ createRequire(import.meta.url);
9
+
10
+ // src/work-cmd.ts
11
+ var GREEN = "\x1B[32m";
12
+ var DIM = "\x1B[2m";
13
+ var RESET = "\x1B[0m";
14
+ var WorkCommandError = class extends Error {
15
+ /** True when the same request is worth sending again: the gateway was
16
+ * unreachable, answered a body that never came from it, or failed in a way
17
+ * that is about the moment rather than the request (5xx, 429). A refusal
18
+ * (401, an unknown repository, a malformed subject) is not.
19
+ *
20
+ * `status` is the response's own status, absent when the request never
21
+ * reached a response. A caller that has to tell one refusal from another —
22
+ * a claim lost to another host from a key that no longer works — reads it
23
+ * rather than matching on the message. */
24
+ constructor(message, retryable = false, status) {
25
+ super(message);
26
+ this.retryable = retryable;
27
+ this.status = status;
28
+ }
29
+ };
30
+ var WORK_ADD_RETRY_DELAYS_MS = [1e3, 5e3, 15e3, 6e4];
31
+ function resolveCredentials(opts) {
32
+ const home = opts.home ?? homeFromEnv();
33
+ const env = opts.env ?? process.env;
34
+ const fileConfig = readCloudConfig(home);
35
+ const url = opts.url ?? env.OUTERLAYER_URL ?? fileConfig.url;
36
+ const apiKey = opts.apiKey ?? env.OUTERLAYER_API_KEY ?? fileConfig.apiKey;
37
+ const appId = opts.appId ?? env.OUTERLAYER_APP_ID ?? fileConfig.appId;
38
+ if (!url || !apiKey || !appId) {
39
+ const missing = [!url && "--url", !apiKey && "--api-key", !appId && "--app-id"].filter(Boolean).join(", ");
40
+ throw new WorkCommandError(
41
+ `missing ${missing}. Run \`outerlayer login --url <your gateway>\` and paste the key at the prompt, or set OUTERLAYER_URL / OUTERLAYER_API_KEY / OUTERLAYER_APP_ID. Mint a key in the dashboard: Settings \u2192 API keys.`
42
+ );
43
+ }
44
+ return { url, apiKey, appId };
45
+ }
46
+ function resolveRepository(repoFlag, cwd) {
47
+ if (repoFlag) return repoFlag;
48
+ const identity = resolveRepoIdentity(cwd);
49
+ if (identity.gitRepo) return identity.gitRepo;
50
+ throw new WorkCommandError(
51
+ "no repository. Pass --repo <owner/name>, or run from a git checkout with a remote."
52
+ );
53
+ }
54
+ function parsePositiveInt(raw, flag) {
55
+ if (raw === void 0) return void 0;
56
+ const n = parseTrackerNumber(raw);
57
+ if (n === void 0) throw new WorkCommandError(trackerNumberError(flag, raw));
58
+ return n;
59
+ }
60
+ function parseRetries(raw) {
61
+ const n = parseTrackerNumber(raw);
62
+ return n === void 0 ? 0 : Math.min(n, WORK_ADD_RETRY_DELAYS_MS.length);
63
+ }
64
+ function resolveSubject(opts) {
65
+ const issue = parsePositiveInt(opts.issue, "--issue");
66
+ if (issue === void 0) {
67
+ throw new WorkCommandError("pass --issue");
68
+ }
69
+ return { issue };
70
+ }
71
+ async function postJson(url, path, apiKey, appId, body, fetchImpl) {
72
+ let response;
73
+ const endpoint = new URL(path, url).toString();
74
+ try {
75
+ response = await fetchImpl(endpoint, {
76
+ method: "POST",
77
+ headers: {
78
+ "content-type": "application/json",
79
+ authorization: `Bearer ${apiKey}`,
80
+ "x-outerlayer-app-id": appId
81
+ },
82
+ body: JSON.stringify(body)
83
+ });
84
+ } catch (err) {
85
+ throw new WorkCommandError(`network error reaching ${endpoint}: ${String(err)}`, true);
86
+ }
87
+ if (response.status === 401) {
88
+ throw new WorkCommandError(
89
+ "not authorized \u2014 the API key is unknown, expired, or bound to a different app. Mint a fresh key: dashboard \u2192 Settings \u2192 API keys.",
90
+ false,
91
+ 401
92
+ );
93
+ }
94
+ let json;
95
+ try {
96
+ json = await response.json();
97
+ } catch {
98
+ throw new WorkCommandError(
99
+ `${endpoint} answered ${response.status} with a body that is not JSON \u2014 the request may not have reached the gateway`,
100
+ true,
101
+ response.status
102
+ );
103
+ }
104
+ if (!response.ok) {
105
+ throw new WorkCommandError(
106
+ json.error?.message ?? `request failed (${response.status})`,
107
+ response.status >= 500 || response.status === 429,
108
+ response.status
109
+ );
110
+ }
111
+ return { status: response.status, data: json.data ?? json };
112
+ }
113
+ async function getJson(url, path, apiKey, appId, fetchImpl) {
114
+ const endpoint = new URL(path, url).toString();
115
+ let response;
116
+ try {
117
+ response = await fetchImpl(endpoint, {
118
+ method: "GET",
119
+ headers: { authorization: `Bearer ${apiKey}`, "x-outerlayer-app-id": appId }
120
+ });
121
+ } catch (err) {
122
+ throw new WorkCommandError(`network error reaching ${endpoint}: ${String(err)}`);
123
+ }
124
+ if (response.status === 401) {
125
+ throw new WorkCommandError(
126
+ "not authorized \u2014 the API key is unknown, expired, or bound to a different app. Mint a fresh key: dashboard \u2192 Settings \u2192 API keys.",
127
+ false,
128
+ 401
129
+ );
130
+ }
131
+ let json;
132
+ try {
133
+ json = await response.json();
134
+ } catch {
135
+ throw new WorkCommandError(
136
+ `${endpoint} answered ${response.status} with a body that is not JSON \u2014 the request may not have reached the gateway`,
137
+ true,
138
+ response.status
139
+ );
140
+ }
141
+ if (!response.ok) {
142
+ throw new WorkCommandError(
143
+ json.error?.message ?? `request failed (${response.status})`,
144
+ response.status >= 500 || response.status === 429,
145
+ response.status
146
+ );
147
+ }
148
+ return { status: response.status, data: json.data ?? json, pagination: json.pagination ?? null };
149
+ }
150
+ async function findLiveItem(creds, repository, subject, fetchImpl) {
151
+ const query = new URLSearchParams({ repository, issue: String(subject.issue) });
152
+ const page = await getJson(
153
+ creds.url,
154
+ `/v1/work-items?${query.toString()}`,
155
+ creds.apiKey,
156
+ creds.appId,
157
+ fetchImpl
158
+ );
159
+ const items = page.data ?? [];
160
+ return items[0] ?? null;
161
+ }
162
+ function environmentFacts(opts) {
163
+ const identity = resolveRepoIdentity(opts.cwd);
164
+ const sessionId = opts.sessionIdOverride ?? detectActiveSession({
165
+ env: opts.env,
166
+ home: opts.home,
167
+ cwds: identity.repoRoot !== void 0 ? [identity.repoRoot, opts.cwd] : [opts.cwd],
168
+ nowMs: Date.now()
169
+ });
170
+ const ci = opts.env.GITHUB_ACTIONS && opts.env.GITHUB_RUN_ID ? { runId: opts.env.GITHUB_RUN_ID } : void 0;
171
+ return {
172
+ ...sessionId !== void 0 ? { sessionId } : {},
173
+ ...ci ? { ci } : {}
174
+ };
175
+ }
176
+ async function runWorkAdd(opts) {
177
+ const cwd = opts.cwd ?? process.cwd();
178
+ const env = opts.env ?? process.env;
179
+ const home = opts.home ?? homeFromEnv(env);
180
+ const { url, apiKey, appId } = resolveCredentials(opts);
181
+ const subject = resolveSubject(opts);
182
+ const repository = resolveRepository(opts.repo, cwd);
183
+ const fetchImpl = opts.fetchImpl ?? fetch;
184
+ const body = {
185
+ subject: { repository, ...subject },
186
+ note: opts.note,
187
+ ...environmentFacts({ cwd, env, home, sessionIdOverride: opts.sessionId })
188
+ };
189
+ const retries = parseRetries(opts.retry);
190
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
191
+ let data;
192
+ for (let attempt = 0; ; attempt += 1) {
193
+ try {
194
+ data = (await postJson(url, "/v1/work-items", apiKey, appId, body, fetchImpl)).data;
195
+ break;
196
+ } catch (err) {
197
+ if (attempt >= retries || !(err instanceof WorkCommandError) || !err.retryable) throw err;
198
+ await sleep(WORK_ADD_RETRY_DELAYS_MS[attempt]);
199
+ }
200
+ }
201
+ const stageLine = data.recomputed === false ? `stage ${data.stage} (not yet derived \u2014 the hourly sweep settles it)` : `stage ${data.stage}`;
202
+ const output = opts.json ? JSON.stringify(data) : `${GREEN}\u2713${RESET} added ${DIM}\xB7${RESET} ${data.number != null ? `#${data.number}` : data.workItemId} ${DIM}\xB7${RESET} attestor ${data.attestor} ${DIM}\xB7${RESET} ${stageLine}`;
203
+ if (!opts.quiet) process.stdout.write(output + "\n");
204
+ return { data, output };
205
+ }
206
+ async function runWorkLinkSession(opts) {
207
+ const cwd = opts.cwd ?? process.cwd();
208
+ const env = opts.env ?? process.env;
209
+ const home = opts.home ?? homeFromEnv(env);
210
+ const { url, apiKey, appId } = resolveCredentials(opts);
211
+ const number = parsePositiveInt(opts.number, "number");
212
+ if (number === void 0) throw new WorkCommandError("pass the item's number");
213
+ const { sessionId } = environmentFacts({ cwd, env, home, sessionIdOverride: opts.sessionId });
214
+ if (!sessionId) {
215
+ throw new WorkCommandError("no session id detected \u2014 pass --session-id or run from a live session");
216
+ }
217
+ const fetchImpl = opts.fetchImpl ?? fetch;
218
+ const identity = resolveRepoIdentity(cwd);
219
+ const body = { sessionId, repository: identity.gitRepo ?? "" };
220
+ const retries = parseRetries(opts.retry);
221
+ const sleep = opts.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
222
+ let data;
223
+ for (let attempt = 0; ; attempt += 1) {
224
+ try {
225
+ data = (await postJson(url, `/v1/work-items/${number}/sessions`, apiKey, appId, body, fetchImpl)).data;
226
+ break;
227
+ } catch (err) {
228
+ if (attempt >= retries || !(err instanceof WorkCommandError) || !err.retryable) throw err;
229
+ await sleep(WORK_ADD_RETRY_DELAYS_MS[attempt]);
230
+ }
231
+ }
232
+ const output = opts.json ? JSON.stringify(data) : `${GREEN}\u2713${RESET} linked ${DIM}\xB7${RESET} session ${sessionId} ${DIM}\xB7${RESET} #${number}`;
233
+ if (!opts.quiet) process.stdout.write(output + "\n");
234
+ return { data, output };
235
+ }
236
+ async function runWorkRemove(opts) {
237
+ const cwd = opts.cwd ?? process.cwd();
238
+ const env = opts.env ?? process.env;
239
+ const home = opts.home ?? homeFromEnv(env);
240
+ const creds = resolveCredentials(opts);
241
+ if (!opts.reason) {
242
+ throw new WorkCommandError("--reason is required \u2014 a removal records why");
243
+ }
244
+ const fetchImpl = opts.fetchImpl ?? fetch;
245
+ const itemNumber = parsePositiveInt(opts.item, "--item");
246
+ if (itemNumber !== void 0) {
247
+ const { data: data2 } = await postJson(
248
+ creds.url,
249
+ `/v1/work-items/${itemNumber}/remove`,
250
+ creds.apiKey,
251
+ creds.appId,
252
+ { reason: opts.reason, ...environmentFacts({ cwd, env, home }) },
253
+ fetchImpl
254
+ );
255
+ const output2 = opts.json ? JSON.stringify(data2) : `${GREEN}\u2713${RESET} removed ${DIM}\xB7${RESET} ${data2.workItemId} ${DIM}\xB7${RESET} withdrawn ${data2.itemWithdrawn}`;
256
+ if (!opts.quiet) process.stdout.write(output2 + "\n");
257
+ return { data: data2, output: output2 };
258
+ }
259
+ const subject = resolveSubject(opts);
260
+ const repository = resolveRepository(opts.repo, cwd);
261
+ const number = subject.issue;
262
+ const item = await findLiveItem(creds, repository, subject, fetchImpl);
263
+ if (!item) {
264
+ const data2 = { workItemId: null, removed: true, itemWithdrawn: false, noLiveAddition: true };
265
+ const output2 = opts.json ? JSON.stringify(data2) : `${GREEN}\u2713${RESET} nothing to remove ${DIM}\xB7${RESET} no live addition on ${repository}#${number}`;
266
+ if (!opts.quiet) process.stdout.write(output2 + "\n");
267
+ return { data: data2, output: output2 };
268
+ }
269
+ const { data } = await postJson(
270
+ creds.url,
271
+ `/v1/work-items/${item.id}/remove`,
272
+ creds.apiKey,
273
+ creds.appId,
274
+ { reason: opts.reason, ...environmentFacts({ cwd, env, home }) },
275
+ fetchImpl
276
+ );
277
+ const output = opts.json ? JSON.stringify(data) : `${GREEN}\u2713${RESET} removed ${DIM}\xB7${RESET} ${data.workItemId} ${DIM}\xB7${RESET} withdrawn ${data.itemWithdrawn}`;
278
+ if (!opts.quiet) process.stdout.write(output + "\n");
279
+ return { data, output };
280
+ }
281
+ async function runWorkStatus(opts) {
282
+ const cwd = opts.cwd ?? process.cwd();
283
+ const creds = resolveCredentials(opts);
284
+ const fetchByItem = opts.item !== void 0 ? parsePositiveInt(opts.item, "--item") : void 0;
285
+ if (fetchByItem !== void 0) {
286
+ const { data: data2 } = await getJson(
287
+ creds.url,
288
+ `/v1/work-items/${fetchByItem}`,
289
+ creds.apiKey,
290
+ creds.appId,
291
+ opts.fetchImpl ?? fetch
292
+ );
293
+ const body = opts.json ? JSON.stringify(data2) : JSON.stringify(data2, null, 2);
294
+ if (!opts.quiet) process.stdout.write(body + "\n");
295
+ return { data: data2, output: body };
296
+ }
297
+ const subject = resolveSubject(opts);
298
+ const repository = resolveRepository(opts.repo, cwd);
299
+ const fetchImpl = opts.fetchImpl ?? fetch;
300
+ const number = subject.issue;
301
+ const item = await findLiveItem(creds, repository, subject, fetchImpl);
302
+ if (!item) {
303
+ throw new WorkCommandError(`no live item for ${repository}#${number}`);
304
+ }
305
+ const { data } = await getJson(creds.url, `/v1/work-items/${item.id}`, creds.apiKey, creds.appId, fetchImpl);
306
+ const output = opts.json ? JSON.stringify(data) : `${JSON.stringify(data, null, 2)}`;
307
+ if (!opts.quiet) process.stdout.write(output + "\n");
308
+ return { data, output };
309
+ }
310
+ async function runWorkList(opts) {
311
+ const cwd = opts.cwd ?? process.cwd();
312
+ const { url, apiKey, appId } = resolveCredentials(opts);
313
+ const fetchImpl = opts.fetchImpl ?? fetch;
314
+ const repository = opts.repo ?? resolveRepoIdentity(cwd).gitRepo;
315
+ if (opts.needs !== void 0 && opts.needs !== "amend") {
316
+ throw new WorkCommandError('--needs only accepts "amend"');
317
+ }
318
+ const query = new URLSearchParams();
319
+ if (repository) query.set("repository", repository);
320
+ if (opts.stage) query.set("stage", opts.stage);
321
+ if (opts.section) query.set("section", opts.section);
322
+ if (opts.unclaimed) query.set("unclaimed", "true");
323
+ if (opts.startable) query.set("startable", "true");
324
+ if (opts.needs) query.set("needs", opts.needs);
325
+ const items = [];
326
+ let cursor = null;
327
+ do {
328
+ if (cursor) query.set("cursor", cursor);
329
+ const page = await getJson(url, `/v1/work-items?${query.toString()}`, apiKey, appId, fetchImpl);
330
+ if (Array.isArray(page.data)) items.push(...page.data);
331
+ cursor = page.pagination?.cursor ?? null;
332
+ } while (cursor);
333
+ const data = items;
334
+ const output = opts.json ? JSON.stringify(data) : items.length === 0 ? "no live work items" : items.map((i) => `${i.trackerKey} ${DIM}\xB7${RESET} ${i.stage} ${DIM}\xB7${RESET} ${i.section} ${DIM}\xB7${RESET} ${i.title}`).join("\n");
335
+ if (!opts.quiet) process.stdout.write(output + "\n");
336
+ return { data, output };
337
+ }
338
+
339
+ export { WorkCommandError, findLiveItem, getJson, parsePositiveInt, postJson, resolveCredentials, resolveRepository, runWorkAdd, runWorkLinkSession, runWorkList, runWorkRemove, runWorkStatus };
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { homeFromEnv } from './chunk-VNVZDWO3.js';
4
+ import { existsSync, statSync, accessSync, constants, readFileSync } from 'fs';
5
+ import { hostname } from 'os';
6
+ import { join } from 'path';
7
+
8
+ createRequire(import.meta.url);
9
+ var RUNNER_QUEUES = ["implement", "amend"];
10
+ var RunnerConfigError = class extends Error {
11
+ };
12
+ var DEFAULT_CONCURRENCY = 1;
13
+ var DEFAULT_POLL_SECONDS = 60;
14
+ var DEFAULT_TIME_LIMIT_MINUTES = 480;
15
+ var DEFAULT_IDLE_LIMIT_MINUTES = 30;
16
+ function defaultRunnerConfigPath(home = homeFromEnv()) {
17
+ return join(home, ".outerlayer", "config.json");
18
+ }
19
+ function fail(configPath, key, reason) {
20
+ throw new RunnerConfigError(`${configPath}: ${key} ${reason}`);
21
+ }
22
+ function readConfigFile(configPath) {
23
+ if (!existsSync(configPath)) return {};
24
+ const text = readFileSync(configPath, "utf8");
25
+ try {
26
+ return JSON.parse(text);
27
+ } catch (err) {
28
+ throw new RunnerConfigError(
29
+ `${configPath}: is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
30
+ );
31
+ }
32
+ }
33
+ var RUNNER_KEYS = [
34
+ "host",
35
+ "queues",
36
+ "concurrency",
37
+ "pollSeconds",
38
+ "timeLimitMinutes",
39
+ "idleLimitMinutes",
40
+ "commands",
41
+ "hooks"
42
+ ];
43
+ var RUNNER_NUMBERS = [
44
+ { key: "concurrency", min: 1 },
45
+ // Below five seconds a runner is asking the gateway a question it has
46
+ // just been answered.
47
+ { key: "pollSeconds", min: 5 },
48
+ { key: "timeLimitMinutes", min: 1 },
49
+ { key: "idleLimitMinutes", min: 1 }
50
+ ];
51
+ function nearestKey(unknown, accepted) {
52
+ const lower = unknown.toLowerCase();
53
+ let best;
54
+ for (const key of accepted) {
55
+ const distance = editDistance(lower, key.toLowerCase());
56
+ if (distance <= Math.max(1, Math.floor(key.length / 4)) && (!best || distance < best.distance)) {
57
+ best = { key, distance };
58
+ }
59
+ }
60
+ return best?.key;
61
+ }
62
+ function editDistance(a, b) {
63
+ const rows = Array.from({ length: a.length + 1 }, (_unused, i) => [i, ...Array(b.length).fill(0)]);
64
+ for (let j = 0; j <= b.length; j++) rows[0][j] = j;
65
+ for (let i = 1; i <= a.length; i++) {
66
+ for (let j = 1; j <= b.length; j++) {
67
+ rows[i][j] = Math.min(
68
+ rows[i - 1][j] + 1,
69
+ rows[i][j - 1] + 1,
70
+ rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
71
+ );
72
+ }
73
+ }
74
+ return rows[a.length][b.length];
75
+ }
76
+ function checkHooks(config) {
77
+ const named = [
78
+ ["runner.hooks.provision", config.hooks.provision],
79
+ ["runner.hooks.cleanup", config.hooks.cleanup],
80
+ ["runner.hooks.report", config.hooks.report]
81
+ ];
82
+ for (const [key, path] of named) {
83
+ if (path === void 0) continue;
84
+ if (path.includes("$") || path.startsWith("~")) {
85
+ return `${key}: ${path} \u2014 hooks are executable paths, not shell lines; $HOME and ~ are not expanded`;
86
+ }
87
+ if (!existsSync(path)) return `${key}: ${path} does not exist`;
88
+ if (!statSync(path).isFile()) return `${key}: ${path} is not a file`;
89
+ try {
90
+ accessSync(path, constants.X_OK);
91
+ } catch {
92
+ return `${key}: ${path} is not executable`;
93
+ }
94
+ }
95
+ return void 0;
96
+ }
97
+ function loadRunnerConfig(configPath) {
98
+ const config = readConfigFile(configPath);
99
+ if (!config.url || !config.apiKey || !config.appId) {
100
+ fail(configPath, "url/apiKey/appId", "missing \u2014 run `outerlayer login` against this config file first");
101
+ }
102
+ const runner = config.runner;
103
+ if (!runner) {
104
+ fail(configPath, "runner", "block is missing");
105
+ }
106
+ for (const key of Object.keys(runner)) {
107
+ if (RUNNER_KEYS.includes(key)) continue;
108
+ const near = nearestKey(key, RUNNER_KEYS);
109
+ fail(configPath, `runner.${key}`, near ? `is not a key this block accepts; did you mean ${near}?` : "is not a key this block accepts");
110
+ }
111
+ for (const { key, min } of RUNNER_NUMBERS) {
112
+ const value = runner[key];
113
+ if (value === void 0) continue;
114
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
115
+ fail(configPath, `runner.${key}`, `must be a whole number of at least ${min}`);
116
+ }
117
+ }
118
+ const queues = runner.queues && runner.queues.length > 0 ? runner.queues : [...RUNNER_QUEUES];
119
+ for (const queue of queues) {
120
+ if (!RUNNER_QUEUES.includes(queue)) {
121
+ fail(configPath, "runner.queues", `names an unknown queue "${queue}" \u2014 must be implement or amend`);
122
+ }
123
+ if (!runner.commands?.[queue]) {
124
+ fail(configPath, `runner.commands.${queue}`, "is required for an enabled queue");
125
+ }
126
+ }
127
+ if (!runner.hooks?.provision) fail(configPath, "runner.hooks.provision", "is required");
128
+ if (!runner.hooks?.cleanup) fail(configPath, "runner.hooks.cleanup", "is required");
129
+ return {
130
+ configPath,
131
+ url: config.url,
132
+ apiKey: config.apiKey,
133
+ appId: config.appId,
134
+ repos: config.repos,
135
+ host: runner.host ?? hostname(),
136
+ queues,
137
+ concurrency: runner.concurrency ?? DEFAULT_CONCURRENCY,
138
+ pollSeconds: runner.pollSeconds ?? DEFAULT_POLL_SECONDS,
139
+ timeLimitMinutes: runner.timeLimitMinutes ?? DEFAULT_TIME_LIMIT_MINUTES,
140
+ idleLimitMinutes: runner.idleLimitMinutes ?? DEFAULT_IDLE_LIMIT_MINUTES,
141
+ commands: runner.commands ?? {},
142
+ hooks: {
143
+ provision: runner.hooks.provision,
144
+ cleanup: runner.hooks.cleanup,
145
+ report: runner.hooks.report
146
+ }
147
+ };
148
+ }
149
+
150
+ export { RunnerConfigError, checkHooks, defaultRunnerConfigPath, loadRunnerConfig };