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.
Files changed (32) hide show
  1. package/dist/cli.js +6 -267
  2. package/dist/lib/features.d.ts +42 -20
  3. package/dist/lib/features.d.ts.map +1 -1
  4. package/dist/lib/features.js +56 -22
  5. package/dist/lib/run.d.ts +22 -0
  6. package/dist/lib/run.d.ts.map +1 -0
  7. package/dist/lib/run.js +318 -0
  8. package/dist/lib/template.d.ts.map +1 -1
  9. package/dist/lib/template.js +11 -1
  10. package/package.json +1 -1
  11. package/templates/default/README.md.tmpl +38 -12
  12. package/templates/default/apps/admin/src/plugins.spec.ts +5 -1
  13. package/templates/default/apps/admin/src/plugins.ts +21 -2
  14. package/templates/default/apps/admin/tsconfig.json +2 -1
  15. package/templates/default/apps/server/config/copilot-anthropic.ts +22 -0
  16. package/templates/default/apps/server/config/copilot-openai.ts +21 -0
  17. package/templates/default/apps/server/config/copilot.ts +61 -0
  18. package/templates/default/apps/server/config/docs.ts +14 -0
  19. package/templates/default/apps/server/config/i18n.ts +16 -0
  20. package/templates/default/apps/server/config/identity.ts +129 -0
  21. package/templates/default/apps/server/config/mcp.ts +25 -0
  22. package/templates/default/apps/server/config/media-storage.ts +84 -0
  23. package/templates/default/apps/server/config/media.ts +67 -0
  24. package/templates/default/apps/server/config/sso-github.ts +42 -0
  25. package/templates/default/apps/server/config/sso-oidc.ts +34 -0
  26. package/templates/default/apps/server/config/sso-saml.ts +46 -0
  27. package/templates/default/apps/server/ortha.config.ts +60 -449
  28. package/templates/default/apps/server/src/plugins.spec.ts +7 -0
  29. package/templates/default/apps/server/src/plugins.ts +84 -19
  30. package/templates/default/apps/server/tsconfig.spec.json +25 -0
  31. package/templates/default/env.tmpl +84 -2
  32. package/templates/default/tsconfig.json +3 -0
@@ -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,CAiCN;AAED,+CAA+C;AAC/C,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAMxD"}
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"}
@@ -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
- (0, node_fs_1.writeFileSync)(destination, render((0, conditionals_1.applyConditionals)(raw, flags, file), values));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ortha-app",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Create an Ortha CMS app: npx create-ortha-app my-cms",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/create-ortha-app",
@@ -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 `process.env` |
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
- `src/server/plugins.ts`. `@InjectDatabase()` from `@orthacms/database` gives it
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 `src/admin/plugins.ts`.
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`. Enabling a hosted backend sends workspace content to a third party, so
149
- it is an explicit decision rather than a default.
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
- Backends are registered in `src/server/plugins.ts`, and **the order is the
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
- The bundled `fake` provider needs no key and no network, and is registered last
158
- so with nothing configured the chat still works, and it is the default only
159
- because it is the only one.
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` pointing `schema` at your
186
- `src/content/index.ts` and `out` at `../../migrations`.
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 `src/server/plugins.ts` on the UI side.
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
  }
@@ -11,7 +11,8 @@
11
11
  "jsx": "react-jsx",
12
12
  "types": [
13
13
  "node",
14
- "vite/client"
14
+ "vite/client",
15
+ "vitest/globals"
15
16
  ],
16
17
  "strict": true,
17
18
  "skipLibCheck": true,
@@ -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
+ }