@pify/cli 0.1.2 → 0.1.3

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 CHANGED
@@ -21,6 +21,7 @@ npx @pify/cli setup
21
21
  ```
22
22
  pify setup # install the pi coding agent (safe to re-run)
23
23
  pify setup --pi-version 0.84.4 # pin the version your team has tested
24
+ pify setup --installer # run pi's official interactive installer (ps1/sh)
24
25
  pify list # show the @pify catalog with install state
25
26
  pify install goal task # short names resolve to @pify/goal, @pify/task
26
27
  pify install goal -l -a # project scope, pre-approved (CI-friendly)
@@ -37,7 +38,7 @@ Every command supports `--help`; `install`/`remove`/`update` support `--dry-run`
37
38
 
38
39
  `pify` deliberately does **not** replace pi's package manager. Every package operation delegates to `pi install` / `pi remove` / `pi update`, which own `~/.pi/agent/settings.json` and the extension npm workspace — pify never edits pi's settings. What `pify` adds:
39
40
 
40
- - **Bootstrap** — `pi update` can't run when pi isn't installed yet; `pify setup` closes that gap (`npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, matching pi's own self-update invocation).
41
+ - **Bootstrap** — `pi update` can't run when pi isn't installed yet; `pify setup` closes that gap with the same invocation as pi's official installers (`npm install -g --ignore-scripts --min-release-age=0 --no-fund --no-audit`), falling back to `bun add -g --ignore-scripts` when npm is absent and to `--prefix ~/.local` on POSIX when npm's global prefix is not writable (never sudo). `pify setup --installer` hands off to the official interactive installer (`install.ps1` on Windows, `install.sh` on Unix), which can bootstrap Node and Git Bash too.
41
42
  - **Catalog** — `@pify/*` short names, discovery (including packages that are planned but not yet published), and install state at a glance.
42
43
  - **One-pass update** — `pify update` chains pi's self-update with per-package updates of everything from this org, without touching non-Pify packages you manage separately.
43
44
  - **Doctor** — environment sanity checks for support requests.
package/dist/cli.js CHANGED
@@ -4,9 +4,9 @@
4
4
  import { parseArgs } from "util";
5
5
 
6
6
  // src/pi.ts
7
- import { readFileSync, realpathSync } from "fs";
7
+ import { accessSync as accessSync2, constants as constants2, readFileSync, realpathSync, statSync as statSync2 } from "fs";
8
8
  import { homedir } from "os";
9
- import { join as join2 } from "path";
9
+ import { dirname, join as join2 } from "path";
10
10
 
11
11
  // src/exec.ts
12
12
  import { spawn, spawnSync } from "child_process";
@@ -97,6 +97,17 @@ function runInherit(command, args) {
97
97
  child.on("close", (code) => resolve2(code ?? 1));
98
98
  });
99
99
  }
100
+ function runOfficialInstaller(kind) {
101
+ const [cmd, argv] = kind === "windows" ? [
102
+ "powershell",
103
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "irm https://pi.dev/install.ps1 | iex"]
104
+ ] : ["sh", ["-c", "curl -fsSL https://pi.dev/install.sh | sh"]];
105
+ return new Promise((resolve2, reject) => {
106
+ const child = spawn(cmd, argv, { stdio: "inherit", windowsHide: false });
107
+ child.on("error", reject);
108
+ child.on("close", (code) => resolve2(code ?? 1));
109
+ });
110
+ }
100
111
  function which(command) {
101
112
  const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
102
113
  if (isWindows) {
@@ -258,38 +269,107 @@ function guardNpmManaged(binPath) {
258
269
  );
259
270
  }
260
271
  }
272
+ function officialInstallerCommand() {
273
+ return isWindows ? 'powershell -c "irm https://pi.dev/install.ps1 | iex"' : "curl -fsSL https://pi.dev/install.sh | sh";
274
+ }
275
+ function writableOrCreatable(path) {
276
+ let current = path;
277
+ for (; ; ) {
278
+ try {
279
+ statSync2(current);
280
+ break;
281
+ } catch {
282
+ const parent = dirname(current);
283
+ if (parent === current) return false;
284
+ current = parent;
285
+ }
286
+ }
287
+ try {
288
+ if (!statSync2(current).isDirectory()) return false;
289
+ accessSync2(current, constants2.W_OK);
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+ function npmInstallPiArgs(spec, npmMajor, prefix) {
296
+ const args = ["install", "-g", "--ignore-scripts"];
297
+ if (npmMajor >= 11) args.push("--min-release-age=0");
298
+ args.push("--no-fund", "--no-audit");
299
+ if (prefix) args.push("--prefix", prefix);
300
+ args.push(spec);
301
+ return args;
302
+ }
303
+ function npmMajorVersion() {
304
+ const res = runCapture("npm", ["--version"]);
305
+ const major = Number.parseInt(res.stdout.trim().split(".")[0] ?? "", 10);
306
+ return Number.isNaN(major) ? 0 : major;
307
+ }
261
308
  async function installPi(version) {
262
309
  assertNodeVersion();
263
- if (!which("npm")) {
264
- throw environmentError(
265
- "npm was not found on PATH.",
266
- "Install Node.js (which bundles npm) from https://nodejs.org and retry."
267
- );
268
- }
269
310
  if (process.env.PI_MANAGED_INSTALL_ROOT && !piStatus().installed) {
270
311
  throw environmentError(
271
312
  "PI_MANAGED_INSTALL_ROOT is set but pi is not installed.",
272
- "Use the managed installer: curl -fsSL https://pi.dev/install.sh | sh (Linux/macOS)."
313
+ `Use the official installer: ${officialInstallerCommand()}`
273
314
  );
274
315
  }
275
316
  const spec = version ? `${PI_PACKAGE}@${version}` : PI_PACKAGE;
276
- step(`npm install -g --ignore-scripts ${spec}`);
277
- const code = await runInherit("npm", ["install", "-g", "--ignore-scripts", spec]);
317
+ const npmPath = which("npm");
318
+ const bunPath = which("bun");
319
+ if (!npmPath && !bunPath) {
320
+ throw environmentError(
321
+ "Neither npm nor bun was found on PATH.",
322
+ `Run the official installer (it can install Node too): ${officialInstallerCommand()}`
323
+ );
324
+ }
325
+ let command;
326
+ let args;
327
+ let localPrefix = null;
328
+ if (npmPath) {
329
+ command = "npm";
330
+ if (!isWindows) {
331
+ const prefixRes = runCapture("npm", ["prefix", "-g"]);
332
+ const prefix = prefixRes.status === 0 ? prefixRes.stdout.trim() : "";
333
+ const prefixWritable = prefix !== "" && writableOrCreatable(join2(prefix, "lib", "node_modules")) && writableOrCreatable(join2(prefix, "bin"));
334
+ if (prefix && !prefixWritable) {
335
+ let staleGlobalPi = false;
336
+ try {
337
+ statSync2(join2(prefix, "bin", "pi"));
338
+ staleGlobalPi = true;
339
+ } catch {
340
+ }
341
+ if (staleGlobalPi) {
342
+ throw environmentError(
343
+ `npm's global directory is not writable (${prefix}) and pi is already installed there.`,
344
+ `Update or remove it first: sudo npm install -g --ignore-scripts ${PI_PACKAGE}`
345
+ );
346
+ }
347
+ localPrefix = join2(homedir(), ".local");
348
+ }
349
+ }
350
+ args = npmInstallPiArgs(spec, npmMajorVersion(), localPrefix ?? void 0);
351
+ } else {
352
+ command = "bun";
353
+ args = ["add", "-g", "--ignore-scripts", spec];
354
+ }
355
+ step(`${command} ${args.join(" ")}`);
356
+ const code = await runInherit(command, args);
278
357
  if (code !== 0) {
279
358
  throw new PifyError(
280
- `npm install -g ${spec} exited with code ${code}.`,
359
+ `${command} ${args[0]} exited with code ${code}.`,
281
360
  ExitCode.SUBPROCESS,
282
- isWindows ? "If this is a permissions or file-lock error, close running pi sessions and retry from an elevated terminal." : "If this is an EACCES error, configure a user-writable npm prefix (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally) or use a Node version manager."
361
+ isWindows ? "If this is a permissions or file-lock error, close running pi sessions and retry from an elevated terminal." : `If the problem persists, try the official installer: ${officialInstallerCommand()}`
283
362
  );
284
363
  }
285
364
  const st = piStatus();
286
365
  if (!st.installed) {
287
- throw environmentError(
288
- "pi was installed but is not resolvable on PATH.",
289
- "Add npm's global prefix to PATH (npm prefix -g) and open a new terminal."
290
- );
366
+ const pathHint = localPrefix ? `Add ${join2(localPrefix, "bin")} to PATH and open a new terminal.` : "Add the global bin directory to PATH (npm prefix -g) and open a new terminal.";
367
+ throw environmentError("pi was installed but is not resolvable on PATH.", pathHint);
291
368
  }
292
369
  success(`pi ${st.version ?? ""} installed.`.replace(" ", " "));
370
+ if (localPrefix) {
371
+ hint(`Installed under ${localPrefix} because npm's global prefix is not writable.`);
372
+ }
293
373
  }
294
374
  function requirePi() {
295
375
  if (!piStatus().installed) {
@@ -349,12 +429,19 @@ function installedVersionOnDisk(name, scope) {
349
429
 
350
430
  // src/commands/setup.ts
351
431
  async function setup(opts) {
352
- assertNodeVersion();
353
- if (!which("npm")) {
354
- throw environmentError(
355
- "npm was not found on PATH.",
356
- "Install Node.js (which bundles npm) from https://nodejs.org and retry."
357
- );
432
+ if (opts.installer) {
433
+ if (!process.stdin.isTTY || process.env.CI) {
434
+ throw environmentError(
435
+ "The official installer is interactive and needs a terminal.",
436
+ "In CI, use plain `pify setup` (non-interactive npm path) instead."
437
+ );
438
+ }
439
+ step(officialInstallerCommand());
440
+ const code = await runOfficialInstaller(isWindows ? "windows" : "posix");
441
+ if (code !== 0) {
442
+ throw new PifyError(`The official installer exited with code ${code}.`, ExitCode.SUBPROCESS);
443
+ }
444
+ return 0;
358
445
  }
359
446
  const st = piStatus();
360
447
  if (st.installed && !opts.force && !opts.piVersion) {
@@ -381,10 +468,10 @@ async function setup(opts) {
381
468
  // src/catalog.ts
382
469
  import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "fs";
383
470
  import { homedir as homedir2 } from "os";
384
- import { dirname, join as join3 } from "path";
471
+ import { dirname as dirname2, join as join3 } from "path";
385
472
  import { fileURLToPath } from "url";
386
473
  function bundledCatalogPath() {
387
- return join3(dirname(fileURLToPath(import.meta.url)), "..", "catalog.json");
474
+ return join3(dirname2(fileURLToPath(import.meta.url)), "..", "catalog.json");
388
475
  }
389
476
  function cachePath() {
390
477
  return join3(homedir2(), ".pify", "catalog.json");
@@ -459,7 +546,7 @@ function readCache() {
459
546
  }
460
547
  function writeCache(catalog) {
461
548
  try {
462
- mkdirSync(dirname(cachePath()), { recursive: true });
549
+ mkdirSync(dirname2(cachePath()), { recursive: true });
463
550
  writeFileSync(cachePath(), JSON.stringify({ fetchedAt: Date.now(), catalog }, null, 2));
464
551
  } catch {
465
552
  }
@@ -822,6 +909,14 @@ async function doctor(opts) {
822
909
  gitPath ? "ok" : "warn",
823
910
  gitPath ?? "git not found - pi's git: package sources will not work"
824
911
  );
912
+ if (isWindows) {
913
+ const bashPath = which("bash");
914
+ push(
915
+ "bash",
916
+ bashPath ? "ok" : "warn",
917
+ bashPath ?? "bash not found - pi needs Git Bash on Windows; run pify setup --installer"
918
+ );
919
+ }
825
920
  const pi = piStatus();
826
921
  if (!pi.installed) {
827
922
  push("pi", "fail", "not installed - run: pify setup");
@@ -1159,15 +1254,21 @@ settings. Anything beyond these commands is pi's job: run pi --help.
1159
1254
  }
1160
1255
  var COMMAND_HELP = {
1161
1256
  setup: `Usage:
1162
- pify setup [--force] [--pi-version <version>]
1257
+ pify setup [--force] [--pi-version <version>] [--installer]
1163
1258
 
1164
- Install the pi coding agent globally via npm. Safe to re-run; reports status
1165
- when pi is already installed. Updates of an existing pi are pi's own job:
1166
- run pify update pi.
1259
+ Install the pi coding agent. Safe to re-run; reports status when pi is
1260
+ already installed. Uses the same npm invocation as pi's official installers
1261
+ (--ignore-scripts --min-release-age=0), falls back to bun when npm is
1262
+ absent, and to --prefix ~/.local on POSIX when npm's global prefix is not
1263
+ writable (never sudo). Updates of an existing pi are pi's own job: run
1264
+ pify update pi.
1167
1265
 
1168
1266
  Options:
1169
1267
  --force Reinstall pi even if it is already installed
1170
1268
  --pi-version <version> Install an exact pi version (may downgrade)
1269
+ --installer Run pi's official interactive installer for
1270
+ this OS (can bootstrap Node and Git Bash;
1271
+ needs a terminal, refuses in CI)
1171
1272
 
1172
1273
  Examples:
1173
1274
  # Bootstrap a bare machine
@@ -1175,6 +1276,9 @@ Examples:
1175
1276
 
1176
1277
  # Pin the version your team has tested
1177
1278
  pify setup --pi-version 0.84.4
1279
+
1280
+ # Full native experience (Windows: install.ps1, Unix: install.sh)
1281
+ pify setup --installer
1178
1282
  `,
1179
1283
  install: `Usage:
1180
1284
  pify install <name...> [-l] [-a] [--dry-run]
@@ -1283,10 +1387,15 @@ var COMMANDS = [
1283
1387
  aliases: [],
1284
1388
  options: {
1285
1389
  force: { type: "boolean", default: false },
1286
- "pi-version": { type: "string" }
1390
+ "pi-version": { type: "string" },
1391
+ installer: { type: "boolean", default: false }
1287
1392
  },
1288
1393
  allowPositionals: false,
1289
- run: (_p, v) => setup({ force: Boolean(v.force), piVersion: v["pi-version"] })
1394
+ run: (_p, v) => setup({
1395
+ force: Boolean(v.force),
1396
+ piVersion: v["pi-version"],
1397
+ installer: Boolean(v.installer)
1398
+ })
1290
1399
  },
1291
1400
  {
1292
1401
  name: "install",
package/dist/index.d.ts CHANGED
@@ -92,10 +92,29 @@ declare function piStatus(): PiStatus;
92
92
  /** Latest pi version from pi's own release endpoint. Null offline/on error. */
93
93
  declare function fetchLatestPiVersion(): Promise<string | null>;
94
94
  declare function compareSemver(a: string, b: string): number;
95
+ /** The official installer one-liner for this OS, for hints and --installer. */
96
+ declare function officialInstallerCommand(): string;
95
97
  /**
96
- * Install pi globally via npm. This is the one operation pi cannot do for
97
- * itself — `pi update --self` requires a working pi. `--ignore-scripts`
98
- * matches pi's own self-update invocation (supply-chain-safe default).
98
+ * The npm install argv pi's official installers use:
99
+ * `--ignore-scripts` (supply-chain-safe; matches pi's self-update),
100
+ * `--min-release-age=0` (pi ships npm-shrinkwrap.json, so bypassing npm's
101
+ * release-age gate does not reopen transitive ranges; npm >= 11 only),
102
+ * `--no-fund --no-audit` (noise). Exported for tests.
103
+ */
104
+ declare function npmInstallPiArgs(spec: string, npmMajor: number, prefix?: string): string[];
105
+ /**
106
+ * Install pi with the same decisions as pi's official installers, minus the
107
+ * interactive parts. This is the one operation pi cannot do for itself —
108
+ * `pi update` requires a working pi.
109
+ *
110
+ * Per-OS behavior (ported from install.sh / install.ps1):
111
+ * - POSIX, npm prefix unwritable, no global pi there: fall back to
112
+ * `--prefix ~/.local` — never sudo.
113
+ * - POSIX, npm prefix unwritable but an old global pi lives there: stop
114
+ * (a ~/.local copy would be shadowed by the stale global one).
115
+ * - No npm but bun on PATH: `bun add -g --ignore-scripts` (documented
116
+ * alternative channel).
117
+ * - Neither: point at the official installer, which can bootstrap Node too.
99
118
  */
100
119
  declare function installPi(version?: string): Promise<void>;
101
120
  /**
@@ -192,4 +211,4 @@ declare class PifyError extends Error {
192
211
  constructor(message: string, code?: ExitCodeValue, hint?: string);
193
212
  }
194
213
 
195
- export { type Catalog, type CatalogPackage, ExitCode, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, README_MD, TESTED_PI_VERSION, type TemplateVars, VERSION, agentDir, assertSafeArg, compareSemver, fetchLatestPiVersion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, packageJsonTemplate, parseInstallSpec, piStatus, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
214
+ export { type Catalog, type CatalogPackage, ExitCode, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, README_MD, TESTED_PI_VERSION, type TemplateVars, VERSION, agentDir, assertSafeArg, compareSemver, fetchLatestPiVersion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, npmInstallPiArgs, officialInstallerCommand, packageJsonTemplate, parseInstallSpec, piStatus, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
package/dist/index.js CHANGED
@@ -34,21 +34,21 @@ var PifyError = class extends Error {
34
34
  code;
35
35
  /** Optional follow-up shown after the message, e.g. a command to run. */
36
36
  hint;
37
- constructor(message, code = ExitCode.FAILURE, hint) {
37
+ constructor(message, code = ExitCode.FAILURE, hint2) {
38
38
  super(message);
39
39
  this.name = "PifyError";
40
40
  this.code = code;
41
- this.hint = hint;
41
+ this.hint = hint2;
42
42
  }
43
43
  };
44
- function usageError(message, hint) {
45
- return new PifyError(message, ExitCode.USAGE, hint);
44
+ function usageError(message, hint2) {
45
+ return new PifyError(message, ExitCode.USAGE, hint2);
46
46
  }
47
- function environmentError(message, hint) {
48
- return new PifyError(message, ExitCode.ENVIRONMENT, hint);
47
+ function environmentError(message, hint2) {
48
+ return new PifyError(message, ExitCode.ENVIRONMENT, hint2);
49
49
  }
50
- function notFoundError(message, hint) {
51
- return new PifyError(message, ExitCode.NOT_FOUND, hint);
50
+ function notFoundError(message, hint2) {
51
+ return new PifyError(message, ExitCode.NOT_FOUND, hint2);
52
52
  }
53
53
 
54
54
  // src/exec.ts
@@ -305,9 +305,9 @@ function resolvePifyName(input) {
305
305
  }
306
306
 
307
307
  // src/pi.ts
308
- import { readFileSync as readFileSync2, realpathSync } from "fs";
308
+ import { accessSync as accessSync2, constants as constants2, readFileSync as readFileSync2, realpathSync, statSync as statSync2 } from "fs";
309
309
  import { homedir as homedir2 } from "os";
310
- import { join as join3 } from "path";
310
+ import { dirname as dirname2, join as join3 } from "path";
311
311
 
312
312
  // src/ui.ts
313
313
  function detectColor(stream) {
@@ -340,6 +340,10 @@ function success(msg) {
340
340
  process.stdout.write(`${style.green(msg)}
341
341
  `);
342
342
  }
343
+ function hint(msg) {
344
+ process.stdout.write(`${style.dim(msg)}
345
+ `);
346
+ }
343
347
 
344
348
  // src/pi.ts
345
349
  var PI_PACKAGE = "@earendil-works/pi-coding-agent";
@@ -388,38 +392,107 @@ function assertNodeVersion() {
388
392
  );
389
393
  }
390
394
  }
395
+ function officialInstallerCommand() {
396
+ return isWindows ? 'powershell -c "irm https://pi.dev/install.ps1 | iex"' : "curl -fsSL https://pi.dev/install.sh | sh";
397
+ }
398
+ function writableOrCreatable(path) {
399
+ let current = path;
400
+ for (; ; ) {
401
+ try {
402
+ statSync2(current);
403
+ break;
404
+ } catch {
405
+ const parent = dirname2(current);
406
+ if (parent === current) return false;
407
+ current = parent;
408
+ }
409
+ }
410
+ try {
411
+ if (!statSync2(current).isDirectory()) return false;
412
+ accessSync2(current, constants2.W_OK);
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+ function npmInstallPiArgs(spec, npmMajor, prefix) {
419
+ const args = ["install", "-g", "--ignore-scripts"];
420
+ if (npmMajor >= 11) args.push("--min-release-age=0");
421
+ args.push("--no-fund", "--no-audit");
422
+ if (prefix) args.push("--prefix", prefix);
423
+ args.push(spec);
424
+ return args;
425
+ }
426
+ function npmMajorVersion() {
427
+ const res = runCapture("npm", ["--version"]);
428
+ const major = Number.parseInt(res.stdout.trim().split(".")[0] ?? "", 10);
429
+ return Number.isNaN(major) ? 0 : major;
430
+ }
391
431
  async function installPi(version) {
392
432
  assertNodeVersion();
393
- if (!which("npm")) {
394
- throw environmentError(
395
- "npm was not found on PATH.",
396
- "Install Node.js (which bundles npm) from https://nodejs.org and retry."
397
- );
398
- }
399
433
  if (process.env.PI_MANAGED_INSTALL_ROOT && !piStatus().installed) {
400
434
  throw environmentError(
401
435
  "PI_MANAGED_INSTALL_ROOT is set but pi is not installed.",
402
- "Use the managed installer: curl -fsSL https://pi.dev/install.sh | sh (Linux/macOS)."
436
+ `Use the official installer: ${officialInstallerCommand()}`
403
437
  );
404
438
  }
405
439
  const spec = version ? `${PI_PACKAGE}@${version}` : PI_PACKAGE;
406
- step(`npm install -g --ignore-scripts ${spec}`);
407
- const code = await runInherit("npm", ["install", "-g", "--ignore-scripts", spec]);
440
+ const npmPath = which("npm");
441
+ const bunPath = which("bun");
442
+ if (!npmPath && !bunPath) {
443
+ throw environmentError(
444
+ "Neither npm nor bun was found on PATH.",
445
+ `Run the official installer (it can install Node too): ${officialInstallerCommand()}`
446
+ );
447
+ }
448
+ let command;
449
+ let args;
450
+ let localPrefix = null;
451
+ if (npmPath) {
452
+ command = "npm";
453
+ if (!isWindows) {
454
+ const prefixRes = runCapture("npm", ["prefix", "-g"]);
455
+ const prefix = prefixRes.status === 0 ? prefixRes.stdout.trim() : "";
456
+ const prefixWritable = prefix !== "" && writableOrCreatable(join3(prefix, "lib", "node_modules")) && writableOrCreatable(join3(prefix, "bin"));
457
+ if (prefix && !prefixWritable) {
458
+ let staleGlobalPi = false;
459
+ try {
460
+ statSync2(join3(prefix, "bin", "pi"));
461
+ staleGlobalPi = true;
462
+ } catch {
463
+ }
464
+ if (staleGlobalPi) {
465
+ throw environmentError(
466
+ `npm's global directory is not writable (${prefix}) and pi is already installed there.`,
467
+ `Update or remove it first: sudo npm install -g --ignore-scripts ${PI_PACKAGE}`
468
+ );
469
+ }
470
+ localPrefix = join3(homedir2(), ".local");
471
+ }
472
+ }
473
+ args = npmInstallPiArgs(spec, npmMajorVersion(), localPrefix ?? void 0);
474
+ } else {
475
+ command = "bun";
476
+ args = ["add", "-g", "--ignore-scripts", spec];
477
+ }
478
+ step(`${command} ${args.join(" ")}`);
479
+ const code = await runInherit(command, args);
408
480
  if (code !== 0) {
409
481
  throw new PifyError(
410
- `npm install -g ${spec} exited with code ${code}.`,
482
+ `${command} ${args[0]} exited with code ${code}.`,
411
483
  ExitCode.SUBPROCESS,
412
- isWindows ? "If this is a permissions or file-lock error, close running pi sessions and retry from an elevated terminal." : "If this is an EACCES error, configure a user-writable npm prefix (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally) or use a Node version manager."
484
+ isWindows ? "If this is a permissions or file-lock error, close running pi sessions and retry from an elevated terminal." : `If the problem persists, try the official installer: ${officialInstallerCommand()}`
413
485
  );
414
486
  }
415
487
  const st = piStatus();
416
488
  if (!st.installed) {
417
- throw environmentError(
418
- "pi was installed but is not resolvable on PATH.",
419
- "Add npm's global prefix to PATH (npm prefix -g) and open a new terminal."
420
- );
489
+ const pathHint = localPrefix ? `Add ${join3(localPrefix, "bin")} to PATH and open a new terminal.` : "Add the global bin directory to PATH (npm prefix -g) and open a new terminal.";
490
+ throw environmentError("pi was installed but is not resolvable on PATH.", pathHint);
421
491
  }
422
492
  success(`pi ${st.version ?? ""} installed.`.replace(" ", " "));
493
+ if (localPrefix) {
494
+ hint(`Installed under ${localPrefix} because npm's global prefix is not writable.`);
495
+ }
423
496
  }
424
497
  async function updatePi() {
425
498
  const st = piStatus();
@@ -615,6 +688,8 @@ export {
615
688
  isOffline,
616
689
  loadBundledCatalog,
617
690
  loadCatalog,
691
+ npmInstallPiArgs,
692
+ officialInstallerCommand,
618
693
  packageJsonTemplate,
619
694
  parseInstallSpec,
620
695
  piStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Front door for the Pify suite: install and update the pi coding agent, manage @pify packages, and scaffold Pi Packages",
5
5
  "keywords": [
6
6
  "pify",