diff --git a/.gitignore b/.gitignore index 9f2b8a7e9..99b6be9a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # doc docs -script .worktrees # codegen diff --git a/ai/agent/info.go b/ai/agent/info.go index af93a85da..6d883e05f 100644 --- a/ai/agent/info.go +++ b/ai/agent/info.go @@ -16,6 +16,10 @@ type AgentInfo struct { // Scenario is the corresponding routing rule scenario Scenario string + + // NPMPackage is the canonical npm package name used by `ci install`. + // Empty for agents that aren't distributed via npm. + NPMPackage string } // ListAgentInfo returns information about all supported agent types @@ -29,7 +33,8 @@ func ListAgentInfo() []AgentInfo { "~/.claude/settings.json", "~/.claude.json", }, - Scenario: "claude_code", + Scenario: "claude_code", + NPMPackage: "@anthropic-ai/claude-code", }, { Type: AgentTypeOpenCode, @@ -38,7 +43,8 @@ func ListAgentInfo() []AgentInfo { ConfigFiles: []string{ "~/.config/opencode/opencode.json", }, - Scenario: "opencode", + Scenario: "opencode", + NPMPackage: "opencode-ai", }, { Type: AgentTypeCodex, @@ -48,7 +54,8 @@ func ListAgentInfo() []AgentInfo { "~/.codex/config.toml", "~/.codex/auth.json", }, - Scenario: "codex", + Scenario: "codex", + NPMPackage: "@openai/codex", }, } } diff --git a/cli/tingly-box/main.go b/cli/tingly-box/main.go index f7580e9c3..28a36570f 100644 --- a/cli/tingly-box/main.go +++ b/cli/tingly-box/main.go @@ -42,6 +42,9 @@ type CLI struct { // Agent commands Agent command.AgentCmdKong `kong:"cmd,help='Agent configuration'"` + // Headless one-shot setup for CI / fully-managed environments + CI command.CICmdKong `kong:"cmd,name='ci',help='Headless one-shot agent setup (provider + model + agent)'"` + // OAuth OAuth command.OAuthCmdKong `kong:"cmd,name='oauth',help='OAuth authentication'"` diff --git a/internal/command/ci.go b/internal/command/ci.go new file mode 100644 index 000000000..087d95a0a --- /dev/null +++ b/internal/command/ci.go @@ -0,0 +1,159 @@ +// Headless one-shot agent setup for CI / fully-managed environments. +// +// `ci apply` configures a single (provider, model, agent) triple from flags +// alone, with no stdin reads and no interactive prompts. It is idempotent: +// a provider with the same name is updated in place rather than duplicated, +// and routing rules / agent config files converge to the requested state. + +package command + +import ( + "fmt" + "os" + "strings" + + "github.com/tingly-dev/tingly-box/internal/agent" + "github.com/tingly-dev/tingly-box/internal/protocol" +) + +// CICmdKong is the headless setup command. The only subcommand today is +// `apply`; it is wired as the default so plain `tingly-box ci ...` keeps +// working even before any other subcommand is added. +type CICmdKong struct { + Apply CIApplyCmdKong `kong:"cmd,name='apply',default='1',help='Apply a (provider, model, agent) configuration in one shot'"` + Install CIInstallCmdKong `kong:"cmd,name='install',help='Install an agent CLI via npm'"` +} + +// CIApplyCmdKong carries all flags accepted by `ci apply`. Every required +// flag is enforced inside Run so that we can emit a single combined error +// message (Kong's per-flag required errors are noisy and exit before we can +// suggest related missing flags). +type CIApplyCmdKong struct { + Agent string `kong:"flag,name='agent',help='Agent type: cc | oc | cx (aliases of claude-code / opencode / codex)'"` + ProviderURL string `kong:"flag,name='provider-url',help='Provider API base URL (used as upsert key)'"` + ProviderToken string `kong:"flag,name='provider-token',help='Provider API token'"` + ProviderStyle string `kong:"flag,name='provider-style',help='Provider API style: openai | anthropic'"` + Model string `kong:"flag,name='model',help='Model name'"` + Unified bool `kong:"flag,name='unified',default='true',negatable,help='Unified mode (claude-code only)'"` + StatusLine bool `kong:"flag,name='status-line',help='Install status line script (claude-code only)'"` + DryRun bool `kong:"flag,name='dry-run',help='Print the plan without applying changes'"` +} + +// Run validates flags, parses the agent type, normalises the API style, then +// delegates the actual work to applyCISpec. Errors here exit with status 2 +// (configuration error) via os.Exit so CI runners can distinguish bad input +// from runtime failures (which return through Kong as status 1). +func (c *CIApplyCmdKong) Run(appManager *AppManager) error { + spec, err := c.toSpec() + if err != nil { + fmt.Fprintf(os.Stderr, "ci: %v\n", err) + os.Exit(2) + } + + if c.DryRun { + printCIPlan(spec) + return nil + } + + return applyCISpec(appManager, spec) +} + +// ciSpec is the validated, internal representation of a `ci apply` request. +// It is intentionally separate from CIApplyCmdKong so that tests (and any +// future YAML loader) can build one without going through Kong. +type ciSpec struct { + AgentType agent.AgentType + ProviderURL string + ProviderToken string + ProviderStyle protocol.APIStyle + Model string + Unified bool + StatusLine bool +} + +// toSpec converts CLI flags into a validated ciSpec, collecting every +// missing-flag complaint into a single error so the user sees them all at +// once rather than having to fix and re-run repeatedly. +func (c *CIApplyCmdKong) toSpec() (*ciSpec, error) { + var missing []string + if strings.TrimSpace(c.Agent) == "" { + missing = append(missing, "--agent") + } + if strings.TrimSpace(c.ProviderURL) == "" { + missing = append(missing, "--provider-url") + } + if strings.TrimSpace(c.ProviderToken) == "" { + missing = append(missing, "--provider-token") + } + if strings.TrimSpace(c.ProviderStyle) == "" { + missing = append(missing, "--provider-style") + } + if strings.TrimSpace(c.Model) == "" { + missing = append(missing, "--model") + } + if len(missing) > 0 { + return nil, fmt.Errorf("missing required flag(s): %s", strings.Join(missing, ", ")) + } + + agentType, err := agent.ParseAgentType(c.Agent) + if err != nil { + return nil, fmt.Errorf("invalid --agent %q (accepted: cc, oc, cx)", c.Agent) + } + + style, err := parseAPIStyle(c.ProviderStyle) + if err != nil { + return nil, err + } + + // status-line / unified only make sense for claude-code. We don't reject + // them outright for non-cc agents (the user may be scripting a matrix and + // passing the same flags everywhere) — silently ignored below via the + // agent-specific branch in ApplyAgent. + return &ciSpec{ + AgentType: agentType, + ProviderURL: c.ProviderURL, + ProviderToken: c.ProviderToken, + ProviderStyle: style, + Model: c.Model, + Unified: c.Unified, + StatusLine: c.StatusLine, + }, nil +} + +// parseAPIStyle accepts "openai" / "anthropic" (case-insensitive). OAuth +// providers are intentionally rejected — they need a browser flow that has +// no place in a CI invocation. +func parseAPIStyle(s string) (protocol.APIStyle, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "openai": + return protocol.APIStyleOpenAI, nil + case "anthropic": + return protocol.APIStyleAnthropic, nil + default: + return "", fmt.Errorf("invalid --provider-style %q (accepted: openai, anthropic)", s) + } +} + +// printCIPlan emits the intended actions without touching disk. Token is +// redacted so the output is safe to capture in CI logs. +func printCIPlan(s *ciSpec) { + fmt.Println("ci apply (dry-run):") + fmt.Printf(" agent: %s\n", s.AgentType) + fmt.Printf(" provider: %s\n", s.ProviderURL) + fmt.Printf(" token: %s\n", redactToken(s.ProviderToken)) + fmt.Printf(" style: %s\n", s.ProviderStyle) + fmt.Printf(" model: %s\n", s.Model) + if s.AgentType == agent.AgentTypeClaudeCode { + fmt.Printf(" unified: %v\n", s.Unified) + fmt.Printf(" status_line: %v\n", s.StatusLine) + } +} + +// redactToken keeps the first/last 4 chars so an operator can sanity-check +// they passed the right secret without leaking the whole value to logs. +func redactToken(t string) string { + if len(t) <= 8 { + return "****" + } + return t[:4] + "…" + t[len(t)-4:] +} diff --git a/internal/command/ci_install.go b/internal/command/ci_install.go new file mode 100644 index 000000000..1c75c0d1c --- /dev/null +++ b/internal/command/ci_install.go @@ -0,0 +1,84 @@ +package command + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/tingly-dev/tingly-box/internal/agent" +) + +// CIInstallCmdKong installs an agent's CLI via npm. It is intentionally +// minimal: one agent per invocation, global install, sudo is NOT handled +// (npm's own EACCES message is more informative than anything we'd wrap). +type CIInstallCmdKong struct { + Agent string `kong:"flag,name='agent',help='Agent type: cc | oc | cx'"` + Version string `kong:"flag,name='version',help='Pin a specific version (default: latest)'"` + Package string `kong:"flag,name='package',help='Override the npm package name'"` + DryRun bool `kong:"flag,name='dry-run',help='Print the npm command without executing it'"` +} + +// Run validates flags and shells out to npm. Validation errors exit with 2 +// (configuration error); npm's exit code is propagated otherwise. +func (c *CIInstallCmdKong) Run(_ *AppManager) error { + pkgSpec, err := resolveInstallPackage(c.Agent, c.Package, c.Version) + if err != nil { + fmt.Fprintf(os.Stderr, "ci install: %v\n", err) + os.Exit(2) + } + + if c.DryRun { + fmt.Printf("npm install -g %s\n", pkgSpec) + return nil + } + + if _, err := exec.LookPath("npm"); err != nil { + fmt.Fprintln(os.Stderr, "ci install: npm not found in PATH; install Node.js / npm first") + os.Exit(2) + } + + fmt.Printf("$ npm install -g %s\n", pkgSpec) + cmd := exec.Command("npm", "install", "-g", pkgSpec) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + if err := cmd.Run(); err != nil { + // npm already printed a meaningful error to stderr; surface its exit + // code to the caller so CI gates behave normally. + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("failed to invoke npm: %w", err) + } + return nil +} + +// resolveInstallPackage produces the "[@version]" argument for +// `npm install -g`. It validates the agent, applies a --package override, +// and tacks on a --version pin if given. Pure (no I/O, no os.Exit) so tests +// can drive every branch. +func resolveInstallPackage(agentFlag, override, version string) (string, error) { + if strings.TrimSpace(agentFlag) == "" { + return "", fmt.Errorf("missing required flag --agent") + } + + agentType, err := agent.ParseAgentType(agentFlag) + if err != nil { + return "", fmt.Errorf("invalid --agent %q (accepted: cc, oc, cx)", agentFlag) + } + + pkg := strings.TrimSpace(override) + if pkg == "" { + info, ok := agent.GetAgentInfo(agentType) + if !ok || info.NPMPackage == "" { + return "", fmt.Errorf("no npm package registered for agent %s; pass --package to override", agentType) + } + pkg = info.NPMPackage + } + + if v := strings.TrimSpace(version); v != "" { + pkg = pkg + "@" + v + } + return pkg, nil +} diff --git a/internal/command/ci_install_test.go b/internal/command/ci_install_test.go new file mode 100644 index 000000000..b57c10e73 --- /dev/null +++ b/internal/command/ci_install_test.go @@ -0,0 +1,81 @@ +package command + +import ( + "strings" + "testing" +) + +func TestResolveInstallPackage_DefaultsByAgent(t *testing.T) { + cases := []struct{ agent, want string }{ + {"cc", "@anthropic-ai/claude-code"}, + {"claude-code", "@anthropic-ai/claude-code"}, + {"oc", "opencode-ai"}, + {"opencode", "opencode-ai"}, + {"cx", "@openai/codex"}, + {"codex", "@openai/codex"}, + } + for _, c := range cases { + got, err := resolveInstallPackage(c.agent, "", "") + if err != nil { + t.Errorf("resolveInstallPackage(%q): %v", c.agent, err) + continue + } + if got != c.want { + t.Errorf("resolveInstallPackage(%q) = %q, want %q", c.agent, got, c.want) + } + } +} + +func TestResolveInstallPackage_VersionAppended(t *testing.T) { + got, err := resolveInstallPackage("cc", "", "1.2.3") + if err != nil { + t.Fatal(err) + } + if got != "@anthropic-ai/claude-code@1.2.3" { + t.Errorf("got %q", got) + } +} + +func TestResolveInstallPackage_OverrideTakesPrecedence(t *testing.T) { + got, err := resolveInstallPackage("cc", "my-fork-of-cc", "") + if err != nil { + t.Fatal(err) + } + if got != "my-fork-of-cc" { + t.Errorf("got %q, want my-fork-of-cc", got) + } + + // Override + version combine. + got, err = resolveInstallPackage("cc", "my-fork-of-cc", "0.1.0") + if err != nil { + t.Fatal(err) + } + if got != "my-fork-of-cc@0.1.0" { + t.Errorf("got %q", got) + } +} + +func TestResolveInstallPackage_MissingAgent(t *testing.T) { + _, err := resolveInstallPackage("", "", "") + if err == nil || !strings.Contains(err.Error(), "--agent") { + t.Errorf("expected --agent error, got %v", err) + } +} + +func TestResolveInstallPackage_InvalidAgent(t *testing.T) { + _, err := resolveInstallPackage("nope", "", "") + if err == nil { + t.Fatal("expected error for invalid agent") + } +} + +func TestResolveInstallPackage_TrimsWhitespace(t *testing.T) { + got, err := resolveInstallPackage("cc", " ", " ") + if err != nil { + t.Fatal(err) + } + // Whitespace-only override / version are equivalent to absent. + if got != "@anthropic-ai/claude-code" { + t.Errorf("got %q", got) + } +} diff --git a/internal/command/ci_runner.go b/internal/command/ci_runner.go new file mode 100644 index 000000000..4b13d4a6c --- /dev/null +++ b/internal/command/ci_runner.go @@ -0,0 +1,68 @@ +package command + +import ( + "fmt" + + "github.com/tingly-dev/tingly-box/internal/agent" +) + +// applyCISpec is the runner for `ci apply`. It performs two steps: +// +// 1. upsert the provider by URL (idempotent: re-running CI with the same URL +// updates the existing row rather than duplicating it). +// 2. delegate to agent.ApplyAgent with Force=true so it never blocks on a +// confirmation prompt. ApplyAgent handles routing-rule creation and +// config-file writes idempotently when given a concrete provider UUID and model. +func applyCISpec(am *AppManager, s *ciSpec) error { + providerUUID, action, err := upsertProviderByURL(am, s) + if err != nil { + return fmt.Errorf("provider upsert failed: %w", err) + } + fmt.Printf("provider %s: %s (%s)\n", action, s.ProviderURL, providerUUID) + + req := &agent.ApplyAgentRequest{ + AgentType: s.AgentType, + Provider: providerUUID, + Model: s.Model, + Unified: s.Unified, + InstallStatusLine: s.StatusLine, + Force: true, + } + + globalConfig := am.GetGlobalConfig() + apply := agent.NewAgentApply(globalConfig, "127.0.0.1") + result, err := apply.ApplyAgent(req) + if err != nil { + return fmt.Errorf("agent apply failed: %w", err) + } + if !result.Success { + return fmt.Errorf("agent apply did not succeed: %s", result.Message) + } + + fmt.Print("\n" + result.Message) + return nil +} + +// upsertProviderByURL looks for an existing provider whose APIBase matches +// s.ProviderURL and updates it in place; otherwise creates a new one with the +// URL as its name. The returned action string ("created" or "updated") is for +// the user-facing log line only. +func upsertProviderByURL(am *AppManager, s *ciSpec) (string, string, error) { + for _, p := range am.ListProviders() { + if p.APIBase == s.ProviderURL { + p.Token = s.ProviderToken + p.APIStyle = s.ProviderStyle + p.Enabled = true + if err := am.UpdateProviderByUUID(p.UUID, p); err != nil { + return "", "", err + } + return p.UUID, "updated", nil + } + } + + uuid, err := am.AddProvider(s.ProviderURL, s.ProviderURL, s.ProviderToken, s.ProviderStyle) + if err != nil { + return "", "", err + } + return uuid, "created", nil +} diff --git a/internal/command/ci_test.go b/internal/command/ci_test.go new file mode 100644 index 000000000..e3efdb6e5 --- /dev/null +++ b/internal/command/ci_test.go @@ -0,0 +1,180 @@ +package command + +import ( + "strings" + "testing" + + "github.com/tingly-dev/tingly-box/internal/agent" + "github.com/tingly-dev/tingly-box/internal/protocol" +) + +// helper: minimal valid flag set, mutated by individual tests. +func validApplyFlags() *CIApplyCmdKong { + return &CIApplyCmdKong{ + Agent: "cc", + ProviderURL: "https://openrouter.ai/api/v1", + ProviderToken: "sk-test-token-1234567890", + ProviderStyle: "openai", + Model: "anthropic/claude-sonnet-4", + Unified: true, + } +} + +func TestCIApply_toSpec_OK(t *testing.T) { + c := validApplyFlags() + spec, err := c.toSpec() + if err != nil { + t.Fatalf("toSpec: %v", err) + } + if spec.AgentType != agent.AgentTypeClaudeCode { + t.Errorf("agent type: got %v want %v", spec.AgentType, agent.AgentTypeClaudeCode) + } + if spec.ProviderStyle != protocol.APIStyleOpenAI { + t.Errorf("provider style: got %v want %v", spec.ProviderStyle, protocol.APIStyleOpenAI) + } + if spec.ProviderURL != "https://openrouter.ai/api/v1" { + t.Errorf("provider URL: got %v", spec.ProviderURL) + } +} + +func TestCIApply_toSpec_MissingFlagsCollected(t *testing.T) { + c := &CIApplyCmdKong{} // everything empty + _, err := c.toSpec() + if err == nil { + t.Fatal("expected error when all required flags missing") + } + msg := err.Error() + for _, want := range []string{ + "--agent", "--provider-url", "--provider-token", "--provider-style", "--model", + } { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } + // --provider-name must not appear — it no longer exists + if strings.Contains(msg, "--provider-name") { + t.Errorf("error should not mention --provider-name (removed flag)") + } +} + +func TestCIApply_toSpec_InvalidAgent(t *testing.T) { + c := validApplyFlags() + c.Agent = "nope" + if _, err := c.toSpec(); err == nil { + t.Fatal("expected error for invalid agent") + } +} + +func TestCIApply_toSpec_RejectsOAuthStyle(t *testing.T) { + for _, bad := range []string{"google", "anthropic-oauth", "oauth", ""} { + c := validApplyFlags() + c.ProviderStyle = bad + _, err := c.toSpec() + if err == nil { + t.Errorf("provider-style %q should be rejected", bad) + } + } +} + +func TestCIApply_toSpec_StyleIsCaseInsensitive(t *testing.T) { + c := validApplyFlags() + c.ProviderStyle = "OpenAI" + spec, err := c.toSpec() + if err != nil { + t.Fatalf("toSpec: %v", err) + } + if spec.ProviderStyle != protocol.APIStyleOpenAI { + t.Errorf("expected APIStyleOpenAI, got %v", spec.ProviderStyle) + } +} + +// TestCI_UpsertProvider_CreateThenUpdate exercises the idempotency contract: +// the second call with the same URL must update in place rather than create +// a duplicate provider row. +func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { + am := newTestAppManager(t) + + first := &ciSpec{ + ProviderURL: "https://example.com/v1", + ProviderToken: "token-A", + ProviderStyle: protocol.APIStyleOpenAI, + } + uuid1, action, err := upsertProviderByURL(am, first) + if err != nil { + t.Fatalf("first upsert: %v", err) + } + if action != "created" { + t.Errorf("first action: got %q want %q", action, "created") + } + + // Second call: same URL, different token+style. Must update, not create. + second := &ciSpec{ + ProviderURL: "https://example.com/v1", + ProviderToken: "token-B", + ProviderStyle: protocol.APIStyleAnthropic, + } + uuid2, action, err := upsertProviderByURL(am, second) + if err != nil { + t.Fatalf("second upsert: %v", err) + } + if action != "updated" { + t.Errorf("second action: got %q want %q", action, "updated") + } + if uuid1 != uuid2 { + t.Errorf("upsert created a duplicate: uuid1=%s uuid2=%s", uuid1, uuid2) + } + + // Confirm the in-place update actually took. + got, err := am.GetProvider(uuid1) + if err != nil || got == nil { + t.Fatalf("provider lookup after upsert failed: %v", err) + } + if got.Token != "token-B" { + t.Errorf("Token not updated: got %q", got.Token) + } + if got.APIStyle != protocol.APIStyleAnthropic { + t.Errorf("APIStyle not updated: got %q", got.APIStyle) + } + + // Still exactly one provider. + if got := len(am.ListProviders()); got != 1 { + t.Errorf("expected 1 provider after upsert, got %d", got) + } +} + +// TestCI_UpsertProvider_DifferentURLsCreateSeparate ensures two different +// URLs produce two distinct provider rows (no cross-contamination). +func TestCI_UpsertProvider_DifferentURLsCreateSeparate(t *testing.T) { + am := newTestAppManager(t) + + for _, url := range []string{"https://a.example.com/v1", "https://b.example.com/v1"} { + _, action, err := upsertProviderByURL(am, &ciSpec{ + ProviderURL: url, + ProviderToken: "tok", + ProviderStyle: protocol.APIStyleOpenAI, + }) + if err != nil { + t.Fatalf("upsert %s: %v", url, err) + } + if action != "created" { + t.Errorf("upsert %s: got action %q want created", url, action) + } + } + if got := len(am.ListProviders()); got != 2 { + t.Errorf("expected 2 providers, got %d", got) + } +} + +func TestRedactToken(t *testing.T) { + cases := []struct{ in, want string }{ + {"", "****"}, + {"short", "****"}, + {"12345678", "****"}, + {"sk-abcdefgh1234", "sk-a…1234"}, + } + for _, c := range cases { + if got := redactToken(c.in); got != c.want { + t.Errorf("redactToken(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/script/install-mirror.sh b/script/install-mirror.sh new file mode 100755 index 000000000..53bb1c242 --- /dev/null +++ b/script/install-mirror.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Mirror-aware variant of install.sh for mainland China networks. +# +# Defaults: +# nvm cloned from gitee.com/mirrors/nvm +# node fetched from npmmirror.com (Taobao) +# npm reg registry.npmmirror.com (Taobao) +# +# Override via env vars: +# NVM_GITEE_REPO git URL for the nvm mirror +# NVM_VERSION nvm tag/branch to checkout +# NODE_VERSION node version (e.g. 20, --lts) +# NODE_MIRROR NVM_NODEJS_ORG_MIRROR target +# NPM_REGISTRY npm registry to write into ~/.npmrc + +set -euo pipefail + +NVM_VERSION="${NVM_VERSION:-v0.40.1}" +NVM_GITEE_REPO="${NVM_GITEE_REPO:-https://gitee.com/mirrors/nvm.git}" +NODE_VERSION="${NODE_VERSION:---lts}" +NODE_MIRROR="${NODE_MIRROR:-https://npmmirror.com/mirrors/node/}" +NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}" + +NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + +log() { printf '\033[1;34m[install-mirror]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[install-mirror]\033[0m %s\n' "$*" >&2; } + +if [ -z "${BASH_VERSION:-}" ]; then + err "this script requires bash; re-run with: bash $0" + exit 2 +fi + +install_nvm() { + if [ -s "$NVM_DIR/nvm.sh" ]; then + log "nvm already installed at $NVM_DIR, skipping" + return + fi + log "cloning nvm ${NVM_VERSION} from ${NVM_GITEE_REPO}" + git clone --depth 1 --branch "$NVM_VERSION" "$NVM_GITEE_REPO" "$NVM_DIR" + ensure_profile_snippet +} + +# ensure_profile_snippet appends NVM_DIR sourcing to the user's shell rc so +# subsequent shells pick up nvm. nvm's official install.sh does this — since +# we're cloning manually, we replicate it. Idempotent: grep before append. +ensure_profile_snippet() { + local snippet + snippet="$(cat <<'EOF' +# nvm (added by tingly-box install-mirror.sh) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" +EOF + )" + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [ -f "$rc" ] || continue + if ! grep -q 'NVM_DIR="$HOME/.nvm"' "$rc"; then + log "appending nvm init to $rc" + printf '\n%s\n' "$snippet" >> "$rc" + fi + done +} + +load_nvm() { + export NVM_DIR + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" +} + +install_node() { + log "installing node (${NODE_VERSION}) from ${NODE_MIRROR}" + NVM_NODEJS_ORG_MIRROR="$NODE_MIRROR" nvm install "$NODE_VERSION" + nvm use "$NODE_VERSION" >/dev/null + log "node $(node -v) / npm $(npm -v)" +} + +configure_npm_registry() { + log "setting npm registry to ${NPM_REGISTRY}" + npm config set registry "$NPM_REGISTRY" +} + +install_tingly_box() { + log "installing tingly-box via npm (registry=${NPM_REGISTRY})" + npm install -g tingly-box + log "installed: $(tingly-box version 2>/dev/null || echo 'tingly-box')" +} + +install_nvm +load_nvm +install_node +configure_npm_registry +install_tingly_box + +log "done. open a new shell or run: . \"\$NVM_DIR/nvm.sh\"" diff --git a/script/install.sh b/script/install.sh new file mode 100755 index 000000000..3781d7c4c --- /dev/null +++ b/script/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Install nvm + LTS Node + tingly-box from official sources. +# For mainland China users, prefer install-mirror.sh. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/tingly-dev/tingly-box/main/script/install.sh | bash +# bash script/install.sh + +set -euo pipefail + +NVM_VERSION="${NVM_VERSION:-v0.40.1}" +NODE_VERSION="${NODE_VERSION:---lts}" + +log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[install]\033[0m %s\n' "$*" >&2; } + +# nvm needs bash/zsh; refuse plain sh so users get a clear message rather +# than a confusing array-syntax error 100 lines into the install. +if [ -z "${BASH_VERSION:-}" ]; then + err "this script requires bash; re-run with: bash $0" + exit 2 +fi + +install_nvm() { + if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then + log "nvm already installed at ${NVM_DIR:-$HOME/.nvm}, skipping" + return + fi + log "installing nvm ${NVM_VERSION}" + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" | bash +} + +load_nvm() { + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" +} + +install_node() { + log "installing node (${NODE_VERSION})" + nvm install "$NODE_VERSION" + nvm use "$NODE_VERSION" >/dev/null + log "node $(node -v) / npm $(npm -v)" +} + +install_tingly_box() { + log "installing tingly-box via npm" + npm install -g tingly-box + log "installed: $(tingly-box version 2>/dev/null || echo 'tingly-box')" +} + +install_nvm +load_nvm +install_node +install_tingly_box + +log "done. open a new shell or run: . \"\$NVM_DIR/nvm.sh\""