@adula/create-app 0.2.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ # The MIT License
2
+
3
+ Copyright (c) 2023
4
+ Copyright (c) 2026 adula contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @adula/create-app
2
+
3
+ Creates a complete, project-owned AdonisJS 7 application with adula, React/Inertia,
4
+ shadcn/ui, authentication, administration and the managed business-design skill.
5
+ Version 0.2.0-alpha.1 is **unreleased**; registry commands become available after publication.
6
+
7
+ ```sh
8
+ npm create @adula/app@alpha my-app
9
+ ```
10
+
11
+ Requires Node.js 24+ and npm. The default provisions PostgreSQL 17 and Redis 7
12
+ through an existing Docker installation with Compose. pnpm is bootstrapped through
13
+ npm and installed as a project-local development dependency; neither a global
14
+ pnpm installation nor a pre-existing AdonisJS application is required.
15
+
16
+ The wizard requests the company name, administrator email and company identity.
17
+ An optional identity JSON file contains `primaryColor` (six-digit hex), `logo`
18
+ (local PNG/JPEG/WebP path relative to the JSON), `fontFamily` and `guidelines`.
19
+ Known identity is applied immediately. Missing identity remains explicitly pending
20
+ in `docs/design-identity.md`; no official branding is invented.
21
+
22
+ Terminal prompts and errors use English so they remain readable in terminals
23
+ without Arabic shaping. The application stays Arabic. Installation shows six real
24
+ stages, with color and a spinner in interactive terminals. `NO_COLOR=1`, redirected
25
+ output and dumb terminals use plain progress lines. Detailed child-command output
26
+ is retained privately in ignored `tmp/install.log`; failures identify that log.
27
+
28
+ ```sh
29
+ npm create @adula/app@alpha my-app -- --company "My company" --admin-email admin@example.com --identity ./brand.json --yes
30
+ ```
31
+
32
+ For existing services, add `--services existing --connection ./local.json`:
33
+
34
+ ```json
35
+ {
36
+ "postgres": {
37
+ "host": "127.0.0.1",
38
+ "port": 5432,
39
+ "user": "adula",
40
+ "password": "YOUR_LOCAL_PASSWORD"
41
+ },
42
+ "redis": { "host": "127.0.0.1", "port": 6379 }
43
+ }
44
+ ```
45
+
46
+ Keep this file private. The PostgreSQL role needs `CREATEDB`; the command creates
47
+ fresh development/test databases. It refuses an existing destination directory
48
+ with content or a pre-existing database. Database failures preserve files/data
49
+ for inspection. Local administrator credentials are saved in ignored
50
+ `tmp/dev-admin.txt`, never printed. No educational business modules are installed.
51
+ An optional `--database name` selects a fresh database name (lowercase letters,
52
+ digits and underscores, at most 50 characters); an existing name is still refused.
53
+ Values containing dotenv interpolation or quote characters are stored in private
54
+ `tmp/env` files and loaded through AdonisJS's `file:` identifier without alteration.
55
+
56
+ After creation: `cd my-app` and `npm run dev`. Workers and scheduler are separate
57
+ runtime processes documented in the application README. SMTP, OAuth, S3, production
58
+ hosting and off-site backups need real configuration; no external accounts are
59
+ created. Node.js and Docker themselves are host prerequisites.
60
+
61
+ Before publication, run the packed CLI with npm exec and `--packages` pointing
62
+ to the directory containing matching `adula-kit-VERSION.tgz` and
63
+ `adula-ui-VERSION.tgz`. `pnpm test:create` exercises this path from an empty folder.
package/build/cli.mjs ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ import { Progress } from './progress.mjs'
3
+ import { parseArgs } from 'node:util'
4
+ import { createInterface } from 'node:readline/promises'
5
+ import { randomBytes } from 'node:crypto'
6
+ import { readFile, realpath } from 'node:fs/promises'
7
+ import { resolve, join, delimiter } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import {
10
+ assertEmpty,
11
+ projectName,
12
+ renderProject,
13
+ writeNew,
14
+ dotenv,
15
+ prepareEnvironment,
16
+ composeFile,
17
+ readIdentity,
18
+ readJson,
19
+ } from './project.mjs'
20
+ import {
21
+ run,
22
+ packageManager,
23
+ packageManagerBin,
24
+ freePort,
25
+ pingRedis,
26
+ createDatabases,
27
+ databaseNames,
28
+ childEnvironment,
29
+ } from './system.mjs'
30
+
31
+ export const help = `Create a new business application with AdonisJS and adula
32
+
33
+ npm create @adula/app@alpha my-app
34
+
35
+ --company "Company name" Company display name (Arabic supported)
36
+ --admin-email email Administrator email
37
+ --identity brand.json Company logo, primaryColor, fontFamily and guidelines
38
+ --services docker|existing Default: Docker; requires Docker Compose
39
+ --connection local.json PostgreSQL and Redis profile for existing services
40
+ --database name Fresh database name; existing databases are rejected
41
+ --packages directory Local kit and UI archives for pre-publication testing
42
+ --yes Noninteractive; requires company and administrator email
43
+ --help Show help
44
+
45
+ Requires Node.js 24+ and npm. Installs pnpm, AdonisJS, adula, the Arabic UI,
46
+ databases, administrator and agent skills. External SMTP, OAuth and S3 require
47
+ your configuration. Credentials are saved privately in tmp/dev-admin.txt.
48
+ Set NO_COLOR=1 for plain progress output.
49
+ `
50
+
51
+ function validateConnection(profile) {
52
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile))
53
+ throw new Error('The connection file must contain a JSON object.')
54
+ for (const key of ['postgres', 'redis']) {
55
+ const value = profile[key]
56
+ if (
57
+ !value ||
58
+ typeof value.host !== 'string' ||
59
+ !value.host ||
60
+ /[\r\n\0]/.test(value.host) ||
61
+ !Number.isInteger(value.port) ||
62
+ value.port < 1 ||
63
+ value.port > 65535
64
+ )
65
+ throw new Error(`Invalid ${key} connection: provide host and port.`)
66
+ if (value.password !== undefined && typeof value.password !== 'string')
67
+ throw new Error(`Invalid ${key} password.`)
68
+ }
69
+ if (
70
+ typeof profile.postgres.user !== 'string' ||
71
+ !profile.postgres.user ||
72
+ typeof profile.postgres.password !== 'string'
73
+ )
74
+ throw new Error('Provide PostgreSQL user and password. The account needs CREATEDB permission.')
75
+ if (profile.postgres.database || profile.postgres.testDatabase)
76
+ throw new Error(
77
+ 'Do not specify an existing database. Setup creates two fresh databases; maintenanceDatabase is only for the administrative connection.'
78
+ )
79
+ return profile
80
+ }
81
+
82
+ export async function main(argv = process.argv.slice(2)) {
83
+ const { values, positionals } = parseArgs({
84
+ args: argv.filter((arg) => arg !== '--'),
85
+ allowPositionals: true,
86
+ options: {
87
+ 'help': { type: 'boolean' },
88
+ 'yes': { type: 'boolean' },
89
+ 'company': { type: 'string' },
90
+ 'admin-email': { type: 'string' },
91
+ 'identity': { type: 'string' },
92
+ 'services': { type: 'string', default: 'docker' },
93
+ 'connection': { type: 'string' },
94
+ 'database': { type: 'string' },
95
+ 'packages': { type: 'string' },
96
+ },
97
+ })
98
+ if (values.help) {
99
+ console.log(help)
100
+ return
101
+ }
102
+ if (Number(process.versions.node.split('.')[0]) < 24)
103
+ throw new Error('Creating an application requires Node.js 24 or later.')
104
+ if (positionals.length > 1) throw new Error('Specify one project directory.')
105
+ if (values.database !== undefined && !/^[a-z][a-z0-9_]{0,49}$/.test(values.database))
106
+ throw new Error(
107
+ 'Database name must start with a lowercase letter and use lowercase letters, digits or underscores (up to 50 characters).'
108
+ )
109
+ if (!['docker', 'existing'].includes(values.services))
110
+ throw new Error('--services must be docker or existing.')
111
+ if (values.services === 'existing' && !values.connection)
112
+ throw new Error('Provide --connection when using existing services.')
113
+ if (values.services === 'docker' && values.connection)
114
+ throw new Error('--connection is only available for existing services.')
115
+ let directory = positionals[0]
116
+ let company = values.company
117
+ let email = values['admin-email']
118
+ let identityFile = values.identity
119
+ const interactive = process.stdin.isTTY && !values.yes
120
+ if (interactive) {
121
+ const prompt = createInterface({
122
+ input: process.stdin,
123
+ output: process.stdout,
124
+ })
125
+ try {
126
+ directory ||= await prompt.question('Application directory: ')
127
+ company ||= await prompt.question('Company name as it should appear in the application: ')
128
+ email ||= await prompt.question('Administrator email: ')
129
+ identityFile ||= await prompt.question(
130
+ 'Company identity JSON (logo, colors, fonts, guidelines; Enter to record as pending): '
131
+ )
132
+ } finally {
133
+ prompt.close()
134
+ }
135
+ }
136
+ if (!directory || !company || !email)
137
+ throw new Error('Provide a directory, --company and --admin-email, or run interactively.')
138
+ if (!/^[^\s@'\0]+@[^\s@'\0]+\.[^\s@'\0]+$/.test(email) || email.length > 254)
139
+ throw new Error('Invalid administrator email.')
140
+ const target = resolve(directory)
141
+ const name = projectName(target)
142
+ await assertEmpty(target)
143
+ const identity = await readIdentity(identityFile || undefined, company)
144
+ const template = JSON.parse(await readFile(new URL('./template.json', import.meta.url), 'utf8'))
145
+ const manager = await packageManager()
146
+ const suffix = randomBytes(6).toString('hex')
147
+ const names = values.database
148
+ ? { database: values.database, testDatabase: `${values.database}_test` }
149
+ : databaseNames(name, suffix)
150
+ const ports = new Set()
151
+ const allocatePort = async () => {
152
+ let port = await freePort()
153
+ while (ports.has(port)) port = await freePort()
154
+ ports.add(port)
155
+ return port
156
+ }
157
+ const env = childEnvironment()
158
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
159
+ env[pathKey] = [
160
+ await packageManagerBin(),
161
+ join(target, 'node_modules/.bin'),
162
+ env[pathKey] ?? '',
163
+ ].join(delimiter)
164
+ let profile
165
+ const progress = new Progress()
166
+ try {
167
+ progress.start('Check services and prepare configuration')
168
+ if (values.services === 'docker') {
169
+ await run('docker', ['compose', 'version'], { env, quiet: true })
170
+ await run('docker', ['info'], { env, quiet: true })
171
+ profile = {
172
+ postgres: {
173
+ host: '127.0.0.1',
174
+ port: await allocatePort(),
175
+ user: 'adula',
176
+ password: randomBytes(24).toString('hex'),
177
+ },
178
+ redis: { host: '127.0.0.1', port: await allocatePort() },
179
+ }
180
+ } else {
181
+ profile = validateConnection(await readJson(resolve(values.connection), 'connection'))
182
+ await pingRedis(profile.redis)
183
+ }
184
+ const port = await allocatePort()
185
+ const testPort = await allocatePort()
186
+ const appEnv = {
187
+ TZ: 'UTC',
188
+ NODE_ENV: 'development',
189
+ HOST: '127.0.0.1',
190
+ PORT: port,
191
+ LOG_LEVEL: 'info',
192
+ APP_KEY: randomBytes(32).toString('base64url'),
193
+ APP_URL: `http://127.0.0.1:${port}`,
194
+ ADULA_NAMESPACE: `${name}-${suffix}`,
195
+ COMPOSE_PROJECT_NAME: `${name}-${suffix}`,
196
+ SESSION_DRIVER: 'database',
197
+ DB_HOST: profile.postgres.host,
198
+ DB_PORT: profile.postgres.port,
199
+ DB_USER: profile.postgres.user,
200
+ DB_PASSWORD: profile.postgres.password,
201
+ DB_DATABASE: names.database,
202
+ REDIS_HOST: profile.redis.host,
203
+ REDIS_PORT: profile.redis.port,
204
+ ...(profile.redis.password ? { REDIS_PASSWORD: profile.redis.password } : {}),
205
+ DRIVE_DISK: 'local',
206
+ LIMITER_STORE: 'redis',
207
+ MAIL_MAILER: 'smtp',
208
+ MAIL_FROM_NAME: identity.company,
209
+ MAIL_FROM_ADDRESS: email,
210
+ SMTP_HOST: '127.0.0.1',
211
+ SMTP_PORT: 1025,
212
+ }
213
+ // Validate all local archives before the first write.
214
+ if (values.packages)
215
+ for (const kind of ['kit', 'ui'])
216
+ await readFile(resolve(values.packages, `adula-${kind}-${template.version}.tgz`))
217
+ progress.start('Create AdonisJS application and company identity')
218
+ await renderProject(target, template, {
219
+ name,
220
+ identity,
221
+ packageFiles: values.packages,
222
+ })
223
+ const encodedEnv = await prepareEnvironment(target, appEnv)
224
+ await writeNew(target, '.env', dotenv(encodedEnv), true)
225
+ await writeNew(
226
+ target,
227
+ '.env.test',
228
+ dotenv({
229
+ ...encodedEnv,
230
+ NODE_ENV: 'test',
231
+ PORT: testPort,
232
+ APP_URL: `http://127.0.0.1:${testPort}`,
233
+ APP_KEY: randomBytes(32).toString('base64url'),
234
+ DB_DATABASE: names.testDatabase,
235
+ }),
236
+ true
237
+ )
238
+ await writeNew(
239
+ target,
240
+ '.env.example',
241
+ dotenv({
242
+ ...encodedEnv,
243
+ APP_KEY: '',
244
+ DB_PASSWORD: '',
245
+ ...(profile.redis.password ? { REDIS_PASSWORD: '' } : {}),
246
+ })
247
+ )
248
+ const password = randomBytes(24).toString('base64url')
249
+ await writeNew(
250
+ target,
251
+ 'tmp/dev-admin.txt',
252
+ `Local development administrator\nEmail: ${email}\nPassword: ${password}\n`,
253
+ true
254
+ )
255
+ await writeNew(
256
+ target,
257
+ 'README.md',
258
+ `# ${name}\n\nCreated with @adula/create-app ${template.version}. Node.js 24+ is required.\n\n## Local development\n\n\`\`\`sh\nnpm run dev\n\`\`\`\n\nOpen ${appEnv.APP_URL}. Administrator credentials are in ignored tmp/dev-admin.txt. After signing in, open /admin/setup and follow docs/initial-setup.md to review identity and verify service readiness.\nRun workers separately with node ace adula:worker, the outbox dispatcher with node ace adula:outbox, and the scheduler with node ace scheduler:run.\n\n${values.services === 'docker' ? 'PostgreSQL 17 and Redis 7 run in Docker. Start them with docker compose up -d --wait; stop with docker compose stop. Named volumes retain data; never use down -v unless deliberately deleting the local databases.' : 'PostgreSQL 17 and Redis use your existing services. Installation created fresh databases; keep .env and .env.test private.'}\n\n## Verification\n\nRun npm run typecheck, npm test, npm run lint and npm run build. Tests use the separate *_test database and a distinct Redis namespace.\n\n## Company identity\n\nRead docs/design-identity.md and the managed design skill before changing UI. Source files and shadcn components belong to this application.\n\n## External services and production\n\nLocal file storage is enabled. SMTP (including a local relay), OAuth provider credentials, S3, off-site backups, and production deployment need actual destination configuration. The creator does not enable unconfigured external services. Production requires backup variables validated in start/env.ts.\n\n## Incomplete installation\n\nReview the reported failing step; files and databases are retained. After resolving it, use npm exec --yes --package=pnpm@11.19.0 -- pnpm install, node ace migration:run, and node ace adula:setup. Setup reads tmp/dev-admin.txt; it refuses to promote an existing account with a different password. Then run node ace adula:doctor and npm run build. Never recreate over a nonempty directory.\n`
259
+ )
260
+ await writeNew(target, 'tmp/install.log', '', true)
261
+ const options = {
262
+ cwd: target,
263
+ env,
264
+ logFile: join(target, 'tmp/install.log'),
265
+ }
266
+ progress.start('Install AdonisJS, adula and UI dependencies')
267
+ await run(manager[0], [...manager.slice(1), 'install', '--prod=false'], options)
268
+ progress.start('Start services and create fresh databases')
269
+ if (values.services === 'docker') {
270
+ await writeNew(target, 'compose.yaml', composeFile())
271
+ await run(
272
+ 'docker',
273
+ ['compose', '--env-file', '.env', 'up', '-d', '--wait', '--wait-timeout', '120'],
274
+ options
275
+ )
276
+ await pingRedis(profile.redis)
277
+ }
278
+ await createDatabases(profile.postgres, names)
279
+ progress.start('Apply migrations and configure administrator, UI and skills')
280
+ await run(process.execPath, ['ace', 'codegen'], options)
281
+ await run(process.execPath, ['ace', 'migration:run', '--force'], options)
282
+ await run(process.execPath, ['ace', 'adula:setup'], options)
283
+ const sourceFiles = [
284
+ ...Object.keys(template.files).filter((path) => /\.(?:ts|tsx|js|json|css)$/.test(path)),
285
+ 'inertia/brand.ts',
286
+ 'inertia/css/brand.css',
287
+ ]
288
+ await run(
289
+ manager[0],
290
+ [...manager.slice(1), 'exec', 'prettier', '--write', ...sourceFiles],
291
+ options
292
+ )
293
+ await run(process.execPath, ['ace', 'adula:doctor'], options)
294
+ progress.start('Typecheck and build the application')
295
+ await run(manager[0], [...manager.slice(1), 'run', 'typecheck'], options)
296
+ await run(manager[0], [...manager.slice(1), 'run', 'build'], options)
297
+ await writeNew(
298
+ target,
299
+ 'adula-setup.json',
300
+ JSON.stringify(
301
+ {
302
+ version: template.version,
303
+ completed: true,
304
+ services: values.services,
305
+ database: names.database,
306
+ testDatabase: names.testDatabase,
307
+ },
308
+ null,
309
+ 2
310
+ ) + '\n'
311
+ )
312
+ progress.finish()
313
+ console.log(
314
+ `\nYour application is ready: ${target}\nAdministrator credentials: tmp/dev-admin.txt\nInstallation log: tmp/install.log\nNext: cd "${target}"\n npm run dev\nOpen: ${appEnv.APP_URL}\n`
315
+ )
316
+ } catch (error) {
317
+ progress.finish(true)
318
+ throw error
319
+ }
320
+ }
321
+
322
+ // npm bin entries are symlinks on Linux. Node resolves the imported module URL,
323
+ // but argv retains the link path; compare their real paths before starting.
324
+ if (process.argv[1] && import.meta.url === pathToFileURL(await realpath(process.argv[1])).href)
325
+ main().catch((error) => {
326
+ console.error(
327
+ `\nSetup did not complete: ${error.message}\nCreated files and databases are retained so you can inspect and resume setup.`
328
+ )
329
+ process.exitCode = 1
330
+ })
@@ -0,0 +1,40 @@
1
+ /** Portable terminal progress. Redirected output and NO_COLOR stay plain and stable. */
2
+ export class Progress {
3
+ constructor(output = process.stdout, env = process.env) {
4
+ this.output = output
5
+ this.animated = Boolean(output.isTTY && !('NO_COLOR' in env) && env.TERM !== 'dumb')
6
+ this.index = 0
7
+ this.total = 6
8
+ this.timer = undefined
9
+ this.current = undefined
10
+ }
11
+
12
+ start(label) {
13
+ this.finish()
14
+ this.current = label
15
+ this.index += 1
16
+ this.started = Date.now()
17
+ let frame = 0
18
+ const draw = () =>
19
+ this.output.write(
20
+ `\r\x1b[2K\x1b[36m${['|', '/', '-', '\\'][frame++ % 4]} [${this.index}/${this.total}]\x1b[0m ${label} (${Math.floor((Date.now() - this.started) / 1000)}s)`
21
+ )
22
+ if (this.animated) {
23
+ draw()
24
+ this.timer = setInterval(draw, 100)
25
+ this.timer.unref()
26
+ } else this.output.write(`[${this.index}/${this.total}] ${label}\n`)
27
+ }
28
+
29
+ finish(failed = false) {
30
+ clearInterval(this.timer)
31
+ this.timer = undefined
32
+ if (!this.current) return
33
+ const prefix = this.animated ? `\r\x1b[2K\x1b[${failed ? '31' : '32'}m` : ''
34
+ const suffix = this.animated ? '\x1b[0m' : ''
35
+ this.output.write(
36
+ `${prefix}${failed ? 'FAIL' : 'OK'} [${this.index}/${this.total}] ${this.current}${suffix}\n`
37
+ )
38
+ this.current = undefined
39
+ }
40
+ }
@@ -0,0 +1,237 @@
1
+ import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, isAbsolute, relative, resolve, join } from 'node:path'
3
+
4
+ export function projectName(target) {
5
+ const name = basename(resolve(target))
6
+ if (!/^[a-z][a-z0-9-]{0,49}$/.test(name))
7
+ throw new Error(
8
+ 'Project directory name must start with a lowercase letter and use lowercase letters, digits or hyphens (up to 50 characters).'
9
+ )
10
+ return name
11
+ }
12
+
13
+ export async function assertEmpty(target) {
14
+ try {
15
+ const info = await lstat(target)
16
+ if (!info.isDirectory() || info.isSymbolicLink() || (await readdir(target)).length)
17
+ throw new Error(
18
+ 'Project directory is nonempty or a symbolic link. Choose a new directory; existing files will not be overwritten.'
19
+ )
20
+ } catch (error) {
21
+ if (error.code !== 'ENOENT') throw error
22
+ }
23
+ }
24
+
25
+ export async function writeNew(root, path, text, secret = false) {
26
+ if (!path || path.includes('\\') || path.split('/').includes('..') || isAbsolute(path))
27
+ throw new Error('Unsafe template path.')
28
+ const dest = resolve(root, path)
29
+ const rel = relative(resolve(root), dest)
30
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('Unsafe template path.')
31
+ // Reject symlink parents as well as final symlinks; all writes use O_EXCL.
32
+ let parent = dirname(dest)
33
+ while (parent !== dirname(resolve(root))) {
34
+ try {
35
+ if ((await lstat(parent)).isSymbolicLink())
36
+ throw new Error('Cannot write through a symbolic link.')
37
+ } catch (error) {
38
+ if (error.code !== 'ENOENT') throw error
39
+ }
40
+ if (parent === resolve(root)) break
41
+ parent = dirname(parent)
42
+ }
43
+ await mkdir(dirname(dest), { recursive: true })
44
+ await writeFile(dest, text, {
45
+ flag: 'wx',
46
+ ...(secret ? { mode: 0o600 } : {}),
47
+ })
48
+ }
49
+
50
+ export function dotenv(values) {
51
+ return (
52
+ Object.entries(values)
53
+ .map(([key, value]) => {
54
+ const text = String(value)
55
+ // Adonis interpolates dollars even inside quotes. Complex values are stored
56
+ // with its file: identifier by prepareEnvironment, preserving exact bytes.
57
+ if (/[\r\n\0'$\\]/.test(text))
58
+ throw new Error(
59
+ `Value for ${key} contains unsupported characters for direct environment encoding.`
60
+ )
61
+ return `${key}='${text}'`
62
+ })
63
+ .join('\n') + '\n'
64
+ )
65
+ }
66
+
67
+ export async function prepareEnvironment(target, values) {
68
+ const encoded = { ...values }
69
+ for (const [key, value] of Object.entries(values)) {
70
+ if (/[\r\n\0'$\\]/.test(String(value)) || String(value).startsWith('file:')) {
71
+ if (String(value).includes('\0')) throw new Error(`Invalid value for ${key}.`)
72
+ await writeNew(target, `tmp/env/${key}`, String(value), true)
73
+ encoded[key] = `file:./tmp/env/${key}`
74
+ }
75
+ }
76
+ return encoded
77
+ }
78
+
79
+ export function composeFile() {
80
+ return `services:
81
+ postgres:
82
+ image: postgres:17
83
+ environment:
84
+ POSTGRES_USER: adula
85
+ POSTGRES_PASSWORD: \${DB_PASSWORD}
86
+ POSTGRES_DB: postgres
87
+ ports: ["127.0.0.1:\${DB_PORT}:5432"]
88
+ volumes: ["postgres-data:/var/lib/postgresql/data"]
89
+ command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]
90
+ healthcheck:
91
+ test: ["CMD-SHELL", "pg_isready -U adula -d postgres"]
92
+ interval: 2s
93
+ timeout: 5s
94
+ retries: 30
95
+ redis:
96
+ image: redis:7
97
+ ports: ["127.0.0.1:\${REDIS_PORT}:6379"]
98
+ volumes: ["redis-data:/data"]
99
+ command: ["redis-server", "--appendonly", "yes"]
100
+ healthcheck:
101
+ test: ["CMD", "redis-cli", "ping"]
102
+ interval: 2s
103
+ timeout: 5s
104
+ retries: 30
105
+ volumes:
106
+ postgres-data:
107
+ redis-data:
108
+ `
109
+ }
110
+
111
+ export async function readIdentity(file, company) {
112
+ const identity = file ? await readJson(resolve(file), 'identity') : {}
113
+ if (!identity || typeof identity !== 'object' || Array.isArray(identity))
114
+ throw new Error('Identity file must contain a JSON object.')
115
+ if (
116
+ Object.keys(identity).some(
117
+ (key) => !['primaryColor', 'fontFamily', 'logo', 'guidelines'].includes(key)
118
+ )
119
+ )
120
+ throw new Error('Identity file accepts only primaryColor, fontFamily, logo and guidelines.')
121
+ if (
122
+ typeof company !== 'string' ||
123
+ !company.trim() ||
124
+ company.length > 150 ||
125
+ /[\r\n\0]/.test(company)
126
+ )
127
+ throw new Error('Company name is required (up to 150 characters, without newlines).')
128
+ for (const key of ['primaryColor', 'fontFamily', 'logo', 'guidelines'])
129
+ if (identity[key] !== undefined && typeof identity[key] !== 'string')
130
+ throw new Error(`Identity field ${key} must be a string.`)
131
+ if (identity.primaryColor && !/^#[\da-fA-F]{6}$/.test(identity.primaryColor))
132
+ throw new Error('Brand color must be a hex color such as #14532d.')
133
+ if (identity.fontFamily && !/^[\p{L}\p{N} _,-]{1,100}$/u.test(identity.fontFamily))
134
+ throw new Error('Invalid font family.')
135
+ if (identity.logo) {
136
+ const path = resolve(dirname(resolve(file)), identity.logo)
137
+ if (!/\.(png|jpe?g|webp)$/i.test(path)) throw new Error('Use a local PNG, JPEG or WebP logo.')
138
+ const stat = await lstat(path)
139
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 5 * 1024 * 1024)
140
+ throw new Error('Logo must be an image file up to 5 MB.')
141
+ identity.logoBytes = await readFile(path)
142
+ identity.logoPath = `/brand/logo.${path.split('.').at(-1).toLowerCase()}`
143
+ }
144
+ return { ...identity, company: company.trim() }
145
+ }
146
+
147
+ export async function readJson(file, label) {
148
+ const text = await readFile(file, 'utf8')
149
+ try {
150
+ return JSON.parse(text)
151
+ } catch {
152
+ throw new Error(`The ${label} file is not valid JSON. Review the file locally.`)
153
+ }
154
+ }
155
+
156
+ export function brandCss(identity) {
157
+ let css = '\n/* Company identity supplied during project creation. */\n'
158
+ if (identity.primaryColor) {
159
+ const channels = identity.primaryColor
160
+ .slice(1)
161
+ .match(/../g)
162
+ .map((hex) => parseInt(hex, 16) / 255)
163
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
164
+ const luminance = channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
165
+ const foreground = luminance > 0.179 ? '#000000' : '#ffffff'
166
+ css += `:root, .dark { --primary: ${identity.primaryColor}; --primary-foreground: ${foreground}; --ring: ${identity.primaryColor}; }\n`
167
+ }
168
+ if (identity.fontFamily)
169
+ css += `@theme { --font-sans: "${identity.fontFamily}", "Noto Sans Arabic", sans-serif; }\n`
170
+ return css
171
+ }
172
+
173
+ export async function renderProject(target, template, { name, identity, packageFiles }) {
174
+ const files = { ...template.files }
175
+ const pkg = JSON.parse(files['package.json'])
176
+ pkg.name = name
177
+ if (packageFiles) {
178
+ // Acceptance tests and offline releases can use reviewed local tarballs.
179
+ // They are copied into the new app, so no monorepo links survive.
180
+ for (const kind of ['kit', 'ui']) {
181
+ const archive = resolve(packageFiles, `adula-${kind}-${template.version}.tgz`)
182
+ await writeNew(target, `.adula-packages/adula-${kind}.tgz`, await readFile(archive))
183
+ pkg.dependencies[`@adula/${kind}`] = `file:.adula-packages/adula-${kind}.tgz`
184
+ }
185
+ }
186
+ files['package.json'] = JSON.stringify(pkg, null, 2) + '\n'
187
+ const brand = { company: identity.company, logo: identity.logoPath ?? null }
188
+ files['company-identity.json'] = JSON.stringify(brand, null, 2) + '\n'
189
+ files['inertia/brand.ts'] = `export const brand = ${JSON.stringify(brand, null, 2)}\n`
190
+ for (const path of [
191
+ 'inertia/layouts/default.tsx',
192
+ 'inertia/layouts/workspace.tsx',
193
+ 'inertia/pages/home.tsx',
194
+ ]) {
195
+ files[path] = `import { brand } from '~/brand'\n` + files[path]
196
+ }
197
+ files['inertia/layouts/default.tsx'] = files['inertia/layouts/default.tsx']
198
+ .replace('aria-label="adula kit — الرئيسية"', 'aria-label={brand.company}')
199
+ .replace(
200
+ '<strong dir="ltr">adula kit</strong>',
201
+ '<strong>{brand.logo && <img src={brand.logo} alt="" className="inline-block size-8 object-contain" />} {brand.company}</strong>'
202
+ )
203
+ files['inertia/layouts/workspace.tsx'] = files['inertia/layouts/workspace.tsx']
204
+ .replaceAll('>عدولة<', '>{brand.company}<')
205
+ .replace('التطبيق المرجعي', '{brand.company}')
206
+ .replace(
207
+ ' ع\n',
208
+ ' {brand.logo ? <img src={brand.logo} alt="" className="size-10 object-contain" /> : brand.company.charAt(0)}\n'
209
+ )
210
+ .replace('text-white', 'text-primary-foreground')
211
+ files['inertia/pages/home.tsx'] = files['inertia/pages/home.tsx'].replace(
212
+ 'title="مساحة العمل · adula kit"',
213
+ 'title={brand.company}'
214
+ )
215
+ files['inertia/app.tsx'] =
216
+ `import { brand } from './brand'\n` +
217
+ files['inertia/app.tsx'].replace(
218
+ "import.meta.env.VITE_APP_NAME || 'adula kit'",
219
+ 'brand.company'
220
+ )
221
+ files['docs/design-identity.md'] =
222
+ `# Company identity\n\nSource: project creator, supplied during installation.\n\n- Company: ${identity.company}\n- Logo: ${identity.logoPath ?? 'Pending; request it before further visual design.'}\n- Primary color: ${identity.primaryColor ?? 'Pending; existing kit colors are provisional.'}\n- Font: ${identity.fontFamily ?? 'Pending; Noto Sans Arabic is provisional.'}\n- Guidelines: ${identity.guidelines ?? 'Pending; request the company brand guide.'}\n\nUse the bundled adula-frontend-design skill for business interfaces. Keep shadcn/ui and Dialog defaults. A specified font must be licensed and installed locally or added as a project-owned asset; do not fetch unprovided fonts or branding.\n`
223
+ files['AGENTS.md'] =
224
+ '# Project rules\n\nRead docs/design-identity.md before visual design. Request only missing identity items. Application code and UI belong to this project; never edit installed @adula packages.\n'
225
+ for (const [path, contents] of Object.entries(files)) await writeNew(target, path, contents)
226
+ if (identity.logoBytes) await writeNew(target, `public${identity.logoPath}`, identity.logoBytes)
227
+ await writeNew(target, 'inertia/css/brand.css', brandCss(identity))
228
+ // Applied after the kit tokens, including after subsequent adula:ui upgrades.
229
+ const app = join(target, 'inertia/app.tsx')
230
+ await writeFile(
231
+ app,
232
+ (await readFile(app, 'utf8')).replace(
233
+ "import './css/kit.css'",
234
+ "import './css/kit.css'\nimport './css/brand.css'"
235
+ )
236
+ )
237
+ }