fapony 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -26
- package/package.json +2 -1
- package/skill/move-to-done/SKILL.md +13 -3
- package/skill/plan-with-pony/SKILL.md +6 -2
- package/src/adapters/hooks/bug-markers.ts +50 -0
- package/src/adapters/hooks/compute-hint-impact.ts +22 -5
- package/src/adapters/hooks/context-data.ts +16 -10
- package/src/adapters/hooks/index.ts +5 -0
- package/src/adapters/hooks/read-hint.ts +17 -1
- package/src/adapters/hooks/session-start.ts +17 -6
- package/src/adapters/hooks/stop.ts +3 -9
- package/src/adapters/mcp/tools/index.ts +6 -1
- package/src/adapters/mcp/tools/mem.ts +40 -121
- package/src/core/debt-format.ts +2 -2
- package/src/core/since.ts +26 -0
- package/src/debt/cli.ts +20 -6
- package/src/debt/load.ts +136 -2
- package/src/digest/collect.ts +1 -19
- package/src/hook.ts +3 -0
- package/src/install/opencode.ts +136 -177
- package/src/install.ts +15 -5
- package/src/mem/commands/plan.ts +272 -13
- package/src/mem/commands/read.ts +128 -24
- package/src/mem/commands/write.ts +26 -31
- package/src/mem/engine.ts +249 -0
- package/src/mem/index.ts +9 -4
- package/src/seed/review-seed.ts +34 -10
- package/src/update.ts +60 -3
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/mem/engine.ts — the single validation+write engine for mem add/close.
|
|
2
|
+
//
|
|
3
|
+
// Both entry points call it: CLI (commands/write.ts cmdAdd/cmdClose) and MCP
|
|
4
|
+
// (adapters/mcp/tools/mem.ts memAdd/memClose). Every domain rule lives here
|
|
5
|
+
// exactly once — kind check, files[] required, hold-requires-spec, open
|
|
6
|
+
// next/hold caps. Wrappers own only their surface: argv parsing +
|
|
7
|
+
// console.error/exit (CLI), worktree-shape checks + JSON results (MCP).
|
|
8
|
+
//
|
|
9
|
+
// Precondition: the store is initialized (initStore) before calling — cli.ts
|
|
10
|
+
// does it at dispatch, the MCP wrapper does it per call with its worktree.
|
|
11
|
+
// (PLAN-unify-mem-engine chunk 1)
|
|
12
|
+
|
|
13
|
+
import { openRows } from "./selectors.js";
|
|
14
|
+
import { KINDS, nextId, put, rows, type WorkKind } from "./store.js";
|
|
15
|
+
|
|
16
|
+
export const CAP_NEXT = 15;
|
|
17
|
+
export const CAP_HOLD = 10;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when an open next/hold cap is full. Carries which cap so the CLI
|
|
21
|
+
* wrapper can append its MEM_FORCE hint while MCP passes the bare message
|
|
22
|
+
* (the two surfaces keep their exact legacy strings — PLAN §4).
|
|
23
|
+
*/
|
|
24
|
+
export class CapError extends Error {
|
|
25
|
+
readonly cap: "next" | "hold";
|
|
26
|
+
constructor(cap: "next" | "hold", message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "CapError";
|
|
29
|
+
this.cap = cap;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EngineAddArgs {
|
|
34
|
+
kind: string;
|
|
35
|
+
text: string;
|
|
36
|
+
files: string[];
|
|
37
|
+
spec?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EngineAddResult {
|
|
41
|
+
id: string;
|
|
42
|
+
kind: string;
|
|
43
|
+
text: string;
|
|
44
|
+
files: string[];
|
|
45
|
+
spec?: string;
|
|
46
|
+
ts: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function engineAdd(a: EngineAddArgs): EngineAddResult {
|
|
50
|
+
if (!KINDS.includes(a.kind as WorkKind)) {
|
|
51
|
+
throw new Error(`kind must be one of ${KINDS.join("|")} — got "${a.kind}"`);
|
|
52
|
+
}
|
|
53
|
+
if (a.files.length === 0) {
|
|
54
|
+
throw new Error("files must contain at least one path");
|
|
55
|
+
}
|
|
56
|
+
if (!a.text.trim()) {
|
|
57
|
+
throw new Error("text is required and must not be empty");
|
|
58
|
+
}
|
|
59
|
+
// Without a spec a hold can never be resolved by rotate — reject at write.
|
|
60
|
+
if (a.kind === "hold" && !a.spec) {
|
|
61
|
+
throw new Error("hold requires a spec — pass spec: <path/to/SPEC.md>");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const all = rows();
|
|
65
|
+
if (a.kind === "next" && !process.env.MEM_FORCE) {
|
|
66
|
+
const openNext = openRows(all).filter((r) => r.kind === "next").length;
|
|
67
|
+
if (openNext >= CAP_NEXT) {
|
|
68
|
+
throw new CapError(
|
|
69
|
+
"next",
|
|
70
|
+
`open next ${openNext}/${CAP_NEXT} is full — close an old one first`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (a.kind === "hold" && !process.env.MEM_FORCE) {
|
|
75
|
+
const openHold = openRows(all).filter((r) => r.kind === "hold").length;
|
|
76
|
+
if (openHold >= CAP_HOLD) {
|
|
77
|
+
throw new CapError(
|
|
78
|
+
"hold",
|
|
79
|
+
`open hold ${openHold}/${CAP_HOLD} is full — close/release an old one first`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const id = nextId(all);
|
|
85
|
+
const ts = new Date().toISOString();
|
|
86
|
+
put({
|
|
87
|
+
id,
|
|
88
|
+
kind: a.kind as WorkKind,
|
|
89
|
+
text: a.text,
|
|
90
|
+
spec: a.spec,
|
|
91
|
+
files: a.files,
|
|
92
|
+
});
|
|
93
|
+
return { id, kind: a.kind, text: a.text, files: a.files, spec: a.spec, ts };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface EngineCloseArgs {
|
|
97
|
+
id: string;
|
|
98
|
+
text: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface EngineCloseResult {
|
|
102
|
+
ref: string;
|
|
103
|
+
text: string;
|
|
104
|
+
ts: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function engineClose(a: EngineCloseArgs): EngineCloseResult {
|
|
108
|
+
if (!a.id.trim()) {
|
|
109
|
+
throw new Error("id is required");
|
|
110
|
+
}
|
|
111
|
+
if (!a.text.trim()) {
|
|
112
|
+
throw new Error("text is required and must not be empty");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const all = rows();
|
|
116
|
+
if (!all.some((r) => "id" in r && r.id === a.id)) {
|
|
117
|
+
throw new Error(`no id "${a.id}" in the log`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ts = new Date().toISOString();
|
|
121
|
+
put({ kind: "close", ref: a.id, text: a.text });
|
|
122
|
+
|
|
123
|
+
return { ref: a.id, text: a.text, ts };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- find ---
|
|
127
|
+
//
|
|
128
|
+
// The single query engine for mem find. Both entry points call it with rows
|
|
129
|
+
// they already read — CLI (commands/read.ts cmdFind, via allRows() which
|
|
130
|
+
// includes rotated archives) and MCP (adapters/mcp/tools/mem.ts memFind, via
|
|
131
|
+
// readMemLog whose loose log*.jsonl regex includes log.YYYY-MM-DD.jsonl too).
|
|
132
|
+
// Archive scope is therefore identical on both sides with no parameter for it
|
|
133
|
+
// (PLAN-unify-mem-engine chunk 2 §4.1: the "MCP reads live only" premise was
|
|
134
|
+
// wrong — both see archives, base rate 0 archive files vs 211 live rows).
|
|
135
|
+
//
|
|
136
|
+
// The engine is pure: it takes rows + typed params, never touches argv
|
|
137
|
+
// strings or the store. Wrappers own their surface — CLI parses
|
|
138
|
+
// --kind/--files/--since/--limit/--open at the argv layer, MCP validates its JSON
|
|
139
|
+
// shape — and each passes its own kind default (PLAN §5 escape):
|
|
140
|
+
// MCP passes no exclude (contract: "every kind, no default filter", locked by
|
|
141
|
+
// test), CLI passes the bookkeeping exclude to keep its legacy output.
|
|
142
|
+
|
|
143
|
+
export const FIND_DEFAULT_LIMIT = 20;
|
|
144
|
+
|
|
145
|
+
/** Bookkeeping rows the CLI hides unless explicitly asked via --kind. */
|
|
146
|
+
export const CLI_FIND_EXCLUDE = ["close", "synced", "claim", "release"];
|
|
147
|
+
|
|
148
|
+
export interface EngineFindArgs {
|
|
149
|
+
/** Substring over text/spec/ref, case-insensitive. Omit = no text filter. */
|
|
150
|
+
text?: string;
|
|
151
|
+
/** Repo-relative paths — stored files[] first, text/spec/ref fallback. */
|
|
152
|
+
files?: string[];
|
|
153
|
+
/** Include filter — when non-empty, wins over excludeKind. */
|
|
154
|
+
kind?: string[];
|
|
155
|
+
/** Exclude filter — applied only when kind is empty. */
|
|
156
|
+
excludeKind?: string[];
|
|
157
|
+
/** ISO timestamp — only rows at or after this time (inclusive). */
|
|
158
|
+
sinceIso?: string;
|
|
159
|
+
/** Max rows returned (total still counts all matches). Default 20. */
|
|
160
|
+
limit?: number;
|
|
161
|
+
/**
|
|
162
|
+
* true = unresolved work only: drops bookkeeping kinds
|
|
163
|
+
* (close/claim/release/synced) plus work rows a close row points at.
|
|
164
|
+
* Mirrors selectors.openRows but stays generic (no store types) so MCP rows
|
|
165
|
+
* qualify. Default false — recall shows closed rows too.
|
|
166
|
+
*/
|
|
167
|
+
open?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface EngineFindResult<T> {
|
|
171
|
+
rows: T[];
|
|
172
|
+
total: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type FindableRow = {
|
|
176
|
+
kind: string;
|
|
177
|
+
text?: string;
|
|
178
|
+
spec?: string;
|
|
179
|
+
ref?: string;
|
|
180
|
+
files?: string[];
|
|
181
|
+
ts: string;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export function engineFind<T extends FindableRow>(
|
|
185
|
+
all: T[],
|
|
186
|
+
a: EngineFindArgs,
|
|
187
|
+
): EngineFindResult<T> {
|
|
188
|
+
let out = [...all];
|
|
189
|
+
|
|
190
|
+
if (a.open === true) {
|
|
191
|
+
const dead = new Set(
|
|
192
|
+
all
|
|
193
|
+
.filter((r) => r.kind === "close" && typeof r.ref === "string")
|
|
194
|
+
.map((r) => r.ref as string),
|
|
195
|
+
);
|
|
196
|
+
const BOOKKEEPING = new Set(["close", "claim", "release", "synced"]);
|
|
197
|
+
out = out.filter(
|
|
198
|
+
(r) =>
|
|
199
|
+
!BOOKKEEPING.has(r.kind) &&
|
|
200
|
+
!("id" in r && typeof r.id === "string" && dead.has(r.id)),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (a.sinceIso) {
|
|
205
|
+
const since = a.sinceIso;
|
|
206
|
+
out = out.filter((r) => !(r.ts < since));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (a.kind && a.kind.length > 0) {
|
|
210
|
+
const keep = new Set(a.kind);
|
|
211
|
+
out = out.filter((r) => keep.has(r.kind));
|
|
212
|
+
} else if (a.excludeKind && a.excludeKind.length > 0) {
|
|
213
|
+
const drop = new Set(a.excludeKind);
|
|
214
|
+
out = out.filter((r) => !drop.has(r.kind));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (a.text?.trim()) {
|
|
218
|
+
const needle = a.text.toLowerCase();
|
|
219
|
+
out = out.filter((r) =>
|
|
220
|
+
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`
|
|
221
|
+
.toLowerCase()
|
|
222
|
+
.includes(needle),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (a.files && a.files.length > 0) {
|
|
227
|
+
// Rows written by `mem add --files` carry files[] — match that first.
|
|
228
|
+
// Older rows (and any row whose author skipped --files) have none, so the
|
|
229
|
+
// text/spec/ref substring stays as the fallback: low recall by nature,
|
|
230
|
+
// a limit of the data rather than of the query.
|
|
231
|
+
const paths = a.files.map((f) => f.toLowerCase());
|
|
232
|
+
out = out.filter((r) => {
|
|
233
|
+
const stored = (r.files ?? []).map((f) => f.toLowerCase());
|
|
234
|
+
if (stored.some((f) => paths.some((p) => f === p || f.endsWith(`/${p}`))))
|
|
235
|
+
return true;
|
|
236
|
+
const hay =
|
|
237
|
+
`${r.text ?? ""}\n${r.spec ?? ""}\n${r.ref ?? ""}`.toLowerCase();
|
|
238
|
+
return paths.some((p) => hay.includes(p));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Newest first — the canonical order. The CLI wrapper reprints oldest-first
|
|
243
|
+
// to keep its legacy output byte-identical (same set, legacy order).
|
|
244
|
+
out.sort((x, y) => y.ts.localeCompare(x.ts));
|
|
245
|
+
|
|
246
|
+
const total = out.length;
|
|
247
|
+
const limit = Math.max(0, a.limit ?? FIND_DEFAULT_LIMIT);
|
|
248
|
+
return { rows: out.slice(0, limit), total };
|
|
249
|
+
}
|
package/src/mem/index.ts
CHANGED
|
@@ -27,7 +27,8 @@ subcommands:
|
|
|
27
27
|
kickoff [<plan.md>] [--pick <n>] open a session + a next-up list
|
|
28
28
|
add <kind> "<text>" --files f1,f2 [spec.md]
|
|
29
29
|
close <id> "<msg>" close a bug
|
|
30
|
-
find "<text>"
|
|
30
|
+
find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
|
|
31
|
+
substring-search every row (archives included)
|
|
31
32
|
done | stale views
|
|
32
33
|
claim <id> | release <id> | synced bookkeeping
|
|
33
34
|
plan-sweep [<plan.md> [--apply]] move shipped plans + fix links
|
|
@@ -57,9 +58,13 @@ example: fapony mem add decision "chose X because Y" --files src/a.ts,src/b.ts`,
|
|
|
57
58
|
tombstone a bug so it stops showing as open work
|
|
58
59
|
--stdin read the message from stdin
|
|
59
60
|
example: fapony mem close mt14 "fixed in a2c6beb"`,
|
|
60
|
-
find: `usage: fapony mem find "<text>"
|
|
61
|
-
substring search over every row's text/spec/ref —
|
|
62
|
-
|
|
61
|
+
find: `usage: fapony mem find ["<text>"] [--kind a,b] [--files f1,f2] [--since <N>d|YYYY-MM-DD] [--limit n]
|
|
62
|
+
substring search over every row's text/spec/ref (rotated archives included) — bookkeeping kinds (close/synced/claim/release) hidden unless --kind names them
|
|
63
|
+
--kind a,b include only these kinds (overrides the bookkeeping default)
|
|
64
|
+
--files f1,f2 rows about these paths (stored files[] first, text/spec/ref fallback)
|
|
65
|
+
--since 7d only rows at or after this time (<N>d or YYYY-MM-DD)
|
|
66
|
+
--limit n max rows returned (default 20, newest first)
|
|
67
|
+
example: fapony mem find "usage-web" --kind bug,decision --limit 5`,
|
|
63
68
|
done: `usage: fapony mem done
|
|
64
69
|
closed rows with their tombstone message
|
|
65
70
|
example: fapony mem done`,
|
package/src/seed/review-seed.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import type { Stats } from "node:fs";
|
|
23
23
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
24
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
25
25
|
import {
|
|
26
26
|
buildGraph,
|
|
27
27
|
collectSourceFiles,
|
|
@@ -357,9 +357,35 @@ function shortSha(cwd: string, ref: string): string | null {
|
|
|
357
357
|
return r.ok ? r.output.split("\n")[0] : null;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
// --plan paths arrive in three shapes: absolute, relative to the invocation
|
|
361
|
+
// cwd (e.g. ../../.fapony/plan/X.md from a subdir), or relative to the
|
|
362
|
+
// worktree root (e.g. .fapony/plan/X.md from anywhere). join(worktree, arg)
|
|
363
|
+
// answers only the third — an absolute arg becomes worktree+abs garbage
|
|
364
|
+
// (join, unlike resolve, does not reset on an absolute segment), and a
|
|
365
|
+
// cwd-relative arg with .. escapes above the root. So: absolute as-is,
|
|
366
|
+
// otherwise first-existing-wins between cwd and worktree (bug mucm1own).
|
|
367
|
+
function resolvePlanPath(arg: string, cwd: string, worktree: string): string {
|
|
368
|
+
if (isAbsolute(arg)) {
|
|
369
|
+
if (existsSync(arg)) return arg;
|
|
370
|
+
throw new SeedError(`review-seed: plan file not found: ${arg}`);
|
|
371
|
+
}
|
|
372
|
+
const candidates = [resolve(cwd, arg), join(worktree, arg)];
|
|
373
|
+
for (const p of new Set(candidates)) {
|
|
374
|
+
if (existsSync(p)) return p;
|
|
375
|
+
}
|
|
376
|
+
throw new SeedError(`review-seed: plan file not found: ${arg}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function resolveScope(
|
|
380
|
+
scope: Scope,
|
|
381
|
+
cwd: string,
|
|
382
|
+
worktree: string,
|
|
383
|
+
): ResolvedScope {
|
|
361
384
|
if (scope.kind === "files") {
|
|
362
|
-
const { files, notes, dirExpanded } = expandFilesScope(
|
|
385
|
+
const { files, notes, dirExpanded } = expandFilesScope(
|
|
386
|
+
scope.list,
|
|
387
|
+
worktree,
|
|
388
|
+
);
|
|
363
389
|
return {
|
|
364
390
|
label: dirExpanded ? "--files (dir-expanded)" : "--files (as given)",
|
|
365
391
|
entries: files,
|
|
@@ -367,10 +393,7 @@ function resolveScope(scope: Scope, cwd: string): ResolvedScope {
|
|
|
367
393
|
};
|
|
368
394
|
}
|
|
369
395
|
if (scope.kind === "plan") {
|
|
370
|
-
const planPath =
|
|
371
|
-
if (!existsSync(planPath)) {
|
|
372
|
-
throw new SeedError(`review-seed: plan file not found: ${scope.path}`);
|
|
373
|
-
}
|
|
396
|
+
const planPath = resolvePlanPath(scope.path, cwd, worktree);
|
|
374
397
|
const planFiles = planFrontFiles(readFileSync(planPath, "utf-8"));
|
|
375
398
|
if (planFiles === null) {
|
|
376
399
|
// No files[] to scope from — fall back to the default diff, say so.
|
|
@@ -567,6 +590,7 @@ function findCallers(
|
|
|
567
590
|
function renderLookup(
|
|
568
591
|
flags: LookupFlags,
|
|
569
592
|
scope: Scope,
|
|
593
|
+
cwd: string,
|
|
570
594
|
worktree: string,
|
|
571
595
|
): string {
|
|
572
596
|
const lines: string[] = [];
|
|
@@ -574,7 +598,7 @@ function renderLookup(
|
|
|
574
598
|
|
|
575
599
|
let resolved: ResolvedScope | null = null;
|
|
576
600
|
if (flags.callers) {
|
|
577
|
-
resolved = resolveScope(scope, worktree);
|
|
601
|
+
resolved = resolveScope(scope, cwd, worktree);
|
|
578
602
|
}
|
|
579
603
|
|
|
580
604
|
const hasGraph = (f: string): boolean => {
|
|
@@ -686,10 +710,10 @@ export function renderSeed(args: string[], cwd: string): string {
|
|
|
686
710
|
// the caller asked for one answer, not the review seed around it.
|
|
687
711
|
const lookup = parseLookup(args);
|
|
688
712
|
if (lookup.body.length > 0 || lookup.callers) {
|
|
689
|
-
return renderLookup(lookup, scope, worktree);
|
|
713
|
+
return renderLookup(lookup, scope, cwd, worktree);
|
|
690
714
|
}
|
|
691
715
|
|
|
692
|
-
const resolved = resolveScope(scope, worktree);
|
|
716
|
+
const resolved = resolveScope(scope, cwd, worktree);
|
|
693
717
|
const entries = [...resolved.entries].sort((a, b) =>
|
|
694
718
|
a.path < b.path ? -1 : 1,
|
|
695
719
|
);
|
package/src/update.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// ROOT must be the repo root: import.meta.dir is src/, one level below it.
|
|
3
3
|
// Shows old → new version, recent commits, and warns if uncommitted changes.
|
|
4
4
|
|
|
5
|
-
import { execSync } from "node:child_process";
|
|
5
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { join } from "node:path";
|
|
8
9
|
import { createInterface } from "node:readline";
|
|
10
|
+
import { opencodePluginFiles } from "./install/opencode.js";
|
|
9
11
|
import { isAffirmative } from "./util.js";
|
|
10
12
|
|
|
11
13
|
/** Repo root (parent of src/) — where package.json and bun.lock live.
|
|
@@ -27,12 +29,59 @@ function defaultInstall(): void {
|
|
|
27
29
|
execSync("bun install", { cwd: ROOT, stdio: "pipe", timeout: 300_000 });
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** argv for the spawned plugin refresh — exported so tests can pin the flags:
|
|
33
|
+
* a wrong flag fails silently (the child would just do a full install and
|
|
34
|
+
* rewrite opencode.json, the exact bug `--plugins-only` exists to prevent). */
|
|
35
|
+
export function refreshArgv(files: string[]): string[] {
|
|
36
|
+
const argv = [
|
|
37
|
+
join(ROOT, "fapony.ts"),
|
|
38
|
+
"install",
|
|
39
|
+
"--platform",
|
|
40
|
+
"opencode",
|
|
41
|
+
// Plugins only: a refresh touches fapony-owned plugin files, never the
|
|
42
|
+
// user's opencode.json (rule 6c).
|
|
43
|
+
"--plugins-only",
|
|
44
|
+
];
|
|
45
|
+
// Opt-in plugin: refresh it when the user installed it, never create it.
|
|
46
|
+
if (files.includes("fapony-git-autonomy.ts")) argv.push("--git-autonomy");
|
|
47
|
+
return argv;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refresh OpenCode's generated plugin bodies after the pull.
|
|
52
|
+
*
|
|
53
|
+
* OpenCode is the only client whose hooks are baked files — every other client
|
|
54
|
+
* writes a `fapony hook-*` command resolved at run time, so a pull alone keeps
|
|
55
|
+
* them current. Spawning a *fresh* process is the whole point: this one already
|
|
56
|
+
* loaded the pre-pull templates, so calling the installer in-process would
|
|
57
|
+
* rewrite the old body — the exact bug this exists to fix. `process.execPath`
|
|
58
|
+
* is the bun running fapony, so no PATH dependency. Runs `--plugins-only`, so
|
|
59
|
+
* the refresh never reads or writes opencode.json or the skills symlink.
|
|
60
|
+
* Best-effort: a refresh must never fail an update.
|
|
61
|
+
*/
|
|
62
|
+
function defaultRefreshPlugins(): void {
|
|
63
|
+
const getHome = (): string => homedir();
|
|
64
|
+
const files = opencodePluginFiles(getHome);
|
|
65
|
+
if (files.length === 0) return;
|
|
66
|
+
console.log("\n Refreshing OpenCode plugins...");
|
|
67
|
+
const r = spawnSync(process.execPath, refreshArgv(files), {
|
|
68
|
+
stdio: "pipe",
|
|
69
|
+
timeout: 30_000,
|
|
70
|
+
});
|
|
71
|
+
if (r.error || r.status !== 0) {
|
|
72
|
+
console.log(
|
|
73
|
+
" ⚠ plugin refresh failed — run manually: fapony install --platform opencode --plugins-only",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
/** Minimal seam for cmdUpdate — git runner (map args→result, throws on failure),
|
|
31
|
-
* prompt, exit,
|
|
32
|
-
* (production) path and the test path. */
|
|
79
|
+
* prompt, exit, bun-install, and the post-pull OpenCode plugin refresh. Every
|
|
80
|
+
* field is used by both the default (production) path and the test path. */
|
|
33
81
|
export interface UpdateDeps {
|
|
34
82
|
git?: (args: string) => string;
|
|
35
83
|
install?: () => void;
|
|
84
|
+
refresh?: () => void;
|
|
36
85
|
prompt?: (question: string, defaultVal?: string) => Promise<string>;
|
|
37
86
|
exit?: (code: number) => never;
|
|
38
87
|
}
|
|
@@ -88,6 +137,7 @@ function defaultPrompt(question: string, defaultVal?: string): Promise<string> {
|
|
|
88
137
|
export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
89
138
|
const git = deps.git ?? defaultGit;
|
|
90
139
|
const installFn = deps.install ?? defaultInstall;
|
|
140
|
+
const refreshFn = deps.refresh ?? defaultRefreshPlugins;
|
|
91
141
|
const promptFn = deps.prompt ?? defaultPrompt;
|
|
92
142
|
const exitFn = deps.exit ?? ((code: number): never => process.exit(code));
|
|
93
143
|
const gitQuiet = (args: string): string | null => {
|
|
@@ -173,6 +223,10 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
|
173
223
|
// --- show what changed ---
|
|
174
224
|
if (isUpToDate(oldSha, newSha)) {
|
|
175
225
|
console.log(`\n ✓ Already up to date (${oldVersion} @ ${oldSha}).`);
|
|
226
|
+
// The repo being current says nothing about the generated plugin bodies —
|
|
227
|
+
// a user who pulled by hand, or installed before the template changed, is
|
|
228
|
+
// exactly who needs this. Refresh is a no-op when nothing is stale.
|
|
229
|
+
refreshFn();
|
|
176
230
|
return;
|
|
177
231
|
}
|
|
178
232
|
|
|
@@ -201,6 +255,9 @@ export async function cmdUpdate(deps: UpdateDeps = {}): Promise<void> {
|
|
|
201
255
|
}
|
|
202
256
|
}
|
|
203
257
|
|
|
258
|
+
// --- refresh generated plugin bodies (after deps — the fresh process needs them) ---
|
|
259
|
+
refreshFn();
|
|
260
|
+
|
|
204
261
|
console.log(`
|
|
205
262
|
┌──────────────────────────────────────────┐
|
|
206
263
|
│ Update complete! │
|