create-ecomiq 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/bin/create-ecomiq.mjs +10 -0
- package/package.json +47 -0
- package/src/index.mjs +268 -0
- package/src/scaffold.mjs +194 -0
- package/src/templates.mjs +84 -0
- package/src/verify-domain.mjs +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# create-ecomiq
|
|
2
|
+
|
|
3
|
+
Scaffold interactivo de storefronts Ecomiq (estilo `create-vite` / `create-next-app`).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm create ecomiq@latest
|
|
7
|
+
# o
|
|
8
|
+
npx create-ecomiq@latest my-store
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Flujo
|
|
12
|
+
|
|
13
|
+
1. Nombre del proyecto
|
|
14
|
+
2. **Domain** — se valida con `GET /v1/settings` + `x-Store-Domain`
|
|
15
|
+
3. **Template** — catálogo en R2 (`templates.json`); hoy solo `default`
|
|
16
|
+
4. Package manager + install
|
|
17
|
+
|
|
18
|
+
## Flags
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
create-ecomiq my-store \
|
|
22
|
+
--domain marketplace \
|
|
23
|
+
--template default \
|
|
24
|
+
--pm pnpm \
|
|
25
|
+
--yes
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Flag | Descripción |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `--domain` | Domain de la tienda (obligatorio con `--yes`) |
|
|
31
|
+
| `--template` | Id del template (`default`) |
|
|
32
|
+
| `--pm` | `npm` \| `pnpm` \| `yarn` \| `bun` |
|
|
33
|
+
| `--yes` / `-y` | Sin prompts |
|
|
34
|
+
| `--skip-install` | Solo archivos |
|
|
35
|
+
|
|
36
|
+
## Templates
|
|
37
|
+
|
|
38
|
+
Los zips viven en R2:
|
|
39
|
+
|
|
40
|
+
- `https://pub-2b5396b516d940dbb1088d750701749c.r2.dev/templates.json`
|
|
41
|
+
- `https://pub-2b5396b516d940dbb1088d750701749c.r2.dev/template-{id}.zip`
|
|
42
|
+
|
|
43
|
+
Tras el scaffold usa la CLI del framework:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd my-store
|
|
47
|
+
npm run dev # ecomiq dev
|
|
48
|
+
npm run build
|
|
49
|
+
npm run deploy
|
|
50
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCreate } from '../src/index.mjs'
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
await runCreate(process.argv.slice(2))
|
|
6
|
+
} catch (error) {
|
|
7
|
+
const message = error instanceof Error ? error.message : 'Error desconocido'
|
|
8
|
+
console.error(`\n ✖ ${message}\n`)
|
|
9
|
+
process.exitCode = 1
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-ecomiq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold interactivo de storefronts Ecomiq (estilo create-vite)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ecomiq",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ecomiq/ecomiq-storefront.git",
|
|
11
|
+
"directory": "packages/create-ecomiq"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/ecomiq/ecomiq-storefront/tree/main/packages/create-ecomiq",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ecomiq/ecomiq-storefront/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ecomiq",
|
|
19
|
+
"create-ecomiq",
|
|
20
|
+
"scaffold",
|
|
21
|
+
"storefront",
|
|
22
|
+
"ecommerce"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"create-ecomiq": "./bin/create-ecomiq.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin",
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@clack/prompts": "^0.11.0",
|
|
41
|
+
"adm-zip": "^0.6.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/adm-zip": "^0.5.8"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {}
|
|
47
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI create-ecomiq — scaffold interactivo de storefronts.
|
|
3
|
+
*
|
|
4
|
+
* npx create-ecomiq@latest [dir]
|
|
5
|
+
* npm create ecomiq@latest
|
|
6
|
+
*/
|
|
7
|
+
import * as p from '@clack/prompts'
|
|
8
|
+
import { basename, resolve } from 'node:path'
|
|
9
|
+
import { verifyDomain } from './verify-domain.mjs'
|
|
10
|
+
import { downloadTemplateZip, listTemplates } from './templates.mjs'
|
|
11
|
+
import {
|
|
12
|
+
detectPackageManager,
|
|
13
|
+
extractTemplateZip,
|
|
14
|
+
installDependencies,
|
|
15
|
+
resolveTargetDirectory,
|
|
16
|
+
slugifyDeployName,
|
|
17
|
+
writeProjectConfig,
|
|
18
|
+
} from './scaffold.mjs'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string[]} argv
|
|
22
|
+
*/
|
|
23
|
+
export function parseArgs(argv) {
|
|
24
|
+
/** @type {{
|
|
25
|
+
* dir?: string
|
|
26
|
+
* domain?: string
|
|
27
|
+
* template?: string
|
|
28
|
+
* pm?: 'npm' | 'pnpm' | 'yarn' | 'bun'
|
|
29
|
+
* yes: boolean
|
|
30
|
+
* skipInstall: boolean
|
|
31
|
+
* help: boolean
|
|
32
|
+
* }} */
|
|
33
|
+
const out = {
|
|
34
|
+
yes: false,
|
|
35
|
+
skipInstall: false,
|
|
36
|
+
help: false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
const arg = argv[i]
|
|
41
|
+
if (arg === '--help' || arg === '-h') {
|
|
42
|
+
out.help = true
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (arg === '--yes' || arg === '-y') {
|
|
46
|
+
out.yes = true
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (arg === '--skip-install') {
|
|
50
|
+
out.skipInstall = true
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (arg === '--domain') {
|
|
54
|
+
out.domain = argv[++i]
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (arg === '--template') {
|
|
58
|
+
out.template = argv[++i]
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (arg === '--pm') {
|
|
62
|
+
const value = argv[++i]
|
|
63
|
+
if (value === 'npm' || value === 'pnpm' || value === 'yarn' || value === 'bun') {
|
|
64
|
+
out.pm = value
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error(`Package manager no soportado: ${value}`)
|
|
67
|
+
}
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (arg.startsWith('-')) {
|
|
71
|
+
throw new Error(`Flag desconocida: ${arg}`)
|
|
72
|
+
}
|
|
73
|
+
if (!out.dir) out.dir = arg
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printHelp() {
|
|
80
|
+
console.log(`
|
|
81
|
+
Uso
|
|
82
|
+
create-ecomiq [dir] [opciones]
|
|
83
|
+
|
|
84
|
+
Opciones
|
|
85
|
+
--domain <slug> Domain de la tienda (x-Store-Domain)
|
|
86
|
+
--template <id> Template (default: default)
|
|
87
|
+
--pm <npm|pnpm|yarn|bun>
|
|
88
|
+
--yes, -y Sin prompts (requiere --domain)
|
|
89
|
+
--skip-install No ejecutar install
|
|
90
|
+
--help, -h
|
|
91
|
+
|
|
92
|
+
Ejemplos
|
|
93
|
+
npx create-ecomiq@latest my-store
|
|
94
|
+
npm create ecomiq@latest
|
|
95
|
+
create-ecomiq . --domain marketplace --template default --yes
|
|
96
|
+
`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string[]} argv
|
|
101
|
+
*/
|
|
102
|
+
export async function runCreate(argv) {
|
|
103
|
+
const args = parseArgs(argv)
|
|
104
|
+
if (args.help) {
|
|
105
|
+
printHelp()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
p.intro('◈ create-ecomiq')
|
|
110
|
+
|
|
111
|
+
if (args.yes && !args.domain) {
|
|
112
|
+
throw new Error('Con --yes debes pasar --domain')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let projectName = args.dir
|
|
116
|
+
if (!projectName && !args.yes) {
|
|
117
|
+
const answer = await p.text({
|
|
118
|
+
message: '¿Nombre del proyecto?',
|
|
119
|
+
placeholder: 'my-store',
|
|
120
|
+
defaultValue: 'my-store',
|
|
121
|
+
validate: (value) => {
|
|
122
|
+
if (!value.trim()) return 'El nombre es obligatorio'
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
if (p.isCancel(answer)) {
|
|
126
|
+
p.cancel('Cancelado')
|
|
127
|
+
process.exitCode = 1
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
projectName = answer.trim()
|
|
131
|
+
}
|
|
132
|
+
projectName = (projectName ?? 'my-store').trim() || 'my-store'
|
|
133
|
+
|
|
134
|
+
const targetDir = resolveTargetDirectory(
|
|
135
|
+
projectName === '.' ? process.cwd() : resolve(process.cwd(), projectName),
|
|
136
|
+
)
|
|
137
|
+
const folderName = basename(targetDir)
|
|
138
|
+
|
|
139
|
+
let domain = args.domain?.trim()
|
|
140
|
+
let storeName = domain ?? ''
|
|
141
|
+
|
|
142
|
+
if (!args.yes) {
|
|
143
|
+
while (true) {
|
|
144
|
+
const answer = await p.text({
|
|
145
|
+
message: 'Domain de la tienda (x-Store-Domain)',
|
|
146
|
+
placeholder: 'marketplace',
|
|
147
|
+
initialValue: domain ?? '',
|
|
148
|
+
validate: (value) => {
|
|
149
|
+
if (!value.trim()) return 'El domain es obligatorio'
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
if (p.isCancel(answer)) {
|
|
153
|
+
p.cancel('Cancelado')
|
|
154
|
+
process.exitCode = 1
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const spinner = p.spinner()
|
|
159
|
+
spinner.start('Verificando domain en la API…')
|
|
160
|
+
const result = await verifyDomain(answer.trim())
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
spinner.stop(result.message)
|
|
163
|
+
p.log.error(result.message)
|
|
164
|
+
domain = answer.trim()
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
spinner.stop(`Tienda: ${result.storeName}`)
|
|
168
|
+
domain = result.domain
|
|
169
|
+
storeName = result.storeName
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
const spinner = p.spinner()
|
|
174
|
+
spinner.start('Verificando domain en la API…')
|
|
175
|
+
const result = await verifyDomain(domain)
|
|
176
|
+
if (!result.ok) {
|
|
177
|
+
spinner.stop(result.message)
|
|
178
|
+
throw new Error(result.message)
|
|
179
|
+
}
|
|
180
|
+
spinner.stop(`Tienda: ${result.storeName}`)
|
|
181
|
+
domain = result.domain
|
|
182
|
+
storeName = result.storeName
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const templates = await listTemplates()
|
|
186
|
+
let templateId = args.template?.trim() || 'default'
|
|
187
|
+
|
|
188
|
+
if (!args.yes) {
|
|
189
|
+
const options = templates.map((tpl) => ({
|
|
190
|
+
value: tpl.id,
|
|
191
|
+
label: tpl.name,
|
|
192
|
+
hint: tpl.description,
|
|
193
|
+
}))
|
|
194
|
+
const selected = await p.select({
|
|
195
|
+
message: 'Elige un template',
|
|
196
|
+
options: options.length ? options : [{ value: 'default', label: 'Default' }],
|
|
197
|
+
initialValue: templateId,
|
|
198
|
+
})
|
|
199
|
+
if (p.isCancel(selected)) {
|
|
200
|
+
p.cancel('Cancelado')
|
|
201
|
+
process.exitCode = 1
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
templateId = selected
|
|
205
|
+
} else if (!templates.some((tpl) => tpl.id === templateId) && templateId !== 'default') {
|
|
206
|
+
throw new Error(`Template desconocido: ${templateId}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let pm = args.pm ?? detectPackageManager()
|
|
210
|
+
if (!args.yes && !args.pm) {
|
|
211
|
+
const selected = await p.select({
|
|
212
|
+
message: 'Package manager',
|
|
213
|
+
options: [
|
|
214
|
+
{ value: 'npm', label: 'npm' },
|
|
215
|
+
{ value: 'pnpm', label: 'pnpm' },
|
|
216
|
+
{ value: 'yarn', label: 'yarn' },
|
|
217
|
+
{ value: 'bun', label: 'bun' },
|
|
218
|
+
],
|
|
219
|
+
initialValue: pm,
|
|
220
|
+
})
|
|
221
|
+
if (p.isCancel(selected)) {
|
|
222
|
+
p.cancel('Cancelado')
|
|
223
|
+
process.exitCode = 1
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
pm = selected
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const deployName = slugifyDeployName(folderName)
|
|
230
|
+
const downloadSpinner = p.spinner()
|
|
231
|
+
downloadSpinner.start(`Descargando template "${templateId}"…`)
|
|
232
|
+
const zipBytes = await downloadTemplateZip(templateId)
|
|
233
|
+
downloadSpinner.stop('Template descargado')
|
|
234
|
+
|
|
235
|
+
const extractSpinner = p.spinner()
|
|
236
|
+
extractSpinner.start('Extrayendo archivos…')
|
|
237
|
+
extractTemplateZip(zipBytes, targetDir, templateId)
|
|
238
|
+
writeProjectConfig(targetDir, { domain, deployName })
|
|
239
|
+
extractSpinner.stop('Proyecto listo')
|
|
240
|
+
|
|
241
|
+
if (!args.skipInstall) {
|
|
242
|
+
const installSpinner = p.spinner()
|
|
243
|
+
installSpinner.start(`Instalando con ${pm}…`)
|
|
244
|
+
try {
|
|
245
|
+
await installDependencies(targetDir, pm)
|
|
246
|
+
installSpinner.stop('Dependencias instaladas')
|
|
247
|
+
} catch (error) {
|
|
248
|
+
installSpinner.stop('Install falló')
|
|
249
|
+
throw error
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const rel =
|
|
254
|
+
targetDir === process.cwd() ? '.' : projectName === '.' ? '.' : projectName
|
|
255
|
+
|
|
256
|
+
p.outro(
|
|
257
|
+
[
|
|
258
|
+
`Storefront para ${storeName} (${domain})`,
|
|
259
|
+
'',
|
|
260
|
+
` cd ${rel}`,
|
|
261
|
+
args.skipInstall ? ` ${pm} install` : null,
|
|
262
|
+
` ${pm === 'npm' ? 'npm run' : pm} dev`,
|
|
263
|
+
'',
|
|
264
|
+
]
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.join('\n'),
|
|
267
|
+
)
|
|
268
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { dirname, join, resolve } from 'node:path'
|
|
13
|
+
import AdmZip from 'adm-zip'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} name
|
|
17
|
+
*/
|
|
18
|
+
export function slugifyDeployName(name) {
|
|
19
|
+
const slug = name
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
23
|
+
.replace(/^-+|-+$/g, '')
|
|
24
|
+
return slug || 'my-storefront'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ domain: string, deployName: string }} options
|
|
29
|
+
*/
|
|
30
|
+
export function buildEcomiqConfig({ domain, deployName }) {
|
|
31
|
+
return `import { defineConfig } from '@ecomiq/storefront/config'
|
|
32
|
+
|
|
33
|
+
export default defineConfig({
|
|
34
|
+
// Identidad para x-Store-Domain: slug corto o dominio completo
|
|
35
|
+
// (marketplace | marketplace.ecomiq.app | marketplace.pe)
|
|
36
|
+
domain: ${JSON.stringify(domain)},
|
|
37
|
+
|
|
38
|
+
theme: {
|
|
39
|
+
primary: 'oklch(0.45 0.15 165)',
|
|
40
|
+
radius: '0.75rem',
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// Portable: Node por defecto. Opt-in a workerd con runtime: 'workerd'.
|
|
44
|
+
dev: { port: 3000 },
|
|
45
|
+
|
|
46
|
+
deploy: {
|
|
47
|
+
target: 'cloudflare',
|
|
48
|
+
name: ${JSON.stringify(deployName)},
|
|
49
|
+
// routes: [{ pattern: 'tienda.example.com', custom_domain: true }],
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} configSource
|
|
57
|
+
* @param {{ domain: string, deployName: string }} patch
|
|
58
|
+
*/
|
|
59
|
+
export function patchEcomiqConfigSource(configSource, patch) {
|
|
60
|
+
let next = configSource
|
|
61
|
+
if (/domain:\s*['"][^'"]*['"]/.test(next)) {
|
|
62
|
+
next = next.replace(/domain:\s*['"][^'"]*['"]/, `domain: ${JSON.stringify(patch.domain)}`)
|
|
63
|
+
}
|
|
64
|
+
if (/name:\s*['"][^'"]*['"]/.test(next)) {
|
|
65
|
+
// Solo el name dentro de deploy (primera aparición tras deploy suele bastar;
|
|
66
|
+
// fallback: reescribir archivo completo si el patrón es ambiguo).
|
|
67
|
+
next = next.replace(
|
|
68
|
+
/(deploy:\s*\{[\s\S]*?name:\s*)['"][^'"]*['"]/,
|
|
69
|
+
`$1${JSON.stringify(patch.deployName)}`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return next
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extrae un zip a destino (JS puro vía adm-zip; Windows/macOS/Linux sin CLI).
|
|
77
|
+
* Espera raíz `template-{id}/` o archivos sueltos.
|
|
78
|
+
* @param {Uint8Array | Buffer} zipBytes
|
|
79
|
+
* @param {string} destDir
|
|
80
|
+
* @param {string} templateId
|
|
81
|
+
*/
|
|
82
|
+
export function extractTemplateZip(zipBytes, destDir, templateId) {
|
|
83
|
+
mkdirSync(destDir, { recursive: true })
|
|
84
|
+
const staging = mkdtempSync(join(tmpdir(), 'create-ecomiq-'))
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const zip = new AdmZip(Buffer.from(zipBytes))
|
|
88
|
+
zip.extractAllTo(staging, /* overwrite */ true)
|
|
89
|
+
|
|
90
|
+
const nested = join(staging, `template-${templateId}`)
|
|
91
|
+
const source = existsSync(nested) ? nested : findExtractedRoot(staging)
|
|
92
|
+
if (!source) {
|
|
93
|
+
throw new Error(`No se encontró el contenido del template "${templateId}" en el zip`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
copyTree(source, destDir)
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(staging, { recursive: true, force: true })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findExtractedRoot(staging) {
|
|
103
|
+
const entries = readdirSync(staging, { withFileTypes: true })
|
|
104
|
+
const dirs = entries.filter((entry) => entry.isDirectory())
|
|
105
|
+
if (dirs.length === 1) return join(staging, dirs[0].name)
|
|
106
|
+
if (entries.some((entry) => entry.name === 'ecomiq.config.js' || entry.name === 'package.json')) {
|
|
107
|
+
return staging
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function copyTree(from, to) {
|
|
113
|
+
mkdirSync(to, { recursive: true })
|
|
114
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
115
|
+
const src = join(from, entry.name)
|
|
116
|
+
const dest = join(to, entry.name)
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
copyTree(src, dest)
|
|
119
|
+
} else {
|
|
120
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
121
|
+
// rename across devices can fail; read/write is safer for tmp → project
|
|
122
|
+
writeFileSync(dest, readFileSync(src))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} destDir
|
|
129
|
+
* @param {{ domain: string, deployName: string }} options
|
|
130
|
+
*/
|
|
131
|
+
export function writeProjectConfig(destDir, { domain, deployName }) {
|
|
132
|
+
const configPath = join(destDir, 'ecomiq.config.js')
|
|
133
|
+
if (existsSync(configPath)) {
|
|
134
|
+
const current = readFileSync(configPath, 'utf8')
|
|
135
|
+
writeFileSync(configPath, patchEcomiqConfigSource(current, { domain, deployName }))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
writeFileSync(configPath, buildEcomiqConfig({ domain, deployName }))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} destDir
|
|
143
|
+
* @param {'npm' | 'pnpm' | 'yarn' | 'bun'} pm
|
|
144
|
+
*/
|
|
145
|
+
export function installDependencies(destDir, pm) {
|
|
146
|
+
const args =
|
|
147
|
+
pm === 'yarn' ? ['install'] : pm === 'pnpm' ? ['install'] : pm === 'bun' ? ['install'] : ['install']
|
|
148
|
+
|
|
149
|
+
return new Promise((resolvePromise, reject) => {
|
|
150
|
+
const child = spawn(pm, args, {
|
|
151
|
+
cwd: destDir,
|
|
152
|
+
stdio: 'inherit',
|
|
153
|
+
shell: process.platform === 'win32',
|
|
154
|
+
env: { ...process.env, CI: process.env.CI ?? 'true' },
|
|
155
|
+
})
|
|
156
|
+
child.on('exit', (code) => {
|
|
157
|
+
if (code === 0) resolvePromise()
|
|
158
|
+
else reject(new Error(`${pm} install salió con código ${code}`))
|
|
159
|
+
})
|
|
160
|
+
child.on('error', reject)
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Detecta package manager desde user-agent de npm_config o locks.
|
|
166
|
+
* @param {string} [cwd]
|
|
167
|
+
* @returns {'npm' | 'pnpm' | 'yarn' | 'bun'}
|
|
168
|
+
*/
|
|
169
|
+
export function detectPackageManager(cwd = process.cwd()) {
|
|
170
|
+
const ua = process.env.npm_config_user_agent ?? ''
|
|
171
|
+
if (ua.includes('pnpm')) return 'pnpm'
|
|
172
|
+
if (ua.includes('yarn')) return 'yarn'
|
|
173
|
+
if (ua.includes('bun')) return 'bun'
|
|
174
|
+
if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
175
|
+
if (existsSync(join(cwd, 'yarn.lock'))) return 'yarn'
|
|
176
|
+
if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) return 'bun'
|
|
177
|
+
return 'npm'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {string} targetPath
|
|
182
|
+
*/
|
|
183
|
+
export function resolveTargetDirectory(targetPath) {
|
|
184
|
+
const resolved = resolve(targetPath)
|
|
185
|
+
if (existsSync(resolved)) {
|
|
186
|
+
const entries = readdirSync(resolved)
|
|
187
|
+
if (entries.length > 0) {
|
|
188
|
+
throw new Error(`El directorio ya existe y no está vacío: ${resolved}`)
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
mkdirSync(resolved, { recursive: true })
|
|
192
|
+
}
|
|
193
|
+
return resolved
|
|
194
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catálogo y descarga de templates desde R2 público.
|
|
3
|
+
*/
|
|
4
|
+
export const DEFAULT_TEMPLATES_BASE_URL =
|
|
5
|
+
'https://pub-2b5396b516d940dbb1088d750701749c.r2.dev'
|
|
6
|
+
|
|
7
|
+
export const FALLBACK_TEMPLATES = [
|
|
8
|
+
{
|
|
9
|
+
id: 'default',
|
|
10
|
+
name: 'Default',
|
|
11
|
+
description: 'Storefront base: catálogo, carrito, checkout y cuenta',
|
|
12
|
+
},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }} [options]
|
|
17
|
+
* @returns {Promise<Array<{ id: string, name: string, description?: string }>>}
|
|
18
|
+
*/
|
|
19
|
+
export async function listTemplates(options = {}) {
|
|
20
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_TEMPLATES_BASE_URL).replace(/\/$/, '')
|
|
21
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
22
|
+
|
|
23
|
+
if (typeof fetchImpl !== 'function') return [...FALLBACK_TEMPLATES]
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetchImpl(`${baseUrl}/templates.json`, {
|
|
27
|
+
headers: { Accept: 'application/json' },
|
|
28
|
+
})
|
|
29
|
+
if (!response.ok) return [...FALLBACK_TEMPLATES]
|
|
30
|
+
const json = await response.json()
|
|
31
|
+
const items = Array.isArray(json)
|
|
32
|
+
? json
|
|
33
|
+
: Array.isArray(json?.templates)
|
|
34
|
+
? json.templates
|
|
35
|
+
: null
|
|
36
|
+
if (!items?.length) return [...FALLBACK_TEMPLATES]
|
|
37
|
+
|
|
38
|
+
return items
|
|
39
|
+
.filter((item) => item && typeof item.id === 'string' && item.id.trim())
|
|
40
|
+
.map((item) => ({
|
|
41
|
+
id: item.id.trim(),
|
|
42
|
+
name: typeof item.name === 'string' && item.name.trim() ? item.name.trim() : item.id,
|
|
43
|
+
description:
|
|
44
|
+
typeof item.description === 'string' ? item.description : undefined,
|
|
45
|
+
}))
|
|
46
|
+
} catch {
|
|
47
|
+
return [...FALLBACK_TEMPLATES]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} templateId
|
|
53
|
+
* @param {{ baseUrl?: string }} [options]
|
|
54
|
+
*/
|
|
55
|
+
export function templateZipUrl(templateId, options = {}) {
|
|
56
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_TEMPLATES_BASE_URL).replace(/\/$/, '')
|
|
57
|
+
return `${baseUrl}/template-${templateId}.zip`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} templateId
|
|
62
|
+
* @param {{ baseUrl?: string, fetchImpl?: typeof fetch }} [options]
|
|
63
|
+
* @returns {Promise<Uint8Array>}
|
|
64
|
+
*/
|
|
65
|
+
export async function downloadTemplateZip(templateId, options = {}) {
|
|
66
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
67
|
+
if (typeof fetchImpl !== 'function') {
|
|
68
|
+
throw new Error('fetch no está disponible en este runtime')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const url = templateZipUrl(templateId, options)
|
|
72
|
+
const response = await fetchImpl(url)
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`No se pudo descargar el template "${templateId}" (${response.status}): ${url}`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const buffer = new Uint8Array(await response.arrayBuffer())
|
|
80
|
+
if (buffer.byteLength === 0) {
|
|
81
|
+
throw new Error(`El zip del template "${templateId}" está vacío`)
|
|
82
|
+
}
|
|
83
|
+
return buffer
|
|
84
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifica que un domain exista en la API storefront (GET /v1/settings).
|
|
3
|
+
*/
|
|
4
|
+
export const DEFAULT_SETTINGS_URL = 'https://storefront.ecomiq.pe/v1/settings'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} domain
|
|
8
|
+
* @param {{ settingsUrl?: string, fetchImpl?: typeof fetch }} [options]
|
|
9
|
+
* @returns {Promise<{ ok: true, storeName: string, domain: string, settings: unknown } | { ok: false, status: number, message: string }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function verifyDomain(domain, options = {}) {
|
|
12
|
+
const trimmed = domain.trim()
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return { ok: false, status: 0, message: 'El domain es obligatorio' }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const settingsUrl = options.settingsUrl ?? DEFAULT_SETTINGS_URL
|
|
18
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
19
|
+
if (typeof fetchImpl !== 'function') {
|
|
20
|
+
return { ok: false, status: 0, message: 'fetch no está disponible en este runtime' }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let response
|
|
24
|
+
try {
|
|
25
|
+
response = await fetchImpl(settingsUrl, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers: {
|
|
28
|
+
Accept: 'application/json',
|
|
29
|
+
'x-Store-Domain': trimmed,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const detail = error instanceof Error ? error.message : 'Error de red'
|
|
34
|
+
return { ok: false, status: 0, message: `No se pudo contactar la API: ${detail}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
status: response.status,
|
|
41
|
+
message:
|
|
42
|
+
response.status === 404
|
|
43
|
+
? `No existe una tienda con domain "${trimmed}"`
|
|
44
|
+
: `La API respondió ${response.status} para domain "${trimmed}"`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let settings
|
|
49
|
+
try {
|
|
50
|
+
settings = await response.json()
|
|
51
|
+
} catch {
|
|
52
|
+
return { ok: false, status: response.status, message: 'La API no devolvió JSON válido' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const storeName =
|
|
56
|
+
settings &&
|
|
57
|
+
typeof settings === 'object' &&
|
|
58
|
+
settings.store &&
|
|
59
|
+
typeof settings.store === 'object' &&
|
|
60
|
+
typeof settings.store.name === 'string'
|
|
61
|
+
? settings.store.name
|
|
62
|
+
: trimmed
|
|
63
|
+
|
|
64
|
+
return { ok: true, storeName, domain: trimmed, settings }
|
|
65
|
+
}
|