@ahmedalbarghouti/farq 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -20
- package/dist/index.js +57 -42
- package/dist/open-pr.d.ts +16 -1
- package/dist/open-pr.js +128 -37
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +38 -0
- package/dist/render/pr.d.ts +6 -0
- package/dist/render/pr.js +33 -8
- package/dist/template.d.ts +6 -0
- package/dist/template.js +43 -18
- package/dist/tools.d.ts +21 -0
- package/dist/tools.js +95 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/lines.d.ts +3 -0
- package/dist/ui/lines.js +48 -0
- package/dist/ui/logger.d.ts +12 -0
- package/dist/ui/logger.js +20 -0
- package/dist/ui/spinner.d.ts +7 -0
- package/dist/ui/spinner.js +57 -0
- package/dist/ui/theme.d.ts +6 -0
- package/dist/ui/theme.js +19 -0
- package/dist/visual/chrome.js +35 -17
- package/dist/visual/cluster.d.ts +23 -0
- package/dist/visual/cluster.js +180 -0
- package/dist/visual/compose.d.ts +2 -0
- package/dist/visual/compose.js +8 -5
- package/dist/visual/diagram.d.ts +1 -0
- package/dist/visual/diagram.js +6 -2
- package/dist/visual/mockup.d.ts +2 -0
- package/dist/visual/mockup.js +9 -5
- package/dist/visual/pipeline.d.ts +5 -0
- package/dist/visual/pipeline.js +156 -69
- package/dist/visual/viewport.d.ts +16 -0
- package/dist/visual/viewport.js +19 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
8
|
+
npx @ahmedalbarghouti/farq@0.0.3 pr # title + markdown body (+ image when feasible)
|
|
9
9
|
npx farq slack # Slack mrkdwn daily update
|
|
10
10
|
npx farq json # structured JSON
|
|
11
11
|
npx farq pr --open # fill PR template + create with gh
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
> **0.0.
|
|
14
|
+
> **0.0.3** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -23,10 +23,10 @@ farq --help
|
|
|
23
23
|
Or run without installing:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
26
|
+
npx @ahmedalbarghouti/farq@0.0.3 --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Generated images land in a **user cache directory outside the repo** by default (no `.gitignore` change needed). Use `--out .farq` if you want them in-tree.
|
|
30
30
|
|
|
31
31
|
## Requirements
|
|
32
32
|
|
|
@@ -76,7 +76,7 @@ farq pr --open
|
|
|
76
76
|
|
|
77
77
|
1. Reads `.github` PR template if present and fills known sections
|
|
78
78
|
2. Infers title style from recent merged PR titles when `gh` works
|
|
79
|
-
3. Best-effort uploads
|
|
79
|
+
3. Best-effort uploads composed image(s) as prerelease assets and embeds the URL(s)
|
|
80
80
|
4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
|
|
81
81
|
|
|
82
82
|
On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
|
|
@@ -91,11 +91,11 @@ Validated change summary plus an `images` array of produced file paths.
|
|
|
91
91
|
|
|
92
92
|
## Images (honesty policy)
|
|
93
93
|
|
|
94
|
-
- Visuals come from the diff only. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram.
|
|
94
|
+
- Visuals come from the diff only. A cheap model groups summary items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate). File-overlap is the fallback if that grouping fails. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. Screenshots are capped at **1280×720** so content must fit the frame (no tall cut-off pages). If a topic is infeasible, it is skipped (still exit 0).
|
|
95
95
|
- Generated compositions include a small **generated preview** badge.
|
|
96
96
|
- Diagrams stay conceptual — no code dumps.
|
|
97
97
|
- Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
|
|
98
|
-
- A local
|
|
98
|
+
- A local image path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload (and rewrites stdout to the hosted URL).
|
|
99
99
|
|
|
100
100
|
## Config
|
|
101
101
|
|
|
@@ -126,7 +126,7 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
126
126
|
| `-t, --tone` | `technical` \| `client` |
|
|
127
127
|
| `--before` / `--after` | manual screenshots (skips generation) |
|
|
128
128
|
| `--no-images` | skip visuals |
|
|
129
|
-
| `-o, --out` | image output dir (default
|
|
129
|
+
| `-o, --out` | image output dir (default: OS user cache, outside the repo) |
|
|
130
130
|
| `--model-cheap` | cheap model id for visuals |
|
|
131
131
|
| `-v, --verbose` | verbose logs (incl. `feasible: false` reasons) |
|
|
132
132
|
| `--open` | (`pr` only) create PR with `gh` |
|
|
@@ -141,22 +141,12 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
141
141
|
|
|
142
142
|
Progress → **stderr**. Artifact → **stdout**.
|
|
143
143
|
|
|
144
|
-
## Publishing notes (maintainers)
|
|
145
|
-
|
|
146
|
-
Tag-triggered publish via GitHub Actions (npm provenance):
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
git tag v0.0.1
|
|
150
|
-
git push origin v0.0.1
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Requires repo secret `NPM_TOKEN`. Package name: `farq`.
|
|
154
|
-
|
|
155
144
|
## Repository ops
|
|
156
145
|
|
|
157
146
|
- **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
|
|
158
147
|
- **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
|
|
159
|
-
- **Releases** are tag-triggered: create `v0.0.
|
|
148
|
+
- **Releases** are tag-triggered: create `v0.0.3` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
|
|
149
|
+
- **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
|
|
160
150
|
|
|
161
151
|
## Roadmap
|
|
162
152
|
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { gatherDiff, NoChangesError, GitError } from "./git.js";
|
|
3
|
+
import { loadConfig, mergeConfig, } from "./config.js";
|
|
4
|
+
import { gatherDiff } from "./git.js";
|
|
6
5
|
import { resolveProvider } from "./providers/index.js";
|
|
7
|
-
import { summarize
|
|
6
|
+
import { summarize } from "./summarize.js";
|
|
8
7
|
import { inferTitleConvention } from "./title.js";
|
|
9
8
|
import { fetchRecentPrTitles, openPullRequest } from "./open-pr.js";
|
|
10
9
|
import { runVisualPipeline } from "./visual/pipeline.js";
|
|
@@ -12,8 +11,11 @@ import { ChromeError } from "./visual/chrome.js";
|
|
|
12
11
|
import { renderPr } from "./render/pr.js";
|
|
13
12
|
import { renderSlack } from "./render/slack.js";
|
|
14
13
|
import { renderJson } from "./render/json.js";
|
|
14
|
+
import { createUi } from "./ui/index.js";
|
|
15
|
+
import { defaultOutDir, displayImageRef } from "./paths.js";
|
|
15
16
|
async function run(type, opts) {
|
|
16
17
|
const cwd = process.cwd();
|
|
18
|
+
const ui = createUi();
|
|
17
19
|
const fileConfig = loadConfig({ cwd });
|
|
18
20
|
const flagConfig = {
|
|
19
21
|
provider: opts.provider,
|
|
@@ -26,34 +28,30 @@ async function run(type, opts) {
|
|
|
26
28
|
: undefined,
|
|
27
29
|
};
|
|
28
30
|
const config = mergeConfig(fileConfig, flagConfig);
|
|
29
|
-
const log = (msg) => {
|
|
30
|
-
console.error(msg);
|
|
31
|
-
};
|
|
32
31
|
let provider;
|
|
33
32
|
try {
|
|
34
33
|
provider = await resolveProvider({
|
|
35
34
|
flag: config.provider,
|
|
36
35
|
config,
|
|
37
|
-
log,
|
|
36
|
+
log: (msg) => ui.note(msg),
|
|
38
37
|
});
|
|
39
38
|
}
|
|
40
39
|
catch (err) {
|
|
41
|
-
|
|
40
|
+
ui.error(err instanceof Error ? err.message : String(err));
|
|
42
41
|
return 1;
|
|
43
42
|
}
|
|
44
43
|
const tone = config.tone ?? "technical";
|
|
45
|
-
const
|
|
44
|
+
const noImages = opts.noImages === true || opts.images === false;
|
|
45
|
+
const imagesEnabled = type === "pr" ? !noImages : false;
|
|
46
46
|
let diff;
|
|
47
|
+
const diffSpin = ui.stage("diff");
|
|
47
48
|
try {
|
|
48
|
-
log("farq: gathering diff…");
|
|
49
49
|
diff = await gatherDiff({ cwd, range: opts.range });
|
|
50
|
+
diffSpin.succeed("diff ready");
|
|
50
51
|
}
|
|
51
52
|
catch (err) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return 1;
|
|
55
|
-
}
|
|
56
|
-
log(err instanceof Error ? err.message : String(err));
|
|
53
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
54
|
+
diffSpin.fail(msg);
|
|
57
55
|
return 1;
|
|
58
56
|
}
|
|
59
57
|
let titleBlurb = "";
|
|
@@ -66,8 +64,8 @@ async function run(type, opts) {
|
|
|
66
64
|
// optional
|
|
67
65
|
}
|
|
68
66
|
}
|
|
69
|
-
log(`farq: summarizing with ${provider.name}…`);
|
|
70
67
|
let summary;
|
|
68
|
+
const sumSpin = ui.stage("summarize", provider.name);
|
|
71
69
|
try {
|
|
72
70
|
summary = await summarize({
|
|
73
71
|
provider,
|
|
@@ -75,13 +73,11 @@ async function run(type, opts) {
|
|
|
75
73
|
tone,
|
|
76
74
|
titleConventionBlurb: titleBlurb || undefined,
|
|
77
75
|
});
|
|
76
|
+
sumSpin.succeed(`summarized with ${provider.name}`);
|
|
78
77
|
}
|
|
79
78
|
catch (err) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return 2;
|
|
83
|
-
}
|
|
84
|
-
log(err instanceof Error ? err.message : String(err));
|
|
79
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
80
|
+
sumSpin.fail(msg);
|
|
85
81
|
return 2;
|
|
86
82
|
}
|
|
87
83
|
const cheapModel = provider.name === "claude"
|
|
@@ -91,48 +87,60 @@ async function run(type, opts) {
|
|
|
91
87
|
: undefined;
|
|
92
88
|
let imagePath = null;
|
|
93
89
|
let images = [];
|
|
90
|
+
let imageMeta = [];
|
|
94
91
|
if (imagesEnabled || (opts.before && opts.after)) {
|
|
95
|
-
|
|
92
|
+
const visSpin = ui.stage("visual", provider.name);
|
|
96
93
|
try {
|
|
97
94
|
const visual = await runVisualPipeline({
|
|
98
95
|
cwd,
|
|
99
|
-
outDir: opts.out ??
|
|
96
|
+
outDir: opts.out ?? defaultOutDir(cwd),
|
|
100
97
|
summary,
|
|
101
98
|
diff,
|
|
102
99
|
provider,
|
|
103
100
|
modelCheap: cheapModel,
|
|
104
|
-
noImages:
|
|
101
|
+
noImages: noImages && !(opts.before && opts.after),
|
|
105
102
|
before: opts.before,
|
|
106
103
|
after: opts.after,
|
|
107
104
|
verbose: opts.verbose,
|
|
108
|
-
log: opts.verbose ?
|
|
105
|
+
log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
|
|
109
106
|
});
|
|
110
107
|
imagePath = visual.imagePath;
|
|
111
108
|
images = visual.images;
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
imageMeta = visual.imageMeta;
|
|
110
|
+
if (visual.warning) {
|
|
111
|
+
visSpin.succeed("visuals skipped");
|
|
112
|
+
ui.note(visual.warning);
|
|
113
|
+
}
|
|
114
|
+
else if (imagePath) {
|
|
115
|
+
visSpin.succeed("visuals ready");
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
visSpin.succeed("no visual (ok)");
|
|
119
|
+
}
|
|
114
120
|
}
|
|
115
121
|
catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
123
|
if (err instanceof ChromeError && opts.before && opts.after) {
|
|
117
|
-
|
|
124
|
+
visSpin.fail(msg);
|
|
118
125
|
return 1;
|
|
119
126
|
}
|
|
120
|
-
|
|
127
|
+
visSpin.succeed("visuals skipped");
|
|
128
|
+
ui.note(`${msg} (continuing without image)`);
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
|
-
const
|
|
131
|
+
const prImages = (imageMeta.length > 0 ? imageMeta : images.map((p) => ({ path: p, title: "before / after" }))).map((img) => ({ path: displayImageRef(cwd, img.path), title: img.title }));
|
|
124
132
|
let artifact = "";
|
|
125
133
|
if (type === "pr") {
|
|
126
|
-
artifact = renderPr({ summary,
|
|
134
|
+
artifact = renderPr({ summary, images: prImages });
|
|
127
135
|
}
|
|
128
136
|
else if (type === "slack") {
|
|
129
137
|
artifact = renderSlack(summary);
|
|
130
138
|
}
|
|
131
139
|
else {
|
|
132
|
-
artifact = renderJson(summary, images.map((p) =>
|
|
140
|
+
artifact = renderJson(summary, images.map((p) => displayImageRef(cwd, p)));
|
|
133
141
|
}
|
|
134
142
|
if (type === "pr" && opts.open) {
|
|
135
|
-
|
|
143
|
+
const openSpin = ui.stage("open");
|
|
136
144
|
try {
|
|
137
145
|
const result = await openPullRequest({
|
|
138
146
|
cwd,
|
|
@@ -141,18 +149,24 @@ async function run(type, opts) {
|
|
|
141
149
|
? artifact.split("\n\n").slice(1).join("\n\n")
|
|
142
150
|
: artifact,
|
|
143
151
|
imagePath,
|
|
152
|
+
imagePaths: images,
|
|
153
|
+
imageTitles: imageMeta.map((m) => m.title),
|
|
144
154
|
});
|
|
145
155
|
if (result.skipped) {
|
|
146
|
-
|
|
156
|
+
openSpin.succeed(result.reason);
|
|
147
157
|
}
|
|
148
158
|
else {
|
|
149
|
-
|
|
159
|
+
openSpin.succeed(`PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
|
|
150
160
|
if (result.warning)
|
|
151
|
-
|
|
161
|
+
ui.note(result.warning);
|
|
162
|
+
// Match GitHub body (hosted image URL, no local-path note).
|
|
163
|
+
artifact = `${result.title}\n\n${result.body}`.replace(/\n{3,}/g, "\n\n");
|
|
164
|
+
if (!artifact.endsWith("\n"))
|
|
165
|
+
artifact += "\n";
|
|
152
166
|
}
|
|
153
167
|
}
|
|
154
168
|
catch (err) {
|
|
155
|
-
|
|
169
|
+
openSpin.fail(err instanceof Error ? err.message : String(err));
|
|
156
170
|
return 1;
|
|
157
171
|
}
|
|
158
172
|
}
|
|
@@ -167,7 +181,7 @@ function addShared(cmd) {
|
|
|
167
181
|
.option("--before <path>", "manual before screenshot")
|
|
168
182
|
.option("--after <path>", "manual after screenshot")
|
|
169
183
|
.option("--no-images", "skip image generation/composition")
|
|
170
|
-
.option("-o, --out <dir>", "output dir for images
|
|
184
|
+
.option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
|
|
171
185
|
.option("--model-cheap <id>", "model for visual generation")
|
|
172
186
|
.option("-v, --verbose", "verbose logging", false);
|
|
173
187
|
}
|
|
@@ -176,11 +190,11 @@ async function main() {
|
|
|
176
190
|
program
|
|
177
191
|
.name("farq")
|
|
178
192
|
.description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
|
|
179
|
-
.version("0.0.
|
|
193
|
+
.version("0.0.3");
|
|
180
194
|
addShared(program
|
|
181
195
|
.command("pr", { isDefault: true })
|
|
182
196
|
.description("PR title + body markdown")
|
|
183
|
-
.option("--open", "create a GitHub PR with gh (template-aware)", false)
|
|
197
|
+
.option("--open", "create or update a GitHub PR with gh (template-aware)", false)
|
|
184
198
|
.action(async (opts) => {
|
|
185
199
|
process.exitCode = await run("pr", opts);
|
|
186
200
|
}));
|
|
@@ -199,6 +213,7 @@ async function main() {
|
|
|
199
213
|
await program.parseAsync(process.argv);
|
|
200
214
|
}
|
|
201
215
|
main().catch((err) => {
|
|
202
|
-
|
|
216
|
+
const ui = createUi();
|
|
217
|
+
ui.error(err instanceof Error ? err.message : String(err));
|
|
203
218
|
process.exitCode = 1;
|
|
204
219
|
});
|
package/dist/open-pr.d.ts
CHANGED
|
@@ -7,7 +7,13 @@ export type OpenPrResult = {
|
|
|
7
7
|
action: "created" | "updated";
|
|
8
8
|
url?: string;
|
|
9
9
|
warning?: string;
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
imageUrl?: string | null;
|
|
13
|
+
imageUrls?: string[];
|
|
10
14
|
};
|
|
15
|
+
/** Prerelease tag used to host a branch's composed PNG. */
|
|
16
|
+
export declare function assetTagForBranch(branch: string): string;
|
|
11
17
|
export declare function resolveDefaultBranch(cwd: string): Promise<string>;
|
|
12
18
|
export declare function currentBranch(cwd: string): Promise<string>;
|
|
13
19
|
export declare function fetchRecentPrTitles(cwd: string, limit?: number): Promise<string[]>;
|
|
@@ -21,6 +27,15 @@ export declare function openPullRequest(options: {
|
|
|
21
27
|
summary: ChangeSummary;
|
|
22
28
|
bodyMarkdown: string;
|
|
23
29
|
imagePath?: string | null;
|
|
30
|
+
imagePaths?: string[];
|
|
31
|
+
imageTitles?: string[];
|
|
24
32
|
}): Promise<OpenPrResult>;
|
|
25
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
35
|
+
* Always keeps `keepTag` (the just-uploaded asset).
|
|
36
|
+
*/
|
|
37
|
+
export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
|
|
38
|
+
/** Best-effort: prerelease asset URL for one PNG. */
|
|
26
39
|
export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
|
|
40
|
+
/** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
|
|
41
|
+
export declare function uploadPrImages(cwd: string, imagePaths: string[], branch: string): Promise<string[]>;
|
package/dist/open-pr.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { execa } from "execa";
|
|
5
5
|
import { fillPrTemplate, findPrTemplate } from "./template.js";
|
|
6
|
+
import { resolveGh } from "./tools.js";
|
|
6
7
|
const GH_TIMEOUT = 60_000;
|
|
8
|
+
/** Prerelease tag used to host a branch's composed PNG. */
|
|
9
|
+
export function assetTagForBranch(branch) {
|
|
10
|
+
return `farq-assets-${branch.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}`;
|
|
11
|
+
}
|
|
12
|
+
async function gh(cwd, args, options = {}) {
|
|
13
|
+
return execa(resolveGh(), args, {
|
|
14
|
+
cwd,
|
|
15
|
+
timeout: GH_TIMEOUT,
|
|
16
|
+
reject: options.reject ?? true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
7
19
|
export async function resolveDefaultBranch(cwd) {
|
|
8
20
|
try {
|
|
9
|
-
const { stdout } = await
|
|
21
|
+
const { stdout } = await gh(cwd, [
|
|
10
22
|
"repo",
|
|
11
23
|
"view",
|
|
12
24
|
"--json",
|
|
13
25
|
"defaultBranchRef",
|
|
14
26
|
"--jq",
|
|
15
27
|
".defaultBranchRef.name",
|
|
16
|
-
]
|
|
28
|
+
]);
|
|
17
29
|
if (stdout.trim())
|
|
18
30
|
return stdout.trim();
|
|
19
31
|
}
|
|
@@ -45,7 +57,7 @@ export async function currentBranch(cwd) {
|
|
|
45
57
|
}
|
|
46
58
|
export async function fetchRecentPrTitles(cwd, limit = 20) {
|
|
47
59
|
try {
|
|
48
|
-
const { stdout } = await
|
|
60
|
+
const { stdout } = await gh(cwd, [
|
|
49
61
|
"pr",
|
|
50
62
|
"list",
|
|
51
63
|
"--state",
|
|
@@ -56,7 +68,7 @@ export async function fetchRecentPrTitles(cwd, limit = 20) {
|
|
|
56
68
|
"title",
|
|
57
69
|
"--jq",
|
|
58
70
|
".[].title",
|
|
59
|
-
]
|
|
71
|
+
]);
|
|
60
72
|
return stdout
|
|
61
73
|
.split("\n")
|
|
62
74
|
.map((s) => s.trim())
|
|
@@ -69,7 +81,7 @@ export async function fetchRecentPrTitles(cwd, limit = 20) {
|
|
|
69
81
|
/** Existing open PR for the current branch, if any. */
|
|
70
82
|
export async function findExistingPr(cwd) {
|
|
71
83
|
try {
|
|
72
|
-
const { stdout, exitCode } = await
|
|
84
|
+
const { stdout, exitCode } = await gh(cwd, ["pr", "view", "--json", "number,url"], { reject: false });
|
|
73
85
|
if (exitCode !== 0 || !stdout.trim())
|
|
74
86
|
return null;
|
|
75
87
|
const parsed = JSON.parse(stdout);
|
|
@@ -92,14 +104,19 @@ export async function openPullRequest(options) {
|
|
|
92
104
|
reason: `Already on ${defaultBranch} — skipping PR create`,
|
|
93
105
|
};
|
|
94
106
|
}
|
|
95
|
-
|
|
107
|
+
const paths = options.imagePaths && options.imagePaths.length > 0
|
|
108
|
+
? options.imagePaths
|
|
109
|
+
: options.imagePath
|
|
110
|
+
? [options.imagePath]
|
|
111
|
+
: [];
|
|
112
|
+
let imageUrls = [];
|
|
96
113
|
let warning;
|
|
97
|
-
if (
|
|
114
|
+
if (paths.length > 0) {
|
|
98
115
|
try {
|
|
99
|
-
|
|
116
|
+
imageUrls = await uploadPrImages(cwd, paths, branch);
|
|
100
117
|
}
|
|
101
118
|
catch (err) {
|
|
102
|
-
warning = `Image upload failed — attach manually: ${
|
|
119
|
+
warning = `Image upload failed — attach manually: ${paths.join(", ")} (${err instanceof Error ? err.message : String(err)})`;
|
|
103
120
|
}
|
|
104
121
|
}
|
|
105
122
|
const template = findPrTemplate(cwd);
|
|
@@ -107,7 +124,11 @@ export async function openPullRequest(options) {
|
|
|
107
124
|
template,
|
|
108
125
|
summary: options.summary,
|
|
109
126
|
bodyMarkdown: options.bodyMarkdown,
|
|
110
|
-
imageUrl,
|
|
127
|
+
imageUrl: imageUrls[0] ?? null,
|
|
128
|
+
images: imageUrls.map((url, i) => ({
|
|
129
|
+
url,
|
|
130
|
+
title: options.imageTitles?.[i],
|
|
131
|
+
})),
|
|
111
132
|
});
|
|
112
133
|
const dir = mkdtempSync(join(tmpdir(), "farq-pr-"));
|
|
113
134
|
const bodyFile = join(dir, "body.md");
|
|
@@ -115,7 +136,7 @@ export async function openPullRequest(options) {
|
|
|
115
136
|
try {
|
|
116
137
|
const existing = await findExistingPr(cwd);
|
|
117
138
|
if (existing) {
|
|
118
|
-
await
|
|
139
|
+
await gh(cwd, [
|
|
119
140
|
"pr",
|
|
120
141
|
"edit",
|
|
121
142
|
String(existing.number),
|
|
@@ -123,13 +144,9 @@ export async function openPullRequest(options) {
|
|
|
123
144
|
title,
|
|
124
145
|
"--body-file",
|
|
125
146
|
bodyFile,
|
|
126
|
-
]
|
|
147
|
+
]);
|
|
127
148
|
try {
|
|
128
|
-
await
|
|
129
|
-
cwd,
|
|
130
|
-
timeout: GH_TIMEOUT,
|
|
131
|
-
reject: false,
|
|
132
|
-
});
|
|
149
|
+
await gh(cwd, ["pr", "view", "--web"], { reject: false });
|
|
133
150
|
}
|
|
134
151
|
catch {
|
|
135
152
|
// non-fatal
|
|
@@ -139,15 +156,22 @@ export async function openPullRequest(options) {
|
|
|
139
156
|
action: "updated",
|
|
140
157
|
url: existing.url,
|
|
141
158
|
warning,
|
|
159
|
+
title,
|
|
160
|
+
body,
|
|
161
|
+
imageUrl: imageUrls[0] ?? null,
|
|
162
|
+
imageUrls,
|
|
142
163
|
};
|
|
143
164
|
}
|
|
144
|
-
const created = await
|
|
165
|
+
const created = await gh(cwd, [
|
|
166
|
+
"pr",
|
|
167
|
+
"create",
|
|
168
|
+
"--title",
|
|
169
|
+
title,
|
|
170
|
+
"--body-file",
|
|
171
|
+
bodyFile,
|
|
172
|
+
]);
|
|
145
173
|
try {
|
|
146
|
-
await
|
|
147
|
-
cwd,
|
|
148
|
-
timeout: GH_TIMEOUT,
|
|
149
|
-
reject: false,
|
|
150
|
-
});
|
|
174
|
+
await gh(cwd, ["pr", "view", "--web"], { reject: false });
|
|
151
175
|
}
|
|
152
176
|
catch {
|
|
153
177
|
// non-fatal
|
|
@@ -157,6 +181,10 @@ export async function openPullRequest(options) {
|
|
|
157
181
|
action: "created",
|
|
158
182
|
url: created.stdout?.trim() || undefined,
|
|
159
183
|
warning,
|
|
184
|
+
title,
|
|
185
|
+
body,
|
|
186
|
+
imageUrl: imageUrls[0] ?? null,
|
|
187
|
+
imageUrls,
|
|
160
188
|
};
|
|
161
189
|
}
|
|
162
190
|
catch (err) {
|
|
@@ -167,33 +195,96 @@ export async function openPullRequest(options) {
|
|
|
167
195
|
rmSync(dir, { recursive: true, force: true });
|
|
168
196
|
}
|
|
169
197
|
}
|
|
170
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
200
|
+
* Always keeps `keepTag` (the just-uploaded asset).
|
|
201
|
+
*/
|
|
202
|
+
export async function pruneOrphanedFarqAssets(cwd, keepTag) {
|
|
203
|
+
const deleted = [];
|
|
204
|
+
try {
|
|
205
|
+
const { stdout: releasesOut, exitCode: relCode } = await gh(cwd, ["release", "list", "--limit", "100", "--json", "tagName,isPrerelease"], { reject: false });
|
|
206
|
+
if (relCode !== 0 || !releasesOut.trim())
|
|
207
|
+
return deleted;
|
|
208
|
+
const releases = JSON.parse(releasesOut);
|
|
209
|
+
const candidates = releases.filter((r) => r.isPrerelease &&
|
|
210
|
+
r.tagName.startsWith("farq-assets-") &&
|
|
211
|
+
r.tagName !== keepTag);
|
|
212
|
+
if (candidates.length === 0)
|
|
213
|
+
return deleted;
|
|
214
|
+
const { stdout: prsOut, exitCode: prCode } = await gh(cwd, ["pr", "list", "--state", "open", "--limit", "100", "--json", "headRefName"], { reject: false });
|
|
215
|
+
const openTags = new Set();
|
|
216
|
+
if (prCode === 0 && prsOut.trim()) {
|
|
217
|
+
const prs = JSON.parse(prsOut);
|
|
218
|
+
for (const pr of prs) {
|
|
219
|
+
openTags.add(assetTagForBranch(pr.headRefName));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const rel of candidates) {
|
|
223
|
+
if (openTags.has(rel.tagName))
|
|
224
|
+
continue;
|
|
225
|
+
try {
|
|
226
|
+
await gh(cwd, ["release", "delete", rel.tagName, "--yes", "--cleanup-tag"], { reject: false });
|
|
227
|
+
deleted.push(rel.tagName);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// best-effort
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// best-effort — never fail --open for prune
|
|
236
|
+
}
|
|
237
|
+
return deleted;
|
|
238
|
+
}
|
|
239
|
+
/** Best-effort: prerelease asset URL for one PNG. */
|
|
171
240
|
export async function uploadPrImage(cwd, imagePath, branch) {
|
|
172
|
-
const
|
|
241
|
+
const urls = await uploadPrImages(cwd, [imagePath], branch);
|
|
242
|
+
const url = urls[0];
|
|
243
|
+
if (!url)
|
|
244
|
+
throw new Error("no asset URL returned");
|
|
245
|
+
return url;
|
|
246
|
+
}
|
|
247
|
+
/** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
|
|
248
|
+
export async function uploadPrImages(cwd, imagePaths, branch) {
|
|
249
|
+
if (imagePaths.length === 0)
|
|
250
|
+
return [];
|
|
251
|
+
const tag = assetTagForBranch(branch);
|
|
173
252
|
try {
|
|
174
|
-
await
|
|
175
|
-
cwd,
|
|
176
|
-
timeout: GH_TIMEOUT,
|
|
253
|
+
await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
|
|
177
254
|
reject: false,
|
|
178
255
|
});
|
|
179
256
|
}
|
|
180
257
|
catch {
|
|
181
258
|
// ok if missing
|
|
182
259
|
}
|
|
183
|
-
await
|
|
260
|
+
await gh(cwd, [
|
|
184
261
|
"release",
|
|
185
262
|
"create",
|
|
186
263
|
tag,
|
|
187
|
-
|
|
264
|
+
...imagePaths,
|
|
188
265
|
"--title",
|
|
189
266
|
`farq assets (${branch})`,
|
|
190
267
|
"--notes",
|
|
191
268
|
"Auto-uploaded by farq for PR description embedding.",
|
|
192
269
|
"--prerelease",
|
|
193
|
-
]
|
|
194
|
-
const { stdout } = await
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
270
|
+
]);
|
|
271
|
+
const { stdout } = await gh(cwd, [
|
|
272
|
+
"release",
|
|
273
|
+
"view",
|
|
274
|
+
tag,
|
|
275
|
+
"--json",
|
|
276
|
+
"assets",
|
|
277
|
+
]);
|
|
278
|
+
const parsed = JSON.parse(stdout);
|
|
279
|
+
const assets = parsed.assets ?? [];
|
|
280
|
+
const urls = imagePaths.map((p) => {
|
|
281
|
+
const name = basename(p);
|
|
282
|
+
const hit = assets.find((a) => a.name === name);
|
|
283
|
+
if (!hit?.url) {
|
|
284
|
+
throw new Error(`no asset URL for ${name}`);
|
|
285
|
+
}
|
|
286
|
+
return hit.url;
|
|
287
|
+
});
|
|
288
|
+
await pruneOrphanedFarqAssets(cwd, tag);
|
|
289
|
+
return urls;
|
|
199
290
|
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default image output dir — outside the repo so generated files
|
|
3
|
+
* never require a `.gitignore` entry.
|
|
4
|
+
*
|
|
5
|
+
* Override with `FARQ_CACHE_DIR` (root) or `--out <dir>`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function defaultOutDir(cwd: string, env?: NodeJS.ProcessEnv): string;
|
|
8
|
+
export declare function cacheRoot(env?: NodeJS.ProcessEnv): string;
|
|
9
|
+
/**
|
|
10
|
+
* Path shown in markdown: repo-relative when inside cwd, else absolute.
|
|
11
|
+
*/
|
|
12
|
+
export declare function displayImageRef(cwd: string, imagePath: string): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Default image output dir — outside the repo so generated files
|
|
6
|
+
* never require a `.gitignore` entry.
|
|
7
|
+
*
|
|
8
|
+
* Override with `FARQ_CACHE_DIR` (root) or `--out <dir>`.
|
|
9
|
+
*/
|
|
10
|
+
export function defaultOutDir(cwd, env = process.env) {
|
|
11
|
+
const abs = resolve(cwd);
|
|
12
|
+
const hash = createHash("sha256").update(abs).digest("hex").slice(0, 16);
|
|
13
|
+
return join(cacheRoot(env), hash);
|
|
14
|
+
}
|
|
15
|
+
export function cacheRoot(env = process.env) {
|
|
16
|
+
if (env.FARQ_CACHE_DIR)
|
|
17
|
+
return resolve(env.FARQ_CACHE_DIR);
|
|
18
|
+
if (process.platform === "win32") {
|
|
19
|
+
const local = env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
|
|
20
|
+
return join(local, "farq", "cache");
|
|
21
|
+
}
|
|
22
|
+
if (process.platform === "darwin") {
|
|
23
|
+
return join(homedir(), "Library", "Caches", "farq");
|
|
24
|
+
}
|
|
25
|
+
const xdg = env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
|
|
26
|
+
return join(xdg, "farq");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Path shown in markdown: repo-relative when inside cwd, else absolute.
|
|
30
|
+
*/
|
|
31
|
+
export function displayImageRef(cwd, imagePath) {
|
|
32
|
+
const abs = resolve(imagePath);
|
|
33
|
+
const rel = relative(resolve(cwd), abs);
|
|
34
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) {
|
|
35
|
+
return rel.replaceAll("\\", "/");
|
|
36
|
+
}
|
|
37
|
+
return abs;
|
|
38
|
+
}
|