@forwardimpact/libwiki 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/weekly-log.js CHANGED
@@ -1,29 +1,14 @@
1
- import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs";
2
1
  import path from "node:path";
2
+ import { isoWeekString } from "@forwardimpact/libutil";
3
3
  import { WEEKLY_LOG_LINE_BUDGET } from "./constants.js";
4
4
 
5
- /** Compute ISO 8601 year-week for a Date. Returns { year, week } where year is the ISO week-year (not necessarily the calendar year for edge weeks). */
6
- export function isoWeek(date) {
7
- const d = new Date(
8
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
9
- );
10
- // Thursday of week: ISO weeks are anchored on Thursday.
11
- const day = d.getUTCDay() || 7;
12
- d.setUTCDate(d.getUTCDate() + 4 - day);
13
- const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
14
- const week = Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
15
- return { year: d.getUTCFullYear(), week };
16
- }
17
-
18
- function formatIsoWeek(date) {
19
- const { year, week } = isoWeek(date);
20
- return `${year}-W${String(week).padStart(2, "0")}`;
21
- }
5
+ // ISO week computation lives in libutil's calendar util (the one place a
6
+ // `new Date` is allowed); re-exported here for the existing public surface.
7
+ export { isoWeek } from "@forwardimpact/libutil";
22
8
 
23
9
  /** Return the path of the current weekly log file for an agent. */
24
10
  export function weeklyLogPath(wikiRoot, agent, today) {
25
- const date = today instanceof Date ? today : new Date(today);
26
- return path.join(wikiRoot, `${agent}-${formatIsoWeek(date)}.md`);
11
+ return path.join(wikiRoot, `${agent}-${isoWeekString(today)}.md`);
27
12
  }
28
13
 
29
14
  function countLines(text) {
@@ -34,11 +19,11 @@ function countLines(text) {
34
19
  return n;
35
20
  }
36
21
 
37
- function nextPartPath(filePath) {
22
+ function nextPartPath(filePath, fs) {
38
23
  const dir = path.dirname(filePath);
39
24
  const base = path.basename(filePath, ".md");
40
25
  let n = 1;
41
- while (existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
26
+ while (fs.existsSync(path.join(dir, `${base}-part${n}.md`))) n++;
42
27
  return path.join(dir, `${base}-part${n}.md`);
43
28
  }
44
29
 
@@ -49,42 +34,57 @@ function agentTitle(agent) {
49
34
  .join(" ");
50
35
  }
51
36
 
52
- function defaultH1(filePath, agent, isoWeekStr) {
37
+ function defaultH1(agent, isoWeekStr) {
53
38
  return `# ${agentTitle(agent)} — ${isoWeekStr}\n`;
54
39
  }
55
40
 
56
- /** Rotate the current weekly log if next append would exceed the budget. Returns { rotated, fromPath, toPath }. */
41
+ /**
42
+ * Rotate the current weekly log if next append would exceed the budget.
43
+ * @returns {{rotated: boolean, fromPath: string, toPath?: string}}
44
+ * @param {string} wikiRoot
45
+ * @param {string} agent
46
+ * @param {string} today - ISO date string.
47
+ * @param {number} [appendLines=0]
48
+ * @param {{force?: boolean}} [options]
49
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
50
+ */
57
51
  export function rotateIfOverBudget(
58
52
  wikiRoot,
59
53
  agent,
60
54
  today,
61
55
  appendLines = 0,
62
56
  options = {},
57
+ fs,
63
58
  ) {
64
59
  const filePath = weeklyLogPath(wikiRoot, agent, today);
65
60
  const { force = false } = options;
66
- if (!existsSync(filePath)) return { rotated: false, fromPath: filePath };
67
- const text = readFileSync(filePath, "utf-8");
61
+ if (!fs.existsSync(filePath)) return { rotated: false, fromPath: filePath };
62
+ const text = fs.readFileSync(filePath, "utf-8");
68
63
  const current = countLines(text);
69
64
  if (!force && current + appendLines <= WEEKLY_LOG_LINE_BUDGET) {
70
65
  return { rotated: false, fromPath: filePath };
71
66
  }
72
- const toPath = nextPartPath(filePath);
73
- renameSync(filePath, toPath);
74
- const date = today instanceof Date ? today : new Date(today);
75
- writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
67
+ const toPath = nextPartPath(filePath, fs);
68
+ fs.renameSync(filePath, toPath);
69
+ fs.writeFileSync(filePath, defaultH1(agent, isoWeekString(today)));
76
70
  return { rotated: true, fromPath: filePath, toPath };
77
71
  }
78
72
 
79
- /** Append a body to a weekly log file. Creates it with an H1 if missing. */
80
- export function appendEntry(filePath, body, agent, today) {
81
- const date = today instanceof Date ? today : new Date(today);
82
- if (!existsSync(filePath)) {
83
- writeFileSync(filePath, defaultH1(filePath, agent, formatIsoWeek(date)));
73
+ /**
74
+ * Append a body to a weekly log file. Creates it with an H1 if missing.
75
+ * @param {string} filePath
76
+ * @param {string} body
77
+ * @param {string} agent
78
+ * @param {string} today - ISO date string.
79
+ * @param {object} fs - Sync filesystem surface (`runtime.fsSync`).
80
+ */
81
+ export function appendEntry(filePath, body, agent, today, fs) {
82
+ if (!fs.existsSync(filePath)) {
83
+ fs.writeFileSync(filePath, defaultH1(agent, isoWeekString(today)));
84
84
  }
85
- const text = readFileSync(filePath, "utf-8");
85
+ const text = fs.readFileSync(filePath, "utf-8");
86
86
  const separator = text.endsWith("\n") ? "\n" : "\n\n";
87
- writeFileSync(
87
+ fs.writeFileSync(
88
88
  filePath,
89
89
  text + separator + body + (body.endsWith("\n") ? "" : "\n"),
90
90
  );
@@ -0,0 +1,164 @@
1
+ import path from "node:path";
2
+
3
+ /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
4
+ export class WikiPullConflict extends Error {
5
+ /** Create a WikiPullConflict with the stderr output from the failed rebase. */
6
+ constructor(stderr) {
7
+ super("rebase conflict on pull");
8
+ this.name = "WikiPullConflict";
9
+ this.stderr = stderr;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Consolidates the wiki repository's pull / rebase / conflict-resolve / push
15
+ * flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
16
+ * Replaces the pre-1370 `WikiRepo`: all shelling-out flows through
17
+ * `gitClient` (itself over `runtime.subprocess`), so libwiki never imports
18
+ * `node:child_process` and tests inject `createMockGitClient`.
19
+ *
20
+ * Network operations (fetch / clone / push) authenticate by resolving a token
21
+ * lazily through `resolveToken` and threading it via `gitClient.withAuth`;
22
+ * local operations never call `resolveToken`. The callback owns the entire
23
+ * resolution policy and its throws propagate to the caller.
24
+ */
25
+ export class WikiSync {
26
+ #runtime;
27
+ #git;
28
+ #wikiDir;
29
+ #parentDir;
30
+ #resolveToken;
31
+
32
+ /**
33
+ * @param {object} options
34
+ * @param {import('@forwardimpact/libutil/runtime').Runtime} options.runtime
35
+ * @param {import('@forwardimpact/libutil').GitClient} options.gitClient
36
+ * @param {string} options.wikiDir - The wiki clone directory.
37
+ * @param {string} options.parentDir - The parent project directory (identity source).
38
+ * @param {() => (string|null)} [options.resolveToken] - Lazy token resolver
39
+ * for network operations; returns a token string or null for anonymous.
40
+ */
41
+ constructor({ runtime, gitClient, wikiDir, parentDir, resolveToken }) {
42
+ if (!runtime) throw new Error("WikiSync: runtime is required");
43
+ if (!gitClient) throw new Error("WikiSync: gitClient is required");
44
+ if (typeof wikiDir !== "string" || wikiDir === "") {
45
+ throw new TypeError("WikiSync: wikiDir must be a non-empty string");
46
+ }
47
+ this.#runtime = runtime;
48
+ this.#git = gitClient;
49
+ this.#wikiDir = wikiDir;
50
+ this.#parentDir = parentDir;
51
+ this.#resolveToken = resolveToken ?? (() => null);
52
+ }
53
+
54
+ /** A GitClient authenticated with the lazily-resolved token, or the bare client when none. */
55
+ #authed() {
56
+ const token = this.#resolveToken();
57
+ return token ? this.#git.withAuth(token) : this.#git;
58
+ }
59
+
60
+ /** Whether the wiki directory is an initialized git clone. */
61
+ isCloned() {
62
+ return this.#runtime.fsSync.existsSync(path.join(this.#wikiDir, ".git"));
63
+ }
64
+
65
+ /** Clone the wiki from `url` if it is not already cloned. */
66
+ async ensureCloned(url) {
67
+ if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
68
+ try {
69
+ await this.#authed().clone(url, this.#wikiDir);
70
+ return { cloned: true, reason: "cloned" };
71
+ } catch (err) {
72
+ return { cloned: false, reason: err.stderr?.trim() || err.message };
73
+ }
74
+ }
75
+
76
+ /** Copy git user.name and user.email from the parent repository into the wiki repository. */
77
+ async inheritIdentity() {
78
+ const name = await this.#git.configGet("user.name", {
79
+ cwd: this.#parentDir,
80
+ });
81
+ const email = await this.#git.configGet("user.email", {
82
+ cwd: this.#parentDir,
83
+ });
84
+ if (name)
85
+ await this.#git.configSet("user.name", name, { cwd: this.#wikiDir });
86
+ if (email) {
87
+ await this.#git.configSet("user.email", email, { cwd: this.#wikiDir });
88
+ }
89
+ }
90
+
91
+ /** Fetch origin/master using token auth when available. */
92
+ async fetch() {
93
+ // Resolve auth first so a misconfigured `resolveToken` still surfaces.
94
+ const client = this.#authed();
95
+ try {
96
+ await client.fetch("origin", "master", { cwd: this.#wikiDir });
97
+ } catch {
98
+ // WikiRepo treated fetch as fire-and-forget (it ignored the git result);
99
+ // a failed fetch leaves the local origin/master ref in place and the
100
+ // rebase proceeds against it. Preserved so push/pull degrade gracefully
101
+ // rather than crash when the network or credentials are unavailable.
102
+ }
103
+ }
104
+
105
+ /** Whether the wiki working tree has no uncommitted changes. */
106
+ async isClean() {
107
+ const r = await this.#git.status({ cwd: this.#wikiDir });
108
+ return r.stdout.trim() === "";
109
+ }
110
+
111
+ /** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
112
+ async pull() {
113
+ await this.fetch();
114
+ const r = await this.#git.rebase("origin/master", { cwd: this.#wikiDir });
115
+ if (r.exitCode !== 0) {
116
+ await this.#git.rebaseAbort({ cwd: this.#wikiDir });
117
+ throw new WikiPullConflict(r.stderr?.trim() || "");
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Stage and commit any working-tree changes, then fetch, rebase on
123
+ * origin/master (falling back to a merge with -X ours if the rebase fails),
124
+ * and push if HEAD is ahead of origin/master. The commit gate and the push
125
+ * gate are independent so a clean tree with local commits still pushes.
126
+ */
127
+ async commitAndPush(message) {
128
+ if (!(await this.isClean())) {
129
+ await this.#git.commitAll(message, { cwd: this.#wikiDir });
130
+ }
131
+ if (!(await this.#hasCommitsAhead())) {
132
+ return { pushed: false, reason: "clean" };
133
+ }
134
+ await this.fetch();
135
+ const rebase = await this.#git.rebase("origin/master", {
136
+ cwd: this.#wikiDir,
137
+ });
138
+ if (rebase.exitCode !== 0) {
139
+ await this.#git.rebaseAbort({ cwd: this.#wikiDir });
140
+ await this.#git.mergeOursStrategy({
141
+ cwd: this.#wikiDir,
142
+ ref: "origin/master",
143
+ });
144
+ }
145
+ // Resolve auth first so a misconfigured `resolveToken` still surfaces; the
146
+ // push itself is fire-and-forget like WikiRepo (which ignored the push
147
+ // result and reported pushed:true regardless), so a network/credential
148
+ // failure degrades to "saved locally" rather than crashing the command.
149
+ const client = this.#authed();
150
+ try {
151
+ await client.push("origin", "master", { cwd: this.#wikiDir });
152
+ } catch {
153
+ // Intentionally ignored — preserves WikiRepo's fire-and-forget push.
154
+ }
155
+ return { pushed: true, reason: "pushed" };
156
+ }
157
+
158
+ async #hasCommitsAhead() {
159
+ const count = await this.#git.revListCount("origin/master..HEAD", {
160
+ cwd: this.#wikiDir,
161
+ });
162
+ return count > 0;
163
+ }
164
+ }
package/src/build-repo.js DELETED
@@ -1,20 +0,0 @@
1
- import path from "node:path";
2
- import fsAsync from "node:fs/promises";
3
- import { Finder } from "@forwardimpact/libutil";
4
- import { createScriptConfig } from "@forwardimpact/libconfig";
5
- import { WikiRepo } from "./wiki-repo.js";
6
-
7
- /** Construct a WikiRepo from CLI values and the working directory. */
8
- export async function buildRepo(values, cwd = process.cwd()) {
9
- const logger = { debug() {} };
10
- const finder = new Finder(fsAsync, logger, { cwd: () => cwd });
11
- const projectRoot = finder.findProjectRoot(cwd);
12
- const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
13
-
14
- const config = await createScriptConfig("wiki");
15
- return new WikiRepo({
16
- wikiDir,
17
- parentDir: projectRoot,
18
- resolveToken: () => config.ghToken(),
19
- });
20
- }
package/src/io.js DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * Process-bound I/O collaborators shared across libwiki commands.
3
- *
4
- * Each command accepts an optional `io` argument; when omitted, the
5
- * bound `process.*` defaults run. Tests construct a synthetic `io`
6
- * (e.g. capturing stdout into a string and recording exit codes
7
- * instead of terminating the runner) and call command handlers
8
- * directly, avoiding `execFileSync("node", [...])`.
9
- */
10
- export function createDefaultIo() {
11
- return {
12
- stdout: (s) => process.stdout.write(s),
13
- stderr: (s) => process.stderr.write(s),
14
- exit: (code) => process.exit(code),
15
- cwd: () => process.cwd(),
16
- env: process.env,
17
- today: () => new Date().toISOString().slice(0, 10),
18
- };
19
- }
20
-
21
- /**
22
- * Test helper: synthetic `io` that captures stdout/stderr into strings
23
- * and records exit codes instead of terminating the process. After a
24
- * handler returns, inspect `out`/`err`/`exitCode` to assert behavior.
25
- *
26
- * @param {object} overrides - Per-test overrides (cwd, env, today).
27
- * @returns {object} `{ stdout, stderr, exit, cwd, env, today, out, err, exitCode }`
28
- */
29
- export function createTestIo(overrides = {}) {
30
- const io = {
31
- out: "",
32
- err: "",
33
- exitCode: null,
34
- stdout(s) {
35
- io.out += s;
36
- },
37
- stderr(s) {
38
- io.err += s;
39
- },
40
- exit(code) {
41
- io.exitCode = code;
42
- throw new IoExit(code);
43
- },
44
- cwd: overrides.cwd ?? (() => process.cwd()),
45
- env: overrides.env ?? process.env,
46
- today: overrides.today ?? (() => new Date().toISOString().slice(0, 10)),
47
- };
48
- return io;
49
- }
50
-
51
- /**
52
- * Thrown by `createTestIo`'s `exit` so handlers stop unwinding the way
53
- * `process.exit` would. Tests can catch it or wrap calls in
54
- * `runWithIo(() => handler(...), io)`.
55
- */
56
- export class IoExit extends Error {
57
- /** @param {number} code - Exit code the handler asked the process to exit with. */
58
- constructor(code) {
59
- super(`IoExit(${code})`);
60
- this.name = "IoExit";
61
- this.code = code;
62
- }
63
- }
64
-
65
- /**
66
- * Run a handler that may call `io.exit()`; swallow the synthetic
67
- * IoExit so callers can inspect `io.exitCode` linearly without
68
- * try/catch boilerplate.
69
- */
70
- export async function runWithIo(fn) {
71
- try {
72
- return await fn();
73
- } catch (err) {
74
- if (err instanceof IoExit) return undefined;
75
- throw err;
76
- }
77
- }
package/src/wiki-repo.js DELETED
@@ -1,167 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- const CREDENTIAL_HELPER_BODY =
4
- '!f() { echo username=x-access-token; echo "password=${GH_TOKEN:-$GITHUB_TOKEN}"; }; f';
5
-
6
- /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
7
- export class WikiPullConflict extends Error {
8
- /** Create a WikiPullConflict with the stderr output from the failed rebase. */
9
- constructor(stderr) {
10
- super("rebase conflict on pull");
11
- this.name = "WikiPullConflict";
12
- this.stderr = stderr;
13
- }
14
- }
15
-
16
- /** Prepend credential-helper config arguments to a git command when a token is available. */
17
- export function buildAuthArgs(args, token) {
18
- if (token) {
19
- return [
20
- "-c",
21
- "credential.helper=",
22
- "-c",
23
- `credential.helper=${CREDENTIAL_HELPER_BODY}`,
24
- ...args,
25
- ];
26
- }
27
- return [...args];
28
- }
29
-
30
- /** Git operations wrapper for the GitHub wiki repository used as agent team memory. */
31
- export class WikiRepo {
32
- #wikiDir;
33
- #parentDir;
34
- #resolveToken;
35
-
36
- /**
37
- * Create a WikiRepo targeting the given wiki directory and its parent project directory.
38
- * @param {{ wikiDir: string, parentDir: string, resolveToken: () => string | null }} opts
39
- * `resolveToken` is called lazily before each network operation. Return a
40
- * GitHub token string to authenticate, or `null` to run anonymously. The
41
- * callback owns the entire resolution policy — libwiki does not read
42
- * `process.env` directly. Throws propagate to the caller so credential
43
- * misconfiguration surfaces loudly. Commands typically pass
44
- * `() => config.ghToken()` from `@forwardimpact/libconfig`.
45
- */
46
- constructor({ wikiDir, parentDir, resolveToken }) {
47
- if (typeof wikiDir !== "string" || wikiDir === "") {
48
- throw new TypeError("WikiRepo: wikiDir must be a non-empty string");
49
- }
50
- if (typeof parentDir !== "string" || parentDir === "") {
51
- throw new TypeError("WikiRepo: parentDir must be a non-empty string");
52
- }
53
- if (typeof resolveToken !== "function") {
54
- throw new TypeError("WikiRepo: resolveToken callback is required");
55
- }
56
- this.#wikiDir = wikiDir;
57
- this.#parentDir = parentDir;
58
- this.#resolveToken = resolveToken;
59
- }
60
-
61
- /** Check whether the wiki directory is an initialized git repository. */
62
- isCloned() {
63
- const r = spawnSync(
64
- "git",
65
- ["-C", this.#wikiDir, "rev-parse", "--git-dir"],
66
- {
67
- stdio: "pipe",
68
- },
69
- );
70
- return r.status === 0;
71
- }
72
-
73
- /** Clone the wiki from the given URL if it is not already cloned. */
74
- ensureCloned(url) {
75
- if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
76
- const r = this.#authGit(["clone", url, this.#wikiDir]);
77
- if (r.status !== 0) {
78
- return {
79
- cloned: false,
80
- reason: r.stderr?.toString().trim() || "clone failed",
81
- };
82
- }
83
- return { cloned: true, reason: "cloned" };
84
- }
85
-
86
- /** Copy git user.name and user.email from the parent repository into the wiki repository. */
87
- inheritIdentity() {
88
- const name = this.#parentConfig("user.name");
89
- const email = this.#parentConfig("user.email");
90
- if (name) this.#git(["config", "user.name", name]);
91
- if (email) this.#git(["config", "user.email", email]);
92
- }
93
-
94
- /** Fetch the latest master branch from the wiki remote using token auth if available. */
95
- fetch() {
96
- this.#authGit(["-C", this.#wikiDir, "fetch", "origin", "master"]);
97
- }
98
-
99
- /** Return true if the wiki working tree has no uncommitted changes. */
100
- isClean() {
101
- const r = this.#git(["status", "--porcelain"]);
102
- return r.stdout.toString().trim() === "";
103
- }
104
-
105
- /** Fetch and rebase on origin/master, throwing WikiPullConflict if the rebase fails. */
106
- pull() {
107
- this.fetch();
108
- const r = this.#git(["rebase", "origin/master"]);
109
- if (r.status !== 0) {
110
- this.#git(["rebase", "--abort"]);
111
- throw new WikiPullConflict(r.stderr?.toString().trim() || "");
112
- }
113
- }
114
-
115
- /** Stage and commit any working-tree changes, then fetch, rebase on origin/master (falling back to a merge with -X ours if rebase fails), and push if HEAD is ahead of origin/master. The commit gate and the push gate are independent so a clean tree with local commits still pushes. */
116
- commitAndPush(message) {
117
- const hasWorkingTreeChanges = !this.isClean();
118
- if (hasWorkingTreeChanges) {
119
- this.#git(["add", "-A"]);
120
- this.#git(["commit", "-m", message]);
121
- }
122
- if (!this.#hasCommitsAhead()) {
123
- return { pushed: false, reason: "clean" };
124
- }
125
- this.fetch();
126
- const rebase = this.#git(["rebase", "origin/master"]);
127
- if (rebase.status !== 0) {
128
- this.#git(["rebase", "--abort"]);
129
- this.#git(["merge", "origin/master", "-X", "ours", "--no-edit"]);
130
- }
131
- this.#authGit(["-C", this.#wikiDir, "push", "origin", "master"]);
132
- return { pushed: true, reason: "pushed" };
133
- }
134
-
135
- #hasCommitsAhead() {
136
- const r = this.#git(["rev-list", "--count", "origin/master..HEAD"]);
137
- const count = parseInt(r.stdout?.toString().trim() || "0", 10);
138
- return count > 0;
139
- }
140
-
141
- #parentConfig(key) {
142
- const r = spawnSync(
143
- "git",
144
- ["-C", this.#parentDir, "config", "--get", key],
145
- {
146
- stdio: "pipe",
147
- },
148
- );
149
- return r.status === 0 ? r.stdout.toString().trim() : null;
150
- }
151
-
152
- #git(args) {
153
- return spawnSync("git", ["-C", this.#wikiDir, ...args], { stdio: "pipe" });
154
- }
155
-
156
- #authGit(args) {
157
- const token = this.#resolveToken();
158
- const fullArgs = buildAuthArgs(args, token);
159
- // The credential helper body keeps `${GH_TOKEN:-$GITHUB_TOKEN}` literal so
160
- // git's child shell expands it at auth time — the token never sits in argv.
161
- // Inject the resolved token into the spawn env so the helper's lazy
162
- // expansion finds it even when the resolver pulled from `.env` or
163
- // `gh auth token` rather than the ambient process env.
164
- const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
165
- return spawnSync("git", fullArgs, { stdio: "pipe", env });
166
- }
167
- }