@stjoseph/prumo 0.5.1 → 0.6.1

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 (42) hide show
  1. package/.prumo-templates/api/auth.md +1 -1
  2. package/.prumo-templates/mcp/server.md +1 -1
  3. package/.prumo-templates/monorepo/layout.md +5 -1
  4. package/.prumo-templates/monorepo/tasks.md +5 -3
  5. package/.prumo-templates/social/sign-in.md +2 -1
  6. package/README.md +3 -1
  7. package/dist/add.js +224 -0
  8. package/dist/apps.js +205 -0
  9. package/dist/asker.js +37 -0
  10. package/dist/clean.js +3 -3
  11. package/dist/cli.js +51 -2
  12. package/dist/commands.js +10 -0
  13. package/dist/compose.js +86 -28
  14. package/dist/context.js +32 -5
  15. package/dist/database.js +3 -4
  16. package/dist/generate.js +26 -24
  17. package/dist/names.js +2 -1
  18. package/dist/next-steps.js +4 -3
  19. package/dist/questions.js +22 -3
  20. package/dist/workspace-yaml.js +91 -0
  21. package/package.json +1 -1
  22. package/templates/api/.env.example +1 -0
  23. package/templates/api/README.md +2 -1
  24. package/templates/api/scripts/ports.mjs +60 -45
  25. package/templates/api/src/infra/config/env.ts +8 -1
  26. package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
  27. package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
  28. package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
  29. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
  30. package/templates/api/test/infra/config/env.spec.ts +26 -0
  31. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
  32. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
  33. package/templates/api/test/support/setup.ts +1 -1
  34. package/templates/mobile/scripts/ports.mjs +60 -45
  35. package/templates/mobile/src/api-contract/auth.ts +15 -3
  36. package/templates/site/scripts/ports.mjs +60 -45
  37. package/templates/web/scripts/ports.mjs +60 -45
  38. package/templates/web/src/api-contract/auth.ts +15 -3
  39. package/templates/web/src/features/auth/social-buttons.tsx +2 -2
  40. package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
  41. package/templates/workspace/README.md +2 -2
  42. package/templates/workspace/scripts/ports.mjs +60 -45
package/dist/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import * as prompt from '@clack/prompts';
4
+ import { addApp } from "./add.js";
5
+ import { TYPES } from "./apps.js";
4
6
  import { terminalAsker } from "./asker.js";
5
7
  import { assetsFor, requireAssets } from "./assets.js";
6
8
  import { cleanText, planClean } from "./clean.js";
@@ -29,6 +31,7 @@ async function runNew(args, json) {
29
31
  allowPositionals: true,
30
32
  options: {
31
33
  types: { type: 'string' },
34
+ app: { type: 'string', multiple: true },
32
35
  alone: { type: 'boolean', default: false },
33
36
  monorepo: { type: 'boolean', default: false },
34
37
  'multi-tenant': { type: 'boolean', default: false },
@@ -61,6 +64,7 @@ async function runNew(args, json) {
61
64
  const answers = await resolveAnswers({
62
65
  name,
63
66
  types: values.types,
67
+ apps: values.app,
64
68
  alone: values.alone,
65
69
  monorepo: values.monorepo,
66
70
  multiTenant: values['multi-tenant'],
@@ -72,8 +76,8 @@ async function runNew(args, json) {
72
76
  social: values.social,
73
77
  noSocial: values['no-social'],
74
78
  }, interactive ? terminalAsker((input) => projectProblem(input, cwd)) : undefined, cwd);
75
- if (!json && answers.types.length > 1) {
76
- prompt.log.info(`${answers.types.length} types make a workspace: apps/ and packages/ under one root.`);
79
+ if (!json && answers.architecture === 'monorepo' && answers.apps.length > 1) {
80
+ prompt.log.info(`${answers.apps.length} apps make a workspace: apps/ and packages/ under one root.`);
77
81
  }
78
82
  const { target, warnings, ...chosen } = answers;
79
83
  // Without --json a person is reading; with it, the warnings travel in the result instead.
@@ -95,6 +99,7 @@ async function runNew(args, json) {
95
99
  target,
96
100
  cwd,
97
101
  types: chosen.types,
102
+ apps: chosen.apps,
98
103
  architecture: chosen.architecture,
99
104
  installed: install,
100
105
  });
@@ -102,6 +107,47 @@ async function runNew(args, json) {
102
107
  }
103
108
  return { data: { ...chosen, target, installed: install, warnings }, text: '' };
104
109
  }
110
+ async function runAdd(args, json) {
111
+ const { values, positionals } = parseArgs({
112
+ args,
113
+ allowPositionals: true,
114
+ options: {
115
+ 'skip-install': { type: 'boolean', default: false },
116
+ json: { type: 'boolean', default: false },
117
+ },
118
+ });
119
+ const [type, name, ...extra] = positionals;
120
+ if (type === undefined || extra.length > 0) {
121
+ throw new CliError('usage', 'Usage: prumo add <type> [name], as in `prumo add web backoffice`.');
122
+ }
123
+ if (!TYPES.includes(type)) {
124
+ throw new CliError('invalid_app', `Unknown type: ${type}. Choose from ${TYPES.join(', ')}.`);
125
+ }
126
+ const install = !values['skip-install'];
127
+ const tools = toolChecks(systemProbe, install ? ['pnpm'] : []);
128
+ if (tools.some((check) => check.status !== 'ok')) {
129
+ throw new CliError('not_ready', 'This machine is missing something Prumo needs.', {
130
+ ready: false,
131
+ checks: tools,
132
+ });
133
+ }
134
+ const added = await addApp({
135
+ ...requireAssets(assetsFor(import.meta.dirname)),
136
+ cwd: process.cwd(),
137
+ type: type,
138
+ name,
139
+ install,
140
+ childOutput: json ? 'stderr' : 'inherit',
141
+ });
142
+ const steps = [
143
+ ...(install ? [] : ['pnpm install']),
144
+ `pnpm ${added.app.name} # on port ${added.port}`,
145
+ ];
146
+ return {
147
+ data: added,
148
+ text: `Added ${added.app.name}, a ${added.app.type}, in apps/${added.app.name}.\nNext:\n${steps.map((step) => ` ${step}\n`).join('')}`,
149
+ };
150
+ }
105
151
  async function runClean(args, json) {
106
152
  const { values } = parseArgs({
107
153
  args,
@@ -198,6 +244,9 @@ async function main(argv) {
198
244
  else if (name === 'new') {
199
245
  result = await runNew(args, json);
200
246
  }
247
+ else if (name === 'add') {
248
+ result = await runAdd(args, json);
249
+ }
201
250
  else {
202
251
  throw new CliError('unknown_command', `Unknown command "${name}". Run \`prumo help\`.`);
203
252
  }
package/dist/commands.js CHANGED
@@ -9,6 +9,10 @@ export const COMMANDS = [
9
9
  summary: 'Generate a project and the .prumo/ context it follows; `.` generates into this empty directory',
10
10
  options: [
11
11
  { flag: '--types <list>', description: 'Comma-separated: api, web, mobile, site' },
12
+ {
13
+ flag: '--app <name>=<type>',
14
+ description: 'Advanced, repeatable: an app with a name of its own, as backoffice=web; only web and site may repeat',
15
+ },
12
16
  {
13
17
  flag: '--alone',
14
18
  description: 'One project of one type; without a terminal, one type needs this or --monorepo',
@@ -32,6 +36,12 @@ export const COMMANDS = [
32
36
  JSON_OPTION,
33
37
  ],
34
38
  },
39
+ {
40
+ name: 'add',
41
+ usage: 'prumo add <type> [name] [options]',
42
+ summary: 'Add an app to this workspace, named after its type unless a name is given; only the Prumo version that generated the project adds to it',
43
+ options: [{ flag: '--skip-install', description: 'Do not run pnpm install' }, JSON_OPTION],
44
+ },
35
45
  {
36
46
  name: 'db',
37
47
  usage: 'prumo db [options]',
package/dist/compose.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join, relative, sep } from 'node:path';
4
+ import { appsOfType, PORTS, portOf } from "./apps.js";
4
5
  import { applyFeatures, cutFeatures, NONE } from "./features.js";
5
6
  import { prependProperty, readJsonc, removeWhatBaseDeclares, setJsonc } from "./jsonc.js";
6
- const CLIENTS = ['web', 'mobile'];
7
+ export const CLIENTS = ['web', 'mobile'];
7
8
  // One exception to one-version-per-dependency: stable NativeWind needs Tailwind 3.
8
9
  const NAMED_CATALOGS = [{ name: 'tailwind3', dependency: 'tailwindcss', major: '3' }];
9
10
  const NOT_COPIED = new Set([
@@ -18,6 +19,10 @@ const NOT_COPIED = new Set([
18
19
  const NOT_COPIED_SUFFIXES = ['.tsbuildinfo'];
19
20
  // The name inside the package, and the name it is restored to. npm deletes a dotted .gitignore from a tarball.
20
21
  const PACKAGED_AS = { gitignore: '.gitignore' };
22
+ // The root's `dev` sets the API's database up first, in whichever directory the API was given.
23
+ export function rootDevPrefix(api) {
24
+ return `node apps/${api}/scripts/database.mjs --check && `;
25
+ }
21
26
  const CONTRACT_IMPORT = "'@/api-contract'";
22
27
  const CONTRACT_PACKAGE = '@app/api-contract';
23
28
  function dependencySections(pkg) {
@@ -46,9 +51,11 @@ export async function copyTemplate(source, destination) {
46
51
  }
47
52
  }
48
53
  }
49
- async function copyApp(templates, target, type) {
50
- const destination = join(target, 'apps', type);
51
- await copyTemplate(join(templates, type), destination);
54
+ export async function copyApp(templates, target, app) {
55
+ const destination = join(target, 'apps', app.name);
56
+ await copyTemplate(join(templates, app.type), destination);
57
+ // The package is the app's name, which the root's `pnpm --filter <name>` finds it by.
58
+ await rewriteText(join(destination, 'package.json'), (text) => setJsonc(text, ['name'], app.name));
52
59
  // The workspace root carries both, for every app below it.
53
60
  for (const rootOnly of ['.githooks', '.gitattributes']) {
54
61
  await rm(join(destination, rootOnly), { recursive: true, force: true });
@@ -62,8 +69,8 @@ async function contractFiles(directory) {
62
69
  }
63
70
  return files;
64
71
  }
65
- async function extractContract(target, clients) {
66
- const [first, ...others] = clients.map((client) => join(target, 'apps', client));
72
+ export async function extractContract(target, clients) {
73
+ const [first, ...others] = clients.map((client) => join(target, 'apps', client.name));
67
74
  if (first === undefined) {
68
75
  return;
69
76
  }
@@ -79,15 +86,62 @@ async function extractContract(target, clients) {
79
86
  }
80
87
  await rename(join(first, 'src', 'api-contract'), join(target, 'packages', 'api-contract', 'src'));
81
88
  for (const client of [first, ...others]) {
82
- for (const directory of ['src', 'test']) {
83
- for (const file of await sourceFiles(join(client, directory)).catch(() => [])) {
84
- await rewriteText(file, (text) => text.replaceAll(CONTRACT_IMPORT, `'${CONTRACT_PACKAGE}'`));
85
- }
89
+ await importContract(client);
90
+ }
91
+ }
92
+ // A client of the workspace imports the contract from its package rather than from a copy of its own.
93
+ async function importContract(client) {
94
+ for (const directory of ['src', 'test']) {
95
+ for (const file of await sourceFiles(join(client, directory)).catch(() => [])) {
96
+ await rewriteText(file, (text) => text.replaceAll(CONTRACT_IMPORT, `'${CONTRACT_PACKAGE}'`));
97
+ }
98
+ }
99
+ await rewriteText(join(client, 'package.json'), (text) => setJsonc(text, ['dependencies', CONTRACT_PACKAGE], 'workspace:*'));
100
+ }
101
+ // A client added to a workspace that already shares a contract takes the team's, which may have grown since, and
102
+ // drops the template's copy.
103
+ export async function joinContract(client) {
104
+ await rm(join(client, 'src', 'api-contract'), { recursive: true });
105
+ await importContract(client);
106
+ }
107
+ function setEnvLine(text, key, value, file) {
108
+ const line = new RegExp(`^${key}=.*$`, 'm');
109
+ if (!line.test(text)) {
110
+ throw new Error(`${file} no longer carries a ${key} line`);
111
+ }
112
+ return text.replace(line, `${key}=${value}`);
113
+ }
114
+ // A second web or site takes the next port of its type, written where the template wrote the first, and the API
115
+ // trusts every web app, the first one first.
116
+ async function placePorts(target, apps) {
117
+ for (const app of apps) {
118
+ const { key, from } = PORTS[app.type];
119
+ const port = portOf(apps, app);
120
+ const directory = join(target, 'apps', app.name);
121
+ if (port === from) {
122
+ continue;
123
+ }
124
+ const example = join(directory, '.env.example');
125
+ await rewriteText(example, (text) => setEnvLine(text, key, String(port), example));
126
+ if (app.type === 'site') {
127
+ await rewriteText(join(directory, 'package.json'), (text) => {
128
+ const start = readJsonc(text).scripts?.start ?? '';
129
+ if (!start.includes(`--port ${from}`)) {
130
+ throw new Error(`${join(directory, 'package.json')} no longer starts on --port ${from}`);
131
+ }
132
+ return setJsonc(text, ['scripts', 'start'], start.replace(`--port ${from}`, `--port ${port}`));
133
+ });
86
134
  }
87
- await rewriteText(join(client, 'package.json'), (text) => setJsonc(text, ['dependencies', CONTRACT_PACKAGE], 'workspace:*'));
135
+ }
136
+ const [api] = appsOfType(apps, 'api');
137
+ const webs = appsOfType(apps, 'web');
138
+ if (api !== undefined && webs.length > 1) {
139
+ const example = join(target, 'apps', api.name, '.env.example');
140
+ const origins = webs.map((web) => `http://localhost:${portOf(apps, web)}`).join(',');
141
+ await rewriteText(example, (text) => setEnvLine(text, 'WEB_ORIGIN', origins, example));
88
142
  }
89
143
  }
90
- function namedCatalogFor(dependency, version) {
144
+ export function namedCatalogFor(dependency, version) {
91
145
  return NAMED_CATALOGS.find((entry) => entry.dependency === dependency && version.split('.')[0] === entry.major)?.name;
92
146
  }
93
147
  export function collectCatalog(packages) {
@@ -113,7 +167,7 @@ export function collectCatalog(packages) {
113
167
  }
114
168
  return catalogs;
115
169
  }
116
- async function adaptAppConfig(target, app) {
170
+ export async function adaptAppConfig(target, app) {
117
171
  const packagePath = join(app, 'package.json');
118
172
  let pkg = await readFile(packagePath, 'utf8');
119
173
  const parsed = readJsonc(pkg);
@@ -143,7 +197,7 @@ async function adaptAppConfig(target, app) {
143
197
  const biome = removeWhatBaseDeclares(await readFile(biomePath, 'utf8'), rootBiome);
144
198
  await writeFile(biomePath, prependProperty(biome, 'extends', '//'));
145
199
  }
146
- async function takeBuildApprovals(app) {
200
+ export async function takeBuildApprovals(app) {
147
201
  const path = join(app, 'pnpm-workspace.yaml');
148
202
  const text = await readFile(path, 'utf8').catch(() => undefined);
149
203
  if (text === undefined) {
@@ -185,20 +239,24 @@ async function assertCatalogCovers(target, catalog) {
185
239
  }
186
240
  }
187
241
  }
188
- export async function composeWorkspace({ templates, target, types, features = NONE, }) {
242
+ export async function composeWorkspace({ templates, target, apps, features = NONE, }) {
189
243
  await mkdir(target, { recursive: true });
190
244
  await copyTemplate(join(templates, 'workspace'), target);
191
245
  // The root README tells a first run what the features change, so it is cut like any app's.
246
+ // It also points into the API's directory, which may have been given a name of its own.
192
247
  const readme = join(target, 'README.md');
193
- await writeFile(readme, cutFeatures(await readFile(readme, 'utf8'), features, readme));
194
- const apps = [];
195
- for (const type of types) {
196
- const app = await copyApp(templates, target, type);
248
+ const [api] = appsOfType(apps, 'api');
249
+ const cut = cutFeatures(await readFile(readme, 'utf8'), features, readme);
250
+ await writeFile(readme, cut.replaceAll('apps/api/', `apps/${api?.name ?? 'api'}/`));
251
+ const directories = [];
252
+ for (const app of apps) {
253
+ const directory = await copyApp(templates, target, app);
197
254
  // Before the catalog is collected, so a dependency that leaves never reaches it.
198
- await applyFeatures(app, type, features);
199
- apps.push(app);
255
+ await applyFeatures(directory, app.type, features);
256
+ directories.push(directory);
200
257
  }
201
- const clients = CLIENTS.filter((client) => types.includes(client));
258
+ await placePorts(target, apps);
259
+ const clients = apps.filter((app) => CLIENTS.includes(app.type));
202
260
  if (clients.length > 0) {
203
261
  await extractContract(target, clients);
204
262
  }
@@ -210,18 +268,18 @@ export async function composeWorkspace({ templates, target, types, features = NO
210
268
  // parallel run would bury, so the root asks first and each app's own check then finds nothing to do.
211
269
  // `pnpm -r --parallel` starts each app in its own process group, so Ctrl+C reaches pnpm and leaves the apps
212
270
  // running. The ports script wraps the run, remembers what it started, and stops all of it on the way out.
213
- const database = types.includes('api') ? 'node apps/api/scripts/database.mjs --check && ' : '';
271
+ const database = api === undefined ? '' : rootDevPrefix(api.name);
214
272
  const dev = `${database}node scripts/ports.mjs --all -- pnpm -r --parallel dev`;
215
- await rewriteText(join(target, 'package.json'), (text) => types.reduce((pkg, type) => setJsonc(pkg, ['scripts', type], `pnpm --filter ${type} dev`), setJsonc(text, ['scripts', 'dev'], dev)));
216
- const manifests = await Promise.all(apps.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
273
+ await rewriteText(join(target, 'package.json'), (text) => apps.reduce((pkg, app) => setJsonc(pkg, ['scripts', app.name], `pnpm --filter ${app.name} dev`), setJsonc(text, ['scripts', 'dev'], dev)));
274
+ const manifests = await Promise.all(directories.map(async (app) => readJsonc(await readFile(join(app, 'package.json'), 'utf8'))));
217
275
  const catalogs = collectCatalog(manifests);
218
276
  if (clients.length > 0) {
219
277
  await assertCatalogCovers(target, catalogs.default);
220
278
  }
221
279
  const approvals = [];
222
- for (const app of apps) {
223
- await adaptAppConfig(target, app);
224
- for (const line of await takeBuildApprovals(app)) {
280
+ for (const directory of directories) {
281
+ await adaptAppConfig(target, directory);
282
+ for (const line of await takeBuildApprovals(directory)) {
225
283
  if (!approvals.includes(line)) {
226
284
  approvals.push(line);
227
285
  }
package/dist/context.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
4
+ import { appsOf } from "./apps.js";
3
5
  import { cliVersion } from "./version.js";
4
6
  const CLIENTS = ['web', 'mobile', 'site'];
5
7
  export function areasFor(answers) {
@@ -20,21 +22,23 @@ async function titleOf(path) {
20
22
  const firstLine = (await readFile(path, 'utf8')).split('\n', 1)[0] ?? '';
21
23
  return firstLine.replace(/^#\s*/, '');
22
24
  }
23
- export async function writeContext(knowledge, root, answers) {
24
- const prumo = join(root, '.prumo');
25
+ // The index lists every file of every area the project holds. It is the tool's file, so it is always rewritten.
26
+ async function writeIndex(prumo, areas) {
25
27
  const lines = [];
26
- await mkdir(prumo, { recursive: true });
27
- for (const area of areasFor(answers)) {
28
- await cp(join(knowledge, area), join(prumo, area), { recursive: true });
28
+ for (const area of areas) {
29
29
  for (const file of (await readdir(join(prumo, area))).filter((f) => f.endsWith('.md')).sort()) {
30
30
  lines.push(`- [${area}/${file}](${area}/${file}): ${await titleOf(join(prumo, area, file))}`);
31
31
  }
32
32
  }
33
33
  await writeFile(join(prumo, 'INDEX.md'), `# Conventions\n\n${lines.join('\n')}\n`);
34
+ }
35
+ async function writeConfig(prumo, answers) {
34
36
  // The version that wrote the project, so a tool reading it can tell a project made by an older Prumo.
35
37
  const config = {
36
38
  prumo: cliVersion(),
37
39
  types: answers.types,
40
+ // Only a workspace has apps/ to name a directory in.
41
+ ...(answers.architecture === 'monorepo' ? { apps: appsOf(answers) } : {}),
38
42
  architecture: answers.architecture,
39
43
  multiTenant: answers.multiTenant,
40
44
  mcp: answers.mcp,
@@ -42,6 +46,29 @@ export async function writeContext(knowledge, root, answers) {
42
46
  social: answers.social,
43
47
  };
44
48
  await writeFile(join(prumo, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
49
+ }
50
+ export async function writeContext(knowledge, root, answers) {
51
+ const prumo = join(root, '.prumo');
52
+ const areas = areasFor(answers);
53
+ await mkdir(prumo, { recursive: true });
54
+ for (const area of areas) {
55
+ await cp(join(knowledge, area), join(prumo, area), { recursive: true });
56
+ }
57
+ await writeIndex(prumo, areas);
58
+ await writeConfig(prumo, answers);
45
59
  await writeFile(join(root, 'AGENTS.md'), `# ${answers.name}\n\nThis project's conventions live in \`.prumo/\`. Read [\`.prumo/INDEX.md\`](.prumo/INDEX.md) before changing how something is done.\n`);
46
60
  await writeFile(join(root, 'CLAUDE.md'), '@AGENTS.md\n');
47
61
  }
62
+ // A project that grew: the areas it now calls for and did not have arrive, and an area it had stays as the team left
63
+ // it. The index and the config follow; AGENTS.md is the team's, and is not touched. Returns the areas that arrived.
64
+ export async function growContext(knowledge, root, answers) {
65
+ const prumo = join(root, '.prumo');
66
+ const areas = areasFor(answers);
67
+ const arrived = areas.filter((area) => !existsSync(join(prumo, area)));
68
+ for (const area of arrived) {
69
+ await cp(join(knowledge, area), join(prumo, area), { recursive: true });
70
+ }
71
+ await writeIndex(prumo, areas);
72
+ await writeConfig(prumo, answers);
73
+ return arrived;
74
+ }
package/dist/database.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
+ import { appDirectory, appsOf, appsOfType } from "./apps.js";
4
5
  import { CliError } from "./output.js";
5
6
  const SCRIPT = join('scripts', 'database.mjs');
6
7
  export function findProjectRoot(from) {
@@ -19,10 +20,8 @@ export function readConfig(root) {
19
20
  // Where the API sits, or undefined when the project has none.
20
21
  export function apiPath(root) {
21
22
  const config = readConfig(root);
22
- if (!config.types.includes('api')) {
23
- return undefined;
24
- }
25
- return config.architecture === 'alone' ? root : join(root, 'apps', 'api');
23
+ const [api] = appsOfType(appsOf(config), 'api');
24
+ return api === undefined ? undefined : appDirectory(root, config.architecture, api);
26
25
  }
27
26
  export function apiDirectory(root) {
28
27
  const api = apiPath(root);
package/dist/generate.js CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { appDirectory, appsOf, appsOfType } from "./apps.js";
5
6
  import { runCommand } from "./command.js";
6
7
  import { composeWorkspace, copyTemplate } from "./compose.js";
7
8
  import { writeContext } from "./context.js";
@@ -16,7 +17,7 @@ async function nameProject(root, name) {
16
17
  await rewrite(join(root, 'package.json'), (text) => setJsonc(text, ['name'], name));
17
18
  await rewrite(join(root, 'README.md'), (text) => text.replace(/^# .*$/m, `# ${name}`));
18
19
  }
19
- async function nameMobileApp(app, name) {
20
+ export async function nameMobileApp(app, name) {
20
21
  await rewrite(join(app, 'app.json'), (text) => {
21
22
  let result = setJsonc(text, ['expo', 'name'], name);
22
23
  result = setJsonc(result, ['expo', 'slug'], name);
@@ -27,7 +28,7 @@ const SECRET_LINE = /^JWT_SECRET=.*$/m;
27
28
  const SCHEME_LINES = /^# The mobile app's scheme.*\nMOBILE_APP_SCHEME=.*\n/m;
28
29
  // Social sign-in sends a mobile app back by its scheme, the one app.json was given. Without a mobile app the setting
29
30
  // names nothing, so it goes. Without social sign-in its marker already took it.
30
- async function settleMobileScheme(api, scheme) {
31
+ export async function settleMobileScheme(api, scheme) {
31
32
  for (const file of ['.env.example', '.env']) {
32
33
  const path = join(api, file);
33
34
  if (existsSync(path)) {
@@ -39,7 +40,7 @@ async function settleMobileScheme(api, scheme) {
39
40
  }
40
41
  // `.env` stays out of git, so the example is the committed truth and a fresh project gets a copy it can start with.
41
42
  // Only the secret differs: a sample value is public, so every generated API draws its own.
42
- async function writeLocalEnv(app, type) {
43
+ export async function writeLocalEnv(app, type) {
43
44
  const example = join(app, '.env.example');
44
45
  if (!existsSync(example)) {
45
46
  return;
@@ -55,7 +56,7 @@ async function writeLocalEnv(app, type) {
55
56
  }
56
57
  // Compose names the container and volume after the project; without this, every workspace's apps/api is `api`
57
58
  // and a second project's database replaces the first's.
58
- async function nameCompose(api, name) {
59
+ export async function nameCompose(api, name) {
59
60
  await rewrite(join(api, 'docker-compose.yml'), (text) => {
60
61
  if (!/^name: .*$/m.test(text)) {
61
62
  throw new Error(`${join(api, 'docker-compose.yml')} no longer carries a top-level name`);
@@ -69,12 +70,18 @@ let interrupted = false;
69
70
  function interrupt() {
70
71
  interrupted = true;
71
72
  }
73
+ // From here until the returned function is called, Ctrl+C stops the next step instead of Prumo itself.
74
+ export function watchInterrupt() {
75
+ interrupted = false;
76
+ process.on('SIGINT', interrupt);
77
+ return () => process.off('SIGINT', interrupt);
78
+ }
72
79
  function stopIfInterrupted() {
73
80
  if (interrupted) {
74
81
  throw new CliError('interrupted', 'Interrupted. Nothing was kept.');
75
82
  }
76
83
  }
77
- function run(command, args, cwd, output) {
84
+ export function run(command, args, cwd, output) {
78
85
  stopIfInterrupted();
79
86
  // Under --json stdout belongs to the result document, so a child's output goes to stderr instead.
80
87
  const result = runCommand(command, args, {
@@ -109,8 +116,7 @@ export async function generate({ templates, knowledge, target, answers, install,
109
116
  throw new CliError('target_not_empty', occupied);
110
117
  }
111
118
  const created = !existsSync(target);
112
- interrupted = false;
113
- process.on('SIGINT', interrupt);
119
+ const unwatch = watchInterrupt();
114
120
  try {
115
121
  await write({ templates, knowledge, target, answers, install, childOutput });
116
122
  }
@@ -119,11 +125,12 @@ export async function generate({ templates, knowledge, target, answers, install,
119
125
  throw error;
120
126
  }
121
127
  finally {
122
- process.off('SIGINT', interrupt);
128
+ unwatch();
123
129
  }
124
130
  }
125
131
  async function write({ templates, knowledge, target, answers, install, childOutput, }) {
126
132
  const [only] = answers.types;
133
+ const apps = appsOf(answers);
127
134
  if (answers.architecture === 'alone' && only !== undefined) {
128
135
  await copyTemplate(join(templates, only), target);
129
136
  await applyFeatures(target, only, enabledFor(answers));
@@ -137,22 +144,18 @@ async function write({ templates, knowledge, target, answers, install, childOutp
137
144
  }
138
145
  }
139
146
  else {
140
- await composeWorkspace({
141
- templates,
142
- target,
143
- types: answers.types,
144
- features: enabledFor(answers),
145
- });
147
+ const at = (app) => join(target, 'apps', app.name);
148
+ await composeWorkspace({ templates, target, apps, features: enabledFor(answers) });
146
149
  await nameProject(target, answers.name);
147
- for (const type of answers.types) {
148
- await writeLocalEnv(join(target, 'apps', type), type);
150
+ for (const app of apps) {
151
+ await writeLocalEnv(at(app), app.type);
149
152
  }
150
- if (answers.types.includes('api')) {
151
- await nameCompose(join(target, 'apps', 'api'), answers.name);
152
- await settleMobileScheme(join(target, 'apps', 'api'), answers.types.includes('mobile') ? schemeFor(answers.name) : null);
153
+ for (const api of appsOfType(apps, 'api')) {
154
+ await nameCompose(at(api), answers.name);
155
+ await settleMobileScheme(at(api), answers.types.includes('mobile') ? schemeFor(answers.name) : null);
153
156
  }
154
- if (answers.types.includes('mobile')) {
155
- await nameMobileApp(join(target, 'apps', 'mobile'), answers.name);
157
+ for (const mobile of appsOfType(apps, 'mobile')) {
158
+ await nameMobileApp(at(mobile), answers.name);
156
159
  }
157
160
  }
158
161
  await writeContext(knowledge, target, answers);
@@ -162,9 +165,8 @@ async function write({ templates, knowledge, target, answers, install, childOutp
162
165
  // Rewriting the contract import changes import grouping and line length; only the formatter can settle both.
163
166
  run('pnpm', ['exec', 'biome', 'check', '--write'], target, childOutput);
164
167
  // The route tree is generated from the route files, and cutting MCP removed one.
165
- if (answers.types.includes('web')) {
166
- const web = answers.architecture === 'alone' ? target : join(target, 'apps', 'web');
167
- run('pnpm', ['exec', 'tsr', 'generate'], web, childOutput);
168
+ for (const web of appsOfType(apps, 'web')) {
169
+ run('pnpm', ['exec', 'tsr', 'generate'], appDirectory(target, answers.architecture, web), childOutput);
168
170
  }
169
171
  }
170
172
  }
package/dist/names.js CHANGED
@@ -4,7 +4,8 @@ import { EXTENDED_BY_PNPM } from "./pnpm-extended.js";
4
4
  const PROJECT_NAME = /^[a-z][a-z0-9-]*$/;
5
5
  // npm refuses a longer package name.
6
6
  const MAX_LENGTH = 214;
7
- // In a workspace each app's package is named after its type, and `pnpm --filter <type>` would match the root too.
7
+ // In a workspace an app's package is named after its type by default, and `pnpm --filter <type>` would match the
8
+ // root too. A name given on purpose is checked against the project's in apps.ts.
8
9
  const RESERVED = ['api', 'web', 'mobile', 'site'];
9
10
  export function validateProjectName(name) {
10
11
  if (!PROJECT_NAME.test(name)) {
@@ -1,8 +1,9 @@
1
1
  import { relative } from 'node:path';
2
+ import { appsOfType, defaultApps } from "./apps.js";
2
3
  // What to type after `prumo new`, built from the scripts the templates ship rather than written per case. Every app
3
4
  // and every workspace root answers to `pnpm dev`; an API's `dev` first offers to create the database, and a mobile
4
5
  // app opens only in a development build, which its `ios` and `android` scripts make.
5
- export function nextSteps({ target, cwd, types, architecture, installed, }) {
6
+ export function nextSteps({ target, cwd, types, apps = defaultApps(types), architecture, installed, }) {
6
7
  const steps = [];
7
8
  const directory = relative(cwd, target);
8
9
  if (directory !== '') {
@@ -11,8 +12,8 @@ export function nextSteps({ target, cwd, types, architecture, installed, }) {
11
12
  if (!installed) {
12
13
  steps.push(['pnpm install']);
13
14
  }
14
- if (types.includes('mobile')) {
15
- const run = architecture === 'alone' ? 'pnpm' : 'pnpm --filter mobile';
15
+ for (const mobile of appsOfType(apps, 'mobile')) {
16
+ const run = architecture === 'alone' ? 'pnpm' : `pnpm --filter ${mobile.name}`;
16
17
  steps.push([`${run} ios`, 'or android: the development build the mobile app opens in']);
17
18
  }
18
19
  steps.push([
package/dist/questions.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { defaultApps, parseApp, TYPES, typesOf, validateApps } from "./apps.js";
1
2
  import { SOCIAL_PROVIDERS } from "./features.js";
2
3
  import { projectAt, targetProblem, validateProjectName } from "./names.js";
3
4
  import { CliError } from "./output.js";
4
- const TYPES = ['api', 'web', 'mobile', 'site'];
5
5
  const TENANT_AWARE = ['api', 'web', 'mobile'];
6
6
  function missing(flag) {
7
7
  throw new CliError('needs_input', `Missing ${flag}. Outside an interactive terminal every answer must come from a flag.`);
@@ -59,18 +59,27 @@ export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
59
59
  if (occupied !== undefined) {
60
60
  throw new CliError('target_not_empty', occupied);
61
61
  }
62
+ const named = (flags.apps ?? []).map(parseApp);
62
63
  let types;
63
64
  if (flags.types !== undefined) {
64
65
  types = parseTypes(flags.types);
65
66
  }
67
+ else if (named.length > 0) {
68
+ types = [];
69
+ }
66
70
  else if (asker !== undefined) {
67
71
  types = await asker.types();
68
72
  }
69
73
  else {
70
74
  missing('--types');
71
75
  }
76
+ // The types answered take their own names; an app named on purpose comes after them.
77
+ let apps = [...defaultApps(types), ...named];
72
78
  let architecture;
73
- if (types.length > 1) {
79
+ if (named.length > 0 && flags.alone) {
80
+ throw new CliError('invalid_input', '--app names an app inside a workspace; drop --alone.');
81
+ }
82
+ if (apps.length > 1 || named.length > 0) {
74
83
  if (flags.alone) {
75
84
  throw new CliError('invalid_input', '--alone holds a single type; several types make a workspace.');
76
85
  }
@@ -85,6 +94,16 @@ export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
85
94
  else {
86
95
  missing('--alone or --monorepo');
87
96
  }
97
+ // Renaming an app, or adding another web or site, is for those who ask: offered once, after the types were asked
98
+ // rather than passed, and answered no by default. With flags, --app says it.
99
+ if (architecture === 'monorepo' &&
100
+ flags.types === undefined &&
101
+ named.length === 0 &&
102
+ asker !== undefined) {
103
+ apps = await asker.apps(apps, name);
104
+ }
105
+ validateApps(apps, name);
106
+ types = typesOf(apps);
88
107
  let multiTenant = false;
89
108
  if (!types.some((type) => TENANT_AWARE.includes(type))) {
90
109
  if (flags.multiTenant) {
@@ -162,5 +181,5 @@ export async function resolveAnswers(flags, asker, cwd = process.cwd()) {
162
181
  warnings.push('Without --email no password account is ever confirmed, so the first sign-in with a provider for an existing ' +
163
182
  "account's email removes its password: the owner keeps the account through the provider. --email avoids it.");
164
183
  }
165
- return { name, types, architecture, multiTenant, mcp, email, social, target, warnings };
184
+ return { name, types, apps, architecture, multiTenant, mcp, email, social, target, warnings };
166
185
  }