@coldic3/slipway 1.0.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,1928 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __slipwayRequire } from 'node:module'; const require = __slipwayRequire(import.meta.url);
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __esm = (fn, res, err) => function __init() {
6
+ if (err) throw err[0];
7
+ try {
8
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
9
+ } catch (e) {
10
+ throw err = [e], e;
11
+ }
12
+ };
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+
18
+ // src/env.ts
19
+ function slipwayEnv(name, env = process.env) {
20
+ return env[`SLIPWAY_${name}`] || void 0;
21
+ }
22
+ var init_env = __esm({
23
+ "src/env.ts"() {
24
+ "use strict";
25
+ }
26
+ });
27
+
28
+ // src/util/exec.ts
29
+ import { spawn } from "node:child_process";
30
+ function run(cmd, args, opts = {}) {
31
+ return new Promise((resolve2) => {
32
+ const child = spawn(cmd, args, {
33
+ cwd: opts.cwd,
34
+ env: opts.env ?? process.env,
35
+ stdio: ["pipe", "pipe", "pipe"]
36
+ });
37
+ let stdout = "";
38
+ let stderr = "";
39
+ let timer;
40
+ if (opts.timeoutMs) {
41
+ timer = setTimeout(() => {
42
+ stderr += `
43
+ [slipway] timed out after ${opts.timeoutMs} ms`;
44
+ child.kill("SIGKILL");
45
+ }, opts.timeoutMs);
46
+ }
47
+ child.stdout.on("data", (d) => stdout += d);
48
+ child.stderr.on("data", (d) => stderr += d);
49
+ child.on("error", (err) => {
50
+ if (timer) clearTimeout(timer);
51
+ resolve2({ code: err.code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + String(err.message) });
52
+ });
53
+ child.on("close", (code) => {
54
+ if (timer) clearTimeout(timer);
55
+ resolve2({ code: code ?? 1, stdout, stderr });
56
+ });
57
+ child.stdin.on("error", () => {
58
+ });
59
+ child.stdin.end(opts.input ?? "");
60
+ });
61
+ }
62
+ async function runOk(cmd, args, opts = {}) {
63
+ const r = await run(cmd, args, opts);
64
+ if (r.code !== 0) throw new ExecError(`${cmd} ${args.join(" ")}`, r);
65
+ return r;
66
+ }
67
+ async function git(cwd, args, env) {
68
+ const r = await runOk("git", args, { cwd, env: env ?? process.env });
69
+ return r.stdout.trim();
70
+ }
71
+ var ExecError;
72
+ var init_exec = __esm({
73
+ "src/util/exec.ts"() {
74
+ "use strict";
75
+ ExecError = class extends Error {
76
+ constructor(cmd, result) {
77
+ super(`Command failed (exit ${result.code}): ${cmd}
78
+ ${result.stderr || result.stdout}`.trim());
79
+ this.cmd = cmd;
80
+ this.result = result;
81
+ }
82
+ cmd;
83
+ result;
84
+ };
85
+ }
86
+ });
87
+
88
+ // src/config.ts
89
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
90
+ import { homedir } from "node:os";
91
+ import { join } from "node:path";
92
+ function slipwayHome() {
93
+ return slipwayEnv("HOME") || join(homedir(), ".slipway");
94
+ }
95
+ function configPath() {
96
+ return join(slipwayHome(), "config.json");
97
+ }
98
+ function loadConfig() {
99
+ const p = configPath();
100
+ if (!existsSync(p)) {
101
+ throw new Error(`Slipway is not configured (${p} missing). Run "slipway init" first.`);
102
+ }
103
+ return JSON.parse(readFileSync(p, "utf8"));
104
+ }
105
+ function saveConfig(config) {
106
+ mkdirSync(slipwayHome(), { recursive: true, mode: 448 });
107
+ const p = configPath();
108
+ writeFileSync(p, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
109
+ chmodSync(p, 384);
110
+ }
111
+ var init_config = __esm({
112
+ "src/config.ts"() {
113
+ "use strict";
114
+ init_env();
115
+ }
116
+ });
117
+
118
+ // src/util/git-auth.ts
119
+ function basicAuthArgs(user, token) {
120
+ const b64 = Buffer.from(`${user}:${token}`).toString("base64");
121
+ return ["-c", `http.extraHeader=Authorization: Basic ${b64}`];
122
+ }
123
+ function bearerAuthArgs(urlPrefix, token) {
124
+ const b64 = Buffer.from(`x-access-token:${token}`).toString("base64");
125
+ return ["-c", `http.${urlPrefix}.extraHeader=Authorization: Basic ${b64}`];
126
+ }
127
+ function parseGitHubRemote(url) {
128
+ const m = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url.trim());
129
+ return m ? `${m[1]}/${m[2]}` : null;
130
+ }
131
+ var init_git_auth = __esm({
132
+ "src/util/git-auth.ts"() {
133
+ "use strict";
134
+ }
135
+ });
136
+
137
+ // src/gitea/client.ts
138
+ var GiteaError, GiteaClient;
139
+ var init_client = __esm({
140
+ "src/gitea/client.ts"() {
141
+ "use strict";
142
+ GiteaError = class extends Error {
143
+ constructor(status2, method, path, body) {
144
+ super(`Gitea API ${method} ${path} failed: ${status2} ${body.slice(0, 500)}`);
145
+ this.status = status2;
146
+ this.method = method;
147
+ this.path = path;
148
+ this.body = body;
149
+ }
150
+ status;
151
+ method;
152
+ path;
153
+ body;
154
+ };
155
+ GiteaClient = class {
156
+ constructor(baseUrl, auth, fetchImpl = fetch) {
157
+ this.baseUrl = baseUrl;
158
+ this.auth = auth;
159
+ this.fetchImpl = fetchImpl;
160
+ }
161
+ baseUrl;
162
+ auth;
163
+ fetchImpl;
164
+ async req(method, path, body, accept = "application/json") {
165
+ const headers = { accept, "content-type": "application/json" };
166
+ if (typeof this.auth === "string") headers.authorization = `token ${this.auth}`;
167
+ else headers.authorization = "Basic " + Buffer.from(this.auth.basic.join(":")).toString("base64");
168
+ const res = await this.fetchImpl(this.baseUrl.replace(/\/$/, "") + "/api/v1" + path, {
169
+ method,
170
+ headers,
171
+ body: body === void 0 ? void 0 : JSON.stringify(body)
172
+ });
173
+ const text = await res.text();
174
+ if (!res.ok) throw new GiteaError(res.status, method, path, text);
175
+ if (accept !== "application/json") return text;
176
+ return text ? JSON.parse(text) : void 0;
177
+ }
178
+ /** Fetches all pages of a list endpoint. */
179
+ async all(path) {
180
+ const out = [];
181
+ const sep = path.includes("?") ? "&" : "?";
182
+ for (let page = 1; page < 100; page++) {
183
+ const items = await this.req("GET", `${path}${sep}limit=50&page=${page}`);
184
+ out.push(...items);
185
+ if (items.length < 50) break;
186
+ }
187
+ return out;
188
+ }
189
+ version() {
190
+ return this.req("GET", "/version");
191
+ }
192
+ // ---- users & tokens -------------------------------------------------------
193
+ async createToken(username, name, scopes) {
194
+ try {
195
+ await this.req("DELETE", `/users/${username}/tokens/${encodeURIComponent(name)}`);
196
+ } catch (e) {
197
+ if (!(e instanceof GiteaError && e.status === 404)) throw e;
198
+ }
199
+ const t = await this.req("POST", `/users/${username}/tokens`, { name, scopes });
200
+ return t.sha1;
201
+ }
202
+ // ---- repos ------------------------------------------------------------------
203
+ createRepo(name, description = "") {
204
+ return this.req("POST", "/user/repos", { name, description, private: true, auto_init: false, default_branch: "main" });
205
+ }
206
+ getRepo(owner, repo) {
207
+ return this.req("GET", `/repos/${owner}/${repo}`);
208
+ }
209
+ editRepo(owner, repo, opts) {
210
+ return this.req("PATCH", `/repos/${owner}/${repo}`, opts);
211
+ }
212
+ addCollaborator(owner, repo, user, permission) {
213
+ return this.req("PUT", `/repos/${owner}/${repo}/collaborators/${user}`, { permission });
214
+ }
215
+ listBranchProtections(owner, repo) {
216
+ return this.req("GET", `/repos/${owner}/${repo}/branch_protections`);
217
+ }
218
+ createBranchProtection(owner, repo, opts) {
219
+ return this.req("POST", `/repos/${owner}/${repo}/branch_protections`, opts);
220
+ }
221
+ editBranchProtection(owner, repo, name, opts) {
222
+ return this.req("PATCH", `/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(name)}`, opts);
223
+ }
224
+ // ---- pulls --------------------------------------------------------------------
225
+ getPull(owner, repo, idx) {
226
+ return this.req("GET", `/repos/${owner}/${repo}/pulls/${idx}`);
227
+ }
228
+ listPulls(owner, repo, state = "open") {
229
+ return this.all(`/repos/${owner}/${repo}/pulls?state=${state}`);
230
+ }
231
+ createPull(owner, repo, opts) {
232
+ return this.req("POST", `/repos/${owner}/${repo}/pulls`, opts);
233
+ }
234
+ editPull(owner, repo, idx, opts) {
235
+ return this.req("PATCH", `/repos/${owner}/${repo}/pulls/${idx}`, opts);
236
+ }
237
+ closePull(owner, repo, idx) {
238
+ return this.editPull(owner, repo, idx, { state: "closed" });
239
+ }
240
+ mergePull(owner, repo, idx, style = "merge") {
241
+ return this.req("POST", `/repos/${owner}/${repo}/pulls/${idx}/merge`, { Do: style });
242
+ }
243
+ pullDiff(owner, repo, idx) {
244
+ return this.req("GET", `/repos/${owner}/${repo}/pulls/${idx}.diff`, void 0, "text/plain");
245
+ }
246
+ listReviews(owner, repo, idx) {
247
+ return this.all(`/repos/${owner}/${repo}/pulls/${idx}/reviews`);
248
+ }
249
+ listReviewComments(owner, repo, idx, reviewId) {
250
+ return this.req("GET", `/repos/${owner}/${repo}/pulls/${idx}/reviews/${reviewId}/comments`);
251
+ }
252
+ createReview(owner, repo, idx, opts) {
253
+ return this.req("POST", `/repos/${owner}/${repo}/pulls/${idx}/reviews`, opts);
254
+ }
255
+ replyToComment(owner, repo, idx, commentId, body) {
256
+ return this.req("POST", `/repos/${owner}/${repo}/pulls/${idx}/comments/${commentId}/replies`, { body });
257
+ }
258
+ resolveComment(owner, repo, commentId) {
259
+ return this.req("POST", `/repos/${owner}/${repo}/pulls/comments/${commentId}/resolve`);
260
+ }
261
+ /** All review comments (with review metadata), oldest review first. */
262
+ async allReviewComments(owner, repo, idx) {
263
+ const reviews = await this.listReviews(owner, repo, idx);
264
+ const out = [];
265
+ for (const review of reviews) {
266
+ for (const comment of await this.listReviewComments(owner, repo, idx, review.id)) out.push({ review, comment });
267
+ }
268
+ return out;
269
+ }
270
+ /**
271
+ * Feedback the agent still has to address: inline threads with no resolver (a thread = path+line,
272
+ * resolved if any comment in it is resolved), from non-dismissed reviews, not written by the agent.
273
+ * The body of the latest non-dismissed "request changes" review is returned first as a general item (id 0).
274
+ */
275
+ async unresolvedComments(owner, repo, idx, agentLogin) {
276
+ const reviews = await this.listReviews(owner, repo, idx);
277
+ const live = reviews.filter((r) => !r.dismissed);
278
+ const all = [];
279
+ for (const review of reviews) {
280
+ for (const comment of await this.listReviewComments(owner, repo, idx, review.id)) all.push({ review, comment });
281
+ }
282
+ const key = (c) => `${c.path}:${c.position || c.original_position}`;
283
+ const resolved = new Set(all.filter(({ comment }) => comment.resolver).map(({ comment }) => key(comment)));
284
+ const out = [];
285
+ const lastRejected = [...live].reverse().find((r) => r.state === "REQUEST_CHANGES" && r.user?.login !== agentLogin);
286
+ if (lastRejected?.body?.trim()) {
287
+ out.push({ id: 0, path: "", line: 0, body: lastRejected.body.trim(), author: lastRejected.user?.login ?? "", diffHunk: "" });
288
+ }
289
+ for (const { review, comment } of all) {
290
+ if (review.dismissed || comment.resolver || comment.user.login === agentLogin || resolved.has(key(comment))) continue;
291
+ out.push({
292
+ id: comment.id,
293
+ path: comment.path,
294
+ line: comment.position || comment.original_position,
295
+ body: comment.body,
296
+ author: comment.user.login,
297
+ diffHunk: comment.diff_hunk ?? ""
298
+ });
299
+ }
300
+ return out;
301
+ }
302
+ // ---- issues/comments/labels ---------------------------------------------------
303
+ comment(owner, repo, idx, body) {
304
+ return this.req("POST", `/repos/${owner}/${repo}/issues/${idx}/comments`, { body });
305
+ }
306
+ listComments(owner, repo, idx) {
307
+ return this.all(`/repos/${owner}/${repo}/issues/${idx}/comments`);
308
+ }
309
+ /** Admin: create a user (requires the owner/admin token). */
310
+ adminCreateUser(opts) {
311
+ return this.req("POST", "/admin/users", { ...opts, must_change_password: false, send_notify: false });
312
+ }
313
+ adminEditUser(username, opts) {
314
+ return this.req("PATCH", `/admin/users/${username}`, { ...opts, login_name: username, source_id: 0, must_change_password: false });
315
+ }
316
+ createOrg(name) {
317
+ return this.req("POST", "/orgs", { username: name, visibility: "private", repo_admin_change_team_access: false });
318
+ }
319
+ listTeams(org) {
320
+ return this.req("GET", `/orgs/${org}/teams`);
321
+ }
322
+ createTeam(org, name) {
323
+ return this.req("POST", `/orgs/${org}/teams`, {
324
+ name,
325
+ description: "Slipway reviewers: write access to every review repo, no repo admin",
326
+ permission: "write",
327
+ units_map: { "repo.code": "write", "repo.pulls": "write" },
328
+ includes_all_repositories: true,
329
+ can_create_org_repo: false
330
+ });
331
+ }
332
+ addTeamMember(teamId, username) {
333
+ return this.req("PUT", `/teams/${teamId}/members/${username}`);
334
+ }
335
+ createOrgRepo(org, name, description = "") {
336
+ return this.req("POST", `/orgs/${org}/repos`, { name, description, private: true, auto_init: false, default_branch: "main" });
337
+ }
338
+ /** Moves a repo (with its PRs, reviews and comments) to another owner. As site admin the transfer is immediate. */
339
+ transferRepo(owner, repo, newOwner) {
340
+ return this.req("POST", `/repos/${owner}/${repo}/transfer`, { new_owner: newOwner });
341
+ }
342
+ };
343
+ }
344
+ });
345
+
346
+ // src/runtime/downloads.ts
347
+ import { createHash } from "node:crypto";
348
+ import { chmodSync as chmodSync2, createWriteStream, existsSync as existsSync4, mkdirSync as mkdirSync5, mkdtempSync, renameSync, rmSync as rmSync2 } from "node:fs";
349
+ import { join as join6 } from "node:path";
350
+ import { Readable } from "node:stream";
351
+ import { pipeline } from "node:stream/promises";
352
+ import { Transform } from "node:stream";
353
+ function giteaUrl(suffix) {
354
+ return `https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-${suffix}`;
355
+ }
356
+ function gitleaksUrl(suffix) {
357
+ return `https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${suffix}.tar.gz`;
358
+ }
359
+ function platform(os = process.platform, arch = process.arch) {
360
+ const key = `${os}-${arch}`;
361
+ if (key in GITEA) return key;
362
+ throw new Error(`Slipway runs on macOS and Linux (x64, arm64); this machine is ${os}/${arch}`);
363
+ }
364
+ function giteaArtifact(p = platform()) {
365
+ return GITEA[p];
366
+ }
367
+ function gitleaksArtifact(p = platform()) {
368
+ return GITLEAKS[p];
369
+ }
370
+ function binDir() {
371
+ return slipwayEnv("BIN_DIR") || join6(slipwayHome(), "bin");
372
+ }
373
+ async function download(a, dest, fetchImpl = fetch) {
374
+ const res = await fetchImpl(a.url);
375
+ if (!res.ok || !res.body) throw new Error(`download failed: ${a.url} (HTTP ${res.status})`);
376
+ const part = `${dest}.part`;
377
+ const hash = createHash("sha256");
378
+ const tap = new Transform({ transform(chunk, _enc, cb) {
379
+ hash.update(chunk);
380
+ cb(null, chunk);
381
+ } });
382
+ try {
383
+ await pipeline(Readable.fromWeb(res.body), tap, createWriteStream(part, { mode: 493 }));
384
+ const got = hash.digest("hex");
385
+ if (got !== a.sha256) throw new Error(`checksum mismatch for ${a.url}: expected ${a.sha256}, got ${got}`);
386
+ renameSync(part, dest);
387
+ } finally {
388
+ rmSync2(part, { force: true });
389
+ }
390
+ }
391
+ async function ensureGitea(log, fetchImpl = fetch) {
392
+ const dest = giteaBinPath();
393
+ if (existsSync4(dest)) return dest;
394
+ mkdirSync5(binDir(), { recursive: true });
395
+ log(`downloading Gitea ${GITEA_VERSION} (about 110 MB, once)\u2026`);
396
+ await download(giteaArtifact(), dest, fetchImpl);
397
+ chmodSync2(dest, 493);
398
+ return dest;
399
+ }
400
+ async function ensureGitleaks(log, fetchImpl = fetch) {
401
+ if ((await run("gitleaks", ["version"])).code === 0) return "gitleaks";
402
+ const dest = gitleaksBinPath();
403
+ if (existsSync4(dest)) return dest;
404
+ mkdirSync5(binDir(), { recursive: true });
405
+ log(`downloading gitleaks ${GITLEAKS_VERSION} (secret scanner, once)\u2026`);
406
+ const tmp = mkdtempSync(join6(binDir(), ".gitleaks-"));
407
+ try {
408
+ const tgz = join6(tmp, "gitleaks.tar.gz");
409
+ await download(gitleaksArtifact(), tgz, fetchImpl);
410
+ await runOk("tar", ["-xzf", tgz, "-C", tmp, "gitleaks"]);
411
+ chmodSync2(join6(tmp, "gitleaks"), 493);
412
+ renameSync(join6(tmp, "gitleaks"), dest);
413
+ } finally {
414
+ rmSync2(tmp, { recursive: true, force: true });
415
+ }
416
+ return dest;
417
+ }
418
+ var GITEA_VERSION, GITLEAKS_VERSION, GITEA, GITLEAKS, giteaBinPath, gitleaksBinPath;
419
+ var init_downloads = __esm({
420
+ "src/runtime/downloads.ts"() {
421
+ "use strict";
422
+ init_config();
423
+ init_env();
424
+ init_exec();
425
+ GITEA_VERSION = "28.3.0";
426
+ GITLEAKS_VERSION = "8.30.1";
427
+ GITEA = {
428
+ "darwin-arm64": { url: giteaUrl("darwin-arm64"), sha256: "0aca8f1f1d6a0b31abc2c7e5c40feec18f3e45ab0c7b11deaef259794ce45812" },
429
+ "darwin-x64": { url: giteaUrl("darwin-amd64"), sha256: "4420bb44df8047cd4f3bec9162d559c0245dee7ef3b58bc5540d7a1eefd8b94f" },
430
+ "linux-arm64": { url: giteaUrl("linux-arm64"), sha256: "24d146e8f5c986b86e5a3311e3ec349ded35202cba056952ab5b585511ace82d" },
431
+ "linux-x64": { url: giteaUrl("linux-amd64"), sha256: "5f834e8f04986f3b9206cf14743b5a6e6996ceddc238dac6409866ebda2a0686" }
432
+ };
433
+ GITLEAKS = {
434
+ "darwin-arm64": { url: gitleaksUrl("darwin_arm64"), sha256: "b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5" },
435
+ "darwin-x64": { url: gitleaksUrl("darwin_x64"), sha256: "dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709" },
436
+ "linux-arm64": { url: gitleaksUrl("linux_arm64"), sha256: "e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080" },
437
+ "linux-x64": { url: gitleaksUrl("linux_x64"), sha256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" }
438
+ };
439
+ giteaBinPath = () => join6(binDir(), `gitea-${GITEA_VERSION}`);
440
+ gitleaksBinPath = () => join6(binDir(), `gitleaks-${GITLEAKS_VERSION}`);
441
+ }
442
+ });
443
+
444
+ // src/github/client.ts
445
+ var GitHubClient;
446
+ var init_client2 = __esm({
447
+ "src/github/client.ts"() {
448
+ "use strict";
449
+ GitHubClient = class {
450
+ constructor(apiBase, token) {
451
+ this.apiBase = apiBase;
452
+ this.token = token;
453
+ }
454
+ apiBase;
455
+ token;
456
+ async req(method, path, body) {
457
+ const res = await fetch(this.apiBase.replace(/\/$/, "") + path, {
458
+ method,
459
+ headers: {
460
+ authorization: `Bearer ${this.token}`,
461
+ accept: "application/vnd.github+json",
462
+ "x-github-api-version": "2022-11-28",
463
+ "content-type": "application/json",
464
+ "user-agent": "slipway"
465
+ },
466
+ body: body === void 0 ? void 0 : JSON.stringify(body)
467
+ });
468
+ const text = await res.text();
469
+ if (!res.ok) throw new Error(`GitHub API ${method} ${path} failed: ${res.status} ${text.slice(0, 500)}`);
470
+ return text ? JSON.parse(text) : void 0;
471
+ }
472
+ /** Creates the PR or, if an open PR for the same head exists, updates its title/body/base. */
473
+ async upsertPull(input) {
474
+ const [owner] = input.repo.split("/");
475
+ const q = new URLSearchParams({ head: `${owner}:${input.head}`, state: "open" });
476
+ const existing = await this.req("GET", `/repos/${input.repo}/pulls?${q}`);
477
+ if (existing.length > 0) {
478
+ const pr2 = existing[0];
479
+ await this.req("PATCH", `/repos/${input.repo}/pulls/${pr2.number}`, { title: input.title, body: input.body, base: input.base });
480
+ return { number: pr2.number, html_url: pr2.html_url, updated: true };
481
+ }
482
+ const pr = await this.req("POST", `/repos/${input.repo}/pulls`, {
483
+ head: input.head,
484
+ base: input.base,
485
+ title: input.title,
486
+ body: input.body,
487
+ draft: input.draft,
488
+ maintainer_can_modify: true
489
+ });
490
+ return { number: pr.number, html_url: pr.html_url, updated: false };
491
+ }
492
+ };
493
+ }
494
+ });
495
+
496
+ // src/util/lease-refs.ts
497
+ function leaseRef(kind, branch) {
498
+ return `refs/slipway/${kind}/${branch}`;
499
+ }
500
+ async function readLease(repoDir, ref, env) {
501
+ const res = await run("git", ["rev-parse", "-q", "--verify", ref], { cwd: repoDir, env });
502
+ return res.code === 0 ? res.stdout.trim() : "";
503
+ }
504
+ async function writeLease(repoDir, ref, sha, env) {
505
+ await git(repoDir, ["update-ref", ref, sha], env);
506
+ }
507
+ var init_lease_refs = __esm({
508
+ "src/util/lease-refs.ts"() {
509
+ "use strict";
510
+ init_exec();
511
+ }
512
+ });
513
+
514
+ // src/promote/git.ts
515
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync5 } from "node:fs";
516
+ import { tmpdir } from "node:os";
517
+ import { join as join8 } from "node:path";
518
+ function hardenedGitEnv(env) {
519
+ const forced = [
520
+ ["core.hooksPath", "/dev/null"],
521
+ ["core.fsmonitor", "false"],
522
+ ["commit.gpgsign", "false"]
523
+ ];
524
+ const n = Number(env.GIT_CONFIG_COUNT || 0);
525
+ const out = { ...env, GIT_CONFIG_COUNT: String(n + forced.length) };
526
+ forced.forEach(([k, v], i) => {
527
+ out[`GIT_CONFIG_KEY_${n + i}`] = k;
528
+ out[`GIT_CONFIG_VALUE_${n + i}`] = v;
529
+ });
530
+ return out;
531
+ }
532
+ async function prepareBranch(o) {
533
+ const env = hardenedGitEnv(o.env ?? process.env);
534
+ const promoteRef = `refs/slipway/promote/${o.branch}`;
535
+ const upstream = `refs/remotes/origin/${o.base}`;
536
+ await git(o.repoDir, [...o.originAuthArgs ?? [], "fetch", "--quiet", "origin", `+refs/heads/${o.base}:${upstream}`], env);
537
+ await git(o.repoDir, [...o.reviewAuthArgs, "-c", "credential.helper=", "fetch", "--quiet", o.reviewUrl, `+refs/heads/${o.branch}:${promoteRef}`], env);
538
+ const fetchedHead = await git(o.repoDir, ["rev-parse", promoteRef], env);
539
+ const baseSha = await git(o.repoDir, ["rev-parse", upstream], env);
540
+ const behindBy = Number(await git(o.repoDir, ["rev-list", "--count", `${fetchedHead}..${baseSha}`], env));
541
+ if (behindBy > 0) {
542
+ await run("git", ["update-ref", "-d", promoteRef], { cwd: o.repoDir, env });
543
+ return { ok: false, behindBy };
544
+ }
545
+ const worktree = mkdtempSync2(join8(tmpdir(), "slipway-promote-"));
546
+ rmSync5(worktree, { recursive: true });
547
+ await git(o.repoDir, ["worktree", "add", "--quiet", "--detach", worktree, fetchedHead], env);
548
+ const cleanup = async () => {
549
+ await run("git", ["worktree", "remove", "--force", worktree], { cwd: o.repoDir, env });
550
+ rmSync5(worktree, { recursive: true, force: true });
551
+ await run("git", ["worktree", "prune"], { cwd: o.repoDir, env });
552
+ await run("git", ["update-ref", "-d", promoteRef], { cwd: o.repoDir, env });
553
+ };
554
+ return { ok: true, worktree, head: fetchedHead, baseSha, fetchedHead, cleanup };
555
+ }
556
+ async function pushToOrigin(o) {
557
+ const env = hardenedGitEnv(o.env ?? process.env);
558
+ const recordRef = leaseRef("promoted", o.branch);
559
+ const githubRef = `refs/slipway/github/${o.branch}`;
560
+ const recorded = await readLease(o.repoDir, recordRef, env);
561
+ await run("git", ["update-ref", "-d", githubRef], { cwd: o.repoDir, env });
562
+ await run("git", [...o.authArgs, "fetch", "--quiet", "origin", `+refs/heads/${o.branch}:${githubRef}`], { cwd: o.repoDir, env });
563
+ const seen = await run("git", ["rev-parse", "-q", "--verify", githubRef], { cwd: o.repoDir, env });
564
+ const remote = seen.code === 0 ? seen.stdout.trim() : "";
565
+ let expected = "";
566
+ if (remote && remote !== recorded) {
567
+ const contained = await run("git", ["merge-base", "--is-ancestor", remote, o.reviewedHead], { cwd: o.repoDir, env });
568
+ if (contained.code !== 0) {
569
+ throw new PushRejectedError(
570
+ `The GitHub branch ${o.branch} has commits that are not in the Slipway PR (GitHub is at ${remote.slice(0, 8)}). Bring them into the Slipway branch (e.g. rebase onto origin/${o.branch}), push and get it re-approved, or delete the GitHub branch; then promote again.`
571
+ );
572
+ }
573
+ expected = remote;
574
+ } else if (remote) {
575
+ expected = recorded;
576
+ }
577
+ const push = await run("git", [...o.authArgs, "push", "--quiet", `--force-with-lease=refs/heads/${o.branch}:${expected}`, "origin", `HEAD:refs/heads/${o.branch}`], { cwd: o.worktree, env });
578
+ if (push.code !== 0) {
579
+ if (/stale info|rejected/.test(push.stderr)) {
580
+ throw new PushRejectedError(`The GitHub branch ${o.branch} changed while promoting. Promote again.`);
581
+ }
582
+ throw new Error(`git push to GitHub failed: ${push.stderr.trim().split("\n").slice(-3).join(" ")}`);
583
+ }
584
+ await writeLease(o.repoDir, recordRef, await git(o.worktree, ["rev-parse", "HEAD"], env), env);
585
+ }
586
+ var PushRejectedError;
587
+ var init_git = __esm({
588
+ "src/promote/git.ts"() {
589
+ "use strict";
590
+ init_exec();
591
+ init_lease_refs();
592
+ PushRejectedError = class extends Error {
593
+ };
594
+ }
595
+ });
596
+
597
+ // src/core/approval.ts
598
+ function isApprovedByOwner(reviews, owner, headSha) {
599
+ const verdicts = reviews.filter((r) => r.user?.login === owner && !r.dismissed && (r.state === "APPROVED" || r.state === "REQUEST_CHANGES"));
600
+ const last = verdicts.at(-1);
601
+ return !!last && last.state === "APPROVED" && (!last.commit_id || last.commit_id === headSha);
602
+ }
603
+ var init_approval = __esm({
604
+ "src/core/approval.ts"() {
605
+ "use strict";
606
+ }
607
+ });
608
+
609
+ // src/promote/secrets.ts
610
+ import { existsSync as existsSync8 } from "node:fs";
611
+ async function localGitleaks(runner = defaultRunner2, downloaded = gitleaksBinPath()) {
612
+ if ((await runner("gitleaks", ["version"])).code !== 127) return "gitleaks";
613
+ return existsSync8(downloaded) ? downloaded : void 0;
614
+ }
615
+ async function scanSecrets(repoDir, range, runner = defaultRunner2, downloaded) {
616
+ const common = ["git", "--no-banner", "--redact", "--exit-code", "1", "--report-format", "json", "--report-path", "-", "--log-opts", range];
617
+ const local = await localGitleaks(runner, downloaded);
618
+ let r = local ? await runner(local, [...common, repoDir]) : { code: 127, stdout: "", stderr: "" };
619
+ if (r.code === 127) {
620
+ r = await runner("docker", [
621
+ "run",
622
+ "--rm",
623
+ "-v",
624
+ `${repoDir}:/repo:ro`,
625
+ "-e",
626
+ "GIT_CONFIG_COUNT=1",
627
+ "-e",
628
+ "GIT_CONFIG_KEY_0=safe.directory",
629
+ "-e",
630
+ "GIT_CONFIG_VALUE_0=*",
631
+ GITLEAKS_IMAGE,
632
+ ...common,
633
+ "/repo"
634
+ ]);
635
+ if (r.code === 127 || r.code !== 0 && r.code !== 1 && /Cannot connect|not found|docker daemon/i.test(r.stderr)) {
636
+ return { ok: false, findings: [], error: "gitleaks is not available (run slipway up to download it, install gitleaks, or start Docker); promotion is blocked" };
637
+ }
638
+ }
639
+ if (r.code !== 0 && r.code !== 1) return { ok: false, findings: [], error: `gitleaks failed (exit ${r.code}): ${r.stderr.slice(-500)}` };
640
+ let raw = [];
641
+ try {
642
+ raw = r.stdout.trim() ? JSON.parse(r.stdout) : [];
643
+ } catch {
644
+ return { ok: false, findings: [], error: `could not parse gitleaks output: ${r.stdout.slice(0, 200)}` };
645
+ }
646
+ const findings = raw.map((f) => ({ rule: f.RuleID, file: f.File, line: f.StartLine, commit: f.Commit }));
647
+ if (r.code === 1 && findings.length === 0) return { ok: false, findings, error: "gitleaks reported leaks but no findings were parsed" };
648
+ return { ok: findings.length === 0, findings };
649
+ }
650
+ var GITLEAKS_IMAGE, defaultRunner2;
651
+ var init_secrets = __esm({
652
+ "src/promote/secrets.ts"() {
653
+ "use strict";
654
+ init_downloads();
655
+ init_exec();
656
+ GITLEAKS_IMAGE = "zricethezav/gitleaks:v8.30.1";
657
+ defaultRunner2 = (cmd, args) => run(cmd, args, { timeoutMs: 5 * 6e4 });
658
+ }
659
+ });
660
+
661
+ // src/promote/promote.ts
662
+ async function promote(o, d) {
663
+ const fail = (code, message) => {
664
+ d.log(message);
665
+ return { code, message };
666
+ };
667
+ if (!d.githubToken) return fail(EXIT.ERROR, "No GitHub token: log in with `gh auth login` (or set SLIPWAY_GITHUB_TOKEN)");
668
+ const entry = d.config.repos[o.repo];
669
+ if (!entry) return fail(EXIT.ERROR, `Unknown repo "${o.repo}"`);
670
+ const owner = d.config.org;
671
+ const approver = d.config.reviewer.username;
672
+ const pr = await d.gitea.getPull(owner, o.repo, o.pr);
673
+ if (pr.state !== "open") return fail(EXIT.ERROR, `PR #${o.pr} is ${pr.state}; only open PRs can be promoted`);
674
+ const approved = isApprovedByOwner(await d.gitea.listReviews(owner, o.repo, o.pr), approver, pr.head.sha);
675
+ if (!approved && !o.force) {
676
+ return fail(EXIT.NOT_APPROVED, `PR #${o.pr} is not approved by ${approver} at its current head. Approve it in Gitea.`);
677
+ }
678
+ const base = o.base ?? entry.base;
679
+ const branch = pr.head.ref;
680
+ d.log(`preparing ${branch} (exact promotion onto origin/${base})`);
681
+ const originAuthArgs = await d.originAuth(entry.path);
682
+ const prep = await d.prepare({
683
+ repoDir: entry.path,
684
+ reviewUrl: `${d.config.giteaUrl.replace(/\/$/, "")}/${owner}/${o.repo}.git`,
685
+ reviewAuthArgs: basicAuthArgs(d.actor.username, d.actor.token),
686
+ originAuthArgs,
687
+ branch,
688
+ base
689
+ });
690
+ if (!prep.ok) {
691
+ await d.gitea.comment(
692
+ owner,
693
+ o.repo,
694
+ o.pr,
695
+ `\u26A0\uFE0F Promotion blocked: the branch is ${prep.behindBy} commit(s) behind \`origin/${base}\`. Slipway promotes exactly what was reviewed, so rebase the branch onto \`origin/${base}\` in Slipway (resolving conflicts), push it, and approve it again.`
696
+ );
697
+ return fail(EXIT.CONFLICT, `branch is ${prep.behindBy} commit(s) behind origin/${base}: rebase it in Slipway, push, and approve again`);
698
+ }
699
+ try {
700
+ if (prep.fetchedHead !== pr.head.sha && !o.force) {
701
+ return fail(EXIT.NOT_APPROVED, `PR #${o.pr} changed after its approval check (${pr.head.sha.slice(0, 8)} \u2192 ${prep.fetchedHead.slice(0, 8)}); review the new commits and approve again.`);
702
+ }
703
+ const scan = await d.scan(entry.path, `${prep.baseSha}..${prep.head}`);
704
+ if (!scan.ok) {
705
+ const list = scan.findings.map((f) => `- \`${f.rule}\` in \`${f.file}:${f.line}\` (commit ${f.commit.slice(0, 8)})`).join("\n");
706
+ await d.gitea.comment(owner, o.repo, o.pr, scan.error ? `\u26A0\uFE0F Promotion blocked: the secret scan could not run (${scan.error}).` : `\u26A0\uFE0F Promotion blocked: gitleaks found possible secrets:
707
+
708
+ ${list}
709
+
710
+ Remove them from the history (not just the latest commit) and rotate the credentials.`);
711
+ return fail(EXIT.SECRETS, scan.error ?? `secret scan found ${scan.findings.length} issue(s)`);
712
+ }
713
+ if (o.testCmd) {
714
+ d.log(`running tests: ${o.testCmd}`);
715
+ const t = await d.runTests(o.testCmd, prep.worktree);
716
+ if (t.code !== 0) {
717
+ const tail2 = (t.stdout + "\n" + t.stderr).trim().split("\n").slice(-40).join("\n");
718
+ await d.gitea.comment(owner, o.repo, o.pr, `\u26A0\uFE0F Promotion blocked: \`${o.testCmd}\` failed (exit ${t.code}).
719
+
720
+ \`\`\`
721
+ ${tail2}
722
+ \`\`\``);
723
+ return fail(EXIT.TESTS, `tests failed (exit ${t.code})`);
724
+ }
725
+ }
726
+ const fresh = await d.gitea.getPull(owner, o.repo, o.pr);
727
+ d.log(`pushing ${branch} to origin`);
728
+ try {
729
+ await d.push({ repoDir: entry.path, worktree: prep.worktree, branch, reviewedHead: prep.fetchedHead, authArgs: originAuthArgs });
730
+ } catch (e) {
731
+ if (!(e instanceof PushRejectedError)) throw e;
732
+ await d.gitea.comment(owner, o.repo, o.pr, `\u26A0\uFE0F Promotion blocked: ${e.message}`);
733
+ return fail(EXIT.ERROR, e.message);
734
+ }
735
+ const gh = await d.github.upsertPull({ repo: entry.github, head: branch, base, title: fresh.title, body: fresh.body ?? "", draft: o.draft });
736
+ await d.gitea.comment(owner, o.repo, o.pr, `\u{1F6A2} Promoted to GitHub: ${gh.html_url}`);
737
+ await d.gitea.closePull(owner, o.repo, o.pr);
738
+ d.log(`promoted \u2192 ${gh.html_url}`);
739
+ return { code: EXIT.OK, url: gh.html_url };
740
+ } finally {
741
+ await prep.cleanup();
742
+ }
743
+ }
744
+ async function githubOriginAuth(repoDir, token) {
745
+ const url = (await run("git", ["remote", "get-url", "origin"], { cwd: repoDir })).stdout.trim();
746
+ const m = /^(https:\/\/[^/]+\/)/.exec(url);
747
+ return m && token ? bearerAuthArgs(m[1], token) : [];
748
+ }
749
+ function runShell(cmd, cwd) {
750
+ return run("sh", ["-c", cmd], { cwd, timeoutMs: 30 * 6e4 });
751
+ }
752
+ var EXIT;
753
+ var init_promote = __esm({
754
+ "src/promote/promote.ts"() {
755
+ "use strict";
756
+ init_approval();
757
+ init_exec();
758
+ init_git_auth();
759
+ init_git();
760
+ init_secrets();
761
+ EXIT = { OK: 0, ERROR: 1, CONFLICT: 2, SECRETS: 3, TESTS: 4, NOT_APPROVED: 5 };
762
+ }
763
+ });
764
+
765
+ // src/promote/github-token.ts
766
+ async function githubToken(runner = (c, a) => run(c, a, { timeoutMs: 15e3 })) {
767
+ const env = slipwayEnv("GITHUB_TOKEN");
768
+ if (env) return env;
769
+ const r = await runner("gh", ["auth", "token"]);
770
+ if (r.code === 127) throw new Error("The GitHub CLI (gh) is not installed. Install it and run: gh auth login");
771
+ const token = r.stdout.trim();
772
+ if (r.code !== 0 || !token) throw new Error("The GitHub CLI is not logged in. Run: gh auth login");
773
+ return token;
774
+ }
775
+ var init_github_token = __esm({
776
+ "src/promote/github-token.ts"() {
777
+ "use strict";
778
+ init_env();
779
+ init_exec();
780
+ }
781
+ });
782
+
783
+ // src/promote/cli.ts
784
+ var cli_exports = {};
785
+ __export(cli_exports, {
786
+ defaultPromoteDeps: () => defaultPromoteDeps,
787
+ githubApiBase: () => githubApiBase,
788
+ parsePromoteFlags: () => parsePromoteFlags,
789
+ runPromoteCli: () => runPromoteCli
790
+ });
791
+ import { parseArgs } from "node:util";
792
+ function githubApiBase() {
793
+ return slipwayEnv("GITHUB_API") || "https://api.github.com";
794
+ }
795
+ function defaultPromoteDeps(log, token, actor) {
796
+ const config = loadConfig();
797
+ const who = actor ?? config.admin;
798
+ return {
799
+ config,
800
+ githubToken: token,
801
+ actor: who,
802
+ gitea: new GiteaClient(config.giteaUrl, who.token),
803
+ github: new GitHubClient(githubApiBase(), token),
804
+ prepare: prepareBranch,
805
+ push: pushToOrigin,
806
+ scan: (dir, range) => scanSecrets(dir, range),
807
+ runTests: runShell,
808
+ originAuth: (dir) => githubOriginAuth(dir, token),
809
+ log
810
+ };
811
+ }
812
+ function parsePromoteFlags(args) {
813
+ const { values, positionals } = parseArgs({
814
+ args,
815
+ allowPositionals: true,
816
+ options: {
817
+ draft: { type: "boolean" },
818
+ base: { type: "string" },
819
+ "test-cmd": { type: "string" },
820
+ force: { type: "boolean" }
821
+ }
822
+ });
823
+ return {
824
+ positionals,
825
+ draft: !!values.draft,
826
+ base: values.base,
827
+ testCmd: values["test-cmd"] ?? slipwayEnv("TEST_CMD") ?? void 0,
828
+ force: !!values.force
829
+ };
830
+ }
831
+ async function runPromoteCli(args, log) {
832
+ const { positionals, ...flags } = parsePromoteFlags(args);
833
+ const [repo, prStr] = positionals;
834
+ const pr = Number(prStr);
835
+ if (!repo || !Number.isInteger(pr)) throw new Error("usage: slipway promote <repo> <pr-number> [flags]");
836
+ let token = "";
837
+ try {
838
+ token = await githubToken();
839
+ } catch (e) {
840
+ log(e.message);
841
+ return 1;
842
+ }
843
+ const r = await promote({ repo, pr, ...flags }, defaultPromoteDeps(log, token));
844
+ if (r.code === 5) log("(to promote anyway, add --force)");
845
+ if (r.url) console.log(r.url);
846
+ return r.code;
847
+ }
848
+ var init_cli = __esm({
849
+ "src/promote/cli.ts"() {
850
+ "use strict";
851
+ init_env();
852
+ init_config();
853
+ init_client();
854
+ init_client2();
855
+ init_git();
856
+ init_promote();
857
+ init_github_token();
858
+ init_secrets();
859
+ }
860
+ });
861
+
862
+ // src/cli.ts
863
+ import { realpathSync } from "node:fs";
864
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
865
+ import { parseArgs as parseArgs2 } from "node:util";
866
+
867
+ // src/init.ts
868
+ import { createInterface } from "node:readline/promises";
869
+
870
+ // src/agents/index.ts
871
+ init_env();
872
+ init_exec();
873
+ var defaultRunner = (cmd, args) => run(cmd, args, { timeoutMs: 3e5 });
874
+ var GITHUB_REPO = "coldic3/slipway";
875
+ function marketplaceSource() {
876
+ return slipwayEnv("MARKETPLACE") || GITHUB_REPO;
877
+ }
878
+ var isRemote = (source) => /^[\w.-]+\/[\w.-]+$/.test(source) || /^(https?|git@)/.test(source);
879
+ var already = (r) => /already/i.test(r.stdout + r.stderr);
880
+ var claudeCode = {
881
+ id: "claude-code",
882
+ label: "Claude Code",
883
+ async install(log, runner = defaultRunner) {
884
+ const source = marketplaceSource();
885
+ const manual = [`/plugin marketplace add ${source}`, "/plugin install slipway@slipway"];
886
+ if ((await runner("claude", ["--version"])).code !== 0) return { done: false, manual };
887
+ log(`adding the Slipway plugin marketplace (${source})`);
888
+ const add = await runner("claude", ["plugin", "marketplace", "add", source, ...isRemote(source) ? ["--sparse", ".claude-plugin", "plugin"] : []]);
889
+ if (add.code !== 0) {
890
+ if (!already(add)) return { done: false, manual: [`(claude plugin marketplace add failed: ${(add.stderr || add.stdout).trim()})`, ...manual] };
891
+ await runner("claude", ["plugin", "marketplace", "update", "slipway"]);
892
+ }
893
+ log("installing the plugin slipway@slipway");
894
+ const inst = await runner("claude", ["plugin", "install", "slipway@slipway"]);
895
+ if (inst.code !== 0) {
896
+ if (!already(inst)) return { done: false, manual: [`(claude plugin install failed: ${(inst.stderr || inst.stdout).trim()})`, ...manual.slice(1)] };
897
+ await runner("claude", ["plugin", "update", "slipway@slipway"]);
898
+ }
899
+ return { done: true };
900
+ }
901
+ };
902
+ var AGENTS = [claudeCode];
903
+
904
+ // src/instance.ts
905
+ init_config();
906
+ init_env();
907
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
908
+ import { join as join2 } from "node:path";
909
+ var RUNTIMES = ["native", "docker"];
910
+ var DEFAULT_PORT = 3514;
911
+ function instancePath() {
912
+ return join2(slipwayHome(), "instance.json");
913
+ }
914
+ function loadInstance() {
915
+ const p = instancePath();
916
+ if (!existsSync2(p)) return void 0;
917
+ return JSON.parse(readFileSync2(p, "utf8"));
918
+ }
919
+ function saveInstance(i) {
920
+ mkdirSync2(slipwayHome(), { recursive: true, mode: 448 });
921
+ writeFileSync2(instancePath(), JSON.stringify(i, null, 2) + "\n", { mode: 384 });
922
+ }
923
+ function parsePort(value) {
924
+ const n = typeof value === "number" ? value : Number(String(value).trim());
925
+ if (!Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(`invalid port "${value}": use a number between 1024 and 65535`);
926
+ return n;
927
+ }
928
+ function resolvePort(flag, instance, env = process.env) {
929
+ if (flag !== void 0 && flag !== "") return parsePort(flag);
930
+ const fromEnv = slipwayEnv("PORT", env);
931
+ if (fromEnv) return parsePort(fromEnv);
932
+ return instance?.port ?? DEFAULT_PORT;
933
+ }
934
+ function parseRuntime(value) {
935
+ if (RUNTIMES.includes(value)) return value;
936
+ throw new Error(`unknown runtime "${value}": use native or docker`);
937
+ }
938
+ function giteaUrlFor(port) {
939
+ return `http://127.0.0.1:${port}`;
940
+ }
941
+
942
+ // src/runtime/port.ts
943
+ import { connect, createServer } from "node:net";
944
+ async function portFree(port, host = "127.0.0.1") {
945
+ const answers = await new Promise((resolve2) => {
946
+ const s = connect({ port, host });
947
+ s.once("connect", () => {
948
+ s.destroy();
949
+ resolve2(true);
950
+ });
951
+ s.once("error", () => resolve2(false));
952
+ s.setTimeout(1e3, () => {
953
+ s.destroy();
954
+ resolve2(false);
955
+ });
956
+ });
957
+ if (answers) return false;
958
+ return new Promise((resolve2) => {
959
+ const srv = createServer();
960
+ srv.once("error", () => resolve2(false));
961
+ srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve2(true)));
962
+ });
963
+ }
964
+ async function nextFreePort(from, tries = 100, free = portFree) {
965
+ for (let p = from; p < from + tries && p <= 65535; p++) if (await free(p)) return p;
966
+ return void 0;
967
+ }
968
+ var PortBusyError = class extends Error {
969
+ constructor(port, suggestion) {
970
+ super(
971
+ `port ${port} is already in use by another program, so Slipway cannot start there.
972
+ ` + (suggestion ? `Start it on a free port instead (Slipway remembers it for later runs):
973
+ slipway up --port ${suggestion}` : "Pick a free port with: slipway up --port <port>") + "\nor set SLIPWAY_PORT."
974
+ );
975
+ this.port = port;
976
+ this.suggestion = suggestion;
977
+ }
978
+ port;
979
+ suggestion;
980
+ };
981
+
982
+ // src/init.ts
983
+ init_exec();
984
+ init_git_auth();
985
+ var NONE = "none";
986
+ async function chooseAgent(opts, ask, agents) {
987
+ if (opts.agent) {
988
+ if (opts.agent === NONE) return void 0;
989
+ const a = agents.find((x) => x.id === opts.agent);
990
+ if (!a) throw new Error(`unknown agent "${opts.agent}": use ${[...agents.map((x) => x.id), NONE].join(", ")}`);
991
+ return a;
992
+ }
993
+ if (!ask) return agents[0];
994
+ const menu = [...agents.map((a, i) => ` ${i + 1}) ${a.label}`), ` ${agents.length + 1}) None for now (only the review server)`].join("\n");
995
+ for (; ; ) {
996
+ const answer = await ask(`Which AI agent should open PRs through Slipway?
997
+ ${menu}
998
+ Choose [1]: `);
999
+ const n = answer === "" ? 1 : Number(answer);
1000
+ if (n >= 1 && n <= agents.length) return agents[n - 1];
1001
+ if (n === agents.length + 1) return void 0;
1002
+ const byName = agents.find((a) => a.id === answer.toLowerCase() || a.label.toLowerCase() === answer.toLowerCase());
1003
+ if (byName) return byName;
1004
+ }
1005
+ }
1006
+ async function askPort(e, ask) {
1007
+ for (; ; ) {
1008
+ const q = e.suggestion ? `Port ${e.port} is already used by another program. Use ${e.suggestion} instead? [Y/n, or type a port]: ` : `Port ${e.port} is already used by another program. Which port should Slipway use? `;
1009
+ const a = (await ask(q)).toLowerCase();
1010
+ if (e.suggestion && (a === "" || a === "y" || a === "yes")) return e.suggestion;
1011
+ if (a === "n" || a === "no") throw new Error("Slipway needs a free port; run slipway init --port <port> when you have one");
1012
+ try {
1013
+ return parsePort(a);
1014
+ } catch (err) {
1015
+ if (a) process.stderr.write(`${err.message}
1016
+ `);
1017
+ }
1018
+ }
1019
+ }
1020
+ var yes = (a) => a === "" || /^y(es)?$/i.test(a);
1021
+ async function init(opts, d) {
1022
+ const ask = opts.yes ? void 0 : d.ask;
1023
+ const agents = d.agents ?? AGENTS;
1024
+ const agent = await chooseAgent(opts, ask, agents);
1025
+ let port = opts.port;
1026
+ let instance;
1027
+ for (; ; ) {
1028
+ try {
1029
+ instance = await d.up({ port, runtime: opts.docker ? "docker" : void 0, log: d.log });
1030
+ break;
1031
+ } catch (e) {
1032
+ if (!(e instanceof PortBusyError) || !ask) throw e;
1033
+ port = await askPort(e, ask);
1034
+ }
1035
+ }
1036
+ const config = await d.setup(d.log);
1037
+ let install;
1038
+ if (agent) install = await agent.install(d.log);
1039
+ let repo;
1040
+ const here = await d.repoHere(config);
1041
+ if (here && ask && yes(await ask(`Register ${here} for review in Slipway? [Y/n]: `))) {
1042
+ repo = (await d.addRepo(here, d.log)).name;
1043
+ }
1044
+ if (!await d.ghLoggedIn()) d.log("warning: the GitHub CLI is not logged in; promotion to GitHub needs it: gh auth login");
1045
+ return { config, instance, agent, install, repo };
1046
+ }
1047
+ function initSummary(r, here) {
1048
+ const lines = [
1049
+ "",
1050
+ `Slipway is ready: ${r.config.giteaUrl}`,
1051
+ ` login: ${r.config.reviewer.username} / ${r.config.reviewer.password} (tick "Remember this device")`
1052
+ ];
1053
+ if (r.agent && r.install?.done) lines.push(` ${r.agent.label}: plugin installed (restart running sessions to load it)`);
1054
+ if (r.agent && r.install && !r.install.done) lines.push(` ${r.agent.label}: finish in ${r.agent.label}:`, ...r.install.manual.map((m) => ` ${m}`));
1055
+ if (r.repo) lines.push(` repo registered: ${r.repo}`);
1056
+ else lines.push(` next: slipway repo add ${here ?? "<path-to-your-clone>"}`);
1057
+ if (r.agent) lines.push("", `Then tell ${r.agent.label}: "Implement X and open a Slipway PR".`);
1058
+ return lines.join("\n");
1059
+ }
1060
+ function terminalAsk() {
1061
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
1062
+ return { ask: async (q) => (await rl.question(q)).trim(), close: () => rl.close() };
1063
+ }
1064
+ async function repoHere(config, cwd = process.cwd()) {
1065
+ const top = await run("git", ["rev-parse", "--show-toplevel"], { cwd });
1066
+ if (top.code !== 0) return void 0;
1067
+ const path = top.stdout.trim();
1068
+ if (Object.values(config.repos).some((r) => r.path === path)) return void 0;
1069
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd: path });
1070
+ return origin.code === 0 && parseGitHubRemote(origin.stdout) ? path : void 0;
1071
+ }
1072
+ async function ghLoggedIn() {
1073
+ return (await run("gh", ["auth", "status"])).code === 0;
1074
+ }
1075
+
1076
+ // src/cli.ts
1077
+ init_config();
1078
+
1079
+ // src/runtime/index.ts
1080
+ init_config();
1081
+ init_env();
1082
+ import { existsSync as existsSync6, rmSync as rmSync4 } from "node:fs";
1083
+
1084
+ // src/setup/repo.ts
1085
+ init_config();
1086
+ init_client();
1087
+ init_exec();
1088
+ init_git_auth();
1089
+ import { basename, resolve } from "node:path";
1090
+
1091
+ // src/setup/sync.ts
1092
+ init_config();
1093
+ init_exec();
1094
+ init_git_auth();
1095
+ async function syncBase(o) {
1096
+ const env = o.env ?? process.env;
1097
+ const fetch2 = await run("git", ["fetch", "--quiet", "origin", `+refs/heads/${o.base}:refs/remotes/origin/${o.base}`], { cwd: o.repoDir, env, timeoutMs: 6e4 });
1098
+ if (fetch2.code !== 0) throw new Error(`could not fetch ${o.base} from GitHub (origin): ${fetch2.stderr.trim().split("\n").pop()}`);
1099
+ await git(o.repoDir, [...o.authArgs, "-c", "credential.helper=", "push", "--quiet", "--force", o.giteaRepoUrl, `refs/remotes/origin/${o.base}:refs/heads/${o.base}`], env);
1100
+ }
1101
+ async function syncMain(name) {
1102
+ const config = loadConfig();
1103
+ const repo = config.repos[name];
1104
+ if (!repo) throw new Error(`Unknown repo "${name}". Known: ${Object.keys(config.repos).join(", ") || "(none)"}`);
1105
+ await syncBase({
1106
+ repoDir: repo.path,
1107
+ base: repo.base,
1108
+ giteaRepoUrl: `${config.giteaUrl.replace(/\/$/, "")}/${config.org}/${name}.git`,
1109
+ authArgs: basicAuthArgs(config.admin.username, config.admin.token)
1110
+ });
1111
+ }
1112
+
1113
+ // src/setup/repo.ts
1114
+ function giteaRemoteUrl(config, name) {
1115
+ return `${config.giteaUrl.replace(/\/$/, "")}/${config.org}/${name}.git`;
1116
+ }
1117
+ var NO_MERGE_SETTINGS = {
1118
+ allow_merge_commits: false,
1119
+ allow_rebase: false,
1120
+ allow_rebase_explicit: false,
1121
+ allow_squash_merge: false,
1122
+ allow_fast_forward_only_merge: false,
1123
+ allow_manual_merge: false,
1124
+ autodetect_manual_merge: false,
1125
+ has_wiki: false,
1126
+ has_projects: false,
1127
+ has_releases: false,
1128
+ has_packages: false,
1129
+ has_actions: false
1130
+ };
1131
+ function protectionFor(base, admin) {
1132
+ return {
1133
+ rule_name: base,
1134
+ branch_name: base,
1135
+ enable_push: true,
1136
+ enable_push_whitelist: true,
1137
+ push_whitelist_usernames: [admin],
1138
+ enable_force_push: true,
1139
+ enable_force_push_allowlist: true,
1140
+ force_push_allowlist_usernames: [admin],
1141
+ enable_merge_whitelist: true,
1142
+ merge_whitelist_usernames: [],
1143
+ block_admin_merge_override: true,
1144
+ block_on_rejected_reviews: true
1145
+ };
1146
+ }
1147
+ async function applyRepoAccess(config, name, base) {
1148
+ const admin = new GiteaClient(config.giteaUrl, config.admin.token);
1149
+ await admin.editRepo(config.org, name, { ...NO_MERGE_SETTINGS, default_branch: base });
1150
+ if (config.reviewer.username !== config.admin.username) await admin.addCollaborator(config.org, name, config.reviewer.username, "write");
1151
+ if (config.claude) await admin.addCollaborator(config.org, name, config.claude.username, "write");
1152
+ const protections = await admin.listBranchProtections(config.org, name).catch(() => []);
1153
+ if (protections.some((p) => p.rule_name === base)) await admin.editBranchProtection(config.org, name, base, protectionFor(base, config.admin.username));
1154
+ else await admin.createBranchProtection(config.org, name, protectionFor(base, config.admin.username));
1155
+ }
1156
+ async function setReviewRemote(config, name, clonePath) {
1157
+ const url = giteaRemoteUrl(config, name);
1158
+ const hasReview = (await run("git", ["remote", "get-url", "review"], { cwd: clonePath })).code === 0;
1159
+ await git(clonePath, hasReview ? ["remote", "set-url", "review", url] : ["remote", "add", "review", url]);
1160
+ }
1161
+ async function addRepo(opts) {
1162
+ const log = opts.log ?? (() => {
1163
+ });
1164
+ const config = loadConfig();
1165
+ const path = resolve(opts.path);
1166
+ const top = await git(path, ["rev-parse", "--show-toplevel"]);
1167
+ const name = opts.name ?? basename(top);
1168
+ const base = opts.base ?? "main";
1169
+ let github = opts.github;
1170
+ if (!github) {
1171
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd: top });
1172
+ github = parseGitHubRemote(origin.stdout) ?? void 0;
1173
+ if (!github) throw new Error('Cannot infer the GitHub repo from remote "origin"; pass --github owner/name');
1174
+ }
1175
+ const admin = new GiteaClient(config.giteaUrl, config.admin.token);
1176
+ try {
1177
+ await admin.createOrgRepo(config.org, name, `Slipway review station for ${github}`);
1178
+ log(`created Gitea repo ${config.org}/${name}`);
1179
+ } catch (e) {
1180
+ if (!(e instanceof GiteaError && e.status === 409)) throw e;
1181
+ log(`Gitea repo ${config.org}/${name} already exists`);
1182
+ }
1183
+ await setReviewRemote(config, name, top);
1184
+ config.repos[name] = { path: top, base, github };
1185
+ saveConfig(config);
1186
+ await syncMain(name);
1187
+ await applyRepoAccess(config, name, base);
1188
+ return { name, url: giteaRemoteUrl(config, name) };
1189
+ }
1190
+
1191
+ // src/runtime/custom.ts
1192
+ init_config();
1193
+ import { cpSync, existsSync as existsSync3, mkdirSync as mkdirSync3, rmSync } from "node:fs";
1194
+ import { dirname as dirname2, join as join4 } from "node:path";
1195
+
1196
+ // src/paths.ts
1197
+ import { dirname, join as join3 } from "node:path";
1198
+ import { fileURLToPath } from "node:url";
1199
+ var SLIPWAY_ROOT = join3(dirname(fileURLToPath(import.meta.url)), "..");
1200
+
1201
+ // src/runtime/custom.ts
1202
+ var SHIPPED_CUSTOM = join4(SLIPWAY_ROOT, "gitea");
1203
+ function customDir() {
1204
+ return join4(slipwayHome(), "gitea", "custom");
1205
+ }
1206
+ function syncCustom(dest = customDir(), source = SHIPPED_CUSTOM) {
1207
+ if (!existsSync3(source)) throw new Error(`Slipway's Gitea branding is missing at ${source}: the installation is incomplete`);
1208
+ rmSync(dest, { recursive: true, force: true });
1209
+ mkdirSync3(dirname2(dest), { recursive: true });
1210
+ cpSync(source, dest, { recursive: true });
1211
+ }
1212
+
1213
+ // src/runtime/docker.ts
1214
+ init_config();
1215
+ init_env();
1216
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
1217
+ import { dirname as dirname3, join as join5 } from "node:path";
1218
+ init_exec();
1219
+
1220
+ // src/runtime/settings.ts
1221
+ function giteaSettings(port) {
1222
+ return {
1223
+ "": { APP_NAME: "Slipway", RUN_MODE: "prod" },
1224
+ server: {
1225
+ ROOT_URL: `http://127.0.0.1:${port}/`,
1226
+ LANDING_PAGE: "login",
1227
+ DISABLE_SSH: "true",
1228
+ // Avatars from Gravatar by e-mail (ADR 0001).
1229
+ OFFLINE_MODE: "false"
1230
+ },
1231
+ database: { DB_TYPE: "sqlite3" },
1232
+ security: {
1233
+ INSTALL_LOCK: "true",
1234
+ // Local-only instance: "Remember this device" lasts a year (ADR 0005).
1235
+ LOGIN_REMEMBER_DAYS: "365",
1236
+ // Default login is slipway / slipway (7 chars); Gitea's default minimum is 8 (ADR 0005).
1237
+ MIN_PASSWORD_LENGTH: "6",
1238
+ PASSWORD_CHECK_PWN: "false",
1239
+ ALLOWED_HOST_LIST: "loopback,private"
1240
+ },
1241
+ service: {
1242
+ DISABLE_REGISTRATION: "true",
1243
+ REQUIRE_SIGNIN_VIEW: "true",
1244
+ ENABLE_TIMETRACKING: "false",
1245
+ DEFAULT_ENABLE_TIMETRACKING: "false",
1246
+ ENABLE_PASSKEY_AUTHENTICATION: "false"
1247
+ },
1248
+ ui: { DEFAULT_THEME: "gitea-dark", THEMES: "gitea-dark", SHOW_USER_EMAIL: "false" },
1249
+ "ui.meta": { AUTHOR: "Slipway", DESCRIPTION: "Review AI pull requests, then launch them to GitHub" },
1250
+ i18n: { LANGS: "en-US", NAMES: "English" },
1251
+ picture: { DISABLE_GRAVATAR: "false", GRAVATAR_SOURCE: "gravatar" },
1252
+ other: {
1253
+ SHOW_FOOTER_VERSION: "false",
1254
+ SHOW_FOOTER_TEMPLATE_LOAD_TIME: "false",
1255
+ SHOW_FOOTER_POWERED_BY: "false",
1256
+ ENABLE_FEED: "false"
1257
+ },
1258
+ repository: {
1259
+ DEFAULT_BRANCH: "main",
1260
+ DEFAULT_PRIVATE: "private",
1261
+ DISABLED_REPO_UNITS: "repo.issues,repo.ext_issues,repo.wiki,repo.ext_wiki,repo.projects,repo.releases,repo.packages,repo.actions",
1262
+ DEFAULT_REPO_UNITS: "repo.code,repo.pulls",
1263
+ DISABLE_STARS: "true",
1264
+ DISABLE_MIGRATIONS: "true",
1265
+ DISABLE_FORKS: "true"
1266
+ },
1267
+ packages: { ENABLED: "false" },
1268
+ actions: { ENABLED: "false" },
1269
+ webhook: { DELIVER_TIMEOUT: "15" },
1270
+ mailer: { ENABLED: "false" },
1271
+ log: { LEVEL: "Warn" }
1272
+ };
1273
+ }
1274
+ function mergeSettings(...all) {
1275
+ const out = {};
1276
+ for (const s of all) for (const [sec, kv] of Object.entries(s)) out[sec] = { ...out[sec], ...kv };
1277
+ return out;
1278
+ }
1279
+ function renderIni(s) {
1280
+ const lines = ["; Generated by Slipway on every `slipway up`. Do not edit: change src/runtime/settings.ts instead."];
1281
+ const root = s[""] ?? {};
1282
+ for (const [k, v] of Object.entries(root)) lines.push(`${k} = ${v}`);
1283
+ for (const [sec, kv] of Object.entries(s)) {
1284
+ if (sec === "") continue;
1285
+ lines.push("", `[${sec}]`);
1286
+ for (const [k, v] of Object.entries(kv)) lines.push(`${k} = ${v}`);
1287
+ }
1288
+ return lines.join("\n") + "\n";
1289
+ }
1290
+ function renderEnv(s) {
1291
+ const lines = [];
1292
+ for (const [sec, kv] of Object.entries(s)) for (const [k, v] of Object.entries(kv)) lines.push(`GITEA__${sec}__${k}=${v}`);
1293
+ return lines.join("\n") + "\n";
1294
+ }
1295
+
1296
+ // src/runtime/docker.ts
1297
+ var COMPOSE_FILE = join5(SLIPWAY_ROOT, "docker", "compose.yaml");
1298
+ var DEFAULT_PROJECT = "slipway";
1299
+ function composeProject() {
1300
+ return slipwayEnv("PROJECT") || DEFAULT_PROJECT;
1301
+ }
1302
+ function dockerEnvFile() {
1303
+ return join5(slipwayHome(), "docker", "gitea.env");
1304
+ }
1305
+ function composeEnv(port) {
1306
+ return {
1307
+ ...process.env,
1308
+ SLIPWAY_PROJECT: composeProject(),
1309
+ SLIPWAY_PORT: String(port),
1310
+ SLIPWAY_GITEA_ENV: dockerEnvFile(),
1311
+ SLIPWAY_GITEA_CUSTOM: customDir()
1312
+ };
1313
+ }
1314
+ var composeArgs = (args) => ["compose", "-f", COMPOSE_FILE, ...args];
1315
+ function writeDockerEnv(port) {
1316
+ mkdirSync4(dirname3(dockerEnvFile()), { recursive: true, mode: 448 });
1317
+ writeFileSync3(dockerEnvFile(), renderEnv(giteaSettings(port)), { mode: 384 });
1318
+ }
1319
+ async function dockerUp(port) {
1320
+ if ((await run("docker", ["version"])).code !== 0) {
1321
+ throw new Error("Docker is not running (or not installed). Start Docker, or use the default runtime without --docker.");
1322
+ }
1323
+ writeDockerEnv(port);
1324
+ await runOk("docker", composeArgs(["up", "-d", "--wait"]), { env: composeEnv(port), timeoutMs: 3e5 });
1325
+ }
1326
+ async function dockerDown(port, purge) {
1327
+ writeDockerEnv(port);
1328
+ await runOk("docker", composeArgs(["down", ...purge ? ["-v"] : []]), { env: composeEnv(port) });
1329
+ }
1330
+ async function dockerRunning(port) {
1331
+ const r = await run("docker", composeArgs(["ps", "--status", "running", "-q", "gitea"]), { env: composeEnv(port) });
1332
+ return r.code === 0 && r.stdout.trim() !== "";
1333
+ }
1334
+ function dockerAdmin(port, args) {
1335
+ return run("docker", composeArgs(["exec", "-T", "gitea", "gitea", ...args]), { env: composeEnv(port) });
1336
+ }
1337
+
1338
+ // src/runtime/index.ts
1339
+ init_downloads();
1340
+
1341
+ // src/runtime/health.ts
1342
+ async function giteaHealthy(url) {
1343
+ try {
1344
+ return (await fetch(`${url}/api/healthz`, { signal: AbortSignal.timeout(2e3) })).ok;
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ }
1349
+ async function waitForGitea(url, timeoutMs = 6e4, failed = () => void 0) {
1350
+ const until = Date.now() + timeoutMs;
1351
+ let last = "";
1352
+ while (Date.now() < until) {
1353
+ const why = failed();
1354
+ if (why) throw new Error(why);
1355
+ try {
1356
+ const r = await fetch(`${url}/api/healthz`, { signal: AbortSignal.timeout(2e3) });
1357
+ if (r.ok) return;
1358
+ last = `HTTP ${r.status}`;
1359
+ } catch (e) {
1360
+ last = String(e);
1361
+ }
1362
+ await new Promise((r) => setTimeout(r, 500));
1363
+ }
1364
+ throw new Error(`Gitea at ${url} did not become ready: ${last}`);
1365
+ }
1366
+
1367
+ // src/runtime/native.ts
1368
+ init_config();
1369
+ import { spawn as spawn2 } from "node:child_process";
1370
+ import { closeSync, existsSync as existsSync5, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
1371
+ import { userInfo } from "node:os";
1372
+ import { join as join7 } from "node:path";
1373
+ init_exec();
1374
+ function nativePaths(home = slipwayHome()) {
1375
+ const dir = join7(home, "gitea");
1376
+ return {
1377
+ dir,
1378
+ ini: join7(dir, "app.ini"),
1379
+ secrets: join7(dir, "secrets.json"),
1380
+ pid: join7(dir, "gitea.pid"),
1381
+ data: join7(dir, "data"),
1382
+ log: join7(dir, "log"),
1383
+ custom: customDir()
1384
+ };
1385
+ }
1386
+ var SECRET_NAMES = ["SECRET_KEY", "INTERNAL_TOKEN", "JWT_SECRET", "LFS_JWT_SECRET"];
1387
+ function giteaGlobalArgs(p) {
1388
+ return ["--config", p.ini, "--work-path", p.dir, "--custom-path", p.custom];
1389
+ }
1390
+ function giteaEnv() {
1391
+ const env = {};
1392
+ for (const [k, v] of Object.entries(process.env)) if (!k.startsWith("GITEA")) env[k] = v;
1393
+ return env;
1394
+ }
1395
+ function nativeSettings(port, p, secrets, runUser) {
1396
+ return mergeSettings(giteaSettings(port), {
1397
+ "": { RUN_USER: runUser, WORK_PATH: p.dir },
1398
+ server: {
1399
+ PROTOCOL: "http",
1400
+ // Security invariant: loopback only.
1401
+ HTTP_ADDR: "127.0.0.1",
1402
+ HTTP_PORT: String(port),
1403
+ DOMAIN: "127.0.0.1",
1404
+ APP_DATA_PATH: p.data,
1405
+ START_SSH_SERVER: "false",
1406
+ LFS_START_SERVER: "false",
1407
+ LFS_JWT_SECRET: secrets.LFS_JWT_SECRET
1408
+ },
1409
+ database: { PATH: join7(p.data, "gitea.db") },
1410
+ repository: { ROOT: join7(p.data, "repositories") },
1411
+ security: { SECRET_KEY: secrets.SECRET_KEY, INTERNAL_TOKEN: secrets.INTERNAL_TOKEN },
1412
+ oauth2: { JWT_SECRET: secrets.JWT_SECRET },
1413
+ log: { MODE: "file", ROOT_PATH: p.log }
1414
+ });
1415
+ }
1416
+ async function ensureSecrets(bin, p) {
1417
+ if (existsSync5(p.secrets)) return JSON.parse(readFileSync3(p.secrets, "utf8"));
1418
+ const out = {};
1419
+ for (const name of SECRET_NAMES) out[name] = (await runOk(bin, ["generate", "secret", name], { env: giteaEnv() })).stdout.trim();
1420
+ mkdirSync6(p.dir, { recursive: true, mode: 448 });
1421
+ writeFileSync4(p.secrets, JSON.stringify(out, null, 2) + "\n", { mode: 384 });
1422
+ return out;
1423
+ }
1424
+ async function writeAppIni(bin, port, p = nativePaths()) {
1425
+ const secrets = await ensureSecrets(bin, p);
1426
+ mkdirSync6(p.data, { recursive: true });
1427
+ mkdirSync6(p.log, { recursive: true });
1428
+ writeFileSync4(p.ini, renderIni(nativeSettings(port, p, secrets, userInfo().username)), { mode: 384 });
1429
+ }
1430
+ function readPid(p = nativePaths()) {
1431
+ if (!existsSync5(p.pid)) return void 0;
1432
+ const n = Number(readFileSync3(p.pid, "utf8").trim());
1433
+ return Number.isInteger(n) && n > 0 ? n : void 0;
1434
+ }
1435
+ async function isGiteaProcess(pid) {
1436
+ try {
1437
+ process.kill(pid, 0);
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+ const r = await run("ps", ["-p", String(pid), "-o", "command="]);
1442
+ return r.code === 0 && /gitea/.test(r.stdout);
1443
+ }
1444
+ async function nativeRunning(port, p = nativePaths()) {
1445
+ const pid = readPid(p);
1446
+ return pid !== void 0 && await isGiteaProcess(pid) && await giteaHealthy(giteaUrlFor(port));
1447
+ }
1448
+ async function nativeStart(bin, port, p = nativePaths()) {
1449
+ const out = openSync(join7(p.log, "stdout.log"), "a");
1450
+ let exited;
1451
+ try {
1452
+ const child = spawn2(bin, [...giteaGlobalArgs(p), "web"], { cwd: p.dir, detached: true, stdio: ["ignore", out, out], env: giteaEnv() });
1453
+ child.once("exit", (code) => {
1454
+ exited = `Gitea exited right after start (code ${code}). Its log: ${join7(p.log, "stdout.log")}
1455
+ ${tail(join7(p.log, "stdout.log"))}`;
1456
+ });
1457
+ child.once("error", (e) => {
1458
+ exited = `cannot start Gitea (${bin}): ${e.message}`;
1459
+ });
1460
+ if (child.pid) writeFileSync4(p.pid, `${child.pid}
1461
+ `);
1462
+ child.unref();
1463
+ } finally {
1464
+ closeSync(out);
1465
+ }
1466
+ await waitForGitea(giteaUrlFor(port), 6e4, () => exited);
1467
+ }
1468
+ function tail(file, lines = 15) {
1469
+ try {
1470
+ return readFileSync3(file, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
1471
+ } catch {
1472
+ return "";
1473
+ }
1474
+ }
1475
+ async function nativeStop(p = nativePaths()) {
1476
+ const pid = readPid(p);
1477
+ if (pid === void 0 || !await isGiteaProcess(pid)) {
1478
+ rmSync3(p.pid, { force: true });
1479
+ return false;
1480
+ }
1481
+ process.kill(pid, "SIGTERM");
1482
+ const until = Date.now() + 15e3;
1483
+ while (Date.now() < until && await isGiteaProcess(pid)) await new Promise((r) => setTimeout(r, 200));
1484
+ if (await isGiteaProcess(pid)) process.kill(pid, "SIGKILL");
1485
+ rmSync3(p.pid, { force: true });
1486
+ return true;
1487
+ }
1488
+ function nativePurge(p = nativePaths()) {
1489
+ rmSync3(p.dir, { recursive: true, force: true });
1490
+ }
1491
+ function nativeAdmin(bin, args, p = nativePaths()) {
1492
+ return run(bin, [...giteaGlobalArgs(p), ...args], { cwd: p.dir, env: giteaEnv() });
1493
+ }
1494
+
1495
+ // src/runtime/index.ts
1496
+ function resolveRuntime(flag, instance) {
1497
+ if (flag) return flag;
1498
+ const env = slipwayEnv("RUNTIME");
1499
+ if (env) return parseRuntime(env);
1500
+ return instance?.runtime ?? "native";
1501
+ }
1502
+ async function isRunning(i) {
1503
+ return i.runtime === "native" ? nativeRunning(i.port) : dockerRunning(i.port);
1504
+ }
1505
+ async function up(opts = {}) {
1506
+ const log = opts.log ?? (() => {
1507
+ });
1508
+ const prev = loadInstance();
1509
+ const runtime = resolveRuntime(opts.runtime, prev);
1510
+ if (prev && prev.runtime !== runtime) {
1511
+ throw new Error(
1512
+ `Slipway already runs with the ${prev.runtime} runtime here; switching to ${runtime} would start with empty data.
1513
+ To start over on ${runtime}: slipway down --purge, then slipway up${runtime === "docker" ? " --docker" : ""}.`
1514
+ );
1515
+ }
1516
+ const port = resolvePort(opts.port, prev);
1517
+ if (prev && await isRunning(prev)) {
1518
+ if (prev.port === port && (runtime === "docker" || prev.giteaBin?.endsWith(`gitea-${GITEA_VERSION}`))) {
1519
+ log(`already running at ${giteaUrlFor(port)}`);
1520
+ if (runtime === "native") await ensureGitleaks(log);
1521
+ return prev;
1522
+ }
1523
+ log("restarting Gitea (new port or Gitea version)");
1524
+ await stop(prev);
1525
+ }
1526
+ if (!await portFree(port)) throw new PortBusyError(port, await nextFreePort(port + 1));
1527
+ syncCustom();
1528
+ let instance;
1529
+ if (runtime === "native") {
1530
+ const bin = await ensureGitea(log);
1531
+ await writeAppIni(bin, port);
1532
+ log(`starting Gitea ${GITEA_VERSION} on ${giteaUrlFor(port)}`);
1533
+ await nativeStart(bin, port);
1534
+ instance = { runtime, port, giteaBin: bin };
1535
+ await ensureGitleaks(log);
1536
+ } else {
1537
+ log(`starting Gitea in Docker on ${giteaUrlFor(port)}`);
1538
+ await dockerUp(port);
1539
+ instance = { runtime, port };
1540
+ }
1541
+ saveInstance(instance);
1542
+ await retargetConfig(port, log);
1543
+ return instance;
1544
+ }
1545
+ async function stop(i) {
1546
+ if (i.runtime === "native") await nativeStop();
1547
+ else await dockerDown(i.port, false);
1548
+ }
1549
+ async function down(opts = {}) {
1550
+ const i = loadInstance();
1551
+ if (!i) return;
1552
+ if (i.runtime === "native") {
1553
+ await nativeStop();
1554
+ if (opts.purge) nativePurge();
1555
+ } else {
1556
+ await dockerDown(i.port, !!opts.purge);
1557
+ }
1558
+ if (opts.purge) rmSync4(instancePath(), { force: true });
1559
+ }
1560
+ function giteaAdmin(args) {
1561
+ const i = loadInstance();
1562
+ if (!i) throw new Error("Slipway is not running here yet. Run: slipway up");
1563
+ if (i.runtime === "docker") return dockerAdmin(i.port, args);
1564
+ if (!i.giteaBin) throw new Error("the instance has no Gitea binary recorded. Run: slipway up");
1565
+ return nativeAdmin(i.giteaBin, args);
1566
+ }
1567
+ function defaultGiteaUrl() {
1568
+ return slipwayEnv("GITEA_URL") || giteaUrlFor(resolvePort(void 0, loadInstance()));
1569
+ }
1570
+ async function retargetConfig(port, log) {
1571
+ if (!existsSync6(configPath()) || slipwayEnv("GITEA_URL")) return;
1572
+ const config = loadConfig();
1573
+ const url = giteaUrlFor(port);
1574
+ if (config.giteaUrl.replace(/\/$/, "") === url) return;
1575
+ config.giteaUrl = url;
1576
+ saveConfig(config);
1577
+ for (const [name, entry] of Object.entries(config.repos)) {
1578
+ if (existsSync6(entry.path)) await setReviewRemote(config, name, entry.path).catch(() => {
1579
+ });
1580
+ }
1581
+ log(`Slipway moved to ${url} (config and review remotes updated)`);
1582
+ }
1583
+ async function status() {
1584
+ const instance = loadInstance();
1585
+ if (!instance) return { running: false };
1586
+ return { instance, running: await isRunning(instance), url: giteaUrlFor(instance.port) };
1587
+ }
1588
+
1589
+ // src/setup/bootstrap.ts
1590
+ init_config();
1591
+ init_client();
1592
+ import { randomBytes } from "node:crypto";
1593
+ import { existsSync as existsSync7 } from "node:fs";
1594
+ var DEFAULT_OWNER = "slipway";
1595
+ var DEFAULT_PASSWORD = "slipway";
1596
+ var DEFAULT_ADMIN = "admin";
1597
+ var DEFAULT_ORG = "repos";
1598
+ var REVIEWERS_TEAM = "reviewers";
1599
+ var OWNER_TOKEN_SCOPES = ["all"];
1600
+ async function ensureUser(username, email, password, admin) {
1601
+ const create = await giteaAdmin([
1602
+ "admin",
1603
+ "user",
1604
+ "create",
1605
+ "--username",
1606
+ username,
1607
+ "--password",
1608
+ password,
1609
+ "--email",
1610
+ email,
1611
+ "--must-change-password=false",
1612
+ ...admin ? ["--admin"] : []
1613
+ ]);
1614
+ if (create.code !== 0 && !/already exists/.test(create.stdout + create.stderr)) {
1615
+ throw new Error(`Creating Gitea user ${username} failed: ${create.stderr || create.stdout}`);
1616
+ }
1617
+ const pw = await giteaAdmin(["admin", "user", "change-password", "--username", username, "--password", password, "--must-change-password=false"]);
1618
+ if (pw.code !== 0) throw new Error(`Setting password for ${username} failed: ${pw.stderr || pw.stdout}`);
1619
+ }
1620
+ async function tokenWorks(giteaUrl2, token, username) {
1621
+ try {
1622
+ const me = await new GiteaClient(giteaUrl2, token).req("GET", "/user");
1623
+ return me.login === username;
1624
+ } catch {
1625
+ return false;
1626
+ }
1627
+ }
1628
+ function resolveOwnerCredentials(opts, previous) {
1629
+ const username = opts.ownerUser || previous?.owner.username || DEFAULT_OWNER;
1630
+ const password = opts.ownerPassword || (previous?.owner.username === username ? previous.owner.password : DEFAULT_PASSWORD);
1631
+ return { username, password };
1632
+ }
1633
+ async function keepValidClaude(previous, works) {
1634
+ return previous && await works(previous) ? previous : void 0;
1635
+ }
1636
+ async function tokenFor(giteaUrl2, account, previousToken, name) {
1637
+ if (previousToken && await tokenWorks(giteaUrl2, previousToken, account.username)) return previousToken;
1638
+ return new GiteaClient(giteaUrl2, { basic: [account.username, account.password] }).createToken(account.username, name, OWNER_TOKEN_SCOPES);
1639
+ }
1640
+ async function bootstrap(opts) {
1641
+ const log = opts.log ?? (() => {
1642
+ });
1643
+ const previous = existsSync7(configPath()) ? loadConfig() : void 0;
1644
+ const giteaUrl2 = opts.giteaUrl ?? previous?.giteaUrl ?? defaultGiteaUrl();
1645
+ await waitForGitea(giteaUrl2);
1646
+ const adminUser = opts.adminUser ?? previous?.admin.username ?? DEFAULT_ADMIN;
1647
+ const keepAdmin = previous?.admin.username === adminUser ? previous.admin : void 0;
1648
+ const adminPassword = keepAdmin?.password ?? randomBytes(18).toString("base64url");
1649
+ await ensureUser(adminUser, `${adminUser}@slipway.local`, adminPassword, true);
1650
+ const adminToken = await tokenFor(giteaUrl2, { username: adminUser, password: adminPassword }, keepAdmin?.token, "slipway-admin");
1651
+ const adminApi = new GiteaClient(giteaUrl2, adminToken);
1652
+ const { username: reviewerUser, password: reviewerPassword } = resolveOwnerCredentials(opts, previous ? { owner: previous.reviewer } : void 0);
1653
+ await ensureUser(reviewerUser, opts.ownerEmail ?? `${reviewerUser}@slipway.local`, reviewerPassword, false);
1654
+ const reviewerToken = await tokenFor(
1655
+ giteaUrl2,
1656
+ { username: reviewerUser, password: reviewerPassword },
1657
+ previous?.reviewer.username === reviewerUser ? previous.reviewer.token : void 0,
1658
+ "slipway-reviewer"
1659
+ );
1660
+ const org = opts.org ?? previous?.org ?? DEFAULT_ORG;
1661
+ try {
1662
+ await adminApi.createOrg(org);
1663
+ log(`created organization ${org}`);
1664
+ } catch (e) {
1665
+ if (!(e instanceof GiteaError && (e.status === 409 || e.status === 422))) throw e;
1666
+ }
1667
+ if (reviewerUser !== adminUser) {
1668
+ const team = (await adminApi.listTeams(org)).find((t) => t.name === REVIEWERS_TEAM) ?? await adminApi.createTeam(org, REVIEWERS_TEAM);
1669
+ await adminApi.addTeamMember(team.id, reviewerUser);
1670
+ }
1671
+ const claude = await keepValidClaude(previous?.claude, (a) => tokenWorks(giteaUrl2, a.token, a.username));
1672
+ const config = {
1673
+ giteaUrl: giteaUrl2,
1674
+ admin: { username: adminUser, password: adminPassword, token: adminToken },
1675
+ reviewer: { username: reviewerUser, password: reviewerPassword, token: reviewerToken },
1676
+ org,
1677
+ ...claude ? { claude } : {},
1678
+ repos: previous?.repos ?? {}
1679
+ };
1680
+ for (const [name, entry] of Object.entries(config.repos)) {
1681
+ const inOrg = await adminApi.getRepo(org, name).then(() => true, (e) => e instanceof GiteaError && e.status === 404 ? false : Promise.reject(e));
1682
+ if (!inOrg) {
1683
+ const from = previous?.org ?? reviewerUser;
1684
+ await adminApi.transferRepo(from, name, org);
1685
+ log(`moved ${from}/${name} \u2192 ${org}/${name}`);
1686
+ }
1687
+ await applyRepoAccess(config, name, entry.base);
1688
+ if (existsSync7(entry.path)) await setReviewRemote(config, name, entry.path);
1689
+ }
1690
+ await adminApi.adminEditUser(reviewerUser, { admin: false });
1691
+ saveConfig(config);
1692
+ return config;
1693
+ }
1694
+
1695
+ // package.json
1696
+ var package_default = {
1697
+ name: "@coldic3/slipway",
1698
+ version: "1.0.0",
1699
+ description: "A local review station for AI-agent pull requests: review in a local Gitea, promote the approved commits to GitHub",
1700
+ keywords: [
1701
+ "ai",
1702
+ "code-review",
1703
+ "pull-requests",
1704
+ "gitea",
1705
+ "github",
1706
+ "claude-code",
1707
+ "mcp"
1708
+ ],
1709
+ homepage: "https://github.com/coldic3/slipway#readme",
1710
+ bugs: {
1711
+ url: "https://github.com/coldic3/slipway/issues"
1712
+ },
1713
+ repository: {
1714
+ type: "git",
1715
+ url: "git+https://github.com/coldic3/slipway.git"
1716
+ },
1717
+ license: "MIT",
1718
+ author: "Kevin Kaniaburka",
1719
+ type: "module",
1720
+ bin: {
1721
+ slipway: "dist/slipway.mjs"
1722
+ },
1723
+ files: [
1724
+ "dist/slipway.mjs",
1725
+ "gitea/",
1726
+ "docker/compose.yaml"
1727
+ ],
1728
+ os: [
1729
+ "darwin",
1730
+ "linux"
1731
+ ],
1732
+ engines: {
1733
+ node: ">=22"
1734
+ },
1735
+ publishConfig: {
1736
+ access: "public"
1737
+ },
1738
+ scripts: {
1739
+ slipway: "tsx src/cli.ts",
1740
+ typecheck: "tsc --noEmit",
1741
+ test: "npm run test:unit && npm run test:functional",
1742
+ "test:unit": "vitest run tests/unit",
1743
+ "test:functional": "vitest run tests/functional",
1744
+ "test:e2e": "playwright test",
1745
+ "test:all": "npm test && npm run test:e2e",
1746
+ "build:plugin": "node scripts/build-plugin.mjs",
1747
+ "build:brand": "node scripts/build-brand.mjs && node scripts/stamp-assets.mjs",
1748
+ build: "npm run build:cli && npm run build:plugin",
1749
+ "build:cli": "node scripts/build-cli.mjs",
1750
+ prepack: "npm run build:cli"
1751
+ },
1752
+ devDependencies: {
1753
+ "@modelcontextprotocol/sdk": "^1.32.2",
1754
+ "@playwright/test": "^1.65.0",
1755
+ "@types/node": "^26.6.6",
1756
+ esbuild: "^0.28.2",
1757
+ tsx: "^4.23.15",
1758
+ typescript: "^5.9.3",
1759
+ vitest: "^5.1.0",
1760
+ zod: "^4.6.7"
1761
+ }
1762
+ };
1763
+
1764
+ // src/version.ts
1765
+ var VERSION = package_default.version;
1766
+
1767
+ // src/cli.ts
1768
+ var HELP = `slipway ${VERSION}: a local review station for AI-agent PRs (Gitea \u2192 GitHub)
1769
+
1770
+ Start here:
1771
+ slipway init [--agent claude-code|none] [--port 3514] [--docker] [--yes]
1772
+ set everything up: the server, your login, your agent's plugin, this repo
1773
+
1774
+ Server:
1775
+ slipway up [--port N] [--docker] start Slipway's Gitea on 127.0.0.1 (default port 3514, remembered)
1776
+ slipway down [--purge] stop it (--purge deletes all review data)
1777
+ slipway status runtime, port, running or not
1778
+
1779
+ Setup:
1780
+ slipway setup [--user slipway] [--password slipway] [--email e] [--org repos] [--admin-user admin]
1781
+ your login (a plain reviewer), the technical admin and the org (idempotent)
1782
+ slipway repo add <path> [--name n] [--github owner/repo] [--base main]
1783
+ register a local clone: Gitea repo, protection, remote "review"
1784
+ slipway sync-main <repo> push origin/<base> to Gitea <base> (force)
1785
+
1786
+ Promotion (usually done by your agent):
1787
+ slipway promote <repo> <pr> [--draft] [--base b] [--test-cmd "npm test"] [--force]
1788
+ publish an approved PR's exact commits to GitHub (token: SLIPWAY_GITHUB_TOKEN or \`gh auth token\`)
1789
+
1790
+ Environment: SLIPWAY_PORT, SLIPWAY_RUNTIME (native|docker), SLIPWAY_HOME (default ~/.slipway).
1791
+ `;
1792
+ async function main(argv) {
1793
+ const [cmd, sub, ...rest] = argv;
1794
+ const log = (m) => console.error(`slipway: ${m}`);
1795
+ switch (cmd) {
1796
+ case void 0:
1797
+ case "-h":
1798
+ case "--help":
1799
+ case "help":
1800
+ console.log(HELP);
1801
+ return 0;
1802
+ case "-v":
1803
+ case "--version":
1804
+ case "version":
1805
+ console.log(VERSION);
1806
+ return 0;
1807
+ case "init": {
1808
+ const { values } = parseArgs2({
1809
+ args: argv.slice(1),
1810
+ options: { agent: { type: "string" }, port: { type: "string" }, docker: { type: "boolean" }, yes: { type: "boolean", short: "y" } }
1811
+ });
1812
+ const terminal = process.stdin.isTTY && !values.yes ? terminalAsk() : void 0;
1813
+ try {
1814
+ let here;
1815
+ const r = await init(values, {
1816
+ ask: terminal?.ask,
1817
+ log,
1818
+ up,
1819
+ setup: (l) => bootstrap({ log: l }),
1820
+ addRepo: (path, l) => addRepo({ path, log: l }),
1821
+ repoHere: async (c) => here = await repoHere(c),
1822
+ ghLoggedIn
1823
+ });
1824
+ console.error(initSummary(r, here));
1825
+ } finally {
1826
+ terminal?.close();
1827
+ }
1828
+ return 0;
1829
+ }
1830
+ case "up": {
1831
+ const { values } = parseArgs2({ args: argv.slice(1), options: { port: { type: "string" }, docker: { type: "boolean" } } });
1832
+ const i = await up({ port: values.port, runtime: values.docker ? "docker" : void 0, log });
1833
+ log(`Slipway is up at http://127.0.0.1:${i.port} (${i.runtime})`);
1834
+ return 0;
1835
+ }
1836
+ case "down": {
1837
+ const { values } = parseArgs2({ args: argv.slice(1), options: { purge: { type: "boolean" } } });
1838
+ await down({ purge: values.purge });
1839
+ return 0;
1840
+ }
1841
+ case "status": {
1842
+ const s = await status();
1843
+ if (!s.instance) {
1844
+ console.log("not set up here yet: run slipway init");
1845
+ return 1;
1846
+ }
1847
+ let configured = "";
1848
+ try {
1849
+ const c = loadConfig();
1850
+ configured = `, login ${c.reviewer.username}, ${Object.keys(c.repos).length} repo(s)`;
1851
+ } catch {
1852
+ configured = ", not set up: run slipway setup";
1853
+ }
1854
+ console.log(`${s.running ? "running" : "stopped"} at ${s.url} (${s.instance.runtime}${configured})`);
1855
+ return s.running ? 0 : 3;
1856
+ }
1857
+ case "setup": {
1858
+ const { values } = parseArgs2({
1859
+ args: argv.slice(1),
1860
+ options: {
1861
+ user: { type: "string" },
1862
+ email: { type: "string" },
1863
+ password: { type: "string" },
1864
+ "gitea-url": { type: "string" },
1865
+ "admin-user": { type: "string" },
1866
+ org: { type: "string" }
1867
+ }
1868
+ });
1869
+ const c = await bootstrap({
1870
+ ownerUser: values.user,
1871
+ ownerEmail: values.email,
1872
+ ownerPassword: values.password,
1873
+ giteaUrl: values["gitea-url"],
1874
+ adminUser: values["admin-user"],
1875
+ org: values.org,
1876
+ log
1877
+ });
1878
+ log(`ready: ${c.giteaUrl} (login: ${c.reviewer.username} / ${c.reviewer.password}; repos live in "${c.org}")`);
1879
+ return 0;
1880
+ }
1881
+ case "repo": {
1882
+ if (sub !== "add") throw new Error("usage: slipway repo add <path> ...");
1883
+ const { values, positionals } = parseArgs2({
1884
+ args: rest,
1885
+ allowPositionals: true,
1886
+ options: { name: { type: "string" }, github: { type: "string" }, base: { type: "string" } }
1887
+ });
1888
+ const r = await addRepo({ path: positionals[0] ?? ".", name: values.name, github: values.github, base: values.base, log });
1889
+ log(`repo ${r.name} ready \u2192 ${r.url}`);
1890
+ return 0;
1891
+ }
1892
+ case "sync-main": {
1893
+ if (!sub) throw new Error("usage: slipway sync-main <repo>");
1894
+ await syncMain(sub);
1895
+ log(`synced ${sub}`);
1896
+ return 0;
1897
+ }
1898
+ case "promote": {
1899
+ const { runPromoteCli: runPromoteCli2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
1900
+ return runPromoteCli2(argv.slice(1), log);
1901
+ }
1902
+ default:
1903
+ console.error(`Unknown command: ${cmd}
1904
+
1905
+ ${HELP}`);
1906
+ return 1;
1907
+ }
1908
+ }
1909
+ function isMain() {
1910
+ if (!process.argv[1]) return false;
1911
+ try {
1912
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath2(import.meta.url));
1913
+ } catch {
1914
+ return false;
1915
+ }
1916
+ }
1917
+ if (isMain()) {
1918
+ main(process.argv.slice(2)).then(
1919
+ (code) => process.exit(code),
1920
+ (err) => {
1921
+ console.error(`slipway: error: ${err.message}`);
1922
+ process.exit(1);
1923
+ }
1924
+ );
1925
+ }
1926
+ export {
1927
+ main
1928
+ };