@dxos/cli-util 0.0.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,8 @@
1
+ MIT License
2
+ Copyright (c) 2025 DXOS
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+
6
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @dxos/cli-util
2
+
3
+ Shared CLI utilities for DXOS CLI commands and plugins.
4
+
5
+ This package provides common utilities that can be used by both the CLI and CLI plugins:
6
+
7
+ - **CommandConfig**: Effect service for CLI command configuration (json, verbose, profile, logLevel)
8
+ - **Print utilities**: Functions for pretty-printing documents with ANSI colors
9
+ - **FormBuilder**: Builder for creating formatted form output
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { CommandConfig } from '@dxos/cli-util/services';
15
+ import { print, FormBuilder } from '@dxos/cli-util/util';
16
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@dxos/cli-util",
3
+ "version": "0.0.0",
4
+ "description": "Shared CLI utilities for DXOS CLI commands and plugins",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "license": "MIT",
8
+ "author": "DXOS.org",
9
+ "sideEffects": false,
10
+ "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "source": "./src/index.ts",
14
+ "types": "./dist/types/src/index.d.ts",
15
+ "node": "./dist/lib/node-esm/index.mjs"
16
+ },
17
+ "./testing": {
18
+ "source": "./src/testing/index.ts",
19
+ "types": "./dist/types/src/testing/index.d.ts",
20
+ "node": "./dist/lib/node-esm/testing/index.mjs"
21
+ }
22
+ },
23
+ "types": "dist/types/src/index.d.ts",
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "dependencies": {
29
+ "@effect/cli": "0.72.1",
30
+ "@effect/printer": "0.47.0",
31
+ "@effect/printer-ansi": "0.47.0",
32
+ "@dxos/client": "0.8.3",
33
+ "@dxos/effect": "0.8.3",
34
+ "@dxos/echo": "0.8.3",
35
+ "@dxos/errors": "0.8.3",
36
+ "@dxos/log": "0.8.3",
37
+ "@dxos/functions": "0.8.3",
38
+ "@dxos/util": "0.8.3",
39
+ "@dxos/debug": "0.8.3",
40
+ "@dxos/protocols": "0.8.3"
41
+ },
42
+ "devDependencies": {
43
+ "effect": "3.19.11",
44
+ "typescript": "^5.9.3",
45
+ "vitest": "3.2.4"
46
+ },
47
+ "peerDependencies": {
48
+ "effect": "3.19.11"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './services';
6
+ export * from './util';
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Context from 'effect/Context';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+
9
+ export class CommandConfig extends Context.Tag('CommandConfig')<
10
+ CommandConfig,
11
+ {
12
+ json: boolean;
13
+ verbose: boolean;
14
+ profile: string;
15
+ logLevel: string;
16
+ }
17
+ >() {
18
+ static isJson: Effect.Effect<boolean, never, CommandConfig> = CommandConfig.pipe(Effect.map((config) => config.json));
19
+ static isVerbose: Effect.Effect<boolean, never, CommandConfig> = CommandConfig.pipe(
20
+ Effect.map((config) => config.verbose),
21
+ );
22
+
23
+ static layerTest = Layer.succeed(CommandConfig, {
24
+ json: true,
25
+ verbose: false,
26
+ profile: 'default',
27
+ logLevel: 'info',
28
+ });
29
+ }
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './command-config';
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './test-console';
6
+ export * from './test-layer';
@@ -0,0 +1,88 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { inspect } from 'node:util';
6
+
7
+ import * as Console from 'effect/Console';
8
+ import * as Context from 'effect/Context';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+
12
+ function logToString(...args: any[]): string {
13
+ return args
14
+ .map((arg) => {
15
+ if (typeof arg === 'string') {
16
+ return arg;
17
+ }
18
+
19
+ return inspect(arg, { colors: false, depth: null });
20
+ })
21
+ .join(' ');
22
+ }
23
+
24
+ class TestConsoleService {
25
+ private _logs: Array<{ level: string; args: unknown; message: string }> = [];
26
+
27
+ readonly console: Console.Console;
28
+
29
+ constructor(console: Console.Console) {
30
+ const pusher =
31
+ (level: string) =>
32
+ (...args: readonly any[]) => {
33
+ this._logs.push({ level, args, message: logToString(...args) });
34
+ };
35
+
36
+ this.console = {
37
+ ...console,
38
+ log: (...args: readonly any[]) => Effect.sync(() => pusher('log')(...args)),
39
+ };
40
+ }
41
+
42
+ get logs() {
43
+ return [...this._logs];
44
+ }
45
+
46
+ clear() {
47
+ this._logs.length = 0;
48
+ }
49
+ }
50
+
51
+ // TODO(burdon): We could have a single namespace "Testing" with all test utilities.
52
+ export namespace TestConsole {
53
+ export class TestConsole extends Context.Tag('TestConsole')<TestConsole, TestConsoleService>() {}
54
+
55
+ /**
56
+ * Extract JSON string from log arguments.
57
+ * Handles both array and string argument formats.
58
+ */
59
+ export const extractJsonString = (log: { args: unknown }): string => {
60
+ if (Array.isArray(log.args) && log.args.length > 0) {
61
+ return String(log.args[0]);
62
+ }
63
+ return log.args as string;
64
+ };
65
+
66
+ /**
67
+ * Parse JSON from log arguments.
68
+ * Handles both array and string argument formats.
69
+ */
70
+ export const parseJson = <T = unknown>(log: { args: unknown }): T => {
71
+ const jsonString = extractJsonString(log);
72
+ return JSON.parse(jsonString) as T;
73
+ };
74
+
75
+ const testConsole = Layer.effect(
76
+ TestConsole,
77
+ Effect.gen(function* () {
78
+ return new TestConsoleService(yield* Effect.console);
79
+ }),
80
+ );
81
+
82
+ const setConsole = Effect.gen(function* () {
83
+ const { console } = yield* TestConsole;
84
+ return Console.setConsole(console);
85
+ }).pipe(Layer.unwrapEffect);
86
+
87
+ export const layer = Layer.provideMerge(setConsole, testConsole);
88
+ }
@@ -0,0 +1,19 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Function from 'effect/Function';
6
+ import * as Layer from 'effect/Layer';
7
+
8
+ import { ClientService, ConfigService } from '@dxos/client';
9
+
10
+ import { CommandConfig } from '../services';
11
+
12
+ import { TestConsole } from './test-console';
13
+
14
+ export const TestLayer = Function.pipe(
15
+ ClientService.layer,
16
+ Layer.provideMerge(ConfigService.layerMemory),
17
+ Layer.provideMerge(TestConsole.layer),
18
+ Layer.provideMerge(CommandConfig.layerTest),
19
+ );
@@ -0,0 +1,157 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, it } from '@effect/vitest';
6
+ import * as Console from 'effect/Console';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Option from 'effect/Option';
9
+
10
+ import { FormBuilder, print } from '@dxos/cli-util';
11
+ import { runAndForwardErrors } from '@dxos/effect';
12
+
13
+ describe('FormBuilder', () => {
14
+ it('option', () =>
15
+ runAndForwardErrors(
16
+ Effect.gen(function* () {
17
+ const builder = FormBuilder.make({ title: 'Option Test' }).pipe(
18
+ FormBuilder.option('some', Option.some('value')),
19
+ FormBuilder.option('none', Option.none()),
20
+ );
21
+
22
+ const doc = FormBuilder.build(builder);
23
+ yield* Console.log(print(doc));
24
+ }),
25
+ ));
26
+
27
+ it('when', () =>
28
+ runAndForwardErrors(
29
+ Effect.gen(function* () {
30
+ const builder = FormBuilder.make({ title: 'When Test' }).pipe(
31
+ FormBuilder.when(true, FormBuilder.set('included', 'true')),
32
+ FormBuilder.when(false, FormBuilder.set('excluded', 'false')),
33
+ );
34
+
35
+ const doc = FormBuilder.build(builder);
36
+ yield* Console.log(print(doc));
37
+ }),
38
+ ));
39
+
40
+ it('each', () =>
41
+ runAndForwardErrors(
42
+ Effect.gen(function* () {
43
+ const items = ['a', 'b', 'c'];
44
+ const builder = FormBuilder.make({ title: 'Each Test' }).pipe(
45
+ FormBuilder.each(items, (item) => FormBuilder.set('item', item)),
46
+ );
47
+
48
+ const doc = FormBuilder.build(builder);
49
+ yield* Console.log(print(doc));
50
+ }),
51
+ ));
52
+
53
+ it('build', () =>
54
+ runAndForwardErrors(
55
+ Effect.gen(function* () {
56
+ const builder = FormBuilder.make({ title: 'Test' }).pipe(
57
+ FormBuilder.set('foo', 100),
58
+ FormBuilder.set('bar', true),
59
+ );
60
+
61
+ const doc = FormBuilder.build(builder);
62
+ yield* Console.log(print(doc));
63
+ }),
64
+ ));
65
+
66
+ it('nest', () =>
67
+ runAndForwardErrors(
68
+ Effect.gen(function* () {
69
+ const nested = FormBuilder.make({ title: 'Nested' }).pipe(
70
+ FormBuilder.set('nested1', 'value1'),
71
+ FormBuilder.set('nested2', 42),
72
+ );
73
+
74
+ const builder = FormBuilder.make({ title: 'Parent' }).pipe(
75
+ FormBuilder.set('top', 'top-level'),
76
+ FormBuilder.nest('child', nested),
77
+ FormBuilder.set('bottom', 'bottom-level'),
78
+ );
79
+
80
+ const doc = FormBuilder.build(builder);
81
+ yield* Console.log(print(doc));
82
+ }),
83
+ ));
84
+
85
+ it('nestedOption', () =>
86
+ runAndForwardErrors(
87
+ Effect.gen(function* () {
88
+ const nested = FormBuilder.make({ title: 'Nested' }).pipe(FormBuilder.set('key', 'value'));
89
+
90
+ const builder = FormBuilder.make({ title: 'Parent' }).pipe(
91
+ FormBuilder.nestedOption('some', Option.some(nested)),
92
+ FormBuilder.nestedOption('none', Option.none()),
93
+ );
94
+
95
+ const doc = FormBuilder.build(builder);
96
+ yield* Console.log(print(doc));
97
+ }),
98
+ ));
99
+
100
+ it('multi-level nesting', () =>
101
+ runAndForwardErrors(
102
+ Effect.gen(function* () {
103
+ const grandchild = FormBuilder.make({ title: 'Grandchild' }).pipe(
104
+ FormBuilder.set('grandchild1', 'value1'),
105
+ FormBuilder.set('grandchild2', 'value2'),
106
+ );
107
+
108
+ const child = FormBuilder.make({ title: 'Child' }).pipe(
109
+ FormBuilder.set('child1', 'value1'),
110
+ FormBuilder.nest('grandchild', grandchild),
111
+ FormBuilder.set('child2', 'value2'),
112
+ );
113
+
114
+ const parent = FormBuilder.make({ title: 'Parent' }).pipe(
115
+ FormBuilder.set('top', 'top-level'),
116
+ FormBuilder.nest('child', child),
117
+ FormBuilder.set('bottom', 'bottom-level'),
118
+ );
119
+
120
+ const doc = FormBuilder.build(parent);
121
+ yield* Console.log(print(doc));
122
+ }),
123
+ ));
124
+
125
+ it('pipeable build', () =>
126
+ runAndForwardErrors(
127
+ Effect.gen(function* () {
128
+ const doc = FormBuilder.make({ title: 'Pipeable' }).pipe(
129
+ FormBuilder.set('foo', 100),
130
+ FormBuilder.set('bar', true),
131
+ FormBuilder.build,
132
+ );
133
+ yield* Console.log(print(doc));
134
+ }),
135
+ ));
136
+
137
+ it('dual calling styles', () =>
138
+ runAndForwardErrors(
139
+ Effect.gen(function* () {
140
+ // Curried style (pipe)
141
+ const builder1 = FormBuilder.make({ title: 'Curried' }).pipe(
142
+ FormBuilder.set('key1', 'value1'),
143
+ FormBuilder.option('key2', Option.some('value2')),
144
+ );
145
+
146
+ // Direct style
147
+ const builder2 = FormBuilder.make({ title: 'Direct' });
148
+ FormBuilder.set(builder2, 'key1', 'value1');
149
+ FormBuilder.option(builder2, 'key2', Option.some('value2'));
150
+
151
+ const doc1 = FormBuilder.build(builder1);
152
+ const doc2 = FormBuilder.build(builder2);
153
+ yield* Console.log('Curried:', print(doc1));
154
+ yield* Console.log('Direct:', print(doc2));
155
+ }),
156
+ ));
157
+ });
@@ -0,0 +1,358 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Doc from '@effect/printer/Doc';
6
+ import * as Ansi from '@effect/printer-ansi/Ansi';
7
+ import * as Option from 'effect/Option';
8
+ import * as Pipeable from 'effect/Pipeable';
9
+
10
+ export type FormBuilderOptions = {
11
+ title?: string;
12
+ prefix?: string;
13
+ };
14
+
15
+ export interface FormBuilder extends Pipeable.Pipeable {
16
+ readonly entries: ReadonlyArray<{ key: string; value: Doc.Doc<any>; isNested?: boolean }>;
17
+ readonly title?: string;
18
+ readonly prefix: string;
19
+ }
20
+
21
+ class FormBuilderImpl implements FormBuilder {
22
+ readonly entries: Array<{ key: string; value: Doc.Doc<any>; isNested?: boolean }> = [];
23
+ readonly title?: string;
24
+ readonly prefix: string;
25
+
26
+ constructor(options: FormBuilderOptions = {}) {
27
+ this.title = options.title;
28
+ this.prefix = options.prefix ?? '- ';
29
+ }
30
+
31
+ pipe() {
32
+ // eslint-disable-next-line prefer-rest-params
33
+ return Pipeable.pipeArguments(this, arguments);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Creates a new FormBuilder instance.
39
+ */
40
+ export const make = (props?: FormBuilderOptions): FormBuilder => new FormBuilderImpl(props);
41
+
42
+ // Helper to calculate dimensions for formatting
43
+ const calculateDimensions = (entries: ReadonlyArray<{ key: string }>, prefix: string) => {
44
+ const maxKeyLen = Math.max(0, ...entries.map((entry) => entry.key.length));
45
+ const targetWidth = prefix.length + maxKeyLen + 2;
46
+ return { maxKeyLen, targetWidth };
47
+ };
48
+
49
+ // Helper to build a formatted key line
50
+ const buildKeyLine = (prefix: string, key: string, targetWidth: number) => {
51
+ // Use fill to pad the key to targetWidth.
52
+ // Note: We don't add indentation here; indentation is handled by the parent container or nestImpl.
53
+ return Doc.annotate(Doc.fill(targetWidth)(Doc.text(prefix + key + ': ')), Ansi.blackBright);
54
+ };
55
+
56
+ // Implementation helper
57
+ const setImpl = <T>(
58
+ builder: FormBuilder,
59
+ key: string,
60
+ value: T,
61
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
62
+ ): FormBuilder => {
63
+ if (value !== undefined) {
64
+ let valueDoc: Doc.Doc<any>;
65
+ if (typeof value === 'object' && value !== null) {
66
+ // Assume it's a Doc.Doc
67
+ valueDoc = value as unknown as Doc.Doc<any>;
68
+ } else {
69
+ valueDoc = Doc.text(String(value));
70
+ }
71
+
72
+ if (color) {
73
+ const ansi = typeof color === 'function' ? color(value as T) : color;
74
+ valueDoc = Doc.annotate(valueDoc, ansi);
75
+ }
76
+
77
+ (builder as FormBuilderImpl).entries.push({
78
+ key,
79
+ value: valueDoc,
80
+ isNested: false,
81
+ });
82
+ }
83
+ return builder;
84
+ };
85
+
86
+ /**
87
+ * Adds a key-value pair to the form.
88
+ */
89
+ export function set<T>(
90
+ builder: FormBuilder,
91
+ key: string,
92
+ value: T,
93
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
94
+ ): FormBuilder;
95
+ export function set<T>(
96
+ key: string,
97
+ value: T,
98
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
99
+ ): (builder: FormBuilder) => FormBuilder;
100
+ export function set<T>(
101
+ builderOrKey: FormBuilder | string,
102
+ keyOrValue?: string | T,
103
+ valueOrColor?: T | Ansi.Ansi | ((value: T) => Ansi.Ansi),
104
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
105
+ ): FormBuilder | ((builder: FormBuilder) => FormBuilder) {
106
+ if (builderOrKey instanceof FormBuilderImpl) {
107
+ // Direct: set(builder, key, value, color?)
108
+ const builder = builderOrKey;
109
+ const key = keyOrValue as string;
110
+ const value = valueOrColor as T;
111
+ return setImpl(builder, key, value, color);
112
+ } else {
113
+ // Curried: set(key, value, color?)
114
+ const key = builderOrKey as string;
115
+ const value = keyOrValue as T;
116
+ const color = valueOrColor as Ansi.Ansi | ((value: T) => Ansi.Ansi) | undefined;
117
+ return (builder: FormBuilder) => setImpl(builder, key, value, color);
118
+ }
119
+ }
120
+
121
+ // Implementation helper
122
+ const nestImpl = (parent: FormBuilder, key: string, builder: FormBuilder): FormBuilder => {
123
+ // Build nested entries without title, directly under the parent field name
124
+
125
+ // Create a temporary builder without title to ignore it.
126
+ const nestedBuilder: FormBuilder = {
127
+ ...builder,
128
+ title: undefined,
129
+ entries: builder.entries,
130
+ };
131
+
132
+ // Build content.
133
+ let valueDoc = build(nestedBuilder);
134
+
135
+ // Indent the content by 2 spaces.
136
+ // This ensures that when this doc is embedded in the parent, it is visually nested.
137
+ valueDoc = Doc.indent(valueDoc, 2);
138
+
139
+ (parent.entries as Array<{ key: string; value: Doc.Doc<any>; isNested?: boolean }>).push({
140
+ key,
141
+ value: valueDoc,
142
+ isNested: true,
143
+ });
144
+ return parent;
145
+ };
146
+
147
+ /**
148
+ * Nests another form builder under a key.
149
+ */
150
+ export function nest(parent: FormBuilder, key: string, builder: FormBuilder): FormBuilder;
151
+ export function nest(key: string, builder: FormBuilder): (parent: FormBuilder) => FormBuilder;
152
+ export function nest(
153
+ parentOrKey: FormBuilder | string,
154
+ keyOrBuilder?: string | FormBuilder,
155
+ builder?: FormBuilder,
156
+ ): FormBuilder | ((parent: FormBuilder) => FormBuilder) {
157
+ if (parentOrKey instanceof FormBuilderImpl) {
158
+ // Direct: nest(parent, key, builder)
159
+ const parent = parentOrKey;
160
+ const key = keyOrBuilder as string;
161
+ return nestImpl(parent, key, builder!);
162
+ } else {
163
+ // Curried: nest(key, builder)
164
+ const key = parentOrKey as string;
165
+ const builder = keyOrBuilder as FormBuilder;
166
+ return (parent: FormBuilder) => nestImpl(parent, key, builder);
167
+ }
168
+ }
169
+
170
+ // Implementation helper
171
+ const optionImpl = <T>(
172
+ builder: FormBuilder,
173
+ key: string,
174
+ value: Option.Option<T>,
175
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
176
+ ): FormBuilder => {
177
+ if (Option.isSome(value)) {
178
+ return setImpl(builder, key, value.value, color);
179
+ }
180
+ return builder;
181
+ };
182
+
183
+ /**
184
+ * Adds an optional value if it exists.
185
+ */
186
+ export function option<T>(
187
+ builder: FormBuilder,
188
+ key: string,
189
+ value: Option.Option<T>,
190
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
191
+ ): FormBuilder;
192
+ export function option<T>(
193
+ key: string,
194
+ value: Option.Option<T>,
195
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
196
+ ): (builder: FormBuilder) => FormBuilder;
197
+ export function option<T>(
198
+ builderOrKey: FormBuilder | string,
199
+ keyOrValue?: string | Option.Option<T>,
200
+ valueOrColor?: Option.Option<T> | Ansi.Ansi | ((value: T) => Ansi.Ansi),
201
+ color?: Ansi.Ansi | ((value: T) => Ansi.Ansi),
202
+ ): FormBuilder | ((builder: FormBuilder) => FormBuilder) {
203
+ if (builderOrKey instanceof FormBuilderImpl) {
204
+ // Direct: option(builder, key, value, color?)
205
+ const builder = builderOrKey;
206
+ const key = keyOrValue as string;
207
+ const value = valueOrColor as Option.Option<T>;
208
+ return optionImpl(builder, key, value, color);
209
+ } else {
210
+ // Curried: option(key, value, color?)
211
+ const key = builderOrKey as string;
212
+ const value = keyOrValue as Option.Option<T>;
213
+ const color = valueOrColor as Ansi.Ansi | ((value: T) => Ansi.Ansi) | undefined;
214
+ return (builder: FormBuilder) => optionImpl(builder, key, value, color);
215
+ }
216
+ }
217
+
218
+ // Implementation helper
219
+ const nestedOptionImpl = (builder: FormBuilder, key: string, value: Option.Option<FormBuilder>): FormBuilder => {
220
+ if (Option.isSome(value)) {
221
+ return nestImpl(builder, key, value.value);
222
+ }
223
+ return builder;
224
+ };
225
+
226
+ /**
227
+ * Nests an optional form builder if it exists.
228
+ */
229
+ export function nestedOption(builder: FormBuilder, key: string, value: Option.Option<FormBuilder>): FormBuilder;
230
+ export function nestedOption(key: string, value: Option.Option<FormBuilder>): (builder: FormBuilder) => FormBuilder;
231
+ export function nestedOption(
232
+ builderOrKey: FormBuilder | string,
233
+ keyOrValue?: string | Option.Option<FormBuilder>,
234
+ value?: Option.Option<FormBuilder>,
235
+ ): FormBuilder | ((builder: FormBuilder) => FormBuilder) {
236
+ if (builderOrKey instanceof FormBuilderImpl) {
237
+ // Direct: nestedOption(builder, key, value)
238
+ const builder = builderOrKey;
239
+ const key = keyOrValue as string;
240
+ return nestedOptionImpl(builder, key, value!);
241
+ } else {
242
+ // Curried: nestedOption(key, value)
243
+ const key = builderOrKey as string;
244
+ const value = keyOrValue as Option.Option<FormBuilder>;
245
+ return (builder: FormBuilder) => nestedOptionImpl(builder, key, value);
246
+ }
247
+ }
248
+
249
+ // Implementation helper
250
+ const whenImpl = (builder: FormBuilder, condition: any, ...ops: ((b: FormBuilder) => FormBuilder)[]): FormBuilder => {
251
+ if (condition) {
252
+ for (const op of ops) {
253
+ op(builder);
254
+ }
255
+ }
256
+ return builder;
257
+ };
258
+
259
+ /**
260
+ * Conditionally executes operations.
261
+ */
262
+ export function when(builder: FormBuilder, condition: any, ...ops: ((b: FormBuilder) => FormBuilder)[]): FormBuilder;
263
+ export function when(
264
+ condition: any,
265
+ ...ops: ((b: FormBuilder) => FormBuilder)[]
266
+ ): (builder: FormBuilder) => FormBuilder;
267
+ export function when(
268
+ builderOrCondition: FormBuilder | any,
269
+ conditionOrOp?: any | ((b: FormBuilder) => FormBuilder),
270
+ ...ops: ((b: FormBuilder) => FormBuilder)[]
271
+ ): FormBuilder | ((builder: FormBuilder) => FormBuilder) {
272
+ if (builderOrCondition instanceof FormBuilderImpl) {
273
+ // Direct: when(builder, condition, ...ops)
274
+ const builder = builderOrCondition;
275
+ const condition = conditionOrOp;
276
+ return whenImpl(builder, condition, ...ops);
277
+ } else {
278
+ // Curried: when(condition, ...ops)
279
+ const condition = builderOrCondition;
280
+ const allOps = [conditionOrOp, ...ops].filter(
281
+ (op): op is (b: FormBuilder) => FormBuilder => typeof op === 'function',
282
+ );
283
+ return (builder: FormBuilder) => whenImpl(builder, condition, ...allOps);
284
+ }
285
+ }
286
+
287
+ // Implementation helper
288
+ const eachImpl = <T>(
289
+ builder: FormBuilder,
290
+ items: T[],
291
+ fn: (item: T) => (b: FormBuilder) => FormBuilder,
292
+ ): FormBuilder => {
293
+ items.forEach((item) => fn(item)(builder));
294
+ return builder;
295
+ };
296
+
297
+ /**
298
+ * Iterates over an array of items.
299
+ */
300
+ export function each<T>(
301
+ builder: FormBuilder,
302
+ items: T[],
303
+ fn: (item: T) => (b: FormBuilder) => FormBuilder,
304
+ ): FormBuilder;
305
+ export function each<T>(
306
+ items: T[],
307
+ fn: (item: T) => (b: FormBuilder) => FormBuilder,
308
+ ): (builder: FormBuilder) => FormBuilder;
309
+ export function each<T>(
310
+ builderOrItems: FormBuilder | T[],
311
+ itemsOrFn?: T[] | ((item: T) => (b: FormBuilder) => FormBuilder),
312
+ fn?: (item: T) => (b: FormBuilder) => FormBuilder,
313
+ ): FormBuilder | ((builder: FormBuilder) => FormBuilder) {
314
+ if (builderOrItems instanceof FormBuilderImpl) {
315
+ // Direct: each(builder, items, fn)
316
+ const builder = builderOrItems;
317
+ const items = itemsOrFn as T[];
318
+ return eachImpl(builder, items, fn!);
319
+ } else {
320
+ // Curried: each(items, fn)
321
+ const items = builderOrItems as T[];
322
+ const fn = itemsOrFn as (item: T) => (b: FormBuilder) => FormBuilder;
323
+ return (builder: FormBuilder) => eachImpl(builder, items, fn);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Builds the final document.
329
+ */
330
+ export const build = (builder: FormBuilder): Doc.Doc<any> => {
331
+ const { targetWidth } = calculateDimensions(builder.entries, builder.prefix);
332
+ const entryLines: Doc.Doc<any>[] = [];
333
+
334
+ builder.entries.forEach(({ key, value, isNested }) => {
335
+ const keyLine = buildKeyLine(builder.prefix, key, targetWidth);
336
+ if (isNested) {
337
+ // Nested content should start on a new line.
338
+ // value is already indented by nestImpl, so we just cat with hardLine.
339
+ entryLines.push(Doc.hcat([keyLine, Doc.hardLine, value]));
340
+ } else {
341
+ // Single-line value, combine key and value.
342
+ // If the value itself is multiline (e.g. from Doc.string with newlines, though Doc handles that specifically),
343
+ // we might want layout flexibility.
344
+ // For now, standard behavior:
345
+ entryLines.push(Doc.hcat([keyLine, value]));
346
+ }
347
+ });
348
+
349
+ const entriesDoc = Doc.vsep(entryLines);
350
+
351
+ if (builder.title) {
352
+ const titleDoc = Doc.hcat([Doc.annotate(Doc.text(builder.title), Ansi.combine(Ansi.bold, Ansi.cyan))]);
353
+ // Join title and entries with a single line break, no extra spacing
354
+ return Doc.cat(titleDoc, Doc.cat(Doc.line, entriesDoc));
355
+ }
356
+
357
+ return entriesDoc;
358
+ };
@@ -0,0 +1,12 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * as FormBuilder from './form-builder';
6
+ export * from './options';
7
+ export * from './platform';
8
+ export * from './printer';
9
+ export * from './runtime';
10
+ export * from './space';
11
+ export * from './space-format';
12
+ export * from './timeout';
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Options from '@effect/cli/Options';
6
+
7
+ import { Key } from '@dxos/echo';
8
+
9
+ //
10
+ // Common options.
11
+ // NOTE: Sub-commands should Function.pipe(Options.optional) if required.
12
+ //
13
+
14
+ export const Common = {
15
+ functionId: Options.text('function-id').pipe(Options.withDescription('EDGE Function ID.')),
16
+ spaceId: Options.text('space-id').pipe(Options.withSchema(Key.SpaceId), Options.withDescription('Space ID.')),
17
+ };
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { spawn } from 'node:child_process';
6
+
7
+ import * as Effect from 'effect/Effect';
8
+
9
+ /**
10
+ * Copy text to the system clipboard.
11
+ * Supports macOS (pbcopy), Windows (clip), and Linux (xclip/xsel).
12
+ */
13
+ export const copyToClipboard = (text: string): Effect.Effect<void, Error> =>
14
+ Effect.tryPromise({
15
+ try: () => {
16
+ return new Promise<void>((resolve, reject) => {
17
+ const platform = process.platform;
18
+ let command: string;
19
+ let args: string[];
20
+
21
+ if (platform === 'darwin') {
22
+ command = 'pbcopy';
23
+ args = [];
24
+ } else if (platform === 'win32') {
25
+ command = 'clip';
26
+ args = [];
27
+ } else {
28
+ // Linux - try xclip or xsel
29
+ command = 'xclip';
30
+ args = ['-selection', 'clipboard'];
31
+ }
32
+
33
+ const proc = spawn(command, args);
34
+ proc.stdin?.write(text);
35
+ proc.stdin?.end();
36
+
37
+ proc.on('close', (code) => {
38
+ if (code === 0) {
39
+ resolve();
40
+ } else {
41
+ // Try xsel as fallback on Linux
42
+ if (platform === 'linux') {
43
+ const proc2 = spawn('xsel', ['--clipboard', '--input']);
44
+ proc2.stdin?.write(text);
45
+ proc2.stdin?.end();
46
+ proc2.on('close', (code2) => {
47
+ if (code2 === 0) {
48
+ resolve();
49
+ } else {
50
+ reject(new Error('Failed to copy to clipboard'));
51
+ }
52
+ });
53
+ proc2.on('error', reject);
54
+ } else {
55
+ reject(new Error('Failed to copy to clipboard'));
56
+ }
57
+ }
58
+ });
59
+
60
+ proc.on('error', reject);
61
+ });
62
+ },
63
+ catch: (error) => new Error(`Failed to copy to clipboard: ${error}`),
64
+ });
65
+
66
+ /**
67
+ * Open a URL in the system's default browser.
68
+ * Supports macOS (open), Windows (start), and Linux (xdg-open).
69
+ */
70
+ export const openBrowser = (url: string): Effect.Effect<void, Error> =>
71
+ Effect.tryPromise({
72
+ try: () => {
73
+ return new Promise<void>((resolve, reject) => {
74
+ const platform = process.platform;
75
+ let command: string;
76
+ let args: string[];
77
+
78
+ if (platform === 'darwin') {
79
+ command = 'open';
80
+ args = [url];
81
+ } else if (platform === 'win32') {
82
+ command = 'start';
83
+ args = [url];
84
+ } else {
85
+ command = 'xdg-open';
86
+ args = [url];
87
+ }
88
+
89
+ const proc = spawn(command, args);
90
+ proc.on('close', (code) => {
91
+ if (code === 0) {
92
+ resolve();
93
+ } else {
94
+ reject(new Error('Failed to open browser'));
95
+ }
96
+ });
97
+ proc.on('error', reject);
98
+ });
99
+ },
100
+ catch: (error) => new Error(`Failed to open browser: ${error}`),
101
+ });
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Doc from '@effect/printer/Doc';
6
+ import * as AnsiDoc from '@effect/printer-ansi/AnsiDoc';
7
+
8
+ /**
9
+ * Pretty print document.
10
+ */
11
+ export const print = (doc: Doc.Doc<any>) => AnsiDoc.render(doc, { style: 'pretty' });
12
+
13
+ /**
14
+ * Pretty prints a list of documents with ANSI colors.
15
+ */
16
+ export const printList = (items: Array<Doc.Doc<any>>) =>
17
+ AnsiDoc.render(Doc.vsep(items.map((item) => Doc.cat(item, Doc.hardLine))), { style: 'pretty' });
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import type * as Schema from 'effect/Schema';
7
+
8
+ import { ClientService } from '@dxos/client';
9
+
10
+ /** @deprecated Migrate to providing types via plugin capabilities. */
11
+ export const withTypes: (...types: Schema.Schema.AnyNoContext[]) => Effect.Effect<void, never, ClientService> = (
12
+ ...types
13
+ ) =>
14
+ Effect.gen(function* () {
15
+ const client = yield* ClientService;
16
+ yield* Effect.promise(() => client.addTypes(types));
17
+ });
@@ -0,0 +1,105 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { type Space, SpaceState, type SpaceSyncState } from '@dxos/client/echo';
8
+
9
+ import * as FormBuilder from './form-builder';
10
+
11
+ // TODO(wittjosiah): Use @effect/printer.
12
+ export const formatSpace = Effect.fn(function* (space: Space, options = { verbose: false, truncateKeys: false }) {
13
+ yield* Effect.tryPromise(() => space.waitUntilReady());
14
+
15
+ // TODO(burdon): Factor out.
16
+ // TODO(burdon): Agent needs to restart before `ready` is available.
17
+ const { open, ready } = space.internal.data.metrics ?? {};
18
+ const startup = open && ready && ready.getTime() - open.getTime();
19
+
20
+ // TODO(burdon): Get feeds from client-services if verbose (factor out from devtools/diagnostics).
21
+ // const host = client.services.services.DevtoolsHost!;
22
+ const pipeline = space.internal.data.pipeline;
23
+ const epoch = pipeline?.currentEpoch?.subject.assertion.number;
24
+
25
+ const syncState = aggregateSyncState(yield* Effect.tryPromise(() => space.internal.db.coreDatabase.getSyncState()));
26
+
27
+ return {
28
+ id: space.id,
29
+ state: SpaceState[space.state.get()],
30
+ name: space.state.get() === SpaceState.SPACE_READY ? space.properties.name : 'loading...',
31
+
32
+ members: space.members.get().length,
33
+ objects: space.internal.db.coreDatabase.getAllObjectIds().length,
34
+
35
+ key: options.truncateKeys ? space.key.truncate() : space.key.toHex(),
36
+ epoch,
37
+ startup,
38
+ automergeRoot: space.internal.data.pipeline?.spaceRootUrl,
39
+ // appliedEpoch,
40
+ syncState: `${syncState.count} ${getSyncIndicator(syncState.up, syncState.down)} (${syncState.peers} peers)`,
41
+ };
42
+ });
43
+
44
+ const aggregateSyncState = (syncState: SpaceSyncState) => {
45
+ const peers = Object.keys(syncState.peers ?? {}).length;
46
+ const missingOnLocal = Math.max(...Object.values(syncState.peers ?? {}).map((peer) => peer.missingOnLocal), 0);
47
+ const missingOnRemote = Math.max(...Object.values(syncState.peers ?? {}).map((peer) => peer.missingOnRemote), 0);
48
+ const differentDocuments = Math.max(
49
+ ...Object.values(syncState.peers ?? {}).map((peer) => peer.differentDocuments),
50
+ 0,
51
+ );
52
+
53
+ return {
54
+ count: missingOnLocal + missingOnRemote + differentDocuments,
55
+ peers,
56
+ up: missingOnRemote > 0 || differentDocuments > 0,
57
+ down: missingOnLocal > 0 || differentDocuments > 0,
58
+ };
59
+ };
60
+
61
+ const getSyncIndicator = (up: boolean, down: boolean) => {
62
+ if (up) {
63
+ if (down) {
64
+ return '⇅';
65
+ } else {
66
+ return '↑';
67
+ }
68
+ } else {
69
+ if (down) {
70
+ return '↓';
71
+ } else {
72
+ return '✓';
73
+ }
74
+ }
75
+ };
76
+
77
+ export type FormattedSpace = {
78
+ id: string;
79
+ state: string;
80
+ name: string | undefined;
81
+ members: number;
82
+ objects: number;
83
+ key: string;
84
+ epoch: any;
85
+ startup: number | undefined;
86
+ automergeRoot: string | undefined;
87
+ syncState: string;
88
+ };
89
+
90
+ /**
91
+ * Pretty prints a space with ANSI colors.
92
+ */
93
+ export const printSpace = (spaceData: FormattedSpace) =>
94
+ FormBuilder.make({ title: spaceData.name ?? spaceData.id }).pipe(
95
+ FormBuilder.set('id', spaceData.id),
96
+ FormBuilder.set('state', spaceData.state),
97
+ FormBuilder.set('key', spaceData.key),
98
+ FormBuilder.set('members', spaceData.members),
99
+ FormBuilder.set('objects', spaceData.objects),
100
+ FormBuilder.set('epoch', spaceData.epoch ?? '<none>'),
101
+ FormBuilder.set('startup', spaceData.startup ? `${spaceData.startup}ms` : '<none>'),
102
+ FormBuilder.set('syncState', spaceData.syncState),
103
+ FormBuilder.set('automergeRoot', spaceData.automergeRoot ?? '<none>'),
104
+ FormBuilder.build,
105
+ );
@@ -0,0 +1,143 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Console from 'effect/Console';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as Match from 'effect/Match';
9
+ import * as Option from 'effect/Option';
10
+
11
+ import { ClientService } from '@dxos/client';
12
+ import { type Space } from '@dxos/client/echo';
13
+ import { Database, type Key } from '@dxos/echo';
14
+ import { BaseError, type BaseErrorOptions } from '@dxos/errors';
15
+ import { QueueService } from '@dxos/functions';
16
+ import { log } from '@dxos/log';
17
+ import { EdgeReplicationSetting } from '@dxos/protocols/proto/dxos/echo/metadata';
18
+ import { isBun } from '@dxos/util';
19
+
20
+ export const getSpace = (spaceId: Key.SpaceId): Effect.Effect<Space, SpaceNotFoundError, ClientService> =>
21
+ Effect.gen(function* () {
22
+ const client = yield* ClientService;
23
+ return yield* Option.fromNullable(client.spaces.get(spaceId));
24
+ }).pipe(Effect.catchTag('NoSuchElementException', () => Effect.fail(new SpaceNotFoundError(spaceId))));
25
+
26
+ export const spaceIdWithDefault = (spaceId: Option.Option<Key.SpaceId>) =>
27
+ Effect.gen(function* () {
28
+ const client = yield* ClientService;
29
+ yield* Effect.promise(() => client.spaces.waitUntilReady());
30
+ return Option.getOrElse(spaceId, () => client.spaces.default.id);
31
+ });
32
+
33
+ // TODO(wittjosiah): Factor out.
34
+ export const spaceLayer = (
35
+ spaceId$: Option.Option<Key.SpaceId>,
36
+ fallbackToDefaultSpace = false,
37
+ ): Layer.Layer<Database.Service | QueueService, never, ClientService> => {
38
+ const getSpace = Effect.fn(function* () {
39
+ const client = yield* ClientService;
40
+ yield* Effect.promise(() => client.spaces.waitUntilReady());
41
+
42
+ const spaceId = Match.value(fallbackToDefaultSpace).pipe(
43
+ Match.when(true, () =>
44
+ spaceId$.pipe(
45
+ Option.getOrElse(() => client.spaces.default.id),
46
+ Option.some,
47
+ ),
48
+ ),
49
+ Match.when(false, () => spaceId$),
50
+ Match.exhaustive,
51
+ );
52
+
53
+ const space = spaceId.pipe(
54
+ Option.flatMap((id) => Option.fromNullable(client.spaces.get(id))),
55
+ Option.getOrUndefined,
56
+ );
57
+
58
+ if (space) {
59
+ yield* Effect.promise(() => space.waitUntilReady());
60
+ }
61
+ return space;
62
+ });
63
+
64
+ const db = Layer.scoped(
65
+ Database.Service,
66
+ Effect.acquireRelease(
67
+ Effect.gen(function* () {
68
+ const space = yield* getSpace();
69
+ if (!space) {
70
+ return {
71
+ get db(): Database.Database {
72
+ throw new Error('Space not found');
73
+ },
74
+ };
75
+ }
76
+ return { db: space.db };
77
+ }),
78
+ ({ db }) => Effect.promise(() => db.flush({ indexes: true })),
79
+ ),
80
+ );
81
+
82
+ const queue = Layer.effect(
83
+ QueueService,
84
+ Effect.gen(function* () {
85
+ const space = yield* getSpace();
86
+ if (!space) {
87
+ return {
88
+ queues: {
89
+ get: (_dxn) => {
90
+ throw new Error('Queues not available');
91
+ },
92
+ create: () => {
93
+ throw new Error('Queues not available');
94
+ },
95
+ },
96
+ queue: undefined,
97
+ };
98
+ }
99
+ return {
100
+ queues: space.queues,
101
+ queue: undefined,
102
+ };
103
+ }),
104
+ );
105
+
106
+ return Layer.merge(db, queue);
107
+ };
108
+
109
+ // TODO(dmaretskyi): There a race condition with edge connection not showing up.
110
+ export const waitForSync = Effect.fn(function* (space: Space) {
111
+ // TODO(wittjosiah): Find a better way to do this.
112
+ if (!isBun()) {
113
+ // Skipping sync to edge when not in bun env as this indicates running a test.
114
+ return;
115
+ }
116
+
117
+ // TODO(wittjosiah): This should probably be prompted for.
118
+ if (space.internal.data.edgeReplication !== EdgeReplicationSetting.ENABLED) {
119
+ yield* Console.log('Edge replication is disabled, enabling...');
120
+ yield* Effect.promise(() => space.internal.setEdgeReplicationPreference(EdgeReplicationSetting.ENABLED));
121
+ }
122
+
123
+ yield* Effect.promise(() =>
124
+ space.internal.syncToEdge({
125
+ onProgress: (state) => log.info('syncing', { state: state ?? 'no connection to edge' }),
126
+ }),
127
+ );
128
+ yield* Console.log('Sync complete');
129
+ });
130
+
131
+ export const flushAndSync = Effect.fn(function* (opts?: Database.FlushOptions) {
132
+ yield* Database.Service.flush(opts);
133
+ const spaceId = yield* Database.Service.spaceId;
134
+ const space = yield* getSpace(spaceId);
135
+ yield* waitForSync(space);
136
+ });
137
+
138
+ // TODO(burdon): Reconcile with @dxos/protocols
139
+ export class SpaceNotFoundError extends BaseError.extend('SpaceNotFoundError', 'Space not found') {
140
+ constructor(spaceId: string, options?: Omit<BaseErrorOptions, 'context'>) {
141
+ super({ context: { spaceId }, ...options });
142
+ }
143
+ }
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type * as Cause from 'effect/Cause';
6
+ import * as Config from 'effect/Config';
7
+ import type * as ConfigError from 'effect/ConfigError';
8
+ import * as Duration from 'effect/Duration';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Option from 'effect/Option';
11
+
12
+ export const withTimeout: <A, E, R>(
13
+ effect: Effect.Effect<A, E, R>,
14
+ ) => Effect.Effect<A, Cause.TimeoutException | ConfigError.ConfigError | E, R> = Effect.fnUntraced(function* (effect) {
15
+ const timeout = yield* Config.integer('TIMEOUT').pipe(Config.option);
16
+ const duration = timeout.pipe(
17
+ Option.map(Duration.millis),
18
+ Option.getOrElse(() => Duration.infinity),
19
+ );
20
+ return yield* effect.pipe(Effect.timeout(duration));
21
+ });