@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 +10 -0
- package/README.md +63 -0
- package/build/cli.mjs +330 -0
- package/build/progress.mjs +40 -0
- package/build/project.mjs +237 -0
- package/build/system.mjs +182 -0
- package/build/template.json +1 -0
- package/package.json +45 -0
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
|
+
}
|