@softize/opus 11.1.1 → 12.0.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/CHANGELOG.md +63 -1
- package/README.md +31 -1
- package/bin/cli.mjs +97 -27
- package/bin/lib/check.mjs +55 -43
- package/bin/lib/copy.mjs +2202 -0
- package/bin/lib/create.mjs +227 -39
- package/bin/lib/db-migrate-runner.mjs +9 -6
- package/bin/lib/db-project-path.mjs +20 -0
- package/bin/lib/db-scaffold-runner.mjs +23 -8
- package/bin/lib/db.mjs +6 -4
- package/bin/lib/gen.mjs +60 -29
- package/bin/lib/init.mjs +212 -56
- package/bin/lib/introspect.mjs +3 -2
- package/bin/lib/materialize.mjs +623 -97
- package/bin/lib/postinstall.mjs +6 -5
- package/bin/lib/validate-skill.mjs +502 -30
- package/docs/code-style.md +142 -7
- package/docs/consumer-upgrade-propagation.md +4 -3
- package/docs/releasing.md +28 -17
- package/package.json +6 -1
- package/registry/git/pre-push.d/00-opus-copy +14 -0
- package/registry/git/pre-push.d/opus +7 -21
- package/registry/git/run-opus-pre-push.mjs +141 -0
- package/registry/hooks/opus-check-on-stop.mjs +13 -31
- package/registry/instructions/opus.md +11 -5
- package/registry/skills/build-opus-ui/SKILL.md +5 -4
- package/registry/skills/create-opus-action/SKILL.md +4 -4
- package/registry/skills/implement-opus-change/SKILL.md +7 -5
- package/registry/skills/upgrade-opus/SKILL.md +8 -4
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
- package/registry/templates/app/package.json +4 -0
- package/registry/templates/app/pnpm-workspace.yaml +3 -2
- package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
- package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
- package/src/ui/components/patterns/split.tsx +66 -24
- package/src/ui/docs/content/cli.md +8 -7
- package/src/ui/docs/content/communication.md +79 -126
- package/src/ui/docs/content/getting-started.md +29 -16
- package/src/ui/docs/content/split.md +43 -3
- package/src/ui/react.tsx +1 -1
- package/registry/skills/write-product-communication/SKILL.md +0 -28
- package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
- package/registry/templates/app/_npmrc +0 -1
- package/registry/templates/monorepo/_npmrc +0 -1
package/bin/lib/create.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* opus create — scaffolda um app opus-based CANÔNICO a partir do template do registry
|
|
3
|
-
* (`registry/templates/app`). O esqueleto versiona com
|
|
3
|
+
* (`registry/templates/app`). O esqueleto versiona com o Opus: sai do mesmo tarball que
|
|
4
4
|
* o SDK que ele configura, então nasce certo por construção — os pré-requisitos do
|
|
5
5
|
* protocolo (opus.config/manifest/scripts), da UI (preset+tema v4 CSS-first) e do
|
|
6
6
|
* previews externos (porta injetada, allowedHosts configurável) já vêm plugados.
|
|
@@ -8,31 +8,42 @@
|
|
|
8
8
|
* DOIS MODOS, por detecção (sobe do destino procurando `pnpm-workspace.yaml`):
|
|
9
9
|
* • standalone — repo próprio: o template inteiro (raiz inclusa);
|
|
10
10
|
* • app em MONOREPO — dentro de um workspace: gera só o que é DO APP e pula o que é
|
|
11
|
-
* da raiz (`.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* da raiz (`.gitignore`, `pnpm-workspace.yaml` — workspace yaml aninhado quebra o
|
|
12
|
+
* pnpm); o que a raiz precisa ter vira AVISO (globs, allowBuilds e quarentena), sem
|
|
13
|
+
* sobrescrever configuração do consumidor.
|
|
14
14
|
*
|
|
15
15
|
* Convenções do template:
|
|
16
|
-
* • segmento de caminho com `_` inicial vira `.` na cópia
|
|
17
|
-
*
|
|
16
|
+
* • segmento de caminho com `_` inicial vira `.` na cópia — o npm pack descarta
|
|
17
|
+
* dotfiles do tarball, então a fonte não pode tê-los;
|
|
18
18
|
* • tokens `{{APP_NAME}}` e `{{OPUS_VERSION}}` são substituídos em todo arquivo.
|
|
19
19
|
*
|
|
20
20
|
* Depois da cópia roda o `initProject` (setup): opus.json e artefatos Opus rastreáveis,
|
|
21
|
-
*
|
|
21
|
+
* skills, hooks e gates locais do Opus (repo-level: acha a raiz pelo `.git`).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { promises as fs } from 'node:fs'
|
|
25
25
|
import path from 'node:path'
|
|
26
|
+
import {
|
|
27
|
+
canonicalProjectDirectory,
|
|
28
|
+
ensureProjectDirectory,
|
|
29
|
+
projectPathErrorMessage,
|
|
30
|
+
readProjectDirectory,
|
|
31
|
+
readProjectFile,
|
|
32
|
+
removeProjectFileIfUnchanged,
|
|
33
|
+
safeProjectPath,
|
|
34
|
+
writeProjectFileAtomically,
|
|
35
|
+
} from '@softize/base/project-path'
|
|
26
36
|
import { initProject } from './init.mjs'
|
|
27
37
|
|
|
28
38
|
/** Arquivos do template que pertencem à RAIZ do repo — fora no modo app. */
|
|
29
|
-
const ROOT_ONLY = new Set(['
|
|
39
|
+
const ROOT_ONLY = new Set(['_gitignore', 'pnpm-workspace.yaml'])
|
|
30
40
|
|
|
31
41
|
/** Raiz do workspace pnpm que contém `dir` (sobe até achar pnpm-workspace.yaml); null se não há. */
|
|
32
42
|
async function workspaceRootOf(dir) {
|
|
33
|
-
let cur = path.resolve(dir)
|
|
43
|
+
let cur = existingDirectory(path.resolve(dir))
|
|
34
44
|
for (;;) {
|
|
35
|
-
|
|
45
|
+
const workspace = readProjectFile(cur, 'pnpm-workspace.yaml', { allowMissing: true })
|
|
46
|
+
if (workspace.exists) return cur
|
|
36
47
|
const up = path.dirname(cur)
|
|
37
48
|
if (up === cur) return null
|
|
38
49
|
cur = up
|
|
@@ -50,32 +61,143 @@ function globMatches(glob, rel) {
|
|
|
50
61
|
return re.test(rel)
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/** Como pnpm/tinyglobby: ao menos um positivo inclui; qualquer `!glob` exclui. */
|
|
65
|
+
function workspaceCovers(patterns, rel) {
|
|
66
|
+
let covered = false
|
|
67
|
+
for (const pattern of patterns) {
|
|
68
|
+
const negative = pattern.startsWith('!')
|
|
69
|
+
const glob = negative ? pattern.slice(1) : pattern
|
|
70
|
+
if (!globMatches(glob, rel)) continue
|
|
71
|
+
if (negative) return false
|
|
72
|
+
covered = true
|
|
73
|
+
}
|
|
74
|
+
return covered
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Leitura mínima e conservadora das três seções escalares que o scaffold consulta.
|
|
79
|
+
* Não tenta ser um parser YAML geral: sintaxe complexa fica como ausente e produz aviso,
|
|
80
|
+
* em vez de um comentário, exemplo ou seção vizinha virar uma garantia falsa.
|
|
81
|
+
*/
|
|
82
|
+
function workspacePolicy(text) {
|
|
83
|
+
const result = {
|
|
84
|
+
packages: [],
|
|
85
|
+
allowOpus: false,
|
|
86
|
+
minimumReleaseAgeExclude: [],
|
|
87
|
+
valid: { packages: true, allowBuilds: true, minimumReleaseAgeExclude: true },
|
|
88
|
+
}
|
|
89
|
+
const relevant = new Set(Object.keys(result.valid))
|
|
90
|
+
const seen = new Set()
|
|
91
|
+
let section = null
|
|
92
|
+
|
|
93
|
+
const scalar = (raw) => {
|
|
94
|
+
const value = raw.trim()
|
|
95
|
+
const single = /^'([^']*)'[ ]*(?:#.*)?$/u.exec(value)
|
|
96
|
+
if (single !== null) return { value: single[1], quoted: true }
|
|
97
|
+
const double = /^"([^"\\]*)"[ ]*(?:#.*)?$/u.exec(value)
|
|
98
|
+
if (double !== null) return { value: double[1], quoted: true }
|
|
99
|
+
const plain = /^([^#\[\]{} ,'"&*!|>@`][^#\[\]{} ,'"\\]*?)[ ]*(?:#.*)?$/u.exec(value)
|
|
100
|
+
return plain === null ? null : { value: plain[1].trim(), quoted: false }
|
|
101
|
+
}
|
|
102
|
+
const invalidate = () => {
|
|
103
|
+
if (section !== null && relevant.has(section.name)) result.valid[section.name] = false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const raw of text.split(/\r?\n/u)) {
|
|
107
|
+
if (/^[ ]*(?:#.*)?$/u.test(raw)) continue
|
|
108
|
+
if (raw.includes('\t')) {
|
|
109
|
+
invalidate()
|
|
110
|
+
section = null
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const top = /^([A-Za-z][A-Za-z0-9_-]*):[ ]*(?:#.*)?$/u.exec(raw)
|
|
114
|
+
if (top !== null) {
|
|
115
|
+
const name = top[1]
|
|
116
|
+
if (relevant.has(name) && seen.has(name)) result.valid[name] = false
|
|
117
|
+
if (relevant.has(name)) seen.add(name)
|
|
118
|
+
section = { name, indent: null }
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
const inlineTop = /^([A-Za-z][A-Za-z0-9_-]*):/u.exec(raw)
|
|
122
|
+
if (inlineTop !== null) {
|
|
123
|
+
if (relevant.has(inlineTop[1])) result.valid[inlineTop[1]] = false
|
|
124
|
+
section = null
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (!/^ +/u.test(raw)) {
|
|
128
|
+
invalidate()
|
|
129
|
+
section = null
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (section === null || !relevant.has(section.name)) continue
|
|
133
|
+
|
|
134
|
+
const indent = /^ +/u.exec(raw)[0].length
|
|
135
|
+
if (section.indent === null) section.indent = indent
|
|
136
|
+
if (indent !== section.indent) {
|
|
137
|
+
invalidate()
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (section.name === 'packages' || section.name === 'minimumReleaseAgeExclude') {
|
|
142
|
+
const item = /^ +- +(.*)$/u.exec(raw)
|
|
143
|
+
const parsed = item === null ? null : scalar(item[1])
|
|
144
|
+
if (parsed === null || parsed.value === '') {
|
|
145
|
+
invalidate()
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (section.name === 'packages') {
|
|
149
|
+
if (
|
|
150
|
+
!/^!?[A-Za-z0-9@._/*-]+$/u.test(parsed.value) ||
|
|
151
|
+
parsed.value === '!' ||
|
|
152
|
+
(parsed.value.startsWith('!') && !parsed.quoted)
|
|
153
|
+
) {
|
|
154
|
+
invalidate()
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
} else if (!/^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(parsed.value)) {
|
|
158
|
+
invalidate()
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
result[section.name].push(parsed.value)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (section.name === 'allowBuilds') {
|
|
166
|
+
const item = /^ +((?:'[^']*'|"[^"\\]*"|[A-Za-z0-9@/._-]+)):[ ]*(true|false)[ ]*(?:#.*)?$/u.exec(raw)
|
|
167
|
+
const key = item === null ? null : scalar(item[1])
|
|
168
|
+
if (
|
|
169
|
+
item === null ||
|
|
170
|
+
key === null ||
|
|
171
|
+
!/^(?:@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)$/u.test(key.value)
|
|
172
|
+
) {
|
|
173
|
+
invalidate()
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (key.value === '@softize/opus') result.allowOpus = item[2] === 'true'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return result
|
|
180
|
+
}
|
|
181
|
+
|
|
53
182
|
/** Avisos do modo app: o que a RAIZ do workspace precisa ter (sem clobber — só aviso). */
|
|
54
183
|
async function workspaceWarnings(wsRoot, appDir) {
|
|
55
184
|
const warnings = []
|
|
56
185
|
const rel = path.relative(wsRoot, appDir).split(path.sep).join('/')
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/* Ilegível → sem como conferir. */
|
|
62
|
-
}
|
|
63
|
-
// packages: — lista simples de globs quotados ou crus.
|
|
64
|
-
const globs = [...ws.matchAll(/^\s*-\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/gm)].map((m) => m[1].trim())
|
|
65
|
-
if (!globs.some((g) => globMatches(g, rel))) {
|
|
186
|
+
const workspace = readProjectFile(wsRoot, 'pnpm-workspace.yaml')
|
|
187
|
+
const ws = workspace.content
|
|
188
|
+
const policy = workspacePolicy(ws)
|
|
189
|
+
if (!policy.valid.packages || !workspaceCovers(policy.packages, rel)) {
|
|
66
190
|
warnings.push(`pnpm-workspace.yaml (raiz): nenhum glob de packages cobre \`${rel}\` — adicione (ex.: '${rel.split('/')[0]}/*').`)
|
|
67
191
|
}
|
|
68
|
-
if (
|
|
192
|
+
if (!policy.valid.allowBuilds || !policy.allowOpus) {
|
|
69
193
|
warnings.push("pnpm-workspace.yaml (raiz): sem allowBuilds pro '@softize/opus' — o postinstall (setup) não roda no install.")
|
|
70
194
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!npmrc.includes('@softize:registry=')) {
|
|
78
|
-
warnings.push('.npmrc (raiz): sem `@softize:registry=https://registry.softize.com.br/` — o install não resolve a base.')
|
|
195
|
+
if (
|
|
196
|
+
!policy.valid.minimumReleaseAgeExclude ||
|
|
197
|
+
!policy.minimumReleaseAgeExclude.includes('@softize/opus') ||
|
|
198
|
+
!policy.minimumReleaseAgeExclude.includes('@softize/base')
|
|
199
|
+
) {
|
|
200
|
+
warnings.push("pnpm-workspace.yaml (raiz): minimumReleaseAgeExclude deve incluir '@softize/opus' e '@softize/base' para o rollout coordenado.")
|
|
79
201
|
}
|
|
80
202
|
return warnings
|
|
81
203
|
}
|
|
@@ -89,6 +211,34 @@ async function exists(p) {
|
|
|
89
211
|
}
|
|
90
212
|
}
|
|
91
213
|
|
|
214
|
+
function existingDirectory(requested) {
|
|
215
|
+
let cursor = path.resolve(requested)
|
|
216
|
+
for (;;) {
|
|
217
|
+
try {
|
|
218
|
+
return canonicalProjectDirectory(cursor)
|
|
219
|
+
} catch {
|
|
220
|
+
const parent = path.dirname(cursor)
|
|
221
|
+
if (parent === cursor) throw new Error(`Diretório base inacessível: ${requested}.`)
|
|
222
|
+
cursor = parent
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ensureScaffoldRoot(requested) {
|
|
228
|
+
const target = path.resolve(requested)
|
|
229
|
+
const ancestor = existingDirectory(path.dirname(target))
|
|
230
|
+
const relativeTarget = path.relative(ancestor, target)
|
|
231
|
+
if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${path.sep}`)) {
|
|
232
|
+
throw new Error(`Destino de scaffold inválido: ${requested}.`)
|
|
233
|
+
}
|
|
234
|
+
let cursor = '.'
|
|
235
|
+
for (const segment of relativeTarget.split(path.sep)) {
|
|
236
|
+
cursor = path.join(cursor, segment)
|
|
237
|
+
ensureProjectDirectory(ancestor, cursor)
|
|
238
|
+
}
|
|
239
|
+
return canonicalProjectDirectory(target)
|
|
240
|
+
}
|
|
241
|
+
|
|
92
242
|
/**
|
|
93
243
|
* Entradas que NÃO contam como "trabalho de alguém" na recusa de dir não-vazio:
|
|
94
244
|
* o ponto de partida normal de repo-cliente é `git init`/clone ANTES do scaffold
|
|
@@ -98,8 +248,15 @@ const PREEXISTING_OK = /^(\.git|README(\.[^/]+)?|LICENSE(\.[^/]+)?)$/i
|
|
|
98
248
|
|
|
99
249
|
/** Recusa destino com conteúdo real (nunca sobrescreve); repo recém-iniciado passa. */
|
|
100
250
|
async function ensureScaffoldable(targetDir) {
|
|
101
|
-
|
|
102
|
-
|
|
251
|
+
let root
|
|
252
|
+
try {
|
|
253
|
+
root = canonicalProjectDirectory(targetDir)
|
|
254
|
+
} catch {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const listing = readProjectDirectory(root, '.')
|
|
258
|
+
if (listing.entries.some((entry) => entry.name === '.git')) safeProjectPath(root, '.git', { mustExist: true })
|
|
259
|
+
const blocking = listing.entries.map((entry) => entry.name).filter((entry) => !PREEXISTING_OK.test(entry))
|
|
103
260
|
if (blocking.length > 0) {
|
|
104
261
|
const shown = blocking.slice(0, 3).join(', ') + (blocking.length > 3 ? ', …' : '')
|
|
105
262
|
throw new Error(
|
|
@@ -128,22 +285,51 @@ function destRelOf(rel) {
|
|
|
128
285
|
|
|
129
286
|
/** Copia um template com os tokens substituídos; `skipTop` pula segmentos de topo. */
|
|
130
287
|
async function copyTemplate(template, targetDir, { appName, version }, skipTop) {
|
|
288
|
+
const root = canonicalProjectDirectory(targetDir)
|
|
131
289
|
const files = []
|
|
290
|
+
const planned = []
|
|
132
291
|
for (const rel of (await walk(template)).sort()) {
|
|
133
292
|
if (skipTop !== undefined && skipTop.has(rel.split(path.sep)[0])) continue
|
|
134
293
|
const destRel = destRelOf(rel)
|
|
135
|
-
const
|
|
136
|
-
|
|
294
|
+
const destination = readProjectFile(root, destRel, { allowMissing: true })
|
|
295
|
+
if (destination.exists) throw new Error(`${destRel}: o scaffold não sobrescreve arquivo existente.`)
|
|
137
296
|
const content = await fs.readFile(path.join(template, rel), 'utf-8')
|
|
138
|
-
|
|
139
|
-
|
|
297
|
+
planned.push({ destRel, rendered: content.replaceAll('{{APP_NAME}}', appName).replaceAll('{{OPUS_VERSION}}', version) })
|
|
298
|
+
}
|
|
299
|
+
const written = []
|
|
300
|
+
try {
|
|
301
|
+
for (const { destRel, rendered } of planned) {
|
|
302
|
+
let parent = path.dirname(destRel)
|
|
303
|
+
const pending = []
|
|
304
|
+
while (parent !== '.') {
|
|
305
|
+
pending.unshift(parent)
|
|
306
|
+
parent = path.dirname(parent)
|
|
307
|
+
}
|
|
308
|
+
for (const directory of pending) ensureProjectDirectory(root, directory)
|
|
309
|
+
writeProjectFileAtomically(root, destRel, rendered, { exists: false, content: undefined })
|
|
310
|
+
written.push({ destRel, snapshot: readProjectFile(root, destRel) })
|
|
311
|
+
files.push(destRel)
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const rollbackErrors = []
|
|
315
|
+
for (const file of written.reverse()) {
|
|
316
|
+
try {
|
|
317
|
+
removeProjectFileIfUnchanged(root, file.destRel, file.snapshot)
|
|
318
|
+
} catch (rollbackError) {
|
|
319
|
+
rollbackErrors.push(`${file.destRel}: ${projectPathErrorMessage(rollbackError)}`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (rollbackErrors.length > 0) {
|
|
323
|
+
throw new Error(`${error.message} Reversão incompleta: ${rollbackErrors.join(' | ')}`)
|
|
324
|
+
}
|
|
325
|
+
throw error
|
|
140
326
|
}
|
|
141
327
|
return files
|
|
142
328
|
}
|
|
143
329
|
|
|
144
330
|
/**
|
|
145
331
|
* Cria a RAIZ de um monorepo canônico (`opus create --monorepo <dir>`): workspace com
|
|
146
|
-
* `apps/*`/`packages/*`, allowBuilds
|
|
332
|
+
* `apps/*`/`packages/*`, allowBuilds, quarentena coordenada e gitignore. Sem app
|
|
147
333
|
* dentro — o primeiro vem de `opus create apps/<nome>` rodado na raiz (o modo app
|
|
148
334
|
* detecta o workspace; a raiz gerada passa limpa pelos avisos dele, por construção).
|
|
149
335
|
* @returns {Promise<{version:string, files:string[]}>}
|
|
@@ -168,7 +354,8 @@ export async function createMonorepo(registryDir, targetDir, name) {
|
|
|
168
354
|
}
|
|
169
355
|
})()
|
|
170
356
|
const version = pkg?.version ?? '0.0.0'
|
|
171
|
-
const
|
|
357
|
+
const root = ensureScaffoldRoot(targetDir)
|
|
358
|
+
const files = await copyTemplate(template, root, { appName: name, version })
|
|
172
359
|
return { version, files }
|
|
173
360
|
}
|
|
174
361
|
|
|
@@ -197,12 +384,13 @@ export async function createProject(registryDir, targetDir, appName) {
|
|
|
197
384
|
const wsRoot = await workspaceRootOf(path.dirname(path.resolve(targetDir)))
|
|
198
385
|
const mode = wsRoot === null ? 'standalone' : 'app'
|
|
199
386
|
|
|
200
|
-
const
|
|
387
|
+
const root = ensureScaffoldRoot(targetDir)
|
|
388
|
+
const files = await copyTemplate(template, root, { appName, version }, mode === 'app' ? ROOT_ONLY : undefined)
|
|
201
389
|
|
|
202
|
-
const warnings = mode === 'app' ? await workspaceWarnings(wsRoot,
|
|
390
|
+
const warnings = mode === 'app' ? await workspaceWarnings(wsRoot, root) : []
|
|
203
391
|
|
|
204
392
|
// Setup por cima: opus.json per-app e materialização repo-level. Em monorepo, a raiz
|
|
205
393
|
// é descoberta pelo Git e recebe skills, hooks, instruções e pre-push específicos do Opus.
|
|
206
|
-
const setup = await initProject(registryDir,
|
|
394
|
+
const setup = await initProject(registryDir, root)
|
|
207
395
|
return { version, mode, files, warnings, setup }
|
|
208
396
|
}
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import path from 'node:path'
|
|
18
|
-
import { promises as fs } from 'node:fs'
|
|
19
18
|
import { pathToFileURL } from 'node:url'
|
|
20
19
|
import { sql } from 'kysely'
|
|
20
|
+
import { canonicalProjectDirectory, readProjectFile } from '@softize/base/project-path'
|
|
21
21
|
|
|
22
22
|
import { isEntityConfig } from '../../src/schema/entity.ts'
|
|
23
23
|
import { kyselyDriftCheck } from '../../src/data/drivers/kysely.ts'
|
|
24
|
+
import { resolveDbConfigPath, resolveDbProjectPath } from './db-project-path.mjs'
|
|
24
25
|
|
|
25
26
|
function emit(obj) {
|
|
26
27
|
process.stdout.write(`\n${JSON.stringify(obj)}\n`)
|
|
@@ -36,12 +37,14 @@ function collectEntities(domain, out) {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async function main() {
|
|
39
|
-
const
|
|
40
|
-
if (typeof
|
|
40
|
+
const requestedConfigPath = process.argv[2]
|
|
41
|
+
if (typeof requestedConfigPath !== 'string') {
|
|
41
42
|
emit({ ok: false, error: 'config path ausente' })
|
|
42
43
|
return
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const projectRoot = canonicalProjectDirectory(process.cwd())
|
|
47
|
+
const configPath = resolveDbConfigPath(projectRoot, requestedConfigPath).path
|
|
45
48
|
const mod = await import(pathToFileURL(path.resolve(configPath)).href)
|
|
46
49
|
const config = mod.default ?? mod
|
|
47
50
|
|
|
@@ -50,11 +53,11 @@ async function main() {
|
|
|
50
53
|
return
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
const
|
|
54
|
-
const schemaPath =
|
|
56
|
+
const schema = resolveDbProjectPath(projectRoot, configPath, config.schema, 'db/schema.sql')
|
|
57
|
+
const schemaPath = schema.path
|
|
55
58
|
let schemaSql
|
|
56
59
|
try {
|
|
57
|
-
schemaSql =
|
|
60
|
+
schemaSql = readProjectFile(projectRoot, path.relative(projectRoot, schemaPath)).content
|
|
58
61
|
} catch {
|
|
59
62
|
emit({
|
|
60
63
|
ok: false,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
|
|
4
|
+
|
|
5
|
+
/** Valida o opus.config.ts antes de importá-lo. */
|
|
6
|
+
export function resolveDbConfigPath(projectDirectory, configPath) {
|
|
7
|
+
const root = canonicalProjectDirectory(projectDirectory)
|
|
8
|
+
const config = safeProjectPath(root, path.relative(root, path.resolve(configPath)), { mustExist: true })
|
|
9
|
+
if (config.kind !== 'file') throw new Error('opus.config.ts deve ser um arquivo regular.')
|
|
10
|
+
return config
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve uma configuração relativa ao opus.config.ts, contida na raiz do comando. */
|
|
14
|
+
export function resolveDbProjectPath(projectDirectory, configPath, configured, fallback) {
|
|
15
|
+
const root = canonicalProjectDirectory(projectDirectory)
|
|
16
|
+
const config = resolveDbConfigPath(root, configPath)
|
|
17
|
+
|
|
18
|
+
const requested = path.resolve(path.dirname(config.path), configured ?? fallback)
|
|
19
|
+
return safeProjectPath(root, path.relative(root, requested))
|
|
20
|
+
}
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import path from 'node:path'
|
|
13
|
-
import { promises as fs } from 'node:fs'
|
|
14
13
|
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import {
|
|
15
|
+
canonicalProjectDirectory,
|
|
16
|
+
ensureProjectDirectory,
|
|
17
|
+
readProjectFile,
|
|
18
|
+
writeProjectFileAtomically,
|
|
19
|
+
} from '@softize/base/project-path'
|
|
15
20
|
|
|
16
21
|
import { isEntityConfig } from '../../src/schema/entity.ts'
|
|
17
22
|
import { scaffoldMigration, scaffoldFileContent } from '../../src/schema/scaffold.ts'
|
|
18
23
|
import { kyselyDriftCheck } from '../../src/data/drivers/kysely.ts'
|
|
24
|
+
import { resolveDbConfigPath, resolveDbProjectPath } from './db-project-path.mjs'
|
|
19
25
|
|
|
20
26
|
function emit(obj) {
|
|
21
27
|
process.stdout.write(`\n${JSON.stringify(obj)}\n`)
|
|
@@ -35,12 +41,14 @@ function timestamp() {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
async function main() {
|
|
38
|
-
const
|
|
39
|
-
if (typeof
|
|
44
|
+
const requestedConfigPath = process.argv[2]
|
|
45
|
+
if (typeof requestedConfigPath !== 'string') {
|
|
40
46
|
emit({ ok: false, error: 'config path ausente' })
|
|
41
47
|
return
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
const projectRoot = canonicalProjectDirectory(process.cwd())
|
|
51
|
+
const configPath = resolveDbConfigPath(projectRoot, requestedConfigPath).path
|
|
44
52
|
const mod = await import(pathToFileURL(path.resolve(configPath)).href)
|
|
45
53
|
const config = mod.default ?? mod
|
|
46
54
|
|
|
@@ -55,6 +63,7 @@ async function main() {
|
|
|
55
63
|
emit({ ok: false, error: 'config.database() ausente — exporte uma factory que retorna um Kysely' })
|
|
56
64
|
return
|
|
57
65
|
}
|
|
66
|
+
const migration = resolveDbProjectPath(projectRoot, configPath, config.migrations, 'migrations')
|
|
58
67
|
|
|
59
68
|
const db = await config.database()
|
|
60
69
|
let result
|
|
@@ -70,11 +79,17 @@ async function main() {
|
|
|
70
79
|
return
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
const migrationRelative = path.relative(projectRoot, migration.path)
|
|
83
|
+
let cursor = '.'
|
|
84
|
+
for (const segment of migrationRelative.split(path.sep).filter((item) => item !== '' && item !== '.')) {
|
|
85
|
+
cursor = path.join(cursor, segment)
|
|
86
|
+
ensureProjectDirectory(projectRoot, cursor)
|
|
87
|
+
}
|
|
88
|
+
const requested = path.join(migrationRelative, `${timestamp()}_scaffold.ts`)
|
|
89
|
+
const destination = readProjectFile(projectRoot, requested, { allowMissing: true })
|
|
90
|
+
if (destination.exists) throw new Error(`${requested}: migration já existe; tente novamente.`)
|
|
91
|
+
writeProjectFileAtomically(projectRoot, requested, scaffoldFileContent(result), { exists: false, content: undefined })
|
|
92
|
+
const file = readProjectFile(projectRoot, requested).path
|
|
78
93
|
|
|
79
94
|
emit({ ok: true, file, statements: result.statements, notes: result.notes })
|
|
80
95
|
}
|
package/bin/lib/db.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import path from 'node:path'
|
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
import { execFile } from 'node:child_process'
|
|
17
17
|
import { promisify } from 'node:util'
|
|
18
|
+
import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
|
|
18
19
|
|
|
19
20
|
const execFileAsync = promisify(execFile)
|
|
20
21
|
|
|
@@ -67,10 +68,11 @@ export async function cmdDb(rest, flags) {
|
|
|
67
68
|
// =============================================================================
|
|
68
69
|
|
|
69
70
|
async function resolveConfig(flags) {
|
|
70
|
-
const cwd = process.cwd()
|
|
71
|
+
const cwd = canonicalProjectDirectory(process.cwd())
|
|
71
72
|
const configRel = flags.config ?? 'opus.config.ts'
|
|
72
|
-
const
|
|
73
|
-
|
|
73
|
+
const config = safeProjectPath(cwd, configRel)
|
|
74
|
+
const configPath = config.path
|
|
75
|
+
if (!config.exists) {
|
|
74
76
|
log('error', `opus.config.ts não encontrado em ${configPath}`)
|
|
75
77
|
log('dim', ' Passa o path via --config <path> ou cria um na raiz do projeto.')
|
|
76
78
|
process.exit(1)
|
|
@@ -245,7 +247,7 @@ export function helpDb() {
|
|
|
245
247
|
SQL no schema (a verdade é o script; revise à mão).
|
|
246
248
|
|
|
247
249
|
Flags:
|
|
248
|
-
--config <path> Caminho
|
|
250
|
+
--config <path> Caminho interno ao projeto para opus.config.ts. Default: ./opus.config.ts
|
|
249
251
|
|
|
250
252
|
O opus.config.ts precisa expor, pros comandos db:
|
|
251
253
|
database: () => Kysely factory LAZY do banco (gen não a chama)
|