@ahmedalbarghouti/farq 0.0.1 → 0.0.2
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 +11 -10
- package/dist/index.js +52 -41
- package/dist/open-pr.d.ts +10 -0
- package/dist/open-pr.js +90 -25
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +38 -0
- package/dist/render/pr.js +5 -2
- package/dist/template.js +6 -2
- 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 +30 -15
- package/dist/visual/compose.js +2 -1
- package/dist/visual/pipeline.js +2 -1
- 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.2 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.2** — 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.2 --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
|
|
|
@@ -95,7 +95,7 @@ Validated change summary plus an `images` array of produced file paths.
|
|
|
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` |
|
|
@@ -146,17 +146,18 @@ Progress → **stderr**. Artifact → **stdout**.
|
|
|
146
146
|
Tag-triggered publish via GitHub Actions (npm provenance):
|
|
147
147
|
|
|
148
148
|
```bash
|
|
149
|
-
git tag v0.0.
|
|
150
|
-
git push origin v0.0.
|
|
149
|
+
git tag v0.0.2
|
|
150
|
+
git push origin v0.0.2
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
Requires repo secret `NPM_TOKEN`. Package name:
|
|
153
|
+
Requires repo secret `NPM_TOKEN`. Package name: `@ahmedalbarghouti/farq`.
|
|
154
154
|
|
|
155
155
|
## Repository ops
|
|
156
156
|
|
|
157
157
|
- **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
|
|
158
158
|
- **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
|
|
159
|
-
- **Releases** are tag-triggered: create `v0.0.
|
|
159
|
+
- **Releases** are tag-triggered: create `v0.0.2` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
|
|
160
|
+
- **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
|
|
160
161
|
|
|
161
162
|
## Roadmap
|
|
162
163
|
|
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"
|
|
@@ -92,35 +88,45 @@ async function run(type, opts) {
|
|
|
92
88
|
let imagePath = null;
|
|
93
89
|
let images = [];
|
|
94
90
|
if (imagesEnabled || (opts.before && opts.after)) {
|
|
95
|
-
|
|
91
|
+
const visSpin = ui.stage("visual", provider.name);
|
|
96
92
|
try {
|
|
97
93
|
const visual = await runVisualPipeline({
|
|
98
94
|
cwd,
|
|
99
|
-
outDir: opts.out ??
|
|
95
|
+
outDir: opts.out ?? defaultOutDir(cwd),
|
|
100
96
|
summary,
|
|
101
97
|
diff,
|
|
102
98
|
provider,
|
|
103
99
|
modelCheap: cheapModel,
|
|
104
|
-
noImages:
|
|
100
|
+
noImages: noImages && !(opts.before && opts.after),
|
|
105
101
|
before: opts.before,
|
|
106
102
|
after: opts.after,
|
|
107
103
|
verbose: opts.verbose,
|
|
108
|
-
log: opts.verbose ?
|
|
104
|
+
log: opts.verbose ? (msg) => ui.note(msg) : () => undefined,
|
|
109
105
|
});
|
|
110
106
|
imagePath = visual.imagePath;
|
|
111
107
|
images = visual.images;
|
|
112
|
-
if (visual.warning)
|
|
113
|
-
|
|
108
|
+
if (visual.warning) {
|
|
109
|
+
visSpin.succeed("visuals skipped");
|
|
110
|
+
ui.note(visual.warning);
|
|
111
|
+
}
|
|
112
|
+
else if (imagePath) {
|
|
113
|
+
visSpin.succeed("visuals ready");
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
visSpin.succeed("no visual (ok)");
|
|
117
|
+
}
|
|
114
118
|
}
|
|
115
119
|
catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
121
|
if (err instanceof ChromeError && opts.before && opts.after) {
|
|
117
|
-
|
|
122
|
+
visSpin.fail(msg);
|
|
118
123
|
return 1;
|
|
119
124
|
}
|
|
120
|
-
|
|
125
|
+
visSpin.succeed("visuals skipped");
|
|
126
|
+
ui.note(`${msg} (continuing without image)`);
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
|
-
const relImage = imagePath != null ?
|
|
129
|
+
const relImage = imagePath != null ? displayImageRef(cwd, imagePath) : null;
|
|
124
130
|
let artifact = "";
|
|
125
131
|
if (type === "pr") {
|
|
126
132
|
artifact = renderPr({ summary, imagePath: relImage });
|
|
@@ -129,10 +135,10 @@ async function run(type, opts) {
|
|
|
129
135
|
artifact = renderSlack(summary);
|
|
130
136
|
}
|
|
131
137
|
else {
|
|
132
|
-
artifact = renderJson(summary, images.map((p) =>
|
|
138
|
+
artifact = renderJson(summary, images.map((p) => displayImageRef(cwd, p)));
|
|
133
139
|
}
|
|
134
140
|
if (type === "pr" && opts.open) {
|
|
135
|
-
|
|
141
|
+
const openSpin = ui.stage("open");
|
|
136
142
|
try {
|
|
137
143
|
const result = await openPullRequest({
|
|
138
144
|
cwd,
|
|
@@ -143,16 +149,20 @@ async function run(type, opts) {
|
|
|
143
149
|
imagePath,
|
|
144
150
|
});
|
|
145
151
|
if (result.skipped) {
|
|
146
|
-
|
|
152
|
+
openSpin.succeed(result.reason);
|
|
147
153
|
}
|
|
148
154
|
else {
|
|
149
|
-
|
|
155
|
+
openSpin.succeed(`PR ${result.action}${result.url ? ` — ${result.url}` : ""}`);
|
|
150
156
|
if (result.warning)
|
|
151
|
-
|
|
157
|
+
ui.note(result.warning);
|
|
158
|
+
// Match GitHub body (hosted image URL, no local-path note).
|
|
159
|
+
artifact = `${result.title}\n\n${result.body}`.replace(/\n{3,}/g, "\n\n");
|
|
160
|
+
if (!artifact.endsWith("\n"))
|
|
161
|
+
artifact += "\n";
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
164
|
catch (err) {
|
|
155
|
-
|
|
165
|
+
openSpin.fail(err instanceof Error ? err.message : String(err));
|
|
156
166
|
return 1;
|
|
157
167
|
}
|
|
158
168
|
}
|
|
@@ -167,7 +177,7 @@ function addShared(cmd) {
|
|
|
167
177
|
.option("--before <path>", "manual before screenshot")
|
|
168
178
|
.option("--after <path>", "manual after screenshot")
|
|
169
179
|
.option("--no-images", "skip image generation/composition")
|
|
170
|
-
.option("-o, --out <dir>", "output dir for images
|
|
180
|
+
.option("-o, --out <dir>", "output dir for images (default: user cache, outside the repo)")
|
|
171
181
|
.option("--model-cheap <id>", "model for visual generation")
|
|
172
182
|
.option("-v, --verbose", "verbose logging", false);
|
|
173
183
|
}
|
|
@@ -176,11 +186,11 @@ async function main() {
|
|
|
176
186
|
program
|
|
177
187
|
.name("farq")
|
|
178
188
|
.description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
|
|
179
|
-
.version("0.0.
|
|
189
|
+
.version("0.0.2");
|
|
180
190
|
addShared(program
|
|
181
191
|
.command("pr", { isDefault: true })
|
|
182
192
|
.description("PR title + body markdown")
|
|
183
|
-
.option("--open", "create a GitHub PR with gh (template-aware)", false)
|
|
193
|
+
.option("--open", "create or update a GitHub PR with gh (template-aware)", false)
|
|
184
194
|
.action(async (opts) => {
|
|
185
195
|
process.exitCode = await run("pr", opts);
|
|
186
196
|
}));
|
|
@@ -199,6 +209,7 @@ async function main() {
|
|
|
199
209
|
await program.parseAsync(process.argv);
|
|
200
210
|
}
|
|
201
211
|
main().catch((err) => {
|
|
202
|
-
|
|
212
|
+
const ui = createUi();
|
|
213
|
+
ui.error(err instanceof Error ? err.message : String(err));
|
|
203
214
|
process.exitCode = 1;
|
|
204
215
|
});
|
package/dist/open-pr.d.ts
CHANGED
|
@@ -7,7 +7,12 @@ 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;
|
|
10
13
|
};
|
|
14
|
+
/** Prerelease tag used to host a branch's composed PNG. */
|
|
15
|
+
export declare function assetTagForBranch(branch: string): string;
|
|
11
16
|
export declare function resolveDefaultBranch(cwd: string): Promise<string>;
|
|
12
17
|
export declare function currentBranch(cwd: string): Promise<string>;
|
|
13
18
|
export declare function fetchRecentPrTitles(cwd: string, limit?: number): Promise<string[]>;
|
|
@@ -22,5 +27,10 @@ export declare function openPullRequest(options: {
|
|
|
22
27
|
bodyMarkdown: string;
|
|
23
28
|
imagePath?: string | null;
|
|
24
29
|
}): Promise<OpenPrResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
32
|
+
* Always keeps `keepTag` (the just-uploaded asset).
|
|
33
|
+
*/
|
|
34
|
+
export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
|
|
25
35
|
/** Best-effort: prerelease asset URL for the PNG. */
|
|
26
36
|
export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
|
package/dist/open-pr.js
CHANGED
|
@@ -3,17 +3,29 @@ import { 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);
|
|
@@ -115,7 +127,7 @@ export async function openPullRequest(options) {
|
|
|
115
127
|
try {
|
|
116
128
|
const existing = await findExistingPr(cwd);
|
|
117
129
|
if (existing) {
|
|
118
|
-
await
|
|
130
|
+
await gh(cwd, [
|
|
119
131
|
"pr",
|
|
120
132
|
"edit",
|
|
121
133
|
String(existing.number),
|
|
@@ -123,13 +135,9 @@ export async function openPullRequest(options) {
|
|
|
123
135
|
title,
|
|
124
136
|
"--body-file",
|
|
125
137
|
bodyFile,
|
|
126
|
-
]
|
|
138
|
+
]);
|
|
127
139
|
try {
|
|
128
|
-
await
|
|
129
|
-
cwd,
|
|
130
|
-
timeout: GH_TIMEOUT,
|
|
131
|
-
reject: false,
|
|
132
|
-
});
|
|
140
|
+
await gh(cwd, ["pr", "view", "--web"], { reject: false });
|
|
133
141
|
}
|
|
134
142
|
catch {
|
|
135
143
|
// non-fatal
|
|
@@ -139,15 +147,21 @@ export async function openPullRequest(options) {
|
|
|
139
147
|
action: "updated",
|
|
140
148
|
url: existing.url,
|
|
141
149
|
warning,
|
|
150
|
+
title,
|
|
151
|
+
body,
|
|
152
|
+
imageUrl,
|
|
142
153
|
};
|
|
143
154
|
}
|
|
144
|
-
const created = await
|
|
155
|
+
const created = await gh(cwd, [
|
|
156
|
+
"pr",
|
|
157
|
+
"create",
|
|
158
|
+
"--title",
|
|
159
|
+
title,
|
|
160
|
+
"--body-file",
|
|
161
|
+
bodyFile,
|
|
162
|
+
]);
|
|
145
163
|
try {
|
|
146
|
-
await
|
|
147
|
-
cwd,
|
|
148
|
-
timeout: GH_TIMEOUT,
|
|
149
|
-
reject: false,
|
|
150
|
-
});
|
|
164
|
+
await gh(cwd, ["pr", "view", "--web"], { reject: false });
|
|
151
165
|
}
|
|
152
166
|
catch {
|
|
153
167
|
// non-fatal
|
|
@@ -157,6 +171,9 @@ export async function openPullRequest(options) {
|
|
|
157
171
|
action: "created",
|
|
158
172
|
url: created.stdout?.trim() || undefined,
|
|
159
173
|
warning,
|
|
174
|
+
title,
|
|
175
|
+
body,
|
|
176
|
+
imageUrl,
|
|
160
177
|
};
|
|
161
178
|
}
|
|
162
179
|
catch (err) {
|
|
@@ -167,20 +184,59 @@ export async function openPullRequest(options) {
|
|
|
167
184
|
rmSync(dir, { recursive: true, force: true });
|
|
168
185
|
}
|
|
169
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
189
|
+
* Always keeps `keepTag` (the just-uploaded asset).
|
|
190
|
+
*/
|
|
191
|
+
export async function pruneOrphanedFarqAssets(cwd, keepTag) {
|
|
192
|
+
const deleted = [];
|
|
193
|
+
try {
|
|
194
|
+
const { stdout: releasesOut, exitCode: relCode } = await gh(cwd, ["release", "list", "--limit", "100", "--json", "tagName,isPrerelease"], { reject: false });
|
|
195
|
+
if (relCode !== 0 || !releasesOut.trim())
|
|
196
|
+
return deleted;
|
|
197
|
+
const releases = JSON.parse(releasesOut);
|
|
198
|
+
const candidates = releases.filter((r) => r.isPrerelease &&
|
|
199
|
+
r.tagName.startsWith("farq-assets-") &&
|
|
200
|
+
r.tagName !== keepTag);
|
|
201
|
+
if (candidates.length === 0)
|
|
202
|
+
return deleted;
|
|
203
|
+
const { stdout: prsOut, exitCode: prCode } = await gh(cwd, ["pr", "list", "--state", "open", "--limit", "100", "--json", "headRefName"], { reject: false });
|
|
204
|
+
const openTags = new Set();
|
|
205
|
+
if (prCode === 0 && prsOut.trim()) {
|
|
206
|
+
const prs = JSON.parse(prsOut);
|
|
207
|
+
for (const pr of prs) {
|
|
208
|
+
openTags.add(assetTagForBranch(pr.headRefName));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const rel of candidates) {
|
|
212
|
+
if (openTags.has(rel.tagName))
|
|
213
|
+
continue;
|
|
214
|
+
try {
|
|
215
|
+
await gh(cwd, ["release", "delete", rel.tagName, "--yes", "--cleanup-tag"], { reject: false });
|
|
216
|
+
deleted.push(rel.tagName);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// best-effort
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// best-effort — never fail --open for prune
|
|
225
|
+
}
|
|
226
|
+
return deleted;
|
|
227
|
+
}
|
|
170
228
|
/** Best-effort: prerelease asset URL for the PNG. */
|
|
171
229
|
export async function uploadPrImage(cwd, imagePath, branch) {
|
|
172
|
-
const tag =
|
|
230
|
+
const tag = assetTagForBranch(branch);
|
|
173
231
|
try {
|
|
174
|
-
await
|
|
175
|
-
cwd,
|
|
176
|
-
timeout: GH_TIMEOUT,
|
|
232
|
+
await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
|
|
177
233
|
reject: false,
|
|
178
234
|
});
|
|
179
235
|
}
|
|
180
236
|
catch {
|
|
181
237
|
// ok if missing
|
|
182
238
|
}
|
|
183
|
-
await
|
|
239
|
+
await gh(cwd, [
|
|
184
240
|
"release",
|
|
185
241
|
"create",
|
|
186
242
|
tag,
|
|
@@ -190,10 +246,19 @@ export async function uploadPrImage(cwd, imagePath, branch) {
|
|
|
190
246
|
"--notes",
|
|
191
247
|
"Auto-uploaded by farq for PR description embedding.",
|
|
192
248
|
"--prerelease",
|
|
193
|
-
]
|
|
194
|
-
const { stdout } = await
|
|
249
|
+
]);
|
|
250
|
+
const { stdout } = await gh(cwd, [
|
|
251
|
+
"release",
|
|
252
|
+
"view",
|
|
253
|
+
tag,
|
|
254
|
+
"--json",
|
|
255
|
+
"assets",
|
|
256
|
+
"--jq",
|
|
257
|
+
".assets[0].url",
|
|
258
|
+
]);
|
|
195
259
|
const url = stdout.trim();
|
|
196
260
|
if (!url)
|
|
197
261
|
throw new Error("no asset URL returned");
|
|
262
|
+
await pruneOrphanedFarqAssets(cwd, tag);
|
|
198
263
|
return url;
|
|
199
264
|
}
|
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
|
+
}
|
package/dist/render/pr.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { truncateTitle, GITHUB_TITLE_MAX } from "../title.js";
|
|
2
|
+
import { isHostedImageUrl, LOCAL_IMAGE_NOTE } from "../tools.js";
|
|
2
3
|
const EMOJI = {
|
|
3
4
|
feature: "\u2728",
|
|
4
5
|
fix: "\uD83D\uDC1B",
|
|
@@ -23,8 +24,10 @@ export function renderPr(options) {
|
|
|
23
24
|
out.push("");
|
|
24
25
|
const alt = options.imageAlt ?? "before / after";
|
|
25
26
|
out.push(``);
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
if (!isHostedImageUrl(options.imagePath)) {
|
|
28
|
+
out.push("");
|
|
29
|
+
out.push(LOCAL_IMAGE_NOTE);
|
|
30
|
+
}
|
|
28
31
|
}
|
|
29
32
|
out.push("");
|
|
30
33
|
out.push("### Changes");
|
package/dist/template.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { truncateTitle, GITHUB_TITLE_MAX } from "./title.js";
|
|
4
|
+
import { stripLocalImageNote } from "./tools.js";
|
|
4
5
|
export function findPrTemplate(cwd) {
|
|
5
6
|
const single = join(cwd, ".github", "pull_request_template.md");
|
|
6
7
|
if (existsSync(single))
|
|
@@ -24,9 +25,10 @@ export function fillPrTemplate(options) {
|
|
|
24
25
|
if (overflow)
|
|
25
26
|
body = overflow + "\n\n" + body;
|
|
26
27
|
if (options.imageUrl) {
|
|
27
|
-
|
|
28
|
+
// Swap any non-http(s) image target (cache path, .farq/, etc.).
|
|
29
|
+
const re = /!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/;
|
|
28
30
|
if (re.test(body)) {
|
|
29
|
-
body = body.replace(re, "](" + options.imageUrl + ")");
|
|
31
|
+
body = body.replace(re, "");
|
|
30
32
|
}
|
|
31
33
|
else {
|
|
32
34
|
body =
|
|
@@ -35,6 +37,7 @@ export function fillPrTemplate(options) {
|
|
|
35
37
|
")\n\n" +
|
|
36
38
|
body;
|
|
37
39
|
}
|
|
40
|
+
body = stripLocalImageNote(body);
|
|
38
41
|
}
|
|
39
42
|
return { title, body };
|
|
40
43
|
}
|
|
@@ -58,6 +61,7 @@ export function fillPrTemplate(options) {
|
|
|
58
61
|
options.imageUrl +
|
|
59
62
|
")\n";
|
|
60
63
|
}
|
|
64
|
+
body = stripLocalImageNote(body);
|
|
61
65
|
}
|
|
62
66
|
return { title, body };
|
|
63
67
|
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare class ToolNotFoundError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/** True when the image reference is already a hosted URL. */
|
|
5
|
+
export declare function isHostedImageUrl(ref: string): boolean;
|
|
6
|
+
/** Italic note appended for local-only image paths in PR markdown. */
|
|
7
|
+
export declare const LOCAL_IMAGE_NOTE = "_Local image path - attach the file when pasting into GitHub if it does not render._";
|
|
8
|
+
/** Strip the local-image attach note from PR markdown. */
|
|
9
|
+
export declare function stripLocalImageNote(body: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve an executable: env override, then PATH, then extra candidate paths.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveExecutable(names: string[], options: {
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
15
|
+
envKey?: string;
|
|
16
|
+
extraPaths?: string[];
|
|
17
|
+
notFoundMessage: string;
|
|
18
|
+
}): string;
|
|
19
|
+
export declare function resolveGh(env?: NodeJS.ProcessEnv): string;
|
|
20
|
+
/** Reset cache (tests). */
|
|
21
|
+
export declare function resetGhCache(): void;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
export class ToolNotFoundError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ToolNotFoundError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** True when the image reference is already a hosted URL. */
|
|
11
|
+
export function isHostedImageUrl(ref) {
|
|
12
|
+
return /^https?:\/\//i.test(ref.trim());
|
|
13
|
+
}
|
|
14
|
+
/** Italic note appended for local-only image paths in PR markdown. */
|
|
15
|
+
export const LOCAL_IMAGE_NOTE = "_Local image path - attach the file when pasting into GitHub if it does not render._";
|
|
16
|
+
/** Strip the local-image attach note from PR markdown. */
|
|
17
|
+
export function stripLocalImageNote(body) {
|
|
18
|
+
return body
|
|
19
|
+
.replace(/\n*_Local image path[^\n]*_\s*/g, "\n")
|
|
20
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve an executable: env override, then PATH, then extra candidate paths.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveExecutable(names, options) {
|
|
26
|
+
const env = options.env ?? process.env;
|
|
27
|
+
if (options.envKey && env[options.envKey]) {
|
|
28
|
+
const forced = env[options.envKey];
|
|
29
|
+
if (existsSync(forced))
|
|
30
|
+
return forced;
|
|
31
|
+
throw new ToolNotFoundError(`${options.envKey} not found: ${forced}`);
|
|
32
|
+
}
|
|
33
|
+
const pathEnv = env.PATH ?? env.Path ?? "";
|
|
34
|
+
const dirs = pathEnv.split(delimiter).filter(Boolean);
|
|
35
|
+
const win = platform() === "win32";
|
|
36
|
+
const exts = win
|
|
37
|
+
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
|
38
|
+
.split(";")
|
|
39
|
+
.map((e) => e.toLowerCase())
|
|
40
|
+
: [""];
|
|
41
|
+
const tryName = (dir, name) => {
|
|
42
|
+
if (win) {
|
|
43
|
+
if (name.includes(".")) {
|
|
44
|
+
const p = join(dir, name);
|
|
45
|
+
return existsSync(p) ? p : null;
|
|
46
|
+
}
|
|
47
|
+
for (const ext of exts) {
|
|
48
|
+
const p = join(dir, name + ext);
|
|
49
|
+
if (existsSync(p))
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const p = join(dir, name);
|
|
55
|
+
return existsSync(p) ? p : null;
|
|
56
|
+
};
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const hit = tryName(dir, name);
|
|
60
|
+
if (hit)
|
|
61
|
+
return hit;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const candidate of options.extraPaths ?? []) {
|
|
65
|
+
if (candidate && existsSync(candidate))
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
throw new ToolNotFoundError(options.notFoundMessage);
|
|
69
|
+
}
|
|
70
|
+
let cachedGh;
|
|
71
|
+
export function resolveGh(env = process.env) {
|
|
72
|
+
if (cachedGh)
|
|
73
|
+
return cachedGh;
|
|
74
|
+
const local = env.LOCALAPPDATA ?? "";
|
|
75
|
+
const pf = env.ProgramFiles ?? "C:\\Program Files";
|
|
76
|
+
const pf86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
77
|
+
cachedGh = resolveExecutable(["gh", "gh.exe"], {
|
|
78
|
+
env,
|
|
79
|
+
envKey: "GH_PATH",
|
|
80
|
+
extraPaths: [
|
|
81
|
+
join(pf, "GitHub CLI", "gh.exe"),
|
|
82
|
+
join(pf86, "GitHub CLI", "gh.exe"),
|
|
83
|
+
join(local, "Programs", "GitHub CLI", "gh.exe"),
|
|
84
|
+
"/usr/local/bin/gh",
|
|
85
|
+
"/opt/homebrew/bin/gh",
|
|
86
|
+
"/usr/bin/gh",
|
|
87
|
+
],
|
|
88
|
+
notFoundMessage: "gh not found — install GitHub CLI (https://cli.github.com) or set GH_PATH",
|
|
89
|
+
});
|
|
90
|
+
return cachedGh;
|
|
91
|
+
}
|
|
92
|
+
/** Reset cache (tests). */
|
|
93
|
+
export function resetGhCache() {
|
|
94
|
+
cachedGh = undefined;
|
|
95
|
+
}
|
package/dist/ui/index.js
ADDED
package/dist/ui/lines.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const SUMMARIZE = {
|
|
2
|
+
claude: [
|
|
3
|
+
"claude is reading the diff…",
|
|
4
|
+
"claude is grouping the mess…",
|
|
5
|
+
"claude is writing the blurb…",
|
|
6
|
+
],
|
|
7
|
+
opencode: [
|
|
8
|
+
"opencode is reading the diff…",
|
|
9
|
+
"opencode is grouping the mess…",
|
|
10
|
+
"opencode is writing the blurb…",
|
|
11
|
+
],
|
|
12
|
+
fake: [
|
|
13
|
+
"fake is vibing on hard mode…",
|
|
14
|
+
"fake is inventing nothing new…",
|
|
15
|
+
"fake is shipping canned vibes…",
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
const VISUAL = {
|
|
19
|
+
claude: [
|
|
20
|
+
"claude is sketching a mockup…",
|
|
21
|
+
"claude is boxing a flowchart…",
|
|
22
|
+
"chrome is taking the shot…",
|
|
23
|
+
],
|
|
24
|
+
opencode: [
|
|
25
|
+
"opencode is sketching a mockup…",
|
|
26
|
+
"opencode is boxing a flowchart…",
|
|
27
|
+
"chrome is taking the shot…",
|
|
28
|
+
],
|
|
29
|
+
fake: [
|
|
30
|
+
"fake is drawing boxes…",
|
|
31
|
+
"fake is pretending to design…",
|
|
32
|
+
"chrome is taking the shot…",
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
const OPEN = [
|
|
36
|
+
"talking to gh…",
|
|
37
|
+
"shipping the PR body…",
|
|
38
|
+
"almost there…",
|
|
39
|
+
];
|
|
40
|
+
export function linesFor(bank, provider) {
|
|
41
|
+
if (bank === "diff") {
|
|
42
|
+
return ["gathering the diff…", "asking git politely…"];
|
|
43
|
+
}
|
|
44
|
+
if (bank === "open")
|
|
45
|
+
return OPEN;
|
|
46
|
+
const p = provider ?? "claude";
|
|
47
|
+
return bank === "summarize" ? SUMMARIZE[p] : VISUAL[p];
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProviderName } from "../config.js";
|
|
2
|
+
import { type LineBank } from "./lines.js";
|
|
3
|
+
import { type SpinnerHandle } from "./spinner.js";
|
|
4
|
+
export type Ui = {
|
|
5
|
+
/** Plain note (no spinner). */
|
|
6
|
+
note: (text: string) => void;
|
|
7
|
+
/** Start a spinning stage with optional rotating witty lines. */
|
|
8
|
+
stage: (bank: LineBank, provider?: ProviderName) => SpinnerHandle;
|
|
9
|
+
error: (text: string) => void;
|
|
10
|
+
success: (text: string) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createUi(): Ui;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { linesFor } from "./lines.js";
|
|
2
|
+
import { startSpinner } from "./spinner.js";
|
|
3
|
+
import { brand, fail, muted, ok } from "./theme.js";
|
|
4
|
+
export function createUi() {
|
|
5
|
+
return {
|
|
6
|
+
note(text) {
|
|
7
|
+
process.stderr.write(`${brand()} ${muted(text)}\n`);
|
|
8
|
+
},
|
|
9
|
+
stage(bank, provider) {
|
|
10
|
+
const lines = linesFor(bank, provider);
|
|
11
|
+
return startSpinner(lines[0] ?? "working…", lines);
|
|
12
|
+
},
|
|
13
|
+
error(text) {
|
|
14
|
+
process.stderr.write(`${brand()} ${fail(text)}\n`);
|
|
15
|
+
},
|
|
16
|
+
success(text) {
|
|
17
|
+
process.stderr.write(`${brand()} ${ok(text)}\n`);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { brand, accent, isInteractive, muted, ok, fail } from "./theme.js";
|
|
2
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
3
|
+
export function startSpinner(initial, rotateLines) {
|
|
4
|
+
const interactive = isInteractive();
|
|
5
|
+
let text = initial;
|
|
6
|
+
let i = 0;
|
|
7
|
+
let lineIdx = 0;
|
|
8
|
+
let timer = null;
|
|
9
|
+
let lineTimer = null;
|
|
10
|
+
let stopped = false;
|
|
11
|
+
const render = () => {
|
|
12
|
+
if (!interactive || stopped)
|
|
13
|
+
return;
|
|
14
|
+
const frame = accent(FRAMES[i % FRAMES.length]);
|
|
15
|
+
process.stderr.write(`\r\x1b[2K${brand()} ${frame} ${text}`);
|
|
16
|
+
i++;
|
|
17
|
+
};
|
|
18
|
+
if (interactive) {
|
|
19
|
+
render();
|
|
20
|
+
timer = setInterval(render, 80);
|
|
21
|
+
if (rotateLines && rotateLines.length > 1) {
|
|
22
|
+
lineTimer = setInterval(() => {
|
|
23
|
+
lineIdx = (lineIdx + 1) % rotateLines.length;
|
|
24
|
+
text = rotateLines[lineIdx];
|
|
25
|
+
}, 2000);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
process.stderr.write(`${brand()} ${muted(text)}\n`);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
update(next) {
|
|
33
|
+
text = next;
|
|
34
|
+
if (!interactive)
|
|
35
|
+
process.stderr.write(`${brand()} ${muted(text)}\n`);
|
|
36
|
+
},
|
|
37
|
+
succeed(next) {
|
|
38
|
+
this.stop();
|
|
39
|
+
process.stderr.write(`${brand()} ${ok(next)}\n`);
|
|
40
|
+
},
|
|
41
|
+
fail(next) {
|
|
42
|
+
this.stop();
|
|
43
|
+
process.stderr.write(`${brand()} ${fail(next)}\n`);
|
|
44
|
+
},
|
|
45
|
+
stop() {
|
|
46
|
+
if (stopped)
|
|
47
|
+
return;
|
|
48
|
+
stopped = true;
|
|
49
|
+
if (timer)
|
|
50
|
+
clearInterval(timer);
|
|
51
|
+
if (lineTimer)
|
|
52
|
+
clearInterval(lineTimer);
|
|
53
|
+
if (interactive)
|
|
54
|
+
process.stderr.write("\r\x1b[2K");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function brand(): string;
|
|
2
|
+
export declare function accent(text: string): string;
|
|
3
|
+
export declare function ok(text: string): string;
|
|
4
|
+
export declare function fail(text: string): string;
|
|
5
|
+
export declare function muted(text: string): string;
|
|
6
|
+
export declare function isInteractive(): boolean;
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
export function brand() {
|
|
3
|
+
return pc.dim("farq");
|
|
4
|
+
}
|
|
5
|
+
export function accent(text) {
|
|
6
|
+
return pc.cyan(text);
|
|
7
|
+
}
|
|
8
|
+
export function ok(text) {
|
|
9
|
+
return `${pc.cyan("✓")} ${pc.dim(text)}`;
|
|
10
|
+
}
|
|
11
|
+
export function fail(text) {
|
|
12
|
+
return `${pc.red("✗")} ${text}`;
|
|
13
|
+
}
|
|
14
|
+
export function muted(text) {
|
|
15
|
+
return pc.dim(text);
|
|
16
|
+
}
|
|
17
|
+
export function isInteractive() {
|
|
18
|
+
return Boolean(process.stderr.isTTY) && process.env.CI !== "true";
|
|
19
|
+
}
|
package/dist/visual/chrome.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
1
|
import { execa } from "execa";
|
|
3
2
|
import { platform } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveExecutable, ToolNotFoundError, } from "../tools.js";
|
|
4
5
|
const TIMEOUT_MS = 30_000;
|
|
5
6
|
export class ChromeError extends Error {
|
|
6
7
|
constructor(message) {
|
|
@@ -9,17 +10,15 @@ export class ChromeError extends Error {
|
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
export function resolveChrome(env = process.env) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const candidates = platform() === "win32"
|
|
13
|
+
const local = env.LOCALAPPDATA ?? "";
|
|
14
|
+
const pf = env.ProgramFiles ?? "C:\\Program Files";
|
|
15
|
+
const pf86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
16
|
+
const extraPaths = platform() === "win32"
|
|
18
17
|
? [
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"
|
|
18
|
+
join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
|
19
|
+
join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
20
|
+
join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
|
21
|
+
join(pf, "Chromium", "Application", "chrome.exe"),
|
|
23
22
|
]
|
|
24
23
|
: platform() === "darwin"
|
|
25
24
|
? [
|
|
@@ -32,11 +31,27 @@ export function resolveChrome(env = process.env) {
|
|
|
32
31
|
"/usr/bin/chromium",
|
|
33
32
|
"/usr/bin/chromium-browser",
|
|
34
33
|
];
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
try {
|
|
35
|
+
return resolveExecutable([
|
|
36
|
+
"google-chrome",
|
|
37
|
+
"google-chrome-stable",
|
|
38
|
+
"chromium",
|
|
39
|
+
"chromium-browser",
|
|
40
|
+
"chrome",
|
|
41
|
+
"chrome.exe",
|
|
42
|
+
], {
|
|
43
|
+
env,
|
|
44
|
+
envKey: "CHROME_PATH",
|
|
45
|
+
extraPaths,
|
|
46
|
+
notFoundMessage: "Chrome/Chromium not found — install Google Chrome or set CHROME_PATH",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err instanceof ToolNotFoundError) {
|
|
51
|
+
throw new ChromeError(err.message);
|
|
52
|
+
}
|
|
53
|
+
throw err;
|
|
38
54
|
}
|
|
39
|
-
throw new ChromeError("Chrome/Chromium not found — install Google Chrome or set CHROME_PATH");
|
|
40
55
|
}
|
|
41
56
|
export async function screenshotHtml(options) {
|
|
42
57
|
const chrome = options.chromePath ?? resolveChrome();
|
package/dist/visual/compose.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { defaultOutDir } from "../paths.js";
|
|
4
5
|
import { resolveChrome, screenshotHtml, ChromeError } from "./chrome.js";
|
|
5
6
|
export function buildComposeHtml(options) {
|
|
6
7
|
const { beforeBase64, afterBase64, badge } = options;
|
|
@@ -32,7 +33,7 @@ function escapeHtml(s) {
|
|
|
32
33
|
}
|
|
33
34
|
export async function composeBeforeAfter(options) {
|
|
34
35
|
const cwd = options.cwd ?? process.cwd();
|
|
35
|
-
const outDir = resolve(cwd, options.outDir ??
|
|
36
|
+
const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
|
|
36
37
|
mkdirSync(outDir, { recursive: true });
|
|
37
38
|
const before = readFileSync(options.beforePath);
|
|
38
39
|
const after = readFileSync(options.afterPath);
|
package/dist/visual/pipeline.js
CHANGED
|
@@ -5,11 +5,12 @@ import { gatherVisualFileContents } from "../git.js";
|
|
|
5
5
|
import { decideGate } from "./gate.js";
|
|
6
6
|
import { generateMockup } from "./mockup.js";
|
|
7
7
|
import { generateDiagram } from "./diagram.js";
|
|
8
|
+
import { defaultOutDir } from "../paths.js";
|
|
8
9
|
import { composeBeforeAfter, ChromeError } from "./compose.js";
|
|
9
10
|
import { screenshotHtml, resolveChrome } from "./chrome.js";
|
|
10
11
|
export async function runVisualPipeline(options) {
|
|
11
12
|
const cwd = options.cwd ?? process.cwd();
|
|
12
|
-
const outDir = resolve(cwd, options.outDir ??
|
|
13
|
+
const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
|
|
13
14
|
const log = options.log ?? (() => undefined);
|
|
14
15
|
const vlog = (msg) => {
|
|
15
16
|
if (options.verbose)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ahmedalbarghouti/farq",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"commander": "^13.0.0",
|
|
38
38
|
"execa": "^9.0.0",
|
|
39
|
+
"picocolors": "^1.1.1",
|
|
39
40
|
"zod": "^3.24.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|