@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 +11 -4
- package/src/exports/projects.d.ts +1 -0
- package/src/exports/projects.js +1 -0
- package/src/exports/workspaces.d.ts +2 -0
- package/src/exports/workspaces.js +1 -0
- package/src/lang/copy.d.ts +1 -0
- package/src/lang/copy.js +1 -0
- package/src/lib/constants.d.ts +8 -0
- package/src/lib/constants.js +9 -0
- package/src/lib/minimalArboristTree.d.ts +118 -0
- package/src/lib/minimalArboristTree.js +32 -0
- package/src/lib/platformVersion.d.ts +3 -0
- package/src/lib/platformVersion.js +20 -0
- package/src/lib/translate.js +18 -8
- package/src/lib/types.d.ts +1 -1
- package/src/lib/workspaces.d.ts +68 -0
- package/src/lib/workspaces.js +290 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubspot/project-parsing-lib",
|
|
3
|
-
"version": "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.
|
|
51
|
-
"@typescript-eslint/parser": "8.
|
|
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';
|
package/src/exports/projects.js
CHANGED
|
@@ -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';
|
package/src/lang/copy.d.ts
CHANGED
|
@@ -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',
|
package/src/lib/constants.d.ts
CHANGED
|
@@ -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";
|
package/src/lib/constants.js
CHANGED
|
@@ -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,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
|
+
}
|
package/src/lib/translate.js
CHANGED
|
@@ -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 (
|
|
17
|
-
hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir,
|
|
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 (
|
|
34
|
-
hsProfileContents = loadHsProfileFile(translationContext.projectSourceDir,
|
|
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,
|
|
49
|
+
const projectNodes = getLocalDevProjectNodes(baseIntermediateRepresentation.intermediateNodesIndexedByUid, translationContext.projectSourceDir, projectNodesAtLastUpload);
|
|
40
50
|
let projectNodesWithErrors = {};
|
|
41
|
-
if (
|
|
42
|
-
projectNodesWithErrors = getRemovedNodesAndNodesWithErrors(baseIntermediateRepresentation.intermediateNodesIndexedByUid,
|
|
51
|
+
if (projectNodesAtLastUpload) {
|
|
52
|
+
projectNodesWithErrors = getRemovedNodesAndNodesWithErrors(baseIntermediateRepresentation.intermediateNodesIndexedByUid, projectNodesAtLastUpload, parsingErrors);
|
|
43
53
|
}
|
|
44
54
|
const profileData = getLocalDevProfileData(baseIntermediateRepresentation.profileData?.vars.profileVariables);
|
|
45
55
|
return {
|
package/src/lib/types.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface TranslationOptions {
|
|
|
74
74
|
skipValidation?: boolean;
|
|
75
75
|
profile?: string;
|
|
76
76
|
}
|
|
77
|
-
export interface TranslationOptionsLocalDev extends
|
|
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
|
+
}
|