runcloud 0.1.121 → 0.1.123
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 +2 -1
- package/dist/commands/run-cloud.js +3 -1
- package/dist/commands/sandbox.js +36 -6
- package/dist/commands/sandboxOutput.js +20 -0
- package/dist/commands/xcode.js +490 -0
- package/dist/run-cloud.js +2 -0
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/runcloud-0.1.123.tgz +0 -0
- package/skills/run-cloud-sandboxes/SKILL.md +15 -3
- package/runcloud-0.1.121.tgz +0 -0
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
|
});
|
|
@@ -1305,13 +1305,15 @@ async function action(fn, opts = {}) {
|
|
|
1305
1305
|
}
|
|
1306
1306
|
function configureMachineReadableParseErrors(program) {
|
|
1307
1307
|
const output = program.configureOutput();
|
|
1308
|
+
const writeErrorLine = output.outputError ??
|
|
1309
|
+
((value, destination) => destination(value));
|
|
1308
1310
|
program.configureOutput({
|
|
1309
1311
|
...output,
|
|
1310
1312
|
outputError: (message, write) => {
|
|
1311
1313
|
const rawArgs = program.rawArgs ?? [];
|
|
1312
1314
|
const argumentsSeen = [...process.argv, ...rawArgs, ...program.args];
|
|
1313
1315
|
if (!argumentsSeen.includes('--json')) {
|
|
1314
|
-
(
|
|
1316
|
+
writeErrorLine(message, write);
|
|
1315
1317
|
return;
|
|
1316
1318
|
}
|
|
1317
1319
|
write(`${JSON.stringify({
|
package/dist/commands/sandbox.js
CHANGED
|
@@ -8,7 +8,7 @@ import { runSandboxCommand, shellCommand } from '../sandboxExec.js';
|
|
|
8
8
|
import { runSandboxShell } from '../shellSession.js';
|
|
9
9
|
import { printJson, StatusLine, style } from '../terminal.js';
|
|
10
10
|
import { noteDeprecated, registerAccessCommands } from './boxAccess.js';
|
|
11
|
-
import { renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
11
|
+
import { renderHeldSecrets, renderSandbox, renderSandboxList, } from './sandboxOutput.js';
|
|
12
12
|
const MAX_SANDBOX_DISK_GIB = 2048;
|
|
13
13
|
const DEFAULT_SANDBOX_TIMEOUT_SECONDS = 300;
|
|
14
14
|
const DEFAULT_BOX_CPU_CORES = 2;
|
|
@@ -260,6 +260,15 @@ function secretSelector(opts) {
|
|
|
260
260
|
out.env = env;
|
|
261
261
|
return out;
|
|
262
262
|
}
|
|
263
|
+
async function replaceSandboxSecrets(id, opts) {
|
|
264
|
+
const body = secretSelector(opts);
|
|
265
|
+
await runCloudApi().put(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`, body);
|
|
266
|
+
if (opts.json) {
|
|
267
|
+
printJson({ id, revoked: opts.noSecrets === true, ...body });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
console.log(opts.noSecrets ? `${id}: all secrets revoked` : `${id}: secrets replaced`);
|
|
271
|
+
}
|
|
263
272
|
function collectRepeatable(value, previous) {
|
|
264
273
|
return [...previous, value];
|
|
265
274
|
}
|
|
@@ -472,17 +481,38 @@ export function registerSandbox(program) {
|
|
|
472
481
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, opts.interval * 1_000));
|
|
473
482
|
}
|
|
474
483
|
}));
|
|
475
|
-
withOutput(sandbox
|
|
484
|
+
const secrets = withOutput(sandbox
|
|
476
485
|
.command('secrets')
|
|
477
|
-
.description('
|
|
486
|
+
.description('Show what secrets a sandbox holds (names and origins, never values)')
|
|
487
|
+
.argument('[id]', 'sandbox id'))
|
|
488
|
+
.showHelpAfterError("hint: `runcloud sandbox secrets set <id> …` changes what a sandbox holds; the bare form only reads it");
|
|
489
|
+
const wantsJson = (opts) => Boolean(secrets.opts().json) || Boolean(opts.json);
|
|
490
|
+
secrets.action((id, opts) => run(async () => {
|
|
491
|
+
if (!id)
|
|
492
|
+
throw new Error('which sandbox? usage: runcloud sandbox secrets <id>');
|
|
493
|
+
const { held } = (await runCloudApi().get(`/run-cloud/sandboxes/${encodeURIComponent(id)}/secrets`));
|
|
494
|
+
if (opts.json) {
|
|
495
|
+
printJson({ held });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
console.log(renderHeldSecrets(id, held));
|
|
499
|
+
}));
|
|
500
|
+
withOutput(secrets
|
|
501
|
+
.command('set')
|
|
502
|
+
.description('Replace what a sandbox holds (a full replacement, not a merge)')
|
|
478
503
|
.argument('<id>', 'sandbox id')
|
|
479
504
|
.option('--secret-group <group>', 'attach a secret group; repeatable and ORDER MATTERS (a later group wins on a name collision)', collectRepeatable, [])
|
|
480
505
|
.option('--secret <group/name>', 'attach one secret out of a group; repeatable, same ordering', collectRepeatable, [])
|
|
481
506
|
.option('--env <NAME=VALUE>', 'literal value; applied last, so it wins; repeatable', collectRepeatable, [])).action((id, opts) => run(async () => {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
507
|
+
if (!opts.secretGroup.length && !opts.secret.length && !opts.env.length) {
|
|
508
|
+
throw new Error('name what the sandbox should hold with --secret-group/--secret/--env, or run `runcloud sandbox secrets revoke <id>` to take everything back');
|
|
509
|
+
}
|
|
510
|
+
await replaceSandboxSecrets(id, { ...opts, json: wantsJson(opts) });
|
|
485
511
|
}));
|
|
512
|
+
withOutput(secrets
|
|
513
|
+
.command('revoke')
|
|
514
|
+
.description('Take every secret back from a sandbox')
|
|
515
|
+
.argument('<id>', 'sandbox id')).action((id, opts) => run(() => replaceSandboxSecrets(id, { noSecrets: true, json: wantsJson(opts) })));
|
|
486
516
|
withOutput(sandbox
|
|
487
517
|
.command('expose')
|
|
488
518
|
.description('Publish a sandbox guest port at a stable hostname, or change the published port')
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { formatAge, formatCpu, formatMib, formatSeconds, formatState, renderDetails, renderTable, shortDigest, style, } from '../terminal.js';
|
|
2
|
+
export function renderHeldSecrets(id, held) {
|
|
3
|
+
const heading = `Secrets held by ${id}`;
|
|
4
|
+
if (held === null) {
|
|
5
|
+
return `${style.bold(heading)}\n\nUnknown — nothing was recorded for this sandbox, so what it\nholds cannot be answered from here.\n\n${style.dim('Check the environment inside it, or run `runcloud sandbox secrets set`,')}\n${style.dim('which records what it pushes.')}`;
|
|
6
|
+
}
|
|
7
|
+
if (held.length === 0) {
|
|
8
|
+
return `${style.bold(heading)}\n\nNothing. This sandbox holds no secrets.\n\n${style.dim('Attach some:')} runcloud sandbox secrets set ${id} --secret-group <group>`;
|
|
9
|
+
}
|
|
10
|
+
return renderTable(`${heading} (${held.length})`, [
|
|
11
|
+
{ key: 'name', label: 'NAME', maxWidth: 32 },
|
|
12
|
+
{ key: 'kind', label: 'KIND' },
|
|
13
|
+
{ key: 'source', label: 'FROM', maxWidth: 28 },
|
|
14
|
+
{ key: 'digest', label: 'DIGEST' },
|
|
15
|
+
], held.map((secret) => ({
|
|
16
|
+
name: secret.name,
|
|
17
|
+
kind: secret.kind,
|
|
18
|
+
source: secret.source,
|
|
19
|
+
digest: secret.digest,
|
|
20
|
+
})));
|
|
21
|
+
}
|
|
2
22
|
function resources(record) {
|
|
3
23
|
const memory = formatMib(record.mem_mb);
|
|
4
24
|
const disk = formatMib(record.disk_mb);
|
|
@@ -0,0 +1,490 @@
|
|
|
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) {
|
|
313
|
+
return await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}/logs?offset=${offset}`));
|
|
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
|
+
for (;;) {
|
|
320
|
+
const build = await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`));
|
|
321
|
+
if (build.logs.available || build.status === 'queued' || build.status === 'running') {
|
|
322
|
+
const chunk = await logChunk(client, id, offset);
|
|
323
|
+
if (chunk.text)
|
|
324
|
+
(flags.json ? process.stderr : process.stdout).write(chunk.text);
|
|
325
|
+
offset = chunk.nextOffset;
|
|
326
|
+
if (TERMINAL.has(build.status) && chunk.complete)
|
|
327
|
+
return build;
|
|
328
|
+
}
|
|
329
|
+
else if (TERMINAL.has(build.status)) {
|
|
330
|
+
return build;
|
|
331
|
+
}
|
|
332
|
+
if (Date.now() >= deadline) {
|
|
333
|
+
throw new Error(`Timed out waiting for Xcode build ${id}; it may still be running. Check it with runcloud xcode get ${id}.`);
|
|
334
|
+
}
|
|
335
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function downloadArtifact(client, build, output, force = false) {
|
|
339
|
+
if (!build.artifact.available)
|
|
340
|
+
throw new Error(`Artifact for Xcode build ${build.id} is not available`);
|
|
341
|
+
const target = resolve(output ?? build.artifact.filename);
|
|
342
|
+
if (existsSync(target) && !force)
|
|
343
|
+
throw new Error(`${target} already exists. Pass --force to overwrite it.`);
|
|
344
|
+
const bytes = await retryXcodeRequest(async () => await client.getBinary(`/run-cloud/xcode/builds/${encodeURIComponent(build.id)}/artifact`));
|
|
345
|
+
await mkdir(dirname(target), { recursive: true });
|
|
346
|
+
await writeFile(target, bytes);
|
|
347
|
+
return target;
|
|
348
|
+
}
|
|
349
|
+
export function registerXcode(program) {
|
|
350
|
+
const xcode = program.command('xcode').description('Build Xcode projects on the run.cloud macOS fleet');
|
|
351
|
+
xcode
|
|
352
|
+
.command('build')
|
|
353
|
+
.description('Upload a local source tree, stream xcodebuild logs, and download its artifact')
|
|
354
|
+
.argument('[path]', 'local source directory', '.')
|
|
355
|
+
.option('--scheme <name>', 'Xcode scheme')
|
|
356
|
+
.option('--workspace <path>', 'relative .xcworkspace path')
|
|
357
|
+
.option('--project <path>', 'relative .xcodeproj path')
|
|
358
|
+
.option('--sdk <sdk>', 'iphonesimulator, iphoneos, watchsimulator, or watchos')
|
|
359
|
+
.option('--configuration <name>', 'Debug or Release')
|
|
360
|
+
.option('--xcodegen-spec <path>', 'relative XcodeGen spec path')
|
|
361
|
+
.option('--xcodegen-project <path>', 'relative XcodeGen output directory')
|
|
362
|
+
.option('--xcodegen-project-root <path>', 'relative XcodeGen project root')
|
|
363
|
+
.option('--git-init', 'initialize Git before project generation', false)
|
|
364
|
+
.option('--dev-server-url <url>', 'Debug React Native or Expo development-server URL')
|
|
365
|
+
.option('--expo-app-dir <path>', 'relative Expo app directory in a monorepo')
|
|
366
|
+
.option('--build-setting <KEY=VALUE>', 'safe Xcode build setting; repeatable', repeated, [])
|
|
367
|
+
.option('--certificate-p12 <path>', 'PKCS#12 signing certificate')
|
|
368
|
+
.option('--certificate-password <password>', 'PKCS#12 certificate password')
|
|
369
|
+
.option('--provisioning-profile <path>', 'provisioning profile; repeatable', repeated, [])
|
|
370
|
+
.option('--signing-method <method>', 'app-store-connect, release-testing, or debugging')
|
|
371
|
+
.option('--team-id <id>', 'Apple Developer team ID for cloud signing')
|
|
372
|
+
.option('--upload <filename>', 'filename for the retained run.cloud artifact')
|
|
373
|
+
.option('--signed-upload-url <url>', 'also PUT the artifact to this presigned HTTPS URL')
|
|
374
|
+
.option('--upload-to-appstore', 'upload the signed IPA to App Store Connect', false)
|
|
375
|
+
.option('--asc-key-id <id>', 'App Store Connect API key ID')
|
|
376
|
+
.option('--asc-issuer-id <id>', 'App Store Connect API issuer ID')
|
|
377
|
+
.option('--asc-key <path>', 'App Store Connect API private key (.p8)')
|
|
378
|
+
.option('--asc-wait-timeout <seconds>', 'upload acceptance compatibility value, 0 to 1800')
|
|
379
|
+
.option('--auto-build-number', 'increment the Xcode project build number before upload', false)
|
|
380
|
+
.option('--ignore <regex>', 'exclude source paths matching a regular expression; repeatable', repeated, [])
|
|
381
|
+
.option('--include <regex>', 'force-include matching source paths; repeatable', repeated, [])
|
|
382
|
+
.option('--additional-file <local=remote>', 'include an extra file; repeatable', repeated, [])
|
|
383
|
+
.option('--org-id <id>', 'organization to own the build')
|
|
384
|
+
.option('--idempotency-key <key>', 'stable key for retry-safe submission')
|
|
385
|
+
.option('--timeout-seconds <seconds>', 'remote xcodebuild timeout, max 21600', '3600')
|
|
386
|
+
.option('--wait-timeout <seconds>', 'client wait timeout, max 23400', '22500')
|
|
387
|
+
.option('--no-wait', 'return after the control plane accepts the build')
|
|
388
|
+
.option('--detach', 'alias for --no-wait; terminal state remains queryable', false)
|
|
389
|
+
.option('-o, --output <path>', 'download the artifact here after a successful build')
|
|
390
|
+
.option('--force', 'overwrite --output if it exists', false)
|
|
391
|
+
.option('--json', 'print the build record as JSON; streamed logs go to stderr', false)
|
|
392
|
+
.action((sourcePath, flags) => action(async () => {
|
|
393
|
+
const request = await requestFor(flags);
|
|
394
|
+
const source = await packSource(sourcePath, flags);
|
|
395
|
+
const archiveBuffer = new ArrayBuffer(source.body.byteLength);
|
|
396
|
+
new Uint8Array(archiveBuffer).set(source.body);
|
|
397
|
+
const form = new FormData();
|
|
398
|
+
form.append('request', JSON.stringify(request));
|
|
399
|
+
form.append('source', new Blob([archiveBuffer], { type: 'application/gzip' }), source.filename);
|
|
400
|
+
const client = api();
|
|
401
|
+
const idempotencyKey = flags.idempotencyKey ?? `xcode_${randomUUID()}`;
|
|
402
|
+
let build = await retryXcodeRequest(async () => await client.uploadForm('/run-cloud/xcode/builds', form, { 'Idempotency-Key': idempotencyKey }));
|
|
403
|
+
if (flags.detach || flags.wait === false) {
|
|
404
|
+
print(build, flags.json);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (!flags.json)
|
|
408
|
+
console.error(`Build ${build.id} accepted; streaming remote logs.`);
|
|
409
|
+
build = await waitForBuild(client, build.id, flags);
|
|
410
|
+
let artifactPath;
|
|
411
|
+
if (flags.output && build.artifact.available)
|
|
412
|
+
artifactPath = await downloadArtifact(client, build, flags.output, flags.force);
|
|
413
|
+
if (flags.json) {
|
|
414
|
+
print({ ...build, ...(artifactPath ? { artifactPath } : {}) }, true);
|
|
415
|
+
}
|
|
416
|
+
else if (build.status === 'succeeded') {
|
|
417
|
+
console.log(`Build ${build.id} succeeded (exit code ${build.result?.exitCode ?? 0}).`);
|
|
418
|
+
console.log(artifactPath
|
|
419
|
+
? `Artifact: ${artifactPath}`
|
|
420
|
+
: `Download: runcloud xcode artifact ${build.id}`);
|
|
421
|
+
}
|
|
422
|
+
if (build.status !== 'succeeded') {
|
|
423
|
+
const reason = build.failure?.reason ?? `Remote build ended with ${build.status}`;
|
|
424
|
+
const next = build.failure?.action ?? `Inspect logs with runcloud xcode logs ${build.id}`;
|
|
425
|
+
throw new Error(`${reason}\n${next}\nBuild ID: ${build.id}`);
|
|
426
|
+
}
|
|
427
|
+
}));
|
|
428
|
+
xcode
|
|
429
|
+
.command('get')
|
|
430
|
+
.description('Show product-visible Xcode build state')
|
|
431
|
+
.argument('<id>')
|
|
432
|
+
.option('--json', 'print JSON', false)
|
|
433
|
+
.action((id, flags) => action(async () => {
|
|
434
|
+
print(await retryXcodeRequest(async () => await api().get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`)), flags.json);
|
|
435
|
+
}));
|
|
436
|
+
xcode
|
|
437
|
+
.command('list')
|
|
438
|
+
.description('List Xcode builds in an organization')
|
|
439
|
+
.option('--org-id <id>', 'organization ID')
|
|
440
|
+
.option('--limit <count>', 'maximum builds, 1 to 100', '50')
|
|
441
|
+
.option('--json', 'print JSON', false)
|
|
442
|
+
.action((flags) => action(async () => {
|
|
443
|
+
const limit = integerFlag(flags.limit, '--limit', 1, 100, 50);
|
|
444
|
+
const query = new URLSearchParams({ limit: String(limit) });
|
|
445
|
+
if (flags.orgId)
|
|
446
|
+
query.set('orgId', flags.orgId);
|
|
447
|
+
const result = await retryXcodeRequest(async () => await api().get(`/run-cloud/xcode/builds?${query}`));
|
|
448
|
+
print(flags.json ? result : result.items, flags.json);
|
|
449
|
+
}));
|
|
450
|
+
xcode
|
|
451
|
+
.command('logs')
|
|
452
|
+
.description('Read the sanitized live or retained Xcode build log')
|
|
453
|
+
.argument('<id>')
|
|
454
|
+
.option('--follow', 'wait for terminal state', false)
|
|
455
|
+
.option('--json', 'emit one JSON object per chunk', false)
|
|
456
|
+
.action((id, flags) => action(async () => {
|
|
457
|
+
const client = api();
|
|
458
|
+
let offset = 0;
|
|
459
|
+
for (;;) {
|
|
460
|
+
const chunk = await logChunk(client, id, offset);
|
|
461
|
+
if (flags.json)
|
|
462
|
+
console.log(JSON.stringify(chunk));
|
|
463
|
+
else if (chunk.text)
|
|
464
|
+
process.stdout.write(chunk.text);
|
|
465
|
+
offset = chunk.nextOffset;
|
|
466
|
+
if (!flags.follow || chunk.complete)
|
|
467
|
+
return;
|
|
468
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
|
|
469
|
+
}
|
|
470
|
+
}));
|
|
471
|
+
xcode
|
|
472
|
+
.command('artifact')
|
|
473
|
+
.description('Download an Xcode build artifact through the authenticated control plane')
|
|
474
|
+
.argument('<id>')
|
|
475
|
+
.option('-o, --output <path>', 'destination path')
|
|
476
|
+
.option('--force', 'overwrite an existing destination', false)
|
|
477
|
+
.action((id, flags) => action(async () => {
|
|
478
|
+
const client = api();
|
|
479
|
+
const build = await retryXcodeRequest(async () => await client.get(`/run-cloud/xcode/builds/${encodeURIComponent(id)}`));
|
|
480
|
+
console.log(await downloadArtifact(client, build, flags.output, flags.force));
|
|
481
|
+
}));
|
|
482
|
+
xcode
|
|
483
|
+
.command('cancel')
|
|
484
|
+
.description('Cancel a queued or running Xcode build')
|
|
485
|
+
.argument('<id>')
|
|
486
|
+
.option('--json', 'print JSON', false)
|
|
487
|
+
.action((id, flags) => action(async () => {
|
|
488
|
+
print(await retryXcodeRequest(async () => await api().post(`/run-cloud/xcode/builds/${encodeURIComponent(id)}/cancel`, {})), flags.json);
|
|
489
|
+
}));
|
|
490
|
+
}
|
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.
|
|
1
|
+
export const CLI_VERSION = '0.1.123';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "runcloud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.123",
|
|
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
|
|
@@ -190,9 +190,20 @@ immediately.
|
|
|
190
190
|
- Attach only the required groups or names with repeatable `--secret-group` and
|
|
191
191
|
`--secret`. Later selectors win on name collisions; `--env` is applied last.
|
|
192
192
|
- Use `--no-secrets` to state explicitly that a new sandbox needs none.
|
|
193
|
-
-
|
|
194
|
-
-
|
|
195
|
-
|
|
193
|
+
- Three verbs, because two of them change things:
|
|
194
|
+
- `runcloud sandbox secrets <id>` reads. Names, where each one resolved from,
|
|
195
|
+
and a digest of its value.
|
|
196
|
+
- `runcloud sandbox secrets set <id> ...` replaces. It is a
|
|
197
|
+
full replacement, not a merge: the sandbox ends up holding exactly what
|
|
198
|
+
that one command names.
|
|
199
|
+
- `runcloud sandbox secrets revoke <id>` takes everything back.
|
|
200
|
+
- A listing of `Unknown` is not `Nothing`. The inventory is recorded when a
|
|
201
|
+
delivery is accepted, so a sandbox created before inventories existed has no
|
|
202
|
+
record — its secrets still work. Only `Nothing` means the sandbox is empty.
|
|
203
|
+
- Values never come back, from any command. The digest exists so you can tell
|
|
204
|
+
whether the value in a sandbox is still the one its group holds; it is not
|
|
205
|
+
reversible and there is no masked form.
|
|
206
|
+
- Snapshots do not contain secrets, so a restored sandbox starts with none.
|
|
196
207
|
|
|
197
208
|
Inspect `runcloud secret-group --help` and `runcloud secrets --help` for the
|
|
198
209
|
current non-plaintext input forms.
|
|
@@ -350,6 +361,7 @@ warm-parked and resumable is the most expensive mistake available here.
|
|
|
350
361
|
| `pause` `resume` `stop` `archive` `restore` | as asked | a caller requested exactly this |
|
|
351
362
|
| `api` `api-force` | destroyed | a caller destroyed it. `api-force` bypassed a wedged guest |
|
|
352
363
|
| `health-sweep` | interrupted | the platform judged it unhealthy |
|
|
364
|
+
| `host-state-reconcile` | paused | the host had already parked it and the control plane caught up. The pause happened earlier than this event says |
|
|
353
365
|
| `host-reconcile` `reap-sweep` | destroyed | the platform reclaimed it |
|
|
354
366
|
| `boot-failure` `placement-timeout` `request-timeout` `scheduler` | never ran | capacity or image, not the workload |
|
|
355
367
|
| `image-built` `image-build-failure` | varies | an async image build finished or failed |
|
package/runcloud-0.1.121.tgz
DELETED
|
Binary file
|