@danieljvdm/dev-kit 0.2.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.
Files changed (47) hide show
  1. package/README.md +290 -0
  2. package/bin/dev-kit.mjs +3 -0
  3. package/dev-kit.example.jsonc +13 -0
  4. package/package.json +69 -0
  5. package/schema/dev-kit.schema.json +128 -0
  6. package/schema/skill-sources.schema.json +83 -0
  7. package/skill-sources.jsonc +55 -0
  8. package/skill-sources.lock.json +136 -0
  9. package/skills/dev-kit/SKILL.md +145 -0
  10. package/skills/dev-kit/agents/openai.yaml +4 -0
  11. package/skills/effect-ts/SKILL.md +242 -0
  12. package/skills/effect-ts/UPSTREAM.md +28 -0
  13. package/skills/effect-ts/agents/openai.yaml +5 -0
  14. package/skills/effect-ts/references/audit-services.md +144 -0
  15. package/skills/effect-ts/references/features.md +525 -0
  16. package/skills/effect-ts/references/guide-cli.md +106 -0
  17. package/skills/effect-ts/references/guide-effect.md +453 -0
  18. package/skills/effect-ts/references/guide-error-handling.md +574 -0
  19. package/skills/effect-ts/references/guide-http-boundaries.md +55 -0
  20. package/skills/effect-ts/references/guide-layers.md +1017 -0
  21. package/skills/effect-ts/references/guide-observability.md +771 -0
  22. package/skills/effect-ts/references/guide-retries.md +446 -0
  23. package/skills/effect-ts/references/guide-schedule.md +357 -0
  24. package/skills/effect-ts/references/guide-schema.md +671 -0
  25. package/skills/effect-ts/references/guide-sql.md +539 -0
  26. package/skills/effect-ts/references/guide-testing.md +534 -0
  27. package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +131 -0
  28. package/skills/effect-ts/references/version-and-source.md +87 -0
  29. package/src/bin/dev-kit.ts +372 -0
  30. package/src/catalog-manager.ts +345 -0
  31. package/src/catalog.ts +246 -0
  32. package/src/cli-ui.ts +110 -0
  33. package/src/effect-source.ts +325 -0
  34. package/src/effect-tsgo.ts +256 -0
  35. package/src/gitignore.ts +212 -0
  36. package/src/index.ts +98 -0
  37. package/src/manifest.ts +133 -0
  38. package/src/node-symbolic-link.ts +31 -0
  39. package/src/path-digest.ts +140 -0
  40. package/src/project-process-lock.ts +76 -0
  41. package/src/project-state.ts +67 -0
  42. package/src/skill-manager.ts +326 -0
  43. package/src/source-manifest.ts +51 -0
  44. package/src/sync.ts +900 -0
  45. package/src/tool-metadata.ts +3 -0
  46. package/src/typescript-package-name.ts +5 -0
  47. package/src/vendor.ts +848 -0
@@ -0,0 +1,212 @@
1
+ import { Cause, Effect, FileSystem, Path, PlatformError, Schema } from "effect";
2
+
3
+ import { printStatus } from "./cli-ui.ts";
4
+ import { observeSymbolicLink } from "./node-symbolic-link.ts";
5
+ import { acquireProjectProcessLock } from "./project-process-lock.ts";
6
+
7
+ export const CANONICAL_REPOSITORIES_DIRECTORY = ".repos";
8
+
9
+ export const DEV_KIT_GITIGNORE_ENTRIES = [
10
+ `${CANONICAL_REPOSITORIES_DIRECTORY}/`,
11
+ ".dev-kit/",
12
+ ] as const;
13
+
14
+ export type GitignoreOptions = {
15
+ readonly dryRun?: boolean;
16
+ readonly projectDir?: string;
17
+ };
18
+
19
+ export type GitignorePatch = {
20
+ readonly path: string;
21
+ readonly changed: boolean;
22
+ readonly added: ReadonlyArray<string>;
23
+ };
24
+
25
+ export class UnsafeGitignorePathError extends Schema.TaggedErrorClass<UnsafeGitignorePathError>()(
26
+ "UnsafeGitignorePathError",
27
+ {
28
+ path: Schema.String,
29
+ reason: Schema.String,
30
+ },
31
+ ) {
32
+ override get message() {
33
+ return `refusing to patch ${this.path}: ${this.reason}`;
34
+ }
35
+ }
36
+
37
+ export class GitignoreConflictError extends Schema.TaggedErrorClass<GitignoreConflictError>()(
38
+ "GitignoreConflictError",
39
+ { path: Schema.String },
40
+ ) {
41
+ override get message() {
42
+ return `${this.path} changed while dev-kit was preparing its patch; rerun the command`;
43
+ }
44
+ }
45
+
46
+ type PlannedGitignorePatch = GitignorePatch & {
47
+ readonly contents: string;
48
+ readonly existed: boolean;
49
+ readonly mode: number | undefined;
50
+ readonly previousContents: string;
51
+ };
52
+
53
+ const publicPatch = (patch: PlannedGitignorePatch): GitignorePatch => ({
54
+ path: patch.path,
55
+ changed: patch.changed,
56
+ added: patch.added,
57
+ });
58
+
59
+ export const patchGitignoreContents = (
60
+ current: string,
61
+ ): { readonly contents: string; readonly added: ReadonlyArray<string> } => {
62
+ const lines = current.split(/\r?\n/);
63
+ const added = DEV_KIT_GITIGNORE_ENTRIES.filter((entry) => !lines.includes(entry));
64
+ if (added.length === 0) return { contents: current, added };
65
+
66
+ const newline = current.includes("\r\n") ? "\r\n" : "\n";
67
+ const separator = current.length === 0
68
+ ? ""
69
+ : current.endsWith(`${newline}${newline}`)
70
+ ? ""
71
+ : current.endsWith(newline)
72
+ ? newline
73
+ : `${newline}${newline}`;
74
+ const block = ["# dev-kit managed paths", ...added].join(newline);
75
+ return {
76
+ contents: `${current}${separator}${block}${newline}`,
77
+ added,
78
+ };
79
+ };
80
+
81
+ const planGitignorePatch = Effect.fn("planGitignorePatch")(function* (
82
+ projectDir: string,
83
+ ): Effect.fn.Return<
84
+ PlannedGitignorePatch,
85
+ UnsafeGitignorePathError | PlatformError.PlatformError,
86
+ FileSystem.FileSystem | Path.Path
87
+ > {
88
+ const fs = yield* FileSystem.FileSystem;
89
+ const path = yield* Path.Path;
90
+ const gitignorePath = path.join(projectDir, ".gitignore");
91
+ const observation = yield* observeSymbolicLink(gitignorePath);
92
+ if (observation.kind === "symlink") {
93
+ return yield* new UnsafeGitignorePathError({
94
+ path: gitignorePath,
95
+ reason: "the file is a symlink",
96
+ });
97
+ }
98
+
99
+ let current = "";
100
+ let mode: number | undefined;
101
+ if (observation.kind !== "missing") {
102
+ const info = yield* fs.stat(gitignorePath);
103
+ if (info.type !== "File") {
104
+ return yield* new UnsafeGitignorePathError({
105
+ path: gitignorePath,
106
+ reason: "the path is not a regular file",
107
+ });
108
+ }
109
+ current = yield* fs.readFileString(gitignorePath);
110
+ mode = info.mode & 0o777;
111
+ }
112
+
113
+ const patch = patchGitignoreContents(current);
114
+ return {
115
+ path: gitignorePath,
116
+ changed: patch.added.length > 0,
117
+ added: patch.added,
118
+ contents: patch.contents,
119
+ existed: observation.kind !== "missing",
120
+ mode,
121
+ previousContents: current,
122
+ };
123
+ });
124
+
125
+ const applyGitignorePatch = Effect.fn("applyGitignorePatch")(function* (
126
+ projectDir: string,
127
+ patch: PlannedGitignorePatch,
128
+ ) {
129
+ if (!patch.changed) return;
130
+ const fs = yield* FileSystem.FileSystem;
131
+ const path = yield* Path.Path;
132
+ const tempDir = yield* fs.makeTempDirectoryScoped({
133
+ directory: path.join(projectDir, ".dev-kit"),
134
+ prefix: "gitignore-",
135
+ });
136
+ const staged = path.join(tempDir, "next.gitignore");
137
+ const backup = path.join(tempDir, "previous.gitignore");
138
+ yield* fs.writeFileString(staged, patch.contents, { mode: patch.mode ?? 0o666 });
139
+
140
+ const currentObservation = yield* observeSymbolicLink(patch.path);
141
+ const changed = patch.existed
142
+ ? currentObservation.kind !== "not-symlink" ||
143
+ (yield* fs.readFileString(patch.path)) !== patch.previousContents
144
+ : currentObservation.kind !== "missing";
145
+ if (changed) {
146
+ return yield* new GitignoreConflictError({ path: patch.path });
147
+ }
148
+
149
+ let backedUp = false;
150
+ let installed = false;
151
+ const rollback = Effect.gen(function* () {
152
+ if (installed) {
153
+ yield* fs.remove(patch.path, { force: true });
154
+ }
155
+ if (backedUp) {
156
+ yield* fs.rename(backup, patch.path);
157
+ }
158
+ });
159
+ const apply = Effect.gen(function* () {
160
+ if (patch.existed) {
161
+ yield* fs.rename(patch.path, backup);
162
+ backedUp = true;
163
+ }
164
+ yield* fs.rename(staged, patch.path);
165
+ installed = true;
166
+ });
167
+
168
+ yield* Effect.uninterruptible(
169
+ apply.pipe(
170
+ Effect.catchCause((applyCause) =>
171
+ rollback.pipe(
172
+ Effect.catchCause((rollbackCause) =>
173
+ Effect.failCause(Cause.combine(applyCause, rollbackCause)),
174
+ ),
175
+ Effect.andThen(Effect.failCause(applyCause)),
176
+ ),
177
+ ),
178
+ ),
179
+ );
180
+ });
181
+
182
+ export const patchProjectGitignore = Effect.fn("patchProjectGitignore")(function* (
183
+ options: GitignoreOptions = {},
184
+ ) {
185
+ const fs = yield* FileSystem.FileSystem;
186
+ const path = yield* Path.Path;
187
+ const projectDir = yield* fs.realPath(path.resolve(options.projectDir ?? "."));
188
+
189
+ if (options.dryRun) {
190
+ const patch = yield* planGitignorePatch(projectDir);
191
+ yield* printStatus(
192
+ patch.changed ? "plan" : "success",
193
+ patch.changed ? "Would update .gitignore" : ".gitignore up to date",
194
+ patch.changed ? `add ${patch.added.join(", ")}` : undefined,
195
+ );
196
+ return publicPatch(patch);
197
+ }
198
+
199
+ return yield* Effect.scoped(
200
+ Effect.gen(function* () {
201
+ yield* acquireProjectProcessLock(projectDir);
202
+ const patch = yield* planGitignorePatch(projectDir);
203
+ yield* applyGitignorePatch(projectDir, patch);
204
+ yield* printStatus(
205
+ "success",
206
+ patch.changed ? "Updated .gitignore" : ".gitignore up to date",
207
+ patch.changed ? `added ${patch.added.join(", ")}` : undefined,
208
+ );
209
+ return publicPatch(patch);
210
+ }),
211
+ );
212
+ });
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ export {
2
+ type DevKitManifest,
3
+ DevKitManifestSchema,
4
+ type EffectSourceSetup,
5
+ EffectSourceSetupSchema,
6
+ type EffectTsgoSetup,
7
+ EffectTsgoSetupSchema,
8
+ type HarnessTarget,
9
+ TargetConfigSchema,
10
+ } from "./manifest.ts";
11
+ export {
12
+ applyEffectSourcePlan,
13
+ EffectSourceCheckoutError,
14
+ EffectSourceDependencyError,
15
+ type EffectSourceOptions,
16
+ type EffectSourcePlan,
17
+ planEffectSource,
18
+ syncEffectSource,
19
+ } from "./effect-source.ts";
20
+ export {
21
+ CANONICAL_REPOSITORIES_DIRECTORY,
22
+ DEV_KIT_GITIGNORE_ENTRIES,
23
+ GitignoreConflictError,
24
+ patchGitignoreContents,
25
+ patchProjectGitignore,
26
+ type GitignoreOptions,
27
+ type GitignorePatch,
28
+ UnsafeGitignorePathError,
29
+ } from "./gitignore.ts";
30
+ export {
31
+ EFFECT_TSGO_PLUGIN_NAME,
32
+ EFFECT_TSGO_TYPESCRIPT_VERSION,
33
+ EFFECT_TSGO_VERSION,
34
+ EffectTsgoDependencyError,
35
+ InvalidEffectTsgoPackageNameError,
36
+ type EffectTsgoPatchOptions,
37
+ type EffectTsgoPatchPlan,
38
+ EffectTsgoPatchCommandError,
39
+ patchEffectTsgo,
40
+ planEffectTsgoPatch,
41
+ } from "./effect-tsgo.ts";
42
+ export {
43
+ ExternalSkillSourceSchema,
44
+ type ExternalSkillSource,
45
+ LockedSkillSourceSchema,
46
+ type LockedSkillSource,
47
+ SkillSourcesLockSchema,
48
+ type SkillSourcesLock,
49
+ SkillSourcesManifestSchema,
50
+ type SkillSourcesManifest,
51
+ } from "./source-manifest.ts";
52
+ export {
53
+ planProjectSkills,
54
+ printSkillPlan,
55
+ runProjectSkillPlan,
56
+ type SkillPlan,
57
+ type SyncOptions,
58
+ } from "./sync.ts";
59
+ export {
60
+ AppliedStateSchema,
61
+ DevKitLockSchema,
62
+ EffectSourceLockSchema,
63
+ EffectTsgoLockSchema,
64
+ ManagedSkillOutputSchema,
65
+ OwnershipReceiptSchema,
66
+ type AppliedState,
67
+ type DevKitLock,
68
+ type EffectSourceLock,
69
+ type EffectTsgoLock,
70
+ type ManagedSkillOutput,
71
+ type OwnershipReceipt,
72
+ } from "./project-state.ts";
73
+ export {
74
+ refreshSkillCatalog,
75
+ vendorExternalSkills,
76
+ type CatalogRefreshOptions,
77
+ type VendorOptions,
78
+ } from "./vendor.ts";
79
+ export {
80
+ loadSkillCatalog,
81
+ resolveSkillSources,
82
+ type CatalogSkill,
83
+ type ResolvedSkillSource,
84
+ type SkillCatalog,
85
+ } from "./catalog.ts";
86
+ export {
87
+ addCatalogSource,
88
+ listCatalogSources,
89
+ removeCatalogEntry,
90
+ showCatalogSource,
91
+ type CatalogAddOptions,
92
+ type CatalogCommandOptions,
93
+ } from "./catalog-manager.ts";
94
+ export {
95
+ inspectCatalogRepository,
96
+ type CatalogInspection,
97
+ type CatalogInspectOptions,
98
+ } from "./vendor.ts";
@@ -0,0 +1,133 @@
1
+ import { Schema } from "effect";
2
+
3
+ import { TYPESCRIPT_PACKAGE_NAME_PATTERN } from "./typescript-package-name.ts";
4
+
5
+ export type HarnessTarget = "agents" | "claude" | "opencode";
6
+
7
+ export const SyncMode = Schema.Literals(["copy", "symlink"]);
8
+ export type SyncMode = "copy" | "symlink";
9
+
10
+ export const TargetConfigSchema = Schema.Struct({
11
+ enabled: Schema.optional(Schema.Boolean),
12
+ mode: Schema.optional(SyncMode),
13
+ path: Schema.optional(Schema.String),
14
+ });
15
+
16
+ export type TargetConfig = typeof TargetConfigSchema.Type;
17
+
18
+ export const EffectTsgoSetupSchema = Schema.Struct({
19
+ enabled: Schema.optional(Schema.Boolean),
20
+ force: Schema.optional(Schema.Boolean),
21
+ typescriptPackage: Schema.optional(
22
+ Schema.String.check(Schema.isPattern(TYPESCRIPT_PACKAGE_NAME_PATTERN)),
23
+ ),
24
+ });
25
+
26
+ export type EffectTsgoSetup = typeof EffectTsgoSetupSchema.Type;
27
+
28
+ export const EffectSourceSetupSchema = Schema.Struct({
29
+ enabled: Schema.optional(Schema.Boolean),
30
+ packageName: Schema.optional(
31
+ Schema.String.check(Schema.isPattern(TYPESCRIPT_PACKAGE_NAME_PATTERN)),
32
+ ),
33
+ path: Schema.optional(Schema.String),
34
+ repository: Schema.optional(Schema.String),
35
+ });
36
+
37
+ export type EffectSourceSetup = typeof EffectSourceSetupSchema.Type;
38
+
39
+ export const DevKitManifestSchema = Schema.Struct({
40
+ $schema: Schema.optional(Schema.String),
41
+ include: Schema.Array(Schema.String),
42
+ exclude: Schema.optional(Schema.Array(Schema.String)),
43
+ setup: Schema.optional(
44
+ Schema.Struct({
45
+ effectSource: Schema.optional(EffectSourceSetupSchema),
46
+ effectTsgo: Schema.optional(EffectTsgoSetupSchema),
47
+ }),
48
+ ),
49
+ targets: Schema.optional(
50
+ Schema.Struct({
51
+ agents: Schema.optional(TargetConfigSchema),
52
+ claude: Schema.optional(TargetConfigSchema),
53
+ opencode: Schema.optional(TargetConfigSchema),
54
+ }),
55
+ ),
56
+ });
57
+
58
+ export type DevKitManifest = typeof DevKitManifestSchema.Type;
59
+
60
+ export type NormalizedTargetConfig = {
61
+ readonly enabled: boolean;
62
+ readonly mode: SyncMode;
63
+ readonly path: string;
64
+ };
65
+
66
+ export type NormalizedManifest = {
67
+ readonly include: ReadonlyArray<string>;
68
+ readonly exclude: ReadonlyArray<string>;
69
+ readonly setup: {
70
+ readonly effectSource: {
71
+ readonly enabled: boolean;
72
+ readonly packageName: string;
73
+ readonly path: string;
74
+ readonly repository: string;
75
+ };
76
+ readonly effectTsgo: {
77
+ readonly enabled: boolean;
78
+ readonly force: boolean;
79
+ readonly typescriptPackage: string;
80
+ };
81
+ };
82
+ readonly targets: Readonly<Record<HarnessTarget, NormalizedTargetConfig>>;
83
+ };
84
+
85
+ const DEFAULT_TARGET_PATHS: Readonly<Record<HarnessTarget, string>> = {
86
+ agents: ".agents/skills",
87
+ claude: ".claude/skills",
88
+ opencode: ".opencode/skills",
89
+ };
90
+
91
+ const DEFAULT_TARGETS: Readonly<Record<HarnessTarget, NormalizedTargetConfig>> = {
92
+ agents: { enabled: true, mode: "copy", path: DEFAULT_TARGET_PATHS.agents },
93
+ claude: { enabled: false, mode: "symlink", path: DEFAULT_TARGET_PATHS.claude },
94
+ opencode: { enabled: false, mode: "symlink", path: DEFAULT_TARGET_PATHS.opencode },
95
+ };
96
+
97
+ export const normalizeManifest = (manifest: DevKitManifest): NormalizedManifest => {
98
+ const targets = {
99
+ ...DEFAULT_TARGETS,
100
+ };
101
+
102
+ for (const key of ["agents", "claude", "opencode"] as const) {
103
+ const override = manifest.targets?.[key];
104
+ if (override) {
105
+ targets[key] = {
106
+ enabled: override.enabled ?? DEFAULT_TARGETS[key].enabled,
107
+ mode: override.mode ?? DEFAULT_TARGETS[key].mode,
108
+ path: override.path ?? DEFAULT_TARGETS[key].path,
109
+ };
110
+ }
111
+ }
112
+
113
+ return {
114
+ exclude: manifest.exclude ?? [],
115
+ include: manifest.include,
116
+ setup: {
117
+ effectSource: {
118
+ enabled: manifest.setup?.effectSource?.enabled ?? false,
119
+ packageName: manifest.setup?.effectSource?.packageName ?? "effect",
120
+ path: manifest.setup?.effectSource?.path ?? ".repos/effect",
121
+ repository:
122
+ manifest.setup?.effectSource?.repository ??
123
+ "https://github.com/Effect-TS/effect.git",
124
+ },
125
+ effectTsgo: {
126
+ enabled: manifest.setup?.effectTsgo?.enabled ?? false,
127
+ force: manifest.setup?.effectTsgo?.force ?? false,
128
+ typescriptPackage: manifest.setup?.effectTsgo?.typescriptPackage ?? "typescript",
129
+ },
130
+ },
131
+ targets,
132
+ };
133
+ };
@@ -0,0 +1,31 @@
1
+ import { Effect, FileSystem, PlatformError } from "effect";
2
+
3
+ export type SymbolicLinkObservation =
4
+ | { readonly kind: "missing" | "not-symlink" }
5
+ | { readonly kind: "symlink"; readonly target: string };
6
+
7
+ // Effect beta.102's Node FileSystem adapter maps readlink(2) EINVAL to Unknown.
8
+ // Keep that runtime-specific normalization at this narrow adapter boundary.
9
+ const isNotSymbolicLink = (error: PlatformError.PlatformError): boolean => {
10
+ if (error.reason._tag !== "Unknown") return false;
11
+ const cause = error.reason.cause;
12
+ return cause instanceof Error && "code" in cause && cause.code === "EINVAL";
13
+ };
14
+
15
+ export const observeSymbolicLink = Effect.fn("observeSymbolicLink")(function* (
16
+ absolutePath: string,
17
+ ) {
18
+ const fs = yield* FileSystem.FileSystem;
19
+ return yield* fs.readLink(absolutePath).pipe(
20
+ Effect.map((target): SymbolicLinkObservation => ({ kind: "symlink", target })),
21
+ Effect.catch((error) => {
22
+ if (error.reason._tag === "NotFound") {
23
+ return Effect.succeed<SymbolicLinkObservation>({ kind: "missing" });
24
+ }
25
+ if (isNotSymbolicLink(error)) {
26
+ return Effect.succeed<SymbolicLinkObservation>({ kind: "not-symlink" });
27
+ }
28
+ return Effect.fail(error);
29
+ }),
30
+ );
31
+ });
@@ -0,0 +1,140 @@
1
+ import { Crypto, Effect, Encoding, FileSystem, Path, PlatformError, Schema } from "effect";
2
+
3
+ import { observeSymbolicLink } from "./node-symbolic-link.ts";
4
+
5
+ export const DigestSchema = Schema.String.check(
6
+ Schema.isPattern(/^sha256:[0-9a-f]{64}$/),
7
+ );
8
+ export type Digest = typeof DigestSchema.Type;
9
+
10
+ export type ObservedPath =
11
+ | { readonly kind: "missing" }
12
+ | { readonly kind: "file" | "directory" | "symlink"; readonly digest: Digest };
13
+
14
+ export class PathInspectionError extends Schema.TaggedErrorClass<PathInspectionError>()(
15
+ "PathInspectionError",
16
+ {
17
+ path: Schema.String,
18
+ operation: Schema.String,
19
+ cause: Schema.Unknown,
20
+ },
21
+ ) {
22
+ override get message() {
23
+ return `could not ${this.operation} managed path ${this.path}`;
24
+ }
25
+ }
26
+
27
+ const textEncoder = new TextEncoder();
28
+
29
+ const frame = (value: string | Uint8Array): Uint8Array => {
30
+ const bytes = typeof value === "string" ? textEncoder.encode(value) : value;
31
+ const framed = new Uint8Array(4 + bytes.length);
32
+ new DataView(framed.buffer).setUint32(0, bytes.length);
33
+ framed.set(bytes, 4);
34
+ return framed;
35
+ };
36
+
37
+ const concatenate = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
38
+ const combined = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0));
39
+ let offset = 0;
40
+ for (const chunk of chunks) {
41
+ combined.set(chunk, offset);
42
+ offset += chunk.length;
43
+ }
44
+ return combined;
45
+ };
46
+
47
+ const compareUtf8 = (left: string, right: string): number => {
48
+ const leftBytes = textEncoder.encode(left);
49
+ const rightBytes = textEncoder.encode(right);
50
+ const sharedLength = Math.min(leftBytes.length, rightBytes.length);
51
+ for (let index = 0; index < sharedLength; index += 1) {
52
+ const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
53
+ if (difference !== 0) return difference;
54
+ }
55
+ return leftBytes.length - rightBytes.length;
56
+ };
57
+
58
+ const digestFrames = Effect.fn("digestPathFrames")(function* (
59
+ values: ReadonlyArray<string | Uint8Array>,
60
+ ) {
61
+ const crypto = yield* Crypto.Crypto;
62
+ const digest = yield* crypto.digest("SHA-256", concatenate(values.map(frame)));
63
+ return `sha256:${Encoding.encodeHex(digest)}`;
64
+ });
65
+
66
+ const digestFileSystemPath = Effect.fn("digestFileSystemPath")(function* (
67
+ absolutePath: string,
68
+ ): Effect.fn.Return<ObservedPath, PlatformError.PlatformError | PathInspectionError, FileSystem.FileSystem | Path.Path | Crypto.Crypto> {
69
+ const fs = yield* FileSystem.FileSystem;
70
+ const path = yield* Path.Path;
71
+ const symbolicLink = yield* observeSymbolicLink(absolutePath);
72
+
73
+ if (symbolicLink.kind === "missing") return { kind: "missing" };
74
+ if (symbolicLink.kind === "symlink") {
75
+ return {
76
+ kind: "symlink",
77
+ digest: yield* digestFrames(["symlink-v1", symbolicLink.target]),
78
+ };
79
+ }
80
+
81
+ const info = yield* fs.stat(absolutePath).pipe(
82
+ Effect.catch((error) =>
83
+ error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
84
+ ),
85
+ );
86
+ if (info === undefined) return { kind: "missing" };
87
+
88
+ if (info.type === "File") {
89
+ return {
90
+ kind: "file",
91
+ digest: yield* digestFrames([
92
+ "file-v1",
93
+ String(info.mode & 0o777),
94
+ yield* fs.readFile(absolutePath),
95
+ ]),
96
+ };
97
+ }
98
+
99
+ if (info.type === "Directory") {
100
+ const entries = (yield* fs.readDirectory(absolutePath)).sort(compareUtf8);
101
+ const frames: Array<string | Uint8Array> = ["directory-v1"];
102
+ for (const entry of entries) {
103
+ const childPath = path.join(absolutePath, entry);
104
+ const child = yield* digestFileSystemPath(childPath);
105
+ if (child.kind === "missing") {
106
+ return yield* new PathInspectionError({
107
+ path: childPath,
108
+ operation: "inspect a stable directory tree",
109
+ cause: "path disappeared during inspection",
110
+ });
111
+ }
112
+ frames.push(entry, child.kind, child.digest);
113
+ }
114
+ return { kind: "directory", digest: yield* digestFrames(frames) };
115
+ }
116
+
117
+ return yield* new PathInspectionError({
118
+ path: absolutePath,
119
+ operation: "inspect unsupported filesystem entry",
120
+ cause: info.type,
121
+ });
122
+ });
123
+
124
+ export const observePath = Effect.fn("observeManagedPath")(function* (absolutePath: string) {
125
+ return yield* digestFileSystemPath(absolutePath).pipe(
126
+ Effect.mapError((cause) =>
127
+ cause instanceof PathInspectionError
128
+ ? cause
129
+ : new PathInspectionError({ path: absolutePath, operation: "inspect", cause }),
130
+ ),
131
+ );
132
+ });
133
+
134
+ export const digestText = Effect.fn("digestText")(function* (value: string) {
135
+ return yield* digestFrames(["text-v1", value]);
136
+ });
137
+
138
+ export const digestSymlinkTarget = Effect.fn("digestSymlinkTarget")(function* (target: string) {
139
+ return yield* digestFrames(["symlink-v1", target]);
140
+ });
@@ -0,0 +1,76 @@
1
+ import { Cause, Crypto, DateTime, Effect, FileSystem, Path, Schema } from "effect";
2
+
3
+ import { DEV_KIT_VERSION } from "./tool-metadata.ts";
4
+
5
+ export const PROJECT_PROCESS_LOCK_PATH = ".dev-kit/apply.lock";
6
+
7
+ export class ProjectAlreadyLockedError extends Schema.TaggedErrorClass<ProjectAlreadyLockedError>()(
8
+ "ProjectAlreadyLockedError",
9
+ { path: Schema.String },
10
+ ) {
11
+ override get message() {
12
+ return `another dev-kit apply may be active (${this.path}); verify the owner before removing a stale lock`;
13
+ }
14
+ }
15
+
16
+ export const acquireProjectProcessLock = Effect.fn("acquireProjectProcessLock")(function* (
17
+ projectDir: string,
18
+ ) {
19
+ const fs = yield* FileSystem.FileSystem;
20
+ const path = yield* Path.Path;
21
+ const crypto = yield* Crypto.Crypto;
22
+ const lockDir = path.join(projectDir, ...PROJECT_PROCESS_LOCK_PATH.split("/"));
23
+ const stateDir = path.dirname(lockDir);
24
+ const ownerPath = path.join(lockDir, "owner.json");
25
+ const token = yield* crypto.randomUUIDv7;
26
+ const startedAt = DateTime.formatIso(yield* DateTime.now);
27
+ const ownerContents = `${JSON.stringify(
28
+ {
29
+ version: 1,
30
+ toolVersion: DEV_KIT_VERSION,
31
+ token,
32
+ startedAt,
33
+ },
34
+ null,
35
+ 2,
36
+ )}\n`;
37
+
38
+ return yield* Effect.acquireRelease(
39
+ Effect.gen(function* () {
40
+ yield* fs.makeDirectory(stateDir, { recursive: true });
41
+ return yield* Effect.uninterruptible(
42
+ Effect.gen(function* () {
43
+ yield* fs.makeDirectory(lockDir).pipe(
44
+ Effect.mapError((error) =>
45
+ error.reason._tag === "AlreadyExists"
46
+ ? new ProjectAlreadyLockedError({ path: lockDir })
47
+ : error,
48
+ ),
49
+ );
50
+ yield* fs.writeFileString(ownerPath, ownerContents).pipe(
51
+ Effect.catchCause((writeCause) =>
52
+ fs.remove(lockDir, { recursive: true, force: true }).pipe(
53
+ Effect.catchCause((cleanupCause) =>
54
+ Effect.failCause(Cause.combine(writeCause, cleanupCause)),
55
+ ),
56
+ Effect.andThen(Effect.failCause(writeCause)),
57
+ ),
58
+ ),
59
+ );
60
+ return { lockDir, ownerContents };
61
+ }),
62
+ );
63
+ }),
64
+ ({ lockDir: acquiredLockDir, ownerContents }) =>
65
+ Effect.gen(function* () {
66
+ const currentOwner = yield* fs.readFileString(ownerPath).pipe(
67
+ Effect.catch((error) =>
68
+ error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
69
+ ),
70
+ );
71
+ if (currentOwner === ownerContents) {
72
+ yield* fs.remove(acquiredLockDir, { recursive: true, force: true });
73
+ }
74
+ }).pipe(Effect.orDie),
75
+ ).pipe(Effect.map(({ lockDir }) => lockDir));
76
+ });