@hubspot/project-parsing-lib 0.13.0 → 0.14.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.13.0",
3
+ "version": "0.14.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",
@@ -40,15 +40,20 @@
40
40
  "./uid": {
41
41
  "import": "./src/exports/uid.js",
42
42
  "types": "./src/exports/uid.d.ts"
43
+ },
44
+ "./workspaces": {
45
+ "import": "./src/exports/workspaces.js",
46
+ "types": "./src/exports/workspaces.d.ts"
43
47
  }
44
48
  },
45
49
  "devDependencies": {
46
50
  "@hubspot/npm-scripts": "0.0.6",
47
51
  "@inquirer/prompts": "^7.1.0",
48
52
  "@types/node": "^24.9.0",
53
+ "@types/npm-packlist": "7.0.3",
49
54
  "@types/semver": "^7.5.8",
50
- "@typescript-eslint/eslint-plugin": "8.46.3",
51
- "@typescript-eslint/parser": "8.46.3",
55
+ "@typescript-eslint/eslint-plugin": "8.54.0",
56
+ "@typescript-eslint/parser": "8.54.0",
52
57
  "eslint": "^9.38.0",
53
58
  "eslint-plugin-import": "^2.31.0",
54
59
  "husky": "^9.1.7",
@@ -62,7 +67,9 @@
62
67
  "dependencies": {
63
68
  "@hubspot/local-dev-lib": "4.0.4",
64
69
  "ajv": "8.18.0",
65
- "ajv-formats": "3.0.1"
70
+ "ajv-formats": "3.0.1",
71
+ "glob": "13.0.0",
72
+ "npm-packlist": "8.0.1"
66
73
  },
67
74
  "scripts": {
68
75
  "build": "tsx ./scripts/build.ts",
@@ -1,3 +1,4 @@
1
1
  export { getProjectMetadata } from '../lib/project.js';
2
2
  export { projectContainsHsMetaFiles } from '../lib/files.js';
3
3
  export type { ProjectMetadata } from '../lib/project.js';
4
+ export { LATEST_SUPPORTED_PLATFORM_VERSION, isSupportedPlatformVersion, isUnifiedProject, } from '../lib/platformVersion.js';
@@ -1,2 +1,3 @@
1
1
  export { getProjectMetadata } from '../lib/project.js';
2
2
  export { projectContainsHsMetaFiles } from '../lib/files.js';
3
+ export { LATEST_SUPPORTED_PLATFORM_VERSION, isSupportedPlatformVersion, isUnifiedProject, } from '../lib/platformVersion.js';
@@ -0,0 +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';
@@ -0,0 +1 @@
1
+ export { findAndParsePackageJsonFiles, resolveWorkspaceDirectories, collectWorkspaceDirectories, collectFileDependencies, getPackableFiles, WorkspaceResolutionError, FileDependencyResolutionError, } from '../lib/workspaces.js';
@@ -13,6 +13,7 @@ export declare const errorMessages: {
13
13
  duplicateComponent: (componentType: string) => string;
14
14
  noPackageJsonForServerless: (appFunctionsPackageFile: string) => string;
15
15
  fileContentMissingFor: (file: string) => string;
16
+ unsupportedPlatformVersion: (platformVersion: string) => string;
16
17
  };
17
18
  profile: {
18
19
  noProfileSpecified: string;
package/src/lang/copy.js CHANGED
@@ -14,6 +14,7 @@ export const errorMessages = {
14
14
  duplicateComponent: (componentType) => `Only one ${componentType} component is allowed`,
15
15
  noPackageJsonForServerless: (appFunctionsPackageFile) => `${appFunctionsPackageFile} does not exist. ${Components[APP_FUNCTIONS_KEY].userFriendlyName} requires a ${PACKAGE_JSON} file to exist in this location`,
16
16
  fileContentMissingFor: (file) => `File content is missing for ${file}`,
17
+ unsupportedPlatformVersion: (platformVersion) => `Unsupported platform version: '${platformVersion}'`,
17
18
  },
18
19
  profile: {
19
20
  noProfileSpecified: 'No profile specified',
@@ -1,3 +1,11 @@
1
+ export declare const PLATFORM_VERSIONS: {
2
+ v2023_2: string;
3
+ v2025_1: string;
4
+ v2025_2: string;
5
+ v2026_03_BETA: string;
6
+ v2026_03: string;
7
+ UNSTABLE: string;
8
+ };
1
9
  import { Components as ComponentsTopLevel } from './types.js';
2
10
  export declare const APP_KEY = "app";
3
11
  export declare const THEME_KEY = "theme";
@@ -1,3 +1,12 @@
1
+ // Project Platform Versions
2
+ export const PLATFORM_VERSIONS = {
3
+ v2023_2: '2023.2',
4
+ v2025_1: '2025.1',
5
+ v2025_2: '2025.2',
6
+ v2026_03_BETA: '2026.03-beta',
7
+ v2026_03: '2026.03',
8
+ UNSTABLE: 'unstable',
9
+ };
1
10
  // Top Level Component types
2
11
  import * as path from 'path';
3
12
  // Component types
@@ -0,0 +1,118 @@
1
+ /**
2
+ * MINIMAL ARBORIST TREE
3
+ *
4
+ * This module provides a lightweight alternative to @npmcli/arborist for use
5
+ * with npm-packlist. Instead of importing the full arborist package (~3.4MB
6
+ * with 35+ transitive dependencies), we create a minimal tree object that
7
+ * satisfies npm-packlist's runtime requirements.
8
+ *
9
+ * WHY THIS EXISTS:
10
+ * - @npmcli/arborist adds ~3.4MB to the bundle
11
+ * - npm-packlist only uses a small subset of the Arborist Node interface
12
+ * - For our use case (determining packable files in workspace directories),
13
+ * we don't need the full dependency resolution capabilities of arborist
14
+ *
15
+ * CONTEXT: HUBSPOT DEVELOPER PROJECTS UPLOAD FLOW
16
+ *
17
+ * This library is used to collect files from npm workspace packages for upload
18
+ * to HubSpot's build system. Important constraints:
19
+ *
20
+ * - node_modules/ directories are EXCLUDED from uploads
21
+ * - The build system runs `npm install` to fetch dependencies
22
+ * - Source files, package.json, and config files are included
23
+ *
24
+ * This means bundleDependencies (which includes files from node_modules/) would
25
+ * not work even if we used full arborist, because node_modules/ is excluded
26
+ * from the upload archive. Developers with private registry packages should use
27
+ * file: dependencies instead, which ARE supported.
28
+ *
29
+ * HOW NPM-PACKLIST USES THE TREE (as of npm-packlist@8.0.1):
30
+ *
31
+ * tree.path - Directory path (REQUIRED)
32
+ * tree.package - Parsed package.json content (REQUIRED)
33
+ * .files - Files array for inclusion
34
+ * .main - Main entry point (always included)
35
+ * .bin - Binary files (always included)
36
+ * .browser - Browser field (always included)
37
+ * .bundleDependencies - Only used when isProjectRoot=true
38
+ * .dependencies - Only used when isProjectRoot=true
39
+ * .optionalDependencies - Only used when isProjectRoot=true
40
+ * tree.workspaces - Map of workspace paths (OPTIONAL, guarded by if-check)
41
+ * tree.isProjectRoot - Boolean for bundled deps handling (OPTIONAL)
42
+ * tree.edgesOut - Dependency edges (only accessed when isProjectRoot=true)
43
+ *
44
+ * KNOWN LIMITATIONS:
45
+ *
46
+ * 1. BUNDLED DEPENDENCIES NOT SUPPORTED
47
+ * We set isProjectRoot=false, which skips bundled dependency handling.
48
+ * If a workspace package has "bundleDependencies" in package.json, those
49
+ * files will NOT be included in the packlist result.
50
+ *
51
+ * NOTE: This would not work anyway because bundleDependencies includes files
52
+ * from node_modules/, and node_modules/ is excluded from project uploads.
53
+ * Even with full arborist, bundled dependencies would be filtered out.
54
+ * Developers needing to include private/local packages should use file:
55
+ * dependencies instead (e.g., "my-pkg": "file:../my-pkg").
56
+ *
57
+ * 2. WORKSPACE-AWARE FILTERING DISABLED
58
+ * We set workspaces=null, so npm-packlist won't exclude workspace
59
+ * directories. This is fine for our use case since we process each
60
+ * workspace independently.
61
+ *
62
+ * 3. VERSION COUPLING
63
+ * This minimal interface is based on npm-packlist@8.0.1. Future versions
64
+ * may access additional properties, causing runtime errors. If packlist
65
+ * behavior changes unexpectedly, check for new property accesses:
66
+ *
67
+ * grep -E "this\.tree\." node_modules/npm-packlist/lib/index.js
68
+ *
69
+ * DEBUGGING:
70
+ *
71
+ * If getPackableFiles() returns unexpected results:
72
+ *
73
+ * 1. Check if npm-packlist version changed (package.json)
74
+ * 2. Compare tree property accesses (grep command above)
75
+ * 3. Test with full arborist to isolate the issue:
76
+ *
77
+ * import Arborist from '@npmcli/arborist';
78
+ * const arb = new Arborist({ path: dir });
79
+ * const tree = await arb.loadActual();
80
+ * const files = await packlist(tree);
81
+ *
82
+ * 4. If full arborist works but minimal tree doesn't, add the missing
83
+ * properties to MinimalArboristTree interface and createMinimalTree()
84
+ */
85
+ /**
86
+ * Minimal subset of package.json fields used by npm-packlist
87
+ */
88
+ interface PackageJsonForPacklist {
89
+ name?: string;
90
+ files?: string[];
91
+ main?: string;
92
+ bin?: string | Record<string, string>;
93
+ browser?: string | Record<string, string>;
94
+ bundleDependencies?: string[];
95
+ dependencies?: Record<string, string>;
96
+ optionalDependencies?: Record<string, string>;
97
+ }
98
+ /**
99
+ * Minimal tree interface that satisfies npm-packlist's runtime requirements.
100
+ * This is NOT the full Arborist Node interface - only the properties that
101
+ * npm-packlist actually accesses at runtime.
102
+ */
103
+ export interface MinimalArboristTree {
104
+ path: string;
105
+ package: PackageJsonForPacklist;
106
+ workspaces: null;
107
+ isProjectRoot: false;
108
+ edgesOut: Map<string, unknown>;
109
+ }
110
+ /**
111
+ * Creates a minimal tree object that satisfies npm-packlist's requirements
112
+ * without importing the heavy @npmcli/arborist package.
113
+ *
114
+ * @param dir - Directory containing the package
115
+ * @returns Minimal tree object compatible with npm-packlist
116
+ */
117
+ export declare function createMinimalTree(dir: string): MinimalArboristTree;
118
+ export {};
@@ -0,0 +1,32 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ /**
4
+ * Creates a minimal tree object that satisfies npm-packlist's requirements
5
+ * without importing the heavy @npmcli/arborist package.
6
+ *
7
+ * @param dir - Directory containing the package
8
+ * @returns Minimal tree object compatible with npm-packlist
9
+ */
10
+ export function createMinimalTree(dir) {
11
+ let pkgJson = {};
12
+ try {
13
+ const content = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
14
+ pkgJson = JSON.parse(content);
15
+ }
16
+ catch {
17
+ // Use empty package.json if not found or invalid
18
+ // npm-packlist handles this gracefully
19
+ }
20
+ return {
21
+ path: dir,
22
+ package: pkgJson,
23
+ // Set to null to skip workspace-aware filtering
24
+ // (we process each workspace independently)
25
+ workspaces: null,
26
+ // Set to false to skip bundled dependency handling
27
+ // (not needed for workspace file collection)
28
+ isProjectRoot: false,
29
+ // Empty map - only accessed when isProjectRoot is true
30
+ edgesOut: new Map(),
31
+ };
32
+ }
@@ -0,0 +1,3 @@
1
+ export declare const LATEST_SUPPORTED_PLATFORM_VERSION: string;
2
+ export declare function isSupportedPlatformVersion(platformVersion?: string | null): boolean;
3
+ export declare function isUnifiedProject(platformVersion?: string | null): boolean;
@@ -0,0 +1,20 @@
1
+ import { PLATFORM_VERSIONS } from './constants.js';
2
+ // Used for logging suggestions to users whenever we encouter an unknown version
3
+ export const LATEST_SUPPORTED_PLATFORM_VERSION = PLATFORM_VERSIONS.v2026_03;
4
+ // We are unable to reliably interact versions of projects that are not officially supported by this library
5
+ export function isSupportedPlatformVersion(platformVersion) {
6
+ if (!platformVersion || typeof platformVersion !== 'string') {
7
+ return false;
8
+ }
9
+ const supportedPlatformVersions = Object.values(PLATFORM_VERSIONS);
10
+ return supportedPlatformVersions.includes(platformVersion);
11
+ }
12
+ export function isUnifiedProject(platformVersion) {
13
+ if (!isSupportedPlatformVersion(platformVersion)) {
14
+ return false;
15
+ }
16
+ const [year, minor] = platformVersion.split(/[.-]/);
17
+ const yearNum = Number(year);
18
+ const minorNum = Number(minor);
19
+ return (yearNum === 2025 && minorNum >= 2) || yearNum > 2025;
20
+ }
@@ -3,18 +3,23 @@ import { validateIntermediateRepresentation } from './validation.js';
3
3
  import { getIntermediateRepresentation, getParsingErrors, transform, } from './transform.js';
4
4
  import { errorMessages } from '../lang/copy.js';
5
5
  import { getLocalDevProfileData, getLocalDevProjectNodes, getRemovedNodesAndNodesWithErrors, } from './localDev.js';
6
+ import { isSupportedPlatformVersion } from './platformVersion.js';
6
7
  const defaultOptions = {
7
8
  skipValidation: false,
8
9
  };
9
10
  export async function translate(translationContext, translationOptions = defaultOptions) {
10
- const { skipValidation } = translationOptions;
11
+ const { skipValidation, profile } = translationOptions;
12
+ if (!skipValidation &&
13
+ !isSupportedPlatformVersion(translationContext.platformVersion)) {
14
+ throw new Error(errorMessages.project.unsupportedPlatformVersion(translationContext.platformVersion));
15
+ }
11
16
  const metafileContents = await loadHsMetaFiles(translationContext);
12
17
  if (metafileContents.length === 0) {
13
18
  throw new Error(errorMessages.project.noHsMetaFiles);
14
19
  }
15
20
  let hsProfileContents;
16
- if (translationOptions.profile) {
17
- hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir, translationOptions.profile);
21
+ if (profile) {
22
+ hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir, profile);
18
23
  }
19
24
  const transformations = transform(metafileContents, translationContext, hsProfileContents);
20
25
  const intermediateRepresentation = getIntermediateRepresentation(transformations, hsProfileContents, skipValidation);
@@ -25,21 +30,26 @@ export async function translate(translationContext, translationOptions = default
25
30
  return intermediateRepresentation;
26
31
  }
27
32
  export async function translateForLocalDev(translationContext, translationOptions) {
33
+ const { skipValidation, profile, projectNodesAtLastUpload } = translationOptions || {};
34
+ if (!skipValidation &&
35
+ !isSupportedPlatformVersion(translationContext.platformVersion)) {
36
+ throw new Error(errorMessages.project.unsupportedPlatformVersion(translationContext.platformVersion));
37
+ }
28
38
  const metafileContents = await loadHsMetaFiles(translationContext);
29
39
  if (metafileContents.length === 0) {
30
40
  throw new Error(errorMessages.project.noHsMetaFiles);
31
41
  }
32
42
  let hsProfileContents;
33
- if (translationOptions?.profile) {
34
- hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir, translationOptions.profile);
43
+ if (profile) {
44
+ hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir, profile);
35
45
  }
36
46
  const transformation = transform(metafileContents, translationContext, hsProfileContents);
37
47
  const baseIntermediateRepresentation = getIntermediateRepresentation(transformation, hsProfileContents, true);
38
48
  const parsingErrors = getParsingErrors(transformation);
39
- const projectNodes = getLocalDevProjectNodes(baseIntermediateRepresentation.intermediateNodesIndexedByUid, translationContext.projectSourceDir, translationOptions?.projectNodesAtLastUpload);
49
+ const projectNodes = getLocalDevProjectNodes(baseIntermediateRepresentation.intermediateNodesIndexedByUid, translationContext.projectSourceDir, projectNodesAtLastUpload);
40
50
  let projectNodesWithErrors = {};
41
- if (translationOptions?.projectNodesAtLastUpload) {
42
- projectNodesWithErrors = getRemovedNodesAndNodesWithErrors(baseIntermediateRepresentation.intermediateNodesIndexedByUid, translationOptions.projectNodesAtLastUpload, parsingErrors);
51
+ if (projectNodesAtLastUpload) {
52
+ projectNodesWithErrors = getRemovedNodesAndNodesWithErrors(baseIntermediateRepresentation.intermediateNodesIndexedByUid, projectNodesAtLastUpload, parsingErrors);
43
53
  }
44
54
  const profileData = getLocalDevProfileData(baseIntermediateRepresentation.profileData?.vars.profileVariables);
45
55
  return {
@@ -74,7 +74,7 @@ export interface TranslationOptions {
74
74
  skipValidation?: boolean;
75
75
  profile?: string;
76
76
  }
77
- export interface TranslationOptionsLocalDev extends Omit<TranslationOptions, 'skipValidation'> {
77
+ export interface TranslationOptionsLocalDev extends TranslationOptions {
78
78
  projectNodesAtLastUpload?: {
79
79
  [key: string]: IntermediateRepresentationNodeLocalDev;
80
80
  };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Error thrown when a workspace directory cannot be resolved
3
+ */
4
+ export declare class WorkspaceResolutionError extends Error {
5
+ workspaceDir: string;
6
+ sourcePackageJson: string;
7
+ constructor(workspaceDir: string, sourcePackageJson: string, cause?: Error);
8
+ }
9
+ /**
10
+ * Error thrown when a file: dependency cannot be resolved
11
+ */
12
+ export declare class FileDependencyResolutionError extends Error {
13
+ packageName: string;
14
+ dependencyPath: string;
15
+ sourcePackageJson: string;
16
+ constructor(packageName: string, dependencyPath: string, sourcePackageJson: string, cause?: Error);
17
+ }
18
+ interface PackageJson {
19
+ name?: string;
20
+ workspaces?: string[] | {
21
+ packages?: string[];
22
+ nohoist?: string[];
23
+ };
24
+ dependencies?: Record<string, string>;
25
+ }
26
+ export interface ParsedPackageJson {
27
+ path: string;
28
+ dir: string;
29
+ content: PackageJson | null;
30
+ }
31
+ export interface WorkspaceMapping {
32
+ workspaceDir: string;
33
+ sourcePackageJsonPath: string;
34
+ }
35
+ export interface FileDependencyMapping {
36
+ packageName: string;
37
+ localPath: string;
38
+ sourcePackageJsonPath: string;
39
+ }
40
+ /**
41
+ * Finds and parses all package.json files in a directory.
42
+ * This is the single entry point for discovering package.json files and parsing their contents.
43
+ */
44
+ export declare function findAndParsePackageJsonFiles(srcDir: string): Promise<ParsedPackageJson[]>;
45
+ /**
46
+ * Resolves workspace glob patterns to actual directories
47
+ */
48
+ export declare function resolveWorkspaceDirectories(baseDir: string, workspaceGlobs: string[]): Promise<string[]>;
49
+ /**
50
+ * Collects all workspace directories that need to be uploaded.
51
+ * Handles edge cases like circular references, symlinks, and overlapping paths.
52
+ * Returns mappings that track which package.json defined each workspace.
53
+ */
54
+ export declare function collectWorkspaceDirectories(parsedPackageJsons: ParsedPackageJson[]): Promise<WorkspaceMapping[]>;
55
+ /**
56
+ * Collects all file: dependencies that need to be uploaded.
57
+ * Returns mappings that track the package name, resolved path, and source package.json.
58
+ */
59
+ export declare function collectFileDependencies(parsedPackageJsons: ParsedPackageJson[]): Promise<FileDependencyMapping[]>;
60
+ /**
61
+ * Returns the set of files that npm would include when publishing a package.
62
+ * Uses npm-packlist which respects the "files" field in package.json,
63
+ * .npmignore, and .gitignore rules.
64
+ *
65
+ * @throws Error if packlist fails to generate the file list
66
+ */
67
+ export declare function getPackableFiles(dir: string): Promise<Set<string>>;
68
+ export {};
@@ -0,0 +1,290 @@
1
+ import fs from 'fs';
2
+ import fsPromises from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { glob } from 'glob';
6
+ import packlist from 'npm-packlist';
7
+ import { logger } from '@hubspot/local-dev-lib/logger';
8
+ import { walk } from '@hubspot/local-dev-lib/fs';
9
+ import { createMinimalTree } from './minimalArboristTree.js';
10
+ /**
11
+ * Error thrown when a workspace directory cannot be resolved
12
+ */
13
+ export class WorkspaceResolutionError extends Error {
14
+ workspaceDir;
15
+ sourcePackageJson;
16
+ constructor(workspaceDir, sourcePackageJson, cause) {
17
+ super(`Failed to resolve workspace directory "${workspaceDir}" declared in ${sourcePackageJson}${cause ? `: ${cause.message}` : ''}`);
18
+ this.workspaceDir = workspaceDir;
19
+ this.sourcePackageJson = sourcePackageJson;
20
+ this.name = 'WorkspaceResolutionError';
21
+ if (cause) {
22
+ this.cause = cause;
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * Error thrown when a file: dependency cannot be resolved
28
+ */
29
+ export class FileDependencyResolutionError extends Error {
30
+ packageName;
31
+ dependencyPath;
32
+ sourcePackageJson;
33
+ constructor(packageName, dependencyPath, sourcePackageJson, cause) {
34
+ super(`Failed to resolve file: dependency "${packageName}" at path "${dependencyPath}" declared in ${sourcePackageJson}${cause ? `: ${cause.message}` : ''}`);
35
+ this.packageName = packageName;
36
+ this.dependencyPath = dependencyPath;
37
+ this.sourcePackageJson = sourcePackageJson;
38
+ this.name = 'FileDependencyResolutionError';
39
+ if (cause) {
40
+ this.cause = cause;
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Expands tilde (~) in paths to the user's home directory
46
+ */
47
+ function expandTildePath(filePath) {
48
+ return filePath.startsWith('~')
49
+ ? path.join(os.homedir(), filePath.slice(1))
50
+ : filePath;
51
+ }
52
+ /**
53
+ * Finds and parses all package.json files in a directory.
54
+ * This is the single entry point for discovering package.json files and parsing their contents.
55
+ */
56
+ export async function findAndParsePackageJsonFiles(srcDir) {
57
+ const allFiles = await walk(srcDir, ['node_modules']);
58
+ const packageJsonPaths = allFiles.filter(file => path.basename(file) === 'package.json');
59
+ return packageJsonPaths.map(filePath => {
60
+ try {
61
+ return {
62
+ path: filePath,
63
+ dir: path.dirname(filePath),
64
+ content: JSON.parse(fs.readFileSync(filePath, 'utf8')),
65
+ };
66
+ }
67
+ catch (e) {
68
+ logger.debug(`Failed to parse package.json at ${filePath}: ${e}`);
69
+ return {
70
+ path: filePath,
71
+ dir: path.dirname(filePath),
72
+ content: null,
73
+ };
74
+ }
75
+ });
76
+ }
77
+ /**
78
+ * Extracts workspace patterns from parsed package.json content
79
+ */
80
+ function extractWorkspaceGlobs(packageJson) {
81
+ const workspaces = packageJson.workspaces;
82
+ if (!workspaces) {
83
+ return [];
84
+ }
85
+ // Handle array format: "workspaces": ["packages/*"]
86
+ if (Array.isArray(workspaces)) {
87
+ return workspaces;
88
+ }
89
+ // Handle object format: "workspaces": {"packages": ["packages/*"]}
90
+ if (workspaces.packages && Array.isArray(workspaces.packages)) {
91
+ return workspaces.packages;
92
+ }
93
+ return [];
94
+ }
95
+ /**
96
+ * Resolves workspace glob patterns to actual directories
97
+ */
98
+ 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);
103
+ 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
+ }
128
+ }
129
+ }
130
+ catch (e) {
131
+ logger.debug(`Failed to resolve workspace pattern "${pattern}": ${e}`);
132
+ }
133
+ }
134
+ return Array.from(resolvedDirs);
135
+ }
136
+ /**
137
+ * Collects all workspace directories that need to be uploaded.
138
+ * Handles edge cases like circular references, symlinks, and overlapping paths.
139
+ * Returns mappings that track which package.json defined each workspace.
140
+ */
141
+ export async function collectWorkspaceDirectories(parsedPackageJsons) {
142
+ const workspaceMappings = [];
143
+ // Track (sourcePackageJsonPath, workspaceDir) pairs to avoid duplicates within
144
+ // 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.
148
+ const visited = new Set();
149
+ for (const parsed of parsedPackageJsons) {
150
+ if (!parsed.content) {
151
+ continue;
152
+ }
153
+ const workspaceGlobs = extractWorkspaceGlobs(parsed.content);
154
+ if (workspaceGlobs.length === 0) {
155
+ continue;
156
+ }
157
+ logger.debug(`Found workspaces in ${parsed.path}: ${workspaceGlobs.join(', ')}`);
158
+ const resolved = await resolveWorkspaceDirectories(parsed.dir, workspaceGlobs);
159
+ for (const dir of resolved) {
160
+ // Resolve symlinks to real path
161
+ let realDir;
162
+ try {
163
+ realDir = await fsPromises.realpath(dir);
164
+ }
165
+ catch (e) {
166
+ throw new WorkspaceResolutionError(dir, parsed.path, e instanceof Error ? e : undefined);
167
+ }
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}`;
172
+ if (visited.has(pairKey)) {
173
+ logger.debug(`Skipping duplicate workspace mapping: ${realDir} from ${parsed.path}`);
174
+ continue;
175
+ }
176
+ 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
+ workspaceMappings.push({
182
+ workspaceDir: realDir,
183
+ sourcePackageJsonPath: parsed.path,
184
+ });
185
+ logger.debug(`Resolved workspace: ${realDir}`);
186
+ }
187
+ }
188
+ return workspaceMappings;
189
+ }
190
+ /**
191
+ * Extracts file: dependencies from parsed package.json content.
192
+ * Only scans production dependencies since devDependencies and optionalDependencies
193
+ * are not needed for the build pipeline.
194
+ */
195
+ function extractFileDependencies(packageJson) {
196
+ const fileDeps = [];
197
+ const deps = packageJson.dependencies;
198
+ if (!deps) {
199
+ return fileDeps;
200
+ }
201
+ 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 });
207
+ }
208
+ }
209
+ return fileDeps;
210
+ }
211
+ /**
212
+ * Collects all file: dependencies that need to be uploaded.
213
+ * Returns mappings that track the package name, resolved path, and source package.json.
214
+ */
215
+ export async function collectFileDependencies(parsedPackageJsons) {
216
+ const fileDependencyMappings = [];
217
+ const visited = new Set();
218
+ for (const parsed of parsedPackageJsons) {
219
+ if (!parsed.content) {
220
+ continue;
221
+ }
222
+ const fileDeps = extractFileDependencies(parsed.content);
223
+ if (fileDeps.length === 0) {
224
+ continue;
225
+ }
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);
229
+ const absolutePath = path.resolve(parsed.dir, expandedPath);
230
+ // Resolve symlinks to real path
231
+ let realPath;
232
+ try {
233
+ realPath = await fsPromises.realpath(absolutePath);
234
+ }
235
+ catch (e) {
236
+ throw new FileDependencyResolutionError(packageName, absolutePath, parsed.path, e instanceof Error ? e : undefined);
237
+ }
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');
244
+ try {
245
+ await fsPromises.access(depPackageJsonPath, fs.constants.F_OK);
246
+ }
247
+ catch {
248
+ throw new FileDependencyResolutionError(packageName, realPath, parsed.path, new Error('Directory does not contain package.json'));
249
+ }
250
+ // Skip if already visited (same path referenced multiple times)
251
+ if (visited.has(realPath)) {
252
+ logger.debug(`Skipping already visited file: dependency: ${realPath}`);
253
+ continue;
254
+ }
255
+ 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
+ fileDependencyMappings.push({
261
+ packageName,
262
+ localPath: realPath,
263
+ sourcePackageJsonPath: parsed.path,
264
+ });
265
+ logger.debug(`Resolved file: dependency ${packageName}: ${realPath}`);
266
+ }
267
+ }
268
+ return fileDependencyMappings;
269
+ }
270
+ /**
271
+ * Returns the set of files that npm would include when publishing a package.
272
+ * Uses npm-packlist which respects the "files" field in package.json,
273
+ * .npmignore, and .gitignore rules.
274
+ *
275
+ * @throws Error if packlist fails to generate the file list
276
+ */
277
+ export async function getPackableFiles(dir) {
278
+ try {
279
+ 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
+ const files = await packlist(tree);
283
+ return new Set(files);
284
+ }
285
+ catch (e) {
286
+ const errorMessage = `Failed to get packlist for ${dir}: ${e instanceof Error ? e.message : String(e)}`;
287
+ logger.error(errorMessage);
288
+ throw new Error(errorMessage, { cause: e });
289
+ }
290
+ }