menloapp 1.5.0 → 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 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
 
@@ -38,6 +91,7 @@ listing yours:
38
91
  "githubRepo": "https://github.com/your-name/your-app",
39
92
  "menloLink": "https://menloapp.lol/your-app",
40
93
  "icon": "menloapp/icon.png",
94
+ "ogImage": null,
41
95
  "screenshots": [
42
96
  "menloapp/screenshot-1.png",
43
97
  "menloapp/screenshot-2.png",
@@ -64,6 +118,14 @@ The public page reads metadata and media from your Git commit. Media must be
64
118
  ordinary files inside `menloapp/`, not symbolic links or Git LFS pointers.
65
119
  Only selected files are served. Updates can take up to 60 seconds to appear.
66
120
 
121
+ Shared links include a 1200 × 630 image with your app icon, first screenshot
122
+ when available, title, description,
123
+ and `menloapp.lol/your-app`. To use your own artwork, set
124
+ `"ogImage": "menloapp/share.png"` (PNG or JPEG, up to 10 MiB; 1200 × 630
125
+ recommended), then commit and push the image and `app.json`. Leave `ogImage`
126
+ absent or `null` for the automatic card. Use menloapp 1.5.1 or later when
127
+ deploying metadata with this field. Social networks may cache older previews.
128
+
67
129
  ## Automatic previews
68
130
 
69
131
  Deploy can build the committed app in a fresh iPhone Simulator, have Codex
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "menloapp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "MENLO: share living iOS apps from GitHub and try them on your own iPhone",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = `Share your app:
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
- let slug = args[0];
26
- if (slug.startsWith("https://")) {
27
- const url = new URL(slug);
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: "guide", args: [] };
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
@@ -1,4 +1,4 @@
1
- export const NPM_CLI_VERSION = "1.5.0";
1
+ export const NPM_CLI_VERSION = "1.6.0";
2
2
  export const PRODUCT_VERSION = "1.3.0";
3
3
  export const MANIFEST_URL = "https://tohseno.com/releases/cli-1.3.0.json";
4
4
  export const MANIFEST_ORIGINS = new Set(["https://tohseno.com"]);
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: !options.json && !options.dryRun && process.env.MENLO_NONINTERACTIVE !== "1" && Boolean(process.stdin.isTTY && process.stderr.isTTY),
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 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`);
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
  }
@@ -29,7 +29,7 @@ function publicURL(value, field) {
29
29
  /** Public presentation is deliberately small; build and identity are derived elsewhere. */
30
30
  export function validatePresentation(value) {
31
31
  if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1) throw new Error("menloapp/app.json requires version: 1.");
32
- for (const key of Object.keys(value)) if (!["version", "name", "subtitle", "description", "icon", "screenshots", "preview", "tokenAddress", "website", "menloLink", "githubRepo"].includes(key)) throw new Error(`Unknown menloapp/app.json field: ${key}`);
32
+ for (const key of Object.keys(value)) if (!["version", "name", "subtitle", "description", "icon", "ogImage", "screenshots", "preview", "tokenAddress", "website", "menloLink", "githubRepo"].includes(key)) throw new Error(`Unknown menloapp/app.json field: ${key}`);
33
33
  const name = text(value.name, "name", 100, true);
34
34
  const subtitle = text(value.subtitle ?? "", "subtitle", 160);
35
35
  const description = text(value.description ?? "", "description", 4000);
@@ -41,6 +41,7 @@ export function validatePresentation(value) {
41
41
  const tokenAddress = value.tokenAddress == null || value.tokenAddress === "" ? null : value.tokenAddress;
42
42
  if (tokenAddress !== null && (typeof tokenAddress !== "string" || !/^eip155:[1-9][0-9]{0,31}\/erc20:0x[a-fA-F0-9]{40}$/.test(tokenAddress))) throw new Error("tokenAddress must be a CAIP-19 ERC-20 identifier: eip155:CHAIN_ID/erc20:0x followed by 40 hex characters.");
43
43
  const icon = value.icon == null ? null : mediaPath(value.icon);
44
+ const ogImage = value.ogImage == null ? null : mediaPath(value.ogImage);
44
45
  const screenshots = value.screenshots ?? [];
45
46
  if (!Array.isArray(screenshots) || screenshots.length > 3 || new Set(screenshots).size !== screenshots.length) throw new Error("Choose up to three distinct screenshots in menloapp/app.json.");
46
47
  let preview = null;
@@ -50,11 +51,11 @@ export function validatePresentation(value) {
50
51
  if (item.source_commit != null && !/^[a-f0-9]{40}$/.test(item.source_commit)) throw new Error("Preview source_commit must be a full Git commit.");
51
52
  preview = { path: mediaPath(item.path, true), kind: item.kind, ...(item.source_commit ? { source_commit: item.source_commit } : {}) };
52
53
  }
53
- return { version: 1, name, subtitle, description, website, tokenAddress, githubRepo, menloLink, icon, screenshots: screenshots.map(item => mediaPath(item)), preview };
54
+ return { version: 1, name, subtitle, description, website, tokenAddress, githubRepo, menloLink, icon, ogImage, screenshots: screenshots.map(item => mediaPath(item)), preview };
54
55
  }
55
56
 
56
57
  export function presentationFiles(value) {
57
- return [...(value.icon ? [value.icon] : []), ...value.screenshots, ...(value.preview ? [value.preview.path] : [])];
58
+ return [...new Set([...(value.icon ? [value.icon] : []), ...(value.ogImage ? [value.ogImage] : []), ...value.screenshots, ...(value.preview ? [value.preview.path] : [])])];
58
59
  }
59
60
 
60
61
  export function mediaType(file) {
@@ -19,6 +19,9 @@ export async function setupPresentation(root, name = path.basename(root)) {
19
19
 
20
20
  Edit app.json and run menloapp deploy from your app's repository.
21
21
  Name, subtitle, description, icon and screenshots appear on your public page.
22
+ Shared links get a card with the app icon, title, description and MENLO URL.
23
+ Set ogImage to a PNG/JPEG path such as menloapp/share.png for custom share artwork
24
+ (1200 x 630 recommended), or leave it null to use the automatic card.
22
25
  website and tokenAddress are optional. An ERC-20 token uses CAIP-19:
23
26
  eip155:CHAIN_ID/erc20:0x followed by the token's 40 hex address characters.
24
27
  MENLO fills githubRepo and menloLink automatically when you deploy.
@@ -75,6 +78,52 @@ export async function updatePresentationLinks(root, repository, menloLink) {
75
78
  return true;
76
79
  }
77
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
+
78
127
  export function previewNeedsRefresh(root, commit, presentation) {
79
128
  if (!presentation?.preview) return true;
80
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("Starting TOHSENO…");
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
- write("Opening TOHSENO…");
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
  }