diff --git a/src/commands/internal/installer/normalize-env.ts b/src/commands/internal/installer/normalize-env.ts new file mode 100644 index 00000000000..d5060b4afbb --- /dev/null +++ b/src/commands/internal/installer/normalize-env.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { normalizeInstallerEnv } from "../../../lib/actions/installer-plan"; + +export default class InternalInstallerNormalizeEnvCommand extends Command { + static hidden = true; + static strict = true; + static summary = "Internal: normalize installer environment values"; + static description = "Normalize installer ref and provider environment values without applying installation changes."; + static usage = ["internal installer normalize-env [--json]"]; + static examples = ["<%= config.bin %> internal installer normalize-env --provider cloud --json"]; + static flags = { + help: Flags.help({ char: "h" }), + json: Flags.boolean({ description: "Print normalized values as JSON" }), + "install-ref": Flags.string({ description: "NEMOCLAW_INSTALL_REF value" }), + "install-tag": Flags.string({ description: "NEMOCLAW_INSTALL_TAG value" }), + provider: Flags.string({ description: "NEMOCLAW_PROVIDER value" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(InternalInstallerNormalizeEnvCommand); + const normalized = normalizeInstallerEnv({ + NEMOCLAW_INSTALL_REF: flags["install-ref"] ?? process.env.NEMOCLAW_INSTALL_REF, + NEMOCLAW_INSTALL_TAG: flags["install-tag"] ?? process.env.NEMOCLAW_INSTALL_TAG, + NEMOCLAW_PROVIDER: flags.provider ?? process.env.NEMOCLAW_PROVIDER, + }); + + if (flags.json) console.log(JSON.stringify(normalized, null, 2)); + else console.log(`ref=${normalized.installRef} provider=${normalized.provider.normalized ?? ""}`); + } +} diff --git a/src/commands/internal/installer/plan.ts b/src/commands/internal/installer/plan.ts new file mode 100644 index 00000000000..df8bb4c8cca --- /dev/null +++ b/src/commands/internal/installer/plan.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { buildInstallerPlan } from "../../../lib/actions/installer-plan"; + +export default class InternalInstallerPlanCommand extends Command { + static hidden = true; + static strict = true; + static summary = "Internal: build the NemoClaw installer plan"; + static description = "Build a deterministic installer plan from environment and probe inputs without applying it."; + static usage = ["internal installer plan [--json]"]; + static examples = ["<%= config.bin %> internal installer plan --json --provider nim --install-ref v0.1.0"]; + static flags = { + help: Flags.help({ char: "h" }), + json: Flags.boolean({ description: "Print the installer plan as JSON" }), + "install-ref": Flags.string({ description: "Install ref override" }), + "install-tag": Flags.string({ description: "Install tag fallback" }), + "git-describe-version": Flags.string({ description: "git describe version fallback", hidden: true }), + "node-version": Flags.string({ description: "Detected Node.js version" }), + "npm-prefix": Flags.string({ description: "Detected npm prefix" }), + "npm-version": Flags.string({ description: "Detected npm version" }), + "package-json-version": Flags.string({ description: "package.json version fallback", hidden: true }), + provider: Flags.string({ description: "Installer provider value" }), + "stamped-version": Flags.string({ description: "Stamped .version fallback", hidden: true }), + }; + + public async run(): Promise { + const { flags } = await this.parse(InternalInstallerPlanCommand); + const plan = buildInstallerPlan({ + env: { + ...process.env, + NEMOCLAW_INSTALL_REF: flags["install-ref"] ?? process.env.NEMOCLAW_INSTALL_REF, + NEMOCLAW_INSTALL_TAG: flags["install-tag"] ?? process.env.NEMOCLAW_INSTALL_TAG, + NEMOCLAW_PROVIDER: flags.provider ?? process.env.NEMOCLAW_PROVIDER, + }, + gitDescribeVersion: flags["git-describe-version"], + nodeVersion: flags["node-version"], + npmPrefix: flags["npm-prefix"], + npmVersion: flags["npm-version"], + packageJsonVersion: flags["package-json-version"], + stampedVersion: flags["stamped-version"], + }); + + if (flags.json) console.log(JSON.stringify(plan, null, 2)); + else console.log(`Installer plan: ref '${plan.installRef}', version '${plan.installerVersion}'`); + } +} diff --git a/src/commands/internal/installer/resolve-release-tag.ts b/src/commands/internal/installer/resolve-release-tag.ts new file mode 100644 index 00000000000..56bd7ae9575 --- /dev/null +++ b/src/commands/internal/installer/resolve-release-tag.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Command, Flags } from "@oclif/core"; + +import { resolveInstallRef } from "../../../lib/domain/installer/ref"; + +export default class InternalInstallerResolveReleaseTagCommand extends Command { + static hidden = true; + static strict = true; + static summary = "Internal: resolve the installer release ref"; + static description = "Resolve the installer ref using the same precedence as install.sh."; + static usage = ["internal installer resolve-release-tag [--json]"]; + static examples = ["<%= config.bin %> internal installer resolve-release-tag --install-ref v0.1.0"]; + static flags = { + help: Flags.help({ char: "h" }), + json: Flags.boolean({ description: "Print the resolved ref as JSON" }), + "install-ref": Flags.string({ description: "NEMOCLAW_INSTALL_REF value" }), + "install-tag": Flags.string({ description: "NEMOCLAW_INSTALL_TAG value" }), + }; + + public async run(): Promise { + const { flags } = await this.parse(InternalInstallerResolveReleaseTagCommand); + const installRef = resolveInstallRef({ + NEMOCLAW_INSTALL_REF: flags["install-ref"] ?? process.env.NEMOCLAW_INSTALL_REF, + NEMOCLAW_INSTALL_TAG: flags["install-tag"] ?? process.env.NEMOCLAW_INSTALL_TAG, + }); + + if (flags.json) console.log(JSON.stringify({ installRef }, null, 2)); + else console.log(installRef); + } +} diff --git a/src/lib/actions/installer-plan.test.ts b/src/lib/actions/installer-plan.test.ts new file mode 100644 index 00000000000..4112e212bf8 --- /dev/null +++ b/src/lib/actions/installer-plan.test.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { buildInstallerPlan, normalizeInstallerEnv } from "./installer-plan"; + +const writableState = { + exists: (targetPath: string) => targetPath !== "/missing", + isWritable: () => true, +}; + +describe("installer plan actions", () => { + it("builds a deterministic installer plan from env, versions, and npm prefix", () => { + const plan = buildInstallerPlan({ + defaultVersion: "0.1.0", + env: { + NEMOCLAW_INSTALL_REF: "feature/refactor", + NEMOCLAW_INSTALL_TAG: "v9.9.9", + NEMOCLAW_PROVIDER: "cloud", + PATH: "/usr/bin", + }, + nodeVersion: "v22.16.0", + npmPrefix: "/tmp/npm-prefix", + npmTargetState: writableState, + npmVersion: "10.1.0", + }); + + expect(plan.installRef).toBe("feature/refactor"); + expect(plan.installerVersion).toBe("feature/refactor"); + expect(plan.provider).toMatchObject({ normalized: "build", raw: "cloud", valid: true }); + expect(plan.runtime).toEqual({ ok: true, nodeVersion: "v22.16.0", npmVersion: "10.1.0" }); + expect(plan.npm?.globalBin).toBe(path.join("/tmp/npm-prefix", "bin")); + expect(plan.npm?.pathWithGlobalBin).toBe(`${path.join("/tmp/npm-prefix", "bin")}${path.delimiter}/usr/bin`); + expect(plan.npm?.linkTargetsWritable?.ok).toBe(true); + }); + + it("marks unsupported providers and missing optional probes without failing plan construction", () => { + const plan = buildInstallerPlan({ env: { NEMOCLAW_PROVIDER: "bad-provider" } }); + + expect(plan.installRef).toBe("latest"); + expect(plan.provider).toMatchObject({ normalized: null, raw: "bad-provider", valid: false }); + expect(plan.runtime).toBeNull(); + expect(plan.npm).toBeNull(); + }); + + it("normalizes installer env for shell-compatible helper output", () => { + expect(normalizeInstallerEnv({ NEMOCLAW_INSTALL_TAG: "v1.2.3", NEMOCLAW_PROVIDER: "nim" })).toEqual({ + installRef: "v1.2.3", + provider: expect.objectContaining({ normalized: "nim-local", raw: "nim", valid: true }), + }); + }); +}); diff --git a/src/lib/actions/installer-plan.ts b/src/lib/actions/installer-plan.ts new file mode 100644 index 00000000000..fc01e4509eb --- /dev/null +++ b/src/lib/actions/installer-plan.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + npmGlobalBin, + npmLinkTargetsWritable, + pathWithPrependedEntries, + type NpmLinkTargetState, + type NpmLinkTargetWritableResult, +} from "../domain/installer/npm"; +import { + installerProviderHelpValues, + normalizeInstallerProvider, + type InstallerProvider, +} from "../domain/installer/provider"; +import { resolveInstallerVersion, resolveInstallRef, type InstallerRefEnv } from "../domain/installer/ref"; +import { checkInstallerRuntime, type RuntimeCheckResult } from "../domain/installer/version"; + +export interface InstallerPlanEnv extends InstallerRefEnv { + NEMOCLAW_PROVIDER?: string | undefined; + PATH?: string | undefined; +} + +export interface BuildInstallerPlanOptions { + defaultVersion?: string; + env?: InstallerPlanEnv; + gitDescribeVersion?: string | null; + nodeVersion?: string | null; + npmPrefix?: string | null; + npmTargetState?: NpmLinkTargetState; + npmVersion?: string | null; + packageJsonVersion?: string | null; + stampedVersion?: string | null; +} + +export interface InstallerProviderPlan { + helpValues: string; + normalized: InstallerProvider | null; + raw: string | null; + valid: boolean; +} + +export interface InstallerNpmPlan { + globalBin: string | null; + linkTargetsWritable: NpmLinkTargetWritableResult | null; + pathWithGlobalBin: string | null; + prefix: string; +} + +export interface InstallerPlan { + installRef: string; + installerVersion: string; + npm: InstallerNpmPlan | null; + provider: InstallerProviderPlan; + runtime: RuntimeCheckResult | null; +} + +function nonEmpty(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +export function buildInstallerPlan(options: BuildInstallerPlanOptions = {}): InstallerPlan { + const env = options.env ?? {}; + const installRef = resolveInstallRef(env); + const providerRaw = nonEmpty(env.NEMOCLAW_PROVIDER); + const normalizedProvider = normalizeInstallerProvider(providerRaw); + const globalBin = options.npmPrefix ? npmGlobalBin(options.npmPrefix) : null; + + return { + installRef, + installerVersion: resolveInstallerVersion({ + defaultVersion: options.defaultVersion ?? "0.1.0", + env, + gitDescribeVersion: options.gitDescribeVersion, + packageJsonVersion: options.packageJsonVersion, + stampedVersion: options.stampedVersion, + }), + npm: options.npmPrefix + ? { + globalBin, + linkTargetsWritable: options.npmTargetState + ? npmLinkTargetsWritable(options.npmPrefix, options.npmTargetState) + : null, + pathWithGlobalBin: globalBin ? pathWithPrependedEntries(env.PATH ?? "", [globalBin]) : null, + prefix: options.npmPrefix.trim(), + } + : null, + provider: { + helpValues: installerProviderHelpValues(), + normalized: normalizedProvider, + raw: providerRaw, + valid: providerRaw === null || normalizedProvider !== null, + }, + runtime: + options.nodeVersion && options.npmVersion + ? checkInstallerRuntime({ nodeVersion: options.nodeVersion, npmVersion: options.npmVersion }) + : null, + }; +} + +export function normalizeInstallerEnv(env: InstallerPlanEnv): Pick { + const plan = buildInstallerPlan({ env }); + return { installRef: plan.installRef, provider: plan.provider }; +} diff --git a/test/internal-cli.test.ts b/test/internal-cli.test.ts index 41c8c60298a..5177e17aa52 100644 --- a/test/internal-cli.test.ts +++ b/test/internal-cli.test.ts @@ -49,4 +49,62 @@ describe("internal oclif namespace", () => { expect(result.stdout).toContain("Internal: link the checkout CLI or create a dev shim"); expect(result.stdout).toContain("nemoclaw internal dev npm-link-or-shim"); }); + + it("exposes installer plan commands through oclif routing", () => { + const help = spawnSync(process.execPath, [CLI, "internal", "installer", "plan", "--help"], { + encoding: "utf-8", + }); + + expect(help.status).toBe(0); + expect(help.stdout).toContain("Internal: build the NemoClaw installer plan"); + expect(help.stdout).toContain("nemoclaw internal installer plan [--json]"); + + const result = spawnSync( + process.execPath, + [ + CLI, + "internal", + "installer", + "plan", + "--json", + "--install-ref", + "v1.2.3", + "--provider", + "cloud", + "--node-version", + "v22.16.0", + "--npm-version", + "10.0.0", + ], + { encoding: "utf-8" }, + ); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + installRef: "v1.2.3", + provider: { normalized: "build", raw: "cloud", valid: true }, + runtime: { ok: true }, + }); + }); + + it("exposes installer ref and env normalization helpers through oclif routing", () => { + const ref = spawnSync( + process.execPath, + [CLI, "internal", "installer", "resolve-release-tag", "--json", "--install-tag", "v2.0.0"], + { encoding: "utf-8" }, + ); + const env = spawnSync( + process.execPath, + [CLI, "internal", "installer", "normalize-env", "--json", "--provider", "nim"], + { encoding: "utf-8" }, + ); + + expect(ref.status).toBe(0); + expect(JSON.parse(ref.stdout)).toEqual({ installRef: "v2.0.0" }); + expect(env.status).toBe(0); + expect(JSON.parse(env.stdout)).toMatchObject({ + installRef: "latest", + provider: { normalized: "nim-local", raw: "nim", valid: true }, + }); + }); });