@softize/opus 11.1.0 → 12.0.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README.md +31 -1
  3. package/bin/cli.mjs +97 -27
  4. package/bin/lib/check.mjs +55 -43
  5. package/bin/lib/copy.mjs +2202 -0
  6. package/bin/lib/create.mjs +227 -39
  7. package/bin/lib/db-migrate-runner.mjs +9 -6
  8. package/bin/lib/db-project-path.mjs +20 -0
  9. package/bin/lib/db-scaffold-runner.mjs +23 -8
  10. package/bin/lib/db.mjs +6 -4
  11. package/bin/lib/gen.mjs +60 -29
  12. package/bin/lib/init.mjs +212 -56
  13. package/bin/lib/introspect.mjs +3 -2
  14. package/bin/lib/materialize.mjs +623 -97
  15. package/bin/lib/postinstall.mjs +6 -5
  16. package/bin/lib/validate-skill.mjs +502 -30
  17. package/docs/code-style.md +142 -7
  18. package/docs/consumer-upgrade-propagation.md +4 -3
  19. package/docs/releasing.md +28 -17
  20. package/package.json +6 -1
  21. package/registry/git/pre-push.d/00-opus-copy +14 -0
  22. package/registry/git/pre-push.d/opus +7 -21
  23. package/registry/git/run-opus-pre-push.mjs +141 -0
  24. package/registry/hooks/opus-check-on-stop.mjs +13 -31
  25. package/registry/instructions/opus.md +11 -5
  26. package/registry/skills/build-opus-ui/SKILL.md +5 -4
  27. package/registry/skills/create-opus-action/SKILL.md +4 -4
  28. package/registry/skills/implement-opus-change/SKILL.md +7 -5
  29. package/registry/skills/upgrade-opus/SKILL.md +8 -4
  30. package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
  31. package/registry/templates/app/package.json +4 -0
  32. package/registry/templates/app/pnpm-workspace.yaml +3 -2
  33. package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
  34. package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
  35. package/src/ui/docs/DocBrowser.tsx +10 -2
  36. package/src/ui/docs/content/cli.md +8 -7
  37. package/src/ui/docs/content/communication.md +83 -0
  38. package/src/ui/docs/content/getting-started.md +29 -16
  39. package/src/ui/docs/registry.tsx +2 -2
  40. package/registry/skills/write-product-communication/SKILL.md +0 -28
  41. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  42. package/registry/templates/app/_npmrc +0 -1
  43. package/registry/templates/monorepo/_npmrc +0 -1
  44. package/src/ui/docs/content/microcopy.md +0 -130
@@ -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 a base: sai do mesmo tarball que
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 (`.npmrc`, `.gitignore`, `pnpm-workspace.yaml` — workspace yaml aninhado
12
- * quebra o pnpm); o que a raiz precisa ter vira AVISO (globs do workspace, escopo
13
- * do registry, allowBuilds), sem clobber.
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 (`_npmrc` `.npmrc`)
17
- * o npm pack descarta dotfiles do tarball, então a fonte não pode tê-los;
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
- * `.claude/memory/` e os gates locais (repo-level: acha a raiz pelo `.git`).
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(['_npmrc', '_gitignore', 'pnpm-workspace.yaml'])
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
- if (await exists(path.join(cur, 'pnpm-workspace.yaml'))) return cur
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
- let ws = ''
58
- try {
59
- ws = await fs.readFile(path.join(wsRoot, 'pnpm-workspace.yaml'), 'utf-8')
60
- } catch {
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 (!/allowBuilds[\s\S]*@softize\/opus/.test(ws)) {
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
- let npmrc = ''
72
- try {
73
- npmrc = await fs.readFile(path.join(wsRoot, '.npmrc'), 'utf-8')
74
- } catch {
75
- /* Sem .npmrc na raiz. */
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
- if (!(await exists(targetDir))) return
102
- const blocking = (await fs.readdir(targetDir)).filter((e) => !PREEXISTING_OK.test(e))
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 dest = path.join(targetDir, destRel)
136
- await fs.mkdir(path.dirname(dest), { recursive: true })
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
- await fs.writeFile(dest, content.replaceAll('{{APP_NAME}}', appName).replaceAll('{{OPUS_VERSION}}', version))
139
- files.push(destRel)
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 da base, escopo do registry e gitignore. Sem app
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 files = await copyTemplate(template, targetDir, { appName: name, version })
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 files = await copyTemplate(template, targetDir, { appName, version }, mode === 'app' ? ROOT_ONLY : undefined)
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, path.resolve(targetDir)) : []
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, targetDir)
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 configPath = process.argv[2]
40
- if (typeof configPath !== 'string') {
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 configDir = path.dirname(path.resolve(configPath))
54
- const schemaPath = path.resolve(configDir, config.schema ?? 'db/schema.sql')
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 = await fs.readFile(schemaPath, 'utf8')
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 configPath = process.argv[2]
39
- if (typeof configPath !== 'string') {
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 configDir = path.dirname(path.resolve(configPath))
74
- const migrationFolder = path.resolve(configDir, config.migrations ?? 'migrations')
75
- await fs.mkdir(migrationFolder, { recursive: true })
76
- const file = path.join(migrationFolder, `${timestamp()}_scaffold.ts`)
77
- await fs.writeFile(file, scaffoldFileContent(result), 'utf8')
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 configPath = path.isAbsolute(configRel) ? configRel : path.resolve(cwd, configRel)
73
- if (!(await fileExists(configPath))) {
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 do opus.config.ts. Default: ./opus.config.ts
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)