Files
pad/internal/cli/workspace.go
T
xarmian ba35908f2b fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612)

Verifying the guide's step 5 found it wrong for the people it is written
for. A project linked to a self-hosted server pins that server's url in
.pad.toml, and the pin wins over the global cloud mode, so
`pad workspace link <slug>` talked to the OLD server, found the slug and
printed "Already linked". With --url it still did, whenever Cloud had the
same slug, which is the usual case after an import.

- link is "already linked" only when the existing link's url equals the
  target. An explicit --url naming another server re-points it, and the
  output names the old and the new server.
- WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only
  workspace and url change, so agent_name, the [push] consent table and
  unknown keys survive every relink (link, switch and the three init
  paths). A file it cannot parse is refused and left as it was.
- Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`.
- Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile
  app's web view the route shows one line ("Open Pad in a browser to move a
  workspace") and both entry points are hidden. The entry points are one
  component carrying that gate, with a wiring test for each mount.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 09:24:50 -04:00

153 lines
5.0 KiB
Go

package cli
import (
"bytes"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// PadToml represents the per-project workspace link file.
type PadToml struct {
Workspace string `toml:"workspace"`
// URL is the base URL of the Pad server hosting this workspace (e.g.
// "https://app.getpad.dev" or a self-hosted remote). When set, it
// overrides the user's global ~/.pad/config.toml URL so the directory
// targets the right server regardless of which workspace the user's
// default config points at. Empty for local-mode workspaces (the
// default loopback server is implied). See BUG-1535.
URL string `toml:"url,omitempty"`
AgentName string `toml:"agent_name,omitempty"` // optional: identifies which AI agent is acting
// Push carries per-repository push/consent settings (PLAN-2613 S2).
// A pointer so an absent `[push]` table is distinguishable from one
// written with every field at its zero value — though for AutoArm the
// two mean the same thing (not opted in), the distinction keeps the
// door open for future push settings where it would matter, and lets
// PadTomlAutoArm answer without a second nil check leaking out.
Push *PadTomlPush `toml:"push,omitempty"`
}
// PadTomlPush is the `[push]` table in a repository's .pad.toml
// (PLAN-2613 S2, D4).
type PadTomlPush struct {
// AutoArm, when true, opts THIS repository's sessions into arming at
// connect — declaring consent to receive `pad push` notifications
// without an explicit in-session `pad session arm` (D4). It is the
// ONLY per-repo enabler, and it lives in a committed file on purpose:
// D4's "explicit file edit = deliberate act" is the whole consent
// story for the auto path. Default (absent/false) is not opted in —
// see ResolveAutoArm for how a per-user setting can veto a true here
// but nothing can turn arming on machine-wide.
AutoArm bool `toml:"auto_arm"`
}
// PadTomlAutoArm reports whether this .pad.toml opts the repository into
// auto-arm. Nil-safe: a nil *PadToml (no workspace linked) or a missing
// `[push]` table both read as "not opted in", so callers need no guard.
func (p *PadToml) PadTomlAutoArm() bool {
return p != nil && p.Push != nil && p.Push.AutoArm
}
// DetectWorkspace walks up the directory tree from cwd looking for .pad.toml.
func DetectWorkspace(flagOverride string) (string, error) {
if flagOverride != "" {
return flagOverride, nil
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
dir := cwd
for {
configPath := filepath.Join(dir, ".pad.toml")
if _, err := os.Stat(configPath); err == nil {
var cfg PadToml
if _, err := toml.DecodeFile(configPath, &cfg); err != nil {
return "", fmt.Errorf("parse %s: %w", configPath, err)
}
if cfg.Workspace != "" {
return cfg.Workspace, nil
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("no workspace linked. Run 'pad workspace init' to create one")
}
// LoadPadToml finds and reads the nearest .pad.toml by walking up from cwd.
// Returns nil if no .pad.toml is found (not an error).
func LoadPadToml() (*PadToml, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
}
dir := cwd
for {
configPath := filepath.Join(dir, ".pad.toml")
if _, err := os.Stat(configPath); err == nil {
var cfg PadToml
if _, err := toml.DecodeFile(configPath, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", configPath, err)
}
return &cfg, nil
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return nil, nil
}
// WriteWorkspaceLink writes a .pad.toml in the given directory.
//
// serverURL is the base URL of the Pad server hosting this workspace. Pass
// the empty string for local-mode workspaces; pass cfg.BaseURL() (or
// equivalently cfg.URL) for any non-local mode (remote, cloud) so that the
// directory targets the right server even when the user's global config
// points at a different one. See BUG-1535.
//
// An existing .pad.toml in dir is UPDATED, not replaced (TASK-3612): only
// `workspace` and `url` change, so `agent_name`, the `[push]` table (the
// repository's auto-arm consent) and any key a newer pad wrote survive a
// relink. Comments and key order are not preserved; values are.
func WriteWorkspaceLink(dir, slug, serverURL string) error {
path := filepath.Join(dir, ".pad.toml")
doc := map[string]any{}
if _, err := os.Stat(path); err == nil {
if _, err := toml.DecodeFile(path, &doc); err != nil {
// Refuse rather than overwrite: a file we cannot read may hold
// settings the caller would lose without being told.
return fmt.Errorf("parse %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return err
}
doc["workspace"] = slug
if serverURL != "" {
doc["url"] = serverURL
} else {
delete(doc, "url")
}
// Encode before touching the file, so a failed encode leaves it intact.
var buf bytes.Buffer
if err := toml.NewEncoder(&buf).Encode(doc); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}