synthesisui 0.16.202 → 0.16.204

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/dist/claude-md.js CHANGED
@@ -350,9 +350,10 @@ Only write something new when nothing in the manifest covers the purpose - and w
350
350
  say which entry you considered and why it did not fit, then FILE it while the reasoning is
351
351
  still yours: the \`request_component\` MCP tool, or \`npx synthesisui@latest request component
352
352
  --name "<name>" --for "<the use case>" --considered "<entries and why not>"\`. That queue is
353
- what the system's author works from - a refusal said only in chat evaporates. To review a
354
- component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
355
- apply it to real production pages unless asked.${selfCheck}`;
353
+ what the system's author works from - a refusal said only in chat evaporates. If you want to eyeball a
354
+ component on its own, an isolated scratch page is optional and never required - and if you make
355
+ one, put it somewhere that does not become a route in this app, and delete it when you are done.
356
+ Do not apply a component to real production pages unless asked.${selfCheck}`;
356
357
  const locale = await readInterfaceLanguage(projectRoot);
357
358
  const language = locale === null
358
359
  ? ""
@@ -148,11 +148,7 @@ opts = {}) {
148
148
  * o único lugar que fala sem ser perguntado. Apontava `connect` até 07/08, quando o dono nomeou a
149
149
  * incoerência - a palavra que significa atualizar era a única que não atualizava.
150
150
  */
151
- if (cli && lock.cli && lock.cli !== cli)
152
- out.push({
153
- says: `the files under _synthesisui/ds/${lock.slug} were written by CLI ${lock.cli} and you are running ${cli} - the design system's version has not changed, but what this CLI writes into that folder has.`,
154
- run: "npx synthesisui upgrade",
155
- });
151
+ const folderBehind = cli && lock.cli && lock.cli !== cli ? lock.cli : null;
156
152
  /**
157
153
  * O HOOK, MEDIDO NELE MESMO - e não pelo `.lock`, que é um proxy que se move sozinho.
158
154
  *
@@ -165,11 +161,39 @@ opts = {}) {
165
161
  * nunca está atrás por si.
166
162
  */
167
163
  const pinned = await pinnedHookVersion(root).catch(() => null);
168
- if (cli && pinned && pinned !== cli)
164
+ const hookBehind = cli && pinned && pinned !== cli ? pinned : null;
165
+ /**
166
+ * UMA CAUSA, UMA LINHA - e as duas metades nomeadas dentro dela.
167
+ *
168
+ * A pasta e o hook são dois lugares diferentes e por isso são duas verificações: o `.lock` já
169
+ * disse 0.16.180 com o `PostToolUse` pinado em 0.16.178, e um alarme desligado por um conserto
170
+ * parcial é pior que alarme nenhum. Mas quando os dois estão na MESMA versão anterior, a causa é
171
+ * uma só - alguém subiu o CLI - e o remédio é um só.
172
+ *
173
+ * O que isso custava: duas frases de ~185 caracteres repetindo "e você está rodando X", com
174
+ * `npx synthesisui upgrade` impresso duas vezes numa mensagem de cinco linhas. O dono leu e
175
+ * chamou de grande demais (11/08), e ele estava certo pelo motivo errado: não era prolixidade,
176
+ * era a mesma coisa dita duas vezes.
177
+ *
178
+ * Divergiram, voltam a ser dois fatos - que é o caso em que a distinção existe para servir.
179
+ */
180
+ if (folderBehind && hookBehind && folderBehind === hookBehind)
169
181
  out.push({
170
- says: `the check that runs after every write is pinned to CLI ${pinned} and you are running ${cli} - your agent's edits are being checked by an older reader than the one measuring this repo.`,
182
+ says: `you are running CLI ${cli} and two things here are still on ${folderBehind}: the files under _synthesisui/ds/${lock.slug}, and the check that runs after every write - so your agent's edits are read by the older one.`,
171
183
  run: "npx synthesisui upgrade",
172
184
  });
185
+ else {
186
+ if (folderBehind)
187
+ out.push({
188
+ says: `the files under _synthesisui/ds/${lock.slug} were written by CLI ${folderBehind} and you are running ${cli} - the design system's version has not changed, but what this CLI writes into that folder has.`,
189
+ run: "npx synthesisui upgrade",
190
+ });
191
+ if (hookBehind)
192
+ out.push({
193
+ says: `the check that runs after every write is pinned to CLI ${hookBehind} and you are running ${cli} - your agent's edits are being checked by an older reader than the one measuring this repo.`,
194
+ run: "npx synthesisui upgrade",
195
+ });
196
+ }
173
197
  if (cli && lock.version == null)
174
198
  out.push({
175
199
  says: `"${lock.slug}" has no version recorded, so nothing can tell whether the CSS in this repo is the one the platform publishes.`,
@@ -328,10 +352,51 @@ from = "session") {
328
352
  : from === "shell"
329
353
  ? "This repo is out of alignment with the design system that governs it:"
330
354
  : "Before this session starts, this environment is out of alignment with the design system that governs it:",
331
- /** O `then` sai numa linha própria e rotulada: um segundo comando solto lê como parte do primeiro. */
332
- ...items.map((m) => ` - ${m.says}${m.run ? `\n ${m.run}` : ""}${m.then ? `\n then, to pick up where you left off:\n ${m.then}` : ""}`),
355
+ ...grouped(items),
333
356
  ].join("\n");
334
357
  }
358
+ /**
359
+ * UM COMANDO APARECE UMA VEZ, com todos os fatos que ele resolve acima dele.
360
+ *
361
+ * O dono leu esta mensagem em 11/08 e a chamou de grande demais. Ela era - mas por repetição, não
362
+ * por prolixidade: as duas linhas diziam a MESMA causa (o CLI andou) e propunham o MESMO comando,
363
+ * e `npx synthesisui upgrade` saía duas vezes numa mensagem de cinco linhas.
364
+ *
365
+ * Contado no código: 5 dos achados propõem `upgrade`, 3 propõem `sync`, 1 propõe `login`. No pior
366
+ * caso alguém via o mesmo comando cinco vezes.
367
+ *
368
+ * Os FATOS continuam separados, um por linha, porque são coisas diferentes que estão erradas - e
369
+ * é isso que a pessoa precisa saber. O que some é a repetição do remédio. E eles não se fundem
370
+ * quando divergem: o `.lock` já disse 0.16.180 com o hook pinado em 0.16.178, e são dois fatos.
371
+ *
372
+ * A ordem é a da primeira aparição: o primeiro achado decide onde o grupo dele fica.
373
+ */
374
+ function grouped(items) {
375
+ const order = [];
376
+ const byRun = new Map();
377
+ for (const m of items) {
378
+ /** O `then` entra na chave: dois achados só compartilham bloco se o remédio for o mesmo INTEIRO. */
379
+ const key = `${m.run ?? ""}\u0000${m.then ?? ""}`;
380
+ if (!byRun.has(key)) {
381
+ byRun.set(key, []);
382
+ order.push(key);
383
+ }
384
+ byRun.get(key)?.push(m);
385
+ }
386
+ const out = [];
387
+ for (const key of order) {
388
+ const group = byRun.get(key) ?? [];
389
+ for (const m of group)
390
+ out.push(` - ${m.says}`);
391
+ const { run, then } = group[0];
392
+ if (run)
393
+ out.push(` ${run}`);
394
+ /** O `then` sai numa linha própria e rotulada: um segundo comando solto lê como parte do primeiro. */
395
+ if (then)
396
+ out.push(` then, to pick up where you left off:\n ${then}`);
397
+ }
398
+ return out;
399
+ }
335
400
  /**
336
401
  * `synthesisui align` - o comando que o hook de sessão roda.
337
402
  *
@@ -1,4 +1,4 @@
1
- import { readFile, rm, stat, writeFile } from "node:fs/promises";
1
+ import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
3
  import { body, section } from "../output.js";
4
4
  // The five SVGs create-next-app drops into public/. Filenames are specific
@@ -100,6 +100,35 @@ export async function clean(opts) {
100
100
  run: () => rm(readmePath),
101
101
  });
102
102
  }
103
+ /**
104
+ * O RASCUNHO QUE A NOSSA PRÓPRIA INSTRUÇÃO PEDIU - e que ninguém sabia remover.
105
+ *
106
+ * O `CLAUDE.md` que este CLI escreve sugere uma página isolada para olhar um componente sozinho,
107
+ * e o agente da pessoa a cria. Nada aqui a escreve, e nada aqui a apagava: ela ficava numa rota
108
+ * de VERDADE do app dela (`src/app/synthesisui-samples/<x>` é URL acessível), sem dono e sem
109
+ * saída, até alguém commitar sem querer. Encontrada no repo do dono em 11/08.
110
+ *
111
+ * É a mesma assimetria do bloco de shell: fácil de entrar, sem porta de saída. Aqui a saída é
112
+ * este comando, e ele mantém o contrato que já tem - seco por padrão, cada caminho nomeado antes
113
+ * de qualquer escrita, `--force` para aplicar.
114
+ *
115
+ * UMA AÇÃO POR PÁGINA, e não uma pela pasta: um destrutivo que anuncia um e remove seis faz mais
116
+ * do que diz, e essa lei já foi paga uma vez na poda de órfãos.
117
+ */
118
+ const samplesDir = join(appDir, "synthesisui-samples");
119
+ for (const entry of await readdir(samplesDir, {
120
+ withFileTypes: true,
121
+ }).catch(() => [])) {
122
+ if (!entry.isDirectory())
123
+ continue;
124
+ const p = join(samplesDir, entry.name);
125
+ actions.push({
126
+ verb: "remove",
127
+ path: rel(p),
128
+ why: "scratch page for eyeballing a component - nothing depends on it",
129
+ run: () => rm(p, { recursive: true }),
130
+ });
131
+ }
103
132
  if (actions.length === 0) {
104
133
  console.log(section("Clean up scaffold"));
105
134
  console.log(body("Nothing to clean - this project is already tidy."));
@@ -4,7 +4,8 @@ import { wireAgent } from "../agent-wiring.js";
4
4
  import { blockHomes, syncClaudeMd } from "../claude-md.js";
5
5
  import { resolveRegistry } from "../config.js";
6
6
  import { body, paint, section, snippet } from "../output.js";
7
- import { hasHook, rcPathFor, shellFrom, shellSnippet, withHook, } from "../shell-hook.js";
7
+ import { readShellAnswer, rememberShellNo } from "../shell-answer.js";
8
+ import { existingRc, hasHook, pinnedInHook, rcPathFor, shellFrom, shellSnippet, withHook, } from "../shell-hook.js";
8
9
  import { IMPORT_SKILL, IMPORT_SKILL_PATH } from "../skill-import.js";
9
10
  import { INIT_SKILL, INIT_SKILL_PATH } from "../skill-init.js";
10
11
  import { add } from "./add.js";
@@ -118,25 +119,48 @@ async function refreshInstall(root, cli, registry) {
118
119
  * Sem TTY (CI, um agente rodando o comando) não há a quem perguntar, e a resposta certa é não
119
120
  * escrever - um pipeline não deveria acordar com o perfil de shell de alguém alterado.
120
121
  */
121
- async function offerShellHook(asked) {
122
+ async function offerShellHook(asked,
123
+ /** A versão que fica pinada no bloco - ver `shellSnippet`. */
124
+ version) {
122
125
  const shell = shellFrom(process.env);
123
126
  if (!shell)
124
127
  return;
125
- const rc = rcPathFor(shell);
128
+ /**
129
+ * O PERFIL QUE EXISTE E QUE O SHELL LÊ - e quando não há nenhum, a gente não cria.
130
+ *
131
+ * Acrescentar a um `.zshrc` que a pessoa mantém é uma coisa; CRIAR um perfil que ela nunca teve é
132
+ * outra, e é maior que a pergunta que fizemos. Quem pediu com `--shell` já decidiu, e aí o padrão
133
+ * vale. Ver `existingRc` - ele também é o que impede escrever num `.bashrc` que um login shell de
134
+ * macOS nunca lê.
135
+ */
136
+ const rc = (await existingRc(shell)) ?? (asked ? rcPathFor(shell) : null);
137
+ if (!rc)
138
+ return;
126
139
  const current = await readFile(rc, "utf8").catch(() => null);
127
- /** Já instalado: re-materializa em silêncio, para uma melhoria no bloco alcançar quem já disse sim. */
140
+ /**
141
+ * JÁ INSTALADO: rematerializa, mas DIZENDO ANTES - e o antes é o conserto.
142
+ *
143
+ * Isto escrevia calado e avisava depois, numa linha que a pessoa pode rolar sem ver. O arquivo é
144
+ * dela e de todos os outros projetos dela, e um `.zshrc` que muda sem uma palavra é a definição
145
+ * de invasivo (dono, 11/08). Ela consentiu em TER o bloco; mover o que está dentro dele é manter
146
+ * o que ela aceitou, então isto informa em vez de perguntar - mas informa ANTES.
147
+ *
148
+ * E passou a acontecer mais: com a versão pinada no bloco, todo bump do CLI o muda. Os dois
149
+ * consertos são um só - ver `shellSnippet`.
150
+ */
128
151
  if (current != null && hasHook(current)) {
129
- const next = withHook(current, shellSnippet(shell));
152
+ const next = withHook(current, shellSnippet(shell, version));
130
153
  if (next !== current) {
131
- await writeFile(rc, next, "utf8").catch(() => { });
154
+ const was = pinnedInHook(current);
132
155
  console.log("");
133
- console.log(body(`Refreshed the terminal check in ${rc}.`));
156
+ console.log(body(`Updating the terminal check in ${rc} - ${was ? `it was pinned to ${was}` : "it was on an unpinned version"}, moving it to ${version}.`));
157
+ await writeFile(rc, next, "utf8").catch(() => { });
134
158
  }
135
159
  return;
136
160
  }
137
161
  /** `--shell` é alguém que já decidiu - perguntar de novo seria não ter escutado. */
138
162
  if (asked) {
139
- await writeFile(rc, withHook(current ?? "", shellSnippet(shell)), "utf8");
163
+ await writeFile(rc, withHook(current ?? "", shellSnippet(shell, version)), "utf8");
140
164
  console.log("");
141
165
  console.log(body(`Terminal check written to ${rc}.`));
142
166
  console.log(body(`Open a new terminal, or run: source ${rc}`));
@@ -149,6 +173,18 @@ async function offerShellHook(asked) {
149
173
  console.log(body(paint.blue(" npx synthesisui@latest connect --shell")));
150
174
  return;
151
175
  }
176
+ /**
177
+ * ELE JÁ DISSE NÃO - e uma recusa que não é lembrada não é uma decisão.
178
+ *
179
+ * Fica a linha calada com o caminho de volta, que é a mesma forma da recusa na tela dele: sumir
180
+ * de vez esconderia algo que ele pode querer rever; perguntar de novo a cada `connect` é o que
181
+ * transforma a recusa em nada. Ver `shell-answer.ts`.
182
+ */
183
+ const said = await readShellAnswer();
184
+ if (said) {
185
+ console.log(body(paint.faint(`You said no to this on ${said.at.slice(0, 10)}. It stays off - \`npx synthesisui@latest connect --shell\` turns it on.`)));
186
+ return;
187
+ }
152
188
  const { createInterface } = await import("node:readline/promises");
153
189
  const rl = createInterface({
154
190
  input: process.stdin,
@@ -156,9 +192,11 @@ async function offerShellHook(asked) {
156
192
  });
157
193
  const answer = await rl.question(` Add it to ${rc}? [y/N] `).catch(() => "");
158
194
  rl.close();
159
- if (!/^y(es)?$/i.test(answer.trim()))
195
+ if (!/^y(es)?$/i.test(answer.trim())) {
196
+ await rememberShellNo(rc);
160
197
  return;
161
- await writeFile(rc, withHook(current ?? "", shellSnippet(shell)), "utf8");
198
+ }
199
+ await writeFile(rc, withHook(current ?? "", shellSnippet(shell, version)), "utf8");
162
200
  console.log(body(`Written. Open a new terminal, or run: source ${rc}`));
163
201
  }
164
202
  export async function connect(opts) {
@@ -318,7 +356,8 @@ export async function connect(opts) {
318
356
  console.log(body(paint.dim("Your PRs can carry this too: annotations on the exact line, and a ratchet that fails only when drift goes UP - never on the debt you already have.")));
319
357
  console.log(paint.blue(snippet(["npx synthesisui@latest ci"])));
320
358
  }
321
- await offerShellHook(opts.shell === true);
359
+ /** A mesma versão que a fiação do editor recebe - os dois pinam no mesmo número. */
360
+ await offerShellHook(opts.shell === true, opts.version);
322
361
  /**
323
362
  * O REINÍCIO SÓ QUANDO ELE É NECESSÁRIO - e pedi-lo sempre é o que fez o dono achar que reiniciar
324
363
  * o editor fazia parte do fluxo (07/08).
package/dist/last-sync.js CHANGED
@@ -24,7 +24,14 @@ export const LAST_SYNC_FILE = ".last-sync.json";
24
24
  *
25
25
  * `name` sai do valor porque já É a chave - hasheá-lo junto só gastaria bytes.
26
26
  */
27
- export function fingerprintReadings(components) {
27
+ export function fingerprintReadings(
28
+ /**
29
+ * A LINHA INTEIRA, e o tipo tem que dizer isso: `{ name?: unknown }` prometia menos do que esta
30
+ * função aceita, e o próprio spec precisou de um `as never` para passar uma linha real. Um tipo
31
+ * que obriga a burlá-lo está errado - e o `as never` que ele forçava é exatamente o que faria a
32
+ * impressão parar de ver um campo novo sem ninguém notar.
33
+ */
34
+ components) {
28
35
  const out = {};
29
36
  for (const c of components ?? []) {
30
37
  const name = typeof c?.name === "string" ? c.name : null;
@@ -0,0 +1,35 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * A RESPOSTA DELE SOBRE O TERMINAL, GRAVADA - senão a pergunta não decide nada.
6
+ *
7
+ * O dono rodou `connect` duas vezes num dia e foi perguntado das duas (11/08). O código já não
8
+ * pergunta quando o bloco EXISTE - ele reescreve calado e sai. O que não existia era memória do
9
+ * "não": quem recusa é perguntado de novo em toda conexão, para sempre.
10
+ *
11
+ * É a mesma lei que a tela dele já segue em `meta.declined`: *sem gravar, o botão não decide nada*.
12
+ * E é a mesma forma da recusa lá - ela não SOME, vira uma linha calada com o caminho de volta:
13
+ * sumir de vez esconderia uma decisão que ele pode querer rever, e continuar cobrando transforma a
14
+ * recusa em nada.
15
+ *
16
+ * MORA NO HOME, ao lado das credenciais, porque a decisão é da MÁQUINA e não do repositório - é o
17
+ * `.zshrc` dela que está em jogo, e ele é o mesmo para todos os projetos que ela tem. Gravar isso
18
+ * dentro de um repo faria a pessoa ser perguntada outra vez no próximo clone.
19
+ */
20
+ const answerPath = (home = homedir()) => join(home, ".synthesisui", "shell.json");
21
+ export async function readShellAnswer(home) {
22
+ try {
23
+ const raw = JSON.parse(await readFile(answerPath(home), "utf8"));
24
+ return raw?.answered === "no" ? raw : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** Nunca derruba o comando: uma recusa que não grava custa uma pergunta, não a conexão. */
31
+ export async function rememberShellNo(rc, home) {
32
+ const path = answerPath(home);
33
+ await mkdir(dirname(path), { recursive: true }).catch(() => { });
34
+ await writeFile(path, `${JSON.stringify({ answered: "no", at: new Date().toISOString(), rc }, null, 2)}\n`, "utf8").catch(() => { });
35
+ }
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  /**
@@ -29,7 +30,20 @@ export const HOOK_END = "# <<< synthesisui align <<<";
29
30
  * `add-zsh-hook chpwd`, o bash tem `PROMPT_COMMAND` e nenhum equivalente de `chpwd`, então lá a
30
31
  * função se protege sozinha comparando o diretório com o da última vez.
31
32
  */
32
- export function shellSnippet(shell) {
33
+ /**
34
+ * A VERSÃO FICA PINADA AQUI TAMBÉM - e a incoerência que isso resolve estava escrita nos dois lados.
35
+ *
36
+ * O hook do editor é pinado de propósito, e o comentário de `agent-wiring` diz por quê: *"um check
37
+ * que muda sob você é indebugável"*. Este rodava `npx --yes synthesisui align` sem versão - sempre
38
+ * o `latest`, resolvido a cada hora, na máquina de todo mundo que disse sim. O mesmo argumento,
39
+ * duas decisões opostas; e o lado despinado é o que ninguém consegue depurar quando um `align`
40
+ * ruim sai: ele é silencioso por desenho (`2>/dev/null`, em background).
41
+ *
42
+ * O PREÇO, dito porque é real: pinar faz o bloco MUDAR a cada bump do CLI, então o `connect` passa
43
+ * a reescrever um arquivo que é da PESSOA com mais frequência. É por isso que a reescrita passou a
44
+ * falar antes de escrever, em vez de avisar depois - os dois consertos são um só.
45
+ */
46
+ export function shellSnippet(shell, version) {
33
47
  const body = shell === "zsh"
34
48
  ? [
35
49
  "sui_align() {",
@@ -41,7 +55,7 @@ export function shellSnippet(shell) {
41
55
  ' (( $(date +%s) - $(stat -c %Y "$stamp" 2>/dev/null || echo 0) < 3600 )) && return',
42
56
  " fi",
43
57
  ' : > "$stamp"',
44
- " (npx --yes synthesisui align --shell 2>/dev/null &)",
58
+ ` (npx --yes synthesisui@${version} align --shell 2>/dev/null &)`,
45
59
  "}",
46
60
  "autoload -U add-zsh-hook",
47
61
  "add-zsh-hook chpwd sui_align",
@@ -59,7 +73,7 @@ export function shellSnippet(shell) {
59
73
  ' [ $(( $(date +%s) - $(stat -c %Y "$stamp" 2>/dev/null || echo 0) )) -lt 3600 ] && return 0',
60
74
  " fi",
61
75
  ' : > "$stamp"',
62
- " (npx --yes synthesisui align --shell 2>/dev/null &)",
76
+ ` (npx --yes synthesisui@${version} align --shell 2>/dev/null &)`,
63
77
  "}",
64
78
  'case "$PROMPT_COMMAND" in *sui_align*) ;; *) PROMPT_COMMAND="sui_align;$PROMPT_COMMAND" ;; esac',
65
79
  ];
@@ -76,11 +90,46 @@ export function shellSnippet(shell) {
76
90
  export function rcPathFor(shell, home = homedir()) {
77
91
  return join(home, shell === "zsh" ? ".zshrc" : ".bashrc");
78
92
  }
93
+ /**
94
+ * O PERFIL QUE EXISTE E QUE É LIDO - e `null` quando não há nenhum.
95
+ *
96
+ * Duas coisas que `rcPathFor` sozinho não sabe, e as duas escrevem no home de alguém:
97
+ *
98
+ * O ARQUIVO PODE NÃO EXISTIR. Acrescentar um bloco a um `.zshrc` que a pessoa mantém é uma coisa;
99
+ * CRIAR um perfil de shell que ela nunca teve é outra, e é maior do que a pergunta que fizemos.
100
+ *
101
+ * `.bashrc` NÃO É LIDO POR TODO BASH. Num login shell de macOS quem é lido é o `.bash_profile`,
102
+ * então escrever no `.bashrc` ali instala um aviso que nunca dispara - a pessoa diz sim, a gente
103
+ * escreve, e nada acontece nunca. Uma promessa que falha em silêncio é pior que a recusa.
104
+ *
105
+ * A ordem para bash é a que o shell usa: o perfil de login primeiro, o `.bashrc` depois.
106
+ */
107
+ export async function existingRc(shell, home = homedir()) {
108
+ const candidates = shell === "zsh"
109
+ ? [".zshrc"]
110
+ : [".bash_profile", ".bash_login", ".profile", ".bashrc"];
111
+ for (const name of candidates) {
112
+ const path = join(home, name);
113
+ if (await readFile(path, "utf8").then(() => true, () => false))
114
+ return path;
115
+ }
116
+ return null;
117
+ }
79
118
  /** Qual shell, lido do ambiente. `null` quando não é um dos dois - aí não há o que escrever. */
80
119
  export function shellFrom(env) {
81
120
  const name = (env.SHELL ?? "").split("/").pop();
82
121
  return name === "zsh" || name === "bash" ? name : null;
83
122
  }
123
+ /**
124
+ * A VERSÃO PINADA DENTRO DO BLOCO - para o `connect` poder dizer o que MOVE antes de mover.
125
+ *
126
+ * `null` num bloco escrito antes da pinagem, e a frase trata isso: "was on an unpinned version"
127
+ * é diferente de "was on 0.16.201", e inventar um número ali seria mentir sobre o arquivo de
128
+ * alguém.
129
+ */
130
+ export function pinnedInHook(text) {
131
+ return /synthesisui@(\d+\.\d+\.\d+)\s+align/.exec(text)?.[1] ?? null;
132
+ }
84
133
  export function hasHook(text) {
85
134
  return text.includes(HOOK_BEGIN);
86
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.202",
3
+ "version": "0.16.204",
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": {
@@ -32,7 +32,8 @@
32
32
  "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
33
33
  "dev": "tsx src/index.ts",
34
34
  "prepublishOnly": "npm run build",
35
- "test": "vitest run"
35
+ "test": "vitest run",
36
+ "check": "tsc -p tsconfig.check.json"
36
37
  },
37
38
  "license": "MIT"
38
39
  }