Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/init-non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"seamless-cli": minor
---

Add a non-interactive `seamless init`. `--yes` (`-y`) answers every question with the option the
prompt marks as recommended, so a scaffold runs from CI, a Dockerfile, or a script with no terminal
attached:

```bash
seamless init my-app --local --yes --email=you@example.com
```

Each question also gets its own flag, honored with or without `--yes`: `--web=<id|alias>` and
`--api=<id|alias>` choose the starters, `--email=<address>` sets the owner who becomes the admin,
`--auth=<docker|local>` picks how the auth server runs, and `--admin=<api|image|source|none>` picks
where the admin console is hosted. Unspecified values fall back to the recommended option, except
the owner email, which has no safe default and is taken from `--email` or the portal session.

`--yes` deliberately stops rather than guessing in three places. Choosing between a managed
application and a local stack needs `--app <id>` or `--local`. Scaffolding into a directory that is
not empty needs `--force`, since starter files overwrite anything with the same name. Rotating a
managed application's existing service token needs `--force` too, because it breaks whatever is
already deployed on the old one.
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m
registry, so no per-flag code. `resolveTemplateAliases` runs in `runCLI` before the project
directory is created and before the non-empty-directory confirmation, so an unknown flag can
never route through a destructive prompt on its way to an error.
- `--yes` runs the whole thing without prompting: every question has a flag (`--web`, `--api`,
`--email`, `--auth`, `--admin`) and anything unspecified falls back to the option the prompt
marks "(recommended)". `--yes` is never enough for a destructive step: overwriting a non-empty
directory and rotating an existing service token both require `--force`, and choosing between a
managed application and a local stack requires `--app` or `--local`. Flag parsing lives in
`parseInitArgs` ([src/index.ts](src/index.ts)); everything it produces is validated in `runCLI`
before a directory is created.
- **templates** ([src/commands/templates.ts](src/commands/templates.ts)) lists the registry
(`seamless templates list [--json]`) so those ids and flags are discoverable without a
checkout. It reads the same source `init` does and needs no login.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,39 @@ registry `init` does, so `SEAMLESS_TEMPLATES_DIR` and `SEAMLESS_TEMPLATES_REF` a

---

## Scripting init

`--yes` answers every question with the recommended option instead of prompting, so `init` runs from
CI, a Dockerfile, or a script with no terminal attached:

```bash
seamless init my-app --local --yes --email=you@example.com
```

Each question also has its own flag, honored with or without `--yes`:

| Flag | Question | Default under `--yes` |
| --- | --- | --- |
| `--web=<id\|alias>` | Web example | first selectable web template |
| `--api=<id\|alias>` | Backend framework | first selectable api template |
| `--email=<address>` | Owner email (becomes the admin) | required |
| `--auth=<docker\|local>` | How the auth server runs | `docker` |
| `--admin=<api\|image\|source\|none>` | Where the admin console is hosted | `api` |

Two things `--yes` deliberately will not decide for you:

- **Managed or local.** With a portal session and neither `--local` nor `--app <id>`, `init` stops
rather than guessing where the project's auth lives.
- **Anything destructive.** Scaffolding into a directory that is not empty (starter files overwrite
anything with the same name) and rotating a managed application's existing service token (which
breaks whatever is deployed on the old one) both take `--force`, not `--yes`.

`--email` has no safe default, so under `--yes` it is required unless `seamless login` has left a
portal session to take it from. Templates that would prompt for OAuth provider credentials are
scaffolded with none configured; add them afterwards with `seamless config oauth-providers add`.

---

## What gets created

Depending on your selections, the CLI generates a project like this:
Expand Down
37 changes: 35 additions & 2 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export interface CommandHelp {
export const COMMAND_HELP: CommandHelp[] = [
{
name: "init",
usage: ["seamless init [project-name] [--<example>]"],
usage: [
"seamless init [project-name] [--<template>]",
"seamless init [project-name] --yes [--web=<id>] [--api=<id>] [--email=<address>] [--auth=<mode>] [--admin=<mode>]",
],
sections: [
{
heading: "init [project-name]",
Expand Down Expand Up @@ -44,7 +47,35 @@ With a template flag (e.g. --oauth, --react-oauth, --fastify):
session from seamless login)

--local
• Point the generated project at a locally running auth stack`,
• Point the generated project at a locally running auth stack

NON-INTERACTIVE

--yes, -y
• Answer every remaining question with the recommended option instead of
prompting, for CI, a Dockerfile, or a scripted run
• Pair it with --local or --app <id>: which stack the project gets is not
something --yes will guess
• It never stands in for a destructive confirmation (see --force)

--web=<id|alias>, --api=<id|alias>
• Choose the web and api starters by name
• Default to the first selectable template of that kind in the registry

--email=<address>
• The owner address, which becomes the admin when you register
• Required under --yes unless a portal session supplies one

--auth=<docker|local>
• How the auth server runs (default: docker)

--admin=<api|image|source|none>
• Where the admin console is hosted (default: api)

--force
• Allow the two destructive steps --yes will not take on its own:
scaffolding into a directory that is not empty, and rotating a managed
application's existing service token`,
},
],
examples: [
Expand All @@ -54,6 +85,8 @@ With a template flag (e.g. --oauth, --react-oauth, --fastify):
→ Create new project in ./my-app`,
`seamless init --oauth my-app
→ Create ./my-app from the OAuth example starter`,
`seamless init my-app --local --yes --email=you@example.com
→ Scaffold the recommended local stack with no prompts`,
],
},
{
Expand Down
216 changes: 215 additions & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ vi.mock("@clack/prompts", () => ({
isCancel: vi.fn(() => false),
}));

vi.mock("../prompts/projectSetup.js", () => ({
// The mode lists are plain constants init validates flags against, so they come
// from the real module; only the prompt runners are stubbed.
vi.mock("../prompts/projectSetup.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../prompts/projectSetup.js")>()),
runManagedTemplatePrompts: vi.fn(),
runProjectSetupPrompts: vi.fn(),
}));
Expand Down Expand Up @@ -587,6 +590,7 @@ describe("template alias resolution", () => {
expect.anything(),
expect.objectContaining({ webTemplateId: "web-oauth" }),
undefined,
undefined,
);
});

Expand All @@ -608,6 +612,7 @@ describe("template alias resolution", () => {
expect.anything(),
expect.objectContaining({ webTemplateId: "web-basic" }),
undefined,
undefined,
);
});

Expand All @@ -617,6 +622,7 @@ describe("template alias resolution", () => {
expect.anything(),
expect.objectContaining({ webTemplateId: "web-oauth" }),
undefined,
undefined,
);
});

Expand Down Expand Up @@ -1250,3 +1256,211 @@ describe("integrateExistingProject", () => {
expect(writeEnv).not.toHaveBeenCalled();
});
});

describe("non-interactive init (--yes)", () => {
function localAnswers(over: Record<string, unknown> = {}) {
return {
webTemplateId: "web-basic",
apiTemplateId: "api-express",
authMode: "docker",
adminMode: "api",
useDocker: true,
ownerEmail: "dev@example.com",
...over,
} as never;
}

beforeEach(() => {
vi.mocked(openTemplateSource).mockResolvedValue(makeSource() as never);
vi.mocked(runProjectSetupPrompts).mockResolvedValue(localAnswers());
vi.mocked(generateDockerCompose).mockResolvedValue({} as never);
});

it("tells the prompts to answer themselves", async () => {
await runCLI(undefined, [], { local: true, yes: true });

expect(runProjectSetupPrompts).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
undefined,
true,
);
});

it("passes the answer flags through as preselected answers", async () => {
await runCLI(undefined, [], {
local: true,
yes: true,
email: "owner@example.com",
auth: "local",
admin: "none",
web: "web-oauth",
api: "api-express",
});

expect(runProjectSetupPrompts).toHaveBeenCalledWith(
expect.anything(),
{
ownerEmail: "owner@example.com",
authMode: "local",
adminMode: "none",
webTemplateId: "web-oauth",
apiTemplateId: "api-express",
},
undefined,
true,
);
});

it("rejects an email that is not an address", async () => {
await expect(
runCLI(undefined, [], { local: true, yes: true, email: "nope" }),
).rejects.toThrow(/--email must be an email address/);
});

it.each([
["auth", "podman", /Unknown value "podman" for --auth/],
["admin", "sidecar", /Unknown value "sidecar" for --admin/],
])("rejects an unknown --%s value", async (flag, value, expected) => {
await expect(
runCLI(undefined, [], { local: true, yes: true, [flag]: value }),
).rejects.toThrow(expected);
});

it("rejects --web naming an api template", async () => {
await expect(
runCLI(undefined, [], { local: true, web: "api-express" }),
).rejects.toThrow(/--web expects a web template/);
});

it("rejects --api disagreeing with a bare template flag", async () => {
await expect(
runCLI(undefined, ["express"], { local: true, api: "api-soon" }),
).rejects.toThrow(/Unknown option "--api-soon"/);
});

it("rejects --web disagreeing with a bare template flag", async () => {
await expect(
runCLI(undefined, ["oauth"], { local: true, web: "web-basic" }),
).rejects.toThrow(/Conflicting web template flags/);
});

// The alias and the id name the same template, so agreeing is not a conflict.
it("accepts --web repeating a bare template flag", async () => {
await expect(
runCLI(undefined, ["oauth"], { local: true, yes: true, web: "web-oauth" }),
).resolves.toBeUndefined();
});

// Starter files overwrite anything with the same name, so a blanket "assume
// yes" is deliberately not enough to reach it.
it("refuses to scaffold into a non-empty directory without --force", async () => {
vi.mocked(fs.readdirSync).mockReturnValue(["src"] as never);

await expect(
runCLI(undefined, [], { local: true, yes: true }),
).rejects.toThrow(/Re-run with --force/);
expect(chooseExistingDirectoryAction).not.toHaveBeenCalled();
expect(runProjectSetupPrompts).not.toHaveBeenCalled();
});

it("scaffolds into a non-empty directory with --force", async () => {
vi.mocked(fs.readdirSync).mockReturnValue(["src"] as never);

await runCLI(undefined, [], { local: true, yes: true, force: true });

expect(chooseExistingDirectoryAction).not.toHaveBeenCalled();
expect(runProjectSetupPrompts).toHaveBeenCalled();
expect(out()).toContain("--force");
});

it("refuses to choose between a managed application and a local stack", async () => {
vi.mocked(createPortalClient).mockResolvedValue({} as never);
vi.mocked(listApplications).mockResolvedValue([app()] as never);

await expect(runCLI(undefined, [], { yes: true })).rejects.toThrow(
/Pass --app <id> .* or --local/,
);
expect(chooseScaffoldTarget).not.toHaveBeenCalled();
});

it("refuses to silently fall back to local when the control plane is unreachable", async () => {
vi.mocked(createPortalClient).mockRejectedValue(new Error("boom"));

await expect(runCLI(undefined, [], { yes: true })).rejects.toThrow(
/--yes will not silently scaffold a local stack/,
);
expect(confirmLocalFallback).not.toHaveBeenCalled();
expect(runProjectSetupPrompts).not.toHaveBeenCalled();
});

it("still scaffolds local when there is no session at all", async () => {
vi.mocked(createPortalClient).mockRejectedValue(
new ReauthRequiredError("no session"),
);

await runCLI(undefined, [], { yes: true, email: "dev@example.com" });

expect(runProjectSetupPrompts).toHaveBeenCalled();
});

it("skips OAuth provider setup rather than prompting for secrets", async () => {
vi.mocked(createPortalClient).mockRejectedValue(
new ReauthRequiredError("no session"),
);
vi.mocked(openTemplateSource).mockResolvedValue(
makeSource({
"web-basic": {
id: "web-basic",
targetDir: "web",
setup: { oauth: true },
},
}) as never,
);

await runCLI(undefined, [], { yes: true, email: "dev@example.com" });

expect(runOAuthSetupPrompts).not.toHaveBeenCalled();
expect(out()).toContain("Skipping OAuth provider setup");
});

it("refuses to rotate an existing service token without --force", async () => {
vi.mocked(createPortalClient).mockResolvedValue({} as never);
vi.mocked(listApplications).mockResolvedValue([
app({ hasServiceToken: true }),
] as never);
vi.mocked(selectApplication).mockResolvedValue(
app({ hasServiceToken: true }) as never,
);
vi.mocked(runManagedTemplatePrompts).mockResolvedValue({
webTemplateId: "web-basic",
apiTemplateId: "api-express",
} as never);

await expect(
runCLI(undefined, [], { yes: true, appId: "app-1" }),
).rejects.toThrow(/Re-run with --force to rotate it anyway/);
expect(rotateServiceToken).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});

it("rotates an existing service token with --force", async () => {
vi.mocked(createPortalClient).mockResolvedValue({} as never);
vi.mocked(listApplications).mockResolvedValue([
app({ hasServiceToken: true }),
] as never);
vi.mocked(selectApplication).mockResolvedValue(
app({ hasServiceToken: true }) as never,
);
vi.mocked(runManagedTemplatePrompts).mockResolvedValue({
webTemplateId: "web-basic",
apiTemplateId: "api-express",
} as never);
vi.mocked(rotateServiceToken).mockResolvedValue("token" as never);

await runCLI(undefined, [], { yes: true, force: true, appId: "app-1" });

expect(rotateServiceToken).toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
});
Loading
Loading