@stealthscale/tool-workspace 0.1.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 +53 -0
- package/dist/index.d.mts +185 -0
- package/dist/index.mjs +286 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @stealthscale/tool-workspace
|
|
2
|
+
|
|
3
|
+
A **kit**: another repository takes it at development time.
|
|
4
|
+
|
|
5
|
+
Reads a workspace off its manifests. It answers which packages the root names, what each one
|
|
6
|
+
declares, and the order their dependencies put them in, so a release, a guard and a Storybook
|
|
7
|
+
all work from one reading of the tree rather than a list somebody keeps in step.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { dependencyClosure, workspaceManifests, workspaceRoot } from '@stealthscale/tool-workspace'
|
|
11
|
+
|
|
12
|
+
const root = workspaceRoot(process.cwd())
|
|
13
|
+
const manifests = workspaceManifests(root)
|
|
14
|
+
const { missing, ordered } = dependencyClosure(['@acme/web'], manifests)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`workspaceRoot` walks up until a manifest names workspaces; `packageRoot` walks up until a
|
|
18
|
+
manifest names anything, which is how a module reads its own package's version whatever depth
|
|
19
|
+
it was emitted at. `workspacePatterns` and `expandWorkspacePattern` turn the root's globs into
|
|
20
|
+
directories, and `readManifest` narrows one manifest to the fields the toolchain reads.
|
|
21
|
+
`dependencyClosure` orders a set of roots so a list walked front to back never meets a package
|
|
22
|
+
before what it needs. `Manifest.exports` carries the exports map as written, so a consumer
|
|
23
|
+
that needs one entry of a package reads it off the manifest rather than resolving the package
|
|
24
|
+
by name, which only works from a directory that depends on it.
|
|
25
|
+
|
|
26
|
+
## What a package registers
|
|
27
|
+
|
|
28
|
+
`Manifest.contributions` carries a package's `stealth` field exactly as written, unread. A
|
|
29
|
+
consumer reads one kind of contribution out of it by naming the key and handing over the
|
|
30
|
+
schema for what sits under that key:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { contributions, workspaceManifests } from '@stealthscale/tool-workspace'
|
|
34
|
+
import { THEME_CONTRIBUTION, THEME_KEY } from '@stealthscale/core-theme'
|
|
35
|
+
|
|
36
|
+
const read = contributions(workspaceManifests(root), THEME_KEY, THEME_CONTRIBUTION)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Every package that registered that kind comes back in workspace order, each beside the
|
|
40
|
+
manifest that declared it, so a relative path inside the contribution resolves against the
|
|
41
|
+
package it came from. A field that does not fit the schema is a refusal rather than a throw,
|
|
42
|
+
and every malformed package is reported at once, each issue pointing at
|
|
43
|
+
`@scope/pkg.stealth.theme.title`.
|
|
44
|
+
|
|
45
|
+
This package knows the field exists and nothing about what a package may put in it. The words
|
|
46
|
+
a theme, an appearance or a set of messages is declared with belong to whatever owns those
|
|
47
|
+
words, and adding a kind of contribution changes nothing here.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
bun add -d @stealthscale/tool-workspace
|
|
53
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Collected } from "@stealthscale/core-result";
|
|
2
|
+
import { FieldIssue, SchemaOf } from "@stealthscale/core-schema";
|
|
3
|
+
//#region src/manifests.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* @fileoverview Reads the manifests of a workspace: which packages the root names, what each
|
|
6
|
+
* declares, and the order their dependencies put them in. The `stealth` field comes back
|
|
7
|
+
* unread: this reader carries what a package registers with the toolchain without knowing
|
|
8
|
+
* what any of it means, and the consumer that owns those words holds the field to a schema.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Describes what the tool reads off a package's manifest.
|
|
12
|
+
*/
|
|
13
|
+
interface Manifest {
|
|
14
|
+
/**
|
|
15
|
+
* Carries `publishConfig.access`, when the manifest states one. Npm reads it from the
|
|
16
|
+
* tarball, so the tool never passes it.
|
|
17
|
+
*/
|
|
18
|
+
access: string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Maps each command the package installs to the file that runs it. The string form of
|
|
21
|
+
* `bin` names one command after the package, which is what npm does with it.
|
|
22
|
+
*/
|
|
23
|
+
bin: Readonly<Record<string, string>>;
|
|
24
|
+
/**
|
|
25
|
+
* Carries the `build` script, when the package has one.
|
|
26
|
+
*/
|
|
27
|
+
build: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* Carries the `stealth` field as it is written, unread. It is what the package registers
|
|
30
|
+
* with the toolchain, and the consumer that owns those words parses it. `undefined` means
|
|
31
|
+
* the package registers nothing.
|
|
32
|
+
*/
|
|
33
|
+
contributions: unknown;
|
|
34
|
+
/**
|
|
35
|
+
* Maps each dependency to its range. A tarball's manifest makes a consumer install these.
|
|
36
|
+
*/
|
|
37
|
+
dependencies: Readonly<Record<string, string>>;
|
|
38
|
+
/**
|
|
39
|
+
* Carries the one line that says what the package is for, when the manifest states it.
|
|
40
|
+
*/
|
|
41
|
+
description: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Names the package's directory, absolute.
|
|
44
|
+
*/
|
|
45
|
+
directory: string;
|
|
46
|
+
/**
|
|
47
|
+
* Carries the `exports` field as it is written, unread. A consumer that needs one entry
|
|
48
|
+
* reads it from here rather than resolving the package by name, which only works from a
|
|
49
|
+
* directory that depends on it. `undefined` means the manifest declares none.
|
|
50
|
+
*/
|
|
51
|
+
exports: unknown;
|
|
52
|
+
/**
|
|
53
|
+
* Lists the `files` a tarball carries, when the manifest declares them.
|
|
54
|
+
*/
|
|
55
|
+
files: readonly string[] | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Names the package.
|
|
58
|
+
*/
|
|
59
|
+
name: string;
|
|
60
|
+
/**
|
|
61
|
+
* Marks a package that refuses to be published.
|
|
62
|
+
*/
|
|
63
|
+
private: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Carries `publishConfig.registry`, when the manifest states one.
|
|
66
|
+
*/
|
|
67
|
+
registry: string | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Carries the version.
|
|
70
|
+
*/
|
|
71
|
+
version: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Describes what the closure of some roots came to.
|
|
75
|
+
*/
|
|
76
|
+
interface Closure {
|
|
77
|
+
/**
|
|
78
|
+
* Lists the roots that are not packages in the workspace.
|
|
79
|
+
*/
|
|
80
|
+
missing: string[];
|
|
81
|
+
/**
|
|
82
|
+
* Lists every package reachable from a root, dependencies before their dependents.
|
|
83
|
+
*/
|
|
84
|
+
ordered: Manifest[];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Finds the workspace root: the nearest directory, from `start` upwards, whose manifest
|
|
88
|
+
* names workspaces.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} start - The directory to start from, absolute.
|
|
91
|
+
* @returns {string} The root, absolute.
|
|
92
|
+
* @throws {Error} When no manifest above `start` names workspaces.
|
|
93
|
+
*/
|
|
94
|
+
declare function workspaceRoot(start: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Finds the package a file belongs to: the nearest directory, from `start` upwards, that
|
|
97
|
+
* holds a manifest. A module reads its own package's version this way, whatever depth the
|
|
98
|
+
* build emitted it at.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} start - The directory to start from, absolute.
|
|
101
|
+
* @returns {string} The package's directory, absolute.
|
|
102
|
+
* @throws {Error} When no directory above `start` holds a manifest.
|
|
103
|
+
*/
|
|
104
|
+
declare function packageRoot(start: string): string;
|
|
105
|
+
/**
|
|
106
|
+
* Reads the workspace patterns the root manifest names, in either form bun accepts.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} root - The directory whose manifest names them.
|
|
109
|
+
* @returns {readonly string[]} The patterns. Empty when the manifest names no workspaces.
|
|
110
|
+
*/
|
|
111
|
+
declare function workspacePatterns(root: string): readonly string[];
|
|
112
|
+
/**
|
|
113
|
+
* Expands a workspace pattern into the directories it names. A segment is a literal or `*`,
|
|
114
|
+
* which stands for every subdirectory. A literal that does not exist is still returned, so
|
|
115
|
+
* the caller decides what a missing directory means.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} root - The directory the pattern is relative to.
|
|
118
|
+
* @param {string} pattern - The pattern: `core/*`, `plugins/*\/*`, `lone`.
|
|
119
|
+
* @returns {string[]} The directories, absolute.
|
|
120
|
+
*/
|
|
121
|
+
declare function expandWorkspacePattern(root: string, pattern: string): string[];
|
|
122
|
+
/**
|
|
123
|
+
* Reads a package's manifest from its directory.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} directory - The package's directory, absolute.
|
|
126
|
+
* @returns {Manifest} The fields the tool reads, with the directory beside them.
|
|
127
|
+
* @throws {Error} When the manifest has no name or no version. Nothing can be packed from it.
|
|
128
|
+
*/
|
|
129
|
+
declare function readManifest(directory: string): Manifest;
|
|
130
|
+
/**
|
|
131
|
+
* Lists every package in the workspace: each directory a pattern names that holds a
|
|
132
|
+
* manifest.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} root - The directory whose manifest names the patterns.
|
|
135
|
+
* @returns {Manifest[]} The manifests, in pattern order.
|
|
136
|
+
*/
|
|
137
|
+
declare function workspaceManifests(root: string): Manifest[];
|
|
138
|
+
/**
|
|
139
|
+
* Collects each root and every workspace package reachable from it through `dependencies`,
|
|
140
|
+
* dependencies before their dependents, so a list walked front to back never meets a
|
|
141
|
+
* package before what it needs. A dependency outside the workspace is the registry's
|
|
142
|
+
* business and stays out.
|
|
143
|
+
*
|
|
144
|
+
* @param {readonly string[]} roots - The package names to start from.
|
|
145
|
+
* @param {readonly Manifest[]} manifests - The workspace's manifests.
|
|
146
|
+
* @returns {Closure} The ordered closure, and the roots the workspace does not have.
|
|
147
|
+
*/
|
|
148
|
+
declare function dependencyClosure(roots: readonly string[], manifests: readonly Manifest[]): Closure;
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/contributions.d.ts
|
|
151
|
+
/**
|
|
152
|
+
* Holds one package's contribution beside the manifest that declared it.
|
|
153
|
+
*
|
|
154
|
+
* @template Value - What the caller's schema parses the contribution to.
|
|
155
|
+
*/
|
|
156
|
+
interface Registered<Value> {
|
|
157
|
+
/**
|
|
158
|
+
* Carries the manifest of the package that registered it. Its `directory` is what turns a
|
|
159
|
+
* path inside the contribution into an absolute one.
|
|
160
|
+
*/
|
|
161
|
+
manifest: Manifest;
|
|
162
|
+
/**
|
|
163
|
+
* Carries the contribution, as the caller's schema parsed it.
|
|
164
|
+
*/
|
|
165
|
+
value: Value;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Reads one kind of contribution out of every manifest of a workspace.
|
|
169
|
+
*
|
|
170
|
+
* Every malformed field is reported at once rather than the first, because somebody fixing
|
|
171
|
+
* manifests wants the whole list. A package that registers nothing, or registers only other
|
|
172
|
+
* kinds, is absent from the result rather than being an entry with nothing in it.
|
|
173
|
+
*
|
|
174
|
+
* @template Value - What the schema parses the contribution to.
|
|
175
|
+
* @param {readonly Manifest[]} manifests - The workspace's manifests, as `workspaceManifests`
|
|
176
|
+
* read them. Their order is the result's order.
|
|
177
|
+
* @param {string} key - The key inside the `stealth` field: `theme`, `appearance`, `messages`.
|
|
178
|
+
* @param {SchemaOf<Value>} schema - The schema for what sits under that key. It belongs to the
|
|
179
|
+
* package that owns those words.
|
|
180
|
+
* @returns {Collected<Registered<Value>, FieldIssue>} Every package that registered this kind,
|
|
181
|
+
* in workspace order, or every refusal with the package on each path.
|
|
182
|
+
*/
|
|
183
|
+
declare function contributions<Value>(manifests: readonly Manifest[], key: string, schema: SchemaOf<Value>): Collected<Registered<Value>, FieldIssue>;
|
|
184
|
+
//#endregion
|
|
185
|
+
export { type Closure, type Manifest, type Registered, contributions, dependencyClosure, expandWorkspacePattern, packageRoot, readManifest, workspaceManifests, workspacePatterns, workspaceRoot };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { collect, refused, succeeded } from "@stealthscale/core-result";
|
|
2
|
+
import { array, boolean, looseObject, optional, record, safeParse, string, union, unknown } from "@stealthscale/core-schema";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
//#region src/manifests.ts
|
|
6
|
+
/**
|
|
7
|
+
* @fileoverview Reads the manifests of a workspace: which packages the root names, what each
|
|
8
|
+
* declares, and the order their dependencies put them in. The `stealth` field comes back
|
|
9
|
+
* unread: this reader carries what a package registers with the toolchain without knowing
|
|
10
|
+
* what any of it means, and the consumer that owns those words holds the field to a schema.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Accepts what npm reads off `publishConfig` when it publishes: the access and the registry.
|
|
14
|
+
*/
|
|
15
|
+
const PUBLISH_CONFIG = looseObject({
|
|
16
|
+
access: optional(string()),
|
|
17
|
+
registry: optional(string())
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Accepts `workspaces` in either form bun reads: a list of patterns, or an object naming them.
|
|
21
|
+
*/
|
|
22
|
+
const WORKSPACES = union([array(string()), looseObject({ packages: optional(array(string())) })]);
|
|
23
|
+
/**
|
|
24
|
+
* Accepts a manifest as it is on disk, in the fields the tool reads. Anything else passes
|
|
25
|
+
* through unread.
|
|
26
|
+
*/
|
|
27
|
+
const RAW_MANIFEST = looseObject({
|
|
28
|
+
bin: optional(union([record(string(), string()), string()])),
|
|
29
|
+
dependencies: optional(record(string(), string())),
|
|
30
|
+
description: optional(string()),
|
|
31
|
+
exports: optional(unknown()),
|
|
32
|
+
files: optional(array(string())),
|
|
33
|
+
name: optional(string()),
|
|
34
|
+
private: optional(boolean()),
|
|
35
|
+
publishConfig: optional(PUBLISH_CONFIG),
|
|
36
|
+
scripts: optional(record(string(), string())),
|
|
37
|
+
stealth: optional(unknown()),
|
|
38
|
+
version: optional(string()),
|
|
39
|
+
workspaces: optional(WORKSPACES)
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Parses one manifest file.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} file - The manifest's path.
|
|
45
|
+
* @returns {RawManifest} The manifest as written.
|
|
46
|
+
* @throws {Error} When the file is not a manifest: a field the tool reads has the wrong shape.
|
|
47
|
+
*/
|
|
48
|
+
function read(file) {
|
|
49
|
+
const result = safeParse(RAW_MANIFEST, JSON.parse(readFileSync(file, "utf8")));
|
|
50
|
+
if (result.ok) return result.value;
|
|
51
|
+
const reasons = result.failure.map((issue) => `${issue.path}: ${issue.reason}`).join("; ");
|
|
52
|
+
throw new Error(`${file} is not a manifest: ${reasons}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reads the commands a manifest installs. Npm names the one command of the string form
|
|
56
|
+
* after the package, without its scope.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} name - The package's name, scope and all.
|
|
59
|
+
* @param {Readonly<Record<string, string>> | string} [bin] - The manifest's `bin`. Default:
|
|
60
|
+
* nothing, giving a package that installs no command.
|
|
61
|
+
* @returns {Readonly<Record<string, string>>} Each command mapped to the file that runs it.
|
|
62
|
+
*/
|
|
63
|
+
function binOf(name, bin) {
|
|
64
|
+
if (bin === void 0) return {};
|
|
65
|
+
if (typeof bin === "string") return { [name.slice(name.indexOf("/") + 1)]: bin };
|
|
66
|
+
return bin;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Finds the workspace root: the nearest directory, from `start` upwards, whose manifest
|
|
70
|
+
* names workspaces.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} start - The directory to start from, absolute.
|
|
73
|
+
* @returns {string} The root, absolute.
|
|
74
|
+
* @throws {Error} When no manifest above `start` names workspaces.
|
|
75
|
+
*/
|
|
76
|
+
function workspaceRoot(start) {
|
|
77
|
+
let directory = start;
|
|
78
|
+
while (true) {
|
|
79
|
+
const file = join(directory, "package.json");
|
|
80
|
+
if (existsSync(file) && read(file).workspaces !== void 0) return directory;
|
|
81
|
+
const parent = dirname(directory);
|
|
82
|
+
if (parent === directory) throw new Error(`No manifest above ${start} names workspaces`);
|
|
83
|
+
directory = parent;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Finds the package a file belongs to: the nearest directory, from `start` upwards, that
|
|
88
|
+
* holds a manifest. A module reads its own package's version this way, whatever depth the
|
|
89
|
+
* build emitted it at.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} start - The directory to start from, absolute.
|
|
92
|
+
* @returns {string} The package's directory, absolute.
|
|
93
|
+
* @throws {Error} When no directory above `start` holds a manifest.
|
|
94
|
+
*/
|
|
95
|
+
function packageRoot(start) {
|
|
96
|
+
let directory = start;
|
|
97
|
+
while (true) {
|
|
98
|
+
if (existsSync(join(directory, "package.json"))) return directory;
|
|
99
|
+
const parent = dirname(directory);
|
|
100
|
+
if (parent === directory) throw new Error(`No directory above ${start} holds a manifest`);
|
|
101
|
+
directory = parent;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reads the workspace patterns the root manifest names, in either form bun accepts.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} root - The directory whose manifest names them.
|
|
108
|
+
* @returns {readonly string[]} The patterns. Empty when the manifest names no workspaces.
|
|
109
|
+
*/
|
|
110
|
+
function workspacePatterns(root) {
|
|
111
|
+
const { workspaces } = read(join(root, "package.json"));
|
|
112
|
+
if (Array.isArray(workspaces)) return workspaces;
|
|
113
|
+
return workspaces?.packages ?? [];
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Lists the subdirectories of a directory.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} directory - The directory to list.
|
|
119
|
+
* @returns {string[]} The names, sorted. Empty when the directory does not exist.
|
|
120
|
+
*/
|
|
121
|
+
function subdirectories(directory) {
|
|
122
|
+
if (!existsSync(directory)) return [];
|
|
123
|
+
return readdirSync(directory, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Expands a workspace pattern into the directories it names. A segment is a literal or `*`,
|
|
127
|
+
* which stands for every subdirectory. A literal that does not exist is still returned, so
|
|
128
|
+
* the caller decides what a missing directory means.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} root - The directory the pattern is relative to.
|
|
131
|
+
* @param {string} pattern - The pattern: `core/*`, `plugins/*\/*`, `lone`.
|
|
132
|
+
* @returns {string[]} The directories, absolute.
|
|
133
|
+
*/
|
|
134
|
+
function expandWorkspacePattern(root, pattern) {
|
|
135
|
+
return pattern.split("/").reduce((directories, segment) => directories.flatMap((directory) => segment === "*" ? subdirectories(directory).map((name) => join(directory, name)) : [join(directory, segment)]), [root]);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Reads a package's manifest from its directory.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} directory - The package's directory, absolute.
|
|
141
|
+
* @returns {Manifest} The fields the tool reads, with the directory beside them.
|
|
142
|
+
* @throws {Error} When the manifest has no name or no version. Nothing can be packed from it.
|
|
143
|
+
*/
|
|
144
|
+
function readManifest(directory) {
|
|
145
|
+
const raw = read(join(directory, "package.json"));
|
|
146
|
+
if (raw.name === void 0 || raw.version === void 0) throw new Error(`${join(directory, "package.json")} has no name or no version`);
|
|
147
|
+
return {
|
|
148
|
+
access: raw.publishConfig?.access,
|
|
149
|
+
bin: binOf(raw.name, raw.bin),
|
|
150
|
+
build: raw.scripts?.["build"],
|
|
151
|
+
contributions: raw.stealth,
|
|
152
|
+
dependencies: raw.dependencies ?? {},
|
|
153
|
+
description: raw.description,
|
|
154
|
+
directory,
|
|
155
|
+
exports: raw.exports,
|
|
156
|
+
files: raw.files,
|
|
157
|
+
name: raw.name,
|
|
158
|
+
private: raw.private === true,
|
|
159
|
+
registry: raw.publishConfig?.registry,
|
|
160
|
+
version: raw.version
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Lists every package in the workspace: each directory a pattern names that holds a
|
|
165
|
+
* manifest.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} root - The directory whose manifest names the patterns.
|
|
168
|
+
* @returns {Manifest[]} The manifests, in pattern order.
|
|
169
|
+
*/
|
|
170
|
+
function workspaceManifests(root) {
|
|
171
|
+
return workspacePatterns(root).flatMap((pattern) => expandWorkspacePattern(root, pattern)).filter((directory) => existsSync(join(directory, "package.json"))).map((directory) => readManifest(directory));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Collects each root and every workspace package reachable from it through `dependencies`,
|
|
175
|
+
* dependencies before their dependents, so a list walked front to back never meets a
|
|
176
|
+
* package before what it needs. A dependency outside the workspace is the registry's
|
|
177
|
+
* business and stays out.
|
|
178
|
+
*
|
|
179
|
+
* @param {readonly string[]} roots - The package names to start from.
|
|
180
|
+
* @param {readonly Manifest[]} manifests - The workspace's manifests.
|
|
181
|
+
* @returns {Closure} The ordered closure, and the roots the workspace does not have.
|
|
182
|
+
*/
|
|
183
|
+
function dependencyClosure(roots, manifests) {
|
|
184
|
+
const byName = new Map(manifests.map((manifest) => [manifest.name, manifest]));
|
|
185
|
+
const found = {
|
|
186
|
+
missing: [],
|
|
187
|
+
ordered: []
|
|
188
|
+
};
|
|
189
|
+
const seen = /* @__PURE__ */ new Set();
|
|
190
|
+
/**
|
|
191
|
+
* Adds a package after everything it depends on, once.
|
|
192
|
+
*
|
|
193
|
+
* @param {Manifest} manifest - The package to add.
|
|
194
|
+
*/
|
|
195
|
+
const visit = (manifest) => {
|
|
196
|
+
if (seen.has(manifest.name)) return;
|
|
197
|
+
seen.add(manifest.name);
|
|
198
|
+
for (const name of Object.keys(manifest.dependencies)) {
|
|
199
|
+
const dependency = byName.get(name);
|
|
200
|
+
if (dependency !== void 0) visit(dependency);
|
|
201
|
+
}
|
|
202
|
+
found.ordered.push(manifest);
|
|
203
|
+
};
|
|
204
|
+
for (const root of roots) {
|
|
205
|
+
const manifest = byName.get(root);
|
|
206
|
+
if (manifest === void 0) found.missing.push(root);
|
|
207
|
+
else visit(manifest);
|
|
208
|
+
}
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/contributions.ts
|
|
213
|
+
/**
|
|
214
|
+
* @fileoverview Reads one kind of contribution out of the `stealth` field of every manifest.
|
|
215
|
+
* The caller names the key and hands over the schema for what it expects under it, so nothing
|
|
216
|
+
* here learns what a theme, an appearance or a set of words is: it finds the field, checks it is
|
|
217
|
+
* an object, and holds one key of it to somebody else's schema. A kind of contribution is
|
|
218
|
+
* therefore added by the package that owns those words, and this module does not change.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Accepts the `stealth` field itself: an object keyed by the kind of contribution. Every
|
|
222
|
+
* value stays unread, because the caller's schema reads the one key it asked for.
|
|
223
|
+
*/
|
|
224
|
+
const FIELD = record(string(), unknown());
|
|
225
|
+
/**
|
|
226
|
+
* Points every issue at the field inside the package that wrote it, so a reading of the whole
|
|
227
|
+
* workspace names both the package and the field.
|
|
228
|
+
*
|
|
229
|
+
* @param {readonly FieldIssue[]} issues - The refusals a schema reported.
|
|
230
|
+
* @param {string} prefix - The path of the field being read: `@scope/pkg.stealth.theme`.
|
|
231
|
+
* @returns {FieldIssue[]} The same refusals, each path prefixed. An issue on the field itself
|
|
232
|
+
* carries the prefix alone.
|
|
233
|
+
*/
|
|
234
|
+
function prefixed(issues, prefix) {
|
|
235
|
+
return issues.map((issue) => ({
|
|
236
|
+
...issue,
|
|
237
|
+
path: issue.path === "" ? prefix : `${prefix}.${issue.path}`
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Reads one package's contribution of one kind.
|
|
242
|
+
*
|
|
243
|
+
* @template Value - What the schema parses the contribution to.
|
|
244
|
+
* @param {Manifest} manifest - The package's manifest.
|
|
245
|
+
* @param {string} key - The key inside the `stealth` field: `theme`, `appearance`, `messages`.
|
|
246
|
+
* @param {SchemaOf<Value>} schema - The schema for what sits under that key.
|
|
247
|
+
* @returns {Collected<Registered<Value>, FieldIssue>} One entry when the package registered
|
|
248
|
+
* this kind, and none when it registered nothing or registered another kind. A `stealth`
|
|
249
|
+
* field that is not an object is a refusal, whatever key was asked for.
|
|
250
|
+
*/
|
|
251
|
+
function contributionOf(manifest, key, schema) {
|
|
252
|
+
if (manifest.contributions === void 0) return succeeded([]);
|
|
253
|
+
const field = safeParse(FIELD, manifest.contributions);
|
|
254
|
+
if (!field.ok) return refused(prefixed(field.failure, `${manifest.name}.stealth`));
|
|
255
|
+
const declared = field.value[key];
|
|
256
|
+
if (declared === void 0) return succeeded([]);
|
|
257
|
+
const read = safeParse(schema, declared);
|
|
258
|
+
if (!read.ok) return refused(prefixed(read.failure, `${manifest.name}.stealth.${key}`));
|
|
259
|
+
return succeeded([{
|
|
260
|
+
manifest,
|
|
261
|
+
value: read.value
|
|
262
|
+
}]);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Reads one kind of contribution out of every manifest of a workspace.
|
|
266
|
+
*
|
|
267
|
+
* Every malformed field is reported at once rather than the first, because somebody fixing
|
|
268
|
+
* manifests wants the whole list. A package that registers nothing, or registers only other
|
|
269
|
+
* kinds, is absent from the result rather than being an entry with nothing in it.
|
|
270
|
+
*
|
|
271
|
+
* @template Value - What the schema parses the contribution to.
|
|
272
|
+
* @param {readonly Manifest[]} manifests - The workspace's manifests, as `workspaceManifests`
|
|
273
|
+
* read them. Their order is the result's order.
|
|
274
|
+
* @param {string} key - The key inside the `stealth` field: `theme`, `appearance`, `messages`.
|
|
275
|
+
* @param {SchemaOf<Value>} schema - The schema for what sits under that key. It belongs to the
|
|
276
|
+
* package that owns those words.
|
|
277
|
+
* @returns {Collected<Registered<Value>, FieldIssue>} Every package that registered this kind,
|
|
278
|
+
* in workspace order, or every refusal with the package on each path.
|
|
279
|
+
*/
|
|
280
|
+
function contributions(manifests, key, schema) {
|
|
281
|
+
const read = collect(manifests.map((manifest) => contributionOf(manifest, key, schema)));
|
|
282
|
+
if (!read.ok) return refused(read.failure.flat());
|
|
283
|
+
return succeeded(read.value.flat());
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
export { contributions, dependencyClosure, expandWorkspacePattern, packageRoot, readManifest, workspaceManifests, workspacePatterns, workspaceRoot };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stealthscale/tool-workspace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reads a workspace off its manifests: which packages the root names, what each declares, and the order their dependencies put them in.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/stealth-scale/tooling.git",
|
|
9
|
+
"directory": "tools/workspace"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"imports": {
|
|
17
|
+
"#*": "./src/*"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"tooling-source": "./src/index.ts",
|
|
22
|
+
"default": "./dist/index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.mjs",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "vp pack src/index.ts"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@stealthscale/core-result": "^0.1.0",
|
|
38
|
+
"@stealthscale/core-schema": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@stealthscale/tool-config": "^0.1.0",
|
|
42
|
+
"@stealthscale/tool-testing": "^0.1.0"
|
|
43
|
+
}
|
|
44
|
+
}
|