From 7a87fb15659a27f50f19fe89ad25215aae9f4a34 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Sat, 17 Oct 2026 16:36:21 +0800 Subject: [PATCH] feat(cli): select targets like rustup selects toolchains Every pocket check, compile and build named its target with --target or --host-profile, and the CLI recognized a framework by its package name. A verb now acts on the first of: an explicit + (before or after the command), --target or --host-profile; POCKET_TARGET; a directory override of the current directory or an ancestor; the first entry of `targets` in the project's pocket.config.ts; the user default. The CLI prints the target and its source before it runs. pocket build --all builds every project target in order. - pocket.config.ts gains `targets`: catalog ids, { target, manifest }, or { hostProfile, manifest }. The project configuration is the nearest pocket.config.ts above the manifest's directory; tools/pocket.ts project bundles it with @pocketjs/framework mapped to the resolved framework. - pocket show [--json] reports the framework and its source, the project, the selection and why, the host, and the catalog. pocket default and pocket override keep their state in /pocket-nexus/settings.json. - A framework is any directory whose package.json carries "pocketjs": { "protocol": 1 }; the package name is not read, so a fork under another name is recognized. The nearest of a checkout and an installed node_modules/@pocketjs/framework wins. The CLI passes --cli-protocol and either side stops on a mismatch. - pocket create writes pocket.config.ts, and its package.json scripts name no target. --- framework/src/config.ts | 16 ++ package.json | 3 + site/content/docs/cli.md | 136 ++++++++++--- tests/cli.test.ts | 264 ++++++++++++++++++++++-- tests/npm-package.test.ts | 2 + tests/targets-project.test.ts | 105 ++++++++++ tools/cli/bin.mjs | 366 ++++++++++++++++++++++++++-------- tools/cli/create.d.mts | 1 - tools/cli/create.mjs | 37 ++-- tools/cli/framework.d.mts | 27 +++ tools/cli/framework.mjs | 98 +++++++++ tools/cli/package.json | 2 + tools/cli/select.d.mts | 37 ++++ tools/cli/select.mjs | 110 ++++++++++ tools/pocket.ts | 19 ++ tools/targets/project.ts | 134 +++++++++++++ tools/test.ts | 1 + 17 files changed, 1221 insertions(+), 137 deletions(-) create mode 100644 tests/targets-project.test.ts create mode 100644 tools/cli/framework.d.mts create mode 100644 tools/cli/framework.mjs create mode 100644 tools/cli/select.d.mts create mode 100644 tools/cli/select.mjs create mode 100644 tools/targets/project.ts diff --git a/framework/src/config.ts b/framework/src/config.ts index 902f9a4d2..2bd19c30d 100644 --- a/framework/src/config.ts +++ b/framework/src/config.ts @@ -2,7 +2,23 @@ import type { AnimationTheme } from "../compiler/animation.ts"; export type PocketFramework = "solid" | "vue-vapor" | "octane"; +/** + * One target a project ships to: a catalog id (`"psp"`), a catalog id with the + * manifest it builds from, or an ESP-IDF host profile, whose `id` names the + * target. Paths resolve against the directory of pocket.config.ts. + */ +export type PocketConfigTarget = + | string + | { readonly target: string; readonly manifest?: string } + | { readonly hostProfile: string; readonly manifest?: string }; + export interface PocketConfig { + /** + * The targets the project ships to. The first is the project's default + * target for `pocket` commands that name none; `pocket build --all` builds + * every entry. + */ + targets?: readonly PocketConfigTarget[]; /** * JSX/runtime framework for application sources. Solid is the default for * existing apps; Vue Vapor or Octane can be selected here or with diff --git a/package.json b/package.json index 6d901256d..08dd1569b 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,9 @@ "publishConfig": { "access": "public" }, + "pocketjs": { + "protocol": 1 + }, "exports": { ".": "./framework/src/index.ts", "./animation": "./framework/src/animation.ts", diff --git a/site/content/docs/cli.md b/site/content/docs/cli.md index 3438d6298..e7d61beb9 100644 --- a/site/content/docs/cli.md +++ b/site/content/docs/cli.md @@ -18,7 +18,9 @@ bunx @pocketjs/cli doctor # one invocation without a global install | `pocket doctor` | toolchain report, in the CLI | any directory | | `pocket setup [--yes]` | `tools/bootstrap.ts` | checkout | | `pocket create [options]` | scaffold, in the CLI | any directory | -| `pocket check`, `compile`, `build` | `tools/pocket.ts` | checkout or project | +| `pocket [+] check`, `compile`, `build` | [target selection](#target-selection), then `tools/pocket.ts` | checkout or project | +| `pocket [+] show [--json]` | the framework, project, selection and catalog | any directory | +| `pocket default`, `pocket override` | the user default and directory overrides | any directory | | `pocket dev`, `psp`, `vita`, `hw`, `psplink`, `devtools`, `tape`, `play`, `symbian`, `ios` | `tools/.ts` | checkout | | `pocket --version` | CLI version and resolved framework version | any directory | @@ -26,14 +28,33 @@ bunx @pocketjs/cli doctor # one invocation without a global install ## Framework resolution -`check`, `compile`, `build`, `doctor` and `--version` take the first match: +**A framework is a directory whose `package.json` carries +`"pocketjs": { "protocol": }`.** The package name is not read, so a fork +published under another name is recognized. `check`, `compile`, `build`, +`show`, `doctor` and `--version` take: -1. `POCKETJS_FRAMEWORK_ROOT`, when set. The directory must contain - `tools/pocket.ts`; a directory without it fails the command. -2. A PocketJS checkout: the nearest ancestor of the current directory whose - `package.json` names `@pocketjs/framework`. -3. The nearest `node_modules/@pocketjs/framework` containing `tools/pocket.ts`, - searched from the current directory upward. +1. `POCKETJS_FRAMEWORK_ROOT`, when set. A directory without the marker fails + the command. +2. Otherwise the nearest of these above the current directory: an ancestor + that carries the marker (a checkout), or an ancestor's + `node_modules/@pocketjs/framework`. When one directory is both, the checkout + wins and `show` names the install it passed over. + +The CLI speaks protocol 1. **A framework on another protocol stops every +command that runs it**, with both versions named. `show` reports the +framework's source: `POCKETJS_FRAMEWORK_ROOT`, `checkout`, `registry`, a git +dependency from the lockfile, a `file:` or `link:` dependency, or +`link → ` for a symlinked install. + +### Switching to a fork + +| Situation | Operation | +| --- | --- | +| develop a fork against an application | `bun link` in the fork, then `bun link @pocketjs/framework` in the application; `package.json` is not edited, and `bun install` restores the registry version | +| a fork with another package name | `"@pocketjs/framework": "link:"` | +| pin a fork commit for a team or CI | `"@pocketjs/framework": "github:/pocketjs#"` | +| a fork published under another name | `"@pocketjs/framework": "npm:@"` | +| one command | `POCKETJS_FRAMEWORK_ROOT= pocket …` | **Manifest commands run in the current directory.** `--manifest`, `--host-profile`, `--project-root` and `--compiler-receipt` resolve against it; @@ -41,6 +62,72 @@ bunx @pocketjs/cli doctor # one invocation without a global install project root. `setup` and the host-development commands use step 2 alone and run with the checkout root as their working directory. +## Target selection + +`check`, `compile`, `build` and `show` act on one target, the first of these +that is present: + +1. **Explicit**: `pocket + `, `pocket +`, + `--target `, or `--host-profile `. Two that differ are an error; + `+` anywhere else before `--` is an error. +2. **`POCKET_TARGET`**. +3. **A directory override** of the current directory or its nearest ancestor + that has one (`pocket override`). +4. **The project**: the first entry of `targets` in the project configuration. +5. **The user default** (`pocket default`). + +None present stops the command with the five ways to name a target. A command +prints the target and its source before it runs: + +```text +pocket: target vita · directory override /work/game +``` + +`pocket build --all` builds every target of the project configuration in +order and stops at the first failure. + +### Project configuration + +`targets` in `pocket.config.ts` lists the targets a project ships to: + +```ts +import { definePocketConfig } from "@pocketjs/framework/config"; + +export default definePocketConfig({ + targets: [ + "psp", + "vita", + { target: "moto-g-play", manifest: "pocket.android.json" }, + { hostProfile: "../firmware/pocket.host.json" }, + ], +}); +``` + +**The project configuration is the nearest `pocket.config.ts` above the +manifest's directory**, the file `tools/build.ts` reads `theme` from; the +lookup stops there, and a file without `targets` names no project default. +`manifest` and `hostProfile` resolve against the file's directory. An entry's +`manifest` replaces the default `pocket.json` when the command names no +`--manifest`. A `hostProfile` entry is a target named by the profile's `id`, +which may not equal a catalog id or a `pocket` command word. An unknown id, a +missing profile and a duplicate entry are errors. The file is bundled with +`@pocketjs/framework` mapped to the resolved framework, so it evaluates in a +project without `node_modules`. + +### show, default, override + +| Command | Effect | +| --- | --- | +| `pocket show [--json] [--manifest ]` | the framework with its source, the project configuration and its targets, the selected target with its source, the host, and the catalog with the targets this host cannot install marked; exits 0 | +| `pocket default` | prints the user default | +| `pocket default `, `pocket default --unset` | sets or removes it | +| `pocket override set [--path ]` | selects `` for `` (default the current directory) and every directory below it | +| `pocket override unset [--path ]`, `pocket override list` | removes one, or lists them with the one active here | + +`default` and `override set` accept a catalog id or a host profile id of the +current project. Both live in `/pocket-nexus/settings.json`, where +`` is `XDG_CONFIG_HOME` or `~/.config`; the file is written by rename. + ## doctor `doctor` prints three groups and **exits with status 1 when a Core or PSP item @@ -108,13 +195,14 @@ error. ### Output Inside a checkout, `create` writes `apps//` with `pocket.json`, -`main.tsx` and `app.tsx`, and prints the `check` and `build` commands with -`--manifest apps//pocket.json`. Anywhere else it writes a standalone +`pocket.config.ts`, `main.tsx` and `app.tsx`, and prints the `check` and +`build` commands with `--manifest apps//pocket.json`. Anywhere else it writes a standalone project at `.//`: | File | Content | | --- | --- | | `pocket.json` | manifest v2: id `dev.example.` with dots for hyphens, version `0.1.0`, entry `main.tsx`, output `-main`, the framework, viewport and `requires` | +| `pocket.config.ts` | `targets` with the target, or the host profile for `esp-idf` | | `main.tsx` | the mount entry for the framework (Octane imports `mount` from `@pocketjs/framework`) | | `app.tsx` | a counter component; CROSS increments it when the target has buttons | | `package.json` | `@pocketjs/framework` `^`, the framework's runtime package (`solid-js`, `vue` or `octane`) at the version the framework pins, `@pocketjs/cli` as a dev dependency, and `check`, `compile`, `build` scripts | @@ -124,16 +212,10 @@ project at `.//`: | `pocket.host.json` | `esp-idf` without `--host-profile` | Outside ESP-IDF the manifest declares a **480×272 `integer-fit` viewport** and -requires `text.glyphs.baked` and `input.buttons`. The `package.json` scripts -depend on the target: - -| Target | `check` | `build` | -| --- | --- | --- | -| `psp`, `vita` | `pocket check --target ` | `pocket build --target -- --release` | -| `pocketbook`, `web-app`, `macos-app`, `linux-app` | `pocket check --target ` | `pocket build --target ` | -| `esp-idf` | `pocket check --host-profile ` | `pocket build --host-profile --output dist/.pocket` | - -`compile` has the arguments of `check`. A standalone project builds with +requires `text.glyphs.baked` and `input.buttons`. `pocket.config.ts` names the +target, so the `package.json` scripts carry none: `pocket check`, +`pocket compile`, and `pocket build`, with `-- --release` for `psp` and `vita` +and `--output dist/.pocket` for `esp-idf`. A standalone project builds with `bun install`, `bun run check` and `bun run build`. ### ESP-IDF scaffold @@ -147,7 +229,8 @@ from it: | `app.viewport.presentation` | `native` when `display.presentations` lists it; the first entry otherwise | | `input.buttons` in `requires`, and the CROSS handler | present when `capabilities` lists `input.buttons` | -The scripts record the profile path relative to the application directory. +`pocket.config.ts` records the profile path relative to the application +directory. `create` reads the three fields above; **`pocket check` validates the full profile schema.** `--target esp-idf` without `--host-profile` writes a starting `pocket.host.json` for the firmware repository to own: id `` cut to 15 @@ -161,13 +244,14 @@ for an application directory at `//`. See ## check, compile, build ```sh -pocket (--target | --host-profile ) [options] [-- ] +pocket [+] [+] [--target | --host-profile ] [options] [-- ] ``` | Option | Default | Effect | | --- | --- | --- | -| `--target ` | — | a target of the [catalog](#targets) | -| `--host-profile ` | — | an ESP-IDF host profile; the command takes exactly one of `--target` and `--host-profile` | +| `--target ` | [selection](#target-selection) | a target of the [catalog](#targets) | +| `--host-profile ` | [selection](#target-selection) | an ESP-IDF host profile | +| `--all` | — | `build` every target of the project configuration | | `--manifest ` | `pocket.json` | the application manifest | | `--project-root ` | the manifest's directory | base for the entry, `tsconfig.json`, `--outdir`, `--plan-dir`, `--output` and `--depfile` | | `--outdir ` | `dist` | compiled output | @@ -249,7 +333,9 @@ directory, and pass their arguments to the script unchanged. | Variable | Read by | Effect | | --- | --- | --- | -| `POCKETJS_FRAMEWORK_ROOT` | manifest commands, `doctor`, `--version` | the framework directory; step 1 of [resolution](#framework-resolution) | +| `POCKETJS_FRAMEWORK_ROOT` | manifest commands, `show`, `doctor`, `--version` | the framework directory; step 1 of [resolution](#framework-resolution) | +| `POCKET_TARGET` | manifest commands, `show` | the target; step 2 of [selection](#target-selection) | +| `XDG_CONFIG_HOME` | `show`, `default`, `override`, manifest commands | parent of `pocket-nexus/settings.json`; `~/.config` when unset | | `POCKET_NEXUS_CACHE_DIR` | `doctor`, `setup`, PSP builds | the toolchain cache root | | `XDG_CACHE_HOME` | `doctor`, `setup`, PSP builds | parent of `pocket-nexus/` when `POCKET_NEXUS_CACHE_DIR` is unset | | `PSP_SDK`, `PSPDEV` | `doctor`, PSP builds | an explicit PSP SDK; `PSP_SDK` takes precedence | diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 5f332866a..64b7a228e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -22,11 +22,17 @@ afterEach(() => { function checkout(): string { const path = `/tmp/pocketjs-cli-${process.pid}-${Math.random().toString(16).slice(2)}`; mkdirSync(path, { recursive: true }); - writeFileSync(join(path, "package.json"), JSON.stringify({ name: "@pocketjs/framework" })); + writeFileSync(join(path, "package.json"), JSON.stringify({ name: "@pocketjs/framework", pocketjs: { protocol: 1 } })); temporary.push(path); return path; } +// Every CLI run gets its own settings directory and no POCKET_TARGET, so the +// machine's settings never reach a test. +const settingsHome = `/tmp/pocketjs-cli-settings-${process.pid}`; +mkdirSync(settingsHome, { recursive: true }); +temporary.push(settingsHome); + /** A directory no PocketJS checkout owns: `pocket create` writes a standalone project here. */ function scratch(): string { const path = `/tmp/pocketjs-cli-standalone-${process.pid}-${Math.random().toString(16).slice(2)}`; @@ -54,7 +60,7 @@ function runCli(cwd: string, args: string[], env: Record = {}) { return Bun.spawnSync({ cmd: [process.execPath, cli, ...args], cwd, - env: { ...process.env, ...env }, + env: { ...process.env, XDG_CONFIG_HOME: settingsHome, POCKET_TARGET: "", ...env }, stdout: "pipe", stderr: "pipe", }); @@ -81,7 +87,7 @@ describe("published PocketJS CLI", () => { viewport: { logical: [480, 272], presentation: "integer-fit" }, }, }); - expect(existsSync(join(app, "pocket.config.ts"))).toBe(false); + expect(readFileSync(join(app, "pocket.config.ts"), "utf8")).toContain('targets: ["psp"]'); expect(readFileSync(join(app, "main.tsx"), "utf8")) .toContain('from "@pocketjs/framework/solid"'); @@ -96,7 +102,11 @@ describe("published PocketJS CLI", () => { const scripts = join(cwd, "tools"); const log = join(cwd, "cli-log.json"); mkdirSync(scripts); - const recorder = `await Bun.write(process.env.POCKET_CLI_TEST_LOG, JSON.stringify({ + const recorder = `if (Bun.argv[2] === "project") { + console.log(JSON.stringify({ ok: true, project: { config: null, targets: [] } })); + process.exit(0); +} +await Bun.write(process.env.POCKET_CLI_TEST_LOG, JSON.stringify({ script: import.meta.url, args: Bun.argv.slice(2), }));\n`; @@ -105,9 +115,9 @@ describe("published PocketJS CLI", () => { } const cases = [ - { cliArgs: ["check", "--target", "psp"], script: "pocket.ts", args: ["check", "--target", "psp"] }, - { cliArgs: ["compile", "--target", "vita"], script: "pocket.ts", args: ["compile", "--target", "vita"] }, - { cliArgs: ["build", "--target", "vita", "--", "--release"], script: "pocket.ts", args: ["build", "--target", "vita", "--", "--release"] }, + { cliArgs: ["check", "--target", "psp"], script: "pocket.ts", args: ["check", "--target", "psp", "--cli-protocol", "1"] }, + { cliArgs: ["compile", "--target", "vita"], script: "pocket.ts", args: ["compile", "--target", "vita", "--cli-protocol", "1"] }, + { cliArgs: ["build", "--target", "vita", "--", "--release"], script: "pocket.ts", args: ["build", "--target", "vita", "--cli-protocol", "1", "--", "--release"] }, { cliArgs: ["play", "vita", "hero"], script: "play.ts", args: ["vita", "hero"] }, { cliArgs: ["vita", "hero", "--release"], script: "vita.ts", args: ["hero", "--release"] }, { cliArgs: ["symbian", "doctor", "--device"], script: "symbian.ts", args: ["doctor", "--device"] }, @@ -133,8 +143,12 @@ describe("published PocketJS CLI", () => { const log = join(cwd, "cli-log.json"); mkdirSync(join(framework, "tools"), { recursive: true }); writeFileSync(join(cwd, "package.json"), JSON.stringify({ name: "external-pocket-app" })); - writeFileSync(join(framework, "package.json"), JSON.stringify({ name: "@pocketjs/framework" })); - writeFileSync(join(framework, "tools/pocket.ts"), `await Bun.write(process.env.POCKET_CLI_TEST_LOG, JSON.stringify({ + writeFileSync(join(framework, "package.json"), JSON.stringify({ name: "@pocketjs/framework", pocketjs: { protocol: 1 } })); + writeFileSync(join(framework, "tools/pocket.ts"), `if (Bun.argv[2] === "project") { + console.log(JSON.stringify({ ok: true, project: { config: null, targets: [] } })); + process.exit(0); +} +await Bun.write(process.env.POCKET_CLI_TEST_LOG, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd(), }));\n`); @@ -153,6 +167,8 @@ describe("published PocketJS CLI", () => { "build", "--host-profile", "firmware/pocket.host.json", + "--cli-protocol", + "1", "--manifest", "app/pocket.json", ], @@ -176,7 +192,7 @@ describe("published PocketJS CLI", () => { expect(result.stdout.toString()).toContain("bun install"); const app = join(cwd, "counter"); - for (const file of ["package.json", "tsconfig.json", "pocket.json", "main.tsx", "app.tsx", "README.md", ".gitignore"]) { + for (const file of ["package.json", "tsconfig.json", "pocket.json", "pocket.config.ts", "main.tsx", "app.tsx", "README.md", ".gitignore"]) { expect(existsSync(join(app, file)), file).toBe(true); } expect(existsSync(join(app, "pocket.host.json"))).toBe(false); @@ -185,10 +201,11 @@ describe("published PocketJS CLI", () => { expect(pkg.dependencies).toEqual({ "@pocketjs/framework": `^${cliVersion}`, vue: FRAMEWORK_DEPENDENCIES["vue-vapor"].vue }); expect(pkg.devDependencies).toEqual({ "@pocketjs/cli": `^${cliVersion}` }); expect(pkg.scripts).toEqual({ - check: "pocket check --target vita", - compile: "pocket compile --target vita", - build: "pocket build --target vita -- --release", + check: "pocket check", + compile: "pocket compile", + build: "pocket build -- --release", }); + expect(readFileSync(join(app, "pocket.config.ts"), "utf8")).toContain('targets: ["vita"]'); expect(readFileSync(join(app, "main.tsx"), "utf8")).toContain('from "@pocketjs/framework/vue-vapor"'); expect(readFileSync(join(app, "app.tsx"), "utf8")).toContain('from "@pocketjs/framework/vue-vapor/lifecycle"'); expect(readJson(join(app, "tsconfig.json")).include).toEqual([ @@ -228,8 +245,9 @@ describe("published PocketJS CLI", () => { expect(readFileSync(join(app, "app.tsx"), "utf8")).toContain("onButtonPress(BTN.CROSS"); const pkg = readJson(join(app, "package.json")); - expect(pkg.scripts.check).toBe("pocket check --host-profile ../firmware/pocket.host.json"); - expect(pkg.scripts.build).toBe("pocket build --host-profile ../firmware/pocket.host.json --output dist/app.pocket"); + expect(pkg.scripts.check).toBe("pocket check"); + expect(pkg.scripts.build).toBe("pocket build --output dist/app.pocket"); + expect(readFileSync(join(app, "pocket.config.ts"), "utf8")).toContain('targets: [{ hostProfile: "../firmware/pocket.host.json" }]'); const readme = readFileSync(join(app, "README.md"), "utf8"); expect(readme).toContain("pocketjs_compile_app("); expect(readme).toContain("pocketjs_embed_package("); @@ -263,7 +281,8 @@ describe("published PocketJS CLI", () => { const manifest = readJson(join(app, "pocket.json")); expect(manifest.app.viewport).toEqual({ logical: [320, 240], presentation: "native" }); expect(resolveAgainstProfile(manifest, profile).ok).toBe(true); - expect(readJson(join(app, "package.json")).scripts.check).toBe("pocket check --host-profile pocket.host.json"); + expect(readJson(join(app, "package.json")).scripts.check).toBe("pocket check"); + expect(readFileSync(join(app, "pocket.config.ts"), "utf8")).toContain('targets: [{ hostProfile: "pocket.host.json" }]'); }); test("create rejects a target that needs a dynamic viewport and a mixed selector", () => { @@ -308,3 +327,216 @@ describe("published PocketJS CLI", () => { } }, 180_000); }); + +describe("target selection", () => { + const catalog = [ + { id: "psp", title: "PlayStation Portable", contract: { kind: "registry", target: "psp" }, hosts: ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] }, + { id: "vita", title: "PlayStation Vita", contract: { kind: "registry", target: "vita" }, hosts: ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] }, + { id: "web-app", title: "Browser", contract: { kind: "registry", target: "web-app" }, hosts: ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] }, + { id: "moto-g-play", title: "Moto G Play", contract: { kind: "profile", target: "moto-g-play-dev" }, hosts: ["darwin-arm64", "darwin-x64", "linux-x64"] }, + ]; + + /** A framework whose tools/pocket.ts answers `project` and `catalog` from + * the environment and appends every other invocation to a log. */ + function fakeFramework(protocol = 1) { + const cwd = checkout(); + writeFileSync(join(cwd, "package.json"), JSON.stringify({ name: "@pocketjs/framework", version: "9.9.9", pocketjs: { protocol } })); + mkdirSync(join(cwd, "tools")); + writeFileSync(join(cwd, "tools/pocket.ts"), `const command = Bun.argv[2]; +if (command === "project") { console.log(process.env.FAKE_PROJECT); process.exit(0); } +if (command === "catalog") { console.log(process.env.FAKE_CATALOG); process.exit(0); } +const log = process.env.POCKET_CLI_TEST_LOG; +const previous = (await Bun.file(log).exists()) ? await Bun.file(log).json() : []; +await Bun.write(log, JSON.stringify([...previous, Bun.argv.slice(2)]));\n`); + const settings = join(cwd, "settings"); + mkdirSync(settings); + return { cwd, log: join(cwd, "log.json"), settings }; + } + + function pocket( + framework: ReturnType, + args: string[], + project: { config: string | null; targets: unknown[] } = { config: null, targets: [] }, + env: Record = {}, + ) { + rmSync(framework.log, { force: true }); + const result = runCli(framework.cwd, args, { + POCKET_CLI_TEST_LOG: framework.log, + XDG_CONFIG_HOME: framework.settings, + FAKE_PROJECT: JSON.stringify({ ok: true, project }), + FAKE_CATALOG: JSON.stringify(catalog), + ...env, + }); + const calls: string[][] = existsSync(framework.log) ? readJson(framework.log) : []; + return { code: result.exitCode, out: result.stdout.toString(), err: result.stderr.toString(), calls }; + } + + const project = (targets: unknown[]) => ({ config: "/work/game/pocket.config.ts", targets }); + + test("no source names a target", () => { + const framework = fakeFramework(); + const result = pocket(framework, ["check"]); + expect(result.code).toBe(1); + expect(result.err).toContain("no target selected"); + expect(result.calls).toEqual([]); + }); + + test("each source overrides the ones after it", () => { + const framework = fakeFramework(); + const targetOf = (result: ReturnType) => result.calls[0]?.slice(1, 3); + + expect(pocket(framework, ["default", "psp"]).code).toBe(0); + let result = pocket(framework, ["check"]); + expect(targetOf(result)).toEqual(["--target", "psp"]); + expect(result.err).toContain("pocket: target psp · user default"); + + result = pocket(framework, ["check"], project([{ id: "vita" }])); + expect(targetOf(result)).toEqual(["--target", "vita"]); + expect(result.err).toContain("pocket: target vita · /work/game/pocket.config.ts"); + + expect(pocket(framework, ["override", "set", "web-app"]).code).toBe(0); + result = pocket(framework, ["check"], project([{ id: "vita" }])); + expect(targetOf(result)).toEqual(["--target", "web-app"]); + expect(result.err).toContain(`pocket: target web-app · directory override ${realpathSync(framework.cwd)}`); + + result = pocket(framework, ["check"], project([{ id: "vita" }]), { POCKET_TARGET: "moto-g-play" }); + expect(targetOf(result)).toEqual(["--target", "moto-g-play"]); + + for (const args of [["+psp", "check"], ["check", "+psp"], ["check", "--target", "psp"], ["check", "--target=psp"]]) { + result = pocket(framework, args, project([{ id: "vita" }]), { POCKET_TARGET: "moto-g-play" }); + expect(targetOf(result), args.join(" ")).toEqual(["--target", "psp"]); + } + + expect(pocket(framework, ["override", "unset"]).code).toBe(0); + expect(pocket(framework, ["default", "--unset"]).code).toBe(0); + expect(pocket(framework, ["check"]).code).toBe(1); + }); + + test("an override applies below its directory", () => { + const framework = fakeFramework(); + const nested = join(framework.cwd, "apps", "game"); + mkdirSync(nested, { recursive: true }); + expect(pocket(framework, ["override", "set", "vita", "--path", framework.cwd]).code).toBe(0); + rmSync(framework.log, { force: true }); + const result = runCli(nested, ["check"], { + POCKET_CLI_TEST_LOG: framework.log, + XDG_CONFIG_HOME: framework.settings, + FAKE_PROJECT: JSON.stringify({ ok: true, project: { config: null, targets: [] } }), + FAKE_CATALOG: JSON.stringify(catalog), + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(readJson(framework.log)[0].slice(1, 3)).toEqual(["--target", "vita"]); + }); + + test("conflicting and misplaced selections are errors", () => { + const framework = fakeFramework(); + let result = pocket(framework, ["+psp", "check", "--target", "vita"]); + expect(result.code).toBe(1); + expect(result.err).toContain("conflicting targets: +psp and --target vita"); + + result = pocket(framework, ["check", "--manifest", "pocket.json", "+psp"]); + expect(result.code).toBe(1); + expect(result.err).toContain("+psp must come right after `pocket` or right after the command"); + + result = pocket(framework, ["check", "--target", "psp", "--", "+vita"]); + expect(result.code).toBe(0); + expect(result.calls[0]).toEqual(["check", "--target", "psp", "--cli-protocol", "1", "--", "+vita"]); + + result = pocket(framework, ["+psp", "create", "x"]); + expect(result.code).toBe(1); + expect(result.err).toContain("+psp selects a target for check, compile, build or show"); + }); + + test("a project entry's manifest and host profile reach tools/pocket.ts", () => { + const framework = fakeFramework(); + const entries = project([ + { id: "moto-g-play", manifest: "/work/game/pocket.android.json" }, + { id: "idf-panel", hostProfile: "/work/firmware/pocket.host.json" }, + ]); + let result = pocket(framework, ["build"], entries); + expect(result.calls[0]).toEqual(["build", "--target", "moto-g-play", "--manifest", "/work/game/pocket.android.json", "--cli-protocol", "1"]); + + result = pocket(framework, ["build", "--manifest", "other.json"], entries); + expect(result.calls[0]).toEqual(["build", "--target", "moto-g-play", "--cli-protocol", "1", "--manifest", "other.json"]); + + result = pocket(framework, ["+idf-panel", "build", "--output", "dist/panel.pocket"], entries); + expect(result.calls[0]).toEqual(["build", "--host-profile", "/work/firmware/pocket.host.json", "--cli-protocol", "1", "--output", "dist/panel.pocket"]); + }); + + test("build --all builds every project target in order", () => { + const framework = fakeFramework(); + const result = pocket(framework, ["build", "--all", "--", "--release"], project([{ id: "psp" }, { id: "vita" }])); + expect(result.code, result.err).toBe(0); + expect(result.calls).toEqual([ + ["build", "--target", "psp", "--cli-protocol", "1", "--", "--release"], + ["build", "--target", "vita", "--cli-protocol", "1", "--", "--release"], + ]); + expect(pocket(framework, ["build", "--all"]).err).toContain("lists none"); + expect(pocket(framework, ["+psp", "build", "--all"], project([{ id: "psp" }])).err).toContain("drop the explicit target"); + }); + + test("a framework on another protocol stops the CLI", () => { + const framework = fakeFramework(2); + const result = pocket(framework, ["check", "--target", "psp"]); + expect(result.code).toBe(1); + expect(result.err).toContain("speaks protocol 1"); + expect(result.err).toContain("speaks protocol 2"); + expect(result.calls).toEqual([]); + }); + + test("default and override reject an id outside the catalog and the project", () => { + const framework = fakeFramework(); + const result = pocket(framework, ["default", "gba"]); + expect(result.code).toBe(1); + expect(result.err).toContain("unknown target gba"); + }); + + test("show reports the framework, the project and the selection with its source", () => { + const framework = fakeFramework(); + const result = pocket(framework, ["show", "--json"], project([{ id: "vita" }])); + expect(result.code, result.err).toBe(0); + const report = JSON.parse(result.out); + expect(report.framework).toMatchObject({ version: "9.9.9", source: "checkout", protocol: 1, root: realpathSync(framework.cwd) }); + expect(report.selection).toEqual({ id: "vita", source: "project", origin: "/work/game/pocket.config.ts" }); + expect(report.targets.map((target: { id: string }) => target.id)).toEqual(["psp", "vita", "web-app", "moto-g-play"]); + + const explicit = JSON.parse(pocket(framework, ["+psp", "show", "--json"], project([{ id: "vita" }])).out); + expect(explicit.selection).toEqual({ id: "psp", source: "explicit", origin: "+psp" }); + }); +}); + +describe("framework resolution", () => { + test("the marker identifies a framework under any package name, and the nearest candidate wins", async () => { + const { resolveFramework } = await import("../tools/cli/framework.mjs"); + const base = scratch(); + const fork = join(base, "fork"); + mkdirSync(fork); + writeFileSync(join(fork, "package.json"), JSON.stringify({ name: "@me/pocketjs-fork", version: "1.0.0", pocketjs: { protocol: 1 } })); + expect(resolveFramework({ cwd: fork, env: {} })).toMatchObject({ root: fork, source: "checkout", name: "@me/pocketjs-fork" }); + + const app = join(fork, "apps", "game"); + mkdirSync(join(app, "node_modules/@pocketjs/framework"), { recursive: true }); + writeFileSync(join(app, "node_modules/@pocketjs/framework/package.json"), JSON.stringify({ name: "@pocketjs/framework", version: "2.0.0", pocketjs: { protocol: 1 } })); + expect(resolveFramework({ cwd: app, env: {} })).toMatchObject({ source: "project", version: "2.0.0", origin: "registry" }); + + const unmarked = join(base, "unmarked"); + mkdirSync(join(unmarked, "node_modules/@pocketjs/framework"), { recursive: true }); + writeFileSync(join(unmarked, "node_modules/@pocketjs/framework/package.json"), JSON.stringify({ name: "@pocketjs/framework", version: "0.14.0" })); + expect(resolveFramework({ cwd: unmarked, env: {} }).error).toContain("no \"pocketjs\": { \"protocol\": … } marker"); + + expect(resolveFramework({ cwd: base, env: { POCKETJS_FRAMEWORK_ROOT: unmarked } }).error).toContain("is not a PocketJS framework"); + }); + + test("a linked install reports where it points", async () => { + const { resolveFramework } = await import("../tools/cli/framework.mjs"); + const base = scratch(); + const fork = join(base, "fork"); + mkdirSync(fork); + writeFileSync(join(fork, "package.json"), JSON.stringify({ name: "@me/fork", version: "1.0.0", pocketjs: { protocol: 1 } })); + const app = join(base, "app"); + mkdirSync(join(app, "node_modules/@pocketjs"), { recursive: true }); + symlinkSync(fork, join(app, "node_modules/@pocketjs/framework"), "dir"); + expect(resolveFramework({ cwd: app, env: {} })).toMatchObject({ root: realpathSync(fork), source: "project", origin: `link → ${realpathSync(fork)}` }); + }); +}); + diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index 3b02574cc..3c1c681de 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -373,8 +373,10 @@ describe("published npm artifacts", () => { "README.md", "bin.mjs", "create.mjs", + "framework.mjs", "package.json", "psp-toolchain.json", + "select.mjs", "symbian-toolchain.json", ]); }); diff --git a/tests/targets-project.test.ts b/tests/targets-project.test.ts new file mode 100644 index 000000000..d5d852360 --- /dev/null +++ b/tests/targets-project.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findProjectConfig, loadProject } from "../tools/targets/project.ts"; + +const root = realpathSync(new URL("..", import.meta.url).pathname); +const temporary: string[] = []; + +afterEach(() => { + for (const path of temporary.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +function scratch(): string { + const path = realpathSync(mkdtempSync(join(tmpdir(), "pocketjs-project-"))); + temporary.push(path); + return path; +} + +const config = (body: string) => `import { definePocketConfig } from "@pocketjs/framework/config";\nexport default definePocketConfig(${body});\n`; +const smokeProfile = readFileSync(join(root, "hosts/esp-idf/examples/smoke/pocket.host.json"), "utf8"); + +describe("project configuration", () => { + test("the nearest pocket.config.ts above the manifest directory wins and the lookup stops there", async () => { + const project = scratch(); + writeFileSync(join(project, "pocket.config.ts"), config('{ targets: ["psp"] }')); + const app = join(project, "apps", "game"); + mkdirSync(app, { recursive: true }); + expect(findProjectConfig(app)).toBe(join(project, "pocket.config.ts")); + + writeFileSync(join(app, "pocket.config.ts"), config("{ theme: {} }")); + const result = await loadProject(app); + expect(result).toEqual({ ok: true, project: { config: join(app, "pocket.config.ts"), targets: [] } }); + }); + + test("entries resolve against the configuration file and host profiles are named by their id", async () => { + const project = scratch(); + mkdirSync(join(project, "firmware")); + writeFileSync(join(project, "firmware/pocket.host.json"), smokeProfile); + writeFileSync(join(project, "pocket.config.ts"), config(`{ + targets: [ + "vita", + { target: "moto-g-play", manifest: "pocket.android.json" }, + { hostProfile: "firmware/pocket.host.json" }, + ], + }`)); + const result = await loadProject(join(project, "src")); + expect(result).toEqual({ + ok: true, + project: { + config: join(project, "pocket.config.ts"), + targets: [ + { id: "vita" }, + { id: "moto-g-play", manifest: join(project, "pocket.android.json") }, + { id: "idf-smoke", hostProfile: join(project, "firmware/pocket.host.json") }, + ], + }, + }); + }); + + test("unknown ids, duplicates, missing profiles and profile ids that collide are diagnostics", async () => { + const project = scratch(); + writeFileSync(join(project, "colliding.host.json"), smokeProfile.replace('"idf-smoke"', '"psp"')); + writeFileSync(join(project, "command.host.json"), smokeProfile.replace('"idf-smoke"', '"show"')); + writeFileSync(join(project, "pocket.config.ts"), config(`{ + targets: ["gba", "psp", "psp", { hostProfile: "missing.json" }, { hostProfile: "colliding.host.json" }, { hostProfile: "command.host.json" }], + }`)); + const result = await loadProject(project); + expect(result.ok).toBe(false); + const diagnostics = result.ok ? [] : result.diagnostics.join("\n"); + expect(diagnostics).toContain("targets[0]: unknown target gba"); + expect(diagnostics).toContain("target psp is listed twice"); + expect(diagnostics).toContain(`targets[3]: host profile not found: ${join(project, "missing.json")}`); + expect(diagnostics).toContain("targets[4]: host profile id psp is a catalog id or a pocket command word"); + expect(diagnostics).toContain("targets[5]: host profile id show is a catalog id or a pocket command word"); + }); + + test("a project without node_modules evaluates against this framework", () => { + const project = scratch(); + writeFileSync(join(project, "pocket.config.ts"), config('{ targets: ["web-app"] }')); + const result = Bun.spawnSync({ + cmd: [process.execPath, "--no-install", join(root, "tools/pocket.ts"), "project"], + cwd: project, + env: { ...process.env, POCKETJS_FRAMEWORK_ROOT: root }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + expect(JSON.parse(result.stdout.toString())).toEqual({ + ok: true, + project: { config: join(project, "pocket.config.ts"), targets: [{ id: "web-app" }] }, + }); + }); + + test("tools/pocket.ts stops when the CLI speaks another protocol", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, join(root, "tools/pocket.ts"), "check", "--target", "psp", "--cli-protocol", "2"], + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("the CLI speaks protocol 2 and the framework"); + }); +}); diff --git a/tools/cli/bin.mjs b/tools/cli/bin.mjs index 5f0c7a88b..e52c41496 100755 --- a/tools/cli/bin.mjs +++ b/tools/cli/bin.mjs @@ -9,8 +9,11 @@ // pocket create [--target T] [--framework F] [--host-profile FILE] // scaffold an app: apps/ inside a checkout, a // standalone project anywhere else -// pocket check|compile|build --target | --host-profile FILE [...args] -// resolve pocket.json once, then build from its plan +// pocket [+] check|compile|build [+] [--target | --host-profile FILE] [...args] +// select a target, resolve pocket.json once, build from its plan +// pocket show [--json] framework, project, selected target and the catalog +// pocket default [ | --unset] +// pocket override set [--path DIR] | unset [--path DIR] | list // pocket play vita build, install and launch a demo in Vita3K // pocket play ios build, stage and launch a demo on the iOS simulator // pocket dev|psp|vita|hw|psplink|devtools|tape [...args] @@ -21,11 +24,21 @@ // The published CLI ships the same manifest consumed by PocketJS build scripts. import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { homedir, platform } from "node:os"; import { dirname, join, posix, relative, resolve } from "node:path"; import { createInterface } from "node:readline"; import { CREATE_USAGE, CreateUsageError, create, pocketCommands } from "./create.mjs"; +import { CLI_PROTOCOL, protocolMismatch, resolveFramework } from "./framework.mjs"; +import { + NO_SELECTION, + overrideFor, + readSettings, + select, + settingsPath, + takeExplicitSelection, + writeSettings, +} from "./select.mjs"; if (typeof Bun === "undefined") { console.error( @@ -55,55 +68,28 @@ const run = (cmd, args) => { const cliVersion = () => JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; // --------------------------------------------------------------------------- -// Framework discovery: a source checkout, or the project's installed package +// Framework: tools/cli/framework.mjs resolves it // --------------------------------------------------------------------------- -function findCheckout(from = process.cwd()) { - let dir = resolve(from); - for (;;) { - const pkg = join(dir, "package.json"); - if (existsSync(pkg)) { - try { - if (JSON.parse(readFileSync(pkg, "utf8")).name === "@pocketjs/framework") return dir; - } catch {} - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } +function fail(message) { + console.error(C.bad(message)); + process.exit(1); } -/** Resolve a project-local published framework when no source checkout owns cwd. */ -function findFrameworkInstallation(from = process.cwd()) { - if (process.env.POCKETJS_FRAMEWORK_ROOT?.trim()) { - const explicit = resolve(process.env.POCKETJS_FRAMEWORK_ROOT.trim()); - return existsSync(join(explicit, "tools", "pocket.ts")) ? explicit : null; - } - let dir = resolve(from); - for (;;) { - const candidate = join(dir, "node_modules", "@pocketjs", "framework"); - if (existsSync(join(candidate, "package.json")) && existsSync(join(candidate, "tools", "pocket.ts"))) { - return candidate; - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } +/** The framework for this directory after the protocol check; exits on either error. */ +function requireFramework() { + const framework = resolveFramework(); + if (framework.error) fail(framework.error); + const mismatch = protocolMismatch(framework, cliVersion()); + if (mismatch) fail(mismatch); + return framework; } -/** The framework whose tools/pocket.ts runs manifest commands: an explicit override, else checkout, else project. */ -function frameworkRoot() { - return process.env.POCKETJS_FRAMEWORK_ROOT?.trim() - ? findFrameworkInstallation() - : findCheckout() ?? findFrameworkInstallation(); -} - -function frameworkVersion(root) { - try { - return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? null; - } catch { - return null; - } +/** The framework root when it is a source checkout or POCKETJS_FRAMEWORK_ROOT; + * host-development commands run the checkout's scripts. */ +function checkoutRoot() { + const framework = resolveFramework(); + return !framework.error && (framework.source === "checkout" || framework.source === "env") ? framework.root : null; } // --------------------------------------------------------------------------- @@ -187,11 +173,9 @@ function llvmResolution() { } function checks() { - const root = findCheckout(); - const framework = frameworkRoot(); - const frameworkSource = !framework - ? undefined - : process.env.POCKETJS_FRAMEWORK_ROOT?.trim() ? "POCKETJS_FRAMEWORK_ROOT" : framework === root ? "checkout" : "project"; + const resolved = resolveFramework(); + const framework = resolved.error ? null : resolved; + const root = checkoutRoot(); const isMac = platform() === "darwin"; const llvm = llvmResolution(); const toolchains = run("rustup", ["toolchain", "list"]) ?? ""; @@ -216,11 +200,11 @@ function checks() { }, { name: framework - ? `@pocketjs/framework ${frameworkVersion(framework) ?? "(unversioned)"}` + ? `@pocketjs/framework ${framework.version ?? "(unversioned)"}` : "@pocketjs/framework", - ok: !!framework, - hint: "git clone https://github.com/pocket-nexus/pocketjs, or add @pocketjs/framework to this project", - detail: framework ? `${frameworkSource} ${framework}` : undefined, + ok: !!framework && !protocolMismatch(framework, cliVersion()), + hint: resolved.error ?? (framework && protocolMismatch(framework, cliVersion())) ?? undefined, + detail: framework ? `${framework.origin} ${framework.root}` : undefined, }, ], psp: [ @@ -312,7 +296,7 @@ async function confirm(question) { } async function setup() { - const root = findCheckout(); + const root = checkoutRoot(); if (!root) { console.error(C.bad("`pocket setup` must run inside a PocketJS checkout")); console.error(C.dim("git clone https://github.com/pocket-nexus/pocketjs && cd pocketjs")); @@ -334,10 +318,13 @@ async function setup() { // --------------------------------------------------------------------------- function scaffold(args) { - const checkoutRoot = findCheckout(); + const resolved = resolveFramework(); let result; try { - result = create(args, { checkoutRoot, cliVersion: cliVersion() }); + result = create(args, { + checkoutRoot: !resolved.error && resolved.source === "checkout" ? resolved.root : null, + cliVersion: cliVersion(), + }); } catch (error) { if (!(error instanceof CreateUsageError)) throw error; console.error(C.bad(error.message)); @@ -383,19 +370,215 @@ const SCRIPTS = { play: "tools/play.ts", }; -function manifestCommand(cmd, args) { - const root = frameworkRoot(); - if (!root) { - console.error(C.bad( - "PocketJS framework not found — install @pocketjs/framework in this project or set POCKETJS_FRAMEWORK_ROOT", - )); - process.exit(1); - } - const r = spawnSync(process.execPath, [join(root, "tools/pocket.ts"), cmd, ...args], { - stdio: "inherit", +// --------------------------------------------------------------------------- +// verbs — select the target (tools/cli/select.mjs), then run tools/pocket.ts +// --------------------------------------------------------------------------- + +/** The value of `--` before `--`, or undefined. */ +function optionValue(args, name) { + const separator = args.indexOf("--"); + const before = separator >= 0 ? args.slice(0, separator) : args; + const inline = before.find((value) => value.startsWith(`--${name}=`)); + if (inline) return inline.slice(name.length + 3); + const index = before.indexOf(`--${name}`); + return index >= 0 ? before[index + 1] : undefined; +} + +/** A query of tools/pocket.ts that prints JSON. */ +function frameworkQuery(framework, args) { + const result = spawnSync(process.execPath, [join(framework.root, "tools/pocket.ts"), ...args], { cwd: process.cwd(), + encoding: "utf8", }); - process.exit(r.status ?? 1); + try { + return JSON.parse(result.stdout); + } catch { + fail(`tools/pocket.ts ${args[0]} failed:\n${result.stderr || result.stdout}`); + } +} + +/** The project configuration for the manifest the command names. */ +function projectFor(framework, manifest) { + const result = frameworkQuery(framework, ["project", ...(manifest ? ["--manifest", manifest] : [])]); + if (!result.ok) fail(result.diagnostics.join("\n")); + return result.project; +} + +/** tools/pocket.ts arguments for a selection: a catalog id, or a host profile; + * a project entry's manifest applies unless the command names one. */ +function selectionArgs(selection, project, args) { + if (selection.hostProfile) return ["--host-profile", selection.hostProfile]; + const entry = project.targets.find((target) => target.id === selection.id); + const manifest = entry?.manifest && optionValue(args, "manifest") === undefined ? ["--manifest", entry.manifest] : []; + if (entry?.hostProfile) return ["--host-profile", entry.hostProfile, ...manifest]; + return ["--target", selection.id, ...manifest]; +} + +function runVerb(framework, cmd, selection, project, args) { + console.error(C.dim(`pocket: target ${selection.id ?? selection.hostProfile} · ${selection.origin}`)); + const result = spawnSync( + process.execPath, + [ + join(framework.root, "tools/pocket.ts"), + cmd, + ...selectionArgs(selection, project, args), + "--cli-protocol", + String(CLI_PROTOCOL), + ...args, + ], + { stdio: "inherit", cwd: process.cwd() }, + ); + return result.status ?? 1; +} + +function verb(cmd, args, leading) { + const framework = requireFramework(); + const taken = takeExplicitSelection(args, leading); + if (taken.error) fail(taken.error); + let rest = taken.args; + const separator = rest.indexOf("--"); + const allIndex = rest.indexOf("--all"); + const all = allIndex >= 0 && (separator < 0 || allIndex < separator); + if (all) { + if (cmd !== "build") fail("--all applies to `pocket build`"); + if (taken.explicit) fail("--all builds the targets of pocket.config.ts; drop the explicit target"); + rest = rest.filter((_, index) => index !== allIndex); + } + const project = projectFor(framework, optionValue(rest, "manifest")); + if (all) { + if (project.targets.length === 0) { + fail(`--all builds the targets of pocket.config.ts, and ${project.config ?? "no pocket.config.ts"} lists none`); + } + for (const target of project.targets) { + const status = runVerb(framework, cmd, { id: target.id, source: "project", origin: project.config }, project, rest); + if (status !== 0) process.exit(status); + } + process.exit(0); + } + const selection = select({ explicit: taken.explicit, settings: readSettings(), project }); + if (!selection) fail(NO_SELECTION); + process.exit(runVerb(framework, cmd, selection, project, rest)); +} + +// --------------------------------------------------------------------------- +// show, default, override +// --------------------------------------------------------------------------- + +function currentHostId() { + const os = platform() === "darwin" ? "darwin" : platform() === "linux" ? "linux" : null; + const cpu = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : null; + return os && cpu ? `${os}-${cpu}` : null; +} + +function show(args, leading) { + const json = args.includes("--json"); + const resolved = resolveFramework(); + const problem = resolved.error ?? protocolMismatch(resolved, cliVersion()); + const settings = readSettings(); + const host = currentHostId(); + const project = problem ? null : projectFor(resolved, optionValue(args, "manifest")); + const catalog = problem ? [] : frameworkQuery(resolved, ["catalog", "--json"]); + const explicit = leading ? { id: leading, origin: `+${leading}` } : null; + const selection = select({ explicit, settings, project }); + const report = { + framework: problem + ? { error: problem } + : { name: resolved.name, version: resolved.version, root: resolved.root, source: resolved.source, origin: resolved.origin, protocol: resolved.protocol }, + project: project ?? null, + selection, + host: host ?? `${platform()}-${process.arch} (unsupported)`, + settings: settingsPath(), + targets: catalog.map((target) => ({ + id: target.id, + title: target.title, + contract: target.contract.target, + supported: host !== null && target.hosts.includes(host), + })), + }; + if (json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + const row = (label, value) => console.log(`${label.padEnd(11)} ${value}`); + row("framework", problem ? C.bad(problem) : `${resolved.name ?? "@pocketjs/framework"} ${resolved.version ?? ""} ${resolved.origin} ${resolved.root}`); + row("project", project?.config + ? `${project.config}${project.targets.length ? ` (targets: ${project.targets.map((target) => target.id).join(", ")})` : " (no targets)"}` + : "no pocket.config.ts"); + row("target", selection ? `${selection.id ?? selection.hostProfile} ${selection.origin}` : C.dim("none — `pocket default `, `pocket override set `, or `targets` in pocket.config.ts")); + row("host", report.host); + if (report.targets.length === 0) return; + console.log("\ntargets"); + for (const target of report.targets) { + const marker = target.id === selection?.id ? "*" : " "; + console.log(` ${marker} ${target.id.padEnd(20)} ${target.title}${target.supported ? "" : C.dim(" unsupported on this host")}`); + } + for (const target of project?.targets.filter((entry) => entry.hostProfile) ?? []) { + const marker = target.id === selection?.id ? "*" : " "; + console.log(` ${marker} ${target.id.padEnd(20)} ESP-IDF host profile ${target.hostProfile}`); + } +} + +/** Fails unless `id` names a catalog target or a host profile of this project; skipped without a framework. */ +function requireKnownTarget(id) { + const resolved = resolveFramework(); + if (resolved.error || protocolMismatch(resolved, cliVersion())) return; + const catalog = frameworkQuery(resolved, ["catalog", "--json"]); + if (catalog.some((target) => target.id === id)) return; + const project = projectFor(resolved, undefined); + if (project.targets.some((target) => target.id === id)) return; + fail(`unknown target ${id}; targets: ${catalog.map((target) => target.id).join(", ")}`); +} + +function defaultCommand(args) { + const settings = readSettings(); + if (args.length === 0) { + console.log(settings.default ?? "no user default"); + return; + } + if (args[0] === "--unset") { + delete settings.default; + writeSettings(settings); + console.log(C.ok("user default removed")); + return; + } + requireKnownTarget(args[0]); + writeSettings({ ...settings, default: args[0] }); + console.log(C.ok(`user default: ${args[0]}`)); +} + +function overrideCommand(args) { + const [action, ...rest] = args; + const settings = readSettings(); + // Real paths: the current directory a later command compares against is one. + const requested = resolve(optionValue(rest, "path") ?? process.cwd()); + const path = existsSync(requested) ? realpathSync(requested) : requested; + switch (action) { + case "set": { + const id = rest.find((value, index) => !value.startsWith("--") && rest[index - 1] !== "--path"); + if (!id) fail("usage: pocket override set [--path DIR]"); + requireKnownTarget(id); + writeSettings({ ...settings, overrides: { ...settings.overrides, [path]: id } }); + console.log(C.ok(`override: ${path} → ${id}`)); + return; + } + case "unset": { + if (!(path in settings.overrides)) fail(`no override for ${path}`); + const { [path]: _removed, ...overrides } = settings.overrides; + writeSettings({ ...settings, overrides }); + console.log(C.ok(`override removed: ${path}`)); + return; + } + case "list": { + const entries = Object.entries(settings.overrides); + if (entries.length === 0) console.log("no overrides"); + for (const [directory, id] of entries) console.log(`${directory} ${id}`); + const active = overrideFor(process.cwd(), settings); + if (active) console.log(C.dim(`active here: ${active.directory} → ${active.id}`)); + return; + } + default: + fail("usage: pocket override set [--path DIR] | unset [--path DIR] | list"); + } } function passthrough(cmd, args) { @@ -403,7 +586,7 @@ function passthrough(cmd, args) { } function passthroughScript(cmd, script, args) { - const root = findCheckout(); + const root = checkoutRoot(); if (!root) { console.error(C.bad(`\`pocket ${cmd}\` must run inside a PocketJS checkout`)); process.exit(1); @@ -416,7 +599,12 @@ function passthroughScript(cmd, script, args) { // main // --------------------------------------------------------------------------- -const [, , cmd, ...rest] = process.argv; +const argvAfterPocket = process.argv.slice(2); +// `pocket + `: a leading selection, as `cargo +`. +const leading = argvAfterPocket[0]?.startsWith("+") ? argvAfterPocket.shift().slice(1) : undefined; +const [cmd, ...rest] = argvAfterPocket; +const SELECTING = new Set(["check", "compile", "build", "show"]); +if (leading !== undefined && !SELECTING.has(cmd)) fail(`+${leading} selects a target for check, compile, build or show`); const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI (runs on Bun) pocket doctor diagnose Bun / Rust / PSP toolchain / PSPLINK @@ -427,14 +615,17 @@ const HELP = `${C.bold("pocket")} — the PocketJS toolchain CLI (runs on Bun) macos-app | linux-app | esp-idf --framework F solid (default) | vue-vapor | octane --host-profile FILE derive the manifest from an ESP-IDF host profile - pocket check --target T validate pocket.json, target APIs and app types - pocket check --host-profile FILE - validate against an ESP-IDF product host - pocket compile --target T - check + emit JS/pak from one resolved build plan - pocket build --target T check + compile + package PSP or Vita artifacts - pocket build --host-profile FILE - build a .pocket for an ESP-IDF product host + pocket [+id] check validate pocket.json, target APIs and app types + pocket [+id] compile check + emit JS/pak from one resolved build plan + pocket [+id] build check + compile + package the target's artifacts + --target ID the same as +ID + --host-profile FILE an ESP-IDF product host + --all (build) every target of pocket.config.ts + pocket show [--json] framework, project, selected target and why, catalog + pocket default [ID | --unset] + the user default target + pocket override set ID [--path DIR] | unset [--path DIR] | list + a target for a directory and everything below it pocket play vita build, install and launch a demo in Vita3K pocket play ios build, stage and launch a demo on the iOS simulator pocket dev -main build + serve an app in the browser @@ -461,7 +652,16 @@ switch (cmd) { case "check": case "compile": case "build": - manifestCommand(cmd, rest); + verb(cmd, rest, leading); + break; + case "show": + show(rest, leading); + break; + case "default": + defaultCommand(rest); + break; + case "override": + overrideCommand(rest); break; case "dev": case "psp": @@ -478,8 +678,10 @@ switch (cmd) { case "--version": case "-v": { console.log(cliVersion()); - const root = frameworkRoot(); - if (root) console.log(C.dim(`@pocketjs/framework ${frameworkVersion(root) ?? "(unversioned)"} (${root})`)); + const framework = resolveFramework(); + if (!framework.error) { + console.log(C.dim(`${framework.name ?? "@pocketjs/framework"} ${framework.version ?? "(unversioned)"} (${framework.origin} ${framework.root})`)); + } break; } default: diff --git a/tools/cli/create.d.mts b/tools/cli/create.d.mts index bf8e5e51b..51d6d20c6 100644 --- a/tools/cli/create.d.mts +++ b/tools/cli/create.d.mts @@ -29,7 +29,6 @@ export interface PocketCommands { export function pocketCommands(input: { readonly name: string; readonly target: CreateTarget; - readonly hostProfilePath?: string; readonly manifest?: string; }): PocketCommands; diff --git a/tools/cli/create.mjs b/tools/cli/create.mjs index ef1c08240..cf19d1b5a 100644 --- a/tools/cli/create.mjs +++ b/tools/cli/create.mjs @@ -252,24 +252,34 @@ function readHostProfile(path) { } /** The `pocket` invocations for a scaffolded app; `manifest` is set when they run from outside its directory. */ -export function pocketCommands({ name, target, hostProfilePath, manifest: manifestPath }) { +/** The `pocket` invocations for a scaffolded app. pocket.config.ts names the + * target, so they carry no selection; `manifest` is set when they run from + * outside the app directory. */ +export function pocketCommands({ name, target, manifest: manifestPath }) { const manifestFlag = manifestPath ? ` --manifest ${manifestPath}` : ""; - if (target === "esp-idf") { - const selector = `--host-profile ${hostProfilePath}${manifestFlag}`; - return { - check: `pocket check ${selector}`, - compile: `pocket compile ${selector}`, - build: `pocket build ${selector} --output dist/${name}.pocket`, - }; - } - const release = target === "psp" || target === "vita" ? " -- --release" : ""; + const build = target === "esp-idf" + ? ` --output dist/${name}.pocket` + : target === "psp" || target === "vita" ? " -- --release" : ""; return { - check: `pocket check --target ${target}${manifestFlag}`, - compile: `pocket compile --target ${target}${manifestFlag}`, - build: `pocket build --target ${target}${manifestFlag}${release}`, + check: `pocket check${manifestFlag}`, + compile: `pocket compile${manifestFlag}`, + build: `pocket build${manifestFlag}${build}`, }; } +/** pocket.config.ts listing the app's target: a catalog id, or the host profile. */ +function pocketConfig({ target, hostProfilePath }) { + const entry = target === "esp-idf" ? `{ hostProfile: ${JSON.stringify(hostProfilePath)} }` : JSON.stringify(target); + return `import { definePocketConfig } from "@pocketjs/framework/config"; + +// The first entry of \`targets\` is the target \`pocket check\`, \`compile\` and +// \`build\` use when the command names none. +export default definePocketConfig({ + targets: [${entry}], +}); +`; +} + const ARTIFACT = { psp: "an EBOOT.PBP from the pinned PSP toolchain (`pocket doctor` lists what it needs)", vita: "a VPK; needs VitaSDK (`VITASDK`) and cargo-vita", @@ -412,6 +422,7 @@ export function create(argv, { checkoutRoot, cliVersion, cwd = process.cwd() }) const requires = ["text.glyphs.baked", ...(buttons ? ["input.buttons"] : [])]; const files = { + "pocket.config.ts": pocketConfig({ target, hostProfilePath }), "app.tsx": APP[framework]({ title, buttons }), "main.tsx": MAIN[framework](title), "pocket.json": json(manifest({ name, title, framework, viewport, requires })), diff --git a/tools/cli/framework.d.mts b/tools/cli/framework.d.mts new file mode 100644 index 000000000..79ac1c9f1 --- /dev/null +++ b/tools/cli/framework.d.mts @@ -0,0 +1,27 @@ +// Types for tests importing tools/cli/framework.mjs; not part of the published +// tarball (tools/cli/package.json "files"). + +export const CLI_PROTOCOL: number; +export const INSTALL_PATH: string; + +export interface FrameworkMarker { + readonly protocol: number; + readonly name: string | null; + readonly version: string | null; +} + +export interface ResolvedFramework extends FrameworkMarker { + readonly root: string; + readonly source: "env" | "checkout" | "project"; + readonly origin: string; + readonly error?: undefined; +} + +export function readMarker(directory: string): FrameworkMarker | null; + +export function resolveFramework(options?: { + readonly cwd?: string; + readonly env?: Record; +}): ResolvedFramework | { readonly error: string }; + +export function protocolMismatch(framework: ResolvedFramework, cliVersion: string): string | null; diff --git a/tools/cli/framework.mjs b/tools/cli/framework.mjs new file mode 100644 index 000000000..549bac624 --- /dev/null +++ b/tools/cli/framework.mjs @@ -0,0 +1,98 @@ +// tools/cli/framework.mjs — which PocketJS framework a `pocket` command runs. +// +// A framework root is a directory whose package.json carries +// `"pocketjs": { "protocol": }`; the package name is not read, so a fork +// published under another name is recognized. An application installs the +// framework at node_modules/@pocketjs/framework, whatever its package name. +// +// 1. POCKETJS_FRAMEWORK_ROOT, when set +// 2. the nearest of: an ancestor that is a framework root (a checkout), or an +// ancestor's node_modules/@pocketjs/framework; when one directory is both, +// the checkout wins + +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** The CLI ↔ framework protocol this CLI speaks. */ +export const CLI_PROTOCOL = 1; + +export const INSTALL_PATH = join("node_modules", "@pocketjs", "framework"); + +/** The marker of a framework root, or null. */ +export function readMarker(directory) { + try { + const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + const protocol = pkg?.pocketjs?.protocol; + if (!Number.isInteger(protocol)) return null; + return { protocol, name: pkg.name ?? null, version: pkg.version ?? null }; + } catch { + return null; + } +} + +/** The lockfile spec an application pinned the framework with, when its bun.lock names one. */ +function lockedSpec(projectDirectory) { + try { + const lock = readFileSync(join(projectDirectory, "bun.lock"), "utf8"); + const match = lock.match(/"@pocketjs\/framework": \["@pocketjs\/framework@([^"]+)"/); + return match ? match[1] : null; + } catch { + return null; + } +} + +/** How an application's install path entry was produced: a link, a git + * dependency, a local tarball or directory, or a registry version. */ +function installSource(projectDirectory, installPath) { + if (lstatSync(installPath).isSymbolicLink()) return `link → ${realpathSync(installPath)}`; + const spec = lockedSpec(projectDirectory); + if (spec && /^(?:github:|git\+|git:|https?:\/\/.*\.git)/.test(spec)) return `git ${spec.replace(/^git\+/, "")}`; + if (spec && /^(?:file:|link:)/.test(spec)) return spec; + return "registry"; +} + +/** + * The framework for `cwd`: `{ root, source, origin, protocol, version, name }`, + * or `{ error }`. `source` is "env", "checkout" or "project"; `origin` says + * where it came from for `pocket show`. + */ +export function resolveFramework({ cwd = process.cwd(), env = process.env } = {}) { + const explicit = env.POCKETJS_FRAMEWORK_ROOT?.trim(); + if (explicit) { + const root = resolve(explicit); + const marker = readMarker(root); + if (!marker) { + return { error: `POCKETJS_FRAMEWORK_ROOT=${root} is not a PocketJS framework: its package.json has no "pocketjs": { "protocol": … } marker` }; + } + return { root, source: "env", origin: "POCKETJS_FRAMEWORK_ROOT", ...marker }; + } + for (let directory = resolve(cwd); ; directory = dirname(directory)) { + const checkout = readMarker(directory); + if (checkout) { + const shadowed = existsSync(join(directory, INSTALL_PATH)); + return { + root: directory, + source: "checkout", + origin: shadowed ? `checkout (over ${join(directory, INSTALL_PATH)})` : "checkout", + ...checkout, + }; + } + const installPath = join(directory, INSTALL_PATH); + if (existsSync(join(installPath, "package.json"))) { + const marker = readMarker(installPath); + if (!marker) { + return { error: `${installPath} is not a PocketJS framework this CLI can drive: its package.json has no "pocketjs": { "protocol": … } marker; install a newer @pocketjs/framework` }; + } + return { root: realpathSync(installPath), source: "project", origin: installSource(directory, installPath), ...marker }; + } + if (dirname(directory) === directory) { + return { error: "PocketJS framework not found — install @pocketjs/framework in this project, work inside a PocketJS checkout, or set POCKETJS_FRAMEWORK_ROOT" }; + } + } +} + +/** A diagnostic when the framework speaks another protocol than this CLI, or null. */ +export function protocolMismatch(framework, cliVersion) { + if (framework.protocol === CLI_PROTOCOL) return null; + return `@pocketjs/cli ${cliVersion} speaks protocol ${CLI_PROTOCOL} and ${framework.name ?? "the framework"} ${framework.version ?? ""} at ${framework.root} speaks protocol ${framework.protocol}; install matching versions`; +} diff --git a/tools/cli/package.json b/tools/cli/package.json index f347911d7..ed0b7e3d2 100644 --- a/tools/cli/package.json +++ b/tools/cli/package.json @@ -10,6 +10,8 @@ "files": [ "bin.mjs", "create.mjs", + "framework.mjs", + "select.mjs", "psp-toolchain.json", "symbian-toolchain.json", "README.md" diff --git a/tools/cli/select.d.mts b/tools/cli/select.d.mts new file mode 100644 index 000000000..9e4c61a06 --- /dev/null +++ b/tools/cli/select.d.mts @@ -0,0 +1,37 @@ +// Types for tests importing tools/cli/select.mjs; not part of the published +// tarball (tools/cli/package.json "files"). + +export const SETTINGS_SCHEMA: string; +export const NO_SELECTION: string; + +export interface Settings { + readonly schema: string; + readonly default?: string; + readonly overrides: Readonly>; +} + +export interface ExplicitSelection { + readonly id?: string; + readonly hostProfile?: string; + readonly origin: string; +} + +export interface Selection extends ExplicitSelection { + readonly source: "explicit" | "env" | "override" | "project" | "default"; +} + +export function settingsPath(env?: Record): string; +export function readSettings(env?: Record): Settings; +export function writeSettings(settings: Settings, env?: Record): void; +export function overrideFor(cwd: string, settings: Settings): { readonly id: string; readonly directory: string } | null; +export function takeExplicitSelection( + args: readonly string[], + leading?: string, +): { readonly args: string[]; readonly explicit: ExplicitSelection | null; readonly error?: undefined } | { readonly error: string }; +export function select(input: { + readonly explicit: ExplicitSelection | null; + readonly env?: Record; + readonly cwd?: string; + readonly settings: Settings; + readonly project: { readonly config: string | null; readonly targets: readonly { readonly id: string }[] } | null; +}): Selection | null; diff --git a/tools/cli/select.mjs b/tools/cli/select.mjs new file mode 100644 index 000000000..8732942ad --- /dev/null +++ b/tools/cli/select.mjs @@ -0,0 +1,110 @@ +// tools/cli/select.mjs — which target a `pocket` verb acts on. +// +// The selection is the first of these that is present: +// +// 1. explicit `pocket + `, `pocket +`, `--target ` +// or `--host-profile `; two that differ are an error +// 2. env POCKET_TARGET +// 3. override the directory override of the nearest ancestor of the +// current directory (`pocket override set`) +// 4. project the first entry of `targets` in the project's pocket.config.ts +// 5. default the user default (`pocket default`) +// +// Overrides and the default live in /pocket-nexus/settings.json, where +// is XDG_CONFIG_HOME or ~/.config. + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +export const SETTINGS_SCHEMA = "pocket-settings/1"; + +export function settingsPath(env = process.env) { + const configHome = env.XDG_CONFIG_HOME?.trim() ? resolve(env.XDG_CONFIG_HOME.trim()) : join(env.HOME || homedir(), ".config"); + return join(configHome, "pocket-nexus", "settings.json"); +} + +export function readSettings(env = process.env) { + const path = settingsPath(env); + if (!existsSync(path)) return { schema: SETTINGS_SCHEMA, overrides: {} }; + const settings = JSON.parse(readFileSync(path, "utf8")); + if (settings.schema !== SETTINGS_SCHEMA) throw new Error(`${path}: unknown schema ${settings.schema}`); + return { ...settings, overrides: settings.overrides ?? {} }; +} + +/** Writes by rename, so a concurrent reader sees the old or the new file. */ +export function writeSettings(settings, env = process.env) { + const path = settingsPath(env); + mkdirSync(dirname(path), { recursive: true }); + const staging = `${path}.${process.pid}.tmp`; + writeFileSync(staging, JSON.stringify({ ...settings, schema: SETTINGS_SCHEMA }, null, 2) + "\n"); + renameSync(staging, path); +} + +/** The override of `cwd` or of its nearest ancestor that has one. */ +export function overrideFor(cwd, settings) { + for (let directory = resolve(cwd); ; directory = dirname(directory)) { + const id = settings.overrides[directory]; + if (id) return { id, directory }; + if (dirname(directory) === directory) return null; + } +} + +/** + * Takes the explicit selection out of a verb's arguments. + * `leading` is a `+id` given before the verb. Returns `{ args, explicit }` or + * `{ error }`; `explicit` is `{ id, origin }`, `{ hostProfile, origin }` or null. + */ +export function takeExplicitSelection(args, leading) { + const rest = [...args]; + const separator = rest.indexOf("--"); + const before = separator >= 0 ? rest.slice(0, separator) : rest; + const after = separator >= 0 ? rest.slice(separator) : []; + const choices = []; + if (leading) choices.push({ id: leading, origin: `+${leading}` }); + if (before[0]?.startsWith("+")) { + const id = before.shift().slice(1); + choices.push({ id, origin: `+${id}` }); + } + const misplaced = before.find((value) => value.startsWith("+")); + if (misplaced) return { error: `${misplaced} must come right after \`pocket\` or right after the command` }; + for (const flag of ["target", "host-profile"]) { + const inline = before.findIndex((value) => value.startsWith(`--${flag}=`)); + if (inline >= 0) { + const value = before.splice(inline, 1)[0].slice(flag.length + 3); + choices.push(flag === "target" ? { id: value, origin: `--target ${value}` } : { hostProfile: value, origin: `--host-profile ${value}` }); + continue; + } + const index = before.indexOf(`--${flag}`); + if (index < 0) continue; + const value = before[index + 1]; + if (!value || value.startsWith("-")) return { error: `--${flag} requires a value` }; + before.splice(index, 2); + choices.push(flag === "target" ? { id: value, origin: `--target ${value}` } : { hostProfile: value, origin: `--host-profile ${value}` }); + } + const distinct = new Set(choices.map((choice) => choice.id ?? `host-profile:${choice.hostProfile}`)); + if (distinct.size > 1) return { error: `conflicting targets: ${choices.map((choice) => choice.origin).join(" and ")}` }; + return { args: [...before, ...after], explicit: choices[0] ?? null }; +} + +/** + * Applies the order above. `project` is `{ config, targets: [{ id, … }] }` or + * null. Returns `{ id, source, origin }` (plus `hostProfile` for an explicit + * host profile) or null when no source names a target. + */ +export function select({ explicit, env = process.env, cwd = process.cwd(), settings, project }) { + if (explicit) return { ...explicit, source: "explicit" }; + const fromEnv = env.POCKET_TARGET?.trim(); + if (fromEnv) return { id: fromEnv, source: "env", origin: "POCKET_TARGET" }; + const override = overrideFor(cwd, settings); + if (override) return { id: override.id, source: "override", origin: `directory override ${override.directory}` }; + const first = project?.targets?.[0]; + if (first) return { id: first.id, source: "project", origin: project.config }; + if (settings.default) return { id: settings.default, source: "default", origin: "user default" }; + return null; +} + +export const NO_SELECTION = + "no target selected; name one with `pocket + ` or `--target `, set POCKET_TARGET, " + + "set a directory override (`pocket override set `), list `targets` in pocket.config.ts, " + + "or set a user default (`pocket default `)"; diff --git a/tools/pocket.ts b/tools/pocket.ts index 8788ae22b..a80c12266 100644 --- a/tools/pocket.ts +++ b/tools/pocket.ts @@ -4,6 +4,8 @@ // bun pocket compile --target psp // bun pocket build --target psp -- --release // bun pocket catalog [--json] the targets --target accepts +// bun pocket project [--manifest pocket.json] +// the project configuration as JSON import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -12,6 +14,7 @@ import { depfile } from "../framework/compiler/build-inputs.ts"; import { runtimeVersionMismatch } from "../framework/compiler/runtime-packages.ts"; import type { PlatformContractRegistry } from "../contracts/spec/platforms.ts"; import { CATALOG, catalogEntry, catalogEntryForContract } from "./targets/index.ts"; +import { loadProject } from "./targets/project.ts"; import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; import { canonicalJson, type ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; import { @@ -62,9 +65,25 @@ if (command === "catalog") { else for (const row of rows) console.log(`${row.id.padEnd(20)} ${row.title}`); process.exit(0); } +if (command === "project") { + const manifestOption = takeOption("manifest"); + const manifestDirectory = dirname(resolve(manifestOption ?? "pocket.json")); + const result = await loadProject(manifestDirectory); + console.log(JSON.stringify(result, null, 2)); + process.exit(result.ok ? 0 : 1); +} if (command !== "check" && command !== "compile" && command !== "build") { usage(`unknown command ${command ?? ""}`); } +// The CLI states the protocol it speaks; a framework that speaks another one +// stops before reading anything else. +const cliProtocol = takeOption("cli-protocol"); +if (cliProtocol !== undefined) { + const frameworkProtocol = JSON.parse(readFileSync(resolve(frameworkRoot, "package.json"), "utf8")).pocketjs?.protocol; + if (String(frameworkProtocol) !== cliProtocol) { + usage(`the CLI speaks protocol ${cliProtocol} and the framework at ${frameworkRoot} speaks protocol ${frameworkProtocol}`); + } +} const targetOption = takeOption("target"); const hostProfileOption = takeOption("host-profile"); if (Boolean(targetOption) === Boolean(hostProfileOption)) { diff --git a/tools/targets/project.ts b/tools/targets/project.ts new file mode 100644 index 000000000..293b33b34 --- /dev/null +++ b/tools/targets/project.ts @@ -0,0 +1,134 @@ +// tools/targets/project.ts — the project configuration: the nearest +// pocket.config.ts above the manifest's directory, and its `targets`. +// +// The lookup starts at the manifest's directory, where tools/build.ts reads +// the same file's `theme`, and stops at the first pocket.config.ts. A file +// without `targets` names no project default. `manifest` and `hostProfile` +// entries resolve against the file's directory. +// +// The file is bundled with `@pocketjs/framework` mapped to this framework, so +// it evaluates when the framework comes from POCKETJS_FRAMEWORK_ROOT and the +// project has no node_modules. + +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BunPlugin } from "bun"; +import type { PocketConfig, PocketConfigTarget } from "../../framework/src/config.ts"; +import { validatePocketIdfHostProfile } from "../../framework/src/manifest/idf-host.ts"; +import { CATALOG, COMMAND_WORDS, catalogEntry } from "./index.ts"; + +const FRAMEWORK_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); + +export interface ProjectTarget { + /** A catalog id, or a host profile's id. */ + readonly id: string; + /** Absolute path of the manifest this target builds, when the entry names one. */ + readonly manifest?: string; + /** Absolute path of the ESP-IDF host profile, for a host-profile entry. */ + readonly hostProfile?: string; +} + +export interface ProjectConfiguration { + /** The configuration file, or null when no pocket.config.ts lies above. */ + readonly config: string | null; + readonly targets: readonly ProjectTarget[]; +} + +export type ProjectResult = + | { readonly ok: true; readonly project: ProjectConfiguration } + | { readonly ok: false; readonly diagnostics: readonly string[] }; + +export function findProjectConfig(from: string): string | null { + for (let directory = resolve(from); ; directory = dirname(directory)) { + const candidate = join(directory, "pocket.config.ts"); + if (existsSync(candidate)) return candidate; + if (dirname(directory) === directory) return null; + } +} + +/** The framework file a `@pocketjs/framework[/subpath]` specifier names, from + * the framework's package.json `exports`. */ +function frameworkFile(specifier: string): string | null { + const exports = JSON.parse(readFileSync(join(FRAMEWORK_ROOT, "package.json"), "utf8")).exports ?? {}; + const key = specifier === "@pocketjs/framework" ? "." : `./${specifier.slice("@pocketjs/framework/".length)}`; + const entry = exports[key]; + const target = typeof entry === "string" ? entry : entry?.default; + return typeof target === "string" ? join(FRAMEWORK_ROOT, target) : null; +} + +const frameworkSpecifiers: BunPlugin = { + name: "pocketjs-framework-specifiers", + setup(build) { + build.onResolve({ filter: /^@pocketjs\/framework(?:\/.*)?$/ }, (args) => { + const path = frameworkFile(args.path); + return path ? { path } : undefined; + }); + }, +}; + +async function evaluateConfig(path: string): Promise { + const bundle = await Bun.build({ entrypoints: [path], target: "bun", format: "esm", plugins: [frameworkSpecifiers] }); + if (!bundle.success) throw new AggregateError(bundle.logs, `${path} does not bundle`); + const code = await bundle.outputs[0]!.text(); + const module = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`); + return (module.default ?? module.config ?? {}) as PocketConfig; +} + +function entryTarget(entry: PocketConfigTarget): { target?: string; hostProfile?: string; manifest?: string } { + return typeof entry === "string" ? { target: entry } : entry; +} + +/** The project configuration for a manifest in `manifestDirectory`. */ +export async function loadProject(manifestDirectory: string): Promise { + const config = findProjectConfig(manifestDirectory); + if (!config) return { ok: true, project: { config: null, targets: [] } }; + let loaded: PocketConfig; + try { + loaded = await evaluateConfig(config); + } catch (error) { + return { ok: false, diagnostics: [`${config}: ${error instanceof Error ? error.message : String(error)}`] }; + } + const base = dirname(config); + const diagnostics: string[] = []; + const targets: ProjectTarget[] = []; + for (const [index, raw] of (loaded.targets ?? []).entries()) { + const entry = entryTarget(raw); + const where = `${config}: targets[${index}]`; + const manifest = entry.manifest ? resolve(base, entry.manifest) : undefined; + if (entry.target !== undefined) { + if (!catalogEntry(entry.target)) { + diagnostics.push(`${where}: unknown target ${entry.target}; targets: ${CATALOG.map((descriptor) => descriptor.id).join(", ")}`); + continue; + } + targets.push({ id: entry.target, ...(manifest ? { manifest } : {}) }); + continue; + } + if (entry.hostProfile === undefined) { + diagnostics.push(`${where}: an entry names a catalog id, { target }, or { hostProfile }`); + continue; + } + const hostProfile = resolve(base, entry.hostProfile); + if (!existsSync(hostProfile)) { + diagnostics.push(`${where}: host profile not found: ${hostProfile}`); + continue; + } + const validated = validatePocketIdfHostProfile(JSON.parse(readFileSync(hostProfile, "utf8"))); + if (!validated.ok) { + diagnostics.push(`${where}: ${hostProfile}: ${validated.diagnostics.map((item) => item.message).join("; ")}`); + continue; + } + const id = validated.value.id; + if (catalogEntry(id) || COMMAND_WORDS.includes(id)) { + diagnostics.push(`${where}: host profile id ${id} is a catalog id or a pocket command word; rename it`); + continue; + } + targets.push({ id, hostProfile, ...(manifest ? { manifest } : {}) }); + } + const seen = new Set(); + for (const target of targets) { + if (seen.has(target.id)) diagnostics.push(`${config}: target ${target.id} is listed twice`); + seen.add(target.id); + } + return diagnostics.length > 0 ? { ok: false, diagnostics } : { ok: true, project: { config, targets } }; +} diff --git a/tools/test.ts b/tools/test.ts index 38d1f9125..c31c85ddb 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -115,6 +115,7 @@ const SUITE: readonly Stage[] = [ "tests/runtime-packages.test.ts", "tests/build-writes.test.ts", "tests/targets-catalog.test.ts", + "tests/targets-project.test.ts", "tests/class-diagnostics.test.ts", "tests/vue-sfc.test.ts", "tests/font-bake.test.ts",