diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d84359..ab2d842 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: permissions: contents: write + id-token: write # npm trusted publishing (OIDC) jobs: goreleaser: @@ -50,3 +51,11 @@ jobs: # Upload install script aws s3 cp install.sh s3://major-cli-releases/install.sh --acl public-read --content-type "text/plain" + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + # Trusted publishing needs npm >= 11.5.1; no token is used. + - run: npm install -g npm@latest + - name: Publish to npm + run: npm/publish.sh "${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index b5d190c..5fb1209 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ For comprehensive guides, command references, and API documentation, visit our * curl -fsSL https://install.major.build | bash ``` +### npm + +```bash +npx major --help # run without installing +npm install -g major # or install globally +``` + ### Homebrew ```bash diff --git a/cmd/update.go b/cmd/update.go index 4e75927..eb6cf3e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "runtime" "strings" @@ -16,7 +17,7 @@ import ( var updateCmd = &cobra.Command{ Use: "update", Short: "Update the major CLI to the latest version", - Long: `Automatically detects your installation method (brew or direct install) and updates to the latest version.`, + Long: `Automatically detects your installation method (brew, npm, or direct install) and updates to the latest version.`, RunE: func(cmd *cobra.Command, args []string) error { return runUpdate(cmd) }, @@ -49,6 +50,12 @@ func runUpdate(cmd *cobra.Command) error { switch installMethod { case "brew": return updateViaBrew(cmd, stepStyle, successStyle) + case "npm": + return updateViaNpm(cmd, stepStyle, successStyle) + case "npx": + cmd.Println() + cmd.Println("npx runs the latest published version automatically. To force a refresh, run: npx major@latest") + return nil case "direct": return updateViaDirect(cmd, stepStyle, successStyle) default: @@ -57,6 +64,12 @@ func runUpdate(cmd *cobra.Command) error { } func detectInstallMethod() string { + if exe, err := os.Executable(); err == nil { + if method := npmInstallMethod(exe); method != "" { + return method + } + } + // Check if installed via brew if runtime.GOOS == "darwin" || runtime.GOOS == "linux" { // Check if brew is available @@ -73,6 +86,34 @@ func detectInstallMethod() string { return "direct" } +// npmInstallMethod reports "npx" or "npm" when exe lives in a node_modules +// tree (the npm package ships the binary as a dependency), else "". +func npmInstallMethod(exe string) string { + exe = filepath.ToSlash(exe) + if !strings.Contains(exe, "/node_modules/") { + return "" + } + if strings.Contains(exe, "/_npx/") { + return "npx" + } + return "npm" +} + +func updateViaNpm(cmd *cobra.Command, stepStyle, successStyle lipgloss.Style) error { + cmd.Println(stepStyle.Render("▸ Updating via npm...")) + + npmCmd := exec.Command("npm", "install", "-g", "major@latest") + npmCmd.Stdout = os.Stdout + npmCmd.Stderr = os.Stderr + if err := npmCmd.Run(); err != nil { + return errors.WrapError("failed to update major via npm", err) + } + + cmd.Println() + cmd.Println(successStyle.Render("✓ Successfully updated Major CLI!")) + return nil +} + func updateViaBrew(cmd *cobra.Command, stepStyle, successStyle lipgloss.Style) error { cmd.Println(stepStyle.Render("▸ Updating via Homebrew...")) diff --git a/cmd/update_test.go b/cmd/update_test.go index 2f0c43a..88a01fd 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -47,3 +47,18 @@ func TestUpdateDirectNonInteractiveRefusesPrivilegeEscalation(t *testing.T) { t.Fatalf("direct install started = %d stdin=%v, want 0 (must not attach os.Stdin)", started, stdinAttached) } } + +func TestNpmInstallMethod(t *testing.T) { + cases := map[string]string{ + "/Users/a/.npm/_npx/1a2b/node_modules/@major-tech/major-darwin-arm64/bin/major": "npx", + "/usr/local/lib/node_modules/major/node_modules/@major-tech/major-linux-x64/bin/major": "npm", + "/Users/a/.nvm/versions/node/v22/lib/node_modules/@major-tech/major-darwin-arm64/bin/major": "npm", + "/Users/a/.major/bin/major": "", + "/opt/homebrew/bin/major": "", + } + for exe, want := range cases { + if got := npmInstallMethod(exe); got != want { + t.Errorf("npmInstallMethod(%q) = %q, want %q", exe, got, want) + } + } +} diff --git a/npm/major/bin/major.js b/npm/major/bin/major.js new file mode 100755 index 0000000..b067d4b --- /dev/null +++ b/npm/major/bin/major.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +const { spawnSync } = require("child_process"); + +const pkg = `@major-tech/major-${process.platform}-${process.arch}`; +let binary; +try { + binary = require.resolve(`${pkg}/bin/major`); +} catch { + console.error(`major: no prebuilt binary for ${process.platform}-${process.arch} (missing ${pkg}).`); + console.error("If you installed with --no-optional or --omit=optional, reinstall without it."); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) throw result.error; +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status); diff --git a/npm/major/package.json b/npm/major/package.json new file mode 100644 index 0000000..ff0b6df --- /dev/null +++ b/npm/major/package.json @@ -0,0 +1,23 @@ +{ + "name": "major", + "version": "0.0.0", + "description": "Major CLI", + "homepage": "https://major.build", + "repository": { + "type": "git", + "url": "git+https://github.com/major-technology/cli.git" + }, + "license": "MIT", + "bin": { + "major": "bin/major.js" + }, + "files": [ + "bin" + ], + "optionalDependencies": { + "@major-tech/major-darwin-arm64": "0.0.0", + "@major-tech/major-darwin-x64": "0.0.0", + "@major-tech/major-linux-arm64": "0.0.0", + "@major-tech/major-linux-x64": "0.0.0" + } +} diff --git a/npm/publish.sh b/npm/publish.sh new file mode 100755 index 0000000..ec0264b --- /dev/null +++ b/npm/publish.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Publishes the npm packages for a release from GoReleaser's dist/ output. +# Usage: npm/publish.sh (run from repo root after goreleaser) +# Auth comes from npm trusted publishing (GitHub OIDC), not a token. +set -euo pipefail + +VERSION="${1#v}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/dist/npm" +rm -rf "$OUT" + +# Skips versions already on npm so re-running a release is safe. +publish() { + local name version + name=$(node -p "require('$1/package.json').name") + version=$(node -p "require('$1/package.json').version") + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version already published, skipping" + else + npm publish "$1" --access public + fi +} + +# goos/goarch -> node platform/arch +for target in darwin/arm64/arm64 darwin/amd64/x64 linux/arm64/arm64 linux/amd64/x64; do + IFS=/ read -r goos goarch nodearch <<< "$target" + name="@major-tech/major-$goos-$nodearch" + src=$(ls "$ROOT"/dist/major_"$goos"_"$goarch"*/major) + dir="$OUT/major-$goos-$nodearch" + mkdir -p "$dir/bin" + cp "$src" "$dir/bin/major" + chmod +x "$dir/bin/major" + cat > "$dir/package.json" <