airfoil 0.1.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 (43) hide show
  1. package/.turbo/turbo-build.log +6 -0
  2. package/CHANGELOG.json +20 -0
  3. package/CHANGELOG.md +13 -0
  4. package/dist/index.js +100608 -0
  5. package/package.json +36 -0
  6. package/src/commands/cluster/data-lake/create-iceberg.ts +61 -0
  7. package/src/commands/cluster/data-lake/create-parquet.ts +61 -0
  8. package/src/commands/cluster/data-lake/create.ts +16 -0
  9. package/src/commands/cluster/data-lake/delete.ts +58 -0
  10. package/src/commands/cluster/data-lake/get.ts +46 -0
  11. package/src/commands/cluster/data-lake/list.ts +72 -0
  12. package/src/commands/cluster/index.ts +52 -0
  13. package/src/commands/cluster/namespace/create.ts +107 -0
  14. package/src/commands/cluster/namespace/delete.ts +62 -0
  15. package/src/commands/cluster/namespace/get.ts +52 -0
  16. package/src/commands/cluster/namespace/list.ts +80 -0
  17. package/src/commands/cluster/object-store/create-aws.ts +108 -0
  18. package/src/commands/cluster/object-store/create-azure.ts +100 -0
  19. package/src/commands/cluster/object-store/create-google.ts +102 -0
  20. package/src/commands/cluster/object-store/create-s3.ts +125 -0
  21. package/src/commands/cluster/object-store/create.ts +20 -0
  22. package/src/commands/cluster/object-store/delete.ts +58 -0
  23. package/src/commands/cluster/object-store/get.ts +51 -0
  24. package/src/commands/cluster/object-store/list.ts +72 -0
  25. package/src/commands/cluster/tenant/create.ts +48 -0
  26. package/src/commands/cluster/tenant/delete.ts +62 -0
  27. package/src/commands/cluster/tenant/get.ts +44 -0
  28. package/src/commands/cluster/tenant/list.ts +60 -0
  29. package/src/commands/cluster/topic/create.ts +306 -0
  30. package/src/commands/cluster/topic/delete.ts +67 -0
  31. package/src/commands/cluster/topic/get.ts +51 -0
  32. package/src/commands/cluster/topic/list.ts +76 -0
  33. package/src/commands/dev.ts +235 -0
  34. package/src/commands/sql.ts +121 -0
  35. package/src/index.ts +18 -0
  36. package/src/scripts/compile-all.ts +21 -0
  37. package/src/utils/client.ts +6 -0
  38. package/src/utils/effect.ts +11 -0
  39. package/src/utils/options.ts +36 -0
  40. package/src/utils/wings.ts +116 -0
  41. package/test-commands.sh +209 -0
  42. package/tsconfig.json +31 -0
  43. package/useairfoil-0.1.0.tgz +0 -0
@@ -0,0 +1,51 @@
1
+ import { WingsClusterMetadata } from "@airfoil/wings";
2
+ import * as p from "@clack/prompts";
3
+ import { Command, Options } from "@effect/cli";
4
+ import { printTable } from "console-table-printer";
5
+ import { Effect } from "effect";
6
+ import { makeClusterMetadataLayer } from "../../../utils/client.js";
7
+ import { handleCliError } from "../../../utils/effect.js";
8
+ import { hostOption, portOption } from "../../../utils/options.js";
9
+
10
+ const nameOption = Options.text("name").pipe(
11
+ Options.withDescription(
12
+ "Topic name in format: tenants/{tenant}/namespaces/{namespace}/topics/{topic}",
13
+ ),
14
+ );
15
+
16
+ export const getTopicCommand = Command.make(
17
+ "get-topic",
18
+ {
19
+ name: nameOption,
20
+ host: hostOption,
21
+ port: portOption,
22
+ },
23
+ ({ name, host, port }) =>
24
+ Effect.gen(function* () {
25
+ const layer = makeClusterMetadataLayer(host, port);
26
+
27
+ const s = p.spinner();
28
+ s.start("Fetching topic...");
29
+
30
+ const topic = yield* WingsClusterMetadata.getTopic({ name }).pipe(
31
+ Effect.provide(layer),
32
+ Effect.tapError(() => Effect.sync(() => s.stop("Failed to get topic"))),
33
+ );
34
+
35
+ s.stop("Topic retrieved");
36
+
37
+ yield* Effect.sync(() => {
38
+ printTable([
39
+ {
40
+ name: topic.name,
41
+ description: topic.description || "-",
42
+ partition_key: topic.partitionKey?.toString() || "-",
43
+ freshness_seconds: topic.compaction.freshnessSeconds.toString(),
44
+ ttl_seconds: topic.compaction.ttlSeconds?.toString() || "-",
45
+ },
46
+ ]);
47
+
48
+ p.outro("✓ Done");
49
+ });
50
+ }).pipe(Effect.catchAll(handleCliError("Failed to get topic"))),
51
+ ).pipe(Command.withDescription("Get details of a specific topic"));
@@ -0,0 +1,76 @@
1
+ import { WingsClusterMetadata } from "@airfoil/wings";
2
+ import * as p from "@clack/prompts";
3
+ import { Command, Options } from "@effect/cli";
4
+ import { printTable } from "console-table-printer";
5
+ import { Effect, Option } from "effect";
6
+ 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";
14
+
15
+ const parentOption = Options.text("parent").pipe(
16
+ Options.withDescription(
17
+ "Parent namespace in format: tenants/{tenant}/namespaces/{namespace}",
18
+ ),
19
+ );
20
+
21
+ export const listTopicsCommand = Command.make(
22
+ "list-topics",
23
+ {
24
+ parent: parentOption,
25
+ pageSize: pageSizeOption,
26
+ pageToken: pageTokenOption,
27
+ host: hostOption,
28
+ port: portOption,
29
+ },
30
+ ({ parent, pageSize, pageToken, host, port }) =>
31
+ Effect.gen(function* () {
32
+ const layer = makeClusterMetadataLayer(host, port);
33
+
34
+ const s = p.spinner();
35
+ s.start("Fetching topics...");
36
+
37
+ const response = yield* WingsClusterMetadata.listTopics({
38
+ parent,
39
+ pageSize,
40
+ pageToken: Option.getOrUndefined(pageToken),
41
+ }).pipe(
42
+ Effect.provide(layer),
43
+ Effect.tapError(() =>
44
+ Effect.sync(() => s.stop("Failed to list topics")),
45
+ ),
46
+ );
47
+
48
+ s.stop(`Found ${response.topics.length} topic(s)`);
49
+
50
+ yield* Effect.sync(() => {
51
+ if (response.topics.length === 0) {
52
+ p.log.warn("No topics found");
53
+ } else {
54
+ printTable(
55
+ response.topics.map(
56
+ (topic: {
57
+ name: string;
58
+ description?: string | null;
59
+ partitionKey?: number | null;
60
+ }) => ({
61
+ name: topic.name,
62
+ description: topic.description || "-",
63
+ partition_key: topic.partitionKey?.toString() || "-",
64
+ }),
65
+ ),
66
+ );
67
+ }
68
+
69
+ if (response.nextPageToken) {
70
+ p.log.info(`Next page token: ${response.nextPageToken}`);
71
+ }
72
+
73
+ p.outro("✓ Done");
74
+ });
75
+ }).pipe(Effect.catchAll(handleCliError("Failed to list topics"))),
76
+ ).pipe(Command.withDescription("List all topics belonging to a namespace"));
@@ -0,0 +1,235 @@
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),
10
+ );
11
+
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"),
17
+ );
18
+
19
+ const tagOption = Options.text("tag").pipe(
20
+ Options.withDescription("Docker image tag (only with --docker)"),
21
+ Options.withDefault("latest"),
22
+ );
23
+
24
+ const forcePullOption = Options.boolean("force-pull").pipe(
25
+ Options.withDescription("Force pull Docker image even if it exists locally"),
26
+ Options.withDefault(false),
27
+ );
28
+
29
+ const stressOption = Options.boolean("stress").pipe(
30
+ Options.withDescription("Use Wings stress binary variant"),
31
+ Options.withDefault(false),
32
+ );
33
+
34
+ const yesOption = Options.boolean("yes").pipe(
35
+ Options.withAlias("y"),
36
+ Options.withDescription("Skip confirmation prompts"),
37
+ Options.withDefault(false),
38
+ );
39
+
40
+ export const devCommand = Command.make(
41
+ "dev",
42
+ {
43
+ docker: dockerOption,
44
+ version: versionOption,
45
+ tag: tagOption,
46
+ forcePull: forcePullOption,
47
+ stress: stressOption,
48
+ yes: yesOption,
49
+ },
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"))),
57
+ ).pipe(
58
+ Command.withDescription(
59
+ "Download and run Wings dev server locally (Docker recommended for portability)",
60
+ ),
61
+ );
62
+
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
+ }
91
+
92
+ const s = p.spinner();
93
+ s.start(`Downloading Wings${isStress ? " stress" : ""} ${version}...`);
94
+
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
+ }
102
+
103
+ const s2 = p.spinner();
104
+ s2.start("Verifying checksum...");
105
+
106
+ try {
107
+ const isValid = await verifyChecksum(version, wingsPath, isStress);
108
+
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
+ );
114
+ }
115
+
116
+ s2.stop("Checksum verified ✓");
117
+ } catch (error) {
118
+ s2.stop("Checksum verification failed");
119
+ throw error;
120
+ }
121
+
122
+ await Bun.$`chmod +x ${wingsPath}`;
123
+ } else {
124
+ p.log.success(
125
+ `Using cached Wings${isStress ? " stress" : ""} binary (${version})`,
126
+ );
127
+ }
128
+
129
+ p.log.info("Starting Wings dev server...");
130
+
131
+ const proc = Bun.spawn([wingsPath, "dev"], {
132
+ stdout: "inherit",
133
+ stderr: "inherit",
134
+ stdin: "inherit",
135
+ });
136
+
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;
175
+ }
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);
195
+ }
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
+
234
+ await proc.exited;
235
+ }
@@ -0,0 +1,121 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { ArrowFlightSqlClient } from "@airfoil/flight";
3
+ import * as p from "@clack/prompts";
4
+ import { Args, Command, Options } from "@effect/cli";
5
+ import { printTable } from "console-table-printer";
6
+ import { Effect, Option } from "effect";
7
+ import { Metadata } from "nice-grpc-common";
8
+ import { handleCliError } from "../utils/effect.js";
9
+ import { hostOption, portOption } from "../utils/options.js";
10
+
11
+ const queryArg = Args.text({ name: "query" }).pipe(
12
+ Args.withDescription("SQL query to execute"),
13
+ Args.optional,
14
+ );
15
+
16
+ const fileOption = Options.text("file").pipe(
17
+ Options.withAlias("f"),
18
+ Options.withDescription("Execute SQL from file"),
19
+ Options.optional,
20
+ );
21
+
22
+ const namespaceOption = Options.text("namespace").pipe(
23
+ Options.withAlias("n"),
24
+ Options.withDescription("Wings namespace"),
25
+ Options.withDefault("tenants/default/namespaces/default"),
26
+ );
27
+
28
+ const jsonOption = Options.boolean("json").pipe(
29
+ Options.withDescription("Output results as JSON"),
30
+ Options.withDefault(false),
31
+ );
32
+
33
+ export const sqlCommand = Command.make(
34
+ "sql",
35
+ {
36
+ query: queryArg,
37
+ host: hostOption,
38
+ port: portOption,
39
+ file: fileOption,
40
+ namespace: namespaceOption,
41
+ json: jsonOption,
42
+ },
43
+ ({ query, host, port, file, namespace, json }) =>
44
+ Effect.gen(function* () {
45
+ let sqlQuery = Option.getOrUndefined(query);
46
+ const filePath = Option.getOrUndefined(file);
47
+
48
+ if (filePath) {
49
+ sqlQuery = yield* Effect.try({
50
+ try: () => readFileSync(filePath, "utf-8"),
51
+ catch: (error) =>
52
+ new Error(
53
+ `Failed to read file: ${error instanceof Error ? error.message : "Unknown error"}`,
54
+ ),
55
+ });
56
+ }
57
+
58
+ if (!sqlQuery) {
59
+ return yield* Effect.fail(
60
+ new Error(
61
+ "No query provided. Use a query argument or --file option.",
62
+ ),
63
+ );
64
+ }
65
+
66
+ const hostPort = `${host}:${port}`;
67
+
68
+ p.intro("🔍 Arrow Flight SQL");
69
+ p.log.info(`Connecting to: ${hostPort}`);
70
+ p.log.info(`Namespace: ${namespace}`);
71
+
72
+ const client = new ArrowFlightSqlClient(
73
+ {
74
+ host: hostPort,
75
+ },
76
+ {
77
+ defaultCallOptions: {
78
+ "*": {
79
+ metadata: Metadata({
80
+ "x-wings-namespace": namespace,
81
+ }),
82
+ },
83
+ },
84
+ },
85
+ );
86
+
87
+ const s = p.spinner();
88
+ s.start("Executing query...");
89
+
90
+ const flightInfo = yield* Effect.tryPromise({
91
+ try: () => client.executeQuery({ query: sqlQuery }),
92
+ catch: (error) =>
93
+ error instanceof Error ? error : new Error("Query failed"),
94
+ }).pipe(Effect.tapError(() => Effect.sync(() => s.stop("Query failed"))));
95
+
96
+ const batches = yield* Effect.tryPromise({
97
+ try: () => Array.fromAsync(client.executeFlightInfo(flightInfo)),
98
+ catch: (error) =>
99
+ error instanceof Error ? error : new Error("Query failed"),
100
+ }).pipe(Effect.tapError(() => Effect.sync(() => s.stop("Query failed"))));
101
+
102
+ const data = batches.flatMap((batch) => batch.toArray());
103
+
104
+ s.stop(`Query executed: ${sqlQuery}`);
105
+
106
+ yield* Effect.sync(() => {
107
+ if (json) {
108
+ console.log(JSON.stringify(data, null, 2));
109
+ } else if (data.length === 0) {
110
+ p.log.warn("No results returned");
111
+ } else {
112
+ printTable(data, {
113
+ defaultColumnOptions: {
114
+ alignment: "left",
115
+ },
116
+ });
117
+ }
118
+ p.outro("✓ Done");
119
+ });
120
+ }).pipe(Effect.catchAll(handleCliError("Command failed"))),
121
+ ).pipe(Command.withDescription("Execute SQL queries using Arrow Flight SQL"));
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "@effect/cli";
3
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
+ import { Effect } from "effect";
5
+ import { clusterCommand } from "./commands/cluster/index.js";
6
+ import { devCommand } from "./commands/dev.js";
7
+ import { sqlCommand } from "./commands/sql.js";
8
+
9
+ const program = Command.make("airfoil", {}, () => Effect.void).pipe(
10
+ Command.withSubcommands([devCommand, sqlCommand, clusterCommand]),
11
+ );
12
+
13
+ const cli = Command.run(program, {
14
+ name: "Airfoil CLI - Manage your Wings deployments",
15
+ version: "0.1.0",
16
+ });
17
+
18
+ cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain);
@@ -0,0 +1,21 @@
1
+ import { $ } from "bun";
2
+
3
+ const targets = [
4
+ { platform: "linux", arch: "x64" },
5
+ { platform: "linux", arch: "arm64" },
6
+ { platform: "darwin", arch: "x64" },
7
+ { platform: "darwin", arch: "arm64" },
8
+ { platform: "windows", arch: "x64" },
9
+ ];
10
+
11
+ for (const { platform, arch } of targets) {
12
+ const outfile = `dist/airfoil-${platform}-${arch}${platform === "windows" ? ".exe" : ""}`;
13
+
14
+ console.log(`Compiling for ${platform}-${arch}...`);
15
+
16
+ await $`bun build src/index.ts --compile --target=bun-${platform}-${arch} --outfile ${outfile}`;
17
+
18
+ console.log(`✓ Created ${outfile}`);
19
+ }
20
+
21
+ console.log("\n✓ All binaries compiled successfully!");
@@ -0,0 +1,6 @@
1
+ import { WingsClusterMetadata } from "@airfoil/wings";
2
+
3
+ export const makeClusterMetadataLayer = (host: string, port: number) =>
4
+ WingsClusterMetadata.layer({
5
+ host: `${host}:${port}`,
6
+ });
@@ -0,0 +1,11 @@
1
+ import * as p from "@clack/prompts";
2
+ import { Effect } from "effect";
3
+
4
+ export const handleCliError = (fallbackMessage: string) => (error: unknown) =>
5
+ Effect.sync(() => {
6
+ p.cancel(error instanceof Error ? error.message : fallbackMessage);
7
+ }).pipe(
8
+ Effect.andThen(
9
+ Effect.fail(error instanceof Error ? error : new Error(fallbackMessage)),
10
+ ),
11
+ );
@@ -0,0 +1,36 @@
1
+ import { Options } from "@effect/cli";
2
+
3
+ /**
4
+ * Common server connection options
5
+ */
6
+ export const hostOption = Options.text("host").pipe(
7
+ Options.withDescription("Server host"),
8
+ Options.withDefault("localhost"),
9
+ );
10
+
11
+ export const portOption = Options.integer("port").pipe(
12
+ Options.withDescription("Server port"),
13
+ Options.withAlias("p"),
14
+ Options.withDefault(7777),
15
+ );
16
+
17
+ /**
18
+ * Common pagination options
19
+ */
20
+ export const pageSizeOption = Options.integer("page-size").pipe(
21
+ Options.withDescription("Number of items to return (max: 1000)"),
22
+ Options.withDefault(100),
23
+ );
24
+
25
+ export const pageTokenOption = Options.text("page-token").pipe(
26
+ Options.withDescription("Continuation token for pagination"),
27
+ Options.optional,
28
+ );
29
+
30
+ /**
31
+ * Force flag for destructive operations
32
+ */
33
+ export const forceOption = Options.boolean("force").pipe(
34
+ Options.withDescription("Skip confirmation prompt"),
35
+ Options.withDefault(false),
36
+ );
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const WINGS_DIR = join(homedir(), ".airfoil", "wings");
6
+ const GITHUB_RELEASES_URL = "https://github.com/useairfoil/wings/releases";
7
+
8
+ /**
9
+ * Get the platform string for Wings binary naming
10
+ * Examples: "aarch64-linux", "x86_64-linux", "aarch64-macos"
11
+ */
12
+ function getPlatformString(): string {
13
+ const platform = process.platform;
14
+ const arch = process.arch;
15
+
16
+ const archMap: Record<string, string> = {
17
+ arm64: "aarch64",
18
+ x64: "x86_64",
19
+ };
20
+
21
+ const osMap: Record<string, string> = {
22
+ darwin: "macos",
23
+ linux: "linux",
24
+ win32: "windows",
25
+ };
26
+
27
+ const mappedArch = archMap[arch] || arch;
28
+ const mappedOs = osMap[platform] || platform;
29
+
30
+ return `${mappedArch}-${mappedOs}`;
31
+ }
32
+
33
+ /**
34
+ * Get the binary filename based on platform and stress variant
35
+ * Examples:
36
+ * - "wings-aarch64-macos"
37
+ * - "wings-stress-aarch64-linux"
38
+ */
39
+ function getBinaryFilename(isStress: boolean): string {
40
+ const platformString = getPlatformString();
41
+ return isStress
42
+ ? `wings-stress-${platformString}`
43
+ : `wings-${platformString}`;
44
+ }
45
+
46
+ export function getWingsPath(version: string, isStress = false): string {
47
+ const binaryFilename = getBinaryFilename(isStress);
48
+ return join(WINGS_DIR, `${binaryFilename}-${version}`);
49
+ }
50
+
51
+ export async function downloadWings(
52
+ version: string,
53
+ targetPath: string,
54
+ isStress = false,
55
+ ): Promise<void> {
56
+ const filename = getBinaryFilename(isStress);
57
+
58
+ const downloadPath =
59
+ version === "latest" ? "latest/download" : `download/${version}`;
60
+ const url = `${GITHUB_RELEASES_URL}/${downloadPath}/${filename}`;
61
+
62
+ await Bun.$`mkdir -p ${WINGS_DIR}`;
63
+
64
+ const response = await fetch(url);
65
+
66
+ if (!response.ok) {
67
+ throw new Error(
68
+ `Failed to download Wings from ${url}: ${response.status} ${response.statusText}`,
69
+ );
70
+ }
71
+
72
+ const buffer = await response.arrayBuffer();
73
+ await Bun.write(targetPath, buffer);
74
+ }
75
+
76
+ /**
77
+ * Download and verify the checksum of a Wings binary
78
+ * The hash file format is: "<hash> <path>/wings"
79
+ */
80
+ export async function verifyChecksum(
81
+ version: string,
82
+ filePath: string,
83
+ isStress = false,
84
+ ): Promise<boolean> {
85
+ const filename = getBinaryFilename(isStress);
86
+ const hashFilename = `${filename}-hash.txt`;
87
+
88
+ const downloadPath =
89
+ version === "latest" ? "latest/download" : `download/${version}`;
90
+ const hashUrl = `${GITHUB_RELEASES_URL}/${downloadPath}/${hashFilename}`;
91
+
92
+ const hashResponse = await fetch(hashUrl);
93
+
94
+ if (!hashResponse.ok) {
95
+ throw new Error(
96
+ `Failed to download hash file from ${hashUrl}: ${hashResponse.status} ${hashResponse.statusText}`,
97
+ );
98
+ }
99
+
100
+ const hashContent = await hashResponse.text();
101
+
102
+ const expectedChecksum = hashContent.trim().split(/\s+/)[0];
103
+
104
+ if (!expectedChecksum) {
105
+ throw new Error("Invalid hash file format");
106
+ }
107
+
108
+ const file = Bun.file(filePath);
109
+ const buffer = await file.arrayBuffer();
110
+
111
+ const hash = createHash("sha256");
112
+ hash.update(new Uint8Array(buffer));
113
+ const actualChecksum = hash.digest("hex");
114
+
115
+ return actualChecksum === expectedChecksum;
116
+ }