diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89af478..64af56b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,7 +147,7 @@ jobs: RELEASE_DIR: ${{ runner.temp }}/gkit-release RELEASE_ID: ${{ steps.release.outputs.release_id }} run: | - for name in "gkit-$VERSION.tgz" gkit.tgz SHA256SUMS; do + for name in "gkit-$VERSION.tgz" gkit.tgz gkit SHA256SUMS; do path="$RELEASE_DIR/$name" local_digest="sha256:$(sha256sum "$path" | cut -d ' ' -f 1)" asset_json=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ diff --git a/README.md b/README.md index ba39cd0..22072a2 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ install command. ## Install +Team machines install the standalone bundle (the bare `gkit` release asset, one executable JS file; embedded manifests and docs unpack to `~/.cache/gkit/assets/`) into `~/.local/bin` via the workspace `update-local-tool` skill. Anyone else: + Install the latest stable release globally: ```bash diff --git a/packages/gkit/package.json b/packages/gkit/package.json index 786fd0b..2dc90c1 100644 --- a/packages/gkit/package.json +++ b/packages/gkit/package.json @@ -1,6 +1,6 @@ { "name": "gkit", - "version": "0.3.4", + "version": "0.3.5", "private": true, "description": "Profile-bound, agent-first growth provider CLI.", "bin": { @@ -15,7 +15,7 @@ "sideEffects": false, "scripts": { "cli": "bun run ./src/cli.ts", - "build": "bun build ./bin/gkit.js --target=bun --outfile=dist/gkit.js", + "build": "bun build ./bin/gkit.js --target=bun --minify --define GKIT_STANDALONE=true --outfile=dist/gkit.js && chmod +x dist/gkit.js", "check-types": "tsc --noEmit", "generate:dataforseo": "bun run ./scripts/generate-dataforseo.ts", "generate:dataforseo:check": "bun run ./scripts/generate-dataforseo.ts --check", diff --git a/packages/gkit/scripts/verify-package-artifact.ts b/packages/gkit/scripts/verify-package-artifact.ts index eaa844d..3aa0f62 100644 --- a/packages/gkit/scripts/verify-package-artifact.ts +++ b/packages/gkit/scripts/verify-package-artifact.ts @@ -1,5 +1,5 @@ import { constants } from "node:fs"; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; @@ -140,6 +140,27 @@ try { throw new Error("The installed gkit artifact could not read its provider documentation."); } + // The bare bundle is the team-machine install: it must run with nothing next to it. + const bundleDirectory = join(consumerDirectory, "standalone"); + await mkdir(bundleDirectory, { recursive: true }); + const bundlePath = join(bundleDirectory, "gkit"); + await copyFile(join(packageRoot, "dist", "gkit.js"), bundlePath); + await chmod(bundlePath, 0o755); + const standaloneEnvironment = { XDG_CACHE_HOME: join(consumerDirectory, "standalone-cache") }; + await runCommand([bundlePath, "--schema", "gsc"], bundleDirectory, standaloneEnvironment); + const standaloneDocs = await runCommand( + [bundlePath, "docs", "--provider", "gsc"], + bundleDirectory, + standaloneEnvironment, + ); + const standaloneCapabilities = await readFile( + join(standaloneDocs.stdout.trim(), "capabilities.md"), + "utf8", + ); + if (!standaloneCapabilities.includes("gsc.properties.list")) { + throw new Error("The standalone gkit bundle could not read its embedded documentation."); + } + if (outputDirectory) { await writeReleaseAssets(tarballPath, packageJson.version, outputDirectory); } @@ -193,12 +214,19 @@ async function writeReleaseAssets( const stablePath = join(destination, stableName); await copyFile(tarballPath, exactPath, constants.COPYFILE_EXCL); await copyFile(tarballPath, stablePath, constants.COPYFILE_EXCL); + const bundleName = "gkit"; + const bundlePath = join(destination, bundleName); + await copyFile(join(packageRoot, "dist", "gkit.js"), bundlePath, constants.COPYFILE_EXCL); + await chmod(bundlePath, 0o755); const digest = createHash("sha256") .update(await readFile(tarballPath)) .digest("hex"); + const bundleDigest = createHash("sha256") + .update(await readFile(bundlePath)) + .digest("hex"); await writeFile( join(destination, "SHA256SUMS"), - `${digest} ${exactName}\n${digest} ${stableName}\n`, + `${digest} ${exactName}\n${digest} ${stableName}\n${bundleDigest} ${bundleName}\n`, { encoding: "utf8", flag: "wx" }, ); } diff --git a/packages/gkit/src/assets.embed.ts b/packages/gkit/src/assets.embed.ts new file mode 100644 index 0000000..bf12ec9 --- /dev/null +++ b/packages/gkit/src/assets.embed.ts @@ -0,0 +1,48 @@ +// Every runtime-read asset, inlined into the standalone bundle (bun text/json imports). +// Keep in sync with generated/*/manifest.json and docs/providers/**; assets.test.ts fails on drift. + +import a0 from "../docs/providers/bing/capabilities.md" with { type: "text" }; +import a1 from "../docs/providers/bing/inventory.md" with { type: "text" }; +import a2 from "../docs/providers/dataforseo/backlinks.md" with { type: "text" }; +import a3 from "../docs/providers/dataforseo/inventory.md" with { type: "text" }; +import a4 from "../docs/providers/google-ads/capabilities.md" with { type: "text" }; +import a5 from "../docs/providers/google-ads/inventory.md" with { type: "text" }; +import a6 from "../docs/providers/gsc/capabilities.md" with { type: "text" }; +import a7 from "../docs/providers/gsc/inventory.md" with { type: "text" }; +import a8 from "../docs/providers/hubspot/capabilities.md" with { type: "text" }; +import a9 from "../docs/providers/hubspot/inventory.md" with { type: "text" }; +import a10 from "../docs/providers/pagespeed/capabilities.md" with { type: "text" }; +import a11 from "../docs/providers/pagespeed/inventory.md" with { type: "text" }; +import a12 from "../docs/providers/posthog/capabilities.md" with { type: "text" }; +import a13 from "../docs/providers/posthog/inventory.md" with { type: "text" }; +import a14 from "../generated/bing/manifest.json" with { type: "json" }; +import a15 from "../generated/dataforseo/manifest.json" with { type: "json" }; +import a16 from "../generated/google-ads/manifest.json" with { type: "json" }; +import a17 from "../generated/gsc/manifest.json" with { type: "json" }; +import a18 from "../generated/hubspot/manifest.json" with { type: "json" }; +import a19 from "../generated/pagespeed/manifest.json" with { type: "json" }; +import a20 from "../generated/posthog/manifest.json" with { type: "json" }; + +export const embeddedAssets: Readonly> = { + "docs/providers/bing/capabilities.md": a0, + "docs/providers/bing/inventory.md": a1, + "docs/providers/dataforseo/backlinks.md": a2, + "docs/providers/dataforseo/inventory.md": a3, + "docs/providers/google-ads/capabilities.md": a4, + "docs/providers/google-ads/inventory.md": a5, + "docs/providers/gsc/capabilities.md": a6, + "docs/providers/gsc/inventory.md": a7, + "docs/providers/hubspot/capabilities.md": a8, + "docs/providers/hubspot/inventory.md": a9, + "docs/providers/pagespeed/capabilities.md": a10, + "docs/providers/pagespeed/inventory.md": a11, + "docs/providers/posthog/capabilities.md": a12, + "docs/providers/posthog/inventory.md": a13, + "generated/bing/manifest.json": `${JSON.stringify(a14, null, 2)}\n`, + "generated/dataforseo/manifest.json": `${JSON.stringify(a15, null, 2)}\n`, + "generated/google-ads/manifest.json": `${JSON.stringify(a16, null, 2)}\n`, + "generated/gsc/manifest.json": `${JSON.stringify(a17, null, 2)}\n`, + "generated/hubspot/manifest.json": `${JSON.stringify(a18, null, 2)}\n`, + "generated/pagespeed/manifest.json": `${JSON.stringify(a19, null, 2)}\n`, + "generated/posthog/manifest.json": `${JSON.stringify(a20, null, 2)}\n`, +}; diff --git a/packages/gkit/src/assets.test.ts b/packages/gkit/src/assets.test.ts new file mode 100644 index 0000000..1a370bc --- /dev/null +++ b/packages/gkit/src/assets.test.ts @@ -0,0 +1,26 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + + +const root = fileURLToPath(new URL("..", import.meta.url)); + +function walk(dir: string): string[] { + return readdirSync(`${root}${dir}`, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? walk(`${dir}/${entry.name}`) : [`${dir}/${entry.name}`], + ); +} + +describe("embedded assets", () => { + it("cover every runtime-read manifest and provider doc", () => { + const onDisk = [ + ...walk("generated").filter((path) => path.endsWith("/manifest.json")), + ...walk("docs/providers"), + ].sort(); + // Read as text: vitest cannot import the bun text-import module itself. + const embedded = [...readFileSync(`${root}src/assets.embed.ts`, "utf8").matchAll(/^ {2}"([^"]+)":/gm)].map( + (match) => match[1], + ); + expect(embedded.sort()).toEqual(onDisk); + }); +}); diff --git a/packages/gkit/src/assets.ts b/packages/gkit/src/assets.ts new file mode 100644 index 0000000..6c49e49 --- /dev/null +++ b/packages/gkit/src/assets.ts @@ -0,0 +1,42 @@ +import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Set by `bun build --define` for the standalone release bundle (package.json `build`). +declare const GKIT_STANDALONE: boolean | undefined; + +/** + * Root directory holding `generated/` and `docs/`. + * + * From source (and tests) that is the package root. The standalone bundle has no + * package root next to it, so its embedded copy is written once to a + * content-addressed cache directory and read from there — agents are handed real + * directories by `gkit docs`, and manifests keep loading from files. + */ +export async function resolveAssetRoot(): Promise { + if (typeof GKIT_STANDALONE === "undefined" || !GKIT_STANDALONE) { + return fileURLToPath(new URL("..", import.meta.url)); + } + const { embeddedAssets } = await import("./assets.embed"); + const key = Bun.hash(JSON.stringify(embeddedAssets)).toString(36); + const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache"); + const root = join(cacheHome, "gkit", "assets", key); + if (existsSync(join(root, ".complete"))) return root; + + const staging = `${root}.tmp-${process.pid}`; + rmSync(staging, { recursive: true, force: true }); + for (const [path, text] of Object.entries(embeddedAssets)) { + const target = join(staging, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, text); + } + writeFileSync(join(staging, ".complete"), ""); + try { + renameSync(staging, root); + } catch { + // A concurrent run won the race with identical content. + rmSync(staging, { recursive: true, force: true }); + } + return root; +} diff --git a/packages/gkit/src/cli.ts b/packages/gkit/src/cli.ts index c4b5409..e2b9cfa 100644 --- a/packages/gkit/src/cli.ts +++ b/packages/gkit/src/cli.ts @@ -1,4 +1,6 @@ -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +import { resolveAssetRoot } from "./assets"; import { ensureTrailingNewline, formatBareSkill } from "@celados/argc/skill"; @@ -41,45 +43,27 @@ import { journalSummary, needsReconciliation, operationView } from "./operations import { loadExecutableManifest } from "./manifest"; import { renderGkitSchema } from "./schema"; -export const dataForSeoManifestPath = fileURLToPath( - new URL("../generated/dataforseo/manifest.json", import.meta.url), -); -export const bingManifestPath = fileURLToPath( - new URL("../generated/bing/manifest.json", import.meta.url), -); -export const bingDocsDirectory = fileURLToPath(new URL("../docs/providers/bing", import.meta.url)); -export const dataForSeoDocsDirectory = fileURLToPath( - new URL("../docs/providers/dataforseo", import.meta.url), -); -export const postHogManifestPath = fileURLToPath( - new URL("../generated/posthog/manifest.json", import.meta.url), -); -export const postHogDocsDirectory = fileURLToPath( - new URL("../docs/providers/posthog", import.meta.url), -); -export const googleAdsManifestPath = fileURLToPath( - new URL("../generated/google-ads/manifest.json", import.meta.url), -); -export const googleAdsDocsDirectory = fileURLToPath( - new URL("../docs/providers/google-ads", import.meta.url), -); -export const gscManifestPath = fileURLToPath( - new URL("../generated/gsc/manifest.json", import.meta.url), -); -export const gscDocsDirectory = fileURLToPath(new URL("../docs/providers/gsc", import.meta.url)); -export const hubSpotManifestPath = fileURLToPath( - new URL("../generated/hubspot/manifest.json", import.meta.url), -); -export const hubSpotDocsDirectory = fileURLToPath( - new URL("../docs/providers/hubspot", import.meta.url), -); -export const pageSpeedManifestPath = fileURLToPath( - new URL("../generated/pagespeed/manifest.json", import.meta.url), -); -export const pageSpeedDocsDirectory = fileURLToPath( - new URL("../docs/providers/pagespeed", import.meta.url), -); -export const providerDocsDirectory = fileURLToPath(new URL("../docs/providers", import.meta.url)); +const assetRoot = await resolveAssetRoot(); +const manifestPathFor = (provider: string): string => + join(assetRoot, "generated", provider, "manifest.json"); +const docsDirectoryFor = (provider: string): string => + join(assetRoot, "docs", "providers", provider); + +export const dataForSeoManifestPath = manifestPathFor("dataforseo"); +export const bingManifestPath = manifestPathFor("bing"); +export const bingDocsDirectory = docsDirectoryFor("bing"); +export const dataForSeoDocsDirectory = docsDirectoryFor("dataforseo"); +export const postHogManifestPath = manifestPathFor("posthog"); +export const postHogDocsDirectory = docsDirectoryFor("posthog"); +export const googleAdsManifestPath = manifestPathFor("google-ads"); +export const googleAdsDocsDirectory = docsDirectoryFor("google-ads"); +export const gscManifestPath = manifestPathFor("gsc"); +export const gscDocsDirectory = docsDirectoryFor("gsc"); +export const hubSpotManifestPath = manifestPathFor("hubspot"); +export const hubSpotDocsDirectory = docsDirectoryFor("hubspot"); +export const pageSpeedManifestPath = manifestPathFor("pagespeed"); +export const pageSpeedDocsDirectory = docsDirectoryFor("pagespeed"); +export const providerDocsDirectory = join(assetRoot, "docs", "providers"); type TerminalEmitter = { writeText(text: string): Promise;