@mepuka/skygent 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 +59 -0
- package/index.ts +146 -0
- package/package.json +56 -0
- package/src/cli/app.ts +75 -0
- package/src/cli/config-command.ts +140 -0
- package/src/cli/config.ts +91 -0
- package/src/cli/derive.ts +205 -0
- package/src/cli/doc/annotation.ts +36 -0
- package/src/cli/doc/filter.ts +69 -0
- package/src/cli/doc/index.ts +9 -0
- package/src/cli/doc/post.ts +155 -0
- package/src/cli/doc/primitives.ts +25 -0
- package/src/cli/doc/render.ts +18 -0
- package/src/cli/doc/table.ts +114 -0
- package/src/cli/doc/thread.ts +46 -0
- package/src/cli/doc/tree.ts +126 -0
- package/src/cli/errors.ts +59 -0
- package/src/cli/exit-codes.ts +52 -0
- package/src/cli/feed.ts +177 -0
- package/src/cli/filter-dsl.ts +1411 -0
- package/src/cli/filter-errors.ts +208 -0
- package/src/cli/filter-help.ts +70 -0
- package/src/cli/filter-input.ts +54 -0
- package/src/cli/filter.ts +435 -0
- package/src/cli/graph.ts +472 -0
- package/src/cli/help.ts +14 -0
- package/src/cli/interval.ts +35 -0
- package/src/cli/jetstream.ts +173 -0
- package/src/cli/layers.ts +180 -0
- package/src/cli/logging.ts +136 -0
- package/src/cli/output-format.ts +26 -0
- package/src/cli/output.ts +82 -0
- package/src/cli/parse.ts +80 -0
- package/src/cli/post.ts +193 -0
- package/src/cli/preferences.ts +11 -0
- package/src/cli/query-fields.ts +247 -0
- package/src/cli/query.ts +415 -0
- package/src/cli/range.ts +44 -0
- package/src/cli/search.ts +465 -0
- package/src/cli/shared-options.ts +169 -0
- package/src/cli/shared.ts +20 -0
- package/src/cli/store-errors.ts +80 -0
- package/src/cli/store-tree.ts +392 -0
- package/src/cli/store.ts +395 -0
- package/src/cli/sync-factory.ts +107 -0
- package/src/cli/sync.ts +366 -0
- package/src/cli/view-thread.ts +196 -0
- package/src/cli/view.ts +47 -0
- package/src/cli/watch.ts +344 -0
- package/src/db/migrations/store-catalog/001_init.ts +14 -0
- package/src/db/migrations/store-index/001_init.ts +34 -0
- package/src/db/migrations/store-index/002_event_log.ts +24 -0
- package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
- package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
- package/src/db/migrations/store-index/005_post_lang.ts +15 -0
- package/src/db/migrations/store-index/006_has_embed.ts +10 -0
- package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
- package/src/domain/bsky.ts +467 -0
- package/src/domain/config.ts +11 -0
- package/src/domain/credentials.ts +6 -0
- package/src/domain/defaults.ts +8 -0
- package/src/domain/derivation.ts +55 -0
- package/src/domain/errors.ts +71 -0
- package/src/domain/events.ts +55 -0
- package/src/domain/extract.ts +64 -0
- package/src/domain/filter-describe.ts +551 -0
- package/src/domain/filter-explain.ts +9 -0
- package/src/domain/filter.ts +797 -0
- package/src/domain/format.ts +91 -0
- package/src/domain/index.ts +13 -0
- package/src/domain/indexes.ts +17 -0
- package/src/domain/policies.ts +16 -0
- package/src/domain/post.ts +88 -0
- package/src/domain/primitives.ts +50 -0
- package/src/domain/raw.ts +140 -0
- package/src/domain/store.ts +103 -0
- package/src/domain/sync.ts +211 -0
- package/src/domain/text-width.ts +56 -0
- package/src/services/app-config.ts +278 -0
- package/src/services/bsky-client.ts +2113 -0
- package/src/services/credential-store.ts +408 -0
- package/src/services/derivation-engine.ts +502 -0
- package/src/services/derivation-settings.ts +61 -0
- package/src/services/derivation-validator.ts +68 -0
- package/src/services/filter-compiler.ts +269 -0
- package/src/services/filter-library.ts +371 -0
- package/src/services/filter-runtime.ts +821 -0
- package/src/services/filter-settings.ts +30 -0
- package/src/services/identity-resolver.ts +563 -0
- package/src/services/jetstream-sync.ts +636 -0
- package/src/services/lineage-store.ts +89 -0
- package/src/services/link-validator.ts +244 -0
- package/src/services/output-manager.ts +274 -0
- package/src/services/post-parser.ts +62 -0
- package/src/services/profile-resolver.ts +223 -0
- package/src/services/resource-monitor.ts +106 -0
- package/src/services/shared.ts +69 -0
- package/src/services/store-cleaner.ts +43 -0
- package/src/services/store-commit.ts +168 -0
- package/src/services/store-db.ts +248 -0
- package/src/services/store-event-log.ts +285 -0
- package/src/services/store-index-sql.ts +289 -0
- package/src/services/store-index.ts +1152 -0
- package/src/services/store-keys.ts +4 -0
- package/src/services/store-manager.ts +358 -0
- package/src/services/store-stats.ts +522 -0
- package/src/services/store-writer.ts +200 -0
- package/src/services/sync-checkpoint-store.ts +169 -0
- package/src/services/sync-engine.ts +547 -0
- package/src/services/sync-reporter.ts +16 -0
- package/src/services/sync-settings.ts +72 -0
- package/src/services/trending-topics.ts +226 -0
- package/src/services/view-checkpoint-store.ts +238 -0
- package/src/typeclass/chunk.ts +84 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Monoid from "@effect/typeclass/Monoid";
|
|
3
|
+
import * as Semigroup from "@effect/typeclass/Semigroup";
|
|
4
|
+
import { MonoidSum } from "@effect/typeclass/data/Number";
|
|
5
|
+
import { AuthorFeedFilter } from "./bsky.js";
|
|
6
|
+
import { FilterExprSchema } from "./filter.js";
|
|
7
|
+
import { StoreRef, SyncUpsertPolicy } from "./store.js";
|
|
8
|
+
import { EventSeq, Timestamp } from "./primitives.js";
|
|
9
|
+
|
|
10
|
+
export const SyncStage = Schema.Literal("source", "parse", "filter", "store");
|
|
11
|
+
export type SyncStage = typeof SyncStage.Type;
|
|
12
|
+
|
|
13
|
+
export class SyncError extends Schema.TaggedError<SyncError>()("SyncError", {
|
|
14
|
+
stage: SyncStage,
|
|
15
|
+
message: Schema.String,
|
|
16
|
+
cause: Schema.optional(Schema.Unknown)
|
|
17
|
+
}) {}
|
|
18
|
+
|
|
19
|
+
export class SyncResult extends Schema.Class<SyncResult>("SyncResult")({
|
|
20
|
+
postsAdded: Schema.Number,
|
|
21
|
+
postsDeleted: Schema.Number,
|
|
22
|
+
postsSkipped: Schema.Number,
|
|
23
|
+
errors: Schema.Array(SyncError)
|
|
24
|
+
}) {}
|
|
25
|
+
|
|
26
|
+
const SyncResultErrorsMonoid = Monoid.array<SyncError>();
|
|
27
|
+
|
|
28
|
+
const SyncResultSemigroup: Semigroup.Semigroup<SyncResult> = Semigroup.make(
|
|
29
|
+
(left, right) =>
|
|
30
|
+
SyncResult.make({
|
|
31
|
+
postsAdded: MonoidSum.combine(left.postsAdded, right.postsAdded),
|
|
32
|
+
postsDeleted: MonoidSum.combine(left.postsDeleted, right.postsDeleted),
|
|
33
|
+
postsSkipped: MonoidSum.combine(left.postsSkipped, right.postsSkipped),
|
|
34
|
+
errors: SyncResultErrorsMonoid.combine(left.errors, right.errors)
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export const SyncResultMonoid: Monoid.Monoid<SyncResult> = Monoid.fromSemigroup(
|
|
39
|
+
SyncResultSemigroup,
|
|
40
|
+
SyncResult.make({
|
|
41
|
+
postsAdded: MonoidSum.empty,
|
|
42
|
+
postsDeleted: MonoidSum.empty,
|
|
43
|
+
postsSkipped: MonoidSum.empty,
|
|
44
|
+
errors: SyncResultErrorsMonoid.empty
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export class DataSourceTimeline extends Schema.TaggedClass<DataSourceTimeline>()(
|
|
49
|
+
"Timeline",
|
|
50
|
+
{}
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
export class DataSourceFeed extends Schema.TaggedClass<DataSourceFeed>()("Feed", {
|
|
54
|
+
uri: Schema.String
|
|
55
|
+
}) {}
|
|
56
|
+
|
|
57
|
+
export class DataSourceList extends Schema.TaggedClass<DataSourceList>()("List", {
|
|
58
|
+
uri: Schema.String
|
|
59
|
+
}) {}
|
|
60
|
+
|
|
61
|
+
export class DataSourceNotifications extends Schema.TaggedClass<DataSourceNotifications>()(
|
|
62
|
+
"Notifications",
|
|
63
|
+
{}
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
export class DataSourceAuthor extends Schema.TaggedClass<DataSourceAuthor>()("Author", {
|
|
67
|
+
actor: Schema.String,
|
|
68
|
+
filter: Schema.optional(AuthorFeedFilter),
|
|
69
|
+
includePins: Schema.optional(Schema.Boolean)
|
|
70
|
+
}) {}
|
|
71
|
+
|
|
72
|
+
export class DataSourceThread extends Schema.TaggedClass<DataSourceThread>()("Thread", {
|
|
73
|
+
uri: Schema.String,
|
|
74
|
+
depth: Schema.optional(Schema.NonNegativeInt),
|
|
75
|
+
parentHeight: Schema.optional(Schema.NonNegativeInt)
|
|
76
|
+
}) {}
|
|
77
|
+
|
|
78
|
+
export class DataSourceJetstream extends Schema.TaggedClass<DataSourceJetstream>()(
|
|
79
|
+
"Jetstream",
|
|
80
|
+
{
|
|
81
|
+
endpoint: Schema.optional(Schema.String),
|
|
82
|
+
collections: Schema.optional(Schema.Array(Schema.String)),
|
|
83
|
+
dids: Schema.optional(Schema.Array(Schema.String)),
|
|
84
|
+
compress: Schema.optional(Schema.Boolean),
|
|
85
|
+
maxMessageSizeBytes: Schema.optional(Schema.Number)
|
|
86
|
+
}
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
export const DataSourceSchema = Schema.Union(
|
|
90
|
+
DataSourceTimeline,
|
|
91
|
+
DataSourceFeed,
|
|
92
|
+
DataSourceList,
|
|
93
|
+
DataSourceNotifications,
|
|
94
|
+
DataSourceAuthor,
|
|
95
|
+
DataSourceThread,
|
|
96
|
+
DataSourceJetstream
|
|
97
|
+
);
|
|
98
|
+
export type DataSource = typeof DataSourceSchema.Type;
|
|
99
|
+
|
|
100
|
+
export const DataSource = {
|
|
101
|
+
timeline: (): DataSource => DataSourceTimeline.make({}),
|
|
102
|
+
feed: (uri: string): DataSource => DataSourceFeed.make({ uri }),
|
|
103
|
+
list: (uri: string): DataSource => DataSourceList.make({ uri }),
|
|
104
|
+
notifications: (): DataSource => DataSourceNotifications.make({}),
|
|
105
|
+
author: (
|
|
106
|
+
actor: string,
|
|
107
|
+
options?: {
|
|
108
|
+
readonly filter?: AuthorFeedFilter;
|
|
109
|
+
readonly includePins?: boolean;
|
|
110
|
+
}
|
|
111
|
+
): DataSource =>
|
|
112
|
+
DataSourceAuthor.make({
|
|
113
|
+
actor,
|
|
114
|
+
filter: options?.filter,
|
|
115
|
+
includePins: options?.includePins
|
|
116
|
+
}),
|
|
117
|
+
thread: (
|
|
118
|
+
uri: string,
|
|
119
|
+
options?: {
|
|
120
|
+
readonly depth?: number;
|
|
121
|
+
readonly parentHeight?: number;
|
|
122
|
+
}
|
|
123
|
+
): DataSource =>
|
|
124
|
+
DataSourceThread.make({
|
|
125
|
+
uri,
|
|
126
|
+
depth: options?.depth,
|
|
127
|
+
parentHeight: options?.parentHeight
|
|
128
|
+
}),
|
|
129
|
+
jetstream: (options?: {
|
|
130
|
+
readonly endpoint?: string;
|
|
131
|
+
readonly collections?: ReadonlyArray<string>;
|
|
132
|
+
readonly dids?: ReadonlyArray<string>;
|
|
133
|
+
readonly compress?: boolean;
|
|
134
|
+
readonly maxMessageSizeBytes?: number;
|
|
135
|
+
}): DataSource =>
|
|
136
|
+
DataSourceJetstream.make({
|
|
137
|
+
endpoint: options?.endpoint,
|
|
138
|
+
collections: options?.collections ? [...options.collections] : undefined,
|
|
139
|
+
dids: options?.dids ? [...options.dids] : undefined,
|
|
140
|
+
compress: options?.compress,
|
|
141
|
+
maxMessageSizeBytes: options?.maxMessageSizeBytes
|
|
142
|
+
})
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export class WatchConfig extends Schema.Class<WatchConfig>("WatchConfig")({
|
|
146
|
+
source: DataSourceSchema,
|
|
147
|
+
store: StoreRef,
|
|
148
|
+
filter: FilterExprSchema,
|
|
149
|
+
interval: Schema.optional(Schema.Duration),
|
|
150
|
+
policy: Schema.optional(SyncUpsertPolicy)
|
|
151
|
+
}) {}
|
|
152
|
+
|
|
153
|
+
export class SyncEvent extends Schema.TaggedClass<SyncEvent>()("SyncEvent", {
|
|
154
|
+
result: SyncResult
|
|
155
|
+
}) {}
|
|
156
|
+
|
|
157
|
+
export class SyncProgress extends Schema.Class<SyncProgress>("SyncProgress")({
|
|
158
|
+
processed: Schema.NonNegativeInt,
|
|
159
|
+
stored: Schema.NonNegativeInt,
|
|
160
|
+
skipped: Schema.NonNegativeInt,
|
|
161
|
+
errors: Schema.NonNegativeInt,
|
|
162
|
+
elapsedMs: Schema.NonNegativeInt,
|
|
163
|
+
rate: Schema.Number
|
|
164
|
+
}) {}
|
|
165
|
+
|
|
166
|
+
export class SyncCheckpoint extends Schema.Class<SyncCheckpoint>("SyncCheckpoint")({
|
|
167
|
+
source: DataSourceSchema,
|
|
168
|
+
cursor: Schema.optional(Schema.String),
|
|
169
|
+
lastEventSeq: Schema.optional(EventSeq),
|
|
170
|
+
filterHash: Schema.optional(Schema.String),
|
|
171
|
+
updatedAt: Timestamp
|
|
172
|
+
}) {}
|
|
173
|
+
|
|
174
|
+
export const dataSourceKey = (source: DataSource): string => {
|
|
175
|
+
const normalizeList = (items: ReadonlyArray<string> | undefined) =>
|
|
176
|
+
items && items.length > 0 ? [...items].sort().join(",") : "";
|
|
177
|
+
|
|
178
|
+
switch (source._tag) {
|
|
179
|
+
case "Timeline":
|
|
180
|
+
return "timeline";
|
|
181
|
+
case "Feed":
|
|
182
|
+
return `feed:${source.uri}`;
|
|
183
|
+
case "List":
|
|
184
|
+
return `list:${source.uri}`;
|
|
185
|
+
case "Notifications":
|
|
186
|
+
return "notifications";
|
|
187
|
+
case "Author": {
|
|
188
|
+
const filter = source.filter ?? "";
|
|
189
|
+
const includePins =
|
|
190
|
+
source.includePins === undefined ? "" : source.includePins ? "1" : "0";
|
|
191
|
+
return `author:${encodeURIComponent(source.actor)}:${encodeURIComponent(
|
|
192
|
+
filter
|
|
193
|
+
)}:${includePins}`;
|
|
194
|
+
}
|
|
195
|
+
case "Thread": {
|
|
196
|
+
const depth = source.depth ?? "";
|
|
197
|
+
const parentHeight = source.parentHeight ?? "";
|
|
198
|
+
return `thread:${encodeURIComponent(source.uri)}:${depth}:${parentHeight}`;
|
|
199
|
+
}
|
|
200
|
+
case "Jetstream": {
|
|
201
|
+
const endpoint = source.endpoint ?? "";
|
|
202
|
+
const collections = normalizeList(source.collections);
|
|
203
|
+
const dids = normalizeList(source.dids);
|
|
204
|
+
const compress = source.compress ? "1" : "0";
|
|
205
|
+
const maxMessageSize = source.maxMessageSizeBytes ?? "";
|
|
206
|
+
return `jetstream:${encodeURIComponent(endpoint)}:${encodeURIComponent(
|
|
207
|
+
collections
|
|
208
|
+
)}:${encodeURIComponent(dids)}:${compress}:${maxMessageSize}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const emojiRegex = (() => {
|
|
2
|
+
try {
|
|
3
|
+
return new RegExp("\\p{Extended_Pictographic}", "u");
|
|
4
|
+
} catch {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
})();
|
|
8
|
+
|
|
9
|
+
const segmenter =
|
|
10
|
+
typeof Intl !== "undefined" && "Segmenter" in Intl
|
|
11
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
12
|
+
: undefined;
|
|
13
|
+
|
|
14
|
+
const toGraphemes = (text: string) =>
|
|
15
|
+
segmenter
|
|
16
|
+
? Array.from(segmenter.segment(text), (part) => part.segment)
|
|
17
|
+
: Array.from(text);
|
|
18
|
+
|
|
19
|
+
const isFullwidthCodePoint = (codePoint: number) =>
|
|
20
|
+
codePoint >= 0x1100 &&
|
|
21
|
+
(codePoint <= 0x115f ||
|
|
22
|
+
codePoint === 0x2329 ||
|
|
23
|
+
codePoint === 0x232a ||
|
|
24
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
25
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
26
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
27
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
28
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
29
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
30
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
31
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
|
|
32
|
+
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
|
|
33
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
|
34
|
+
|
|
35
|
+
export const displayWidth = (text: string) => {
|
|
36
|
+
let width = 0;
|
|
37
|
+
for (const grapheme of toGraphemes(text)) {
|
|
38
|
+
if (grapheme.length === 0) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const codePoint = grapheme.codePointAt(0) ?? 0;
|
|
42
|
+
const isWide =
|
|
43
|
+
(emojiRegex ? emojiRegex.test(grapheme) : false) ||
|
|
44
|
+
isFullwidthCodePoint(codePoint);
|
|
45
|
+
width += isWide ? 2 : 1;
|
|
46
|
+
}
|
|
47
|
+
return width;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const padEndDisplay = (text: string, targetWidth: number) => {
|
|
51
|
+
const width = displayWidth(text);
|
|
52
|
+
if (width >= targetWidth) {
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
return `${text}${" ".repeat(targetWidth - width)}`;
|
|
56
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { FileSystem } from "@effect/platform";
|
|
2
|
+
import { Path } from "@effect/platform";
|
|
3
|
+
import { Config, Context, Effect, Layer, Option, Schema } from "effect";
|
|
4
|
+
import { formatSchemaError, pickDefined } from "./shared.js";
|
|
5
|
+
import { AppConfig, OutputFormat } from "../domain/config.js";
|
|
6
|
+
import { ConfigError } from "../domain/errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Application Configuration Service
|
|
10
|
+
*
|
|
11
|
+
* This module provides centralized configuration management for the application.
|
|
12
|
+
* It implements a layered configuration resolution strategy with support for
|
|
13
|
+
* multiple configuration sources and home directory expansion.
|
|
14
|
+
*
|
|
15
|
+
* Configuration Resolution Priority (highest to lowest):
|
|
16
|
+
* 1. Runtime overrides (ConfigOverrides service)
|
|
17
|
+
* 2. Environment variables (SKYGENT_*)
|
|
18
|
+
* 3. Config file (~/.skygent/config.json)
|
|
19
|
+
* 4. Default values
|
|
20
|
+
*
|
|
21
|
+
* Key features:
|
|
22
|
+
* - Home directory expansion (~ → $HOME)
|
|
23
|
+
* - Path normalization (relative → absolute)
|
|
24
|
+
* - Schema validation with detailed error messages
|
|
25
|
+
* - Graceful handling of missing config files
|
|
26
|
+
* - Type-safe configuration access via Effect Context
|
|
27
|
+
*
|
|
28
|
+
* Environment Variables:
|
|
29
|
+
* - SKYGENT_SERVICE: Bluesky service URL (default: https://bsky.social)
|
|
30
|
+
* - SKYGENT_STORE_ROOT: Root directory for store data (default: ~/.skygent)
|
|
31
|
+
* - SKYGENT_OUTPUT_FORMAT: Output format (json, ndjson, markdown, table)
|
|
32
|
+
* - SKYGENT_IDENTIFIER: User identifier for authentication
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* import { Effect } from "effect";
|
|
37
|
+
* import { AppConfigService, ConfigOverrides } from "./services/app-config.js";
|
|
38
|
+
*
|
|
39
|
+
* // Basic usage - read configuration
|
|
40
|
+
* const program = Effect.gen(function* () {
|
|
41
|
+
* const config = yield* AppConfigService;
|
|
42
|
+
* console.log(`Store root: ${config.storeRoot}`);
|
|
43
|
+
* console.log(`Service: ${config.service}`);
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* // With runtime overrides
|
|
47
|
+
* const withOverrides = program.pipe(
|
|
48
|
+
* Effect.provide(
|
|
49
|
+
* ConfigOverrides.layer({
|
|
50
|
+
* storeRoot: "/custom/path",
|
|
51
|
+
* outputFormat: "json"
|
|
52
|
+
* })
|
|
53
|
+
* )
|
|
54
|
+
* );
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @module services/app-config
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
type AppConfigOverrides = Partial<AppConfig>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Service for providing runtime configuration overrides.
|
|
64
|
+
*
|
|
65
|
+
* Allows injection of configuration values at runtime that take precedence
|
|
66
|
+
* over environment variables and config file settings. This is useful for
|
|
67
|
+
* CLI arguments, test configuration, or dynamic configuration scenarios.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* // Provide overrides via layer
|
|
72
|
+
* const overridesLayer = ConfigOverrides.layer({
|
|
73
|
+
* storeRoot: "/tmp/test-store",
|
|
74
|
+
* outputFormat: "json"
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* // Use in program
|
|
78
|
+
* const program = Effect.gen(function* () {
|
|
79
|
+
* const config = yield* AppConfigService;
|
|
80
|
+
* // config.storeRoot will be "/tmp/test-store"
|
|
81
|
+
* }).pipe(Effect.provide(overridesLayer));
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export class ConfigOverrides extends Context.Tag("@skygent/ConfigOverrides")<
|
|
85
|
+
ConfigOverrides,
|
|
86
|
+
AppConfigOverrides
|
|
87
|
+
>() {
|
|
88
|
+
/**
|
|
89
|
+
* Default empty configuration overrides layer.
|
|
90
|
+
*
|
|
91
|
+
* Use this as a base layer when no overrides are needed, or extend it
|
|
92
|
+
* with custom overrides using Layer.succeed.
|
|
93
|
+
*/
|
|
94
|
+
static readonly layer = Layer.succeed(ConfigOverrides, {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PartialAppConfig = Schema.Struct({
|
|
98
|
+
service: Schema.optional(Schema.String),
|
|
99
|
+
storeRoot: Schema.optional(Schema.String),
|
|
100
|
+
outputFormat: Schema.optional(OutputFormat),
|
|
101
|
+
identifier: Schema.optional(Schema.String)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
type PartialAppConfig = typeof PartialAppConfig.Type;
|
|
105
|
+
|
|
106
|
+
const defaultService = "https://bsky.social";
|
|
107
|
+
const defaultOutputFormat: OutputFormat = "ndjson";
|
|
108
|
+
const defaultRootDirName = ".skygent";
|
|
109
|
+
const configFileName = "config.json";
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
const resolveHomeDir = () =>
|
|
114
|
+
process.env.HOME ?? process.env.USERPROFILE ?? process.env.HOMEPATH;
|
|
115
|
+
|
|
116
|
+
const expandHome = (path: Path.Path, value: string, home?: string) => {
|
|
117
|
+
if (!home) return value;
|
|
118
|
+
if (value === "~") return home;
|
|
119
|
+
if (value.startsWith("~/")) {
|
|
120
|
+
return path.join(home, value.slice(2));
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const resolveDefaultRoot = (path: Path.Path) => {
|
|
126
|
+
const home = resolveHomeDir();
|
|
127
|
+
return home ? path.join(home, defaultRootDirName) : path.resolve(defaultRootDirName);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const normalizeStoreRoot = (path: Path.Path, value: string) => {
|
|
131
|
+
const home = resolveHomeDir();
|
|
132
|
+
const expanded = expandHome(path, value, home);
|
|
133
|
+
return path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const decodeConfigJson = (raw: string, configPath: string) =>
|
|
137
|
+
Schema.decodeUnknown(Schema.parseJson(PartialAppConfig))(raw).pipe(
|
|
138
|
+
Effect.mapError((error) =>
|
|
139
|
+
ConfigError.make({
|
|
140
|
+
message: `Invalid config JSON at ${configPath}: ${formatSchemaError(error)}`,
|
|
141
|
+
path: configPath,
|
|
142
|
+
cause: error
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const loadFileConfig = (configPath: string) =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const fs = yield* FileSystem.FileSystem;
|
|
150
|
+
const content = yield* fs.readFileString(configPath).pipe(
|
|
151
|
+
Effect.map(Option.some),
|
|
152
|
+
Effect.catchTag("SystemError", (error) =>
|
|
153
|
+
error.reason === "NotFound"
|
|
154
|
+
? Effect.succeed(Option.none())
|
|
155
|
+
: Effect.fail(
|
|
156
|
+
ConfigError.make({
|
|
157
|
+
message: `Failed to read config at ${configPath}`,
|
|
158
|
+
path: configPath,
|
|
159
|
+
cause: error
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
return yield* Option.match(content, {
|
|
166
|
+
onNone: () => Effect.succeed({} as PartialAppConfig),
|
|
167
|
+
onSome: (raw) => decodeConfigJson(raw, configPath)
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const envOutputFormat = Config.literal("json", "ndjson", "markdown", "table")(
|
|
172
|
+
"SKYGENT_OUTPUT_FORMAT"
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Service for accessing the resolved application configuration.
|
|
177
|
+
*
|
|
178
|
+
* Provides type-safe access to the fully resolved application configuration
|
|
179
|
+
* with values from all sources (overrides, environment, config file, defaults)
|
|
180
|
+
* merged according to the priority hierarchy.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```typescript
|
|
184
|
+
* // Access configuration in an Effect
|
|
185
|
+
* const program = Effect.gen(function* () {
|
|
186
|
+
* const config = yield* AppConfigService;
|
|
187
|
+
*
|
|
188
|
+
* // All configuration values are resolved and validated
|
|
189
|
+
* const { service, storeRoot, outputFormat, identifier } = config;
|
|
190
|
+
*
|
|
191
|
+
* // Use configuration values
|
|
192
|
+
* console.log(`Using service: ${service}`);
|
|
193
|
+
* console.log(`Storing data in: ${storeRoot}`);
|
|
194
|
+
* });
|
|
195
|
+
*
|
|
196
|
+
* // Provide the configuration layer
|
|
197
|
+
* const runnable = program.pipe(Effect.provide(AppConfigService.layer));
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
export class AppConfigService extends Context.Tag("@skygent/AppConfig")<
|
|
201
|
+
AppConfigService,
|
|
202
|
+
AppConfig
|
|
203
|
+
>() {
|
|
204
|
+
/**
|
|
205
|
+
* Layer that constructs the AppConfigService by resolving configuration
|
|
206
|
+
* from all sources in priority order.
|
|
207
|
+
*
|
|
208
|
+
* Resolution order (highest to lowest priority):
|
|
209
|
+
* 1. ConfigOverrides service values
|
|
210
|
+
* 2. Environment variables (SKYGENT_*)
|
|
211
|
+
* 3. ~/.skygent/config.json file
|
|
212
|
+
* 4. Default values
|
|
213
|
+
*
|
|
214
|
+
* @returns Layer providing the resolved AppConfigService
|
|
215
|
+
* @throws ConfigError if configuration validation fails
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```typescript
|
|
219
|
+
* // Basic usage with defaults
|
|
220
|
+
* const program = Effect.provide(myProgram, AppConfigService.layer);
|
|
221
|
+
*
|
|
222
|
+
* // With custom overrides
|
|
223
|
+
* const customLayer = Layer.merge(
|
|
224
|
+
* AppConfigService.layer,
|
|
225
|
+
* ConfigOverrides.layer({ outputFormat: "json" })
|
|
226
|
+
* );
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
static readonly layer = Layer.effect(
|
|
230
|
+
AppConfigService,
|
|
231
|
+
Effect.gen(function* () {
|
|
232
|
+
const overrides = yield* ConfigOverrides;
|
|
233
|
+
const path = yield* Path.Path;
|
|
234
|
+
const defaultRoot = resolveDefaultRoot(path);
|
|
235
|
+
const configPath = path.join(defaultRoot, configFileName);
|
|
236
|
+
|
|
237
|
+
const fileConfig = yield* loadFileConfig(configPath);
|
|
238
|
+
|
|
239
|
+
const envService = yield* Config.string("SKYGENT_SERVICE").pipe(Config.option);
|
|
240
|
+
const envStoreRoot = yield* Config.string("SKYGENT_STORE_ROOT").pipe(Config.option);
|
|
241
|
+
const envFormat = yield* envOutputFormat.pipe(Config.option);
|
|
242
|
+
const envIdentifier = yield* Config.string("SKYGENT_IDENTIFIER").pipe(Config.option);
|
|
243
|
+
|
|
244
|
+
const envConfig = pickDefined({
|
|
245
|
+
service: Option.getOrUndefined(envService),
|
|
246
|
+
storeRoot: Option.getOrUndefined(envStoreRoot),
|
|
247
|
+
outputFormat: Option.getOrUndefined(envFormat),
|
|
248
|
+
identifier: Option.getOrUndefined(envIdentifier)
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const merged = {
|
|
252
|
+
service: defaultService,
|
|
253
|
+
storeRoot: defaultRoot,
|
|
254
|
+
outputFormat: defaultOutputFormat,
|
|
255
|
+
...fileConfig,
|
|
256
|
+
...envConfig,
|
|
257
|
+
...pickDefined(overrides as Record<string, unknown>)
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const resolvedStoreRoot = merged.storeRoot ?? defaultRoot;
|
|
261
|
+
const normalized = {
|
|
262
|
+
...merged,
|
|
263
|
+
storeRoot: normalizeStoreRoot(path, resolvedStoreRoot)
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const decoded = yield* Schema.decodeUnknown(AppConfig)(normalized).pipe(
|
|
267
|
+
Effect.mapError((error) =>
|
|
268
|
+
ConfigError.make({
|
|
269
|
+
message: `Invalid config: ${formatSchemaError(error)}`,
|
|
270
|
+
path: configPath,
|
|
271
|
+
cause: error
|
|
272
|
+
})
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
return AppConfigService.of(decoded);
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
}
|