create-ortha-app 0.4.2 → 0.5.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/dist/cli.js +6 -267
- package/dist/lib/features.d.ts +42 -20
- package/dist/lib/features.d.ts.map +1 -1
- package/dist/lib/features.js +56 -22
- 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/dist/lib/template.d.ts.map +1 -1
- package/dist/lib/template.js +11 -1
- package/package.json +1 -1
- package/templates/default/README.md.tmpl +38 -12
- package/templates/default/apps/admin/src/plugins.spec.ts +5 -1
- package/templates/default/apps/admin/src/plugins.ts +21 -2
- package/templates/default/apps/admin/tsconfig.json +2 -1
- package/templates/default/apps/server/config/copilot-anthropic.ts +22 -0
- package/templates/default/apps/server/config/copilot-openai.ts +21 -0
- package/templates/default/apps/server/config/copilot.ts +61 -0
- package/templates/default/apps/server/config/docs.ts +14 -0
- package/templates/default/apps/server/config/i18n.ts +16 -0
- package/templates/default/apps/server/config/identity.ts +129 -0
- package/templates/default/apps/server/config/mcp.ts +25 -0
- package/templates/default/apps/server/config/media-storage.ts +84 -0
- package/templates/default/apps/server/config/media.ts +67 -0
- package/templates/default/apps/server/config/sso-github.ts +42 -0
- package/templates/default/apps/server/config/sso-oidc.ts +34 -0
- package/templates/default/apps/server/config/sso-saml.ts +46 -0
- package/templates/default/apps/server/ortha.config.ts +60 -449
- package/templates/default/apps/server/src/plugins.spec.ts +7 -0
- package/templates/default/apps/server/src/plugins.ts +84 -19
- package/templates/default/apps/server/tsconfig.spec.json +25 -0
- package/templates/default/env.tmpl +84 -2
- package/templates/default/tsconfig.json +3 -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
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/lib/template.ts"],"names":[],"mappings":"AAWA,OAAO,EAIH,KAAK,gBAAgB,EACxB,MAAM,YAAY,CAAC;AAEpB,+DAA+D;AAC/D,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,YAAY,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,+BAA+B;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,SAAS,EAAE,gBAAgB,CAAC;CAC/B;AAoBD,yDAAyD;AACzD,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,MAAM,CAevE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC1B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,GACvB,MAAM,CAmBR;AAuBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC1B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,cAAc,GACvB,IAAI,
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/lib/template.ts"],"names":[],"mappings":"AAWA,OAAO,EAIH,KAAK,gBAAgB,EACxB,MAAM,YAAY,CAAC;AAEpB,+DAA+D;AAC/D,MAAM,WAAW,cAAc;IAC3B,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,YAAY,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,+BAA+B;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,SAAS,EAAE,gBAAgB,CAAC;CAC/B;AAoBD,yDAAyD;AACzD,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,MAAM,CAevE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC1B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,GACvB,MAAM,CAmBR;AAuBD;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC1B,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,cAAc,GACvB,IAAI,CA0CN;AAED,+CAA+C;AAC/C,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAMxD"}
|
package/dist/lib/template.js
CHANGED
|
@@ -104,7 +104,17 @@ function renderTemplate(templateDir, target, values) {
|
|
|
104
104
|
(0, node_fs_1.writeFileSync)(destination, renderManifest(raw, values));
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
const contents = (0, conditionals_1.applyConditionals)(raw, flags, file);
|
|
108
|
+
// A file that conditioned *itself* away is not written at all. This is
|
|
109
|
+
// what lets one module per optional plugin live under `config/`: wrap
|
|
110
|
+
// the whole of `config/mcp.ts` in `ortha:if mcp` and an app generated
|
|
111
|
+
// without MCP has no such file, rather than an empty one whose only
|
|
112
|
+
// job is to explain why it is empty. Guarded on the source having had
|
|
113
|
+
// content, so a template file that is deliberately blank still ships.
|
|
114
|
+
if (raw.trim() !== '' && contents.trim() === '') {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
(0, node_fs_1.writeFileSync)(destination, render(contents, values));
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
/** Whether `dir` exists and holds anything. */
|
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
|
|
@@ -145,18 +145,25 @@ talking to itself.
|
|
|
145
145
|
### AI copilot
|
|
146
146
|
|
|
147
147
|
Installed, and **off** until you turn it on: set `COPILOT_ENABLED=true` in
|
|
148
|
-
`.env`.
|
|
149
|
-
|
|
148
|
+
`.env`. Off means off — with it `false` the app registers no `/api/copilot`
|
|
149
|
+
route, so those endpoints 404 and the admin's chat panel, Agents view and
|
|
150
|
+
Skills page are not there. Whether this app offers an AI assistant at all is a
|
|
151
|
+
decision rather than a default.
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
It is not the switch that keeps your content in-house, though. A backend is
|
|
154
|
+
registered only when its settings are present (below), so an app with no key
|
|
155
|
+
reaches no third party either way.
|
|
156
|
+
|
|
157
|
+
Backends are registered in `apps/server/src/plugins.ts`, and **the order is the
|
|
152
158
|
setting** — there is no `defaultProvider`. The first registered backend serves a
|
|
153
159
|
run that names none, and it is what the admin's model picker opens on. A backend
|
|
154
160
|
is only registered when its connection settings are present, so an unconfigured
|
|
155
161
|
one never appears as an option that fails on the first message.
|
|
156
162
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
There is no offline stand-in. Configure no backend and the copilot has nothing
|
|
164
|
+
to call, so leave `COPILOT_ENABLED=false` until you have one — turning it on
|
|
165
|
+
with an empty provider list refuses to boot rather than shipping a chat that
|
|
166
|
+
cannot answer.
|
|
160
167
|
<!-- ortha:if graphql -->
|
|
161
168
|
### GraphQL
|
|
162
169
|
|
|
@@ -182,8 +189,27 @@ This app starts with none. To add some:
|
|
|
182
189
|
1. Write them under `apps/server/src/content/` and export a `contentTypes` array.
|
|
183
190
|
2. Pass them to `ContentPlugin({ types: contentTypes, migrations: … })` in
|
|
184
191
|
`apps/server/src/plugins.ts` — the comment there has the exact shape.
|
|
185
|
-
3. Add `apps/server/drizzle.config.ts
|
|
186
|
-
|
|
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
|
+
|
|
187
213
|
4. `npm run generate -- --name=add_content_types && npm run migrate`
|
|
188
214
|
|
|
189
215
|
## Deploying
|
|
@@ -31,10 +31,14 @@ const EXPECTED_PLUGINS = [
|
|
|
31
31
|
'i18n',
|
|
32
32
|
'wysiwyg',
|
|
33
33
|
'media',
|
|
34
|
+
'transfer',
|
|
35
|
+
'alarms',
|
|
34
36
|
'copilot',
|
|
37
|
+
'segments',
|
|
35
38
|
'users',
|
|
36
39
|
'activity',
|
|
37
|
-
'api-tokens'
|
|
40
|
+
'api-tokens',
|
|
41
|
+
'webhooks'
|
|
38
42
|
];
|
|
39
43
|
|
|
40
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';
|
|
@@ -11,9 +12,13 @@ import { UsersPlugin } from '@orthacms/users-admin';
|
|
|
11
12
|
import { WorkspacesPlugin } from '@orthacms/workspaces-admin';
|
|
12
13
|
import { WysiwygPlugin } from '@orthacms/wysiwyg-admin';
|
|
13
14
|
import { CopilotPlugin } from '@orthacms/copilot-admin';
|
|
15
|
+
import { AlarmsPlugin } from '@orthacms/alarms-admin';
|
|
16
|
+
import { transferAdminPlugin } from '@orthacms/transfer-admin';
|
|
17
|
+
import { SegmentsPlugin } from '@orthacms/segments-admin';
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
|
-
* The admin's composition, mirroring `
|
|
20
|
+
* The admin's composition, mirroring `apps/server/src/plugins.ts` on the UI
|
|
21
|
+
* side.
|
|
17
22
|
*
|
|
18
23
|
* **Two positions matter; the rest is legibility.**
|
|
19
24
|
*
|
|
@@ -46,12 +51,26 @@ export function buildPlugins(): AdminPlugin[] {
|
|
|
46
51
|
I18nPlugin(),
|
|
47
52
|
WysiwygPlugin(),
|
|
48
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(),
|
|
58
|
+
// Another Content Library slot filler — the entry rail's checks block,
|
|
59
|
+
// an optional records column, and "Save as rule" in the toolbar.
|
|
60
|
+
AlarmsPlugin(),
|
|
49
61
|
// The docked chat panel plus the full-page Agents view. Belongs with
|
|
50
62
|
// the workspace-interior features: the panel mounts into the workspace
|
|
51
63
|
// shell's sidebar footer.
|
|
52
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(),
|
|
53
71
|
UsersPlugin(),
|
|
54
72
|
ActivityPlugin(),
|
|
55
|
-
ApiTokensPlugin()
|
|
73
|
+
ApiTokensPlugin(),
|
|
74
|
+
WebhooksPlugin()
|
|
56
75
|
];
|
|
57
76
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ortha:if copilot-anthropic
|
|
2
|
+
import type { AnthropicProviderConfig } from '@orthacms/copilot-provider-anthropic';
|
|
3
|
+
import { readEnv, readList } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Native Claude, or nothing.
|
|
7
|
+
*
|
|
8
|
+
* Setting the key is what REGISTERS this backend — leave it empty and there is
|
|
9
|
+
* no `claude` in the picker at all, rather than one that fails on the first
|
|
10
|
+
* message.
|
|
11
|
+
*/
|
|
12
|
+
export function anthropicProvider(): AnthropicProviderConfig | undefined {
|
|
13
|
+
const apiKey = readEnv('ANTHROPIC_API_KEY');
|
|
14
|
+
if (!apiKey) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
apiKey,
|
|
19
|
+
models: readList('COPILOT_ANTHROPIC_MODELS', 'claude-sonnet-5')
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ortha:end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ortha:if copilot-openai
|
|
2
|
+
import type { OpenAiProviderConfig } from '@orthacms/copilot-provider-openai';
|
|
3
|
+
import { readEnv, readList } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An OpenAI-wire backend, or nothing. No default endpoint: an unset variable
|
|
7
|
+
* means "this deployment has no such backend", not "assume one is running on
|
|
8
|
+
* this laptop".
|
|
9
|
+
*/
|
|
10
|
+
export function openAiProvider(): OpenAiProviderConfig | undefined {
|
|
11
|
+
const baseUrl = readEnv('COPILOT_OPENAI_BASE_URL');
|
|
12
|
+
if (!baseUrl) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
baseUrl,
|
|
17
|
+
apiKey: readEnv('COPILOT_OPENAI_API_KEY') ?? '',
|
|
18
|
+
models: readList('COPILOT_OPENAI_MODELS', 'llama3.1')
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// ortha:end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The copilot kill switch and the model backends it can reach. */
|
|
2
|
+
import type { CopilotPluginConfig } from '@orthacms/copilot-server';
|
|
3
|
+
// ortha:if copilot-anthropic
|
|
4
|
+
import type { AnthropicProviderConfig } from '@orthacms/copilot-provider-anthropic';
|
|
5
|
+
// ortha:end
|
|
6
|
+
// ortha:if copilot-openai
|
|
7
|
+
import type { OpenAiProviderConfig } from '@orthacms/copilot-provider-openai';
|
|
8
|
+
// ortha:end
|
|
9
|
+
import { defined, readFlag, readPositiveInt } from '@orthacms/utils-server';
|
|
10
|
+
|
|
11
|
+
// ortha:if copilot-anthropic
|
|
12
|
+
import { anthropicProvider } from './copilot-anthropic';
|
|
13
|
+
// ortha:end
|
|
14
|
+
// ortha:if copilot-openai
|
|
15
|
+
import { openAiProvider } from './copilot-openai';
|
|
16
|
+
// ortha:end
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Copilot settings plus the backends this deployment can reach.
|
|
20
|
+
*
|
|
21
|
+
* The provider settings live **here**, not inside `CopilotPluginConfig`: the
|
|
22
|
+
* plugin is adapter-agnostic by decision, so it names no provider kind. A key
|
|
23
|
+
* is present only when the deployment configured that backend, and `plugins.ts`
|
|
24
|
+
* registers exactly the ones that are — "configured" is a fact this file can
|
|
25
|
+
* read, where a `defaultProvider` naming one of them could be misspelled or
|
|
26
|
+
* point at a backend nobody registered.
|
|
27
|
+
*/
|
|
28
|
+
export interface AppCopilotConfig extends CopilotPluginConfig {
|
|
29
|
+
providers: {
|
|
30
|
+
// ortha:if copilot-anthropic
|
|
31
|
+
/** Native Claude. Present when ANTHROPIC_API_KEY is set. */
|
|
32
|
+
claude?: AnthropicProviderConfig;
|
|
33
|
+
// ortha:end
|
|
34
|
+
// ortha:if copilot-openai
|
|
35
|
+
/**
|
|
36
|
+
* An OpenAI-wire endpoint — Ollama, vLLM, LiteLLM, Azure or OpenAI.
|
|
37
|
+
* Present when COPILOT_OPENAI_BASE_URL is set: an endpoint nobody
|
|
38
|
+
* named is a backend that can only time out.
|
|
39
|
+
*/
|
|
40
|
+
openai?: OpenAiProviderConfig;
|
|
41
|
+
// ortha:end
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The copilot kill switch and the model backends it can reach. */
|
|
46
|
+
export function copilotConfig(): AppCopilotConfig {
|
|
47
|
+
return {
|
|
48
|
+
// Off by default: enabling a hosted provider sends workspace content to
|
|
49
|
+
// a third party, which is an operator's decision to make explicitly.
|
|
50
|
+
enabled: readFlag('COPILOT_ENABLED', false),
|
|
51
|
+
maxOutputTokens: readPositiveInt('COPILOT_MAX_OUTPUT_TOKENS', 8192),
|
|
52
|
+
providers: defined({
|
|
53
|
+
// ortha:if copilot-anthropic
|
|
54
|
+
claude: anthropicProvider(),
|
|
55
|
+
// ortha:end
|
|
56
|
+
// ortha:if copilot-openai
|
|
57
|
+
openai: openAiProvider()
|
|
58
|
+
// ortha:end
|
|
59
|
+
})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** The OpenAPI document and the API reference it is served as. */
|
|
2
|
+
import type { ApiDocsOptions } from '@orthacms/bootstrap-server';
|
|
3
|
+
import { isProduction, readFlag } from '@orthacms/utils-server';
|
|
4
|
+
|
|
5
|
+
/** The OpenAPI document and the API reference it is served as. */
|
|
6
|
+
export function docsConfig(): ApiDocsOptions {
|
|
7
|
+
return {
|
|
8
|
+
// On outside production, where the reference is a development tool.
|
|
9
|
+
// `API_DOCS=true` publishes it from a deployed instance.
|
|
10
|
+
enabled: readFlag('API_DOCS', !isProduction()),
|
|
11
|
+
title: '__APP_TITLE__ API',
|
|
12
|
+
version: '1.0.0'
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The content locales, and what to do about rows left in a removed one. */
|
|
2
|
+
import type { I18nPluginConfig } from '@orthacms/i18n-server';
|
|
3
|
+
|
|
4
|
+
/** The content locales, and what to do about rows left in a removed one. */
|
|
5
|
+
export function i18nConfig(): I18nPluginConfig {
|
|
6
|
+
return {
|
|
7
|
+
// Content locales. Stable product configuration, hence literals. The
|
|
8
|
+
// slugs are stored on entry rows, so removing one hides its rows rather
|
|
9
|
+
// than deleting them — which is what `orphanedLocales` is about below.
|
|
10
|
+
locales: [{ slug: 'en', name: 'English', isDefault: true }],
|
|
11
|
+
// Rows in a locale no longer listed above are intact and unreachable,
|
|
12
|
+
// the worst shape for a silent failure. Fail the boot and put the
|
|
13
|
+
// choice in front of whoever edited the array.
|
|
14
|
+
orphanedLocales: 'fail'
|
|
15
|
+
};
|
|
16
|
+
}
|