synthesisui 0.16.250 → 0.16.252

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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A LINHA DE COMANDO, DESMONTADA - e o único lugar que sabe fazer isso.
3
+ *
4
+ * Este parser vivia dentro do `index.ts`, privado, e por isso nunca foi
5
+ * exercido por um teste. O custo apareceu inteiro num defeito: `login --force`
6
+ * era lido de `args` - a lista de POSICIONAIS, de onde este parser já tirou
7
+ * toda `--flag` - então a flag anunciada no help nunca chegou ao comando, e a
8
+ * resposta era sugerir o comando que a pessoa acabou de rodar.
9
+ *
10
+ * Separado, ele é testável, e `boolFlag` fecha a porta: quem lê uma flag
11
+ * booleana pede pelo NOME dela a quem tem a resposta, e não procura a string
12
+ * numa lista que por construção não a contém.
13
+ */
14
+ /**
15
+ * A REPEATED FLAG ACCUMULATES rather than overwriting.
16
+ *
17
+ * `--usage apps/web --usage apps/admin` is the natural way to say two, and keeping
18
+ * only the last would drop one silently - which is the failure mode this codebase
19
+ * refuses everywhere else. Every existing flag is passed once and still arrives as
20
+ * a string, so nothing that reads `typeof flags.x === "string"` changes.
21
+ */
22
+ export function parseFlags(argv) {
23
+ const positionals = [];
24
+ const flags = {};
25
+ const put = (key, value) => {
26
+ const had = flags[key];
27
+ if (had === undefined) {
28
+ flags[key] = value;
29
+ return;
30
+ }
31
+ if (typeof value !== "string")
32
+ return;
33
+ flags[key] = [
34
+ ...(Array.isArray(had) ? had : typeof had === "string" ? [had] : []),
35
+ value,
36
+ ];
37
+ };
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const arg = argv[i];
40
+ if (arg === "-h" || arg === "--help") {
41
+ flags.help = true;
42
+ }
43
+ else if (arg.startsWith("--")) {
44
+ const body = arg.slice(2);
45
+ const eq = body.indexOf("=");
46
+ if (eq !== -1) {
47
+ // `--key=value` form (e.g. --out=templates/page.tsx)
48
+ put(body.slice(0, eq), body.slice(eq + 1));
49
+ }
50
+ else {
51
+ // `--key value` form
52
+ const next = argv[i + 1];
53
+ if (next !== undefined && !next.startsWith("--")) {
54
+ put(body, next);
55
+ i++;
56
+ }
57
+ else {
58
+ put(body, true);
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ positionals.push(arg);
64
+ }
65
+ }
66
+ return { positionals, flags };
67
+ }
68
+ /**
69
+ * UMA FLAG BOOLEANA, LIDA PELO NOME - `true` só quando ela está mesmo lá.
70
+ *
71
+ * `--yes` sozinha chega como `true`; `--yes` seguida de um posicional chega
72
+ * como a string daquele posicional, porque o parser não sabe quais flags levam
73
+ * valor. Nos dois casos a pessoa DISSE a flag, e é isso que um booleano
74
+ * responde - tratar `--yes algo` como não-dito seria obedecer pela metade.
75
+ */
76
+ export function boolFlag(flags, name) {
77
+ const v = flags[name];
78
+ return v === true || typeof v === "string" || Array.isArray(v);
79
+ }
@@ -3,6 +3,7 @@ import { basename, dirname, join, relative } from "node:path";
3
3
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
4
4
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
5
5
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
6
+ import { declaredElsewhere } from "../declared-elsewhere.js";
6
7
  import { architectureRule, describeArchitecture, describeChoice, detectArchitectures, } from "../doctor/architecture.js";
7
8
  import { findBrokenRefs } from "../doctor/broken-refs.js";
8
9
  import { nestingRules, propRules, readDefinitionProps, readNesting, readRuntime, } from "../doctor/call-sites.js";
@@ -37,6 +38,7 @@ import { withLibraryStructure } from "../library-structure.js";
37
38
  import { body, paint, section } from "../output.js";
38
39
  import { phase, startProgress } from "../progress.js";
39
40
  import { detectStack, resolveDeps, stackVersions } from "../stack.js";
41
+ import { placeInWorkspace } from "../workspace-place.js";
40
42
  import { walk, walkAll } from "./doctor.js";
41
43
  /**
42
44
  * How many distinct values travel, PER KIND.
@@ -1996,7 +1998,15 @@ export async function takeCensus(root, opts) {
1996
1998
  },
1997
1999
  };
1998
2000
  }
1999
- function summarize(c) {
2001
+ /**
2002
+ * O RESUMO RECEBE O LUGAR JUNTO COM O CENSO - e não a metade dele.
2003
+ *
2004
+ * `sayBrokenRefs` precisa saber ONDE procurar a declaração que o escopo não viu,
2005
+ * e um parâmetro opcional aqui seria a mesma porta que o §6 do método manda
2006
+ * fechar: uma função que pode ser chamada pela metade vai ser, e o sintoma seria
2007
+ * o relatório voltando a dizer "your CSS never declares" sem que nada falhe.
2008
+ */
2009
+ async function summarize(c, root, scope) {
2000
2010
  const byKind = new Map();
2001
2011
  for (const v of c.observed)
2002
2012
  byKind.set(v.kind, (byKind.get(v.kind) ?? 0) + v.count);
@@ -2045,7 +2055,7 @@ function summarize(c) {
2045
2055
  }
2046
2056
  }
2047
2057
  sayConventions(c);
2048
- sayBrokenRefs(c);
2058
+ await sayBrokenRefs(c, root, scope);
2049
2059
  }
2050
2060
  /**
2051
2061
  * THEIR VOCABULARY, MEASURED. This section exists so every later finding can be
@@ -2076,14 +2086,70 @@ function sayConventions(c) {
2076
2086
  * finding that needs no agreement with us: the convention being broken is
2077
2087
  * theirs.
2078
2088
  */
2079
- function sayBrokenRefs(c) {
2080
- const broken = c.brokenRefs ?? [];
2081
- if (broken.length === 0)
2089
+ /**
2090
+ * A PASTA QUE CONTÉM TODOS ESTES CAMINHOS - o `--scope` que traria as
2091
+ * declarações junto, derivado dos endereços e não sugerido de cabeça.
2092
+ *
2093
+ * Devolve `null` quando os caminhos não compartilham pasta nenhuma: aí não existe
2094
+ * um escopo único que resolva, e inventar um seria pior que dizer a regra geral.
2095
+ */
2096
+ function commonPrefix(paths) {
2097
+ if (paths.length === 0)
2098
+ return null;
2099
+ const out = [];
2100
+ for (let i = 0;; i++) {
2101
+ const seg = paths[0][i];
2102
+ // O último segmento é o arquivo, e um arquivo não é um escopo.
2103
+ if (seg == null || i >= paths[0].length - 1)
2104
+ break;
2105
+ if (!paths.every((p) => p[i] === seg && i < p.length - 1))
2106
+ break;
2107
+ out.push(seg);
2108
+ }
2109
+ return out.length > 0 ? out.join("/") : null;
2110
+ }
2111
+ async function sayBrokenRefs(c, root, scope) {
2112
+ const all = c.brokenRefs ?? [];
2113
+ if (all.length === 0)
2082
2114
  return;
2115
+ /**
2116
+ * DUAS PERGUNTAS DIFERENTES, E O RELATÓRIO FAZIA UMA SÓ.
2117
+ *
2118
+ * "Não resolve no que eu medi" e "não existe no seu código" são coisas
2119
+ * distintas, e o texto afirmava a segunda. Com `--scope src/components` no
2120
+ * monorepo do dono são 1021 referências contra 2 declarações, enquanto o CSS
2121
+ * dele declara 118 em `src/app/**` e 764 em `packages/ui` (medido 19/08).
2122
+ *
2123
+ * Culpar o CSS de quem confiou o repositório por um recorte NOSSO é o pior
2124
+ * lugar para errar: o relatório é o que a pessoa lê depois, sozinha.
2125
+ */
2126
+ const at = await declaredElsewhere(root, scope, all.map((b) => b.name)).catch(() => new Map());
2127
+ const outside = all.filter((b) => at.has(b.name));
2128
+ const broken = all.filter((b) => !at.has(b.name));
2129
+ if (outside.length > 0) {
2130
+ const uses = outside.reduce((n, b) => n + b.count, 0);
2131
+ console.log("");
2132
+ console.log(section("These are declared outside what was measured"));
2133
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${outside.length} token${outside.length === 1 ? "" : "s"} your code DOES declare - just not inside ${paint.strong(scope ?? "this folder")}. Your CSS is fine; the recipes carry the value you typed instead of the name you gave it, because the name was not in what we read.`));
2134
+ console.log("");
2135
+ for (const b of outside.slice(0, 6)) {
2136
+ console.log(body(`${paint.strong(`var(${b.name})`)} ${paint.faint(`${b.count}× here`)} - declared in ${paint.strong(at.get(b.name) ?? "")}`));
2137
+ }
2138
+ if (outside.length > 6)
2139
+ console.log(body(paint.faint(`(${outside.length - 6} more)`)));
2140
+ console.log("");
2141
+ /** O caminho comum dos endereços é o escopo que traria todos de uma vez. */
2142
+ const wider = commonPrefix([...at.values()].map((v) => v.split("/")));
2143
+ console.log(body(wider
2144
+ ? `A wider ${paint.strong("--scope")} keeps your names: ${paint.strong(`--scope ${wider}`)} covers the declarations above.`
2145
+ : "A wider --scope keeps your names, because the declarations come along with the code that uses them."));
2146
+ if (broken.length === 0)
2147
+ return;
2148
+ }
2083
2149
  const uses = broken.reduce((n, b) => n + b.count, 0);
2084
2150
  console.log("");
2085
2151
  console.log(section("These resolve to nothing"));
2086
- console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} your CSS never declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour. Where exactly one declared token differs by a namespace, the recipe now uses THAT one, so the look survives; the rest travel as the value you typed.`));
2152
+ console.log(body(`${uses} reference${uses === 1 ? "" : "s"} to ${broken.length} token${broken.length === 1 ? "" : "s"} nothing in this repository declares. A ${paint.strong("var()")} with no declaration paints no colour - not a different colour. Where exactly one declared token differs by a namespace, the recipe now uses THAT one, so the look survives; the rest travel as the value you typed.`));
2087
2153
  console.log("");
2088
2154
  for (const b of broken.slice(0, 8)) {
2089
2155
  const where = `${b.count}× in ${b.files} file${b.files === 1 ? "" : "s"}`;
@@ -2286,6 +2352,75 @@ function printAgentContract() {
2286
2352
  * just stops letting a person believe the average of three apps is their design
2287
2353
  * system.
2288
2354
  */
2355
+ /**
2356
+ * O LUGAR, DITO ANTES DO ESCOPO - e o comando que a resposta implica.
2357
+ *
2358
+ * A detecção de monorepo olhava só para BAIXO (`siblingProjects` lê `apps/`,
2359
+ * `packages/`, `libs/` dentro do root), então quem roda o import de dentro do app
2360
+ * que está migrando recebia silêncio: `apps/web-dashboard` não contém `apps/`.
2361
+ * Medido no monorepo do dono (19/08): 5 pacotes, `packages/ui` declarando 764
2362
+ * tokens em 3 arquivos contra 114 em 6 no app onde ele rodou, e o aviso que
2363
+ * ensina `--scope packages/ui --usage apps/web-dashboard` nunca apareceu.
2364
+ *
2365
+ * A ORDEM É A DA DÚVIDA DE QUEM LÊ: onde eu estou, onde mora o vocabulário, e só
2366
+ * então o comando. Um comando oferecido antes das duas primeiras respostas é uma
2367
+ * receita para copiar sem entender.
2368
+ */
2369
+ async function sayAboutPlace(root, scope) {
2370
+ const place = await placeInWorkspace(root);
2371
+ /** Sem workspace acima, a pergunta antiga ainda vale: há projetos AQUI dentro? */
2372
+ if (!place) {
2373
+ if (!scope)
2374
+ await sayIfWorkspace(root);
2375
+ return;
2376
+ }
2377
+ /** Na raiz do workspace o aviso que já existia é o certo - ele lista os apps. */
2378
+ if (place.here === null) {
2379
+ if (!scope)
2380
+ await sayIfWorkspace(root);
2381
+ return;
2382
+ }
2383
+ const vocab = place.vocabulary;
2384
+ const mine = place.packages.find((p) => p.rel === place.here);
2385
+ /**
2386
+ * NADA A DIZER quando o vocabulário está aqui mesmo: a pessoa escolheu o
2387
+ * pacote que declara os tokens, e repetir o que ela acertou é ruído.
2388
+ */
2389
+ if (!vocab || vocab.rel === place.here)
2390
+ return;
2391
+ /** Nem quando o escopo dado já aponta para lá - ela já sabe. */
2392
+ if (scope && join(place.here, scope) === vocab.rel)
2393
+ return;
2394
+ const up = relative(root, place.workspaceRoot).split("\\").join("/") || ".";
2395
+ /**
2396
+ * SÓ QUEM CONSOME A BIBLIOTECA doa evidência. Oferecer `--usage apps/web-admin`
2397
+ * para um app que nunca a importa ensina a medir escolhas de um lugar que não
2398
+ * vota - e `--usage` existe justamente para dizer quais escolhas já são lei.
2399
+ * Medido no monorepo do dono: dos dois outros apps, nenhum declara a
2400
+ * biblioteca, e a primeira versão desta mensagem oferecia os dois.
2401
+ */
2402
+ const consumers = place.packages.filter((p) => p.rel !== vocab.rel &&
2403
+ p.rel !== place.here &&
2404
+ vocab.name != null &&
2405
+ p.dependsOn.includes(vocab.name));
2406
+ const usage = [place.here, ...consumers.map((c) => c.rel)]
2407
+ .slice(0, 3)
2408
+ .map((a) => `--usage ${a}`)
2409
+ .join(" ");
2410
+ console.log("");
2411
+ console.log(section("This folder is one package of a workspace"));
2412
+ console.log(body(`${paint.strong(place.here)} is one of ${place.packages.length} packages under ${paint.strong(place.workspaceRoot)}.`));
2413
+ console.log("");
2414
+ console.log(body(`And the vocabulary is not here: ${paint.strong(vocab.rel)} declares ${vocab.declarations} token${vocab.declarations === 1 ? "" : "s"} across ${vocab.files} file${vocab.files === 1 ? "" : "s"}, while this package declares ${mine?.declarations ?? 0} across ${mine?.files ?? 0}.`));
2415
+ console.log(body(paint.faint("A system built from the app is the average of one consumer; the tokens live where the library declares them.")));
2416
+ console.log("");
2417
+ console.log(body("The system is one place, and the evidence is another:"));
2418
+ console.log("");
2419
+ console.log(body(` ${paint.strong(`synthesisui import --dir ${up} --scope ${vocab.rel} ${usage}`)}`));
2420
+ console.log(body(paint.faint(` --dir points at the workspace root, so --scope and --usage are read from there.`)));
2421
+ console.log("");
2422
+ console.log(body(paint.dim("Nothing you ran is wasted - this census is a fine diagnosis of this app. It is a system for one consumer, which is the part worth knowing before you publish it.")));
2423
+ }
2289
2424
  async function sayIfWorkspace(root) {
2290
2425
  const { apps, shared } = await siblingProjects(root);
2291
2426
  if (apps.length < 2)
@@ -3058,11 +3193,22 @@ export async function runImport(opts) {
3058
3193
  */
3059
3194
  if (!opts.census)
3060
3195
  await resolveReadParts(census, root);
3061
- summarize(census);
3062
- // Only when nobody has scoped yet. Telling someone to scope to the folder
3063
- // they just scoped to reads as the tool not having noticed.
3064
- if (!opts.census && !scope)
3065
- await sayIfWorkspace(root);
3196
+ await summarize(census, root, scope);
3197
+ /**
3198
+ * ONDE ESTOU vem antes de O QUE EU LEIO, e o `!scope` calava exatamente quem
3199
+ * mais precisava ouvir.
3200
+ *
3201
+ * A condição era "só quando ninguém escopou ainda", pelo motivo certo: mandar
3202
+ * escopar para a pasta que a pessoa acabou de escopar lê como a ferramenta não
3203
+ * ter percebido. Só que ela também calava o caso em que o escopo escolhido não
3204
+ * é onde o vocabulário mora - e aí o silêncio não é discrição, é perder a
3205
+ * única chance de dizer que o sistema está em outro pacote.
3206
+ *
3207
+ * `sayAboutPlace` faz as duas perguntas na ordem: onde este comando está, e
3208
+ * onde as declarações estão. Ele só fala quando as respostas divergem.
3209
+ */
3210
+ if (!opts.census)
3211
+ await sayAboutPlace(root, scope);
3066
3212
  // A census handed to us is written BACK TO ITSELF; one we took lands at the
3067
3213
  // ROOT, whatever it measured. Everything else in `_synthesisui/` is anchored
3068
3214
  // there - config, `ds/<slug>/`, the hook's marker - and the census was the
@@ -53,7 +53,8 @@ export async function login(opts) {
53
53
  if (existing && sameRegistry(existing.registry, base)) {
54
54
  console.log("");
55
55
  console.log(`✓ Already signed in to ${base} on this machine.`);
56
- console.log(" Run `synthesisui login --force` to sign in as someone else.");
56
+ console.log(" Run `synthesisui login --force` to sign in as someone else,");
57
+ console.log(" or `synthesisui logout` to sign out of this machine.");
57
58
  return;
58
59
  }
59
60
  }
@@ -0,0 +1,26 @@
1
+ import { clearCredentials, credentialsPath, readCredentials, } from "../config.js";
2
+ /**
3
+ * SAIR DA CONTA NESTA MÁQUINA - o comando que faltava do par.
4
+ *
5
+ * O CLI sabia entrar e não sabia sair. Quem precisava trocar de conta era
6
+ * mandado apagar `~/.synthesisui/credentials.json` na mão - e uma instrução que
7
+ * termina em `rm` num diretório de configuração é a que faz alguém apagar a
8
+ * pasta errada.
9
+ *
10
+ * Ele diz de QUAL host saiu, porque a credencial guarda o registry que a
11
+ * emitiu: sair de um localhost e achar que saiu da produção é o mesmo mal
12
+ * entendido que fez o token de dev ser enviado para o site em 31/07.
13
+ */
14
+ export async function logout() {
15
+ const existing = await readCredentials();
16
+ const had = await clearCredentials();
17
+ console.log("");
18
+ if (!had) {
19
+ console.log("✓ You are not signed in on this machine - nothing to do.");
20
+ console.log(" Run `synthesisui login` to connect the CLI to an account.");
21
+ return;
22
+ }
23
+ console.log(`✓ Signed out${existing?.registry ? ` of ${existing.registry}` : ""} on this machine.`);
24
+ console.log(` Removed ${credentialsPath}`);
25
+ console.log(" Run `synthesisui login` to sign in as someone else.");
26
+ }
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  /**
@@ -76,6 +76,30 @@ export async function writeToken(token, registry) {
76
76
  mode: 0o600,
77
77
  });
78
78
  }
79
+ /**
80
+ * SAIR DA MÁQUINA - o outro lado da porta que o `login` abre.
81
+ *
82
+ * Existir era o mínimo: até aqui a única forma de encerrar a sessão era apagar
83
+ * `~/.synthesisui/credentials.json` à mão, e um produto que só sabe entrar
84
+ * obriga a pessoa a mexer no disco para trocar de conta.
85
+ *
86
+ * O arquivo inteiro vai embora, e não só o campo `token`: um arquivo sem token
87
+ * é indistinguível de nenhum arquivo para quem lê (`readCredentials` devolve
88
+ * `null` nos dois casos) e ainda deixaria o host da sessão antiga gravado.
89
+ *
90
+ * Devolve `false` quando não havia nada - a diferença entre "saí" e "você já
91
+ * estava fora" é a única coisa que o comando tem para dizer.
92
+ */
93
+ export async function clearCredentials() {
94
+ const existing = await readCredentials();
95
+ try {
96
+ await rm(credentialsPath, { force: true });
97
+ }
98
+ catch {
99
+ // já não existe, ou o disco recusou - o estado final é o mesmo
100
+ }
101
+ return existing !== null;
102
+ }
79
103
  /** Project-level config (committed): `<root>/_synthesisui/config.json`. */
80
104
  export const DEFAULT_CONFIG = {
81
105
  target: "next",
@@ -0,0 +1,77 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { placeInWorkspace } from "./workspace-place.js";
4
+ /**
5
+ * ONDE ESTE TOKEN É DECLARADO, QUANDO NÃO É NO QUE FOI MEDIDO.
6
+ *
7
+ * `findBrokenRefs` compara os `var()` do escopo contra as declarações do escopo,
8
+ * e o relatório chama o resto de *"tokens your CSS never declares"*. Com
9
+ * `--scope` isso pode ser falso, e no monorepo do dono era (19/08):
10
+ * `--scope src/components` deixa 1021 referências contra 2 declarações, e o CSS
11
+ * dele declara 118 em `src/app/**` e 764 em `packages/ui`.
12
+ *
13
+ * Afirmar um defeito que não existe no código de quem confiou o repositório é
14
+ * pior do que não dizer nada - o relatório é o documento que a pessoa lê depois,
15
+ * sozinha, e ele estava culpando o CSS dela por uma escolha de recorte NOSSA.
16
+ *
17
+ * Então a pergunta muda de "existe?" para "existe ONDE?", em duas camadas: fora
18
+ * do escopo mas dentro do que foi apontado, e nos outros pacotes do mesmo
19
+ * workspace - que é onde uma biblioteca de design system mora.
20
+ */
21
+ const SHEET = /\.(css|scss|sass|less)$/i;
22
+ const DECL = /(^|[\s{;])(--[a-zA-Z0-9-]+)\s*:/g;
23
+ /** Onde cada nome aparece declarado, primeiro achado ganha - basta um endereço. */
24
+ async function harvest(dir, label, skip, into) {
25
+ const visit = async (at, depth) => {
26
+ if (depth > 8)
27
+ return;
28
+ for (const e of await readdir(at, { withFileTypes: true }).catch(() => [])) {
29
+ if (e.name.startsWith(".") || e.name === "node_modules")
30
+ continue;
31
+ const full = join(at, e.name);
32
+ if (skip && full === skip)
33
+ continue;
34
+ if (e.isDirectory()) {
35
+ await visit(full, depth + 1);
36
+ continue;
37
+ }
38
+ if (!SHEET.test(e.name))
39
+ continue;
40
+ const body = await readFile(full, "utf8").catch(() => "");
41
+ for (const m of body.matchAll(DECL)) {
42
+ const name = m[2];
43
+ if (!into.has(name))
44
+ into.set(name, `${label}${relative(dir, full).split("\\").join("/")}`);
45
+ }
46
+ }
47
+ };
48
+ await visit(dir, 0);
49
+ }
50
+ /**
51
+ * NOME -> CAMINHO onde ele é declarado, fora do que foi medido.
52
+ *
53
+ * Só roda quando há algo a explicar: sem referências pendentes não há pergunta,
54
+ * e um walk a mais por nada é um walk a mais.
55
+ */
56
+ export async function declaredElsewhere(root, scope, names) {
57
+ const out = new Map();
58
+ if (names.length === 0)
59
+ return out;
60
+ const wanted = new Set(names);
61
+ const found = new Map();
62
+ /** Camada 1: o resto do que a pessoa apontou, sem reler o escopo. */
63
+ await harvest(root, "", scope ? join(root, scope) : null, found);
64
+ /** Camada 2: os outros pacotes do workspace - onde uma biblioteca mora. */
65
+ const place = await placeInWorkspace(root);
66
+ if (place) {
67
+ for (const pkg of place.packages) {
68
+ if (pkg.rel === place.here)
69
+ continue;
70
+ await harvest(join(place.workspaceRoot, pkg.rel), `${pkg.rel}/`, null, found);
71
+ }
72
+ }
73
+ for (const [name, where] of found)
74
+ if (wanted.has(name))
75
+ out.set(name, where);
76
+ return out;
77
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { boolFlag, parseFlags } from "./cli-flags.js";
4
5
  import { absorb } from "./commands/absorb.js";
5
6
  import { add } from "./commands/add.js";
6
7
  import { adopt } from "./commands/adopt.js";
@@ -18,6 +19,7 @@ import { runImport } from "./commands/import.js";
18
19
  import { init } from "./commands/init.js";
19
20
  import { list } from "./commands/list.js";
20
21
  import { login } from "./commands/login.js";
22
+ import { logout } from "./commands/logout.js";
21
23
  import { mcp } from "./commands/mcp.js";
22
24
  import { refit } from "./commands/refit.js";
23
25
  import { request } from "./commands/request.js";
@@ -37,6 +39,7 @@ const HELP = `synthesisui - bring SynthesisUI design systems into your project
37
39
 
38
40
  Usage - deterministic, FREE:
39
41
  synthesisui login [options] connect the CLI to your account (device-flow; --force to switch accounts)
42
+ synthesisui logout sign out of this machine (removes the saved token)
40
43
  synthesisui init [options] write _synthesisui/config.json (target, dirs); --ds to bring one in
41
44
  synthesisui list [options] list the published design systems
42
45
  synthesisui list --mine your own systems, with their group
@@ -135,61 +138,6 @@ Examples:
135
138
  synthesisui advise "habit-building app for tracking personal finances"
136
139
  synthesisui generate "an upgrade banner with a title, message and a primary CTA"
137
140
  `;
138
- /** Extracts simple `--flag value` pairs and the remaining positionals. */
139
- /**
140
- * A REPEATED FLAG ACCUMULATES rather than overwriting.
141
- *
142
- * `--usage apps/web --usage apps/admin` is the natural way to say two, and keeping
143
- * only the last would drop one silently - which is the failure mode this codebase
144
- * refuses everywhere else. Every existing flag is passed once and still arrives as
145
- * a string, so nothing that reads `typeof flags.x === "string"` changes.
146
- */
147
- function parseFlags(argv) {
148
- const positionals = [];
149
- const flags = {};
150
- const put = (key, value) => {
151
- const had = flags[key];
152
- if (had === undefined) {
153
- flags[key] = value;
154
- return;
155
- }
156
- if (typeof value !== "string")
157
- return;
158
- flags[key] = [
159
- ...(Array.isArray(had) ? had : typeof had === "string" ? [had] : []),
160
- value,
161
- ];
162
- };
163
- for (let i = 0; i < argv.length; i++) {
164
- const arg = argv[i];
165
- if (arg === "-h" || arg === "--help") {
166
- flags.help = true;
167
- }
168
- else if (arg.startsWith("--")) {
169
- const body = arg.slice(2);
170
- const eq = body.indexOf("=");
171
- if (eq !== -1) {
172
- // `--key=value` form (e.g. --out=templates/page.tsx)
173
- put(body.slice(0, eq), body.slice(eq + 1));
174
- }
175
- else {
176
- // `--key value` form
177
- const next = argv[i + 1];
178
- if (next !== undefined && !next.startsWith("--")) {
179
- put(body, next);
180
- i++;
181
- }
182
- else {
183
- put(body, true);
184
- }
185
- }
186
- }
187
- else {
188
- positionals.push(arg);
189
- }
190
- }
191
- return { positionals, flags };
192
- }
193
141
  async function main() {
194
142
  const { positionals, flags } = parseFlags(process.argv.slice(2));
195
143
  const [command, ...args] = positionals;
@@ -397,11 +345,19 @@ async function main() {
397
345
  await add(slug, { registry, dir, version, cli: CLI_VERSION });
398
346
  break;
399
347
  }
348
+ case "logout":
349
+ /**
350
+ * O outro lado do `login`. Sem ele a única saída era apagar
351
+ * `~/.synthesisui/credentials.json` à mão - e trocar de conta é o
352
+ * caminho normal de quem testa a plataforma em mais de um repositório.
353
+ */
354
+ await logout();
355
+ break;
400
356
  case "login":
401
357
  // `--force` porque o comando agora sai cedo quando esta máquina já tem
402
358
  // sessão para este host - trocar de conta continua possível, e passa a
403
359
  // ser dito em vez de ser o comportamento padrão.
404
- await login({ registry, force: args.includes("--force") });
360
+ await login({ registry, force: boolFlag(flags, "force") });
405
361
  break;
406
362
  case "init": {
407
363
  const target = typeof flags.target === "string" ? flags.target : undefined;
@@ -562,10 +518,10 @@ async function main() {
562
518
  dir,
563
519
  registry,
564
520
  cli: CLI_VERSION,
565
- recordOnly: args.includes("--record-only"),
566
- yes: args.includes("--yes"),
521
+ recordOnly: boolFlag(flags, "record-only"),
522
+ yes: boolFlag(flags, "yes"),
567
523
  /** `--full`: o relatório inteiro do leitor. Por padrão a re-medição conta o que MUDOU. */
568
- full: args.includes("--full") || flags.full === true,
524
+ full: boolFlag(flags, "full"),
569
525
  });
570
526
  break;
571
527
  /**
package/dist/stack.js CHANGED
@@ -27,6 +27,15 @@ import { join } from "node:path";
27
27
  * app que usa o sistema, então o manifesto daquele app é evidência tanto quanto
28
28
  * o da raiz.
29
29
  */
30
+ /**
31
+ * O RANGE QUE DIZ "ISTO É DESTE REPOSITÓRIO" - o mesmo prefixo que
32
+ * `frontier-kind.ts` já reconhece como local, escrito aqui uma vez.
33
+ *
34
+ * Sobrescrever o range com este valor é mais verdadeiro do que preservar o que o
35
+ * app digitou: um `*` num monorepo NÃO significa "qualquer versão do npm", e era
36
+ * assim que ele estava sendo lido.
37
+ */
38
+ const LOCAL_RANGE = "workspace:*";
30
39
  export async function resolveDeps(root) {
31
40
  const deps = {};
32
41
  const workspaceDirs = [];
@@ -84,6 +93,47 @@ export async function resolveDeps(root) {
84
93
  }
85
94
  for (const w of workspaceDirs)
86
95
  await readManifest(w);
96
+ /**
97
+ * O NOME DE UM PACOTE DESTE WORKSPACE É LOCAL, QUALQUER QUE SEJA O RANGE - e
98
+ * é a lei 13 se aplicando a si mesma: a ORIGEM decide, não o nome.
99
+ *
100
+ * Medido no monorepo do dono (19/08): `apps/web-dashboard` declara
101
+ * `"@frontend-hub/ui": "*"`, que é como npm e yarn workspaces apontam para o
102
+ * pacote ao lado. `LOCAL_RANGE` em `frontier-kind.ts` reconhece
103
+ * `workspace:|file:|link:|portal:`, então `frontierOf` respondia `opaque` -
104
+ * "de terceiro, se desenha sozinha" - sobre a biblioteca que É o design
105
+ * system dele, em 52 arquivos daquele app. Um monorepo que versiona de
106
+ * verdade (`"^2.1.0"`) caía igual.
107
+ *
108
+ * Quem decidia era o FORMATO DO RANGE, que não é a origem nem o nome: é uma
109
+ * terceira coisa. A origem está aqui - a raiz declara `workspaces`, e um
110
+ * daqueles diretórios tem um `package.json` cujo `name` é aquele
111
+ * especificador. Este laço grava esse fato, e ele SOBRESCREVE o range que o
112
+ * app escreveu, porque um pacote deste repositório não deixa de ser deste
113
+ * repositório por causa de como alguém o apontou.
114
+ *
115
+ * Corrigido aqui e não em `frontier-kind.ts` por dois motivos: a informação já
116
+ * passa por esta função, e aquele arquivo é gêmeo byte-idêntico de
117
+ * `libs/ds-contracts/src/frontier-kind.ts` - mudar a pergunta lá custaria os
118
+ * dois, e a pergunta lá está certa.
119
+ *
120
+ * ENTRA MESMO SEM O APP DECLARAR: alias de tsconfig, import direto ou
121
+ * dependência esquecida - o pacote existe no repositório, e é isso que a lei
122
+ * pergunta.
123
+ */
124
+ for (const w of workspaceDirs) {
125
+ const raw = await readFile(join(w, "package.json"), "utf8").catch(() => null);
126
+ if (!raw)
127
+ continue;
128
+ try {
129
+ const name = JSON.parse(raw).name;
130
+ if (typeof name === "string" && name)
131
+ deps[name] = LOCAL_RANGE;
132
+ }
133
+ catch {
134
+ // manifesto ilegível custa a detecção daquele pacote, nunca a corrida
135
+ }
136
+ }
87
137
  return deps;
88
138
  }
89
139
  /**
@@ -0,0 +1,140 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ const MANIFEST = "package.json";
4
+ const SHEET = /\.(css|scss|sass|less)$/i;
5
+ const DECL = /(^|[\s{;])--[a-zA-Z0-9-]+\s*:/g;
6
+ async function manifest(dir) {
7
+ const raw = await readFile(join(dir, MANIFEST), "utf8").catch(() => null);
8
+ if (!raw)
9
+ return null;
10
+ try {
11
+ return JSON.parse(raw);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ /** Os globs que um manifesto usa para declarar workspaces, nas duas formas reais. */
18
+ function workspaceGlobs(p) {
19
+ const w = p.workspaces;
20
+ const list = Array.isArray(w)
21
+ ? w
22
+ : Array.isArray(w?.packages)
23
+ ? w.packages
24
+ : [];
25
+ return list.filter((g) => typeof g === "string");
26
+ }
27
+ /**
28
+ * Quantas custom properties um pacote declara - o mesmo peso que
29
+ * `siblingProjects` já usa para escolher entre `packages/core` e `packages/ui`.
30
+ *
31
+ * Diretório com ponto e `node_modules` ficam fora pelo mesmo motivo que o `walk`
32
+ * do doctor os ignora: `.next/` guarda CSS COMPILADO, e contá-lo faria o build
33
+ * output votar em onde mora o vocabulário.
34
+ */
35
+ async function weigh(dir) {
36
+ let declarations = 0;
37
+ let files = 0;
38
+ const visit = async (at, depth) => {
39
+ if (depth > 8)
40
+ return;
41
+ for (const e of await readdir(at, { withFileTypes: true }).catch(() => [])) {
42
+ if (e.name.startsWith(".") || e.name === "node_modules")
43
+ continue;
44
+ const full = join(at, e.name);
45
+ if (e.isDirectory()) {
46
+ // Um pacote aninhado fala o próprio sistema - ver `walk`, mesma regra.
47
+ if (await manifest(full))
48
+ continue;
49
+ await visit(full, depth + 1);
50
+ continue;
51
+ }
52
+ if (!SHEET.test(e.name))
53
+ continue;
54
+ const body = await readFile(full, "utf8").catch(() => "");
55
+ const n = (body.match(DECL) ?? []).length;
56
+ if (n > 0) {
57
+ declarations += n;
58
+ files += 1;
59
+ }
60
+ }
61
+ };
62
+ await visit(dir, 0);
63
+ return { declarations, files };
64
+ }
65
+ /**
66
+ * O LUGAR, ou `null` quando não há workspace nenhum acima - e aí não há nada a
67
+ * anunciar, que é o caso da maioria dos projetos.
68
+ *
69
+ * Sobe no máximo quatro níveis, o mesmo teto de `resolveDeps`: cobre todo layout
70
+ * pnpm/npm/yarn sem sair andando pela pasta pessoal de quem roda.
71
+ */
72
+ export async function placeInWorkspace(root) {
73
+ let dir = root;
74
+ let workspaceRoot = null;
75
+ let globs = [];
76
+ for (let up = 0; up < 4; up++) {
77
+ const p = await manifest(dir);
78
+ if (p) {
79
+ const g = workspaceGlobs(p);
80
+ if (g.length > 0) {
81
+ workspaceRoot = dir;
82
+ globs = g;
83
+ break;
84
+ }
85
+ }
86
+ const parent = join(dir, "..");
87
+ if (parent === dir)
88
+ break;
89
+ dir = parent;
90
+ }
91
+ if (!workspaceRoot)
92
+ return null;
93
+ const dirs = [];
94
+ for (const g of globs) {
95
+ if (g.endsWith("/*")) {
96
+ const parent = join(workspaceRoot, g.slice(0, -2));
97
+ for (const e of await readdir(parent, { withFileTypes: true }).catch(() => []))
98
+ if (e.isDirectory())
99
+ dirs.push(join(parent, e.name));
100
+ }
101
+ else if (!g.includes("*")) {
102
+ dirs.push(join(workspaceRoot, g));
103
+ }
104
+ }
105
+ const packages = [];
106
+ const local = new Set();
107
+ const read = [];
108
+ for (const d of dirs) {
109
+ const p = await manifest(d);
110
+ if (!p)
111
+ continue;
112
+ read.push({ p, d });
113
+ if (typeof p.name === "string")
114
+ local.add(p.name);
115
+ }
116
+ for (const { p, d } of read) {
117
+ const { declarations, files } = await weigh(d);
118
+ const deps = {
119
+ ...(p.dependencies ?? {}),
120
+ ...(p.devDependencies ?? {}),
121
+ };
122
+ packages.push({
123
+ rel: relative(workspaceRoot, d).split("\\").join("/"),
124
+ name: typeof p.name === "string" ? p.name : null,
125
+ declarations,
126
+ files,
127
+ dependsOn: Object.keys(deps).filter((k) => local.has(k)),
128
+ });
129
+ }
130
+ if (packages.length === 0)
131
+ return null;
132
+ const rel = relative(workspaceRoot, root).split("\\").join("/");
133
+ const ranked = [...packages].sort((a, b) => b.declarations - a.declarations);
134
+ return {
135
+ workspaceRoot,
136
+ here: rel === "" ? null : rel,
137
+ packages,
138
+ vocabulary: ranked[0]?.declarations > 0 ? ranked[0] : null,
139
+ };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.250",
3
+ "version": "0.16.252",
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": {