@graftland/package-manager 0.0.0-stage → 0.2.0-beta.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.
@@ -0,0 +1,184 @@
1
+ # @graftland/package-manager
2
+
3
+ [English](https://github.com/graftland/graft/blob/main/packages/package-manager/README.md)
4
+ |
5
+ [简体中文](https://github.com/graftland/graft/blob/main/packages/package-manager/README.zh-CN.md)
6
+ | 繁體中文
7
+
8
+ 使用 npm、pnpm 或 Yarn 安裝相依套件,並在失敗時還原。
9
+
10
+ 支援的最低 Node.js 版本是 22.19。Windows 依現況提供。Windows 上的行程樹清理和還原沒有經過驗證,也不作承諾。
11
+
12
+ ```sh
13
+ npm install @graftland/package-manager effect
14
+ ```
15
+
16
+ `make` 會偵測專案所用的套件管理器。它的 `install`
17
+ 可以新增、更新或移除相依套件。如果安裝失敗,它會還原 `package.json`
18
+ 和鎖定檔。`graft add` 和 `graft remove` 使用它。
19
+
20
+ ## 安裝的所有權與復原
21
+
22
+ 每個專案只使用一個管理器。呼叫端必須讓管理器及其子行程獨占專案的寫入權限。這從一次
23
+ `install` 呼叫開始,持續到它結束,包括復原階段。
24
+
25
+ - 在此期間,不要編輯專案檔案。
26
+ - 不要同時執行另一個安裝程式。
27
+ - 同一個管理器內的呼叫是循序的。Graft 不會在多個管理器、編輯器或行程之間強制加鎖。
28
+
29
+ 在 POSIX 上,行程清理只涵蓋安裝程式最初的行程群組。安裝腳本和復原腳本不得建立不受管理的寫入者。例如,處於獨立行程群組的子行程,以及持續寫入專案檔案的常駐程式。Graft 不提供作業系統層級的隔離,也不會發現逃逸的寫入者。確認最初的行程群組已經停止,並不能證明所有可能的後代都已經停止。違反這條規則的腳本不在還原保證的範圍內。
30
+
31
+ 受管理的路徑必須是一般檔案,或者不存在。`make` 和每次 `install` 都使用 `lstat`
32
+ 檢查快照集合。快照集合包括
33
+ `package.json`、鎖定檔,以及被追蹤的設定檔和安裝狀態檔。
34
+
35
+ - 符號連結會被拒絕,包括懸空連結。其他檔案類型也會被拒絕。
36
+ - 錯誤是
37
+ `UnsupportedFileTypeError`。它在任何交易寫入之前、任何安裝程式啟動之前擲出。
38
+ - 錯誤會給出 `path` 和 `fileType`。連結及其目標保持不變。
39
+ - 在交易開始之前的拒絕,不需要復原,也不會讓管理器無法使用。
40
+
41
+ ### 還原
42
+
43
+ 當安裝在提交之前失敗或被中斷時,會執行還原。還原需要先確認最初的行程群組已經停止。然後它會做這些事:
44
+
45
+ - 把已備份的套件檔案逐位元組還原,並保留它們記錄下來的權限位元和擁有者。
46
+ - 移除安裝之前不存在的被追蹤檔案。
47
+
48
+ 被追蹤的檔案包括
49
+ `package.json`、鎖定檔,以及管理器所追蹤的套件管理器設定檔和安裝狀態檔。
50
+
51
+ 還原會覆蓋安裝期間所做的變更。它不會偵測衝突,也不會合併編輯。
52
+
53
+ 如果專案已經有所選套件管理器的鎖定檔,復原階段還會嘗試修復
54
+ `node_modules`。復原錯誤會與原始失敗一起保留。不承諾在行程當機之後復原。
55
+
56
+ ### 檔案寫入與行程清理
57
+
58
+ 已接受的檔案寫入有一個完成邊界。取消會等待原生操作完成,然後才進行還原、清理快照,以及釋放管理器的鎖。交易會追蹤它自己的檔案寫入以及最初的安裝程式行程群組。建立快照和還原寫入也有同樣的完成保證。
59
+
60
+ 每個安裝行程和每個復原行程都有十分鐘的結束等待時間。取消或逾時會在還原檔案之前關閉它的行程 scope。
61
+
62
+ - 行程會收到 SIGTERM。
63
+ - 如果五秒後它還沒有停止,就會收到 SIGKILL。
64
+ - 在 POSIX 上,即使 kill 成功,也必須確認原行程群組已不存在。每 20 毫秒檢查一次,最多追加 100 次檢查(等待兩秒),給短暫殘留的殭屍行程群組留出回收時間。EPERM 視為仍然存在,不視為已消失。
65
+ - 如果行程群組仍存在或檢查失敗,則終止未獲確認:管理器保留快照,不執行回復和復原安裝,並進入
66
+ `RecoveryRequired`。
67
+ - 行程清理失敗會與安裝和復原的失敗一起保留在回傳的 cause 中。
68
+ - 結束逾時不包括行程清理和檔案還原,也不是整個「安裝並復原」操作的截止時間。
69
+
70
+ Node 轉接器可能會把原始的作業系統 errno 取代為通用的 kill 失敗。只有 POSIX 檢查確認行程群組不存在後,PackageManager 才消解帶有 ESRCH 的訊號失敗,或轉接器特定的
71
+ `Unknown` kill 錯誤(cause 為
72
+ `Failed to kill child process`)。同一 Cause 中的其他原因仍然保留,包括 join 失敗、其他 kill 錯誤、defect 和中斷。行程群組仍存在時保留原始 Cause;檢查失敗時保留兩個完整 Cause。
73
+
74
+ ## 已提交的安裝與快照清理
75
+
76
+ `install` 成功的結果表示相依套件的變更已經提交。
77
+
78
+ `result.snapshotCleanupFailure` 是 `Option<SnapshotCleanupFailure>`:
79
+
80
+ - `None` 表示快照刪除成功。
81
+ - `Some` 包含 `snapshotDirectory` 和完整的清理 `cause`,包括 defect。
82
+
83
+ 提交之後刪除快照失敗,不會讓這次安裝變成失敗,也不會觸發還原。管理器仍然可用,`recoveryRequired`
84
+ 仍然是 `None`。
85
+
86
+ 呼叫端應該這樣做:
87
+
88
+ - 檢查這個欄位,回報或記錄它的 Cause。
89
+ - 在檔案系統問題解決之後,重試刪除該快照目錄。該目錄可能已被部分刪除。
90
+ - 不要還原舊的快照,也不要僅僅因為快照刪除失敗就重複安裝。那些變更已經提交了。
91
+ - 保留下來的資料可能包含憑證,所以要確保它最終被刪除。
92
+
93
+ 提交之前的失敗仍然會使操作失敗並執行還原。任何快照清理失敗都會與原始失敗合併。
94
+
95
+ 取消可能會使提交之後的結果無法送達。這並不能證明變更已被撤銷。這種情況下,清理失敗會保留在被中斷的 Cause 中。
96
+
97
+ ## 何時需要手動復原
98
+
99
+ 在安裝變更專案檔案之前,會先儲存一份快照。快照位於作業系統暫存目錄中一個私有的
100
+ `graft-recovery-*` 目錄裡。
101
+
102
+ - 只有成功的安裝或完整的還原,才允許刪除快照並執行下一次安裝。
103
+ - 已提交但快照刪除失敗的安裝,依上文所述回報。
104
+ - 行程終止和交易完成是兩個獨立的條件。停止寫入者,還原才能開始,但這並不證明還原成功。
105
+
106
+ 只要有任何一個還原步驟失敗,管理器就會保留快照,並需要手動復原。這包括在復原之前或之後還原檔案,以及執行復原安裝程式。即使最後的檔案還原成功,這條規則也適用,因為失敗的復原可能讓
107
+ `node_modules` 不完整。
108
+
109
+ 管理器如何還原一個檔案:
110
+
111
+ 1. 在目標目錄中以獨占方式建立暫存檔,模式為 `0600`。
112
+ 2. 當平台回報了儲存的 uid/gid 時,先還原它們,然後還原權限位元。
113
+ 3. 對檔案執行 sync 並關閉。
114
+ 4. 把該檔案重新命名覆蓋到目標上。
115
+
116
+ 中繼資料失敗和暫存檔清理失敗同樣需要手動復原,並保留在 Cause 中。時間戳記、ACL 和擴充屬性不會被記錄。
117
+
118
+ 如果無法確認行程已經終止,就不會再進行任何還原或復原。這是為了避免與可能仍在寫入的行程發生衝突。
119
+
120
+ ### 復原錯誤
121
+
122
+ 失敗呼叫的 Cause 會保留原始失敗以及所有還原和終止的失敗。它包含一個
123
+ `RecoveryRequiredError`,其欄位如下:
124
+
125
+ - `snapshotDirectory` 給出儲存的位置。
126
+ - `directory` 識別專案。
127
+ - `recoveryCause` 保留完整的原始 Cause 和復原 Cause。
128
+ - `operation`:在寫入者已停止但還原不完整時是 `rollback`;在無法確認終止時是
129
+ `install` 或 `recover`,此時 `processId` 和 `terminationCause`
130
+ 給出行程的詳細資訊。
131
+
132
+ 取消之後,呼叫端還可以讀取 `manager.recoveryRequired`。它是一個產出
133
+ `Option<RecoveryRequiredError>` 的 Effect。呼叫端不需要去攔截被中斷的呼叫。
134
+
135
+ 管理器會永久拒絕之後所有的 `install`
136
+ 呼叫,並回傳這個帶型別的錯誤。這包括已經排隊的呼叫,以及變更集為空的呼叫。`readState`
137
+ 仍然是唯讀的診斷操作。在專案檔案不完整時,它可能失敗。
138
+
139
+ ### 手動復原的步驟
140
+
141
+ 1. 找出失敗的安裝程式及其所有後代,並停止它們。記錄下來的行程 ID 只用於診斷,不能證明之後具有相同 ID 的行程屬於這次安裝。在所有寫入者停止之前,不要還原檔案,也不要啟動另一個安裝程式。
142
+ 2. 解決阻止還原的檔案系統問題,例如檔案權限。然後還原檔案:
143
+ - 開啟 `snapshotDirectory` 中的 `snapshot.json`。檢查其中的專案 `directory`
144
+ 和 `agent`。
145
+ - 對每個 `files` 項目,把指定的 `backup` 中的位元組複製到專案 `name`
146
+ 旁邊的暫存檔中。以私有、獨占的方式建立該暫存檔。
147
+ - 當 `uid` 和 `gid` 不為 null 時,還原記錄下來的值,然後還原
148
+ `mode`。請在取代目標之前完成這些。
149
+ - 這些中繼資料值是 JSON 整數。`mode`
150
+ 是數值形式的權限位元遮罩,不是八進位格式的字串。
151
+ - 視需要建立父目錄。
152
+ - `backup` 為 null 表示該檔案原本不存在。如果它現在存在,就刪除它。
153
+ 3. 使用所選的套件管理器,基於已還原的清單檔和鎖定檔修復
154
+ `node_modules`。該行程停止之後,如果它重寫了快照中的檔案,請再次還原這些檔案。快照不包含
155
+ `node_modules`。
156
+ 4. 驗證專案。然後刪除快照,並用 `make`
157
+ 建立一個新的管理器。舊的管理器沒有重設操作。
158
+
159
+ 快照可能包含來自套件管理器設定檔的憑證。請保持私有。它們在管理器和 Effect
160
+ scope 關閉之後依然存在。但是作業系統可能會清理暫存目錄。如果手動復原會被延後,請把保留的快照複製到一個安全的私有位置。
161
+
162
+ 這個機制不是當機復原,也不是跨行程的專案鎖。建立新的管理器,並不會讓仍在執行的安裝程式變得安全。
163
+
164
+ ## 文件
165
+
166
+ - [命令列](https://graftland.github.io/graft/zh-tw/cli/)
167
+
168
+ 屬於 [Graft](https://github.com/graftland/graft)。依
169
+ [Apache 2.0](https://github.com/graftland/graft/blob/main/LICENSE) 授權。
170
+
171
+ ## 套件管理器宣告
172
+
173
+ 共用的 `PackageManagerDeclaration` Schema 接受 `npm`、`pnpm` 或
174
+ `yarn`,後接精確的 SemVer 版本(版本部分最多 256 個字元),可包含預發布識別碼。它拒絕空後綴、版本及預發布數字識別碼的前置零、版本範圍、標籤、URL 和首尾空白。
175
+
176
+ `+` 後綴僅用於
177
+ [Corepack hash](https://github.com/nodejs/corepack#when-authoring-packages):支援
178
+ `sha224`、`sha256`、`sha384`、`sha512`,後接點號及分別為 56、64、96、128 個字元的小寫十六進位摘要。不支援一般 SemVer
179
+ build metadata。這裡只檢查宣告語法,不下載或驗證套件管理器壓縮檔的真實性。
180
+
181
+ 建立專案與偵測套件管理器使用同一個 Schema,可從
182
+ `@graftland/package-manager/PackageManagerDeclaration`
183
+ 匯入。建立時完整保留傳入的宣告;偵測時明確指定的 `agent`
184
+ 仍優先於 manifest 宣告。
@@ -0,0 +1,6 @@
1
+ import { Schema } from "effect";
2
+ export declare const PackageManagerName: Schema.Literals<readonly ["npm", "pnpm", "yarn"]>;
3
+ /** Exact SemVer with optional prerelease and Corepack hash; ranges, URLs and arbitrary build metadata are unsupported. */
4
+ export declare const PackageManagerDeclaration: Schema.TemplateLiteralParser<readonly [Schema.Literals<readonly ["npm", "pnpm", "yarn"]>, "@", Schema.decodeTo<Schema.SemVerFromString, Schema.String, never, never>]>;
5
+ export type PackageManagerDeclaration = typeof PackageManagerDeclaration.Type;
6
+ //# sourceMappingURL=PackageManagerDeclaration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PackageManagerDeclaration.d.ts","names":[],"sources":["../src/PackageManagerDeclaration.ts"],"mappings":";qBAEa,oBAAkB,OAAA;;qBAkClB,2BAAyB,OAAA,gCAAA,OAAA,iDAAA,OAAA,SAAA,OAAA,kBAAA,OAAA;YAK1B,mCAAmC,0BAA0B"}
@@ -0,0 +1,24 @@
1
+ import { Schema } from "effect";
2
+ // src/PackageManagerDeclaration.ts
3
+ const PackageManagerName = Schema.Literals([
4
+ "npm",
5
+ "pnpm",
6
+ "yarn"
7
+ ]);
8
+ const CorepackHash = Schema.Union([
9
+ Schema.Tuple([Schema.Literal("sha224"), Schema.String.check(Schema.isPattern(/^[0-9a-f]{56}$/))]),
10
+ Schema.Tuple([Schema.Literal("sha256"), Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/))]),
11
+ Schema.Tuple([Schema.Literal("sha384"), Schema.String.check(Schema.isPattern(/^[0-9a-f]{96}$/))]),
12
+ Schema.Tuple([Schema.Literal("sha512"), Schema.String.check(Schema.isPattern(/^[0-9a-f]{128}$/))])
13
+ ]);
14
+ const isCorepackHash = Schema.is(CorepackHash);
15
+ const Version = Schema.String.check(Schema.isMaxLength(256)).pipe(Schema.decodeTo(Schema.SemVerFromString)).check(Schema.makeFilter((version) => version.build.length === 0 || isCorepackHash(version.build), { expected: "no build metadata, or a Corepack sha224/sha256/sha384/sha512 lowercase hexadecimal hash" }));
16
+ /** Exact SemVer with optional prerelease and Corepack hash; ranges, URLs and arbitrary build metadata are unsupported. */
17
+ const PackageManagerDeclaration = Schema.TemplateLiteralParser([
18
+ PackageManagerName,
19
+ "@",
20
+ Version
21
+ ]);
22
+ export { PackageManagerDeclaration, PackageManagerName };
23
+
24
+ //# sourceMappingURL=PackageManagerDeclaration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PackageManagerDeclaration.js","names":[],"sources":["../src/PackageManagerDeclaration.ts"],"sourcesContent":["import { Schema } from \"effect\"\n\nexport const PackageManagerName = Schema.Literals([\"npm\", \"pnpm\", \"yarn\"])\n\nconst CorepackHash = Schema.Union([\n\tSchema.Tuple([\n\t\tSchema.Literal(\"sha224\"),\n\t\tSchema.String.check(Schema.isPattern(/^[0-9a-f]{56}$/)),\n\t]),\n\tSchema.Tuple([\n\t\tSchema.Literal(\"sha256\"),\n\t\tSchema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/)),\n\t]),\n\tSchema.Tuple([\n\t\tSchema.Literal(\"sha384\"),\n\t\tSchema.String.check(Schema.isPattern(/^[0-9a-f]{96}$/)),\n\t]),\n\tSchema.Tuple([\n\t\tSchema.Literal(\"sha512\"),\n\t\tSchema.String.check(Schema.isPattern(/^[0-9a-f]{128}$/)),\n\t]),\n])\nconst isCorepackHash = Schema.is(CorepackHash)\nconst Version = Schema.String.check(Schema.isMaxLength(256))\n\t.pipe(Schema.decodeTo(Schema.SemVerFromString))\n\t.check(\n\t\tSchema.makeFilter(\n\t\t\t(version) => version.build.length === 0 || isCorepackHash(version.build),\n\t\t\t{\n\t\t\t\texpected:\n\t\t\t\t\t\"no build metadata, or a Corepack sha224/sha256/sha384/sha512 lowercase hexadecimal hash\",\n\t\t\t},\n\t\t),\n\t)\n\n/** Exact SemVer with optional prerelease and Corepack hash; ranges, URLs and arbitrary build metadata are unsupported. */\nexport const PackageManagerDeclaration = Schema.TemplateLiteralParser([\n\tPackageManagerName,\n\t\"@\",\n\tVersion,\n])\nexport type PackageManagerDeclaration = typeof PackageManagerDeclaration.Type\n"],"mappings":";;AAEA,MAAa,qBAAqB,OAAO,SAAS;CAAC;CAAO;CAAQ;AAAM,CAAC;AAEzE,MAAM,eAAe,OAAO,MAAM;CACjC,OAAO,MAAM,CACZ,OAAO,QAAQ,QAAQ,GACvB,OAAO,OAAO,MAAM,OAAO,UAAU,gBAAgB,CAAC,CACvD,CAAC;CACD,OAAO,MAAM,CACZ,OAAO,QAAQ,QAAQ,GACvB,OAAO,OAAO,MAAM,OAAO,UAAU,gBAAgB,CAAC,CACvD,CAAC;CACD,OAAO,MAAM,CACZ,OAAO,QAAQ,QAAQ,GACvB,OAAO,OAAO,MAAM,OAAO,UAAU,gBAAgB,CAAC,CACvD,CAAC;CACD,OAAO,MAAM,CACZ,OAAO,QAAQ,QAAQ,GACvB,OAAO,OAAO,MAAM,OAAO,UAAU,iBAAiB,CAAC,CACxD,CAAC;AACF,CAAC;AACD,MAAM,iBAAiB,OAAO,GAAG,YAAY;AAC7C,MAAM,UAAU,OAAO,OAAO,MAAM,OAAO,YAAY,GAAG,CAAC,CAAC,CAC1D,KAAK,OAAO,SAAS,OAAO,gBAAgB,CAAC,CAAC,CAC9C,MACA,OAAO,YACL,YAAY,QAAQ,MAAM,WAAW,KAAK,eAAe,QAAQ,KAAK,GACvE,EACC,UACC,0FACF,CACD,CACD;;AAGD,MAAa,4BAA4B,OAAO,sBAAsB;CACrE;CACA;CACA;AACD,CAAC"}
@@ -0,0 +1,100 @@
1
+ import { PackageManagerDeclaration } from "./PackageManagerDeclaration.js";
2
+ import { SnapshotCleanupFailure, UnsupportedFileTypeError } from "./snapshot.js";
3
+ import { Changes, PackageName } from "./manifest.js";
4
+ import { Cause, Effect, FileSystem, HashMap, Option, Path, PlatformError, Schema } from "effect";
5
+ import { ChildProcessSpawner } from "effect/process";
6
+ export declare const Agent: Schema.Literals<readonly ["npm", "pnpm", "yarn-classic", "yarn-berry"]>;
7
+ export type Agent = typeof Agent.Type;
8
+ declare const PackageManagerError_base: Schema.Class<PackageManagerError, Schema.TaggedStruct<"PackageManagerError", {
9
+ readonly operation: Schema.Literals<readonly ["detect", "install", "recover", "state"]>;
10
+ readonly message: Schema.String;
11
+ readonly exitCode: Schema.optionalKey<Schema.Int>;
12
+ }>, Cause.YieldableError & {
13
+ readonly brand: unique symbol;
14
+ }>;
15
+ export declare class PackageManagerError extends PackageManagerError_base {}
16
+ declare const RecoveryRequiredError_base: Schema.Class<RecoveryRequiredError, Schema.TaggedStruct<"RecoveryRequiredError", {
17
+ readonly directory: Schema.String;
18
+ readonly snapshotDirectory: Schema.String;
19
+ readonly operation: Schema.Literals<readonly ["install", "recover", "rollback"]>;
20
+ readonly processId: Schema.optionalKey<Schema.Int>;
21
+ readonly message: Schema.String;
22
+ readonly terminationCause: Schema.optionalKey<Schema.Cause<Schema.Unknown, Schema.Unknown>>;
23
+ readonly recoveryCause: Schema.Cause<Schema.Unknown, Schema.Unknown>;
24
+ }>, Cause.YieldableError & {
25
+ readonly brand: unique symbol;
26
+ }>;
27
+ export declare class RecoveryRequiredError extends RecoveryRequiredError_base {}
28
+ export type Error = PackageManagerError | UnsupportedFileTypeError | RecoveryRequiredError | PlatformError.PlatformError | Schema.SchemaError;
29
+ export interface Options {
30
+ /** Project directory, containing package.json. Use the workspace root for workspace installs. */
31
+ readonly directory: string;
32
+ /** Explicit selection takes precedence over detection. */
33
+ readonly agent?: Agent;
34
+ /** Optional npm_config_user_agent supplied by the caller; used only as a fallback. */
35
+ readonly userAgent?: string;
36
+ }
37
+ /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
38
+ export declare const detectAgent: (options: Options) => Effect.Effect<"npm" | "pnpm" | "yarn-berry" | "yarn-classic", PackageManagerError | PlatformError.PlatformError | Schema.SchemaError, FileSystem.FileSystem | Path.Path>;
39
+ export interface PackageState {
40
+ readonly spec: string;
41
+ /** Missing optional or not-yet-installed dependencies have no version/path. */
42
+ readonly version: string | undefined;
43
+ readonly path: string | undefined;
44
+ }
45
+ /** Direct dependencies only, keyed by their manifest names (including aliases). */
46
+ export type State = HashMap.HashMap<string, PackageState>;
47
+ export interface PackageChange {
48
+ readonly name: string;
49
+ readonly before: PackageState | undefined;
50
+ readonly after: PackageState | undefined;
51
+ }
52
+ export interface StateDiff {
53
+ readonly added: ReadonlyArray<PackageChange>;
54
+ readonly removed: ReadonlyArray<PackageChange>;
55
+ readonly updated: ReadonlyArray<PackageChange>;
56
+ }
57
+ export declare const diffState: (before: State, after: State) => StateDiff;
58
+ export interface InstallResult extends StateDiff {
59
+ readonly agent: Agent;
60
+ /** The install committed; retry only snapshot deletion, not installation. */
61
+ readonly snapshotCleanupFailure: Option.Option<SnapshotCleanupFailure>;
62
+ }
63
+ export interface PackageManager {
64
+ readonly directory: string;
65
+ readonly agent: Agent;
66
+ readonly readState: Effect.Effect<State, Error>;
67
+ /** Present when manual recovery is required; the manager cannot be reused. */
68
+ readonly recoveryRequired: Effect.Effect<Option.Option<RecoveryRequiredError>>;
69
+ /** Serialized per manager. Empty changes installs the current manifest, including first installs. */
70
+ readonly install: (changes: Changes) => Effect.Effect<InstallResult, Error>;
71
+ }
72
+ /**
73
+ * Own one manager per project. The caller must give this manager and its child
74
+ * processes exclusive write access to the project for each install, including
75
+ * recovery. Do not edit project files or run another installer concurrently.
76
+ * Calls are serialized only within this manager; no cross-process lock is taken.
77
+ * Managed paths must be regular files or absent. make and install reject symbolic
78
+ * links and other file types with UnsupportedFileTypeError before any write.
79
+ *
80
+ * Accepted file writes finish before cancellation can start rollback or release
81
+ * the lock. File writes and the original process group participate in the transaction.
82
+ * Scripts must not create escaped writers in independent groups or daemons.
83
+ * Failed or interrupted installs before commit restore backed-up package files byte-for-byte
84
+ * with recorded permissions and ownership, and remove tracked files that did not
85
+ * exist before installation. Writes made
86
+ * during installation are overwritten by rollback, never merged. When a lockfile
87
+ * existed, recovery also attempts to repair node_modules. Recovery failures are
88
+ * combined with the original cause. If process termination cannot be confirmed,
89
+ * no further restoration or recovery runs. Any failed rollback step also requires
90
+ * manual recovery, even if all writers stopped. Only successful installation or
91
+ * complete rollback permits snapshot deletion and reuse. RecoveryRequiredError
92
+ * and recoveryRequired expose the retained snapshot directory and complete cause;
93
+ * this manager rejects
94
+ * all later installs. Complete manual recovery before making a new manager.
95
+ * Committed installs report snapshot deletion failures in snapshotCleanupFailure
96
+ * rather than failing or rolling back. No crash recovery is promised.
97
+ */
98
+ export declare const make: (options: Options) => Effect.Effect<PackageManager, Error, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
99
+ export { Changes, PackageManagerDeclaration, PackageName, type SnapshotCleanupFailure, UnsupportedFileTypeError };
100
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;qBA+Ca,OAAK,OAAA;YAMN,eAAe,MAAM;;;;;;WAGd;;qBAFN,4BAA4B;;;;;;;;;;WAUtB;;qBAFN,8BAA8B;YAoC/B,QACT,sBACA,2BACA,wBACA,cAAc,gBACd,OAAO;iBACO;;WAEP;;WAEA,QAAQ;;WAER;;;qBAqDG,cAAW,SAAA,YAAA,OAAA,uDAAA,sBAAA,cAAA,gBAAA,OAAA,aAAA,WAAA,aAAA,KAAA;iBAkDP;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;;WAEP,wBAAwB,OAAO,OAAO;;iBAE/B;WACP;WACA,OAAO;WACP,WAAW,OAAO,OAAO,OAAO;;WAEhC,kBAAkB,OAAO,OAAO,OAAO,OAAO;;WAE9C,UAAU,SAAS,YAAY,OAAO,OAAO,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA6BzD,OAAI,SACP,YACP,OAAO,OACT,gBACA,OACA,WAAW,aAAa,KAAK,OAAO,oBAAoB"}
package/dist/index.js ADDED
@@ -0,0 +1,324 @@
1
+ import { PackageManagerDeclaration, PackageManagerName } from "./PackageManagerDeclaration.js";
2
+ import { terminateProcess } from "./process.js";
3
+ import { UnsupportedFileTypeError, inspectManagedFiles, withSnapshot, writeSnapshot } from "./snapshot.js";
4
+ import { Changes, PackageName, applyChanges, decodeManifest, encodeManifest, fields } from "./manifest.js";
5
+ import { Array, Data, Effect, Exit, FileSystem, HashMap, Option, Path, PlatformError, Record, Ref, Schema, Semaphore, String } from "effect";
6
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
7
+ // src/index.ts
8
+ /** Non-interactive package installation. Provide platform services at the application boundary. */
9
+ const Agent = Schema.Literals([
10
+ "npm",
11
+ "pnpm",
12
+ "yarn-classic",
13
+ "yarn-berry"
14
+ ]);
15
+ var PackageManagerError = class extends Schema.TaggedError()("PackageManagerError", {
16
+ operation: Schema.Literals([
17
+ "detect",
18
+ "install",
19
+ "recover",
20
+ "state"
21
+ ]),
22
+ message: Schema.String,
23
+ exitCode: Schema.optionalKey(Schema.Int)
24
+ }) {};
25
+ var RecoveryRequiredError = class extends Schema.TaggedError()("RecoveryRequiredError", {
26
+ directory: Schema.String,
27
+ snapshotDirectory: Schema.String,
28
+ operation: Schema.Literals([
29
+ "install",
30
+ "recover",
31
+ "rollback"
32
+ ]),
33
+ processId: Schema.optionalKey(Schema.Int),
34
+ message: Schema.String,
35
+ terminationCause: Schema.optionalKey(Schema.Cause(Schema.Unknown, Schema.Unknown)),
36
+ recoveryCause: Schema.Cause(Schema.Unknown, Schema.Unknown)
37
+ }) {};
38
+ const WriterState = Data.taggedEnum();
39
+ const ManagerState = Data.taggedEnum();
40
+ const lockfiles = [
41
+ "package-lock.json",
42
+ "npm-shrinkwrap.json",
43
+ "pnpm-lock.yaml",
44
+ "yarn.lock"
45
+ ];
46
+ const backupFiles = [
47
+ "package.json",
48
+ ...lockfiles,
49
+ ".npmrc",
50
+ "pnpm-workspace.yaml",
51
+ ".yarnrc",
52
+ ".yarnrc.yml",
53
+ ".yarn/install-state.gz",
54
+ ".pnp.cjs",
55
+ ".pnp.loader.mjs"
56
+ ];
57
+ const executable = (agent) => String.startsWith("yarn")(agent) ? "yarn" : agent;
58
+ const readSource = Effect.fnUntraced(function* (fs, file) {
59
+ const bytes = yield* fs.readFile(file);
60
+ return yield* Effect.try({
61
+ try: () => new TextDecoder("utf-8", {
62
+ ignoreBOM: true,
63
+ fatal: true
64
+ }).decode(bytes),
65
+ catch: (cause) => PlatformError.badArgument({
66
+ module: "FileSystem",
67
+ method: "readManifest",
68
+ description: "The manifest must contain valid UTF-8",
69
+ cause
70
+ })
71
+ });
72
+ });
73
+ const Executable = PackageManagerName;
74
+ const Digits = Schema.String.check(Schema.isPattern(/^\d+$/));
75
+ const decodePackageManager = Schema.decodeUnknownOption(PackageManagerDeclaration);
76
+ /** `name/version ...`, as package managers set `npm_config_user_agent`. */
77
+ const decodeUserAgent = Schema.decodeUnknownOption(Schema.TemplateLiteralParser([
78
+ Executable,
79
+ "/",
80
+ Digits,
81
+ Schema.String
82
+ ]));
83
+ const agentOf = (name, major) => name === "yarn" ? major === "1" ? "yarn-classic" : "yarn-berry" : name;
84
+ /** packageManager, then lockfile, then caller user-agent, then npm. Conflicting lockfiles fail. */
85
+ const detectAgent = Effect.fnUntraced(function* (options) {
86
+ if (options.agent !== void 0) return yield* Schema.decodeEffect(Agent)(options.agent);
87
+ const fs = yield* FileSystem.FileSystem;
88
+ const path = yield* Path.Path;
89
+ const manifest = yield* readSource(fs, path.join(options.directory, "package.json")).pipe(Effect.flatMap(decodeManifest));
90
+ if (manifest.packageManager !== void 0) return yield* Option.match(decodePackageManager(manifest.packageManager), {
91
+ onNone: () => PackageManagerError.make({
92
+ operation: "detect",
93
+ message: `Unsupported packageManager: ${manifest.packageManager}`
94
+ }),
95
+ onSome: ([name, , version]) => Effect.succeed(agentOf(name, `${version.major}`))
96
+ });
97
+ const found = yield* Effect.filter(lockfiles, (file) => fs.exists(path.join(options.directory, file)));
98
+ const kinds = Array.dedupe(Array.map(found, (file) => file === "pnpm-lock.yaml" ? "pnpm" : file === "yarn.lock" ? "yarn" : "npm"));
99
+ if (kinds.length > 1) return yield* PackageManagerError.make({
100
+ operation: "detect",
101
+ message: "Conflicting lockfiles; select an agent explicitly"
102
+ });
103
+ if (kinds[0] === "yarn") {
104
+ const lock = yield* fs.readFileString(path.join(options.directory, "yarn.lock"));
105
+ return /^__metadata:/m.test(lock) ? "yarn-berry" : "yarn-classic";
106
+ }
107
+ if (kinds[0] !== void 0) return kinds[0];
108
+ return Option.match(decodeUserAgent(options.userAgent ?? ""), {
109
+ onNone: () => "npm",
110
+ onSome: ([name, , major]) => agentOf(name, major)
111
+ });
112
+ });
113
+ const diffState = (before, after) => {
114
+ const changes = Array.map(Array.sort(Array.dedupe([...HashMap.keys(before), ...HashMap.keys(after)]), String.Order), (name) => ({
115
+ name,
116
+ before: Option.getOrUndefined(HashMap.get(before, name)),
117
+ after: Option.getOrUndefined(HashMap.get(after, name))
118
+ }));
119
+ return {
120
+ added: Array.filter(changes, (change) => change.before === void 0),
121
+ removed: Array.filter(changes, (change) => change.after === void 0),
122
+ updated: Array.filter(changes, ({ before, after }) => before !== void 0 && after !== void 0 && (before.spec !== after.spec || before.version !== after.version || before.path !== after.path))
123
+ };
124
+ };
125
+ /**
126
+ * Own one manager per project. The caller must give this manager and its child
127
+ * processes exclusive write access to the project for each install, including
128
+ * recovery. Do not edit project files or run another installer concurrently.
129
+ * Calls are serialized only within this manager; no cross-process lock is taken.
130
+ * Managed paths must be regular files or absent. make and install reject symbolic
131
+ * links and other file types with UnsupportedFileTypeError before any write.
132
+ *
133
+ * Accepted file writes finish before cancellation can start rollback or release
134
+ * the lock. File writes and the original process group participate in the transaction.
135
+ * Scripts must not create escaped writers in independent groups or daemons.
136
+ * Failed or interrupted installs before commit restore backed-up package files byte-for-byte
137
+ * with recorded permissions and ownership, and remove tracked files that did not
138
+ * exist before installation. Writes made
139
+ * during installation are overwritten by rollback, never merged. When a lockfile
140
+ * existed, recovery also attempts to repair node_modules. Recovery failures are
141
+ * combined with the original cause. If process termination cannot be confirmed,
142
+ * no further restoration or recovery runs. Any failed rollback step also requires
143
+ * manual recovery, even if all writers stopped. Only successful installation or
144
+ * complete rollback permits snapshot deletion and reuse. RecoveryRequiredError
145
+ * and recoveryRequired expose the retained snapshot directory and complete cause;
146
+ * this manager rejects
147
+ * all later installs. Complete manual recovery before making a new manager.
148
+ * Committed installs report snapshot deletion failures in snapshotCleanupFailure
149
+ * rather than failing or rolling back. No crash recovery is promised.
150
+ */
151
+ const make = (options) => Effect.gen(function* () {
152
+ const fs = yield* FileSystem.FileSystem;
153
+ const path = yield* Path.Path;
154
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
155
+ const directory = path.resolve(options.directory);
156
+ yield* inspectManagedFiles(fs, path, directory, backupFiles);
157
+ const agent = yield* detectAgent({
158
+ ...options,
159
+ directory
160
+ });
161
+ const lock = yield* Semaphore.make(1);
162
+ const transaction = yield* Ref.make(ManagerState.Ready());
163
+ const manifestFile = path.join(directory, "package.json");
164
+ const readManifest = readSource(fs, manifestFile).pipe(Effect.flatMap(decodeManifest));
165
+ const installed = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.optionalKey(Schema.String) })));
166
+ const stateOf = Effect.fnUntraced(function* (manifest) {
167
+ if (yield* fs.exists(path.join(directory, ".pnp.cjs"))) return yield* PackageManagerError.make({
168
+ operation: "state",
169
+ message: "Plug'n'Play is not supported; configure Yarn with nodeLinker: node-modules"
170
+ });
171
+ const deps = Array.reduce(fields, {}, (all, field) => ({
172
+ ...all,
173
+ ...manifest[field]
174
+ }));
175
+ const entries = yield* Effect.forEach(Record.toEntries(deps), ([name, spec]) => Effect.gen(function* () {
176
+ const file = path.join(directory, "node_modules", name, "package.json");
177
+ if (!(yield* fs.exists(file))) return [name, {
178
+ spec,
179
+ version: void 0,
180
+ path: void 0
181
+ }];
182
+ return [name, {
183
+ spec,
184
+ version: (yield* fs.readFileString(file).pipe(Effect.flatMap(installed))).version,
185
+ path: yield* fs.realPath(path.dirname(file))
186
+ }];
187
+ }));
188
+ return HashMap.fromIterable(entries);
189
+ });
190
+ const readState = Effect.flatMap(readManifest, stateOf);
191
+ const termination = { forceKillAfter: "5 seconds" };
192
+ const run = Effect.fnUntraced(function* (operation) {
193
+ const recovery = operation === "recover";
194
+ const args = agent === "npm" ? [
195
+ "install",
196
+ "--no-audit",
197
+ "--no-fund",
198
+ ...recovery ? ["--prefer-offline"] : []
199
+ ] : agent === "pnpm" ? [
200
+ "install",
201
+ recovery ? "--frozen-lockfile" : "--no-frozen-lockfile",
202
+ "--config.confirmModulesPurge=false"
203
+ ] : agent === "yarn-classic" ? [
204
+ "install",
205
+ "--non-interactive",
206
+ ...recovery ? ["--frozen-lockfile", "--prefer-offline"] : []
207
+ ] : ["install", ...recovery ? ["--immutable"] : []];
208
+ return yield* Effect.acquireUseRelease(spawner.spawn(ChildProcess.make(executable(agent), args, {
209
+ cwd: directory,
210
+ extendEnv: true,
211
+ stdin: "ignore",
212
+ stdout: "inherit",
213
+ stderr: "inherit",
214
+ detached: true,
215
+ ...termination,
216
+ env: {
217
+ CI: "true",
218
+ ...agent === "yarn-berry" ? {
219
+ YARN_ENABLE_IMMUTABLE_INSTALLS: recovery ? "true" : "false",
220
+ YARN_NODE_LINKER: "node-modules"
221
+ } : {}
222
+ }
223
+ })).pipe(Effect.tap(() => Ref.set(transaction, ManagerState.Active({ writer: WriterState.Running() })))), (process) => Effect.gen(function* () {
224
+ const code = yield* process.exitCode.pipe(Effect.timeoutOrElse({
225
+ duration: "10 minutes",
226
+ orElse: () => PackageManagerError.make({
227
+ operation,
228
+ message: `${executable(agent)} ${operation} timed out`
229
+ })
230
+ }));
231
+ if (code !== 0) return yield* PackageManagerError.make({
232
+ operation,
233
+ exitCode: code,
234
+ message: `${executable(agent)} ${operation} exited with code ${code}`
235
+ });
236
+ }), (process) => Effect.gen(function* () {
237
+ const result = yield* terminateProcess(process, termination);
238
+ yield* Ref.set(transaction, ManagerState.Active({ writer: result._tag === "OriginalGroupStopped" ? WriterState.ManagedWritersIdle() : WriterState.Unconfirmed({
239
+ operation,
240
+ processId: process.pid,
241
+ cause: result.cause
242
+ }) }));
243
+ if (result._tag === "Unconfirmed") return yield* Effect.failCause(result.cause);
244
+ }));
245
+ }, Effect.scoped);
246
+ const withFileWriter = (effect) => Effect.acquireUseRelease(Ref.set(transaction, ManagerState.Active({ writer: WriterState.FileWrite() })), () => effect, () => Ref.set(transaction, ManagerState.Active({ writer: WriterState.ManagedWritersIdle() }))).pipe(Effect.uninterruptible);
247
+ const install = (changes) => lock.withPermits(1)(Effect.gen(function* () {
248
+ const state = yield* Ref.get(transaction);
249
+ if (state._tag === "RecoveryRequired") return yield* state.error;
250
+ const validated = yield* Schema.decodeEffect(Changes)(changes);
251
+ const inspected = yield* inspectManagedFiles(fs, path, directory, backupFiles);
252
+ const backups = yield* Effect.forEach(inspected, ({ name, file, metadata }) => Effect.gen(function* () {
253
+ return {
254
+ name,
255
+ file,
256
+ contents: yield* Option.match(metadata, {
257
+ onNone: () => Effect.succeedNone,
258
+ onSome: (info) => fs.readFile(file).pipe(Effect.map((bytes) => Option.some({
259
+ ...info,
260
+ bytes
261
+ })))
262
+ })
263
+ };
264
+ }));
265
+ const original = yield* readSource(fs, manifestFile);
266
+ const manifest = yield* decodeManifest(original);
267
+ const before = yield* stateOf(manifest);
268
+ const text = yield* encodeManifest(applyChanges(manifest, validated), original);
269
+ const agentLocks = agent === "npm" ? ["package-lock.json", "npm-shrinkwrap.json"] : agent === "pnpm" ? ["pnpm-lock.yaml"] : ["yarn.lock"];
270
+ const hasLock = Array.some(backups, ({ name, contents }) => Array.contains(agentLocks, name) && Option.isSome(contents));
271
+ const completed = yield* withSnapshot(fs, Ref.get(transaction).pipe(Effect.map((state) => state._tag === "Ready")), (snapshotDirectory) => Effect.gen(function* () {
272
+ const snapshot = yield* writeSnapshot(fs, path, snapshotDirectory, directory, agent, backups);
273
+ const restore = Effect.flatMap(Ref.get(transaction), (state) => state._tag === "Active" && state.writer._tag === "ManagedWritersIdle" ? withFileWriter(snapshot.restore(state.writer)) : Effect.void);
274
+ return yield* Effect.acquireUseRelease(Ref.set(transaction, ManagerState.Active({ writer: WriterState.ManagedWritersIdle() })), () => Effect.gen(function* () {
275
+ yield* withFileWriter(fs.writeFileString(manifestFile, text));
276
+ yield* run("install");
277
+ const after = yield* readState;
278
+ return {
279
+ agent,
280
+ ...diffState(before, after)
281
+ };
282
+ }), (_, exit) => Effect.gen(function* () {
283
+ if (Exit.isFailure(exit)) {
284
+ const state = yield* Ref.get(transaction);
285
+ if (state._tag !== "Active" || state.writer._tag !== "ManagedWritersIdle") return;
286
+ yield* withFileWriter(snapshot.restore(state.writer));
287
+ if (hasLock) yield* run("recover").pipe(Effect.onExit(() => restore));
288
+ }
289
+ yield* Ref.set(transaction, ManagerState.Ready());
290
+ })).pipe(Effect.onExit((exit) => Effect.gen(function* () {
291
+ const state = yield* Ref.get(transaction);
292
+ if (Exit.isSuccess(exit) || state._tag !== "Active") return;
293
+ const writer = state.writer;
294
+ const error = RecoveryRequiredError.make({
295
+ directory,
296
+ snapshotDirectory,
297
+ operation: writer._tag === "Unconfirmed" ? writer.operation : "rollback",
298
+ ...writer._tag === "Unconfirmed" ? {
299
+ processId: writer.processId,
300
+ terminationCause: writer.cause
301
+ } : {},
302
+ message: `Manual recovery is required. Stop all writers before restoring the snapshot at ${snapshotDirectory}`,
303
+ recoveryCause: exit.cause
304
+ });
305
+ yield* Ref.set(transaction, ManagerState.RecoveryRequired({ error }));
306
+ return yield* error;
307
+ })));
308
+ }));
309
+ return {
310
+ ...completed.value,
311
+ snapshotCleanupFailure: completed.snapshotCleanupFailure
312
+ };
313
+ }));
314
+ return {
315
+ directory,
316
+ agent,
317
+ recoveryRequired: Ref.get(transaction).pipe(Effect.map((state) => state._tag === "RecoveryRequired" ? Option.some(state.error) : Option.none())),
318
+ readState: lock.withPermits(1)(readState),
319
+ install
320
+ };
321
+ });
322
+ export { Agent, Changes, PackageManagerDeclaration, PackageManagerError, PackageName, RecoveryRequiredError, UnsupportedFileTypeError, detectAgent, diffState, make };
323
+
324
+ //# sourceMappingURL=index.js.map