create-okjt-app 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,48 +1,48 @@
1
- # create-okjt-app
2
-
3
- Starts an OKJTech project: a Laravel 12 API on
4
- [`okjt/laravel-kit`](https://packagist.org/packages/okjt/laravel-kit) and a
5
- React 19 app on [`@okjt/site`](https://www.npmjs.com/package/@okjt/site) and
6
- [`@okjt/dashboard`](https://www.npmjs.com/package/@okjt/dashboard), with your
7
- name, colours, roles and features, and a workflow that tests and deploys it.
8
-
9
- ```bash
10
- npm create okjt-app@latest bloom
11
- ```
12
-
13
- Needs Node 20+, PHP 8.2+ and Composer.
14
-
15
- ## What it asks
16
-
17
- | Question | Becomes |
18
- |---|---|
19
- | Name, domain, one line about it | The public pages, the SEO, the deploy workflow |
20
- | Brand colour and text colour | The whole palette in `frontend/okjt.config.js` (17 colours mixed from the two; edit any) |
21
- | What people work on together | The workspace: "project" makes `Project`, `projects`, `project_user`, `/projects` |
22
- | Roles | `full`: superadmin › account owner / staff › owner / editor / viewer. `standard`: superadmin › owner / editor / viewer. `simple`: admin › user |
23
- | Features | `google`, `passkeys`, `backups`, `console` (superadmin presets), `billing` (Paystack; `full` only) |
24
-
25
- Or answer with flags, for scripts:
26
-
27
- ```bash
28
- npm create okjt-app@latest bloom -- --yes --name "Bloom" --accent "#0F766E" --preset full --features google,console,billing
29
- ```
30
-
31
- `--no-install` writes the files only; `--no-checks` skips running the tests and the build.
32
-
33
- ## What you get
34
-
35
- ```
36
- bloom/
37
- backend/ Laravel 12 + okjt/laravel-kit: sign-in, two-step codes, email
38
- verification, workspaces and roles, sharing and invitations,
39
- presence, version history, notifications, and the features you
40
- chose; a starter Notes module with its tests; SQLite locally
41
- frontend/ React 19 + Vite + Tailwind 4: the home page, sign-in and sign-up,
42
- your workspaces, and the dashboard (overview and notes)
43
- .github/workflows/deploy.yml tests both, then deploys through deploy-kit
44
- README.md running it, where things are, adding a module, deploying
45
- ```
46
-
47
- It installs everything, migrates, creates the first user (printed at the end,
48
- from `backend/.env`), runs the API's tests and builds the app.
1
+ # create-okjt-app
2
+
3
+ Starts an OKJTech project: a Laravel 12 API on
4
+ [`okjt/laravel-kit`](https://packagist.org/packages/okjt/laravel-kit) and a
5
+ React 19 app on [`@okjt/site`](https://www.npmjs.com/package/@okjt/site) and
6
+ [`@okjt/dashboard`](https://www.npmjs.com/package/@okjt/dashboard), with your
7
+ name, colours, roles and features, and a workflow that tests and deploys it.
8
+
9
+ ```bash
10
+ npm create okjt-app@latest bloom
11
+ ```
12
+
13
+ Needs Node 20+, PHP 8.2+ and Composer.
14
+
15
+ ## What it asks
16
+
17
+ | Question | Becomes |
18
+ |---|---|
19
+ | Name, domain, one line about it | The public pages, the SEO, the deploy workflow |
20
+ | Brand colour and text colour | The whole palette in `frontend/okjt.config.js` (17 colours mixed from the two; edit any) |
21
+ | What people work on together | The workspace: "project" makes `Project`, `projects`, `project_user`, `/projects` |
22
+ | Roles | `full`: superadmin › account owner / staff › owner / editor / viewer. `standard`: superadmin › owner / editor / viewer. `simple`: admin › user |
23
+ | Features | `google`, `passkeys`, `backups`, `console` (superadmin presets), `billing` (Paystack; `full` only) |
24
+
25
+ Or answer with flags, for scripts:
26
+
27
+ ```bash
28
+ npm create okjt-app@latest bloom -- --yes --name "Bloom" --accent "#0F766E" --preset full --features google,console,billing
29
+ ```
30
+
31
+ `--no-install` writes the files only; `--no-checks` skips running the tests and the build.
32
+
33
+ ## What you get
34
+
35
+ ```
36
+ bloom/
37
+ backend/ Laravel 12 + okjt/laravel-kit: sign-in, two-step codes, email
38
+ verification, workspaces and roles, sharing and invitations,
39
+ presence, version history, notifications, and the features you
40
+ chose; a starter Notes module with its tests; SQLite locally
41
+ frontend/ React 19 + Vite + Tailwind 4: the home page, sign-in and sign-up,
42
+ your workspaces, and the dashboard (overview and notes)
43
+ .github/workflows/deploy.yml tests both, then deploys through deploy-kit
44
+ README.md running it, where things are, adding a module, deploying
45
+ ```
46
+
47
+ It installs everything, migrates, creates the first user (printed at the end,
48
+ from `backend/.env`), runs the API's tests and builds the app.
@@ -1,88 +1,88 @@
1
- #!/usr/bin/env node
2
- /**
3
- * npm create okjt-app@latest [folder] -- [--name … --preset … --features a,b --yes]
4
- *
5
- * Asks for what it isn't given; --yes takes the defaults for the rest.
6
- */
7
- import { resolve } from 'node:path';
8
- import { createInterface } from 'node:readline/promises';
9
- import { parseArgs } from 'node:util';
10
- import { FEATURES, PRESETS, describeProject, featuresFor, slugify } from '../src/answers.mjs';
11
- import { createProject } from '../src/create.mjs';
12
-
13
- const { values: flags, positionals } = parseArgs({
14
- allowPositionals: true,
15
- options: {
16
- name: { type: 'string' },
17
- slug: { type: 'string' },
18
- domain: { type: 'string' },
19
- tagline: { type: 'string' },
20
- accent: { type: 'string' },
21
- ink: { type: 'string' },
22
- workspace: { type: 'string' },
23
- preset: { type: 'string' },
24
- features: { type: 'string' },
25
- yes: { type: 'boolean', short: 'y', default: false },
26
- 'no-install': { type: 'boolean', default: false },
27
- 'no-checks': { type: 'boolean', default: false },
28
- 'no-git': { type: 'boolean', default: false },
29
- help: { type: 'boolean', short: 'h', default: false },
30
- },
31
- });
32
-
33
- if (flags.help) {
34
- console.log(`create-okjt-app [folder]
35
-
36
- --name "Bloom" the product's name
37
- --domain bloom.co.ke where it will live
38
- --tagline "…" one line about it
39
- --accent #2563EB the brand colour
40
- --ink #1F2937 the darkest text colour
41
- --workspace project what people work on together (a project, a site, a case)
42
- --preset standard roles: ${Object.entries(PRESETS).map(([k, v]) => `${k} (${v})`).join('; ')}
43
- --features a,b ${Object.keys(FEATURES).join(', ')}
44
- --yes defaults for anything not given
45
- --no-install write the files only
46
- --no-checks skip running the tests and the build
47
- --no-git no git repository`);
48
- process.exit(0);
49
- }
50
-
51
- const rl = flags.yes ? null : createInterface({ input: process.stdin, output: process.stdout });
52
- const ask = async (question, fallback) => {
53
- if (!rl) return fallback;
54
- const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ''}: `)).trim();
55
- return answer || fallback;
56
- };
57
-
58
- try {
59
- const name = flags.name || await ask('Project name', positionals[0] || 'My app');
60
- const slug = flags.slug || slugify(positionals[0] || name);
61
- const domain = flags.domain || await ask('Domain', `${slug}.okjtech.co.ke`);
62
- const tagline = flags.tagline || await ask('One line about it', `${name} keeps your work in one place.`);
63
- const accent = flags.accent || await ask('Brand colour', '#2563EB');
64
- const ink = flags.ink || await ask('Text colour', '#1F2937');
65
- const workspace = flags.workspace || await ask('What do people work on together? (project, site, case…)', 'project');
66
- if (rl && !flags.preset) console.log(Object.entries(PRESETS).map(([k, v]) => ` ${k}: ${v}`).join('\n'));
67
- const preset = flags.preset || await ask('Roles', 'standard');
68
- let features = flags.features?.split(',').map((f) => f.trim()).filter(Boolean);
69
- if (!features) {
70
- const offered = featuresFor(preset);
71
- if (rl) console.log(offered.map((f) => ` ${f}: ${FEATURES[f]}`).join('\n'));
72
- const answer = await ask('Features (comma-separated, or none)', offered.filter((f) => f !== 'billing').join(','));
73
- features = answer === 'none' ? [] : answer.split(',').map((f) => f.trim()).filter(Boolean);
74
- }
75
- rl?.close();
76
-
77
- const project = describeProject({ name, slug, domain, tagline, accent, ink, workspace, preset, features });
78
- await createProject(project, {
79
- dir: resolve(positionals[0] || project.slug),
80
- install: !flags['no-install'],
81
- checks: !flags['no-checks'],
82
- git: !flags['no-git'],
83
- });
84
- } catch (error) {
85
- rl?.close();
86
- console.error(`\ncreate-okjt-app: ${error.message}`);
87
- process.exit(1);
88
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npm create okjt-app@latest [folder] -- [--name … --preset … --features a,b --yes]
4
+ *
5
+ * Asks for what it isn't given; --yes takes the defaults for the rest.
6
+ */
7
+ import { resolve } from 'node:path';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { parseArgs } from 'node:util';
10
+ import { FEATURES, PRESETS, describeProject, featuresFor, slugify } from '../src/answers.mjs';
11
+ import { createProject } from '../src/create.mjs';
12
+
13
+ const { values: flags, positionals } = parseArgs({
14
+ allowPositionals: true,
15
+ options: {
16
+ name: { type: 'string' },
17
+ slug: { type: 'string' },
18
+ domain: { type: 'string' },
19
+ tagline: { type: 'string' },
20
+ accent: { type: 'string' },
21
+ ink: { type: 'string' },
22
+ workspace: { type: 'string' },
23
+ preset: { type: 'string' },
24
+ features: { type: 'string' },
25
+ yes: { type: 'boolean', short: 'y', default: false },
26
+ 'no-install': { type: 'boolean', default: false },
27
+ 'no-checks': { type: 'boolean', default: false },
28
+ 'no-git': { type: 'boolean', default: false },
29
+ help: { type: 'boolean', short: 'h', default: false },
30
+ },
31
+ });
32
+
33
+ if (flags.help) {
34
+ console.log(`create-okjt-app [folder]
35
+
36
+ --name "Bloom" the product's name
37
+ --domain bloom.co.ke where it will live
38
+ --tagline "…" one line about it
39
+ --accent #2563EB the brand colour
40
+ --ink #1F2937 the darkest text colour
41
+ --workspace project what people work on together (a project, a site, a case)
42
+ --preset standard roles: ${Object.entries(PRESETS).map(([k, v]) => `${k} (${v})`).join('; ')}
43
+ --features a,b ${Object.keys(FEATURES).join(', ')}
44
+ --yes defaults for anything not given
45
+ --no-install write the files only
46
+ --no-checks skip running the tests and the build
47
+ --no-git no git repository`);
48
+ process.exit(0);
49
+ }
50
+
51
+ const rl = flags.yes ? null : createInterface({ input: process.stdin, output: process.stdout });
52
+ const ask = async (question, fallback) => {
53
+ if (!rl) return fallback;
54
+ const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ''}: `)).trim();
55
+ return answer || fallback;
56
+ };
57
+
58
+ try {
59
+ const name = flags.name || await ask('Project name', positionals[0] || 'My app');
60
+ const slug = flags.slug || slugify(positionals[0] || name);
61
+ const domain = flags.domain || await ask('Domain', `${slug}.okjtech.co.ke`);
62
+ const tagline = flags.tagline || await ask('One line about it', `${name} keeps your work in one place.`);
63
+ const accent = flags.accent || await ask('Brand colour', '#2563EB');
64
+ const ink = flags.ink || await ask('Text colour', '#1F2937');
65
+ const workspace = flags.workspace || await ask('What do people work on together? (project, site, case…)', 'project');
66
+ if (rl && !flags.preset) console.log(Object.entries(PRESETS).map(([k, v]) => ` ${k}: ${v}`).join('\n'));
67
+ const preset = flags.preset || await ask('Roles', 'standard');
68
+ let features = flags.features?.split(',').map((f) => f.trim()).filter(Boolean);
69
+ if (!features) {
70
+ const offered = featuresFor(preset);
71
+ if (rl) console.log(offered.map((f) => ` ${f}: ${FEATURES[f]}`).join('\n'));
72
+ const answer = await ask('Features (comma-separated, or none)', offered.filter((f) => f !== 'billing').join(','));
73
+ features = answer === 'none' ? [] : answer.split(',').map((f) => f.trim()).filter(Boolean);
74
+ }
75
+ rl?.close();
76
+
77
+ const project = describeProject({ name, slug, domain, tagline, accent, ink, workspace, preset, features });
78
+ await createProject(project, {
79
+ dir: resolve(positionals[0] || project.slug),
80
+ install: !flags['no-install'],
81
+ checks: !flags['no-checks'],
82
+ git: !flags['no-git'],
83
+ });
84
+ } catch (error) {
85
+ rl?.close();
86
+ console.error(`\ncreate-okjt-app: ${error.message}`);
87
+ process.exit(1);
88
+ }
package/package.json CHANGED
@@ -1,29 +1,29 @@
1
- {
2
- "name": "create-okjt-app",
3
- "version": "0.1.0",
4
- "description": "Start an OKJTech project: a Laravel API on okjt/laravel-kit and a React app on @okjt/site and @okjt/dashboard, with your name, colours, roles and features, ready to deploy",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/Juniortambo2628/OKJT.git",
10
- "directory": "packages/create-app"
11
- },
12
- "bin": {
13
- "create-okjt-app": "bin/create-okjt-app.mjs"
14
- },
15
- "files": [
16
- "bin",
17
- "src",
18
- "README.md"
19
- ],
20
- "engines": {
21
- "node": ">=20"
22
- },
23
- "scripts": {
24
- "test": "node --test \"test/*.test.mjs\""
25
- },
26
- "publishConfig": {
27
- "access": "public"
28
- }
29
- }
1
+ {
2
+ "name": "create-okjt-app",
3
+ "version": "0.1.1",
4
+ "description": "Start an OKJTech project: a Laravel API on okjt/laravel-kit and a React app on @okjt/site and @okjt/dashboard, with your name, colours, roles and features, ready to deploy",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Juniortambo2628/OKJT.git",
10
+ "directory": "packages/create-app"
11
+ },
12
+ "bin": {
13
+ "create-okjt-app": "bin/create-okjt-app.mjs"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test \"test/*.test.mjs\""
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }
package/src/answers.mjs CHANGED
@@ -1,93 +1,93 @@
1
- /**
2
- * What the installer asks, and the project it describes: names, colours,
3
- * the role preset, and features. Everything is checked here, so the
4
- * templates can trust it.
5
- */
6
-
7
- export const PRESETS = {
8
- full: 'superadmin › account owner / staff › owner / editor / viewer',
9
- standard: 'superadmin › owner / editor / viewer',
10
- simple: 'admin › user',
11
- };
12
-
13
- export const FEATURES = {
14
- google: 'Sign in with Google',
15
- passkeys: 'Passkeys',
16
- backups: 'Backups (superadmin)',
17
- console: 'Console with audit log (superadmin)',
18
- billing: 'Billing: plans and Paystack (needs accounts: the full preset)',
19
- };
20
-
21
- /** Features each preset can have: no superadmin in `simple`, no accounts outside `full`. */
22
- export function featuresFor(preset) {
23
- const all = Object.keys(FEATURES);
24
- if (preset === 'simple') return ['google', 'passkeys'];
25
- if (preset === 'standard') return all.filter((f) => f !== 'billing');
26
- return all;
27
- }
28
-
29
- const HEX = /^#[0-9a-f]{6}$/i;
30
-
31
- export const slugify = (text) => String(text).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
32
- const pascal = (text) => slugify(text).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
33
-
34
- /** Answers (from prompts or flags) → the project the templates are written from. */
35
- export function describeProject(answers) {
36
- const name = String(answers.name ?? '').trim();
37
- if (!name) throw new Error('A project name is needed.');
38
- const slug = slugify(answers.slug || name);
39
- if (!slug) throw new Error(`"${name}" gives no usable folder name; pass --slug.`);
40
-
41
- const preset = answers.preset || 'standard';
42
- if (!PRESETS[preset]) throw new Error(`Unknown role preset "${preset}" (${Object.keys(PRESETS).join(', ')}).`);
43
-
44
- const allowed = featuresFor(preset);
45
- const asked = (answers.features ?? []).filter(Boolean);
46
- const unknown = asked.filter((f) => !FEATURES[f]);
47
- if (unknown.length) throw new Error(`Unknown feature ${unknown.join(', ')} (${Object.keys(FEATURES).join(', ')}).`);
48
- const refused = asked.filter((f) => !allowed.includes(f));
49
- if (refused.length) throw new Error(`The ${preset} preset can't have ${refused.join(', ')}.`);
50
- const features = Object.fromEntries(Object.keys(FEATURES).map((f) => [f, asked.includes(f)]));
51
-
52
- const accent = answers.accent || '#2563EB';
53
- const ink = answers.ink || '#1F2937';
54
- for (const [label, value] of [['accent', accent], ['ink', ink]]) {
55
- if (!HEX.test(value)) throw new Error(`The ${label} colour must look like #1A2B3C (got "${value}").`);
56
- }
57
-
58
- const label = slugify(answers.workspace || 'project').replace(/-/g, ' ');
59
- const plural = answers.workspacePlural || (label.endsWith('s') ? label : `${label}s`);
60
- const Model = pascal(label);
61
- const key = slugify(label).replace(/-/g, '_');
62
-
63
- return {
64
- name,
65
- slug,
66
- domain: answers.domain || `${slug}.example.com`,
67
- tagline: answers.tagline || `${name} keeps your work in one place.`,
68
- preset,
69
- platform: preset !== 'simple',
70
- accounts: preset === 'full',
71
- features,
72
- colors: { accent: accent.toUpperCase(), ink: ink.toUpperCase(), tone: answers.tone || 'stone' },
73
- workspace: {
74
- label,
75
- plural,
76
- Model,
77
- table: `${key}s`,
78
- foreignKey: `${key}_id`,
79
- members: `${key}_user`,
80
- path: slugify(plural),
81
- },
82
- roles: preset === 'simple'
83
- ? [
84
- { value: 'owner', label: 'Admin', description: 'Can change everything, and invite people.' },
85
- { value: 'viewer', label: 'User', description: 'Can see everything, and change nothing.' },
86
- ]
87
- : [
88
- { value: 'owner', label: 'Owner', description: 'Can change everything, invite people and delete it.' },
89
- { value: 'editor', label: 'Editor', description: 'Can add and change things.' },
90
- { value: 'viewer', label: 'Viewer', description: 'Can see everything, and change nothing.' },
91
- ],
92
- };
93
- }
1
+ /**
2
+ * What the installer asks, and the project it describes: names, colours,
3
+ * the role preset, and features. Everything is checked here, so the
4
+ * templates can trust it.
5
+ */
6
+
7
+ export const PRESETS = {
8
+ full: 'superadmin › account owner / staff › owner / editor / viewer',
9
+ standard: 'superadmin › owner / editor / viewer',
10
+ simple: 'admin › user',
11
+ };
12
+
13
+ export const FEATURES = {
14
+ google: 'Sign in with Google',
15
+ passkeys: 'Passkeys',
16
+ backups: 'Backups (superadmin)',
17
+ console: 'Console with audit log (superadmin)',
18
+ billing: 'Billing: plans and Paystack (needs accounts: the full preset)',
19
+ };
20
+
21
+ /** Features each preset can have: no superadmin in `simple`, no accounts outside `full`. */
22
+ export function featuresFor(preset) {
23
+ const all = Object.keys(FEATURES);
24
+ if (preset === 'simple') return ['google', 'passkeys'];
25
+ if (preset === 'standard') return all.filter((f) => f !== 'billing');
26
+ return all;
27
+ }
28
+
29
+ const HEX = /^#[0-9a-f]{6}$/i;
30
+
31
+ export const slugify = (text) => String(text).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
32
+ const pascal = (text) => slugify(text).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
33
+
34
+ /** Answers (from prompts or flags) → the project the templates are written from. */
35
+ export function describeProject(answers) {
36
+ const name = String(answers.name ?? '').trim();
37
+ if (!name) throw new Error('A project name is needed.');
38
+ const slug = slugify(answers.slug || name);
39
+ if (!slug) throw new Error(`"${name}" gives no usable folder name; pass --slug.`);
40
+
41
+ const preset = answers.preset || 'standard';
42
+ if (!PRESETS[preset]) throw new Error(`Unknown role preset "${preset}" (${Object.keys(PRESETS).join(', ')}).`);
43
+
44
+ const allowed = featuresFor(preset);
45
+ const asked = (answers.features ?? []).filter(Boolean);
46
+ const unknown = asked.filter((f) => !FEATURES[f]);
47
+ if (unknown.length) throw new Error(`Unknown feature ${unknown.join(', ')} (${Object.keys(FEATURES).join(', ')}).`);
48
+ const refused = asked.filter((f) => !allowed.includes(f));
49
+ if (refused.length) throw new Error(`The ${preset} preset can't have ${refused.join(', ')}.`);
50
+ const features = Object.fromEntries(Object.keys(FEATURES).map((f) => [f, asked.includes(f)]));
51
+
52
+ const accent = answers.accent || '#2563EB';
53
+ const ink = answers.ink || '#1F2937';
54
+ for (const [label, value] of [['accent', accent], ['ink', ink]]) {
55
+ if (!HEX.test(value)) throw new Error(`The ${label} colour must look like #1A2B3C (got "${value}").`);
56
+ }
57
+
58
+ const label = slugify(answers.workspace || 'project').replace(/-/g, ' ');
59
+ const plural = answers.workspacePlural || (label.endsWith('s') ? label : `${label}s`);
60
+ const Model = pascal(label);
61
+ const key = slugify(label).replace(/-/g, '_');
62
+
63
+ return {
64
+ name,
65
+ slug,
66
+ domain: answers.domain || `${slug}.example.com`,
67
+ tagline: answers.tagline || `${name} keeps your work in one place.`,
68
+ preset,
69
+ platform: preset !== 'simple',
70
+ accounts: preset === 'full',
71
+ features,
72
+ colors: { accent: accent.toUpperCase(), ink: ink.toUpperCase(), tone: answers.tone || 'stone' },
73
+ workspace: {
74
+ label,
75
+ plural,
76
+ Model,
77
+ table: `${key}s`,
78
+ foreignKey: `${key}_id`,
79
+ members: `${key}_user`,
80
+ path: slugify(plural),
81
+ },
82
+ roles: preset === 'simple'
83
+ ? [
84
+ { value: 'owner', label: 'Admin', description: 'Can change everything, and invite people.' },
85
+ { value: 'viewer', label: 'User', description: 'Can see everything, and change nothing.' },
86
+ ]
87
+ : [
88
+ { value: 'owner', label: 'Owner', description: 'Can change everything, invite people and delete it.' },
89
+ { value: 'editor', label: 'Editor', description: 'Can add and change things.' },
90
+ { value: 'viewer', label: 'Viewer', description: 'Can see everything, and change nothing.' },
91
+ ],
92
+ };
93
+ }