create-okjt-app 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -0,0 +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,6 +1,29 @@
1
- {
2
- "name": "create-okjt-app",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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
+ }
@@ -0,0 +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
+ }
package/src/create.mjs ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Writes the project and installs it: the React app from templates, the
3
+ * Laravel API from `laravel/laravel` with the kit on top, then checks both
4
+ * (the API's tests, the app's build).
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { backendFiles } from './templates/backend.mjs';
11
+ import { frontendFiles } from './templates/frontend.mjs';
12
+ import { rootFiles } from './templates/root.mjs';
13
+
14
+ export function writeFiles(dir, files) {
15
+ for (const [path, body] of Object.entries(files)) {
16
+ const target = join(dir, path);
17
+ mkdirSync(dirname(target), { recursive: true });
18
+ writeFileSync(target, body);
19
+ }
20
+ }
21
+
22
+ function run(command, args, cwd, log) {
23
+ log(` $ ${command} ${args.join(' ')}`);
24
+ const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
25
+ if (result.error) throw new Error(`${command} isn't available (${result.error.code}). Install it and try again.`);
26
+ if (result.status !== 0) throw new Error(`"${command} ${args.join(' ')}" failed in ${cwd}.`);
27
+ }
28
+
29
+ /** Sets keys in an .env file: replaces the ones it has, adds the rest at the end. */
30
+ export function setEnv(text, values) {
31
+ let out = text.endsWith('\n') ? text : `${text}\n`;
32
+ const added = [];
33
+ for (const [key, value] of Object.entries(values)) {
34
+ const line = `${key}=${value}`;
35
+ const pattern = new RegExp(`^#?\\s*${key}=.*$`, 'm');
36
+ if (pattern.test(out)) out = out.replace(pattern, () => line);
37
+ else added.push(line);
38
+ }
39
+ return added.length ? `${out}\n${added.join('\n')}\n` : out;
40
+ }
41
+
42
+ /** The .env settings the kit and its features read. */
43
+ export function envValues(p, { example = false } = {}) {
44
+ const quote = (v) => (/\s/.test(v) ? `"${v}"` : v);
45
+ return {
46
+ APP_NAME: quote(p.name),
47
+ APP_URL: 'http://localhost:8000',
48
+ FRONTEND_URL: 'http://localhost:5173',
49
+ OKJT_ADMIN_NAME: quote(`${p.name} admin`),
50
+ OKJT_ADMIN_EMAIL: `admin@${p.domain}`,
51
+ OKJT_ADMIN_PASSWORD: example ? '' : randomBytes(9).toString('base64url'),
52
+ ...(p.features.backups ? { AUTO_BACKUPS: 'true' } : {}),
53
+ ...(p.features.billing ? { BILLING_ENABLED: 'false', BILLING_CURRENCY: 'KES', PAYSTACK_SECRET_KEY: '', PAYSTACK_PUBLIC_KEY: '' } : {}),
54
+ ...(p.features.google ? { GOOGLE_CLIENT_ID: '', GOOGLE_CLIENT_SECRET: '', GOOGLE_REDIRECT_URI: '"${APP_URL}/api/auth/google/callback"' } : {}),
55
+ };
56
+ }
57
+
58
+ /** Laravel's own files the kit needs a line in (kept, not replaced). */
59
+ export function patchLaravel(backend, p) {
60
+ const appConfig = join(backend, 'config/app.php');
61
+ const app = readFileSync(appConfig, 'utf8');
62
+ if (!app.includes("'frontend_url'")) {
63
+ writeFileSync(appConfig, app.replace(/(\n\s*'url' => env\('APP_URL'[^\n]*\n)/, (m) => `${m}\n // Where the React app is (links in emails, Google sign-in's return).\n 'frontend_url' => env('FRONTEND_URL', env('APP_URL')),\n`));
64
+ }
65
+ if (p.features.google) {
66
+ const servicesPath = join(backend, 'config/services.php');
67
+ const services = readFileSync(servicesPath, 'utf8');
68
+ if (!services.includes("'google'")) {
69
+ writeFileSync(servicesPath, services.replace(/\n\];\s*$/, "\n\n 'google' => [\n 'client_id' => env('GOOGLE_CLIENT_ID'),\n 'client_secret' => env('GOOGLE_CLIENT_SECRET'),\n 'redirect' => env('GOOGLE_REDIRECT_URI'),\n ],\n\n];\n"));
70
+ }
71
+ }
72
+ for (const [file, example] of [['.env', false], ['.env.example', true]]) {
73
+ const path = join(backend, file);
74
+ if (existsSync(path)) writeFileSync(path, setEnv(readFileSync(path, 'utf8'), envValues(p, { example })));
75
+ }
76
+ }
77
+
78
+ export async function createProject(p, { dir, install = true, checks = true, git = true, log = console.log }) {
79
+ if (existsSync(dir) && readdirSync(dir).length) throw new Error(`${dir} already exists and isn't empty.`);
80
+ mkdirSync(dir, { recursive: true });
81
+ const backend = join(dir, 'backend');
82
+ const frontend = join(dir, 'frontend');
83
+
84
+ log(`\nWriting ${p.name} in ${dir}`);
85
+ writeFiles(dir, rootFiles(p));
86
+ writeFiles(frontend, frontendFiles(p));
87
+
88
+ if (!install) {
89
+ writeFiles(backend, backendFiles(p));
90
+ log("Files written (--no-install: backend/ holds only the kit's files, without Laravel).");
91
+ return;
92
+ }
93
+
94
+ log('\nThe API: Laravel 12 with the OKJT kit');
95
+ run('composer', ['create-project', 'laravel/laravel:^12.0', 'backend', '--no-interaction', '--prefer-dist', '--quiet'], dir, log);
96
+ run('php', ['artisan', 'install:api', '--without-migration-prompt', '--no-interaction'], backend, log);
97
+ const packages = ['okjt/laravel-kit:^0.2', 'bacon/bacon-qr-code:^3.0'];
98
+ if (p.features.google) packages.push('laravel/socialite:^5.0');
99
+ if (p.features.passkeys) packages.push('laravel/passkeys:^0.2');
100
+ run('composer', ['require', ...packages, '--no-interaction', '--quiet'], backend, log);
101
+ writeFiles(backend, backendFiles(p));
102
+ patchLaravel(backend, p);
103
+ run('php', ['artisan', 'vendor:publish', '--tag=okjt-migrations', '--no-interaction'], backend, log);
104
+ if (p.features.passkeys) run('php', ['artisan', 'vendor:publish', '--tag=passkeys-migrations', '--no-interaction'], backend, log);
105
+ run('php', ['artisan', 'migrate', '--force', '--no-interaction'], backend, log);
106
+ run('php', ['artisan', 'db:seed', '--force', '--no-interaction'], backend, log);
107
+
108
+ log('\nThe app: React on @okjt/site and @okjt/dashboard');
109
+ run('npm', ['install', '--no-audit', '--no-fund'], frontend, log);
110
+
111
+ if (checks) {
112
+ log('\nChecking it works');
113
+ run('php', ['artisan', 'test'], backend, log);
114
+ run('npm', ['run', 'build'], frontend, log);
115
+ }
116
+
117
+ if (git && !existsSync(join(dir, '.git')) && spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: dir }).status === 0) {
118
+ spawnSync('git', ['add', '-A'], { cwd: dir });
119
+ spawnSync('git', ['commit', '-q', '-m', `${p.name}: created with create-okjt-app`], { cwd: dir });
120
+ }
121
+
122
+ const env = readFileSync(join(backend, '.env'), 'utf8');
123
+ log(`
124
+ ${p.name} is ready.
125
+
126
+ cd ${dir}/backend && php artisan serve
127
+ cd ${dir}/frontend && npm run dev → http://localhost:5173
128
+
129
+ Sign in as ${env.match(/^OKJT_ADMIN_EMAIL=(.*)$/m)?.[1]} with the password ${env.match(/^OKJT_ADMIN_PASSWORD=(.*)$/m)?.[1]} (in backend/.env).
130
+ Deploying: README.md › Deploy.
131
+ `);
132
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The theme's seventeen colours (@okjt/ui's tokens) from the two a project
3
+ * chooses: its accent and its ink (the darkest text). Lighter and darker
4
+ * shades are mixes towards white and black, so the palette stays in one
5
+ * family; edit okjt.config.js to fine-tune any of them.
6
+ */
7
+
8
+ const toRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
9
+ const toHex = (rgb) => `#${rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('')}`.toUpperCase();
10
+
11
+ /** `amount` of the way from `hex` to `towards` (0 = hex, 1 = towards). */
12
+ export function mix(hex, towards, amount) {
13
+ const a = toRgb(hex);
14
+ const b = toRgb(towards);
15
+ return toHex(a.map((v, i) => v + (b[i] - v) * amount));
16
+ }
17
+
18
+ const WHITE = '#FFFFFF';
19
+ const BLACK = '#000000';
20
+
21
+ export function palette({ accent, ink, tone = 'stone' }) {
22
+ return {
23
+ ink,
24
+ inkDeep: mix(ink, BLACK, 0.3),
25
+ body: mix(ink, WHITE, 0.15),
26
+ soft: mix(ink, WHITE, 0.3),
27
+ muted: mix(ink, WHITE, 0.45),
28
+ faint: mix(ink, WHITE, 0.58),
29
+ accent,
30
+ accentStrong: mix(accent, BLACK, 0.1),
31
+ accentDark: mix(accent, BLACK, 0.25),
32
+ accentSoft: mix(accent, WHITE, 0.4),
33
+ line: mix(accent, WHITE, 0.85),
34
+ lineStrong: mix(accent, WHITE, 0.75),
35
+ tint: mix(accent, WHITE, 0.93),
36
+ paper: mix(accent, WHITE, 0.975),
37
+ paperWarm: mix(accent, WHITE, 0.96),
38
+ positive: '#5B8A5B',
39
+ positiveDark: '#3F6B3F',
40
+ tone,
41
+ };
42
+ }