diff --git a/.circleci/config.yml b/.circleci/config.yml index bc90bac..69b3de3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ parameters: default: false orbs: - codecov: codecov/codecov@5.4.3 + codecov: codecov/codecov@6.0.0 executors: base: diff --git a/cli/src/__tests__/commands/xlr-compile-packages.test.ts b/cli/src/__tests__/commands/xlr-compile-packages.test.ts new file mode 100644 index 0000000..7922bbb --- /dev/null +++ b/cli/src/__tests__/commands/xlr-compile-packages.test.ts @@ -0,0 +1,249 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { test, expect, describe, beforeEach, afterEach, vi } from "vitest"; +import { Errors } from "@oclif/core"; +import XLRCompile from "../../commands/xlr/compile"; + +/** A plugin package with one asset, laid out the way `xlr compile` expects */ +function writeFixture(dir: string, packageJson?: Record) { + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + + if (packageJson) { + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify(packageJson), + ); + } + + fs.writeFileSync( + path.join(dir, "src", "index.ts"), + ` +import type { ExtendedPlayerPlugin } from "@player-ui/player"; + +export interface TestAsset { + id: string; + type: "test"; +} + +export class TestPlugin implements ExtendedPlayerPlugin<[TestAsset]> { + name = "test-plugin"; +} +`, + ); +} + +/** Silences `Errors.warn` while capturing what it was called with */ +function spyOnWarn() { + return vi.spyOn(Errors, "warn").mockImplementation(() => undefined); +} + +function readManifest(dir: string) { + return JSON.parse( + fs.readFileSync(path.join(dir, "dist", "xlr", "manifest.json"), "utf-8"), + ); +} + +describe("xlr compile package info", () => { + /** An isolated root, so nothing on the ambient filesystem can be picked up */ + let workspace: string; + let cwd: string; + let warn: ReturnType; + const env = { ...process.env }; + + beforeEach(() => { + workspace = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "xlr-compile-")), + ); + cwd = process.cwd(); + warn = spyOnWarn(); + delete process.env.BAZEL_STABLE_STATUS_FILE; + delete process.env.BAZEL_PACKAGE; + delete process.env.XLR_PACKAGE_NAME; + delete process.env.JS_BINARY__EXECROOT; + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(workspace, { recursive: true, force: true }); + process.env = { ...env }; + warn.mockRestore(); + }); + + describe("non-bazel", () => { + beforeEach(() => { + process.chdir(workspace); + }); + + test("records the name and version from package.json", async () => { + writeFixture(workspace, { name: "@test/plugin", version: "2.3.4" }); + + await XLRCompile.run(["-i", "src", "-o", "dist"]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin", version: "2.3.4" }, + }); + }); + + test("records the name alone when package.json has no version", async () => { + writeFixture(workspace, { name: "@test/plugin" }); + + await XLRCompile.run(["-i", "src", "-o", "dist"]); + + expect(readManifest(workspace).packages).toStrictEqual({ + react: { name: "@test/plugin" }, + }); + }); + + // The omission must be noisy: a manifest silently losing its `packages` key is the + // failure mode this whole path exists to prevent. + describe("when package.json is missing or incomplete", () => { + test("omits packages and warns when there is no package.json", async () => { + writeFixture(workspace); + + await XLRCompile.run(["-i", "src", "-o", "dist"]); + + expect(readManifest(workspace).packages).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Could not read"), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Omitting package information"), + ); + }); + + test("omits packages and warns when package.json has no name", async () => { + writeFixture(workspace, { version: "2.3.4" }); + + await XLRCompile.run(["-i", "src", "-o", "dist"]); + + expect(readManifest(workspace).packages).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('No "name" in'), + ); + }); + }); + }); + + describe("bazel", () => { + // Bazel runs from the workspace root and names the package in BAZEL_PACKAGE, so the + // working directory alone does not identify the package. + const pkgPath = path.join("plugins", "test-plugin"); + + beforeEach(() => { + // a workspace root package.json that must never be picked up + fs.writeFileSync( + path.join(workspace, "package.json"), + JSON.stringify({ name: "workspace-root", version: "0.0.0" }), + ); + process.chdir(workspace); + process.env.BAZEL_PACKAGE = pkgPath; + }); + + test("takes the name from XLR_PACKAGE_NAME and the version from the stamp", async () => { + // No package.json in the package: Bazel does not stage one, it passes the name instead + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + const statusFile = path.join(workspace, "stable-status.txt"); + fs.writeFileSync(statusFile, "STABLE_VERSION 1.1.0\n"); + process.env.BAZEL_STABLE_STATUS_FILE = statusFile; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + react: { name: "@test/plugin", version: "1.1.0" }, + }); + }); + + test("resolves an execroot-relative stamp path against the execroot", async () => { + // Bazel names the status file relative to the execroot, but the js_binary launcher + // runs the tool from BAZEL_BINDIR, so a relative path does not resolve against cwd. + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + fs.mkdirSync(path.join(workspace, "bazel-out"), { recursive: true }); + fs.writeFileSync( + path.join(workspace, "bazel-out", "stable-status.txt"), + "STABLE_VERSION 1.2.0-next.7\n", + ); + process.env.BAZEL_STABLE_STATUS_FILE = "bazel-out/stable-status.txt"; + process.env.JS_BINARY__EXECROOT = workspace; + + const bindir = path.join(workspace, "bazel-out", "bin"); + fs.mkdirSync(bindir, { recursive: true }); + process.chdir(bindir); + + await XLRCompile.run([ + "-i", + path.join(workspace, pkgPath, "src"), + "-o", + path.join(workspace, pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + react: { name: "@test/plugin", version: "1.2.0-next.7" }, + }); + }); + + test("omits the version when not stamped", async () => { + writeFixture(path.join(workspace, pkgPath)); + process.env.XLR_PACKAGE_NAME = "@test/plugin"; + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toStrictEqual({ + react: { name: "@test/plugin" }, + }); + }); + + describe("when package.json is missing or incomplete", () => { + test("omits packages and warns when neither XLR_PACKAGE_NAME nor package.json is available", async () => { + writeFixture(path.join(workspace, pkgPath)); + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(path.join(pkgPath, "package.json")), + ); + }); + + test("omits packages when package.json has no name", async () => { + writeFixture(path.join(workspace, pkgPath), { version: "2.3.4" }); + + await XLRCompile.run([ + "-i", + path.join(pkgPath, "src"), + "-o", + path.join(pkgPath, "dist"), + ]); + + expect( + readManifest(path.join(workspace, pkgPath)).packages, + ).toBeUndefined(); + }); + }); + }); +}); diff --git a/cli/src/commands/xlr/compile.ts b/cli/src/commands/xlr/compile.ts index 17732ab..c0f9411 100644 --- a/cli/src/commands/xlr/compile.ts +++ b/cli/src/commands/xlr/compile.ts @@ -5,11 +5,12 @@ import path from "path"; import globby from "globby"; import logSymbols from "log-symbols"; import { TsConverter } from "@xlr-lib/xlr-converters"; -import type { Manifest } from "@xlr-lib/xlr"; +import type { Manifest, PlatformPackages } from "@xlr-lib/xlr"; import chalk from "chalk"; import { BaseCommand } from "../../utils/base-command"; import { pluginVisitor, fileVisitor } from "../../utils/xlr/visitors"; import { Mode, customPrimitives } from "../../utils/xlr/consts"; +import { getPackages } from "../../utils/xlr/packages"; /** * Exports TS Interfaces/Types to XLR format @@ -61,8 +62,9 @@ export default class XLRCompile extends BaseCommand { `${inputPath}/**/*.ts`, `${inputPath}/**/*.tsx`, ]); + const packages = getPackages(); try { - this.processTypes(inputFiles, outputDir, {}, mode); + this.processTypes(inputFiles, outputDir, {}, mode, packages); } catch (e: any) { console.log(""); console.log( @@ -90,6 +92,7 @@ export default class XLRCompile extends BaseCommand { outputDirectory: string, options: ts.CompilerOptions, mode: Mode = Mode.PLUGIN, + packages?: PlatformPackages, ): void { // Build a program using the set of root file names in fileNames const program = ts.createProgram(fileNames, options); @@ -148,11 +151,16 @@ export default class XLRCompile extends BaseCommand { throw new Error("Error: Unable to parse any XLRs in package"); } + const manifest: Manifest = { + ...capabilities, + ...(packages ? { packages } : {}), + }; + // print out the manifest files - const jsonManifest = JSON.stringify(capabilities, this.replacer, 4); + const jsonManifest = JSON.stringify(manifest, this.replacer, 4); fs.writeFileSync(path.join(outputDirectory, "manifest.json"), jsonManifest); - const tsManifestFile = `${[...(capabilities.capabilities?.values() ?? [])] + const tsManifestFile = `${[...(manifest.capabilities?.values() ?? [])] .flat(2) .map((capability) => { return `const ${capability.replace(".", "_")} = require("./${capability}.json")`; @@ -160,16 +168,20 @@ export default class XLRCompile extends BaseCommand { .join("\n")} module.exports = { - "pluginName": "${capabilities.pluginName}", + "pluginName": "${manifest.pluginName}",${ + manifest.packages + ? `\n "packages": ${JSON.stringify(manifest.packages)},` + : "" + } "capabilities": { - ${[...(capabilities.capabilities?.entries() ?? [])] + ${[...(manifest.capabilities?.entries() ?? [])] .map(([capabilityName, provides]) => { return `"${capabilityName}":[${provides.join(",").replaceAll(".", "_")}],`; }) .join("\n\t\t")} }, "customPrimitives": [ - ${[capabilities.customPrimitives?.map((i) => `"${i}"`).join(",") ?? ""]} + ${[manifest.customPrimitives?.map((i) => `"${i}"`).join(",") ?? ""]} ] } `; diff --git a/cli/src/utils/xlr/packages.ts b/cli/src/utils/xlr/packages.ts new file mode 100644 index 0000000..f1039f1 --- /dev/null +++ b/cli/src/utils/xlr/packages.ts @@ -0,0 +1,131 @@ +import fs from "fs"; +import path from "path"; +import { Errors } from "@oclif/core"; +import type { PlatformPackages } from "@xlr-lib/xlr"; + +/** + * Where the npm name and version of the package being compiled come from. + * + * | | name | version | + * | --- | --- | --- | + * | Bazel (stamped) | `XLR_PACKAGE_NAME`, else the package's `package.json` | the `STABLE_VERSION` stamp | + * | anywhere else | `XLR_PACKAGE_NAME`, else the package's `package.json` | the package's `package.json` | + * + * Only the version differs between the two: under Bazel the version in `package.json` is a + * placeholder that is substituted at publish time, so the stamp is the only real source. + * Elsewhere the package manager keeps `package.json` current and it is read directly. + */ + +/** + * The directory of the package being compiled. + * + * Bazel runs from the workspace root and names the package in `BAZEL_PACKAGE`; everywhere + * else the working directory is already the package. + */ +function getPackageDir(): string { + const bazelPackage = process.env.BAZEL_PACKAGE; + + return bazelPackage + ? path.resolve(process.cwd(), bazelPackage) + : process.cwd(); +} + +/** The parsed `package.json` of the package being compiled, or undefined if there isn't a readable one */ +function getPackageJson( + packageDir: string, +): Record | undefined { + const packageJsonPath = path.join(packageDir, "package.json"); + + try { + return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + } catch (error) { + Errors.warn( + `Could not read ${packageJsonPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } +} + +/** The `name` of a `package.json`, warning if there isn't one */ +function getPackageJsonName( + packageDir: string, + packageJson: Record | undefined, +): string | undefined { + if (!packageJson) { + // getPackageJson already warned about why it is unavailable + return undefined; + } + + const { name } = packageJson; + + if (typeof name !== "string" || !name) { + Errors.warn(`No "name" in ${path.join(packageDir, "package.json")}.`); + return undefined; + } + + return name; +} + +/** The `version` of a `package.json` */ +function getPackageJsonVersion( + packageJson: Record | undefined, +): string | undefined { + const version = packageJson?.version; + + return typeof version === "string" && version ? version : undefined; +} + +/** The version Bazel stamped this build with, read from the stable status file */ +function getStampedVersion(): string | undefined { + const statusFile = process.env.BAZEL_STABLE_STATUS_FILE; + + if (!statusFile) { + return undefined; + } + + // Bazel names the status file relative to the execroot (`File.path`), but the js_binary + // launcher changes directory out of the execroot into BAZEL_BINDIR before running the + // tool, so re-anchor the path before reading it. + const execroot = process.env.JS_BINARY__EXECROOT; + const resolved = execroot ? path.join(execroot, statusFile) : statusFile; + + if (!fs.existsSync(resolved)) { + return undefined; + } + + const line = fs + .readFileSync(resolved, "utf-8") + .split("\n") + .find((l) => l.startsWith("STABLE_VERSION ")); + + return line?.slice("STABLE_VERSION ".length).trim() || undefined; +} + +/** + * The npm package that provides the capabilities being compiled, or undefined if its name + * cannot be determined. + */ +export function getPackages(): PlatformPackages | undefined { + const packageDir = getPackageDir(); + const packageJson = getPackageJson(packageDir); + + // Bazel only knows the package path, so it passes the npm name through the environment. + const name = + process.env.XLR_PACKAGE_NAME || getPackageJsonName(packageDir, packageJson); + + if (!name) { + Errors.warn("Omitting package information from the manifest."); + return undefined; + } + + // Only a stamped Bazel build produces a status file; otherwise `package.json` is the source. + const version = getStampedVersion() ?? getPackageJsonVersion(packageJson); + + // TODO: only `react` is generated, because XLR is compiled from TypeScript and there is no + // equivalent for iOS or Android. Native configurations will be added later. + return { + react: { name, ...(version ? { version } : {}) }, + }; +} diff --git a/package.json b/package.json index e42dc5a..1bfb56d 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,10 @@ "@oclif/plugin-plugins": "^1.9.0", "@player-lang/react-dsl": "^1.0.1", "@player-lang/json-language-service": "^1.0.1", - "@xlr-lib/xlr": "^1.0.0", - "@xlr-lib/xlr-converters": "^1.0.0", - "@xlr-lib/xlr-sdk": "^1.0.0", - "@xlr-lib/xlr-utils": "^1.0.0", + "@xlr-lib/xlr": "1.1.0-next.0", + "@xlr-lib/xlr-converters": "1.1.0-next.0", + "@xlr-lib/xlr-sdk": "1.1.0-next.0", + "@xlr-lib/xlr-utils": "1.1.0-next.0", "@types/babel__register": "^7.17.0", "@types/fs-extra": "^9.0.13", "@types/mkdirp": "^1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dfe289..43c17c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,17 +57,17 @@ importers: specifier: ^1.0.1 version: 1.0.4 '@xlr-lib/xlr': - specifier: ^1.0.0 - version: 1.0.0 + specifier: 1.1.0-next.0 + version: 1.1.0-next.0 '@xlr-lib/xlr-converters': - specifier: ^1.0.0 - version: 1.0.0(jsonc-parser@2.3.1) + specifier: 1.1.0-next.0 + version: 1.1.0-next.0(jsonc-parser@2.3.1) '@xlr-lib/xlr-sdk': - specifier: ^1.0.0 - version: 1.0.0 + specifier: 1.1.0-next.0 + version: 1.1.0-next.0 '@xlr-lib/xlr-utils': - specifier: ^1.0.0 - version: 1.0.0(jsonc-parser@2.3.1) + specifier: 1.1.0-next.0 + version: 1.1.0-next.0(jsonc-parser@2.3.1) chalk: specifier: ^4.0.1 version: 4.1.2 @@ -2224,21 +2224,32 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==, tarball: https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz} - '@xlr-lib/xlr-converters@1.0.0': - resolution: {integrity: sha512-TovXuu4VMwJBWZnAhhO93jncx5NXfn1khy1mYVIgMXrC7b1c++g6EyxLs4svkUAiQiynEkmSa96QvydSgAeYzw==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-converters/-/xlr-converters-1.0.0.tgz} + '@xlr-lib/xlr-converters@1.1.0-next.0': + resolution: {integrity: sha512-Petyxyt0WmEt8ule4qLh5XgGyW2RSzRTWGjy+xHv1qEBNYX6LdHOe96h9xjIw3fGAe2kpm1KYFpUwn/0ZIjipA==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-converters/-/xlr-converters-1.1.0-next.0.tgz} hasBin: true '@xlr-lib/xlr-sdk@1.0.0': resolution: {integrity: sha512-3ge1NTzCukn9F76HQPZXeNZI/C/HOJF365II6m5GbzhR1Aag9Ghf4/5w5/PIrfb734eZ5kFkMiF6UeMOXUegtQ==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-sdk/-/xlr-sdk-1.0.0.tgz} + '@xlr-lib/xlr-sdk@1.1.0-next.0': + resolution: {integrity: sha512-TG0pHb58/IK/QYh0LNw6OK4/NO4y9hsk6sEw0Z7/PJcIigF6SpevqT2g4vqYC6VnrveGoRcpNFB3fMAw2gJBRA==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-sdk/-/xlr-sdk-1.1.0-next.0.tgz} + '@xlr-lib/xlr-utils@1.0.0': resolution: {integrity: sha512-swoRx3QppioaClwvf+89yJrPJ8oEsIJNWUdney7Vom9XmUKbVY4XgPF0fZSYSC+HWsvB8NbkQ5CNCE8AXW6oxQ==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-utils/-/xlr-utils-1.0.0.tgz} peerDependencies: jsonc-parser: 2.3.1 + '@xlr-lib/xlr-utils@1.1.0-next.0': + resolution: {integrity: sha512-eT9KEwQQLdraJdVDY7lq1YnzQY5kYMJES7EUZtFH2Ge0XkRf9K5kyAGO/BI5x7wGkfPmKtPmaRrXc5IbZA/low==, tarball: https://registry.npmjs.org/@xlr-lib/xlr-utils/-/xlr-utils-1.1.0-next.0.tgz} + peerDependencies: + jsonc-parser: 2.3.1 + '@xlr-lib/xlr@1.0.0': resolution: {integrity: sha512-Zfu6/lLauRQafuUo+JvbKZ2lmQkxtDgJxnGtWieGNct6mSHhWKWRc7siXQlboBxwu84yMcjLSrOxvD9WrBtLWQ==, tarball: https://registry.npmjs.org/@xlr-lib/xlr/-/xlr-1.0.0.tgz} + '@xlr-lib/xlr@1.1.0-next.0': + resolution: {integrity: sha512-ZfiMna7EekGVMriUUIUsMPLmpwlHC4GFpbW4pZ4XHm2lAOwC/obA6/29Bt7InlBPrrImqLF50wfmXE1jz5TWZA==, tarball: https://registry.npmjs.org/@xlr-lib/xlr/-/xlr-1.1.0-next.0.tgz} + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==, tarball: https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz} @@ -7891,10 +7902,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 - '@xlr-lib/xlr-converters@1.0.0(jsonc-parser@2.3.1)': + '@xlr-lib/xlr-converters@1.1.0-next.0(jsonc-parser@2.3.1)': dependencies: - '@xlr-lib/xlr': 1.0.0 - '@xlr-lib/xlr-utils': 1.0.0(jsonc-parser@2.3.1) + '@xlr-lib/xlr': 1.1.0-next.0 + '@xlr-lib/xlr-utils': 1.1.0-next.0(jsonc-parser@2.3.1) tslib: 2.8.1 typescript: 5.5.4 transitivePeerDependencies: @@ -7910,16 +7921,36 @@ snapshots: jsonc-parser: 2.3.1 tslib: 2.8.1 + '@xlr-lib/xlr-sdk@1.1.0-next.0': + dependencies: + '@types/fs-extra': 9.0.13 + '@types/node': 18.19.130 + '@xlr-lib/xlr': 1.1.0-next.0 + '@xlr-lib/xlr-utils': 1.1.0-next.0(jsonc-parser@2.3.1) + fs-extra: 10.1.0 + jsonc-parser: 2.3.1 + tslib: 2.8.1 + '@xlr-lib/xlr-utils@1.0.0(jsonc-parser@2.3.1)': dependencies: '@xlr-lib/xlr': 1.0.0 jsonc-parser: 2.3.1 tslib: 2.8.1 + '@xlr-lib/xlr-utils@1.1.0-next.0(jsonc-parser@2.3.1)': + dependencies: + '@xlr-lib/xlr': 1.1.0-next.0 + jsonc-parser: 2.3.1 + tslib: 2.8.1 + '@xlr-lib/xlr@1.0.0': dependencies: tslib: 2.8.1 + '@xlr-lib/xlr@1.1.0-next.0': + dependencies: + tslib: 2.8.1 + '@yarnpkg/lockfile@1.1.0': {} acorn-jsx@5.3.2(acorn@8.15.0):