menloapp 1.5.1 → 1.7.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,14 +2,43 @@
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
8
24
  ```
9
25
 
10
- Run deploy in your app’s public GitHub repository. It returns a live link like
26
+ Run deploy in your app’s folder. It returns a live link like
11
27
  **https://menloapp.lol/your-app**. Share it. New commits update the same app page.
12
28
 
29
+ Deploy takes care of the Git and GitHub steps, asking before each one. A plain
30
+ folder gets a Git repository (with a standard Xcode `.gitignore` that keeps
31
+ `.env`, signing keys and provisioning profiles out). Uncommitted changes can be
32
+ committed with your message, a missing GitHub repository is created as a public
33
+ repository under your account, and unpushed commits are pushed. Deploy never
34
+ commits files that look like secrets and stops on files GitHub would reject.
35
+ Interactive Mac deploy copies the link and prints a ready-to-share X link.
36
+ Empty app descriptions are filled from your public GitHub repository description;
37
+ supplied copy is preserved.
38
+ When there is exactly one committed AppIcon asset catalog, deploy copies its
39
+ normal app icon into the public presentation folder. Existing selected icons
40
+ and artwork are preserved; ambiguous catalogs require you to choose the icon.
41
+
13
42
  To install someone’s app:
14
43
 
15
44
  ```sh
@@ -19,6 +48,37 @@ menloapp try https://menloapp.lol/hello-menlo
19
48
  MENLO uses your Mac, Xcode and Apple signing identity to build the app for your
20
49
  paired iPhone. You review the exact source before its build runs. The iPhone
21
50
  needs Trust and Developer Mode enabled.
51
+ The app you came for installs first. You can add Menlo to your iPhone later with
52
+ `menloapp setup`; it is not required to try the linked app.
53
+
54
+ Shared versions include `?commit=<full-commit>&repository=<numeric-id>`.
55
+ `menloapp try` preserves those parameters through installation. From an iPhone,
56
+ Get app offers the system share sheet to send that version to your Mac. After
57
+ a Mac download, returning to the page resumes the selected version.
58
+
59
+ ## Review a version
60
+
61
+ Open **Review this version** on an app page, examine the linked source, sign in
62
+ with GitHub, choose the scopes you examined, and explicitly confirm your
63
+ recommendation. Notes are public; you can withdraw your own review from that
64
+ same version page. Browser sign-in lasts ten minutes.
65
+
66
+ You can also use Terminal:
67
+
68
+ ```sh
69
+ menloapp review 'https://menloapp.lol/your-app?commit=<full-commit>&repository=<numeric-id>'
70
+ ```
71
+
72
+ The command opens the exact source and asks you to confirm:
73
+ “I reviewed this version's source and recommend it for installation.”
74
+ Add `--scope dependencies`, `--scope networking`, or `--notes "Your findings"`
75
+ to explain what you examined. Use `--withdraw` with the same version link to
76
+ withdraw your recommendation. No source is executed by this command.
77
+
78
+ Reviews name a verified GitHub account and one source commit/build recipe.
79
+ They express human judgment rather than a safety guarantee. New commits and
80
+ changed build recipes start with no inherited reviews. Share the exact-version
81
+ link when recommending code you reviewed.
22
82
 
23
83
  [Discover apps](https://menloapp.lol)
24
84
 
@@ -65,7 +125,8 @@ The public page reads metadata and media from your Git commit. Media must be
65
125
  ordinary files inside `menloapp/`, not symbolic links or Git LFS pointers.
66
126
  Only selected files are served. Updates can take up to 60 seconds to appear.
67
127
 
68
- Shared links include a 1200 × 630 image with your app icon, title, description,
128
+ Shared links include a 1200 × 630 image with your app icon, first screenshot
129
+ when available, title, description,
69
130
  and `menloapp.lol/your-app`. To use your own artwork, set
70
131
  `"ogImage": "menloapp/share.png"` (PNG or JPEG, up to 10 MiB; 1200 × 630
71
132
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "menloapp",
3
- "version": "1.5.1",
3
+ "version": "1.7.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.1";
1
+ export const NPM_CLI_VERSION = "1.7.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,8 +4,9 @@ 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
+ import { createGitHubRepository, initRepository, offerCommit, pushBranch } from "./repository-setup.js";
9
10
  import { previewTools } from "./experience-agent.js";
10
11
 
11
12
  export const MENLO_ORIGIN = "https://menloapp.lol";
@@ -121,6 +122,11 @@ export async function authenticate(ui, { fetcher = fetch, execute = run, env = p
121
122
  catch (error) { if (error.status === 401) throw new Error(`GitHub did not accept the new sign-in.\n${instructions}`); throw error; }
122
123
  }
123
124
 
125
+ function isAncestor(root, ancestor, commit) {
126
+ try { execFileSync("git", ["-C", root, "merge-base", "--is-ancestor", ancestor, commit], { stdio: "ignore", timeout: 20_000 }); return true; }
127
+ catch { return false; }
128
+ }
129
+
124
130
  function git(directory, ...args) {
125
131
  try { return run("git", ["-C", directory, ...args]); }
126
132
  catch { throw new Error("Open your app's Git repository, then run menloapp deploy. It needs a GitHub origin and at least one commit."); }
@@ -138,9 +144,34 @@ async function inspectRepository(options) {
138
144
  return { root, directory, explicitContainer, repository: githubRepository(remote) };
139
145
  }
140
146
 
147
+ /**
148
+ * Take the folder as far as a GitHub repository with at least one commit,
149
+ * asking before each step: git init, first commit, public GitHub repository.
150
+ */
151
+ async function prepareRepository(options, ui, { credential, user, fetcher, description }) {
152
+ let input;
153
+ try { input = await realpath(options.directory); } catch { throw new Error(`App directory not found: ${options.directory}\nRun menloapp deploy from your app's repository.`); }
154
+ const directory = /\.(xcodeproj|xcworkspace)$/.test(input) ? path.dirname(input) : input;
155
+ let root = optionalRun(run, "git", ["-C", directory, "rev-parse", "--show-toplevel"]);
156
+ if (!root) root = await initRepository(directory, ui);
157
+ if (!optionalRun(run, "git", ["-C", root, "rev-parse", "--verify", "-q", "HEAD"])) {
158
+ if (!ui.interactive) throw new Error("This repository has no commits yet.\nCommit your app (git add -A && git commit -m \"First version\"), then run menloapp deploy.");
159
+ if (!await offerCommit(root, ui, { first: true, name: path.basename(root) })) throw new Error("Deploy needs a first commit. Nothing was published.");
160
+ }
161
+ if (!optionalRun(run, "git", ["-C", root, "remote", "get-url", "origin"])) {
162
+ const call = (url, init) => api(url, { ...init, headers: { ...headers(credential), "Content-Type": "application/json" } }, fetcher);
163
+ await createGitHubRepository(root, ui, { credential, user, api: call, describe: description });
164
+ }
165
+ return inspectRepository(options);
166
+ }
167
+
141
168
  export async function inspectProject(options, ui = deployUI(options)) {
142
169
  const { root, directory, explicitContainer, repository } = await inspectRepository(options);
143
170
  while (git(root, "status", "--porcelain", "--untracked-files=normal")) {
171
+ if (ui.interactive) {
172
+ try { if (await offerCommit(root, ui, { name: path.basename(root) })) continue; }
173
+ catch (error) { ui.log(`\n${error.message}`); }
174
+ }
144
175
  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.");
145
176
  }
146
177
  const commit = git(root, "rev-parse", "HEAD");
@@ -232,21 +263,43 @@ export async function deploy(args, dependencies = {}) {
232
263
  const ui = dependencies.ui || deployUI(options);
233
264
  const fetcher = dependencies.fetcher || fetch;
234
265
  if (options.help) {
235
- 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 (regenerate the automatic preview), --no-preview,\n --seconds 3–60, --simulator UDID");
266
+ 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 sets up Git and a public GitHub repository if needed, asking first.\nLater 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 (regenerate the automatic preview), --no-preview,\n --seconds 3–60, --simulator UDID");
236
267
  return 0;
237
268
  }
238
269
  if (options.dryRun) { ui.print(JSON.stringify(await inspectProject(options, ui), null, 2)); return 0; }
239
- const local = await inspectRepository(options);
240
- ui.log(`MENLO deploy\nRepository: ${local.repository}`);
241
- const { credential } = await authenticate(ui, { ...dependencies, fetcher });
270
+ // A non-GitHub origin fails before sign-in; a missing repository or origin is set up after it.
271
+ const existingRoot = optionalRun(run, "git", ["-C", options.directory, "rev-parse", "--show-toplevel"]);
272
+ const existingRemote = existingRoot && optionalRun(run, "git", ["-C", existingRoot, "remote", "get-url", "origin"]);
273
+ if (existingRemote) githubRepository(existingRemote);
274
+ ui.log("MENLO deploy");
275
+ const { credential, user } = await authenticate(ui, { ...dependencies, fetcher });
276
+ const local = await prepareRepository(options, ui, { credential, user, fetcher, description: "" });
277
+ ui.log(`Repository: ${local.repository}`);
242
278
  const repo = await publicRepository(local.repository, credential, ui, fetcher);
279
+ const sleep = dependencies.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
280
+ let pushed = 0;
243
281
  let project;
244
282
  while (true) {
245
283
  project = await inspectProject(options, ui);
246
284
  if (project.repository !== local.repository) throw new Error("The GitHub origin changed during deployment. Run menloapp deploy again to connect the new repository.");
247
- const head = await api(`https://api.github.com/repos/${project.repository}/commits/${encodeURIComponent(repo.default_branch)}`, { headers: headers(credential) }, fetcher);
248
- if (head.sha === project.commit) break;
285
+ let head;
286
+ try { head = await api(`https://api.github.com/repos/${project.repository}/commits/${encodeURIComponent(repo.default_branch)}`, { headers: headers(credential) }, fetcher); }
287
+ catch (error) { if (error.status !== 409 && error.status !== 404) throw error; head = null; /* An empty GitHub repository. */ }
288
+ if (head?.sha === project.commit) break;
289
+ // GitHub can take a moment to report a branch that was just pushed.
290
+ if (pushed && pushed < 4) { pushed++; await sleep(1500); continue; }
249
291
  const branch = git(local.root, "branch", "--show-current");
292
+ const ahead = branch === repo.default_branch && (!head || isAncestor(local.root, head.sha, project.commit));
293
+ if (ahead && ui.interactive && pushed === 0) {
294
+ const count = Number(optionalRun(run, "git", ["-C", local.root, "rev-list", "--count", head ? `${head.sha}..HEAD` : "HEAD"])) || 1;
295
+ const answer = await ui.ask(`Push ${count === 1 ? "your new commit" : `${count} new commits`} to GitHub's ${branch} branch? [Y/n] `);
296
+ if (!answer || /^y(es)?$/i.test(answer)) {
297
+ pushBranch(local.root, project.repository, branch, credential);
298
+ pushed = 1;
299
+ continue;
300
+ }
301
+ }
302
+ pushed = 0;
250
303
  const instruction = branch === repo.default_branch
251
304
  ? `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)}`
252
305
  : `MENLO follows ${repo.default_branch}. Merge your changes into that branch and push it, then switch this checkout to ${repo.default_branch}.`;
@@ -254,6 +307,8 @@ export async function deploy(args, dependencies = {}) {
254
307
  }
255
308
  const generated = [];
256
309
  if (await setupPresentation(local.root, project.name)) generated.push("menloapp/app.json", "menloapp/README.md");
310
+ if (await fillPresentationDescription(local.root, repo.description)) generated.push("menloapp/app.json");
311
+ generated.push(...await fillPresentationIcon(local.root, project.commit));
257
312
  const presentation = await readCommittedPresentation(local.root, project.commit);
258
313
  if (!options.noPreview && (options.record || previewNeedsRefresh(local.root, project.commit, presentation))) {
259
314
  const missing = (dependencies.previewTools || previewTools)();
@@ -269,7 +324,7 @@ export async function deploy(args, dependencies = {}) {
269
324
  }
270
325
  }
271
326
  if (generated.length) {
272
- (dependencies.commitPresentation || commitPresentation)(local.root, [...new Set(generated)], repo.default_branch);
327
+ (dependencies.commitPresentation || commitPresentation)(local.root, [...new Set(generated)], repo.default_branch, { repository: local.repository, credential });
273
328
  project = await inspectProject(options, ui);
274
329
  }
275
330
  ui.log(`App: ${project.name}\nProject: ${project.project} · ${project.scheme}\nConnecting this app to MENLO…`);
@@ -307,11 +362,21 @@ export async function deploy(args, dependencies = {}) {
307
362
  }
308
363
  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.");
309
364
  if (await updatePresentationLinks(local.root, project.repository, result.public_url)) {
310
- (dependencies.commitPresentation || commitPresentation)(local.root, ["menloapp/app.json"], repo.default_branch);
365
+ (dependencies.commitPresentation || commitPresentation)(local.root, ["menloapp/app.json"], repo.default_branch, { repository: local.repository, credential });
311
366
  ui.log("Saved your GitHub and MENLO links to menloapp/app.json.");
312
367
  }
313
368
  result = { ...result, githubRepo: `https://github.com/${project.repository}`, menloLink: result.public_url };
369
+ const finalCommit = git(local.root, "rev-parse", "HEAD");
370
+ if (Number.isSafeInteger(result.repository_id) && result.repository_id > 0) result.version_url = `${result.public_url}?commit=${finalCommit}&repository=${result.repository_id}`;
371
+ result.share_on_x_url = `https://twitter.com/intent/tweet?${new URLSearchParams({ text: project.name, url: result.public_url })}`;
314
372
  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`);
373
+ else {
374
+ let copied = false;
375
+ try {
376
+ const copyLink = dependencies.copyLink ?? ui.copyLink;
377
+ if (copyLink) copied = copyLink(result.public_url) !== false;
378
+ } catch { /* The app is deployed even if the clipboard is unavailable. */ }
379
+ 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`);
380
+ }
316
381
  return 0;
317
382
  }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { pushBranch } from "./repository-setup.js";
4
5
  import { PRESENTATION_PATH, MAX_PRESENTATION_BYTES, MAX_IMAGE_BYTES, MAX_VIDEO_BYTES, validatePresentation, presentationFiles, validateMediaBytes } from "./presentation.js";
5
6
 
6
7
  export function projectRoot(directory) {
@@ -78,6 +79,52 @@ export async function updatePresentationLinks(root, repository, menloLink) {
78
79
  return true;
79
80
  }
80
81
 
82
+ export async function fillPresentationDescription(root, description) {
83
+ if (typeof description !== "string" || !description.trim()) return false;
84
+ const file = path.join(root, PRESENTATION_PATH);
85
+ if (!(await lstat(file)).isFile()) throw new Error("menloapp/app.json must be a regular file.");
86
+ const value = validatePresentation(JSON.parse(await readFile(file, "utf8")));
87
+ if (value.description) return false;
88
+ await writeFile(file, JSON.stringify(validatePresentation({ ...value, description: description.slice(0, 4000) }), null, 2) + "\n");
89
+ return true;
90
+ }
91
+
92
+ /** Reuse one unambiguous committed app icon; never guess between app targets. */
93
+ export async function fillPresentationIcon(root, commit) {
94
+ const file = path.join(root, PRESENTATION_PATH);
95
+ if (!(await lstat(file)).isFile()) throw new Error("menloapp/app.json must be a regular file.");
96
+ const value = validatePresentation(JSON.parse(await readFile(file, "utf8")));
97
+ if (value.icon) return [];
98
+ const git = (...args) => execFileSync("git", ["-C", root, ...args], { maxBuffer: MAX_IMAGE_BYTES + 1024, stdio: ["ignore", "pipe", "pipe"] });
99
+ 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]]; }));
100
+ const catalogs = [...entries.keys()].filter(name => /\/[^/]+\.appiconset\/Contents\.json$/.test(name));
101
+ if (catalogs.length !== 1 || entries.get(catalogs[0]) !== "100644" || !catalogs[0].endsWith("/AppIcon.appiconset/Contents.json")) return [];
102
+ const read = (name, maximum) => {
103
+ if (entries.get(name) !== "100644" || Number(git("cat-file", "-s", `${commit}:${name}`).toString()) > maximum) return null;
104
+ return git("show", `${commit}:${name}`);
105
+ };
106
+ const contents = read(catalogs[0], MAX_PRESENTATION_BYTES);
107
+ if (!contents) return [];
108
+ let catalog;
109
+ try { catalog = JSON.parse(contents.toString("utf8")); } catch { return []; }
110
+ if (!Array.isArray(catalog.images)) return [];
111
+ const choices = catalog.images.filter(image => image && !image.appearances && typeof image.filename === "string" && /^[A-Za-z0-9_.-]+\.(png|jpe?g)$/.test(image.filename));
112
+ const pixels = image => (parseFloat(image.size) || 0) * (parseFloat(image.scale) || 1);
113
+ choices.sort((a, b) => pixels(b) - pixels(a));
114
+ for (const image of choices) {
115
+ const source = `${path.posix.dirname(catalogs[0])}/${image.filename}`;
116
+ const bytes = read(source, MAX_IMAGE_BYTES);
117
+ if (!bytes) continue;
118
+ const destination = `menloapp/icon.${image.filename.split(".").at(-1)}`;
119
+ try { validateMediaBytes(destination, bytes); } catch { continue; }
120
+ try { await writeFile(path.join(root, destination), bytes, { flag: "wx" }); }
121
+ catch (error) { if (error.code === "EEXIST") return []; throw error; }
122
+ await writeFile(file, JSON.stringify(validatePresentation({ ...value, icon: destination }), null, 2) + "\n");
123
+ return [PRESENTATION_PATH, destination];
124
+ }
125
+ return [];
126
+ }
127
+
81
128
  export function previewNeedsRefresh(root, commit, presentation) {
82
129
  if (!presentation?.preview) return true;
83
130
  if (presentation.preview.kind !== "simulator") return false;
@@ -88,7 +135,7 @@ export function previewNeedsRefresh(root, commit, presentation) {
88
135
  } catch { return true; }
89
136
  }
90
137
 
91
- export function commitPresentation(root, files, branch) {
138
+ export function commitPresentation(root, files, branch, { repository, credential } = {}) {
92
139
  const allowed = new Set(files);
93
140
  for (const file of allowed) if (!file.startsWith("menloapp/") || file.split("/").some(part => part === ".." || part === ".")) throw new Error("Only generated app presentation files can be committed by deploy.");
94
141
  const git = (...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -99,7 +146,9 @@ export function commitPresentation(root, files, branch) {
99
146
  if (!trackedChanges.length && !untracked.length) return false;
100
147
  git("add", "--", ...allowed);
101
148
  git("commit", "-m", "Update MENLO app page", "--only", "--", ...allowed);
102
- try { git("push", "origin", branch); }
103
- catch { throw new Error(`The app page is committed locally, but the push did not complete. Run git push origin ${branch}, then menloapp deploy.`); }
149
+ try {
150
+ if (repository) pushBranch(root, repository, branch, credential);
151
+ else git("push", "origin", branch);
152
+ } catch { throw new Error(`The app page is committed locally, but the push did not complete. Run git push origin ${branch}, then menloapp deploy.`); }
104
153
  return true;
105
154
  }
@@ -0,0 +1,158 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ // Deploy can take a plain folder all the way to a public GitHub repository.
7
+ // Every step that publishes code asks first, and nothing secret-looking is committed.
8
+
9
+ const GITHUB_FILE_LIMIT = 100 * 1024 * 1024;
10
+
11
+ export const XCODE_GITIGNORE = `# Xcode
12
+ build/
13
+ DerivedData/
14
+ xcuserdata/
15
+ *.xcuserstate
16
+ *.xcscmblueprint
17
+ *.xccheckout
18
+ .build/
19
+ .swiftpm/xcode/
20
+
21
+ # macOS
22
+ .DS_Store
23
+
24
+ # Secrets and signing material stay on this Mac
25
+ .env
26
+ .env.*
27
+ *.p8
28
+ *.p12
29
+ *.pem
30
+ *.key
31
+ *.cer
32
+ *.mobileprovision
33
+ *.provisionprofile
34
+ `;
35
+
36
+ // Files that usually hold credentials. Deploy refuses to publish them automatically.
37
+ const RISKY = [
38
+ /(?:^|\/)\.env(?:\.|$)/, /\.(?:p8|p12|pem|key|cer|mobileprovision|provisionprofile|keystore|jks)$/i,
39
+ /(?:^|\/)(?:credentials|secrets?)(?:\.[a-z]+)?$/i, /(?:^|\/)id_(?:rsa|ed25519|ecdsa)$/,
40
+ ];
41
+ export const riskyFile = file => RISKY.some(pattern => pattern.test(file));
42
+
43
+ const gitIn = (root, args, options = {}) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 120_000, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...options }).trim();
44
+
45
+ /** Files that `git add -A` would stage, relative to the repository root. */
46
+ function pendingFiles(root) {
47
+ const untracked = gitIn(root, ["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean);
48
+ let changed = [];
49
+ try { changed = gitIn(root, ["diff", "HEAD", "--name-only", "-z"]).split("\0").filter(Boolean); }
50
+ catch { changed = gitIn(root, ["ls-files", "-z"]).split("\0").filter(Boolean); }
51
+ return [...new Set([...changed, ...untracked])].sort();
52
+ }
53
+
54
+ async function checkPublishable(root, files) {
55
+ const risky = files.filter(riskyFile);
56
+ if (risky.length) throw new Error(`These files look like secrets, so Menlo will not publish them:\n${risky.slice(0, 10).map(file => ` ${file}`).join("\n")}\nAdd them to .gitignore (or remove them), then run menloapp deploy again.`);
57
+ const large = [];
58
+ for (const file of files) {
59
+ const info = await stat(path.join(root, file)).catch(() => null);
60
+ if (info?.isFile() && info.size > GITHUB_FILE_LIMIT) large.push(file);
61
+ }
62
+ if (large.length) throw new Error(`GitHub does not accept files over 100 MB:\n${large.map(file => ` ${file}`).join("\n")}\nAdd them to .gitignore, then run menloapp deploy again.`);
63
+ }
64
+
65
+ function summary(files) {
66
+ const shown = files.slice(0, 12).map(file => ` ${file}`).join("\n");
67
+ return files.length > 12 ? `${shown}\n …and ${files.length - 12} more` : shown;
68
+ }
69
+
70
+ const yes = (answer, fallback) => answer === "" ? fallback : /^y(es)?$/i.test(answer);
71
+
72
+ /** Make a plain app folder into a Git repository with one reviewed first commit. */
73
+ export async function initRepository(directory, ui) {
74
+ if (!ui.interactive) throw new Error("This folder is not a Git repository yet.\nRun menloapp deploy in a terminal and Menlo will set it up, or run:\n git init && git add -A && git commit -m \"First version\"");
75
+ const projects = execFileSync("find", [directory, "-maxdepth", "4", "(", "-name", "*.xcodeproj", "-o", "-name", "*.xcworkspace", ")", "-not", "-path", "*/DerivedData/*", "-not", "-path", "*/build/*", "-not", "-path", "*.xcodeproj/*"], { encoding: "utf8", timeout: 20_000 }).trim();
76
+ if (!projects) throw new Error("No Xcode project was found in this folder.\nRun menloapp deploy from your iOS app's folder.");
77
+ ui.log(`\nThis folder is not a Git repository yet. Menlo shares apps from public GitHub source,\nso it will set one up here (with a standard Xcode .gitignore that keeps secrets out).`);
78
+ if (!yes(await ui.ask("Set up Git here? [Y/n] "), true)) throw new Error("Deploy stopped. Nothing was changed.");
79
+ gitIn(directory, ["init", "-q", "--initial-branch=main"]);
80
+ const ignore = path.join(directory, ".gitignore");
81
+ try { await writeFile(ignore, XCODE_GITIGNORE, { flag: "wx" }); }
82
+ catch (error) { if (error.code !== "EEXIST") throw error; }
83
+ return directory;
84
+ }
85
+
86
+ /**
87
+ * Offer to commit pending work. Returns true when a commit was made.
88
+ * `first` describes a brand-new repository's first commit.
89
+ */
90
+ export async function offerCommit(root, ui, { first = false, name = "app" } = {}) {
91
+ const files = pendingFiles(root);
92
+ if (!files.length) return false;
93
+ await checkPublishable(root, files);
94
+ ui.log(`\n${first ? "Files for the first version" : "Uncommitted changes"} (${files.length}):\n${summary(files)}`);
95
+ const question = first ? `Commit ${files.length === 1 ? "this file" : `these ${files.length} files`}? [Y/n] ` : `Commit ${files.length === 1 ? "this change" : `these ${files.length} changes`} and publish them to GitHub? [y/N] `;
96
+ if (!yes(await ui.ask(question), first)) return false;
97
+ const message = (first ? "" : await ui.ask(`Commit message [Update ${name}]: `)) || (first ? `First version of ${name}` : `Update ${name}`);
98
+ gitIn(root, ["add", "-A"]);
99
+ try { gitIn(root, ["commit", "-q", "-m", message]); }
100
+ catch (error) {
101
+ const detail = String(error.stderr || "").trim();
102
+ throw new Error(`Git could not commit${detail ? `:\n${detail}` : "."}${/user\.(?:name|email)|identity/i.test(detail) ? "\nSet your Git name and email, then run menloapp deploy again:\n git config --global user.name \"Your Name\"\n git config --global user.email you@example.com" : ""}`);
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Push a branch to GitHub. Use the person's own Git credentials first; if
109
+ * those are not set up, use the GitHub sign-in deploy already verified for this
110
+ * one push, passed through the environment and never saved.
111
+ */
112
+ export function pushBranch(root, repository, branch, credential, { upstream = false } = {}) {
113
+ const target = `HEAD:refs/heads/${branch}`;
114
+ const quiet = { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" };
115
+ try { gitIn(root, ["push", "-q", ...(upstream ? ["-u"] : []), "origin", target], { env: quiet, timeout: 600_000 }); return; }
116
+ catch { /* Fall back to the verified sign-in below. */ }
117
+ if (!credential) throw new Error(`Git could not push to GitHub. Run git push origin ${branch}, then menloapp deploy.`);
118
+ const header = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${credential}`).toString("base64")}`;
119
+ const env = { ...quiet, GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_0: "credential.helper", GIT_CONFIG_VALUE_0: "", GIT_CONFIG_KEY_1: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_1: header };
120
+ try { gitIn(root, ["push", "-q", `https://github.com/${repository}.git`, target], { env, timeout: 600_000 }); }
121
+ catch { throw new Error(`GitHub did not accept the push to ${repository}.\nRun git push origin ${branch} to see why, then menloapp deploy again.`); }
122
+ // Keep `git status` accurate after a push that bypassed the remote's configured URL.
123
+ try { gitIn(root, ["fetch", "-q", "origin", branch], { env: quiet, timeout: 60_000 }); } catch { /* Cosmetic only. */ }
124
+ }
125
+
126
+ /** A GitHub repository name derived from the folder, e.g. "My App!" → "My-App". */
127
+ export function repositoryName(directory) {
128
+ return path.basename(directory).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|-+$/g, "").slice(0, 100) || "my-app";
129
+ }
130
+
131
+ /** Create the public GitHub repository for this folder, connect it as origin, and push. */
132
+ export async function createGitHubRepository(root, ui, { credential, user, api, describe = "" }) {
133
+ if (!ui.interactive) throw new Error(`This repository has no GitHub origin.\nRun menloapp deploy in a terminal and Menlo will create the public GitHub repository, or run:\n gh repo create --public --source=. --remote=origin --push`);
134
+ let name = repositoryName(root);
135
+ ui.log(`\nThis app is not on GitHub yet. Menlo shares apps from public GitHub source:\nanyone can read the code and its history.`);
136
+ while (true) {
137
+ const answer = await ui.ask(`Create the public repository github.com/${user.login}/${name}? [Y/n, or type another name] `);
138
+ if (/^n(o)?$/i.test(answer)) throw new Error("Deploy stopped. Nothing was published.");
139
+ if (answer && !/^y(es)?$/i.test(answer)) {
140
+ if (!/^[A-Za-z0-9._-]{1,100}$/.test(answer) || /^\.+$/.test(answer)) { ui.log("Use letters, numbers, dots, hyphens and underscores."); continue; }
141
+ name = answer;
142
+ }
143
+ try {
144
+ await api("https://api.github.com/user/repos", { method: "POST", body: JSON.stringify({ name, private: false, ...(describe ? { description: describe.slice(0, 350) } : {}) }) });
145
+ break;
146
+ } catch (error) {
147
+ if (error.status === 422) { ui.log(`You already have a repository named ${name} on GitHub. Choose another name.`); name = `${name}-app`; continue; }
148
+ if (error.status === 403 || error.status === 404) throw new Error(`Your GitHub sign-in cannot create repositories.\nCreate it at https://github.com/new (public), then run:\n git remote add origin https://github.com/${user.login}/${name}.git\n menloapp deploy`);
149
+ throw error;
150
+ }
151
+ }
152
+ const repository = `${user.login}/${name}`;
153
+ gitIn(root, ["remote", "add", "origin", `https://github.com/${repository}.git`]);
154
+ const branch = gitIn(root, ["branch", "--show-current"]) || "main";
155
+ ui.log(`Created https://github.com/${repository}\nPushing ${branch}…`);
156
+ pushBranch(root, repository, branch, credential, { upstream: true });
157
+ return repository;
158
+ }
@@ -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
  }