@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.
- package/.prumo-templates/api/auth.md +1 -1
- package/.prumo-templates/mcp/server.md +1 -1
- package/.prumo-templates/monorepo/layout.md +5 -1
- package/.prumo-templates/monorepo/tasks.md +5 -3
- package/.prumo-templates/social/sign-in.md +2 -1
- package/README.md +3 -1
- package/dist/add.js +224 -0
- package/dist/apps.js +205 -0
- package/dist/asker.js +37 -0
- package/dist/clean.js +3 -3
- package/dist/cli.js +51 -2
- package/dist/commands.js +10 -0
- package/dist/compose.js +86 -28
- package/dist/context.js +32 -5
- package/dist/database.js +3 -4
- package/dist/generate.js +26 -24
- package/dist/names.js +2 -1
- package/dist/next-steps.js +4 -3
- package/dist/questions.js +22 -3
- package/dist/workspace-yaml.js +91 -0
- package/package.json +1 -1
- package/templates/api/.env.example +1 -0
- package/templates/api/README.md +2 -1
- package/templates/api/scripts/ports.mjs +60 -45
- package/templates/api/src/infra/config/env.ts +8 -1
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +2 -2
- package/templates/api/src/infra/http/controllers/social.controller.ts +29 -13
- package/templates/api/src/infra/http/hooks/origin.hook.ts +4 -4
- package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +1 -1
- package/templates/api/test/infra/config/env.spec.ts +26 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +24 -0
- package/templates/api/test/infra/http/controllers/social.controller.spec.ts +23 -1
- package/templates/api/test/support/setup.ts +1 -1
- package/templates/mobile/scripts/ports.mjs +60 -45
- package/templates/mobile/src/api-contract/auth.ts +15 -3
- package/templates/site/scripts/ports.mjs +60 -45
- package/templates/web/scripts/ports.mjs +60 -45
- package/templates/web/src/api-contract/auth.ts +15 -3
- package/templates/web/src/features/auth/social-buttons.tsx +2 -2
- package/templates/web/test/features/auth/social-buttons.spec.tsx +1 -0
- package/templates/workspace/README.md +2 -2
- 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.
|
|
76
|
-
prompt.log.info(`${answers.
|
|
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,
|
|
50
|
-
const destination = join(target, 'apps',
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
199
|
-
|
|
255
|
+
await applyFeatures(directory, app.type, features);
|
|
256
|
+
directories.push(directory);
|
|
200
257
|
}
|
|
201
|
-
|
|
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 =
|
|
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) =>
|
|
216
|
-
const manifests = await Promise.all(
|
|
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
|
|
223
|
-
await adaptAppConfig(target,
|
|
224
|
-
for (const line of await takeBuildApprovals(
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
148
|
-
await writeLocalEnv(
|
|
150
|
+
for (const app of apps) {
|
|
151
|
+
await writeLocalEnv(at(app), app.type);
|
|
149
152
|
}
|
|
150
|
-
|
|
151
|
-
await nameCompose(
|
|
152
|
-
await settleMobileScheme(
|
|
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
|
-
|
|
155
|
-
await nameMobileApp(
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
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)) {
|
package/dist/next-steps.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
const run = architecture === 'alone' ? 'pnpm' :
|
|
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 (
|
|
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
|
}
|