create-windy 0.6.6 → 0.6.8

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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +11 -68
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/apps/agent-server/README.md +4 -4
  6. package/template/apps/server/Dockerfile +3 -2
  7. package/template/apps/server/src/ai-runtime-report/actor-handle.test.ts +2 -1
  8. package/template/apps/server/src/data-access/drizzle-predicate.ts +16 -6
  9. package/template/apps/web/Dockerfile +1 -0
  10. package/template/docs/architecture/ai-egress.md +1 -1
  11. package/template/docs/architecture/ai-runtime-readiness.md +2 -2
  12. package/template/docs/architecture/ai-runtime.md +15 -15
  13. package/template/docs/architecture/module-secret-resolution.md +4 -4
  14. package/template/docs/architecture/report-staging.md +1 -1
  15. package/template/docs/development/custom-login-page.md +3 -3
  16. package/template/docs/license/license-offline-activation-code.md +2 -2
  17. package/template/docs/operations/incremental-delivery.md +213 -0
  18. package/template/docs/platform/agent-runtime.md +4 -4
  19. package/template/docs/platform/document-ai-runtime.md +3 -3
  20. package/template/docs/platform/speech-ai-runtime.md +1 -1
  21. package/template/docs/platform/vision-ai-runtime.md +1 -1
  22. package/template/docs/product/built-in-roles.md +9 -1
  23. package/template/package.json +2 -1
  24. package/template/packages/delivery/bin/windy-delivery +2 -0
  25. package/template/packages/delivery/index.ts +14 -0
  26. package/template/packages/delivery/package.json +23 -0
  27. package/template/packages/delivery/src/archive-portability.test.ts +84 -0
  28. package/template/packages/delivery/src/archive.ts +247 -0
  29. package/template/packages/delivery/src/blob-store.ts +242 -0
  30. package/template/packages/delivery/src/builder.ts +199 -0
  31. package/template/packages/delivery/src/checksums.ts +83 -0
  32. package/template/packages/delivery/src/cli.ts +331 -0
  33. package/template/packages/delivery/src/delivery.test.ts +332 -0
  34. package/template/packages/delivery/src/digest.ts +31 -0
  35. package/template/packages/delivery/src/manifest.ts +255 -0
  36. package/template/packages/delivery/src/negotiation.ts +68 -0
  37. package/template/packages/delivery/src/pack.test.ts +165 -0
  38. package/template/packages/delivery/src/paths.ts +27 -0
  39. package/template/packages/delivery/src/process-command.ts +49 -0
  40. package/template/packages/delivery/src/redact.ts +14 -0
  41. package/template/packages/delivery/src/registry.test.ts +166 -0
  42. package/template/packages/delivery/src/registry.ts +148 -0
  43. package/template/packages/delivery/src/security.test.ts +41 -0
  44. package/template/packages/delivery/src/ssh-capabilities.ts +65 -0
  45. package/template/packages/delivery/src/ssh-process.ts +349 -0
  46. package/template/packages/delivery/src/ssh-sync.test.ts +236 -0
  47. package/template/packages/delivery/src/ssh-sync.ts +167 -0
  48. package/template/packages/delivery/src/staging.ts +241 -0
  49. package/template/packages/delivery/src/transfer.ts +54 -0
  50. package/template/packages/delivery/src/types.ts +78 -0
  51. package/template/packages/delivery/tsconfig.json +16 -0
@@ -0,0 +1,349 @@
1
+ import type {
2
+ ContentBlob,
3
+ DeliveryCapabilities,
4
+ DeliveryManifestV2,
5
+ Sha256Digest,
6
+ } from "./types.js";
7
+ import type { SshDeliveryTransport } from "./ssh-sync.js";
8
+ import { redactSensitiveText } from "./redact.js";
9
+ import { runProcessCommand } from "./process-command.js";
10
+ import {
11
+ assertOwnerToken,
12
+ isExplicitLegacyCapabilityResponse,
13
+ parseCapabilities,
14
+ safeRemotePath,
15
+ safeSshTarget,
16
+ } from "./ssh-capabilities.js";
17
+
18
+ export interface SshProcessTransportOptions {
19
+ target: string;
20
+ remoteBinary: string;
21
+ remoteStore: string;
22
+ remoteInstallRoot: string;
23
+ remoteFallbackRoot: string;
24
+ }
25
+
26
+ export class SshProcessTransport implements SshDeliveryTransport {
27
+ readonly #target: string;
28
+ readonly #binary: string;
29
+ readonly #store: string;
30
+ readonly #installRoot: string;
31
+ readonly #fallbackRoot: string;
32
+
33
+ constructor(options: SshProcessTransportOptions) {
34
+ this.#target = safeSshTarget(options.target);
35
+ this.#binary = safeRemotePath(options.remoteBinary, "remoteBinary");
36
+ this.#store = safeRemotePath(options.remoteStore, "remoteStore");
37
+ this.#installRoot = safeRemotePath(
38
+ options.remoteInstallRoot,
39
+ "remoteInstallRoot",
40
+ );
41
+ this.#fallbackRoot = safeRemotePath(
42
+ options.remoteFallbackRoot,
43
+ "remoteFallbackRoot",
44
+ );
45
+ }
46
+
47
+ async capabilities(): Promise<DeliveryCapabilities | undefined> {
48
+ const result = await this.#run(
49
+ ["ssh", this.#target, this.#binary, "capabilities"],
50
+ true,
51
+ );
52
+ if (result.exitCode === 0) {
53
+ return parseCapabilities(result.stdout);
54
+ }
55
+ if (isExplicitLegacyCapabilityResponse(result)) return undefined;
56
+ throw new Error(
57
+ `SSH capability 探测失败 (${result.exitCode}):${redactSensitiveText(result.stderr)}`,
58
+ );
59
+ }
60
+
61
+ async lock(ownerToken: string): Promise<void> {
62
+ assertOwnerToken(ownerToken);
63
+ const lock = `${this.#installRoot}/.delivery-lock`;
64
+ await this.#run(["ssh", this.#target, "mkdir", "-p", this.#installRoot]);
65
+ const acquired = await this.#run(
66
+ ["ssh", this.#target, "mkdir", lock],
67
+ true,
68
+ );
69
+ if (acquired.exitCode !== 0) {
70
+ if (/File exists|文件已存在/i.test(acquired.stderr)) {
71
+ throw new Error("已有并发交付持有安装锁");
72
+ }
73
+ throw new Error(
74
+ `无法获取远端交付锁:${redactSensitiveText(acquired.stderr)}`,
75
+ );
76
+ }
77
+ try {
78
+ await this.#run(
79
+ ["ssh", this.#target, "tee", `${lock}/owner`],
80
+ false,
81
+ `${ownerToken}\n`,
82
+ );
83
+ await this.#run(["ssh", this.#target, "chmod", "600", `${lock}/owner`]);
84
+ } catch (error) {
85
+ await this.#run(["ssh", this.#target, "rm", "--", `${lock}/owner`], true);
86
+ await this.#run(["ssh", this.#target, "rmdir", "--", lock], true);
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ async unlock(ownerToken: string): Promise<void> {
92
+ assertOwnerToken(ownerToken);
93
+ const lock = `${this.#installRoot}/.delivery-lock`;
94
+ const owner = await this.#run([
95
+ "ssh",
96
+ this.#target,
97
+ "cat",
98
+ `${lock}/owner`,
99
+ ]);
100
+ if (owner.stdout.trim() !== ownerToken) {
101
+ throw new Error("交付锁 owner 不匹配,拒绝释放");
102
+ }
103
+ await this.#run(["ssh", this.#target, "rm", "--", `${lock}/owner`]);
104
+ await this.#run(["ssh", this.#target, "rmdir", "--", lock]);
105
+ }
106
+
107
+ async inventory(blobs: readonly ContentBlob[]): Promise<Set<Sha256Digest>> {
108
+ const result = await this.#json<{ present: Sha256Digest[] }>(
109
+ ["inventory", "--store", this.#store],
110
+ JSON.stringify(blobs),
111
+ );
112
+ return new Set(result.present);
113
+ }
114
+
115
+ async preflight(
116
+ manifest: DeliveryManifestV2,
117
+ ownerToken: string,
118
+ ): Promise<void> {
119
+ await this.#json(
120
+ ["preflight", "--store", this.#store, ...this.#installArgs(ownerToken)],
121
+ JSON.stringify(manifest),
122
+ );
123
+ }
124
+
125
+ async preflightFallback(
126
+ fallback: NonNullable<DeliveryManifestV2["compatibility"]["fullBundle"]>,
127
+ ownerToken: string,
128
+ ): Promise<void> {
129
+ await this.#verifyProcessLock(ownerToken);
130
+ await this.#run(["ssh", this.#target, "mkdir", "-p", this.#fallbackRoot]);
131
+ const disk = await this.#run([
132
+ "ssh",
133
+ this.#target,
134
+ "df",
135
+ "-Pk",
136
+ this.#fallbackRoot,
137
+ ]);
138
+ const line = disk.stdout.trim().split(/\r?\n/).at(-1) ?? "";
139
+ const availableKiB = Number(line.trim().split(/\s+/)[3]);
140
+ const requiredBytes = fallback.size + 512 * 1024 * 1024;
141
+ if (
142
+ !Number.isSafeInteger(availableKiB) ||
143
+ availableKiB < 0 ||
144
+ !Number.isSafeInteger(availableKiB * 1024)
145
+ ) {
146
+ throw new Error("无法解析 fallback 文件系统空间");
147
+ }
148
+ const availableBytes = availableKiB * 1024;
149
+ if (availableBytes < requiredBytes) {
150
+ throw new Error(
151
+ `fallback 空间不足:需要 ${requiredBytes},可用 ${availableBytes}`,
152
+ );
153
+ }
154
+ }
155
+
156
+ async partial(blob: ContentBlob): Promise<number> {
157
+ const result = await this.#json<{ offset: number }>([
158
+ "partial",
159
+ "--store",
160
+ this.#store,
161
+ ...blobArgs(blob),
162
+ ]);
163
+ return result.offset;
164
+ }
165
+
166
+ async upload(
167
+ blob: ContentBlob,
168
+ offset: number,
169
+ localPath: string,
170
+ ): Promise<number> {
171
+ const result = await this.#json<{
172
+ complete: boolean;
173
+ receivedBytes: number;
174
+ }>(
175
+ [
176
+ "receive",
177
+ "--store",
178
+ this.#store,
179
+ ...blobArgs(blob),
180
+ "--offset",
181
+ String(offset),
182
+ ],
183
+ Bun.file(localPath).slice(offset),
184
+ );
185
+ if (!result.complete || result.receivedBytes !== blob.size - offset) {
186
+ throw new Error(`远端未完整接收 blob:${blob.digest}`);
187
+ }
188
+ return result.receivedBytes;
189
+ }
190
+
191
+ async stage(
192
+ manifest: DeliveryManifestV2,
193
+ digest: Sha256Digest,
194
+ ownerToken: string,
195
+ ): Promise<string> {
196
+ const result = await this.#json<{ staged: boolean; directory: string }>(
197
+ [
198
+ "stage",
199
+ "--store",
200
+ this.#store,
201
+ "--manifest-digest",
202
+ digest,
203
+ ...this.#installArgs(ownerToken),
204
+ ],
205
+ JSON.stringify(manifest),
206
+ );
207
+ if (!result.staged) throw new Error("远端未确认 release stage");
208
+ return result.directory;
209
+ }
210
+
211
+ async uploadFallback(
212
+ fallback: NonNullable<DeliveryManifestV2["compatibility"]["fullBundle"]>,
213
+ localPath: string,
214
+ ownerToken: string,
215
+ ): Promise<{ remotePath: string; uploaded: boolean }> {
216
+ await this.#verifyProcessLock(ownerToken);
217
+ const remotePath = `${this.#fallbackRoot}/${fallback.file}`;
218
+ await this.#run(["ssh", this.#target, "mkdir", "-p", this.#fallbackRoot]);
219
+ const existing = await this.#run(
220
+ ["ssh", this.#target, "sha256sum", remotePath],
221
+ true,
222
+ );
223
+ if (existing.exitCode === 0) {
224
+ if (`sha256:${existing.stdout.split(/\s+/)[0]}` !== fallback.digest) {
225
+ throw new Error("远端已存在同名但不同 digest 的完整 bundle");
226
+ }
227
+ return { remotePath, uploaded: false };
228
+ }
229
+ if (!/No such file|not found/i.test(existing.stderr)) {
230
+ throw new Error(
231
+ `无法盘点远端完整 bundle:${redactSensitiveText(existing.stderr)}`,
232
+ );
233
+ }
234
+ const partial = `${remotePath}.${ownerToken}.partial`;
235
+ try {
236
+ await this.#run(["scp", localPath, `${this.#target}:${partial}`]);
237
+ const digest = await this.#run([
238
+ "ssh",
239
+ this.#target,
240
+ "sha256sum",
241
+ partial,
242
+ ]);
243
+ if (`sha256:${digest.stdout.split(/\s+/)[0]}` !== fallback.digest) {
244
+ throw new Error("远端完整 bundle 上传摘要不匹配");
245
+ }
246
+ await this.#run([
247
+ "ssh",
248
+ this.#target,
249
+ "mv",
250
+ "-n",
251
+ "--",
252
+ partial,
253
+ remotePath,
254
+ ]);
255
+ const committed = await this.#run([
256
+ "ssh",
257
+ this.#target,
258
+ "sha256sum",
259
+ remotePath,
260
+ ]);
261
+ if (`sha256:${committed.stdout.split(/\s+/)[0]}` !== fallback.digest) {
262
+ throw new Error("远端完整 bundle 原子提交后摘要不匹配");
263
+ }
264
+ return { remotePath, uploaded: true };
265
+ } finally {
266
+ await this.#run(["ssh", this.#target, "rm", "--", partial], true);
267
+ }
268
+ }
269
+
270
+ #installArgs(ownerToken: string): string[] {
271
+ assertOwnerToken(ownerToken);
272
+ return ["--install-root", this.#installRoot, "--owner-token", ownerToken];
273
+ }
274
+
275
+ async #json<T>(args: readonly string[], stdin?: string | Blob): Promise<T> {
276
+ const process = Bun.spawn(["ssh", this.#target, this.#binary, ...args], {
277
+ stdin: stdin === undefined ? "ignore" : "pipe",
278
+ stdout: "pipe",
279
+ stderr: "pipe",
280
+ });
281
+ const feed =
282
+ stdin === undefined ? Promise.resolve() : feedStdin(process, stdin);
283
+ const [exitCode, stdout, stderr] = await Promise.all([
284
+ process.exited,
285
+ new Response(process.stdout).text(),
286
+ new Response(process.stderr).text(),
287
+ feed,
288
+ ]);
289
+ if (exitCode !== 0) {
290
+ throw new Error(
291
+ `SSH delivery 命令失败 (${exitCode}):${redactSensitiveText(stderr.trim())}`,
292
+ );
293
+ }
294
+ return JSON.parse(stdout) as T;
295
+ }
296
+
297
+ async #run(
298
+ command: readonly string[],
299
+ allowFailure = false,
300
+ stdin?: string,
301
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
302
+ return runProcessCommand(command, {
303
+ allowFailure,
304
+ stdin,
305
+ errorLabel: "远端传输命令失败",
306
+ });
307
+ }
308
+
309
+ async #verifyProcessLock(ownerToken: string): Promise<void> {
310
+ assertOwnerToken(ownerToken);
311
+ const owner = await this.#run([
312
+ "ssh",
313
+ this.#target,
314
+ "cat",
315
+ `${this.#installRoot}/.delivery-lock/owner`,
316
+ ]);
317
+ if (owner.stdout.trim() !== ownerToken) {
318
+ throw new Error("交付锁 owner 不匹配");
319
+ }
320
+ }
321
+ }
322
+
323
+ async function feedStdin(
324
+ process: ReturnType<typeof Bun.spawn>,
325
+ input: string | Blob,
326
+ ): Promise<void> {
327
+ if (process.stdin === undefined || typeof process.stdin === "number") {
328
+ throw new Error("SSH 子进程 stdin 不可写");
329
+ }
330
+ if (typeof input === "string") {
331
+ process.stdin.write(input);
332
+ } else {
333
+ const reader = input.stream().getReader();
334
+ try {
335
+ while (true) {
336
+ const result = await reader.read();
337
+ if (result.done) break;
338
+ process.stdin.write(result.value);
339
+ }
340
+ } finally {
341
+ reader.releaseLock();
342
+ }
343
+ }
344
+ await process.stdin.end();
345
+ }
346
+
347
+ function blobArgs(blob: ContentBlob): string[] {
348
+ return ["--digest", blob.digest, "--size", String(blob.size)];
349
+ }
@@ -0,0 +1,236 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ingestArchiveFiles, receiveBlobChunk } from "./blob-store.js";
6
+ import { sha256Bytes } from "./digest.js";
7
+ import { manifestDigest } from "./manifest.js";
8
+ import { syncReleaseOverSsh, type SshDeliveryTransport } from "./ssh-sync.js";
9
+ import type {
10
+ ContentBlob,
11
+ DeliveryCapabilities,
12
+ DeliveryManifestV2,
13
+ Sha256Digest,
14
+ } from "./types.js";
15
+
16
+ const roots: string[] = [];
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(
20
+ roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
21
+ );
22
+ });
23
+
24
+ describe("SSH 增量协调器", () => {
25
+ test("续传只统计本次实际上传 bytes,并在 stage 后释放锁", async () => {
26
+ const root = await temporaryRoot();
27
+ const archive = join(root, "archive");
28
+ const localStore = join(root, "local-store");
29
+ const remoteStore = join(root, "remote-store");
30
+ await mkdir(archive);
31
+ await writeFile(join(archive, "layer.tar"), "0123456789");
32
+ const graph = await ingestArchiveFiles(archive, ["layer.tar"], localStore);
33
+ const manifest = releaseManifest(graph.entries, graph.blobs);
34
+ const transport = new FilesystemTransport(remoteStore, manifest);
35
+ await receiveBlobChunk({
36
+ storeRoot: remoteStore,
37
+ blob: graph.blobs[0]!,
38
+ offset: 0,
39
+ bytes: new TextEncoder().encode("0123"),
40
+ });
41
+ const result = await syncReleaseOverSsh({
42
+ manifest,
43
+ localStoreRoot: localStore,
44
+ transport,
45
+ ownerToken: "a".repeat(48),
46
+ preference: "blob-delta",
47
+ });
48
+ expect(result).toEqual({
49
+ mode: "blob-delta",
50
+ stagedDirectory: "/remote/releases/release-1",
51
+ statistics: {
52
+ totalBytes: 10,
53
+ reusedBytes: 4,
54
+ uploadedBytes: 6,
55
+ totalBlobs: 1,
56
+ reusedBlobs: 0,
57
+ uploadedBlobs: 1,
58
+ },
59
+ });
60
+ expect(transport.locked).toBeFalse();
61
+ });
62
+
63
+ test("旧端没有 V2 capability 时显式返回完整 bundle", async () => {
64
+ const root = await temporaryRoot();
65
+ const archive = join(root, "archive");
66
+ const store = join(root, "store");
67
+ await mkdir(archive);
68
+ await writeFile(join(archive, "manifest.json"), "{}\n");
69
+ const graph = await ingestArchiveFiles(archive, ["manifest.json"], store);
70
+ const fallbackBytes = new Uint8Array(1024).fill(7);
71
+ const fallbackPath = join(root, "windy-full.tar.gz");
72
+ await writeFile(fallbackPath, fallbackBytes);
73
+ const manifest = releaseManifest(
74
+ graph.entries,
75
+ graph.blobs,
76
+ sha256Bytes(fallbackBytes),
77
+ );
78
+ const transport = new FilesystemTransport(store, manifest, null);
79
+ const result = await syncReleaseOverSsh({
80
+ manifest,
81
+ localStoreRoot: store,
82
+ transport,
83
+ ownerToken: "b".repeat(48),
84
+ fallbackPath,
85
+ });
86
+ expect(result).toEqual({
87
+ mode: "full-bundle-required",
88
+ selection: {
89
+ mode: "full-bundle",
90
+ schemaVersion: 1,
91
+ file: "windy-full.tar.gz",
92
+ digest: sha256Bytes(fallbackBytes),
93
+ },
94
+ remoteArchive: "/remote/windy-full.tar.gz",
95
+ statistics: {
96
+ totalBytes: 1024,
97
+ reusedBytes: 0,
98
+ uploadedBytes: 1024,
99
+ totalBlobs: 1,
100
+ reusedBlobs: 0,
101
+ uploadedBlobs: 1,
102
+ },
103
+ });
104
+ expect(transport.fallbackUploads).toBe(1);
105
+ expect(transport.locked).toBeFalse();
106
+ });
107
+ });
108
+
109
+ class FilesystemTransport implements SshDeliveryTransport {
110
+ locked = false;
111
+ fallbackUploads = 0;
112
+ readonly #store: string;
113
+ readonly #manifest: DeliveryManifestV2;
114
+ readonly #capabilities: DeliveryCapabilities | undefined;
115
+
116
+ constructor(
117
+ store: string,
118
+ manifest: DeliveryManifestV2,
119
+ capabilities: DeliveryCapabilities | null = modernCapabilities,
120
+ ) {
121
+ this.#store = store;
122
+ this.#manifest = manifest;
123
+ this.#capabilities = capabilities ?? undefined;
124
+ }
125
+
126
+ async capabilities() {
127
+ return this.#capabilities;
128
+ }
129
+
130
+ async lock() {
131
+ if (this.locked) throw new Error("locked");
132
+ this.locked = true;
133
+ }
134
+
135
+ async unlock() {
136
+ this.locked = false;
137
+ }
138
+
139
+ async inventory(blobs: readonly ContentBlob[]) {
140
+ const { inventoryBlobs } = await import("./blob-store.js");
141
+ return inventoryBlobs(this.#store, blobs);
142
+ }
143
+
144
+ async partial(blob: ContentBlob) {
145
+ const { partialBlobSize } = await import("./blob-store.js");
146
+ return partialBlobSize(this.#store, blob);
147
+ }
148
+
149
+ async preflight() {
150
+ if (!this.locked) throw new Error("preflight requires lock");
151
+ }
152
+
153
+ async preflightFallback() {
154
+ if (!this.locked) throw new Error("fallback preflight requires lock");
155
+ }
156
+
157
+ async upload(blob: ContentBlob, offset: number, localPath: string) {
158
+ const bytes = new Uint8Array(
159
+ await Bun.file(localPath).slice(offset).arrayBuffer(),
160
+ );
161
+ const result = await receiveBlobChunk({
162
+ storeRoot: this.#store,
163
+ blob,
164
+ offset,
165
+ bytes,
166
+ });
167
+ return result.receivedBytes;
168
+ }
169
+
170
+ async stage(
171
+ manifest: DeliveryManifestV2,
172
+ digest: Sha256Digest,
173
+ _ownerToken: string,
174
+ ) {
175
+ expect(manifest).toEqual(this.#manifest);
176
+ expect(digest).toBe(manifestDigest(this.#manifest));
177
+ return "/remote/releases/release-1";
178
+ }
179
+
180
+ async uploadFallback(
181
+ fallback: NonNullable<DeliveryManifestV2["compatibility"]["fullBundle"]>,
182
+ _localPath: string,
183
+ ) {
184
+ this.fallbackUploads += 1;
185
+ return { remotePath: `/remote/${fallback.file}`, uploaded: true };
186
+ }
187
+ }
188
+
189
+ const modernCapabilities: DeliveryCapabilities = {
190
+ schemaVersions: [1, 2],
191
+ registryByDigest: false,
192
+ blobDelta: true,
193
+ resumableBlobTransfer: true,
194
+ };
195
+
196
+ function releaseManifest(
197
+ entries: DeliveryManifestV2["dockerArchive"]["entries"],
198
+ blobs: ContentBlob[],
199
+ fallbackDigest?: Sha256Digest,
200
+ ): DeliveryManifestV2 {
201
+ return {
202
+ schemaVersion: 2,
203
+ minimumReaderVersion: 2,
204
+ product: "windy",
205
+ releaseId: "release-1",
206
+ gitSha: "a".repeat(40),
207
+ platform: "linux/amd64",
208
+ createdAt: "2026-08-08T00:00:00.000Z",
209
+ images: [
210
+ {
211
+ role: "server",
212
+ platform: "linux/amd64",
213
+ configDigest: blobs[0]!.digest,
214
+ },
215
+ ],
216
+ dockerArchive: { format: "oci-image-layout-v1", entries },
217
+ releaseFiles: [],
218
+ blobs,
219
+ compatibility: fallbackDigest
220
+ ? {
221
+ fullBundle: {
222
+ schemaVersion: 1,
223
+ file: "windy-full.tar.gz",
224
+ digest: fallbackDigest,
225
+ size: 1024,
226
+ },
227
+ }
228
+ : {},
229
+ };
230
+ }
231
+
232
+ async function temporaryRoot() {
233
+ const root = await mkdtemp(join(tmpdir(), "windy-ssh-sync-"));
234
+ roots.push(root);
235
+ return root;
236
+ }