create-ortha-app 0.4.3 → 0.5.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/dist/cli.js +6 -299
- package/dist/lib/features.d.ts +21 -1
- package/dist/lib/features.d.ts.map +1 -1
- package/dist/lib/features.js +30 -2
- package/dist/lib/run.d.ts +22 -0
- package/dist/lib/run.d.ts.map +1 -0
- package/dist/lib/run.js +318 -0
- package/package.json +1 -1
- package/templates/default/README.md.tmpl +26 -7
- package/templates/default/apps/admin/src/plugins.spec.ts +4 -1
- package/templates/default/apps/admin/src/plugins.ts +17 -2
- package/templates/default/apps/server/config/copilot-openai.ts +1 -1
- package/templates/default/apps/server/config/identity.ts +38 -5
- package/templates/default/apps/server/config/media-storage.ts +3 -2
- package/templates/default/apps/server/config/sso-github.ts +42 -0
- package/templates/default/apps/server/config/sso-oidc.ts +1 -1
- package/templates/default/apps/server/config/sso-saml.ts +46 -0
- package/templates/default/apps/server/ortha.config.ts +6 -1
- package/templates/default/apps/server/src/plugins.spec.ts +6 -0
- package/templates/default/apps/server/src/plugins.ts +68 -13
- package/templates/default/env.tmpl +77 -0
package/dist/lib/run.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.USAGE = void 0;
|
|
4
|
+
exports.lockedOf = lockedOf;
|
|
5
|
+
exports.resolveAnswers = resolveAnswers;
|
|
6
|
+
exports.main = main;
|
|
7
|
+
const tslib_1 = require("tslib");
|
|
8
|
+
/**
|
|
9
|
+
* The scaffolder itself: read the flags, ask what a terminal can be asked,
|
|
10
|
+
* write the app.
|
|
11
|
+
*
|
|
12
|
+
* Separate from `src/cli.ts`, which is the `bin` and does nothing but call
|
|
13
|
+
* `main()` and turn a thrown error into an exit code. The split is what makes
|
|
14
|
+
* this testable: importing a module that scaffolds on import would scaffold
|
|
15
|
+
* during a test run, so the refusals and the defaults below could only be
|
|
16
|
+
* checked by spawning a process.
|
|
17
|
+
*/
|
|
18
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const node_child_process_1 = require("node:child_process");
|
|
20
|
+
const node_fs_1 = require("node:fs");
|
|
21
|
+
const node_path_1 = require("node:path");
|
|
22
|
+
const promises_1 = require("node:readline/promises");
|
|
23
|
+
const features_1 = require("./features");
|
|
24
|
+
const template_1 = require("./template");
|
|
25
|
+
const ui = tslib_1.__importStar(require("./ui"));
|
|
26
|
+
const validate_1 = require("./validate");
|
|
27
|
+
exports.USAGE = `create-ortha-app — scaffold an Ortha CMS app
|
|
28
|
+
|
|
29
|
+
Usage: npx create-ortha-app <directory> [options]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--yes Accept every default, asking nothing
|
|
33
|
+
--media <id> Storage adapter (default: media-local)
|
|
34
|
+
--copilot <ids> Comma-separated copilot providers, or "none"
|
|
35
|
+
--sso <ids> Comma-separated identity providers (sso-oidc, sso-github,
|
|
36
|
+
sso-saml), or "none"
|
|
37
|
+
--protocols <ids> Comma-separated protocols beyond REST (graphql, mcp), or "none"
|
|
38
|
+
--no-install Skip installing dependencies
|
|
39
|
+
--no-git Skip initialising a git repository
|
|
40
|
+
-h, --help Show this message
|
|
41
|
+
`;
|
|
42
|
+
/** Whether a bare `--flag` is present. */
|
|
43
|
+
function flag(argv, name) {
|
|
44
|
+
return argv.includes(`--${name}`);
|
|
45
|
+
}
|
|
46
|
+
/** Reads `--name=value` or `--name value` from argv. */
|
|
47
|
+
function option(argv, name) {
|
|
48
|
+
const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
|
|
49
|
+
if (inline)
|
|
50
|
+
return inline.slice(`--${name}=`.length);
|
|
51
|
+
const index = argv.indexOf(`--${name}`);
|
|
52
|
+
const next = index === -1 ? undefined : argv[index + 1];
|
|
53
|
+
return next && !next.startsWith('-') ? next : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Splits a comma-separated flag value; `none` means an empty selection. */
|
|
56
|
+
function idList(raw) {
|
|
57
|
+
if (raw === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (raw.trim() === 'none')
|
|
60
|
+
return [];
|
|
61
|
+
return raw
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((id) => id.trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The ids a `--media` / `--copilot` / `--sso` / `--protocols` flag named, checked
|
|
68
|
+
* against the group it belongs to.
|
|
69
|
+
*
|
|
70
|
+
* An id nobody recognises has to stop the run. The flags take *feature ids*
|
|
71
|
+
* (`media-s3`), not the label or the bare adapter name, and `--media s3` is the
|
|
72
|
+
* natural guess — it used to select nothing, and "nothing" is a real answer for
|
|
73
|
+
* three of the four groups, so it scaffolded an app quietly missing whatever was
|
|
74
|
+
* asked for. For storage that app cannot even compile: no adapter means no
|
|
75
|
+
* `storage` setting and no `mediaStorage()` for the config to call. Naming the
|
|
76
|
+
* valid ids here costs a typo nothing and turns a broken app into one line of
|
|
77
|
+
* output.
|
|
78
|
+
*
|
|
79
|
+
* `required` marks a single-choice group that must end up with something —
|
|
80
|
+
* storage, where "no adapter" is not a configuration an app can run.
|
|
81
|
+
*/
|
|
82
|
+
function selected(argv, name, group, required = false) {
|
|
83
|
+
const chosen = idList(option(argv, name)) ?? defaultsOf(group);
|
|
84
|
+
const known = new Set(group.map((feature) => feature.id));
|
|
85
|
+
const unknown = chosen.filter((id) => !known.has(id));
|
|
86
|
+
if (unknown.length > 0) {
|
|
87
|
+
throw new Error(`--${name}: no such option ${unknown.map((id) => `"${id}"`).join(', ')}. ` +
|
|
88
|
+
`Expected ${[...known].join(', ')}${required ? '' : ', or none'}.`);
|
|
89
|
+
}
|
|
90
|
+
if (required && chosen.length === 0) {
|
|
91
|
+
throw new Error(`--${name}: an app needs one of ${[...known].join(', ')} — ` +
|
|
92
|
+
'there is no configuration for "no adapter at all".');
|
|
93
|
+
}
|
|
94
|
+
return chosen;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* This package's own version, which every `@orthacms/*` dependency in the
|
|
98
|
+
* generated app is pinned to.
|
|
99
|
+
*
|
|
100
|
+
* The scaffolder is released in lockstep with the packages it scaffolds, so its
|
|
101
|
+
* version *is* the matching set — which is the whole mechanism keeping a
|
|
102
|
+
* generated app internally consistent. Reading it from the manifest rather than
|
|
103
|
+
* resolving `latest` from the registry also means `npx create-ortha-app@0.3.0`
|
|
104
|
+
* generates a 0.3.0 app, not whatever shipped since.
|
|
105
|
+
*/
|
|
106
|
+
function ownVersion() {
|
|
107
|
+
const manifest = (0, node_path_1.join)(__dirname, '../../package.json');
|
|
108
|
+
return JSON.parse((0, node_fs_1.readFileSync)(manifest, 'utf8'))
|
|
109
|
+
.version;
|
|
110
|
+
}
|
|
111
|
+
/** Runs a command in `cwd`, returning whether it succeeded. */
|
|
112
|
+
function runCommand(command, args, cwd) {
|
|
113
|
+
return ((0, node_child_process_1.spawnSync)(command, args, {
|
|
114
|
+
cwd,
|
|
115
|
+
stdio: 'inherit',
|
|
116
|
+
shell: process.platform === 'win32'
|
|
117
|
+
}).status === 0);
|
|
118
|
+
}
|
|
119
|
+
/** Turns a feature into a picker row. */
|
|
120
|
+
function toChoice(feature) {
|
|
121
|
+
return {
|
|
122
|
+
value: feature.id,
|
|
123
|
+
label: feature.label,
|
|
124
|
+
hint: feature.hint,
|
|
125
|
+
selected: feature.enabledByDefault,
|
|
126
|
+
disabled: !feature.available,
|
|
127
|
+
locked: feature.locked
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The ids a feature group falls back to when nothing is picked.
|
|
132
|
+
*
|
|
133
|
+
* A locked feature is in every answer, including `--protocols none`: REST is
|
|
134
|
+
* not something the flag can switch off.
|
|
135
|
+
*/
|
|
136
|
+
function defaultsOf(features) {
|
|
137
|
+
return features
|
|
138
|
+
.filter((feature) => (feature.enabledByDefault || feature.locked) &&
|
|
139
|
+
feature.available)
|
|
140
|
+
.map((feature) => feature.id);
|
|
141
|
+
}
|
|
142
|
+
/** The ids that are on regardless of what was asked or passed. */
|
|
143
|
+
function lockedOf(features) {
|
|
144
|
+
return features
|
|
145
|
+
.filter((feature) => feature.locked && feature.available)
|
|
146
|
+
.map((feature) => feature.id);
|
|
147
|
+
}
|
|
148
|
+
/** Asks a free-text question, re-asking until it validates. */
|
|
149
|
+
async function askText(label, fallback, validate) {
|
|
150
|
+
const rl = (0, promises_1.createInterface)({
|
|
151
|
+
input: process.stdin,
|
|
152
|
+
output: process.stdout
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const raw = await rl.question(`${ui.cyan('◆')} ${ui.bold(label)} ${ui.dim(`(${fallback})`)} `);
|
|
157
|
+
const answer = raw.trim() || fallback;
|
|
158
|
+
const problem = validate?.(answer);
|
|
159
|
+
if (!problem)
|
|
160
|
+
return answer;
|
|
161
|
+
ui.error(problem);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
rl.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Resolves the answers, asking only when there is a terminal to ask in.
|
|
170
|
+
*
|
|
171
|
+
* Non-interactive is not an error case to warn about — it is CI, a piped
|
|
172
|
+
* install, and `--yes`. A scaffolder that blocks on a prompt nobody can answer
|
|
173
|
+
* hangs a pipeline until it times out, which is a far worse failure than
|
|
174
|
+
* defaulting, so the flags and the defaults cover every question.
|
|
175
|
+
*/
|
|
176
|
+
async function resolveAnswers(argv, defaultName) {
|
|
177
|
+
const asked = !flag(argv, 'yes') && ui.interactive();
|
|
178
|
+
const defaultDatabaseUrl = `postgresql://ortha:ortha@localhost:5432/${defaultName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
179
|
+
// Storage is `required`: it is a single choice, and an app with no adapter
|
|
180
|
+
// has no `storage` setting for `plugins.ts` to hand its factory.
|
|
181
|
+
const media = selected(argv, 'media', features_1.MEDIA_PROVIDERS, true);
|
|
182
|
+
const copilot = selected(argv, 'copilot', features_1.COPILOT_PROVIDERS);
|
|
183
|
+
const sso = selected(argv, 'sso', features_1.SSO_PROVIDERS);
|
|
184
|
+
const protocols = [
|
|
185
|
+
...lockedOf(features_1.PROTOCOLS),
|
|
186
|
+
...selected(argv, 'protocols', features_1.PROTOCOLS)
|
|
187
|
+
];
|
|
188
|
+
if (!asked) {
|
|
189
|
+
return {
|
|
190
|
+
appName: defaultName,
|
|
191
|
+
databaseUrl: defaultDatabaseUrl,
|
|
192
|
+
adminEmail: 'admin@example.com',
|
|
193
|
+
selection: {
|
|
194
|
+
enabled: new Set([...media, ...copilot, ...sso, ...protocols])
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
ui.section('Project');
|
|
199
|
+
const appName = await askText('App name', defaultName, validate_1.validateAppName);
|
|
200
|
+
const databaseUrl = await askText('Database URL', defaultDatabaseUrl, validate_1.validateDatabaseUrl);
|
|
201
|
+
const adminEmail = await askText('Admin email', 'admin@example.com');
|
|
202
|
+
ui.section('Features');
|
|
203
|
+
ui.note('Everything else is installed for you. These are the choices.');
|
|
204
|
+
console.log('');
|
|
205
|
+
// Only ask when there is more than one answer available. S3 is not
|
|
206
|
+
// published, so today this is a question with a single possible reply, and
|
|
207
|
+
// asking it would be noise pretending to be a choice.
|
|
208
|
+
const selectable = features_1.MEDIA_PROVIDERS.filter((provider) => provider.available);
|
|
209
|
+
const chosenMedia = selectable.length > 1
|
|
210
|
+
? [
|
|
211
|
+
(await ui.select('Where should uploads be stored?', features_1.MEDIA_PROVIDERS.map(toChoice))) ?? media[0]
|
|
212
|
+
]
|
|
213
|
+
: media;
|
|
214
|
+
const chosenCopilot = (await ui.multiselect('AI copilot — which model backends?', features_1.COPILOT_PROVIDERS.map(toChoice))) ?? copilot;
|
|
215
|
+
const chosenSso = (await ui.multiselect('Single sign-on — which identity providers?', features_1.SSO_PROVIDERS.map(toChoice))) ?? sso;
|
|
216
|
+
const chosenProtocols = (await ui.multiselect('Which protocols should the content API speak?', features_1.PROTOCOLS.map(toChoice))) ?? protocols;
|
|
217
|
+
return {
|
|
218
|
+
appName,
|
|
219
|
+
databaseUrl,
|
|
220
|
+
adminEmail,
|
|
221
|
+
selection: {
|
|
222
|
+
enabled: new Set([
|
|
223
|
+
...chosenMedia.filter(Boolean),
|
|
224
|
+
...chosenCopilot,
|
|
225
|
+
...chosenSso,
|
|
226
|
+
...chosenProtocols
|
|
227
|
+
])
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Human-readable summary of what was chosen. */
|
|
232
|
+
function describeSelection(selection) {
|
|
233
|
+
const labelsFor = (features) => {
|
|
234
|
+
const chosen = features
|
|
235
|
+
.filter((feature) => selection.enabled.has(feature.id))
|
|
236
|
+
.map((feature) => feature.label);
|
|
237
|
+
return chosen.length > 0 ? chosen.join(', ') : ui.dim('none');
|
|
238
|
+
};
|
|
239
|
+
const providers = features_1.COPILOT_PROVIDERS.filter((provider) => selection.enabled.has(provider.id));
|
|
240
|
+
return [
|
|
241
|
+
['Storage', labelsFor(features_1.MEDIA_PROVIDERS)],
|
|
242
|
+
['Protocols', labelsFor(features_1.PROTOCOLS)],
|
|
243
|
+
[
|
|
244
|
+
'Copilot',
|
|
245
|
+
providers.length > 0
|
|
246
|
+
? providers.map((p) => p.label).join(', ')
|
|
247
|
+
: ui.dim('no backend')
|
|
248
|
+
],
|
|
249
|
+
['Sign-in', labelsFor(features_1.SSO_PROVIDERS)],
|
|
250
|
+
['Ortha packages', String((0, features_1.resolvePackages)(selection).length + 1)]
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
async function main(argv = process.argv.slice(2)) {
|
|
254
|
+
if (flag(argv, 'help') || argv.includes('-h')) {
|
|
255
|
+
console.log(exports.USAGE);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const positional = argv.filter((arg) => !arg.startsWith('-'));
|
|
259
|
+
const target = (0, node_path_1.resolve)(positional[0] ?? '.');
|
|
260
|
+
ui.banner('Ortha CMS', `Creating an app in ${target}`);
|
|
261
|
+
if ((0, template_1.isNonEmptyDirectory)(target)) {
|
|
262
|
+
throw new Error(`${target} already exists and is not empty. Pick a new directory, ` +
|
|
263
|
+
`or empty that one first.`);
|
|
264
|
+
}
|
|
265
|
+
const answers = await resolveAnswers(argv, (0, node_path_1.basename)(target));
|
|
266
|
+
// Generated rather than prompted: a password typed at a prompt is echoed to
|
|
267
|
+
// the terminal and lands in shell history. This one is written only to the
|
|
268
|
+
// git-ignored .env, and printed once below.
|
|
269
|
+
const adminPassword = (0, node_crypto_1.randomBytes)(12).toString('base64url');
|
|
270
|
+
ui.summary('Your app', [
|
|
271
|
+
['Name', answers.appName],
|
|
272
|
+
['Database', (0, validate_1.databaseNameFrom)(answers.databaseUrl)],
|
|
273
|
+
...describeSelection(answers.selection)
|
|
274
|
+
]);
|
|
275
|
+
(0, node_fs_1.mkdirSync)(target, { recursive: true });
|
|
276
|
+
(0, template_1.renderTemplate)((0, node_path_1.join)(__dirname, '../../templates/default'), target, {
|
|
277
|
+
appName: answers.appName,
|
|
278
|
+
appTitle: answers.appName,
|
|
279
|
+
databaseUrl: answers.databaseUrl,
|
|
280
|
+
databaseName: (0, validate_1.databaseNameFrom)(answers.databaseUrl),
|
|
281
|
+
adminEmail: answers.adminEmail,
|
|
282
|
+
adminPassword,
|
|
283
|
+
orthaVersion: ownVersion(),
|
|
284
|
+
selection: answers.selection
|
|
285
|
+
});
|
|
286
|
+
console.log('');
|
|
287
|
+
ui.success('Files written');
|
|
288
|
+
if (!flag(argv, 'no-git') && !(0, node_fs_1.existsSync)((0, node_path_1.join)(target, '.git'))) {
|
|
289
|
+
if (runCommand('git', ['init', '--quiet'], target)) {
|
|
290
|
+
ui.success('Git repository initialised');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let installed = true;
|
|
294
|
+
if (!flag(argv, 'no-install')) {
|
|
295
|
+
console.log('');
|
|
296
|
+
ui.note('Installing dependencies…');
|
|
297
|
+
installed = runCommand('npm', ['install', '--no-audit', '--no-fund'], target);
|
|
298
|
+
if (installed)
|
|
299
|
+
ui.success('Dependencies installed');
|
|
300
|
+
}
|
|
301
|
+
if (!installed) {
|
|
302
|
+
ui.error('Dependency installation failed — fix the error above, then run ' +
|
|
303
|
+
'`npm install` in the new directory.');
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
}
|
|
306
|
+
ui.summary('Your admin account', [
|
|
307
|
+
['Email', answers.adminEmail],
|
|
308
|
+
['Password', ui.bold(adminPassword)],
|
|
309
|
+
['', ui.dim('Also written to .env')]
|
|
310
|
+
]);
|
|
311
|
+
ui.nextSteps([
|
|
312
|
+
`cd ${(0, node_path_1.basename)(target)}`,
|
|
313
|
+
...(flag(argv, 'no-install') ? ['npm install'] : []),
|
|
314
|
+
'docker compose up -d',
|
|
315
|
+
'npm run migrate',
|
|
316
|
+
`npm run dev ${ui.dim('→ http://localhost:4200')}`
|
|
317
|
+
]);
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ provisioned on boot, idempotently — clear those two variables once you have it
|
|
|
22
22
|
| `npm run build` | Compiles the server and builds the admin bundle |
|
|
23
23
|
| `npm start` | Runs the built server — API **and** admin, one process, one origin |
|
|
24
24
|
| `npm run migrate` | Applies every plugin's pending migrations |
|
|
25
|
-
| `npm run generate -- --name=<name>` | Generates a migration for your own content tables |
|
|
25
|
+
| `npm run generate -- --name=<name>` | Generates a migration for your own content tables — needs a `drizzle.config.ts` first, see below |
|
|
26
26
|
| `npm run studio` | Opens Drizzle Studio on this app's database |
|
|
27
27
|
| `npm test` | Unit tests — both halves |
|
|
28
28
|
| `npm run e2e` | Both end-to-end suites |
|
|
@@ -109,7 +109,7 @@ source lands in the same place here.
|
|
|
109
109
|
| --- | --- |
|
|
110
110
|
| `apps/server/src/plugins.ts` | Which plugins the API runs, and **in what order** |
|
|
111
111
|
| `apps/admin/src/plugins.ts` | Which plugins the admin UI runs |
|
|
112
|
-
| `apps/server/ortha.config.ts` | Every setting, typed — the only reader of
|
|
112
|
+
| `apps/server/ortha.config.ts` | Every setting, typed — with `apps/server/config/`, the only reader of the environment |
|
|
113
113
|
| `apps/admin/src/styles.css` | The theme tokens: this app's whole visual identity |
|
|
114
114
|
|
|
115
115
|
Upgrading is a version bump. Keep the `@orthacms/*` versions **in step with each
|
|
@@ -121,9 +121,9 @@ and UI that silently stops talking to itself.
|
|
|
121
121
|
|
|
122
122
|
A server plugin is a `ServerPlugin` from `@orthacms/bootstrap-server` — a name,
|
|
123
123
|
a NestJS module, and optionally its own migrations — added to the array in
|
|
124
|
-
`
|
|
124
|
+
`apps/server/src/plugins.ts`. `@InjectDatabase()` from `@orthacms/database` gives it
|
|
125
125
|
the shared connection. An admin plugin is an `AdminPlugin` from
|
|
126
|
-
`@orthacms/bootstrap-admin`, added to `
|
|
126
|
+
`@orthacms/bootstrap-admin`, added to `apps/admin/src/plugins.ts`.
|
|
127
127
|
|
|
128
128
|
`@orthacms/utils-server` and `@orthacms/utils-admin` hold the shared toolkit
|
|
129
129
|
(filter parsing, `apiClient`, slots, table URL state), and
|
|
@@ -154,7 +154,7 @@ It is not the switch that keeps your content in-house, though. A backend is
|
|
|
154
154
|
registered only when its settings are present (below), so an app with no key
|
|
155
155
|
reaches no third party either way.
|
|
156
156
|
|
|
157
|
-
Backends are registered in `
|
|
157
|
+
Backends are registered in `apps/server/src/plugins.ts`, and **the order is the
|
|
158
158
|
setting** — there is no `defaultProvider`. The first registered backend serves a
|
|
159
159
|
run that names none, and it is what the admin's model picker opens on. A backend
|
|
160
160
|
is only registered when its connection settings are present, so an unconfigured
|
|
@@ -189,8 +189,27 @@ This app starts with none. To add some:
|
|
|
189
189
|
1. Write them under `apps/server/src/content/` and export a `contentTypes` array.
|
|
190
190
|
2. Pass them to `ContentPlugin({ types: contentTypes, migrations: … })` in
|
|
191
191
|
`apps/server/src/plugins.ts` — the comment there has the exact shape.
|
|
192
|
-
3. Add `apps/server/drizzle.config.ts
|
|
193
|
-
|
|
192
|
+
3. Add `apps/server/drizzle.config.ts`:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { defineConfig } from 'drizzle-kit';
|
|
196
|
+
|
|
197
|
+
export default defineConfig({
|
|
198
|
+
dialect: 'postgresql',
|
|
199
|
+
schema: 'apps/server/src/content/index.ts',
|
|
200
|
+
out: 'migrations'
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Both paths are relative to **this app's root**, not to the config file.
|
|
205
|
+
drizzle-kit resolves `schema` and `out` from the working directory, and
|
|
206
|
+
`ortha generate` runs it from the root — so `out: 'migrations'` is the
|
|
207
|
+
same `migrations/` the plugin's descriptor in step 2 points at, and a
|
|
208
|
+
`../../` in front of it would write two levels above your project.
|
|
209
|
+
|
|
210
|
+
No `dbCredentials`: generating a migration only diffs the schema against
|
|
211
|
+
the last snapshot and never connects, so there is no secret to put here.
|
|
212
|
+
|
|
194
213
|
4. `npm run generate -- --name=add_content_types && npm run migrate`
|
|
195
214
|
|
|
196
215
|
## Deploying
|
|
@@ -31,11 +31,14 @@ const EXPECTED_PLUGINS = [
|
|
|
31
31
|
'i18n',
|
|
32
32
|
'wysiwyg',
|
|
33
33
|
'media',
|
|
34
|
+
'transfer',
|
|
34
35
|
'alarms',
|
|
35
36
|
'copilot',
|
|
37
|
+
'segments',
|
|
36
38
|
'users',
|
|
37
39
|
'activity',
|
|
38
|
-
'api-tokens'
|
|
40
|
+
'api-tokens',
|
|
41
|
+
'webhooks'
|
|
39
42
|
];
|
|
40
43
|
|
|
41
44
|
describe('buildPlugins()', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
|
|
2
2
|
import { ActivityPlugin } from '@orthacms/activity-admin';
|
|
3
3
|
import { ApiTokensPlugin } from '@orthacms/api-tokens-admin';
|
|
4
|
+
import { WebhooksPlugin } from '@orthacms/webhooks-admin';
|
|
4
5
|
import { ContentPlugin } from '@orthacms/content-admin';
|
|
5
6
|
import { I18nPlugin } from '@orthacms/i18n-admin';
|
|
6
7
|
import { IdentityPlugin } from '@orthacms/identity-admin';
|
|
@@ -12,9 +13,12 @@ import { WorkspacesPlugin } from '@orthacms/workspaces-admin';
|
|
|
12
13
|
import { WysiwygPlugin } from '@orthacms/wysiwyg-admin';
|
|
13
14
|
import { CopilotPlugin } from '@orthacms/copilot-admin';
|
|
14
15
|
import { AlarmsPlugin } from '@orthacms/alarms-admin';
|
|
16
|
+
import { transferAdminPlugin } from '@orthacms/transfer-admin';
|
|
17
|
+
import { SegmentsPlugin } from '@orthacms/segments-admin';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
|
-
* The admin's composition, mirroring `
|
|
20
|
+
* The admin's composition, mirroring `apps/server/src/plugins.ts` on the UI
|
|
21
|
+
* side.
|
|
18
22
|
*
|
|
19
23
|
* **Two positions matter; the rest is legibility.**
|
|
20
24
|
*
|
|
@@ -47,6 +51,10 @@ export function buildPlugins(): AdminPlugin[] {
|
|
|
47
51
|
I18nPlugin(),
|
|
48
52
|
WysiwygPlugin(),
|
|
49
53
|
MediaPlugin(),
|
|
54
|
+
// Export/import. Another Content Library slot filler — the entry menu,
|
|
55
|
+
// the records selection bar and the collection toolbar — so it reads
|
|
56
|
+
// after ContentPlugin() for the same reason I18nPlugin() does.
|
|
57
|
+
transferAdminPlugin(),
|
|
50
58
|
// Another Content Library slot filler — the entry rail's checks block,
|
|
51
59
|
// an optional records column, and "Save as rule" in the toolbar.
|
|
52
60
|
AlarmsPlugin(),
|
|
@@ -54,8 +62,15 @@ export function buildPlugins(): AdminPlugin[] {
|
|
|
54
62
|
// the workspace-interior features: the panel mounts into the workspace
|
|
55
63
|
// shell's sidebar footer.
|
|
56
64
|
CopilotPlugin(),
|
|
65
|
+
// Reader entitlements — who may *read* published content. It fills the
|
|
66
|
+
// Content Library's entry-header and entry-tab slots, so like the other
|
|
67
|
+
// library fillers it reads after ContentPlugin(); its own audience
|
|
68
|
+
// directory is independent of that order. Inert until an audience
|
|
69
|
+
// exists.
|
|
70
|
+
SegmentsPlugin(),
|
|
57
71
|
UsersPlugin(),
|
|
58
72
|
ActivityPlugin(),
|
|
59
|
-
ApiTokensPlugin()
|
|
73
|
+
ApiTokensPlugin(),
|
|
74
|
+
WebhooksPlugin()
|
|
60
75
|
];
|
|
61
76
|
}
|
|
@@ -14,7 +14,7 @@ export function openAiProvider(): OpenAiProviderConfig | undefined {
|
|
|
14
14
|
}
|
|
15
15
|
return {
|
|
16
16
|
baseUrl,
|
|
17
|
-
apiKey:
|
|
17
|
+
apiKey: readEnv('COPILOT_OPENAI_API_KEY') ?? '',
|
|
18
18
|
models: readList('COPILOT_OPENAI_MODELS', 'llama3.1')
|
|
19
19
|
};
|
|
20
20
|
}
|
|
@@ -3,6 +3,12 @@ import type { IdentityPluginConfig } from '@orthacms/identity-server';
|
|
|
3
3
|
// ortha:if sso-oidc
|
|
4
4
|
import type { OidcProviderConfig } from '@orthacms/identity-provider-oidc';
|
|
5
5
|
// ortha:end
|
|
6
|
+
// ortha:if sso-github
|
|
7
|
+
import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
|
|
8
|
+
// ortha:end
|
|
9
|
+
// ortha:if sso-saml
|
|
10
|
+
import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
|
|
11
|
+
// ortha:end
|
|
6
12
|
import {
|
|
7
13
|
defined,
|
|
8
14
|
isProduction,
|
|
@@ -14,6 +20,12 @@ import {
|
|
|
14
20
|
// ortha:if sso-oidc
|
|
15
21
|
import { oidcProvider } from './sso-oidc';
|
|
16
22
|
// ortha:end
|
|
23
|
+
// ortha:if sso-github
|
|
24
|
+
import { githubProvider } from './sso-github';
|
|
25
|
+
// ortha:end
|
|
26
|
+
// ortha:if sso-saml
|
|
27
|
+
import { samlProvider } from './sso-saml';
|
|
28
|
+
// ortha:end
|
|
17
29
|
|
|
18
30
|
/**
|
|
19
31
|
* Identity settings, plus the identity providers this app can reach.
|
|
@@ -36,6 +48,14 @@ export interface AppIdentityConfig extends IdentityPluginConfig {
|
|
|
36
48
|
/** A generic OpenID Connect provider. Present when both env vars are set. */
|
|
37
49
|
oidc?: OidcProviderConfig & { name: string };
|
|
38
50
|
// ortha:end
|
|
51
|
+
// ortha:if sso-github
|
|
52
|
+
/** GitHub or GitHub Enterprise Server. Present when both env vars are set. */
|
|
53
|
+
github?: GithubProviderConfig & { name: string };
|
|
54
|
+
// ortha:end
|
|
55
|
+
// ortha:if sso-saml
|
|
56
|
+
/** A SAML 2.0 identity provider. Present when all three env vars are set. */
|
|
57
|
+
saml?: SamlProviderConfig & { name: string };
|
|
58
|
+
// ortha:end
|
|
39
59
|
};
|
|
40
60
|
}
|
|
41
61
|
|
|
@@ -67,15 +87,28 @@ export function identityConfig(): AppIdentityConfig {
|
|
|
67
87
|
limit: readPositiveInt('LOGIN_RATE_LIMIT', 10)
|
|
68
88
|
},
|
|
69
89
|
sso: ssoConfig(),
|
|
70
|
-
// ortha:if sso
|
|
71
|
-
ssoProviders: defined({
|
|
90
|
+
// ortha:if sso
|
|
91
|
+
ssoProviders: defined({
|
|
92
|
+
// ortha:if sso-oidc
|
|
93
|
+
oidc: oidcProvider(),
|
|
94
|
+
// ortha:end
|
|
95
|
+
// ortha:if sso-github
|
|
96
|
+
github: githubProvider(),
|
|
97
|
+
// ortha:end
|
|
98
|
+
// ortha:if sso-saml
|
|
99
|
+
saml: samlProvider()
|
|
100
|
+
// ortha:end
|
|
101
|
+
}),
|
|
72
102
|
// ortha:end
|
|
73
103
|
// With an email set, an admin is provisioned on boot — idempotent and
|
|
74
104
|
// non-destructive. This is how you get your first login.
|
|
105
|
+
// Read through `readEnv`, so all three are trimmed and a whitespace-only
|
|
106
|
+
// value is nothing rather than a value — a password of three spaces
|
|
107
|
+
// would otherwise be provisioned as the administrator's, silently.
|
|
75
108
|
rootAdmin: {
|
|
76
|
-
email:
|
|
77
|
-
password:
|
|
78
|
-
name:
|
|
109
|
+
email: readEnv('ORTHA_ROOT_ADMIN_EMAIL') ?? '',
|
|
110
|
+
password: readEnv('ORTHA_ROOT_ADMIN_PASSWORD') ?? '',
|
|
111
|
+
name: readEnv('ORTHA_ROOT_ADMIN_NAME') ?? ''
|
|
79
112
|
}
|
|
80
113
|
};
|
|
81
114
|
}
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
// ortha:if media-local
|
|
10
10
|
import type { LocalStorageConfig } from '@orthacms/media-provider-local';
|
|
11
|
+
import { readEnv } from '@orthacms/utils-server';
|
|
11
12
|
|
|
12
13
|
/** Local-filesystem blobs. */
|
|
13
14
|
export function mediaStorage(): LocalStorageConfig {
|
|
14
15
|
return {
|
|
15
16
|
// Point MEDIA_LOCAL_ROOT at a persistent volume in production: a
|
|
16
17
|
// container's own disk is wiped on every deploy.
|
|
17
|
-
rootDir:
|
|
18
|
+
rootDir: readEnv('MEDIA_LOCAL_ROOT') ?? './.storage/media'
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
// ortha:end
|
|
@@ -67,7 +68,7 @@ export function mediaStorage(): S3StorageConfig {
|
|
|
67
68
|
return defined({
|
|
68
69
|
bucket: requireEnv('MEDIA_S3_BUCKET'),
|
|
69
70
|
// `auto` is what R2 expects; AWS needs its real region.
|
|
70
|
-
region:
|
|
71
|
+
region: readEnv('MEDIA_S3_REGION') ?? 'auto',
|
|
71
72
|
// Omit for AWS S3 itself; set it for R2, MinIO, Spaces, B2…
|
|
72
73
|
endpoint: readEnv('MEDIA_S3_ENDPOINT'),
|
|
73
74
|
forcePathStyle: readFlag('MEDIA_S3_FORCE_PATH_STYLE', false),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// ortha:if sso-github
|
|
2
|
+
import type { GithubProviderConfig } from '@orthacms/identity-provider-github';
|
|
3
|
+
import { defined, readEnv, readList } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The GitHub provider, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* Present only when both values are set. The secret is not optional the way an
|
|
9
|
+
* OIDC one can be: GitHub's code exchange has no PKCE, so the secret is the
|
|
10
|
+
* only thing proving the code is being redeemed by this application — and the
|
|
11
|
+
* adapter refuses at construction rather than at the first sign-in, because
|
|
12
|
+
* every SSO failure looks the same to whoever clicked the button.
|
|
13
|
+
*/
|
|
14
|
+
export function githubProvider():
|
|
15
|
+
| (GithubProviderConfig & { name: string })
|
|
16
|
+
| undefined {
|
|
17
|
+
const clientId = readEnv('SSO_GITHUB_CLIENT_ID');
|
|
18
|
+
const clientSecret = readEnv('SSO_GITHUB_CLIENT_SECRET');
|
|
19
|
+
if (!clientId || !clientSecret) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
// `.env` ships this key blank, and `readList` answers a blank with `[]` —
|
|
23
|
+
// which is a value, not an absence, so passing it straight through would
|
|
24
|
+
// request *no* scopes and leave the profile read with nothing to read.
|
|
25
|
+
const scopes = readList('SSO_GITHUB_SCOPES', '');
|
|
26
|
+
return defined({
|
|
27
|
+
name: readEnv('SSO_GITHUB_NAME') ?? 'github',
|
|
28
|
+
clientId,
|
|
29
|
+
clientSecret,
|
|
30
|
+
label: readEnv('SSO_GITHUB_LABEL'),
|
|
31
|
+
// Defaults to `read:user user:email` — a profile and the verified
|
|
32
|
+
// addresses on it. GitHub's scopes are coarse, so anything wider hands
|
|
33
|
+
// the CMS access it has no use for.
|
|
34
|
+
scopes: scopes.length > 0 ? scopes : undefined,
|
|
35
|
+
// Only for GitHub Enterprise Server; github.com needs none.
|
|
36
|
+
enterpriseBaseUrl: readEnv('SSO_GITHUB_ENTERPRISE_BASE_URL'),
|
|
37
|
+
// Cosmetic, like Google's `hd`: it shapes the account chooser. Who
|
|
38
|
+
// actually gets in is this CMS's own decision, not GitHub's.
|
|
39
|
+
organization: readEnv('SSO_GITHUB_ORGANIZATION')
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// ortha:end
|
|
@@ -16,7 +16,7 @@ export function oidcProvider(): (OidcProviderConfig & { name: string }) | undefi
|
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
return defined({
|
|
19
|
-
name:
|
|
19
|
+
name: readEnv('SSO_OIDC_NAME') ?? 'oidc',
|
|
20
20
|
issuer,
|
|
21
21
|
clientId,
|
|
22
22
|
clientSecret: readEnv('SSO_OIDC_CLIENT_SECRET'),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ortha:if sso-saml
|
|
2
|
+
import type { SamlProviderConfig } from '@orthacms/identity-provider-saml';
|
|
3
|
+
import { defined, readEnv, readFlag } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The SAML provider, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* All three of the entry point, the certificate and this app's entity id are
|
|
9
|
+
* required, and there is nothing to fall back to: SAML has no discovery
|
|
10
|
+
* document and no key endpoint, so the certificate an operator copies out of
|
|
11
|
+
* their IdP is the whole of the trust relationship.
|
|
12
|
+
*/
|
|
13
|
+
export function samlProvider():
|
|
14
|
+
| (SamlProviderConfig & { name: string })
|
|
15
|
+
| undefined {
|
|
16
|
+
const entryPoint = readEnv('SSO_SAML_ENTRY_POINT');
|
|
17
|
+
const idpCert = readEnv('SSO_SAML_IDP_CERT');
|
|
18
|
+
const issuer = readEnv('SSO_SAML_ISSUER');
|
|
19
|
+
if (!entryPoint || !idpCert || !issuer) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return defined({
|
|
23
|
+
name: readEnv('SSO_SAML_NAME') ?? 'saml',
|
|
24
|
+
entryPoint,
|
|
25
|
+
// A PEM body on one line, `\n` escapes and all: an environment
|
|
26
|
+
// variable cannot hold real newlines, so they are put back here rather
|
|
27
|
+
// than left for the XML parser to fail on.
|
|
28
|
+
idpCert: idpCert.replace(/\\n/g, '\n'),
|
|
29
|
+
issuer,
|
|
30
|
+
label: readEnv('SSO_SAML_LABEL'),
|
|
31
|
+
// Worth setting whenever the IdP's NameID format is `emailAddress`: an
|
|
32
|
+
// address is not a stable identifier, and a profile whose subject is
|
|
33
|
+
// one is refused.
|
|
34
|
+
subjectAttribute: readEnv('SSO_SAML_SUBJECT_ATTRIBUTE'),
|
|
35
|
+
emailAttribute: readEnv('SSO_SAML_EMAIL_ATTRIBUTE'),
|
|
36
|
+
nameAttribute: readEnv('SSO_SAML_NAME_ATTRIBUTE'),
|
|
37
|
+
groupsAttribute: readEnv('SSO_SAML_GROUPS_ATTRIBUTE'),
|
|
38
|
+
// Defaults to false, and stays an assertion rather than a reading:
|
|
39
|
+
// **SAML carries no verification claim at all**, so there is nothing
|
|
40
|
+
// an adapter could inspect and be honest about. Setting it says this
|
|
41
|
+
// directory is authoritative for the addresses it reports — which is
|
|
42
|
+
// the only gate on a first sign-in claiming an existing account.
|
|
43
|
+
emailVerified: readFlag('SSO_SAML_EMAIL_VERIFIED', false)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// ortha:end
|