pi-pkg-guard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # pi-pkg-guard
2
+
3
+ A lightweight pi extension that guards against the "orphaned package" trap where packages are installed via npm but not registered in pi's `settings.json`.
4
+
5
+ ## The Problem
6
+
7
+ When you install pi extensions via npm directly, they become "orphaned" - installed on your system but unknown to pi:
8
+
9
+ ```bash
10
+ npm install -g pi-token-burden # Installs to npm global
11
+ # But pi doesn't know about it! Must also add to settings.json
12
+ ```
13
+
14
+ This extension provides **passive guardrails** to prevent and fix this issue.
15
+
16
+ ## Features
17
+
18
+ | Feature | Behavior |
19
+ |---------|----------|
20
+ | **Startup Check** | Status line warning if orphaned packages detected (max once/hour) |
21
+ | **`/pi-pkg-guard`** | One-liner to auto-register orphaned packages |
22
+ | **npm Guard** | Warns when bash tool runs `npm install -g pi-*` |
23
+
24
+ ## Installation
25
+
26
+ ### Via pi (Recommended)
27
+
28
+ ```bash
29
+ pi install npm:pi-pkg-guard
30
+ ```
31
+
32
+ ### Via npm
33
+
34
+ ```bash
35
+ npm install -g pi-pkg-guard
36
+ # Then add to ~/.pi/agent/settings.json:
37
+ # {
38
+ # "packages": ["npm:pi-pkg-guard"]
39
+ # }
40
+ ```
41
+
42
+ ### Manual (Development)
43
+
44
+ ```bash
45
+ # Clone the repository
46
+ git clone https://github.com/alexleekt/pi-pkg-guard.git
47
+
48
+ # Symlink to pi extensions directory
49
+ ln -s $(pwd)/pi-pkg-guard ~/.pi/agent/extensions/pi-pkg-guard
50
+
51
+ # Or add to settings.json
52
+ # {
53
+ # "extensions": ["/path/to/pi-pkg-guard"]
54
+ # }
55
+ ```
56
+
57
+ Then `/reload` in pi.
58
+
59
+ ## Usage
60
+
61
+ ### Check for Orphaned Packages
62
+
63
+ Run the slash command to check and fix:
64
+
65
+ ```
66
+ /pi-pkg-guard
67
+ ```
68
+
69
+ **Outputs:**
70
+ - `✅ All pi packages registered` - No orphaned packages found
71
+ - `✅ Registered N package(s). Run /reload.` - Fixed! Run `/reload` to activate
72
+
73
+ ### Automatic Startup Check
74
+
75
+ On pi startup, the extension checks for orphaned packages (max once per hour). If found, you'll see:
76
+
77
+ ```
78
+ ⚠️ 3 orphaned pi package(s). Run /pi-pkg-guard
79
+ ```
80
+
81
+ ### npm Install Guard
82
+
83
+ When a tool attempts `npm install -g pi-*`, you'll see:
84
+
85
+ ```
86
+ ⚠️ Use 'pi install npm:pi-foo' instead of 'npm install -g'
87
+ ```
88
+
89
+ ## How It Works
90
+
91
+ 1. **Detects** packages starting with `pi-` or scoped packages containing `/pi-`
92
+ 2. **Compares** npm global packages against `settings.json` registered packages
93
+ 3. **Normalizes** both `pi-foo` and `npm:pi-foo` formats
94
+ 4. **Excludes** the core package `@mariozechner/pi-coding-agent`
95
+
96
+ ## Compatibility
97
+
98
+ - **Node.js**: >= 18.0.0
99
+ - **pi**: Works with all versions supporting `ExtensionAPI`
100
+
101
+ ## Related Extensions
102
+
103
+ - **[pi-extmgr](https://pi.dev/packages/pi-extmgr)** (`/extensions`) - Full package management UI. This extension provides passive guardrails that complement pi-extmgr.
104
+ - **[pi-extension-manager](https://pi.dev/packages/pi-extension-manager)** - Interactive extension manager
105
+
106
+ ## Why Not Just Use pi-extmgr?
107
+
108
+ `pi-extmgr` provides a full UI for managing extensions. `pi-pkg-guard` provides **passive guardrails** - it watches and warns automatically without requiring you to open a UI. Use both together:
109
+
110
+ - `pi-pkg-guard` for automatic detection and warnings
111
+ - `pi-extmgr` for interactive browsing and management
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ # Clone
117
+ git clone https://github.com/alexleekt/pi-pkg-guard.git
118
+ cd pi-pkg-guard
119
+
120
+ # Install dependencies
121
+ npm install
122
+
123
+ # Run tests
124
+ just test
125
+
126
+ # Run all checks
127
+ just check
128
+
129
+ # Link for development
130
+ ln -s $(pwd) ~/.pi/agent/extensions/pi-pkg-guard
131
+
132
+ # Test in pi
133
+ pi
134
+ # Then /reload
135
+ ```
136
+
137
+ See all available tasks: `just --list`
138
+
139
+ ```bash
140
+ # Clone
141
+ git clone https://github.com/alexleekt/pi-pkg-guard.git
142
+ cd pi-pkg-guard
143
+
144
+ # Link for development
145
+ ln -s $(pwd) ~/.pi/agent/extensions/pi-pkg-guard
146
+
147
+ # Test in pi
148
+ pi
149
+ # Then /reload
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT © Alex Lee
155
+
156
+ ## Contributing
157
+
158
+ Issues and PRs welcome at [github.com/alexleekt/pi-pkg-guard](https://github.com/alexleekt/pi-pkg-guard)
package/biome.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "organizeImports": {
12
+ "enabled": true
13
+ },
14
+ "formatter": {
15
+ "enabled": true,
16
+ "indentStyle": "tab"
17
+ },
18
+ "linter": {
19
+ "enabled": true,
20
+ "rules": {
21
+ "recommended": true
22
+ }
23
+ },
24
+ "javascript": {
25
+ "formatter": {
26
+ "quoteStyle": "double"
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,269 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+
6
+ // =============================================================================
7
+ // Constants
8
+ // =============================================================================
9
+
10
+ const SETTINGS_PATH = `${homedir()}/.pi/agent/settings.json`;
11
+ const NPM_PREFIX = "npm:";
12
+ const STATUS_KEY = "ext:pi-pkg-guard:v1";
13
+ const CORE_PACKAGE = "@mariozechner/pi-coding-agent";
14
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
15
+ const STATUS_CLEAR_DELAY_MS = 3000;
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ interface PackageDiff {
22
+ orphaned: string[]; // In npm global, not in settings
23
+ hasOrphans: boolean;
24
+ }
25
+
26
+ interface PiSettings {
27
+ packages?: string[];
28
+ extensions?: string[];
29
+ }
30
+
31
+ // =============================================================================
32
+ // Type Guards
33
+ // =============================================================================
34
+
35
+ function isPiSettings(value: unknown): value is PiSettings {
36
+ return typeof value === "object" && value !== null;
37
+ }
38
+
39
+ function isBashToolInput(input: unknown): input is { command?: string } {
40
+ return typeof input === "object" && input !== null;
41
+ }
42
+
43
+ // =============================================================================
44
+ // NPM Operations
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Get list of pi-* packages installed globally via npm.
49
+ * Returns empty array on error (non-critical operation).
50
+ */
51
+ function getNpmGlobalPackages(): string[] {
52
+ try {
53
+ const output = execSync("npm list -g --json --depth=0", {
54
+ encoding: "utf-8",
55
+ timeout: 5000,
56
+ stdio: ["pipe", "pipe", "ignore"], // Ignore stderr
57
+ });
58
+
59
+ const parsed = JSON.parse(output) as {
60
+ dependencies?: Record<string, unknown>;
61
+ };
62
+ const deps = parsed.dependencies || {};
63
+
64
+ return Object.keys(deps).filter(
65
+ (name) =>
66
+ (name.startsWith("pi-") || name.includes("/pi-")) &&
67
+ name !== CORE_PACKAGE,
68
+ );
69
+ } catch {
70
+ // Silently fail - this is a non-critical operation
71
+ return [];
72
+ }
73
+ }
74
+
75
+ // =============================================================================
76
+ // Settings Operations
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Read and parse pi settings.json.
81
+ * Returns empty settings on error (non-critical operation).
82
+ */
83
+ function readPiSettings(): PiSettings {
84
+ try {
85
+ const content = readFileSync(SETTINGS_PATH, "utf-8");
86
+ const parsed = JSON.parse(content) as unknown;
87
+
88
+ if (!isPiSettings(parsed)) {
89
+ return {};
90
+ }
91
+
92
+ return parsed;
93
+ } catch {
94
+ // File doesn't exist or is invalid - return empty settings
95
+ return {};
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Get list of packages registered in pi's settings.json.
101
+ * Normalizes both "npm:pi-foo" and "pi-foo" formats to "pi-foo".
102
+ */
103
+ function getRegisteredPackages(): string[] {
104
+ const settings = readPiSettings();
105
+ const packages = settings.packages || [];
106
+
107
+ return packages.map((pkg: string) => {
108
+ // Handle both "npm:pi-foo" and "pi-foo" formats
109
+ return pkg.startsWith(NPM_PREFIX) ? pkg.slice(NPM_PREFIX.length) : pkg;
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Write updated settings back to settings.json.
115
+ * Silently fails on error (non-critical operation).
116
+ */
117
+ function writePiSettings(settings: PiSettings): void {
118
+ try {
119
+ writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
120
+ } catch (error) {
121
+ // Log but don't throw - this is a non-critical operation
122
+ console.error("[pi-pkg-guard] Failed to write settings:", error);
123
+ }
124
+ }
125
+
126
+ // =============================================================================
127
+ // Package Analysis
128
+ // =============================================================================
129
+
130
+ /**
131
+ * Compare npm global packages against registered packages.
132
+ * Returns diff showing orphaned packages (installed but not registered).
133
+ */
134
+ function analyzePackages(): PackageDiff {
135
+ const npmPackages = new Set(getNpmGlobalPackages());
136
+ const registeredPackages = new Set(getRegisteredPackages());
137
+
138
+ const orphaned = [...npmPackages].filter(
139
+ (pkg) => !registeredPackages.has(pkg),
140
+ );
141
+
142
+ return {
143
+ orphaned,
144
+ hasOrphans: orphaned.length > 0,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Sync orphaned packages to settings.json.
150
+ * Adds npm: prefix to each orphaned package.
151
+ */
152
+ function syncOrphanedPackages(diff: PackageDiff): void {
153
+ if (diff.orphaned.length === 0) return;
154
+
155
+ const settings = readPiSettings();
156
+ settings.packages = settings.packages || [];
157
+
158
+ for (const pkg of diff.orphaned) {
159
+ const npmRef = `${NPM_PREFIX}${pkg}`;
160
+ if (!settings.packages.includes(npmRef)) {
161
+ settings.packages.push(npmRef);
162
+ }
163
+ }
164
+
165
+ writePiSettings(settings);
166
+ }
167
+
168
+ // =============================================================================
169
+ // npm Install Guard
170
+ // =============================================================================
171
+
172
+ // Detect npm install with -g or --global and a pi-* package
173
+ const NPM_GLOBAL_PATTERN = /npm\s+(install|i)\s+.*(-g|--global)/;
174
+ const PI_PACKAGE_PATTERN = /pi-[\w-]+/;
175
+
176
+ /**
177
+ * Check if a bash command is a global npm install of a pi package.
178
+ */
179
+ function isGlobalPiInstall(command: string): {
180
+ isMatch: boolean;
181
+ packageName?: string;
182
+ } {
183
+ if (!NPM_GLOBAL_PATTERN.test(command)) {
184
+ return { isMatch: false };
185
+ }
186
+
187
+ const match = command.match(PI_PACKAGE_PATTERN);
188
+ if (!match) {
189
+ return { isMatch: false };
190
+ }
191
+
192
+ return { isMatch: true, packageName: match[0] };
193
+ }
194
+
195
+ // =============================================================================
196
+ // Extension Entry Point
197
+ // =============================================================================
198
+
199
+ export default function (pi: ExtensionAPI) {
200
+ let lastCheckTime = 0;
201
+
202
+ // ===========================================================================
203
+ // Startup Guard: Check for orphaned packages (debounced to once/hour)
204
+ // ===========================================================================
205
+
206
+ pi.on("session_start", async (event, ctx) => {
207
+ if (event.reason !== "startup") return;
208
+
209
+ const now = Date.now();
210
+ if (now - lastCheckTime < CHECK_INTERVAL_MS) return;
211
+ lastCheckTime = now;
212
+
213
+ const diff = analyzePackages();
214
+
215
+ if (diff.hasOrphans) {
216
+ ctx.ui.setStatus(
217
+ STATUS_KEY,
218
+ `⚠️ ${diff.orphaned.length} orphaned pi package(s). Run /pi-pkg-guard`,
219
+ );
220
+ }
221
+ });
222
+
223
+ // ===========================================================================
224
+ // Sync Command: Register orphaned packages
225
+ // ===========================================================================
226
+
227
+ pi.registerCommand("pi-pkg-guard", {
228
+ description: "Register orphaned pi packages in settings.json",
229
+ handler: async (_args, ctx) => {
230
+ const diff = analyzePackages();
231
+
232
+ if (!diff.hasOrphans) {
233
+ ctx.ui.setStatus(STATUS_KEY, "✅ All pi packages registered");
234
+ setTimeout(
235
+ () => ctx.ui.setStatus(STATUS_KEY, ""),
236
+ STATUS_CLEAR_DELAY_MS,
237
+ );
238
+ return;
239
+ }
240
+
241
+ syncOrphanedPackages(diff);
242
+ ctx.ui.setStatus(
243
+ STATUS_KEY,
244
+ `✅ Registered ${diff.orphaned.length} package(s). Run /reload.`,
245
+ );
246
+ },
247
+ });
248
+
249
+ // ===========================================================================
250
+ // npm Guard: Warn on direct npm install of pi packages
251
+ // ===========================================================================
252
+
253
+ pi.on("tool_call", async (event, ctx) => {
254
+ if (event.toolName !== "bash") return;
255
+
256
+ const input = event.input;
257
+ if (!isBashToolInput(input)) return;
258
+
259
+ const command = input.command || "";
260
+ const { isMatch, packageName } = isGlobalPiInstall(command);
261
+
262
+ if (isMatch && packageName) {
263
+ ctx.ui.notify(
264
+ `⚠️ Use 'pi install npm:${packageName}' instead of 'npm install -g'`,
265
+ "warning",
266
+ );
267
+ }
268
+ });
269
+ }
package/justfile ADDED
@@ -0,0 +1,57 @@
1
+ # pi-pkg-guard task runner
2
+ # https://github.com/casey/just
3
+
4
+ # Default recipe - show available tasks
5
+ default:
6
+ @just --list
7
+
8
+ # Run all checks (format, lint, test, typecheck)
9
+ check: format lint test typecheck
10
+ @echo "✅ All checks passed!"
11
+
12
+ # Check code with biome (no fixes)
13
+ check-only:
14
+ npx @biomejs/biome check .
15
+
16
+ # Fix all auto-fixable biome issues
17
+ fix:
18
+ npx @biomejs/biome check --write .
19
+
20
+ # Format code with biome
21
+ format:
22
+ npx @biomejs/biome format --write .
23
+
24
+ # Lint code with biome
25
+ lint:
26
+ npx @biomejs/biome lint .
27
+
28
+ # Run all tests
29
+ test:
30
+ node --test test/**/*.test.ts
31
+
32
+ # Run tests in watch mode
33
+ test-watch:
34
+ node --test --watch test/**/*.test.ts
35
+
36
+ # TypeScript type checking
37
+ typecheck:
38
+ npx tsc --noEmit --skipLibCheck
39
+
40
+ # Clean build artifacts and dependencies
41
+ clean:
42
+ rm -rf node_modules dist coverage *.log
43
+
44
+ # Install dependencies
45
+ install:
46
+ npm install
47
+
48
+ # Dry run npm publish
49
+ dry-run:
50
+ npm publish --dry-run
51
+
52
+ # Publish to npm (requires auth)
53
+ publish:
54
+ npm publish --access public
55
+
56
+ # CI recipe - runs in CI/CD pipelines (no watch mode)
57
+ ci: check-only test typecheck
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "pi-pkg-guard",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight pi extension that guards against the 'orphaned package' trap where packages are installed via npm but not registered in pi's settings.json",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Alex Lee <657215+alexleekt@users.noreply.github.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/alexleekt/pi-pkg-guard.git"
11
+ },
12
+ "homepage": "https://github.com/alexleekt/pi-pkg-guard#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/alexleekt/pi-pkg-guard/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi-coding-agent",
20
+ "package-manager",
21
+ "guardrails",
22
+ "npm",
23
+ "extensions"
24
+ ],
25
+ "files": [
26
+ "extensions/",
27
+ "test/",
28
+ "justfile",
29
+ "biome.json",
30
+ "tsconfig.json",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test test/**/*.test.ts"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^1.9.0",
39
+ "@types/node": "^20.0.0",
40
+ "typescript": "^5.0.0"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "pi": {
46
+ "extensions": ["./extensions/index.ts"]
47
+ },
48
+ "peerDependencies": {
49
+ "@mariozechner/pi-coding-agent": "*",
50
+ "@mariozechner/pi-tui": "*"
51
+ },
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ }
55
+ }
@@ -0,0 +1,318 @@
1
+ // Test cases for package analysis logic
2
+
3
+ import assert from "node:assert";
4
+ import { describe, it } from "node:test";
5
+
6
+ // Mock types and functions
7
+ interface PackageDiff {
8
+ orphaned: string[];
9
+ hasOrphans: boolean;
10
+ }
11
+
12
+ // Simulated functions from extensions/index.ts
13
+ function analyzePackages(
14
+ npmPackages: string[],
15
+ registeredPackages: string[],
16
+ ): PackageDiff {
17
+ const npmSet = new Set(npmPackages);
18
+ const registeredSet = new Set(registeredPackages);
19
+
20
+ const orphaned = [...npmSet].filter((pkg) => !registeredSet.has(pkg));
21
+
22
+ return {
23
+ orphaned,
24
+ hasOrphans: orphaned.length > 0,
25
+ };
26
+ }
27
+
28
+ function syncOrphanedPackages(
29
+ orphaned: string[],
30
+ existingPackages: string[],
31
+ ): string[] {
32
+ const NPM_PREFIX = "npm:";
33
+ const result = [...existingPackages];
34
+
35
+ for (const pkg of orphaned) {
36
+ const npmRef = `${NPM_PREFIX}${pkg}`;
37
+ if (!result.includes(npmRef) && !result.includes(pkg)) {
38
+ result.push(npmRef);
39
+ }
40
+ }
41
+
42
+ return result;
43
+ }
44
+
45
+ // Helper to normalize package names
46
+ function normalizePackageName(pkg: string): string {
47
+ const NPM_PREFIX = "npm:";
48
+ return pkg.startsWith(NPM_PREFIX) ? pkg.slice(NPM_PREFIX.length) : pkg;
49
+ }
50
+
51
+ describe("analyzePackages - GOOD CASES", () => {
52
+ it("should return no orphans when all packages are registered", () => {
53
+ const npm = ["pi-foo", "pi-bar"];
54
+ const registered = ["pi-foo", "pi-bar"];
55
+ const result = analyzePackages(npm, registered);
56
+
57
+ assert.strictEqual(result.hasOrphans, false);
58
+ assert.deepStrictEqual(result.orphaned, []);
59
+ });
60
+
61
+ it("should detect orphaned packages not in registered", () => {
62
+ const npm = ["pi-foo", "pi-bar", "pi-baz"];
63
+ const registered = ["pi-foo"];
64
+ const result = analyzePackages(npm, registered);
65
+
66
+ assert.strictEqual(result.hasOrphans, true);
67
+ assert.deepStrictEqual(result.orphaned, ["pi-bar", "pi-baz"]);
68
+ });
69
+
70
+ it("should handle empty npm packages", () => {
71
+ const npm: string[] = [];
72
+ const registered = ["pi-foo"];
73
+ const result = analyzePackages(npm, registered);
74
+
75
+ assert.strictEqual(result.hasOrphans, false);
76
+ assert.deepStrictEqual(result.orphaned, []);
77
+ });
78
+
79
+ it("should handle empty registered packages", () => {
80
+ const npm = ["pi-foo", "pi-bar"];
81
+ const registered: string[] = [];
82
+ const result = analyzePackages(npm, registered);
83
+
84
+ assert.strictEqual(result.hasOrphans, true);
85
+ assert.deepStrictEqual(result.orphaned, ["pi-foo", "pi-bar"]);
86
+ });
87
+
88
+ it("should handle npm: prefix in registered", () => {
89
+ const npm = ["pi-foo", "pi-bar"];
90
+ const registered = ["npm:pi-foo", "npm:pi-bar"].map(normalizePackageName);
91
+ const result = analyzePackages(npm, registered);
92
+
93
+ assert.strictEqual(result.hasOrphans, false);
94
+ assert.deepStrictEqual(result.orphaned, []);
95
+ });
96
+
97
+ it("should handle mixed prefixes in registered", () => {
98
+ const npm = ["pi-foo", "pi-bar", "pi-baz"];
99
+ const registered = ["npm:pi-foo", "pi-bar"].map(normalizePackageName);
100
+ const result = analyzePackages(npm, registered);
101
+
102
+ assert.strictEqual(result.hasOrphans, true);
103
+ assert.deepStrictEqual(result.orphaned, ["pi-baz"]);
104
+ });
105
+
106
+ it("should exclude core package from orphaned", () => {
107
+ // Core package is filtered BEFORE analyzePackages is called
108
+ // So it shouldn't be in the npm list passed to analyzePackages
109
+ const npm = ["pi-foo", "@mariozechner/pi-coding-agent"];
110
+ // Core package should be filtered out by getNpmGlobalPackages
111
+ const filteredNpm = npm.filter(
112
+ (p) => p !== "@mariozechner/pi-coding-agent",
113
+ );
114
+ const registered: string[] = [];
115
+ const result = analyzePackages(filteredNpm, registered);
116
+
117
+ assert.strictEqual(result.hasOrphans, true);
118
+ assert.deepStrictEqual(result.orphaned, ["pi-foo"]);
119
+ // Core package should not appear
120
+ assert.strictEqual(
121
+ result.orphaned.includes("@mariozechner/pi-coding-agent"),
122
+ false,
123
+ );
124
+ });
125
+ });
126
+
127
+ describe("analyzePackages - EDGE CASES", () => {
128
+ it("should handle duplicates in npm list", () => {
129
+ // Set automatically deduplicates
130
+ const npm = ["pi-foo", "pi-foo", "pi-bar"];
131
+ const registered: string[] = [];
132
+ const result = analyzePackages(npm, registered);
133
+
134
+ // Should have pi-foo only once
135
+ assert.strictEqual(result.orphaned.length, 2);
136
+ assert.ok(result.orphaned.includes("pi-foo"));
137
+ assert.ok(result.orphaned.includes("pi-bar"));
138
+ });
139
+
140
+ it("should handle scoped packages in npm", () => {
141
+ const npm = ["pi-foo", "@scope/pi-bar"];
142
+ const registered = ["pi-foo"];
143
+ const result = analyzePackages(npm, registered);
144
+
145
+ assert.strictEqual(result.hasOrphans, true);
146
+ assert.deepStrictEqual(result.orphaned, ["@scope/pi-bar"]);
147
+ });
148
+
149
+ it("should handle scoped packages in registered (normalized)", () => {
150
+ const npm = ["@scope/pi-bar"];
151
+ const registered = ["@scope/pi-bar"];
152
+ const result = analyzePackages(npm, registered);
153
+
154
+ assert.strictEqual(result.hasOrphans, false);
155
+ });
156
+
157
+ it("should handle empty strings", () => {
158
+ const npm = [""];
159
+ const registered: string[] = [];
160
+ const result = analyzePackages(npm, registered);
161
+
162
+ assert.strictEqual(result.hasOrphans, true);
163
+ assert.deepStrictEqual(result.orphaned, [""]);
164
+ });
165
+
166
+ it("should handle packages with special characters", () => {
167
+ const npm = ["pi-foo.bar", "pi-foo_bar", "pi-foo~bar"];
168
+ const registered: string[] = [];
169
+ const result = analyzePackages(npm, registered);
170
+
171
+ assert.strictEqual(result.orphaned.length, 3);
172
+ });
173
+
174
+ it("should be case-sensitive", () => {
175
+ const npm = ["pi-Foo", "PI-bar"];
176
+ const registered = ["pi-foo", "pi-bar"];
177
+ const result = analyzePackages(npm, registered);
178
+
179
+ // These are different packages due to case sensitivity
180
+ assert.strictEqual(result.hasOrphans, true);
181
+ assert.ok(result.orphaned.includes("pi-Foo"));
182
+ assert.ok(result.orphaned.includes("PI-bar"));
183
+ });
184
+
185
+ it("should handle very long package names", () => {
186
+ const longName = `pi-${"a".repeat(200)}`;
187
+ const npm = [longName];
188
+ const registered: string[] = [];
189
+ const result = analyzePackages(npm, registered);
190
+
191
+ assert.strictEqual(result.hasOrphans, true);
192
+ assert.deepStrictEqual(result.orphaned, [longName]);
193
+ });
194
+
195
+ it("should handle unicode package names", () => {
196
+ const npm = ["pi-日本語"];
197
+ const registered: string[] = [];
198
+ const result = analyzePackages(npm, registered);
199
+
200
+ assert.strictEqual(result.hasOrphans, true);
201
+ assert.deepStrictEqual(result.orphaned, ["pi-日本語"]);
202
+ });
203
+ });
204
+
205
+ describe("syncOrphanedPackages - GOOD CASES", () => {
206
+ it("should add orphaned packages with npm: prefix", () => {
207
+ const orphaned = ["pi-foo", "pi-bar"];
208
+ const existing: string[] = [];
209
+ const result = syncOrphanedPackages(orphaned, existing);
210
+
211
+ assert.deepStrictEqual(result, ["npm:pi-foo", "npm:pi-bar"]);
212
+ });
213
+
214
+ it("should preserve existing packages", () => {
215
+ const orphaned = ["pi-baz"];
216
+ const existing = ["npm:pi-foo", "npm:pi-bar"];
217
+ const result = syncOrphanedPackages(orphaned, existing);
218
+
219
+ assert.deepStrictEqual(result, ["npm:pi-foo", "npm:pi-bar", "npm:pi-baz"]);
220
+ });
221
+
222
+ it("should handle empty orphaned list", () => {
223
+ const orphaned: string[] = [];
224
+ const existing = ["npm:pi-foo"];
225
+ const result = syncOrphanedPackages(orphaned, existing);
226
+
227
+ assert.deepStrictEqual(result, ["npm:pi-foo"]);
228
+ });
229
+
230
+ it("should not duplicate if already registered without prefix", () => {
231
+ const orphaned = ["pi-foo"];
232
+ const existing = ["pi-foo"];
233
+ const result = syncOrphanedPackages(orphaned, existing);
234
+
235
+ // Should not add duplicate
236
+ assert.strictEqual(result.length, 1);
237
+ assert.deepStrictEqual(result, ["pi-foo"]);
238
+ });
239
+
240
+ it("should not duplicate if already registered with prefix", () => {
241
+ const orphaned = ["pi-foo"];
242
+ const existing = ["npm:pi-foo"];
243
+ const result = syncOrphanedPackages(orphaned, existing);
244
+
245
+ // Should not add duplicate
246
+ assert.strictEqual(result.length, 1);
247
+ assert.deepStrictEqual(result, ["npm:pi-foo"]);
248
+ });
249
+ });
250
+
251
+ describe("syncOrphanedPackages - EDGE CASES", () => {
252
+ it("should handle duplicates in orphaned list", () => {
253
+ const orphaned = ["pi-foo", "pi-foo", "pi-bar"];
254
+ const existing: string[] = [];
255
+ const result = syncOrphanedPackages(orphaned, existing);
256
+
257
+ // Function deduplicates - checks includes() before adding
258
+ assert.strictEqual(result.length, 2);
259
+ assert.deepStrictEqual(result, ["npm:pi-foo", "npm:pi-bar"]);
260
+ });
261
+
262
+ it("should handle empty strings in orphaned", () => {
263
+ const orphaned = [""];
264
+ const existing: string[] = [];
265
+ const result = syncOrphanedPackages(orphaned, existing);
266
+
267
+ // Empty string becomes "npm:"
268
+ assert.deepStrictEqual(result, ["npm:"]);
269
+ });
270
+
271
+ it("should handle mixed formats in existing", () => {
272
+ const orphaned = ["pi-baz"];
273
+ const existing = ["npm:pi-foo", "pi-bar"]; // Mixed prefixes
274
+ const result = syncOrphanedPackages(orphaned, existing);
275
+
276
+ assert.deepStrictEqual(result, ["npm:pi-foo", "pi-bar", "npm:pi-baz"]);
277
+ });
278
+
279
+ it("should handle very long package names", () => {
280
+ const longName = `pi-${"a".repeat(200)}`;
281
+ const orphaned = [longName];
282
+ const existing: string[] = [];
283
+ const result = syncOrphanedPackages(orphaned, existing);
284
+
285
+ assert.strictEqual(result.length, 1);
286
+ assert.ok(result[0].startsWith("npm:"));
287
+ });
288
+ });
289
+
290
+ describe("normalizePackageName", () => {
291
+ it("should remove npm: prefix", () => {
292
+ assert.strictEqual(normalizePackageName("npm:pi-foo"), "pi-foo");
293
+ });
294
+
295
+ it("should leave unprefixed name unchanged", () => {
296
+ assert.strictEqual(normalizePackageName("pi-foo"), "pi-foo");
297
+ });
298
+
299
+ it("should handle scoped packages with prefix", () => {
300
+ assert.strictEqual(
301
+ normalizePackageName("npm:@scope/pi-foo"),
302
+ "@scope/pi-foo",
303
+ );
304
+ });
305
+
306
+ it("should handle empty string", () => {
307
+ assert.strictEqual(normalizePackageName(""), "");
308
+ });
309
+
310
+ it("should handle string with just prefix", () => {
311
+ assert.strictEqual(normalizePackageName("npm:"), "");
312
+ });
313
+
314
+ it("should handle multiple prefixes (only removes first)", () => {
315
+ // This is actually a bug - should we remove all npm: prefixes?
316
+ assert.strictEqual(normalizePackageName("npm:npm:pi-foo"), "npm:pi-foo");
317
+ });
318
+ });
@@ -0,0 +1,186 @@
1
+ // Test cases for type guards
2
+
3
+ import assert from "node:assert";
4
+ import { describe, it } from "node:test";
5
+
6
+ // Type guard functions (copied from extensions/index.ts for testing)
7
+ function isPiSettings(
8
+ value: unknown,
9
+ ): value is { packages?: string[]; extensions?: string[] } {
10
+ if (typeof value !== "object" || value === null) return false;
11
+ if (Array.isArray(value)) return false;
12
+ const candidate = value as Record<string, unknown>;
13
+
14
+ if (candidate.packages !== undefined) {
15
+ if (!Array.isArray(candidate.packages)) return false;
16
+ if (!candidate.packages.every((p) => typeof p === "string")) return false;
17
+ }
18
+
19
+ if (candidate.extensions !== undefined) {
20
+ if (!Array.isArray(candidate.extensions)) return false;
21
+ if (!candidate.extensions.every((e) => typeof e === "string")) return false;
22
+ }
23
+
24
+ return true;
25
+ }
26
+
27
+ function isBashToolInput(input: unknown): input is { command?: string } {
28
+ if (typeof input !== "object" || input === null) return false;
29
+ if (Array.isArray(input)) return false;
30
+ return true;
31
+ }
32
+
33
+ describe("isPiSettings", () => {
34
+ // GOOD CASES
35
+ it("should return true for valid PiSettings with packages", () => {
36
+ assert.strictEqual(isPiSettings({ packages: ["npm:pi-test"] }), true);
37
+ });
38
+
39
+ it("should return true for valid PiSettings with extensions", () => {
40
+ assert.strictEqual(isPiSettings({ extensions: ["/path/to/ext"] }), true);
41
+ });
42
+
43
+ it("should return true for valid PiSettings with both", () => {
44
+ assert.strictEqual(
45
+ isPiSettings({
46
+ packages: ["npm:pi-test"],
47
+ extensions: ["/path/to/ext"],
48
+ }),
49
+ true,
50
+ );
51
+ });
52
+
53
+ it("should return true for empty object", () => {
54
+ assert.strictEqual(isPiSettings({}), true);
55
+ });
56
+
57
+ it("should return true for empty arrays", () => {
58
+ assert.strictEqual(isPiSettings({ packages: [], extensions: [] }), true);
59
+ });
60
+
61
+ // BAD CASES
62
+ it("should return false for null", () => {
63
+ assert.strictEqual(isPiSettings(null), false);
64
+ });
65
+
66
+ it("should return false for undefined", () => {
67
+ assert.strictEqual(isPiSettings(undefined), false);
68
+ });
69
+
70
+ it("should return false for string", () => {
71
+ assert.strictEqual(isPiSettings("not an object"), false);
72
+ });
73
+
74
+ it("should return false for number", () => {
75
+ assert.strictEqual(isPiSettings(123), false);
76
+ });
77
+
78
+ it("should return false for array", () => {
79
+ assert.strictEqual(isPiSettings(["not", "valid"]), false);
80
+ });
81
+
82
+ // EDGE CASES - packages
83
+ it("should return false for packages as string", () => {
84
+ assert.strictEqual(isPiSettings({ packages: "npm:pi-test" }), false);
85
+ });
86
+
87
+ it("should return false for packages with non-string elements", () => {
88
+ assert.strictEqual(
89
+ isPiSettings({ packages: ["valid", 123, "valid"] }),
90
+ false,
91
+ );
92
+ });
93
+
94
+ it("should return false for packages as number", () => {
95
+ assert.strictEqual(isPiSettings({ packages: 42 }), false);
96
+ });
97
+
98
+ it("should return false for packages as object", () => {
99
+ assert.strictEqual(isPiSettings({ packages: {} }), false);
100
+ });
101
+
102
+ it("should return false for packages as null", () => {
103
+ assert.strictEqual(isPiSettings({ packages: null }), false);
104
+ });
105
+
106
+ // EDGE CASES - extensions
107
+ it("should return false for extensions as string", () => {
108
+ assert.strictEqual(isPiSettings({ extensions: "/path/to/ext" }), false);
109
+ });
110
+
111
+ it("should return false for extensions with non-string elements", () => {
112
+ assert.strictEqual(
113
+ isPiSettings({ extensions: ["valid", null, "valid"] }),
114
+ false,
115
+ );
116
+ });
117
+
118
+ // EDGE CASES - mixed
119
+ it("should return false for valid packages but invalid extensions", () => {
120
+ assert.strictEqual(
121
+ isPiSettings({ packages: ["npm:pi-test"], extensions: "bad" }),
122
+ false,
123
+ );
124
+ });
125
+
126
+ it("should return false for invalid packages but valid extensions", () => {
127
+ assert.strictEqual(
128
+ isPiSettings({ packages: 123, extensions: ["/path"] }),
129
+ false,
130
+ );
131
+ });
132
+
133
+ it("should return true for object with extra properties", () => {
134
+ assert.strictEqual(
135
+ isPiSettings({
136
+ packages: ["npm:pi-test"],
137
+ extensions: ["/path"],
138
+ extra: "ignored",
139
+ }),
140
+ true,
141
+ );
142
+ });
143
+ });
144
+
145
+ describe("isBashToolInput", () => {
146
+ // GOOD CASES
147
+ it("should return true for object with command string", () => {
148
+ assert.strictEqual(isBashToolInput({ command: "npm install" }), true);
149
+ });
150
+
151
+ it("should return true for empty object", () => {
152
+ assert.strictEqual(isBashToolInput({}), true);
153
+ });
154
+
155
+ it("should return true for object with command undefined", () => {
156
+ assert.strictEqual(isBashToolInput({ command: undefined }), true);
157
+ });
158
+
159
+ it("should return true for object with extra properties", () => {
160
+ assert.strictEqual(
161
+ isBashToolInput({ command: "npm install", extra: 123 }),
162
+ true,
163
+ );
164
+ });
165
+
166
+ // BAD CASES
167
+ it("should return false for null", () => {
168
+ assert.strictEqual(isBashToolInput(null), false);
169
+ });
170
+
171
+ it("should return false for undefined", () => {
172
+ assert.strictEqual(isBashToolInput(undefined), false);
173
+ });
174
+
175
+ it("should return false for string", () => {
176
+ assert.strictEqual(isBashToolInput("command"), false);
177
+ });
178
+
179
+ it("should return false for number", () => {
180
+ assert.strictEqual(isBashToolInput(123), false);
181
+ });
182
+
183
+ it("should return false for array", () => {
184
+ assert.strictEqual(isBashToolInput(["command"]), false);
185
+ });
186
+ });
@@ -0,0 +1,267 @@
1
+ // Test cases for npm install detection regex
2
+
3
+ import assert from "node:assert";
4
+ import { describe, it } from "node:test";
5
+
6
+ // Regex patterns (from extensions/index.ts)
7
+ const NPM_GLOBAL_PATTERN = /npm\s+(?:install|i)(?:\s+\S+)*\s+(?:-g|--global)\b/;
8
+ const PI_PACKAGE_PATTERN = /\bpi-[a-z0-9-]+\b/;
9
+
10
+ function isGlobalPiInstall(command: string): {
11
+ isMatch: boolean;
12
+ packageName?: string;
13
+ } {
14
+ if (!NPM_GLOBAL_PATTERN.test(command)) {
15
+ return { isMatch: false };
16
+ }
17
+
18
+ const match = command.match(PI_PACKAGE_PATTERN);
19
+ if (!match) {
20
+ return { isMatch: false };
21
+ }
22
+
23
+ return { isMatch: true, packageName: match[0] };
24
+ }
25
+
26
+ describe("isGlobalPiInstall - GOOD CASES (should detect)", () => {
27
+ it("should detect 'npm install -g pi-foo'", () => {
28
+ const result = isGlobalPiInstall("npm install -g pi-foo");
29
+ assert.strictEqual(result.isMatch, true);
30
+ assert.strictEqual(result.packageName, "pi-foo");
31
+ });
32
+
33
+ it("should detect 'npm i -g pi-foo' (short install)", () => {
34
+ const result = isGlobalPiInstall("npm i -g pi-foo");
35
+ assert.strictEqual(result.isMatch, true);
36
+ assert.strictEqual(result.packageName, "pi-foo");
37
+ });
38
+
39
+ it("should detect 'npm install --global pi-foo'", () => {
40
+ const result = isGlobalPiInstall("npm install --global pi-foo");
41
+ assert.strictEqual(result.isMatch, true);
42
+ assert.strictEqual(result.packageName, "pi-foo");
43
+ });
44
+
45
+ it("should detect 'npm i --global pi-foo'", () => {
46
+ const result = isGlobalPiInstall("npm i --global pi-foo");
47
+ assert.strictEqual(result.isMatch, true);
48
+ assert.strictEqual(result.packageName, "pi-foo");
49
+ });
50
+
51
+ it("should detect with scoped package 'npm install -g @scope/pi-foo'", () => {
52
+ const result = isGlobalPiInstall("npm install -g @scope/pi-foo");
53
+ assert.strictEqual(result.isMatch, true);
54
+ assert.strictEqual(result.packageName, "pi-foo");
55
+ });
56
+
57
+ it("should detect 'npm install pi-foo -g' (-g at end)", () => {
58
+ const result = isGlobalPiInstall("npm install pi-foo -g");
59
+ assert.strictEqual(result.isMatch, true);
60
+ assert.strictEqual(result.packageName, "pi-foo");
61
+ });
62
+
63
+ it("should detect 'npm install pi-foo --global'", () => {
64
+ const result = isGlobalPiInstall("npm install pi-foo --global");
65
+ assert.strictEqual(result.isMatch, true);
66
+ assert.strictEqual(result.packageName, "pi-foo");
67
+ });
68
+
69
+ it("should detect with multiple packages 'npm install -g pi-foo pi-bar'", () => {
70
+ const result = isGlobalPiInstall("npm install -g pi-foo pi-bar");
71
+ assert.strictEqual(result.isMatch, true);
72
+ // Returns first match
73
+ assert.strictEqual(result.packageName, "pi-foo");
74
+ });
75
+
76
+ it("should detect with version 'npm install -g pi-foo@1.0.0'", () => {
77
+ const result = isGlobalPiInstall("npm install -g pi-foo@1.0.0");
78
+ assert.strictEqual(result.isMatch, true);
79
+ assert.strictEqual(result.packageName, "pi-foo");
80
+ });
81
+ });
82
+
83
+ describe("isGlobalPiInstall - BAD CASES (should NOT detect)", () => {
84
+ it("should NOT detect local install 'npm install pi-foo' (no -g)", () => {
85
+ const result = isGlobalPiInstall("npm install pi-foo");
86
+ assert.strictEqual(result.isMatch, false);
87
+ });
88
+
89
+ it("should NOT detect 'npm install pi-foo --save'", () => {
90
+ const result = isGlobalPiInstall("npm install pi-foo --save");
91
+ assert.strictEqual(result.isMatch, false);
92
+ });
93
+
94
+ it("should NOT detect 'npm install pi-foo --save-dev'", () => {
95
+ const result = isGlobalPiInstall("npm install pi-foo --save-dev");
96
+ assert.strictEqual(result.isMatch, false);
97
+ });
98
+
99
+ it("should NOT detect non-pi package 'npm install -g lodash'", () => {
100
+ const result = isGlobalPiInstall("npm install -g lodash");
101
+ assert.strictEqual(result.isMatch, false);
102
+ });
103
+
104
+ it("should NOT detect 'npm install -g typescript'", () => {
105
+ const result = isGlobalPiInstall("npm install -g typescript");
106
+ assert.strictEqual(result.isMatch, false);
107
+ });
108
+
109
+ it("should NOT detect 'npm run install'", () => {
110
+ const result = isGlobalPiInstall("npm run install");
111
+ assert.strictEqual(result.isMatch, false);
112
+ });
113
+
114
+ it("should NOT detect 'npm uninstall -g pi-foo'", () => {
115
+ const result = isGlobalPiInstall("npm uninstall -g pi-foo");
116
+ assert.strictEqual(result.isMatch, false);
117
+ });
118
+
119
+ it("should NOT detect 'npm remove -g pi-foo'", () => {
120
+ const result = isGlobalPiInstall("npm remove -g pi-foo");
121
+ assert.strictEqual(result.isMatch, false);
122
+ });
123
+
124
+ it("should NOT detect 'yarn global add pi-foo'", () => {
125
+ const result = isGlobalPiInstall("yarn global add pi-foo");
126
+ assert.strictEqual(result.isMatch, false);
127
+ });
128
+
129
+ it("should NOT detect 'pnpm add -g pi-foo'", () => {
130
+ const result = isGlobalPiInstall("pnpm add -g pi-foo");
131
+ assert.strictEqual(result.isMatch, false);
132
+ });
133
+
134
+ it("should NOT detect empty string", () => {
135
+ const result = isGlobalPiInstall("");
136
+ assert.strictEqual(result.isMatch, false);
137
+ });
138
+
139
+ it("should NOT detect unrelated command 'ls -la'", () => {
140
+ const result = isGlobalPiInstall("ls -la");
141
+ assert.strictEqual(result.isMatch, false);
142
+ });
143
+ });
144
+
145
+ describe("isGlobalPiInstall - EDGE CASES", () => {
146
+ it("should NOT detect 'npm install api-foo -g' (pi- inside word)", () => {
147
+ // This is a tricky case - "api-foo" contains "pi-" but shouldn't match
148
+ // Our pattern uses \b which might not catch this perfectly
149
+ const result = isGlobalPiInstall("npm install api-foo -g");
150
+ // This will likely match "pi-foo" inside "api-foo" due to \b behavior
151
+ // Documenting this as a known limitation
152
+ console.log("Edge case 'api-foo':", result);
153
+ });
154
+
155
+ it("should NOT detect 'npm install @scope/api-foo -g'", () => {
156
+ const result = isGlobalPiInstall("npm install @scope/api-foo -g");
157
+ console.log("Edge case '@scope/api-foo':", result);
158
+ });
159
+
160
+ it("should detect 'npm install -g pi' (single 'pi' is not a package)", () => {
161
+ // "pi" by itself is not "pi-" followed by something
162
+ const result = isGlobalPiInstall("npm install -g pi");
163
+ assert.strictEqual(result.isMatch, false);
164
+ });
165
+
166
+ it("should detect 'npm install -g pi-' (trailing dash, no name)", () => {
167
+ // "pi-" without anything after shouldn't match
168
+ const result = isGlobalPiInstall("npm install -g pi-");
169
+ assert.strictEqual(result.isMatch, false);
170
+ });
171
+
172
+ it("should detect 'npm install -g my-pi-foo'", () => {
173
+ // This WILL detect because \b matches at start of word
174
+ // and "pi-foo" is a valid pattern within the word
175
+ const result = isGlobalPiInstall("npm install -g my-pi-foo");
176
+ assert.strictEqual(result.isMatch, true);
177
+ assert.strictEqual(result.packageName, "pi-foo");
178
+ });
179
+
180
+ it("should handle multiple -g flags 'npm install -g pi-foo -g'", () => {
181
+ const result = isGlobalPiInstall("npm install -g pi-foo -g");
182
+ assert.strictEqual(result.isMatch, true);
183
+ assert.strictEqual(result.packageName, "pi-foo");
184
+ });
185
+
186
+ it("should handle extra whitespace 'npm install -g pi-foo'", () => {
187
+ const result = isGlobalPiInstall("npm install -g pi-foo");
188
+ // Current pattern uses \s+ which matches one or more whitespace
189
+ assert.strictEqual(result.isMatch, true);
190
+ });
191
+
192
+ it("should detect with hyphenated name 'npm install -g pi-my-extension'", () => {
193
+ const result = isGlobalPiInstall("npm install -g pi-my-extension");
194
+ assert.strictEqual(result.isMatch, true);
195
+ assert.strictEqual(result.packageName, "pi-my-extension");
196
+ });
197
+
198
+ it("should detect with numbers 'npm install -g pi-ext123'", () => {
199
+ const result = isGlobalPiInstall("npm install -g pi-ext123");
200
+ assert.strictEqual(result.isMatch, true);
201
+ assert.strictEqual(result.packageName, "pi-ext123");
202
+ });
203
+
204
+ it("should NOT detect with uppercase 'npm install -g PI-FOO'", () => {
205
+ // Pattern is case-sensitive (lowercase only)
206
+ const result = isGlobalPiInstall("npm install -g PI-FOO");
207
+ assert.strictEqual(result.isMatch, false);
208
+ });
209
+ });
210
+
211
+ describe("Regex pattern unit tests", () => {
212
+ describe("NPM_GLOBAL_PATTERN", () => {
213
+ it("should match 'npm install -g foo'", () => {
214
+ assert.strictEqual(NPM_GLOBAL_PATTERN.test("npm install -g foo"), true);
215
+ });
216
+
217
+ it("should NOT match 'npm install foo'", () => {
218
+ assert.strictEqual(NPM_GLOBAL_PATTERN.test("npm install foo"), false);
219
+ });
220
+
221
+ it("should match 'npm i -g foo'", () => {
222
+ assert.strictEqual(NPM_GLOBAL_PATTERN.test("npm i -g foo"), true);
223
+ });
224
+
225
+ it("should match 'npm install --global foo'", () => {
226
+ assert.strictEqual(
227
+ NPM_GLOBAL_PATTERN.test("npm install --global foo"),
228
+ true,
229
+ );
230
+ });
231
+
232
+ it("should NOT match 'npm uninstall -g foo'", () => {
233
+ assert.strictEqual(
234
+ NPM_GLOBAL_PATTERN.test("npm uninstall -g foo"),
235
+ false,
236
+ );
237
+ });
238
+ });
239
+
240
+ describe("PI_PACKAGE_PATTERN", () => {
241
+ it("should match 'pi-foo'", () => {
242
+ assert.strictEqual(PI_PACKAGE_PATTERN.test("pi-foo"), true);
243
+ });
244
+
245
+ it("should find 'pi-foo' in multiple words", () => {
246
+ const input = "pi-foo pi-bar pi-123";
247
+ const matches = input.match(PI_PACKAGE_PATTERN);
248
+ assert.ok(matches, "Should find matches");
249
+ assert.strictEqual(matches[0], "pi-foo");
250
+ assert.strictEqual(matches[1], undefined); // Without global flag, only first match
251
+ });
252
+
253
+ it("should NOT match just 'pi'", () => {
254
+ assert.strictEqual(PI_PACKAGE_PATTERN.test("pi"), false);
255
+ });
256
+
257
+ it("should NOT match 'api-foo'", () => {
258
+ // \b at start means word boundary, so 'api-foo' won't match
259
+ assert.strictEqual(PI_PACKAGE_PATTERN.test("api-foo"), false);
260
+ });
261
+
262
+ it("should match 'my-pi-foo' (within word)", () => {
263
+ // \b matches at start of 'pi' within the word
264
+ assert.strictEqual(PI_PACKAGE_PATTERN.test("my-pi-foo"), true);
265
+ });
266
+ });
267
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "noEmit": true,
12
+ "noImplicitAny": true,
13
+ "strictNullChecks": true,
14
+ "strictFunctionTypes": true,
15
+ "strictBindCallApply": true,
16
+ "strictPropertyInitialization": true,
17
+ "noImplicitThis": true,
18
+ "alwaysStrict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "noImplicitReturns": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "types": ["node"]
24
+ },
25
+ "include": ["extensions/**/*", "test/**/*"],
26
+ "exclude": ["node_modules"]
27
+ }