···6 "fmt"
7 "net/http"
8 "os"
09 "path/filepath"
10 "runtime"
11 "runtime/debug"
12 "sort"
13 "strings"
0014 "time"
1516 "tangled.org/atscan.net/plcbundle/bundle"
···61 switch command {
62 case "fetch":
63 cmdFetch()
0064 case "rebuild":
65 cmdRebuild()
66 case "verify":
···9697Commands:
98 fetch Fetch next bundle from PLC directory
099 rebuild Rebuild index from existing bundle files
100 verify Verify bundle integrity
101 info Show bundle information
···237 } else {
238 fmt.Printf("\n✓ Already up to date!\n")
239 }
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000240}
241242// isEndOfDataError checks if the error indicates we've reached the end of available data
···6 "fmt"
7 "net/http"
8 "os"
9+ "os/signal"
10 "path/filepath"
11 "runtime"
12 "runtime/debug"
13 "sort"
14 "strings"
15+ "sync"
16+ "syscall"
17 "time"
1819 "tangled.org/atscan.net/plcbundle/bundle"
···64 switch command {
65 case "fetch":
66 cmdFetch()
67+ case "clone":
68+ cmdClone()
69 case "rebuild":
70 cmdRebuild()
71 case "verify":
···101102Commands:
103 fetch Fetch next bundle from PLC directory
104+ clone Clone bundles from remote HTTP endpoint
105 rebuild Rebuild index from existing bundle files
106 verify Verify bundle integrity
107 info Show bundle information
···243 } else {
244 fmt.Printf("\n✓ Already up to date!\n")
245 }
246+}
247+248+func cmdClone() {
249+ fs := flag.NewFlagSet("clone", flag.ExitOnError)
250+ workers := fs.Int("workers", 4, "number of concurrent download workers")
251+ verbose := fs.Bool("v", false, "verbose output")
252+ skipExisting := fs.Bool("skip-existing", true, "skip bundles that already exist locally")
253+ saveInterval := fs.Duration("save-interval", 5*time.Second, "interval to save index during download")
254+ fs.Parse(os.Args[2:])
255+256+ if fs.NArg() < 1 {
257+ fmt.Fprintf(os.Stderr, "Usage: plcbundle clone <remote-url> [options]\n")
258+ fmt.Fprintf(os.Stderr, "\nClone bundles from a remote plcbundle HTTP endpoint\n\n")
259+ fmt.Fprintf(os.Stderr, "Options:\n")
260+ fs.PrintDefaults()
261+ fmt.Fprintf(os.Stderr, "\nExample:\n")
262+ fmt.Fprintf(os.Stderr, " plcbundle clone https://plc.example.com\n")
263+ fmt.Fprintf(os.Stderr, " plcbundle clone https://plc.example.com --workers 8\n")
264+ os.Exit(1)
265+ }
266+267+ remoteURL := strings.TrimSuffix(fs.Arg(0), "/")
268+269+ // Create manager
270+ mgr, dir, err := getManager("")
271+ if err != nil {
272+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
273+ os.Exit(1)
274+ }
275+ defer mgr.Close()
276+277+ fmt.Printf("Cloning from: %s\n", remoteURL)
278+ fmt.Printf("Target directory: %s\n", dir)
279+ fmt.Printf("Workers: %d\n", *workers)
280+ fmt.Printf("(Press Ctrl+C to safely interrupt - progress will be saved)\n\n")
281+282+ // Set up signal handling
283+ ctx, cancel := context.WithCancel(context.Background())
284+ defer cancel()
285+286+ sigChan := make(chan os.Signal, 1)
287+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
288+289+ go func() {
290+ <-sigChan
291+ fmt.Printf("\n\n⚠️ Interrupt received! Finishing current downloads and saving progress...\n")
292+ cancel()
293+ }()
294+295+ // Set up progress bar
296+ var progress *ProgressBar
297+ var progressMu sync.Mutex
298+299+ // Clone with library
300+ result, err := mgr.CloneFromRemote(ctx, bundle.CloneOptions{
301+ RemoteURL: remoteURL,
302+ Workers: *workers,
303+ SkipExisting: *skipExisting,
304+ SaveInterval: *saveInterval,
305+ Verbose: *verbose,
306+ ProgressFunc: func(downloaded, total int, bytesDownloaded, bytesTotal int64) {
307+ progressMu.Lock()
308+ defer progressMu.Unlock()
309+310+ if progress == nil {
311+ progress = NewProgressBarWithBytes(total, bytesTotal)
312+ progress.showBytes = true
313+ }
314+ progress.SetWithBytes(downloaded, bytesDownloaded)
315+ },
316+ })
317+318+ if progress != nil {
319+ progress.Finish()
320+ }
321+322+ fmt.Printf("\n")
323+324+ if err != nil {
325+ fmt.Fprintf(os.Stderr, "Clone failed: %v\n", err)
326+ os.Exit(1)
327+ }
328+329+ // Display results
330+ if result.Interrupted {
331+ fmt.Printf("⚠️ Download interrupted by user\n")
332+ } else {
333+ fmt.Printf("✓ Clone complete in %s\n", result.Duration.Round(time.Millisecond))
334+ }
335+336+ fmt.Printf("\nResults:\n")
337+ fmt.Printf(" Remote bundles: %d\n", result.RemoteBundles)
338+ if result.Skipped > 0 {
339+ fmt.Printf(" Skipped (existing): %d\n", result.Skipped)
340+ }
341+ fmt.Printf(" Downloaded: %d\n", result.Downloaded)
342+ if result.Failed > 0 {
343+ fmt.Printf(" Failed: %d\n", result.Failed)
344+ }
345+ fmt.Printf(" Total size: %s\n", formatBytes(result.TotalBytes))
346+347+ if result.Duration.Seconds() > 0 && result.Downloaded > 0 {
348+ mbPerSec := float64(result.TotalBytes) / result.Duration.Seconds() / (1024 * 1024)
349+ bundlesPerSec := float64(result.Downloaded) / result.Duration.Seconds()
350+ fmt.Printf(" Average speed: %.1f MB/s (%.1f bundles/s)\n", mbPerSec, bundlesPerSec)
351+ }
352+353+ if result.Failed > 0 {
354+ fmt.Printf("\n⚠️ Failed bundles: ")
355+ for i, num := range result.FailedBundles {
356+ if i > 0 {
357+ fmt.Printf(", ")
358+ }
359+ if i > 10 {
360+ fmt.Printf("... and %d more", len(result.FailedBundles)-10)
361+ break
362+ }
363+ fmt.Printf("%06d", num)
364+ }
365+ fmt.Printf("\n")
366+ fmt.Printf("Re-run the clone command to retry failed bundles.\n")
367+ os.Exit(1)
368+ }
369+370+ if result.Interrupted {
371+ fmt.Printf("\n✓ Progress saved. Re-run the clone command to resume.\n")
372+ os.Exit(1)
373+ }
374+375+ fmt.Printf("\n✓ Clone complete!\n")
376}
377378// isEndOfDataError checks if the error indicates we've reached the end of available data