mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
This commit introduces a comprehensive Starlark scripting system that enables custom automation with access to all subdaemon RPCs (lnd, loop, pool, faraday, taproot-assets). Scripts use native LND macaroons for permission enforcement. Key features: - Starlark execution engine with sandboxed resource limits - Standard builtins: print, log, sleep, now, json_encode/decode - HTTP GET requests with URL allowlisting - Persistent KV store with bucket-based permissions - LND event subscriptions for long-running daemon scripts - Script CRUD operations with macaroon baking - Execution history and running script tracking - Complete CLI commands (litcli scripts ...) Database schema: - scripts: Store script definitions with macaroon permissions - script_executions: Audit trail for script runs - script_kv_store: Persistent key-value storage for scripts - running_scripts: Track currently running scripts Security model: - Each script has an LND macaroon baked with specific permissions - RPC calls from scripts include this macaroon in the header - LND/subdaemons validate permissions natively - URL and bucket allowlists validated at runtime
745 lines
15 KiB
Go
745 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/lightninglabs/lightning-terminal/litrpc"
|
|
"github.com/urfave/cli"
|
|
)
|
|
|
|
var scriptCommands = []cli.Command{
|
|
{
|
|
Name: "scripts",
|
|
ShortName: "scr",
|
|
Usage: "Manage Starlark scripts for automation",
|
|
Category: "Scripts",
|
|
Subcommands: []cli.Command{
|
|
createScriptCommand,
|
|
updateScriptCommand,
|
|
deleteScriptCommand,
|
|
getScriptCommand,
|
|
listScriptsCommand,
|
|
startScriptCommand,
|
|
stopScriptCommand,
|
|
listRunningScriptsCommand,
|
|
validateScriptCommand,
|
|
historyCommand,
|
|
kvCommands,
|
|
},
|
|
Description: `
|
|
Manage Starlark scripts that can automate LND operations. Scripts have access
|
|
to LND and subdaemon RPCs using native macaroon permissions.
|
|
|
|
Scripts can be:
|
|
- Action scripts: Run once with optional timeout
|
|
- Daemon scripts: Run continuously with event subscriptions
|
|
`,
|
|
},
|
|
}
|
|
|
|
var (
|
|
scriptNameFlag = cli.StringFlag{
|
|
Name: "name",
|
|
Usage: "The unique name for the script",
|
|
Required: true,
|
|
}
|
|
scriptFileFlag = cli.StringFlag{
|
|
Name: "file",
|
|
Usage: "Path to the Starlark script file",
|
|
}
|
|
scriptSourceFlag = cli.StringFlag{
|
|
Name: "source",
|
|
Usage: "Inline Starlark source code",
|
|
}
|
|
scriptDescFlag = cli.StringFlag{
|
|
Name: "description",
|
|
Usage: "Description of what the script does",
|
|
}
|
|
scriptPermsFlag = cli.StringSliceFlag{
|
|
Name: "perm",
|
|
Usage: "Permission in format 'entity:action' (can be " +
|
|
"specified multiple times)",
|
|
}
|
|
scriptTimeoutFlag = cli.UintFlag{
|
|
Name: "timeout",
|
|
Usage: "Execution timeout in seconds (0 = no timeout)",
|
|
Value: 0,
|
|
}
|
|
scriptMemoryFlag = cli.Uint64Flag{
|
|
Name: "max-memory",
|
|
Usage: "Maximum memory in bytes (default: 100MB)",
|
|
Value: 104857600,
|
|
}
|
|
scriptURLsFlag = cli.StringSliceFlag{
|
|
Name: "allowed-url",
|
|
Usage: "URL pattern the script can access (can be specified multiple times)",
|
|
}
|
|
scriptBucketsFlag = cli.StringSliceFlag{
|
|
Name: "allowed-bucket",
|
|
Usage: "KV bucket the script can access (can be specified multiple times)",
|
|
}
|
|
scriptArgsFlag = cli.StringFlag{
|
|
Name: "args",
|
|
Usage: "JSON-encoded arguments to pass to main()",
|
|
}
|
|
)
|
|
|
|
var createScriptCommand = cli.Command{
|
|
Name: "create",
|
|
ShortName: "c",
|
|
Usage: "Create a new script",
|
|
Action: createScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
scriptFileFlag,
|
|
scriptSourceFlag,
|
|
scriptDescFlag,
|
|
scriptPermsFlag,
|
|
scriptTimeoutFlag,
|
|
scriptMemoryFlag,
|
|
scriptURLsFlag,
|
|
scriptBucketsFlag,
|
|
},
|
|
Description: `
|
|
Create a new Starlark script with the specified permissions. The script source
|
|
can be provided via --file or --source.
|
|
|
|
Example:
|
|
litcli scripts create --name mybot --file bot.star \
|
|
--perm "lnrpc.Lightning:read" \
|
|
--perm "looprpc.SwapClient:write" \
|
|
--allowed-url "https://api.example.com/*"
|
|
`,
|
|
}
|
|
|
|
func createScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
// Get source from file or flag.
|
|
source, err := getScriptSource(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse permissions.
|
|
perms, err := parsePermissions(ctx.StringSlice("perm"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := &litrpc.CreateScriptRequest{
|
|
Name: ctx.String("name"),
|
|
Description: ctx.String("description"),
|
|
Source: source,
|
|
Permissions: perms,
|
|
TimeoutSecs: uint32(ctx.Uint("timeout")),
|
|
MaxMemoryBytes: ctx.Uint64("max-memory"),
|
|
AllowedUrls: ctx.StringSlice("allowed-url"),
|
|
AllowedBuckets: ctx.StringSlice("allowed-bucket"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.CreateScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var updateScriptCommand = cli.Command{
|
|
Name: "update",
|
|
ShortName: "u",
|
|
Usage: "Update an existing script",
|
|
Action: updateScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
scriptFileFlag,
|
|
scriptSourceFlag,
|
|
scriptDescFlag,
|
|
scriptPermsFlag,
|
|
scriptTimeoutFlag,
|
|
scriptMemoryFlag,
|
|
scriptURLsFlag,
|
|
scriptBucketsFlag,
|
|
},
|
|
Description: `
|
|
Update an existing script's source code or configuration.
|
|
Only provided fields will be updated.
|
|
`,
|
|
}
|
|
|
|
func updateScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.UpdateScriptRequest{
|
|
Name: ctx.String("name"),
|
|
Description: ctx.String("description"),
|
|
}
|
|
|
|
// Get source if provided.
|
|
if ctx.IsSet("file") || ctx.IsSet("source") {
|
|
source, err := getScriptSource(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Source = source
|
|
}
|
|
|
|
// Parse permissions if provided.
|
|
if ctx.IsSet("perm") {
|
|
perms, err := parsePermissions(ctx.StringSlice("perm"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Permissions = perms
|
|
}
|
|
|
|
if ctx.IsSet("timeout") {
|
|
req.TimeoutSecs = uint32(ctx.Uint("timeout"))
|
|
}
|
|
if ctx.IsSet("max-memory") {
|
|
req.MaxMemoryBytes = ctx.Uint64("max-memory")
|
|
}
|
|
if ctx.IsSet("allowed-url") {
|
|
req.AllowedUrls = ctx.StringSlice("allowed-url")
|
|
}
|
|
if ctx.IsSet("allowed-bucket") {
|
|
req.AllowedBuckets = ctx.StringSlice("allowed-bucket")
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.UpdateScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var deleteScriptCommand = cli.Command{
|
|
Name: "delete",
|
|
ShortName: "d",
|
|
Usage: "Delete a script",
|
|
Action: deleteScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
cli.BoolFlag{
|
|
Name: "delete-kv",
|
|
Usage: "Also delete the script's KV bucket data",
|
|
},
|
|
},
|
|
}
|
|
|
|
func deleteScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.DeleteScriptRequest{
|
|
Name: ctx.String("name"),
|
|
DeleteKvData: ctx.Bool("delete-kv"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
_, err = client.DeleteScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Script deleted successfully")
|
|
return nil
|
|
}
|
|
|
|
var getScriptCommand = cli.Command{
|
|
Name: "get",
|
|
ShortName: "g",
|
|
Usage: "Get a script by name",
|
|
Action: getScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
},
|
|
}
|
|
|
|
func getScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.GetScriptRequest{
|
|
Name: ctx.String("name"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.GetScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var listScriptsCommand = cli.Command{
|
|
Name: "list",
|
|
ShortName: "l",
|
|
Usage: "List all scripts",
|
|
Action: listScripts,
|
|
}
|
|
|
|
func listScripts(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.ListScripts(rpcCtx, &litrpc.ListScriptsRequest{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var startScriptCommand = cli.Command{
|
|
Name: "start",
|
|
ShortName: "s",
|
|
Usage: "Start a script",
|
|
Action: startScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
scriptArgsFlag,
|
|
},
|
|
Description: `
|
|
Start execution of a script. For action scripts (with timeout), the result
|
|
is returned when execution completes. For daemon scripts (no timeout), the
|
|
script runs in the background with any configured subscriptions.
|
|
|
|
Example:
|
|
litcli scripts start --name mybot --args '{"threshold": 0.8}'
|
|
`,
|
|
}
|
|
|
|
func startScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.StartScriptRequest{
|
|
Name: ctx.String("name"),
|
|
ArgsJson: ctx.String("args"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.StartScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var stopScriptCommand = cli.Command{
|
|
Name: "stop",
|
|
Usage: "Stop a running script",
|
|
Action: stopScript,
|
|
Flags: []cli.Flag{
|
|
scriptNameFlag,
|
|
},
|
|
}
|
|
|
|
func stopScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.StopScriptRequest{
|
|
Name: ctx.String("name"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
_, err = client.StopScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Script stopped successfully")
|
|
return nil
|
|
}
|
|
|
|
var listRunningScriptsCommand = cli.Command{
|
|
Name: "running",
|
|
ShortName: "r",
|
|
Usage: "List running scripts",
|
|
Action: listRunningScripts,
|
|
}
|
|
|
|
func listRunningScripts(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.ListRunningScripts(rpcCtx, &litrpc.ListRunningScriptsRequest{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var validateScriptCommand = cli.Command{
|
|
Name: "validate",
|
|
ShortName: "v",
|
|
Usage: "Validate a script without creating it",
|
|
Action: validateScript,
|
|
Flags: []cli.Flag{
|
|
scriptFileFlag,
|
|
scriptSourceFlag,
|
|
},
|
|
}
|
|
|
|
func validateScript(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
source, err := getScriptSource(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := &litrpc.ValidateScriptRequest{
|
|
Source: source,
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.ValidateScript(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
var historyCommand = cli.Command{
|
|
Name: "history",
|
|
Usage: "View script execution history",
|
|
Action: getHistory,
|
|
Flags: []cli.Flag{
|
|
cli.StringFlag{
|
|
Name: "name",
|
|
Usage: "Script name (optional, shows all if not specified)",
|
|
},
|
|
cli.UintFlag{
|
|
Name: "limit",
|
|
Usage: "Maximum number of entries to return",
|
|
Value: 50,
|
|
},
|
|
cli.UintFlag{
|
|
Name: "offset",
|
|
Usage: "Offset for pagination",
|
|
Value: 0,
|
|
},
|
|
},
|
|
}
|
|
|
|
func getHistory(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.GetExecutionHistoryRequest{
|
|
Name: ctx.String("name"),
|
|
Limit: uint32(ctx.Uint("limit")),
|
|
Offset: uint32(ctx.Uint("offset")),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.GetExecutionHistory(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
// KV store commands
|
|
var kvCommands = cli.Command{
|
|
Name: "kv",
|
|
ShortName: "k",
|
|
Usage: "Manage script KV store",
|
|
Subcommands: []cli.Command{
|
|
kvGetCommand,
|
|
kvPutCommand,
|
|
kvDeleteCommand,
|
|
kvListCommand,
|
|
},
|
|
}
|
|
|
|
var (
|
|
kvBucketFlag = cli.StringFlag{
|
|
Name: "bucket",
|
|
Usage: "Bucket name",
|
|
Required: true,
|
|
}
|
|
kvKeyFlag = cli.StringFlag{
|
|
Name: "key",
|
|
Usage: "Key name",
|
|
Required: true,
|
|
}
|
|
kvValueFlag = cli.StringFlag{
|
|
Name: "value",
|
|
Usage: "Value to store",
|
|
}
|
|
kvPrefixFlag = cli.StringFlag{
|
|
Name: "prefix",
|
|
Usage: "Key prefix filter",
|
|
}
|
|
)
|
|
|
|
var kvGetCommand = cli.Command{
|
|
Name: "get",
|
|
Usage: "Get a value from the KV store",
|
|
Action: kvGet,
|
|
Flags: []cli.Flag{
|
|
kvBucketFlag,
|
|
kvKeyFlag,
|
|
},
|
|
}
|
|
|
|
func kvGet(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.KVGetRequest{
|
|
Bucket: ctx.String("bucket"),
|
|
Key: ctx.String("key"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.KVGet(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !resp.Found {
|
|
fmt.Println("Key not found")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("%s\n", string(resp.Value))
|
|
return nil
|
|
}
|
|
|
|
var kvPutCommand = cli.Command{
|
|
Name: "put",
|
|
Usage: "Store a value in the KV store",
|
|
Action: kvPut,
|
|
Flags: []cli.Flag{
|
|
kvBucketFlag,
|
|
kvKeyFlag,
|
|
kvValueFlag,
|
|
},
|
|
}
|
|
|
|
func kvPut(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.KVPutRequest{
|
|
Bucket: ctx.String("bucket"),
|
|
Key: ctx.String("key"),
|
|
Value: []byte(ctx.String("value")),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
_, err = client.KVPut(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Value stored successfully")
|
|
return nil
|
|
}
|
|
|
|
var kvDeleteCommand = cli.Command{
|
|
Name: "delete",
|
|
Usage: "Delete a value from the KV store",
|
|
Action: kvDelete,
|
|
Flags: []cli.Flag{
|
|
kvBucketFlag,
|
|
kvKeyFlag,
|
|
},
|
|
}
|
|
|
|
func kvDelete(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.KVDeleteRequest{
|
|
Bucket: ctx.String("bucket"),
|
|
Key: ctx.String("key"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
_, err = client.KVDelete(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Value deleted successfully")
|
|
return nil
|
|
}
|
|
|
|
var kvListCommand = cli.Command{
|
|
Name: "list",
|
|
Usage: "List keys in a bucket",
|
|
Action: kvList,
|
|
Flags: []cli.Flag{
|
|
kvBucketFlag,
|
|
kvPrefixFlag,
|
|
},
|
|
}
|
|
|
|
func kvList(ctx *cli.Context) error {
|
|
clientConn, cleanup, err := connectClient(ctx, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
client := litrpc.NewScriptsClient(clientConn)
|
|
|
|
req := &litrpc.KVListRequest{
|
|
Bucket: ctx.String("bucket"),
|
|
Prefix: ctx.String("prefix"),
|
|
}
|
|
|
|
rpcCtx := getContext()
|
|
resp, err := client.KVList(rpcCtx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
printRespJSON(resp)
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func getScriptSource(ctx *cli.Context) (string, error) {
|
|
if ctx.IsSet("file") {
|
|
content, err := os.ReadFile(ctx.String("file"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read script file: %w", err)
|
|
}
|
|
return string(content), nil
|
|
}
|
|
|
|
if ctx.IsSet("source") {
|
|
return ctx.String("source"), nil
|
|
}
|
|
|
|
return "", fmt.Errorf("either --file or --source must be provided")
|
|
}
|
|
|
|
func parsePermissions(perms []string) ([]*litrpc.MacaroonPermission, error) {
|
|
result := make([]*litrpc.MacaroonPermission, 0, len(perms))
|
|
|
|
for _, perm := range perms {
|
|
// Parse "entity:action" format.
|
|
var entity, action string
|
|
n, err := fmt.Sscanf(perm, "%s:%s", &entity, &action)
|
|
if err != nil || n != 2 {
|
|
// Try splitting on colon.
|
|
parts := splitOnce(perm, ':')
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("invalid permission format '%s', "+
|
|
"expected 'entity:action'", perm)
|
|
}
|
|
entity = parts[0]
|
|
action = parts[1]
|
|
}
|
|
|
|
result = append(result, &litrpc.MacaroonPermission{
|
|
Entity: entity,
|
|
Action: action,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func splitOnce(s string, sep byte) []string {
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == sep {
|
|
return []string{s[:i], s[i+1:]}
|
|
}
|
|
}
|
|
return []string{s}
|
|
}
|
|
|
|
// For JSON output consistency.
|
|
func printScriptJSON(script *litrpc.Script) {
|
|
data, err := json.MarshalIndent(script, "", " ")
|
|
if err != nil {
|
|
fmt.Printf("Error formatting output: %v\n", err)
|
|
return
|
|
}
|
|
fmt.Println(string(data))
|
|
}
|