project

package
v8.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 8, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Index

Constants

View Source
const (

	// StoreRootEnv names the environment variable that overrides the configured
	// central store root with an explicit one, so a test harness or an agent can
	// point tk — `tk serve` above all — at a throwaway store without touching the
	// real one. It is an environment variable rather than a cobra flag on purpose:
	// pkg/ticket and internal/mcp resolve config directly and never see cobra's
	// flags, and the env is the lever a harness has over a subprocess it spawns.
	StoreRootEnv = "TK_STORE_ROOT"
)

Variables

This section is empty.

Functions

func CentralProjectDir

func CentralProjectDir(projectName string) (string, error)

CentralProjectDir returns <centralRoot>/tickets/<projectName>.

The name is checked before it is joined, not by each caller: filepath.Join cleans traversal segments rather than failing, so a name carrying "/" or ".." resolves a store outside the central root — and names reach here from config map keys, which the shared config replicates from other machines. This is the single bound; a caller that already knows the name is valid inherits it harmlessly.

func CentralRegistered

func CentralRegistered(cfg Config, name string) bool

CentralRegistered reports whether a project is registered with the central store. Presence in the merged config is not enough: `store: central` lives in the shared config alone — saveLocal writes only `path` per project once a central root is set — so a project whose shared config is missing (never cloned, or lost in a sync) merges to an entry with an empty store. Write authorization, read resolution, and the unregistered markers all key on this one predicate so they describe the same set of projects.

func CentralStoreRoot

func CentralStoreRoot() (string, error)

CentralStoreRoot returns the central ticket store root directory, or the TK_STORE_ROOT override when set — which is answered without reading any config file, so no configured store is touched. Returns an error if central_root is not configured — run `tk init` first.

func ConfigPath

func ConfigPath() (string, error)

ConfigPath returns ~/.ticket/config.yaml, or the override root's own .ticket/config.yaml when TK_STORE_ROOT is set. Keeping the local config inside the override root mirrors the production layout and keeps an isolated run from reading or writing the machine's shared one.

func ConfiguredRepoPath added in v8.1.0

func ConfiguredRepoPath(cfg Config, name string) (string, bool)

ConfiguredRepoPath resolves an exact project name to the repository path registered for it on this machine. Callers keep path handling as their fallback, so a name wins when it also names a relative filesystem path.

func DetectProjectPath

func DetectProjectPath(cwd string) string

DetectProjectPath returns git top-level directory if available; otherwise cwd.

func IsConfigured

func IsConfigured() bool

IsConfigured returns true if ~/.ticket/config.yaml exists and has central_root set, or if TK_STORE_ROOT is set — an override is configuration in itself, so an isolated run needs no local config file at all.

A set-but-unusable override still reports configured: it is a broken store root, not a missing configuration, and the CLI refuses it before this gate with an error naming the variable. Reporting it unconfigured here would send the reader to `tk init`, and falling through to the local config would resolve the real store.

func MigrateJournalDefaults

func MigrateJournalDefaults(cfg *Config) (flipped []string, changed bool)

MigrateJournalDefaults turns the commit journal on for registrations that never chose to be without it, once ever. `tk init` hardcoded auto_link and auto_close to false, so a project carrying both false holds that un-chosen default and is flipped to true; a mixed pair is a deliberate link-only or close-only choice and is left alone. The marker is what makes it once-ever — a user who disables journaling after the migration stays disabled.

It reports the projects it flipped, and separately whether the config needs saving: setting the marker is itself a change worth persisting, or the flip would run again on the next start and undo a later disable.

func ResolveName

func ResolveName(cfg Config, cwd string, explicit string) (name string, source string)

ResolveName resolves project name with precedence: 1) explicit override 2) config path mapping 3) git remote name 4) directory name

Names are sanitized to prevent path traversal (no ".." or path separators).

func ResolveWorkDir

func ResolveWorkDir(ticketsDir string, cfg Config) string

ResolveWorkDir returns the project's real repo working directory for a tickets directory: the `path` its config entry records, or "" when it records none — a project registered on another machine has no local path, and the local half of the config is the only thing that knows where the repo is.

There is nothing to fall back to. ticketsDir is always <centralRoot>/tickets/<project>, so its parent is the central tickets dir and never a repo; returning it would hand the caller a directory inside the ticket store, which for `tk ui` is the directory a spawned work session runs in. The caller decides what to do with "" — it knows which repo the resolution started from, and this does not.

func Save

func Save(cfg Config) error

Save writes the config to both local and shared files, splitting fields appropriately. Local gets top-level fields + per-project path and verify_timeout. Shared gets journal_defaults_migrated and per-project store, auto_link, auto_close, auto_retrospect, registered_at.

func SharedConfigPath

func SharedConfigPath() (string, error)

SharedConfigPath returns <central_root>/config.yaml.

func SpawnCommand

func SpawnCommand() (string, error)

SpawnCommand returns the TUI's spawn_command template, plus the error that made it unreadable. It reads ~/.ticket/config.yaml directly for the same reason VerifyAllow does: the template is handed to `sh -c`, so whoever supplies it runs code as the machine owner, and neither the synced shared config nor a TK_STORE_ROOT root the caller named may be that source.

An empty template is the caller's signal to use the built-in default, so a sandbox with no home config gets the machine owner's default rather than a template of its own.

func StoreRootOverride

func StoreRootOverride() (string, bool, error)

StoreRootOverride returns the TK_STORE_ROOT store root, whether the variable is set at all, and the error a set-but-unusable value is. A value that is not an absolute path is that error and never falls back to the configured root: silently resolving the real central store from a broken override is the exact failure the override exists to prevent.

Presence is tested with LookupEnv rather than by comparing the value to "", because the empty string is a set-but-unusable value, not an absent one: a harness exporting a variable that expanded to nothing has named a store root tk cannot resolve, and treating it as unset would resolve the configured one.

func ValidName

func ValidName(name string) bool

ValidName reports whether a project name is safe to join into a filesystem path. Names containing path separators or ".." traverse out of the root they are joined into, and filepath.Join cleans those segments instead of failing; "" and "." collapse onto the root itself, rooting a project store at a directory no project owns. Names reach here from config, git remotes, and directory basenames as well as from the project half of a namespaced ticket ID.

func VerifyAllow

func VerifyAllow() ([]string, error)

VerifyAllow returns the argv[0] allow-list a ticket's verify commands are checked against, plus the error that made it unreadable. It reads ~/.ticket/config.yaml directly rather than the merged config on purpose: the shared config lives inside the synced tickets repo, so whatever can push a malicious verify command there could widen the list meant to refuse it in the same push.

It reads the home path directly rather than ConfigPath for the same reason: TK_STORE_ROOT deliberately does not move this one setting. The override root belongs to whoever set the variable — a harness, or an agent — so relocating the allow-list there would let a sandbox widen it (`verify_allow: [sh]`), and a fresh sandbox with no config at all would silently restore the defaults over a list the machine owner had narrowed. The allow-list always comes from the machine owner's own config, so a sandbox can neither widen nor narrow it.

Three states are kept apart so the control fails closed. A local config that cannot be read or parsed — partial write, merge conflict markers, bad permissions — returns an empty list and the error, so nothing runs and the refusal can say why rather than silently restoring defaults over a list the user had narrowed. A `verify_allow` that is present but empty returns empty, which is how a user refuses everything. Only a genuinely absent key falls back to defaultVerifyAllow.

func VerifyTimeout added in v8.3.0

func VerifyTimeout(cfg Config, name string) (time.Duration, error)

VerifyTimeout returns the bound a project's verify commands run under, and the error a set-but-unusable value is. A project that is absent, or that sets no verify_timeout, returns 0 — no per-project bound, so RunVerify applies ticket.DefaultVerifyTimeout.

It fails closed: a value that does not parse, or that is not positive, is returned as an error naming the key and the value rather than as 0. The caller hands that error to RunVerify, which refuses every command with it, because silently restoring the default would run a suite under a bound the user had deliberately changed and record the overrun as a failed contract — the outcome the key exists to fix.

The value is taken from the merged config's project entry, which mergeConfigs fills from the local half alone: a verify_timeout in the shared config is ignored, on the same terms as verify_allow.

Reading the merged config means TK_STORE_ROOT relocates this key exactly as it relocates `path`, rather than pinning it to the home config the way verify_allow is pinned. That is the right seam: the bound decides how long an already-allow-listed program runs, not what runs, so whoever sets the override gains no program the right to run.

Types

type Config

type Config struct {
	CentralRoot string `yaml:"central_root,omitempty" json:"central_root,omitempty"`
	GitEmail    string `yaml:"git_email,omitempty" json:"git_email,omitempty"`
	GitName     string `yaml:"git_name,omitempty" json:"git_name,omitempty"`
	// DefaultStore decides nothing and never did since the central store became
	// the only topology: no code reads it to choose anything, and tk never
	// writes it. It is retained so an old config still loads and round-trips,
	// the same terms as ProjectConfig.Store. Not a hook to wire up — a second
	// store kind is what ticket/remove-local-tickets-3d72 removed.
	DefaultStore string `yaml:"default_store,omitempty" json:"default_store,omitempty"`
	SyncInterval string `yaml:"sync_interval,omitempty" json:"sync_interval,omitempty"`
	// SpawnCommand: read it via project.SpawnCommand(). The value here is the
	// merged one, whose local half is the override root's config under
	// TK_STORE_ROOT, and it exists for Save's round-trip only — never as a read
	// path, because the template is the string handed to `sh -c` as the machine
	// owner.
	SpawnCommand string `yaml:"spawn_command,omitempty" json:"spawn_command,omitempty"`
	// VerifyAllow: read it via project.VerifyAllow(), for the same reason and on
	// the same terms — merged from a half the override root may own, kept here so
	// Save round-trips it, and never a read path, because the list decides which
	// programs run as the machine owner.
	VerifyAllow VerifyAllowList `yaml:"verify_allow,omitempty" json:"verify_allow,omitempty"`
	// JournalDefaultsMigrated marks that MigrateJournalDefaults has run. It is a
	// shared-config field, alongside the auto_link/auto_close it decides, so the
	// one-time flip travels with the store instead of re-running on every machine
	// that reads it.
	JournalDefaultsMigrated bool                     `yaml:"journal_defaults_migrated,omitempty" json:"journal_defaults_migrated,omitempty"`
	Projects                map[string]ProjectConfig `yaml:"projects"`
}

Config stores tk project configuration (merged view of local + shared).

func Load

func Load() (Config, error)

Load reads both local (~/.ticket/config.yaml) and shared (<central_root>/config.yaml) configs, merging them into a single Config. Local fields (central_root, git_email, git_name, default_store, sync_interval, spawn_command, per-project path and verify_timeout) come from local config. Shared fields (journal_defaults_migrated, per-project store, auto_link, auto_close, auto_retrospect, registered_at) come from shared config. Missing files are not errors — returns what's available.

func (*Config) UnmarshalYAML

func (cfg *Config) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML decodes a config, then recovers the one distinction the yaml decoder erases on its own. yaml.v3 resolves an explicit null before it consults a field's Unmarshaler, so a bare `verify_allow:` — and `~`, and `null` — decodes to the same nil slice an absent key does, which VerifyAllow reads as unset and answers with the defaults. That fails the allow-list open on a reasonable spelling of "allow nothing": a written key is intent to refuse everything and must not collapse into absent. The raw node is the only thing left that tells the two apart.

func (*Config) UpsertProject

func (cfg *Config) UpsertProject(name string, project ProjectConfig)

UpsertProject inserts or updates a project entry.

type ProjectConfig

type ProjectConfig struct {
	Path string `yaml:"path,omitempty" json:"path,omitempty"`
	// VerifyTimeout bounds each of this project's verify commands, as a Go
	// duration ("300s", "5m"); read it via project.VerifyTimeout. Local-only
	// like Path, and for a stronger reason: the bound is a property of this
	// machine and its suite, and the shared config syncs over the same remote
	// that carries the ticket text a verify command comes from — a bound
	// settable from there would be settable by whoever wrote the command.
	VerifyTimeout string `yaml:"verify_timeout,omitempty" json:"verify_timeout,omitempty"`
	// Store is the registration marker. "central" is the only value tk writes
	// and the only one that means anything: tk resolves no other kind of store,
	// so an entry carrying an old `store: local` reads as an unregistered
	// project — the repo owns no store and every command says so, naming its
	// .tickets/ if it still has one. The field is kept rather than dropped so
	// those configs still load, and so a save round-trips whatever a project
	// entry already carried instead of rewriting another machine's config.
	//
	// CentralRegistered is the only reader that decides anything from it, and it
	// asks only whether the value is "central". The two others neither branch on
	// a value nor compare one: saveShared tests presence to decide whether a
	// project entry belongs in the shared config at all, and `tk status` prints
	// the raw string in its store column.
	Store     string `yaml:"store,omitempty" json:"store,omitempty"`
	AutoLink  bool   `yaml:"auto_link" json:"auto_link"`
	AutoClose bool   `yaml:"auto_close" json:"auto_close"`
	// AutoRetrospect fires `loom retrospect <id>` from the watch cycle when a
	// ticket is first seen done or closed. omitempty, unlike the two flags above:
	// it is a rare opt-in enabled by hand, and writing `auto_retrospect: false`
	// into every project entry on the next save would dirty the shared config for
	// a feature nobody asked for. `tk init` does not set it, and the journal
	// defaults migration does not touch it.
	AutoRetrospect bool   `yaml:"auto_retrospect,omitempty" json:"auto_retrospect,omitempty"`
	RegisteredAt   string `yaml:"registered_at,omitempty" json:"registered_at,omitempty"`
}

ProjectConfig stores per-project settings.

type VerifyAllowList

type VerifyAllowList []string

VerifyAllowList is the verify_allow setting. Absent and present-but-empty mean different things — fall back to the default, versus refuse everything — so it defines IsZero rather than letting omitempty collapse them: a save round-trip (project registration, for one) must not turn a user's explicit `verify_allow: []` back into an unset key, which would silently restore the defaults.

func (VerifyAllowList) IsZero

func (l VerifyAllowList) IsZero() bool

IsZero reports whether omitempty should drop the field. Only an absent list is dropped.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL