Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions scripts/lib/build-phases.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,30 +94,38 @@ async function createBundledArchive(outputPath, manifest, skillZips) {
});
}

/** Where this build's release assets live: `SKILLS_BASE_URL` wins, then the pinned version, else latest. */
function resolveBaseDownloadUrl(version) {
return process.env.SKILLS_BASE_URL
|| (version && version !== 'dev'
? `${REPO_URL}/releases/download/v${version}`
: `${REPO_URL}/releases/latest/download`);
}

/**
* Build the manifest object. Pure — no I/O beyond reading env vars.
*
* `resources` is the merged list of skills + doc entries (already in the
* manifest-builder shape: { id, name, description, tags, type, group, shortId }).
* Doc entries are detected by `type === 'doc'` and a matching `docContents[id]` —
* those get inlined under `resource.text`; everything else becomes a skill
* resource with a download URL.
* resource with a download URL. Bundled variants are left out — they ship inside
* their group's JSON rather than as a zip, and `skill-menu.json` is where the
* group is published.
*/
function generateManifest({ resources, uriSchema, version, docContents = {} }) {
const scheme = uriSchema.scheme;
const skillPattern = uriSchema.patterns.skill;
const docPattern = uriSchema.patterns.doc;

const baseDownloadUrl = process.env.SKILLS_BASE_URL
|| (version && version !== 'dev'
? `${REPO_URL}/releases/download/v${version}`
: `${REPO_URL}/releases/latest/download`);
const baseDownloadUrl = resolveBaseDownloadUrl(version);

return {
version: uriSchema.manifest_version,
buildVersion: version,
buildTimestamp: new Date().toISOString(),
resources: resources.map(skill => {
// A bundled variant ships inside its group's JSON, so it has no zip of its own to point at.
resources: resources.filter(skill => !skill.bundle).map(skill => {
const isGuide = skill.type === 'doc' && docContents[skill.id];
const uri = isGuide
? `${scheme}${docPattern.replace('{id}', skill.id)}`
Expand Down Expand Up @@ -203,7 +211,8 @@ function writeManifestAndMenu({ allSkills, docContents, distDir, configDir, vers
group,
bundle: true,
variants: [],
downloadUrl: url?.replace(/\/[^/]+\.zip$/, `/${group}.json`),
// Bundled variants are absent from the manifest, so the group's URL is built from the base.
downloadUrl: `${resolveBaseDownloadUrl(version)}/${group}.json`,
};
bundleEntries.set(group, entry);
skillsByCategory[cat].push(entry);
Expand Down
68 changes: 68 additions & 0 deletions scripts/lib/tests/manifest-generator.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { generateManifest } from '../build-phases.js';

const uriSchema = {
manifest_version: '1.0',
scheme: 'posthog://',
patterns: { skill: 'skills/{group}/{id}', doc: 'docs/{id}' },
};

const skill = (shortId, extra = {}) => ({
id: `integration-v2-capture-${shortId}`,
shortId,
name: shortId,
group: 'integration-v2/capture',
description: `${shortId} description`,
tags: [],
...extra,
});

const generate = (resources, docContents = {}) =>
generateManifest({ resources, uriSchema, version: '1.2.3', docContents });

const ids = manifest => manifest.resources.map(r => r.id);

describe('generateManifest', () => {
it('omits bundled variants, which ship inside their group JSON and have no zip to point at', () => {
const manifest = generate([skill('django', { bundle: true }), skill('nextjs', { bundle: true })]);

expect(manifest.resources).toEqual([]);
});

it('keeps an unbundled skill alongside bundled variants of the same group', () => {
const manifest = generate([skill('django', { bundle: true }), skill('nextjs')]);

expect(ids(manifest)).toEqual(['integration-v2-capture-nextjs']);
});

it('gives an unbundled skill a file and download url naming its own zip', () => {
const [entry] = generate([skill('nextjs')]).resources;

expect(entry.file).toBe('integration-v2-capture-nextjs.zip');
expect(entry.downloadUrl).toMatch(/integration-v2-capture-nextjs\.zip$/);
expect(entry.resource.text).toBe(entry.downloadUrl);
});

it('never emits a file for a resource the build does not ship as a zip', () => {
const manifest = generate(
[skill('django', { bundle: true }), skill('nextjs'), { id: 'guide', type: 'doc', name: 'Guide', tags: [] }],
{ guide: 'guide text' },
);

const shipped = new Set(['integration-v2-capture-nextjs.zip']);
for (const entry of manifest.resources) {
if (entry.file) {
expect(shipped).toContain(entry.file);
}
}
});

it('inlines doc text instead of pointing at a zip', () => {
const [entry] = generate([{ id: 'guide', type: 'doc', name: 'Guide', tags: [] }], { guide: 'guide text' })
.resources;

expect(entry.file).toBeUndefined();
expect(entry.resource.mimeType).toBe('text/markdown');
expect(entry.resource.text).toBe('guide text');
});
});
69 changes: 69 additions & 0 deletions scripts/lib/tests/manifest-menu-writer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { writeManifestAndMenu } from '../build-phases.js';

const URI_SCHEMA = `
manifest_version: "1.0"
scheme: posthog://
patterns:
skill: skills/{group}/{id}
doc: docs/{id}
`;

const skill = (shortId, extra = {}) => ({
id: `integration-v2-capture-${shortId}`,
shortId,
name: shortId,
group: 'integration-v2/capture',
description: `${shortId} description`,
tags: [],
...extra,
});

let dir;
const configDir = () => path.join(dir, 'config');
const distDir = () => path.join(dir, 'dist');
const read = name => JSON.parse(fs.readFileSync(path.join(distDir(), 'skills', name), 'utf8'));

const write = allSkills =>
writeManifestAndMenu({ allSkills, docContents: {}, distDir: distDir(), configDir: configDir(), version: '1.2.3' });

const menuEntries = () => Object.values(read('skill-menu.json').categories).flat();

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'manifest-menu-'));
fs.mkdirSync(configDir(), { recursive: true });
fs.writeFileSync(path.join(configDir(), 'uri-schema.yaml'), URI_SCHEMA);
});

afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

describe('writeManifestAndMenu', () => {
it('gives a bundled group one menu entry whose download url names the group JSON', () => {
write([skill('django', { bundle: true }), skill('nextjs', { bundle: true })]);

const [entry] = menuEntries();
expect(entry.bundle).toBe(true);
expect(entry.downloadUrl).toMatch(/\/integration-v2-capture\.json$/);
});

it('keeps bundled variants out of the manifest while the menu still lists every one', () => {
write([skill('django', { bundle: true }), skill('nextjs', { bundle: true })]);

expect(read('manifest.json').resources).toEqual([]);
expect(menuEntries()[0].variants.map(v => v.id).sort()).toEqual([
'integration-v2-capture-django',
'integration-v2-capture-nextjs',
]);
});

it('still points an unbundled skill at its own zip', () => {
write([skill('nextjs')]);

const [entry] = menuEntries();
expect(entry.bundle).toBeUndefined();
expect(entry.downloadUrl).toMatch(/\/integration-v2-capture-nextjs\.zip$/);
});
});
Loading