diff --git a/package-lock.json b/package-lock.json index 3ae6cb52d..0b9a328ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "agent-relay", - "version": "3.2.15", + "version": "3.2.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "3.2.15", + "version": "3.2.18", "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,13 +24,15 @@ "web" ], "dependencies": { - "@agent-relay/config": "3.2.15", - "@agent-relay/hooks": "3.2.15", - "@agent-relay/sdk": "3.2.15", - "@agent-relay/telemetry": "3.2.15", - "@agent-relay/trajectory": "3.2.15", - "@agent-relay/user-directory": "3.2.15", - "@agent-relay/utils": "3.2.15", + "@agent-relay/cloud": "3.2.18", + "@agent-relay/config": "3.2.18", + "@agent-relay/hooks": "3.2.18", + "@agent-relay/sdk": "3.2.18", + "@agent-relay/telemetry": "3.2.18", + "@agent-relay/trajectory": "3.2.18", + "@agent-relay/user-directory": "3.2.18", + "@agent-relay/utils": "3.2.18", + "@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", @@ -99,6 +104,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 +197,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 +452,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 +542,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 +572,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 +593,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 +618,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 +637,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 +660,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 +677,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 +696,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 +763,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 +798,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 +853,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 +897,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.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/core": "^3.973.23", + "@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 +956,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 +1020,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 +1068,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 +1136,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", @@ -2427,6 +2732,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 +4004,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 +4083,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 +4169,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 +4199,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 +4238,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 +8633,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 +11190,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 +13713,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 +13759,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", @@ -14458,10 +14957,10 @@ }, "packages/acp-bridge": { "name": "@agent-relay/acp-bridge", - "version": "3.2.15", + "version": "3.2.18", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "3.2.15", + "@agent-relay/sdk": "3.2.18", "@agentclientprotocol/sdk": "^0.12.0" }, "bin": { @@ -14476,9 +14975,24 @@ "node": ">=18.0.0" } }, + "packages/cloud": { + "name": "@agent-relay/cloud", + "version": "3.2.18", + "dependencies": { + "@agent-relay/config": "3.2.18", + "@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", + "version": "3.2.18", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -14491,11 +15005,11 @@ }, "packages/hooks": { "name": "@agent-relay/hooks", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/config": "3.2.15", - "@agent-relay/sdk": "3.2.15", - "@agent-relay/trajectory": "3.2.15" + "@agent-relay/config": "3.2.18", + "@agent-relay/sdk": "3.2.18", + "@agent-relay/trajectory": "3.2.18" }, "devDependencies": { "@types/node": "^22.19.3", @@ -14505,9 +15019,9 @@ }, "packages/memory": { "name": "@agent-relay/memory", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/hooks": "3.2.15" + "@agent-relay/hooks": "3.2.18" }, "devDependencies": { "@types/node": "^22.19.3", @@ -14517,11 +15031,11 @@ }, "packages/openclaw": { "name": "@agent-relay/openclaw", - "version": "3.2.15", + "version": "3.2.18", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "3.2.15", + "@agent-relay/sdk": "3.2.18", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, @@ -15345,9 +15859,9 @@ }, "packages/policy": { "name": "@agent-relay/policy", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/config": "3.2.15" + "@agent-relay/config": "3.2.18" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15357,9 +15871,9 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/config": "3.2.15", + "@agent-relay/config": "3.2.18", "@relaycast/sdk": "^1.1.0", "@sinclair/typebox": "^0.34.48", "chalk": "^4.1.2", @@ -15443,7 +15957,7 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { "posthog-node": "^4.0.1" }, @@ -15455,9 +15969,9 @@ }, "packages/trajectory": { "name": "@agent-relay/trajectory", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/config": "3.2.15" + "@agent-relay/config": "3.2.18" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15467,9 +15981,9 @@ }, "packages/user-directory": { "name": "@agent-relay/user-directory", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/utils": "3.2.15" + "@agent-relay/utils": "3.2.18" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15479,9 +15993,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "3.2.15", + "version": "3.2.18", "dependencies": { - "@agent-relay/config": "3.2.15", + "@agent-relay/config": "3.2.18", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/package.json b/package.json index 8393a4cab..c6c3ab8d1 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", @@ -176,9 +177,11 @@ }, "homepage": "https://github.com/AgentWorkforce/relay#readme", "dependencies": { + "@agent-relay/cloud": "3.2.18", "@agent-relay/config": "3.2.18", "@agent-relay/hooks": "3.2.18", "@agent-relay/sdk": "3.2.18", + "@aws-sdk/client-s3": "^3.1004.0", "@agent-relay/telemetry": "3.2.18", "@agent-relay/trajectory": "3.2.18", "@agent-relay/user-directory": "3.2.18", @@ -196,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", @@ -244,9 +249,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/packages/cloud/package.json b/packages/cloud/package.json new file mode 100644 index 000000000..84d3b9a05 --- /dev/null +++ b/packages/cloud/package.json @@ -0,0 +1,44 @@ +{ + "name": "@agent-relay/cloud", + "version": "3.2.18", + "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.18", + "@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..e5d2f4034 --- /dev/null +++ b/packages/cloud/src/auth.ts @@ -0,0 +1,314 @@ +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"); + + // 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", + detail: error, + }); + if (!settled) { + settled = true; + server.close(); + reject(new Error(error)); + } + 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 ( + !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..e0dacfb84 --- /dev/null +++ b/packages/cloud/src/workflows.ts @@ -0,0 +1,539 @@ +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", + ".env", + ".env.*", + ".env.local", + ".env.production", + "*.pem", + "*.key", + "credentials.json", + ".aws", + ".ssh", +]; + +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 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.`); + } + 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; + } + } + + 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/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..e09354f36 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,69 @@ 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', - }); - - const output = getOutput(deps.log); - expect(output).toContain('Cloud sync: Enabled'); - expect(output).toContain('Cloud connection: Online'); + 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'); + + 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('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'); + const optionNames = status?.options.map((option) => option.long); + expect(optionNames).toContain('--json'); }); - 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'); }); }); diff --git a/src/cli/commands/cloud.ts b/src/cli/commands/cloud.ts index 0405b436f..591b2928d 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, + REFRESH_WINDOW_MS, + 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,70 @@ function withDefaults( }; } -function readConfigFile(configPath: string): CloudConfig | undefined { - if (!fs.existsSync(configPath)) { - return undefined; +const PROVIDER_ALIASES: Record = { + claude: 'anthropic', + codex: 'openai', + gemini: 'google', +}; + +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(); + return PROVIDER_ALIASES[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 body: string; + try { + body = await response.text(); + } catch { + 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 config; } +// ── Command registration ───────────────────────────────────────────────────── + export function registerCloudCommands( program: Command, overrides: Partial = {} @@ -140,353 +128,388 @@ 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() > REFRESH_WINDOW_MS) { + 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...'); - - 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); - } - - 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); - } + await clearStoredAuth(); + deps.log('Logged out.'); }); - cloudCommand - .command('unlink') - .description('Unlink this machine from Agent Relay Cloud') - .action(async () => { - const dataDir = deps.getDataDir(); - const { configPath } = getPaths(dataDir); + // ── whoami ───────────────────────────────────────────────────────────────── - if (!fs.existsSync(configPath)) { - deps.log('This machine is not linked to Agent Relay Cloud.'); - return; + 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'); } - 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(''); + 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('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; + .command('connect') + .description('Connect a provider via interactive SSH session') + .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) + .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).'); } - 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(''); - - try { - const client = deps.createApiClient(); - const online = await client.checkConnection({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - }); - 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})`); + 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(', ')}`); } - 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('sync') - .description('Manually sync credentials from cloud') - .action(async () => { - const dataDir = deps.getDataDir(); - const { configPath, credentialsPath } = getPaths(dataDir); - const config = ensureLinked(configPath, deps); + const start = (await createResponse.json().catch(() => null)) as + | (AuthSessionResponse & { error?: string; message?: string }) + | null; + + if (!createResponse.ok || !start?.sessionId) { + const detail = start?.error || start?.message || `${createResponse.status} ${createResponse.statusText}`; + throw new Error(detail); + } - deps.log('Syncing credentials from cloud...'); + 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.'); + } + 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 credentials = await client.syncCredentials({ - 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, }); + } catch (error) { + throw new Error(`Failed to connect via SSH: ${error instanceof Error ? error.message : String(error)}`); + } - deps.log(''); - deps.log(`Synced ${credentials.length} provider credentials:`); - for (const credential of credentials) { - deps.log(` - ${credential.provider}`); + io.log(''); + const success = sessionResult.authDetected; + + 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 }), } + ); - fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2)); - fs.chmodSync(credentialsPath, 0o600); + if (!completeResponse.ok) { + throw new Error(await getErrorDetails(completeResponse)); + } - 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); + 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`); } + + 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(''); }); - 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); + // ── run ──────────────────────────────────────────────────────────────────── - try { - const client = deps.createApiClient(); - const agents = await client.listAgents({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - }); - - if (options.json) { - deps.log(JSON.stringify(agents, null, 2)); - return; - } + 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; + } - if (!agents.length) { - deps.log('No agents found across linked machines.'); - deps.log('Make sure brokers are running on linked machines.'); - 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}`); + }); - 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); - } + // ── status ───────────────────────────────────────────────────────────────── - 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 }, - ]) - ); - } - } + cloudCommand + .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); + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + return; + } - 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); + 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}`); } }); - 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}...`); + // ── logs ─────────────────────────────────────────────────────────────────── - try { - const client = deps.createApiClient(); - const allAgents = await client.listAgents({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, + 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, }); - 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; + if (options.json) { + deps.log(JSON.stringify(result, null, 2)); + } else if (result.content) { + process.stdout.write(result.content); } - await client.sendMessage({ - cloudUrl: config.cloudUrl, - apiKey: config.apiKey, - targetBrokerId: targetAgent.brokerId, - targetAgent: agent, - from: options.from, - content: message, - }); + offset = result.offset; + if (!options.follow || result.done) { + break; + } - 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); + await sleep((options.pollInterval ?? 2) * 1000); } }); + // ── sync ─────────────────────────────────────────────────────────────────── + 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); + .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; + } + + if (options.dryRun) { + deps.log('\n--- Patch (dry run) ---'); + process.stdout.write(result.patch); + deps.log('\n--- End patch ---'); + return; + } + + const { execSync } = await import('node:child_process'); + 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 { - 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); }); } 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;