mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
d110b3bb6d
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
// publish shells out to git + gh; drive those from here so we can assert what it WOULD run.
|
|
const calls: Array<{ bin: string; args: string[] }> = [];
|
|
let releaseExistsOnRemote = false;
|
|
/** The commit the local tag points at; null = no local tag. `git tag` mutates it like git would. */
|
|
let localTagSha: string | null = null;
|
|
let remoteTagOnOrigin = false;
|
|
let failPrCreate = false;
|
|
const HEAD_SHA = 'headfeedheadfeedheadfeedheadfeedheadfeed';
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFileSync: (bin: string, args: string[]) => {
|
|
calls.push({ bin, args });
|
|
if (bin === 'gh' && args[0] === 'release' && args[1] === 'view') {
|
|
if (!releaseExistsOnRemote) throw new Error('release not found');
|
|
return Buffer.from('');
|
|
}
|
|
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create' && failPrCreate) throw new Error('pr create exploded');
|
|
if (bin === 'gh' && args[0] === 'api') return Buffer.from('someone');
|
|
if (bin === 'git' && args.includes('ls-remote')) {
|
|
return Buffer.from(remoteTagOnOrigin ? `${HEAD_SHA}\trefs/tags/v1.0.0` : '');
|
|
}
|
|
if (bin === 'git' && args.includes('tag')) {
|
|
// Mirror git's behaviour so later rev-parses see what the run did.
|
|
const i = args.indexOf('tag');
|
|
if (args[i + 1] === '-d') localTagSha = null;
|
|
else localTagSha = HEAD_SHA; // `git tag <t>` and `git tag -f <t>` both land on HEAD
|
|
return Buffer.from('');
|
|
}
|
|
if (bin === 'git' && args.includes('rev-parse')) {
|
|
const target = args[args.indexOf('rev-parse') + 1];
|
|
if (target === 'HEAD') return Buffer.from(HEAD_SHA);
|
|
if (localTagSha === null) throw new Error('unknown revision');
|
|
return Buffer.from(localTagSha);
|
|
}
|
|
return Buffer.from('');
|
|
},
|
|
}));
|
|
|
|
const { publishPlugin } = await import('../src/cli/publish.js');
|
|
const { scaffold } = await import('../src/cli/create.js');
|
|
const { makePublishable } = await import('./helpers.js');
|
|
|
|
beforeEach(() => {
|
|
localTagSha = null;
|
|
remoteTagOnOrigin = false;
|
|
failPrCreate = false;
|
|
});
|
|
|
|
describe('publish — a released artifact is immutable', () => {
|
|
let tmp: string;
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
releaseExistsOnRemote = false;
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-'));
|
|
scaffold('immutable-plug', 'integration', tmp);
|
|
dir = path.join(tmp, 'immutable-plug');
|
|
// publish now gates on the registry's checks BEFORE it packs or releases, so a bare scaffold
|
|
// never reaches the `gh` calls these tests are about. Document the plugin first.
|
|
makePublishable(dir);
|
|
});
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
const run = (force?: boolean) => publishPlugin({
|
|
dir, repo: 'someone/trek-plugin-immutable-plug', tag: 'v1.0.0',
|
|
now: '2026-01-01T00:00:00Z', skipPreflight: true, force, log: () => {},
|
|
});
|
|
|
|
it('REFUSES to overwrite an existing release, and never uploads', async () => {
|
|
releaseExistsOnRemote = true;
|
|
// The registry pins the artifact's sha256. Rewriting the bytes of a release that is
|
|
// already in the registry breaks that pin for everyone who installed that version.
|
|
await expect(run()).rejects.toThrow(/already exists/i);
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args.includes('upload'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args.includes('--clobber'))).toBe(false);
|
|
});
|
|
|
|
it('--force overwrites deliberately (for a release never merged into the registry)', async () => {
|
|
releaseExistsOnRemote = true;
|
|
await run(true).catch(() => {}); // submit/preflight are stubbed out; we only care about the gh calls
|
|
const upload = calls.find((c) => c.bin === 'gh' && c.args.includes('upload'));
|
|
expect(upload?.args).toContain('--clobber');
|
|
});
|
|
|
|
it('creates the release normally when it does not exist yet', async () => {
|
|
releaseExistsOnRemote = false;
|
|
await run().catch(() => {});
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'create')).toBe(true);
|
|
expect(calls.some((c) => c.args.includes('--clobber'))).toBe(false);
|
|
});
|
|
|
|
it('keeps plugin.zip — the entry sha256 must be hashed from the uploaded bytes', async () => {
|
|
await run().catch(() => {});
|
|
// A re-pack on another machine/SDK version can produce different bytes (CRLF, walk
|
|
// order), so deleting the artifact after publishing made a follow-up `entry`/`sign` wrong.
|
|
expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The reorder, and why it is the most important thing in this file.
|
|
*
|
|
* publish used to run: pack → tag + GitHub release → preflight → submit. So the checks that
|
|
* catch an unwritten README or a missing screenshot ran AFTER the release was cut — and a release
|
|
* is immutable in the only sense that matters, because the registry pins its sha256. An author
|
|
* who failed preflight had already burned their v1.0.0 tag, and the only way out was to throw it
|
|
* away and cut a v1.0.1 whose sole change was the README.
|
|
*
|
|
* So: when a local gate fails, NOTHING may happen. No zip, no tag, no push, no release.
|
|
*/
|
|
describe('publish — the local gates run before anything is cut', () => {
|
|
let tmp: string;
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
releaseExistsOnRemote = false;
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-gate-'));
|
|
scaffold('gated-plug', 'integration', tmp);
|
|
dir = path.join(tmp, 'gated-plug');
|
|
// Deliberately NOT made publishable: this is a fresh scaffold, exactly what a first-time
|
|
// author has when they reach for `publish` too early.
|
|
});
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
const run = () => publishPlugin({
|
|
dir, repo: 'someone/trek-plugin-gated-plug', tag: 'v1.0.0',
|
|
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {},
|
|
});
|
|
|
|
it('refuses an undocumented plugin and names what is wrong', async () => {
|
|
await expect(run()).rejects.toThrow(/would be rejected by the registry/i);
|
|
await expect(run()).rejects.toThrow(/prose|screenshot/i);
|
|
});
|
|
|
|
it('packs nothing, tags nothing, pushes nothing, releases nothing', async () => {
|
|
await run().catch(() => {});
|
|
|
|
expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('push'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'gh')).toBe(false);
|
|
});
|
|
|
|
it('goes through once the plugin is documented', async () => {
|
|
makePublishable(dir);
|
|
await run().catch(() => {}); // submit is not stubbed; we only care that it got past the gate
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'create')).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The signing half of the same trap.
|
|
*
|
|
* TREK pins the author key on first install. A plugin that shipped SIGNED and then publishes
|
|
* UNSIGNED is refused on every instance that already has it (SIGNATURE_MISSING) — the update is
|
|
* not merely rejected, it strands every existing user on the version they have.
|
|
*
|
|
* `preflight` has always caught this. But preflight is step 4, and the immutable GitHub release is
|
|
* cut at step 3 — so the author learned it with their tag already burned, for a fact that was
|
|
* knowable before a single byte was packed. The check belongs in step 1, and this pins it there.
|
|
*/
|
|
/**
|
|
* The cleanup half of the immutability story. Step 3 creates a local tag, pushes it, and cuts
|
|
* the release; when step 4/5 then failed, all three were stranded and the author had to delete
|
|
* them by hand before re-running. A failed publish now rolls back exactly what IT created —
|
|
* and only that: anything that existed before the run (a merged release, a pre-pushed tag) is
|
|
* never touched.
|
|
*/
|
|
describe('publish — a failed run rolls back what it created', () => {
|
|
let tmp: string;
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
releaseExistsOnRemote = false;
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-rollback-'));
|
|
scaffold('rollback-plug', 'integration', tmp);
|
|
dir = path.join(tmp, 'rollback-plug');
|
|
makePublishable(dir);
|
|
});
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
const run = (opts: { force?: boolean; keepRelease?: boolean } = {}) => publishPlugin({
|
|
dir, repo: 'someone/trek-plugin-rollback-plug', tag: 'v1.0.0',
|
|
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {}, ...opts,
|
|
});
|
|
|
|
it('deletes the release, remote tag, and local tag this run created', async () => {
|
|
failPrCreate = true;
|
|
await expect(run()).rejects.toThrow(/rolled back/i);
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args[0] === 'release' && c.args[1] === 'delete' && c.args.includes('v1.0.0'))).toBe(true);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes(':refs/tags/v1.0.0'))).toBe(true);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
|
|
});
|
|
|
|
it('--keep-release leaves everything in place and prints the manual cleanup', async () => {
|
|
failPrCreate = true;
|
|
await expect(run({ keepRelease: true })).rejects.toThrow(/gh release delete/);
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
|
|
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
|
|
});
|
|
|
|
it('never deletes a release or remote tag that existed BEFORE the run', async () => {
|
|
releaseExistsOnRemote = true;
|
|
remoteTagOnOrigin = true;
|
|
failPrCreate = true;
|
|
await run({ force: true }).catch(() => {});
|
|
expect(calls.some((c) => c.bin === 'gh' && c.args[1] === 'delete')).toBe(false);
|
|
expect(calls.some((c) => c.args.includes(':refs/tags/v1.0.0'))).toBe(false);
|
|
// the local tag WAS created by this run, so that one goes
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-d'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The stale-tag trap: a failed publish leaves a tag; the author commits a fix and re-runs; the
|
|
* old flow silently reused the stale tag and released the UNFIXED code. A tag that does not
|
|
* point at HEAD and has no release is debris — move it. One that HAS a release is immutable.
|
|
*/
|
|
describe('publish — a stale tag from a failed run is moved, not reused', () => {
|
|
let tmp: string;
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
releaseExistsOnRemote = false;
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-stale-'));
|
|
scaffold('stale-plug', 'integration', tmp);
|
|
dir = path.join(tmp, 'stale-plug');
|
|
makePublishable(dir);
|
|
});
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
const run = () => publishPlugin({
|
|
dir, repo: 'someone/trek-plugin-stale-plug', tag: 'v1.0.0',
|
|
now: '2026-01-01T00:00:00Z', skipPreflight: true, log: () => {},
|
|
});
|
|
|
|
it('re-points a debris tag (no release) at HEAD and force-pushes it', async () => {
|
|
localTagSha = 'stalestalestalestalestalestalestalestale';
|
|
await run().catch(() => {});
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && c.args.includes('-f'))).toBe(true);
|
|
const push = calls.find((c) => c.bin === 'git' && c.args.includes('push') && c.args.includes('v1.0.0'));
|
|
expect(push?.args).toContain('--force');
|
|
});
|
|
|
|
it('refuses a mismatched tag whose release already exists', async () => {
|
|
localTagSha = 'stalestalestalestalestalestalestalestale';
|
|
releaseExistsOnRemote = true;
|
|
await expect(run()).rejects.toThrow(/points at/i);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('-f'))).toBe(false);
|
|
});
|
|
|
|
it('reuses a tag already on HEAD without touching it', async () => {
|
|
localTagSha = HEAD_SHA;
|
|
await run().catch(() => {});
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag') && !c.args.includes('-d'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('publish — a signed plugin cannot quietly go unsigned', () => {
|
|
let tmp: string;
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
calls.length = 0;
|
|
releaseExistsOnRemote = false;
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'publish-sign-'));
|
|
scaffold('signed-plug', 'integration', tmp);
|
|
dir = path.join(tmp, 'signed-plug');
|
|
makePublishable(dir);
|
|
});
|
|
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
/** Pretend the registry already lists this plugin as signed, without hitting the network. */
|
|
const publishedSigned = {
|
|
hasKey: true,
|
|
keyPath: '/nonexistent/signing.key',
|
|
publishedSigned: true,
|
|
publishedKey: 'a-published-key',
|
|
};
|
|
|
|
const run = (signKeyPath?: string) => publishPlugin({
|
|
dir, repo: 'someone/trek-plugin-signed-plug', tag: 'v2.0.0',
|
|
now: '2026-01-01T00:00:00Z', skipPreflight: true, signKeyPath,
|
|
signing: publishedSigned, log: () => {},
|
|
});
|
|
|
|
it('refuses, and names what it would have broken', async () => {
|
|
await expect(run(undefined)).rejects.toThrow(/published SIGNED/i);
|
|
await expect(run(undefined)).rejects.toThrow(/strand every existing user/i);
|
|
});
|
|
|
|
it('cuts no release — the whole point of checking at step 1', async () => {
|
|
await run(undefined).catch(() => {});
|
|
expect(fs.existsSync(path.join(dir, 'plugin.zip'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'git' && c.args.includes('tag'))).toBe(false);
|
|
expect(calls.some((c) => c.bin === 'gh')).toBe(false);
|
|
});
|
|
});
|