@graftland/package-manager 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @graftland/package-manager
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Installs dependencies with npm, pnpm or Yarn, and rolls back on failure.
4
+
5
+ ```sh
6
+ npm install @graftland/package-manager effect
7
+ ```
8
+
9
+ `make` detects the project's package manager. Its `install` adds, updates or
10
+ removes dependencies, and restores `package.json` and the lockfile if installing
11
+ fails. `graft add` and `graft remove` use it.
12
+
13
+ ## Documentation
14
+
15
+ - [Command line](https://graftland.github.io/graft/cli/)
16
+
17
+ Part of [Graft](https://github.com/graftland/graft). Licensed under
18
+ [Apache 2.0](https://github.com/graftland/graft/blob/main/LICENSE).
@@ -0,0 +1,61 @@
1
+ import { Changes, PackageName } from "./manifest.js";
2
+ import { Effect, FileSystem, HashMap, Path, PlatformError, Schema } from "effect";
3
+ import { ChildProcessSpawner } from "effect/process";
4
+ export declare const Agent: Schema.Literals<readonly ["npm", "pnpm", "yarn-classic", "yarn-berry"]>;
5
+ export type Agent = typeof Agent.Type;
6
+ declare const PackageManagerError_base: Schema.Class<PackageManagerError, Schema.TaggedStruct<"PackageManagerError", {
7
+ readonly operation: Schema.Literals<readonly ["detect", "install", "recover", "state"]>;
8
+ readonly message: Schema.String;
9
+ readonly exitCode: Schema.optionalKey<Schema.Int>;
10
+ }>, import("effect/Cause").YieldableError & {
11
+ readonly brand: unique symbol;
12
+ }>;
13
+ export declare class PackageManagerError extends PackageManagerError_base {}
14
+ export type Error = PackageManagerError | PlatformError.PlatformError | Schema.SchemaError;
15
+ export interface Options {
16
+ /** Project directory, containing package.json. Use the workspace root for workspace installs. */
17
+ readonly directory: string;
18
+ /** Explicit selection takes precedence over detection. */
19
+ readonly agent?: Agent;
20
+ /** Optional npm_config_user_agent supplied by the caller; used only as a fallback. */
21
+ readonly userAgent?: string;
22
+ }
23
+ /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
24
+ export declare const detectAgent: (options: Options) => Effect.Effect<"npm" | "pnpm" | "yarn-berry" | "yarn-classic", PackageManagerError | PlatformError.PlatformError | Schema.SchemaError, FileSystem.FileSystem | Path.Path>;
25
+ export interface PackageState {
26
+ readonly spec: string;
27
+ /** Missing optional or not-yet-installed dependencies have no version/path. */
28
+ readonly version: string | undefined;
29
+ readonly path: string | undefined;
30
+ }
31
+ /** Direct dependencies only, keyed by their manifest names (including aliases). */
32
+ export type State = HashMap.HashMap<string, PackageState>;
33
+ export interface PackageChange {
34
+ readonly name: string;
35
+ readonly before: PackageState | undefined;
36
+ readonly after: PackageState | undefined;
37
+ }
38
+ export interface StateDiff {
39
+ readonly added: ReadonlyArray<PackageChange>;
40
+ readonly removed: ReadonlyArray<PackageChange>;
41
+ readonly updated: ReadonlyArray<PackageChange>;
42
+ }
43
+ export declare const diffState: (before: State, after: State) => StateDiff;
44
+ export interface InstallResult extends StateDiff {
45
+ readonly agent: Agent;
46
+ }
47
+ export interface PackageManager {
48
+ readonly directory: string;
49
+ readonly agent: Agent;
50
+ readonly readState: Effect.Effect<State, Error>;
51
+ /** Serialized per manager. Empty changes installs the current manifest, including first installs. */
52
+ readonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>;
53
+ }
54
+ /**
55
+ * Own one manager per project. Failed or interrupted installs restore the exact
56
+ * manifest/lockfiles, then attempt to repair node_modules when a lockfile existed.
57
+ * Recovery failures are combined with the original cause. No crash recovery is promised.
58
+ */
59
+ export declare const make: (options: Options) => Effect.Effect<PackageManager, Error, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
60
+ export { Changes, PackageName };
61
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;qBA0Ba,OAAK,OAAA;YAMN,eAAe,MAAM;;;;;;WAGd;;qBAFN,4BAA4B;YAQ7B,QACX,sBAAsB,cAAc,gBAAgB,OAAO;iBAC3C;;WAEP;;WAEA,QAAQ;;WAER;;;qBAuBG,cAAW,SAAA,YAAA,OAAA,uDAAA,sBAAA,cAAA,gBAAA,OAAA,aAAA,WAAA,aAAA,KAAA;iBAyDP;WACP;;WAEA;WACA;;;YAGE,QAAQ,QAAQ,gBAAgB;iBAC3B;WACP;WACA,QAAQ;WACR,OAAO;;iBAEA;WACP,OAAO,cAAc;WACrB,SAAS,cAAc;WACvB,SAAS,cAAc;;qBAEpB,YAAS,QAAY,OAAK,OAAS,UAAQ;iBA0BvC,sBAAsB;WAC7B,OAAO;;iBAEA;WACP;WACA,OAAO;WACP,WAAW,OAAO,OAAO,OAAO;;WAEhC,UAAU,SAAS,YAAY,OAAO,OAAO,eAAe;;;;;;;qBAQzD,OAAI,SACP,YACP,OAAO,OACT,gBACA,OACA,WAAW,aAAa,KAAK,OAAO,oBAAoB"}
package/dist/index.js ADDED
@@ -0,0 +1,204 @@
1
+ import { Changes, PackageName, applyChanges, decodeManifest, encodeManifest, fields } from "./manifest.js";
2
+ import { Array, Effect, Exit, FileSystem, HashMap, Option, Path, Record, Schema, Semaphore, String } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
+ // src/index.ts
5
+ /** Non-interactive package installation. Provide platform services at the application boundary. */
6
+ const Agent = Schema.Literals([
7
+ "npm",
8
+ "pnpm",
9
+ "yarn-classic",
10
+ "yarn-berry"
11
+ ]);
12
+ var PackageManagerError = class extends Schema.TaggedError()("PackageManagerError", {
13
+ operation: Schema.Literals([
14
+ "detect",
15
+ "install",
16
+ "recover",
17
+ "state"
18
+ ]),
19
+ message: Schema.String,
20
+ exitCode: Schema.optionalKey(Schema.Int)
21
+ }) {};
22
+ const lockfiles = [
23
+ "package-lock.json",
24
+ "npm-shrinkwrap.json",
25
+ "pnpm-lock.yaml",
26
+ "yarn.lock"
27
+ ];
28
+ const backupFiles = [
29
+ "package.json",
30
+ ...lockfiles,
31
+ ".npmrc",
32
+ "pnpm-workspace.yaml",
33
+ ".yarnrc",
34
+ ".yarnrc.yml",
35
+ ".yarn/install-state.gz",
36
+ ".pnp.cjs",
37
+ ".pnp.loader.mjs"
38
+ ];
39
+ const executable = (agent) => String.startsWith("yarn")(agent) ? "yarn" : agent;
40
+ /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
41
+ const detectAgent = Effect.fnUntraced(function* (options) {
42
+ if (options.agent !== void 0) return yield* Schema.decodeEffect(Agent)(options.agent);
43
+ const fs = yield* FileSystem.FileSystem;
44
+ const path = yield* Path.Path;
45
+ const manifest = yield* fs.readFileString(path.join(options.directory, "package.json")).pipe(Effect.flatMap(decodeManifest));
46
+ if (manifest.packageManager !== void 0) {
47
+ const match = /^(npm|pnpm|yarn)@(\d+)\.\d+\.\d+(?:[-+].*)?$/.exec(manifest.packageManager);
48
+ if (match === null) return yield* PackageManagerError.make({
49
+ operation: "detect",
50
+ message: `Unsupported packageManager: ${manifest.packageManager}`
51
+ });
52
+ return match[1] === "yarn" ? match[2] === "1" ? "yarn-classic" : "yarn-berry" : match[1];
53
+ }
54
+ const found = yield* Effect.filter(lockfiles, (file) => fs.exists(path.join(options.directory, file)));
55
+ const kinds = Array.dedupe(Array.map(found, (file) => file === "pnpm-lock.yaml" ? "pnpm" : file === "yarn.lock" ? "yarn" : "npm"));
56
+ if (kinds.length > 1) return yield* PackageManagerError.make({
57
+ operation: "detect",
58
+ message: "Conflicting lockfiles; select an agent explicitly"
59
+ });
60
+ if (kinds[0] === "yarn") {
61
+ const lock = yield* fs.readFileString(path.join(options.directory, "yarn.lock"));
62
+ return /^__metadata:/m.test(lock) ? "yarn-berry" : "yarn-classic";
63
+ }
64
+ if (kinds[0] !== void 0) return kinds[0];
65
+ const caller = /^(npm|pnpm|yarn)\/(\d+)/.exec(options.userAgent ?? "");
66
+ return caller?.[1] === "yarn" ? caller[2] === "1" ? "yarn-classic" : "yarn-berry" : caller?.[1] === "pnpm" ? "pnpm" : "npm";
67
+ });
68
+ const diffState = (before, after) => {
69
+ const changes = Array.map(Array.sort(Array.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]), String.Order), (name) => ({
70
+ name,
71
+ before: Option.getOrUndefined(HashMap.get(before, name)),
72
+ after: Option.getOrUndefined(HashMap.get(after, name))
73
+ }));
74
+ return {
75
+ added: Array.filter(changes, (change) => change.before === void 0),
76
+ removed: Array.filter(changes, (change) => change.after === void 0),
77
+ updated: Array.filter(changes, ({ before, after }) => before !== void 0 && after !== void 0 && (before.spec !== after.spec || before.version !== after.version || before.path !== after.path))
78
+ };
79
+ };
80
+ /**
81
+ * Own one manager per project. Failed or interrupted installs restore the exact
82
+ * manifest/lockfiles, then attempt to repair node_modules when a lockfile existed.
83
+ * Recovery failures are combined with the original cause. No crash recovery is promised.
84
+ */
85
+ const make = (options) => Effect.gen(function* () {
86
+ const fs = yield* FileSystem.FileSystem;
87
+ const path = yield* Path.Path;
88
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
89
+ const directory = path.resolve(options.directory);
90
+ const agent = yield* detectAgent({
91
+ ...options,
92
+ directory
93
+ });
94
+ const lock = yield* Semaphore.make(1);
95
+ const manifestFile = path.join(directory, "package.json");
96
+ const readManifest = fs.readFileString(manifestFile).pipe(Effect.flatMap(decodeManifest));
97
+ const installed = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.optionalKey(Schema.String) })));
98
+ const stateOf = Effect.fnUntraced(function* (manifest) {
99
+ if (yield* fs.exists(path.join(directory, ".pnp.cjs"))) return yield* PackageManagerError.make({
100
+ operation: "state",
101
+ message: "Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules"
102
+ });
103
+ const deps = Array.reduce(fields, {}, (all, field) => ({
104
+ ...all,
105
+ ...manifest[field]
106
+ }));
107
+ const entries = yield* Effect.forEach(Record.toEntries(deps), ([name, spec]) => Effect.gen(function* () {
108
+ const file = path.join(directory, "node_modules", name, "package.json");
109
+ if (!(yield* fs.exists(file))) return [name, {
110
+ spec,
111
+ version: void 0,
112
+ path: void 0
113
+ }];
114
+ return [name, {
115
+ spec,
116
+ version: (yield* fs.readFileString(file).pipe(Effect.flatMap(installed))).version,
117
+ path: yield* fs.realPath(path.dirname(file))
118
+ }];
119
+ }));
120
+ return HashMap.fromIterable(entries);
121
+ });
122
+ const readState = Effect.flatMap(readManifest, stateOf);
123
+ const run = Effect.fnUntraced(function* (operation) {
124
+ const recovery = operation === "recover";
125
+ const args = agent === "npm" ? [
126
+ "install",
127
+ "--no-audit",
128
+ "--no-fund",
129
+ ...recovery ? ["--prefer-offline"] : []
130
+ ] : agent === "pnpm" ? [
131
+ "install",
132
+ recovery ? "--frozen-lockfile" : "--no-frozen-lockfile",
133
+ "--config.confirmModulesPurge=false"
134
+ ] : agent === "yarn-classic" ? [
135
+ "install",
136
+ "--non-interactive",
137
+ ...recovery ? ["--frozen-lockfile", "--prefer-offline"] : []
138
+ ] : ["install", ...recovery ? ["--immutable"] : []];
139
+ const code = yield* spawner.exitCode(ChildProcess.make(executable(agent), args, {
140
+ cwd: directory,
141
+ extendEnv: true,
142
+ stdin: "ignore",
143
+ stdout: "inherit",
144
+ stderr: "inherit",
145
+ env: {
146
+ CI: "true",
147
+ ...agent === "yarn-berry" ? {
148
+ YARN_ENABLE_IMMUTABLE_INSTALLS: recovery ? "true" : "false",
149
+ YARN_NODE_LINKER: "node-modules"
150
+ } : {}
151
+ }
152
+ })).pipe(Effect.timeoutOrElse({
153
+ duration: "10 minutes",
154
+ orElse: () => PackageManagerError.make({
155
+ operation,
156
+ message: `${executable(agent)} ${operation} timed out`
157
+ })
158
+ }));
159
+ if (code !== 0) return yield* PackageManagerError.make({
160
+ operation,
161
+ exitCode: code,
162
+ message: `${executable(agent)} ${operation} exited with code ${code}`
163
+ });
164
+ });
165
+ const install = (changes) => lock.withPermits(1)(Effect.gen(function* () {
166
+ const validated = yield* Schema.decodeEffect(Changes)(changes);
167
+ const original = yield* fs.readFileString(manifestFile);
168
+ const manifest = yield* decodeManifest(original);
169
+ const before = yield* stateOf(manifest);
170
+ const text = yield* encodeManifest(applyChanges(manifest, validated), original);
171
+ const backups = yield* Effect.forEach(backupFiles, (name) => Effect.gen(function* () {
172
+ const file = path.join(directory, name);
173
+ return {
174
+ name,
175
+ file,
176
+ bytes: (yield* fs.exists(file)) ? yield* fs.readFile(file).pipe(Effect.asSome) : Option.none()
177
+ };
178
+ }));
179
+ const restore = Array.reduce(backups, Effect.void, (previous, { file, bytes }) => previous.pipe(Effect.onExit(() => Option.match(bytes, {
180
+ onNone: () => fs.remove(file, { force: true }),
181
+ onSome: (bytes) => fs.writeFile(file, bytes)
182
+ }))));
183
+ const agentLocks = agent === "npm" ? ["package-lock.json", "npm-shrinkwrap.json"] : agent === "pnpm" ? ["pnpm-lock.yaml"] : ["yarn.lock"];
184
+ const hasLock = Array.some(backups, ({ name, bytes }) => Array.contains(agentLocks, name) && Option.isSome(bytes));
185
+ return yield* Effect.acquireUseRelease(Effect.void, () => Effect.gen(function* () {
186
+ yield* fs.writeFileString(manifestFile, text);
187
+ yield* run("install");
188
+ const after = yield* readState;
189
+ return {
190
+ agent,
191
+ ...diffState(before, after)
192
+ };
193
+ }), (_, exit) => Exit.isSuccess(exit) ? Effect.void : restore.pipe(Effect.andThen(hasLock ? run("recover") : Effect.void), Effect.onExit(() => restore)));
194
+ }));
195
+ return {
196
+ directory,
197
+ agent,
198
+ readState: lock.withPermits(1)(readState),
199
+ install
200
+ };
201
+ });
202
+ export { Agent, Changes, PackageManagerError, PackageName, detectAgent, diffState, make };
203
+
204
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["Str","Arr"],"sources":["../src/index.ts"],"sourcesContent":["/** Non-interactive package installation. Provide platform services at the application boundary. */\nimport {\n\tArray as Arr,\n\tEffect,\n\tExit,\n\tFileSystem,\n\tHashMap,\n\tOption,\n\tPath,\n\tRecord,\n\tSchema,\n\tSemaphore,\n\tString as Str,\n} from \"effect\"\nimport type { PlatformError } from \"effect\"\nimport { ChildProcess, ChildProcessSpawner } from \"effect/process\"\nimport {\n\tapplyChanges,\n\tChanges,\n\tdecodeManifest,\n\tencodeManifest,\n\tfields,\n\ttype Manifest,\n} from \"./manifest\"\nexport { Changes, PackageName } from \"./manifest\"\n\nexport const Agent = Schema.Literals([\n\t\"npm\",\n\t\"pnpm\",\n\t\"yarn-classic\",\n\t\"yarn-berry\",\n])\nexport type Agent = typeof Agent.Type\nexport class PackageManagerError extends Schema.TaggedError<\n\tPackageManagerError,\n\t{ readonly brand: unique symbol }\n>()(\"PackageManagerError\", {\n\toperation: Schema.Literals([\"detect\", \"install\", \"recover\", \"state\"]),\n\tmessage: Schema.String,\n\texitCode: Schema.optionalKey(Schema.Int),\n}) {}\nexport type Error =\n\tPackageManagerError | PlatformError.PlatformError | Schema.SchemaError\nexport interface Options {\n\t/** Project directory, containing package.json. Use the workspace root for workspace installs. */\n\treadonly directory: string\n\t/** Explicit selection takes precedence over detection. */\n\treadonly agent?: Agent\n\t/** Optional npm_config_user_agent supplied by the caller; used only as a fallback. */\n\treadonly userAgent?: string\n}\nconst lockfiles = [\n\t\"package-lock.json\",\n\t\"npm-shrinkwrap.json\",\n\t\"pnpm-lock.yaml\",\n\t\"yarn.lock\",\n] as const\nconst backupFiles = [\n\t\"package.json\",\n\t...lockfiles,\n\t\".npmrc\",\n\t\"pnpm-workspace.yaml\",\n\t\".yarnrc\",\n\t\".yarnrc.yml\",\n\t\".yarn/install-state.gz\",\n\t\".pnp.cjs\",\n\t\".pnp.loader.mjs\",\n]\nconst executable = (agent: Agent) =>\n\tStr.startsWith(\"yarn\")(agent) ? \"yarn\" : agent\n\n/** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */\nexport const detectAgent = Effect.fnUntraced(function* (options: Options) {\n\tif (options.agent !== undefined)\n\t\treturn yield* Schema.decodeEffect(Agent)(options.agent)\n\tconst fs = yield* FileSystem.FileSystem\n\tconst path = yield* Path.Path\n\tconst manifest = yield* fs\n\t\t.readFileString(path.join(options.directory, \"package.json\"))\n\t\t.pipe(Effect.flatMap(decodeManifest))\n\tif (manifest.packageManager !== undefined) {\n\t\tconst match = /^(npm|pnpm|yarn)@(\\d+)\\.\\d+\\.\\d+(?:[-+].*)?$/.exec(\n\t\t\tmanifest.packageManager,\n\t\t)\n\t\tif (match === null)\n\t\t\treturn yield* PackageManagerError.make({\n\t\t\t\toperation: \"detect\",\n\t\t\t\tmessage: `Unsupported packageManager: ${manifest.packageManager}`,\n\t\t\t})\n\t\treturn match[1] === \"yarn\"\n\t\t\t? match[2] === \"1\"\n\t\t\t\t? \"yarn-classic\"\n\t\t\t\t: \"yarn-berry\"\n\t\t\t: (match[1] as \"npm\" | \"pnpm\")\n\t}\n\tconst found = yield* Effect.filter(lockfiles, (file) =>\n\t\tfs.exists(path.join(options.directory, file)),\n\t)\n\tconst kinds = Arr.dedupe(\n\t\tArr.map(found, (file) =>\n\t\t\tfile === \"pnpm-lock.yaml\"\n\t\t\t\t? \"pnpm\"\n\t\t\t\t: file === \"yarn.lock\"\n\t\t\t\t\t? \"yarn\"\n\t\t\t\t\t: \"npm\",\n\t\t),\n\t)\n\tif (kinds.length > 1)\n\t\treturn yield* PackageManagerError.make({\n\t\t\toperation: \"detect\",\n\t\t\tmessage: \"Conflicting lockfiles; select an agent explicitly\",\n\t\t})\n\tif (kinds[0] === \"yarn\") {\n\t\tconst lock = yield* fs.readFileString(\n\t\t\tpath.join(options.directory, \"yarn.lock\"),\n\t\t)\n\t\treturn /^__metadata:/m.test(lock) ? \"yarn-berry\" : \"yarn-classic\"\n\t}\n\tif (kinds[0] !== undefined) return kinds[0]\n\tconst caller = /^(npm|pnpm|yarn)\\/(\\d+)/.exec(options.userAgent ?? \"\")\n\treturn caller?.[1] === \"yarn\"\n\t\t? caller[2] === \"1\"\n\t\t\t? \"yarn-classic\"\n\t\t\t: \"yarn-berry\"\n\t\t: caller?.[1] === \"pnpm\"\n\t\t\t? \"pnpm\"\n\t\t\t: \"npm\"\n})\n\nexport interface PackageState {\n\treadonly spec: string\n\t/** Missing optional or not-yet-installed dependencies have no version/path. */\n\treadonly version: string | undefined\n\treadonly path: string | undefined\n}\n/** Direct dependencies only, keyed by their manifest names (including aliases). */\nexport type State = HashMap.HashMap<string, PackageState>\nexport interface PackageChange {\n\treadonly name: string\n\treadonly before: PackageState | undefined\n\treadonly after: PackageState | undefined\n}\nexport interface StateDiff {\n\treadonly added: ReadonlyArray<PackageChange>\n\treadonly removed: ReadonlyArray<PackageChange>\n\treadonly updated: ReadonlyArray<PackageChange>\n}\nexport const diffState = (before: State, after: State): StateDiff => {\n\tconst changes = Arr.map(\n\t\tArr.sort(\n\t\t\tArr.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]),\n\t\t\tStr.Order,\n\t\t),\n\t\t(name) => ({\n\t\t\tname,\n\t\t\tbefore: Option.getOrUndefined(HashMap.get(before, name)),\n\t\t\tafter: Option.getOrUndefined(HashMap.get(after, name)),\n\t\t}),\n\t)\n\treturn {\n\t\tadded: Arr.filter(changes, (change) => change.before === undefined),\n\t\tremoved: Arr.filter(changes, (change) => change.after === undefined),\n\t\tupdated: Arr.filter(\n\t\t\tchanges,\n\t\t\t({ before, after }) =>\n\t\t\t\tbefore !== undefined &&\n\t\t\t\tafter !== undefined &&\n\t\t\t\t(before.spec !== after.spec ||\n\t\t\t\t\tbefore.version !== after.version ||\n\t\t\t\t\tbefore.path !== after.path),\n\t\t),\n\t}\n}\nexport interface InstallResult extends StateDiff {\n\treadonly agent: Agent\n}\nexport interface PackageManager {\n\treadonly directory: string\n\treadonly agent: Agent\n\treadonly readState: Effect.Effect<State, Error>\n\t/** Serialized per manager. Empty changes installs the current manifest, including first installs. */\n\treadonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>\n}\n\n/**\n * Own one manager per project. Failed or interrupted installs restore the exact\n * manifest/lockfiles, then attempt to repair node_modules when a lockfile existed.\n * Recovery failures are combined with the original cause. No crash recovery is promised.\n */\nexport const make = (\n\toptions: Options,\n): Effect.Effect<\n\tPackageManager,\n\tError,\n\tFileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner\n> =>\n\tEffect.gen(function* () {\n\t\tconst fs = yield* FileSystem.FileSystem\n\t\tconst path = yield* Path.Path\n\t\tconst spawner = yield* ChildProcessSpawner.ChildProcessSpawner\n\t\tconst directory = path.resolve(options.directory)\n\t\tconst agent = yield* detectAgent({ ...options, directory })\n\t\tconst lock = yield* Semaphore.make(1)\n\t\tconst manifestFile = path.join(directory, \"package.json\")\n\t\tconst readManifest = fs\n\t\t\t.readFileString(manifestFile)\n\t\t\t.pipe(Effect.flatMap(decodeManifest))\n\t\tconst installed = Schema.decodeUnknownEffect(\n\t\t\tSchema.fromJsonString(\n\t\t\t\tSchema.Struct({ version: Schema.optionalKey(Schema.String) }),\n\t\t\t),\n\t\t)\n\t\tconst stateOf = Effect.fnUntraced(function* (manifest: Manifest) {\n\t\t\tif (yield* fs.exists(path.join(directory, \".pnp.cjs\")))\n\t\t\t\treturn yield* PackageManagerError.make({\n\t\t\t\t\toperation: \"state\",\n\t\t\t\t\tmessage:\n\t\t\t\t\t\t\"Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules\",\n\t\t\t\t})\n\t\t\tconst deps = Arr.reduce(\n\t\t\t\tfields,\n\t\t\t\t{} as Readonly<Record<string, string>>,\n\t\t\t\t(all, field) => ({ ...all, ...manifest[field] }),\n\t\t\t)\n\t\t\tconst entries = yield* Effect.forEach(\n\t\t\t\tRecord.toEntries(deps),\n\t\t\t\t([name, spec]) =>\n\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\tconst file = path.join(\n\t\t\t\t\t\t\tdirectory,\n\t\t\t\t\t\t\t\"node_modules\",\n\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\"package.json\",\n\t\t\t\t\t\t)\n\t\t\t\t\t\tif (!(yield* fs.exists(file)))\n\t\t\t\t\t\t\treturn [\n\t\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\t{ spec, version: undefined, path: undefined },\n\t\t\t\t\t\t\t] as const\n\t\t\t\t\t\tconst pkg = yield* fs\n\t\t\t\t\t\t\t.readFileString(file)\n\t\t\t\t\t\t\t.pipe(Effect.flatMap(installed))\n\t\t\t\t\t\treturn [\n\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tspec,\n\t\t\t\t\t\t\t\tversion: pkg.version,\n\t\t\t\t\t\t\t\tpath: yield* fs.realPath(path.dirname(file)),\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t] as const\n\t\t\t\t\t}),\n\t\t\t)\n\t\t\treturn HashMap.fromIterable<string, PackageState>(entries)\n\t\t})\n\t\tconst readState = Effect.flatMap(readManifest, stateOf)\n\t\tconst run = Effect.fnUntraced(function* (operation: \"install\" | \"recover\") {\n\t\t\tconst recovery = operation === \"recover\"\n\t\t\tconst args =\n\t\t\t\tagent === \"npm\"\n\t\t\t\t\t? [\n\t\t\t\t\t\t\t\"install\",\n\t\t\t\t\t\t\t\"--no-audit\",\n\t\t\t\t\t\t\t\"--no-fund\",\n\t\t\t\t\t\t\t...(recovery ? [\"--prefer-offline\"] : []),\n\t\t\t\t\t\t]\n\t\t\t\t\t: agent === \"pnpm\"\n\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\"install\",\n\t\t\t\t\t\t\t\trecovery ? \"--frozen-lockfile\" : \"--no-frozen-lockfile\",\n\t\t\t\t\t\t\t\t\"--config.confirmModulesPurge=false\",\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t: agent === \"yarn-classic\"\n\t\t\t\t\t\t\t? [\n\t\t\t\t\t\t\t\t\t\"install\",\n\t\t\t\t\t\t\t\t\t\"--non-interactive\",\n\t\t\t\t\t\t\t\t\t...(recovery\n\t\t\t\t\t\t\t\t\t\t? [\"--frozen-lockfile\", \"--prefer-offline\"]\n\t\t\t\t\t\t\t\t\t\t: []),\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t: [\"install\", ...(recovery ? [\"--immutable\"] : [])]\n\t\t\tconst code = yield* spawner\n\t\t\t\t.exitCode(\n\t\t\t\t\tChildProcess.make(executable(agent), args, {\n\t\t\t\t\t\tcwd: directory,\n\t\t\t\t\t\textendEnv: true,\n\t\t\t\t\t\tstdin: \"ignore\",\n\t\t\t\t\t\tstdout: \"inherit\",\n\t\t\t\t\t\tstderr: \"inherit\",\n\t\t\t\t\t\tenv: {\n\t\t\t\t\t\t\tCI: \"true\",\n\t\t\t\t\t\t\t...(agent === \"yarn-berry\"\n\t\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\t\tYARN_ENABLE_IMMUTABLE_INSTALLS: recovery ? \"true\" : \"false\",\n\t\t\t\t\t\t\t\t\t\tYARN_NODE_LINKER: \"node-modules\",\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\t.pipe(\n\t\t\t\t\tEffect.timeoutOrElse({\n\t\t\t\t\t\tduration: \"10 minutes\",\n\t\t\t\t\t\torElse: () =>\n\t\t\t\t\t\t\tPackageManagerError.make({\n\t\t\t\t\t\t\t\toperation,\n\t\t\t\t\t\t\t\tmessage: `${executable(agent)} ${operation} timed out`,\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\tif (code !== 0)\n\t\t\t\treturn yield* PackageManagerError.make({\n\t\t\t\t\toperation,\n\t\t\t\t\texitCode: code,\n\t\t\t\t\tmessage: `${executable(agent)} ${operation} exited with code ${code}`,\n\t\t\t\t})\n\t\t})\n\t\tconst install = (changes: Changes) =>\n\t\t\tlock.withPermits(1)(\n\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\tconst validated = yield* Schema.decodeEffect(Changes)(changes)\n\t\t\t\t\tconst original = yield* fs.readFileString(manifestFile)\n\t\t\t\t\tconst manifest = yield* decodeManifest(original)\n\t\t\t\t\tconst before = yield* stateOf(manifest)\n\t\t\t\t\tconst text = yield* encodeManifest(\n\t\t\t\t\t\tapplyChanges(manifest, validated),\n\t\t\t\t\t\toriginal,\n\t\t\t\t\t)\n\t\t\t\t\tconst backups = yield* Effect.forEach(backupFiles, (name) =>\n\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\tconst file = path.join(directory, name)\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tname,\n\t\t\t\t\t\t\t\tfile,\n\t\t\t\t\t\t\t\tbytes: (yield* fs.exists(file))\n\t\t\t\t\t\t\t\t\t? yield* fs.readFile(file).pipe(Effect.asSome)\n\t\t\t\t\t\t\t\t\t: Option.none<Uint8Array>(),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t\t// Every file gets a restoration attempt; onExit combines failures.\n\t\t\t\t\tconst restore = Arr.reduce<\n\t\t\t\t\t\t(typeof backups)[number],\n\t\t\t\t\t\tEffect.Effect<void, PlatformError.PlatformError>\n\t\t\t\t\t>(backups, Effect.void, (previous, { file, bytes }) =>\n\t\t\t\t\t\tprevious.pipe(\n\t\t\t\t\t\t\tEffect.onExit(() =>\n\t\t\t\t\t\t\t\tOption.match(bytes, {\n\t\t\t\t\t\t\t\t\tonNone: () => fs.remove(file, { force: true }),\n\t\t\t\t\t\t\t\t\tonSome: (bytes) => fs.writeFile(file, bytes),\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t),\n\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t\tconst agentLocks =\n\t\t\t\t\t\tagent === \"npm\"\n\t\t\t\t\t\t\t? [\"package-lock.json\", \"npm-shrinkwrap.json\"]\n\t\t\t\t\t\t\t: agent === \"pnpm\"\n\t\t\t\t\t\t\t\t? [\"pnpm-lock.yaml\"]\n\t\t\t\t\t\t\t\t: [\"yarn.lock\"]\n\t\t\t\t\tconst hasLock = Arr.some(\n\t\t\t\t\t\tbackups,\n\t\t\t\t\t\t({ name, bytes }) =>\n\t\t\t\t\t\t\tArr.contains(agentLocks, name) && Option.isSome(bytes),\n\t\t\t\t\t)\n\t\t\t\t\treturn yield* Effect.acquireUseRelease(\n\t\t\t\t\t\tEffect.void,\n\t\t\t\t\t\t() =>\n\t\t\t\t\t\t\tEffect.gen(function* () {\n\t\t\t\t\t\t\t\tyield* fs.writeFileString(manifestFile, text)\n\t\t\t\t\t\t\t\tyield* run(\"install\")\n\t\t\t\t\t\t\t\tconst after = yield* readState\n\t\t\t\t\t\t\t\treturn { agent, ...diffState(before, after) }\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t(_, exit) =>\n\t\t\t\t\t\t\tExit.isSuccess(exit)\n\t\t\t\t\t\t\t\t? Effect.void\n\t\t\t\t\t\t\t\t: restore.pipe(\n\t\t\t\t\t\t\t\t\t\tEffect.andThen(hasLock ? run(\"recover\") : Effect.void),\n\t\t\t\t\t\t\t\t\t\t// Recovery itself may rewrite the manifest or lockfile, including on failure.\n\t\t\t\t\t\t\t\t\t\tEffect.onExit(() => restore),\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t)\n\t\t\t\t}),\n\t\t\t)\n\t\treturn {\n\t\t\tdirectory,\n\t\t\tagent,\n\t\t\treadState: lock.withPermits(1)(readState),\n\t\t\tinstall,\n\t\t}\n\t})\n"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { Effect, Schema } from "effect";
2
+ export declare const PackageName: Schema.String;
3
+ export declare const Changes: Schema.$Record<Schema.String, Schema.Union<readonly [Schema.String, Schema.Null]>>;
4
+ /** A spec adds or updates a dependency; null removes it from all dependency fields. */
5
+ export type Changes = typeof Changes.Type;
6
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","names":[],"sources":["../src/manifest.ts"],"mappings":";qBAEa,aAAW,OAAA;qBAkBX,SAAO,OAAA,QAAA,OAAA,QAAA,OAAA,gBAAA,OAAA,QAAA,OAAA;;YAKR,iBAAiB,QAAQ"}
@@ -0,0 +1,41 @@
1
+ import { Array, Effect, Record, Schema, String } from "effect";
2
+ // src/manifest.ts
3
+ const PackageName = Schema.String.check(Schema.isPattern(/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i), Schema.isMaxLength(214));
4
+ const Dependencies = Schema.Record(PackageName, Schema.String);
5
+ const Manifest = Schema.StructWithRest(Schema.Struct({
6
+ packageManager: Schema.optionalKey(Schema.String),
7
+ dependencies: Schema.optionalKey(Dependencies),
8
+ devDependencies: Schema.optionalKey(Dependencies),
9
+ optionalDependencies: Schema.optionalKey(Dependencies)
10
+ }), [Schema.Record(Schema.String, Schema.Unknown)]);
11
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
12
+ const Changes = Schema.Record(PackageName, Schema.Union([Schema.String.check(Schema.isPattern(/\S/)), Schema.Null]));
13
+ const fields = [
14
+ "dependencies",
15
+ "devDependencies",
16
+ "optionalDependencies"
17
+ ];
18
+ const applyChanges = (manifest, changes) => {
19
+ let next = manifest;
20
+ for (const [name, spec] of Record.toEntries(changes)) {
21
+ const existing = Array.filter(fields, (field) => Record.has(next[field] ?? {}, name));
22
+ const targets = spec === null ? fields : existing.length > 0 ? existing : ["dependencies"];
23
+ for (const field of targets) {
24
+ const deps = next[field] ?? {};
25
+ next = {
26
+ ...next,
27
+ [field]: spec === null ? Record.remove(deps, name) : Record.set(deps, name, spec)
28
+ };
29
+ }
30
+ }
31
+ return next;
32
+ };
33
+ const encodeManifest = Effect.fnUntraced(function* (manifest, original) {
34
+ const space = /^[{[]\r?\n([ \t]+)/.exec(original)?.[1] ?? 2;
35
+ const text = yield* Schema.encodeEffect(Schema.fromJsonString(Manifest, { space }))(manifest);
36
+ const eol = String.includes("\r\n")(original) ? "\r\n" : "\n";
37
+ return String.replaceAll("\n", eol)(text) + (/\r?\n$/.test(original) ? eol : "");
38
+ });
39
+ export { Changes, Manifest, PackageName, applyChanges, decodeManifest, encodeManifest, fields };
40
+
41
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","names":["Arr","Str"],"sources":["../src/manifest.ts"],"sourcesContent":["import { Array as Arr, Effect, Record, Schema, String as Str } from \"effect\"\n\nexport const PackageName = Schema.String.check(\n\tSchema.isPattern(/^(?:@[a-z0-9-~][a-z0-9-._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/i),\n\tSchema.isMaxLength(214),\n)\nconst Dependencies = Schema.Record(PackageName, Schema.String)\nexport const Manifest = Schema.StructWithRest(\n\tSchema.Struct({\n\t\tpackageManager: Schema.optionalKey(Schema.String),\n\t\tdependencies: Schema.optionalKey(Dependencies),\n\t\tdevDependencies: Schema.optionalKey(Dependencies),\n\t\toptionalDependencies: Schema.optionalKey(Dependencies),\n\t}),\n\t[Schema.Record(Schema.String, Schema.Unknown)],\n)\nexport type Manifest = typeof Manifest.Type\nexport const decodeManifest = Schema.decodeUnknownEffect(\n\tSchema.fromJsonString(Manifest),\n)\nexport const Changes = Schema.Record(\n\tPackageName,\n\tSchema.Union([Schema.String.check(Schema.isPattern(/\\S/)), Schema.Null]),\n)\n/** A spec adds or updates a dependency; null removes it from all dependency fields. */\nexport type Changes = typeof Changes.Type\nexport const fields = [\n\t\"dependencies\",\n\t\"devDependencies\",\n\t\"optionalDependencies\",\n] as const\n\nexport const applyChanges = (\n\tmanifest: Manifest,\n\tchanges: Changes,\n): Manifest => {\n\tlet next = manifest\n\tfor (const [name, spec] of Record.toEntries(changes)) {\n\t\tconst existing = Arr.filter(fields, (field) =>\n\t\t\tRecord.has(next[field] ?? {}, name),\n\t\t)\n\t\tconst targets =\n\t\t\tspec === null\n\t\t\t\t? fields\n\t\t\t\t: existing.length > 0\n\t\t\t\t\t? existing\n\t\t\t\t\t: ([\"dependencies\"] as const)\n\t\tfor (const field of targets) {\n\t\t\tconst deps = next[field] ?? {}\n\t\t\tnext = {\n\t\t\t\t...next,\n\t\t\t\t[field]:\n\t\t\t\t\tspec === null\n\t\t\t\t\t\t? Record.remove(deps, name)\n\t\t\t\t\t\t: Record.set(deps, name, spec),\n\t\t\t}\n\t\t}\n\t}\n\treturn next\n}\n\nexport const encodeManifest = Effect.fnUntraced(function* (\n\tmanifest: Manifest,\n\toriginal: string,\n) {\n\tconst space = /^[{[]\\r?\\n([ \\t]+)/.exec(original)?.[1] ?? 2\n\tconst text = yield* Schema.encodeEffect(\n\t\tSchema.fromJsonString(Manifest, { space }),\n\t)(manifest)\n\tconst eol = Str.includes(\"\\r\\n\")(original) ? \"\\r\\n\" : \"\\n\"\n\treturn Str.replaceAll(\"\\n\", eol)(text) + (/\\r?\\n$/.test(original) ? eol : \"\")\n})\n"],"mappings":";;AAEA,MAAa,cAAc,OAAO,OAAO,MACxC,OAAO,UAAU,2DAA2D,GAC5E,OAAO,YAAY,GAAG,CACvB;AACA,MAAM,eAAe,OAAO,OAAO,aAAa,OAAO,MAAM;AAC7D,MAAa,WAAW,OAAO,eAC9B,OAAO,OAAO;CACb,gBAAgB,OAAO,YAAY,OAAO,MAAM;CAChD,cAAc,OAAO,YAAY,YAAY;CAC7C,iBAAiB,OAAO,YAAY,YAAY;CAChD,sBAAsB,OAAO,YAAY,YAAY;AACtD,CAAC,GACD,CAAC,OAAO,OAAO,OAAO,QAAQ,OAAO,OAAO,CAAC,CAC9C;AAEA,MAAa,iBAAiB,OAAO,oBACpC,OAAO,eAAe,QAAQ,CAC/B;AACA,MAAa,UAAU,OAAO,OAC7B,aACA,OAAO,MAAM,CAAC,OAAO,OAAO,MAAM,OAAO,UAAU,IAAI,CAAC,GAAG,OAAO,IAAI,CAAC,CACxE;AAGA,MAAa,SAAS;CACrB;CACA;CACA;AACD;AAEA,MAAa,gBACZ,UACA,YACc;CACd,IAAI,OAAO;CACX,KAAK,MAAM,CAAC,MAAM,SAAS,OAAO,UAAU,OAAO,GAAG;EACrD,MAAM,WAAWA,MAAI,OAAO,SAAS,UACpC,OAAO,IAAI,KAAK,UAAU,CAAC,GAAG,IAAI,CACnC;EACA,MAAM,UACL,SAAS,OACN,SACA,SAAS,SAAS,IACjB,WACC,CAAC,cAAc;EACrB,KAAK,MAAM,SAAS,SAAS;GAC5B,MAAM,OAAO,KAAK,UAAU,CAAC;GAC7B,OAAO;IACN,GAAG;KACF,QACA,SAAS,OACN,OAAO,OAAO,MAAM,IAAI,IACxB,OAAO,IAAI,MAAM,MAAM,IAAI;GAChC;EACD;CACD;CACA,OAAO;AACR;AAEA,MAAa,iBAAiB,OAAO,WAAW,WAC/C,UACA,UACC;CACD,MAAM,QAAQ,qBAAqB,KAAK,QAAQ,CAAC,GAAG,MAAM;CAC1D,MAAM,OAAO,OAAO,OAAO,aAC1B,OAAO,eAAe,UAAU,EAAE,MAAM,CAAC,CAC1C,CAAC,CAAC,QAAQ;CACV,MAAM,MAAMC,OAAI,SAAS,MAAM,CAAC,CAAC,QAAQ,IAAI,SAAS;CACtD,OAAOA,OAAI,WAAW,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,SAAS,KAAK,QAAQ,IAAI,MAAM;AAC3E,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@graftland/package-manager",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Installs dependencies with npm, pnpm or Yarn, and rolls back on failure",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/graftland/graft.git",
10
+ "directory": "packages/package-manager"
11
+ },
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist",
15
+ "src"
16
+ ],
17
+ "exports": {
18
+ ".": "./dist/index.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "peerDependencies": {
22
+ "effect": "4.1.2"
23
+ },
24
+ "devDependencies": {
25
+ "@effect/vitest": "4.1.2",
26
+ "effect": "4.1.2",
27
+ "vitest": "5.1.0",
28
+ "@effect/platform-node": "4.1.2"
29
+ },
30
+ "dependencies": {},
31
+ "scripts": {
32
+ "build": "tsdown",
33
+ "test": "vitest run"
34
+ }
6
35
  }
package/src/index.ts ADDED
@@ -0,0 +1,391 @@
1
+ /** Non-interactive package installation. Provide platform services at the application boundary. */
2
+ import {
3
+ Array as Arr,
4
+ Effect,
5
+ Exit,
6
+ FileSystem,
7
+ HashMap,
8
+ Option,
9
+ Path,
10
+ Record,
11
+ Schema,
12
+ Semaphore,
13
+ String as Str,
14
+ } from "effect"
15
+ import type { PlatformError } from "effect"
16
+ import { ChildProcess, ChildProcessSpawner } from "effect/process"
17
+ import {
18
+ applyChanges,
19
+ Changes,
20
+ decodeManifest,
21
+ encodeManifest,
22
+ fields,
23
+ type Manifest,
24
+ } from "./manifest"
25
+ export { Changes, PackageName } from "./manifest"
26
+
27
+ export const Agent = Schema.Literals([
28
+ "npm",
29
+ "pnpm",
30
+ "yarn-classic",
31
+ "yarn-berry",
32
+ ])
33
+ export type Agent = typeof Agent.Type
34
+ export class PackageManagerError extends Schema.TaggedError<
35
+ PackageManagerError,
36
+ { readonly brand: unique symbol }
37
+ >()("PackageManagerError", {
38
+ operation: Schema.Literals(["detect", "install", "recover", "state"]),
39
+ message: Schema.String,
40
+ exitCode: Schema.optionalKey(Schema.Int),
41
+ }) {}
42
+ export type Error =
43
+ PackageManagerError | PlatformError.PlatformError | Schema.SchemaError
44
+ export interface Options {
45
+ /** Project directory, containing package.json. Use the workspace root for workspace installs. */
46
+ readonly directory: string
47
+ /** Explicit selection takes precedence over detection. */
48
+ readonly agent?: Agent
49
+ /** Optional npm_config_user_agent supplied by the caller; used only as a fallback. */
50
+ readonly userAgent?: string
51
+ }
52
+ const lockfiles = [
53
+ "package-lock.json",
54
+ "npm-shrinkwrap.json",
55
+ "pnpm-lock.yaml",
56
+ "yarn.lock",
57
+ ] as const
58
+ const backupFiles = [
59
+ "package.json",
60
+ ...lockfiles,
61
+ ".npmrc",
62
+ "pnpm-workspace.yaml",
63
+ ".yarnrc",
64
+ ".yarnrc.yml",
65
+ ".yarn/install-state.gz",
66
+ ".pnp.cjs",
67
+ ".pnp.loader.mjs",
68
+ ]
69
+ const executable = (agent: Agent) =>
70
+ Str.startsWith("yarn")(agent) ? "yarn" : agent
71
+
72
+ /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
73
+ export const detectAgent = Effect.fnUntraced(function* (options: Options) {
74
+ if (options.agent !== undefined)
75
+ return yield* Schema.decodeEffect(Agent)(options.agent)
76
+ const fs = yield* FileSystem.FileSystem
77
+ const path = yield* Path.Path
78
+ const manifest = yield* fs
79
+ .readFileString(path.join(options.directory, "package.json"))
80
+ .pipe(Effect.flatMap(decodeManifest))
81
+ if (manifest.packageManager !== undefined) {
82
+ const match = /^(npm|pnpm|yarn)@(\d+)\.\d+\.\d+(?:[-+].*)?$/.exec(
83
+ manifest.packageManager,
84
+ )
85
+ if (match === null)
86
+ return yield* PackageManagerError.make({
87
+ operation: "detect",
88
+ message: `Unsupported packageManager: ${manifest.packageManager}`,
89
+ })
90
+ return match[1] === "yarn"
91
+ ? match[2] === "1"
92
+ ? "yarn-classic"
93
+ : "yarn-berry"
94
+ : (match[1] as "npm" | "pnpm")
95
+ }
96
+ const found = yield* Effect.filter(lockfiles, (file) =>
97
+ fs.exists(path.join(options.directory, file)),
98
+ )
99
+ const kinds = Arr.dedupe(
100
+ Arr.map(found, (file) =>
101
+ file === "pnpm-lock.yaml"
102
+ ? "pnpm"
103
+ : file === "yarn.lock"
104
+ ? "yarn"
105
+ : "npm",
106
+ ),
107
+ )
108
+ if (kinds.length > 1)
109
+ return yield* PackageManagerError.make({
110
+ operation: "detect",
111
+ message: "Conflicting lockfiles; select an agent explicitly",
112
+ })
113
+ if (kinds[0] === "yarn") {
114
+ const lock = yield* fs.readFileString(
115
+ path.join(options.directory, "yarn.lock"),
116
+ )
117
+ return /^__metadata:/m.test(lock) ? "yarn-berry" : "yarn-classic"
118
+ }
119
+ if (kinds[0] !== undefined) return kinds[0]
120
+ const caller = /^(npm|pnpm|yarn)\/(\d+)/.exec(options.userAgent ?? "")
121
+ return caller?.[1] === "yarn"
122
+ ? caller[2] === "1"
123
+ ? "yarn-classic"
124
+ : "yarn-berry"
125
+ : caller?.[1] === "pnpm"
126
+ ? "pnpm"
127
+ : "npm"
128
+ })
129
+
130
+ export interface PackageState {
131
+ readonly spec: string
132
+ /** Missing optional or not-yet-installed dependencies have no version/path. */
133
+ readonly version: string | undefined
134
+ readonly path: string | undefined
135
+ }
136
+ /** Direct dependencies only, keyed by their manifest names (including aliases). */
137
+ export type State = HashMap.HashMap<string, PackageState>
138
+ export interface PackageChange {
139
+ readonly name: string
140
+ readonly before: PackageState | undefined
141
+ readonly after: PackageState | undefined
142
+ }
143
+ export interface StateDiff {
144
+ readonly added: ReadonlyArray<PackageChange>
145
+ readonly removed: ReadonlyArray<PackageChange>
146
+ readonly updated: ReadonlyArray<PackageChange>
147
+ }
148
+ export const diffState = (before: State, after: State): StateDiff => {
149
+ const changes = Arr.map(
150
+ Arr.sort(
151
+ Arr.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]),
152
+ Str.Order,
153
+ ),
154
+ (name) => ({
155
+ name,
156
+ before: Option.getOrUndefined(HashMap.get(before, name)),
157
+ after: Option.getOrUndefined(HashMap.get(after, name)),
158
+ }),
159
+ )
160
+ return {
161
+ added: Arr.filter(changes, (change) => change.before === undefined),
162
+ removed: Arr.filter(changes, (change) => change.after === undefined),
163
+ updated: Arr.filter(
164
+ changes,
165
+ ({ before, after }) =>
166
+ before !== undefined &&
167
+ after !== undefined &&
168
+ (before.spec !== after.spec ||
169
+ before.version !== after.version ||
170
+ before.path !== after.path),
171
+ ),
172
+ }
173
+ }
174
+ export interface InstallResult extends StateDiff {
175
+ readonly agent: Agent
176
+ }
177
+ export interface PackageManager {
178
+ readonly directory: string
179
+ readonly agent: Agent
180
+ readonly readState: Effect.Effect<State, Error>
181
+ /** Serialized per manager. Empty changes installs the current manifest, including first installs. */
182
+ readonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>
183
+ }
184
+
185
+ /**
186
+ * Own one manager per project. Failed or interrupted installs restore the exact
187
+ * manifest/lockfiles, then attempt to repair node_modules when a lockfile existed.
188
+ * Recovery failures are combined with the original cause. No crash recovery is promised.
189
+ */
190
+ export const make = (
191
+ options: Options,
192
+ ): Effect.Effect<
193
+ PackageManager,
194
+ Error,
195
+ FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
196
+ > =>
197
+ Effect.gen(function* () {
198
+ const fs = yield* FileSystem.FileSystem
199
+ const path = yield* Path.Path
200
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
201
+ const directory = path.resolve(options.directory)
202
+ const agent = yield* detectAgent({ ...options, directory })
203
+ const lock = yield* Semaphore.make(1)
204
+ const manifestFile = path.join(directory, "package.json")
205
+ const readManifest = fs
206
+ .readFileString(manifestFile)
207
+ .pipe(Effect.flatMap(decodeManifest))
208
+ const installed = Schema.decodeUnknownEffect(
209
+ Schema.fromJsonString(
210
+ Schema.Struct({ version: Schema.optionalKey(Schema.String) }),
211
+ ),
212
+ )
213
+ const stateOf = Effect.fnUntraced(function* (manifest: Manifest) {
214
+ if (yield* fs.exists(path.join(directory, ".pnp.cjs")))
215
+ return yield* PackageManagerError.make({
216
+ operation: "state",
217
+ message:
218
+ "Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules",
219
+ })
220
+ const deps = Arr.reduce(
221
+ fields,
222
+ {} as Readonly<Record<string, string>>,
223
+ (all, field) => ({ ...all, ...manifest[field] }),
224
+ )
225
+ const entries = yield* Effect.forEach(
226
+ Record.toEntries(deps),
227
+ ([name, spec]) =>
228
+ Effect.gen(function* () {
229
+ const file = path.join(
230
+ directory,
231
+ "node_modules",
232
+ name,
233
+ "package.json",
234
+ )
235
+ if (!(yield* fs.exists(file)))
236
+ return [
237
+ name,
238
+ { spec, version: undefined, path: undefined },
239
+ ] as const
240
+ const pkg = yield* fs
241
+ .readFileString(file)
242
+ .pipe(Effect.flatMap(installed))
243
+ return [
244
+ name,
245
+ {
246
+ spec,
247
+ version: pkg.version,
248
+ path: yield* fs.realPath(path.dirname(file)),
249
+ },
250
+ ] as const
251
+ }),
252
+ )
253
+ return HashMap.fromIterable<string, PackageState>(entries)
254
+ })
255
+ const readState = Effect.flatMap(readManifest, stateOf)
256
+ const run = Effect.fnUntraced(function* (operation: "install" | "recover") {
257
+ const recovery = operation === "recover"
258
+ const args =
259
+ agent === "npm"
260
+ ? [
261
+ "install",
262
+ "--no-audit",
263
+ "--no-fund",
264
+ ...(recovery ? ["--prefer-offline"] : []),
265
+ ]
266
+ : agent === "pnpm"
267
+ ? [
268
+ "install",
269
+ recovery ? "--frozen-lockfile" : "--no-frozen-lockfile",
270
+ "--config.confirmModulesPurge=false",
271
+ ]
272
+ : agent === "yarn-classic"
273
+ ? [
274
+ "install",
275
+ "--non-interactive",
276
+ ...(recovery
277
+ ? ["--frozen-lockfile", "--prefer-offline"]
278
+ : []),
279
+ ]
280
+ : ["install", ...(recovery ? ["--immutable"] : [])]
281
+ const code = yield* spawner
282
+ .exitCode(
283
+ ChildProcess.make(executable(agent), args, {
284
+ cwd: directory,
285
+ extendEnv: true,
286
+ stdin: "ignore",
287
+ stdout: "inherit",
288
+ stderr: "inherit",
289
+ env: {
290
+ CI: "true",
291
+ ...(agent === "yarn-berry"
292
+ ? {
293
+ YARN_ENABLE_IMMUTABLE_INSTALLS: recovery ? "true" : "false",
294
+ YARN_NODE_LINKER: "node-modules",
295
+ }
296
+ : {}),
297
+ },
298
+ }),
299
+ )
300
+ .pipe(
301
+ Effect.timeoutOrElse({
302
+ duration: "10 minutes",
303
+ orElse: () =>
304
+ PackageManagerError.make({
305
+ operation,
306
+ message: `${executable(agent)} ${operation} timed out`,
307
+ }),
308
+ }),
309
+ )
310
+ if (code !== 0)
311
+ return yield* PackageManagerError.make({
312
+ operation,
313
+ exitCode: code,
314
+ message: `${executable(agent)} ${operation} exited with code ${code}`,
315
+ })
316
+ })
317
+ const install = (changes: Changes) =>
318
+ lock.withPermits(1)(
319
+ Effect.gen(function* () {
320
+ const validated = yield* Schema.decodeEffect(Changes)(changes)
321
+ const original = yield* fs.readFileString(manifestFile)
322
+ const manifest = yield* decodeManifest(original)
323
+ const before = yield* stateOf(manifest)
324
+ const text = yield* encodeManifest(
325
+ applyChanges(manifest, validated),
326
+ original,
327
+ )
328
+ const backups = yield* Effect.forEach(backupFiles, (name) =>
329
+ Effect.gen(function* () {
330
+ const file = path.join(directory, name)
331
+ return {
332
+ name,
333
+ file,
334
+ bytes: (yield* fs.exists(file))
335
+ ? yield* fs.readFile(file).pipe(Effect.asSome)
336
+ : Option.none<Uint8Array>(),
337
+ }
338
+ }),
339
+ )
340
+ // Every file gets a restoration attempt; onExit combines failures.
341
+ const restore = Arr.reduce<
342
+ (typeof backups)[number],
343
+ Effect.Effect<void, PlatformError.PlatformError>
344
+ >(backups, Effect.void, (previous, { file, bytes }) =>
345
+ previous.pipe(
346
+ Effect.onExit(() =>
347
+ Option.match(bytes, {
348
+ onNone: () => fs.remove(file, { force: true }),
349
+ onSome: (bytes) => fs.writeFile(file, bytes),
350
+ }),
351
+ ),
352
+ ),
353
+ )
354
+ const agentLocks =
355
+ agent === "npm"
356
+ ? ["package-lock.json", "npm-shrinkwrap.json"]
357
+ : agent === "pnpm"
358
+ ? ["pnpm-lock.yaml"]
359
+ : ["yarn.lock"]
360
+ const hasLock = Arr.some(
361
+ backups,
362
+ ({ name, bytes }) =>
363
+ Arr.contains(agentLocks, name) && Option.isSome(bytes),
364
+ )
365
+ return yield* Effect.acquireUseRelease(
366
+ Effect.void,
367
+ () =>
368
+ Effect.gen(function* () {
369
+ yield* fs.writeFileString(manifestFile, text)
370
+ yield* run("install")
371
+ const after = yield* readState
372
+ return { agent, ...diffState(before, after) }
373
+ }),
374
+ (_, exit) =>
375
+ Exit.isSuccess(exit)
376
+ ? Effect.void
377
+ : restore.pipe(
378
+ Effect.andThen(hasLock ? run("recover") : Effect.void),
379
+ // Recovery itself may rewrite the manifest or lockfile, including on failure.
380
+ Effect.onExit(() => restore),
381
+ ),
382
+ )
383
+ }),
384
+ )
385
+ return {
386
+ directory,
387
+ agent,
388
+ readState: lock.withPermits(1)(readState),
389
+ install,
390
+ }
391
+ })
@@ -0,0 +1,72 @@
1
+ import { Array as Arr, Effect, Record, Schema, String as Str } from "effect"
2
+
3
+ export const PackageName = Schema.String.check(
4
+ Schema.isPattern(/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i),
5
+ Schema.isMaxLength(214),
6
+ )
7
+ const Dependencies = Schema.Record(PackageName, Schema.String)
8
+ export const Manifest = Schema.StructWithRest(
9
+ Schema.Struct({
10
+ packageManager: Schema.optionalKey(Schema.String),
11
+ dependencies: Schema.optionalKey(Dependencies),
12
+ devDependencies: Schema.optionalKey(Dependencies),
13
+ optionalDependencies: Schema.optionalKey(Dependencies),
14
+ }),
15
+ [Schema.Record(Schema.String, Schema.Unknown)],
16
+ )
17
+ export type Manifest = typeof Manifest.Type
18
+ export const decodeManifest = Schema.decodeUnknownEffect(
19
+ Schema.fromJsonString(Manifest),
20
+ )
21
+ export const Changes = Schema.Record(
22
+ PackageName,
23
+ Schema.Union([Schema.String.check(Schema.isPattern(/\S/)), Schema.Null]),
24
+ )
25
+ /** A spec adds or updates a dependency; null removes it from all dependency fields. */
26
+ export type Changes = typeof Changes.Type
27
+ export const fields = [
28
+ "dependencies",
29
+ "devDependencies",
30
+ "optionalDependencies",
31
+ ] as const
32
+
33
+ export const applyChanges = (
34
+ manifest: Manifest,
35
+ changes: Changes,
36
+ ): Manifest => {
37
+ let next = manifest
38
+ for (const [name, spec] of Record.toEntries(changes)) {
39
+ const existing = Arr.filter(fields, (field) =>
40
+ Record.has(next[field] ?? {}, name),
41
+ )
42
+ const targets =
43
+ spec === null
44
+ ? fields
45
+ : existing.length > 0
46
+ ? existing
47
+ : (["dependencies"] as const)
48
+ for (const field of targets) {
49
+ const deps = next[field] ?? {}
50
+ next = {
51
+ ...next,
52
+ [field]:
53
+ spec === null
54
+ ? Record.remove(deps, name)
55
+ : Record.set(deps, name, spec),
56
+ }
57
+ }
58
+ }
59
+ return next
60
+ }
61
+
62
+ export const encodeManifest = Effect.fnUntraced(function* (
63
+ manifest: Manifest,
64
+ original: string,
65
+ ) {
66
+ const space = /^[{[]\r?\n([ \t]+)/.exec(original)?.[1] ?? 2
67
+ const text = yield* Schema.encodeEffect(
68
+ Schema.fromJsonString(Manifest, { space }),
69
+ )(manifest)
70
+ const eol = Str.includes("\r\n")(original) ? "\r\n" : "\n"
71
+ return Str.replaceAll("\n", eol)(text) + (/\r?\n$/.test(original) ? eol : "")
72
+ })