1// Publishes agent discovery documents with the site build:
2//
3// - /.well-known/agent-skills/: the official Spice skills at a pinned commit of
4// https://github.com/spiceai/skills and an index.json, per the Agent Skills Discovery RFC v0.2.0
5// (https://github.com/cloudflare/agent-skills-discovery-rfc). A skill that consists of only a
6// SKILL.md is published as that file. A skill with supporting files is published as a .tar.gz
7// archive.
8// - /.well-known/ard.json and /.well-known/ai-catalog.json: an Agentic Resource Discovery manifest
9// (https://agenticresourcediscovery.org/) of the runtime HTTP API and the skills. ard.json is the
10// current well-known path; ai-catalog.json is the predecessor path.
11// - /openapi.json: the runtime HTTP API description that the API catalog and manifest link to.
12
13const crypto = require('crypto')
14const fs = require('fs/promises')
15const path = require('path')
16const zlib = require('zlib')
17
18const SKILLS_INDEX_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
19const SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
20
21// The per-skill contents of the official plugin package (scripts/package_plugin.sh in
22// spiceai/skills). Evals and local configuration stay in that repository.
23const SKILL_RESOURCE_DIRS = new Set(['scripts', 'references', 'config', 'examples'])
24const EXCLUDED_NAMES = new Set([
25 '.private',
26 '.git',
27 '.DS_Store',
28 '__pycache__',
29 '.audit',
30 'evals',
31 '.env'
32])
33const EXCLUDED_SKILLS = new Set(['improve-skills'])
34
35// 1980-01-01, the timestamp the official package uses, so that unchanged skills produce
36// identical archives and digests.
37const ARCHIVE_MTIME = 315532800
38
39module.exports = function agentDiscoveryPlugin(context, options) {
40 return {
41 name: 'agent-discovery',
42 async postBuild({ outDir }) {
43 const siteUrl = context.siteConfig.url
44 await fs.copyFile(
45 path.join(context.siteDir, options.openApiSpec),
46 path.join(outDir, 'openapi.json')
47 )
48 const skills = await publishSkills(outDir, options.skillsRepository, options.skillsCommit)
49 await publishArdManifest(outDir, siteUrl, skills)
50 }
51 }
52}
53
54// Downloads the skills at a commit rather than a tag. A tag can be moved to different content, and
55// the index digests describe only the bytes that were downloaded.
56async function publishSkills(outDir, repository, commit) {
57 if (!/^[0-9a-f]{40}$/.test(commit ?? '')) {
58 throw new Error(`agent-discovery: skillsCommit must be a full commit SHA, not ${commit}`)
59 }
60 const url = `https://github.com/${repository}/archive/${commit}.tar.gz`
61 const response = await fetch(url)
62 if (!response.ok) {
63 throw new Error(`agent-discovery: downloading ${url} failed with HTTP ${response.status}`)
64 }
65 const archive = readTar(zlib.gunzipSync(Buffer.from(await response.arrayBuffer())))
66 if (archive.commit !== commit) {
67 throw new Error(
68 `agent-discovery: ${url} is an archive of commit ${archive.commit}, not ${commit}`
69 )
70 }
71 const collected = [...collectSkills(archive.files)].sort(([a], [b]) => a.localeCompare(b))
72 if (collected.length === 0) {
73 throw new Error(`agent-discovery: no skills found in ${repository}@${commit}`)
74 }
75
76 const skillsDir = path.join(outDir, '.well-known', 'agent-skills')
77 await fs.mkdir(skillsDir, { recursive: true })
78 const skills = []
79 for (const [name, skill] of collected) {
80 const markdown = skill.files.get('SKILL.md')?.data.toString('utf8')
81 if (!markdown) {
82 throw new Error(`agent-discovery: skill ${name} in ${repository}@${commit} has no SKILL.md`)
83 }
84 const { fields, body } = readFrontMatter(name, markdown)
85 const { description } = validateFrontMatter(name, fields)
86
87 const isArchive = skill.files.size > 1
88 const artifact = isArchive ? `${name}.tar.gz` : `${name}/SKILL.md`
89 const bytes = isArchive ? createTarGz(skill.files) : skill.files.get('SKILL.md').data
90 await fs.mkdir(path.dirname(path.join(skillsDir, artifact)), { recursive: true })
91 await fs.writeFile(path.join(skillsDir, artifact), bytes)
92
93 skills.push({
94 name,
95 type: isArchive ? 'archive' : 'skill-md',
96 description,
97 url: `/.well-known/agent-skills/${artifact}`,
98 digest: `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`,
99 displayName: /^#\s+(.+)$/m.exec(body)?.[1].trim() ?? name,
100 representativeQueries: representativeQueries(skill.evals)
101 })
102 }
103
104 const index = {
105 $schema: SKILLS_INDEX_SCHEMA,
106 skills: skills.map(({ name, type, description, url, digest }) => ({
107 name,
108 type,
109 description,
110 url,
111 digest
112 }))
113 }
114 await fs.writeFile(path.join(skillsDir, 'index.json'), `${JSON.stringify(index, null, 2)}\n`)
115 return skills
116}
117
118async function publishArdManifest(outDir, siteUrl, skills) {
119 const publisher = new URL(siteUrl).hostname
120 const manifest = {
121 specVersion: '1.0',
122 host: {
123 displayName: 'Spice AI, Inc.',
124 identifier: publisher,
125 documentationUrl: `${siteUrl}/docs`
126 },
127 entries: [
128 {
129 identifier: `urn:air:${publisher}:api:runtime-http`,
130 displayName: 'Spice Runtime HTTP API',
131 type: 'application/vnd.oai.openapi+json',
132 url: `${siteUrl}/openapi.json`,
133 description:
134 'OpenAPI description of the HTTP API that a Spice runtime serves, by default at http://localhost:8090: SQL queries, search, OpenAI-compatible chat completions and embeddings, MCP, dataset acceleration, and readiness.',
135 representativeQueries: [
136 'run a SQL query against a Spice runtime over HTTP',
137 'call the OpenAI-compatible chat completions API in Spice',
138 'search a Spice dataset over HTTP',
139 'check whether the Spice runtime is ready'
140 ]
141 },
142 ...skills.map((skill) => ({
143 identifier: `urn:air:${publisher}:skill:${skill.name}`,
144 displayName: skill.displayName,
145 // The ARD media types of a skill archive and of a single SKILL.md.
146 type:
147 skill.type === 'archive'
148 ? 'application/agent-skills+gzip'
149 : 'text/markdown; profile="urn:air:agent-skills"',
150 url: new URL(skill.url, siteUrl).href,
151 description: skill.description,
152 ...(skill.representativeQueries.length >= 2 && {
153 representativeQueries: skill.representativeQueries
154 })
155 }))
156 ]
157 }
158
159 const json = `${JSON.stringify(manifest, null, 2)}\n`
160 for (const file of ['ard.json', 'ai-catalog.json']) {
161 await fs.writeFile(path.join(outDir, '.well-known', file), json)
162 }
163}
164
165// Groups the files under skills/<name>/ by skill, keeping the files the official package ships.
166function collectSkills(files) {
167 const skills = new Map()
168 for (const file of files) {
169 const match = /^skills\/([^/]+)\/(.+)$/.exec(file.path)
170 if (!match || EXCLUDED_SKILLS.has(match[1])) {
171 continue
172 }
173 const [, name, relativePath] = match
174 if (!skills.has(name)) {
175 skills.set(name, { files: new Map(), evals: null })
176 }
177 const skill = skills.get(name)
178 if (relativePath === 'evals/evals.json') {
179 skill.evals = parseJson(file.data.toString('utf8'))
180 } else if (isPackagedSkillFile(relativePath)) {
181 skill.files.set(relativePath, file)
182 }
183 }
184 return skills
185}
186
187function isPackagedSkillFile(relativePath) {
188 const segments = relativePath.split('/')
189 const excluded = segments.some(
190 (segment) =>
191 EXCLUDED_NAMES.has(segment) ||
192 segment.startsWith('.env.') ||
193 segment.includes('.local.') ||
194 /(\.pyc|\.pyo|-workspace)$/.test(segment)
195 )
196 if (excluded) {
197 return false
198 }
199 if (relativePath === 'SKILL.md') {
200 return true
201 }
202 // The package copies only the top-level files of config/.
203 if (segments[0] === 'config') {
204 return segments.length === 2
205 }
206 return SKILL_RESOURCE_DIRS.has(segments[0]) && segments.length > 1
207}
208
209// Representative queries are optional, so an unreadable evals file only omits them.
210function parseJson(text) {
211 try {
212 return JSON.parse(text)
213 } catch {
214 return null
215 }
216}
217
218// Eval prompts are the requests each skill is tested against. The shortest ones serve as ARD
219// representative queries.
220function representativeQueries(evals) {
221 return (evals?.evals ?? [])
222 .map((entry) => String(entry.prompt ?? '').replace(/\s+/g, ' ').trim())
223 .filter((prompt) => prompt.length > 0 && prompt.length <= 160)
224 .sort((a, b) => a.length - b.length)
225 .slice(0, 3)
226}
227
228// Parses SKILL.md front matter. Only single-line `key: value` fields are supported, which is the
229// format the Agent Skills specification uses for name and description.
230function readFrontMatter(skillName, markdown) {
231 const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(markdown)
232 if (!match) {
233 throw new Error(`agent-discovery: SKILL.md of skill ${skillName} has no front matter`)
234 }
235 const fields = {}
236 for (const line of match[1].split(/\r?\n/)) {
237 if (line.trim() === '') {
238 continue
239 }
240 const field = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line)
241 if (!field || /^[>|]/.test(field[2])) {
242 throw new Error(
243 `agent-discovery: unsupported front matter in skill ${skillName}: ${JSON.stringify(line)}`
244 )
245 }
246 fields[field[1]] = unquote(field[2].trim())
247 }
248 return { fields, body: markdown.slice(match[0].length) }
249}
250
251function unquote(value) {
252 if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
253 return JSON.parse(value)
254 }
255 if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
256 return value.slice(1, -1).replace(/''/g, "'")
257 }
258 return value
259}
260
261function validateFrontMatter(skillName, { name, description }) {
262 if (name !== skillName || name.length > 64 || !SKILL_NAME.test(name)) {
263 throw new Error(`agent-discovery: skill directory ${skillName} has invalid name ${name}`)
264 }
265 if (!description || description.length > 1024) {
266 throw new Error(`agent-discovery: skill ${skillName} needs a description of 1-1024 characters`)
267 }
268 return { name, description }
269}
270
271// Reads the regular files of an uncompressed tar archive, without its top-level directory, and the
272// commit that GitHub records in the archive's global header.
273function readTar(buffer) {
274 const files = []
275 let commit
276 let paxPath
277 for (let offset = 0; offset + 512 <= buffer.length; ) {
278 const header = buffer.subarray(offset, offset + 512)
279 if (header.every((byte) => byte === 0)) {
280 break
281 }
282 const size = parseInt(readField(header, 124, 12), 8) || 0
283 const type = String.fromCharCode(header[156])
284 const data = buffer.subarray(offset + 512, offset + 512 + size)
285 offset += 512 + Math.ceil(size / 512) * 512
286
287 if (type === 'g') {
288 commit = /(?:^|\n)\d+ comment=([0-9a-f]{40})\n/.exec(data.toString('utf8'))?.[1]
289 continue
290 }
291 if (type === 'x') {
292 paxPath = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString('utf8'))?.[1]
293 continue
294 }
295 const name = readField(header, 0, 100)
296 const prefix = readField(header, 345, 155)
297 const entryPath = paxPath ?? (prefix ? `${prefix}/${name}` : name)
298 paxPath = undefined
299 if (type === '0' || type === '\0') {
300 files.push({
301 path: entryPath.split('/').slice(1).join('/'),
302 mode: parseInt(readField(header, 100, 8), 8),
303 data
304 })
305 }
306 }
307 return { commit, files }
308}
309
310function readField(header, start, length) {
311 const field = header.subarray(start, start + length)
312 const end = field.indexOf(0)
313 return field
314 .subarray(0, end === -1 ? length : end)
315 .toString('utf8')
316 .trim()
317}
318
319// Creates a gzip-compressed ustar archive with the files at its root and normalized metadata.
320function createTarGz(files) {
321 const blocks = []
322 for (const [name, file] of [...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
323 if (Buffer.byteLength(name) >= 100) {
324 throw new Error(`agent-discovery: archive path is too long: ${name}`)
325 }
326 const header = Buffer.alloc(512)
327 header.write(name, 0, 'utf8')
328 header.write(octal(file.mode & 0o111 ? 0o755 : 0o644, 8), 100)
329 header.write(octal(0, 8), 108)
330 header.write(octal(0, 8), 116)
331 header.write(octal(file.data.length, 12), 124)
332 header.write(octal(ARCHIVE_MTIME, 12), 136)
333 header.write(' '.repeat(8), 148)
334 header.write('0', 156)
335 header.write('ustar\u000000', 257)
336 header.write(octal(header.reduce((sum, byte) => sum + byte, 0), 7), 148)
337 blocks.push(header, file.data, Buffer.alloc((512 - (file.data.length % 512)) % 512))
338 }
339 blocks.push(Buffer.alloc(1024))
340 return zlib.gzipSync(Buffer.concat(blocks), { level: 9 })
341}
342
343function octal(value, length) {
344 return `${value.toString(8).padStart(length - 1, '0')}\0`
345}
346
1// Publishes agent discovery documents with the site build:
2//
3// - /.well-known/agent-skills/: the official Spice skills at a pinned commit of
4// https://github.com/spiceai/skills and an index.json, per the Agent Skills Discovery RFC v0.2.0
5// (https://github.com/cloudflare/agent-skills-discovery-rfc). A skill that consists of only a
6// SKILL.md is published as that file. A skill with supporting files is published as a .tar.gz
7// archive.
8// - /.well-known/ard.json and /.well-known/ai-catalog.json: an Agentic Resource Discovery manifest
9// (https://agenticresourcediscovery.org/) of the runtime HTTP API and the skills. ard.json is the
10// current well-known path; ai-catalog.json is the predecessor path.
11// - /openapi.json: the runtime HTTP API description that the API catalog and manifest link to.
12
13const crypto = require('crypto')
14const fs = require('fs/promises')
15const path = require('path')
16const zlib = require('zlib')
17
18const SKILLS_INDEX_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'
19const SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
20
21// The per-skill contents of the official plugin package (scripts/package_plugin.sh in
22// spiceai/skills). Evals and local configuration stay in that repository.
23const SKILL_RESOURCE_DIRS = new Set(['scripts', 'references', 'config', 'examples'])
24const EXCLUDED_NAMES = new Set([
25 '.private',
26 '.git',
27 '.DS_Store',
28 '__pycache__',
29 '.audit',
30 'evals',
31 '.env'
32])
33const EXCLUDED_SKILLS = new Set(['improve-skills'])
34
35// 1980-01-01, the timestamp the official package uses, so that unchanged skills produce
36// identical archives and digests.
37const ARCHIVE_MTIME = 315532800
38
39module.exports = function agentDiscoveryPlugin(context, options) {
40 return {
41 name: 'agent-discovery',
42 async postBuild({ outDir }) {
43 const siteUrl = context.siteConfig.url
44 await fs.copyFile(
45 path.join(context.siteDir, options.openApiSpec),
46 path.join(outDir, 'openapi.json')
47 )
48 const skills = await publishSkills(outDir, options.skillsRepository, options.skillsCommit)
49 await publishArdManifest(outDir, siteUrl, skills)
50 }
51 }
52}
53
54// Downloads the skills at a commit rather than a tag. A tag can be moved to different content, and
55// the index digests describe only the bytes that were downloaded.
56async function publishSkills(outDir, repository, commit) {
57 if (!/^[0-9a-f]{40}$/.test(commit ?? '')) {
58 throw new Error(`agent-discovery: skillsCommit must be a full commit SHA, not ${commit}`)
59 }
60 const url = `https://github.com/${repository}/archive/${commit}.tar.gz`
61 const response = await fetch(url)
62 if (!response.ok) {
63 throw new Error(`agent-discovery: downloading ${url} failed with HTTP ${response.status}`)
64 }
65 const archive = readTar(zlib.gunzipSync(Buffer.from(await response.arrayBuffer())))
66 if (archive.commit !== commit) {
67 throw new Error(
68 `agent-discovery: ${url} is an archive of commit ${archive.commit}, not ${commit}`
69 )
70 }
71 const collected = [...collectSkills(archive.files)].sort(([a], [b]) => a.localeCompare(b))
72 if (collected.length === 0) {
73 throw new Error(`agent-discovery: no skills found in ${repository}@${commit}`)
74 }
75
76 const skillsDir = path.join(outDir, '.well-known', 'agent-skills')
77 await fs.mkdir(skillsDir, { recursive: true })
78 const skills = []
79 for (const [name, skill] of collected) {
80 const markdown = skill.files.get('SKILL.md')?.data.toString('utf8')
81 if (!markdown) {
82 throw new Error(`agent-discovery: skill ${name} in ${repository}@${commit} has no SKILL.md`)
83 }
84 const { fields, body } = readFrontMatter(name, markdown)
85 const { description } = validateFrontMatter(name, fields)
86
87 const isArchive = skill.files.size > 1
88 const artifact = isArchive ? `${name}.tar.gz` : `${name}/SKILL.md`
89 const bytes = isArchive ? createTarGz(skill.files) : skill.files.get('SKILL.md').data
90 await fs.mkdir(path.dirname(path.join(skillsDir, artifact)), { recursive: true })
91 await fs.writeFile(path.join(skillsDir, artifact), bytes)
92
93 skills.push({
94 name,
95 type: isArchive ? 'archive' : 'skill-md',
96 description,
97 url: `/.well-known/agent-skills/${artifact}`,
98 digest: `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`,
99 displayName: /^#\s+(.+)$/m.exec(body)?.[1].trim() ?? name,
100 representativeQueries: representativeQueries(skill.evals)
101 })
102 }
103
104 const index = {
105 $schema: SKILLS_INDEX_SCHEMA,
106 skills: skills.map(({ name, type, description, url, digest }) => ({
107 name,
108 type,
109 description,
110 url,
111 digest
112 }))
113 }
114 await fs.writeFile(path.join(skillsDir, 'index.json'), `${JSON.stringify(index, null, 2)}\n`)
115 return skills
116}
117
118async function publishArdManifest(outDir, siteUrl, skills) {
119 const publisher = new URL(siteUrl).hostname
120 const manifest = {
121 specVersion: '1.0',
122 host: {
123 displayName: 'Spice AI, Inc.',
124 identifier: publisher,
125 documentationUrl: `${siteUrl}/docs`
126 },
127 entries: [
128 {
129 identifier: `urn:air:${publisher}:api:runtime-http`,
130 displayName: 'Spice Runtime HTTP API',
131 type: 'application/vnd.oai.openapi+json',
132 url: `${siteUrl}/openapi.json`,
133 description:
134 'OpenAPI description of the HTTP API that a Spice runtime serves, by default at http://localhost:8090: SQL queries, search, OpenAI-compatible chat completions and embeddings, MCP, dataset acceleration, and readiness.',
135 representativeQueries: [
136 'run a SQL query against a Spice runtime over HTTP',
137 'call the OpenAI-compatible chat completions API in Spice',
138 'search a Spice dataset over HTTP',
139 'check whether the Spice runtime is ready'
140 ]
141 },
142 ...skills.map((skill) => ({
143 identifier: `urn:air:${publisher}:skill:${skill.name}`,
144 displayName: skill.displayName,
145 // The ARD media types of a skill archive and of a single SKILL.md.
146 type:
147 skill.type === 'archive'
148 ? 'application/agent-skills+gzip'
149 : 'text/markdown; profile="urn:air:agent-skills"',
150 url: new URL(skill.url, siteUrl).href,
151 description: skill.description,
152 ...(skill.representativeQueries.length >= 2 && {
153 representativeQueries: skill.representativeQueries
154 })
155 }))
156 ]
157 }
158
159 const json = `${JSON.stringify(manifest, null, 2)}\n`
160 for (const file of ['ard.json', 'ai-catalog.json']) {
161 await fs.writeFile(path.join(outDir, '.well-known', file), json)
162 }
163}
164
165// Groups the files under skills/<name>/ by skill, keeping the files the official package ships.
166function collectSkills(files) {
167 const skills = new Map()
168 for (const file of files) {
169 const match = /^skills\/([^/]+)\/(.+)$/.exec(file.path)
170 if (!match || EXCLUDED_SKILLS.has(match[1])) {
171 continue
172 }
173 const [, name, relativePath] = match
174 if (!skills.has(name)) {
175 skills.set(name, { files: new Map(), evals: null })
176 }
177 const skill = skills.get(name)
178 if (relativePath === 'evals/evals.json') {
179 skill.evals = parseJson(file.data.toString('utf8'))
180 } else if (isPackagedSkillFile(relativePath)) {
181 skill.files.set(relativePath, file)
182 }
183 }
184 return skills
185}
186
187function isPackagedSkillFile(relativePath) {
188 const segments = relativePath.split('/')
189 const excluded = segments.some(
190 (segment) =>
191 EXCLUDED_NAMES.has(segment) ||
192 segment.startsWith('.env.') ||
193 segment.includes('.local.') ||
194 /(\.pyc|\.pyo|-workspace)$/.test(segment)
195 )
196 if (excluded) {
197 return false
198 }
199 if (relativePath === 'SKILL.md') {
200 return true
201 }
202 // The package copies only the top-level files of config/.
203 if (segments[0] === 'config') {
204 return segments.length === 2
205 }
206 return SKILL_RESOURCE_DIRS.has(segments[0]) && segments.length > 1
207}
208
209// Representative queries are optional, so an unreadable evals file only omits them.
210function parseJson(text) {
211 try {
212 return JSON.parse(text)
213 } catch {
214 return null
215 }
216}
217
218// Eval prompts are the requests each skill is tested against. The shortest ones serve as ARD
219// representative queries.
220function representativeQueries(evals) {
221 return (evals?.evals ?? [])
222 .map((entry) => String(entry.prompt ?? '').replace(/\s+/g, ' ').trim())
223 .filter((prompt) => prompt.length > 0 && prompt.length <= 160)
224 .sort((a, b) => a.length - b.length)
225 .slice(0, 3)
226}
227
228// Parses SKILL.md front matter. Only single-line `key: value` fields are supported, which is the
229// format the Agent Skills specification uses for name and description.
230function readFrontMatter(skillName, markdown) {
231 const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(markdown)
232 if (!match) {
233 throw new Error(`agent-discovery: SKILL.md of skill ${skillName} has no front matter`)
234 }
235 const fields = {}
236 for (const line of match[1].split(/\r?\n/)) {
237 if (line.trim() === '') {
238 continue
239 }
240 const field = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line)
241 if (!field || /^[>|]/.test(field[2])) {
242 throw new Error(
243 `agent-discovery: unsupported front matter in skill ${skillName}: ${JSON.stringify(line)}`
244 )
245 }
246 fields[field[1]] = unquote(field[2].trim())
247 }
248 return { fields, body: markdown.slice(match[0].length) }
249}
250
251function unquote(value) {
252 if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
253 return JSON.parse(value)
254 }
255 if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
256 return value.slice(1, -1).replace(/''/g, "'")
257 }
258 return value
259}
260
261function validateFrontMatter(skillName, { name, description }) {
262 if (name !== skillName || name.length > 64 || !SKILL_NAME.test(name)) {
263 throw new Error(`agent-discovery: skill directory ${skillName} has invalid name ${name}`)
264 }
265 if (!description || description.length > 1024) {
266 throw new Error(`agent-discovery: skill ${skillName} needs a description of 1-1024 characters`)
267 }
268 return { name, description }
269}
270
271// Reads the regular files of an uncompressed tar archive, without its top-level directory, and the
272// commit that GitHub records in the archive's global header.
273function readTar(buffer) {
274 const files = []
275 let commit
276 let paxPath
277 for (let offset = 0; offset + 512 <= buffer.length; ) {
278 const header = buffer.subarray(offset, offset + 512)
279 if (header.every((byte) => byte === 0)) {
280 break
281 }
282 const size = parseInt(readField(header, 124, 12), 8) || 0
283 const type = String.fromCharCode(header[156])
284 const data = buffer.subarray(offset + 512, offset + 512 + size)
285 offset += 512 + Math.ceil(size / 512) * 512
286
287 if (type === 'g') {
288 commit = /(?:^|\n)\d+ comment=([0-9a-f]{40})\n/.exec(data.toString('utf8'))?.[1]
289 continue
290 }
291 if (type === 'x') {
292 paxPath = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString('utf8'))?.[1]
293 continue
294 }
295 const name = readField(header, 0, 100)
296 const prefix = readField(header, 345, 155)
297 const entryPath = paxPath ?? (prefix ? `${prefix}/${name}` : name)
298 paxPath = undefined
299 if (type === '0' || type === '\0') {
300 files.push({
301 path: entryPath.split('/').slice(1).join('/'),
302 mode: parseInt(readField(header, 100, 8), 8),
303 data
304 })
305 }
306 }
307 return { commit, files }
308}
309
310function readField(header, start, length) {
311 const field = header.subarray(start, start + length)
312 const end = field.indexOf(0)
313 return field
314 .subarray(0, end === -1 ? length : end)
315 .toString('utf8')
316 .trim()
317}
318
319// Creates a gzip-compressed ustar archive with the files at its root and normalized metadata.
320function createTarGz(files) {
321 const blocks = []
322 for (const [name, file] of [...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
323 if (Buffer.byteLength(name) >= 100) {
324 throw new Error(`agent-discovery: archive path is too long: ${name}`)
325 }
326 const header = Buffer.alloc(512)
327 header.write(name, 0, 'utf8')
328 header.write(octal(file.mode & 0o111 ? 0o755 : 0o644, 8), 100)
329 header.write(octal(0, 8), 108)
330 header.write(octal(0, 8), 116)
331 header.write(octal(file.data.length, 12), 124)
332 header.write(octal(ARCHIVE_MTIME, 12), 136)
333 header.write(' '.repeat(8), 148)
334 header.write('0', 156)
335 header.write('ustar\u000000', 257)
336 header.write(octal(header.reduce((sum, byte) => sum + byte, 0), 7), 148)
337 blocks.push(header, file.data, Buffer.alloc((512 - (file.data.length % 512)) % 512))
338 }
339 blocks.push(Buffer.alloc(1024))
340 return zlib.gzipSync(Buffer.concat(blocks), { level: 9 })
341}
342
343function octal(value, length) {
344 return `${value.toString(8).padStart(length - 1, '0')}\0`
345}
346
