create-ovok 0.0.0-stage → 0.1.0-dev.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +35 -2
  2. package/dist/adapters/clack-prompt.js +28 -0
  3. package/dist/adapters/fs-project-files.js +30 -0
  4. package/dist/adapters/fs-template-source.js +25 -0
  5. package/dist/adapters/git-repository.js +24 -0
  6. package/dist/adapters/pnpm-install.js +23 -0
  7. package/dist/adapters/scaffold-dependencies.js +5 -0
  8. package/dist/application/add-app.js +50 -0
  9. package/dist/application/copy-template.js +7 -0
  10. package/dist/application/create-workspace.js +19 -0
  11. package/dist/application/ports.js +1 -0
  12. package/dist/application/refresh-agent-rules.js +24 -0
  13. package/dist/application/workspace-answers.js +83 -0
  14. package/dist/commands/create.js +59 -0
  15. package/dist/domain/agent-rules.js +43 -0
  16. package/dist/domain/app-spec.js +46 -0
  17. package/dist/domain/template-text.js +12 -0
  18. package/dist/main.js +122 -0
  19. package/package.json +23 -4
  20. package/templates/mobile/AGENTS.md +5 -0
  21. package/templates/mobile/App.tsx +50 -0
  22. package/templates/mobile/CLAUDE.md +1 -0
  23. package/templates/mobile/app.config.ts +30 -0
  24. package/templates/mobile/index.js +8 -0
  25. package/templates/mobile/package.json +31 -0
  26. package/templates/mobile/src/ovok.ts +12 -0
  27. package/templates/mobile/tsconfig.json +8 -0
  28. package/templates/package/package.json +14 -0
  29. package/templates/package/src/index.ts +1 -0
  30. package/templates/package/tsconfig.json +12 -0
  31. package/templates/web/AGENTS.md +5 -0
  32. package/templates/web/CLAUDE.md +1 -0
  33. package/templates/web/index.html +12 -0
  34. package/templates/web/package.json +27 -0
  35. package/templates/web/src/App.tsx +66 -0
  36. package/templates/web/src/env.d.ts +4 -0
  37. package/templates/web/src/main.tsx +12 -0
  38. package/templates/web/src/ovok.ts +7 -0
  39. package/templates/web/tsconfig.json +15 -0
  40. package/templates/web/vite.config.ts +16 -0
  41. package/templates/workspace/.env +2 -0
  42. package/templates/workspace/.mcp.json +8 -0
  43. package/templates/workspace/AGENTS.md +11 -0
  44. package/templates/workspace/CLAUDE.md +1 -0
  45. package/templates/workspace/README.md +11 -0
  46. package/templates/workspace/_gitignore +9 -0
  47. package/templates/workspace/package.json +15 -0
  48. package/templates/workspace/pnpm-workspace.yaml +5 -0
  49. package/templates/workspace/turbo/generators/config.ts +57 -0
  50. package/templates/workspace/turbo.json +18 -0
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # create-ovok
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```bash
4
+ pnpm create ovok my-app
5
+ ```
6
+
7
+ It asks for the project name, then offers the recommended setup (web and mobile apps, iOS and
8
+ Android, the Ovok sandbox) or lets you choose each part. It then runs `pnpm install` and makes
9
+ the first git commit. The result is a Turborepo with the chosen apps in `apps/`, the Ovok API
10
+ and tenant in `.env`, and the Ovok MCP server in `.mcp.json`.
11
+
12
+ Flags answer their questions, and `--yes` takes the recommended setup for the rest:
13
+
14
+ | Flag | Answers |
15
+ |---|---|
16
+ | `--platform web\|mobile\|mobile-web` | The apps to start with |
17
+ | `--type ios\|android` | One mobile platform instead of both |
18
+ | `--tenant <code>` | The Ovok tenant code |
19
+ | `--base-url <url>` | The Ovok API URL |
20
+ | `-y`, `--yes` | Every question no flag answered, with the recommended setup |
21
+ | `--skip-install` | Leaves `pnpm install` to you |
22
+ | `--disable-git` | Leaves the folder without a git repository |
23
+
24
+ Outside a terminal, such as in CI, nothing is asked: unanswered questions take the recommended
25
+ setup.
26
+
27
+ Inside the workspace:
28
+
29
+ ```bash
30
+ pnpm turbo gen app # add a web or mobile app
31
+ pnpm turbo gen package # add a shared package
32
+ pnpm turbo gen agents # rewrite the Ovok block of every AGENTS.md
33
+ ```
34
+
35
+ Every app has an `AGENTS.md`, and a `CLAUDE.md` that imports it. The Ovok rules sit between
36
+ `BEGIN:ovok-agent-rules` and `END:ovok-agent-rules`; the rest of each file is yours.
@@ -0,0 +1,28 @@
1
+ import { isCancel, select, text } from "@clack/prompts";
2
+ import { CancelledError, } from "../application/workspace-answers.js";
3
+ /** Arrow-key and typed answers in a terminal; an empty answer takes the fallback. */
4
+ export class ClackPrompt {
5
+ async text(question) {
6
+ const answer = await text({
7
+ message: question.message,
8
+ placeholder: question.fallback,
9
+ defaultValue: question.fallback,
10
+ validate: (value) => question.validate(value || question.fallback) ?? undefined,
11
+ });
12
+ return answered(answer);
13
+ }
14
+ async select(question) {
15
+ const answer = await select({
16
+ message: question.message,
17
+ // Clack's Option is conditional on T, which a generic T cannot resolve.
18
+ options: question.options.map((choice) => ({ ...choice })),
19
+ initialValue: question.fallback,
20
+ });
21
+ return answered(answer);
22
+ }
23
+ }
24
+ function answered(answer) {
25
+ if (isCancel(answer))
26
+ throw new CancelledError("Cancelled.");
27
+ return answer;
28
+ }
@@ -0,0 +1,30 @@
1
+ import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ export class FsProjectFiles {
4
+ #root;
5
+ constructor(root) {
6
+ this.#root = root;
7
+ }
8
+ async exists(path) {
9
+ return access(join(this.#root, path)).then(() => true, () => false);
10
+ }
11
+ async read(path) {
12
+ return readFile(join(this.#root, path), "utf8").catch(missingAs(null));
13
+ }
14
+ async write(path, content) {
15
+ const absolute = join(this.#root, path);
16
+ await mkdir(dirname(absolute), { recursive: true });
17
+ await writeFile(absolute, content);
18
+ }
19
+ async directories(path) {
20
+ const entries = await readdir(join(this.#root, path), { withFileTypes: true }).catch(missingAs([]));
21
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
22
+ }
23
+ }
24
+ function missingAs(fallback) {
25
+ return (error) => {
26
+ if (error.code === "ENOENT")
27
+ return fallback;
28
+ throw error;
29
+ };
30
+ }
@@ -0,0 +1,25 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const TEMPLATES_DIRECTORY = fileURLToPath(new URL("../../templates/", import.meta.url));
5
+ /** npm leaves `.gitignore` out of a published package, so templates carry it as `_gitignore`. */
6
+ const PUBLISHED_NAMES = { _gitignore: ".gitignore" };
7
+ export class FsTemplateSource {
8
+ async files(template) {
9
+ const root = join(TEMPLATES_DIRECTORY, template);
10
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
11
+ const files = entries.filter((entry) => entry.isFile());
12
+ return Promise.all(files.map(async (entry) => {
13
+ const absolute = join(entry.parentPath, entry.name);
14
+ return {
15
+ path: projectPathOf(relative(root, absolute)),
16
+ content: await readFile(absolute, "utf8"),
17
+ };
18
+ }));
19
+ }
20
+ }
21
+ function projectPathOf(templatePath) {
22
+ const segments = templatePath.split(sep);
23
+ const name = segments.pop() ?? "";
24
+ return [...segments, PUBLISHED_NAMES[name] ?? name].join("/");
25
+ }
@@ -0,0 +1,24 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ const INITIAL_COMMIT_MESSAGE = "chore: initial commit from create-ovok";
5
+ /** A new repository with the scaffold as its first commit, unless the folder is in one already. */
6
+ export async function initialiseGitRepository(directory) {
7
+ if (await succeeds(["rev-parse", "--is-inside-work-tree"], directory))
8
+ return "inside-repository";
9
+ if (!(await succeeds(["init", "--quiet", "--initial-branch=main"], directory))) {
10
+ return "unavailable";
11
+ }
12
+ const committed = (await succeeds(["add", "--all"], directory)) &&
13
+ (await succeeds(["commit", "--quiet", "--message", INITIAL_COMMIT_MESSAGE], directory));
14
+ return committed ? "committed" : "uncommitted";
15
+ }
16
+ async function succeeds(args, directory) {
17
+ try {
18
+ await run("git", args, { cwd: directory });
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
@@ -0,0 +1,23 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import { createInterface } from "node:readline";
4
+ /** Runs `pnpm install` in `directory`, handing on each line it prints. */
5
+ export async function installDependencies(directory, onLine) {
6
+ const child = spawn("pnpm", ["install"], {
7
+ cwd: directory,
8
+ stdio: ["ignore", "pipe", "pipe"],
9
+ shell: process.platform === "win32",
10
+ });
11
+ for (const output of [child.stdout, child.stderr]) {
12
+ createInterface({ input: output }).on("line", onLine);
13
+ }
14
+ const [code] = await once(child, "close").catch(rethrowMissingPnpm);
15
+ if (code !== 0)
16
+ throw new Error(`pnpm install exited with code ${code}.`);
17
+ }
18
+ function rethrowMissingPnpm(error) {
19
+ if (error.code === "ENOENT") {
20
+ throw new Error("pnpm is not installed. Run `corepack enable pnpm`, then `pnpm install`.");
21
+ }
22
+ throw error;
23
+ }
@@ -0,0 +1,5 @@
1
+ import { FsProjectFiles } from "./fs-project-files.js";
2
+ import { FsTemplateSource } from "./fs-template-source.js";
3
+ export function dependenciesAt(root) {
4
+ return { templates: new FsTemplateSource(), files: new FsProjectFiles(root) };
5
+ }
@@ -0,0 +1,50 @@
1
+ import { appRulesBlock } from "../domain/agent-rules.js";
2
+ import { bundleIdFor, MOBILE_OSES } from "../domain/app-spec.js";
3
+ import { copyTemplate } from "./copy-template.js";
4
+ export class NotAWorkspaceError extends Error {
5
+ }
6
+ export class AlreadyExistsError extends Error {
7
+ }
8
+ export async function addApp(app, deps) {
9
+ const workspace = await workspaceNameOf(deps.files);
10
+ const directory = await freeDirectory(`apps/${app.name}`, deps.files);
11
+ await copyTemplate(app.platform, directory, {
12
+ workspace,
13
+ folder: app.name,
14
+ bundleId: bundleIdFor(workspace, app.name),
15
+ platforms: app.os.map((os) => `"${os}"`).join(", "),
16
+ agentRules: appRulesBlock(app.platform),
17
+ }, deps);
18
+ if (app.platform === "mobile") {
19
+ await keepRunScriptsOf(app.os, `${directory}/package.json`, deps.files);
20
+ }
21
+ return directory;
22
+ }
23
+ export async function addPackage(name, deps) {
24
+ const workspace = await workspaceNameOf(deps.files);
25
+ const directory = await freeDirectory(`packages/${name}`, deps.files);
26
+ await copyTemplate("package", directory, { workspace, folder: name }, deps);
27
+ return directory;
28
+ }
29
+ async function workspaceNameOf(files) {
30
+ const manifest = await files.read("package.json");
31
+ const isWorkspace = manifest !== null && (await files.exists("pnpm-workspace.yaml"));
32
+ const name = isWorkspace ? JSON.parse(manifest).name : undefined;
33
+ if (typeof name !== "string") {
34
+ throw new NotAWorkspaceError("Run this at the root of a workspace made by create-ovok.");
35
+ }
36
+ return name;
37
+ }
38
+ async function freeDirectory(directory, files) {
39
+ if (await files.exists(directory))
40
+ throw new AlreadyExistsError(`${directory} already exists.`);
41
+ return directory;
42
+ }
43
+ /** Drops the run script of each OS the app leaves out, so `pnpm android` is not offered to an iOS-only app. */
44
+ async function keepRunScriptsOf(os, path, files) {
45
+ const manifest = JSON.parse((await files.read(path)) ?? "{}");
46
+ for (const dropped of MOBILE_OSES.filter((candidate) => !os.includes(candidate))) {
47
+ delete manifest.scripts?.[dropped];
48
+ }
49
+ await files.write(path, `${JSON.stringify(manifest, null, 2)}\n`);
50
+ }
@@ -0,0 +1,7 @@
1
+ import { posix } from "node:path";
2
+ import { renderTemplate } from "../domain/template-text.js";
3
+ export async function copyTemplate(template, directory, variables, deps) {
4
+ for (const file of await deps.templates.files(template)) {
5
+ await deps.files.write(posix.join(directory, file.path), renderTemplate(file.content, variables));
6
+ }
7
+ }
@@ -0,0 +1,19 @@
1
+ import { workspaceRulesBlock } from "../domain/agent-rules.js";
2
+ import { AlreadyExistsError, addApp } from "./add-app.js";
3
+ import { copyTemplate } from "./copy-template.js";
4
+ export async function createWorkspace(spec, deps) {
5
+ if (await deps.files.exists("package.json")) {
6
+ throw new AlreadyExistsError("The folder already holds a project.");
7
+ }
8
+ await copyTemplate("workspace", "", {
9
+ workspace: spec.name,
10
+ baseUrl: spec.baseUrl,
11
+ tenantCode: spec.tenantCode,
12
+ createOvokVersion: spec.createOvokVersion,
13
+ agentRules: workspaceRulesBlock(),
14
+ }, deps);
15
+ const directories = [];
16
+ for (const app of spec.apps)
17
+ directories.push(await addApp(app, deps));
18
+ return directories;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { appRulesBlock, replaceRulesBlock, workspaceRulesBlock } from "../domain/agent-rules.js";
2
+ import { PLATFORMS } from "../domain/app-spec.js";
3
+ const AGENTS_FILE = "AGENTS.md";
4
+ /** Rewrites the Ovok block of every AGENTS.md to this create-ovok's rules; the rest is the team's. */
5
+ export async function refreshAgentRules(files) {
6
+ const refreshed = [await refresh(AGENTS_FILE, workspaceRulesBlock(), files)];
7
+ for (const app of await files.directories("apps")) {
8
+ const platform = await platformOf(`apps/${app}/package.json`, files);
9
+ if (platform !== null) {
10
+ refreshed.push(await refresh(`apps/${app}/${AGENTS_FILE}`, appRulesBlock(platform), files));
11
+ }
12
+ }
13
+ return refreshed;
14
+ }
15
+ async function refresh(path, block, files) {
16
+ await files.write(path, replaceRulesBlock((await files.read(path)) ?? "", block));
17
+ return path;
18
+ }
19
+ /** Apps made by create-ovok name their platform in package.json; other apps are left alone. */
20
+ async function platformOf(path, files) {
21
+ const manifest = await files.read(path);
22
+ const platform = manifest === null ? undefined : JSON.parse(manifest)?.ovok?.platform;
23
+ return PLATFORMS.find((candidate) => candidate === platform) ?? null;
24
+ }
@@ -0,0 +1,83 @@
1
+ import { parsePackageName } from "../domain/app-spec.js";
2
+ export class CancelledError extends Error {
3
+ }
4
+ /** Answers every question with its fallback: for --yes and anywhere that is not a terminal. */
5
+ export const DEFAULT_ANSWERS = {
6
+ text: async (question) => question.fallback,
7
+ select: async (question) => question.fallback,
8
+ };
9
+ export const RECOMMENDED = {
10
+ platforms: "mobile-web",
11
+ tenantCode: "public-example",
12
+ baseUrl: "https://api.sandbox.ovok.com",
13
+ };
14
+ const NAME_QUESTION = {
15
+ message: "What is your project named?",
16
+ fallback: "ovok-app",
17
+ validate: (answer) => reasonRefused(() => parsePackageName(answer)),
18
+ };
19
+ const SETUP_QUESTION = {
20
+ message: "Use the recommended setup?",
21
+ options: [
22
+ {
23
+ value: "recommended",
24
+ label: "Yes, use the recommended setup",
25
+ hint: "web and mobile apps, iOS and Android, the Ovok sandbox",
26
+ },
27
+ { value: "customise", label: "No, customise it" },
28
+ ],
29
+ fallback: "recommended",
30
+ };
31
+ const PLATFORMS_QUESTION = {
32
+ message: "Which apps should it start with?",
33
+ options: [
34
+ { value: "mobile-web", label: "Web and mobile", hint: "apps/web and apps/mobile" },
35
+ { value: "web", label: "Web", hint: "Vite and React" },
36
+ { value: "mobile", label: "Mobile", hint: "Expo" },
37
+ ],
38
+ fallback: RECOMMENDED.platforms,
39
+ };
40
+ const TYPE_QUESTION = {
41
+ message: "Which mobile platforms?",
42
+ options: [
43
+ { value: null, label: "iOS and Android" },
44
+ { value: "ios", label: "iOS" },
45
+ { value: "android", label: "Android" },
46
+ ],
47
+ fallback: null,
48
+ };
49
+ const TENANT_QUESTION = {
50
+ message: "Which Ovok tenant code?",
51
+ fallback: RECOMMENDED.tenantCode,
52
+ validate: (answer) => (answer.trim() === "" ? "Enter a tenant code." : null),
53
+ };
54
+ const BASE_URL_QUESTION = {
55
+ message: "Which Ovok API URL?",
56
+ fallback: RECOMMENDED.baseUrl,
57
+ validate: (answer) => URL.canParse(answer) ? null : `Enter a URL such as ${RECOMMENDED.baseUrl}.`,
58
+ };
59
+ /** Asks only what no flag answered; after the recommended setup, only the name. */
60
+ export async function answersFor(flags, prompt) {
61
+ const name = parsePackageName(flags.name ?? (await prompt.text(NAME_QUESTION)));
62
+ const ask = (await customises(flags, prompt)) ? prompt : DEFAULT_ANSWERS;
63
+ const platforms = flags.platforms ?? (await ask.select(PLATFORMS_QUESTION));
64
+ const type = flags.type ?? (platforms === "web" ? null : await ask.select(TYPE_QUESTION));
65
+ const tenantCode = flags.tenantCode ?? (await ask.text(TENANT_QUESTION));
66
+ const baseUrl = (flags.baseUrl ?? (await ask.text(BASE_URL_QUESTION))).replace(/\/+$/, "");
67
+ return { name, platforms, type, tenantCode, baseUrl };
68
+ }
69
+ /** A setup flag means the person is choosing already, so the rest is asked rather than assumed. */
70
+ async function customises(flags, prompt) {
71
+ const setupFlags = [flags.platforms, flags.type, flags.tenantCode, flags.baseUrl];
72
+ return (setupFlags.some((flag) => flag !== null) ||
73
+ (await prompt.select(SETUP_QUESTION)) === "customise");
74
+ }
75
+ function reasonRefused(check) {
76
+ try {
77
+ check();
78
+ return null;
79
+ }
80
+ catch (error) {
81
+ return error instanceof Error ? error.message : String(error);
82
+ }
83
+ }
@@ -0,0 +1,59 @@
1
+ import { resolve } from "node:path";
2
+ import { intro, log, note, outro, taskLog } from "@clack/prompts";
3
+ import { initialiseGitRepository } from "../adapters/git-repository.js";
4
+ import { installDependencies } from "../adapters/pnpm-install.js";
5
+ import { dependenciesAt } from "../adapters/scaffold-dependencies.js";
6
+ import { createWorkspace } from "../application/create-workspace.js";
7
+ import { answersFor } from "../application/workspace-answers.js";
8
+ import { startingApps } from "../domain/app-spec.js";
9
+ const INSTALL_LOG_LINES = 6;
10
+ const DOCS_URL = "https://docs.ovok.com";
11
+ export async function scaffold(flags, prompt, version) {
12
+ intro(`create-ovok ${version}`);
13
+ const answers = await answersFor(flags, prompt);
14
+ const directory = resolve(answers.name);
15
+ const apps = startingApps(answers.platforms, answers.type);
16
+ await createWorkspace({
17
+ name: answers.name,
18
+ baseUrl: answers.baseUrl,
19
+ tenantCode: answers.tenantCode,
20
+ createOvokVersion: version,
21
+ apps,
22
+ }, dependenciesAt(directory));
23
+ const folders = apps.map((app) => `apps/${app.name}`).join(" and ");
24
+ log.success(`Created ${answers.name} with ${folders} in ${directory}.`);
25
+ return { name: answers.name, directory };
26
+ }
27
+ /** A failed install leaves the workspace in place; the next steps then include the install. */
28
+ export async function install(workspace) {
29
+ const progress = taskLog({
30
+ title: "Installing dependencies with pnpm",
31
+ limit: INSTALL_LOG_LINES,
32
+ });
33
+ try {
34
+ await installDependencies(workspace.directory, (line) => progress.message(line));
35
+ progress.success("Installed dependencies.");
36
+ return "installed";
37
+ }
38
+ catch (error) {
39
+ progress.error(`Could not install dependencies: ${messageOf(error)}`, { showLog: true });
40
+ return "failed";
41
+ }
42
+ }
43
+ const GIT_REPORTS = {
44
+ committed: () => log.success("Initialised a git repository with a first commit."),
45
+ uncommitted: () => log.warn("Initialised a git repository; commit the files yourself once git knows your name."),
46
+ "inside-repository": () => log.info("Skipped git: the folder is inside a repository already."),
47
+ unavailable: () => log.warn("Skipped git: git is not installed."),
48
+ };
49
+ export async function initialiseGit(workspace) {
50
+ GIT_REPORTS[await initialiseGitRepository(workspace.directory)]();
51
+ }
52
+ export function showNextSteps(workspace, installation) {
53
+ const install = installation === "installed" ? [] : ["pnpm install"];
54
+ note([`cd ${workspace.name}`, ...install, "pnpm dev"].join("\n"), "Next steps");
55
+ outro(`Add an app with pnpm turbo gen app. Docs: ${DOCS_URL}`);
56
+ }
57
+ function messageOf(error) {
58
+ return error instanceof Error ? error.message : String(error);
59
+ }
@@ -0,0 +1,43 @@
1
+ export const RULES_BEGIN = "<!-- BEGIN:ovok-agent-rules -->";
2
+ export const RULES_END = "<!-- END:ovok-agent-rules -->";
3
+ const WORKSPACE_RULES = `## This is NOT the Ovok you know
4
+
5
+ Your training data is out of date for Ovok. Before writing code that touches it, read the guide in
6
+ \`node_modules/@ovok/core/docs/\` (web) or \`node_modules/@ovok/native/docs/\` (mobile), resolved
7
+ from the app's own directory: they match the installed version. If that folder is missing, read
8
+ https://docs.ovok.com/llms.txt instead. Platform concepts (FHIR, settings, Signals) are there too.
9
+
10
+ - Check live data and settings with the \`ovok\` MCP server in \`.mcp.json\` instead of guessing.
11
+ - Never evaluate vital-sign thresholds in app code: Signals decides alerts; you send or edit its rules.
12
+ - Add apps and packages with \`pnpm turbo gen\`, never by copying a folder.`;
13
+ const PLATFORM_RULES = {
14
+ web: `## Ovok in this web app
15
+
16
+ - Vite and React on \`@ovok/core\`. Use the one \`Ovok\` client in \`src/ovok.ts\`; never create another.
17
+ - \`OVOK_BASE_URL\` and \`OVOK_TENANT_CODE\` come from the root \`.env\` through \`vite.config.ts\`.
18
+ Only those two reach the browser; never widen that list, as everything in this app ships to users.`,
19
+ mobile: `## Ovok in this mobile app
20
+
21
+ - Expo with a development build: Bluetooth needs native code, so Expo Go cannot run it. Use
22
+ \`pnpm ios\` or \`pnpm android\`.
23
+ - Read \`node_modules/@ovok/native/docs/\` before touching devices or Bluetooth.
24
+ - Use the one \`Ovok\` client in \`src/ovok.ts\`; its settings come from the root \`.env\` through
25
+ \`app.config.ts\`.`,
26
+ };
27
+ /** The block `create-ovok agents` owns: it rewrites what is between the markers and nothing else. */
28
+ export function workspaceRulesBlock() {
29
+ return block(WORKSPACE_RULES);
30
+ }
31
+ export function appRulesBlock(platform) {
32
+ return block(PLATFORM_RULES[platform]);
33
+ }
34
+ export function replaceRulesBlock(document, replacement) {
35
+ const start = document.indexOf(RULES_BEGIN);
36
+ const end = document.indexOf(RULES_END, start);
37
+ if (start === -1 || end === -1)
38
+ return `${replacement}\n\n${document}`;
39
+ return document.slice(0, start) + replacement + document.slice(end + RULES_END.length);
40
+ }
41
+ function block(rules) {
42
+ return `${RULES_BEGIN}\n${rules}\n${RULES_END}`;
43
+ }
@@ -0,0 +1,46 @@
1
+ export const PLATFORMS = ["web", "mobile"];
2
+ export const MOBILE_OSES = ["ios", "android"];
3
+ export const STARTING_PLATFORMS = ["web", "mobile", "mobile-web"];
4
+ /** The npm rules for an unscoped name, which every app folder and workspace name follows. */
5
+ const PACKAGE_NAME = /^[a-z0-9][a-z0-9._-]*$/;
6
+ const PACKAGE_NAME_MAX_LENGTH = 214;
7
+ export class InvalidSpecError extends Error {
8
+ }
9
+ export function parsePackageName(raw) {
10
+ if (!PACKAGE_NAME.test(raw) || raw.length > PACKAGE_NAME_MAX_LENGTH) {
11
+ throw new InvalidSpecError(`"${raw}" is not a valid name: use lowercase letters, digits, ".", "_" and "-".`);
12
+ }
13
+ return raw;
14
+ }
15
+ export function parseChoice(raw, choices, flag) {
16
+ const choice = choices.find((candidate) => candidate === raw);
17
+ if (choice === undefined)
18
+ throw new InvalidSpecError(`${flag} is one of ${choices.join(", ")}.`);
19
+ return choice;
20
+ }
21
+ /** `--type` narrows a mobile app to one OS; on a web app it means nothing, so it is refused. */
22
+ export function osFor(platform, type) {
23
+ if (platform === "web") {
24
+ if (type !== null)
25
+ throw new InvalidSpecError("--type only applies to mobile apps.");
26
+ return [];
27
+ }
28
+ return type === null ? MOBILE_OSES : [type];
29
+ }
30
+ /** A reverse-DNS id both stores accept: letters and digits per segment, never a leading digit. */
31
+ export function bundleIdFor(workspace, app) {
32
+ const segment = (raw) => {
33
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9]/g, "");
34
+ return /^[a-z]/.test(cleaned) ? cleaned : `app${cleaned}`;
35
+ };
36
+ return ["com", "example", segment(workspace), segment(app)].join(".");
37
+ }
38
+ export function startingApps(platforms, type) {
39
+ const web = { name: "web", platform: "web", os: [] };
40
+ const mobile = { name: "mobile", platform: "mobile", os: osFor("mobile", type) };
41
+ if (platforms === "web")
42
+ return [{ ...web, os: osFor("web", type) }];
43
+ if (platforms === "mobile")
44
+ return [mobile];
45
+ return [web, mobile];
46
+ }
@@ -0,0 +1,12 @@
1
+ const PLACEHOLDER = /\{\{(\w+)\}\}/g;
2
+ export class MissingVariableError extends Error {
3
+ }
4
+ /** Fills every `{{name}}`; a placeholder without a value is a template bug, so it fails loudly. */
5
+ export function renderTemplate(text, variables) {
6
+ return text.replace(PLACEHOLDER, (_match, name) => {
7
+ const value = variables[name];
8
+ if (value === undefined)
9
+ throw new MissingVariableError(`The template needs "${name}".`);
10
+ return value;
11
+ });
12
+ }
package/dist/main.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { cancel } from "@clack/prompts";
5
+ import { ClackPrompt } from "./adapters/clack-prompt.js";
6
+ import { FsProjectFiles } from "./adapters/fs-project-files.js";
7
+ import { dependenciesAt } from "./adapters/scaffold-dependencies.js";
8
+ import { addApp, addPackage } from "./application/add-app.js";
9
+ import { refreshAgentRules } from "./application/refresh-agent-rules.js";
10
+ import { CancelledError, DEFAULT_ANSWERS, RECOMMENDED, } from "./application/workspace-answers.js";
11
+ import { initialiseGit, install, scaffold, showNextSteps } from "./commands/create.js";
12
+ import { MOBILE_OSES, osFor, PLATFORMS, parseChoice, parsePackageName, STARTING_PLATFORMS, } from "./domain/app-spec.js";
13
+ const EXIT_FAILURE = 1;
14
+ const EXIT_CANCELLED = 130;
15
+ const OPTIONS = {
16
+ platform: { type: "string" },
17
+ type: { type: "string" },
18
+ tenant: { type: "string" },
19
+ "base-url": { type: "string" },
20
+ yes: { type: "boolean", short: "y" },
21
+ "skip-install": { type: "boolean" },
22
+ "disable-git": { type: "boolean" },
23
+ help: { type: "boolean", short: "h" },
24
+ version: { type: "boolean", short: "v" },
25
+ };
26
+ const USAGE = `Usage: pnpm create ovok [name] [options]
27
+
28
+ Creates a Turborepo with Ovok web and mobile apps, installs it and makes its first commit.
29
+ Flags answer their questions; the rest are asked, or take the recommended setup with --yes.
30
+
31
+ Options:
32
+ --platform <web|mobile|mobile-web> The apps to start with
33
+ --type <ios|android> Build the mobile app for one platform only
34
+ --tenant <code> Ovok tenant code (recommended: ${RECOMMENDED.tenantCode})
35
+ --base-url <url> Ovok API URL (recommended: ${RECOMMENDED.baseUrl})
36
+ -y, --yes Take the recommended setup for every unanswered question
37
+ --skip-install Do not run pnpm install
38
+ --disable-git Do not create a git repository
39
+ -h, --help Show this help
40
+ -v, --version Show the create-ovok version
41
+
42
+ Inside a workspace (pnpm turbo gen runs these):
43
+ add <name> --platform web|mobile [--type ios|android]
44
+ add-package <name>
45
+ agents Refresh the Ovok rules in every AGENTS.md`;
46
+ function parse(argv) {
47
+ return parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
48
+ }
49
+ const SUBCOMMANDS = {
50
+ add: async ([name], values) => {
51
+ const platform = parseChoice(values.platform ?? "", PLATFORMS, "--platform");
52
+ const app = {
53
+ name: parsePackageName(name ?? ""),
54
+ platform,
55
+ os: osFor(platform, typeOf(values)),
56
+ };
57
+ console.log(`Added ${await addApp(app, dependenciesAt(process.cwd()))}.`);
58
+ },
59
+ "add-package": async ([name]) => {
60
+ console.log(`Added ${await addPackage(parsePackageName(name ?? ""), dependenciesAt(process.cwd()))}.`);
61
+ },
62
+ agents: async () => {
63
+ const refreshed = await refreshAgentRules(new FsProjectFiles(process.cwd()));
64
+ console.log(`Refreshed the Ovok rules in ${refreshed.join(", ")}.`);
65
+ },
66
+ };
67
+ async function main(argv) {
68
+ const { values, positionals } = parse(argv);
69
+ if (values.help)
70
+ return console.log(USAGE);
71
+ if (values.version)
72
+ return console.log(await ownVersion());
73
+ const [first, ...rest] = positionals;
74
+ const subcommand = first === undefined ? undefined : SUBCOMMANDS[first];
75
+ if (subcommand === undefined)
76
+ return create(first ?? null, values);
77
+ return subcommand(rest, values);
78
+ }
79
+ async function create(name, values) {
80
+ const workspace = await scaffold(setupFlagsOf(name, values), promptFor(values), await ownVersion());
81
+ const installation = values["skip-install"] ? "skipped" : await install(workspace);
82
+ if (!values["disable-git"])
83
+ await initialiseGit(workspace);
84
+ showNextSteps(workspace, installation);
85
+ if (installation === "failed")
86
+ process.exitCode = EXIT_FAILURE;
87
+ }
88
+ function promptFor(values) {
89
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && !values.yes;
90
+ return interactive ? new ClackPrompt() : DEFAULT_ANSWERS;
91
+ }
92
+ function setupFlagsOf(name, values) {
93
+ return {
94
+ name,
95
+ platforms: values.platform === undefined
96
+ ? null
97
+ : parseChoice(values.platform, STARTING_PLATFORMS, "--platform"),
98
+ type: typeOf(values),
99
+ tenantCode: values.tenant ?? null,
100
+ baseUrl: values["base-url"] ?? null,
101
+ };
102
+ }
103
+ function typeOf(values) {
104
+ return values.type === undefined ? null : parseChoice(values.type, MOBILE_OSES, "--type");
105
+ }
106
+ async function ownVersion() {
107
+ const manifest = await readFile(new URL("../package.json", import.meta.url), "utf8");
108
+ return JSON.parse(manifest).version;
109
+ }
110
+ try {
111
+ await main(process.argv.slice(2));
112
+ }
113
+ catch (error) {
114
+ if (error instanceof CancelledError) {
115
+ cancel(error.message);
116
+ process.exitCode = EXIT_CANCELLED;
117
+ }
118
+ else {
119
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
120
+ process.exitCode = EXIT_FAILURE;
121
+ }
122
+ }
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "create-ovok",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-dev.16",
4
+ "description": "Create a Turborepo with Ovok web and mobile apps, and add more apps through turbo gen.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-ovok": "dist/main.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.12"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.build.json",
19
+ "typecheck": "tsc -p tsconfig.json",
20
+ "test": "node --test \"src/**/*.test.ts\""
21
+ },
22
+ "dependencies": {
23
+ "@clack/prompts": "^1.8.1"
24
+ }
25
+ }
@@ -0,0 +1,5 @@
1
+ # {{folder}}
2
+
3
+ {{agentRules}}
4
+
5
+ Start the development server with `pnpm dev` here, after a first `pnpm ios` or `pnpm android` build.
@@ -0,0 +1,50 @@
1
+ import { useState } from "react";
2
+ import { Button, StyleSheet, Text, TextInput, View } from "react-native";
3
+ import { ovok } from "./src/ovok";
4
+
5
+ export default function App() {
6
+ const [email, setEmail] = useState("");
7
+ const [password, setPassword] = useState("");
8
+ const [status, setStatus] = useState("Sign in with a patient account.");
9
+
10
+ async function signIn() {
11
+ try {
12
+ const result = await ovok.auth.login({ profileType: "Patient", email, password });
13
+ setStatus(result.status === "signed-in" ? "Signed in." : "This account needs a second factor.");
14
+ } catch (error) {
15
+ setStatus(error instanceof Error ? error.message : String(error));
16
+ }
17
+ }
18
+
19
+ return (
20
+ <View style={styles.screen}>
21
+ <Text style={styles.status} accessibilityLiveRegion="polite">
22
+ {status}
23
+ </Text>
24
+ <TextInput
25
+ style={styles.input}
26
+ placeholder="Email"
27
+ autoCapitalize="none"
28
+ autoComplete="email"
29
+ keyboardType="email-address"
30
+ value={email}
31
+ onChangeText={setEmail}
32
+ />
33
+ <TextInput
34
+ style={styles.input}
35
+ placeholder="Password"
36
+ autoComplete="current-password"
37
+ secureTextEntry
38
+ value={password}
39
+ onChangeText={setPassword}
40
+ />
41
+ <Button title="Sign in" onPress={signIn} />
42
+ </View>
43
+ );
44
+ }
45
+
46
+ const styles = StyleSheet.create({
47
+ screen: { flex: 1, justifyContent: "center", gap: 12, padding: 24 },
48
+ status: { fontSize: 16 },
49
+ input: { borderWidth: 1, borderRadius: 8, padding: 12 },
50
+ });
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,30 @@
1
+ import type { ExpoConfig } from "expo/config";
2
+
3
+ process.loadEnvFile("../../.env");
4
+
5
+ const config: ExpoConfig = {
6
+ name: "{{folder}}",
7
+ slug: "{{workspace}}-{{folder}}",
8
+ version: "1.0.0",
9
+ orientation: "portrait",
10
+ platforms: [{{platforms}}],
11
+ ios: { bundleIdentifier: "{{bundleId}}" },
12
+ android: { package: "{{bundleId}}" },
13
+ plugins: [
14
+ "expo-secure-store",
15
+ [
16
+ "react-native-ble-plx",
17
+ {
18
+ isBackgroundEnabled: false,
19
+ bluetoothAlwaysPermission: "Connect to your measurement devices to receive readings.",
20
+ },
21
+ ],
22
+ ["react-native-permissions", { iosPermissions: ["Bluetooth"] }],
23
+ ],
24
+ extra: {
25
+ ovokBaseUrl: process.env.OVOK_BASE_URL,
26
+ ovokTenantCode: process.env.OVOK_TENANT_CODE,
27
+ },
28
+ };
29
+
30
+ export default config;
@@ -0,0 +1,8 @@
1
+ import { registerRootComponent } from "expo";
2
+ import { polyfillWebCrypto } from "expo-standard-web-crypto";
3
+
4
+ polyfillWebCrypto();
5
+
6
+ // The Ovok client's PKCE helpers need web crypto before they load.
7
+ const App = require("./App").default;
8
+ registerRootComponent(App);
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@{{workspace}}/{{folder}}",
3
+ "private": true,
4
+ "main": "index.js",
5
+ "ovok": {
6
+ "platform": "mobile"
7
+ },
8
+ "scripts": {
9
+ "dev": "expo start --dev-client",
10
+ "ios": "expo run:ios",
11
+ "android": "expo run:android",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@ovok/core": "^0.4.49-rc.4",
16
+ "@ovok/native": "^1.16.1-rc.3",
17
+ "expo": "~57.0.31",
18
+ "expo-constants": "~57.0.22",
19
+ "expo-dev-client": "~57.0.20",
20
+ "expo-secure-store": "~57.0.4",
21
+ "expo-standard-web-crypto": "^57.0.3",
22
+ "react": "19.2.3",
23
+ "react-native": "0.86.3",
24
+ "react-native-ble-plx": "^3.5.1",
25
+ "react-native-permissions": "^5.6.2"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "~19.2.0",
29
+ "typescript": "~6.0.3"
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ import { Ovok } from "@ovok/core/next";
2
+ import Constants from "expo-constants";
3
+
4
+ type OvokExtra = { ovokBaseUrl: string; ovokTenantCode: string };
5
+
6
+ const extra = Constants.expoConfig?.extra as OvokExtra;
7
+
8
+ export const ovok = new Ovok({
9
+ baseUrl: extra.ovokBaseUrl,
10
+ tenantCode: extra.ovokTenantCode,
11
+ fhirPath: "/fhir/R4/",
12
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noUncheckedIndexedAccess": true
6
+ },
7
+ "exclude": ["node_modules", "ios", "android"]
8
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@{{workspace}}/{{folder}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "devDependencies": {
12
+ "typescript": "^7.0.2"
13
+ }
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["src"]
12
+ }
@@ -0,0 +1,5 @@
1
+ # {{folder}}
2
+
3
+ {{agentRules}}
4
+
5
+ Run it with `pnpm dev` here, or `pnpm turbo run dev --filter @{{workspace}}/{{folder}}` from the root.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{folder}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@{{workspace}}/{{folder}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "ovok": {
6
+ "platform": "web"
7
+ },
8
+ "scripts": {
9
+ "dev": "vite",
10
+ "build": "tsc --noEmit && vite build",
11
+ "typecheck": "tsc --noEmit",
12
+ "preview": "vite preview"
13
+ },
14
+ "dependencies": {
15
+ "@ovok/core": "^0.4.49-rc.4",
16
+ "react": "^19.3.0",
17
+ "react-dom": "^19.3.0"
18
+ },
19
+ "devDependencies": {
20
+ "@medplum/fhirtypes": "^5.2.11",
21
+ "@types/react": "^19.3.0",
22
+ "@types/react-dom": "^19.3.0",
23
+ "@vitejs/plugin-react": "^6.1.2",
24
+ "typescript": "^7.0.2",
25
+ "vite": "^8.3.4"
26
+ }
27
+ }
@@ -0,0 +1,66 @@
1
+ import type { Patient } from "@medplum/fhirtypes";
2
+ import { type FormEvent, useState } from "react";
3
+ import { ovok } from "./ovok";
4
+
5
+ const PATIENTS_SHOWN = 10;
6
+
7
+ export function App() {
8
+ const [patients, setPatients] = useState<Patient[] | null>(null);
9
+ const [problem, setProblem] = useState<string | null>(null);
10
+
11
+ async function signIn(event: FormEvent<HTMLFormElement>) {
12
+ event.preventDefault();
13
+ const form = new FormData(event.currentTarget);
14
+ setProblem(null);
15
+ try {
16
+ const result = await ovok.auth.login({
17
+ profileType: "Practitioner",
18
+ email: String(form.get("email")),
19
+ password: String(form.get("password")),
20
+ });
21
+ if (result.status !== "signed-in") {
22
+ setProblem("This account needs a second factor: finish with ovok.auth.verifyMfa.");
23
+ return;
24
+ }
25
+ const page = await ovok.fhir.search("Patient", { _count: String(PATIENTS_SHOWN) });
26
+ setPatients(page.data);
27
+ } catch (error) {
28
+ setProblem(error instanceof Error ? error.message : String(error));
29
+ }
30
+ }
31
+
32
+ if (patients !== null) {
33
+ return (
34
+ <main>
35
+ <h1>Patients</h1>
36
+ <ul>
37
+ {patients.map((patient) => (
38
+ <li key={patient.id}>{nameOf(patient)}</li>
39
+ ))}
40
+ </ul>
41
+ </main>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <main>
47
+ <h1>Sign in</h1>
48
+ <form onSubmit={signIn}>
49
+ <label>
50
+ Email <input name="email" type="email" autoComplete="username" required />
51
+ </label>
52
+ <label>
53
+ Password <input name="password" type="password" autoComplete="current-password" required />
54
+ </label>
55
+ <button type="submit">Sign in</button>
56
+ {problem !== null && <p role="alert">{problem}</p>}
57
+ </form>
58
+ </main>
59
+ );
60
+ }
61
+
62
+ function nameOf(patient: Patient): string {
63
+ const name = patient.name?.[0];
64
+ const parts = [...(name?.given ?? []), name?.family].filter((part) => part !== undefined);
65
+ return parts.length > 0 ? parts.join(" ") : (patient.id ?? "Unnamed patient");
66
+ }
@@ -0,0 +1,4 @@
1
+ interface ImportMetaEnv {
2
+ readonly OVOK_BASE_URL: string;
3
+ readonly OVOK_TENANT_CODE: string;
4
+ }
@@ -0,0 +1,12 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+
5
+ const root = document.getElementById("root");
6
+ if (root === null) throw new Error("index.html has no #root element.");
7
+
8
+ createRoot(root).render(
9
+ <StrictMode>
10
+ <App />
11
+ </StrictMode>,
12
+ );
@@ -0,0 +1,7 @@
1
+ import { Ovok } from "@ovok/core/next";
2
+
3
+ export const ovok = new Ovok({
4
+ baseUrl: import.meta.env.OVOK_BASE_URL,
5
+ tenantCode: import.meta.env.OVOK_TENANT_CODE,
6
+ fhirPath: "/fhir/R4/",
7
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "lib": ["es2022", "dom", "dom.iterable"],
5
+ "module": "esnext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "types": ["vite/client"],
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "noEmit": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src", "vite.config.ts"]
15
+ }
@@ -0,0 +1,16 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineConfig, loadEnv } from "vite";
3
+
4
+ const WORKSPACE_ROOT = "../..";
5
+
6
+ export default defineConfig(({ mode }) => {
7
+ const env = loadEnv(mode, WORKSPACE_ROOT, "OVOK_");
8
+ return {
9
+ plugins: [react()],
10
+ // Everything here ships to the browser, so it names each variable rather than a prefix.
11
+ define: {
12
+ "import.meta.env.OVOK_BASE_URL": JSON.stringify(env.OVOK_BASE_URL),
13
+ "import.meta.env.OVOK_TENANT_CODE": JSON.stringify(env.OVOK_TENANT_CODE),
14
+ },
15
+ };
16
+ });
@@ -0,0 +1,2 @@
1
+ OVOK_BASE_URL={{baseUrl}}
2
+ OVOK_TENANT_CODE={{tenantCode}}
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "ovok": {
4
+ "type": "http",
5
+ "url": "{{baseUrl}}/mcp"
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,11 @@
1
+ # {{workspace}}
2
+
3
+ {{agentRules}}
4
+
5
+ ## This repository
6
+
7
+ - A Turborepo on pnpm workspaces: apps in `apps/`, shared code in `packages/`.
8
+ - `pnpm dev` runs every app; `pnpm turbo run dev --filter <app>` runs one.
9
+ - `OVOK_BASE_URL` and `OVOK_TENANT_CODE` live in the root `.env`, which every app reads.
10
+ - `pnpm turbo gen app` adds a web or mobile app and `pnpm turbo gen package` a shared package.
11
+ `pnpm turbo gen agents` rewrites the Ovok block of every AGENTS.md after `create-ovok` updates.
@@ -0,0 +1 @@
1
+ @AGENTS.md
@@ -0,0 +1,11 @@
1
+ # {{workspace}}
2
+
3
+ An Ovok workspace made with `create-ovok`.
4
+
5
+ ```bash
6
+ pnpm install
7
+ pnpm dev
8
+ ```
9
+
10
+ - Apps live in `apps/`. Add one with `pnpm turbo gen app`, a shared package with `pnpm turbo gen package`.
11
+ - The Ovok API and tenant are set in `.env`.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ .turbo/
4
+ .expo/
5
+ ios/
6
+ android/
7
+ *.tsbuildinfo
8
+ .env.local
9
+ .DS_Store
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "{{workspace}}",
3
+ "private": true,
4
+ "packageManager": "pnpm@10.8.0",
5
+ "scripts": {
6
+ "dev": "turbo run dev",
7
+ "build": "turbo run build",
8
+ "typecheck": "turbo run typecheck"
9
+ },
10
+ "devDependencies": {
11
+ "@turbo/gen": "2.11.10",
12
+ "create-ovok": "^{{createOvokVersion}}",
13
+ "turbo": "2.11.10"
14
+ }
15
+ }
@@ -0,0 +1,5 @@
1
+ packages:
2
+ - apps/*
3
+ - packages/*
4
+ onlyBuiltDependencies:
5
+ - esbuild
@@ -0,0 +1,57 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { PlopTypes } from "@turbo/gen";
3
+
4
+ type AppAnswers = { name: string; platform: "web" | "mobile"; os?: "both" | "ios" | "android" };
5
+
6
+ /** Each generator hands off to create-ovok, so `turbo gen` and `pnpm create ovok` share one set of templates. */
7
+ function createOvok(args: string[]): void {
8
+ execFileSync("pnpm", ["exec", "create-ovok", ...args], { stdio: "inherit" });
9
+ }
10
+
11
+ export default function generator(plop: PlopTypes.NodePlopAPI): void {
12
+ plop.setGenerator("app", {
13
+ description: "Add an Ovok web or mobile app to apps/",
14
+ prompts: [
15
+ { type: "input", name: "name", message: "App name (its folder under apps/)" },
16
+ { type: "list", name: "platform", message: "Platform", choices: ["web", "mobile"] },
17
+ {
18
+ type: "list",
19
+ name: "os",
20
+ message: "Mobile OS",
21
+ choices: ["both", "ios", "android"],
22
+ when: (answers: AppAnswers) => answers.platform === "mobile",
23
+ },
24
+ ],
25
+ actions: [
26
+ (answers) => {
27
+ const { name, platform, os } = answers as AppAnswers;
28
+ const type = os === undefined || os === "both" ? [] : ["--type", os];
29
+ createOvok(["add", name, "--platform", platform, ...type]);
30
+ return `Added apps/${name}`;
31
+ },
32
+ ],
33
+ });
34
+
35
+ plop.setGenerator("package", {
36
+ description: "Add a shared package to packages/",
37
+ prompts: [{ type: "input", name: "name", message: "Package name (its folder under packages/)" }],
38
+ actions: [
39
+ (answers) => {
40
+ const { name } = answers as { name: string };
41
+ createOvok(["add-package", name]);
42
+ return `Added packages/${name}`;
43
+ },
44
+ ],
45
+ });
46
+
47
+ plop.setGenerator("agents", {
48
+ description: "Rewrite the Ovok block of every AGENTS.md",
49
+ prompts: [],
50
+ actions: [
51
+ () => {
52
+ createOvok(["agents"]);
53
+ return "Refreshed the Ovok rules";
54
+ },
55
+ ],
56
+ });
57
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://turborepo.com/schema.json",
3
+ "globalDependencies": [".env"],
4
+ "globalEnv": ["OVOK_BASE_URL", "OVOK_TENANT_CODE"],
5
+ "tasks": {
6
+ "build": {
7
+ "dependsOn": ["^build"],
8
+ "outputs": ["dist/**"]
9
+ },
10
+ "dev": {
11
+ "cache": false,
12
+ "persistent": true
13
+ },
14
+ "typecheck": {
15
+ "dependsOn": ["^typecheck"]
16
+ }
17
+ }
18
+ }