@domino-sdk/relay-cli 0.6.0 → 0.8.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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Option } from "commander";
2
3
  import { connectedDev } from "../connected-dev.mjs";
3
4
  import { dirname, resolve } from "node:path";
@@ -20,6 +21,12 @@ export function registerHostingCommands(program) {
20
21
  .action(
21
22
  action(async ({ project }) => {
22
23
  if (!project) throw new Error("No relay.json found.");
24
+ const { migrationPlan } = await import("../migrate.mjs");
25
+ const { diagnostics } = await migrationPlan(project.path);
26
+ if (diagnostics.some((item) => item.severity === "error")) {
27
+ process.exitCode = 1;
28
+ return { checked: false, diagnostics };
29
+ }
23
30
  if (project.config.app) {
24
31
  try {
25
32
  await promisify(execFile)(
@@ -34,10 +41,12 @@ export function registerHostingCommands(program) {
34
41
  const bundle = await bundleProject(project, { allowEmpty: true });
35
42
  return {
36
43
  checked: true,
44
+ diagnostics,
37
45
  quests: bundle.releases.length,
38
46
  leaderboards: bundle.leaderboards.length,
39
47
  referrals: bundle.referrals.length,
40
48
  types: bundle.types.length,
49
+ pointLedgers: bundle.pointLedgers.length,
41
50
  collections: bundle.collections.length,
42
51
  };
43
52
  }),
@@ -119,13 +128,21 @@ export function registerHostingCommands(program) {
119
128
  }
120
129
  if (!organization)
121
130
  throw new Error("Select an organization with --organization ID.");
131
+ const created = await request(
132
+ { ...connection, organization },
133
+ "/projects",
134
+ "POST",
135
+ {
136
+ actionId: randomUUID(),
137
+ name,
138
+ },
139
+ );
122
140
  const scoped = {
123
141
  ...connection,
124
142
  organization,
125
- project: name,
143
+ project: created.project,
126
144
  environment: "test",
127
145
  };
128
- await request(scoped, "/projects", "POST", { project: name, name });
129
146
  const repository = await request(scoped, "/repository", "POST", {
130
147
  starter: "campaign",
131
148
  });
@@ -4,16 +4,19 @@ import { action } from "../runtime.mjs";
4
4
  import { confirmAction } from "../prompts.mjs";
5
5
  import { selectProject } from "../select-project.mjs";
6
6
  import { request } from "../connection.mjs";
7
- import { initialize, bundleProject } from "../project.mjs";
7
+ import { initialize, bundleProject, findProjectFile } from "../project.mjs";
8
+
9
+ import { formatResult } from "../output.mjs";
8
10
 
9
11
  function summary(value) {
10
12
  return {
11
13
  deployment: value.id,
12
14
  revision: value.revision ?? value.baseRevision,
13
15
  releases: value.releases.map(
14
- ({ id, quest, title, createdAt, configuration, values }) => ({
16
+ ({ id, quest, key, title, createdAt, configuration, values }) => ({
15
17
  id,
16
18
  quest,
19
+ key,
17
20
  title,
18
21
  createdAt,
19
22
  deploymentDefaults: configuration.defaults,
@@ -24,10 +27,12 @@ function summary(value) {
24
27
  ),
25
28
  types: value.types.map((t) => ({
26
29
  id: t.id,
30
+ key: t.key,
27
31
  version: t.version,
28
32
  title: t.definition.title,
29
33
  })),
30
34
  collections: value.collections,
35
+ pointLedgers: value.pointLedgers,
31
36
  supportedInteractions: value.supportedInteractions,
32
37
  leaderboards: value.leaderboards,
33
38
  referrals: value.referrals,
@@ -87,7 +92,7 @@ ${detail}`,
87
92
  );
88
93
  if (options.preview) return { status: "preview", ...summary(preview) };
89
94
  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.`,
95
+ `Deployment ${preview.id}: ${preview.releases.length} quests, ${preview.types.length} types, ${preview.collections.length} collections, ${preview.pointLedgers?.length ?? 0} point ledgers, ${preview.leaderboards.length} leaderboards, ${preview.referrals.length} referral programs.`,
91
96
  );
92
97
  return publish(connection, preview.id);
93
98
  }
@@ -98,6 +103,7 @@ ${detail}`,
98
103
  file: options.out,
99
104
  quests: payload.releases.length,
100
105
  types: payload.types.length,
106
+ pointLedgers: payload.pointLedgers.length,
101
107
  collections: payload.collections.length,
102
108
  leaderboards: payload.leaderboards.length,
103
109
  referrals: payload.referrals.length,
@@ -107,6 +113,22 @@ ${detail}`,
107
113
  }
108
114
 
109
115
  export function registerProjectCommands(program) {
116
+ program
117
+ .command("migrate")
118
+ .description(
119
+ "Preview an offline authoring migration without changing resource names",
120
+ )
121
+ .option("--write", "Apply the displayed source and manifest changes")
122
+ .action(async (_, command) => {
123
+ const options = command.optsWithGlobals();
124
+ const path = await findProjectFile(options.config);
125
+ if (!path) throw new Error("No relay.json found. Pass --config FILE.");
126
+ const { migrate } = await import("../migrate.mjs");
127
+ const result = await migrate(path, options.write);
128
+ if (result.blocked) process.exitCode = 1;
129
+ console.log(formatResult("migrate", result, options));
130
+ });
131
+
110
132
  program
111
133
  .command("init")
112
134
  .description("Link an existing project without generating starter data")
package/cli/dev.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { once } from "node:events";
1
2
  import { spawn } from "node:child_process";
2
3
  import { createRequire } from "node:module";
3
4
  import { createServer } from "node:http";
@@ -53,6 +54,7 @@ export async function dev(project, options) {
53
54
  token: randomBytes(32).toString("hex"),
54
55
  };
55
56
  const children = [];
57
+ const expectedStops = new WeakSet();
56
58
  let stopping = false;
57
59
  let failure;
58
60
  let watcher;
@@ -122,7 +124,7 @@ export async function dev(project, options) {
122
124
  stop();
123
125
  });
124
126
  child.on("exit", (code) => {
125
- if (!stopping) {
127
+ if (!stopping && !expectedStops.has(child)) {
126
128
  failure = new Error(`Development process exited (${code}).`);
127
129
  stop();
128
130
  }
@@ -142,42 +144,62 @@ export async function dev(project, options) {
142
144
  AUTH_ENVIRONMENT: "test",
143
145
  AUTH_ORIGINS: origin,
144
146
  };
145
- start(
146
- process.execPath,
147
- [
148
- wrangler,
149
- "dev",
150
- "--local",
151
- "--ip",
152
- "127.0.0.1",
153
- "--port",
154
- String(apiPort),
155
- "--config",
156
- join(runtime, "wrangler.jsonc"),
157
- "--persist-to",
158
- join(root, ".wrangler/domino"),
159
- ...Object.entries(vars).flatMap(([key, value]) => [
160
- "--var",
161
- `${key}:${value}`,
162
- ]),
163
- ],
164
- { WRANGLER_SEND_METRICS: "false" },
165
- );
166
- let ready = false;
167
- for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
168
- try {
169
- ready = (await fetch(connection.apiUrl + "/v1/auth/session")).ok;
170
- } catch {
171
- /* Runtime is starting. */
147
+ const startApi = () =>
148
+ start(
149
+ process.execPath,
150
+ [
151
+ wrangler,
152
+ "dev",
153
+ "--local",
154
+ "--ip",
155
+ "127.0.0.1",
156
+ "--port",
157
+ String(apiPort),
158
+ "--config",
159
+ join(runtime, "wrangler.jsonc"),
160
+ "--persist-to",
161
+ join(root, ".wrangler/domino"),
162
+ ...Object.entries(vars).flatMap(([key, value]) => [
163
+ "--var",
164
+ `${key}:${value}`,
165
+ ]),
166
+ ],
167
+ { WRANGLER_SEND_METRICS: "false" },
168
+ );
169
+ let api = startApi();
170
+ const waitReady = async () => {
171
+ let ready = false;
172
+ for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
173
+ try {
174
+ ready = (await fetch(connection.apiUrl + "/v1/auth/session")).ok;
175
+ } catch {
176
+ /* Runtime is starting. */
177
+ }
178
+ if (ready) break;
179
+ await delay(200);
172
180
  }
173
- if (ready) break;
174
- await delay(200);
181
+ if (!ready) throw failure ?? new Error("Local Relay did not start.");
182
+ };
183
+ await waitReady();
184
+ const access = await request(connection, "/access");
185
+ const legacy = access.projects.find((p) => p.project === "campaign");
186
+ const local =
187
+ legacy ??
188
+ (await request(connection, "/projects", "POST", {
189
+ actionId: "offline-campaign-v1",
190
+ name: "Local campaign",
191
+ }));
192
+ if (local.project !== connection.project) {
193
+ connection.project = local.project;
194
+ vars.AUTH_PROJECT = local.project;
195
+ expectedStops.add(api);
196
+ const exited = once(api, "exit");
197
+ if (process.platform === "win32") api.kill("SIGTERM");
198
+ else process.kill(-api.pid, "SIGTERM");
199
+ await exited;
200
+ api = startApi();
201
+ await waitReady();
175
202
  }
176
- if (!ready) throw failure ?? new Error("Local Relay did not start.");
177
- await request(connection, "/projects", "POST", {
178
- project: connection.project,
179
- name: "Local campaign",
180
- });
181
203
  const sync = developmentSync(connection, project.path, options.reviewMode);
182
204
  await sync();
183
205
  let dirty = false;
@@ -0,0 +1,399 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, relative, resolve } from "node:path";
3
+ import ts from "typescript";
4
+ import { createTwoFilesPatch } from "diff";
5
+ import { discoverEntries } from "./discovery.mjs";
6
+
7
+ const helpers = new Set([
8
+ "defineQuest",
9
+ "defineQuestType",
10
+ "defineTieredReward",
11
+ "defineLeaderboard",
12
+ "defineReferral",
13
+ ]);
14
+ const excluded =
15
+ /(^|[/\\])(node_modules|dist|build|out|coverage|\.git|\.next|\.domino|\.domino-build|\.wrangler|\.artifacts)([/\\]|$)/;
16
+ const fieldName = (node) =>
17
+ node.name && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))
18
+ ? node.name.text
19
+ : undefined;
20
+ const property = (node, name) =>
21
+ node.properties.find((p) => fieldName(p) === name);
22
+ const valueOf = (node) =>
23
+ ts.isPropertyAssignment(node)
24
+ ? node.initializer
25
+ : ts.isShorthandPropertyAssignment(node)
26
+ ? node.name
27
+ : undefined;
28
+
29
+ /** Inspect syntax only. Never import or execute a project's modules. */
30
+ export async function migrationPlan(path) {
31
+ const root = dirname(path);
32
+ const manifestText = await readFile(path, "utf8");
33
+ const manifest = JSON.parse(manifestText);
34
+ const diagnostics = [];
35
+ const changes = [];
36
+ const references = new Map();
37
+ const project = {
38
+ path,
39
+ config: {
40
+ ...manifest,
41
+ quests: manifest.quests ?? [],
42
+ questTypes: manifest.questTypes ?? [],
43
+ },
44
+ };
45
+ const entries = [
46
+ ...(await discoverEntries(project, "quests")),
47
+ ...(await discoverEntries(project, "questTypes")),
48
+ ...(manifest.leaderboards ?? []),
49
+ ...(manifest.referrals ?? []),
50
+ ];
51
+ const configPath = ts.findConfigFile(root, ts.sys.fileExists);
52
+ const config = configPath
53
+ ? ts.readConfigFile(configPath, ts.sys.readFile)
54
+ : null;
55
+ const options =
56
+ config && !config.error
57
+ ? ts.parseJsonConfigFileContent(
58
+ config.config,
59
+ ts.sys,
60
+ dirname(configPath),
61
+ ).options
62
+ : {};
63
+ const program = ts.createProgram(
64
+ entries.map((entry) => resolve(root, entry.entry)),
65
+ { ...options, allowJs: true, noEmit: true },
66
+ );
67
+ const files = program
68
+ .getSourceFiles()
69
+ .filter((source) => {
70
+ const local = relative(root, source.fileName);
71
+ return (
72
+ !source.isDeclarationFile &&
73
+ !local.startsWith("..") &&
74
+ !excluded.test(local)
75
+ );
76
+ })
77
+ .map((source) => source.fileName)
78
+ .sort();
79
+ const checker = program.getTypeChecker();
80
+ function diagnostic(source, node, code, severity, message, suggestion) {
81
+ const position = source.getLineAndCharacterOfPosition(
82
+ node.getStart(source),
83
+ );
84
+ const item = {
85
+ code,
86
+ severity,
87
+ file: relative(root, source.fileName),
88
+ line: position.line + 1,
89
+ column: position.character + 1,
90
+ message,
91
+ suggestion,
92
+ };
93
+ diagnostics.push(item);
94
+ return item;
95
+ }
96
+ function importedName(expression) {
97
+ if (ts.isIdentifier(expression)) {
98
+ const declaration =
99
+ checker.getSymbolAtLocation(expression)?.declarations?.[0];
100
+ if (!declaration || !ts.isImportSpecifier(declaration)) return;
101
+ const imported = declaration.parent.parent.parent;
102
+ if (
103
+ ts.isImportDeclaration(imported) &&
104
+ /^@domino-sdk\/relay(?:\/authoring)?$/.test(
105
+ imported.moduleSpecifier.text,
106
+ )
107
+ )
108
+ return (declaration.propertyName ?? declaration.name).text;
109
+ }
110
+ if (ts.isPropertyAccessExpression(expression)) {
111
+ const declaration = checker.getSymbolAtLocation(expression.expression)
112
+ ?.declarations?.[0];
113
+ if (!declaration || !ts.isNamespaceImport(declaration)) return;
114
+ const imported = declaration.parent.parent;
115
+ if (
116
+ ts.isImportDeclaration(imported) &&
117
+ /^@domino-sdk\/relay(?:\/authoring)?$/.test(
118
+ imported.moduleSpecifier.text,
119
+ )
120
+ )
121
+ return expression.name.text;
122
+ }
123
+ }
124
+ function literal(node, depth = 0) {
125
+ if (!node || depth > 8) return;
126
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
127
+ return node.text;
128
+ if (
129
+ ts.isParenthesizedExpression(node) ||
130
+ ts.isAsExpression(node) ||
131
+ ts.isSatisfiesExpression(node)
132
+ )
133
+ return literal(node.expression, depth + 1);
134
+ if (ts.isIdentifier(node)) {
135
+ const declaration = checker.getSymbolAtLocation(node)?.valueDeclaration;
136
+ if (
137
+ declaration &&
138
+ ts.isVariableDeclaration(declaration) &&
139
+ declaration.parent.flags & ts.NodeFlags.Const
140
+ )
141
+ return literal(declaration.initializer, depth + 1);
142
+ }
143
+ }
144
+ function ledger(source, node) {
145
+ const name = literal(node);
146
+ if (name === undefined) {
147
+ diagnostic(
148
+ source,
149
+ node,
150
+ "LEDGER_REFERENCE_DYNAMIC",
151
+ "warning",
152
+ "This ledger reference cannot be determined without running code.",
153
+ "Verify that the selected ledger already exists, or declare its stable key in relay.json pointLedgers.",
154
+ );
155
+ } else if (
156
+ /^[\w-]+$/.test(name) &&
157
+ !/^ledger_[A-Za-z0-9]{22}$/.test(name)
158
+ ) {
159
+ if (!references.has(name)) references.set(name, { source, node });
160
+ }
161
+ }
162
+ for (const file of files) {
163
+ const source = program.getSourceFile(file);
164
+ if (!source) continue;
165
+ const edits = [];
166
+ function visit(node) {
167
+ if (ts.isCallExpression(node)) {
168
+ const name = importedName(node.expression);
169
+ const argument = node.arguments[0];
170
+ if (name === "points" && argument) ledger(source, argument);
171
+ if (helpers.has(name)) {
172
+ if (
173
+ !argument ||
174
+ !ts.isObjectLiteralExpression(argument) ||
175
+ argument.properties.some(ts.isSpreadAssignment)
176
+ ) {
177
+ diagnostic(
178
+ source,
179
+ node,
180
+ "AUTHORING_IDENTITY_DYNAMIC",
181
+ "warning",
182
+ "This declaration uses an indirect object or spread; it was left unchanged.",
183
+ "Keep the existing authoring name and replace id with key in the defining object. Legacy id remains supported.",
184
+ );
185
+ } else {
186
+ const id = property(argument, "id");
187
+ const key = property(argument, "key");
188
+ if (id && key) {
189
+ const oldValue = literal(valueOf(id));
190
+ const newValue = literal(valueOf(key));
191
+ if (
192
+ oldValue === undefined ||
193
+ newValue === undefined ||
194
+ oldValue !== newValue
195
+ ) {
196
+ diagnostic(
197
+ source,
198
+ key,
199
+ "AUTHORING_IDENTITY_CONFLICT",
200
+ "error",
201
+ "Both id and key are present and cannot be proven equal.",
202
+ "Keep the original id value as key and remove id. Do not choose a new name for an existing resource.",
203
+ );
204
+ } else {
205
+ const index = argument.properties.indexOf(id);
206
+ const next = argument.properties[index + 1];
207
+ const previous = argument.properties[index - 1];
208
+ edits.push({
209
+ start: id.getStart(source),
210
+ end: id.end,
211
+ text: "",
212
+ });
213
+ const scanner = ts.createScanner(
214
+ ts.ScriptTarget.Latest,
215
+ true,
216
+ ts.LanguageVariant.Standard,
217
+ source.text,
218
+ );
219
+ scanner.setTextPos(next ? id.end : (previous?.end ?? id.end));
220
+ if (scanner.scan() === ts.SyntaxKind.CommaToken)
221
+ edits.push({
222
+ start: scanner.getTokenPos(),
223
+ end: scanner.getTextPos(),
224
+ text: "",
225
+ });
226
+ diagnostic(
227
+ source,
228
+ id,
229
+ "AUTHORING_ID_DEPRECATED",
230
+ "warning",
231
+ "id repeats the authoring key.",
232
+ "Run domino migrate --write to remove the redundant alias.",
233
+ );
234
+ }
235
+ } else if (id) {
236
+ edits.push({
237
+ start: id.name.getStart(source),
238
+ end: id.name.end,
239
+ text: ts.isShorthandPropertyAssignment(id) ? "key: id" : "key",
240
+ });
241
+ diagnostic(
242
+ source,
243
+ id,
244
+ "AUTHORING_ID_DEPRECATED",
245
+ "warning",
246
+ "id is supported as an authoring key alias.",
247
+ "Run domino migrate --write to rename it to key without changing its value.",
248
+ );
249
+ } else if (!key) {
250
+ diagnostic(
251
+ source,
252
+ argument,
253
+ "AUTHORING_KEY_MISSING",
254
+ "error",
255
+ "This declaration has no authoring key.",
256
+ "Add a stable key. For an existing resource, use its original authoring id value.",
257
+ );
258
+ }
259
+ function balances(child) {
260
+ if (
261
+ ts.isPropertyAssignment(child) &&
262
+ fieldName(child) === "balance"
263
+ ) {
264
+ const object = child.parent;
265
+ const parent = object.parent;
266
+ const kind = ts.isObjectLiteralExpression(object)
267
+ ? property(object, "kind")
268
+ : undefined;
269
+ const isPointReward =
270
+ kind && literal(valueOf(kind)) === "points";
271
+ const isBudgetCost =
272
+ ts.isPropertyAssignment(parent) &&
273
+ fieldName(parent) === "cost";
274
+ const isBonus =
275
+ name === "defineReferral" &&
276
+ ts.isArrayLiteralExpression(parent) &&
277
+ ts.isPropertyAssignment(parent.parent) &&
278
+ fieldName(parent.parent) === "bonuses";
279
+ if (
280
+ (name === "defineLeaderboard" && object === argument) ||
281
+ isPointReward ||
282
+ isBudgetCost ||
283
+ isBonus
284
+ )
285
+ ledger(source, child.initializer);
286
+ }
287
+ ts.forEachChild(child, balances);
288
+ }
289
+ balances(argument);
290
+ }
291
+ }
292
+ }
293
+ ts.forEachChild(node, visit);
294
+ }
295
+ visit(source);
296
+ let after = source.text;
297
+ for (const edit of edits.sort((a, b) => b.start - a.start))
298
+ after = after.slice(0, edit.start) + edit.text + after.slice(edit.end);
299
+ if (after !== source.text)
300
+ changes.push({ path: file, before: source.text, after });
301
+ }
302
+ const jsonSource = ts.parseJsonText(path, manifestText);
303
+ const jsonObject = jsonSource.statements[0]?.expression;
304
+ const collectionNodes =
305
+ jsonObject && property(jsonObject, "collections")?.initializer?.elements;
306
+ let manifestChanged = false;
307
+ for (const [index, collection] of (manifest.collections ?? []).entries()) {
308
+ if (
309
+ !collection ||
310
+ typeof collection !== "object" ||
311
+ collection.id === undefined
312
+ )
313
+ continue;
314
+ const node = collectionNodes?.[index] ?? jsonObject;
315
+ if (collection.key !== undefined && collection.key !== collection.id) {
316
+ diagnostic(
317
+ jsonSource,
318
+ node,
319
+ "AUTHORING_IDENTITY_CONFLICT",
320
+ "error",
321
+ "Collection id and key differ.",
322
+ "Keep the original id value as key and remove id.",
323
+ );
324
+ } else {
325
+ diagnostic(
326
+ jsonSource,
327
+ node,
328
+ "AUTHORING_ID_DEPRECATED",
329
+ "warning",
330
+ "Collection id is supported as an authoring key alias.",
331
+ "Run domino migrate --write to rename it to key.",
332
+ );
333
+ collection.key = collection.id;
334
+ delete collection.id;
335
+ manifestChanged = true;
336
+ }
337
+ }
338
+ const declared = new Set(
339
+ (manifest.pointLedgers ?? []).map((ledger) => ledger.key),
340
+ );
341
+ for (const [key, { source, node }] of references) {
342
+ if (declared.has(key)) continue;
343
+ diagnostic(
344
+ source,
345
+ node,
346
+ "LEDGER_DECLARATION_MISSING",
347
+ "warning",
348
+ `Ledger ${JSON.stringify(key)} has no local declaration. It must already exist remotely or be declared before publication.`,
349
+ "Run domino migrate to review a pointLedgers declaration, or keep using the existing remote ledger.",
350
+ );
351
+ manifest.pointLedgers ??= [];
352
+ manifest.pointLedgers.push({ key, name: key });
353
+ manifestChanged = true;
354
+ }
355
+ if (manifestChanged)
356
+ changes.push({
357
+ path,
358
+ before: manifestText,
359
+ after: JSON.stringify(manifest, null, 2) + "\n",
360
+ });
361
+ return { diagnostics, changes };
362
+ }
363
+
364
+ export async function migrate(path, write = false) {
365
+ const plan = await migrationPlan(path);
366
+ const blocked = plan.diagnostics.some((item) => item.severity === "error");
367
+ if (write && !blocked) {
368
+ // Refuse stale plans before touching any file.
369
+ for (const change of plan.changes)
370
+ if ((await readFile(change.path, "utf8")) !== change.before)
371
+ throw new Error(
372
+ `File changed during migration: ${change.path}. Run domino migrate again.`,
373
+ );
374
+ for (const change of plan.changes)
375
+ await writeFile(change.path, change.after);
376
+ }
377
+ return {
378
+ applied: write && !blocked,
379
+ blocked,
380
+ files: plan.changes.map((change) => relative(dirname(path), change.path)),
381
+ diff: plan.changes
382
+ .map((change) => {
383
+ const name = relative(dirname(path), change.path);
384
+ return createTwoFilesPatch(
385
+ `a/${name}`,
386
+ `b/${name}`,
387
+ change.before,
388
+ change.after,
389
+ );
390
+ })
391
+ .join("\n"),
392
+ diagnostics: plan.diagnostics,
393
+ next: blocked
394
+ ? "Resolve the reported conflicts, then run domino migrate again."
395
+ : write
396
+ ? "Review the diff, then run domino check --json."
397
+ : "Review the diff. Run domino migrate --write to apply it.",
398
+ };
399
+ }
package/cli/output.mjs CHANGED
@@ -44,6 +44,16 @@ export function formatDetails(value, indent = "") {
44
44
  export function formatResult(command, result, options = {}) {
45
45
  if (options.json || command === "api")
46
46
  return JSON.stringify(result, null, options.json ? undefined : 2);
47
+ if (command === "migrate")
48
+ return [
49
+ result.diff || "No automatic changes needed.",
50
+ formatDetails({
51
+ applied: result.applied,
52
+ blocked: result.blocked,
53
+ diagnostics: result.diagnostics,
54
+ next: result.next,
55
+ }),
56
+ ].join("\n");
47
57
  if (command === "logs") return result.logs.trimEnd();
48
58
  if (command === "reference" && result.markdown)
49
59
  return result.markdown.trimEnd();
@@ -66,6 +76,7 @@ export function formatResult(command, result, options = {}) {
66
76
  result: "Bundle ready",
67
77
  quests: result.releases.length,
68
78
  types: result.types.length,
79
+ pointLedgers: result.pointLedgers.length,
69
80
  collections: result.collections.length,
70
81
  leaderboards: result.leaderboards.length,
71
82
  referrals: result.referrals.length,