mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
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) 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
153 lines
5.0 KiB
Go
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)
|
|
}
|