atmn 1.1.25 → 2.0.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.
Files changed (117) hide show
  1. package/README.md +120 -0
  2. package/dist/bin.js +23102 -0
  3. package/dist/index.js +2906 -0
  4. package/dist/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +27 -74
  6. package/src/actions/api/callApi.ts +280 -0
  7. package/src/actions/api/registerApiCommands.ts +121 -0
  8. package/src/actions/env/fetchOrgInfo.ts +32 -0
  9. package/src/actions/env/types/orgInfo.ts +18 -0
  10. package/src/actions/env.ts +94 -0
  11. package/src/actions/init/runInit.ts +401 -0
  12. package/src/actions/login/keyless.ts +135 -0
  13. package/src/actions/login.ts +144 -0
  14. package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
  15. package/src/actions/pull/applyPreview.ts +553 -0
  16. package/src/actions/pull/applySettingsPreview.ts +114 -0
  17. package/src/actions/pull/changedFixtureKeys.ts +88 -0
  18. package/src/actions/pull/listSourceFiles.ts +29 -0
  19. package/src/actions/pull/locateFixture.ts +78 -0
  20. package/src/actions/pull/resolveCollectionTarget.ts +198 -0
  21. package/src/actions/pull/rewriteConfig.ts +57 -0
  22. package/src/actions/pull/scaffoldConfig.ts +118 -0
  23. package/src/actions/pull.ts +399 -0
  24. package/src/actions/push/backfillInternalIds.ts +394 -0
  25. package/src/actions/push/deprecatedFields.ts +55 -0
  26. package/src/actions/push.ts +294 -0
  27. package/src/actions/reset/runReset.ts +58 -0
  28. package/src/actions/sandbox/createSandbox.ts +83 -0
  29. package/src/actions/sandbox/deleteSandbox.ts +91 -0
  30. package/src/actions/sandbox/listSandboxes.ts +28 -0
  31. package/src/actions/sandbox/types/sandboxClient.ts +16 -0
  32. package/src/actions/sandbox/useSandbox.ts +157 -0
  33. package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
  34. package/src/actions/skills/skills.ts +246 -0
  35. package/src/auth/announceAuthorizationUrl.ts +26 -0
  36. package/src/auth/browser/openSystemBrowser.ts +7 -0
  37. package/src/auth/browser/tryOpenBrowser.ts +17 -0
  38. package/src/auth/browser/watchLauncher.ts +46 -0
  39. package/src/auth/buildAuthorizationUrl.ts +36 -0
  40. package/src/auth/callbackPages.ts +126 -0
  41. package/src/auth/createOrgApiKeys.ts +46 -0
  42. package/src/auth/keyless.ts +119 -0
  43. package/src/auth/oauthConfig.ts +63 -0
  44. package/src/auth/runOAuthFlow.ts +230 -0
  45. package/src/auth/types/browserOpener.ts +5 -0
  46. package/src/auth/types/impersonationTokens.ts +23 -0
  47. package/src/auth/types/oauthTokens.ts +13 -0
  48. package/src/auth/types/orgApiKeys.ts +6 -0
  49. package/src/bin.ts +9 -0
  50. package/src/cli.ts +648 -0
  51. package/src/config/configPackageName.ts +9 -0
  52. package/src/config/legacyConfig.ts +25 -0
  53. package/src/config/loadConfig.ts +231 -0
  54. package/src/env/assertSandboxTarget.ts +20 -0
  55. package/src/env/loadEnv.ts +184 -0
  56. package/src/env/resolveTarget.ts +134 -0
  57. package/src/env/sandboxKeyName.ts +18 -0
  58. package/src/generated/apiRoutes.ts +3787 -0
  59. package/src/generated/client.ts +51564 -0
  60. package/src/generated/emit.ts +1163 -0
  61. package/src/generated/emitRuntime.ts +522 -0
  62. package/src/generated/features.ts +146 -0
  63. package/src/generated/labels.ts +29 -0
  64. package/src/generated/licenses.ts +287 -0
  65. package/src/generated/lintRules.ts +2207 -0
  66. package/src/generated/lintRuntime.ts +865 -0
  67. package/src/generated/plans.ts +1628 -0
  68. package/src/generated/referralPrograms.ts +22 -0
  69. package/src/generated/rewards.ts +58 -0
  70. package/src/generated/settings.ts +21 -0
  71. package/src/generated/skills.ts +305 -0
  72. package/src/generated/variants.ts +934 -0
  73. package/src/generated/wire.ts +334 -0
  74. package/src/http/autumnFetch.ts +30 -0
  75. package/src/index.ts +20 -0
  76. package/src/project/chooseConfigDir.ts +41 -0
  77. package/src/project/resolveProject.ts +115 -0
  78. package/src/project/rootMarker.ts +40 -0
  79. package/src/prompt/prompt.ts +186 -0
  80. package/src/prompt/select.ts +162 -0
  81. package/src/render/renderEnv.ts +77 -0
  82. package/src/render/renderPreview.ts +945 -0
  83. package/src/render/renderSandboxes.ts +92 -0
  84. package/src/render/stripTerminalControls.ts +19 -0
  85. package/src/repo/findRepoRoot.ts +79 -0
  86. package/src/surgery/appendPropertyEdit.ts +67 -0
  87. package/src/surgery/appendToArray.ts +87 -0
  88. package/src/surgery/appendToBinding.ts +19 -0
  89. package/src/surgery/appendToCollection.ts +40 -0
  90. package/src/surgery/appendToFixtureArray.ts +71 -0
  91. package/src/surgery/arrayBinding.ts +30 -0
  92. package/src/surgery/deleteFixtureLiteral.ts +81 -0
  93. package/src/surgery/deleteReference.ts +48 -0
  94. package/src/surgery/ensureBuilderImport.ts +65 -0
  95. package/src/surgery/findFixture.ts +238 -0
  96. package/src/surgery/fixtureEdit.ts +156 -0
  97. package/src/surgery/fixtureLocation.ts +32 -0
  98. package/src/surgery/insertCollection.ts +86 -0
  99. package/src/surgery/insertFirstProperty.ts +73 -0
  100. package/src/surgery/patchFixtureProperty.ts +152 -0
  101. package/src/surgery/patchSingletonProperty.ts +221 -0
  102. package/src/surgery/replaceFixture.ts +28 -0
  103. package/src/surgery/setFixtureProperty.ts +55 -0
  104. package/src/surgery/staticFixtureRule.ts +48 -0
  105. package/src/version.ts +5 -0
  106. package/dist/cli.js +0 -146296
  107. package/dist/compose/index.js +0 -122
  108. package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
  109. package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
  110. package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
  111. package/dist/src/compose/index.d.ts +0 -19
  112. package/dist/src/compose/models/featureModels.d.ts +0 -262
  113. package/dist/src/compose/models/index.d.ts +0 -3
  114. package/dist/src/compose/models/planModels.d.ts +0 -562
  115. package/dist/src/compose/models/rewardModels.d.ts +0 -52
  116. package/dist/src/compose/models/variantModels.d.ts +0 -34
  117. package/readme.md +0 -186
@@ -0,0 +1,945 @@
1
+ import chalk from "chalk";
2
+ import { PREVIOUS_ATTRIBUTE_LABELS } from "../generated/labels";
3
+
4
+ /**
5
+ * Headless rendering only, for now — the shape a CI log or a piped terminal
6
+ * wants. The interactive view comes later and reads the same fields; nothing
7
+ * here computes anything, it only reports what preview returned.
8
+ */
9
+
10
+ type ChangeAction = "create" | "update" | "delete" | "skip" | string;
11
+
12
+ type PreviewChange = {
13
+ action?: ChangeAction;
14
+ name?: string;
15
+ };
16
+
17
+ type PriceLite = {
18
+ amount?: number;
19
+ interval?: string;
20
+ intervalCount?: number;
21
+ tiers?: unknown[];
22
+ } | null;
23
+
24
+ type TrialLite = { durationLength?: number; durationType?: string } | null;
25
+
26
+ type ItemLite = {
27
+ featureId?: string;
28
+ included?: number;
29
+ unlimited?: boolean;
30
+ price?: PriceLite;
31
+ display?: { primaryText?: string; secondaryText?: string } | null;
32
+ };
33
+
34
+ type PlanItemChangeLite = {
35
+ action?: string;
36
+ featureId?: string;
37
+ item?: ItemLite;
38
+ };
39
+
40
+ type PlanLicenseChangeLite = {
41
+ action?: string;
42
+ licensePlanId?: string;
43
+ version?: number;
44
+ included?: number;
45
+ prepaidOnly?: boolean;
46
+ previousAttributes?: Record<string, unknown> | null;
47
+ planChange?: PlanChangeLite | null;
48
+ };
49
+
50
+ /** The server's diff for one plan row; absent on creates and deletes. */
51
+ type PlanChangeLite = {
52
+ previousAttributes?: Record<string, unknown> | null;
53
+ priceChange?: { previous?: PriceLite; current?: PriceLite };
54
+ freeTrialChange?: { previous?: TrialLite; current?: TrialLite };
55
+ itemChanges?: PlanItemChangeLite[];
56
+ licenseChanges?: PlanLicenseChangeLite[];
57
+ /** The edit that produces this diff; a create's name lives here. */
58
+ customize?: Record<string, unknown> | null;
59
+ };
60
+
61
+ // Fixture casing, not wire: the client recases every response on the way in,
62
+ // so this renders `featureId`, never `feature_id`. One exception: a feature's
63
+ // `previousAttributes` is a frozen record and keeps its snake_case keys.
64
+ type FeatureChange = PreviewChange & {
65
+ featureId?: string;
66
+ previousAttributes?: Record<string, unknown> | null;
67
+ };
68
+ /**
69
+ * A variant plan under its base. It has no `action` of its own: `variantAction`
70
+ * says how it resolved against the base edit, and `planChange` says whether
71
+ * anything actually changes.
72
+ */
73
+ type VariantChange = {
74
+ planId?: string;
75
+ /** Null until the row exists, so a null id is this update minting it. */
76
+ internalId?: string | null;
77
+ version?: number;
78
+ active?: boolean;
79
+ variantAction?: string;
80
+ planChange?: PlanChangeLite | null;
81
+ siblingVersions?: VariantChange[];
82
+ };
83
+
84
+ type PlanChange = PreviewChange & {
85
+ planId?: string;
86
+ newPlanId?: string;
87
+ version?: number;
88
+ versionSlug?: string;
89
+ newVersionSlug?: string;
90
+ active?: boolean;
91
+ planChange?: PlanChangeLite | null;
92
+ /** `new_version` says the row is minted by this update, not edited. */
93
+ versioning?: { resolved?: string; newVersion?: number | null } | null;
94
+ siblingVersions?: PlanChange[];
95
+ variants?: VariantChange[];
96
+ state?: unknown;
97
+ };
98
+
99
+ type RewardChange = PreviewChange & {
100
+ id?: string;
101
+ kind?: string;
102
+ previousAttributes?: Record<string, unknown> | null;
103
+ };
104
+
105
+ type ReferralProgramChange = PreviewChange & {
106
+ id?: string;
107
+ rewardId?: string | null;
108
+ previousAttributes?: Record<string, unknown> | null;
109
+ };
110
+
111
+ /** One flag organization.preview_update reports: moved, or left behind. */
112
+ export type SettingChange = {
113
+ key?: string;
114
+ action?: "update" | "unmanaged" | string;
115
+ previous?: boolean;
116
+ current?: boolean | null;
117
+ };
118
+
119
+ export type SettingsPreview = {
120
+ config?: { changes?: SettingChange[] };
121
+ };
122
+
123
+ export type CatalogPreview = {
124
+ features?: FeatureChange[];
125
+ plans?: PlanChange[];
126
+ rewards?: RewardChange[];
127
+ referralPrograms?: ReferralProgramChange[];
128
+ migrations?: PlannedMigration[];
129
+ /** Absent when the config states no `settings`. */
130
+ settings?: SettingsPreview;
131
+ };
132
+
133
+ const MARKERS: Record<
134
+ string,
135
+ { symbol: string; paint: (s: string) => string }
136
+ > = {
137
+ create: { symbol: "+", paint: chalk.green },
138
+ update: { symbol: "~", paint: chalk.yellow },
139
+ delete: { symbol: "-", paint: chalk.red },
140
+ };
141
+
142
+ /** The spec's enum is create | update | delete | skip | none. */
143
+ const APPLIED_ACTIONS = new Set(["create", "update", "delete"]);
144
+
145
+ /** Nested changes speak in the past tense (created, updated, removed). */
146
+ const NESTED_ACTIONS: Record<string, string> = {
147
+ created: "create",
148
+ updated: "update",
149
+ deleted: "delete",
150
+ removed: "delete",
151
+ };
152
+
153
+ const marker = (action: ChangeAction | undefined) =>
154
+ MARKERS[NESTED_ACTIONS[action ?? ""] ?? action ?? ""] ?? {
155
+ symbol: "?",
156
+ paint: chalk.dim,
157
+ };
158
+
159
+ const line = ({
160
+ action,
161
+ id,
162
+ label,
163
+ indent = " ",
164
+ }: {
165
+ action: ChangeAction | undefined;
166
+ id: string;
167
+ label?: string;
168
+ indent?: string;
169
+ }): string => {
170
+ const { symbol, paint } = marker(action);
171
+ const suffix = label && label !== id ? chalk.dim(` ${label}`) : "";
172
+ return `${indent}${paint(`${symbol} ${id}`)}${suffix}`;
173
+ };
174
+
175
+ const formatValue = (value: unknown): string => {
176
+ if (value === null || value === undefined) return "unset";
177
+ if (typeof value === "string") return JSON.stringify(value);
178
+ if (typeof value === "number" || typeof value === "boolean")
179
+ return String(value);
180
+ return JSON.stringify(value);
181
+ };
182
+
183
+ /** Printed as the API names them, because prose would read as a different field. */
184
+ const LITERAL_LABEL_KEYS = new Set(["active"]);
185
+
186
+ /** `credit_schema` and `billingControls` both read as "Billing controls". */
187
+ /** The shared label when there is one; the key's own words otherwise. */
188
+ const labelFor = (key: string): string => {
189
+ if (LITERAL_LABEL_KEYS.has(key)) return key;
190
+ const wireKey = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
191
+ return PREVIOUS_ATTRIBUTE_LABELS[wireKey] ?? humanizeKey(key);
192
+ };
193
+
194
+ const humanizeKey = (key: string): string => {
195
+ const spaced = key.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2");
196
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
197
+ };
198
+
199
+ /**
200
+ * One line per changed attribute. A null previous value means the field was
201
+ * unset, so it reads as added; a current value, when the row carries one,
202
+ * completes the arrow.
203
+ */
204
+ const renderPreviousAttributes = ({
205
+ attributes,
206
+ indent,
207
+ current = {},
208
+ skip = [],
209
+ fresh = false,
210
+ }: {
211
+ attributes: Record<string, unknown> | null | undefined;
212
+ indent: string;
213
+ current?: Record<string, unknown>;
214
+ /** Keys a dedicated change line already covers. */
215
+ skip?: string[];
216
+ /** The row did not exist before: every value is set, none is left. */
217
+ fresh?: boolean;
218
+ }): string[] =>
219
+ Object.entries(attributes ?? {})
220
+ .filter(([key]) => !skip.includes(key))
221
+ .flatMap(([key, previous]) => {
222
+ const label = labelFor(key);
223
+ const now = current[key];
224
+ if (fresh) {
225
+ // A default the empty "before" carried is not something the row set.
226
+ if (now === undefined) return [];
227
+ const { symbol, paint } = marker("create");
228
+ return [`${indent}${paint(`${symbol} ${label}: ${formatValue(now)}`)}`];
229
+ }
230
+ const added = previous === null || previous === undefined;
231
+ const { symbol, paint } = marker(added ? "create" : "update");
232
+ const text =
233
+ now !== undefined
234
+ ? added
235
+ ? formatValue(now)
236
+ : `${formatValue(previous)} -> ${formatValue(now)}`
237
+ : added
238
+ ? "added"
239
+ : `was ${formatValue(previous)}`;
240
+ return [`${indent}${paint(`${symbol} ${label}: ${text}`)}`];
241
+ });
242
+
243
+ const formatMoney = (amount: number): string =>
244
+ `$${Number.isInteger(amount) ? amount : amount.toFixed(2)}`;
245
+
246
+ const formatInterval = (interval?: string, count?: number): string => {
247
+ if (interval === undefined || interval === "one_off") return "one-off";
248
+ const unit = interval.replace(/_/g, " ");
249
+ return count !== undefined && count > 1
250
+ ? `per ${count} ${unit}s`
251
+ : `per ${unit}`;
252
+ };
253
+
254
+ const formatPrice = (price: PriceLite | undefined): string => {
255
+ if (price === null || price === undefined) return "Free";
256
+ if (price.tiers !== undefined && price.tiers.length > 0)
257
+ return `${price.tiers.length} tiers ${formatInterval(price.interval, price.intervalCount)}`;
258
+ return `${formatMoney(price.amount ?? 0)} ${formatInterval(price.interval, price.intervalCount)}`;
259
+ };
260
+
261
+ const formatItem = (item: ItemLite | undefined): string => {
262
+ if (item === undefined) return "?";
263
+ const display = [item.display?.primaryText, item.display?.secondaryText]
264
+ .filter((text): text is string => typeof text === "string" && text !== "")
265
+ .join(", ");
266
+ if (display !== "") return display;
267
+ const quantity = item.unlimited ? "unlimited" : String(item.included ?? 0);
268
+ const price = item.price ? ` (${formatPrice(item.price)})` : "";
269
+ return `${quantity} ${item.featureId ?? "?"}${price}`;
270
+ };
271
+
272
+ /**
273
+ * The server lists a changed item as one deleted and one created entry on the
274
+ * same feature; pairing them back up is the renderer's job.
275
+ */
276
+ const pairItemChanges = (
277
+ itemChanges: PlanItemChangeLite[],
278
+ ): {
279
+ changed: { from: PlanItemChangeLite; to: PlanItemChangeLite }[];
280
+ added: PlanItemChangeLite[];
281
+ removed: PlanItemChangeLite[];
282
+ } => {
283
+ const deleted = itemChanges.filter((change) => change.action === "deleted");
284
+ const created = itemChanges.filter((change) => change.action === "created");
285
+ const changed: { from: PlanItemChangeLite; to: PlanItemChangeLite }[] = [];
286
+ const added: PlanItemChangeLite[] = [];
287
+ for (const change of created) {
288
+ const index = deleted.findIndex(
289
+ (candidate) => candidate.featureId === change.featureId,
290
+ );
291
+ if (index === -1) {
292
+ added.push(change);
293
+ continue;
294
+ }
295
+ const [from] = deleted.splice(index, 1);
296
+ changed.push({ from, to: change });
297
+ }
298
+ return { changed, added, removed: deleted };
299
+ };
300
+
301
+ const renderItemChanges = ({
302
+ itemChanges,
303
+ indent,
304
+ }: {
305
+ itemChanges: PlanItemChangeLite[];
306
+ indent: string;
307
+ }): string[] => {
308
+ const { changed, added, removed } = pairItemChanges(itemChanges);
309
+ const render = (action: string, id: string, text: string): string => {
310
+ const { symbol, paint } = marker(action);
311
+ return `${indent}${paint(`${symbol} ${id}`)} ${text}`;
312
+ };
313
+ return [
314
+ ...added.map((change) =>
315
+ render("create", change.featureId ?? "?", formatItem(change.item)),
316
+ ),
317
+ ...removed.map((change) =>
318
+ render("delete", change.featureId ?? "?", formatItem(change.item)),
319
+ ),
320
+ ...changed.map(({ from, to }) =>
321
+ render(
322
+ "update",
323
+ to.featureId ?? "?",
324
+ `${formatItem(from.item)} -> ${formatItem(to.item)}`,
325
+ ),
326
+ ),
327
+ ];
328
+ };
329
+
330
+ const renderPriceChange = ({
331
+ priceChange,
332
+ indent,
333
+ fresh = false,
334
+ }: {
335
+ priceChange: PlanChangeLite["priceChange"];
336
+ indent: string;
337
+ fresh?: boolean;
338
+ }): string[] => {
339
+ if (priceChange === undefined) return [];
340
+ if (fresh) {
341
+ if (priceChange.current === null || priceChange.current === undefined)
342
+ return [];
343
+ const { symbol, paint } = marker("create");
344
+ return [
345
+ `${indent}${paint(`${symbol} Price: ${formatPrice(priceChange.current)}`)}`,
346
+ ];
347
+ }
348
+ const { symbol, paint } = marker("update");
349
+ return [
350
+ `${indent}${paint(`${symbol} Price: ${formatPrice(priceChange.previous)} -> ${formatPrice(priceChange.current)}`)}`,
351
+ ];
352
+ };
353
+
354
+ const formatTrial = (trial: TrialLite | undefined): string =>
355
+ trial === null || trial === undefined
356
+ ? "none"
357
+ : `${trial.durationLength ?? "?"} ${trial.durationType ?? "day"} trial`;
358
+
359
+ const renderFreeTrialChange = ({
360
+ freeTrialChange,
361
+ indent,
362
+ fresh = false,
363
+ }: {
364
+ freeTrialChange: PlanChangeLite["freeTrialChange"];
365
+ indent: string;
366
+ fresh?: boolean;
367
+ }): string[] => {
368
+ if (freeTrialChange === undefined) return [];
369
+ if (fresh) {
370
+ if (
371
+ freeTrialChange.current === null ||
372
+ freeTrialChange.current === undefined
373
+ )
374
+ return [];
375
+ const { symbol, paint } = marker("create");
376
+ return [
377
+ `${indent}${paint(`${symbol} Free trial: ${formatTrial(freeTrialChange.current)}`)}`,
378
+ ];
379
+ }
380
+ const { symbol, paint } = marker("update");
381
+ return [
382
+ `${indent}${paint(`${symbol} Free trial: ${formatTrial(freeTrialChange.previous)} -> ${formatTrial(freeTrialChange.current)}`)}`,
383
+ ];
384
+ };
385
+
386
+ const renderLicenseChanges = ({
387
+ licenseChanges,
388
+ indent,
389
+ }: {
390
+ licenseChanges: PlanLicenseChangeLite[];
391
+ indent: string;
392
+ }): string[] =>
393
+ licenseChanges.flatMap((change) => {
394
+ const id =
395
+ change.version === undefined
396
+ ? (change.licensePlanId ?? "?")
397
+ : `${change.licensePlanId}@v${change.version}`;
398
+ return [
399
+ `${line({ action: change.action, id, indent })}${chalk.dim(" (license)")}`,
400
+ ...renderPreviousAttributes({
401
+ attributes: change.previousAttributes,
402
+ indent: `${indent} `,
403
+ current: {
404
+ included: change.included,
405
+ prepaidOnly: change.prepaidOnly,
406
+ version: change.version,
407
+ },
408
+ }),
409
+ ...(change.planChange
410
+ ? renderPlanChangeDetail({
411
+ planChange: change.planChange,
412
+ indent: `${indent} `,
413
+ })
414
+ : []),
415
+ ];
416
+ });
417
+
418
+ /** Lines a caller already put on the row itself, so the detail block does not
419
+ * print them twice. */
420
+ type CoveredDetail = "name" | "price" | "items";
421
+
422
+ /** Every field-level line the server's plan diff carries, nested under a row. */
423
+ const renderPlanChangeDetail = ({
424
+ planChange,
425
+ current = {},
426
+ indent,
427
+ covered = [],
428
+ fresh = false,
429
+ }: {
430
+ planChange: PlanChangeLite;
431
+ current?: Record<string, unknown>;
432
+ indent: string;
433
+ covered?: readonly CoveredDetail[];
434
+ /** A row this update brings into being: it sets values, it changes none. */
435
+ fresh?: boolean;
436
+ }): string[] => [
437
+ ...renderPreviousAttributes({
438
+ attributes: planChange.previousAttributes,
439
+ skip: [
440
+ ...(planChange.freeTrialChange === undefined ? [] : ["freeTrial"]),
441
+ ...(covered.includes("name") ? ["name"] : []),
442
+ ],
443
+ indent,
444
+ current,
445
+ fresh,
446
+ }),
447
+ ...(covered.includes("price")
448
+ ? []
449
+ : renderPriceChange({
450
+ priceChange: planChange.priceChange,
451
+ indent,
452
+ fresh,
453
+ })),
454
+ ...renderFreeTrialChange({
455
+ freeTrialChange: planChange.freeTrialChange,
456
+ indent,
457
+ fresh,
458
+ }),
459
+ ...(covered.includes("items")
460
+ ? []
461
+ : renderItemChanges({ itemChanges: planChange.itemChanges ?? [], indent })),
462
+ ...renderLicenseChanges({
463
+ licenseChanges: planChange.licenseChanges ?? [],
464
+ indent,
465
+ }),
466
+ ];
467
+
468
+ /**
469
+ * Positive on purpose: listing what counts as a change means a no-op value
470
+ * added to the enum later reads as "nothing to do" rather than leaking into
471
+ * the output as an unknown marker.
472
+ */
473
+ const isChange = (change: PreviewChange): boolean =>
474
+ change.action !== undefined && APPLIED_ACTIONS.has(change.action);
475
+
476
+ /** The no-op end of every action enum the preview uses. */
477
+ const NOOP_ACTIONS = new Set(["none", "skip", "unchanged"]);
478
+
479
+ /** `explicit` and `propagated` say how a variant resolved against the base
480
+ * edit, not that anything changes — only its own diff decides that. */
481
+ const VARIANT_RESOLUTIONS = new Set(["explicit", "propagated"]);
482
+
483
+ const statesPlanChange = (row: Record<string, unknown>): boolean =>
484
+ row.planChange !== null && row.planChange !== undefined;
485
+
486
+ /**
487
+ * A nested row (a variant, a license link, a sibling version) is work when it
488
+ * states a changing action, when a resolution word comes with a diff, or when
489
+ * anything nested under it is work.
490
+ */
491
+ const nestedRowHasWork = (entry: unknown): boolean => {
492
+ if (entry === null || typeof entry !== "object") return false;
493
+ const row = entry as Record<string, unknown>;
494
+ for (const [key, value] of Object.entries(row)) {
495
+ if (Array.isArray(value) && value.some(nestedRowHasWork)) return true;
496
+ if (!key.toLowerCase().endsWith("action") || typeof value !== "string")
497
+ continue;
498
+ if (NOOP_ACTIONS.has(value)) continue;
499
+ if (VARIANT_RESOLUTIONS.has(value)) {
500
+ if (statesPlanChange(row)) return true;
501
+ continue;
502
+ }
503
+ return true;
504
+ }
505
+ return false;
506
+ };
507
+
508
+ /** The one rule the gate and the renderer share: a row counts when its own
509
+ * action is applied, or when anything nested under it has work. */
510
+ const rowHasWork = (row: PreviewChange): boolean =>
511
+ isChange(row) ||
512
+ Object.values(row as Record<string, unknown>).some(
513
+ (value) => Array.isArray(value) && value.some(nestedRowHasWork),
514
+ );
515
+
516
+ const DETAIL_INDENT = " ";
517
+ const VARIANT_INDENT = DETAIL_INDENT;
518
+
519
+ const planRowId = (row: { planId?: string; version?: number }): string =>
520
+ row.version === undefined
521
+ ? (row.planId ?? "?")
522
+ : `${row.planId}@v${row.version}`;
523
+
524
+ /** A row printed only to place the work nested under it: no marker, no colour. */
525
+ const contextLine = ({
526
+ id,
527
+ label,
528
+ indent = " ",
529
+ }: {
530
+ id: string;
531
+ label?: string;
532
+ indent?: string;
533
+ }): string => {
534
+ const suffix = label && label !== id ? chalk.dim(` ${label}`) : "";
535
+ return `${indent} ${chalk.dim(id)}${suffix}`;
536
+ };
537
+
538
+ /** A variant with no stable id yet is one this update mints. */
539
+ const isVariantCreate = (variant: VariantChange): boolean =>
540
+ variant.internalId === null || variant.internalId === undefined;
541
+
542
+ /** A minted row has no `name` of its own, so the edit that mints it names it. */
543
+ const variantCreateLabel = (planChange: PlanChangeLite | undefined): string => {
544
+ const name = planChange?.customize?.name;
545
+ const price = formatPrice(planChange?.priceChange?.current);
546
+ return typeof name === "string" ? `${name}, ${price}` : price;
547
+ };
548
+
549
+ /**
550
+ * One variant under its base: a propagated row only names the change it takes,
551
+ * a create carries its price on the row and its items below, and an edit reads
552
+ * as any other plan diff.
553
+ */
554
+ const renderVariantRow = ({
555
+ variant,
556
+ baseId,
557
+ indent,
558
+ }: {
559
+ variant: VariantChange;
560
+ baseId: string;
561
+ indent: string;
562
+ }): string[] => {
563
+ const id = planRowId(variant);
564
+ const detailIndent = `${indent} `;
565
+ const planChange = variant.planChange ?? undefined;
566
+ const nested = (variant.siblingVersions ?? [])
567
+ .filter(nestedRowHasWork)
568
+ .flatMap((sibling) =>
569
+ renderVariantRow({ variant: sibling, baseId, indent: detailIndent }),
570
+ );
571
+ if (variant.variantAction === "propagated") {
572
+ return [
573
+ `${line({ action: "update", id, indent })}${chalk.dim(` follows ${baseId}`)}`,
574
+ ...nested,
575
+ ];
576
+ }
577
+ if (isVariantCreate(variant)) {
578
+ return [
579
+ line({
580
+ action: "create",
581
+ id,
582
+ label: variantCreateLabel(planChange),
583
+ indent,
584
+ }),
585
+ ...renderItemChanges({
586
+ itemChanges: planChange?.itemChanges ?? [],
587
+ indent: detailIndent,
588
+ }),
589
+ // The row's label carries the name and the price, and the items are
590
+ // already out; everything else the server sent still belongs here.
591
+ ...(planChange
592
+ ? renderPlanChangeDetail({
593
+ planChange,
594
+ indent: detailIndent,
595
+ covered: ["name", "price", "items"],
596
+ })
597
+ : []),
598
+ ...nested,
599
+ ];
600
+ }
601
+ return [
602
+ line({ action: "update", id, indent }),
603
+ ...(planChange
604
+ ? renderPlanChangeDetail({
605
+ planChange,
606
+ current: currentAttributes(variant),
607
+ indent: detailIndent,
608
+ })
609
+ : []),
610
+ ...nested,
611
+ ];
612
+ };
613
+
614
+ /** Variants hang off the edited row and off every sibling version an
615
+ * `all_versions` edit fans out to; each lane names its own base. */
616
+ const renderVariantLanes = ({ plan }: { plan: PlanChange }): string[] =>
617
+ [
618
+ { baseId: planRowId(plan), variants: plan.variants ?? [] },
619
+ ...(plan.siblingVersions ?? []).map((sibling) => ({
620
+ baseId: planRowId(sibling),
621
+ variants: sibling.variants ?? [],
622
+ })),
623
+ ].flatMap(({ baseId, variants }) =>
624
+ variants
625
+ .filter(nestedRowHasWork)
626
+ .flatMap((variant) =>
627
+ renderVariantRow({ variant, baseId, indent: VARIANT_INDENT }),
628
+ ),
629
+ );
630
+
631
+ /** The row's own scalars, so a previous value can complete its arrow. */
632
+ const currentAttributes = (plan: {
633
+ name?: string;
634
+ active?: boolean;
635
+ }): Record<string, unknown> => ({
636
+ ...(plan.name === undefined ? {} : { name: plan.name }),
637
+ ...(plan.active === undefined ? {} : { active: plan.active }),
638
+ });
639
+
640
+ /** A minted version is an `update` of its plan, yet the row itself is new:
641
+ * the server diffs it against the version it clones, which is not a "before". */
642
+ const isMintedVersion = (plan: PlanChange): boolean =>
643
+ plan.versioning?.resolved === "new_version" ||
644
+ (plan.versioning?.newVersion !== null &&
645
+ plan.versioning?.newVersion !== undefined);
646
+
647
+ /** A row that did not exist before this update: a create, or a minted version. */
648
+ const isFreshRow = (plan: PlanChange): boolean =>
649
+ plan.action === "create" || isMintedVersion(plan);
650
+
651
+ /** The plan's own line — a marker when it changes, context when its variants
652
+ * are the only work — then its identity moves, its diff, then those variants. */
653
+ const renderPlanRow = ({ plan }: { plan: PlanChange }): string[] => {
654
+ const id = planRowId(plan);
655
+ const fresh = isFreshRow(plan);
656
+ return [
657
+ isChange(plan)
658
+ ? line({ action: fresh ? "create" : plan.action, id, label: plan.name })
659
+ : contextLine({ id, label: plan.name }),
660
+ ...renderIdentityChanges({ row: plan, indent: DETAIL_INDENT }),
661
+ ...(plan.planChange
662
+ ? renderPlanChangeDetail({
663
+ planChange: plan.planChange,
664
+ current: currentAttributes(plan),
665
+ indent: DETAIL_INDENT,
666
+ fresh,
667
+ })
668
+ : []),
669
+ ...renderVariantLanes({ plan }),
670
+ ];
671
+ };
672
+
673
+ /** A row that keeps its stable id but changes what it is called: plan id, version slug. */
674
+ const renderIdentityChanges = ({
675
+ row,
676
+ indent,
677
+ }: {
678
+ row: Pick<
679
+ PlanChange,
680
+ "planId" | "newPlanId" | "versionSlug" | "newVersionSlug"
681
+ >;
682
+ indent: string;
683
+ }): string[] => {
684
+ const { symbol, paint } = marker("update");
685
+ const moves: [string, string | undefined, string | undefined][] = [
686
+ ["Plan id", row.planId, row.newPlanId],
687
+ ["Version slug", row.versionSlug, row.newVersionSlug],
688
+ ];
689
+ return moves.flatMap(([label, previous, next]) =>
690
+ next === undefined || next === previous
691
+ ? []
692
+ : [
693
+ `${indent}${paint(`${symbol} ${label}: ${formatValue(previous)} -> ${formatValue(next)}`)}`,
694
+ ],
695
+ );
696
+ };
697
+
698
+ /** A migration the preview says a push would draft: no id yet, only its targets. */
699
+ export type PlannedMigration = {
700
+ id?: string;
701
+ plans?: { planId: string; versions?: number[] }[];
702
+ includeCustom?: boolean;
703
+ };
704
+
705
+ /**
706
+ * The plan row a migration target names: the top-level row for that version,
707
+ * else the sibling version one of them lists. Undefined when nothing matches —
708
+ * another version's customer count and diff would describe the wrong move.
709
+ */
710
+ const planRowForTarget = ({
711
+ plans,
712
+ planId,
713
+ version,
714
+ }: {
715
+ plans: PlanChange[];
716
+ planId: string;
717
+ version: number;
718
+ }): PlanChange | undefined => {
719
+ const rows = plans.filter((plan) => plan.planId === planId);
720
+ const direct = rows.find((row) => row.version === version);
721
+ if (direct !== undefined) return direct;
722
+ for (const row of rows) {
723
+ const sibling = (row.siblingVersions ?? []).find(
724
+ (candidate) => candidate.version === version,
725
+ );
726
+ if (sibling !== undefined) return { ...row, ...sibling };
727
+ }
728
+ return undefined;
729
+ };
730
+
731
+ const customerCount = ({ row }: { row: PlanChange | undefined }): string => {
732
+ const customers = (
733
+ row?.state as
734
+ | { usage?: { customers?: { count?: number; countCapped?: boolean } } }
735
+ | undefined
736
+ )?.usage?.customers;
737
+ if (customers?.count === undefined) return "";
738
+ const count = `${customers.count}${customers.countCapped ? "+" : ""}`;
739
+ return `, ${count} customer${customers.count === 1 && !customers.countCapped ? "" : "s"}`;
740
+ };
741
+
742
+ /**
743
+ * What each migration is: the plan version whose customers it moves, how many
744
+ * of them, and the changes those customers receive — the target row's own diff.
745
+ */
746
+ export const renderPlannedMigrations = ({
747
+ migrations,
748
+ plans,
749
+ }: {
750
+ migrations: PlannedMigration[];
751
+ plans: PlanChange[];
752
+ }): string =>
753
+ [
754
+ chalk.bold(`Migrations (${migrations.length})`),
755
+ ...migrations.flatMap((migration) =>
756
+ (migration.plans ?? []).flatMap((target) =>
757
+ (target.versions ?? []).flatMap((version) => {
758
+ const row = planRowForTarget({
759
+ plans,
760
+ planId: target.planId,
761
+ version,
762
+ });
763
+ const custom = migration.includeCustom
764
+ ? ", customized plans too"
765
+ : "";
766
+ return [
767
+ ` ${chalk.cyan(`${target.planId} v${version}`)}${customerCount({ row })}${custom}`,
768
+ ...(row?.planChange
769
+ ? renderPlanChangeDetail({
770
+ planChange: row.planChange,
771
+ indent: DETAIL_INDENT,
772
+ })
773
+ : []),
774
+ ];
775
+ }),
776
+ ),
777
+ ),
778
+ ].join("\n");
779
+
780
+ /** The drafted migrations, one link per line. */
781
+ export const renderMigrationLinks = ({
782
+ migrations,
783
+ migrationLinkBase,
784
+ }: {
785
+ migrations: { id?: string }[];
786
+ migrationLinkBase?: string;
787
+ }): string =>
788
+ [
789
+ chalk.bold(`Draft migrations (${migrations.length})`),
790
+ ...migrations.map((migration) =>
791
+ migrationLinkBase && migration.id
792
+ ? ` ${chalk.cyan(`${migrationLinkBase}/migrations/${migration.id}`)}`
793
+ : ` ${chalk.cyan(migration.id ?? "?")}`,
794
+ ),
795
+ ].join("\n");
796
+
797
+ /** An unmanaged flag is one the config no longer states: atmn leaves it as it
798
+ * is, and the only way to turn it off is to state it off. */
799
+ const UNMANAGED_NOTE =
800
+ "unmanaged (set false explicitly to disable; atmn won't override)";
801
+
802
+ const renderSettingChange = ({ change }: { change: SettingChange }): string => {
803
+ const label = labelFor(change.key ?? "?");
804
+ if (change.action === "unmanaged") {
805
+ const { symbol, paint } = marker("update");
806
+ return `${DETAIL_INDENT}${paint(`${symbol} ${label}: ${formatValue(change.previous)} -> ${UNMANAGED_NOTE}`)}`;
807
+ }
808
+ const { symbol, paint } = marker("update");
809
+ return `${DETAIL_INDENT}${paint(`${symbol} ${label}: ${formatValue(change.previous)} -> ${formatValue(change.current)}`)}`;
810
+ };
811
+
812
+ /** A stated flag that moves, or an unstated one sitting off its default. */
813
+ const settingChanges = (
814
+ settings: SettingsPreview | undefined,
815
+ ): SettingChange[] => settings?.config?.changes ?? [];
816
+
817
+ export const settingsHaveWork = ({
818
+ settings,
819
+ }: {
820
+ settings: SettingsPreview | undefined;
821
+ }): boolean =>
822
+ settingChanges(settings).some((change) => change.action === "update");
823
+
824
+ export const renderPreview = ({
825
+ preview,
826
+ migrationLinkBase,
827
+ }: {
828
+ preview: CatalogPreview;
829
+ /** Omitted in tests; the dashboard origin in real runs. */
830
+ migrationLinkBase?: string;
831
+ }): string => {
832
+ const features = (preview.features ?? []).filter(rowHasWork);
833
+ const plans = (preview.plans ?? []).filter(rowHasWork);
834
+ const rewards = (preview.rewards ?? []).filter(rowHasWork);
835
+ const referralPrograms = (preview.referralPrograms ?? []).filter(rowHasWork);
836
+ const migrations = preview.migrations ?? [];
837
+ const settings = settingChanges(preview.settings);
838
+
839
+ if (
840
+ features.length === 0 &&
841
+ plans.length === 0 &&
842
+ rewards.length === 0 &&
843
+ referralPrograms.length === 0 &&
844
+ settings.length === 0
845
+ ) {
846
+ return chalk.dim("No changes. Your catalog matches your config.");
847
+ }
848
+
849
+ const sections: string[] = [];
850
+
851
+ if (settings.length > 0) {
852
+ sections.push(
853
+ [
854
+ chalk.bold(`Settings (${settings.length})`),
855
+ ...settings.map((change) => renderSettingChange({ change })),
856
+ ].join("\n"),
857
+ );
858
+ }
859
+
860
+ if (features.length > 0) {
861
+ sections.push(
862
+ [
863
+ chalk.bold(`Features (${features.length})`),
864
+ ...features.flatMap((feature) => [
865
+ line({
866
+ action: feature.action,
867
+ id: feature.featureId ?? "?",
868
+ label: feature.name,
869
+ }),
870
+ ...renderPreviousAttributes({
871
+ attributes: feature.previousAttributes,
872
+ indent: DETAIL_INDENT,
873
+ }),
874
+ ]),
875
+ ].join("\n"),
876
+ );
877
+ }
878
+
879
+ if (plans.length > 0) {
880
+ sections.push(
881
+ [
882
+ chalk.bold(`Plans (${plans.length})`),
883
+ ...plans.flatMap((plan) => renderPlanRow({ plan })),
884
+ ].join("\n"),
885
+ );
886
+ }
887
+
888
+ if (rewards.length > 0) {
889
+ sections.push(
890
+ [
891
+ chalk.bold(`Rewards (${rewards.length})`),
892
+ ...rewards.flatMap((reward) => [
893
+ line({
894
+ action: reward.action,
895
+ id: reward.id ?? "?",
896
+ label: reward.name ?? reward.kind,
897
+ }),
898
+ ...renderPreviousAttributes({
899
+ attributes: reward.previousAttributes,
900
+ indent: DETAIL_INDENT,
901
+ }),
902
+ ]),
903
+ ].join("\n"),
904
+ );
905
+ }
906
+
907
+ if (referralPrograms.length > 0) {
908
+ sections.push(
909
+ [
910
+ chalk.bold(`Referral programs (${referralPrograms.length})`),
911
+ ...referralPrograms.flatMap((program) => [
912
+ line({
913
+ action: program.action,
914
+ id: program.id ?? "?",
915
+ label: program.rewardId ?? undefined,
916
+ }),
917
+ ...renderPreviousAttributes({
918
+ attributes: program.previousAttributes,
919
+ indent: DETAIL_INDENT,
920
+ }),
921
+ ]),
922
+ ].join("\n"),
923
+ );
924
+ }
925
+
926
+ if (migrations.length > 0) {
927
+ // The server saying customers would need moving. Nothing is drafted by a
928
+ // preview; the applied block after --yes carries the ids and links.
929
+ sections.push(renderPlannedMigrations({ migrations, plans }));
930
+ }
931
+
932
+ return sections.join("\n\n");
933
+ };
934
+
935
+ /** True when there is nothing to apply — lets push skip the write entirely. */
936
+ export const previewIsEmpty = ({
937
+ preview,
938
+ }: {
939
+ preview: CatalogPreview;
940
+ }): boolean =>
941
+ !(preview.features ?? []).some(rowHasWork) &&
942
+ !(preview.plans ?? []).some(rowHasWork) &&
943
+ !(preview.rewards ?? []).some(rowHasWork) &&
944
+ !(preview.referralPrograms ?? []).some(rowHasWork) &&
945
+ !settingsHaveWork({ settings: preview.settings });