menloapp 1.5.1 → 1.6.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/README.md +55 -1
- package/bin/tohseno.js +4 -1
- package/package.json +1 -1
- package/src/app-link.js +20 -0
- package/src/cli.js +15 -10
- package/src/constants.js +1 -1
- package/src/deploy-ui.js +8 -2
- package/src/github.js +14 -2
- package/src/project-presentation.js +46 -0
- package/src/review-policy.js +3 -0
- package/src/review.js +66 -0
- package/src/start.js +16 -2
package/README.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Share your iPhone app. Let anyone try it.
|
|
4
4
|
|
|
5
|
+
Starting from scratch:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i -g menloapp
|
|
9
|
+
menloapp
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Keep your intended iPhone connected and unlocked. Menlo checks Xcode and your
|
|
13
|
+
Apple signing, installs Menlo on your iPhone, and guides you through the private
|
|
14
|
+
connection. Setup finishes when the app is installed and paired. Open Menlo on
|
|
15
|
+
your iPhone to send intents; your Mac builds and signs the apps you ask for.
|
|
16
|
+
If setup stops for an Apple prerequisite, follow the instruction and run
|
|
17
|
+
`menloapp setup` to continue.
|
|
18
|
+
|
|
19
|
+
Already have an app to share:
|
|
20
|
+
|
|
5
21
|
```sh
|
|
6
22
|
npm i -g menloapp
|
|
7
23
|
menloapp deploy
|
|
@@ -9,6 +25,12 @@ menloapp deploy
|
|
|
9
25
|
|
|
10
26
|
Run deploy in your app’s public GitHub repository. It returns a live link like
|
|
11
27
|
**https://menloapp.lol/your-app**. Share it. New commits update the same app page.
|
|
28
|
+
Interactive Mac deploy copies the link and prints a ready-to-share X link.
|
|
29
|
+
Empty app descriptions are filled from your public GitHub repository description;
|
|
30
|
+
supplied copy is preserved.
|
|
31
|
+
When there is exactly one committed AppIcon asset catalog, deploy copies its
|
|
32
|
+
normal app icon into the public presentation folder. Existing selected icons
|
|
33
|
+
and artwork are preserved; ambiguous catalogs require you to choose the icon.
|
|
12
34
|
|
|
13
35
|
To install someone’s app:
|
|
14
36
|
|
|
@@ -19,6 +41,37 @@ menloapp try https://menloapp.lol/hello-menlo
|
|
|
19
41
|
MENLO uses your Mac, Xcode and Apple signing identity to build the app for your
|
|
20
42
|
paired iPhone. You review the exact source before its build runs. The iPhone
|
|
21
43
|
needs Trust and Developer Mode enabled.
|
|
44
|
+
The app you came for installs first. You can add Menlo to your iPhone later with
|
|
45
|
+
`menloapp setup`; it is not required to try the linked app.
|
|
46
|
+
|
|
47
|
+
Shared versions include `?commit=<full-commit>&repository=<numeric-id>`.
|
|
48
|
+
`menloapp try` preserves those parameters through installation. From an iPhone,
|
|
49
|
+
Get app offers the system share sheet to send that version to your Mac. After
|
|
50
|
+
a Mac download, returning to the page resumes the selected version.
|
|
51
|
+
|
|
52
|
+
## Review a version
|
|
53
|
+
|
|
54
|
+
Open **Review this version** on an app page, examine the linked source, sign in
|
|
55
|
+
with GitHub, choose the scopes you examined, and explicitly confirm your
|
|
56
|
+
recommendation. Notes are public; you can withdraw your own review from that
|
|
57
|
+
same version page. Browser sign-in lasts ten minutes.
|
|
58
|
+
|
|
59
|
+
You can also use Terminal:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
menloapp review 'https://menloapp.lol/your-app?commit=<full-commit>&repository=<numeric-id>'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The command opens the exact source and asks you to confirm:
|
|
66
|
+
“I reviewed this version's source and recommend it for installation.”
|
|
67
|
+
Add `--scope dependencies`, `--scope networking`, or `--notes "Your findings"`
|
|
68
|
+
to explain what you examined. Use `--withdraw` with the same version link to
|
|
69
|
+
withdraw your recommendation. No source is executed by this command.
|
|
70
|
+
|
|
71
|
+
Reviews name a verified GitHub account and one source commit/build recipe.
|
|
72
|
+
They express human judgment rather than a safety guarantee. New commits and
|
|
73
|
+
changed build recipes start with no inherited reviews. Share the exact-version
|
|
74
|
+
link when recommending code you reviewed.
|
|
22
75
|
|
|
23
76
|
[Discover apps](https://menloapp.lol)
|
|
24
77
|
|
|
@@ -65,7 +118,8 @@ The public page reads metadata and media from your Git commit. Media must be
|
|
|
65
118
|
ordinary files inside `menloapp/`, not symbolic links or Git LFS pointers.
|
|
66
119
|
Only selected files are served. Updates can take up to 60 seconds to appear.
|
|
67
120
|
|
|
68
|
-
Shared links include a 1200 × 630 image with your app icon,
|
|
121
|
+
Shared links include a 1200 × 630 image with your app icon, first screenshot
|
|
122
|
+
when available, title, description,
|
|
69
123
|
and `menloapp.lol/your-app`. To use your own artwork, set
|
|
70
124
|
`"ogImage": "menloapp/share.png"` (PNG or JPEG, up to 10 MiB; 1200 × 630
|
|
71
125
|
recommended), then commit and push the image and `app.json`. Leave `ogImage`
|
package/bin/tohseno.js
CHANGED
|
@@ -5,14 +5,16 @@ import { GUIDE, HELP, parseCommand, redact, suggestedCommand, tryArguments } fro
|
|
|
5
5
|
import { NPM_CLI_VERSION, PRODUCT_VERSION } from "../src/constants.js";
|
|
6
6
|
import { delegate, installedNative } from "../src/native.js";
|
|
7
7
|
import { installAuthorizedNative } from "../src/installer.js";
|
|
8
|
-
import { startProduct } from "../src/start.js";
|
|
8
|
+
import { openProduct, startProduct } from "../src/start.js";
|
|
9
9
|
import { initPresentation } from "../src/project-presentation.js";
|
|
10
10
|
import { deploy } from "../src/github.js";
|
|
11
|
+
import { review } from "../src/review.js";
|
|
11
12
|
|
|
12
13
|
async function main() {
|
|
13
14
|
const raw = process.argv.slice(2);
|
|
14
15
|
if (raw[0] === "init") return initPresentation(raw.slice(1));
|
|
15
16
|
const commandIndex = raw[0] === "--json" ? 1 : 0;
|
|
17
|
+
if (raw[commandIndex] === "review") return review([...raw.slice(commandIndex + 1), ...raw.slice(0, commandIndex)]);
|
|
16
18
|
const suggestion = suggestedCommand(raw[commandIndex]);
|
|
17
19
|
if (suggestion) throw new Error(`Unknown command: ${raw[commandIndex]}. Did you mean menloapp ${suggestion}?`);
|
|
18
20
|
if (raw[commandIndex] === "deploy" && !raw.includes("--legacy-registry")) {
|
|
@@ -53,6 +55,7 @@ async function main() {
|
|
|
53
55
|
if (!installed) throw new Error("the verified native release did not activate safely");
|
|
54
56
|
}
|
|
55
57
|
if (command.kind === "delegate") return delegate(command.args);
|
|
58
|
+
if (command.kind === "open") return openProduct();
|
|
56
59
|
return startProduct();
|
|
57
60
|
}
|
|
58
61
|
|
package/package.json
CHANGED
package/src/app-link.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
2
|
+
|
|
3
|
+
/** Parse only MENLO app links. Version parameters are never silently discarded. */
|
|
4
|
+
export function parseAppLink(value) {
|
|
5
|
+
let slug = value, commit = null, repositoryID = null;
|
|
6
|
+
if (value.startsWith("https://")) {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.origin !== "https://menloapp.lol" || url.username || url.password || (url.hash && !["#get-app", "#reviews"].includes(url.hash))) throw new Error("Use a menloapp.lol app link.");
|
|
9
|
+
for (const key of url.searchParams.keys()) {
|
|
10
|
+
if (!["commit", "repository"].includes(key) || url.searchParams.getAll(key).length !== 1) throw new Error("Use a MENLO app link with one exact version.");
|
|
11
|
+
}
|
|
12
|
+
commit = url.searchParams.get("commit");
|
|
13
|
+
repositoryID = url.searchParams.get("repository");
|
|
14
|
+
if (commit !== null && !SHA.test(commit)) throw new Error("An app version needs the full Git commit.");
|
|
15
|
+
if (repositoryID !== null && (!commit || !/^[1-9]\d*$/.test(repositoryID) || !Number.isSafeInteger(Number(repositoryID)))) throw new Error("Invalid app repository identity.");
|
|
16
|
+
slug = url.pathname.replace(/^\/|\/$/g, "");
|
|
17
|
+
}
|
|
18
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length < 2 || slug.length > 64) throw new Error("Use https://menloapp.lol/your-app");
|
|
19
|
+
return { slug, commit, repositoryID };
|
|
20
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { NPM_CLI_VERSION } from "./constants.js";
|
|
2
|
+
import { parseAppLink } from "./app-link.js";
|
|
2
3
|
|
|
3
4
|
export const HELP = `MENLO · menloapp ${NPM_CLI_VERSION}
|
|
4
5
|
|
|
6
|
+
menloapp Set up Menlo on your iPhone.
|
|
7
|
+
menloapp setup Continue iPhone setup.
|
|
5
8
|
menloapp deploy [path] Share your app. Get a live URL.
|
|
6
9
|
menloapp try <link> Install an app on your iPhone.
|
|
10
|
+
menloapp review <link> Recommend a version you reviewed.
|
|
7
11
|
|
|
8
12
|
menloapp init [path] Edit your app page before deploying
|
|
9
13
|
menloapp open Open MENLO on your Mac
|
|
@@ -12,24 +16,24 @@ export const HELP = `MENLO · menloapp ${NPM_CLI_VERSION}
|
|
|
12
16
|
|
|
13
17
|
Run menloapp deploy --help for advanced options.`;
|
|
14
18
|
|
|
15
|
-
export const GUIDE = `
|
|
19
|
+
export const GUIDE = `Start from scratch:
|
|
20
|
+
menloapp
|
|
21
|
+
Your Mac installs Menlo on your iPhone so you can send intents from there.
|
|
22
|
+
|
|
23
|
+
Share your app:
|
|
16
24
|
menloapp deploy
|
|
17
25
|
|
|
18
26
|
Try an app:
|
|
19
27
|
menloapp try https://menloapp.lol/hello-menlo
|
|
28
|
+
The linked app installs first. Menlo on iPhone is optional afterward.
|
|
20
29
|
|
|
21
30
|
Discover apps at https://menloapp.lol`;
|
|
22
31
|
|
|
23
32
|
export function tryArguments(args) {
|
|
24
33
|
if (!args.length || args.includes("--help") || args.includes("-h")) return null;
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
if (url.origin !== "https://menloapp.lol" || url.username || url.password || url.search || url.hash) throw new Error("Use a menloapp.lol app link.");
|
|
29
|
-
slug = url.pathname.replace(/^\/|\/$/g, "");
|
|
30
|
-
}
|
|
31
|
-
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length < 2 || slug.length > 64) throw new Error("Use menloapp try https://menloapp.lol/your-app");
|
|
32
|
-
return ["github", "install", slug, ...args.slice(1)];
|
|
34
|
+
const { slug, commit, repositoryID } = parseAppLink(args[0]);
|
|
35
|
+
if (commit && args.slice(1).some(arg => /^(--commit|--repository-id)(=|$)/.test(arg))) throw new Error("The link already selects a version. Remove the extra version options.");
|
|
36
|
+
return ["github", "install", slug, ...(commit ? ["--commit", commit] : []), ...(repositoryID ? ["--repository-id", repositoryID] : []), ...args.slice(1)];
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export function suggestedCommand(value) {
|
|
@@ -46,7 +50,8 @@ export function suggestedCommand(value) {
|
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function parseCommand(args) {
|
|
49
|
-
if (!args.length) return { kind: "
|
|
53
|
+
if (!args.length || (args.length === 1 && args[0] === "setup")) return { kind: "start", args: [] };
|
|
54
|
+
if (args.length === 1 && args[0] === "guide") return { kind: "guide", args: [] };
|
|
50
55
|
if (args.length === 1 && ["--help", "-h", "help"].includes(args[0])) return { kind: "help", args: [] };
|
|
51
56
|
if (args.length === 1 && ["--version", "-V"].includes(args[0])) return { kind: "version", args: [] };
|
|
52
57
|
if (args.length === 1 && ["open", "doctor"].includes(args[0])) return { kind: args[0], args: [] };
|
package/src/constants.js
CHANGED
package/src/deploy-ui.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
|
|
5
5
|
export function shellQuote(value) {
|
|
@@ -25,10 +25,16 @@ function ask(question) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function deployUI(options) {
|
|
28
|
+
const interactive = !options.json && !options.dryRun && process.env.MENLO_NONINTERACTIVE !== "1" && Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
28
29
|
return {
|
|
29
|
-
interactive
|
|
30
|
+
interactive,
|
|
30
31
|
log: options.json || options.dryRun ? () => {} : text => console.error(text),
|
|
31
32
|
print: text => console.log(text), ask, open: openBrowser,
|
|
33
|
+
copyLink: url => {
|
|
34
|
+
if (process.platform !== "darwin" || !interactive) return false;
|
|
35
|
+
execFileSync("/usr/bin/pbcopy", [], { input: url, timeout: 2000, stdio: ["pipe", "ignore", "ignore"] });
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
32
38
|
};
|
|
33
39
|
}
|
|
34
40
|
|
package/src/github.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import process from "node:process";
|
|
5
5
|
import { choose, continueAfter, deployUI, openBrowser, shellQuote } from "./deploy-ui.js";
|
|
6
6
|
|
|
7
|
-
import { setupPresentation, readCommittedPresentation, updatePresentationLinks, commitPresentation, previewNeedsRefresh } from "./project-presentation.js";
|
|
7
|
+
import { setupPresentation, readCommittedPresentation, updatePresentationLinks, fillPresentationDescription, fillPresentationIcon, commitPresentation, previewNeedsRefresh } from "./project-presentation.js";
|
|
8
8
|
import { recordExperience } from "./record.js";
|
|
9
9
|
import { previewTools } from "./experience-agent.js";
|
|
10
10
|
|
|
@@ -254,6 +254,8 @@ export async function deploy(args, dependencies = {}) {
|
|
|
254
254
|
}
|
|
255
255
|
const generated = [];
|
|
256
256
|
if (await setupPresentation(local.root, project.name)) generated.push("menloapp/app.json", "menloapp/README.md");
|
|
257
|
+
if (await fillPresentationDescription(local.root, repo.description)) generated.push("menloapp/app.json");
|
|
258
|
+
generated.push(...await fillPresentationIcon(local.root, project.commit));
|
|
257
259
|
const presentation = await readCommittedPresentation(local.root, project.commit);
|
|
258
260
|
if (!options.noPreview && (options.record || previewNeedsRefresh(local.root, project.commit, presentation))) {
|
|
259
261
|
const missing = (dependencies.previewTools || previewTools)();
|
|
@@ -311,7 +313,17 @@ export async function deploy(args, dependencies = {}) {
|
|
|
311
313
|
ui.log("Saved your GitHub and MENLO links to menloapp/app.json.");
|
|
312
314
|
}
|
|
313
315
|
result = { ...result, githubRepo: `https://github.com/${project.repository}`, menloLink: result.public_url };
|
|
316
|
+
const finalCommit = git(local.root, "rev-parse", "HEAD");
|
|
317
|
+
if (Number.isSafeInteger(result.repository_id) && result.repository_id > 0) result.version_url = `${result.public_url}?commit=${finalCommit}&repository=${result.repository_id}`;
|
|
318
|
+
result.share_on_x_url = `https://twitter.com/intent/tweet?${new URLSearchParams({ text: project.name, url: result.public_url })}`;
|
|
314
319
|
if (options.json) ui.print(JSON.stringify(result));
|
|
315
|
-
else
|
|
320
|
+
else {
|
|
321
|
+
let copied = false;
|
|
322
|
+
try {
|
|
323
|
+
const copyLink = dependencies.copyLink ?? ui.copyLink;
|
|
324
|
+
if (copyLink) copied = copyLink(result.public_url) !== false;
|
|
325
|
+
} catch { /* The app is deployed even if the clipboard is unavailable. */ }
|
|
326
|
+
ui.print(`\nYour app is live:\n${result.public_url}\n\n${copied ? "App link copied. " : ""}Share this link. Testers open it in MENLO to build and run the app on their iPhone.\nShare on X: ${result.share_on_x_url}\n${result.version_url ? `This exact version: ${result.version_url}\n` : ""}Keep pushing to ${repo.default_branch}; this link follows your code automatically.\n`);
|
|
327
|
+
}
|
|
316
328
|
return 0;
|
|
317
329
|
}
|
|
@@ -78,6 +78,52 @@ export async function updatePresentationLinks(root, repository, menloLink) {
|
|
|
78
78
|
return true;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export async function fillPresentationDescription(root, description) {
|
|
82
|
+
if (typeof description !== "string" || !description.trim()) return false;
|
|
83
|
+
const file = path.join(root, PRESENTATION_PATH);
|
|
84
|
+
if (!(await lstat(file)).isFile()) throw new Error("menloapp/app.json must be a regular file.");
|
|
85
|
+
const value = validatePresentation(JSON.parse(await readFile(file, "utf8")));
|
|
86
|
+
if (value.description) return false;
|
|
87
|
+
await writeFile(file, JSON.stringify(validatePresentation({ ...value, description: description.slice(0, 4000) }), null, 2) + "\n");
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reuse one unambiguous committed app icon; never guess between app targets. */
|
|
92
|
+
export async function fillPresentationIcon(root, commit) {
|
|
93
|
+
const file = path.join(root, PRESENTATION_PATH);
|
|
94
|
+
if (!(await lstat(file)).isFile()) throw new Error("menloapp/app.json must be a regular file.");
|
|
95
|
+
const value = validatePresentation(JSON.parse(await readFile(file, "utf8")));
|
|
96
|
+
if (value.icon) return [];
|
|
97
|
+
const git = (...args) => execFileSync("git", ["-C", root, ...args], { maxBuffer: MAX_IMAGE_BYTES + 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
98
|
+
const entries = new Map(git("ls-tree", "-r", "-z", commit).toString("utf8").split("\0").filter(Boolean).map(line => { const [info, name] = line.split("\t"); return [name, info.split(" ")[0]]; }));
|
|
99
|
+
const catalogs = [...entries.keys()].filter(name => /\/[^/]+\.appiconset\/Contents\.json$/.test(name));
|
|
100
|
+
if (catalogs.length !== 1 || entries.get(catalogs[0]) !== "100644" || !catalogs[0].endsWith("/AppIcon.appiconset/Contents.json")) return [];
|
|
101
|
+
const read = (name, maximum) => {
|
|
102
|
+
if (entries.get(name) !== "100644" || Number(git("cat-file", "-s", `${commit}:${name}`).toString()) > maximum) return null;
|
|
103
|
+
return git("show", `${commit}:${name}`);
|
|
104
|
+
};
|
|
105
|
+
const contents = read(catalogs[0], MAX_PRESENTATION_BYTES);
|
|
106
|
+
if (!contents) return [];
|
|
107
|
+
let catalog;
|
|
108
|
+
try { catalog = JSON.parse(contents.toString("utf8")); } catch { return []; }
|
|
109
|
+
if (!Array.isArray(catalog.images)) return [];
|
|
110
|
+
const choices = catalog.images.filter(image => image && !image.appearances && typeof image.filename === "string" && /^[A-Za-z0-9_.-]+\.(png|jpe?g)$/.test(image.filename));
|
|
111
|
+
const pixels = image => (parseFloat(image.size) || 0) * (parseFloat(image.scale) || 1);
|
|
112
|
+
choices.sort((a, b) => pixels(b) - pixels(a));
|
|
113
|
+
for (const image of choices) {
|
|
114
|
+
const source = `${path.posix.dirname(catalogs[0])}/${image.filename}`;
|
|
115
|
+
const bytes = read(source, MAX_IMAGE_BYTES);
|
|
116
|
+
if (!bytes) continue;
|
|
117
|
+
const destination = `menloapp/icon.${image.filename.split(".").at(-1)}`;
|
|
118
|
+
try { validateMediaBytes(destination, bytes); } catch { continue; }
|
|
119
|
+
try { await writeFile(path.join(root, destination), bytes, { flag: "wx" }); }
|
|
120
|
+
catch (error) { if (error.code === "EEXIST") return []; throw error; }
|
|
121
|
+
await writeFile(file, JSON.stringify(validatePresentation({ ...value, icon: destination }), null, 2) + "\n");
|
|
122
|
+
return [PRESENTATION_PATH, destination];
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
81
127
|
export function previewNeedsRefresh(root, commit, presentation) {
|
|
82
128
|
if (!presentation?.preview) return true;
|
|
83
129
|
if (presentation.preview.kind !== "simulator") return false;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export const REVIEW_POLICY = "menlo.github-source-review/1";
|
|
2
|
+
export const REVIEW_STATEMENT = "I reviewed this version's source and recommend it for installation.";
|
|
3
|
+
export const REVIEW_SCOPES = ["source", "dependencies", "entitlements", "permissions", "networking", "privacy", "payments", "reproducibility"];
|
package/src/review.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { authenticate, MENLO_ORIGIN } from "./github.js";
|
|
2
|
+
import { parseAppLink } from "./app-link.js";
|
|
3
|
+
import { deployUI } from "./deploy-ui.js";
|
|
4
|
+
import { REVIEW_POLICY, REVIEW_SCOPES, REVIEW_STATEMENT } from "./review-policy.js";
|
|
5
|
+
|
|
6
|
+
export async function review(args, dependencies = {}) {
|
|
7
|
+
if (!args.length || args.includes("--help") || args.includes("-h")) {
|
|
8
|
+
(dependencies.ui?.print ?? console.log)(`menloapp review <link> [--scope dependencies] [--notes "Your findings"]
|
|
9
|
+
|
|
10
|
+
Publish a GitHub-authenticated recommendation of one exact app version.
|
|
11
|
+
Only publish after personally examining the source. This does not build the app.
|
|
12
|
+
Scopes: ${REVIEW_SCOPES.join(", ")}.
|
|
13
|
+
Use --withdraw to withdraw your recommendation of the selected version.
|
|
14
|
+
Noninteractive publishing requires --confirm and a version-pinned link.`);
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const selected = parseAppLink(args[0]);
|
|
18
|
+
let withdraw = false, confirm = false, json = false, notes = "";
|
|
19
|
+
const scopes = new Set(["source"]);
|
|
20
|
+
for (let i = 1; i < args.length; i++) {
|
|
21
|
+
if (args[i] === "--withdraw") withdraw = true;
|
|
22
|
+
else if (args[i] === "--confirm") confirm = true;
|
|
23
|
+
else if (args[i] === "--json") json = true;
|
|
24
|
+
else if (["--scope", "--notes"].includes(args[i])) {
|
|
25
|
+
const option = args[i], value = args[++i];
|
|
26
|
+
if (!value || value.startsWith("--")) throw new Error(`${option} needs a value`);
|
|
27
|
+
if (option === "--notes") notes = value;
|
|
28
|
+
else if (REVIEW_SCOPES.includes(value)) scopes.add(value);
|
|
29
|
+
else throw new Error(`Choose a review scope: ${REVIEW_SCOPES.join(", ")}`);
|
|
30
|
+
} else throw new Error(`Unknown review option: ${args[i]}`);
|
|
31
|
+
}
|
|
32
|
+
if (notes.length > 2000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(notes)) throw new Error("Review notes must be at most 2000 characters.");
|
|
33
|
+
const ui = dependencies.ui ?? deployUI({ json });
|
|
34
|
+
if (!ui.interactive && !confirm || confirm && (!selected.commit || !selected.repositoryID)) throw new Error("Noninteractive reviews require an exact version link and --confirm after you have reviewed that source.");
|
|
35
|
+
const fetcher = dependencies.fetcher ?? fetch;
|
|
36
|
+
const request = async (url, init = {}) => {
|
|
37
|
+
const response = await fetcher(url, { ...init, redirect: "error", signal: AbortSignal.timeout(20_000) });
|
|
38
|
+
const body = await response.json();
|
|
39
|
+
if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : `MENLO review failed (${response.status}).`);
|
|
40
|
+
return body;
|
|
41
|
+
};
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
if (selected.commit) params.set("commit", selected.commit);
|
|
44
|
+
if (selected.repositoryID) params.set("repository", selected.repositoryID);
|
|
45
|
+
const endpoint = `${MENLO_ORIGIN}/api/menlo/v1/apps/${selected.slug}`;
|
|
46
|
+
const app = await request(`${endpoint}${params.size ? `?${params}` : ""}`);
|
|
47
|
+
if (!/^[a-f0-9]{40}$/.test(app.head_commit) || !Number.isSafeInteger(app.repository_id) || app.repository_id <= 0 || selected.commit && app.head_commit !== selected.commit || selected.repositoryID && app.repository_id !== Number(selected.repositoryID) || !/^[A-Za-z0-9-]+\/[A-Za-z0-9_.-]+$/.test(app.repository)) throw new Error("MENLO did not confirm the selected source version.");
|
|
48
|
+
const sourceURL = `https://github.com/${app.repository}/tree/${app.head_commit}`;
|
|
49
|
+
const versionURL = `${MENLO_ORIGIN}/${selected.slug}?commit=${app.head_commit}&repository=${app.repository_id}`;
|
|
50
|
+
ui.log(`Source: ${sourceURL}\nVersion: ${app.head_commit}\nReviewed scopes: ${[...scopes].join(", ")}\n${withdraw ? "Withdraw your recommendation of this version." : REVIEW_STATEMENT}\n${notes ? `Public notes: ${notes}\n` : ""}Your GitHub identity and statement will be public. A new version receives no inherited review.`);
|
|
51
|
+
if (!confirm && !withdraw) ui.open(sourceURL);
|
|
52
|
+
let identity;
|
|
53
|
+
try { identity = await authenticate(ui, { ...dependencies, fetcher }); }
|
|
54
|
+
catch (error) { throw new Error(error.message.replaceAll("menloapp deploy", "menloapp review")); }
|
|
55
|
+
if (!confirm) {
|
|
56
|
+
const word = withdraw ? "withdraw" : "recommend";
|
|
57
|
+
if (await ui.ask(`Publishing as @${identity.user.login}. Type ${word}, or press Enter to cancel: `) !== word) { ui.print("Review cancelled."); return 0; }
|
|
58
|
+
}
|
|
59
|
+
const result = await request(`${endpoint}/reviews?commit=${app.head_commit}&repository=${app.repository_id}`, {
|
|
60
|
+
method: "POST", headers: { Authorization: `Bearer ${identity.credential}`, "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ policy: REVIEW_POLICY, statement: REVIEW_STATEMENT, outcome: withdraw ? "withdraw" : "recommend", repository_id: app.repository_id, commit: app.head_commit, project: app.project, scheme: app.scheme, scopes: withdraw ? [] : [...scopes], notes }),
|
|
62
|
+
});
|
|
63
|
+
if (result.schema !== REVIEW_POLICY || result.commit !== app.head_commit || result.repository_id !== app.repository_id || result.reviewer?.id !== identity.user.id || result.outcome !== (withdraw ? "withdraw" : "recommend")) throw new Error("MENLO did not confirm your review. Check the version page before retrying.");
|
|
64
|
+
ui.print(json ? JSON.stringify(result) : `${withdraw ? "Recommendation withdrawn" : "Source review published"}.\n${versionURL}#reviews`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
package/src/start.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { delegate } from "./native.js";
|
|
2
2
|
|
|
3
3
|
export function startProduct(run = delegate, write = console.log) {
|
|
4
|
-
write("
|
|
4
|
+
write("Welcome to Menlo. First, we’ll install Menlo on your iPhone.");
|
|
5
|
+
write("You’ll send intents from your iPhone. This Mac builds and signs the apps you ask for.");
|
|
6
|
+
write("Connect your iPhone and keep it unlocked. We’ll check Xcode, your Apple signing, installation, and the private connection.");
|
|
5
7
|
const service = run(["service", "install"]);
|
|
6
8
|
if (service !== 0) return service;
|
|
7
|
-
|
|
9
|
+
const setup = run(["companion", "install"]);
|
|
10
|
+
if (setup !== 0) {
|
|
11
|
+
write("Setup is not complete. Follow the step above, then run menloapp setup to continue.");
|
|
12
|
+
return setup;
|
|
13
|
+
}
|
|
14
|
+
write("Menlo is installed and connected. Open it on your iPhone and tap the Menlo button to send your first intent.");
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function openProduct(run = delegate, write = console.log) {
|
|
19
|
+
const service = run(["service", "install"]);
|
|
20
|
+
if (service !== 0) return service;
|
|
21
|
+
write("Opening Menlo on your Mac…");
|
|
8
22
|
return run(["studio"]);
|
|
9
23
|
}
|