synthesisui 0.16.481 → 0.16.483

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.
@@ -204,3 +204,55 @@ export function agentPrompt(plan) {
204
204
  "Change a value only where it is used as the line says. Leave every other colour as it is, and do not add or invent a name or a colour - these six were chosen by the person.",
205
205
  ].join("\n");
206
206
  }
207
+ /**
208
+ * O UPGRADE TRAZ AS SEIS CORES NOVAS - etapa 01 da jornada "cada um sabe se o seu código está em dia", 29/09.
209
+ *
210
+ * Só o `colours` escrevia o bloco, então quem recebia a versão nova de um colega pelo `upgrade` ficava com os valores
211
+ * velhos no CSS global (medido no teste-e09: `--color-primary: #0070f3` com o sistema em `#2e5078`). Aqui o bloco que
212
+ * é NOSSO - entre as marcas - tem os valores trocados pelos da versão nova; nada fora dele é tocado.
213
+ *
214
+ * O papel de cada linha: pela grafia (`--<prefixo>-<palavra>`) e, quando o nome é o DELE (`--accent`), pela cor de
215
+ * antes - é a cor que a versão instalada dava àquele papel.
216
+ */
217
+ export function refreshColourBlock(css, slug, before, after) {
218
+ const hit = blockMarks.END_RE(slug).exec(css);
219
+ if (!hit)
220
+ return { css, changes: [] };
221
+ const low = (x) => (x ?? "").trim().toLowerCase();
222
+ const changes = [];
223
+ const taken = new Set();
224
+ const block = hit[0].replace(/^(\s*)(--[a-z0-9-]+)(\s*:\s*)([^;]+);/gim, (line, pad, name, colon, value) => {
225
+ const byWord = SIX_ROLES.find((r) => !taken.has(r) &&
226
+ new RegExp(`^--[a-z0-9]+-${WORD[r].word}$`, "i").test(name));
227
+ const role = byWord ??
228
+ SIX_ROLES.find((r) => !taken.has(r) && before[r] && low(before[r]) === low(value));
229
+ if (!role)
230
+ return line;
231
+ taken.add(role);
232
+ const to = after[role];
233
+ if (!to || low(to) === low(value))
234
+ return line;
235
+ changes.push({ role, name, from: value.trim(), to });
236
+ return `${pad}${name}${colon}${to};`;
237
+ });
238
+ if (changes.length === 0)
239
+ return { css, changes };
240
+ return {
241
+ css: css.slice(0, hit.index) + block + css.slice(hit.index + hit[0].length),
242
+ changes,
243
+ };
244
+ }
245
+ /** O mesmo, no disco: acha o CSS global dele e reescreve só se algum valor mudou. */
246
+ export async function refreshColourBlockOnDisk(root, slug, before, after) {
247
+ const v = await readVocabulary(root, slug);
248
+ if (!v.global)
249
+ return { file: null, changes: [] };
250
+ const path = join(root, v.global);
251
+ const css = await readFile(path, "utf8").catch(() => null);
252
+ if (css == null)
253
+ return { file: null, changes: [] };
254
+ const r = refreshColourBlock(css, slug, before, after);
255
+ if (r.changes.length > 0)
256
+ await writeFile(path, r.css, "utf8");
257
+ return { file: v.global, changes: r.changes };
258
+ }
@@ -1,6 +1,5 @@
1
- import { neverMeasured } from "../never-measured.js";
2
1
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
4
3
  import { agentsToMaintain } from "../agents-chosen.js";
5
4
  import { syncClaudeMd } from "../claude-md.js";
6
5
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
@@ -9,18 +8,19 @@ import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.
9
8
  import { detectAppDirs } from "../global-sheet.js";
10
9
  import { lockReference } from "../group-role.js";
11
10
  import { buildGuide } from "../guide.js";
11
+ import { nextInstalledAt, readInstalledAt, writeInstalledAt, } from "../installed-at.js";
12
12
  import { censusScope } from "../measured-scope.js";
13
13
  import { recallAvailable } from "../memory/availability.js";
14
+ import { neverMeasured } from "../never-measured.js";
14
15
  import { nextFontFacts } from "../next-font-weights.js";
15
16
  import { body as line, section, snippet } from "../output.js";
16
17
  import { fetchDesignSystem } from "../registry.js";
17
18
  import { repoStateOf } from "../repo-state.js";
18
19
  import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.js";
19
- import { detectStack } from "../stack.js";
20
+ import { detectStack, resolveDeps } from "../stack.js";
20
21
  import { onlyWhatMatched } from "../their-theme.js";
21
22
  import { tongueOf } from "../their-tongue.js";
22
23
  import { pointTokensAtTheirNames } from "../their-vars.js";
23
- import { resolveDeps } from "../stack.js";
24
24
  import { tracksAnyOf } from "../tracked.js";
25
25
  /**
26
26
  * QUAL METADE DESTA PASTA UM TIME COMMITA.
@@ -75,6 +75,11 @@ const IGNORED = [
75
75
  * turno de quem instalasse faria um arquivo nosso aparecer no `git status` dele.
76
76
  */
77
77
  ".turn-clock",
78
+ /**
79
+ * A HORA EM QUE A VERSÃO ATERROU NESTE CLONE - ver `installed-at.ts` (29/09). Dentro do `.lock` ela fazia dois upgrades
80
+ * da mesma versão, em branches separadas, darem conflito numa linha só.
81
+ */
82
+ ".fetched-at.json",
78
83
  ];
79
84
  const IGNORE_HEADER = "# Managed by synthesisui. The identity and the CSS are committed so a fresh\n" +
80
85
  "# clone is governed; the measurement and the local record are not, because\n" +
@@ -196,7 +201,10 @@ async function familiesHeNames(root, families) {
196
201
  const kept = Object.entries(families).filter(([, v]) => {
197
202
  if (typeof v !== "string")
198
203
  return true;
199
- const first = v.split(",")[0]?.trim().replace(/^["']|["']$/g, "") ?? "";
204
+ const first = v
205
+ .split(",")[0]
206
+ ?.trim()
207
+ .replace(/^["']|["']$/g, "") ?? "";
200
208
  return !first || /^var\(/.test(first) || css.includes(first.toLowerCase());
201
209
  });
202
210
  return Object.fromEntries(kept);
@@ -392,7 +400,9 @@ export async function add(slug, opts) {
392
400
  * E O SISTEMA QUE NUNCA FOI MEDIDO NUM CÓDIGO já nasce assim - ver `neverMeasured` (rodada 5, 27/09):
393
401
  * o fork do leigo-5 terminava o `connect` mandando rodar `sync` para uma pasta que não existe.
394
402
  */
395
- ...(prev?.reading === "none" || neverMeasured(payload.document) ? { reading: "none" } : {}),
403
+ ...(prev?.reading === "none" || neverMeasured(payload.document)
404
+ ? { reading: "none" }
405
+ : {}),
396
406
  /** Ver `RegistryPayload.compiler`: é o que faz um conserto de CSS chegar a um install. */
397
407
  ...(payload.compiler != null ? { compiler: payload.compiler } : {}),
398
408
  ...(payload.rulesStamp ? { rules: payload.rulesStamp } : {}),
@@ -428,17 +438,21 @@ export async function add(slug, opts) {
428
438
  /** A memória do que NÓS escrevemos atravessa a re-materialização - ver `RootLock.written`. */
429
439
  ...(prev?.written ? { written: prev.written } : {}),
430
440
  };
431
- const landed = (() => {
432
- if (!prev?.fetchedAt)
433
- return true;
434
- const { fetchedAt: _was, ...before } = prev;
435
- return JSON.stringify(before) !== JSON.stringify(identity);
436
- })();
437
- const lock = {
438
- ...identity,
439
- fetchedAt: landed || !prev?.fetchedAt ? new Date().toISOString() : prev.fetchedAt,
440
- };
441
+ /**
442
+ * O `.lock` SEM A HORA - ver `installed-at.ts` (29/09): dois clones na mesma versão escrevem o mesmo arquivo, e a hora
443
+ * de cada um vai para `.fetched-at.json`, que o git ignora.
444
+ */
445
+ const slugDirOfLock = dirname(rootLockPath);
446
+ const installedAt = nextInstalledAt({
447
+ prevLock: prev ??
448
+ null,
449
+ prevAt: await readInstalledAt(slugDirOfLock, null),
450
+ identity: identity,
451
+ now: new Date().toISOString(),
452
+ });
453
+ const lock = { ...identity };
441
454
  await writeFile(rootLockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
455
+ await writeInstalledAt(slugDirOfLock, installedAt);
442
456
  /**
443
457
  * O MAPA SOBE AGORA, e é o único momento em que ele é novo.
444
458
  *
@@ -637,7 +651,9 @@ export async function add(slug, opts) {
637
651
  * `@theme` - vinte linhas depois de "100% of your 59 style decisions carry your own vocabulary".
638
652
  */
639
653
  const wearsTailwind = await projectWearsTailwind(projectRoot);
640
- if (wearsTailwind && !opts.themeFollows && (aligned.dropped.length > 0 || aligned.own.length > 0)) {
654
+ if (wearsTailwind &&
655
+ !opts.themeFollows &&
656
+ (aligned.dropped.length > 0 || aligned.own.length > 0)) {
641
657
  console.log(line(
642
658
  /**
643
659
  * A LINHA DIZ QUE FICARAM DE FORA - e nao AFIRMA por que, porque ela nao sabe.
@@ -779,7 +795,11 @@ export async function add(slug, opts) {
779
795
  */
780
796
  const stackDeps = await resolveDeps(projectRoot).catch(() => ({}));
781
797
  const onNext = projectConfig.target === "next" && "next" in stackDeps;
782
- const headHome = onNext ? "app/layout.tsx" : "vite" in stackDeps ? "index.html" : "your root HTML";
798
+ const headHome = onNext
799
+ ? "app/layout.tsx"
800
+ : "vite" in stackDeps
801
+ ? "index.html"
802
+ : "your root HTML";
783
803
  const fontPlan = onNext
784
804
  ? nextFontSnippet({
785
805
  families,
@@ -9,6 +9,7 @@ import { unsentEvents } from "../doctor/ledger.js";
9
9
  import { readRequests } from "../doctor/requests.js";
10
10
  import { declaredReference } from "../group-role.js";
11
11
  import { CHECKER_SINCE, installedBehind, MATERIALISER_SINCE, READER_SINCE, } from "../install-marks.js";
12
+ import { readInstalledAt } from "../installed-at.js";
12
13
  import { fmt, say } from "../lang.js";
13
14
  import { measuredScope } from "../measured-scope.js";
14
15
  import { body, bodyWrapped, paint, section, snippet } from "../output.js";
@@ -34,7 +35,8 @@ async function wholeRepoMeasured(root) {
34
35
  return false;
35
36
  try {
36
37
  const c = JSON.parse(raw);
37
- const narrowed = (typeof c.scope === "string" && c.scope.trim() !== "") || (Array.isArray(c.scopes) && c.scopes.length > 0);
38
+ const narrowed = (typeof c.scope === "string" && c.scope.trim() !== "") ||
39
+ (Array.isArray(c.scopes) && c.scopes.length > 0);
38
40
  return c.measured != null && typeof c.measured === "object" && !narrowed;
39
41
  }
40
42
  catch {
@@ -66,7 +68,10 @@ async function locksIn(root) {
66
68
  if (!raw)
67
69
  continue;
68
70
  try {
69
- out.push(JSON.parse(raw));
71
+ const lock = JSON.parse(raw);
72
+ /** A hora mora ao lado desde 29/09 - ver `installed-at.ts`; o `.lock` antigo ainda responde. */
73
+ const at = await readInstalledAt(join(dsDir, entry.name), lock);
74
+ out.push(at ? { ...lock, fetchedAt: at } : lock);
70
75
  }
71
76
  catch {
72
77
  // lock ilegível - o `status` já reporta isso com o nome do arquivo
@@ -228,7 +233,10 @@ opts = {}) {
228
233
  * sistema de 36).
229
234
  */
230
235
  const scope = await measuredScope(root, lock.slug);
231
- if (!scope.system && scope.usage.length === 0 && lock.reading !== "none" && !(await wholeRepoMeasured(root)))
236
+ if (!scope.system &&
237
+ scope.usage.length === 0 &&
238
+ lock.reading !== "none" &&
239
+ !(await wholeRepoMeasured(root)))
232
240
  out.push({
233
241
  says: `"${lock.slug}" does not record where it was measured, so a re-measure would read this whole repo instead of the folder the system came from. Sync asks once and remembers the answer.`,
234
242
  /**
@@ -521,6 +529,7 @@ export async function versionBehind(root, opts = {}) {
521
529
  return repaired;
522
530
  if (body.version > lock.version)
523
531
  return {
532
+ kind: "version",
524
533
  says: `v${body.version} of "${lock.slug}" is published and this repo is on v${lock.version}. The CSS here and the rules your agent reads are both v${lock.version} - they move together, which is why this is worth saying rather than applying.`,
525
534
  run: `npx synthesisui upgrade ${lock.slug}`,
526
535
  };
@@ -707,7 +716,7 @@ opts = {}) {
707
716
  ...(opts.cli ? { cli: opts.cli } : {}),
708
717
  ...(opts.home ? { home: opts.home } : {}),
709
718
  }).catch(() => null);
710
- if (remote)
719
+ if (remote && !(opts.skipVersion && remote.kind === "version"))
711
720
  items.push(remote);
712
721
  return items;
713
722
  }
@@ -27,7 +27,11 @@ export async function colours(asked, opts) {
27
27
  ? await readFile(join(slugDir, `v${lock.version}`, "tokens.css"), "utf8").catch(() => "")
28
28
  : "";
29
29
  const roleHex = roleColours(tokens);
30
- const plan = await planColourNames(root, slug, roleHex, before);
30
+ /** O que ele marcou na tela em "no seu código hoje" vale acima do palpite pela versão de antes (28/09). */
31
+ const docJson = typeof lock.version === "number"
32
+ ? await readFile(join(slugDir, `v${lock.version}`, "design-system.json"), "utf8").catch(() => "{}")
33
+ : "{}";
34
+ const plan = await planColourNames(root, slug, roleHex, markedBefore(before, docJson));
31
35
  console.log("");
32
36
  console.log(section("Your six colours, back in your code"));
33
37
  if (!plan.file) {
@@ -78,8 +82,23 @@ export function roleColours(tokensCss) {
78
82
  }
79
83
  return out;
80
84
  }
85
+ /**
86
+ * A COR DE HOJE DE CADA PAPEL, com o que ELE marcou por cima - `foundations.color.replaces`, escrito pela tela de
87
+ * escolher cores (controle completo, 28/09). `""` é resposta dele: o papel é novo e não troca cor nenhuma.
88
+ */
89
+ export function markedBefore(before, documentJson) {
90
+ let marked = {};
91
+ try {
92
+ marked =
93
+ JSON.parse(documentJson).foundations?.color?.replaces ?? {};
94
+ }
95
+ catch {
96
+ marked = {};
97
+ }
98
+ return { ...before, ...marked };
99
+ }
81
100
  /** As seis cores da versão que está instalada agora - `{}` quando ainda não há nenhuma. */
82
- async function installedRoles(slugDir) {
101
+ export async function installedRoles(slugDir) {
83
102
  const lock = JSON.parse(await readFile(join(slugDir, ".lock"), "utf8").catch(() => "{}"));
84
103
  if (typeof lock.version !== "number")
85
104
  return {};
@@ -13,11 +13,16 @@ import { measuredScope, rememberScope } from "../measured-scope.js";
13
13
  import { fromCensus } from "../memory/observation.js";
14
14
  import { reportMeasurement } from "../memory/report.js";
15
15
  import { mergeCensus } from "../merge-census.js";
16
- import { describeGrowth, recordGrowthForTurn, whatGrew } from "../what-grew.js";
16
+ import { readVocabulary } from "../new-project-theme.js";
17
+ import { gitFacts, offerNewVersion } from "../new-version.js";
17
18
  import { body, paint, section, snippet } from "../output.js";
19
+ import { fetchDesignSystem } from "../registry.js";
18
20
  import { repoStateOf } from "../repo-state.js";
21
+ import { describeGrowth, recordGrowthForTurn, whatGrew } from "../what-grew.js";
19
22
  import { reportWhatIsLeft } from "./align.js";
23
+ import { installedRoles, roleColours } from "./colours.js";
20
24
  import { resolveReadParts, siblingProjects, takeCensus, } from "./import.js";
25
+ import { upgrade } from "./upgrade.js";
21
26
  /**
22
27
  * What each decision means ON THIS MACHINE - the card decides, the sync
23
28
  * enacts. The web never touches the local file; this line is printed right
@@ -321,9 +326,50 @@ export async function sync(opts) {
321
326
  * dizendo o que sobrou resolve o que a dúvida realmente era: acabou ou não? Mudo quando não sobrou
322
327
  * nada, que é o caso normal.
323
328
  */
324
- await reportWhatIsLeft(root);
329
+ /**
330
+ * UMA VERSÃO NOVA, DITA POR EXTENSO - etapa 02 de "cada um sabe se o seu código está em dia" (29/09): o que mudou,
331
+ * e o caminho sem conflito. Quando ela foi dita aqui, a lista do fim não a repete.
332
+ */
333
+ const offered = await offerNewVersionHere(root, slug, base).catch(() => "current");
334
+ await reportWhatIsLeft(root, offered === "current" ? {} : { skipVersion: true });
325
335
  sayRefused(refused);
326
336
  }
337
+ async function offerNewVersionHere(root, slug, base) {
338
+ const slugDir = join(root, "_synthesisui", "ds", slug);
339
+ const lock = JSON.parse(await readFile(join(slugDir, ".lock"), "utf8").catch(() => "{}"));
340
+ if (typeof lock.version !== "number")
341
+ return "current";
342
+ const { global } = await readVocabulary(root, slug);
343
+ return offerNewVersion(root, { slug, version: lock.version }, { globalCss: global }, {
344
+ latest: async () => {
345
+ const p = await fetchDesignSystem(base, slug);
346
+ return {
347
+ version: p.version,
348
+ roles: roleColours(p.artifacts["tokens.css"] ?? ""),
349
+ };
350
+ },
351
+ installedRoles: () => installedRoles(slugDir),
352
+ git: () => gitFacts(root, slug, global),
353
+ ask: askYesNo,
354
+ upgrade: (s) => upgrade(s, { registry: base, dir: root }),
355
+ tty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
356
+ });
357
+ }
358
+ async function askYesNo(question) {
359
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
360
+ return false;
361
+ const { createInterface } = await import("node:readline/promises");
362
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
363
+ try {
364
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
365
+ }
366
+ catch {
367
+ return false;
368
+ }
369
+ finally {
370
+ rl.close();
371
+ }
372
+ }
327
373
  /**
328
374
  * O QUE NÃO FOI ENVIADO, DITO POR ÚLTIMO - e o "por último" é o desenho, não um detalhe.
329
375
  *
@@ -1,14 +1,15 @@
1
1
  import { readdir, readFile, writeFile } from "node:fs/promises";
2
- import { speakInHisProject } from "../in-his-form.js";
3
2
  import { join } from "node:path";
4
3
  import { pinnedHookVersion, wireAgent } from "../agent-wiring.js";
5
4
  import { agentsToMaintain } from "../agents-chosen.js";
6
5
  import { isOlderCli } from "../cli-version.js";
6
+ import { refreshColourBlockOnDisk } from "../colour-names.js";
7
7
  import { generateComponentFiles } from "../component-codegen.js";
8
8
  import { readProjectConfig, readToken, resolveRegistry } from "../config.js";
9
9
  import { unsentEvents } from "../doctor/ledger.js";
10
10
  import { diffLocalDocuments, localChangelogMarkdown, } from "../document-diff.js";
11
11
  import { detectAppDirs } from "../global-sheet.js";
12
+ import { speakInHisProject } from "../in-his-form.js";
12
13
  import { installedBehind, MATERIALISER_SINCE } from "../install-marks.js";
13
14
  import { body, section, snippet } from "../output.js";
14
15
  import { reactMajorOf, readInstalledConvention, readInstalledScheme, theirThemeVars, themeVocabOf, } from "../project-facts.js";
@@ -19,6 +20,7 @@ import { readCensus, unreadComment, unreadForComponent, } from "../unread-for-co
19
20
  import { editedSinceWritten, keptLine, readWritten, recordWritten, } from "../written.js";
20
21
  import { add } from "./add.js";
21
22
  import { reportWhatIsLeft } from "./align.js";
23
+ import { installedRoles } from "./colours.js";
22
24
  import { doctor } from "./doctor.js";
23
25
  /**
24
26
  * The highest `v<n>` below `installed` among the folder names given, or the one
@@ -384,6 +386,8 @@ export async function upgrade(asked, opts) {
384
386
  console.log(`✓ ${slug} v${installed} is newer than the registry's v${latest.version} - nothing to do.`);
385
387
  return;
386
388
  }
389
+ /** As seis cores de ANTES - o bloco do CSS global dele guarda estas (etapa 01, 29/09). */
390
+ const coloursBefore = await installedRoles(slugDir);
387
391
  // 1. re-materialize the artifacts (v<latest>/ + root re-exports + .lock);
388
392
  // setup hints suppressed - an upgrade means the app is already wired.
389
393
  await add(slug, {
@@ -392,6 +396,11 @@ export async function upgrade(asked, opts) {
392
396
  setupHints: false,
393
397
  ...(opts.cli ? { cli: opts.cli } : {}),
394
398
  });
399
+ /**
400
+ * AS SEIS CORES NOVAS NO CSS GLOBAL DELE - etapa 01 de "cada um sabe se o seu código está em dia", 29/09. Quem
401
+ * recebia a versão de um colega ficava com os valores velhos no bloco que o `colours` escreveu.
402
+ */
403
+ const colourBlock = await refreshColourBlockOnDisk(root, slug, coloursBefore, await installedRoles(slugDir));
395
404
  // 2. regenerate YOUR materialized components (the ones `component` wrote)
396
405
  const config = await readProjectConfig(root);
397
406
  const regenerated = [];
@@ -541,6 +550,12 @@ export async function upgrade(asked, opts) {
541
550
  else {
542
551
  console.log(body("No breaking changes detected."));
543
552
  }
553
+ if (colourBlock.changes.length > 0) {
554
+ console.log("");
555
+ console.log(body(`✓ ${colourBlock.file}: ${colourBlock.changes.length === 1 ? "1 of your six colours" : `${colourBlock.changes.length} of your six colours`} now ${colourBlock.changes.length === 1 ? "has" : "have"} the new value:`));
556
+ for (const c of colourBlock.changes)
557
+ console.log(body(` ${c.name}: ${c.from} → ${c.to} (${c.role})`));
558
+ }
544
559
  if (regenerated.length > 0) {
545
560
  console.log("");
546
561
  console.log(body(`Regenerated ${regenerated.length} materialized component(s): ${regenerated.join(", ")}`));
@@ -579,5 +594,5 @@ export async function upgrade(asked, opts) {
579
594
  * `button-gen`: "Could not regenerate (...) (removed/renamed in v2?)". Sem cabeçalho, a pasta é a melhor pista.
580
595
  */
581
596
  export function recipeNameOf(head, entry) {
582
- return /Generated by SynthesisUI - "([^"]+)" from the/.exec(head)?.[1] ?? entry;
597
+ return (/Generated by SynthesisUI - "([^"]+)" from the/.exec(head)?.[1] ?? entry);
583
598
  }
@@ -310,7 +310,11 @@
310
310
  * `[color:#ffffff]`, `font-body` em vez de `[font-family:Inter,_sans-serif]`), e a largura em px no degrau vira `w-10`.
311
311
  * Um componente escrito antes reprova na própria checagem.
312
312
  */
313
- export const MATERIALISER_SINCE = "0.16.476";
313
+ /**
314
+ * 0.16.483 (29/09): o `.lock` sai sem a hora (`fetchedAt` vai para `.fetched-at.json`, ignorado pelo git) - dois
315
+ * upgrades da mesma versão, em branches separadas, davam conflito numa linha só dele. Ver `installed-at.ts`.
316
+ */
317
+ export const MATERIALISER_SINCE = "0.16.483";
314
318
  /**
315
319
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
316
320
  *
@@ -0,0 +1,41 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * A HORA EM QUE A VERSÃO ATERROU NESTE CLONE, FORA DO `.lock` - pedido dele em 29/09 ("tira, neste PR").
5
+ *
6
+ * MEDIDO: dois clones trouxeram a mesma versão em branches separadas e o merge parou numa linha só do `.lock` -
7
+ * `"fetchedAt"`, a hora de cada download. O `globals.css`, os tokens e a pasta da versão juntaram limpo. O `.lock` é
8
+ * commitado e o resto dele é função da versão, do compilador e das regras; a hora era a única coisa que descrevia UMA
9
+ * máquina, e é ela que fazia dois upgrades iguais brigarem.
10
+ *
11
+ * Então ela mora num arquivo ao lado, na lista do `.gitignore` que o CLI gerencia - como o `.last-sync.json`. Quem lê
12
+ * (`repairSaid` no align, `installedAt` no repo-state) lê daqui, e do `.lock` antigo quando este arquivo ainda não
13
+ * existe - um repo instalado antes continua respondendo.
14
+ */
15
+ export const INSTALLED_AT_FILE = ".fetched-at.json";
16
+ /** A hora nova: anda quando algo aterrou, e fica onde estava quando nada mudou - a mesma regra do `.lock` de antes. */
17
+ export function nextInstalledAt(v) {
18
+ const was = v.prevAt ?? v.prevLock?.fetchedAt ?? null;
19
+ if (!v.prevLock || !was)
20
+ return v.now;
21
+ const { fetchedAt: _was, ...before } = v.prevLock;
22
+ const landed = JSON.stringify(before) !== JSON.stringify(v.identity);
23
+ return landed ? v.now : was;
24
+ }
25
+ export async function readInstalledAt(slugDir, lock) {
26
+ const raw = await readFile(join(slugDir, INSTALLED_AT_FILE), "utf8").catch(() => null);
27
+ if (raw) {
28
+ try {
29
+ const at = JSON.parse(raw).fetchedAt;
30
+ if (typeof at === "string")
31
+ return at;
32
+ }
33
+ catch {
34
+ // ilegível: cai no `.lock` antigo
35
+ }
36
+ }
37
+ return typeof lock?.fetchedAt === "string" ? lock.fetchedAt : null;
38
+ }
39
+ export async function writeInstalledAt(slugDir, at) {
40
+ await writeFile(join(slugDir, INSTALLED_AT_FILE), `${JSON.stringify({ fetchedAt: at }, null, 2)}\n`, "utf8");
41
+ }
@@ -0,0 +1,201 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { body, section, snippet } from "./output.js";
5
+ export function pathFor(f) {
6
+ if (f.remoteVersion != null && f.remoteVersion >= f.latest)
7
+ return "pull";
8
+ if (f.dirty.length > 0)
9
+ return "commit-first";
10
+ return f.tty ? "ask" : "say";
11
+ }
12
+ /** As seis cores que mudaram entre a versão instalada e a publicada, na ordem dos papéis. */
13
+ export function colourChanges(before, after) {
14
+ const low = (x) => (x ?? "").trim().toLowerCase();
15
+ return Object.keys(after)
16
+ .filter((r) => before[r] && after[r] && low(before[r]) !== low(after[r]))
17
+ .map((role) => ({
18
+ role,
19
+ from: before[role],
20
+ to: after[role],
21
+ }));
22
+ }
23
+ /**
24
+ * O QUADRADO DA COR (29/09, pedido dele olhando o terminal: *"deveria ter um quadrado com as cores ao lado do hex"*). Um
25
+ * bloco com o fundo na cor exata (24 bits); sem terminal, nada - o agente lê texto, e um código de escape é ruído.
26
+ */
27
+ export function colourSwatch(hex, tty) {
28
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
29
+ if (!tty || !m)
30
+ return "";
31
+ const [r, g, b] = [m[1], m[2], m[3]].map((x) => Number.parseInt(x, 16));
32
+ return `\u001b[48;2;${r};${g};${b}m \u001b[0m`;
33
+ }
34
+ /** O aviso, antes de qualquer escrita: as duas versões e o que mudou nas seis cores. */
35
+ export function newVersionLines(v) {
36
+ const sw = (hex) => {
37
+ const s = v.swatch?.(hex) ?? "";
38
+ return s ? `${s} ` : "";
39
+ };
40
+ const out = [
41
+ `v${v.latest} of "${v.slug}" is published and this repo is on v${v.installed}.`,
42
+ ];
43
+ if (v.changes.length > 0) {
44
+ out.push("What changed in your six colours:");
45
+ for (const c of v.changes)
46
+ out.push(` ${c.role.padEnd(11)}${sw(c.from)}${c.from} → ${sw(c.to)}${c.to}`);
47
+ }
48
+ else {
49
+ out.push("None of your six colours changed - the rest (rules, components) is listed in UPGRADE.md once it is here.");
50
+ }
51
+ return out;
52
+ }
53
+ function run(cmd, args, cwd, timeout = 8000) {
54
+ return new Promise((resolve) => {
55
+ execFile(cmd, args, { cwd, timeout, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, (err, stdout) => resolve(err ? null : stdout.toString()));
56
+ });
57
+ }
58
+ /**
59
+ * O `.lock` só difere do commit pelo `reading` que o próprio sync anota (o 404 da leitura, em `markNoReading`)?
60
+ * Então a mudança é nossa, e o upgrade a reescreve igual - não é trabalho dele a proteger. Achado no PASS de 29/09.
61
+ */
62
+ async function onlyOurReading(root, lockPath) {
63
+ const committed = await run("git", ["show", `HEAD:./${lockPath}`], root);
64
+ const now = await readFile(join(root, lockPath), "utf8").catch(() => null);
65
+ if (committed == null || now == null)
66
+ return false;
67
+ try {
68
+ const { reading: _a, ...was } = JSON.parse(committed);
69
+ const { reading: _b, ...is } = JSON.parse(now);
70
+ return JSON.stringify(was) === JSON.stringify(is);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ export async function gitFacts(root, slug, globalCss) {
77
+ const none = {
78
+ repo: false,
79
+ origin: false,
80
+ dirty: [],
81
+ remoteVersion: null,
82
+ remoteRef: null,
83
+ gh: false,
84
+ };
85
+ const inside = await run("git", ["rev-parse", "--is-inside-work-tree"], root);
86
+ if (inside?.trim() !== "true")
87
+ return none;
88
+ const paths = [`_synthesisui/ds/${slug}`, ...(globalCss ? [globalCss] : [])];
89
+ const status = (await run("git", ["status", "--porcelain", "--", ...paths], root)) ?? "";
90
+ const lockPath = `_synthesisui/ds/${slug}/.lock`;
91
+ const dirty = [];
92
+ for (const f of status
93
+ .split("\n")
94
+ .map((l) => l.slice(3).trim())
95
+ .filter(Boolean)) {
96
+ if (f === lockPath && (await onlyOurReading(root, lockPath)))
97
+ continue;
98
+ dirty.push(f);
99
+ }
100
+ let remoteVersion = null;
101
+ let remoteRef = null;
102
+ const origin = await run("git", ["remote", "get-url", "origin"], root);
103
+ if (origin) {
104
+ /** O fetch só lê; sem rede ele falha calado e o resto segue sem a informação. */
105
+ await run("git", ["fetch", "--quiet", "origin"], root, 10000);
106
+ const head = (await run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], root))?.trim();
107
+ const candidates = [head, "origin/main", "origin/master"].filter(Boolean);
108
+ const prefix = ((await run("git", ["rev-parse", "--show-prefix"], root)) ?? "").trim();
109
+ for (const ref of candidates) {
110
+ const lock = await run("git", ["show", `${ref}:${prefix}_synthesisui/ds/${slug}/.lock`], root);
111
+ if (lock == null)
112
+ continue;
113
+ remoteRef = ref;
114
+ try {
115
+ const v = JSON.parse(lock).version;
116
+ remoteVersion = typeof v === "number" ? v : null;
117
+ }
118
+ catch {
119
+ remoteVersion = null;
120
+ }
121
+ break;
122
+ }
123
+ }
124
+ const gh = (await run("gh", ["auth", "status"], root, 5000)) != null;
125
+ return {
126
+ repo: true,
127
+ origin: origin != null,
128
+ dirty,
129
+ remoteVersion,
130
+ remoteRef,
131
+ gh,
132
+ };
133
+ }
134
+ /**
135
+ * O aviso inteiro, com o caminho escolhido. Devolve o que aconteceu - o `sync` usa para não repetir, na lista do que
136
+ * falta, a linha de versão que acabou de ser dita aqui.
137
+ */
138
+ export async function offerNewVersion(_root, lock, where, deps, print = (l) => console.log(l)) {
139
+ const latest = await deps.latest().catch(() => null);
140
+ if (!latest || latest.version <= lock.version)
141
+ return "current";
142
+ const changes = colourChanges(await deps.installedRoles(), latest.roles);
143
+ const facts = await deps.git();
144
+ const path = pathFor({
145
+ tty: deps.tty,
146
+ dirty: facts.dirty,
147
+ remoteVersion: facts.remoteVersion,
148
+ latest: latest.version,
149
+ });
150
+ const slug = lock.slug;
151
+ print("");
152
+ print(section(`A new version of ${slug}`));
153
+ for (const l of newVersionLines({
154
+ slug,
155
+ installed: lock.version,
156
+ latest: latest.version,
157
+ changes,
158
+ swatch: (hex) => colourSwatch(hex, deps.tty),
159
+ }))
160
+ print(body(l));
161
+ print("");
162
+ if (path === "pull") {
163
+ const branch = (facts.remoteRef ?? "origin/main").replace(/^origin\//, "");
164
+ print(body(`Someone on your team already brought v${latest.version} into ${facts.remoteRef}. Bring it from git instead of upgrading here - doing the same change twice, in two commits, is what makes a conflict:`));
165
+ print(snippet([`git pull origin ${branch}`]));
166
+ return path;
167
+ }
168
+ if (path === "commit-first") {
169
+ print(body("You have changes not committed where the upgrade writes. Commit or stash them first, so the upgrade lands in a diff of its own:"));
170
+ for (const f of facts.dirty)
171
+ print(body(` ${f}`));
172
+ print("");
173
+ print(body("then:"));
174
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
175
+ return path;
176
+ }
177
+ if (path === "say") {
178
+ print(body("To bring it here:"));
179
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
180
+ return path;
181
+ }
182
+ const yes = await deps.ask(` Bring v${latest.version} into this repo now? It rewrites _synthesisui/ds/${slug}${where.globalCss ? ` and the six colours in ${where.globalCss}` : ""}. [y/N] `);
183
+ if (!yes) {
184
+ print(body("Nothing was changed. When you want it:"));
185
+ print(snippet([`npx synthesisui upgrade ${slug}`]));
186
+ return "declined";
187
+ }
188
+ await deps.upgrade(slug);
189
+ print("");
190
+ print(section("Give it to your team"));
191
+ print(body("It is in your files now, not in git. Commit it on a branch of its own, so whoever pulls gets it without redoing it:"));
192
+ print(snippet([
193
+ `git switch -c synthesisui-${slug}-v${latest.version}`,
194
+ "git status # only what the upgrade wrote should be listed",
195
+ "git add -A",
196
+ `git commit -m "synthesisui: ${slug} v${latest.version}"`,
197
+ ...(facts.origin ? ["git push -u origin HEAD"] : []),
198
+ ...(facts.origin && facts.gh ? ["gh pr create --fill"] : []),
199
+ ]));
200
+ return "upgraded";
201
+ }
@@ -26,6 +26,7 @@
26
26
  import { readFile } from "node:fs/promises";
27
27
  import { join } from "node:path";
28
28
  import { readEvents, } from "./doctor/ledger.js";
29
+ import { readInstalledAt } from "./installed-at.js";
29
30
  /**
30
31
  * O estado de um slug neste projeto, ou nada quando não há `.lock`.
31
32
  *
@@ -34,6 +35,8 @@ import { readEvents, } from "./doctor/ledger.js";
34
35
  */
35
36
  export async function repoStateOf(projectRoot, slug, cli) {
36
37
  const lock = await readJson(join(projectRoot, "_synthesisui", "ds", slug, ".lock"));
38
+ /** A hora mora ao lado do `.lock` desde 29/09 - ver `installed-at.ts`. */
39
+ const installedAt = await readInstalledAt(join(projectRoot, "_synthesisui", "ds", slug), lock);
37
40
  /**
38
41
  * O ÚLTIMO DOCTOR, PELO MESMO LEITOR QUE O `sync` USA (`readEvents`). Abrir o arquivo por conta
39
42
  * própria aqui seria uma segunda implementação do formato do registro - e a primeira coisa que
@@ -93,9 +96,7 @@ export async function repoStateOf(projectRoot, slug, cli) {
93
96
  ? { unrepresentedMatches: doctor.unrepresentedMatches }
94
97
  : {}),
95
98
  ...(runs.length > 0 ? { runs } : {}),
96
- ...(typeof lock?.fetchedAt === "string"
97
- ? { installedAt: lock.fetchedAt }
98
- : {}),
99
+ ...(installedAt ? { installedAt } : {}),
99
100
  };
100
101
  /** Só o `cli` não é estado: sem nada do repo, não há o que reportar. */
101
102
  return Object.keys(payload).length > 1 ? payload : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.481",
3
+ "version": "0.16.483",
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": {