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/LICENSE +13 -0
- package/README.md +120 -0
- package/bin/tohseno.js +59 -0
- package/package.json +35 -0
- package/src/archive.js +88 -0
- package/src/cli.js +54 -0
- package/src/constants.js +12 -0
- package/src/deploy-ui.js +51 -0
- package/src/download.js +66 -0
- package/src/github.js +297 -0
- package/src/installer.js +170 -0
- package/src/manifest.js +105 -0
- package/src/native.js +32 -0
- package/src/presentation.js +57 -0
- package/src/project-presentation.js +49 -0
- package/src/record.js +79 -0
- package/src/semver.js +16 -0
- package/src/start.js +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 TOHSENO contributors
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# MENLO (`menloapp`)
|
|
2
|
+
|
|
3
|
+
Share your iOS app from a public GitHub repository:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm i -g menloapp
|
|
7
|
+
cd YourApp
|
|
8
|
+
menloapp init
|
|
9
|
+
# Edit menloapp/app.json, then commit and push your changes.
|
|
10
|
+
menloapp deploy
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Deploy returns `https://menloapp.lol/your-app`. Subsequent pushes update the same
|
|
14
|
+
page automatically. GitHub supplies repository identity, commits, source and
|
|
15
|
+
feedback. MENLO checks that you have write access before registering the app.
|
|
16
|
+
There is no gas, wallet, or Companion publication approval on this path.
|
|
17
|
+
|
|
18
|
+
You can also install it as a project dependency:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install menloapp
|
|
22
|
+
npx menloapp init
|
|
23
|
+
# Review, commit and push package.json, package-lock.json and menloapp/.
|
|
24
|
+
npx menloapp deploy
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For a local dependency, keep `/node_modules/` in your project’s `.gitignore`;
|
|
28
|
+
commit the package manifests and your public `menloapp/` folder.
|
|
29
|
+
|
|
30
|
+
Installation runs no setup, download or upload scripts. `menloapp init` creates
|
|
31
|
+
`menloapp/app.json` and a short README without replacing existing files. The
|
|
32
|
+
first deploy creates that folder if it is missing, then asks you to review,
|
|
33
|
+
commit and push it. Node 20+ and Git are required. An existing `gh auth login`
|
|
34
|
+
session works; a configured GitHub device flow is also supported.
|
|
35
|
+
|
|
36
|
+
## Your app page
|
|
37
|
+
|
|
38
|
+
The website reads this file from the same exact Git commit as its media:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"version": 1,
|
|
43
|
+
"name": "Your App",
|
|
44
|
+
"subtitle": "A short reason to try it",
|
|
45
|
+
"description": "What the app does and who it helps.",
|
|
46
|
+
"icon": "menloapp/icon.png",
|
|
47
|
+
"screenshots": [
|
|
48
|
+
"menloapp/screenshot-1.png",
|
|
49
|
+
"menloapp/screenshot-2.png",
|
|
50
|
+
"menloapp/screenshot-3.png"
|
|
51
|
+
],
|
|
52
|
+
"preview": null
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Put the real files at those paths before committing. An icon is optional
|
|
57
|
+
(`null`), and zero to three PNG/JPEG screenshots are supported, up to 10 MiB per
|
|
58
|
+
image. No placeholder icon or fake screenshot is inserted. Metadata limits:
|
|
59
|
+
100 characters for the name, 160 for the subtitle, and 4,000 for the description.
|
|
60
|
+
Descriptions support line breaks and are rendered as text.
|
|
61
|
+
|
|
62
|
+
Files must be ordinary Git files inside `menloapp/`; symbolic links, external
|
|
63
|
+
URLs and Git LFS pointers are not supported. Only files explicitly selected in
|
|
64
|
+
`app.json` are served as presentation media. Media URLs contain the full commit,
|
|
65
|
+
and the server checks the downloaded bytes against that commit's Git blob.
|
|
66
|
+
Pages follow the default branch with a cache of up to 60 seconds. A recording
|
|
67
|
+
can show an earlier app version; its recorded source commit stays visible.
|
|
68
|
+
|
|
69
|
+
## Record an experience
|
|
70
|
+
|
|
71
|
+
Open an iPhone Simulator, then:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
menloapp deploy --record --seconds 20
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This builds an isolated copy of the current committed source for that Simulator,
|
|
78
|
+
launches the chosen app, and records its screen while you use it. It executes
|
|
79
|
+
your project's normal Xcode build scripts. It does not use your physical phone
|
|
80
|
+
or Apple signing identity. The duration is 3–60 seconds. With multiple booted
|
|
81
|
+
Simulators, pass `--simulator UDID` from `xcrun simctl list devices booted`.
|
|
82
|
+
|
|
83
|
+
The command writes a new MP4 and updates `app.json`, then stops for your review.
|
|
84
|
+
Review the recording, commit and push `menloapp/`, and run `menloapp deploy` to
|
|
85
|
+
publish its selection. The page labels it as a Simulator recording and shows
|
|
86
|
+
the exact source commit used to build it. Recording is explicit; deploy does
|
|
87
|
+
not silently record an app or publish private captures.
|
|
88
|
+
|
|
89
|
+
You can supply an existing MP4 of up to 50 MiB instead:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
"preview": { "path": "menloapp/preview.mp4", "kind": "screen-recording" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `"kind": "device"` for your own physical-device recording. These are
|
|
96
|
+
maker-supplied descriptions, not installation attestations. H.264 MP4 gives
|
|
97
|
+
broad browser playback support. Previews have playback controls and are not
|
|
98
|
+
interactive versions of the app.
|
|
99
|
+
|
|
100
|
+
## Deploy and try
|
|
101
|
+
|
|
102
|
+
`menloapp deploy --project path/App.xcodeproj --scheme App --app-slug your-app`
|
|
103
|
+
handles explicit project selection. Slugs use 2–64 lowercase letters, numbers
|
|
104
|
+
and single hyphens. `--dry-run` checks local committed metadata without writing,
|
|
105
|
+
authenticating or publishing. `--json` and noninteractive sessions do not prompt.
|
|
106
|
+
`--name` supplies an initial scaffold name; committed `app.json` is authoritative.
|
|
107
|
+
|
|
108
|
+
Recipients open the link in the MENLO Mac app, or run
|
|
109
|
+
`menloapp github install your-app`. They review the exact source and build with
|
|
110
|
+
Xcode and their own Apple signing identity for their paired intended iPhone.
|
|
111
|
+
Build scripts can require `--approve-mac-review` after local review.
|
|
112
|
+
|
|
113
|
+
Commands needing the native runtime retain the exact signed native 1.3.0
|
|
114
|
+
manifest at `https://tohseno.com/releases/cli-1.3.0.json`; hashes, archive layout,
|
|
115
|
+
and Apple signature checks remain unchanged. Deployment and recording run in
|
|
116
|
+
Node directly. Existing `tohseno` and `menlo` commands belong to the earlier
|
|
117
|
+
package; this package installs only `menloapp`, so it can coexist with them.
|
|
118
|
+
Historical publication is available through `deploy --legacy-registry`.
|
|
119
|
+
|
|
120
|
+
Development: `npm test`, `npm pack --dry-run`.
|
package/bin/tohseno.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { GUIDE, HELP, parseCommand, redact, suggestedCommand } from "../src/cli.js";
|
|
5
|
+
import { NPM_CLI_VERSION, PRODUCT_VERSION } from "../src/constants.js";
|
|
6
|
+
import { delegate, installedNative } from "../src/native.js";
|
|
7
|
+
import { installAuthorizedNative } from "../src/installer.js";
|
|
8
|
+
import { startProduct } from "../src/start.js";
|
|
9
|
+
import { initPresentation } from "../src/project-presentation.js";
|
|
10
|
+
import { deploy } from "../src/github.js";
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
const raw = process.argv.slice(2);
|
|
14
|
+
if (raw[0] === "init") return initPresentation(raw.slice(1));
|
|
15
|
+
const commandIndex = raw[0] === "--json" ? 1 : 0;
|
|
16
|
+
const suggestion = suggestedCommand(raw[commandIndex]);
|
|
17
|
+
if (suggestion) throw new Error(`Unknown command: ${raw[commandIndex]}. Did you mean menloapp ${suggestion}?`);
|
|
18
|
+
if (raw[commandIndex] === "deploy" && !raw.includes("--legacy-registry")) {
|
|
19
|
+
return deploy([...raw.slice(0, commandIndex), ...raw.slice(commandIndex + 1)]);
|
|
20
|
+
}
|
|
21
|
+
const command = parseCommand(process.argv.slice(2));
|
|
22
|
+
if (command.kind === "help") { console.log(HELP); return 0; }
|
|
23
|
+
if (command.kind === "version") { console.log(`menloapp ${NPM_CLI_VERSION}`); return 0; }
|
|
24
|
+
if (command.kind === "guide") { console.log(GUIDE); return 0; }
|
|
25
|
+
if (process.platform !== "darwin") throw new Error("TOHSENO installs on macOS only.");
|
|
26
|
+
let installed = await installedNative(PRODUCT_VERSION);
|
|
27
|
+
if (command.kind === "doctor") {
|
|
28
|
+
const diagnosticNative = installed ?? await installedNative();
|
|
29
|
+
const macOS = spawnSync("/usr/bin/sw_vers", ["-productVersion"], { encoding: "utf8" });
|
|
30
|
+
const tools = spawnSync("/usr/bin/xcode-select", ["-p"], { encoding: "utf8" });
|
|
31
|
+
const xcode = spawnSync("/usr/bin/xcodebuild", ["-version"], { encoding: "utf8" });
|
|
32
|
+
console.log(`macOS: ${macOS.status === 0 ? macOS.stdout.trim() : "unknown"}`);
|
|
33
|
+
console.log(`architecture: ${process.arch}`);
|
|
34
|
+
console.log(`Node: ${process.version}`);
|
|
35
|
+
console.log(`Xcode command-line tools: ${tools.status === 0 ? "ready" : "not ready"}`);
|
|
36
|
+
console.log(`Xcode: ${xcode.status === 0 ? xcode.stdout.split("\n", 1)[0] : "not installed"}`);
|
|
37
|
+
if (!diagnosticNative) {
|
|
38
|
+
console.log("native TOHSENO: not installed");
|
|
39
|
+
console.log("release manifest: checked during install");
|
|
40
|
+
console.log("Local Workspace Service: unavailable until native install");
|
|
41
|
+
console.log("Companion and entitlement: unavailable until native install");
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
return delegate(["doctor"]);
|
|
45
|
+
}
|
|
46
|
+
if (!installed) {
|
|
47
|
+
console.log("Installing the verified TOHSENO CLI runtime…");
|
|
48
|
+
await installAuthorizedNative();
|
|
49
|
+
installed = await installedNative(PRODUCT_VERSION);
|
|
50
|
+
if (!installed) throw new Error("the verified native release did not activate safely");
|
|
51
|
+
}
|
|
52
|
+
if (command.kind === "delegate") return delegate(command.args);
|
|
53
|
+
return startProduct();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
57
|
+
console.error(`menloapp: ${redact(error.message)}`);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "menloapp",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "MENLO: share living iOS apps from GitHub and try them on your own iPhone",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"menloapp": "bin/tohseno.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/tohseno.js",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/jpfraneto/menloapp.git",
|
|
21
|
+
"directory": "packages/cli"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://menloapp.lol",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/jpfraneto/menloapp/issues"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.js",
|
|
33
|
+
"test:pack": "node test/pack-install.test.js"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/archive.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, lstat, mkdir, open, readFile, readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const SAFE_PATH = /^[A-Za-z0-9._/-]+$/;
|
|
8
|
+
|
|
9
|
+
export function validateArchivePaths(listing) {
|
|
10
|
+
const names = listing.split("\n").filter(Boolean);
|
|
11
|
+
if (!names.length || names.length > 100_000) throw new Error("native release archive has an invalid entry count");
|
|
12
|
+
for (const name of names) {
|
|
13
|
+
const normalized = name.replace(/^\.\//, "").replace(/\/$/, "");
|
|
14
|
+
if (!normalized || normalized.startsWith("/") || !SAFE_PATH.test(normalized)
|
|
15
|
+
|| normalized.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
16
|
+
throw new Error("native release archive contains an unsafe path");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return names;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function extractVerifiedArchive(archive, destination) {
|
|
23
|
+
const table = spawnSync("/usr/bin/tar", ["-tzf", archive], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
24
|
+
if (table.status !== 0) throw new Error("native release archive could not be listed");
|
|
25
|
+
validateArchivePaths(table.stdout);
|
|
26
|
+
const verbose = spawnSync("/usr/bin/tar", ["-tvzf", archive], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
27
|
+
if (verbose.status !== 0 || verbose.stdout.split("\n").filter(Boolean).some((line) => !["-", "d"].includes(line[0]))) {
|
|
28
|
+
throw new Error("native release archive contains links or unsupported entries");
|
|
29
|
+
}
|
|
30
|
+
await mkdir(destination, { mode: 0o700 });
|
|
31
|
+
const extracted = spawnSync("/usr/bin/tar", ["-xzf", archive, "-C", destination, "--no-same-owner"], { encoding: "utf8" });
|
|
32
|
+
if (extracted.status !== 0) throw new Error("native release archive extraction failed");
|
|
33
|
+
const roots = (await readdir(destination)).filter((name) => !name.startsWith("."));
|
|
34
|
+
if (roots.length !== 1) throw new Error("native release archive must contain one root directory");
|
|
35
|
+
const root = path.join(destination, roots[0]);
|
|
36
|
+
if (!(await lstat(root)).isDirectory()) throw new Error("native release root is not a directory");
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function walk(root, current = root, output = []) {
|
|
41
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
42
|
+
const absolute = path.join(current, entry.name);
|
|
43
|
+
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
44
|
+
const metadata = await lstat(absolute);
|
|
45
|
+
if (metadata.isSymbolicLink()) throw new Error("native release contains a symlink");
|
|
46
|
+
if (metadata.isDirectory()) await walk(root, absolute, output);
|
|
47
|
+
else if (metadata.isFile()) output.push(relative);
|
|
48
|
+
else throw new Error("native release contains an unsupported file");
|
|
49
|
+
}
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function verifyReleaseTree(root, version, target) {
|
|
54
|
+
const release = JSON.parse(await readFile(path.join(root, "RELEASE.json"), "utf8"));
|
|
55
|
+
if (release.schema !== "tohseno.release/1" || release.version !== version || release.target !== target
|
|
56
|
+
|| release.channel !== "stable" || release.prerelease !== false || release.dirty !== false) {
|
|
57
|
+
throw new Error("native RELEASE.json does not match the authorized release");
|
|
58
|
+
}
|
|
59
|
+
const manifestBytes = await readFile(path.join(root, "CHECKSUMS.sha256"));
|
|
60
|
+
const manifest = manifestBytes.toString("ascii");
|
|
61
|
+
if (!manifest.endsWith("\n")) throw new Error("native checksum manifest is not canonical");
|
|
62
|
+
const expected = new Map();
|
|
63
|
+
for (const line of manifest.trimEnd().split("\n")) {
|
|
64
|
+
const match = /^([0-9a-f]{64}) ([A-Za-z0-9._/-]+)$/.exec(line);
|
|
65
|
+
if (!match || match[2] === "CHECKSUMS.sha256" || expected.has(match[2])
|
|
66
|
+
|| match[2].split("/").some((part) => !part || part === "." || part === "..")) {
|
|
67
|
+
throw new Error("native checksum manifest is invalid");
|
|
68
|
+
}
|
|
69
|
+
expected.set(match[2], match[1]);
|
|
70
|
+
}
|
|
71
|
+
const actual = (await walk(root)).filter((name) => name !== "CHECKSUMS.sha256").sort();
|
|
72
|
+
if (actual.length !== expected.size || actual.some((name, index) => name !== [...expected.keys()].sort()[index])) {
|
|
73
|
+
throw new Error("native checksum manifest does not cover the exact release tree");
|
|
74
|
+
}
|
|
75
|
+
for (const [name, digest] of expected) {
|
|
76
|
+
const bytes = await readFile(path.join(root, name));
|
|
77
|
+
if (createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error("native release tree checksum differs");
|
|
78
|
+
}
|
|
79
|
+
const launcher = path.join(root, "bin", "tohseno");
|
|
80
|
+
const launcherMetadata = await stat(launcher);
|
|
81
|
+
if (!launcherMetadata.isFile() || (launcherMetadata.mode & 0o022) !== 0) {
|
|
82
|
+
throw new Error("native release launcher is missing or writable by another user");
|
|
83
|
+
}
|
|
84
|
+
await access(launcher, constants.X_OK);
|
|
85
|
+
const descriptor = await open(launcher, "r");
|
|
86
|
+
await descriptor.close();
|
|
87
|
+
return release;
|
|
88
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NPM_CLI_VERSION } from "./constants.js";
|
|
2
|
+
|
|
3
|
+
export const HELP = `MENLO (menloapp ${NPM_CLI_VERSION})
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
menloapp init [path] Create your public app metadata folder
|
|
7
|
+
menloapp deploy [path] Connect a public GitHub app and get its link
|
|
8
|
+
menloapp deploy --record Record the app in Simulator for review
|
|
9
|
+
menloapp github install <slug> Build a GitHub app for your iPhone
|
|
10
|
+
menloapp open Open MENLO on your Mac
|
|
11
|
+
menloapp doctor Check this Mac
|
|
12
|
+
menloapp --version Print the npm CLI version
|
|
13
|
+
|
|
14
|
+
Commit and push your app and menloapp/ assets, then deploy. Its link follows
|
|
15
|
+
GitHub's default branch. Testers use Xcode and their own Apple signing identity.`;
|
|
16
|
+
|
|
17
|
+
export const GUIDE = `MENLO CLI ${NPM_CLI_VERSION} is installed.
|
|
18
|
+
|
|
19
|
+
From your app's public GitHub repository:
|
|
20
|
+
cd /path/to/YourApp
|
|
21
|
+
menloapp init
|
|
22
|
+
# Edit menloapp/app.json, then commit and push.
|
|
23
|
+
menloapp deploy
|
|
24
|
+
|
|
25
|
+
Share the link. Keep pushing code. Your testers choose when to update.
|
|
26
|
+
Deploy guides you through GitHub sign-in and choosing an app if needed.`;
|
|
27
|
+
|
|
28
|
+
export function suggestedCommand(value) {
|
|
29
|
+
if (!value || value === "deploy") return null;
|
|
30
|
+
const target = "deploy";
|
|
31
|
+
if (Math.abs(value.length - target.length) > 1) return null;
|
|
32
|
+
for (let i = 0; i < Math.max(value.length, target.length); i++) {
|
|
33
|
+
if (value[i] === target[i]) continue;
|
|
34
|
+
if (value.slice(i + 1) === target.slice(i + 1) || value.slice(i + 1) === target.slice(i) || value.slice(i) === target.slice(i + 1)) return target;
|
|
35
|
+
if (value[i] === target[i + 1] && value[i + 1] === target[i] && value.slice(i + 2) === target.slice(i + 2)) return target;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseCommand(args) {
|
|
42
|
+
if (!args.length) return { kind: "guide", args: [] };
|
|
43
|
+
if (args.length === 1 && ["--help", "-h", "help"].includes(args[0])) return { kind: "help", args: [] };
|
|
44
|
+
if (args.length === 1 && ["--version", "-V"].includes(args[0])) return { kind: "version", args: [] };
|
|
45
|
+
if (args.length === 1 && ["open", "doctor"].includes(args[0])) return { kind: args[0], args: [] };
|
|
46
|
+
return { kind: "delegate", args };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redact(message) {
|
|
50
|
+
return String(message)
|
|
51
|
+
.replace(/([?&](?:token|claim|nonce|secret|key)=)[^&\s]+/gi, "$1[redacted]")
|
|
52
|
+
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g, "[redacted]")
|
|
53
|
+
.replace(/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9_-]+\b/g, "[redacted]");
|
|
54
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const NPM_CLI_VERSION = "1.4.0";
|
|
2
|
+
export const PRODUCT_VERSION = "1.3.0";
|
|
3
|
+
export const MANIFEST_URL = "https://tohseno.com/releases/cli-1.3.0.json";
|
|
4
|
+
export const MANIFEST_ORIGINS = new Set(["https://tohseno.com"]);
|
|
5
|
+
export const ARTIFACT_ORIGINS = new Set([
|
|
6
|
+
"https://github.com",
|
|
7
|
+
"https://objects.githubusercontent.com",
|
|
8
|
+
"https://release-assets.githubusercontent.com",
|
|
9
|
+
]);
|
|
10
|
+
export const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
11
|
+
export const MAX_ARTIFACT_BYTES = 512 * 1024 * 1024;
|
|
12
|
+
export const RELEASE_LAYOUT = "tohseno-user-release/2";
|
package/src/deploy-ui.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
export function shellQuote(value) {
|
|
6
|
+
return /^[A-Za-z0-9_./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function openBrowser(url) {
|
|
10
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
11
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
12
|
+
const child = spawn(command, args, { stdio: "ignore" });
|
|
13
|
+
child.on("error", () => {});
|
|
14
|
+
child.unref();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ask(question) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const reader = createInterface({ input: process.stdin, output: process.stderr });
|
|
20
|
+
let answered = false;
|
|
21
|
+
reader.on("close", () => { if (!answered) reject(new Error("Deploy cancelled. Run menloapp deploy when you are ready.")); });
|
|
22
|
+
reader.on("SIGINT", () => reader.close());
|
|
23
|
+
reader.question(question, answer => { answered = true; reader.close(); resolve(answer.trim()); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function deployUI(options) {
|
|
28
|
+
return {
|
|
29
|
+
interactive: !options.json && !options.dryRun && process.env.MENLO_NONINTERACTIVE !== "1" && Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
30
|
+
log: options.json || options.dryRun ? () => {} : text => console.error(text),
|
|
31
|
+
print: text => console.log(text), ask, open: openBrowser,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function choose(ui, title, values, flag) {
|
|
36
|
+
if (values.length === 1) return values[0];
|
|
37
|
+
const choices = values.map((value, i) => ` ${i + 1}. ${value}`).join("\n");
|
|
38
|
+
if (!ui.interactive) throw new Error(`${title}\n${choices}\n\nRun menloapp deploy in a terminal to choose, or use ${flag} ${shellQuote(values[0])}.`);
|
|
39
|
+
ui.log(`\n${title}\n${choices}`);
|
|
40
|
+
while (true) {
|
|
41
|
+
const answer = await ui.ask(`Choose 1–${values.length}: `);
|
|
42
|
+
if (/^[1-9]\d*$/.test(answer) && Number(answer) <= values.length) return values[Number(answer) - 1];
|
|
43
|
+
ui.log(`Enter a number from 1 to ${values.length}.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function continueAfter(ui, message, question = "After fixing this, press Enter to continue (Ctrl+C to cancel): ") {
|
|
48
|
+
if (!ui.interactive) throw new Error(`${message}\n\nThen run menloapp deploy again.`);
|
|
49
|
+
ui.log(`\n${message}`);
|
|
50
|
+
await ui.ask(question);
|
|
51
|
+
}
|
package/src/download.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWriteStream } from "node:fs";
|
|
3
|
+
import { open, stat } from "node:fs/promises";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { NPM_CLI_VERSION } from "./constants.js";
|
|
7
|
+
import { validatedHttpsURL } from "./manifest.js";
|
|
8
|
+
|
|
9
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
10
|
+
const MAX_REDIRECTS = 3;
|
|
11
|
+
|
|
12
|
+
async function fetchAllowlisted(url, allowedOrigins) {
|
|
13
|
+
let current = validatedHttpsURL(url, allowedOrigins);
|
|
14
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
15
|
+
const response = await fetch(current, {
|
|
16
|
+
redirect: "manual",
|
|
17
|
+
headers: { "user-agent": `tohseno-npm/${NPM_CLI_VERSION}` },
|
|
18
|
+
});
|
|
19
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
20
|
+
if (redirects === MAX_REDIRECTS) throw new Error("release download has too many redirects");
|
|
21
|
+
const location = response.headers.get("location");
|
|
22
|
+
if (!location) throw new Error("release download redirect is missing its destination");
|
|
23
|
+
current = validatedHttpsURL(new URL(location, current).href, allowedOrigins);
|
|
24
|
+
if (response.body) await response.body.cancel();
|
|
25
|
+
}
|
|
26
|
+
throw new Error("release download has too many redirects");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function fetchBounded(url, maximumBytes, allowedOrigins) {
|
|
30
|
+
const response = await fetchAllowlisted(url, allowedOrigins);
|
|
31
|
+
if (!response.ok) throw new Error(`release download failed with HTTP ${response.status}`);
|
|
32
|
+
const declared = Number(response.headers.get("content-length"));
|
|
33
|
+
if (Number.isFinite(declared) && declared > maximumBytes) throw new Error("release response is oversized");
|
|
34
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
35
|
+
if (bytes.byteLength > maximumBytes) throw new Error("release response is oversized");
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function downloadArtifact(url, destination, expectedSize, expectedDigest) {
|
|
40
|
+
const response = await fetchAllowlisted(url);
|
|
41
|
+
if (!response.ok || !response.body) throw new Error(`native release download failed with HTTP ${response.status}`);
|
|
42
|
+
const declared = Number(response.headers.get("content-length"));
|
|
43
|
+
if (Number.isFinite(declared) && declared !== expectedSize) throw new Error("native release declared byte size differs");
|
|
44
|
+
const hash = createHash("sha256");
|
|
45
|
+
let observed = 0;
|
|
46
|
+
const transform = new TransformStream({
|
|
47
|
+
transform(chunk, controller) {
|
|
48
|
+
observed += chunk.byteLength;
|
|
49
|
+
if (observed > expectedSize) throw new Error("native release exceeds its authorized byte size");
|
|
50
|
+
hash.update(chunk);
|
|
51
|
+
controller.enqueue(chunk);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
await pipeline(Readable.fromWeb(response.body.pipeThrough(transform)), createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
55
|
+
if (observed !== expectedSize) throw new Error("native release byte size differs");
|
|
56
|
+
if (hash.digest("hex") !== expectedDigest) throw new Error("native release SHA-256 differs");
|
|
57
|
+
const descriptor = await open(destination, "r");
|
|
58
|
+
try { await descriptor.sync(); } finally { await descriptor.close(); }
|
|
59
|
+
if ((await stat(destination)).size !== expectedSize) throw new Error("native release changed after download");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function verifyArtifactBytes(bytes, expectedSize, expectedDigest) {
|
|
63
|
+
if (bytes.byteLength !== expectedSize) throw new Error("native release byte size differs");
|
|
64
|
+
const observed = createHash("sha256").update(bytes).digest("hex");
|
|
65
|
+
if (observed !== expectedDigest) throw new Error("native release SHA-256 differs");
|
|
66
|
+
}
|