diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b21201ac..69b3694d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,8 +47,14 @@ permissions: issues: write jobs: - # macOS ARM is a standalone job so publish-npm can depend on it alone, - # without being blocked by the Windows leg. + # macOS ARM is a standalone job so downstream sync jobs (sync-homebrew-cask, + # sync-skill-repo) can depend on it alone, without being blocked by the + # Windows leg. (An earlier publish-npm job used to be the reason this was + # split out; it was removed before ever running -- npm publishing was + # dropped as a distribution path in favor of the public + # NodeSpaceAI/nodespace-skill repo sync-skill-repo pushes to, so there is no + # publish-npm job to depend on it anymore. The split itself stayed useful + # for the reason above and was kept.) build-tauri-macos-arm: name: Build Tauri (macOS ARM) runs-on: macos-latest @@ -544,6 +550,35 @@ jobs: # loudly here and notify-failure below opens an issue. HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + sync-skill-repo: + name: Publish NodeSpace skill to nodespace-skill + runs-on: ubuntu-22.04 + # Unlike sync-homebrew-cask/sync-homebrew-formula, this job needs no + # release asset -- scripts/publish-skill-repo.ts reads packages/skill/ + # SKILL.md and references/cli.md straight from the checked-out repo at + # the release tag, so it doesn't wait on any build-* job and cannot be + # blocked by one of them failing. + if: github.event_name == 'release' + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + + - name: Publish skill to nodespace-skill + run: bun run scripts/publish-skill-repo.ts ${{ github.event.release.tag_name }} --push + env: + # PAT (or fine-grained token) with contents:write on + # NodeSpaceAI/nodespace-skill -- secrets.GITHUB_TOKEN is scoped to + # this repo only and cannot push cross-repo. Must be added as a + # repo secret before this step can succeed; until then it fails + # loudly here and notify-failure below opens an issue. + SKILL_REPO_TOKEN: ${{ secrets.SKILL_REPO_TOKEN }} + sync-install-script: name: Pin nodespace-website's install.sh to this release runs-on: ubuntu-22.04 @@ -629,7 +664,7 @@ jobs: # before this job existed there was only ever one writer per release. # Running them concurrently would let either `git push` lose a # non-fast-forward race against the other with no retry (see - # homebrew-tap-push.ts) -- ordering this job after sync-homebrew-cask + # push-to-external-repo.ts) -- ordering this job after sync-homebrew-cask # finishes (success OR failure, hence `always()` below rather than # gating on its result) removes that race entirely: this job's own # fresh `git clone` always sees whatever sync-homebrew-cask already @@ -661,7 +696,7 @@ jobs: notify-failure: name: Notify on build failure runs-on: ubuntu-22.04 - needs: [build-tauri-macos-arm, build-tauri, build-pkg-macos, build-headless, sync-homebrew-cask, sync-install-script, sync-homebrew-formula] + needs: [build-tauri-macos-arm, build-tauri, build-pkg-macos, build-headless, sync-homebrew-cask, sync-skill-repo, sync-install-script, sync-homebrew-formula] if: failure() steps: diff --git a/Cargo.lock b/Cargo.lock index 51a9e4c00..8a318df99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3377,7 +3377,7 @@ dependencies = [ [[package]] name = "nodespace-app" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "async-stream", diff --git a/packages/skill/README.md b/packages/skill/README.md index 028de51e6..5ddfbd63e 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -1,30 +1,43 @@ # @nodespaceai/skill -Install the NodeSpace skill into PTY agents (Claude Code, Codex, Gemini CLI, OpenCode) with a single command. - -## Installation - -```bash -npx @nodespaceai/skill install -``` - -Detects which agents are present on your machine and installs the NodeSpace `SKILL.md` into each agent's skills directory. - -## Usage +Installs the NodeSpace Agent Skill into PTY agents (Claude Code, Codex, Gemini CLI, OpenCode). + +**This package is not published to npm.** `@nodespaceai/skill` never existed +on the npm registry, and it is not going to: the NodeSpace desktop app is the +one thing that installs this package's output, and it does so by running the +built installer directly with `bun` (see +`packages/desktop-app/src-tauri/src/skill_setup.rs`) — never `npx`/`npm`, so +publishing to npm was never actually required for the app's own install path. + +If you're using an external agent harness yourself (not launched via the +NodeSpace app) and want the skill without installing NodeSpace first, import +the generated public repo instead: +**[NodeSpaceAI/nodespace-skill](https://github.com/NodeSpaceAI/nodespace-skill)**. +It carries a spec-compliant `skills/nodespace/` folder, regenerated and +pushed by this repo's release pipeline (`scripts/publish-skill-repo.ts`) on +every release — never hand-edited. + +## What's in this package + +This package's build output (`dist/`, `shims/`, `SKILL.md`, `references/`) is +consumed two ways, both inside this monorepo's own tooling: + +1. **Bundled into the desktop app.** `scripts/build-skill.ts` stages it into + `packages/desktop-app/src-tauri/resources/skill/` as a Tauri resource. On + first launch, the app runs `bun dist/install.js install` to detect which + agents are present and copy `SKILL.md` (with the right frontmatter + prepended) into each one's skills directory. +2. **Published to `NodeSpaceAI/nodespace-skill`.** The release pipeline runs + `scripts/publish-skill-repo.ts`, which renders the same frontmatter this + package builds (via `buildSkillFrontmatter` in `src/agents.ts`) plus + `SKILL.md`'s body and `references/cli.md`, and pushes them to the public + repo — the channel for a harness the app didn't launch. + +## Manual usage (from a source checkout) ```bash -# Install for all detected agents -npx @nodespaceai/skill install - -# Install for a specific agent -npx @nodespaceai/skill install claude-code -npx @nodespaceai/skill install codex -npx @nodespaceai/skill install gemini -npx @nodespaceai/skill install opencode - -# Uninstall -npx @nodespaceai/skill uninstall -npx @nodespaceai/skill uninstall claude-code +bun run --cwd packages/skill build +bun packages/skill/dist/install.js install ``` ## Supported Agents @@ -43,7 +56,7 @@ The `nodespace` CLI must be on `$PATH`. Install it via the [NodeSpace desktop ap ## Programmatic API ```ts -import { install, uninstall } from '@nodespaceai/skill'; +import { install, uninstall } from './installer.js'; // Install for all detected agents const results = install(); diff --git a/packages/skill/src/agents.ts b/packages/skill/src/agents.ts index 5318432d4..4074a7e2c 100644 --- a/packages/skill/src/agents.ts +++ b/packages/skill/src/agents.ts @@ -95,3 +95,32 @@ export const AGENTS: AgentConfig[] = [ /** The frontmatter block every target installs. Exported for tests. */ export const SHARED_SKILL_FRONTMATTER = SKILL_FRONTMATTER; + +/** + * Builds the shared frontmatter block, optionally stamping the optional + * `compatibility` field (max 500 chars per the spec) in before the closing + * `---`. + * + * The installer path above never needs this — the app installs whatever + * `packages/skill` was built with, and SHARED_SKILL_FRONTMATTER already + * covers it — so this stays a separate, additive export rather than a + * change to AGENTS. `scripts/publish-skill-repo.ts` is the one caller: it + * passes the released NodeSpace app version (from the same release tag + * `scripts/update-homebrew-cask.ts` takes, itself derived from the + * `tauri.conf.json` canonical version `scripts/check-version-sync.ts` + * enforces) so a user importing the published skill can tell which app + * version a given revision targets. + */ +export function buildSkillFrontmatter(opts: { compatibility?: string } = {}): string { + const { compatibility } = opts; + if (!compatibility) return SKILL_FRONTMATTER; + if (compatibility.length > 500) { + throw new Error( + `compatibility exceeds the Agent Skills spec's 500-character limit ` + + `(${compatibility.length}): ${compatibility}`, + ); + } + // Insert as the last field, right before the closing `---`, so every + // other field (and its ordering) stays identical to SHARED_SKILL_FRONTMATTER. + return SKILL_FRONTMATTER.replace(/\n---\n$/, `\ncompatibility: ${compatibility}\n---\n`); +} diff --git a/packages/skill/src/install.ts b/packages/skill/src/install.ts index d211d53f3..4be355a9e 100644 --- a/packages/skill/src/install.ts +++ b/packages/skill/src/install.ts @@ -1,4 +1,11 @@ #!/usr/bin/env node +// Invoked directly via `bun [agent]` — the +// desktop app's bundled installer runs it this way (see +// packages/desktop-app/src-tauri/src/skill_setup.rs), and it's the same +// invocation for a manual run from a source checkout. Never `npx`/`npm`: +// `@nodespaceai/skill` is not published to npm (the public +// NodeSpaceAI/nodespace-skill repo is the distribution channel for external +// harnesses that want the skill outside the app — see packages/skill/README.md). import { install, uninstall } from './installer.js'; import type { AgentName } from './types.js'; import { AGENTS } from './agents.js'; @@ -13,7 +20,7 @@ function isValidAgent(name: string): name is AgentName { } function printUsage(): void { - console.log(`Usage: npx @nodespaceai/skill [agent] + console.log(`Usage: bun install.js [agent] Commands: install [agent] Install NodeSpace skill for detected (or specified) agents @@ -22,9 +29,9 @@ Commands: Agents: ${validAgents.join(', ')} Examples: - npx @nodespaceai/skill install - npx @nodespaceai/skill install claude-code - npx @nodespaceai/skill uninstall`); + bun install.js install + bun install.js install claude-code + bun install.js uninstall`); } if (!command || command === '--help' || command === '-h') { @@ -46,7 +53,7 @@ if (command === 'install') { if (results.length === 0) { console.log('No supported agents detected.'); console.log(`Checked: ${validAgents.join(', ')}`); - console.log('To install manually, specify an agent: npx @nodespaceai/skill install '); + console.log('To install manually, specify an agent: bun install.js install '); process.exit(0); } diff --git a/packages/skill/src/tests/agents.test.ts b/packages/skill/src/tests/agents.test.ts new file mode 100644 index 000000000..195ac38b2 --- /dev/null +++ b/packages/skill/src/tests/agents.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { SHARED_SKILL_FRONTMATTER, buildSkillFrontmatter } from '../agents.js'; + +// buildSkillFrontmatter() is the escape hatch scripts/publish-skill-repo.ts uses +// to stamp a `compatibility` line onto the frontmatter it pushes to +// NodeSpaceAI/nodespace-skill, without touching the installer path (AGENTS still +// installs the plain SHARED_SKILL_FRONTMATTER, unchanged). +describe('buildSkillFrontmatter', () => { + it('returns SHARED_SKILL_FRONTMATTER unchanged when called with no options', () => { + expect(buildSkillFrontmatter()).toBe(SHARED_SKILL_FRONTMATTER); + }); + + it('returns SHARED_SKILL_FRONTMATTER unchanged when compatibility is omitted', () => { + expect(buildSkillFrontmatter({})).toBe(SHARED_SKILL_FRONTMATTER); + }); + + it('inserts a compatibility field right before the closing "---"', () => { + const result = buildSkillFrontmatter({ compatibility: 'Targets NodeSpace app v0.2.2' }); + expect(result).toContain('\ncompatibility: Targets NodeSpace app v0.2.2\n---\n'); + // Every other field from the shared block must still be present, unchanged. + expect(result).toContain('name: nodespace'); + expect(result).toContain('allowed-tools: Bash(nodespace:*)'); + // Exactly one "---\n" pair still closes the block (opening + closing). + expect(result.match(/^---$/gm)).toHaveLength(2); + }); + + it('rejects a compatibility string over the spec\'s 500-character limit', () => { + const tooLong = 'x'.repeat(501); + expect(() => buildSkillFrontmatter({ compatibility: tooLong })).toThrow(/500-character limit/); + }); + + it('accepts a compatibility string at exactly the 500-character limit', () => { + const atLimit = 'x'.repeat(500); + expect(() => buildSkillFrontmatter({ compatibility: atLimit })).not.toThrow(); + }); +}); diff --git a/scripts/publish-skill-repo.test.ts b/scripts/publish-skill-repo.test.ts new file mode 100644 index 000000000..506a35c20 --- /dev/null +++ b/scripts/publish-skill-repo.test.ts @@ -0,0 +1,128 @@ +// Covers the offline, deterministic parts of scripts/publish-skill-repo.ts: +// version normalization and file rendering. pushSkillUpdate (the +// GitHub-talking function) is intentionally not exercised here -- this suite +// runs as part of `bun run test:scripts` / `test:all` (the pre-push gate), +// which must stay fast and deterministic, not depend on network or a real +// SKILL_REPO_TOKEN. +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { + normalizeVersion, + readSkillSource, + renderPublishFiles, + sharedShimPaths, + SKILL_REPO, +} from "./publish-skill-repo"; + +const REPO_ROOT = join(dirname(new URL(import.meta.url).pathname), ".."); + +describe("normalizeVersion", () => { + test("strips a leading v", () => { + expect(normalizeVersion("v0.2.2")).toBe("0.2.2"); + }); + + test("leaves a bare version untouched", () => { + expect(normalizeVersion("0.2.2")).toBe("0.2.2"); + }); +}); + +describe("SKILL_REPO", () => { + test("targets the public generated-only skill repo", () => { + expect(SKILL_REPO).toBe("NodeSpaceAI/nodespace-skill"); + }); +}); + +describe("readSkillSource", () => { + test("reads the live packages/skill/SKILL.md and references/cli.md off disk", () => { + const { body, referenceCli } = readSkillSource(); + // Read independently (not via the function under test) so this actually + // catches the function reading a stale/wrong path, not just echoing it. + const expectedBody = readFileSync( + join(REPO_ROOT, "packages", "skill", "SKILL.md"), + "utf8", + ); + const expectedReferenceCli = readFileSync( + join(REPO_ROOT, "packages", "skill", "references", "cli.md"), + "utf8", + ); + expect(body).toBe(expectedBody); + expect(referenceCli).toBe(expectedReferenceCli); + }); + + // The checked-in SKILL.md body carries no frontmatter (renderPublishFiles + // is the one place that adds it) -- a body that already starts with `---` + // would double up frontmatter blocks in the published file. + test("the body has no baked-in frontmatter", () => { + const { body } = readSkillSource(); + expect(body.startsWith("---")).toBe(false); + }); +}); + +describe("sharedShimPaths", () => { + // Guards the drift class packages/skill/src/tests/installer.test.ts's + // "publishes every directory the agents install from" test guards + // elsewhere: this derives the published file set from AGENTS instead of a + // hardcoded list, so a shared file added to (or removed from) every + // agent's shims here gets picked up automatically -- and this test fails + // loudly if that derivation ever stops matching what today's AGENTS + // actually declares as shared. + test("is exactly SKILL.md and references/cli.md today", () => { + expect(sharedShimPaths().sort()).toEqual(["SKILL.md", "references/cli.md"]); + }); + + test("excludes every harness-specific shim", () => { + const shared = new Set(sharedShimPaths()); + expect(shared.has("shims/claude-code/nodespace-hook.ts")).toBe(false); + expect(shared.has("shims/codex/nodespace-plugin.ts")).toBe(false); + expect(shared.has("shims/gemini/nodespace-handler.ts")).toBe(false); + expect(shared.has("shims/opencode/nodespace-plugin.ts")).toBe(false); + }); +}); + +describe("renderPublishFiles", () => { + test("publishes exactly SKILL.md and references/cli.md under skills/nodespace/", () => { + const files = renderPublishFiles("v0.2.2"); + expect(files.map((f) => f.relPath).sort()).toEqual([ + "skills/nodespace/SKILL.md", + "skills/nodespace/references/cli.md", + ]); + }); + + test("SKILL.md is spec-compliant frontmatter + the unmodified body", () => { + const files = renderPublishFiles("v0.2.2"); + const skillMd = files.find((f) => f.relPath === "skills/nodespace/SKILL.md")!; + const { body } = readSkillSource(); + + expect(skillMd.content.startsWith("---\nname: nodespace\n")).toBe(true); + expect(skillMd.content).toContain(body); + // name must match the directory it publishes into. + expect(skillMd.relPath.split("/")[1]).toBe("nodespace"); + }); + + test("stamps a compatibility field with the released app version, within the spec's 500-char limit", () => { + const files = renderPublishFiles("v0.2.2"); + const skillMd = files.find((f) => f.relPath === "skills/nodespace/SKILL.md")!; + const m = /^compatibility:\s*(.+)$/m.exec(skillMd.content); + expect(m).toBeTruthy(); + expect(m![1]).toContain("v0.2.2"); + expect(m![1].length).toBeLessThanOrEqual(500); + }); + + test("normalizes a leading v the same way for the compatibility field", () => { + const withV = renderPublishFiles("v0.2.2").find( + (f) => f.relPath === "skills/nodespace/SKILL.md", + )!; + const withoutV = renderPublishFiles("0.2.2").find( + (f) => f.relPath === "skills/nodespace/SKILL.md", + )!; + expect(withV.content).toBe(withoutV.content); + }); + + test("references/cli.md is copied through verbatim", () => { + const files = renderPublishFiles("v0.2.2"); + const referenceCli = files.find((f) => f.relPath === "skills/nodespace/references/cli.md")!; + const { referenceCli: expected } = readSkillSource(); + expect(referenceCli.content).toBe(expected); + }); +}); diff --git a/scripts/publish-skill-repo.ts b/scripts/publish-skill-repo.ts new file mode 100644 index 000000000..072ea0ce4 --- /dev/null +++ b/scripts/publish-skill-repo.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env bun +/** + * Publish the NodeSpace Agent Skill to NodeSpaceAI/nodespace-skill -- the + * public repo external agent harnesses (Claude Code, Codex, Gemini CLI, + * OpenCode, ...) import to get NodeSpace's Agent Skill without installing + * the NodeSpace app first. + * + * `@nodespaceai/skill` is not published to npm and this script does not + * change that: the desktop app runs `packages/skill`'s built installer + * directly with `bun` (never `npx`/`npm` -- see + * packages/desktop-app/src-tauri/src/skill_setup.rs), so npm publishing was + * never actually required for the app's own install path -- the package + * never landing on the registry never blocked anything real. This repo is + * the sole distribution channel for the harness-native import path. + * + * It never hand-writes the skill body: it copies `packages/skill/SKILL.md` + * (the body -- the checked-in file carries no frontmatter, see + * packages/skill/src/types.ts) and `packages/skill/references/cli.md` + * verbatim, whatever they currently are. If a future change to + * `packages/skill` shrinks SKILL.md to a stub with guidance fetched from the + * graph at runtime instead, this script keeps working unmodified -- it has + * no assumption baked in about the body's size or shape, only about where it + * lives on disk. + * + * The published SKILL.md gets the same shared frontmatter every installer + * target uses (`buildSkillFrontmatter` in packages/skill/src/agents.ts: + * `name`, `description`, `allowed-tools` -- every field is one the Agent + * Skills spec (agentskills.io) defines, `allowed-tools` included: it's + * listed as one of the spec's six frontmatter fields, "Experimental" but not + * Claude-Code-specific, so sharing it across all four installer targets -- + * and this generic publish target -- is spec-compliant, not a Claude Code + * leak), plus one field the installer path doesn't set: `compatibility`, + * stamped with the released NodeSpace app version so a user importing this + * repo can tell which app version a given revision targets. That version + * comes from the same release tag update-homebrew-cask.ts takes as its + * `` argument -- itself the canonical app version + * scripts/check-version-sync.ts enforces (tauri.conf.json) -- not + * re-derived from packages/skill/package.json's own version field, which is + * NOT kept in sync with the app (it still reads 0.2.0 while the app ships + * 0.2.2) and would be a second, already-drifted source of truth. + * + * This is a generated-only repo, same contract as homebrew-nodespace + * (NodeSpaceAI/nodespace-skill's own README says so): every push here + * overwrites by rendering fresh from `packages/skill`, never reads back or + * merges what's already there. Hand edits to NodeSpaceAI/nodespace-skill do + * not survive the next release. + * + * Usage: + * bun run scripts/publish-skill-repo.ts # dry run -- prints + * # the rendered files, pushes nothing + * bun run scripts/publish-skill-repo.ts --push # pushes to nodespace-skill's + * # main branch (requires + * # SKILL_REPO_TOKEN) + * + * `--push` requires SKILL_REPO_TOKEN: a PAT (or fine-grained token) with + * `contents: write` on NodeSpaceAI/nodespace-skill, set as a repo secret -- + * the same shape as HOMEBREW_TAP_TOKEN and WEBSITE_DEPLOY_TOKEN, one + * dedicated token per external target repo rather than one shared credential + * (a token scoped to nodespace-skill cannot be used to push anywhere else if + * it ever leaks). `secrets.GITHUB_TOKEN` is scoped to nodespace-core only + * and cannot push cross-repo. + * + * Where this hooks in: release.yml's `sync-skill-repo` job runs this script + * with `--push` on the `release` event (the same `published`-only trigger + * the Homebrew tap sync uses, so a release saved as a draft and published + * later fires this exactly once). + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { AGENTS, buildSkillFrontmatter } from "../packages/skill/src/agents"; +import { pushFilesToRepo, type RepoFile } from "./push-to-external-repo"; + +export const SKILL_REPO = "NodeSpaceAI/nodespace-skill"; + +const REPO_ROOT = join(dirname(new URL(import.meta.url).pathname), ".."); +const SKILL_DIR = join(REPO_ROOT, "packages", "skill"); + +export function normalizeVersion(version: string): string { + return version.replace(/^v/, ""); +} + +/** The harness-agnostic files every installer target ships -- the + * intersection of all four agents' `shims` lists in packages/skill/src/agents.ts + * (today: SKILL.md and references/cli.md). Harness-specific shims (the + * `shims/claude-code/nodespace-hook.ts` family) are deliberately excluded: + * they're per-harness integration glue the installer places into each + * agent's own hook/plugin system, not part of a generic Agent Skills folder + * a user can drop into any of the four. + * + * Derived from AGENTS rather than hardcoded, on purpose: this is the same + * "four separate places enumerate what the skill is made of" drift class + * `packages/skill/src/tests/installer.test.ts` guards against for + * build-skill.ts, the installer, and the PTY path -- a fifth hardcoded list + * here would be exactly the kind of copy that silently stops matching AGENTS + * if a shared reference is ever added or removed. */ +export function sharedShimPaths(): string[] { + const [first, ...rest] = AGENTS.map((a) => new Set(a.shims)); + return [...first].filter((path) => rest.every((shims) => shims.has(path))); +} + +/** Reads packages/skill's current build inputs from disk -- never a cached + * or previously-rendered copy, so this always reflects whatever + * `packages/skill` produces *right now*, drift-free by construction. */ +export function readSkillSource(): { body: string; referenceCli: string } { + return { + body: readFileSync(join(SKILL_DIR, "SKILL.md"), "utf8"), + referenceCli: readFileSync(join(SKILL_DIR, "references", "cli.md"), "utf8"), + }; +} + +/** Renders every shared file this script publishes -- the SKILL.md + * frontmatter is generated here (`compatibility` needs the release version, + * which `packages/skill`'s own build doesn't know at compile time); every + * other shared file (currently just `references/cli.md`) is copied through + * unmodified. */ +export function renderPublishFiles(version: string): RepoFile[] { + const v = normalizeVersion(version); + const frontmatter = buildSkillFrontmatter({ + compatibility: `Targets NodeSpace app v${v}. Requires the \`nodespace\` CLI on $PATH.`, + }); + + return sharedShimPaths().map((relSrcPath) => { + const content = readFileSync(join(SKILL_DIR, relSrcPath), "utf8"); + const isSkillMd = relSrcPath === "SKILL.md"; + return { + relPath: `skills/nodespace/${relSrcPath}`, + content: isSkillMd ? frontmatter + "\n" + content : content, + }; + }); +} + +async function pushSkillUpdate(version: string, files: RepoFile[], token: string): Promise { + const v = normalizeVersion(version); + const pushed = await pushFilesToRepo( + SKILL_REPO, + files, + `Publish skill for v${v} (automated release sync)`, + token, + ); + console.log( + pushed + ? `Pushed skill update for v${v} to ${SKILL_REPO}.` + : `${SKILL_REPO} already matches -- nothing to push.`, + ); +} + +function usage(): void { + console.log(`Usage: + bun run scripts/publish-skill-repo.ts [--push]`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || !/^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(command)) { + usage(); + process.exit(1); + } + + const push = args.includes("--push"); + const token = process.env.SKILL_REPO_TOKEN; + if (push && !token) { + console.error( + "SKILL_REPO_TOKEN is not set -- required for --push (a PAT with contents:write on " + + `${SKILL_REPO}). Running without --push shows what would change.`, + ); + process.exit(1); + } + + const files = renderPublishFiles(command); + for (const file of files) { + console.log(`--- ${file.relPath} ---`); + console.log(file.content); + } + + if (!push) { + console.log("(dry run -- pass --push with SKILL_REPO_TOKEN set to publish this)"); + return; + } + await pushSkillUpdate(command, files, token as string); +} + +if (import.meta.main) { + // Matches scripts/test-gate.ts's convention: a bare uncaught rejection + // here would otherwise print a raw Bun stack trace / ShellError dump + // instead of an operator-facing message. + try { + await main(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ ${message}`); + process.exit(1); + } +} diff --git a/scripts/homebrew-tap-push.ts b/scripts/push-to-external-repo.ts similarity index 57% rename from scripts/homebrew-tap-push.ts rename to scripts/push-to-external-repo.ts index 393bd728d..cc56f7e55 100644 --- a/scripts/homebrew-tap-push.ts +++ b/scripts/push-to-external-repo.ts @@ -1,15 +1,21 @@ #!/usr/bin/env bun /** * Shared "clone, write, commit, push" mechanics for pushing an automated - * update to NodeSpaceAI/homebrew-nodespace (the tap repo). + * update to a generated-only public repo this org owns. * - * Used by both update-homebrew-cask.ts (Casks/nodespace.rb) and - * update-homebrew-formula.ts (Formula/nodespace-cli.rb) -- pulled out here - * so the credential-scrubbing safety net (covering every step, not just the - * clone -- see pushFilesToTap's inner try/catch) can't drift out of sync - * between two copies of the same ~35 lines. Both scripts still own their - * own rendering, digest resolution, and drift-check logic; this file is - * only the network/git plumbing they share. + * Used by update-homebrew-cask.ts (NodeSpaceAI/homebrew-nodespace, + * Casks/nodespace.rb), update-homebrew-formula.ts (same repo, + * Formula/nodespace-cli.rb), and publish-skill-repo.ts + * (NodeSpaceAI/nodespace-skill, skills/nodespace/) -- pulled out here so the + * credential-scrubbing safety net (covering every step, not just the clone -- + * see pushFilesToRepo's inner try/catch) can't drift out of sync between + * three copies of the same ~35 lines. Each caller still owns its own + * rendering, digest resolution, and drift-check logic; this file is only the + * network/git plumbing they share. + * + * Originally homebrew-tap-push.ts, hardcoded to the tap repo alone -- now + * generalized (`repo` is a parameter, not a module constant) now that a + * second, unrelated target repo needs the identical mechanics. */ import { $ } from "bun"; @@ -17,25 +23,25 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -export const TAP_REPO = "NodeSpaceAI/homebrew-nodespace"; - -export interface TapFile { - /** Path relative to the tap repo root, e.g. "Casks/nodespace.rb" or - * "Formula/nodespace-cli.rb". */ +export interface RepoFile { + /** Path relative to the target repo root, e.g. "Casks/nodespace.rb" or + * "skills/nodespace/SKILL.md". */ relPath: string; content: string; } -/** Clones TAP_REPO fresh, writes each of `files`, and pushes a single - * commit to main if anything actually changed. Returns whether a push - * happened, so callers can print an accurate "already in sync" vs "pushed" - * message without a second network round trip to check. */ -export async function pushFilesToTap( - files: TapFile[], +/** Clones `repo` (e.g. "NodeSpaceAI/homebrew-nodespace") fresh, writes each + * of `files`, and pushes a single commit to main if anything actually + * changed. Returns whether a push happened, so callers can print an + * accurate "already in sync" vs "pushed" message without a second network + * round trip to check. */ +export async function pushFilesToRepo( + repo: string, + files: RepoFile[], commitMessage: string, token: string, ): Promise { - const workDir = mkdtempSync(join(tmpdir(), "homebrew-nodespace-push-")); + const workDir = mkdtempSync(join(tmpdir(), "nodespace-external-repo-push-")); try { // Defense in depth, covering every git/fs step below (not just the // clone): git itself redacts credentials from its own stderr on an @@ -46,7 +52,7 @@ export async function pushFilesToTap( // block regardless, in case a wrapper error ever echoes more than // expected. This must never surface the raw token. try { - const authUrl = `https://x-access-token:${token}@github.com/${TAP_REPO}.git`; + const authUrl = `https://x-access-token:${token}@github.com/${repo}.git`; await $`git clone --depth 1 ${authUrl} ${workDir}`.quiet(); for (const file of files) { @@ -69,7 +75,7 @@ export async function pushFilesToTap( token, "***", ); - throw new Error(`push to ${TAP_REPO} failed: ${message}`); + throw new Error(`push to ${repo} failed: ${message}`); } } finally { // Also closes the residual window where a temp credential URL sits in diff --git a/scripts/update-homebrew-cask.ts b/scripts/update-homebrew-cask.ts index b91f33dbc..62f28ad8e 100644 --- a/scripts/update-homebrew-cask.ts +++ b/scripts/update-homebrew-cask.ts @@ -40,7 +40,7 @@ * the sync job itself was skipped or failed silently. * * The actual "clone the tap, write the file, commit, push" mechanics live - * in homebrew-tap-push.ts, shared with update-homebrew-formula.ts (the same + * in push-to-external-repo.ts, shared with update-homebrew-formula.ts (the same * kind of sync job for the sibling `nodespace-cli` Homebrew formula). */ @@ -49,10 +49,10 @@ import { createHash } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pushFilesToTap, TAP_REPO } from "./homebrew-tap-push"; +import { pushFilesToRepo } from "./push-to-external-repo"; export const CORE_REPO = "NodeSpaceAI/nodespace-core"; -export { TAP_REPO }; +export const TAP_REPO = "NodeSpaceAI/homebrew-nodespace"; // The .app bundle's CLI binary lives at Contents/MacOS/nodespace in the // current Tauri packaging layout (verified by mounting the real v0.2.0 .dmg @@ -271,7 +271,8 @@ export async function checkTapDrift(): Promise { async function pushCaskUpdate(version: string, caskContent: string, token: string): Promise { const v = normalizeVersion(version); - const pushed = await pushFilesToTap( + const pushed = await pushFilesToRepo( + TAP_REPO, [{ relPath: "Casks/nodespace.rb", content: caskContent }], `Update cask to v${v} (automated release sync)`, token, diff --git a/scripts/update-homebrew-formula.ts b/scripts/update-homebrew-formula.ts index b52339bc9..c8d042844 100644 --- a/scripts/update-homebrew-formula.ts +++ b/scripts/update-homebrew-formula.ts @@ -62,7 +62,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { pushFilesToTap, TAP_REPO } from "./homebrew-tap-push"; +import { pushFilesToRepo } from "./push-to-external-repo"; import { REQUIRED_HEADLESS_TARGETS } from "./publish-install-script"; import { downloadAndHash, @@ -70,6 +70,7 @@ import { fetchReleaseAssets, isVersionDrifted, normalizeVersion, + TAP_REPO, } from "./update-homebrew-cask"; export { TAP_REPO }; @@ -416,7 +417,8 @@ async function pushFormulaUpdate( token: string, ): Promise { const v = normalizeVersion(version); - const pushed = await pushFilesToTap( + const pushed = await pushFilesToRepo( + TAP_REPO, [{ relPath: "Formula/nodespace-cli.rb", content: formulaContent }], `Update nodespace-cli formula to v${v} (automated release sync)`, token,