@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.
- package/README.md +290 -0
- package/bin/dev-kit.mjs +3 -0
- package/dev-kit.example.jsonc +13 -0
- package/package.json +69 -0
- package/schema/dev-kit.schema.json +128 -0
- package/schema/skill-sources.schema.json +83 -0
- package/skill-sources.jsonc +55 -0
- package/skill-sources.lock.json +136 -0
- package/skills/dev-kit/SKILL.md +145 -0
- package/skills/dev-kit/agents/openai.yaml +4 -0
- package/skills/effect-ts/SKILL.md +242 -0
- package/skills/effect-ts/UPSTREAM.md +28 -0
- package/skills/effect-ts/agents/openai.yaml +5 -0
- package/skills/effect-ts/references/audit-services.md +144 -0
- package/skills/effect-ts/references/features.md +525 -0
- package/skills/effect-ts/references/guide-cli.md +106 -0
- package/skills/effect-ts/references/guide-effect.md +453 -0
- package/skills/effect-ts/references/guide-error-handling.md +574 -0
- package/skills/effect-ts/references/guide-http-boundaries.md +55 -0
- package/skills/effect-ts/references/guide-layers.md +1017 -0
- package/skills/effect-ts/references/guide-observability.md +771 -0
- package/skills/effect-ts/references/guide-retries.md +446 -0
- package/skills/effect-ts/references/guide-schedule.md +357 -0
- package/skills/effect-ts/references/guide-schema.md +671 -0
- package/skills/effect-ts/references/guide-sql.md +539 -0
- package/skills/effect-ts/references/guide-testing.md +534 -0
- package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +131 -0
- package/skills/effect-ts/references/version-and-source.md +87 -0
- package/src/bin/dev-kit.ts +372 -0
- package/src/catalog-manager.ts +345 -0
- package/src/catalog.ts +246 -0
- package/src/cli-ui.ts +110 -0
- package/src/effect-source.ts +325 -0
- package/src/effect-tsgo.ts +256 -0
- package/src/gitignore.ts +212 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +133 -0
- package/src/node-symbolic-link.ts +31 -0
- package/src/path-digest.ts +140 -0
- package/src/project-process-lock.ts +76 -0
- package/src/project-state.ts +67 -0
- package/src/skill-manager.ts +326 -0
- package/src/source-manifest.ts +51 -0
- package/src/sync.ts +900 -0
- package/src/tool-metadata.ts +3 -0
- package/src/typescript-package-name.ts +5 -0
- package/src/vendor.ts +848 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
2
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
3
|
+
|
|
4
|
+
import { acquireProjectProcessLock } from "./project-process-lock.ts";
|
|
5
|
+
import { printStatus, withSpinner } from "./cli-ui.ts";
|
|
6
|
+
import { observeSymbolicLink } from "./node-symbolic-link.ts";
|
|
7
|
+
import { isTypeScriptPackageName } from "./typescript-package-name.ts";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_EFFECT_REPOSITORY = "https://github.com/Effect-TS/effect.git";
|
|
10
|
+
export const DEFAULT_EFFECT_SOURCE_PATH = ".repos/effect";
|
|
11
|
+
|
|
12
|
+
export type EffectSourceOptions = {
|
|
13
|
+
readonly dryRun?: boolean;
|
|
14
|
+
readonly packageName?: string;
|
|
15
|
+
readonly path?: string;
|
|
16
|
+
readonly projectDir?: string;
|
|
17
|
+
readonly repository?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type EffectSourcePlan = {
|
|
21
|
+
readonly action: "sync" | "unchanged" | "skipped";
|
|
22
|
+
readonly checkoutDir: string;
|
|
23
|
+
readonly packageName: string;
|
|
24
|
+
readonly packageVersion: string;
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly projectDir: string;
|
|
27
|
+
readonly repository: string;
|
|
28
|
+
readonly tag: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class EffectSourceDependencyError extends Schema.TaggedErrorClass<EffectSourceDependencyError>()(
|
|
32
|
+
"EffectSourceDependencyError",
|
|
33
|
+
{ packageName: Schema.String },
|
|
34
|
+
) {
|
|
35
|
+
override get message() {
|
|
36
|
+
return `${this.packageName} must be installed before syncing its Effect source checkout`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class EffectSourceCheckoutError extends Schema.TaggedErrorClass<EffectSourceCheckoutError>()(
|
|
41
|
+
"EffectSourceCheckoutError",
|
|
42
|
+
{ message: Schema.String },
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
class EffectSourceCommandError extends Schema.TaggedErrorClass<EffectSourceCommandError>()(
|
|
46
|
+
"EffectSourceCommandError",
|
|
47
|
+
{ command: Schema.String, exitCode: Schema.Int, output: Schema.String },
|
|
48
|
+
) {
|
|
49
|
+
override get message() {
|
|
50
|
+
return this.output.length > 0
|
|
51
|
+
? `${this.command} exited with code ${this.exitCode}: ${this.output}`
|
|
52
|
+
: `${this.command} exited with code ${this.exitCode}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PackageVersionSchema = Schema.fromJsonString(
|
|
57
|
+
Schema.Struct({
|
|
58
|
+
version: Schema.String.check(
|
|
59
|
+
Schema.isPattern(/^[0-9A-Za-z][0-9A-Za-z.+-]*$/),
|
|
60
|
+
),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const runCommand = Effect.fn("runEffectSourceCommand")(function* (
|
|
65
|
+
cwd: string,
|
|
66
|
+
command: string,
|
|
67
|
+
args: ReadonlyArray<string>,
|
|
68
|
+
) {
|
|
69
|
+
const child = yield* ChildProcess.make(command, args, {
|
|
70
|
+
cwd,
|
|
71
|
+
stderr: "pipe",
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
});
|
|
74
|
+
const [output, exitCode] = yield* Effect.all([
|
|
75
|
+
Stream.mkString(Stream.decodeText(child.all)),
|
|
76
|
+
child.exitCode,
|
|
77
|
+
]);
|
|
78
|
+
const trimmed = output.trim();
|
|
79
|
+
if (exitCode !== 0) {
|
|
80
|
+
return yield* new EffectSourceCommandError({
|
|
81
|
+
command: [command, ...args].join(" "),
|
|
82
|
+
exitCode,
|
|
83
|
+
output: trimmed,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return trimmed;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const runGit = (cwd: string, args: ReadonlyArray<string>) =>
|
|
90
|
+
runCommand(cwd, "git", args);
|
|
91
|
+
|
|
92
|
+
const readPackageVersion = Effect.fn("readEffectSourcePackageVersion")(function* (
|
|
93
|
+
projectDir: string,
|
|
94
|
+
packageName: string,
|
|
95
|
+
) {
|
|
96
|
+
const fs = yield* FileSystem.FileSystem;
|
|
97
|
+
const path = yield* Path.Path;
|
|
98
|
+
const manifestPath = path.join(
|
|
99
|
+
projectDir,
|
|
100
|
+
"node_modules",
|
|
101
|
+
...packageName.split("/"),
|
|
102
|
+
"package.json",
|
|
103
|
+
);
|
|
104
|
+
const contents = yield* fs.readFileString(manifestPath).pipe(
|
|
105
|
+
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
106
|
+
Effect.fail(new EffectSourceDependencyError({ packageName })),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
return yield* Schema.decodeUnknownEffect(PackageVersionSchema)(contents).pipe(
|
|
110
|
+
Effect.mapError(() => new EffectSourceDependencyError({ packageName })),
|
|
111
|
+
Effect.map((manifest) => manifest.version),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const resolveCheckoutPath = Effect.fn("resolveEffectSourceCheckoutPath")(function* (
|
|
116
|
+
projectDir: string,
|
|
117
|
+
candidate: string,
|
|
118
|
+
) {
|
|
119
|
+
const path = yield* Path.Path;
|
|
120
|
+
if (candidate.length === 0 || path.isAbsolute(candidate)) {
|
|
121
|
+
return yield* new EffectSourceCheckoutError({
|
|
122
|
+
message: `Effect source path must be a non-empty project-relative path: ${candidate}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const checkoutDir = path.resolve(projectDir, candidate);
|
|
126
|
+
const relative = path.relative(projectDir, checkoutDir);
|
|
127
|
+
if (
|
|
128
|
+
relative.length === 0 ||
|
|
129
|
+
relative === ".." ||
|
|
130
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
131
|
+
path.isAbsolute(relative)
|
|
132
|
+
) {
|
|
133
|
+
return yield* new EffectSourceCheckoutError({
|
|
134
|
+
message: `Effect source path resolves outside the project: ${candidate}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
let ancestor = projectDir;
|
|
138
|
+
for (const segment of relative.split(path.sep).slice(0, -1)) {
|
|
139
|
+
ancestor = path.join(ancestor, segment);
|
|
140
|
+
if ((yield* observeSymbolicLink(ancestor)).kind === "symlink") {
|
|
141
|
+
return yield* new EffectSourceCheckoutError({
|
|
142
|
+
message: `Effect source path has a symlink ancestor: ${ancestor}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
checkoutDir,
|
|
148
|
+
path: path.sep === "/" ? relative : relative.split(path.sep).join("/"),
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const inspectExistingCheckout = Effect.fn("inspectExistingEffectSource")(function* (
|
|
153
|
+
checkoutDir: string,
|
|
154
|
+
repository: string,
|
|
155
|
+
tag: string,
|
|
156
|
+
) {
|
|
157
|
+
const fs = yield* FileSystem.FileSystem;
|
|
158
|
+
if ((yield* observeSymbolicLink(checkoutDir)).kind === "symlink") {
|
|
159
|
+
return yield* new EffectSourceCheckoutError({
|
|
160
|
+
message: `Effect source destination is a symlink: ${checkoutDir}`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (!(yield* fs.exists(checkoutDir))) return "sync" as const;
|
|
164
|
+
|
|
165
|
+
const actualRoot = yield* runGit(checkoutDir, ["rev-parse", "--show-toplevel"]).pipe(
|
|
166
|
+
Effect.mapError(() =>
|
|
167
|
+
new EffectSourceCheckoutError({
|
|
168
|
+
message: `Effect source destination exists but is not a Git checkout: ${checkoutDir}`,
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
const expectedRoot = yield* fs.realPath(checkoutDir);
|
|
173
|
+
if ((yield* fs.realPath(actualRoot)) !== expectedRoot) {
|
|
174
|
+
return yield* new EffectSourceCheckoutError({
|
|
175
|
+
message: `Effect source destination is nested inside another Git checkout: ${checkoutDir}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const remote = yield* runGit(checkoutDir, ["remote", "get-url", "origin"]);
|
|
179
|
+
if (remote !== repository) {
|
|
180
|
+
return yield* new EffectSourceCheckoutError({
|
|
181
|
+
message: `Effect source origin is ${remote}; expected ${repository}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const target = yield* runGit(checkoutDir, [
|
|
186
|
+
"rev-parse",
|
|
187
|
+
"-q",
|
|
188
|
+
"--verify",
|
|
189
|
+
`${tag}^{commit}`,
|
|
190
|
+
]).pipe(Effect.catchTag("EffectSourceCommandError", () => Effect.void));
|
|
191
|
+
if (target !== undefined) {
|
|
192
|
+
const current = yield* runGit(checkoutDir, ["rev-parse", "HEAD"]);
|
|
193
|
+
if (current === target) return "unchanged" as const;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const dirty = yield* runGit(checkoutDir, ["status", "--porcelain", "--untracked-files=all"]);
|
|
197
|
+
if (dirty.length > 0) {
|
|
198
|
+
return yield* new EffectSourceCheckoutError({
|
|
199
|
+
message: `Effect source checkout has local changes; refusing to switch ${checkoutDir} to ${tag}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return "sync" as const;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
export const planEffectSource = Effect.fn("planEffectSource")(function* (
|
|
206
|
+
options: EffectSourceOptions = {},
|
|
207
|
+
) {
|
|
208
|
+
const fs = yield* FileSystem.FileSystem;
|
|
209
|
+
const path = yield* Path.Path;
|
|
210
|
+
const projectDir = yield* fs.realPath(path.resolve(options.projectDir ?? "."));
|
|
211
|
+
const packageName = options.packageName ?? "effect";
|
|
212
|
+
if (!isTypeScriptPackageName(packageName)) {
|
|
213
|
+
return yield* new EffectSourceCheckoutError({
|
|
214
|
+
message: `invalid Effect source package name: ${packageName}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const repository = options.repository ?? DEFAULT_EFFECT_REPOSITORY;
|
|
218
|
+
if (repository.length === 0) {
|
|
219
|
+
return yield* new EffectSourceCheckoutError({ message: "Effect source repository cannot be empty" });
|
|
220
|
+
}
|
|
221
|
+
const resolved = yield* resolveCheckoutPath(
|
|
222
|
+
projectDir,
|
|
223
|
+
options.path ?? DEFAULT_EFFECT_SOURCE_PATH,
|
|
224
|
+
);
|
|
225
|
+
const packageVersion = yield* readPackageVersion(projectDir, packageName);
|
|
226
|
+
const tag = `effect@${packageVersion}`;
|
|
227
|
+
const ci = yield* Config.string("CI").pipe(Config.withDefault(""));
|
|
228
|
+
const action = ci === "true" || ci === "1"
|
|
229
|
+
? "skipped" as const
|
|
230
|
+
: yield* inspectExistingCheckout(resolved.checkoutDir, repository, tag);
|
|
231
|
+
return {
|
|
232
|
+
action,
|
|
233
|
+
checkoutDir: resolved.checkoutDir,
|
|
234
|
+
packageName,
|
|
235
|
+
packageVersion,
|
|
236
|
+
path: resolved.path,
|
|
237
|
+
projectDir,
|
|
238
|
+
repository,
|
|
239
|
+
tag,
|
|
240
|
+
} satisfies EffectSourcePlan;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
export const applyEffectSourcePlan = Effect.fn("applyEffectSourcePlan")(function* (
|
|
244
|
+
plan: EffectSourcePlan,
|
|
245
|
+
) {
|
|
246
|
+
if (plan.action !== "sync") return;
|
|
247
|
+
const fs = yield* FileSystem.FileSystem;
|
|
248
|
+
const path = yield* Path.Path;
|
|
249
|
+
if (!(yield* fs.exists(plan.checkoutDir))) {
|
|
250
|
+
const parent = path.dirname(plan.checkoutDir);
|
|
251
|
+
yield* fs.makeDirectory(parent, { recursive: true });
|
|
252
|
+
const tempDir = yield* fs.makeTempDirectoryScoped({
|
|
253
|
+
directory: parent,
|
|
254
|
+
prefix: ".dev-kit-effect-source-",
|
|
255
|
+
});
|
|
256
|
+
const staged = path.join(tempDir, "checkout");
|
|
257
|
+
yield* runGit(plan.projectDir, [
|
|
258
|
+
"clone",
|
|
259
|
+
"--depth",
|
|
260
|
+
"1",
|
|
261
|
+
"--branch",
|
|
262
|
+
plan.tag,
|
|
263
|
+
"--single-branch",
|
|
264
|
+
"--",
|
|
265
|
+
plan.repository,
|
|
266
|
+
staged,
|
|
267
|
+
]);
|
|
268
|
+
if (yield* fs.exists(plan.checkoutDir)) {
|
|
269
|
+
return yield* new EffectSourceCheckoutError({
|
|
270
|
+
message: `Effect source destination appeared while cloning: ${plan.checkoutDir}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
yield* fs.rename(staged, plan.checkoutDir);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
yield* runGit(plan.checkoutDir, [
|
|
278
|
+
"fetch",
|
|
279
|
+
"--depth",
|
|
280
|
+
"1",
|
|
281
|
+
"--force",
|
|
282
|
+
"--quiet",
|
|
283
|
+
"origin",
|
|
284
|
+
`refs/tags/${plan.tag}:refs/tags/${plan.tag}`,
|
|
285
|
+
]);
|
|
286
|
+
const target = yield* runGit(plan.checkoutDir, [
|
|
287
|
+
"rev-parse",
|
|
288
|
+
"-q",
|
|
289
|
+
"--verify",
|
|
290
|
+
`${plan.tag}^{commit}`,
|
|
291
|
+
]);
|
|
292
|
+
yield* runGit(plan.checkoutDir, ["checkout", "--detach", target]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
export const syncEffectSource = Effect.fn("syncEffectSource")(function* (
|
|
296
|
+
options: EffectSourceOptions = {},
|
|
297
|
+
) {
|
|
298
|
+
const plan = yield* planEffectSource(options);
|
|
299
|
+
const detail = `${plan.tag} → ${plan.path}`;
|
|
300
|
+
if (plan.action === "skipped") {
|
|
301
|
+
yield* printStatus("plan", "Effect source skipped", "CI");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (options.dryRun) {
|
|
305
|
+
yield* printStatus(
|
|
306
|
+
plan.action === "sync" ? "plan" : "success",
|
|
307
|
+
plan.action === "sync" ? "Would sync Effect source" : "Effect source up to date",
|
|
308
|
+
detail,
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (plan.action === "unchanged") {
|
|
313
|
+
yield* printStatus("success", "Effect source up to date", detail);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
yield* acquireProjectProcessLock(plan.projectDir);
|
|
317
|
+
const replanned = yield* planEffectSource(options);
|
|
318
|
+
if (JSON.stringify(plan) !== JSON.stringify(replanned)) {
|
|
319
|
+
return yield* new EffectSourceCheckoutError({
|
|
320
|
+
message: "Effect source checkout changed after planning; rerun the command",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
yield* withSpinner("Syncing Effect source", applyEffectSourcePlan(replanned));
|
|
324
|
+
yield* printStatus("success", "Effect source ready", detail);
|
|
325
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Crypto, Effect, Encoding, FileSystem, Path, Schema, Stream } from "effect";
|
|
2
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
3
|
+
|
|
4
|
+
import { acquireProjectProcessLock } from "./project-process-lock.ts";
|
|
5
|
+
import { printStatus, withSpinner } from "./cli-ui.ts";
|
|
6
|
+
import { isTypeScriptPackageName } from "./typescript-package-name.ts";
|
|
7
|
+
|
|
8
|
+
export const EFFECT_TSGO_VERSION = "0.24.3";
|
|
9
|
+
export const EFFECT_TSGO_TYPESCRIPT_VERSION = "7.0.2";
|
|
10
|
+
export const EFFECT_TSGO_PLUGIN_NAME = "@effect/language-service";
|
|
11
|
+
|
|
12
|
+
export type EffectTsgoPatchOptions = {
|
|
13
|
+
readonly dryRun?: boolean;
|
|
14
|
+
readonly force?: boolean;
|
|
15
|
+
readonly projectDir?: string;
|
|
16
|
+
readonly typescriptPackage?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type EffectTsgoPatchPlan = {
|
|
20
|
+
readonly alreadyPatched: boolean;
|
|
21
|
+
readonly projectDir: string;
|
|
22
|
+
readonly executable: string;
|
|
23
|
+
readonly args: ReadonlyArray<string>;
|
|
24
|
+
readonly effectTsgoVersion: string;
|
|
25
|
+
readonly typescriptPackage: string;
|
|
26
|
+
readonly typescriptVersion: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class EffectTsgoDependencyError extends Schema.TaggedErrorClass<EffectTsgoDependencyError>()(
|
|
30
|
+
"EffectTsgoDependencyError",
|
|
31
|
+
{
|
|
32
|
+
packageName: Schema.String,
|
|
33
|
+
expectedVersion: Schema.String,
|
|
34
|
+
actualVersion: Schema.optional(Schema.String),
|
|
35
|
+
},
|
|
36
|
+
) {
|
|
37
|
+
override get message() {
|
|
38
|
+
return this.actualVersion === undefined
|
|
39
|
+
? `${this.packageName}@${this.expectedVersion} must be installed before patching`
|
|
40
|
+
: `${this.packageName}@${this.actualVersion} is installed; dev-kit requires ${this.packageName}@${this.expectedVersion}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class EffectTsgoPatchCommandError extends Schema.TaggedErrorClass<EffectTsgoPatchCommandError>()(
|
|
45
|
+
"EffectTsgoPatchCommandError",
|
|
46
|
+
{
|
|
47
|
+
command: Schema.String,
|
|
48
|
+
exitCode: Schema.Int,
|
|
49
|
+
output: Schema.String,
|
|
50
|
+
},
|
|
51
|
+
) {
|
|
52
|
+
override get message() {
|
|
53
|
+
return this.output.length > 0
|
|
54
|
+
? `${this.command} exited with code ${this.exitCode}: ${this.output}`
|
|
55
|
+
: `${this.command} exited with code ${this.exitCode}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class InvalidEffectTsgoPackageNameError extends Schema.TaggedErrorClass<InvalidEffectTsgoPackageNameError>()(
|
|
60
|
+
"InvalidEffectTsgoPackageNameError",
|
|
61
|
+
{ packageName: Schema.String },
|
|
62
|
+
) {
|
|
63
|
+
override get message() {
|
|
64
|
+
return `invalid native TypeScript package name: ${this.packageName}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PackageVersionSchema = Schema.fromJsonString(
|
|
69
|
+
Schema.Struct({ version: Schema.String }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const packagePath = (path: Path.Path, projectDir: string, packageName: string): string =>
|
|
73
|
+
path.join(projectDir, "node_modules", ...packageName.split("/"), "package.json");
|
|
74
|
+
|
|
75
|
+
const readExactPackageVersion = Effect.fn("readExactEffectTsgoPackageVersion")(function* (
|
|
76
|
+
projectDir: string,
|
|
77
|
+
packageName: string,
|
|
78
|
+
expectedVersion: string,
|
|
79
|
+
) {
|
|
80
|
+
const fs = yield* FileSystem.FileSystem;
|
|
81
|
+
const path = yield* Path.Path;
|
|
82
|
+
const manifestPath = packagePath(path, projectDir, packageName);
|
|
83
|
+
const contents = yield* fs.readFileString(manifestPath).pipe(
|
|
84
|
+
Effect.catchReason(
|
|
85
|
+
"PlatformError",
|
|
86
|
+
"NotFound",
|
|
87
|
+
() => Effect.fail(new EffectTsgoDependencyError({ packageName, expectedVersion })),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
const manifest = yield* Schema.decodeUnknownEffect(PackageVersionSchema)(contents).pipe(
|
|
91
|
+
Effect.mapError(() =>
|
|
92
|
+
new EffectTsgoDependencyError({ packageName, expectedVersion }),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
if (manifest.version !== expectedVersion) {
|
|
96
|
+
return yield* new EffectTsgoDependencyError({
|
|
97
|
+
packageName,
|
|
98
|
+
expectedVersion,
|
|
99
|
+
actualVersion: manifest.version,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return manifest.version;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const resolveEffectTsgoExecutable = Effect.fn("resolveEffectTsgoExecutable")(function* (
|
|
106
|
+
projectDir: string,
|
|
107
|
+
) {
|
|
108
|
+
const fs = yield* FileSystem.FileSystem;
|
|
109
|
+
const path = yield* Path.Path;
|
|
110
|
+
const binDir = path.join(projectDir, "node_modules", ".bin");
|
|
111
|
+
const candidates = path.sep === "\\"
|
|
112
|
+
? [path.join(binDir, "effect-tsgo.cmd"), path.join(binDir, "effect-tsgo")]
|
|
113
|
+
: [path.join(binDir, "effect-tsgo"), path.join(binDir, "effect-tsgo.cmd")];
|
|
114
|
+
for (const candidate of candidates) {
|
|
115
|
+
if (yield* fs.exists(candidate)) return candidate;
|
|
116
|
+
}
|
|
117
|
+
return yield* new EffectTsgoDependencyError({
|
|
118
|
+
packageName: "@effect/tsgo",
|
|
119
|
+
expectedVersion: EFFECT_TSGO_VERSION,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const digestFileContents = Effect.fn("digestEffectTsgoFileContents")(function* (
|
|
124
|
+
filePath: string,
|
|
125
|
+
) {
|
|
126
|
+
const crypto = yield* Crypto.Crypto;
|
|
127
|
+
const fs = yield* FileSystem.FileSystem;
|
|
128
|
+
return Encoding.encodeHex(yield* crypto.digest("SHA-256", yield* fs.readFile(filePath)));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const isEffectTsgoPatched = Effect.fn("isEffectTsgoPatched")(function* (
|
|
132
|
+
projectDir: string,
|
|
133
|
+
) {
|
|
134
|
+
const fs = yield* FileSystem.FileSystem;
|
|
135
|
+
const path = yield* Path.Path;
|
|
136
|
+
const scopeDir = path.join(projectDir, "node_modules", "@typescript");
|
|
137
|
+
if (!(yield* fs.exists(scopeDir))) return false;
|
|
138
|
+
const entries = yield* fs.readDirectory(scopeDir);
|
|
139
|
+
const executableName = path.sep === "\\" ? "tsc.exe" : "tsc";
|
|
140
|
+
const effectExecutableNames = path.sep === "\\"
|
|
141
|
+
? ["tsc.exe", "tsc-next.exe"]
|
|
142
|
+
: ["tsc", "tsc-next"];
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (!entry.startsWith("typescript-")) continue;
|
|
145
|
+
const platform = entry.slice("typescript-".length);
|
|
146
|
+
const installedPath = path.join(scopeDir, entry, "lib", executableName);
|
|
147
|
+
if (!(yield* fs.exists(installedPath))) continue;
|
|
148
|
+
const installedDigest = yield* digestFileContents(installedPath);
|
|
149
|
+
for (const effectExecutableName of effectExecutableNames) {
|
|
150
|
+
const effectBinaryPath = path.join(
|
|
151
|
+
projectDir,
|
|
152
|
+
"node_modules",
|
|
153
|
+
"@effect",
|
|
154
|
+
`tsgo-${platform}`,
|
|
155
|
+
"lib",
|
|
156
|
+
effectExecutableName,
|
|
157
|
+
);
|
|
158
|
+
if (
|
|
159
|
+
(yield* fs.exists(effectBinaryPath)) &&
|
|
160
|
+
installedDigest === (yield* digestFileContents(effectBinaryPath))
|
|
161
|
+
) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export const planEffectTsgoPatch = Effect.fn("planEffectTsgoPatch")(function* (
|
|
170
|
+
options: EffectTsgoPatchOptions = {},
|
|
171
|
+
) {
|
|
172
|
+
const fs = yield* FileSystem.FileSystem;
|
|
173
|
+
const path = yield* Path.Path;
|
|
174
|
+
const projectDir = yield* fs.realPath(path.resolve(options.projectDir ?? "."));
|
|
175
|
+
const typescriptPackage = options.typescriptPackage ?? "typescript";
|
|
176
|
+
if (!isTypeScriptPackageName(typescriptPackage)) {
|
|
177
|
+
return yield* new InvalidEffectTsgoPackageNameError({ packageName: typescriptPackage });
|
|
178
|
+
}
|
|
179
|
+
const effectTsgoVersion = yield* readExactPackageVersion(
|
|
180
|
+
projectDir,
|
|
181
|
+
"@effect/tsgo",
|
|
182
|
+
EFFECT_TSGO_VERSION,
|
|
183
|
+
);
|
|
184
|
+
const typescriptVersion = yield* readExactPackageVersion(
|
|
185
|
+
projectDir,
|
|
186
|
+
typescriptPackage,
|
|
187
|
+
EFFECT_TSGO_TYPESCRIPT_VERSION,
|
|
188
|
+
);
|
|
189
|
+
const executable = yield* resolveEffectTsgoExecutable(projectDir);
|
|
190
|
+
return {
|
|
191
|
+
alreadyPatched: yield* isEffectTsgoPatched(projectDir),
|
|
192
|
+
projectDir,
|
|
193
|
+
executable,
|
|
194
|
+
args: [
|
|
195
|
+
"patch",
|
|
196
|
+
...(options.force ? ["--force"] : []),
|
|
197
|
+
...(typescriptPackage === "typescript"
|
|
198
|
+
? []
|
|
199
|
+
: ["--typescript-package", typescriptPackage]),
|
|
200
|
+
],
|
|
201
|
+
effectTsgoVersion,
|
|
202
|
+
typescriptPackage,
|
|
203
|
+
typescriptVersion,
|
|
204
|
+
} satisfies EffectTsgoPatchPlan;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
export const applyEffectTsgoPatchPlan = Effect.fn("applyEffectTsgoPatchPlan")(function* (
|
|
208
|
+
plan: EffectTsgoPatchPlan,
|
|
209
|
+
) {
|
|
210
|
+
if (plan.alreadyPatched) return;
|
|
211
|
+
const child = yield* ChildProcess.make(plan.executable, plan.args, {
|
|
212
|
+
cwd: plan.projectDir,
|
|
213
|
+
stderr: "pipe",
|
|
214
|
+
stdout: "pipe",
|
|
215
|
+
});
|
|
216
|
+
const [output, exitCode] = yield* Effect.all([
|
|
217
|
+
Stream.mkString(Stream.decodeText(child.all)),
|
|
218
|
+
child.exitCode,
|
|
219
|
+
]);
|
|
220
|
+
const trimmed = output.trim();
|
|
221
|
+
if (exitCode !== 0) {
|
|
222
|
+
return yield* new EffectTsgoPatchCommandError({
|
|
223
|
+
command: [plan.executable, ...plan.args].join(" "),
|
|
224
|
+
exitCode,
|
|
225
|
+
output: trimmed,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export const patchEffectTsgo = Effect.fn("patchEffectTsgo")(function* (
|
|
231
|
+
options: EffectTsgoPatchOptions = {},
|
|
232
|
+
) {
|
|
233
|
+
const plan = yield* planEffectTsgoPatch(options);
|
|
234
|
+
const detail = `@effect/tsgo@${plan.effectTsgoVersion} → ${plan.typescriptPackage}@${plan.typescriptVersion}`;
|
|
235
|
+
if (options.dryRun) {
|
|
236
|
+
yield* printStatus(
|
|
237
|
+
plan.alreadyPatched ? "success" : "plan",
|
|
238
|
+
plan.alreadyPatched ? "TypeScript patch up to date" : "Would patch TypeScript",
|
|
239
|
+
detail,
|
|
240
|
+
);
|
|
241
|
+
return plan;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return yield* Effect.scoped(
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
yield* acquireProjectProcessLock(plan.projectDir);
|
|
247
|
+
if (plan.alreadyPatched) {
|
|
248
|
+
yield* printStatus("success", "TypeScript patch up to date", detail);
|
|
249
|
+
return plan;
|
|
250
|
+
}
|
|
251
|
+
yield* withSpinner("Patching TypeScript", applyEffectTsgoPatchPlan(plan));
|
|
252
|
+
yield* printStatus("success", "TypeScript patched", detail);
|
|
253
|
+
return plan;
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
});
|