@vevedh/create-feathers-app 0.0.0-stage → 0.1.0-rc.4
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 +21 -0
- package/README.md +79 -2
- package/bin/create-feathers-app.mjs +44 -0
- package/lib/planner/options.mjs +74 -0
- package/lib/planner/plan.mjs +107 -0
- package/lib/scaffold.mjs +300 -0
- package/lib/services.mjs +110 -0
- package/package.json +32 -4
- package/templates/common/dev-all-quasar-ssr.mjs +37 -0
- package/templates/common/dev-all.mjs +46 -0
- package/templates/daisyui-ssg/.env.example +6 -0
- package/templates/daisyui-ssg/README.md +21 -0
- package/templates/daisyui-ssg/api/server.mjs +45 -0
- package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssg/gitignore.template +36 -0
- package/templates/daisyui-ssg/index.html +2 -0
- package/templates/daisyui-ssg/package.json +39 -0
- package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
- package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
- package/templates/daisyui-ssg/src/App.vue +14 -0
- package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssg/src/entry-client.ts +9 -0
- package/templates/daisyui-ssg/src/entry-server.ts +9 -0
- package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssg/src/style.css +5 -0
- package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssg/tsconfig.json +4 -0
- package/templates/daisyui-ssg/vite.config.ts +6 -0
- package/templates/daisyui-ssr/.env.example +6 -0
- package/templates/daisyui-ssr/README.md +21 -0
- package/templates/daisyui-ssr/api/server.mjs +45 -0
- package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
- package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
- package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
- package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
- package/templates/daisyui-ssr/gitignore.template +36 -0
- package/templates/daisyui-ssr/index.html +2 -0
- package/templates/daisyui-ssr/package.json +39 -0
- package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
- package/templates/daisyui-ssr/src/App.vue +14 -0
- package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
- package/templates/daisyui-ssr/src/entry-client.ts +9 -0
- package/templates/daisyui-ssr/src/entry-server.ts +9 -0
- package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
- package/templates/daisyui-ssr/src/style.css +5 -0
- package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
- package/templates/daisyui-ssr/tsconfig.json +4 -0
- package/templates/daisyui-ssr/vite.config.ts +7 -0
- package/templates/quasar-ssr/.env.example +11 -0
- package/templates/quasar-ssr/api/server.mjs +45 -0
- package/templates/quasar-ssr/api/services/health/health.class.mjs +3 -0
- package/templates/quasar-ssr/api/services/health/health.service.mjs +4 -0
- package/templates/quasar-ssr/api/services/notes/notes.class.mjs +25 -0
- package/templates/quasar-ssr/api/services/notes/notes.service.mjs +10 -0
- package/templates/quasar-ssr/gitignore.template +36 -0
- package/templates/quasar-ssr/index.html +12 -0
- package/templates/quasar-ssr/quasar.config.ts +27 -0
- package/templates/quasar-ssr/scripts/edge-proxy.mjs +30 -0
- package/templates/quasar-ssr/scripts/edge-routing.mjs +14 -0
- package/templates/quasar-ssr/src/App.vue +1 -0
- package/templates/quasar-ssr/src/css/app.css +20 -0
- package/templates/quasar-ssr/src/pages/IndexPage.vue +146 -0
- package/templates/quasar-ssr/src/pages/NotFoundPage.vue +11 -0
- package/templates/quasar-ssr/src/router/index.ts +8 -0
- package/templates/quasar-ssr/src/router/routes.ts +6 -0
- package/templates/quasar-ssr/src/stores/index.ts +4 -0
- package/templates/quasar-ssr/src/stores/notes.ts +73 -0
- package/templates/quasar-ssr/src-ssr/middlewares/render.ts +15 -0
- package/templates/quasar-ssr/src-ssr/server.ts +42 -0
- package/templates/quasar-ssr/tsconfig.json +4 -0
- package/templates/spa/common/.env.example +8 -0
- package/templates/spa/common/api/server.mjs +46 -0
- package/templates/spa/common/api/services/health/health.class.mjs +3 -0
- package/templates/spa/common/api/services/health/health.service.mjs +4 -0
- package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
- package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
- package/templates/spa/common/gitignore.template +39 -0
- package/templates/spa/common/index.html +3 -0
- package/templates/spa/common/src/components/NotesPanel.vue +25 -0
- package/templates/spa/common/src/stores/notes.ts +105 -0
- package/templates/spa/common/src/vite-env.d.ts +1 -0
- package/templates/spa/common/tsconfig.json +4 -0
- package/templates/spa/daisyui/src/App.vue +2 -0
- package/templates/spa/daisyui/src/components/NotesPanel.vue +39 -0
- package/templates/spa/daisyui/src/main.ts +5 -0
- package/templates/spa/daisyui/src/style.css +5 -0
- package/templates/spa/daisyui/vite.config.ts +8 -0
- package/templates/spa/quasar2/src/App.vue +3 -0
- package/templates/spa/quasar2/src/components/NotesPanel.vue +41 -0
- package/templates/spa/quasar2/src/main.ts +7 -0
- package/templates/spa/quasar2/src/style.css +1 -0
- package/templates/spa/quasar2/vite.config.ts +8 -0
package/lib/scaffold.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { mkdir, lstat, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { buildProjectPlan } from './planner/plan.mjs'
|
|
5
|
+
|
|
6
|
+
const KEYS = new Set(['--name', '--dir', '--ui', '--render', '--adapter', '--transport', '--dry-run', '--json', '--schema', '--auth', '--db', '--pwa', '--dev-api'])
|
|
7
|
+
const CASES = Object.freeze({
|
|
8
|
+
'quasar2:spa': 'quasar-spa',
|
|
9
|
+
'quasar2:ssr': 'quasar-ssr',
|
|
10
|
+
'daisyui:spa': 'daisyui-spa',
|
|
11
|
+
'daisyui:ssr': 'daisyui-ssr',
|
|
12
|
+
'daisyui:ssg': 'daisyui-ssg',
|
|
13
|
+
})
|
|
14
|
+
const IGNORED = new Set(['node_modules', 'dist', 'coverage', '.env', 'test-results', 'playwright-report', 'docs-private', '.vite', '.quasar', '.git', 'pnpm-lock.yaml'])
|
|
15
|
+
|
|
16
|
+
async function walk(directory, prefix = '') {
|
|
17
|
+
const output = []
|
|
18
|
+
for (const entry of (await readdir(directory, {withFileTypes: true})).sort((a,b)=>a.name.localeCompare(b.name,'en'))) {
|
|
19
|
+
if (entry.isSymbolicLink()) throw new Error('Template symlink refused')
|
|
20
|
+
if (IGNORED.has(entry.name)) continue
|
|
21
|
+
const key = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
22
|
+
if (entry.isDirectory()) output.push(...await walk(join(directory, entry.name), key))
|
|
23
|
+
else if (entry.isFile()) output.push([key, await readFile(join(directory, entry.name),'utf8')])
|
|
24
|
+
}
|
|
25
|
+
return output
|
|
26
|
+
}
|
|
27
|
+
async function rejectSymlinks(pathname) {
|
|
28
|
+
let current = pathname
|
|
29
|
+
while (true) {
|
|
30
|
+
if (existsSync(current) && (await lstat(current)).isSymbolicLink()) throw new Error('Symlink in destination path')
|
|
31
|
+
const parent = dirname(current)
|
|
32
|
+
if (parent === current) break
|
|
33
|
+
current = parent
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function safeProjectName(value) {
|
|
37
|
+
return typeof value === 'string' && value.length <= 48 && /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(value) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value)
|
|
38
|
+
}
|
|
39
|
+
function envChange(text, options) {
|
|
40
|
+
return text.replace(/^FEATHERS_ADAPTER=.*$/m, `FEATHERS_ADAPTER=${options.adapter}`)
|
|
41
|
+
.replace(/^FEATHERS_SOCKETIO=.*$/m, `FEATHERS_SOCKETIO=${options.transport === 'both' ? '1' : '0'}`)
|
|
42
|
+
.replace(/^VITE_FEATHERS_REALTIME=.*$/m, `VITE_FEATHERS_REALTIME=${options.transport === 'both' ? '1' : '0'}`)
|
|
43
|
+
}
|
|
44
|
+
function spaManifest(o) {
|
|
45
|
+
const dependencies = {
|
|
46
|
+
'@vevedh/vite-plugin-feathersjs':'0.2.6', '@feathersjs/feathers':'^5.0.50', '@feathersjs/errors':'^5.0.50',
|
|
47
|
+
'@feathersjs/koa':'^5.0.50', '@feathersjs/express':'^5.0.50', '@feathersjs/socketio':'^5.0.50',
|
|
48
|
+
vue:'^3.5.44', pinia:'^3.0.4', 'socket.io-client':'^4.8.4',
|
|
49
|
+
}
|
|
50
|
+
const devDependencies = {
|
|
51
|
+
vite:'^7.3.7', '@vitejs/plugin-vue':'^6.0.0', 'vue-tsc':'^3.0.7',
|
|
52
|
+
typescript:'^5.9.3', '@types/node':'^22.0.0',
|
|
53
|
+
}
|
|
54
|
+
if (o.pwa === 'on') devDependencies['vite-plugin-pwa'] = '^2.0.0'
|
|
55
|
+
if (o.ui === 'quasar2') { dependencies.quasar = '^2.35.2'; devDependencies['@quasar/vite-plugin']='^1.12.0';devDependencies['sass-embedded']='^1.93.2' }
|
|
56
|
+
else { devDependencies.tailwindcss='^4.1.0';devDependencies['@tailwindcss/vite']='^4.1.0';devDependencies.daisyui='^5.0.0' }
|
|
57
|
+
return {name:o.name,version:'0.1.0',private:true,type:'module',packageManager:'pnpm@10.30.2',engines:{node:'>=22.12'},
|
|
58
|
+
scripts:{dev:'vite --host 127.0.0.1', 'dev:api':'node --env-file=.env.example api/server.mjs',
|
|
59
|
+
'dev:all':'node scripts/dev-all.mjs',build:'vue-tsc --noEmit && vite build',typecheck:'vue-tsc --noEmit',preview:'vite preview --host 127.0.0.1'},
|
|
60
|
+
dependencies,devDependencies}
|
|
61
|
+
}
|
|
62
|
+
function generatedReadme(o) {
|
|
63
|
+
const renderInfo = o.render === 'ssg' ? 'Le build pré-rend les routes publiques / et /about. Les notes restent dynamiques via /api.'
|
|
64
|
+
: o.render === 'ssr' ? 'Le serveur Node effectue le SSR avec un état Pinia distinct par requête.'
|
|
65
|
+
: o.devApi === 'vite'
|
|
66
|
+
? 'En développement, le plugin Vite monte les services Feathers directement sur /api. En production, l’API doit être lancée et déployée séparément.'
|
|
67
|
+
: 'Le frontend est une SPA Vite. En développement, une API Feathers indépendante écoute sur 127.0.0.1:3030.'
|
|
68
|
+
return `# ${o.name}\n\nProjet ${o.ui} ${o.render.toUpperCase()} avec Vue 3, Pinia et FeathersJS v5 (${o.adapter}, ${o.transport}).\n\n`+
|
|
69
|
+
`${renderInfo}\n\n## Démarrage local (Windows / PowerShell)\n\n`+
|
|
70
|
+
'```powershell\ncorepack pnpm install\ncorepack pnpm run dev:all\n```\n\n' +
|
|
71
|
+
(o.devApi === 'vite' ? 'Ouvrir http://127.0.0.1:5173. Pendant le développement, Feathers REST est servi sur le même port via /api.\n\n' : 'Ouvrir http://127.0.0.1:5173. L’API indépendante utilise http://127.0.0.1:3030.\n\n') +
|
|
72
|
+
'```powershell\ncorepack pnpm run typecheck\ncorepack pnpm run build\n```\n\n' +
|
|
73
|
+
(o.render !== 'spa' ? 'Pour vérifier le build, lancer `corepack pnpm run serve:production` avec l’API active.\n\n' : '') +
|
|
74
|
+
(o.devApi === 'vite' ? 'Le plugin Vite est actif en développement uniquement. `pnpm run preview` sert les fichiers statiques et ne démarre pas l’API. Pour tester la SPA construite avec CRUD, lancer `corepack pnpm run dev:api` dans un premier terminal puis `corepack pnpm run preview` dans un second. Le proxy `preview.proxy` relaie /api vers 127.0.0.1:3030 (et Socket.IO vers 3031 si activé). En l’absence de backend, l’interface affiche « API indisponible » ; hors ligne, seule la coque statique reste disponible. Ne jamais exposer le serveur de démonstration sans authentification.\n\n' : '') +
|
|
75
|
+
`Profil: schema ${o.schema}; auth ${o.auth}; databases ${o.db}. Les profils d'identité et les bases externes nécessitent un raccordement explicite.\n\n`+
|
|
76
|
+
(o.ui==='quasar2' && o.render==='ssr'?'Quasar SSR : ouvrir http://127.0.0.1:8080 (reverse proxy local). Ne pas exposer le serveur SSR directement.\n\n':'')+
|
|
77
|
+
(o.pwa === 'on' ? 'PWA SPA activée : le service worker n’est produit qu’au build. Il met en cache les ressources statiques uniquement, jamais les réponses API ou Socket.IO.\nL’icône SVG d’exemple doit être remplacée par des icônes PNG 192/512 adaptées avant une distribution publique. En HTTPS, tester les mises à jour et l’installation sur le navigateur cible.\n\n' : '') +
|
|
78
|
+
'Les données de démonstration sont en mémoire ; sans authentification les services ne doivent pas être exposés publiquement.\n'
|
|
79
|
+
}
|
|
80
|
+
/** Replace the SPA proxy with the public Vite plugin. The standalone API remains for production. */
|
|
81
|
+
function enableSpaViteRuntime(files, options) {
|
|
82
|
+
const source = files.get('vite.config.ts')
|
|
83
|
+
const marker = 'plugins: [vue(),'
|
|
84
|
+
const oldProxy = ",\n proxy: { '/api': 'http://127.0.0.1:3030', '/socket.io': { target: 'ws://127.0.0.1:3031', ws: true } }"
|
|
85
|
+
if (typeof source !== 'string' || !source.includes(marker) || !source.includes(oldProxy)) {
|
|
86
|
+
throw new Error('Unsupported SPA Vite template for embedded Feathers; refusing partial generation')
|
|
87
|
+
}
|
|
88
|
+
const realtime = options.transport === 'both'
|
|
89
|
+
const socket = realtime
|
|
90
|
+
? "{ port: 3031, host: '127.0.0.1', origins: ['http://127.0.0.1:5173'] }"
|
|
91
|
+
: 'false'
|
|
92
|
+
const runtime = `feathers({
|
|
93
|
+
serviceFiles: 'api/services/*/*.service.mjs',
|
|
94
|
+
watchDirectories: ['api/services'],
|
|
95
|
+
adapter: ${JSON.stringify(options.adapter)},
|
|
96
|
+
prefix: '/api',
|
|
97
|
+
cors: false,
|
|
98
|
+
socketio: ${socket},
|
|
99
|
+
})`
|
|
100
|
+
// 'vite preview' serves static assets: the Feathers Vite plugin is dev-only.
|
|
101
|
+
// Proxy API traffic to the separately started, loopback-bound demo API.
|
|
102
|
+
const previewProxy = `preview: { host: '127.0.0.1', port: 4173, strictPort: true,
|
|
103
|
+
proxy: { '/api': 'http://127.0.0.1:3030',${realtime ? " '/socket.io': { target: 'ws://127.0.0.1:3031', ws: true }" : ''} } },`
|
|
104
|
+
const socketProxy = realtime
|
|
105
|
+
? ",\n proxy: { '/socket.io': { target: 'ws://127.0.0.1:3031', ws: true } }"
|
|
106
|
+
: ''
|
|
107
|
+
files.set('vite.config.ts',
|
|
108
|
+
`import feathers from '@vevedh/vite-plugin-feathersjs'\n` +
|
|
109
|
+
`import { assertCapabilities } from './api/config/capabilities.mjs'\n` +
|
|
110
|
+
source.replace(marker, `${marker} ${runtime},`).replace(oldProxy, socketProxy)
|
|
111
|
+
.replace('export default defineConfig({',
|
|
112
|
+
'// Do not silently start an unprotected or unconfigured identity/database service.\nassertCapabilities()\nexport default defineConfig({\n' +
|
|
113
|
+
` ${previewProxy}\n` +
|
|
114
|
+
` define: { 'import.meta.env.VITE_FEATHERS_REALTIME': '${JSON.stringify(realtime ? '1' : '0')}' },`))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** PWA is opt-in, SPA-only, and must never cache API or realtime responses. */
|
|
118
|
+
function enableSpaPwa(files, options) {
|
|
119
|
+
const config = files.get('vite.config.ts')
|
|
120
|
+
const marker = 'plugins: [vue(),'
|
|
121
|
+
if (typeof config !== 'string' || !config.includes(marker) || config.includes('vite-plugin-pwa')) {
|
|
122
|
+
throw new Error('Unsupported Vite SPA template for PWA; refusing partial generation')
|
|
123
|
+
}
|
|
124
|
+
const pwa = `VitePWA({
|
|
125
|
+
registerType: 'prompt',
|
|
126
|
+
injectRegister: 'auto',
|
|
127
|
+
devOptions: { enabled: false },
|
|
128
|
+
includeAssets: ['pwa-icon.svg'],
|
|
129
|
+
manifest: {
|
|
130
|
+
name: ${JSON.stringify(options.name)},
|
|
131
|
+
short_name: ${JSON.stringify(options.name)},
|
|
132
|
+
description: 'Application Feathers et Vue',
|
|
133
|
+
lang: 'fr',
|
|
134
|
+
start_url: '/',
|
|
135
|
+
scope: '/',
|
|
136
|
+
display: 'standalone',
|
|
137
|
+
background_color: '#ffffff',
|
|
138
|
+
theme_color: '#ffffff',
|
|
139
|
+
icons: [{ src: '/pwa-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
|
|
140
|
+
},
|
|
141
|
+
workbox: {
|
|
142
|
+
globPatterns: ['**/*.{js,css,html,svg}'],
|
|
143
|
+
globIgnores: ['**/api/**', '**/socket.io/**'],
|
|
144
|
+
navigateFallback: '/index.html',
|
|
145
|
+
navigateFallbackDenylist: [/^\\/api(?:\\/|$)/, /^\\/socket\\.io(?:\\/|$)/],
|
|
146
|
+
runtimeCaching: [],
|
|
147
|
+
},
|
|
148
|
+
})`
|
|
149
|
+
files.set('vite.config.ts', `import { VitePWA } from 'vite-plugin-pwa'\n${config.replace(marker, `${marker} ${pwa},`)}`)
|
|
150
|
+
if (files.has('public/pwa-icon.svg')) throw new Error('PWA icon conflict in source template')
|
|
151
|
+
files.set('public/pwa-icon.svg', '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" role="img" aria-label="Feathers app"><rect width="192" height="192" rx="40" fill="#1e293b"/><path d="M44 96l40 40 64-80" fill="none" stroke="#fff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/></svg>\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createGenerator({outputRoot, templatesRoot, examplesRoot = templatesRoot, reportRoot = outputRoot}) {
|
|
155
|
+
const output = resolve(outputRoot), templates = resolve(templatesRoot), report = resolve(reportRoot)
|
|
156
|
+
function parse(args) {
|
|
157
|
+
const o={ui:'daisyui',render:'spa',adapter:'koa',transport:'rest',schema:'typebox',auth:'none',db:'main:memory',pwa:'off',devApi:undefined,dryRun:false,json:false}
|
|
158
|
+
const seen=new Set()
|
|
159
|
+
if (args.length && !args[0].startsWith('-')) {o.name=args[0];args=args.slice(1);if(args.includes('--name'))throw new Error('Use either positional name or --name, not both')}
|
|
160
|
+
for(let i=0;i<args.length;i++) {
|
|
161
|
+
const flag=args[i]
|
|
162
|
+
if(!KEYS.has(flag)||seen.has(flag))throw new Error(`Unknown or duplicate option: ${flag}`)
|
|
163
|
+
seen.add(flag)
|
|
164
|
+
if(flag==='--dry-run'||flag==='--json') o[flag==='--dry-run'?'dryRun':'json']=true
|
|
165
|
+
else { const value=args[++i];if(!value||value.startsWith('--'))throw new Error(`Missing ${flag}`);o[flag==='--dev-api'?'devApi':flag.slice(2)]=value }
|
|
166
|
+
}
|
|
167
|
+
if(!safeProjectName(o.name))throw new Error('Invalid or missing --name (lowercase kebab-case, max 48 characters)')
|
|
168
|
+
o.devApi ??= o.render === 'spa' ? 'vite' : 'separate'
|
|
169
|
+
if (!['vite','separate'].includes(o.devApi)) throw new Error('Invalid --dev-api: expected vite|separate')
|
|
170
|
+
if (o.devApi === 'vite' && o.render !== 'spa') throw new Error('--dev-api vite is supported for SPA only; SSR/SSG require separate API')
|
|
171
|
+
if(!['koa','express'].includes(o.adapter))throw new Error('Only koa|express adapters are supported')
|
|
172
|
+
if(!['rest','both'].includes(o.transport))throw new Error('Only rest|both are supported')
|
|
173
|
+
if (!['on','off'].includes(o.pwa)) throw new Error('Invalid --pwa: expected on|off')
|
|
174
|
+
if (o.pwa==='on' && o.render!=='spa') throw new Error('PWA is supported for SPA only; SSR/SSG require separate certification')
|
|
175
|
+
if (!['typebox','json','zod'].includes(o.schema)) throw new Error('Invalid --schema: expected typebox|json|zod')
|
|
176
|
+
if (!['none','jwt','oauth2','oidc','keycloak'].includes(o.auth)) throw new Error('Invalid --auth: expected none|jwt|oauth2|oidc|keycloak')
|
|
177
|
+
if (!/^[a-z][a-z0-9-]{0,31}:(?:memory|mongodb|postgresql|mariadb|mysql|sqlite)(?:,[a-z][a-z0-9-]{0,31}:(?:memory|mongodb|postgresql|mariadb|mysql|sqlite))*$/.test(o.db)) throw new Error('Invalid --db: use alias:adapter[,alias:adapter]')
|
|
178
|
+
const dbPairs=o.db.split(',').map(x=>{const [alias,adapter]=x.split(':');return {alias,adapter}})
|
|
179
|
+
if(new Set(dbPairs.map(x=>x.alias)).size!==dbPairs.length)throw new Error('Duplicate database alias')
|
|
180
|
+
// Reuse the actual R15 planner as the capability validator, not a separate feature catalogue.
|
|
181
|
+
const plan=buildProjectPlan({name:o.name,ui:o.ui,render:o.render,transport:o.transport,
|
|
182
|
+
engine:'auto',auth:o.auth==='oauth2'?'jwt,oauth':o.auth==='oidc'||o.auth==='keycloak'?'jwt,oidc':o.auth,db:o.db,primary:dbPairs[0].alias,pwa:o.pwa,schema:o.schema})
|
|
183
|
+
if(!CASES[`${o.ui}:${o.render}`])throw new Error('Unsupported combination: Quasar2 supports SPA/SSR; daisyui supports SPA/SSR/SSG')
|
|
184
|
+
if(o.render!=='spa' && o.transport!=='rest')throw new Error('SSR/SSG supports REST only; Socket.IO proxy not certified')
|
|
185
|
+
if(o.dir!==undefined && !safeProjectName(o.dir))throw new Error('--dir must be a single safe directory name')
|
|
186
|
+
o.destination=resolve(output,o.dir||o.name)
|
|
187
|
+
if(dirname(o.destination)!==output)throw new Error('Destination escapes output root')
|
|
188
|
+
o.case=CASES[`${o.ui}:${o.render}`];o.plan=plan;o.databases=dbPairs
|
|
189
|
+
return o
|
|
190
|
+
}
|
|
191
|
+
async function generate(o) {
|
|
192
|
+
await rejectSymlinks(output)
|
|
193
|
+
await rejectSymlinks(o.destination)
|
|
194
|
+
if(existsSync(o.destination))throw new Error('Destination already exists; refusing overwrite')
|
|
195
|
+
const files=new Map(o.render==='spa' ? [
|
|
196
|
+
...await walk(join(templates,'spa','common')),
|
|
197
|
+
...await walk(join(templates,'spa',o.ui)),
|
|
198
|
+
] : await walk(join(resolve(examplesRoot),`${o.ui==='quasar2'?'quasar':'daisyui'}-${o.render}`)))
|
|
199
|
+
// npm pack excludes files literally named .gitignore, even in subdirectories.
|
|
200
|
+
// The published templates use gitignore.template, rewritten here to real .gitignore.
|
|
201
|
+
if(files.has('gitignore.template')){files.set('.gitignore',files.get('gitignore.template'));files.delete('gitignore.template')}
|
|
202
|
+
if(files.has('.gitignore')) {
|
|
203
|
+
const ignores=files.get('.gitignore')
|
|
204
|
+
const additions=['.agents/','AGENTS.md','SKILLS.md','*.prompt.md','.ai/','.codex/','.cursor/','.claude/','.gemini/','.windsurf/','.aider*','.mcp.json','.vscode/mcp.json','.github/copilot-instructions.md','docs-private/','.npmrc','.env*','!.env.example']
|
|
205
|
+
files.set('.gitignore',ignores.trimEnd()+'\n\n# Local editor tooling and private maintenance files\n'+additions.filter(x=>!ignores.split(/\r?\n/).includes(x)).join('\n')+'\n')
|
|
206
|
+
}
|
|
207
|
+
if(!files.has('api/server.mjs')||!files.has('.env.example')||!files.has('index.html'))throw new Error('Incomplete source template')
|
|
208
|
+
files.set('.env.example',envChange(files.get('.env.example'),o))
|
|
209
|
+
if (o.devApi === 'vite') {
|
|
210
|
+
enableSpaViteRuntime(files, o)
|
|
211
|
+
files.set('api/config/capabilities.d.mts', 'export declare function assertCapabilities(): void\n')
|
|
212
|
+
}
|
|
213
|
+
if (o.pwa === 'on') enableSpaPwa(files,o)
|
|
214
|
+
if(o.ui==='quasar2' && o.render==='ssr') {
|
|
215
|
+
// R16 HTML has no Vite SPA identity marker; add one without touching frozen R16 sources.
|
|
216
|
+
const html=files.get('index.html')
|
|
217
|
+
files.set('index.html',html.replace('<meta charset="utf-8">','<meta charset="utf-8">\n <meta name="r17-example" content="quasar-ssr">\n <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\'/%3E">'))
|
|
218
|
+
}
|
|
219
|
+
files.set('index.html',files.get('index.html').replace('__R17_CASE__',o.case).replace(/(<meta name="r17-example" content=")[^"]+("\s*\/?>)/,`$1${o.case}$2`))
|
|
220
|
+
const manifest=o.render==='spa'?spaManifest(o):o.ui==='quasar2'?quasarSSRManifest(o):JSON.parse(files.get('package.json'))
|
|
221
|
+
manifest.name=o.name;manifest.private=true
|
|
222
|
+
manifest.scripts={...manifest.scripts,'dev:all':o.devApi==='vite'?'vite --host 127.0.0.1':'node scripts/dev-all.mjs'}
|
|
223
|
+
files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
|
|
224
|
+
files.set('README.md',generatedReadme(o))
|
|
225
|
+
// Keep the visible application description consistent with the actual dev API mode.
|
|
226
|
+
// The standalone production API is intentionally unaffected.
|
|
227
|
+
if (o.render === 'spa' && o.devApi === 'vite') {
|
|
228
|
+
const app = files.get('src/App.vue')
|
|
229
|
+
const separateDescription = 'Le service Feathers est exécuté dans un processus séparé.'
|
|
230
|
+
if (typeof app !== 'string' || !app.includes(separateDescription)) {
|
|
231
|
+
throw new Error('SPA description contract changed: refusing inconsistent Vite-native scaffold')
|
|
232
|
+
}
|
|
233
|
+
files.set('src/App.vue', app.replace(separateDescription,
|
|
234
|
+
'En développement, les services Feathers sont intégrés directement à Vite.'))
|
|
235
|
+
}
|
|
236
|
+
if (o.devApi !== 'vite') files.set('scripts/dev-all.mjs',await readFile(join(templates,'common',o.ui==='quasar2'&&o.render==='ssr'?'dev-all-quasar-ssr.mjs':'dev-all.mjs'),'utf8'))
|
|
237
|
+
// A configuration contract, not a simulated authentication or persistence layer.
|
|
238
|
+
// Non-memory databases and external identity providers fail closed at startup
|
|
239
|
+
// until explicitly integrated and tested with a real provider.
|
|
240
|
+
files.set('api/config/capabilities.mjs',capabilityGuard(o))
|
|
241
|
+
const server=files.get('api/server.mjs').replace("from 'vite-plugin-feathersjs'","from '@vevedh/vite-plugin-feathersjs'")
|
|
242
|
+
files.set('api/server.mjs',"import { assertCapabilities } from './config/capabilities.mjs'\nassertCapabilities()\n" + server)
|
|
243
|
+
files.set('api/config/README.md',`# Configuration des capacités\n\nProfil sélectionné : schéma ${o.schema}, identité ${o.auth}, connexions ${o.db}.\n\nLes services en mémoire et sans authentification sont destinés au laboratoire local. Les bases externes nécessitent une instance réelle, un client Feathers/Knex ou MongoDB, ses migrations et la vérification de son cycle de vie.\n\nL'authentification JWT exige un service utilisateurs, un secret hors Git, une stratégie JWT active, des contrôles d'accès et des tests. OAuth2, OIDC et Keycloak exigent en plus un fournisseur HTTPS, la vérification de l'issuer, audience, JWKS, PKCE et le mapping des identités. Ne pas exposer l'API avant validation.\n\nLa commande \`service add tickets --schema zod --db main\` crée un service et l'enregistre dans l'API.\n`)
|
|
244
|
+
files.set('api/config/profile.json',JSON.stringify({schema:o.schema,auth:o.auth,databases:o.databases,primary:o.databases[0].alias,operational:o.auth==='none' && o.databases.every(db=>db.adapter==='memory')},null,2)+'\n')
|
|
245
|
+
const depMap={typebox:['@feathersjs/typebox','ajv'],json:['ajv'],zod:['zod']}
|
|
246
|
+
// Dependencies for the service subcommand; project options alone create no arbitrary services.
|
|
247
|
+
for(const dep of depMap[o.schema])manifest.dependencies[dep] ||= dep==='ajv'?'^8.17.1':dep==='zod'?'^4.1.0':'^5.0.50'
|
|
248
|
+
if(o.auth==='jwt')manifest.dependencies['@feathersjs/authentication']='^5.0.50'
|
|
249
|
+
if(o.auth==='oauth2')manifest.dependencies['@feathersjs/authentication-oauth']='^5.0.50'
|
|
250
|
+
for(const db of o.databases) {
|
|
251
|
+
const drivers={mongodb:['@feathersjs/mongodb','mongodb'],postgresql:['@feathersjs/knex','knex','pg'],mariadb:['@feathersjs/knex','knex','mysql2'],mysql:['@feathersjs/knex','knex','mysql2'],sqlite:['@feathersjs/knex','knex','better-sqlite3']}
|
|
252
|
+
for(const dep of drivers[db.adapter]||[])manifest.dependencies[dep]||='*'
|
|
253
|
+
}
|
|
254
|
+
files.set('package.json',JSON.stringify(manifest,null,2)+'\n')
|
|
255
|
+
const list=[...files.keys()].sort()
|
|
256
|
+
const result={name:o.name,ui:o.ui,render:o.render,adapter:o.adapter,transport:o.transport,
|
|
257
|
+
destination:relative(report,o.destination).split(sep).join('/'),schema:o.schema,auth:o.auth,pwa:o.pwa,devApi:o.devApi,databases:o.databases,files:list}
|
|
258
|
+
if(o.dryRun)return result
|
|
259
|
+
// Do not follow symlinks and refuse any existing destination, including empty ones.
|
|
260
|
+
await mkdir(output,{recursive:true})
|
|
261
|
+
await rejectSymlinks(output)
|
|
262
|
+
await mkdir(o.destination,{recursive:false})
|
|
263
|
+
for(const [key,value] of [...files].sort(([a],[b])=>a.localeCompare(b,'en'))) {
|
|
264
|
+
const filepath=resolve(o.destination,key)
|
|
265
|
+
if(!filepath.startsWith(o.destination+sep))throw new Error('Template path escapes destination')
|
|
266
|
+
await mkdir(dirname(filepath),{recursive:true})
|
|
267
|
+
await writeFile(filepath,value.replace(/\r\n/g,'\n'),{flag:'wx'})
|
|
268
|
+
}
|
|
269
|
+
return result
|
|
270
|
+
}
|
|
271
|
+
return {parse,generate}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function quasarSSRManifest(o){
|
|
275
|
+
return {name:o.name,version:'0.1.0',private:true,type:'module',packageManager:'pnpm@10.30.2',engines:{node:'>=22.12'},
|
|
276
|
+
scripts:{'dev:ssr':'quasar dev -m ssr','build:ssr':'quasar build -m ssr',build:'quasar build -m ssr',
|
|
277
|
+
typecheck:'vue-tsc --noEmit', 'dev:api':'node --env-file=.env.example api/server.mjs',
|
|
278
|
+
'serve:production':'node dist/ssr/index.js',postinstall:'quasar prepare', 'dev:edge':'node scripts/edge-proxy.mjs'},
|
|
279
|
+
dependencies:{'@vevedh/vite-plugin-feathersjs':'0.2.6', '@feathersjs/feathers':'^5.0.50', '@feathersjs/koa':'^5.0.50',
|
|
280
|
+
'@feathersjs/express':'^5.0.50', '@feathersjs/errors':'^5.0.50', '@feathersjs/socketio':'^5.0.50',
|
|
281
|
+
'@feathersjs/rest-client':'^5.0.50', '@feathersjs/socketio-client':'^5.0.50',
|
|
282
|
+
'@quasar/extras':'^1.18.0',pinia:'^3.0.4',quasar:'^2.35.2','socket.io-client':'^4.8.4',
|
|
283
|
+
vue:'^3.5.44','vue-router':'^4.6.4',express:'^4.22.3','serialize-javascript':'^7.1.4'},
|
|
284
|
+
devDependencies:{'@quasar/app-vite':'2.6.2','@types/node':'^22.0.0',typescript:'^5.9.3','vue-tsc':'^3.3.14',
|
|
285
|
+
// Quasar app-vite 2.6.2 runs Express v4. Keep its ambient types in
|
|
286
|
+
// the same major as Express runtime, including nested pnpm dependencies.
|
|
287
|
+
'@types/express':'4.17.25','@types/express-serve-static-core':'4.19.9',vite:'^7.3.7'},
|
|
288
|
+
pnpm:{overrides:{'@types/express':'4.17.25','@types/express-serve-static-core':'4.19.9'}}}
|
|
289
|
+
}
|
|
290
|
+
function capabilityGuard(o) {
|
|
291
|
+
const external=o.databases.filter(x=>x.adapter!=='memory')
|
|
292
|
+
const desc=JSON.stringify({auth:o.auth,databases:o.databases})
|
|
293
|
+
return `// Generated capability contract: never silently downgrade identity or persistence.\n`+
|
|
294
|
+
`export const profile=Object.freeze(${desc})\n`+
|
|
295
|
+
`export function assertCapabilities(){\n`+
|
|
296
|
+
` if (profile.auth !== 'none') throw new Error('Authentication '+profile.auth+' is a configuration blueprint; identity provider and JWT validation must be integrated and certified before serving requests')\n`+
|
|
297
|
+
` const missing=profile.databases.filter(item=>item.adapter!=='memory')\n`+
|
|
298
|
+
` if(missing.length)throw new Error('Database adapters not provisioned: '+missing.map(x=>x.alias+':'+x.adapter).join(', ')+'. Configure named adapters before serving requests')\n`+
|
|
299
|
+
`}\n`
|
|
300
|
+
}
|
package/lib/services.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const VALID_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
6
|
+
const VALID_SCHEMA = new Set(['typebox','json','zod'])
|
|
7
|
+
const VALID_AUTH = new Set(['none','jwt'])
|
|
8
|
+
function safeName(s){return typeof s==='string' && s.length<=48 && VALID_NAME.test(s) && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)}
|
|
9
|
+
async function rejectLinks(path){
|
|
10
|
+
let current=path
|
|
11
|
+
while(true){if(existsSync(current)&&(await lstat(current)).isSymbolicLink())throw new Error('Symlink path refused');const next=dirname(current);if(next===current)break;current=next}
|
|
12
|
+
}
|
|
13
|
+
export function parseServiceArguments(args){
|
|
14
|
+
if(args[0]!=='add')throw new Error('Use: service add <name> [--project <dir>] [--schema typebox|json|zod] [--db <alias>] [--auth none|jwt] [--dry-run]')
|
|
15
|
+
if(!safeName(args[1]))throw new Error('Invalid service name (lowercase kebab-case)')
|
|
16
|
+
const options={name:args[1],project:'.',schema:undefined,db:undefined,auth:undefined,dryRun:false}
|
|
17
|
+
const keys=new Map([['--project','project'],['--schema','schema'],['--db','db'],['--auth','auth']])
|
|
18
|
+
const seen=new Set()
|
|
19
|
+
for(let i=2;i<args.length;i++){
|
|
20
|
+
const flag=args[i]
|
|
21
|
+
if(seen.has(flag))throw new Error('Duplicate option: '+flag)
|
|
22
|
+
seen.add(flag)
|
|
23
|
+
if(flag==='--dry-run'){options.dryRun=true;continue}
|
|
24
|
+
if(!keys.has(flag))throw new Error('Unknown service option: '+flag)
|
|
25
|
+
const value=args[++i]
|
|
26
|
+
if(!value||value.startsWith('--'))throw new Error('Missing service option: '+flag)
|
|
27
|
+
options[keys.get(flag)]=value
|
|
28
|
+
}
|
|
29
|
+
if(!VALID_SCHEMA.has(options.schema??'typebox'))throw new Error('Invalid schema')
|
|
30
|
+
if(!VALID_AUTH.has(options.auth??'none'))throw new Error('Invalid service auth: none|jwt')
|
|
31
|
+
if(options.db!==undefined&&!safeName(options.db))throw new Error('Invalid database alias')
|
|
32
|
+
if(!/^\.?\/?[a-zA-Z0-9._/-]*$/.test(options.project)||options.project.includes('..')||options.project.includes('\\')||options.project.startsWith('/'))throw new Error('Unsafe project path; use a local relative directory')
|
|
33
|
+
return options
|
|
34
|
+
}
|
|
35
|
+
function schemaSource(schema){
|
|
36
|
+
const opening="import { BadRequest } from '@feathersjs/errors'\n"
|
|
37
|
+
if(schema==='zod')return opening+`import { z } from 'zod'
|
|
38
|
+
export const dataSchema=z.object({title:z.string().trim().min(1).max(120)}).strict()
|
|
39
|
+
export function validateData(value){const parsed=dataSchema.safeParse(value);if(!parsed.success)throw new BadRequest('Invalid service data');return parsed.data}
|
|
40
|
+
`
|
|
41
|
+
const def=schema==='typebox'?`import { Type } from '@feathersjs/typebox'
|
|
42
|
+
export const dataSchema=Type.Object({title:Type.String({minLength:1,maxLength:120})},{additionalProperties:false})
|
|
43
|
+
`:`export const dataSchema={type:'object',additionalProperties:false,required:['title'],properties:{title:{type:'string',minLength:1,maxLength:120}}}
|
|
44
|
+
`
|
|
45
|
+
return opening+`import Ajv from 'ajv'
|
|
46
|
+
`+def+`const valid=new Ajv({allErrors:true}).compile(dataSchema)
|
|
47
|
+
export function validateData(value){if(!valid(value))throw new BadRequest('Invalid service data');return {title:value.title.trim()}}
|
|
48
|
+
`
|
|
49
|
+
}
|
|
50
|
+
function classSource(){return `import { NotFound, BadRequest } from '@feathersjs/errors'
|
|
51
|
+
import { validateData } from './schema.mjs'
|
|
52
|
+
export class GeneratedService {
|
|
53
|
+
#items=new Map()
|
|
54
|
+
#counter=1
|
|
55
|
+
constructor(){for(const method of ['find','get','create','patch','remove'])this[method]=this[method].bind(this)}
|
|
56
|
+
#id(value){const n=Number(value);if(!Number.isSafeInteger(n)||n<1)throw new BadRequest('Invalid ID');return n}
|
|
57
|
+
async find(){return [...this.#items.values()].map(x=>({...x}))}
|
|
58
|
+
async get(id){const item=this.#items.get(this.#id(id));if(!item)throw new NotFound('Not found');return {...item}}
|
|
59
|
+
async create(data){const row={id:this.#counter++,...validateData(data)};this.#items.set(row.id,row);return {...row}}
|
|
60
|
+
async patch(id,data){const key=this.#id(id);if(!this.#items.has(key))throw new NotFound('Not found');const row={id:key,...validateData(data)};this.#items.set(key,row);return {...row}}
|
|
61
|
+
async remove(id){const key=this.#id(id);const row=this.#items.get(key);if(!row)throw new NotFound('Not found');this.#items.delete(key);return {...row}}
|
|
62
|
+
}
|
|
63
|
+
`}
|
|
64
|
+
function registrar(name,auth){
|
|
65
|
+
return `import { GeneratedService } from './service.class.mjs'
|
|
66
|
+
${auth==='jwt'?"import { authenticate } from '@feathersjs/authentication'\n":''}export default function register(app){
|
|
67
|
+
app.use('${name}',new GeneratedService(),{methods:['find','get','create','patch','remove']})
|
|
68
|
+
${auth==='jwt'?`app.service('${name}').hooks({before:{all:[authenticate('jwt')]}})`:'// This demonstration service has no authentication; bind HTTP only to loopback.'}
|
|
69
|
+
}
|
|
70
|
+
`
|
|
71
|
+
}
|
|
72
|
+
export async function generateService(opts, cwd=process.cwd()){
|
|
73
|
+
const project=resolve(cwd,opts.project)
|
|
74
|
+
if(opts.project!=='.' && !project.startsWith(resolve(cwd)+sep))throw new Error('Project escapes current directory')
|
|
75
|
+
await rejectLinks(project)
|
|
76
|
+
const manifestPath=join(project,'package.json'),serverPath=join(project,'api/server.mjs'),profilePath=join(project,'api/config/profile.json')
|
|
77
|
+
if(!existsSync(manifestPath)||!existsSync(serverPath)||!existsSync(profilePath))throw new Error('Not a CLI generated project')
|
|
78
|
+
const manifest=JSON.parse(await readFile(manifestPath,'utf8'))
|
|
79
|
+
const profile=JSON.parse(await readFile(profilePath,'utf8'))
|
|
80
|
+
const schema=opts.schema??profile.schema,auth=opts.auth??profile.auth,db=opts.db??profile.primary
|
|
81
|
+
if(!VALID_SCHEMA.has(schema))throw new Error('Invalid schema')
|
|
82
|
+
if(!VALID_AUTH.has(auth))throw new Error('Service supports none|jwt; OAuth2/OIDC/Keycloak require audited identity integration')
|
|
83
|
+
if(auth!=='none' && profile.auth!==auth)throw new Error('Service auth must match configured project profile')
|
|
84
|
+
const connection=profile.databases.find(x=>x.alias===db)
|
|
85
|
+
if(!connection)throw new Error('Unknown database alias: '+db)
|
|
86
|
+
const destination=join(project,'api/services',opts.name)
|
|
87
|
+
await rejectLinks(destination)
|
|
88
|
+
if(existsSync(destination))throw new Error('Service already exists; refusing overwrite')
|
|
89
|
+
const text=await readFile(serverPath,'utf8')
|
|
90
|
+
const marker='const modules = [\n'
|
|
91
|
+
if(!text.includes(marker))throw new Error('Missing Feathers modules registry; no changes made')
|
|
92
|
+
const moduleEntry=` new URL('./services/${opts.name}/${opts.name}.service.mjs', import.meta.url),\n`
|
|
93
|
+
if(text.includes(moduleEntry))throw new Error('Duplicate service registration')
|
|
94
|
+
// Never replace a user-controlled arbitrary string outside of the explicit registry.
|
|
95
|
+
const output={name:opts.name,project,database:{alias:db,adapter:connection.adapter},auth,schema,files:[`api/services/${opts.name}/schema.mjs`,`api/services/${opts.name}/service.class.mjs`,`api/services/${opts.name}/${opts.name}.service.mjs`],ready:connection.adapter==='memory'&&auth==='none'}
|
|
96
|
+
if(opts.dryRun)return output
|
|
97
|
+
const dependencies=manifest.dependencies||{}
|
|
98
|
+
const deps=schema==='zod'?{'zod':'^4.1.0'}:schema==='typebox'?{'@feathersjs/typebox':'^5.0.50','ajv':'^8.17.1'}:{'ajv':'^8.17.1'}
|
|
99
|
+
if(auth==='jwt')deps['@feathersjs/authentication']='^5.0.50'
|
|
100
|
+
for(const [name,version] of Object.entries(deps))dependencies[name]??=version
|
|
101
|
+
await mkdir(destination,{recursive:false})
|
|
102
|
+
for(const [name,contents] of Object.entries({'schema.mjs':schemaSource(schema),'service.class.mjs':classSource(),[`${opts.name}.service.mjs`]:registrar(opts.name,auth)}))await writeFile(join(destination,name),contents,{flag:'wx'})
|
|
103
|
+
await writeFile(serverPath,text.replace(marker,marker+moduleEntry))
|
|
104
|
+
await writeFile(manifestPath,JSON.stringify({...manifest,dependencies},null,2)+'\n')
|
|
105
|
+
return output
|
|
106
|
+
}
|
|
107
|
+
export async function runServiceCommand(args,cwd){
|
|
108
|
+
const result=await generateService(parseServiceArguments(args),cwd)
|
|
109
|
+
console.log(JSON.stringify(result,null,2))
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vevedh/create-feathers-app",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Scaffold FeathersJS v5 projects with Quasar 2 or daisyUI, Vite, Vue 3 and Pinia",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-feathers-app": "./bin/create-feathers-app.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22.12.0"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"lib",
|
|
16
|
+
"templates",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"create",
|
|
25
|
+
"scaffold",
|
|
26
|
+
"vite",
|
|
27
|
+
"feathersjs",
|
|
28
|
+
"quasar",
|
|
29
|
+
"daisyui",
|
|
30
|
+
"vue",
|
|
31
|
+
"ssr",
|
|
32
|
+
"ssg"
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { loadEnvFile } from 'node:process'
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
6
|
+
const file=existsSync('.env')?'.env':'.env.example'
|
|
7
|
+
if (!existsSync(file))throw new Error('Missing .env.example')
|
|
8
|
+
loadEnvFile(file)
|
|
9
|
+
const env={...process.env}
|
|
10
|
+
const children=[]
|
|
11
|
+
let closed=false
|
|
12
|
+
function stop(code=0){if(closed)return;closed=true;for(const child of children.slice().reverse())if(child.exitCode===null)try{child.kill()}catch{};process.exitCode=code}
|
|
13
|
+
for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>stop(signal==='SIGINT'?130:143))
|
|
14
|
+
function start(label,command,args,opts={}) {
|
|
15
|
+
const proc=spawn(command,args,{env,stdio:'inherit',windowsHide:true,...opts})
|
|
16
|
+
children.push(proc)
|
|
17
|
+
proc.on('error',error=>{console.error(label,error.message);stop(1)})
|
|
18
|
+
proc.on('exit',code=>{if(!closed){console.error(label,'stopped',code);stop(code||1)}})
|
|
19
|
+
return proc
|
|
20
|
+
}
|
|
21
|
+
start('Feathers',process.execPath,[`--env-file=${file}`,'api/server.mjs'])
|
|
22
|
+
let ready=false
|
|
23
|
+
for(let attempt=0;attempt<90 && !closed;attempt++){
|
|
24
|
+
try{const res=await fetch('http://127.0.0.1:3030/api/health',{signal:AbortSignal.timeout(650)});if(res.ok){ready=true;break}}catch{}
|
|
25
|
+
await sleep(200)
|
|
26
|
+
}
|
|
27
|
+
if(!ready){console.error('Feathers health unavailable on loopback');stop(1)}
|
|
28
|
+
if(!closed){
|
|
29
|
+
start('Quasar SSR',process.platform==='win32'?'corepack.cmd':'corepack',['pnpm','run','dev:ssr'],{shell:process.platform==='win32'})
|
|
30
|
+
let ssrReady=false
|
|
31
|
+
for(let attempt=0;attempt<150 && !closed;attempt++){
|
|
32
|
+
try{const res=await fetch('http://127.0.0.1:9100/',{signal:AbortSignal.timeout(800)});if(res.ok){ssrReady=true;break}}catch{}
|
|
33
|
+
await sleep(350)
|
|
34
|
+
}
|
|
35
|
+
if(!ssrReady){console.error('Quasar SSR unavailable on loopback :9100');stop(1)}
|
|
36
|
+
else start('Edge',process.execPath,['scripts/edge-proxy.mjs'])
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Starts Feathers first, then the frontend, with one controlled shutdown.
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { loadEnvFile } from 'node:process'
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
const file=existsSync('.env') ? '.env' : '.env.example'
|
|
9
|
+
if(!existsSync(file))throw new Error('Missing .env.example')
|
|
10
|
+
loadEnvFile(file)
|
|
11
|
+
const usesVite=existsSync('src/main.ts')
|
|
12
|
+
const isSSG=existsSync('scripts/serve-static.mjs')
|
|
13
|
+
const env={...process.env}
|
|
14
|
+
const processes=[]
|
|
15
|
+
let stopped=false
|
|
16
|
+
function stop(code=0){
|
|
17
|
+
if(stopped)return
|
|
18
|
+
stopped=true
|
|
19
|
+
for(const p of [...processes].reverse())if(p.exitCode===null)try{p.kill()}catch{}
|
|
20
|
+
process.exitCode=code
|
|
21
|
+
}
|
|
22
|
+
process.on('SIGINT',()=>stop(130))
|
|
23
|
+
process.on('SIGTERM',()=>stop(143))
|
|
24
|
+
const api=spawn(process.execPath,[`--env-file=${file}`,'api/server.mjs'],{env,stdio:'inherit',windowsHide:true})
|
|
25
|
+
processes.push(api)
|
|
26
|
+
api.once('error',err=>{console.error('Feathers:',err.message);stop(1)})
|
|
27
|
+
api.once('exit',code=>{if(!stopped){console.error('Feathers stopped:',code);stop(code||1)}})
|
|
28
|
+
const port=Number(env.FEATHERS_API_PORT||3030)
|
|
29
|
+
let ready=false
|
|
30
|
+
for(let i=0;i<80 && !stopped;i++){
|
|
31
|
+
if(api.exitCode!==null)break
|
|
32
|
+
try {
|
|
33
|
+
const res=await fetch(`http://127.0.0.1:${port}/api/health`,{signal:AbortSignal.timeout(700)})
|
|
34
|
+
if(res.ok && (await res.json()).transport==='feathers-v5'){ready=true;break}
|
|
35
|
+
}catch{}
|
|
36
|
+
await sleep(200)
|
|
37
|
+
}
|
|
38
|
+
if(!ready){console.error('API Feathers non disponible sur 127.0.0.1:'+port);stop(1)}
|
|
39
|
+
if(!stopped){
|
|
40
|
+
const args=usesVite?['node_modules/vite/bin/vite.js','--host','127.0.0.1','--port','5173','--strictPort']
|
|
41
|
+
:[isSSG?'node_modules/vite/bin/vite.js':'scripts/serve-ssr.mjs',...(isSSG?['--host','127.0.0.1','--port','5173','--strictPort']:[])]
|
|
42
|
+
const ui=spawn(process.execPath,args,{env,stdio:'inherit',windowsHide:true})
|
|
43
|
+
processes.push(ui)
|
|
44
|
+
ui.once('error',err=>{console.error('Frontend:',err.message);stop(1)})
|
|
45
|
+
ui.once('exit',code=>{if(!stopped){console.error('Frontend stopped:',code);stop(code||1)}})
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# daisyUI SSG — FeathersJS v5
|
|
2
|
+
|
|
3
|
+
Exemple de rendu SSG avec Vue 3, Pinia, Vite 7 et Tailwind CSS/daisyUI. Le service FeathersJS v5 est exécuté dans un processus indépendant. Les pages statiques ne contiennent pas de notes ni de secrets. Données de démonstration uniquement, sans authentification.
|
|
4
|
+
|
|
5
|
+
```powershell
|
|
6
|
+
corepack pnpm install
|
|
7
|
+
# Terminal 1 : API Feathers
|
|
8
|
+
corepack pnpm run dev:api
|
|
9
|
+
# Terminal 2 : frontend
|
|
10
|
+
corepack pnpm run dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
corepack pnpm run typecheck
|
|
15
|
+
corepack pnpm run build
|
|
16
|
+
corepack pnpm run serve:production
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
En développement, Vite sert une application cliente. Le build pré-rend `/` et `/about` dans `dist/ssg`. Seules des pages publiques sont générées ; le service `notes` reste dynamique et nécessite une API accessible en production. Prévoir la configuration de proxy `/api` sur le serveur statique.
|
|
20
|
+
|
|
21
|
+
La certification Windows complète reste à exécuter. Ne pas exposer publiquement l’API de démonstration en mémoire, sans authentification.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createFeathersRuntime } from '@vevedh/vite-plugin-feathersjs'
|
|
3
|
+
const adapter = process.env.FEATHERS_ADAPTER === 'express' ? 'express' : 'koa'
|
|
4
|
+
const port = Number(process.env.FEATHERS_API_PORT || 3030)
|
|
5
|
+
const wsPort = Number(process.env.FEATHERS_SOCKET_PORT || 3031)
|
|
6
|
+
const realtime = process.env.FEATHERS_SOCKETIO === '1'
|
|
7
|
+
const origin = process.env.FEATHERS_PUBLIC_ORIGIN || 'http://127.0.0.1:5173'
|
|
8
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535 || !Number.isInteger(wsPort) || wsPort === port) throw new Error('Invalid port configuration')
|
|
9
|
+
const modules = [
|
|
10
|
+
new URL('./services/health/health.service.mjs', import.meta.url),
|
|
11
|
+
new URL('./services/notes/notes.service.mjs', import.meta.url),
|
|
12
|
+
]
|
|
13
|
+
const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {
|
|
14
|
+
serviceFiles: [], adapter, prefix: '/api', cors: false,
|
|
15
|
+
socketio: realtime ? { port: wsPort, origins: [origin], host: '127.0.0.1' } : false,
|
|
16
|
+
})
|
|
17
|
+
await runtime.start()
|
|
18
|
+
const httpServer = createServer((req, res) => {
|
|
19
|
+
const pathname = new URL(req.url || '/', 'http://localhost').pathname
|
|
20
|
+
if (pathname !== '/api' && !pathname.startsWith('/api/')) {
|
|
21
|
+
res.writeHead(404, { 'content-type': 'application/json' }); res.end('{"error":"Not found"}'); return
|
|
22
|
+
}
|
|
23
|
+
// The Feathers transport is mounted at /api, while its services register as health/notes.
|
|
24
|
+
req.url = (req.url || '/').slice(4) || '/'
|
|
25
|
+
runtime.handler(req, res, error => {
|
|
26
|
+
if (res.headersSent) { res.end(); return }
|
|
27
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
28
|
+
res.end(JSON.stringify({ error: 'Internal server error' }))
|
|
29
|
+
if (error) console.error('[feathers] request failed')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
await new Promise((resolve, reject) => {
|
|
33
|
+
httpServer.once('error', reject)
|
|
34
|
+
httpServer.listen(port, '127.0.0.1', resolve)
|
|
35
|
+
})
|
|
36
|
+
console.log(`Feathers ${adapter} API listening on http://127.0.0.1:${port}/api (socketio=${realtime})`)
|
|
37
|
+
let stopping = false
|
|
38
|
+
async function close() {
|
|
39
|
+
if (stopping) return
|
|
40
|
+
stopping = true
|
|
41
|
+
await new Promise(resolve => httpServer.close(resolve))
|
|
42
|
+
await runtime.close()
|
|
43
|
+
}
|
|
44
|
+
process.on('SIGINT', () => { void close().then(() => process.exit(0)) })
|
|
45
|
+
process.on('SIGTERM', () => { void close().then(() => process.exit(0)) })
|