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
21 changes: 21 additions & 0 deletions .changeset/mobile-template-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'seamless-cli': minor
---

`seamless init` learns the `mobile` template kind.

`seamless-templates` gains a third kind next to `web` and `api`, starting with an Expo (React
Native) starter. The CLI treated an unknown kind as if it were not there: the alias resolver
skipped it, the prompts never offered it, and `verify` filtered it out without a word.

A mobile starter is optional. `init` asks "Mobile app" after the backend, defaulting to none;
`--mobile=<id|alias>` (or the bare alias, `--mobile` / `--expo`) includes one; `--yes` scaffolds
without, since a native app brings prerequisites (an associated domain for passkeys) an
unattended run should not opt into; and a registry that predates the kind offers nothing, so
there is no question to ask. A chosen starter lands at `mobile/` with its `.env` filled from the
manifest, is recorded as `services.mobile` in `seamless.config.json`, is checked by `seamless
check` only when recorded, and is called out in the success output with how to start it and the
Android emulator host. `verify` announces the mobile templates it cannot drive instead of
filtering them silently. Template copying skips `.expo`, `ios`, and `android`.

The templates registry pin moves to the release that carries the starter in a follow-up.
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,11 @@ seamless templates list

```text
ID KIND FRAMEWORK FLAGS STATUS
react-vite web react --basic, --react-vite stable
react-oauth web react --oauth, --react-oauth stable
express api express --express stable
fastify api fastify --fastify beta
react-vite web react --basic, --react-vite stable
react-oauth web react --oauth, --react-oauth stable
express api express --express stable
fastify api fastify --fastify beta
expo mobile expo --mobile, --expo beta
```

Every template answers to `--<id>`; some also declare a shorter `--<alias>`, and the two are
Expand All @@ -145,6 +146,11 @@ interchangeable. Passing a flag skips that layer's prompt:
seamless init my-app --react-oauth --express
```

A web and an api starter are always placed. A mobile starter is optional: the prompt defaults to
none, and `--mobile` (or `--expo`) includes it at `mobile/`. Its email codes and sign-in links work
against the local stack as soon as `init` finishes; passkeys need an associated domain, which the
starter's README walks through.

`--json` emits the registry entries for scripting. The command needs no login and reads the same
registry `init` does, so `SEAMLESS_TEMPLATES_DIR` and `SEAMLESS_TEMPLATES_REF` apply.

Expand All @@ -165,6 +171,7 @@ Each question also has its own flag, honored with or without `--yes`:
| --- | --- | --- |
| `--web=<id\|alias>` | Web example | first selectable web template |
| `--api=<id\|alias>` | Backend framework | first selectable api template |
| `--mobile=<id\|alias>` | Mobile app | none |
| `--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` |
Expand Down Expand Up @@ -202,8 +209,9 @@ Depending on your selections, the CLI generates a project like this:
```text
my-app/
├─ auth/ # Seamless Auth server (local auth mode only)
├─ web/ # React web application (optional)
├─ api/ # Express or Fastify API server (optional)
├─ web/ # React web application
├─ api/ # Express or Fastify API server
├─ mobile/ # Expo mobile app (--mobile only)
├─ admin/ # Admin console source (--admin=source only)
├─ docker-compose.yml # not written for a managed project
└─ seamless.config.json
Expand Down Expand Up @@ -326,7 +334,8 @@ seamless verify --keep-up # leave the stack running afterwards
`--local` is the pre-publish check: it builds and packs the local SDK source rather than
installing from npm, so an SDK regression surfaces before a release rather than after.
The browser layer runs once per web template in the registry, each scoped to the flows
its `template.json` declares.
its `template.json` declares. Mobile templates are announced and skipped: the harness has no
simulator to drive, so a native app is checked by running it against a `--keep-up` stack.

Sibling repositories are resolved next to this one and can be pointed elsewhere with
`SEAMLESS_API_DIR`, `SEAMLESS_SERVER_DIR`, `SEAMLESS_REACT_SDK_DIR`, and
Expand Down Expand Up @@ -574,9 +583,9 @@ Seamless CLI scaffolds from, and conformance-tests against, these repositories:
| Repository | What it provides | How the CLI uses it |
| --- | --- | --- |
| [seamless-auth-api](https://github.com/fells-code/seamless-auth-api) | The auth server | Run as a pinned image (`--auth=docker`) or cloned into `auth/` (`--auth=local`) |
| [seamless-templates](https://github.com/fells-code/seamless-templates) | The web and API starters | Scaffolded from its registry at a pinned ref |
| [seamless-templates](https://github.com/fells-code/seamless-templates) | The web, API, and mobile starters | Scaffolded from its registry at a pinned ref |
| [seamless-auth-server](https://github.com/fells-code/seamless-auth-server) | `@seamless-auth/core`, `/express`, `/fastify` | The adapters the scaffolded `api/` runs on |
| [seamless-auth-react](https://github.com/fells-code/seamless-auth-react) | `@seamless-auth/react` | The client SDK the scaffolded `web/` runs on |
| [seamless-auth-react](https://github.com/fells-code/seamless-auth-react) | `@seamless-auth/client`, `/react`, `/react-native` | The client SDKs the scaffolded `web/` and `mobile/` run on |

The starters live in the templates monorepo and are listed in its registry, so the set of
frameworks the CLI offers grows there. Each project can be used independently, but the CLI connects
Expand Down
28 changes: 28 additions & 0 deletions src/commands/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,34 @@ describe("runCheck", () => {
expect(out).toContain("Check complete.");
});

it("checks the mobile project only when the config records one", async () => {
const withMobile = {
...CONFIG,
services: { ...CONFIG.services, mobile: { framework: "expo", path: "mobile" } },
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(withMobile));
vi.mocked(execSync).mockReturnValue(Buffer.from("api\n") as never);
vi.mocked(fetch).mockResolvedValue({ ok: true, status: 200 } as Response);

// Present on disk.
vi.mocked(fs.existsSync).mockReturnValue(true);
await runCheck();
expect(output()).toContain("Mobile project detected");

// Recorded in the config but gone from disk.
logs.length = 0;
vi.mocked(fs.existsSync).mockImplementation((p: string) => !String(p).endsWith("mobile"));
await runCheck();
expect(output()).toContain("Mobile project missing");

// Never chosen: not a finding either way.
logs.length = 0;
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(CONFIG));
vi.mocked(fs.existsSync).mockReturnValue(true);
await runCheck();
expect(output()).not.toContain("Mobile project");
});

it("reports the unhealthy branches when everything is missing or down", async () => {
// config present so we proceed, but web/api/compose paths missing.
vi.mocked(fs.existsSync).mockImplementation((p: string) => {
Expand Down
11 changes: 11 additions & 0 deletions src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ function checkStructure(root: string, config: any, report: Report) {
} else {
report.fail("API project missing");
}

// Only projects that chose a mobile starter record one, so its absence from
// the config is not a finding; its absence from disk is.
const mobilePath = services.mobile?.path;
if (mobilePath) {
if (fs.existsSync(path.join(root, mobilePath))) {
report.ok("Mobile project detected");
} else {
report.fail("Mobile project missing");
}
}
}

function checkDocker(report: Report) {
Expand Down
10 changes: 8 additions & 2 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const COMMAND_HELP: CommandHelp[] = [
name: "init",
usage: [
"seamless init [project-name] [--<template>]",
"seamless init [project-name] --yes [--web=<id>] [--api=<id>] [--email=<address>] [--auth=<mode>] [--admin=<mode>]",
"seamless init [project-name] --yes [--web=<id>] [--api=<id>] [--mobile=<id>] [--email=<address>] [--auth=<mode>] [--admin=<mode>]",
],
sections: [
{
Expand Down Expand Up @@ -63,6 +63,12 @@ NON-INTERACTIVE
• Choose the web and api starters by name
• Default to the first selectable template of that kind in the registry

--mobile=<id|alias>
• Include a mobile starter (Expo), placed at mobile/
• Optional: the prompt defaults to none, and --yes scaffolds without one
• Email codes and sign-in links work against the local stack; passkeys
need an associated domain, which the starter's README walks through

--email=<address>
• The owner address, which becomes the admin when you register
• Required under --yes unless a portal session supplies one
Expand Down Expand Up @@ -100,7 +106,7 @@ NON-INTERACTIVE
init uses (so SEAMLESS_TEMPLATES_DIR and SEAMLESS_TEMPLATES_REF apply).
Needs no login.

• Columns: id, kind (web or api), framework, the init flags that select
• Columns: id, kind (web, api, or mobile), framework, the init flags that select
it, and status
• Every template answers to --<id>; some also declare a shorter --<alias>
• Templates marked coming-soon cannot be selected yet, so they list no flag
Expand Down
115 changes: 114 additions & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ function registry() {
status: "coming-soon",
path: "api-soon",
},
{
id: "expo",
kind: "mobile",
framework: "expo",
label: "Expo",
alias: "mobile",
status: "beta",
path: "mobile/expo",
},
],
};
}
Expand All @@ -188,7 +197,7 @@ function makeSource(manifests: Record<string, any> = {}) {
if (manifests[entry.id]) return manifests[entry.id];
return {
id: entry.id,
targetDir: entry.kind === "web" ? "web" : "api",
targetDir: entry.kind,
};
}),
copyInto: vi.fn(async () => {}),
Expand Down Expand Up @@ -496,6 +505,37 @@ describe("scaffoldLocal", () => {
expect(printSuccessOutput).toHaveBeenCalled();
});

it("places the mobile starter and records it when one is chosen", async () => {
vi.mocked(runProjectSetupPrompts).mockResolvedValue({
webTemplateId: "web-basic",
apiTemplateId: "api-express",
mobileTemplateId: "expo",
authMode: "docker",
adminMode: "api",
ownerEmail: "dev@example.com",
} as never);
vi.mocked(generateDockerCompose).mockResolvedValue({
apiToken: "docker-token",
kid: "docker-kid",
} as never);

await runCLI(undefined, []);

expect(applyTemplateEnv).toHaveBeenCalledTimes(3);
expect(applyTemplateEnv).toHaveBeenLastCalledWith(
"/work/mobile",
expect.anything(),
expect.objectContaining({ apiUrl: expect.stringContaining("http://localhost:3000") }),
);
expect(generateSeamlessConfig).toHaveBeenCalledWith(
"/work",
expect.objectContaining({ mobileFramework: "expo" }),
);
expect(printSuccessOutput).toHaveBeenCalledWith(
expect.objectContaining({ mobileFramework: "expo" }),
);
});

// The compose file is written in both auth modes, so a local auth server's
// own token and kid have to survive the compose call that follows it.
it("keeps the local auth server's shared config when auth runs from source", async () => {
Expand Down Expand Up @@ -724,6 +764,51 @@ describe("template alias resolution", () => {
);
});

it("preselects a mobile template from its alias, and from --mobile=", async () => {
await runCLI(undefined, ["mobile"]);
expect(runProjectSetupPrompts).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ mobileTemplateId: "expo" }),
undefined,
undefined,
);

await runCLI(undefined, [], { mobile: "expo" });
expect(runProjectSetupPrompts).toHaveBeenLastCalledWith(
expect.anything(),
expect.objectContaining({ mobileTemplateId: "expo" }),
undefined,
undefined,
);
});

it("rejects --mobile naming a template of another kind", async () => {
await expect(runCLI(undefined, [], { mobile: "express" })).rejects.toThrow(
/--mobile expects a mobile template/,
);
});

it("rejects conflicting mobile flags", async () => {
const src = makeSource();
src.registry.templates.push({
id: "bare-rn",
kind: "mobile",
framework: "react-native",
label: "Bare RN",
alias: "bare",
status: "stable",
path: "mobile/bare",
} as never);
vi.mocked(openTemplateSource).mockResolvedValue(src as never);

await expect(runCLI(undefined, ["mobile", "bare"])).rejects.toThrow(
/Conflicting mobile template flags/,
);
await expect(runCLI(undefined, ["mobile"], { mobile: "bare" })).rejects.toThrow(
/Conflicting mobile template flags: --mobile=bare cannot combine with --expo/,
);
});

it("rejects conflicting api alias flags", async () => {
const src = makeSource();
// Make the coming-soon go template stable so it becomes a usable api alias.
Expand Down Expand Up @@ -807,6 +892,34 @@ describe("scaffoldManaged", () => {
expect(confirm).not.toHaveBeenCalled();
});

it("places the mobile starter in a managed scaffold when one is chosen", async () => {
loggedIn();
vi.mocked(runManagedTemplatePrompts).mockResolvedValue({
webTemplateId: "web-basic",
apiTemplateId: "api-express",
mobileTemplateId: "expo",
} as never);
vi.mocked(listApplications).mockResolvedValue([app()] as never);
vi.mocked(selectApplication).mockResolvedValue(app() as never);
vi.mocked(rotateServiceToken).mockResolvedValue("svc-token");

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

expect(applyTemplateEnv).toHaveBeenCalledTimes(3);
expect(applyTemplateEnv).toHaveBeenLastCalledWith(
"/work/mobile",
expect.anything(),
expect.anything(),
);
expect(generateSeamlessConfig).toHaveBeenCalledWith(
"/work",
expect.objectContaining({ mobileFramework: "expo" }),
);
expect(printManagedSuccessOutput).toHaveBeenCalledWith(
expect.objectContaining({ mobileFramework: "expo" }),
);
});

it("confirms before rotating when the app already has a service token", async () => {
loggedIn();
const existing = app({ hasServiceToken: true });
Expand Down
Loading
Loading