pi-extmgr 0.1.22 → 0.1.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -70,12 +70,17 @@ export default function extensionsManager(pi: ExtensionAPI) {
70
70
  // Restore persisted auto-update config into session entries so sync lookups are valid.
71
71
  await hydrateAutoUpdateConfig(pi, ctx);
72
72
 
73
- if (!ctx.hasUI) return;
73
+ if (!ctx.hasUI) {
74
+ stopAutoUpdateTimer();
75
+ return;
76
+ }
74
77
 
75
78
  const config = getAutoUpdateConfig(ctx);
76
79
  if (config.enabled && config.intervalMs > 0) {
77
80
  const getCtx: ContextProvider = () => ctx;
78
81
  startAutoUpdateTimer(pi, getCtx, createAutoUpdateNotificationHandler(ctx));
82
+ } else {
83
+ stopAutoUpdateTimer();
79
84
  }
80
85
 
81
86
  setImmediate(() => {
@@ -12,7 +12,12 @@ import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.j
12
12
  import { CACHE_TTL, TIMEOUTS } from "../constants.js";
13
13
  import { readSummary } from "../utils/fs.js";
14
14
  import { parseNpmSource } from "../utils/format.js";
15
- import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
15
+ import {
16
+ getPackageSourceKind,
17
+ normalizeLocalSourceIdentity,
18
+ splitGitRepoAndRef,
19
+ } from "../utils/package-source.js";
20
+ import { execNpm } from "../utils/npm-exec.js";
16
21
 
17
22
  let searchCache: SearchCache | null = null;
18
23
 
@@ -67,9 +72,8 @@ export async function searchNpmPackages(
67
72
  ctx.ui.notify(`Searching npm for "${query}"...`, "info");
68
73
  }
69
74
 
70
- const res = await pi.exec("npm", ["search", "--json", `--searchlimit=${searchLimit}`, query], {
75
+ const res = await execNpm(pi, ["search", "--json", `--searchlimit=${searchLimit}`, query], ctx, {
71
76
  timeout: TIMEOUTS.npmSearch,
72
- cwd: ctx.cwd,
73
77
  });
74
78
 
75
79
  if (res.code !== 0) {
@@ -118,7 +122,14 @@ function sanitizeListSourceSuffix(source: string): string {
118
122
  }
119
123
 
120
124
  function normalizeSourceIdentity(source: string): string {
121
- return sanitizeListSourceSuffix(source).replace(/\\/g, "/").toLowerCase();
125
+ const sanitized = sanitizeListSourceSuffix(source);
126
+ const kind = getPackageSourceKind(sanitized);
127
+
128
+ if (kind === "local") {
129
+ return normalizeLocalSourceIdentity(sanitized);
130
+ }
131
+
132
+ return sanitized.replace(/\\/g, "/").toLowerCase();
122
133
  }
123
134
 
124
135
  function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
@@ -375,9 +386,8 @@ async function fetchPackageSize(
375
386
 
376
387
  try {
377
388
  // Try to get unpacked size from npm view
378
- const res = await pi.exec("npm", ["view", pkgName, "dist.unpackedSize", "--json"], {
389
+ const res = await execNpm(pi, ["view", pkgName, "dist.unpackedSize", "--json"], ctx, {
379
390
  timeout: TIMEOUTS.npmView,
380
- cwd: ctx.cwd,
381
391
  });
382
392
  if (res.code === 0) {
383
393
  try {
@@ -441,9 +451,8 @@ async function addPackageMetadata(
441
451
  if (cached?.description) {
442
452
  pkg.description = cached.description;
443
453
  } else {
444
- const res = await pi.exec("npm", ["view", pkgName, "description", "--json"], {
454
+ const res = await execNpm(pi, ["view", pkgName, "description", "--json"], ctx, {
445
455
  timeout: TIMEOUTS.npmView,
446
- cwd: ctx.cwd,
447
456
  });
448
457
  if (res.code === 0) {
449
458
  try {
@@ -16,6 +16,7 @@ import { notify, error as notifyError, success } from "../utils/notify.js";
16
16
  import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
17
17
  import { tryOperation } from "../utils/mode.js";
18
18
  import { updateExtmgrStatus } from "../utils/status.js";
19
+ import { execNpm } from "../utils/npm-exec.js";
19
20
  import { TIMEOUTS } from "../constants.js";
20
21
 
21
22
  export type InstallScope = "global" | "project";
@@ -291,9 +292,8 @@ export async function installPackageLocally(
291
292
  await mkdir(extensionDir, { recursive: true });
292
293
  showProgress(ctx, "Fetching", packageName);
293
294
 
294
- const viewRes = await pi.exec("npm", ["view", packageName, "--json"], {
295
+ const viewRes = await execNpm(pi, ["view", packageName, "--json"], ctx, {
295
296
  timeout: TIMEOUTS.fetchPackageInfo,
296
- cwd: ctx.cwd,
297
297
  });
298
298
 
299
299
  if (viewRes.code !== 0) {
@@ -11,7 +11,11 @@ import {
11
11
  } from "./discovery.js";
12
12
  import { waitForCondition } from "../utils/retry.js";
13
13
  import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
14
- import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
14
+ import {
15
+ getPackageSourceKind,
16
+ normalizeLocalSourceIdentity,
17
+ splitGitRepoAndRef,
18
+ } from "../utils/package-source.js";
15
19
  import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
16
20
  import { clearUpdatesAvailable } from "../utils/settings.js";
17
21
  import { notify, error as notifyError, success } from "../utils/notify.js";
@@ -33,12 +37,19 @@ const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
33
37
  reloaded: false,
34
38
  };
35
39
 
40
+ const BULK_UPDATE_LABEL = "all packages";
41
+
36
42
  function packageMutationOutcome(
37
43
  overrides: Partial<PackageMutationOutcome>
38
44
  ): PackageMutationOutcome {
39
45
  return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
40
46
  }
41
47
 
48
+ function isUpToDateOutput(stdout: string): boolean {
49
+ const pinnedAsStatus = /^\s*pinned\b(?!\s+dependency\b)(?:\s*$|\s*[:(-])/im.test(stdout);
50
+ return /already\s+up\s+to\s+date/i.test(stdout) || pinnedAsStatus;
51
+ }
52
+
42
53
  async function updatePackageInternal(
43
54
  source: string,
44
55
  ctx: ExtensionCommandContext,
@@ -60,9 +71,10 @@ async function updatePackageInternal(
60
71
  }
61
72
 
62
73
  const stdout = res.stdout || "";
63
- if (stdout.includes("already up to date") || stdout.includes("pinned")) {
74
+ if (isUpToDateOutput(stdout)) {
64
75
  notify(ctx, `${source} is already up to date (or pinned).`, "info");
65
76
  logPackageUpdate(pi, source, source, undefined, true);
77
+ clearUpdatesAvailable(pi, ctx);
66
78
  void updateExtmgrStatus(ctx, pi);
67
79
  return NO_PACKAGE_MUTATION_OUTCOME;
68
80
  }
@@ -87,18 +99,23 @@ async function updatePackagesInternal(
87
99
  const res = await pi.exec("pi", ["update"], { timeout: TIMEOUTS.packageUpdateAll, cwd: ctx.cwd });
88
100
 
89
101
  if (res.code !== 0) {
90
- notifyError(ctx, `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
102
+ const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
103
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg);
104
+ notifyError(ctx, errorMsg);
91
105
  void updateExtmgrStatus(ctx, pi);
92
106
  return NO_PACKAGE_MUTATION_OUTCOME;
93
107
  }
94
108
 
95
109
  const stdout = res.stdout || "";
96
- if (stdout.includes("already up to date") || stdout.trim() === "") {
110
+ if (isUpToDateOutput(stdout) || stdout.trim() === "") {
97
111
  notify(ctx, "All packages are already up to date.", "info");
112
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
113
+ clearUpdatesAvailable(pi, ctx);
98
114
  void updateExtmgrStatus(ctx, pi);
99
115
  return NO_PACKAGE_MUTATION_OUTCOME;
100
116
  }
101
117
 
118
+ logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
102
119
  success(ctx, "Packages updated");
103
120
  clearUpdatesAvailable(pi, ctx);
104
121
 
@@ -145,12 +162,18 @@ function packageIdentity(source: string, fallbackName?: string): string {
145
162
  return `npm:${npm.name}`;
146
163
  }
147
164
 
148
- if (getPackageSourceKind(source) === "git") {
165
+ const sourceKind = getPackageSourceKind(source);
166
+
167
+ if (sourceKind === "git") {
149
168
  const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
150
169
  const { repo } = splitGitRepoAndRef(gitSpec);
151
170
  return `git:${repo}`;
152
171
  }
153
172
 
173
+ if (sourceKind === "local") {
174
+ return `src:${normalizeLocalSourceIdentity(source)}`;
175
+ }
176
+
154
177
  if (fallbackName) {
155
178
  return `name:${fallbackName}`;
156
179
  }
@@ -238,12 +261,18 @@ function formatRemovalTargets(targets: RemovalTarget[]): string {
238
261
  return targets.map((t) => `${t.scope}: ${t.source}`).join("\n");
239
262
  }
240
263
 
264
+ interface RemovalExecutionResult {
265
+ target: RemovalTarget;
266
+ success: boolean;
267
+ error?: string;
268
+ }
269
+
241
270
  async function executeRemovalTargets(
242
271
  targets: RemovalTarget[],
243
272
  ctx: ExtensionCommandContext,
244
273
  pi: ExtensionAPI
245
- ): Promise<string[]> {
246
- const failures: string[] = [];
274
+ ): Promise<RemovalExecutionResult[]> {
275
+ const results: RemovalExecutionResult[] = [];
247
276
 
248
277
  for (const target of targets) {
249
278
  showProgress(ctx, "Removing", `${target.source} (${target.scope})`);
@@ -254,14 +283,15 @@ async function executeRemovalTargets(
254
283
  if (res.code !== 0) {
255
284
  const errorMsg = `Remove failed (${target.scope}): ${res.stderr || res.stdout || `exit ${res.code}`}`;
256
285
  logPackageRemove(pi, target.source, target.name, false, errorMsg);
257
- failures.push(errorMsg);
286
+ results.push({ target, success: false, error: errorMsg });
258
287
  continue;
259
288
  }
260
289
 
261
290
  logPackageRemove(pi, target.source, target.name, true);
291
+ results.push({ target, success: true });
262
292
  }
263
293
 
264
- return failures;
294
+ return results;
265
295
  }
266
296
 
267
297
  function notifyRemovalSummary(
@@ -326,9 +356,18 @@ async function removePackageInternal(
326
356
  return NO_PACKAGE_MUTATION_OUTCOME;
327
357
  }
328
358
 
329
- const failures = await executeRemovalTargets(targets, ctx, pi);
359
+ const results = await executeRemovalTargets(targets, ctx, pi);
330
360
  clearSearchCache();
331
361
 
362
+ const failures = results
363
+ .filter((result): result is RemovalExecutionResult & { success: false; error: string } =>
364
+ Boolean(!result.success && result.error)
365
+ )
366
+ .map((result) => result.error);
367
+ const successfulTargets = results
368
+ .filter((result) => result.success)
369
+ .map((result) => result.target);
370
+
332
371
  const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
333
372
  (p) => packageIdentity(p.source, p.name) === identity
334
373
  );
@@ -338,13 +377,15 @@ async function removePackageInternal(
338
377
  clearUpdatesAvailable(pi, ctx);
339
378
  }
340
379
 
341
- // Wait for selected targets to disappear from their target scopes before reloading.
342
- if (failures.length === 0 && targets.length > 0) {
380
+ const successfulRemovalCount = successfulTargets.length;
381
+
382
+ // Wait for successfully removed targets to disappear from their target scopes before reloading.
383
+ if (successfulTargets.length > 0) {
343
384
  notify(ctx, "Waiting for removal to complete...", "info");
344
385
  const isRemoved = await waitForCondition(
345
386
  async () => {
346
387
  const installedChecks = await Promise.all(
347
- targets.map((target) =>
388
+ successfulTargets.map((target) =>
348
389
  isSourceInstalled(target.source, ctx, pi, {
349
390
  scope: target.scope,
350
391
  })
@@ -360,6 +401,11 @@ async function removePackageInternal(
360
401
  }
361
402
  }
362
403
 
404
+ if (successfulRemovalCount === 0) {
405
+ void updateExtmgrStatus(ctx, pi);
406
+ return NO_PACKAGE_MUTATION_OUTCOME;
407
+ }
408
+
363
409
  const reloaded = await confirmReload(ctx, "Removal complete.");
364
410
  if (!reloaded) {
365
411
  void updateExtmgrStatus(ctx, pi);
package/src/ui/remote.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isCacheValid,
16
16
  } from "../packages/discovery.js";
17
17
  import { installPackage, installPackageLocally } from "../packages/install.js";
18
+ import { execNpm } from "../utils/npm-exec.js";
18
19
  import { notify } from "../utils/notify.js";
19
20
 
20
21
  interface PackageInfoCacheEntry {
@@ -143,9 +144,8 @@ async function buildPackageInfoText(
143
144
  }
144
145
 
145
146
  const [infoRes, weeklyDownloads] = await Promise.all([
146
- pi.exec("npm", ["view", packageName, "--json"], {
147
+ execNpm(pi, ["view", packageName, "--json"], ctx, {
147
148
  timeout: TIMEOUTS.npmView,
148
- cwd: ctx.cwd,
149
149
  }),
150
150
  fetchWeeklyDownloads(packageName),
151
151
  ]);
package/src/ui/unified.ts CHANGED
@@ -263,7 +263,11 @@ async function showInteractiveOnce(
263
263
  }
264
264
 
265
265
  function normalizePathForDuplicateCheck(value: string): string {
266
- return value.replace(/\\/g, "/").toLowerCase();
266
+ const normalized = value.replace(/\\/g, "/");
267
+ const looksWindowsPath =
268
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");
269
+
270
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
267
271
  }
268
272
 
269
273
  export function buildUnifiedItems(
@@ -18,6 +18,7 @@ import {
18
18
  type AutoUpdateConfig,
19
19
  } from "./settings.js";
20
20
  import { parseNpmSource } from "./format.js";
21
+ import { execNpm } from "./npm-exec.js";
21
22
  import { TIMEOUTS } from "../constants.js";
22
23
 
23
24
  import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
@@ -134,9 +135,8 @@ async function checkPackageUpdate(
134
135
  if (!pkgName) return false;
135
136
 
136
137
  try {
137
- const res = await pi.exec("npm", ["view", pkgName, "version", "--json"], {
138
+ const res = await execNpm(pi, ["view", pkgName, "version", "--json"], ctx, {
138
139
  timeout: TIMEOUTS.npmView,
139
- cwd: ctx.cwd,
140
140
  });
141
141
 
142
142
  if (res.code !== 0) return false;
@@ -3,7 +3,83 @@
3
3
  */
4
4
 
5
5
  export function tokenizeArgs(input: string): string[] {
6
- return input.trim().split(/\s+/).filter(Boolean);
6
+ const tokens: string[] = [];
7
+ let current = "";
8
+ let inSingleQuote = false;
9
+ let inDoubleQuote = false;
10
+ let tokenStarted = false;
11
+
12
+ const pushCurrent = () => {
13
+ if (tokenStarted) {
14
+ tokens.push(current);
15
+ current = "";
16
+ tokenStarted = false;
17
+ }
18
+ };
19
+
20
+ for (let i = 0; i < input.length; i++) {
21
+ const char = input[i]!;
22
+ const next = input[i + 1];
23
+
24
+ if (inSingleQuote) {
25
+ if (char === "'") {
26
+ inSingleQuote = false;
27
+ } else {
28
+ current += char;
29
+ }
30
+ continue;
31
+ }
32
+
33
+ if (inDoubleQuote) {
34
+ if (char === '"') {
35
+ inDoubleQuote = false;
36
+ continue;
37
+ }
38
+
39
+ if (char === "\\" && next === '"') {
40
+ current += next;
41
+ i++;
42
+ continue;
43
+ }
44
+
45
+ current += char;
46
+ continue;
47
+ }
48
+
49
+ if (/\s/.test(char)) {
50
+ pushCurrent();
51
+ continue;
52
+ }
53
+
54
+ if (char === "'") {
55
+ inSingleQuote = true;
56
+ tokenStarted = true;
57
+ continue;
58
+ }
59
+
60
+ if (char === '"') {
61
+ inDoubleQuote = true;
62
+ tokenStarted = true;
63
+ continue;
64
+ }
65
+
66
+ if (char === "\\" && (next === '"' || next === "'" || /\s/.test(next ?? ""))) {
67
+ tokenStarted = true;
68
+ if (next) {
69
+ current += next;
70
+ i++;
71
+ } else {
72
+ current += char;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ tokenStarted = true;
78
+ current += char;
79
+ }
80
+
81
+ pushCurrent();
82
+ return tokens;
7
83
  }
8
84
 
9
85
  export function splitCommandArgs(input: string): { subcommand: string; args: string[] } {
@@ -59,6 +59,9 @@ export function formatBytes(bytes: number): string {
59
59
 
60
60
  const GIT_PATTERNS = {
61
61
  gitPrefix: /^git:/,
62
+ gitPlusHttpPrefix: /^git\+https?:\/\//,
63
+ gitPlusSshPrefix: /^git\+ssh:\/\//,
64
+ gitPlusGitPrefix: /^git\+git:\/\//,
62
65
  httpPrefix: /^https?:\/\//,
63
66
  sshPrefix: /^ssh:\/\//,
64
67
  gitProtoPrefix: /^git:\/\//,
@@ -78,6 +81,9 @@ const LOCAL_PATH_PATTERNS = {
78
81
  function isGitLikeSource(source: string): boolean {
79
82
  return (
80
83
  GIT_PATTERNS.gitPrefix.test(source) ||
84
+ GIT_PATTERNS.gitPlusHttpPrefix.test(source) ||
85
+ GIT_PATTERNS.gitPlusSshPrefix.test(source) ||
86
+ GIT_PATTERNS.gitPlusGitPrefix.test(source) ||
81
87
  GIT_PATTERNS.httpPrefix.test(source) ||
82
88
  GIT_PATTERNS.sshPrefix.test(source) ||
83
89
  GIT_PATTERNS.gitProtoPrefix.test(source) ||
@@ -97,22 +103,36 @@ function isLocalPathSource(source: string): boolean {
97
103
  );
98
104
  }
99
105
 
106
+ function unwrapQuotedSource(source: string): string {
107
+ const trimmed = source.trim();
108
+ if (trimmed.length < 2) return trimmed;
109
+
110
+ const first = trimmed[0];
111
+ const last = trimmed[trimmed.length - 1];
112
+
113
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
114
+ return trimmed.slice(1, -1).trim();
115
+ }
116
+
117
+ return trimmed;
118
+ }
119
+
100
120
  export function isPackageSource(str: string): boolean {
101
- const source = str.trim();
121
+ const source = unwrapQuotedSource(str);
102
122
  if (!source) return false;
103
123
 
104
124
  return source.startsWith("npm:") || isGitLikeSource(source) || isLocalPathSource(source);
105
125
  }
106
126
 
107
127
  export function normalizePackageSource(source: string): string {
108
- const trimmed = source.trim();
128
+ const trimmed = unwrapQuotedSource(source);
109
129
  if (!trimmed) return trimmed;
110
130
 
111
131
  if (GIT_PATTERNS.gitSsh.test(trimmed)) {
112
132
  return `git:${trimmed}`;
113
133
  }
114
134
 
115
- if (isPackageSource(trimmed)) {
135
+ if (trimmed.startsWith("npm:") || isGitLikeSource(trimmed) || isLocalPathSource(trimmed)) {
116
136
  return trimmed;
117
137
  }
118
138
 
@@ -0,0 +1,47 @@
1
+ import path from "node:path";
2
+ import { execPath, platform } from "node:process";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ interface NpmCommandResolutionOptions {
6
+ platform?: NodeJS.Platform;
7
+ nodeExecPath?: string;
8
+ }
9
+
10
+ interface NpmExecOptions {
11
+ timeout: number;
12
+ }
13
+
14
+ function getNpmCliPath(nodeExecPath: string, runtimePlatform: NodeJS.Platform): string {
15
+ const pathImpl = runtimePlatform === "win32" ? path.win32 : path;
16
+ return pathImpl.join(pathImpl.dirname(nodeExecPath), "node_modules", "npm", "bin", "npm-cli.js");
17
+ }
18
+
19
+ export function resolveNpmCommand(
20
+ npmArgs: string[],
21
+ options?: NpmCommandResolutionOptions
22
+ ): { command: string; args: string[] } {
23
+ const runtimePlatform = options?.platform ?? platform;
24
+
25
+ if (runtimePlatform === "win32") {
26
+ const nodeBinary = options?.nodeExecPath ?? execPath;
27
+ return {
28
+ command: nodeBinary,
29
+ args: [getNpmCliPath(nodeBinary, runtimePlatform), ...npmArgs],
30
+ };
31
+ }
32
+
33
+ return { command: "npm", args: npmArgs };
34
+ }
35
+
36
+ export async function execNpm(
37
+ pi: ExtensionAPI,
38
+ npmArgs: string[],
39
+ ctx: { cwd: string },
40
+ options: NpmExecOptions
41
+ ): Promise<{ code: number; stdout: string; stderr: string; killed: boolean }> {
42
+ const resolved = resolveNpmCommand(npmArgs);
43
+ return pi.exec(resolved.command, resolved.args, {
44
+ timeout: options.timeout,
45
+ cwd: ctx.cwd,
46
+ });
47
+ }
@@ -18,6 +18,10 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
18
18
 
19
19
  if (
20
20
  normalized.startsWith("git:") ||
21
+ normalized.startsWith("git+http://") ||
22
+ normalized.startsWith("git+https://") ||
23
+ normalized.startsWith("git+ssh://") ||
24
+ normalized.startsWith("git+git://") ||
21
25
  normalized.startsWith("http://") ||
22
26
  normalized.startsWith("https://") ||
23
27
  normalized.startsWith("ssh://") ||
@@ -43,6 +47,14 @@ export function getPackageSourceKind(source: string): PackageSourceKind {
43
47
  return "unknown";
44
48
  }
45
49
 
50
+ export function normalizeLocalSourceIdentity(source: string): string {
51
+ const normalized = source.replace(/\\/g, "/");
52
+ const looksWindowsPath =
53
+ /^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || source.includes("\\");
54
+
55
+ return looksWindowsPath ? normalized.toLowerCase() : normalized;
56
+ }
57
+
46
58
  export function splitGitRepoAndRef(gitSpec: string): { repo: string; ref?: string | undefined } {
47
59
  const lastAt = gitSpec.lastIndexOf("@");
48
60
  if (lastAt <= 0) {