diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fcb3bc..51d6d33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,10 @@ permissions: contents: read jobs: - # Build on the minimum supported LTS runtime. The smoke matrix also covers - # the current release line so forward-compatibility regressions surface early. + # Build and test on the minimum supported LTS runtime — the floor declared in + # package.json#engines, not the newest thing that happens to work. The smoke + # matrix also covers the current release line so forward-compatibility + # regressions surface early. check: runs-on: ubuntu-latest steps: @@ -30,7 +32,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 24 + node-version: 22 # The enterprise Actions policy only permits GitHub-owned Marketplace # actions. Corepack reads the pinned pnpm version from package.json. @@ -74,7 +76,7 @@ jobs: strategy: fail-fast: false matrix: - node: [24, 26] + node: [22, 24, 26] steps: - uses: actions/download-artifact@v8 with: diff --git a/.node-version b/.node-version index a45fd52..2bd5a0a 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24 +22 diff --git a/README.md b/README.md index 00b0bc5..104e5f4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The flag surface is deliberately curated rather than a 1:1 mirror of the API: de ## Install -Requires Node 24+. No Python, no other runtime. +Requires Node 22.12+ (the active LTS line). No Python, no other runtime. ```bash npm i -g meshy-cli # installs `meshy-cli` and `meshy` @@ -23,7 +23,22 @@ meshy doctor # local diagnosis: versions, credential sources, base U The same build is also published under the scoped alias [`@meshy-ai/cli`](https://www.npmjs.com/package/@meshy-ai/cli) (`npm i -g @meshy-ai/cli`) — identical contents, pick whichever name you -remember; don't install both. +remember. + +**Install one, not both.** The two packages declare the same `meshy` and +`meshy-cli` binaries, and npm refuses to relink a binary owned by the other +package, so installing the second one fails with `EEXIST: file already exists`. +To switch, uninstall the one you have first: + +```bash +npm uninstall -g @meshy-ai/cli && npm i -g meshy-cli +``` + +Versions 0.2.0–0.3.1 declared `engines.node: >=24` by mistake. npm silently +resolves an install to the newest version whose `engines` your runtime +satisfies, so `npm i -g meshy-cli` on Node 22 quietly installed **0.1.3** with +no warning. If `meshy --version` reports 0.1.x, that is why — reinstall now +that the floor is correct. ### Development @@ -466,10 +481,11 @@ message before any task is created. GLB-only fields (`uv-unwrap`, `rigging` | `--base-url-v1 ` / `--base-url-v2 ` | Override endpoints (staging/proxy) | | `--base-url-creative-lab ` | Override the Creative Lab base (default: `/openapi/creative-lab`) | | `--output-schema legacy\|v1` | Stdout data model (existing commands default to `legacy`; new commands are `v1`) | -| `--format json\|pretty\|ndjson` | Stdout rendering (default `json`) | +| `--format json\|pretty\|ndjson` | Stdout rendering. Defaults to `pretty` when stdout is a terminal and `json` everywhere else — piped, redirected, or spawned as a subprocess, which is every agent, script and CI run. `--json` is shorthand for `--format json`. `-o ` keeps writing JSON unless `--format` is explicit | | `-o, --output ` | Download artifacts to a file/directory (task commands); output file for `mesh prepare-print` | | `--workspace ` | Confine every written file to this directory: `download`, `-o` on task verbs and `make` (report-only tasks included), `--save-json`, `--project`/project folders and the history index (skipped with `index_dirty` when its root would fall outside), `mesh prepare-print` outputs and their copied materials — checked on real paths before anything, even a directory, is created. The boundary is frozen when the command starts (real path and directory identity): a workspace or project replaced by a symlink while a request is in flight is refused, never followed | | `--no-update-check` | Skip the background npm version check in this process | +| `NO_COLOR` / `FORCE_COLOR` (env) | Colour is on only when the stream is a terminal. `NO_COLOR` turns it off, `FORCE_COLOR` forces it on (`FORCE_COLOR=0` off), `TERM=dumb` disables it. `json` and `ndjson` are never coloured, and neither is anything written to a file | | `-v, --verbose` | Debug logging to stderr | | `--log-level ` | `debug \| info \| warn \| error \| silent` | diff --git a/docs/skill-parity/decisions.md b/docs/skill-parity/decisions.md index 9b7c993..4f9d554 100644 --- a/docs/skill-parity/decisions.md +++ b/docs/skill-parity/decisions.md @@ -734,3 +734,101 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu by content type. `tests/live-verification.test.ts` (L02) covers both products; the same tasks re-downloaded live produce `lamp.stl`/`base.stl` (byte-identical to the mis-named files) and `model.obj.zip`. + +## D-061 The supported Node floor is 22.12, and it is one number, not three + +- `engines.node` was raised from `>=20` to `>=24` in `7edf056` ("chore: upgrade + Node and dependencies") as a side effect of a dependency bump, not because any + code needed Node 24. Audited on 2026-09-22: the only runtime gate in the tree + was `REQUIRED_NODE_MAJOR = 24` in `doctor.ts`; `@types/node@^22` typechecks + clean and the full suite is 563/563 on Node 22.22.0. The real floor is the + strictest dependency, `commander@15` at `>=22.12.0`. +- The overstated floor was not a warning, it was a silent downgrade. npm resolves + an unpinned install to the newest version whose `engines` the current runtime + satisfies, so `npm i -g meshy-cli` on Node 22 installed **0.1.3** — the last + version declaring `>=20` — with no warning at all. Reproduced against the live + registry. Users then read `meshy --version` as 0.1.3 and reported the CLI as + stale; agents read `engines` and reported it as incompatible. +- `engines.node`, `.node-version`, the CI `check` job and `doctor`'s floor are now + all 22 / 22.12.0, and `tests/version.test.ts` pins `engines.node` to the value + `doctor` enforces so the two cannot drift again. The smoke matrix is + `[22, 24, 26]`: the floor, the current release line, and the next one. +- Reviewer check: `engines.node` must equal the strictest `engines.node` among + `dependencies` — raise it only when a dependency or a used API forces it, and + publish a release at the same time, because every version left behind the floor + is what npm will hand to users below it. + +## D-062 Everything user-facing that names the npm package reads it from package.json + +- The same tree is published twice, as `meshy-cli` and — after `npm pkg set name` + in `release.yml` — as `@meshy-ai/cli`. Both declare the same `meshy` and + `meshy-cli` bins, and npm refuses to relink a bin owned by another package. +- The update notifier hardcoded `npm i -g meshy-cli@latest`, so an `@meshy-ai/cli` + user who followed its advice got `EEXIST: file already exists` on + `/bin/meshy-cli` and no upgrade. Observed 2026-09-22. +- `version.ts` now exports `PACKAGE_NAME` alongside `VERSION` from the same + package.json read, and the notifier derives `REGISTRY_URL`, `UPDATE_COMMAND` + and its message from it — the alias checks and upgrades itself. Both scoped URL + forms (`@meshy-ai/cli/latest` and `@meshy-ai%2Fcli/latest`) return 200. +- Not fixed in code, because it cannot be: installing both packages still + collides. README says install one, and how to switch. Retiring the alias is a + publishing decision, not a code change. + +## D-063 `--format` follows the destination: pretty on a TTY, json everywhere else + +- Reported 2026-09-22: `meshy balance` typed at a terminal answers with + `{ "balance": 2357 }` spread over three lines. Surveyed the CLIs on the same machine — `gh release + list`, `npm view`, `kubectl config get-contexts`, `docker` all render a human + shape by default and keep the machine shape behind `--json` / `-o json` / + `--format`. `aws` is the counterexample, and it is configurable. A CLI whose + default face is raw JSON braces is the outlier, not the norm. +- The fix is the default, not the contract. `--format` untyped now resolves to + `pretty` when `process.stdout.isTTY` and `json` otherwise. Every agent, script, + pipe, redirect, `$(...)` and CI run reaches the CLI through something that is + not a TTY, so the bytes they read are unchanged; `--format json` / `--json` + still force it, and SKILL.md already told agents to pass it. +- Two traps this had to clear before it was safe: + - commander carried `.default("json")` on the option, which made "not typed" + indistinguishable from `--format json`. The default is gone from the option + and lives in `parseOutputFormat`; `runtime.ts`'s duplicate `normalizeFormat` + was deleted rather than taught the same rule twice. + - legacy `-o ` renders through `emit()` with the same format, so a + TTY-derived `pretty` would have silently landed in a file every caller reads + back as JSON. `GlobalFlags.formatExplicit` records whether `--format` was + actually typed; an untyped format writes JSON to a file whatever the + terminal would have shown. `--save-json` was never affected — it has its own + writer. Both branches are covered in `tests/output.test.ts`. +- Side effect, and the point: the update notifier's two channels finally + separate. `attachUpdateNotice` already skipped `pretty`, so a human now gets + one stderr line instead of a `_notice` blob inside their output *and* the line; + a pipe still carries `_notice` in the JSON. +- Ceiling: `renderPretty` is a recursive `key: value` dump. Right for `balance` + and `doctor`, thin for a 30-field task. Reach for a real table renderer when + someone complains about a specific command, not before. +- Reviewer check: anything that writes to a file or is consumed by a machine + must not read `flags.format` without also honouring `flags.formatExplicit`. + +## D-064 Colour is a property of the stream, and the machine formats never have it + +- Follows D-063: once `pretty` is what a person actually sees, the output should + look like the CLIs it sits next to. Added in `src/internal/color.ts`, ~50 lines + and no dependency — four SGR codes do not justify one. +- The decision table, in order: `FORCE_COLOR` (on, unless `0`), then `NO_COLOR` + (off), then `TERM=dumb` (off), then whether the stream is a TTY. Both env vars + are the cross-ecosystem conventions and users expect them to work here too. +- Painted against the stream the text is going to, never a global flag: + - stdout, `pretty` only — `meshy doctor` in a terminal; + - stderr for the `error:` / `hint:` lines and the update hint, so + `meshy ... | jq` still shows a red error while `2> log` stays clean; + - `json` / `ndjson` take the painter and ignore it, pinned by a test — this is + the one that would silently corrupt every agent reading stdout; + - anything written to a file renders unpainted, because `--format pretty -o + notes.txt` must not put control codes on disk. `render()` therefore defaults + to the plain painter and only the two stdout call sites opt in. +- Palette, deliberately small: keys dim, `null` dim, and whole-value state words + (`ok`/`SUCCEEDED`/`true` green, `FAILED`/`error`/`false` red, + `PENDING`/`skipped`/`IN_PROGRESS` yellow). Matched on the entire value, case + insensitively, so a prompt reading "a failed robot" is never repainted. +- Reviewer check: a new writer must pass the painter for *its own* destination. + `painterFor(process.stdout)` in something that writes to stderr or a file is + the bug this table exists to prevent. diff --git a/package.json b/package.json index 3cffd7a..608a558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meshy-cli", - "version": "0.3.1", + "version": "0.3.2", "description": "Official command-line interface for the Meshy AI API — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animate, retexture, 2D images, multi-color print, Creative Lab, balance — plus selective downloads, project folders, face checks, OBJ print preparation and slicer launch.", "license": "MIT", "type": "module", @@ -40,7 +40,7 @@ "skills" ], "engines": { - "node": ">=24" + "node": ">=22.12.0" }, "scripts": { "build": "tsc", @@ -59,7 +59,7 @@ "zod": "^4.5.2" }, "devDependencies": { - "@types/node": "^24.13.3", + "@types/node": "^22.20.4", "tsx": "^4.23.12", "typescript": "^7.0.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af3e215..db51ce3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,14 +13,14 @@ importers: version: 15.0.0 sharp: specifier: ^0.35.4 - version: 0.35.4(@types/node@24.13.3) + version: 0.35.4(@types/node@22.20.4) zod: specifier: ^4.5.2 version: 4.5.2 devDependencies: '@types/node': - specifier: ^24.13.3 - version: 24.13.3 + specifier: ^22.20.4 + version: 22.20.4 tsx: specifier: ^4.23.12 version: 4.23.12 @@ -351,8 +351,8 @@ packages: cpu: [x64] os: [win32] - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} @@ -519,8 +519,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} zod@4.5.2: resolution: {integrity: sha512-XkYXCol10+ba/6F/cueWV+TezUeOqXW0hdeJt5CdXjTYeAgAQg5N03RQdJ80mhfFE72+pblvYMW4wy2Qp4Qbrg==} @@ -716,9 +716,9 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true - '@types/node@24.13.3': + '@types/node@22.20.4': dependencies: - undici-types: 7.18.2 + undici-types: 6.21.0 '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -818,7 +818,7 @@ snapshots: semver@7.8.5: {} - sharp@0.35.4(@types/node@24.13.3): + sharp@0.35.4(@types/node@22.20.4): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -849,7 +849,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 - '@types/node': 24.13.3 + '@types/node': 22.20.4 tslib@2.8.1: optional: true @@ -883,6 +883,6 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.18.2: {} + undici-types@6.21.0: {} zod@4.5.2: {} diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index b0a1e92..c0d3462 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -2,7 +2,7 @@ name: meshy-cli description: "Generate 3D models, motion clips, 2D images and Creative Lab print products with the Meshy API through the meshy-cli command — text-to-3D, image-to-3D, text-to-motion, remesh, UV unwrap, rigging, animation, retexture, printability, selective downloads, project folders, face checks, OBJ print preparation and slicer launch. Use for any Meshy asset or 3D-printing request." license: MIT -compatibility: Requires meshy-cli on PATH (Node 24+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline. +compatibility: Requires meshy-cli on PATH (Node 22.12+, no Python) and a stored credential, MESHY_API_KEY or --api-key-file for API commands; network access to api.meshy.ai. Local helpers work offline. metadata: version: "1.1.0" cli-help: "meshy --help" @@ -22,7 +22,12 @@ network; `meshy doctor --check-api` makes one free balance call. ## Always ask for the stable envelope -Add `--output-schema v1 --format json` to every command you parse. stdout is then +Add `--output-schema v1 --format json` to every command you parse. `--format` is +not optional politeness: untyped, it renders `pretty` for a human at a terminal +and `json` everywhere else. You will almost always be on the `json` side — a +subprocess pipe is not a TTY — but say it and the shape is yours regardless of +how you are spawned. `json` and `ndjson` never carry terminal colour codes, so +you never need to strip escapes. stdout is then exactly one JSON object with six keys — `schema_version, command, ok, result, error, warnings` — and nothing else; progress goes to stderr. `ok` is whether the CLI operation completed; `result.task.status` is the server's task state (a `get` diff --git a/src/internal/color.ts b/src/internal/color.ts new file mode 100644 index 0000000..05f5146 --- /dev/null +++ b/src/internal/color.ts @@ -0,0 +1,64 @@ +/** + * ANSI colour for human output. + * + * Colour is a property of the destination, not of the command: it is on only + * when the stream is a terminal, and the environment can always veto. The two + * conventions every CLI is expected to honour are NO_COLOR (https://no-color.org) + * and FORCE_COLOR; `TERM=dumb` is the third, for terminals that cannot render + * escapes at all. + * + * `json` and `ndjson` output never passes through here — a machine reading + * stdout must never receive an escape sequence. Only `pretty` and the human + * prose on stderr are painted. + * + * No dependency: four SGR codes do not justify one. + */ + +export type Style = "dim" | "bold" | "red" | "green" | "yellow" | "cyan"; + +const CODES: Record = { + dim: "2", + bold: "1", + red: "31", + green: "32", + yellow: "33", + cyan: "36", +}; + +export interface ColorEnv { + NO_COLOR?: string; + FORCE_COLOR?: string; + TERM?: string; + [key: string]: string | undefined; +} + +/** + * FORCE_COLOR wins (except when set to "0"), then NO_COLOR, then a dumb + * terminal, then whether the stream is actually a TTY. + */ +export function colorEnabled( + stream: { isTTY?: boolean } = process.stdout, + env: ColorEnv = process.env, +): boolean { + const force = env["FORCE_COLOR"]; + if (force !== undefined && force !== "" && force !== "0") return true; + if (force === "0") return false; + if (env["NO_COLOR"] !== undefined && env["NO_COLOR"] !== "") return false; + if (env["TERM"] === "dumb") return false; + return Boolean(stream.isTTY); +} + +/** A painter that applies a style, or the identity when colour is off. */ +export type Painter = (text: string, style: Style) => string; + +export const plain: Painter = (text) => text; + +export const painted: Painter = (text, style) => + text === "" ? text : `\u001b[${CODES[style]}m${text}\u001b[0m`; + +export function painterFor( + stream: { isTTY?: boolean } = process.stdout, + env: ColorEnv = process.env, +): Painter { + return colorEnabled(stream, env) ? painted : plain; +} diff --git a/src/internal/command-helpers.ts b/src/internal/command-helpers.ts index 7917612..05543b9 100644 --- a/src/internal/command-helpers.ts +++ b/src/internal/command-helpers.ts @@ -45,7 +45,12 @@ export async function emitResult( await emitEnvelope(okEnvelope(opened.command, v1Result, opts.warnings ?? []), format); return; } - emit(legacyValue, { format, file: opts.legacyFile }); + // Legacy `-o ` writes the payload to disk instead of stdout. The TTY + // default describes a terminal, not a file, so an untyped --format must not + // leak `pretty` into what callers have always read back as JSON. + const fileFormat = + opts.legacyFile && !opened.flags.formatExplicit && opts.format === undefined ? "json" : format; + emit(legacyValue, { format: fileFormat, file: opts.legacyFile }); } /** v1: `-o` is reserved for assets; JSON goes through --save-json. */ diff --git a/src/internal/doctor.ts b/src/internal/doctor.ts index efd9e43..12890e3 100644 --- a/src/internal/doctor.ts +++ b/src/internal/doctor.ts @@ -91,7 +91,15 @@ export interface DoctorOutcome { apiFailure: DoctorApiFailure | null; } -const REQUIRED_NODE_MAJOR = 24; +/** + * Mirrors package.json#engines.node. The binding constraint is commander@15 + * (>=22.12.0), not anything we write — keep the two in lockstep, because a + * floor that overstates the real one makes npm silently resolve `npm i -g + * meshy-cli` to an ancient version instead of refusing to install. + */ +const REQUIRED_NODE_MAJOR = 22; +const REQUIRED_NODE_MINOR = 12; +export const REQUIRED_NODE = `${REQUIRED_NODE_MAJOR}.${REQUIRED_NODE_MINOR}.0`; const CWD_ENV_CANDIDATES = [".env", ".env.local"] as const; /** Mirrors config.ts: an empty or placeholder key means "unset". */ const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); @@ -151,15 +159,20 @@ export async function runDoctorDetailed(opts: DoctorOptions): Promise= REQUIRED_NODE_MAJOR; + const [rawMajor, rawMinor] = process.versions.node.split("."); + const nodeMajor = Number.parseInt(rawMajor ?? "", 10); + const nodeMinor = Number.parseInt(rawMinor ?? "", 10); + const nodeOk = + Number.isFinite(nodeMajor) && + (nodeMajor > REQUIRED_NODE_MAJOR || + (nodeMajor === REQUIRED_NODE_MAJOR && Number.isFinite(nodeMinor) && nodeMinor >= REQUIRED_NODE_MINOR)); checks.push({ id: "cli", status: "ok", detail: `meshy-cli ${VERSION} on node ${process.version} (${process.platform} ${process.arch})` }); checks.push({ id: "node", status: nodeOk ? "ok" : "fail", detail: nodeOk - ? `node ${process.version} satisfies the required >=${REQUIRED_NODE_MAJOR}` - : `node ${process.version} is below the required >=${REQUIRED_NODE_MAJOR}; install Node ${REQUIRED_NODE_MAJOR} or newer`, + ? `node ${process.version} satisfies the required >=${REQUIRED_NODE}` + : `node ${process.version} is below the required >=${REQUIRED_NODE}; install Node ${REQUIRED_NODE} or newer`, }); // --- Base URLs (same precedence as config.ts, resolved without a credential) --- diff --git a/src/internal/errors.ts b/src/internal/errors.ts index e65bb04..b7e0be3 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -13,6 +13,7 @@ import { CommanderError } from "commander"; import { MeshyApiError } from "../client/errors.js"; import { emit, type OutputFormat } from "./output.js"; +import { painterFor } from "./color.js"; export const EXIT_CODES = { OK: 0, @@ -348,9 +349,12 @@ function base( /** Legacy stderr + payload reporter (unchanged shape for 0.2.0 consumers). */ export function reportError(err: unknown, format: OutputFormat): void { const payload = toErrorPayload(err); - process.stderr.write(`error: ${payload.message}\n`); + // Painted against stderr, not stdout: `meshy ... | jq` still shows a red + // error line in the terminal, and `2> log` gets clean text. + const paint = painterFor(process.stderr); + process.stderr.write(`${paint("error:", "red")} ${payload.message}\n`); if (typeof payload["hint"] === "string") { - process.stderr.write(`hint: ${payload["hint"] as string}\n`); + process.stderr.write(`${paint("hint:", "yellow")} ${payload["hint"] as string}\n`); } if (format !== "pretty") { try { diff --git a/src/internal/global-options.ts b/src/internal/global-options.ts index fc6481d..e9f461c 100644 --- a/src/internal/global-options.ts +++ b/src/internal/global-options.ts @@ -23,7 +23,12 @@ const FACTORIES: OptionFactory[] = [ "--base-url-creative-lab ", "override the Creative Lab base URL (default: /openapi/creative-lab)", ), - () => new Option("--format ", "output format").choices(["json", "pretty", "ndjson"]), + () => + new Option("--format ", "output format (default: pretty on a terminal, json when piped)").choices([ + "json", + "pretty", + "ndjson", + ]), () => new Option("--json", "output as JSON (alias for --format json)"), () => new Option( @@ -67,7 +72,8 @@ const FACTORIES: OptionFactory[] = [ export function registerRootGlobalOptions(root: Command): void { for (const factory of FACTORIES) { const opt = factory(); - if (opt.long === "--format") opt.default("json"); + // No default here on purpose: readGlobalFlags must be able to tell "not + // typed" from "--format json", because only the former follows the TTY. if (opt.long === "--verbose") opt.default(false); root.addOption(opt); } diff --git a/src/internal/oauth-page.ts b/src/internal/oauth-page.ts new file mode 100644 index 0000000..5f994f4 --- /dev/null +++ b/src/internal/oauth-page.ts @@ -0,0 +1,71 @@ +type CallbackPageStatus = "authorized" | "canceled" | "error"; + +// Official assets/brand/meshy-wordmark-64.svg from meshy-webapp. Inlined so +// loopback pages work offline and never send callback URLs to an asset host. +const WORDMARK = ""; + +const COPY = { + en: { + authorized: "Meshy CLI authorized", + canceled: "Connection canceled", + error: "Unable to connect", + next: "Return to your terminal to continue. You can close this tab.", + canceledNext: "No access was granted. You can close this tab.", + retry: "Return to your terminal and try signing in again.", + details: "Error details", + back: "Back to Meshy", + }, + zh: { + authorized: "已授权 Meshy CLI", + canceled: "连接已取消", + error: "无法完成连接", + next: "请回到终端继续操作。此页面可以关闭。", + canceledNext: "未授予访问权限。此页面可以关闭。", + retry: "请回到终端,重新发起登录。", + details: "错误详情", + back: "返回 Meshy", + }, +}; + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +export function renderCallbackPage(status: CallbackPageStatus, message = "", acceptLanguage = ""): string { + const locale = /^zh\b/i.test(acceptLanguage.trim()) ? "zh" : "en"; + const copy = COPY[locale]; + const title = copy[status]; + const description = status === "authorized" ? copy.next : status === "canceled" ? copy.canceledNext : copy.retry; + const glyph = status === "authorized" ? '' : ''; + return ` + + + + + +Meshy — ${title} + + +
+ +
+ +

${title}

${description}

+${status === "error" ? 'meshy auth login' : ""} +${status === "error" && message ? `
${copy.details}

${escapeHtml(message)}

` : ""} +${copy.back} +
`; +} diff --git a/src/internal/oauth.ts b/src/internal/oauth.ts index 323e22d..66a7304 100644 --- a/src/internal/oauth.ts +++ b/src/internal/oauth.ts @@ -19,6 +19,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { spawn } from "node:child_process"; import { HintedError } from "./errors.js"; import { USER_AGENT } from "./user-agent.js"; +import { renderCallbackPage } from "./oauth-page.js"; // --------------------------------------------------------------------------- // PKCE helpers @@ -65,20 +66,6 @@ export function buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string { return url.toString(); } -// --------------------------------------------------------------------------- -// HTML helpers -// --------------------------------------------------------------------------- - -/** Escape characters that are special in HTML to prevent reflected XSS. */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - // --------------------------------------------------------------------------- // Loopback callback server // --------------------------------------------------------------------------- @@ -96,27 +83,6 @@ export interface CallbackServer { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -const SUCCESS_HTML = ` - -Meshy — Login successful - -

Login successful

-

You can close this tab and return to the terminal.

- -`; - -function errorHtml(msg: string): string { - return ` - -Meshy — Login failed - -

Login failed

-

${escapeHtml(msg)}

-

Return to the terminal for details.

- -`; -} - /** * Starts a loopback HTTP server bound to 127.0.0.1 only. * @@ -165,6 +131,11 @@ export function startCallbackServer( return; } + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); + const language = req.headers["accept-language"]; + const code = url.searchParams.get("code") ?? undefined; const state = url.searchParams.get("state") ?? undefined; const error = url.searchParams.get("error") ?? undefined; @@ -173,7 +144,7 @@ export function startCallbackServer( if (error) { const msg = errorDescription ?? error; res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml(msg)); + res.end(renderCallbackPage(error === "access_denied" ? "canceled" : "error", msg, language)); settle(new HintedError({ message: `Authorization denied: ${msg}`, code: "oauth_denied", @@ -185,7 +156,7 @@ export function startCallbackServer( // State verification: reject mismatches before showing any success page. if (state !== expectedState) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml("Login failed: state mismatch — you can close this tab and retry.")); + res.end(renderCallbackPage("error", "OAuth state mismatch. Start a new login from your terminal.", language)); settle(new HintedError({ message: "OAuth state mismatch — possible CSRF attack. Run: meshy auth login", code: "oauth_state_mismatch", @@ -200,7 +171,7 @@ export function startCallbackServer( // the user while the terminal fails with oauth_no_code. if (!code) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml("Login failed: no authorization code received — you can close this tab and retry.")); + res.end(renderCallbackPage("error", "No authorization code received. Start a new login from your terminal.", language)); settle(new HintedError({ message: "No authorization code received from the callback.", code: "oauth_no_code", @@ -210,7 +181,8 @@ export function startCallbackServer( } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(SUCCESS_HTML); + // Receiving a code precedes token exchange and credential persistence. + res.end(renderCallbackPage("authorized", "", language)); settle({ code, state }); }); diff --git a/src/internal/output.ts b/src/internal/output.ts index 37c758a..5ec4d28 100644 --- a/src/internal/output.ts +++ b/src/internal/output.ts @@ -1,6 +1,13 @@ /** - * Output rendering. Keep stdout machine-parseable by default; `pretty` is - * opt-in for human eyes. + * Output rendering. + * + * `--format` defaults to the shape the destination can actually use: `pretty` + * when stdout is a TTY (a human typed the command), `json` otherwise — piped, + * redirected, or spawned as a subprocess, which covers every agent, script and + * CI run. This is the `gh` / `npm` / `kubectl` convention; printing raw JSON + * braces at a person is the `aws` one. Nothing about the machine contract + * moves: a pipe still gets exactly the same bytes as before, and `--format + * json` / `--json` still force it. * * Legacy path (`emit`): bare payloads, optionally decorated with * `_notice.update` when a newer meshy-cli version is available. See @@ -13,6 +20,7 @@ import { writeFileSync } from "node:fs"; import { attachUpdateNotice, getUpdateNotice, printHumanUpdateHint } from "./update-notifier.js"; +import { painterFor, plain, type Painter } from "./color.js"; import type { StreamEventEnvelope, V1Envelope } from "./result.js"; export type OutputFormat = "json" | "pretty" | "ndjson"; @@ -25,7 +33,7 @@ export interface OutputOptions { export function emit(value: unknown, opts: OutputOptions): void { const notice = getUpdateNotice(); const decorated = attachUpdateNotice(value, opts.format, notice); - const text = render(decorated, opts.format); + const text = render(decorated, opts.format, opts.file ? plain : painterFor(process.stdout)); if (opts.file) { writeFileSync(opts.file, text.endsWith("\n") ? text : `${text}\n`, "utf8"); } else { @@ -53,7 +61,7 @@ export function writeStdout(text: string): Promise { /** Print one v1 envelope in the requested rendering. */ export async function emitEnvelope(envelope: V1Envelope, format: OutputFormat): Promise { - const text = format === "pretty" ? renderPretty(envelope) : format === "ndjson" ? JSON.stringify(envelope) : JSON.stringify(envelope, null, 2); + const text = render(envelope, format, painterFor(process.stdout)); await writeStdout(`${text}\n`); printHumanUpdateHint(getUpdateNotice(), process); } @@ -63,7 +71,12 @@ export async function emitStreamEvent(event: StreamEventEnvelope): Promise await writeStdout(`${JSON.stringify(event)}\n`); } -export function render(value: unknown, format: OutputFormat): string { +/** + * `paint` defaults to plain: a caller that does not say where the text is going + * gets no escapes. Only the stdout paths opt in — a file must never receive + * them, or `--format pretty -o notes.txt` writes control codes to disk. + */ +export function render(value: unknown, format: OutputFormat, paint: Painter = plain): string { switch (format) { case "json": return JSON.stringify(value, null, 2); @@ -71,32 +84,83 @@ export function render(value: unknown, format: OutputFormat): string { if (Array.isArray(value)) return value.map((v) => JSON.stringify(v)).join("\n"); return JSON.stringify(value); case "pretty": - return renderPretty(value); + return renderPretty(value, 0, paint); } } -export function renderPretty(value: unknown, indent = 0): string { +/** + * Values whose meaning a reader scans for rather than reads: task and check + * states. Matched case-insensitively on the whole value, so a prompt or a + * model name containing the word is never repainted. + */ +const VALUE_STYLES: Record = { + ok: "green", + pass: "green", + passed: "green", + succeeded: "green", + success: "green", + ready: "green", + true: "green", + fail: "red", + failed: "red", + error: "red", + false: "red", + skipped: "yellow", + pending: "yellow", + in_progress: "yellow", + canceled: "yellow", + cancelled: "yellow", + warn: "yellow", + warning: "yellow", +}; + +function paintScalar(v: unknown, paint: Painter): string { + if (v === null || v === undefined) return paint("-", "dim"); + const text = String(v); + const style = VALUE_STYLES[text.toLowerCase()]; + return style ? paint(text, style) : text; +} + +export function renderPretty(value: unknown, indent = 0, paint: Painter = plain): string { const pad = " ".repeat(indent); - if (value === null || value === undefined) return `${pad}-`; - if (typeof value !== "object") return `${pad}${String(value)}`; + if (value === null || value === undefined) return `${pad}${paint("-", "dim")}`; + if (typeof value !== "object") return `${pad}${paintScalar(value, paint)}`; if (Array.isArray(value)) { if (value.length === 0) return `${pad}[]`; - return value.map((v) => `${pad}- ${renderPretty(v, indent + 1).trimStart()}`).join("\n"); + return value.map((v) => `${pad}${paint("-", "dim")} ${renderPretty(v, indent + 1, paint).trimStart()}`).join("\n"); } const entries = Object.entries(value as Record); if (entries.length === 0) return `${pad}{}`; return entries .map(([k, v]) => { + const key = paint(`${k}:`, "dim"); if (v !== null && typeof v === "object") { - return `${pad}${k}:\n${renderPretty(v, indent + 1)}`; + // An empty array/object reads as `warnings: []`, not a dangling key + // with `[]` on the next line. Only matters now that pretty is what a + // person sees by default. + const nested = renderPretty(v, indent + 1, paint); + if (nested.trim() === "[]" || nested.trim() === "{}") return `${pad}${key} ${nested.trim()}`; + return `${pad}${key}\n${nested}`; } - return `${pad}${k}: ${v === null || v === undefined ? "-" : String(v)}`; + return `${pad}${key} ${paintScalar(v, paint)}`; }) .join("\n"); } + + +/** + * The format to use when `--format` was not given. A TTY means a person is + * reading; anything else is a pipe, a file or a subprocess, and must keep + * getting JSON. + */ +export function defaultOutputFormat(isTTY: boolean = Boolean(process.stdout.isTTY)): OutputFormat { + return isTTY ? "pretty" : "json"; +} + export function parseOutputFormat(raw: string | undefined): OutputFormat { - const v = (raw ?? "json").toLowerCase(); + if (raw === undefined) return defaultOutputFormat(); + const v = raw.toLowerCase(); if (v === "json" || v === "pretty" || v === "ndjson") return v; throw new Error(`invalid --format '${raw}'. Expected: json | pretty | ndjson`); } diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index e7822b2..30cf9a6 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -20,7 +20,7 @@ import { authRequiredError, UsageError } from "./errors.js"; import { logger, setLogLevel } from "./logger.js"; import { refreshTokens } from "./oauth.js"; import type { LogLevel } from "./logger.js"; -import type { OutputFormat } from "./output.js"; +import { parseOutputFormat, type OutputFormat } from "./output.js"; import type { OutputSchema } from "./result.js"; export interface GlobalFlags { @@ -29,6 +29,8 @@ export interface GlobalFlags { baseUrlV2?: string; baseUrlCreativeLab?: string; format: OutputFormat; + /** true when --format/--json was actually typed; false when format came from the TTY default. */ + formatExplicit: boolean; json?: boolean; outputSchema?: OutputSchema; output?: string; @@ -225,13 +227,18 @@ export function readGlobalFlags(cmd: Command): GlobalFlags { ); } // --json is an alias for --format json; --json wins if both are set. - const format = opts.json ? "json" : normalizeFormat(opts.format); + // Untyped --format resolves against the TTY (see output.ts); remember that it + // was untyped, because a file write must stay JSON no matter what the + // terminal would have shown. + const formatExplicit = opts.json === true || opts.format !== undefined; + const format = opts.json ? "json" : parseOutputFormat(opts.format); return { apiKey: opts.apiKey, baseUrlV1: opts.baseUrlV1, baseUrlV2: opts.baseUrlV2, baseUrlCreativeLab: opts.baseUrlCreativeLab, format, + formatExplicit, json: opts.json, outputSchema: normalizeSchema(opts.outputSchema), output: opts.output, @@ -268,12 +275,6 @@ function normalizeSchema(raw: string | undefined): OutputSchema | undefined { throw new UsageError(`invalid --output-schema '${raw}'. Expected: legacy | v1`); } -function normalizeFormat(raw: string | undefined): OutputFormat { - const v = (raw ?? "json").toLowerCase(); - if (v === "json" || v === "pretty" || v === "ndjson") return v; - throw new Error(`invalid --format '${raw}'. Expected: json | pretty | ndjson`); -} - function normalizeLogLevel(raw: string | undefined): LogLevel | undefined { if (!raw) return undefined; const v = raw.toLowerCase(); diff --git a/src/internal/update-notifier.ts b/src/internal/update-notifier.ts index c3e1183..caa0c0f 100644 --- a/src/internal/update-notifier.ts +++ b/src/internal/update-notifier.ts @@ -18,18 +18,25 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { spawn } from "node:child_process"; import { configDir } from "./credentials.js"; -import { VERSION } from "./version.js"; +import { PACKAGE_NAME, VERSION } from "./version.js"; +import { painterFor } from "./color.js"; import type { OutputFormat } from "./output.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -export const REGISTRY_URL = "https://registry.npmjs.org/meshy-cli/latest"; +/** + * Both the registry lookup and the upgrade hint name the package this build + * was actually installed from. `meshy-cli` and its alias `@meshy-ai/cli` ship + * the same bins, and npm will not relink a bin owned by the other package — + * so a hardcoded name sends half the users into `EEXIST: file already exists`. + */ +export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`; export const FETCH_TIMEOUT_MS = 15_000; export const MAX_RESPONSE_BYTES = 256 * 1024; export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; -export const UPDATE_COMMAND = "npm i -g meshy-cli@latest"; +export const UPDATE_COMMAND = `npm i -g ${PACKAGE_NAME}@latest`; /** * Hidden self-command used for the detached background refresh child. @@ -196,7 +203,7 @@ export function buildNotice(latest: string, current: string = VERSION): UpdateNo return { current, latest, - message: `meshy-cli ${latest} available (current ${current}), run: ${UPDATE_COMMAND}`, + message: `${PACKAGE_NAME} ${latest} available (current ${current}), run: ${UPDATE_COMMAND}`, command: UPDATE_COMMAND, }; } @@ -338,7 +345,7 @@ export function printHumanUpdateHint( ): boolean { if (!notice) return false; if (!io.stdout.isTTY || !io.stderr.isTTY || !io.stdin.isTTY) return false; - io.stderr.write(`${notice.message}\n`); + io.stderr.write(`${painterFor(io.stderr)(notice.message, "dim")}\n`); return true; } diff --git a/src/internal/version.ts b/src/internal/version.ts index a1be529..6e5777d 100644 --- a/src/internal/version.ts +++ b/src/internal/version.ts @@ -1,14 +1,26 @@ import { readFileSync } from "node:fs"; /** - * package.json is the single source of truth for the version — read it at - * startup instead of hardcoding. This file sits exactly two directories - * below package.json in both layouts that matter (src/internal/ under tsx, - * dist/internal/ after `tsc`), and tests/version.test.ts pins VERSION === - * package.json#version so the two can never drift again. + * package.json is the single source of truth for the version and the published + * package name — read it at startup instead of hardcoding. This file sits + * exactly two directories below package.json in both layouts that matter + * (src/internal/ under tsx, dist/internal/ after `tsc`), and + * tests/version.test.ts pins VERSION === package.json#version so the two can + * never drift again. */ -const { version } = JSON.parse( +const { version, name } = JSON.parse( readFileSync(new URL("../../package.json", import.meta.url), "utf8"), -) as { version: string }; +) as { version: string; name: string }; export const VERSION = version; + +/** + * The npm package this build was installed from. The release workflow + * publishes the same tree twice — as `meshy-cli` and, after `npm pkg set + * name`, as the scoped alias `@meshy-ai/cli` — and both declare the same + * `meshy` / `meshy-cli` bins. npm refuses to relink a bin owned by the other + * package, so telling an `@meshy-ai/cli` user to run `npm i -g meshy-cli` is + * an EEXIST waiting to happen. Everything user-facing that names the package + * reads it from here. + */ +export const PACKAGE_NAME = name; diff --git a/src/root.ts b/src/root.ts index 7895c74..70a0c84 100644 --- a/src/root.ts +++ b/src/root.ts @@ -130,7 +130,7 @@ GLOBAL FLAGS (accepted at any position in the command line): --base-url-v1 override MESHY_BASE_URL_V1 --base-url-v2 override MESHY_BASE_URL_V2 --base-url-creative-lab override the Creative Lab base (default: /openapi/creative-lab) - --format json (default) | pretty | ndjson + --format pretty on a terminal, json when piped | json | pretty | ndjson --output-schema legacy (default for existing commands) | v1 (stable envelope; new commands) --output, -o download task artifacts to a file or directory, write meta.json alongside, and replace stdout diff --git a/tests/color.test.ts b/tests/color.test.ts new file mode 100644 index 0000000..9188f09 --- /dev/null +++ b/tests/color.test.ts @@ -0,0 +1,65 @@ +/** + * Colour is a property of the destination. These tests pin the decision table, + * because the failure mode is invisible in a terminal and only shows up when + * someone greps a log or parses a file full of escape codes. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { colorEnabled, painted, painterFor, plain } from "../src/internal/color.js"; +import { render, renderPretty } from "../src/internal/output.js"; + +const TTY = { isTTY: true }; +const PIPE = { isTTY: false }; + +test("colorEnabled — a TTY with a plain environment is the only default yes", () => { + assert.equal(colorEnabled(TTY, {}), true); + assert.equal(colorEnabled(PIPE, {}), false); +}); + +test("colorEnabled — NO_COLOR vetoes a TTY, FORCE_COLOR overrides a pipe", () => { + assert.equal(colorEnabled(TTY, { NO_COLOR: "1" }), false); + assert.equal(colorEnabled(TTY, { NO_COLOR: "" }), true, "empty NO_COLOR is not set"); + assert.equal(colorEnabled(PIPE, { FORCE_COLOR: "1" }), true); + assert.equal(colorEnabled(TTY, { FORCE_COLOR: "0" }), false, "FORCE_COLOR=0 means off"); + assert.equal(colorEnabled(TTY, { FORCE_COLOR: "0", NO_COLOR: "1" }), false); +}); + +test("colorEnabled — TERM=dumb cannot render escapes", () => { + assert.equal(colorEnabled(TTY, { TERM: "dumb" }), false); + assert.equal(colorEnabled(TTY, { TERM: "xterm-256color" }), true); +}); + +test("painterFor — returns the identity painter when colour is off", () => { + assert.equal(painterFor(PIPE, {}), plain); + assert.equal(painterFor(TTY, {}), painted); + assert.equal(plain("ok", "green"), "ok"); + assert.equal(painted("ok", "green"), "\u001b[32mok\u001b[0m"); + assert.equal(painted("", "green"), "", "never wrap an empty string"); +}); + +test("renderPretty — unpainted by default, so every existing caller stays plain", () => { + assert.equal(renderPretty({ status: "ok" }), "status: ok"); + assert.match(renderPretty({ status: "ok" }, 0, painted), /\u001b\[/); +}); + +test("renderPretty — states are painted by meaning, whole-value and case-insensitive", () => { + assert.equal(renderPretty({ s: "SUCCEEDED" }, 0, painted), "\u001b[2ms:\u001b[0m \u001b[32mSUCCEEDED\u001b[0m"); + assert.equal(renderPretty({ s: "FAILED" }, 0, painted), "\u001b[2ms:\u001b[0m \u001b[31mFAILED\u001b[0m"); + assert.equal(renderPretty({ s: "PENDING" }, 0, painted), "\u001b[2ms:\u001b[0m \u001b[33mPENDING\u001b[0m"); + // A value that merely contains a state word is left alone. + assert.equal(renderPretty({ s: "a failed robot" }, 0, painted), "\u001b[2ms:\u001b[0m a failed robot"); +}); + +/** + * The machine formats must never carry an escape, whatever the painter says — + * this is the one that would silently corrupt every agent reading stdout. + */ +test("render — json and ndjson ignore the painter entirely", () => { + for (const format of ["json", "ndjson"] as const) { + const out = render({ status: "ok", nested: { v: true } }, format, painted); + assert.doesNotMatch(out, /\u001b\[/, `${format} must stay escape-free`); + assert.deepEqual(JSON.parse(out), { status: "ok", nested: { v: true } }); + } +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 1034102..b5a42a9 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -32,7 +32,7 @@ const DOTENV_SECRET = "msy_dotenv_secret_value_123"; const FILE_SECRET = "msy_keyfile_secret_value_321"; function flagsOf(extra: Partial = {}): GlobalFlags { - return { format: "json", updateCheck: false, verbose: false, ...extra }; + return { format: "json", formatExplicit: true, updateCheck: false, verbose: false, ...extra }; } function tmp(prefix = "meshy-doctor-"): string { diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 961b4f0..14f7d7d 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -179,12 +179,43 @@ test("callback server — success page on valid /callback with correct state", a const res = await fetch(`http://127.0.0.1:${port}/callback?code=mycode&state=${encodeURIComponent(state)}`); assert.equal(res.status, 200); const body = await res.text(); - assert.ok(body.includes("Login successful")); + assert.ok(body.includes("Meshy CLI authorized")); + assert.ok(!body.includes("Login successful"), "token exchange has not completed yet"); + assert.ok(!body.includes("mycode"), "authorization codes must not be reflected into the page"); + assert.equal(res.headers.get("cache-control"), "no-store"); + assert.equal(res.headers.get("referrer-policy"), "no-referrer"); const result = await waitForCallback; assert.equal(result.code, "mycode"); assert.equal(result.state, state); }); +test("callback page — Chinese UI follows the browser language", async () => { + const { port, waitForCallback } = await startCallbackServer(0, "language-state"); + const res = await fetch(`http://127.0.0.1:${port}/callback?code=example&state=language-state`, { + headers: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" }, + }); + const body = await res.text(); + assert.match(body, /lang="zh"/); + assert.match(body, /已授权 Meshy CLI/); + assert.match(body, /请回到终端继续操作/); + await waitForCallback; +}); + +test("callback page — error details cannot inject markup or load third-party assets", async () => { + const { port, waitForCallback } = await startCallbackServer(0, "error-state"); + const rejected = assert.rejects(waitForCallback); + const res = await hitCallback(port, { + error: "server_error", + error_description: '', + }); + const body = await res.text(); + assert.ok(body.includes("<img")); + assert.ok(!body.includes(" { const state = generateState(); const { port, waitForCallback } = await startCallbackServer(0, state); @@ -198,7 +229,12 @@ test("callback server — ?error=access_denied rejects with that error", async ( }, ); // Now drive the callback with an error. - await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=User+denied`); + const res = await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=User+denied`); + const body = await res.text(); + // access_denied is a deliberate cancel, not a failure: it gets its own page. + assert.ok(body.includes("Connection canceled")); + assert.ok(!body.includes("Unable to connect")); + assert.ok(!body.includes("User denied"), "canceled pages carry no error details"); await rejectionPromise; }); @@ -223,9 +259,9 @@ test("callback server — state tampering: wrong state → 400 response, promise assert.equal(res.status, 400, "state mismatch must return 400, not 200"); const body = await res.text(); // Must NOT show the success page. - assert.ok(!body.includes("Login successful"), "must not show success page on state mismatch"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page on state mismatch"); // Must show an error indication. - assert.ok(body.includes("state mismatch") || body.includes("Login failed"), "must show error on state mismatch"); + assert.ok(body.includes("state mismatch") || body.includes("Unable to connect"), "must show error on state mismatch"); await rejectionPromise; }); @@ -279,8 +315,8 @@ test("callback server — correct state but no code → 400, rejection, no succe ); assert.equal(res.status, 400, "missing code must return 400"); const body = await res.text(); - assert.ok(!body.includes("Login successful"), "must not show success page when code is missing"); - assert.ok(body.includes("Login failed") || body.includes("no authorization code"), "must show error page"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page when code is missing"); + assert.ok(body.includes("Unable to connect") || body.includes("no authorization code"), "must show error page"); await rejectionPromise; }); @@ -304,8 +340,8 @@ test("callback server — correct state but empty code (&code=) → 400, rejecti ); assert.equal(res.status, 400, "empty code must return 400"); const body = await res.text(); - assert.ok(!body.includes("Login successful"), "must not show success page when code is empty"); - assert.ok(body.includes("Login failed") || body.includes("no authorization code"), "must show error page"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page when code is empty"); + assert.ok(body.includes("Unable to connect") || body.includes("no authorization code"), "must show error page"); await rejectionPromise; }); diff --git a/tests/output.test.ts b/tests/output.test.ts index 7b7f1ec..ef0e977 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -11,7 +11,9 @@ import assert from "node:assert/strict"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { emit, parseOutputFormat } from "../src/internal/output.js"; +import { defaultOutputFormat, emit, parseOutputFormat, renderPretty } from "../src/internal/output.js"; +import { emitResult, type OpenedCommand } from "../src/internal/command-helpers.js"; +import type { GlobalFlags } from "../src/internal/runtime.js"; function captureStdout(fn: () => void): string { const chunks: string[] = []; @@ -72,9 +74,65 @@ test("parseOutputFormat — accepts the three canonical values", () => { assert.equal(parseOutputFormat("json"), "json"); assert.equal(parseOutputFormat("pretty"), "pretty"); assert.equal(parseOutputFormat("NDJSON"), "ndjson"); + // Untyped follows the TTY; this runner's stdout is a pipe. assert.equal(parseOutputFormat(undefined), "json"); }); +/** + * The machine contract is "a pipe gets JSON", not "the default is JSON". Every + * agent, script and CI run reaches the CLI through a pipe or a subprocess, so + * only a person at a terminal ever sees the other branch. + */ +test("defaultOutputFormat — pretty on a TTY, json everywhere else", () => { + assert.equal(defaultOutputFormat(true), "pretty"); + assert.equal(defaultOutputFormat(false), "json"); + assert.equal(defaultOutputFormat(), "json", "the test runner's stdout is a pipe"); +}); + +test("renderPretty — an empty collection stays on the key's line", () => { + assert.equal(renderPretty({ warnings: [], meta: {} }), "warnings: []\nmeta: {}"); + assert.equal(renderPretty({ warnings: ["a"] }), "warnings:\n - a"); +}); + test("parseOutputFormat — rejects garbage", () => { assert.throws(() => parseOutputFormat("yaml"), /invalid --format/); }); + +// --------------------------------------------------------------------------- +// Legacy `-o ` under the TTY default +// --------------------------------------------------------------------------- + +function opened(format: "json" | "pretty", formatExplicit: boolean): OpenedCommand { + const flags = { format, formatExplicit, updateCheck: false, verbose: false } as GlobalFlags; + return { command: "balance", schema: "legacy", format, flags }; +} + +/** + * `-o ` writes the payload to disk instead of stdout. A TTY-derived + * `pretty` describes the terminal, not the file, and callers have always read + * that file back as JSON — so an untyped --format must not leak into it. + */ +test("emitResult — untyped --format on a TTY still writes JSON to -o", async () => { + const file = join(mkdtempSync(join(tmpdir(), "meshy-cli-ofile-")), "out.json"); + await emitResult(opened("pretty", false), { balance: 7 }, { balance: 7 }, { legacyFile: file }); + assert.equal(readFileSync(file, "utf8"), '{\n "balance": 7\n}\n'); +}); + +test("emitResult — an explicit --format pretty is honoured for -o", async () => { + const file = join(mkdtempSync(join(tmpdir(), "meshy-cli-ofile-")), "out.txt"); + await emitResult(opened("pretty", true), { balance: 7 }, { balance: 7 }, { legacyFile: file }); + assert.equal(readFileSync(file, "utf8"), "balance: 7\n"); +}); + +/** + * A file is not a terminal. `--format pretty -o notes.txt` must land readable + * text on disk, never control codes — the bug you only notice a week later in + * a diff. + */ +test("emit — a file never receives colour, even from a painted terminal", () => { + const file = join(mkdtempSync(join(tmpdir(), "meshy-cli-color-"))," out.txt".trim()); + emit({ status: "ok" }, { format: "pretty", file }); + const written = readFileSync(file, "utf8"); + assert.equal(written, "status: ok\n"); + assert.doesNotMatch(written, /\u001b\[/); +}); diff --git a/tests/update-notifier.test.ts b/tests/update-notifier.test.ts index e2a497e..cab901c 100644 --- a/tests/update-notifier.test.ts +++ b/tests/update-notifier.test.ts @@ -28,7 +28,10 @@ import { REFRESH_COMMAND, UPDATE_COMMAND, } from "../src/internal/update-notifier.js"; +import { PACKAGE_NAME } from "../src/internal/version.js"; import { emit } from "../src/internal/output.js"; + +const stripAnsi = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, ""); import { formatReport } from "../src/internal/report.js"; // --------------------------------------------------------------------------- @@ -264,7 +267,7 @@ test("buildNotice — 0.2.0 > 0.1.0 → notice with exact message", () => { assert.equal(notice.current, "0.1.0"); assert.equal( notice.message, - `meshy-cli 0.2.0 available (current 0.1.0), run: ${UPDATE_COMMAND}`, + `${PACKAGE_NAME} 0.2.0 available (current 0.1.0), run: ${UPDATE_COMMAND}`, ); assert.equal(notice.command, UPDATE_COMMAND); }); @@ -630,7 +633,23 @@ test("printHumanUpdateHint — all three isTTY true → writes message + newline const result = printHumanUpdateHint(fakeNotice, io); assert.equal(result, true); assert.equal(written.length, 1); - assert.equal(written[0], `${fakeNotice.message}\n`); + // The hint is dimmed because this fake stderr claims to be a TTY; the text + // itself is what the contract is about. + assert.equal(stripAnsi(written[0] ?? ""), `${fakeNotice.message}\n`); + assert.match(written[0] ?? "", /\u001b\[2m/, "a TTY hint is dimmed"); +}); + +test("printHumanUpdateHint — a non-TTY stderr is never painted", () => { + const written: string[] = []; + const io = { + stdout: { isTTY: true as boolean | undefined }, + stderr: { isTTY: undefined as boolean | undefined, write: (s: string) => { written.push(s); return true; } }, + stdin: { isTTY: true as boolean | undefined }, + }; + // stderr is not a TTY, so the hint is suppressed entirely — and if that rule + // ever loosens, whatever comes out must still be escape-free. + assert.equal(printHumanUpdateHint(fakeNotice, io), false); + for (const line of written) assert.doesNotMatch(line, /\u001b\[/); }); test("printHumanUpdateHint — stdout not TTY → no write, false", () => { diff --git a/tests/version.test.ts b/tests/version.test.ts index d08d2f8..9f5333a 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -8,7 +8,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { VERSION } from "../src/internal/version.js"; +import { PACKAGE_NAME, VERSION } from "../src/internal/version.js"; +import { REQUIRED_NODE } from "../src/internal/doctor.js"; test("VERSION === package.json version", () => { const pkg = JSON.parse( @@ -17,6 +18,26 @@ test("VERSION === package.json version", () => { assert.equal(VERSION, pkg.version); }); +test("PACKAGE_NAME === package.json name", () => { + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { name: string }; + assert.equal(PACKAGE_NAME, pkg.name); +}); + +/** + * engines.node and the floor `meshy doctor` enforces must move together. They + * drifted once in the other direction: engines claimed >=24 while nothing in + * the tree needed more than 22.12, and npm answered by silently resolving + * `npm i -g meshy-cli` to 0.1.3 for every Node 22 user. + */ +test("engines.node === the floor doctor enforces", () => { + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { engines: { node: string } }; + assert.equal(pkg.engines.node, `>=${REQUIRED_NODE}`); +}); + test("VERSION is a semver-shaped string", () => { assert.match(VERSION, /^\d+\.\d+\.\d+/); });