native-sim 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +395 -0
- package/bin/native-sim.js +8 -0
- package/package.json +31 -0
- package/src/cli.js +151 -0
- package/src/commands/doctor.js +54 -0
- package/src/commands/down.js +48 -0
- package/src/commands/init.js +64 -0
- package/src/commands/r2.js +62 -0
- package/src/commands/status.js +30 -0
- package/src/commands/turn.js +83 -0
- package/src/commands/up.js +288 -0
- package/src/commands/upload.js +60 -0
- package/src/lib/gh.js +170 -0
- package/src/lib/ghrelease.js +50 -0
- package/src/lib/git.js +68 -0
- package/src/lib/proc.js +37 -0
- package/src/lib/project.js +35 -0
- package/src/lib/r2.js +94 -0
- package/src/lib/session.js +24 -0
- package/src/lib/ui.js +44 -0
- package/templates/gate.cjs +184 -0
- package/templates/native-sim.yml +654 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as gh from '../lib/gh.js';
|
|
2
|
+
import { loadSession, clearSession } from '../lib/session.js';
|
|
3
|
+
import { info, ok, warn, dim } from '../lib/ui.js';
|
|
4
|
+
|
|
5
|
+
const WORKFLOW = 'native-sim.yml';
|
|
6
|
+
|
|
7
|
+
export async function down(cwd, flags) {
|
|
8
|
+
gh.requireAuth();
|
|
9
|
+
|
|
10
|
+
// The session file only ever remembers the most recent `up`, so --all is the
|
|
11
|
+
// only way to catch sessions started before this one.
|
|
12
|
+
if (flags.all) {
|
|
13
|
+
const runs = gh.inFlightRuns(cwd, WORKFLOW);
|
|
14
|
+
if (runs.length === 0) {
|
|
15
|
+
info('no native-sim runs in flight');
|
|
16
|
+
clearSession(cwd);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (const run of runs) {
|
|
20
|
+
if (gh.cancelRun(cwd, run.databaseId)) ok(`cancelled ${run.databaseId} ${dim(run.displayTitle ?? '')}`);
|
|
21
|
+
else warn(`could not cancel ${run.databaseId} — ${run.url}`);
|
|
22
|
+
}
|
|
23
|
+
clearSession(cwd);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const session = loadSession(cwd);
|
|
28
|
+
if (!session) {
|
|
29
|
+
info(`no native-sim session recorded ${dim('(try: native-sim down --all)')}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const run = gh.getRun(cwd, session.runId);
|
|
34
|
+
if (run?.status === 'completed') {
|
|
35
|
+
info(`run already finished (${run.conclusion})`);
|
|
36
|
+
} else if (gh.cancelRun(cwd, session.runId)) {
|
|
37
|
+
ok(`cancelled run ${session.runId} — the stream and the runner shut down together`);
|
|
38
|
+
} else {
|
|
39
|
+
warn(`could not cancel run ${session.runId}; cancel it at ${session.url}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const others = gh.inFlightRuns(cwd, WORKFLOW).filter((r) => r.databaseId !== session.runId);
|
|
43
|
+
if (others.length) {
|
|
44
|
+
warn(`${others.length} other native-sim run(s) still in flight — ${dim('native-sim down --all')}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clearSession(cwd);
|
|
48
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { assertExpoProject } from '../lib/project.js';
|
|
5
|
+
import { GITIGNORE } from '../lib/git.js';
|
|
6
|
+
import { ok, info, warn, dim } from '../lib/ui.js';
|
|
7
|
+
|
|
8
|
+
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
|
|
9
|
+
|
|
10
|
+
export const WORKFLOW_PATH = '.github/workflows/native-sim.yml';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Templates carry a version stamp so an out-of-date file (which must be
|
|
14
|
+
* refreshed, or the CLI will dispatch inputs the deployed workflow does not
|
|
15
|
+
* declare) can be told apart from one the user deliberately customised.
|
|
16
|
+
*/
|
|
17
|
+
const VERSION_RE = /native-sim-template-version:\s*(\d+)/;
|
|
18
|
+
const versionOf = (text) => Number(text.match(VERSION_RE)?.[1] ?? 0);
|
|
19
|
+
export const GATE_PATH = '.github/native-sim/gate.cjs';
|
|
20
|
+
|
|
21
|
+
/** Writes the workflow + gate into the project. Returns true if anything changed. */
|
|
22
|
+
export function scaffold(cwd, { force = false } = {}) {
|
|
23
|
+
let changed = false;
|
|
24
|
+
|
|
25
|
+
for (const [rel, src] of [[WORKFLOW_PATH, 'native-sim.yml'], [GATE_PATH, 'gate.cjs']]) {
|
|
26
|
+
const dest = join(cwd, rel);
|
|
27
|
+
const template = readFileSync(join(TEMPLATES, src), 'utf8');
|
|
28
|
+
if (existsSync(dest) && !force) {
|
|
29
|
+
const existing = readFileSync(dest, 'utf8');
|
|
30
|
+
if (existing === template) continue;
|
|
31
|
+
|
|
32
|
+
const [have, want] = [versionOf(existing), versionOf(template)];
|
|
33
|
+
if (have >= want) {
|
|
34
|
+
warn(`${rel} differs from the bundled template ${dim('(native-sim init --force to overwrite)')}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
38
|
+
writeFileSync(dest, template);
|
|
39
|
+
ok(`updated ${rel} ${dim(`(template v${have} → v${want})`)}`);
|
|
40
|
+
changed = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
44
|
+
writeFileSync(dest, template);
|
|
45
|
+
ok(`wrote ${rel}`);
|
|
46
|
+
changed = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const gitignore = join(cwd, '.gitignore');
|
|
50
|
+
if (!existsSync(gitignore)) {
|
|
51
|
+
writeFileSync(gitignore, GITIGNORE);
|
|
52
|
+
ok('wrote .gitignore');
|
|
53
|
+
changed = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return changed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function init(cwd, flags) {
|
|
60
|
+
assertExpoProject(cwd);
|
|
61
|
+
const changed = scaffold(cwd, { force: flags.force });
|
|
62
|
+
if (!changed) info('already initialized — nothing to do');
|
|
63
|
+
console.log(`\nNext: ${dim('native-sim up')}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import * as r2 from '../lib/r2.js';
|
|
3
|
+
import { ok, info, warn, step, bold, dim, cyan } from '../lib/ui.js';
|
|
4
|
+
|
|
5
|
+
/** Reads a line; when `hidden`, suppresses echo so secrets stay off the screen. */
|
|
6
|
+
function prompt(question, { hidden = false } = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
9
|
+
if (hidden) {
|
|
10
|
+
rl._writeToOutput = (chunk) => {
|
|
11
|
+
if (chunk.includes(question)) rl.output.write(question);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
if (hidden) process.stdout.write('\n');
|
|
17
|
+
resolve(answer.trim());
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function r2Setup(cwd, flags) {
|
|
23
|
+
if (flags.status) {
|
|
24
|
+
const config = r2.loadConfig();
|
|
25
|
+
const set = (v) => (v ? cyan('set') : dim('unset'));
|
|
26
|
+
console.log(`\n ${bold('config')} ${r2.configPath()}`);
|
|
27
|
+
console.log(` account ${config.accountId ?? dim('unset')}`);
|
|
28
|
+
console.log(` bucket ${config.bucket ?? dim('unset')}`);
|
|
29
|
+
console.log(` key id ${set(config.accessKeyId)}`);
|
|
30
|
+
console.log(` secret ${set(config.secretAccessKey)}`);
|
|
31
|
+
if (config.accountId && config.bucket && config.accessKeyId && config.secretAccessKey) {
|
|
32
|
+
console.log(` reachable ${r2.bucketExists(config) ? cyan('yes') : dim('no')}`);
|
|
33
|
+
}
|
|
34
|
+
console.log('');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
step('Configure R2');
|
|
39
|
+
console.log(dim(' Create an R2 API token with Object Read & Write at:'));
|
|
40
|
+
console.log(dim(' https://dash.cloudflare.com -> R2 -> Manage API Tokens'));
|
|
41
|
+
console.log('');
|
|
42
|
+
|
|
43
|
+
const accountId = await prompt(' Cloudflare account ID: ');
|
|
44
|
+
const bucket = (await prompt(' Bucket name [native-sim-builds]: ')) || 'native-sim-builds';
|
|
45
|
+
const accessKeyId = await prompt(' R2 access key ID: ');
|
|
46
|
+
const secretAccessKey = await prompt(' R2 secret access key: ', { hidden: true });
|
|
47
|
+
|
|
48
|
+
const config = { accountId, bucket, accessKeyId, secretAccessKey };
|
|
49
|
+
r2.assertConfigured(config);
|
|
50
|
+
|
|
51
|
+
if (r2.bucketExists(config)) {
|
|
52
|
+
info(`bucket ${bold(bucket)} already exists`);
|
|
53
|
+
} else {
|
|
54
|
+
r2.createBucket(config);
|
|
55
|
+
ok(`created bucket ${bold(bucket)}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const path = r2.saveConfig(config);
|
|
59
|
+
ok(`saved ${path} ${dim('(0600)')}`);
|
|
60
|
+
warn('keep this file private -- it holds your R2 secret');
|
|
61
|
+
console.log(`\nNext: ${dim('native-sim upload ./MyApp.app')}`);
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as gh from '../lib/gh.js';
|
|
2
|
+
import { loadSession } from '../lib/session.js';
|
|
3
|
+
import { info, ok, warn, dim, bold, cyan, green, red } from '../lib/ui.js';
|
|
4
|
+
|
|
5
|
+
export async function status(cwd) {
|
|
6
|
+
const session = loadSession(cwd);
|
|
7
|
+
if (!session) {
|
|
8
|
+
info('no native-sim session recorded for this project');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const run = gh.getRun(cwd, session.runId);
|
|
13
|
+
const commitStatus = gh.readStatus(cwd, session.repo, session.sha, session.context);
|
|
14
|
+
const age = Math.round((Date.now() - session.startedAt) / 60000);
|
|
15
|
+
|
|
16
|
+
console.log('');
|
|
17
|
+
console.log(` ${bold('session')} ${session.session} ${dim(`· started ${age}m ago`)}`);
|
|
18
|
+
console.log(` ${bold('repo')} ${session.repo}`);
|
|
19
|
+
console.log(` ${bold('run')} ${cyan(session.url)}`);
|
|
20
|
+
console.log(` ${bold('state')} ${run ? `${run.status}${run.conclusion ? ` / ${run.conclusion}` : ''}` : dim('unknown')}`);
|
|
21
|
+
|
|
22
|
+
if (commitStatus?.state === 'success' && commitStatus.target_url) {
|
|
23
|
+
console.log(` ${bold('stream')} ${green('●')} ${commitStatus.target_url}`);
|
|
24
|
+
} else if (commitStatus) {
|
|
25
|
+
console.log(` ${bold('stream')} ${red('○')} ${commitStatus.description ?? commitStatus.state}`);
|
|
26
|
+
} else {
|
|
27
|
+
console.log(` ${bold('stream')} ${dim('not published yet')}`);
|
|
28
|
+
}
|
|
29
|
+
console.log('');
|
|
30
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import * as gh from '../lib/gh.js';
|
|
3
|
+
import { ok, info, warn, step, bold, dim, cyan } from '../lib/ui.js';
|
|
4
|
+
|
|
5
|
+
function prompt(question, { hidden = false } = {}) {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
8
|
+
if (hidden) {
|
|
9
|
+
rl._writeToOutput = (chunk) => {
|
|
10
|
+
if (chunk.includes(question)) rl.output.write(question);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
rl.question(question, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
if (hidden) process.stdout.write('\n');
|
|
16
|
+
resolve(answer.trim());
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KEYS = [
|
|
22
|
+
'NATIVE_SIM_TURN_KEY_ID',
|
|
23
|
+
'NATIVE_SIM_TURN_KEY_TOKEN',
|
|
24
|
+
'NATIVE_SIM_TURN_URL',
|
|
25
|
+
'NATIVE_SIM_TURN_USERNAME',
|
|
26
|
+
'NATIVE_SIM_TURN_CREDENTIAL',
|
|
27
|
+
'NATIVE_SIM_STUN_URL',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* TURN config lives in repo secrets, never workflow inputs: dispatch inputs are
|
|
32
|
+
* visible to anyone who can read the repo, and on a public repo that is everyone.
|
|
33
|
+
*
|
|
34
|
+
* With a Cloudflare Realtime key the runner mints a short-lived credential per
|
|
35
|
+
* session, so nothing long-lived is stored and a leak expires by itself.
|
|
36
|
+
*/
|
|
37
|
+
export async function turn(cwd, flags) {
|
|
38
|
+
gh.requireAuth();
|
|
39
|
+
const repo = gh.nameWithOwner(cwd);
|
|
40
|
+
if (!repo) throw new Error('no GitHub remote here — run native-sim up first');
|
|
41
|
+
|
|
42
|
+
if (flags.status) {
|
|
43
|
+
const names = gh.listSecrets(cwd);
|
|
44
|
+
console.log(`\n ${bold(repo)}`);
|
|
45
|
+
for (const k of KEYS) console.log(` ${names.includes(k) ? cyan('set ') : dim('unset')} ${k}`);
|
|
46
|
+
console.log('');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
step(`Configure TURN for ${bold(repo)}`);
|
|
51
|
+
console.log(dim(' A cloudflared quick tunnel carries no UDP, so WebRTC needs TURN.'));
|
|
52
|
+
console.log(dim(' STUN alone cannot traverse it.'));
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(' Cloudflare Realtime (recommended — credentials are minted per session):');
|
|
55
|
+
console.log(dim(' dash.cloudflare.com -> Realtime -> TURN Keys -> Create'));
|
|
56
|
+
console.log(dim(' then paste the Key ID and its API token below'));
|
|
57
|
+
console.log('');
|
|
58
|
+
console.log(dim(' Leave the Key ID blank to enter a static TURN URL instead.'));
|
|
59
|
+
console.log('');
|
|
60
|
+
|
|
61
|
+
const keyId = await prompt(' Realtime TURN Key ID (blank for static): ');
|
|
62
|
+
|
|
63
|
+
if (keyId) {
|
|
64
|
+
const token = await prompt(' TURN Key API token: ', { hidden: true });
|
|
65
|
+
if (!token) throw new Error('a TURN key API token is required');
|
|
66
|
+
gh.setSecret(cwd, 'NATIVE_SIM_TURN_KEY_ID', keyId);
|
|
67
|
+
gh.setSecret(cwd, 'NATIVE_SIM_TURN_KEY_TOKEN', token);
|
|
68
|
+
ok(`stored Realtime TURN key on ${repo}`);
|
|
69
|
+
info('the runner mints a 2h credential per session; nothing long-lived is kept');
|
|
70
|
+
} else {
|
|
71
|
+
const url = await prompt(' TURN URL (turn:host:3478): ');
|
|
72
|
+
if (!url) throw new Error('a TURN URL is required');
|
|
73
|
+
const username = await prompt(' TURN username: ');
|
|
74
|
+
const credential = await prompt(' TURN credential: ', { hidden: true });
|
|
75
|
+
gh.setSecret(cwd, 'NATIVE_SIM_TURN_URL', url);
|
|
76
|
+
if (username) gh.setSecret(cwd, 'NATIVE_SIM_TURN_USERNAME', username);
|
|
77
|
+
if (credential) gh.setSecret(cwd, 'NATIVE_SIM_TURN_CREDENTIAL', credential);
|
|
78
|
+
ok(`stored static TURN credentials on ${repo}`);
|
|
79
|
+
warn('static credentials do not expire — prefer a Realtime key');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(`\nNext: ${dim('native-sim up --transport webrtc --public')}`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import * as git from '../lib/git.js';
|
|
5
|
+
import * as gh from '../lib/gh.js';
|
|
6
|
+
import { sh, sleep, open as openUrl } from '../lib/proc.js';
|
|
7
|
+
import { assertExpoProject, defaultRepoName } from '../lib/project.js';
|
|
8
|
+
import { scaffold, WORKFLOW_PATH } from './init.js';
|
|
9
|
+
import { saveSession } from '../lib/session.js';
|
|
10
|
+
import * as r2 from '../lib/r2.js';
|
|
11
|
+
import * as ghrelease from '../lib/ghrelease.js';
|
|
12
|
+
import { info, ok, warn, step, spinner, bold, dim, cyan, green } from '../lib/ui.js';
|
|
13
|
+
|
|
14
|
+
const WORKFLOW = 'native-sim.yml';
|
|
15
|
+
|
|
16
|
+
export async function up(cwd, flags) {
|
|
17
|
+
const appFile = flags['app-file'];
|
|
18
|
+
const appRelease = flags['app-release'];
|
|
19
|
+
const mode = flags.app || appFile || appRelease ? 'app' : (flags.mode ?? 'build');
|
|
20
|
+
|
|
21
|
+
// R2 uploads happen before any git work, so a bad config fails fast rather
|
|
22
|
+
// than after a push and a dispatch. A GitHub-hosted upload cannot: it needs
|
|
23
|
+
// the repo to exist, so it runs further down, once `repo` is known.
|
|
24
|
+
let appUrl = flags.app ?? '';
|
|
25
|
+
let appReleaseAsset = appRelease && appRelease !== true ? appRelease : '';
|
|
26
|
+
if (appFile && flags.r2) {
|
|
27
|
+
const config = r2.loadConfig();
|
|
28
|
+
r2.assertConfigured(config);
|
|
29
|
+
step(`Uploading ${bold(appFile)} to R2`);
|
|
30
|
+
const uploaded = r2.upload(config, appFile, { expiresIn: 7200 });
|
|
31
|
+
appUrl = uploaded.url;
|
|
32
|
+
ok(`uploaded ${(uploaded.bytes / 1048576).toFixed(1)} MB ${dim('(presigned 2h)')}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// In app mode nothing is compiled from this checkout, so the directory does
|
|
36
|
+
// not have to be an Expo project — the repo only carries the workflow.
|
|
37
|
+
if (mode !== 'app') assertExpoProject(cwd);
|
|
38
|
+
gh.requireAuth();
|
|
39
|
+
|
|
40
|
+
step('Preparing repository');
|
|
41
|
+
|
|
42
|
+
if (!git.isRepo(cwd)) {
|
|
43
|
+
git.init(cwd);
|
|
44
|
+
ok('git init');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const branch = git.currentBranch(cwd);
|
|
48
|
+
const message = flags.message ?? `native-sim: ${new Date().toISOString()}`;
|
|
49
|
+
|
|
50
|
+
// Commit and push the app *before* scaffolding the workflow. GitHub does not
|
|
51
|
+
// scan the first push to a brand-new empty repo for workflow files, so a
|
|
52
|
+
// workflow shipped in that push is never registered with Actions.
|
|
53
|
+
if (git.isDirty(cwd) || !git.hasCommits(cwd)) {
|
|
54
|
+
git.commitAll(cwd, message);
|
|
55
|
+
ok(`committed on ${bold(branch)}`);
|
|
56
|
+
} else {
|
|
57
|
+
info(`working tree clean on ${bold(branch)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let repo = gh.nameWithOwner(cwd);
|
|
61
|
+
if (!repo) {
|
|
62
|
+
const name = flags.repo ?? defaultRepoName(cwd);
|
|
63
|
+
const isPublic = Boolean(flags.public);
|
|
64
|
+
step(`Creating ${isPublic ? 'public' : 'private'} repo ${bold(name)}`);
|
|
65
|
+
if (!isPublic) {
|
|
66
|
+
warn(`private repos bill macOS minutes at ${bold('10×')} — use ${dim('--public')} for unlimited free minutes`);
|
|
67
|
+
}
|
|
68
|
+
repo = gh.createRepo(cwd, name, { isPublic, branch });
|
|
69
|
+
ok(`created ${repo}`);
|
|
70
|
+
} else {
|
|
71
|
+
git.push(cwd, branch);
|
|
72
|
+
ok(`pushed to ${repo}`);
|
|
73
|
+
if (!gh.isPublicRepo(cwd)) {
|
|
74
|
+
warn(`${repo} is private — macOS minutes bill at ${bold('10×')} against your quota`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Second push. This one GitHub does scan for workflow files.
|
|
79
|
+
if (scaffold(cwd) || git.isDirty(cwd)) {
|
|
80
|
+
git.commitAll(cwd, 'native-sim: add simulator streaming workflow');
|
|
81
|
+
git.push(cwd, branch);
|
|
82
|
+
ok('pushed native-sim workflow');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Now that the repo exists, a local build can be hosted on its own release.
|
|
86
|
+
// The runner fetches it with the job's own token — no third-party account,
|
|
87
|
+
// and nothing publicly downloadable when the release stays a draft.
|
|
88
|
+
if (appFile && !flags.r2) {
|
|
89
|
+
step(`Uploading ${bold(appFile)} to ${bold(repo)}`);
|
|
90
|
+
const uploaded = ghrelease.upload(cwd, repo, appFile);
|
|
91
|
+
appReleaseAsset = uploaded.asset;
|
|
92
|
+
ok(`uploaded ${(uploaded.bytes / 1048576).toFixed(1)} MB ${dim(`as ${uploaded.asset}`)}`);
|
|
93
|
+
if (ghrelease.isPubliclyReadable(cwd, repo)) {
|
|
94
|
+
warn('published release on a public repo — the build is downloadable by anyone');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await assertWorkflowIsDispatchable(cwd, repo, branch);
|
|
99
|
+
await ensureRegistered(cwd, repo, branch);
|
|
100
|
+
|
|
101
|
+
const sha = git.headSha(cwd);
|
|
102
|
+
const session = randomBytes(4).toString('hex');
|
|
103
|
+
const gateToken = randomBytes(24).toString('base64url');
|
|
104
|
+
const context = `native-sim/${session}`;
|
|
105
|
+
|
|
106
|
+
step('Dispatching build');
|
|
107
|
+
await gh.dispatch(cwd, WORKFLOW, branch, {
|
|
108
|
+
session,
|
|
109
|
+
gate_token: gateToken,
|
|
110
|
+
minutes: String(flags.minutes ?? 30),
|
|
111
|
+
device: flags.device ?? 'iPhone 17 Pro',
|
|
112
|
+
mode,
|
|
113
|
+
app_url: appUrl,
|
|
114
|
+
app_release_asset: appReleaseAsset,
|
|
115
|
+
export_app: flags.export ? 'true' : 'false',
|
|
116
|
+
agent_device: flags.agent ? 'true' : 'false',
|
|
117
|
+
agent_device_version: agentDeviceVersion(),
|
|
118
|
+
transport: flags.transport ?? 'http',
|
|
119
|
+
codec: flags.codec ?? 'mjpeg',
|
|
120
|
+
max_dimension: String(flags['max-dimension'] ?? 900),
|
|
121
|
+
video_fps: String(flags.fps ?? 30),
|
|
122
|
+
video_quality: String(flags.quality ?? 0.7),
|
|
123
|
+
scheme: flags.scheme ?? '',
|
|
124
|
+
cache: flags.cache === false ? 'false' : 'true',
|
|
125
|
+
runner: flags.runner ?? 'macos-26',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const run = await waitForRun(cwd, session);
|
|
129
|
+
ok(`run ${cyan(run.url)}`);
|
|
130
|
+
saveSession(cwd, { session, runId: run.databaseId, sha, repo, context, url: run.url });
|
|
131
|
+
|
|
132
|
+
const base = await waitForStream(cwd, { repo, sha, context, runId: run.databaseId });
|
|
133
|
+
// The runner publishes only the bare tunnel URL; the key never leaves this machine.
|
|
134
|
+
const url = `${base.replace(/\/$/, '')}/?k=${gateToken}`;
|
|
135
|
+
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log(` ${green('●')} ${bold('Simulator is live')}`);
|
|
138
|
+
console.log(` ${url}`);
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(dim(` Anyone with that link can drive the simulator.`));
|
|
141
|
+
console.log(dim(` Stop it early with: native-sim down`));
|
|
142
|
+
console.log('');
|
|
143
|
+
|
|
144
|
+
if (flags.agent) printAgentConnect(base, gateToken);
|
|
145
|
+
|
|
146
|
+
if (flags.export) await exportArtifact(cwd, repo, run.databaseId, session, flags.out ?? process.cwd());
|
|
147
|
+
if (flags.open !== false) openUrl(url);
|
|
148
|
+
return url;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The proxied daemon and the local client should be the same build, so the
|
|
153
|
+
* runner installs whatever this machine already has rather than a floating
|
|
154
|
+
* latest. Falls back to the workflow's own default when agent-device is absent
|
|
155
|
+
* locally — the session still works, you just install the client afterwards.
|
|
156
|
+
*/
|
|
157
|
+
function agentDeviceVersion() {
|
|
158
|
+
const r = sh('agent-device', ['--version']);
|
|
159
|
+
const version = r.ok ? r.out.trim().split(/\s+/).pop() : '';
|
|
160
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : '0.20.1';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The agent-device proxy rides the same tunnel and the same token as the
|
|
165
|
+
* stream, so the connect line is derivable locally — nothing extra has to be
|
|
166
|
+
* published from the runner.
|
|
167
|
+
*/
|
|
168
|
+
function printAgentConnect(base, gateToken) {
|
|
169
|
+
const daemon = `${base.replace(/\/$/, '')}/agent-device`;
|
|
170
|
+
console.log(` ${bold('Drive it from an agent')} ${dim('(agent-device)')}`);
|
|
171
|
+
console.log('');
|
|
172
|
+
console.log(cyan(` agent-device connect proxy \\`));
|
|
173
|
+
console.log(cyan(` --daemon-base-url ${daemon} \\`));
|
|
174
|
+
console.log(cyan(` --daemon-auth-token ${gateToken}`));
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(dim(` Then: agent-device devices --platform ios`));
|
|
177
|
+
console.log(dim(` agent-device open <app-id> --platform ios`));
|
|
178
|
+
console.log(dim(` agent-device snapshot -i`));
|
|
179
|
+
console.log(dim(` Release it with: agent-device close && agent-device disconnect`));
|
|
180
|
+
console.log('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Waits for the build to upload the .app archive, then fetches it. */
|
|
184
|
+
async function exportArtifact(cwd, repo, runId, session, dir) {
|
|
185
|
+
const name = `native-sim-app-${session}`;
|
|
186
|
+
const spin = spinner('waiting for the app archive (built after the stream comes up)');
|
|
187
|
+
try {
|
|
188
|
+
await gh.waitForArtifact(cwd, repo, runId, name);
|
|
189
|
+
} finally {
|
|
190
|
+
spin.stop();
|
|
191
|
+
}
|
|
192
|
+
gh.downloadArtifact(cwd, runId, name, dir);
|
|
193
|
+
ok(`exported app archive to ${bold(dir)}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `workflow_dispatch` reads the workflow definition from the default branch, so
|
|
198
|
+
* a workflow that only exists on a feature branch silently isn't dispatchable.
|
|
199
|
+
*/
|
|
200
|
+
async function assertWorkflowIsDispatchable(cwd, repo, branch) {
|
|
201
|
+
const meta = gh.api(`repos/${repo}`);
|
|
202
|
+
const defaultBranch = meta?.default_branch ?? 'main';
|
|
203
|
+
if (branch === defaultBranch) return;
|
|
204
|
+
|
|
205
|
+
const onDefault = gh.api(`repos/${repo}/contents/${WORKFLOW_PATH}?ref=${defaultBranch}`);
|
|
206
|
+
if (onDefault) return;
|
|
207
|
+
|
|
208
|
+
throw new Error(
|
|
209
|
+
`${WORKFLOW_PATH} is not on the default branch (${defaultBranch}).\n` +
|
|
210
|
+
`GitHub only exposes workflow_dispatch for workflows present there.\n` +
|
|
211
|
+
`Merge ${branch} into ${defaultBranch}, or run native-sim from ${defaultBranch}.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Actions must register the workflow before it can be dispatched. If polling
|
|
217
|
+
* times out, nudge GitHub by making the workflow file part of a fresh push.
|
|
218
|
+
*/
|
|
219
|
+
async function ensureRegistered(cwd, repo, branch) {
|
|
220
|
+
const spin = spinner('waiting for GitHub to register the workflow');
|
|
221
|
+
try {
|
|
222
|
+
if (await gh.waitForWorkflowRegistration(cwd, repo, WORKFLOW_PATH, { timeoutMs: 120000 })) return;
|
|
223
|
+
|
|
224
|
+
spin.update('nudging GitHub to rescan the workflow');
|
|
225
|
+
const file = join(cwd, WORKFLOW_PATH);
|
|
226
|
+
const body = readFileSync(file, 'utf8').replace(/\n# native-sim-revision:.*\n$/, '\n');
|
|
227
|
+
writeFileSync(file, `${body}# native-sim-revision: ${Date.now()}\n`);
|
|
228
|
+
git.commitAll(cwd, 'native-sim: refresh workflow registration');
|
|
229
|
+
git.push(cwd, branch);
|
|
230
|
+
|
|
231
|
+
if (await gh.waitForWorkflowRegistration(cwd, repo, WORKFLOW_PATH, { timeoutMs: 120000 })) return;
|
|
232
|
+
} finally {
|
|
233
|
+
spin.stop();
|
|
234
|
+
}
|
|
235
|
+
throw new Error(
|
|
236
|
+
`GitHub never registered ${WORKFLOW_PATH}.\n` +
|
|
237
|
+
`Check that Actions is enabled: https://github.com/${repo}/settings/actions`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function waitForRun(cwd, session) {
|
|
242
|
+
const spin = spinner('waiting for GitHub to queue the run');
|
|
243
|
+
for (let i = 0; i < 40; i++) {
|
|
244
|
+
const run = gh.findRun(cwd, WORKFLOW, session);
|
|
245
|
+
if (run) {
|
|
246
|
+
spin.stop();
|
|
247
|
+
return run;
|
|
248
|
+
}
|
|
249
|
+
await sleep(3000);
|
|
250
|
+
}
|
|
251
|
+
spin.stop();
|
|
252
|
+
throw new Error('GitHub never queued the run. Check: gh run list --workflow native-sim.yml');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Poll the commit status the runner writes once the tunnel is up. */
|
|
256
|
+
async function waitForStream(cwd, { repo, sha, context, runId }) {
|
|
257
|
+
const spin = spinner('starting runner');
|
|
258
|
+
const deadline = Date.now() + 45 * 60 * 1000;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
while (Date.now() < deadline) {
|
|
262
|
+
const status = gh.readStatus(cwd, repo, sha, context);
|
|
263
|
+
if (status?.state === 'success' && status.target_url) return status.target_url;
|
|
264
|
+
|
|
265
|
+
const run = gh.getRun(cwd, runId);
|
|
266
|
+
if (run?.status === 'completed') {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Run finished (${run.conclusion}) without publishing a stream URL.\n` +
|
|
269
|
+
`Logs: gh run view ${runId} --log-failed`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
spin.update(describe(run));
|
|
273
|
+
await sleep(5000);
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
spin.stop();
|
|
277
|
+
}
|
|
278
|
+
throw new Error('Timed out after 45 minutes waiting for the stream URL.');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function describe(run) {
|
|
282
|
+
const job = run?.jobs?.[0];
|
|
283
|
+
if (!job) return 'waiting for a macOS runner';
|
|
284
|
+
const active = job.steps?.find((s) => s.status === 'in_progress');
|
|
285
|
+
const done = job.steps?.filter((s) => s.status === 'completed').length ?? 0;
|
|
286
|
+
const total = job.steps?.length ?? 0;
|
|
287
|
+
return active ? `${active.name} ${dim(`(${done}/${total})`)}` : `${job.status.replace('_', ' ')}`;
|
|
288
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as r2 from '../lib/r2.js';
|
|
2
|
+
import * as ghrelease from '../lib/ghrelease.js';
|
|
3
|
+
import * as gh from '../lib/gh.js';
|
|
4
|
+
import { ok, info, warn, step, bold, dim, cyan } from '../lib/ui.js';
|
|
5
|
+
|
|
6
|
+
const mb = (bytes) => `${(bytes / 1048576).toFixed(1)} MB`;
|
|
7
|
+
|
|
8
|
+
export async function upload(cwd, flags) {
|
|
9
|
+
const file = flags._positional?.[0];
|
|
10
|
+
if (!file) throw new Error('usage: native-sim upload <path to .app, .tar.gz or .zip>');
|
|
11
|
+
|
|
12
|
+
if (flags.r2) return uploadToR2(cwd, file, flags);
|
|
13
|
+
return uploadToGitHub(cwd, file);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default host. The runner's own GITHUB_TOKEN is already scoped to this repo,
|
|
18
|
+
* so an asset here needs no third-party account and no presigned URL that can
|
|
19
|
+
* expire mid-session — and on a private repo it is private for free.
|
|
20
|
+
*/
|
|
21
|
+
function uploadToGitHub(cwd, file) {
|
|
22
|
+
gh.requireAuth();
|
|
23
|
+
const repo = gh.nameWithOwner(cwd);
|
|
24
|
+
if (!repo) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
'No GitHub repo here yet. Run this from the project you stream, or create one first:\n' +
|
|
27
|
+
' gh repo create --source . --private',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
step(`Uploading ${bold(file)} to ${bold(repo)}`);
|
|
32
|
+
const result = ghrelease.upload(cwd, repo, file);
|
|
33
|
+
|
|
34
|
+
ok(`uploaded ${mb(result.bytes)} as ${dim(result.asset)}`);
|
|
35
|
+
if (ghrelease.isPubliclyReadable(cwd, repo)) {
|
|
36
|
+
warn('this release is published on a public repo — the build is downloadable by anyone');
|
|
37
|
+
} else {
|
|
38
|
+
info('draft release on this repo — only people who can read the repo can fetch it');
|
|
39
|
+
}
|
|
40
|
+
console.log('');
|
|
41
|
+
console.log(dim(` run it: native-sim up --app-release ${result.asset}`));
|
|
42
|
+
console.log('');
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function uploadToR2(cwd, file, flags) {
|
|
47
|
+
const config = r2.loadConfig();
|
|
48
|
+
r2.assertConfigured(config);
|
|
49
|
+
|
|
50
|
+
step(`Uploading ${bold(file)} to R2`);
|
|
51
|
+
const expiresIn = Number(flags.expires ?? 3600);
|
|
52
|
+
const result = r2.upload(config, file, { expiresIn });
|
|
53
|
+
|
|
54
|
+
ok(`uploaded ${mb(result.bytes)} as ${dim(result.key)}`);
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(` ${cyan(result.url)}`);
|
|
57
|
+
console.log('');
|
|
58
|
+
info(`presigned for ${Math.round(expiresIn / 60)} min -- fetch a fresh one per session`);
|
|
59
|
+
return result;
|
|
60
|
+
}
|