@amenophis1er/foreman 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/deck.ts CHANGED
@@ -759,6 +759,25 @@ async function artifactsFor(folder: string, runId: string, since: number | undef
759
759
  return out.slice(0, ARTIFACT_CAP);
760
760
  }
761
761
 
762
+ /** Listed files per project tree. */
763
+ const TREE_CAP = 2_000;
764
+
765
+ /**
766
+ * The project's working tree as it stands: every file the deck would consider,
767
+ * minus `.foreman`, which is Foreman's own. No baseline, no diff — this is the
768
+ * view for a project between missions, where "what is in this folder" is the
769
+ * question and every run shares the answer.
770
+ */
771
+ export async function projectTree(folder: string): Promise<{ files: DeckArtifact[]; truncated: boolean }> {
772
+ const { files, truncated } = await walk(folder, { skipWork: true, limit: TREE_CAP });
773
+ const out: DeckArtifact[] = [];
774
+ for (const f of files) {
775
+ if (f.rel === '.foreman' || f.rel.startsWith('.foreman/')) continue;
776
+ out.push({ path: f.rel, kind: artifactKind(f.rel), size: f.size, mtimeMs: f.mtimeMs });
777
+ }
778
+ return { files: out, truncated };
779
+ }
780
+
762
781
  /**
763
782
  * Resolve an artifact path for serving, or null if it is not something the
764
783
  * deck may hand out. The jail is the realpath of `folder`: `..`, absolute
@@ -820,30 +839,37 @@ function sendJson(res: ServerResponse, code: number, body: unknown): void {
820
839
  }
821
840
 
822
841
  /**
823
- * `GET /runs/{id}/deck`, `GET /runs/{id}/artifact?path=<rel>` and
824
- * `GET /runs/{id}/preview/<rel>` (sandboxed render, see below). Returns
842
+ * `GET /runs/{id}/deck`, `GET /projects/{id}/tree`, `GET …/artifact?path=<rel>`
843
+ * and `GET …/preview/<rel>` (sandboxed render, see below). Returns
825
844
  * true when the URL was one of ours (whatever the outcome), false so the
826
845
  * caller's router falls through. `lookup` maps a run id to its folder; null
827
846
  * means unknown run, which is a 404 rather than an error.
828
847
  */
829
848
  export async function handleDeckRoute(
830
849
  req: IncomingMessage, res: ServerResponse, url: URL,
831
- lookup: (runId: string) => Promise<{ folder: string } | null>,
850
+ lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
832
851
  ): Promise<boolean> {
833
- const m = url.pathname.match(/^\/runs\/([^/]+)\/(deck|artifact|preview)(?:\/(.*))?$/);
852
+ // The same jail and the same viewer serve two scopes: a run (its deck,
853
+ // relative to a baseline) and a project (its tree as it stands, no baseline).
854
+ const m = url.pathname.match(/^\/(runs|projects)\/([^/]+)\/(deck|tree|artifact|preview)(?:\/(.*))?$/);
834
855
  if (!m) return false;
835
- const [, runId, what, previewRel] = m;
856
+ const [, scope, runId, what, previewRel] = m;
836
857
  if ((what === 'preview') !== (previewRel !== undefined)) return false;
858
+ if ((what === 'deck' && scope !== 'runs') || (what === 'tree' && scope !== 'projects')) return false;
837
859
  try {
838
860
  if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return true; }
839
861
  if (!RUN_ID_RE.test(runId)) { sendJson(res, 404, { error: 'not found' }); return true; }
840
- const run = await lookup(runId);
862
+ const run = await lookup(scope as 'runs' | 'projects', runId);
841
863
  if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
842
864
 
843
865
  if (what === 'deck') {
844
866
  sendJson(res, 200, await deckFor(run.folder, runId));
845
867
  return true;
846
868
  }
869
+ if (what === 'tree') {
870
+ sendJson(res, 200, await projectTree(run.folder));
871
+ return true;
872
+ }
847
873
 
848
874
  if (what === 'preview') {
849
875
  // A rendered look at an HTML artifact. Files come out with their real
@@ -152,8 +152,9 @@ that project's planner; otherwise it comes to you.
152
152
  WHAT YOU CAN DO — through the tools, nothing else:
153
153
  - list_projects / project_detail / run_report: answer "how is X doing",
154
154
  "what needs me", "what happened to Y".
155
- - create_project / link_project: a new folder under the projects root, or
156
- an existing one, linked into the fleet.
155
+ - create_project / link_project: a new folder under the projects root, an
156
+ existing one, or a Git repository cloned under the root (link_project
157
+ with the URL), linked into the fleet.
157
158
  - open_planning: hand a request about an EXISTING codebase to that
158
159
  project's planner, which can read the folder. Use this whenever the right
159
160
  mission depends on what is already there. After you call it, that planner
@@ -280,8 +281,8 @@ export async function runFleetTurn(turn: FleetTurn): Promise<FleetResult> {
280
281
  tool('create_project', 'Create a new folder under the projects root and link it as a project. Use when the human wants to start something that has no home yet.',
281
282
  { name: z.string().describe('What to call it; becomes the folder name') },
282
283
  async ({ name }) => text(await safe(() => host.createProject(name)))),
283
- tool('link_project', 'Link an existing folder as a project. The folder must already exist.',
284
- { folder: z.string().describe('Absolute path, or ~/…') },
284
+ tool('link_project', 'Link an existing folder as a project — or clone a Git repository (a GitHub URL, a git@ URL, or owner/repo) under the projects root and link it. Cloning runs as the human with their own git credentials and can take a minute; wait for the result.',
285
+ { folder: z.string().describe('Absolute path, ~/…, or a Git repository URL') },
285
286
  async ({ folder }) => text(await safe(() => host.linkProject(folder)))),
286
287
  tool('open_planning', 'Hand the request to that project\'s planner, which can read the folder and will propose a mission. After this, the planner owns the conversation: tell the human in one line and stop.',
287
288
  {
@@ -0,0 +1,95 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { mkdtemp, writeFile } from 'node:fs/promises';
7
+ import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch } from './gitwork.js';
8
+
9
+ const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
+
11
+ async function repo(): Promise<string> {
12
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'gitwork-'));
13
+ sh(dir, 'init', '-q', '-b', 'main');
14
+ sh(dir, 'config', 'user.email', 'me@example.com');
15
+ sh(dir, 'config', 'user.name', 'Me');
16
+ await writeFile(path.join(dir, 'README.md'), 'hello\n');
17
+ sh(dir, 'add', '-A'); sh(dir, 'commit', '-q', '-m', 'init');
18
+ return dir;
19
+ }
20
+
21
+ test('missionBranchName: readable, bounded, unique per run', () => {
22
+ assert.equal(missionBranchName('Build Tick, a vanilla Pomodoro timer\nmore', '1788713434983-226123af'), 'foreman/build-tick-a-vanilla-pomodoro-timer-23af');
23
+ assert.match(missionBranchName(' \n!!!', 'run-xyz9'), /^foreman\/mission-xyz9$/);
24
+ assert.ok(missionBranchName('a '.repeat(80), 'r1').length < 60);
25
+ });
26
+
27
+ test('gitInfo: a plain folder is not a repo; a repo reports branch, head, dirty, remote', async () => {
28
+ const plain = await mkdtemp(path.join(os.tmpdir(), 'gitwork-plain-'));
29
+ assert.deepEqual(await gitInfo(plain), { repo: false });
30
+ const dir = await repo();
31
+ const info = await gitInfo(dir);
32
+ assert.equal(info.repo, true); assert.equal(info.branch, 'main'); assert.equal(info.dirty, false); assert.ok(info.head);
33
+ sh(dir, 'remote', 'add', 'origin', 'git@github.com:acme/widget.git');
34
+ await writeFile(path.join(dir, 'x.txt'), 'x');
35
+ const again = await gitInfo(dir);
36
+ assert.equal(again.dirty, true); assert.equal(again.remote, 'git@github.com:acme/widget.git');
37
+ });
38
+
39
+ test('a mission gets its own branch from the current one, and closing commits the work on it', async () => {
40
+ const dir = await repo();
41
+ const g = await startMissionBranch(dir, 'Add a footer to the page', 'run-1234abcd');
42
+ assert.ok(!('error' in g), JSON.stringify(g));
43
+ if ('error' in g) return;
44
+ assert.equal(g.branch, 'foreman/add-a-footer-to-the-page-abcd');
45
+ assert.equal(g.base, 'main');
46
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), g.branch);
47
+
48
+ // Nothing to commit yet: no commit, zero commits ahead.
49
+ const idle = await closeMissionBranch(dir, g, 'foreman: nothing');
50
+ assert.equal(idle.committed, false); assert.equal(idle.commits, 0); assert.equal(idle.error, undefined);
51
+
52
+ await writeFile(path.join(dir, 'footer.html'), '<footer/>');
53
+ const closed = await closeMissionBranch(dir, g, 'foreman: Add a footer');
54
+ assert.equal(closed.committed, true); assert.equal(closed.commits, 1); assert.ok(closed.commit);
55
+ assert.equal(sh(dir, 'status', '--porcelain').trim(), '');
56
+ assert.match(sh(dir, 'log', '-1', '--format=%s'), /^foreman: Add a footer/);
57
+ // main is untouched.
58
+ assert.equal(sh(dir, 'rev-list', '--count', 'main').trim(), '1');
59
+ });
60
+
61
+ test('ensureMissionBranch goes back to the branch for a resume, and says why when it cannot', async () => {
62
+ const dir = await repo();
63
+ const g = await startMissionBranch(dir, 'Thing', 'run-1');
64
+ if ('error' in g) throw new Error(g.error);
65
+ sh(dir, 'checkout', '-q', 'main');
66
+ assert.equal(await ensureMissionBranch(dir, g.branch), null);
67
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), g.branch);
68
+ assert.match((await ensureMissionBranch(dir, 'foreman/does-not-exist')) ?? '', /checkout/);
69
+ });
70
+
71
+ test('startMissionBranch on a plain folder says so instead of throwing', async () => {
72
+ const plain = await mkdtemp(path.join(os.tmpdir(), 'gitwork-plain2-'));
73
+ assert.deepEqual(await startMissionBranch(plain, 'x', 'r'), { error: 'not a git repository' });
74
+ });
75
+
76
+ test('remoteWebUrl and compareUrl: the three hosts people use, and a plain page elsewhere', async () => {
77
+ const { remoteWebUrl, compareUrl } = await import('./gitwork.js');
78
+ assert.deepEqual(remoteWebUrl('git@github.com:acme/widget.git'), { host: 'github.com', path: 'acme/widget', web: 'https://github.com/acme/widget' });
79
+ assert.deepEqual(remoteWebUrl('https://gitlab.com/group/sub/widget.git'), { host: 'gitlab.com', path: 'group/sub/widget', web: 'https://gitlab.com/group/sub/widget' });
80
+ assert.equal(remoteWebUrl(undefined), null);
81
+ assert.equal(compareUrl('git@github.com:acme/widget.git', 'main', 'foreman/x-1'), 'https://github.com/acme/widget/compare/main...foreman%2Fx-1?expand=1');
82
+ assert.match(compareUrl('https://gitlab.com/g/w.git', 'main', 'foreman/x') ?? '', /merge_requests\/new\?merge_request%5Bsource_branch%5D=foreman%2Fx/);
83
+ assert.match(compareUrl('git@bitbucket.org:t/w.git', 'main', 'foreman/x') ?? '', /pull-requests\/new\?source=foreman%2Fx&dest=main/);
84
+ assert.equal(compareUrl('https://git.example.com/a/b', 'main', 'x'), 'https://git.example.com/a/b');
85
+ });
86
+
87
+ test('prDraft: the run title, the brief, the boxes as the mission left them, and a footer', async () => {
88
+ const { prDraft } = await import('./gitwork.js');
89
+ const d = prDraft({ title: 'Add a footer', mission: 'Add a footer to the page.\nKeep it small.', costUsd: 0.42, costBasis: 'priced', git: { branch: 'foreman/add-a-footer-ab12', base: 'main', baseHead: null } },
90
+ '# Mission\n- [x] footer.html exists\n- [ ] linked from index\n');
91
+ assert.equal(d.title, 'Add a footer');
92
+ assert.match(d.body, /## Mission\n\nAdd a footer to the page\.\nKeep it small\./);
93
+ assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
94
+ assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
95
+ });
package/src/gitwork.ts ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Git around a mission: which branch a folder is on, a branch of its own for
3
+ * each mission, and the commit that closes one.
4
+ *
5
+ * A mission is a unit of work; in a repository, a branch makes it one in
6
+ * git too — isolation, a diff that is exactly the mission, an easy revert,
7
+ * a pull request if there is a remote. Foreman creates the branch and
8
+ * commits on it. It never merges, and it pushes only when the human presses
9
+ * the button that says so — once, for that branch, to open the pull request.
10
+ */
11
+ import { execFile } from 'node:child_process';
12
+
13
+ export interface GitInfo {
14
+ repo: boolean;
15
+ /** Current branch; a short sha when detached. */
16
+ branch?: string;
17
+ /** Uncommitted changes, tracked or untracked. */
18
+ dirty?: boolean;
19
+ head?: string | null;
20
+ /** `origin`'s URL when there is one, so the UI can say "has a remote". */
21
+ remote?: string;
22
+ }
23
+
24
+ /** Recorded on a run that got a branch of its own. */
25
+ export interface MissionGit {
26
+ branch: string;
27
+ /** Where it was made from: the branch (or short sha) that was checked out. */
28
+ base: string;
29
+ baseHead: string | null;
30
+ /** Commits on the branch since `baseHead`, updated when the mission closes. */
31
+ commits?: number;
32
+ /** The closing commit, when the mission's work was committed. */
33
+ commit?: string;
34
+ }
35
+
36
+ function git(args: string[], cwd: string, timeoutMs = 15_000): Promise<string> {
37
+ return new Promise((resolve, reject) => {
38
+ execFile('git', args, {
39
+ cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
40
+ env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
41
+ }, (err, stdout, stderr) => {
42
+ if (err) reject(new Error(`git ${args[0]}: ${(stderr || err.message).trim()}`));
43
+ else resolve(String(stdout));
44
+ });
45
+ });
46
+ }
47
+
48
+ /** What git says about a folder; `{ repo: false }` for anything that is not a work tree. Never throws. */
49
+ export async function gitInfo(folder: string): Promise<GitInfo> {
50
+ try {
51
+ const inside = (await git(['rev-parse', '--is-inside-work-tree'], folder)).trim();
52
+ if (inside !== 'true') return { repo: false };
53
+ } catch {
54
+ return { repo: false };
55
+ }
56
+ const info: GitInfo = { repo: true };
57
+ try {
58
+ const ref = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], folder)).trim();
59
+ info.branch = ref === 'HEAD' ? (await git(['rev-parse', '--short', 'HEAD'], folder)).trim() : ref;
60
+ } catch {
61
+ // An empty repository: HEAD names a branch with no commits yet.
62
+ try { info.branch = (await git(['symbolic-ref', '--short', 'HEAD'], folder)).trim(); } catch { /* leave unset */ }
63
+ }
64
+ try { info.head = (await git(['rev-parse', '--verify', 'HEAD'], folder)).trim() || null; } catch { info.head = null; }
65
+ try { info.dirty = (await git(['status', '--porcelain', '--untracked-files=normal'], folder)).trim().length > 0; } catch { /* unknown */ }
66
+ try { info.remote = (await git(['remote', 'get-url', 'origin'], folder)).trim() || undefined; } catch { /* no remote */ }
67
+ return info;
68
+ }
69
+
70
+ /** `foreman/<first words of the brief>-<id tail>`: readable in `git branch`, unique per run. */
71
+ export function missionBranchName(mission: string, runId: string): string {
72
+ const first = mission.split('\n').find((l) => l.trim())?.trim() ?? 'mission';
73
+ const slug = first.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').split('-').filter(Boolean).slice(0, 6).join('-').slice(0, 40).replace(/-+$/, '') || 'mission';
74
+ const tail = runId.replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase();
75
+ return `foreman/${slug}-${tail}`;
76
+ }
77
+
78
+ /**
79
+ * A branch of the mission's own, made from what is checked out now.
80
+ * Uncommitted changes come along, as `checkout -b` always does. Resolves to
81
+ * the record for the run, or to one sentence on why it could not.
82
+ */
83
+ export async function startMissionBranch(folder: string, mission: string, runId: string): Promise<MissionGit | { error: string }> {
84
+ const info = await gitInfo(folder);
85
+ if (!info.repo) return { error: 'not a git repository' };
86
+ const branch = missionBranchName(mission, runId);
87
+ try {
88
+ await git(['checkout', '-b', branch], folder);
89
+ } catch (err) {
90
+ return { error: err instanceof Error ? err.message : String(err) };
91
+ }
92
+ return { branch, base: info.branch ?? 'HEAD', baseHead: info.head ?? null };
93
+ }
94
+
95
+ /**
96
+ * Back on the mission's branch for a resume. Returns null when already or
97
+ * now there, else why not — a dirty tree that would be clobbered, typically.
98
+ */
99
+ export async function ensureMissionBranch(folder: string, branch: string): Promise<string | null> {
100
+ const info = await gitInfo(folder);
101
+ if (!info.repo) return 'the folder is no longer a git repository';
102
+ if (info.branch === branch) return null;
103
+ try {
104
+ await git(['checkout', branch], folder);
105
+ return null;
106
+ } catch (err) {
107
+ return err instanceof Error ? err.message : String(err);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Commits whatever the mission left uncommitted, on its branch, as the user
113
+ * (their git config), falling back to a Foreman identity only when the
114
+ * repository has none. Then counts the branch's commits since its base.
115
+ */
116
+ export async function closeMissionBranch(folder: string, g: MissionGit, message: string): Promise<MissionGit & { committed: boolean; error?: string }> {
117
+ const out: MissionGit & { committed: boolean; error?: string } = { ...g, committed: false };
118
+ try {
119
+ const info = await gitInfo(folder);
120
+ if (!info.repo) return { ...out, error: 'not a git repository' };
121
+ if (info.branch !== g.branch) return { ...out, error: `the folder is on ${info.branch ?? 'another branch'}, not ${g.branch}; nothing committed` };
122
+ if (info.dirty) {
123
+ await git(['add', '-A', '--', '.'], folder);
124
+ const identity = ['-c', 'user.name=Foreman', '-c', 'user.email=foreman@localhost'];
125
+ const hasName = await git(['config', 'user.name'], folder).then((v) => v.trim().length > 0).catch(() => false);
126
+ await git([...(hasName ? [] : identity), 'commit', '-q', '-m', message], folder);
127
+ out.commit = (await git(['rev-parse', '--short', 'HEAD'], folder)).trim();
128
+ out.committed = true;
129
+ }
130
+ if (g.baseHead) {
131
+ out.commits = Number((await git(['rev-list', '--count', `${g.baseHead}..HEAD`], folder)).trim()) || 0;
132
+ } else {
133
+ out.commits = Number((await git(['rev-list', '--count', 'HEAD'], folder)).trim()) || 0;
134
+ }
135
+ } catch (err) {
136
+ out.error = err instanceof Error ? err.message : String(err);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // The pull request: the one outward-facing act, and only ever on a button
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /** `git@github.com:o/r.git` or `https://host/o/r(.git)` → the repository's web page. Null for anything else. */
146
+ export function remoteWebUrl(remote: string | undefined): { host: string; web: string; path: string } | null {
147
+ if (!remote) return null;
148
+ let m = /^git@([^:]+):(.+?)(?:\.git)?\/?$/.exec(remote.trim());
149
+ if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
150
+ m = /^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/.exec(remote.trim());
151
+ if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
152
+ m = /^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(remote.trim());
153
+ if (m) return { host: m[1].toLowerCase(), path: m[2], web: `https://${m[1]}/${m[2]}` };
154
+ return null;
155
+ }
156
+
157
+ /** Where a human finishes the pull request in a browser when `gh` is not around: GitHub, GitLab and Bitbucket forms; null elsewhere. */
158
+ export function compareUrl(remote: string | undefined, base: string, branch: string): string | null {
159
+ const r = remoteWebUrl(remote);
160
+ if (!r) return null;
161
+ const enc = encodeURIComponent;
162
+ if (r.host === 'github.com' || r.host.endsWith('.github.com')) return `${r.web}/compare/${enc(base)}...${enc(branch)}?expand=1`;
163
+ if (r.host.includes('gitlab')) return `${r.web}/-/merge_requests/new?merge_request%5Bsource_branch%5D=${enc(branch)}&merge_request%5Btarget_branch%5D=${enc(base)}`;
164
+ if (r.host.includes('bitbucket')) return `${r.web}/pull-requests/new?source=${enc(branch)}&dest=${enc(base)}`;
165
+ return `${r.web}`;
166
+ }
167
+
168
+ /** The pull request as Foreman drafts it: the run's title, and a body a reviewer can read without opening Foreman. */
169
+ export function prDraft(run: { title?: string; mission: string; costUsd: number; costBasis?: string; git?: MissionGit }, missionDoc: string | null): { title: string; body: string } {
170
+ const first = run.mission.split('\n').find((l) => l.trim())?.trim() ?? 'Mission';
171
+ const title = (run.title || first).slice(0, 120);
172
+ const boxes = (missionDoc ?? '').split('\n').filter((l) => /^\s*[-*] \[[ xX]\]/.test(l)).map((l) => l.trim());
173
+ const spend = run.costBasis === 'priced' || run.costBasis === undefined ? `$${run.costUsd.toFixed(2)}` : run.costBasis;
174
+ const parts = [
175
+ '## Mission', '', run.mission.trim(), '',
176
+ ];
177
+ if (boxes.length) parts.push('## Done when', '', ...boxes, '');
178
+ parts.push('---', `Run by [Foreman](https://github.com/amenophis1er/foreman) on branch \`${run.git?.branch ?? ''}\` from \`${run.git?.base ?? ''}\` · spend ${spend}.`);
179
+ return { title, body: parts.join('\n') };
180
+ }
181
+
182
+ /** `git push -u origin <branch>`, as the user. Null on success, git's reason otherwise. */
183
+ export async function pushBranch(folder: string, branch: string): Promise<string | null> {
184
+ try {
185
+ await git(['push', '-u', 'origin', branch], folder, 5 * 60_000);
186
+ return null;
187
+ } catch (err) {
188
+ const t = err instanceof Error ? err.message : String(err);
189
+ if (/could not read Username|Authentication failed|Permission denied|terminal prompts disabled/i.test(t)) {
190
+ return 'Git could not authenticate to the remote. Set up a credential helper or gh auth, or use an SSH remote with a key this machine has.';
191
+ }
192
+ return t.replace(/^git push:\s*/, 'git push failed: ');
193
+ }
194
+ }
195
+
196
+ /** Is GitHub's CLI here and signed in? Best effort, a few seconds at most. */
197
+ export async function ghReady(): Promise<{ present: boolean; authed: boolean }> {
198
+ return new Promise((resolve) => {
199
+ execFile('gh', ['auth', 'status'], { timeout: 8_000 }, (err) => {
200
+ if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') return resolve({ present: false, authed: false });
201
+ resolve({ present: true, authed: !err });
202
+ });
203
+ });
204
+ }
205
+
206
+ /** What became of a pull request, from gh: open, merged or closed. Null when gh cannot say. */
207
+ export function pullRequestState(folder: string, url: string): Promise<{ state: 'open' | 'merged' | 'closed'; mergedAt?: string; number?: number } | null> {
208
+ return new Promise((resolve) => {
209
+ execFile('gh', ['pr', 'view', url, '--json', 'state,mergedAt,number'], {
210
+ cwd: folder, timeout: 15_000, env: { ...process.env, GH_PROMPT_DISABLED: '1' },
211
+ }, (err, stdout) => {
212
+ if (err) return resolve(null);
213
+ try {
214
+ const d = JSON.parse(String(stdout)) as { state?: string; mergedAt?: string | null; number?: number };
215
+ const state = d.state === 'MERGED' ? 'merged' : d.state === 'CLOSED' ? 'closed' : d.state === 'OPEN' ? 'open' : null;
216
+ resolve(state ? { state, mergedAt: d.mergedAt ?? undefined, number: d.number } : null);
217
+ } catch { resolve(null); }
218
+ });
219
+ });
220
+ }
221
+
222
+ /** `gh pr create`, as the user. Resolves to the PR's URL, or to why not. */
223
+ export function createPullRequest(folder: string, opts: { base: string; branch: string; title: string; body: string }): Promise<{ url?: string; error?: string }> {
224
+ return new Promise((resolve) => {
225
+ execFile('gh', ['pr', 'create', '--base', opts.base, '--head', opts.branch, '--title', opts.title, '--body', opts.body], {
226
+ cwd: folder, timeout: 60_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0' },
227
+ }, (err, stdout, stderr) => {
228
+ if (err) {
229
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return resolve({ error: 'gh is not installed' });
230
+ const t = String(stderr || err.message).trim();
231
+ const existing = /already exists:\s*(https?:\S+)/.exec(t);
232
+ if (existing) return resolve({ url: existing[1] });
233
+ return resolve({ error: `gh pr create: ${t.split('\n').filter(Boolean).pop() ?? t}` });
234
+ }
235
+ const url = String(stdout).split('\n').map((l) => l.trim()).find((l) => /^https?:\/\//.test(l));
236
+ resolve(url ? { url } : { error: 'gh did not return a pull request URL' });
237
+ });
238
+ });
239
+ }
@@ -0,0 +1,104 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type http from 'node:http';
4
+ import { requestAllowed, pathPermitted } from './guard.js';
5
+
6
+ const PORT = 4177;
7
+ const TAILNET = { ip: '100.94.221.98', dnsName: 'laptop.tail1234.ts.net' };
8
+ const OPTS = { port: PORT, tailnet: TAILNET };
9
+
10
+ /** A request as the guard sees one: a method and a bag of headers. */
11
+ function req(method: string, headers: Record<string, string>) {
12
+ return { method, headers: headers as http.IncomingHttpHeaders };
13
+ }
14
+
15
+ test('requestAllowed: the hosts Foreman answers to', () => {
16
+ assert.deepEqual(requestAllowed(req('GET', { host: `localhost:${PORT}` }), OPTS), { ok: true });
17
+ assert.deepEqual(requestAllowed(req('GET', { host: `127.0.0.1:${PORT}` }), OPTS), { ok: true });
18
+ assert.deepEqual(requestAllowed(req('GET', { host: `[::1]:${PORT}` }), OPTS), { ok: true });
19
+ assert.deepEqual(requestAllowed(req('GET', { host: `100.94.221.98:${PORT}` }), OPTS), { ok: true });
20
+ // The MagicDNS name with Foreman's port, and with whatever port
21
+ // `tailscale serve` put in front of it — including none at all (443).
22
+ assert.deepEqual(requestAllowed(req('GET', { host: `laptop.tail1234.ts.net:${PORT}` }), OPTS), { ok: true });
23
+ assert.deepEqual(requestAllowed(req('GET', { host: 'laptop.tail1234.ts.net' }), OPTS), { ok: true });
24
+ assert.deepEqual(requestAllowed(req('GET', { host: 'laptop.tail1234.ts.net:8443' }), OPTS), { ok: true });
25
+ // Case never matters in a host name.
26
+ assert.deepEqual(requestAllowed(req('GET', { host: 'LapTop.Tail1234.TS.net' }), OPTS), { ok: true });
27
+ });
28
+
29
+ test('requestAllowed: an unrecognised or missing Host is 421 (DNS rebinding)', () => {
30
+ assert.deepEqual(requestAllowed(req('GET', { host: `evil.example:${PORT}` }), OPTS), {
31
+ ok: false, status: 421, error: 'unrecognised Host',
32
+ });
33
+ assert.deepEqual(requestAllowed(req('GET', {}), OPTS), {
34
+ ok: false, status: 421, error: 'unrecognised Host',
35
+ });
36
+ // A neighbouring name is not the tailnet name.
37
+ assert.equal(requestAllowed(req('GET', { host: 'notlaptop.tail1234.ts.net' }), OPTS).ok, false);
38
+ // No tailnet: its address is not special.
39
+ assert.equal(requestAllowed(req('GET', { host: `100.94.221.98:${PORT}` }), { port: PORT, tailnet: null }).ok, false);
40
+ });
41
+
42
+ test('requestAllowed: safe methods pass on Host alone, whatever the Origin', () => {
43
+ const ok = req('GET', { host: `localhost:${PORT}`, origin: 'http://evil.example' });
44
+ assert.deepEqual(requestAllowed(ok, OPTS), { ok: true });
45
+ assert.deepEqual(requestAllowed(req('HEAD', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), { ok: true });
46
+ assert.deepEqual(requestAllowed(req('OPTIONS', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), { ok: true });
47
+ // Including the event stream, which is how the dashboard stays live.
48
+ assert.deepEqual(requestAllowed(req('GET', { host: `127.0.0.1:${PORT}`, 'sec-fetch-site': 'cross-site' }), OPTS), { ok: true });
49
+ });
50
+
51
+ test('requestAllowed: an unsafe method with a foreign Origin is refused', () => {
52
+ assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'http://evil.example' }), OPTS), {
53
+ ok: false, status: 403, error: 'cross-site request refused',
54
+ });
55
+ // Right name, wrong port: a different origin to the browser and to us.
56
+ assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'http://localhost:9999' }), OPTS).ok, false);
57
+ // Not a web origin at all.
58
+ assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'null' }), OPTS).ok, false);
59
+ assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: 'file://' }), OPTS).ok, false);
60
+ });
61
+
62
+ test('requestAllowed: an unsafe method from Foreman’s own page is allowed', () => {
63
+ assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, origin: `http://localhost:${PORT}` }), OPTS), { ok: true });
64
+ assert.deepEqual(requestAllowed(req('DELETE', { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}` }), OPTS), { ok: true });
65
+ // The phone, through `tailscale serve` on 443: HTTPS, no port in the Origin.
66
+ assert.deepEqual(requestAllowed(req('POST', { host: 'laptop.tail1234.ts.net', origin: 'https://laptop.tail1234.ts.net' }), OPTS), { ok: true });
67
+ assert.deepEqual(requestAllowed(req('POST', { host: `100.94.221.98:${PORT}`, origin: `http://100.94.221.98:${PORT}` }), OPTS), { ok: true });
68
+ });
69
+
70
+ test('requestAllowed: with no Origin, Sec-Fetch-Site decides', () => {
71
+ // curl, src/cli.ts, the Telegram paths and the tests: neither header.
72
+ assert.deepEqual(requestAllowed(req('POST', { host: `127.0.0.1:${PORT}` }), OPTS), { ok: true });
73
+ assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'same-origin' }), OPTS), { ok: true });
74
+ assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'none' }), OPTS), { ok: true });
75
+ assert.deepEqual(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'cross-site' }), OPTS), {
76
+ ok: false, status: 403, error: 'cross-site request refused',
77
+ });
78
+ assert.equal(requestAllowed(req('POST', { host: `localhost:${PORT}`, 'sec-fetch-site': 'same-site' }), OPTS).ok, false);
79
+ });
80
+
81
+ test('requestAllowed: FOREMAN_BIND=all takes any Host, and Origin must match it', () => {
82
+ const all = { port: PORT, tailnet: null, bindAll: true };
83
+ assert.deepEqual(requestAllowed(req('GET', { host: '192.168.1.20:4177' }), all), { ok: true });
84
+ assert.deepEqual(requestAllowed(req('GET', { host: 'foreman.lan' }), all), { ok: true });
85
+ assert.deepEqual(requestAllowed(req('POST', { host: 'foreman.lan', origin: 'http://foreman.lan' }), all), { ok: true });
86
+ assert.equal(requestAllowed(req('POST', { host: 'foreman.lan', origin: 'http://evil.example' }), all).ok, false);
87
+ // Even wide open, a request with no Host at all is not one we can place.
88
+ assert.equal(requestAllowed(req('GET', {}), all).ok, false);
89
+ });
90
+
91
+ test('pathPermitted: at a root, under one, and never through ..', () => {
92
+ const roots = ['/Users/me/Projects', '/Users/me/work'];
93
+ assert.equal(pathPermitted('/Users/me/Projects', roots), true);
94
+ assert.equal(pathPermitted('/Users/me/Projects/foreman/src', roots), true);
95
+ assert.equal(pathPermitted('/Users/me/work', roots), true);
96
+ assert.equal(pathPermitted('/etc', roots), false);
97
+ assert.equal(pathPermitted('/Users/me', roots), false);
98
+ // A neighbour that merely shares a prefix is outside.
99
+ assert.equal(pathPermitted('/Users/me/Projects-backup', roots), false);
100
+ // Traversal is resolved before it is judged.
101
+ assert.equal(pathPermitted('/Users/me/Projects/../../../etc/passwd', roots), false);
102
+ assert.equal(pathPermitted('/Users/me/Projects/foreman/../other', roots), true);
103
+ assert.equal(pathPermitted('/Users/me/Projects/foreman', []), false);
104
+ });