Skip to content
Merged

Dev #91

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
196 changes: 117 additions & 79 deletions packages/cli/src/commands/create/module.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { cp, mkdir, writeFile } from "node:fs/promises";
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import * as p from "@clack/prompts";
import { consola } from "consola";

import {
askFramework,
askInstallDeps,
askPackageManager,
copyAgentsFile,
createPackageJson,
exitOnCancel,
type BaseProjectAnswers,
handleSharedAnswers,
afterAnswer,
copyServerEntry,
getCliPackageVersion,
writeRunableConfig,
installDependenciesIfWanted,
} from "./shared.js";
import { copyStarterTemplate } from "./starter.js";

/** Answers collected for the "create a Runable module" flow: the shared answers plus the module's own identity (name and `configKey`). */
export interface ModuleProjectAnswers extends BaseProjectAnswers {
export interface ModuleProjectAnswers {
moduleName: string;
configKey: string;
framework: string;
packageManager: string;
installDeps: boolean;
}

/** Prompts for the module name, enforcing the same format as a valid (optionally scoped) npm package name. */
Expand Down Expand Up @@ -51,10 +56,7 @@ function printSummary(answers: ModuleProjectAnswers): void {
consola.success("Configuration collected:");
consola.info(` Module name: ${answers.moduleName}`);
consola.info(` Config key: ${answers.configKey}`);
consola.info(` appDir: ${answers.appDir}`);
consola.info(` outputDir: ${answers.outputDir}`);
consola.info(` distDir: ${answers.distDir}`);
consola.info(` publicDir: ${answers.publicDir}`);
consola.info(` Framework: ${answers.framework}`);
consola.info(` packageManager: ${answers.packageManager}`);
consola.info(` installDeps: ${answers.installDeps ? "yes" : "no"}`);
}
Expand All @@ -65,69 +67,111 @@ export async function createModulePlayground(
options: {
moduleName: string;
framework: string;
createServerEntry: boolean;
},
) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const sharedStarterDir = resolve(__dirname, "../../../starters/_shared");
const playgroundDir = resolve(moduleDir, "playground");

await mkdir(playgroundDir, { recursive: true });
await cp(resolve(sharedStarterDir, "app"), resolve(playgroundDir, "app"), {
recursive: true,
force: true,
});
if (options.framework !== "other") {
await copyStarterTemplate(options.framework, playgroundDir);
} else {
await mkdir(playgroundDir, { recursive: true });
await cp(sharedStarterDir, playgroundDir, {
recursive: true,
force: true,
});

const version = await getCliPackageVersion();
await writeFile(
resolve(playgroundDir, "package.json"),
`${JSON.stringify(
{
name: "playground",
private: true,
type: "module",
scripts: {
prepare: "runable prepare",
build: "runable build",
typecheck: "tsc --noEmit",
},
dependencies: {
runable: version,
vue: "^3.5.0",
"vue-router": "^5.2.0",
},
devDependencies: {
"@runablejs/cli": version,
"@types/node": "^24.13.3",
typescript: "^6.0.3",
},
},
null,
2,
)}\n`,
);
}

const packageJsonPath = resolve(playgroundDir, "package.json");
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
packageJson.name = `${options.moduleName.replace(/^@/, "").replace("/", "-")}-playground`;
packageJson.private = true;
packageJson.scripts = packageJson.scripts ?? {};
packageJson.scripts.prepare = "runable prepare";
delete packageJson.scripts.preprepare;
delete packageJson.scripts.prebuild;
delete packageJson.scripts["app:prepare"];
delete packageJson.scripts["app:build"];
await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);

await writeFile(
resolve(playgroundDir, "runable.config.ts"),
`import { defineConfig } from "runable";

export default defineConfig({
output: "../.app",
distdir: "../.output",
modules: [".."],
});
`,
);
}

/** Creates the publishable module package at the project root. */
export async function createModuleRoot(
moduleDir: string,
options: {
moduleName: string;
configKey: string;
packageManager: string;
},
) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const sharedStarterDir = resolve(__dirname, "../../../starters/_shared");

await mkdir(moduleDir, { recursive: true });
await copyAgentsFile(moduleDir);
await writeFile(
resolve(moduleDir, "runable.config.ts"),
`import { defineModule } from "runable";

export default defineModule({
configKey: ${JSON.stringify(options.configKey)},
});
`,
);
await cp(
resolve(sharedStarterDir, "tsconfig.json"),
resolve(playgroundDir, "tsconfig.json"),
resolve(moduleDir, "tsconfig.json"),
);
await cp(
resolve(sharedStarterDir, "tsconfig.node.json"),
resolve(playgroundDir, "tsconfig.node.json"),
);
await writeRunableConfig({ modules: [".."] }, { cwd: playgroundDir });
await copyServerEntry(
options.createServerEntry,
options.framework,
playgroundDir,
resolve(moduleDir, "tsconfig.node.json"),
);

const version = await getCliPackageVersion();
const packageJson = {
name: `${options.moduleName.replace(/^@/, "").replace("/", "-")}-playground`,
private: true,
type: "module",
scripts: {
...(options.createServerEntry
? { dev: "runable prepare && tsx watch server.ts" }
: {}),
prepare: "runable prepare",
build: "runable build",
typecheck: "tsc --noEmit",
},
dependencies: {
[options.moduleName]: "*",
...(options.framework === "express" && options.createServerEntry
? { express: "^5.2.1" }
: {}),
runable: version,
vue: "^3.5.0",
"vue-router": "^5.2.0",
},
devDependencies: {
"@runablejs/cli": version,
...(options.framework === "express" && options.createServerEntry
? { "@types/express": "^5.0.6" }
: {}),
"@types/node": "^24.13.3",
tsx: "^4.23.12",
typescript: "^6.0.3",
},
};

await writeFile(
resolve(playgroundDir, "package.json"),
`${JSON.stringify(packageJson, null, 2)}\n`,
await createPackageJson(
moduleDir,
options.moduleName,
options.packageManager,
);
}

Expand All @@ -145,28 +189,22 @@ export async function handleModuleProject() {
consola.info(`Selected module: ${moduleName}`);
consola.info(`Config key: ${configKey}`);

const answers = await handleSharedAnswers();
const framework = await askFramework();
const packageManager = await askPackageManager();
const installDeps = await askInstallDeps();

// Unlike the "existing project" flow, a module gets its own fresh
// directory (named after it) rather than being added to `process.cwd()`.
const moduleDir = resolve(process.cwd(), moduleName);
await mkdir(moduleDir, { recursive: true });

// Record the config key alongside the other shared config values so it
// ends up in the module's generated runable.config.
Object.assign(answers._config, { configKey });

await createModulePlayground(moduleDir, { moduleName, ...answers });

// `isModule: true` tells `afterAnswer` to scaffold module-specific output
// (e.g. `defineModule` instead of `defineConfig`) rather than a regular project.
await afterAnswer(
moduleDir,
{ ...answers, createServerEntry: false },
{ moduleName, isModule: true },
);
await createModuleRoot(moduleDir, {
moduleName,
configKey,
packageManager,
});
await createModulePlayground(moduleDir, { moduleName, framework });
await installDependenciesIfWanted(packageManager, installDeps, moduleDir);

// printSummary(answers);

return answers;
return { moduleName, configKey, framework, packageManager, installDeps };
}
4 changes: 3 additions & 1 deletion packages/cli/src/commands/create/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export async function writeRunableConfig(
export async function createPackageJson(
targetDir: string,
moduleName: string,
packageManager = "pnpm",
): Promise<void> {
const pkgPath = resolve(targetDir, "package.json");

Expand Down Expand Up @@ -477,9 +478,10 @@ export async function createPackageJson(
workspaces: ["playground"],
scripts: {
build: "runable build",
"app:prepare": "runable prepare",
prepare: "runable prepare",
"playground:prepare": "cd playground && runable prepare",
"playground:build": "cd playground && runable build",
"playground:dev": `cd playground && ${packageManager} run dev`,
},
devDependencies: {
"@runablejs/cli": version,
Expand Down
1 change: 1 addition & 0 deletions packages/runable/src/app/components/runable-welcome.vue
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ const links = [
background: var(--welcome-background);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
margin: -8px;
}

.welcome-shell ::selection {
Expand Down
6 changes: 6 additions & 0 deletions packages/runable/src/utils/tsconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export function writeTsConfig() {
tsconfig.app.addInclude(
normalizeDir(join(relative(output, config.appDir), "**/*")),
);

if (config._configFile) {
tsconfig.app.addInclude(
normalizeDir(relative(output, config._configFile)),
);
}
}

const obj = tsconfig.app.toObject();
Expand Down
23 changes: 17 additions & 6 deletions tests/regressions/cli-create-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,21 @@ describe("runable create --module", () => {
const directory = createFixtureDir("cli-module-playground-");

try {
const { createModulePlayground } = await import(
const { createModulePlayground, createModuleRoot } = await import(
"../../packages/cli/dist/commands/create/module.js"
);
const { configurePnpmBuilds, createPackageJson } = await import(
const { configurePnpmBuilds } = await import(
"../../packages/cli/dist/commands/create/shared.js"
);
await createModuleRoot(directory, {
moduleName: "test-module",
configKey: "testModule",
packageManager: "pnpm",
});
await createModulePlayground(directory, {
moduleName: "test-module",
framework: "other",
createServerEntry: false,
framework: "fastify",
});
await createPackageJson(directory, "test-module");
await configurePnpmBuilds("pnpm", directory);

expect(existsSync(path.join(directory, "playground/app/app.vue"))).toBe(
Expand All @@ -69,9 +72,14 @@ describe("runable create --module", () => {
);
expect(packageJson.workspaces).toEqual(["playground"]);
expect(packageJson.scripts).toMatchObject({
prepare: "runable prepare",
"playground:prepare": "cd playground && runable prepare",
"playground:build": "cd playground && runable build",
"playground:dev": "cd playground && pnpm run dev",
});
expect(existsSync(path.join(directory, "tsconfig.json"))).toBe(true);
expect(existsSync(path.join(directory, "tsconfig.node.json"))).toBe(true);
expect(existsSync(path.join(directory, "app"))).toBe(false);

const playgroundPackageJson = JSON.parse(
readFileSync(path.join(directory, "playground/package.json"), "utf8"),
Expand All @@ -80,7 +88,7 @@ describe("runable create --module", () => {
name: "test-module-playground",
private: true,
dependencies: {
"test-module": "*",
fastify: expect.any(String),
runable: expect.any(String),
vue: expect.any(String),
"vue-router": expect.any(String),
Expand All @@ -90,6 +98,9 @@ describe("runable create --module", () => {
typescript: expect.any(String),
},
});
expect(playgroundPackageJson.dependencies).not.toHaveProperty(
"test-module",
);
expect(
readFileSync(path.join(directory, "pnpm-workspace.yaml"), "utf8"),
).toContain("- playground");
Expand Down
18 changes: 10 additions & 8 deletions website/content/docs/en/guide/cli/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,20 +98,22 @@ The module workflow asks for:

1. **Module name** — a valid lowercase npm package name, optionally scoped.
2. **Config key** — the property consumers use in `runable.config.ts`; it defaults to the module name.
3. **Directories** — `appDir`, `outputDir`, `distDir`, and `publicDir`.
3. **Backend framework** — used by the generated playground application.
4. **Package manager** — detected from the current project when possible.
5. **Install dependencies now?**

It creates a **new directory** named after the module. The generated package
contains the application template, `AGENTS.md`, a publishable `package.json`,
and a `runable.config.ts` defined with `defineModule()` instead of
`defineConfig()`. Because these packages are only needed to develop and build
the module, `runable`, `vue`, and `vue-router` are added to `devDependencies`
rather than its runtime dependencies.
contains `AGENTS.md`, TypeScript configuration, a publishable `package.json`,
and a minimal `runable.config.ts` defined with `defineModule()`. Application
files and backend code live in the playground rather than the module root.
Because the framework packages are only needed to develop and build the module,
`runable`, `vue`, and `vue-router` are added to root `devDependencies` rather
than runtime dependencies.

It also creates a `playground/` Runable application for developing the module
locally. Its `runable.config.ts` declares `modules: [".."]`, so it always loads
the module from the project root without requiring the package to be published.
locally using the selected backend starter. Its `runable.config.ts` writes
generated output to the module root and declares `modules: [".."]`, so it
always loads the module locally without requiring the package to be published.
Use `playground:prepare` and `playground:build` from the module root to run the
corresponding Runable commands against that application. The playground has
its own private `package.json` and is registered as a workspace of the module.
Expand Down
Loading