trackfw 2.15.1 → 3.0.0
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/README.md +183 -0
- package/package.json +1 -1
- package/src/commands/identity-wizard.js +258 -0
- package/src/commands/init.js +65 -4
- package/src/commands/integrations.js +133 -6
- package/src/commands/update.js +8 -1
- package/src/generators/init.js +28 -5
- package/src/i18n/locales/en-US.json +12 -1
- package/src/i18n/locales/es-ES.json +12 -1
- package/src/i18n/locales/pt-BR.json +12 -1
- package/src/identity/config.js +134 -0
- package/src/identity/index.js +19 -0
- package/src/identity/preset.js +197 -0
- package/src/identity/slug.js +62 -0
- package/src/integrations/index.js +12 -2
- package/src/integrations/manager.js +50 -0
- package/src/integrations/render.js +149 -14
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const os = require('node:os')
|
|
3
4
|
const { Command } = require('commander')
|
|
4
|
-
const { catalog, execute, parseSurfaces } = require('../integrations')
|
|
5
|
+
const { catalog, execute, parseSurfaces, buildPlans } = require('../integrations')
|
|
6
|
+
const identityStore = require('../identity')
|
|
7
|
+
const identityWizard = require('./identity-wizard')
|
|
8
|
+
const { t } = require('../i18n')
|
|
5
9
|
|
|
6
10
|
const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
|
|
7
11
|
const collect = (value, previous) => previous.concat(value)
|
|
@@ -23,6 +27,66 @@ async function promptSelection(kind, options, prompts = require('@inquirer/promp
|
|
|
23
27
|
options.items = await checkbox({ message: `${kind} to manage`, choices: catalog[kind].map(item => ({ name: item.name, value: item.id })), required: true })
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
// resolveScope decide o escopo de instalação (ADR-2026-07-25-escopo-de-
|
|
31
|
+
// instalacao-selecionavel-para-agents-e-skills):
|
|
32
|
+
//
|
|
33
|
+
// - `--scope` explícito (`options.scope !== undefined`) é sempre respeitado
|
|
34
|
+
// e nunca dispara prompt — apenas validado contra os valores aceitos. A
|
|
35
|
+
// detecção é por *flag-set* (undefined), nunca por comparação de valor,
|
|
36
|
+
// para não confundir um `--scope project` explícito com o default.
|
|
37
|
+
// - Sem flag e `interactive` desligado (comando `list`, D6): default
|
|
38
|
+
// `global`, nunca lança erro (list nunca é destrutivo).
|
|
39
|
+
// - Sem flag e stdin não é um TTY (D1): default `global` — exceto para
|
|
40
|
+
// `uninstall` (ADR D8), que falha exigindo `--scope` explícito. D1 foi
|
|
41
|
+
// aprovado no enquadramento "onde instalar"; aplicá-lo a uninstall
|
|
42
|
+
// permitiria que um script de CI que hoje limpa `.claude/agents/` do
|
|
43
|
+
// repositório passasse a apagar arquivos do home do usuário.
|
|
44
|
+
// - Sem flag, `interactive` ligado e TTY (D2): pergunta o escopo, com
|
|
45
|
+
// `global` pré-selecionado, para toda operação incluindo uninstall — o
|
|
46
|
+
// usuário vê a escolha antes de destruir. Usa o mesmo mecanismo de prompt
|
|
47
|
+
// (`@inquirer/prompts`) já empregado por `identity-wizard.js`, sem
|
|
48
|
+
// dependência nova.
|
|
49
|
+
//
|
|
50
|
+
// `interactive` é passado pelo chamador como `false` para `list` — comando de
|
|
51
|
+
// leitura que nunca deve bloquear em prompt, mas que ainda assim precisa do
|
|
52
|
+
// mesmo default `global` para não reportar deployments divergentes dos que o
|
|
53
|
+
// `install` gravou (D6). `operation` identifica install/update/uninstall/list
|
|
54
|
+
// para o branch de D8. Espelha
|
|
55
|
+
// internal/commands/integrations_flags.go:resolveScope.
|
|
56
|
+
async function resolveScope(options, { interactive = true, operation, prompts = require('@inquirer/prompts') } = {}) {
|
|
57
|
+
if (options.scope !== undefined) {
|
|
58
|
+
if (options.scope !== 'project' && options.scope !== 'global') throw new Error(`Unsupported scope: ${options.scope}`)
|
|
59
|
+
return options.scope
|
|
60
|
+
}
|
|
61
|
+
if (!interactive) return 'global'
|
|
62
|
+
if (!process.stdin.isTTY) {
|
|
63
|
+
if (operation === 'uninstall') throw new Error('uninstall requires --scope in non-interactive mode')
|
|
64
|
+
return 'global'
|
|
65
|
+
}
|
|
66
|
+
const { select } = prompts
|
|
67
|
+
return select({
|
|
68
|
+
message: 'Onde instalar os artefatos?',
|
|
69
|
+
default: 'global',
|
|
70
|
+
choices: [
|
|
71
|
+
{ name: 'Pasta do usuário (~/.claude) — vale para todos os projetos', value: 'global' },
|
|
72
|
+
{ name: 'Este projeto (.claude) — apenas neste repositório', value: 'project' },
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// printResolvedDestinations imprime, antes da gravação, o escopo escolhido e
|
|
78
|
+
// os caminhos de destino que serão afetados (ADR D5) — nunca em modo --json,
|
|
79
|
+
// que já é o canal determinístico consumido por scripts. Recalcula os planos
|
|
80
|
+
// via buildPlans (sem side effect) só para enumerar destinos; a gravação em
|
|
81
|
+
// si acontece depois, em execute().
|
|
82
|
+
function printResolvedDestinations(kind, options) {
|
|
83
|
+
if (options.json) return
|
|
84
|
+
const plans = buildPlans(kind, options)
|
|
85
|
+
const destinations = [...new Set(plans.map(plan => plan.destination))].sort()
|
|
86
|
+
console.log(`Destino (${options.scope}):`)
|
|
87
|
+
for (const destination of destinations) console.log(` ${destination}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
26
90
|
async function promptAmbiguousSurfaces(kind, options, prompts = require('@inquirer/prompts')) {
|
|
27
91
|
const { select } = prompts
|
|
28
92
|
const selected = parseSurfaces(options.surfaces)
|
|
@@ -39,23 +103,86 @@ async function promptAmbiguousSurfaces(kind, options, prompts = require('@inquir
|
|
|
39
103
|
function createLifecycleCommand(kind) {
|
|
40
104
|
const root = new Command(kind).description(`Manage trackfw ${kind}`)
|
|
41
105
|
for (const operation of ['list', 'install', 'uninstall', 'update']) {
|
|
106
|
+
const mutation = operation !== 'list'
|
|
42
107
|
const command = new Command(operation)
|
|
43
108
|
.option('--targets <targets>', 'Comma-separated target CLIs', csv)
|
|
44
109
|
.option('--items <items>', `Comma-separated ${kind} IDs`, csv)
|
|
45
|
-
.option('--scope <scope>', 'Installation scope: project or global
|
|
110
|
+
.option('--scope <scope>', 'Installation scope: project or global (default: global; asks interactively)')
|
|
46
111
|
.option('--surface <target=surface>', 'Surface selection (repeatable)', collect, [])
|
|
47
112
|
.option('--json', 'Print deterministic JSON')
|
|
48
113
|
.option('--force', 'Replace or remove modified artifacts')
|
|
49
|
-
|
|
114
|
+
|
|
115
|
+
// Flags de identidade são exclusivas de agents (ADR D5): skills não têm
|
|
116
|
+
// identidade, e createLifecycleCommand é compartilhado entre `agents` e
|
|
117
|
+
// `skills` — sem este filtro por kind, `trackfw skills install
|
|
118
|
+
// --identity` aceitaria silenciosamente uma flag sem nenhum efeito.
|
|
119
|
+
// Espelha internal/commands/integrations_flags.go:addIntegrationFlags.
|
|
120
|
+
if (mutation && kind === 'agents') {
|
|
121
|
+
command
|
|
122
|
+
.option('--identity', 'Reconfigure agent identity even if ~/.trackfw/identity.json already exists')
|
|
123
|
+
.option('--identity-preset <preset>', `Agent identity preset (non-interactive): none, neutral, ${identityStore.presetNames().join(', ')}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
command.action(async (options, cmd) => {
|
|
50
127
|
options.surfaces = options.surface || []
|
|
51
|
-
|
|
52
|
-
|
|
128
|
+
|
|
129
|
+
// Gate de escopo (ADR D1-D3, D6): resolvido incondicionalmente aqui,
|
|
130
|
+
// antes de qualquer seleção de targets/surfaces ou construção de
|
|
131
|
+
// planos, e independentemente de --targets já ter sido informado —
|
|
132
|
+
// caso contrário o caso mais comum (`agents install --targets claude`)
|
|
133
|
+
// nunca passaria por prompt algum. `list` (mutation === false) nunca
|
|
134
|
+
// pergunta (comando de leitura), apenas adota o default `global`.
|
|
135
|
+
options.scope = await resolveScope(options, { interactive: mutation, operation })
|
|
136
|
+
|
|
137
|
+
// O booleano de --identity nunca deve chegar a execute()/buildPlans()
|
|
138
|
+
// sob a chave "identity" — essa chave ali é reservada para uma Config
|
|
139
|
+
// de identidade já resolvida (ver src/integrations/index.js:execute).
|
|
140
|
+
// Colidir os dois faria "agents install --identity" renderizar uma
|
|
141
|
+
// identidade booleana nos artefatos em vez da Config real.
|
|
142
|
+
const forceIdentity = options.identity === true
|
|
143
|
+
delete options.identity
|
|
144
|
+
|
|
145
|
+
// --identity-preset é validado e persistido incondicionalmente, acima
|
|
146
|
+
// de qualquer ramo dependente de TTY abaixo — isso é o que faz um
|
|
147
|
+
// --identity-preset inválido falhar alto em CI em vez de
|
|
148
|
+
// silenciosamente não fazer nada. Espelha init.js e
|
|
149
|
+
// internal/commands/integrations_flags.go:executeIntegrationMutation.
|
|
150
|
+
let presetChanged = false
|
|
151
|
+
if (mutation && kind === 'agents') {
|
|
152
|
+
presetChanged = cmd.getOptionValueSource('identityPreset') === 'cli'
|
|
153
|
+
if (presetChanged) identityWizard.applyIdentityPresetFlag(os.homedir(), options.identityPreset, operation)
|
|
154
|
+
}
|
|
155
|
+
|
|
53
156
|
if (mutation && (!options.targets || !options.targets.length)) {
|
|
54
157
|
if (!process.stdin.isTTY) throw new Error(`${operation} requires --targets in non-interactive mode`)
|
|
55
158
|
await promptSelection(kind, options)
|
|
56
159
|
}
|
|
57
160
|
if (mutation && process.stdin.isTTY) await promptAmbiguousSurfaces(kind, options)
|
|
58
161
|
options.allSurfaces = operation === 'list'
|
|
162
|
+
|
|
163
|
+
// Disparo do wizard de identidade (ADR D2): mostrado somente quando o
|
|
164
|
+
// caminho da flag acima ainda não resolveu a identidade desta
|
|
165
|
+
// execução, e somente para agents (nunca skills, D5). Roda depois da
|
|
166
|
+
// seleção de alvo/superfície e antes de execute() para que a
|
|
167
|
+
// identidade recém-gravada pelo wizard seja a que é renderizada nos
|
|
168
|
+
// planos abaixo. Espelha
|
|
169
|
+
// internal/commands/integrations_flags.go:executeIntegrationMutation.
|
|
170
|
+
if (mutation && kind === 'agents' && !presetChanged) {
|
|
171
|
+
const homeRoot = os.homedir()
|
|
172
|
+
const identityExists = identityWizard.identityFileExists(homeRoot)
|
|
173
|
+
const isTTY = Boolean(process.stdin.isTTY)
|
|
174
|
+
if (identityWizard.shouldPromptIdentity(kind, isTTY, identityExists, forceIdentity)) {
|
|
175
|
+
await identityWizard.runIdentityWizard(catalog, homeRoot)
|
|
176
|
+
} else if (identityExists && !options.json) {
|
|
177
|
+
const existing = identityStore.load(homeRoot)
|
|
178
|
+
console.log(t('identity.inUse', { count: String(Object.keys(existing.agents || {}).length) }))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// D5: caminhos de destino impressos antes da gravação, apenas para
|
|
183
|
+
// operações de mutação (install/update/uninstall) e fora de --json.
|
|
184
|
+
if (mutation) printResolvedDestinations(kind, options)
|
|
185
|
+
|
|
59
186
|
const output = execute(kind, operation, options)
|
|
60
187
|
console.log(options.json ? JSON.stringify(output) : human(output))
|
|
61
188
|
})
|
|
@@ -64,4 +191,4 @@ function createLifecycleCommand(kind) {
|
|
|
64
191
|
return root
|
|
65
192
|
}
|
|
66
193
|
|
|
67
|
-
module.exports = { createLifecycleCommand, csv, human, promptSelection, promptAmbiguousSurfaces }
|
|
194
|
+
module.exports = { createLifecycleCommand, csv, human, promptSelection, promptAmbiguousSurfaces, resolveScope }
|
package/src/commands/update.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const { Command } = require('commander');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const identityStore = require('../identity');
|
|
6
8
|
|
|
7
9
|
function readUpdateConfig(rootDir) {
|
|
8
10
|
const yaml = path.join(rootDir, 'trackfw.yaml');
|
|
@@ -62,6 +64,11 @@ cmd.action(() => {
|
|
|
62
64
|
const generators = require('../generators/init');
|
|
63
65
|
const discover = require('./discover');
|
|
64
66
|
|
|
67
|
+
// A identidade é carregada uma única vez, fora de qualquer try/catch —
|
|
68
|
+
// um identity.json corrompido deve abortar o comando inteiro, nunca cair
|
|
69
|
+
// silenciosamente para os nomes neutros default.
|
|
70
|
+
const identityConfig = identityStore.load(os.homedir());
|
|
71
|
+
|
|
65
72
|
console.log('trackfw update — re-aplicando templates atuais...\n');
|
|
66
73
|
|
|
67
74
|
// 1. Agent rules (marker-delimited, idempotent)
|
|
@@ -77,7 +84,7 @@ cmd.action(() => {
|
|
|
77
84
|
const roots = { projectRoot: cwd };
|
|
78
85
|
const manager = new IntegrationManager(roots);
|
|
79
86
|
for (const kind of ['agents', 'skills']) {
|
|
80
|
-
const plans = buildPlans(kind, { targets: ['codex'], scope: 'project' });
|
|
87
|
+
const plans = buildPlans(kind, { targets: ['codex'], scope: 'project', identity: identityConfig });
|
|
81
88
|
const statuses = manager.inspect(plans);
|
|
82
89
|
const existing = plans.filter((_, index) => statuses[index].state !== 'not-installed');
|
|
83
90
|
manager.update(existing);
|
package/src/generators/init.js
CHANGED
|
@@ -1006,12 +1006,35 @@ async function installAmazonQ(cwd = process.cwd()) {
|
|
|
1006
1006
|
}
|
|
1007
1007
|
}
|
|
1008
1008
|
|
|
1009
|
-
/**
|
|
1010
|
-
|
|
1011
|
-
|
|
1009
|
+
/**
|
|
1010
|
+
* Install the canonical agents and skills for one AI target.
|
|
1011
|
+
*
|
|
1012
|
+
* `scope` defaults to "project" for backward compatibility with the
|
|
1013
|
+
* compatibility entrypoints below (installAgents, installGemini, etc.), that
|
|
1014
|
+
* historical tests exercise directly without going through the `init`
|
|
1015
|
+
* wizard's scope prompt. `trackfw init` itself always passes the scope it
|
|
1016
|
+
* resolved (ADR-2026-07-25-escopo-de-instalacao-selecionavel-para-agents-e-
|
|
1017
|
+
* skills, D4).
|
|
1018
|
+
*/
|
|
1019
|
+
async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project') {
|
|
1020
|
+
const { execute, buildPlans } = require('../integrations')
|
|
1012
1021
|
const roots = { projectRoot: cwd }
|
|
1013
|
-
|
|
1014
|
-
|
|
1022
|
+
const options = { targets: [target], scope }
|
|
1023
|
+
// D5 — transparency: print resolved destinations before writing anything.
|
|
1024
|
+
// buildPlans has no side effects, so it is safe to call here purely to
|
|
1025
|
+
// enumerate destinations; the actual write happens below via execute().
|
|
1026
|
+
// Mirrors printResolvedDestinations in commands/integrations.js and Go's
|
|
1027
|
+
// installAITools (internal/commands/init.go) — this call site (init's AI
|
|
1028
|
+
// tools install) was the one place that stayed silent (divergence #2,
|
|
1029
|
+
// ROADMAP-2026-07-25-escopo-de-instalacao-selecionavel-para-agents-e-skills).
|
|
1030
|
+
const destinations = [
|
|
1031
|
+
...buildPlans('agents', options).map(plan => plan.destination),
|
|
1032
|
+
...buildPlans('skills', options).map(plan => plan.destination),
|
|
1033
|
+
]
|
|
1034
|
+
console.log(`Destino (${scope}):`)
|
|
1035
|
+
for (const destination of [...new Set(destinations)].sort()) console.log(` ${destination}`)
|
|
1036
|
+
execute('agents', 'install', options, roots)
|
|
1037
|
+
execute('skills', 'install', options, roots)
|
|
1015
1038
|
console.log(` ✓ ${target} agents and skills`)
|
|
1016
1039
|
}
|
|
1017
1040
|
|
|
@@ -15,10 +15,21 @@
|
|
|
15
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
16
16
|
"projectType_frontend": "Frontend only",
|
|
17
17
|
"projectType_backend": "Backend only",
|
|
18
|
-
"projectType_governance": "Governance only (no build stack)"
|
|
18
|
+
"projectType_governance": "Governance only (no build stack)",
|
|
19
|
+
"identityPreset": "How would you like to name your agents?",
|
|
20
|
+
"identityCustomName": "Display name for the agent",
|
|
21
|
+
"identityNickname": "What should we call you? (optional)"
|
|
19
22
|
},
|
|
20
23
|
"success": "✓ trackfw initialized — run 'trackfw status' to see your governance state."
|
|
21
24
|
},
|
|
25
|
+
"identity": {
|
|
26
|
+
"inUse": "identity: {{count}} custom agent(s)",
|
|
27
|
+
"wizard": {
|
|
28
|
+
"confirmHeader": "── Confirmation ──────────────────────────────",
|
|
29
|
+
"confirmQuestion": "Confirm?",
|
|
30
|
+
"nicknameRowLabel": "What we'll call you:"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
22
33
|
"adr": {
|
|
23
34
|
"description": "Manage Architecture Decision Records",
|
|
24
35
|
"new": {
|
|
@@ -15,10 +15,21 @@
|
|
|
15
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
16
16
|
"projectType_frontend": "Solo frontend",
|
|
17
17
|
"projectType_backend": "Solo backend",
|
|
18
|
-
"projectType_governance": "Solo gobernanza (sin stack de build)"
|
|
18
|
+
"projectType_governance": "Solo gobernanza (sin stack de build)",
|
|
19
|
+
"identityPreset": "¿Cómo quieres nombrar a tus agentes?",
|
|
20
|
+
"identityCustomName": "Nombre de exhibición para el agente",
|
|
21
|
+
"identityNickname": "¿Cómo debemos llamarte? (opcional)"
|
|
19
22
|
},
|
|
20
23
|
"success": "✓ trackfw inicializado — ejecuta 'trackfw status' para ver el estado de gobernanza."
|
|
21
24
|
},
|
|
25
|
+
"identity": {
|
|
26
|
+
"inUse": "identidad: {{count}} agente(s) personalizado(s)",
|
|
27
|
+
"wizard": {
|
|
28
|
+
"confirmHeader": "── Confirmación ──────────────────────────────",
|
|
29
|
+
"confirmQuestion": "¿Confirmar?",
|
|
30
|
+
"nicknameRowLabel": "Cómo te llamaremos:"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
22
33
|
"adr": {
|
|
23
34
|
"description": "Gestionar Architecture Decision Records",
|
|
24
35
|
"new": {
|
|
@@ -15,10 +15,21 @@
|
|
|
15
15
|
"projectType_fullstack": "Full-stack (frontend + backend)",
|
|
16
16
|
"projectType_frontend": "Somente frontend",
|
|
17
17
|
"projectType_backend": "Somente backend",
|
|
18
|
-
"projectType_governance": "Somente governança (sem stack de build)"
|
|
18
|
+
"projectType_governance": "Somente governança (sem stack de build)",
|
|
19
|
+
"identityPreset": "Como você quer nomear seus agentes?",
|
|
20
|
+
"identityCustomName": "Nome de exibição para o agente",
|
|
21
|
+
"identityNickname": "Como devemos te chamar? (opcional)"
|
|
19
22
|
},
|
|
20
23
|
"success": "✓ trackfw inicializado — execute 'trackfw status' para ver o estado de governança."
|
|
21
24
|
},
|
|
25
|
+
"identity": {
|
|
26
|
+
"inUse": "identidade: {{count}} agente(s) personalizado(s)",
|
|
27
|
+
"wizard": {
|
|
28
|
+
"confirmHeader": "── Confirmação ──────────────────────────────",
|
|
29
|
+
"confirmQuestion": "Confirmar?",
|
|
30
|
+
"nicknameRowLabel": "Como você será chamado:"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
22
33
|
"adr": {
|
|
23
34
|
"description": "Gerenciar Architecture Decision Records",
|
|
24
35
|
"new": {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const fs = require('node:fs')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
|
|
7
|
+
// SCHEMA_VERSION é a versão de schema atual do arquivo de configuração de
|
|
8
|
+
// identidade. Espelha internal/identity/identity.go:schemaVersion.
|
|
9
|
+
const SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
// slugPattern casa um slug válido: segmentos alfanuméricos em minúsculas
|
|
12
|
+
// unidos por hífens simples, ex.: "zeus", "meu-agente".
|
|
13
|
+
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
14
|
+
|
|
15
|
+
function identityPath(homeDir) {
|
|
16
|
+
return path.join(homeDir, '.trackfw', 'identity.json')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// load lê a configuração de identidade de <homeDir>/.trackfw/identity.json.
|
|
20
|
+
//
|
|
21
|
+
// Se o arquivo não existir, load retorna uma Config vazia sem erro — este é
|
|
22
|
+
// o caminho de não-regressão e nunca deve surgir como falha para quem ainda
|
|
23
|
+
// não customizou sua identidade. Espelha internal/identity/identity.go:Load.
|
|
24
|
+
function load(homeDir) {
|
|
25
|
+
const filename = identityPath(homeDir)
|
|
26
|
+
let data
|
|
27
|
+
try {
|
|
28
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === 'ENOENT') return { schema_version: SCHEMA_VERSION, agents: {} }
|
|
31
|
+
throw new Error(`identity: falha ao ler ${filename}: ${err.message}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let parsed
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(data)
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new Error(`identity: falha ao decodificar ${filename}: ${err.message}`)
|
|
39
|
+
}
|
|
40
|
+
if (parsed.schema_version !== SCHEMA_VERSION) {
|
|
41
|
+
throw new Error(`identity: versao de schema nao suportada em ${filename}: ${parsed.schema_version} (esperado ${SCHEMA_VERSION})`)
|
|
42
|
+
}
|
|
43
|
+
if (!parsed.agents || typeof parsed.agents !== 'object') parsed.agents = {}
|
|
44
|
+
return parsed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// save persiste cfg em <homeDir>/.trackfw/identity.json de forma atômica.
|
|
48
|
+
// Espelha internal/identity/identity.go:Save — inclusive a omissão de
|
|
49
|
+
// user_nickname vazio e agents vazio, que resulta do comportamento
|
|
50
|
+
// omitempty do encoding/json do Go, e a ordenação alfabética das chaves do
|
|
51
|
+
// mapa agents, que resulta da ordenação de mapas do encoding/json do Go.
|
|
52
|
+
function save(homeDir, cfg) {
|
|
53
|
+
const out = { schema_version: SCHEMA_VERSION }
|
|
54
|
+
if (cfg.user_nickname) out.user_nickname = cfg.user_nickname
|
|
55
|
+
const agents = cfg.agents || {}
|
|
56
|
+
const ids = Object.keys(agents).sort()
|
|
57
|
+
if (ids.length > 0) {
|
|
58
|
+
const sortedAgents = {}
|
|
59
|
+
for (const id of ids) {
|
|
60
|
+
sortedAgents[id] = { display_name: agents[id].display_name, slug: agents[id].slug }
|
|
61
|
+
}
|
|
62
|
+
out.agents = sortedAgents
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const data = `${JSON.stringify(out, null, 2)}\n`
|
|
66
|
+
const filename = identityPath(homeDir)
|
|
67
|
+
atomicWrite(filename, data, 0o600)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// atomicWrite grava data em filename de forma atômica: cria um arquivo
|
|
71
|
+
// temporário no mesmo diretório, escreve, sincroniza e então renomeia para o
|
|
72
|
+
// lugar final. Espelha internal/identity/identity.go:atomicWrite.
|
|
73
|
+
function atomicWrite(filename, data, mode) {
|
|
74
|
+
const directory = path.dirname(filename)
|
|
75
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
76
|
+
const temporaryName = path.join(directory, `.trackfw-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
|
|
77
|
+
const fd = fs.openSync(temporaryName, 'w', mode)
|
|
78
|
+
try {
|
|
79
|
+
fs.writeSync(fd, data)
|
|
80
|
+
fs.fsyncSync(fd)
|
|
81
|
+
} finally {
|
|
82
|
+
fs.closeSync(fd)
|
|
83
|
+
}
|
|
84
|
+
fs.renameSync(temporaryName, filename)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// agentName retorna o display name customizado sufixado com "-tf". Este é o
|
|
88
|
+
// único lugar do código onde o sufixo "-tf" é aplicado a um slug. Espelha
|
|
89
|
+
// internal/identity/identity.go:AgentName.
|
|
90
|
+
function agentName(slug) {
|
|
91
|
+
return `${slug}-tf`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// validate verifica cfg quanto à integridade estrutural e referencial:
|
|
95
|
+
// - todo id de agente em cfg.agents deve estar presente em knownIds
|
|
96
|
+
// - display_name não pode ser vazio
|
|
97
|
+
// - slug deve casar com ^[a-z0-9]+(-[a-z0-9]+)*$
|
|
98
|
+
// - slugs devem ser únicos entre os agentes
|
|
99
|
+
// - slug não pode terminar com o sufixo "-tf" (ele é acrescentado
|
|
100
|
+
// automaticamente por agentName)
|
|
101
|
+
// Espelha internal/identity/identity.go:Validate.
|
|
102
|
+
function validate(cfg, knownIds) {
|
|
103
|
+
const known = new Set(knownIds)
|
|
104
|
+
const seenSlugs = new Map()
|
|
105
|
+
const agents = (cfg && cfg.agents) || {}
|
|
106
|
+
|
|
107
|
+
for (const [id, agent] of Object.entries(agents)) {
|
|
108
|
+
if (!known.has(id)) {
|
|
109
|
+
throw new Error(`identity: agente desconhecido "${id}" nao esta na lista de agentes conhecidos`)
|
|
110
|
+
}
|
|
111
|
+
if (!agent.display_name) {
|
|
112
|
+
throw new Error(`identity: display_name vazio para o agente "${id}"`)
|
|
113
|
+
}
|
|
114
|
+
if (!SLUG_PATTERN.test(agent.slug)) {
|
|
115
|
+
throw new Error(`identity: slug invalido "${agent.slug}" para o agente "${id}" (esperado padrao ${SLUG_PATTERN.source})`)
|
|
116
|
+
}
|
|
117
|
+
if (seenSlugs.has(agent.slug)) {
|
|
118
|
+
throw new Error(`identity: slug duplicado "${agent.slug}" entre os agentes "${seenSlugs.get(agent.slug)}" e "${id}"`)
|
|
119
|
+
}
|
|
120
|
+
if (agent.slug.endsWith('-tf')) {
|
|
121
|
+
throw new Error(`identity: slug "${agent.slug}" do agente "${id}" nao deve incluir o sufixo "-tf"; ele e acrescentado automaticamente (use "${agent.slug.slice(0, -3)}" em vez de "${agent.slug}")`)
|
|
122
|
+
}
|
|
123
|
+
seenSlugs.set(agent.slug, id)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// lookup retorna a identidade configurada para id, se houver. Espelha
|
|
128
|
+
// internal/identity/identity.go:Lookup.
|
|
129
|
+
function lookup(cfg, id) {
|
|
130
|
+
const agents = (cfg && cfg.agents) || {}
|
|
131
|
+
return Object.prototype.hasOwnProperty.call(agents, id) ? agents[id] : undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { SCHEMA_VERSION, load, save, agentName, validate, lookup, identityPath }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { slugify } = require('./slug')
|
|
4
|
+
const { knownAgentIds, preset, presetNames } = require('./preset')
|
|
5
|
+
const { load, save, agentName, validate, lookup, identityPath, SCHEMA_VERSION } = require('./config')
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
slugify,
|
|
9
|
+
knownAgentIds,
|
|
10
|
+
preset,
|
|
11
|
+
presetNames,
|
|
12
|
+
load,
|
|
13
|
+
save,
|
|
14
|
+
agentName,
|
|
15
|
+
validate,
|
|
16
|
+
lookup,
|
|
17
|
+
identityPath,
|
|
18
|
+
SCHEMA_VERSION,
|
|
19
|
+
}
|