mirror of https://github.com/Marcusk19/dotctl
implement Phase 1 + 2: idempotent link, apply command, and install script (#43)
- Rewrite cmd/link.go with safe idempotent logic: skips correct symlinks, backs up real files to .dotctl.bak, respects --overwrite and --no-backup - Add cmd/apply.go: new bootstrap command that clones or pulls a dotfiles repo then runs the idempotent link logic with a summary - Add --overwrite and --no-backup persistent flags to root command - Remove unstable cmd/sync.go - Fix cmd/init.go gitignore to not exclude dotctl/config.yml (required for apply to work on fresh machines) - Add install.sh: detects OS/arch, downloads binary from GitHub releases, optionally runs dotctl apply <url> - Update README with Quick Start section covering both bootstrap methods - Rewrite test/link_test.go with 10 real-filesystem idempotency tests - Add test/apply_test.go with 5 tests covering dry-run, linking, and idempotency - Fix pre-existing TestInitCommand failure (missing MemMapFs setup) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>work
parent
084be9f565
commit
ce0d506e21
@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(applyCommand)
|
||||
}
|
||||
|
||||
var applyCommand = &cobra.Command{
|
||||
Use: "apply <repo-url>",
|
||||
Short: "Clone a dotfiles repo and link all tracked configs",
|
||||
Long: `apply clones a dotfiles repository (or pulls if it already exists),
|
||||
reads the dotctl config, and creates symlinks for all tracked configs.
|
||||
|
||||
Example:
|
||||
dotctl apply https://github.com/user/dotfiles.git`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runApplyCommand,
|
||||
}
|
||||
|
||||
func runApplyCommand(cmd *cobra.Command, args []string) {
|
||||
repoURL := args[0]
|
||||
dotfilePath := viper.GetString("dotfile-path")
|
||||
// strip trailing slash for consistency
|
||||
dotfilePath = filepath.Clean(dotfilePath)
|
||||
|
||||
// Step 1: Clone or pull
|
||||
stat, err := os.Stat(dotfilePath)
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s into %s...\n", repoURL, dotfilePath)
|
||||
if !DryRun {
|
||||
_, err = git.PlainClone(dotfilePath, false, &git.CloneOptions{
|
||||
URL: repoURL,
|
||||
Progress: cmd.OutOrStdout(),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Error: clone failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot stat %s: %v\n", dotfilePath, err)
|
||||
os.Exit(1)
|
||||
} else if stat.IsDir() {
|
||||
// Check if it's a git repo
|
||||
repo, err := git.PlainOpen(dotfilePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s exists but is not a git repository\n", dotfilePath)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Remove it or use a different --dotfile-path\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Pulling latest changes in %s...\n", dotfilePath)
|
||||
if !DryRun {
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot get worktree: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
err = w.Pull(&git.PullOptions{RemoteName: "origin"})
|
||||
if err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Warning: git pull failed (%v), continuing with local state\n", err)
|
||||
} else if err == git.NoErrAlreadyUpToDate {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Already up to date.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read config
|
||||
configPath := filepath.Join(dotfilePath, "dotctl", "config.yml")
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Error: cannot read config at %s: %v\n", configPath, err)
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Is this repo set up with dotctl? Run 'dotctl init' first.")
|
||||
os.Exit(1)
|
||||
}
|
||||
links := v.GetStringMapString("links")
|
||||
|
||||
if len(links) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No links configured in config.yml — nothing to link.")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Run idempotent link
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Linking dotfiles...")
|
||||
result := LinkDotfiles(cmd.OutOrStdout(), dotfilePath, links, Overwrite, NoBackup, DryRun)
|
||||
|
||||
// Step 4: Print summary
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\nDone! %d linked, %d skipped, %d backed up\n",
|
||||
result.Linked, result.Skipped, result.Backed)
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var remoteRepository string
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(syncCommand)
|
||||
syncCommand.Flags().StringVarP(
|
||||
&remoteRepository,
|
||||
"remote",
|
||||
"r",
|
||||
"",
|
||||
"URL of remote repository",
|
||||
)
|
||||
|
||||
viper.BindPFlag("dotctl-origin", syncCommand.Flags().Lookup("remote"))
|
||||
}
|
||||
|
||||
var syncCommand = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync dotfiles with git",
|
||||
Long: "TODO: add longer description",
|
||||
Run: runSyncCommand,
|
||||
}
|
||||
|
||||
func validateInput(input string) error {
|
||||
if input == "" {
|
||||
return errors.New("Missing input")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitAddFiles(worktree *git.Worktree, fs afero.Fs) error {
|
||||
dotfilepath := viper.GetString("dotfile-path")
|
||||
entries, err := afero.ReadDir(fs, dotfilepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == "dotctl" {
|
||||
continue
|
||||
}
|
||||
_, err = worktree.Add(entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSyncCommand(cmd *cobra.Command, args []string) {
|
||||
origin := viper.GetString("dotctl-origin")
|
||||
if origin == "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "No remote repository found")
|
||||
return
|
||||
}
|
||||
|
||||
dotfilepath := viper.GetString("dotfile-path")
|
||||
r, err := git.PlainOpen(dotfilepath)
|
||||
CheckIfError(err)
|
||||
|
||||
// check remotes and if origin does not exist
|
||||
// we need to create it
|
||||
list, err := r.Remotes()
|
||||
CheckIfError(err)
|
||||
|
||||
if len(list) == 0 {
|
||||
r.CreateRemote(&config.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{origin},
|
||||
})
|
||||
}
|
||||
|
||||
w, err := r.Worktree()
|
||||
CheckIfError(err)
|
||||
|
||||
username := promptui.Prompt{
|
||||
Label: "username",
|
||||
Validate: validateInput,
|
||||
}
|
||||
|
||||
password := promptui.Prompt{
|
||||
Label: "password",
|
||||
Validate: validateInput,
|
||||
HideEntered: true,
|
||||
Mask: '*',
|
||||
}
|
||||
|
||||
usernameVal, err := username.Run()
|
||||
CheckIfError(err)
|
||||
|
||||
passwordVal, err := password.Run()
|
||||
CheckIfError(err)
|
||||
|
||||
fmt.Println("Pulling from remote")
|
||||
|
||||
err = w.Pull(&git.PullOptions{
|
||||
RemoteName: "origin",
|
||||
Auth: &http.BasicAuth{
|
||||
Username: usernameVal,
|
||||
Password: passwordVal,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "successfully pulled from %s", origin)
|
||||
}
|
||||
|
||||
status, err := w.Status()
|
||||
if err != nil {
|
||||
log.Fatalln("Error getting status", err)
|
||||
}
|
||||
|
||||
if !status.IsClean() {
|
||||
fmt.Println("Changes detected, do you want to push them?")
|
||||
confirm := promptui.Prompt{
|
||||
Label: "commit and push changes",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
_, err := confirm.Run()
|
||||
if err != nil {
|
||||
fmt.Println("Will not push changes")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Pushing changes...")
|
||||
|
||||
err = gitAddFiles(w, FileSystem)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not add files: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
commitMessage := "backup " + time.Now().String()
|
||||
|
||||
commit, err := w.Commit(commitMessage, &git.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: "dotctl CLI",
|
||||
Email: "example@example.com",
|
||||
When: time.Now(),
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
|
||||
obj, err := r.CommitObject(commit)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Cannot commit: %s", err)
|
||||
}
|
||||
|
||||
fmt.Println(obj)
|
||||
|
||||
err = r.Push(&git.PushOptions{
|
||||
RemoteName: "origin",
|
||||
Auth: &http.BasicAuth{
|
||||
Username: usernameVal,
|
||||
Password: passwordVal,
|
||||
},
|
||||
})
|
||||
CheckIfError(err)
|
||||
}
|
||||
|
||||
// a pull deletes the dotctl config from the filesystem, need to recreate it
|
||||
rewriteConfig()
|
||||
}
|
||||
|
||||
func rewriteConfig() {
|
||||
fs := UseFilesystem()
|
||||
err := fs.MkdirAll(path.Join(DotfilePath, "dotctl"), 0755)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create dotfile structure: %s", error.Error(err))
|
||||
}
|
||||
|
||||
_, err = fs.Create(path.Join(DotfilePath, "dotctl/config"))
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Unable to create config file %w", err))
|
||||
}
|
||||
|
||||
err = viper.WriteConfig()
|
||||
if err != nil {
|
||||
fmt.Println("Error: could not write config: ", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO="Marcusk19/dotctl"
|
||||
BINARY="dotctl"
|
||||
|
||||
# Detect OS
|
||||
OS=$(uname -s)
|
||||
case "$OS" in
|
||||
Linux) OS="Linux" ;;
|
||||
Darwin) OS="Darwin" ;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect architecture
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="x86_64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
i386 | i686) ARCH="i386" ;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Determine archive name and format
|
||||
if [ "$OS" = "Windows" ]; then
|
||||
ARCHIVE="${BINARY}_${OS}_${ARCH}.zip"
|
||||
else
|
||||
ARCHIVE="${BINARY}_${OS}_${ARCH}.tar.gz"
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/latest/download/${ARCHIVE}"
|
||||
|
||||
echo "Downloading dotctl for ${OS}/${ARCH}..."
|
||||
echo "URL: ${DOWNLOAD_URL}"
|
||||
|
||||
# Download to a temp dir
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$ARCHIVE"
|
||||
|
||||
# Extract
|
||||
cd "$TMP_DIR"
|
||||
if [[ "$ARCHIVE" == *.tar.gz ]]; then
|
||||
tar xzf "$ARCHIVE"
|
||||
else
|
||||
unzip -q "$ARCHIVE"
|
||||
fi
|
||||
|
||||
# Determine install location
|
||||
if [ -w "/usr/local/bin" ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
else
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install binary
|
||||
mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
|
||||
chmod +x "$INSTALL_DIR/$BINARY"
|
||||
|
||||
echo ""
|
||||
echo "dotctl installed to $INSTALL_DIR/$BINARY"
|
||||
|
||||
# PATH hint if using ~/.local/bin
|
||||
if [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then
|
||||
if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
|
||||
echo ""
|
||||
echo "NOTE: Add ~/.local/bin to your PATH if not already done:"
|
||||
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run apply if a repo URL was provided
|
||||
if [ -n "${1:-}" ]; then
|
||||
echo ""
|
||||
echo "Running: dotctl apply $1"
|
||||
"$INSTALL_DIR/$BINARY" apply "$1"
|
||||
fi
|
||||
@ -0,0 +1,189 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Marcusk19/dotctl/cmd"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// initLocalGitRepo creates a temp directory and initializes it as a git repo.
|
||||
func initLocalGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
_, err := gogit.PlainInit(dir, false)
|
||||
require.NoError(t, err)
|
||||
return dir
|
||||
}
|
||||
|
||||
// writeApplyConfig writes a dotctl/config.yml with the given links map.
|
||||
func writeApplyConfig(t *testing.T, dir string, links map[string]string) {
|
||||
t.Helper()
|
||||
configDir := filepath.Join(dir, "dotctl")
|
||||
require.NoError(t, os.MkdirAll(configDir, 0755))
|
||||
|
||||
content := "links:\n"
|
||||
for name, path := range links {
|
||||
content += fmt.Sprintf(" %s: %s\n", name, path)
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.yml"), []byte(content), 0644))
|
||||
}
|
||||
|
||||
// resetGlobalState resets the global cmd flags to defaults.
|
||||
func resetGlobalState() {
|
||||
cmd.DryRun = false
|
||||
cmd.Overwrite = false
|
||||
cmd.NoBackup = false
|
||||
}
|
||||
|
||||
func TestApplyCommand_ExistingRepo_EmptyLinks(t *testing.T) {
|
||||
defer resetGlobalState()
|
||||
|
||||
repoDir := initLocalGitRepo(t)
|
||||
writeApplyConfig(t, repoDir, map[string]string{})
|
||||
|
||||
viper.Set("dotfile-path", repoDir)
|
||||
defer viper.Set("dotfile-path", "")
|
||||
|
||||
rootCmd := cmd.RootCmd
|
||||
buf := new(bytes.Buffer)
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"})
|
||||
|
||||
rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Pulling latest changes")
|
||||
assert.Contains(t, output, "No links configured")
|
||||
}
|
||||
|
||||
func TestApplyCommand_ExistingRepo_WithLinks_DryRun(t *testing.T) {
|
||||
defer resetGlobalState()
|
||||
|
||||
repoDir := initLocalGitRepo(t)
|
||||
targetRoot := t.TempDir()
|
||||
|
||||
nvimTarget := filepath.Join(targetRoot, ".config", "nvim")
|
||||
|
||||
writeApplyConfig(t, repoDir, map[string]string{
|
||||
"nvim": nvimTarget,
|
||||
})
|
||||
|
||||
// Create source directory in the repo
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755))
|
||||
|
||||
viper.Set("dotfile-path", repoDir)
|
||||
defer viper.Set("dotfile-path", "")
|
||||
|
||||
cmd.DryRun = true
|
||||
|
||||
rootCmd := cmd.RootCmd
|
||||
buf := new(bytes.Buffer)
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"})
|
||||
|
||||
rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Linking dotfiles...")
|
||||
assert.Contains(t, output, "would link")
|
||||
|
||||
// Verify no actual symlink was created
|
||||
_, err := os.Lstat(nvimTarget)
|
||||
assert.True(t, os.IsNotExist(err), "symlink should NOT be created in dry-run mode")
|
||||
}
|
||||
|
||||
func TestApplyCommand_ExistingRepo_WithLinks(t *testing.T) {
|
||||
defer resetGlobalState()
|
||||
|
||||
repoDir := initLocalGitRepo(t)
|
||||
targetRoot := t.TempDir()
|
||||
|
||||
nvimTarget := filepath.Join(targetRoot, ".config", "nvim")
|
||||
|
||||
writeApplyConfig(t, repoDir, map[string]string{
|
||||
"nvim": nvimTarget,
|
||||
})
|
||||
|
||||
// Create source directory in the repo
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755))
|
||||
|
||||
viper.Set("dotfile-path", repoDir)
|
||||
defer viper.Set("dotfile-path", "")
|
||||
|
||||
rootCmd := cmd.RootCmd
|
||||
buf := new(bytes.Buffer)
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"})
|
||||
|
||||
rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Linking dotfiles...")
|
||||
assert.Contains(t, output, "1 linked, 0 skipped, 0 backed up")
|
||||
|
||||
// Verify symlink was actually created
|
||||
linkTarget, err := os.Readlink(nvimTarget)
|
||||
require.NoError(t, err, "symlink should exist at target")
|
||||
assert.Equal(t, filepath.Join(repoDir, "nvim"), linkTarget)
|
||||
}
|
||||
|
||||
func TestApplyCommand_NoArgs(t *testing.T) {
|
||||
defer resetGlobalState()
|
||||
|
||||
rootCmd := cmd.RootCmd
|
||||
buf := new(bytes.Buffer)
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"apply"})
|
||||
|
||||
err := rootCmd.Execute()
|
||||
|
||||
assert.Error(t, err, "apply with no args should return an error")
|
||||
}
|
||||
|
||||
func TestApplyCommand_ExistingRepo_MultipleLinks_DryRun(t *testing.T) {
|
||||
defer resetGlobalState()
|
||||
|
||||
repoDir := initLocalGitRepo(t)
|
||||
targetRoot := t.TempDir()
|
||||
|
||||
nvimTarget := filepath.Join(targetRoot, ".config", "nvim")
|
||||
zshTarget := filepath.Join(targetRoot, ".zshrc")
|
||||
|
||||
writeApplyConfig(t, repoDir, map[string]string{
|
||||
"nvim": nvimTarget,
|
||||
"zsh": zshTarget,
|
||||
})
|
||||
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "nvim"), 0755))
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(repoDir, "zsh"), 0755))
|
||||
|
||||
viper.Set("dotfile-path", repoDir)
|
||||
defer viper.Set("dotfile-path", "")
|
||||
|
||||
cmd.DryRun = true
|
||||
|
||||
rootCmd := cmd.RootCmd
|
||||
buf := new(bytes.Buffer)
|
||||
rootCmd.SetOut(buf)
|
||||
rootCmd.SetErr(buf)
|
||||
rootCmd.SetArgs([]string{"apply", "https://fake.url/repo.git"})
|
||||
|
||||
rootCmd.Execute()
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "Linking dotfiles...")
|
||||
assert.Contains(t, output, "would link")
|
||||
assert.Contains(t, output, "Done!")
|
||||
}
|
||||
Loading…
Reference in New Issue