@savvy-web/silk-effects 5.3.1 → 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 +28 -1
- package/index.d.ts +65 -5
- package/package.json +3 -3
- package/repos/errors.js +12 -1
- package/repos/index.js +8 -3
- package/repos/services/lockdown.js +119 -0
- package/repos/services/manager.js +51 -40
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
|
|
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
|
@@ -8281,6 +8281,20 @@ declare class NoteNotFoundError extends NoteNotFoundErrorBase<{
|
|
|
8281
8281
|
}> {
|
|
8282
8282
|
get message(): string;
|
|
8283
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
|
+
}
|
|
8284
8298
|
//#endregion
|
|
8285
8299
|
//#region src/repos/schemas/manifest.d.ts
|
|
8286
8300
|
/**
|
|
@@ -8474,6 +8488,52 @@ declare class ReposConfigStore extends ReposConfigStore_base {
|
|
|
8474
8488
|
static readonly layer: Layer.Layer<ReposConfigStore, never, FileSystem.FileSystem | Path.Path>;
|
|
8475
8489
|
}
|
|
8476
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
|
|
8477
8537
|
//#region src/repos/services/manager.d.ts
|
|
8478
8538
|
/**
|
|
8479
8539
|
* Minimum age (in milliseconds) a `.lock` file must reach before `sync` will
|
|
@@ -8498,15 +8558,15 @@ declare const STALE_LOCK_MAX_AGE_MS: number;
|
|
|
8498
8558
|
*/
|
|
8499
8559
|
interface ReposManagerShape {
|
|
8500
8560
|
readonly status: (root: string) => Effect.Effect<ReposStatusReport, ReposConfigError | GitSubmoduleError>;
|
|
8501
|
-
readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError>;
|
|
8561
|
+
readonly sync: (root: string) => Effect.Effect<ReposSyncReport, ReposConfigError | GitSubmoduleError | ReposLockdownError>;
|
|
8502
8562
|
readonly add: (root: string, options: {
|
|
8503
8563
|
readonly url: string;
|
|
8504
8564
|
readonly ref: string;
|
|
8505
8565
|
readonly purpose: string;
|
|
8506
8566
|
readonly name?: string;
|
|
8507
8567
|
readonly sparse?: ReadonlyArray<string>;
|
|
8508
|
-
}) => Effect.Effect<ReposAddResult, ReposConfigError | GitSubmoduleError>;
|
|
8509
|
-
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>;
|
|
8510
8570
|
readonly note: (root: string, name: string, op: {
|
|
8511
8571
|
readonly op: "add";
|
|
8512
8572
|
readonly note: string;
|
|
@@ -8539,10 +8599,10 @@ declare class ReposManager extends ReposManager_base {
|
|
|
8539
8599
|
* this module's {@link GitSubmoduleError} to keep the declared error unions.
|
|
8540
8600
|
* @public
|
|
8541
8601
|
*/
|
|
8542
|
-
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>;
|
|
8543
8603
|
}
|
|
8544
8604
|
declare namespace index_d_exports$3 {
|
|
8545
|
-
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 };
|
|
8546
8606
|
}
|
|
8547
8607
|
//#endregion
|
|
8548
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
|
+
"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.
|
|
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
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.
|
|
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 =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
yield*
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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*
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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]: {
|