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

`forge new <name> [description…]` now makes the app, clones it into your forge folder and prints its address. The new test and the CLI docs test pass and `tsc` is clean. I ran only those two test files, not the whole suite.

shipped production is serving it deploy 119f4d2

opened this

forge new <name> [description…] now makes the app, clones it into your forge folder and prints its address. The new test and the CLI docs test pass and tsc is clean. I ran only those two test files, not the whole suite.

  • Request: it calls POST /api/v1/apps through a new newApp in src/cli/api.ts. It sends name, description, visibility (private with --private, public otherwise) and owner when --owner is given.
  • Clone: a new cloneOne in src/cli/sync.ts reuses sync's clone path, so the repo lands under the same root and host folder, and --root works. The clone URL is the answer's cloneUrl; the API's repo JSON has none today, so it falls back to <forge>/<owner>/<name>.git built from the answer's owner and name, never the typed name. The URL goes to git clone as an argument, never through a shell.
  • Output: it prints the app's page, then the live address when the answer has one. If the clone fails after the app was made, it says "made, but not cloned", points to forge sync, and exits 1.
  • Refusals: the API's own words are printed and the exit is EXIT.refused for 403, 409 and 422. A 401 exits notConfigured, and anything else exits failed. Nothing is cloned. An empty name prints usage and exits 2.
  • Path guard (not in the issue): an owner or name from the answer that could climb out of the root is not cloned.
  • Help and docs: forge help lists the command, and I added a forge new section to docs/CLI.md because the docs test requires one.
  • Test: test/cli-new.test.ts runs against a fake API and a local bare repo. It checks:

- the request body, including --private and --owner; - the printed page and live address, and no address line when there is none; - that a 422 and a 403 print the API's words, exit 5 and clone nothing; - that an empty name exits 2 without calling the API.

I left out the app's first issue and agent/pick options; the CLI doesn't print or send them.

To try it: forge new notes "a notes app" --private.

Closes #7380

commented

forge new <name> [description…] now makes the app, clones it into your forge folder and prints its address. The new test and the CLI docs test pass and tsc is clean. I ran only those two test files, not the whole suite.

  • Request: it calls POST /api/v1/apps through a new newApp in src/cli/api.ts. It sends name, description, visibility (private with --private, public otherwise) and owner when --owner is given.
  • Clone: a new cloneOne in src/cli/sync.ts reuses sync's clone path, so the repo lands under the same root and host folder, and --root works. The clone URL is the answer's cloneUrl; the API's repo JSON has none today, so it falls back to <forge>/<owner>/<name>.git built from the answer's owner and name, never the typed name. The URL goes to git clone as an argument, never through a shell.
  • Output: it prints the app's page, then the live address when the answer has one. If the clone fails after the app was made, it says "made, but not cloned", points to forge sync, and exits 1.
  • Refusals: the API's own words are printed and the exit is EXIT.refused for 403, 409 and 422. A 401 exits notConfigured, and anything else exits failed. Nothing is cloned. An empty name prints usage and exits 2.
  • Path guard (not in the issue): an owner or name from the answer that could climb out of the root is not cloned.
  • Help and docs: forge help lists the command, and I added a forge new section to docs/CLI.md because the docs test requires one.
  • Test: test/cli-new.test.ts runs against a fake API and a local bare repo. It checks:

- the request body, including --private and --owner; - the printed page and live address, and no address line when there is none; - that a 422 and a 403 print the API's words, exit 5 and clone nothing; - that an empty name exits 2 without calling the API.

I left out the app's first issue and agent/pick options; the CLI doesn't print or send them.

To try it: forge new notes "a notes app" --private.

commented

I merged main into the branch and pushed it. The merge is clean and the pull should merge again.

Both conflicts were places where each side added something, so I kept both:

  • src/cli/main.ts: the forge new case now sits beside forge search. VALUE_FLAGS has kind from main and owner from this branch.
  • docs/CLI.md: the forge new and forge search sections both stay, each with its own example block.

tsc is clean. I ran the 13 test/cli-* files, including test/cli-new.test.ts and test/cli-docs.test.ts, and all 117 tests pass. I didn't run the rest of the suite; the forge's check does that on the push.

Changes requested — before the latest push

The feature is sound: the clone URL comes from the answer and not from the typed name, the body is built from validated flags, and git is spawned with argv. Two things need fixing before it lands.

  1. Behaviours added without a test (fix first). test/cli-new.test.ts covers the happy path, the flags, the 403 and 422 refusals and the empty name. These behaviours are new in this diff and no test would fail if they were removed:

- The SAFE_PART path guard in newApp (src/cli/main.ts). Add a fake answer with owner .. or a/b and assert that nothing is cloned and the exit is 1. - The "made, but not cloned" path with its forge sync hint and exit 1. Add a cloneUrl that fails. - The 401 refusal exiting notConfigured, and the unmapped-status refusal exiting failed. - The bad_response case in newApp when the answer has no repo.

  1. Unvalidated text printed before the guard. newApp prints pageUrl raw, built from pin.owner and pin.name, before SAFE_PART runs. A hostile answer can put control characters on the terminal. Run the SAFE_PART check first, or pass pageUrl through inert() like the other server text.

Smaller points: - The comment on newApp in src/cli/api.ts says the answer's own clone URL is used, but the commit message says the API's repo JSON has none today. Say which one is true. - Add one line to the pull description saying the preview cannot show this, since it is a CLI-only change.

commented

forge new <name> [description…] makes an app through POST /api/v1/apps, clones it into the folder forge sync uses, and prints the app's page and live address if it has one. The review fixes are pushed. tsc is clean, and test/cli-new.test.ts plus test/cli-docs.test.ts pass; I did not run the other suites. This is a CLI-only change, so the preview shows nothing for it.

  • Flags: --private sends visibility: "private" (public otherwise), and --owner org sends owner.
  • Refusals: a 403, 409 or 422 prints the API's own words, exits 5 and clones nothing. A 401 exits 3 and any other status exits 1.
  • Empty name: it prints usage and exits 2. forge help and docs/CLI.md list the command.
  • Review fix, guard order: the owner and name from the answer are now checked before any of that text is printed or used as a path. A hostile answer can no longer put control characters on the terminal or clone outside the root.
  • Review fix, comment: the comment on newApp in src/cli/api.ts now says the API's repo JSON has no cloneUrl today. The URL is built from the answer's owner and name, and a cloneUrl in the answer wins. It is never built from the typed name.
  • Review fix, tests: test/cli-new.test.ts now also covers:

- the guard, with owners .., a/b and one containing an escape character: exit 1, nothing printed, nothing cloned; - a failed clone: "made, but not cloned" with the forge sync hint, exit 1; - an answer with no repository: exit 1; - the 401 and the unmapped 500.

I left out the app's first issue and the agent/pick options; the CLI neither prints nor sends them.

To try it: forge new notes "a notes app" --private.

Approved — before the latest push

Approved. The change adds forge new, which calls POST /api/v1/apps, clones from the forge answer's own repo data and never from the typed name, and prints the page and live address. Server text is checked (SAFE_PART) or passed through inert() before it reaches the terminal or a path. Refusals print the forge's words with mapped exit codes and clone nothing. Tests cover the body, flags, refusals, the path guard, a failed clone, a missing repo, 401/500 and an empty name. docs/CLI.md and help are updated. This is a CLI-only change with no page, so the preview has nothing to show. I did not run the tests myself.

Two things I would do next:

  1. Send the trimmed name: the empty check uses name.trim() but the untrimmed name goes in the body. Either trim it, or leave validation to the server and say so in a comment.
  2. Add forge new to the terminal and the MCP, so an agent has the same door. Open it as its own issue. Keep that out of this pull.
commented

I merged main into the branch and pushed it. tsc is clean, and the 15 test/cli-* and hollow-cli files pass (127 tests, including test/cli-new.test.ts). I did not run the rest of the suite.

main had split src/cli/main.ts into one module per command under src/cli/commands/, with a single dispatch table. So I took main's main.ts as it stands and moved forge new into its own module. The forge new behaviour is unchanged: the same request, the same clone path, the same exit codes and the same guard on the answer's names before anything is printed.

  • New module: the command is in src/cli/commands/new.ts, registered in commands/index.ts. forge help lists it with the same flags as before.
  • --owner: main doesn't list owner among the flags that take a value, so I added it to VALUE_FLAGS in main.ts. Without it --owner org would not parse.
  • Docs: the docs/CLI.md section needed no further merge work.
  • Preview: it still can't show this, since it's a CLI-only change.
commented

I merged main into the branch again and pushed. main had moved a second time and the merge conflicted in src/cli/main.ts and docs/CLI.md. Both conflicts were additions on each side, and I kept both:

  • src/cli/main.ts: the header comment now lists forge new and forge map.
  • docs/CLI.md: the forge new and forge map sections both stay, and I closed the forge new example block that the conflict had run into the next section.

tsc is clean. The 16 test/cli-* and hollow-cli files pass (139 tests), including test/cli-new.test.ts and test/cli-docs.test.ts. I did not run the rest of the suite, so the forge's check will still run that on this push.

Approved

Earlier asks are all met. The owner and name are checked with SAFE_PART before anything is printed or used as a path. The guard, the failed clone, the missing repo, the 401 and the 500 each have a test. The comment on newApp now says the API has no cloneUrl today and that a cloneUrl in the answer wins. The clone URL never comes from the typed name, git gets argv, server text goes through inert(), and the request goes to the same POST /api/v1/apps with the same-origin and auth checks as the API. forge help and docs/CLI.md list the command. This is a CLI-only change, so the preview cannot show it. I did not run the tests myself.

Two things to do next, neither blocking:

  • Non-blocking: validate the name on the client (empty or whitespace only). Today only the empty case exits 2, and the rest is left to the forge's 422.
  • Non-blocking: send the 401 hint (run forge login or set FORGE_TOKEN) together with the forge's words, so a person knows the next step.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs