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/src/lib/gh.js ADDED
@@ -0,0 +1,170 @@
1
+ import { sh, shx } from './proc.js';
2
+
3
+ export function requireAuth() {
4
+ if (!sh('which', ['gh']).ok) {
5
+ throw new Error('GitHub CLI not found. Install it: brew install gh');
6
+ }
7
+ if (!sh('gh', ['auth', 'status']).ok) {
8
+ throw new Error('Not logged in to GitHub. Run: gh auth login');
9
+ }
10
+ }
11
+
12
+ /** `gh api <path>` returning parsed JSON. Returns null on failure. */
13
+ export function api(path, extra = []) {
14
+ const r = sh('gh', ['api', path, ...extra]);
15
+ if (!r.ok) return null;
16
+ try {
17
+ return JSON.parse(r.out);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /** owner/repo for the repo in `cwd`, or null when there is no remote yet. */
24
+ export function nameWithOwner(cwd) {
25
+ const r = sh('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { cwd });
26
+ return r.ok ? r.out : null;
27
+ }
28
+
29
+ export function createRepo(cwd, name, { isPublic, branch }) {
30
+ const args = [
31
+ 'repo', 'create', name,
32
+ isPublic ? '--public' : '--private',
33
+ '--source=.', '--remote=origin', '--push',
34
+ ];
35
+ shx('gh', args, { cwd });
36
+ // `--push` pushes the current branch; make sure upstream is set.
37
+ sh('git', ['branch', '--set-upstream-to', `origin/${branch}`, branch], { cwd });
38
+ return nameWithOwner(cwd);
39
+ }
40
+
41
+ export function repoExists(cwd) {
42
+ return nameWithOwner(cwd) !== null;
43
+ }
44
+
45
+ export function isPublicRepo(cwd) {
46
+ const r = sh('gh', ['repo', 'view', '--json', 'visibility', '-q', '.visibility'], { cwd });
47
+ return r.ok && r.out.toUpperCase() === 'PUBLIC';
48
+ }
49
+
50
+ /**
51
+ * GitHub indexes a newly pushed workflow asynchronously, so the very first
52
+ * dispatch after `gh repo create` routinely 404s for a few seconds.
53
+ */
54
+ export async function dispatch(cwd, workflow, ref, inputs, { attempts = 12 } = {}) {
55
+ const args = ['workflow', 'run', workflow, '--ref', ref];
56
+ for (const [k, v] of Object.entries(inputs)) args.push('-f', `${k}=${v}`);
57
+
58
+ let last;
59
+ for (let i = 0; i < attempts; i++) {
60
+ const r = sh('gh', args, { cwd });
61
+ if (r.ok) return;
62
+ last = r.err || r.out;
63
+ if (!/could not find|not found|404|does not exist/i.test(last)) break;
64
+ await new Promise((resolve) => setTimeout(resolve, 5000));
65
+ }
66
+ throw new Error(`Could not dispatch ${workflow} on ${ref}:\n${last}`);
67
+ }
68
+
69
+ /**
70
+ * The first push to a brand-new empty repo is not scanned for workflows, so a
71
+ * freshly created repo can hold a valid workflow that Actions has never seen.
72
+ * Poll until GitHub has actually registered it.
73
+ */
74
+ export async function waitForWorkflowRegistration(cwd, repo, path, { timeoutMs = 180000 } = {}) {
75
+ const deadline = Date.now() + timeoutMs;
76
+ while (Date.now() < deadline) {
77
+ const list = api(`repos/${repo}/actions/workflows`);
78
+ if (list?.workflows?.some((w) => w.path === path)) return true;
79
+ await new Promise((resolve) => setTimeout(resolve, 4000));
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** Find the run whose display title carries our session id. */
85
+ export function findRun(cwd, workflow, session) {
86
+ const r = sh('gh', [
87
+ 'run', 'list', '--workflow', workflow, '--limit', '25',
88
+ '--json', 'databaseId,displayTitle,status,conclusion,url,createdAt',
89
+ ], { cwd });
90
+ if (!r.ok) return null;
91
+ let runs;
92
+ try {
93
+ runs = JSON.parse(r.out);
94
+ } catch {
95
+ return null;
96
+ }
97
+ return runs.find((run) => run.displayTitle?.includes(session)) ?? null;
98
+ }
99
+
100
+ export function getRun(cwd, id) {
101
+ const r = sh('gh', [
102
+ 'run', 'view', String(id),
103
+ '--json', 'databaseId,status,conclusion,url,displayTitle,jobs',
104
+ ], { cwd });
105
+ if (!r.ok) return null;
106
+ try {
107
+ return JSON.parse(r.out);
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** Every run of `workflow` that is still queued or executing. */
114
+ export function inFlightRuns(cwd, workflow) {
115
+ const r = sh('gh', [
116
+ 'run', 'list', '--workflow', workflow, '--limit', '50',
117
+ '--json', 'databaseId,status,displayTitle,url',
118
+ ], { cwd });
119
+ if (!r.ok) return [];
120
+ try {
121
+ return JSON.parse(r.out).filter((run) => run.status !== 'completed');
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ /** Artifacts are queryable as soon as their upload step finishes, mid-job. */
128
+ export async function waitForArtifact(cwd, repo, runId, name, { timeoutMs = 45 * 60 * 1000 } = {}) {
129
+ const deadline = Date.now() + timeoutMs;
130
+ while (Date.now() < deadline) {
131
+ const list = api(`repos/${repo}/actions/runs/${runId}/artifacts`);
132
+ const hit = list?.artifacts?.find((a) => a.name === name && !a.expired);
133
+ if (hit) return hit;
134
+ const run = getRun(cwd, runId);
135
+ if (run?.status === 'completed' && run.conclusion !== 'success') {
136
+ throw new Error(`run finished (${run.conclusion}) without producing ${name}`);
137
+ }
138
+ await new Promise((resolve) => setTimeout(resolve, 10000));
139
+ }
140
+ throw new Error(`timed out waiting for artifact ${name}`);
141
+ }
142
+
143
+ export function downloadArtifact(cwd, runId, name, dir) {
144
+ shx('gh', ['run', 'download', String(runId), '-n', name, '-D', dir], { cwd });
145
+ }
146
+
147
+ /** Sets a repo secret. The value goes over stdin so it never lands in argv or shell history. */
148
+ export function setSecret(cwd, name, value) {
149
+ const r = sh('gh', ['secret', 'set', name], { cwd, input: value });
150
+ if (!r.ok) throw new Error(`could not set ${name}: ${r.err || r.out}`);
151
+ }
152
+
153
+ export function listSecrets(cwd) {
154
+ const r = sh('gh', ['secret', 'list', '--json', 'name', '-q', '.[].name'], { cwd });
155
+ return r.ok ? r.out.split('\n').filter(Boolean) : [];
156
+ }
157
+
158
+ export function cancelRun(cwd, id) {
159
+ return sh('gh', ['run', 'cancel', String(id)], { cwd }).ok;
160
+ }
161
+
162
+ /**
163
+ * The runner publishes the tunnel URL as a commit status, which is readable
164
+ * live over the API (unlike logs or artifacts, which only land when the job ends).
165
+ */
166
+ export function readStatus(cwd, repo, sha, context) {
167
+ const list = api(`repos/${repo}/commits/${sha}/statuses`, ['--jq', '.']);
168
+ if (!Array.isArray(list)) return null;
169
+ return list.find((s) => s.context === context) ?? null;
170
+ }
@@ -0,0 +1,50 @@
1
+ import { basename } from 'node:path';
2
+ import { statSync } from 'node:fs';
3
+ import { sh, shx } from './proc.js';
4
+ import { ensureArchive } from './r2.js';
5
+
6
+ /**
7
+ * Host simulator builds as release assets on the repo the workflow runs in.
8
+ *
9
+ * The runner already has `GH_TOKEN: ${{ github.token }}`, and that token is
10
+ * scoped to exactly this repo — so an asset here is fetchable with no PAT, no
11
+ * third-party account, and no presigned URL to expire mid-session. A private
12
+ * repo's assets are private for free, which is the thing R2 was buying.
13
+ *
14
+ * One stable tag is reused and the asset clobbered, rather than a release per
15
+ * session, so the repo's release list does not fill with build noise.
16
+ */
17
+ export const TAG = 'native-sim-build';
18
+
19
+ /** Draft releases are not listed publicly, so a public repo does not publish your binary. */
20
+ export function ensureRelease(cwd, repo, { draft = true } = {}) {
21
+ const existing = sh('gh', ['release', 'view', TAG, '--repo', repo, '--json', 'tagName'], { cwd });
22
+ if (existing.ok) return { created: false };
23
+
24
+ shx('gh', [
25
+ 'release', 'create', TAG,
26
+ '--repo', repo,
27
+ '--title', 'native-sim builds',
28
+ '--notes', 'Simulator builds uploaded by `native-sim`. Managed automatically; safe to delete.',
29
+ ...(draft ? ['--draft'] : []),
30
+ ], { cwd });
31
+ return { created: true };
32
+ }
33
+
34
+ /** Uploads (or replaces) one archive. Returns what the workflow needs to fetch it. */
35
+ export function upload(cwd, repo, filePath, { draft = true } = {}) {
36
+ const archive = ensureArchive(filePath);
37
+ const asset = basename(archive);
38
+ ensureRelease(cwd, repo, { draft });
39
+ // --clobber so re-running with the same build name replaces rather than fails.
40
+ shx('gh', ['release', 'upload', TAG, archive, '--repo', repo, '--clobber'], { cwd });
41
+ return { repo, tag: TAG, asset, bytes: statSync(archive).size };
42
+ }
43
+
44
+ /** True when the repo would expose this asset to anyone (published release on a public repo). */
45
+ export function isPubliclyReadable(cwd, repo) {
46
+ const vis = sh('gh', ['repo', 'view', repo, '--json', 'visibility', '-q', '.visibility'], { cwd });
47
+ if (!vis.ok || vis.out.trim() !== 'PUBLIC') return false;
48
+ const rel = sh('gh', ['release', 'view', TAG, '--repo', repo, '--json', 'isDraft', '-q', '.isDraft'], { cwd });
49
+ return rel.ok && rel.out.trim() === 'false';
50
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,68 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { sh, shx } from './proc.js';
3
+
4
+ /**
5
+ * True only when `cwd` is itself a repo root. `--is-inside-work-tree` is true for
6
+ * any subdirectory of a repo, which would make native-sim skip `git init` inside a
7
+ * monorepo and then fail in `gh repo create`, which requires a repo root.
8
+ */
9
+ export function isRepo(cwd) {
10
+ const r = sh('git', ['rev-parse', '--show-toplevel'], { cwd });
11
+ if (!r.ok) return false;
12
+ try {
13
+ return realpathSync(r.out) === realpathSync(cwd);
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function init(cwd) {
20
+ shx('git', ['init', '-b', 'main'], { cwd });
21
+ }
22
+
23
+ export function currentBranch(cwd) {
24
+ const r = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
25
+ return r.ok && r.out !== 'HEAD' ? r.out : 'main';
26
+ }
27
+
28
+ export function headSha(cwd) {
29
+ return shx('git', ['rev-parse', 'HEAD'], { cwd });
30
+ }
31
+
32
+ export function isDirty(cwd) {
33
+ return sh('git', ['status', '--porcelain'], { cwd }).out.length > 0;
34
+ }
35
+
36
+ export function hasCommits(cwd) {
37
+ return sh('git', ['rev-parse', '--verify', 'HEAD'], { cwd }).ok;
38
+ }
39
+
40
+ export function commitAll(cwd, message) {
41
+ shx('git', ['add', '-A'], { cwd });
42
+ // `git commit` exits non-zero when there is nothing staged; treat that as fine.
43
+ const r = sh('git', ['commit', '-m', message], { cwd });
44
+ if (!r.ok && !/nothing to commit/i.test(r.out + r.err)) {
45
+ throw new Error(`git commit failed\n${r.err || r.out}`);
46
+ }
47
+ }
48
+
49
+ export function remoteUrl(cwd, name = 'origin') {
50
+ const r = sh('git', ['remote', 'get-url', name], { cwd });
51
+ return r.ok ? r.out : null;
52
+ }
53
+
54
+ export function push(cwd, branch) {
55
+ shx('git', ['push', '-u', 'origin', branch], { cwd });
56
+ }
57
+
58
+ /** Ensure a .gitignore exists that covers the usual Expo build noise. */
59
+ export const GITIGNORE = `node_modules/
60
+ .expo/
61
+ dist/
62
+ web-build/
63
+ ios/
64
+ android/
65
+ *.log
66
+ .DS_Store
67
+ .env*.local
68
+ `;
@@ -0,0 +1,37 @@
1
+ import { spawnSync, spawn } from 'node:child_process';
2
+
3
+ /** Run a command, capture output. Never throws. */
4
+ export function sh(cmd, args = [], opts = {}) {
5
+ const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
6
+ return {
7
+ ok: r.status === 0,
8
+ code: r.status,
9
+ out: (r.stdout ?? '').trim(),
10
+ err: (r.stderr ?? '').trim(),
11
+ };
12
+ }
13
+
14
+ /** Run a command, capture output, throw on non-zero. */
15
+ export function shx(cmd, args = [], opts = {}) {
16
+ const r = sh(cmd, args, opts);
17
+ if (!r.ok) {
18
+ throw new Error(`${cmd} ${args.join(' ')} failed (${r.code})\n${r.err || r.out}`);
19
+ }
20
+ return r.out;
21
+ }
22
+
23
+ /** Run a command with inherited stdio (user sees live output). */
24
+ export function run(cmd, args = [], opts = {}) {
25
+ const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
26
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`);
27
+ }
28
+
29
+ export function has(cmd) {
30
+ return sh('which', [cmd]).ok;
31
+ }
32
+
33
+ export function open(url) {
34
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
35
+ }
36
+
37
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -0,0 +1,35 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+
4
+ /** Throws unless `cwd` looks like an Expo app. */
5
+ export function assertExpoProject(cwd) {
6
+ const pkgPath = join(cwd, 'package.json');
7
+ if (!existsSync(pkgPath)) {
8
+ throw new Error(`No package.json in ${cwd} — run native-sim from an Expo project root.`);
9
+ }
10
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
11
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ if (!deps.expo) {
13
+ throw new Error(`${pkg.name ?? basename(cwd)} has no "expo" dependency — native-sim targets Expo apps.`);
14
+ }
15
+ return pkg;
16
+ }
17
+
18
+ /** Best-effort read of app.json / app.config.json. Config plugins in JS are ignored. */
19
+ export function readAppConfig(cwd) {
20
+ for (const file of ['app.json', 'app.config.json']) {
21
+ const path = join(cwd, file);
22
+ if (!existsSync(path)) continue;
23
+ try {
24
+ return JSON.parse(readFileSync(path, 'utf8')).expo ?? {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+ return {};
30
+ }
31
+
32
+
33
+ export function defaultRepoName(cwd) {
34
+ return basename(cwd).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'expo-app';
35
+ }
package/src/lib/r2.js ADDED
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { sh, shx, has } from './proc.js';
6
+
7
+ const CONFIG_DIR = join(homedir(), '.native-sim');
8
+ const CONFIG_PATH = join(CONFIG_DIR, 'r2.json');
9
+
10
+ /** Env wins over the config file, so CI can override without a file. */
11
+ export function loadConfig() {
12
+ const file = existsSync(CONFIG_PATH) ? JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) : {};
13
+ return {
14
+ accountId: process.env.R2_ACCOUNT_ID ?? file.accountId,
15
+ bucket: process.env.R2_BUCKET ?? file.bucket,
16
+ accessKeyId: process.env.R2_ACCESS_KEY_ID ?? file.accessKeyId,
17
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? file.secretAccessKey,
18
+ };
19
+ }
20
+
21
+ export function saveConfig(config) {
22
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
23
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
24
+ return CONFIG_PATH;
25
+ }
26
+
27
+ export const configPath = () => CONFIG_PATH;
28
+
29
+ export function assertConfigured(config) {
30
+ if (!has('aws')) {
31
+ throw new Error('aws CLI not found — R2 uses the S3 API. Install: brew install awscli');
32
+ }
33
+ const missing = ['accountId', 'bucket', 'accessKeyId', 'secretAccessKey'].filter((k) => !config[k]);
34
+ if (missing.length) {
35
+ throw new Error(
36
+ `R2 is not configured (missing: ${missing.join(', ')}).\n` +
37
+ `Run: native-sim r2 setup\n` +
38
+ `Or set R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY.`,
39
+ );
40
+ }
41
+ }
42
+
43
+ const endpoint = (config) => `https://${config.accountId}.r2.cloudflarestorage.com`;
44
+
45
+ function awsEnv(config) {
46
+ return {
47
+ ...process.env,
48
+ AWS_ACCESS_KEY_ID: config.accessKeyId,
49
+ AWS_SECRET_ACCESS_KEY: config.secretAccessKey,
50
+ AWS_DEFAULT_REGION: 'auto',
51
+ // R2 is not EC2; without this the SDK stalls probing instance metadata.
52
+ AWS_EC2_METADATA_DISABLED: 'true',
53
+ };
54
+ }
55
+
56
+ const awsArgs = (config) => ['--endpoint-url', endpoint(config), '--region', 'auto'];
57
+
58
+ export function bucketExists(config) {
59
+ return sh('aws', ['s3api', 'head-bucket', '--bucket', config.bucket, ...awsArgs(config)],
60
+ { env: awsEnv(config) }).ok;
61
+ }
62
+
63
+ export function createBucket(config) {
64
+ shx('aws', ['s3api', 'create-bucket', '--bucket', config.bucket, ...awsArgs(config)],
65
+ { env: awsEnv(config) });
66
+ }
67
+
68
+ /**
69
+ * simctl installs a .app directory, so archive one if that is what we were given.
70
+ * Returns a path to a .tar.gz.
71
+ */
72
+ export function ensureArchive(path) {
73
+ if (!existsSync(path)) throw new Error(`No such file: ${path}`);
74
+ if (!statSync(path).isDirectory()) return path;
75
+ if (!path.endsWith('.app')) throw new Error(`${path} is a directory but not a .app bundle`);
76
+
77
+ const out = join(tmpdir(), `${basename(path, '.app')}-${Date.now()}.tar.gz`);
78
+ shx('tar', ['-C', join(path, '..'), '-czf', out, basename(path)]);
79
+ return out;
80
+ }
81
+
82
+ /** Uploads and returns a presigned URL valid for `expiresIn` seconds. */
83
+ export function upload(config, filePath, { expiresIn = 3600, prefix = 'native-sim' } = {}) {
84
+ assertConfigured(config);
85
+ const archive = ensureArchive(filePath);
86
+ const key = `${prefix}/${Date.now()}-${randomBytes(6).toString('hex')}-${basename(archive)}`;
87
+ const target = `s3://${config.bucket}/${key}`;
88
+
89
+ shx('aws', ['s3', 'cp', archive, target, ...awsArgs(config)], { env: awsEnv(config) });
90
+ const url = shx('aws', ['s3', 'presign', target, '--expires-in', String(expiresIn), ...awsArgs(config)],
91
+ { env: awsEnv(config) });
92
+
93
+ return { url, key, bytes: statSync(archive).size, expiresIn };
94
+ }
@@ -0,0 +1,24 @@
1
+ import { writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** Inside .git so it is never committed and dies with the clone. */
5
+ const file = (cwd) => join(cwd, '.git', 'native-sim-session.json');
6
+
7
+ export function saveSession(cwd, data) {
8
+ if (!existsSync(join(cwd, '.git'))) return;
9
+ writeFileSync(file(cwd), JSON.stringify({ ...data, startedAt: Date.now() }, null, 2));
10
+ }
11
+
12
+ export function loadSession(cwd) {
13
+ const path = file(cwd);
14
+ if (!existsSync(path)) return null;
15
+ try {
16
+ return JSON.parse(readFileSync(path, 'utf8'));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function clearSession(cwd) {
23
+ rmSync(file(cwd), { force: true });
24
+ }
package/src/lib/ui.js ADDED
@@ -0,0 +1,44 @@
1
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const c = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
3
+
4
+ export const dim = c('2');
5
+ export const bold = c('1');
6
+ export const red = c('31');
7
+ export const green = c('32');
8
+ export const yellow = c('33');
9
+ export const cyan = c('36');
10
+
11
+ export const info = (msg) => console.log(`${cyan('›')} ${msg}`);
12
+ export const ok = (msg) => console.log(`${green('✓')} ${msg}`);
13
+ export const warn = (msg) => console.log(`${yellow('!')} ${msg}`);
14
+ export const step = (msg) => console.log(`\n${bold(msg)}`);
15
+
16
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
17
+
18
+ /** Minimal spinner that degrades to plain lines when not a TTY. */
19
+ export function spinner(text) {
20
+ let i = 0;
21
+ let label = text;
22
+ let timer = null;
23
+ if (tty) {
24
+ timer = setInterval(() => {
25
+ process.stdout.write(`\r${cyan(FRAMES[i++ % FRAMES.length])} ${label}\x1b[K`);
26
+ }, 80);
27
+ } else {
28
+ console.log(`… ${label}`);
29
+ }
30
+ return {
31
+ update(next) {
32
+ if (next === label) return;
33
+ label = next;
34
+ if (!tty) console.log(`… ${label}`);
35
+ },
36
+ stop(finalLine) {
37
+ if (timer) {
38
+ clearInterval(timer);
39
+ process.stdout.write('\r\x1b[K');
40
+ }
41
+ if (finalLine) console.log(finalLine);
42
+ },
43
+ };
44
+ }