airfoil 0.2.0 → 0.4.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 (37) hide show
  1. package/dist/index.js +1181 -104238
  2. package/package.json +19 -24
  3. package/src/commands/cluster/data-lake/create-iceberg.ts +8 -10
  4. package/src/commands/cluster/data-lake/create-parquet.ts +8 -10
  5. package/src/commands/cluster/data-lake/create.ts +4 -10
  6. package/src/commands/cluster/data-lake/delete.ts +6 -10
  7. package/src/commands/cluster/data-lake/get.ts +7 -13
  8. package/src/commands/cluster/data-lake/list.ts +8 -18
  9. package/src/commands/cluster/index.ts +3 -6
  10. package/src/commands/cluster/namespace/create.ts +18 -28
  11. package/src/commands/cluster/namespace/delete.ts +6 -10
  12. package/src/commands/cluster/namespace/get.ts +6 -10
  13. package/src/commands/cluster/namespace/list.ts +7 -16
  14. package/src/commands/cluster/object-store/create-aws.ts +20 -24
  15. package/src/commands/cluster/object-store/create-azure.ts +17 -21
  16. package/src/commands/cluster/object-store/create-google.ts +19 -34
  17. package/src/commands/cluster/object-store/create-s3.ts +22 -34
  18. package/src/commands/cluster/object-store/create.ts +2 -1
  19. package/src/commands/cluster/object-store/delete.ts +6 -8
  20. package/src/commands/cluster/object-store/get.ts +6 -8
  21. package/src/commands/cluster/object-store/list.ts +8 -18
  22. package/src/commands/cluster/tenant/create.ts +6 -10
  23. package/src/commands/cluster/tenant/delete.ts +10 -16
  24. package/src/commands/cluster/tenant/get.ts +6 -10
  25. package/src/commands/cluster/tenant/list.ts +5 -12
  26. package/src/commands/cluster/topic/create.ts +43 -66
  27. package/src/commands/cluster/topic/delete.ts +9 -11
  28. package/src/commands/cluster/topic/get.ts +5 -5
  29. package/src/commands/cluster/topic/list.ts +7 -16
  30. package/src/commands/dev.ts +89 -192
  31. package/src/commands/sql.ts +38 -59
  32. package/src/index.ts +18 -9
  33. package/src/utils/docker.ts +43 -0
  34. package/src/utils/options.ts +16 -17
  35. package/src/utils/wings.ts +68 -64
  36. package/src/scripts/compile-all.ts +0 -21
  37. package/src/utils/effect.ts +0 -11
@@ -1,12 +1,13 @@
1
- import * as fs from "node:fs";
2
- import * as p from "@clack/prompts";
3
- import { Command, Options } from "@effect/cli";
4
1
  import type { FieldConfig } from "@useairfoil/wings";
2
+
3
+ import * as p from "@clack/prompts";
5
4
  import { WingsClusterMetadata } from "@useairfoil/wings";
6
5
  import { printTable } from "console-table-printer";
7
6
  import { Effect, Option } from "effect";
7
+ import { Command, Flag } from "effect/unstable/cli";
8
+ import * as fs from "node:fs";
9
+
8
10
  import { makeClusterMetadataLayer } from "../../../utils/client.js";
9
- import { handleCliError } from "../../../utils/effect.js";
10
11
  import { hostOption, portOption } from "../../../utils/options.js";
11
12
 
12
13
  // Supported simple types (no config required)
@@ -40,55 +41,47 @@ const SUPPORTED_INLINE_TYPES = [
40
41
 
41
42
  type SupportedInlineType = (typeof SUPPORTED_INLINE_TYPES)[number];
42
43
 
43
- const parentOption = Options.text("parent").pipe(
44
- Options.withDescription(
45
- "Parent namespace in format: tenants/{tenant}/namespaces/{namespace}",
46
- ),
44
+ const parentOption = Flag.string("parent").pipe(
45
+ Flag.withDescription("Parent namespace in format: tenants/{tenant}/namespaces/{namespace}"),
47
46
  );
48
47
 
49
- const topicIdOption = Options.text("topic-id").pipe(
50
- Options.withDescription("Unique identifier for the topic"),
48
+ const topicIdOption = Flag.string("topic-id").pipe(
49
+ Flag.withDescription("Unique identifier for the topic"),
51
50
  );
52
51
 
53
- const descriptionOption = Options.text("description").pipe(
54
- Options.withDescription("Topic description"),
55
- Options.optional,
52
+ const descriptionOption = Flag.string("description").pipe(
53
+ Flag.withDescription("Topic description"),
54
+ Flag.optional,
56
55
  );
57
56
 
58
- const fieldsOption = Options.text("fields").pipe(
59
- Options.withDescription(
60
- 'Field definitions in format "name:Type" or "name:Type?" for nullable',
61
- ),
62
- Options.repeated,
57
+ const fieldsOption = Flag.string("fields").pipe(
58
+ Flag.withDescription('Field definitions in format "name:Type" or "name:Type?" for nullable'),
59
+ Flag.atLeast(0),
63
60
  );
64
61
 
65
- const schemaFileOption = Options.text("schema-file").pipe(
66
- Options.withDescription(
67
- "Path to JSON file containing FieldConfig[] (for complex types)",
68
- ),
69
- Options.optional,
62
+ const schemaFileOption = Flag.string("schema-file").pipe(
63
+ Flag.withDescription("Path to JSON file containing FieldConfig[] (for complex types)"),
64
+ Flag.optional,
70
65
  );
71
66
 
72
- const partitionKeyOption = Options.text("partition-key").pipe(
73
- Options.withDescription("Name of the field used for partitioning"),
74
- Options.optional,
67
+ const partitionKeyOption = Flag.string("partition-key").pipe(
68
+ Flag.withDescription("Name of the field used for partitioning"),
69
+ Flag.optional,
75
70
  );
76
71
 
77
- const freshnessSecondsOption = Options.integer("freshness-seconds").pipe(
78
- Options.withDescription("How often to compact the topic (seconds)"),
79
- Options.withDefault(60),
72
+ const freshnessSecondsOption = Flag.integer("freshness-seconds").pipe(
73
+ Flag.withDescription("How often to compact the topic (seconds)"),
74
+ Flag.withDefault(60),
80
75
  );
81
76
 
82
- const ttlSecondsOption = Options.integer("ttl-seconds").pipe(
83
- Options.withDescription("How long to keep topic data (seconds)"),
84
- Options.optional,
77
+ const ttlSecondsOption = Flag.integer("ttl-seconds").pipe(
78
+ Flag.withDescription("How long to keep topic data (seconds)"),
79
+ Flag.optional,
85
80
  );
86
81
 
87
- const targetFileSizeBytesOption = Options.integer(
88
- "target-file-size-bytes",
89
- ).pipe(
90
- Options.withDescription("Target file size for compaction (bytes)"),
91
- Options.withDefault(1024 * 1024),
82
+ const targetFileSizeBytesOption = Flag.integer("target-file-size-bytes").pipe(
83
+ Flag.withDescription("Target file size for compaction (bytes)"),
84
+ Flag.withDefault(1024 * 1024),
92
85
  );
93
86
 
94
87
  /**
@@ -131,7 +124,7 @@ function parseFieldString(fieldStr: string, index: number): FieldConfig {
131
124
  /**
132
125
  * Parse multiple field strings into FieldConfig array
133
126
  */
134
- function parseFieldsFromArgs(fields: string[]): FieldConfig[] {
127
+ function parseFieldsFromArgs(fields: ReadonlyArray<string>): FieldConfig[] {
135
128
  return fields.map((field, index) => parseFieldString(field, index));
136
129
  }
137
130
 
@@ -153,15 +146,11 @@ function loadFieldsFromFile(filePath: string): FieldConfig[] {
153
146
  }
154
147
 
155
148
  if (!Array.isArray(parsed)) {
156
- throw new Error(
157
- `Schema file must contain an array of fields. Got: ${typeof parsed}`,
158
- );
149
+ throw new Error(`Schema file must contain an array of fields. Got: ${typeof parsed}`);
159
150
  }
160
151
 
161
152
  if (parsed.length === 0) {
162
- throw new Error(
163
- "Schema file contains an empty array. At least one field is required.",
164
- );
153
+ throw new Error("Schema file contains an empty array. At least one field is required.");
165
154
  }
166
155
 
167
156
  return (parsed as FieldConfig[]).map((field, index) => {
@@ -208,9 +197,7 @@ export const createTopicCommand = Command.make(
208
197
  const hasFields = fields.length > 0;
209
198
 
210
199
  if (!hasFields && !schemaFilePath) {
211
- return yield* Effect.fail(
212
- new Error("Either --fields or --schema-file must be provided"),
213
- );
200
+ return yield* Effect.fail(new Error("Either --fields or --schema-file must be provided"));
214
201
  }
215
202
 
216
203
  if (hasFields && schemaFilePath) {
@@ -221,11 +208,8 @@ export const createTopicCommand = Command.make(
221
208
 
222
209
  const topicFields = yield* Effect.try({
223
210
  try: () =>
224
- schemaFilePath
225
- ? loadFieldsFromFile(schemaFilePath)
226
- : parseFieldsFromArgs(fields),
227
- catch: (error) =>
228
- error instanceof Error ? error : new Error("Failed to parse fields"),
211
+ schemaFilePath ? loadFieldsFromFile(schemaFilePath) : parseFieldsFromArgs(fields),
212
+ catch: (error) => (error instanceof Error ? error : new Error("Failed to parse fields")),
229
213
  });
230
214
 
231
215
  if (topicFields.length === 0) {
@@ -235,9 +219,7 @@ export const createTopicCommand = Command.make(
235
219
  const partitionKeyName = Option.getOrUndefined(partitionKey);
236
220
  let partitionKeyId: bigint | undefined;
237
221
  if (partitionKeyName) {
238
- const index = topicFields.findIndex(
239
- (field) => field.name === partitionKeyName,
240
- );
222
+ const index = topicFields.findIndex((field) => field.name === partitionKeyName);
241
223
  if (index === -1) {
242
224
  return yield* Effect.fail(
243
225
  new Error(
@@ -269,16 +251,12 @@ export const createTopicCommand = Command.make(
269
251
  partitionKey: partitionKeyId,
270
252
  compaction: {
271
253
  freshnessSeconds: BigInt(freshnessSeconds),
272
- ttlSeconds: Option.getOrUndefined(
273
- Option.map(ttlSeconds, (ttl) => BigInt(ttl)),
274
- ),
254
+ ttlSeconds: Option.getOrUndefined(Option.map(ttlSeconds, (ttl) => BigInt(ttl))),
275
255
  targetFileSizeBytes: BigInt(targetFileSizeBytes),
276
256
  },
277
257
  }).pipe(
278
258
  Effect.provide(layer),
279
- Effect.tapError(() =>
280
- Effect.sync(() => s.stop("Failed to create topic")),
281
- ),
259
+ Effect.tapError(() => Effect.sync(() => s.stop("Failed to create topic"))),
282
260
  );
283
261
 
284
262
  s.stop("Topic created successfully");
@@ -290,13 +268,12 @@ export const createTopicCommand = Command.make(
290
268
  description: topic.description || "-",
291
269
  partition_key:
292
270
  topic.partitionKey !== undefined
293
- ? topicFields.find((field) => field.id === topic.partitionKey)
294
- ?.name || topic.partitionKey.toString()
271
+ ? topicFields.find((field) => field.id === topic.partitionKey)?.name ||
272
+ topic.partitionKey.toString()
295
273
  : "-",
296
274
  freshness_seconds: topic.compaction.freshnessSeconds.toString(),
297
275
  ttl_seconds: topic.compaction.ttlSeconds?.toString() || "-",
298
- target_file_size_bytes:
299
- topic.compaction.targetFileSizeBytes.toString(),
276
+ target_file_size_bytes: topic.compaction.targetFileSizeBytes.toString(),
300
277
  fields_count: topic.schema.fields.length.toString(),
301
278
  },
302
279
  ]);
@@ -315,5 +292,5 @@ export const createTopicCommand = Command.make(
315
292
 
316
293
  p.outro("✓ Done");
317
294
  });
318
- }).pipe(Effect.catchAll(handleCliError("Failed to create topic"))),
295
+ }),
319
296
  ).pipe(Command.withDescription("Create a new topic belonging to a namespace"));
@@ -1,20 +1,20 @@
1
1
  import * as p from "@clack/prompts";
2
- import { Command, Options } from "@effect/cli";
3
2
  import { WingsClusterMetadata } from "@useairfoil/wings";
4
3
  import { Effect } from "effect";
4
+ import { Command, Flag } from "effect/unstable/cli";
5
+
5
6
  import { makeClusterMetadataLayer } from "../../../utils/client.js";
6
- import { handleCliError } from "../../../utils/effect.js";
7
7
  import { forceOption, hostOption, portOption } from "../../../utils/options.js";
8
8
 
9
- const nameOption = Options.text("name").pipe(
10
- Options.withDescription(
9
+ const nameOption = Flag.string("name").pipe(
10
+ Flag.withDescription(
11
11
  "Topic name in format: tenants/{tenant}/namespaces/{namespace}/topics/{topic}",
12
12
  ),
13
13
  );
14
14
 
15
- const forceDeleteOption = Options.boolean("force-delete").pipe(
16
- Options.withDescription("Also delete data associated with the topic"),
17
- Options.withDefault(false),
15
+ const forceDeleteOption = Flag.boolean("force-delete").pipe(
16
+ Flag.withDescription("Also delete data associated with the topic"),
17
+ Flag.withDefault(false),
18
18
  );
19
19
 
20
20
  export const deleteTopicCommand = Command.make(
@@ -56,12 +56,10 @@ export const deleteTopicCommand = Command.make(
56
56
  force: forceDelete,
57
57
  }).pipe(
58
58
  Effect.provide(layer),
59
- Effect.tapError(() =>
60
- Effect.sync(() => s.stop("Failed to delete topic")),
61
- ),
59
+ Effect.tapError(() => Effect.sync(() => s.stop("Failed to delete topic"))),
62
60
  );
63
61
 
64
62
  s.stop("Topic deleted successfully");
65
63
  p.outro("✓ Done");
66
- }).pipe(Effect.catchAll(handleCliError("Failed to delete topic"))),
64
+ }),
67
65
  ).pipe(Command.withDescription("Delete a topic from the cluster"));
@@ -1,14 +1,14 @@
1
1
  import * as p from "@clack/prompts";
2
- import { Command, Options } from "@effect/cli";
3
2
  import { WingsClusterMetadata } from "@useairfoil/wings";
4
3
  import { printTable } from "console-table-printer";
5
4
  import { Effect } from "effect";
5
+ import { Command, Flag } from "effect/unstable/cli";
6
+
6
7
  import { makeClusterMetadataLayer } from "../../../utils/client.js";
7
- import { handleCliError } from "../../../utils/effect.js";
8
8
  import { hostOption, portOption } from "../../../utils/options.js";
9
9
 
10
- const nameOption = Options.text("name").pipe(
11
- Options.withDescription(
10
+ const nameOption = Flag.string("name").pipe(
11
+ Flag.withDescription(
12
12
  "Topic name in format: tenants/{tenant}/namespaces/{namespace}/topics/{topic}",
13
13
  ),
14
14
  );
@@ -47,5 +47,5 @@ export const getTopicCommand = Command.make(
47
47
 
48
48
  p.outro("✓ Done");
49
49
  });
50
- }).pipe(Effect.catchAll(handleCliError("Failed to get topic"))),
50
+ }),
51
51
  ).pipe(Command.withDescription("Get details of a specific topic"));
@@ -1,21 +1,14 @@
1
1
  import * as p from "@clack/prompts";
2
- import { Command, Options } from "@effect/cli";
3
2
  import { WingsClusterMetadata } from "@useairfoil/wings";
4
3
  import { printTable } from "console-table-printer";
5
4
  import { Effect, Option } from "effect";
5
+ import { Command, Flag } from "effect/unstable/cli";
6
+
6
7
  import { makeClusterMetadataLayer } from "../../../utils/client.js";
7
- import { handleCliError } from "../../../utils/effect.js";
8
- import {
9
- hostOption,
10
- pageSizeOption,
11
- pageTokenOption,
12
- portOption,
13
- } from "../../../utils/options.js";
8
+ import { hostOption, pageSizeOption, pageTokenOption, portOption } from "../../../utils/options.js";
14
9
 
15
- const parentOption = Options.text("parent").pipe(
16
- Options.withDescription(
17
- "Parent namespace in format: tenants/{tenant}/namespaces/{namespace}",
18
- ),
10
+ const parentOption = Flag.string("parent").pipe(
11
+ Flag.withDescription("Parent namespace in format: tenants/{tenant}/namespaces/{namespace}"),
19
12
  );
20
13
 
21
14
  export const listTopicsCommand = Command.make(
@@ -40,9 +33,7 @@ export const listTopicsCommand = Command.make(
40
33
  pageToken: Option.getOrUndefined(pageToken),
41
34
  }).pipe(
42
35
  Effect.provide(layer),
43
- Effect.tapError(() =>
44
- Effect.sync(() => s.stop("Failed to list topics")),
45
- ),
36
+ Effect.tapError(() => Effect.sync(() => s.stop("Failed to list topics"))),
46
37
  );
47
38
 
48
39
  s.stop(`Found ${response.topics.length} topic(s)`);
@@ -66,5 +57,5 @@ export const listTopicsCommand = Command.make(
66
57
 
67
58
  p.outro("✓ Done");
68
59
  });
69
- }).pipe(Effect.catchAll(handleCliError("Failed to list topics"))),
60
+ }),
70
61
  ).pipe(Command.withDescription("List all topics belonging to a namespace"));
@@ -1,40 +1,39 @@
1
- import * as p from "@clack/prompts";
2
- import { Command, Options } from "@effect/cli";
3
- import { Effect } from "effect";
4
- import { handleCliError } from "../utils/effect.js";
5
- import { downloadWings, getWingsPath, verifyChecksum } from "../utils/wings.js";
6
-
7
- const dockerOption = Options.boolean("docker").pipe(
8
- Options.withDescription("Run Wings using Docker (recommended)"),
9
- Options.withDefault(false),
1
+ import { Effect, FileSystem } from "effect";
2
+ import { Command, Flag, Prompt } from "effect/unstable/cli";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+
5
+ import { checkDockerVersion, createDockerVolume, runDockerContainer } from "../utils/docker";
6
+ import { downloadWings, getWingsPath } from "../utils/wings";
7
+
8
+ const dockerOption = Flag.boolean("docker").pipe(
9
+ Flag.withDescription("Run Wings using Docker (recommended)"),
10
+ Flag.withDefault(false),
10
11
  );
11
12
 
12
- const versionOption = Options.text("version").pipe(
13
- Options.withDescription(
14
- "Specify Wings version (e.g., v0.1.0-alpha.11 or 'latest')",
15
- ),
16
- Options.withDefault("latest"),
13
+ const versionOption = Flag.string("version").pipe(
14
+ Flag.withDescription("Specify Wings version (e.g., v0.1.0-alpha.11 or 'latest')"),
15
+ Flag.withDefault("latest"),
17
16
  );
18
17
 
19
- const tagOption = Options.text("tag").pipe(
20
- Options.withDescription("Docker image tag (only with --docker)"),
21
- Options.withDefault("latest"),
18
+ const tagOption = Flag.string("tag").pipe(
19
+ Flag.withDescription("Docker image tag (only with --docker)"),
20
+ Flag.withDefault("latest"),
22
21
  );
23
22
 
24
- const forcePullOption = Options.boolean("force-pull").pipe(
25
- Options.withDescription("Force pull Docker image even if it exists locally"),
26
- Options.withDefault(false),
23
+ const forcePullOption = Flag.boolean("force-pull").pipe(
24
+ Flag.withDescription("Force pull Docker image even if it exists locally"),
25
+ Flag.withDefault(false),
27
26
  );
28
27
 
29
- const stressOption = Options.boolean("stress").pipe(
30
- Options.withDescription("Use Wings stress binary variant"),
31
- Options.withDefault(false),
28
+ const stressOption = Flag.boolean("stress").pipe(
29
+ Flag.withDescription("Use Wings stress binary variant"),
30
+ Flag.withDefault(false),
32
31
  );
33
32
 
34
- const yesOption = Options.boolean("yes").pipe(
35
- Options.withAlias("y"),
36
- Options.withDescription("Skip confirmation prompts"),
37
- Options.withDefault(false),
33
+ const yesOption = Flag.boolean("yes").pipe(
34
+ Flag.withAlias("y"),
35
+ Flag.withDescription("Skip confirmation prompts"),
36
+ Flag.withDefault(false),
38
37
  );
39
38
 
40
39
  export const devCommand = Command.make(
@@ -47,189 +46,87 @@ export const devCommand = Command.make(
47
46
  stress: stressOption,
48
47
  yes: yesOption,
49
48
  },
50
- (options) =>
51
- Effect.tryPromise({
52
- try: () =>
53
- options.docker ? runWithDocker(options) : runWithBinary(options),
54
- catch: (error) =>
55
- error instanceof Error ? error : new Error("Operation failed"),
56
- }).pipe(Effect.catchAll(handleCliError("Operation failed"))),
49
+ (options) => (options.docker ? runWithDocker(options) : runWithBinary(options)),
57
50
  ).pipe(
58
51
  Command.withDescription(
59
52
  "Download and run Wings dev server locally (Docker recommended for portability)",
60
53
  ),
61
54
  );
62
55
 
63
- async function runWithBinary(options: {
64
- version: string;
65
- yes: boolean;
66
- stress: boolean;
67
- }) {
68
- p.intro("🪽 Airfoil Dev");
69
-
70
- const version = options.version;
71
- const isStress = options.stress;
72
- const wingsPath = getWingsPath(version, isStress);
73
- const fileExists = await Bun.file(wingsPath).exists();
74
-
75
- if (!fileExists) {
76
- p.log.warn(
77
- `Wings${isStress ? " stress" : ""} binary not found for version ${version}`,
78
- );
79
-
80
- if (!options.yes) {
81
- const confirm = await p.confirm({
82
- message: `Download Wings${isStress ? " stress" : ""} ${version} binary? (~100MB)`,
83
- initialValue: true,
84
- });
85
-
86
- if (p.isCancel(confirm) || !confirm) {
87
- p.cancel("Download cancelled");
88
- process.exit(0);
89
- }
90
- }
56
+ const runWithBinary = (options: { version: string; yes: boolean; stress: boolean }) =>
57
+ Effect.gen(function* () {
58
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
59
+ const fs = yield* FileSystem.FileSystem;
91
60
 
92
- const s = p.spinner();
93
- s.start(`Downloading Wings${isStress ? " stress" : ""} ${version}...`);
61
+ yield* Effect.logInfo("🪽 Airfoil Dev");
94
62
 
95
- try {
96
- await downloadWings(version, wingsPath, isStress);
97
- s.stop(`Downloaded Wings${isStress ? " stress" : ""} ${version}`);
98
- } catch (error) {
99
- s.stop("Download failed");
100
- throw error;
101
- }
63
+ const version = options.version;
64
+ const isStress = options.stress;
65
+ const wingsPath = yield* getWingsPath(version, isStress);
66
+ const exists = yield* fs.exists(wingsPath);
102
67
 
103
- const s2 = p.spinner();
104
- s2.start("Verifying checksum...");
68
+ if (!exists) {
69
+ yield* Effect.logWarning(
70
+ `Wings${isStress ? " stress" : ""} binary not found for version ${version}`,
71
+ );
105
72
 
106
- try {
107
- const isValid = await verifyChecksum(version, wingsPath, isStress);
73
+ if (!options.yes) {
74
+ const confirm = yield* Prompt.confirm({
75
+ message: `Download Wings${isStress ? " stress" : ""} ${version} binary? (~100MB)`,
76
+ initial: true,
77
+ });
108
78
 
109
- if (!isValid) {
110
- s2.stop("Checksum verification failed");
111
- throw new Error(
112
- "Binary checksum does not match expected value. This could indicate a corrupted download or security issue.",
113
- );
79
+ if (!confirm) {
80
+ return yield* Effect.fail(new Error("Download cancelled"));
81
+ }
114
82
  }
115
83
 
116
- s2.stop("Checksum verified ✓");
117
- } catch (error) {
118
- s2.stop("Checksum verification failed");
119
- throw error;
84
+ yield* Effect.logInfo(`Downloading Wings${isStress ? " stress" : ""} ${version}...`);
85
+
86
+ yield* downloadWings(version, wingsPath, isStress);
87
+ } else {
88
+ yield* Effect.logInfo(`Using cached Wings${isStress ? " stress" : ""} binary (${version})`);
120
89
  }
121
90
 
122
- await Bun.$`chmod +x ${wingsPath}`;
123
- } else {
124
- p.log.success(
125
- `Using cached Wings${isStress ? " stress" : ""} binary (${version})`,
126
- );
127
- }
91
+ yield* Effect.logInfo(`Starting Wings dev server...`);
92
+
93
+ const proc = ChildProcess.make(wingsPath, ["dev"], {
94
+ stdin: "inherit",
95
+ stdout: "inherit",
96
+ });
128
97
 
129
- p.log.info("Starting Wings dev server...");
98
+ const handle = yield* spawner.spawn(proc);
130
99
 
131
- const proc = Bun.spawn([wingsPath, "dev"], {
132
- stdout: "inherit",
133
- stderr: "inherit",
134
- stdin: "inherit",
135
- });
100
+ const exitCode = yield* handle.exitCode;
136
101
 
137
- await proc.exited;
138
- }
139
-
140
- async function runWithDocker(options: { tag: string; forcePull: boolean }) {
141
- p.intro("🐳 Airfoil Dev (Docker)");
142
-
143
- let tag = options.tag;
144
-
145
- // If tag is not "latest" and doesn't include architecture, append it
146
- // Format: 0.1.0-alpha.10-aarch64 or 0.1.0-alpha.10-x86_64
147
- if (tag !== "latest" && !tag.includes("aarch64") && !tag.includes("x86_64")) {
148
- const arch = process.arch === "arm64" ? "aarch64" : "x86_64";
149
- // Remove 'v' prefix if present for docker tags
150
- const cleanTag = tag.startsWith("v") ? tag.substring(1) : tag;
151
- tag = `${cleanTag}-${arch}`;
152
- }
153
-
154
- const image = `docker.useairfoil.com/airfoil/wings:${tag}`;
155
-
156
- p.log.info(`Using Docker image: ${image}`);
157
- p.log.info("Ports: 7777 (metadata), 7780 (http)");
158
-
159
- try {
160
- await Bun.$`docker --version`.quiet();
161
- } catch {
162
- p.cancel(
163
- "Docker is not installed or not running. Please install Docker first.",
164
- );
165
- process.exit(1);
166
- }
167
-
168
- let imageExists = false;
169
- if (!options.forcePull) {
170
- try {
171
- const result = await Bun.$`docker images -q ${image}`.text();
172
- imageExists = result.trim().length > 0;
173
- } catch {
174
- imageExists = false;
102
+ if (exitCode !== 0) {
103
+ yield* Effect.fail(new Error(`Process exited with code ${exitCode}`));
175
104
  }
176
- }
177
-
178
- if (!imageExists || options.forcePull) {
179
- const s = p.spinner();
180
- s.start(
181
- options.forcePull
182
- ? "Pulling latest Docker image..."
183
- : "Downloading Docker image...",
184
- );
185
-
186
- try {
187
- await Bun.$`docker pull ${image}`.quiet();
188
- s.stop("Docker image ready");
189
- } catch {
190
- s.stop("Failed to pull image");
191
- p.cancel(
192
- "Could not download Docker image. Please check your internet connection and registry access.",
193
- );
194
- process.exit(1);
105
+ });
106
+
107
+ const runWithDocker = (options: { tag: string; forcePull: boolean }) =>
108
+ Effect.gen(function* () {
109
+ yield* Effect.logInfo("🐳 Airfoil Dev (Docker)");
110
+
111
+ let tag = options.tag;
112
+
113
+ // If tag is not "latest" and doesn't include architecture, append it
114
+ // Format: 0.1.0-alpha.10-aarch64 or 0.1.0-alpha.10-x86_64
115
+ if (tag !== "latest" && !tag.includes("aarch64") && !tag.includes("x86_64")) {
116
+ const arch = process.arch === "arm64" ? "aarch64" : "x86_64";
117
+ // Remove 'v' prefix if present for docker tags
118
+ const cleanTag = tag.startsWith("v") ? tag.substring(1) : tag;
119
+ tag = `${cleanTag}-${arch}`;
195
120
  }
196
- } else {
197
- p.log.success("Using cached Docker image");
198
- }
199
-
200
- try {
201
- await Bun.$`docker volume create wings-data`.quiet();
202
- } catch {
203
- // volume may already exist, that's fine
204
- }
205
-
206
- p.log.info("Starting Wings dev server...");
207
-
208
- const proc = Bun.spawn(
209
- [
210
- "docker",
211
- "run",
212
- "-it",
213
- "--rm",
214
- "--name",
215
- "wings-dev",
216
- "-v",
217
- "wings-data:/tmp",
218
- "-p",
219
- "7777:7777",
220
- "-p",
221
- "7780:7780",
222
- image,
223
- "dev",
224
- "--http.address=0.0.0.0:7780",
225
- "--metadata.address=0.0.0.0:7777",
226
- ],
227
- {
228
- stdout: "inherit",
229
- stderr: "inherit",
230
- stdin: "inherit",
231
- },
232
- );
233
121
 
234
- await proc.exited;
235
- }
122
+ const image = `docker.useairfoil.com/airfoil/wings:${tag}`;
123
+
124
+ yield* Effect.logInfo(`Using Docker image: ${image}`);
125
+ yield* Effect.logInfo("Ports: 7777 (gRPC), 7780 (http)");
126
+
127
+ yield* checkDockerVersion();
128
+
129
+ yield* createDockerVolume("wings-data");
130
+
131
+ yield* runDockerContainer(image, "wings-data");
132
+ });