@touchtechclub/simbox 0.4.9 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1273 -306
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Argument, Command, Option } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import pc6 from "picocolors";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
@@ -12,7 +12,75 @@ import { join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
// ../../packages/shared/src/constants.ts
|
|
14
14
|
var PROD_API_URL = "https://api.simbox.touchtech.club";
|
|
15
|
+
var WORKFLOW_PATH = ".github/workflows/simbox.yml";
|
|
16
|
+
// ../../packages/shared/src/device-profile.ts
|
|
17
|
+
var XCODE_VERSION = /^\d{1,3}(\.\d{1,3}){0,2}$/;
|
|
18
|
+
var IOS_VERSION = /^\d{1,3}(\.\d{1,3}){0,2}$/;
|
|
19
|
+
var ANDROID_API = /^\d{1,3}(\.\d{1,2})?$/;
|
|
20
|
+
var MODEL = /^[A-Za-z0-9][A-Za-z0-9 ._()+,'-]{0,79}$/;
|
|
21
|
+
var KEYS = new Set(["model", "os", "foldable", "xcode"]);
|
|
22
|
+
function validateDeviceProfile(value, platform) {
|
|
23
|
+
if (value === undefined || value === null)
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
26
|
+
throw new Error("Device must be {model?, os?, foldable?, xcode?}.");
|
|
27
|
+
if (Object.keys(value).some((key) => !KEYS.has(key)))
|
|
28
|
+
throw new Error("Device only accepts model, os, foldable and xcode.");
|
|
29
|
+
const { model, os, foldable, xcode } = value;
|
|
30
|
+
if (foldable !== undefined && typeof foldable !== "boolean")
|
|
31
|
+
throw new Error("foldable must be true or false.");
|
|
32
|
+
if (model !== undefined) {
|
|
33
|
+
if (typeof model !== "string" || !MODEL.test(model) || model !== model.trim())
|
|
34
|
+
throw new Error('Device model must be a simulator or emulator profile name such as "iPhone 16 Pro" or "pixel_8".');
|
|
35
|
+
if (foldable === true)
|
|
36
|
+
throw new Error("Choose either a foldable or a device model, not both.");
|
|
37
|
+
}
|
|
38
|
+
if (os !== undefined) {
|
|
39
|
+
if (typeof os !== "string")
|
|
40
|
+
throw new Error("os must be a version string.");
|
|
41
|
+
const keyword = os === "latest" || os === "oldest";
|
|
42
|
+
if (!keyword && platform === "ios" && !IOS_VERSION.test(os))
|
|
43
|
+
throw new Error('iOS version must look like "18.4", "18", "latest" or "oldest".');
|
|
44
|
+
if (!keyword && platform === "android" && !ANDROID_API.test(os))
|
|
45
|
+
throw new Error('Android version must be an API level such as "35", "latest" or "oldest".');
|
|
46
|
+
}
|
|
47
|
+
if (xcode !== undefined) {
|
|
48
|
+
if (typeof xcode !== "string" || !XCODE_VERSION.test(xcode))
|
|
49
|
+
throw new Error('Xcode must be a version such as "27.1".');
|
|
50
|
+
if (platform !== "ios")
|
|
51
|
+
throw new Error("Xcode selection only applies to iOS runs.");
|
|
52
|
+
}
|
|
53
|
+
const profile = {
|
|
54
|
+
...model !== undefined ? { model } : {},
|
|
55
|
+
...os !== undefined ? { os } : {},
|
|
56
|
+
...foldable === true ? { foldable: true } : {},
|
|
57
|
+
...xcode !== undefined ? { xcode } : {}
|
|
58
|
+
};
|
|
59
|
+
return Object.keys(profile).length > 0 ? profile : null;
|
|
60
|
+
}
|
|
61
|
+
function parseDevicePatch(value) {
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).length === 0 || Object.keys(value).some((key) => key !== "ios" && key !== "android")) {
|
|
63
|
+
throw new Error("Expected ios and/or android device settings; use null to inherit.");
|
|
64
|
+
}
|
|
65
|
+
const patch = {};
|
|
66
|
+
for (const platform of ["ios", "android"]) {
|
|
67
|
+
if (!Object.hasOwn(value, platform))
|
|
68
|
+
continue;
|
|
69
|
+
const profile = validateDeviceProfile(value[platform], platform);
|
|
70
|
+
if (profile?.xcode)
|
|
71
|
+
throw new Error("Xcode is chosen per run with --xcode, not as a default.");
|
|
72
|
+
patch[platform] = profile;
|
|
73
|
+
}
|
|
74
|
+
return patch;
|
|
75
|
+
}
|
|
76
|
+
function formatDeviceProfile(profile, platform) {
|
|
77
|
+
const model = profile?.foldable ? platform === "ios" ? "iPhone Duo" : "Android foldable" : profile?.model ?? "default";
|
|
78
|
+
const os = profile?.os ? platform === "ios" ? `iOS ${profile.os}` : /^\d/.test(profile.os) ? `API ${profile.os}` : `Android ${profile.os}` : null;
|
|
79
|
+
return [model, os, profile?.xcode ? `Xcode ${profile.xcode}` : null].filter((part) => part !== null).join(", ");
|
|
80
|
+
}
|
|
81
|
+
|
|
15
82
|
// ../../packages/shared/src/runners.ts
|
|
83
|
+
var PLATFORMS = ["ios", "android"];
|
|
16
84
|
var DEFAULT_RUNNERS = {
|
|
17
85
|
ios: { labels: ["macos-latest"] },
|
|
18
86
|
android: { labels: ["ubuntu-latest"] }
|
|
@@ -81,37 +149,373 @@ function parseRunnerInput(input, platform) {
|
|
|
81
149
|
function formatRunner(runner) {
|
|
82
150
|
return runner ? runner.labels.join(" + ") : "unknown (legacy workflow)";
|
|
83
151
|
}
|
|
84
|
-
// ../../packages/shared/src/
|
|
85
|
-
var
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const { foldable, xcode } = value;
|
|
95
|
-
if (foldable !== undefined && typeof foldable !== "boolean")
|
|
96
|
-
throw new Error("foldable must be true or false.");
|
|
97
|
-
if (xcode !== undefined) {
|
|
98
|
-
if (typeof xcode !== "string" || !XCODE_VERSION.test(xcode))
|
|
99
|
-
throw new Error('Xcode must be a version such as "27.1".');
|
|
100
|
-
if (platform !== "ios")
|
|
101
|
-
throw new Error("Xcode selection only applies to iOS runs.");
|
|
152
|
+
// ../../packages/shared/src/preview.ts
|
|
153
|
+
var PREVIEW_SESSION_MS = 5 * 60000;
|
|
154
|
+
// ../../packages/shared/src/device-catalog.ts
|
|
155
|
+
function compareVersions(a, b) {
|
|
156
|
+
const left = a.split(".").map(Number);
|
|
157
|
+
const right = b.split(".").map(Number);
|
|
158
|
+
for (let i = 0;i < Math.max(left.length, right.length); i++) {
|
|
159
|
+
const diff = (left[i] ?? 0) - (right[i] ?? 0);
|
|
160
|
+
if (diff !== 0)
|
|
161
|
+
return diff;
|
|
102
162
|
}
|
|
103
|
-
|
|
104
|
-
...foldable === true ? { foldable: true } : {},
|
|
105
|
-
...xcode !== undefined ? { xcode } : {}
|
|
106
|
-
};
|
|
107
|
-
return Object.keys(profile).length > 0 ? profile : null;
|
|
163
|
+
return 0;
|
|
108
164
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
165
|
+
// ../../packages/shared/src/workflow.ts
|
|
166
|
+
var WORKFLOW_SCHEMA_VERSION = 2;
|
|
167
|
+
var MARKER = "# simbox-workflow:";
|
|
168
|
+
var RUNNER_ACTION = "TouchTechClub/runner@v1";
|
|
169
|
+
var SIMBOX_STEPS = {
|
|
170
|
+
prepare: {
|
|
171
|
+
name: "Prepare Simbox",
|
|
172
|
+
description: "Checks the runner (OS, architecture, Xcode or KVM and the Android SDK) and downloads the checksum-verified Simbox agent."
|
|
173
|
+
},
|
|
174
|
+
start: {
|
|
175
|
+
name: "Start devices",
|
|
176
|
+
description: "Boots the requested simulator or emulator in the background. Devices are booted but not reachable yet, so steps after this one can install apps or seed data."
|
|
177
|
+
},
|
|
178
|
+
serve: {
|
|
179
|
+
name: "Serve devices",
|
|
180
|
+
description: "Opens the tunnel, marks the run live and serves devices until idle, stop or the time limit. Steps after it run once the session ends."
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
var SIMBOX_ORDER = ["prepare", "start", "serve"];
|
|
184
|
+
var isLockedStep = (step) => ("simbox" in step);
|
|
185
|
+
var stepId = (step) => isLockedStep(step) ? step.simbox : step.id;
|
|
186
|
+
function defaultWorkflowSpec() {
|
|
187
|
+
return { steps: SIMBOX_ORDER.map((simbox) => ({ simbox })) };
|
|
188
|
+
}
|
|
189
|
+
var MAX_USER_STEPS = 40;
|
|
190
|
+
var STEP_ID = /^[a-z][a-z0-9_-]{0,39}$/;
|
|
191
|
+
var NAME = /^[^\r\n\t]{1,100}$/;
|
|
192
|
+
var USES = /^(\.\/[\w./-]{1,200}|docker:\/\/[\w./:@-]{1,200}|[\w.-]{1,100}\/[\w./-]{1,200}@[\w./-]{1,100})$/;
|
|
193
|
+
var SHELL = /^[\w {}./-]{1,60}$/;
|
|
194
|
+
var MAP_KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/;
|
|
195
|
+
var PERMISSION_SCOPES = new Set([
|
|
196
|
+
"actions",
|
|
197
|
+
"attestations",
|
|
198
|
+
"checks",
|
|
199
|
+
"contents",
|
|
200
|
+
"deployments",
|
|
201
|
+
"id-token",
|
|
202
|
+
"issues",
|
|
203
|
+
"packages",
|
|
204
|
+
"pages",
|
|
205
|
+
"pull-requests",
|
|
206
|
+
"security-events",
|
|
207
|
+
"statuses"
|
|
208
|
+
]);
|
|
209
|
+
var RESERVED_IDS = new Set(SIMBOX_ORDER);
|
|
210
|
+
var STEP_KEYS = new Set([
|
|
211
|
+
"id",
|
|
212
|
+
"name",
|
|
213
|
+
"run",
|
|
214
|
+
"shell",
|
|
215
|
+
"uses",
|
|
216
|
+
"with",
|
|
217
|
+
"env",
|
|
218
|
+
"if",
|
|
219
|
+
"platform",
|
|
220
|
+
"workingDirectory",
|
|
221
|
+
"continueOnError",
|
|
222
|
+
"timeoutMinutes"
|
|
223
|
+
]);
|
|
224
|
+
function fail(message) {
|
|
225
|
+
throw new Error(message);
|
|
226
|
+
}
|
|
227
|
+
function stringMap(value, label) {
|
|
228
|
+
if (value === undefined)
|
|
229
|
+
return;
|
|
230
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
231
|
+
fail(`${label} must be an object of strings.`);
|
|
232
|
+
const entries = Object.entries(value);
|
|
233
|
+
if (entries.length > 50)
|
|
234
|
+
fail(`${label} has too many entries.`);
|
|
235
|
+
const out = {};
|
|
236
|
+
for (const [key, raw] of entries) {
|
|
237
|
+
if (!MAP_KEY.test(key))
|
|
238
|
+
fail(`${label} key "${key}" is not a valid name.`);
|
|
239
|
+
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "boolean")
|
|
240
|
+
fail(`${label}.${key} must be a string, number or boolean.`);
|
|
241
|
+
const text = String(raw);
|
|
242
|
+
if (text.length > 4000)
|
|
243
|
+
fail(`${label}.${key} is too long.`);
|
|
244
|
+
out[key] = text;
|
|
245
|
+
}
|
|
246
|
+
return entries.length ? out : undefined;
|
|
247
|
+
}
|
|
248
|
+
function expression(value) {
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
const wrapped = /^\$\{\{([\s\S]*)\}\}$/.exec(trimmed);
|
|
251
|
+
return (wrapped ? wrapped[1] : trimmed).trim();
|
|
252
|
+
}
|
|
253
|
+
function validateUserStep(value) {
|
|
254
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
255
|
+
fail("A step must be an object.");
|
|
256
|
+
const v = value;
|
|
257
|
+
const unknown = Object.keys(v).find((key) => !STEP_KEYS.has(key));
|
|
258
|
+
if (unknown)
|
|
259
|
+
fail(`Unsupported step field "${unknown}".`);
|
|
260
|
+
if (typeof v.id !== "string" || !STEP_ID.test(v.id))
|
|
261
|
+
fail("Step id must be 1–40 lowercase letters, digits, - or _, starting with a letter.");
|
|
262
|
+
if (RESERVED_IDS.has(v.id) || v.id.startsWith("simbox"))
|
|
263
|
+
fail(`Step id "${v.id}" is reserved for Simbox.`);
|
|
264
|
+
if (typeof v.name !== "string" || !NAME.test(v.name.trim()))
|
|
265
|
+
fail(`Step "${v.id}" needs a single-line name (1–100 characters).`);
|
|
266
|
+
const hasRun = v.run !== undefined;
|
|
267
|
+
const hasUses = v.uses !== undefined;
|
|
268
|
+
if (hasRun === hasUses)
|
|
269
|
+
fail(`Step "${v.id}" needs exactly one of run or uses.`);
|
|
270
|
+
const step = { id: v.id, name: v.name.trim() };
|
|
271
|
+
if (hasRun) {
|
|
272
|
+
if (typeof v.run !== "string" || !v.run.trim() || v.run.length > 20000)
|
|
273
|
+
fail(`Step "${v.id}" run must be a non-empty script up to 20,000 characters.`);
|
|
274
|
+
step.run = v.run;
|
|
275
|
+
if (v.shell !== undefined) {
|
|
276
|
+
if (typeof v.shell !== "string" || !SHELL.test(v.shell))
|
|
277
|
+
fail(`Step "${v.id}" shell is invalid.`);
|
|
278
|
+
step.shell = v.shell;
|
|
279
|
+
}
|
|
280
|
+
if (v.with !== undefined)
|
|
281
|
+
fail(`Step "${v.id}" uses run, so it cannot take with inputs.`);
|
|
282
|
+
} else {
|
|
283
|
+
if (typeof v.uses !== "string" || !USES.test(v.uses))
|
|
284
|
+
fail(`Step "${v.id}" uses must look like owner/repo@ref, ./path or docker://image.`);
|
|
285
|
+
if (v.shell !== undefined)
|
|
286
|
+
fail(`Step "${v.id}" uses an action, so it cannot set shell.`);
|
|
287
|
+
step.uses = v.uses;
|
|
288
|
+
const inputs = stringMap(v.with, `Step "${v.id}" with`);
|
|
289
|
+
if (inputs)
|
|
290
|
+
step.with = inputs;
|
|
291
|
+
}
|
|
292
|
+
const env = stringMap(v.env, `Step "${v.id}" env`);
|
|
293
|
+
if (env)
|
|
294
|
+
step.env = env;
|
|
295
|
+
if (v.if !== undefined) {
|
|
296
|
+
if (typeof v.if !== "string" || !expression(v.if) || v.if.length > 1000)
|
|
297
|
+
fail(`Step "${v.id}" if must be a non-empty expression.`);
|
|
298
|
+
step.if = expression(v.if);
|
|
299
|
+
}
|
|
300
|
+
if (v.platform !== undefined) {
|
|
301
|
+
if (v.platform !== "ios" && v.platform !== "android")
|
|
302
|
+
fail(`Step "${v.id}" platform must be ios or android (omit it for both).`);
|
|
303
|
+
step.platform = v.platform;
|
|
304
|
+
}
|
|
305
|
+
if (v.workingDirectory !== undefined) {
|
|
306
|
+
if (typeof v.workingDirectory !== "string" || !/^[\w./${}\s-]{1,200}$/.test(v.workingDirectory))
|
|
307
|
+
fail(`Step "${v.id}" workingDirectory is invalid.`);
|
|
308
|
+
step.workingDirectory = v.workingDirectory;
|
|
309
|
+
}
|
|
310
|
+
if (v.continueOnError !== undefined) {
|
|
311
|
+
if (typeof v.continueOnError !== "boolean")
|
|
312
|
+
fail(`Step "${v.id}" continueOnError must be boolean.`);
|
|
313
|
+
if (v.continueOnError)
|
|
314
|
+
step.continueOnError = true;
|
|
315
|
+
}
|
|
316
|
+
if (v.timeoutMinutes !== undefined) {
|
|
317
|
+
if (typeof v.timeoutMinutes !== "number" || !Number.isInteger(v.timeoutMinutes) || v.timeoutMinutes < 1 || v.timeoutMinutes > 360)
|
|
318
|
+
fail(`Step "${v.id}" timeoutMinutes must be a whole number from 1 to 360.`);
|
|
319
|
+
step.timeoutMinutes = v.timeoutMinutes;
|
|
320
|
+
}
|
|
321
|
+
return step;
|
|
322
|
+
}
|
|
323
|
+
function validateWorkflowSpec(value) {
|
|
324
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
325
|
+
fail("Expected {steps: [...]}.");
|
|
326
|
+
const v = value;
|
|
327
|
+
if (Object.keys(v).some((key) => key !== "steps" && key !== "permissions"))
|
|
328
|
+
fail("A workflow only has steps and permissions.");
|
|
329
|
+
if (!Array.isArray(v.steps))
|
|
330
|
+
fail("steps must be an array.");
|
|
331
|
+
const steps = [];
|
|
332
|
+
const seen = new Set;
|
|
333
|
+
const locked = [];
|
|
334
|
+
for (const raw of v.steps) {
|
|
335
|
+
if (raw && typeof raw === "object" && "simbox" in raw) {
|
|
336
|
+
const key = raw.simbox;
|
|
337
|
+
if (Object.keys(raw).length !== 1 || !SIMBOX_ORDER.includes(key))
|
|
338
|
+
fail("Locked steps are {simbox: prepare|start|serve} and cannot be edited.");
|
|
339
|
+
locked.push(key);
|
|
340
|
+
steps.push({ simbox: key });
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const step = validateUserStep(raw);
|
|
344
|
+
if (seen.has(step.id))
|
|
345
|
+
fail(`Duplicate step id "${step.id}".`);
|
|
346
|
+
seen.add(step.id);
|
|
347
|
+
steps.push(step);
|
|
348
|
+
}
|
|
349
|
+
if (locked.join() !== SIMBOX_ORDER.join())
|
|
350
|
+
fail("The Simbox steps prepare, start and serve must each appear once, in that order.");
|
|
351
|
+
if (seen.size > MAX_USER_STEPS)
|
|
352
|
+
fail(`Use at most ${MAX_USER_STEPS} custom steps.`);
|
|
353
|
+
const spec = { steps };
|
|
354
|
+
if (v.permissions !== undefined) {
|
|
355
|
+
if (!v.permissions || typeof v.permissions !== "object" || Array.isArray(v.permissions))
|
|
356
|
+
fail("permissions must map scopes to read, write or none.");
|
|
357
|
+
const permissions = {};
|
|
358
|
+
for (const [scope, level] of Object.entries(v.permissions)) {
|
|
359
|
+
if (!PERMISSION_SCOPES.has(scope))
|
|
360
|
+
fail(`Unknown permission scope "${scope}".`);
|
|
361
|
+
if (level !== "read" && level !== "write" && level !== "none")
|
|
362
|
+
fail(`Permission ${scope} must be read, write or none.`);
|
|
363
|
+
permissions[scope] = level;
|
|
364
|
+
}
|
|
365
|
+
if (Object.keys(permissions).length)
|
|
366
|
+
spec.permissions = permissions;
|
|
367
|
+
}
|
|
368
|
+
if (renderWorkflow(spec).length > 256000)
|
|
369
|
+
fail("The workflow is too large.");
|
|
370
|
+
return spec;
|
|
371
|
+
}
|
|
372
|
+
function indexOf(spec, id) {
|
|
373
|
+
const index = spec.steps.findIndex((step) => stepId(step) === id);
|
|
374
|
+
if (index < 0)
|
|
375
|
+
fail(`No step "${id}". Steps: ${spec.steps.map(stepId).join(", ")}.`);
|
|
376
|
+
return index;
|
|
377
|
+
}
|
|
378
|
+
function targetIndex(spec, position) {
|
|
379
|
+
return "before" in position ? indexOf(spec, position.before) : indexOf(spec, position.after) + 1;
|
|
380
|
+
}
|
|
381
|
+
function insertStep(spec, step, position) {
|
|
382
|
+
const steps = [...spec.steps];
|
|
383
|
+
steps.splice(targetIndex(spec, position), 0, step);
|
|
384
|
+
return validateWorkflowSpec({ ...spec, steps });
|
|
385
|
+
}
|
|
386
|
+
function removeStep(spec, id) {
|
|
387
|
+
const index = indexOf(spec, id);
|
|
388
|
+
if (isLockedStep(spec.steps[index]))
|
|
389
|
+
fail(`"${id}" is a locked Simbox step.`);
|
|
390
|
+
return validateWorkflowSpec({ ...spec, steps: spec.steps.filter((_, i) => i !== index) });
|
|
391
|
+
}
|
|
392
|
+
function moveStep(spec, id, position) {
|
|
393
|
+
const index = indexOf(spec, id);
|
|
394
|
+
const step = spec.steps[index];
|
|
395
|
+
if (isLockedStep(step))
|
|
396
|
+
fail(`"${id}" is a locked Simbox step and cannot move.`);
|
|
397
|
+
const without = { ...spec, steps: spec.steps.filter((_, i) => i !== index) };
|
|
398
|
+
return insertStep(without, step, position);
|
|
399
|
+
}
|
|
400
|
+
function scalar(value, indent) {
|
|
401
|
+
const lines = value.replace(/\r\n?/g, `
|
|
402
|
+
`).split(`
|
|
403
|
+
`);
|
|
404
|
+
const blockable = lines.length > 1 && !/^\s/.test(lines[0]) && lines.every((line) => !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(line));
|
|
405
|
+
if (!blockable)
|
|
406
|
+
return JSON.stringify(value);
|
|
407
|
+
let body = value.replace(/\r\n?/g, `
|
|
408
|
+
`);
|
|
409
|
+
const trailing = /\n*$/.exec(body)[0].length;
|
|
410
|
+
const chomp = trailing === 0 ? "-" : trailing === 1 ? "" : "+";
|
|
411
|
+
body = body.replace(/\n+$/, "");
|
|
412
|
+
const rendered = body.split(`
|
|
413
|
+
`).map((line) => line ? `${indent}${line}` : "").join(`
|
|
414
|
+
`);
|
|
415
|
+
return `|${chomp}
|
|
416
|
+
${rendered}${`
|
|
417
|
+
`.repeat(Math.max(0, trailing - 1))}`;
|
|
418
|
+
}
|
|
419
|
+
function condition(step) {
|
|
420
|
+
const platform = step.platform ? `inputs.platform == '${step.platform}'` : null;
|
|
421
|
+
if (platform && step.if)
|
|
422
|
+
return `\${{ ${platform} && (${step.if}) }}`;
|
|
423
|
+
if (platform)
|
|
424
|
+
return `\${{ ${platform} }}`;
|
|
425
|
+
return step.if ? `\${{ ${step.if} }}` : null;
|
|
426
|
+
}
|
|
427
|
+
function renderUserStep(step) {
|
|
428
|
+
const out = [` - id: ${step.id}`, ` name: ${JSON.stringify(step.name)}`];
|
|
429
|
+
const when = condition(step);
|
|
430
|
+
if (when)
|
|
431
|
+
out.push(` if: ${JSON.stringify(when)}`);
|
|
432
|
+
if (step.workingDirectory)
|
|
433
|
+
out.push(` working-directory: ${JSON.stringify(step.workingDirectory)}`);
|
|
434
|
+
if (step.continueOnError)
|
|
435
|
+
out.push(" continue-on-error: true");
|
|
436
|
+
if (step.timeoutMinutes)
|
|
437
|
+
out.push(` timeout-minutes: ${step.timeoutMinutes}`);
|
|
438
|
+
if (step.uses)
|
|
439
|
+
out.push(` uses: ${JSON.stringify(step.uses)}`);
|
|
440
|
+
if (step.with) {
|
|
441
|
+
out.push(" with:");
|
|
442
|
+
for (const [key, value] of Object.entries(step.with))
|
|
443
|
+
out.push(` ${key}: ${scalar(value, " ")}`);
|
|
444
|
+
}
|
|
445
|
+
if (step.env) {
|
|
446
|
+
out.push(" env:");
|
|
447
|
+
for (const [key, value] of Object.entries(step.env))
|
|
448
|
+
out.push(` ${key}: ${scalar(value, " ")}`);
|
|
449
|
+
}
|
|
450
|
+
if (step.shell)
|
|
451
|
+
out.push(` shell: ${JSON.stringify(step.shell)}`);
|
|
452
|
+
if (step.run !== undefined)
|
|
453
|
+
out.push(` run: ${scalar(step.run, " ")}`);
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
function renderLockedStep(key) {
|
|
457
|
+
const out = [
|
|
458
|
+
` - id: simbox_${key}`,
|
|
459
|
+
` name: ${SIMBOX_STEPS[key].name}`,
|
|
460
|
+
` uses: ${RUNNER_ACTION}`,
|
|
461
|
+
" with:",
|
|
462
|
+
` step: ${key}`,
|
|
463
|
+
" platform: ${{ inputs.platform }}"
|
|
464
|
+
];
|
|
465
|
+
if (key === "start")
|
|
466
|
+
out.push(" token: ${{ secrets.SIMBOX_TOKEN }}", " run_id: ${{ inputs.simbox_run }}");
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
function renderWorkflow(spec) {
|
|
470
|
+
const permissions = { contents: "read", ...spec.permissions };
|
|
471
|
+
const header = [
|
|
472
|
+
`${MARKER} ${WORKFLOW_SCHEMA_VERSION}`,
|
|
473
|
+
"# Managed by Simbox. Edit steps in the dashboard or with `simbox workflow`;",
|
|
474
|
+
"# direct edits are replaced the next time Simbox saves or repairs this file.",
|
|
475
|
+
"name: simbox",
|
|
476
|
+
'run-name: "simbox ${{ inputs.platform }} ${{ inputs.simbox_run }}"',
|
|
477
|
+
"",
|
|
478
|
+
"on:",
|
|
479
|
+
" workflow_dispatch:",
|
|
480
|
+
" inputs:",
|
|
481
|
+
" platform:",
|
|
482
|
+
" type: choice",
|
|
483
|
+
" options: [ios, android]",
|
|
484
|
+
" default: ios",
|
|
485
|
+
" description: Device platform (Android uses Linux/KVM)",
|
|
486
|
+
" runner_labels:",
|
|
487
|
+
" type: string",
|
|
488
|
+
' default: ""',
|
|
489
|
+
" description: JSON array of runner labels (empty uses platform default)",
|
|
490
|
+
" simbox_run:",
|
|
491
|
+
" type: string",
|
|
492
|
+
' default: ""',
|
|
493
|
+
" description: Run id assigned by Simbox (empty for manual runs)",
|
|
494
|
+
"",
|
|
495
|
+
"permissions:",
|
|
496
|
+
...Object.entries(permissions).map(([scope, level]) => ` ${scope}: ${level}`),
|
|
497
|
+
"",
|
|
498
|
+
"jobs:",
|
|
499
|
+
" simbox:",
|
|
500
|
+
` runs-on: \${{ fromJSON(inputs.runner_labels || (inputs.platform == 'android' && '["ubuntu-latest"]' || '["macos-latest"]')) }}`,
|
|
501
|
+
" timeout-minutes: 360",
|
|
502
|
+
" steps:"
|
|
503
|
+
];
|
|
504
|
+
const steps = spec.steps.flatMap((step) => isLockedStep(step) ? renderLockedStep(step.simbox) : renderUserStep(step));
|
|
505
|
+
return `${[...header, ...steps].join(`
|
|
506
|
+
`)}
|
|
507
|
+
`;
|
|
508
|
+
}
|
|
509
|
+
var WORKFLOW_YAML = renderWorkflow(defaultWorkflowSpec());
|
|
510
|
+
function suggestStepId(spec, name) {
|
|
511
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^(?![a-z])/, "step-").slice(0, 34).replace(/^simbox/, "step") || "step";
|
|
512
|
+
const taken = new Set(spec.steps.map(stepId));
|
|
513
|
+
if (!taken.has(base) && !RESERVED_IDS.has(base))
|
|
514
|
+
return base;
|
|
515
|
+
for (let n = 2;; n++)
|
|
516
|
+
if (!taken.has(`${base}-${n}`))
|
|
517
|
+
return `${base}-${n}`;
|
|
112
518
|
}
|
|
113
|
-
// ../../packages/shared/src/preview.ts
|
|
114
|
-
var PREVIEW_SESSION_MS = 5 * 60000;
|
|
115
519
|
// src/config.ts
|
|
116
520
|
var DEFAULT_WEB_URL = "https://simbox.touchtech.club";
|
|
117
521
|
function stripTrailingSlash(url) {
|
|
@@ -188,6 +592,14 @@ class ApiRequestError extends Error {
|
|
|
188
592
|
this.name = "ApiRequestError";
|
|
189
593
|
}
|
|
190
594
|
}
|
|
595
|
+
var API_TIMEOUT_MS = 30000;
|
|
596
|
+
var ENSURE_TIMEOUT_MS = 120000;
|
|
597
|
+
function unreachable(err, timeoutMs) {
|
|
598
|
+
const error = err;
|
|
599
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError")
|
|
600
|
+
return new CliError(`${apiBaseUrl()} did not answer within ${Math.round(timeoutMs / 1000)}s — check your connection and retry.`);
|
|
601
|
+
return new CliError(`Could not reach ${apiBaseUrl()} — check your connection. (${error?.message ?? String(err)})`);
|
|
602
|
+
}
|
|
191
603
|
async function api(path, opts = {}) {
|
|
192
604
|
const headers = {
|
|
193
605
|
accept: "application/json"
|
|
@@ -198,15 +610,18 @@ async function api(path, opts = {}) {
|
|
|
198
610
|
headers.authorization = `Bearer ${token}`;
|
|
199
611
|
if (opts.body !== undefined)
|
|
200
612
|
headers["content-type"] = "application/json";
|
|
613
|
+
const timeoutMs = opts.timeoutMs ?? API_TIMEOUT_MS;
|
|
614
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
201
615
|
let res;
|
|
202
616
|
try {
|
|
203
617
|
res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
204
618
|
method: opts.method ?? "GET",
|
|
205
619
|
headers,
|
|
206
|
-
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined
|
|
620
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
621
|
+
signal
|
|
207
622
|
});
|
|
208
623
|
} catch (err) {
|
|
209
|
-
throw
|
|
624
|
+
throw unreachable(err, timeoutMs);
|
|
210
625
|
}
|
|
211
626
|
if (res.status === 401 || res.status === 403) {
|
|
212
627
|
throw new ApiRequestError(res.status, "unauthorized", "Your session is invalid or expired. Run `simbox login`.");
|
|
@@ -219,7 +634,11 @@ async function api(path, opts = {}) {
|
|
|
219
634
|
const body = await parseErrorBody(res);
|
|
220
635
|
throw new ApiRequestError(res.status, body?.error ?? `http_${res.status}`, body?.message ?? `Request failed (HTTP ${res.status}).`);
|
|
221
636
|
}
|
|
222
|
-
|
|
637
|
+
try {
|
|
638
|
+
return await res.json();
|
|
639
|
+
} catch (err) {
|
|
640
|
+
throw unreachable(err, timeoutMs);
|
|
641
|
+
}
|
|
223
642
|
}
|
|
224
643
|
async function parseErrorBody(res) {
|
|
225
644
|
try {
|
|
@@ -244,10 +663,11 @@ async function pollDeviceToken(deviceCode) {
|
|
|
244
663
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
245
664
|
device_code: deviceCode,
|
|
246
665
|
client_id: CLI_CLIENT_ID
|
|
247
|
-
})
|
|
666
|
+
}),
|
|
667
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
|
248
668
|
});
|
|
249
669
|
} catch (err) {
|
|
250
|
-
throw
|
|
670
|
+
throw unreachable(err, API_TIMEOUT_MS);
|
|
251
671
|
}
|
|
252
672
|
const body = await res.json().catch(() => null);
|
|
253
673
|
if (res.ok && body?.access_token)
|
|
@@ -260,27 +680,25 @@ async function pollDeviceToken(deviceCode) {
|
|
|
260
680
|
}
|
|
261
681
|
var me = async (token) => (await api("/v1/me", { token })).user;
|
|
262
682
|
var repoStatus = () => api("/v1/repo/status");
|
|
263
|
-
var repairRepo = () => api("/v1/repo/repair", {
|
|
264
|
-
method: "POST"
|
|
265
|
-
});
|
|
683
|
+
var repairRepo = () => api("/v1/repo/repair", { method: "POST" });
|
|
266
684
|
var ensureRun = (fresh, platform2, runner, device) => api("/v1/runs/ensure", {
|
|
267
685
|
method: "POST",
|
|
268
|
-
body: { new: fresh, platform: platform2, runner, device }
|
|
686
|
+
body: { new: fresh, platform: platform2, runner, device },
|
|
687
|
+
timeoutMs: ENSURE_TIMEOUT_MS
|
|
269
688
|
});
|
|
270
689
|
var runnerSettings = () => api("/v1/runners");
|
|
271
690
|
var runnerDiagnostics = () => api("/v1/runners/doctor");
|
|
272
691
|
var updateRunners = (scope, patch) => api(`/v1/runners/${scope}`, { method: "POST", body: patch });
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
692
|
+
var updateDevices = (scope, patch) => api(`/v1/devices/${scope}`, { method: "POST", body: patch });
|
|
693
|
+
var inventory = (platform2, runner) => {
|
|
694
|
+
const query = new URLSearchParams({ platform: platform2 });
|
|
695
|
+
if (runner)
|
|
696
|
+
query.set("runner", runner.labels.length === 1 ? runner.labels[0] : JSON.stringify(runner.labels));
|
|
697
|
+
return api(`/v1/inventory?${query}`);
|
|
698
|
+
};
|
|
699
|
+
var getWorkflow = () => api("/v1/workflow");
|
|
700
|
+
var putWorkflow = (spec) => api("/v1/workflow", { method: "PUT", body: { spec } });
|
|
701
|
+
var activeRuns = async () => (await api("/v1/runs/active")).runs;
|
|
284
702
|
var stopRun = (runId) => api(`/v1/runs/${encodeURIComponent(runId)}/stop`, {
|
|
285
703
|
method: "POST"
|
|
286
704
|
});
|
|
@@ -300,6 +718,18 @@ That run is full — try \`simbox sim --new\` for a fresh one.`);
|
|
|
300
718
|
if (err.code === "needs_repair" || err.code === "repo_needs_repair" || err.code === "repo_not_ready" || err.code === "dispatch_failed") {
|
|
301
719
|
return new CliError(`${err.message}
|
|
302
720
|
Run \`simbox repair\` to fix the repo setup.`);
|
|
721
|
+
}
|
|
722
|
+
if (err.code === "workflow_needs_repair") {
|
|
723
|
+
return new CliError(`${err.message}
|
|
724
|
+
Run \`simbox repair\` to upgrade the workflow (merge its PR if the default branch is protected).`);
|
|
725
|
+
}
|
|
726
|
+
if (err.code === "platform_busy") {
|
|
727
|
+
return new CliError(`${err.message}
|
|
728
|
+
See \`simbox ps\`; \`simbox stop --platform <ios|android>\` ends a run and \`simbox sim --new --platform <ios|android>\` replaces one.`);
|
|
729
|
+
}
|
|
730
|
+
if (err.code === "invalid_device") {
|
|
731
|
+
return new CliError(`${err.message}
|
|
732
|
+
Run \`simbox devices list --platform <ios|android>\` to see what the runner offers.`);
|
|
303
733
|
}
|
|
304
734
|
return new CliError(err.message);
|
|
305
735
|
}
|
|
@@ -310,41 +740,6 @@ Run \`simbox repair\` to fix the repo setup.`);
|
|
|
310
740
|
|
|
311
741
|
// src/commands.ts
|
|
312
742
|
import { rmSync as rmSync2 } from "node:fs";
|
|
313
|
-
|
|
314
|
-
// src/run-cache.ts
|
|
315
|
-
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
316
|
-
import { join as join2 } from "node:path";
|
|
317
|
-
var cachePath = () => join2(configDir(), "current-run.json");
|
|
318
|
-
function readCachedRun(now = Date.now()) {
|
|
319
|
-
try {
|
|
320
|
-
const info = JSON.parse(readFileSync2(cachePath(), "utf8"));
|
|
321
|
-
if (typeof info.runId !== "string" || typeof info.tunnelUrl !== "string" || typeof info.daemonToken !== "string")
|
|
322
|
-
return null;
|
|
323
|
-
const expiresAt = typeof info.expiresAt === "number" ? info.expiresAt : null;
|
|
324
|
-
if (expiresAt !== null && expiresAt <= now)
|
|
325
|
-
return null;
|
|
326
|
-
return {
|
|
327
|
-
runId: info.runId,
|
|
328
|
-
tunnelUrl: info.tunnelUrl,
|
|
329
|
-
daemonToken: info.daemonToken,
|
|
330
|
-
expiresAt
|
|
331
|
-
};
|
|
332
|
-
} catch {
|
|
333
|
-
return null;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
function writeCachedRun(info) {
|
|
337
|
-
try {
|
|
338
|
-
mkdirSync2(configDir(), { recursive: true, mode: 448 });
|
|
339
|
-
writeFileSync2(cachePath(), JSON.stringify(info), { mode: 384 });
|
|
340
|
-
chmodSync2(cachePath(), 384);
|
|
341
|
-
} catch {}
|
|
342
|
-
}
|
|
343
|
-
function clearCachedRun() {
|
|
344
|
-
rmSync(cachePath(), { force: true });
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// src/commands.ts
|
|
348
743
|
import pc3 from "picocolors";
|
|
349
744
|
|
|
350
745
|
// src/connect.ts
|
|
@@ -361,10 +756,14 @@ function findAgentDevice() {
|
|
|
361
756
|
const first = (res.stdout ?? "").split(/\r?\n/).find((l) => l.trim().length > 0);
|
|
362
757
|
return first?.trim() ?? null;
|
|
363
758
|
}
|
|
364
|
-
function connectFlow(info, quiet = false) {
|
|
759
|
+
function connectFlow(info, hints, quiet = false) {
|
|
365
760
|
const out = quiet ? process.stderr : process.stdout;
|
|
366
761
|
const base = daemonBaseUrl(info.tunnelUrl);
|
|
367
762
|
const bin = findAgentDevice();
|
|
763
|
+
const { platform: platform2, bootedDevice } = hints;
|
|
764
|
+
const device = bootedDevice ? JSON.stringify(bootedDevice.name) : '"<device from simbox exec devices>"';
|
|
765
|
+
const selector = platform2 === "ios" ? "--udid" : "--serial";
|
|
766
|
+
const id = bootedDevice?.id ?? (platform2 === "ios" ? "<udid>" : "<serial>");
|
|
368
767
|
if (bin) {
|
|
369
768
|
out.write(`${pc.green("▶")} Ready for ${pc.bold("agent-device")} via Simbox.
|
|
370
769
|
`);
|
|
@@ -372,14 +771,17 @@ function connectFlow(info, quiet = false) {
|
|
|
372
771
|
out.write(`${pc.yellow("!")} ${pc.bold("agent-device")} not found on PATH — install it, then:
|
|
373
772
|
`);
|
|
374
773
|
}
|
|
774
|
+
if (bootedDevice)
|
|
775
|
+
out.write(` Booted: ${pc.bold(bootedDevice.name)} (${platform2 === "ios" ? "iOS" : "API"} ${bootedDevice.os}${bootedDevice.id ? `, ${bootedDevice.id}` : ""})
|
|
776
|
+
`);
|
|
375
777
|
out.write(`
|
|
376
|
-
${pc.cyan(
|
|
778
|
+
${pc.cyan(`simbox exec devices --platform ${platform2}`)}
|
|
377
779
|
`);
|
|
378
|
-
out.write(` ${pc.cyan(
|
|
780
|
+
out.write(` ${pc.cyan(`simbox exec open <app> --platform ${platform2} --device ${device}`)}
|
|
379
781
|
`);
|
|
380
|
-
out.write(` ${pc.cyan(
|
|
782
|
+
out.write(` ${pc.cyan(`simbox exec snapshot -i --platform ${platform2}`)}
|
|
381
783
|
`);
|
|
382
|
-
out.write(`
|
|
784
|
+
out.write(` Pass --platform ${platform2} whenever both an iOS and an Android run are active; use ${selector} to pick a specific device.
|
|
383
785
|
`);
|
|
384
786
|
out.write(` exec supplies auth, refreshes tunnel URLs, and allows 5 minutes for cold startup.
|
|
385
787
|
|
|
@@ -390,7 +792,7 @@ function connectFlow(info, quiet = false) {
|
|
|
390
792
|
`);
|
|
391
793
|
out.write(` ${pc.cyan(`export AGENT_DEVICE_DAEMON_BASE_URL=${base}`)}
|
|
392
794
|
`);
|
|
393
|
-
out.write(` ${pc.cyan(`agent-device open <app> --platform
|
|
795
|
+
out.write(` ${pc.cyan(`agent-device open <app> --platform ${platform2} ${selector} ${id} --session simbox-${info.runId} --timeout 300000`)}
|
|
394
796
|
`);
|
|
395
797
|
out.write(` Raw agent-device commands need these exports again if the tunnel changes.
|
|
396
798
|
`);
|
|
@@ -433,6 +835,18 @@ function fmtCountdown(expiresAt) {
|
|
|
433
835
|
return `${m}m ${sec}s`;
|
|
434
836
|
return `${sec}s`;
|
|
435
837
|
}
|
|
838
|
+
function fmtAgo(at, now = Date.now()) {
|
|
839
|
+
const ms = now - (at < 1000000000000 ? at * 1000 : at);
|
|
840
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
841
|
+
const unit = (n, name) => `${n} ${name}${n === 1 ? "" : "s"} ago`;
|
|
842
|
+
if (s < 60)
|
|
843
|
+
return "just now";
|
|
844
|
+
if (s < 3600)
|
|
845
|
+
return unit(Math.floor(s / 60), "minute");
|
|
846
|
+
if (s < 86400)
|
|
847
|
+
return unit(Math.floor(s / 3600), "hour");
|
|
848
|
+
return unit(Math.floor(s / 86400), "day");
|
|
849
|
+
}
|
|
436
850
|
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
437
851
|
|
|
438
852
|
class Spinner {
|
|
@@ -500,26 +914,22 @@ class Spinner {
|
|
|
500
914
|
}
|
|
501
915
|
}
|
|
502
916
|
|
|
503
|
-
// src/remote.ts
|
|
504
|
-
import { spawn } from "node:child_process";
|
|
505
|
-
import { join as join4 } from "node:path";
|
|
506
|
-
|
|
507
917
|
// src/device-registration.ts
|
|
508
918
|
import { randomUUID } from "node:crypto";
|
|
509
|
-
import { existsSync, mkdirSync as
|
|
919
|
+
import { existsSync, mkdirSync as mkdirSync2, readdirSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
510
920
|
import { homedir as homedir2 } from "node:os";
|
|
511
|
-
import { join as
|
|
921
|
+
import { join as join2 } from "node:path";
|
|
512
922
|
var MAX_AGE_MS = 5 * 60000;
|
|
513
923
|
function registerDevice(info, options = {}) {
|
|
514
|
-
if (!process.env.T3CODE_HOME && !process.env.SIMBOX_DEVICE_REGISTRATION_DIR && ![".s5code", ".t3"].some((home) => existsSync(
|
|
924
|
+
if (!process.env.T3CODE_HOME && !process.env.SIMBOX_DEVICE_REGISTRATION_DIR && ![".s5code", ".t3"].some((home) => existsSync(join2(homedir2(), home, "userdata", "server-runtime.json"))))
|
|
515
925
|
return;
|
|
516
926
|
try {
|
|
517
|
-
const directory = process.env.SIMBOX_DEVICE_REGISTRATION_DIR ??
|
|
518
|
-
|
|
927
|
+
const directory = process.env.SIMBOX_DEVICE_REGISTRATION_DIR ?? join2(configDir(), "device-registrations");
|
|
928
|
+
mkdirSync2(directory, { recursive: true, mode: 448 });
|
|
519
929
|
for (const file of readdirSync(directory)) {
|
|
520
930
|
if (!/^[a-f0-9-]{36}\.json$/.test(file))
|
|
521
931
|
continue;
|
|
522
|
-
const path =
|
|
932
|
+
const path = join2(directory, file);
|
|
523
933
|
if (Date.now() - statSync(path).mtimeMs > MAX_AGE_MS)
|
|
524
934
|
unlinkSync(path);
|
|
525
935
|
}
|
|
@@ -531,7 +941,7 @@ function registerDevice(info, options = {}) {
|
|
|
531
941
|
return index >= 0 ? flags[index + 1] : flags.find((arg) => arg.startsWith(`${name}=`))?.slice(name.length + 1);
|
|
532
942
|
};
|
|
533
943
|
const id = randomUUID();
|
|
534
|
-
|
|
944
|
+
writeFileSync2(join2(directory, `${id}.json`), JSON.stringify({
|
|
535
945
|
version: 1,
|
|
536
946
|
createdAt: Date.now(),
|
|
537
947
|
...info,
|
|
@@ -552,33 +962,148 @@ function registerDevice(info, options = {}) {
|
|
|
552
962
|
}
|
|
553
963
|
}
|
|
554
964
|
|
|
555
|
-
// src/
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
965
|
+
// src/run-cache.ts
|
|
966
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
967
|
+
import { join as join3 } from "node:path";
|
|
968
|
+
var cachePath = (platform2) => join3(configDir(), `current-run-${platform2}.json`);
|
|
969
|
+
var legacyPath = () => join3(configDir(), "current-run.json");
|
|
970
|
+
function removeLegacy() {
|
|
971
|
+
try {
|
|
972
|
+
rmSync(legacyPath(), { force: true });
|
|
973
|
+
} catch {}
|
|
974
|
+
}
|
|
975
|
+
function readCachedRun(platform2, now = Date.now()) {
|
|
976
|
+
removeLegacy();
|
|
977
|
+
try {
|
|
978
|
+
const info = JSON.parse(readFileSync2(cachePath(platform2), "utf8"));
|
|
979
|
+
if (info.platform !== platform2 || typeof info.runId !== "string" || typeof info.tunnelUrl !== "string" || typeof info.daemonToken !== "string")
|
|
980
|
+
return null;
|
|
981
|
+
const expiresAt = typeof info.expiresAt === "number" ? info.expiresAt : null;
|
|
982
|
+
if (expiresAt !== null && expiresAt <= now)
|
|
983
|
+
return null;
|
|
984
|
+
return {
|
|
985
|
+
runId: info.runId,
|
|
986
|
+
tunnelUrl: info.tunnelUrl,
|
|
987
|
+
daemonToken: info.daemonToken,
|
|
988
|
+
expiresAt
|
|
989
|
+
};
|
|
990
|
+
} catch {
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function writeCachedRun(platform2, info) {
|
|
995
|
+
removeLegacy();
|
|
996
|
+
const path = cachePath(platform2);
|
|
997
|
+
try {
|
|
998
|
+
mkdirSync3(configDir(), { recursive: true, mode: 448 });
|
|
999
|
+
const { runId, tunnelUrl, daemonToken, expiresAt } = info;
|
|
1000
|
+
writeFileSync3(path, JSON.stringify({ platform: platform2, runId, tunnelUrl, daemonToken, expiresAt }), {
|
|
1001
|
+
mode: 384
|
|
1002
|
+
});
|
|
1003
|
+
chmodSync2(path, 384);
|
|
1004
|
+
} catch {}
|
|
1005
|
+
}
|
|
1006
|
+
function clearCachedRun(platform2) {
|
|
1007
|
+
removeLegacy();
|
|
1008
|
+
rmSync(cachePath(platform2), { force: true });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// src/runs.ts
|
|
1012
|
+
var PLATFORM_NAMES = { ios: "iOS", android: "Android" };
|
|
1013
|
+
var defaultRunsDeps = {
|
|
1014
|
+
activeRuns,
|
|
1015
|
+
history: runHistory,
|
|
1016
|
+
probe: probeRemote,
|
|
1017
|
+
readCache: (platform2) => readCachedRun(platform2),
|
|
1018
|
+
writeCache: writeCachedRun,
|
|
1019
|
+
clearCache: clearCachedRun,
|
|
1020
|
+
pause: sleep
|
|
1021
|
+
};
|
|
1022
|
+
var withDefaults = (deps = {}) => ({ ...defaultRunsDeps, ...deps });
|
|
1023
|
+
var DEVICES_TIMEOUT_MS = 12 * 60000;
|
|
1024
|
+
var POLL_MS = 3000;
|
|
1025
|
+
function platformFlag(args) {
|
|
1026
|
+
const end = args.indexOf("--");
|
|
1027
|
+
const flags = end < 0 ? args : args.slice(0, end);
|
|
1028
|
+
let found;
|
|
1029
|
+
for (let i = 0;i < flags.length; i++) {
|
|
1030
|
+
const arg = flags[i];
|
|
1031
|
+
const value = arg === "--platform" ? flags[i + 1] : arg.startsWith("--platform=") ? arg.slice("--platform=".length) : undefined;
|
|
1032
|
+
if (value === undefined)
|
|
1033
|
+
continue;
|
|
1034
|
+
if (value !== "ios" && value !== "android")
|
|
1035
|
+
throw new CliError(`Simbox runs serve --platform ios or android, not "${value}".`);
|
|
1036
|
+
if (found && found !== value)
|
|
1037
|
+
throw new CliError("Pass --platform once: a command goes to one run.");
|
|
1038
|
+
found = value;
|
|
1039
|
+
}
|
|
1040
|
+
return found;
|
|
1041
|
+
}
|
|
1042
|
+
function describe(run) {
|
|
1043
|
+
return `${PLATFORM_NAMES[run.platform]} (run ${run.id}, ${run.state})`;
|
|
1044
|
+
}
|
|
1045
|
+
function targetPlatform(explicit, active, hint = "Pass --platform ios or --platform android.") {
|
|
1046
|
+
if (explicit)
|
|
1047
|
+
return explicit;
|
|
1048
|
+
if (active.length === 1)
|
|
1049
|
+
return active[0].platform;
|
|
1050
|
+
if (active.length === 0)
|
|
1051
|
+
throw new CliError("No active run. Start one with `simbox sim`.");
|
|
1052
|
+
throw new CliError(`Two runs are active: ${active.map(describe).join(" and ")}. ${hint}`);
|
|
1053
|
+
}
|
|
1054
|
+
function devicesReady(run) {
|
|
1055
|
+
return run.devicesReady ?? (run.platform === "android" ? run.androidReady : true);
|
|
1056
|
+
}
|
|
1057
|
+
function connectInfo(run) {
|
|
1058
|
+
if (run.state !== "live" || !run.tunnelUrl || !run.daemonToken)
|
|
1059
|
+
return null;
|
|
1060
|
+
return {
|
|
1061
|
+
runId: run.id,
|
|
1062
|
+
tunnelUrl: run.tunnelUrl,
|
|
1063
|
+
daemonToken: run.daemonToken,
|
|
1064
|
+
expiresAt: run.expiresAt
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
var STOPPED = new Set(["closing", "ended", "failed"]);
|
|
1068
|
+
async function waitForRun(runId, options) {
|
|
1069
|
+
const deps = withDefaults(options.deps);
|
|
1070
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
1071
|
+
let last = null;
|
|
1072
|
+
while (true) {
|
|
1073
|
+
const run = (await deps.activeRuns()).find((candidate) => candidate.id === runId);
|
|
1074
|
+
if (!run) {
|
|
1075
|
+
const past = (await deps.history()).find((candidate) => candidate.id === runId);
|
|
1076
|
+
const name = past ? PLATFORM_NAMES[past.platform] : "The";
|
|
1077
|
+
const retry = past ? ` --platform ${past.platform}` : "";
|
|
1078
|
+
throw new CliError(past && STOPPED.has(past.state) ? `The ${name} run ${past.state} before its devices were ready (${past.endReason ?? "check the Actions logs"}). Try \`simbox sim --new${retry}\`.` : `${name} run ${runId} is no longer active (it ended or was replaced). Run \`simbox sim${retry}\` again.`);
|
|
566
1079
|
}
|
|
567
|
-
if (run.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
1080
|
+
if (STOPPED.has(run.state))
|
|
1081
|
+
throw new CliError(`The ${PLATFORM_NAMES[run.platform]} run ${run.state} before its devices were ready (${run.endReason ?? "check the Actions logs"}). Try \`simbox sim --new --platform ${run.platform}\`.`);
|
|
1082
|
+
last = run;
|
|
1083
|
+
options.onUpdate?.(run);
|
|
1084
|
+
const info = connectInfo(run);
|
|
1085
|
+
if (info && devicesReady(run))
|
|
1086
|
+
return { run, info };
|
|
1087
|
+
if (Date.now() >= deadline)
|
|
1088
|
+
break;
|
|
1089
|
+
await deps.pause(POLL_MS);
|
|
1090
|
+
}
|
|
1091
|
+
if (last?.state === "live")
|
|
1092
|
+
throw new CliError(`The ${PLATFORM_NAMES[last.platform]} run is live but its devices did not finish booting. Inspect the Actions job's device logs. No device command was sent.`);
|
|
1093
|
+
throw new CliError(options.timeoutMessage);
|
|
572
1094
|
}
|
|
573
|
-
async function
|
|
1095
|
+
async function refreshPlatform(platform2, deps = {}) {
|
|
1096
|
+
const run = (await withDefaults(deps).activeRuns()).find((r) => r.platform === platform2);
|
|
1097
|
+
return run ? connectInfo(run) : null;
|
|
1098
|
+
}
|
|
1099
|
+
async function waitForRemote(initial, options) {
|
|
574
1100
|
const probe = options.probe ?? probeRemote;
|
|
575
|
-
const refresh = options.refresh ?? refreshRemote;
|
|
576
1101
|
const deadline = Date.now() + (options.timeoutMs ?? 180000);
|
|
577
1102
|
let info = initial;
|
|
578
1103
|
do {
|
|
579
1104
|
if (await probe(info))
|
|
580
1105
|
return info;
|
|
581
|
-
const next = await refresh();
|
|
1106
|
+
const next = await options.refresh();
|
|
582
1107
|
if (!next || next.runId !== initial.runId) {
|
|
583
1108
|
throw new CliError("The run ended or was replaced. Run `simbox sim` to connect again.");
|
|
584
1109
|
}
|
|
@@ -600,87 +1125,44 @@ async function probeRemote(info) {
|
|
|
600
1125
|
return false;
|
|
601
1126
|
}
|
|
602
1127
|
}
|
|
603
|
-
async function
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
function remoteArgs(args, info, stateDir) {
|
|
615
|
-
const separator = args.indexOf("--");
|
|
616
|
-
const result = separator < 0 ? [...args] : args.slice(0, separator);
|
|
617
|
-
const tail = separator < 0 ? [] : args.slice(separator);
|
|
618
|
-
if (["open", "boot", "prepare"].includes(args[0] ?? "") && !hasFlag(args, "--timeout")) {
|
|
619
|
-
result.push("--timeout", String(STARTUP_TIMEOUT_MS));
|
|
1128
|
+
async function connect(platform2, options = {}) {
|
|
1129
|
+
const deps = withDefaults(options.deps);
|
|
1130
|
+
const cached = deps.readCache(platform2);
|
|
1131
|
+
if (cached && await deps.probe(cached))
|
|
1132
|
+
return cached;
|
|
1133
|
+
const active = options.active ?? await deps.activeRuns();
|
|
1134
|
+
const run = active.find((candidate) => candidate.platform === platform2);
|
|
1135
|
+
if (!run) {
|
|
1136
|
+
deps.clearCache(platform2);
|
|
1137
|
+
throw new CliError(`No active ${PLATFORM_NAMES[platform2]} run. Start one with \`simbox sim --platform ${platform2}\`.`);
|
|
620
1138
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
1139
|
+
const ready = await waitForRun(run.id, {
|
|
1140
|
+
timeoutMs: options.timeoutMs ?? DEVICES_TIMEOUT_MS,
|
|
1141
|
+
timeoutMessage: `The ${PLATFORM_NAMES[platform2]} run did not come online in time. Check the workflow run in your repo's Actions tab. No device command was sent.`,
|
|
1142
|
+
deps
|
|
1143
|
+
});
|
|
1144
|
+
const info = await waitForRemote(ready.info, {
|
|
1145
|
+
probe: deps.probe,
|
|
1146
|
+
refresh: () => refreshPlatform(platform2, deps),
|
|
1147
|
+
pause: () => deps.pause(1000)
|
|
1148
|
+
});
|
|
1149
|
+
deps.writeCache(platform2, info);
|
|
1150
|
+
return info;
|
|
629
1151
|
}
|
|
630
|
-
async function
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
"--state-dir"
|
|
639
|
-
];
|
|
640
|
-
if (reserved.some((flag) => hasFlag(args, flag))) {
|
|
641
|
-
throw new CliError("Simbox manages the remote URL, credentials and state directory for `exec`.");
|
|
642
|
-
}
|
|
643
|
-
if (["connect", "disconnect", "proxy", "auth"].includes(args[0])) {
|
|
644
|
-
throw new CliError("Use device commands with `simbox exec`, not connection/auth commands.");
|
|
645
|
-
}
|
|
646
|
-
const bin = findAgentDevice();
|
|
647
|
-
if (!bin)
|
|
648
|
-
throw new CliError("Install agent-device first: npm i -g agent-device@0.21.28");
|
|
649
|
-
const android = args.some((arg, i) => arg === "--platform=android" || arg === "--platform" && args[i + 1] === "android");
|
|
650
|
-
let info = android ? null : readCachedRun();
|
|
651
|
-
if (info && !await probeRemote(info))
|
|
652
|
-
info = null;
|
|
653
|
-
if (!info) {
|
|
654
|
-
const initial = await refreshRemote();
|
|
655
|
-
if (!initial) {
|
|
656
|
-
clearCachedRun();
|
|
657
|
-
throw new CliError("No live run. Start one with `simbox sim`.");
|
|
658
|
-
}
|
|
659
|
-
if (android)
|
|
660
|
-
await waitForAndroid(initial.runId);
|
|
661
|
-
info = await waitForRemote(initial);
|
|
662
|
-
}
|
|
663
|
-
writeCachedRun(info);
|
|
664
|
-
const env = {
|
|
665
|
-
...process.env,
|
|
666
|
-
AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken
|
|
667
|
-
};
|
|
668
|
-
delete env.AGENT_DEVICE_REMOTE_CONFIG;
|
|
669
|
-
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
|
|
670
|
-
const code = await new Promise((resolve, reject) => {
|
|
671
|
-
const child = spawn(bin, remoteArgs(args, info, join4(configDir(), "agent-device")), {
|
|
672
|
-
stdio: "inherit",
|
|
673
|
-
env
|
|
674
|
-
});
|
|
675
|
-
child.on("error", reject);
|
|
676
|
-
child.on("exit", (status) => resolve(status ?? 1));
|
|
1152
|
+
async function connectForArgs(args, deps = {}) {
|
|
1153
|
+
const all = withDefaults(deps);
|
|
1154
|
+
const explicit = platformFlag(args);
|
|
1155
|
+
if (explicit)
|
|
1156
|
+
return { platform: explicit, info: await connect(explicit, { deps: all }) };
|
|
1157
|
+
const cached = PLATFORMS.flatMap((platform3) => {
|
|
1158
|
+
const info = all.readCache(platform3);
|
|
1159
|
+
return info ? [{ platform: platform3, info }] : [];
|
|
677
1160
|
});
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
process.exitCode = code;
|
|
1161
|
+
if (cached.length === 1 && await all.probe(cached[0].info))
|
|
1162
|
+
return cached[0];
|
|
1163
|
+
const active = await all.activeRuns();
|
|
1164
|
+
const platform2 = targetPlatform(undefined, active);
|
|
1165
|
+
return { platform: platform2, info: await connect(platform2, { active, deps: all }) };
|
|
684
1166
|
}
|
|
685
1167
|
|
|
686
1168
|
// src/commands.ts
|
|
@@ -765,136 +1247,180 @@ function printRepoConnected(repo) {
|
|
|
765
1247
|
}
|
|
766
1248
|
console.log(`Run ${pc3.cyan("simbox sim")} to boot a simulator.`);
|
|
767
1249
|
}
|
|
768
|
-
var SIM_TIMEOUT_MS =
|
|
769
|
-
var
|
|
770
|
-
|
|
771
|
-
const json = opts.json === true;
|
|
772
|
-
const out = json ? process.stderr : process.stdout;
|
|
1250
|
+
var SIM_TIMEOUT_MS = 25 * 60 * 1000;
|
|
1251
|
+
var SIM_RUNTIME_TIMEOUT_MS = 45 * 60 * 1000;
|
|
1252
|
+
function simRequest(opts) {
|
|
773
1253
|
if (opts.runner !== undefined && !opts.platform)
|
|
774
1254
|
throw new CliError("Use --platform ios or android with --runner.");
|
|
775
1255
|
const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
1256
|
+
const flags = [
|
|
1257
|
+
opts.device !== undefined && "--device",
|
|
1258
|
+
opts.os !== undefined && "--os",
|
|
1259
|
+
opts.foldable === true && "--foldable",
|
|
1260
|
+
opts.xcode !== undefined && "--xcode"
|
|
1261
|
+
].filter((flag) => typeof flag === "string");
|
|
1262
|
+
if (flags.length && !opts.platform)
|
|
1263
|
+
throw new CliError(`Use --platform ios or android with ${flags.join(", ")}.`);
|
|
779
1264
|
let device;
|
|
780
1265
|
try {
|
|
781
|
-
device =
|
|
1266
|
+
device = flags.length ? validateDeviceProfile({
|
|
1267
|
+
model: opts.device,
|
|
1268
|
+
os: opts.os,
|
|
1269
|
+
foldable: opts.foldable || undefined,
|
|
1270
|
+
xcode: opts.xcode
|
|
1271
|
+
}, opts.platform) ?? undefined : undefined;
|
|
782
1272
|
} catch (err) {
|
|
783
1273
|
throw new CliError(err.message);
|
|
784
1274
|
}
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
if (run.state === "live" && run.tunnelUrl && run.daemonToken) {
|
|
811
|
-
live = { run, tunnelUrl: run.tunnelUrl, daemonToken: run.daemonToken };
|
|
812
|
-
break;
|
|
813
|
-
}
|
|
814
|
-
if (run.state === "failed" || run.state === "ended" || run.state === "closing") {
|
|
815
|
-
spinner.fail(`Run ${run.state}`);
|
|
816
|
-
throw new CliError(`The run ${run.state} before coming online (${run.endReason ?? "no reason given"}). Try \`simbox sim --new\`.`);
|
|
817
|
-
}
|
|
818
|
-
spinner.set(`Run ${run.state}`);
|
|
819
|
-
}
|
|
820
|
-
if (!live) {
|
|
821
|
-
spinner.fail("Timed out");
|
|
822
|
-
throw new CliError("Run didn't come online within 12 minutes. Check the workflow run in your repo's Actions tab, then try `simbox sim` again (or `--new`).");
|
|
823
|
-
}
|
|
824
|
-
spinner.succeed("Run is live");
|
|
825
|
-
info = {
|
|
826
|
-
runId: live.run.id,
|
|
827
|
-
tunnelUrl: live.tunnelUrl,
|
|
828
|
-
daemonToken: live.daemonToken,
|
|
829
|
-
expiresAt: live.run.expiresAt
|
|
830
|
-
};
|
|
1275
|
+
const timeoutMs = opts.platform === "ios" && (device?.foldable || device?.xcode) ? SIM_RUNTIME_TIMEOUT_MS : SIM_TIMEOUT_MS;
|
|
1276
|
+
return { platform: opts.platform, runner, device, timeoutMs };
|
|
1277
|
+
}
|
|
1278
|
+
function simTimeoutMessage(timeoutMs) {
|
|
1279
|
+
return `Run didn't come online within ${Math.round(timeoutMs / 60000)} minutes. Check the workflow run in your repo's Actions tab, then try \`simbox sim\` again (or \`--new\`).`;
|
|
1280
|
+
}
|
|
1281
|
+
async function cmdSim(opts, deps = {}) {
|
|
1282
|
+
const json = opts.json === true;
|
|
1283
|
+
const all = { ...defaultRunsDeps, ...deps };
|
|
1284
|
+
const request = simRequest(opts);
|
|
1285
|
+
const first = await ensureRun(opts.new === true, request.platform, request.runner, request.device);
|
|
1286
|
+
const spinner = new Spinner(`Run ${first.state}`);
|
|
1287
|
+
spinner.begin();
|
|
1288
|
+
let ready;
|
|
1289
|
+
try {
|
|
1290
|
+
ready = await waitForRun(first.runId, {
|
|
1291
|
+
timeoutMs: request.timeoutMs,
|
|
1292
|
+
timeoutMessage: simTimeoutMessage(request.timeoutMs),
|
|
1293
|
+
onUpdate: (run2) => spinner.set(`${PLATFORM_NAMES[run2.platform]} run ${run2.state === "live" ? "live, waiting for devices" : run2.state}`),
|
|
1294
|
+
deps: all
|
|
1295
|
+
});
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
spinner.fail("Run did not come online");
|
|
1298
|
+
throw err;
|
|
831
1299
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1300
|
+
spinner.succeed(`${PLATFORM_NAMES[ready.run.platform]} run is live`);
|
|
1301
|
+
const { run } = ready;
|
|
1302
|
+
const info = await waitForRemote(ready.info, {
|
|
1303
|
+
probe: all.probe,
|
|
1304
|
+
refresh: () => refreshPlatform(run.platform, all)
|
|
1305
|
+
});
|
|
1306
|
+
all.writeCache(run.platform, info);
|
|
1307
|
+
registerDevice(info, { platform: run.platform });
|
|
837
1308
|
if (json) {
|
|
838
1309
|
const payload = {
|
|
839
1310
|
tunnel_url: info.tunnelUrl,
|
|
840
1311
|
daemon_token: info.daemonToken,
|
|
841
1312
|
run_id: info.runId,
|
|
842
|
-
expires_at: info.expiresAt
|
|
1313
|
+
expires_at: info.expiresAt,
|
|
1314
|
+
platform: run.platform,
|
|
1315
|
+
booted_device: run.bootedDevice ?? null
|
|
843
1316
|
};
|
|
844
1317
|
process.stdout.write(JSON.stringify(payload) + `
|
|
845
1318
|
`);
|
|
846
1319
|
return;
|
|
847
1320
|
}
|
|
848
|
-
|
|
1321
|
+
process.stdout.write(`${PLATFORM_NAMES[run.platform]} run ${pc3.bold(info.runId)} is ${pc3.green("live")}` + (info.expiresAt ? ` — expires in ${fmtCountdown(info.expiresAt)}` : "") + `
|
|
849
1322
|
|
|
850
1323
|
`);
|
|
851
|
-
connectFlow(info,
|
|
1324
|
+
connectFlow(info, { platform: run.platform, bootedDevice: run.bootedDevice });
|
|
852
1325
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
console.log(`${pc3.bold("run")} ${run.id}`);
|
|
861
|
-
console.log(` state: ${stateColor(run.state)}`);
|
|
862
|
-
console.log(` platform: ${run.platform}`);
|
|
1326
|
+
function stateColor(state) {
|
|
1327
|
+
return state === "live" ? pc3.green : state === "failed" ? pc3.red : pc3.yellow;
|
|
1328
|
+
}
|
|
1329
|
+
function printRun(run) {
|
|
1330
|
+
const booted = run.bootedDevice;
|
|
1331
|
+
console.log(`${pc3.bold(PLATFORM_NAMES[run.platform])} run ${run.id}`);
|
|
1332
|
+
console.log(` state: ${stateColor(run.state)(run.state)}`);
|
|
863
1333
|
console.log(` runner: ${formatRunner(run.runner)}`);
|
|
864
|
-
console.log(`
|
|
865
|
-
console.log(`
|
|
1334
|
+
console.log(` requested: ${formatDeviceProfile(run.device ?? null, run.platform)}`);
|
|
1335
|
+
console.log(` booted: ${booted ? `${booted.name} (${run.platform === "ios" ? "iOS" : "API"} ${booted.os}${booted.id ? `, ${booted.id}` : ""})` : pc3.dim(run.devicesReady ? "unknown" : "not yet")}`);
|
|
1336
|
+
console.log(` devices_ready: ${run.devicesReady ? "yes" : "no"}`);
|
|
866
1337
|
console.log(` active_devices: ${run.activeDevices}`);
|
|
867
|
-
console.log(`
|
|
1338
|
+
console.log(` gh_run_id: ${run.ghRunId ?? pc3.dim("pending")}`);
|
|
868
1339
|
console.log(` expires: ${run.expiresAt ? fmtCountdown(run.expiresAt) : pc3.dim("n/a")}`);
|
|
869
1340
|
if (run.endReason)
|
|
870
1341
|
console.log(` end_reason: ${run.endReason}`);
|
|
871
1342
|
}
|
|
872
|
-
async function
|
|
873
|
-
const
|
|
874
|
-
if (
|
|
875
|
-
console.log(
|
|
1343
|
+
async function cmdPs(opts = {}) {
|
|
1344
|
+
const runs = await activeRuns();
|
|
1345
|
+
if (opts.json) {
|
|
1346
|
+
console.log(JSON.stringify({ runs }));
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
if (!runs.length) {
|
|
1350
|
+
console.log(`No active runs. Start one with ${pc3.cyan("simbox sim --platform ios|android")}.`);
|
|
876
1351
|
return;
|
|
877
1352
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1353
|
+
runs.forEach((run, i) => {
|
|
1354
|
+
if (i)
|
|
1355
|
+
console.log("");
|
|
1356
|
+
printRun(run);
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
async function cmdStop(opts = {}) {
|
|
1360
|
+
if (opts.all && opts.platform)
|
|
1361
|
+
throw new CliError("Use either --platform or --all, not both.");
|
|
1362
|
+
const active = await activeRuns();
|
|
1363
|
+
let targets;
|
|
1364
|
+
if (opts.all)
|
|
1365
|
+
targets = active;
|
|
1366
|
+
else if (opts.platform)
|
|
1367
|
+
targets = active.filter((run) => run.platform === opts.platform);
|
|
1368
|
+
else if (!active.length)
|
|
1369
|
+
targets = [];
|
|
1370
|
+
else {
|
|
1371
|
+
const platform2 = targetPlatform(undefined, active, "Pass --platform ios|android to stop one, or --all to stop both.");
|
|
1372
|
+
targets = active.filter((run) => run.platform === platform2);
|
|
1373
|
+
}
|
|
1374
|
+
if (!targets.length) {
|
|
1375
|
+
if (opts.platform)
|
|
1376
|
+
clearCachedRun(opts.platform);
|
|
1377
|
+
console.log(opts.platform ? `No active ${PLATFORM_NAMES[opts.platform]} run to stop.` : "No active run to stop.");
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
for (const run of targets) {
|
|
1381
|
+
await stopRun(run.id);
|
|
1382
|
+
clearCachedRun(run.platform);
|
|
1383
|
+
if (run.tunnelUrl && run.daemonToken)
|
|
1384
|
+
registerDevice({
|
|
1385
|
+
runId: run.id,
|
|
1386
|
+
tunnelUrl: run.tunnelUrl,
|
|
1387
|
+
daemonToken: run.daemonToken,
|
|
1388
|
+
expiresAt: run.expiresAt
|
|
1389
|
+
}, { stop: true, platform: run.platform });
|
|
1390
|
+
console.log(`${pc3.green("✔")} Stop requested for the ${PLATFORM_NAMES[run.platform]} run ${pc3.bold(run.id)}.`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function printRepoStatus(status) {
|
|
1394
|
+
const repo = status.repo;
|
|
1395
|
+
if (!status.connected || !repo) {
|
|
1396
|
+
console.log(`No repo connected. Run ${pc3.cyan("simbox init")}.`);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const color = repo.state === "ok" ? pc3.green : pc3.yellow;
|
|
1400
|
+
console.log(`Repo ${pc3.bold(repo.fullName)}: ${color(repo.state)}${repo.workflowVersion ? pc3.dim(` (workflow v${repo.workflowVersion})`) : ""}`);
|
|
1401
|
+
if (repo.state === "pending_pr")
|
|
1402
|
+
console.log(repo.prUrl ? `The default branch is protected — merge ${pc3.underline(repo.prUrl)} to apply the workflow.` : "The default branch is protected — merge the Simbox pull request to apply the workflow.");
|
|
1403
|
+
else if (repo.state === "ok" && repo.prUrl)
|
|
1404
|
+
console.log(`The default branch is protected — merge ${pc3.underline(repo.prUrl)} to apply the update. The current workflow keeps working meanwhile.`);
|
|
1405
|
+
else if (repo.state === "ok")
|
|
1406
|
+
console.log("Workflow up to date and installation verified (the repo secret rotates only while no run is active).");
|
|
1407
|
+
else if (repo.state === "needs_repair")
|
|
1408
|
+
console.log("The repo still needs repair; check the GitHub App installation and permissions.");
|
|
1409
|
+
else if (repo.state === "uninstalled")
|
|
1410
|
+
console.log(`The GitHub App is not installed. Run ${pc3.cyan("simbox init")}.`);
|
|
888
1411
|
}
|
|
889
1412
|
async function cmdRepair() {
|
|
890
1413
|
const spinner = new Spinner("Repairing repo");
|
|
891
1414
|
spinner.begin();
|
|
892
|
-
|
|
1415
|
+
let status;
|
|
1416
|
+
try {
|
|
1417
|
+
status = await repairRepo();
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
spinner.fail("Repair failed");
|
|
1420
|
+
throw err;
|
|
1421
|
+
}
|
|
893
1422
|
spinner.succeed("Repair complete");
|
|
894
|
-
|
|
895
|
-
console.log(res.message);
|
|
896
|
-
else
|
|
897
|
-
console.log("Workflow re-committed, secret rotated, installation verified.");
|
|
1423
|
+
printRepoStatus(status);
|
|
898
1424
|
}
|
|
899
1425
|
async function cmdLogout() {
|
|
900
1426
|
try {
|
|
@@ -905,6 +1431,114 @@ async function cmdLogout() {
|
|
|
905
1431
|
}
|
|
906
1432
|
}
|
|
907
1433
|
|
|
1434
|
+
// src/remote.ts
|
|
1435
|
+
import { spawn } from "node:child_process";
|
|
1436
|
+
import { join as join4 } from "node:path";
|
|
1437
|
+
var STARTUP_TIMEOUT_MS = 300000;
|
|
1438
|
+
function remoteArgs(args, info, stateDir) {
|
|
1439
|
+
const separator = args.indexOf("--");
|
|
1440
|
+
const result = separator < 0 ? [...args] : args.slice(0, separator);
|
|
1441
|
+
const tail = separator < 0 ? [] : args.slice(separator);
|
|
1442
|
+
if (["open", "boot", "prepare"].includes(args[0] ?? "") && !hasFlag(args, "--timeout")) {
|
|
1443
|
+
result.push("--timeout", String(STARTUP_TIMEOUT_MS));
|
|
1444
|
+
}
|
|
1445
|
+
if (!hasFlag(args, "--session"))
|
|
1446
|
+
result.push("--session", `simbox-${info.runId}`);
|
|
1447
|
+
result.push("--state-dir", stateDir, "--daemon-base-url", daemonBaseUrl(info.tunnelUrl), "--daemon-transport", "http");
|
|
1448
|
+
return [...result, ...tail];
|
|
1449
|
+
}
|
|
1450
|
+
var REPLAY_SAFE = new Set([
|
|
1451
|
+
"devices",
|
|
1452
|
+
"snapshot",
|
|
1453
|
+
"screenshot",
|
|
1454
|
+
"fold",
|
|
1455
|
+
"session",
|
|
1456
|
+
"appstate",
|
|
1457
|
+
"apps",
|
|
1458
|
+
"get",
|
|
1459
|
+
"is"
|
|
1460
|
+
]);
|
|
1461
|
+
var TRANSPORT_ERROR = /Remote daemon is unavailable|Daemon request timed out|Failed to read daemon response|fetch failed|ECONNRESET|socket hang up/;
|
|
1462
|
+
function shouldReplay(args, code, output) {
|
|
1463
|
+
return code !== 0 && REPLAY_SAFE.has(args[0] ?? "") && TRANSPORT_ERROR.test(output);
|
|
1464
|
+
}
|
|
1465
|
+
var REPLAY_ATTEMPTS = 3;
|
|
1466
|
+
function hasFlag(args, flag) {
|
|
1467
|
+
const end = args.indexOf("--");
|
|
1468
|
+
return (end < 0 ? args : args.slice(0, end)).some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
1469
|
+
}
|
|
1470
|
+
async function cmdExec(args) {
|
|
1471
|
+
if (!args.length)
|
|
1472
|
+
throw new CliError("Usage: simbox exec <agent-device command> [arguments]");
|
|
1473
|
+
const reserved = [
|
|
1474
|
+
"--daemon-base-url",
|
|
1475
|
+
"--daemon-auth-token",
|
|
1476
|
+
"--daemon-transport",
|
|
1477
|
+
"--remote-config",
|
|
1478
|
+
"--state-dir"
|
|
1479
|
+
];
|
|
1480
|
+
if (reserved.some((flag) => hasFlag(args, flag))) {
|
|
1481
|
+
throw new CliError("Simbox manages the remote URL, credentials and state directory for `exec`.");
|
|
1482
|
+
}
|
|
1483
|
+
if (["connect", "disconnect", "proxy", "auth"].includes(args[0])) {
|
|
1484
|
+
throw new CliError("Use device commands with `simbox exec`, not connection/auth commands.");
|
|
1485
|
+
}
|
|
1486
|
+
const bin = findAgentDevice();
|
|
1487
|
+
if (!bin)
|
|
1488
|
+
throw new CliError("Install agent-device first: npm i -g agent-device@0.21.28");
|
|
1489
|
+
const { platform: platform2, info } = await connectForArgs(args);
|
|
1490
|
+
const replaySafe = REPLAY_SAFE.has(args[0]);
|
|
1491
|
+
let connected = info;
|
|
1492
|
+
let result = await runRemote(bin, args, connected, replaySafe);
|
|
1493
|
+
for (let attempt = 1;attempt < REPLAY_ATTEMPTS; attempt++) {
|
|
1494
|
+
if (!shouldReplay(args, result.code, result.output))
|
|
1495
|
+
break;
|
|
1496
|
+
process.stderr.write(`Tunnel dropped during \`${args[0]}\`; retrying (${attempt}/${REPLAY_ATTEMPTS - 1}).
|
|
1497
|
+
`);
|
|
1498
|
+
await sleep(attempt * 5000);
|
|
1499
|
+
if (!await probeRemote(connected)) {
|
|
1500
|
+
const refreshed = await refreshPlatform(platform2);
|
|
1501
|
+
if (!refreshed || refreshed.runId !== connected.runId)
|
|
1502
|
+
break;
|
|
1503
|
+
connected = await waitForRemote(refreshed, { refresh: () => refreshPlatform(platform2) });
|
|
1504
|
+
writeCachedRun(platform2, connected);
|
|
1505
|
+
}
|
|
1506
|
+
result = await runRemote(bin, args, connected, replaySafe);
|
|
1507
|
+
}
|
|
1508
|
+
if (replaySafe) {
|
|
1509
|
+
process.stdout.write(result.stdout);
|
|
1510
|
+
process.stderr.write(result.stderr);
|
|
1511
|
+
}
|
|
1512
|
+
const code = result.code;
|
|
1513
|
+
if (code === 0 && !args.some((arg) => ["help", "--help", "-h", "--version", "version"].includes(arg)))
|
|
1514
|
+
registerDevice(connected, { args, platform: platform2 });
|
|
1515
|
+
if (code !== 0 && !replaySafe) {
|
|
1516
|
+
process.stderr.write("Simbox did not retry the device command (it may already have executed). Use `simbox exec session list` to inspect it; the next command refreshes the tunnel automatically.\n");
|
|
1517
|
+
}
|
|
1518
|
+
process.exitCode = code;
|
|
1519
|
+
}
|
|
1520
|
+
async function runRemote(bin, args, info, capture) {
|
|
1521
|
+
const env = {
|
|
1522
|
+
...process.env,
|
|
1523
|
+
AGENT_DEVICE_DAEMON_AUTH_TOKEN: info.daemonToken
|
|
1524
|
+
};
|
|
1525
|
+
delete env.AGENT_DEVICE_REMOTE_CONFIG;
|
|
1526
|
+
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
|
|
1527
|
+
let stdout = "";
|
|
1528
|
+
let stderr = "";
|
|
1529
|
+
const code = await new Promise((resolve, reject) => {
|
|
1530
|
+
const child = spawn(bin, remoteArgs(args, info, join4(configDir(), "agent-device")), {
|
|
1531
|
+
stdio: capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
1532
|
+
env
|
|
1533
|
+
});
|
|
1534
|
+
child.stdout?.on("data", (chunk) => stdout += chunk.toString());
|
|
1535
|
+
child.stderr?.on("data", (chunk) => stderr += chunk.toString());
|
|
1536
|
+
child.on("error", reject);
|
|
1537
|
+
child.on("close", (status) => resolve(status ?? 1));
|
|
1538
|
+
});
|
|
1539
|
+
return { code, output: stdout + stderr, stdout, stderr };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
908
1542
|
// src/runners.ts
|
|
909
1543
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
910
1544
|
function printRunnerSettings(settings, json = false) {
|
|
@@ -917,10 +1551,25 @@ function printRunnerSettings(settings, json = false) {
|
|
|
917
1551
|
console.log(`${platform2}: ${formatRunner(settings.effective[platform2].runner)} (${settings.effective[platform2].source})`);
|
|
918
1552
|
console.log(` account: ${settings.account[platform2] ? formatRunner(settings.account[platform2]) : "GitHub default"}`);
|
|
919
1553
|
console.log(` repository: ${settings.repository?.[platform2] ? formatRunner(settings.repository[platform2]) : "inherit account"}`);
|
|
1554
|
+
const device = settings.devices?.effective?.[platform2];
|
|
1555
|
+
if (device)
|
|
1556
|
+
console.log(` device: ${formatLayeredDevice(device, platform2)}`);
|
|
920
1557
|
}
|
|
921
1558
|
console.log("Settings apply to future runs only. Run `simbox repair` once to upgrade older workflows.");
|
|
1559
|
+
console.log("Device defaults: `simbox devices set <platform> --device <model> --os <version> --scope account|repo`.");
|
|
922
1560
|
console.log("Blacksmith requires its GitHub App on the repository and bills separately. Custom runners need Linux x64/KVM or macOS ARM64/Xcode.");
|
|
923
1561
|
}
|
|
1562
|
+
function formatLayeredDevice(layered, platform2) {
|
|
1563
|
+
if (!layered.device)
|
|
1564
|
+
return "runner default";
|
|
1565
|
+
const sources = Object.entries(layered.sources).filter(([, source]) => source !== undefined).map(([field, source]) => `${field}: ${source}`);
|
|
1566
|
+
const unset = [
|
|
1567
|
+
layered.device.model === undefined && !layered.device.foldable && "model",
|
|
1568
|
+
layered.device.os === undefined && "os"
|
|
1569
|
+
].filter((field) => typeof field === "string");
|
|
1570
|
+
const parts = [...sources, ...unset.map((field) => `${field}: runner default`)];
|
|
1571
|
+
return `${formatDeviceProfile(layered.device, platform2)} (${parts.join(", ")})`;
|
|
1572
|
+
}
|
|
924
1573
|
async function cmdRunnersShow(json = false) {
|
|
925
1574
|
printRunnerSettings(await runnerSettings(), json);
|
|
926
1575
|
}
|
|
@@ -1039,7 +1688,290 @@ async function cmdSkillsInstall(opts) {
|
|
|
1039
1688
|
});
|
|
1040
1689
|
}
|
|
1041
1690
|
|
|
1691
|
+
// src/devices.ts
|
|
1692
|
+
import pc4 from "picocolors";
|
|
1693
|
+
function runnerArg(runner) {
|
|
1694
|
+
return runner.labels.length === 1 && /^[\w.-]+$/.test(runner.labels[0]) ? runner.labels[0] : `'${JSON.stringify(runner.labels)}'`;
|
|
1695
|
+
}
|
|
1696
|
+
var MAX_LISTED_MODELS = 6;
|
|
1697
|
+
function formatInventory(res) {
|
|
1698
|
+
const { platform: platform2, runner } = res;
|
|
1699
|
+
const lines = [`${pc4.bold(PLATFORM_NAMES[platform2])} on ${formatRunner(runner)}`];
|
|
1700
|
+
const inv = res.inventory;
|
|
1701
|
+
if (!inv) {
|
|
1702
|
+
lines.push(" No device catalog yet. A runner reports what it offers on its first run; start one with:", ` ${pc4.cyan(`simbox sim --platform ${platform2} --runner ${runnerArg(runner)}`)}`);
|
|
1703
|
+
return lines;
|
|
1704
|
+
}
|
|
1705
|
+
lines.push(pc4.dim(` reported by ${formatRunner(inv.runner)} ${fmtAgo(inv.reportedAt)}`));
|
|
1706
|
+
const names = new Map(inv.models.map((model) => [model.id, model.name]));
|
|
1707
|
+
lines.push(` ${platform2 === "ios" ? "iOS versions" : "Android API levels"} (newest first):`);
|
|
1708
|
+
for (const os of [...inv.os].sort((a, b) => compareVersions(b.version, a.version))) {
|
|
1709
|
+
const marks = os.version === inv.defaults.os ? pc4.green(" (default)") : "";
|
|
1710
|
+
let runs = "";
|
|
1711
|
+
if (os.models && os.models.length < inv.models.length) {
|
|
1712
|
+
const listed = os.models.map((id) => names.get(id) ?? id);
|
|
1713
|
+
runs = pc4.dim(` — ${listed.slice(0, MAX_LISTED_MODELS).join(", ")}${listed.length > MAX_LISTED_MODELS ? `, +${listed.length - MAX_LISTED_MODELS} more` : ""}`);
|
|
1714
|
+
}
|
|
1715
|
+
lines.push(` ${os.version.padEnd(8)} ${os.name}${marks}${runs}`);
|
|
1716
|
+
}
|
|
1717
|
+
lines.push(" Models:");
|
|
1718
|
+
for (const model of inv.models) {
|
|
1719
|
+
const isDefault = model.id === inv.defaults.model || model.name === inv.defaults.model;
|
|
1720
|
+
lines.push(` ${model.name}${platform2 === "android" && model.id !== model.name ? pc4.dim(` (${model.id})`) : ""}${model.foldable ? " [foldable]" : ""}${isDefault ? pc4.green(" (default)") : ""}`);
|
|
1721
|
+
}
|
|
1722
|
+
if (inv.xcode?.length)
|
|
1723
|
+
lines.push(` Xcode: ${inv.xcode.join(", ")}`);
|
|
1724
|
+
lines.push(pc4.dim(` e.g. simbox sim --platform ${platform2} --device ${JSON.stringify(inv.models.find((m) => !m.foldable)?.name ?? inv.defaults.model)} --os ${inv.defaults.os}`));
|
|
1725
|
+
return lines;
|
|
1726
|
+
}
|
|
1727
|
+
async function cmdDevicesList(opts, read = inventory) {
|
|
1728
|
+
if (opts.runner !== undefined && !opts.platform)
|
|
1729
|
+
throw new CliError("Use --platform ios or android with --runner.");
|
|
1730
|
+
const runner = opts.runner === undefined ? undefined : parseRunnerInput(opts.runner, opts.platform);
|
|
1731
|
+
const platforms = opts.platform ? [opts.platform] : PLATFORMS;
|
|
1732
|
+
const results = await Promise.all(platforms.map((platform2) => read(platform2, runner)));
|
|
1733
|
+
if (opts.json) {
|
|
1734
|
+
console.log(JSON.stringify(Object.fromEntries(results.map((res) => [res.platform, res]))));
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
console.log(results.map((res) => formatInventory(res).join(`
|
|
1738
|
+
`)).join(`
|
|
1739
|
+
|
|
1740
|
+
`));
|
|
1741
|
+
}
|
|
1742
|
+
async function cmdDevicesSet(platform2, opts, scope, json = false) {
|
|
1743
|
+
let profile = null;
|
|
1744
|
+
if (opts) {
|
|
1745
|
+
profile = parseDevicePatch({
|
|
1746
|
+
[platform2]: {
|
|
1747
|
+
...opts.device !== undefined ? { model: opts.device } : {},
|
|
1748
|
+
...opts.os !== undefined ? { os: opts.os } : {},
|
|
1749
|
+
...opts.foldable ? { foldable: true } : {}
|
|
1750
|
+
}
|
|
1751
|
+
})[platform2];
|
|
1752
|
+
if (!profile)
|
|
1753
|
+
throw new CliError(`Pass --device, --os or --foldable. To inherit instead, run \`simbox devices reset ${platform2} --scope ${scope}\`.`);
|
|
1754
|
+
}
|
|
1755
|
+
printRunnerSettings(await updateDevices(scope, { [platform2]: profile }), json);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// src/workflow.ts
|
|
1759
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
1760
|
+
import { mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1761
|
+
import { tmpdir } from "node:os";
|
|
1762
|
+
import { join as join5 } from "node:path";
|
|
1763
|
+
import pc5 from "picocolors";
|
|
1764
|
+
var defaultWorkflowDeps = {
|
|
1765
|
+
get: getWorkflow,
|
|
1766
|
+
put: putWorkflow,
|
|
1767
|
+
openEditor,
|
|
1768
|
+
log: (line) => console.log(line)
|
|
1769
|
+
};
|
|
1770
|
+
var withDefaults2 = (deps = {}) => ({
|
|
1771
|
+
...defaultWorkflowDeps,
|
|
1772
|
+
...deps
|
|
1773
|
+
});
|
|
1774
|
+
function edit(fn) {
|
|
1775
|
+
try {
|
|
1776
|
+
return fn();
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
throw new CliError(err.message);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
var STATUS_TEXT = {
|
|
1782
|
+
current: "current — the committed file matches these steps",
|
|
1783
|
+
outdated: "outdated — run `simbox repair` to upgrade it",
|
|
1784
|
+
drifted: "differs from these steps — a pending pull request or an edit outside Simbox",
|
|
1785
|
+
missing: "missing — run `simbox repair` to commit it"
|
|
1786
|
+
};
|
|
1787
|
+
function oneLine(text, max = 60) {
|
|
1788
|
+
const first = text.trim().split(`
|
|
1789
|
+
`)[0];
|
|
1790
|
+
const more = first.length > max || text.trim().includes(`
|
|
1791
|
+
`);
|
|
1792
|
+
return `${first.slice(0, max)}${more ? "…" : ""}`;
|
|
1793
|
+
}
|
|
1794
|
+
function describeStep(step) {
|
|
1795
|
+
const parts = [];
|
|
1796
|
+
if (step.platform)
|
|
1797
|
+
parts.push(`[${step.platform} only]`);
|
|
1798
|
+
if (step.if)
|
|
1799
|
+
parts.push(`if: ${step.if}`);
|
|
1800
|
+
if (step.uses) {
|
|
1801
|
+
const inputs = step.with ? Object.keys(step.with) : [];
|
|
1802
|
+
parts.push(`uses: ${step.uses}${inputs.length ? ` (with ${inputs.join(", ")})` : ""}`);
|
|
1803
|
+
} else if (step.run !== undefined)
|
|
1804
|
+
parts.push(`run: ${oneLine(step.run)}`);
|
|
1805
|
+
if (step.env)
|
|
1806
|
+
parts.push(`env: ${Object.keys(step.env).join(", ")}`);
|
|
1807
|
+
return parts.join(" ");
|
|
1808
|
+
}
|
|
1809
|
+
function formatWorkflow(res) {
|
|
1810
|
+
const lines = [
|
|
1811
|
+
`${pc5.bold(WORKFLOW_PATH)}: ${res.status ? STATUS_TEXT[res.status] : pc5.yellow("unknown — GitHub could not be read")}`,
|
|
1812
|
+
`Repo state: ${res.repoState === "ok" ? pc5.green("ok") : pc5.yellow(res.repoState)}`
|
|
1813
|
+
];
|
|
1814
|
+
if (res.prUrl)
|
|
1815
|
+
lines.push(`Merge ${pc5.underline(res.prUrl)} to apply these steps (the default branch is protected).`);
|
|
1816
|
+
else if (res.repoState === "pending_pr")
|
|
1817
|
+
lines.push("Merge the Simbox pull request to apply the workflow (the default branch is protected).");
|
|
1818
|
+
lines.push("", "Steps:");
|
|
1819
|
+
const width = Math.max(...res.spec.steps.map((s) => (isLockedStep(s) ? s.simbox : s.id).length));
|
|
1820
|
+
res.spec.steps.forEach((step, i) => {
|
|
1821
|
+
const n = `${String(i + 1).padStart(2)}.`;
|
|
1822
|
+
if (isLockedStep(step)) {
|
|
1823
|
+
lines.push(`${n} ${pc5.dim("locked")} ${step.simbox.padEnd(width)} ${SIMBOX_STEPS[step.simbox].name}`);
|
|
1824
|
+
} else {
|
|
1825
|
+
const detail = describeStep(step);
|
|
1826
|
+
lines.push(`${n} ${step.id.padEnd(width)} ${step.name}${detail ? pc5.dim(` ${detail}`) : ""}`);
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
if (res.spec.permissions)
|
|
1830
|
+
lines.push("", `Permissions: ${Object.entries(res.spec.permissions).map(([scope, level]) => `${scope}: ${level}`).join(", ")}`);
|
|
1831
|
+
return lines;
|
|
1832
|
+
}
|
|
1833
|
+
async function cmdWorkflowShow(opts, deps = {}) {
|
|
1834
|
+
const d = withDefaults2(deps);
|
|
1835
|
+
if (opts.yaml && opts.json)
|
|
1836
|
+
throw new CliError("Choose either --yaml or --json.");
|
|
1837
|
+
const res = await d.get();
|
|
1838
|
+
if (opts.json)
|
|
1839
|
+
d.log(JSON.stringify(res));
|
|
1840
|
+
else if (opts.yaml)
|
|
1841
|
+
d.log(res.yaml.replace(/\n$/, ""));
|
|
1842
|
+
else
|
|
1843
|
+
for (const line of formatWorkflow(res))
|
|
1844
|
+
d.log(line);
|
|
1845
|
+
}
|
|
1846
|
+
async function save(spec, d, json = false) {
|
|
1847
|
+
const res = await d.put(spec);
|
|
1848
|
+
if (json) {
|
|
1849
|
+
d.log(JSON.stringify(res));
|
|
1850
|
+
return res;
|
|
1851
|
+
}
|
|
1852
|
+
d.log(`${pc5.green("✔")} Workflow saved.`);
|
|
1853
|
+
for (const line of formatWorkflow(res))
|
|
1854
|
+
d.log(line);
|
|
1855
|
+
return res;
|
|
1856
|
+
}
|
|
1857
|
+
function stepPosition(opts) {
|
|
1858
|
+
if (opts.before !== undefined && opts.after !== undefined)
|
|
1859
|
+
throw new CliError("Choose either --before or --after.");
|
|
1860
|
+
if (opts.after !== undefined)
|
|
1861
|
+
return { after: opts.after };
|
|
1862
|
+
return { before: opts.before ?? "start" };
|
|
1863
|
+
}
|
|
1864
|
+
function keyValues(pairs, flag) {
|
|
1865
|
+
if (!pairs?.length)
|
|
1866
|
+
return;
|
|
1867
|
+
const out = {};
|
|
1868
|
+
for (const pair of pairs) {
|
|
1869
|
+
const eq = pair.indexOf("=");
|
|
1870
|
+
if (eq <= 0)
|
|
1871
|
+
throw new CliError(`${flag} takes key=value (got "${pair}").`);
|
|
1872
|
+
out[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1873
|
+
}
|
|
1874
|
+
return out;
|
|
1875
|
+
}
|
|
1876
|
+
function buildStep(spec, opts) {
|
|
1877
|
+
if (opts.run === undefined === (opts.uses === undefined))
|
|
1878
|
+
throw new CliError("Pass exactly one of --run <script> or --uses <action>.");
|
|
1879
|
+
if (opts.with?.length && opts.uses === undefined)
|
|
1880
|
+
throw new CliError("--with only applies to --uses steps.");
|
|
1881
|
+
const inputs = keyValues(opts.with, "--with");
|
|
1882
|
+
const env = keyValues(opts.env, "--env");
|
|
1883
|
+
return {
|
|
1884
|
+
id: opts.id ?? suggestStepId(spec, opts.name),
|
|
1885
|
+
name: opts.name,
|
|
1886
|
+
...opts.run !== undefined ? { run: opts.run } : { uses: opts.uses },
|
|
1887
|
+
...inputs ? { with: inputs } : {},
|
|
1888
|
+
...env ? { env } : {},
|
|
1889
|
+
...opts.if !== undefined ? { if: opts.if } : {},
|
|
1890
|
+
...opts.platform ? { platform: opts.platform } : {}
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
async function cmdWorkflowAdd(opts, deps = {}) {
|
|
1894
|
+
const d = withDefaults2(deps);
|
|
1895
|
+
const position = stepPosition(opts);
|
|
1896
|
+
const { spec } = await d.get();
|
|
1897
|
+
const step = buildStep(spec, opts);
|
|
1898
|
+
await save(edit(() => insertStep(spec, step, position)), d, opts.json);
|
|
1899
|
+
}
|
|
1900
|
+
async function cmdWorkflowRemove(id, opts = {}, deps = {}) {
|
|
1901
|
+
const d = withDefaults2(deps);
|
|
1902
|
+
const { spec } = await d.get();
|
|
1903
|
+
await save(edit(() => removeStep(spec, id)), d, opts.json);
|
|
1904
|
+
}
|
|
1905
|
+
async function cmdWorkflowMove(id, opts, deps = {}) {
|
|
1906
|
+
const d = withDefaults2(deps);
|
|
1907
|
+
if (opts.before === undefined && opts.after === undefined)
|
|
1908
|
+
throw new CliError("Pass --before <id> or --after <id>.");
|
|
1909
|
+
const position = stepPosition(opts);
|
|
1910
|
+
const { spec } = await d.get();
|
|
1911
|
+
await save(edit(() => moveStep(spec, id, position)), d, opts.json);
|
|
1912
|
+
}
|
|
1913
|
+
function parseSpecText(text, source) {
|
|
1914
|
+
let value;
|
|
1915
|
+
try {
|
|
1916
|
+
value = JSON.parse(text);
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
throw new CliError(`${source} is not valid JSON: ${err.message}`);
|
|
1919
|
+
}
|
|
1920
|
+
if (value && typeof value === "object" && "spec" in value && !("steps" in value))
|
|
1921
|
+
value = value.spec;
|
|
1922
|
+
return edit(() => validateWorkflowSpec(value));
|
|
1923
|
+
}
|
|
1924
|
+
async function cmdWorkflowApply(file, opts = {}, deps = {}) {
|
|
1925
|
+
const d = withDefaults2(deps);
|
|
1926
|
+
let text;
|
|
1927
|
+
try {
|
|
1928
|
+
text = readFileSync3(file, "utf8");
|
|
1929
|
+
} catch (err) {
|
|
1930
|
+
throw new CliError(`Could not read ${file}: ${err.message}`);
|
|
1931
|
+
}
|
|
1932
|
+
await save(parseSpecText(text, file), d, opts.json);
|
|
1933
|
+
}
|
|
1934
|
+
async function cmdWorkflowEdit(opts = {}, deps = {}) {
|
|
1935
|
+
const d = withDefaults2(deps);
|
|
1936
|
+
const { spec } = await d.get();
|
|
1937
|
+
const original = `${JSON.stringify(spec, null, 2)}
|
|
1938
|
+
`;
|
|
1939
|
+
const dir = mkdtempSync(join5(tmpdir(), "simbox-workflow-"));
|
|
1940
|
+
const file = join5(dir, "simbox-workflow.json");
|
|
1941
|
+
writeFileSync4(file, original, { mode: 384 });
|
|
1942
|
+
let keep = false;
|
|
1943
|
+
try {
|
|
1944
|
+
d.openEditor(file);
|
|
1945
|
+
const text = readFileSync3(file, "utf8");
|
|
1946
|
+
if (text === original) {
|
|
1947
|
+
d.log("No changes.");
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
let next;
|
|
1951
|
+
try {
|
|
1952
|
+
next = parseSpecText(text, "The edited workflow");
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
keep = true;
|
|
1955
|
+
throw new CliError(`${err.message}
|
|
1956
|
+
Your edit is kept at ${file}; fix it and run \`simbox workflow apply ${file}\`.`);
|
|
1957
|
+
}
|
|
1958
|
+
await save(next, d, opts.json);
|
|
1959
|
+
} finally {
|
|
1960
|
+
if (!keep)
|
|
1961
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
function openEditor(file) {
|
|
1965
|
+
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
|
1966
|
+
const result = process.platform === "win32" ? spawnSync3(editor, [file], { stdio: "inherit", shell: true }) : spawnSync3("sh", ["-c", `${editor} "$1"`, "sh", file], { stdio: "inherit" });
|
|
1967
|
+
if (result.error || result.status !== 0)
|
|
1968
|
+
throw new CliError(`Editor "${editor}" ${result.error ? `failed to start (${result.error.message})` : `exited with code ${result.status}`}; nothing was saved.`);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1042
1971
|
// src/cli.ts
|
|
1972
|
+
var platformOption = (description) => new Option("--platform <platform>", description).choices(["ios", "android"]);
|
|
1973
|
+
var scopeOption = () => new Option("--scope <scope>", "account defaults or connected repository override").choices(["account", "repo"]).makeOptionMandatory();
|
|
1974
|
+
var collect = (value, previous = []) => [...previous, value];
|
|
1043
1975
|
var program = new Command().enablePositionalOptions();
|
|
1044
1976
|
program.name("simbox").description("On-demand iOS simulators & Android emulators on your own GitHub Actions minutes.").version(getVersion());
|
|
1045
1977
|
program.command("login").description("Authenticate via device flow").action(() => run(cmdLogin()));
|
|
@@ -1047,26 +1979,23 @@ program.command("init").description("Open onboarding and wait until a repo is co
|
|
|
1047
1979
|
requireToken();
|
|
1048
1980
|
return run(cmdInit());
|
|
1049
1981
|
});
|
|
1050
|
-
program.command("sim").description("Ensure a run and its tunnel are ready for device commands").option("--new", "
|
|
1051
|
-
"ios",
|
|
1052
|
-
"android"
|
|
1053
|
-
])).option("--runner <label-or-json>", "override runner label or JSON label array; requires --platform").option("--foldable", "boot a foldable: iPhone Duo (needs Xcode 27.1+, e.g. --runner xcode-27) or a 7.6in Android foldable; requires --platform").option("--xcode <version>", "iOS: select this Xcode (e.g. 27.1) before the device daemon starts").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at} as JSON only").action((opts) => {
|
|
1982
|
+
program.command("sim").description("Ensure a run and its tunnel are ready for device commands").option("--new", "replace this platform's run with a fresh one").addOption(platformOption("ios: macOS runner; android: Linux/KVM runner (omitted: reuse the only active run, else iOS)")).option("--runner <label-or-json>", "override runner label or JSON label array; requires --platform").option("--device <model>", 'simulator/emulator model, e.g. "iPhone 16 Pro" or pixel_8 (see `simbox devices list`); requires --platform').option("--os <version>", "iOS version (18.4, 18), Android API level (35), latest or oldest; requires --platform").option("--foldable", "boot a foldable: iPhone Duo (needs Xcode 27.1+, e.g. --runner xcode-27) or a 7.6in Android foldable; requires --platform").option("--xcode <version>", "iOS: select this Xcode (e.g. 27.1) before the device daemon starts").option("--json", "print {tunnel_url, daemon_token, run_id, expires_at, platform, booted_device} as JSON only").action((opts) => {
|
|
1054
1983
|
requireToken();
|
|
1055
1984
|
return run(cmdSim(opts));
|
|
1056
1985
|
});
|
|
1057
|
-
program.command("exec").description("Run agent-device with fresh tunnel credentials and a cold-boot startup budget").argument("[args...]", "agent-device command and arguments").allowUnknownOption().allowExcessArguments().passThroughOptions().action((args) => {
|
|
1986
|
+
program.command("exec").description("Run agent-device with fresh tunnel credentials and a cold-boot startup budget; --platform picks the run when both are active").argument("[args...]", "agent-device command and arguments").allowUnknownOption().allowExcessArguments().passThroughOptions().action((args) => {
|
|
1058
1987
|
requireToken();
|
|
1059
1988
|
return run(cmdExec(args));
|
|
1060
1989
|
});
|
|
1061
|
-
program.command("ps").description("Show
|
|
1990
|
+
program.command("ps").description("Show active runs (at most one per platform)").option("--json", "print {runs} as JSON").action((opts) => {
|
|
1062
1991
|
requireToken();
|
|
1063
|
-
return run(cmdPs());
|
|
1992
|
+
return run(cmdPs(opts));
|
|
1064
1993
|
});
|
|
1065
|
-
program.command("stop").description("
|
|
1994
|
+
program.command("stop").description("Stop the active run (--platform picks one when both are active)").addOption(platformOption("stop this platform's run")).option("--all", "stop every active run").action((opts) => {
|
|
1066
1995
|
requireToken();
|
|
1067
|
-
return run(cmdStop());
|
|
1996
|
+
return run(cmdStop(opts));
|
|
1068
1997
|
});
|
|
1069
|
-
program.command("repair").description("Re-commit the workflow, rotate the repo secret
|
|
1998
|
+
program.command("repair").description("Re-commit the workflow, re-verify the installation, and rotate the repo secret when no run is active").action(() => {
|
|
1070
1999
|
requireToken();
|
|
1071
2000
|
return run(cmdRepair());
|
|
1072
2001
|
});
|
|
@@ -1085,7 +2014,7 @@ for (const command of ["set", "reset"]) {
|
|
|
1085
2014
|
const cmd = runners2.command(command).addArgument(new Argument("<platform>").choices(["ios", "android"]));
|
|
1086
2015
|
if (command === "set")
|
|
1087
2016
|
cmd.argument("<label-or-json>", "runner label or JSON label array");
|
|
1088
|
-
cmd.addOption(
|
|
2017
|
+
cmd.addOption(scopeOption()).option("--json", "print settings as JSON");
|
|
1089
2018
|
if (command === "set")
|
|
1090
2019
|
cmd.action((platform2, input, opts) => {
|
|
1091
2020
|
requireToken();
|
|
@@ -1097,6 +2026,44 @@ for (const command of ["set", "reset"]) {
|
|
|
1097
2026
|
return run(cmdRunnersSet(platform2, null, opts.scope, opts.json));
|
|
1098
2027
|
});
|
|
1099
2028
|
}
|
|
2029
|
+
var devices = program.command("devices").description("Device models and OS versions a runner offers, and your device defaults");
|
|
2030
|
+
devices.command("list").description("Show the last catalog a runner reported (models, OS versions, Xcode)").addOption(platformOption("only this platform")).option("--runner <label-or-json>", "a runner other than the effective one; requires --platform").option("--json", "print {ios?, android?} inventory responses as JSON").action((opts) => {
|
|
2031
|
+
requireToken();
|
|
2032
|
+
return run(cmdDevicesList(opts));
|
|
2033
|
+
});
|
|
2034
|
+
devices.command("set").description("Set the default device model and/or OS for future runs").addArgument(new Argument("<platform>").choices(["ios", "android"])).option("--device <model>", 'model, e.g. "iPhone 16 Pro" or pixel_8').option("--os <version>", "iOS version, Android API level, latest or oldest").option("--foldable", "default to a foldable instead of a model").addOption(scopeOption()).option("--json", "print settings as JSON").action((platform2, opts) => {
|
|
2035
|
+
requireToken();
|
|
2036
|
+
return run(cmdDevicesSet(platform2, opts, opts.scope, opts.json));
|
|
2037
|
+
});
|
|
2038
|
+
devices.command("reset").description("Clear a device default so the scope inherits").addArgument(new Argument("<platform>").choices(["ios", "android"])).addOption(scopeOption()).option("--json", "print settings as JSON").action((platform2, opts) => {
|
|
2039
|
+
requireToken();
|
|
2040
|
+
return run(cmdDevicesSet(platform2, null, opts.scope, opts.json));
|
|
2041
|
+
});
|
|
2042
|
+
var workflow2 = program.command("workflow").description("Show and edit the steps of .github/workflows/simbox.yml");
|
|
2043
|
+
workflow2.command("show").description("List steps (prepare/start/serve are locked) and the committed file's status").option("--yaml", "print the rendered YAML").option("--json", "print {spec, yaml, status, repoState, prUrl} as JSON").action((opts) => {
|
|
2044
|
+
requireToken();
|
|
2045
|
+
return run(cmdWorkflowShow(opts));
|
|
2046
|
+
});
|
|
2047
|
+
workflow2.command("add").description("Add a step (default: before the locked `start` step, i.e. before devices boot)").requiredOption("--name <name>", "step name").option("--run <script>", "shell script to run").option("--uses <action>", "action to use, e.g. actions/checkout@v4").option("--with <key=value>", "action input (repeatable)", collect).option("--env <key=value>", "environment variable (repeatable)", collect).option("--if <expression>", "GitHub Actions condition").addOption(platformOption("only run this step for one platform")).option("--id <id>", "step id (default: derived from --name)").option("--before <id>", "insert before this step").option("--after <id>", "insert after this step").option("--json", "print the saved workflow as JSON").action((opts) => {
|
|
2048
|
+
requireToken();
|
|
2049
|
+
return run(cmdWorkflowAdd(opts));
|
|
2050
|
+
});
|
|
2051
|
+
workflow2.command("remove").description("Remove a custom step").argument("<id>", "step id").option("--json", "print the saved workflow as JSON").action((id, opts) => {
|
|
2052
|
+
requireToken();
|
|
2053
|
+
return run(cmdWorkflowRemove(id, opts));
|
|
2054
|
+
});
|
|
2055
|
+
workflow2.command("move").description("Move a custom step before or after another step").argument("<id>", "step id").option("--before <id>", "move before this step").option("--after <id>", "move after this step").option("--json", "print the saved workflow as JSON").action((id, opts) => {
|
|
2056
|
+
requireToken();
|
|
2057
|
+
return run(cmdWorkflowMove(id, opts));
|
|
2058
|
+
});
|
|
2059
|
+
workflow2.command("edit").description("Edit the steps as JSON in $VISUAL/$EDITOR, then save").option("--json", "print the saved workflow as JSON").action((opts) => {
|
|
2060
|
+
requireToken();
|
|
2061
|
+
return run(cmdWorkflowEdit(opts));
|
|
2062
|
+
});
|
|
2063
|
+
workflow2.command("apply").description("Save a workflow spec from a JSON file ({steps, permissions?})").argument("<file>", "JSON file").option("--json", "print the saved workflow as JSON").action((file, opts) => {
|
|
2064
|
+
requireToken();
|
|
2065
|
+
return run(cmdWorkflowApply(file, opts));
|
|
2066
|
+
});
|
|
1100
2067
|
program.command("skills").description("Set up the small Simbox agent skill").command("install").description("Install via bunx skills; asks current project or global").option("--project", "install to the current project").option("--global", "install to your user account").option("--agent <agents...>", "target skills CLI agent IDs; otherwise let the installer ask").option("--yes", "accept installer confirmations; requires explicit scope and agents").option("--dry-run", "print the command without installing").action((opts) => run(cmdSkillsInstall(opts)));
|
|
1101
2068
|
function getVersion() {
|
|
1102
2069
|
try {
|
|
@@ -1112,13 +2079,13 @@ async function run(p) {
|
|
|
1112
2079
|
await p;
|
|
1113
2080
|
} catch (err) {
|
|
1114
2081
|
const error = friendlyError(err);
|
|
1115
|
-
process.stderr.write(`${
|
|
2082
|
+
process.stderr.write(`${pc6.red("✖")} ${error.message}
|
|
1116
2083
|
`);
|
|
1117
2084
|
process.exitCode = error.exitCode;
|
|
1118
2085
|
}
|
|
1119
2086
|
}
|
|
1120
2087
|
program.parseAsync(process.argv).catch((err) => {
|
|
1121
|
-
process.stderr.write(`${
|
|
2088
|
+
process.stderr.write(`${pc6.red("✖")} ${friendlyError(err).message}
|
|
1122
2089
|
`);
|
|
1123
2090
|
process.exitCode = 1;
|
|
1124
2091
|
});
|