runcloud 0.1.2

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.
@@ -0,0 +1,173 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { clearCredentials, credentialsDisplayPath, saveCredentials } from '../config.js';
5
+ const DEFAULT_APP_URL = process.env.NEWLY_APP_URL ?? 'https://newly.app';
6
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
7
+ export async function startCallbackServer(state, appOrigin, timeoutMs) {
8
+ let resolvePayload;
9
+ let rejectPayload;
10
+ const payloadPromise = new Promise((resolve, reject) => {
11
+ resolvePayload = resolve;
12
+ rejectPayload = reject;
13
+ });
14
+ const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`)), timeoutMs);
15
+ const corsHeaders = {
16
+ 'Access-Control-Allow-Origin': appOrigin,
17
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
18
+ 'Access-Control-Allow-Headers': 'content-type',
19
+ };
20
+ const server = http.createServer((req, res) => {
21
+ if (req.method === 'OPTIONS') {
22
+ res.writeHead(204, corsHeaders);
23
+ res.end();
24
+ return;
25
+ }
26
+ if (req.method !== 'POST') {
27
+ res.writeHead(405, corsHeaders);
28
+ res.end();
29
+ return;
30
+ }
31
+ let body = '';
32
+ req.on('data', (chunk) => (body += chunk));
33
+ req.on('end', () => {
34
+ try {
35
+ const parsed = JSON.parse(body);
36
+ if (parsed.state !== state) {
37
+ res.writeHead(403, corsHeaders);
38
+ res.end('state mismatch');
39
+ return;
40
+ }
41
+ res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify({ ok: true }));
43
+ clearTimeout(timer);
44
+ resolvePayload(parsed);
45
+ }
46
+ catch {
47
+ res.writeHead(400, corsHeaders);
48
+ res.end('bad request');
49
+ }
50
+ });
51
+ });
52
+ const port = await new Promise((resolve) => {
53
+ server.listen(0, '127.0.0.1', () => {
54
+ const addr = server.address();
55
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
56
+ });
57
+ });
58
+ return {
59
+ port,
60
+ waitForToken: () => payloadPromise,
61
+ close: () => {
62
+ clearTimeout(timer);
63
+ server.close();
64
+ },
65
+ };
66
+ }
67
+ function openBrowser(url) {
68
+ const [cmd, args] = process.platform === 'darwin'
69
+ ? ['open', [url]]
70
+ : process.platform === 'win32'
71
+ ? ['cmd', ['/c', 'start', '', url]]
72
+ : ['xdg-open', [url]];
73
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
74
+ child.on('error', () => {
75
+ });
76
+ child.unref();
77
+ }
78
+ export function parseManualCredential(blob) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(blob);
82
+ }
83
+ catch {
84
+ throw new Error('Could not parse the pasted credential as JSON. Copy the entire blob shown in the browser and try again.');
85
+ }
86
+ const c = parsed;
87
+ if (typeof c.token !== 'string' || typeof c.expiresIn !== 'number' || typeof c.apiUrl !== 'string') {
88
+ throw new Error('Pasted credential is missing required fields (expected JSON with token, expiresIn, apiUrl). ' +
89
+ 'Copy the entire blob shown in the browser.');
90
+ }
91
+ return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
92
+ }
93
+ export function registerLogin(program) {
94
+ program
95
+ .command('login')
96
+ .description('Log in with your Newly account via the browser')
97
+ .option('--app-url <url>', 'Newly web app URL', DEFAULT_APP_URL)
98
+ .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
99
+ .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
100
+ .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
101
+ .option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
102
+ .action(async (opts) => {
103
+ try {
104
+ const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
105
+ const hasToken = typeof opts.token === 'string' && opts.token.length > 0;
106
+ if (hasApiUrl !== hasToken) {
107
+ throw new Error('--api-url and --token must be provided together for headless login.');
108
+ }
109
+ if (hasApiUrl && hasToken) {
110
+ try {
111
+ new URL(opts.apiUrl);
112
+ }
113
+ catch {
114
+ throw new Error(`--api-url is not a valid URL: ${opts.apiUrl}`);
115
+ }
116
+ saveCredentials({
117
+ token: opts.token,
118
+ apiUrl: opts.apiUrl,
119
+ appUrl: opts.appUrl,
120
+ expiresAt: Math.floor(Date.now() / 1000) + opts.expiresIn,
121
+ });
122
+ console.log(`Logged in headlessly. Credentials written for API ${opts.apiUrl}.`);
123
+ return;
124
+ }
125
+ if (opts.manual) {
126
+ const { createInterface } = await import('node:readline/promises');
127
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
128
+ const blob = await rl.question('Paste the credential from the browser: ');
129
+ rl.close();
130
+ const parsed = parseManualCredential(blob);
131
+ saveCredentials({
132
+ token: parsed.token,
133
+ apiUrl: parsed.apiUrl,
134
+ appUrl: opts.appUrl,
135
+ expiresAt: Math.floor(Date.now() / 1000) + parsed.expiresIn,
136
+ });
137
+ console.log('Logged in.');
138
+ return;
139
+ }
140
+ const state = crypto.randomBytes(16).toString('hex');
141
+ const appOrigin = new URL(opts.appUrl).origin;
142
+ const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
143
+ const loginUrl = `${opts.appUrl}/cli-auth?port=${port}&state=${state}`;
144
+ console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
145
+ console.log("If the browser doesn't open, paste the URL above into it manually.");
146
+ openBrowser(loginUrl);
147
+ try {
148
+ const payload = await waitForToken();
149
+ saveCredentials({
150
+ token: payload.token,
151
+ apiUrl: payload.apiUrl,
152
+ appUrl: opts.appUrl,
153
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
154
+ });
155
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
156
+ }
157
+ finally {
158
+ close();
159
+ }
160
+ }
161
+ catch (e) {
162
+ console.error(e instanceof Error ? e.message : String(e));
163
+ process.exitCode = 1;
164
+ }
165
+ });
166
+ program
167
+ .command('logout')
168
+ .description('Delete the stored CLI credential')
169
+ .action(() => {
170
+ clearCredentials();
171
+ console.log('Logged out.');
172
+ });
173
+ }
@@ -0,0 +1,175 @@
1
+ import { ApiClient, friendlyApiError } from '../api.js';
2
+ import { requireCredentials, resolveProject } from '../config.js';
3
+ export function assertDevOnly(env, command) {
4
+ if (env !== 'dev') {
5
+ throw new Error(`\`newly ${command}\` is dev-only from the CLI. Prod ${command} requires the Newly web app (a browser session).`);
6
+ }
7
+ }
8
+ export function parseDuration(s) {
9
+ const m = /^(\d+)(s|m|h)$/.exec(s.trim());
10
+ if (!m)
11
+ throw new Error(`Invalid duration "${s}" (use e.g. 30s, 5m, 2h)`);
12
+ const n = Number(m[1]);
13
+ const unit = m[2];
14
+ const mult = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : 3_600_000;
15
+ return n * mult;
16
+ }
17
+ const ISO_8601 = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})?)?$/;
18
+ function validateIso(s) {
19
+ if (!ISO_8601.test(s) || Number.isNaN(Date.parse(s))) {
20
+ throw new Error(`Invalid ISO 8601 timestamp "${s}" (expected e.g. 2026-06-15 or 2026-06-15T08:00:00Z)`);
21
+ }
22
+ }
23
+ export function resolveLogWindow(opts, now = Date.now()) {
24
+ let start = opts.start;
25
+ if (!start && opts.since)
26
+ start = new Date(now - parseDuration(opts.since)).toISOString();
27
+ if (opts.start)
28
+ validateIso(opts.start);
29
+ if (opts.end)
30
+ validateIso(opts.end);
31
+ return { start, end: opts.end };
32
+ }
33
+ export function formatLogEntry(e) {
34
+ if (!e || typeof e !== 'object')
35
+ return String(e);
36
+ const ts = e.ts ?? '-';
37
+ const sev = (e.severity ?? 'OK').padEnd(5);
38
+ const trace = e.trace_id ? ` trace=${e.trace_id.slice(0, 16)}` : '';
39
+ const name = e.name ?? '';
40
+ return `${ts} ${sev} ${name}${trace}`.trimEnd();
41
+ }
42
+ export async function fetchLogs(client, projectId, env, service, pageSize, bounds = {}, filters = {}) {
43
+ const params = new URLSearchParams();
44
+ params.set('env', env);
45
+ params.set('service', service);
46
+ params.set('page_size', String(pageSize));
47
+ if (bounds.start)
48
+ params.set('start', bounds.start);
49
+ if (bounds.end)
50
+ params.set('end', bounds.end);
51
+ if (filters.severityMin)
52
+ params.set('severity_min', filters.severityMin);
53
+ if (filters.traceId)
54
+ params.set('trace_id', filters.traceId);
55
+ if (filters.textSearch)
56
+ params.set('text_search', filters.textSearch);
57
+ const out = await client.get(`/newly_backend/logs/${encodeURIComponent(projectId)}?${params.toString()}`);
58
+ if (Array.isArray(out.entries)) {
59
+ return out.entries.map((e) => formatLogEntry(e)).join('\n');
60
+ }
61
+ if (typeof out.logs === 'string')
62
+ return out.logs;
63
+ if (Array.isArray(out.lines))
64
+ return out.lines.join('\n');
65
+ return JSON.stringify(out, null, 2);
66
+ }
67
+ export async function listSecrets(client, projectId, env, service) {
68
+ const out = await client.get(`/newly_backend/read_secrets?project_id=${encodeURIComponent(projectId)}&env=${env}&service=${service}`);
69
+ if (Array.isArray(out.keys))
70
+ return [...out.keys].sort().join('\n');
71
+ const secrets = out.secrets ?? out;
72
+ return Object.keys(secrets).sort().join('\n');
73
+ }
74
+ export async function setSecret(client, projectId, env, service, key, value) {
75
+ await client.post('/newly_backend/set_secret', {
76
+ project_id: projectId,
77
+ env,
78
+ service,
79
+ key,
80
+ value,
81
+ });
82
+ }
83
+ export async function getDbUrl(client, projectId, env) {
84
+ const out = await client.get(`/newly_backend/readonly_db_url?project_id=${encodeURIComponent(projectId)}&env=${env}`);
85
+ return out.database_url ?? out.url ?? JSON.stringify(out);
86
+ }
87
+ function makeClient(project) {
88
+ const creds = requireCredentials();
89
+ return {
90
+ client: new ApiClient(creds.apiUrl, creds.token),
91
+ projectId: resolveProject(process.cwd(), project),
92
+ };
93
+ }
94
+ export function registerMisc(program) {
95
+ program
96
+ .command('logs')
97
+ .description('Tail backend service logs')
98
+ .option('--env <env>', 'dev or prod', 'dev')
99
+ .option('--service <service>', 'api | auth', 'api')
100
+ .option('-n, --lines <n>', 'page size (number of log entries to fetch)', '200')
101
+ .option('--since <duration>', 'relative window, e.g. 5m, 1h, 30s')
102
+ .option('--start <iso>', 'window start (ISO 8601 timestamp)')
103
+ .option('--end <iso>', 'window end (ISO 8601 timestamp)')
104
+ .option('--severity <min>', 'min severity: ok | warn | error')
105
+ .option('--trace <id>', 'restrict to a single trace_id')
106
+ .option('--search <text>', 'substring filter on the entry name')
107
+ .option('--project <id>', 'project id')
108
+ .action(async (opts) => {
109
+ try {
110
+ assertDevOnly(opts.env, 'logs');
111
+ if (opts.service !== 'api' && opts.service !== 'auth') {
112
+ throw new Error(`--service must be 'api' or 'auth' (got ${JSON.stringify(opts.service)})`);
113
+ }
114
+ const bounds = resolveLogWindow(opts);
115
+ const { client, projectId } = makeClient(opts.project);
116
+ console.log(await fetchLogs(client, projectId, opts.env, opts.service, Number(opts.lines), bounds, { severityMin: opts.severity, traceId: opts.trace, textSearch: opts.search }));
117
+ }
118
+ catch (e) {
119
+ console.error(friendlyApiError(e));
120
+ process.exitCode = 1;
121
+ }
122
+ });
123
+ const secrets = program.command('secrets').description('Manage backend secrets');
124
+ secrets
125
+ .command('list')
126
+ .option('--env <env>', 'dev or prod', 'dev')
127
+ .option('--service <service>', 'backend service name', 'worker')
128
+ .option('--project <id>', 'project id')
129
+ .action(async (opts) => {
130
+ try {
131
+ assertDevOnly(opts.env, 'secrets list');
132
+ const { client, projectId } = makeClient(opts.project);
133
+ console.log(await listSecrets(client, projectId, opts.env, opts.service));
134
+ }
135
+ catch (e) {
136
+ console.error(friendlyApiError(e));
137
+ process.exitCode = 1;
138
+ }
139
+ });
140
+ secrets
141
+ .command('set')
142
+ .argument('<key>')
143
+ .argument('<value>')
144
+ .option('--env <env>', 'dev or prod', 'dev')
145
+ .option('--service <service>', 'backend service name', 'worker')
146
+ .option('--project <id>', 'project id')
147
+ .action(async (key, value, opts) => {
148
+ try {
149
+ assertDevOnly(opts.env, 'secrets set');
150
+ const { client, projectId } = makeClient(opts.project);
151
+ await setSecret(client, projectId, opts.env, opts.service, key, value);
152
+ console.log(`Secret ${key} set for ${opts.service} (${opts.env}). Redeploy to apply.`);
153
+ }
154
+ catch (e) {
155
+ console.error(friendlyApiError(e));
156
+ process.exitCode = 1;
157
+ }
158
+ });
159
+ const db = program.command('db').description('Database helpers');
160
+ db.command('url')
161
+ .description('Print a readonly URL for the Newly DB')
162
+ .option('--env <env>', 'dev or prod', 'dev')
163
+ .option('--project <id>', 'project id')
164
+ .action(async (opts) => {
165
+ try {
166
+ assertDevOnly(opts.env, 'db url');
167
+ const { client, projectId } = makeClient(opts.project);
168
+ console.log(await getDbUrl(client, projectId, opts.env));
169
+ }
170
+ catch (e) {
171
+ console.error(friendlyApiError(e));
172
+ process.exitCode = 1;
173
+ }
174
+ });
175
+ }
@@ -0,0 +1,53 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials, resolveProject, writeProjectLink } from '../config.js';
4
+ import { extractSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
5
+ export function assertPullTarget(dir, force) {
6
+ const entries = readdirSync(dir).filter((e) => e !== '.newly');
7
+ if (entries.length > 0 && !force) {
8
+ throw new Error(`Target directory is not empty. Re-run with --force to overwrite local files (this discards local changes).`);
9
+ }
10
+ }
11
+ export async function extractTarball(data, dir) {
12
+ await extractSnapshot(data, dir);
13
+ }
14
+ export function registerPull(program) {
15
+ program
16
+ .command('pull')
17
+ .argument('[project-id]', 'Newly project id (defaults to the linked project in this directory)')
18
+ .description('Download the project working tree (app + backend) into the current directory')
19
+ .option('--force', 'overwrite files in a non-empty directory', false)
20
+ .option('--branch <name>', 'branch to pull (defaults to the project active branch / main)')
21
+ .action(async (projectIdArg, opts) => {
22
+ try {
23
+ const creds = requireCredentials();
24
+ const dir = process.cwd();
25
+ const projectId = resolveProject(dir, projectIdArg);
26
+ assertPullTarget(dir, opts.force);
27
+ const client = new ApiClient(creds.apiUrl, creds.token);
28
+ const query = opts.branch ? `?branch=${encodeURIComponent(opts.branch)}` : '';
29
+ console.log(`Downloading project ${projectId}${opts.branch ? ` (branch ${opts.branch})` : ''}…`);
30
+ const res = (await client.get(`/newly_backend/repo_archive/${projectId}${query}`));
31
+ const data = Buffer.from(await res.arrayBuffer());
32
+ if (opts.force) {
33
+ const keep = await snapshotTopLevelEntries(data);
34
+ const removed = pruneStaleTopLevel(dir, keep);
35
+ if (removed.length > 0) {
36
+ console.log(`Removed ${removed.length} stale top-level ${removed.length === 1 ? 'entry' : 'entries'} not in the pulled tree: ${removed.join(', ')}`);
37
+ }
38
+ }
39
+ await extractTarball(data, dir);
40
+ const pulledBranch = res.headers.get('x-newly-branch');
41
+ writeProjectLink(dir, {
42
+ projectId,
43
+ pulledSha: res.headers.get('x-newly-head-sha'),
44
+ pulledAt: new Date().toISOString(),
45
+ });
46
+ console.log(`Pulled${pulledBranch ? ` branch ${pulledBranch}` : ''} into ${dir} and linked to project ${projectId}.`);
47
+ }
48
+ catch (e) {
49
+ console.error(friendlyApiError(e));
50
+ process.exitCode = 1;
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,272 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { ApiClient, friendlyApiError } from '../api.js';
5
+ import { requireCredentials } from '../config.js';
6
+ const RUN_CLOUD_SKILL = `---
7
+ name: run-cloud-ios-simulator
8
+ description: Build, install, and inspect iOS apps with run.cloud remote simulator sessions.
9
+ version: 0.1.0
10
+ ---
11
+
12
+ # run.cloud iOS Simulator
13
+
14
+ Use this skill when a user asks an agent to run, inspect, smoke test, or debug an iOS app in a remote simulator through run.cloud.
15
+
16
+ ## Requirements
17
+
18
+ - The user must be authenticated with \`runcloud login\`.
19
+ - The account must have run.cloud access and a positive run.cloud balance.
20
+ - App artifacts should be simulator-compatible builds. Device App Store IPAs may fail to install on iOS Simulator.
21
+
22
+ ## Workflow
23
+
24
+ 1. Check access with \`runcloud account --json\`.
25
+ 2. Create a simulator with \`runcloud ios create --install ./build/MyApp.tar.gz --json\`.
26
+ 3. Save the returned session id and URL.
27
+ 4. Use \`runcloud ios open-url <url> --id <session-id>\` when a URL or deep link must be opened.
28
+ 5. Release the session with \`runcloud ios delete <session-id>\`.
29
+
30
+ ## Embedded Iframes
31
+
32
+ - Use \`runcloud ios create --inactivity-timeout 60s --json\` when the embed should auto-close after user inactivity.
33
+ - Omit \`--inactivity-timeout\` or pass \`none\` when the user needs a metered session without idle auto-close.
34
+ - Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` to the parent window. Verify \`event.source\` before acting.
35
+ - If \`ios-simulator:session-restart-requested\` arrives, create a fresh run.cloud session; do not reuse the ended iframe URL.
36
+
37
+ ## Rules
38
+
39
+ - Prefer \`--json\` for parsed output.
40
+ - Always release sessions you create unless the user asks to keep them open.
41
+ - If installation fails, verify that the artifact is a simulator build before attempting code changes.
42
+ - Do not assume a local tunnel is installed on the user's machine.
43
+ - Do not expose iOS simulator tokens in logs or screenshots.
44
+ `;
45
+ function client() {
46
+ const creds = requireCredentials();
47
+ return new ApiClient(creds.apiUrl, creds.token);
48
+ }
49
+ function print(value, opts = {}) {
50
+ if (opts.json) {
51
+ console.log(JSON.stringify(value, null, 2));
52
+ return;
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value)
56
+ console.log(formatRecord(item));
57
+ return;
58
+ }
59
+ console.log(formatRecord(value));
60
+ }
61
+ function formatRecord(value) {
62
+ if (!value || typeof value !== 'object')
63
+ return String(value);
64
+ const record = value;
65
+ return Object.entries(record)
66
+ .filter(([, v]) => v !== undefined && v !== null)
67
+ .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
68
+ .join('\n');
69
+ }
70
+ function parseLabels(labels) {
71
+ const out = {};
72
+ for (const raw of labels ?? []) {
73
+ const idx = raw.indexOf('=');
74
+ if (idx <= 0)
75
+ throw new Error(`Invalid --label ${raw}; expected key=value`);
76
+ out[raw.slice(0, idx)] = raw.slice(idx + 1);
77
+ }
78
+ return out;
79
+ }
80
+ function fileBlob(path) {
81
+ const resolved = resolve(path);
82
+ if (!existsSync(resolved))
83
+ throw new Error(`File does not exist: ${path}`);
84
+ if (!statSync(resolved).isFile())
85
+ throw new Error(`Not a file: ${path}`);
86
+ const data = readFileSync(resolved);
87
+ return {
88
+ blob: new Blob([new Uint8Array(data)], { type: 'application/octet-stream' }),
89
+ name: basename(resolved),
90
+ };
91
+ }
92
+ function skillBase(agent, scope) {
93
+ if (scope === 'project') {
94
+ if (agent === 'claude')
95
+ return join(process.cwd(), '.claude', 'skills');
96
+ if (agent === 'codex')
97
+ return join(process.cwd(), '.codex', 'skills');
98
+ if (agent === 'cursor')
99
+ return join(process.cwd(), '.agents', 'skills');
100
+ }
101
+ if (agent === 'claude')
102
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'skills');
103
+ if (agent === 'codex')
104
+ return join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'skills');
105
+ if (agent === 'cursor')
106
+ return join(homedir(), '.agents', 'skills');
107
+ throw new Error(`Unsupported agent: ${agent}`);
108
+ }
109
+ function installSkill(opts) {
110
+ const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'cursor'];
111
+ const scope = opts.scope ?? 'project';
112
+ const results = [];
113
+ for (const agent of agents) {
114
+ const target = join(skillBase(agent, scope), 'run-cloud-ios-simulator');
115
+ if (existsSync(target)) {
116
+ if (!opts.force)
117
+ throw new Error(`${target} already exists. Pass --force to overwrite.`);
118
+ rmSync(target, { recursive: true, force: true });
119
+ }
120
+ mkdirSync(target, { recursive: true });
121
+ writeFileSync(join(target, 'SKILL.md'), RUN_CLOUD_SKILL);
122
+ results.push({ agent, path: target, status: 'installed' });
123
+ }
124
+ return results;
125
+ }
126
+ async function pushAsset(path, opts = {}) {
127
+ const { blob, name } = fileBlob(path);
128
+ const form = new FormData();
129
+ form.set('file', blob, name);
130
+ if (opts.name)
131
+ form.set('name', opts.name);
132
+ return (await client().uploadForm('/run-cloud/assets', form));
133
+ }
134
+ async function action(fn) {
135
+ try {
136
+ await fn();
137
+ }
138
+ catch (err) {
139
+ console.error(friendlyApiError(err));
140
+ process.exitCode = 1;
141
+ }
142
+ }
143
+ export function registerRunCloud(program) {
144
+ program
145
+ .command('account')
146
+ .description('Show run.cloud entitlement and metered balance')
147
+ .option('--json', 'output JSON', false)
148
+ .action((opts) => action(async () => {
149
+ print(await client().get('/run-cloud/account'), opts);
150
+ }));
151
+ const ios = program.command('ios').description('Create, list, inspect, and delete iOS simulator sessions');
152
+ ios
153
+ .command('create')
154
+ .description('Create a remote iOS simulator session')
155
+ .option('--model <iphone|ipad>', 'simulator model', 'iphone')
156
+ .option('--region <region>', 'fleet region')
157
+ .option('--display-name <name>', 'human-readable name')
158
+ .option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
159
+ .option('--install <file>', 'local .ipa, .zip, .tar.gz, or simulator .app archive to upload and install', (v, p) => [...p, v], [])
160
+ .option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
161
+ .option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
162
+ .option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
163
+ .option('--rm', 'release the session when this command exits', false)
164
+ .option('--json', 'output JSON', false)
165
+ .action((opts) => action(async () => {
166
+ const uploadedAssets = [];
167
+ for (const file of opts.install ?? [])
168
+ uploadedAssets.push(await pushAsset(file));
169
+ const installAssets = [
170
+ ...(opts.installAsset ?? []),
171
+ ...uploadedAssets
172
+ .map((asset) => asset.id)
173
+ .filter((id) => typeof id === 'string' && id.length > 0),
174
+ ];
175
+ const session = await client().post('/run-cloud/ios', {
176
+ model: opts.model,
177
+ region: opts.region,
178
+ displayName: opts.displayName,
179
+ labels: parseLabels(opts.label),
180
+ installAssets,
181
+ inactivityTimeout: opts.inactivityTimeout,
182
+ hardTimeout: opts.hardTimeout,
183
+ autoDelete: opts.rm,
184
+ });
185
+ print(session, opts);
186
+ }));
187
+ ios
188
+ .command('list')
189
+ .description('List active iOS simulator sessions')
190
+ .option('--all', 'include released sessions', false)
191
+ .option('--json', 'output JSON', false)
192
+ .action((opts) => action(async () => {
193
+ const qs = opts.all ? '?all=1' : '';
194
+ print(await client().get(`/run-cloud/ios${qs}`), opts);
195
+ }));
196
+ ios
197
+ .command('get')
198
+ .description('Show an iOS simulator session')
199
+ .argument('<id>')
200
+ .option('--json', 'output JSON', false)
201
+ .action((id, opts) => action(async () => {
202
+ print(await client().get(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
203
+ }));
204
+ ios
205
+ .command('delete')
206
+ .description('Release an iOS simulator session')
207
+ .argument('<id>')
208
+ .option('--json', 'output JSON', false)
209
+ .action((id, opts) => action(async () => {
210
+ print(await client().delete(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
211
+ }));
212
+ ios
213
+ .command('open-url')
214
+ .description('Open a URL or deep link in an iOS simulator session')
215
+ .argument('<url>')
216
+ .requiredOption('--id <id>', 'session id')
217
+ .option('--json', 'output JSON', false)
218
+ .action((url, opts) => action(async () => {
219
+ print(await client().post(`/run-cloud/ios/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
220
+ }));
221
+ const asset = program
222
+ .command('asset')
223
+ .alias('assets')
224
+ .description('Upload, list, inspect, and delete run.cloud assets');
225
+ asset
226
+ .command('push')
227
+ .description('Upload an app asset for simulator installation')
228
+ .argument('<file>')
229
+ .option('-n, --name <name>', 'asset name')
230
+ .option('--json', 'output JSON', false)
231
+ .action((file, opts) => action(async () => {
232
+ print(await pushAsset(file, opts), opts);
233
+ }));
234
+ asset
235
+ .command('list')
236
+ .description('List uploaded assets')
237
+ .option('--json', 'output JSON', false)
238
+ .action((opts) => action(async () => {
239
+ print(await client().get('/run-cloud/assets'), opts);
240
+ }));
241
+ asset
242
+ .command('pull')
243
+ .description('Download an asset')
244
+ .argument('<id>')
245
+ .option('-o, --output <path>', 'output path')
246
+ .action((id, opts) => action(async () => {
247
+ const body = await client().getBinary(`/run-cloud/assets/${encodeURIComponent(id)}/download`);
248
+ const output = opts.output ?? id;
249
+ writeFileSync(output, body);
250
+ console.log(output);
251
+ }));
252
+ asset
253
+ .command('delete')
254
+ .description('Delete an uploaded asset')
255
+ .argument('<id>')
256
+ .option('--json', 'output JSON', false)
257
+ .action((id, opts) => action(async () => {
258
+ print(await client().delete(`/run-cloud/assets/${encodeURIComponent(id)}`), opts);
259
+ }));
260
+ const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
261
+ skills
262
+ .command('install')
263
+ .description('Install the run.cloud iOS simulator skill')
264
+ .option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
265
+ .option('--scope <project|global>', 'install scope', 'project')
266
+ .option('--force', 'overwrite existing skill', false)
267
+ .option('--json', 'output JSON', false)
268
+ .action((opts) => action(async () => {
269
+ const scope = opts.scope === 'global' ? 'global' : 'project';
270
+ print(installSkill({ ...opts, scope }), opts);
271
+ }));
272
+ }