menloapp 1.4.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/github.js ADDED
@@ -0,0 +1,297 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { choose, continueAfter, deployUI, openBrowser, shellQuote } from "./deploy-ui.js";
6
+
7
+ import { setupPresentation, readCommittedPresentation } from "./project-presentation.js";
8
+ import { recordExperience } from "./record.js";
9
+
10
+ export const MENLO_ORIGIN = "https://menloapp.lol";
11
+ const SHA = /^[a-f0-9]{40}$/;
12
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const run = (command, args) => execFileSync(command, args, { encoding: "utf8", timeout: 20_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim();
14
+
15
+ export function githubRepository(remote) {
16
+ const match = /^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([A-Za-z0-9-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(remote.trim());
17
+ if (!match || match[1].split("/").some(p => p === "." || p === "..")) throw new Error("This app needs a GitHub repository as its origin.\nSet it with: git remote set-url origin https://github.com/YOUR-NAME/YOUR-APP.git\nThen run menloapp deploy again.");
18
+ return match[1];
19
+ }
20
+
21
+ export function deployOptions(args) {
22
+ const options = { directory: ".", json: false, dryRun: false, record: false, seconds: 20 };
23
+ let directory = false;
24
+ for (let i = 0; i < args.length; i++) {
25
+ const arg = args[i];
26
+ if (arg === "--json") options.json = true;
27
+ else if (arg === "--record") options.record = true;
28
+ else if (arg === "--dry-run") options.dryRun = true;
29
+ else if (arg === "--help" || arg === "-h") options.help = true;
30
+ else if (["--scheme", "--app-slug", "--project", "--name", "--simulator", "--seconds"].includes(arg)) {
31
+ const value = args[++i];
32
+ if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value`);
33
+ options[{ "--scheme": "scheme", "--app-slug": "slug", "--project": "project", "--name": "name", "--simulator": "simulator", "--seconds": "seconds" }[arg]] = value;
34
+ } else if (arg.startsWith("-")) throw new Error(`Unknown MENLO deploy option: ${arg}\nRun menloapp deploy --help for supported options.`);
35
+ else if (!directory) { options.directory = arg; directory = true; }
36
+ else throw new Error("Choose one app directory.");
37
+ }
38
+ options.seconds = Number(options.seconds);
39
+ if (!Number.isInteger(options.seconds) || options.seconds < 3 || options.seconds > 60) throw new Error("Choose --seconds from 3 to 60.");
40
+ if (options.dryRun && options.record) throw new Error("Choose --dry-run or --record, not both.");
41
+ return options;
42
+ }
43
+
44
+ class APIError extends Error {
45
+ constructor(message, status) { super(message); this.status = status; }
46
+ }
47
+
48
+ async function api(url, init = {}, fetcher = fetch) {
49
+ const service = ["api.github.com", "github.com"].includes(new URL(url).hostname) ? "GitHub" : "MENLO";
50
+ let response;
51
+ try { response = await fetcher(url, { ...init, redirect: "error", signal: AbortSignal.timeout(20_000) }); }
52
+ catch { throw new Error(`Could not reach ${service}. Check your connection and run menloapp deploy again.`); }
53
+ let body;
54
+ try { body = await response.json(); } catch { throw new Error(`${service} returned an unreadable response. Run menloapp deploy again shortly.`); }
55
+ if (!body || typeof body !== "object") throw new Error(`${service} returned an unreadable response. Run menloapp deploy again shortly.`);
56
+ if (!response.ok) {
57
+ const detail = typeof body.error === "string" ? body.error : typeof body.message === "string" ? body.message : "";
58
+ if (response.status === 429 || response.status === 403 && /rate limit/i.test(detail)) throw new APIError(`${service}'s request limit was reached. Wait a few minutes, then run menloapp deploy again.`, 429);
59
+ throw new APIError(detail.slice(0, 500).replace(/[\x00-\x1f\x7f]/g, " ") || `${service} request failed (${response.status}).`, response.status);
60
+ }
61
+ return body;
62
+ }
63
+
64
+ export async function deviceLogin(clientId, { fetcher = fetch, log = console.error, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), open = openBrowser } = {}) {
65
+ const post = (url, fields) => api(url, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields) }, fetcher);
66
+ const device = await post("https://github.com/login/device/code", { client_id: clientId, scope: "read:user public_repo" });
67
+ if (!device.device_code || !device.user_code || device.verification_uri !== "https://github.com/login/device" || !Number.isFinite(device.expires_in)) throw new Error("MENLO's GitHub sign-in is unavailable. Run gh auth login, then menloapp deploy.");
68
+ log(`Sign in to GitHub: ${device.verification_uri}\nEnter code: ${device.user_code}\nWaiting for your approval in the browser…`);
69
+ open(device.verification_uri);
70
+ const deadline = Date.now() + Math.min(device.expires_in, 900) * 1000;
71
+ let interval = Math.max(device.interval || 5, 5) * 1000;
72
+ while (Date.now() < deadline) {
73
+ await sleep(interval);
74
+ const result = await post("https://github.com/login/oauth/access_token", { client_id: clientId, device_code: device.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" });
75
+ if (typeof result.access_token === "string") return result.access_token;
76
+ if (result.error === "slow_down") interval += 5000;
77
+ else if (result.error !== "authorization_pending") throw new Error(`GitHub sign-in ${result.error === "access_denied" ? "was cancelled" : "expired"}. Run menloapp deploy to sign in again.`);
78
+ }
79
+ throw new Error("GitHub sign-in expired. Run menloapp deploy to sign in again.");
80
+ }
81
+
82
+ const headers = credential => ({ Authorization: `Bearer ${credential}`, Accept: "application/vnd.github+json", "User-Agent": "menlo-cli" });
83
+ const optionalRun = (execute, command, args) => { try { return execute(command, args); } catch { return ""; } };
84
+
85
+ export async function authenticate(ui, { fetcher = fetch, execute = run, env = process.env, platform = process.platform, login = () => spawnSync("gh", ["auth", "login", "--hostname", "github.com", "--git-protocol", "https", "--web"], { stdio: "inherit" }).status, device = deviceLogin } = {}) {
86
+ const variable = env.GH_TOKEN ? "GH_TOKEN" : env.GITHUB_TOKEN ? "GITHUB_TOKEN" : null;
87
+ let credential = variable ? env[variable] : optionalRun(execute, "gh", ["auth", "token", "--hostname", "github.com"]);
88
+ if (!credential && platform === "darwin") credential = optionalRun(execute, "/usr/bin/security", ["find-generic-password", "-s", "com.menlo.github", "-a", "github", "-w"]);
89
+ const validate = async value => {
90
+ const user = await api("https://api.github.com/user", { headers: headers(value) }, fetcher);
91
+ if (!user.login || !Number.isSafeInteger(user.id)) throw new Error("GitHub did not return your account. Run gh auth login, then menloapp deploy.");
92
+ ui.log(`GitHub: signed in as ${user.login}`);
93
+ return { credential: value, user };
94
+ };
95
+ if (credential) {
96
+ try { return await validate(credential); }
97
+ catch (error) {
98
+ if (error.status !== 401) throw error;
99
+ if (variable) throw new Error(`GitHub rejected the credential in ${variable}.\nReplace it with a valid token, or run:\n unset ${variable}\n gh auth login\n menloapp deploy`);
100
+ ui.log("Your GitHub sign-in has expired. Sign in again to continue.");
101
+ }
102
+ } else ui.log("You are not signed in to GitHub. Sign in to connect this app to your account.");
103
+ const hasGH = Boolean(optionalRun(execute, "gh", ["--version"]));
104
+ const instructions = hasGH ? "Run gh auth login, then menloapp deploy." : "Install GitHub CLI from https://cli.github.com (on Mac: brew install gh), then run:\n gh auth login\n menloapp deploy";
105
+ if (!ui.interactive) throw new Error(`You are not signed in to GitHub, or your sign-in has expired.\n${instructions}`);
106
+ if (hasGH) {
107
+ const answer = await ui.ask("Sign in to GitHub in your browser now? [Y/n] ");
108
+ if (answer && !/^y(es)?$/i.test(answer)) throw new Error(`GitHub sign-in is required to deploy.\n${instructions}`);
109
+ if (login() !== 0) throw new Error(`GitHub sign-in did not finish.\n${instructions}`);
110
+ credential = optionalRun(execute, "gh", ["auth", "token", "--hostname", "github.com"]);
111
+ } else {
112
+ const status = await api(`${MENLO_ORIGIN}/api/menlo/v1/status`, {}, fetcher);
113
+ if (!status.github_client_id) throw new Error(`MENLO's browser sign-in is not configured yet.\n${instructions}`);
114
+ credential = await device(status.github_client_id, { fetcher, log: ui.log, open: ui.open });
115
+ }
116
+ if (!credential) throw new Error(`GitHub sign-in did not return a credential.\n${instructions}`);
117
+ try { return await validate(credential); }
118
+ catch (error) { if (error.status === 401) throw new Error(`GitHub did not accept the new sign-in.\n${instructions}`); throw error; }
119
+ }
120
+
121
+ function git(directory, ...args) {
122
+ try { return run("git", ["-C", directory, ...args]); }
123
+ catch { throw new Error("Open your app's Git repository, then run menloapp deploy. It needs a GitHub origin and at least one commit."); }
124
+ }
125
+
126
+ async function inspectRepository(options) {
127
+ let input;
128
+ try { input = await realpath(options.directory); } catch { throw new Error(`App directory not found: ${options.directory}\nRun menloapp deploy from your app's repository.`); }
129
+ const explicitContainer = /\.(xcodeproj|xcworkspace)$/.test(input) ? input : undefined;
130
+ const directory = explicitContainer ? path.dirname(input) : input;
131
+ const root = git(directory, "rev-parse", "--show-toplevel");
132
+ let remote;
133
+ try { remote = run("git", ["-C", root, "remote", "get-url", "origin"]); }
134
+ catch { throw new Error("This repository has no GitHub origin.\nCreate a GitHub repository and connect it with gh repo create --source=. --remote=origin, then run menloapp deploy."); }
135
+ return { root, directory, explicitContainer, repository: githubRepository(remote) };
136
+ }
137
+
138
+ export async function inspectProject(options, ui = deployUI(options)) {
139
+ const { root, directory, explicitContainer, repository } = await inspectRepository(options);
140
+ while (git(root, "status", "--porcelain", "--untracked-files=normal")) {
141
+ await continueAfter(ui, "Your app has uncommitted changes. MENLO shares the code pushed to GitHub.\nIn another terminal, review git status, commit the files you want to share, and push them. Local files will not be uploaded automatically.");
142
+ }
143
+ const commit = git(root, "rev-parse", "HEAD");
144
+ if (!SHA.test(commit)) throw new Error("This Git revision is unsupported.");
145
+ // Only committed containers are available to recipients. Ignore abandoned
146
+ // projects and generated build folders even when they exist on this Mac.
147
+ const files = git(root, "ls-tree", "-r", "--name-only", "-z", "HEAD").split("\0").filter(Boolean);
148
+ const tracked = new Set(files);
149
+ let containers = files.flatMap(file => {
150
+ if (/\.xcodeproj\/project\.pbxproj$/.test(file)) return [path.dirname(file)];
151
+ if (/\.xcworkspace\/contents\.xcworkspacedata$/.test(file) && !file.includes(".xcodeproj/")) return [path.dirname(file)];
152
+ return [];
153
+ }).filter(file => {
154
+ const relative = path.relative(directory, path.join(root, file));
155
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
156
+ }).sort();
157
+ const members = new Map();
158
+ for (const workspace of containers.filter(file => file.endsWith(".xcworkspace"))) {
159
+ const xml = await readFile(path.join(root, workspace, "contents.xcworkspacedata"), "utf8");
160
+ members.set(workspace, [...xml.matchAll(/location\s*=\s*"group:([^"]+\.xcodeproj)"/g)].map(match => path.posix.normalize(path.posix.join(path.posix.dirname(workspace), match[1]))));
161
+ }
162
+ if (options.project || explicitContainer) {
163
+ let selected;
164
+ try { selected = await realpath(options.project ? path.resolve(root, options.project) : explicitContainer); }
165
+ catch { throw new Error("That Xcode project does not exist. Run menloapp deploy without --project to choose a committed app."); }
166
+ const relative = path.relative(root, selected);
167
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Choose a project inside this GitHub repository.");
168
+ if (!tracked.has(`${relative}/${relative.endsWith(".xcodeproj") ? "project.pbxproj" : "contents.xcworkspacedata"}`)) throw new Error("That Xcode project is not committed to GitHub. Commit and push it, or run menloapp deploy without --project to select a committed app.");
169
+ containers = [relative];
170
+ } else {
171
+ const covered = new Set([...members.values()].flat());
172
+ containers = containers.filter(file => !covered.has(file));
173
+ }
174
+ if (!containers.length) throw new Error("No committed Xcode project or workspace was found.\nGenerate or add your iOS project (.xcodeproj or .xcworkspace), commit and push it, then run menloapp deploy.");
175
+ const project = await choose(ui, "Which app do you want to deploy?", containers, "--project");
176
+ let scheme = options.scheme;
177
+ if (!scheme) {
178
+ const sources = [project, ...(members.get(project) || [])];
179
+ const schemeFiles = files.filter(file => sources.some(source => file.startsWith(`${source}/xcshareddata/xcschemes/`)) && file.endsWith(".xcscheme"));
180
+ const appSchemes = [];
181
+ for (const file of schemeFiles) {
182
+ if (/BuildableName\s*=\s*"[^"]+\.app"/.test(await readFile(path.join(root, file), "utf8"))) appSchemes.push(path.basename(file, ".xcscheme"));
183
+ }
184
+ let names = [...new Set(appSchemes)].sort();
185
+ if (!names.length) {
186
+ try {
187
+ const kind = project.endsWith(".xcworkspace") ? "-workspace" : "-project";
188
+ const result = JSON.parse(execFileSync("xcodebuild", ["-list", "-json", kind, path.join(root, project), "-disableAutomaticPackageResolution", "-skipPackageUpdates"], { encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "ignore"] }));
189
+ names = (result.project || result.workspace)?.schemes ?? [];
190
+ } catch { /* Explain how to make the app scheme available. */ }
191
+ }
192
+ if (!names.length) throw new Error(`Could not find an app scheme in ${project}.\nIn Xcode, open Product → Scheme → Manage Schemes and mark your iOS app scheme Shared. Commit and push it, then run menloapp deploy.\nIf you already know the scheme: menloapp deploy --scheme YOUR-APP`);
193
+ scheme = await choose(ui, "Which iOS app scheme should testers build?", names, "--scheme");
194
+ }
195
+ const presentation = await readCommittedPresentation(root, commit);
196
+ return { repository, project, scheme, name: presentation?.name || options.name || scheme, slug: options.slug || repository.split("/")[1].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), commit, ...(presentation ? { description: presentation.description } : {}) };
197
+ }
198
+
199
+ async function publicRepository(repository, credential, ui, fetcher) {
200
+ const read = async () => {
201
+ try { return await api(`https://api.github.com/repos/${repository}`, { headers: headers(credential) }, fetcher); }
202
+ catch (error) {
203
+ if (error.status === 404 || error.status === 403 && !/request limit/.test(error.message)) throw new Error(`Your GitHub account could not access ${repository}.\nCheck https://github.com/${repository} and grant access to this repository, or sign in with gh auth login using the account that owns it.\nThen run menloapp deploy again.`);
204
+ if (error.status === 401) throw new Error("Your GitHub sign-in expired. Run gh auth login, then menloapp deploy.");
205
+ throw error;
206
+ }
207
+ };
208
+ let repo = await read();
209
+ if (repo.private !== false) {
210
+ const url = `https://github.com/${repository}/settings#danger-zone`;
211
+ const message = `${repository} is private. MENLO currently lets testers build from public GitHub source.\nTo deploy this app, make the repository public in GitHub Settings → Danger Zone → Change visibility. This makes its code and history public.\n${url}`;
212
+ if (!ui.interactive) throw new Error(`${message}\n\nThen run menloapp deploy again. To keep this source private, stop here; private repositories are not supported yet.`);
213
+ ui.log(`\n${message}`);
214
+ const answer = await ui.ask("Open GitHub settings to continue? [Y/n] ");
215
+ if (answer && !/^y(es)?$/i.test(answer)) throw new Error("Deploy stopped. Your repository is still private.");
216
+ ui.open(url);
217
+ while (repo.private !== false) {
218
+ await ui.ask("After making the repository public, press Enter to check again (Ctrl+C to cancel): ");
219
+ repo = await read();
220
+ if (repo.private !== false) ui.log("GitHub still reports this repository as private.");
221
+ }
222
+ }
223
+ if (repo.archived || repo.disabled) throw new Error("This GitHub repository is archived or disabled. Restore it in GitHub settings, then run menloapp deploy.");
224
+ return repo;
225
+ }
226
+
227
+ export async function deploy(args, dependencies = {}) {
228
+ const options = deployOptions(args);
229
+ const ui = dependencies.ui || deployUI(options);
230
+ const fetcher = dependencies.fetcher || fetch;
231
+ if (options.help) {
232
+ ui.print("MENLO deploy\n\nmenloapp deploy [path]\n\nSign in to GitHub, choose your app if needed, and get a production app link.\nMENLO guides you through each step. Later pushes appear automatically.\n\nOptions: --project path/App.xcodeproj, --scheme Name, --app-slug your-app,\n --name Name, --dry-run (local checks only), --json (no prompts)\n --record (build and record, then review/commit before publishing),\n --seconds 3–60, --simulator UDID");
233
+ return 0;
234
+ }
235
+ if (options.dryRun) { ui.print(JSON.stringify(await inspectProject(options, ui), null, 2)); return 0; }
236
+ const local = await inspectRepository(options);
237
+ if (await setupPresentation(local.root, options.name || local.repository.split("/")[1])) {
238
+ await continueAfter(ui, "Created menloapp/app.json and asset instructions. Edit your public app details, add your icon and up to three screenshots, then commit and push menloapp/.");
239
+ }
240
+ if (options.record) {
241
+ const project = await inspectProject(options, ui);
242
+ await recordExperience(local.root, project, options, ui);
243
+ return 0;
244
+ }
245
+ ui.log(`MENLO deploy\nRepository: ${local.repository}`);
246
+ const { credential } = await authenticate(ui, { ...dependencies, fetcher });
247
+ const repo = await publicRepository(local.repository, credential, ui, fetcher);
248
+ let project;
249
+ while (true) {
250
+ project = await inspectProject(options, ui);
251
+ if (project.repository !== local.repository) throw new Error("The GitHub origin changed during deployment. Run menloapp deploy again to connect the new repository.");
252
+ const head = await api(`https://api.github.com/repos/${project.repository}/commits/${encodeURIComponent(repo.default_branch)}`, { headers: headers(credential) }, fetcher);
253
+ if (head.sha === project.commit) break;
254
+ const branch = git(local.root, "branch", "--show-current");
255
+ const instruction = branch === repo.default_branch
256
+ ? `Push your committed changes: git push origin ${shellQuote(branch)}\nIf GitHub has newer commits, bring them in first: git pull --ff-only origin ${shellQuote(branch)}`
257
+ : `MENLO follows ${repo.default_branch}. Merge your changes into that branch and push it, then switch this checkout to ${repo.default_branch}.`;
258
+ await continueAfter(ui, `Your local commit ${project.commit.slice(0, 7)} is not the latest commit on GitHub's ${repo.default_branch} branch.\n${instruction}`);
259
+ }
260
+ ui.log(`App: ${project.name}\nProject: ${project.project} · ${project.scheme}\nConnecting this app to MENLO…`);
261
+ let result;
262
+ let reusedLink = false;
263
+ while (true) {
264
+ try {
265
+ // The server reads full presentation copy from the pinned Git commit.
266
+ // Only a short legacy fallback belongs in the registration request.
267
+ result = await api(`${MENLO_ORIGIN}/api/menlo/v1/apps`, { method: "POST", headers: { Authorization: `Bearer ${credential}`, "Content-Type": "application/json" }, body: JSON.stringify({ ...project, description: (repo.description || "").slice(0, 500) }) }, fetcher);
268
+ break;
269
+ } catch (error) {
270
+ const existing = /^This repository already has a link: https:\/\/menloapp\.lol\/([a-z0-9-]+)\. Use --app-slug /.exec(error.message);
271
+ if (error.status === 409 && existing && !reusedLink) {
272
+ project.slug = existing[1]; reusedLink = true;
273
+ ui.log(`Keeping your existing app link: ${MENLO_ORIGIN}/${project.slug}`);
274
+ continue;
275
+ }
276
+ if ((error.status === 409 && /app link.*(?:in use|another repository)/.test(error.message) || error.status === 400 && /app slug/.test(error.message)) && ui.interactive) {
277
+ ui.log(`That app link is unavailable: ${MENLO_ORIGIN}/${project.slug}`);
278
+ const suggestion = `${project.repository.split("/")[0].toLowerCase()}-${project.slug}`.slice(0, 64).replace(/-$/, "");
279
+ let slug;
280
+ do {
281
+ slug = await ui.ask(`Choose another link name [${suggestion}]: `) || suggestion;
282
+ if (slug.length >= 2 && slug.length <= 64 && SLUG.test(slug)) break;
283
+ ui.log("Use 2–64 lowercase letters, numbers, and single hyphens.");
284
+ } while (true);
285
+ project.slug = slug;
286
+ continue;
287
+ }
288
+ if (error.status === 401) throw new Error("GitHub sign-in was not accepted by MENLO. Run gh auth login, then menloapp deploy.");
289
+ if (error.status === 403) throw new Error(`Your GitHub account needs write access to ${project.repository} to deploy it.\nAsk its owner for access, or run gh auth login with an account that has access. Then run menloapp deploy.`);
290
+ throw new Error(`${error.message}\n\nRun menloapp deploy again after completing that step.`);
291
+ }
292
+ }
293
+ if (result.public_url !== `${MENLO_ORIGIN}/${project.slug}`) throw new Error("MENLO did not confirm the expected app link. Run menloapp deploy again to verify it.");
294
+ if (options.json) ui.print(JSON.stringify(result));
295
+ else ui.print(`\nYour app is live:\n${result.public_url}\n\nShare this link. Testers open it in MENLO to build and run the app on their iPhone.\nKeep pushing to ${repo.default_branch}; this link follows your code automatically.\n`);
296
+ return 0;
297
+ }
@@ -0,0 +1,170 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import {
4
+ chmod, cp, lstat, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile,
5
+ } from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import {
9
+ MANIFEST_ORIGINS, MANIFEST_URL, MAX_MANIFEST_BYTES, NPM_CLI_VERSION,
10
+ } from "./constants.js";
11
+ import { extractVerifiedArchive, verifyReleaseTree } from "./archive.js";
12
+ import { downloadArtifact, fetchBounded } from "./download.js";
13
+ import { nodeArchitecture, validateManifest } from "./manifest.js";
14
+ import { installRoot } from "./native.js";
15
+
16
+ const INSTALL_MARKER = "tohseno-stable-install-v2\n";
17
+
18
+ async function realDirectory(directory, mode = 0o700) {
19
+ try {
20
+ const metadata = await lstat(directory);
21
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("installer-owned path is unsafe");
22
+ } catch (error) {
23
+ if (error.code !== "ENOENT") throw error;
24
+ await mkdir(directory, { mode });
25
+ }
26
+ }
27
+
28
+ async function replaceableFile(pathname, allowSymlink = false) {
29
+ try {
30
+ const metadata = await lstat(pathname);
31
+ if ((allowSymlink && metadata.isSymbolicLink()) || (metadata.isFile() && !metadata.isSymbolicLink())) return;
32
+ throw new Error("installer-owned replacement path is unsafe");
33
+ } catch (error) {
34
+ if (error.code !== "ENOENT") throw error;
35
+ }
36
+ }
37
+
38
+ export async function ensureInstallerMarker(root = installRoot()) {
39
+ await realDirectory(root);
40
+ const marker = path.join(root, ".tohseno-install-root");
41
+ await replaceableFile(marker);
42
+ const markerStage = path.join(root, `.install-marker-${randomBytes(12).toString("hex")}`);
43
+ await writeFile(markerStage, INSTALL_MARKER, { flag: "wx", mode: 0o600 });
44
+ await rename(markerStage, marker);
45
+ const legacy = path.join(root, ".installer-managed");
46
+ try {
47
+ const metadata = await lstat(legacy);
48
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 128
49
+ || await readFile(legacy, "utf8") !== INSTALL_MARKER) {
50
+ throw new Error("legacy installer marker is unsafe or unrecognized");
51
+ }
52
+ await rm(legacy);
53
+ } catch (error) {
54
+ if (error.code !== "ENOENT") throw error;
55
+ }
56
+ }
57
+
58
+ async function safeHome() {
59
+ const home = os.homedir();
60
+ if (!path.isAbsolute(home)) throw new Error("the Mac home directory is not absolute");
61
+ let cursor = path.parse(home).root;
62
+ for (const component of home.slice(cursor.length).split(path.sep).filter(Boolean)) {
63
+ cursor = path.join(cursor, component);
64
+ const metadata = await lstat(cursor);
65
+ if (metadata.isSymbolicLink()) throw new Error("the Mac home directory contains a symlink");
66
+ }
67
+ return home;
68
+ }
69
+
70
+ export function verifyAppleSignature(binary, signing, spawn = spawnSync) {
71
+ if (signing.kind !== "apple-developer-id") return;
72
+ // codesign interprets a bare -R argument as a path to a requirements file.
73
+ // The leading `=` is required when the manifest supplies the requirement
74
+ // expression inline.
75
+ const verified = spawn("/usr/bin/codesign", [
76
+ "--verify",
77
+ "--deep",
78
+ "--strict",
79
+ "--test-requirement",
80
+ `=${signing.designated_requirement}`,
81
+ binary,
82
+ ], { encoding: "utf8" });
83
+ if (verified.status !== 0) throw new Error("native Apple signature verification failed");
84
+ const details = spawn("/usr/bin/codesign", ["-dv", "--verbose=4", binary], { encoding: "utf8" });
85
+ if (details.status !== 0 || !details.stderr.includes(`TeamIdentifier=${signing.team_id}`)) {
86
+ throw new Error("native Apple signing Team ID differs from the release manifest");
87
+ }
88
+ }
89
+
90
+ const LAUNCHER = `#!/bin/sh
91
+ set -eu
92
+ launcher_directory="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)"
93
+ launcher_root="$(dirname -- "$launcher_directory")"
94
+ release_root="$(CDPATH= cd -- "$launcher_root/current" && pwd -P)" || {
95
+ printf '%s\n' "tohseno: release pointer is missing or broken" >&2
96
+ exit 1
97
+ }
98
+ case "$release_root/" in
99
+ "$launcher_root"/releases/*/) ;;
100
+ *) printf '%s\n' "tohseno: release pointer escapes its installation root" >&2; exit 1 ;;
101
+ esac
102
+ native="$release_root/bin/tohseno"
103
+ if [ -L "$native" ] || [ ! -f "$native" ] || [ ! -x "$native" ]; then
104
+ printf '%s\n' "tohseno: installed executable is missing or unsafe" >&2
105
+ exit 1
106
+ fi
107
+ exec "$native" "$@"
108
+ `;
109
+
110
+ export async function installAuthorizedNative() {
111
+ await safeHome();
112
+ const manifestBytes = await fetchBounded(MANIFEST_URL, MAX_MANIFEST_BYTES, MANIFEST_ORIGINS);
113
+ let decoded;
114
+ try { decoded = new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes); }
115
+ catch { throw new Error("release manifest is not UTF-8"); }
116
+ if (decoded.charCodeAt(0) === 0xfeff) throw new Error("release manifest has a noncanonical byte-order mark");
117
+ let parsed;
118
+ try { parsed = JSON.parse(decoded); } catch { throw new Error("release manifest is not valid JSON"); }
119
+ const manifest = validateManifest(parsed, NPM_CLI_VERSION, nodeArchitecture());
120
+
121
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "tohseno-native-"));
122
+ try {
123
+ const archive = path.join(temporary, "release.tar.gz");
124
+ await downloadArtifact(manifest.artifact.url, archive, manifest.artifact.byte_size, manifest.artifact.sha256);
125
+ const extracted = await extractVerifiedArchive(archive, path.join(temporary, "extracted"));
126
+ await verifyReleaseTree(extracted, manifest.native_release_version, manifest.artifact.target);
127
+ verifyAppleSignature(path.join(extracted, "bin", "tohseno"), manifest.artifact.signing);
128
+
129
+ const root = installRoot();
130
+ await realDirectory(root);
131
+ const releases = path.join(root, "releases");
132
+ const binaries = path.join(root, "bin");
133
+ await realDirectory(releases);
134
+ await realDirectory(binaries, 0o755);
135
+ const releaseName = `${manifest.native_release_version}-${manifest.artifact.target}-${manifest.artifact.sha256.slice(0, 12)}`;
136
+ const finalRelease = path.join(releases, releaseName);
137
+ try {
138
+ const existing = await lstat(finalRelease);
139
+ if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error("installed native release path is unsafe");
140
+ await verifyReleaseTree(finalRelease, manifest.native_release_version, manifest.artifact.target);
141
+ } catch (error) {
142
+ if (error.code !== "ENOENT") throw error;
143
+ const stage = await mkdtemp(path.join(releases, ".release-stage-"));
144
+ try {
145
+ await cp(extracted, stage, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true, verbatimSymlinks: true });
146
+ await verifyReleaseTree(stage, manifest.native_release_version, manifest.artifact.target);
147
+ verifyAppleSignature(path.join(stage, "bin", "tohseno"), manifest.artifact.signing);
148
+ await rename(stage, finalRelease);
149
+ } catch (stageError) {
150
+ await rm(stage, { recursive: true, force: true });
151
+ throw stageError;
152
+ }
153
+ }
154
+
155
+ const nonce = randomBytes(12).toString("hex");
156
+ await replaceableFile(path.join(root, "current"), true);
157
+ await replaceableFile(path.join(binaries, "tohseno"));
158
+ const currentStage = path.join(root, `.current-${nonce}`);
159
+ await symlink(path.join("releases", releaseName), currentStage);
160
+ await rename(currentStage, path.join(root, "current"));
161
+ const launcherStage = path.join(binaries, `.tohseno-${nonce}`);
162
+ await writeFile(launcherStage, LAUNCHER, { flag: "wx", mode: 0o755 });
163
+ await chmod(launcherStage, 0o755);
164
+ await rename(launcherStage, path.join(binaries, "tohseno"));
165
+ await ensureInstallerMarker(root);
166
+ return { version: manifest.native_release_version, launcher: path.join(binaries, "tohseno") };
167
+ } finally {
168
+ await rm(temporary, { recursive: true, force: true });
169
+ }
170
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ ARTIFACT_ORIGINS,
3
+ MAX_ARTIFACT_BYTES,
4
+ PRODUCT_VERSION,
5
+ RELEASE_LAYOUT,
6
+ } from "./constants.js";
7
+ import { compareVersions, parseVersion } from "./semver.js";
8
+
9
+ const HEX_256 = /^[0-9a-f]{64}$/;
10
+ const ARCHITECTURES = new Map([
11
+ ["arm64", "aarch64-apple-darwin"],
12
+ ["x64", "x86_64-apple-darwin"],
13
+ ]);
14
+ const EXACT_KEYS = [
15
+ "schema",
16
+ "native_release_version",
17
+ "minimum_npm_cli_version",
18
+ "layout_version",
19
+ "artifacts",
20
+ ];
21
+
22
+ function exactKeys(value, expected, label) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
24
+ throw new Error(`${label} must be an object`);
25
+ }
26
+ const observed = Object.keys(value).sort();
27
+ const wanted = [...expected].sort();
28
+ if (observed.length !== wanted.length || observed.some((key, index) => key !== wanted[index])) {
29
+ throw new Error(`${label} contains missing or unexpected fields`);
30
+ }
31
+ }
32
+
33
+ export function validatedHttpsURL(value, origins = ARTIFACT_ORIGINS) {
34
+ let url;
35
+ try { url = new URL(value); } catch { throw new Error("release URL is malformed"); }
36
+ if (url.protocol !== "https:" || url.username || url.password || url.port || !origins.has(url.origin)) {
37
+ throw new Error("release URL is not on the exact HTTPS allowlist");
38
+ }
39
+ if (url.hash) throw new Error("release URL must not contain a fragment");
40
+ return url;
41
+ }
42
+
43
+ export function validateManifest(value, npmVersion, architecture) {
44
+ exactKeys(value, EXACT_KEYS, "release manifest");
45
+ if (value.schema !== "tohseno.native-release-manifest/1") {
46
+ throw new Error("release manifest schema is unsupported");
47
+ }
48
+ parseVersion(value.native_release_version);
49
+ if (value.native_release_version !== PRODUCT_VERSION) {
50
+ throw new Error(
51
+ `tohseno@${npmVersion} requires native TOHSENO ${PRODUCT_VERSION}; the public manifest is not coherent yet`,
52
+ );
53
+ }
54
+ parseVersion(value.minimum_npm_cli_version);
55
+ if (compareVersions(npmVersion, value.minimum_npm_cli_version) < 0) {
56
+ throw new Error("this npm TOHSENO is too old for the authorized native release");
57
+ }
58
+ if (value.layout_version !== RELEASE_LAYOUT) {
59
+ throw new Error("native release layout is unsupported");
60
+ }
61
+ if (!Array.isArray(value.artifacts) || value.artifacts.length !== ARCHITECTURES.size) {
62
+ throw new Error("release manifest must contain exactly one artifact per supported architecture");
63
+ }
64
+ const seen = new Set();
65
+ const artifacts = value.artifacts.map((artifact) => {
66
+ exactKeys(artifact, ["architecture", "target", "url", "byte_size", "sha256", "signing"], "artifact");
67
+ if (!ARCHITECTURES.has(artifact.architecture) || seen.has(artifact.architecture)) {
68
+ throw new Error("release manifest contains a duplicate or unsupported architecture");
69
+ }
70
+ seen.add(artifact.architecture);
71
+ if (artifact.target !== ARCHITECTURES.get(artifact.architecture)) {
72
+ throw new Error("release target does not match its architecture");
73
+ }
74
+ if (!Number.isSafeInteger(artifact.byte_size) || artifact.byte_size <= 0 || artifact.byte_size > MAX_ARTIFACT_BYTES) {
75
+ throw new Error("release artifact byte size is invalid");
76
+ }
77
+ if (typeof artifact.sha256 !== "string" || !HEX_256.test(artifact.sha256)) {
78
+ throw new Error("release artifact SHA-256 is missing or noncanonical");
79
+ }
80
+ const url = validatedHttpsURL(artifact.url);
81
+ exactKeys(artifact.signing, ["kind", "team_id", "designated_requirement"], "artifact signing policy");
82
+ if (!["release-package", "apple-developer-id"].includes(artifact.signing.kind)) {
83
+ throw new Error("release signing policy is unsupported");
84
+ }
85
+ if (artifact.signing.kind === "apple-developer-id") {
86
+ if (!/^[A-Z0-9]{10}$/.test(artifact.signing.team_id)
87
+ || typeof artifact.signing.designated_requirement !== "string"
88
+ || artifact.signing.designated_requirement.length < 10
89
+ || artifact.signing.designated_requirement.length > 512) {
90
+ throw new Error("Apple signing identity policy is invalid");
91
+ }
92
+ } else if (artifact.signing.team_id !== null || artifact.signing.designated_requirement !== null) {
93
+ throw new Error("unsigned release-package policy must not invent an Apple identity");
94
+ }
95
+ return { ...artifact, url };
96
+ });
97
+ const artifact = artifacts.find((candidate) => candidate.architecture === architecture);
98
+ if (!artifact) throw new Error("this Mac architecture is not supported by the release");
99
+ return { ...value, artifacts, artifact };
100
+ }
101
+
102
+ export function nodeArchitecture(value = process.arch) {
103
+ if (!ARCHITECTURES.has(value)) throw new Error("TOHSENO supports Apple silicon and Intel Macs only");
104
+ return value;
105
+ }
package/src/native.js ADDED
@@ -0,0 +1,32 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export function installRoot() { return path.join(os.homedir(), ".tohseno"); }
7
+ export function nativeLauncher() { return path.join(installRoot(), "bin", "tohseno"); }
8
+
9
+ export async function installedNative(expectedVersion = null) {
10
+ return installedNativeAt(nativeLauncher(), installRoot(), expectedVersion);
11
+ }
12
+
13
+ export async function installedNativeAt(launcher, root, expectedVersion = null, spawn = spawnSync) {
14
+ try {
15
+ const metadata = await lstat(launcher);
16
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return null;
17
+ const resolved = await realpath(launcher);
18
+ if (!resolved.startsWith(`${root}${path.sep}`)) return null;
19
+ const result = spawn(launcher, ["--version"], { encoding: "utf8", timeout: 10_000 });
20
+ if (result.status !== 0) return null;
21
+ const match = /^tohseno (\d+\.\d+\.\d+)\n?$/.exec(result.stdout);
22
+ if (!match || (expectedVersion && match[1] !== expectedVersion)) return null;
23
+ return { launcher, version: match[1] };
24
+ } catch { return null; }
25
+ }
26
+
27
+ export function delegate(args) {
28
+ const launcher = nativeLauncher();
29
+ const result = spawnSync(launcher, args, { stdio: "inherit" });
30
+ if (result.error) throw new Error("the installed native TOHSENO could not start");
31
+ return result.status ?? 1;
32
+ }