@savvy-web/silk-effects 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Shared [Effect](https://effect.website/) library providing Silk Suite convention
13
13
  - Supply the Silk commitlint config, its prompt and formatter, and the `silk/body-no-markdown` rule
14
14
  - Run the lint-staged handlers and the `savvy lint fmt` formatters from a single implementation so the hook and the CLI cannot drift
15
15
  - Inspect a Turborepo read-only — diagnose per-package cache hits, derive the task graph and compute affected packages, all over `turbo --dry`
16
- - Manage the vendored reference repos declared in `.repos/config.json`, including submodule sync, pinning and drift reporting
16
+ - Manage the vendored reference repos declared in `.repos/config.json` submodule status, sync, pinning and notes — and keep every vendored tree read-only between mutations
17
17
  - Locate config files and keep Biome schema URLs in sync across workspaces
18
18
 
19
19
  ## Install
@@ -364,6 +364,33 @@ const diagnosis = await Effect.runPromise(
364
364
  // => CacheDiagnosis: per-package HIT/MISS breakdown for the task
365
365
  ```
366
366
 
367
+ #### ReposManager and ReposLockdown
368
+
369
+ The `Repos` namespace drives vendored reference repos — upstream sources checked out as git submodules under `.repos/`, declared in a `.repos/config.json` manifest. `ReposConfigStore` reads and writes that manifest, and `ReposManager` does the git work: `status(root)` reports presence, the pinned commit, working-tree dirtiness and stale notes, `sync(root)` initializes missing submodules and applies each entry's sparse-checkout, `pin(root, name, ref)` moves an entry to a new ref, `add(root, options)` vendors a new one, and `note(root, name, op)` adds, removes or promotes an agent note.
370
+
371
+ `ReposLockdown` is the permissions boundary around all of that. `lock(root, name)` chmods a vendored worktree and its submodule git metadata to files `0444` and directories `0555`; `unlock` reverses it; `withUnlocked(root, name, effect)` brackets an effect between the two. `ReposManager`'s `sync`, `add` and `pin` run their git mutations inside that bracket and re-lock afterwards, so a vendored tree is read-only whenever the manager is not mid-write. Reads are unaffected — a locked tree needs no special handling to open a file.
372
+
373
+ Two consequences for callers: `ReposManager.layer` now requires `ReposLockdown` alongside `ReposConfigStore`, `Git`, `FileSystem` and `Path`, and `sync`, `add` and `pin` widen their error channel with `ReposLockdownError`, which carries the offending `path` and a `reason`.
374
+
375
+ ```typescript
376
+ import { Effect, Layer } from "effect";
377
+ import { NodeServices } from "@effect/platform-node";
378
+ import { Git } from "@effected/git";
379
+ import { Repos } from "@savvy-web/silk-effects";
380
+
381
+ const report = await Effect.runPromise(
382
+ Effect.gen(function* () {
383
+ const repos = yield* Repos.ReposManager;
384
+ return yield* repos.sync(process.cwd());
385
+ }).pipe(
386
+ Effect.provide(Repos.ReposManager.layer),
387
+ Effect.provide(Layer.mergeAll(Repos.ReposConfigStore.layer, Repos.ReposLockdown.layer, Git.layer)),
388
+ Effect.provide(NodeServices.layer),
389
+ ),
390
+ );
391
+ // => ReposSyncReport: per-repo initialization and sparse-checkout outcome, trees left read-only
392
+ ```
393
+
367
394
  ## Documentation
368
395
 
369
396
  - [Overview](./docs/01-overview.md) — what the library is, its design philosophy and platform-layer model
package/index.d.ts CHANGED
@@ -289,7 +289,7 @@ declare class Categories {
289
289
  static isValidHeading(heading: string): boolean;
290
290
  }
291
291
  //#endregion
292
- //#region ../../node_modules/.pnpm/@changesets+types@7.0.0-next.8/node_modules/@changesets/types/dist/index.d.mts
292
+ //#region ../../node_modules/.pnpm/@changesets+types@7.0.0-next.9/node_modules/@changesets/types/dist/index.d.mts
293
293
  //#region src/index.d.ts
294
294
  type MaybePromise<T> = T | Promise<T>;
295
295
  type VersionType$1 = "major" | "minor" | "patch" | "none";
@@ -417,7 +417,6 @@ type ChangelogFunctions = {
417
417
  type PreState = {
418
418
  mode: "pre" | "exit";
419
419
  tag: string;
420
- changesets: string[];
421
420
  };
422
421
  //#endregion
423
422
  //#region src/changesets/api/changelog.d.ts
@@ -8282,6 +8281,20 @@ declare class NoteNotFoundError extends NoteNotFoundErrorBase<{
8282
8281
  }> {
8283
8282
  get message(): string;
8284
8283
  }
8284
+ /** @internal */
8285
+ declare const ReposLockdownErrorBase: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
8286
+ readonly _tag: "ReposLockdownError";
8287
+ } & Readonly<A>;
8288
+ /**
8289
+ * A permissions (lockdown) operation on a vendored repo failed.
8290
+ * @public
8291
+ */
8292
+ declare class ReposLockdownError extends ReposLockdownErrorBase<{
8293
+ readonly path: string;
8294
+ readonly reason: string;
8295
+ }> {
8296
+ get message(): string;
8297
+ }
8285
8298
  //#endregion
8286
8299
  //#region src/repos/schemas/manifest.d.ts
8287
8300
  /**
@@ -8475,6 +8488,52 @@ declare class ReposConfigStore extends ReposConfigStore_base {
8475
8488
  static readonly layer: Layer.Layer<ReposConfigStore, never, FileSystem.FileSystem | Path.Path>;
8476
8489
  }
8477
8490
  //#endregion
8491
+ //#region src/repos/services/lockdown.d.ts
8492
+ /**
8493
+ * The {@link ReposLockdown} service shape.
8494
+ * @public
8495
+ */
8496
+ interface ReposLockdownShape {
8497
+ readonly lock: (root: string, name: string) => Effect.Effect<void, ReposLockdownError>;
8498
+ readonly unlock: (root: string, name: string) => Effect.Effect<void, ReposLockdownError>;
8499
+ readonly withUnlocked: <A, E, R>(root: string, name: string, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | ReposLockdownError, R>;
8500
+ }
8501
+ /**
8502
+ * Derives a submodule's git metadata directory (`.git/modules/...`) from the
8503
+ * checkout itself rather than assuming it is named after the manifest key.
8504
+ *
8505
+ * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
8506
+ * containing a single `gitdir: <path>` line pointing at the real metadata
8507
+ * directory, which git names after whatever path/name the submodule was
8508
+ * REGISTERED under — not necessarily the manifest key (e.g. this repo's own
8509
+ * `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
8510
+ * reads that pointer and resolves it (relative pointers are relative to the
8511
+ * directory containing the `.git` file) so callers always land on the real
8512
+ * metadata directory.
8513
+ *
8514
+ * Falls back to the name-based path `<root>/.git/modules/<REPOS_DIR>/<name>`
8515
+ * whenever the pointer can't be read (submodule not initialized, `.git`
8516
+ * missing) — this never fails, it only degrades to prior behavior. If
8517
+ * `<root>/.repos/<name>/.git` is itself a directory (a plain, non-submodule
8518
+ * checkout), it is used directly as the metadata directory.
8519
+ *
8520
+ * @internal
8521
+ */
8522
+ declare const resolveModuleDir: (fs: FileSystem.FileSystem, path: Path.Path, root: string, name: string) => Effect.Effect<string>;
8523
+ declare const ReposLockdown_base: Context.ServiceClass<ReposLockdown, "@savvy-web/silk-effects/ReposLockdown", ReposLockdownShape>;
8524
+ /**
8525
+ * Enforces OS-level read-only permissions on vendored repos so they cannot
8526
+ * be accidentally edited outside the sync flow.
8527
+ * @public
8528
+ */
8529
+ declare class ReposLockdown extends ReposLockdown_base {
8530
+ /**
8531
+ * Production layer over the core FileSystem.
8532
+ * @public
8533
+ */
8534
+ static readonly layer: Layer.Layer<ReposLockdown, never, FileSystem.FileSystem | Path.Path>;
8535
+ }
8536
+ //#endregion
8478
8537
  //#region src/repos/services/manager.d.ts
8479
8538
  /**
8480
8539
  * Minimum age (in milliseconds) a `.lock` file must reach before `sync` will
@@ -8499,15 +8558,15 @@ declare const STALE_LOCK_MAX_AGE_MS: number;
8499
8558
  */
8500
8559
  interface ReposManagerShape {
8501
8560
  readonly status: (root: string) => Effect.Effect<ReposStatusReport, ReposConfigError | GitSubmoduleError>;
8502
- readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError>;
8561
+ readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8503
8562
  readonly add: (root: string, options: {
8504
8563
  readonly url: string;
8505
8564
  readonly ref: string;
8506
8565
  readonly purpose: string;
8507
8566
  readonly name?: string;
8508
8567
  readonly sparse?: ReadonlyArray<string>;
8509
- }) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError>;
8510
- readonly pin: (root: string, name: string, ref: string) => Effect.Effect<ReposPinResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError>;
8568
+ }) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
8569
+ readonly pin: (root: string, name: string, ref: string) => Effect.Effect<ReposPinResult, ReposConfigError | GitSubmoduleError | RepoNotFoundError | ReposLockdownError>;
8511
8570
  readonly note: (root: string, name: string, op: {
8512
8571
  readonly op: "add";
8513
8572
  readonly note: string;
@@ -8540,10 +8599,10 @@ declare class ReposManager extends ReposManager_base {
8540
8599
  * this module's {@link GitSubmoduleError} to keep the declared error unions.
8541
8600
  * @public
8542
8601
  */
8543
- static readonly layer: Layer.Layer<ReposManager, never, ReposConfigStore | Git | FileSystem.FileSystem | Path.Path>;
8602
+ static readonly layer: Layer.Layer<ReposManager, never, ReposConfigStore | Git | FileSystem.FileSystem | Path.Path | ReposLockdown>;
8544
8603
  }
8545
8604
  declare namespace index_d_exports$3 {
8546
- export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS };
8605
+ export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NOTE_LIMIT, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposConfigStoreShape, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposLockdownShape, ReposManager, ReposManagerShape, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, resolveModuleDir };
8547
8606
  }
8548
8607
  //#endregion
8549
8608
  //#region src/schemas/BiomeConfig.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/silk-effects",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "private": false,
5
5
  "description": "Shared Effect library for Silk Suite conventions",
6
6
  "homepage": "https://github.com/savvy-web/systems/tree/main/packages/silk-effects",
@@ -33,14 +33,14 @@
33
33
  "@changesets/config": "^4.0.0-next.6",
34
34
  "@changesets/get-github-info": "^1.0.0-next.4",
35
35
  "@changesets/get-release-plan": "^5.0.0-next.9",
36
- "@effected/commands": "^0.2.1",
36
+ "@effected/commands": "^0.3.1",
37
37
  "@effected/git": "^0.5.2",
38
38
  "@effected/glob": "^0.2.2",
39
39
  "@effected/jsonc": "^0.5.2",
40
- "@effected/package-json": "^0.7.2",
40
+ "@effected/package-json": "^0.7.3",
41
41
  "@effected/templates": "^0.1.1",
42
42
  "@effected/walker": "^0.3.4",
43
- "@effected/workspaces": "^0.9.4",
43
+ "@effected/workspaces": "^0.10.0",
44
44
  "@effected/yaml": "^0.6.1",
45
45
  "@manypkg/get-packages": "^3.1.0",
46
46
  "mdast-util-heading-range": "^4.0.0",
package/repos/errors.js CHANGED
@@ -45,6 +45,17 @@ var NoteNotFoundError = class extends NoteNotFoundErrorBase {
45
45
  return `no note "${this.id}" on vendored repo "${this.name}"`;
46
46
  }
47
47
  };
48
+ /** @internal */
49
+ const ReposLockdownErrorBase = Data.TaggedError("ReposLockdownError");
50
+ /**
51
+ * A permissions (lockdown) operation on a vendored repo failed.
52
+ * @public
53
+ */
54
+ var ReposLockdownError = class extends ReposLockdownErrorBase {
55
+ get message() {
56
+ return `repos lockdown failed at ${this.path}: ${this.reason}`;
57
+ }
58
+ };
48
59
 
49
60
  //#endregion
50
- export { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase };
61
+ export { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase, ReposLockdownError, ReposLockdownErrorBase };
package/repos/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { __exportAll } from "../_virtual/_rolldown/runtime.js";
2
2
  import { MANIFEST_PATH, REPOS_DIR } from "./constants.js";
3
- import { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase } from "./errors.js";
3
+ import { GitSubmoduleError, GitSubmoduleErrorBase, NoteNotFoundError, NoteNotFoundErrorBase, RepoNotFoundError, RepoNotFoundErrorBase, ReposConfigError, ReposConfigErrorBase, ReposLockdownError, ReposLockdownErrorBase } from "./errors.js";
4
4
  import { RepoEntry, RepoName, RepoNote, RepoOrientation, ReposManifestFile } from "./schemas/manifest.js";
5
5
  import { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport } from "./schemas/reports.js";
6
6
  import { ReposConfigStore } from "./services/config-store.js";
7
+ import { ReposLockdown, resolveModuleDir } from "./services/lockdown.js";
7
8
  import { ReposManager, STALE_LOCK_MAX_AGE_MS } from "./services/manager.js";
8
9
 
9
10
  //#region src/repos/index.ts
@@ -26,14 +27,18 @@ var repos_exports = /* @__PURE__ */ __exportAll({
26
27
  ReposConfigError: () => ReposConfigError,
27
28
  ReposConfigErrorBase: () => ReposConfigErrorBase,
28
29
  ReposConfigStore: () => ReposConfigStore,
30
+ ReposLockdown: () => ReposLockdown,
31
+ ReposLockdownError: () => ReposLockdownError,
32
+ ReposLockdownErrorBase: () => ReposLockdownErrorBase,
29
33
  ReposManager: () => ReposManager,
30
34
  ReposManifestFile: () => ReposManifestFile,
31
35
  ReposNoteResult: () => ReposNoteResult,
32
36
  ReposPinResult: () => ReposPinResult,
33
37
  ReposStatusReport: () => ReposStatusReport,
34
38
  ReposSyncReport: () => ReposSyncReport,
35
- STALE_LOCK_MAX_AGE_MS: () => STALE_LOCK_MAX_AGE_MS
39
+ STALE_LOCK_MAX_AGE_MS: () => STALE_LOCK_MAX_AGE_MS,
40
+ resolveModuleDir: () => resolveModuleDir
36
41
  });
37
42
 
38
43
  //#endregion
39
- export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports };
44
+ export { GitSubmoduleError, GitSubmoduleErrorBase, MANIFEST_PATH, NoteNotFoundError, NoteNotFoundErrorBase, REPOS_DIR, RepoEntry, RepoName, RepoNotFoundError, RepoNotFoundErrorBase, RepoNote, RepoOrientation, RepoStatusEntry, ReposAddResult, ReposConfigError, ReposConfigErrorBase, ReposConfigStore, ReposLockdown, ReposLockdownError, ReposLockdownErrorBase, ReposManager, ReposManifestFile, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport, STALE_LOCK_MAX_AGE_MS, repos_exports, resolveModuleDir };
@@ -0,0 +1,119 @@
1
+ import { REPOS_DIR } from "../constants.js";
2
+ import { ReposLockdownError } from "../errors.js";
3
+ import { Context, Effect, Exit, FileSystem, Layer, Option, Path } from "effect";
4
+
5
+ //#region src/repos/services/lockdown.ts
6
+ const FILE_LOCKED_BASE_MODE = 292;
7
+ const DIR_LOCKED_MODE = 365;
8
+ const FILE_UNLOCKED_BASE_MODE = 420;
9
+ const DIR_UNLOCKED_MODE = 493;
10
+ const EXEC_BITS = 73;
11
+ /**
12
+ * Preserves the executable bit through a lock/unlock transition: a file
13
+ * whose current mode has any of the owner/group/other execute bits set
14
+ * locks to `0o555` and unlocks to `0o755` instead of stripping execute
15
+ * permission entirely.
16
+ */
17
+ const fileModeFor = (baseMode, currentMode) => baseMode | (currentMode & EXEC_BITS ? EXEC_BITS : 0);
18
+ /**
19
+ * Derives a submodule's git metadata directory (`.git/modules/...`) from the
20
+ * checkout itself rather than assuming it is named after the manifest key.
21
+ *
22
+ * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
23
+ * containing a single `gitdir: <path>` line pointing at the real metadata
24
+ * directory, which git names after whatever path/name the submodule was
25
+ * REGISTERED under — not necessarily the manifest key (e.g. this repo's own
26
+ * `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
27
+ * reads that pointer and resolves it (relative pointers are relative to the
28
+ * directory containing the `.git` file) so callers always land on the real
29
+ * metadata directory.
30
+ *
31
+ * Falls back to the name-based path `<root>/.git/modules/<REPOS_DIR>/<name>`
32
+ * whenever the pointer can't be read (submodule not initialized, `.git`
33
+ * missing) — this never fails, it only degrades to prior behavior. If
34
+ * `<root>/.repos/<name>/.git` is itself a directory (a plain, non-submodule
35
+ * checkout), it is used directly as the metadata directory.
36
+ *
37
+ * @internal
38
+ */
39
+ const resolveModuleDir = (fs, path, root, name) => Effect.gen(function* () {
40
+ const fallback = path.join(root, ".git", "modules", REPOS_DIR, name);
41
+ const dotGit = path.join(root, REPOS_DIR, name, ".git");
42
+ const info = yield* fs.stat(dotGit).pipe(Effect.option);
43
+ if (Option.isNone(info)) return fallback;
44
+ if (info.value.type === "Directory") return dotGit;
45
+ const content = yield* fs.readFileString(dotGit).pipe(Effect.option);
46
+ if (Option.isNone(content)) return fallback;
47
+ const match = /^gitdir:\s*(.+)$/m.exec(content.value);
48
+ if (!match?.[1]) return fallback;
49
+ const pointer = match[1].trim();
50
+ const resolved = path.isAbsolute(pointer) ? pointer : path.resolve(path.dirname(dotGit), pointer);
51
+ const relative = path.relative(root, resolved);
52
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return fallback;
53
+ return resolved;
54
+ });
55
+ /**
56
+ * Enforces OS-level read-only permissions on vendored repos so they cannot
57
+ * be accidentally edited outside the sync flow.
58
+ * @public
59
+ */
60
+ var ReposLockdown = class extends Context.Service()("@savvy-web/silk-effects/ReposLockdown") {
61
+ /**
62
+ * Production layer over the core FileSystem.
63
+ * @public
64
+ */
65
+ static layer = Layer.effect(this, Effect.gen(function* () {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const chmod = (entryPath, mode) => fs.chmod(entryPath, mode).pipe(Effect.mapError((cause) => new ReposLockdownError({
69
+ path: entryPath,
70
+ reason: `chmod failed: ${String(cause)}`
71
+ })));
72
+ const isSymlink = (entryPath) => fs.readLink(entryPath).pipe(Effect.option, Effect.map(Option.isSome));
73
+ const walk = (dir, fileBaseMode, dirMode, order) => Effect.gen(function* () {
74
+ if (order === "unlock") yield* chmod(dir, dirMode);
75
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
76
+ path: dir,
77
+ reason: `readDirectory failed: ${String(cause)}`
78
+ })));
79
+ for (const entry of entries) {
80
+ const entryPath = path.join(dir, entry);
81
+ if (yield* isSymlink(entryPath)) continue;
82
+ const maybeInfo = yield* fs.stat(entryPath).pipe(Effect.option);
83
+ if (Option.isNone(maybeInfo)) continue;
84
+ const info = maybeInfo.value;
85
+ if (info.type === "Directory") yield* walk(entryPath, fileBaseMode, dirMode, order);
86
+ else yield* chmod(entryPath, fileModeFor(fileBaseMode, info.mode));
87
+ }
88
+ if (order === "lock") yield* chmod(dir, dirMode);
89
+ });
90
+ const walkRoot = (root, name, fileBaseMode, dirMode, order) => Effect.gen(function* () {
91
+ const moduleDir = yield* resolveModuleDir(fs, path, root, name);
92
+ for (const dir of [path.join(root, REPOS_DIR, name), moduleDir]) {
93
+ if (!(yield* fs.exists(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
94
+ path: dir,
95
+ reason: `stat failed: ${String(cause)}`
96
+ }))))) continue;
97
+ yield* walk(dir, fileBaseMode, dirMode, order);
98
+ }
99
+ });
100
+ const lock = (root, name) => walkRoot(root, name, FILE_LOCKED_BASE_MODE, DIR_LOCKED_MODE, "lock");
101
+ const unlock = (root, name) => walkRoot(root, name, FILE_UNLOCKED_BASE_MODE, DIR_UNLOCKED_MODE, "unlock");
102
+ const withUnlocked = (root, name, effect) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
103
+ const unlockExit = yield* Effect.exit(unlock(root, name));
104
+ const resultExit = Exit.isFailure(unlockExit) ? Exit.failCause(unlockExit.cause) : yield* Effect.exit(restore(effect));
105
+ const relockExit = yield* Effect.exit(lock(root, name));
106
+ if (Exit.isFailure(resultExit)) return yield* Exit.failCause(resultExit.cause);
107
+ if (Exit.isFailure(relockExit)) return yield* Exit.failCause(relockExit.cause);
108
+ return resultExit.value;
109
+ }));
110
+ return {
111
+ lock,
112
+ unlock,
113
+ withUnlocked
114
+ };
115
+ }));
116
+ };
117
+
118
+ //#endregion
119
+ export { ReposLockdown, resolveModuleDir };
@@ -2,6 +2,7 @@ import { MANIFEST_PATH, REPOS_DIR } from "../constants.js";
2
2
  import { GitSubmoduleError, NoteNotFoundError, RepoNotFoundError, ReposConfigError } from "../errors.js";
3
3
  import { RepoName } from "../schemas/manifest.js";
4
4
  import { ReposConfigStore } from "./config-store.js";
5
+ import { ReposLockdown, resolveModuleDir } from "./lockdown.js";
5
6
  import { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
6
7
  import { Git } from "@effected/git";
7
8
  import { createHash } from "node:crypto";
@@ -49,6 +50,7 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
49
50
  const fs = yield* FileSystem.FileSystem;
50
51
  const path = yield* Path.Path;
51
52
  const git = yield* Git;
53
+ const lockdown = yield* ReposLockdown;
52
54
  /** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
53
55
  const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
54
56
  command,
@@ -88,33 +90,35 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
88
90
  const clearedLocks = [];
89
91
  for (const [name, entry] of Object.entries(manifest.repos)) {
90
92
  const repoPath = path.join(root, REPOS_DIR, name);
91
- const moduleDir = path.join(root, ".git", "modules", REPOS_DIR, name);
92
- let clearedAnyLock = false;
93
- for (const lock of STALE_LOCKS) {
94
- const lockPath = path.join(moduleDir, lock);
95
- const info = yield* fs.stat(lockPath).pipe(Effect.option);
96
- if (Option.isNone(info)) continue;
97
- const mtime = info.value.mtime;
98
- if (Option.isNone(mtime)) continue;
99
- if ((yield* Clock.currentTimeMillis) - mtime.value.getTime() < 6e5) continue;
100
- if (yield* fs.remove(lockPath).pipe(Effect.match({
101
- onSuccess: () => true,
102
- onFailure: () => false
103
- }))) clearedAnyLock = true;
104
- }
105
- if (clearedAnyLock) clearedLocks.push(name);
106
- if (!(yield* isPresent(repoPath))) {
107
- yield* git.submoduleUpdate(root, {
108
- init: true,
109
- depth: 1,
110
- paths: [`${REPOS_DIR}/${name}`]
111
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${REPOS_DIR}/${name}`, root)));
112
- initialized.push(name);
113
- } else upToDate.push(name);
114
- if (entry.sparse && entry.sparse.length > 0) {
115
- yield* git.sparseCheckoutSet(repoPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", repoPath)));
116
- sparseApplied.push(name);
117
- }
93
+ const moduleDir = yield* resolveModuleDir(fs, path, root, name);
94
+ yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
95
+ let clearedAnyLock = false;
96
+ for (const lock of STALE_LOCKS) {
97
+ const lockPath = path.join(moduleDir, lock);
98
+ const info = yield* fs.stat(lockPath).pipe(Effect.option);
99
+ if (Option.isNone(info)) continue;
100
+ const mtime = info.value.mtime;
101
+ if (Option.isNone(mtime)) continue;
102
+ if ((yield* Clock.currentTimeMillis) - mtime.value.getTime() < 6e5) continue;
103
+ if (yield* fs.remove(lockPath).pipe(Effect.match({
104
+ onSuccess: () => true,
105
+ onFailure: () => false
106
+ }))) clearedAnyLock = true;
107
+ }
108
+ if (clearedAnyLock) clearedLocks.push(name);
109
+ if (!(yield* isPresent(repoPath))) {
110
+ yield* git.submoduleUpdate(root, {
111
+ init: true,
112
+ depth: 1,
113
+ paths: [`${REPOS_DIR}/${name}`]
114
+ }).pipe(Effect.mapError(asSubmoduleError(`git submodule update --init --depth 1 -- ${REPOS_DIR}/${name}`, root)));
115
+ initialized.push(name);
116
+ } else upToDate.push(name);
117
+ if (entry.sparse && entry.sparse.length > 0) {
118
+ yield* git.sparseCheckoutSet(repoPath, entry.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", repoPath)));
119
+ sparseApplied.push(name);
120
+ }
121
+ }));
118
122
  }
119
123
  return {
120
124
  initialized,
@@ -157,15 +161,17 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
157
161
  }));
158
162
  const repoPath = `${REPOS_DIR}/${name}`;
159
163
  const subPath = path.join(root, repoPath);
160
- yield* git.submoduleAdd(root, {
161
- url: options.url,
162
- path: repoPath,
163
- depth: 1
164
- }).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${options.url} ${repoPath}`, root)));
165
- yield* git.configSet(root, `submodule.${repoPath}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPath}.shallow true`, root)));
166
- yield* fetchRef(subPath, options.ref);
167
- yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
168
- if (options.sparse && options.sparse.length > 0) yield* git.sparseCheckoutSet(subPath, options.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", subPath)));
164
+ yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
165
+ yield* git.submoduleAdd(root, {
166
+ url: options.url,
167
+ path: repoPath,
168
+ depth: 1
169
+ }).pipe(Effect.mapError(asSubmoduleError(`git submodule add --depth 1 ${options.url} ${repoPath}`, root)));
170
+ yield* git.configSet(root, `submodule.${repoPath}.shallow`, "true", { file: ".gitmodules" }).pipe(Effect.mapError(asSubmoduleError(`git config -f .gitmodules submodule.${repoPath}.shallow true`, root)));
171
+ yield* fetchRef(subPath, options.ref);
172
+ yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
173
+ if (options.sparse && options.sparse.length > 0) yield* git.sparseCheckoutSet(subPath, options.sparse, { cone: false }).pipe(Effect.mapError(asSubmoduleError("git sparse-checkout set --no-cone", subPath)));
174
+ }));
169
175
  const entry = {
170
176
  url: options.url,
171
177
  ref: options.ref,
@@ -193,10 +199,15 @@ var ReposManager = class extends Context.Service()("@savvy-web/silk-effects/Repo
193
199
  if (!entry) return yield* Effect.fail(new RepoNotFoundError({ name }));
194
200
  const repoPath = `${REPOS_DIR}/${name}`;
195
201
  const subPath = path.join(root, repoPath);
196
- const oldCommit = yield* git.revParse(subPath, "HEAD").pipe(Effect.orElseSucceed(() => null));
197
- yield* fetchRef(subPath, ref);
198
- yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
199
- const newCommit = yield* git.revParse(subPath, "HEAD").pipe(Effect.mapError(asSubmoduleError("git rev-parse HEAD", subPath)));
202
+ const { oldCommit, newCommit } = yield* lockdown.withUnlocked(root, name, Effect.gen(function* () {
203
+ const oldCommit = yield* git.revParse(subPath, "HEAD").pipe(Effect.orElseSucceed(() => null));
204
+ yield* fetchRef(subPath, ref);
205
+ yield* git.checkout(subPath, "FETCH_HEAD", { detach: true }).pipe(Effect.mapError(asSubmoduleError("git checkout --detach FETCH_HEAD", subPath)));
206
+ return {
207
+ oldCommit,
208
+ newCommit: yield* git.revParse(subPath, "HEAD").pipe(Effect.mapError(asSubmoduleError("git rev-parse HEAD", subPath)))
209
+ };
210
+ }));
200
211
  yield* configStore.write(root, { repos: {
201
212
  ...manifest.repos,
202
213
  [name]: {