@phren/cli 0.0.23 → 0.0.25

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.
@@ -290,9 +290,10 @@ export function repairPreexistingInstall(phrenPath) {
290
290
  function isExpectedVerifyFailure(phrenPath, check) {
291
291
  if (check.ok)
292
292
  return false;
293
- if (check.name === "git-remote")
294
- return true;
295
293
  const prefs = readInstallPreferences(phrenPath);
294
+ // git-remote failure is only expected when the user chose local-only (no clone URL)
295
+ if (check.name === "git-remote")
296
+ return prefs.syncIntent !== "sync";
296
297
  if (check.name === "mcp-config" && prefs.mcpEnabled === false)
297
298
  return true;
298
299
  if (check.name === "hooks-registered" && prefs.hooksEnabled === false)
@@ -339,17 +340,30 @@ function gitRemoteStatus(phrenPath) {
339
340
  catch {
340
341
  return { ok: false, detail: "phren path is not a git repository" };
341
342
  }
343
+ let remote;
342
344
  try {
343
- const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
345
+ remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
344
346
  encoding: "utf8",
345
347
  stdio: ["ignore", "pipe", "ignore"],
346
348
  timeout: EXEC_TIMEOUT_QUICK_MS,
347
349
  }).trim();
348
- return remote ? { ok: true, detail: `origin=${remote}` } : { ok: false, detail: "git origin remote not configured" };
350
+ if (!remote)
351
+ return { ok: false, detail: "git origin remote not configured" };
349
352
  }
350
353
  catch {
351
354
  return { ok: false, detail: "git origin remote not configured" };
352
355
  }
356
+ // Connectivity test: verify the remote is reachable (10s timeout)
357
+ try {
358
+ execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
359
+ stdio: ["ignore", "ignore", "ignore"],
360
+ timeout: 10_000,
361
+ });
362
+ return { ok: true, detail: `origin=${remote}` };
363
+ }
364
+ catch {
365
+ return { ok: false, detail: `origin=${remote} (configured but unreachable)` };
366
+ }
353
367
  }
354
368
  function copyStarterFile(phrenPath, src, dest) {
355
369
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -1147,14 +1161,22 @@ export function runPostInitVerify(phrenPath) {
1147
1161
  }
1148
1162
  else {
1149
1163
  const gitRemote = gitRemoteStatus(phrenPath);
1164
+ const wantSync = prefs.syncIntent === "sync";
1150
1165
  const gitRemoteDetail = gitRemote.ok
1151
1166
  ? gitRemote.detail
1152
- : `${gitRemote.detail} (optional unless you want cross-machine sync)`;
1167
+ : wantSync
1168
+ ? `${gitRemote.detail} — sync was configured but remote is missing or unreachable`
1169
+ : `${gitRemote.detail} (optional unless you want cross-machine sync)`;
1170
+ const gitRemoteFix = gitRemote.ok
1171
+ ? undefined
1172
+ : wantSync
1173
+ ? `Your clone URL didn't work. Fix: cd ${phrenPath} && git remote add origin <URL> && git push -u origin main`
1174
+ : "Optional: initialize a repo and add an origin remote for cross-machine sync.";
1153
1175
  checks.push({
1154
1176
  name: "git-remote",
1155
1177
  ok: gitRemote.ok,
1156
1178
  detail: gitRemoteDetail,
1157
- fix: gitRemote.ok ? undefined : "Optional: initialize a repo and add an origin remote for cross-machine sync.",
1179
+ fix: gitRemoteFix,
1158
1180
  });
1159
1181
  const settingsPath = hookConfigPath("claude");
1160
1182
  const configWritable = nearestWritableTarget(settingsPath);
package/mcp/dist/init.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import * as crypto from "crypto";
8
- import { execFileSync } from "child_process";
8
+ import { execFileSync, spawnSync } from "child_process";
9
9
  import { configureAllHooks } from "./hooks.js";
10
10
  import { getMachineName, machineFilePath, persistMachineName } from "./machine-identity.js";
11
11
  import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, getProjectDirs, readRootManifest, writeRootManifest, } from "./shared.js";
@@ -24,6 +24,7 @@ import { DEFAULT_PHREN_PATH, STARTER_DIR, VERSION, log, confirmPrompt } from "./
24
24
  import { PROJECT_OWNERSHIP_MODES, getProjectOwnershipDefault, } from "./project-config.js";
25
25
  import { getWorkflowPolicy, updateWorkflowPolicy } from "./shared-governance.js";
26
26
  import { addProjectToProfile } from "./profile-store.js";
27
+ const PHREN_NPM_PACKAGE_NAME = "@phren/cli";
27
28
  function parseVersion(version) {
28
29
  const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?/);
29
30
  if (!match)
@@ -58,6 +59,56 @@ export function isVersionNewer(current, previous) {
58
59
  return true;
59
60
  return c.pre > p.pre;
60
61
  }
62
+ function getNpmCommand() {
63
+ return process.platform === "win32" ? "npm.cmd" : "npm";
64
+ }
65
+ function runSyncCommand(command, args) {
66
+ try {
67
+ const result = spawnSync(command, args, {
68
+ encoding: "utf8",
69
+ stdio: ["ignore", "pipe", "pipe"],
70
+ });
71
+ return {
72
+ ok: result.status === 0,
73
+ status: result.status,
74
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
75
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
76
+ };
77
+ }
78
+ catch (err) {
79
+ return {
80
+ ok: false,
81
+ status: null,
82
+ stdout: "",
83
+ stderr: errorMessage(err),
84
+ };
85
+ }
86
+ }
87
+ function shouldUninstallCurrentGlobalPackage() {
88
+ const entryScript = process.argv[1];
89
+ if (!entryScript)
90
+ return false;
91
+ const npmRootResult = runSyncCommand(getNpmCommand(), ["root", "-g"]);
92
+ if (!npmRootResult.ok)
93
+ return false;
94
+ const npmRoot = npmRootResult.stdout.trim();
95
+ if (!npmRoot)
96
+ return false;
97
+ const resolvedEntryScript = path.resolve(entryScript);
98
+ const resolvedGlobalPackageRoot = path.resolve(path.join(npmRoot, PHREN_NPM_PACKAGE_NAME));
99
+ return resolvedEntryScript === resolvedGlobalPackageRoot
100
+ || resolvedEntryScript.startsWith(`${resolvedGlobalPackageRoot}${path.sep}`);
101
+ }
102
+ function uninstallCurrentGlobalPackage() {
103
+ const result = runSyncCommand(getNpmCommand(), ["uninstall", "-g", PHREN_NPM_PACKAGE_NAME]);
104
+ if (result.ok) {
105
+ log(` Removed global npm package (${PHREN_NPM_PACKAGE_NAME})`);
106
+ return;
107
+ }
108
+ const detail = result.stderr.trim() || result.stdout.trim() || (result.status === null ? "failed to start command" : `exit code ${result.status}`);
109
+ log(` Warning: could not remove global npm package (${PHREN_NPM_PACKAGE_NAME})`);
110
+ debugLog(`uninstall: global npm cleanup failed: ${detail}`);
111
+ }
61
112
  export function parseMcpMode(raw) {
62
113
  if (!raw)
63
114
  return undefined;
@@ -1037,9 +1088,23 @@ export async function runInit(opts = {}) {
1037
1088
  }
1038
1089
  catch (e) {
1039
1090
  log(` Clone failed: ${e instanceof Error ? e.message : String(e)}`);
1040
- log(` Continuing with fresh install instead.`);
1091
+ log("");
1092
+ log(" ┌──────────────────────────────────────────────────────────────────┐");
1093
+ log(" │ WARNING: Sync is NOT configured. Your phren data is local-only. │");
1094
+ log(" │ │");
1095
+ log(" │ To fix later: │");
1096
+ log(` │ cd ${phrenPath}`);
1097
+ log(" │ git remote add origin <YOUR_REPO_URL> │");
1098
+ log(" │ git push -u origin main │");
1099
+ log(" └──────────────────────────────────────────────────────────────────┘");
1100
+ log("");
1101
+ log(` Continuing with fresh local-only install.`);
1041
1102
  }
1042
1103
  }
1104
+ // Record sync intent: "sync" if a clone URL was provided (regardless of success), "local" otherwise.
1105
+ // On re-runs of existing installs, preserve the existing syncIntent unless the user provided a new clone URL.
1106
+ const existingSyncIntent = hasExistingInstall ? readInstallPreferences(phrenPath).syncIntent : undefined;
1107
+ const syncIntent = opts._walkthroughCloneUrl ? "sync" : (existingSyncIntent ?? "local");
1043
1108
  const mcpEnabled = opts.mcp ? opts.mcp === "on" : getMcpEnabledPreference(phrenPath);
1044
1109
  const hooksEnabled = opts.hooks ? opts.hooks === "on" : getHooksEnabledPreference(phrenPath);
1045
1110
  const skillsScope = opts.skillsScope ?? "global";
@@ -1194,7 +1259,7 @@ export async function runInit(opts = {}) {
1194
1259
  log(` No starter template updates were applied (starter files not found).`);
1195
1260
  }
1196
1261
  }
1197
- writeInstallPreferences(phrenPath, { mcpEnabled, hooksEnabled, skillsScope, installedVersion: VERSION });
1262
+ writeInstallPreferences(phrenPath, { mcpEnabled, hooksEnabled, skillsScope, installedVersion: VERSION, syncIntent });
1198
1263
  if (repaired.removedLegacyProjects > 0) {
1199
1264
  log(` Removed ${repaired.removedLegacyProjects} legacy starter project entr${repaired.removedLegacyProjects === 1 ? "y" : "ies"} from profiles.`);
1200
1265
  }
@@ -1372,7 +1437,7 @@ export async function runInit(opts = {}) {
1372
1437
  // Configure MCP for all detected AI coding tools and hooks
1373
1438
  configureMcpTargets(phrenPath, { mcpEnabled, hooksEnabled }, "Configured");
1374
1439
  configureHooksIfEnabled(phrenPath, hooksEnabled, "Configured");
1375
- writeInstallPreferences(phrenPath, { mcpEnabled, hooksEnabled, skillsScope, installedVersion: VERSION });
1440
+ writeInstallPreferences(phrenPath, { mcpEnabled, hooksEnabled, skillsScope, installedVersion: VERSION, syncIntent });
1376
1441
  // Post-init verification
1377
1442
  log(`\nVerifying setup...`);
1378
1443
  const verify = runPostInitVerify(phrenPath);
@@ -1718,6 +1783,7 @@ export async function runUninstall(opts = {}) {
1718
1783
  return;
1719
1784
  }
1720
1785
  log("\nUninstalling phren...\n");
1786
+ const shouldRemoveGlobalPackage = shouldUninstallCurrentGlobalPackage();
1721
1787
  // Confirmation prompt (shared-mode only — project-local is low-stakes)
1722
1788
  if (!opts.yes) {
1723
1789
  const confirmed = phrenPath
@@ -1910,6 +1976,16 @@ export async function runUninstall(opts = {}) {
1910
1976
  catch (err) {
1911
1977
  debugLog(`uninstall: cleanup failed for ${machineFile}: ${errorMessage(err)}`);
1912
1978
  }
1979
+ const contextFile = homePath(".phren-context.md");
1980
+ try {
1981
+ if (fs.existsSync(contextFile)) {
1982
+ fs.unlinkSync(contextFile);
1983
+ log(` Removed machine context file (${contextFile})`);
1984
+ }
1985
+ }
1986
+ catch (err) {
1987
+ debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
1988
+ }
1913
1989
  // Sweep agent skill directories for symlinks pointing into the phren store
1914
1990
  if (phrenPath) {
1915
1991
  try {
@@ -1929,6 +2005,9 @@ export async function runUninstall(opts = {}) {
1929
2005
  log(` Warning: could not remove phren root (${phrenPath})`);
1930
2006
  }
1931
2007
  }
2008
+ if (shouldRemoveGlobalPackage) {
2009
+ uninstallCurrentGlobalPackage();
2010
+ }
1932
2011
  log(`\nPhren config, hooks, and installed data removed.`);
1933
2012
  log(`Restart your agent(s) to apply changes.\n`);
1934
2013
  }
@@ -163,15 +163,52 @@ export function register(server, ctx) {
163
163
  if ((process.env.PHREN_DEBUG))
164
164
  process.stderr.write(`[phren] healthCheck taskMode: ${errorMessage(err)}\n`);
165
165
  }
166
+ let syncIntent;
166
167
  try {
167
168
  const { readInstallPreferences } = await import("./init-preferences.js");
168
169
  const prefs = readInstallPreferences(phrenPath);
169
170
  proactivity = prefs.proactivity || "high";
171
+ syncIntent = prefs.syncIntent;
170
172
  }
171
173
  catch (err) {
172
174
  if ((process.env.PHREN_DEBUG))
173
175
  process.stderr.write(`[phren] healthCheck proactivity: ${errorMessage(err)}\n`);
174
176
  }
177
+ // Determine sync status from intent + git remote state
178
+ let syncStatus = "local-only";
179
+ let syncDetail = "no git remote configured";
180
+ try {
181
+ const { execFileSync } = await import("child_process");
182
+ const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
183
+ encoding: "utf8",
184
+ stdio: ["ignore", "pipe", "ignore"],
185
+ timeout: 5_000,
186
+ }).trim();
187
+ if (remote) {
188
+ try {
189
+ execFileSync("git", ["-C", phrenPath, "ls-remote", "--exit-code", "origin"], {
190
+ stdio: ["ignore", "ignore", "ignore"],
191
+ timeout: 10_000,
192
+ });
193
+ syncStatus = "synced";
194
+ syncDetail = `origin=${remote}`;
195
+ }
196
+ catch {
197
+ syncStatus = syncIntent === "sync" ? "broken" : "local-only";
198
+ syncDetail = `origin=${remote} (unreachable)`;
199
+ }
200
+ }
201
+ else if (syncIntent === "sync") {
202
+ syncStatus = "broken";
203
+ syncDetail = "sync was configured but no remote found";
204
+ }
205
+ }
206
+ catch {
207
+ if (syncIntent === "sync") {
208
+ syncStatus = "broken";
209
+ syncDetail = "sync was configured but no remote found";
210
+ }
211
+ }
175
212
  const lines = [
176
213
  `Phren v${version}`,
177
214
  `Profile: ${activeProfile || "(default)"}`,
@@ -182,6 +219,7 @@ export function register(server, ctx) {
182
219
  `Hooks: ${hooksEnabled ? "enabled" : "disabled"}`,
183
220
  `Proactivity: ${proactivity}`,
184
221
  `Task mode: ${taskMode}`,
222
+ `Sync: ${syncStatus}${syncStatus !== "synced" ? ` (${syncDetail})` : ""}`,
185
223
  `Path: ${phrenPath}`,
186
224
  ].filter(Boolean);
187
225
  return mcpResponse({
@@ -197,6 +235,8 @@ export function register(server, ctx) {
197
235
  hooksEnabled,
198
236
  proactivity,
199
237
  taskMode,
238
+ syncStatus,
239
+ syncDetail,
200
240
  phrenPath,
201
241
  },
202
242
  });