native-sim 0.1.0 → 0.1.1

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 CHANGED
@@ -111,9 +111,29 @@ Requirements:
111
111
  fine. Signed URLs expire, so fetch a fresh one per session.
112
112
  - `.tar.gz` or `.zip` containing the `.app`.
113
113
 
114
+ ### Choosing where builds live
115
+
116
+ Two things decide who can download your build: **where it is hosted**, and — for the
117
+ default backend — **whether the repo is public**. Those axes interact, and the useful
118
+ combinations are:
119
+
120
+ | Repo | Hosting | Your build is | macOS minutes |
121
+ | --- | --- | --- | --- |
122
+ | private | GitHub release *(default)* | private | bill at [**10×**](#cost) |
123
+ | public | GitHub release *(default)* | **downloadable by anyone** | free, unlimited |
124
+ | public | `--r2` | private — 2h signed URL | free, unlimited |
125
+
126
+ That last row is the reason `--r2` exists. A public repo is what makes macOS minutes free
127
+ and unlimited, and the default backend inherits *repo* visibility — so on a public repo it
128
+ publishes your binary. R2 is the only combination that keeps both the free minutes and a
129
+ private build. The cost is a Cloudflare account and the `aws` CLI.
130
+
131
+ If you are already on a private repo, the default backend is private too and `--r2` buys
132
+ you nothing.
133
+
114
134
  ### Hosting your own builds
115
135
 
116
- By default a local build is uploaded as an asset on a **draft release of the same repo the
136
+ By default a local build is uploaded as an asset on a **release of the same repo the
117
137
  workflow runs in**, and the runner fetches it with the job's own `GITHUB_TOKEN`:
118
138
 
119
139
  ```sh
@@ -126,9 +146,19 @@ This needs no third-party account and no credentials file. It works for **public
126
146
  repos alike**: a private repo's release assets are readable only by people who can read the
127
147
  repo, and there is no signed URL to expire mid-session.
128
148
 
129
- The release is created as a **draft**, which is not listed publicly so a public repo does
130
- not start publishing your binaries as a side effect. If a release ever would be publicly
131
- downloadable, the CLI says so.
149
+ The release is **published, not a draft**. GitHub only shows draft releases to callers with
150
+ push access, and the workflow job runs with `contents: read` on purpose so a draft is
151
+ something the runner cannot fetch at all. Granting the job `contents: write` to work around
152
+ that would give repo write access to a job that also opens a public tunnel, which is a worse
153
+ trade than publishing a release.
154
+
155
+ What that means for you:
156
+
157
+ - **Private repo** — nothing changes. Release assets inherit repo visibility, so the build is
158
+ readable only by people who can already read the repo.
159
+ - **Public repo** — the asset *is* downloadable by anyone, and the CLI warns you when it
160
+ uploads. Use [`--r2`](#hosting-on-r2-instead) to keep the build private — see
161
+ [Choosing where builds live](#choosing-where-builds-live).
132
162
 
133
163
  All builds land on one reused tag, `native-sim-build`, with the asset clobbered each time,
134
164
  so the release list does not fill up with build noise. Deleting that release is safe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "native-sim",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Stream a live iOS Simulator of your React Native or Expo app from a GitHub-hosted macOS runner to your browser — and let a coding agent drive it.",
5
5
  "keywords": [
6
6
  "expo",
@@ -83,15 +83,18 @@ export async function up(cwd, flags) {
83
83
  }
84
84
 
85
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.
86
+ // The runner fetches it with the job's own token — no third-party account
87
+ // and no presigned URL to expire mid-session. The release is published rather
88
+ // than a draft because the job token is `contents: read`, which cannot see
89
+ // drafts; on a private repo the asset is private either way.
88
90
  if (appFile && !flags.r2) {
89
91
  step(`Uploading ${bold(appFile)} to ${bold(repo)}`);
90
92
  const uploaded = ghrelease.upload(cwd, repo, appFile);
91
93
  appReleaseAsset = uploaded.asset;
92
94
  ok(`uploaded ${(uploaded.bytes / 1048576).toFixed(1)} MB ${dim(`as ${uploaded.asset}`)}`);
93
95
  if (ghrelease.isPubliclyReadable(cwd, repo)) {
94
- warn('published release on a public repo the build is downloadable by anyone');
96
+ warn(`this repo is public, so ${uploaded.asset} is downloadable by anyone`);
97
+ warn(`keep the build private with ${bold('--r2')} ${dim('(native-sim r2)')}`);
95
98
  }
96
99
  }
97
100
 
@@ -33,9 +33,10 @@ function uploadToGitHub(cwd, file) {
33
33
 
34
34
  ok(`uploaded ${mb(result.bytes)} as ${dim(result.asset)}`);
35
35
  if (ghrelease.isPubliclyReadable(cwd, repo)) {
36
- warn('this release is published on a public repo — the build is downloadable by anyone');
36
+ warn('this repo is public — the build is downloadable by anyone');
37
+ warn(`keep it private with ${bold('--r2')} ${dim('(native-sim r2)')}`);
37
38
  } else {
38
- info('draft release on this repo — only people who can read the repo can fetch it');
39
+ info('release on this repo — only people who can read the repo can fetch it');
39
40
  }
40
41
  console.log('');
41
42
  console.log(dim(` run it: native-sim up --app-release ${result.asset}`));
package/src/lib/gh.js CHANGED
@@ -1,8 +1,14 @@
1
- import { sh, shx } from './proc.js';
1
+ import { sh, shx, has } from './proc.js';
2
+
3
+ const INSTALL_GH = process.platform === 'win32'
4
+ ? 'Install it: winget install --id GitHub.cli'
5
+ : process.platform === 'darwin'
6
+ ? 'Install it: brew install gh'
7
+ : 'Install it: https://github.com/cli/cli#installation';
2
8
 
3
9
  export function requireAuth() {
4
- if (!sh('which', ['gh']).ok) {
5
- throw new Error('GitHub CLI not found. Install it: brew install gh');
10
+ if (!has('gh')) {
11
+ throw new Error(`GitHub CLI not found on PATH. ${INSTALL_GH}`);
6
12
  }
7
13
  if (!sh('gh', ['auth', 'status']).ok) {
8
14
  throw new Error('Not logged in to GitHub. Run: gh auth login');
@@ -152,7 +158,8 @@ export function setSecret(cwd, name, value) {
152
158
 
153
159
  export function listSecrets(cwd) {
154
160
  const r = sh('gh', ['secret', 'list', '--json', 'name', '-q', '.[].name'], { cwd });
155
- return r.ok ? r.out.split('\n').filter(Boolean) : [];
161
+ // gh emits CRLF on Windows; a trailing \r would corrupt every secret name.
162
+ return r.ok ? r.out.split(/\r?\n/).map((n) => n.trim()).filter(Boolean) : [];
156
163
  }
157
164
 
158
165
  export function cancelRun(cwd, id) {
@@ -16,26 +16,49 @@ import { ensureArchive } from './r2.js';
16
16
  */
17
17
  export const TAG = 'native-sim-build';
18
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 };
19
+ /**
20
+ * The release is published, not a draft.
21
+ *
22
+ * A draft looks like the safer default — it is not listed publicly — but the
23
+ * runner cannot read it. Draft releases are only returned to callers with push
24
+ * access, and the workflow's job token is deliberately `contents: read`, so
25
+ * `gh release download` 404'd on every `--app-file` run. Raising the job to
26
+ * `contents: write` to fix that would hand write access to a job that also
27
+ * opens a public tunnel, which is a far worse trade than publishing a release.
28
+ *
29
+ * On a private repo a published release is private anyway — the asset inherits
30
+ * repo visibility, so nothing is given up. On a public repo it is genuinely
31
+ * readable by anyone, so `up` warns and points at `--r2`.
32
+ */
33
+ export function ensureRelease(cwd, repo) {
34
+ const existing = sh('gh', [
35
+ 'release', 'view', TAG, '--repo', repo, '--json', 'isDraft', '-q', '.isDraft',
36
+ ], { cwd });
37
+
38
+ if (existing.ok) {
39
+ // Repos set up by an earlier native-sim still carry a draft release, which
40
+ // the runner cannot fetch from. Publish it rather than failing on the runner.
41
+ if (existing.out.trim() === 'true') {
42
+ shx('gh', ['release', 'edit', TAG, '--repo', repo, '--draft=false'], { cwd });
43
+ return { created: false, published: true };
44
+ }
45
+ return { created: false, published: false };
46
+ }
23
47
 
24
48
  shx('gh', [
25
49
  'release', 'create', TAG,
26
50
  '--repo', repo,
27
51
  '--title', 'native-sim builds',
28
52
  '--notes', 'Simulator builds uploaded by `native-sim`. Managed automatically; safe to delete.',
29
- ...(draft ? ['--draft'] : []),
30
53
  ], { cwd });
31
- return { created: true };
54
+ return { created: true, published: false };
32
55
  }
33
56
 
34
57
  /** Uploads (or replaces) one archive. Returns what the workflow needs to fetch it. */
35
- export function upload(cwd, repo, filePath, { draft = true } = {}) {
58
+ export function upload(cwd, repo, filePath) {
36
59
  const archive = ensureArchive(filePath);
37
60
  const asset = basename(archive);
38
- ensureRelease(cwd, repo, { draft });
61
+ ensureRelease(cwd, repo);
39
62
  // --clobber so re-running with the same build name replaces rather than fails.
40
63
  shx('gh', ['release', 'upload', TAG, archive, '--repo', repo, '--clobber'], { cwd });
41
64
  return { repo, tag: TAG, asset, bytes: statSync(archive).size };
@@ -44,7 +67,7 @@ export function upload(cwd, repo, filePath, { draft = true } = {}) {
44
67
  /** True when the repo would expose this asset to anyone (published release on a public repo). */
45
68
  export function isPubliclyReadable(cwd, repo) {
46
69
  const vis = sh('gh', ['repo', 'view', repo, '--json', 'visibility', '-q', '.visibility'], { cwd });
47
- if (!vis.ok || vis.out.trim() !== 'PUBLIC') return false;
70
+ if (!vis.ok || vis.out.trim().toUpperCase() !== 'PUBLIC') return false;
48
71
  const rel = sh('gh', ['release', 'view', TAG, '--repo', repo, '--json', 'isDraft', '-q', '.isDraft'], { cwd });
49
72
  return rel.ok && rel.out.trim() === 'false';
50
73
  }
package/src/lib/proc.js CHANGED
@@ -1,8 +1,78 @@
1
1
  import { spawnSync, spawn } from 'node:child_process';
2
+ import { accessSync, constants, statSync } from 'node:fs';
3
+ import { join, extname } from 'node:path';
4
+
5
+ const isWindows = process.platform === 'win32';
6
+
7
+ /**
8
+ * Windows has no `which`, so the old `sh('which', cmd)` probe reported every
9
+ * tool as missing even when it was installed and on PATH — `native-sim up`
10
+ * died with "GitHub CLI not found" on machines where gh worked fine.
11
+ * Resolve against PATH ourselves instead: no subprocess, same answer on
12
+ * every platform.
13
+ */
14
+ export function resolve(cmd) {
15
+ if (cmd.includes('/') || (isWindows && cmd.includes('\\'))) {
16
+ return isExecutable(cmd) ? cmd : null;
17
+ }
18
+
19
+ // PATHEXT is what makes `gh` mean `gh.exe` and `agent-device` mean
20
+ // `agent-device.cmd`; on POSIX the only candidate is the bare name.
21
+ const pathext = isWindows
22
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
23
+ : [];
24
+ // A command that already carries one of those extensions is tried as-is.
25
+ const exts = !isWindows || pathext.some((e) => e.toUpperCase() === extname(cmd).toUpperCase())
26
+ ? ['']
27
+ : pathext;
28
+
29
+ // PATH only, never the working directory: cmd.exe would search `.` first,
30
+ // which would let a checked-out repo shadow gh or git with its own binary.
31
+ const delimiter = isWindows ? ';' : ':';
32
+ for (const dir of (process.env.PATH ?? '').split(delimiter).filter(Boolean)) {
33
+ for (const ext of exts) {
34
+ const candidate = join(dir, cmd + ext);
35
+ if (isExecutable(candidate)) return candidate;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function isExecutable(path) {
42
+ try {
43
+ if (!statSync(path).isFile()) return false;
44
+ // X_OK is meaningless on Windows: every readable file reports executable,
45
+ // which is why the PATHEXT filter above carries the check there.
46
+ accessSync(path, isWindows ? constants.F_OK : constants.X_OK);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * `.cmd`/`.bat` shims — how npm installs CLIs like agent-device and how the
55
+ * MSI installs aws on Windows — are scripts, not executables, so spawn cannot
56
+ * run them directly. Route those through cmd.exe, quoting the arguments here
57
+ * rather than letting cmd.exe re-split a path that contains spaces.
58
+ */
59
+ function spawnArgs(cmd, args) {
60
+ const path = resolve(cmd);
61
+ if (!path) return { file: cmd, args, opts: {} };
62
+ if (!isWindows || !/\.(cmd|bat)$/i.test(path)) return { file: path, args, opts: {} };
63
+ return {
64
+ file: process.env.COMSPEC ?? 'cmd.exe',
65
+ args: ['/d', '/s', '/c', `"${[path, ...args].map(quote).join(' ')}"`],
66
+ opts: { windowsVerbatimArguments: true },
67
+ };
68
+ }
69
+
70
+ const quote = (arg) => (/[\s"]/.test(arg) ? `"${String(arg).replace(/"/g, '""')}"` : String(arg));
2
71
 
3
72
  /** Run a command, capture output. Never throws. */
4
73
  export function sh(cmd, args = [], opts = {}) {
5
- const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
74
+ const spec = spawnArgs(cmd, args);
75
+ const r = spawnSync(spec.file, spec.args, { encoding: 'utf8', ...spec.opts, ...opts });
6
76
  return {
7
77
  ok: r.status === 0,
8
78
  code: r.status,
@@ -22,16 +92,26 @@ export function shx(cmd, args = [], opts = {}) {
22
92
 
23
93
  /** Run a command with inherited stdio (user sees live output). */
24
94
  export function run(cmd, args = [], opts = {}) {
25
- const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
95
+ const spec = spawnArgs(cmd, args);
96
+ const r = spawnSync(spec.file, spec.args, { stdio: 'inherit', ...spec.opts, ...opts });
26
97
  if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`);
27
98
  }
28
99
 
29
100
  export function has(cmd) {
30
- return sh('which', [cmd]).ok;
101
+ return resolve(cmd) !== null;
31
102
  }
32
103
 
33
104
  export function open(url) {
34
- spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
105
+ // `open` is macOS-only. Without a listener the ENOENT on other platforms
106
+ // surfaces as an uncaught exception *after* the session is already up.
107
+ const [cmd, args] = isWindows
108
+ ? [process.env.COMSPEC ?? 'cmd.exe', ['/d', '/s', '/c', 'start', '""', url.replace(/&/g, '^&')]]
109
+ : process.platform === 'darwin'
110
+ ? ['open', [url]]
111
+ : ['xdg-open', [url]];
112
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
113
+ child.on('error', () => {});
114
+ child.unref();
35
115
  }
36
116
 
37
117
  export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -1,4 +1,4 @@
1
- # native-sim-template-version: 18
1
+ # native-sim-template-version: 19
2
2
  # Generated by `native-sim init`. Boots an iOS Simulator on a GitHub-hosted macOS
3
3
  # runner, builds this Expo app into it, and streams it back over a tunnel using
4
4
  # @expo/serve-sim. The tunnel URL is published as a commit status, which is the
@@ -454,14 +454,26 @@ jobs:
454
454
 
455
455
  if [ -n "$APP_RELEASE_ASSET" ]; then
456
456
  # Hosted on this repo's own release. The job token is already scoped
457
- # here, so a private repo costs no extra credential and a draft
458
- # release is not downloadable by the public.
457
+ # here, so a private repo costs no extra credential.
458
+ #
459
+ # This job runs with `contents: read`, and GitHub only returns draft
460
+ # releases to callers with push access — so a draft `native-sim-build`
461
+ # release 404s here no matter how healthy the upload looked. native-sim
462
+ # publishes the release for that reason; say so plainly if one is still
463
+ # a draft, rather than dying on a bare "release not found".
459
464
  echo "Downloading $APP_RELEASE_ASSET from release $APP_RELEASE_TAG"
460
- gh release download "$APP_RELEASE_TAG" \
465
+ if ! gh release download "$APP_RELEASE_TAG" \
461
466
  --repo "$GITHUB_REPOSITORY" \
462
467
  --pattern "$APP_RELEASE_ASSET" \
463
468
  --output download.bin \
464
469
  --clobber
470
+ then
471
+ echo "::error::could not download $APP_RELEASE_ASSET from release $APP_RELEASE_TAG."
472
+ echo "::error::If that release is a draft, this job cannot read it (contents: read)."
473
+ echo "::error::Publish it: gh release edit $APP_RELEASE_TAG --draft=false"
474
+ echo "::error::Or upgrade native-sim, which now publishes it for you."
475
+ exit 1
476
+ fi
465
477
  else
466
478
  echo "Downloading prebuilt app"
467
479
  curl -fsSL --retry 3 -o download.bin "$APP_URL"