synthesisui 0.16.422 → 0.16.423

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.
@@ -226,6 +226,19 @@ export async function add(slug, opts) {
226
226
  * componente em vez de por arquivo.
227
227
  */
228
228
  await writeFile(join(versionDir, "design-system.json"), `${JSON.stringify(payload.document)}\n`, "utf8");
229
+ /**
230
+ * E A CÓPIA DO QUE FOI INSTALADO, ao lado - é ela que faz o `sync` saber o que o agente dele
231
+ * mudou depois.
232
+ *
233
+ * O que o cliente ganha: o ciclo fecha sem uma ida à rede. Ele cola o prompt que a plataforma
234
+ * montou, o agente edita `design-system.json`, roda o `sync` - e o comando compara os dois
235
+ * arquivos aqui mesmo. Sem este baseline, a única forma de saber o que mudou seria perguntar
236
+ * ao servidor a cada `sync`, e um `sync` offline não conseguiria nem isso.
237
+ *
238
+ * O PONTO NA FRENTE não é estética: `.installed.json` é registro nosso desta pasta, como o
239
+ * `.lock`, e não um artefato que ele lê. Ver `local-edits.ts`.
240
+ */
241
+ await writeFile(join(versionDir, ".installed.json"), `${JSON.stringify(payload.document)}\n`, "utf8");
229
242
  /**
230
243
  * 3. O GUIA - e o catálogo só é COPIADO onde não há caminho de volta.
231
244
  *
@@ -471,8 +484,18 @@ export async function add(slug, opts) {
471
484
  ...Object.keys(payload.artifacts).filter((f) => !(f === "shadcn.css" && skipped)),
472
485
  "design-system.json",
473
486
  "GUIDE.md",
487
+ ".installed.json",
474
488
  ];
475
489
  console.log(` v${v}/: ${files.join(", ")}`);
490
+ /**
491
+ * O ARQUIVO NOVO É ANUNCIADO, e não plantado em silêncio - lei 8.
492
+ *
493
+ * Ele é versionado junto com o resto da pasta de propósito: assim um colega que clona tem o
494
+ * baseline e o `sync` dele funciona. Mas um arquivo de ponto que nasce sem aviso e reaparece
495
+ * no `git status` a cada sync é o atrito que a régua do `census.json` foi desenhada para
496
+ * evitar - e a diferença entre os dois é uma frase.
497
+ */
498
+ console.log(` (.installed.json records what was installed, so \`sync\` can tell what you changed)`);
476
499
  /**
477
500
  * O QUE NÃO FOI ESCRITO, DITO EM VOZ ALTA - lei 8, e ela vale nos dois sentidos.
478
501
  *
@@ -8,6 +8,7 @@ import { markSent, readEvents } from "../doctor/ledger.js";
8
8
  import { checkableName, closeRequest, readRequests, verifyAndCloseRequests, } from "../doctor/requests.js";
9
9
  import { sampledForUpload } from "../doctor/style-ledger.js";
10
10
  import { describeDelta, fingerprintReadings, readSyncMark, writeSyncMark, } from "../last-sync.js";
11
+ import { installedVersion, landedLine, localEdits, readInstalledPair, sentLine, settleBaseline, } from "../local-edits.js";
11
12
  import { measuredScope, rememberScope } from "../measured-scope.js";
12
13
  import { fromCensus } from "../memory/observation.js";
13
14
  import { reportMeasurement } from "../memory/report.js";
@@ -551,6 +552,43 @@ export async function remeasure(args) {
551
552
  return;
552
553
  }
553
554
  }
555
+ /**
556
+ * O QUE ELE MUDOU NO DESIGN SYSTEM INSTALADO, lido do disco e sem rede.
557
+ *
558
+ * A comparação é contra `.installed.json`, a cópia que o `add` deixa ao lado. Um sistema
559
+ * instalado antes desta etapa não a tem, e aí o comando DIZ o que falta em vez de tratar o
560
+ * documento inteiro como mudado - ver `readInstalledPair`.
561
+ */
562
+ let edits = [];
563
+ let localDocument = null;
564
+ let localSaid = null;
565
+ {
566
+ const version = await installedVersion(root, slug);
567
+ /**
568
+ * UM ERRO INESPERADO AQUI É DITO, e não engolido. A primeira escrita caía em
569
+ * "not-installed" para qualquer exceção - um disco cheio, uma permissão, um link quebrado -
570
+ * e a saída ficava idêntica à de um repositório sem sistema instalado.
571
+ */
572
+ const pair = version
573
+ ? await readInstalledPair(root, slug, version).catch((error) => ({
574
+ kind: "unreadable",
575
+ where: `_synthesisui/ds/${slug}/v${version}`,
576
+ why: error instanceof Error ? error.message : String(error),
577
+ }))
578
+ : { kind: "not-installed" };
579
+ if (pair.kind === "pair") {
580
+ edits = localEdits(pair.installed, pair.local);
581
+ if (edits.length > 0)
582
+ localDocument = pair.local;
583
+ localSaid = sentLine(edits, slug);
584
+ }
585
+ else if (pair.kind === "unreadable") {
586
+ localSaid = `your local ${slug} could not be read, so nothing of it went up: ${pair.why}\n ${pair.where}`;
587
+ }
588
+ else if (pair.kind === "no-baseline") {
589
+ localSaid = `we cannot tell what changed in your local ${slug}: ${pair.why}`;
590
+ }
591
+ }
554
592
  const res = await fetch(`${base}/api/registry/ds/${slug}/census`, {
555
593
  method: "POST",
556
594
  headers: {
@@ -568,6 +606,17 @@ export async function remeasure(args) {
568
606
  census: census.ledger
569
607
  ? { ...census, ledger: sampledForUpload(census.ledger) }
570
608
  : census,
609
+ /**
610
+ * E O QUE O AGENTE DELE MUDOU NOS ARQUIVOS DO DESIGN SYSTEM - a outra metade do ciclo.
611
+ *
612
+ * O prompt que a plataforma monta termina mandando rodar este comando, e até aqui ele
613
+ * media só o CÓDIGO dele: o design system instalado aparecia apenas para descobrir quais
614
+ * slugs existem. O documento só sobe quando há diferença de verdade contra o que o `add`
615
+ * instalou - sem edição, este campo não existe e nada muda do lado de lá.
616
+ */
617
+ ...(edits.length > 0 && localDocument
618
+ ? { document: localDocument, edits }
619
+ : {}),
571
620
  }),
572
621
  }).catch(() => null);
573
622
  if (!res?.ok) {
@@ -580,6 +629,20 @@ export async function remeasure(args) {
580
629
  console.log("");
581
630
  console.log(section("Sent"));
582
631
  console.log(body(`${out.written ?? 0} of ${out.total ?? 0} components written.`));
632
+ /**
633
+ * O QUE VEIO DOS ARQUIVOS DELE, DITO - e antes de qualquer outra nota, porque é a única parte
634
+ * desta saída que fala do trabalho que ELE acabou de fazer.
635
+ *
636
+ * Um "ok" mudo depois de um agente ter mexido em doze peças é pior que nenhuma saída: ele não
637
+ * tem como saber se o comando entendeu o trabalho, e roda de novo para conferir.
638
+ */
639
+ if (localSaid)
640
+ console.log(body(localSaid));
641
+ /** A única frase no passado, e ela vem DEPOIS da resposta do servidor. */
642
+ if (out.hisEdits?.applied)
643
+ console.log(body(landedLine(out.hisEdits.applied, slug)));
644
+ if (out.hisEdits?.refused)
645
+ console.log(body(paint.strong(` ✕ your local changes were refused, and nothing of them was written: ${out.hisEdits.refused}`)));
583
646
  for (const note of out.notes ?? [])
584
647
  console.log(body(paint.faint(note)));
585
648
  /**
@@ -662,6 +725,27 @@ export async function remeasure(args) {
662
725
  ...(args.cli ? { cli: args.cli } : {}),
663
726
  readings: fingerprintReadings(census.components),
664
727
  };
728
+ /**
729
+ * E O BASELINE PASSA A SER O QUE ESTÁ NO DISCO - a metade que fecha o ciclo.
730
+ *
731
+ * Sem esta linha, todo `sync` reenviaria a mesma alteração para sempre, e o documento na
732
+ * plataforma ganharia uma revisão nova a cada rodada sem nada ter mudado. Só depois de o
733
+ * servidor ter aceitado: uma recusa mantém o baseline velho, para a próxima rodada tentar de
734
+ * novo em vez de esquecer o que ele fez.
735
+ */
736
+ /**
737
+ * A CONDIÇÃO É `applied`, e não "não recusou" - a diferença apaga o trabalho dele.
738
+ *
739
+ * Um servidor que responde 200 SEM o campo `hisEdits` é todo deploy anterior a esta etapa, e
740
+ * essa janela existe de verdade: o CLI é publicado antes de a plataforma subir. Com
741
+ * `!refused`, o CLI dava o baseline por assentado, o disco esquecia o que o agente fez, e a
742
+ * plataforma nunca tinha recebido. A alteração some dos dois lados.
743
+ */
744
+ if (out.hisEdits?.applied && localDocument) {
745
+ const version = await installedVersion(root, slug);
746
+ if (version)
747
+ await settleBaseline(root, slug, version, localDocument).catch(() => { });
748
+ }
665
749
  console.log(body(paint.faint(describeDelta(mark, await readSyncMark(root)))));
666
750
  await writeSyncMark(root, mark);
667
751
  /**
@@ -235,7 +235,7 @@
235
235
  * chama `wireAgent`, então rodar o comando é o caminho de volta - e ele só alarga a string que era
236
236
  * nossa, nunca um filtro que uma pessoa escreveu.
237
237
  */
238
- export const MATERIALISER_SINCE = "0.16.422";
238
+ export const MATERIALISER_SINCE = "0.16.423";
239
239
  /**
240
240
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
241
241
  *
@@ -0,0 +1,257 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /** A forma mínima que faz de um objeto um design system - abaixo disto não há o que comparar. */
4
+ function shapeOf(value) {
5
+ if (!value || typeof value !== "object")
6
+ return "it is not an object";
7
+ const doc = value;
8
+ if (!doc.meta || typeof doc.meta !== "object")
9
+ return "it has no `meta` - a design system always carries its name and slug";
10
+ if (!doc.foundations || typeof doc.foundations !== "object")
11
+ return "it has no `foundations` - a design system always carries its tokens";
12
+ return null;
13
+ }
14
+ async function readDocument(path) {
15
+ let raw;
16
+ try {
17
+ raw = await readFile(path, "utf8");
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch (error) {
27
+ return {
28
+ ok: false,
29
+ why: error instanceof Error ? error.message : "it is not valid JSON",
30
+ };
31
+ }
32
+ const wrong = shapeOf(parsed);
33
+ if (wrong)
34
+ return { ok: false, why: wrong };
35
+ return { ok: true, document: parsed };
36
+ }
37
+ /**
38
+ * QUAL VERSÃO ESTÁ MATERIALIZADA NESTE REPOSITÓRIO - a que o `.lock` registra.
39
+ *
40
+ * Ler a pasta `v*` mais alta seria adivinhar: um `add --version N` deixa as duas no disco, e o
41
+ * `.lock` é o que diz qual delas o repositório está usando. `null` quando não há install.
42
+ */
43
+ export async function installedVersion(root, slug) {
44
+ try {
45
+ const raw = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8");
46
+ const lock = JSON.parse(raw);
47
+ return typeof lock.version === "number" ? lock.version : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** O par que a comparação precisa: o que foi instalado, e o que está no disco agora. */
54
+ export async function readInstalledPair(root, slug, version) {
55
+ const dir = join(root, "_synthesisui", "ds", slug, `v${version}`);
56
+ const local = await readDocument(join(dir, "design-system.json"));
57
+ if (local === null)
58
+ return { kind: "not-installed" };
59
+ if (!local.ok)
60
+ return {
61
+ kind: "unreadable",
62
+ where: join(dir, "design-system.json"),
63
+ why: local.why,
64
+ };
65
+ const installed = await readDocument(join(dir, ".installed.json"));
66
+ if (installed === null)
67
+ return {
68
+ kind: "no-baseline",
69
+ /**
70
+ * O CAMINHO DE SAÍDA AVISA DO RISCO ANTES DO COMANDO.
71
+ *
72
+ * `add` reescreve `design-system.json` sem comparar nada. Mandar rodá-lo sem dizer isso
73
+ * apagaria, em silêncio, exatamente a edição que este recurso existe para preservar - e a
74
+ * pessoa teria seguido o passo que a plataforma recomendou.
75
+ */
76
+ why: `this install predates the file that tracks your edits, and \`add\` rewrites design-system.json. If you already edited it, copy it first (cp _synthesisui/ds/${slug}/v<n>/design-system.json /tmp/${slug}-before-add.json), then run \`npx synthesisui@latest add ${slug}\` once - every edit made after that comes up with the next sync.`,
77
+ };
78
+ if (!installed.ok)
79
+ return {
80
+ kind: "unreadable",
81
+ where: join(dir, ".installed.json"),
82
+ why: installed.why,
83
+ };
84
+ return { kind: "pair", installed: installed.document, local: local.document };
85
+ }
86
+ /** Achata fundação e motion em `caminho → valor`, na mesma gramática do documento. */
87
+ function flatten(value, prefix, into) {
88
+ if (!value || typeof value !== "object")
89
+ return;
90
+ for (const [key, inner] of Object.entries(value)) {
91
+ const path = prefix ? `${prefix}.${key}` : key;
92
+ if (typeof inner === "string" || typeof inner === "number")
93
+ into.set(path, String(inner));
94
+ else
95
+ flatten(inner, path, into);
96
+ }
97
+ }
98
+ /**
99
+ * O QUE NÃO VIAJA, DITO EM VOZ ALTA - lei 8, e a lista é curta de propósito.
100
+ *
101
+ * `meta` é a identidade do sistema (nome, slug, tagline): mudá-la é ato dela na plataforma, não
102
+ * do agente dele. `philosophy` e `analyses` são texto que a plataforma escreve e nenhum prompt
103
+ * pede para editar. Os três ficam fora porque não são decisão do código dele - e é por isso que
104
+ * a ausência deles aqui é uma escolha, e não um esquecimento.
105
+ */
106
+ export const NOT_FROM_HIS_FILES = ["meta", "philosophy", "analyses"];
107
+ const RECIPE_MAPS = [
108
+ ["components", "component"],
109
+ ["blocks", "block"],
110
+ ["layouts", "layout"],
111
+ ["charts", "chart"],
112
+ ];
113
+ /**
114
+ * A PROCEDÊNCIA NÃO É UM TOKEN, e o mesmo defeito já custou uma sessão do outro lado.
115
+ *
116
+ * `foundations.source` guarda quem escreveu cada caminho. Sem esta linha, um carimbo mudado por
117
+ * um comando nosso apareceria como "o agente dele trocou um valor de design".
118
+ */
119
+ function tokensOf(doc) {
120
+ const out = new Map();
121
+ const { source: _source, ...rest } = doc.foundations;
122
+ flatten(rest, "", out);
123
+ /**
124
+ * E TODO EIXO DE RAIZ QUE NÃO É RECEITA, varrido pelo que o documento TEM - e não por uma
125
+ * lista escrita à mão.
126
+ *
127
+ * A primeira escrita enumerava `foundations` e `motion`, e deixava de fora `icons` e
128
+ * `globals` - este último carrega a folha de projeto inteira, 107 declarações num cliente
129
+ * real. O agente editava, o `sync` dizia "nothing new", e a alteração morria no disco.
130
+ * Varrer o que existe faz um eixo NOVO do contrato entrar sozinho, em vez de esperar alguém
131
+ * lembrar.
132
+ */
133
+ const root = doc;
134
+ const recipes = new Set(RECIPE_MAPS.map(([key]) => key));
135
+ for (const key of Object.keys(root)) {
136
+ if (key === "foundations")
137
+ continue;
138
+ if (recipes.has(key))
139
+ continue;
140
+ if (NOT_FROM_HIS_FILES.includes(key))
141
+ continue;
142
+ flatten(root[key], key, out);
143
+ }
144
+ return out;
145
+ }
146
+ function fileOf(recipe) {
147
+ return recipe?.source?.file;
148
+ }
149
+ /**
150
+ * O QUE MUDOU ENTRE O QUE FOI INSTALADO E O QUE ESTÁ NO DISCO.
151
+ *
152
+ * Puro sobre os dois documentos. Uma receita conta como UMA alteração, e não uma por declaração:
153
+ * o que o cliente lê no terminal é "o agente mexeu no Button", e a lista de declarações mora no
154
+ * documento que sobe junto.
155
+ */
156
+ export function localEdits(installed, local) {
157
+ const out = [];
158
+ const was = tokensOf(installed);
159
+ const now = tokensOf(local);
160
+ for (const path of [...new Set([...was.keys(), ...now.keys()])].sort()) {
161
+ const from = was.get(path);
162
+ const to = now.get(path);
163
+ if (from === to)
164
+ continue;
165
+ out.push({
166
+ what: path,
167
+ of: "token",
168
+ kind: from === undefined ? "added" : to === undefined ? "removed" : "changed",
169
+ ...(from !== undefined ? { from } : {}),
170
+ ...(to !== undefined ? { to } : {}),
171
+ });
172
+ }
173
+ for (const [key, of_] of RECIPE_MAPS) {
174
+ const before = installed[key];
175
+ const after = local[key];
176
+ const names = [
177
+ ...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]),
178
+ ].sort();
179
+ for (const name of names) {
180
+ const from = before?.[name];
181
+ const to = after?.[name];
182
+ if (from === undefined && to === undefined)
183
+ continue;
184
+ if (from !== undefined && to !== undefined) {
185
+ if (JSON.stringify(from) === JSON.stringify(to))
186
+ continue;
187
+ const file = fileOf(to) ?? fileOf(from);
188
+ out.push({
189
+ what: name,
190
+ of: of_,
191
+ kind: "changed",
192
+ ...(file ? { file } : {}),
193
+ });
194
+ continue;
195
+ }
196
+ const file = fileOf(to ?? from);
197
+ out.push({
198
+ what: name,
199
+ of: of_,
200
+ kind: from === undefined ? "added" : "removed",
201
+ ...(file ? { file } : {}),
202
+ });
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+ /**
208
+ * QUANTAS LINHAS O TERMINAL NOMEIA ANTES DE DIZER QUANTAS SOBRARAM.
209
+ *
210
+ * Doze é o que cabe numa tela sem rolagem depois do resto da saída do `sync`, e o que não couber
211
+ * é DITO - uma lista cortada em silêncio faz doze alterações parecerem doze quando são quarenta.
212
+ */
213
+ const NAMED = 12;
214
+ /**
215
+ * O QUE O `sync` DIZ SOBRE O QUE ELE ENCONTROU NOS ARQUIVOS - nunca um "ok" mudo, e nunca uma
216
+ * afirmação de sucesso antes de o servidor responder.
217
+ *
218
+ * A primeira escrita dizia *"N changes from your files WENT UP"*, e ela era montada antes do
219
+ * envio: quando o servidor recusava, a saída afirmava sucesso numa linha e fracasso na seguinte,
220
+ * na mesma tela. Quem lê rápido carrega a primeira. Esta frase relata o que foi ENCONTRADO; quem
221
+ * diz que chegou é `landedLine`, depois da resposta.
222
+ */
223
+ export function sentLine(edits, slug) {
224
+ if (edits.length === 0)
225
+ return `nothing new in ${slug} - the design system on this machine matches what the platform has`;
226
+ const head = `${edits.length} change${edits.length === 1 ? "" : "s"} found in your files, from ${slug}:`;
227
+ const lines = edits.slice(0, NAMED).map((edit) => {
228
+ const where = edit.file ? ` · ${edit.file}` : "";
229
+ const move = edit.from !== undefined && edit.to !== undefined
230
+ ? ` ${edit.from} → ${edit.to}`
231
+ : edit.kind === "added"
232
+ ? " (new)"
233
+ : edit.kind === "removed"
234
+ ? " (gone)"
235
+ : "";
236
+ return ` ${edit.what}${move}${where}`;
237
+ });
238
+ const rest = edits.length > NAMED ? [` and ${edits.length - NAMED} more`] : [];
239
+ return [head, ...lines, ...rest].join("\n");
240
+ }
241
+ /**
242
+ * DEPOIS DE SUBIR, O BASELINE PASSA A SER O QUE ESTÁ NO DISCO - a metade que fecha o ciclo.
243
+ *
244
+ * Sem isto, todo `sync` reenviaria a mesma alteração para sempre e o documento na plataforma
245
+ * ganharia uma revisão nova a cada rodada, sem nada ter mudado. É a outra metade de A4, e ela
246
+ * mora aqui - e não no corpo do comando - para poder ser exercida por spec sem subir nada.
247
+ *
248
+ * SÓ DEPOIS DE O SERVIDOR TER ACEITADO: uma recusa mantém o baseline velho, para a próxima
249
+ * rodada tentar de novo em vez de esquecer o que ele fez.
250
+ */
251
+ /** E a confirmação, depois de o servidor ter aceitado - a única frase no passado. */
252
+ export function landedLine(applied, slug) {
253
+ return `${applied} change${applied === 1 ? "" : "s"} from your files went up to ${slug}`;
254
+ }
255
+ export async function settleBaseline(root, slug, version, document) {
256
+ await writeFile(join(root, "_synthesisui", "ds", slug, `v${version}`, ".installed.json"), `${JSON.stringify(document)}\n`, "utf8");
257
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.422",
3
+ "version": "0.16.423",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {