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,294 @@
1
+ import { loadConfig } from "../config/loadConfig";
2
+ import { loadEnvFiles } from "../env/loadEnv";
3
+ import type { AutumnClient } from "../generated/client";
4
+ import { splitWire } from "../generated/wire";
5
+ import { resolveProject } from "../project/resolveProject";
6
+ import {
7
+ type CatalogPreview,
8
+ previewIsEmpty,
9
+ renderMigrationLinks,
10
+ renderPreview,
11
+ type SettingsPreview,
12
+ settingsHaveWork,
13
+ } from "../render/renderPreview";
14
+ import {
15
+ backfillInternalIds,
16
+ identityRowsFromApplied,
17
+ } from "./push/backfillInternalIds";
18
+ import {
19
+ deprecatedUsesIn,
20
+ renderDeprecatedUses,
21
+ } from "./push/deprecatedFields";
22
+ import { withSettingsScopeHint } from "./sandbox/withSandboxScopeHint";
23
+
24
+ export type PushResult = {
25
+ configPath: string;
26
+ preview: CatalogPreview;
27
+ /** Absent on a dry run, or when the preview showed nothing to do. */
28
+ applied?: unknown;
29
+ migrationIds: string[];
30
+ };
31
+
32
+ /** The settings lane of one push: absent when the config states no `settings`. */
33
+ const previewSettings = async ({
34
+ client,
35
+ body,
36
+ }: {
37
+ client: AutumnClient;
38
+ body: Record<string, unknown> | undefined;
39
+ }): Promise<SettingsPreview | undefined> => {
40
+ if (body === undefined) return undefined;
41
+ try {
42
+ return (await client.previewUpdateOrganization(body)) as SettingsPreview;
43
+ } catch (error) {
44
+ throw withSettingsScopeHint({ error });
45
+ }
46
+ };
47
+
48
+ export type PushOptions = {
49
+ client: AutumnClient;
50
+ cwd?: string;
51
+ /** `-c`: the config file or its folder; found from cwd or the root marker otherwise. */
52
+ configPath?: string;
53
+ dryRun?: boolean;
54
+ /** Where to write progress. Injected so tests can capture it. */
55
+ write?: (text: string) => void;
56
+ migrationLinkBase?: string;
57
+ };
58
+
59
+ type RewardBody = { id?: string; internal_id?: string };
60
+
61
+ type WireLike = {
62
+ features?: { feature_id?: string; internal_id?: string }[];
63
+ plans?: { plan_id?: string; internal_id?: string }[];
64
+ rewards?: { coupon?: RewardBody; feature_grant?: RewardBody }[];
65
+ referral_programs?: { id?: string; internal_id?: string }[];
66
+ };
67
+
68
+ const fixtureCount = (count: number): string =>
69
+ `${count} fixture${count === 1 ? "" : "s"}`;
70
+
71
+ /** One count per field: the two sets overlap only sometimes, and a fixture
72
+ * that took just one of them must not read as having taken both. */
73
+ export const backfillSummary = ({
74
+ backfilled,
75
+ slugged,
76
+ }: {
77
+ backfilled: string[];
78
+ slugged: string[];
79
+ }): string =>
80
+ `Wrote ${[
81
+ ...(backfilled.length > 0
82
+ ? [`internalId into ${fixtureCount(backfilled.length)}`]
83
+ : []),
84
+ ...(slugged.length > 0
85
+ ? [`versionSlug into ${fixtureCount(slugged.length)}`]
86
+ : []),
87
+ ].join(" and ")}.\n`;
88
+
89
+ /**
90
+ * A rename the config cannot express shows up as a delete beside a create
91
+ * with no internalId. The push may be right, so this is a note, not a refusal.
92
+ */
93
+ export const possibleRenameHint = ({
94
+ preview,
95
+ wire,
96
+ }: {
97
+ preview: CatalogPreview;
98
+ wire: WireLike;
99
+ }): string | null => {
100
+ const lanes: {
101
+ rows: {
102
+ action?: string;
103
+ featureId?: string;
104
+ planId?: string;
105
+ id?: string;
106
+ }[];
107
+ idOf: (row: {
108
+ featureId?: string;
109
+ planId?: string;
110
+ id?: string;
111
+ }) => string | undefined;
112
+ stated: Set<string>;
113
+ noun: string;
114
+ }[] = [
115
+ {
116
+ rows: preview.features ?? [],
117
+ idOf: (row) => row.featureId,
118
+ stated: new Set(
119
+ (wire.features ?? [])
120
+ .filter((row) => row.internal_id === undefined)
121
+ .map((row) => row.feature_id ?? ""),
122
+ ),
123
+ noun: "feature",
124
+ },
125
+ {
126
+ rows: preview.plans ?? [],
127
+ idOf: (row) => row.planId,
128
+ stated: new Set(
129
+ (wire.plans ?? [])
130
+ .filter((row) => row.internal_id === undefined)
131
+ .map((row) => row.plan_id ?? ""),
132
+ ),
133
+ noun: "plan",
134
+ },
135
+ {
136
+ rows: preview.rewards ?? [],
137
+ idOf: (row) => row.id,
138
+ stated: new Set(
139
+ (wire.rewards ?? [])
140
+ .map((row) => row.coupon ?? row.feature_grant)
141
+ .filter(
142
+ (body) => body !== undefined && body.internal_id === undefined,
143
+ )
144
+ .map((body) => body?.id ?? ""),
145
+ ),
146
+ noun: "reward",
147
+ },
148
+ {
149
+ rows: preview.referralPrograms ?? [],
150
+ idOf: (row) => row.id,
151
+ stated: new Set(
152
+ (wire.referral_programs ?? [])
153
+ .filter((row) => row.internal_id === undefined)
154
+ .map((row) => row.id ?? ""),
155
+ ),
156
+ noun: "referral program",
157
+ },
158
+ ];
159
+ const notes: string[] = [];
160
+ for (const lane of lanes) {
161
+ const deleted = lane.rows
162
+ .filter((row) => row.action === "delete")
163
+ .map((row) => lane.idOf(row) ?? "?");
164
+ const createdWithoutId = lane.rows
165
+ .filter((row) => row.action === "create")
166
+ .map((row) => lane.idOf(row) ?? "?")
167
+ .filter((id) => lane.stated.has(id));
168
+ if (deleted.length === 0 || createdWithoutId.length === 0) continue;
169
+ notes.push(
170
+ `Note: this push removes ${lane.noun} ${deleted.join(", ")} and creates ${createdWithoutId.join(", ")} without an internalId. If that is a rename, pull first so the fixture carries its id, or it will be treated as delete + create.`,
171
+ );
172
+ }
173
+ return notes.length > 0 ? notes.join("\n") : null;
174
+ };
175
+
176
+ /** The directories a config may live in, nearest first. */
177
+ export const configSearchDirs = ({
178
+ cwd,
179
+ configPath,
180
+ }: {
181
+ cwd: string;
182
+ configPath?: string;
183
+ }): string[] => {
184
+ const project = resolveProject({ cwd, configFlag: configPath });
185
+ return [...new Set([project.configDir, cwd, ...project.envDirs])];
186
+ };
187
+
188
+ /**
189
+ * Preview, then apply. The CLI decides nothing here — it sends the same
190
+ * document twice and renders what comes back, which is why a clean preview is
191
+ * a real guarantee rather than a guess.
192
+ */
193
+ export const runPush = async ({
194
+ client,
195
+ cwd = process.cwd(),
196
+ configPath: configFlag,
197
+ dryRun = false,
198
+ write = (text) => process.stdout.write(text),
199
+ migrationLinkBase,
200
+ }: PushOptions): Promise<PushResult> => {
201
+ const project = resolveProject({ cwd, configFlag });
202
+ const dirs = configSearchDirs({ cwd, configPath: configFlag });
203
+ loadEnvFiles({ dirs: project.envDirs });
204
+
205
+ const { path: configPath, wire: document } = await loadConfig({
206
+ dirs,
207
+ ...(project.configPath === null ? {} : { configPath: project.configPath }),
208
+ });
209
+ // One document, two operations: the catalog and each singleton go their own way.
210
+ const { catalog: wire, singletons } = splitWire(document);
211
+
212
+ const deprecated = deprecatedUsesIn({ wire });
213
+ if (deprecated.length > 0)
214
+ write(`${renderDeprecatedUses({ uses: deprecated })}\n\n`);
215
+
216
+ // Settings go first, on their own: a flag like multi_currency changes what
217
+ // the catalog accepts, so the catalog is previewed against the settings as
218
+ // they will be, and a settings write can never fail on the catalog's account.
219
+ const settingsBody = singletons.settings;
220
+ const settings = await previewSettings({ client, body: settingsBody });
221
+ const settingsWork =
222
+ settingsBody !== undefined && settingsHaveWork({ settings });
223
+ if (settingsWork) {
224
+ write(`${renderPreview({ preview: { settings } })}\n`);
225
+ if (!dryRun) {
226
+ try {
227
+ await client.updateOrganization(settingsBody);
228
+ } catch (error) {
229
+ throw withSettingsScopeHint({ error });
230
+ }
231
+ write("\nApplied settings.\n\n");
232
+ }
233
+ }
234
+
235
+ const catalogPreview = (await client.previewUpdate(wire)) as CatalogPreview;
236
+ // The settings lane is already printed when it applied; the unmanaged
237
+ // notes still belong beside the catalog's own rows.
238
+ const preview: CatalogPreview = {
239
+ ...catalogPreview,
240
+ ...(settingsWork ? {} : { settings }),
241
+ };
242
+
243
+ write(`${renderPreview({ preview, migrationLinkBase })}\n`);
244
+
245
+ const renameHint = possibleRenameHint({ preview, wire: wire as WireLike });
246
+ if (renameHint !== null) write(`${renameHint}\n\n`);
247
+
248
+ const fullPreview: CatalogPreview = { ...catalogPreview, settings };
249
+ if (previewIsEmpty({ preview: catalogPreview })) {
250
+ return { configPath, preview: fullPreview, migrationIds: [] };
251
+ }
252
+ if (dryRun) return { configPath, preview: fullPreview, migrationIds: [] };
253
+
254
+ const applied = (await client.update(wire)) as {
255
+ migrations?: { id?: string }[];
256
+ results?: Record<string, unknown>;
257
+ };
258
+
259
+ const migrationIds = (applied.migrations ?? [])
260
+ .map((migration) => migration.id)
261
+ .filter((id): id is string => typeof id === "string");
262
+
263
+ write("\nApplied.\n");
264
+ if (migrationIds.length > 0) {
265
+ write(
266
+ `${renderMigrationLinks({
267
+ migrations: migrationIds.map((id) => ({ id })),
268
+ migrationLinkBase,
269
+ })}\n\n`,
270
+ );
271
+ }
272
+
273
+ // The update response has no variant edges; when the config states any,
274
+ // the catalog's plan rows (with each variant's resolved plan) fill them in.
275
+ const statesVariants = (
276
+ (wire as { plans?: { variants?: unknown[] }[] }).plans ?? []
277
+ ).some((row) => Array.isArray(row.variants) && row.variants.length > 0);
278
+ const rows = identityRowsFromApplied({ applied });
279
+ if (statesVariants) {
280
+ const catalog = (await client.get({
281
+ include_versions: true,
282
+ })) as unknown as {
283
+ plans?: unknown;
284
+ };
285
+ if (Array.isArray(catalog.plans))
286
+ rows.plans = catalog.plans as typeof rows.plans;
287
+ }
288
+ const { backfilled, slugged } = backfillInternalIds({ rows, configPath });
289
+ if (backfilled.length > 0 || slugged.length > 0) {
290
+ write(backfillSummary({ backfilled, slugged }));
291
+ }
292
+
293
+ return { configPath, preview: fullPreview, applied, migrationIds };
294
+ };
@@ -0,0 +1,58 @@
1
+ import { assertSandboxTarget } from "../../env/assertSandboxTarget";
2
+ import type { Target } from "../../env/resolveTarget";
3
+ import type { AutumnClient } from "../../generated/client";
4
+ import type { WriteLine } from "../sandbox/types/sandboxClient";
5
+
6
+ /** The one operation `atmn reset` needs, so a test can hand over a fake. */
7
+ export type ResetClient = Pick<AutumnClient, "resetSandbox">;
8
+
9
+ export type ResetOptions = {
10
+ client: ResetClient;
11
+ /** Which sandbox is wiped: reset acts on whatever the key belongs to. */
12
+ target: Target;
13
+ /** The pinned sandbox's name, when the CLI could look it up; the id is the fallback. */
14
+ sandboxName?: string;
15
+ /** Nothing is sent without this: the whole catalog goes. */
16
+ yes?: boolean;
17
+ write?: WriteLine;
18
+ };
19
+
20
+ export type ResetResult = { wiped: boolean };
21
+
22
+ /** What the copy calls the sandbox: the pinned one by name, or the main one. */
23
+ const targetName = ({
24
+ target,
25
+ sandboxName,
26
+ }: {
27
+ target: Target;
28
+ sandboxName: string | undefined;
29
+ }): string =>
30
+ target.sandboxId === undefined
31
+ ? "your main sandbox"
32
+ : sandboxName === undefined
33
+ ? `sandbox ${target.sandboxId}`
34
+ : `sandbox ${sandboxName} (${target.sandboxId})`;
35
+
36
+ export const runReset = async ({
37
+ client,
38
+ target,
39
+ sandboxName,
40
+ yes = false,
41
+ write = (text) => process.stdout.write(text),
42
+ }: ResetOptions): Promise<ResetResult> => {
43
+ assertSandboxTarget({ target });
44
+
45
+ const name = targetName({ target, sandboxName });
46
+
47
+ if (!yes) {
48
+ write(
49
+ `This wipes ${name}: every customer, plan, feature and migration draft. Keys and settings stay. Re-run with --yes to wipe.\n`,
50
+ );
51
+ return { wiped: false };
52
+ }
53
+
54
+ await client.resetSandbox({});
55
+
56
+ write(`Wiped ${name}. Run atmn push to rebuild it from your config.\n`);
57
+ return { wiped: true };
58
+ };
@@ -0,0 +1,83 @@
1
+ import { writeEnvValues } from "../../env/loadEnv";
2
+ import { SANDBOX_PIN_NAME, sandboxKeyName } from "../../env/sandboxKeyName";
3
+ import type {
4
+ CreateSandboxParams,
5
+ CreateSandboxResponse,
6
+ } from "../../generated/client";
7
+ import { stripTerminalControls } from "../../render/stripTerminalControls";
8
+ import type { SandboxClient, WriteLine } from "./types/sandboxClient";
9
+
10
+ export type SandboxCreateOptions = {
11
+ client: SandboxClient;
12
+ name: string;
13
+ color?: CreateSandboxParams["color"];
14
+ icon?: string;
15
+ /** Also pin AUTUMN_SANDBOX_ID, so later commands target the new sandbox. */
16
+ use?: boolean;
17
+ json?: boolean;
18
+ /** Where to look for the .env the key is written to. */
19
+ envDirs: string[];
20
+ write?: WriteLine;
21
+ writeError?: WriteLine;
22
+ };
23
+
24
+ export type SandboxCreateResult = {
25
+ sandbox: CreateSandboxResponse;
26
+ envPath: string;
27
+ keyName: string;
28
+ };
29
+
30
+ export const runSandboxCreate = async ({
31
+ client,
32
+ name,
33
+ color,
34
+ icon,
35
+ use = false,
36
+ json = false,
37
+ envDirs,
38
+ write = (text) => process.stdout.write(text),
39
+ writeError = (text) => process.stderr.write(text),
40
+ }: SandboxCreateOptions): Promise<SandboxCreateResult> => {
41
+ const sandbox = await client.createSandbox({
42
+ name,
43
+ ...(color === undefined ? {} : { color }),
44
+ ...(icon === undefined ? {} : { icon }),
45
+ });
46
+
47
+ // The key is readable exactly once, in this response: it reaches disk
48
+ // before anything else can fail.
49
+ const keyName = sandboxKeyName({ sandboxId: sandbox.id });
50
+ let envPath: string;
51
+ try {
52
+ envPath = writeEnvValues({
53
+ dirs: envDirs,
54
+ values: {
55
+ [keyName]: sandbox.secretKey,
56
+ ...(use ? { [SANDBOX_PIN_NAME]: sandbox.id } : {}),
57
+ },
58
+ });
59
+ } catch (error) {
60
+ // The key exists nowhere else: show it before the failure takes it, on
61
+ // stderr under --json so stdout stays parseable.
62
+ (json ? writeError : write)(
63
+ `Could not write your .env. Save this key yourself:\n${keyName}=${sandbox.secretKey}\n`,
64
+ );
65
+ throw error;
66
+ }
67
+
68
+ if (json) {
69
+ write(`${JSON.stringify(sandbox, null, 2)}\n`);
70
+ return { sandbox, envPath, keyName };
71
+ }
72
+
73
+ write(
74
+ `Created sandbox ${stripTerminalControls(sandbox.name)} (${sandbox.id}).\nSaved its key to ${envPath} as ${keyName}.\n`,
75
+ );
76
+ if (use) {
77
+ write(
78
+ `Pinned ${SANDBOX_PIN_NAME}=${sandbox.id}; every command targets it until you change it.\n`,
79
+ );
80
+ }
81
+
82
+ return { sandbox, envPath, keyName };
83
+ };
@@ -0,0 +1,91 @@
1
+ import { removeEnvValues, removeEnvValueWhen } from "../../env/loadEnv";
2
+ import { SANDBOX_PIN_NAME, sandboxKeyName } from "../../env/sandboxKeyName";
3
+ import type { SandboxRow } from "../../render/renderSandboxes";
4
+ import { stripTerminalControls } from "../../render/stripTerminalControls";
5
+ import type { SandboxClient, WriteLine } from "./types/sandboxClient";
6
+
7
+ export type SandboxDeleteOptions = {
8
+ client: SandboxClient;
9
+ id: string;
10
+ /** Nothing is sent without this: delete takes the catalog and the customers too. */
11
+ yes?: boolean;
12
+ /** Where to look for the .env the sandbox's key is scrubbed from. */
13
+ envDirs: string[];
14
+ write?: WriteLine;
15
+ writeError?: WriteLine;
16
+ };
17
+
18
+ export type SandboxDeleteResult = {
19
+ deleted: boolean;
20
+ sandbox: SandboxRow;
21
+ /** Every .env that changed; empty when the sandbox had no key or pin on disk. */
22
+ envPaths: string[];
23
+ };
24
+
25
+ /** The key goes from every file holding it; the pin only where it pointed
26
+ * here, since another sandbox may own it in another file. */
27
+ const scrubSandboxFromEnv = ({
28
+ sandboxId,
29
+ envDirs,
30
+ }: {
31
+ sandboxId: string;
32
+ envDirs: string[];
33
+ }): string[] => {
34
+ const scrubbed = [
35
+ ...removeEnvValues({
36
+ dirs: envDirs,
37
+ keys: [sandboxKeyName({ sandboxId })],
38
+ }),
39
+ ...removeEnvValueWhen({
40
+ dirs: envDirs,
41
+ key: SANDBOX_PIN_NAME,
42
+ equals: sandboxId,
43
+ }),
44
+ ];
45
+ return [...new Set(scrubbed)];
46
+ };
47
+
48
+ export const runSandboxDelete = async ({
49
+ client,
50
+ id,
51
+ yes = false,
52
+ envDirs,
53
+ write = (text) => process.stdout.write(text),
54
+ writeError = (text) => process.stderr.write(text),
55
+ }: SandboxDeleteOptions): Promise<SandboxDeleteResult> => {
56
+ // Listing first names the sandbox in the copy, and turns a typo into an
57
+ // error instead of a request the server has to refuse.
58
+ const { list } = await client.listSandboxes({});
59
+ const sandbox = list.find((entry) => entry.id === id);
60
+ if (sandbox === undefined) {
61
+ throw new Error(`No sandbox with id ${id}. Run atmn sandbox list.`);
62
+ }
63
+
64
+ if (!yes) {
65
+ write(
66
+ `This deletes sandbox ${stripTerminalControls(sandbox.name)} (${sandbox.id}) and everything in it. Re-run with --yes to delete.\n`,
67
+ );
68
+ return { deleted: false, sandbox, envPaths: [] };
69
+ }
70
+
71
+ await client.deleteSandbox({ id: sandbox.id });
72
+ // The remote delete is done and cannot be undone: say so before anything
73
+ // local can fail, so a .env error never reads as a failed deletion.
74
+ write(
75
+ `Deleted sandbox ${stripTerminalControls(sandbox.name)} (${sandbox.id}).\n`,
76
+ );
77
+
78
+ let envPaths: string[];
79
+ try {
80
+ envPaths = scrubSandboxFromEnv({ sandboxId: sandbox.id, envDirs });
81
+ } catch (error) {
82
+ writeError(
83
+ `Sandbox ${sandbox.id} is gone, but its .env could not be rewritten. Remove the line ${sandboxKeyName({ sandboxId: sandbox.id })}=... by hand, and ${SANDBOX_PIN_NAME}=${sandbox.id} if it is there.\n`,
84
+ );
85
+ throw error;
86
+ }
87
+
88
+ if (envPaths.length > 0)
89
+ write(`Dropped its key from ${envPaths.join(", ")}.\n`);
90
+ return { deleted: true, sandbox, envPaths };
91
+ };
@@ -0,0 +1,28 @@
1
+ import type { ListSandboxesResponse } from "../../generated/client";
2
+ import { renderSandboxes } from "../../render/renderSandboxes";
3
+ import type { SandboxClient, WriteLine } from "./types/sandboxClient";
4
+
5
+ export type SandboxListOptions = {
6
+ client: SandboxClient;
7
+ /** The sandbox `--sandbox` or AUTUMN_SANDBOX_ID points at; marked in the table. */
8
+ currentSandboxId?: string;
9
+ json?: boolean;
10
+ write?: WriteLine;
11
+ };
12
+
13
+ export const runSandboxList = async ({
14
+ client,
15
+ currentSandboxId,
16
+ json = false,
17
+ write = (text) => process.stdout.write(text),
18
+ }: SandboxListOptions): Promise<ListSandboxesResponse> => {
19
+ const response = await client.listSandboxes({});
20
+
21
+ if (json) {
22
+ write(`${JSON.stringify(response, null, 2)}\n`);
23
+ return response;
24
+ }
25
+
26
+ write(`${renderSandboxes({ sandboxes: response.list, currentSandboxId })}\n`);
27
+ return response;
28
+ };
@@ -0,0 +1,16 @@
1
+ import type { AutumnClient } from "../../../generated/client";
2
+
3
+ /** The three operations `atmn sandbox` needs, so a test can hand over a fake. */
4
+ export type SandboxClient = Pick<
5
+ AutumnClient,
6
+ "createSandbox" | "listSandboxes" | "deleteSandbox"
7
+ >;
8
+
9
+ /** What `atmn sandbox use` needs: the list to pick from, and a key when none is on disk. */
10
+ export type UseSandboxClient = Pick<
11
+ AutumnClient,
12
+ "listSandboxes" | "createSandboxKey"
13
+ >;
14
+
15
+ /** Where a command writes its progress. Injected so tests can capture it. */
16
+ export type WriteLine = (text: string) => void;