@hubspot/project-parsing-lib 0.16.0-beta.0 → 0.17.0-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/project-parsing-lib",
3
- "version": "0.16.0-beta.0",
3
+ "version": "0.17.0-beta.0",
4
4
  "description": "Parsing library for converting projects directory structures to their intermediate representation",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -111,6 +111,7 @@
111
111
  "@inquirer/prompts": "^7.1.0",
112
112
  "@types/node": "^24.9.0",
113
113
  "@types/npm-packlist": "7.0.3",
114
+ "@types/npmcli__map-workspaces": "3.0.4",
114
115
  "@types/semver": "^7.5.8",
115
116
  "@typescript-eslint/eslint-plugin": "8.54.0",
116
117
  "@typescript-eslint/parser": "8.54.0",
@@ -126,11 +127,15 @@
126
127
  },
127
128
  "dependencies": {
128
129
  "@hubspot/local-dev-lib": "4.0.4",
130
+ "@npmcli/map-workspaces": "4.0.2",
129
131
  "ajv": "8.18.0",
130
132
  "ajv-formats": "3.0.1",
131
133
  "glob": "13.0.0",
132
134
  "npm-packlist": "8.0.1"
133
135
  },
136
+ "resolutions": {
137
+ "ajv-formats/ajv": "8.18.0"
138
+ },
134
139
  "scripts": {
135
140
  "build": "tsx ./scripts/build.ts",
136
141
  "lint": "echo 'Linting is disabled for Blazar'",
@@ -1,2 +1,2 @@
1
- export { findAndParsePackageJsonFiles, resolveWorkspaceDirectories, collectWorkspaceDirectories, collectFileDependencies, getPackableFiles, WorkspaceResolutionError, FileDependencyResolutionError, } from '../lib/workspaces.js';
2
- export type { ParsedPackageJson, WorkspaceMapping, FileDependencyMapping, } from '../lib/workspaces.js';
1
+ export { findAndParsePackageJsonFiles, resolveWorkspaceDirectories, collectWorkspaceDirectories, collectFileDependencies, validateWorkspaceProtocolDependencies, parseLocalSpec, getPackableFiles, WorkspaceResolutionError, FileDependencyResolutionError, OrphanWorkspaceProtocolError, } from '../lib/workspaces.js';
2
+ export type { ParsedPackageJson, WorkspaceMapping, FileDependencyMapping, FileDependencyKind, LocalDependencyProtocol, } from '../lib/workspaces.js';
@@ -1 +1 @@
1
- export { findAndParsePackageJsonFiles, resolveWorkspaceDirectories, collectWorkspaceDirectories, collectFileDependencies, getPackableFiles, WorkspaceResolutionError, FileDependencyResolutionError, } from '../lib/workspaces.js';
1
+ export { findAndParsePackageJsonFiles, resolveWorkspaceDirectories, collectWorkspaceDirectories, collectFileDependencies, validateWorkspaceProtocolDependencies, parseLocalSpec, getPackableFiles, WorkspaceResolutionError, FileDependencyResolutionError, OrphanWorkspaceProtocolError, } from '../lib/workspaces.js';
@@ -51,4 +51,7 @@ export declare const logMessages: {
51
51
  files: {
52
52
  skippingPath: (path: string) => string;
53
53
  };
54
+ workspaces: {
55
+ cannotAccessDirectory: (dir: string, e: unknown) => string;
56
+ };
54
57
  };
package/src/lang/copy.js CHANGED
@@ -66,4 +66,7 @@ export const logMessages = {
66
66
  files: {
67
67
  skippingPath: (path) => `Skipping ${path} as it is not in a valid directory`,
68
68
  },
69
+ workspaces: {
70
+ cannotAccessDirectory: (dir, e) => `Cannot access directory ${dir}: ${e}`,
71
+ },
69
72
  };
@@ -6,11 +6,15 @@ export function isSupportedPlatformVersion(platformVersion) {
6
6
  if (!platformVersion || typeof platformVersion !== 'string') {
7
7
  return false;
8
8
  }
9
+ if (process.env.HUBSPOT_PROJECT_INTERNAL_TEST === 'true')
10
+ return true;
9
11
  const supportedPlatformVersions = Object.values(PLATFORM_VERSIONS);
10
12
  return supportedPlatformVersions.includes(platformVersion);
11
13
  }
12
14
  export function isLegacyProject(platformVersion) {
13
15
  if (!platformVersion)
14
16
  return false;
17
+ if (process.env.HUBSPOT_PROJECT_INTERNAL_TEST === 'true')
18
+ return false;
15
19
  return [PLATFORM_VERSIONS.v2023_2, PLATFORM_VERSIONS.v2025_1].includes(platformVersion);
16
20
  }
@@ -15,6 +15,17 @@ export declare class FileDependencyResolutionError extends Error {
15
15
  sourcePackageJson: string;
16
16
  constructor(packageName: string, dependencyPath: string, sourcePackageJson: string, cause?: Error);
17
17
  }
18
+ /**
19
+ * Error thrown when a `workspace:` protocol dependency does not match any known
20
+ * workspace member. npm itself does not support the `workspace:` protocol —
21
+ * only pnpm and yarn berry do — so the install fails with a confusing message
22
+ * server-side. Surface a clearer error up front.
23
+ */
24
+ export declare class OrphanWorkspaceProtocolError extends Error {
25
+ packageName: string;
26
+ sourcePackageJson: string;
27
+ constructor(packageName: string, sourcePackageJson: string);
28
+ }
18
29
  interface PackageJson {
19
30
  name?: string;
20
31
  workspaces?: string[] | {
@@ -32,18 +43,40 @@ export interface WorkspaceMapping {
32
43
  workspaceDir: string;
33
44
  sourcePackageJsonPath: string;
34
45
  }
46
+ export type FileDependencyKind = 'directory' | 'tarball';
47
+ export type LocalDependencyProtocol = 'file' | 'link';
35
48
  export interface FileDependencyMapping {
36
49
  packageName: string;
37
50
  localPath: string;
38
51
  sourcePackageJsonPath: string;
52
+ kind: FileDependencyKind;
53
+ protocol: LocalDependencyProtocol;
39
54
  }
55
+ /**
56
+ * Parses a `file:` or `link:` dependency spec into its protocol and raw path.
57
+ * Handles all npm-supported shapes:
58
+ * - file:./relative/path
59
+ * - file:relative/path (no leading slash/dot)
60
+ * - file:/absolute/path
61
+ * - file://./relative — npm-style file URL with relative path
62
+ * - file:///absolute — file URL with absolute path
63
+ * - link:./relative/path (symlink semantics for directories only)
64
+ */
65
+ export declare function parseLocalSpec(spec: string): {
66
+ protocol: LocalDependencyProtocol;
67
+ rawPath: string;
68
+ } | null;
40
69
  /**
41
70
  * Finds and parses all package.json files in a directory.
42
71
  * This is the single entry point for discovering package.json files and parsing their contents.
43
72
  */
44
73
  export declare function findAndParsePackageJsonFiles(srcDir: string): Promise<ParsedPackageJson[]>;
45
74
  /**
46
- * Resolves workspace glob patterns to actual directories
75
+ * Resolves workspace glob patterns to actual directories.
76
+ *
77
+ * Uses @npmcli/map-workspaces (npm's reference impl) so that negation patterns
78
+ * like `["packages/*", "!packages/excluded"]` are honored — a hand-rolled
79
+ * per-pattern glob() silently accepted negations without actually excluding.
47
80
  */
48
81
  export declare function resolveWorkspaceDirectories(baseDir: string, workspaceGlobs: string[]): Promise<string[]>;
49
82
  /**
@@ -53,10 +86,22 @@ export declare function resolveWorkspaceDirectories(baseDir: string, workspaceGl
53
86
  */
54
87
  export declare function collectWorkspaceDirectories(parsedPackageJsons: ParsedPackageJson[]): Promise<WorkspaceMapping[]>;
55
88
  /**
56
- * Collects all file: dependencies that need to be uploaded.
57
- * Returns mappings that track the package name, resolved path, and source package.json.
89
+ * Collects all `file:` and `link:` dependencies that need to be uploaded.
90
+ * Returns mappings that track the package name, resolved path, source
91
+ * package.json, kind (directory vs tarball) and protocol (file vs link).
58
92
  */
59
93
  export declare function collectFileDependencies(parsedPackageJsons: ParsedPackageJson[]): Promise<FileDependencyMapping[]>;
94
+ /**
95
+ * Validates that every `workspace:` protocol dependency in the parsed package
96
+ * trees matches a known workspace member. Throws OrphanWorkspaceProtocolError
97
+ * for the first orphan found.
98
+ *
99
+ * npm itself does not implement the `workspace:` protocol; only pnpm and yarn
100
+ * berry do. If a project uses `workspace:*` without a matching member declared
101
+ * under `workspaces`, the install fails server-side with a confusing message —
102
+ * fail fast with a clearer one.
103
+ */
104
+ export declare function validateWorkspaceProtocolDependencies(parsedPackageJsons: ParsedPackageJson[], workspaceMappings: WorkspaceMapping[]): void;
60
105
  /**
61
106
  * Returns the set of files that npm would include when publishing a package.
62
107
  * Uses npm-packlist which respects the "files" field in package.json,
@@ -2,11 +2,13 @@ import fs from 'fs';
2
2
  import fsPromises from 'fs/promises';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
- import { glob } from 'glob';
5
+ import { fileURLToPath } from 'node:url';
6
+ import mapWorkspaces from '@npmcli/map-workspaces';
6
7
  import packlist from 'npm-packlist';
7
8
  import { logger } from '@hubspot/local-dev-lib/logger';
8
9
  import { walk } from '@hubspot/local-dev-lib/fs';
9
10
  import { createMinimalTree } from './minimalArboristTree.js';
11
+ import { logMessages } from '../lang/copy.js';
10
12
  /**
11
13
  * Error thrown when a workspace directory cannot be resolved
12
14
  */
@@ -41,6 +43,28 @@ export class FileDependencyResolutionError extends Error {
41
43
  }
42
44
  }
43
45
  }
46
+ /**
47
+ * Error thrown when a `workspace:` protocol dependency does not match any known
48
+ * workspace member. npm itself does not support the `workspace:` protocol —
49
+ * only pnpm and yarn berry do — so the install fails with a confusing message
50
+ * server-side. Surface a clearer error up front.
51
+ */
52
+ export class OrphanWorkspaceProtocolError extends Error {
53
+ packageName;
54
+ sourcePackageJson;
55
+ constructor(packageName, sourcePackageJson) {
56
+ super(`Found \`workspace:\` protocol dependency "${packageName}" in ${sourcePackageJson} but no workspace member is named "${packageName}". ` +
57
+ `The \`workspace:\` protocol requires a matching workspace member declared under "workspaces" in a parent package.json. ` +
58
+ `If you're migrating from pnpm or yarn berry, replace with \`file:./<relative-path>\` or \`link:./<relative-path>\`, or add the package under "workspaces".`);
59
+ this.packageName = packageName;
60
+ this.sourcePackageJson = sourcePackageJson;
61
+ this.name = 'OrphanWorkspaceProtocolError';
62
+ }
63
+ }
64
+ function hasTarballExtension(p) {
65
+ const { ext, name } = path.parse(p.toLowerCase());
66
+ return (ext === '.tgz' || ext === '.tar' || (ext === '.gz' && name.endsWith('.tar')));
67
+ }
44
68
  /**
45
69
  * Expands tilde (~) in paths to the user's home directory
46
70
  */
@@ -49,6 +73,33 @@ function expandTildePath(filePath) {
49
73
  ? path.join(os.homedir(), filePath.slice(1))
50
74
  : filePath;
51
75
  }
76
+ /**
77
+ * Parses a `file:` or `link:` dependency spec into its protocol and raw path.
78
+ * Handles all npm-supported shapes:
79
+ * - file:./relative/path
80
+ * - file:relative/path (no leading slash/dot)
81
+ * - file:/absolute/path
82
+ * - file://./relative — npm-style file URL with relative path
83
+ * - file:///absolute — file URL with absolute path
84
+ * - link:./relative/path (symlink semantics for directories only)
85
+ */
86
+ export function parseLocalSpec(spec) {
87
+ const match = /^(file|link):(.*)$/.exec(spec);
88
+ if (!match) {
89
+ return null;
90
+ }
91
+ const protocol = match[1];
92
+ let rawPath = match[2];
93
+ if (rawPath.startsWith('//')) {
94
+ try {
95
+ rawPath = fileURLToPath(`file:${rawPath}`);
96
+ }
97
+ catch {
98
+ return { protocol, rawPath };
99
+ }
100
+ }
101
+ return { protocol, rawPath };
102
+ }
52
103
  /**
53
104
  * Finds and parses all package.json files in a directory.
54
105
  * This is the single entry point for discovering package.json files and parsing their contents.
@@ -82,56 +133,58 @@ function extractWorkspaceGlobs(packageJson) {
82
133
  if (!workspaces) {
83
134
  return [];
84
135
  }
85
- // Handle array format: "workspaces": ["packages/*"]
86
136
  if (Array.isArray(workspaces)) {
87
137
  return workspaces;
88
138
  }
89
- // Handle object format: "workspaces": {"packages": ["packages/*"]}
90
139
  if (workspaces.packages && Array.isArray(workspaces.packages)) {
91
140
  return workspaces.packages;
92
141
  }
93
142
  return [];
94
143
  }
95
144
  /**
96
- * Resolves workspace glob patterns to actual directories
145
+ * Resolves workspace glob patterns to actual directories.
146
+ *
147
+ * Uses @npmcli/map-workspaces (npm's reference impl) so that negation patterns
148
+ * like `["packages/*", "!packages/excluded"]` are honored — a hand-rolled
149
+ * per-pattern glob() silently accepted negations without actually excluding.
97
150
  */
98
151
  export async function resolveWorkspaceDirectories(baseDir, workspaceGlobs) {
99
- const resolvedDirs = new Set();
100
- for (const pattern of workspaceGlobs) {
101
- const expandedPattern = expandTildePath(pattern);
102
- const absolutePattern = path.resolve(baseDir, expandedPattern);
152
+ if (workspaceGlobs.length === 0) {
153
+ return [];
154
+ }
155
+ const expandedGlobs = workspaceGlobs.map(expandTildePath);
156
+ let workspaceMap;
157
+ try {
158
+ workspaceMap = await mapWorkspaces({
159
+ pkg: { workspaces: expandedGlobs },
160
+ cwd: baseDir,
161
+ });
162
+ }
163
+ catch (e) {
164
+ logger.debug(`Failed to resolve workspace patterns in ${baseDir} via map-workspaces: ${e}`);
165
+ return [];
166
+ }
167
+ const resolved = new Set();
168
+ for (const dir of workspaceMap.values()) {
103
169
  try {
104
- // Use glob to find matching directories
105
- const matches = await glob(absolutePattern, {
106
- absolute: true,
107
- withFileTypes: false,
108
- });
109
- // Filter to directories that contain package.json
110
- for (const match of matches) {
111
- try {
112
- const stats = await fsPromises.stat(match);
113
- if (stats.isDirectory()) {
114
- const packageJsonPath = path.join(match, 'package.json');
115
- try {
116
- await fsPromises.access(packageJsonPath, fs.constants.F_OK);
117
- resolvedDirs.add(match);
118
- }
119
- catch {
120
- // Directory exists but doesn't contain package.json - skip silently
121
- }
122
- }
123
- }
124
- catch (e) {
125
- // Inaccessible directories may indicate permission issues or broken symlinks
126
- logger.warn(`Cannot access directory ${match}: ${e}`);
127
- }
170
+ const stats = await fsPromises.stat(dir);
171
+ if (!stats.isDirectory()) {
172
+ continue;
173
+ }
174
+ const packageJsonPath = path.join(dir, 'package.json');
175
+ try {
176
+ await fsPromises.access(packageJsonPath, fs.constants.F_OK);
177
+ resolved.add(dir);
178
+ }
179
+ catch {
180
+ // Directory exists but doesn't contain package.json — skip silently.
128
181
  }
129
182
  }
130
183
  catch (e) {
131
- logger.debug(`Failed to resolve workspace pattern "${pattern}": ${e}`);
184
+ logger.warn(logMessages.workspaces.cannotAccessDirectory(dir, e));
132
185
  }
133
186
  }
134
- return Array.from(resolvedDirs);
187
+ return Array.from(resolved);
135
188
  }
136
189
  /**
137
190
  * Collects all workspace directories that need to be uploaded.
@@ -142,9 +195,8 @@ export async function collectWorkspaceDirectories(parsedPackageJsons) {
142
195
  const workspaceMappings = [];
143
196
  // Track (sourcePackageJsonPath, workspaceDir) pairs to avoid duplicates within
144
197
  // the same source, while still allowing the same workspace dir to appear in
145
- // mappings from multiple different package.json files. This ensures every
146
- // package.json that declares a workspace gets its workspaces field rewritten,
147
- // regardless of whether another package.json already references the same dir.
198
+ // mappings from multiple different package.json files. Both keys use realpath
199
+ // form so case-only differences on case-insensitive filesystems collapse.
148
200
  const visited = new Set();
149
201
  for (const parsed of parsedPackageJsons) {
150
202
  if (!parsed.content) {
@@ -156,8 +208,14 @@ export async function collectWorkspaceDirectories(parsedPackageJsons) {
156
208
  }
157
209
  logger.debug(`Found workspaces in ${parsed.path}: ${workspaceGlobs.join(', ')}`);
158
210
  const resolved = await resolveWorkspaceDirectories(parsed.dir, workspaceGlobs);
211
+ let sourceRealPath;
212
+ try {
213
+ sourceRealPath = await fsPromises.realpath(parsed.path);
214
+ }
215
+ catch {
216
+ sourceRealPath = parsed.path;
217
+ }
159
218
  for (const dir of resolved) {
160
- // Resolve symlinks to real path
161
219
  let realDir;
162
220
  try {
163
221
  realDir = await fsPromises.realpath(dir);
@@ -165,19 +223,12 @@ export async function collectWorkspaceDirectories(parsedPackageJsons) {
165
223
  catch (e) {
166
224
  throw new WorkspaceResolutionError(dir, parsed.path, e instanceof Error ? e : undefined);
167
225
  }
168
- // Skip duplicate (source, workspace) pairs only — the same workspace dir
169
- // may legitimately be referenced by multiple package.json files and each
170
- // needs its own mapping so its workspaces field is rewritten correctly.
171
- const pairKey = `${parsed.path}::${realDir}`;
226
+ const pairKey = `${sourceRealPath}::${realDir}`;
172
227
  if (visited.has(pairKey)) {
173
228
  logger.debug(`Skipping duplicate workspace mapping: ${realDir} from ${parsed.path}`);
174
229
  continue;
175
230
  }
176
231
  visited.add(pairKey);
177
- // Include all workspaces - both internal (inside srcDir) and external.
178
- // The CLI determines the archive path:
179
- // - Internal: preserved as-is (already included via srcDir walk)
180
- // - External: _workspaces/<name>-<hash>
181
232
  workspaceMappings.push({
182
233
  workspaceDir: realDir,
183
234
  sourcePackageJsonPath: parsed.path,
@@ -188,7 +239,7 @@ export async function collectWorkspaceDirectories(parsedPackageJsons) {
188
239
  return workspaceMappings;
189
240
  }
190
241
  /**
191
- * Extracts file: dependencies from parsed package.json content.
242
+ * Extracts file: and link: dependencies from parsed package.json content.
192
243
  * Only scans production dependencies since devDependencies and optionalDependencies
193
244
  * are not needed for the build pipeline.
194
245
  */
@@ -199,18 +250,24 @@ function extractFileDependencies(packageJson) {
199
250
  return fileDeps;
200
251
  }
201
252
  for (const [packageName, version] of Object.entries(deps)) {
202
- // Only handle file: dependencies. workspace: dependencies reference packages
203
- // already collected via the workspaces field, so they don't need separate handling.
204
- if (typeof version === 'string' && version.startsWith('file:')) {
205
- const filePath = version.slice(5); // Remove 'file:' prefix
206
- fileDeps.push({ packageName, filePath });
253
+ if (typeof version !== 'string') {
254
+ continue;
255
+ }
256
+ const parsed = parseLocalSpec(version);
257
+ if (parsed) {
258
+ fileDeps.push({
259
+ packageName,
260
+ protocol: parsed.protocol,
261
+ rawPath: parsed.rawPath,
262
+ });
207
263
  }
208
264
  }
209
265
  return fileDeps;
210
266
  }
211
267
  /**
212
- * Collects all file: dependencies that need to be uploaded.
213
- * Returns mappings that track the package name, resolved path, and source package.json.
268
+ * Collects all `file:` and `link:` dependencies that need to be uploaded.
269
+ * Returns mappings that track the package name, resolved path, source
270
+ * package.json, kind (directory vs tarball) and protocol (file vs link).
214
271
  */
215
272
  export async function collectFileDependencies(parsedPackageJsons) {
216
273
  const fileDependencyMappings = [];
@@ -223,11 +280,14 @@ export async function collectFileDependencies(parsedPackageJsons) {
223
280
  if (fileDeps.length === 0) {
224
281
  continue;
225
282
  }
226
- logger.debug(`Found file: dependencies in ${parsed.path}: ${fileDeps.map(d => d.packageName).join(', ')}`);
227
- for (const { packageName, filePath } of fileDeps) {
228
- const expandedPath = expandTildePath(filePath);
283
+ logger.debug(`Found file:/link: dependencies in ${parsed.path}: ${fileDeps.map(d => d.packageName).join(', ')}`);
284
+ for (const { packageName, protocol, rawPath } of fileDeps) {
285
+ const expandedPath = expandTildePath(rawPath);
229
286
  const absolutePath = path.resolve(parsed.dir, expandedPath);
230
- // Resolve symlinks to real path
287
+ const looksLikeTarball = hasTarballExtension(absolutePath);
288
+ if (looksLikeTarball && protocol === 'link') {
289
+ throw new FileDependencyResolutionError(packageName, absolutePath, parsed.path, new Error('`link:` protocol is for directories (symlink semantics); use `file:` for tarballs'));
290
+ }
231
291
  let realPath;
232
292
  try {
233
293
  realPath = await fsPromises.realpath(absolutePath);
@@ -235,38 +295,93 @@ export async function collectFileDependencies(parsedPackageJsons) {
235
295
  catch (e) {
236
296
  throw new FileDependencyResolutionError(packageName, absolutePath, parsed.path, e instanceof Error ? e : undefined);
237
297
  }
238
- // Verify it's a directory with a package.json
239
- const stats = await fsPromises.stat(realPath);
240
- if (!stats.isDirectory()) {
241
- throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Path is not a directory'));
242
- }
243
- const depPackageJsonPath = path.join(realPath, 'package.json');
298
+ let stats;
244
299
  try {
245
- await fsPromises.access(depPackageJsonPath, fs.constants.F_OK);
300
+ stats = await fsPromises.stat(realPath);
246
301
  }
247
- catch {
248
- throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Directory does not contain package.json'));
302
+ catch (e) {
303
+ throw new FileDependencyResolutionError(packageName, realPath, parsed.path, e instanceof Error ? e : undefined);
304
+ }
305
+ let kind;
306
+ if (looksLikeTarball) {
307
+ if (!stats.isFile()) {
308
+ throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Tarball path is not a regular file'));
309
+ }
310
+ kind = 'tarball';
311
+ }
312
+ else if (stats.isDirectory()) {
313
+ const depPackageJsonPath = path.join(realPath, 'package.json');
314
+ try {
315
+ await fsPromises.access(depPackageJsonPath, fs.constants.F_OK);
316
+ }
317
+ catch {
318
+ throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Directory does not contain package.json'));
319
+ }
320
+ kind = 'directory';
321
+ }
322
+ else {
323
+ throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Path is not a directory'));
249
324
  }
250
- // Skip if already visited (same path referenced multiple times)
251
325
  if (visited.has(realPath)) {
252
326
  logger.debug(`Skipping already visited file: dependency: ${realPath}`);
253
327
  continue;
254
328
  }
255
329
  visited.add(realPath);
256
- // Include all file: dependencies - both internal (inside srcDir) and external
257
- // The CLI determines the archive path:
258
- // - Internal: _workspaces/<relative-path>
259
- // - External: _workspaces/external/<name>-<hash>
260
330
  fileDependencyMappings.push({
261
331
  packageName,
262
332
  localPath: realPath,
263
333
  sourcePackageJsonPath: parsed.path,
334
+ kind,
335
+ protocol,
264
336
  });
265
- logger.debug(`Resolved file: dependency ${packageName}: ${realPath}`);
337
+ logger.debug(`Resolved ${protocol}: dependency ${packageName} (${kind}): ${realPath}`);
266
338
  }
267
339
  }
268
340
  return fileDependencyMappings;
269
341
  }
342
+ /**
343
+ * Validates that every `workspace:` protocol dependency in the parsed package
344
+ * trees matches a known workspace member. Throws OrphanWorkspaceProtocolError
345
+ * for the first orphan found.
346
+ *
347
+ * npm itself does not implement the `workspace:` protocol; only pnpm and yarn
348
+ * berry do. If a project uses `workspace:*` without a matching member declared
349
+ * under `workspaces`, the install fails server-side with a confusing message —
350
+ * fail fast with a clearer one.
351
+ */
352
+ export function validateWorkspaceProtocolDependencies(parsedPackageJsons, workspaceMappings) {
353
+ const workspacePackageNames = new Set();
354
+ const workspaceDirs = new Set(workspaceMappings.map(m => m.workspaceDir));
355
+ for (const parsed of parsedPackageJsons) {
356
+ if (!parsed.content?.name) {
357
+ continue;
358
+ }
359
+ let realDir;
360
+ try {
361
+ realDir = fs.realpathSync(parsed.dir);
362
+ }
363
+ catch {
364
+ continue;
365
+ }
366
+ if (workspaceDirs.has(realDir)) {
367
+ workspacePackageNames.add(parsed.content.name);
368
+ }
369
+ }
370
+ for (const parsed of parsedPackageJsons) {
371
+ const deps = parsed.content?.dependencies;
372
+ if (!deps) {
373
+ continue;
374
+ }
375
+ for (const [packageName, version] of Object.entries(deps)) {
376
+ if (typeof version !== 'string' || !version.startsWith('workspace:')) {
377
+ continue;
378
+ }
379
+ if (!workspacePackageNames.has(packageName)) {
380
+ throw new OrphanWorkspaceProtocolError(packageName, parsed.path);
381
+ }
382
+ }
383
+ }
384
+ }
270
385
  /**
271
386
  * Returns the set of files that npm would include when publishing a package.
272
387
  * Uses npm-packlist which respects the "files" field in package.json,
@@ -277,8 +392,6 @@ export async function collectFileDependencies(parsedPackageJsons) {
277
392
  export async function getPackableFiles(dir) {
278
393
  try {
279
394
  const tree = createMinimalTree(dir);
280
- // Cast to Parameters<typeof packlist>[0] since npm-packlist only uses
281
- // a subset of the Arborist Node properties at runtime
282
395
  const files = await packlist(tree);
283
396
  return new Set(files);
284
397
  }