@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +63 -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/components/patterns/split.tsx +66 -24
  36. package/src/ui/docs/content/cli.md +8 -7
  37. package/src/ui/docs/content/communication.md +79 -126
  38. package/src/ui/docs/content/getting-started.md +29 -16
  39. package/src/ui/docs/content/split.md +43 -3
  40. package/src/ui/react.tsx +1 -1
  41. package/registry/skills/write-product-communication/SKILL.md +0 -28
  42. package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
  43. package/registry/templates/app/_npmrc +0 -1
  44. package/registry/templates/monorepo/_npmrc +0 -1
package/bin/lib/gen.mjs CHANGED
@@ -27,6 +27,15 @@ import path from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
  import { execFile } from 'node:child_process'
29
29
  import { promisify } from 'node:util'
30
+ import {
31
+ canonicalProjectDirectory,
32
+ ensureProjectDirectory,
33
+ readProjectDirectory,
34
+ readProjectFile,
35
+ removeProjectFileIfUnchanged,
36
+ safeProjectPath,
37
+ writeProjectFileAtomically,
38
+ } from '@softize/base/project-path'
30
39
 
31
40
  import { buildManifest } from './gen-manifest.mjs'
32
41
  import { buildOpenAPI } from './gen-openapi.mjs'
@@ -59,13 +68,12 @@ function log(level, msg) {
59
68
  // =============================================================================
60
69
 
61
70
  export async function cmdGen(flags) {
62
- const cwd = process.cwd()
71
+ const cwd = canonicalProjectDirectory(process.cwd())
63
72
  const configRel = flags.config ?? 'opus.config.ts'
64
- const configPath = path.isAbsolute(configRel)
65
- ? configRel
66
- : path.resolve(cwd, configRel)
73
+ const config = safeProjectPath(cwd, configRel)
74
+ const configPath = config.path
67
75
 
68
- if (!(await fileExists(configPath))) {
76
+ if (!config.exists) {
69
77
  log('error', `opus.config.ts não encontrado em ${configPath}`)
70
78
  log(
71
79
  'dim',
@@ -81,9 +89,9 @@ export async function cmdGen(flags) {
81
89
 
82
90
  // Output: flag > config > default.
83
91
  const outputDir = flags.output ?? payload.output ?? '.gen'
84
- const outputAbs = path.isAbsolute(outputDir)
85
- ? outputDir
86
- : path.resolve(cwd, outputDir)
92
+ const output = safeProjectPath(cwd, outputDir)
93
+ const outputRel = path.relative(cwd, output.path) || '.'
94
+ const outputAbs = output.path
87
95
 
88
96
  const manifest = buildManifest(payload)
89
97
  const openapi = buildOpenAPI(manifest)
@@ -91,31 +99,32 @@ export async function cmdGen(flags) {
91
99
  const stubs = buildClientStubs(manifest)
92
100
  const dictStubs = buildDictStubs(manifest)
93
101
 
94
- await fs.mkdir(outputAbs, { recursive: true })
95
- await fs.mkdir(path.join(outputAbs, 'docs'), { recursive: true })
96
- await fs.mkdir(path.join(outputAbs, 'client-stubs'), { recursive: true })
102
+ ensureProjectTree(cwd, outputRel)
103
+ const outputRoot = canonicalProjectDirectory(outputAbs)
104
+ ensureProjectDirectory(outputRoot, 'docs')
105
+ ensureProjectDirectory(outputRoot, 'client-stubs')
97
106
  if (dictStubs.length > 0) {
98
- await fs.mkdir(path.join(outputAbs, 'client-stubs', 'dicts'), {
99
- recursive: true,
100
- })
107
+ ensureProjectDirectory(outputRoot, 'client-stubs/dicts')
101
108
  }
102
109
 
103
- await writeJson(path.join(outputAbs, 'manifest.json'), manifest, flags.force)
104
- await writeJson(path.join(outputAbs, 'openapi.json'), openapi, flags.force)
110
+ await writeJson(outputRoot, 'manifest.json', manifest, flags.force)
111
+ await writeJson(outputRoot, 'openapi.json', openapi, flags.force)
105
112
 
106
113
  for (const doc of docs) {
107
- await writeText(path.join(outputAbs, 'docs', doc.filename), doc.content, flags.force)
114
+ await writeText(outputRoot, path.join('docs', doc.filename), doc.content, flags.force)
108
115
  }
109
116
  for (const stub of stubs) {
110
117
  await writeText(
111
- path.join(outputAbs, 'client-stubs', stub.filename),
118
+ outputRoot,
119
+ path.join('client-stubs', stub.filename),
112
120
  stub.content,
113
121
  flags.force,
114
122
  )
115
123
  }
116
124
  for (const dict of dictStubs) {
117
125
  await writeText(
118
- path.join(outputAbs, 'client-stubs', 'dicts', dict.filename),
126
+ outputRoot,
127
+ path.join('client-stubs', 'dicts', dict.filename),
119
128
  dict.content,
120
129
  flags.force,
121
130
  )
@@ -251,8 +260,16 @@ async function fileExists(p) {
251
260
  }
252
261
  }
253
262
 
254
- async function writeJson(p, value, _force) {
255
- await fs.writeFile(p, JSON.stringify(value, null, 2) + '\n', 'utf8')
263
+ function ensureProjectTree(root, requested) {
264
+ let cursor = '.'
265
+ for (const segment of requested.split(path.sep).filter((item) => item !== '' && item !== '.')) {
266
+ cursor = path.join(cursor, segment)
267
+ ensureProjectDirectory(root, cursor)
268
+ }
269
+ }
270
+
271
+ async function writeJson(root, requested, value, _force) {
272
+ await writeText(root, requested, JSON.stringify(value, null, 2) + '\n', _force)
256
273
  }
257
274
 
258
275
  /**
@@ -262,25 +279,39 @@ async function writeJson(p, value, _force) {
262
279
  * que o consumer tenha posto lá fica intacta. Exportada pra teste.
263
280
  */
264
281
  export async function pruneOrphans(dir, keep, exts) {
265
- let entries
282
+ let parent
266
283
  try {
267
- entries = await fs.readdir(dir, { withFileTypes: true })
284
+ parent = canonicalProjectDirectory(path.dirname(path.resolve(dir)))
268
285
  } catch {
269
286
  return [] // dir nem existe → nada a podar
270
287
  }
288
+ const requested = path.relative(parent, path.resolve(dir))
289
+ const inspected = safeProjectPath(parent, requested)
290
+ if (!inspected.exists) return []
291
+ const root = canonicalProjectDirectory(inspected.path)
292
+ const entries = readProjectDirectory(root, '.').entries
271
293
  const removed = []
272
294
  for (const e of entries) {
273
- if (!e.isFile()) continue
274
295
  if (!exts.some((x) => e.name.endsWith(x))) continue
296
+ if (e.isDirectory()) continue
297
+ // A identidade é verificada antes de `keep`: um artefato esperado também não pode
298
+ // ser link simbólico, hardlink ou outro tipo especial dentro do diretório gerenciado.
299
+ const file = readProjectFile(root, e.name)
275
300
  if (keep.has(e.name)) continue
276
- await fs.unlink(path.join(dir, e.name))
301
+ removeProjectFileIfUnchanged(root, e.name, file)
277
302
  removed.push(e.name)
278
303
  }
279
304
  return removed.sort()
280
305
  }
281
306
 
282
- async function writeText(p, content, _force) {
283
- await fs.writeFile(p, content, 'utf8')
307
+ async function writeText(root, requested, content, _force) {
308
+ const current = readProjectFile(root, requested, { allowMissing: true })
309
+ writeProjectFileAtomically(
310
+ root,
311
+ requested,
312
+ content,
313
+ { exists: current.exists, content: current.content },
314
+ )
284
315
  }
285
316
 
286
317
  // =============================================================================
@@ -299,8 +330,8 @@ Lê opus.config.ts do consumer e gera:
299
330
  - <output>/client-stubs/dicts/<domain>.ts Dicts (t.dict({...})) prontos pro frontend
300
331
 
301
332
  Flags:
302
- --config <path> Caminho do opus.config.ts. Default: ./opus.config.ts
303
- --output <path> Pasta de saída. Default: do config (ou ./.gen)
333
+ --config <path> Caminho interno ao projeto para opus.config.ts. Default: ./opus.config.ts
334
+ --output <path> Pasta interna ao projeto. Default: do config (ou ./.gen)
304
335
  --force, -f Sobrescreve existente (atualmente sempre sobrescreve)
305
336
 
306
337
  Exemplos:
package/bin/lib/init.mjs CHANGED
@@ -4,40 +4,78 @@ import { promises as fs } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
 
7
- import { materializeOpus, PACKAGE_VERSION } from "./materialize.mjs";
7
+ import {
8
+ canonicalProjectDirectory,
9
+ projectPathErrorMessage,
10
+ readProjectDirectory,
11
+ readProjectFile,
12
+ removeProjectFileIfUnchanged,
13
+ safeProjectPath,
14
+ writeProjectFileAtomically,
15
+ } from "@softize/base/project-path";
16
+ import {
17
+ discoverProjectRoot,
18
+ validGitDirectory,
19
+ } from "@softize/base/project-root";
20
+
21
+ import { materializeOpus, PACKAGE_VERSION, validateOpusSetup } from "./materialize.mjs";
22
+ import { writeCopyInventory } from "./copy.mjs";
8
23
 
9
24
  const MARKER = "opus.json";
10
25
 
11
- async function exists(p) {
26
+ async function readJson(p) {
12
27
  try {
13
- await fs.access(p);
14
- return true;
28
+ return JSON.parse(await fs.readFile(p, "utf-8"));
15
29
  } catch {
16
- return false;
30
+ return null;
17
31
  }
18
32
  }
19
33
 
20
- async function readJson(p) {
34
+ function localFileExists(projectRoot, requested) {
35
+ return safeProjectPath(projectRoot, requested).exists;
36
+ }
37
+
38
+ function readLocalText(projectRoot, requested) {
39
+ return readProjectFile(projectRoot, requested).content;
40
+ }
41
+
42
+ function readLocalJson(projectRoot, requested) {
21
43
  try {
22
- return JSON.parse(await fs.readFile(p, "utf-8"));
23
- } catch {
24
- return null;
44
+ const file = readProjectFile(projectRoot, requested, { allowMissing: true });
45
+ return file.exists ? JSON.parse(file.content) : null;
46
+ } catch (error) {
47
+ if (error instanceof SyntaxError) return null;
48
+ throw error;
25
49
  }
26
50
  }
27
51
 
28
52
  /** Raiz do repo (sobe até achar `.git`); sem repo → null (o chamador decide o fallback). */
29
53
  export async function repoRootOf(dir) {
30
- let cur = path.resolve(dir);
54
+ const origin = path.resolve(dir);
31
55
  const temporaryRoot = path.resolve(tmpdir());
32
- for (;;) {
33
- // O diretório temporário global pode receber marcadores transitórios de ferramentas
34
- // e sandboxes. Um projeto dentro dele ainda pode ter sua própria raiz Git, mas não deve
35
- // herdar `/tmp/.git` como se todos os fixtures pertencessem ao mesmo repositório.
36
- if (cur === temporaryRoot) return null;
37
- if (await exists(path.join(cur, ".git"))) return cur;
38
- const up = path.dirname(cur);
39
- if (up === cur) return null;
40
- cur = up;
56
+ const discovered = discoverProjectRoot(origin);
57
+ // O diretório temporário global pode receber marcadores transitórios de ferramentas e
58
+ // sandboxes. Um projeto dentro dele não deve herdar `/tmp/.git` como raiz compartilhada.
59
+ if (discovered === temporaryRoot && origin !== temporaryRoot) return null;
60
+
61
+ let marker;
62
+ try {
63
+ marker = safeProjectPath(discovered, ".git");
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (!marker.exists) return null;
68
+ if (marker.kind === "directory") return validGitDirectory(marker.path) ? discovered : null;
69
+ if (marker.kind !== "file") return null;
70
+
71
+ try {
72
+ const gitFile = readProjectFile(discovered, ".git");
73
+ const match = /^gitdir: ([^\0\r\n]+)\r?\n?$/u.exec(gitFile.content);
74
+ if (match === null || match[1].trim() !== match[1]) return null;
75
+ const gitDirectory = canonicalProjectDirectory(path.resolve(discovered, match[1]));
76
+ return validGitDirectory(gitDirectory) ? discovered : null;
77
+ } catch {
78
+ return null;
41
79
  }
42
80
  }
43
81
 
@@ -148,26 +186,22 @@ const CSS_IGNORADAS = new Set([
148
186
  */
149
187
  async function collectCss(projectDir, dir = "src", profundidade = 0) {
150
188
  if (profundidade > 3) return [];
151
- let entradas;
152
- try {
153
- entradas = await fs.readdir(path.join(projectDir, dir), {
154
- withFileTypes: true,
155
- });
156
- } catch {
157
- return []; // sem `src/` → nada a varrer.
158
- }
189
+ const directory = readProjectDirectory(projectDir, dir, { allowMissing: true });
190
+ if (!directory.exists) return []; // sem `src/` → nada a varrer.
191
+ const entradas = directory.entries;
159
192
  const aqui = [];
160
193
  const abaixo = [];
161
194
  for (const e of entradas.sort((a, b) => a.name.localeCompare(b.name))) {
162
195
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
163
196
  if (e.isDirectory() && CSS_IGNORADAS.has(e.name)) continue;
164
197
  const rel = `${dir}/${e.name}`;
198
+ if (e.isSymbolicLink()) safeProjectPath(projectDir, rel, { mustExist: true });
165
199
  if (e.isDirectory()) {
166
200
  abaixo.push(...(await collectCss(projectDir, rel, profundidade + 1)));
167
201
  } else if (e.name.endsWith(".css")) {
168
202
  aqui.push({
169
203
  file: rel,
170
- text: await fs.readFile(path.join(projectDir, rel), "utf-8"),
204
+ text: readLocalText(projectDir, rel),
171
205
  });
172
206
  }
173
207
  }
@@ -195,10 +229,11 @@ function pickCss(achados) {
195
229
  * @returns {Promise<{applicable:boolean, created:string[], warnings:string[]}>}
196
230
  */
197
231
  export async function setupUiFoundation(projectDir) {
198
- const hasIndexHtml = await exists(path.join(projectDir, "index.html"));
232
+ projectDir = canonicalProjectDirectory(projectDir);
233
+ const hasIndexHtml = localFileExists(projectDir, "index.html");
199
234
  let twConfig = null;
200
235
  for (const c of TAILWIND_CONFIGS) {
201
- if (await exists(path.join(projectDir, c))) {
236
+ if (localFileExists(projectDir, c)) {
202
237
  twConfig = c;
203
238
  break;
204
239
  }
@@ -209,7 +244,7 @@ export async function setupUiFoundation(projectDir) {
209
244
 
210
245
  const created = [];
211
246
  const warnings = [];
212
- const pkg = await readJson(path.join(projectDir, "package.json"));
247
+ const pkg = readLocalJson(projectDir, "package.json");
213
248
  const major = await tailwindMajor(projectDir, pkg);
214
249
  const v4 = major !== null && major >= 4;
215
250
  const todosCss = v4 ? await collectCss(projectDir) : [];
@@ -242,13 +277,14 @@ export async function setupUiFoundation(projectDir) {
242
277
  );
243
278
  }
244
279
  } else if (!twConfig) {
245
- await fs.writeFile(
246
- path.join(projectDir, "tailwind.config.js"),
247
- tailwindConfigTemplate(),
248
- );
280
+ const destination = readProjectFile(projectDir, "tailwind.config.js", { allowMissing: true });
281
+ writeProjectFileAtomically(projectDir, "tailwind.config.js", tailwindConfigTemplate(), {
282
+ exists: destination.exists,
283
+ content: destination.content,
284
+ });
249
285
  created.push("tailwind.config.js");
250
286
  } else {
251
- const txt = await fs.readFile(path.join(projectDir, twConfig), "utf-8");
287
+ const txt = readLocalText(projectDir, twConfig);
252
288
  if (!txt.includes("@softize/opus/ui/preset")) {
253
289
  warnings.push(
254
290
  `${twConfig}: estenda o preset — \`import preset from '@softize/opus/ui/preset'\` + \`presets: [preset]\` + glob \`./node_modules/@softize/opus/src/ui/**/*.{ts,tsx}\` no content.`,
@@ -267,14 +303,14 @@ export async function setupUiFoundation(projectDir) {
267
303
  } else {
268
304
  let entry = null;
269
305
  for (const e of ENTRY_FILES) {
270
- if (await exists(path.join(projectDir, e))) {
306
+ if (localFileExists(projectDir, e)) {
271
307
  entry = e;
272
308
  break;
273
309
  }
274
310
  }
275
311
  // App v4 sem CSS de entrada já foi avisado no passo 1 — não repete.
276
312
  if (entry !== null && !v4) {
277
- const txt = await fs.readFile(path.join(projectDir, entry), "utf-8");
313
+ const txt = readLocalText(projectDir, entry);
278
314
  if (!txt.includes("@softize/opus/ui/theme.css")) {
279
315
  warnings.push(
280
316
  `${entry}: importe o tema — \`import '@softize/opus/ui/theme.css'\` (antes do seu CSS).`,
@@ -288,9 +324,8 @@ export async function setupUiFoundation(projectDir) {
288
324
  }
289
325
 
290
326
  // 3. allowImportingTsExtensions (o Opus exporta source .ts/.tsx; o consumidor compila).
291
- const tsconfigPath = path.join(projectDir, "tsconfig.json");
292
- if (await exists(tsconfigPath)) {
293
- const txt = await fs.readFile(tsconfigPath, "utf-8");
327
+ if (localFileExists(projectDir, "tsconfig.json")) {
328
+ const txt = readLocalText(projectDir, "tsconfig.json");
294
329
  if (!txt.includes("allowImportingTsExtensions")) {
295
330
  warnings.push(
296
331
  'tsconfig.json: adicione `"allowImportingTsExtensions": true`.',
@@ -316,34 +351,155 @@ export async function initProject(registryDir, projectDir) {
316
351
  void registryDir;
317
352
  const created = [];
318
353
  const synced = [];
354
+ const warnings = [];
319
355
  const version = PACKAGE_VERSION;
356
+ const repoRoot = (await repoRootOf(projectDir)) ?? path.resolve(projectDir);
357
+ const projectLocal = path.relative(repoRoot, path.resolve(projectDir)) || ".";
358
+
359
+ // Fase 1: fotografa todas as entradas específicas do init antes da primeira mutação.
360
+ // O materializador tem seu próprio preflight; aqui cobrimos marcador, legado e migração.
361
+ let appPkg = null;
362
+ const preflightErrors = [];
363
+ try {
364
+ const packageFile = readProjectFile(repoRoot, path.join(projectLocal, "package.json"), { allowMissing: true });
365
+ if (packageFile.exists) {
366
+ try {
367
+ appPkg = JSON.parse(packageFile.content);
368
+ } catch {
369
+ preflightErrors.push("package.json: JSON inválido; preservado.");
370
+ }
371
+ }
372
+ } catch (error) {
373
+ preflightErrors.push(`package.json: ${projectPathErrorMessage(error)}`);
374
+ }
375
+ preflightErrors.push(...validateOpusSetup(repoRoot, { invocationDirectory: projectDir }));
376
+
377
+ const markerDestination = path.join(projectLocal, MARKER);
378
+ let markerFile = null;
379
+ let prev = null;
380
+ try {
381
+ markerFile = readProjectFile(repoRoot, markerDestination, { allowMissing: true });
382
+ if (markerFile.exists) {
383
+ try {
384
+ prev = JSON.parse(markerFile.content);
385
+ } catch {
386
+ prev = null;
387
+ }
388
+ }
389
+ } catch (error) {
390
+ preflightErrors.push(`${MARKER}: ${projectPathErrorMessage(error)}`);
391
+ }
392
+
393
+ const claudeDestination = path.join(projectLocal, "CLAUDE.md");
394
+ let claudeFile = null;
395
+ try {
396
+ claudeFile = readProjectFile(repoRoot, claudeDestination, { allowMissing: true });
397
+ } catch (error) {
398
+ preflightErrors.push(`CLAUDE.md: ${projectPathErrorMessage(error)}`);
399
+ }
400
+
401
+ const npmrcFiles = [];
402
+ for (const directory of new Set([path.resolve(projectDir), path.resolve(repoRoot)])) {
403
+ const destination = path.join(path.relative(repoRoot, directory) || ".", ".npmrc");
404
+ const label = path.relative(repoRoot, path.join(directory, ".npmrc")) || ".npmrc";
405
+ try {
406
+ npmrcFiles.push({ destination, label, file: readProjectFile(repoRoot, destination, { allowMissing: true }) });
407
+ } catch (error) {
408
+ preflightErrors.push(`${label}: ${projectPathErrorMessage(error)}`);
409
+ }
410
+ }
320
411
 
321
- const markerPath = path.join(projectDir, MARKER);
322
- const prev = await readJson(markerPath);
412
+ if (preflightErrors.length > 0) {
413
+ return {
414
+ version,
415
+ created,
416
+ synced,
417
+ warnings,
418
+ wasInitialized: prev !== null,
419
+ materialization: { ok: false, errors: preflightErrors, changes: [], expected: [] },
420
+ };
421
+ }
422
+
423
+ // Fase 2: aplica somente snapshots já validados. Conflitos concorrentes interrompem a
424
+ // sequência e nunca liberam a materialização sobre uma migração parcial conhecida.
425
+ const setupErrors = [];
323
426
  const marker = `${JSON.stringify({ package: "@softize/opus", version }, null, 2)}\n`;
324
- if (!(await exists(markerPath)) || (await fs.readFile(markerPath, "utf8")) !== marker) {
325
- await fs.writeFile(markerPath, marker);
326
- (prev === null ? created : synced).push(MARKER);
427
+ if (!markerFile.exists || markerFile.content !== marker) {
428
+ try {
429
+ writeProjectFileAtomically(
430
+ repoRoot,
431
+ markerDestination,
432
+ markerFile.exists ? marker.replace(/\n/gu, markerFile.content.includes("\r\n") ? "\r\n" : "\n") : marker,
433
+ { exists: markerFile.exists, content: markerFile.content },
434
+ );
435
+ (prev === null ? created : synced).push(MARKER);
436
+ } catch (error) {
437
+ setupErrors.push(`${MARKER}: ${projectPathErrorMessage(error)}`);
438
+ }
327
439
  }
328
440
 
329
441
  // Remove somente o bloco legado que o próprio setup antigo declarava como gerenciado.
330
- const claudePath = path.join(projectDir, "CLAUDE.md");
331
- if (await exists(claudePath)) {
332
- const cur = await fs.readFile(claudePath, "utf-8");
442
+ if (setupErrors.length === 0 && claudeFile.exists) {
443
+ const cur = claudeFile.content;
333
444
  const next = cur.replace(/<!-- opus:base -->[\s\S]*?<!-- \/opus:base -->\s*/g, "");
334
445
  if (next !== cur) {
335
- await fs.writeFile(claudePath, next);
336
- synced.push("CLAUDE.md (bloco legado removido)");
446
+ try {
447
+ writeProjectFileAtomically(repoRoot, claudeDestination, next, { exists: true, content: cur });
448
+ synced.push("CLAUDE.md (bloco legado removido)");
449
+ } catch (error) {
450
+ setupErrors.push(`CLAUDE.md: ${projectPathErrorMessage(error)}`);
451
+ }
452
+ }
453
+ }
454
+
455
+ // A registry privada foi descontinuada. Remove apenas a diretiva exata conhecida, em
456
+ // escopo de projeto/repo; configuração global da máquina é diagnosticada, nunca mutada.
457
+ for (const { destination, label, file: npmrc } of npmrcFiles) {
458
+ if (setupErrors.length > 0 || !npmrc.exists) continue;
459
+ const current = npmrc.content;
460
+ const lines = current.split(/\r?\n/u);
461
+ const kept = lines.filter((line) => !/^\s*@softize:registry\s*=\s*https?:\/\/registry\.softize\.com\.br\/?\s*$/iu.test(line));
462
+ if (kept.length === lines.length) continue;
463
+ const newline = current.includes("\r\n") ? "\r\n" : "\n";
464
+ const next = kept.join(newline).replace(/^(?:\r?\n)+|(?:\r?\n)+$/gu, "");
465
+ try {
466
+ if (next === "") removeProjectFileIfUnchanged(repoRoot, destination, npmrc);
467
+ else writeProjectFileAtomically(repoRoot, destination, `${next}${newline}`, { exists: true, content: current });
468
+ synced.push(`${label} (registry legada removida)`);
469
+ warnings.push("se a registry legada também estiver global, rode `pnpm config delete @softize:registry --global`.");
470
+ } catch (error) {
471
+ setupErrors.push(`${label}: ${projectPathErrorMessage(error)}`);
337
472
  }
338
473
  }
339
474
 
340
- const repoRoot = (await repoRootOf(projectDir)) ?? projectDir;
341
- const materialization = materializeOpus(repoRoot, "setup");
475
+ if (setupErrors.length > 0) {
476
+ return {
477
+ version,
478
+ created,
479
+ synced,
480
+ warnings,
481
+ wasInitialized: prev !== null,
482
+ materialization: { ok: false, errors: setupErrors, changes: [], expected: [] },
483
+ };
484
+ }
485
+
486
+ const materialization = materializeOpus(repoRoot, "setup", { invocationDirectory: projectDir });
342
487
  synced.push(...materialization.changes);
488
+ if (materialization.ok) {
489
+ const copy = writeCopyInventory(repoRoot);
490
+ if (!copy.ok) {
491
+ materialization.ok = false;
492
+ materialization.errors.push(
493
+ ...copy.diagnostics.map(
494
+ (item) => `copy ${item.source}:${item.line}: ${item.message}`,
495
+ ),
496
+ );
497
+ } else if (copy.changed) {
498
+ synced.push(path.relative(repoRoot, copy.path));
499
+ }
500
+ }
343
501
 
344
- // Avisos de dia zero (sem clobber igual à fundação de UI): o que falta plugar à mão.
345
- const warnings = [];
346
- const appPkg = await readJson(path.join(projectDir, "package.json"));
502
+ // Avisos do setup específico do Opus (sem sobrescrever, como na fundação de UI).
347
503
  if (appPkg !== null && typeof appPkg.scripts?.test !== "string") {
348
504
  warnings.push(
349
505
  "package.json: sem script `test` — o gate de entrega não confere a suíte sem ele.",
@@ -9,9 +9,9 @@
9
9
  * opus introspect [dir] [--json]
10
10
  */
11
11
 
12
- import { promises as fs } from 'node:fs'
13
12
  import path from 'node:path'
14
13
  import ts from 'typescript'
14
+ import { canonicalProjectDirectory, readProjectFile } from '@softize/base/project-path'
15
15
  import { walkTsFiles } from './check.mjs'
16
16
 
17
17
  function prop(obj, key, sf) {
@@ -92,10 +92,11 @@ export function deriveWiring(model) {
92
92
 
93
93
  /** Escaneia um diretório → modelo agregado + wiring. */
94
94
  export async function introspect(rootDir) {
95
+ rootDir = canonicalProjectDirectory(rootDir)
95
96
  const files = await walkTsFiles(rootDir)
96
97
  const model = { actions: [], reactions: [], schedules: [] }
97
98
  for (const file of files) {
98
- const text = await fs.readFile(file, 'utf-8')
99
+ const text = readProjectFile(rootDir, path.relative(rootDir, file)).content
99
100
  if (!/define(Action|Reaction|Schedule)/.test(text)) continue
100
101
  const s = parseStructure(file, text)
101
102
  const rel = path.relative(rootDir, file)