atmn 1.1.24 → 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 -142410
  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 -367
  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,231 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { COLLECTIONS } from "../generated/emit";
5
+ import { ConfigError, type LintIssue } from "../generated/lintRuntime";
6
+ import { fixtureLocation } from "../surgery/fixtureLocation";
7
+ import { configPackageName } from "./configPackageName";
8
+ import { isLegacyConfigText, LegacyConfigError } from "./legacyConfig";
9
+
10
+ const CONFIG_FILENAMES = ["autumn.config.ts", "autumn.config.js"] as const;
11
+
12
+ /** Opaque until the generator emits the real type — the wire document. */
13
+ export type WireDocument = Record<string, unknown>;
14
+
15
+ export class ConfigNotFoundError extends Error {
16
+ constructor(searched: string[]) {
17
+ super(
18
+ `No autumn.config.ts found. Looked in:\n${searched
19
+ .map((path) => ` ${path}`)
20
+ .join(
21
+ "\n",
22
+ )}\n\nRun \`${configPackageName()} init\` to create one, or pass -c <dir>.`,
23
+ );
24
+ this.name = "ConfigNotFoundError";
25
+ }
26
+ }
27
+
28
+ export const findConfigPath = ({ dirs }: { dirs: string[] }): string | null => {
29
+ for (const dir of dirs) {
30
+ for (const filename of CONFIG_FILENAMES) {
31
+ const path = join(dir, filename);
32
+ if (existsSync(path)) return path;
33
+ }
34
+ }
35
+ return null;
36
+ };
37
+
38
+ /** A finding's breadcrumb starts with its top-level fixture, e.g. `feature "x"`. */
39
+ const TOP_LEVEL_FIXTURE = /^(\w+) "([^"]*)"/;
40
+
41
+ /** Where a finding's fixture lives, `undefined` when its breadcrumb names none
42
+ * (a document-level error) or the fixture text can't be found. */
43
+ const locationOf = ({
44
+ issue,
45
+ configPath,
46
+ }: {
47
+ issue: LintIssue;
48
+ configPath: string;
49
+ }): string | undefined => {
50
+ const match = TOP_LEVEL_FIXTURE.exec(issue.path.split(" › ")[0] ?? "");
51
+ if (!match) return undefined;
52
+ const [, builder, id] = match;
53
+ const collection = Object.values(COLLECTIONS).find(
54
+ (spec) => spec.builder === builder,
55
+ );
56
+ if (!collection || id === undefined) return undefined;
57
+
58
+ const found = fixtureLocation({
59
+ configPath,
60
+ builder: collection.builder,
61
+ idField: collection.idField,
62
+ id,
63
+ });
64
+ return found ? `${found.file}:${found.line}` : undefined;
65
+ };
66
+
67
+ /** Every locatable finding gets its source appended, so a config error points
68
+ * back at the file the user actually needs to open. */
69
+ const withFixtureLocations = ({
70
+ error,
71
+ configPath,
72
+ }: {
73
+ error: ConfigError;
74
+ configPath: string;
75
+ }): ConfigError =>
76
+ new ConfigError(
77
+ error.issues.map((issue) => {
78
+ const location = locationOf({ issue, configPath });
79
+ return location
80
+ ? { ...issue, message: `${issue.message} (${location})` }
81
+ : issue;
82
+ }),
83
+ );
84
+
85
+ /**
86
+ * Imports the config in-process — it is the user's own code on their own
87
+ * machine, npm-script trust level. The subprocess + stdout contract that
88
+ * existed for non-TS producers is gone; this function is the seam a future
89
+ * `--config-json` would slot into.
90
+ */
91
+ /**
92
+ * A fresh process per load: the root imports its collection files, and no
93
+ * in-process import — Bun's `?v=` bust, jiti's `moduleCache: false` — re-reads
94
+ * a dependency after the first load. A single push never notices; pull edits
95
+ * the collection files and then re-evaluates the config, so it must.
96
+ */
97
+ /**
98
+ * Bun imports a .ts config natively. Node goes through jiti's own import,
99
+ * which transpiles and handles CJS/ESM interop itself; the package is resolved
100
+ * from this CLI's install, never from the config's folder.
101
+ */
102
+ export const importConfigArgs = ({
103
+ path,
104
+ onBun = typeof Bun !== "undefined",
105
+ }: {
106
+ path: string;
107
+ onBun?: boolean;
108
+ }): string[] => {
109
+ const target = JSON.stringify(path);
110
+ const load = onBun
111
+ ? `import(${target})`
112
+ : `import(${JSON.stringify(import.meta.resolve("jiti"))}).then(({ createJiti }) => createJiti(${target}).import(${target}))`;
113
+ const report = `(m) => process.stdout.write(JSON.stringify({ ok: true, module: { ...m, default: m.default } })), (e) => process.stdout.write(JSON.stringify({ ok: false, name: e?.name, message: e?.message, issues: e?.issues }))`;
114
+ return ["-e", `${load}.then(${report})`];
115
+ };
116
+
117
+ const importConfigModule = async ({
118
+ path,
119
+ }: {
120
+ path: string;
121
+ }): Promise<Record<string, unknown> & { default?: WireDocument }> => {
122
+ const result = spawnSync(process.execPath, importConfigArgs({ path }), {
123
+ cwd: dirname(path),
124
+ encoding: "utf8",
125
+ });
126
+ if (result.status !== 0 || result.stdout.length === 0) {
127
+ throw new Error(result.stderr.trim() || `Failed to load ${path}`);
128
+ }
129
+ const parsed = JSON.parse(result.stdout) as
130
+ | { ok: true; module: Record<string, unknown> & { default?: WireDocument } }
131
+ | { ok: false; name?: string; message?: string; issues?: LintIssue[] };
132
+ if (parsed.ok) return parsed.module;
133
+ if (parsed.name === "ConfigError" && parsed.issues)
134
+ throw new ConfigError(parsed.issues);
135
+ throw new Error(parsed.message ?? `Failed to load ${path}`);
136
+ };
137
+
138
+ export const loadConfig = async ({
139
+ dirs,
140
+ configPath,
141
+ }: {
142
+ dirs: string[];
143
+ /** `-c`: an exact file, which may be named anything. */
144
+ configPath?: string;
145
+ }): Promise<{ path: string; wire: WireDocument }> => {
146
+ // An explicit path is the whole answer: a typo must not quietly load
147
+ // whichever config the search would have found instead.
148
+ const path =
149
+ configPath !== undefined
150
+ ? existsSync(configPath)
151
+ ? configPath
152
+ : null
153
+ : findConfigPath({ dirs });
154
+ if (!path) throw new ConfigNotFoundError(configPath ? [configPath] : dirs);
155
+ // A 1.x config would die inside the import with "item is not exported";
156
+ // say what happened and how to move instead.
157
+ if (isLegacyConfigText({ text: readFileSync(path, "utf8") })) {
158
+ throw new LegacyConfigError({ path });
159
+ }
160
+
161
+ // Cache-busted because the module cache would otherwise pin the first read
162
+ // for the life of the process — irrelevant for a single `atmn push`, wrong
163
+ // for anything that pushes twice (tests today, a watch mode later).
164
+ // The query goes on the plain path: appended to a file:// href Bun
165
+ // normalises it away and serves the cached module.
166
+ let module: Record<string, unknown> & { default?: WireDocument };
167
+ try {
168
+ module = await importConfigModule({ path });
169
+ } catch (error) {
170
+ if (error instanceof ConfigError) {
171
+ throw withFixtureLocations({ error, configPath: path });
172
+ }
173
+ throw error;
174
+ }
175
+ const wire = module.default;
176
+
177
+ if (looksLikeV2Config({ module })) throw new LegacyConfigError({ path });
178
+ if (wire === undefined) {
179
+ throw new Error(
180
+ `${path} has no default export. It should end with \`export default atmn({ ... })\`.`,
181
+ );
182
+ }
183
+
184
+ return { path, wire };
185
+ };
186
+
187
+ /**
188
+ * v2 exported plain fixtures — a default object with `products`/`features`
189
+ * arrays, or named `feature()`/`product()` results — never a wire document,
190
+ * which always carries `skip_deletions`.
191
+ */
192
+ /** Fields only a v2 product carries. `items` is optional — an empty plan
193
+ * states none — so the settings have to name the row on their own. */
194
+ const V2_PRODUCT_FIELDS = [
195
+ "items",
196
+ "is_add_on",
197
+ "is_default",
198
+ "free_trial",
199
+ "group",
200
+ ] as const;
201
+
202
+ /** A v2 fixture: a public `id` beside a feature's `type` or a product's own settings. */
203
+ const isV2Row = (value: unknown): boolean => {
204
+ if (typeof value !== "object" || value === null) return false;
205
+ const row = value as Record<string, unknown>;
206
+ if (!("id" in row)) return false;
207
+ return "type" in row || V2_PRODUCT_FIELDS.some((field) => field in row);
208
+ };
209
+
210
+ export const looksLikeV2Config = ({
211
+ module,
212
+ }: {
213
+ module: Record<string, unknown>;
214
+ }): boolean => {
215
+ const defaults = module.default;
216
+ if (typeof defaults === "object" && defaults !== null) {
217
+ const bag = defaults as Record<string, unknown>;
218
+ if ("skip_deletions" in bag) return false;
219
+ if (
220
+ ["products", "features", "plans", "rewards"].some((key) =>
221
+ Array.isArray(bag[key]),
222
+ )
223
+ )
224
+ return true;
225
+ }
226
+ // A v2 default object proves nothing on its own: the named fixtures beside
227
+ // it still do, so the check runs whatever the default export holds.
228
+ return Object.entries(module).some(
229
+ ([name, value]) => name !== "default" && isV2Row(value),
230
+ );
231
+ };
@@ -0,0 +1,20 @@
1
+ import type { Target } from "./resolveTarget";
2
+
3
+ const TEST_KEY_PREFIX = "am_sk_test";
4
+
5
+ export const SANDBOX_ONLY_REFUSAL =
6
+ "Reset only wipes sandboxes; the key for this target is a live key.";
7
+
8
+ /**
9
+ * A live key never even reaches the server: the wipe is irreversible, so it is
10
+ * refused here rather than left to the server's 400.
11
+ */
12
+ export const assertSandboxTarget = ({ target }: { target: Target }): void => {
13
+ if (target.secretKeyName === "AUTUMN_PROD_SECRET_KEY")
14
+ throw new Error(SANDBOX_ONLY_REFUSAL);
15
+
16
+ const key = process.env[target.secretKeyName];
17
+ // A key that is not set at all is requireSecretKey's error to give.
18
+ if (key !== undefined && !key.startsWith(TEST_KEY_PREFIX))
19
+ throw new Error(SANDBOX_ONLY_REFUSAL);
20
+ };
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "dotenv";
4
+
5
+ /** Later files never override earlier ones; process.env always wins. */
6
+ const FILES_IN_PRECEDENCE_ORDER = [".env.local", ".env"] as const;
7
+
8
+ export type AutumnKeys = {
9
+ sandboxKey?: string;
10
+ prodKey?: string;
11
+ };
12
+
13
+ /**
14
+ * Reads `.env.local` then `.env` into process.env without clobbering anything
15
+ * already set, so injected secrets (Doppler, Infisical, CI) keep priority.
16
+ * Mirrors v2's precedence: process.env → .env.local → .env.
17
+ */
18
+ export const loadEnvFiles = ({ dirs }: { dirs: string[] }): string[] => {
19
+ const loaded: string[] = [];
20
+
21
+ for (const dir of dirs) {
22
+ for (const file of FILES_IN_PRECEDENCE_ORDER) {
23
+ const path = join(dir, file);
24
+ if (!existsSync(path)) continue;
25
+
26
+ try {
27
+ for (const [key, value] of Object.entries(
28
+ parse(readFileSync(path, "utf8")),
29
+ )) {
30
+ if (process.env[key] === undefined) process.env[key] = value;
31
+ }
32
+ loaded.push(path);
33
+ } catch {
34
+ // An unreadable or malformed .env is not fatal — the key may well
35
+ // be in the real environment already.
36
+ }
37
+ }
38
+ }
39
+
40
+ return loaded;
41
+ };
42
+
43
+ export const readAutumnKeys = (): AutumnKeys => ({
44
+ sandboxKey: process.env.AUTUMN_SECRET_KEY,
45
+ prodKey: process.env.AUTUMN_PROD_SECRET_KEY,
46
+ });
47
+
48
+ /** Every env file the search set holds, in the order `loadEnvFiles` reads them. */
49
+ export const findEnvFiles = ({ dirs }: { dirs: string[] }): string[] => {
50
+ const paths: string[] = [];
51
+ for (const dir of dirs) {
52
+ for (const file of FILES_IN_PRECEDENCE_ORDER) {
53
+ const path = join(dir, file);
54
+ if (existsSync(path)) paths.push(path);
55
+ }
56
+ }
57
+ return paths;
58
+ };
59
+
60
+ /** The file `loadEnvFiles` reads first, so writes land where reads look. */
61
+ export const findEnvFile = ({ dirs }: { dirs: string[] }): string | undefined =>
62
+ findEnvFiles({ dirs })[0];
63
+
64
+ const isAssignmentFor = ({
65
+ line,
66
+ key,
67
+ }: {
68
+ line: string;
69
+ key: string;
70
+ }): boolean => line.trimStart().startsWith(`${key}=`);
71
+
72
+ /** Rewrites keys in place and appends the rest; every other line survives. */
73
+ export const upsertEnvContent = ({
74
+ content,
75
+ values,
76
+ }: {
77
+ content: string;
78
+ values: Record<string, string>;
79
+ }): string => {
80
+ const trimmed = content.endsWith("\n") ? content.slice(0, -1) : content;
81
+ const lines = trimmed === "" ? [] : trimmed.split("\n");
82
+
83
+ for (const [key, value] of Object.entries(values)) {
84
+ const index = lines.findIndex((line) => isAssignmentFor({ line, key }));
85
+ if (index === -1) lines.push(`${key}=${value}`);
86
+ else lines[index] = `${key}=${value}`;
87
+ }
88
+
89
+ return `${lines.join("\n")}\n`;
90
+ };
91
+
92
+ /** Drops the assignments for these keys; every other line survives. */
93
+ export const removeEnvContent = ({
94
+ content,
95
+ keys,
96
+ }: {
97
+ content: string;
98
+ keys: string[];
99
+ }): string => {
100
+ const kept = content
101
+ .split("\n")
102
+ .filter((line) => !keys.some((key) => isAssignmentFor({ line, key })));
103
+ // The split leaves a trailing empty segment for the final newline; rejoining
104
+ // without dropping it would grow a blank line on every removal.
105
+ const trimmed = kept[kept.length - 1] === "" ? kept.slice(0, -1) : kept;
106
+ return trimmed.length === 0 ? "" : `${trimmed.join("\n")}\n`;
107
+ };
108
+
109
+ /** What the env file on disk assigns — not what the process happens to carry. */
110
+ export const readEnvFileValue = ({
111
+ dirs,
112
+ key,
113
+ }: {
114
+ dirs: string[];
115
+ key: string;
116
+ }): string | undefined => {
117
+ const path = findEnvFile({ dirs });
118
+ if (path === undefined) return undefined;
119
+ return parse(readFileSync(path, "utf8"))[key];
120
+ };
121
+
122
+ /**
123
+ * Removes keys from every env file that assigns one — a key left in a
124
+ * lower-precedence file would come back the moment the higher one loses it.
125
+ * The paths that changed.
126
+ */
127
+ export const removeEnvValues = ({
128
+ dirs,
129
+ keys,
130
+ }: {
131
+ dirs: string[];
132
+ keys: string[];
133
+ }): string[] => removeWhere({ dirs, keys, keep: () => false });
134
+
135
+ /** Removes a key only from the files assigning it this exact value. */
136
+ export const removeEnvValueWhen = ({
137
+ dirs,
138
+ key,
139
+ equals,
140
+ }: {
141
+ dirs: string[];
142
+ key: string;
143
+ equals: string;
144
+ }): string[] =>
145
+ removeWhere({
146
+ dirs,
147
+ keys: [key],
148
+ keep: ({ content }) => parse(content)[key] !== equals,
149
+ });
150
+
151
+ const removeWhere = ({
152
+ dirs,
153
+ keys,
154
+ keep,
155
+ }: {
156
+ dirs: string[];
157
+ keys: string[];
158
+ keep: (params: { content: string }) => boolean;
159
+ }): string[] => {
160
+ const changed: string[] = [];
161
+ for (const path of findEnvFiles({ dirs })) {
162
+ const content = readFileSync(path, "utf8");
163
+ if (keep({ content })) continue;
164
+ const next = removeEnvContent({ content, keys });
165
+ if (next === content) continue;
166
+ writeFileSync(path, next);
167
+ changed.push(path);
168
+ }
169
+ return changed;
170
+ };
171
+
172
+ export const writeEnvValues = ({
173
+ dirs,
174
+ values,
175
+ }: {
176
+ dirs: string[];
177
+ values: Record<string, string>;
178
+ }): string => {
179
+ const path = findEnvFile({ dirs }) ?? join(dirs[0] ?? process.cwd(), ".env");
180
+ const content = existsSync(path) ? readFileSync(path, "utf8") : "";
181
+
182
+ writeFileSync(path, upsertEnvContent({ content, values }));
183
+ return path;
184
+ };
@@ -0,0 +1,134 @@
1
+ import { CLI_CLIENT_ID } from "../auth/oauthConfig";
2
+ import {
3
+ SANDBOX_PIN_NAME,
4
+ type SandboxSecretKeyName,
5
+ sandboxKeyName,
6
+ } from "./sandboxKeyName";
7
+
8
+ const LOCAL_HOST = "http://localhost";
9
+ const DEFAULT_LOCAL_PORT = 8080;
10
+
11
+ /** The spec's server: what every command talks to unless told otherwise. */
12
+ export const DEFAULT_BASE_URL = "https://api.useautumn.com";
13
+
14
+ /** The global flags every command shares; parsed once by the program. */
15
+ export type TargetFlags = {
16
+ prod?: boolean;
17
+ sandbox?: string;
18
+ local?: boolean;
19
+ port?: string;
20
+ baseUrl?: string;
21
+ clientId?: string;
22
+ };
23
+
24
+ /** Every env var a command may authenticate with: the org's two, or a sandbox's own. */
25
+ export type SecretKeyName =
26
+ | "AUTUMN_SECRET_KEY"
27
+ | "AUTUMN_PROD_SECRET_KEY"
28
+ | SandboxSecretKeyName;
29
+
30
+ export type Target = {
31
+ /** Undefined means the generated client's default (the spec's server). */
32
+ baseUrl?: string;
33
+ secretKeyName: SecretKeyName;
34
+ /** The OAuth client the CLI identifies as; flag, then env, then the registered default. */
35
+ clientId: string;
36
+ /** A named sandbox to address instead of the org's default one. */
37
+ sandboxId?: string;
38
+ };
39
+
40
+ /** A sandbox's own key when one is pinned, else the org key `--prod` selects. */
41
+ const secretKeyNameFor = ({
42
+ prod,
43
+ sandboxId,
44
+ }: {
45
+ prod: boolean;
46
+ sandboxId: string | undefined;
47
+ }): SecretKeyName => {
48
+ if (sandboxId !== undefined) return sandboxKeyName({ sandboxId });
49
+ return prod ? "AUTUMN_PROD_SECRET_KEY" : "AUTUMN_SECRET_KEY";
50
+ };
51
+
52
+ /**
53
+ * Where to send, which key to send it with, and who the CLI says it is.
54
+ *
55
+ * The most specific target wins: `--base-url`, then `--local`/`--port`, then
56
+ * `AUTUMN_BASE_URL`, then the spec's server. Ranking rather than refusing
57
+ * means a scripted `-l` can be overridden by an ad-hoc `-b` without editing
58
+ * the script. The env var exists so a directory can pin itself to a local
59
+ * server through its own `.env` — the default is production.
60
+ */
61
+ export const resolveTarget = ({
62
+ prod,
63
+ sandbox,
64
+ local,
65
+ port,
66
+ baseUrl,
67
+ clientId,
68
+ }: TargetFlags): Target => {
69
+ if (prod === true && sandbox !== undefined) {
70
+ throw new Error("Pick one of --prod and --sandbox.");
71
+ }
72
+ // `--prod` overrides a pinned sandbox rather than combining with it: keeping
73
+ // the pin would make the flag silently address the sandbox anyway.
74
+ // An empty pin is "no pin": how a parent process addresses the main
75
+ // sandbox for a child without editing the .env the child will read.
76
+ const pinned = process.env[SANDBOX_PIN_NAME];
77
+ const sandboxId =
78
+ prod === true
79
+ ? undefined
80
+ : (sandbox ?? (pinned === "" ? undefined : pinned));
81
+
82
+ const identity: Omit<Target, "baseUrl"> = {
83
+ secretKeyName: secretKeyNameFor({ prod: prod === true, sandboxId }),
84
+ clientId:
85
+ clientId ??
86
+ process.env.AUTUMN_CLIENT_ID ??
87
+ process.env.ATMN_CLI_CLIENT_ID ??
88
+ CLI_CLIENT_ID,
89
+ ...(sandboxId ? { sandboxId } : {}),
90
+ };
91
+
92
+ if (baseUrl) return { baseUrl, ...identity };
93
+ // A port implies the host: `--port 3001` alone is a local target.
94
+ if (local || port) {
95
+ return {
96
+ baseUrl: `${LOCAL_HOST}:${port ?? DEFAULT_LOCAL_PORT}`,
97
+ ...identity,
98
+ };
99
+ }
100
+
101
+ const fromEnv = process.env.AUTUMN_BASE_URL;
102
+ return fromEnv ? { baseUrl: fromEnv, ...identity } : identity;
103
+ };
104
+
105
+ /** The concrete URL a command should hit: the target's, else the spec's server. */
106
+ export const targetBaseUrl = ({ target }: { target: Target }): string =>
107
+ target.baseUrl ?? DEFAULT_BASE_URL;
108
+
109
+ /**
110
+ * `sandbox list|create|delete` are organization operations: the server refuses a
111
+ * sandbox's own key for them, so they drop the pin and use the org key.
112
+ */
113
+ export const managementTarget = ({ target }: { target: Target }): Target => {
114
+ if (target.sandboxId === undefined) return target;
115
+ return {
116
+ ...(target.baseUrl === undefined ? {} : { baseUrl: target.baseUrl }),
117
+ secretKeyName: "AUTUMN_SECRET_KEY",
118
+ clientId: target.clientId,
119
+ };
120
+ };
121
+
122
+ const missingKeyMessage = ({ name }: { name: SecretKeyName }): string => {
123
+ if (name === "AUTUMN_SECRET_KEY")
124
+ return `${name} is not set. Run atmn login, or atmn login --keyless if you don't have an account.`;
125
+ if (name === "AUTUMN_PROD_SECRET_KEY")
126
+ return `${name} is not set. Run atmn login.`;
127
+ return `${name} is not set. Run atmn sandbox create, or add its key to your .env.`;
128
+ };
129
+
130
+ export const requireSecretKey = ({ target }: { target: Target }): string => {
131
+ const key = process.env[target.secretKeyName];
132
+ if (!key) throw new Error(missingKeyMessage({ name: target.secretKeyName }));
133
+ return key;
134
+ };
@@ -0,0 +1,18 @@
1
+ /** The env var holding one sandbox's secret key. */
2
+ export type SandboxSecretKeyName = `AUTUMN_SANDBOX_${string}_SECRET_KEY`;
3
+
4
+ const NON_ALPHANUMERIC_RUN = /[^a-z0-9]+/gi;
5
+
6
+ /**
7
+ * A sandbox's key lives under a name derived from its id, so a `.env` can hold
8
+ * a key per sandbox and `--sandbox <id>` alone is enough to find the right one.
9
+ */
10
+ export const sandboxKeyName = ({
11
+ sandboxId,
12
+ }: {
13
+ sandboxId: string;
14
+ }): SandboxSecretKeyName =>
15
+ `AUTUMN_SANDBOX_${sandboxId.toUpperCase().replace(NON_ALPHANUMERIC_RUN, "_")}_SECRET_KEY`;
16
+
17
+ /** The env var `atmn sandbox create --use` writes to pin a target sandbox. */
18
+ export const SANDBOX_PIN_NAME = "AUTUMN_SANDBOX_ID";