@pify/cli 0.4.8 → 0.5.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/catalog.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 31,
2
+ "version": 32,
3
3
  "org": "pifydev",
4
4
  "scope": "@pify",
5
5
  "remote": "https://raw.githubusercontent.com/pifydev/cli/main/catalog.json",
@@ -112,14 +112,14 @@
112
112
  "name": "subagent",
113
113
  "npm": "@pify/subagent",
114
114
  "repo": "https://github.com/pifydev/subagent",
115
- "description": "Spawn scoped subagents: agent_run/agent_result, @agent mentions, custom agent types, ask_supervisor, shell gates with bounded repair, task outcome separate from session status, results delivered not polled",
115
+ "description": "Spawn scoped subagents: agent_run/agent_result, @agent mentions, custom agent types, ask_supervisor, shell gates run async (wait on agent_result) with bounded repair, task outcome separate from session status, results delivered not polled",
116
116
  "status": "published"
117
117
  },
118
118
  {
119
119
  "name": "swarm",
120
120
  "npm": "@pify/swarm",
121
121
  "repo": "https://github.com/pifydev/swarm",
122
- "description": "Run many pi agents in parallel: fan-out with per-item routing, needs-DAG with skip-on-upstream-failure, per-item shell gates, concurrency queue, shared mailbox, first hard failure interrupts early, results delivered not polled",
122
+ "description": "Run many pi agents in parallel: fan-out with per-item routing, needs-DAG with skip-on-upstream-failure, per-item shell gates, concurrency queue, shared mailbox, /swarm stop, first hard failure interrupts early, wait for results (delivered not polled)",
123
123
  "status": "published"
124
124
  },
125
125
  {
@@ -147,7 +147,7 @@
147
147
  "name": "workflow",
148
148
  "npm": "@pify/workflow",
149
149
  "repo": "https://github.com/pifydev/workflow",
150
- "description": "Deterministic agent orchestration through JavaScript scripts: agent()/parallel()/pipeline(), gates, resume",
150
+ "description": "Deterministic agent orchestration through JavaScript scripts: agent()/parallel()/pipeline(), gates, resume, /workflows stop, failure ledger, wait for results",
151
151
  "status": "published"
152
152
  },
153
153
  {
package/dist/cli.js CHANGED
@@ -515,16 +515,23 @@ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
515
515
  function loadBundledCatalog() {
516
516
  return JSON.parse(readFileSync2(bundledCatalogPath(), "utf8"));
517
517
  }
518
+ var CLEAN_TEXT = /^[^\x00-\x1f\x7f]{1,500}$/;
519
+ var CATALOG_REPO = /^https:\/\/github\.com\/pifydev\/[A-Za-z0-9._-]+$/;
520
+ var CATALOG_IDENT = /^[@a-z0-9-]+$/;
518
521
  function validateCatalog(data) {
519
522
  if (typeof data !== "object" || data === null) return false;
520
523
  const c = data;
521
524
  if (!Number.isInteger(c.version)) return false;
525
+ if (typeof c.org !== "string" || !CATALOG_IDENT.test(c.org)) return false;
526
+ if (typeof c.scope !== "string" || !CATALOG_IDENT.test(c.scope)) return false;
522
527
  if (!Array.isArray(c.packages)) return false;
523
528
  for (const entry of c.packages) {
524
529
  if (typeof entry !== "object" || entry === null) return false;
525
530
  const p = entry;
526
531
  if (typeof p.name !== "string" || !/^[a-z0-9-]+$/.test(p.name)) return false;
527
532
  if (typeof p.npm !== "string" || !p.npm.startsWith("@pify/")) return false;
533
+ if (typeof p.description !== "string" || !CLEAN_TEXT.test(p.description)) return false;
534
+ if (typeof p.repo !== "string" || !CATALOG_REPO.test(p.repo)) return false;
528
535
  if (p.status !== "published" && p.status !== "planned") return false;
529
536
  if (p.conflicts !== void 0) {
530
537
  if (!Array.isArray(p.conflicts)) return false;
@@ -540,7 +547,7 @@ function validateCatalog(data) {
540
547
  if (typeof entry !== "object" || entry === null) return false;
541
548
  const b = entry;
542
549
  if (typeof b.name !== "string" || !/^[a-z0-9-]+$/.test(b.name)) return false;
543
- if (typeof b.description !== "string") return false;
550
+ if (typeof b.description !== "string" || !CLEAN_TEXT.test(b.description)) return false;
544
551
  if (!Array.isArray(b.packages) || b.packages.length === 0) return false;
545
552
  for (const member of b.packages) {
546
553
  if (typeof member !== "string" || !known.has(member)) return false;
@@ -965,27 +972,50 @@ async function profile(args, opts) {
965
972
  }
966
973
 
967
974
  // src/commands/update.ts
968
- async function checkUpdates() {
969
- const installed = installedPifyPackages();
975
+ function piRow(installed, latest) {
976
+ const base = { name: "pi", installed, latest, pin: null };
977
+ if (installed === null) return { ...base, state: "missing" };
978
+ if (latest === null) return { ...base, state: "unknown" };
979
+ return { ...base, state: compareSemver(installed, latest) < 0 ? "outdated" : "current" };
980
+ }
981
+ async function checkUpdates(deps = {}) {
982
+ const installed = deps.installed ?? installedPifyPackages();
983
+ const versionOnDisk = deps.versionOnDisk ?? installedVersionOnDisk;
984
+ const latestPackage = deps.latestPackage ?? fetchLatestPackageVersion;
985
+ const piVersion = deps.piVersion !== void 0 ? deps.piVersion : piStatus().version;
986
+ const latestPi = deps.latestPi ?? fetchLatestPiVersion;
970
987
  const names = [...installed.keys()].sort();
971
- const statuses = await Promise.all(
972
- names.map(async (name) => {
988
+ const [piLatest, ...packages] = await Promise.all([
989
+ latestPi(),
990
+ ...names.map(async (name) => {
973
991
  const entry = installed.get(name);
974
- const onDisk = installedVersionOnDisk(name, entry.scope);
975
- const latest = await fetchLatestPackageVersion(name);
976
- if (!onDisk.present) return { name, installed: null, latest, state: "missing" };
992
+ const onDisk = versionOnDisk(name, entry.scope);
993
+ const latest = await latestPackage(name);
994
+ const pin = entry.pin;
995
+ if (!onDisk.present) return { name, installed: null, latest, pin, state: "missing" };
977
996
  if (!onDisk.version || !latest) {
978
- return { name, installed: onDisk.version, latest, state: "unknown" };
997
+ return { name, installed: onDisk.version, latest, pin, state: "unknown" };
979
998
  }
980
- return {
981
- name,
982
- installed: onDisk.version,
983
- latest,
984
- state: compareSemver(onDisk.version, latest) < 0 ? "outdated" : "current"
985
- };
999
+ const behind = compareSemver(onDisk.version, latest) < 0;
1000
+ const state = behind ? pin !== null ? "pinned" : "outdated" : "current";
1001
+ return { name, installed: onDisk.version, latest, pin, state };
986
1002
  })
987
- );
988
- return statuses;
1003
+ ]);
1004
+ return { pi: piRow(piVersion, piLatest), packages };
1005
+ }
1006
+ function checkDetail(status) {
1007
+ switch (status.state) {
1008
+ case "outdated":
1009
+ return `${status.installed} \u2192 ${status.latest}`;
1010
+ case "pinned":
1011
+ return `${status.installed} pinned, ${status.latest} available - pify install ${status.name}@${status.latest}`;
1012
+ case "missing":
1013
+ return status.name === "pi" ? "not installed" : "configured but not on disk";
1014
+ case "unknown":
1015
+ return `${status.installed ?? "?"} (registry unreachable)`;
1016
+ default:
1017
+ return `${status.installed}`;
1018
+ }
989
1019
  }
990
1020
  async function update(targets, opts) {
991
1021
  if (isOffline()) {
@@ -1006,23 +1036,30 @@ async function update(targets, opts) {
1006
1036
  }
1007
1037
  if (opts.check) {
1008
1038
  if (targets.length > 0) throw usageError("--check reports on everything; drop the package names.");
1009
- const statuses = await checkUpdates();
1039
+ const report = await checkUpdates();
1010
1040
  if (opts.json) {
1011
- out(JSON.stringify({ packages: statuses }, null, 2));
1041
+ out(JSON.stringify({ pi: report.pi, packages: report.packages }, null, 2));
1012
1042
  return 0;
1013
1043
  }
1014
- if (statuses.length === 0) {
1015
- hint("No @pify packages installed.");
1016
- return 0;
1017
- }
1018
- const width = statuses.reduce((m, s) => Math.max(m, s.name.length), 0);
1019
- for (const status of statuses) {
1020
- const detail = status.state === "outdated" ? `${status.installed} \u2192 ${status.latest}` : status.state === "missing" ? "configured but not on disk" : status.state === "unknown" ? `${status.installed ?? "?"} (registry unreachable)` : `${status.installed}`;
1021
- out(` ${status.state.padEnd(9)}${status.name.padEnd(width + 2)}${detail}`);
1044
+ const rows = [report.pi, ...report.packages];
1045
+ const width = rows.reduce((m, s) => Math.max(m, s.name.length), 0);
1046
+ for (const status of rows) {
1047
+ out(` ${status.state.padEnd(9)}${status.name.padEnd(width + 2)}${checkDetail(status)}`);
1022
1048
  }
1023
- const outdated = statuses.filter((s) => s.state === "outdated").length;
1049
+ if (report.packages.length === 0) hint("No @pify packages installed.");
1050
+ const outdatedPackages = report.packages.filter((s) => s.state === "outdated").length;
1051
+ const pinnedCount = report.packages.filter((s) => s.state === "pinned").length;
1052
+ const piOutdated = report.pi.state === "outdated";
1024
1053
  out();
1025
- out(outdated > 0 ? `${outdated} package(s) can be updated: pify update` : "Everything is current.");
1054
+ if (outdatedPackages > 0) {
1055
+ out(`${outdatedPackages} package(s) can be updated: pify update`);
1056
+ } else if (piOutdated) {
1057
+ out("pi can be updated: pify update pi");
1058
+ } else if (pinnedCount > 0) {
1059
+ out(`Everything current except ${pinnedCount} pinned package(s) \u2014 re-pin to advance.`);
1060
+ } else {
1061
+ out("Everything is current.");
1062
+ }
1026
1063
  return 0;
1027
1064
  }
1028
1065
  const updateEverything = targets.length === 0;
@@ -1698,7 +1735,9 @@ function powershellScript() {
1698
1735
  const commands = COMMAND_SPECS.flatMap(allNames);
1699
1736
  const caseBlocks = COMMAND_SPECS.map((spec) => {
1700
1737
  const words = [...positionalWords(spec), ...flagTokens(spec)].map((w) => `'${w}'`).join(", ");
1701
- return ` ${allNames(spec).map((n) => `'${n}'`).join(" ")} { @(${words}) }`;
1738
+ const names = allNames(spec);
1739
+ const condition = names.length > 1 ? `{ $_ -in ${names.map((n) => `'${n}'`).join(", ")} }` : `'${names[0]}'`;
1740
+ return ` ${condition} { @(${words}) }`;
1702
1741
  }).join("\n");
1703
1742
  return `# PowerShell completion for pify \u2014 generated by \`pify completions powershell\`
1704
1743
  # Install: pify completions powershell | Out-String | Invoke-Expression (add to $PROFILE)
package/dist/index.d.ts CHANGED
@@ -39,6 +39,128 @@ declare function planApply(profile: Profile, installedNow: Map<string, {
39
39
  scope: "user" | "project";
40
40
  }>): PlanRow[];
41
41
 
42
+ /** The npm package that provides the `pi` binary. */
43
+ declare const PI_PACKAGE = "@earendil-works/pi-coding-agent";
44
+ /** Minimum Node version required by the pi coding agent. */
45
+ declare const NODE_FLOOR = "22.19.0";
46
+ /** The pi version this CLI release was developed and tested against. */
47
+ declare const TESTED_PI_VERSION = "0.84.4";
48
+ interface PiStatus {
49
+ installed: boolean;
50
+ /** Absolute path to the `pi` launcher, when installed. */
51
+ binPath: string | null;
52
+ /** Version reported by `pi --version`, when installed. */
53
+ version: string | null;
54
+ }
55
+ declare function piStatus(): PiStatus;
56
+ /** Latest pi version from pi's own release endpoint. Null offline/on error. */
57
+ declare function fetchLatestPiVersion(): Promise<string | null>;
58
+ declare function compareSemver(a: string, b: string): number;
59
+ /** The official installer one-liner for this OS, for hints and --installer. */
60
+ declare function officialInstallerCommand(): string;
61
+ /**
62
+ * The npm install argv pi's official installers use:
63
+ * `--ignore-scripts` (supply-chain-safe; matches pi's self-update),
64
+ * `--min-release-age=0` (pi ships npm-shrinkwrap.json, so bypassing npm's
65
+ * release-age gate does not reopen transitive ranges; npm >= 11 only),
66
+ * `--no-fund --no-audit` (noise). Exported for tests.
67
+ */
68
+ declare function npmInstallPiArgs(spec: string, npmMajor: number, prefix?: string): string[];
69
+ /**
70
+ * Install pi with the same decisions as pi's official installers, minus the
71
+ * interactive parts. This is the one operation pi cannot do for itself —
72
+ * `pi update` requires a working pi.
73
+ *
74
+ * Per-OS behavior (ported from install.sh / install.ps1):
75
+ * - POSIX, npm prefix unwritable, no global pi there: fall back to
76
+ * `--prefix ~/.local` — never sudo.
77
+ * - POSIX, npm prefix unwritable but an old global pi lives there: stop
78
+ * (a ~/.local copy would be shadowed by the stale global one).
79
+ * - No npm but bun on PATH: `bun add -g --ignore-scripts` (documented
80
+ * alternative channel).
81
+ * - Neither: point at the official installer, which can bootstrap Node too.
82
+ */
83
+ declare function installPi(version?: string): Promise<void>;
84
+ /**
85
+ * Update pi. Delegates to bare `pi update` (its default target is self):
86
+ * pi's updater owns install-method detection, the pi.dev rename protocol,
87
+ * managed-install staging, and the Windows native-module quarantine.
88
+ * Reimplementing any of it would rot. When pi is absent, fall through to a
89
+ * fresh install so `npx @pify/cli update` works on a bare machine.
90
+ */
91
+ declare function updatePi(): Promise<void>;
92
+ interface PiSettings {
93
+ packages?: Array<string | {
94
+ source: string;
95
+ [k: string]: unknown;
96
+ }>;
97
+ [k: string]: unknown;
98
+ }
99
+ /** pi's agent directory: tilde-expanded PI_CODING_AGENT_DIR or ~/.pi/agent. */
100
+ declare function agentDir(): string;
101
+ /**
102
+ * Read-only view of pi settings. All writes go through the pi CLI — pi owns
103
+ * that state (dedup rules, npm workspace, trust model), and two writers would
104
+ * eventually disagree. Parse failures are treated as absent (doctor warns).
105
+ */
106
+ declare function readSettings(scope: "user" | "project"): PiSettings | null;
107
+ interface InstalledPifyPackage {
108
+ name: string;
109
+ scope: "user" | "project";
110
+ source: string;
111
+ pin: string | null;
112
+ }
113
+ declare function installedPifyPackages(): Map<string, InstalledPifyPackage>;
114
+ interface OnDiskState {
115
+ present: boolean;
116
+ version: string | null;
117
+ }
118
+ /** Version of an installed @pify package as materialized in pi's npm root. */
119
+ declare function installedVersionOnDisk(name: string, scope: "user" | "project"): OnDiskState;
120
+
121
+ interface UpdateStatus {
122
+ name: string;
123
+ installed: string | null;
124
+ latest: string | null;
125
+ /** The exact pin holding this package back, if any (packages only). */
126
+ pin: string | null;
127
+ /**
128
+ * "pinned" means behind the registry but held at an exact version, so
129
+ * `pify update` will not (and should not) advance it — the user must re-pin.
130
+ */
131
+ state: "current" | "outdated" | "missing" | "unknown" | "pinned";
132
+ }
133
+ /** pi plus every installed @pify package, in one snapshot. */
134
+ interface UpdateReport {
135
+ pi: UpdateStatus;
136
+ packages: UpdateStatus[];
137
+ }
138
+ /**
139
+ * Seams for the check, so a test can drive checkUpdates without a real pi
140
+ * install or a network round-trip. Every field defaults to the live primitive.
141
+ */
142
+ interface CheckUpdatesDeps {
143
+ installed?: Map<string, InstalledPifyPackage>;
144
+ versionOnDisk?: (name: string, scope: "user" | "project") => OnDiskState;
145
+ latestPackage?: (name: string) => Promise<string | null>;
146
+ /** pi's own installed version; `undefined` means ask piStatus(). */
147
+ piVersion?: string | null;
148
+ latestPi?: () => Promise<string | null>;
149
+ }
150
+ /**
151
+ * Compare what is installed against what is published, without installing
152
+ * anything. Separating the question from the action is the point: "is there
153
+ * anything to do" is asked far more often than "do it", and it should not
154
+ * cost a package install to find out.
155
+ *
156
+ * pi is checked alongside the packages because `pify update` updates pi first;
157
+ * a check that skipped it would say "current" and then `pify update` would
158
+ * upgrade the agent the user was told nothing about. Its latest-version fetch
159
+ * rides in the SAME Promise.all as the packages', so adding it costs no serial
160
+ * latency.
161
+ */
162
+ declare function checkUpdates(deps?: CheckUpdatesDeps): Promise<UpdateReport>;
163
+
42
164
  interface CatalogPackage {
43
165
  /** Short name used on the pify command line, e.g. "goal". */
44
166
  name: string;
@@ -72,14 +194,6 @@ interface Catalog {
72
194
  bundles?: CatalogBundle[];
73
195
  }
74
196
  declare function loadBundledCatalog(): Catalog;
75
- /**
76
- * SECURITY-CRITICAL. A fetched catalog is either fully valid or fully
77
- * discarded: every npm name must stay inside the @pify scope so a tampered
78
- * catalog cannot redirect installs outside the org (npm publish rights are a
79
- * separate credential from GitHub). Combined with the install rule that only
80
- * `npm:@pify/...` specs are ever delegated to pi, this bounds the blast
81
- * radius of a compromised remote to "packages the org itself published".
82
- */
83
197
  declare function validateCatalog(data: unknown): data is Catalog;
84
198
  /** The bundle by that name, or null. Package names always win over bundles. */
85
199
  declare function findBundle(catalog: Catalog, name: string): CatalogBundle | null;
@@ -134,85 +248,6 @@ declare function resolveInstallTarget(catalog: Catalog, input: string): InstallT
134
248
  */
135
249
  declare function resolvePifyName(input: string): string;
136
250
 
137
- /** The npm package that provides the `pi` binary. */
138
- declare const PI_PACKAGE = "@earendil-works/pi-coding-agent";
139
- /** Minimum Node version required by the pi coding agent. */
140
- declare const NODE_FLOOR = "22.19.0";
141
- /** The pi version this CLI release was developed and tested against. */
142
- declare const TESTED_PI_VERSION = "0.84.4";
143
- interface PiStatus {
144
- installed: boolean;
145
- /** Absolute path to the `pi` launcher, when installed. */
146
- binPath: string | null;
147
- /** Version reported by `pi --version`, when installed. */
148
- version: string | null;
149
- }
150
- declare function piStatus(): PiStatus;
151
- /** Latest pi version from pi's own release endpoint. Null offline/on error. */
152
- declare function fetchLatestPiVersion(): Promise<string | null>;
153
- declare function compareSemver(a: string, b: string): number;
154
- /** The official installer one-liner for this OS, for hints and --installer. */
155
- declare function officialInstallerCommand(): string;
156
- /**
157
- * The npm install argv pi's official installers use:
158
- * `--ignore-scripts` (supply-chain-safe; matches pi's self-update),
159
- * `--min-release-age=0` (pi ships npm-shrinkwrap.json, so bypassing npm's
160
- * release-age gate does not reopen transitive ranges; npm >= 11 only),
161
- * `--no-fund --no-audit` (noise). Exported for tests.
162
- */
163
- declare function npmInstallPiArgs(spec: string, npmMajor: number, prefix?: string): string[];
164
- /**
165
- * Install pi with the same decisions as pi's official installers, minus the
166
- * interactive parts. This is the one operation pi cannot do for itself —
167
- * `pi update` requires a working pi.
168
- *
169
- * Per-OS behavior (ported from install.sh / install.ps1):
170
- * - POSIX, npm prefix unwritable, no global pi there: fall back to
171
- * `--prefix ~/.local` — never sudo.
172
- * - POSIX, npm prefix unwritable but an old global pi lives there: stop
173
- * (a ~/.local copy would be shadowed by the stale global one).
174
- * - No npm but bun on PATH: `bun add -g --ignore-scripts` (documented
175
- * alternative channel).
176
- * - Neither: point at the official installer, which can bootstrap Node too.
177
- */
178
- declare function installPi(version?: string): Promise<void>;
179
- /**
180
- * Update pi. Delegates to bare `pi update` (its default target is self):
181
- * pi's updater owns install-method detection, the pi.dev rename protocol,
182
- * managed-install staging, and the Windows native-module quarantine.
183
- * Reimplementing any of it would rot. When pi is absent, fall through to a
184
- * fresh install so `npx @pify/cli update` works on a bare machine.
185
- */
186
- declare function updatePi(): Promise<void>;
187
- interface PiSettings {
188
- packages?: Array<string | {
189
- source: string;
190
- [k: string]: unknown;
191
- }>;
192
- [k: string]: unknown;
193
- }
194
- /** pi's agent directory: tilde-expanded PI_CODING_AGENT_DIR or ~/.pi/agent. */
195
- declare function agentDir(): string;
196
- /**
197
- * Read-only view of pi settings. All writes go through the pi CLI — pi owns
198
- * that state (dedup rules, npm workspace, trust model), and two writers would
199
- * eventually disagree. Parse failures are treated as absent (doctor warns).
200
- */
201
- declare function readSettings(scope: "user" | "project"): PiSettings | null;
202
- interface InstalledPifyPackage {
203
- name: string;
204
- scope: "user" | "project";
205
- source: string;
206
- pin: string | null;
207
- }
208
- declare function installedPifyPackages(): Map<string, InstalledPifyPackage>;
209
- interface OnDiskState {
210
- present: boolean;
211
- version: string | null;
212
- }
213
- /** Version of an installed @pify package as materialized in pi's npm root. */
214
- declare function installedVersionOnDisk(name: string, scope: "user" | "project"): OnDiskState;
215
-
216
251
  /** True when the user asked for offline behavior (pi's env var is honored too). */
217
252
  declare function isOffline(): boolean;
218
253
  declare function assertSafeArg(arg: string, what?: string): void;
@@ -299,4 +334,4 @@ declare class PifyError extends Error {
299
334
  constructor(message: string, code?: ExitCodeValue, hint?: string);
300
335
  }
301
336
 
302
- export { COMMAND_SPECS, COMPLETION_SHELLS, type Catalog, type CatalogBundle, type CatalogPackage, type CommandSpec, type CompletionShell, ExitCode, type FlagSpec, GLOBAL_FLAGS, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, type PlanRow, type Profile, type ProfileEntry, README_MD, TESTED_PI_VERSION, type TemplateVars, VERSION, agentDir, assertSafeArg, buildProfile, compareSemver, expandBundles, fetchLatestPiVersion, findBundle, generateCompletion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, npmInstallPiArgs, officialInstallerCommand, packageJsonTemplate, parseArgsOptionsFor, parseInstallSpec, parseProfile, piStatus, planApply, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
337
+ export { COMMAND_SPECS, COMPLETION_SHELLS, type Catalog, type CatalogBundle, type CatalogPackage, type CheckUpdatesDeps, type CommandSpec, type CompletionShell, ExitCode, type FlagSpec, GLOBAL_FLAGS, INDEX_TS, type InstallSpec, type InstallTarget, type InstalledPifyPackage, NODE_FLOOR, PI_PACKAGE, type PiSettings, type PiStatus, PifyError, type PlanRow, type Profile, type ProfileEntry, README_MD, TESTED_PI_VERSION, type TemplateVars, type UpdateReport, type UpdateStatus, VERSION, agentDir, assertSafeArg, buildProfile, checkUpdates, compareSemver, expandBundles, fetchLatestPiVersion, findBundle, generateCompletion, installPi, installedPifyPackages, installedVersionOnDisk, interpolate, isOffline, loadBundledCatalog, loadCatalog, npmInstallPiArgs, officialInstallerCommand, packageJsonTemplate, parseArgsOptionsFor, parseInstallSpec, parseProfile, piStatus, planApply, readSettings, refreshCatalog, resolveInstallTarget, resolvePifyName, updatePi, validateCatalog, which };
package/dist/index.js CHANGED
@@ -142,16 +142,23 @@ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
142
142
  function loadBundledCatalog() {
143
143
  return JSON.parse(readFileSync(bundledCatalogPath(), "utf8"));
144
144
  }
145
+ var CLEAN_TEXT = /^[^\x00-\x1f\x7f]{1,500}$/;
146
+ var CATALOG_REPO = /^https:\/\/github\.com\/pifydev\/[A-Za-z0-9._-]+$/;
147
+ var CATALOG_IDENT = /^[@a-z0-9-]+$/;
145
148
  function validateCatalog(data) {
146
149
  if (typeof data !== "object" || data === null) return false;
147
150
  const c = data;
148
151
  if (!Number.isInteger(c.version)) return false;
152
+ if (typeof c.org !== "string" || !CATALOG_IDENT.test(c.org)) return false;
153
+ if (typeof c.scope !== "string" || !CATALOG_IDENT.test(c.scope)) return false;
149
154
  if (!Array.isArray(c.packages)) return false;
150
155
  for (const entry of c.packages) {
151
156
  if (typeof entry !== "object" || entry === null) return false;
152
157
  const p = entry;
153
158
  if (typeof p.name !== "string" || !/^[a-z0-9-]+$/.test(p.name)) return false;
154
159
  if (typeof p.npm !== "string" || !p.npm.startsWith("@pify/")) return false;
160
+ if (typeof p.description !== "string" || !CLEAN_TEXT.test(p.description)) return false;
161
+ if (typeof p.repo !== "string" || !CATALOG_REPO.test(p.repo)) return false;
155
162
  if (p.status !== "published" && p.status !== "planned") return false;
156
163
  if (p.conflicts !== void 0) {
157
164
  if (!Array.isArray(p.conflicts)) return false;
@@ -167,7 +174,7 @@ function validateCatalog(data) {
167
174
  if (typeof entry !== "object" || entry === null) return false;
168
175
  const b = entry;
169
176
  if (typeof b.name !== "string" || !/^[a-z0-9-]+$/.test(b.name)) return false;
170
- if (typeof b.description !== "string") return false;
177
+ if (typeof b.description !== "string" || !CLEAN_TEXT.test(b.description)) return false;
171
178
  if (!Array.isArray(b.packages) || b.packages.length === 0) return false;
172
179
  for (const member of b.packages) {
173
180
  if (typeof member !== "string" || !known.has(member)) return false;
@@ -581,6 +588,25 @@ function installedPifyPackages() {
581
588
  }
582
589
  return result;
583
590
  }
591
+ async function fetchLatestPackageVersion(name) {
592
+ if (isOffline()) return null;
593
+ const controller = new AbortController();
594
+ const timer = setTimeout(() => controller.abort(), 5e3);
595
+ try {
596
+ const res = await fetch(`https://registry.npmjs.org/@pify/${encodeURIComponent(name)}/latest`, {
597
+ signal: controller.signal,
598
+ headers: { "user-agent": `pify/${VERSION}`, accept: "application/json" },
599
+ redirect: "error"
600
+ });
601
+ if (!res.ok) return null;
602
+ const data = await res.json();
603
+ return typeof data.version === "string" ? data.version : null;
604
+ } catch {
605
+ return null;
606
+ } finally {
607
+ clearTimeout(timer);
608
+ }
609
+ }
584
610
  function installedVersionOnDisk(name, scope) {
585
611
  const root = scope === "user" ? join3(agentDir(), "npm") : join3(process.cwd(), ".pi", "npm");
586
612
  try {
@@ -662,6 +688,39 @@ function planApply(profile, installedNow) {
662
688
  return rows.sort((a, b) => a.name.localeCompare(b.name));
663
689
  }
664
690
 
691
+ // src/commands/update.ts
692
+ function piRow(installed, latest) {
693
+ const base = { name: "pi", installed, latest, pin: null };
694
+ if (installed === null) return { ...base, state: "missing" };
695
+ if (latest === null) return { ...base, state: "unknown" };
696
+ return { ...base, state: compareSemver(installed, latest) < 0 ? "outdated" : "current" };
697
+ }
698
+ async function checkUpdates(deps = {}) {
699
+ const installed = deps.installed ?? installedPifyPackages();
700
+ const versionOnDisk = deps.versionOnDisk ?? installedVersionOnDisk;
701
+ const latestPackage = deps.latestPackage ?? fetchLatestPackageVersion;
702
+ const piVersion = deps.piVersion !== void 0 ? deps.piVersion : piStatus().version;
703
+ const latestPi = deps.latestPi ?? fetchLatestPiVersion;
704
+ const names = [...installed.keys()].sort();
705
+ const [piLatest, ...packages] = await Promise.all([
706
+ latestPi(),
707
+ ...names.map(async (name) => {
708
+ const entry = installed.get(name);
709
+ const onDisk = versionOnDisk(name, entry.scope);
710
+ const latest = await latestPackage(name);
711
+ const pin = entry.pin;
712
+ if (!onDisk.present) return { name, installed: null, latest, pin, state: "missing" };
713
+ if (!onDisk.version || !latest) {
714
+ return { name, installed: onDisk.version, latest, pin, state: "unknown" };
715
+ }
716
+ const behind = compareSemver(onDisk.version, latest) < 0;
717
+ const state = behind ? pin !== null ? "pinned" : "outdated" : "current";
718
+ return { name, installed: onDisk.version, latest, pin, state };
719
+ })
720
+ ]);
721
+ return { pi: piRow(piVersion, piLatest), packages };
722
+ }
723
+
665
724
  // src/registry.ts
666
725
  var GLOBAL_FLAGS = [
667
726
  { name: "help", short: "h", description: "Show help" },
@@ -879,7 +938,9 @@ function powershellScript() {
879
938
  const commands = COMMAND_SPECS.flatMap(allNames);
880
939
  const caseBlocks = COMMAND_SPECS.map((spec) => {
881
940
  const words = [...positionalWords(spec), ...flagTokens(spec)].map((w) => `'${w}'`).join(", ");
882
- return ` ${allNames(spec).map((n) => `'${n}'`).join(" ")} { @(${words}) }`;
941
+ const names = allNames(spec);
942
+ const condition = names.length > 1 ? `{ $_ -in ${names.map((n) => `'${n}'`).join(", ")} }` : `'${names[0]}'`;
943
+ return ` ${condition} { @(${words}) }`;
883
944
  }).join("\n");
884
945
  return `# PowerShell completion for pify \u2014 generated by \`pify completions powershell\`
885
946
  # Install: pify completions powershell | Out-String | Invoke-Expression (add to $PROFILE)
@@ -1050,6 +1111,7 @@ export {
1050
1111
  agentDir,
1051
1112
  assertSafeArg,
1052
1113
  buildProfile,
1114
+ checkUpdates,
1053
1115
  compareSemver,
1054
1116
  expandBundles,
1055
1117
  fetchLatestPiVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/cli",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
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",