@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 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.1 pr # title + markdown body (+ image when feasible)
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.1** — first public release. Expect rough edges; the CLI surface (`pr` / `slack` / `json`) is the stable bit.
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.1 --help
26
+ npx @ahmedalbarghouti/farq@0.0.3 --help
27
27
  ```
28
28
 
29
- Add `.farq/` to your repo ignore file (generated HTML/PNG land there).
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 the composed image as a prerelease asset and embeds the URL
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. If the model cannot produce a faithful preview, **no image** is produced (still exit 0).
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 `.farq/before-after.png` path will not render on GitHub until the file is attached or uploaded. `--open` tries a best-effort upload.
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 `.farq/`) |
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.1` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
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 { relative, resolve } from "node:path";
4
- import { loadConfig, mergeConfig } from "./config.js";
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, SummarizeError } from "./summarize.js";
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
- log(err instanceof Error ? err.message : String(err));
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 imagesEnabled = type === "pr" ? !opts.noImages : false;
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
- if (err instanceof NoChangesError || err instanceof GitError) {
53
- log(err.message);
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
- if (err instanceof SummarizeError) {
81
- log(err.message);
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
- log("farq: visual pipeline…");
92
+ const visSpin = ui.stage("visual", provider.name);
96
93
  try {
97
94
  const visual = await runVisualPipeline({
98
95
  cwd,
99
- outDir: opts.out ?? ".farq",
96
+ outDir: opts.out ?? defaultOutDir(cwd),
100
97
  summary,
101
98
  diff,
102
99
  provider,
103
100
  modelCheap: cheapModel,
104
- noImages: opts.noImages && !(opts.before && opts.after),
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 ? log : () => undefined,
105
+ log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
109
106
  });
110
107
  imagePath = visual.imagePath;
111
108
  images = visual.images;
112
- if (visual.warning)
113
- log(`farq: ${visual.warning}`);
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
- log(err.message);
124
+ visSpin.fail(msg);
118
125
  return 1;
119
126
  }
120
- log(`farq: ${err instanceof Error ? err.message : String(err)} (continuing without image)`);
127
+ visSpin.succeed("visuals skipped");
128
+ ui.note(`${msg} (continuing without image)`);
121
129
  }
122
130
  }
123
- const relImage = imagePath != null ? relative(cwd, resolve(imagePath)).replaceAll("\\", "/") : null;
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, imagePath: relImage });
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) => relative(cwd, p).replaceAll("\\", "/")));
140
+ artifact = renderJson(summary, images.map((p) => displayImageRef(cwd, p)));
133
141
  }
134
142
  if (type === "pr" && opts.open) {
135
- log("farq: opening PR…");
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
- log(`farq: ${result.reason}`);
156
+ openSpin.succeed(result.reason);
147
157
  }
148
158
  else {
149
- log(`farq: PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
159
+ openSpin.succeed(`PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
150
160
  if (result.warning)
151
- log(`farq: ${result.warning}`);
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
- log(err instanceof Error ? err.message : String(err));
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", ".farq")
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.1");
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
- console.error(err instanceof Error ? err.message : String(err));
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
- /** Best-effort: prerelease asset URL for the PNG. */
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 execa("gh", [
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
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
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 execa("gh", [
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
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
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 execa("gh", ["pr", "view", "--json", "number,url"], { cwd, timeout: GH_TIMEOUT, reject: false });
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
- let imageUrl = null;
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 (options.imagePath) {
114
+ if (paths.length > 0) {
98
115
  try {
99
- imageUrl = await uploadPrImage(cwd, options.imagePath, branch);
116
+ imageUrls = await uploadPrImages(cwd, paths, branch);
100
117
  }
101
118
  catch (err) {
102
- warning = `Image upload failed — attach manually: ${options.imagePath} (${err instanceof Error ? err.message : String(err)})`;
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 execa("gh", [
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
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
147
+ ]);
127
148
  try {
128
- await execa("gh", ["pr", "view", "--web"], {
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 execa("gh", ["pr", "create", "--title", title, "--body-file", bodyFile], { cwd, timeout: GH_TIMEOUT, reject: true });
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 execa("gh", ["pr", "view", "--web"], {
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
- /** Best-effort: prerelease asset URL for the PNG. */
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 tag = `farq-assets-${branch.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 40)}`;
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 execa("gh", ["release", "delete", tag, "--yes", "--cleanup-tag"], {
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 execa("gh", [
260
+ await gh(cwd, [
184
261
  "release",
185
262
  "create",
186
263
  tag,
187
- imagePath,
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
- ], { cwd, timeout: GH_TIMEOUT, reject: true });
194
- const { stdout } = await execa("gh", ["release", "view", tag, "--json", "assets", "--jq", ".assets[0].url"], { cwd, timeout: GH_TIMEOUT, reject: true });
195
- const url = stdout.trim();
196
- if (!url)
197
- throw new Error("no asset URL returned");
198
- return url;
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
  }
@@ -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
+ }