Public: anyone can read this; the crew can change it.

`forge new <name> "what it is"` makes an app from your shell, clones it into your forge folder and prints its address, as POST /api/v1/apps does #7718

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7380-forge-new-name-what-it-is-makes-an-app-f → main 6 commits fork

  • Merged. scout/7380-forge-new-name-what-it-is-makes-an-app-f was merged into main as e0888a3.

Review

Sign in to review this change.
7 files changed +346 −1
docs/CLI.md +23
@@ -289,6 +289,29 @@ look alice/shop Pull #3 is green — look and merge https://forge.example.
289289 parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9
290290 ```
291291
292 ## forge new
293
294 Makes an app from the shell, as `POST /api/v1/apps` does, then clones its
295 repository into the same folder `forge sync` fills (cloned from the address
296 the forge answers with) and prints the app's page and, when it has one, its
297 live address. A refusal (name taken, invalid name, repository, storage or app
298 limit) prints the forge's own words and exits 5, and nothing is cloned. No
299 name is exit 2.
300
301 Flags:
302
303 - `--private` — only you can see it (public otherwise).
304 - `--owner org` — make it under an organization you belong to.
305 - `--root DIR` — the library folder to clone into.
306
307 ```console
308 $ forge new notes a notes app for the team
309 ✓ made alice/notes
310 https://forge.example.com/alice/notes
311 https://notes--alice.example.com
312 cloned to /home/alice/src/forge.example.com/alice/notes
313 ```
314
292315 ## forge map
293316
294317 The plan of the repository: three groups that do not overlap, each headed
src/cli/api.ts +40
@@ -49,6 +49,14 @@ export interface PullSummary {
4949 readonly held: boolean;
5050 }
5151
52 /** What `forge new` needs from the forge's answer. */
53 export interface NewApp {
54 readonly pin: Pin;
55 readonly pageUrl: string;
56 /** The app's live address, when the forge serves it. */
57 readonly liveUrl: string | null;
58 }
59
5260 /** Pages of 100 a listing follows before it says it is cut. */
5361 const PULL_PAGES = 20;
5462
@@ -256,6 +264,38 @@ export class ForgeApi {
256264 );
257265 }
258266
267 /**
268 * Make an app (`POST /api/v1/apps`). The repo comes back as a `Pin`. The
269 * API's repo JSON carries no `cloneUrl` today, so the URL is derived from
270 * the answer's owner and name, as for pins; a `cloneUrl` in the answer
271 * wins. Never built from what was typed.
272 */
273 async newApp(input: {
274 name: string;
275 description?: string;
276 visibility: "public" | "private";
277 owner?: string;
278 }): Promise<ApiResult<NewApp>> {
279 const out = await this.call<Record<string, unknown>>("/api/v1/apps", {
280 method: "POST",
281 body: JSON.stringify(input),
282 });
283 if (!out.ok) return out;
284 const pin = this.toPins([out.value["repo"]])[0];
285 if (pin === undefined)
286 return err({
287 code: "bad_response",
288 message: "the forge answered without a repository",
289 status: 200,
290 });
291 const app = out.value["app"];
292 const live =
293 typeof app === "object" && app !== null
294 ? str((app as Record<string, unknown>)["url"])
295 : undefined;
296 return ok({ pin, pageUrl: this.webUrl(pin.owner, pin.name), liveUrl: live ?? null });
297 }
298
259299 /** One pull, without its diff: title, state, mergeable, hold. */
260300 async pull(
261301 owner: string,
src/cli/commands/index.ts +2
@@ -16,6 +16,7 @@ import { command as pulls } from "./pulls.ts";
1616 import { command as asks } from "./asks.ts";
1717 import { command as map } from "./map.ts";
1818 import { command as search } from "./search.ts";
19 import { command as newApp } from "./new.ts";
1920 import { command as ship } from "./ship.ts";
2021 import type { Command } from "./types.ts";
2122
@@ -34,5 +35,6 @@ export const commands: readonly Command[] = [
3435 asks,
3536 map,
3637 search,
38 newApp,
3739 ship,
3840 ];
src/cli/commands/new.ts added +79
@@ -0,0 +1,79 @@
1 import { ForgeApi, type ApiError } from "../api.ts";
2 import type { Args } from "../args.ts";
3 import { rootDir } from "../config.ts";
4 import { requireConfig } from "../context.ts";
5 import { EXIT } from "../exit.ts";
6 import { inert, type Out } from "../out.ts";
7 import { cloneOne } from "../sync.ts";
8 import { G } from "../../tui/term/screen.ts";
9 import type { Command } from "./types.ts";
10
11 /** A path part from the forge's answer that cannot climb out of the root. */
12 const SAFE_PART = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
14 /**
15 * `forge new notes "a notes app"`: make the app as POST /api/v1/apps does,
16 * then clone it where `forge sync` would. The clone address is the answer's,
17 * never built from the typed name; a refusal prints the forge's own words
18 * and clones nothing.
19 */
20 async function newApp(out: Out, args: Args): Promise<number> {
21 const [name, ...words] = args.positional;
22 if (name === undefined || name.trim() === "") {
23 out.err("forge new: what is it called? e.g. forge new notes \"a notes app\"");
24 return EXIT.usage;
25 }
26 const loaded = await requireConfig(out);
27 if (typeof loaded === "number") return loaded;
28 const api = new ForgeApi(loaded.url, loaded.token);
29 const description = words.join(" ").trim();
30 const owner = args.value("owner");
31 const made = await api.newApp({
32 name,
33 ...(description === "" ? {} : { description }),
34 visibility: args.flag("private") ? "private" : "public",
35 ...(owner === undefined ? {} : { owner }),
36 });
37 if (!made.ok) return refusal(out, made.error);
38
39 const { pin, pageUrl, liveUrl } = made.value;
40 // Before any of the answer's text reaches the terminal or the disk.
41 if (!SAFE_PART.test(pin.owner) || !SAFE_PART.test(pin.name)) {
42 out.err("forge new: the forge named a repository this client will not clone");
43 return EXIT.failed;
44 }
45 out.line(
46 `${out.paint(G.check, out.c.success)} made ${pin.owner}/${pin.name}`,
47 );
48 out.line(pageUrl);
49 if (liveUrl !== null) out.line(inert(liveUrl));
50 const report = await cloneOne(pin, {
51 api,
52 token: loaded.token,
53 root: rootDir(args.value("root")),
54 });
55 if (report.error !== null || !report.present) {
56 out.err(`forge new: made, but not cloned: ${inert(report.error ?? "")}`);
57 out.err(" run `forge sync` to try again");
58 return EXIT.failed;
59 }
60 out.line(out.paint(` cloned to ${report.dir}`, out.c.faint));
61 return EXIT.ok;
62 }
63
64 function refusal(out: Out, error: ApiError): number {
65 out.err(`forge new: ${inert(error.message)}`);
66 if (error.status === 401) return EXIT.notConfigured;
67 if (error.status === 403 || error.status === 409 || error.status === 422)
68 return EXIT.refused;
69 return EXIT.failed;
70 }
71
72 export const command: Command = {
73 name: "new",
74 usage: {
75 synopsis: "forge new <name> [words…]",
76 flags: "[--private] [--owner org] [--root DIR]",
77 },
78 run: (out, args) => newApp(out, args),
79 };
src/cli/main.ts +2 −1
@@ -10,6 +10,7 @@
1010 * forge search <words> code, issues, pulls and repos you can read
1111 * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N
1212 * forge asks --undo <id> take back what that decided
13 * forge new <name> [words] make an app and clone it into your forge folder
1314 * forge map the plan: being built, ready now, what waits
1415 * forge ship <number> land a pull after a y/N, as its Merge button does
1516 *
@@ -34,7 +35,7 @@ export { EXIT };
3435 /** Every command by name, built once from `commands/index.ts`. */
3536 const TABLE = new Map(commands.map((c) => [c.name, c]));
3637
37 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason"];
38 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason", "owner"];
3839
3940 export async function main(
4041 argv: readonly string[],
src/cli/sync.ts +10
@@ -112,6 +112,16 @@ export async function syncOnce(opts: SyncOptions): Promise<SyncReport> {
112112 };
113113 }
114114
115 /** Clone one repo into the same place a sync would, and report it. */
116 export async function cloneOne(
117 pin: Pin,
118 opts: Omit<SyncOptions, "fetch">,
119 ): Promise<RepoReport> {
120 const root = join(opts.root, hostDir(opts.api.url));
121 await mkdir(root, { recursive: true });
122 return visit(root, pin.owner, pin.name, pin, { ...opts, fetch: true });
123 }
124
115125 /** Every `<owner>/<name>` git repo under the host directory. */
116126 export async function scanLocal(root: string): Promise<string[]> {
117127 const out: string[] = [];
test/cli-new.test.ts added +190
@@ -0,0 +1,190 @@
1 /**
2 * `forge new` against a fake API: the request body is the proof of what was
3 * asked, the folder is the proof of what was cloned, and a refusal clones
4 * nothing and prints the forge's own words.
5 */
6
7 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8 import { existsSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { EXIT } from "../src/cli/main.ts";
13 import { gitClientEnv } from "./helpers/git-client.ts";
14
15 const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname;
16
17 describe("forge new", () => {
18 let server: ReturnType<typeof Bun.serve>;
19 let base = "";
20 let work = "";
21 let source = "";
22 let bodies: Record<string, unknown>[] = [];
23 let refusal: { status: number; body: unknown } | null = null;
24 let answer: Record<string, unknown> | null = null;
25 let live: string | null = "https://notes--alice.example.test";
26
27 const git = async (args: string[], cwd: string) => {
28 const p = Bun.spawn(["git", ...args], { cwd, env: gitClientEnv(), stdout: "pipe", stderr: "pipe" });
29 expect(await p.exited).toBe(0);
30 };
31
32 beforeAll(async () => {
33 work = await mkdtemp(join(tmpdir(), "forge-new-"));
34 const seed = join(work, "seed");
35 source = join(work, "source.git");
36 await git(["init", "-q", "-b", "main", seed], work);
37 await Bun.write(join(seed, "README.md"), "hello\n");
38 await git(["add", "."], seed);
39 await git(["commit", "-qm", "first"], seed);
40 await git(["clone", "-q", "--bare", seed, source], work);
41 server = Bun.serve({
42 port: 0,
43 hostname: "127.0.0.1",
44 async fetch(req) {
45 const path = new URL(req.url).pathname;
46 if (req.headers.get("authorization") !== "Bearer t0k")
47 return Response.json({ error: "unauthorized", message: "sign in" }, { status: 401 });
48 if (req.method === "POST" && path === "/api/v1/apps") {
49 bodies.push((await req.json()) as Record<string, unknown>);
50 if (refusal !== null) return Response.json(refusal.body, { status: refusal.status });
51 if (answer !== null) return Response.json(answer, { status: 201 });
52 return Response.json(
53 {
54 // The answer's clone URL, not one built from the typed name.
55 repo: { ownerName: "alice", name: "notes", cloneUrl: source },
56 app: { url: live },
57 issue: null,
58 },
59 { status: 201 },
60 );
61 }
62 return Response.json({ error: "not_found", message: "nope" }, { status: 404 });
63 },
64 });
65 base = `http://127.0.0.1:${server.port}`;
66 });
67
68 afterAll(async () => {
69 server.stop(true);
70 await rm(work, { recursive: true, force: true });
71 });
72
73 let root = "";
74 beforeEach(async () => {
75 bodies = [];
76 refusal = null;
77 answer = null;
78 live = "https://notes--alice.example.test";
79 root = await mkdtemp(join(work, "root-"));
80 });
81
82 async function forge(args: string[]): Promise<{ code: number; out: string; err: string }> {
83 const proc = Bun.spawn(["bun", CLI, ...args, "--root", root], {
84 cwd: work,
85 env: {
86 PATH: process.env["PATH"] ?? "",
87 HOME: process.env["HOME"] ?? "",
88 FORGE_URL: base,
89 FORGE_TOKEN: "t0k",
90 FORGE_KEYCHAIN: "0",
91 NO_COLOR: "1",
92 GIT_CONFIG_GLOBAL: "/dev/null",
93 GIT_CONFIG_NOSYSTEM: "1",
94 },
95 stdin: "ignore",
96 stdout: "pipe",
97 stderr: "pipe",
98 });
99 const [out, err, code] = await Promise.all([
100 new Response(proc.stdout).text(),
101 new Response(proc.stderr).text(),
102 proc.exited,
103 ]);
104 return { code, out, err };
105 }
106
107 const cloned = () => existsSync(join(root, `127.0.0.1_${server.port}`, "alice", "notes", ".git"));
108
109 test("makes the app, clones it, prints the page and the live address", async () => {
110 const r = await forge(["new", "notes", "a", "notes", "app"]);
111 expect(r.code).toBe(EXIT.ok);
112 expect(bodies).toEqual([{ name: "notes", description: "a notes app", visibility: "public" }]);
113 expect(r.out).toContain(`${base}/alice/notes`);
114 expect(r.out).toContain("https://notes--alice.example.test");
115 expect(cloned()).toBe(true);
116 });
117
118 test("--private and --owner are sent", async () => {
119 const r = await forge(["new", "notes", "--private", "--owner", "acme"]);
120 expect(r.code).toBe(EXIT.ok);
121 expect(bodies).toEqual([{ name: "notes", visibility: "private", owner: "acme" }]);
122 });
123
124 test("no live address, none printed", async () => {
125 live = null;
126 const r = await forge(["new", "notes"]);
127 expect(r.code).toBe(EXIT.ok);
128 expect(r.out.trim().split("\n")).toHaveLength(3);
129 });
130
131 test("a refusal prints the API's words, exits refused, and clones nothing", async () => {
132 for (const [status, error, message] of [
133 [422, "name_taken", "you already have a repository named notes"],
134 [403, "repository_limit", "you are at your repository limit"],
135 ] as const) {
136 refusal = { status, body: { error, message } };
137 const r = await forge(["new", "notes"]);
138 expect(r.code).toBe(EXIT.refused);
139 expect(r.err).toContain(message);
140 expect(cloned()).toBe(false);
141 }
142 });
143
144 test("an owner or name that could leave the root is never cloned or printed", async () => {
145 for (const ownerName of ["..", "a/b", "x\x1b[31m"]) {
146 answer = { repo: { ownerName, name: "notes", cloneUrl: source }, app: null };
147 const r = await forge(["new", "notes"]);
148 expect(r.code).toBe(EXIT.failed);
149 expect(r.err).toContain("will not clone");
150 expect(r.out).toBe("");
151 expect(r.err).not.toContain("\x1b");
152 expect(existsSync(join(root, `127.0.0.1_${server.port}`))).toBe(false);
153 }
154 });
155
156 test("made but the clone fails: says so, points at sync, exits failed", async () => {
157 answer = { repo: { ownerName: "alice", name: "notes", cloneUrl: join(work, "missing.git") }, app: null };
158 const r = await forge(["new", "notes"]);
159 expect(r.code).toBe(EXIT.failed);
160 expect(r.out).toContain("made alice/notes");
161 expect(r.err).toContain("made, but not cloned");
162 expect(r.err).toContain("forge sync");
163 expect(cloned()).toBe(false);
164 });
165
166 test("an answer without a repository is a failure, not a crash", async () => {
167 answer = { app: null };
168 const r = await forge(["new", "notes"]);
169 expect(r.code).toBe(EXIT.failed);
170 expect(r.err).toContain("without a repository");
171 });
172
173 test("a 401 exits notConfigured and any other status exits failed", async () => {
174 refusal = { status: 401, body: { error: "unauthorized", message: "token refused" } };
175 let r = await forge(["new", "notes"]);
176 expect(r.code).toBe(EXIT.notConfigured);
177 expect(r.err).toContain("token refused");
178 refusal = { status: 500, body: { error: "boom", message: "it broke" } };
179 r = await forge(["new", "notes"]);
180 expect(r.code).toBe(EXIT.failed);
181 expect(r.err).toContain("it broke");
182 expect(cloned()).toBe(false);
183 });
184
185 test("an empty name is usage and asks nothing of the forge", async () => {
186 const r = await forge(["new"]);
187 expect(r.code).toBe(EXIT.usage);
188 expect(bodies).toEqual([]);
189 });
190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs