runcloud 0.1.122 → 0.1.124

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/dist/api.js CHANGED
@@ -168,11 +168,12 @@ export class ApiClient {
168
168
  const contentType_ = res.headers.get('content-type') ?? '';
169
169
  return contentType_.includes('application/json') ? res.json() : res;
170
170
  }
171
- async uploadForm(path, form) {
171
+ async uploadForm(path, form, headers = {}) {
172
172
  const res = await fetch(`${this.baseUrl}${path}`, {
173
173
  method: 'POST',
174
174
  headers: {
175
175
  Authorization: `Bearer ${this.token}`,
176
+ ...headers,
176
177
  },
177
178
  body: form,
178
179
  });
@@ -853,6 +853,7 @@ async function pushAsset(path, opts = {}) {
853
853
  const api = client();
854
854
  const prepared = await api.post('/run-cloud/assets/uploads', {
855
855
  filename: name,
856
+ ...(opts.orgId ? { orgId: opts.orgId } : {}),
856
857
  ...(opts.name ? { name: opts.name } : {}),
857
858
  contentType: blob.type || 'application/octet-stream',
858
859
  byteSize: blob.size,
@@ -886,6 +887,7 @@ async function createSimulatorSession(platform, opts) {
886
887
  .filter((id) => typeof id === 'string' && id.length > 0),
887
888
  ];
888
889
  return await client().post(`/run-cloud/${platform}`, {
890
+ ...(opts.orgId ? { orgId: opts.orgId } : {}),
889
891
  model: opts.model,
890
892
  region: opts.region,
891
893
  displayName: opts.displayName,
@@ -965,6 +967,7 @@ function registerSimulatorCommands(program, platform) {
965
967
  .option('--model <model>', platform === 'ios' ? 'simulator model' : 'emulator model', platform === 'ios' ? 'iphone' : 'pixel')
966
968
  .option('--region <region>', 'fleet region')
967
969
  .option('--display-name <name>', 'human-readable name')
970
+ .option('--org-id <id>', 'organization to own the session')
968
971
  .option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
969
972
  .option('--install <file>', 'local app artifact to upload and install', (v, p) => [...p, v], [])
970
973
  .option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
@@ -1416,6 +1419,7 @@ export function registerRunCloud(program) {
1416
1419
  .description('Upload an app asset for simulator installation')
1417
1420
  .argument('<file>')
1418
1421
  .option('-n, --name <name>', 'asset name')
1422
+ .option('--org-id <id>', 'organization to own the Asset')
1419
1423
  .option('--json', 'output JSON', false)
1420
1424
  .action((file, opts) => action(async () => {
1421
1425
  print(await pushAsset(file, opts), opts);
@@ -1423,9 +1427,11 @@ export function registerRunCloud(program) {
1423
1427
  asset
1424
1428
  .command('list')
1425
1429
  .description('List uploaded assets')
1430
+ .option('--org-id <id>', 'organization ID')
1426
1431
  .option('--json', 'output JSON', false)
1427
1432
  .action((opts) => action(async () => {
1428
- print(await client().get('/run-cloud/assets'), opts);
1433
+ const query = opts.orgId ? `?orgId=${encodeURIComponent(opts.orgId)}` : '';
1434
+ print(await client().get(`/run-cloud/assets${query}`), opts);
1429
1435
  }));
1430
1436
  asset
1431
1437
  .command('pull')
@@ -1446,6 +1452,113 @@ export function registerRunCloud(program) {
1446
1452
  .action((id, opts) => action(async () => {
1447
1453
  print(await client().delete(`/run-cloud/assets/${encodeURIComponent(id)}`), opts);
1448
1454
  }));
1455
+ const capability = program
1456
+ .command('capability')
1457
+ .alias('capabilities')
1458
+ .description('Mint, inspect, and revoke short-lived resource capabilities');
1459
+ capability
1460
+ .command('create')
1461
+ .description('Mint a bearer token bound to explicit org resources')
1462
+ .requiredOption('--scope <scope>', 'asset.read, mobile.launch, xcode.build.create, or xcode.build.read; repeatable', (value, previous) => [...previous, value], [])
1463
+ .option('--asset <id>', 'allowed Asset ID; repeatable', (value, previous) => [...previous, value], [])
1464
+ .option('--build <id>', 'allowed Xcode build ID; repeatable', (value, previous) => [...previous, value], [])
1465
+ .option('--idempotency-key <key>', 'allowed build or launch idempotency key; repeatable', (value, previous) => [...previous, value], [])
1466
+ .option('--org-id <id>', 'organization ID')
1467
+ .option('--name <name>', 'human-readable capability name')
1468
+ .option('--expires-in <seconds>', 'lifetime from 60 to 3600 seconds', '900')
1469
+ .option('--json', 'output JSON, including the one-time token', false)
1470
+ .action((opts) => action(async () => {
1471
+ const expiresInSeconds = Number(opts.expiresIn);
1472
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 60 || expiresInSeconds > 3600) {
1473
+ throw new Error('--expires-in must be an integer from 60 to 3600');
1474
+ }
1475
+ const resources = {
1476
+ ...(opts.asset?.length ? { assetIds: opts.asset } : {}),
1477
+ ...(opts.build?.length ? { buildIds: opts.build } : {}),
1478
+ ...(opts.idempotencyKey?.length ? { idempotencyKeys: opts.idempotencyKey } : {}),
1479
+ };
1480
+ if (Object.keys(resources).length === 0) {
1481
+ throw new Error('Provide at least one --asset, --build, or --idempotency-key resource');
1482
+ }
1483
+ print(await client().post('/run-cloud/capabilities', {
1484
+ scopes: opts.scope,
1485
+ resources,
1486
+ expiresInSeconds,
1487
+ ...(opts.orgId ? { orgId: opts.orgId } : {}),
1488
+ ...(opts.name ? { name: opts.name } : {}),
1489
+ }), opts);
1490
+ }));
1491
+ capability
1492
+ .command('list')
1493
+ .description('List capability metadata; secret token values are never returned')
1494
+ .option('--org-id <id>', 'organization ID')
1495
+ .option('--all', 'include expired and revoked capabilities', false)
1496
+ .option('--json', 'output JSON', false)
1497
+ .action((opts) => action(async () => {
1498
+ const query = new URLSearchParams();
1499
+ if (opts.orgId)
1500
+ query.set('orgId', opts.orgId);
1501
+ if (opts.all)
1502
+ query.set('active', '0');
1503
+ print(await client().get(`/run-cloud/capabilities${query.size ? `?${query}` : ''}`), opts);
1504
+ }));
1505
+ capability
1506
+ .command('revoke')
1507
+ .description('Revoke a capability immediately')
1508
+ .argument('<id>')
1509
+ .option('--json', 'output JSON', false)
1510
+ .action((id, opts) => action(async () => {
1511
+ print(await client().delete(`/run-cloud/capabilities/${encodeURIComponent(id)}`), opts);
1512
+ }));
1513
+ const mobile = program
1514
+ .command('mobile')
1515
+ .description('Idempotently launch an Asset or Xcode build in a mobile session');
1516
+ mobile
1517
+ .command('launch')
1518
+ .description('Create one session and install one reusable output')
1519
+ .option('--asset <id>', 'Asset ID to install')
1520
+ .option('--build <id>', 'successful Xcode simulator build to install')
1521
+ .requiredOption('--idempotency-key <key>', 'stable retry key owned by this launch intent')
1522
+ .option('--platform <ios|android>', 'mobile platform', 'ios')
1523
+ .option('--org-id <id>', 'organization ID')
1524
+ .option('--model <model>', 'simulator or emulator model')
1525
+ .option('--region <region>', 'fleet region')
1526
+ .option('--display-name <name>', 'human-readable session name')
1527
+ .option('--label <key=value>', 'session tag; repeatable', (value, previous) => [...previous, value], [])
1528
+ .option('--inactivity-timeout <duration>', 'idle timeout such as 60s, 3m, or none')
1529
+ .option('--hard-timeout <duration>', 'maximum lifetime such as 10m or 1h')
1530
+ .option('--codec <auto|mjpeg|webrtc>', 'viewer codec', 'auto')
1531
+ .option('--json', 'output JSON', false)
1532
+ .action((opts) => action(async () => {
1533
+ if (Boolean(opts.asset) === Boolean(opts.build)) {
1534
+ throw new Error('Provide exactly one of --asset or --build');
1535
+ }
1536
+ if (opts.platform !== 'ios' && opts.platform !== 'android') {
1537
+ throw new Error('--platform must be ios or android');
1538
+ }
1539
+ print(await client().postWithHeaders('/run-cloud/mobile/launch', {
1540
+ ...(opts.asset ? { assetId: opts.asset } : { buildId: opts.build }),
1541
+ platform: opts.platform,
1542
+ ...(opts.orgId ? { orgId: opts.orgId } : {}),
1543
+ session: {
1544
+ ...(opts.model ? { model: opts.model } : {}),
1545
+ ...(opts.region ? { region: opts.region } : {}),
1546
+ ...(opts.displayName ? { displayName: opts.displayName } : {}),
1547
+ tags: parseTags(opts.label),
1548
+ ...(opts.inactivityTimeout ? { inactivityTimeout: opts.inactivityTimeout } : {}),
1549
+ ...(opts.hardTimeout ? { hardTimeout: opts.hardTimeout } : {}),
1550
+ ...(opts.codec ? { codec: opts.codec } : {}),
1551
+ },
1552
+ }, { 'Idempotency-Key': opts.idempotencyKey }), opts);
1553
+ }));
1554
+ mobile
1555
+ .command('get')
1556
+ .description('Read one mobile launch operation and its session or failure')
1557
+ .argument('<id>')
1558
+ .option('--json', 'output JSON', false)
1559
+ .action((id, opts) => action(async () => {
1560
+ print(await client().get(`/run-cloud/mobile/launches/${encodeURIComponent(id)}`), opts);
1561
+ }));
1449
1562
  const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
1450
1563
  skills
1451
1564
  .command('install')
@@ -0,0 +1,576 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { cp, copyFile, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
+ import ignore from 'ignore';
7
+ import { create as createTar } from 'tar';
8
+ import { ApiClient, ApiError, friendlyApiError } from '../api.js';
9
+ import { requireCredentials } from '../config.js';
10
+ const MAX_SOURCE_ARCHIVE_BYTES = 100 * 1024 * 1024;
11
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
12
+ function transientError(error) {
13
+ if (error instanceof ApiError) {
14
+ return error.status === 408 || error.status === 429 || error.status >= 500;
15
+ }
16
+ const cause = error?.cause ?? error;
17
+ const nested = typeof cause === 'object' && cause !== null && Array.isArray(cause.errors)
18
+ ? cause.errors[0]
19
+ : cause;
20
+ const code = nested?.code;
21
+ return typeof code === 'string' && [
22
+ 'ECONNREFUSED',
23
+ 'ECONNRESET',
24
+ 'ENETUNREACH',
25
+ 'EAI_AGAIN',
26
+ 'ETIMEDOUT',
27
+ ].includes(code);
28
+ }
29
+ async function retryXcodeRequest(operation) {
30
+ let lastError;
31
+ for (let attempt = 0; attempt < 4; attempt += 1) {
32
+ try {
33
+ return await operation();
34
+ }
35
+ catch (error) {
36
+ lastError = error;
37
+ if (!transientError(error) || attempt === 3)
38
+ throw error;
39
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 250 * 2 ** attempt));
40
+ }
41
+ }
42
+ throw lastError;
43
+ }
44
+ function api() {
45
+ const credentials = requireCredentials();
46
+ return new ApiClient(credentials.apiUrl, credentials.token);
47
+ }
48
+ function print(value, json = false) {
49
+ if (json) {
50
+ console.log(JSON.stringify(value, null, 2));
51
+ return;
52
+ }
53
+ if (!value || typeof value !== 'object') {
54
+ console.log(String(value));
55
+ return;
56
+ }
57
+ for (const [key, item] of Object.entries(value)) {
58
+ if (item !== undefined && item !== null)
59
+ console.log(`${key}: ${typeof item === 'object' ? JSON.stringify(item) : item}`);
60
+ }
61
+ }
62
+ async function action(work) {
63
+ try {
64
+ await work();
65
+ }
66
+ catch (error) {
67
+ console.error(friendlyApiError(error));
68
+ process.exitCode = 1;
69
+ }
70
+ }
71
+ function integerFlag(value, name, minimum, maximum, fallback) {
72
+ if (value === undefined)
73
+ return fallback;
74
+ const parsed = Number(value);
75
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
76
+ throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
77
+ }
78
+ return parsed;
79
+ }
80
+ function repeated(value, previous) {
81
+ return [...previous, value];
82
+ }
83
+ function buildSettings(values = []) {
84
+ const result = {};
85
+ for (const value of values) {
86
+ const separator = value.indexOf('=');
87
+ if (separator < 1)
88
+ throw new Error(`Invalid --build-setting ${value}; expected KEY=VALUE`);
89
+ const key = value.slice(0, separator).trim();
90
+ if (Object.prototype.hasOwnProperty.call(result, key))
91
+ throw new Error(`Duplicate --build-setting ${key}`);
92
+ result[key] = value.slice(separator + 1);
93
+ }
94
+ return Object.keys(result).length ? result : undefined;
95
+ }
96
+ async function base64File(path, flag) {
97
+ try {
98
+ return (await readFile(resolve(path))).toString('base64');
99
+ }
100
+ catch (error) {
101
+ throw new Error(`Could not read ${flag} at ${path}: ${error instanceof Error ? error.message : error}`);
102
+ }
103
+ }
104
+ function defaultExcluded(relativePath) {
105
+ const normalized = relativePath.replace(/\/$/, '');
106
+ const parts = normalized.split('/').filter(Boolean);
107
+ const name = parts.at(-1) ?? '';
108
+ if (!parts.length)
109
+ return false;
110
+ if (name === '.DS_Store' || name.endsWith('.dSYM'))
111
+ return true;
112
+ if (parts.includes('.git') || parts.includes('.runcloud-home') || parts.includes('xcuserdata'))
113
+ return true;
114
+ if (['build', '.build', 'DerivedData', 'Index.noindex', 'ModuleCache.noindex', '.index-build', '.swiftpm', 'Pods'].some((item) => parts.includes(item)))
115
+ return true;
116
+ return parts.some((part, index) => part === 'Carthage' && parts[index + 1] === 'Build');
117
+ }
118
+ function compilePatterns(patterns = [], flag) {
119
+ return patterns.map((pattern) => {
120
+ try {
121
+ return new RegExp(pattern);
122
+ }
123
+ catch (error) {
124
+ throw new Error(`Invalid ${flag} regular expression ${pattern}: ${error instanceof Error ? error.message : error}`);
125
+ }
126
+ });
127
+ }
128
+ function matches(patterns, value) {
129
+ return patterns.some((pattern) => {
130
+ pattern.lastIndex = 0;
131
+ return pattern.test(value);
132
+ });
133
+ }
134
+ function additionalFile(value) {
135
+ const separator = value.indexOf('=');
136
+ if (separator < 1 || separator === value.length - 1) {
137
+ throw new Error(`Invalid --additional-file ${value}; expected localPath=remotePath`);
138
+ }
139
+ return { localPath: value.slice(0, separator), remotePath: value.slice(separator + 1) };
140
+ }
141
+ function remoteAdditionalPath(value) {
142
+ const normalized = value.replaceAll('\\', '/');
143
+ const home = normalized.startsWith('~/');
144
+ const path = home ? normalized.slice(2) : normalized.replace(/^\.\//, '');
145
+ if (!path || path.startsWith('/') || /^[A-Za-z]:\//.test(path) || path.split('/').some((part) => part === '..' || !part)) {
146
+ throw new Error(`Additional file destination must stay inside the source or ~/ directory: ${value}`);
147
+ }
148
+ if (!home && path.split('/')[0] === '.runcloud-home') {
149
+ throw new Error('.runcloud-home is reserved for additional files targeting ~/');
150
+ }
151
+ return { home, path };
152
+ }
153
+ export async function packSource(sourcePath, flags) {
154
+ const root = resolve(sourcePath);
155
+ const rootStat = await stat(root).catch(() => null);
156
+ if (!rootStat?.isDirectory())
157
+ throw new Error(`Xcode source path is not a directory: ${sourcePath}`);
158
+ const temporary = await mkdtemp(join(tmpdir(), 'runcloud-xcode-'));
159
+ const workspace = join(temporary, 'workspace');
160
+ const archivePath = join(temporary, 'source.tar.gz');
161
+ try {
162
+ const gitignore = ignore();
163
+ const gitignoreText = await readFile(join(root, '.gitignore'), 'utf8').catch(() => '');
164
+ if (gitignoreText)
165
+ gitignore.add(gitignoreText);
166
+ const ignored = compilePatterns(flags.ignore, '--ignore');
167
+ const included = compilePatterns(flags.include, '--include');
168
+ await cp(root, workspace, {
169
+ recursive: true,
170
+ dereference: false,
171
+ preserveTimestamps: true,
172
+ filter: async (source) => {
173
+ if (source === root)
174
+ return true;
175
+ const info = await lstat(source);
176
+ const relativePath = relative(root, source).split(sep).join('/');
177
+ const candidate = info.isDirectory() ? `${relativePath}/` : relativePath;
178
+ const forced = relativePath.endsWith('.xcconfig') || matches(included, candidate);
179
+ if (!forced && (defaultExcluded(candidate) || gitignore.ignores(candidate) || matches(ignored, candidate)))
180
+ return false;
181
+ if (info.isSymbolicLink())
182
+ throw new Error(`Xcode source archives do not follow symbolic links: ${relativePath}`);
183
+ return true;
184
+ },
185
+ });
186
+ for (const raw of flags.additionalFile ?? []) {
187
+ const entry = additionalFile(raw);
188
+ const localPath = resolve(entry.localPath);
189
+ const info = await stat(localPath).catch(() => null);
190
+ if (!info?.isFile())
191
+ throw new Error(`Additional file is not a regular file: ${entry.localPath}`);
192
+ const remote = remoteAdditionalPath(entry.remotePath);
193
+ const destinationRoot = resolve(workspace, remote.home ? '.runcloud-home' : '.');
194
+ const destination = resolve(destinationRoot, remote.path);
195
+ if (!destination.startsWith(`${destinationRoot}${sep}`))
196
+ throw new Error(`Unsafe additional file destination: ${entry.remotePath}`);
197
+ await mkdir(dirname(destination), { recursive: true });
198
+ await copyFile(localPath, destination);
199
+ }
200
+ await createTar({ cwd: workspace, file: archivePath, gzip: true, portable: true, noMtime: true }, ['.']);
201
+ const body = await readFile(archivePath);
202
+ if (body.length > MAX_SOURCE_ARCHIVE_BYTES)
203
+ throw new Error('Xcode source archive exceeds the 100 MiB compressed limit');
204
+ return {
205
+ body: new Uint8Array(body),
206
+ filename: `${basename(root) || 'source'}.tar.gz`,
207
+ sha256: createHash('sha256').update(body).digest('hex'),
208
+ };
209
+ }
210
+ finally {
211
+ await rm(temporary, { recursive: true, force: true });
212
+ }
213
+ }
214
+ export async function requestFor(flags) {
215
+ if (flags.workspace && flags.project)
216
+ throw new Error('Use either --workspace or --project, not both');
217
+ if (flags.upload && flags.signedUploadUrl)
218
+ throw new Error('Use either --upload or --signed-upload-url, not both');
219
+ if (flags.devServerUrl && flags.configuration === 'Release')
220
+ throw new Error('--dev-server-url is only supported for Debug builds');
221
+ if ((flags.xcodegenSpec || flags.xcodegenProject || flags.xcodegenProjectRoot) &&
222
+ (flags.devServerUrl || flags.expoAppDir)) {
223
+ throw new Error('XcodeGen options cannot be combined with Expo or React Native options');
224
+ }
225
+ const manualValues = [flags.certificateP12, flags.certificatePassword, ...(flags.provisioningProfile ?? [])];
226
+ const hasManual = manualValues.some(Boolean);
227
+ if (hasManual && (!flags.certificateP12 || !flags.certificatePassword || !(flags.provisioningProfile?.length))) {
228
+ throw new Error('--certificate-p12, --certificate-password, and at least one --provisioning-profile are required together');
229
+ }
230
+ const hasCloud = Boolean(flags.signingMethod || flags.teamId);
231
+ if (hasCloud && (!flags.signingMethod || !flags.teamId || !flags.ascKeyId || !flags.ascIssuerId || !flags.ascKey)) {
232
+ throw new Error('--signing-method requires --team-id, --asc-key-id, --asc-issuer-id, and --asc-key');
233
+ }
234
+ if (hasManual && hasCloud)
235
+ throw new Error('Manual certificate signing and --signing-method are mutually exclusive');
236
+ const hasAscCredentials = Boolean(flags.ascKeyId || flags.ascIssuerId || flags.ascKey);
237
+ if (!flags.uploadToAppstore && !hasCloud && hasAscCredentials) {
238
+ throw new Error('App Store Connect credential flags require --signing-method or --upload-to-appstore');
239
+ }
240
+ if (!flags.uploadToAppstore && (flags.ascWaitTimeout !== undefined || flags.autoBuildNumber)) {
241
+ throw new Error('--asc-wait-timeout and --auto-build-number require --upload-to-appstore');
242
+ }
243
+ if (flags.uploadToAppstore && (!flags.ascKeyId || !flags.ascKey || (!hasManual && !hasCloud))) {
244
+ throw new Error('--upload-to-appstore requires signing plus --asc-key-id and --asc-key');
245
+ }
246
+ let sdk = flags.sdk;
247
+ if ((hasManual || hasCloud) && !sdk)
248
+ sdk = 'iphoneos';
249
+ if (hasManual && sdk && !['iphoneos', 'watchos'].includes(sdk))
250
+ throw new Error('Manual signing requires --sdk iphoneos or --sdk watchos');
251
+ if (hasCloud && sdk !== 'iphoneos')
252
+ throw new Error('Cloud signing requires --sdk iphoneos');
253
+ if (flags.uploadToAppstore && sdk !== 'iphoneos')
254
+ throw new Error('--upload-to-appstore requires --sdk iphoneos');
255
+ const ascPrivateKey = flags.ascKey ? await base64File(flags.ascKey, '--asc-key') : undefined;
256
+ const waitTimeoutSeconds = integerFlag(flags.ascWaitTimeout, '--asc-wait-timeout', 0, 1800, 0);
257
+ const timeoutSeconds = integerFlag(flags.timeoutSeconds, '--timeout-seconds', 1, 21600, 3600);
258
+ const parsedBuildSettings = buildSettings(flags.buildSetting);
259
+ return {
260
+ ...(flags.orgId ? { orgId: flags.orgId } : {}),
261
+ ...(flags.workspace ? { workspace: flags.workspace } : {}),
262
+ ...(flags.project ? { project: flags.project } : {}),
263
+ ...(flags.scheme ? { scheme: flags.scheme } : {}),
264
+ ...(sdk ? { sdk } : {}),
265
+ ...(flags.configuration ? { configuration: flags.configuration } : {}),
266
+ ...((flags.xcodegenSpec || flags.xcodegenProject || flags.xcodegenProjectRoot)
267
+ ? { xcodegen: {
268
+ ...(flags.xcodegenSpec ? { spec: flags.xcodegenSpec } : {}),
269
+ ...(flags.xcodegenProject ? { project: flags.xcodegenProject } : {}),
270
+ ...(flags.xcodegenProjectRoot ? { projectRoot: flags.xcodegenProjectRoot } : {}),
271
+ } }
272
+ : {}),
273
+ ...((flags.devServerUrl || flags.expoAppDir)
274
+ ? { reactNative: {
275
+ ...(flags.devServerUrl ? { devServerUrl: flags.devServerUrl } : {}),
276
+ ...(flags.expoAppDir ? { expoAppDir: flags.expoAppDir } : {}),
277
+ } }
278
+ : {}),
279
+ ...(parsedBuildSettings ? { buildSettings: parsedBuildSettings } : {}),
280
+ ...(flags.gitInit ? { gitInit: true } : {}),
281
+ ...(hasManual
282
+ ? { signing: {
283
+ certificateP12Base64: await base64File(flags.certificateP12, '--certificate-p12'),
284
+ certificatePassword: flags.certificatePassword,
285
+ provisioningProfilesBase64: await Promise.all(flags.provisioningProfile.map((file) => base64File(file, '--provisioning-profile'))),
286
+ } }
287
+ : {}),
288
+ ...(hasCloud
289
+ ? { cloudSigning: {
290
+ method: flags.signingMethod,
291
+ teamId: flags.teamId,
292
+ apiKeyId: flags.ascKeyId,
293
+ apiIssuerId: flags.ascIssuerId,
294
+ apiPrivateKeyBase64: ascPrivateKey,
295
+ } }
296
+ : {}),
297
+ ...(flags.uploadToAppstore
298
+ ? { appStore: {
299
+ enabled: true,
300
+ apiKeyId: flags.ascKeyId,
301
+ ...(flags.ascIssuerId ? { apiIssuerId: flags.ascIssuerId } : {}),
302
+ apiPrivateKeyBase64: ascPrivateKey,
303
+ waitTimeoutSeconds,
304
+ autoIncrementBuildNumber: flags.autoBuildNumber === true,
305
+ } }
306
+ : {}),
307
+ ...(flags.upload ? { upload: { assetName: flags.upload } } : {}),
308
+ ...(flags.signedUploadUrl ? { upload: { signedUploadUrl: flags.signedUploadUrl } } : {}),
309
+ timeoutSeconds,
310
+ };
311
+ }
312
+ async function logChunk(client, id, offset, source) {
313
+ return await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}/logs?offset=${offset}${source ? `&source=${source}` : ''}`));
314
+ }
315
+ async function waitForBuild(client, id, flags) {
316
+ const timeout = integerFlag(flags.waitTimeout, '--wait-timeout', 1, 23400, 22500);
317
+ const deadline = Date.now() + timeout * 1000;
318
+ let offset = 0;
319
+ let source;
320
+ for (;;) {
321
+ const build = await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`));
322
+ if (build.logs.available || build.status === 'queued' || build.status === 'running') {
323
+ const chunk = await logChunk(client, id, offset, source);
324
+ if (chunk.text)
325
+ (flags.json ? process.stderr : process.stdout).write(chunk.text);
326
+ offset = chunk.nextOffset;
327
+ source = chunk.source ?? source;
328
+ if (TERMINAL.has(build.status) && chunk.complete)
329
+ return build;
330
+ }
331
+ else if (TERMINAL.has(build.status)) {
332
+ return build;
333
+ }
334
+ if (Date.now() >= deadline) {
335
+ throw new Error(`Timed out waiting for Xcode build ${id}; it may still be running. Check it with runcloud xcode get ${id}.`);
336
+ }
337
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
338
+ }
339
+ }
340
+ async function downloadArtifact(client, build, output, force = false) {
341
+ if (!build.artifact.available)
342
+ throw new Error(`Artifact for Xcode build ${build.id} is not available`);
343
+ const target = resolve(output ?? build.artifact.filename);
344
+ if (existsSync(target) && !force)
345
+ throw new Error(`${target} already exists. Pass --force to overwrite it.`);
346
+ const bytes = await retryXcodeRequest(async () => await client.getBinary(`/run-cloud/xcode/builds/${encodeURIComponent(build.id)}/artifact`));
347
+ await mkdir(dirname(target), { recursive: true });
348
+ await writeFile(target, bytes);
349
+ return target;
350
+ }
351
+ export function registerXcode(program) {
352
+ const xcode = program.command('xcode').description('Build Xcode projects on the run.cloud macOS fleet');
353
+ xcode
354
+ .command('build')
355
+ .description('Upload a local source tree, stream xcodebuild logs, and download its artifact')
356
+ .argument('[path]', 'local source directory', '.')
357
+ .option('--scheme <name>', 'Xcode scheme')
358
+ .option('--workspace <path>', 'relative .xcworkspace path')
359
+ .option('--project <path>', 'relative .xcodeproj path')
360
+ .option('--sdk <sdk>', 'iphonesimulator, iphoneos, watchsimulator, or watchos')
361
+ .option('--configuration <name>', 'Debug or Release')
362
+ .option('--xcodegen-spec <path>', 'relative XcodeGen spec path')
363
+ .option('--xcodegen-project <path>', 'relative XcodeGen output directory')
364
+ .option('--xcodegen-project-root <path>', 'relative XcodeGen project root')
365
+ .option('--git-init', 'initialize Git before project generation', false)
366
+ .option('--dev-server-url <url>', 'Debug React Native or Expo development-server URL')
367
+ .option('--expo-app-dir <path>', 'relative Expo app directory in a monorepo')
368
+ .option('--build-setting <KEY=VALUE>', 'safe Xcode build setting; repeatable', repeated, [])
369
+ .option('--certificate-p12 <path>', 'PKCS#12 signing certificate')
370
+ .option('--certificate-password <password>', 'PKCS#12 certificate password')
371
+ .option('--provisioning-profile <path>', 'provisioning profile; repeatable', repeated, [])
372
+ .option('--signing-method <method>', 'app-store-connect, release-testing, or debugging')
373
+ .option('--team-id <id>', 'Apple Developer team ID for cloud signing')
374
+ .option('--upload <filename>', 'public filename for the retained run.cloud artifact')
375
+ .option('--signed-upload-url <url>', 'also PUT the artifact to this presigned HTTPS URL')
376
+ .option('--upload-to-appstore', 'upload the signed IPA to App Store Connect', false)
377
+ .option('--asc-key-id <id>', 'App Store Connect API key ID')
378
+ .option('--asc-issuer-id <id>', 'App Store Connect API issuer ID')
379
+ .option('--asc-key <path>', 'App Store Connect API private key (.p8)')
380
+ .option('--asc-wait-timeout <seconds>', 'upload acceptance compatibility value, 0 to 1800')
381
+ .option('--auto-build-number', 'increment the Xcode project build number before upload', false)
382
+ .option('--ignore <regex>', 'exclude source paths matching a regular expression; repeatable', repeated, [])
383
+ .option('--include <regex>', 'force-include matching source paths; repeatable', repeated, [])
384
+ .option('--additional-file <local=remote>', 'include an extra file; repeatable', repeated, [])
385
+ .option('--org-id <id>', 'organization to own the build')
386
+ .option('--idempotency-key <key>', 'stable key for retry-safe submission')
387
+ .option('--timeout-seconds <seconds>', 'remote xcodebuild timeout, max 21600', '3600')
388
+ .option('--wait-timeout <seconds>', 'client wait timeout, max 23400', '22500')
389
+ .option('--no-wait', 'return after the control plane accepts the build')
390
+ .option('--detach', 'alias for --no-wait; terminal state remains queryable', false)
391
+ .option('-o, --output <path>', 'download the artifact here after a successful build')
392
+ .option('--force', 'overwrite --output if it exists', false)
393
+ .option('--json', 'print the build record as JSON; streamed logs go to stderr', false)
394
+ .action((sourcePath, flags) => action(async () => {
395
+ const request = await requestFor(flags);
396
+ const source = await packSource(sourcePath, flags);
397
+ const archiveBuffer = new ArrayBuffer(source.body.byteLength);
398
+ new Uint8Array(archiveBuffer).set(source.body);
399
+ const form = new FormData();
400
+ form.append('request', JSON.stringify(request));
401
+ form.append('source', new Blob([archiveBuffer], { type: 'application/gzip' }), source.filename);
402
+ const client = api();
403
+ const idempotencyKey = flags.idempotencyKey ?? `xcode_${randomUUID()}`;
404
+ let build = await retryXcodeRequest(async () => await client.uploadForm('/run-cloud/xcode/builds', form, { 'Idempotency-Key': idempotencyKey }));
405
+ if (flags.detach || flags.wait === false) {
406
+ print(build, flags.json);
407
+ return;
408
+ }
409
+ if (!flags.json)
410
+ console.error(`Build ${build.id} accepted; streaming remote logs.`);
411
+ build = await waitForBuild(client, build.id, flags);
412
+ let artifactPath;
413
+ if (flags.output && build.artifact.available)
414
+ artifactPath = await downloadArtifact(client, build, flags.output, flags.force);
415
+ if (flags.json) {
416
+ print({ ...build, ...(artifactPath ? { artifactPath } : {}) }, true);
417
+ }
418
+ else if (build.status === 'succeeded') {
419
+ console.log(`Build ${build.id} succeeded (exit code ${build.result?.exitCode ?? 0}).`);
420
+ console.log(artifactPath
421
+ ? `Artifact: ${artifactPath}`
422
+ : `Download: runcloud xcode artifact ${build.id}`);
423
+ }
424
+ if (build.status !== 'succeeded') {
425
+ const reason = build.failure?.reason ?? `Remote build ended with ${build.status}`;
426
+ const next = build.failure?.action ?? `Inspect logs with runcloud xcode logs ${build.id}`;
427
+ throw new Error(`${reason}\n${next}\nBuild ID: ${build.id}`);
428
+ }
429
+ }));
430
+ xcode
431
+ .command('rebundle')
432
+ .description('Reuse a compatible React Native native shell and replace only its JavaScript bundle')
433
+ .argument('[path]', 'local Expo or React Native source directory', '.')
434
+ .requiredOption('--basis-build <id>', 'successful simulator build providing the native shell')
435
+ .option('--expo-app-dir <path>', 'relative Expo app directory in a monorepo')
436
+ .option('--ignore <regex>', 'exclude source paths matching a regular expression; repeatable', repeated, [])
437
+ .option('--include <regex>', 'force-include matching source paths; repeatable', repeated, [])
438
+ .option('--additional-file <local=remote>', 'include an extra file; repeatable', repeated, [])
439
+ .option('--org-id <id>', 'organization to own the build')
440
+ .option('--idempotency-key <key>', 'stable key for retry-safe submission')
441
+ .option('--timeout-seconds <seconds>', 'remote bundle timeout, max 21600', '3600')
442
+ .option('--wait-timeout <seconds>', 'client wait timeout, max 23400', '22500')
443
+ .option('--no-wait', 'return after the control plane accepts the build')
444
+ .option('--detach', 'alias for --no-wait; terminal state remains queryable', false)
445
+ .option('-o, --output <path>', 'download the rebundled app here after success')
446
+ .option('--force', 'overwrite --output if it exists', false)
447
+ .option('--json', 'print the build record as JSON; streamed logs go to stderr', false)
448
+ .action((sourcePath, flags) => action(async () => {
449
+ if (!/^xcb_[A-Za-z0-9]+$/.test(flags.basisBuild)) {
450
+ throw new Error('--basis-build must be a run.cloud Xcode build ID');
451
+ }
452
+ const request = await requestFor({
453
+ ...flags,
454
+ sdk: 'iphonesimulator',
455
+ configuration: 'Release',
456
+ });
457
+ request.reactNative = { ...(flags.expoAppDir ? { expoAppDir: flags.expoAppDir } : {}) };
458
+ request.reuse = { basisBuildId: flags.basisBuild };
459
+ const source = await packSource(sourcePath, flags);
460
+ const archiveBuffer = new ArrayBuffer(source.body.byteLength);
461
+ new Uint8Array(archiveBuffer).set(source.body);
462
+ const form = new FormData();
463
+ form.append('request', JSON.stringify(request));
464
+ form.append('source', new Blob([archiveBuffer], { type: 'application/gzip' }), source.filename);
465
+ const client = api();
466
+ const idempotencyKey = flags.idempotencyKey ?? `xcode_rebundle_${randomUUID()}`;
467
+ let build = await retryXcodeRequest(async () => await client.uploadForm('/run-cloud/xcode/builds', form, { 'Idempotency-Key': idempotencyKey }));
468
+ if (flags.detach || flags.wait === false) {
469
+ print(build, flags.json);
470
+ return;
471
+ }
472
+ if (!flags.json)
473
+ console.error(`Rebundle ${build.id} accepted; streaming remote logs.`);
474
+ build = await waitForBuild(client, build.id, flags);
475
+ const artifactPath = flags.output && build.artifact.available
476
+ ? await downloadArtifact(client, build, flags.output, flags.force)
477
+ : undefined;
478
+ if (flags.json)
479
+ print({ ...build, ...(artifactPath ? { artifactPath } : {}) }, true);
480
+ else if (build.status === 'succeeded') {
481
+ console.log(`Rebundle ${build.id} succeeded.`);
482
+ console.log(artifactPath ? `Artifact: ${artifactPath}` : `Asset: ${build.artifact.assetId}`);
483
+ }
484
+ if (build.status !== 'succeeded') {
485
+ throw new Error(`${build.failure?.reason ?? `Rebundle ended with ${build.status}`}\n${build.failure?.action ?? `Inspect logs with runcloud xcode logs ${build.id}`}\nBuild ID: ${build.id}`);
486
+ }
487
+ }));
488
+ xcode
489
+ .command('get')
490
+ .description('Show product-visible Xcode build state')
491
+ .argument('<id>')
492
+ .option('--json', 'print JSON', false)
493
+ .action((id, flags) => action(async () => {
494
+ print(await retryXcodeRequest(async () => await api().get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`)), flags.json);
495
+ }));
496
+ xcode
497
+ .command('list')
498
+ .description('List Xcode builds in an organization')
499
+ .option('--org-id <id>', 'organization ID')
500
+ .option('--limit <count>', 'maximum builds, 1 to 100', '50')
501
+ .option('--json', 'print JSON', false)
502
+ .action((flags) => action(async () => {
503
+ const limit = integerFlag(flags.limit, '--limit', 1, 100, 50);
504
+ const query = new URLSearchParams({ limit: String(limit) });
505
+ if (flags.orgId)
506
+ query.set('orgId', flags.orgId);
507
+ const result = await retryXcodeRequest(async () => await api().get(`/run-cloud/xcode/builds?${query}`));
508
+ print(flags.json ? result : result.items, flags.json);
509
+ }));
510
+ xcode
511
+ .command('events')
512
+ .description('Read ordered replayable Xcode build events after a durable cursor')
513
+ .option('--org-id <id>', 'organization ID')
514
+ .option('--build-id <id>', 'filter to one build')
515
+ .option('--after <cursor>', 'last processed event sequence', '0')
516
+ .option('--limit <count>', 'maximum events, 1 to 500', '100')
517
+ .option('--json', 'print the page, including nextCursor, as JSON', false)
518
+ .action((flags) => action(async () => {
519
+ const after = integerFlag(flags.after, '--after', 0, Number.MAX_SAFE_INTEGER, 0);
520
+ const limit = integerFlag(flags.limit, '--limit', 1, 500, 100);
521
+ const query = new URLSearchParams({ after: String(after), limit: String(limit) });
522
+ if (flags.orgId)
523
+ query.set('orgId', flags.orgId);
524
+ if (flags.buildId)
525
+ query.set('buildId', flags.buildId);
526
+ const page = await retryXcodeRequest(async () => await api().get(`/run-cloud/xcode/events?${query}`));
527
+ if (flags.json)
528
+ print(page, true);
529
+ else {
530
+ print(page.items);
531
+ console.error(`nextCursor: ${page.nextCursor}`);
532
+ }
533
+ }));
534
+ xcode
535
+ .command('logs')
536
+ .description('Read the sanitized live or retained Xcode build log')
537
+ .argument('<id>')
538
+ .option('--follow', 'wait for terminal state', false)
539
+ .option('--json', 'emit one JSON object per chunk', false)
540
+ .action((id, flags) => action(async () => {
541
+ const client = api();
542
+ let offset = 0;
543
+ let source;
544
+ for (;;) {
545
+ const chunk = await logChunk(client, id, offset, source);
546
+ if (flags.json)
547
+ console.log(JSON.stringify(chunk));
548
+ else if (chunk.text)
549
+ process.stdout.write(chunk.text);
550
+ offset = chunk.nextOffset;
551
+ source = chunk.source ?? source;
552
+ if (!flags.follow || chunk.complete)
553
+ return;
554
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
555
+ }
556
+ }));
557
+ xcode
558
+ .command('artifact')
559
+ .description('Download an Xcode build artifact through the authenticated control plane')
560
+ .argument('<id>')
561
+ .option('-o, --output <path>', 'destination path')
562
+ .option('--force', 'overwrite an existing destination', false)
563
+ .action((id, flags) => action(async () => {
564
+ const client = api();
565
+ const build = await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`));
566
+ console.log(await downloadArtifact(client, build, flags.output, flags.force));
567
+ }));
568
+ xcode
569
+ .command('cancel')
570
+ .description('Cancel a queued or running Xcode build')
571
+ .argument('<id>')
572
+ .option('--json', 'print JSON', false)
573
+ .action((id, flags) => action(async () => {
574
+ print(await retryXcodeRequest(async () => await api().post(`/run-cloud/xcode/builds/${encodeURIComponent(id)}/cancel`, {})), flags.json);
575
+ }));
576
+ }
package/dist/run-cloud.js CHANGED
@@ -6,6 +6,7 @@ import { registerRunCloud } from './commands/run-cloud.js';
6
6
  import { registerSandbox } from './commands/sandbox.js';
7
7
  import { registerSecrets } from './commands/secrets.js';
8
8
  import { registerToken } from './commands/token.js';
9
+ import { registerXcode } from './commands/xcode.js';
9
10
  import { CLI_VERSION } from './version.js';
10
11
  const program = new Command('runcloud')
11
12
  .description('Create and control remote mobile simulators and microVM sandboxes')
@@ -15,5 +16,6 @@ registerRunCloud(program);
15
16
  registerSandbox(program);
16
17
  registerSecrets(program);
17
18
  registerToken(program);
19
+ registerXcode(program);
18
20
  registerImage(program);
19
21
  await program.parseAsync(process.argv);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.122';
1
+ export const CLI_VERSION = '0.1.124';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.122",
3
+ "version": "0.1.124",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -45,6 +45,8 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "commander": "^12.1.0",
48
+ "ignore": "^7.0.5",
49
+ "tar": "^7.4.3",
48
50
  "ws": "^8.21.0"
49
51
  }
50
52
  }
Binary file
Binary file