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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7380-forge-new-name-what-it-is-makes-an-app-f → main 6 commits fork
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. | ||
| 289 | 289 | parked alice/notes Issue #9 waits on a decision https://forge.example.com/alice/notes/issues/9 |
| 290 | 290 | |
| 291 | 291 | |
| 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 | ||
| 292 | 315 | ## forge map |
| 293 | 316 | |
| 294 | 317 | 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 { | ||
| 49 | 49 | readonly held: boolean; |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | ||
| 52 | 60 | /** Pages of 100 a listing follows before it says it is cut. */ |
| 53 | 61 | const PULL_PAGES = 20; |
| 54 | 62 | |
| @@ -256,6 +264,38 @@ export class ForgeApi { | ||
| 256 | 264 | ); |
| 257 | 265 | } |
| 258 | 266 | |
| 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 | ||
| 259 | 299 | /** One pull, without its diff: title, state, mergeable, hold. */ |
| 260 | 300 | async pull( |
| 261 | 301 | owner: string, |
src/cli/commands/index.ts +2
| @@ -16,6 +16,7 @@ import { command as pulls } from "./pulls.ts"; | ||
| 16 | 16 | import { command as asks } from "./asks.ts"; |
| 17 | 17 | import { command as map } from "./map.ts"; |
| 18 | 18 | import { command as search } from "./search.ts"; |
| 19 | import { command as newApp } from "./new.ts"; | |
| 19 | 20 | import { command as ship } from "./ship.ts"; |
| 20 | 21 | import type { Command } from "./types.ts"; |
| 21 | 22 | |
| @@ -34,5 +35,6 @@ export const commands: readonly Command[] = [ | ||
| 34 | 35 | asks, |
| 35 | 36 | map, |
| 36 | 37 | search, |
| 38 | newApp, | |
| 37 | 39 | ship, |
| 38 | 40 | ]; |
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 @@ | ||
| 10 | 10 | * forge search <words> code, issues, pulls and repos you can read |
| 11 | 11 | * forge asks --ship 1 3 decide asks by their numbers: planned, then a y/N |
| 12 | 12 | * forge asks --undo <id> take back what that decided |
| 13 | * forge new <name> [words] make an app and clone it into your forge folder | |
| 13 | 14 | * forge map the plan: being built, ready now, what waits |
| 14 | 15 | * forge ship <number> land a pull after a y/N, as its Merge button does |
| 15 | 16 | * |
| @@ -34,7 +35,7 @@ export { EXIT }; | ||
| 34 | 35 | /** Every command by name, built once from `commands/index.ts`. */ |
| 35 | 36 | const TABLE = new Map(commands.map((c) => [c.name, c])); |
| 36 | 37 | |
| 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"]; | |
| 38 | 39 | |
| 39 | 40 | export async function main( |
| 40 | 41 | argv: readonly string[], |
src/cli/sync.ts +10
| @@ -112,6 +112,16 @@ export async function syncOnce(opts: SyncOptions): Promise<SyncReport> { | ||
| 112 | 112 | }; |
| 113 | 113 | } |
| 114 | 114 | |
| 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 | ||
| 115 | 125 | /** Every `<owner>/<name>` git repo under the host directory. */ |
| 116 | 126 | export async function scanLocal(root: string): Promise<string[]> { |
| 117 | 127 | 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 | }); |
new above
offline