trackfw 3.1.0 → 5.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 +85 -1
- package/package.json +1 -1
- package/src/commands/barrier.js +386 -0
- package/src/commands/help.js +99 -9
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +10 -2
- package/src/commands/integrations.js +9 -0
- package/src/commands/update-harness.js +208 -0
- package/src/commands/update.js +206 -72
- package/src/generators/init.js +126 -38
- package/src/generators/roadmap.js +125 -0
- package/src/integrations/assets/agents/architect.md +4 -1
- package/src/integrations/assets/agents/backend.md +3 -3
- package/src/integrations/assets/agents/code-quality.md +3 -0
- package/src/integrations/assets/agents/data.md +3 -3
- package/src/integrations/assets/agents/dba.md +3 -3
- package/src/integrations/assets/agents/frontend.md +3 -3
- package/src/integrations/assets/agents/iac.md +3 -3
- package/src/integrations/assets/agents/infra.md +3 -3
- package/src/integrations/assets/agents/qa.md +3 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/assets/agents/tooling.md +3 -3
- package/src/integrations/assets/agents/ux.md +3 -0
- package/src/integrations/catalog.js +25 -1
- package/src/integrations/index.js +21 -3
- package/src/integrations/manager.js +34 -5
- package/src/lib/update-engine.js +217 -0
- package/src/validator/index.js +2 -0
|
@@ -31,4 +31,28 @@ function readAsset(item) {
|
|
|
31
31
|
return fs.readFileSync(path.join(ASSET_ROOT, relative), 'utf8')
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
// truncateBeforeIdSegment drops the "{{id}}"-bearing path segment and
|
|
35
|
+
// everything after it, returning the shared parent directory — mirrors
|
|
36
|
+
// internal/integrations/plan.go:truncateBeforeIDSegment.
|
|
37
|
+
function truncateBeforeIdSegment(p) {
|
|
38
|
+
const segments = p.split('/')
|
|
39
|
+
const index = segments.findIndex(segment => segment.includes('{{id}}'))
|
|
40
|
+
if (index === -1) return p
|
|
41
|
+
return segments.slice(0, index).join('/')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// globalGroupPath returns the tilde-abbreviated directory shared by every
|
|
45
|
+
// catalog item of (toolId, kind) at global scope, derived from the catalog's
|
|
46
|
+
// own path template rather than any individual installed plan's destination
|
|
47
|
+
// — so the reported path never depends on catalog item iteration order. See
|
|
48
|
+
// docs/cli-parity.md, "Declared harness targets — pinned list", and the Go
|
|
49
|
+
// counterpart internal/integrations/plan.go:GlobalGroupPath.
|
|
50
|
+
function globalGroupPath(toolId, kind) {
|
|
51
|
+
const targetEntry = target(toolId)
|
|
52
|
+
const surface = surfaceFor(targetEntry, undefined, kind)
|
|
53
|
+
const installPath = surface.paths[kind].find(entry => entry.scope === 'global')
|
|
54
|
+
if (!installPath) throw new Error(`target ${toolId} has no global ${kind} path`)
|
|
55
|
+
return truncateBeforeIdSegment(installPath.path)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { catalog, items, target, surfaceFor, readAsset, globalGroupPath }
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const os = require('node:os')
|
|
4
4
|
|
|
5
|
-
const { catalog, items, target, surfaceFor, readAsset } = require('./catalog')
|
|
5
|
+
const { catalog, items, target, surfaceFor, readAsset, globalGroupPath } = require('./catalog')
|
|
6
6
|
const { render } = require('./render')
|
|
7
7
|
const { IntegrationManager } = require('./manager')
|
|
8
8
|
const { legacyHashes } = require('./legacy')
|
|
9
9
|
const identityStore = require('../identity')
|
|
10
|
+
const { injectRulesForTool } = require('../generators/init')
|
|
10
11
|
|
|
11
12
|
function parseSurfaces(values = []) {
|
|
12
13
|
const result = {}
|
|
@@ -100,14 +101,31 @@ function execute(kind, operation, options = {}, roots = {}) {
|
|
|
100
101
|
const homeRoot = roots.homeRoot || os.homedir()
|
|
101
102
|
const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
|
|
102
103
|
const plans = buildPlans(kind, { ...options, identity: identityConfig })
|
|
103
|
-
const manager = new IntegrationManager(roots)
|
|
104
|
+
const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
|
|
104
105
|
let statuses
|
|
105
106
|
if (operation === 'list') statuses = manager.inspect(plans)
|
|
106
107
|
else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
|
|
107
108
|
else if (operation === 'update') statuses = manager.update(plans, { force: options.force })
|
|
108
109
|
else if (operation === 'uninstall') statuses = manager.uninstall(plans, { force: options.force })
|
|
109
110
|
else throw new Error(`Unsupported integration operation: ${operation}`)
|
|
111
|
+
|
|
112
|
+
// Auxiliary rules files (GEMINI.md, .github/copilot-instructions.md,
|
|
113
|
+
// .windsurfrules, .amazonq/developer/guidelines.md, etc.) are outside the
|
|
114
|
+
// agents/skills catalog managed by IntegrationManager above — they are a
|
|
115
|
+
// separate, tool-specific mechanism (injectRulesForTool), and this is the
|
|
116
|
+
// canonical catalog-based install path (`trackfw agents|skills install
|
|
117
|
+
// --targets <tool>`). Mirrors internal/commands/integrations_flags.go:
|
|
118
|
+
// executeIntegrationMutation (ML-5E/ML-5G of ROADMAP-2026-07-29-barrier-
|
|
119
|
+
// governanca-e-autoridade-do-orquestrador). Scoped to "install" only,
|
|
120
|
+
// mirroring the one-shot semantics the removed deprecated CLI aliases had.
|
|
121
|
+
// injectRulesForTool no-ops for targets without a rules surface (e.g.
|
|
122
|
+
// antigravity, kiro) and is idempotent for repeated runs.
|
|
123
|
+
if (operation === 'install') {
|
|
124
|
+
const targetValues = options.targets && options.targets.length ? options.targets : catalog.targets.map(entry => entry.id)
|
|
125
|
+
for (const targetID of targetValues) injectRulesForTool(targetID, manager.roots.project)
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
return result(kind, plans, statuses)
|
|
111
129
|
}
|
|
112
130
|
|
|
113
|
-
module.exports = { catalog, buildPlans, execute, IntegrationManager, parseSurfaces }
|
|
131
|
+
module.exports = { catalog, buildPlans, execute, IntegrationManager, parseSurfaces, globalGroupPath }
|
|
@@ -6,6 +6,7 @@ const os = require('node:os')
|
|
|
6
6
|
const path = require('node:path')
|
|
7
7
|
|
|
8
8
|
const { frontmatterName } = require('./render')
|
|
9
|
+
const { tildeify } = require('../lib/update-engine')
|
|
9
10
|
|
|
10
11
|
const SCHEMA_VERSION = 1
|
|
11
12
|
const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
|
|
@@ -13,8 +14,9 @@ const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind,
|
|
|
13
14
|
const cleanClaim = claim => ({ target: claim.target, surface: claim.surface, scope: claim.scope, kind: claim.kind, item: claim.item })
|
|
14
15
|
|
|
15
16
|
class IntegrationManager {
|
|
16
|
-
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}) {
|
|
17
|
+
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
17
18
|
this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
|
|
19
|
+
this.onSkip = onSkip
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
manifestPath(scope) { return path.join(this.roots[scope], '.trackfw', 'integrations-manifest.json') }
|
|
@@ -127,17 +129,33 @@ class IntegrationManager {
|
|
|
127
129
|
const manifests = new Map()
|
|
128
130
|
for (const { plan } of resolved) if (!manifests.has(plan.claim.scope)) manifests.set(plan.claim.scope, this.loadManifest(plan.claim.scope))
|
|
129
131
|
const desiredByFile = new Map()
|
|
132
|
+
const skippedFiles = new Set()
|
|
130
133
|
for (const item of resolved) {
|
|
131
134
|
const desired = sha256(item.plan.content)
|
|
132
135
|
if (operation !== 'uninstall' && desiredByFile.has(item.file) && desiredByFile.get(item.file) !== desired) throw new Error(`Conflicting content planned for: ${item.file}`)
|
|
133
136
|
desiredByFile.set(item.file, desired)
|
|
134
|
-
this.preflight(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
137
|
+
const skip = this.preflight(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
138
|
+
if (skip) {
|
|
139
|
+
skippedFiles.add(item.file)
|
|
140
|
+
if (typeof this.onSkip === 'function') {
|
|
141
|
+
// O manager compõe a linha completa e passa tilde-abreviado como
|
|
142
|
+
// destination (contrato: docs/cli-parity.md, "Valor de cada
|
|
143
|
+
// parâmetro — pinado"). A remediação é derivada de
|
|
144
|
+
// plan.claim.scope por artefato, nunca de inferência sobre o
|
|
145
|
+
// caminho renderizado.
|
|
146
|
+
const abbrev = this.tildeAbbrev(item.file, item.plan.claim.scope)
|
|
147
|
+
const remediation = item.plan.claim.scope === 'global' ? 'trackfw update harness' : 'trackfw update'
|
|
148
|
+
const reason = `warning: skipping outdated artifact ${abbrev}; run '${remediation}' to refresh it`
|
|
149
|
+
this.onSkip(abbrev, reason)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
135
152
|
}
|
|
153
|
+
const active = resolved.filter(item => !skippedFiles.has(item.file))
|
|
136
154
|
const snapshots = new Map()
|
|
137
|
-
for (const item of
|
|
155
|
+
for (const item of active) this.snapshot(snapshots, item.file)
|
|
138
156
|
for (const scope of manifests.keys()) this.snapshot(snapshots, this.manifestPath(scope))
|
|
139
157
|
try {
|
|
140
|
-
for (const item of
|
|
158
|
+
for (const item of active) this.apply(operation, item, manifests.get(item.plan.claim.scope), force)
|
|
141
159
|
for (const [scope, manifest] of [...manifests].sort(([a], [b]) => a.localeCompare(b))) this.saveManifest(scope, manifest)
|
|
142
160
|
} catch (error) {
|
|
143
161
|
this.rollback(snapshots)
|
|
@@ -153,13 +171,14 @@ class IntegrationManager {
|
|
|
153
171
|
const owned = Boolean(record && record.claims.some(claim => claimKey(claim) === claimKey(plan.claim)))
|
|
154
172
|
if (operation === 'install') {
|
|
155
173
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
156
|
-
if (status.state === 'outdated' && owned && !force)
|
|
174
|
+
if (status.state === 'outdated' && owned && !force) return true // skip: bytes preserved, batch continues
|
|
157
175
|
} else if (operation === 'update') {
|
|
158
176
|
if (!owned && status.state === 'modified') throw new Error(`Unmanaged artifact does not match a trackfw template: ${file}`)
|
|
159
177
|
if (status.state === 'modified' && !force) throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
160
178
|
} else if (operation === 'uninstall' && owned && status.state === 'modified' && !force) {
|
|
161
179
|
throw new Error(`Artifact is modified; use --force: ${file}`)
|
|
162
180
|
}
|
|
181
|
+
return false
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
// detectNameCollision protege contra dois artefatos de agente gerenciados
|
|
@@ -273,6 +292,16 @@ class IntegrationManager {
|
|
|
273
292
|
}
|
|
274
293
|
}
|
|
275
294
|
|
|
295
|
+
// tildeAbbrev — retorna o caminho de exibição abreviado para uso em mensagens
|
|
296
|
+
// de aviso de skip. Espelha internal/integrations/manager.go:tildeAbbrev.
|
|
297
|
+
// - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
|
|
298
|
+
// de barra dupla corrigida no ML-6H).
|
|
299
|
+
// - scope de projeto: caminho relativo ao projectRoot, sem './' prefixo.
|
|
300
|
+
tildeAbbrev(file, scope) {
|
|
301
|
+
if (scope === 'global') return tildeify(this.roots.global, file)
|
|
302
|
+
return path.relative(this.roots.project, file)
|
|
303
|
+
}
|
|
304
|
+
|
|
276
305
|
cleanEmpty(directory, root) {
|
|
277
306
|
while (directory !== root) {
|
|
278
307
|
const rel = path.relative(root, directory)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// update-engine.js — shared state machine for `trackfw update` and
|
|
4
|
+
// `trackfw update harness` (ML-6C of ROADMAP-2026-07-29-barrier-governanca-
|
|
5
|
+
// e-autoridade-do-orquestrador). Both commands report one of exactly four
|
|
6
|
+
// states per target (`updated`, `skipped`, `missing`, `failed`) and emit the
|
|
7
|
+
// same JSON document shape — see docs/cli-parity.md, section
|
|
8
|
+
// "`trackfw update` vs `trackfw update harness`" for the frozen contract.
|
|
9
|
+
//
|
|
10
|
+
// Key invariant enforced here: a `missing` target is NEVER installed unless
|
|
11
|
+
// `installMissing` is explicitly true. This is the single most
|
|
12
|
+
// safety-critical rule in the contract, so it is centralized in
|
|
13
|
+
// `runFileTarget` rather than re-implemented per target.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs')
|
|
16
|
+
const os = require('os')
|
|
17
|
+
const path = require('path')
|
|
18
|
+
const crypto = require('crypto')
|
|
19
|
+
|
|
20
|
+
function sha256(buf) {
|
|
21
|
+
return crypto.createHash('sha256').update(buf).digest('hex')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function collectDirEntries(dir) {
|
|
25
|
+
const out = []
|
|
26
|
+
function walk(current, rel) {
|
|
27
|
+
let entries
|
|
28
|
+
try {
|
|
29
|
+
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const childRel = rel ? path.join(rel, entry.name) : entry.name
|
|
36
|
+
const childAbs = path.join(current, entry.name)
|
|
37
|
+
if (entry.isDirectory()) walk(childAbs, childRel)
|
|
38
|
+
else out.push([childRel, sha256(fs.readFileSync(childAbs))])
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
walk(dir, '')
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// hashPath — returns null when the path does not exist, a content hash for
|
|
46
|
+
// a file, or a hash of the recursive (relative-path, content-hash) listing
|
|
47
|
+
// for a directory. Used to detect "nothing changed" without caring whether
|
|
48
|
+
// the target is a single file or a directory of generated artifacts.
|
|
49
|
+
function hashPath(target) {
|
|
50
|
+
if (!fs.existsSync(target)) return null
|
|
51
|
+
const stat = fs.lstatSync(target)
|
|
52
|
+
if (stat.isDirectory()) return JSON.stringify(collectDirEntries(target))
|
|
53
|
+
return sha256(fs.readFileSync(target))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function copyPath(src, dst) {
|
|
57
|
+
if (!fs.existsSync(src)) return
|
|
58
|
+
const stat = fs.lstatSync(src)
|
|
59
|
+
if (stat.isDirectory()) {
|
|
60
|
+
fs.mkdirSync(dst, { recursive: true })
|
|
61
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
62
|
+
copyPath(path.join(src, entry.name), path.join(dst, entry.name))
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
66
|
+
fs.copyFileSync(src, dst)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// silenceConsole — apply()s reused from generators/init.js and friends log
|
|
71
|
+
// their own progress via console.log; those messages are only meaningful
|
|
72
|
+
// against the real root, not the scratch copy used to predict --dry-run
|
|
73
|
+
// state. Suppressed during simulation only.
|
|
74
|
+
function silenceConsole(fn) {
|
|
75
|
+
const original = console.log
|
|
76
|
+
console.log = () => {}
|
|
77
|
+
try {
|
|
78
|
+
return fn()
|
|
79
|
+
} finally {
|
|
80
|
+
console.log = original
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* runFileTarget — computes updated/skipped/missing/failed for a target whose
|
|
86
|
+
* only observable effect is writing under a fixed set of paths (files or
|
|
87
|
+
* directories) relative to `root`, by diffing content hashes before/after
|
|
88
|
+
* invoking `apply(root)`.
|
|
89
|
+
*
|
|
90
|
+
* - `--dry-run`: `apply` runs against a scratch temp directory seeded only
|
|
91
|
+
* with copies of the paths that already exist under `root` — the real
|
|
92
|
+
* `root` is never touched.
|
|
93
|
+
* - real run: `apply` runs directly against `root`.
|
|
94
|
+
* - `missing` (nothing present under any of `relPaths`) never triggers
|
|
95
|
+
* `apply` unless `installMissing` is true.
|
|
96
|
+
*/
|
|
97
|
+
function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, installMissing }) {
|
|
98
|
+
const before = relPaths.map(rel => hashPath(path.join(root, rel)))
|
|
99
|
+
const allMissingBefore = before.every(h => h === null)
|
|
100
|
+
|
|
101
|
+
if (allMissingBefore && !installMissing) {
|
|
102
|
+
return { id, state: 'missing', path: displayPath }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
let after
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trackfw-update-'))
|
|
109
|
+
try {
|
|
110
|
+
for (const rel of relPaths) copyPath(path.join(root, rel), path.join(tmp, rel))
|
|
111
|
+
silenceConsole(() => apply(tmp))
|
|
112
|
+
after = relPaths.map(rel => hashPath(path.join(tmp, rel)))
|
|
113
|
+
} finally {
|
|
114
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
apply(root)
|
|
118
|
+
after = relPaths.map(rel => hashPath(path.join(root, rel)))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const allMissingAfter = after.every(h => h === null)
|
|
122
|
+
if (allMissingBefore && allMissingAfter) return { id, state: 'missing', path: displayPath }
|
|
123
|
+
const unchanged = before.length === after.length && before.every((h, i) => h === after[i])
|
|
124
|
+
return { id, state: unchanged ? 'skipped' : 'updated', path: displayPath }
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return { id, state: 'failed', path: displayPath, message: e.message }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function summarize(targets) {
|
|
131
|
+
const summary = { updated: 0, skipped: 0, missing: 0, failed: 0 }
|
|
132
|
+
for (const t of targets) summary[t.state] += 1
|
|
133
|
+
return summary
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// tildeify — the contract's JSON example shows global paths abbreviated as
|
|
137
|
+
// `~/...`; emitting the absolute path would both break byte-parity with the
|
|
138
|
+
// other runtimes and leak the local username into committed/shared output.
|
|
139
|
+
//
|
|
140
|
+
// Both operands are run through path.normalize before comparison: a raw
|
|
141
|
+
// process.env.HOME can contain a redundant separator (e.g. macOS's default
|
|
142
|
+
// $TMPDIR ends in "/", so a test harness building HOME as `"$TMPDIR/foo"`
|
|
143
|
+
// yields ".../T//foo"), while `absPath` here is always produced via
|
|
144
|
+
// path.join, which normalizes internally — comparing the two without
|
|
145
|
+
// normalizing homeRoot first made the prefix check silently fail and fall
|
|
146
|
+
// through to the absolute-path branch. Mirrors Python's _tildeify
|
|
147
|
+
// (pypi/trackfw/commands/update_harness.py), which normalizes both sides
|
|
148
|
+
// via os.path.normpath for the same reason.
|
|
149
|
+
//
|
|
150
|
+
// A second, subtler case (ML-6H): path.normalize PRESERVES a trailing
|
|
151
|
+
// separator when the input already has one (macOS's default $TMPDIR itself
|
|
152
|
+
// ends in "/", so a HOME built as "$TMPDIR/foo" collapses the internal
|
|
153
|
+
// "//" but still ends in a single trailing "/"). The prefix check below
|
|
154
|
+
// then appended ANOTHER path.sep, comparing against ".../foo//" — which
|
|
155
|
+
// normalizedPath (which never carries a trailing separator, since it comes
|
|
156
|
+
// from path.join) never starts with — so the check silently failed and
|
|
157
|
+
// fell through to the absolute-path branch even though homeRoot had
|
|
158
|
+
// already been normalized. Stripping a trailing separator from
|
|
159
|
+
// normalizedHome (root "/" excepted) before the prefix check closes this.
|
|
160
|
+
function tildeify(homeRoot, absPath) {
|
|
161
|
+
let normalizedHome = path.normalize(homeRoot)
|
|
162
|
+
if (normalizedHome.length > path.sep.length && normalizedHome.endsWith(path.sep)) {
|
|
163
|
+
normalizedHome = normalizedHome.slice(0, -path.sep.length)
|
|
164
|
+
}
|
|
165
|
+
const normalizedPath = path.normalize(absPath)
|
|
166
|
+
if (normalizedPath === normalizedHome) return '~'
|
|
167
|
+
if (normalizedPath.startsWith(normalizedHome + path.sep)) return '~' + normalizedPath.slice(normalizedHome.length)
|
|
168
|
+
return normalizedPath
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// validateTargets — throws (usage error) when the caller asked for an id
|
|
172
|
+
// that does not exist in the declared universe for this command. Returns
|
|
173
|
+
// the requested id list unchanged (or null when no filter was requested).
|
|
174
|
+
function validateTargets(allIds, wanted) {
|
|
175
|
+
if (!wanted || !wanted.length) return null
|
|
176
|
+
for (const id of wanted) {
|
|
177
|
+
if (!allIds.includes(id)) throw new Error(`Unknown update target: ${id}`)
|
|
178
|
+
}
|
|
179
|
+
return wanted
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function buildDocument(scope, dryRun, targets) {
|
|
183
|
+
return {
|
|
184
|
+
scope,
|
|
185
|
+
dry_run: dryRun,
|
|
186
|
+
targets: targets.map(t => {
|
|
187
|
+
const entry = { id: t.id, state: t.state, path: t.path }
|
|
188
|
+
if (t.state === 'failed' && t.message) entry.message = t.message
|
|
189
|
+
return entry
|
|
190
|
+
}),
|
|
191
|
+
summary: summarize(targets),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function humanReport(scope, dryRun, targets) {
|
|
196
|
+
const lines = [`trackfw update — scope: ${scope}${dryRun ? ' (dry-run)' : ''}`, '']
|
|
197
|
+
for (const t of targets) {
|
|
198
|
+
const mark = { updated: '✓', skipped: '·', missing: '—', failed: '✗' }[t.state] || '?'
|
|
199
|
+
const suffix = t.state === 'failed' && t.message ? ` (${t.message})` : ''
|
|
200
|
+
lines.push(` ${mark} ${t.id.padEnd(24)} ${t.state.padEnd(8)} ${t.path}${suffix}`)
|
|
201
|
+
}
|
|
202
|
+
const summary = summarize(targets)
|
|
203
|
+
lines.push('', `updated=${summary.updated} skipped=${summary.skipped} missing=${summary.missing} failed=${summary.failed}`)
|
|
204
|
+
return lines.join('\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
hashPath,
|
|
209
|
+
copyPath,
|
|
210
|
+
runFileTarget,
|
|
211
|
+
summarize,
|
|
212
|
+
tildeify,
|
|
213
|
+
validateTargets,
|
|
214
|
+
buildDocument,
|
|
215
|
+
humanReport,
|
|
216
|
+
silenceConsole,
|
|
217
|
+
}
|
package/src/validator/index.js
CHANGED