@effected/package-json 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/Dependency.js +76 -0
- package/DevEngines.js +42 -0
- package/LICENSE +21 -0
- package/License.js +45 -0
- package/Package.js +329 -0
- package/PackageJsonFile.js +128 -0
- package/PackageManager.js +63 -0
- package/PackageName.js +62 -0
- package/PackageValidator.js +146 -0
- package/Person.js +51 -0
- package/README.md +215 -0
- package/index.d.ts +747 -0
- package/index.js +12 -0
- package/internal/format.js +106 -0
- package/package.json +50 -0
- package/tsdoc-metadata.json +11 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { IntegrityHash } from "@effected/npm";
|
|
2
|
+
import { Effect, Exit, Option, Schema, SchemaIssue, SchemaTransformation } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/PackageManager.ts
|
|
5
|
+
const PACKAGE_MANAGER_RE = /^([a-z]+)@(\d+\.\d+\.\d+(?:-[a-zA-Z0-9._-]+)?)(?:\+(.+))?$/;
|
|
6
|
+
/**
|
|
7
|
+
* The `packageManager` field only ever carries corepack's `<algo>.<hex>`
|
|
8
|
+
* integrity form (the `name@version+sha512.<hex>` tail). Restrict the
|
|
9
|
+
* `@effected/npm` `IntegrityHash` brand — which also admits the SRI and yarn
|
|
10
|
+
* forms — to just the corepack shape, so an SRI or yarn integrity here fails
|
|
11
|
+
* typed rather than being accepted into a field that can never legitimately
|
|
12
|
+
* hold it.
|
|
13
|
+
*/
|
|
14
|
+
const CorepackIntegrity = IntegrityHash.pipe(Schema.check(Schema.makeFilter((value) => IntegrityHash.isCorepack(value) ? void 0 : "Expected a corepack (<algo>.<hex>) integrity hash")));
|
|
15
|
+
/**
|
|
16
|
+
* A structured `packageManager` value with `name`, `version` and an optional
|
|
17
|
+
* `integrity` hash.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
var PackageManager = class PackageManager extends Schema.Class("PackageManager")({
|
|
22
|
+
/** The package-manager name (e.g. `pnpm`). */
|
|
23
|
+
name: Schema.String,
|
|
24
|
+
/** The version (e.g. `10.33.0`). */
|
|
25
|
+
version: Schema.String,
|
|
26
|
+
/** The optional integrity hash (e.g. `sha512.abc`), an `@effected/npm` `IntegrityHash` restricted to the corepack `<algo>.<hex>` form. */
|
|
27
|
+
integrity: Schema.Option(CorepackIntegrity)
|
|
28
|
+
}) {
|
|
29
|
+
/**
|
|
30
|
+
* Schema transformation between the `"name@version+integrity"` string and a
|
|
31
|
+
* {@link PackageManager}.
|
|
32
|
+
*/
|
|
33
|
+
static FromString = Schema.String.pipe(Schema.decodeTo(Schema.instanceOf(PackageManager), SchemaTransformation.transformOrFail({
|
|
34
|
+
decode: (input) => {
|
|
35
|
+
const match = input.match(PACKAGE_MANAGER_RE);
|
|
36
|
+
if (match === null) return Effect.fail(new SchemaIssue.InvalidValue(Option.some(input), { message: `Invalid packageManager format: "${input}"` }));
|
|
37
|
+
const rawIntegrity = match[3];
|
|
38
|
+
if (rawIntegrity === void 0) return Effect.succeed(PackageManager.make({
|
|
39
|
+
name: match[1],
|
|
40
|
+
version: match[2],
|
|
41
|
+
integrity: Option.none()
|
|
42
|
+
}));
|
|
43
|
+
const decoded = Schema.decodeUnknownExit(CorepackIntegrity)(rawIntegrity);
|
|
44
|
+
if (Exit.isFailure(decoded)) return Effect.fail(new SchemaIssue.InvalidValue(Option.some(input), { message: `Invalid packageManager integrity: "${rawIntegrity}"` }));
|
|
45
|
+
return Effect.succeed(PackageManager.make({
|
|
46
|
+
name: match[1],
|
|
47
|
+
version: match[2],
|
|
48
|
+
integrity: Option.some(decoded.value)
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
encode: (pm) => Effect.succeed(Option.match(pm.integrity, {
|
|
52
|
+
onNone: () => `${pm.name}@${pm.version}`,
|
|
53
|
+
onSome: (integrity) => `${pm.name}@${pm.version}+${integrity}`
|
|
54
|
+
}))
|
|
55
|
+
})));
|
|
56
|
+
/** Whether an integrity hash is present. */
|
|
57
|
+
get hasIntegrity() {
|
|
58
|
+
return Option.isSome(this.integrity);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
63
|
+
export { PackageManager };
|
package/PackageName.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Option, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/PackageName.ts
|
|
4
|
+
/**
|
|
5
|
+
* Indicates that a string could not be used as a valid npm package name.
|
|
6
|
+
*
|
|
7
|
+
* Raised by {@link Package.setName} and the decode direction of
|
|
8
|
+
* `PackageName`. The offending string is preserved on `input`.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var InvalidPackageNameError = class extends Schema.TaggedErrorClass()("InvalidPackageNameError", {
|
|
13
|
+
/** The raw input string that failed validation. */
|
|
14
|
+
input: Schema.String }) {
|
|
15
|
+
get message() {
|
|
16
|
+
return `Invalid package name "${this.input}": does not satisfy npm naming rules`;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const UNSCOPED_RE = /^[a-z0-9-][a-z0-9._-]*$/;
|
|
20
|
+
const SCOPED_RE = /^@[a-z0-9-][a-z0-9._-]*\/[a-z0-9-][a-z0-9._-]*$/;
|
|
21
|
+
const MAX_LENGTH = 214;
|
|
22
|
+
/**
|
|
23
|
+
* A valid npm scoped package name (`@scope/name`).
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
const ScopedPackageName = Schema.String.pipe(Schema.check(Schema.isPattern(SCOPED_RE), Schema.isMaxLength(MAX_LENGTH)), Schema.brand("ScopedPackageName"));
|
|
28
|
+
/**
|
|
29
|
+
* A valid npm unscoped package name (no `@scope/` prefix).
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
*/
|
|
33
|
+
const UnscopedPackageName = Schema.String.pipe(Schema.check(Schema.isPattern(UNSCOPED_RE), Schema.isMaxLength(MAX_LENGTH)), Schema.brand("UnscopedPackageName"));
|
|
34
|
+
const isValid = (name) => name.length > 0 && name.length <= MAX_LENGTH && (UNSCOPED_RE.test(name) || SCOPED_RE.test(name));
|
|
35
|
+
const scope = (name) => {
|
|
36
|
+
if (!name.startsWith("@")) return Option.none();
|
|
37
|
+
const slash = name.indexOf("/");
|
|
38
|
+
return slash === -1 ? Option.none() : Option.some(name.slice(1, slash));
|
|
39
|
+
};
|
|
40
|
+
const unscoped = (name) => {
|
|
41
|
+
if (!name.startsWith("@")) return name;
|
|
42
|
+
const slash = name.indexOf("/");
|
|
43
|
+
return slash === -1 ? name : name.slice(slash + 1);
|
|
44
|
+
};
|
|
45
|
+
const isScoped = (name) => name.startsWith("@");
|
|
46
|
+
/**
|
|
47
|
+
* The union of `ScopedPackageName` and `UnscopedPackageName`,
|
|
48
|
+
* carrying the classification statics (`PackageName.isValid` and friends)
|
|
49
|
+
* that absorb the v3 floating `PackageNameUtil` object. Use it as the schema
|
|
50
|
+
* for a package-name field and reach for the statics to inspect a raw string.
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
const PackageName = Object.assign(Schema.Union([ScopedPackageName, UnscopedPackageName]), {
|
|
55
|
+
isValid,
|
|
56
|
+
scope,
|
|
57
|
+
unscoped,
|
|
58
|
+
isScoped
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
62
|
+
export { InvalidPackageNameError, PackageName, ScopedPackageName, UnscopedPackageName };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Context, Effect, HashMap, Layer, Option, Result, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/PackageValidator.ts
|
|
4
|
+
/**
|
|
5
|
+
* Indicates that a {@link Package} failed one or more validation rules.
|
|
6
|
+
*
|
|
7
|
+
* Raised by {@link PackageValidator}. Every rule failure is aggregated on
|
|
8
|
+
* `failures`; the `message` getter renders a multi-line report.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var PackageValidationError = class extends Schema.TaggedErrorClass()("PackageValidationError", {
|
|
13
|
+
/** The aggregated rule failures. */
|
|
14
|
+
failures: Schema.Array(Schema.Struct({
|
|
15
|
+
rule: Schema.String,
|
|
16
|
+
message: Schema.String,
|
|
17
|
+
path: Schema.Option(Schema.String)
|
|
18
|
+
})) }) {
|
|
19
|
+
get message() {
|
|
20
|
+
return `package.json validation failed:\n${this.failures.map((failure) => {
|
|
21
|
+
const path = Option.match(failure.path, {
|
|
22
|
+
onNone: () => "",
|
|
23
|
+
onSome: (value) => ` (at ${value})`
|
|
24
|
+
});
|
|
25
|
+
return ` - [${failure.rule}]${path}: ${failure.message}`;
|
|
26
|
+
}).join("\n")}`;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const hasLicense = {
|
|
30
|
+
name: "has-license",
|
|
31
|
+
validate: (pkg) => pkg.license !== void 0 ? Effect.void : Effect.fail({
|
|
32
|
+
message: "Missing license field",
|
|
33
|
+
path: Option.some("license")
|
|
34
|
+
})
|
|
35
|
+
};
|
|
36
|
+
const hasDescription = {
|
|
37
|
+
name: "has-description",
|
|
38
|
+
validate: (pkg) => pkg.description !== void 0 ? Effect.void : Effect.fail({
|
|
39
|
+
message: "Missing description field",
|
|
40
|
+
path: Option.some("description")
|
|
41
|
+
})
|
|
42
|
+
};
|
|
43
|
+
const hasRepository = {
|
|
44
|
+
name: "has-repository",
|
|
45
|
+
validate: (pkg) => pkg.repository !== void 0 ? Effect.void : Effect.fail({
|
|
46
|
+
message: "Missing repository field",
|
|
47
|
+
path: Option.some("repository")
|
|
48
|
+
})
|
|
49
|
+
};
|
|
50
|
+
const notPrivate = {
|
|
51
|
+
name: "not-private",
|
|
52
|
+
validate: (pkg) => pkg.isPrivate ? Effect.fail({
|
|
53
|
+
message: "Package is private",
|
|
54
|
+
path: Option.some("private")
|
|
55
|
+
}) : Effect.void
|
|
56
|
+
};
|
|
57
|
+
const anyDependencyMatches = (pkg, predicate) => [
|
|
58
|
+
pkg.dependencies,
|
|
59
|
+
pkg.devDependencies,
|
|
60
|
+
pkg.peerDependencies,
|
|
61
|
+
pkg.optionalDependencies
|
|
62
|
+
].some((map) => Array.from(HashMap.values(map)).some(predicate));
|
|
63
|
+
/**
|
|
64
|
+
* A rule that fails when any dependency uses an unresolved `workspace:` or
|
|
65
|
+
* `catalog:` specifier.
|
|
66
|
+
*
|
|
67
|
+
* @public
|
|
68
|
+
*/
|
|
69
|
+
const noUnresolvedDepsRule = {
|
|
70
|
+
name: "no-unresolved-deps",
|
|
71
|
+
validate: (pkg) => anyDependencyMatches(pkg, (specifier) => specifier.startsWith("workspace:") || specifier.startsWith("catalog:")) ? Effect.fail({
|
|
72
|
+
message: "Unresolved workspace:/catalog: dependency",
|
|
73
|
+
path: Option.none()
|
|
74
|
+
}) : Effect.void
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* A rule that fails when any dependency uses a local `file:`, `link:` or
|
|
78
|
+
* `portal:` specifier.
|
|
79
|
+
*
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
const noLocalDepsRule = {
|
|
83
|
+
name: "no-local-deps",
|
|
84
|
+
validate: (pkg) => anyDependencyMatches(pkg, (specifier) => specifier.startsWith("file:") || specifier.startsWith("link:") || specifier.startsWith("portal:")) ? Effect.fail({
|
|
85
|
+
message: "Local file:/link:/portal: dependency",
|
|
86
|
+
path: Option.none()
|
|
87
|
+
}) : Effect.void
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* The default validation rules: license, description, repository and
|
|
91
|
+
* not-private.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
const defaultRules = [
|
|
96
|
+
hasLicense,
|
|
97
|
+
hasDescription,
|
|
98
|
+
hasRepository,
|
|
99
|
+
notPrivate
|
|
100
|
+
];
|
|
101
|
+
const runRules = Effect.fn("PackageValidator.validate")(function* (pkg, rules) {
|
|
102
|
+
const failures = [];
|
|
103
|
+
for (const rule of rules) {
|
|
104
|
+
const result = yield* Effect.result(rule.validate(pkg));
|
|
105
|
+
if (Result.isFailure(result)) failures.push({
|
|
106
|
+
rule: rule.name,
|
|
107
|
+
message: result.failure.message,
|
|
108
|
+
path: result.failure.path
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (failures.length > 0) return yield* new PackageValidationError({ failures });
|
|
112
|
+
});
|
|
113
|
+
/**
|
|
114
|
+
* Validates a {@link Package} against a set of {@link ValidationRule}s,
|
|
115
|
+
* aggregating every failure into one {@link PackageValidationError}.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* import { Package, PackageValidator } from "@effected/package-json";
|
|
120
|
+
* import { Effect } from "effect";
|
|
121
|
+
*
|
|
122
|
+
* const program = Effect.gen(function* () {
|
|
123
|
+
* const pkg = yield* Package.decode({ name: "my-pkg", version: "1.0.0" });
|
|
124
|
+
* const validator = yield* PackageValidator;
|
|
125
|
+
* yield* validator.validate(pkg);
|
|
126
|
+
* }).pipe(Effect.provide(PackageValidator.layer));
|
|
127
|
+
* ```
|
|
128
|
+
*
|
|
129
|
+
* @public
|
|
130
|
+
*/
|
|
131
|
+
var PackageValidator = class PackageValidator extends Context.Service()("@effected/package-json/PackageValidator") {
|
|
132
|
+
/** The default layer, backed by {@link defaultRules}. */
|
|
133
|
+
static layer = Layer.succeed(PackageValidator, { validate: (pkg) => runRules(pkg, defaultRules) });
|
|
134
|
+
/**
|
|
135
|
+
* Build a layer from a custom set of rules (a genuinely-parameterized factory).
|
|
136
|
+
*
|
|
137
|
+
* @param config - the rule set to validate against, replacing {@link defaultRules}
|
|
138
|
+
* @returns a layer providing `PackageValidator` backed by `config.rules`
|
|
139
|
+
*/
|
|
140
|
+
static layerRules(config) {
|
|
141
|
+
return Layer.succeed(PackageValidator, { validate: (pkg) => runRules(pkg, config.rules) });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
//#endregion
|
|
146
|
+
export { PackageValidationError, PackageValidator, defaultRules, noLocalDepsRule, noUnresolvedDepsRule };
|
package/Person.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Schema, SchemaTransformation } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/Person.ts
|
|
4
|
+
const parsePersonString = (input) => {
|
|
5
|
+
const emailMatch = input.match(/<([^>]+)>/);
|
|
6
|
+
const urlMatch = input.match(/\(([^)]+)\)/);
|
|
7
|
+
let name = input;
|
|
8
|
+
if (emailMatch !== null) name = name.replace(emailMatch[0], "");
|
|
9
|
+
if (urlMatch !== null) name = name.replace(urlMatch[0], "");
|
|
10
|
+
name = name.trim();
|
|
11
|
+
return Person.make({
|
|
12
|
+
name,
|
|
13
|
+
...emailMatch !== null ? { email: emailMatch[1] } : {},
|
|
14
|
+
...urlMatch !== null ? { url: urlMatch[1] } : {}
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A structured person object with `name` and optional `email` / `url`.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
var Person = class Person extends Schema.Class("Person")({
|
|
23
|
+
/** The person's name. */
|
|
24
|
+
name: Schema.String,
|
|
25
|
+
/** The optional email address. */
|
|
26
|
+
email: Schema.optionalKey(Schema.String),
|
|
27
|
+
/** The optional homepage URL. */
|
|
28
|
+
url: Schema.optionalKey(Schema.String)
|
|
29
|
+
}) {
|
|
30
|
+
/**
|
|
31
|
+
* Schema transformation between the `"Name <email> (url)"` shorthand string
|
|
32
|
+
* and a {@link Person}.
|
|
33
|
+
*/
|
|
34
|
+
static FromString = Schema.String.pipe(Schema.decodeTo(Schema.instanceOf(Person), SchemaTransformation.transform({
|
|
35
|
+
decode: (input) => parsePersonString(input),
|
|
36
|
+
encode: (person) => {
|
|
37
|
+
let result = person.name;
|
|
38
|
+
if (person.email !== void 0) result += ` <${person.email}>`;
|
|
39
|
+
if (person.url !== void 0) result += ` (${person.url})`;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
})));
|
|
43
|
+
/**
|
|
44
|
+
* The `author` / `contributors` value: either the shorthand string or the
|
|
45
|
+
* structured object, always decoded to a {@link Person}.
|
|
46
|
+
*/
|
|
47
|
+
static FromValue = Schema.Union([Person, Person.FromString]);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { Person };
|
package/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# @effected/package-json
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/package-json)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
package.json parsing, editing, validation and file IO as Effect schemas. `Package` is a `Schema.Class` with the manifest's known fields typed — `name` is a branded npm name, `version` is a real `SemVer`, `packageManager` decodes into `{ name, version, integrity }` — and a `rest` catch-all that carries every unknown top-level key through a read, edit and write cycle without losing it. Editing is immutable and dual-signature, validation is a rule set you can replace, and `catalog:` / `workspace:` specifiers expand through the `@effected/npm` resolver contracts as an explicit step you opt into.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/package-json
|
|
23
|
+
|
|
24
|
+
Tools that rewrite a package.json usually treat it as a `Record<string, unknown>`: read it, mutate a key, `JSON.stringify` it back. That works right up until it does not. Unknown keys survive by accident rather than by design, `version` is a string you compare with `<`, and the day someone's manifest has a field your types never modeled is the day you find out whether your write path preserved it. The alternative — a strict schema over the *known* fields — usually solves the typing by deleting everyone's data.
|
|
25
|
+
|
|
26
|
+
This package refuses both. Known fields are typed and validated; everything else lands in `rest` and is flattened back to top-level keys on encode, so the on-disk shape never grows a literal `rest` key and never loses your `customTool` block. Serialization applies the canonical `sort-package-json` key order, alphabetizes dependency maps and strips empty ones, deterministically and locale-independently. `PackageJsonFile.write` does not silently resolve your `workspace:` specifiers on the way out, because a write that quietly rewrites your dependency values is not a write, it is a policy — so `Package.resolve` is a step you compose in deliberately. And every distinct failure has its own tag: a missing file, an unreadable file, invalid JSON and a document that does not satisfy the schema are four different problems with four different recoveries.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @effected/package-json effect @effect/platform-node
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pnpm add @effected/package-json effect @effect/platform-node
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Node.js >=24.11.0.
|
|
39
|
+
|
|
40
|
+
`effect` v4 is the only peer dependency. `@effected/semver` and `@effected/npm` come along as ordinary dependencies — they back the `version` field and the resolver contracts — and `spdx-expression-parse` is the one external package in the tree, used to validate SPDX license expressions.
|
|
41
|
+
|
|
42
|
+
Reading and writing files needs a `FileSystem` and a `Path` implementation, provided once at the edge, from `@effect/platform-node` on Node. Everything except `PackageJsonFile` is pure and needs no platform layer at all.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
Decode a manifest, edit it, read the computed properties back:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Package } from "@effected/package-json";
|
|
50
|
+
import { Effect } from "effect";
|
|
51
|
+
|
|
52
|
+
const program = Effect.gen(function* () {
|
|
53
|
+
const pkg = yield* Package.decode({ name: "@acme/widget", version: "1.0.0", private: true });
|
|
54
|
+
const next = yield* Package.setVersion(pkg, "1.1.0");
|
|
55
|
+
return [next.name, next.version.toString(), next.isScoped, next.isPrivate] as const;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.log(Effect.runSync(program));
|
|
59
|
+
// => ["@acme/widget", "1.1.0", true, true]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`next.version` is a `SemVer`, not a string, so you compare it with `SemVer.gt` and bump it with `version.bump.minor()` rather than reaching for a regex.
|
|
63
|
+
|
|
64
|
+
## The Package model
|
|
65
|
+
|
|
66
|
+
Editing returns a new `Package`. The mutation statics are dual, so `Package.addDependency(pkg, "effect", "^4.0.0")` and `pkg.pipe(Package.addDependency("effect", "^4.0.0"))` are the same call. The ones that can fail — `setVersion`, `setName`, `setLicense` — return an `Effect` with the corresponding typed error, and the rest are plain functions.
|
|
67
|
+
|
|
68
|
+
Unknown keys round-trip. `toJsonString` encodes through the wire codec, flattens `rest` back to the top level, and applies the canonical key order:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { Package } from "@effected/package-json";
|
|
72
|
+
import { Effect } from "effect";
|
|
73
|
+
|
|
74
|
+
const program = Effect.gen(function* () {
|
|
75
|
+
const pkg = yield* Package.decode({
|
|
76
|
+
name: "widget",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
scripts: { build: "tsc" },
|
|
79
|
+
customTool: { flag: true },
|
|
80
|
+
});
|
|
81
|
+
return Package.addDevDependency(pkg, "typescript", "^6.0.0").toJsonString();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
console.log(Effect.runSync(program));
|
|
85
|
+
// {
|
|
86
|
+
// "name": "widget",
|
|
87
|
+
// "version": "1.0.0",
|
|
88
|
+
// "scripts": {
|
|
89
|
+
// "build": "tsc"
|
|
90
|
+
// },
|
|
91
|
+
// "devDependencies": {
|
|
92
|
+
// "typescript": "^6.0.0"
|
|
93
|
+
// },
|
|
94
|
+
// "customTool": {
|
|
95
|
+
// "flag": true
|
|
96
|
+
// }
|
|
97
|
+
// }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`toJsonString` takes `PackageFormatOptions` — `indent`, `sort`, `stripEmpty`, `newline` — if you want the raw shape instead of the canonical one.
|
|
101
|
+
|
|
102
|
+
Alongside `Package` the leaf concepts are usable on their own. `PackageName` classifies and validates npm names (`isValid`, `isScoped`, `scope`, `unscoped`) and brands them as `ScopedPackageName` or `UnscopedPackageName`. `DependencySpecifier` classifies any specifier string into one protocol — `range`, `tag`, `git`, `url`, `npm`, `file`, `link`, `portal`, `catalog`, `workspace` or `unknown` — and parses the range case into a `Range` from `@effected/semver`. `Dependency` pairs a name with a specifier and the `kind` of map it came from, exposing the same protocol predicates as getters.
|
|
103
|
+
|
|
104
|
+
## Reading and writing
|
|
105
|
+
|
|
106
|
+
`PackageJsonFile` is the only IO in the package: one service, two methods, over core `FileSystem` and `Path`.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { Package, PackageJsonFile } from "@effected/package-json";
|
|
110
|
+
import { NodeFileSystem, NodePath } from "@effect/platform-node";
|
|
111
|
+
import { Effect, Layer } from "effect";
|
|
112
|
+
|
|
113
|
+
const bumpMinor = Effect.gen(function* () {
|
|
114
|
+
const files = yield* PackageJsonFile;
|
|
115
|
+
const pkg = yield* files.read("./package.json");
|
|
116
|
+
const next = pkg.copyWith({ version: pkg.version.bump.minor() });
|
|
117
|
+
yield* files.write("./package.json", next);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
|
|
121
|
+
|
|
122
|
+
Effect.runPromise(bumpMinor.pipe(Effect.provide(PackageJsonFile.layer), Effect.provide(PlatformLive)));
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`read` fails four different ways and says which: `PackageJsonNotFoundError` when the file is not there, `PackageJsonReadError` for any other filesystem failure, `PackageJsonParseError` when the bytes are not JSON, and `PackageDecodeError` when the JSON is not a package.json. There is no `exists` pre-check, so a file deleted between the check and the read cannot be misreported as an IO error.
|
|
126
|
+
|
|
127
|
+
## Validation
|
|
128
|
+
|
|
129
|
+
`PackageValidator` runs a rule set over a decoded `Package` and aggregates *every* failure into one `PackageValidationError`, rather than stopping at the first.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { Package, PackageValidator } from "@effected/package-json";
|
|
133
|
+
import { Effect } from "effect";
|
|
134
|
+
|
|
135
|
+
const program = Effect.gen(function* () {
|
|
136
|
+
const pkg = yield* Package.decode({ name: "widget", version: "1.0.0" });
|
|
137
|
+
const validator = yield* PackageValidator;
|
|
138
|
+
return yield* validator.validate(pkg);
|
|
139
|
+
}).pipe(
|
|
140
|
+
Effect.provide(PackageValidator.layer),
|
|
141
|
+
Effect.catchTag("PackageValidationError", (error) => Effect.succeed(error.failures.map((failure) => failure.rule))),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
console.log(Effect.runSync(program));
|
|
145
|
+
// => ["has-license", "has-description", "has-repository"]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`PackageValidator.layer` carries the default rules (`has-license`, `has-description`, `has-repository`, `not-private`). `PackageValidator.layerRules({ rules })` takes your own set instead — a `ValidationRule` is a name plus a check that fails with a `RuleFailure`. Two extra rules ship for publish gates: `noUnresolvedDepsRule` fails on any `workspace:` or `catalog:` specifier still in the manifest, and `noLocalDepsRule` fails on `file:`, `link:` and `portal:`.
|
|
149
|
+
|
|
150
|
+
## Resolving catalog: and workspace: specifiers
|
|
151
|
+
|
|
152
|
+
`Package.resolve` expands `catalog:` and `workspace:` specifiers across all four dependency maps, using the `CatalogResolver` and `WorkspaceResolver` contracts from `@effected/npm`. This package deliberately cannot implement them — it has no view of the workspace — so the implementation arrives from context. `@effected/workspaces` provides the real ones; a fixed record does fine for a test.
|
|
153
|
+
|
|
154
|
+
Specifiers the resolvers answer `None` for are left exactly as they were.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { CatalogResolver, WorkspaceResolver } from "@effected/npm";
|
|
158
|
+
import { Package } from "@effected/package-json";
|
|
159
|
+
import { Effect, HashMap, Layer, Option } from "effect";
|
|
160
|
+
|
|
161
|
+
const Resolvers = Layer.mergeAll(
|
|
162
|
+
Layer.succeed(CatalogResolver, { rangeOf: () => Effect.succeed(Option.some("^4.0.0")) }),
|
|
163
|
+
Layer.succeed(WorkspaceResolver, { versionOf: () => Effect.succeed(Option.some("1.4.0")) }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const program = Effect.gen(function* () {
|
|
167
|
+
const pkg = yield* Package.decode({
|
|
168
|
+
name: "widget",
|
|
169
|
+
version: "1.0.0",
|
|
170
|
+
dependencies: { effect: "catalog:", "@acme/core": "workspace:^" },
|
|
171
|
+
});
|
|
172
|
+
const resolved = yield* Package.resolve(pkg);
|
|
173
|
+
return [
|
|
174
|
+
Option.getOrElse(HashMap.get(resolved.dependencies, "effect"), () => "unresolved"),
|
|
175
|
+
Option.getOrElse(HashMap.get(resolved.dependencies, "@acme/core"), () => "unresolved"),
|
|
176
|
+
] as const;
|
|
177
|
+
}).pipe(Effect.provide(Resolvers));
|
|
178
|
+
|
|
179
|
+
console.log(Effect.runSync(program));
|
|
180
|
+
// => ["^4.0.0", "^1.4.0"]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The `workspace:` range modifier is honored: `workspace:*` takes the bare version, `workspace:^` and `workspace:~` prefix it, and an explicit modifier is used as-is.
|
|
184
|
+
|
|
185
|
+
## Errors
|
|
186
|
+
|
|
187
|
+
Every failure is a `Schema.TaggedErrorClass` routed with `Effect.catchTag`. Causes are preserved structurally on a `Schema.Defect` field — a `PackageDecodeError` hands you the `SchemaError` issue tree, not `String(error)`.
|
|
188
|
+
|
|
189
|
+
| Tag | Means |
|
|
190
|
+
| --- | ----- |
|
|
191
|
+
| `PackageJsonNotFoundError` | No file at the path. Often not an error at all: fall back, or walk up. |
|
|
192
|
+
| `PackageJsonReadError` | The file is there and could not be read. Carries `path` and the structural `cause`. |
|
|
193
|
+
| `PackageJsonParseError` | The bytes are not JSON. Carries `path` and the `SyntaxError`. |
|
|
194
|
+
| `PackageDecodeError` | The JSON is not a package.json. Carries the `SchemaError` cause with its issue tree. |
|
|
195
|
+
| `PackageJsonWriteError` | The write failed. Narrowed to the filesystem failure only, never an encode error. |
|
|
196
|
+
| `PackageValidationError` | One or more validation rules failed. Carries every `failure`, each with its rule name, message and JSON path. |
|
|
197
|
+
| `InvalidPackageNameError` | A string does not satisfy npm's naming rules. Raised by `Package.setName`. |
|
|
198
|
+
| `InvalidSpdxLicenseError` | A string is not a valid SPDX license expression. Raised by `Package.setLicense`. |
|
|
199
|
+
| `InvalidDependencySpecifierError` | A string is not a recognized dependency specifier. Raised by `DependencySpecifier.decode`. |
|
|
200
|
+
|
|
201
|
+
`Package.setVersion` fails with `InvalidVersionError` from `@effected/semver`, which is where the version grammar lives.
|
|
202
|
+
|
|
203
|
+
## Features
|
|
204
|
+
|
|
205
|
+
- `Package` — the manifest model: typed known fields, the `rest` catch-all, computed getters (`isPrivate`, `isScoped`, `isESM`, `hasDependency`, the four `get*Dependencies`), dual mutation statics, `copyWith`, `Package.decode` and the pure `toJsonString` serializer.
|
|
206
|
+
- `Package.schema` / `Package.wireFor` — the open-JSON ↔ class wire codec, and the factory that builds one for a `.extend()`ed subclass so its custom fields decode as typed members instead of falling into `rest`.
|
|
207
|
+
- `PackageJsonFile` — the IO surface: `read` and `write` over core `FileSystem` / `Path`, with the platform implementation supplied at the edge.
|
|
208
|
+
- `PackageValidator` — rule-based validation aggregating every failure, with the default rule set, a parameterized `layerRules` factory, and the publish-gate rules `noUnresolvedDepsRule` and `noLocalDepsRule`.
|
|
209
|
+
- `Package.resolve` — `catalog:` and `workspace:` expansion over the `@effected/npm` contracts, as an explicit step that `write` never performs for you.
|
|
210
|
+
- `PackageName`, `DependencySpecifier`, `Dependency`, `SpdxLicense`, `PackageManager`, `Person`, `DevEngine` — the leaf concepts, each owning its own statics, brand and error, usable independently of `Package`.
|
|
211
|
+
- Field schemas (`DependencyMapField`, `BinField`, `ExportsField`, `RepositoryField`, `PublishConfigField`, `PeerDependenciesMetaField`, `StringMapField`) exported for subclasses that extend the model.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT](LICENSE)
|