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 +63 -2
- 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 +75 -10
- package/src/project-presentation.js +52 -3
- package/src/repository-setup.js +158 -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,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
|
|
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,
|
|
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
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,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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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 {
|
|
103
|
-
|
|
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("
|
|
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
|
}
|