@domino-sdk/relay-cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@
20
20
  {
21
21
  "id": "connected-accounts",
22
22
  "support": "native",
23
- "description": "Operators connect a Discord server and install the managed bot in Console Integrations. Participants sign in with Discord or link its identity. Declare integrations: [discord] and use ctx.discord.member() without a server ID. Captured membership and role reads survive recovery. No messages, writes, or X verification.",
23
+ "description": "Project-scoped Discord membership, roles, nickname, display name, boosting status and captured message reads. Participants link identity; operators install the bot once. No arbitrary Discord writes or X verification.",
24
24
  "entrypoints": [
25
25
  "auth.accounts",
26
26
  "auth.start",
@@ -43,33 +43,72 @@
43
43
  "apps/api/src/identity-integration.ts"
44
44
  ]
45
45
  },
46
+ {
47
+ "id": "observation-quests",
48
+ "support": "native",
49
+ "description": "Trusted backend observations and Discord reaction.add, reaction.received and message.create trigger automatic quests. Actor identity is separate from beneficiary membership. Keyed completions and atomic budgets prevent duplicate awards. Multi-action counters and streaks are not implemented.",
50
+ "entrypoints": [
51
+ "automatic",
52
+ "keyed",
53
+ "budget",
54
+ "management.observations.submit"
55
+ ],
56
+ "reference": "authoring",
57
+ "evidence": [
58
+ "tests/observation-quests.test.mjs",
59
+ "tests/discord-observations.test.mjs"
60
+ ]
61
+ },
46
62
  {
47
63
  "id": "quests",
48
64
  "support": "native",
49
65
  "description": "Claim, photo, quiz, staff and automatic quests; immutable releases pin attempts and promised rewards.",
50
- "entrypoints": ["quests.experience", "quests.observe", "quests.open"],
51
- "evidence": ["tests/quest-catalog.test.mjs", "tests/submissions.test.mjs"]
66
+ "entrypoints": [
67
+ "quests.experience",
68
+ "quests.observe",
69
+ "quests.open"
70
+ ],
71
+ "evidence": [
72
+ "tests/quest-catalog.test.mjs",
73
+ "tests/submissions.test.mjs"
74
+ ]
52
75
  },
53
76
  {
54
77
  "id": "points",
55
78
  "support": "native",
56
79
  "description": "Award points to the participant completing a quest. The server applies completion and rewards; the browser cannot write balances.",
57
- "entrypoints": ["points", "decision.accept", "me.observeProgress"],
58
- "evidence": ["tests/journey.test.mjs"]
80
+ "entrypoints": [
81
+ "points",
82
+ "decision.accept",
83
+ "me.observeProgress"
84
+ ],
85
+ "evidence": [
86
+ "tests/journey.test.mjs"
87
+ ]
59
88
  },
60
89
  {
61
90
  "id": "review",
62
91
  "support": "native",
63
92
  "description": "Queue a quest attempt for an authorized human reviewer. Review decisions and reward execution remain server-owned.",
64
- "entrypoints": ["decision.review"],
65
- "evidence": ["tests/photo-review.test.mjs"]
93
+ "entrypoints": [
94
+ "decision.review"
95
+ ],
96
+ "evidence": [
97
+ "tests/photo-review.test.mjs"
98
+ ]
66
99
  },
67
100
  {
68
101
  "id": "milestones",
69
102
  "support": "composed",
70
103
  "description": "Compose same-participant completion prerequisites with requires: [completed(quest)]. Referral programs support milestones based on distinct qualified friends. Other aggregate thresholds need application logic.",
71
- "entrypoints": ["completed", "automatic", "defineQuest"],
72
- "evidence": ["tests/availability.test.mjs"]
104
+ "entrypoints": [
105
+ "completed",
106
+ "automatic",
107
+ "defineQuest"
108
+ ],
109
+ "evidence": [
110
+ "tests/availability.test.mjs"
111
+ ]
73
112
  },
74
113
  {
75
114
  "id": "referrals",
@@ -83,15 +122,24 @@
83
122
  "referrals.history"
84
123
  ],
85
124
  "reference": "referrals",
86
- "evidence": ["tests/referrals.test.mjs", "packages/sdk/src/referrals.ts"]
125
+ "evidence": [
126
+ "tests/referrals.test.mjs",
127
+ "packages/sdk/src/referrals.ts"
128
+ ]
87
129
  },
88
130
  {
89
131
  "id": "cross-participant-rewards",
90
132
  "support": "native",
91
133
  "description": "Referral programs can reward inviter and invitee independently and award a selective percentage of eligible points to the inviter. Bonuses accumulate fractions and support limits, suspension and correction recovery. Arbitrary participant-to-participant transfers and multi-level commissions are not supported.",
92
- "entrypoints": ["defineReferral", "referrals.summary"],
134
+ "entrypoints": [
135
+ "defineReferral",
136
+ "referrals.summary"
137
+ ],
93
138
  "reference": "referrals",
94
- "evidence": ["tests/referrals.test.mjs", "apps/api/src/referrals.ts"]
139
+ "evidence": [
140
+ "tests/referrals.test.mjs",
141
+ "apps/api/src/referrals.ts"
142
+ ]
95
143
  },
96
144
  {
97
145
  "id": "rankings",
@@ -111,8 +159,44 @@
111
159
  "id": "wallet-verification",
112
160
  "support": "native",
113
161
  "description": "Connect a wallet and verify supported Solana balances. This reads a real network; it does not send assets or execute payouts.",
114
- "entrypoints": ["wallet.connection", "wallet.challenge", "wallet.verify"],
115
- "evidence": ["tests/wallet.test.mjs"]
162
+ "entrypoints": [
163
+ "wallet.connection",
164
+ "wallet.challenge",
165
+ "wallet.verify"
166
+ ],
167
+ "evidence": [
168
+ "tests/wallet.test.mjs"
169
+ ]
170
+ },
171
+ {
172
+ "id": "resource-settings",
173
+ "support": "native",
174
+ "description": "Reusable Console resource and date/time fields. Discord channels resolve from the connected project integration; backend publication validates access. Empty resource settings keep code-authored quests paused.",
175
+ "entrypoints": [
176
+ "settings.resource",
177
+ "settings.datetime",
178
+ "discord.channels",
179
+ "discord.roles"
180
+ ],
181
+ "reference": "authoring",
182
+ "evidence": [
183
+ "tests/resource-settings.test.mjs"
184
+ ]
185
+ },
186
+ {
187
+ "id": "discord-role-rewards",
188
+ "support": "native",
189
+ "description": "Quest-declared Discord role rewards commit durable delivery requests with completion, retry independently, and remain bound to the original account/server. Role selectors validate assignability. Referral reward lists do not accept integration rewards.",
190
+ "entrypoints": [
191
+ "discordRole",
192
+ "me.rewardDeliveries",
193
+ "management.rewardDeliveries"
194
+ ],
195
+ "reference": "authoring",
196
+ "evidence": [
197
+ "tests/discord-role-delivery.test.mjs",
198
+ "tests/discord-expansion.test.mjs"
199
+ ]
116
200
  }
117
201
  ]
118
202
  }
@@ -2,6 +2,7 @@ import { Option } from "commander";
2
2
  import { dirname } from "node:path";
3
3
  import { action } from "../runtime.mjs";
4
4
  import { installAgentSkills } from "../agents.mjs";
5
+ import { canPrompt, promptSelect } from "../prompts.mjs";
5
6
  import { diagnose } from "../doctor.mjs";
6
7
 
7
8
  export function registerAgentCommands(program) {
@@ -17,18 +18,29 @@ export function registerAgentCommands(program) {
17
18
  "Project root (defaults to the linked project or current directory)",
18
19
  )
19
20
  .addOption(
20
- new Option("--agent <name>", "Agent to configure")
21
- .choices(["codex", "claude", "both"])
22
- .default("both"),
21
+ new Option("--agent <name>", "Agent to configure").choices([
22
+ "codex",
23
+ "claude",
24
+ "both",
25
+ ]),
23
26
  )
24
27
  .action(
25
- action(({ project, options }) =>
26
- installAgentSkills(
28
+ action(async ({ project, options }) => {
29
+ const agent =
30
+ options.agent ??
31
+ (canPrompt(options)
32
+ ? await promptSelect("Choose your coding agent", [
33
+ { label: "Codex", value: "codex" },
34
+ { label: "Claude", value: "claude" },
35
+ { label: "Both", value: "both" },
36
+ ])
37
+ : "both");
38
+ return installAgentSkills(
27
39
  options.directory ??
28
40
  (project ? dirname(project.path) : process.cwd()),
29
- options.agent,
30
- ),
31
- ),
41
+ agent,
42
+ );
43
+ }),
32
44
  );
33
45
 
34
46
  program
@@ -4,6 +4,8 @@ import { dirname, resolve } from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { action } from "../runtime.mjs";
7
+ import { canPrompt, promptText, promptSelect } from "../prompts.mjs";
8
+ import { selectProject } from "../select-project.mjs";
7
9
  import { request } from "../connection.mjs";
8
10
  import { checkout, requireNewDirectory } from "../git.mjs";
9
11
  import { dev } from "../dev.mjs";
@@ -69,20 +71,54 @@ export function registerHostingCommands(program) {
69
71
  program
70
72
  .command("create")
71
73
  .description("Create a hosted campaign repository and check it out locally")
72
- .argument("<name>", "Project identifier")
74
+ .argument("[name]", "Project identifier (prompted if omitted)")
73
75
  .option("--directory <path>", "Local directory (defaults to project name)")
74
76
  .action(
75
77
  action(async ({ connection, options }, name) => {
76
- if (!/^[a-zA-Z0-9_-]+$/.test(name))
77
- throw new Error(
78
- "Use letters, numbers, underscores, or hyphens for the project name.",
79
- );
80
- const directory = resolve(options.directory ?? name);
78
+ const interactive = canPrompt(options);
79
+ const validateName = (value) =>
80
+ /^[a-zA-Z0-9_-]+$/.test(value)
81
+ ? undefined
82
+ : "Use letters, numbers, underscores, or hyphens for the project name.";
83
+ if (!name && interactive)
84
+ name = await promptText("Project name", { validate: validateName });
85
+ if (!name)
86
+ throw new Error("Provide a project name: domino create NAME.");
87
+ const nameError = validateName(name);
88
+ if (nameError) throw new Error(nameError);
89
+ const destination =
90
+ options.directory ??
91
+ (interactive
92
+ ? await promptText("Destination directory", {
93
+ defaultValue: name,
94
+ validate: async (value) => {
95
+ try {
96
+ await requireNewDirectory(resolve(value));
97
+ } catch (error) {
98
+ return error.message;
99
+ }
100
+ },
101
+ })
102
+ : name);
103
+ const directory = resolve(destination);
81
104
  await requireNewDirectory(directory);
82
105
  const access = await request(connection, "/access");
83
- const organization = connection.organization || access.organization;
106
+ let organization = connection.organization || access.organization;
107
+ if (!organization && interactive) {
108
+ const organizations = [
109
+ ...new Set(
110
+ access.projects.map((item) => item.organization).filter(Boolean),
111
+ ),
112
+ ];
113
+ organization = organizations.length
114
+ ? await promptSelect(
115
+ "Choose an organization",
116
+ organizations.map((id) => ({ label: id, value: id })),
117
+ )
118
+ : await promptText("Organization ID");
119
+ }
84
120
  if (!organization)
85
- throw new Error("Select an organization with --organization.");
121
+ throw new Error("Select an organization with --organization ID.");
86
122
  const scoped = {
87
123
  ...connection,
88
124
  organization,
@@ -101,13 +137,31 @@ export function registerHostingCommands(program) {
101
137
  .description(
102
138
  "Recover or clone a hosted campaign with Domino Git authentication",
103
139
  )
104
- .argument("<project>")
140
+ .argument(
141
+ "[project]",
142
+ "Project identifier (choose interactively if omitted)",
143
+ )
105
144
  .option("--directory <path>", "Local directory (defaults to project name)")
106
145
  .action(
107
146
  action(async ({ connection, options }, project) => {
108
- const scoped = { ...connection, project, environment: "test" };
147
+ const scoped = {
148
+ ...connection,
149
+ project: project ?? connection.project,
150
+ environment: "test",
151
+ };
152
+ if (!scoped.project) {
153
+ const access = await request(scoped, "/access");
154
+ const selected = await selectProject(access, scoped, options);
155
+ scoped.project = selected.project;
156
+ scoped.organization =
157
+ scoped.organization ?? selected.organization ?? access.organization;
158
+ }
109
159
  const repository = await request(scoped, "/repository");
110
- return checkout(scoped, repository, options.directory ?? project);
160
+ return checkout(
161
+ scoped,
162
+ repository,
163
+ options.directory ?? scoped.project,
164
+ );
111
165
  }),
112
166
  );
113
167
  }
@@ -16,7 +16,11 @@ export function registerInspectCommands(program) {
16
16
  program
17
17
  .command("status")
18
18
  .description("Show the project overview")
19
- .action(action(({ connection }) => request(connection, "/overview")));
19
+ .action(
20
+ action(({ connection }) => request(connection, "/overview"), {
21
+ projectScope: true,
22
+ }),
23
+ );
20
24
 
21
25
  for (const [name, description] of [
22
26
  ["quests", "List current quests"],
@@ -34,14 +38,17 @@ export function registerInspectCommands(program) {
34
38
  .option("--filter <value>", "Filter the collection")
35
39
  .option("--q <text>", "Search the collection")
36
40
  .action(
37
- action(({ connection, options }) => {
38
- const query = new URLSearchParams();
39
- for (const key of ["limit", "offset", "filter", "q"]) {
40
- if (options[key] !== undefined)
41
- query.set(key, String(options[key]));
42
- }
43
- return request(connection, `/${name}?${query}`);
44
- }),
41
+ action(
42
+ ({ connection, options }) => {
43
+ const query = new URLSearchParams();
44
+ for (const key of ["limit", "offset", "filter", "q"]) {
45
+ if (options[key] !== undefined)
46
+ query.set(key, String(options[key]));
47
+ }
48
+ return request(connection, `/${name}?${query}`);
49
+ },
50
+ { projectScope: true },
51
+ ),
45
52
  );
46
53
  }
47
54
  }
@@ -1,6 +1,8 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { action } from "../runtime.mjs";
4
+ import { confirmAction } from "../prompts.mjs";
5
+ import { selectProject } from "../select-project.mjs";
4
6
  import { request } from "../connection.mjs";
5
7
  import { initialize, bundleProject } from "../project.mjs";
6
8
 
@@ -64,7 +66,17 @@ async function build({ project, connection, options }, deploy) {
64
66
  throw new Error(
65
67
  "Live publication requires Console approval. Run deploy --dry-run --out deployment.json, then import the project deployment in Console.",
66
68
  );
67
- if (options.resume) return publish(connection, options.resume);
69
+ const confirmPublication = (detail) =>
70
+ confirmAction(
71
+ options,
72
+ `Publish to ${connection.organization ?? "current organization"}/${connection.project} in ${connection.environment ?? "test"}.
73
+ API: ${connection.apiUrl}
74
+ ${detail}`,
75
+ );
76
+ if (options.resume) {
77
+ await confirmPublication(`Resume deployment ${options.resume}.`);
78
+ return publish(connection, options.resume);
79
+ }
68
80
  const payload = await bundleProject(project);
69
81
  if (deploy) {
70
82
  const preview = await request(
@@ -74,6 +86,9 @@ async function build({ project, connection, options }, deploy) {
74
86
  payload,
75
87
  );
76
88
  if (options.preview) return { status: "preview", ...summary(preview) };
89
+ await confirmPublication(
90
+ `Deployment ${preview.id}: ${preview.releases.length} quests, ${preview.types.length} types, ${preview.collections.length} collections, ${preview.leaderboards.length} leaderboards, ${preview.referrals.length} referral programs.`,
91
+ );
77
92
  return publish(connection, preview.id);
78
93
  }
79
94
  if (options.out) {
@@ -104,21 +119,11 @@ export function registerProjectCommands(program) {
104
119
  .action(
105
120
  action(async ({ connection, options }, directory) => {
106
121
  const access = await request(connection, "/access");
107
- const selected = connection.project
108
- ? access.projects.find((item) => item.project === connection.project)
109
- : access.projects.length === 1
110
- ? access.projects[0]
111
- : null;
112
- if (!selected)
113
- throw new Error(
114
- "Select an accessible project with --project ID. Run domino whoami to list projects; create projects in Console.",
115
- );
122
+ const selected = await selectProject(access, connection, options);
116
123
  const environment = connection.environment ?? "test";
117
- if (!selected.environments.includes(environment))
118
- throw new Error(`No access to ${environment} for this project.`);
119
124
  return initialize(directory, {
120
125
  ...connection,
121
- organization: access.organization,
126
+ organization: selected.organization ?? access.organization,
122
127
  project: selected.project,
123
128
  environment,
124
129
  quests: (options.entry ?? []).map((entry) => ({
@@ -146,6 +151,7 @@ export function registerProjectCommands(program) {
146
151
  .description(
147
152
  "Atomically deploy quests, reusable types, and collection slots, preserving Console-owned settings and placement",
148
153
  )
154
+ .option("--yes", "Skip the terminal publication confirmation")
149
155
  .option(
150
156
  "--dry-run",
151
157
  "Build locally only; does not preview Relay custom values",
@@ -2,6 +2,8 @@ import { dirname } from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { action } from "../runtime.mjs";
4
4
  import { request } from "../connection.mjs";
5
+ import { canPrompt, promptSelect, confirmAction } from "../prompts.mjs";
6
+ import { projectConnection } from "../select-project.mjs";
5
7
  import { runGit } from "../git.mjs";
6
8
 
7
9
  export function registerStagingCommands(program) {
@@ -15,6 +17,7 @@ export function registerStagingCommands(program) {
15
17
  "--request-id <uuid>",
16
18
  "Resume a previously submitted build request",
17
19
  )
20
+ .option("--yes", "Skip the terminal confirmation")
18
21
  .option("--no-wait", "Return the build ID without waiting")
19
22
  .action(
20
23
  action(async ({ connection, project, options }) => {
@@ -29,7 +32,10 @@ export function registerStagingCommands(program) {
29
32
  if (preflight.staging !== "configured")
30
33
  throw new Error(
31
34
  preflight.checks
32
- .filter((check) => check.status === "error")
35
+ .filter(
36
+ (check) =>
37
+ check.id !== "hosted-test-auth" && check.status === "error",
38
+ )
33
39
  .map((check) => check.message)
34
40
  .join("\n"),
35
41
  );
@@ -45,6 +51,12 @@ export function registerStagingCommands(program) {
45
51
  options.commit ?? (await runGit(["rev-parse", "HEAD"], { cwd }));
46
52
  if (!/^[a-f0-9]{40}$/.test(commit))
47
53
  throw new Error("Use a full 40-character commit SHA.");
54
+ await confirmAction(
55
+ options,
56
+ `Stage commit ${commit} for ${scoped.organization ?? "current organization"}/${scoped.project} in test.
57
+ API: ${scoped.apiUrl}
58
+ This builds the pushed commit and updates its staging preview.`,
59
+ );
48
60
  const requestId = options.requestId ?? randomUUID();
49
61
  process.stderr.write(`Build request ${requestId}\n`);
50
62
  let build;
@@ -81,18 +93,41 @@ export function registerStagingCommands(program) {
81
93
  .command("builds")
82
94
  .description("List recent cloud builds for this campaign")
83
95
  .action(
84
- action(({ connection }) =>
85
- request({ ...connection, environment: "test" }, "/builds"),
86
- ),
96
+ action(({ connection }) => request(connection, "/builds"), {
97
+ projectScope: true,
98
+ environment: "test",
99
+ }),
87
100
  );
88
101
  program
89
102
  .command("logs")
90
103
  .description("Read retained cloud build logs")
91
- .argument("<id>", "Build ID")
104
+ .argument("[id]", "Build ID (choose a recent build if omitted)")
92
105
  .action(
93
- action(({ connection }, id) => {
94
- if (!/^[a-f0-9-]{36}$/.test(id))
106
+ action(async ({ connection, options }, id) => {
107
+ if (!id && !canPrompt(options))
108
+ throw new Error(
109
+ "Provide a build ID: domino logs ID. Run domino builds to list recent builds.",
110
+ );
111
+ if (id && !/^[a-f0-9-]{36}$/.test(id))
95
112
  throw new Error("Expected a build UUID.");
113
+ connection = await projectConnection(
114
+ { ...connection, environment: "test" },
115
+ options,
116
+ );
117
+ if (!id) {
118
+ const builds = await request(connection, "/builds");
119
+ if (!builds.length)
120
+ throw new Error(
121
+ "No builds yet. Push a commit and run domino stage first.",
122
+ );
123
+ id = await promptSelect(
124
+ "Choose a build",
125
+ builds.map((build) => ({
126
+ label: `${build.phase} • ${new Date(build.createdAt).toISOString()} • ${build.commit.slice(0, 8)} • ${build.id}${build.current ? " • current" : ""}`,
127
+ value: build.id,
128
+ })),
129
+ );
130
+ }
96
131
  return request(
97
132
  { ...connection, environment: "test" },
98
133
  `/builds/${id}/logs`,
package/cli/dev.mjs CHANGED
@@ -113,7 +113,7 @@ export async function dev(project, options) {
113
113
  const child = spawn(command, args, {
114
114
  cwd: root,
115
115
  env: { ...process.env, ...extraEnv },
116
- stdio: "inherit",
116
+ stdio: ["inherit", 2, 2],
117
117
  detached: process.platform !== "win32",
118
118
  });
119
119
  children.push(child);
package/cli/output.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Keep presentation separate from the result contracts consumed with --json.
2
+ const label = (key) => {
3
+ const words = key.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]/g, " ");
4
+ return words.charAt(0).toUpperCase() + words.slice(1);
5
+ };
6
+ const scalar = (value) =>
7
+ value == null
8
+ ? "—"
9
+ : typeof value === "boolean"
10
+ ? value
11
+ ? "yes"
12
+ : "no"
13
+ : String(value);
14
+
15
+ export function formatDetails(value, indent = "") {
16
+ if (value === null || typeof value !== "object")
17
+ return indent + scalar(value);
18
+ if (Array.isArray(value)) {
19
+ if (!value.length) return indent + "None";
20
+ return value
21
+ .map((item) => {
22
+ if (item === null || typeof item !== "object")
23
+ return `${indent}- ${scalar(item)}`;
24
+ return formatDetails(item, indent + " ").replace(
25
+ indent + " ",
26
+ indent + "- ",
27
+ );
28
+ })
29
+ .join("\n");
30
+ }
31
+ const entries = Object.entries(value).filter(
32
+ ([, item]) => item !== undefined,
33
+ );
34
+ if (!entries.length) return indent + "None";
35
+ return entries
36
+ .map(([key, item]) =>
37
+ item !== null && typeof item === "object"
38
+ ? `${indent}${label(key)}:\n${formatDetails(item, indent + " ")}`
39
+ : `${indent}${label(key)}: ${scalar(item)}`,
40
+ )
41
+ .join("\n");
42
+ }
43
+
44
+ export function formatResult(command, result, options = {}) {
45
+ if (options.json || command === "api")
46
+ return JSON.stringify(result, null, options.json ? undefined : 2);
47
+ if (command === "logs") return result.logs.trimEnd();
48
+ if (command === "reference" && result.markdown)
49
+ return result.markdown.trimEnd();
50
+ if (command === "whoami") {
51
+ const identity = formatDetails({
52
+ actor: result.actor,
53
+ organization: result.organization,
54
+ });
55
+ const projects = result.projects.map(
56
+ (item) =>
57
+ ` ${item.name && item.name !== item.project ? `${item.name} (${item.project})` : item.project} [${item.environments.join(", ")}]`,
58
+ );
59
+ return `${identity}\n\nAccessible projects:\n${projects.length ? projects.join("\n") : " None"}\n\nRun domino checkout to choose a hosted project, or domino init to link this app.`;
60
+ }
61
+ if (
62
+ (command === "build" || (command === "deploy" && options.dryRun)) &&
63
+ result.releases
64
+ ) {
65
+ return formatDetails({
66
+ result: "Bundle ready",
67
+ quests: result.releases.length,
68
+ types: result.types.length,
69
+ collections: result.collections.length,
70
+ leaderboards: result.leaderboards.length,
71
+ referrals: result.referrals.length,
72
+ next: "Use --out FILE to save the bundle, or --json to print it.",
73
+ });
74
+ }
75
+ return formatDetails(result);
76
+ }