@ramarivera/coding-buddy 0.9.4 → 0.9.5

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
8
- "version": "0.9.4"
8
+ "version": "0.9.5"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "coding-buddy",
13
13
  "source": "./",
14
14
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
15
- "version": "0.9.4",
15
+ "version": "0.9.5",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "ramarivera"
package/cli/install.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * ~/.claude/ layout when the env var is unset.
8
8
  */
9
9
 
10
- import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync, rmSync } from "fs";
11
11
  import { execFileSync, execSync } from "child_process";
12
12
  import { resolve, dirname, join } from "path";
13
13
 
@@ -22,7 +22,12 @@ import {
22
22
  } from "../server/path.ts";
23
23
  import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
24
24
  import { generateFallbackName } from "../core/reactions.ts"
25
- import { copyRuntimeApp, stableRuntimePaths } from "./runtime-app.ts";
25
+ import {
26
+ installRuntimeAppAtomically,
27
+ redactSecrets,
28
+ stableRuntimePaths,
29
+ supportsTextLockfile,
30
+ } from "./runtime-app.ts";
26
31
 
27
32
  /** Recognise any buddy-owned hook command, however it is pathed. */
28
33
  function isBuddyHookCommand(command: unknown): boolean {
@@ -261,10 +266,31 @@ function installHooks(settings: Record<string, any>, appDir: string) {
261
266
  }
262
267
 
263
268
  function installRuntimeApp(): string {
264
- const appDir = copyRuntimeApp(PROJECT_ROOT, buddyStateDir());
265
- const installArgs = ["install", "--production", "--ignore-scripts"];
266
- if (existsSync(join(appDir, "bun.lock"))) installArgs.push("--frozen-lockfile");
267
- execFileSync("bun", installArgs, { cwd: appDir, stdio: "ignore" });
269
+ const appDir = installRuntimeAppAtomically(PROJECT_ROOT, buddyStateDir(), (stagingDir) => {
270
+ const installArgs = ["install", "--production", "--ignore-scripts"];
271
+ const lockfile = join(stagingDir, "bun.lock");
272
+ if (existsSync(lockfile)) {
273
+ const bunVersion = execFileSync("bun", ["--version"], { encoding: "utf8" }).trim();
274
+ if (supportsTextLockfile(bunVersion)) {
275
+ installArgs.push("--frozen-lockfile");
276
+ } else {
277
+ rmSync(lockfile, { force: true });
278
+ warn(`Bun ${bunVersion} cannot read bun.lock (needs 1.2 or newer); installing dependencies unpinned. Upgrade Bun for a reproducible install.`);
279
+ }
280
+ }
281
+ try {
282
+ execFileSync("bun", installArgs, { cwd: stagingDir, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
283
+ } catch (error) {
284
+ // Show bun's own output: without it a dependency failure reads as a bare
285
+ // "Command failed" and the user cannot tell what broke. The original
286
+ // error is not attached as `cause` because it carries the raw output.
287
+ const failure = error as { stdout?: string; stderr?: string };
288
+ const output = redactSecrets([failure.stdout, failure.stderr].filter(Boolean).join("\n").trim());
289
+ throw new Error(
290
+ `bun ${installArgs.join(" ")} failed${output ? `:\n${output}` : ""}\nThe previous runtime app, if any, was left in place.`,
291
+ );
292
+ }
293
+ });
268
294
  ok(`Stable runtime installed: ${appDir}`);
269
295
  return appDir;
270
296
  }
@@ -1,5 +1,6 @@
1
- import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
1
+ import { cpSync, existsSync, mkdirSync, rmSync, renameSync } from "fs";
2
2
  import { join } from "path";
3
+ import { randomUUID } from "crypto";
3
4
 
4
5
  export const RUNTIME_DIRECTORIES = [
5
6
  "core",
@@ -11,6 +12,13 @@ export const RUNTIME_DIRECTORIES = [
11
12
 
12
13
  export const RUNTIME_FILES = ["package.json", "bun.lock"] as const;
13
14
 
15
+ /**
16
+ * npm never packs a file named bun.lock, so the published tarball carries the
17
+ * lockfile as runtime.bun.lock (written by the prepack script). A git checkout
18
+ * has the real bun.lock instead.
19
+ */
20
+ export const PACKED_LOCKFILE = "runtime.bun.lock";
21
+
14
22
  export function stableRuntimeAppDir(stateDir: string): string {
15
23
  return join(stateDir, "app");
16
24
  }
@@ -25,12 +33,28 @@ export function stableRuntimePaths(appDir: string) {
25
33
  };
26
34
  }
27
35
 
36
+ export const STAGING_PREFIX = "app.staging-";
37
+
28
38
  /**
29
- * Replace the stable runtime copy. The app directory is owned by the
30
- * installer, so removing it first also removes files deleted by an upgrade.
39
+ * A staging directory unique to this install, so two installers running at
40
+ * once (a manual run and a post-apply hook, say) never delete each other's
41
+ * in-progress dependency install.
31
42
  */
32
- export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
33
- const appDir = stableRuntimeAppDir(stateDir);
43
+ export function stagingRuntimeAppDir(stateDir: string): string {
44
+ return join(stateDir, `${STAGING_PREFIX}${process.pid}-${randomUUID().slice(0, 8)}`);
45
+ }
46
+
47
+ /**
48
+ * Write a fresh runtime copy into `targetDir`, the stable app directory by
49
+ * default. The directory is owned by the installer, so removing it first also
50
+ * removes files deleted by an upgrade.
51
+ */
52
+ export function copyRuntimeApp(
53
+ sourceRoot: string,
54
+ stateDir: string,
55
+ targetDir: string = stableRuntimeAppDir(stateDir),
56
+ ): string {
57
+ const appDir = targetDir;
34
58
  rmSync(appDir, { recursive: true, force: true });
35
59
  mkdirSync(appDir, { recursive: true });
36
60
 
@@ -45,5 +69,133 @@ export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
45
69
  if (existsSync(source)) cpSync(source, join(appDir, file), { force: true });
46
70
  }
47
71
 
72
+ const packedLock = join(sourceRoot, PACKED_LOCKFILE);
73
+ const appLock = join(appDir, "bun.lock");
74
+ if (!existsSync(appLock) && existsSync(packedLock)) cpSync(packedLock, appLock);
75
+
48
76
  return appDir;
49
77
  }
78
+
79
+ /**
80
+ * Build the runtime app next to the live one and swap it in only after
81
+ * `prepare` (the dependency install) succeeds. A failed install then leaves
82
+ * the previous working app, and the MCP registration that points at it,
83
+ * untouched.
84
+ */
85
+ export function installRuntimeAppAtomically(
86
+ sourceRoot: string,
87
+ stateDir: string,
88
+ prepare: (stagingDir: string) => void,
89
+ ): string {
90
+ const appDir = stableRuntimeAppDir(stateDir);
91
+ const stagingDir = stagingRuntimeAppDir(stateDir);
92
+ try {
93
+ copyRuntimeApp(sourceRoot, stateDir, stagingDir);
94
+ prepare(stagingDir);
95
+ } catch (error) {
96
+ rmSync(stagingDir, { recursive: true, force: true });
97
+ throw error;
98
+ }
99
+ promoteStagedApp(stagingDir, appDir);
100
+ return appDir;
101
+ }
102
+
103
+ const PROMOTE_ATTEMPTS = 3;
104
+ export const PREVIOUS_PREFIX = "app.previous-";
105
+
106
+ /**
107
+ * Swap the staged app into place without ever leaving the user with no app.
108
+ *
109
+ * The live app is moved aside, not deleted, and is moved back if the staged
110
+ * app cannot take its place. Another installer can recreate `appDir` in the
111
+ * middle of the swap, so the swap retries; if it still cannot rename and a
112
+ * complete app from that other installer is in place, that app is kept.
113
+ * Staging and moved-aside copies are removed on every path.
114
+ */
115
+ export function promoteStagedApp(
116
+ stagingDir: string,
117
+ appDir: string,
118
+ rename: (from: string, to: string) => void = renameSync,
119
+ ): void {
120
+ const backups: string[] = [];
121
+ let lastError: unknown;
122
+ // True only when our move-in failed because an app we did not put there was
123
+ // in the way: another installer finished between our two renames.
124
+ let displacedByOther = false;
125
+ // Where the previous app was left if it could be neither replaced nor put
126
+ // back. Kept across retries so the final error can always name it.
127
+ let stranded: string | undefined;
128
+
129
+ for (let attempt = 0; attempt < PROMOTE_ATTEMPTS; attempt++) {
130
+ const backup = `${appDir}.previous-${process.pid}-${randomUUID().slice(0, 8)}`;
131
+ let movedAside = false;
132
+ let promoting = false;
133
+ displacedByOther = false;
134
+ try {
135
+ if (existsSync(appDir)) {
136
+ backups.push(backup);
137
+ rename(appDir, backup);
138
+ movedAside = true;
139
+ }
140
+ promoting = true;
141
+ rename(stagingDir, appDir);
142
+ for (const dir of backups) rmSync(dir, { recursive: true, force: true });
143
+ if (stranded) rmSync(stranded, { recursive: true, force: true });
144
+ return;
145
+ } catch (error) {
146
+ lastError = error;
147
+ displacedByOther = promoting && existsSync(appDir);
148
+ if (movedAside && !existsSync(appDir)) {
149
+ try {
150
+ rename(backup, appDir);
151
+ } catch {
152
+ // Leave the moved-aside copy where it is; the final error names it.
153
+ backups.splice(backups.indexOf(backup), 1);
154
+ stranded = backup;
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ rmSync(stagingDir, { recursive: true, force: true });
161
+ for (const dir of backups) rmSync(dir, { recursive: true, force: true });
162
+ // Another installer's complete app is in place: keep it. Every other
163
+ // failure, including never getting the old app out of the way, is an error.
164
+ if (displacedByOther && existsSync(stableRuntimePaths(appDir).mcpServer)) {
165
+ if (stranded) rmSync(stranded, { recursive: true, force: true });
166
+ return;
167
+ }
168
+ if (stranded) {
169
+ throw new Error(
170
+ `Could not install the new runtime app or put the previous one back. The previous app is at ${stranded}; rename it to ${appDir} to recover.`,
171
+ { cause: lastError },
172
+ );
173
+ }
174
+ throw lastError;
175
+ }
176
+
177
+ /**
178
+ * bun.lock is Bun's text lockfile, which Bun reads from 1.2.0 on. Older Bun
179
+ * rejects it under --frozen-lockfile, so the installer drops the lockfile for
180
+ * those versions and installs unpinned, as it did before the lockfile shipped.
181
+ */
182
+ export function supportsTextLockfile(bunVersion: string): boolean {
183
+ const match = /^(\d+)\.(\d+)\./.exec(bunVersion.trim());
184
+ if (!match) return false;
185
+ const major = Number(match[1]);
186
+ const minor = Number(match[2]);
187
+ return major > 1 || (major === 1 && minor >= 2);
188
+ }
189
+
190
+ /**
191
+ * Strip registry credentials from tool output before it is shown. Bun before
192
+ * 1.1.34 could echo an .npmrc token in a warning.
193
+ */
194
+ export function redactSecrets(text: string): string {
195
+ return text
196
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1[redacted]@")
197
+ .replace(/(_authToken|_auth|_password)\s*=\s*\S+/gi, "$1=[redacted]")
198
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, "$1 [redacted]")
199
+ .replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "[redacted]");
200
+ }
201
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -38,6 +38,8 @@
38
38
  "gen:emoji-widths": "bun run scripts/gen-emoji-widths.ts",
39
39
  "sync-version": "bun run scripts/sync-version.ts",
40
40
  "version": "bun run sync-version && git add .claude-plugin",
41
+ "prepack": "node -e \"require('fs').copyFileSync('bun.lock','runtime.bun.lock')\"",
42
+ "postpack": "node -e \"require('fs').rmSync('runtime.bun.lock',{force:true})\"",
41
43
  "ci:statusline": "bash scripts/ci/check-statusline.sh",
42
44
  "ci:vhs:render": "bash scripts/ci/render-vhs-goldens.sh",
43
45
  "ci:vhs:update": "bash scripts/ci/render-vhs-goldens.sh --update",
@@ -53,6 +55,7 @@
53
55
  "statusline/",
54
56
  "scripts/",
55
57
  ".claude-plugin/",
58
+ "runtime.bun.lock",
56
59
  "!**/*.test.ts"
57
60
  ],
58
61
  "keywords": [