@livestore/cli 0.4.0-dev.9 → 0.5.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/__tests__/fixtures/mock-config.d.ts +63 -0
  3. package/dist/__tests__/fixtures/mock-config.d.ts.map +1 -0
  4. package/dist/__tests__/fixtures/mock-config.js +94 -0
  5. package/dist/__tests__/fixtures/mock-config.js.map +1 -0
  6. package/dist/__tests__/sync-operations.test.d.ts +2 -0
  7. package/dist/__tests__/sync-operations.test.d.ts.map +1 -0
  8. package/dist/__tests__/sync-operations.test.js +167 -0
  9. package/dist/__tests__/sync-operations.test.js.map +1 -0
  10. package/dist/bin.js +6 -5
  11. package/dist/bin.js.map +1 -1
  12. package/dist/cli.d.ts +2 -9
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +4 -5
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/__fixtures__/mcp-protocol-server.d.ts +2 -0
  17. package/dist/commands/__fixtures__/mcp-protocol-server.d.ts.map +1 -0
  18. package/dist/commands/__fixtures__/mcp-protocol-server.js +10 -0
  19. package/dist/commands/__fixtures__/mcp-protocol-server.js.map +1 -0
  20. package/dist/commands/import-export.d.ts +20 -0
  21. package/dist/commands/import-export.d.ts.map +1 -0
  22. package/dist/commands/import-export.js +135 -0
  23. package/dist/commands/import-export.js.map +1 -0
  24. package/dist/commands/mcp-coach.d.ts +21 -12
  25. package/dist/commands/mcp-coach.d.ts.map +1 -1
  26. package/dist/commands/mcp-coach.js +49 -53
  27. package/dist/commands/mcp-coach.js.map +1 -1
  28. package/dist/commands/mcp-protocols.d.ts +7 -0
  29. package/dist/commands/mcp-protocols.d.ts.map +1 -0
  30. package/dist/commands/mcp-protocols.js +12 -0
  31. package/dist/commands/mcp-protocols.js.map +1 -0
  32. package/dist/commands/mcp-protocols.test.d.ts +2 -0
  33. package/dist/commands/mcp-protocols.test.d.ts.map +1 -0
  34. package/dist/commands/mcp-protocols.test.js +60 -0
  35. package/dist/commands/mcp-protocols.test.js.map +1 -0
  36. package/dist/commands/mcp-stdio.test.d.ts +2 -0
  37. package/dist/commands/mcp-stdio.test.d.ts.map +1 -0
  38. package/dist/commands/mcp-stdio.test.js +79 -0
  39. package/dist/commands/mcp-stdio.test.js.map +1 -0
  40. package/dist/commands/mcp-tool-handlers.d.ts +6 -0
  41. package/dist/commands/mcp-tool-handlers.d.ts.map +1 -0
  42. package/dist/commands/{mcp-tools.js → mcp-tool-handlers.js} +80 -45
  43. package/dist/commands/mcp-tool-handlers.js.map +1 -0
  44. package/dist/commands/mcp-tools-defs.d.ts +134 -0
  45. package/dist/commands/mcp-tools-defs.d.ts.map +1 -0
  46. package/dist/commands/mcp-tools-defs.js +285 -0
  47. package/dist/commands/mcp-tools-defs.js.map +1 -0
  48. package/dist/commands/mcp-tools-defs.test.d.ts +2 -0
  49. package/dist/commands/mcp-tools-defs.test.d.ts.map +1 -0
  50. package/dist/commands/mcp-tools-defs.test.js +11 -0
  51. package/dist/commands/mcp-tools-defs.test.js.map +1 -0
  52. package/dist/commands/mcp.d.ts +1 -3
  53. package/dist/commands/mcp.d.ts.map +1 -1
  54. package/dist/commands/mcp.js +7 -5
  55. package/dist/commands/mcp.js.map +1 -1
  56. package/dist/commands/new-project.d.ts +22 -23
  57. package/dist/commands/new-project.d.ts.map +1 -1
  58. package/dist/commands/new-project.js +85 -44
  59. package/dist/commands/new-project.js.map +1 -1
  60. package/dist/mcp-content/architecture.d.ts +1 -1
  61. package/dist/mcp-content/architecture.d.ts.map +1 -1
  62. package/dist/mcp-content/architecture.js +3 -3
  63. package/dist/mcp-content/features.d.ts +1 -1
  64. package/dist/mcp-content/features.d.ts.map +1 -1
  65. package/dist/mcp-content/features.js +1 -1
  66. package/dist/mcp-content/getting-started.d.ts +1 -1
  67. package/dist/mcp-content/getting-started.d.ts.map +1 -1
  68. package/dist/mcp-content/getting-started.js +37 -37
  69. package/dist/mcp-content/overview.d.ts +1 -1
  70. package/dist/mcp-content/overview.d.ts.map +1 -1
  71. package/dist/mcp-content/overview.js +6 -6
  72. package/dist/mcp-content/schemas/blog.d.ts +1 -1
  73. package/dist/mcp-content/schemas/blog.d.ts.map +1 -1
  74. package/dist/mcp-content/schemas/blog.js +25 -25
  75. package/dist/mcp-content/schemas/ecommerce.d.ts +1 -1
  76. package/dist/mcp-content/schemas/ecommerce.d.ts.map +1 -1
  77. package/dist/mcp-content/schemas/ecommerce.js +67 -67
  78. package/dist/mcp-content/schemas/social.d.ts +1 -1
  79. package/dist/mcp-content/schemas/social.d.ts.map +1 -1
  80. package/dist/mcp-content/schemas/social.js +48 -48
  81. package/dist/mcp-content/schemas/todo.d.ts +1 -1
  82. package/dist/mcp-content/schemas/todo.d.ts.map +1 -1
  83. package/dist/mcp-content/schemas/todo.js +35 -35
  84. package/dist/mcp-runtime/runtime.d.ts +48 -0
  85. package/dist/mcp-runtime/runtime.d.ts.map +1 -0
  86. package/dist/mcp-runtime/runtime.js +107 -0
  87. package/dist/mcp-runtime/runtime.js.map +1 -0
  88. package/dist/module-loader.d.ts +22 -0
  89. package/dist/module-loader.d.ts.map +1 -0
  90. package/dist/module-loader.js +75 -0
  91. package/dist/module-loader.js.map +1 -0
  92. package/dist/package-manager.d.ts +38 -0
  93. package/dist/package-manager.d.ts.map +1 -0
  94. package/dist/package-manager.js +33 -0
  95. package/dist/package-manager.js.map +1 -0
  96. package/dist/package-manager.test.d.ts +2 -0
  97. package/dist/package-manager.test.d.ts.map +1 -0
  98. package/dist/package-manager.test.js +57 -0
  99. package/dist/package-manager.test.js.map +1 -0
  100. package/dist/sync-operations.d.ts +115 -0
  101. package/dist/sync-operations.d.ts.map +1 -0
  102. package/dist/sync-operations.js +178 -0
  103. package/dist/sync-operations.js.map +1 -0
  104. package/package.json +57 -15
  105. package/src/__tests__/fixtures/mock-config.ts +112 -0
  106. package/src/__tests__/sync-operations.test.ts +218 -0
  107. package/src/bin.ts +8 -6
  108. package/src/cli.ts +5 -6
  109. package/src/commands/__fixtures__/mcp-protocol-server.ts +12 -0
  110. package/src/commands/import-export.ts +283 -0
  111. package/src/commands/mcp-coach.ts +76 -75
  112. package/src/commands/mcp-protocols.test.ts +76 -0
  113. package/src/commands/mcp-protocols.ts +12 -0
  114. package/src/commands/mcp-stdio.test.ts +93 -0
  115. package/src/commands/{mcp-tools.ts → mcp-tool-handlers.ts} +100 -61
  116. package/src/commands/mcp-tools-defs.test.ts +13 -0
  117. package/src/commands/mcp-tools-defs.ts +320 -0
  118. package/src/commands/mcp.ts +10 -6
  119. package/src/commands/new-project.ts +124 -57
  120. package/src/mcp-content/architecture.ts +3 -3
  121. package/src/mcp-content/features.ts +1 -1
  122. package/src/mcp-content/getting-started.ts +37 -37
  123. package/src/mcp-content/overview.ts +6 -6
  124. package/src/mcp-content/schemas/blog.ts +25 -25
  125. package/src/mcp-content/schemas/ecommerce.ts +67 -67
  126. package/src/mcp-content/schemas/social.ts +48 -48
  127. package/src/mcp-content/schemas/todo.ts +35 -35
  128. package/src/mcp-runtime/runtime.ts +147 -0
  129. package/src/module-loader.ts +94 -0
  130. package/src/package-manager.test.ts +66 -0
  131. package/src/package-manager.ts +44 -0
  132. package/src/sync-operations.ts +360 -0
  133. package/dist/commands/mcp-tools.d.ts +0 -58
  134. package/dist/commands/mcp-tools.d.ts.map +0 -1
  135. package/dist/commands/mcp-tools.js.map +0 -1
  136. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,112 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath, pathToFileURL } from 'node:url'
3
+
4
+ import { Events, makeSchema, State } from '@livestore/common/schema'
5
+ import type { MockSyncBackend } from '@livestore/common/sync'
6
+ import { EventFactory } from '@livestore/common/testing'
7
+ import { Effect, FileSystem, type Queue, Schema } from '@livestore/utils/effect'
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
10
+
11
+ class DynamicImportError extends Schema.TaggedError<DynamicImportError>()('DynamicImportError', {
12
+ cause: Schema.Defect(),
13
+ path: Schema.String,
14
+ }) {}
15
+
16
+ // Use package-local temp directory for test config files to ensure proper module resolution
17
+ const tmpDir = path.join(__dirname, '.tmp-test-configs')
18
+
19
+ const items = State.SQLite.table({
20
+ name: 'items',
21
+ columns: {
22
+ id: State.SQLite.text({ primaryKey: true }),
23
+ title: State.SQLite.text({ default: '', nullable: false }),
24
+ },
25
+ })
26
+
27
+ export const events = {
28
+ itemAdded: Events.synced({
29
+ name: 'itemAdded',
30
+ schema: Schema.Struct({ id: Schema.String, title: Schema.String }),
31
+ }),
32
+ }
33
+
34
+ const materializers = State.SQLite.materializers(events, {
35
+ itemAdded: ({ id, title }) => items.insert({ id, title }),
36
+ })
37
+
38
+ const state = State.SQLite.makeState({
39
+ tables: { items },
40
+ materializers,
41
+ })
42
+
43
+ export const schema = makeSchema({ state, events })
44
+
45
+ const schemaModuleUrl = pathToFileURL(path.join(__dirname, 'mock-config.ts')).href
46
+
47
+ /** Generates a per-test config module exporting schema, a mock backend, and connection event taps. */
48
+ const makeTempConfig = () => {
49
+ const moduleSource = `
50
+ import { schema } from ${JSON.stringify(schemaModuleUrl)}
51
+ import { makeMockSyncBackend } from '@livestore/common/sync'
52
+ import { Effect, Queue } from '@livestore/utils/effect'
53
+
54
+ export const mockBackend = await Effect.runPromise(Effect.scoped(makeMockSyncBackend({ startConnected: true })))
55
+ export const connectionEvents = await Effect.runPromise(Queue.make<'connect' | 'disconnect'>())
56
+
57
+ export { schema }
58
+
59
+ export const syncBackend = (_args) =>
60
+ mockBackend.makeSyncBackend.pipe(
61
+ Effect.tap(() => Queue.offer(connectionEvents, 'connect')),
62
+ Effect.map((backend) => {
63
+ const disconnect = backend.disconnect ?? Effect.void
64
+ return {
65
+ ...backend,
66
+ disconnect: disconnect.pipe(Effect.tap(() => Queue.offer(connectionEvents, 'disconnect'))),
67
+ }
68
+ }),
69
+ )
70
+ `
71
+
72
+ return moduleSource
73
+ }
74
+
75
+ /**
76
+ * Creates a temporary config module (schema + mock backend) and cleans it up afterwards.
77
+ * Returns the module path plus handles to the backend and connection event mailbox, keeping lifecycle assertions local to each test.
78
+ */
79
+ export const useMockConfig = Effect.acquireRelease(
80
+ Effect.gen(function* () {
81
+ const fs = yield* FileSystem.FileSystem
82
+
83
+ yield* fs.makeDirectory(tmpDir, { recursive: true })
84
+
85
+ const tempPath = path.join(tmpDir, `mock-config-${Date.now()}-${Math.random().toString(16).slice(2)}.ts`)
86
+ const moduleSource = makeTempConfig()
87
+
88
+ yield* fs.writeFileString(tempPath, moduleSource)
89
+
90
+ const mod = (yield* Effect.tryPromise({
91
+ try: () => import(pathToFileURL(tempPath).href),
92
+ catch: (cause) => new DynamicImportError({ cause, path: tempPath }),
93
+ })) as {
94
+ mockBackend: MockSyncBackend
95
+ connectionEvents: Queue.Queue<'connect' | 'disconnect'>
96
+ }
97
+
98
+ return { configPath: tempPath, mockBackend: mod.mockBackend, connectionEvents: mod.connectionEvents }
99
+ }),
100
+ ({ configPath }) =>
101
+ Effect.gen(function* () {
102
+ const fs = yield* FileSystem.FileSystem
103
+ yield* fs.remove(configPath, { recursive: false }).pipe(Effect.catch(() => Effect.void))
104
+ }),
105
+ )
106
+
107
+ export const makeEventFactory = () =>
108
+ EventFactory.makeFactory(events)({
109
+ client: EventFactory.clientIdentity('cli-test-client'),
110
+ startSeq: 1,
111
+ initialParent: 'root',
112
+ })
@@ -0,0 +1,218 @@
1
+ import { Effect, FetchHttpClient, Layer, Queue, Stream } from '@livestore/utils/effect'
2
+ import { PlatformNode } from '@livestore/utils/node'
3
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
4
+ import { expect } from 'vitest'
5
+
6
+ import { pullEventsFromSyncBackend, pushEventsToSyncBackend } from '../sync-operations.ts'
7
+ import { makeEventFactory, useMockConfig } from './fixtures/mock-config.ts'
8
+
9
+ const baseLayer = Layer.mergeAll(PlatformNode.NodeFileSystem.layer, FetchHttpClient.layer)
10
+ const withTestCtx = Vitest.makeWithTestCtx({ makeLayer: () => baseLayer })
11
+
12
+ /** Each test acquires its own temporary config module via useMockConfig, avoiding shared state. */
13
+ Vitest.describe('sync-operations', { timeout: 10_000 }, () => {
14
+ const storeId = 'test-store'
15
+ const clientId = 'test-client'
16
+
17
+ /** Collects the connect + disconnect lifecycle emitted by the mock sync backend. */
18
+ const expectConnectLifecycle = (
19
+ mailbox: Queue.Queue<'connect' | 'disconnect'>,
20
+ ): Effect.Effect<ReadonlyArray<'connect' | 'disconnect'>> =>
21
+ Stream.fromQueue(mailbox).pipe(Stream.take(2), Stream.runCollect)
22
+
23
+ Vitest.live('exports events and releases the backend connection', (test: Vitest.TestContext) =>
24
+ Effect.gen(function* () {
25
+ const { mockBackend, connectionEvents, configPath } = yield* useMockConfig
26
+ const factory = makeEventFactory()
27
+
28
+ const batch = [
29
+ factory.itemAdded.next({ id: 'e1', title: 'First' }),
30
+ factory.itemAdded.next({ id: 'e2', title: 'Second' }),
31
+ ]
32
+
33
+ yield* mockBackend.advance(...batch)
34
+
35
+ const result = yield* pullEventsFromSyncBackend({
36
+ configPath,
37
+ storeId,
38
+ clientId,
39
+ })
40
+
41
+ expect(result.eventCount).toBe(2)
42
+ expect(result.data.events).toHaveLength(2)
43
+
44
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
45
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
46
+ }).pipe(withTestCtx(test)),
47
+ )
48
+
49
+ Vitest.live('fails import when backend is not empty', (test: Vitest.TestContext) =>
50
+ Effect.gen(function* () {
51
+ const { mockBackend, connectionEvents, configPath } = yield* useMockConfig
52
+ const factory = makeEventFactory()
53
+
54
+ yield* mockBackend.advance(factory.itemAdded.next({ id: 'existing', title: 'Present' }))
55
+
56
+ const importBatch = [
57
+ factory.itemAdded.next({ id: 'incoming-1', title: 'Incoming' }),
58
+ factory.itemAdded.next({ id: 'incoming-2', title: 'Incoming 2' }),
59
+ ]
60
+
61
+ const result = yield* pushEventsToSyncBackend({
62
+ configPath,
63
+ storeId,
64
+ clientId,
65
+ data: {
66
+ version: 1,
67
+ storeId,
68
+ exportedAt: new Date().toISOString(),
69
+ eventCount: importBatch.length,
70
+ events: importBatch,
71
+ },
72
+ force: false,
73
+ dryRun: false,
74
+ }).pipe(Effect.result)
75
+
76
+ expect(result._tag).toBe('Failure')
77
+ if (result._tag === 'Failure') {
78
+ expect(result.failure._tag).toBe('ImportError')
79
+ }
80
+
81
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
82
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
83
+ }).pipe(withTestCtx(test)),
84
+ )
85
+
86
+ Vitest.live('supports dry-run import and releases backend', (test: Vitest.TestContext) =>
87
+ Effect.gen(function* () {
88
+ const { configPath, connectionEvents } = yield* useMockConfig
89
+ const factory = makeEventFactory()
90
+ const importBatch = [factory.itemAdded.next({ id: 'dry-run', title: 'Simulated' })]
91
+
92
+ const result = yield* pushEventsToSyncBackend({
93
+ configPath,
94
+ storeId,
95
+ clientId,
96
+ data: {
97
+ version: 1,
98
+ storeId,
99
+ exportedAt: new Date().toISOString(),
100
+ eventCount: importBatch.length,
101
+ events: importBatch,
102
+ },
103
+ force: false,
104
+ dryRun: true,
105
+ })
106
+
107
+ expect(result.dryRun).toBe(true)
108
+ expect(result.eventCount).toBe(importBatch.length)
109
+
110
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
111
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
112
+ }).pipe(withTestCtx(test)),
113
+ )
114
+
115
+ Vitest.live('imports events into empty backend with progress and batching', (test: Vitest.TestContext) =>
116
+ Effect.gen(function* () {
117
+ const { mockBackend, configPath, connectionEvents } = yield* useMockConfig
118
+ const factory = makeEventFactory()
119
+ const importBatch = Array.from({ length: 120 }, (_, idx) =>
120
+ factory.itemAdded.next({ id: `id-${idx + 1}`, title: `Item ${idx + 1}` }),
121
+ )
122
+
123
+ const progress: Array<{ pushed: number; total: number }> = []
124
+
125
+ const result = yield* pushEventsToSyncBackend({
126
+ configPath,
127
+ storeId,
128
+ clientId,
129
+ data: {
130
+ version: 1,
131
+ storeId,
132
+ exportedAt: new Date().toISOString(),
133
+ eventCount: importBatch.length,
134
+ events: importBatch,
135
+ },
136
+ force: false,
137
+ dryRun: false,
138
+ onProgress: (pushed, total) => Effect.sync(() => progress.push({ pushed, total })),
139
+ })
140
+
141
+ expect(result.dryRun).toBe(false)
142
+ expect(result.eventCount).toBe(importBatch.length)
143
+ expect(progress).toEqual([
144
+ { pushed: 100, total: 120 },
145
+ { pushed: 120, total: 120 },
146
+ ])
147
+
148
+ const pushedEvents = yield* mockBackend.pushedEvents.pipe(Stream.take(importBatch.length), Stream.runCollect)
149
+ expect(pushedEvents.map((event) => event.seqNum)).toHaveLength(importBatch.length)
150
+
151
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
152
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
153
+ }).pipe(withTestCtx(test)),
154
+ )
155
+
156
+ Vitest.live('allows force import on store ID mismatch', (test: Vitest.TestContext) =>
157
+ Effect.gen(function* () {
158
+ const { mockBackend, configPath, connectionEvents } = yield* useMockConfig
159
+ const factory = makeEventFactory()
160
+ const importBatch = [factory.itemAdded.next({ id: 'force-1', title: 'Force' })]
161
+
162
+ const result = yield* pushEventsToSyncBackend({
163
+ configPath,
164
+ storeId,
165
+ clientId,
166
+ data: {
167
+ version: 1,
168
+ storeId: 'different-store',
169
+ exportedAt: new Date().toISOString(),
170
+ eventCount: importBatch.length,
171
+ events: importBatch,
172
+ },
173
+ force: true,
174
+ dryRun: false,
175
+ })
176
+
177
+ expect(result.dryRun).toBe(false)
178
+ expect(result.eventCount).toBe(importBatch.length)
179
+
180
+ const pushedEvents = yield* mockBackend.pushedEvents.pipe(Stream.take(1), Stream.runCollect)
181
+ expect(pushedEvents).toHaveLength(1)
182
+
183
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
184
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
185
+ }).pipe(withTestCtx(test)),
186
+ )
187
+
188
+ Vitest.live('rejects store ID mismatch without force', (test: Vitest.TestContext) =>
189
+ Effect.gen(function* () {
190
+ const { configPath, connectionEvents } = yield* useMockConfig
191
+ const factory = makeEventFactory()
192
+ const importBatch = [factory.itemAdded.next({ id: 'mismatch', title: 'Mismatch' })]
193
+
194
+ const result = yield* pushEventsToSyncBackend({
195
+ configPath,
196
+ storeId,
197
+ clientId,
198
+ data: {
199
+ version: 1,
200
+ storeId: 'other-store',
201
+ exportedAt: new Date().toISOString(),
202
+ eventCount: importBatch.length,
203
+ events: importBatch,
204
+ },
205
+ force: false,
206
+ dryRun: false,
207
+ }).pipe(Effect.result)
208
+
209
+ expect(result._tag).toBe('Failure')
210
+ if (result._tag === 'Failure') {
211
+ expect(result.failure._tag).toBe('ImportError')
212
+ }
213
+
214
+ const lifecycle = yield* expectConnectLifecycle(connectionEvents)
215
+ expect(lifecycle).toEqual(['connect', 'disconnect'])
216
+ }).pipe(withTestCtx(test)),
217
+ )
218
+ })
package/src/bin.ts CHANGED
@@ -1,24 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { liveStoreVersion } from '@livestore/common'
4
- import { Console, Effect, FetchHttpClient, Layer, Logger, LogLevel } from '@livestore/utils/effect'
4
+ import { Console, Effect, FetchHttpClient, Layer, Logger, References } from '@livestore/utils/effect'
5
5
  import { Cli, PlatformNode } from '@livestore/utils/node'
6
+
6
7
  import { command } from './cli.ts'
7
8
 
8
9
  const cli = Cli.Command.run(command, {
9
- name: 'livestore',
10
10
  version: liveStoreVersion,
11
11
  })
12
12
 
13
- const showExperimentalWarning = Console.log('⚠️ Warning: LiveStore CLI is experimental and under active development')
13
+ const showExperimentalWarning = Console.error('⚠️ Warning: LiveStore CLI is experimental and under active development')
14
14
 
15
15
  const layer = Layer.mergeAll(
16
- PlatformNode.NodeContext.layer,
16
+ PlatformNode.NodeServices.layer,
17
17
  FetchHttpClient.layer,
18
- Logger.minimumLogLevel(LogLevel.Info),
18
+ Layer.succeed(References.MinimumLogLevel, 'Info'),
19
+ // Keep stdout available for command data and protocol transports such as MCP stdio.
20
+ Layer.succeed(Logger.LogToStderr, true),
19
21
  )
20
22
 
21
23
  Effect.gen(function* () {
22
24
  yield* showExperimentalWarning
23
- return yield* cli(process.argv)
25
+ return yield* cli
24
26
  }).pipe(Effect.provide(layer), PlatformNode.NodeRuntime.runMain)
package/src/cli.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { Cli } from '@livestore/utils/node'
2
+
3
+ import { syncCommand } from './commands/import-export.ts'
2
4
  import { mcpCommand } from './commands/mcp.ts'
3
- import { newProjectCommand } from './commands/new-project.ts'
5
+ import { createCommand } from './commands/new-project.ts'
4
6
 
5
7
  export const command = Cli.Command.make('livestore', {
6
- verbose: Cli.Options.boolean('verbose').pipe(Cli.Options.withDefault(false)),
7
- }).pipe(Cli.Command.withSubcommands([mcpCommand, newProjectCommand]))
8
-
9
- if (import.meta.main) {
10
- }
8
+ verbose: Cli.Flag.boolean('verbose').pipe(Cli.Flag.withDefault(false)),
9
+ }).pipe(Cli.Command.withSubcommands([mcpCommand, createCommand, syncCommand]))
@@ -0,0 +1,12 @@
1
+ import { McpServer } from 'effect/unstable/ai'
2
+
3
+ import { Effect, Layer } from '@livestore/utils/effect'
4
+ import { PlatformNode } from '@livestore/utils/node'
5
+
6
+ import { supportedMcpProtocols } from '../mcp-protocols.ts'
7
+
8
+ McpServer.layerStdio({
9
+ name: 'livestore-mcp-protocol-test',
10
+ version: '0.0.0',
11
+ protocols: supportedMcpProtocols,
12
+ }).pipe(Layer.provide(PlatformNode.NodeStdio.layer), Layer.launch, Effect.scoped, PlatformNode.NodeRuntime.runMain)
@@ -0,0 +1,283 @@
1
+ import path from 'node:path'
2
+
3
+ import type { UnknownError } from '@livestore/common'
4
+ import { Console, Effect, FileSystem, type HttpClient, Schema, type Scope } from '@livestore/utils/effect'
5
+ import { Cli } from '@livestore/utils/node'
6
+
7
+ import * as SyncOps from '../sync-operations.ts'
8
+
9
+ const jsonStringifyPretty = (value: SyncOps.ExportFile) => JSON.stringify(value, null, 2)
10
+ const jsonParse = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))
11
+
12
+ const LARGE_EVENT_WARNING_THRESHOLD = 100_000
13
+
14
+ /**
15
+ * Export events from the sync backend to a JSON file.
16
+ */
17
+ const exportEvents = ({
18
+ configPath,
19
+ storeId,
20
+ clientId,
21
+ outputPath,
22
+ }: {
23
+ configPath: string
24
+ storeId: string
25
+ clientId: string
26
+ outputPath: string
27
+ }): Effect.Effect<
28
+ void,
29
+ SyncOps.ExportError | SyncOps.ConnectionError | UnknownError,
30
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
31
+ > =>
32
+ Effect.gen(function* () {
33
+ yield* Console.log(`Connecting to sync backend...`)
34
+
35
+ const result = yield* SyncOps.pullEventsFromSyncBackend({ configPath, storeId, clientId })
36
+
37
+ yield* Console.log(`✓ Connected to sync backend: ${result.backendName}`)
38
+ yield* Console.log(`Pulled ${result.eventCount} events`)
39
+ if (result.eventCount > LARGE_EVENT_WARNING_THRESHOLD) {
40
+ yield* Console.log(
41
+ `Warning: exporting ${result.eventCount} events may consume significant memory. Consider exporting on a machine with enough RAM.`,
42
+ )
43
+ }
44
+
45
+ const fs = yield* FileSystem.FileSystem
46
+ const absOutputPath = path.isAbsolute(outputPath) === true ? outputPath : path.resolve(process.cwd(), outputPath)
47
+
48
+ yield* fs.writeFileString(absOutputPath, jsonStringifyPretty(result.data)).pipe(
49
+ Effect.mapError(
50
+ (cause) =>
51
+ new SyncOps.ExportError({
52
+ cause,
53
+ note: `Failed to write export file: ${String(cause)}`,
54
+ }),
55
+ ),
56
+ )
57
+
58
+ yield* Console.log(`Exported ${result.eventCount} events to ${absOutputPath}`)
59
+ }).pipe(Effect.withSpan('cli:export'))
60
+
61
+ /**
62
+ * Import events from a JSON file to the sync backend.
63
+ */
64
+ const importEvents = ({
65
+ configPath,
66
+ storeId,
67
+ clientId,
68
+ inputPath,
69
+ force,
70
+ dryRun,
71
+ }: {
72
+ configPath: string
73
+ storeId: string
74
+ clientId: string
75
+ inputPath: string
76
+ force: boolean
77
+ dryRun: boolean
78
+ }): Effect.Effect<
79
+ void,
80
+ SyncOps.ImportError | SyncOps.ConnectionError | UnknownError,
81
+ FileSystem.FileSystem | HttpClient.HttpClient | Scope.Scope
82
+ > =>
83
+ Effect.gen(function* () {
84
+ const fs = yield* FileSystem.FileSystem
85
+ const absInputPath = path.isAbsolute(inputPath) === true ? inputPath : path.resolve(process.cwd(), inputPath)
86
+
87
+ const exists = yield* fs.exists(absInputPath).pipe(
88
+ Effect.mapError(
89
+ (cause) =>
90
+ new SyncOps.ImportError({
91
+ cause,
92
+ note: `Failed to check file existence: ${String(cause)}`,
93
+ }),
94
+ ),
95
+ )
96
+ if (exists === false) {
97
+ return yield* new SyncOps.ImportError({
98
+ cause: new Error(`File not found: ${absInputPath}`),
99
+ note: `Import file does not exist at ${absInputPath}`,
100
+ })
101
+ }
102
+
103
+ yield* Console.log(`Reading import file...`)
104
+
105
+ const fileContent = yield* fs.readFileString(absInputPath).pipe(
106
+ Effect.mapError(
107
+ (cause) =>
108
+ new SyncOps.ImportError({
109
+ cause,
110
+ note: `Failed to read import file: ${String(cause)}`,
111
+ }),
112
+ ),
113
+ )
114
+
115
+ const parsedContent = yield* Effect.try({
116
+ try: () => jsonParse(fileContent),
117
+ catch: (error) =>
118
+ new SyncOps.ImportError({
119
+ cause: new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`),
120
+ note: `Invalid JSON in import file: ${error instanceof Error ? error.message : String(error)}`,
121
+ }),
122
+ })
123
+
124
+ /** Validate export file format before proceeding */
125
+ const validation = yield* SyncOps.validateExportData({ data: parsedContent, targetStoreId: storeId })
126
+
127
+ if (validation.storeIdMismatch === true) {
128
+ if (force !== true) {
129
+ return yield* new SyncOps.ImportError({
130
+ cause: new Error(`Store ID mismatch: file has '${validation.sourceStoreId}', expected '${storeId}'`),
131
+ note: `The export file was created for a different store. Use --force to import anyway.`,
132
+ })
133
+ }
134
+ yield* Console.log(
135
+ `Store ID mismatch: file has '${validation.sourceStoreId}', importing to '${storeId}' (--force)`,
136
+ )
137
+ }
138
+
139
+ yield* Console.log(`Found ${validation.eventCount} events in export file`)
140
+ if (validation.eventCount > LARGE_EVENT_WARNING_THRESHOLD) {
141
+ yield* Console.log(
142
+ `Warning: importing ${validation.eventCount} events may consume significant memory. Ensure the machine has enough RAM.`,
143
+ )
144
+ }
145
+
146
+ if (dryRun === true) {
147
+ yield* Console.log(`Dry run - validating import file...`)
148
+ yield* Console.log(`Dry run complete. ${validation.eventCount} events would be imported.`)
149
+ return
150
+ }
151
+
152
+ yield* Console.log(`Checking for existing events...`)
153
+
154
+ yield* Console.log(`Connecting to sync backend...`)
155
+ yield* Console.log(`Pushing events to sync backend...`)
156
+
157
+ const result = yield* SyncOps.pushEventsToSyncBackend({
158
+ configPath,
159
+ storeId,
160
+ clientId,
161
+ data: parsedContent,
162
+ force,
163
+ dryRun: false,
164
+ onProgress: (pushed, total) => Console.log(` Pushed ${pushed}/${total} events`),
165
+ })
166
+
167
+ yield* Console.log(`✓ Connected to sync backend: ${result.backendName ?? 'unknown'}`)
168
+ yield* Console.log(`Successfully imported ${result.eventCount} events`)
169
+ }).pipe(Effect.withSpan('cli:import'))
170
+
171
+ export const exportCommand = Cli.Command.make(
172
+ 'export',
173
+ {
174
+ config: Cli.Flag.string('config').pipe(
175
+ Cli.Flag.withAlias('c'),
176
+ Cli.Flag.withDescription('Path to the config module that exports schema and syncBackend'),
177
+ ),
178
+ storeId: Cli.Flag.string('store-id').pipe(
179
+ Cli.Flag.withAlias('i'),
180
+ Cli.Flag.withDescription('Store identifier'),
181
+ ),
182
+ clientId: Cli.Flag.string('client-id').pipe(
183
+ Cli.Flag.withDefault('cli-export'),
184
+ Cli.Flag.withDescription('Client identifier for the sync connection'),
185
+ ),
186
+ output: Cli.Argument.string('file').pipe(Cli.Argument.withDescription('Output JSON file path')),
187
+ },
188
+ Effect.fn(function* ({
189
+ config,
190
+ storeId,
191
+ clientId,
192
+ output,
193
+ }: {
194
+ config: string
195
+ storeId: string
196
+ clientId: string
197
+ output: string
198
+ }) {
199
+ yield* Console.log(`Exporting events from LiveStore...`)
200
+ yield* Console.log(` Config: ${config}`)
201
+ yield* Console.log(` Store ID: ${storeId}`)
202
+ yield* Console.log(` Output: ${output}`)
203
+ yield* Console.log('')
204
+
205
+ yield* exportEvents({
206
+ configPath: config,
207
+ storeId,
208
+ clientId,
209
+ outputPath: output,
210
+ }).pipe(Effect.scoped)
211
+ }),
212
+ ).pipe(
213
+ Cli.Command.withDescription(
214
+ 'Export all events from the sync backend to a JSON file. Useful for backup and migration.',
215
+ ),
216
+ )
217
+
218
+ export const importCommand = Cli.Command.make(
219
+ 'import',
220
+ {
221
+ config: Cli.Flag.string('config').pipe(
222
+ Cli.Flag.withAlias('c'),
223
+ Cli.Flag.withDescription('Path to the config module that exports schema and syncBackend'),
224
+ ),
225
+ storeId: Cli.Flag.string('store-id').pipe(
226
+ Cli.Flag.withAlias('i'),
227
+ Cli.Flag.withDescription('Store identifier'),
228
+ ),
229
+ clientId: Cli.Flag.string('client-id').pipe(
230
+ Cli.Flag.withDefault('cli-import'),
231
+ Cli.Flag.withDescription('Client identifier for the sync connection'),
232
+ ),
233
+ force: Cli.Flag.boolean('force').pipe(
234
+ Cli.Flag.withAlias('f'),
235
+ Cli.Flag.withDefault(false),
236
+ Cli.Flag.withDescription('Force import even if store ID does not match'),
237
+ ),
238
+ dryRun: Cli.Flag.boolean('dry-run').pipe(
239
+ Cli.Flag.withDefault(false),
240
+ Cli.Flag.withDescription('Validate the import file without actually importing'),
241
+ ),
242
+ input: Cli.Argument.string('file').pipe(Cli.Argument.withDescription('Input JSON file to import')),
243
+ },
244
+ Effect.fn(function* ({
245
+ config,
246
+ storeId,
247
+ clientId,
248
+ force,
249
+ dryRun,
250
+ input,
251
+ }: {
252
+ config: string
253
+ storeId: string
254
+ clientId: string
255
+ force: boolean
256
+ dryRun: boolean
257
+ input: string
258
+ }) {
259
+ yield* Console.log(`Importing events to LiveStore...`)
260
+ yield* Console.log(` Config: ${config}`)
261
+ yield* Console.log(` Store ID: ${storeId}`)
262
+ yield* Console.log(` Input: ${input}`)
263
+ if (force === true) yield* Console.log(` Force: enabled`)
264
+ if (dryRun === true) yield* Console.log(` Dry run: enabled`)
265
+ yield* Console.log('')
266
+
267
+ yield* importEvents({
268
+ configPath: config,
269
+ storeId,
270
+ clientId,
271
+ inputPath: input,
272
+ force,
273
+ dryRun,
274
+ }).pipe(Effect.scoped)
275
+ }),
276
+ ).pipe(
277
+ Cli.Command.withDescription('Import events from a JSON file to the sync backend. The sync backend must be empty.'),
278
+ )
279
+
280
+ export const syncCommand = Cli.Command.make('sync').pipe(
281
+ Cli.Command.withSubcommands([exportCommand, importCommand]),
282
+ Cli.Command.withDescription('Import and export events from the sync backend'),
283
+ )