Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:

permissions:
contents: write
id-token: write # npm trusted publishing (OIDC)

jobs:
goreleaser:
Expand Down Expand Up @@ -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}"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"

Expand All @@ -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)
},
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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..."))

Expand Down
15 changes: 15 additions & 0 deletions cmd/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
17 changes: 17 additions & 0 deletions npm/major/bin/major.js
Original file line number Diff line number Diff line change
@@ -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);
23 changes: 23 additions & 0 deletions npm/major/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
57 changes: 57 additions & 0 deletions npm/publish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/bin/bash
# Publishes the npm packages for a release from GoReleaser's dist/ output.
# Usage: npm/publish.sh <version> (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" <<JSON
{
"name": "$name",
"version": "$VERSION",
"description": "The $goos $nodearch binary for the Major CLI",
"repository": { "type": "git", "url": "git+https://github.com/major-technology/cli.git" },
"license": "MIT",
"os": ["$goos"],
"cpu": ["$nodearch"]
}
JSON
publish "$dir"
done

cp -r "$ROOT/npm/major" "$OUT/major"
cp "$ROOT/README.md" "$ROOT/LICENSE" "$OUT/major/"
node -e '
const fs = require("fs");
const [file, version] = process.argv.slice(1);
const pkg = JSON.parse(fs.readFileSync(file));
pkg.version = version;
for (const dep in pkg.optionalDependencies) pkg.optionalDependencies[dep] = version;
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n");
' "$OUT/major/package.json" "$VERSION"
publish "$OUT/major"
Loading