@softize/opus 13.0.0 → 14.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.
- package/CHANGELOG.md +81 -0
- package/PROMOTED.md +46 -0
- package/README.md +28 -19
- package/bin/cli.mjs +87 -216
- package/bin/lib/cli-shared.mjs +131 -0
- package/bin/lib/copy.mjs +276 -6
- package/bin/lib/db.mjs +16 -74
- package/bin/lib/gen-openapi.mjs +3 -3
- package/bin/lib/gen-runner.mjs +1 -1
- package/bin/lib/gen.mjs +14 -69
- package/bin/lib/mcp.mjs +3 -1
- package/bin/lib/seed.mjs +5 -62
- package/docs/code-style.md +4 -1
- package/docs/ownership-vs-shadcn-lock.md +2 -3
- package/docs/protocol.md +7 -7
- package/docs/releasing.md +8 -2
- package/package.json +7 -3
- package/registry/instructions/opus.md +3 -3
- package/registry/templates/app/package.json +1 -1
- package/registry/templates/app/src/App.tsx +11 -6
- package/registry/templates/app/src/main.tsx +4 -4
- package/src/audit/drivers/console.ts +1 -0
- package/src/auth/drivers/better-auth.ts +1 -0
- package/src/auth/drivers/jwt.ts +1 -0
- package/src/cache/drivers/memory.ts +1 -0
- package/src/client/drivers/fetch.ts +2 -1
- package/src/core/actions.ts +6 -1
- package/src/core/audit.ts +9 -3
- package/src/core/contracts.ts +7 -0
- package/src/core/domain.ts +1 -1
- package/src/core/errors.ts +18 -15
- package/src/core/index.ts +4 -2
- package/src/core/package-version.ts +26 -0
- package/src/core/reactions.ts +1 -1
- package/src/core/runtime.ts +33 -23
- package/src/core/schedules.ts +1 -1
- package/src/core/types.ts +5 -6
- package/src/dsl/eval.ts +2 -2
- package/src/dsl/kysely.ts +2 -2
- package/src/dsl/loads.ts +1 -1
- package/src/dsl/parser.ts +5 -5
- package/src/events/drivers/mitt.ts +1 -0
- package/src/mcp/index.ts +2 -1
- package/src/observability/drivers/opentelemetry.ts +1 -0
- package/src/queue/drivers/bullmq.ts +3 -3
- package/src/scheduler/drivers/node-cron.ts +3 -2
- package/src/scheduler/every.ts +7 -7
- package/src/schema/openapi.ts +3 -3
- package/src/seed/index.ts +29 -0
- package/src/server/drivers/fastify.ts +5 -2
- package/src/server/drivers/node.ts +9 -6
- package/src/server/index.ts +3 -1
- package/src/storage/drivers/fs.ts +1 -0
- package/src/testing/index.ts +3 -3
- package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
- package/src/ui/components/patterns/confirm.tsx +2 -31
- package/src/ui/components/patterns/content-header.tsx +44 -137
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/dock.tsx +20 -3
- package/src/ui/components/patterns/form.tsx +26 -22
- package/src/ui/components/patterns/list.tsx +18 -12
- package/src/ui/components/patterns/page-state.tsx +39 -51
- package/src/ui/components/patterns/page.tsx +37 -54
- package/src/ui/components/patterns/sidebar.tsx +17 -6
- package/src/ui/components/patterns/state-surface.tsx +148 -0
- package/src/ui/components/patterns/surface-header.tsx +119 -0
- package/src/ui/components/patterns/trigger.tsx +21 -25
- package/src/ui/components/patterns/view.tsx +29 -22
- package/src/ui/components/primitives/alert.tsx +1 -27
- package/src/ui/components/primitives/ask.tsx +3 -3
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +5 -41
- package/src/ui/components/primitives/breadcrumb.tsx +2 -2
- package/src/ui/components/primitives/button.tsx +39 -30
- package/src/ui/components/primitives/calendar.tsx +28 -2
- package/src/ui/components/primitives/carousel.tsx +3 -3
- package/src/ui/components/primitives/chat.tsx +1 -1
- package/src/ui/components/primitives/checkbox.tsx +1 -1
- package/src/ui/components/primitives/command.tsx +2 -2
- package/src/ui/components/primitives/control.ts +72 -0
- package/src/ui/components/primitives/copyable.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +12 -7
- package/src/ui/components/primitives/dot.tsx +1 -25
- package/src/ui/components/primitives/drawer.tsx +10 -3
- package/src/ui/components/primitives/field.tsx +3 -3
- package/src/ui/components/primitives/icon-picker.tsx +3 -1
- package/src/ui/components/primitives/input-group.tsx +12 -9
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/input.tsx +2 -2
- package/src/ui/components/primitives/item.tsx +3 -1
- package/src/ui/components/primitives/menu.tsx +1 -7
- package/src/ui/components/primitives/pagination.tsx +16 -8
- package/src/ui/components/primitives/progress.tsx +32 -3
- package/src/ui/components/primitives/radio-group.tsx +1 -1
- package/src/ui/components/primitives/resizable.tsx +3 -1
- package/src/ui/components/primitives/select.tsx +6 -6
- package/src/ui/components/primitives/slider.tsx +5 -1
- package/src/ui/components/primitives/sonner.tsx +3 -0
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +5 -1
- package/src/ui/components/primitives/tabs.tsx +6 -3
- package/src/ui/components/primitives/textarea.tsx +1 -1
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/components/primitives/tooltip.tsx +1 -0
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/action-form.md +36 -3
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +13 -2
- package/src/ui/docs/content/action-trigger.md +12 -5
- package/src/ui/docs/content/action-view.md +11 -3
- package/src/ui/docs/content/ask.md +11 -0
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +30 -14
- package/src/ui/docs/content/calendar.md +13 -0
- package/src/ui/docs/content/card.md +26 -0
- package/src/ui/docs/content/chat.md +20 -0
- package/src/ui/docs/content/cli.md +71 -19
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/composer.md +15 -0
- package/src/ui/docs/content/content.md +17 -1
- package/src/ui/docs/content/copyable.md +8 -0
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/detail.md +19 -1
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/dictionary-value.md +9 -2
- package/src/ui/docs/content/dock.md +8 -0
- package/src/ui/docs/content/dot.md +8 -0
- package/src/ui/docs/content/empty.md +2 -2
- package/src/ui/docs/content/getting-started.md +2 -2
- package/src/ui/docs/content/icon-picker.md +11 -0
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/label.md +7 -0
- package/src/ui/docs/content/menu.md +6 -0
- package/src/ui/docs/content/metric-card.md +13 -0
- package/src/ui/docs/content/page.md +20 -4
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/popover.md +6 -0
- package/src/ui/docs/content/progress.md +8 -11
- package/src/ui/docs/content/select.md +5 -5
- package/src/ui/docs/content/semantic-context.md +5 -4
- package/src/ui/docs/content/sidebar.md +13 -47
- package/src/ui/docs/content/skeleton.md +6 -0
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/split.md +21 -0
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/textarea.md +7 -0
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/docs/content/tokens.md +4 -4
- package/src/ui/docs/content/truncate.md +8 -0
- package/src/ui/docs/content/ui.md +14 -0
- package/src/ui/docs/doc-client.tsx +5 -5
- package/src/ui/docs/doc.tsx +26 -14
- package/src/ui/docs/registry.tsx +5 -5
- package/src/ui/docs/standalone.tsx +2 -2
- package/src/ui/drivers/react.tsx +12 -12
- package/src/ui/lib/action-errors.ts +45 -0
- package/src/ui/lib/zod-pt-br.ts +31 -4
- package/src/ui/meta.ts +8 -8
- package/src/ui/react.tsx +10 -16
- package/src/ui/theme.css +10 -8
- package/src/vite/design.ts +6 -18
- package/src/ui/components/patterns/shell-nav.tsx +0 -147
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitários compartilhados pelos comandos do CLI `opus`.
|
|
3
|
+
*
|
|
4
|
+
* Concentra o que mais de um comando repetia: log colorido no terminal, teste de
|
|
5
|
+
* existência de arquivo, resolução do `opus.config.ts` do consumer e o spawn do `tsx`
|
|
6
|
+
* que carrega esse config TypeScript num runner isolado (`gen`, `db` e `seed` usam a
|
|
7
|
+
* mesma infra: o runner emite uma linha JSON na última linha de stdout).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFile } from 'node:child_process'
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { promisify } from 'node:util'
|
|
15
|
+
|
|
16
|
+
import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile)
|
|
19
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
20
|
+
|
|
21
|
+
export const CONFIG_FILE = 'opus.config.ts'
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Log
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
const COLORS = {
|
|
28
|
+
info: '\x1b[36m',
|
|
29
|
+
success: '\x1b[32m',
|
|
30
|
+
error: '\x1b[31m',
|
|
31
|
+
warn: '\x1b[33m',
|
|
32
|
+
dim: '\x1b[2m',
|
|
33
|
+
}
|
|
34
|
+
const RESET = '\x1b[0m'
|
|
35
|
+
|
|
36
|
+
/** Escreve uma linha colorida em stdout. Nível desconhecido sai sem cor. */
|
|
37
|
+
export function log(level, message) {
|
|
38
|
+
console.log(`${COLORS[level] ?? ''}${message}${RESET}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// FS
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
export async function fileExists(file) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.access(file)
|
|
48
|
+
return true
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Config do consumer
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve o `opus.config.ts` a partir de `flags.config` (default: `./opus.config.ts`),
|
|
60
|
+
* contido no projeto. Lança `Error` com uma mensagem única quando o arquivo não existe;
|
|
61
|
+
* cada comando decide o canal (texto ou JSON) e o código de saída.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveConfig(flags, cwd = process.cwd()) {
|
|
64
|
+
const root = canonicalProjectDirectory(cwd)
|
|
65
|
+
const config = safeProjectPath(root, flags.config ?? CONFIG_FILE)
|
|
66
|
+
if (!config.exists) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${CONFIG_FILE} não encontrado em ${config.path}. ` +
|
|
69
|
+
'Passe o caminho com --config <path> ou crie o arquivo na raiz do projeto.',
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
return { cwd: root, configPath: config.path }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Runner via tsx
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Binário do tsx: o empacotado em `<package>/node_modules/.bin/tsx` quando existe
|
|
81
|
+
* (independe do PATH do consumer); senão confia no `tsx` do PATH.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveTsxBin() {
|
|
84
|
+
const local = path.join(PACKAGE_ROOT, 'node_modules', '.bin', 'tsx')
|
|
85
|
+
return (await fileExists(local)) ? local : 'tsx'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Executa `tsx <runnerPath> ...args` capturando stdout/stderr em utf8 e herdando o
|
|
90
|
+
* ambiente. Rejeita como o `execFile` (o erro traz `stdout` e `stderr`) quando o
|
|
91
|
+
* processo falha.
|
|
92
|
+
*/
|
|
93
|
+
export async function spawnTsx(runnerPath, args, { maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
94
|
+
const command = await resolveTsxBin()
|
|
95
|
+
return execFileAsync(command, [runnerPath, ...args], {
|
|
96
|
+
encoding: 'utf8',
|
|
97
|
+
maxBuffer,
|
|
98
|
+
env: { ...process.env },
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Última linha de stdout que parece um objeto JSON. Logs do consumer durante o
|
|
104
|
+
* import do config vão para o início; o runner emite o payload por último.
|
|
105
|
+
*/
|
|
106
|
+
export function lastJsonLine(stdout) {
|
|
107
|
+
const lines = stdout.split('\n').filter((line) => line.trim().length > 0)
|
|
108
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
109
|
+
const line = lines[index].trim()
|
|
110
|
+
if (line.startsWith('{') && line.endsWith('}')) return line
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Roda um runner que responde `{ ok, ... }` em JSON. Nunca rejeita: falha de spawn ou
|
|
117
|
+
* ausência de payload viram `{ ok: false, error }` para o comando reportar.
|
|
118
|
+
*/
|
|
119
|
+
export async function runJsonRunner(runnerPath, args, options) {
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await spawnTsx(runnerPath, args, options)
|
|
122
|
+
const line = lastJsonLine(stdout)
|
|
123
|
+
return line === null ? { ok: false, error: 'runner não emitiu JSON' } : JSON.parse(line)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const detail =
|
|
126
|
+
typeof error.stderr === 'string' && error.stderr.trim().length > 0
|
|
127
|
+
? error.stderr.trim()
|
|
128
|
+
: error.message
|
|
129
|
+
return { ok: false, error: detail }
|
|
130
|
+
}
|
|
131
|
+
}
|
package/bin/lib/copy.mjs
CHANGED
|
@@ -107,8 +107,16 @@ const JSX_CHILD_ROLES = new Map([
|
|
|
107
107
|
['TableCaption', 'description'],
|
|
108
108
|
['TableHead', 'heading'],
|
|
109
109
|
['TabsTrigger', 'tab'],
|
|
110
|
+
// A Base não tem papel `tooltip`. O tooltip nomeia um controle icon-only, logo é um
|
|
111
|
+
// fragmento (sem ponto final), não uma frase; `label` é o papel que reflete isso.
|
|
112
|
+
['TooltipContent', 'label'],
|
|
110
113
|
])
|
|
111
114
|
|
|
115
|
+
// Componentes cuja copy entra pelas próprias props e que, como filhos, não acrescentam
|
|
116
|
+
// texto ao pai: `<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>` inventaria `Nome`
|
|
117
|
+
// como label e a ajuda como helper-text, sem tornar o filho opaco.
|
|
118
|
+
const SELF_CONTAINED_CHILD_COMPONENTS = new Set(['LabelHelp'])
|
|
119
|
+
|
|
112
120
|
const OPTIONAL_CHILD_COMPONENTS = new Set(['ActionForm', 'ActionFormCard', 'ActionFormDialog'])
|
|
113
121
|
const JSX_ACTION_CONSUMERS = new Set([
|
|
114
122
|
'ActionForm', 'ActionFormCard', 'ActionFormDialog', 'ActionList', 'ActionListDialog', 'ActionTrigger', 'ActionView',
|
|
@@ -125,6 +133,14 @@ const JSX_PROP_ROLES = new Map([
|
|
|
125
133
|
['ActionForm', new Map([['submitLabel', 'button'], ['cancelLabel', 'button']])],
|
|
126
134
|
['ActionFormCard', new Map([['title', 'title'], ['description', 'description'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
|
|
127
135
|
['ActionFormDialog', new Map([['title', 'title'], ['description', 'dialog-body'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
|
|
136
|
+
// Estados unificados (14.0): a mesma tríade de mensagens em ActionList, ActionListDialog,
|
|
137
|
+
// DataState e PageState; ActionView só tem o vazio.
|
|
138
|
+
['ActionList', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
|
|
139
|
+
['ActionListDialog', new Map([
|
|
140
|
+
['title', 'title'], ['description', 'dialog-body'],
|
|
141
|
+
['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
|
|
142
|
+
])],
|
|
143
|
+
['ActionView', new Map([['emptyMessage', 'empty-state']])],
|
|
128
144
|
// `itemLabel` identifica o dado alvo (por exemplo, `customer.name`); não é
|
|
129
145
|
// microcopy estável e, por isso, não pertence ao inventário editorial.
|
|
130
146
|
['ActionTrigger', new Map([['label', 'button']])],
|
|
@@ -132,7 +148,7 @@ const JSX_PROP_ROLES = new Map([
|
|
|
132
148
|
['CommandInput', new Map([['placeholder', 'placeholder']])],
|
|
133
149
|
['Content', new Map([['title', 'title'], ['description', 'description']])],
|
|
134
150
|
['ContentHeader', new Map([['title', 'title'], ['description', 'description']])],
|
|
135
|
-
['DataState', new Map([['
|
|
151
|
+
['DataState', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
|
|
136
152
|
// A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
|
|
137
153
|
// exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
|
|
138
154
|
['Dock', new Map([['label', 'label']])],
|
|
@@ -140,8 +156,14 @@ const JSX_PROP_ROLES = new Map([
|
|
|
140
156
|
['Input', new Map([['placeholder', 'placeholder']])],
|
|
141
157
|
['InputGroupInput', new Map([['placeholder', 'placeholder']])],
|
|
142
158
|
['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
|
|
159
|
+
// A ajuda vive num tooltip (portal): não herda transformação nem classe do label.
|
|
160
|
+
['LabelHelp', new Map([['help', 'helper-text']])],
|
|
143
161
|
['MetricCard', new Map([['label', 'label'], ['description', 'description']])],
|
|
144
162
|
['Page', new Map([['title', 'title'], ['description', 'description']])],
|
|
163
|
+
['PageState', new Map([
|
|
164
|
+
['title', 'title'], ['description', 'description'],
|
|
165
|
+
['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
|
|
166
|
+
])],
|
|
145
167
|
['Select', new Map([
|
|
146
168
|
['placeholder', 'placeholder'],
|
|
147
169
|
['searchPlaceholder', 'placeholder'],
|
|
@@ -215,6 +237,36 @@ const JSX_PORTAL_BOUNDARIES = new Set([
|
|
|
215
237
|
'PopoverContent', 'TooltipContent',
|
|
216
238
|
])
|
|
217
239
|
|
|
240
|
+
// Respostas imperativas `dialog.*` da UI Opus. O DialogHost renderiza em portal com classes
|
|
241
|
+
// próprias; nenhuma transformação do chamador alcança o texto.
|
|
242
|
+
const DIALOG_METHOD_ROLES = new Map([
|
|
243
|
+
['alert', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'], ['action', 'button']])],
|
|
244
|
+
['confirm', new Map([
|
|
245
|
+
['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
|
|
246
|
+
['action', 'button'], ['cancel', 'button'],
|
|
247
|
+
])],
|
|
248
|
+
['prompt', new Map([
|
|
249
|
+
['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
|
|
250
|
+
['action', 'button'], ['cancel', 'button'], ['placeholder', 'placeholder'],
|
|
251
|
+
])],
|
|
252
|
+
['choose', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body']])],
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
// Métodos de array que não mutam o receptor. Os que devolvem os próprios elementos (num
|
|
256
|
+
// array novo ou avulsos) exigem que o resultado também seja usado de forma estável; os
|
|
257
|
+
// demais só produzem primitivos, novos objetos ou nada.
|
|
258
|
+
const ARRAY_RETURNING_ARRAY_METHODS = new Set(['concat', 'filter', 'flat', 'slice', 'toReversed', 'toSorted'])
|
|
259
|
+
const ELEMENT_PICKING_ARRAY_METHODS = new Set(['at', 'find', 'findLast'])
|
|
260
|
+
const ELEMENT_ITERATING_ARRAY_METHODS = new Set([
|
|
261
|
+
'every', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'flatMap', 'forEach', 'map', 'some',
|
|
262
|
+
])
|
|
263
|
+
const READ_ONLY_ARRAY_METHODS = new Set([
|
|
264
|
+
...ARRAY_RETURNING_ARRAY_METHODS, ...ELEMENT_PICKING_ARRAY_METHODS, ...ELEMENT_ITERATING_ARRAY_METHODS,
|
|
265
|
+
'includes', 'indexOf', 'join', 'lastIndexOf',
|
|
266
|
+
])
|
|
267
|
+
// Cadeia que preserva os elementos entre o array literal e a iteração que os lê.
|
|
268
|
+
const ELEMENT_PRESERVING_ARRAY_METHODS = new Set(['filter', 'slice', 'toReversed', 'toSorted'])
|
|
269
|
+
|
|
218
270
|
const digest = (content) => createHash('sha256').update(content).digest('hex')
|
|
219
271
|
const portable = (value) => value.split(path.sep).join('/')
|
|
220
272
|
|
|
@@ -421,6 +473,17 @@ function knownBindAction(call, checker) {
|
|
|
421
473
|
return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
|
|
422
474
|
}
|
|
423
475
|
|
|
476
|
+
/** Reconhece `dialog.confirm({...})` e `Opus.dialog.alert({...})` importados da UI Opus. */
|
|
477
|
+
function knownDialogCall(call, checker) {
|
|
478
|
+
const callee = unwrap(call.expression)
|
|
479
|
+
if (
|
|
480
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
481
|
+
DIALOG_METHOD_ROLES.has(callee.name.text) &&
|
|
482
|
+
importedMemberName(callee.expression, checker, uiModule) === 'dialog'
|
|
483
|
+
) return { method: callee.name.text, field: `dialog.${callee.name.text}()` }
|
|
484
|
+
return null
|
|
485
|
+
}
|
|
486
|
+
|
|
424
487
|
function safeBindActionBinding(call, checker) {
|
|
425
488
|
if (call.arguments.length < 2) return false
|
|
426
489
|
const binding = unwrap(call.arguments[1])
|
|
@@ -521,6 +584,16 @@ function stableExpressionUse(node, checker, mode, stack) {
|
|
|
521
584
|
const parent = carrier.parent
|
|
522
585
|
|
|
523
586
|
if (assignmentTarget(carrier)) return false
|
|
587
|
+
if (consumerMode === 'aggregate' && ts.isCallExpression(parent) && parent.expression === carrier) {
|
|
588
|
+
const access = unwrap(carrier)
|
|
589
|
+
return (
|
|
590
|
+
ts.isPropertyAccessExpression(access) && arrayValued(access.expression, checker) &&
|
|
591
|
+
readOnlyArrayMethodUse(access.name.text, parent, checker, stack)
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
if (ts.isForOfStatement(parent) && parent.expression === carrier) {
|
|
595
|
+
return consumerMode === 'aggregate' && arrayValued(carrier, checker) && readOnlyForOfBinding(parent, checker)
|
|
596
|
+
}
|
|
524
597
|
if (ts.isVariableDeclaration(parent) && parent.initializer === carrier) {
|
|
525
598
|
if (!ts.isVariableDeclarationList(parent.parent) || (parent.parent.flags & ts.NodeFlags.Const) === 0) return false
|
|
526
599
|
const aliases = bindingNames(parent.name, checker)
|
|
@@ -534,6 +607,8 @@ function stableExpressionUse(node, checker, mode, stack) {
|
|
|
534
607
|
if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
|
|
535
608
|
if (consumerMode === 'aggregate') {
|
|
536
609
|
if (knownDictionaryFactory(parent, checker)) return true
|
|
610
|
+
// O DialogHost copia as opções (`{ ...options }`) e só as lê; nada muta o literal.
|
|
611
|
+
if (parent.arguments[0] === carrier && knownDialogCall(parent, checker) !== null) return true
|
|
537
612
|
return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
|
|
538
613
|
}
|
|
539
614
|
if (
|
|
@@ -573,6 +648,166 @@ function stableReferences(binding, checker, mode, stack) {
|
|
|
573
648
|
return stable
|
|
574
649
|
}
|
|
575
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Um parâmetro de callback (ou variável de `for…of`) é somente leitura quando cada uso é
|
|
653
|
+
* `elemento.chave` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
|
|
654
|
+
* propriedade nunca altera o rótulo literal; qualquer outro uso (spread, argumento, retorno,
|
|
655
|
+
* atribuição) poderia mutar ou fazer o objeto escapar e é rejeitado. Destructuring copia os
|
|
656
|
+
* valores de primeiro nível e, por isso, também não alcança o literal.
|
|
657
|
+
*/
|
|
658
|
+
function readOnlyElementBinding(name, checker) {
|
|
659
|
+
if (ts.isObjectBindingPattern(name)) return true
|
|
660
|
+
if (!ts.isIdentifier(name)) return false
|
|
661
|
+
const symbol = bindingSymbol(name, checker)
|
|
662
|
+
if (symbol === undefined) return false
|
|
663
|
+
let readOnly = true
|
|
664
|
+
const visit = (node) => {
|
|
665
|
+
if (!readOnly) return
|
|
666
|
+
if (ts.isIdentifier(node) && sameBinding(node, symbol, checker) && !declarationIdentifier(node, symbol)) {
|
|
667
|
+
const carrier = transparentCarrier(node)
|
|
668
|
+
const access = carrier.parent
|
|
669
|
+
readOnly =
|
|
670
|
+
(ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) &&
|
|
671
|
+
access.expression === carrier && !assignmentTarget(access)
|
|
672
|
+
}
|
|
673
|
+
ts.forEachChild(node, visit)
|
|
674
|
+
}
|
|
675
|
+
visit(name.getSourceFile())
|
|
676
|
+
return readOnly
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function readOnlyCallback(callback, checker) {
|
|
680
|
+
const value = callback === undefined ? null : unwrap(callback)
|
|
681
|
+
if (value === null || (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))) return false
|
|
682
|
+
// O terceiro parâmetro expõe o próprio array ao callback; índice e `thisArg` não.
|
|
683
|
+
if (value.parameters.length > 2) return false
|
|
684
|
+
const element = value.parameters[0]
|
|
685
|
+
if (element === undefined) return true
|
|
686
|
+
if (element.dotDotDotToken !== undefined || element.initializer !== undefined) return false
|
|
687
|
+
return readOnlyElementBinding(element.name, checker)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function readOnlyArrayMethodUse(method, call, checker, stack) {
|
|
691
|
+
if (!READ_ONLY_ARRAY_METHODS.has(method)) return false
|
|
692
|
+
if (ELEMENT_ITERATING_ARRAY_METHODS.has(method) && !readOnlyCallback(call.arguments[0], checker)) return false
|
|
693
|
+
if (ARRAY_RETURNING_ARRAY_METHODS.has(method) || ELEMENT_PICKING_ARRAY_METHODS.has(method)) {
|
|
694
|
+
return stableExpressionUse(call, checker, 'aggregate', stack)
|
|
695
|
+
}
|
|
696
|
+
return true
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* A semântica somente leitura de `map`, `filter`… vale para arrays. Um objeto literal pode
|
|
701
|
+
* declarar um método com o mesmo nome e mutar o que quiser; por isso o receptor precisa ser
|
|
702
|
+
* um `const` com array literal ou o resultado de um método que devolve array sobre ele.
|
|
703
|
+
*/
|
|
704
|
+
function arrayValued(node, checker) {
|
|
705
|
+
const value = unwrap(node)
|
|
706
|
+
if (ts.isIdentifier(value)) {
|
|
707
|
+
const binding = constInitializer(value, checker)
|
|
708
|
+
return binding !== null && ts.isArrayLiteralExpression(unwrap(binding.initializer))
|
|
709
|
+
}
|
|
710
|
+
if (ts.isCallExpression(value)) {
|
|
711
|
+
const callee = unwrap(value.expression)
|
|
712
|
+
return ts.isPropertyAccessExpression(callee) && ARRAY_RETURNING_ARRAY_METHODS.has(callee.name.text) &&
|
|
713
|
+
arrayValued(callee.expression, checker)
|
|
714
|
+
}
|
|
715
|
+
return ts.isArrayLiteralExpression(value)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function readOnlyForOfBinding(statement, checker) {
|
|
719
|
+
const initializer = statement.initializer
|
|
720
|
+
if (!ts.isVariableDeclarationList(initializer) || initializer.declarations.length !== 1) return false
|
|
721
|
+
return readOnlyElementBinding(initializer.declarations[0].name, checker)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Localiza o array literal do qual um identificador é elemento: parâmetro de callback de
|
|
726
|
+
* `map`/`forEach`/`filter`… ou variável de `for…of`, possivelmente atrás de `filter`,
|
|
727
|
+
* `slice`, `toSorted` e `toReversed`, que preservam os elementos. Só arrays literais no
|
|
728
|
+
* mesmo arquivo entram; qualquer outra origem devolve null.
|
|
729
|
+
*/
|
|
730
|
+
function elementSource(identifier, checker) {
|
|
731
|
+
const symbol = bindingSymbol(identifier, checker)
|
|
732
|
+
const declaration = symbol?.declarations?.find(
|
|
733
|
+
(item) => ts.isParameter(item) || ts.isBindingElement(item) || ts.isVariableDeclaration(item),
|
|
734
|
+
)
|
|
735
|
+
if (declaration === undefined) return null
|
|
736
|
+
|
|
737
|
+
let key = null
|
|
738
|
+
let binding = declaration
|
|
739
|
+
if (ts.isBindingElement(declaration)) {
|
|
740
|
+
if (declaration.dotDotDotToken !== undefined || declaration.initializer !== undefined) return null
|
|
741
|
+
const pattern = declaration.parent
|
|
742
|
+
if (!ts.isObjectBindingPattern(pattern) || (!ts.isParameter(pattern.parent) && !ts.isVariableDeclaration(pattern.parent))) return null
|
|
743
|
+
const keyNode = declaration.propertyName ?? declaration.name
|
|
744
|
+
if (!ts.isIdentifier(keyNode) && !ts.isStringLiteral(keyNode)) return null
|
|
745
|
+
key = keyNode.text
|
|
746
|
+
binding = pattern.parent
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
let receiver
|
|
750
|
+
if (ts.isParameter(binding)) {
|
|
751
|
+
if (binding.dotDotDotToken !== undefined || binding.initializer !== undefined) return null
|
|
752
|
+
const callback = binding.parent
|
|
753
|
+
if ((!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) || callback.parameters[0] !== binding) return null
|
|
754
|
+
const call = transparentCarrier(callback).parent
|
|
755
|
+
if (!ts.isCallExpression(call) || call.arguments[0] !== transparentCarrier(callback)) return null
|
|
756
|
+
const callee = unwrap(call.expression)
|
|
757
|
+
if (!ts.isPropertyAccessExpression(callee) || !ELEMENT_ITERATING_ARRAY_METHODS.has(callee.name.text)) return null
|
|
758
|
+
receiver = callee.expression
|
|
759
|
+
} else if (ts.isVariableDeclaration(binding)) {
|
|
760
|
+
const list = binding.parent
|
|
761
|
+
if (!ts.isVariableDeclarationList(list) || !ts.isForOfStatement(list.parent) || list.parent.initializer !== list) return null
|
|
762
|
+
receiver = list.parent.expression
|
|
763
|
+
} else return null
|
|
764
|
+
|
|
765
|
+
let source = unwrap(receiver)
|
|
766
|
+
while (
|
|
767
|
+
ts.isCallExpression(source) && ts.isPropertyAccessExpression(unwrap(source.expression)) &&
|
|
768
|
+
ELEMENT_PRESERVING_ARRAY_METHODS.has(unwrap(source.expression).name.text)
|
|
769
|
+
) source = unwrap(unwrap(source.expression).expression)
|
|
770
|
+
const array = resolveBinding(source, checker)
|
|
771
|
+
return ts.isArrayLiteralExpression(array) ? { array, key } : null
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Resolve `elemento.chave` (ou a chave destruturada) para o texto literal de cada elemento
|
|
776
|
+
* do array de origem. Um único elemento opaco ou sem texto estático torna o conjunto todo
|
|
777
|
+
* inextraível: o inventário não pode listar só parte do que a superfície renderiza.
|
|
778
|
+
*/
|
|
779
|
+
function elementTexts(node, checker) {
|
|
780
|
+
const value = unwrap(node)
|
|
781
|
+
let identifier
|
|
782
|
+
let key = null
|
|
783
|
+
if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
|
|
784
|
+
identifier = unwrap(value.expression)
|
|
785
|
+
key = value.name.text
|
|
786
|
+
} else if (ts.isElementAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
|
|
787
|
+
identifier = unwrap(value.expression)
|
|
788
|
+
key = value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
|
|
789
|
+
} else if (ts.isIdentifier(value)) {
|
|
790
|
+
identifier = value
|
|
791
|
+
} else return null
|
|
792
|
+
const source = elementSource(identifier, checker)
|
|
793
|
+
if (source === null) return null
|
|
794
|
+
if (ts.isIdentifier(value)) key = source.key
|
|
795
|
+
else if (source.key !== null) return null
|
|
796
|
+
if (key === null) return null
|
|
797
|
+
|
|
798
|
+
const listed = objects(source.array, checker)
|
|
799
|
+
if (listed.opaque.length > 0 || listed.values.length === 0) return null
|
|
800
|
+
const texts = []
|
|
801
|
+
for (const element of listed.values) {
|
|
802
|
+
const resolved = property(element, key, element.getSourceFile(), checker)
|
|
803
|
+
if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
|
|
804
|
+
const text = staticText(propertyValue(resolved.candidate), checker)
|
|
805
|
+
if (text === null) return null
|
|
806
|
+
texts.push(text)
|
|
807
|
+
}
|
|
808
|
+
return texts
|
|
809
|
+
}
|
|
810
|
+
|
|
576
811
|
/**
|
|
577
812
|
* Objetos e arrays `const` continuam mutáveis. A extração só faz fold quando cada uso
|
|
578
813
|
* está numa allowlist observável: composição `const` igualmente estável, factory Opus
|
|
@@ -982,7 +1217,6 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
982
1217
|
const field = (object, prefix) => {
|
|
983
1218
|
addProperty(object, 'label', 'label', prefix)
|
|
984
1219
|
addProperty(object, 'placeholder', 'placeholder', prefix)
|
|
985
|
-
addProperty(object, 'hint', 'helper-text', prefix)
|
|
986
1220
|
addProperty(object, 'help', 'helper-text', prefix)
|
|
987
1221
|
nestedObject(object, 'options', optionItems, prefix)
|
|
988
1222
|
}
|
|
@@ -1384,6 +1618,11 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1384
1618
|
? { kind: 'decorative' }
|
|
1385
1619
|
: { kind: 'text', ...text, uppercase: inheritedUppercase }
|
|
1386
1620
|
}
|
|
1621
|
+
const listed = elementTexts(value, checker)
|
|
1622
|
+
if (listed !== null) {
|
|
1623
|
+
const values = listed.map((item) => ({ kind: 'text', ...item, uppercase: inheritedUppercase }))
|
|
1624
|
+
return values.length === 1 ? values[0] : { kind: 'alternatives', values, node: values[0].node }
|
|
1625
|
+
}
|
|
1387
1626
|
if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
|
|
1388
1627
|
return childNode(value, inheritedUppercase, hiddenAncestor)
|
|
1389
1628
|
}
|
|
@@ -1439,6 +1678,9 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1439
1678
|
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
1440
1679
|
const opening = ts.isJsxElement(child) ? child.openingElement : child
|
|
1441
1680
|
const component = opusComponent(opening.tagName)
|
|
1681
|
+
if (component !== null && SELF_CONTAINED_CHILD_COMPONENTS.has(component) && ts.isJsxSelfClosingElement(child)) {
|
|
1682
|
+
return { kind: 'decorative' }
|
|
1683
|
+
}
|
|
1442
1684
|
const provenElement = transformableElement(opening, component)
|
|
1443
1685
|
const hidden = attribute(opening.attributes, 'aria-hidden')
|
|
1444
1686
|
if (hidden.opaque !== undefined) return { kind: 'opaque', node: hidden.opaque }
|
|
@@ -1593,7 +1835,10 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1593
1835
|
if (component === null) return
|
|
1594
1836
|
let inherited
|
|
1595
1837
|
const inheritedFor = (className, styleName, portal = false) => {
|
|
1596
|
-
|
|
1838
|
+
// Um componente que renderiza em portal (TooltipContent) não é descendente visual
|
|
1839
|
+
// dos ancestrais no JSX; a transformação deles não alcança o texto.
|
|
1840
|
+
if (portal || JSX_PORTAL_BOUNDARIES.has(component)) return false
|
|
1841
|
+
if (localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
|
|
1597
1842
|
inherited ??= ancestorTransform(element)
|
|
1598
1843
|
return inherited.opaque ? false : inherited.uppercase
|
|
1599
1844
|
}
|
|
@@ -1676,7 +1921,11 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1676
1921
|
}
|
|
1677
1922
|
} else {
|
|
1678
1923
|
const value = raw === null ? null : attributeText(item)
|
|
1679
|
-
|
|
1924
|
+
// `title={group.label}` dentro de `GROUPS.map(...)`: cada literal do array local
|
|
1925
|
+
// vira um texto na própria linha, em vez de um diagnóstico sobre a prop.
|
|
1926
|
+
const listed = value === null && raw !== null ? elementTexts(raw, checker) : null
|
|
1927
|
+
const texts = value !== null ? [value] : listed ?? []
|
|
1928
|
+
if (texts.length === 0) {
|
|
1680
1929
|
diagnostics.push(diagnostic(
|
|
1681
1930
|
file,
|
|
1682
1931
|
sourceFile,
|
|
@@ -1685,8 +1934,8 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1685
1934
|
raw === null ? 'missing-content' : 'content',
|
|
1686
1935
|
))
|
|
1687
1936
|
}
|
|
1688
|
-
|
|
1689
|
-
const entry = { source: file, line: lineOf(sourceFile,
|
|
1937
|
+
for (const text of texts) {
|
|
1938
|
+
const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
|
|
1690
1939
|
if (transformed.uppercase) entry.transform = 'uppercase'
|
|
1691
1940
|
entries.push(entry)
|
|
1692
1941
|
}
|
|
@@ -1838,7 +2087,28 @@ export function extractCopyFromSource(file, sourceText) {
|
|
|
1838
2087
|
}
|
|
1839
2088
|
}
|
|
1840
2089
|
|
|
2090
|
+
/**
|
|
2091
|
+
* `dialog.confirm({ title, description, action })` é copy tão editorial quanto a de um
|
|
2092
|
+
* `<ActionFormDialog>`; a chamada só é imperativa. Um template com interpolação
|
|
2093
|
+
* (`description: describe(name)`) cai no diagnóstico `content` normal, declarável em
|
|
2094
|
+
* `copy.dynamic` como `message-template`.
|
|
2095
|
+
*/
|
|
2096
|
+
function extractDialog(call, { method, field }) {
|
|
2097
|
+
const raw = call.arguments[0]
|
|
2098
|
+
const options = raw === undefined ? null : resolveBinding(raw, checker)
|
|
2099
|
+
if (options === null || !ts.isObjectLiteralExpression(options)) {
|
|
2100
|
+
diagnostics.push(diagnostic(file, sourceFile, raw ?? call, `${field} options`, 'structure'))
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
for (const [name, role] of DIALOG_METHOD_ROLES.get(method)) addVisualObjectProperty(options, name, role, `${field}.`)
|
|
2104
|
+
if (method === 'choose') {
|
|
2105
|
+
nestedObject(options, 'actions', (action, prefix) => addVisualObjectProperty(action, 'label', 'button', prefix), `${field}.`)
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1841
2109
|
function visit(node) {
|
|
2110
|
+
const dialogCall = ts.isCallExpression(node) ? knownDialogCall(node, checker) : null
|
|
2111
|
+
if (dialogCall !== null) extractDialog(node, dialogCall)
|
|
1842
2112
|
const factory = ts.isCallExpression(node) ? contractFactory(node.expression) : null
|
|
1843
2113
|
if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
|
|
1844
2114
|
hasContract = true
|
package/bin/lib/db.mjs
CHANGED
|
@@ -10,31 +10,12 @@
|
|
|
10
10
|
* Ver `docs/data-layer.md`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { promises as fs } from 'node:fs'
|
|
14
13
|
import path from 'node:path'
|
|
15
14
|
import { fileURLToPath } from 'node:url'
|
|
16
|
-
import { execFile } from 'node:child_process'
|
|
17
|
-
import { promisify } from 'node:util'
|
|
18
|
-
import { canonicalProjectDirectory, safeProjectPath } from '@softize/base/project-path'
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
import { log, resolveConfig, runJsonRunner } from './cli-shared.mjs'
|
|
21
17
|
|
|
22
|
-
const
|
|
23
|
-
const __dirname = path.dirname(__filename)
|
|
24
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
25
|
-
|
|
26
|
-
const COLORS = {
|
|
27
|
-
info: '\x1b[36m',
|
|
28
|
-
success: '\x1b[32m',
|
|
29
|
-
error: '\x1b[31m',
|
|
30
|
-
warn: '\x1b[33m',
|
|
31
|
-
dim: '\x1b[2m',
|
|
32
|
-
}
|
|
33
|
-
const RESET = '\x1b[0m'
|
|
34
|
-
|
|
35
|
-
function log(level, msg) {
|
|
36
|
-
console.log(`${COLORS[level] ?? ''}${msg}${RESET}`)
|
|
37
|
-
}
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
38
19
|
|
|
39
20
|
// =============================================================================
|
|
40
21
|
// Dispatch
|
|
@@ -67,17 +48,13 @@ export async function cmdDb(rest, flags) {
|
|
|
67
48
|
// Config
|
|
68
49
|
// =============================================================================
|
|
69
50
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!config.exists) {
|
|
76
|
-
log('error', `opus.config.ts não encontrado em ${configPath}`)
|
|
77
|
-
log('dim', ' Passa o path via --config <path> ou cria um na raiz do projeto.')
|
|
51
|
+
function resolveConfigOrExit(flags) {
|
|
52
|
+
try {
|
|
53
|
+
return resolveConfig(flags)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log('error', error instanceof Error ? error.message : String(error))
|
|
78
56
|
process.exit(1)
|
|
79
57
|
}
|
|
80
|
-
return { cwd, configPath }
|
|
81
58
|
}
|
|
82
59
|
|
|
83
60
|
// =============================================================================
|
|
@@ -85,7 +62,7 @@ async function resolveConfig(flags) {
|
|
|
85
62
|
// =============================================================================
|
|
86
63
|
|
|
87
64
|
async function cmdDbCheck(flags) {
|
|
88
|
-
const { cwd, configPath } =
|
|
65
|
+
const { cwd, configPath } = resolveConfigOrExit(flags)
|
|
89
66
|
log('info', `→ drift-check via ${path.relative(cwd, configPath)}...`)
|
|
90
67
|
|
|
91
68
|
const result = await runRunner('db-check-runner.mjs', configPath)
|
|
@@ -128,7 +105,7 @@ async function cmdDbMigrate(sub2, flags) {
|
|
|
128
105
|
log('dim', ' Rollback = editar o schema e re-rodar `opus db migrate`; catástrofe = snapshot pré-deploy.')
|
|
129
106
|
process.exit(1)
|
|
130
107
|
}
|
|
131
|
-
const { cwd, configPath } =
|
|
108
|
+
const { cwd, configPath } = resolveConfigOrExit(flags)
|
|
132
109
|
log('info', `→ db migrate via ${path.relative(cwd, configPath)}...`)
|
|
133
110
|
|
|
134
111
|
const result = await runRunner('db-migrate-runner.mjs', configPath)
|
|
@@ -163,7 +140,7 @@ async function cmdDbMigrate(sub2, flags) {
|
|
|
163
140
|
// =============================================================================
|
|
164
141
|
|
|
165
142
|
async function cmdDbScaffold(flags) {
|
|
166
|
-
const { cwd, configPath } =
|
|
143
|
+
const { cwd, configPath } = resolveConfigOrExit(flags)
|
|
167
144
|
log('info', `→ db scaffold via ${path.relative(cwd, configPath)}...`)
|
|
168
145
|
|
|
169
146
|
const result = await runRunner('db-scaffold-runner.mjs', configPath)
|
|
@@ -188,46 +165,8 @@ async function cmdDbScaffold(flags) {
|
|
|
188
165
|
// Runner spawn (via tsx)
|
|
189
166
|
// =============================================================================
|
|
190
167
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const hasLocalTsx = await fileExists(tsxBin)
|
|
194
|
-
const runnerPath = path.join(__dirname, runnerFile)
|
|
195
|
-
const cmd = hasLocalTsx ? tsxBin : 'tsx'
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
const { stdout } = await execFileAsync(cmd, [runnerPath, configPath, ...extraArgs], {
|
|
199
|
-
encoding: 'utf8',
|
|
200
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
201
|
-
env: { ...process.env },
|
|
202
|
-
})
|
|
203
|
-
const line = lastJsonLine(stdout)
|
|
204
|
-
if (line === null) return { ok: false, error: 'runner não emitiu JSON' }
|
|
205
|
-
return JSON.parse(line)
|
|
206
|
-
} catch (err) {
|
|
207
|
-
const detail =
|
|
208
|
-
typeof err.stderr === 'string' && err.stderr.trim().length > 0
|
|
209
|
-
? err.stderr.trim()
|
|
210
|
-
: err.message
|
|
211
|
-
return { ok: false, error: detail }
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function lastJsonLine(stdout) {
|
|
216
|
-
const lines = stdout.split('\n').filter((l) => l.trim().length > 0)
|
|
217
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
218
|
-
const trimmed = lines[i].trim()
|
|
219
|
-
if (trimmed.startsWith('{') && trimmed.endsWith('}')) return trimmed
|
|
220
|
-
}
|
|
221
|
-
return null
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function fileExists(p) {
|
|
225
|
-
try {
|
|
226
|
-
await fs.access(p)
|
|
227
|
-
return true
|
|
228
|
-
} catch {
|
|
229
|
-
return false
|
|
230
|
-
}
|
|
168
|
+
function runRunner(runnerFile, configPath, extraArgs = []) {
|
|
169
|
+
return runJsonRunner(path.join(__dirname, runnerFile), [configPath, ...extraArgs])
|
|
231
170
|
}
|
|
232
171
|
|
|
233
172
|
// =============================================================================
|
|
@@ -245,9 +184,12 @@ export function helpDb() {
|
|
|
245
184
|
re-rodável) e roda o drift-check na sequência. Exit ≠ 0 se divergir.
|
|
246
185
|
db scaffold Gera um rascunho kysely a partir do diff — REFERÊNCIA pra escrever o
|
|
247
186
|
SQL no schema (a verdade é o script; revise à mão).
|
|
187
|
+
db migrate down Aposentado: encerra com erro. O schema é um script idempotente sem
|
|
188
|
+
histórico a reverter; rollback = editar o script e rodar db migrate.
|
|
248
189
|
|
|
249
190
|
Flags:
|
|
250
191
|
--config <path> Caminho interno ao projeto para opus.config.ts. Default: ./opus.config.ts
|
|
192
|
+
--help, -h Mostra esta mensagem
|
|
251
193
|
|
|
252
194
|
O opus.config.ts precisa expor, pros comandos db:
|
|
253
195
|
database: () => Kysely factory LAZY do banco (gen não a chama)
|
|
@@ -258,6 +200,6 @@ O opus.config.ts precisa expor, pros comandos db:
|
|
|
258
200
|
Exemplos:
|
|
259
201
|
npx @softize/opus db check
|
|
260
202
|
npx @softize/opus db migrate
|
|
261
|
-
npx @softize/opus db
|
|
203
|
+
npx @softize/opus db scaffold --config ./apps/api/opus.config.ts
|
|
262
204
|
`)
|
|
263
205
|
}
|