| ... | ... | @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi |
| 63 | 63 | }; |
| 64 | 64 | }); |
| 65 | 65 | |
| 66 | | export class CodexShadowHomeError extends Schema.TaggedErrorClass<CodexShadowHomeError>()( |
| 67 | | "CodexShadowHomeError", |
| 66 | const CodexShadowHomeContext = { |
| 67 | sharedHomePath: Schema.String, |
| 68 | effectiveHomePath: Schema.String, |
| 69 | }; |
| 70 | |
| 71 | export class CodexShadowHomeFileSystemError extends Schema.TaggedErrorClass<CodexShadowHomeFileSystemError>()( |
| 72 | "CodexShadowHomeFileSystemError", |
| 73 | { |
| 74 | ...CodexShadowHomeContext, |
| 75 | operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]), |
| 76 | path: Schema.String, |
| 77 | targetPath: Schema.optional(Schema.String), |
| 78 | entryName: Schema.optional(Schema.String), |
| 79 | cause: Schema.Defect(), |
| 80 | }, |
| 81 | ) { |
| 82 | override get message(): string { |
| 83 | const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; |
| 84 | return `Codex shadow home filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | export class CodexShadowHomePathConflictError extends Schema.TaggedErrorClass<CodexShadowHomePathConflictError>()( |
| 89 | "CodexShadowHomePathConflictError", |
| 90 | CodexShadowHomeContext, |
| 91 | ) { |
| 92 | override get message(): string { |
| 93 | return `Codex shadow home path '${this.effectiveHomePath}' must be different from the shared home path '${this.sharedHomePath}'.`; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | export class CodexShadowHomeEntryConflictError extends Schema.TaggedErrorClass<CodexShadowHomeEntryConflictError>()( |
| 98 | "CodexShadowHomeEntryConflictError", |
| 68 | 99 | { |
| 69 | | detail: Schema.String, |
| 70 | | cause: Schema.optional(Schema.Unknown), |
| 100 | ...CodexShadowHomeContext, |
| 101 | entryName: Schema.String, |
| 102 | linkPath: Schema.String, |
| 103 | targetPath: Schema.String, |
| 71 | 104 | }, |
| 72 | 105 | ) { |
| 73 | 106 | override get message(): string { |
| 74 | | return this.detail; |
| 107 | return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; |
| 75 | 108 | } |
| 76 | 109 | } |
| 77 | | const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); |
| 110 | |
| 111 | export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedErrorClass<CodexShadowHomePrivateEntrySymlinkError>()( |
| 112 | "CodexShadowHomePrivateEntrySymlinkError", |
| 113 | { |
| 114 | ...CodexShadowHomeContext, |
| 115 | entryName: Schema.String, |
| 116 | path: Schema.String, |
| 117 | }, |
| 118 | ) { |
| 119 | override get message(): string { |
| 120 | return `Codex shadow home private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | export const CodexShadowHomeError = Schema.Union([ |
| 125 | CodexShadowHomeFileSystemError, |
| 126 | CodexShadowHomePathConflictError, |
| 127 | CodexShadowHomeEntryConflictError, |
| 128 | CodexShadowHomePrivateEntrySymlinkError, |
| 129 | ]); |
| 130 | export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; |
| 78 | 131 | |
| 79 | 132 | type LinkState = |
| 80 | 133 | | { |
| ... | ... | @@ -88,21 +141,6 @@ type LinkState = |
| 88 | 141 | readonly target: string; |
| 89 | 142 | }; |
| 90 | 143 | |
| 91 | | function toShadowHomeError(cause: unknown): CodexShadowHomeError { |
| 92 | | return isCodexShadowHomeError(cause) |
| 93 | | ? cause |
| 94 | | : new CodexShadowHomeError({ |
| 95 | | detail: "Failed to materialize Codex shadow home.", |
| 96 | | cause, |
| 97 | | }); |
| 98 | | } |
| 99 | | |
| 100 | | function normalizeShadowHomeError<A, E, R>( |
| 101 | | effect: Effect.Effect<A, E, R>, |
| 102 | | ): Effect.Effect<A, CodexShadowHomeError, R> { |
| 103 | | return effect.pipe(Effect.mapError(toShadowHomeError)); |
| 104 | | } |
| 105 | | |
| 106 | 144 | function isNotSymlinkError(error: PlatformError.PlatformError): boolean { |
| 107 | 145 | const cause = error.reason.cause; |
| 108 | 146 | return ( |
| ... | ... | @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { |
| 114 | 152 | ); |
| 115 | 153 | } |
| 116 | 154 | |
| 117 | | const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( |
| 118 | | fileSystem: FileSystem.FileSystem, |
| 119 | | linkPath: string, |
| 120 | | ): Effect.fn.Return<LinkState, CodexShadowHomeError> { |
| 121 | | return yield* fileSystem.readLink(linkPath).pipe( |
| 155 | const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (input: { |
| 156 | readonly fileSystem: FileSystem.FileSystem; |
| 157 | readonly sharedHomePath: string; |
| 158 | readonly effectiveHomePath: string; |
| 159 | readonly entryName: string; |
| 160 | readonly linkPath: string; |
| 161 | }): Effect.fn.Return<LinkState, CodexShadowHomeError> { |
| 162 | return yield* input.fileSystem.readLink(input.linkPath).pipe( |
| 122 | 163 | Effect.map((target): LinkState => ({ _tag: "Symlink", target })), |
| 123 | | Effect.catch((error) => { |
| 124 | | if (error.reason._tag === "NotFound") { |
| 125 | | return Effect.succeed<LinkState>({ _tag: "Missing" }); |
| 126 | | } |
| 127 | | if (isNotSymlinkError(error)) { |
| 128 | | return Effect.succeed<LinkState>({ _tag: "NotSymlink" }); |
| 129 | | } |
| 130 | | return Effect.fail(toShadowHomeError(error)); |
| 164 | Effect.catchTags({ |
| 165 | PlatformError: (cause) => { |
| 166 | if (cause.reason._tag === "NotFound") { |
| 167 | return Effect.succeed<LinkState>({ _tag: "Missing" }); |
| 168 | } |
| 169 | if (isNotSymlinkError(cause)) { |
| 170 | return Effect.succeed<LinkState>({ _tag: "NotSymlink" }); |
| 171 | } |
| 172 | return new CodexShadowHomeFileSystemError({ |
| 173 | sharedHomePath: input.sharedHomePath, |
| 174 | effectiveHomePath: input.effectiveHomePath, |
| 175 | operation: "readLink", |
| 176 | path: input.linkPath, |
| 177 | entryName: input.entryName, |
| 178 | cause, |
| 179 | }); |
| 180 | }, |
| 131 | 181 | }), |
| 132 | 182 | ); |
| 133 | 183 | }); |
| 134 | 184 | |
| 135 | 185 | const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { |
| 136 | 186 | readonly fileSystem: FileSystem.FileSystem; |
| 137 | | readonly shadowPath: string; |
| 187 | readonly sharedHomePath: string; |
| 188 | readonly effectiveHomePath: string; |
| 138 | 189 | readonly entryName: string; |
| 139 | 190 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 140 | 191 | const path = yield* Path.Path; |
| 141 | | const privatePath = path.join(input.shadowPath, input.entryName); |
| 142 | | const state = yield* readLinkState(input.fileSystem, privatePath); |
| 192 | const privatePath = path.join(input.effectiveHomePath, input.entryName); |
| 193 | const state = yield* readLinkState({ |
| 194 | ...input, |
| 195 | linkPath: privatePath, |
| 196 | }); |
| 143 | 197 | if (state._tag === "Symlink") { |
| 144 | | yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); |
| 198 | yield* input.fileSystem.remove(privatePath).pipe( |
| 199 | Effect.catchTags({ |
| 200 | PlatformError: (cause) => |
| 201 | new CodexShadowHomeFileSystemError({ |
| 202 | sharedHomePath: input.sharedHomePath, |
| 203 | effectiveHomePath: input.effectiveHomePath, |
| 204 | operation: "remove", |
| 205 | path: privatePath, |
| 206 | entryName: input.entryName, |
| 207 | cause, |
| 208 | }), |
| 209 | }), |
| 210 | ); |
| 145 | 211 | } |
| 146 | 212 | }); |
| 147 | 213 | |
| 148 | 214 | const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { |
| 149 | 215 | readonly fileSystem: FileSystem.FileSystem; |
| 150 | | readonly shadowPath: string; |
| 151 | | readonly sharedPath: string; |
| 216 | readonly sharedHomePath: string; |
| 217 | readonly effectiveHomePath: string; |
| 152 | 218 | readonly entryName: string; |
| 153 | 219 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 154 | 220 | const path = yield* Path.Path; |
| 155 | | const target = path.join(input.sharedPath, input.entryName); |
| 156 | | const link = path.join(input.shadowPath, input.entryName); |
| 157 | | const state = yield* readLinkState(input.fileSystem, link); |
| 221 | const target = path.join(input.sharedHomePath, input.entryName); |
| 222 | const link = path.join(input.effectiveHomePath, input.entryName); |
| 223 | const state = yield* readLinkState({ |
| 224 | ...input, |
| 225 | linkPath: link, |
| 226 | }); |
| 158 | 227 | |
| 159 | 228 | if (state._tag === "NotSymlink") { |
| 160 | | return yield* new CodexShadowHomeError({ |
| 161 | | detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, |
| 229 | return yield* new CodexShadowHomeEntryConflictError({ |
| 230 | sharedHomePath: input.sharedHomePath, |
| 231 | effectiveHomePath: input.effectiveHomePath, |
| 232 | entryName: input.entryName, |
| 233 | linkPath: link, |
| 234 | targetPath: target, |
| 162 | 235 | }); |
| 163 | 236 | } |
| 164 | 237 | |
| 238 | const createLink = input.fileSystem.symlink(target, link).pipe( |
| 239 | Effect.catchTags({ |
| 240 | PlatformError: (cause) => |
| 241 | new CodexShadowHomeFileSystemError({ |
| 242 | sharedHomePath: input.sharedHomePath, |
| 243 | effectiveHomePath: input.effectiveHomePath, |
| 244 | operation: "symlink", |
| 245 | path: link, |
| 246 | targetPath: target, |
| 247 | entryName: input.entryName, |
| 248 | cause, |
| 249 | }), |
| 250 | }), |
| 251 | ); |
| 252 | |
| 165 | 253 | if (state._tag === "Missing") { |
| 166 | | return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); |
| 254 | return yield* createLink; |
| 167 | 255 | } |
| 168 | 256 | |
| 169 | 257 | const resolvedExisting = path.resolve(path.dirname(link), state.target); |
| 170 | 258 | if (resolvedExisting !== target) { |
| 171 | | yield* normalizeShadowHomeError(input.fileSystem.remove(link)); |
| 172 | | yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); |
| 259 | yield* input.fileSystem.remove(link).pipe( |
| 260 | Effect.catchTags({ |
| 261 | PlatformError: (cause) => |
| 262 | new CodexShadowHomeFileSystemError({ |
| 263 | sharedHomePath: input.sharedHomePath, |
| 264 | effectiveHomePath: input.effectiveHomePath, |
| 265 | operation: "remove", |
| 266 | path: link, |
| 267 | entryName: input.entryName, |
| 268 | cause, |
| 269 | }), |
| 270 | }), |
| 271 | ); |
| 272 | yield* createLink; |
| 173 | 273 | } |
| 174 | 274 | }); |
| 175 | 275 | |
| 176 | | const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( |
| 177 | | fileSystem: FileSystem.FileSystem, |
| 178 | | shadowPath: string, |
| 179 | | ): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 180 | | const path = yield* Path.Path; |
| 181 | | const authPath = path.join(shadowPath, "auth.json"); |
| 182 | | const state = yield* readLinkState(fileSystem, authPath); |
| 183 | | if (state._tag === "Symlink") { |
| 184 | | return yield* new CodexShadowHomeError({ |
| 185 | | detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, |
| 276 | const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( |
| 277 | function* (input: { |
| 278 | readonly fileSystem: FileSystem.FileSystem; |
| 279 | readonly sharedHomePath: string; |
| 280 | readonly effectiveHomePath: string; |
| 281 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 282 | const path = yield* Path.Path; |
| 283 | const entryName = "auth.json"; |
| 284 | const authPath = path.join(input.effectiveHomePath, entryName); |
| 285 | const state = yield* readLinkState({ |
| 286 | ...input, |
| 287 | entryName, |
| 288 | linkPath: authPath, |
| 186 | 289 | }); |
| 187 | | } |
| 188 | | }); |
| 290 | if (state._tag === "Symlink") { |
| 291 | return yield* new CodexShadowHomePrivateEntrySymlinkError({ |
| 292 | sharedHomePath: input.sharedHomePath, |
| 293 | effectiveHomePath: input.effectiveHomePath, |
| 294 | entryName, |
| 295 | path: authPath, |
| 296 | }); |
| 297 | } |
| 298 | }, |
| 299 | ); |
| 189 | 300 | |
| 190 | 301 | export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( |
| 191 | 302 | layout: CodexHomeLayout, |
| ... | ... | @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 194 | 305 | const effectiveHomePath = layout.effectiveHomePath; |
| 195 | 306 | if (!effectiveHomePath) return; |
| 196 | 307 | if (layout.sharedHomePath === effectiveHomePath) { |
| 197 | | return yield* new CodexShadowHomeError({ |
| 198 | | detail: "Codex shadow home path must be different from the shared home path.", |
| 308 | return yield* new CodexShadowHomePathConflictError({ |
| 309 | sharedHomePath: layout.sharedHomePath, |
| 310 | effectiveHomePath, |
| 199 | 311 | }); |
| 200 | 312 | } |
| 201 | 313 | |
| 202 | 314 | const fileSystem = yield* FileSystem.FileSystem; |
| 203 | 315 | const path = yield* Path.Path; |
| 204 | 316 | |
| 205 | | yield* normalizeShadowHomeError( |
| 206 | | Effect.all( |
| 207 | | [ |
| 208 | | fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), |
| 209 | | fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), |
| 210 | | ...KNOWN_SHARED_DIRECTORIES.map((directory) => |
| 211 | | fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { |
| 212 | | recursive: true, |
| 317 | const makeDirectory = (directoryPath: string) => |
| 318 | fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( |
| 319 | Effect.catchTags({ |
| 320 | PlatformError: (cause) => |
| 321 | new CodexShadowHomeFileSystemError({ |
| 322 | sharedHomePath: layout.sharedHomePath, |
| 323 | effectiveHomePath, |
| 324 | operation: "makeDirectory", |
| 325 | path: directoryPath, |
| 326 | cause, |
| 213 | 327 | }), |
| 214 | | ), |
| 215 | | ], |
| 216 | | { concurrency: "unbounded" }, |
| 217 | | ), |
| 328 | }), |
| 329 | ); |
| 330 | |
| 331 | yield* Effect.all( |
| 332 | [ |
| 333 | makeDirectory(layout.sharedHomePath), |
| 334 | makeDirectory(effectiveHomePath), |
| 335 | ...KNOWN_SHARED_DIRECTORIES.map((directory) => |
| 336 | makeDirectory(path.join(layout.sharedHomePath, directory)), |
| 337 | ), |
| 338 | ], |
| 339 | { concurrency: "unbounded" }, |
| 218 | 340 | ); |
| 219 | 341 | |
| 220 | | const sharedEntryNames = yield* normalizeShadowHomeError( |
| 221 | | fileSystem.readDirectory(layout.sharedHomePath), |
| 342 | const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( |
| 343 | Effect.catchTags({ |
| 344 | PlatformError: (cause) => |
| 345 | new CodexShadowHomeFileSystemError({ |
| 346 | sharedHomePath: layout.sharedHomePath, |
| 347 | effectiveHomePath, |
| 348 | operation: "readDirectory", |
| 349 | path: layout.sharedHomePath, |
| 350 | cause, |
| 351 | }), |
| 352 | }), |
| 222 | 353 | ); |
| 223 | 354 | const entries = new Set<string>(KNOWN_SHARED_DIRECTORIES); |
| 224 | 355 | for (const entryName of sharedEntryNames) { |
| ... | ... | @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 234 | 365 | ? Effect.void |
| 235 | 366 | : removePrivateSymlink({ |
| 236 | 367 | fileSystem, |
| 237 | | shadowPath: effectiveHomePath, |
| 368 | sharedHomePath: layout.sharedHomePath, |
| 369 | effectiveHomePath, |
| 238 | 370 | entryName, |
| 239 | 371 | }), |
| 240 | 372 | { discard: true }, |
| ... | ... | @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 248 | 380 | } |
| 249 | 381 | return ensureSymlink({ |
| 250 | 382 | fileSystem, |
| 251 | | shadowPath: effectiveHomePath, |
| 252 | | sharedPath: layout.sharedHomePath, |
| 383 | sharedHomePath: layout.sharedHomePath, |
| 384 | effectiveHomePath, |
| 253 | 385 | entryName, |
| 254 | 386 | }); |
| 255 | 387 | }, |
| 256 | 388 | { discard: true }, |
| 257 | 389 | ); |
| 258 | 390 | |
| 259 | | yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); |
| 391 | yield* ensureShadowAuthIsPrivate({ |
| 392 | fileSystem, |
| 393 | sharedHomePath: layout.sharedHomePath, |
| 394 | effectiveHomePath, |
| 395 | }); |
| 260 | 396 | }); |
| 261 | 397 | |
| 262 | 398 | export function codexContinuationIdentity(layout: CodexHomeLayout) { |