@proteinjs/reflection-build 2.0.1 → 2.0.3
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/CHANGELOG.md +16 -0
- package/package.json +5 -5
- package/test/examples/source-repository/a/node_modules/.package-lock.json +13 -13
- package/test/examples/source-repository/a/node_modules/@proteinjs/util/CHANGELOG.md +8 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util/LICENSE +21 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util/package.json +3 -2
- package/test/examples/source-repository/a/node_modules/@proteinjs/util/tsconfig.json +1 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/CHANGELOG.md +63 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/LICENSE +21 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.d.ts +1 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.js +1 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/index.js.map +1 -1
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js +7 -4
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js.map +1 -1
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/Fs.js +16 -12
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/Fs.js.map +1 -1
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +59 -13
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +119 -80
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.d.ts +91 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js +608 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js.map +1 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/dist/src/promisifiedFs.d.ts +0 -1
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/index.ts +1 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/package.json +4 -3
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/ArgsMap.ts +7 -4
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/Fs.ts +12 -9
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/PackageUtil.ts +92 -38
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/src/WorkspaceDeclaration.ts +340 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/ArgsMap.test.ts +35 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/Fs.getFilePathsMatchingGlob.test.ts +70 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/PackageUtil.getLocalPackageMap.test.ts +107 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/PackageUtil.pathKeyedIdentity.test.ts +169 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/test/WorkspaceDeclaration.test.ts +174 -0
- package/test/examples/source-repository/a/node_modules/@proteinjs/util-node/tsconfig.json +1 -0
- package/test/examples/source-repository/a/package-lock.json +16 -16
- package/test/examples/source-repository/a/package.json +3 -3
- package/test/examples/source-repository/b/node_modules/.package-lock.json +10 -10
- package/test/examples/source-repository/b/node_modules/@proteinjs/util/CHANGELOG.md +8 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util/LICENSE +21 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util/package.json +3 -2
- package/test/examples/source-repository/b/node_modules/@proteinjs/util/tsconfig.json +1 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/CHANGELOG.md +63 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/LICENSE +21 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.d.ts +1 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.js +1 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/index.js.map +1 -1
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js +7 -4
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/ArgsMap.js.map +1 -1
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/Fs.js +16 -12
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/Fs.js.map +1 -1
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.d.ts +59 -13
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js +119 -80
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/PackageUtil.js.map +1 -1
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.d.ts +91 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js +608 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/WorkspaceDeclaration.js.map +1 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/dist/src/promisifiedFs.d.ts +0 -1
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/index.ts +1 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/package.json +4 -3
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/ArgsMap.ts +7 -4
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/Fs.ts +12 -9
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/PackageUtil.ts +92 -38
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/WorkspaceDeclaration.ts +340 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/ArgsMap.test.ts +35 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/Fs.getFilePathsMatchingGlob.test.ts +70 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/PackageUtil.getLocalPackageMap.test.ts +107 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/PackageUtil.pathKeyedIdentity.test.ts +169 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/WorkspaceDeclaration.test.ts +174 -0
- package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/tsconfig.json +1 -0
- package/test/examples/source-repository/b/package-lock.json +13 -13
- package/test/examples/source-repository/b/package.json +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteinjs/util-node",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Util libs for node",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@dagrejs/graphlib": "2.1.4",
|
|
28
|
-
"@proteinjs/util": "^1.6.
|
|
28
|
+
"@proteinjs/util": "^1.6.2",
|
|
29
29
|
"fs-extra": "11.1.1",
|
|
30
30
|
"globby": "11.0.1",
|
|
31
31
|
"typescript": "5.2.2"
|
|
@@ -42,5 +42,6 @@
|
|
|
42
42
|
"eslint-plugin-prettier": "5.1.3",
|
|
43
43
|
"jest": "^29.6.4",
|
|
44
44
|
"ts-jest": "^29.1.1"
|
|
45
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"gitHead": "6ddfa98b5143e30991703881a29d1a73cec35e85"
|
|
46
47
|
}
|
|
@@ -9,11 +9,14 @@ export const parseArgsMap = (rawArgs: string[]): ArgsMap => {
|
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// Split on the FIRST '=' only — values legitimately contain '=' (e.g. --note=port=9040)
|
|
13
|
+
// and must be preserved in full.
|
|
14
|
+
const keyValue = arg.slice(2);
|
|
15
|
+
const separatorIndex = keyValue.indexOf('=');
|
|
16
|
+
if (separatorIndex >= 0) {
|
|
17
|
+
args[keyValue.slice(0, separatorIndex)] = keyValue.slice(separatorIndex + 1);
|
|
15
18
|
} else {
|
|
16
|
-
args[keyValue
|
|
19
|
+
args[keyValue] = true;
|
|
17
20
|
}
|
|
18
21
|
});
|
|
19
22
|
return args;
|
|
@@ -104,21 +104,24 @@ export class Fs {
|
|
|
104
104
|
// @param globIgnorePatterns ie. ['**/node_modules/**', '**/dist/**'] to ignore these directories
|
|
105
105
|
// @return string[] of file paths
|
|
106
106
|
static async getFilePaths(dir: string, globIgnorePatterns: string[] = []) {
|
|
107
|
-
return await
|
|
108
|
-
ignore: [...globIgnorePatterns],
|
|
109
|
-
});
|
|
107
|
+
return await Fs.getFilePathsMatchingGlob(dir, '**/*', globIgnorePatterns);
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
// @param dirPrefix recursively search for files in this dir
|
|
113
111
|
// @param glob file matching pattern ie. **/package.json
|
|
114
112
|
// @param globIgnorePatterns ie. ['**/node_modules/**', '**/dist/**'] to ignore these directories
|
|
115
|
-
// @return string[] of file paths
|
|
113
|
+
// @return string[] of absolute file paths
|
|
116
114
|
static async getFilePathsMatchingGlob(dirPrefix: string, glob: string, globIgnorePatterns: string[] = []) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
// Match relative to the directory (`cwd`) rather than folding it into an absolute pattern.
|
|
116
|
+
// In the absolute form fast-glob matched the ignore list against the absolute entry path,
|
|
117
|
+
// and a globstar does not cross a dot-segment — so under any base path containing a
|
|
118
|
+
// dot-directory (`~/.n3xa/workspaces/<name>`, a `.scratch` estate) the ignore list matched
|
|
119
|
+
// nothing: every node_modules/dist was walked in full, workspace symlinks followed into
|
|
120
|
+
// cycles, and the caller ran out of heap on a large tree. Relative matching keeps the ignore
|
|
121
|
+
// list and the entry paths in the same frame, so the prune holds under any base path.
|
|
122
|
+
return await globby(glob, {
|
|
123
|
+
cwd: dirPrefix,
|
|
124
|
+
absolute: true,
|
|
122
125
|
ignore: [...globIgnorePatterns],
|
|
123
126
|
});
|
|
124
127
|
}
|
package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/src/PackageUtil.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as fs from 'fs/promises';
|
|
|
4
4
|
import { Graph, GraphAlgorithms } from '@proteinjs/util';
|
|
5
5
|
import { cmd } from './cmd';
|
|
6
6
|
import { Fs } from './Fs';
|
|
7
|
+
import { WorkspaceDeclaration } from './WorkspaceDeclaration';
|
|
7
8
|
|
|
8
9
|
export type Package = {
|
|
9
10
|
name: string;
|
|
@@ -23,12 +24,31 @@ export type LocalPackage = {
|
|
|
23
24
|
};
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Name-keyed resolution index: dependency declarations reference packages BY NAME, so name →
|
|
29
|
+
* package is how a declared dep resolves to a workspace member. Names are not identities —
|
|
30
|
+
* every workspace root in a metarepo is named `root` — so when several packages share a name,
|
|
31
|
+
* this map holds only one of them (deterministically: the lexicographically last package.json
|
|
32
|
+
* path wins). Enumeration and identity questions belong to `LocalPackagePathMap`.
|
|
33
|
+
*/
|
|
26
34
|
export type LocalPackageMap = {
|
|
27
35
|
[packageName: string]: LocalPackage;
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Path-keyed identity map: one entry per discovered package.json, keyed by its absolute path.
|
|
40
|
+
* This is the DISCOVERY truth — same-named packages (the metarepo root, the app root, and every
|
|
41
|
+
* nested lerna root are all named `root`) never collide here. Use it wherever the question is
|
|
42
|
+
* "which packages exist" (enumeration, symlink coverage); use `LocalPackageMap` only to resolve
|
|
43
|
+
* a dependency NAME to its workspace member.
|
|
44
|
+
*/
|
|
45
|
+
export type LocalPackagePathMap = {
|
|
46
|
+
[packageJsonPath: string]: LocalPackage;
|
|
47
|
+
};
|
|
48
|
+
|
|
30
49
|
export type WorkspaceMetadata = {
|
|
31
50
|
packageMap: LocalPackageMap;
|
|
51
|
+
packagePathMap: LocalPackagePathMap; // every discovered package, path-keyed (collision-free)
|
|
32
52
|
packageGraph: any; // @dagrejs/graphlib.Graph
|
|
33
53
|
sortedPackageNames: string[]; // local package names, in dependency order (ie. if a depends on b, [b, a] will be returned)
|
|
34
54
|
workspaceToPackageMap: { [workspacePath: string]: string[] }; // string[] is names of packages in workspace
|
|
@@ -155,23 +175,40 @@ export class PackageUtil {
|
|
|
155
175
|
}
|
|
156
176
|
|
|
157
177
|
/**
|
|
158
|
-
* Get
|
|
178
|
+
* Get the name-keyed resolution index of local packages within the repo specified by
|
|
179
|
+
* directory path. Derived from `getLocalPackagePathMap` (which owns discovery); see
|
|
180
|
+
* `LocalPackageMap` for the name-collision semantics.
|
|
159
181
|
*
|
|
160
182
|
* @param dir dir path that contains local packages
|
|
161
|
-
* @param globIgnorePatterns already includes: ['**\/node_modules/**', '**\/dist/**']
|
|
162
183
|
* @returns {[packageName: string]: LocalPackage}
|
|
163
184
|
*/
|
|
164
|
-
static async getLocalPackageMap(dir: string
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
static async getLocalPackageMap(dir: string): Promise<LocalPackageMap> {
|
|
186
|
+
return PackageUtil.toNameKeyedMap(await PackageUtil.getLocalPackagePathMap(dir));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Every member package of the workspace rooted at `dir`, keyed by package.json path — the
|
|
191
|
+
* package's IDENTITY. Same-named packages (every workspace root is named `root`) are distinct
|
|
192
|
+
* entries here; anything that enumerates workspace members must start from this map, not from
|
|
193
|
+
* the name-keyed index.
|
|
194
|
+
*
|
|
195
|
+
* Membership is DECLARED, never crawled — `WorkspaceDeclaration` is the one owner: a root's
|
|
196
|
+
* lerna.json `packages` (plus the root package.json's `workspacePackages` extra roots) names
|
|
197
|
+
* exactly its members, a declared root owns its subtree, and only a tree with no declaration
|
|
198
|
+
* at its root is walked (bounded: never into hidden directories, `node_modules`, `dist`, or
|
|
199
|
+
* through symlinks — the unbounded-glob OOM of 2026-09-02 stays fixed). A same-named
|
|
200
|
+
* package.json nobody declared (the CI fixture trees that shadowed the app's packages in
|
|
201
|
+
* n3xah/app run 33747781291) is not a member; a leaf-name collision among members is a hard
|
|
202
|
+
* error naming both paths.
|
|
203
|
+
*
|
|
204
|
+
* @param dir dir path that contains local packages
|
|
205
|
+
* @returns {[packageJsonPath: string]: LocalPackage}
|
|
206
|
+
*/
|
|
207
|
+
static async getLocalPackagePathMap(dir: string): Promise<LocalPackagePathMap> {
|
|
208
|
+
const packagePathMap: LocalPackagePathMap = {};
|
|
209
|
+
for (const { filePath, packageJson } of await WorkspaceDeclaration.discover(dir)) {
|
|
173
210
|
const name = packageJson['name'];
|
|
174
|
-
|
|
211
|
+
packagePathMap[filePath] = {
|
|
175
212
|
name,
|
|
176
213
|
filePath,
|
|
177
214
|
packageJson,
|
|
@@ -186,7 +223,7 @@ export class PackageUtil {
|
|
|
186
223
|
const workspaceLernaJson = (await Fs.exists(workspaceLernaJsonPath))
|
|
187
224
|
? JSON.parse(await Fs.readFile(workspaceLernaJsonPath))
|
|
188
225
|
: undefined;
|
|
189
|
-
|
|
226
|
+
packagePathMap[filePath].workspace = {
|
|
190
227
|
path: workspacePath,
|
|
191
228
|
rootPackageJson: workspacePackageJson,
|
|
192
229
|
lernaJson: workspaceLernaJson,
|
|
@@ -194,7 +231,7 @@ export class PackageUtil {
|
|
|
194
231
|
}
|
|
195
232
|
}
|
|
196
233
|
|
|
197
|
-
return
|
|
234
|
+
return packagePathMap;
|
|
198
235
|
}
|
|
199
236
|
|
|
200
237
|
/**
|
|
@@ -309,7 +346,8 @@ export class PackageUtil {
|
|
|
309
346
|
* @returns `WorkspaceMetadata`
|
|
310
347
|
*/
|
|
311
348
|
static async getWorkspaceMetadata(workspacePath: string): Promise<WorkspaceMetadata> {
|
|
312
|
-
const
|
|
349
|
+
const packagePathMap = await PackageUtil.getLocalPackagePathMap(workspacePath);
|
|
350
|
+
const packageMap = PackageUtil.toNameKeyedMap(packagePathMap);
|
|
313
351
|
const packageGraph = await PackageUtil.getPackageDependencyGraph(packageMap);
|
|
314
352
|
const sortedPackageNames = PackageUtil.getDependencyOrder(packageGraph).filter(
|
|
315
353
|
(packageName) => !!packageMap[packageName]
|
|
@@ -317,6 +355,7 @@ export class PackageUtil {
|
|
|
317
355
|
const workspaceToPackageMap = PackageUtil.getWorkspaceToPackageMap(packageMap);
|
|
318
356
|
return {
|
|
319
357
|
packageMap,
|
|
358
|
+
packagePathMap,
|
|
320
359
|
packageGraph,
|
|
321
360
|
sortedPackageNames,
|
|
322
361
|
workspaceToPackageMap,
|
|
@@ -359,17 +398,18 @@ export class PackageUtil {
|
|
|
359
398
|
/**
|
|
360
399
|
* Compute the transitive closure of workspace dependencies for `localPackage`.
|
|
361
400
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
401
|
+
* Traverses dependency DECLARATIONS (`dependencies` + `devDependencies`) starting from THIS
|
|
402
|
+
* package instance's own package.json, resolving each declared name through
|
|
403
|
+
* `localPackageMap` and expanding through the resolved packages' declarations in turn.
|
|
404
|
+
* Cycle safety and the visited set are PATH-KEYED (package.json paths): identity is the
|
|
405
|
+
* path, never the name. The closure is never seeded from a shared name-keyed graph node —
|
|
406
|
+
* same-named packages (every workspace root is named `root`) share such a node, so a
|
|
407
|
+
* graph-seeded closure conflates their dependencies and computes another package's closure
|
|
408
|
+
* for whichever same-named instance lost the name-keyed collision.
|
|
368
409
|
*
|
|
369
|
-
* Only names present in `localPackageMap` are returned — i.e. packages that
|
|
370
|
-
* actually live in the workspace and can be symlinked
|
|
371
|
-
*
|
|
372
|
-
* again here so the result is exactly the set of linkable packages).
|
|
410
|
+
* Only names present in `localPackageMap` are traversed and returned — i.e. packages that
|
|
411
|
+
* actually live in the workspace and can be symlinked. The package itself is excluded from
|
|
412
|
+
* the result (its own path seeds the visited set).
|
|
373
413
|
*
|
|
374
414
|
* Public: WorkspaceDoctor (@proteinjs/build) diagnoses the same closure this method links —
|
|
375
415
|
* verification and repair must agree on the set of packages that ought to be symlinked.
|
|
@@ -380,25 +420,24 @@ export class PackageUtil {
|
|
|
380
420
|
localPackage: LocalPackage,
|
|
381
421
|
localPackageMap: LocalPackageMap
|
|
382
422
|
): Promise<string[]> {
|
|
383
|
-
const graph = await PackageUtil.getPackageDependencyGraph(localPackageMap);
|
|
384
|
-
const rootPackageName = localPackage.packageJson['name'];
|
|
385
|
-
|
|
386
423
|
const transitiveDependencies = new Set<string>();
|
|
387
|
-
const
|
|
388
|
-
const stack:
|
|
424
|
+
const visitedPaths = new Set<string>([localPackage.filePath]);
|
|
425
|
+
const stack: LocalPackage[] = [localPackage];
|
|
389
426
|
while (stack.length > 0) {
|
|
390
427
|
const current = stack.pop()!;
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
428
|
+
const declared = {
|
|
429
|
+
...(current.packageJson['dependencies'] ?? {}),
|
|
430
|
+
...(current.packageJson['devDependencies'] ?? {}),
|
|
431
|
+
};
|
|
432
|
+
for (const dependencyPackageName of Object.keys(declared)) {
|
|
433
|
+
const dependencyPackage = localPackageMap[dependencyPackageName];
|
|
434
|
+
if (!dependencyPackage || visitedPaths.has(dependencyPackage.filePath)) {
|
|
394
435
|
continue;
|
|
395
436
|
}
|
|
396
437
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
transitiveDependencies.add(dependencyPackageName);
|
|
401
|
-
}
|
|
438
|
+
visitedPaths.add(dependencyPackage.filePath);
|
|
439
|
+
transitiveDependencies.add(dependencyPackageName);
|
|
440
|
+
stack.push(dependencyPackage);
|
|
402
441
|
}
|
|
403
442
|
}
|
|
404
443
|
|
|
@@ -527,4 +566,19 @@ export class PackageUtil {
|
|
|
527
566
|
}
|
|
528
567
|
}
|
|
529
568
|
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Derive the name-keyed resolution index from the path-keyed discovery map. When several
|
|
572
|
+
* packages share a name, the lexicographically last package.json path wins — deterministic,
|
|
573
|
+
* and irrelevant for real dependency names (nothing declares a dep on `root`; uniquely-named
|
|
574
|
+
* packages resolve identically either way).
|
|
575
|
+
*/
|
|
576
|
+
private static toNameKeyedMap(packagePathMap: LocalPackagePathMap): LocalPackageMap {
|
|
577
|
+
const packageMap: LocalPackageMap = {};
|
|
578
|
+
for (const filePath of Object.keys(packagePathMap).sort()) {
|
|
579
|
+
const localPackage = packagePathMap[filePath];
|
|
580
|
+
packageMap[localPackage.name] = localPackage;
|
|
581
|
+
}
|
|
582
|
+
return packageMap;
|
|
583
|
+
}
|
|
530
584
|
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
/** A declared-workspace rule was broken. The message names every path involved. */
|
|
5
|
+
export class WorkspaceDeclarationError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'WorkspaceDeclarationError';
|
|
9
|
+
Object.setPrototypeOf(this, WorkspaceDeclarationError.prototype); // ES5 target: keep instanceof true across the Error subclass
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DiscoveredPackage = {
|
|
14
|
+
/** Absolute package.json path — the package's identity. */
|
|
15
|
+
filePath: string;
|
|
16
|
+
packageJson: any;
|
|
17
|
+
/** `declared`: named by a workspace declaration; `crawled`: found by the bounded walk of an undeclared tree. */
|
|
18
|
+
origin: 'declared' | 'crawled';
|
|
19
|
+
/** The declaration file (lerna.json or the root package.json) that named a `declared` package. */
|
|
20
|
+
declaredBy?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Declaration = {
|
|
24
|
+
rootDir: string;
|
|
25
|
+
/** Each pattern with the file that declared it, in declaration order. */
|
|
26
|
+
patterns: Array<{ pattern: string; source: string }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The ONE owner of workspace membership: which package.json files belong to the workspace rooted
|
|
31
|
+
* at a directory.
|
|
32
|
+
*
|
|
33
|
+
* Membership is DECLARED, never crawled. A directory holding a `lerna.json` (its `packages`
|
|
34
|
+
* globs; lerna's own default `packages/*` when the field is absent) or a root `package.json`
|
|
35
|
+
* carrying `workspacePackages` (extra roots the workspace tooling builds alongside lerna's
|
|
36
|
+
* members — e.g. an app's `ops/*` tools that lerna must not version) is a workspace ROOT. Its
|
|
37
|
+
* members are exactly what it declares, and it OWNS its subtree: nothing undeclared beneath it is
|
|
38
|
+
* a member, however the tree is walked from above. A member that is itself a root expands through
|
|
39
|
+
* its own declaration (the metarepo's nested lerna roots).
|
|
40
|
+
*
|
|
41
|
+
* Why: the previous crawl (every package.json under the root, keyed by name, last path wins)
|
|
42
|
+
* let a CI fixture tree whose package.json files reused the app's own package names shadow the
|
|
43
|
+
* real packages — build-workspace built "1 package in workspace" and the release image shipped
|
|
44
|
+
* with no server dist (n3xah/app Deploy to Test run 33747781291, 2026-09-03). A declaration
|
|
45
|
+
* cannot be shadowed by a file nobody declared.
|
|
46
|
+
*
|
|
47
|
+
* A tree with no declaration at its root (the n3xa metarepo root) still crawls — bounded exactly
|
|
48
|
+
* as before: never into hidden directories, `node_modules`, `dist`, or through symlinks — and
|
|
49
|
+
* hands every declared root it meets to that root's declaration.
|
|
50
|
+
*
|
|
51
|
+
* Hard errors, each naming its paths: a declared literal path with no package.json; two leaf
|
|
52
|
+
* packages sharing a name (dependencies resolve to members BY NAME). Containers — a package.json
|
|
53
|
+
* with members beneath it; every root in the metarepo is named `root` — are identified by path
|
|
54
|
+
* and exempt from the name rule.
|
|
55
|
+
*
|
|
56
|
+
* npm's own `workspaces` field is deliberately NOT read: it changes how `npm i` installs at the
|
|
57
|
+
* root, which is a different install model from the per-package installs this tooling performs.
|
|
58
|
+
*/
|
|
59
|
+
export class WorkspaceDeclaration {
|
|
60
|
+
/** Root package.json field naming extra member globs beside lerna's `packages`. */
|
|
61
|
+
static readonly EXTRA_PACKAGES_FIELD = 'workspacePackages';
|
|
62
|
+
private static readonly lernaDefaultPackages = ['packages/*'];
|
|
63
|
+
private static readonly prunedDirNames = new Set(['node_modules', 'dist']);
|
|
64
|
+
|
|
65
|
+
/** Every member package.json of the workspace rooted at `rootDir`, sorted by path. */
|
|
66
|
+
static async discover(rootDir: string): Promise<DiscoveredPackage[]> {
|
|
67
|
+
const found = new Map<string, DiscoveredPackage>();
|
|
68
|
+
const root = path.resolve(rootDir);
|
|
69
|
+
const declaration = await WorkspaceDeclaration.read(root);
|
|
70
|
+
if (declaration) {
|
|
71
|
+
await WorkspaceDeclaration.discoverDeclared(declaration, found);
|
|
72
|
+
} else {
|
|
73
|
+
await WorkspaceDeclaration.crawl(root, found);
|
|
74
|
+
}
|
|
75
|
+
const discovered = Array.from(found.values()).sort((a, b) =>
|
|
76
|
+
a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0
|
|
77
|
+
);
|
|
78
|
+
WorkspaceDeclaration.assertNoLeafNameCollisions(discovered);
|
|
79
|
+
return discovered;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The declaration at `dir` when `dir` is a workspace root; undefined otherwise. */
|
|
83
|
+
static async read(dir: string): Promise<Declaration | undefined> {
|
|
84
|
+
const patterns: Declaration['patterns'] = [];
|
|
85
|
+
const lernaPath = path.join(dir, 'lerna.json');
|
|
86
|
+
const lernaJson = await WorkspaceDeclaration.readJsonIfExists(lernaPath);
|
|
87
|
+
if (lernaJson) {
|
|
88
|
+
const declared = Array.isArray(lernaJson.packages)
|
|
89
|
+
? lernaJson.packages
|
|
90
|
+
: WorkspaceDeclaration.lernaDefaultPackages;
|
|
91
|
+
for (const pattern of declared) {
|
|
92
|
+
patterns.push({ pattern: WorkspaceDeclaration.assertPattern(pattern, lernaPath), source: lernaPath });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const packageJsonPath = path.join(dir, 'package.json');
|
|
96
|
+
const packageJson = await WorkspaceDeclaration.readJsonIfExists(packageJsonPath);
|
|
97
|
+
const extra = packageJson?.[WorkspaceDeclaration.EXTRA_PACKAGES_FIELD];
|
|
98
|
+
if (extra !== undefined) {
|
|
99
|
+
if (!Array.isArray(extra)) {
|
|
100
|
+
throw new WorkspaceDeclarationError(
|
|
101
|
+
`${packageJsonPath}: "${WorkspaceDeclaration.EXTRA_PACKAGES_FIELD}" must be an array of package globs`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
for (const pattern of extra) {
|
|
105
|
+
patterns.push({
|
|
106
|
+
pattern: WorkspaceDeclaration.assertPattern(pattern, packageJsonPath),
|
|
107
|
+
source: packageJsonPath,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return patterns.length > 0 ? { rootDir: dir, patterns } : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A declared root: its own package.json (the container), then exactly its declared members. */
|
|
115
|
+
private static async discoverDeclared(declaration: Declaration, found: Map<string, DiscoveredPackage>) {
|
|
116
|
+
const own = path.join(declaration.rootDir, 'package.json');
|
|
117
|
+
if (await WorkspaceDeclaration.exists(own)) {
|
|
118
|
+
await WorkspaceDeclaration.add(found, own, 'declared', declaration.patterns[0].source);
|
|
119
|
+
}
|
|
120
|
+
for (const { pattern, source } of declaration.patterns) {
|
|
121
|
+
for (const memberDir of await WorkspaceDeclaration.expand(declaration.rootDir, pattern, source)) {
|
|
122
|
+
const nested = await WorkspaceDeclaration.read(memberDir);
|
|
123
|
+
if (nested) {
|
|
124
|
+
await WorkspaceDeclaration.discoverDeclared(nested, found); // a nested root owns its subtree
|
|
125
|
+
} else {
|
|
126
|
+
await WorkspaceDeclaration.add(found, path.join(memberDir, 'package.json'), 'declared', source);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The bounded walk of an UNDECLARED tree (the metarepo root): every package.json it meets is a
|
|
134
|
+
* member, and every declared root it meets is handed to that root's declaration instead of
|
|
135
|
+
* being walked. Never enters hidden directories, node_modules, or dist; never follows symlinks
|
|
136
|
+
* (`withFileTypes` reports a symlink as neither directory nor file).
|
|
137
|
+
*/
|
|
138
|
+
private static async crawl(rootDir: string, found: Map<string, DiscoveredPackage>) {
|
|
139
|
+
const pending: string[] = [rootDir];
|
|
140
|
+
while (pending.length > 0) {
|
|
141
|
+
const currentDir = pending.pop()!;
|
|
142
|
+
for (const dirent of await fs.readdir(currentDir, { withFileTypes: true })) {
|
|
143
|
+
const entryPath = path.join(currentDir, dirent.name);
|
|
144
|
+
if (dirent.isDirectory()) {
|
|
145
|
+
if (WorkspaceDeclaration.isPrunedDirName(dirent.name)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const declaration = await WorkspaceDeclaration.read(entryPath);
|
|
149
|
+
if (declaration) {
|
|
150
|
+
await WorkspaceDeclaration.discoverDeclared(declaration, found);
|
|
151
|
+
} else {
|
|
152
|
+
pending.push(entryPath);
|
|
153
|
+
}
|
|
154
|
+
} else if (dirent.isFile() && dirent.name === 'package.json') {
|
|
155
|
+
await WorkspaceDeclaration.add(found, entryPath, 'crawled');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The directories a declared pattern names under `rootDir`, lerna's semantics: a literal path
|
|
163
|
+
* MUST hold a package.json (hard error otherwise — a declared package that does not exist is
|
|
164
|
+
* a broken declaration, never a silent skip); a glob (`*` within a segment, `**` for any depth)
|
|
165
|
+
* matches directories that hold one, walking with the same prune rules as the crawl. A declared
|
|
166
|
+
* root met inside a glob's walk is matched as a whole and not walked past — it owns its subtree.
|
|
167
|
+
*/
|
|
168
|
+
private static async expand(rootDir: string, pattern: string, source: string): Promise<string[]> {
|
|
169
|
+
const segments = pattern.split('/').filter((segment) => segment.length > 0 && segment !== '.');
|
|
170
|
+
if (!segments.some((segment) => segment.includes('*'))) {
|
|
171
|
+
const dir = path.join(rootDir, ...segments);
|
|
172
|
+
if (!(await WorkspaceDeclaration.exists(path.join(dir, 'package.json')))) {
|
|
173
|
+
throw new WorkspaceDeclarationError(
|
|
174
|
+
`${source} declares "${pattern}" but ${path.join(dir, 'package.json')} does not exist — a declared package must exist (fix the declaration or restore the package)`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return [dir];
|
|
178
|
+
}
|
|
179
|
+
const matches = new Set<string>();
|
|
180
|
+
await WorkspaceDeclaration.matchSegments(rootDir, segments, 0, matches);
|
|
181
|
+
return Array.from(matches).sort();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private static async matchSegments(dir: string, segments: string[], index: number, matches: Set<string>) {
|
|
185
|
+
if (index === segments.length) {
|
|
186
|
+
if (await WorkspaceDeclaration.exists(path.join(dir, 'package.json'))) {
|
|
187
|
+
matches.add(dir);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const segment = segments[index];
|
|
192
|
+
const remaining = segments.length - index - 1;
|
|
193
|
+
if (segment === '**') {
|
|
194
|
+
await WorkspaceDeclaration.matchSegments(dir, segments, index + 1, matches); // zero directories
|
|
195
|
+
for (const child of await WorkspaceDeclaration.childDirs(dir)) {
|
|
196
|
+
if (await WorkspaceDeclaration.read(child)) {
|
|
197
|
+
matches.add(child); // a nested root owns its subtree — matched whole, never walked past
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
await WorkspaceDeclaration.matchSegments(child, segments, index, matches); // one more directory under **
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const children = segment.includes('*')
|
|
205
|
+
? (await WorkspaceDeclaration.childDirs(dir)).filter((child) =>
|
|
206
|
+
WorkspaceDeclaration.segmentMatcher(segment).test(path.basename(child))
|
|
207
|
+
)
|
|
208
|
+
: (await WorkspaceDeclaration.isRealDir(path.join(dir, segment)))
|
|
209
|
+
? [path.join(dir, segment)]
|
|
210
|
+
: [];
|
|
211
|
+
for (const child of children) {
|
|
212
|
+
if (remaining > 0 && (await WorkspaceDeclaration.read(child))) {
|
|
213
|
+
matches.add(child);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
await WorkspaceDeclaration.matchSegments(child, segments, index + 1, matches);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Leaf packages resolve by name; two leaves with one name is a broken workspace, not a tie to break. */
|
|
221
|
+
private static assertNoLeafNameCollisions(discovered: DiscoveredPackage[]) {
|
|
222
|
+
const dirs = discovered.map((entry) => path.dirname(entry.filePath));
|
|
223
|
+
const isContainer = (entry: DiscoveredPackage) => {
|
|
224
|
+
const prefix = path.dirname(entry.filePath) + path.sep;
|
|
225
|
+
return dirs.some((dir) => dir.startsWith(prefix));
|
|
226
|
+
};
|
|
227
|
+
const byName = new Map<string, DiscoveredPackage[]>();
|
|
228
|
+
for (const entry of discovered) {
|
|
229
|
+
const name = entry.packageJson?.name;
|
|
230
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
byName.set(name, (byName.get(name) ?? []).concat(entry));
|
|
234
|
+
}
|
|
235
|
+
const collisions: string[] = [];
|
|
236
|
+
for (const [name, entries] of Array.from(byName.entries())) {
|
|
237
|
+
const leaves = entries.filter((entry) => !isContainer(entry));
|
|
238
|
+
if (leaves.length < 2) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
collisions.push(
|
|
242
|
+
`"${name}" is claimed by\n` +
|
|
243
|
+
leaves
|
|
244
|
+
.map(
|
|
245
|
+
(entry) =>
|
|
246
|
+
` - ${entry.filePath} (${entry.origin === 'declared' ? `declared by ${entry.declaredBy}` : 'crawled — no declaration names it'})`
|
|
247
|
+
)
|
|
248
|
+
.join('\n')
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (collisions.length > 0) {
|
|
252
|
+
throw new WorkspaceDeclarationError(
|
|
253
|
+
`Workspace package name collision — dependencies resolve to workspace members BY NAME, so two packages cannot share one:\n${collisions.join('\n')}\nRename the impostor (fixtures carry fixture names) or move it outside the workspace.`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private static async add(
|
|
259
|
+
found: Map<string, DiscoveredPackage>,
|
|
260
|
+
filePath: string,
|
|
261
|
+
origin: DiscoveredPackage['origin'],
|
|
262
|
+
declaredBy?: string
|
|
263
|
+
) {
|
|
264
|
+
if (found.has(filePath)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const packageJson = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
268
|
+
found.set(filePath, { filePath, packageJson, origin, ...(declaredBy ? { declaredBy } : {}) });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private static assertPattern(pattern: unknown, source: string): string {
|
|
272
|
+
if (typeof pattern !== 'string' || pattern.trim().length === 0) {
|
|
273
|
+
throw new WorkspaceDeclarationError(
|
|
274
|
+
`${source}: package patterns must be non-empty strings, got ${JSON.stringify(pattern)}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (path.isAbsolute(pattern) || pattern.split('/').includes('..')) {
|
|
278
|
+
throw new WorkspaceDeclarationError(
|
|
279
|
+
`${source}: package pattern "${pattern}" must be relative to the root and stay inside it`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return pattern;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private static segmentMatcher(segment: string): RegExp {
|
|
286
|
+
const escaped = segment.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
|
|
287
|
+
return new RegExp(`^${escaped}$`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private static isPrunedDirName(name: string): boolean {
|
|
291
|
+
return name.startsWith('.') || WorkspaceDeclaration.prunedDirNames.has(name);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Real (non-symlink, non-pruned) child directories of `dir`. */
|
|
295
|
+
private static async childDirs(dir: string): Promise<string[]> {
|
|
296
|
+
let dirents;
|
|
297
|
+
try {
|
|
298
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
299
|
+
} catch {
|
|
300
|
+
return [];
|
|
301
|
+
}
|
|
302
|
+
return dirents
|
|
303
|
+
.filter((dirent) => dirent.isDirectory() && !WorkspaceDeclaration.isPrunedDirName(dirent.name))
|
|
304
|
+
.map((dirent) => path.join(dir, dirent.name))
|
|
305
|
+
.sort();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private static async isRealDir(p: string): Promise<boolean> {
|
|
309
|
+
try {
|
|
310
|
+
return (await fs.lstat(p)).isDirectory();
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private static async exists(p: string): Promise<boolean> {
|
|
317
|
+
try {
|
|
318
|
+
await fs.access(p);
|
|
319
|
+
return true;
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private static async readJsonIfExists(p: string): Promise<any | undefined> {
|
|
326
|
+
let text: string;
|
|
327
|
+
try {
|
|
328
|
+
text = await fs.readFile(p, 'utf8');
|
|
329
|
+
} catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
return JSON.parse(text);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw new WorkspaceDeclarationError(
|
|
336
|
+
`${p} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
package/test/examples/source-repository/b/node_modules/@proteinjs/util-node/test/ArgsMap.test.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { parseArgsMap } from '../src/ArgsMap';
|
|
2
|
+
|
|
3
|
+
describe('parseArgsMap', () => {
|
|
4
|
+
test('preserves the full value when it contains embedded = characters', () => {
|
|
5
|
+
// The estate CLI's --note values regularly carry '=' (e.g. env assignments, key=value notes).
|
|
6
|
+
// The parser must split on the FIRST '=' only and keep the remainder intact.
|
|
7
|
+
const args = parseArgsMap(['--note=emulator=spanner-x port=9040']);
|
|
8
|
+
expect(args['note']).toEqual('emulator=spanner-x port=9040');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('parses a simple key=value pair', () => {
|
|
12
|
+
const args = parseArgsMap(['--name=my-estate']);
|
|
13
|
+
expect(args['name']).toEqual('my-estate');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('parses a bare flag as boolean true', () => {
|
|
17
|
+
const args = parseArgsMap(['--pin']);
|
|
18
|
+
expect(args['pin']).toEqual(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parses an explicitly empty value as the empty string', () => {
|
|
22
|
+
const args = parseArgsMap(['--note=']);
|
|
23
|
+
expect(args['note']).toEqual('');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('ignores args that do not start with --', () => {
|
|
27
|
+
const args = parseArgsMap(['positional', '-x', '--keep=yes']);
|
|
28
|
+
expect(args).toEqual({ keep: 'yes' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('parses multiple args together, preserving each full value', () => {
|
|
32
|
+
const args = parseArgsMap(['--containers=spanner-x', '--pids=123', '--pin', '--note=a=b=c']);
|
|
33
|
+
expect(args).toEqual({ containers: 'spanner-x', pids: '123', pin: true, note: 'a=b=c' });
|
|
34
|
+
});
|
|
35
|
+
});
|