create-ovok 0.1.0-dev.13 → 0.1.0-dev.21

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.
package/README.md CHANGED
@@ -1,11 +1,28 @@
1
1
  # create-ovok
2
2
 
3
3
  ```bash
4
- pnpm create ovok my-app --platform web|mobile|mobile-web [--type ios|android] [--tenant <code>] [--base-url <url>]
4
+ pnpm create ovok my-app
5
5
  ```
6
6
 
7
- Missing options are asked for on a terminal. The result is a Turborepo with the chosen apps in
8
- `apps/`, the Ovok API and tenant in `.env`, and the Ovok MCP server in `.mcp.json`.
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.
9
26
 
10
27
  Inside the workspace:
11
28
 
@@ -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,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,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
+ }
package/dist/main.js CHANGED
@@ -1,23 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
3
  import { parseArgs } from "node:util";
4
+ import { cancel } from "@clack/prompts";
5
+ import { ClackPrompt } from "./adapters/clack-prompt.js";
5
6
  import { FsProjectFiles } from "./adapters/fs-project-files.js";
6
- import { FsTemplateSource } from "./adapters/fs-template-source.js";
7
- import { TerminalPrompt } from "./adapters/terminal-prompt.js";
7
+ import { dependenciesAt } from "./adapters/scaffold-dependencies.js";
8
8
  import { addApp, addPackage } from "./application/add-app.js";
9
- import { createWorkspace } from "./application/create-workspace.js";
10
9
  import { refreshAgentRules } from "./application/refresh-agent-rules.js";
11
- import { MOBILE_OSES, osFor, PLATFORMS, parseChoice, parsePackageName, STARTING_PLATFORMS, startingApps, } from "./domain/app-spec.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";
12
13
  const EXIT_FAILURE = 1;
13
- const DEFAULT_BASE_URL = "https://api.sandbox.ovok.com";
14
- const DEFAULT_TENANT_CODE = "public-example";
14
+ const EXIT_CANCELLED = 130;
15
15
  const OPTIONS = {
16
16
  platform: { type: "string" },
17
17
  type: { type: "string" },
18
18
  tenant: { type: "string" },
19
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" },
20
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
+ }
21
49
  const SUBCOMMANDS = {
22
50
  add: async ([name], values) => {
23
51
  const platform = parseChoice(values.platform ?? "", PLATFORMS, "--platform");
@@ -37,42 +65,44 @@ const SUBCOMMANDS = {
37
65
  },
38
66
  };
39
67
  async function main(argv) {
40
- const { values, positionals } = parseArgs({
41
- args: argv,
42
- allowPositionals: true,
43
- options: OPTIONS,
44
- });
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());
45
73
  const [first, ...rest] = positionals;
46
74
  const subcommand = first === undefined ? undefined : SUBCOMMANDS[first];
47
75
  if (subcommand === undefined)
48
- return create(first, values);
76
+ return create(first ?? null, values);
49
77
  return subcommand(rest, values);
50
78
  }
51
- async function create(nameArgument, values) {
52
- const prompt = new TerminalPrompt();
53
- const name = parsePackageName(nameArgument ?? (await prompt.ask("Project name", "ovok-app")));
54
- const platforms = values.platform === undefined
55
- ? await prompt.choose("Platforms", STARTING_PLATFORMS, "mobile-web")
56
- : parseChoice(values.platform, STARTING_PLATFORMS, "--platform");
57
- const apps = startingApps(platforms, typeOf(values));
58
- const directory = resolve(name);
59
- await createWorkspace({
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 {
60
94
  name,
61
- baseUrl: (values["base-url"] ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
62
- tenantCode: values.tenant ?? (await prompt.ask("Tenant code", DEFAULT_TENANT_CODE)),
63
- createOvokVersion: await ownVersion(),
64
- apps,
65
- }, dependenciesAt(directory));
66
- console.log(`Created ${name} with ${apps.map((app) => `apps/${app.name}`).join(" and ")}.\n`);
67
- console.log(` cd ${name}\n pnpm install\n pnpm dev\n`);
68
- console.log("Add another app with `pnpm turbo gen app`.");
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
+ };
69
102
  }
70
103
  function typeOf(values) {
71
104
  return values.type === undefined ? null : parseChoice(values.type, MOBILE_OSES, "--type");
72
105
  }
73
- function dependenciesAt(root) {
74
- return { templates: new FsTemplateSource(), files: new FsProjectFiles(root) };
75
- }
76
106
  async function ownVersion() {
77
107
  const manifest = await readFile(new URL("../package.json", import.meta.url), "utf8");
78
108
  return JSON.parse(manifest).version;
@@ -81,6 +111,12 @@ try {
81
111
  await main(process.argv.slice(2));
82
112
  }
83
113
  catch (error) {
84
- console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
85
- process.exitCode = EXIT_FAILURE;
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
+ }
86
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ovok",
3
- "version": "0.1.0-dev.13",
3
+ "version": "0.1.0-dev.21",
4
4
  "description": "Create a Turborepo with Ovok web and mobile apps, and add more apps through turbo gen.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,5 +18,8 @@
18
18
  "build": "tsc -p tsconfig.build.json",
19
19
  "typecheck": "tsc -p tsconfig.json",
20
20
  "test": "node --test \"src/**/*.test.ts\""
21
+ },
22
+ "dependencies": {
23
+ "@clack/prompts": "^1.8.1"
21
24
  }
22
25
  }
@@ -1,28 +0,0 @@
1
- import { createInterface } from "node:readline/promises";
2
- /** Asks only on a terminal; anywhere else a missing answer takes its default. */
3
- export class TerminalPrompt {
4
- #interactive;
5
- constructor(interactive = Boolean(process.stdin.isTTY)) {
6
- this.#interactive = interactive;
7
- }
8
- async ask(question, fallback) {
9
- if (!this.#interactive)
10
- return fallback;
11
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
12
- try {
13
- const answer = (await prompt.question(`${question} (${fallback}): `)).trim();
14
- return answer === "" ? fallback : answer;
15
- }
16
- finally {
17
- prompt.close();
18
- }
19
- }
20
- async choose(question, choices, fallback) {
21
- for (;;) {
22
- const answer = await this.ask(`${question} [${choices.join(" | ")}]`, fallback);
23
- const choice = choices.find((candidate) => candidate === answer);
24
- if (choice !== undefined)
25
- return choice;
26
- }
27
- }
28
- }