@stjoseph/prumo 0.5.0 → 0.6.0
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/core/testing.md +14 -4
- 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 +15 -2
- package/dist/add.js +219 -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 +15 -2
- package/dist/next-steps.js +4 -3
- package/dist/pnpm-extended.js +159 -0
- package/dist/questions.js +22 -3
- package/dist/workspace-yaml.js +91 -0
- package/package.json +4 -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/mobile/test/features/auth/authenticated-layout.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/email-flows.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +3 -2
- package/templates/mobile/test/features/auth/social-buttons.spec.tsx +3 -2
- package/templates/mobile/test/features/profile/profile-form.spec.tsx +3 -4
- package/templates/mobile/test/support/query-client.ts +14 -0
- 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/web/test/features/oauth/consent-form.spec.tsx +3 -2
- package/templates/web/test/features/profile/profile-form.spec.tsx +3 -2
- package/templates/web/test/support/query-client.ts +14 -0
- package/templates/web/test/support/render-app.tsx +2 -2
- package/templates/web/vite.config.ts +10 -3
- package/templates/workspace/README.md +2 -2
- package/templates/workspace/scripts/ports.mjs +60 -45
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// pnpm applies a compatibility database, @yarnpkg/extensions, to every manifest it reads: the registry's packages and
|
|
2
|
+
// the project's own package.json alike. An entry matches by name and version range, and the template version 0.0.1
|
|
3
|
+
// falls inside ranges like `ws@<7.2.1`. A project carrying one of these names therefore gains the dependencies the
|
|
4
|
+
// entry adds, installed into its root, and a package among them with a build script stops `pnpm install` with
|
|
5
|
+
// ERR_PNPM_IGNORED_BUILDS. Each name maps to the packages it would add.
|
|
6
|
+
//
|
|
7
|
+
// Derived from @yarnpkg/extensions, pinned in devDependencies, by test/names.spec.ts, which fails when this list and
|
|
8
|
+
// the database part ways. Only names a project can take are kept, and only entries that add a package: one that sets
|
|
9
|
+
// nothing but peerDependenciesMeta changes no install.
|
|
10
|
+
export const EXTENDED_BY_PNPM = {
|
|
11
|
+
'apollo-upload-client': ['graphql'],
|
|
12
|
+
'auto-relay': ['reflect-metadata'],
|
|
13
|
+
'babel-plugin-graphql-tag': ['graphql'],
|
|
14
|
+
'babel-plugin-remove-graphql-queries': ['@babel/runtime', '@babel/types', 'gatsby-core-utils'],
|
|
15
|
+
'babel-plugin-transform-typescript-metadata': ['@babel/core', '@babel/traverse'],
|
|
16
|
+
'babel-preset-gatsby-package': ['@babel/runtime'],
|
|
17
|
+
'bin-links': ['mkdirp-infer-owner'],
|
|
18
|
+
'cacheable-lookup': ['@types/keyv'],
|
|
19
|
+
'clipanion-v3-codemod': ['jscodeshift'],
|
|
20
|
+
'connect-mongo': ['express-session'],
|
|
21
|
+
consolidate: [
|
|
22
|
+
'arc-templates',
|
|
23
|
+
'atpl',
|
|
24
|
+
'babel-core',
|
|
25
|
+
'bracket-template',
|
|
26
|
+
'coffee-script',
|
|
27
|
+
'dot',
|
|
28
|
+
'dust',
|
|
29
|
+
'dustjs-helpers',
|
|
30
|
+
'dustjs-linkedin',
|
|
31
|
+
'eco',
|
|
32
|
+
'ect',
|
|
33
|
+
'ejs',
|
|
34
|
+
'haml-coffee',
|
|
35
|
+
'hamlet',
|
|
36
|
+
'hamljs',
|
|
37
|
+
'handlebars',
|
|
38
|
+
'hogan.js',
|
|
39
|
+
'htmling',
|
|
40
|
+
'jade',
|
|
41
|
+
'jazz',
|
|
42
|
+
'jqtpl',
|
|
43
|
+
'just',
|
|
44
|
+
'liquid-node',
|
|
45
|
+
'liquor',
|
|
46
|
+
'lodash',
|
|
47
|
+
'marko',
|
|
48
|
+
'mote',
|
|
49
|
+
'mustache',
|
|
50
|
+
'nunjucks',
|
|
51
|
+
'plates',
|
|
52
|
+
'pug',
|
|
53
|
+
'qejs',
|
|
54
|
+
'ractive',
|
|
55
|
+
'razor-tmpl',
|
|
56
|
+
'react',
|
|
57
|
+
'react-dom',
|
|
58
|
+
'slm',
|
|
59
|
+
'squirrelly',
|
|
60
|
+
'swig',
|
|
61
|
+
'swig-templates',
|
|
62
|
+
'teacup',
|
|
63
|
+
'templayed',
|
|
64
|
+
'then-jade',
|
|
65
|
+
'then-pug',
|
|
66
|
+
'tinyliquid',
|
|
67
|
+
'toffee',
|
|
68
|
+
'twig',
|
|
69
|
+
'twing',
|
|
70
|
+
'underscore',
|
|
71
|
+
'vash',
|
|
72
|
+
'velocityjs',
|
|
73
|
+
'walrus',
|
|
74
|
+
'whiskers',
|
|
75
|
+
],
|
|
76
|
+
'cordova-ios': ['underscore'],
|
|
77
|
+
'cordova-lib': ['underscore'],
|
|
78
|
+
'create-gatsby': ['@babel/runtime'],
|
|
79
|
+
'elm-webpack-loader': ['temp'],
|
|
80
|
+
'eslint-import-resolver-vite': ['debug', 'resolve'],
|
|
81
|
+
fdir: ['picomatch'],
|
|
82
|
+
'fork-ts-checker-webpack-plugin': ['eslint', 'typescript', 'vue-template-compiler', 'webpack'],
|
|
83
|
+
'gatsby-admin': ['@babel/runtime'],
|
|
84
|
+
'gatsby-cli': ['@babel/runtime'],
|
|
85
|
+
'gatsby-core-utils': ['@babel/runtime', 'got'],
|
|
86
|
+
'gatsby-design-tokens': ['@babel/runtime'],
|
|
87
|
+
'gatsby-legacy-polyfills': ['@babel/runtime'],
|
|
88
|
+
'gatsby-plugin-benchmark-reporting': ['@babel/runtime'],
|
|
89
|
+
'gatsby-plugin-favicon': ['webpack'],
|
|
90
|
+
'gatsby-plugin-gatsby-cloud': ['gatsby-core-utils', 'webpack'],
|
|
91
|
+
'gatsby-plugin-graphql-config': ['@babel/runtime'],
|
|
92
|
+
'gatsby-plugin-i18n': ['ramda'],
|
|
93
|
+
'gatsby-plugin-image': ['@babel/runtime'],
|
|
94
|
+
'gatsby-plugin-mdx': ['@babel/runtime', 'mkdirp'],
|
|
95
|
+
'gatsby-plugin-netlify-cms': ['@babel/runtime'],
|
|
96
|
+
'gatsby-plugin-no-sourcemaps': ['@babel/runtime'],
|
|
97
|
+
'gatsby-plugin-page-creator': ['@babel/runtime', 'fs-extra'],
|
|
98
|
+
'gatsby-plugin-preact': ['@babel/runtime'],
|
|
99
|
+
'gatsby-plugin-preload-fonts': ['@babel/runtime'],
|
|
100
|
+
'gatsby-plugin-schema-snapshot': ['@babel/runtime'],
|
|
101
|
+
'gatsby-plugin-sharp': ['debug'],
|
|
102
|
+
'gatsby-plugin-styletron': ['@babel/runtime'],
|
|
103
|
+
'gatsby-plugin-subfont': ['@babel/runtime'],
|
|
104
|
+
'gatsby-plugin-utils': ['@babel/runtime', 'fastq', 'graphql'],
|
|
105
|
+
'gatsby-react-router-scroll': ['prop-types'],
|
|
106
|
+
'gatsby-recipes': ['@babel/runtime'],
|
|
107
|
+
'gatsby-remark-prismjs': ['lodash'],
|
|
108
|
+
'gatsby-source-apiserver': ['babel-polyfill'],
|
|
109
|
+
'gatsby-source-shopify': ['@babel/runtime'],
|
|
110
|
+
'gatsby-source-wikipedia': ['@babel/runtime'],
|
|
111
|
+
'gatsby-transformer-screenshot': ['@babel/runtime'],
|
|
112
|
+
'gatsby-worker': ['@babel/runtime'],
|
|
113
|
+
'git-node-fs': ['js-git'],
|
|
114
|
+
got: ['@types/keyv', '@types/responselike'],
|
|
115
|
+
'http-link-dataloader': ['graphql'],
|
|
116
|
+
'ink-select-input': ['react'],
|
|
117
|
+
'jest-vue-preprocessor': ['@babel/core', '@babel/template', 'pug'],
|
|
118
|
+
'jss-plugin-rule-value-function': ['tiny-warning'],
|
|
119
|
+
mqtt: ['duplexify'],
|
|
120
|
+
'query-ast': ['lodash'],
|
|
121
|
+
'rc-animate': ['react', 'react-dom'],
|
|
122
|
+
'react-bootstrap-table2-paginator': ['classnames'],
|
|
123
|
+
'react-color': ['react'],
|
|
124
|
+
'react-dev-utils': ['typescript', 'webpack'],
|
|
125
|
+
'react-draggable': ['react', 'react-dom'],
|
|
126
|
+
'react-github-btn': ['react'],
|
|
127
|
+
'react-instantsearch-core': ['algoliasearch'],
|
|
128
|
+
'react-instantsearch-dom': ['react-fast-compare'],
|
|
129
|
+
'react-live': ['react', 'react-dom'],
|
|
130
|
+
'react-portal': ['react-dom'],
|
|
131
|
+
'react-rnd': ['react', 'react-dom'],
|
|
132
|
+
'react-scripts': ['eslint', 'react'],
|
|
133
|
+
reactcss: ['react'],
|
|
134
|
+
rebass: ['react'],
|
|
135
|
+
'redux-persist': ['react'],
|
|
136
|
+
'redux-thunk': ['redux'],
|
|
137
|
+
'rollup-plugin-polyfill-node': ['rollup'],
|
|
138
|
+
'scss-parser': ['lodash'],
|
|
139
|
+
skypack: ['tar'],
|
|
140
|
+
snowpack: ['magic-string'],
|
|
141
|
+
terser: ['acorn'],
|
|
142
|
+
testcafe: ['@babel/plugin-transform-for-of', '@babel/runtime'],
|
|
143
|
+
'testcafe-legacy-api': ['read-file-relative', 'testcafe-hammerhead'],
|
|
144
|
+
'typescript-language-server': ['vscode-jsonrpc', 'vscode-languageserver-protocol'],
|
|
145
|
+
unified: ['@types/unist'],
|
|
146
|
+
'unplugin-vue2-script-setup': ['@vue/composition-api', '@vue/runtime-dom'],
|
|
147
|
+
'vite-plugin-vuetify': ['vue'],
|
|
148
|
+
'volar-service-typescript': ['typescript'],
|
|
149
|
+
'volar-service-typescript-twoslash-queries': ['typescript'],
|
|
150
|
+
'vue-cli-plugin-vuetify': ['null-loader', 'semver'],
|
|
151
|
+
'vue-i18n': ['vue'],
|
|
152
|
+
'vue-loader': ['@vue/compiler-sfc', 'webpack'],
|
|
153
|
+
'vue-router': ['vue'],
|
|
154
|
+
'vue-template-babel-compiler': ['vue-template-compiler'],
|
|
155
|
+
'webpack-plugin-vuetify': ['vue'],
|
|
156
|
+
'winston-transport': ['logform'],
|
|
157
|
+
ws: ['bufferutil', 'utf-8-validate'],
|
|
158
|
+
xo: ['webpack'],
|
|
159
|
+
};
|
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
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// pnpm-workspace.yaml as Prumo writes it: `catalog:`, `catalogs:` with a block per named catalog, and `allowBuilds:`,
|
|
2
|
+
// each a top-level key with its entries indented below it. An app that joins only adds to them: an entry already
|
|
3
|
+
// there is the workspace's, and its version wins, since a team that moved it did so on purpose.
|
|
4
|
+
const entryName = (line) => /^\s*'?([^'#\s][^':]*)'?\s*:/.exec(line)?.[1];
|
|
5
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
6
|
+
// The lines of a block's entries, from the line after its key to the first line indented no deeper than it.
|
|
7
|
+
function block(lines, key, indent, from = 0, to = lines.length) {
|
|
8
|
+
const start = lines.findIndex((line, index) => index >= from && index < to && line === `${' '.repeat(indent)}${key}:`);
|
|
9
|
+
if (start === -1) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
let end = start + 1;
|
|
13
|
+
while (end < to && (lines[end]?.trim() === '' || indentOf(lines[end] ?? '') > indent)) {
|
|
14
|
+
end += 1;
|
|
15
|
+
}
|
|
16
|
+
// Blank lines before the next key belong to the gap, not to the block.
|
|
17
|
+
while (end > start + 1 && lines[end - 1]?.trim() === '') {
|
|
18
|
+
end -= 1;
|
|
19
|
+
}
|
|
20
|
+
return { start, end };
|
|
21
|
+
}
|
|
22
|
+
function addEntries(lines, where, entries, indent) {
|
|
23
|
+
const present = new Set(lines
|
|
24
|
+
.slice(where.start + 1, where.end)
|
|
25
|
+
.map(entryName)
|
|
26
|
+
.filter((name) => name !== undefined));
|
|
27
|
+
const missing = Object.keys(entries)
|
|
28
|
+
.filter((name) => !present.has(name))
|
|
29
|
+
.sort()
|
|
30
|
+
.map((name) => `${' '.repeat(indent)}'${name}': ${entries[name]}`);
|
|
31
|
+
lines.splice(where.end, 0, ...missing);
|
|
32
|
+
}
|
|
33
|
+
export function addToCatalogs(text, catalogs) {
|
|
34
|
+
const lines = text.replace(/\n$/, '').split('\n');
|
|
35
|
+
const catalog = block(lines, 'catalog', 0);
|
|
36
|
+
if (catalog === undefined) {
|
|
37
|
+
lines.push('', 'catalog:');
|
|
38
|
+
addEntries(lines, { start: lines.length - 1, end: lines.length }, catalogs.default, 2);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
addEntries(lines, catalog, catalogs.default, 2);
|
|
42
|
+
}
|
|
43
|
+
for (const [name, entries] of Object.entries(catalogs.named)) {
|
|
44
|
+
let named = block(lines, 'catalogs', 0);
|
|
45
|
+
if (named === undefined) {
|
|
46
|
+
lines.push('', 'catalogs:');
|
|
47
|
+
named = { start: lines.length - 1, end: lines.length };
|
|
48
|
+
}
|
|
49
|
+
const own = block(lines, name, 2, named.start + 1, named.end);
|
|
50
|
+
if (own === undefined) {
|
|
51
|
+
lines.splice(named.end, 0, ` ${name}:`);
|
|
52
|
+
addEntries(lines, { start: named.end, end: named.end + 1 }, entries, 4);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
addEntries(lines, own, entries, 4);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return `${lines.join('\n')}\n`;
|
|
59
|
+
}
|
|
60
|
+
// Each approval arrives with the comment above it that says why, as the template wrote them.
|
|
61
|
+
export function addApprovals(text, approvals) {
|
|
62
|
+
const lines = text.replace(/\n$/, '').split('\n');
|
|
63
|
+
let builds = block(lines, 'allowBuilds', 0);
|
|
64
|
+
if (builds === undefined) {
|
|
65
|
+
if (approvals.length === 0) {
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
lines.push('', 'allowBuilds:');
|
|
69
|
+
builds = { start: lines.length - 1, end: lines.length };
|
|
70
|
+
}
|
|
71
|
+
const present = new Set(lines
|
|
72
|
+
.slice(builds.start + 1, builds.end)
|
|
73
|
+
.map(entryName)
|
|
74
|
+
.filter((name) => name !== undefined));
|
|
75
|
+
const added = [];
|
|
76
|
+
let comments = [];
|
|
77
|
+
for (const line of approvals) {
|
|
78
|
+
if (line.trimStart().startsWith('#')) {
|
|
79
|
+
comments.push(line);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const name = entryName(line);
|
|
83
|
+
if (name !== undefined && !present.has(name)) {
|
|
84
|
+
added.push(...comments, line);
|
|
85
|
+
present.add(name);
|
|
86
|
+
}
|
|
87
|
+
comments = [];
|
|
88
|
+
}
|
|
89
|
+
lines.splice(builds.end, 0, ...added);
|
|
90
|
+
return `${lines.join('\n')}\n`;
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stjoseph/prumo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"scaffolding",
|
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@biomejs/biome": "2.5.13",
|
|
50
50
|
"@types/node": "24.13.4",
|
|
51
|
+
"@types/semver": "7.8.0",
|
|
52
|
+
"@yarnpkg/extensions": "2.0.7",
|
|
53
|
+
"semver": "7.8.5",
|
|
51
54
|
"typescript": "6.0.3",
|
|
52
55
|
"vitest": "4.1.11"
|
|
53
56
|
},
|
|
@@ -10,6 +10,7 @@ POSTGRES_PORT=5432
|
|
|
10
10
|
# Signs the access tokens. `prumo new` writes a random one into .env; never reuse it between environments.
|
|
11
11
|
JWT_SECRET=replace-this-with-at-least-32-characters
|
|
12
12
|
API_URL=http://localhost:3000
|
|
13
|
+
# Every web app the API answers, comma-separated. The first is where MCP consent opens.
|
|
13
14
|
WEB_ORIGIN=http://localhost:5173
|
|
14
15
|
# prumo:social
|
|
15
16
|
# The mobile app's scheme, as in app.json: where it is sent back after signing in with a provider.
|
package/templates/api/README.md
CHANGED
|
@@ -51,7 +51,8 @@ one comes from.
|
|
|
51
51
|
<!-- prumo:mcp -->
|
|
52
52
|
|
|
53
53
|
The MCP server answers at `/api/mcp`. This API is also the OAuth authorization server that issues its tokens: an MCP
|
|
54
|
-
client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the web
|
|
54
|
+
client finds it through `/.well-known/oauth-protected-resource`, and the user signs in and consents on the first web
|
|
55
|
+
app in `WEB_ORIGIN`.
|
|
55
56
|
<!-- prumo:end-mcp -->
|
|
56
57
|
|
|
57
58
|
## Everyday commands
|
|
@@ -14,22 +14,23 @@ import { basename, dirname, join, resolve } from 'node:path'
|
|
|
14
14
|
import { createInterface } from 'node:readline/promises'
|
|
15
15
|
import { parseArgs } from 'node:util'
|
|
16
16
|
|
|
17
|
-
// Where
|
|
18
|
-
const
|
|
17
|
+
// Where an app of each type keeps its port, what it falls back to, and how its dev command is told about it.
|
|
18
|
+
const TYPES = {
|
|
19
19
|
api: { key: 'PORT', fallback: 3000, flag: [] },
|
|
20
20
|
web: { key: 'WEB_PORT', fallback: 5173, flag: ['--port', '--strictPort'] },
|
|
21
21
|
site: { key: 'SITE_PORT', fallback: 3200, flag: ['--port'] },
|
|
22
22
|
mobile: { key: 'METRO_PORT', fallback: 8081, flag: ['--port'] },
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// A port that moves takes these with it: the key to write, in
|
|
25
|
+
// A port that moves takes these with it: the key to write, in every app of which type. A list holds one URL per app,
|
|
26
|
+
// so only the moving app's entry changes.
|
|
26
27
|
const FOLLOWERS = {
|
|
27
28
|
api: [
|
|
28
|
-
{
|
|
29
|
-
{
|
|
30
|
-
{
|
|
29
|
+
{ type: 'api', key: 'API_URL' },
|
|
30
|
+
{ type: 'web', key: 'VITE_API_URL' },
|
|
31
|
+
{ type: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
|
|
31
32
|
],
|
|
32
|
-
web: [{
|
|
33
|
+
web: [{ type: 'api', key: 'WEB_ORIGIN', list: true }],
|
|
33
34
|
site: [],
|
|
34
35
|
mobile: [],
|
|
35
36
|
}
|
|
@@ -117,36 +118,43 @@ function projectRoot() {
|
|
|
117
118
|
const root = projectRoot()
|
|
118
119
|
const workspace = existsSync(join(root, 'apps'))
|
|
119
120
|
|
|
120
|
-
function
|
|
121
|
-
|
|
121
|
+
function readConfig() {
|
|
122
|
+
const path = join(root, '.prumo', 'config.json')
|
|
123
|
+
|
|
124
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// Every app of the project, as { name, type, directory }. A workspace lists its apps in .prumo/config.json; one written
|
|
128
|
+
// before apps had names holds one per type, named after it. An alone project is its one app, named after its type.
|
|
129
|
+
function projectApps() {
|
|
130
|
+
const config = readConfig()
|
|
131
|
+
|
|
132
|
+
if (!workspace) {
|
|
133
|
+
// A project is named by its author, so only .prumo/config.json says what it is.
|
|
134
|
+
const type = TYPES[basename(root)] !== undefined ? basename(root) : config.types?.[0]
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
return name
|
|
136
|
+
return type === undefined ? [] : [{ name: type, type, directory: root }]
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
const config = join(root, '.prumo', 'config.json')
|
|
133
|
-
const types = existsSync(config) ? JSON.parse(readFileSync(config, 'utf8')).types : []
|
|
139
|
+
const listed = config.apps ?? (config.types ?? []).map((type) => ({ name: type, type }))
|
|
134
140
|
|
|
135
|
-
return
|
|
141
|
+
return listed
|
|
142
|
+
.map((app) => ({ ...app, directory: join(root, 'apps', app.name) }))
|
|
143
|
+
.filter(
|
|
144
|
+
(app) => TYPES[app.type] !== undefined && existsSync(join(app.directory, 'package.json')),
|
|
145
|
+
)
|
|
136
146
|
}
|
|
137
147
|
|
|
138
|
-
|
|
139
|
-
if (!values.all) {
|
|
140
|
-
return [typeOf(here)]
|
|
141
|
-
}
|
|
148
|
+
const apps = projectApps()
|
|
142
149
|
|
|
143
|
-
|
|
150
|
+
function appsToCheck() {
|
|
151
|
+
return values.all ? apps : apps.filter((app) => app.directory === here)
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
function portOf(
|
|
147
|
-
const
|
|
154
|
+
function portOf(app) {
|
|
155
|
+
const { key, fallback } = TYPES[app.type]
|
|
148
156
|
|
|
149
|
-
return Number(envValue(readEnv(
|
|
157
|
+
return Number(envValue(readEnv(app.directory), key) ?? fallback)
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
function listening(host, port) {
|
|
@@ -269,22 +277,31 @@ async function nextFree(from) {
|
|
|
269
277
|
throw new Failure('port_busy', `No free port between ${from + 1} and ${from + PORTS_TRIED - 1}.`)
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
function movePort(
|
|
273
|
-
const
|
|
280
|
+
function movePort(app, from, port) {
|
|
281
|
+
const before = `http://localhost:${from}`
|
|
282
|
+
const after = `http://localhost:${port}`
|
|
274
283
|
|
|
275
|
-
writeEnvValue(
|
|
284
|
+
writeEnvValue(app.directory, TYPES[app.type].key, port)
|
|
276
285
|
|
|
277
|
-
for (const follower of FOLLOWERS[type]) {
|
|
278
|
-
const
|
|
286
|
+
for (const follower of FOLLOWERS[app.type]) {
|
|
287
|
+
for (const other of apps.filter((each) => each.type === follower.type)) {
|
|
288
|
+
const current = envValue(readEnv(other.directory), follower.key)
|
|
289
|
+
const entries = (current ?? '').split(',').map((entry) => entry.trim())
|
|
290
|
+
const value =
|
|
291
|
+
follower.list && current !== undefined
|
|
292
|
+
? (entries.includes(before)
|
|
293
|
+
? entries.map((entry) => (entry === before ? after : entry))
|
|
294
|
+
: [...entries, after]
|
|
295
|
+
).join(',')
|
|
296
|
+
: after
|
|
279
297
|
|
|
280
|
-
|
|
281
|
-
writeEnvValue(directory, follower.key, `http://localhost:${port}`)
|
|
298
|
+
writeEnvValue(other.directory, follower.key, value)
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
}
|
|
285
302
|
|
|
286
|
-
async function settle(
|
|
287
|
-
const port = portOf(
|
|
303
|
+
async function settle(app) {
|
|
304
|
+
const port = portOf(app)
|
|
288
305
|
|
|
289
306
|
if (!(await taken(port))) {
|
|
290
307
|
return port
|
|
@@ -292,12 +309,12 @@ async function settle(type) {
|
|
|
292
309
|
|
|
293
310
|
const found = holders(port)
|
|
294
311
|
|
|
295
|
-
say(`${
|
|
312
|
+
say(`${app.name} wants port ${port}, held by ${describe(found)}.`)
|
|
296
313
|
|
|
297
314
|
const moved = async () => {
|
|
298
315
|
const free = await nextFree(port)
|
|
299
|
-
movePort(
|
|
300
|
-
say(`${
|
|
316
|
+
movePort(app, port, free)
|
|
317
|
+
say(`${app.name} moved to port ${free}, written to .env.`)
|
|
301
318
|
return free
|
|
302
319
|
}
|
|
303
320
|
|
|
@@ -311,7 +328,7 @@ async function settle(type) {
|
|
|
311
328
|
if (!interactive) {
|
|
312
329
|
throw new Failure(
|
|
313
330
|
'port_busy',
|
|
314
|
-
`Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${
|
|
331
|
+
`Port ${port} is in use by ${describe(found)}. Pass --kill to stop it, or --change to move ${app.name} to the next free port.`,
|
|
315
332
|
)
|
|
316
333
|
}
|
|
317
334
|
|
|
@@ -669,10 +686,8 @@ async function main() {
|
|
|
669
686
|
const starting = command === undefined ? undefined : startJob()
|
|
670
687
|
|
|
671
688
|
try {
|
|
672
|
-
for (const
|
|
673
|
-
|
|
674
|
-
ports[type] = await settle(type)
|
|
675
|
-
}
|
|
689
|
+
for (const app of appsToCheck()) {
|
|
690
|
+
ports[app.name] = await settle(app)
|
|
676
691
|
}
|
|
677
692
|
} catch (error) {
|
|
678
693
|
;(await starting)?.close()
|
|
@@ -685,10 +700,10 @@ async function main() {
|
|
|
685
700
|
|
|
686
701
|
// Only an app tells its command which port to use. A workspace root wraps `pnpm`, which would pass a stray
|
|
687
702
|
// `--port` on to every app it runs.
|
|
688
|
-
const [
|
|
689
|
-
const flag =
|
|
703
|
+
const [app] = values.all ? [] : appsToCheck()
|
|
704
|
+
const flag = app === undefined ? [] : TYPES[app.type].flag
|
|
690
705
|
const withPort = flag.flatMap((entry) =>
|
|
691
|
-
entry === '--port' ? ['--port', String(ports[
|
|
706
|
+
entry === '--port' ? ['--port', String(ports[app.name])] : [entry],
|
|
692
707
|
)
|
|
693
708
|
// Before the command starts, so that it and everything it starts are members from their first instant.
|
|
694
709
|
const job = await starting
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
|
+
// Written as a browser sends it in an Origin header: scheme, host and port, with no path or trailing slash.
|
|
5
|
+
const origin = z.url().transform((url) => new URL(url).origin)
|
|
6
|
+
|
|
4
7
|
const envSchema = z.object({
|
|
5
8
|
NODE_ENV: z.enum(['development', 'test', 'production']),
|
|
6
9
|
PORT: z.coerce.number().int().min(1).max(65535),
|
|
@@ -8,7 +11,11 @@ const envSchema = z.object({
|
|
|
8
11
|
JWT_SECRET: z.string().min(32),
|
|
9
12
|
// Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
|
|
10
13
|
API_URL: z.url(),
|
|
11
|
-
|
|
14
|
+
// Every web app this API answers, comma-separated. The first is the one MCP consent opens on.
|
|
15
|
+
WEB_ORIGIN: z
|
|
16
|
+
.string()
|
|
17
|
+
.transform((value) => value.split(',').map((origin) => origin.trim()))
|
|
18
|
+
.pipe(z.tuple([origin], origin)),
|
|
12
19
|
// prumo:social
|
|
13
20
|
// Where a mobile app is sent back after signing in with a provider: its own scheme, as in app.json.
|
|
14
21
|
MOBILE_APP_SCHEME: z
|
|
@@ -129,8 +129,8 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
|
|
|
129
129
|
scopesSupported,
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
-
// Signing in, if needed, and the consent itself happen on the web app.
|
|
133
|
-
return reply.redirect(`${env.WEB_ORIGIN}/consent?request=${authorization.id}`)
|
|
132
|
+
// Signing in, if needed, and the consent itself happen on the first web app.
|
|
133
|
+
return reply.redirect(`${env.WEB_ORIGIN[0]}/consent?request=${authorization.id}`)
|
|
134
134
|
} catch (error) {
|
|
135
135
|
if (error instanceof AuthorizationRequestError) {
|
|
136
136
|
return reply.redirect(
|
|
@@ -51,10 +51,14 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
|
|
|
51
51
|
path: '/api/auth/social',
|
|
52
52
|
} as const
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// A web app is sent back to itself, so only an origin this API trusts is ever a destination.
|
|
55
|
+
const trusted = (origin: string | undefined) =>
|
|
56
|
+
origin !== undefined && env.WEB_ORIGIN.includes(origin) ? origin : env.WEB_ORIGIN[0]
|
|
57
|
+
|
|
58
|
+
const back = (client: SocialClient, origin: string, query: Record<string, string>) =>
|
|
55
59
|
client === 'mobile'
|
|
56
60
|
? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
|
|
57
|
-
: `${
|
|
61
|
+
: `${origin}/sign-in?${new URLSearchParams(query)}`
|
|
58
62
|
|
|
59
63
|
// Apple's answer is a form.
|
|
60
64
|
app.addContentTypeParser(
|
|
@@ -73,11 +77,19 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
|
|
|
73
77
|
querystring: z.object({
|
|
74
78
|
client: z.enum(['web', 'mobile']).default('web'),
|
|
75
79
|
returnTo: z.string().max(512).optional(),
|
|
80
|
+
// The web app that started, when there are several; one the API does not trust is refused.
|
|
81
|
+
origin: z.string().max(512).optional(),
|
|
76
82
|
}),
|
|
77
83
|
},
|
|
78
84
|
},
|
|
79
85
|
async (request, reply) => {
|
|
80
|
-
const { client, returnTo } = request.query
|
|
86
|
+
const { client, returnTo, origin: requested } = request.query
|
|
87
|
+
|
|
88
|
+
if (requested !== undefined && !env.WEB_ORIGIN.includes(requested)) {
|
|
89
|
+
throw new HttpError(422, 'origin is not one of the web apps this API trusts')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const origin = client === 'web' ? trusted(requested) : ''
|
|
81
93
|
|
|
82
94
|
if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
|
|
83
95
|
throw new HttpError(
|
|
@@ -92,8 +104,9 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
|
|
|
92
104
|
returnTo: safePath(returnTo),
|
|
93
105
|
})
|
|
94
106
|
|
|
95
|
-
// The client
|
|
96
|
-
|
|
107
|
+
// The client and its origin ride beside the token, so an error found before the flow is read still goes to the
|
|
108
|
+
// right place.
|
|
109
|
+
reply.setCookie(BROWSER_COOKIE, `${client}~${origin}~${started.browserToken}`, {
|
|
97
110
|
...cookie,
|
|
98
111
|
maxAge: BROWSER_COOKIE_MAX_AGE_S,
|
|
99
112
|
})
|
|
@@ -132,16 +145,17 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
|
|
|
132
145
|
},
|
|
133
146
|
},
|
|
134
147
|
async (request, reply) => {
|
|
135
|
-
const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
const [client, written, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~~').split(
|
|
149
|
+
'~',
|
|
150
|
+
) as [SocialClient, string | undefined, string | undefined]
|
|
151
|
+
// Read back through the list, so a cookie edited by hand cannot name a destination of its own.
|
|
152
|
+
const origin = trusted(written)
|
|
139
153
|
const { state, code, error } = request.query
|
|
140
154
|
|
|
141
155
|
reply.clearCookie(BROWSER_COOKIE, cookie)
|
|
142
156
|
|
|
143
157
|
if (error !== undefined || state === undefined || code === undefined) {
|
|
144
|
-
return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
|
|
158
|
+
return reply.redirect(back(client, origin, { error: 'social_sign_in_failed' }))
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
try {
|
|
@@ -154,15 +168,17 @@ export async function socialController(fastify: FastifyInstance): Promise<void>
|
|
|
154
168
|
})
|
|
155
169
|
|
|
156
170
|
if (outcome.client === 'mobile') {
|
|
157
|
-
return reply.redirect(back('mobile', { code: outcome.code }))
|
|
171
|
+
return reply.redirect(back('mobile', origin, { code: outcome.code }))
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
setCookies(reply, outcome.tokens)
|
|
161
175
|
|
|
162
|
-
return reply.redirect(`${
|
|
176
|
+
return reply.redirect(`${origin}${outcome.returnTo}`)
|
|
163
177
|
} catch (caught) {
|
|
164
178
|
if (caught instanceof DomainError) {
|
|
165
|
-
return reply.redirect(
|
|
179
|
+
return reply.redirect(
|
|
180
|
+
back(client, origin, { error: caught.code ?? 'social_sign_in_failed' }),
|
|
181
|
+
)
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
throw caught
|