@savvy-web/silk-effects 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Savvy Web Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @savvy-web/silk-effects
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@savvy-web/silk-effects)](https://www.npmjs.com/package/@savvy-web/silk-effects)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Shared [Effect](https://effect.website/) library providing Silk Suite conventions for publishability detection, versioning strategy, tag formatting, managed sections, config discovery, and Biome schema synchronization. Platform-agnostic: consumers provide their own runtime layer (Node.js, Bun, etc.).
7
+
8
+ ## Features
9
+
10
+ - Resolve publish targets from shorthand strings, URLs, or objects with sensible defaults
11
+ - Detect versioning strategy (single, fixed-group, independent) from changeset config
12
+ - Format git tags consistently based on workspace structure
13
+ - Manage tool-owned sections inside user-editable files without clobbering user content
14
+ - Discover config files using a priority-based search convention
15
+ - Keep Biome `$schema` URLs in sync across config files
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pnpm add @savvy-web/silk-effects effect @effect/platform @effect/platform-node
21
+ ```
22
+
23
+ `effect` is a peer dependency -- install it alongside the package.
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import { Effect } from "effect";
29
+ import { TargetResolver, TargetResolverLive } from "@savvy-web/silk-effects/publish";
30
+
31
+ const targets = await Effect.runPromise(
32
+ Effect.gen(function* () {
33
+ const resolver = yield* TargetResolver;
34
+ return yield* resolver.resolve(["npm", "github"]);
35
+ }).pipe(Effect.provide(TargetResolverLive)),
36
+ );
37
+ ```
38
+
39
+ Modules that access the filesystem require a platform layer:
40
+
41
+ ```typescript
42
+ import { NodeContext } from "@effect/platform-node";
43
+ import { ManagedSection, ManagedSectionLive } from "@savvy-web/silk-effects/hooks";
44
+
45
+ await Effect.runPromise(
46
+ Effect.gen(function* () {
47
+ const section = yield* ManagedSection;
48
+ yield* section.write(".husky/pre-commit", "silk", "\nnpx lint-staged\n");
49
+ }).pipe(
50
+ Effect.provide(ManagedSectionLive),
51
+ Effect.provide(NodeContext.layer),
52
+ ),
53
+ );
54
+ ```
55
+
56
+ ## Modules
57
+
58
+ Each module has its own entry point -- import only what you need:
59
+
60
+ | Module | Entry Point | Platform Layer | Docs |
61
+ | ------ | ----------- | -------------- | ---- |
62
+ | Publish | `@savvy-web/silk-effects/publish` | No | [docs/publish.md](./docs/publish.md) |
63
+ | Versioning | `@savvy-web/silk-effects/versioning` | Yes | [docs/versioning.md](./docs/versioning.md) |
64
+ | Tags | `@savvy-web/silk-effects/tags` | No | [docs/tags.md](./docs/tags.md) |
65
+ | Hooks | `@savvy-web/silk-effects/hooks` | Yes | [docs/hooks.md](./docs/hooks.md) |
66
+ | Config | `@savvy-web/silk-effects/config` | Yes | [docs/config.md](./docs/config.md) |
67
+ | Biome | `@savvy-web/silk-effects/biome` | Yes | [docs/biome.md](./docs/biome.md) |
68
+
69
+ ## Documentation
70
+
71
+ For service API reference, schemas, error types, and advanced usage, see [docs/](./docs/).
72
+
73
+ ## License
74
+
75
+ [MIT](./LICENSE)
package/biome.d.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { Context } from 'effect';
2
+ import { Effect } from 'effect';
3
+ import { FileSystem } from '@effect/platform';
4
+ import { Layer } from 'effect';
5
+ import { Schema } from 'effect';
6
+ import { VoidIfEmpty } from 'effect/Types';
7
+ import { YieldableError } from 'effect/Cause';
8
+
9
+ /**
10
+ * Service that keeps the `$schema` URL in Biome config files in sync with a target version.
11
+ *
12
+ * @remarks
13
+ * Locates `biome.json` and `biome.jsonc` files in the working directory, then compares
14
+ * each file's `$schema` field against the expected `biomejs.dev` URL for the given version.
15
+ * `sync` writes updates in-place; `check` returns the same result without modifying files.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const result = await Effect.runPromise(
20
+ * Effect.gen(function* () {
21
+ * const syncer = yield* BiomeSchemaSync;
22
+ * return yield* syncer.sync("^1.9.3");
23
+ * }).pipe(
24
+ * Effect.provide(BiomeSchemaSyncLive),
25
+ * Effect.provide(NodeContext.layer),
26
+ * )
27
+ * );
28
+ * ```
29
+ *
30
+ * @since 0.1.0
31
+ */
32
+ export declare class BiomeSchemaSync extends BiomeSchemaSync_base {
33
+ }
34
+
35
+ declare const BiomeSchemaSync_base: Context.TagClass<BiomeSchemaSync, "@savvy-web/silk-effects/BiomeSchemaSync", {
36
+ /**
37
+ * Update the `$schema` URL in all located Biome config files to match `version`.
38
+ *
39
+ * @param version - Target Biome version (range operators are stripped automatically).
40
+ * @param options - Optional `cwd` and `gitignore` overrides.
41
+ * @returns An `Effect` that succeeds with a {@link BiomeSyncResult} or fails with {@link BiomeSyncError}.
42
+ *
43
+ * @since 0.1.0
44
+ */
45
+ readonly sync: (version: string, options?: {
46
+ cwd?: string;
47
+ gitignore?: boolean;
48
+ }) => Effect.Effect<BiomeSyncResult, BiomeSyncError>;
49
+ /**
50
+ * Check whether the `$schema` URL in Biome config files is current, without writing any changes.
51
+ *
52
+ * @param version - Target Biome version (range operators are stripped automatically).
53
+ * @param options - Optional `cwd` and `gitignore` overrides.
54
+ * @returns An `Effect` that succeeds with a {@link BiomeSyncResult} or fails with {@link BiomeSyncError}.
55
+ * Files that would be updated appear in `updated`; no disk writes occur.
56
+ *
57
+ * @since 0.1.0
58
+ */
59
+ readonly check: (version: string, options?: {
60
+ cwd?: string;
61
+ gitignore?: boolean;
62
+ }) => Effect.Effect<BiomeSyncResult, BiomeSyncError>;
63
+ }>;
64
+
65
+ /**
66
+ * Live implementation of {@link BiomeSchemaSync}.
67
+ *
68
+ * @remarks
69
+ * Requires `FileSystem` from `@effect/platform`. Provide `NodeContext.layer` or
70
+ * `BunContext.layer` to satisfy this dependency.
71
+ *
72
+ * @since 0.1.0
73
+ */
74
+ export declare const BiomeSchemaSyncLive: Layer.Layer<BiomeSchemaSync, never, FileSystem.FileSystem>;
75
+
76
+ /**
77
+ * Raised when a Biome config file cannot be read or its `$schema` URL cannot be updated.
78
+ *
79
+ * @remarks
80
+ * Returned by {@link BiomeSchemaSync.sync} and {@link BiomeSchemaSync.check} when
81
+ * a `biome.json` or `biome.jsonc` file exists but cannot be read, contains invalid JSON,
82
+ * or cannot be written back to disk.
83
+ *
84
+ * @since 0.1.0
85
+ */
86
+ export declare class BiomeSyncError extends BiomeSyncError_base<{
87
+ readonly path: string;
88
+ readonly reason: string;
89
+ }> {
90
+ get message(): string;
91
+ }
92
+
93
+ declare const BiomeSyncError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty< { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
94
+ readonly _tag: "BiomeSyncError";
95
+ } & Readonly<A>;
96
+
97
+ /**
98
+ * Options for {@link BiomeSchemaSync} operations.
99
+ *
100
+ * @remarks
101
+ * `cwd` overrides the working directory used to locate `biome.json` / `biome.jsonc`.
102
+ * `gitignore` is reserved for future use to skip gitignored config files (defaults to `true`).
103
+ *
104
+ * @since 0.1.0
105
+ */
106
+ export declare const BiomeSyncOptions: Schema.Struct<{
107
+ cwd: Schema.optional<typeof Schema.String>;
108
+ gitignore: Schema.optionalWith<typeof Schema.Boolean, {
109
+ default: () => true;
110
+ }>;
111
+ }>;
112
+
113
+ /** @since 0.1.0 */
114
+ export declare type BiomeSyncOptions = typeof BiomeSyncOptions.Type;
115
+
116
+ /**
117
+ * Result of a Biome schema URL sync or check operation.
118
+ *
119
+ * @remarks
120
+ * - `updated` — paths of config files whose `$schema` URL was changed (or would be changed on `check`).
121
+ * - `skipped` — paths of config files with no `$schema` field or a non-biomejs.dev URL.
122
+ * - `current` — paths of config files already pointing to the expected schema URL.
123
+ *
124
+ * @since 0.1.0
125
+ */
126
+ export declare const BiomeSyncResult: Schema.Struct<{
127
+ updated: Schema.Array$<typeof Schema.String>;
128
+ skipped: Schema.Array$<typeof Schema.String>;
129
+ current: Schema.Array$<typeof Schema.String>;
130
+ }>;
131
+
132
+ /** @since 0.1.0 */
133
+ export declare type BiomeSyncResult = typeof BiomeSyncResult.Type;
134
+
135
+ export { }
package/biome.js ADDED
@@ -0,0 +1,84 @@
1
+ import { FileSystem } from "@effect/platform";
2
+ import { Context, Data, Effect, Layer } from "effect";
3
+ import { parse } from "jsonc-effect";
4
+ class BiomeSyncError extends Data.TaggedError("BiomeSyncError") {
5
+ get message() {
6
+ return `Failed to sync biome schema in ${this.path}: ${this.reason}`;
7
+ }
8
+ }
9
+ function extractSemver(version) {
10
+ return version.replace(/^[\^~>=<v]+/, "");
11
+ }
12
+ function buildSchemaUrl(version) {
13
+ return `https://biomejs.dev/schemas/${version}/schema.json`;
14
+ }
15
+ const BIOME_SCHEMA_HOSTNAME = "biomejs.dev";
16
+ function findBiomeConfigs(cwd, fs) {
17
+ const candidates = [
18
+ `${cwd}/biome.json`,
19
+ `${cwd}/biome.jsonc`
20
+ ];
21
+ return Effect.gen(function*() {
22
+ const results = [];
23
+ for (const candidate of candidates){
24
+ const exists = yield* fs.exists(candidate).pipe(Effect.orElseSucceed(()=>false));
25
+ if (exists) results.push(candidate);
26
+ }
27
+ return results;
28
+ });
29
+ }
30
+ class BiomeSchemaSync extends Context.Tag("@savvy-web/silk-effects/BiomeSchemaSync")() {
31
+ }
32
+ const BiomeSchemaSyncLive = Layer.effect(BiomeSchemaSync, Effect.gen(function*() {
33
+ const fs = yield* FileSystem.FileSystem;
34
+ const run = (version, options, write)=>Effect.gen(function*() {
35
+ const cwd = options?.cwd ?? process.cwd();
36
+ const semver = extractSemver(version);
37
+ const expectedUrl = buildSchemaUrl(semver);
38
+ const configs = yield* findBiomeConfigs(cwd, fs);
39
+ const updated = [];
40
+ const skipped = [];
41
+ const current = [];
42
+ for (const configPath of configs){
43
+ const raw = yield* fs.readFileString(configPath).pipe(Effect.mapError((cause)=>new BiomeSyncError({
44
+ path: configPath,
45
+ reason: String(cause)
46
+ })));
47
+ const parsed = yield* parse(raw).pipe(Effect.mapError((e)=>new BiomeSyncError({
48
+ path: configPath,
49
+ reason: `Failed to parse JSONC: ${String(e)}`
50
+ })));
51
+ const schema = parsed.$schema;
52
+ if ("string" != typeof schema) {
53
+ skipped.push(configPath);
54
+ continue;
55
+ }
56
+ if (!schema.includes(BIOME_SCHEMA_HOSTNAME)) {
57
+ skipped.push(configPath);
58
+ continue;
59
+ }
60
+ if (schema === expectedUrl) {
61
+ current.push(configPath);
62
+ continue;
63
+ }
64
+ if (write) {
65
+ const updated_content = raw.replaceAll(schema, expectedUrl);
66
+ yield* fs.writeFileString(configPath, updated_content).pipe(Effect.mapError((cause)=>new BiomeSyncError({
67
+ path: configPath,
68
+ reason: String(cause)
69
+ })));
70
+ }
71
+ updated.push(configPath);
72
+ }
73
+ return {
74
+ updated,
75
+ skipped,
76
+ current
77
+ };
78
+ });
79
+ return {
80
+ sync: (version, options)=>run(version, options, true),
81
+ check: (version, options)=>run(version, options, false)
82
+ };
83
+ }));
84
+ export { BiomeSchemaSync, BiomeSchemaSyncLive, BiomeSyncError };
package/config.d.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { Context } from 'effect';
2
+ import { Effect } from 'effect';
3
+ import { FileSystem } from '@effect/platform';
4
+ import { Layer } from 'effect';
5
+ import { Schema } from 'effect';
6
+ import { VoidIfEmpty } from 'effect/Types';
7
+ import { YieldableError } from 'effect/Cause';
8
+
9
+ /**
10
+ * Service that locates named config files within a workspace using priority-ordered search paths.
11
+ *
12
+ * @remarks
13
+ * Search priority (highest to lowest):
14
+ * 1. `lib/configs/{name}` — shared config provided by a dependency package.
15
+ * 2. `{cwd}/{name}` — local override at the workspace root.
16
+ *
17
+ * Missing files are silently skipped; only files that actually exist are returned.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const result = await Effect.runPromise(
22
+ * Effect.gen(function* () {
23
+ * const discovery = yield* ConfigDiscovery;
24
+ * return yield* discovery.find("biome.json");
25
+ * }).pipe(
26
+ * Effect.provide(ConfigDiscoveryLive),
27
+ * Effect.provide(NodeContext.layer),
28
+ * )
29
+ * );
30
+ * ```
31
+ *
32
+ * @since 0.1.0
33
+ */
34
+ export declare class ConfigDiscovery extends ConfigDiscovery_base {
35
+ }
36
+
37
+ declare const ConfigDiscovery_base: Context.TagClass<ConfigDiscovery, "@savvy-web/silk-effects/ConfigDiscovery", {
38
+ /**
39
+ * Return the highest-priority {@link ConfigLocation} for the given config file name,
40
+ * or `null` when none of the candidate paths exist.
41
+ *
42
+ * @param name - Config file name (e.g. `"biome.json"`).
43
+ * @param options - Optional `cwd` override for path resolution.
44
+ * @returns An `Effect` that always succeeds with a {@link ConfigLocation} or `null`.
45
+ *
46
+ * @since 0.1.0
47
+ */
48
+ readonly find: (name: string, options?: {
49
+ cwd?: string;
50
+ }) => Effect.Effect<ConfigLocation | null>;
51
+ /**
52
+ * Return all existing {@link ConfigLocation} entries for the given config file name,
53
+ * ordered from highest to lowest priority.
54
+ *
55
+ * @param name - Config file name (e.g. `"biome.json"`).
56
+ * @param options - Optional `cwd` override for path resolution.
57
+ * @returns An `Effect` that always succeeds with an array of {@link ConfigLocation} records.
58
+ *
59
+ * @since 0.1.0
60
+ */
61
+ readonly findAll: (name: string, options?: {
62
+ cwd?: string;
63
+ }) => Effect.Effect<ReadonlyArray<ConfigLocation>>;
64
+ }>;
65
+
66
+ /**
67
+ * Live implementation of {@link ConfigDiscovery}.
68
+ *
69
+ * @remarks
70
+ * Requires `FileSystem` from `@effect/platform`. Provide `NodeContext.layer` or
71
+ * `BunContext.layer` to satisfy this dependency.
72
+ *
73
+ * @since 0.1.0
74
+ */
75
+ export declare const ConfigDiscoveryLive: Layer.Layer<ConfigDiscovery, never, FileSystem.FileSystem>;
76
+
77
+ /**
78
+ * Options passed to config discovery methods.
79
+ *
80
+ * @remarks
81
+ * `cwd` overrides the working directory for path resolution (defaults to `process.cwd()`).
82
+ * `tool` is reserved for future use as a tool-specific discovery hint.
83
+ *
84
+ * @since 0.1.0
85
+ */
86
+ export declare const ConfigDiscoveryOptions: Schema.Struct<{
87
+ cwd: Schema.optional<typeof Schema.String>;
88
+ tool: Schema.optional<typeof Schema.String>;
89
+ }>;
90
+
91
+ /** @since 0.1.0 */
92
+ export declare type ConfigDiscoveryOptions = typeof ConfigDiscoveryOptions.Type;
93
+
94
+ /**
95
+ * The resolved location of a discovered config file.
96
+ *
97
+ * @remarks
98
+ * Produced by {@link ConfigDiscovery.find} and {@link ConfigDiscovery.findAll}.
99
+ * `path` is the absolute file path; `source` indicates how it was discovered.
100
+ *
101
+ * @since 0.1.0
102
+ */
103
+ export declare const ConfigLocation: Schema.Struct<{
104
+ path: typeof Schema.String;
105
+ source: Schema.Literal<["lib", "root", "cosmiconfig"]>;
106
+ }>;
107
+
108
+ /** @since 0.1.0 */
109
+ export declare type ConfigLocation = typeof ConfigLocation.Type;
110
+
111
+ /**
112
+ * Raised when a config file cannot be located in any of the expected locations.
113
+ *
114
+ * @remarks
115
+ * Returned by consumers that require a config file to exist. {@link ConfigDiscovery.find}
116
+ * itself returns `null` instead of failing — callers that need a hard failure should
117
+ * map `null` to this error.
118
+ *
119
+ * @since 0.1.0
120
+ */
121
+ export declare class ConfigNotFoundError extends ConfigNotFoundError_base<{
122
+ readonly name: string;
123
+ readonly searchedPaths: ReadonlyArray<string>;
124
+ }> {
125
+ get message(): string;
126
+ }
127
+
128
+ declare const ConfigNotFoundError_base: new <A extends Record<string, any> = {}>(args: VoidIfEmpty< { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => YieldableError & {
129
+ readonly _tag: "ConfigNotFoundError";
130
+ } & Readonly<A>;
131
+
132
+ /**
133
+ * The discovery strategy used to locate a config file.
134
+ *
135
+ * @remarks
136
+ * - `"lib"` — found under `lib/configs/{name}` relative to the workspace root.
137
+ * - `"root"` — found directly in the workspace root as `{name}`.
138
+ * - `"cosmiconfig"` — reserved for future cosmiconfig-based discovery.
139
+ *
140
+ * @since 0.1.0
141
+ */
142
+ export declare const ConfigSource: Schema.Literal<["lib", "root", "cosmiconfig"]>;
143
+
144
+ /** @since 0.1.0 */
145
+ export declare type ConfigSource = typeof ConfigSource.Type;
146
+
147
+ export { }
package/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import { FileSystem } from "@effect/platform";
2
+ import { Context, Data, Effect, Layer } from "effect";
3
+ class ConfigDiscovery extends Context.Tag("@savvy-web/silk-effects/ConfigDiscovery")() {
4
+ }
5
+ function safeExists(fs, path) {
6
+ return fs.exists(path).pipe(Effect.orElseSucceed(()=>false));
7
+ }
8
+ const ConfigDiscoveryLive = Layer.effect(ConfigDiscovery, Effect.gen(function*() {
9
+ const fs = yield* FileSystem.FileSystem;
10
+ const findAll = (name, options)=>Effect.gen(function*() {
11
+ const cwd = options?.cwd ?? process.cwd();
12
+ const results = [];
13
+ const libPath = `${cwd}/lib/configs/${name}`;
14
+ const libExists = yield* safeExists(fs, libPath);
15
+ if (libExists) results.push({
16
+ path: libPath,
17
+ source: "lib"
18
+ });
19
+ const rootPath = `${cwd}/${name}`;
20
+ const rootExists = yield* safeExists(fs, rootPath);
21
+ if (rootExists) results.push({
22
+ path: rootPath,
23
+ source: "root"
24
+ });
25
+ return results;
26
+ });
27
+ const find = (name, options)=>findAll(name, options).pipe(Effect.map((results)=>results[0] ?? null));
28
+ return {
29
+ find,
30
+ findAll
31
+ };
32
+ }));
33
+ class ConfigNotFoundError extends Data.TaggedError("ConfigNotFoundError") {
34
+ get message() {
35
+ return `Config '${this.name}' not found. Searched: ${this.searchedPaths.join(", ")}`;
36
+ }
37
+ }
38
+ export { ConfigDiscovery, ConfigDiscoveryLive, ConfigNotFoundError };