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.
- package/README.md +120 -0
- package/dist/bin.js +23102 -0
- package/dist/index.js +2906 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -74
- package/src/actions/api/callApi.ts +280 -0
- package/src/actions/api/registerApiCommands.ts +121 -0
- package/src/actions/env/fetchOrgInfo.ts +32 -0
- package/src/actions/env/types/orgInfo.ts +18 -0
- package/src/actions/env.ts +94 -0
- package/src/actions/init/runInit.ts +401 -0
- package/src/actions/login/keyless.ts +135 -0
- package/src/actions/login.ts +144 -0
- package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
- package/src/actions/pull/applyPreview.ts +553 -0
- package/src/actions/pull/applySettingsPreview.ts +114 -0
- package/src/actions/pull/changedFixtureKeys.ts +88 -0
- package/src/actions/pull/listSourceFiles.ts +29 -0
- package/src/actions/pull/locateFixture.ts +78 -0
- package/src/actions/pull/resolveCollectionTarget.ts +198 -0
- package/src/actions/pull/rewriteConfig.ts +57 -0
- package/src/actions/pull/scaffoldConfig.ts +118 -0
- package/src/actions/pull.ts +399 -0
- package/src/actions/push/backfillInternalIds.ts +394 -0
- package/src/actions/push/deprecatedFields.ts +55 -0
- package/src/actions/push.ts +294 -0
- package/src/actions/reset/runReset.ts +58 -0
- package/src/actions/sandbox/createSandbox.ts +83 -0
- package/src/actions/sandbox/deleteSandbox.ts +91 -0
- package/src/actions/sandbox/listSandboxes.ts +28 -0
- package/src/actions/sandbox/types/sandboxClient.ts +16 -0
- package/src/actions/sandbox/useSandbox.ts +157 -0
- package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
- package/src/actions/skills/skills.ts +246 -0
- package/src/auth/announceAuthorizationUrl.ts +26 -0
- package/src/auth/browser/openSystemBrowser.ts +7 -0
- package/src/auth/browser/tryOpenBrowser.ts +17 -0
- package/src/auth/browser/watchLauncher.ts +46 -0
- package/src/auth/buildAuthorizationUrl.ts +36 -0
- package/src/auth/callbackPages.ts +126 -0
- package/src/auth/createOrgApiKeys.ts +46 -0
- package/src/auth/keyless.ts +119 -0
- package/src/auth/oauthConfig.ts +63 -0
- package/src/auth/runOAuthFlow.ts +230 -0
- package/src/auth/types/browserOpener.ts +5 -0
- package/src/auth/types/impersonationTokens.ts +23 -0
- package/src/auth/types/oauthTokens.ts +13 -0
- package/src/auth/types/orgApiKeys.ts +6 -0
- package/src/bin.ts +9 -0
- package/src/cli.ts +648 -0
- package/src/config/configPackageName.ts +9 -0
- package/src/config/legacyConfig.ts +25 -0
- package/src/config/loadConfig.ts +231 -0
- package/src/env/assertSandboxTarget.ts +20 -0
- package/src/env/loadEnv.ts +184 -0
- package/src/env/resolveTarget.ts +134 -0
- package/src/env/sandboxKeyName.ts +18 -0
- package/src/generated/apiRoutes.ts +3787 -0
- package/src/generated/client.ts +51564 -0
- package/src/generated/emit.ts +1163 -0
- package/src/generated/emitRuntime.ts +522 -0
- package/src/generated/features.ts +146 -0
- package/src/generated/labels.ts +29 -0
- package/src/generated/licenses.ts +287 -0
- package/src/generated/lintRules.ts +2207 -0
- package/src/generated/lintRuntime.ts +865 -0
- package/src/generated/plans.ts +1628 -0
- package/src/generated/referralPrograms.ts +22 -0
- package/src/generated/rewards.ts +58 -0
- package/src/generated/settings.ts +21 -0
- package/src/generated/skills.ts +305 -0
- package/src/generated/variants.ts +934 -0
- package/src/generated/wire.ts +334 -0
- package/src/http/autumnFetch.ts +30 -0
- package/src/index.ts +20 -0
- package/src/project/chooseConfigDir.ts +41 -0
- package/src/project/resolveProject.ts +115 -0
- package/src/project/rootMarker.ts +40 -0
- package/src/prompt/prompt.ts +186 -0
- package/src/prompt/select.ts +162 -0
- package/src/render/renderEnv.ts +77 -0
- package/src/render/renderPreview.ts +945 -0
- package/src/render/renderSandboxes.ts +92 -0
- package/src/render/stripTerminalControls.ts +19 -0
- package/src/repo/findRepoRoot.ts +79 -0
- package/src/surgery/appendPropertyEdit.ts +67 -0
- package/src/surgery/appendToArray.ts +87 -0
- package/src/surgery/appendToBinding.ts +19 -0
- package/src/surgery/appendToCollection.ts +40 -0
- package/src/surgery/appendToFixtureArray.ts +71 -0
- package/src/surgery/arrayBinding.ts +30 -0
- package/src/surgery/deleteFixtureLiteral.ts +81 -0
- package/src/surgery/deleteReference.ts +48 -0
- package/src/surgery/ensureBuilderImport.ts +65 -0
- package/src/surgery/findFixture.ts +238 -0
- package/src/surgery/fixtureEdit.ts +156 -0
- package/src/surgery/fixtureLocation.ts +32 -0
- package/src/surgery/insertCollection.ts +86 -0
- package/src/surgery/insertFirstProperty.ts +73 -0
- package/src/surgery/patchFixtureProperty.ts +152 -0
- package/src/surgery/patchSingletonProperty.ts +221 -0
- package/src/surgery/replaceFixture.ts +28 -0
- package/src/surgery/setFixtureProperty.ts +55 -0
- package/src/surgery/staticFixtureRule.ts +48 -0
- package/src/version.ts +5 -0
- package/dist/cli.js +0 -146296
- package/dist/compose/index.js +0 -122
- package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
- package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
- package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
- package/dist/src/compose/index.d.ts +0 -19
- package/dist/src/compose/models/featureModels.d.ts +0 -262
- package/dist/src/compose/models/index.d.ts +0 -3
- package/dist/src/compose/models/planModels.d.ts +0 -562
- package/dist/src/compose/models/rewardModels.d.ts +0 -52
- package/dist/src/compose/models/variantModels.d.ts +0 -34
- 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 });
|