enablement-build-manifest 1.0.4 → 1.6.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/README.md +14 -24
- package/package.json +2 -1
- package/src/dependencyOrder.mjs +26 -0
- package/src/detect.mjs +48 -0
- package/src/hashFile.mjs +501 -0
- package/src/manifest.mjs +94 -71
- package/src/options.mjs +5 -3
- package/src/workspace.mjs +97 -0
- package/tests/dependencyOrder.test.mjs +43 -0
- package/tests/detect.test.mjs +75 -0
- package/tests/folderHash.test.mjs +112 -0
- package/tests/hashFile.test.mjs +171 -0
- package/tests/manifest.test.mjs +262 -5
- package/tests/options.test.mjs +9 -1
- package/tests/workspace.test.mjs +172 -0
package/README.md
CHANGED
|
@@ -10,24 +10,21 @@ npx enablement-build-manifest [options]
|
|
|
10
10
|
|
|
11
11
|
Run from the root of your monorepo. The manifest is printed to stdout and optionally saved to disk.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Every `--save` run converts the manifest to its current schema version: any template routing still recorded in the manifest is moved to `{configDir}/templates/templated.json`, any `cicd` hash values are moved to `{configDir}/hash.json`, and neither is kept in `manifest.json` afterward. `cicd` version fields are still preserved.
|
|
14
14
|
|
|
15
15
|
## Options
|
|
16
16
|
|
|
17
17
|
| Flag | Description |
|
|
18
18
|
|------|-------------|
|
|
19
19
|
| `--save` | Write the manifest to `{configDir}/manifest.json` in addition to stdout |
|
|
20
|
-
| `--platform <github
|
|
20
|
+
| `--platform <github|ado>` | Select the platform; defaults to `ado` |
|
|
21
|
+
| `--children <dirs>` | Comma- or space-separated list of top-level directories to scan for packages (e.g. `packages,apps`). Overrides auto-detection from `pnpm-workspace.yaml` or `package.json` workspaces. |
|
|
22
|
+
| `--exclude <dirs>` | Comma- or space-separated list of top-level directories to drop from whichever list of children is in effect — auto-detected or from `--children` (e.g. auto-detect everything in `pnpm-workspace.yaml` but skip `tools`). |
|
|
21
23
|
| `--debug` | Print the number of discovered packages |
|
|
22
24
|
|
|
23
|
-
## Platform
|
|
25
|
+
## Platform
|
|
24
26
|
|
|
25
|
-
The platform
|
|
26
|
-
|
|
27
|
-
- `.github/templates/templated.json` or `.github/manifest.json` → `github` (uses `.github/`)
|
|
28
|
-
- Neither found → `ado` (uses `.cicd/`)
|
|
29
|
-
|
|
30
|
-
Pass `--platform github` or `--platform ado` to override.
|
|
27
|
+
The platform defaults to `ado`, which uses `.cicd/`. Pass `--platform github` to use `.github/`.
|
|
31
28
|
|
|
32
29
|
## Output
|
|
33
30
|
|
|
@@ -35,15 +32,19 @@ The manifest is a JSON object with six top-level keys:
|
|
|
35
32
|
|
|
36
33
|
```json
|
|
37
34
|
{
|
|
35
|
+
"version": 2,
|
|
38
36
|
"versions": { "<package-name>": "<version>", ... },
|
|
39
37
|
"dependencies": { "<package-name>": ["<local-dep>", ...], ... },
|
|
40
38
|
"dependents": { "<package-name>": ["<consumer>", ...], ... },
|
|
41
39
|
"pipelines": { "<template-file>": [ <stage>, ... ], ... },
|
|
42
|
-
"templated": { "<template-file>": { "<placeholder>": { "<package-name>": "<job-template>", ... } } },
|
|
43
40
|
"cicd": { "<package-name>": { "output": "...", "language": "...", "tier": "...", ... }, ... }
|
|
44
41
|
}
|
|
45
42
|
```
|
|
46
43
|
|
|
44
|
+
### `version`
|
|
45
|
+
|
|
46
|
+
The manifest's schema version. Always `2` in the current version of this tool.
|
|
47
|
+
|
|
47
48
|
### `versions`
|
|
48
49
|
|
|
49
50
|
Maps every local package name to its current version from `package.json`.
|
|
@@ -57,21 +58,9 @@ Maps every local package name to its current version from `package.json`.
|
|
|
57
58
|
|
|
58
59
|
Populated when `{configDir}/templates/templated.json` exists. Each key is a pipeline template filename; the value is an ordered list of resolved stages with their associated package lists.
|
|
59
60
|
|
|
60
|
-
###
|
|
61
|
-
|
|
62
|
-
Stores the template routing that used to live in `templated.json`. The first key is the output file name, the second key is the placeholder name, and the innermost map assigns each package name to the job template file or files it should use.
|
|
61
|
+
### Template routing
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
```json
|
|
67
|
-
{
|
|
68
|
-
"build.yml": {
|
|
69
|
-
"PACKAGES": {
|
|
70
|
-
"enablement-build-deploy-version": "build_node_component.yml"
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
```
|
|
63
|
+
Template routing (which job template each package uses) no longer lives in `manifest.json` — it's read from and written to `{configDir}/templates/templated.json` directly, in the shape [packages/template-generate](../template-generate) expects: `{ "version": 2, "templated": { "<template-file>": { "<placeholder>": { "<package-name>": "<job-template>", ... } } }, "cicd": { "<package-name>": { "folderName": "...", "packageFolder": "...", "metadata": {...} } } }`. Every `--save` run migrates any leftover routing it finds in `manifest.json` into this file, additively (existing assignments in `templated.json` win over stale ones from `manifest.json`).
|
|
75
64
|
|
|
76
65
|
### `cicd`
|
|
77
66
|
|
|
@@ -87,6 +76,7 @@ CI/CD metadata for each package. Sourced from the `.cicd` field in `package.json
|
|
|
87
76
|
| `pyproject.toml` present | `pypi` | `Python` | `api` |
|
|
88
77
|
| `Dockerfile` in image/container path | `docker` | `Dockerfile` | `infra` |
|
|
89
78
|
|
|
79
|
+
A `hash` field left over from an older manifest is moved to `{configDir}/hash.json` on the next `--save` and removed from `cicd`.
|
|
90
80
|
|
|
91
81
|
## Example
|
|
92
82
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enablement-build-manifest",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "This builds a dependency tree.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./index.mjs",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"author": "Contributors",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"globby": "^16.2.0",
|
|
12
|
+
"minimatch": "~10.1.1",
|
|
12
13
|
"yaml": "^2.8.1"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Order `names` so that, as much as possible, each name comes after every
|
|
2
|
+
// local dependency it has (per `dependencies`, a name -> [depName, ...] map —
|
|
3
|
+
// the same shape as manifest.json's "dependencies" property). Dependencies
|
|
4
|
+
// that aren't in `names` are ignored (they're outside whatever list is being
|
|
5
|
+
// ordered). Cycles can't be fully resolved; when one is hit, the first member
|
|
6
|
+
// reached is emitted without waiting on the rest, so this never hangs or
|
|
7
|
+
// throws — it just orders what it safely can.
|
|
8
|
+
export function topologicalSort(names, dependencies = {}) {
|
|
9
|
+
const nameSet = new Set(names);
|
|
10
|
+
const visited = new Set();
|
|
11
|
+
const result = [];
|
|
12
|
+
|
|
13
|
+
function visit(name, stack) {
|
|
14
|
+
if (visited.has(name) || stack.has(name)) return;
|
|
15
|
+
stack.add(name);
|
|
16
|
+
for (const dep of dependencies[name] || []) {
|
|
17
|
+
if (nameSet.has(dep)) visit(dep, stack);
|
|
18
|
+
}
|
|
19
|
+
stack.delete(name);
|
|
20
|
+
visited.add(name);
|
|
21
|
+
result.push(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const name of names) visit(name, new Set());
|
|
25
|
+
return result;
|
|
26
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { globbySync } from 'globby';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
// Classify a package's cicd output/language/tier/packageType from its package.json
|
|
5
|
+
// contents and directory. Returns null when nothing matches.
|
|
6
|
+
//
|
|
7
|
+
// pkgPath is the absolute path to the package's package.json (matching the shape
|
|
8
|
+
// generateManifest already has on hand); the package directory is derived from it.
|
|
9
|
+
export function detectCicdType(pkgName, pkg, pkgPath) {
|
|
10
|
+
if (pkg['main'] || pkg['module']) {
|
|
11
|
+
const result = { output: 'npm', language: 'JavaScript', tier: 'component' };
|
|
12
|
+
if (pkg['bin']) {
|
|
13
|
+
result.packageType = 'cli';
|
|
14
|
+
result.tier = 'tooling';
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if ((pkg['devDependencies'] && pkg['devDependencies']['moleculer']) || (pkg['dependencies'] && pkg['dependencies']['moleculer'])) {
|
|
20
|
+
return { output: 'npm', language: 'node', packageType: 'moleculer', tier: 'api' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if ((pkg['devDependencies'] && pkg['devDependencies']['react']) || (pkg['dependencies'] && pkg['dependencies']['react'])) {
|
|
24
|
+
return { output: 'npm', language: 'JavaScript', packageType: 'ux', tier: 'ux' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// not node
|
|
28
|
+
const pkgDir = path.dirname(pkgPath);
|
|
29
|
+
const csproj = globbySync('*.csproj', { cwd: pkgDir, absolute: true });
|
|
30
|
+
const toml = globbySync('pyproject.toml', { cwd: pkgDir, absolute: true });
|
|
31
|
+
const docker = globbySync('Dockerfile', { cwd: pkgDir, absolute: true });
|
|
32
|
+
|
|
33
|
+
if (csproj.length > 0) {
|
|
34
|
+
return { output: 'nuget', language: 'C#', packageType: 'dotnet', tier: 'api' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (toml.length > 0) {
|
|
38
|
+
const result = { output: 'pypi', language: 'Python', tier: 'api' };
|
|
39
|
+
if (pkgName.toLocaleLowerCase().endsWith('api')) result.packageType = 'pypi';
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (docker.length > 0 && (pkgPath.indexOf('image') >= 0 || pkgPath.indexOf('container') >= 0)) {
|
|
44
|
+
return { output: 'docker', language: 'Dockerfile', tier: 'infra' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
package/src/hashFile.mjs
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
5
|
+
import { minimatch } from 'minimatch';
|
|
6
|
+
|
|
7
|
+
// ─── hash.json file handling ─────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// Hashes are always stored in a hash.json file, keyed by full package name. When the requested --hashFile path points at a manifest.json (legacy
|
|
10
|
+
// "manifest mode"), storage is redirected to a sibling hash.json instead of
|
|
11
|
+
// writing hash data into the manifest.
|
|
12
|
+
//
|
|
13
|
+
// hash.json schema versions. 1 = legacy (flat map of name -> {hash, version,
|
|
14
|
+
// fullName, packageFolder}, no wrapper). 2 = current (name -> hash string,
|
|
15
|
+
// nested under a "hash" property, alongside a top-level "version"). Every
|
|
16
|
+
// write stamps HASH_FILE_VERSION; version 1 is only ever inferred on read,
|
|
17
|
+
// for files that predate the wrapper.
|
|
18
|
+
const HASH_FILE_VERSION = 2;
|
|
19
|
+
|
|
20
|
+
// Resolve the physical file used to store hash state, given the requested
|
|
21
|
+
// hashFile path (relative to prefixPath).
|
|
22
|
+
export function resolveHashFilePath(prefixPath, hashFile) {
|
|
23
|
+
const requestedHashFile = path.join(prefixPath, hashFile);
|
|
24
|
+
const usesManifestHashFile = hashFile.replace(/\\/g, '/').endsWith('/manifest.json');
|
|
25
|
+
const changeConfig = usesManifestHashFile
|
|
26
|
+
? path.join(path.dirname(requestedHashFile), 'hash.json')
|
|
27
|
+
: requestedHashFile;
|
|
28
|
+
return { changeConfig, usesManifestHashFile };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Create an empty hash file if it doesn't exist yet, so subsequent reads
|
|
32
|
+
// always find valid JSON. Under options.try, only logs what would happen.
|
|
33
|
+
export function ensureHashFileExists(changeConfig, options = {}) {
|
|
34
|
+
if (existsSync(changeConfig)) return;
|
|
35
|
+
if (options.try) {
|
|
36
|
+
console.log('\x1b[36m%s\x1b[0m', `[try] Hash file ${changeConfig} not found — treating previous state as empty`);
|
|
37
|
+
} else {
|
|
38
|
+
writeFileSync(changeConfig, "{}", "utf8");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load previously-recorded hashes, keyed by full package name, as
|
|
43
|
+
// { name: { hash } }. Understands both the current format (hash values under
|
|
44
|
+
// a "hash" property) and the legacy flat format, so callers never need to
|
|
45
|
+
// know which one is on disk.
|
|
46
|
+
export function loadHashFile(changeConfig) {
|
|
47
|
+
const data = existsSync(changeConfig) ? readFileSync(changeConfig, "utf8") : "{}";
|
|
48
|
+
const raw = JSON.parse(data || "{}");
|
|
49
|
+
|
|
50
|
+
if (raw.hash && typeof raw.hash === 'object') {
|
|
51
|
+
return Object.fromEntries(Object.entries(raw.hash).map(([name, hash]) => [name, { hash }]));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Legacy format: { name: { hash, version?, fullName?, packageFolder? }, ... }
|
|
55
|
+
const { version, ...legacyEntries } = raw;
|
|
56
|
+
return Object.fromEntries(
|
|
57
|
+
Object.entries(legacyEntries).map(([name, entry]) => [name, { hash: entry && entry.hash }])
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Persist current hashes. Every write stamps the current schema version and
|
|
62
|
+
// nests hash values (name -> hash string) under a "hash" property. In
|
|
63
|
+
// manifest mode, only the touched packages' hashes are updated — every other
|
|
64
|
+
// package already on disk is left alone. Otherwise, the file is fully
|
|
65
|
+
// replaced with just the sorted current hash map.
|
|
66
|
+
export function writeHashFile(changeConfig, current, { usesManifestHashFile }) {
|
|
67
|
+
let hashes;
|
|
68
|
+
if (usesManifestHashFile) {
|
|
69
|
+
const existing = loadHashFile(changeConfig);
|
|
70
|
+
Object.keys(current).forEach(name => {
|
|
71
|
+
existing[name] = { hash: current[name].hash };
|
|
72
|
+
});
|
|
73
|
+
hashes = Object.fromEntries(Object.entries(existing).map(([name, entry]) => [name, entry.hash]));
|
|
74
|
+
} else {
|
|
75
|
+
hashes = Object.fromEntries(Object.keys(current).sort().map(name => [name, current[name].hash]));
|
|
76
|
+
}
|
|
77
|
+
writeFileSync(changeConfig, JSON.stringify({ version: HASH_FILE_VERSION, hash: hashes }, null, 2), "utf8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Recursive folder hashing ────────────────────────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// Hashes a file/folder tree, honoring include/exclude rules per file, folder,
|
|
83
|
+
// and symbolic link. Adapted from the "folder-hash" package.
|
|
84
|
+
|
|
85
|
+
const defaultOptions = {
|
|
86
|
+
algo: 'sha1', // see crypto.getHashes() for options
|
|
87
|
+
encoding: 'base64', // 'base64', 'base64url', 'hex' or 'binary'
|
|
88
|
+
files: {
|
|
89
|
+
exclude: [],
|
|
90
|
+
include: [],
|
|
91
|
+
matchBasename: true,
|
|
92
|
+
matchPath: false,
|
|
93
|
+
ignoreBasename: false,
|
|
94
|
+
ignoreRootName: false,
|
|
95
|
+
},
|
|
96
|
+
folders: {
|
|
97
|
+
exclude: [],
|
|
98
|
+
include: [],
|
|
99
|
+
matchBasename: true,
|
|
100
|
+
matchPath: false,
|
|
101
|
+
ignoreBasename: false,
|
|
102
|
+
ignoreRootName: false,
|
|
103
|
+
},
|
|
104
|
+
symbolicLinks: {
|
|
105
|
+
include: true,
|
|
106
|
+
ignoreBasename: false,
|
|
107
|
+
ignoreTargetPath: true,
|
|
108
|
+
ignoreTargetContent: false,
|
|
109
|
+
ignoreTargetContentAfterError: false,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const debug = function(txt) {
|
|
114
|
+
return params => {
|
|
115
|
+
console.log(txt, params);
|
|
116
|
+
return params;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Use the environment variable DEBUG to log output, e.g. `set DEBUG=fhash:*`
|
|
121
|
+
const log = {
|
|
122
|
+
match: function() {},
|
|
123
|
+
params: function(params) {return params},
|
|
124
|
+
err: debug('fhash:err'),
|
|
125
|
+
symlink: debug('fhash:symlink'),
|
|
126
|
+
queue: function() {}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function prep(fs) {
|
|
130
|
+
let queue = [];
|
|
131
|
+
let queueTimer = undefined;
|
|
132
|
+
|
|
133
|
+
function hashElement(name, dir, options, callback) {
|
|
134
|
+
callback = arguments[arguments.length - 1];
|
|
135
|
+
|
|
136
|
+
return parseParameters(arguments)
|
|
137
|
+
.then(({ basename, dir, options }) => {
|
|
138
|
+
// this is only used for the root level
|
|
139
|
+
options.skipMatching = true;
|
|
140
|
+
return fs.promises
|
|
141
|
+
.lstat(path.join(dir, basename))
|
|
142
|
+
.then(stats => {
|
|
143
|
+
stats.name = basename;
|
|
144
|
+
return stats;
|
|
145
|
+
})
|
|
146
|
+
.then(stats => hashElementPromise(stats, dir, options, true));
|
|
147
|
+
})
|
|
148
|
+
.then(result => {
|
|
149
|
+
if (isFunction(callback)) {
|
|
150
|
+
return callback(undefined, result);
|
|
151
|
+
} else {
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.catch(reason => {
|
|
156
|
+
log.err('Fatal error:', reason);
|
|
157
|
+
if (isFunction(callback)) {
|
|
158
|
+
return callback(reason);
|
|
159
|
+
} else {
|
|
160
|
+
throw reason;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {fs.Stats} stats folder element, can also be of type fs.Dirent
|
|
167
|
+
* @param {string} dirname
|
|
168
|
+
* @param {Options} options
|
|
169
|
+
* @param {boolean} isRootElement
|
|
170
|
+
*/
|
|
171
|
+
function hashElementPromise(stats, dirname, options, isRootElement = false) {
|
|
172
|
+
const name = stats.name;
|
|
173
|
+
let promise = undefined;
|
|
174
|
+
if (stats.isDirectory()) {
|
|
175
|
+
promise = hashFolderPromise(name, dirname, options, isRootElement);
|
|
176
|
+
} else if (stats.isFile()) {
|
|
177
|
+
promise = hashFilePromise(name, dirname, options, isRootElement);
|
|
178
|
+
} else if (stats.isSymbolicLink()) {
|
|
179
|
+
promise = hashSymLinkPromise(name, dirname, options, isRootElement);
|
|
180
|
+
} else {
|
|
181
|
+
log.err('hashElementPromise cannot handle ', stats);
|
|
182
|
+
return Promise.resolve({ name, hash: 'Error: unknown element type' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return promise.catch(err => {
|
|
186
|
+
if (err.code && (err.code === 'EMFILE' || err.code === 'ENFILE')) {
|
|
187
|
+
log.queue(`queued ${dirname}/${name} because of ${err.code}`);
|
|
188
|
+
|
|
189
|
+
const promise = new Promise((resolve, reject) => {
|
|
190
|
+
queue.push(() => {
|
|
191
|
+
log.queue(`Will processs queued ${dirname}/${name}`);
|
|
192
|
+
return hashElementPromise(stats, dirname, options, isRootElement)
|
|
193
|
+
.then(ok => resolve(ok))
|
|
194
|
+
.catch(err => reject(err));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (queueTimer === undefined) {
|
|
199
|
+
queueTimer = setTimeout(processQueue, 0);
|
|
200
|
+
}
|
|
201
|
+
return promise;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
throw err;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function processQueue() {
|
|
209
|
+
queueTimer = undefined;
|
|
210
|
+
const runnables = queue;
|
|
211
|
+
queue = [];
|
|
212
|
+
runnables.forEach(run => run());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function hashFolderPromise(name, dir, options, isRootElement = false) {
|
|
216
|
+
const folderPath = path.join(dir, name);
|
|
217
|
+
let ignoreBasenameOnce = options.ignoreBasenameOnce;
|
|
218
|
+
delete options.ignoreBasenameOnce;
|
|
219
|
+
|
|
220
|
+
if (options.skipMatching) {
|
|
221
|
+
// this is currently only used for the root folder
|
|
222
|
+
log.match(`skipped '${folderPath}'`);
|
|
223
|
+
delete options.skipMatching;
|
|
224
|
+
} else if (ignore(name, folderPath, options.folders)) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const files = await fs.promises.readdir(folderPath, { withFileTypes: true });
|
|
229
|
+
const children = await Promise.all(
|
|
230
|
+
files
|
|
231
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
232
|
+
.map(child => hashElementPromise(child, folderPath, options)),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (ignoreBasenameOnce) options.ignoreBasenameOnce = true;
|
|
236
|
+
const hash = new HashedFolder(name, children.filter(notUndefined), options, isRootElement);
|
|
237
|
+
return hash;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hashFilePromise(name, dir, options, isRootElement = false) {
|
|
241
|
+
const filePath = path.join(dir, name);
|
|
242
|
+
|
|
243
|
+
if (options.skipMatching) {
|
|
244
|
+
// this is currently only used for the root folder
|
|
245
|
+
log.match(`skipped '${filePath}'`);
|
|
246
|
+
delete options.skipMatching;
|
|
247
|
+
} else if (ignore(name, filePath, options.files)) {
|
|
248
|
+
return Promise.resolve(undefined);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
try {
|
|
253
|
+
const hash = crypto.createHash(options.algo);
|
|
254
|
+
if (
|
|
255
|
+
options.files.ignoreBasename ||
|
|
256
|
+
options.ignoreBasenameOnce ||
|
|
257
|
+
(isRootElement && options.files.ignoreRootName)
|
|
258
|
+
) {
|
|
259
|
+
delete options.ignoreBasenameOnce;
|
|
260
|
+
log.match(`omitted name of ${filePath} from hash`);
|
|
261
|
+
} else {
|
|
262
|
+
hash.update(name);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const f = fs.createReadStream(filePath);
|
|
266
|
+
f.on('error', err => {
|
|
267
|
+
reject(err);
|
|
268
|
+
});
|
|
269
|
+
f.pipe(hash, { end: false });
|
|
270
|
+
|
|
271
|
+
f.on('end', () => {
|
|
272
|
+
const hashedFile = new HashedFile(name, hash, options.encoding);
|
|
273
|
+
return resolve(hashedFile);
|
|
274
|
+
});
|
|
275
|
+
} catch (ex) {
|
|
276
|
+
return reject(ex);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function hashSymLinkPromise(name, dir, options, isRootElement = false) {
|
|
282
|
+
const target = await fs.promises.readlink(path.join(dir, name));
|
|
283
|
+
log.symlink(`handling symbolic link ${name} -> ${target}`);
|
|
284
|
+
if (options.symbolicLinks.include) {
|
|
285
|
+
if (options.symbolicLinks.ignoreTargetContent) {
|
|
286
|
+
return symLinkIgnoreTargetContent(name, target, options, isRootElement);
|
|
287
|
+
} else {
|
|
288
|
+
return symLinkResolve(name, dir, target, options, isRootElement);
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
log.symlink('skipping symbolic link');
|
|
292
|
+
return Promise.resolve(undefined);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function symLinkIgnoreTargetContent(name, target, options, isRootElement) {
|
|
297
|
+
delete options.skipMatching; // only used for the root level
|
|
298
|
+
log.symlink('ignoring symbolic link target content');
|
|
299
|
+
const hash = crypto.createHash(options.algo);
|
|
300
|
+
if (!options.symbolicLinks.ignoreBasename && !(isRootElement && options.files.ignoreRootName)) {
|
|
301
|
+
log.symlink('hash basename');
|
|
302
|
+
hash.update(name);
|
|
303
|
+
}
|
|
304
|
+
if (!options.symbolicLinks.ignoreTargetPath) {
|
|
305
|
+
log.symlink('hash targetpath');
|
|
306
|
+
hash.update(target);
|
|
307
|
+
}
|
|
308
|
+
return Promise.resolve(new HashedFile(name, hash, options.encoding));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function symLinkResolve(name, dir, target, options, isRootElement) {
|
|
312
|
+
delete options.skipMatching; // only used for the root level
|
|
313
|
+
if (options.symbolicLinks.ignoreBasename) {
|
|
314
|
+
options.ignoreBasenameOnce = true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const stats = await fs.promises.stat(path.join(dir, name));
|
|
319
|
+
stats.name = name;
|
|
320
|
+
const temp = await hashElementPromise(stats, dir, options, isRootElement);
|
|
321
|
+
|
|
322
|
+
if (!options.symbolicLinks.ignoreTargetPath) {
|
|
323
|
+
const hash = crypto.createHash(options.algo);
|
|
324
|
+
hash.update(temp.hash);
|
|
325
|
+
log.symlink('hash targetpath');
|
|
326
|
+
hash.update(target);
|
|
327
|
+
temp.hash = hash.digest(options.encoding);
|
|
328
|
+
}
|
|
329
|
+
return temp;
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (options.symbolicLinks.ignoreTargetContentAfterError) {
|
|
332
|
+
log.symlink(`Ignoring error "${err.code}" when hashing symbolic link ${name}`, err);
|
|
333
|
+
const hash = crypto.createHash(options.algo);
|
|
334
|
+
if (
|
|
335
|
+
!options.symbolicLinks.ignoreBasename &&
|
|
336
|
+
!(isRootElement && options.files.ignoreRootName)
|
|
337
|
+
) {
|
|
338
|
+
hash.update(name);
|
|
339
|
+
}
|
|
340
|
+
if (!options.symbolicLinks.ignoreTargetPath) {
|
|
341
|
+
hash.update(target);
|
|
342
|
+
}
|
|
343
|
+
return new HashedFile(name, hash, options.encoding);
|
|
344
|
+
} else {
|
|
345
|
+
log.symlink(`Error "${err.code}": When hashing symbolic link ${name}`, err);
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ignore(name, path, rules) {
|
|
352
|
+
if (rules.exclude) {
|
|
353
|
+
if (rules.matchBasename && rules.exclude(name)) {
|
|
354
|
+
log.match(`exclude basename '${name}'`);
|
|
355
|
+
return true;
|
|
356
|
+
} else if (rules.matchPath && rules.exclude(path)) {
|
|
357
|
+
log.match(`exclude path '${path}'`);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (rules.include) {
|
|
362
|
+
if (rules.matchBasename && rules.include(name)) {
|
|
363
|
+
log.match(`include basename '${name}'`);
|
|
364
|
+
return false;
|
|
365
|
+
} else if (rules.matchPath && rules.include(path)) {
|
|
366
|
+
log.match(`include path '${path}'`);
|
|
367
|
+
return false;
|
|
368
|
+
} else {
|
|
369
|
+
log.match(`include rule failed for path '${path}'`);
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
log.match(`Will not ignore unmatched '${path}'`);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return hashElement;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function parseParameters(args) {
|
|
382
|
+
let basename = args[0],
|
|
383
|
+
dir = args[1],
|
|
384
|
+
options_ = args[2];
|
|
385
|
+
|
|
386
|
+
if (!isString(basename)) {
|
|
387
|
+
return Promise.reject(new TypeError('First argument must be a string'));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!isString(dir)) {
|
|
391
|
+
dir = path.dirname(basename);
|
|
392
|
+
basename = path.basename(basename);
|
|
393
|
+
options_ = args[1];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// parse options (fallback default options)
|
|
397
|
+
if (!isObject(options_)) options_ = {};
|
|
398
|
+
const options = {
|
|
399
|
+
algo: options_.algo || defaultOptions.algo,
|
|
400
|
+
encoding: options_.encoding || defaultOptions.encoding,
|
|
401
|
+
files: Object.assign({}, defaultOptions.files, options_.files),
|
|
402
|
+
folders: Object.assign({}, defaultOptions.folders, options_.folders),
|
|
403
|
+
match: Object.assign({}, defaultOptions.match, options_.match),
|
|
404
|
+
symbolicLinks: Object.assign({}, defaultOptions.symbolicLinks, options_.symbolicLinks),
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// transform match globs to Regex
|
|
408
|
+
options.files.exclude = reduceGlobPatterns(options.files.exclude);
|
|
409
|
+
options.files.include = reduceGlobPatterns(options.files.include);
|
|
410
|
+
options.folders.exclude = reduceGlobPatterns(options.folders.exclude);
|
|
411
|
+
options.folders.include = reduceGlobPatterns(options.folders.include);
|
|
412
|
+
|
|
413
|
+
return Promise.resolve(log.params({ basename, dir, options }));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const HashedFolder = function HashedFolder(name, children, options, isRootElement = false) {
|
|
417
|
+
this.name = name;
|
|
418
|
+
this.children = children;
|
|
419
|
+
|
|
420
|
+
const hash = crypto.createHash(options.algo);
|
|
421
|
+
if (
|
|
422
|
+
options.folders.ignoreBasename ||
|
|
423
|
+
options.ignoreBasenameOnce ||
|
|
424
|
+
(isRootElement && options.folders.ignoreRootName)
|
|
425
|
+
) {
|
|
426
|
+
delete options.ignoreBasenameOnce;
|
|
427
|
+
log.match(`omitted name of folder ${name} from hash`);
|
|
428
|
+
} else {
|
|
429
|
+
hash.update(name);
|
|
430
|
+
}
|
|
431
|
+
children.forEach(child => {
|
|
432
|
+
if (child.hash) {
|
|
433
|
+
hash.update(child.hash);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
this.hash = hash.digest(options.encoding);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
HashedFolder.prototype.toString = function (padding = '') {
|
|
441
|
+
const first = `${padding}{ name: '${this.name}', hash: '${this.hash}',\n`;
|
|
442
|
+
padding += ' ';
|
|
443
|
+
|
|
444
|
+
return `${first}${padding}children: ${this.childrenToString(padding)}}`;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
HashedFolder.prototype.childrenToString = function (padding = '') {
|
|
448
|
+
if (this.children.length === 0) {
|
|
449
|
+
return '[]';
|
|
450
|
+
} else {
|
|
451
|
+
const nextPadding = padding + ' ';
|
|
452
|
+
const children = this.children.map(child => child.toString(nextPadding)).join('\n');
|
|
453
|
+
return `[\n${children}\n${padding}]`;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const HashedFile = function HashedFile(name, hash, encoding) {
|
|
458
|
+
this.name = name;
|
|
459
|
+
this.hash = hash.digest(encoding);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
HashedFile.prototype.toString = function (padding = '') {
|
|
463
|
+
return padding + "{ name: '" + this.name + "', hash: '" + this.hash + "' }";
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
function isFunction(any) {
|
|
467
|
+
return typeof any === 'function';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isString(str) {
|
|
471
|
+
return typeof str === 'string' || str instanceof String;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isObject(obj) {
|
|
475
|
+
return obj !== null && typeof obj === 'object';
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function notUndefined(obj) {
|
|
479
|
+
return typeof obj !== 'undefined';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function reduceGlobPatterns(globs) {
|
|
483
|
+
if (isFunction(globs)) {
|
|
484
|
+
return globs;
|
|
485
|
+
} else if (!globs || !Array.isArray(globs) || globs.length === 0) {
|
|
486
|
+
return undefined;
|
|
487
|
+
} else {
|
|
488
|
+
// combine globs into one single RegEx
|
|
489
|
+
const regex = new RegExp(
|
|
490
|
+
globs
|
|
491
|
+
.reduce((acc, exclude) => {
|
|
492
|
+
return acc + '|' + minimatch.makeRe(exclude).source;
|
|
493
|
+
}, '')
|
|
494
|
+
.substr(1),
|
|
495
|
+
);
|
|
496
|
+
return param => regex.test(param);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export const defaults = defaultOptions;
|
|
501
|
+
export const hashElement = prep(fs);
|