ff-serv 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "ff-serv",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
+ "bin": {
6
+ "ff-serv": "./dist/cli"
7
+ },
5
8
  "exports": {
6
9
  ".": {
7
10
  "types": "./dist/index.d.ts",
@@ -19,15 +22,22 @@
19
22
  "src"
20
23
  ],
21
24
  "scripts": {
22
- "build": "tsup",
25
+ "build": "tsup && bun run build:cli",
26
+ "build:cli": "bun build src/cli/index.ts --production --target=bun --outfile dist/cli.js",
23
27
  "test": "bun -b vitest run",
24
28
  "dev": "tsup --watch"
25
29
  },
26
30
  "devDependencies": {
31
+ "@effect/cli": "^0.71.0",
32
+ "@effect/platform-bun": "^0.87.0",
27
33
  "@effect/vitest": "^0.27.0",
28
34
  "@orpc/client": "^1.13.2",
29
35
  "@types/bun": "^1.3.2",
36
+ "@types/cli-progress": "^3.11.6",
37
+ "cli-progress": "^3.12.0",
30
38
  "ff-effect": "^0.0.7",
39
+ "inquirer": "^12.10.0",
40
+ "postgres": "^3.4.7",
31
41
  "tsup": "^8.5.0",
32
42
  "typescript": "^5.9.3",
33
43
  "vitest": "^4.0.16"
@@ -59,4 +69,4 @@
59
69
  "@effect/platform": "^0.94.1",
60
70
  "nanoid": "^5.1.6"
61
71
  }
62
- }
72
+ }
@@ -0,0 +1,37 @@
1
+ import * as cli from '@effect/cli';
2
+ import { Effect, Option } from 'effect';
3
+ import { loadConfig } from '../../config/index.js';
4
+ import {
5
+ dumpToFile,
6
+ getDatabaseUrlFromSource,
7
+ resolveDatabaseSource,
8
+ } from './shared.js';
9
+
10
+ export const dumpCommand = cli.Command.make(
11
+ 'dump',
12
+ {
13
+ output: cli.Options.file('output').pipe(
14
+ cli.Options.withAlias('o'),
15
+ cli.Options.withDefault('./dump.sql'),
16
+ ),
17
+ config: cli.Options.file('config').pipe(cli.Options.optional),
18
+ },
19
+ ({ output, config }) =>
20
+ Effect.gen(function* () {
21
+ const loadedConfig = yield* loadConfig(
22
+ Option.isSome(config) ? config.value : undefined,
23
+ );
24
+
25
+ const source = yield* resolveDatabaseSource(
26
+ Option.isSome(loadedConfig) && loadedConfig.value.pullDatabase?.source
27
+ ? loadedConfig.value.pullDatabase.source
28
+ : undefined,
29
+ );
30
+
31
+ const sourceUrl = yield* getDatabaseUrlFromSource(source);
32
+
33
+ yield* Effect.log(`Dumping database to: ${output}`);
34
+ yield* dumpToFile(sourceUrl, output);
35
+ yield* Effect.log(`Database dump complete: ${output}`);
36
+ }).pipe(Effect.scoped),
37
+ );
@@ -0,0 +1,8 @@
1
+ import * as cli from '@effect/cli';
2
+ import { Effect } from 'effect';
3
+ import { dumpCommand } from './dump.js';
4
+ import { pullCommand } from './pull.js';
5
+
6
+ export const dbCommand = cli.Command.make('db', {}, () =>
7
+ Effect.log('Database commands - UsepullCommandle subcommands'),
8
+ ).pipe(cli.Command.withSubcommands([pullCommand, dumpCommand]));
@@ -0,0 +1,338 @@
1
+ import * as cli from '@effect/cli';
2
+ import * as platform from '@effect/platform';
3
+ import { Effect, Option, Cause } from 'effect';
4
+ import inquirer from 'inquirer';
5
+ import postgres from 'postgres';
6
+ import { loadConfig } from '../../config/index.js';
7
+ import {
8
+ promptCleanupDump,
9
+ promptRetry,
10
+ promptTargetUrl,
11
+ } from '../../utils/prompts.js';
12
+ import {
13
+ dumpToFile,
14
+ getDatabaseUrlFromSource,
15
+ resolveDatabaseSource,
16
+ } from './shared.js';
17
+
18
+ const DEFAULT_TARGET_DATABASE_URL =
19
+ 'postgresql://postgres:supersecret@postgres:5432/postgres';
20
+
21
+ type DatabaseInfo = {
22
+ host: string;
23
+ port: string;
24
+ database: string;
25
+ user: string;
26
+ };
27
+
28
+ const parsePostgresUrl = (url: string): DatabaseInfo => {
29
+ const parsed = new URL(url);
30
+ return {
31
+ host: parsed.hostname || 'localhost',
32
+ port: parsed.port || '5432',
33
+ database: parsed.pathname.slice(1) || 'postgres',
34
+ user: parsed.username || 'postgres',
35
+ };
36
+ };
37
+
38
+ const confirmDatabaseUrls = (
39
+ sourceUrl: string | null,
40
+ targetUrl: string,
41
+ ) =>
42
+ Effect.gen(function* () {
43
+ const target = parsePostgresUrl(targetUrl);
44
+
45
+ if (sourceUrl) {
46
+ const source = parsePostgresUrl(sourceUrl);
47
+ yield* Effect.log('\n--- Source Database ---');
48
+ yield* Effect.log(`Host: ${source.host}`);
49
+ yield* Effect.log(`Port: ${source.port}`);
50
+ yield* Effect.log(`Database: ${source.database}`);
51
+ } else {
52
+ yield* Effect.log('\n--- Source ---');
53
+ yield* Effect.log('Local dump file');
54
+ }
55
+
56
+ yield* Effect.log('\n--- Target Database ---');
57
+ yield* Effect.log(`Host: ${target.host}`);
58
+ yield* Effect.log(`Port: ${target.port}`);
59
+ yield* Effect.log(`Database: ${target.database}`);
60
+
61
+ return yield* Effect.tryPromise(() =>
62
+ inquirer.prompt([
63
+ {
64
+ type: 'confirm',
65
+ name: 'confirmed',
66
+ message: 'Are the settings correct?',
67
+ default: false,
68
+ },
69
+ ]),
70
+ ).pipe(Effect.map((r) => r.confirmed as boolean));
71
+ });
72
+
73
+ type SchemaTableInfo = {
74
+ schema: string;
75
+ tables: string[];
76
+ };
77
+
78
+ const getSchemaTablesInfo = (databaseUrl: string) =>
79
+ Effect.gen(function* () {
80
+ const conn = postgres(databaseUrl);
81
+ yield* Effect.addFinalizer(() =>
82
+ Effect.ignore(Effect.tryPromise(() => conn.end())),
83
+ );
84
+
85
+ const schemas = yield* Effect.tryPromise(
86
+ () =>
87
+ conn<[{ schema_name: string }]>`
88
+ SELECT schema_name
89
+ FROM information_schema.schemata
90
+ WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
91
+ ORDER BY schema_name
92
+ `,
93
+ );
94
+
95
+ const schemaTableInfo: SchemaTableInfo[] = [];
96
+
97
+ for (const { schema_name } of schemas) {
98
+ const tables = yield* Effect.tryPromise(
99
+ () =>
100
+ conn<[{ table_name: string }]>`
101
+ SELECT table_name
102
+ FROM information_schema.tables
103
+ WHERE table_schema = ${schema_name}
104
+ ORDER BY table_name
105
+ `,
106
+ );
107
+
108
+ schemaTableInfo.push({
109
+ schema: schema_name,
110
+ tables: tables.map((t) => t.table_name),
111
+ });
112
+ }
113
+
114
+ return schemaTableInfo;
115
+ });
116
+
117
+ const confirmDatabaseReset = (databaseUrl: string) =>
118
+ Effect.gen(function* () {
119
+ const schemaInfo = yield* getSchemaTablesInfo(databaseUrl);
120
+
121
+ yield* Effect.log('\nThe following will be truncated:\n');
122
+
123
+ for (const { schema, tables } of schemaInfo) {
124
+ if (tables.length === 0) {
125
+ yield* Effect.log(`Schema: ${schema}`);
126
+ yield* Effect.log(' (no tables)\n');
127
+ continue;
128
+ }
129
+
130
+ yield* Effect.log(`Schema: ${schema}`);
131
+ for (const table of tables) {
132
+ yield* Effect.log(` - ${table}`);
133
+ }
134
+ yield* Effect.log('');
135
+ }
136
+
137
+ const { shouldReset } = yield* Effect.tryPromise(() =>
138
+ inquirer.prompt([
139
+ {
140
+ type: 'confirm',
141
+ name: 'shouldReset',
142
+ message: 'Proceed with truncation?',
143
+ default: false,
144
+ },
145
+ ]),
146
+ );
147
+
148
+ return { shouldReset: shouldReset as boolean, schemaInfo };
149
+ });
150
+
151
+ const truncateAllTables = (
152
+ databaseUrl: string,
153
+ schemaInfo: SchemaTableInfo[],
154
+ ) =>
155
+ Effect.gen(function* () {
156
+ const conn = postgres(databaseUrl);
157
+ yield* Effect.addFinalizer(() =>
158
+ Effect.ignore(Effect.tryPromise(() => conn.end())),
159
+ );
160
+
161
+ yield* Effect.log(`Truncating ${schemaInfo.length} schema(s)...`);
162
+
163
+ for (const { schema, tables } of schemaInfo) {
164
+ if (tables.length === 0) {
165
+ yield* Effect.log(` No tables in "${schema}"`);
166
+ continue;
167
+ }
168
+
169
+ yield* Effect.log(
170
+ `Truncating ${tables.length} table(s) in schema "${schema}"...`,
171
+ );
172
+
173
+ const tableNames = tables
174
+ .map((t) => `"${schema}"."${t}"`)
175
+ .join(', ');
176
+
177
+ yield* Effect.tryPromise(() =>
178
+ conn.unsafe(`TRUNCATE ${tableNames} RESTART IDENTITY CASCADE`),
179
+ );
180
+ }
181
+
182
+ yield* Effect.log('Database reset complete');
183
+ });
184
+
185
+ const restoreFromFile = (databaseUrl: string, filePath: string) =>
186
+ Effect.gen(function* () {
187
+ yield* Effect.log('Restoring from file');
188
+ yield* platform.Command.make('psql', databaseUrl, '-f', filePath).pipe(
189
+ platform.Command.stdout('inherit'),
190
+ platform.Command.exitCode,
191
+ );
192
+ });
193
+
194
+ const createDumpFile = Effect.gen(function* () {
195
+ const fs = yield* platform.FileSystem.FileSystem;
196
+ const path = yield* platform.Path.Path;
197
+ const tmpDir = yield* fs.makeTempDirectory();
198
+ const file = path.join(tmpDir, 'dump.sql');
199
+ yield* Effect.log(`Prepared dump file: ${file}`);
200
+ return file;
201
+ });
202
+
203
+ const saveDumpToPath = (sourcePath: string, destinationPath: string) =>
204
+ Effect.gen(function* () {
205
+ const fs = yield* platform.FileSystem.FileSystem;
206
+ yield* fs.copy(sourcePath, destinationPath);
207
+ yield* Effect.log(`Dump saved to: ${destinationPath}`);
208
+ });
209
+
210
+ interface DumpState {
211
+ filePath: string;
212
+ downloaded: boolean;
213
+ }
214
+
215
+ const executeWithRetry = <A, E, R>(
216
+ operation: Effect.Effect<A, E, R>,
217
+ dumpState: DumpState,
218
+ ): Effect.Effect<A, E | Cause.UnknownException, R> =>
219
+ Effect.catchAll(operation, (error) =>
220
+ Effect.gen(function* () {
221
+ yield* Effect.logError(`Operation failed: ${error}`);
222
+
223
+ if (dumpState.downloaded) {
224
+ yield* Effect.log(`Dump file preserved at: ${dumpState.filePath}`);
225
+ yield* Effect.log('You can retry using --fromDump flag');
226
+ }
227
+
228
+ const shouldRetry = yield* promptRetry;
229
+
230
+ if (shouldRetry) {
231
+ yield* Effect.log('Retrying...');
232
+ return yield* executeWithRetry(operation, dumpState);
233
+ }
234
+
235
+ return yield* Effect.fail(error);
236
+ }),
237
+ );
238
+
239
+ export const pullCommand = cli.Command.make(
240
+ 'pull',
241
+ {
242
+ fromDump: cli.Options.file('fromDump').pipe(cli.Options.optional),
243
+ targetDatabaseUrl: cli.Args.text({ name: 'targetDatabaseUrl' }).pipe(
244
+ cli.Args.optional,
245
+ ),
246
+ saveDump: cli.Options.file('saveDump').pipe(cli.Options.optional),
247
+ config: cli.Options.file('config').pipe(cli.Options.optional),
248
+ },
249
+ ({ fromDump, targetDatabaseUrl, saveDump, config }) =>
250
+ Effect.gen(function* () {
251
+ const loadedConfig = yield* loadConfig(
252
+ Option.isSome(config) ? config.value : undefined,
253
+ );
254
+
255
+ const targetUrl =
256
+ Option.getOrUndefined(targetDatabaseUrl) ||
257
+ Option.flatMap(loadedConfig, (c) =>
258
+ Option.fromNullable(c.pullDatabase?.targetDatabaseUrl),
259
+ ).pipe(Option.getOrUndefined) ||
260
+ (yield* promptTargetUrl(DEFAULT_TARGET_DATABASE_URL));
261
+
262
+ let dumpState: DumpState;
263
+
264
+ if (Option.isSome(fromDump)) {
265
+ dumpState = { filePath: fromDump.value, downloaded: true };
266
+ yield* Effect.log(`Using dump file: ${dumpState.filePath}`);
267
+
268
+ const confirmed = yield* confirmDatabaseUrls(null, targetUrl);
269
+ if (!confirmed) {
270
+ yield* Effect.log('Operation cancelled');
271
+ return;
272
+ }
273
+ } else {
274
+ const dumpPath = yield* createDumpFile;
275
+ dumpState = { filePath: dumpPath, downloaded: false };
276
+
277
+ const source = yield* resolveDatabaseSource(
278
+ Option.flatMap(loadedConfig, (c) =>
279
+ Option.fromNullable(c.pullDatabase?.source),
280
+ ).pipe(Option.getOrUndefined),
281
+ );
282
+
283
+ const sourceUrl = yield* getDatabaseUrlFromSource(source);
284
+
285
+ const urlsConfirmed = yield* confirmDatabaseUrls(
286
+ sourceUrl,
287
+ targetUrl,
288
+ );
289
+ if (!urlsConfirmed) {
290
+ yield* Effect.log('Operation cancelled');
291
+ return;
292
+ }
293
+
294
+ yield* executeWithRetry(
295
+ dumpToFile(sourceUrl, dumpState.filePath).pipe(
296
+ Effect.tap(() =>
297
+ Effect.sync(() => {
298
+ dumpState.downloaded = true;
299
+ }),
300
+ ),
301
+ ),
302
+ dumpState,
303
+ );
304
+
305
+ if (Option.isSome(saveDump)) {
306
+ yield* saveDumpToPath(dumpState.filePath, saveDump.value);
307
+ }
308
+ }
309
+
310
+ yield* executeWithRetry(
311
+ Effect.gen(function* () {
312
+ const { shouldReset, schemaInfo } =
313
+ yield* confirmDatabaseReset(targetUrl);
314
+ if (shouldReset) {
315
+ yield* truncateAllTables(targetUrl, schemaInfo);
316
+ }
317
+
318
+ yield* restoreFromFile(targetUrl, dumpState.filePath);
319
+ }),
320
+ dumpState,
321
+ );
322
+
323
+ if (!Option.isSome(fromDump)) {
324
+ const shouldCleanup = yield* promptCleanupDump(
325
+ dumpState.filePath,
326
+ );
327
+ if (shouldCleanup) {
328
+ const fs = yield* platform.FileSystem.FileSystem;
329
+ yield* fs.remove(dumpState.filePath, { recursive: true });
330
+ yield* Effect.log('Dump file cleaned up');
331
+ } else {
332
+ yield* Effect.log(`Dump file kept at: ${dumpState.filePath}`);
333
+ }
334
+ }
335
+
336
+ yield* Effect.log('Database pull complete!');
337
+ }).pipe(Effect.scoped),
338
+ );
@@ -0,0 +1,112 @@
1
+ import * as platform from '@effect/platform';
2
+ import cliProgress from 'cli-progress';
3
+ import { Effect, Fiber } from 'effect';
4
+ import type { DatabaseSourceConfig } from '../../config/schema.js';
5
+ import {
6
+ createDirectSource,
7
+ createRailwaySource,
8
+ type DatabaseSource,
9
+ } from '../../utils/database-source.js';
10
+ import {
11
+ promptDatabaseSourceType,
12
+ promptDirectUrl,
13
+ promptRailwayConfig,
14
+ } from '../../utils/prompts.js';
15
+
16
+ export const getDatabaseUrlFromSource = (source: DatabaseSource) =>
17
+ Effect.gen(function* () {
18
+ yield* Effect.log(`Getting database URL from ${source.displayName}...`);
19
+ return yield* source.getConnectionUrl;
20
+ });
21
+
22
+ export const dumpToFile = (databaseUrl: string, filePath: string) =>
23
+ Effect.gen(function* () {
24
+ const fs = yield* platform.FileSystem.FileSystem;
25
+
26
+ const bar = new cliProgress.SingleBar({
27
+ format: 'Dumping |{bar}| {fileSize} MB ({rate} MB/s)',
28
+ barCompleteChar: '\u2588',
29
+ barIncompleteChar: '\u2591',
30
+ hideCursor: true,
31
+ });
32
+
33
+ const process = yield* platform.Command.make(
34
+ 'pg_dump',
35
+ databaseUrl,
36
+ '--file',
37
+ filePath,
38
+ ).pipe(platform.Command.start);
39
+
40
+ bar.start(100, 0, { fileSize: '0', rate: '0' });
41
+
42
+ const startTime = Date.now();
43
+ let lastSize = 0n;
44
+ let lastCheckTime = startTime;
45
+
46
+ const fileSizeMonitor = Effect.gen(function* () {
47
+ while (true) {
48
+ yield* Effect.sleep('500 millis');
49
+
50
+ const stat = yield* fs
51
+ .stat(filePath)
52
+ .pipe(Effect.catchAll(() => Effect.succeed({ size: 0n })));
53
+ const now = Date.now();
54
+ const elapsed = (now - startTime) / 1000;
55
+ const sizeBytes = Number(stat.size);
56
+ const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
57
+
58
+ const timeSinceLastCheck = (now - lastCheckTime) / 1000;
59
+ const bytesSinceLastCheck = Number(stat.size - lastSize);
60
+ const rateMB =
61
+ timeSinceLastCheck > 0
62
+ ? (
63
+ bytesSinceLastCheck /
64
+ 1024 /
65
+ 1024 /
66
+ timeSinceLastCheck
67
+ ).toFixed(2)
68
+ : '0';
69
+
70
+ bar.update(elapsed, {
71
+ fileSize: sizeMB,
72
+ rate: rateMB,
73
+ });
74
+
75
+ lastSize = stat.size;
76
+ lastCheckTime = now;
77
+ }
78
+ });
79
+
80
+ const monitorFiber = yield* Effect.fork(fileSizeMonitor);
81
+ yield* process.exitCode;
82
+ yield* Fiber.interrupt(monitorFiber);
83
+
84
+ bar.stop();
85
+ yield* Effect.log('Dump complete');
86
+ });
87
+
88
+ export const resolveDatabaseSource = (
89
+ sourceConfig?: DatabaseSourceConfig,
90
+ ): Effect.Effect<DatabaseSource, Effect.Effect.Error<typeof promptDatabaseSourceType>> =>
91
+ Effect.gen(function* () {
92
+ if (sourceConfig) {
93
+ if (sourceConfig.type === 'railway') {
94
+ return createRailwaySource({
95
+ projectId: sourceConfig.projectId,
96
+ environmentId: sourceConfig.environmentId,
97
+ serviceId: sourceConfig.serviceId,
98
+ });
99
+ }
100
+ return createDirectSource(sourceConfig.databaseUrl);
101
+ }
102
+
103
+ const sourceType = yield* promptDatabaseSourceType;
104
+
105
+ if (sourceType === 'railway') {
106
+ const config = yield* promptRailwayConfig;
107
+ return createRailwaySource(config);
108
+ }
109
+
110
+ const url = yield* promptDirectUrl;
111
+ return createDirectSource(url);
112
+ });
@@ -0,0 +1,80 @@
1
+ import * as platform from '@effect/platform';
2
+ import { Effect, Option, Schema } from 'effect';
3
+ import { FfServConfig } from './schema.js';
4
+
5
+ const DEFAULT_CONFIG_PATHS = ['.ff-serv.json', 'ff-serv.config.json'];
6
+
7
+ const tryLoadConfigFromPath = (
8
+ filePath: string,
9
+ ): Effect.Effect<
10
+ Option.Option<FfServConfig>,
11
+ never,
12
+ platform.FileSystem.FileSystem
13
+ > =>
14
+ Effect.gen(function* () {
15
+ const fs = yield* platform.FileSystem.FileSystem;
16
+
17
+ const exists = yield* fs
18
+ .exists(filePath)
19
+ .pipe(Effect.catchAll(() => Effect.succeed(false)));
20
+
21
+ if (!exists) {
22
+ return Option.none<FfServConfig>();
23
+ }
24
+
25
+ const contentResult = yield* fs
26
+ .readFileString(filePath)
27
+ .pipe(Effect.either);
28
+
29
+ if (contentResult._tag === 'Left') {
30
+ return Option.none<FfServConfig>();
31
+ }
32
+
33
+ const parseResult = yield* Effect.try(() =>
34
+ JSON.parse(contentResult.right),
35
+ ).pipe(Effect.either);
36
+
37
+ if (parseResult._tag === 'Left') {
38
+ yield* Effect.logWarning(`Failed to parse config file: ${filePath}`);
39
+ return Option.none<FfServConfig>();
40
+ }
41
+
42
+ const validateResult = yield* Schema.decodeUnknown(FfServConfig)(
43
+ parseResult.right,
44
+ ).pipe(Effect.either);
45
+
46
+ if (validateResult._tag === 'Left') {
47
+ yield* Effect.logWarning(`Invalid config schema in: ${filePath}`);
48
+ return Option.none<FfServConfig>();
49
+ }
50
+
51
+ return Option.some(validateResult.right);
52
+ });
53
+
54
+ export const loadConfig = (
55
+ customConfigPath?: string,
56
+ ): Effect.Effect<
57
+ Option.Option<FfServConfig>,
58
+ never,
59
+ platform.FileSystem.FileSystem
60
+ > =>
61
+ Effect.gen(function* () {
62
+ const pathsToTry = customConfigPath
63
+ ? [customConfigPath]
64
+ : [
65
+ ...(process.env.FF_SERV_CONFIG
66
+ ? [process.env.FF_SERV_CONFIG]
67
+ : []),
68
+ ...DEFAULT_CONFIG_PATHS,
69
+ ];
70
+
71
+ for (const configPath of pathsToTry) {
72
+ const result = yield* tryLoadConfigFromPath(configPath);
73
+ if (Option.isSome(result)) {
74
+ yield* Effect.log(`Loaded config from: ${configPath}`);
75
+ return Option.some(result.value);
76
+ }
77
+ }
78
+
79
+ return Option.none();
80
+ });
@@ -0,0 +1,34 @@
1
+ import { Schema } from 'effect';
2
+
3
+ const RailwaySourceConfig = Schema.Struct({
4
+ type: Schema.Literal('railway'),
5
+ projectId: Schema.String,
6
+ environmentId: Schema.String,
7
+ serviceId: Schema.String,
8
+ });
9
+
10
+ const DirectSourceConfig = Schema.Struct({
11
+ type: Schema.Literal('direct'),
12
+ databaseUrl: Schema.String,
13
+ });
14
+
15
+ export const DatabaseSourceConfig = Schema.Union(
16
+ RailwaySourceConfig,
17
+ DirectSourceConfig,
18
+ );
19
+
20
+ export const PullDatabaseConfig = Schema.Struct({
21
+ source: DatabaseSourceConfig,
22
+ targetDatabaseUrl: Schema.optional(Schema.String),
23
+ defaultDumpPath: Schema.optional(Schema.String),
24
+ });
25
+
26
+ export const FfServConfig = Schema.Struct({
27
+ pullDatabase: Schema.optional(PullDatabaseConfig),
28
+ });
29
+
30
+ export type FfServConfig = Schema.Schema.Type<typeof FfServConfig>;
31
+ export type PullDatabaseConfig = Schema.Schema.Type<typeof PullDatabaseConfig>;
32
+ export type DatabaseSourceConfig = Schema.Schema.Type<
33
+ typeof DatabaseSourceConfig
34
+ >;
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import * as cli from '@effect/cli';
3
+ import * as BunContext from '@effect/platform-bun/BunContext';
4
+ import * as BunRuntime from '@effect/platform-bun/BunRuntime';
5
+ import { Effect } from 'effect';
6
+ import { dbCommand } from './commands/db/index.js';
7
+ import pkg from '../../package.json' with { type: 'json' };
8
+
9
+ const rootCommand = cli.Command.make('ff-serv', {}, () =>
10
+ Effect.log('ff-serv CLI - Use --help for available commands'),
11
+ ).pipe(cli.Command.withSubcommands([dbCommand]));
12
+
13
+ const main = cli.Command.run(rootCommand, {
14
+ name: 'ff-serv',
15
+ version: pkg.version,
16
+ });
17
+
18
+ main(process.argv).pipe(Effect.provide(BunContext.layer), BunRuntime.runMain);