@rness/cli 0.4.0 → 0.5.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.
@@ -0,0 +1,3857 @@
1
+ // src/core/fs.ts
2
+ import {
3
+ access,
4
+ lstat,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ writeFile
9
+ } from "fs/promises";
10
+ import { basename, dirname, join } from "path";
11
+ async function exists(p) {
12
+ try {
13
+ await access(p);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function isSymlink(p) {
20
+ try {
21
+ return (await lstat(p)).isSymbolicLink();
22
+ } catch (e) {
23
+ if (e.code === "ENOENT") return false;
24
+ throw e;
25
+ }
26
+ }
27
+ async function readOrNull(p) {
28
+ try {
29
+ return await readFile(p, "utf8");
30
+ } catch (e) {
31
+ if (e.code === "ENOENT") return null;
32
+ throw e;
33
+ }
34
+ }
35
+ var sequence = 0;
36
+ async function writeFileAtomic(p, text) {
37
+ sequence += 1;
38
+ const tmp = join(dirname(p), `.${basename(p)}.${process.pid}.${sequence}.tmp`);
39
+ await writeFile(tmp, text, "utf8");
40
+ try {
41
+ await rename(tmp, p);
42
+ } catch (e) {
43
+ await rm(tmp, { force: true }).catch(() => void 0);
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ // src/core/auth.ts
49
+ import { chmod, mkdir, open, readFile as readFile2, rm as rm2, stat } from "fs/promises";
50
+ import { homedir } from "os";
51
+ import { join as join2 } from "path";
52
+
53
+ // src/core/device-flow.ts
54
+ var GITHUB_CLIENT_ID = "Ov23liv1ia2UWeWttsOK";
55
+ var GITHUB_WEB = "https://github.com";
56
+ var SCOPES = "repo read:org";
57
+ function clientId() {
58
+ return process.env["RNESS_GITHUB_CLIENT_ID"] ?? GITHUB_CLIENT_ID;
59
+ }
60
+ function webBase() {
61
+ return (process.env["RNESS_GITHUB_WEB"] ?? GITHUB_WEB).replace(/\/+$/, "");
62
+ }
63
+ var LoginError = class extends Error {
64
+ };
65
+ var RefreshRejected = class extends Error {
66
+ };
67
+ function reason(e) {
68
+ if (e instanceof Error) {
69
+ return e.name === "TimeoutError" ? "timed out" : e.message;
70
+ }
71
+ return String(e);
72
+ }
73
+ async function post(path, params) {
74
+ let res;
75
+ try {
76
+ res = await fetch(`${webBase()}${path}`, {
77
+ method: "POST",
78
+ headers: {
79
+ Accept: "application/json",
80
+ "Content-Type": "application/x-www-form-urlencoded",
81
+ "User-Agent": "rness-cli"
82
+ },
83
+ body: new URLSearchParams(params).toString(),
84
+ signal: AbortSignal.timeout(15e3)
85
+ });
86
+ } catch (e) {
87
+ throw new Error(`cannot reach github.com: ${reason(e)}`, { cause: e });
88
+ }
89
+ let body;
90
+ try {
91
+ body = await res.json();
92
+ } catch (e) {
93
+ throw new Error(`github.com answered ${res.status} for ${path}`, {
94
+ cause: e
95
+ });
96
+ }
97
+ if (body === null || typeof body !== "object")
98
+ throw new Error(`github.com answered an unexpected body for ${path}`);
99
+ return body;
100
+ }
101
+ var str = (v) => typeof v === "string" ? v : null;
102
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
103
+ function tokenSet(body, now) {
104
+ const accessToken = str(body["access_token"]);
105
+ if (accessToken === null) return null;
106
+ const expiresIn = num(body["expires_in"]);
107
+ const refreshIn = num(body["refresh_token_expires_in"]);
108
+ return {
109
+ accessToken,
110
+ expiresAt: expiresIn === null ? null : now + expiresIn * 1e3,
111
+ refreshToken: str(body["refresh_token"]),
112
+ refreshExpiresAt: refreshIn === null ? null : now + refreshIn * 1e3,
113
+ scopes: str(body["scope"]) ?? ""
114
+ };
115
+ }
116
+ async function requestDeviceCode() {
117
+ const id = clientId();
118
+ if (id === "")
119
+ throw new LoginError(
120
+ "rness has no GitHub client ID yet; set RNESS_GITHUB_CLIENT_ID"
121
+ );
122
+ const body = await post("/login/device/code", {
123
+ client_id: id,
124
+ scope: SCOPES
125
+ });
126
+ const deviceCode = str(body["device_code"]);
127
+ const userCode = str(body["user_code"]);
128
+ const verificationUri = str(body["verification_uri"]);
129
+ if (deviceCode === null || userCode === null || verificationUri === null) {
130
+ const error = str(body["error"]);
131
+ throw new LoginError(
132
+ error === "device_flow_disabled" ? "the device flow is not enabled on the rness GitHub app" : `github.com refused to start the login${error === null ? "" : ` (${error})`}`
133
+ );
134
+ }
135
+ return {
136
+ deviceCode,
137
+ userCode,
138
+ verificationUri,
139
+ expiresIn: num(body["expires_in"]) ?? 900,
140
+ interval: num(body["interval"]) ?? 5
141
+ };
142
+ }
143
+ async function pollForToken(code, deps = {}) {
144
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
145
+ const now = deps.now ?? Date.now;
146
+ const deadline = now() + code.expiresIn * 1e3;
147
+ let interval = code.interval;
148
+ for (; ; ) {
149
+ await sleep(interval * 1e3);
150
+ const body = await post("/login/oauth/access_token", {
151
+ client_id: clientId(),
152
+ device_code: code.deviceCode,
153
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
154
+ });
155
+ const tokens = tokenSet(body, now());
156
+ if (tokens !== null) return tokens;
157
+ const error = str(body["error"]);
158
+ if (error === "authorization_pending") {
159
+ if (now() > deadline)
160
+ throw new LoginError("the code expired before it was approved");
161
+ continue;
162
+ }
163
+ if (error === "slow_down") {
164
+ interval = (num(body["interval"]) ?? interval) + 5;
165
+ continue;
166
+ }
167
+ if (error === "expired_token")
168
+ throw new LoginError("the code expired before it was approved");
169
+ if (error === "access_denied")
170
+ throw new LoginError("the login was cancelled on github.com");
171
+ throw new LoginError(`github.com refused the login (${error ?? "unknown"})`);
172
+ }
173
+ }
174
+ async function refreshTokenSet(refreshToken, now = Date.now) {
175
+ const body = await post("/login/oauth/access_token", {
176
+ client_id: clientId(),
177
+ grant_type: "refresh_token",
178
+ refresh_token: refreshToken
179
+ });
180
+ const tokens = tokenSet(body, now());
181
+ if (tokens !== null) return tokens;
182
+ throw new RefreshRejected(str(body["error"]) ?? "refused");
183
+ }
184
+
185
+ // src/core/auth.ts
186
+ function configDir() {
187
+ const xdg = process.env["XDG_CONFIG_HOME"];
188
+ if (xdg !== void 0 && xdg !== "") return join2(xdg, "rness");
189
+ const appData = process.env["APPDATA"];
190
+ if (process.platform === "win32" && appData !== void 0 && appData !== "")
191
+ return join2(appData, "rness");
192
+ return join2(homedir(), ".config", "rness");
193
+ }
194
+ var authFile = () => join2(configDir(), "auth.json");
195
+ var lockFile = () => join2(configDir(), "auth.lock");
196
+ function isTokenSet(v) {
197
+ if (v === null || typeof v !== "object") return false;
198
+ const t = v;
199
+ return typeof t["accessToken"] === "string" && t["accessToken"] !== "";
200
+ }
201
+ async function readAuth() {
202
+ try {
203
+ const parsed = JSON.parse(await readFile2(authFile(), "utf8"));
204
+ const entry = parsed !== null && typeof parsed === "object" ? parsed["github.com"] : void 0;
205
+ if (entry === null || typeof entry !== "object") return null;
206
+ const { login, tokens } = entry;
207
+ if (typeof login !== "string" || !isTokenSet(tokens)) return null;
208
+ return {
209
+ login,
210
+ tokens: {
211
+ accessToken: tokens.accessToken,
212
+ expiresAt: tokens.expiresAt ?? null,
213
+ refreshToken: tokens.refreshToken ?? null,
214
+ refreshExpiresAt: tokens.refreshExpiresAt ?? null,
215
+ scopes: tokens.scopes ?? ""
216
+ }
217
+ };
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+ async function writeAuth(login) {
223
+ const dir = configDir();
224
+ await mkdir(dir, { recursive: true, mode: 448 });
225
+ const file = authFile();
226
+ await writeFileAtomic(
227
+ file,
228
+ `${JSON.stringify({ "github.com": login }, null, 2)}
229
+ `
230
+ );
231
+ await chmod(file, 384);
232
+ }
233
+ async function clearAuth() {
234
+ await rm2(authFile(), { force: true });
235
+ }
236
+ function envToken() {
237
+ for (const source of ["GITHUB_TOKEN", "GH_TOKEN"]) {
238
+ const token = process.env[source];
239
+ if (token !== void 0 && token !== "")
240
+ return { token, source, login: null };
241
+ }
242
+ return null;
243
+ }
244
+ var REFRESH_MARGIN_MS = 5 * 6e4;
245
+ var LOCK_WAIT_MS = 1e4;
246
+ var LOCK_STALE_MS = 3e4;
247
+ var fresh = (tokens, now) => tokens.expiresAt === null || tokens.expiresAt - now > REFRESH_MARGIN_MS;
248
+ async function withLock(work) {
249
+ await mkdir(configDir(), { recursive: true, mode: 448 });
250
+ const file = lockFile();
251
+ const deadline = Date.now() + LOCK_WAIT_MS;
252
+ for (; ; ) {
253
+ try {
254
+ const handle = await open(file, "wx");
255
+ await handle.close();
256
+ break;
257
+ } catch (e) {
258
+ if (e.code !== "EEXIST") throw e;
259
+ const age = await stat(file).then(
260
+ (s) => Date.now() - s.mtimeMs,
261
+ () => 0
262
+ );
263
+ if (age > LOCK_STALE_MS) await rm2(file, { force: true });
264
+ else if (Date.now() > deadline) return null;
265
+ else await new Promise((r) => setTimeout(r, 50));
266
+ }
267
+ }
268
+ try {
269
+ return await work();
270
+ } finally {
271
+ await rm2(file, { force: true });
272
+ }
273
+ }
274
+ async function resolveToken(deps = {}) {
275
+ const env = envToken();
276
+ if (env !== null) return env;
277
+ const now = deps.now ?? Date.now;
278
+ const stored = await readAuth();
279
+ if (stored === null) return null;
280
+ const answer = (s) => ({
281
+ token: s.tokens.accessToken,
282
+ source: "login",
283
+ login: s.login
284
+ });
285
+ if (fresh(stored.tokens, now())) return answer(stored);
286
+ const refreshed = await withLock(async () => {
287
+ const current = await readAuth();
288
+ if (current === null) return null;
289
+ if (fresh(current.tokens, now())) return current;
290
+ if (current.tokens.refreshToken === null) return null;
291
+ try {
292
+ const tokens = await refreshTokenSet(current.tokens.refreshToken, now);
293
+ const next = { login: current.login, tokens };
294
+ await writeAuth(next);
295
+ return next;
296
+ } catch (e) {
297
+ if (e instanceof RefreshRejected) {
298
+ await clearAuth();
299
+ (deps.notify ?? ((l) => process.stderr.write(`${l}
300
+ `)))(
301
+ "your GitHub login expired; run rness login"
302
+ );
303
+ return null;
304
+ }
305
+ const expiresAt = current.tokens.expiresAt;
306
+ return expiresAt !== null && expiresAt > now() ? current : null;
307
+ }
308
+ });
309
+ return refreshed === null ? null : answer(refreshed);
310
+ }
311
+
312
+ // src/core/github.ts
313
+ var DEFAULT_GITHUB_API = "https://api.github.com";
314
+ var MAX_PAGES = 50;
315
+ var PER_PAGE = 100;
316
+ var ADD_LATER = "; add repositories later with rness add <repo>";
317
+ var NEXT = /<[^>]*>\s*;\s*rel="next"/;
318
+ function isRepository(value) {
319
+ if (value === null || typeof value !== "object") return false;
320
+ const r = value;
321
+ return typeof r["name"] === "string" && typeof r["private"] === "boolean" && typeof r["archived"] === "boolean";
322
+ }
323
+ function reason2(e) {
324
+ if (e instanceof Error && e.cause instanceof Error) return e.cause.message;
325
+ return e instanceof Error ? e.message : String(e);
326
+ }
327
+ async function getPage(url, headers, timeoutMs) {
328
+ const signal = AbortSignal.timeout(timeoutMs);
329
+ let res;
330
+ try {
331
+ res = await fetch(url, { headers, signal });
332
+ } catch (e) {
333
+ throw new Error(`cannot reach the GitHub API: ${reason2(e)}`, { cause: e });
334
+ }
335
+ if (!res.ok) {
336
+ await res.body?.cancel().catch(() => void 0);
337
+ return { status: res.status, headers: res.headers, body: void 0 };
338
+ }
339
+ let text;
340
+ try {
341
+ text = await res.text();
342
+ } catch (e) {
343
+ throw new Error(`cannot reach the GitHub API: ${reason2(e)}`, { cause: e });
344
+ }
345
+ try {
346
+ return { status: res.status, headers: res.headers, body: JSON.parse(text) };
347
+ } catch (e) {
348
+ throw new Error(
349
+ `GitHub API answered invalid JSON for ${new URL(url).pathname}`,
350
+ { cause: e }
351
+ );
352
+ }
353
+ }
354
+ function httpError(page, path) {
355
+ const cause = { status: page.status, path };
356
+ if (page.status === 401)
357
+ return new Error("GitHub rejected the token (401)", { cause });
358
+ if (page.status === 429 || page.status === 403 && (page.headers.get("x-ratelimit-remaining") === "0" || page.headers.has("retry-after"))) {
359
+ return new Error(
360
+ "GitHub API rate limit reached; set GITHUB_TOKEN or add repositories later with rness add",
361
+ { cause }
362
+ );
363
+ }
364
+ return new Error(
365
+ `GitHub API answered ${page.status} for ${path}${ADD_LATER}`,
366
+ { cause }
367
+ );
368
+ }
369
+ async function listRepositories(owner, options) {
370
+ const base = (options.apiBase ?? DEFAULT_GITHUB_API).replace(/\/+$/, "");
371
+ const timeoutMs = options.timeoutMs ?? 15e3;
372
+ const headers = {
373
+ Accept: "application/vnd.github+json",
374
+ "X-GitHub-Api-Version": "2022-11-28",
375
+ "User-Agent": "rness-cli"
376
+ };
377
+ if (options.token !== void 0 && options.token !== "")
378
+ headers["Authorization"] = `Bearer ${options.token}`;
379
+ const encoded = encodeURIComponent(owner);
380
+ const ORG = {
381
+ path: `/orgs/${encoded}/repos`,
382
+ query: "type=all",
383
+ owner: "org"
384
+ };
385
+ const USER = {
386
+ path: `/users/${encoded}/repos`,
387
+ query: "type=owner",
388
+ owner: "user"
389
+ };
390
+ const SELF = {
391
+ path: "/user/repos",
392
+ query: "affiliation=owner",
393
+ owner: "self"
394
+ };
395
+ const url = (k, n) => `${base}${k.path}?${k.query}&per_page=${PER_PAGE}&page=${n}`;
396
+ let kind = ORG;
397
+ const repositories = [];
398
+ for (let n = 1; n <= MAX_PAGES; n++) {
399
+ let page = await getPage(url(kind, n), headers, timeoutMs);
400
+ if (n === 1 && page.status === 404) {
401
+ const self = await options.self?.() ?? null;
402
+ kind = self?.toLowerCase() === owner.toLowerCase() ? SELF : USER;
403
+ page = await getPage(url(kind, n), headers, timeoutMs);
404
+ if (page.status === 404) {
405
+ throw new Error(
406
+ `GitHub has no organization or user named "${owner}" (or it is not visible to you)${ADD_LATER}`,
407
+ { cause: { status: 404, path: kind.path } }
408
+ );
409
+ }
410
+ }
411
+ if (page.status < 200 || page.status >= 300)
412
+ throw httpError(page, kind.path);
413
+ if (!Array.isArray(page.body) || !page.body.every(isRepository)) {
414
+ throw new Error(
415
+ `GitHub API answered an unexpected body for ${kind.path}`,
416
+ {
417
+ cause: page.body
418
+ }
419
+ );
420
+ }
421
+ for (const r of page.body)
422
+ repositories.push({
423
+ name: r.name,
424
+ private: r.private,
425
+ archived: r.archived
426
+ });
427
+ if (!NEXT.test(page.headers.get("link") ?? ""))
428
+ return {
429
+ repositories,
430
+ owner: kind.owner,
431
+ truncated: false
432
+ };
433
+ }
434
+ return {
435
+ repositories,
436
+ owner: kind.owner,
437
+ truncated: true
438
+ };
439
+ }
440
+ function apiHeaders(token) {
441
+ return {
442
+ Accept: "application/vnd.github+json",
443
+ "X-GitHub-Api-Version": "2022-11-28",
444
+ "User-Agent": "rness-cli",
445
+ Authorization: `Bearer ${token}`
446
+ };
447
+ }
448
+ async function getJson(path, options) {
449
+ const base = (options.apiBase ?? DEFAULT_GITHUB_API).replace(/\/+$/, "");
450
+ let res;
451
+ try {
452
+ res = await fetch(`${base}${path}`, {
453
+ headers: apiHeaders(options.token),
454
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15e3)
455
+ });
456
+ } catch (e) {
457
+ throw new Error(`cannot reach the GitHub API: ${reason2(e)}`, { cause: e });
458
+ }
459
+ const body = await res.json().catch(() => void 0);
460
+ return { status: res.status, body };
461
+ }
462
+ async function getUser(options) {
463
+ const { status: status2, body } = await getJson("/user", options);
464
+ if (status2 === 401) throw new Error("GitHub rejected the token (401)");
465
+ const login = body !== null && typeof body === "object" ? body["login"] : void 0;
466
+ if (status2 !== 200 || typeof login !== "string")
467
+ throw new Error(`GitHub API answered ${status2} for /user`);
468
+ return { login };
469
+ }
470
+ async function listUserOrganizations(options) {
471
+ const { status: status2, body } = await getJson(
472
+ `/user/orgs?per_page=${PER_PAGE}`,
473
+ options
474
+ );
475
+ if (status2 !== 200 || !Array.isArray(body))
476
+ throw new Error(`GitHub API answered ${status2} for /user/orgs`);
477
+ return body.map(
478
+ (o) => o !== null && typeof o === "object" ? o["login"] : void 0
479
+ ).filter((l) => typeof l === "string");
480
+ }
481
+ async function getOrgMembership(org, options) {
482
+ const { status: status2, body } = await getJson(
483
+ `/user/memberships/orgs/${encodeURIComponent(org)}`,
484
+ options
485
+ );
486
+ if (status2 === 200) {
487
+ const state = body !== null && typeof body === "object" ? body["state"] : void 0;
488
+ return state === "active" ? "member" : "not-member";
489
+ }
490
+ if (status2 === 404) return "not-member";
491
+ if (status2 === 403) {
492
+ const message = body !== null && typeof body === "object" ? String(body["message"] ?? "") : "";
493
+ if (/OAuth App access restrictions/i.test(message)) return "restricted";
494
+ }
495
+ return "unknown";
496
+ }
497
+ async function createRepository(owner, name, options) {
498
+ const base = (options.apiBase ?? DEFAULT_GITHUB_API).replace(/\/+$/, "");
499
+ const post2 = async (path) => {
500
+ let res;
501
+ try {
502
+ res = await fetch(`${base}${path}`, {
503
+ method: "POST",
504
+ headers: {
505
+ ...apiHeaders(options.token),
506
+ "Content-Type": "application/json"
507
+ },
508
+ body: JSON.stringify({ name, private: true }),
509
+ signal: AbortSignal.timeout(options.timeoutMs ?? 15e3)
510
+ });
511
+ } catch (e) {
512
+ throw new Error(`cannot reach the GitHub API: ${reason2(e)}`, { cause: e });
513
+ }
514
+ const body = await res.json().catch(() => void 0);
515
+ const message = body !== null && typeof body === "object" ? String(body["message"] ?? "") : "";
516
+ return { status: res.status, message };
517
+ };
518
+ let answer = await post2(`/orgs/${encodeURIComponent(owner)}/repos`);
519
+ if (answer.status === 404 && options.self !== void 0 && options.self !== null && options.self.toLowerCase() === owner.toLowerCase())
520
+ answer = await post2("/user/repos");
521
+ if (answer.status === 201) return { kind: "created" };
522
+ if (answer.status === 422) return { kind: "exists" };
523
+ return {
524
+ kind: "refused",
525
+ reason: answer.message === "" ? `GitHub answered ${answer.status}` : `${answer.message} (${answer.status})`
526
+ };
527
+ }
528
+
529
+ // src/core/github-oauth-provider.ts
530
+ var GITHUB_HTTPS = "https://github.com/";
531
+ var GitHubOAuthProvider = class {
532
+ #token;
533
+ #apiBase;
534
+ #login;
535
+ get authenticated() {
536
+ return this.#token !== null;
537
+ }
538
+ constructor(options) {
539
+ this.#token = options.token;
540
+ this.#apiBase = options.apiBase;
541
+ }
542
+ #api(token) {
543
+ return this.#apiBase === void 0 ? { token } : { token, apiBase: this.#apiBase };
544
+ }
545
+ identity() {
546
+ this.#login ??= (async () => {
547
+ const t = this.#token;
548
+ if (t === null) return null;
549
+ if (t.login !== null) return { login: t.login };
550
+ try {
551
+ return await getUser(this.#api(t.token));
552
+ } catch {
553
+ return null;
554
+ }
555
+ })();
556
+ return this.#login;
557
+ }
558
+ async listOrganizations() {
559
+ if (this.#token === null) return [];
560
+ const logins = await listUserOrganizations(this.#api(this.#token.token));
561
+ return logins.map((login) => ({ login }));
562
+ }
563
+ async organizationAccess(org) {
564
+ if (this.#token === null) return "unknown";
565
+ try {
566
+ return await getOrgMembership(org, this.#api(this.#token.token));
567
+ } catch {
568
+ return "unknown";
569
+ }
570
+ }
571
+ async listRepositories(owner) {
572
+ return listRepositories(owner, {
573
+ token: this.#token?.token,
574
+ ...this.#apiBase === void 0 ? {} : { apiBase: this.#apiBase },
575
+ self: async () => (await this.identity())?.login ?? null
576
+ });
577
+ }
578
+ async createRepository(owner, name) {
579
+ if (this.#token === null)
580
+ return { kind: "refused", reason: "not logged in" };
581
+ return createRepository(owner, name, {
582
+ ...this.#api(this.#token.token),
583
+ self: (await this.identity())?.login ?? null
584
+ });
585
+ }
586
+ credentialsFor(url) {
587
+ if (this.#token === null || !url.startsWith(GITHUB_HTTPS)) return null;
588
+ return { username: "x-access-token", password: this.#token.token };
589
+ }
590
+ };
591
+ async function githubProvider(apiBase) {
592
+ return new GitHubOAuthProvider({
593
+ token: await resolveToken(),
594
+ ...apiBase === void 0 ? {} : { apiBase }
595
+ });
596
+ }
597
+
598
+ // src/core/remote.ts
599
+ import { spawn } from "child_process";
600
+
601
+ // src/core/git.ts
602
+ import { execFile } from "child_process";
603
+ import { dirname as dirname2, resolve } from "path";
604
+ import { promisify } from "util";
605
+ var execFileP = promisify(execFile);
606
+ var ENV_HELPER = '!f() { test "$1" = get && printf "username=%s\\npassword=%s\\n" "$RNESS_GIT_USERNAME" "$RNESS_GIT_TOKEN"; }; f';
607
+ function withCredentials(credentials) {
608
+ if (credentials === null || credentials === void 0)
609
+ return { args: [], env: {} };
610
+ return {
611
+ args: ["-c", "credential.helper=", "-c", `credential.helper=${ENV_HELPER}`],
612
+ env: {
613
+ RNESS_GIT_USERNAME: credentials.username,
614
+ RNESS_GIT_TOKEN: credentials.password
615
+ }
616
+ };
617
+ }
618
+ async function git(args, cwd, credentials) {
619
+ const auth = withCredentials(credentials);
620
+ try {
621
+ const { stdout } = await execFileP("git", [...auth.args, ...args], {
622
+ ...cwd === void 0 ? {} : { cwd },
623
+ env: {
624
+ ...process.env,
625
+ ...auth.env,
626
+ GIT_TERMINAL_PROMPT: "0",
627
+ // Pin the command to `cwd` itself: without a ceiling, git walks up and
628
+ // a plain directory under `org/` would report — or be pulled into —
629
+ // whatever repository happens to sit above the workspace root. git
630
+ // ignores a relative ceiling entry, hence the resolve().
631
+ ...cwd === void 0 ? {} : { GIT_CEILING_DIRECTORIES: dirname2(resolve(cwd)) }
632
+ },
633
+ maxBuffer: 16 * 1024 * 1024
634
+ });
635
+ return stdout;
636
+ } catch (e) {
637
+ const err = e;
638
+ const text = err.stderr !== void 0 && err.stderr !== "" ? err.stderr : err.message ?? "unknown error";
639
+ const first = text.split("\n").find((l) => l.trim() !== "") ?? "unknown error";
640
+ throw new Error(`git ${args[0] ?? ""} failed: ${first.trim()}`, {
641
+ cause: e
642
+ });
643
+ }
644
+ }
645
+ function positional(value, what) {
646
+ if (value.startsWith("-"))
647
+ throw new Error(`refusing suspicious ${what}: ${value}`);
648
+ return value;
649
+ }
650
+ async function clone(url, dir, credentials) {
651
+ await git(
652
+ [
653
+ "clone",
654
+ "--quiet",
655
+ "--",
656
+ positional(url, "repository url"),
657
+ positional(dir, "directory")
658
+ ],
659
+ void 0,
660
+ credentials
661
+ );
662
+ }
663
+ async function isClean(dir, ignore = []) {
664
+ const pathspec = ignore.map((p) => `:!${p}`);
665
+ return (await git(
666
+ [
667
+ "status",
668
+ "--porcelain",
669
+ ...pathspec.length > 0 ? ["--", ...pathspec] : []
670
+ ],
671
+ dir
672
+ )).trim() === "";
673
+ }
674
+ async function pullFastForward(dir, credentials) {
675
+ await git(["pull", "--ff-only", "--quiet"], dir, credentials);
676
+ }
677
+ async function publish(dir, url, credentials) {
678
+ await git(["remote", "add", "origin", positional(url, "repository url")], dir);
679
+ await git(["push", "--quiet", "-u", "origin", "main"], dir, credentials);
680
+ }
681
+ async function originUrl(dir) {
682
+ try {
683
+ return (await git(["remote", "get-url", "origin"], dir)).trim();
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+ async function init(dir) {
689
+ await git(["init", "-q", "-b", "main"], dir);
690
+ }
691
+ async function commitAll(dir, message) {
692
+ await git(["add", "-A"], dir);
693
+ await git(["commit", "-q", "-m", message], dir);
694
+ }
695
+
696
+ // src/core/remote.ts
697
+ var DEFAULT_HOST = "https://github.com/";
698
+ var SSH_HOST = "git@github.com:";
699
+ function repoUrl(host, org, repo) {
700
+ return `${host}${org}/${repo}.git`;
701
+ }
702
+ var AUTH = [
703
+ /authentication failed/i,
704
+ /permission denied/i,
705
+ /could not read username/i,
706
+ /terminal prompts disabled/i
707
+ ];
708
+ var NOT_FOUND = [
709
+ /repository .*not found/i,
710
+ /does not appear to be a git repository/i
711
+ ];
712
+ function firstLine(text) {
713
+ return text.split("\n").find((l) => l.trim() !== "")?.trim() ?? "unknown error";
714
+ }
715
+ function classifyProbe(url, exitCode, stderr) {
716
+ if (exitCode === 0 || exitCode === 2) return { kind: "found" };
717
+ if (AUTH.some((r) => r.test(stderr)))
718
+ return {
719
+ kind: "error",
720
+ message: `cannot access ${url}: ${firstLine(stderr)}`
721
+ };
722
+ if (NOT_FOUND.some((r) => r.test(stderr))) return { kind: "not-found" };
723
+ return { kind: "error", message: `cannot reach ${url}: ${firstLine(stderr)}` };
724
+ }
725
+ var STDERR_FLUSH_MS = 200;
726
+ async function probeRemote(url, timeoutMs = 6e4, credentials) {
727
+ positional(url, "repository url");
728
+ return new Promise((resolve4) => {
729
+ const auth = withCredentials(credentials);
730
+ const child = spawn(
731
+ "git",
732
+ [...auth.args, "ls-remote", "--exit-code", url, "HEAD"],
733
+ {
734
+ stdio: ["ignore", "ignore", "pipe"],
735
+ env: { ...process.env, ...auth.env, GIT_TERMINAL_PROMPT: "0" }
736
+ }
737
+ );
738
+ const chunks = [];
739
+ child.stderr?.setEncoding("utf8");
740
+ child.stderr?.on("data", (chunk) => chunks.push(chunk));
741
+ let settled = false;
742
+ const settle = (probe) => {
743
+ if (settled) return;
744
+ settled = true;
745
+ clearTimeout(timer);
746
+ child.stderr?.destroy();
747
+ resolve4(probe);
748
+ };
749
+ const timer = setTimeout(() => {
750
+ child.kill("SIGKILL");
751
+ settle({
752
+ kind: "error",
753
+ message: `cannot reach ${url}: timed out after ${Math.round(timeoutMs / 1e3)}s`
754
+ });
755
+ }, timeoutMs);
756
+ child.on("error", (e) => settle(classifyProbe(url, 1, e.message)));
757
+ child.on("exit", (code) => {
758
+ const finish = () => settle(classifyProbe(url, code ?? 1, chunks.join("")));
759
+ const stderr = child.stderr;
760
+ if (stderr === null || stderr.readableEnded || stderr.destroyed)
761
+ return finish();
762
+ const flush = setTimeout(finish, STDERR_FLUSH_MS);
763
+ const done = () => {
764
+ clearTimeout(flush);
765
+ finish();
766
+ };
767
+ stderr.once("end", done);
768
+ stderr.once("error", done);
769
+ });
770
+ });
771
+ }
772
+
773
+ // src/core/manifest.ts
774
+ import { readFile as readFile3 } from "fs/promises";
775
+ import { basename as basename2, join as join3 } from "path";
776
+ var NAME = /^(?!\.{1,2}$)[a-z0-9._][a-z0-9._-]*$/;
777
+ var NAME_RULE = "lowercase letters, digits, '.', '_' and '-', not starting with '-'";
778
+ var ORG_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
779
+ var KEYS = ["contract", "org", "repos", "scopes"];
780
+ function fail(message) {
781
+ throw new Error(`rness.json: ${message}`);
782
+ }
783
+ function isRecord(value) {
784
+ return value !== null && typeof value === "object" && !Array.isArray(value);
785
+ }
786
+ function checkPath(scope, path) {
787
+ if (typeof path !== "string" || path === "")
788
+ fail(`scope "${scope}" needs a string "path"`);
789
+ if (path.startsWith("/")) fail(`scope "${scope}" path must be relative`);
790
+ if (path.split("/").includes(".."))
791
+ fail(`scope "${scope}" path must not contain ".."`);
792
+ if (path.split("/").some((segment) => segment === ""))
793
+ fail(`scope "${scope}" path must not contain empty segments`);
794
+ return path;
795
+ }
796
+ function readOrg(value) {
797
+ if (value === void 0 || value === null) return null;
798
+ if (typeof value !== "string" || !ORG_NAME.test(value)) {
799
+ fail(
800
+ `"org" must be a GitHub organization name (got ${JSON.stringify(value)})`
801
+ );
802
+ }
803
+ return value;
804
+ }
805
+ function resolveOrg(manifest, root) {
806
+ if (manifest.org !== null) return { org: manifest.org, warning: null };
807
+ const name = basename2(root);
808
+ return {
809
+ org: name,
810
+ warning: `rness.json: no "org"; using the directory name "${name}"`
811
+ };
812
+ }
813
+ function readRepos(value) {
814
+ const raw = value ?? {};
815
+ if (!isRecord(raw)) fail('"repos" must be an object');
816
+ const repos = {};
817
+ for (const [name, entry] of Object.entries(raw)) {
818
+ if (!NAME.test(name))
819
+ fail(`repo name "${name}" may only contain ${NAME_RULE}`);
820
+ if (!isRecord(entry) || typeof entry.url !== "string")
821
+ fail(`repo "${name}" needs a string "url"`);
822
+ repos[name] = { url: entry.url };
823
+ }
824
+ return repos;
825
+ }
826
+ function readScopes(value) {
827
+ const raw = value ?? {};
828
+ if (!isRecord(raw)) fail('"scopes" must be an object');
829
+ const scopes = {};
830
+ for (const [name, entry] of Object.entries(raw)) {
831
+ if (!NAME.test(name))
832
+ fail(`scope name "${name}" may only contain ${NAME_RULE}`);
833
+ if (!isRecord(entry)) fail(`scope "${name}" must be an object`);
834
+ const path = checkPath(name, entry.path);
835
+ const ext = entry.extends ?? [];
836
+ if (!Array.isArray(ext) || !ext.every((t) => typeof t === "string")) {
837
+ fail(`scope "${name}" extends must be an array of scope names`);
838
+ }
839
+ scopes[name] = { path, extends: ext };
840
+ }
841
+ for (const [name, entry] of Object.entries(scopes)) {
842
+ for (const target of entry.extends) {
843
+ if (!Object.hasOwn(scopes, target))
844
+ fail(`scope "${name}" extends unknown scope "${target}"`);
845
+ }
846
+ }
847
+ return scopes;
848
+ }
849
+ async function loadManifest(rnessDir) {
850
+ let raw;
851
+ try {
852
+ raw = await readFile3(join3(rnessDir, "rness.json"), "utf8");
853
+ } catch {
854
+ fail("not found");
855
+ }
856
+ let data;
857
+ try {
858
+ data = JSON.parse(raw);
859
+ } catch (e) {
860
+ fail(`invalid JSON (${e instanceof Error ? e.message : String(e)})`);
861
+ }
862
+ if (!isRecord(data)) fail("must be a JSON object");
863
+ if (data.contract !== 1)
864
+ fail(`unsupported contract: ${JSON.stringify(data.contract)} (expected 1)`);
865
+ for (const key of Object.keys(data)) {
866
+ if (!KEYS.includes(key)) fail(`unknown key ${JSON.stringify(key)}`);
867
+ }
868
+ return {
869
+ contract: 1,
870
+ org: readOrg(data.org),
871
+ repos: readRepos(data.repos),
872
+ scopes: readScopes(data.scopes)
873
+ };
874
+ }
875
+ async function writeManifest(rnessDir, manifest) {
876
+ const repos = Object.entries(manifest.repos).map(
877
+ ([name, r]) => ` ${JSON.stringify(name)}: { "url": ${JSON.stringify(r.url)} }`
878
+ );
879
+ const scopes = Object.entries(manifest.scopes).map(([name, s]) => {
880
+ const ext = s.extends.length === 0 ? "" : `, "extends": [${s.extends.map((e) => JSON.stringify(e)).join(", ")}]`;
881
+ return ` ${JSON.stringify(name)}: { "path": ${JSON.stringify(s.path)}${ext} }`;
882
+ });
883
+ const lines = ["{", ' "contract": 1,'];
884
+ if (manifest.org !== null)
885
+ lines.push(` "org": ${JSON.stringify(manifest.org)},`);
886
+ lines.push(
887
+ ' "repos": {',
888
+ repos.join(",\n"),
889
+ " },",
890
+ ' "scopes": {',
891
+ scopes.join(",\n"),
892
+ " }",
893
+ "}"
894
+ );
895
+ const text = `${lines.filter((l) => l !== "").join("\n")}
896
+ `;
897
+ await writeFileAtomic(join3(rnessDir, "rness.json"), text);
898
+ }
899
+ var FULL_URL = /^(https?:\/\/|git@|ssh:\/\/|file:\/\/)/;
900
+ function parseRepoSpec(spec, org, host) {
901
+ let url;
902
+ if (FULL_URL.test(spec)) {
903
+ url = spec;
904
+ } else {
905
+ const parts = spec.split("/");
906
+ if (parts.length === 1) url = repoUrl(host, org, spec);
907
+ else if (parts.length === 2 && parts[0] !== void 0 && parts[1] !== void 0)
908
+ url = repoUrl(host, parts[0], parts[1]);
909
+ else
910
+ throw new Error(
911
+ `repository name "${spec}" is not a name (use <repo>, <owner>/<repo>, or a URL)`
912
+ );
913
+ }
914
+ const last = url.replace(/\/+$/, "").split(/[/:]/).at(-1) ?? "";
915
+ const name = last.endsWith(".git") ? last.slice(0, -4) : last;
916
+ if (!NAME.test(name))
917
+ throw new Error(`repository name "${name}" may only contain ${NAME_RULE}`);
918
+ return { name, url };
919
+ }
920
+
921
+ // src/core/repos.ts
922
+ import { glob, readFile as readFile4, rm as rm3 } from "fs/promises";
923
+ import { join as join4, posix, sep } from "path";
924
+ async function addRepository(input) {
925
+ const { name, url } = parseRepoSpec(input.spec, input.org, input.host);
926
+ const dir = join4(input.root, "org", name);
927
+ const repoPath = `org/${name}`;
928
+ const current = input.manifest.scopes[name];
929
+ if (current !== void 0 && current.path !== repoPath)
930
+ throw new Error(`scope "${name}" already points at ${current.path}`);
931
+ const scopes = {
932
+ ...input.manifest.scopes,
933
+ [name]: { path: repoPath, extends: current?.extends ?? [] }
934
+ };
935
+ const subs = [];
936
+ for (const sub of input.scopes) {
937
+ const clean = sub.replace(/^\/+|\/+$/g, "");
938
+ const segments = clean.split("/");
939
+ if (segments.some((s) => s === "" || s === "." || s === ".."))
940
+ throw new Error(
941
+ `sub-scope "${clean}" must be a relative path inside the repository`
942
+ );
943
+ const base = segments.at(-1) ?? "";
944
+ const path = `org/${name}/${clean}`;
945
+ if (!NAME.test(base))
946
+ throw new Error(
947
+ `the last segment of sub-scope "${clean}" may only contain ${NAME_RULE}`
948
+ );
949
+ const existing = scopes[base];
950
+ if (existing !== void 0 && existing.path !== path)
951
+ throw new Error(`scope "${base}" already points at ${existing.path}`);
952
+ scopes[base] = { path, extends: [name] };
953
+ subs.push({ path, base });
954
+ }
955
+ let action;
956
+ if (await exists(dir)) {
957
+ const origin = await originUrl(dir);
958
+ if (origin === null)
959
+ throw new Error(`org/${name} exists and is not a git clone`);
960
+ if (origin !== url)
961
+ throw new Error(`org/${name} is a clone of ${origin}, not ${url}`);
962
+ action = "adopted";
963
+ } else {
964
+ await clone(url, dir, input.credentialsFor?.(url) ?? null);
965
+ action = "cloned";
966
+ }
967
+ const declared = [name];
968
+ for (const { path, base } of subs) {
969
+ if (!await exists(join4(input.root, ...path.split("/")))) {
970
+ if (action === "cloned") await rm3(dir, { recursive: true, force: true });
971
+ throw new Error(`${path} does not exist`);
972
+ }
973
+ declared.push(base);
974
+ }
975
+ const manifest = {
976
+ ...input.manifest,
977
+ repos: { ...input.manifest.repos, [name]: { url } },
978
+ scopes
979
+ };
980
+ await writeManifest(input.rnessDir, manifest);
981
+ return { manifest, name, url, action, declared };
982
+ }
983
+ async function workspaceDirs(repoDir) {
984
+ let patterns = [];
985
+ try {
986
+ const pkg = JSON.parse(
987
+ await readFile4(join4(repoDir, "package.json"), "utf8")
988
+ );
989
+ const w = pkg.workspaces;
990
+ if (Array.isArray(w))
991
+ patterns = w.filter((p) => typeof p === "string");
992
+ else if (w !== null && typeof w === "object" && Array.isArray(w.packages)) {
993
+ patterns = w.packages.filter(
994
+ (p) => typeof p === "string"
995
+ );
996
+ }
997
+ } catch {
998
+ return [];
999
+ }
1000
+ const dirs = /* @__PURE__ */ new Set();
1001
+ for (const pattern of patterns) {
1002
+ for await (const match of glob(pattern, { cwd: repoDir })) {
1003
+ const rel = match.split(sep).join(posix.sep);
1004
+ if (await exists(join4(repoDir, ...rel.split(posix.sep), "package.json")))
1005
+ dirs.add(rel);
1006
+ }
1007
+ }
1008
+ return [...dirs].sort();
1009
+ }
1010
+
1011
+ // src/core/terminal.ts
1012
+ var defaultTerminal = {
1013
+ isTty: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
1014
+ // Lazy: the prompt library stays off every non-interactive path.
1015
+ prompts: () => import("@clack/prompts")
1016
+ };
1017
+
1018
+ // src/core/transport.ts
1019
+ import { spawn as spawn2 } from "child_process";
1020
+
1021
+ // src/core/style.ts
1022
+ import { styleText } from "util";
1023
+ var FORMAT = {
1024
+ done: "green",
1025
+ idle: "dim",
1026
+ info: "cyan",
1027
+ warn: "yellow",
1028
+ error: "red"
1029
+ };
1030
+ function noColor() {
1031
+ const off = process.env["NO_COLOR"];
1032
+ return off !== void 0 && off !== "" && process.env["FORCE_COLOR"] === void 0;
1033
+ }
1034
+ function paint(tone, text, stream = process.stdout) {
1035
+ if (noColor()) return text;
1036
+ return styleText(FORMAT[tone], text, { stream, validateStream: true });
1037
+ }
1038
+ var IDLE = /* @__PURE__ */ new Set(["unchanged", "skipped", "not cloned"]);
1039
+ var DONE = /* @__PURE__ */ new Set([
1040
+ "created",
1041
+ "cloned",
1042
+ "adopted",
1043
+ "declared",
1044
+ "installed",
1045
+ "committed",
1046
+ "updated",
1047
+ "upgraded",
1048
+ "pulled",
1049
+ "pushed",
1050
+ "logged in",
1051
+ "logged out",
1052
+ "synced",
1053
+ "listed"
1054
+ ]);
1055
+ function toneOf(verb) {
1056
+ if (DONE.has(verb)) return "done";
1057
+ if (IDLE.has(verb)) return "idle";
1058
+ if (verb === "stale") return "warn";
1059
+ return "info";
1060
+ }
1061
+ var COLUMN = 8;
1062
+ function status(verb, rest, stream = process.stdout) {
1063
+ const pad = " ".repeat(Math.max(0, COLUMN - verb.length));
1064
+ return `${paint(toneOf(verb), verb, stream)}${pad} ${rest}`;
1065
+ }
1066
+ function bold(text, stream = process.stdout) {
1067
+ if (noColor()) return text;
1068
+ return styleText("bold", text, { stream, validateStream: true });
1069
+ }
1070
+ function badge(text, stream = process.stdout) {
1071
+ if (noColor()) return text;
1072
+ const padded = ` ${text} `;
1073
+ const painted = styleText(["bgCyan", "black", "bold"], padded, {
1074
+ stream,
1075
+ validateStream: true
1076
+ });
1077
+ return painted === padded ? text : painted;
1078
+ }
1079
+ function grey(text, stream = process.stdout) {
1080
+ if (noColor()) return text;
1081
+ return styleText("gray", text, { stream, validateStream: true });
1082
+ }
1083
+ function unicode() {
1084
+ if (process.platform !== "win32") return process.env["TERM"] !== "linux";
1085
+ return process.env["WT_SESSION"] !== void 0 || process.env["TERM_PROGRAM"] === "vscode";
1086
+ }
1087
+ var PRIVATE_MARK = "\u{1F512}";
1088
+ function warn(text) {
1089
+ return `${paint("warn", "warning:", process.stderr)} ${text}`;
1090
+ }
1091
+ var WORD = [" \u2588\u2580\u2588 \u2588\u2584 \u2588 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2588\u2580\u2580", " \u2588\u2580\u2584 \u2588 \u2580\u2588 \u2588\u2588\u2584 \u2584\u2584\u2588 \u2584\u2584\u2588"];
1092
+ var FROM = [63, 185, 80];
1093
+ var TO = [57, 197, 207];
1094
+ function gradient(line) {
1095
+ const chars = [...line];
1096
+ const last = Math.max(1, chars.length - 1);
1097
+ const painted = chars.map((ch, i) => {
1098
+ if (ch === " ") return ch;
1099
+ const [r, g, b] = FROM.map(
1100
+ (from, c) => Math.round(from + ((TO[c] ?? from) - from) * i / last)
1101
+ );
1102
+ return `\x1B[38;2;${r};${g};${b}m${ch}`;
1103
+ });
1104
+ return `${painted.join("")}\x1B[39m`;
1105
+ }
1106
+ function banner(version, stream = process.stdout) {
1107
+ const forced = process.env["FORCE_COLOR"] !== void 0 && process.env["FORCE_COLOR"] !== "0";
1108
+ if (stream.isTTY !== true && !forced) return "";
1109
+ const coloured = paint("done", "x", stream) !== "x";
1110
+ const trueColour = coloured && typeof stream.hasColors === "function" && stream.hasColors(2 ** 24);
1111
+ const word = WORD.map(
1112
+ (line) => trueColour ? gradient(line) : paint("done", line, stream)
1113
+ );
1114
+ return [
1115
+ "",
1116
+ ...word,
1117
+ "",
1118
+ ` ${bold(`rness v${version}`, stream)}`,
1119
+ ` ${paint("idle", "Context for AI coding agents, across an organization\u2019s repositories", stream)}`,
1120
+ "",
1121
+ ""
1122
+ ].join("\n");
1123
+ }
1124
+
1125
+ // src/core/transport.ts
1126
+ var GITHUB_HOSTS = { ssh: SSH_HOST, https: DEFAULT_HOST };
1127
+ var GREETING = /^Hi ([^!\s]+)! You've successfully authenticated/m;
1128
+ function parseSshGreeting(stderr) {
1129
+ return GREETING.exec(stderr)?.[1] ?? null;
1130
+ }
1131
+ function firstLine2(text) {
1132
+ return text.split("\n").find((l) => l.trim() !== "")?.trim() ?? null;
1133
+ }
1134
+ var STDERR_FLUSH_MS2 = 200;
1135
+ async function detectGithubSsh(opts) {
1136
+ const timeoutMs = opts.timeoutMs ?? (opts.interactive ? 12e4 : 1e4);
1137
+ const args = [
1138
+ "-T",
1139
+ "-o",
1140
+ "ConnectTimeout=5",
1141
+ ...opts.interactive ? [] : ["-o", "BatchMode=yes"],
1142
+ "git@github.com"
1143
+ ];
1144
+ return new Promise((resolve4) => {
1145
+ const child = spawn2(opts.command ?? "ssh", args, {
1146
+ stdio: [opts.interactive ? "inherit" : "ignore", "ignore", "pipe"]
1147
+ });
1148
+ const chunks = [];
1149
+ child.stderr?.setEncoding("utf8");
1150
+ child.stderr?.on("data", (chunk) => chunks.push(chunk));
1151
+ let settled = false;
1152
+ const settle = (access2) => {
1153
+ if (settled) return;
1154
+ settled = true;
1155
+ clearTimeout(timer);
1156
+ child.stderr?.destroy();
1157
+ resolve4(access2);
1158
+ };
1159
+ const timer = setTimeout(() => {
1160
+ child.kill("SIGKILL");
1161
+ settle({ ok: false, reason: `timed out after ${timeoutMs / 1e3}s` });
1162
+ }, timeoutMs);
1163
+ child.on("error", (e) => settle({ ok: false, reason: e.message }));
1164
+ child.on("exit", (code) => {
1165
+ const finish = () => {
1166
+ const stderr2 = chunks.join("");
1167
+ const login = parseSshGreeting(stderr2);
1168
+ settle(
1169
+ login !== null ? { ok: true, login } : {
1170
+ ok: false,
1171
+ reason: firstLine2(stderr2) ?? `ssh exited with code ${code ?? "?"}`
1172
+ }
1173
+ );
1174
+ };
1175
+ const stderr = child.stderr;
1176
+ if (stderr === null || stderr.readableEnded || stderr.destroyed)
1177
+ return finish();
1178
+ const flush = setTimeout(finish, STDERR_FLUSH_MS2);
1179
+ const done = () => {
1180
+ clearTimeout(flush);
1181
+ finish();
1182
+ };
1183
+ stderr.once("end", done);
1184
+ stderr.once("error", done);
1185
+ });
1186
+ });
1187
+ }
1188
+ var defaultTransport = {
1189
+ hosts: GITHUB_HOSTS,
1190
+ detect: detectGithubSsh
1191
+ };
1192
+ async function testGithubSsh(transport, interactive, announce) {
1193
+ const batch = await transport.detect({ interactive: false });
1194
+ if (batch.ok || !interactive) return { access: batch, unattended: batch.ok };
1195
+ announce();
1196
+ return {
1197
+ access: await transport.detect({ interactive: true }),
1198
+ unattended: false
1199
+ };
1200
+ }
1201
+ var loggedInAs = (login) => login === void 0 || login === null ? "" : `, logged in as ${login}`;
1202
+ function usingSentence(access2, login) {
1203
+ return access2.ok ? `SSH works for github.com (as ${access2.login}) \u2014 repositories are cloned over SSH` : `No SSH access to github.com (${access2.reason}) \u2014 repositories are cloned over HTTPS${loggedInAs(login)}`;
1204
+ }
1205
+ function isSshUrl(url, hosts) {
1206
+ return url.startsWith(hosts.ssh);
1207
+ }
1208
+ var CHECKING_LINE = status("checking", "ssh access to github.com\u2026");
1209
+ var CHECKING = [
1210
+ "checking",
1211
+ "ssh access to github.com\u2026",
1212
+ "Checking SSH access to github.com \u2014 ssh may ask for your passphrase"
1213
+ ];
1214
+ function usingRest(access2, login) {
1215
+ return access2.ok ? `ssh (github.com as ${access2.login})` : `https (ssh to github.com unavailable: ${access2.reason}${loggedInAs(login)})`;
1216
+ }
1217
+ function sshWorkspaceLines(reason3) {
1218
+ return [
1219
+ `this workspace clones over SSH (rness.json) but ssh to github.com fails: ${reason3}`,
1220
+ "set up an SSH key: https://docs.github.com/authentication/connecting-to-github-with-ssh"
1221
+ ];
1222
+ }
1223
+ var INSTEAD_OF_LINES = [
1224
+ "or make git use HTTPS for github.com on this machine:",
1225
+ ' git config --global url."https://github.com/".insteadOf git@github.com:'
1226
+ ];
1227
+ function httpsFlagLine(spec) {
1228
+ return `or clone this repository over HTTPS: rness add ${spec} --https`;
1229
+ }
1230
+
1231
+ // src/core/workspace.ts
1232
+ import { dirname as dirname3, join as join5, parse, resolve as resolve2 } from "path";
1233
+ async function findWorkspace(startDir) {
1234
+ const start = resolve2(startDir);
1235
+ const { root: fsRoot } = parse(start);
1236
+ const ancestors = [];
1237
+ for (let dir = start; ; dir = dirname3(dir)) {
1238
+ ancestors.push(dir);
1239
+ if (dir === fsRoot) break;
1240
+ }
1241
+ for (const dir of ancestors) {
1242
+ if (await exists(join5(dir, ".rness", "rness.json")))
1243
+ return { root: dir, rnessDir: join5(dir, ".rness") };
1244
+ }
1245
+ for (const dir of ancestors) {
1246
+ if (await exists(join5(dir, "rness.json")))
1247
+ return { root: dirname3(dir), rnessDir: dir };
1248
+ }
1249
+ throw new Error(`no rness workspace found above ${startDir}`);
1250
+ }
1251
+
1252
+ // src/report.ts
1253
+ function reportError(e) {
1254
+ const debug = process.env["RNESS_DEBUG"] === "1";
1255
+ if (debug && e instanceof Error && e.stack) {
1256
+ process.stderr.write(`${e.stack}
1257
+ `);
1258
+ } else {
1259
+ const message = e instanceof Error ? e.message : String(e);
1260
+ process.stderr.write(`${paint("error", message, process.stderr)}
1261
+ `);
1262
+ }
1263
+ return 1;
1264
+ }
1265
+
1266
+ // src/core/block.ts
1267
+ import { createHash } from "crypto";
1268
+ var BEGIN = "<!-- BEGIN rness -->";
1269
+ var END = "<!-- END rness -->";
1270
+ var BLOCK_SIZE_WARNING = 32 * 1024;
1271
+ var HEADER = /^<!-- rness (?:(\S+) )?· scope: (\S+) · contract: 1 · hash: ([0-9a-f]{12}) · generated: run `rness sync`, never edit inside this block -->$/;
1272
+ function toLf(s) {
1273
+ return s.replace(/\r\n?/g, "\n");
1274
+ }
1275
+ function blockHash(body) {
1276
+ return createHash("sha256").update(toLf(body), "utf8").digest("hex").slice(0, 12);
1277
+ }
1278
+ function intro(scope, org, depth) {
1279
+ if (scope === null) {
1280
+ return [
1281
+ `This is the root of rness workspace \`${org}\`. Full context lives in \`.rness/\` \u2014`,
1282
+ "read `STATUS.md`, then task-relevant `adr/`, `specs/`, `plans/`; live: `rness context`.",
1283
+ "Every `org/<repo>/` carries its own block with the rules that apply there."
1284
+ ].join("\n");
1285
+ }
1286
+ const rel = `${"../".repeat(depth)}.rness/`;
1287
+ return [
1288
+ `This directory is scope \`${scope}\` of rness workspace \`${org}\`. Full context lives in`,
1289
+ `\`${rel}\` \u2014 read \`STATUS.md\`, then task-relevant \`adr/\`, \`specs/\`, \`plans/\`;`,
1290
+ `live: \`rness context --scope ${scope}\`. If \`.rness/\` is not reachable, this is a`,
1291
+ "standalone clone: the rules below are all you have."
1292
+ ].join("\n");
1293
+ }
1294
+ function header(scope, hash) {
1295
+ return `<!-- rness \xB7 scope: ${scope ?? "global"} \xB7 contract: 1 \xB7 hash: ${hash} \xB7 generated: run \`rness sync\`, never edit inside this block -->`;
1296
+ }
1297
+ function renderBlock({
1298
+ scope,
1299
+ org,
1300
+ depth,
1301
+ context
1302
+ }) {
1303
+ const standards = context.collections.find((c) => c.name === "standards")?.files ?? [];
1304
+ const rules = standards.length === 0 ? ["_No standards apply to this scope yet._"] : standards.map(
1305
+ (f) => `<!-- rness: standards/${f.rel} -->
1306
+ ${toLf(f.content).trim()}`
1307
+ );
1308
+ const body = `${intro(scope, org, depth)}
1309
+
1310
+ ## Rules
1311
+ ${rules.join("\n\n")}`;
1312
+ const hash = blockHash(body);
1313
+ const text = [BEGIN, header(scope, hash), body, END].join("\n");
1314
+ return { text, hash, bytes: Buffer.byteLength(text, "utf8") };
1315
+ }
1316
+ function parseHeader(line) {
1317
+ const m = HEADER.exec(line.trimEnd());
1318
+ if (m === null) return null;
1319
+ return { version: m[1] ?? null, scope: m[2] ?? "", hash: m[3] ?? "" };
1320
+ }
1321
+ function bodyOf(blockLines) {
1322
+ return blockLines.slice(2, -1).join("\n");
1323
+ }
1324
+ function isCurrentBlock(blockLines, freshHash) {
1325
+ const header2 = parseHeader(blockLines[1] ?? "");
1326
+ return header2 !== null && header2.hash === freshHash && blockHash(bodyOf(blockLines)) === freshHash;
1327
+ }
1328
+
1329
+ // src/core/frontmatter.ts
1330
+ import { parse as parse2 } from "yaml";
1331
+ var FRONT_MATTER = /^---\r?\n(?:([\s\S]*?)\r?\n)?---(?:\r?\n|$)/;
1332
+ function fail2(message) {
1333
+ throw new Error(`front matter: ${message}`);
1334
+ }
1335
+ function stripBom(source) {
1336
+ return source.startsWith("\uFEFF") ? source.slice(1) : source;
1337
+ }
1338
+ function parseFrontMatter(source) {
1339
+ const match = stripBom(source).match(FRONT_MATTER);
1340
+ if (match === null) return null;
1341
+ const block = match[1] ?? "";
1342
+ if (block.trim() === "") return {};
1343
+ let doc;
1344
+ try {
1345
+ doc = parse2(block, { logLevel: "error" });
1346
+ } catch (e) {
1347
+ fail2(
1348
+ e instanceof Error ? (e.message.split("\n")[0] ?? "invalid YAML").replace(/:$/, "") : "invalid YAML"
1349
+ );
1350
+ }
1351
+ if (doc === null || doc === void 0) return {};
1352
+ if (typeof doc !== "object" || Array.isArray(doc))
1353
+ fail2("block must be a mapping of keys to values");
1354
+ return doc;
1355
+ }
1356
+ function stripFrontMatter(source) {
1357
+ const text = stripBom(source);
1358
+ const match = text.match(FRONT_MATTER);
1359
+ if (match === null) return text;
1360
+ return text.slice(match[0].length).replace(/^(?:[ \t]*\r?\n)+/, "");
1361
+ }
1362
+ function extractTitle(source) {
1363
+ return stripBom(source).match(/^# (.+)$/m)?.[1]?.trim() ?? null;
1364
+ }
1365
+
1366
+ // src/core/collect.ts
1367
+ import { glob as glob2, readFile as readFile5 } from "fs/promises";
1368
+ import { join as join6, posix as posix2, sep as sep2 } from "path";
1369
+ function toPosix(rel) {
1370
+ return rel.split(sep2).join(posix2.sep);
1371
+ }
1372
+ async function collectMarkdown(dir) {
1373
+ const rels = [];
1374
+ for await (const rel of glob2("**/*.md", { cwd: dir })) rels.push(toPosix(rel));
1375
+ const items = await Promise.all(
1376
+ rels.map(async (rel) => {
1377
+ const path = join6(dir, ...rel.split(posix2.sep));
1378
+ const body = await readFile5(path, "utf8");
1379
+ let fields = null;
1380
+ let fieldsError = null;
1381
+ try {
1382
+ fields = parseFrontMatter(body);
1383
+ } catch (e) {
1384
+ fieldsError = e instanceof Error ? e.message : String(e);
1385
+ }
1386
+ return {
1387
+ path,
1388
+ rel,
1389
+ fields,
1390
+ fieldsError,
1391
+ title: extractTitle(body),
1392
+ body,
1393
+ content: stripFrontMatter(body)
1394
+ };
1395
+ })
1396
+ );
1397
+ return items.sort((a, b) => a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
1398
+ }
1399
+
1400
+ // src/core/scope.ts
1401
+ function isPrefix(prefix, target) {
1402
+ return prefix === target || target.startsWith(`${prefix}/`);
1403
+ }
1404
+ function resolveScope(manifest, relDir) {
1405
+ let best = null;
1406
+ let bestLen = -1;
1407
+ for (const [name, entry] of Object.entries(manifest.scopes)) {
1408
+ if (isPrefix(entry.path, relDir) && entry.path.length > bestLen) {
1409
+ best = name;
1410
+ bestLen = entry.path.length;
1411
+ }
1412
+ }
1413
+ return best;
1414
+ }
1415
+ function scopeChain(manifest, scope) {
1416
+ if (scope === null) return [];
1417
+ const chain = [];
1418
+ const seen = /* @__PURE__ */ new Set();
1419
+ const visit = (name, stack) => {
1420
+ if (stack.has(name)) throw new Error(`extends cycle at scope "${name}"`);
1421
+ if (seen.has(name)) return;
1422
+ seen.add(name);
1423
+ chain.push(name);
1424
+ stack.add(name);
1425
+ const entry = Object.hasOwn(manifest.scopes, name) ? manifest.scopes[name] : void 0;
1426
+ for (const target of entry?.extends ?? []) visit(target, stack);
1427
+ stack.delete(name);
1428
+ };
1429
+ visit(scope, /* @__PURE__ */ new Set());
1430
+ return chain;
1431
+ }
1432
+
1433
+ // src/core/context.ts
1434
+ import { join as join7 } from "path";
1435
+ var COLLECTIONS = [
1436
+ "standards",
1437
+ "adr",
1438
+ "specs",
1439
+ "plans",
1440
+ "skills"
1441
+ ];
1442
+ function ownerScope(rel) {
1443
+ const slash = rel.indexOf("/");
1444
+ return slash === -1 ? null : rel.slice(0, slash);
1445
+ }
1446
+ async function assembleCollection(dir, name, chain) {
1447
+ const items = await collectMarkdown(dir);
1448
+ const groups = new Map(chain.map((s) => [s, []]));
1449
+ const rootFiles = [];
1450
+ for (const item of items) {
1451
+ const owner = ownerScope(item.rel);
1452
+ if (owner === null) rootFiles.push({ ...item, scope: null });
1453
+ else groups.get(owner)?.push({ ...item, scope: owner });
1454
+ }
1455
+ const files = [];
1456
+ const push = (f) => {
1457
+ files.push({
1458
+ rel: f.rel,
1459
+ scope: f.scope,
1460
+ title: f.title,
1461
+ body: f.body,
1462
+ content: f.content
1463
+ });
1464
+ };
1465
+ for (const s of chain) for (const f of groups.get(s) ?? []) push(f);
1466
+ for (const f of rootFiles) push(f);
1467
+ return { name, files };
1468
+ }
1469
+ async function assembleContext({
1470
+ rnessDir,
1471
+ manifest,
1472
+ scope
1473
+ }) {
1474
+ const chain = scopeChain(manifest, scope);
1475
+ const collections = await Promise.all(
1476
+ COLLECTIONS.map(
1477
+ (name) => assembleCollection(join7(rnessDir, name), name, chain)
1478
+ )
1479
+ );
1480
+ return { scope, collections };
1481
+ }
1482
+
1483
+ // src/core/merge.ts
1484
+ import { join as join8 } from "path";
1485
+ function findBlock(lines) {
1486
+ const begins = [];
1487
+ const ends = [];
1488
+ lines.forEach((line, i) => {
1489
+ const l = line.trimEnd();
1490
+ if (l === BEGIN) begins.push(i);
1491
+ if (l === END) ends.push(i);
1492
+ });
1493
+ if (begins.length === 0 && ends.length === 0) return { kind: "none" };
1494
+ const begin = begins[0];
1495
+ const end = ends[0];
1496
+ if (begins.length !== 1 || ends.length !== 1 || begin === void 0 || end === void 0) {
1497
+ return {
1498
+ kind: "error",
1499
+ message: `expected exactly one ${BEGIN} \u2026 ${END} span, found ${begins.length} BEGIN and ${ends.length} END`
1500
+ };
1501
+ }
1502
+ if (end < begin)
1503
+ return { kind: "error", message: `${END} comes before ${BEGIN}` };
1504
+ return { kind: "one", begin, end };
1505
+ }
1506
+ function mergeBlock(existing, block) {
1507
+ if (existing === null) return { ok: true, text: `${block}
1508
+ `, changed: true };
1509
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
1510
+ const lines = existing.split(/\r?\n/);
1511
+ const found = findBlock(lines);
1512
+ if (found.kind === "error") return { ok: false, error: found.message };
1513
+ const rendered = block.split("\n").join(eol);
1514
+ const starts = [0];
1515
+ for (const m of existing.matchAll(/\r?\n/g))
1516
+ starts.push((m.index ?? 0) + m[0].length);
1517
+ const lineStart = (i) => starts[i] ?? existing.length;
1518
+ let text;
1519
+ if (found.kind === "one") {
1520
+ const endLine = lines[found.end] ?? "";
1521
+ const afterEnd = lineStart(found.end) + endLine.length;
1522
+ text = `${existing.slice(0, lineStart(found.begin))}${rendered}${existing.slice(afterEnd)}`;
1523
+ } else if (lines.length >= 3 && (lines[0] ?? "").startsWith("# ") && lines[1] === "") {
1524
+ const at = lineStart(2);
1525
+ text = `${existing.slice(0, at)}${rendered}${eol}${eol}${existing.slice(at)}`;
1526
+ } else {
1527
+ text = `${rendered}${eol}${eol}${existing}`;
1528
+ }
1529
+ return { ok: true, text, changed: text !== existing };
1530
+ }
1531
+ async function ensureClaudeMd(dir) {
1532
+ const file = join8(dir, "CLAUDE.md");
1533
+ if (await isSymlink(file)) return "unchanged";
1534
+ const existing = await readOrNull(file);
1535
+ if (existing === null) {
1536
+ await writeFileAtomic(file, "@AGENTS.md\n");
1537
+ return "created";
1538
+ }
1539
+ if (existing.split(/\r?\n/).some((l) => l.trim() === "@AGENTS.md"))
1540
+ return "unchanged";
1541
+ const eol = existing.includes("\r\n") ? "\r\n" : "\n";
1542
+ await writeFileAtomic(file, `@AGENTS.md${eol}${existing}`);
1543
+ return "prepended";
1544
+ }
1545
+
1546
+ // src/commands/sync.ts
1547
+ import { readdir } from "fs/promises";
1548
+ import { join as join9 } from "path";
1549
+
1550
+ // src/core/progress.ts
1551
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1552
+ var INTERVAL_MS = 80;
1553
+ var CLEAR_LINE = "\r\x1B[2K";
1554
+ function isLive(stream) {
1555
+ return stream.isTTY === true && process.env["TERM"] !== "dumb" && process.env["CI"] === void 0;
1556
+ }
1557
+ async function step(options, work) {
1558
+ const stream = options.stream ?? process.stdout;
1559
+ if (!isLive(stream)) return work();
1560
+ const draw = (frame) => {
1561
+ rawWrite(
1562
+ `${CLEAR_LINE}${paint("info", frame, stream)} ${paint("idle", options.label, stream)}`
1563
+ );
1564
+ };
1565
+ let timer;
1566
+ let shown = true;
1567
+ const erase = () => {
1568
+ if (!shown) return;
1569
+ shown = false;
1570
+ if (timer !== void 0) clearInterval(timer);
1571
+ rawWrite(CLEAR_LINE);
1572
+ };
1573
+ const rawWrite = stream.write.bind(stream);
1574
+ const rawErr = process.stderr.write.bind(
1575
+ process.stderr
1576
+ );
1577
+ const guard = (raw) => ((...args) => {
1578
+ erase();
1579
+ return raw(...args);
1580
+ });
1581
+ if (options.animate) {
1582
+ let i = 0;
1583
+ draw(FRAMES[0] ?? "");
1584
+ timer = setInterval(() => {
1585
+ i = (i + 1) % FRAMES.length;
1586
+ if (shown) draw(FRAMES[i] ?? "");
1587
+ }, INTERVAL_MS);
1588
+ timer.unref();
1589
+ } else {
1590
+ draw("\u2026");
1591
+ }
1592
+ stream.write = guard(rawWrite);
1593
+ if (process.stderr !== stream) process.stderr.write = guard(rawErr);
1594
+ try {
1595
+ return await work();
1596
+ } finally {
1597
+ stream.write = rawWrite;
1598
+ if (process.stderr !== stream) process.stderr.write = rawErr;
1599
+ erase();
1600
+ }
1601
+ }
1602
+
1603
+ // src/core/ui.ts
1604
+ var capitalise = (s) => s === "" ? s : `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`;
1605
+ var tidy = (label) => capitalise(label.replace(/\s+/g, " ").trim());
1606
+ var plainUi = {
1607
+ session: false,
1608
+ gitIsSilent: false,
1609
+ intro: () => void 0,
1610
+ outro: () => void 0,
1611
+ line: (verb, rest) => {
1612
+ process.stdout.write(`${status(verb, rest)}
1613
+ `);
1614
+ },
1615
+ warn: (text) => {
1616
+ process.stderr.write(`${warn(text)}
1617
+ `);
1618
+ },
1619
+ note: (title, lines) => {
1620
+ process.stdout.write(
1621
+ ["", `${title}:`, ...lines.map((l) => ` ${l}`), ""].join("\n")
1622
+ );
1623
+ },
1624
+ hint: (text, stream = "stdout") => {
1625
+ process[stream].write(`${text}
1626
+ `);
1627
+ },
1628
+ error: (text) => {
1629
+ process.stderr.write(`${text}
1630
+ `);
1631
+ },
1632
+ cancelled: () => {
1633
+ process.stderr.write("cancelled\n");
1634
+ },
1635
+ async step(options, work, done) {
1636
+ const result = await step(
1637
+ { label: options.doing, animate: options.git !== true },
1638
+ work
1639
+ );
1640
+ const line = options.quiet === true ? null : done?.(result) ?? null;
1641
+ if (line !== null) this.line(line[0], line[1]);
1642
+ return result;
1643
+ }
1644
+ };
1645
+ function sessionUi(p) {
1646
+ const say = (verb, text) => {
1647
+ const tone = toneOf(verb);
1648
+ if (tone === "done") p.log.step(text);
1649
+ else if (tone === "warn") p.log.warn(text);
1650
+ else if (tone === "idle") p.log.message(paint("idle", text));
1651
+ else p.log.info(text);
1652
+ };
1653
+ return {
1654
+ session: true,
1655
+ gitIsSilent: false,
1656
+ intro: (title) => {
1657
+ p.intro(badge(title));
1658
+ },
1659
+ outro: (message, tone = "done") => {
1660
+ p.outro(paint(tone, message));
1661
+ },
1662
+ line: (verb, rest, sentence) => {
1663
+ say(verb, sentence ?? tidy(`${verb} ${rest}`));
1664
+ },
1665
+ warn: (text) => {
1666
+ p.log.warn(text);
1667
+ },
1668
+ note: (title, lines) => {
1669
+ p.note(lines.join("\n"), title, { format: (line) => grey(line) });
1670
+ },
1671
+ hint: (text) => {
1672
+ p.log.message(paint("idle", text));
1673
+ },
1674
+ error: (text) => {
1675
+ p.log.error(text);
1676
+ },
1677
+ cancelled: () => {
1678
+ p.cancel("Cancelled");
1679
+ },
1680
+ async step(options, work, done) {
1681
+ const label = options.sentence ?? tidy(options.doing);
1682
+ const finish = (result) => {
1683
+ const line = done?.(result) ?? null;
1684
+ return line === null ? null : tidy(`${line[0]} ${line[1]}`);
1685
+ };
1686
+ if (options.git === true && !this.gitIsSilent) {
1687
+ p.log.message(paint("idle", `${label}\u2026`));
1688
+ const result = await work();
1689
+ const text = finish(result);
1690
+ if (text !== null) p.log.step(text);
1691
+ return result;
1692
+ }
1693
+ const spin = p.spinner();
1694
+ spin.start(label);
1695
+ try {
1696
+ const result = await work();
1697
+ spin.stop(finish(result) ?? label);
1698
+ return result;
1699
+ } catch (e) {
1700
+ spin.error(`${label} \u2014 failed`);
1701
+ throw e;
1702
+ }
1703
+ }
1704
+ };
1705
+ }
1706
+ async function makeUi(terminal) {
1707
+ const live = terminal.isTty() && process.stdout.isTTY === true && process.env["CI"] === void 0 && process.env["TERM"] !== "dumb";
1708
+ if (!live) return { ...plainUi };
1709
+ const p = await terminal.prompts();
1710
+ if (p.intro === void 0 || p.outro === void 0 || p.cancel === void 0 || p.note === void 0 || p.log === void 0 || p.spinner === void 0)
1711
+ return { ...plainUi };
1712
+ return sessionUi({
1713
+ intro: p.intro,
1714
+ outro: p.outro,
1715
+ cancel: p.cancel,
1716
+ note: p.note,
1717
+ log: p.log,
1718
+ spinner: p.spinner
1719
+ });
1720
+ }
1721
+
1722
+ // src/commands/sync.ts
1723
+ var MANAGED_FILES = ["AGENTS.md", "CLAUDE.md"];
1724
+ function targetsOf(manifest, root, only) {
1725
+ const targets = [];
1726
+ if (only === void 0)
1727
+ targets.push({ scope: null, dir: root, depth: 0, label: "AGENTS.md" });
1728
+ for (const [name, entry] of Object.entries(manifest.scopes)) {
1729
+ if (only !== void 0 && name !== only) continue;
1730
+ const segments = entry.path.split("/");
1731
+ targets.push({
1732
+ scope: name,
1733
+ dir: join9(root, ...segments),
1734
+ depth: segments.length,
1735
+ label: `${entry.path}/AGENTS.md`
1736
+ });
1737
+ }
1738
+ return targets;
1739
+ }
1740
+ var SELECT_ALL = "*";
1741
+ async function clonesIn(root) {
1742
+ try {
1743
+ const entries = await readdir(join9(root, "org"), { withFileTypes: true });
1744
+ return new Set(
1745
+ entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name)
1746
+ );
1747
+ } catch {
1748
+ return /* @__PURE__ */ new Set();
1749
+ }
1750
+ }
1751
+ async function askWhatToSync(input) {
1752
+ if (!input.terminal.isTty()) {
1753
+ process.stderr.write(
1754
+ "rness sync writes files; pass --yes to run without a prompt\n"
1755
+ );
1756
+ return 2;
1757
+ }
1758
+ const p = await input.terminal.prompts();
1759
+ const cancelled2 = () => {
1760
+ input.ui.cancelled();
1761
+ return 1;
1762
+ };
1763
+ if (input.offer && input.missing.length > 0) {
1764
+ const answer = await p.autocompleteMultiselect({
1765
+ message: "Do you want to sync as well?",
1766
+ options: [
1767
+ {
1768
+ value: SELECT_ALL,
1769
+ label: "Select all",
1770
+ hint: `${input.missing.length} repositories from rness.json`
1771
+ },
1772
+ ...input.missing.map((name) => ({ value: name, label: name }))
1773
+ ],
1774
+ required: false,
1775
+ placeholder: "Type to filter"
1776
+ });
1777
+ if (p.isCancel(answer) || !Array.isArray(answer)) return cancelled2();
1778
+ return answer.includes(SELECT_ALL) ? [...input.missing] : answer.filter((name) => name !== SELECT_ALL);
1779
+ }
1780
+ const ok = await p.confirm({
1781
+ message: `Sync workspace \`${input.org}\`: rewrite the rness blocks of your clones?`
1782
+ });
1783
+ if (p.isCancel(ok) || ok !== true) return cancelled2();
1784
+ return [];
1785
+ }
1786
+ async function syncCommand(opts, deps = {}) {
1787
+ const terminal = deps.terminal ?? defaultTerminal;
1788
+ const transport = deps.transport ?? defaultTransport;
1789
+ let provider = deps.provider;
1790
+ const credentialsFor = async (url) => (provider ??= await githubProvider()).credentialsFor(url);
1791
+ const cwd = opts.cwd ?? process.cwd();
1792
+ const check = opts.check === true;
1793
+ const ownUi = deps.ui === void 0;
1794
+ const ui = deps.ui ?? (opts.yes !== true && !check ? await makeUi(terminal) : { ...plainUi });
1795
+ try {
1796
+ const ws = await findWorkspace(cwd);
1797
+ const manifest = await loadManifest(ws.rnessDir);
1798
+ const { org, warning } = resolveOrg(manifest, ws.root);
1799
+ if (ownUi) ui.intro(`Sync workspace ${org}`);
1800
+ if (warning !== null) ui.warn(warning);
1801
+ if (opts.scope !== void 0 && !Object.hasOwn(manifest.scopes, opts.scope)) {
1802
+ process.stderr.write(`unknown scope: ${opts.scope}
1803
+ `);
1804
+ return 1;
1805
+ }
1806
+ const clones = await clonesIn(ws.root);
1807
+ const missing = Object.keys(manifest.repos).filter((n) => !clones.has(n));
1808
+ let toClone = new Set(opts.all === true && !check ? missing : []);
1809
+ if (!check && opts.yes !== true) {
1810
+ const answer = await askWhatToSync({
1811
+ org,
1812
+ missing,
1813
+ // `--all` already answers; `--scope` is about one block, not the workspace.
1814
+ offer: opts.all !== true && opts.scope === void 0,
1815
+ terminal,
1816
+ ui
1817
+ });
1818
+ if (typeof answer === "number") return answer;
1819
+ if (opts.all !== true) toClone = new Set(answer);
1820
+ }
1821
+ const overSsh = [...toClone].some((name) => {
1822
+ const repo = manifest.repos[name];
1823
+ return repo !== void 0 && isSshUrl(repo.url, transport.hosts);
1824
+ });
1825
+ if (!overSsh) ui.gitIsSilent = true;
1826
+ if (overSsh) {
1827
+ const interactive = opts.yes !== true && terminal.isTty();
1828
+ const { access: access2, unattended } = await testGithubSsh(
1829
+ transport,
1830
+ interactive,
1831
+ () => ui.line(...CHECKING)
1832
+ );
1833
+ if (unattended) ui.gitIsSilent = true;
1834
+ if (!access2.ok) {
1835
+ process.stderr.write(
1836
+ `${[...sshWorkspaceLines(access2.reason), ...INSTEAD_OF_LINES].join("\n")}
1837
+ `
1838
+ );
1839
+ return 1;
1840
+ }
1841
+ }
1842
+ const lines = [];
1843
+ const problems = [];
1844
+ try {
1845
+ const notCloned = /* @__PURE__ */ new Set();
1846
+ for (const [name, repo] of Object.entries(manifest.repos)) {
1847
+ const dir = join9(ws.root, "org", name);
1848
+ const label = `org/${name}`;
1849
+ if (!await exists(dir)) {
1850
+ if (!toClone.has(name)) {
1851
+ notCloned.add(name);
1852
+ continue;
1853
+ }
1854
+ try {
1855
+ const credentials = await credentialsFor(repo.url);
1856
+ await ui.step(
1857
+ { doing: `cloning ${label}`, git: true },
1858
+ () => clone(repo.url, dir, credentials)
1859
+ );
1860
+ lines.push(["cloned", `${label}`]);
1861
+ } catch (e) {
1862
+ problems.push(
1863
+ `${label}: ${e instanceof Error ? e.message : String(e)}`
1864
+ );
1865
+ }
1866
+ } else if (opts.pull === true && !check) {
1867
+ try {
1868
+ if (!await isClean(dir, MANAGED_FILES)) {
1869
+ lines.push(["skipped", `${label} (working tree not clean)`]);
1870
+ continue;
1871
+ }
1872
+ const credentials = await credentialsFor(repo.url);
1873
+ await ui.step(
1874
+ { doing: `pulling ${label}`, git: true },
1875
+ () => pullFastForward(dir, credentials)
1876
+ );
1877
+ lines.push(["pulled", `${label}`]);
1878
+ } catch (e) {
1879
+ problems.push(
1880
+ `${label}: ${e instanceof Error ? e.message : String(e)}`
1881
+ );
1882
+ }
1883
+ }
1884
+ }
1885
+ if (notCloned.size > 0)
1886
+ lines.push(
1887
+ `not cloned: ${[...notCloned].join(", ")} (rness add <name>, or rness sync --all)`
1888
+ );
1889
+ const scoped = new Set(
1890
+ Object.values(manifest.scopes).map((s) => s.path.split("/")[1])
1891
+ );
1892
+ const undeclared = [...clones].filter(
1893
+ (n) => !Object.hasOwn(manifest.repos, n) && !scoped.has(n)
1894
+ );
1895
+ if (undeclared.length > 0)
1896
+ lines.push(
1897
+ `not in rness.json: ${undeclared.join(", ")} (rness add <name> declares it)`
1898
+ );
1899
+ if (!await exists(join9(ws.root, "org"))) {
1900
+ lines.push(["skipped", "blocks (no org/ directory here)"]);
1901
+ } else {
1902
+ let differences = 0;
1903
+ for (const t of targetsOf(manifest, ws.root, opts.scope)) {
1904
+ if (!await exists(t.dir)) {
1905
+ const repo = t.label.split("/")[1];
1906
+ if (!t.label.startsWith("org/") || repo === void 0 || !notCloned.has(repo))
1907
+ lines.push(["skipped", `${t.label} (directory not present)`]);
1908
+ continue;
1909
+ }
1910
+ const linked = await Promise.all(
1911
+ MANAGED_FILES.map((f) => isSymlink(join9(t.dir, f)))
1912
+ );
1913
+ if (linked.includes(true)) {
1914
+ lines.push(["skipped", `${t.label} (symlink)`]);
1915
+ continue;
1916
+ }
1917
+ const context = await assembleContext({
1918
+ rnessDir: ws.rnessDir,
1919
+ manifest,
1920
+ scope: t.scope
1921
+ });
1922
+ const block = renderBlock({
1923
+ scope: t.scope,
1924
+ org,
1925
+ depth: t.depth,
1926
+ context
1927
+ });
1928
+ if (block.bytes > BLOCK_SIZE_WARNING) {
1929
+ ui.warn(
1930
+ `${t.label}: block is ${Math.round(block.bytes / 1024)} KB (over 32 KB)`
1931
+ );
1932
+ }
1933
+ const file = join9(t.dir, "AGENTS.md");
1934
+ const existing = await readOrNull(file);
1935
+ const existingLines = existing === null ? [] : existing.split(/\r?\n/);
1936
+ const span = findBlock(existingLines);
1937
+ const current = span.kind === "one" && isCurrentBlock(
1938
+ existingLines.slice(span.begin, span.end + 1),
1939
+ block.hash
1940
+ );
1941
+ const merged = current ? { ok: true, text: existing ?? "", changed: false } : mergeBlock(existing, block.text);
1942
+ if (!merged.ok) {
1943
+ problems.push(`${t.label}: ${merged.error}`);
1944
+ continue;
1945
+ }
1946
+ if (!merged.changed) {
1947
+ lines.push(["unchanged", `${t.label}`]);
1948
+ if (!check) await ensureClaudeMd(t.dir);
1949
+ continue;
1950
+ }
1951
+ if (check) {
1952
+ lines.push(["stale", `${t.label}`]);
1953
+ differences += 1;
1954
+ continue;
1955
+ }
1956
+ await writeFileAtomic(file, merged.text);
1957
+ await ensureClaudeMd(t.dir);
1958
+ lines.push(["updated", `${t.label}`]);
1959
+ }
1960
+ if (check && differences > 0)
1961
+ problems.push(`${differences} block(s) out of date \u2014 run rness sync`);
1962
+ }
1963
+ return problems.length > 0 ? 1 : 0;
1964
+ } finally {
1965
+ for (const l of lines)
1966
+ if (typeof l === "string") ui.hint(l);
1967
+ else ui.line(l[0], l[1]);
1968
+ for (const p of problems) ui.error(p);
1969
+ if (ownUi)
1970
+ ui.outro(
1971
+ problems.length > 0 ? "Done, with problems" : check ? "Checked" : "Your clones carry the current context",
1972
+ problems.length > 0 ? "warn" : "done"
1973
+ );
1974
+ }
1975
+ } catch (e) {
1976
+ return reportError(e);
1977
+ }
1978
+ }
1979
+
1980
+ // src/commands/add.ts
1981
+ import { join as join10 } from "path";
1982
+ function printDeclared(ui, result, already) {
1983
+ for (const name of result.declared) {
1984
+ if (already.has(name)) continue;
1985
+ const entry = result.manifest.scopes[name];
1986
+ if (entry === void 0) continue;
1987
+ const ext = entry.extends.length === 0 ? "" : `, extends ${entry.extends.join(", ")}`;
1988
+ ui.line("declared", `scope ${name} (${entry.path}${ext})`);
1989
+ }
1990
+ }
1991
+ var FULL_URL2 = /^(https?:\/\/|git@|ssh:\/\/|file:\/\/)/;
1992
+ async function addCommand(spec, opts, deps = {}) {
1993
+ const terminal = deps.terminal ?? defaultTerminal;
1994
+ const transport = deps.transport ?? defaultTransport;
1995
+ let provider = deps.provider;
1996
+ const credentialsFor = async (url) => (provider ??= await githubProvider()).credentialsFor(url);
1997
+ const cwd = opts.cwd ?? process.cwd();
1998
+ if (opts.ssh === true && opts.https === true) {
1999
+ process.stderr.write("--ssh and --https cannot be combined\n");
2000
+ return 2;
2001
+ }
2002
+ try {
2003
+ const ws = await findWorkspace(cwd);
2004
+ const manifest = await loadManifest(ws.rnessDir);
2005
+ const { org, warning } = resolveOrg(manifest, ws.root);
2006
+ if (warning !== null) process.stderr.write(`${warn(warning)}
2007
+ `);
2008
+ let parsed;
2009
+ try {
2010
+ parsed = parseRepoSpec(spec, org, transport.hosts.https);
2011
+ } catch (e) {
2012
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}
2013
+ `);
2014
+ return 2;
2015
+ }
2016
+ const interactive = opts.yes !== true;
2017
+ const ownUi = deps.ui === void 0;
2018
+ if (interactive && !terminal.isTty()) {
2019
+ process.stderr.write(
2020
+ "rness add clones and writes files; pass --yes to run without a prompt\n"
2021
+ );
2022
+ return 2;
2023
+ }
2024
+ const ui = deps.ui ?? (interactive ? await makeUi(terminal) : { ...plainUi });
2025
+ if (ownUi) ui.intro(`Add ${parsed.name} to workspace ${org}`);
2026
+ if (opts.host !== void 0 || opts.https === true) ui.gitIsSilent = true;
2027
+ let host = opts.host;
2028
+ if (host === void 0 && opts.ssh === true) host = transport.hosts.ssh;
2029
+ if (host === void 0 && opts.https === true) host = transport.hosts.https;
2030
+ if (host === void 0 && FULL_URL2.test(spec)) host = transport.hosts.https;
2031
+ if (host === void 0) {
2032
+ const { access: access2, unattended } = await testGithubSsh(
2033
+ transport,
2034
+ interactive,
2035
+ () => ui.line(...CHECKING)
2036
+ );
2037
+ if (unattended || !access2.ok) ui.gitIsSilent = true;
2038
+ const overSsh = Object.values(manifest.repos).some(
2039
+ (r) => isSshUrl(r.url, transport.hosts)
2040
+ );
2041
+ if (access2.ok || !overSsh) {
2042
+ ui.line("using", usingRest(access2), usingSentence(access2));
2043
+ host = access2.ok ? transport.hosts.ssh : transport.hosts.https;
2044
+ } else {
2045
+ const lines = sshWorkspaceLines(access2.reason);
2046
+ if (!interactive) {
2047
+ process.stderr.write(
2048
+ `${[...lines, httpsFlagLine(spec)].join("\n")}
2049
+ `
2050
+ );
2051
+ return 1;
2052
+ }
2053
+ ui.error(lines[0]);
2054
+ ui.hint(lines[1], "stderr");
2055
+ const p = await terminal.prompts();
2056
+ const https = await p.confirm({
2057
+ message: `Clone ${parsed.name} over HTTPS instead?`,
2058
+ initialValue: false
2059
+ });
2060
+ if (p.isCancel(https) || https !== true) return 1;
2061
+ host = transport.hosts.https;
2062
+ }
2063
+ }
2064
+ parsed = parseRepoSpec(spec, org, host);
2065
+ const scopes = (opts.scopes ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
2066
+ const dir = join10(ws.root, "org", parsed.name);
2067
+ const present = await exists(dir);
2068
+ if (interactive) {
2069
+ const { confirm, isCancel } = await terminal.prompts();
2070
+ const plan = present ? `adopt org/${parsed.name}` : `clone ${parsed.url} into org/${parsed.name}`;
2071
+ const ok = await confirm({
2072
+ message: `${plan}, declare scope "${parsed.name}" in rness.json, then sync?`
2073
+ });
2074
+ if (isCancel(ok) || ok !== true) {
2075
+ ui.cancelled();
2076
+ return 1;
2077
+ }
2078
+ }
2079
+ const credentials = await credentialsFor(parsed.url);
2080
+ const chosenHost = host;
2081
+ let result = await ui.step(
2082
+ {
2083
+ doing: `${present ? "adopting" : "cloning "} org/${parsed.name}`,
2084
+ git: true
2085
+ },
2086
+ () => addRepository({
2087
+ root: ws.root,
2088
+ rnessDir: ws.rnessDir,
2089
+ manifest,
2090
+ spec,
2091
+ org,
2092
+ host: chosenHost,
2093
+ scopes,
2094
+ credentialsFor: () => credentials
2095
+ }),
2096
+ (r) => [r.action, `org/${r.name}`]
2097
+ );
2098
+ const announced = /* @__PURE__ */ new Set();
2099
+ printDeclared(ui, result, announced);
2100
+ for (const name of result.declared) announced.add(name);
2101
+ if (opts.yes !== true && scopes.length === 0) {
2102
+ const candidates = await workspaceDirs(dir);
2103
+ if (candidates.length > 0) {
2104
+ const { isCancel, multiselect } = await terminal.prompts();
2105
+ const picked = await multiselect({
2106
+ message: "Declare these workspace packages as scopes?",
2107
+ options: candidates.map((c) => ({ value: c, label: c })),
2108
+ required: false
2109
+ });
2110
+ if (isCancel(picked)) {
2111
+ ui.cancelled();
2112
+ return 1;
2113
+ }
2114
+ if (Array.isArray(picked) && picked.length > 0) {
2115
+ result = await addRepository({
2116
+ root: ws.root,
2117
+ rnessDir: ws.rnessDir,
2118
+ manifest: result.manifest,
2119
+ spec,
2120
+ org,
2121
+ host,
2122
+ scopes: picked,
2123
+ credentialsFor: () => credentials
2124
+ });
2125
+ printDeclared(ui, result, announced);
2126
+ }
2127
+ }
2128
+ }
2129
+ const code = await syncCommand({ yes: true, cwd: ws.root }, { ui });
2130
+ if (ownUi)
2131
+ ui.outro(
2132
+ code === 0 ? `${result.name} is in your workspace` : "Done, with problems",
2133
+ code === 0 ? "done" : "warn"
2134
+ );
2135
+ return code;
2136
+ } catch (e) {
2137
+ return reportError(e);
2138
+ }
2139
+ }
2140
+
2141
+ // src/commands/context.ts
2142
+ import { posix as posix3, relative, sep as sep3 } from "path";
2143
+ function renderMarkdown(ctx) {
2144
+ const parts = [];
2145
+ for (const collection of ctx.collections) {
2146
+ if (collection.files.length === 0) continue;
2147
+ parts.push(`## ${collection.name}
2148
+ `);
2149
+ for (const file of collection.files) {
2150
+ parts.push(
2151
+ `<!-- rness: ${collection.name}/${file.rel} -->
2152
+ ${file.content.trim()}
2153
+ `
2154
+ );
2155
+ }
2156
+ }
2157
+ return `${parts.join("\n")}
2158
+ `;
2159
+ }
2160
+ async function contextCommand(opts) {
2161
+ const cwd = opts.cwd ?? process.cwd();
2162
+ try {
2163
+ const ws = await findWorkspace(cwd);
2164
+ const manifest = await loadManifest(ws.rnessDir);
2165
+ let scope;
2166
+ if (opts.scope !== void 0) {
2167
+ if (!Object.hasOwn(manifest.scopes, opts.scope)) {
2168
+ process.stderr.write(`unknown scope: ${opts.scope}
2169
+ `);
2170
+ return 1;
2171
+ }
2172
+ scope = opts.scope;
2173
+ } else {
2174
+ const rel = relative(ws.root, cwd).split(sep3).join(posix3.sep);
2175
+ scope = resolveScope(manifest, rel);
2176
+ }
2177
+ const ctx = await assembleContext({
2178
+ rnessDir: ws.rnessDir,
2179
+ manifest,
2180
+ scope
2181
+ });
2182
+ process.stdout.write(
2183
+ opts.json ? `${JSON.stringify(ctx, null, 2)}
2184
+ ` : renderMarkdown(ctx)
2185
+ );
2186
+ return 0;
2187
+ } catch (e) {
2188
+ return reportError(e);
2189
+ }
2190
+ }
2191
+
2192
+ // src/core/delegate.ts
2193
+ import { readFile as readFile6 } from "fs/promises";
2194
+ import { join as join11 } from "path";
2195
+ var DEFAULT_BIN = "dist/bin/rness.js";
2196
+ async function readPinnedCli(rnessDir) {
2197
+ const dir = join11(rnessDir, "node_modules", "@rness", "cli");
2198
+ let pkg;
2199
+ try {
2200
+ pkg = JSON.parse(await readFile6(join11(dir, "package.json"), "utf8"));
2201
+ } catch {
2202
+ return null;
2203
+ }
2204
+ const declared = pkg.bin;
2205
+ let bin = DEFAULT_BIN;
2206
+ if (typeof declared === "string") bin = declared;
2207
+ else if (declared !== null && typeof declared === "object") {
2208
+ const named = declared["rness"];
2209
+ if (typeof named === "string") bin = named;
2210
+ }
2211
+ return {
2212
+ dir,
2213
+ bin,
2214
+ version: typeof pkg.version === "string" ? pkg.version : null
2215
+ };
2216
+ }
2217
+ async function findDelegate(cwd, ownVersion) {
2218
+ let root;
2219
+ let rnessDir;
2220
+ try {
2221
+ const ws = await findWorkspace(cwd);
2222
+ root = ws.root;
2223
+ rnessDir = ws.rnessDir;
2224
+ } catch {
2225
+ return null;
2226
+ }
2227
+ const pinned = await readPinnedCli(rnessDir);
2228
+ if (pinned === null) return null;
2229
+ const { version } = pinned;
2230
+ if (version === null || version === ownVersion) return null;
2231
+ return { entry: join11(pinned.dir, "dist", "index.js"), version, root };
2232
+ }
2233
+
2234
+ // src/core/pm.ts
2235
+ import { execFile as execFile2 } from "child_process";
2236
+ import { promisify as promisify2 } from "util";
2237
+ var execFileP2 = promisify2(execFile2);
2238
+ var PACKAGE_MANAGERS = [
2239
+ "npm",
2240
+ "pnpm",
2241
+ "yarn",
2242
+ "bun"
2243
+ ];
2244
+ function isPackageManager(value) {
2245
+ return PACKAGE_MANAGERS.includes(value);
2246
+ }
2247
+ function detectPackageManager(userAgent = process.env["npm_config_user_agent"] ?? "") {
2248
+ const name = userAgent.split("/")[0] ?? "";
2249
+ return isPackageManager(name) ? name : "npm";
2250
+ }
2251
+ function firstLine3(text) {
2252
+ return text.split("\n").find((l) => l.trim() !== "")?.trim() ?? "unknown error";
2253
+ }
2254
+ async function packageManagerVersion(pm) {
2255
+ try {
2256
+ const { stdout } = await execFileP2(pm, ["--version"]);
2257
+ return stdout.trim();
2258
+ } catch (e) {
2259
+ throw new Error(`${pm} is not available on PATH`, { cause: e });
2260
+ }
2261
+ }
2262
+ var BARE_NPM_CODE = /^npm (error|ERR!) code \S+$/;
2263
+ function installErrorLine(stderr) {
2264
+ const lines = stderr.split("\n").map((l) => l.trim()).filter((l) => l !== "");
2265
+ return lines.find((l) => !BARE_NPM_CODE.test(l)) ?? firstLine3(stderr);
2266
+ }
2267
+ async function installDependencies(pm, dir) {
2268
+ try {
2269
+ await execFileP2(pm, ["install"], { cwd: dir, maxBuffer: 16 * 1024 * 1024 });
2270
+ } catch (e) {
2271
+ const err = e;
2272
+ throw new Error(
2273
+ `${pm} install failed in ${dir}: ${installErrorLine(err.stderr ?? err.message)}`,
2274
+ { cause: e }
2275
+ );
2276
+ }
2277
+ }
2278
+
2279
+ // src/core/pinned.ts
2280
+ import { execFile as execFile3 } from "child_process";
2281
+ import { readFile as readFile7 } from "fs/promises";
2282
+ import { basename as basename3, join as join12 } from "path";
2283
+ import { promisify as promisify3 } from "util";
2284
+ var execFileP3 = promisify3(execFile3);
2285
+ var PACKAGE = "@rness/cli";
2286
+ var EXACT_VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
2287
+ function compareVersions(a, b) {
2288
+ const split = (v) => {
2289
+ const dash = v.indexOf("-");
2290
+ const core = dash === -1 ? v : v.slice(0, dash);
2291
+ return [core.split(".").map(Number), dash === -1 ? null : v.slice(dash + 1)];
2292
+ };
2293
+ const [coreA, preA] = split(a);
2294
+ const [coreB, preB] = split(b);
2295
+ for (let i = 0; i < 3; i += 1) {
2296
+ const diff = (coreA[i] ?? 0) - (coreB[i] ?? 0);
2297
+ if (diff !== 0) return diff;
2298
+ }
2299
+ if (preA === preB) return 0;
2300
+ if (preA === null) return 1;
2301
+ if (preB === null) return -1;
2302
+ return preA.localeCompare(preB, "en", { numeric: true });
2303
+ }
2304
+ async function readPackageJson(rnessDir) {
2305
+ try {
2306
+ const parsed = JSON.parse(
2307
+ await readFile7(join12(rnessDir, "package.json"), "utf8")
2308
+ );
2309
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
2310
+ } catch {
2311
+ return null;
2312
+ }
2313
+ }
2314
+ async function readPin(rnessDir) {
2315
+ const pkg = await readPackageJson(rnessDir);
2316
+ if (pkg === null) return null;
2317
+ for (const field of ["devDependencies", "dependencies"]) {
2318
+ const deps = pkg[field];
2319
+ if (deps === null || typeof deps !== "object") continue;
2320
+ const spec = deps[PACKAGE];
2321
+ if (typeof spec === "string") return { spec, field };
2322
+ }
2323
+ return null;
2324
+ }
2325
+ async function writePin(rnessDir, from, to) {
2326
+ const file = join12(rnessDir, "package.json");
2327
+ const text = await readFile7(file, "utf8");
2328
+ const entry = new RegExp(
2329
+ `("${PACKAGE}"\\s*:\\s*")${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(")`
2330
+ );
2331
+ if (!entry.test(text))
2332
+ throw new Error(`"${PACKAGE}": "${from}" not found in package.json`);
2333
+ await writeFileAtomic(file, text.replace(entry, `$1${to}$2`));
2334
+ }
2335
+ var LOCKFILES = [
2336
+ ["pnpm-lock.yaml", "pnpm"],
2337
+ ["yarn.lock", "yarn"],
2338
+ ["bun.lock", "bun"],
2339
+ ["bun.lockb", "bun"],
2340
+ ["package-lock.json", "npm"]
2341
+ ];
2342
+ async function workspacePackageManager(rnessDir) {
2343
+ const declared = (await readPackageJson(rnessDir))?.["packageManager"];
2344
+ if (typeof declared === "string") {
2345
+ const name = declared.split("@")[0] ?? "";
2346
+ if (isPackageManager(name)) return name;
2347
+ }
2348
+ for (const [lockfile, pm] of LOCKFILES)
2349
+ if (await exists(join12(rnessDir, lockfile))) return pm;
2350
+ return "npm";
2351
+ }
2352
+ async function pinDrift(rnessDir) {
2353
+ const pin = await readPin(rnessDir);
2354
+ if (pin === null || !EXACT_VERSION.test(pin.spec)) return null;
2355
+ const installed = (await readPinnedCli(rnessDir))?.version ?? null;
2356
+ return installed === pin.spec ? null : { pin: pin.spec, installed };
2357
+ }
2358
+ async function driftWarning(cwd) {
2359
+ let root;
2360
+ let rnessDir;
2361
+ try {
2362
+ const ws = await findWorkspace(cwd);
2363
+ root = ws.root;
2364
+ rnessDir = ws.rnessDir;
2365
+ } catch {
2366
+ return null;
2367
+ }
2368
+ const drift = await pinDrift(rnessDir);
2369
+ if (drift === null) return null;
2370
+ const pm = await workspacePackageManager(rnessDir);
2371
+ return warn(
2372
+ `${basename3(root)}/.rness pins @rness/cli ${drift.pin} but ${drift.installed ?? "nothing"} is installed \u2014 run ${pm} install in .rness`
2373
+ );
2374
+ }
2375
+ async function runPinnedSync(root) {
2376
+ const installed = await readPinnedCli(join12(root, ".rness"));
2377
+ if (installed === null) return null;
2378
+ const pinned = join12(installed.dir, ...installed.bin.split("/"));
2379
+ if (!await exists(pinned))
2380
+ return {
2381
+ code: 1,
2382
+ stdout: "",
2383
+ stderr: `@rness/cli in .rness is not installed correctly (missing ${installed.bin}); reinstall in .rness/
2384
+ `
2385
+ };
2386
+ try {
2387
+ const { stdout, stderr } = await execFileP3(
2388
+ process.execPath,
2389
+ [pinned, "sync", "--yes"],
2390
+ { cwd: root, maxBuffer: 16 * 1024 * 1024 }
2391
+ );
2392
+ return { code: 0, stdout, stderr };
2393
+ } catch (e) {
2394
+ const err = e;
2395
+ return {
2396
+ code: typeof err.code === "number" ? err.code : 1,
2397
+ stdout: err.stdout ?? "",
2398
+ stderr: err.stderr ?? ""
2399
+ };
2400
+ }
2401
+ }
2402
+ function summariseSync(stdout) {
2403
+ const counts = /* @__PURE__ */ new Map();
2404
+ const others = [];
2405
+ for (const line of stdout.split("\n")) {
2406
+ if (line.trim() === "") continue;
2407
+ const verb = /^(updated|unchanged|stale)\s/.exec(line)?.[1];
2408
+ if (verb === void 0) others.push(line);
2409
+ else counts.set(verb, (counts.get(verb) ?? 0) + 1);
2410
+ }
2411
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
2412
+ const detail = [...counts].map(([verb, n]) => `${n} ${verb}`).join(", ");
2413
+ return {
2414
+ summary: `${total} block${total === 1 ? "" : "s"}${detail === "" ? "" : `: ${detail}`}`,
2415
+ others
2416
+ };
2417
+ }
2418
+ async function syncPinned(root, shown, fallback) {
2419
+ const rnessDir = join12(root, ".rness");
2420
+ const installed = await readPinnedCli(rnessDir);
2421
+ if (installed === null) {
2422
+ if (fallback !== void 0) return fallback();
2423
+ process.stderr.write(
2424
+ `@rness/cli is not installed in ${shown}/.rness; install in .rness/, then rness sync
2425
+ `
2426
+ );
2427
+ return 1;
2428
+ }
2429
+ const pinned = join12(installed.dir, ...installed.bin.split("/"));
2430
+ if (!await exists(pinned)) {
2431
+ process.stderr.write(
2432
+ `@rness/cli in ${shown}/.rness is not installed correctly (missing ${installed.bin}); reinstall in .rness/
2433
+ `
2434
+ );
2435
+ return 1;
2436
+ }
2437
+ try {
2438
+ const { stdout, stderr } = await execFileP3(
2439
+ process.execPath,
2440
+ [pinned, "sync", "--yes"],
2441
+ { cwd: root, maxBuffer: 16 * 1024 * 1024 }
2442
+ );
2443
+ process.stdout.write(stdout);
2444
+ if (stderr !== "") process.stderr.write(stderr);
2445
+ return 0;
2446
+ } catch (e) {
2447
+ const err = e;
2448
+ if (err.stdout !== void 0 && err.stdout !== "")
2449
+ process.stdout.write(err.stdout);
2450
+ if (err.stderr !== void 0 && err.stderr !== "")
2451
+ process.stderr.write(err.stderr);
2452
+ return typeof err.code === "number" ? err.code : 1;
2453
+ }
2454
+ }
2455
+
2456
+ // src/core/scaffold.ts
2457
+ import { statSync } from "fs";
2458
+ import { dirname as dirname4, join as join13 } from "path";
2459
+ import { fileURLToPath } from "url";
2460
+ var SCAFFOLD_FILES = [
2461
+ "AGENTS.md",
2462
+ "CONVENTIONS.md",
2463
+ "README.md",
2464
+ "WORKSPACE.md",
2465
+ "rness.json",
2466
+ "package.json",
2467
+ "_gitignore",
2468
+ ".githooks/pre-commit",
2469
+ ".github/workflows/validate.yml",
2470
+ "pnpm-workspace.yaml",
2471
+ "adr/0000-template.md",
2472
+ "standards/.gitkeep",
2473
+ "specs/.gitkeep",
2474
+ "plans/.gitkeep",
2475
+ "skills/.gitkeep",
2476
+ "docs/.gitkeep"
2477
+ ];
2478
+ function scaffoldDir() {
2479
+ let dir = dirname4(fileURLToPath(import.meta.url));
2480
+ for (let i = 0; i < 4; i += 1) {
2481
+ const candidate = join13(dir, "scaffold");
2482
+ try {
2483
+ if (statSync(join13(candidate, "rness.json")).isFile()) return candidate;
2484
+ } catch {
2485
+ }
2486
+ dir = dirname4(dir);
2487
+ }
2488
+ throw new Error("scaffold directory not found next to the @rness/cli package");
2489
+ }
2490
+
2491
+ // src/core/scaffold-copy.ts
2492
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile8, stat as stat2, writeFile as writeFile2 } from "fs/promises";
2493
+ import { dirname as dirname5, join as join14 } from "path";
2494
+ var TOKENISED = /* @__PURE__ */ new Set(["package.json"]);
2495
+ async function copyScaffold(dest, tokens) {
2496
+ const source = scaffoldDir();
2497
+ for (const rel of SCAFFOLD_FILES) {
2498
+ if (rel === "rness.json") continue;
2499
+ const from = join14(source, ...rel.split("/"));
2500
+ const targetRel = rel === "_gitignore" ? ".gitignore" : rel;
2501
+ const to = join14(dest, ...targetRel.split("/"));
2502
+ await mkdir2(dirname5(to), { recursive: true });
2503
+ let content = await readFile8(from, "utf8");
2504
+ if (TOKENISED.has(rel)) {
2505
+ content = content.replaceAll("__RNESS_VERSION__", tokens.version).replaceAll("__RNESS_PM__", tokens.packageManager);
2506
+ }
2507
+ await writeFile2(to, content, "utf8");
2508
+ const mode = (await stat2(from)).mode & 511;
2509
+ if (mode & 73) await chmod2(to, mode);
2510
+ }
2511
+ }
2512
+
2513
+ // src/version.ts
2514
+ var VERSION = "0.5.0";
2515
+
2516
+ // src/commands/login.ts
2517
+ import { spawn as spawn3 } from "child_process";
2518
+ import { realpath } from "fs/promises";
2519
+
2520
+ // src/core/setup-git.ts
2521
+ import { execFile as execFile4 } from "child_process";
2522
+ import { promisify as promisify4 } from "util";
2523
+ var execFileP4 = promisify4(execFile4);
2524
+ var KEY = "credential.https://github.com.helper";
2525
+ function helperValue(node, bin) {
2526
+ return `!'${node}' '${bin}' git-credential`;
2527
+ }
2528
+ var isOurs = (value) => /^!'[^']+' '[^']*rness[^']*' git-credential$/.test(value);
2529
+ async function config(args) {
2530
+ const { stdout } = await execFileP4("git", ["config", "--global", ...args]);
2531
+ return stdout;
2532
+ }
2533
+ async function values() {
2534
+ try {
2535
+ const out = await config(["--get-all", KEY]);
2536
+ return out.replace(/\n$/, "").split("\n");
2537
+ } catch {
2538
+ return [];
2539
+ }
2540
+ }
2541
+ function isStableInstall(bin) {
2542
+ return !/[\\/]_npx[\\/]/.test(bin);
2543
+ }
2544
+ async function setupGit(node, bin) {
2545
+ const ours = helperValue(node, bin);
2546
+ if ((await values()).includes(ours)) return false;
2547
+ await removeGitSetup();
2548
+ await config(["--add", KEY, ""]);
2549
+ await config(["--add", KEY, ours]);
2550
+ return true;
2551
+ }
2552
+ async function removeGitSetup() {
2553
+ const current = await values();
2554
+ if (!current.some(isOurs)) return false;
2555
+ const kept = current.filter(
2556
+ (value, i) => !isOurs(value) && !(value === "" && isOurs(current[i + 1] ?? ""))
2557
+ );
2558
+ await config(["--unset-all", KEY]);
2559
+ for (const value of kept) await config(["--add", KEY, value]);
2560
+ return true;
2561
+ }
2562
+
2563
+ // src/commands/login.ts
2564
+ function openInBrowser(url) {
2565
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
2566
+ try {
2567
+ const child = spawn3(command, args, { stdio: "ignore", detached: true });
2568
+ child.on("error", () => void 0);
2569
+ child.unref();
2570
+ } catch {
2571
+ }
2572
+ }
2573
+ async function offerSetupGit(opts, terminal, bin, ui) {
2574
+ if (opts.setupGit === false) return;
2575
+ if (!isStableInstall(bin)) {
2576
+ ui.hint(
2577
+ "install rness globally to let git use your login: npm i -g @rness/cli"
2578
+ );
2579
+ return;
2580
+ }
2581
+ if (opts.setupGit !== true) {
2582
+ if (!terminal.isTty()) return;
2583
+ const p = await terminal.prompts();
2584
+ const ok = await p.confirm({
2585
+ message: "Use rness to authenticate git over HTTPS for github.com?",
2586
+ initialValue: true
2587
+ });
2588
+ if (p.isCancel(ok) || ok !== true) return;
2589
+ }
2590
+ const changed = await setupGit(process.execPath, bin);
2591
+ ui.line(
2592
+ changed ? "updated" : "unchanged",
2593
+ "git: rness answers for https://github.com (rness logout undoes it)",
2594
+ "git now asks rness for your github.com login over HTTPS (rness logout undoes it)"
2595
+ );
2596
+ }
2597
+ async function loginCommand(opts, deps = {}) {
2598
+ const terminal = deps.terminal ?? defaultTerminal;
2599
+ const own = deps.ui === void 0;
2600
+ const ui = deps.ui ?? await makeUi(terminal);
2601
+ if (own) ui.intro("Log in to GitHub");
2602
+ try {
2603
+ const bin = deps.bin ?? await realpath(process.argv[1] ?? "").catch(() => "");
2604
+ const env = envToken();
2605
+ if (env !== null)
2606
+ ui.hint(`${env.source} is set and wins over a stored login`);
2607
+ const stored = await readAuth();
2608
+ if (stored !== null && await resolveToken() !== null) {
2609
+ ui.line(
2610
+ "logged in",
2611
+ `as ${stored.login} (github.com); rness logout to switch`
2612
+ );
2613
+ await offerSetupGit(opts, terminal, bin, ui);
2614
+ if (own) ui.outro("Nothing to do", "idle");
2615
+ return 0;
2616
+ }
2617
+ const code = await requestDeviceCode();
2618
+ if (ui.session)
2619
+ ui.line(
2620
+ "open",
2621
+ code.verificationUri,
2622
+ `Open ${code.verificationUri} and enter the code ${code.userCode}`
2623
+ );
2624
+ else {
2625
+ ui.line("open", code.verificationUri);
2626
+ ui.line("code", code.userCode);
2627
+ }
2628
+ if (terminal.isTty() && process.env["SSH_CONNECTION"] === void 0)
2629
+ (deps.open ?? openInBrowser)(code.verificationUri);
2630
+ if (!ui.session)
2631
+ ui.line("waiting", "for you to approve rness on github.com\u2026");
2632
+ const login = await ui.step(
2633
+ {
2634
+ doing: "waiting for you to approve rness on github.com",
2635
+ sentence: "Waiting for your approval on github.com",
2636
+ quiet: true
2637
+ },
2638
+ async () => {
2639
+ const tokens = await pollForToken(code, deps);
2640
+ const user = await getUser({
2641
+ token: tokens.accessToken,
2642
+ apiBase: opts.githubApi ?? DEFAULT_GITHUB_API
2643
+ });
2644
+ await writeAuth({ login: user.login, tokens });
2645
+ return user.login;
2646
+ },
2647
+ (name) => ["logged in", `as ${name} (github.com)`]
2648
+ );
2649
+ if (!ui.session) ui.line("logged in", `as ${login} (github.com)`);
2650
+ await offerSetupGit(opts, terminal, bin, ui);
2651
+ if (own) ui.outro(`You are logged in as ${login}`);
2652
+ return 0;
2653
+ } catch (e) {
2654
+ if (e instanceof LoginError) {
2655
+ ui.error(e.message);
2656
+ return 1;
2657
+ }
2658
+ return reportError(e);
2659
+ }
2660
+ }
2661
+ function revokeUrl() {
2662
+ return `https://github.com/settings/connections/applications/${clientId()}`;
2663
+ }
2664
+
2665
+ // src/commands/create.ts
2666
+ import { mkdir as mkdir3, mkdtemp, readdir as readdir2, rename as rename2, rm as rm4, stat as stat3 } from "fs/promises";
2667
+ import { tmpdir } from "os";
2668
+ import { dirname as dirname6, join as join15, relative as relative2, resolve as resolve3 } from "path";
2669
+
2670
+ // src/core/sync-blocks.ts
2671
+ async function syncBlocks(ui, root, shown, inPlace) {
2672
+ if (!ui.session)
2673
+ return ui.step(
2674
+ { doing: "syncing the blocks", git: true },
2675
+ () => syncPinned(root, shown, inPlace)
2676
+ );
2677
+ const result = await ui.step(
2678
+ {
2679
+ doing: "syncing the blocks",
2680
+ sentence: "Writing the rness block into every AGENTS.md"
2681
+ },
2682
+ () => runPinnedSync(root),
2683
+ (r) => r === null || r.code !== 0 ? null : ["synced", summariseSync(r.stdout).summary]
2684
+ );
2685
+ if (result === null) {
2686
+ if (inPlace !== void 0) return inPlace();
2687
+ ui.error(
2688
+ `@rness/cli is not installed in ${shown}/.rness; install in .rness/, then rness sync`
2689
+ );
2690
+ return 1;
2691
+ }
2692
+ for (const line of summariseSync(result.stdout).others) ui.hint(line);
2693
+ for (const line of result.stderr.split("\n"))
2694
+ if (line.trim() !== "") ui.error(line);
2695
+ return result.code;
2696
+ }
2697
+
2698
+ // src/commands/create.ts
2699
+ async function isEmptyDir(dir) {
2700
+ return (await readdir2(dir)).length === 0;
2701
+ }
2702
+ async function insideWorkspace(dir) {
2703
+ try {
2704
+ await findWorkspace(dir);
2705
+ return true;
2706
+ } catch {
2707
+ return false;
2708
+ }
2709
+ }
2710
+ function firstLine4(text) {
2711
+ return text.split("\n").find((l) => l.trim() !== "")?.trim() ?? "unknown error";
2712
+ }
2713
+ function splitSpecs(list) {
2714
+ return list.split(",").map((s) => s.trim()).filter((s) => s !== "");
2715
+ }
2716
+ async function targetProblem(cwd, root, pm) {
2717
+ const shown = relative2(cwd, root) || ".";
2718
+ if (await insideWorkspace(cwd) || await insideWorkspace(dirname6(root)))
2719
+ return "already inside an rness workspace; create runs from outside one";
2720
+ if (!await exists(root)) return null;
2721
+ if (await exists(join15(root, ".rness", "rness.json"))) {
2722
+ if (!await exists(join15(root, ".rness", "node_modules")))
2723
+ return `${shown} holds an rness workspace whose dependencies are not installed; cd ${shown}/.rness && ${pm} install, then rness sync`;
2724
+ return `${shown} already holds an rness workspace (.rness/); run rness add inside it to add repositories`;
2725
+ }
2726
+ if (!(await stat3(root)).isDirectory()) return `${shown} is not a directory`;
2727
+ if (!await isEmptyDir(root)) return `${shown} is not empty`;
2728
+ return null;
2729
+ }
2730
+ function cancelled(ui) {
2731
+ ui.cancelled();
2732
+ return 0;
2733
+ }
2734
+ async function pickRepositories(input) {
2735
+ const { org, prompts, provider, ui } = input;
2736
+ if (provider.authenticated) {
2737
+ const access2 = await provider.organizationAccess(org);
2738
+ const login = (await provider.identity())?.login;
2739
+ if (access2 === "member")
2740
+ ui.line(
2741
+ "member",
2742
+ `${org}${login === void 0 ? "" : ` (as ${login})`}`,
2743
+ `You are a member of ${org}${login === void 0 ? "" : ` (as ${login})`}`
2744
+ );
2745
+ else if (access2 === "restricted") {
2746
+ ui.warn(
2747
+ `${org} restricts OAuth apps, so its private repositories are hidden from rness`
2748
+ );
2749
+ ui.hint(`ask an owner to approve it: ${revokeUrl()}`, "stderr");
2750
+ }
2751
+ }
2752
+ let listed = [];
2753
+ let failed = false;
2754
+ if (!ui.session) ui.line("listing", `${org} repositories\u2026`);
2755
+ try {
2756
+ const listing = await ui.step(
2757
+ {
2758
+ doing: `listing ${org} repositories`,
2759
+ sentence: `Listing the repositories of ${org}`,
2760
+ quiet: true
2761
+ },
2762
+ () => provider.listRepositories(org),
2763
+ (l) => ["listed", `${l.repositories.length} repositories of ${org}`]
2764
+ );
2765
+ listed = listing.repositories;
2766
+ if (listing.owner === "user")
2767
+ ui.hint(
2768
+ `only public repositories of ${org} are listed; add private ones later with rness add <repo>`
2769
+ );
2770
+ else if (!provider.authenticated)
2771
+ ui.hint(
2772
+ "only public repositories are listed; rness login lists the private ones you can access"
2773
+ );
2774
+ if (listing.truncated)
2775
+ ui.hint(`listed the first ${MAX_PAGES * PER_PAGE} repositories of ${org}`);
2776
+ } catch (e) {
2777
+ failed = true;
2778
+ ui.warn(firstLine4(e instanceof Error ? e.message : String(e)));
2779
+ }
2780
+ const inCatalogue = (name) => Object.hasOwn(input.catalogue, name);
2781
+ const byLabel = (a, b) => a.label.localeCompare(b.label, "en", { sensitivity: "base" });
2782
+ const candidates = listed.filter(
2783
+ (r) => !r.archived && !r.name.startsWith(".")
2784
+ );
2785
+ const padlock = unicode();
2786
+ const hint = (isPrivate, catalogued) => [isPrivate && !padlock ? "private" : null, catalogued ? "in .rness" : null].filter((h) => h !== null).join(" \xB7 ") || null;
2787
+ const declarable = candidates.filter((r) => NAME.test(r.name.toLowerCase())).map((r) => {
2788
+ const value = r.name.toLowerCase();
2789
+ const h = hint(r.private, inCatalogue(value));
2790
+ const label = r.private && padlock ? `${r.name} ${PRIVATE_MARK}` : r.name;
2791
+ return { value, label, ...h === null ? {} : { hint: h } };
2792
+ });
2793
+ const offered = new Set(declarable.map((o) => o.value));
2794
+ for (const name of Object.keys(input.catalogue)) {
2795
+ if (!offered.has(name))
2796
+ declarable.push({ value: name, label: name, hint: "in .rness" });
2797
+ }
2798
+ declarable.sort(byLabel);
2799
+ const unsupported = candidates.filter((r) => !NAME.test(r.name.toLowerCase())).map((r) => ({
2800
+ value: r.name,
2801
+ label: r.name,
2802
+ hint: "name not supported yet",
2803
+ disabled: true
2804
+ })).sort(byLabel);
2805
+ if (unsupported.length > 0) {
2806
+ const names = unsupported.slice(0, 5).map((o) => o.label);
2807
+ const more = unsupported.length > names.length ? ` and ${unsupported.length - names.length} more` : "";
2808
+ ui.hint(
2809
+ `names rness cannot declare yet (struck through): ${names.join(", ")}${more}`
2810
+ );
2811
+ }
2812
+ if (declarable.length === 0) {
2813
+ if (!failed) ui.warn(`no repositories to list for ${org}`);
2814
+ return { picked: [], prompted: false };
2815
+ }
2816
+ const initialValues = Object.keys(input.catalogue);
2817
+ const answer = await prompts.autocompleteMultiselect({
2818
+ message: "Which repositories do you want in your workspace?",
2819
+ options: [...declarable, ...unsupported],
2820
+ ...initialValues.length > 0 ? { initialValues } : {},
2821
+ required: false,
2822
+ placeholder: "Type to filter"
2823
+ });
2824
+ if (prompts.isCancel(answer)) return null;
2825
+ const allowed = new Set(declarable.map((o) => o.value));
2826
+ return {
2827
+ picked: Array.isArray(answer) ? answer.filter((v) => allowed.has(v)) : [],
2828
+ prompted: true
2829
+ };
2830
+ }
2831
+ async function cloneSelection(input) {
2832
+ const { ui } = input;
2833
+ let manifest = input.manifest;
2834
+ const failures = [];
2835
+ for (const spec of new Set(input.specs)) {
2836
+ try {
2837
+ let name = null;
2838
+ try {
2839
+ name = parseRepoSpec(spec, input.org, input.host).name;
2840
+ } catch {
2841
+ }
2842
+ const entry = name === null ? void 0 : manifest.repos[name];
2843
+ if (name !== null && entry !== void 0) {
2844
+ const repo = name;
2845
+ await ui.step(
2846
+ { doing: `cloning org/${repo}`, git: true },
2847
+ () => clone(
2848
+ entry.url,
2849
+ join15(input.root, "org", repo),
2850
+ input.provider.credentialsFor(entry.url)
2851
+ ),
2852
+ () => ["cloned", `org/${repo}`]
2853
+ );
2854
+ continue;
2855
+ }
2856
+ const current = manifest;
2857
+ const result = await ui.step(
2858
+ { doing: `cloning ${spec}`, git: true },
2859
+ () => addRepository({
2860
+ root: input.root,
2861
+ rnessDir: input.rnessDir,
2862
+ manifest: current,
2863
+ spec,
2864
+ org: input.org,
2865
+ host: input.host,
2866
+ scopes: [],
2867
+ credentialsFor: (url) => input.provider.credentialsFor(url)
2868
+ }),
2869
+ (r) => [r.action, `org/${r.name}`]
2870
+ );
2871
+ manifest = result.manifest;
2872
+ ui.line("declared", `scope ${result.name} (org/${result.name})`);
2873
+ } catch (e) {
2874
+ const message = firstLine4(e instanceof Error ? e.message : String(e));
2875
+ ui.error(`${spec}: ${message}`);
2876
+ failures.push({ spec, message });
2877
+ }
2878
+ }
2879
+ return failures;
2880
+ }
2881
+ async function placeContext(staged, rnessDir, url, credentials) {
2882
+ try {
2883
+ await rename2(staged, rnessDir);
2884
+ } catch (e) {
2885
+ if (e.code !== "EXDEV") throw e;
2886
+ await clone(url, rnessDir, credentials);
2887
+ }
2888
+ }
2889
+ var OTHER = /* @__PURE__ */ Symbol("another organization");
2890
+ async function chooseOrganization(provider, prompts) {
2891
+ if (!provider.authenticated) return OTHER;
2892
+ let names;
2893
+ try {
2894
+ const self = (await provider.identity())?.login;
2895
+ names = (await provider.listOrganizations()).map((o) => o.login);
2896
+ if (self !== void 0) names.push(self);
2897
+ } catch {
2898
+ return OTHER;
2899
+ }
2900
+ if (names.length === 0) return OTHER;
2901
+ const answer = await prompts.select({
2902
+ message: "Which GitHub organization?",
2903
+ options: [
2904
+ ...names.map((name) => ({ value: name, label: name })),
2905
+ { value: "", label: "another one\u2026" }
2906
+ ]
2907
+ });
2908
+ if (prompts.isCancel(answer)) return null;
2909
+ if (typeof answer !== "string" || answer === "") return OTHER;
2910
+ return answer;
2911
+ }
2912
+ async function publishContext(input) {
2913
+ const { ui, prompts, provider, org } = input;
2914
+ if (!provider.authenticated) return false;
2915
+ const ok = await prompts.confirm({
2916
+ message: `Create ${org}/.rness on GitHub (private) and push it?`,
2917
+ initialValue: true
2918
+ });
2919
+ if (prompts.isCancel(ok)) return null;
2920
+ if (ok !== true) return false;
2921
+ try {
2922
+ const result = await ui.step(
2923
+ { doing: `creating ${org}/.rness on GitHub` },
2924
+ () => provider.createRepository(org, ".rness")
2925
+ );
2926
+ if (result.kind === "exists") {
2927
+ ui.warn(`${org}/.rness already exists on GitHub; nothing was pushed`);
2928
+ return false;
2929
+ }
2930
+ if (result.kind === "refused") {
2931
+ ui.warn(`GitHub did not create ${org}/.rness: ${result.reason}`);
2932
+ return false;
2933
+ }
2934
+ ui.line("created", `github.com/${org}/.rness (private)`);
2935
+ await ui.step(
2936
+ { doing: `pushing ${org}/.rness`, git: true },
2937
+ () => publish(input.rnessDir, input.url, provider.credentialsFor(input.url)),
2938
+ () => ["pushed", `${org}/.rness`]
2939
+ );
2940
+ return true;
2941
+ } catch (e) {
2942
+ ui.warn(firstLine4(e instanceof Error ? e.message : String(e)));
2943
+ return false;
2944
+ }
2945
+ }
2946
+ async function createCommand(opts, deps = {}) {
2947
+ const terminal = deps.terminal ?? defaultTerminal;
2948
+ const transport = deps.transport ?? defaultTransport;
2949
+ let provider = deps.provider;
2950
+ const getProvider = async () => provider ??= await githubProvider(opts.githubApi);
2951
+ const cwd = opts.cwd ?? process.cwd();
2952
+ if (opts.template !== void 0) {
2953
+ process.stderr.write("--template is reserved for a later version\n");
2954
+ return 2;
2955
+ }
2956
+ if (opts.pm !== void 0 && !isPackageManager(opts.pm)) {
2957
+ process.stderr.write(`--pm must be one of ${PACKAGE_MANAGERS.join(", ")}
2958
+ `);
2959
+ return 2;
2960
+ }
2961
+ if (opts.ssh === true && opts.https === true) {
2962
+ process.stderr.write("--ssh and --https cannot be combined\n");
2963
+ return 2;
2964
+ }
2965
+ if (opts.org !== void 0 && !ORG_NAME.test(opts.org)) {
2966
+ process.stderr.write(
2967
+ `organization name "${opts.org}" is not a valid GitHub organization name
2968
+ `
2969
+ );
2970
+ return 2;
2971
+ }
2972
+ const pm = opts.pm !== void 0 && isPackageManager(opts.pm) ? opts.pm : detectPackageManager();
2973
+ const interactive = opts.yes !== true && terminal.isTty();
2974
+ const ui = interactive ? await makeUi(terminal) : { ...plainUi };
2975
+ let sshTest;
2976
+ const testSsh = async () => {
2977
+ if (sshTest === void 0) {
2978
+ sshTest = await testGithubSsh(
2979
+ transport,
2980
+ interactive,
2981
+ () => ui.line(...CHECKING)
2982
+ );
2983
+ if (sshTest.unattended) ui.gitIsSilent = true;
2984
+ }
2985
+ return sshTest.access;
2986
+ };
2987
+ const chooseHost = async () => {
2988
+ if (opts.host !== void 0 || opts.https === true) ui.gitIsSilent = true;
2989
+ if (opts.host !== void 0) return opts.host;
2990
+ if (opts.ssh === true) return transport.hosts.ssh;
2991
+ if (opts.https === true) return transport.hosts.https;
2992
+ const access2 = await testSsh();
2993
+ const github = access2.ok ? null : await getProvider();
2994
+ const login = github?.authenticated ? (await github.identity())?.login : void 0;
2995
+ ui.line("using", usingRest(access2, login), usingSentence(access2, login));
2996
+ if (!access2.ok) ui.gitIsSilent = true;
2997
+ return access2.ok ? transport.hosts.ssh : transport.hosts.https;
2998
+ };
2999
+ let loaded;
3000
+ const prompts = async () => loaded ??= await terminal.prompts();
3001
+ let staging;
3002
+ if (interactive) process.stdout.write(banner(VERSION));
3003
+ ui.intro("Create a workspace");
3004
+ try {
3005
+ if (interactive && await insideWorkspace(cwd)) {
3006
+ process.stderr.write(
3007
+ "already inside an rness workspace; create runs from outside one\n"
3008
+ );
3009
+ return 1;
3010
+ }
3011
+ let prompted = false;
3012
+ let org = opts.org;
3013
+ if (org === void 0 && !interactive) {
3014
+ process.stderr.write("rness create needs --org <name> without a prompt\n");
3015
+ return 2;
3016
+ }
3017
+ if (interactive && opts.repos === void 0 && !(await getProvider()).authenticated) {
3018
+ const p = await prompts();
3019
+ const login = await p.confirm({
3020
+ message: "Log in to GitHub to list private repositories?",
3021
+ initialValue: true
3022
+ });
3023
+ if (p.isCancel(login)) return cancelled(ui);
3024
+ if (login === true) {
3025
+ const code2 = await loginCommand(
3026
+ opts.githubApi === void 0 ? {} : { githubApi: opts.githubApi },
3027
+ { terminal, ui }
3028
+ );
3029
+ if (code2 !== 0) return code2;
3030
+ provider = await githubProvider(opts.githubApi);
3031
+ }
3032
+ }
3033
+ if (org === void 0) {
3034
+ const chosen = await chooseOrganization(
3035
+ await getProvider(),
3036
+ await prompts()
3037
+ );
3038
+ if (chosen === null) return cancelled(ui);
3039
+ if (chosen !== OTHER) {
3040
+ org = chosen;
3041
+ prompted = true;
3042
+ }
3043
+ }
3044
+ if (org === void 0) {
3045
+ const p = await prompts();
3046
+ const answer = await p.text({
3047
+ message: "What is your GitHub organization named?",
3048
+ placeholder: "acme",
3049
+ validate: (value) => {
3050
+ const v = value?.trim() ?? "";
3051
+ return ORG_NAME.test(v) ? void 0 : `"${v}" is not a valid GitHub organization name`;
3052
+ }
3053
+ });
3054
+ if (p.isCancel(answer) || typeof answer !== "string") return cancelled(ui);
3055
+ org = answer.trim();
3056
+ prompted = true;
3057
+ }
3058
+ const root = resolve3(cwd, org);
3059
+ const shown = relative2(cwd, root) || ".";
3060
+ const problem = await targetProblem(cwd, root, pm);
3061
+ if (problem !== null) {
3062
+ process.stderr.write(`${problem}
3063
+ `);
3064
+ return 1;
3065
+ }
3066
+ if (opts.yes !== true && !terminal.isTty()) {
3067
+ process.stderr.write(
3068
+ "rness create writes files; pass --yes to run without a prompt\n"
3069
+ );
3070
+ return 2;
3071
+ }
3072
+ const host = await chooseHost();
3073
+ const contextUrl = repoUrl(host, org, ".rness");
3074
+ const gitProvider = await getProvider();
3075
+ const probe = await probeRemote(
3076
+ contextUrl,
3077
+ void 0,
3078
+ gitProvider.credentialsFor(contextUrl)
3079
+ );
3080
+ if (probe.kind === "error") {
3081
+ process.stderr.write(`${probe.message}
3082
+ `);
3083
+ return 1;
3084
+ }
3085
+ const joining = probe.kind === "found";
3086
+ if (joining)
3087
+ ui.line(
3088
+ "found",
3089
+ `${org}/.rness \u2014 joining`,
3090
+ `${org} has a workspace \u2014 joining it`
3091
+ );
3092
+ else
3093
+ ui.line(
3094
+ "not found",
3095
+ `${org}/.rness (or not visible to you) \u2014 starting a new workspace`,
3096
+ `${org} has no workspace yet (or none you can see) \u2014 starting one`
3097
+ );
3098
+ let catalogue = { contract: 1, org, repos: {}, scopes: {} };
3099
+ const staged = async () => {
3100
+ staging ??= await mkdtemp(join15(tmpdir(), "rness-join-"));
3101
+ return join15(staging, ".rness");
3102
+ };
3103
+ if (joining) {
3104
+ const stagedDir = await staged();
3105
+ await ui.step(
3106
+ {
3107
+ doing: `cloning ${org}/.rness`,
3108
+ sentence: `Reading the catalogue of ${org}`,
3109
+ git: true
3110
+ },
3111
+ () => clone(contextUrl, stagedDir, gitProvider.credentialsFor(contextUrl))
3112
+ );
3113
+ catalogue = await loadManifest(await staged());
3114
+ const overSsh = Object.values(catalogue.repos).some(
3115
+ (r) => isSshUrl(r.url, transport.hosts)
3116
+ );
3117
+ if (overSsh && opts.ssh !== true && opts.host === void 0) {
3118
+ const access2 = await testSsh();
3119
+ if (!access2.ok) {
3120
+ process.stderr.write(
3121
+ `${[...sshWorkspaceLines(access2.reason), ...INSTEAD_OF_LINES].join("\n")}
3122
+ `
3123
+ );
3124
+ return 1;
3125
+ }
3126
+ }
3127
+ }
3128
+ let specs;
3129
+ if (opts.repos !== void 0) specs = splitSpecs(opts.repos);
3130
+ else if (interactive) {
3131
+ const result = await pickRepositories({
3132
+ org,
3133
+ provider: gitProvider,
3134
+ ui,
3135
+ prompts: await prompts(),
3136
+ catalogue: catalogue.repos
3137
+ });
3138
+ if (result === null) return cancelled(ui);
3139
+ specs = result.picked;
3140
+ prompted ||= result.prompted;
3141
+ } else {
3142
+ specs = Object.keys(catalogue.repos);
3143
+ }
3144
+ if (interactive && !prompted) {
3145
+ const p = await prompts();
3146
+ const ok = await p.confirm({
3147
+ message: `Create workspace ${org} in ./${shown}?`
3148
+ });
3149
+ if (p.isCancel(ok) || ok !== true) return cancelled(ui);
3150
+ }
3151
+ const rnessDir = join15(root, ".rness");
3152
+ const install = async () => {
3153
+ if (opts.skipInstall === true) {
3154
+ ui.line("skipped", "install (--skip-install)");
3155
+ return;
3156
+ }
3157
+ await ui.step(
3158
+ { doing: `installing dependencies with ${pm}` },
3159
+ () => installDependencies(pm, rnessDir),
3160
+ () => ["installed", `dependencies with ${pm}`]
3161
+ );
3162
+ };
3163
+ if (joining) {
3164
+ await mkdir3(root, { recursive: true });
3165
+ await placeContext(
3166
+ await staged(),
3167
+ rnessDir,
3168
+ contextUrl,
3169
+ gitProvider.credentialsFor(contextUrl)
3170
+ );
3171
+ ui.line("cloned", `${shown}/.rness (joined ${org})`);
3172
+ const pin = (await readPin(rnessDir))?.spec;
3173
+ if (pin !== void 0 && EXACT_VERSION.test(pin) && compareVersions(pin, VERSION) < 0)
3174
+ ui.hint(
3175
+ `${org}/.rness pins @rness/cli ${pin} \u2014 you run ${VERSION}; rness upgrade moves the workspace`
3176
+ );
3177
+ await install();
3178
+ await mkdir3(join15(root, "org"), { recursive: true });
3179
+ const failures2 = await cloneSelection({
3180
+ root,
3181
+ rnessDir,
3182
+ manifest: catalogue,
3183
+ org,
3184
+ host,
3185
+ specs,
3186
+ provider: gitProvider,
3187
+ ui
3188
+ });
3189
+ const code2 = await syncBlocks(
3190
+ ui,
3191
+ root,
3192
+ shown,
3193
+ () => syncCommand({ yes: true, cwd: root })
3194
+ );
3195
+ if (code2 !== 0) return code2;
3196
+ if (failures2.length > 0)
3197
+ ui.note(
3198
+ "Next steps",
3199
+ failures2.map((f) => `rness add ${f.spec} # failed: ${f.message}`)
3200
+ );
3201
+ ui.outro(`You joined ${org} \u2014 your workspace is in ${shown}/`);
3202
+ return failures2.length > 0 ? 1 : 0;
3203
+ }
3204
+ const pmVersion = await packageManagerVersion(pm);
3205
+ await mkdir3(root, { recursive: true });
3206
+ try {
3207
+ await copyScaffold(rnessDir, {
3208
+ version: VERSION,
3209
+ packageManager: `${pm}@${pmVersion}`
3210
+ });
3211
+ await writeManifest(rnessDir, catalogue);
3212
+ ui.line("created", `${shown}/.rness (new workspace)`);
3213
+ await install();
3214
+ } catch (e) {
3215
+ reportError(e);
3216
+ await rm4(rnessDir, { recursive: true, force: true }).catch(
3217
+ () => void 0
3218
+ );
3219
+ if (await isEmptyDir(root).catch(() => false))
3220
+ await rm4(root, { recursive: true, force: true }).catch(() => void 0);
3221
+ process.stderr.write(
3222
+ `removed ${shown}/.rness after the failure; fix it and retry
3223
+ `
3224
+ );
3225
+ return 1;
3226
+ }
3227
+ await mkdir3(join15(root, "org"), { recursive: true });
3228
+ const failures = await cloneSelection({
3229
+ root,
3230
+ rnessDir,
3231
+ manifest: catalogue,
3232
+ org,
3233
+ host,
3234
+ specs,
3235
+ provider: gitProvider,
3236
+ ui
3237
+ });
3238
+ await init(rnessDir);
3239
+ try {
3240
+ await commitAll(rnessDir, "chore: rness workspace context");
3241
+ ui.line("committed", `${shown}/.rness`);
3242
+ } catch (e) {
3243
+ ui.warn(
3244
+ `${e instanceof Error ? e.message : String(e)} \u2014 commit ${shown}/.rness yourself`
3245
+ );
3246
+ }
3247
+ const code = await syncBlocks(
3248
+ ui,
3249
+ root,
3250
+ shown,
3251
+ () => syncCommand({ yes: true, cwd: root })
3252
+ );
3253
+ if (code !== 0) return code;
3254
+ const name = org;
3255
+ const published = interactive ? await publishContext({
3256
+ ui,
3257
+ prompts: await prompts(),
3258
+ provider: gitProvider,
3259
+ org: name,
3260
+ rnessDir,
3261
+ url: contextUrl
3262
+ }) : false;
3263
+ if (published === null) return cancelled(ui);
3264
+ const next = [
3265
+ `cd ${shown}/.rness`,
3266
+ // What was asked for and did not happen, as the command that retries
3267
+ // it — the workspace is complete otherwise.
3268
+ ...failures.map((f) => `rness add ${f.spec} # failed: ${f.message}`),
3269
+ ...published ? [] : [
3270
+ `# create the empty private repository ${org}/.rness on github.com, then:`,
3271
+ `git remote add origin ${contextUrl} && git push -u origin main`,
3272
+ // git first: everyone has it. The GitHub CLI second, for those
3273
+ // who do — it creates the repository and pushes in one step.
3274
+ "# or, with the GitHub CLI (gh) installed, in one step:",
3275
+ `gh repo create ${org}/.rness --private --source . --push`
3276
+ ],
3277
+ `# then, for every teammate: npm create rness ${org}`
3278
+ ];
3279
+ if (ui.session) {
3280
+ ui.note("Next steps", next);
3281
+ ui.outro(`Workspace for ${org} is ready in ${shown}/`);
3282
+ } else
3283
+ process.stdout.write(
3284
+ [
3285
+ "",
3286
+ `Workspace for \`${org}\` is ready in ${shown}/.`,
3287
+ "",
3288
+ "Next:",
3289
+ ...next.map((line) => ` ${line}`),
3290
+ ""
3291
+ ].join("\n")
3292
+ );
3293
+ return failures.length > 0 ? 1 : 0;
3294
+ } catch (e) {
3295
+ return reportError(e);
3296
+ } finally {
3297
+ if (staging !== void 0)
3298
+ await rm4(staging, { recursive: true, force: true }).catch(() => void 0);
3299
+ }
3300
+ }
3301
+
3302
+ // src/commands/logout.ts
3303
+ async function logoutCommand() {
3304
+ try {
3305
+ const stored = await readAuth();
3306
+ await clearAuth();
3307
+ const undone = await removeGitSetup().catch(() => false);
3308
+ if (stored === null && !undone) {
3309
+ process.stdout.write("not logged in\n");
3310
+ return 0;
3311
+ }
3312
+ if (stored !== null)
3313
+ process.stdout.write(
3314
+ `${status("logged out", `${stored.login} (github.com)`)}
3315
+ `
3316
+ );
3317
+ if (undone)
3318
+ process.stdout.write(
3319
+ `${status("updated", "git: rness no longer answers for https://github.com")}
3320
+ `
3321
+ );
3322
+ process.stdout.write(`revoke rness on GitHub: ${revokeUrl()}
3323
+ `);
3324
+ return 0;
3325
+ } catch (e) {
3326
+ return reportError(e);
3327
+ }
3328
+ }
3329
+
3330
+ // src/commands/upgrade.ts
3331
+ import { execFile as execFile5 } from "child_process";
3332
+ import { readFile as readFile9, readdir as readdir3 } from "fs/promises";
3333
+ import { basename as basename4, join as join16, relative as relative3 } from "path";
3334
+ import { promisify as promisify5 } from "util";
3335
+ var execFileP5 = promisify5(execFile5);
3336
+ var NOTES = "https://github.com/rness-dev/rness/tree/main/packages/cli#readme";
3337
+ var OLD_WORKFLOW_RUN = /npx --yes @rness\/cli@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?) validate/;
3338
+ var WORKFLOW_RUN = `npx --yes "@rness/cli@$(node -p "require('./package.json').devDependencies['@rness/cli']")" validate`;
3339
+ async function latestVersion() {
3340
+ try {
3341
+ const { stdout } = await execFileP5(
3342
+ "npm",
3343
+ ["view", "@rness/cli@latest", "version"],
3344
+ { timeout: 3e4 }
3345
+ );
3346
+ const version = stdout.trim();
3347
+ return EXACT_VERSION.test(version) ? version : null;
3348
+ } catch {
3349
+ return null;
3350
+ }
3351
+ }
3352
+ async function upgradeCommand(version, opts, terminal = defaultTerminal) {
3353
+ const cwd = opts.cwd ?? process.cwd();
3354
+ if (version !== void 0 && !EXACT_VERSION.test(version)) {
3355
+ process.stderr.write(
3356
+ `version "${version}" is not an exact version (for example 0.5.0)
3357
+ `
3358
+ );
3359
+ return 2;
3360
+ }
3361
+ try {
3362
+ const ws = await findWorkspace(cwd);
3363
+ const label = `${basename4(ws.root)}/.rness`;
3364
+ const pin = await readPin(ws.rnessDir);
3365
+ if (pin === null) {
3366
+ process.stderr.write(
3367
+ `no @rness/cli entry in ${label}/package.json; add it to devDependencies, then run rness upgrade
3368
+ `
3369
+ );
3370
+ return 1;
3371
+ }
3372
+ const interactive = opts.yes !== true;
3373
+ if (interactive && !terminal.isTty()) {
3374
+ process.stderr.write(
3375
+ "rness upgrade installs and writes files; pass --yes to run without a prompt\n"
3376
+ );
3377
+ return 2;
3378
+ }
3379
+ const ui = interactive ? await makeUi(terminal) : { ...plainUi };
3380
+ ui.intro(`Upgrade workspace ${basename4(ws.root)}`);
3381
+ const target = version ?? await ui.step(
3382
+ { doing: "asking the registry for the latest release", quiet: true },
3383
+ () => latestVersion(),
3384
+ (v) => v === null ? null : ["latest", `@rness/cli ${v}`]
3385
+ );
3386
+ if (target === null) {
3387
+ process.stderr.write(
3388
+ "cannot reach the registry; name the version: rness upgrade <version>\n"
3389
+ );
3390
+ return 1;
3391
+ }
3392
+ const installed = (await readPinnedCli(ws.rnessDir))?.version ?? null;
3393
+ if (pin.spec === target && installed === target) {
3394
+ if (ui.session) ui.outro(`Already at ${target}`);
3395
+ else process.stdout.write(`already at ${target}
3396
+ `);
3397
+ return 0;
3398
+ }
3399
+ const downgrading = EXACT_VERSION.test(pin.spec) && compareVersions(target, pin.spec) < 0;
3400
+ if (downgrading && version === void 0) {
3401
+ process.stderr.write(
3402
+ `${label} pins @rness/cli ${pin.spec}, newer than the latest release ${target}; to downgrade: rness upgrade ${target}
3403
+ `
3404
+ );
3405
+ return 1;
3406
+ }
3407
+ const pm = await workspacePackageManager(ws.rnessDir);
3408
+ if (pin.spec === target)
3409
+ ui.line(
3410
+ "install",
3411
+ `@rness/cli ${target} in ${label} (${pm}) \u2014 ${installed ?? "nothing"} is installed`
3412
+ );
3413
+ else {
3414
+ ui.line(
3415
+ "upgrade",
3416
+ `@rness/cli ${pin.spec} \u2192 ${target} in ${label} (${pm})${downgrading ? " \u2014 downgrading" : ""}`
3417
+ );
3418
+ ui.line("notes", NOTES, `Release notes: ${NOTES}`);
3419
+ }
3420
+ if (interactive) {
3421
+ const p = await terminal.prompts();
3422
+ const ok = await p.confirm({
3423
+ message: `Upgrade @rness/cli to ${target}?`
3424
+ });
3425
+ if (p.isCancel(ok) || ok !== true) {
3426
+ ui.cancelled();
3427
+ return 1;
3428
+ }
3429
+ }
3430
+ const packageFile = join16(ws.rnessDir, "package.json");
3431
+ const restore = /* @__PURE__ */ new Map([
3432
+ [packageFile, await readFile9(packageFile, "utf8")]
3433
+ ]);
3434
+ if (pin.spec !== target) await writePin(ws.rnessDir, pin.spec, target);
3435
+ const workflows = join16(ws.rnessDir, ".github", "workflows");
3436
+ for (const name of await readdir3(workflows).catch(() => [])) {
3437
+ if (!/\.ya?ml$/.test(name)) continue;
3438
+ const file = join16(workflows, name);
3439
+ const text = await readOrNull(file);
3440
+ if (text === null) continue;
3441
+ const shown = `.github/workflows/${name}`;
3442
+ if (name === "validate.yml" && OLD_WORKFLOW_RUN.test(text)) {
3443
+ restore.set(file, text);
3444
+ await writeFileAtomic(
3445
+ file,
3446
+ text.replace(OLD_WORKFLOW_RUN, () => WORKFLOW_RUN)
3447
+ );
3448
+ ui.line(
3449
+ "updated",
3450
+ `${shown} (it now reads the version from package.json)`
3451
+ );
3452
+ } else if (pin.spec !== target && text.includes(`@rness/cli@${pin.spec}`)) {
3453
+ ui.warn(`${shown} names @rness/cli@${pin.spec}; update it by hand`);
3454
+ }
3455
+ }
3456
+ try {
3457
+ await ui.step(
3458
+ { doing: `installing dependencies with ${pm}` },
3459
+ () => installDependencies(pm, ws.rnessDir),
3460
+ () => ["installed", `dependencies with ${pm}`]
3461
+ );
3462
+ } catch (e) {
3463
+ for (const [file, text] of restore) await writeFileAtomic(file, text);
3464
+ const message = e instanceof Error ? e.message : String(e);
3465
+ ui.error(message);
3466
+ ui.hint(`restored ${relative3(cwd, packageFile) || packageFile}`, "stderr");
3467
+ if (/minimum.?release.?age|NO_MATURE/i.test(message))
3468
+ ui.hint(
3469
+ "pnpm refuses versions published less than 24 h ago: add '@rness/cli' under minimumReleaseAgeExclude in .rness/pnpm-workspace.yaml",
3470
+ "stderr"
3471
+ );
3472
+ return 1;
3473
+ }
3474
+ const now = (await readPinnedCli(ws.rnessDir))?.version ?? null;
3475
+ if (now !== target) {
3476
+ process.stderr.write(
3477
+ `${pm} install left @rness/cli ${now ?? "not installed"} in ${label}, not ${target}
3478
+ `
3479
+ );
3480
+ return 1;
3481
+ }
3482
+ ui.gitIsSilent = true;
3483
+ const code = await syncBlocks(ui, ws.root, basename4(ws.root));
3484
+ if (code !== 0) return code;
3485
+ const rel = relative3(cwd, ws.rnessDir) || ".";
3486
+ const next = [
3487
+ `git -C ${rel} add -A && git -C ${rel} commit -m "chore: rness ${target}"`,
3488
+ `# teammates: git pull, then ${pm} install in .rness`
3489
+ ];
3490
+ if (ui.session) {
3491
+ ui.note("Next steps", next);
3492
+ ui.outro(`${label} runs @rness/cli ${target}`);
3493
+ } else
3494
+ process.stdout.write(
3495
+ [
3496
+ "",
3497
+ status("upgraded", `${label} to @rness/cli ${target}`),
3498
+ "",
3499
+ "Next:",
3500
+ ...next.map((line) => ` ${line}`),
3501
+ ""
3502
+ ].join("\n")
3503
+ );
3504
+ return 0;
3505
+ } catch (e) {
3506
+ return reportError(e);
3507
+ }
3508
+ }
3509
+
3510
+ // src/core/blocks.ts
3511
+ import { join as join17 } from "path";
3512
+ async function checkBlocks({
3513
+ root,
3514
+ rnessDir,
3515
+ manifest,
3516
+ org
3517
+ }) {
3518
+ const problems = [];
3519
+ const warnings = [];
3520
+ if (!await exists(join17(root, "org"))) return { problems, warnings };
3521
+ const targets = [{ scope: null, dir: root, depth: 0, label: "AGENTS.md" }];
3522
+ for (const [name, entry] of Object.entries(manifest.scopes)) {
3523
+ const segments = entry.path.split("/");
3524
+ targets.push({
3525
+ scope: name,
3526
+ dir: join17(root, ...segments),
3527
+ depth: segments.length,
3528
+ label: `${entry.path}/AGENTS.md`
3529
+ });
3530
+ }
3531
+ for (const t of targets) {
3532
+ if (!await exists(t.dir)) {
3533
+ warnings.push(`${t.label}: directory not present, block not checked`);
3534
+ continue;
3535
+ }
3536
+ const text = await readOrNull(join17(t.dir, "AGENTS.md"));
3537
+ const lines = text === null ? [] : text.split(/\r?\n/);
3538
+ const found = findBlock(lines);
3539
+ if (found.kind === "none") {
3540
+ warnings.push(`${t.label}: no rness block yet (run rness sync)`);
3541
+ continue;
3542
+ }
3543
+ if (found.kind === "error") {
3544
+ problems.push(`${t.label}: ${found.message}`);
3545
+ continue;
3546
+ }
3547
+ const context = await assembleContext({
3548
+ rnessDir,
3549
+ manifest,
3550
+ scope: t.scope
3551
+ });
3552
+ const fresh2 = renderBlock({
3553
+ scope: t.scope,
3554
+ org,
3555
+ depth: t.depth,
3556
+ context
3557
+ });
3558
+ if (!isCurrentBlock(lines.slice(found.begin, found.end + 1), fresh2.hash)) {
3559
+ problems.push(`${t.label}: stale rness block (run rness sync)`);
3560
+ }
3561
+ }
3562
+ return { problems, warnings };
3563
+ }
3564
+
3565
+ // src/core/contract.ts
3566
+ import { join as join18 } from "path";
3567
+ var STATUSES = {
3568
+ adr: ["Proposed", "Accepted", "Rejected", "Superseded"],
3569
+ specs: [
3570
+ "Draft",
3571
+ "Proposed",
3572
+ "Approved",
3573
+ "Implemented",
3574
+ "Superseded",
3575
+ "Rejected"
3576
+ ],
3577
+ plans: ["Draft", "Ready", "In progress", "Blocked", "Completed", "Abandoned"]
3578
+ };
3579
+ var SKIP = /* @__PURE__ */ new Set(["adr/0000-template.md"]);
3580
+ var PLACEMENT_KEYS = ["scopes", "scope"];
3581
+ async function checkContract(rnessDir) {
3582
+ const problems = [];
3583
+ for (const name of COLLECTIONS) {
3584
+ const allowed = STATUSES[name];
3585
+ const items = await collectMarkdown(join18(rnessDir, name));
3586
+ for (const item of items) {
3587
+ const where = `${name}/${item.rel}`;
3588
+ if (SKIP.has(where)) continue;
3589
+ if (item.fieldsError !== null) {
3590
+ problems.push(`${where}: invalid front matter (${item.fieldsError})`);
3591
+ continue;
3592
+ }
3593
+ if (item.fields !== null) {
3594
+ for (const key of PLACEMENT_KEYS) {
3595
+ if (Object.hasOwn(item.fields, key)) {
3596
+ problems.push(
3597
+ `${where}: front matter "${key}" is not supported \u2014 the directory decides the scope; move the file`
3598
+ );
3599
+ }
3600
+ }
3601
+ }
3602
+ if (allowed === void 0) continue;
3603
+ if (item.fields === null) {
3604
+ problems.push(`${where}: missing a front-matter block`);
3605
+ continue;
3606
+ }
3607
+ if (item.title === null) problems.push(`${where}: missing a "# " title`);
3608
+ const status2 = item.fields["status"];
3609
+ if (status2 === void 0 || status2 === null || status2 === "") {
3610
+ problems.push(`${where}: missing "status"`);
3611
+ } else if (typeof status2 !== "string" || !allowed.includes(status2)) {
3612
+ problems.push(
3613
+ `${where}: unknown status ${JSON.stringify(status2)} (expected ${allowed.join(", ")})`
3614
+ );
3615
+ }
3616
+ }
3617
+ }
3618
+ return problems;
3619
+ }
3620
+
3621
+ // src/commands/validate.ts
3622
+ async function validateCommand(opts) {
3623
+ const cwd = opts.cwd ?? process.cwd();
3624
+ try {
3625
+ const ws = await findWorkspace(cwd);
3626
+ const manifest = await loadManifest(ws.rnessDir);
3627
+ for (const s of Object.keys(manifest.scopes)) scopeChain(manifest, s);
3628
+ const { org, warning } = resolveOrg(manifest, ws.root);
3629
+ const warnings = warning === null ? [] : [warning];
3630
+ const problems = await checkContract(ws.rnessDir);
3631
+ const blocks = await checkBlocks({
3632
+ root: ws.root,
3633
+ rnessDir: ws.rnessDir,
3634
+ manifest,
3635
+ org
3636
+ });
3637
+ problems.push(...blocks.problems);
3638
+ warnings.push(...blocks.warnings);
3639
+ for (const p of problems) process.stderr.write(`${p}
3640
+ `);
3641
+ for (const w of warnings) process.stderr.write(`${warn(w)}
3642
+ `);
3643
+ if (problems.length > 0) return 1;
3644
+ process.stdout.write("context ok\n");
3645
+ return 0;
3646
+ } catch (e) {
3647
+ return reportError(e);
3648
+ }
3649
+ }
3650
+
3651
+ // src/cli.ts
3652
+ import { Command, CommanderError, Option } from "commander";
3653
+
3654
+ // src/commands/git-credential.ts
3655
+ async function readStdin() {
3656
+ const chunks = [];
3657
+ for await (const chunk of process.stdin) chunks.push(chunk);
3658
+ return Buffer.concat(chunks).toString("utf8");
3659
+ }
3660
+ async function gitCredentialCommand(operation, input) {
3661
+ if (operation !== "get") return 0;
3662
+ const fields = /* @__PURE__ */ new Map();
3663
+ for (const line of (input ?? await readStdin()).split("\n")) {
3664
+ const eq = line.indexOf("=");
3665
+ if (eq > 0) fields.set(line.slice(0, eq), line.slice(eq + 1).trimEnd());
3666
+ }
3667
+ if (fields.get("protocol") !== "https" || fields.get("host") !== "github.com")
3668
+ return 0;
3669
+ const resolved = await resolveToken().catch(() => null);
3670
+ if (resolved === null) return 0;
3671
+ process.stdout.write(`username=x-access-token
3672
+ password=${resolved.token}
3673
+ `);
3674
+ return 0;
3675
+ }
3676
+
3677
+ // src/cli.ts
3678
+ function buildProgram(state) {
3679
+ const program = new Command().name("rness").description("Configuration-plane CLI for rness workspaces").version(VERSION, "-v, --version", "print the version").exitOverride().showHelpAfterError().configureOutput({
3680
+ writeOut: (s) => {
3681
+ process.stdout.write(s);
3682
+ },
3683
+ writeErr: (s) => {
3684
+ process.stderr.write(s);
3685
+ },
3686
+ outputError: (s, write) => {
3687
+ write(paint("error", s, process.stderr));
3688
+ }
3689
+ }).configureHelp({
3690
+ styleTitle: (s) => bold(s),
3691
+ styleUsage: (s) => paint("done", s),
3692
+ styleCommandText: (s) => paint("info", s),
3693
+ styleSubcommandText: (s) => paint("info", s),
3694
+ styleArgumentText: (s) => paint("info", s),
3695
+ styleOptionText: (s) => paint("warn", s),
3696
+ styleDescriptionText: (s) => paint("idle", s)
3697
+ }).addHelpText("before", () => banner(VERSION));
3698
+ program.command("context").description("Resolve and print the context for a scope").option(
3699
+ "--scope <name>",
3700
+ "scope to resolve (default: the scope owning the current directory)"
3701
+ ).option("--json", "print JSON instead of Markdown").addOption(new Option("--cwd <dir>").hideHelp()).action(async (opts) => {
3702
+ state.code = await contextCommand(opts);
3703
+ });
3704
+ program.command("validate").description(
3705
+ "Check the .rness/ tree against the contract and every generated block"
3706
+ ).addOption(new Option("--cwd <dir>").hideHelp()).action(async (opts) => {
3707
+ state.code = await validateCommand(opts);
3708
+ });
3709
+ program.command("sync").description(
3710
+ "Write the rness block into every AGENTS.md of the cloned repositories"
3711
+ ).option("--scope <name>", "only this scope (the root block is skipped)").option(
3712
+ "--check",
3713
+ "render and compare only; exit 1 when a block is out of date"
3714
+ ).option("--pull", "git pull --ff-only in every clean clone").option("--all", "clone every rness.json repository missing from org/").option("-y, --yes", "do not ask for confirmation").addOption(new Option("--cwd <dir>").hideHelp()).action(async (opts) => {
3715
+ state.code = await syncCommand(opts);
3716
+ });
3717
+ program.command("add").description(
3718
+ "Clone (or adopt) a repository under org/ and declare it in rness.json"
3719
+ ).argument("<repo>", "<repo>, <owner>/<repo>, or a clone URL").option(
3720
+ "--scopes <list>",
3721
+ "comma-separated sub-directories to declare as scopes extending the repository"
3722
+ ).option(
3723
+ "--ssh",
3724
+ "force git@github.com: (default: SSH when it works, else HTTPS)"
3725
+ ).option("--https", "force https://github.com/").option("-y, --yes", "do not ask for confirmation").addOption(new Option("--host <base>").hideHelp()).addOption(new Option("--cwd <dir>").hideHelp()).action(async (repo, opts) => {
3726
+ state.code = await addCommand(repo, opts);
3727
+ });
3728
+ program.command("login").description("Log in to GitHub, to list and clone private repositories").option("--setup-git", "make rness git's credential helper for github.com").option("--no-setup-git", "do not ask about git").addOption(new Option("--github-api <base>").hideHelp()).action(async (opts) => {
3729
+ state.code = await loginCommand(opts);
3730
+ });
3731
+ program.command("logout").description("Forget the GitHub login of this machine").action(async () => {
3732
+ state.code = await logoutCommand();
3733
+ });
3734
+ program.command("git-credential", { hidden: true }).argument("<operation>").action(async (operation) => {
3735
+ state.code = await gitCredentialCommand(operation);
3736
+ });
3737
+ program.command("upgrade").alias("update").description(
3738
+ "Move this workspace to another @rness/cli: pin it in .rness/package.json, install, sync"
3739
+ ).argument("[version]", "an exact version (default: the latest release)").option("-y, --yes", "do not ask for confirmation").addOption(new Option("--cwd <dir>").hideHelp()).action(async (version, opts) => {
3740
+ state.code = await upgradeCommand(version, opts);
3741
+ });
3742
+ program.command("create").description(
3743
+ "Create a workspace for a GitHub organization, or join its existing .rness"
3744
+ ).argument("[org]", "the GitHub organization, same as --org").option(
3745
+ "--org <name>",
3746
+ "the GitHub organization, exact name (github.com/<name>)"
3747
+ ).option(
3748
+ "--repos <list>",
3749
+ "comma-separated repositories for your workspace (catalogue entries are cloned, others added)"
3750
+ ).option(
3751
+ "--pm <name>",
3752
+ `package manager for .rness/ (${PACKAGE_MANAGERS.join(", ")}; default: the one running this command)`
3753
+ ).option(
3754
+ "--ssh",
3755
+ "force git@github.com: (default: SSH when it works, else HTTPS)"
3756
+ ).option("--https", "force https://github.com/").option("--skip-install", "do not install .rness/ dependencies").option("-y, --yes", "do not ask for confirmation").option("--template <name>", "reserved").addOption(new Option("--host <base>").hideHelp()).addOption(new Option("--github-api <base>").hideHelp()).addOption(new Option("--cwd <dir>").hideHelp()).action(async (orgArg, opts) => {
3757
+ if (orgArg !== void 0 && opts.org !== void 0 && orgArg !== opts.org) {
3758
+ process.stderr.write(
3759
+ `organization given twice: "${orgArg}" and --org "${opts.org}"
3760
+ `
3761
+ );
3762
+ state.code = 2;
3763
+ return;
3764
+ }
3765
+ const org = opts.org ?? orgArg;
3766
+ state.code = await createCommand(
3767
+ org === void 0 ? opts : { ...opts, org }
3768
+ );
3769
+ });
3770
+ return program;
3771
+ }
3772
+ async function run(argv) {
3773
+ const state = { code: 0 };
3774
+ const program = buildProgram(state);
3775
+ if (argv.length === 0) {
3776
+ process.stdout.write(banner(VERSION) + program.helpInformation());
3777
+ return 0;
3778
+ }
3779
+ try {
3780
+ await program.parseAsync(argv, { from: "user" });
3781
+ } catch (e) {
3782
+ if (e instanceof CommanderError) {
3783
+ return e.exitCode === 0 ? 0 : 2;
3784
+ }
3785
+ throw e;
3786
+ }
3787
+ return state.code;
3788
+ }
3789
+
3790
+ export {
3791
+ exists,
3792
+ readOrNull,
3793
+ writeFileAtomic,
3794
+ GitHubOAuthProvider,
3795
+ githubProvider,
3796
+ DEFAULT_HOST,
3797
+ SSH_HOST,
3798
+ repoUrl,
3799
+ classifyProbe,
3800
+ probeRemote,
3801
+ NAME,
3802
+ NAME_RULE,
3803
+ ORG_NAME,
3804
+ loadManifest,
3805
+ writeManifest,
3806
+ parseRepoSpec,
3807
+ addRepository,
3808
+ workspaceDirs,
3809
+ defaultTerminal,
3810
+ GITHUB_HOSTS,
3811
+ detectGithubSsh,
3812
+ defaultTransport,
3813
+ findWorkspace,
3814
+ reportError,
3815
+ BEGIN,
3816
+ END,
3817
+ BLOCK_SIZE_WARNING,
3818
+ blockHash,
3819
+ renderBlock,
3820
+ parseHeader,
3821
+ bodyOf,
3822
+ parseFrontMatter,
3823
+ extractTitle,
3824
+ collectMarkdown,
3825
+ resolveScope,
3826
+ scopeChain,
3827
+ COLLECTIONS,
3828
+ assembleContext,
3829
+ findBlock,
3830
+ mergeBlock,
3831
+ ensureClaudeMd,
3832
+ syncCommand,
3833
+ addCommand,
3834
+ renderMarkdown,
3835
+ contextCommand,
3836
+ findDelegate,
3837
+ PACKAGE_MANAGERS,
3838
+ isPackageManager,
3839
+ detectPackageManager,
3840
+ packageManagerVersion,
3841
+ installDependencies,
3842
+ driftWarning,
3843
+ SCAFFOLD_FILES,
3844
+ scaffoldDir,
3845
+ copyScaffold,
3846
+ VERSION,
3847
+ loginCommand,
3848
+ createCommand,
3849
+ logoutCommand,
3850
+ upgradeCommand,
3851
+ checkBlocks,
3852
+ STATUSES,
3853
+ checkContract,
3854
+ validateCommand,
3855
+ run
3856
+ };
3857
+ //# sourceMappingURL=chunk-WSGPDTPQ.js.map