mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 13:31:26 -10:00
* feat(plugins): add CLI enable/disable for plugin.json
Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.
* fix(plugins): serialize toggles with installs and preserve manifest bytes
Address review findings on the enable/disable CLI:
- Take the same cross-process installtxn lock plugins add/remove take on
the plugin's install root before the final lookup and read, and publish
plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
be overwritten with manifest bytes read before it committed, retrying
when precedence moves to the other root; a plugin removed under the
lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
order, formatting, and numeric literals survive; the old
decode/re-encode dropped precision (9007199254740993 became
9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
after a toggle, and leave pre-existing drift in place instead of
absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
error, and document project-over-user precedence in the help text.
* fix(plugins): recover interrupted install transactions before toggling
SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.
Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.
Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(plugins): say when a plugin toggle takes effect
Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.
State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key
Address the review findings on the plugin enable/disable toggle.
Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.
Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.
Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): keep the toggle on the pinned plugin directory throughout
The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.
Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(plugins): hash plugin trees through native os.Root calls
20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.
Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
553 lines
18 KiB
Go
553 lines
18 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Twigpine/zero/internal/installtxn"
|
|
"github.com/Twigpine/zero/internal/pathjail"
|
|
)
|
|
|
|
// SetEnabledResult describes a successful enable/disable of one plugin manifest.
|
|
type SetEnabledResult struct {
|
|
ID string `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
Changed bool `json:"changed"`
|
|
Source Source `json:"source"`
|
|
ManifestPath string `json:"manifestPath"`
|
|
}
|
|
|
|
// maxEnabledRelockAttempts bounds how many times SetEnabledByID re-resolves the
|
|
// plugin after it moves to another install root while waiting for the lock.
|
|
const maxEnabledRelockAttempts = 4
|
|
|
|
// FindByID returns the loaded plugin with the given id after normal discovery
|
|
// and precedence (project overrides user). A missing id returns ok=false.
|
|
func FindByID(options LoadOptions, id string) (LoadedPlugin, bool, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return LoadedPlugin{}, false, errors.New("plugin id is required")
|
|
}
|
|
result, err := Load(options)
|
|
if err != nil {
|
|
return LoadedPlugin{}, false, err
|
|
}
|
|
for _, plugin := range result.Plugins {
|
|
if plugin.ID == id {
|
|
return plugin, true, nil
|
|
}
|
|
}
|
|
return LoadedPlugin{}, false, nil
|
|
}
|
|
|
|
// SetEnabledByID finds a plugin by id (respecting LoadOptions) and toggles the
|
|
// top-level "enabled" field of its plugin.json.
|
|
//
|
|
// The read-modify-write is serialized with plugin installs and removals: it
|
|
// takes the same cross-process installtxn lock they take on the plugin's
|
|
// install root, runs the same interrupted-transaction recovery they run under
|
|
// it, re-resolves the plugin under that lock, and publishes the
|
|
// manifest with an atomic rename while still holding it, so a concurrent
|
|
// install can never be interleaved between the read and the write. Only the
|
|
// bytes of the "enabled" value change, so field order, formatting, and numeric
|
|
// literals in the rest of the manifest survive the toggle.
|
|
func SetEnabledByID(options LoadOptions, id string, enabled bool) (SetEnabledResult, error) {
|
|
for attempt := 0; ; attempt++ {
|
|
plugin, ok, err := FindByID(options, id)
|
|
if err != nil {
|
|
return SetEnabledResult{}, err
|
|
}
|
|
if !ok {
|
|
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
|
|
}
|
|
// Take the install root lock before the final lookup and read so an
|
|
// install or remove cannot swap the tree between them.
|
|
unlock, err := installtxn.Lock(plugin.Root)
|
|
if err != nil {
|
|
return SetEnabledResult{}, err
|
|
}
|
|
// Put back anything an earlier install or removal was killed mid-commit,
|
|
// and abort if a transaction could not be resolved: editing a tree that is
|
|
// still owed a restore loses the edit when the next install or removal
|
|
// puts the backup back over it. See installtxn.Recover.
|
|
if err := installtxn.Recover(plugin.Root, reconcileInterrupted(plugin.Root)); err != nil {
|
|
unlock()
|
|
return SetEnabledResult{}, err
|
|
}
|
|
final, ok, err := FindByID(options, id)
|
|
if err != nil {
|
|
unlock()
|
|
return SetEnabledResult{}, err
|
|
}
|
|
if !ok {
|
|
unlock()
|
|
return SetEnabledResult{}, fmt.Errorf("%w: %q", ErrNotInstalled, id)
|
|
}
|
|
if final.Root != plugin.Root {
|
|
// Precedence moved to another root while this lock was pending;
|
|
// retry against the root that wins now so the edit still serializes
|
|
// with that root's installs.
|
|
unlock()
|
|
if attempt >= maxEnabledRelockAttempts {
|
|
return SetEnabledResult{}, fmt.Errorf("plugin %q kept changing roots while waiting for the install lock", id)
|
|
}
|
|
continue
|
|
}
|
|
changed, err := setEnabledLocked(final, enabled)
|
|
unlock()
|
|
if err != nil {
|
|
return SetEnabledResult{}, err
|
|
}
|
|
return SetEnabledResult{
|
|
ID: final.ID,
|
|
Enabled: enabled,
|
|
Changed: changed,
|
|
Source: final.Source,
|
|
ManifestPath: final.ManifestPath,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// setEnabledLocked performs the manifest read-modify-write while the caller
|
|
// holds the installtxn lock for the plugin's install root.
|
|
//
|
|
// The lock only serializes cooperating installers; it does not stop the plugin
|
|
// directory from being swapped after discovery found it. So the whole update
|
|
// works on one directory, pinned by openPluginDir: the manifest is read and
|
|
// published, and the tree hashed for the lockfile, through that handle, never
|
|
// by re-resolving the path.
|
|
func setEnabledLocked(plugin LoadedPlugin, enabled bool) (bool, error) {
|
|
pinned, err := openPluginDir(plugin)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer pinned.Close()
|
|
return setEnabledPinned(plugin, pinned, enabled)
|
|
}
|
|
|
|
// setEnabledPinned is setEnabledLocked on an already pinned plugin directory.
|
|
//
|
|
// The edit lands in the pinned directory even if the path now names another
|
|
// one, so before refreshing the lockfile or reporting any result it confirms
|
|
// that the directory entry still is the pinned directory. Otherwise the
|
|
// command would report a state that the installed plugin does not have.
|
|
func setEnabledPinned(plugin LoadedPlugin, pinned *pinnedPluginDir, enabled bool) (bool, error) {
|
|
manifestName := filepath.Base(plugin.ManifestPath)
|
|
raw, perm, err := readManifest(pinned.dir, manifestName)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
data, changed, err := toggleEnabledManifest(raw, enabled)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !changed {
|
|
if err := pinned.verifyLive(); err != nil {
|
|
return false, err
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Read the lockfile before the edit so the refresh below only runs when the
|
|
// installed tree still matched its recorded hash; a toggle must never mask
|
|
// pre-existing drift.
|
|
lock, entry, hashClean := cleanLockEntry(plugin, pinned.dir)
|
|
|
|
if err := publishManifest(pinned.dir, manifestName, data, perm); err != nil {
|
|
return false, err
|
|
}
|
|
if !hashClean {
|
|
if err := pinned.verifyLive(); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
after, err := hashRootedTree(pinned.dir)
|
|
if err != nil {
|
|
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
|
|
}
|
|
if err := pinned.verifyLive(); err != nil {
|
|
return false, err
|
|
}
|
|
entry.Hash = after
|
|
lock[plugin.ID] = entry
|
|
if err := writeLock(plugin.Root, lock); err != nil {
|
|
return true, fmt.Errorf("plugin manifest updated but %s hash refresh failed: %w", LockFileName, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// errPluginDirReplaced reports that the plugin's directory entry no longer
|
|
// names the directory the toggle pinned and edited.
|
|
var errPluginDirReplaced = errors.New("plugin directory was replaced while it was being updated; the plugin installed there now was not changed")
|
|
|
|
// pinnedPluginDir is the plugin directory discovery found, held open for the
|
|
// length of one toggle.
|
|
type pinnedPluginDir struct {
|
|
root *os.Root // the plugin's install root
|
|
name string // the plugin directory's entry name in root
|
|
path string // the plugin directory's path, for messages
|
|
dir *os.Root // the plugin directory itself
|
|
}
|
|
|
|
// Close releases both handles.
|
|
func (p *pinnedPluginDir) Close() {
|
|
_ = p.dir.Close()
|
|
_ = p.root.Close()
|
|
}
|
|
|
|
// verifyLive confirms that the plugin's entry in its install root is still a
|
|
// real directory, not a symlink, junction, or other reparse point, and is the
|
|
// very directory p.dir holds open.
|
|
func (p *pinnedPluginDir) verifyLive() error {
|
|
opened, err := p.dir.Stat(".")
|
|
if err != nil {
|
|
return fmt.Errorf("inspect plugin directory: %w", err)
|
|
}
|
|
entry, err := p.root.Lstat(p.name)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect plugin directory: %w", err)
|
|
}
|
|
if entry.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
|
|
return fmt.Errorf("%w: plugin directory %s", pathjail.ErrReparse, p.path)
|
|
}
|
|
if !os.SameFile(entry, opened) {
|
|
return fmt.Errorf("%w: %s", errPluginDirReplaced, p.path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openPluginDir pins the plugin directory discovery found, confined to the
|
|
// plugin's install root.
|
|
//
|
|
// A link swapped in before the open is refused here. One swapped in after it
|
|
// cannot redirect anything, because every later read, write, and hash is
|
|
// relative to the returned handle rather than to a path; verifyLive then
|
|
// detects that the path no longer names the edited directory.
|
|
func openPluginDir(plugin LoadedPlugin) (*pinnedPluginDir, error) {
|
|
root, name, err := pathjail.Open(plugin.Root, plugin.PluginDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open plugin directory: %w", err)
|
|
}
|
|
if name == "." || filepath.Base(name) != name {
|
|
_ = root.Close()
|
|
return nil, fmt.Errorf("%w: plugin directory %s is not an entry of %s", pathjail.ErrEscapes, plugin.PluginDir, plugin.Root)
|
|
}
|
|
if err := pathjail.RefuseReparse(root, name); err != nil {
|
|
_ = root.Close()
|
|
return nil, fmt.Errorf("open plugin directory: %w", err)
|
|
}
|
|
dir, err := root.OpenRoot(name)
|
|
if err != nil {
|
|
_ = root.Close()
|
|
return nil, fmt.Errorf("open plugin directory: %w", err)
|
|
}
|
|
pinned := &pinnedPluginDir{root: root, name: name, path: plugin.PluginDir, dir: dir}
|
|
if err := pinned.verifyLive(); err != nil {
|
|
pinned.Close()
|
|
return nil, err
|
|
}
|
|
return pinned, nil
|
|
}
|
|
|
|
// readManifest reads name from dir and returns its bytes and permission bits.
|
|
// Failing on a stat error keeps the publish from widening the permissions.
|
|
func readManifest(dir *os.Root, name string) ([]byte, os.FileMode, error) {
|
|
file, err := dir.Open(name)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("inspect plugin manifest: %w", err)
|
|
}
|
|
raw, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("read plugin manifest: %w", err)
|
|
}
|
|
return raw, info.Mode().Perm(), nil
|
|
}
|
|
|
|
// publishManifest replaces name in dir with data by renaming a complete
|
|
// temporary sibling over it, so a concurrent reader sees either the old
|
|
// manifest or the new one, never a partial write.
|
|
func publishManifest(dir *os.Root, name string, data []byte, perm os.FileMode) error {
|
|
temp, tempName, err := pathjail.CreateTemp(dir, ".", ".zero-manifest", "")
|
|
if err != nil {
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
defer func() { _ = dir.Remove(tempName) }()
|
|
if err := temp.Chmod(perm); err != nil {
|
|
_ = temp.Close()
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
if _, err := temp.Write(data); err != nil {
|
|
_ = temp.Close()
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
_ = temp.Close()
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
if err := dir.Rename(tempName, name); err != nil {
|
|
return fmt.Errorf("write plugin manifest: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cleanLockEntry reports whether the pinned plugin directory dir still matches
|
|
// the lockfile entry recorded for the plugin and returns both for a refresh.
|
|
func cleanLockEntry(plugin LoadedPlugin, dir *os.Root) (map[string]LockEntry, LockEntry, bool) {
|
|
lock, err := ReadLock(plugin.Root)
|
|
if err != nil {
|
|
return nil, LockEntry{}, false
|
|
}
|
|
entry, ok := lock[plugin.ID]
|
|
if !ok {
|
|
return nil, LockEntry{}, false
|
|
}
|
|
current, err := hashRootedTree(dir)
|
|
if err != nil || current != entry.Hash {
|
|
return nil, LockEntry{}, false
|
|
}
|
|
return lock, entry, true
|
|
}
|
|
|
|
// toggleEnabledManifest returns raw with the top-level "enabled" member set to
|
|
// enabled. A missing or null value counts as enabled, matching the loader's
|
|
// optionalBool fallback. changed reports whether the result differs from raw.
|
|
func toggleEnabledManifest(raw []byte, enabled bool) (out []byte, changed bool, err error) {
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
openTok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
if delim, ok := openTok.(json.Delim); !ok || delim != '{' {
|
|
return nil, false, errors.New("plugin manifest must be a JSON object")
|
|
}
|
|
openEnd := dec.InputOffset()
|
|
|
|
current := true
|
|
lastInvalid := false
|
|
var valueStart, valueEnd int64 = -1, -1
|
|
for dec.More() {
|
|
keyTok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
key, ok := keyTok.(string)
|
|
if !ok {
|
|
return nil, false, errors.New("plugin manifest must be a JSON object")
|
|
}
|
|
keyEnd := dec.InputOffset()
|
|
if key != "enabled" {
|
|
if err := skipManifestValue(dec); err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
valueTok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
var literal string
|
|
switch value := valueTok.(type) {
|
|
case bool:
|
|
current = value
|
|
literal = "false"
|
|
if value {
|
|
literal = "true"
|
|
}
|
|
case nil:
|
|
current = true
|
|
literal = "null"
|
|
default:
|
|
// Only the last occurrence of a duplicate key counts, as in the
|
|
// loader's json.Unmarshal, so an earlier non-boolean value that a
|
|
// later one overwrites is not an error. Remember it and decide after
|
|
// the last member.
|
|
if err := skipManifestComposite(dec, valueTok); err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
lastInvalid = true
|
|
continue
|
|
}
|
|
lastInvalid = false
|
|
start, err := enabledValueOffset(raw, keyEnd)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if start+int64(len(literal)) > int64(len(raw)) ||
|
|
!bytes.Equal(raw[start:start+int64(len(literal))], []byte(literal)) {
|
|
return nil, false, errors.New("parse plugin manifest: cannot locate the enabled value")
|
|
}
|
|
// A duplicate key keeps the last occurrence, matching json.Unmarshal.
|
|
valueStart, valueEnd = start, start+int64(len(literal))
|
|
}
|
|
closingTok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("parse plugin manifest: %w", err)
|
|
}
|
|
if delim, ok := closingTok.(json.Delim); !ok || delim != '}' {
|
|
return nil, false, errors.New("plugin manifest must be a JSON object")
|
|
}
|
|
closingEnd := dec.InputOffset()
|
|
if _, err := dec.Token(); err != io.EOF {
|
|
return nil, false, errors.New("parse plugin manifest: unexpected data after the top-level object")
|
|
}
|
|
if lastInvalid {
|
|
return nil, false, ManifestError{FieldPath: "enabled", Message: "Expected a boolean."}
|
|
}
|
|
|
|
if current == enabled {
|
|
return nil, false, nil
|
|
}
|
|
newLiteral := "false"
|
|
if enabled {
|
|
newLiteral = "true"
|
|
}
|
|
if valueStart >= 0 {
|
|
out := make([]byte, 0, len(raw)-int(valueEnd-valueStart)+len(newLiteral))
|
|
out = append(out, raw[:valueStart]...)
|
|
out = append(out, newLiteral...)
|
|
out = append(out, raw[valueEnd:]...)
|
|
return out, true, nil
|
|
}
|
|
return insertEnabledMember(raw, openEnd, closingEnd-1, newLiteral), true, nil
|
|
}
|
|
|
|
// enabledValueOffset returns the byte offset of the value that follows the
|
|
// colon after an "enabled" key token that ended at keyEnd.
|
|
func enabledValueOffset(raw []byte, keyEnd int64) (int64, error) {
|
|
i := keyEnd
|
|
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
|
|
i++
|
|
}
|
|
if i >= int64(len(raw)) || raw[i] != ':' {
|
|
return 0, errors.New("parse plugin manifest: missing colon after key")
|
|
}
|
|
i++
|
|
for i < int64(len(raw)) && isJSONSpace(raw[i]) {
|
|
i++
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
// insertEnabledMember adds a top-level "enabled" member to a manifest that has
|
|
// none, mirroring the document's own whitespace style where it can. openEnd is
|
|
// the byte offset just after the opening brace and bracePos is the byte index
|
|
// of the closing brace.
|
|
func insertEnabledMember(raw []byte, openEnd, bracePos int64, literal string) []byte {
|
|
insertPos := bracePos
|
|
for insertPos > openEnd && isJSONSpace(raw[insertPos-1]) {
|
|
insertPos--
|
|
}
|
|
|
|
newline := "\n"
|
|
if bytes.Contains(raw, []byte("\r\n")) {
|
|
newline = "\r\n"
|
|
}
|
|
|
|
first := openEnd
|
|
for first < bracePos && isJSONSpace(raw[first]) {
|
|
first++
|
|
}
|
|
empty := first >= bracePos
|
|
multiline := bytes.IndexByte(raw[openEnd:bracePos], '\n') >= 0
|
|
|
|
member := `"enabled": ` + literal
|
|
var insert string
|
|
switch {
|
|
case empty && !multiline:
|
|
insert = member
|
|
case empty && multiline:
|
|
insert = newline + closingLineIndent(raw, openEnd, bracePos) + " " + member
|
|
case !empty && !multiline:
|
|
insert = "," + member
|
|
default:
|
|
insert = "," + newline + memberLineIndent(raw, openEnd, first) + member
|
|
}
|
|
|
|
out := make([]byte, 0, len(raw)+len(insert))
|
|
out = append(out, raw[:insertPos]...)
|
|
out = append(out, insert...)
|
|
out = append(out, raw[insertPos:]...)
|
|
return out
|
|
}
|
|
|
|
// memberLineIndent returns the indentation of the line the first member of a
|
|
// JSON object starts on. first is the byte index of that member's first token.
|
|
func memberLineIndent(raw []byte, openEnd, first int64) string {
|
|
lineStart := first
|
|
for lineStart > openEnd && raw[lineStart-1] != '\n' {
|
|
lineStart--
|
|
}
|
|
end := lineStart
|
|
for end < first && (raw[end] == ' ' || raw[end] == '\t') {
|
|
end++
|
|
}
|
|
return string(raw[lineStart:end])
|
|
}
|
|
|
|
// closingLineIndent returns the indentation before an object's closing brace.
|
|
func closingLineIndent(raw []byte, openEnd, bracePos int64) string {
|
|
start := bracePos
|
|
for start > openEnd && (raw[start-1] == ' ' || raw[start-1] == '\t') {
|
|
start--
|
|
}
|
|
return string(raw[start:bracePos])
|
|
}
|
|
|
|
// skipManifestValue consumes the next JSON value from dec.
|
|
func skipManifestValue(dec *json.Decoder) error {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return skipManifestComposite(dec, tok)
|
|
}
|
|
|
|
// skipManifestComposite consumes the rest of the value whose first token tok
|
|
// was just read from dec. A scalar is already complete; an object or array is
|
|
// consumed through its matching closing delimiter.
|
|
func skipManifestComposite(dec *json.Decoder, tok json.Token) error {
|
|
delim, ok := tok.(json.Delim)
|
|
if !ok || (delim != '{' && delim != '[') {
|
|
return nil
|
|
}
|
|
depth := 1
|
|
for depth > 0 {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inner, ok := tok.(json.Delim); ok {
|
|
switch inner {
|
|
case '{', '[':
|
|
depth++
|
|
case '}', ']':
|
|
depth--
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isJSONSpace(b byte) bool {
|
|
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
|
}
|