@newlogic-digital/cli 1.4.0 → 1.5.0-next.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/index.mjs +45 -24
- package/package.json +5 -2
- package/src/commands/blocks/index.mjs +195 -0
- package/src/commands/blocks/repository.mjs +126 -0
- package/src/commands/blocks/service.mjs +587 -0
- package/src/commands/init/ui.mjs +34 -6
- package/src/utils.mjs +14 -3
package/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import init from './src/commands/init/index.mjs'
|
|
4
4
|
import cms from './src/commands/cms/index.mjs'
|
|
5
|
+
import blocks from './src/commands/blocks/index.mjs'
|
|
5
6
|
import { styleText } from 'node:util'
|
|
6
7
|
import { version, name } from './src/utils.mjs'
|
|
7
8
|
|
|
@@ -60,30 +61,42 @@ const command = rawArgs[0]
|
|
|
60
61
|
|
|
61
62
|
if (!command) {
|
|
62
63
|
console.log(`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
${styleText('blue', `${name} v${version}`)}
|
|
65
|
+
|
|
66
|
+
Usage:
|
|
67
|
+
|
|
68
|
+
-- init --
|
|
69
|
+
${styleText('green', 'newlogic init')} - Creates a new project in current directory
|
|
70
|
+
${styleText('green', 'newlogic init')} ${styleText('yellow', '<directory>')} - Creates a new project in new directory with the name ${styleText('yellow', '<directory>')}
|
|
71
|
+
${styleText('green', 'newlogic init ui')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in current directory
|
|
72
|
+
${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/ui')} project in new directory with the name ${styleText('yellow', '<directory>')}
|
|
73
|
+
${styleText('green', 'newlogic init cms')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in current directory
|
|
74
|
+
${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} project in new directory with the name ${styleText('yellow', '<directory>')}
|
|
75
|
+
${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --scope=blank --git --remote=<git-url> --install')}
|
|
76
|
+
${styleText('green', 'newlogic init cms')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '--branch=dev --clone=https --variant=cms-web --install --prepare --dev --migrations')}
|
|
77
|
+
${styleText('green', 'newlogic init ui')} ${styleText('yellow', '<directory>')} ${styleText('cyan', '-y')} - Runs with default options without prompts
|
|
78
|
+
|
|
79
|
+
-- cms --
|
|
80
|
+
${styleText('green', 'newlogic cms prepare')} - Copies templates and components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')}
|
|
81
|
+
${styleText('green', 'newlogic cms prepare views')} - Copies views from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
|
|
82
|
+
${styleText('green', 'newlogic cms prepare components')} - Copies components from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
|
|
83
|
+
${styleText('green', 'newlogic cms new-component')} ${styleText('yellow', '<name>')} - Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
|
|
84
|
+
${styleText('red', 'newlogic cms prepare templates')} - (deprecated) Copies templates from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
|
|
85
|
+
${styleText('red', 'newlogic cms prepare sections')} - (deprecated) Copies sections from ${styleText('blue', '@newlogic-digital/ui')} project to ${styleText('blue', '@newlogic-digital/cms')} even if they already exists
|
|
86
|
+
${styleText('red', 'newlogic cms new-section')} ${styleText('yellow', '<name>')} - (deprecated) Creates a new ${styleText('blue', '@newlogic-digital/cms')} section with name ${styleText('yellow', '<name>')}
|
|
87
|
+
|
|
88
|
+
-- blocks --
|
|
89
|
+
${styleText('green', 'newlogic blocks list')} - Lists all available installable blocks with descriptions
|
|
90
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name...>')} - Installs one or more blocks by kebab-case or PascalCase name
|
|
91
|
+
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies
|
|
92
|
+
${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}
|
|
93
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'about-accordion')}
|
|
94
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'AboutAccordion')}
|
|
95
|
+
${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}
|
|
96
|
+
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'about-accordion')}
|
|
97
|
+
${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}
|
|
98
|
+
${styleText('green', 'newlogic blocks update')}
|
|
99
|
+
`)
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
if (command === 'init') {
|
|
@@ -101,3 +114,11 @@ if (command === 'cms') {
|
|
|
101
114
|
|
|
102
115
|
await cms(action, name)
|
|
103
116
|
}
|
|
117
|
+
|
|
118
|
+
if (command === 'blocks') {
|
|
119
|
+
const { positionals } = parseCommandArgs(rawArgs.slice(1))
|
|
120
|
+
const action = positionals[0]
|
|
121
|
+
const names = positionals.slice(1)
|
|
122
|
+
|
|
123
|
+
await blocks(action, names)
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newlogic-digital/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0-next.1",
|
|
4
4
|
"main": "index.mjs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"newlogic-cli": "index.mjs",
|
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"oxlint": "oxlint",
|
|
11
|
-
"oxlint-fix": "oxlint --fix"
|
|
11
|
+
"oxlint-fix": "oxlint --fix",
|
|
12
|
+
"test": "node --test",
|
|
13
|
+
"publish-next": "npm publish --tag next"
|
|
12
14
|
},
|
|
13
15
|
"dependencies": {
|
|
14
16
|
"@clack/prompts": "^1.1.0",
|
|
17
|
+
"ajv": "^8.17.1",
|
|
15
18
|
"dedent": "^1.7.2",
|
|
16
19
|
"es-toolkit": "^1.45.1",
|
|
17
20
|
"fast-glob": "^3.3.3",
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { styleText } from 'node:util'
|
|
2
|
+
import { createBlocksService } from './service.mjs'
|
|
3
|
+
|
|
4
|
+
function label(color, text) {
|
|
5
|
+
return styleText([color, 'bold'], text)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function highlightQuoted(text) {
|
|
9
|
+
return text.replace(/"([^"]+)"/g, (_, value) => styleText(['yellow', 'bold'], `"${value}"`))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function highlightTokens(text) {
|
|
13
|
+
return highlightQuoted(text)
|
|
14
|
+
.replaceAll('newlogic.config.json', styleText(['yellow', 'bold'], 'newlogic.config.json'))
|
|
15
|
+
.replaceAll('composer.json', styleText(['yellow', 'bold'], 'composer.json'))
|
|
16
|
+
.replaceAll('package.json', styleText(['yellow', 'bold'], 'package.json'))
|
|
17
|
+
.replaceAll('sharedFiles', styleText(['magenta', 'bold'], 'sharedFiles'))
|
|
18
|
+
.replace(/\bcomposer\b/g, styleText(['blue', 'bold'], 'composer'))
|
|
19
|
+
.replace(/\bnpm\b/g, styleText(['blue', 'bold'], 'npm'))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatErrorMessage(message) {
|
|
23
|
+
let nextMessage = highlightTokens(message)
|
|
24
|
+
|
|
25
|
+
if (nextMessage.startsWith('Command failed: ')) {
|
|
26
|
+
nextMessage = `Command failed: ${styleText(['red', 'bold'], nextMessage.slice(16))}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (nextMessage.startsWith('Request failed for ')) {
|
|
30
|
+
nextMessage = nextMessage.replace(
|
|
31
|
+
/^Request failed for ([^(]+) \((.+)\)$/,
|
|
32
|
+
(_, url, status) => `Request failed for ${styleText('cyan', url.trim())} ${styleText(['red', 'bold'], `(${status})`)}`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return nextMessage
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createSilentLogger() {
|
|
40
|
+
return {
|
|
41
|
+
info() {},
|
|
42
|
+
warn() {},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatInfoMessage(message) {
|
|
47
|
+
if (message.startsWith('install ')) {
|
|
48
|
+
return `${label('cyan', 'install')} ${styleText(['white', 'bold'], message.slice(8))}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (message.startsWith('remove ')) {
|
|
52
|
+
return `${label('cyan', 'remove')} ${styleText(['white', 'bold'], message.slice(7))}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (message.startsWith('write ')) {
|
|
56
|
+
return ` ${label('green', 'write')} ${styleText('white', message.slice(6))}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (message.startsWith('delete ')) {
|
|
60
|
+
return ` ${label('red', 'delete')} ${styleText('white', message.slice(7))}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (message.startsWith('skip missing ')) {
|
|
64
|
+
return ` ${label('yellow', 'skip')} ${styleText('dim', message.slice(13))} ${styleText('dim', '(missing)')}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (message.startsWith('skip ')) {
|
|
68
|
+
return ` ${label('yellow', 'skip')} ${styleText('dim', message.slice(5))}`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (message.startsWith('composer require ')) {
|
|
72
|
+
return `${label('blue', 'composer')} ${styleText(['white', 'bold'], message.slice(17))}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (message.startsWith('npm install ')) {
|
|
76
|
+
return `${label('blue', 'npm')} ${styleText(['white', 'bold'], message.slice(12))}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (message.startsWith('postInstall ')) {
|
|
80
|
+
return `${label('magenta', 'hook')} ${styleText(['white', 'bold'], message.slice(12))}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (message.startsWith('added ')) {
|
|
84
|
+
return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(6))}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (message.startsWith('removed ')) {
|
|
88
|
+
return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(8))}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (message.startsWith('updated ')) {
|
|
92
|
+
return `${label('green', 'done')} ${styleText(['white', 'bold'], message.slice(8))}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (message.startsWith('No blocks configured')) {
|
|
96
|
+
return `${label('yellow', 'info')} ${styleText('yellow', message)}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `${label('gray', 'info')} ${highlightTokens(message)}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createCliLogger() {
|
|
103
|
+
return {
|
|
104
|
+
info(message) {
|
|
105
|
+
console.log(formatInfoMessage(message))
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
warn(message) {
|
|
109
|
+
console.log(`${label('yellow', 'warn')} ${styleText('yellow', highlightTokens(message))}`)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printBlocksList(blocks) {
|
|
115
|
+
const width = blocks.reduce((maxWidth, block) => Math.max(maxWidth, block.name.length), 0)
|
|
116
|
+
|
|
117
|
+
console.log(`${styleText(['blue', 'bold'], 'Available Blocks')} ${styleText('dim', `(${blocks.length})`)}`)
|
|
118
|
+
console.log('')
|
|
119
|
+
|
|
120
|
+
for (const block of blocks) {
|
|
121
|
+
const name = styleText(['green', 'bold'], block.name.padEnd(width))
|
|
122
|
+
const description = block.description
|
|
123
|
+
? styleText('dim', block.description)
|
|
124
|
+
: styleText('dim', 'No description')
|
|
125
|
+
|
|
126
|
+
console.log(` ${name} ${description}`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function printBlocksUsage() {
|
|
131
|
+
console.log([
|
|
132
|
+
styleText(['blue', 'bold'], 'newlogic blocks'),
|
|
133
|
+
'',
|
|
134
|
+
styleText(['white', 'bold'], 'Usage:'),
|
|
135
|
+
'',
|
|
136
|
+
` ${styleText('green', 'newlogic blocks list')} - Lists all available blocks with descriptions`,
|
|
137
|
+
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', '<name...>')} - Adds one or more blocks by kebab-case or PascalCase name`,
|
|
138
|
+
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', '<name...>')} - Removes one or more blocks and orphaned dependencies`,
|
|
139
|
+
` ${styleText('green', 'newlogic blocks update')} - Reinstalls all configured blocks from ${styleText('yellow', 'newlogic.config.json')}`,
|
|
140
|
+
'',
|
|
141
|
+
styleText(['white', 'bold'], 'Examples:'),
|
|
142
|
+
'',
|
|
143
|
+
` ${styleText('green', 'newlogic blocks add')} ${styleText('yellow', 'header-nav-left contact-info-card hero-floating-text')}`,
|
|
144
|
+
` ${styleText('green', 'newlogic blocks remove')} ${styleText('yellow', 'header-nav-left hero-floating-text')}`,
|
|
145
|
+
].join('\n'))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default async function blocks(action, names = []) {
|
|
149
|
+
if (!action || action === 'help' || action === '--help') {
|
|
150
|
+
printBlocksUsage()
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (action === 'list') {
|
|
156
|
+
const service = createBlocksService({ logger: createSilentLogger() })
|
|
157
|
+
const entries = await service.listBlocks()
|
|
158
|
+
|
|
159
|
+
printBlocksList(entries)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (action === 'add') {
|
|
164
|
+
if (names.length === 0) {
|
|
165
|
+
throw new Error('Missing block name for "newlogic blocks add"')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const service = createBlocksService({ logger: createCliLogger() })
|
|
169
|
+
await service.addBlocks(names)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (action === 'remove') {
|
|
174
|
+
if (names.length === 0) {
|
|
175
|
+
throw new Error('Missing block name for "newlogic blocks remove"')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const service = createBlocksService({ logger: createCliLogger() })
|
|
179
|
+
await service.removeBlocks(names)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (action === 'update') {
|
|
184
|
+
const service = createBlocksService({ logger: createCliLogger() })
|
|
185
|
+
await service.updateBlocks()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
throw new Error(`Unknown blocks action "${action}"`)
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.log(`${label('red', 'error')} ${formatErrorMessage(error.message)}`)
|
|
193
|
+
process.exit(1)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { resolveInside } from '../../utils.mjs'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REMOTE_SOURCE_BASE_URL = 'https://git.newlogic.cz/newlogic-digital/newlogic-blocks/-/raw/main/src'
|
|
6
|
+
|
|
7
|
+
function encodePath(relativePath) {
|
|
8
|
+
return relativePath
|
|
9
|
+
.split('/')
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.map(segment => encodeURIComponent(segment))
|
|
12
|
+
.join('/')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildRemoteUrl(baseUrl, relativePath) {
|
|
16
|
+
return `${baseUrl}/${encodePath(relativePath)}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fetchOrThrow(fetchImpl, url) {
|
|
20
|
+
const response = await fetchImpl(url)
|
|
21
|
+
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Request failed for ${url} (${response.status} ${response.statusText})`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return response
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchJson(fetchImpl, url) {
|
|
30
|
+
const response = await fetchOrThrow(fetchImpl, url)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await response.json()
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
throw new Error(`Invalid JSON returned from ${url}: ${error.message}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchBuffer(fetchImpl, url) {
|
|
41
|
+
const response = await fetchOrThrow(fetchImpl, url)
|
|
42
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
43
|
+
|
|
44
|
+
return Buffer.from(arrayBuffer)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildGeneratedIndex(componentsRoot) {
|
|
48
|
+
const blocks = fs.readdirSync(componentsRoot, { withFileTypes: true })
|
|
49
|
+
.filter(entry => entry.isDirectory())
|
|
50
|
+
.map(entry => entry.name)
|
|
51
|
+
.filter(name => fs.existsSync(path.join(componentsRoot, name, 'meta.json')))
|
|
52
|
+
.map((name) => {
|
|
53
|
+
const metaPath = path.join(componentsRoot, name, 'meta.json')
|
|
54
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name: meta.name,
|
|
58
|
+
description: meta.description ?? '',
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return { blocks }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createRemoteBlocksRepository({ baseUrl = DEFAULT_REMOTE_SOURCE_BASE_URL, fetchImpl = fetch } = {}) {
|
|
66
|
+
return {
|
|
67
|
+
async getSchema() {
|
|
68
|
+
return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, 'schema.json'))
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async getIndex() {
|
|
72
|
+
return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, 'index.json'))
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async getMeta(name) {
|
|
76
|
+
return await fetchJson(fetchImpl, buildRemoteUrl(baseUrl, `components/${name}/meta.json`))
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getFileBuffer(name, sourcePath) {
|
|
80
|
+
return await fetchBuffer(fetchImpl, buildRemoteUrl(baseUrl, `components/${name}/${sourcePath}`))
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createFilesystemBlocksRepository({ rootDir }) {
|
|
86
|
+
const sourceRoot = path.resolve(rootDir, 'src')
|
|
87
|
+
const componentsRoot = path.join(sourceRoot, 'components')
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
async getSchema() {
|
|
91
|
+
const schemaPath = resolveInside(sourceRoot, 'schema.json')
|
|
92
|
+
|
|
93
|
+
return JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async getIndex() {
|
|
97
|
+
const indexPath = path.join(sourceRoot, 'index.json')
|
|
98
|
+
|
|
99
|
+
if (fs.existsSync(indexPath)) {
|
|
100
|
+
return JSON.parse(fs.readFileSync(indexPath, 'utf8'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return buildGeneratedIndex(componentsRoot)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async getMeta(name) {
|
|
107
|
+
const metaPath = resolveInside(componentsRoot, name, 'meta.json')
|
|
108
|
+
|
|
109
|
+
if (!fs.existsSync(metaPath)) {
|
|
110
|
+
throw new Error(`Missing meta.json for block "${name}" in ${componentsRoot}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async getFileBuffer(name, sourcePath) {
|
|
117
|
+
const filePath = resolveInside(componentsRoot, name, ...sourcePath.split('/'))
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(filePath)) {
|
|
120
|
+
throw new Error(`Missing source file for block "${name}": ${sourcePath}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return fs.readFileSync(filePath)
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import childProcess from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
5
|
+
import fse from 'fs-extra'
|
|
6
|
+
import { resolveInside } from '../../utils.mjs'
|
|
7
|
+
import { createRemoteBlocksRepository } from './repository.mjs'
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILE_NAME = 'newlogic.config.json'
|
|
10
|
+
const BLOCK_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
11
|
+
|
|
12
|
+
function defaultLogger() {
|
|
13
|
+
return {
|
|
14
|
+
info: message => console.log(message),
|
|
15
|
+
warn: message => console.warn(message),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runOrThrow(command, args, options = {}) {
|
|
20
|
+
const result = childProcess.spawnSync(command, args, {
|
|
21
|
+
cwd: options.cwd,
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (result.error) {
|
|
26
|
+
throw result.error
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result.status !== 0) {
|
|
30
|
+
throw new Error(`Command failed: ${command} ${args.join(' ')}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shellOrThrow(command, options = {}) {
|
|
35
|
+
const shell = process.env.SHELL || '/bin/sh'
|
|
36
|
+
const result = childProcess.spawnSync(shell, ['-lc', command], {
|
|
37
|
+
cwd: options.cwd,
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (result.error) {
|
|
42
|
+
throw result.error
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
throw new Error(`Command failed: ${command}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createDefaultCommandRunner(projectRoot) {
|
|
51
|
+
return {
|
|
52
|
+
async runComposerRequire(packages) {
|
|
53
|
+
runOrThrow('composer', ['require', ...packages], { cwd: projectRoot })
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async runNpmInstall(packages) {
|
|
57
|
+
runOrThrow('npm', ['install', ...packages], { cwd: projectRoot })
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async runPostInstallCommand(command) {
|
|
61
|
+
shellOrThrow(command, { cwd: projectRoot })
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeBlockName(value) {
|
|
67
|
+
const trimmed = `${value ?? ''}`.trim()
|
|
68
|
+
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return trimmed
|
|
74
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
75
|
+
.replace(/[\s_]+/g, '-')
|
|
76
|
+
.replace(/-+/g, '-')
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertBlockName(name, label = 'block name') {
|
|
81
|
+
if (!BLOCK_NAME_PATTERN.test(name)) {
|
|
82
|
+
throw new Error(`Invalid ${label}: "${name}"`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toBlockKey(block) {
|
|
87
|
+
return `${block.name}@${block.variant}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveTargetPath(projectRoot, rule) {
|
|
91
|
+
const destinationRoot = resolveInside(projectRoot, rule.destination)
|
|
92
|
+
const parentDirectory = rule.parentDirectory ? rule.parentDirectory.split('/').filter(Boolean) : []
|
|
93
|
+
|
|
94
|
+
return resolveInside(destinationRoot, ...parentDirectory, rule.fileName)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cleanupEmptyDirectories(startDir, stopDir) {
|
|
98
|
+
let currentDir = startDir
|
|
99
|
+
const stopPath = path.resolve(stopDir)
|
|
100
|
+
|
|
101
|
+
while (currentDir.startsWith(stopPath) && currentDir !== stopPath) {
|
|
102
|
+
if (!fs.existsSync(currentDir)) {
|
|
103
|
+
currentDir = path.dirname(currentDir)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const stat = fs.lstatSync(currentDir)
|
|
108
|
+
|
|
109
|
+
if (!stat.isDirectory()) {
|
|
110
|
+
currentDir = path.dirname(currentDir)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (fs.readdirSync(currentDir).length > 0) {
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.rmdirSync(currentDir)
|
|
119
|
+
currentDir = path.dirname(currentDir)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadProjectConfig(projectRoot) {
|
|
124
|
+
const configPath = path.join(projectRoot, CONFIG_FILE_NAME)
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(configPath)) {
|
|
127
|
+
return {
|
|
128
|
+
configPath,
|
|
129
|
+
config: { blocks: [] },
|
|
130
|
+
blocks: [],
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let config
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw new Error(`Invalid ${CONFIG_FILE_NAME}: ${error.message}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
144
|
+
throw new Error(`${CONFIG_FILE_NAME} must contain a JSON object`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const blocksRaw = config.blocks ?? []
|
|
148
|
+
|
|
149
|
+
if (!Array.isArray(blocksRaw)) {
|
|
150
|
+
throw new Error(`"${CONFIG_FILE_NAME}" field "blocks" must be an array`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const blocks = blocksRaw.map((entry, index) => {
|
|
154
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
155
|
+
throw new Error(`Invalid block config entry at index ${index}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const name = normalizeBlockName(entry.name)
|
|
159
|
+
const variant = `${entry.variant ?? ''}`.trim()
|
|
160
|
+
|
|
161
|
+
assertBlockName(name, 'configured block name')
|
|
162
|
+
|
|
163
|
+
if (!variant) {
|
|
164
|
+
throw new Error(`Configured block "${name}" is missing a variant`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { name, variant }
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return { configPath, config, blocks: dedupeExplicitBlocks(blocks) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function saveProjectConfig(configPath, config, blocks) {
|
|
174
|
+
const nextConfig = {
|
|
175
|
+
...config,
|
|
176
|
+
blocks,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dedupeExplicitBlocks(blocks) {
|
|
183
|
+
const byName = new Map()
|
|
184
|
+
|
|
185
|
+
for (const block of blocks) {
|
|
186
|
+
byName.set(block.name, block)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [...byName.values()]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeRequestedBlockNames(rawNames, label = 'block name') {
|
|
193
|
+
if (!Array.isArray(rawNames) || rawNames.length === 0) {
|
|
194
|
+
throw new Error(`Missing ${label}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const normalizedNames = rawNames.map((rawName) => {
|
|
198
|
+
const name = normalizeBlockName(rawName)
|
|
199
|
+
|
|
200
|
+
assertBlockName(name, label)
|
|
201
|
+
|
|
202
|
+
return name
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return [...new Set(normalizedNames)]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function createMetaValidator(repository) {
|
|
209
|
+
const schema = await repository.getSchema()
|
|
210
|
+
const ajv = new Ajv2020({
|
|
211
|
+
allErrors: true,
|
|
212
|
+
strict: false,
|
|
213
|
+
validateFormats: false,
|
|
214
|
+
})
|
|
215
|
+
const validate = ajv.compile(schema)
|
|
216
|
+
|
|
217
|
+
return (meta, sourceLabel) => {
|
|
218
|
+
const valid = validate(meta)
|
|
219
|
+
|
|
220
|
+
if (valid) {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const issues = (validate.errors ?? [])
|
|
225
|
+
.map((error) => {
|
|
226
|
+
const location = error.instancePath || '/'
|
|
227
|
+
return `${location} ${error.message}`.trim()
|
|
228
|
+
})
|
|
229
|
+
.join('; ')
|
|
230
|
+
|
|
231
|
+
throw new Error(`Invalid metadata for ${sourceLabel}: ${issues}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function ensureIndex(index) {
|
|
236
|
+
if (!index || typeof index !== 'object' || Array.isArray(index)) {
|
|
237
|
+
throw new Error('Invalid blocks index payload')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!Array.isArray(index.blocks)) {
|
|
241
|
+
throw new Error('Invalid blocks index payload: missing "blocks" array')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return index.blocks.map((block, indexPosition) => {
|
|
245
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
246
|
+
throw new Error(`Invalid block index entry at position ${indexPosition}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const name = normalizeBlockName(block.name)
|
|
250
|
+
const description = `${block.description ?? ''}`.trim()
|
|
251
|
+
|
|
252
|
+
assertBlockName(name)
|
|
253
|
+
|
|
254
|
+
return { name, description }
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function createBlocksService({
|
|
259
|
+
projectRoot = process.cwd(),
|
|
260
|
+
repository = createRemoteBlocksRepository(),
|
|
261
|
+
commandRunner = createDefaultCommandRunner(projectRoot),
|
|
262
|
+
logger = defaultLogger(),
|
|
263
|
+
} = {}) {
|
|
264
|
+
const metaCache = new Map()
|
|
265
|
+
let validatorPromise
|
|
266
|
+
|
|
267
|
+
async function validateMeta(meta, sourceLabel) {
|
|
268
|
+
validatorPromise ||= createMetaValidator(repository)
|
|
269
|
+
const validator = await validatorPromise
|
|
270
|
+
|
|
271
|
+
validator(meta, sourceLabel)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function getMeta(name) {
|
|
275
|
+
const normalizedName = normalizeBlockName(name)
|
|
276
|
+
|
|
277
|
+
assertBlockName(normalizedName)
|
|
278
|
+
|
|
279
|
+
if (!metaCache.has(normalizedName)) {
|
|
280
|
+
const meta = await repository.getMeta(normalizedName)
|
|
281
|
+
await validateMeta(meta, `block "${normalizedName}"`)
|
|
282
|
+
metaCache.set(normalizedName, meta)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return metaCache.get(normalizedName)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function resolveInstallPlan(rootBlocks) {
|
|
289
|
+
const visited = new Set()
|
|
290
|
+
const resolving = new Set()
|
|
291
|
+
const plan = []
|
|
292
|
+
|
|
293
|
+
async function visit(name, preferredVariant) {
|
|
294
|
+
const normalizedName = normalizeBlockName(name)
|
|
295
|
+
const meta = await getMeta(normalizedName)
|
|
296
|
+
const variantName = preferredVariant || meta.install.defaultVariant
|
|
297
|
+
const variant = meta.install?.variants?.[variantName]
|
|
298
|
+
|
|
299
|
+
if (!variant) {
|
|
300
|
+
throw new Error(`Variant "${variantName}" is not available for block "${normalizedName}"`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const key = toBlockKey({ name: normalizedName, variant: variantName })
|
|
304
|
+
|
|
305
|
+
if (visited.has(key)) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (resolving.has(key)) {
|
|
310
|
+
throw new Error(`Dependency cycle detected at "${key}"`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
resolving.add(key)
|
|
314
|
+
|
|
315
|
+
for (const dependency of variant.dependencies?.components ?? []) {
|
|
316
|
+
await visit(dependency.name, dependency.variant || variantName)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
resolving.delete(key)
|
|
320
|
+
visited.add(key)
|
|
321
|
+
plan.push({
|
|
322
|
+
name: normalizedName,
|
|
323
|
+
variant: variantName,
|
|
324
|
+
meta,
|
|
325
|
+
installVariant: variant,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const block of rootBlocks) {
|
|
330
|
+
await visit(block.name, block.variant)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return plan
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function summarizePlan(plan) {
|
|
337
|
+
const composer = new Set()
|
|
338
|
+
const npm = new Set()
|
|
339
|
+
const postInstall = []
|
|
340
|
+
const warnings = []
|
|
341
|
+
|
|
342
|
+
for (const item of plan) {
|
|
343
|
+
const sharedFiles = item.meta.install.sharedFiles ?? []
|
|
344
|
+
|
|
345
|
+
if (sharedFiles.length > 0) {
|
|
346
|
+
warnings.push(`Block "${item.name}" contains ${sharedFiles.length} sharedFiles entries. raw-only mode does not install them yet, skipping.`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const dependency of item.installVariant.dependencies?.composer ?? []) {
|
|
350
|
+
composer.add(dependency)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const dependency of item.installVariant.dependencies?.npm ?? []) {
|
|
354
|
+
npm.add(dependency)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const step of item.installVariant.postInstall ?? []) {
|
|
358
|
+
postInstall.push({
|
|
359
|
+
blockName: item.name,
|
|
360
|
+
step,
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
composer: [...composer],
|
|
367
|
+
npm: [...npm],
|
|
368
|
+
postInstall,
|
|
369
|
+
warnings,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function validateProjectDependencies(summary) {
|
|
374
|
+
if (summary.composer.length > 0 && !fs.existsSync(path.join(projectRoot, 'composer.json'))) {
|
|
375
|
+
throw new Error('composer dependencies are required by blocks metadata, but composer.json is missing in the current project')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (summary.npm.length > 0 && !fs.existsSync(path.join(projectRoot, 'package.json'))) {
|
|
379
|
+
throw new Error('npm dependencies are required by blocks metadata, but package.json is missing in the current project')
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function installFile(blockName, rule) {
|
|
384
|
+
const whenExists = rule.whenExists || 'fail'
|
|
385
|
+
const targetPath = resolveTargetPath(projectRoot, rule)
|
|
386
|
+
const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
|
|
387
|
+
|
|
388
|
+
if (fs.existsSync(targetPath)) {
|
|
389
|
+
if (whenExists === 'skip') {
|
|
390
|
+
logger.info(`skip ${relativeTargetPath}`)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (whenExists === 'fail') {
|
|
395
|
+
throw new Error(`Cannot install block "${blockName}": target already exists (${relativeTargetPath})`)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (whenExists === 'overwrite') {
|
|
399
|
+
fse.removeSync(targetPath)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const contents = await repository.getFileBuffer(blockName, rule.source)
|
|
404
|
+
|
|
405
|
+
fse.ensureDirSync(path.dirname(targetPath))
|
|
406
|
+
fs.writeFileSync(targetPath, contents)
|
|
407
|
+
logger.info(`write ${relativeTargetPath}`)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function installPlan(plan) {
|
|
411
|
+
const summary = summarizePlan(plan)
|
|
412
|
+
|
|
413
|
+
validateProjectDependencies(summary)
|
|
414
|
+
|
|
415
|
+
for (const warning of summary.warnings) {
|
|
416
|
+
logger.warn(warning)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const item of plan) {
|
|
420
|
+
logger.info(`install ${item.name}@${item.variant}`)
|
|
421
|
+
|
|
422
|
+
for (const rule of item.installVariant.files ?? []) {
|
|
423
|
+
await installFile(item.name, rule)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (summary.composer.length > 0) {
|
|
428
|
+
logger.info(`composer require ${summary.composer.join(' ')}`)
|
|
429
|
+
await commandRunner.runComposerRequire(summary.composer)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (summary.npm.length > 0) {
|
|
433
|
+
logger.info(`npm install ${summary.npm.join(' ')}`)
|
|
434
|
+
await commandRunner.runNpmInstall(summary.npm)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const entry of summary.postInstall) {
|
|
438
|
+
if (entry.step.type === 'info') {
|
|
439
|
+
logger.info(entry.step.value)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
logger.info(`postInstall ${entry.blockName}: ${entry.step.value}`)
|
|
444
|
+
await commandRunner.runPostInstallCommand(entry.step.value)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function removePlanItems(items) {
|
|
449
|
+
for (const item of [...items].reverse()) {
|
|
450
|
+
logger.info(`remove ${item.name}@${item.variant}`)
|
|
451
|
+
|
|
452
|
+
for (const rule of item.installVariant.files ?? []) {
|
|
453
|
+
const targetPath = resolveTargetPath(projectRoot, rule)
|
|
454
|
+
const relativeTargetPath = path.relative(projectRoot, targetPath) || path.basename(targetPath)
|
|
455
|
+
|
|
456
|
+
if (!fs.existsSync(targetPath)) {
|
|
457
|
+
logger.info(`skip missing ${relativeTargetPath}`)
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
fse.removeSync(targetPath)
|
|
462
|
+
cleanupEmptyDirectories(path.dirname(targetPath), projectRoot)
|
|
463
|
+
logger.info(`delete ${relativeTargetPath}`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function listBlocks() {
|
|
469
|
+
const index = await repository.getIndex()
|
|
470
|
+
const blocks = ensureIndex(index)
|
|
471
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
472
|
+
|
|
473
|
+
for (const block of blocks) {
|
|
474
|
+
logger.info(`${block.name} - ${block.description}`)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return blocks
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function addBlock(rawName) {
|
|
481
|
+
const result = await addBlocks([rawName])
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
block: result.blocks[0],
|
|
485
|
+
plan: result.plan,
|
|
486
|
+
configBlocks: result.configBlocks,
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function addBlocks(rawNames) {
|
|
491
|
+
const names = normalizeRequestedBlockNames(rawNames)
|
|
492
|
+
const rootBlocks = []
|
|
493
|
+
|
|
494
|
+
for (const name of names) {
|
|
495
|
+
const meta = await getMeta(name)
|
|
496
|
+
|
|
497
|
+
rootBlocks.push({
|
|
498
|
+
name,
|
|
499
|
+
variant: meta.install.defaultVariant,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const plan = await resolveInstallPlan(rootBlocks)
|
|
504
|
+
|
|
505
|
+
await installPlan(plan)
|
|
506
|
+
|
|
507
|
+
const { configPath, config, blocks } = loadProjectConfig(projectRoot)
|
|
508
|
+
const nextBlocks = dedupeExplicitBlocks([
|
|
509
|
+
...blocks.filter(block => !names.includes(block.name)),
|
|
510
|
+
...rootBlocks,
|
|
511
|
+
])
|
|
512
|
+
|
|
513
|
+
saveProjectConfig(configPath, config, nextBlocks)
|
|
514
|
+
logger.info(`added ${rootBlocks.map(block => `${block.name}@${block.variant}`).join(', ')}`)
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
blocks: rootBlocks,
|
|
518
|
+
plan,
|
|
519
|
+
configBlocks: nextBlocks,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function removeBlock(rawName) {
|
|
524
|
+
const result = await removeBlocks([rawName])
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
removed: result.removed,
|
|
528
|
+
configBlocks: result.configBlocks,
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function removeBlocks(rawNames) {
|
|
533
|
+
const names = normalizeRequestedBlockNames(rawNames)
|
|
534
|
+
const { configPath, config, blocks } = loadProjectConfig(projectRoot)
|
|
535
|
+
const missingNames = names.filter(name => !blocks.some(block => block.name === name))
|
|
536
|
+
|
|
537
|
+
if (missingNames.length > 0) {
|
|
538
|
+
const suffix = missingNames.length === 1 ? 'is' : 'are'
|
|
539
|
+
throw new Error(`Block${missingNames.length === 1 ? '' : 's'} "${missingNames.join('", "')}" ${suffix} not installed in ${CONFIG_FILE_NAME}`)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const beforePlan = await resolveInstallPlan(blocks)
|
|
543
|
+
const remainingBlocks = blocks.filter(block => !names.includes(block.name))
|
|
544
|
+
const afterPlan = remainingBlocks.length > 0 ? await resolveInstallPlan(remainingBlocks) : []
|
|
545
|
+
const afterKeys = new Set(afterPlan.map(toBlockKey))
|
|
546
|
+
const removedItems = beforePlan.filter(item => !afterKeys.has(toBlockKey(item)))
|
|
547
|
+
|
|
548
|
+
await removePlanItems(removedItems)
|
|
549
|
+
saveProjectConfig(configPath, config, remainingBlocks)
|
|
550
|
+
logger.info(`removed ${names.join(', ')}`)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
removed: removedItems,
|
|
554
|
+
configBlocks: remainingBlocks,
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function updateBlocks() {
|
|
559
|
+
const { blocks } = loadProjectConfig(projectRoot)
|
|
560
|
+
|
|
561
|
+
if (blocks.length === 0) {
|
|
562
|
+
logger.info(`No blocks configured in ${CONFIG_FILE_NAME}`)
|
|
563
|
+
return {
|
|
564
|
+
plan: [],
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const plan = await resolveInstallPlan(blocks)
|
|
569
|
+
|
|
570
|
+
await installPlan(plan)
|
|
571
|
+
logger.info(`updated ${blocks.length} explicit block(s)`)
|
|
572
|
+
|
|
573
|
+
return { plan }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
addBlock,
|
|
578
|
+
addBlocks,
|
|
579
|
+
listBlocks,
|
|
580
|
+
removeBlock,
|
|
581
|
+
removeBlocks,
|
|
582
|
+
resolveInstallPlan,
|
|
583
|
+
updateBlocks,
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export { normalizeBlockName }
|
package/src/commands/init/ui.mjs
CHANGED
|
@@ -4,6 +4,37 @@ import fse from 'fs-extra'
|
|
|
4
4
|
import { join, resolve } from 'path'
|
|
5
5
|
import { isAutoYes, normalizeEnum, normalizeYesNo } from './options.mjs'
|
|
6
6
|
|
|
7
|
+
function replaceInFile(path, searchValue, replaceValue) {
|
|
8
|
+
if (!fse.existsSync(path)) {
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const content = fse.readFileSync(path, 'utf8')
|
|
13
|
+
fse.writeFileSync(path, content.replace(searchValue, replaceValue))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function updateBlankPackageJson(path) {
|
|
17
|
+
if (!fse.existsSync(path)) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const packageJson = JSON.parse(fse.readFileSync(path, 'utf8'))
|
|
22
|
+
const originalDependencies = (packageJson.dependencies && typeof packageJson.dependencies === 'object')
|
|
23
|
+
? packageJson.dependencies
|
|
24
|
+
: {}
|
|
25
|
+
|
|
26
|
+
if (packageJson.scripts && typeof packageJson.scripts === 'object') {
|
|
27
|
+
delete packageJson.scripts['build-emails']
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
packageJson.dependencies = Object.fromEntries(
|
|
31
|
+
['@newlogic-digital/utils-js', 'winduum']
|
|
32
|
+
.flatMap(name => Object.hasOwn(originalDependencies, name) ? [[name, originalDependencies[name]]] : []),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
fse.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`)
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
function prepareBlankInstall(projectPath) {
|
|
8
39
|
[
|
|
9
40
|
join(projectPath, 'src', 'template', 'emails'),
|
|
@@ -14,12 +45,9 @@ function prepareBlankInstall(projectPath) {
|
|
|
14
45
|
join(projectPath, 'src', 'styles', 'tinymce.css'),
|
|
15
46
|
].forEach(path => fse.removeSync(path))
|
|
16
47
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const mainData = fse.readFileSync(mainDataPath, 'utf8').replace('"cookieConsent": true', '"cookieConsent": false')
|
|
21
|
-
fse.writeFileSync(mainDataPath, mainData)
|
|
22
|
-
}
|
|
48
|
+
replaceInFile(join(projectPath, 'src', 'data', 'main.json'), '"cookieConsent": true', '"cookieConsent": false')
|
|
49
|
+
replaceInFile(join(projectPath, 'src', 'templates', 'layouts', 'default.latte'), '<body data-controller="x-app invoke" data-naja-snippet-append>', '<body>')
|
|
50
|
+
updateBlankPackageJson(join(projectPath, 'package.json'))
|
|
23
51
|
|
|
24
52
|
const iconsPath = join(projectPath, 'src', 'icons')
|
|
25
53
|
fse.ensureDirSync(iconsPath)
|
package/src/utils.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import childProcess from 'child_process'
|
|
2
2
|
import fs from 'fs'
|
|
3
|
-
import
|
|
3
|
+
import path from 'path'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
|
|
6
|
-
const { version, name } = JSON.parse(fs.readFileSync(resolve(dirname((fileURLToPath(import.meta.url))), '../package.json')).toString())
|
|
6
|
+
const { version, name } = JSON.parse(fs.readFileSync(path.resolve(path.dirname((fileURLToPath(import.meta.url))), '../package.json')).toString())
|
|
7
7
|
|
|
8
8
|
const execSync = (cmd) => {
|
|
9
9
|
try {
|
|
@@ -34,4 +34,15 @@ const stripIndent = (string) => {
|
|
|
34
34
|
return string.replace(regex, '')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
function resolveInside(rootDir, ...segments) {
|
|
38
|
+
const nextPath = path.resolve(rootDir, ...segments)
|
|
39
|
+
const relative = path.relative(rootDir, nextPath)
|
|
40
|
+
|
|
41
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
42
|
+
throw new Error(`Path escapes root: ${nextPath}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return nextPath
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { execSync, stripIndent, resolveInside, version, name }
|