From 22540224261a8fe40db8ca113436ea16ef558880 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 25 Mar 2026 13:46:52 -0400 Subject: [PATCH 1/7] Add cloud package and CLI integration Introduce a new packages/cloud workspace (package.json, tsconfig.json) implementing cloud API client, auth, workflows, types and index files. Wire cloud support into the CLI by updating src/cli/commands/cloud.ts and src/cli/commands/connect.ts. Update root package.json and package-lock.json to include the new package and dependency changes. --- package-lock.json | 655 +++++++++++++++++++++++--- package.json | 3 +- packages/cloud/package.json | 44 ++ packages/cloud/src/api-client.ts | 169 +++++++ packages/cloud/src/auth.ts | 299 ++++++++++++ packages/cloud/src/index.ts | 41 ++ packages/cloud/src/types.ts | 97 ++++ packages/cloud/src/workflows.ts | 526 +++++++++++++++++++++ packages/cloud/tsconfig.json | 21 + src/cli/commands/cloud.ts | 769 +++++++++++++++---------------- src/cli/commands/connect.ts | 18 +- 11 files changed, 2173 insertions(+), 469 deletions(-) create mode 100644 packages/cloud/package.json create mode 100644 packages/cloud/src/api-client.ts create mode 100644 packages/cloud/src/auth.ts create mode 100644 packages/cloud/src/index.ts create mode 100644 packages/cloud/src/types.ts create mode 100644 packages/cloud/src/workflows.ts create mode 100644 packages/cloud/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 3ae6cb52d..c325fb08c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,6 +99,10 @@ "resolved": "packages/acp-bridge", "link": true }, + "node_modules/@agent-relay/cloud": { + "resolved": "packages/cloud", + "link": true + }, "node_modules/@agent-relay/config": { "resolved": "packages/config", "link": true @@ -188,6 +192,83 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -366,10 +447,76 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1016.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1016.0.tgz", + "integrity": "sha512-E9umet1PolP6I8TpjQQ2W88aIIguyiRQJE98ag6N6QeLgjSZsF+h9l3KclwCRvqUFU68x+HRwrgXxvbIBVFLbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/credential-provider-node": "^3.972.25", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.4", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.24", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.25", + "@aws-sdk/region-config-resolver": "^3.972.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.12", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.11", + "@smithy/config-resolver": "^4.4.13", + "@smithy/core": "^3.23.12", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.27", + "@smithy/middleware-retry": "^4.4.44", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.43", + "@smithy/util-defaults-mode-node": "^4.2.47", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.973.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.23.tgz", - "integrity": "sha512-aoJncvD1XvloZ9JLnKqTRL9dBy+Szkryoag9VT+V1TqsuUgIxV9cnBVM/hrDi2vE8bDqLiDR8nirdRcCdtJu0w==", + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.24.tgz", + "integrity": "sha512-vvf82RYQu2GidWAuQq+uIzaPz9V0gSCXVqdVzRosgl5rXcspXOpSD3wFreGGW6AYymPr97Z69kjVnLePBxloDw==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.6", @@ -390,13 +537,26 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.21.tgz", - "integrity": "sha512-BkAfKq8Bd4shCtec1usNz//urPJF/SZy14qJyxkSaRJQ/Vv1gVh0VZSTmS7aE6aLMELkFV5wHHrS9ZcdG8Kxsg==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.22.tgz", + "integrity": "sha512-cXp0VTDWT76p3hyK5D51yIKEfpf6/zsUvMfaB8CkyqadJxMQ8SbEeVroregmDlZbtG31wkj9ei0WnftmieggLg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", + "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", @@ -407,12 +567,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.23.tgz", - "integrity": "sha512-4XZ3+Gu5DY8/n8zQFHBgcKTF7hWQl42G6CY9xfXVo2d25FM/lYkpmuzhYopYoPL1ITWkJ2OSBQfYEu5JRfHOhA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.24.tgz", + "integrity": "sha512-h694K7+tRuepSRJr09wTvQfaEnjzsKZ5s7fbESrVds02GT/QzViJ94/HCNwM7bUfFxqpPXHxulZfL6Cou0dwPg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", + "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.0", @@ -428,19 +588,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.23.tgz", - "integrity": "sha512-PZLSmU0JFpNCDFReidBezsgL5ji9jOBry8CnZdw4Jj6d0K2z3Ftnp44NXgADqYx5BLMu/ZHujfeJReaDoV+IwQ==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.24.tgz", + "integrity": "sha512-O46fFmv0RDFWiWEA9/e6oW92BnsyAXuEgTTasxHligjn2RCr9L/DK773m/NoFaL3ZdNAUz8WxgxunleMnHAkeQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", - "@aws-sdk/credential-provider-env": "^3.972.21", - "@aws-sdk/credential-provider-http": "^3.972.23", - "@aws-sdk/credential-provider-login": "^3.972.23", - "@aws-sdk/credential-provider-process": "^3.972.21", - "@aws-sdk/credential-provider-sso": "^3.972.23", - "@aws-sdk/credential-provider-web-identity": "^3.972.23", - "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/credential-provider-env": "^3.972.22", + "@aws-sdk/credential-provider-http": "^3.972.24", + "@aws-sdk/credential-provider-login": "^3.972.24", + "@aws-sdk/credential-provider-process": "^3.972.22", + "@aws-sdk/credential-provider-sso": "^3.972.24", + "@aws-sdk/credential-provider-web-identity": "^3.972.24", + "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -453,13 +613,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.23.tgz", - "integrity": "sha512-OmE/pSkbMM3dCj1HdOnZ5kXnKK+R/Yz+kbBugraBecp0pGAs21eEURfQRz+1N2gzIHLVyGIP1MEjk/uSrFsngg==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.24.tgz", + "integrity": "sha512-sIk8oa6AzDoUhxsR11svZESqvzGuXesw62Rl2oW6wguZx8i9cdGCvkFg+h5K7iucUZP8wyWibUbJMc+J66cu5g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", - "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", @@ -472,17 +632,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.24.tgz", - "integrity": "sha512-9Jwi7aps3AfUicJyF5udYadPypPpCwUZ6BSKr/QjRbVCpRVS1wc+1Q6AEZ/qz8J4JraeRd247pSzyMQSIHVebw==", + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.25.tgz", + "integrity": "sha512-m7dR0Dsva2P+VUpL+VkC0WwiDby5pgmWXkRVDB5rlwv0jXJrQJf7YMtCoM8Wjk0H9jPeCYOxOXXcIgp/qp5Alg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.21", - "@aws-sdk/credential-provider-http": "^3.972.23", - "@aws-sdk/credential-provider-ini": "^3.972.23", - "@aws-sdk/credential-provider-process": "^3.972.21", - "@aws-sdk/credential-provider-sso": "^3.972.23", - "@aws-sdk/credential-provider-web-identity": "^3.972.23", + "@aws-sdk/credential-provider-env": "^3.972.22", + "@aws-sdk/credential-provider-http": "^3.972.24", + "@aws-sdk/credential-provider-ini": "^3.972.24", + "@aws-sdk/credential-provider-process": "^3.972.22", + "@aws-sdk/credential-provider-sso": "^3.972.24", + "@aws-sdk/credential-provider-web-identity": "^3.972.24", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", @@ -495,12 +655,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.21", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.21.tgz", - "integrity": "sha512-nRxbeOJ1E1gVA0lNQezuMVndx+ZcuyaW/RB05pUsznN5BxykSlH6KkZ/7Ca/ubJf3i5N3p0gwNO5zgPSCzj+ww==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.22.tgz", + "integrity": "sha512-Os32s8/4gTZjBk5BtoS/cuTILaj+K72d0dVG7TCJX/fC4598cxwLDmf1AEHEpER5oL3K//yETjvFaz0V8oO5Xw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", + "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -512,14 +672,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.23.tgz", - "integrity": "sha512-APUccADuYPLL0f2htpM8Z4czabSmHOdo4r41W6lKEZdy++cNJ42Radqy6x4TopENzr3hR6WYMyhiuiqtbf/nAA==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.24.tgz", + "integrity": "sha512-PaFv7snEfypU2yXkpvfyWgddEbDLtgVe51wdZlinhc2doubBjUzJZZpgwuF2Jenl1FBydMhNpMjD6SBUM3qdSA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", - "@aws-sdk/nested-clients": "^3.996.13", - "@aws-sdk/token-providers": "3.1014.0", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/nested-clients": "^3.996.14", + "@aws-sdk/token-providers": "3.1015.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -531,13 +691,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.23.tgz", - "integrity": "sha512-H5JNqtIwOu/feInmMMWcK0dL5r897ReEn7n2m16Dd0DPD9gA2Hg8Cq4UDzZ/9OzaLh/uqBM6seixz0U6Fi2Eag==", + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.24.tgz", + "integrity": "sha512-J6H4R1nvr3uBTqD/EeIPAskrBtET4WFfNhpFySr2xW7bVZOXpQfPjrLSIx65jcNjBmLXzWq8QFLdVoGxiGG/SA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", - "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -598,6 +758,24 @@ "@aws-sdk/client-dynamodb": "^3.1014.0" } }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-endpoint-discovery": { "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.8.tgz", @@ -615,6 +793,46 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.4.tgz", + "integrity": "sha512-fhCbZXPAyy8btnNbnBlR7Cc1nD54cETSvGn2wey71ehsM89AKPO8Dpco9DBAAgvrUdLrdHQepBXcyX4vxC5OwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-host-header": { "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", @@ -630,6 +848,20 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", @@ -660,13 +892,52 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-user-agent": { + "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.24.tgz", - "integrity": "sha512-dLTWy6IfAMhNiSEvMr07g/qZ54be6pLqlxVblbF6AzafmmGAzMMj8qMoY9B4+YgT+gY9IcuxZslNh03L6PyMCQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.24.tgz", + "integrity": "sha512-4sXxVC/enYgMkZefNMOzU6C6KtAXEvwVJLgNcUx1dvROH6GvKB5Sm2RGnGzTp0/PwkibIyMw4kOzF8tbLfaBAQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.7", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.25.tgz", + "integrity": "sha512-QxiMPofvOt8SwSynTOmuZfvvPM1S9QfkESBxB22NMHTRXCJhR5BygLl8IXfC4jELiisQgwsgUby21GtXfX3f/g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.24", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.12", @@ -680,23 +951,23 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.13.tgz", - "integrity": "sha512-ptZ1HF4yYHNJX8cgFF+8NdYO69XJKZn7ft0/ynV3c0hCbN+89fAbrLS+fqniU2tW8o9Kfqhj8FUh+IPXb2Qsuw==", + "version": "3.996.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.14.tgz", + "integrity": "sha512-fSESKvh1VbfjtV3QMnRkCPZWkUbQof6T/DOpiLp33yP2wA+rbwwnZeG3XT3Ekljgw2I8X4XaQPnw+zSR8yxJ5Q==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.23", + "@aws-sdk/core": "^3.973.24", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/region-config-resolver": "^3.972.9", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.10", + "@aws-sdk/util-user-agent-node": "^3.973.11", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.12", "@smithy/fetch-http-handler": "^5.3.15", @@ -744,14 +1015,31 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.12.tgz", + "integrity": "sha512-abRObSqjVeKUUHIZfAp78PTYrEsxCgVKDs/YET357pzT5C02eDDEvmWyeEC2wglWcYC4UTbBFk22gd2YJUlCQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.24", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1014.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1014.0.tgz", - "integrity": "sha512-gHTHNUoaOGNrSWkl32A7wFsU78jlNTlqMccLu0byUk5CysYYXaxNMIonIVr4YcykC7vgtDS5ABuz83giy6fzJA==", + "version": "3.1015.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1015.0.tgz", + "integrity": "sha512-3OSD4y110nisRhHzFOjoEeHU4GQL4KpzkX9PxzWaiZe0Yg2+thZKM0Pn9DjYwezH5JYfh/K++xK/SE0IHGrmCQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.23", - "@aws-sdk/nested-clients": "^3.996.13", + "@aws-sdk/core": "^3.973.24", + "@aws-sdk/nested-clients": "^3.996.14", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", @@ -775,6 +1063,18 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/util-dynamodb": { "version": "3.996.2", "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.2.tgz", @@ -831,12 +1131,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.10.tgz", - "integrity": "sha512-E99zeTscCc+pTMfsvnfi6foPpKmdD1cZfOC7/P8UUrjsoQdg9VEWPRD+xdFduKnfPXwcvby58AlO9jwwF6U96g==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.11.tgz", + "integrity": "sha512-1qdXbXo2s5MMLpUvw00284LsbhtlQ4ul7Zzdn5n+7p4WVgCMLqhxImpHIrjSoc72E/fyc4Wq8dLtUld2Gsh+lA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.24", + "@aws-sdk/middleware-user-agent": "^3.972.25", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", @@ -999,7 +1299,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -2427,6 +2727,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -3687,6 +3999,31 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.4.13", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", @@ -3741,6 +4078,76 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/fetch-http-handler": { "version": "5.3.15", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", @@ -3757,6 +4164,21 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/hash-node": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", @@ -3772,6 +4194,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", @@ -3797,6 +4233,20 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/md5-js": { + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.12", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", @@ -8178,7 +8628,6 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -10736,12 +11185,23 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -13248,6 +13708,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -13278,6 +13754,24 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -14476,6 +14970,21 @@ "node": ">=18.0.0" } }, + "packages/cloud": { + "name": "@agent-relay/cloud", + "version": "3.2.15", + "dependencies": { + "@agent-relay/config": "3.2.15", + "@aws-sdk/client-s3": "^3.1004.0", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } + }, "packages/config": { "name": "@agent-relay/config", "version": "3.2.15", diff --git a/package.json b/package.json index 31c616318..cb4a38438 100644 --- a/package.json +++ b/package.json @@ -73,9 +73,10 @@ "web" ], "bundledDependencies": [ - "@agent-relay/sdk", + "@agent-relay/cloud", "@agent-relay/config", "@agent-relay/hooks", + "@agent-relay/sdk", "@agent-relay/telemetry", "@agent-relay/trajectory", "@agent-relay/user-directory", diff --git a/packages/cloud/package.json b/packages/cloud/package.json new file mode 100644 index 000000000..b6f73b7f8 --- /dev/null +++ b/packages/cloud/package.json @@ -0,0 +1,44 @@ +{ + "name": "@agent-relay/cloud", + "version": "3.2.15", + "description": "Cloud SDK for Agent Relay — auth, workflow execution, and provider connections", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@agent-relay/config": "3.2.15", + "@aws-sdk/client-s3": "^3.1004.0", + "ignore": "^7.0.5", + "tar": "^7.5.10" + }, + "devDependencies": { + "@types/node": "^22.19.3", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/AgentWorkforce/relay.git", + "directory": "packages/cloud" + } +} diff --git a/packages/cloud/src/api-client.ts b/packages/cloud/src/api-client.ts new file mode 100644 index 000000000..d0c38bf21 --- /dev/null +++ b/packages/cloud/src/api-client.ts @@ -0,0 +1,169 @@ +import { REFRESH_WINDOW_MS } from "./types.js"; + +export type CloudApiClientOptions = { + apiUrl: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: string; + refreshTokenExpiresAt?: string; +}; + +export type CloudApiClientSnapshot = { + apiUrl: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: string; + refreshTokenExpiresAt?: string; +}; + +function trimLeadingSlash(p: string): string { + return p.replace(/^\/+/, ""); +} + +function withTrailingSlash(p: string): string { + return p.endsWith("/") ? p : `${p}/`; +} + +export function buildApiUrl(apiUrl: string, p: string): URL { + return new URL(trimLeadingSlash(p), withTrailingSlash(apiUrl)); +} + +export class CloudApiClient { + private accessToken: string; + private refreshToken: string; + private accessTokenExpiresAt: string; + private refreshTokenExpiresAt?: string; + private refreshPromise: Promise | null = null; + + constructor(private readonly options: CloudApiClientOptions) { + this.accessToken = options.accessToken; + this.refreshToken = options.refreshToken; + this.accessTokenExpiresAt = options.accessTokenExpiresAt; + this.refreshTokenExpiresAt = options.refreshTokenExpiresAt; + } + + static fromEnv(env: NodeJS.ProcessEnv): CloudApiClient | null { + const apiUrl = env.CLOUD_API_URL?.trim(); + const accessToken = env.CLOUD_API_ACCESS_TOKEN?.trim(); + const refreshToken = env.CLOUD_API_REFRESH_TOKEN?.trim(); + const accessTokenExpiresAt = env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT?.trim(); + const refreshTokenExpiresAt = env.CLOUD_API_REFRESH_TOKEN_EXPIRES_AT?.trim(); + + if (!apiUrl || !accessToken || !refreshToken || !accessTokenExpiresAt) { + return null; + } + + return new CloudApiClient({ + apiUrl, + accessToken, + refreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + }); + } + + snapshot(): CloudApiClientSnapshot { + return { + apiUrl: this.options.apiUrl, + accessToken: this.accessToken, + refreshToken: this.refreshToken, + accessTokenExpiresAt: this.accessTokenExpiresAt, + ...(this.refreshTokenExpiresAt ? { refreshTokenExpiresAt: this.refreshTokenExpiresAt } : {}), + }; + } + + async fetch(p: string, init: RequestInit = {}): Promise { + await this.refresh(); + + const response = await fetch(buildApiUrl(this.options.apiUrl, p), { + ...init, + headers: this.buildHeaders(init.headers), + }); + + if (response.status !== 401) { + return response; + } + + await this.refresh(true); + + return fetch(buildApiUrl(this.options.apiUrl, p), { + ...init, + headers: this.buildHeaders(init.headers), + }); + } + + async revoke(): Promise { + const response = await fetch(buildApiUrl(this.options.apiUrl, "/api/v1/auth/token/revoke"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: this.refreshToken }), + }); + + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to revoke API token: ${response.status} ${response.statusText}`); + } + } + + private async refresh(force = false): Promise { + if (this.refreshPromise) { + return this.refreshPromise; + } + + if (!force && !this.shouldRefresh()) { + return; + } + + this.refreshPromise = this.doRefresh().finally(() => { + this.refreshPromise = null; + }); + + return this.refreshPromise; + } + + private async doRefresh(): Promise { + const response = await fetch(buildApiUrl(this.options.apiUrl, "/api/v1/auth/token/refresh"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshToken: this.refreshToken }), + }); + + if (!response.ok) { + throw new Error(`Failed to refresh API token: ${response.status} ${response.statusText}`); + } + + const payload = (await response.json()) as { + accessToken?: string; + accessTokenExpiresAt?: string; + refreshToken?: string; + refreshTokenExpiresAt?: string; + }; + + if (!payload.accessToken || !payload.accessTokenExpiresAt || !payload.refreshToken) { + throw new Error("Refresh response missing token fields"); + } + + this.accessToken = payload.accessToken; + this.accessTokenExpiresAt = payload.accessTokenExpiresAt; + this.refreshToken = payload.refreshToken; + this.refreshTokenExpiresAt = payload.refreshTokenExpiresAt; + } + + private buildHeaders(headers: HeadersInit | undefined): Headers { + const merged = new Headers(headers); + merged.set("Authorization", `Bearer ${this.accessToken}`); + return merged; + } + + private shouldRefresh(): boolean { + const expiresAt = Date.parse(this.accessTokenExpiresAt); + if (Number.isNaN(expiresAt)) { + return true; + } + + return expiresAt - Date.now() <= REFRESH_WINDOW_MS; + } +} diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts new file mode 100644 index 000000000..6b1516247 --- /dev/null +++ b/packages/cloud/src/auth.ts @@ -0,0 +1,299 @@ +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +import { buildApiUrl } from "./api-client.js"; +import { AUTH_FILE_PATH, REFRESH_WINDOW_MS, type StoredAuth } from "./types.js"; + +function isValidStoredAuth(value: unknown): value is StoredAuth { + if (!value || typeof value !== "object") { + return false; + } + + const auth = value as Partial; + return ( + typeof auth.accessToken === "string" && + typeof auth.refreshToken === "string" && + typeof auth.accessTokenExpiresAt === "string" && + typeof auth.apiUrl === "string" + ); +} + +export async function readStoredAuth(): Promise { + try { + const file = await fs.readFile(AUTH_FILE_PATH, "utf8"); + const parsed = JSON.parse(file) as unknown; + return isValidStoredAuth(parsed) ? parsed : null; + } catch { + return null; + } +} + +export async function writeStoredAuth(auth: StoredAuth): Promise { + await fs.mkdir(path.dirname(AUTH_FILE_PATH), { + recursive: true, + mode: 0o700, + }); + await fs.writeFile(AUTH_FILE_PATH, `${JSON.stringify(auth, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +export async function clearStoredAuth(): Promise { + await fs.rm(AUTH_FILE_PATH, { force: true }); +} + +function shouldRefresh(accessTokenExpiresAt: string): boolean { + const expiresAt = Date.parse(accessTokenExpiresAt); + if (Number.isNaN(expiresAt)) { + return true; + } + + return expiresAt - Date.now() <= REFRESH_WINDOW_MS; +} + +function openBrowser(url: string) { + const platform = os.platform(); + + if (platform === "darwin") { + return spawn("open", [url], { stdio: "ignore", detached: true }); + } + + if (platform === "win32") { + return spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }); + } + + return spawn("xdg-open", [url], { stdio: "ignore", detached: true }); +} + +function redirectToHostedCliAuthPage( + response: http.ServerResponse, + apiUrl: string, + options: { + status: "success" | "error"; + detail?: string; + }, +): void { + const resultUrl = buildApiUrl(apiUrl, "/cli/auth-result"); + resultUrl.searchParams.set("status", options.status); + if (options.detail) { + resultUrl.searchParams.set("detail", options.detail); + } + + response.statusCode = 302; + response.setHeader("location", resultUrl.toString()); + response.end(); +} + +async function beginBrowserLogin(apiUrl: string): Promise { + const state = crypto.randomUUID(); + + return new Promise((resolve, reject) => { + let settled = false; + + const server = http.createServer((request, response) => { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1"); + + if (requestUrl.pathname !== "/callback") { + response.statusCode = 404; + response.end("Not found"); + return; + } + + const returnedState = requestUrl.searchParams.get("state"); + const accessToken = requestUrl.searchParams.get("access_token"); + const refreshToken = requestUrl.searchParams.get("refresh_token"); + const accessTokenExpiresAt = requestUrl.searchParams.get("access_token_expires_at"); + const returnedApiUrl = requestUrl.searchParams.get("api_url"); + const error = requestUrl.searchParams.get("error"); + + if (error) { + redirectToHostedCliAuthPage(response, apiUrl, { + status: "error", + detail: error, + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error(error)); + } + return; + } + + if ( + returnedState !== state || + !accessToken || + !refreshToken || + !accessTokenExpiresAt || + !returnedApiUrl + ) { + redirectToHostedCliAuthPage(response, apiUrl, { + status: "error", + detail: "Expected access token, refresh token, API URL, and expiration timestamp.", + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error("CLI login callback was missing required fields")); + } + return; + } + + redirectToHostedCliAuthPage(response, returnedApiUrl, { + status: "success", + detail: `API endpoint: ${returnedApiUrl}`, + }); + + if (!settled) { + settled = true; + server.close(); + resolve({ + accessToken, + refreshToken, + accessTokenExpiresAt, + apiUrl: returnedApiUrl, + }); + } + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + if (!settled) { + settled = true; + server.close(); + reject(new Error("Failed to start local callback server")); + } + return; + } + + const callbackUrl = new URL("/callback", `http://127.0.0.1:${address.port}`); + const loginUrl = buildApiUrl(apiUrl, "/api/v1/cli/login"); + loginUrl.searchParams.set("redirect_uri", callbackUrl.toString()); + loginUrl.searchParams.set("state", state); + + console.log(`Opening browser for cloud login: ${loginUrl.toString()}`); + console.log("If the browser does not open, paste this URL into your browser."); + + try { + const child = openBrowser(loginUrl.toString()); + child.unref(); + } catch { + // Browser open failure is non-fatal; user still has the URL. + } + }); + + server.on("error", (error) => { + if (!settled) { + settled = true; + reject(error); + } + }); + + setTimeout(() => { + if (!settled) { + settled = true; + server.close(); + reject(new Error("Timed out waiting for browser login")); + } + }, 5 * 60_000).unref(); + }); +} + +export async function refreshStoredAuth(auth: StoredAuth): Promise { + const response = await fetch(buildApiUrl(auth.apiUrl, "/api/v1/auth/token/refresh"), { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ refreshToken: auth.refreshToken }), + }); + + const payload = (await response.json().catch(() => null)) as + | { + accessToken?: string; + refreshToken?: string; + accessTokenExpiresAt?: string; + } + | null; + + if (!response.ok || !payload?.accessToken || !payload?.refreshToken || !payload?.accessTokenExpiresAt) { + throw new Error("Stored cloud login has expired"); + } + + const nextAuth: StoredAuth = { + apiUrl: auth.apiUrl, + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + accessTokenExpiresAt: payload.accessTokenExpiresAt, + }; + await writeStoredAuth(nextAuth); + return nextAuth; +} + +async function loginWithBrowser(apiUrl: string): Promise { + const auth = await beginBrowserLogin(apiUrl); + await writeStoredAuth(auth); + console.log(`Logged in to ${auth.apiUrl}`); + return auth; +} + +export async function ensureAuthenticated(apiUrl: string, options?: { force?: boolean }): Promise { + const force = options?.force === true; + const stored = !force ? await readStoredAuth() : null; + + if (!stored || stored.apiUrl !== apiUrl) { + return loginWithBrowser(apiUrl); + } + + if (!shouldRefresh(stored.accessTokenExpiresAt)) { + return stored; + } + + try { + return await refreshStoredAuth(stored); + } catch { + return loginWithBrowser(apiUrl); + } +} + +function apiFetch( + apiUrl: string, + accessToken: string, + requestPath: string, + init: RequestInit, +): Promise { + return fetch(buildApiUrl(apiUrl, requestPath), { + ...init, + headers: { + "content-type": "application/json", + authorization: `Bearer ${accessToken}`, + ...(init.headers ?? {}), + }, + }); +} + +export async function authorizedApiFetch( + auth: StoredAuth, + requestPath: string, + init: RequestInit, +): Promise<{ response: Response; auth: StoredAuth }> { + let activeAuth = auth; + let response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); + + if (response.status !== 401) { + return { response, auth: activeAuth }; + } + + try { + activeAuth = await refreshStoredAuth(activeAuth); + } catch { + activeAuth = await loginWithBrowser(activeAuth.apiUrl); + } + + response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); + return { response, auth: activeAuth }; +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts new file mode 100644 index 000000000..871c75df2 --- /dev/null +++ b/packages/cloud/src/index.ts @@ -0,0 +1,41 @@ +export { + readStoredAuth, + writeStoredAuth, + clearStoredAuth, + refreshStoredAuth, + ensureAuthenticated, + authorizedApiFetch, +} from "./auth.js"; + +export { + CloudApiClient, + buildApiUrl, + type CloudApiClientOptions, + type CloudApiClientSnapshot, +} from "./api-client.js"; + +export { + runWorkflow, + getRunStatus, + getRunLogs, + cancelWorkflow, + syncWorkflowPatch, + resolveWorkflowInput, + inferWorkflowFileType, + shouldSyncCodeByDefault, +} from "./workflows.js"; + +export { + type StoredAuth, + type WhoAmIResponse, + type AuthSessionResponse, + type WorkflowFileType, + type RunWorkflowResponse, + type WorkflowLogsResponse, + type SyncPatchResponse, + SUPPORTED_PROVIDERS, + REFRESH_WINDOW_MS, + AUTH_FILE_PATH, + defaultApiUrl, + isSupportedProvider, +} from "./types.js"; diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts new file mode 100644 index 000000000..d0b7b2f2d --- /dev/null +++ b/packages/cloud/src/types.ts @@ -0,0 +1,97 @@ +import os from "node:os"; +import path from "node:path"; + +export type StoredAuth = { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: string; + apiUrl: string; +}; + +export type WhoAmIResponse = { + authenticated: boolean; + source: "session" | "token"; + subjectType: string | null; + scopes: string[]; + user: { + id: string; + email: string | null; + name: string | null; + avatarUrl: string | null; + }; + currentOrganization: { + id: string; + slug: string; + name: string; + role: string; + status: string; + }; + currentWorkspace: { + id: string; + organization_id: string; + slug: string; + name: string; + }; +}; + +export type AuthSessionResponse = { + sessionId: string; + ssh: { + host: string; + port: number; + user: string; + password: string; + }; + remoteCommand: string; + provider: string; + expiresAt: string; +}; + +export type WorkflowFileType = "yaml" | "ts" | "py"; + +export type RunWorkflowOptions = { + apiUrl?: string; + fileType?: WorkflowFileType; + syncCode?: boolean; +}; + +export type RunWorkflowResponse = { + runId: string; + sandboxId?: string; + status: string; + [key: string]: unknown; +}; + +export type WorkflowLogsResponse = { + content: string; + offset: number; + totalSize: number; + done: boolean; + [key: string]: unknown; +}; + +export type SyncPatchResponse = { + patch: string; + hasChanges: boolean; + [key: string]: unknown; +}; + +export const SUPPORTED_PROVIDERS = [ + "anthropic", + "openai", + "google", + "cursor", + "opencode", + "droid", +] as const; + +export const REFRESH_WINDOW_MS = 60_000; +export const AUTH_FILE_PATH = path.join(os.homedir(), ".agent-relay", "cloud-auth.json"); + +export function defaultApiUrl(): string { + return process.env.CLOUD_API_URL?.trim() || "https://agentrelay.dev"; +} + +export function isSupportedProvider(provider: string): boolean { + return SUPPORTED_PROVIDERS.includes(provider as (typeof SUPPORTED_PROVIDERS)[number]); +} diff --git a/packages/cloud/src/workflows.ts b/packages/cloud/src/workflows.ts new file mode 100644 index 000000000..5ba420857 --- /dev/null +++ b/packages/cloud/src/workflows.ts @@ -0,0 +1,526 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import ignore from "ignore"; +import * as tar from "tar"; + +import { ensureAuthenticated, authorizedApiFetch } from "./auth.js"; +import { defaultApiUrl, type WorkflowFileType, type RunWorkflowResponse, type WorkflowLogsResponse, type SyncPatchResponse } from "./types.js"; + +type ResolvedWorkflowInput = { + workflow: string; + fileType: WorkflowFileType; + sourceFileType?: WorkflowFileType; +}; + +type S3Credentials = { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + bucket: string; + prefix: string; +}; + +type PrepareWorkflowResponse = { + runId: string; + s3Credentials: S3Credentials; + s3CodeKey: string; +}; + +type RunWorkflowOptions = { + apiUrl?: string; + fileType?: WorkflowFileType; + syncCode?: boolean; +}; + +const CODE_SYNC_EXCLUDES = [".git", "node_modules", ".sst", ".next", ".open-next"]; + +function validateYamlWorkflow(content: string): void { + const hasField = (field: string) => + new RegExp(`^${field}\\s*:`, "m").test(content); + + if (!hasField("version")) { + throw new Error('missing required field "version"'); + } + if (!hasField("swarm")) { + throw new Error('missing required field "swarm"'); + } + if (!hasField("agents")) { + throw new Error('missing required field "agents"'); + } + if (!hasField("workflows")) { + throw new Error('missing required field "workflows"'); + } +} + +async function validateTypeScriptWorkflow(content: string): Promise { + try { + const { execSync } = await import("node:child_process"); + execSync("npx --yes esbuild --bundle=false --format=esm --loader=ts", { + input: content, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 30000, + }); + } catch (error) { + const err = error as { status?: number; killed?: boolean; stderr?: unknown }; + if (err.killed || !err.status) { + console.error("TypeScript validation skipped: esbuild not available or timed out"); + return; + } + const stderr = typeof err.stderr === "string" ? err.stderr.trim() : ""; + const message = stderr || "TypeScript validation failed"; + throw new Error(`Workflow file has syntax errors:\n${message}`); + } +} + +export function inferWorkflowFileType(filePath: string): WorkflowFileType | null { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".yaml": + case ".yml": + return "yaml"; + case ".ts": + case ".mts": + case ".cts": + return "ts"; + case ".py": + return "py"; + default: + return null; + } +} + +export function shouldSyncCodeByDefault( + _workflowArg: string, + _explicitFileType?: WorkflowFileType, +): boolean { + return true; +} + +export async function resolveWorkflowInput( + workflowArg: string, + explicitFileType?: WorkflowFileType, +): Promise { + const looksLikeFile = path.isAbsolute(workflowArg) || + workflowArg.includes(path.sep) || + inferWorkflowFileType(workflowArg) !== null; + + try { + const stat = await fs.stat(workflowArg); + if (!stat.isFile()) { + throw new Error(`Workflow path is not a file: ${workflowArg}`); + } + + const fileType = explicitFileType ?? inferWorkflowFileType(workflowArg); + if (!fileType) { + throw new Error(`Could not infer workflow type from ${workflowArg}. Use --file-type.`); + } + + const workflow = await fs.readFile(workflowArg, "utf-8"); + return { workflow, fileType }; + } catch (error) { + if (!isMissingFileError(error)) { + throw error; + } + } + + if (looksLikeFile) { + throw new Error(`Workflow file not found: ${workflowArg}`); + } + + return { + workflow: workflowArg, + fileType: explicitFileType ?? "yaml", + }; +} + +export async function runWorkflow( + workflowArg: string, + options: RunWorkflowOptions = {}, +): Promise { + const apiUrl = options.apiUrl ?? defaultApiUrl(); + let auth = await ensureAuthenticated(apiUrl); + const input = await resolveWorkflowInput(workflowArg, options.fileType); + + if (input.fileType === "ts") { + await validateTypeScriptWorkflow(input.workflow); + } else if (input.fileType === "yaml") { + console.error("Validating workflow..."); + validateYamlWorkflow(input.workflow); + } + + const syncCode = options.syncCode ?? shouldSyncCodeByDefault(workflowArg, options.fileType); + const requestBody: Record = { + workflow: input.workflow, + fileType: input.fileType, + }; + if (input.sourceFileType) { + requestBody.sourceFileType = input.sourceFileType; + } + + if (syncCode) { + const t0 = Date.now(); + console.error("Preparing run..."); + const { response: prepResponse, auth: prepAuth } = await authorizedApiFetch(auth, "/api/v1/workflows/prepare", { + method: "POST", + headers: { Accept: "application/json" }, + }); + auth = prepAuth; + + const prepPayload = await readJsonResponse(prepResponse); + if (!prepResponse.ok) { + throw new Error(`Workflow prepare failed: ${describeResponseError(prepResponse, prepPayload)}`); + } + + if (!isPrepareWorkflowResponse(prepPayload)) { + throw new Error("Workflow prepare response was not valid JSON."); + } + + const prepared = prepPayload; + console.error(` Prepared in ${((Date.now() - t0) / 1000).toFixed(1)}s`); + + const t1 = Date.now(); + console.error("Creating tarball..."); + const s3Client = createScopedS3Client(prepared.s3Credentials); + const tarball = await createTarball(process.cwd()); + console.error(` Tarball: ${(tarball.length / 1024).toFixed(0)}KB in ${((Date.now() - t1) / 1000).toFixed(1)}s`); + + const t2 = Date.now(); + console.error("Uploading to S3..."); + const key = scopedCodeKey(prepared.s3Credentials.prefix, prepared.s3CodeKey); + await s3Client.send( + new PutObjectCommand({ + Bucket: prepared.s3Credentials.bucket, + Key: key, + Body: tarball, + ContentType: "application/gzip", + }), + ); + console.error(` Uploaded in ${((Date.now() - t2) / 1000).toFixed(1)}s`); + + requestBody.runId = prepared.runId; + requestBody.s3CodeKey = prepared.s3CodeKey; + } + + const t3 = Date.now(); + console.error("Launching workflow..."); + const { response, auth: updatedAuth } = await authorizedApiFetch( + auth, + "/api/v1/workflows/run", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(requestBody), + }, + ); + auth = updatedAuth; + + console.error(` Launched in ${((Date.now() - t3) / 1000).toFixed(1)}s`); + + const payload = await readJsonResponse(response); + if (!response.ok) { + throw new Error(`Workflow run failed: ${describeResponseError(response, payload)}`); + } + + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { runId?: unknown }).runId !== "string" || + typeof (payload as { status?: unknown }).status !== "string" + ) { + throw new Error("Workflow run response was not valid JSON."); + } + + return payload as RunWorkflowResponse; +} + +export async function getRunStatus( + runId: string, + options: { apiUrl?: string } = {}, +): Promise> { + const apiUrl = options.apiUrl ?? defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const { response } = await authorizedApiFetch( + auth, + `/api/v1/workflows/runs/${encodeURIComponent(runId)}`, + { + headers: { Accept: "application/json" }, + }, + ); + + const payload = await readJsonResponse(response); + if (!response.ok) { + throw new Error(`Status request failed: ${describeResponseError(response, payload)}`); + } + + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Status response was not valid JSON."); + } + + return payload as Record; +} + +export async function cancelWorkflow( + runId: string, + options: { apiUrl?: string } = {}, +): Promise<{ runId: string; status: string }> { + const apiUrl = options.apiUrl ?? defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const { response } = await authorizedApiFetch( + auth, + `/api/v1/workflows/runs/${encodeURIComponent(runId)}/cancel`, + { + method: "POST", + headers: { Accept: "application/json" }, + }, + ); + + const payload = await readJsonResponse(response); + if (!response.ok) { + throw new Error(`Cancel failed: ${describeResponseError(response, payload)}`); + } + + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Cancel response was not valid JSON."); + } + + return payload as { runId: string; status: string }; +} + +export async function getRunLogs( + runId: string, + options: { + apiUrl?: string; + offset?: number; + sandboxId?: string; + } = {}, +): Promise { + const apiUrl = options.apiUrl ?? defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const searchParams = new URLSearchParams(); + if (typeof options.offset === "number") { + searchParams.set("offset", String(options.offset)); + } + if (options.sandboxId) { + searchParams.set("sandboxId", options.sandboxId); + } + + const requestPath = `/api/v1/workflows/runs/${encodeURIComponent(runId)}/logs${searchParams.size ? `?${searchParams.toString()}` : ""}`; + + const { response } = await authorizedApiFetch(auth, requestPath, { + headers: { Accept: "application/json" }, + }); + + const payload = await readJsonResponse(response); + if (!response.ok) { + throw new Error(`Log request failed: ${describeResponseError(response, payload)}`); + } + + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { content?: unknown }).content !== "string" || + typeof (payload as { offset?: unknown }).offset !== "number" || + typeof (payload as { totalSize?: unknown }).totalSize !== "number" || + typeof (payload as { done?: unknown }).done !== "boolean" + ) { + throw new Error("Log response was not valid JSON."); + } + + return payload as WorkflowLogsResponse; +} + +export async function syncWorkflowPatch( + runId: string, + options: { apiUrl?: string } = {}, +): Promise { + const apiUrl = options.apiUrl ?? defaultApiUrl(); + let auth = await ensureAuthenticated(apiUrl); + + // Verify the run is completed + const { response: statusResponse, auth: a1 } = await authorizedApiFetch( + auth, + `/api/v1/workflows/runs/${encodeURIComponent(runId)}`, + { headers: { Accept: "application/json" } }, + ); + auth = a1; + + if (!statusResponse.ok) { + const payload = await readJsonResponse(statusResponse); + throw new Error(`Failed to fetch run status: ${describeResponseError(statusResponse, payload)}`); + } + + const runData = (await statusResponse.json()) as { status?: string }; + if (runData.status !== "completed" && runData.status !== "failed" && runData.status !== "cancelled") { + throw new Error(`Run is still ${runData.status ?? "unknown"}. Wait for completion before syncing.`); + } + + // Download the patch + const { response } = await authorizedApiFetch( + auth, + `/api/v1/workflows/runs/${encodeURIComponent(runId)}/patch`, + { headers: { Accept: "application/json" } }, + ); + + const payload = await readJsonResponse(response); + if (!response.ok) { + throw new Error(`Patch download failed: ${describeResponseError(response, payload)}`); + } + + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { hasChanges?: unknown }).hasChanges !== "boolean" + ) { + throw new Error("Patch response was not valid JSON."); + } + + return payload as SyncPatchResponse; +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +async function readJsonResponse(response: Response): Promise { + const rawBody = await response.text(); + if (!rawBody) { + return null; + } + + try { + return JSON.parse(rawBody); + } catch { + return rawBody; + } +} + +function describeResponseError(response: Response, payload: unknown): string { + if (typeof payload === "string" && payload.trim()) { + return `${response.status} ${response.statusText}: ${payload.trim()}`; + } + + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const record = payload as Record; + const message = record.error ?? record.message; + if (typeof message === "string" && message.trim()) { + return `${response.status} ${response.statusText}: ${message.trim()}`; + } + } + + return `${response.status} ${response.statusText}`; +} + +function isMissingFileError(error: unknown): error is NodeJS.ErrnoException { + return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); +} + +function isPrepareWorkflowResponse(payload: unknown): payload is PrepareWorkflowResponse { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + + const record = payload as Record; + const s3Creds = record.s3Credentials; + if (!s3Creds || typeof s3Creds !== "object" || Array.isArray(s3Creds)) { + return false; + } + + const creds = s3Creds as Record; + return ( + typeof record.runId === "string" && + typeof record.s3CodeKey === "string" && + typeof creds.accessKeyId === "string" && + typeof creds.secretAccessKey === "string" && + typeof creds.sessionToken === "string" && + typeof creds.bucket === "string" && + typeof creds.prefix === "string" + ); +} + +function createScopedS3Client(s3Credentials: S3Credentials): S3Client { + return new S3Client({ + region: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1", + credentials: { + accessKeyId: s3Credentials.accessKeyId, + secretAccessKey: s3Credentials.secretAccessKey, + sessionToken: s3Credentials.sessionToken, + }, + }); +} + +async function createTarball(rootDir: string): Promise { + const absoluteRoot = path.resolve(rootDir); + + try { + const { execSync } = await import("node:child_process"); + const gitFiles = execSync("git ls-files -z", { + cwd: absoluteRoot, + encoding: "utf-8", + maxBuffer: 50 * 1024 * 1024, + }); + const files = gitFiles.split("\0").filter(Boolean); + if (files.length > 0) { + const tarStream = tar.create( + { gzip: true, cwd: absoluteRoot, portable: true }, + files, + ); + const chunks: Buffer[] = []; + for await (const chunk of tarStream) { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + return Buffer.concat(chunks); + } + } catch { + // Not a git repo or git not available — fall back to ignore-based filter + } + + const ig = await buildIgnoreMatcher(absoluteRoot); + const tarStream = tar.create( + { + gzip: true, + cwd: absoluteRoot, + portable: true, + filter(entryPath: string): boolean { + const normalized = normalizeEntryPath(entryPath); + if (!normalized || normalized === ".") return true; + return !ig.ignores(normalized); + }, + }, + ["."], + ); + + const chunks: Buffer[] = []; + for await (const chunk of tarStream) { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + + return Buffer.concat(chunks); +} + +async function buildIgnoreMatcher(rootDir: string): Promise { + const ig = ignore(); + ig.add(CODE_SYNC_EXCLUDES); + + try { + const gitignoreContent = await fs.readFile(path.join(rootDir, ".gitignore"), "utf-8"); + ig.add(gitignoreContent); + } catch (error) { + if (!isMissingFileError(error)) { + throw error; + } + } + + return ig; +} + +function normalizeEntryPath(entryPath: string): string { + return entryPath.replace(/^\.\//, "").replace(/\\/g, "/"); +} + +function scopedCodeKey(prefix: string, key: string): string { + return [prefix, key].filter(Boolean).join("/"); +} diff --git a/packages/cloud/tsconfig.json b/packages/cloud/tsconfig.json new file mode 100644 index 000000000..dac76181e --- /dev/null +++ b/packages/cloud/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index 0405b436f..b4ad16499 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -1,94 +1,54 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import readline from 'node:readline'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { randomBytes } from 'node:crypto'; -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; +import { CLI_AUTH_CONFIG } from '@agent-relay/config/cli-auth-config'; -import { formatTableRow } from '../lib/formatting.js'; import { - createCloudApiClient, - type CloudApiClient, - type CloudAgent, -} from '../lib/cloud-client.js'; + ensureAuthenticated, + authorizedApiFetch, + readStoredAuth, + clearStoredAuth, + defaultApiUrl, + AUTH_FILE_PATH, + SUPPORTED_PROVIDERS, + runWorkflow, + getRunStatus, + getRunLogs, + syncWorkflowPatch, + type WhoAmIResponse, + type AuthSessionResponse, + type WorkflowFileType, +} from '@agent-relay/cloud'; + +import { runInteractiveSession } from '../lib/ssh-interactive.js'; + +// ── Types ──────────────────────────────────────────────────────────────────── type ExitFn = (code: number) => never; -interface CloudConfig { - apiKey: string; - cloudUrl: string; - machineId: string; - machineName: string; - linkedAt: string; -} - -export type { CloudApiClient, CloudAgent }; - export interface CloudDependencies { - createApiClient: () => CloudApiClient; - getDataDir: () => string; - getHostname: () => string; - randomHex: (bytes: number) => string; - now: () => Date; - openExternal: (url: string) => Promise; - prompt: (question: string) => Promise; log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; } -const DEFAULT_CLOUD_URL = process.env.AGENT_RELAY_CLOUD_URL || 'https://agent-relay.com'; -const execFileAsync = promisify(execFile); +// ── Helpers ────────────────────────────────────────────────────────────────── + +const color = { + cyan: (s: string) => `\x1b[36m${s}\x1b[0m`, + green: (s: string) => `\x1b[32m${s}\x1b[0m`, + yellow: (s: string) => `\x1b[33m${s}\x1b[0m`, + red: (s: string) => `\x1b[31m${s}\x1b[0m`, + dim: (s: string) => `\x1b[2m${s}\x1b[0m`, +}; function defaultExit(code: number): never { process.exit(code); } -async function defaultPrompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return await new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -async function defaultOpenExternal(url: string): Promise { - if (process.platform === 'darwin') { - await execFileAsync('open', [url]); - return; - } - - if (process.platform === 'win32') { - await execFileAsync('cmd', ['/c', 'start', '', url]); - return; - } - - await execFileAsync('xdg-open', [url]); -} - -function createDefaultApiClient(): CloudApiClient { - return createCloudApiClient(); -} - -function withDefaults( - overrides: Partial = {} -): CloudDependencies { +function withDefaults(overrides: Partial = {}): CloudDependencies { return { - createApiClient: createDefaultApiClient, - getDataDir: () => process.env.AGENT_RELAY_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'agent-relay'), - getHostname: () => os.hostname(), - randomHex: (bytes: number) => randomBytes(bytes).toString('hex'), - now: () => new Date(), - openExternal: defaultOpenExternal, - prompt: defaultPrompt, log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, @@ -96,42 +56,60 @@ function withDefaults( }; } -function readConfigFile(configPath: string): CloudConfig | undefined { - if (!fs.existsSync(configPath)) { - return undefined; +function normalizeProvider(providerArg: string): string { + const providerInput = providerArg.toLowerCase().trim(); + const providerMap: Record = { + claude: 'anthropic', + codex: 'openai', + gemini: 'google', + }; + return providerMap[providerInput] || providerInput; +} + +function parsePositiveInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError('Expected a positive integer.'); } + return parsed; +} - const raw = fs.readFileSync(configPath, 'utf-8'); - return JSON.parse(raw) as CloudConfig; +function parseNonNegativeInteger(value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError('Expected a non-negative integer.'); + } + return parsed; } -function stripApiSuffix(cloudUrl: string): string { - return cloudUrl.replace(/\/api\/?$/, ''); +function parseWorkflowFileType(value: string): WorkflowFileType { + if (value === 'yaml' || value === 'ts' || value === 'py') { + return value; + } + throw new InvalidArgumentError('Expected workflow type to be one of: yaml, ts, py'); } -function getPaths(dataDir: string): { - machineIdPath: string; - configPath: string; - tempCodePath: string; - credentialsPath: string; -} { - return { - machineIdPath: path.join(dataDir, 'machine-id'), - configPath: path.join(dataDir, 'cloud-config.json'), - tempCodePath: path.join(dataDir, '.link-code'), - credentialsPath: path.join(dataDir, 'cloud-credentials.json'), - }; +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } -function ensureLinked(configPath: string, deps: CloudDependencies): CloudConfig { - const config = readConfigFile(configPath); - if (!config) { - deps.error('Not linked to cloud. Run `agent-relay cloud link` first.'); - deps.exit(1); +async function getErrorDetails(response: Response): Promise { + let details = response.statusText; + try { + const json = (await response.json()) as { error?: string; message?: string }; + details = json.error || json.message || details; + } catch { + try { + details = await response.text(); + } catch { + // ignore + } } - return config; + return details || response.statusText; } +// ── Command registration ───────────────────────────────────────────────────── + export function registerCloudCommands( program: Command, overrides: Partial = {} @@ -140,353 +118,374 @@ export function registerCloudCommands( const cloudCommand = program .command('cloud') - .description('Cloud account and sync commands') - .addHelpText( - 'afterAll', - '\nBREAKING CHANGE: daemon compatibility was removed. Cloud integrations must use /api/brokers/* and brokerId/brokerName.' - ); + .description('Cloud account, provider auth, and workflow commands'); + + // ── login ────────────────────────────────────────────────────────────────── cloudCommand - .command('link') - .description('Link this machine to your Agent Relay Cloud account') - .option('--name ', 'Name for this machine') - .option('--cloud-url ', 'Cloud API URL', DEFAULT_CLOUD_URL) - .action(async (options: { name?: string; cloudUrl: string }) => { - const cloudUrl = options.cloudUrl; - const machineName = options.name || deps.getHostname(); - const dataDir = deps.getDataDir(); - const { machineIdPath, configPath, tempCodePath } = getPaths(dataDir); - - let machineId: string; - if (fs.existsSync(machineIdPath)) { - machineId = fs.readFileSync(machineIdPath, 'utf-8').trim(); - } else { - machineId = `${deps.getHostname()}-${deps.randomHex(8)}`; - fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(machineIdPath, machineId); + .command('login') + .description('Authenticate with Agent Relay Cloud via browser') + .option('--api-url ', 'Cloud API base URL') + .option('--force', 'Force re-authentication even if already logged in') + .action(async (options: { apiUrl?: string; force?: boolean }) => { + const apiUrl = options.apiUrl || defaultApiUrl(); + + if (!options.force) { + const existing = await readStoredAuth(); + if (existing && existing.apiUrl === apiUrl) { + const expiresAt = Date.parse(existing.accessTokenExpiresAt); + if (!Number.isNaN(expiresAt) && expiresAt - Date.now() > 60_000) { + deps.log(`Already logged in to ${existing.apiUrl}`); + return; + } + } } - deps.log(''); - deps.log('Agent Relay Cloud - Link Machine'); - deps.log(''); - deps.log(`Machine: ${machineName}`); - deps.log(`ID: ${machineId}`); - deps.log(''); - - const tempCode = deps.randomHex(16); - fs.writeFileSync(tempCodePath, tempCode); + await ensureAuthenticated(apiUrl, { force: options.force }); + }); - const authUrl = - `${stripApiSuffix(cloudUrl)}/cloud/link?code=${tempCode}` + - `&machine=${encodeURIComponent(machineId)}&name=${encodeURIComponent(machineName)}`; + // ── logout ───────────────────────────────────────────────────────────────── - deps.log('Open this URL in your browser to authenticate:'); - deps.log(''); - deps.log(` ${authUrl}`); - deps.log(''); + cloudCommand + .command('logout') + .description('Clear stored cloud credentials') + .action(async () => { + const auth = await readStoredAuth(); + if (!auth) { + deps.log('Not logged in.'); + return; + } try { - await deps.openExternal(authUrl); - deps.log('(Browser opened automatically)'); + const revokeUrl = new URL( + 'api/v1/auth/token/revoke', + auth.apiUrl.endsWith('/') ? auth.apiUrl : `${auth.apiUrl}/` + ); + await fetch(revokeUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: auth.refreshToken }), + }); } catch { - deps.log('(Copy the URL above and paste it in your browser)'); + // best-effort revoke } - deps.log(''); - deps.log('After authenticating, paste your API key here:'); - - const apiKey = (await deps.prompt('API Key: ')).trim(); - if (!apiKey || !apiKey.startsWith('ar_live_')) { - deps.error(''); - deps.error('Invalid API key format. Expected ar_live_...'); - deps.exit(1); - } - - deps.log(''); - deps.log('Verifying API key...'); + await clearStoredAuth(); + deps.log('Logged out.'); + }); - try { - const client = deps.createApiClient(); - await client.verifyApiKey({ cloudUrl, apiKey }); - - const config: CloudConfig = { - apiKey, - cloudUrl, - machineId, - machineName, - linkedAt: deps.now().toISOString(), - }; - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - fs.chmodSync(configPath, 0o600); - - if (fs.existsSync(tempCodePath)) { - fs.unlinkSync(tempCodePath); - } + // ── whoami ───────────────────────────────────────────────────────────────── - deps.log(''); - deps.log('Machine linked successfully!'); - deps.log(''); - deps.log('Your broker will now sync with Agent Relay Cloud.'); - deps.log('Run `agent-relay up` to start with cloud sync enabled.'); - deps.log(''); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.error(`Failed to connect to cloud: ${message}`); - deps.exit(1); + cloudCommand + .command('whoami') + .description('Show current authentication status') + .option('--api-url ', 'Cloud API base URL') + .action(async (options: { apiUrl?: string }) => { + const apiUrl = options.apiUrl || defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl); + const { response } = await authorizedApiFetch(auth, '/api/v1/auth/whoami', { + method: 'GET', + }); + + const payload = (await response.json().catch(() => null)) as + | (WhoAmIResponse & { error?: string }) + | null; + + if (!response.ok || !payload?.authenticated) { + throw new Error(payload?.error || 'Failed to resolve auth status'); } + + deps.log(`API URL: ${auth.apiUrl}`); + deps.log(`Auth source: ${payload.source}`); + deps.log(`Subject type: ${payload.subjectType ?? 'session'}`); + deps.log(`User: ${payload.user.name || '(no name)'}${payload.user.email ? ` <${payload.user.email}>` : ''}`); + deps.log(`Organization: ${payload.currentOrganization.name}`); + deps.log(`Workspace: ${payload.currentWorkspace.name}`); + deps.log(`Scopes: ${payload.scopes.length > 0 ? payload.scopes.join(', ') : '(none)'}`); + deps.log(`Token file: ${AUTH_FILE_PATH}`); }); + // ── connect ──────────────────────────────────────────────────────────────── + cloudCommand - .command('unlink') - .description('Unlink this machine from Agent Relay Cloud') - .action(async () => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); + .command('connect') + .description('Connect a provider via interactive SSH session') + .argument('', `Provider to connect (${SUPPORTED_PROVIDERS.join(', ')})`) + .option('--api-url ', 'Cloud API base URL') + .option('--language ', 'Sandbox language/image', 'typescript') + .option('--timeout ', 'Connection timeout in seconds', parsePositiveInteger, 300) + .action(async (providerArg: string, options: { apiUrl?: string; language: string; timeout: number }) => { + const timeoutMs = options.timeout * 1000; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('This command requires an interactive terminal (TTY).'); + } - if (!fs.existsSync(configPath)) { - deps.log('This machine is not linked to Agent Relay Cloud.'); - return; + const provider = normalizeProvider(providerArg); + const providerConfig = CLI_AUTH_CONFIG[provider]; + if (!providerConfig) { + const known = Object.keys(CLI_AUTH_CONFIG).sort(); + throw new Error(`Unknown provider: ${providerArg}. Supported providers: ${known.join(', ')}`); } - const config = readConfigFile(configPath); - fs.unlinkSync(configPath); - - deps.log(''); - deps.log('Machine unlinked from Agent Relay Cloud'); - deps.log(''); - deps.log(`Machine ID: ${config?.machineId || 'unknown'}`); - deps.log(`Was linked since: ${config?.linkedAt || 'unknown'}`); - deps.log(''); - deps.log('Note: The API key has been removed locally. To fully revoke access,'); - deps.log('visit your Agent Relay Cloud dashboard and remove this machine.'); - deps.log(''); - }); + const apiUrl = options.apiUrl || defaultApiUrl(); + + const io = { + log: deps.log, + error: deps.error, + }; + + io.log(''); + io.log(color.cyan('═══════════════════════════════════════════════════')); + io.log(color.cyan(' Provider Authentication (Daytona Connect)')); + io.log(color.cyan('═══════════════════════════════════════════════════')); + io.log(''); + io.log(`Provider: ${providerConfig.displayName} (${provider})`); + io.log(`Language: ${color.dim(options.language)}`); + io.log(color.dim(`Cloud: ${apiUrl}`)); + io.log(''); + io.log('Requesting sandbox from cloud...'); + + let auth = await ensureAuthenticated(apiUrl); + + const { response: createResponse, auth: refreshedAuth } = await authorizedApiFetch( + auth, + '/api/v1/cli/auth', + { + method: 'POST', + body: JSON.stringify({ provider, language: options.language }), + } + ); + auth = refreshedAuth; - cloudCommand - .command('status') - .description('Show cloud sync status') - .action(async () => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); - const config = readConfigFile(configPath); - - if (!config) { - deps.log(''); - deps.log('Cloud sync: Not configured'); - deps.log(''); - deps.log('Run `agent-relay cloud link` to connect to Agent Relay Cloud.'); - deps.log(''); - return; + const start = (await createResponse.json().catch(() => null)) as + | (AuthSessionResponse & { error?: string; message?: string }) + | null; + + if (!createResponse.ok || !start?.sessionId) { + throw new Error(start?.error || start?.message || (await getErrorDetails(createResponse))); + } + + const sshPort = typeof start.ssh?.port === 'string' + ? Number.parseInt(start.ssh.port as unknown as string, 10) + : start.ssh?.port; + if (!start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) { + throw new Error('Cloud returned invalid SSH session details.'); } - deps.log(''); - deps.log('Cloud sync: Enabled'); - deps.log(''); - deps.log(` Machine: ${config.machineName}`); - deps.log(` ID: ${config.machineId}`); - deps.log(` Cloud URL: ${config.cloudUrl}`); - deps.log(` Linked: ${new Date(config.linkedAt).toLocaleString()}`); - deps.log(''); + io.log(color.green('✓ Sandbox ready')); + io.log(color.dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`)); + io.log(''); + io.log(color.yellow('Connecting via SSH...')); + io.log(color.dim(` Running: ${start.remoteCommand}`)); + io.log(''); + let sessionResult; try { - const client = deps.createApiClient(); - const online = await client.checkConnection({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, + sessionResult = await runInteractiveSession({ + ssh: { + host: start.ssh.host, + port: sshPort, + user: start.ssh.user, + password: start.ssh.password, + }, + remoteCommand: start.remoteCommand, + successPatterns: providerConfig.successPatterns || [], + errorPatterns: providerConfig.errorPatterns || [], + timeoutMs, + io, }); - deps.log(` Cloud connection: ${online ? 'Online' : 'Error (API key may be invalid)'}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.log(` Cloud connection: Offline (${message})`); + } catch (error) { + throw new Error(`Failed to connect via SSH: ${error instanceof Error ? error.message : String(error)}`); } - deps.log(''); - }); - - cloudCommand - .command('sync') - .description('Manually sync credentials from cloud') - .action(async () => { - const dataDir = deps.getDataDir(); - const { configPath, credentialsPath } = getPaths(dataDir); - const config = ensureLinked(configPath, deps); + io.log(''); + const success = sessionResult.authDetected; - deps.log('Syncing credentials from cloud...'); + io.log('Finalizing authentication with cloud...'); + const { response: completeResponse } = await authorizedApiFetch( + auth, + '/api/v1/cli/auth/complete', + { + method: 'POST', + body: JSON.stringify({ sessionId: start.sessionId, success }), + } + ); - try { - const client = deps.createApiClient(); - const credentials = await client.syncCredentials({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - }); + if (!completeResponse.ok) { + throw new Error(await getErrorDetails(completeResponse)); + } - deps.log(''); - deps.log(`Synced ${credentials.length} provider credentials:`); - for (const credential of credentials) { - deps.log(` - ${credential.provider}`); + if (!success) { + const exitCode = sessionResult.exitCode; + if (typeof exitCode === 'number' && exitCode !== 0) { + io.error(color.red(`Remote auth command exited with code ${exitCode}.`)); } + if (sessionResult.exitCode === 127) { + io.log(color.yellow(`The ${providerConfig.displayName} CLI ("${providerConfig.command}") is not installed on the sandbox.`)); + io.log(color.dim('Check the sandbox snapshot includes the required CLI tools.')); + } + throw new Error(`Provider auth for ${provider} did not complete successfully`); + } - fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2)); - fs.chmodSync(credentialsPath, 0o600); + io.log(''); + io.log(color.green('═══════════════════════════════════════════════════')); + io.log(color.green(' Authentication Complete!')); + io.log(color.green('═══════════════════════════════════════════════════')); + io.log(''); + io.log(`${providerConfig.displayName} credentials are now stored and encrypted.`); + io.log(color.dim('Your workflows will automatically use these credentials.')); + io.log(''); + }); - deps.log(''); - deps.log('Credentials synced successfully'); - deps.log(''); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.error(`Failed to sync: ${message}`); - deps.exit(1); + // ── run ──────────────────────────────────────────────────────────────────── + + cloudCommand + .command('run') + .description('Submit a workflow run') + .argument('', 'Workflow file path or inline workflow content') + .option('--api-url ', 'Cloud API base URL') + .option('--file-type ', 'Workflow type: yaml, ts, or py', parseWorkflowFileType) + .option('--sync-code', 'Upload the current working directory before running') + .option('--no-sync-code', 'Skip uploading the current working directory') + .option('--json', 'Print raw JSON response', false) + .action(async ( + workflow: string, + options: { apiUrl?: string; fileType?: WorkflowFileType; syncCode?: boolean; json?: boolean }, + ) => { + const result = await runWorkflow(workflow, options); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + return; + } + + deps.log(`Run created: ${result.runId}`); + if (typeof result.sandboxId === 'string') { + deps.log(`Sandbox: ${result.sandboxId}`); } + deps.log(`Status: ${result.status}`); + deps.log(`\nView logs: agent-relay cloud logs ${result.runId} --follow`); + deps.log(`Sync code: agent-relay cloud sync ${result.runId}`); }); + // ── status ───────────────────────────────────────────────────────────────── + cloudCommand - .command('agents') - .description('List agents across all linked machines') - .option('--json', 'Output as JSON') - .action(async (options: { json?: boolean }) => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); - const config = ensureLinked(configPath, deps); + .command('status') + .description('Fetch workflow run status') + .argument('', 'Workflow run id') + .option('--api-url ', 'Cloud API base URL') + .option('--json', 'Print raw JSON response', false) + .action(async (runId: string, options: { apiUrl?: string; json?: boolean }) => { + const result = await getRunStatus(runId, options); + deps.log(JSON.stringify(result, null, 2)); + }); - try { - const client = deps.createApiClient(); - const agents = await client.listAgents({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, + // ── logs ─────────────────────────────────────────────────────────────────── + + cloudCommand + .command('logs') + .description('Read workflow run logs') + .argument('', 'Workflow run id') + .option('--api-url ', 'Cloud API base URL') + .option('--follow', 'Poll until the run is done', false) + .option('--poll-interval ', 'Polling interval while following', parsePositiveInteger, 2) + .option('--offset ', 'Start reading logs from a byte offset', parseNonNegativeInteger, 0) + .option('--agent ', 'Read logs for a specific agent') + .option('--sandbox-id ', 'Read logs for a specific step sandbox') + .option('--json', 'Print raw JSON responses', false) + .action(async ( + runId: string, + options: { + apiUrl?: string; + follow?: boolean; + pollInterval?: number; + offset?: number; + agent?: string; + sandboxId?: string; + json?: boolean; + }, + ) => { + let offset = options.offset ?? 0; + const sandboxId = options.agent ?? options.sandboxId; + + while (true) { + const result = await getRunLogs(runId, { + apiUrl: options.apiUrl, + offset, + sandboxId, }); if (options.json) { - deps.log(JSON.stringify(agents, null, 2)); - return; - } - - if (!agents.length) { - deps.log('No agents found across linked machines.'); - deps.log('Make sure brokers are running on linked machines.'); - return; + deps.log(JSON.stringify(result, null, 2)); + } else if (result.content) { + process.stdout.write(result.content); } - deps.log(''); - deps.log('Agents across all linked machines:'); - deps.log(''); - deps.log('NAME STATUS BROKER MACHINE'); - deps.log('─'.repeat(65)); - - const byBroker = new Map(); - for (const agent of agents) { - const current = byBroker.get(agent.brokerName) || []; - current.push(agent); - byBroker.set(agent.brokerName, current); - } - - for (const [brokerName, brokerAgents] of byBroker.entries()) { - for (const agent of brokerAgents) { - const machine = (agent.machineId || '').substring(0, 20); - deps.log( - formatTableRow([ - { value: agent.name, width: 15 }, - { value: agent.status, width: 8 }, - { value: brokerName, width: 18 }, - { value: machine }, - ]) - ); - } + offset = result.offset; + if (!options.follow || result.done) { + break; } - deps.log(''); - deps.log(`Total: ${agents.length} agents on ${byBroker.size} machines`); - deps.log(''); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - deps.error(`Failed to fetch agents: ${message}`); - deps.exit(1); + await sleep((options.pollInterval ?? 2) * 1000); } }); - cloudCommand - .command('send') - .description('Send a message to an agent on any linked machine') - .argument('', 'Target agent name') - .argument('', 'Message to send') - .option('--from ', 'Sender name', '__cli_sender__') - .action(async (agent: string, message: string, options: { from: string }) => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); - const config = ensureLinked(configPath, deps); - - deps.log(`Sending message to ${agent}...`); - - try { - const client = deps.createApiClient(); - const allAgents = await client.listAgents({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - }); - - const targetAgent = allAgents.find((candidate) => candidate.name === agent); - if (!targetAgent) { - deps.error(`Agent "${agent}" not found.`); - deps.log('Available agents:'); - for (const availableAgent of allAgents) { - deps.log(` - ${availableAgent.name} (on ${availableAgent.brokerName})`); - } - deps.exit(1); - return; - } + // ── sync ─────────────────────────────────────────────────────────────────── - await client.sendMessage({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - targetBrokerId: targetAgent.brokerId, - targetAgent: agent, - from: options.from, - content: message, - }); + cloudCommand + .command('sync') + .description('Download and apply code changes from a completed workflow run') + .argument('', 'Workflow run id') + .option('--api-url ', 'Cloud API base URL') + .option('--dir ', 'Local directory to apply the patch to', '.') + .option('--dry-run', 'Download and display the patch without applying', false) + .action(async ( + runId: string, + options: { apiUrl?: string; dir?: string; dryRun?: boolean }, + ) => { + const targetDir = path.resolve(options.dir ?? '.'); + deps.log(`Fetching patch for run ${runId}...`); + + const result = await syncWorkflowPatch(runId, { apiUrl: options.apiUrl }); + + if (!result.hasChanges) { + deps.log('No changes to sync — the workflow did not modify any files.'); + return; + } - deps.log(''); - deps.log(`Message sent to ${agent} on ${targetAgent.brokerName}`); - deps.log(''); - } catch (err) { - const messageText = err instanceof Error ? err.message : String(err); - deps.error(`Failed to send message: ${messageText}`); - deps.exit(1); + if (options.dryRun) { + deps.log('\n--- Patch (dry run) ---'); + process.stdout.write(result.patch); + deps.log('\n--- End patch ---'); + return; } - }); - cloudCommand - .command('brokers') - .description('List all linked broker instances') - .option('--json', 'Output as JSON') - .action(async (options: { json?: boolean }) => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); - const config = ensureLinked(configPath, deps); + const { execSync } = await import('node:child_process'); + const tmpPatch = path.join(os.tmpdir(), `cloud-sync-${Date.now()}.patch`); + fs.writeFileSync(tmpPatch, result.patch); try { - if (options.json) { - deps.log(JSON.stringify([{ - machineName: config.machineName, - machineId: config.machineId, - cloudUrl: config.cloudUrl, - linkedAt: config.linkedAt, - }], null, 2)); - return; + const stat = execSync(`git apply --stat "${tmpPatch}"`, { + cwd: targetDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (stat.trim()) { + deps.log('\nFiles changed by agent:'); + deps.log(stat); } - deps.log(''); - deps.log('Linked Broker:'); - deps.log(''); - deps.log(` Machine: ${config.machineName}`); - deps.log(` ID: ${config.machineId}`); - deps.log(` Cloud: ${config.cloudUrl}`); - deps.log(` Linked: ${new Date(config.linkedAt).toLocaleString()}`); - deps.log(''); - deps.log('Note: To see all linked brokers, visit your cloud dashboard.'); - deps.log(''); - } catch (err) { + execSync(`git apply "${tmpPatch}"`, { + cwd: targetDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + deps.log('Patch applied successfully.'); + } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); - deps.error(`Failed: ${message}`); + deps.error(`Failed to apply patch: ${message}`); + deps.error(`Patch saved to: ${tmpPatch}`); deps.exit(1); } }); diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index 13e421ba0..5dbca902a 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -1,19 +1,17 @@ import { Command } from 'commander'; -import { runConnectCommand, type ConnectCommandOptions } from '../lib/connect-daytona.js'; export function registerConnectCommands(program: Command): void { program .command('connect ') - .description('Authenticate a provider CLI via Daytona sandbox (stores credentials in volume)') + .description('[DEPRECATED] Use `agent-relay cloud connect ` instead') .option('--timeout ', 'Timeout in seconds (default: 300)', '300') .option('--language ', 'Sandbox language/image (default: typescript)', 'typescript') - .option('--cloud-url ', 'Cloud API URL (or set AGENT_RELAY_CLOUD_URL env var)') - .action(async (providerArg: string, options: ConnectCommandOptions) => { - const io = { - log: (...args: unknown[]) => console.log(...args), - error: (...args: unknown[]) => console.error(...args), - exit: ((code: number) => process.exit(code)) as (code: number) => never, - }; - await runConnectCommand(providerArg, options, io); + .option('--cloud-url ', 'Cloud API URL') + .action(async (providerArg: string) => { + console.error( + '\x1b[33m[DEPRECATED]\x1b[0m `agent-relay connect` has moved. Use:\n\n' + + ` agent-relay cloud connect ${providerArg}\n` + ); + process.exit(1); }); } From 4ec1f33e3b1c8f005556bdf5de84bbcc3166148e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 25 Mar 2026 13:54:37 -0400 Subject: [PATCH 2/7] Update and simplify cloud CLI tests Replace and update cloud-related expectations across CLI tests. bootstrap.test.ts: update expected cloud subcommands (replace link/unlink/agents/send/brokers with login/logout/whoami/connect/run/logs) and adjust the total leaf command count. src/cli/commands/cloud.test.ts: refactor tests to remove filesystem/API integration scaffolding and many scenario tests; simplify createHarness to return only program and deps, use program.exitOverride, adjust exit mock, and add lighter unit tests that assert command names, descriptions, and key options (e.g. --follow, --poll-interval, --dry-run). Overall this moves tests to focus on command signatures and options rather than end-to-end behavior. --- src/cli/bootstrap.test.ts | 13 +- src/cli/commands/cloud.test.ts | 303 ++++++--------------------------- 2 files changed, 55 insertions(+), 261 deletions(-) diff --git a/src/cli/bootstrap.test.ts b/src/cli/bootstrap.test.ts index 6695a7ffd..234fae6c9 100644 --- a/src/cli/bootstrap.test.ts +++ b/src/cli/bootstrap.test.ts @@ -35,13 +35,14 @@ const expectedLeafCommands = [ 'run', 'connect', 'workflows list', - 'cloud link', - 'cloud unlink', + 'cloud login', + 'cloud logout', + 'cloud whoami', + 'cloud connect', + 'cloud run', 'cloud status', + 'cloud logs', 'cloud sync', - 'cloud agents', - 'cloud send', - 'cloud brokers', ]; function collectLeafCommandPaths(program: Command): string[] { @@ -114,7 +115,7 @@ describe('bootstrap CLI', () => { const program = createProgram(); const leafCommandPaths = collectLeafCommandPaths(program); - expect(leafCommandPaths).toHaveLength(38); + expect(leafCommandPaths).toHaveLength(39); expect(leafCommandPaths).toEqual(expect.arrayContaining(expectedLeafCommands)); expect(leafCommandPaths).not.toContain('create-agent'); }); diff --git a/src/cli/commands/cloud.test.ts b/src/cli/commands/cloud.test.ts index 840a03aba..d9fccb249 100644 --- a/src/cli/commands/cloud.test.ts +++ b/src/cli/commands/cloud.test.ts @@ -1,117 +1,24 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { Command } from 'commander'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { - registerCloudCommands, - type CloudApiClient, - type CloudDependencies, -} from './cloud.js'; - -class ExitSignal extends Error { - constructor(public readonly code: number) { - super(`exit:${code}`); - } -} - -function createApiClientMock(overrides: Partial = {}): CloudApiClient { - return { - verifyApiKey: vi.fn(async () => undefined), - checkConnection: vi.fn(async () => true), - syncCredentials: vi.fn(async () => []), - listAgents: vi.fn(async () => []), - sendMessage: vi.fn(async () => undefined), - ...overrides, - }; -} - -const createdTempDirs: string[] = []; - -afterEach(() => { - for (const dir of createdTempDirs.splice(0)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -function createHarness(options?: { - apiClient?: CloudApiClient; - promptResponse?: string; - hostname?: string; - randomHexValues?: string[]; - now?: Date; - dataDir?: string; -}) { - const apiClient = options?.apiClient ?? createApiClientMock(); - const dataDir = options?.dataDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-command-test-')); - - if (!options?.dataDir) { - createdTempDirs.push(dataDir); - } - - const hexValues = [...(options?.randomHexValues ?? ['machinehex00112233', 'tempauthccddeeff'])]; +import { registerCloudCommands, type CloudDependencies } from './cloud.js'; +function createHarness() { const exit = vi.fn((code: number) => { - throw new ExitSignal(code); + throw new Error(`exit:${code}`); }) as unknown as CloudDependencies['exit']; const deps: CloudDependencies = { - createApiClient: vi.fn(() => apiClient), - getDataDir: vi.fn(() => dataDir), - getHostname: vi.fn(() => options?.hostname ?? 'devbox'), - randomHex: vi.fn((_bytes: number) => hexValues.shift() ?? 'fallbackhex'), - now: vi.fn(() => options?.now ?? new Date('2026-02-20T12:00:00.000Z')), - openExternal: vi.fn(async () => undefined), - prompt: vi.fn(async () => options?.promptResponse ?? 'ar_live_test_key'), log: vi.fn(() => undefined), error: vi.fn(() => undefined), exit, }; const program = new Command(); + program.exitOverride(); registerCloudCommands(program, deps); - return { program, deps, apiClient, dataDir }; -} - -async function runCommand(program: Command, args: string[]): Promise { - try { - await program.parseAsync(args, { from: 'user' }); - return undefined; - } catch (err) { - if (err instanceof ExitSignal) { - return err.code; - } - throw err; - } -} - -function writeCloudConfig( - dataDir: string, - overrides: Partial<{ - apiKey: string; - cloudUrl: string; - machineId: string; - machineName: string; - linkedAt: string; - }> = {} -): void { - fs.mkdirSync(dataDir, { recursive: true }); - const config = { - apiKey: 'ar_live_key', - cloudUrl: 'https://cloud.example.com', - machineId: 'machine-1', - machineName: 'Local Dev', - linkedAt: '2026-02-18T10:00:00.000Z', - ...overrides, - }; - fs.writeFileSync(path.join(dataDir, 'cloud-config.json'), JSON.stringify(config, null, 2)); -} - -function getOutput(mockFn: unknown): string { - const calls = (mockFn as { mock: { calls: unknown[][] } }).mock.calls; - return calls.map((call) => call.map((value) => String(value)).join(' ')).join('\n'); + return { program, deps }; } describe('registerCloudCommands', () => { @@ -120,177 +27,63 @@ describe('registerCloudCommands', () => { const cloud = program.commands.find((command) => command.name() === 'cloud'); expect(cloud).toBeDefined(); - expect(cloud?.commands.map((command) => command.name())).toEqual( - expect.arrayContaining(['link', 'unlink', 'status', 'sync', 'agents', 'send', 'brokers']) - ); - }); - - it('cloud link prompts for API key and connects to cloud account', async () => { - const apiClient = createApiClientMock(); - const { program, deps, dataDir } = createHarness({ - apiClient, - promptResponse: 'ar_live_linked_key', - hostname: 'local-host', - randomHexValues: ['a1b2c3d4e5f60708', 'deadbeefcafefeed'], - now: new Date('2026-02-20T16:30:00.000Z'), - }); - - const exitCode = await runCommand(program, [ - 'cloud', - 'link', - '--name', - 'Workstation', - '--cloud-url', - 'https://cloud.example.com/api', + expect(cloud?.commands.map((command) => command.name())).toEqual([ + 'login', + 'logout', + 'whoami', + 'connect', + 'run', + 'status', + 'logs', + 'sync', ]); - - expect(exitCode).toBeUndefined(); - expect(deps.prompt).toHaveBeenCalledWith('API Key: '); - expect(apiClient.verifyApiKey).toHaveBeenCalledWith({ - cloudUrl: 'https://cloud.example.com/api', - apiKey: 'ar_live_linked_key', - }); - expect(deps.openExternal).toHaveBeenCalledTimes(1); - - const machineIdPath = path.join(dataDir, 'machine-id'); - const configPath = path.join(dataDir, 'cloud-config.json'); - - expect(fs.existsSync(machineIdPath)).toBe(true); - expect(fs.existsSync(configPath)).toBe(true); - - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { - apiKey: string; - cloudUrl: string; - machineName: string; - linkedAt: string; - }; - - expect(config).toMatchObject({ - apiKey: 'ar_live_linked_key', - cloudUrl: 'https://cloud.example.com/api', - machineName: 'Workstation', - linkedAt: '2026-02-20T16:30:00.000Z', - }); - expect(fs.existsSync(path.join(dataDir, '.link-code'))).toBe(false); }); - it('cloud status shows sync status', async () => { - const apiClient = createApiClientMock({ - checkConnection: vi.fn(async () => true), - }); - const { program, deps, dataDir } = createHarness({ apiClient }); - - writeCloudConfig(dataDir, { - apiKey: 'ar_live_status_key', - machineName: 'Laptop', - machineId: 'machine-status', - }); - - const exitCode = await runCommand(program, ['cloud', 'status']); - - expect(exitCode).toBeUndefined(); - expect(apiClient.checkConnection).toHaveBeenCalledWith({ - cloudUrl: 'https://cloud.example.com', - apiKey: 'ar_live_status_key', - }); + it('connect requires a provider argument', () => { + const { program } = createHarness(); + const cloud = program.commands.find((command) => command.name() === 'cloud'); + const connect = cloud?.commands.find((command) => command.name() === 'connect'); - const output = getOutput(deps.log); - expect(output).toContain('Cloud sync: Enabled'); - expect(output).toContain('Cloud connection: Online'); + expect(connect).toBeDefined(); + expect(connect?.description()).toContain('interactive SSH session'); }); - it('cloud agents lists agents across machines', async () => { - const apiClient = createApiClientMock({ - listAgents: vi.fn(async () => [ - { - name: 'Planner', - status: 'online', - brokerId: 'broker-1', - brokerName: 'MacBook-Pro', - machineId: 'machine-alpha', - }, - { - name: 'Reviewer', - status: 'idle', - brokerId: 'broker-2', - brokerName: 'Desktop-Linux', - machineId: 'machine-beta', - }, - ]), - }); - const { program, deps, dataDir } = createHarness({ apiClient }); - - writeCloudConfig(dataDir); - - const exitCode = await runCommand(program, ['cloud', 'agents']); - - expect(exitCode).toBeUndefined(); - expect(apiClient.listAgents).toHaveBeenCalledWith({ - cloudUrl: 'https://cloud.example.com', - apiKey: 'ar_live_key', - }); + it('run requires a workflow argument', () => { + const { program } = createHarness(); + const cloud = program.commands.find((command) => command.name() === 'cloud'); + const run = cloud?.commands.find((command) => command.name() === 'run'); - const output = getOutput(deps.log); - expect(output).toContain('Agents across all linked machines'); - expect(output).toContain('Planner'); - expect(output).toContain('Reviewer'); - expect(output).toContain('Total: 2 agents on 2 machines'); + expect(run).toBeDefined(); + expect(run?.description()).toContain('workflow run'); }); - it('cloud send routes a message to a remote agent', async () => { - const apiClient = createApiClientMock({ - listAgents: vi.fn(async () => [ - { - name: 'Planner', - status: 'online', - brokerId: 'broker-9', - brokerName: 'Remote-Machine', - machineId: 'machine-zeta', - }, - ]), - sendMessage: vi.fn(async () => undefined), - }); - const { program, apiClient: client, dataDir } = createHarness({ apiClient }); - - writeCloudConfig(dataDir); - - const exitCode = await runCommand(program, ['cloud', 'send', 'Planner', 'Ship it', '--from', 'local-cli']); + it('status requires a runId argument', () => { + const { program } = createHarness(); + const cloud = program.commands.find((command) => command.name() === 'cloud'); + const status = cloud?.commands.find((command) => command.name() === 'status'); - expect(exitCode).toBeUndefined(); - expect(client.sendMessage).toHaveBeenCalledWith({ - cloudUrl: 'https://cloud.example.com', - apiKey: 'ar_live_key', - targetBrokerId: 'broker-9', - targetAgent: 'Planner', - from: 'local-cli', - content: 'Ship it', - }); + expect(status).toBeDefined(); + expect(status?.description()).toContain('workflow run status'); }); - it('fails when cloud commands are used before linking', async () => { - const apiClient = createApiClientMock(); - const { program, deps } = createHarness({ apiClient }); - - const exitCode = await runCommand(program, ['cloud', 'agents']); + it('logs has --follow and --poll-interval options', () => { + const { program } = createHarness(); + const cloud = program.commands.find((command) => command.name() === 'cloud'); + const logs = cloud?.commands.find((command) => command.name() === 'logs'); - expect(exitCode).toBe(1); - expect(deps.error).toHaveBeenCalledWith('Not linked to cloud. Run `agent-relay cloud link` first.'); - expect(apiClient.listAgents).not.toHaveBeenCalled(); + expect(logs).toBeDefined(); + const optionNames = logs?.options.map((option) => option.long); + expect(optionNames).toContain('--follow'); + expect(optionNames).toContain('--poll-interval'); }); - it('handles network errors from cloud API calls', async () => { - const apiClient = createApiClientMock({ - listAgents: vi.fn(async () => { - throw new Error('network unavailable'); - }), - }); - const { program, deps, dataDir } = createHarness({ apiClient }); - - writeCloudConfig(dataDir); - - const exitCode = await runCommand(program, ['cloud', 'agents']); + it('sync has --dry-run option', () => { + const { program } = createHarness(); + const cloud = program.commands.find((command) => command.name() === 'cloud'); + const sync = cloud?.commands.find((command) => command.name() === 'sync'); - expect(exitCode).toBe(1); - expect(deps.error).toHaveBeenCalledWith('Failed to fetch agents: network unavailable'); + expect(sync).toBeDefined(); + const optionNames = sync?.options.map((option) => option.long); + expect(optionNames).toContain('--dry-run'); }); }); From 0ee3f33850859f895690117bef76b67c4f92b43b Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 25 Mar 2026 14:04:32 -0400 Subject: [PATCH 3/7] Add cloud dependency and improve CLI error Add @agent-relay/cloud@3.2.15 to package.json and include it in bundleDependencies so the cloud package is bundled with the app. Update cloud CLI error handling to build a clearer error message (start.error || start.message || `${createResponse.status} ${createResponse.statusText}`) and throw that instead of awaiting getErrorDetails(createResponse), simplifying and making failures more informative when session creation fails. --- package.json | 4 +++- src/cli/commands/cloud.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cb4a38438..b9485b8cf 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ }, "homepage": "https://github.com/AgentWorkforce/relay#readme", "dependencies": { + "@agent-relay/cloud": "3.2.15", "@agent-relay/config": "3.2.15", "@agent-relay/hooks": "3.2.15", "@agent-relay/sdk": "3.2.15", @@ -245,9 +246,10 @@ "react-dom": "^18.3.1" }, "bundleDependencies": [ - "@agent-relay/sdk", + "@agent-relay/cloud", "@agent-relay/config", "@agent-relay/hooks", + "@agent-relay/sdk", "@agent-relay/telemetry", "@agent-relay/trajectory", "@agent-relay/user-directory", diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index b4ad16499..e3a9ea8fb 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -263,7 +263,8 @@ export function registerCloudCommands( | null; if (!createResponse.ok || !start?.sessionId) { - throw new Error(start?.error || start?.message || (await getErrorDetails(createResponse))); + const detail = start?.error || start?.message || `${createResponse.status} ${createResponse.statusText}`; + throw new Error(detail); } const sshPort = typeof start.ssh?.port === 'string' From 02426414bf509272eeb4b585dd7431ce14dd2fcb Mon Sep 17 00:00:00 2001 From: Noodle Date: Wed, 25 Mar 2026 19:05:08 -0400 Subject: [PATCH 4/7] Fix cloud bundled deps and CLI polish --- package-lock.json | 9 ++++++-- package.json | 3 +++ src/cli/commands/cloud.test.ts | 6 ++++++ src/cli/commands/cloud.ts | 39 ++++++++++++++++++++++++++-------- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index c325fb08c..0659768e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,10 @@ "name": "agent-relay", "version": "3.2.15", "bundleDependencies": [ - "@agent-relay/sdk", + "@agent-relay/cloud", "@agent-relay/config", "@agent-relay/hooks", + "@agent-relay/sdk", "@agent-relay/telemetry", "@agent-relay/trajectory", "@agent-relay/user-directory", @@ -23,6 +24,7 @@ "web" ], "dependencies": { + "@agent-relay/cloud": "3.2.15", "@agent-relay/config": "3.2.15", "@agent-relay/hooks": "3.2.15", "@agent-relay/sdk": "3.2.15", @@ -30,6 +32,7 @@ "@agent-relay/trajectory": "3.2.15", "@agent-relay/user-directory": "3.2.15", "@agent-relay/utils": "3.2.15", + "@aws-sdk/client-s3": "^3.1004.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relaycast/mcp": "1.0.0", "@relaycast/sdk": "1.0.0", @@ -42,11 +45,13 @@ "dotenv": "^17.2.3", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", + "ignore": "^7.0.5", "listr2": "^10.2.1", "pg": "^8.16.3", "posthog-node": "^4.0.1", "smol-toml": "^1.6.0", "ssh2": "^1.17.0", + "tar": "^7.5.10", "uuid": "^10.0.0", "ws": "^8.18.3", "yaml": "^2.7.0", @@ -1299,7 +1304,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b9485b8cf..87dbd4140 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "@agent-relay/config": "3.2.15", "@agent-relay/hooks": "3.2.15", "@agent-relay/sdk": "3.2.15", + "@aws-sdk/client-s3": "^3.1004.0", "@agent-relay/telemetry": "3.2.15", "@agent-relay/trajectory": "3.2.15", "@agent-relay/user-directory": "3.2.15", @@ -198,9 +199,11 @@ "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "listr2": "^10.2.1", + "ignore": "^7.0.5", "pg": "^8.16.3", "posthog-node": "^4.0.1", "smol-toml": "^1.6.0", + "tar": "^7.5.10", "ssh2": "^1.17.0", "uuid": "^10.0.0", "ws": "^8.18.3", diff --git a/src/cli/commands/cloud.test.ts b/src/cli/commands/cloud.test.ts index d9fccb249..e09354f36 100644 --- a/src/cli/commands/cloud.test.ts +++ b/src/cli/commands/cloud.test.ts @@ -46,6 +46,10 @@ describe('registerCloudCommands', () => { expect(connect).toBeDefined(); expect(connect?.description()).toContain('interactive SSH session'); + expect(connect?.registeredArguments[0]?.argChoices).toBeUndefined(); + expect(connect?.registeredArguments[0]?.description).toContain('anthropic (alias: claude)'); + expect(connect?.registeredArguments[0]?.description).toContain('openai (alias: codex)'); + expect(connect?.registeredArguments[0]?.description).toContain('google (alias: gemini)'); }); it('run requires a workflow argument', () => { @@ -64,6 +68,8 @@ describe('registerCloudCommands', () => { expect(status).toBeDefined(); expect(status?.description()).toContain('workflow run status'); + const optionNames = status?.options.map((option) => option.long); + expect(optionNames).toContain('--json'); }); it('logs has --follow and --poll-interval options', () => { diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index e3a9ea8fb..ee7dbf823 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -11,7 +11,6 @@ import { clearStoredAuth, defaultApiUrl, AUTH_FILE_PATH, - SUPPORTED_PROVIDERS, runWorkflow, getRunStatus, getRunLogs, @@ -56,14 +55,24 @@ function withDefaults(overrides: Partial = {}): CloudDependen }; } +const PROVIDER_ALIASES: Record = { + claude: 'anthropic', + codex: 'openai', + gemini: 'google', +}; + +const PROVIDER_HELP_TEXT = [ + 'anthropic (alias: claude)', + 'openai (alias: codex)', + 'google (alias: gemini)', + 'cursor', + 'opencode', + 'droid', +].join(', '); + function normalizeProvider(providerArg: string): string { const providerInput = providerArg.toLowerCase().trim(); - const providerMap: Record = { - claude: 'anthropic', - codex: 'openai', - gemini: 'google', - }; - return providerMap[providerInput] || providerInput; + return PROVIDER_ALIASES[providerInput] || providerInput; } function parsePositiveInteger(value: string): number { @@ -210,7 +219,7 @@ export function registerCloudCommands( cloudCommand .command('connect') .description('Connect a provider via interactive SSH session') - .argument('', `Provider to connect (${SUPPORTED_PROVIDERS.join(', ')})`) + .argument('', `Provider to connect (${PROVIDER_HELP_TEXT})`) .option('--api-url ', 'Cloud API base URL') .option('--language ', 'Sandbox language/image', 'typescript') .option('--timeout ', 'Connection timeout in seconds', parsePositiveInteger, 300) @@ -379,7 +388,19 @@ export function registerCloudCommands( .option('--json', 'Print raw JSON response', false) .action(async (runId: string, options: { apiUrl?: string; json?: boolean }) => { const result = await getRunStatus(runId, options); - deps.log(JSON.stringify(result, null, 2)); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + return; + } + + deps.log(`Run: ${result.runId ?? runId}`); + deps.log(`Status: ${result.status ?? 'unknown'}`); + if (typeof result.sandboxId === 'string') { + deps.log(`Sandbox: ${result.sandboxId}`); + } + if (typeof result.updatedAt === 'string') { + deps.log(`Updated: ${result.updatedAt}`); + } }); // ── logs ─────────────────────────────────────────────────────────────────── From 1c72b6b03c48615533358fc31842f950023ed982 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 26 Mar 2026 00:19:30 -0400 Subject: [PATCH 5/7] Make dark hero background transparent Move the decorative radial gradients into the dark .page context and explicitly set :global(html[data-theme='dark']) .heroSection to background: transparent. This ensures the dark-theme gradients apply at the page level while the hero section itself is cleared (preserving child stacking and layout). --- web/app/landing.module.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/app/landing.module.css b/web/app/landing.module.css index d53c1925b..c8ae7dd28 100644 --- a/web/app/landing.module.css +++ b/web/app/landing.module.css @@ -21,10 +21,6 @@ } :global(html[data-theme='dark']) .page { - background: var(--bg); -} - -:global(html[data-theme='dark']) .heroSection { background: radial-gradient(circle at 14% 0%, color-mix(in srgb, var(--primary) 18%, transparent), transparent 36%), radial-gradient(circle at 78% 14%, color-mix(in srgb, var(--primary) 14%, transparent), transparent 30%), @@ -32,6 +28,10 @@ var(--landing-hero-bg); } +:global(html[data-theme='dark']) .heroSection { + background: transparent; +} + .heroSection > * { position: relative; z-index: 2; From a59358a4b52e31e1c6b3fbbd1c0f0deec3b99463 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 26 Mar 2026 00:22:56 -0400 Subject: [PATCH 6/7] cloud: add sync excludes, improve error handling Update cloud CLI and workflows to improve security and robustness: - packages/cloud/src/workflows.ts: expand CODE_SYNC_EXCLUDES to omit environment files, keys, AWS/SSH credentials and other sensitive artifacts from code syncs. - src/cli/commands/cloud.ts: import crypto and use crypto.randomUUID() for temporary patch filenames to avoid collisions; derive provider help text dynamically from CLI_AUTH_CONFIG and PROVIDER_ALIASES; import REFRESH_WINDOW_MS and use it to determine auth refresh timing; rewrite getErrorDetails to prefer parsing response bodies (JSON or text) with safer fallbacks. These changes prevent leaking sensitive files during sync, produce clearer error messages, reduce tmp file name collisions, and make auth refresh behavior configurable. --- packages/cloud/src/workflows.ts | 17 +++++++++++++- src/cli/commands/cloud.ts | 40 +++++++++++++++++---------------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/cloud/src/workflows.ts b/packages/cloud/src/workflows.ts index 5ba420857..802154248 100644 --- a/packages/cloud/src/workflows.ts +++ b/packages/cloud/src/workflows.ts @@ -34,7 +34,22 @@ type RunWorkflowOptions = { syncCode?: boolean; }; -const CODE_SYNC_EXCLUDES = [".git", "node_modules", ".sst", ".next", ".open-next"]; +const CODE_SYNC_EXCLUDES = [ + ".git", + "node_modules", + ".sst", + ".next", + ".open-next", + ".env", + ".env.*", + ".env.local", + ".env.production", + "*.pem", + "*.key", + "credentials.json", + ".aws", + ".ssh", +]; function validateYamlWorkflow(content: string): void { const hasField = (field: string) => diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index ee7dbf823..0d568782c 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -11,6 +12,7 @@ import { clearStoredAuth, defaultApiUrl, AUTH_FILE_PATH, + REFRESH_WINDOW_MS, runWorkflow, getRunStatus, getRunLogs, @@ -61,14 +63,13 @@ const PROVIDER_ALIASES: Record = { gemini: 'google', }; -const PROVIDER_HELP_TEXT = [ - 'anthropic (alias: claude)', - 'openai (alias: codex)', - 'google (alias: gemini)', - 'cursor', - 'opencode', - 'droid', -].join(', '); +const PROVIDER_HELP_TEXT = Object.keys(CLI_AUTH_CONFIG) + .sort() + .map((id) => { + const alias = Object.entries(PROVIDER_ALIASES).find(([, target]) => target === id); + return alias ? `${id} (alias: ${alias[0]})` : id; + }) + .join(', '); function normalizeProvider(providerArg: string): string { const providerInput = providerArg.toLowerCase().trim(); @@ -103,18 +104,19 @@ function sleep(ms: number): Promise { } async function getErrorDetails(response: Response): Promise { - let details = response.statusText; + let body: string; try { - const json = (await response.json()) as { error?: string; message?: string }; - details = json.error || json.message || details; + body = await response.text(); } catch { - try { - details = await response.text(); - } catch { - // ignore - } + return response.statusText; + } + if (!body) return response.statusText; + try { + const json = JSON.parse(body) as { error?: string; message?: string }; + return json.error || json.message || response.statusText; + } catch { + return body; } - return details || response.statusText; } // ── Command registration ───────────────────────────────────────────────────── @@ -143,7 +145,7 @@ export function registerCloudCommands( const existing = await readStoredAuth(); if (existing && existing.apiUrl === apiUrl) { const expiresAt = Date.parse(existing.accessTokenExpiresAt); - if (!Number.isNaN(expiresAt) && expiresAt - Date.now() > 60_000) { + if (!Number.isNaN(expiresAt) && expiresAt - Date.now() > REFRESH_WINDOW_MS) { deps.log(`Already logged in to ${existing.apiUrl}`); return; } @@ -484,7 +486,7 @@ export function registerCloudCommands( } const { execSync } = await import('node:child_process'); - const tmpPatch = path.join(os.tmpdir(), `cloud-sync-${Date.now()}.patch`); + const tmpPatch = path.join(os.tmpdir(), `cloud-sync-${crypto.randomUUID()}.patch`); fs.writeFileSync(tmpPatch, result.patch); try { From e860ededa671eb41215c53b020b19181397a8748 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 26 Mar 2026 00:42:42 -0400 Subject: [PATCH 7/7] Fix CodeQL high-severity alerts in cloud package - auth.ts: Validate state param (CSRF) before user-controlled error param to prevent user-controlled bypass of security check - workflows.ts: Remove stat-then-read TOCTOU race by reading file directly and handling EISDIR in the catch - cloud.ts: Use mkdtempSync + restrictive permissions (0o600) for temp patch file instead of predictable path in os.tmpdir() Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cloud/src/auth.ts | 27 +++++++++++++++++++++------ packages/cloud/src/workflows.ts | 12 +++++------- src/cli/commands/cloud.ts | 6 +++--- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 6b1516247..e5d2f4034 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -104,12 +104,23 @@ async function beginBrowserLogin(apiUrl: string): Promise { } const returnedState = requestUrl.searchParams.get("state"); - const accessToken = requestUrl.searchParams.get("access_token"); - const refreshToken = requestUrl.searchParams.get("refresh_token"); - const accessTokenExpiresAt = requestUrl.searchParams.get("access_token_expires_at"); - const returnedApiUrl = requestUrl.searchParams.get("api_url"); - const error = requestUrl.searchParams.get("error"); + // Validate state parameter first (CSRF protection) — this check + // must run unconditionally, before any user-controlled values. + if (returnedState !== state) { + redirectToHostedCliAuthPage(response, apiUrl, { + status: "error", + detail: "Invalid state parameter", + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error("Invalid state parameter in CLI login callback")); + } + return; + } + + const error = requestUrl.searchParams.get("error"); if (error) { redirectToHostedCliAuthPage(response, apiUrl, { status: "error", @@ -123,8 +134,12 @@ async function beginBrowserLogin(apiUrl: string): Promise { return; } + const accessToken = requestUrl.searchParams.get("access_token"); + const refreshToken = requestUrl.searchParams.get("refresh_token"); + const accessTokenExpiresAt = requestUrl.searchParams.get("access_token_expires_at"); + const returnedApiUrl = requestUrl.searchParams.get("api_url"); + if ( - returnedState !== state || !accessToken || !refreshToken || !accessTokenExpiresAt || diff --git a/packages/cloud/src/workflows.ts b/packages/cloud/src/workflows.ts index 802154248..e0dacfb84 100644 --- a/packages/cloud/src/workflows.ts +++ b/packages/cloud/src/workflows.ts @@ -123,19 +123,17 @@ export async function resolveWorkflowInput( inferWorkflowFileType(workflowArg) !== null; try { - const stat = await fs.stat(workflowArg); - if (!stat.isFile()) { - throw new Error(`Workflow path is not a file: ${workflowArg}`); - } - + const workflow = await fs.readFile(workflowArg, "utf-8"); const fileType = explicitFileType ?? inferWorkflowFileType(workflowArg); if (!fileType) { throw new Error(`Could not infer workflow type from ${workflowArg}. Use --file-type.`); } - - const workflow = await fs.readFile(workflowArg, "utf-8"); return { workflow, fileType }; } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "EISDIR") { + throw new Error(`Workflow path is not a file: ${workflowArg}`); + } if (!isMissingFileError(error)) { throw error; } diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index 0d568782c..591b2928d 100644 --- a/src/cli/commands/cloud.ts +++ b/src/cli/commands/cloud.ts @@ -1,4 +1,3 @@ -import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -486,8 +485,9 @@ export function registerCloudCommands( } const { execSync } = await import('node:child_process'); - const tmpPatch = path.join(os.tmpdir(), `cloud-sync-${crypto.randomUUID()}.patch`); - fs.writeFileSync(tmpPatch, result.patch); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-sync-')); + const tmpPatch = path.join(tmpDir, 'changes.patch'); + fs.writeFileSync(tmpPatch, result.patch, { mode: 0o600 }); try { const stat = execSync(`git apply --stat "${tmpPatch}"`, {