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.
- 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 -142410
- 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 -367
- 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,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";
|