@rayrun/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,19 @@
1
1
  # @rayrun/cli
2
2
 
3
- Connect supported MCP clients, execute approved tools through OAuth, and manage Rayrun through its
4
- public API.
3
+ Build and deploy complete MCP servers, connect supported MCP clients, execute approved tools through
4
+ OAuth, and manage Rayrun through its public API.
5
5
 
6
6
  ```sh
7
7
  pnpm --package @rayrun/cli@latest dlx rayrun setup https://copy-the-endpoint-from-rayrun.example/mcp
8
8
  ```
9
9
 
10
- The command detects Claude Code, Codex/ChatGPT desktop, Cursor, VS Code, Windsurf/Devin Local, and OpenCode v2. It shows the detected clients before changing anything. Pass `--client cursor` more than once for automation, or `--all --yes` to configure every detected client.
10
+ The command detects Claude Code, Codex/ChatGPT desktop, Cursor, VS Code, Windsurf/Devin Local, and
11
+ OpenCode v2. It shows the targets before writing. Repeat `--client` to select targets, or use
12
+ `--all --yes` to configure all detected clients.
11
13
 
12
- It writes only the public Rayrun endpoint. OAuth stays inside each client; no bearer token or API key is put in a config file or shell history. Before changing configuration it stores byte-for-byte backups under `~/.rayrun/backups/setup`, writes JSON atomically, and restores every target if a configuration write fails. A later client-login failure leaves the valid configuration in place and prints the exact login command to retry.
13
-
14
- Backups are private to your user account (`0700` directories and `0600` files) and are retained until you remove old backup directories after their rollback window is no longer useful.
14
+ It writes only the public Rayrun endpoint. OAuth stays in each client; no token or API key enters a
15
+ config file or shell history. Before writing, it creates private byte-for-byte backups under
16
+ `~/.rayrun/backups/setup`. Configuration writes are atomic and roll back together on failure.
15
17
 
16
18
  ```sh
17
19
  rayrun setup <endpoint> --dry-run --all
@@ -20,9 +22,13 @@ rayrun setup <endpoint> --client codex --yes --login
20
22
  rayrun setup rollback <backup-id>
21
23
  ```
22
24
 
23
- Use `--project` for project-local configuration in Claude Code, Codex, Cursor, VS Code, Devin, and OpenCode. Legacy Windsurf remains user-scoped. Claude Code asks you to approve a project MCP server when that project opens. Codex loads `.codex/config.toml` only after you trust the project; restart Codex after doing so.
25
+ Use `--project` for project-local configuration in Claude Code, Codex, Cursor, VS Code, Devin, and
26
+ OpenCode. Legacy Windsurf remains user-scoped. Claude Code asks you to approve a project MCP server
27
+ when the project opens. Codex loads `.codex/config.toml` after you trust the project; restart Codex
28
+ after doing so.
24
29
 
25
- Interactive setup runs supported clients' OAuth login commands. `--yes` and non-interactive setup never launch login unless you explicitly pass `--login`; the command prints the exact login command when it skips one. Pass `--no-login` to make the choice explicit in scripts.
30
+ Interactive setup runs supported OAuth login commands. `--yes` and non-interactive setup skip login
31
+ unless you pass `--login`; use `--no-login` in scripts.
26
32
 
27
33
  ## Execute tools
28
34
 
@@ -52,14 +58,86 @@ Approve in the browser, then run the printed command. `resume` replays only the
52
58
  server-issued continuation state; its `accept` response cannot authorize the call without the
53
59
  authenticated browser decision. Pending files can contain the exact tool arguments and are stored
54
60
  under `~/.rayrun/execution/pending`. The resume window expires after 10 minutes; expired files are
55
- pruned by the next execution command and are also removed by `rayrun logout`. OAuth refresh tokens
56
- use the same account-private storage. `RAYRUN_CONFIG_HOME` must use a local, per-host filesystem.
57
- POSIX systems enforce `0700` directories and `0600` files. On Windows, execution storage must remain
58
- inside the current user profile and inherits that profile's ACL. `logout` removes tokens locally and
59
- attempts token revocation.
61
+ pruned by the next execution command or `rayrun logout`. OAuth tokens use the same private,
62
+ per-host storage; keep `RAYRUN_CONFIG_HOME` on a local filesystem. POSIX systems enforce `0700`
63
+ directories and `0600` files; Windows uses the current profile's ACL. `logout` removes local tokens
64
+ and attempts revocation.
65
+
66
+ ## Build and deploy an MCP server
67
+
68
+ `rayrun init` creates a complete TypeScript MCP server using Node.js 24,
69
+ [ViteMCP](https://github.com/vitemcp/server), Zod, and Vitest. Give the generated directory to Codex,
70
+ Claude Code, or another coding agent and ask it to implement the server—not just one tool. The
71
+ server may expose as many tools as the project needs, up to the release limit documented below.
72
+
73
+ ```sh
74
+ rayrun init my-mcp
75
+ cd my-mcp
76
+ npm test
77
+ export RAYRUN_API_KEY='rak_...'
78
+ rayrun deploy --wait
79
+ ```
80
+
81
+ On first deploy, Rayrun creates the service and saves its connection UID in
82
+ `.rayrun/project.json`. Later deploys update that same service. Use `--connection` to target a
83
+ different existing source deployment, and declare required secret names in `rayrun.json`:
84
+
85
+ ```sh
86
+ rayrun deploy --secret GITHUB_TOKEN=<workspace-secret-uid> --wait
87
+ rayrun deployments list --connection <connection-uid>
88
+ rayrun deployments logs <build-uid>
89
+ rayrun deployments releases <connection-uid>
90
+ rayrun deployments rollback <connection-uid> <release-uid> --wait
91
+ ```
92
+
93
+ The generated `rayrun.json` is the deployment contract. Visible configuration belongs in
94
+ `environment`; required secret environment names belong in `secrets` and are bound to
95
+ workspace-secret UIDs at deploy time:
96
+
97
+ ```json
98
+ {
99
+ "entrypoint": "dist/server.js",
100
+ "environment": {
101
+ "INVOICE_API_URL": "https://api.example.com"
102
+ },
103
+ "name": "invoice-automation",
104
+ "secrets": ["INVOICE_API_TOKEN"],
105
+ "version": 1
106
+ }
107
+ ```
108
+
109
+ The project root must also contain `package.json` with `"type": "module"` and a build script, plus
110
+ `package-lock.json`. The built server must listen on `PORT` and expose Streamable HTTP MCP at
111
+ `/mcp`. `NODE_ENV`, `PORT`, and `RAYRUN_DEPLOYMENT_UID` are reserved.
112
+
113
+ The recommended scaffold is a golden path, not a platform lock-in. Rayrun accepts a Node project
114
+ with a lockfile and build script whose configured entrypoint serves Streamable HTTP MCP at `/mcp`
115
+ on `PORT`. Source uploads reject symlinks, a Dockerfile, `.env` files, obvious private-key material,
116
+ more than 512 files, or more than 16 MiB. Source is encrypted at rest. Builds run remotely with a
117
+ fixed Node 24 Dockerfile whose runtime stage uses a non-root user, and activation happens only after
118
+ the candidate returns at least one tool from `tools/list`. A failed candidate leaves the active
119
+ release untouched.
120
+
121
+ Build logs are encrypted with the workspace data key and erased after 30 days. API keys need the
122
+ separate `deployments:logs:read` scope to read them.
123
+ The Dashboard's **Deploy services** key preset grants only the three deployment scopes required by
124
+ this CLI workflow.
125
+
126
+ Rotating a bound workspace secret marks the source service for redeploy. Running `rayrun deploy
127
+ --wait` again reuses the already-built image, verifies a candidate with the new secret version, and
128
+ activates it without uploading the secret value or rebuilding unchanged source.
129
+
130
+ `--wait` follows the exact release UID returned by Rayrun when a built image is reused. Source
131
+ images are limited to 512 MiB, workspace image retention is limited to 5 GiB, and inactive images
132
+ remain available for rollback for 30 days.
133
+
134
+ A workspace can hold 25 source-deployed services, run 10 source builds concurrently, and start 50
135
+ isolated builds in 24 hours. One service can build at a time. A release may advertise up to 1,000
136
+ tools; build logs are capped at 1 MiB and erased after 30 days.
60
137
 
61
- Execution lock errors print the exact generation file to remove if a process crashed and its PID was
62
- reused. Remove that file only after verifying that no Rayrun CLI execution command is still running.
138
+ Private package-manager credentials are not accepted: `.npmrc` and Yarn credential files are
139
+ excluded locally and rejected by the API. Build dependencies must be readable without placing a
140
+ registry token in the uploaded project.
63
141
 
64
142
  ## Manage a workspace
65
143
 
@@ -73,6 +151,21 @@ export RAYRUN_API_KEY='rak_...'
73
151
  rayrun connect mcp https://mcp.example.com --name 'Internal tools'
74
152
  rayrun connect openapi https://api.example.com/openapi.json --name 'Example API'
75
153
  rayrun connections list
154
+ rayrun connections history <connection-uid>
155
+ rayrun connections diff <connection-uid> <version-uid>
156
+ rayrun connections restore <connection-uid> <version-uid> --reason 'Restore reviewed settings'
157
+ rayrun access-profiles list
158
+ rayrun access-profiles history <profile-uid>
159
+ rayrun access-profiles diff <profile-uid> <version-uid>
160
+ rayrun access-profiles restore <profile-uid> <version-uid> \
161
+ --reason 'Restore reviewed policy' --confirm-risk
162
+ rayrun webhooks list
163
+ rayrun webhooks update <webhook-uid> --config '{"enabled":false}' \
164
+ --reason 'Pause deliveries during maintenance'
165
+ rayrun webhooks history <webhook-uid>
166
+ rayrun webhooks diff <webhook-uid> <version-uid>
167
+ rayrun webhooks restore <webhook-uid> <version-uid> \
168
+ --reason 'Restore reviewed destination'
76
169
  rayrun clients list
77
170
  rayrun tools search create issue --connection <connection-uid>
78
171
  rayrun policy inspect <client-uid> --query issue
@@ -84,3 +177,93 @@ List commands show a compact table. Add `--json` for stable machine-readable out
84
177
  request up to 100 rows, and `--cursor` with the printed next cursor to continue. The CLI calls the
85
178
  same hosted API and policy evaluator as the dashboard; it does not run a local gateway or store API
86
179
  keys.
180
+
181
+ Connection history versions the service name, slug, description, timeout, enablement, and payload
182
+ capture together. Credentials, headers, OAuth state, authority policy, health, and index state never
183
+ enter the snapshot. `history` is cursor-paginated, `diff` compares with the immediate predecessor,
184
+ and `restore` creates a new attributed version using optimistic concurrency.
185
+
186
+ Access-profile history versions the name, description, default, and exact tool rules together.
187
+ Client assignments and archival remain separate authorization events. Restore refuses references to
188
+ tools that are no longer available; pass `--confirm-risk` only after reviewing every Destructive or
189
+ Unknown tool allowed by the restored default or an exact rule. Immutable access-profile history is
190
+ limited to 64 MiB per workspace.
191
+
192
+ Webhook history versions the destination URL, description, event selection, delivery mode, batch
193
+ size, enablement, and payload forwarding together. Signing secrets, deliveries, retry state, and
194
+ health never enter the snapshot. Restore revalidates the historical destination, creates a new
195
+ attributed version, and preserves the current signing secret. Immutable webhook history is limited
196
+ to 64 MiB per workspace. Destination paths and query strings can contain provider tokens, so
197
+ `diff` requires `webhooks:read`; add `webhooks:write` to reveal full destination URLs. Read-only
198
+ API keys receive `null` instead of current or historical URLs.
199
+
200
+ ## Publish Workspace Skills
201
+
202
+ Workspace Skills are Agent Skills packages delivered by compatible clients through Rayrun's
203
+ SEP-2640 MCP extension. The package root must contain `SKILL.md`; local reads reject symlinks,
204
+ special files, unsafe paths, more than 512 files, or more than 16 MiB.
205
+
206
+ ```sh
207
+ rayrun skills validate ./skills/incident-response
208
+ rayrun skills push ./skills/incident-response --publish \
209
+ --mode both --all-clients --reason 'Reviewed for production use'
210
+ rayrun skills list
211
+ rayrun skills show <skill-uid>
212
+ rayrun skills audience <skill-uid> --mode direct --profiles <profile-uid>
213
+ rayrun skills history <skill-uid>
214
+ rayrun skills diff <skill-uid> <version-uid>
215
+ rayrun skills pull <skill-uid> --output ./recovered-skill
216
+ rayrun skills restore <skill-uid> <version-uid> --reason 'Recover reviewed instructions'
217
+ rayrun skills disable <skill-uid>
218
+ rayrun skills archive <skill-uid>
219
+ rayrun skills delete <skill-uid> --confirm-name incident-response
220
+ ```
221
+
222
+ `push` saves a draft unless `--publish` is explicit. A new draft never moves the existing published
223
+ pointer. Pass `--profiles` as comma-separated access-profile UIDs to narrow discovery, or explicitly
224
+ pass `--all-clients`. Later publish and audience commands preserve the current audience when both
225
+ flags are omitted. Script, binary-file, and possible-secret warnings require
226
+ `--confirm-risk` before publication. `allowed-tools` is guidance, not access policy.
227
+
228
+ `pull` refuses to overwrite an existing path and creates account-private directories and files.
229
+ Restore creates another immutable draft revision and does not publish it. Permanent deletion removes
230
+ all content and delivery summaries, cannot be undone, and keeps the Skill name reserved so an old
231
+ `skill://` URI cannot resolve to unrelated content.
232
+
233
+ ## Author hosted tool hooks
234
+
235
+ Use a full-control API key. Hook source executes in Rayrun’s hosted sandbox, not in the CLI process.
236
+
237
+ ```sh
238
+ rayrun hooks pull <connection-uid> <tool-uid> \
239
+ --output hook.ts --types-output rayrun-hooks.d.ts
240
+
241
+ rayrun hooks test <connection-uid> <tool-uid> \
242
+ --file hook.ts --arguments '{"query":"release"}' --mock-result '{"items":[]}'
243
+
244
+ rayrun hooks save <connection-uid> <tool-uid> \
245
+ --file hook.ts --reason 'Normalize release results'
246
+ rayrun hooks history <connection-uid> <tool-uid>
247
+ rayrun hooks diff <connection-uid> <tool-uid> <draft-version-uid>
248
+ rayrun hooks restore <connection-uid> <tool-uid> <draft-version-uid> \
249
+ --reason 'Restore the reviewed mapping'
250
+ rayrun hooks reset <connection-uid> <tool-uid> --confirm <hook-uid>@<version> \
251
+ --reason 'Remove obsolete experiment history'
252
+ rayrun hooks deploy <connection-uid> <tool-uid> --shadow
253
+ rayrun hooks logs <connection-uid> <tool-uid>
254
+ rayrun hooks rollback <connection-uid> <tool-uid> <revision-uid>
255
+ rayrun hooks deactivate <connection-uid> <tool-uid> --shadow
256
+ ```
257
+
258
+ `pull` can write the draft and generated TypeScript declarations separately. `test` compiles and
259
+ runs before plus an optional after stage using your mock result; it never contacts the upstream.
260
+ Each material `save` creates an immutable, attributed draft version; identical saves do not create
261
+ noise. `history` is paginated, `diff` compares with the previous version, and `restore` creates a
262
+ new draft head. Draft history and deployment revisions remain separate. Source and config history
263
+ is limited to 64 MiB per workspace.
264
+
265
+ `reset` permanently removes the hook's history and deployment revisions using the current hook UID
266
+ and version. A connection cannot be deleted while it owns hook history. `deploy` creates an
267
+ immutable revision and defaults to active; rollback and deactivate also accept `--shadow`. Hook
268
+ payloads follow the service capture setting, while timing, outcome, revision, request ID, and shadow
269
+ comparison remain available. Captured details require a full-control key.
package/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { main } from './src/main.js';
4
+ import { sanitizeTerminalError } from './src/sanitizeTerminalError.js';
4
5
 
5
6
  try {
6
7
  await main(process.argv.slice(2));
7
8
  } catch (error) {
8
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
9
+ process.stderr.write(
10
+ `${sanitizeTerminalError(error instanceof Error ? error.message : String(error))}\n`,
11
+ );
9
12
  process.exitCode = 1;
10
13
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rayrun/cli",
3
- "version": "0.3.0",
4
- "description": "Set up MCP clients, execute tools, and manage Rayrun from the command line",
3
+ "version": "0.5.0",
4
+ "description": "Build and deploy MCP servers, connect clients, execute tools, and manage Rayrun",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -15,11 +15,14 @@
15
15
  "index.js",
16
16
  "src/clients.js",
17
17
  "src/config.js",
18
+ "src/deployments.js",
18
19
  "src/execution.js",
19
20
  "src/main.js",
20
21
  "src/management.js",
21
22
  "src/oauth.js",
23
+ "src/sanitizeTerminalError.js",
22
24
  "src/setup.js",
25
+ "src/skills.js",
23
26
  "README.md",
24
27
  "LICENSE"
25
28
  ],
@@ -29,12 +32,14 @@
29
32
  },
30
33
  "dependencies": {
31
34
  "@modelcontextprotocol/client": "2.0.0",
32
- "@rayrun/sdk": "^0.4.0",
35
+ "@rayrun/sdk": "^0.7.0",
36
+ "diff": "^9.0.0",
37
+ "ignore": "7.0.6",
33
38
  "jsonc-parser": "^3.3.1",
34
39
  "smol-toml": "^1.8.0"
35
40
  },
36
41
  "devDependencies": {
37
- "vitest": "^4.1.10"
42
+ "vitest": "^4.1.11"
38
43
  },
39
44
  "engines": {
40
45
  "node": ">=20"
@@ -0,0 +1,233 @@
1
+ import createIgnore from 'ignore';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { lstat, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ const DEFAULT_IGNORES = [
8
+ '.git/',
9
+ '.rayrun/',
10
+ '.dockerignore',
11
+ '.env',
12
+ '.env.*',
13
+ '.npmrc',
14
+ '.yarnrc',
15
+ '.yarnrc.yml',
16
+ '!.env.example',
17
+ '!.env.sample',
18
+ '!.env.template',
19
+ 'coverage/',
20
+ 'dist/',
21
+ 'node_modules/',
22
+ ];
23
+ const MAX_SOURCE_BYTES = 16_777_216;
24
+ const MAX_SOURCE_FILES = 512;
25
+
26
+ const packageJsonFor = (name) => ({
27
+ dependencies: { '@vitemcp/server': '1.6.5', zod: '4.4.3' },
28
+ devDependencies: {
29
+ '@types/node': '26.1.1',
30
+ tsx: '4.23.12',
31
+ typescript: '6.0.2',
32
+ vitest: '4.1.10',
33
+ },
34
+ engines: { node: '>=24' },
35
+ name,
36
+ private: true,
37
+ scripts: {
38
+ build: 'tsc -p tsconfig.json',
39
+ dev: 'tsx watch src/server.ts',
40
+ start: 'node dist/server.js',
41
+ test: 'vitest run',
42
+ },
43
+ type: 'module',
44
+ version: '1.0.0',
45
+ });
46
+
47
+ const scaffoldFiles = (name) => ({
48
+ '.gitignore': 'node_modules/\ndist/\ncoverage/\n.env\n.env.*\n!.env.example\n.rayrun/\n',
49
+ 'README.md': `# ${name}\n\nA stateless MCP server built with [ViteMCP](https://github.com/vitemcp/server) and ready for Rayrun.\n\n\`\`\`sh\nnpm test\nnpm run build\nnpx --yes rayrun@latest deploy --wait\n\`\`\`\n`,
50
+ 'package.json': `${JSON.stringify(packageJsonFor(name), null, 2)}\n`,
51
+ 'rayrun.json': `${JSON.stringify(
52
+ { entrypoint: 'dist/server.js', environment: {}, name, secrets: [], version: 1 },
53
+ null,
54
+ 2,
55
+ )}\n`,
56
+ 'src/greet.ts': `export const greet = (name: string) => \`Hello, \${name}!\`;\n`,
57
+ 'src/greet.test.ts': `import { greet } from './greet.js';\nimport { expect, test } from 'vitest';\n\ntest('greets the requested name', () => {\n expect(greet('Rayrun')).toBe('Hello, Rayrun!');\n});\n`,
58
+ 'src/server.ts': `import { ViteMCP } from '@vitemcp/server';\nimport { z } from 'zod';\nimport { greet } from './greet.js';\n\nconst server = new ViteMCP({ name: '${name}', version: '1.0.0' });\n\nserver.addTool({\n annotations: { readOnlyHint: true },\n description: 'Return a friendly greeting.',\n execute: async ({ name }) => greet(name),\n name: 'greet',\n parameters: z.object({ name: z.string().min(1).describe('Name to greet') }),\n});\n\nawait server.start({\n httpStream: { endpoint: '/mcp', port: Number.parseInt(process.env.PORT ?? '8080', 10) },\n transportType: 'httpStream',\n});\n`,
59
+ 'tsconfig.json': `${JSON.stringify(
60
+ {
61
+ compilerOptions: {
62
+ declaration: true,
63
+ esModuleInterop: true,
64
+ module: 'NodeNext',
65
+ moduleResolution: 'NodeNext',
66
+ noUncheckedIndexedAccess: true,
67
+ outDir: 'dist',
68
+ rootDir: 'src',
69
+ strict: true,
70
+ target: 'ES2023',
71
+ types: ['node'],
72
+ },
73
+ exclude: ['dist', 'node_modules', 'src/**/*.test.ts'],
74
+ include: ['src/**/*.ts'],
75
+ },
76
+ null,
77
+ 2,
78
+ )}\n`,
79
+ });
80
+
81
+ const runNpmInstall = async (directory) => {
82
+ await new Promise((resolve, reject) => {
83
+ const child = spawn('npm', ['install', '--ignore-scripts'], {
84
+ cwd: directory,
85
+ stdio: 'inherit',
86
+ });
87
+ child.once('error', reject);
88
+ child.once('exit', (code) =>
89
+ code === 0 ? resolve() : reject(new Error(`npm install exited with code ${String(code)}.`)),
90
+ );
91
+ });
92
+ };
93
+
94
+ const validateDeploymentName = (value) => {
95
+ const name = value.trim().toLowerCase();
96
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name) || name.length > 64) {
97
+ throw new Error('--name must be a lowercase kebab-case name of at most 64 characters.');
98
+ }
99
+ return name;
100
+ };
101
+
102
+ export const scaffoldDeploymentProject = async ({ directory, install = true, name }) => {
103
+ const destination = path.resolve(directory);
104
+ const projectName = validateDeploymentName(name ?? path.basename(destination));
105
+ try {
106
+ await lstat(destination);
107
+ throw new Error(`Refusing to overwrite existing path: ${destination}`);
108
+ } catch (error) {
109
+ if (error?.code !== 'ENOENT') throw error;
110
+ }
111
+ await mkdir(destination, { recursive: true });
112
+ const files = scaffoldFiles(projectName);
113
+ for (const [relativePath, content] of Object.entries(files)) {
114
+ const filePath = path.join(destination, relativePath);
115
+ await mkdir(path.dirname(filePath), { recursive: true });
116
+ await writeFile(filePath, content, { flag: 'wx', mode: 0o600 });
117
+ }
118
+ if (install) await runNpmInstall(destination);
119
+ return {
120
+ directory: destination,
121
+ fileCount: Object.keys(files).length + (install ? 1 : 0),
122
+ name: projectName,
123
+ };
124
+ };
125
+
126
+ const optionalText = async (filePath) => {
127
+ try {
128
+ return await readFile(filePath, 'utf8');
129
+ } catch (error) {
130
+ if (error?.code === 'ENOENT') return '';
131
+ throw error;
132
+ }
133
+ };
134
+
135
+ export const readDeploymentDirectory = async (directory) => {
136
+ const root = path.resolve(directory);
137
+ const rootStat = await lstat(root);
138
+ if (!rootStat.isDirectory()) throw new Error(`${root} is not a directory.`);
139
+ const hardIgnored = createIgnore().add(DEFAULT_IGNORES);
140
+ const projectIgnored = createIgnore();
141
+ projectIgnored.add(await optionalText(path.join(root, '.gitignore')));
142
+ projectIgnored.add(await optionalText(path.join(root, '.dockerignore')));
143
+ const files = [];
144
+ let byteCount = 0;
145
+
146
+ const walk = async (relativeDirectory = '') => {
147
+ const entries = await readdir(path.join(root, relativeDirectory), { withFileTypes: true });
148
+ for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
149
+ const relativePath = path.posix.join(relativeDirectory.split(path.sep).join('/'), entry.name);
150
+ const ignorePath = entry.isDirectory() ? `${relativePath}/` : relativePath;
151
+ if (hardIgnored.ignores(ignorePath) || projectIgnored.ignores(ignorePath)) continue;
152
+ if (entry.isSymbolicLink())
153
+ throw new Error(`Source symlinks are not allowed: ${relativePath}`);
154
+ if (entry.isDirectory()) {
155
+ await walk(relativePath);
156
+ continue;
157
+ }
158
+ if (!entry.isFile()) throw new Error(`Unsupported source file type: ${relativePath}`);
159
+ const bytes = await readFile(path.join(root, relativePath));
160
+ byteCount += bytes.byteLength;
161
+ if (files.length + 1 > MAX_SOURCE_FILES) {
162
+ throw new Error(`Source contains more than ${String(MAX_SOURCE_FILES)} files.`);
163
+ }
164
+ if (byteCount > MAX_SOURCE_BYTES) {
165
+ throw new Error('Source is larger than 16 MiB.');
166
+ }
167
+ files.push({ contentBase64: bytes.toString('base64'), path: relativePath });
168
+ }
169
+ };
170
+ await walk();
171
+ return files;
172
+ };
173
+
174
+ export const readDeploymentProjectState = async (directory) => {
175
+ try {
176
+ const stateDirectory = path.join(directory, '.rayrun');
177
+ const stateDirectoryStat = await lstat(stateDirectory);
178
+ if (!stateDirectoryStat.isDirectory() || stateDirectoryStat.isSymbolicLink()) {
179
+ throw new Error('.rayrun must be a real directory.');
180
+ }
181
+ const statePath = path.join(stateDirectory, 'project.json');
182
+ const stateStat = await lstat(statePath);
183
+ if (!stateStat.isFile() || stateStat.isSymbolicLink()) {
184
+ throw new Error('.rayrun/project.json must be a regular file.');
185
+ }
186
+ const parsed = JSON.parse(await readFile(statePath, 'utf8'));
187
+ return typeof parsed.connectionUid === 'string' ? parsed : null;
188
+ } catch (error) {
189
+ if (error?.code === 'ENOENT') return null;
190
+ throw new Error(
191
+ '.rayrun/project.json is invalid. Remove it or provide --connection explicitly.',
192
+ );
193
+ }
194
+ };
195
+
196
+ export const writeDeploymentProjectState = async (directory, connectionUid) => {
197
+ const stateDirectory = path.join(directory, '.rayrun');
198
+ try {
199
+ const state = await lstat(stateDirectory);
200
+ if (!state.isDirectory() || state.isSymbolicLink()) {
201
+ throw new Error('.rayrun must be a real directory.');
202
+ }
203
+ } catch (error) {
204
+ if (error?.code !== 'ENOENT') throw error;
205
+ await mkdir(stateDirectory, { mode: 0o700 });
206
+ }
207
+ const statePath = path.join(stateDirectory, 'project.json');
208
+ const temporaryPath = path.join(stateDirectory, `.project-${randomUUID()}.tmp`);
209
+ try {
210
+ await writeFile(temporaryPath, `${JSON.stringify({ connectionUid, version: 1 }, null, 2)}\n`, {
211
+ flag: 'wx',
212
+ mode: 0o600,
213
+ });
214
+ await rename(temporaryPath, statePath);
215
+ } catch (error) {
216
+ await unlink(temporaryPath).catch(() => undefined);
217
+ throw error;
218
+ }
219
+ };
220
+
221
+ export const parseSecretBindings = (values = []) => {
222
+ const bindings = {};
223
+ for (const value of values) {
224
+ const separator = value.indexOf('=');
225
+ if (separator <= 0 || separator === value.length - 1) {
226
+ throw new Error('--secret must use NAME=workspace-secret-uid.');
227
+ }
228
+ const name = value.slice(0, separator);
229
+ if (Object.hasOwn(bindings, name)) throw new Error(`${name} can only be bound once.`);
230
+ bindings[name] = value.slice(separator + 1);
231
+ }
232
+ return bindings;
233
+ };
package/src/main.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { clientDefinitions, detectClients } from './clients.js';
2
+ import { scaffoldDeploymentProject } from './deployments.js';
2
3
  import { executionUsage, isExecutionCommand, runExecutionCommand } from './execution.js';
3
4
  import { isManagementCommand, managementUsage, runManagementCommand } from './management.js';
4
5
  import { applySetup, rollbackSetup } from './setup.js';
@@ -8,6 +9,7 @@ import { createInterface } from 'node:readline/promises';
8
9
  const packageVersion = createRequire(import.meta.url)('../package.json').version;
9
10
 
10
11
  const usage = `Usage:
12
+ rayrun init <directory> [--name <kebab-case-name>] [--no-install]
11
13
  rayrun setup <endpoint> [--client <id> ... | --all] [--project] [--yes] [--dry-run] [--login | --no-login]
12
14
  rayrun setup rollback <backup-id>
13
15
 
@@ -112,6 +114,30 @@ export const main = async (arguments_, dependencies) => {
112
114
  process.stdout.write(`${String(packageVersion)}\n`);
113
115
  return;
114
116
  }
117
+ if (arguments_[0] === 'init') {
118
+ const directory = arguments_[1];
119
+ if (!directory || directory.startsWith('--')) {
120
+ throw new Error(usage);
121
+ }
122
+ let name;
123
+ let install = true;
124
+ for (let index = 2; index < arguments_.length; index += 1) {
125
+ const argument = arguments_[index];
126
+ if (argument === '--no-install') install = false;
127
+ else if (argument === '--name') {
128
+ if (name !== undefined) throw new Error('--name can only be provided once.');
129
+ name = arguments_[index + 1];
130
+ if (!name || name.startsWith('--')) throw new Error('--name requires a value.');
131
+ index += 1;
132
+ } else throw new Error(`Unknown option: ${String(argument)}`);
133
+ }
134
+ const created = await scaffoldDeploymentProject({ directory, install, name });
135
+ const output = dependencies?.output ?? process.stdout;
136
+ output.write(
137
+ `Created ${created.name} in ${created.directory}.\nNext: cd ${created.directory} && rayrun deploy --wait\n`,
138
+ );
139
+ return;
140
+ }
115
141
  if (isExecutionCommand(arguments_)) {
116
142
  await runExecutionCommand(arguments_, { ...dependencies, version: packageVersion });
117
143
  return;