trackfw 6.9.1 → 7.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 +0 -3
- package/package.json +1 -1
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +12 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +69 -0
- package/src/commands/index.js +22 -4
- package/src/commands/integrations.js +7 -0
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +3 -1
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +479 -2
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +1 -19
- package/src/i18n/locales/es-ES.json +1 -19
- package/src/i18n/locales/pt-BR.json +1 -19
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +15 -2
- package/src/integrations/render.js +102 -4
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +213 -26
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +715 -16
- package/src/commands/plugins.js +0 -184
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/provenance.go — the D6/D8c provenance
|
|
4
|
+
// record and TOCTOU-closing approval check. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D6, D8c, D8f).
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
|
|
11
|
+
const { atomicWrite } = require('./quarantine')
|
|
12
|
+
const { redactURL } = require('./markers')
|
|
13
|
+
|
|
14
|
+
// PROVENANCE_SCHEMA_VERSION mirrors internal/thirdparty/provenance.go:provenanceSchemaVersion.
|
|
15
|
+
//
|
|
16
|
+
// Bumped 1 -> 2 (ADR-2026-08-15 D2-bis, ML-3B) to add the entry field
|
|
17
|
+
// installed_sha256 (SHA-256 of the NORMALIZED bytes, computed at install
|
|
18
|
+
// time). checksum_sha256 (SHA-256 of the RAW bytes, the D8c approval
|
|
19
|
+
// anchor) is unchanged. No migration path exists or is needed: at the time
|
|
20
|
+
// of the bump no provenance file existed anywhere with schema_version 1.
|
|
21
|
+
const PROVENANCE_SCHEMA_VERSION = 2
|
|
22
|
+
|
|
23
|
+
function emptyProvenance() {
|
|
24
|
+
return { schema_version: PROVENANCE_SCHEMA_VERSION, entries: {} }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// provenancePath returns the on-disk path of the provenance file, rooted at
|
|
28
|
+
// root. Mirrors internal/thirdparty/provenance.go:ProvenancePath.
|
|
29
|
+
function provenancePath(root) {
|
|
30
|
+
return path.join(root, '.trackfw', 'thirdparty-provenance.json')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// loadProvenance reads and validates the provenance file: a missing file is
|
|
34
|
+
// a legitimate "nothing installed from a third party yet" state and returns
|
|
35
|
+
// an empty, schema-valid Provenance; invalid JSON or an unsupported
|
|
36
|
+
// schema_version are fatal and thrown as errors — never silently degraded
|
|
37
|
+
// to empty (D8f). Deliberately asymmetric with readQuarantine (see
|
|
38
|
+
// quarantine.js) — do NOT "fix" this asymmetry, it is intentional. Mirrors
|
|
39
|
+
// internal/thirdparty/provenance.go:LoadProvenance.
|
|
40
|
+
function loadProvenance(root) {
|
|
41
|
+
const filename = provenancePath(root)
|
|
42
|
+
let data
|
|
43
|
+
try {
|
|
44
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code === 'ENOENT') return emptyProvenance()
|
|
47
|
+
throw new Error(`read thirdparty provenance: ${err.message}`)
|
|
48
|
+
}
|
|
49
|
+
let prov
|
|
50
|
+
try {
|
|
51
|
+
prov = JSON.parse(data)
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`decode thirdparty provenance: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
if (prov.schema_version !== PROVENANCE_SCHEMA_VERSION) {
|
|
56
|
+
throw new Error(`unsupported thirdparty provenance schema ${prov.schema_version}`)
|
|
57
|
+
}
|
|
58
|
+
if (!prov.entries || Array.isArray(prov.entries)) prov.entries = {}
|
|
59
|
+
return prov
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// writeProvenance persists prov atomically.
|
|
63
|
+
//
|
|
64
|
+
// Failure here MUST propagate to the caller and abort the installation
|
|
65
|
+
// (D6) — the deliberate opposite of a best-effort/log-and-continue write.
|
|
66
|
+
// Provenance is the only record of who approved a third-party artifact, so
|
|
67
|
+
// losing a write silently would leave an unapproved artifact on disk
|
|
68
|
+
// indistinguishable from an approved one.
|
|
69
|
+
//
|
|
70
|
+
// Every entry's url is passed through redactURL before serialization
|
|
71
|
+
// (D6-bis) — defense-in-depth, mirroring
|
|
72
|
+
// internal/thirdparty/provenance.go:WriteProvenance's doc comment: no
|
|
73
|
+
// command in this codebase writes a provenance entry's url today (D10.2),
|
|
74
|
+
// but this call site guarantees the query string is never persisted here
|
|
75
|
+
// regardless. Idempotent: redacting an already-redacted url is a no-op.
|
|
76
|
+
function writeProvenance(root, prov) {
|
|
77
|
+
const entries = prov.entries || {}
|
|
78
|
+
const redactedEntries = {}
|
|
79
|
+
for (const [dest, entry] of Object.entries(entries)) {
|
|
80
|
+
redactedEntries[dest] = { ...entry, url: redactURL(entry.url) }
|
|
81
|
+
}
|
|
82
|
+
const toWrite = { schema_version: PROVENANCE_SCHEMA_VERSION, entries: redactedEntries }
|
|
83
|
+
const data = `${JSON.stringify(toWrite, null, 2)}\n`
|
|
84
|
+
atomicWrite(provenancePath(root), data, 0o600)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// upsertProvenanceEntry loads the current provenance, sets
|
|
88
|
+
// entries[dest] = entry, and writes it back. The write is fatal-on-failure
|
|
89
|
+
// (see writeProvenance) — callers MUST treat a thrown error as
|
|
90
|
+
// "installation aborted", never log-and-continue. Mirrors
|
|
91
|
+
// internal/thirdparty/provenance.go:UpsertProvenanceEntry.
|
|
92
|
+
function upsertProvenanceEntry(root, dest, entry) {
|
|
93
|
+
const prov = loadProvenance(root)
|
|
94
|
+
prov.entries[dest] = entry
|
|
95
|
+
writeProvenance(root, prov)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// verifyApproval is the D8c TOCTOU close: it only succeeds if dest has a
|
|
99
|
+
// provenance entry whose checksum_sha256 matches checksum exactly and whose
|
|
100
|
+
// approved_by is non-empty. A destination with no entry, an entry for a
|
|
101
|
+
// different checksum, or an entry with an empty approved_by all fail
|
|
102
|
+
// identically: not approved. Mirrors
|
|
103
|
+
// internal/thirdparty/provenance.go:VerifyApproval.
|
|
104
|
+
function verifyApproval(root, checksum, dest) {
|
|
105
|
+
const prov = loadProvenance(root)
|
|
106
|
+
const entry = prov.entries[dest]
|
|
107
|
+
if (!entry) throw new Error(`no provenance entry for destination "${dest}": not approved`)
|
|
108
|
+
if (entry.checksum_sha256 !== checksum) {
|
|
109
|
+
throw new Error(`provenance checksum mismatch for "${dest}": approved ${entry.checksum_sha256}, got ${checksum}`)
|
|
110
|
+
}
|
|
111
|
+
if (!entry.approved_by) throw new Error(`provenance entry for "${dest}" has no approved_by`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { provenancePath, loadProvenance, writeProvenance, upsertProvenanceEntry, verifyApproval, PROVENANCE_SCHEMA_VERSION }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/quarantine.go — the D8a/b on-disk
|
|
4
|
+
// quarantine record for one fetched third-party artifact. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D8a, D8b, D8f).
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto')
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const path = require('node:path')
|
|
11
|
+
|
|
12
|
+
const { checksum: sha256hex, redactURL } = require('./markers')
|
|
13
|
+
|
|
14
|
+
// QUARANTINE_SCHEMA_VERSION mirrors internal/thirdparty/quarantine.go:quarantineSchemaVersion.
|
|
15
|
+
const QUARANTINE_SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
// atomicWrite writes data to filename via a temp file in the same
|
|
18
|
+
// directory followed by fs.renameSync, so a reader never observes a
|
|
19
|
+
// partially written file. Mirrors internal/thirdparty/quarantine.go's
|
|
20
|
+
// atomicWrite (shared there by quarantine.go and provenance.go; replicated
|
|
21
|
+
// here and in references.js, same rationale as the Go reference's own
|
|
22
|
+
// per-package duplication of Checksum in markers.go's doc comment).
|
|
23
|
+
function atomicWrite(filename, data, mode) {
|
|
24
|
+
const directory = path.dirname(filename)
|
|
25
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
26
|
+
const tmp = path.join(directory, `.trackfw-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(tmp, data, { mode })
|
|
29
|
+
fs.chmodSync(tmp, mode)
|
|
30
|
+
fs.renameSync(tmp, filename)
|
|
31
|
+
} finally {
|
|
32
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// quarantinePath returns the on-disk path of the quarantine record for
|
|
37
|
+
// checksum, rooted at root — the project or home directory the caller is
|
|
38
|
+
// operating on. Mirrors internal/thirdparty/quarantine.go:QuarantinePath.
|
|
39
|
+
function quarantinePath(root, checksum) {
|
|
40
|
+
return path.join(root, '.trackfw', 'thirdparty-quarantine', `${checksum}.json`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// newQuarantineEntry builds a quarantine entry from freshly fetched content.
|
|
44
|
+
// matchedMarkers is checkMarkers()'s return value for raw; an empty array
|
|
45
|
+
// yields marker_check.result === "pass". The content is embedded whole,
|
|
46
|
+
// base64-encoded, in content_base64 — never a path to another file (D8b):
|
|
47
|
+
// an indirection through a second file would reopen the TOCTOU window the
|
|
48
|
+
// quarantine record exists to close.
|
|
49
|
+
//
|
|
50
|
+
// rawURL is stored via redactURL, not verbatim (D6-bis) — see
|
|
51
|
+
// internal/thirdparty/quarantine.go:NewQuarantineEntry's doc comment.
|
|
52
|
+
function newQuarantineEntry(rawURL, raw, matchedMarkers, kind, requestedTargets) {
|
|
53
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
|
|
54
|
+
return {
|
|
55
|
+
schema_version: QUARANTINE_SCHEMA_VERSION,
|
|
56
|
+
url: redactURL(rawURL),
|
|
57
|
+
checksum_sha256: sha256hex(buf),
|
|
58
|
+
fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
59
|
+
content_base64: buf.toString('base64'),
|
|
60
|
+
marker_check: {
|
|
61
|
+
result: matchedMarkers.length > 0 ? 'fail' : 'pass',
|
|
62
|
+
matched_markers: matchedMarkers,
|
|
63
|
+
},
|
|
64
|
+
kind,
|
|
65
|
+
requested_targets: requestedTargets || [],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// writeQuarantine persists entry atomically at
|
|
70
|
+
// quarantinePath(root, entry.checksum_sha256). Mirrors
|
|
71
|
+
// internal/thirdparty/quarantine.go:WriteQuarantine.
|
|
72
|
+
function writeQuarantine(root, entry) {
|
|
73
|
+
const toWrite = { ...entry, schema_version: QUARANTINE_SCHEMA_VERSION }
|
|
74
|
+
const data = `${JSON.stringify(toWrite, null, 2)}\n`
|
|
75
|
+
atomicWrite(quarantinePath(root, entry.checksum_sha256), data, 0o600)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// readQuarantine reads and validates the quarantine record for checksum,
|
|
79
|
+
// fail-closed (D8f): a missing file, invalid JSON, or an unsupported
|
|
80
|
+
// schema_version are all thrown as errors, never degraded to an empty
|
|
81
|
+
// value. Deliberately asymmetric with loadProvenance (see provenance.js):
|
|
82
|
+
// here, the caller already holds a checksum obtained from a prior fetch and
|
|
83
|
+
// is asking for that specific record, so its absence is itself the failure
|
|
84
|
+
// being guarded against. Do NOT "fix" this asymmetry — it is intentional,
|
|
85
|
+
// verified against the Go reference by audit. Mirrors
|
|
86
|
+
// internal/thirdparty/quarantine.go:ReadQuarantine.
|
|
87
|
+
function readQuarantine(root, checksum) {
|
|
88
|
+
const filename = quarantinePath(root, checksum)
|
|
89
|
+
let data
|
|
90
|
+
try {
|
|
91
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`read quarantine entry "${checksum}": ${err.message}`)
|
|
94
|
+
}
|
|
95
|
+
let entry
|
|
96
|
+
try {
|
|
97
|
+
entry = JSON.parse(data)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(`decode quarantine entry "${checksum}": ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
if (entry.schema_version !== QUARANTINE_SCHEMA_VERSION) {
|
|
102
|
+
throw new Error(`unsupported quarantine schema ${entry.schema_version} for "${checksum}"`)
|
|
103
|
+
}
|
|
104
|
+
return entry
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// decodeContent decodes entry.content_base64 back to the original raw
|
|
108
|
+
// bytes (a Buffer). Mirrors internal/thirdparty/quarantine.go:QuarantineEntry.DecodeContent.
|
|
109
|
+
function decodeContent(entry) {
|
|
110
|
+
return Buffer.from(entry.content_base64, 'base64')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent, atomicWrite, QUARANTINE_SCHEMA_VERSION }
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of the D9 extension point from internal/integrations/render.go
|
|
4
|
+
// (ThirdPartyReference, UpsertThirdPartyReference, ApplyThirdPartyReferences,
|
|
5
|
+
// NormalizeThirdPartyContent, ResolveThirdPartySkillDestination) — see the
|
|
6
|
+
// D9 amendment in
|
|
7
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md.
|
|
8
|
+
//
|
|
9
|
+
// D9 note (see docs/agents-working-context.md, ML-1A entry): this file
|
|
10
|
+
// persists .trackfw/thirdparty-references.json, an artifact NOT mentioned
|
|
11
|
+
// by the original ADR text (D1-D8) — it exists because a plain rendered
|
|
12
|
+
// catalog agent file cannot simply have a reference block appended once:
|
|
13
|
+
// the next `trackfw agents update` calls buildPlans -> render again, which
|
|
14
|
+
// re-derives content straight from the catalog asset and knows nothing
|
|
15
|
+
// about the appended block. Persisting the reference here and having
|
|
16
|
+
// buildPlans call applyThirdPartyReferences after every render (see
|
|
17
|
+
// ../integrations/index.js) makes the canonical render reproduce the
|
|
18
|
+
// block, so repeated `agents update` runs settle at state "current"
|
|
19
|
+
// instead of fighting the third-party attachment.
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs')
|
|
22
|
+
const path = require('node:path')
|
|
23
|
+
|
|
24
|
+
const { atomicWrite } = require('./quarantine')
|
|
25
|
+
const { target: catalogTarget, surfaceFor } = require('../integrations/catalog')
|
|
26
|
+
|
|
27
|
+
const THIRDPARTY_REFERENCES_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
// truncateBeforeIdSegment drops the "{{id}}"-bearing path segment and
|
|
30
|
+
// everything after it, returning the shared parent directory. Duplicated
|
|
31
|
+
// (not imported) from npm/src/integrations/catalog.js — that module does
|
|
32
|
+
// not export it, and this ML's file list does not include editing
|
|
33
|
+
// catalog.js; this is the same small, pure, low-risk duplication already
|
|
34
|
+
// used by the Go reference itself (see markers.go's doc comment on
|
|
35
|
+
// Checksum) rather than widening catalog.js's public surface for one
|
|
36
|
+
// caller.
|
|
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
|
+
function thirdPartyReferencesPath(root) {
|
|
45
|
+
return path.join(root, '.trackfw', 'thirdparty-references.json')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emptyRegistry() {
|
|
49
|
+
return { schema_version: THIRDPARTY_REFERENCES_SCHEMA_VERSION, entries: {} }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadThirdPartyReferenceRegistry(root) {
|
|
53
|
+
const filename = thirdPartyReferencesPath(root)
|
|
54
|
+
let data
|
|
55
|
+
try {
|
|
56
|
+
data = fs.readFileSync(filename, 'utf8')
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err.code === 'ENOENT') return emptyRegistry()
|
|
59
|
+
throw new Error(`read thirdparty reference registry: ${err.message}`)
|
|
60
|
+
}
|
|
61
|
+
let reg
|
|
62
|
+
try {
|
|
63
|
+
reg = JSON.parse(data)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`decode thirdparty reference registry: ${err.message}`)
|
|
66
|
+
}
|
|
67
|
+
if (reg.schema_version !== THIRDPARTY_REFERENCES_SCHEMA_VERSION) {
|
|
68
|
+
throw new Error(`unsupported thirdparty reference registry schema ${reg.schema_version}`)
|
|
69
|
+
}
|
|
70
|
+
if (!reg.entries || Array.isArray(reg.entries)) reg.entries = {}
|
|
71
|
+
return reg
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeThirdPartyReferenceRegistry(root, reg) {
|
|
75
|
+
const toWrite = { schema_version: THIRDPARTY_REFERENCES_SCHEMA_VERSION, entries: reg.entries || {} }
|
|
76
|
+
const data = `${JSON.stringify(toWrite, null, 2)}\n`
|
|
77
|
+
atomicWrite(thirdPartyReferencesPath(root), data, 0o600)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// upsertThirdPartyReference records (or replaces, keyed by ref.slug) a
|
|
81
|
+
// reference from the rendered catalog agent artifact (targetID,
|
|
82
|
+
// agentItemID) to a third-party skill, persisted under root (project root
|
|
83
|
+
// — D4). Idempotent: calling it twice with the same slug replaces the
|
|
84
|
+
// prior entry instead of duplicating it. Mirrors
|
|
85
|
+
// internal/integrations/render.go:UpsertThirdPartyReference.
|
|
86
|
+
function upsertThirdPartyReference(root, targetID, agentItemID, ref) {
|
|
87
|
+
const reg = loadThirdPartyReferenceRegistry(root)
|
|
88
|
+
const key = `${targetID}/${agentItemID}`
|
|
89
|
+
const refs = reg.entries[key] || []
|
|
90
|
+
let replaced = false
|
|
91
|
+
for (let i = 0; i < refs.length; i++) {
|
|
92
|
+
if (refs[i].slug === ref.slug) {
|
|
93
|
+
refs[i] = ref
|
|
94
|
+
replaced = true
|
|
95
|
+
break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!replaced) refs.push(ref)
|
|
99
|
+
refs.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
100
|
+
reg.entries[key] = refs
|
|
101
|
+
writeThirdPartyReferenceRegistry(root, reg)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// THIRD_PARTY_REF_START/END are the composition markers (D5), dedicated and
|
|
105
|
+
// distinct from the auxiliary-rules-file markers (a different subsystem).
|
|
106
|
+
// Mirrors internal/integrations/render.go:thirdPartyRefStart/thirdPartyRefEnd.
|
|
107
|
+
const THIRD_PARTY_REF_START = '<!-- trackfw:thirdparty-skills:start -->'
|
|
108
|
+
const THIRD_PARTY_REF_END = '<!-- trackfw:thirdparty-skills:end -->'
|
|
109
|
+
|
|
110
|
+
// applyThirdPartyReferences injects or updates the third-party reference
|
|
111
|
+
// block in content for (targetID, agentItemID), based on entries persisted
|
|
112
|
+
// by upsertThirdPartyReference. When root is empty or there are no entries
|
|
113
|
+
// for this key, content is returned byte-for-byte unchanged — this is the
|
|
114
|
+
// guarantee that every agent artifact which never received a third-party
|
|
115
|
+
// attachment renders exactly as it did before D5/D9 were introduced.
|
|
116
|
+
// Mirrors internal/integrations/render.go:ApplyThirdPartyReferences.
|
|
117
|
+
function applyThirdPartyReferences(root, content, targetID, agentItemID) {
|
|
118
|
+
if (!root) return content
|
|
119
|
+
const reg = loadThirdPartyReferenceRegistry(root)
|
|
120
|
+
const refs = reg.entries[`${targetID}/${agentItemID}`] || []
|
|
121
|
+
if (!refs.length) return content
|
|
122
|
+
|
|
123
|
+
let block = `${THIRD_PARTY_REF_START}\n`
|
|
124
|
+
for (const ref of refs) {
|
|
125
|
+
block += `- Third-party skill "${ref.slug}": ${ref.destination} (source: ${ref.url})\n`
|
|
126
|
+
}
|
|
127
|
+
block += THIRD_PARTY_REF_END
|
|
128
|
+
|
|
129
|
+
let text = content
|
|
130
|
+
const start = text.indexOf(THIRD_PARTY_REF_START)
|
|
131
|
+
if (start === -1) {
|
|
132
|
+
if (!text.endsWith('\n')) text += '\n'
|
|
133
|
+
text += `\n${block}\n`
|
|
134
|
+
return text
|
|
135
|
+
}
|
|
136
|
+
// Search for the end marker starting at start, not from the beginning of
|
|
137
|
+
// text (hefesto-tf finding, ML-4C): searching the whole text could find
|
|
138
|
+
// an END marker that appears BEFORE start, producing end < start and
|
|
139
|
+
// silently corrupting the composed output. Anchoring the search at start
|
|
140
|
+
// makes that impossible: end is either -1 or >= start.
|
|
141
|
+
const relEnd = text.slice(start).indexOf(THIRD_PARTY_REF_END)
|
|
142
|
+
if (relEnd === -1) {
|
|
143
|
+
// Malformed (start without end): append a fresh block rather than
|
|
144
|
+
// guess at repair.
|
|
145
|
+
text += `\n${block}\n`
|
|
146
|
+
return text
|
|
147
|
+
}
|
|
148
|
+
const end = start + relEnd
|
|
149
|
+
const newText = text.slice(0, start) + block + text.slice(end + THIRD_PARTY_REF_END.length)
|
|
150
|
+
return `${newText.replace(/\n+$/, '')}\n`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// normalizeThirdPartyContent applies the same normalization used for
|
|
154
|
+
// managed catalog skill content (trim + single trailing newline) to raw
|
|
155
|
+
// third-party bytes before they are written through IntegrationManager, so
|
|
156
|
+
// third-party artifacts are stored with the same on-disk convention as
|
|
157
|
+
// catalog artifacts. Mirrors
|
|
158
|
+
// internal/integrations/render.go:NormalizeThirdPartyContent.
|
|
159
|
+
function normalizeThirdPartyContent(content) {
|
|
160
|
+
const text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content)
|
|
161
|
+
return `${text.trim()}\n`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// resolveThirdPartySkillDestination computes where a third-party artifact's
|
|
165
|
+
// content should live for (targetID, scope), per D5: the shared parent
|
|
166
|
+
// directory of the target's own canonical project/global Skills install
|
|
167
|
+
// path template, followed by "/thirdparty/<slug>.md". The directory always
|
|
168
|
+
// comes from the catalog's own path template — never a hardcoded
|
|
169
|
+
// ".claude" — so every target the catalog declares a Skills capability for
|
|
170
|
+
// is supported without per-target special-casing. Mirrors
|
|
171
|
+
// internal/integrations/render.go:ResolveThirdPartySkillDestination.
|
|
172
|
+
function resolveThirdPartySkillDestination(targetID, scope, slug) {
|
|
173
|
+
let targetEntry
|
|
174
|
+
try {
|
|
175
|
+
targetEntry = catalogTarget(targetID)
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error(`unknown target "${targetID}"`)
|
|
178
|
+
}
|
|
179
|
+
const surface = surfaceFor(targetEntry, undefined, 'skills')
|
|
180
|
+
const installPath = (surface.paths.skills || []).find(entry => entry.scope === scope)
|
|
181
|
+
if (!installPath) throw new Error(`target ${targetID} surface ${surface.id} has no ${scope} skills path`)
|
|
182
|
+
const baseDir = truncateBeforeIdSegment(installPath.path)
|
|
183
|
+
return { destination: `${baseDir}/thirdparty/${slug}.md`, surfaceID: surface.id }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
thirdPartyReferencesPath,
|
|
188
|
+
upsertThirdPartyReference,
|
|
189
|
+
applyThirdPartyReferences,
|
|
190
|
+
normalizeThirdPartyContent,
|
|
191
|
+
resolveThirdPartySkillDestination,
|
|
192
|
+
THIRD_PARTY_REF_START,
|
|
193
|
+
THIRD_PARTY_REF_END,
|
|
194
|
+
}
|