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 +34 -4
- package/package.json +1 -1
- package/src/commands/up.js +6 -3
- package/src/commands/upload.js +3 -2
- package/src/lib/gh.js +11 -4
- package/src/lib/ghrelease.js +32 -9
- package/src/lib/proc.js +84 -4
- package/templates/native-sim.yml +16 -4
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 **
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
package/src/commands/up.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
package/src/commands/upload.js
CHANGED
|
@@ -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
|
|
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('
|
|
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 (!
|
|
5
|
-
throw new Error(
|
|
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
|
-
|
|
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) {
|
package/src/lib/ghrelease.js
CHANGED
|
@@ -16,26 +16,49 @@ import { ensureArchive } from './r2.js';
|
|
|
16
16
|
*/
|
|
17
17
|
export const TAG = 'native-sim-build';
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
58
|
+
export function upload(cwd, repo, filePath) {
|
|
36
59
|
const archive = ensureArchive(filePath);
|
|
37
60
|
const asset = basename(archive);
|
|
38
|
-
ensureRelease(cwd, repo
|
|
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
|
|
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
|
|
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
|
|
101
|
+
return resolve(cmd) !== null;
|
|
31
102
|
}
|
|
32
103
|
|
|
33
104
|
export function open(url) {
|
|
34
|
-
|
|
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));
|
package/templates/native-sim.yml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# native-sim-template-version:
|
|
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
|
|
458
|
-
#
|
|
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"
|