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,94 @@
1
+ import { DEFAULT_BASE_URL, type Target } from "../env/resolveTarget";
2
+ import { renderEnv } from "../render/renderEnv";
3
+ import type { FetchOrgInfo, OrgInfo } from "./env/types/orgInfo";
4
+ import type { WriteLine } from "./sandbox/types/sandboxClient";
5
+
6
+ export type EnvOptions = {
7
+ /** Resolved by the CLI from its global flags: -p, --sandbox, -l all land here. */
8
+ target: Target;
9
+ fetchOrgInfo: FetchOrgInfo;
10
+ json?: boolean;
11
+ write?: WriteLine;
12
+ };
13
+
14
+ const isMainSandboxKey = ({ info }: { info: OrgInfo }): boolean =>
15
+ info.is_sandbox !== true;
16
+
17
+ /** The same facts an agent reads from `--json`, plus what to do about them. */
18
+ export const envJson = ({
19
+ info,
20
+ target,
21
+ }: {
22
+ info: OrgInfo;
23
+ target: Target;
24
+ }) => {
25
+ const isMaster = isMainSandboxKey({ info });
26
+ const notes: string[] = [];
27
+ // A pinned sandbox is meant to answer as itself; only the org's own key
28
+ // answering as a sandbox is the mistake worth flagging.
29
+ if (!isMaster && target.secretKeyName === "AUTUMN_SECRET_KEY")
30
+ notes.push(
31
+ `AUTUMN_SECRET_KEY belongs to sandbox "${info.name}" (${info.id}), not your main sandbox. Sandbox commands need the main key: run atmn login.`,
32
+ );
33
+ if (target.sandboxId !== undefined && target.sandboxId !== info.id)
34
+ notes.push(
35
+ `AUTUMN_SANDBOX_ID points at ${target.sandboxId} but the key answers as ${info.id}; run atmn sandbox use to repair the pin.`,
36
+ );
37
+ if (info.claim_state === "pending")
38
+ notes.push(
39
+ `This org has no owner yet${info.claim_expires_at ? ` (link it before ${info.claim_expires_at})` : ""}: atmn login --claim <email>.`,
40
+ );
41
+ if (target.sandboxId !== undefined)
42
+ notes.push("`atmn sandbox use --clear` returns to the main sandbox.");
43
+ else if (isMaster)
44
+ notes.push(
45
+ "`atmn sandbox use <name|id>` targets a named sandbox; `atmn sandbox list` shows them.",
46
+ );
47
+ return {
48
+ organization: { id: info.id, name: info.name, slug: info.slug },
49
+ env: info.env,
50
+ isMaster,
51
+ /** False while a keyless org waits to be linked; true for any owned org. */
52
+ claimed: info.claim_state !== "pending",
53
+ claimExpiresAt:
54
+ info.claim_state === "pending" ? (info.claim_expires_at ?? null) : null,
55
+ sandbox:
56
+ target.sandboxId === undefined
57
+ ? null
58
+ : { id: target.sandboxId, authenticatedAs: info.id },
59
+ user: info.user ?? null,
60
+ keyName: target.secretKeyName,
61
+ baseUrl: target.baseUrl ?? DEFAULT_BASE_URL,
62
+ notes,
63
+ };
64
+ };
65
+
66
+ /** Report which org, env and key this directory's commands would act on. */
67
+ export const runEnv = async ({
68
+ target,
69
+ fetchOrgInfo,
70
+ json = false,
71
+ write = (text) => process.stdout.write(text),
72
+ }: EnvOptions): Promise<OrgInfo> => {
73
+ const info = await fetchOrgInfo();
74
+
75
+ if (json) {
76
+ write(`${JSON.stringify(envJson({ info, target }), null, 2)}\n`);
77
+ return info;
78
+ }
79
+
80
+ write(
81
+ `${renderEnv({
82
+ info,
83
+ secretKeyName: target.secretKeyName,
84
+ // The default is noise; a local or staging server is the surprise worth showing.
85
+ ...(target.baseUrl === undefined || target.baseUrl === DEFAULT_BASE_URL
86
+ ? {}
87
+ : { baseUrl: target.baseUrl }),
88
+ ...(target.sandboxId === undefined
89
+ ? {}
90
+ : { sandboxId: target.sandboxId }),
91
+ })}\n`,
92
+ );
93
+ return info;
94
+ };
@@ -0,0 +1,401 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { configPackageName } from "../../config/configPackageName";
4
+ import {
5
+ loadEnvFiles,
6
+ removeEnvValues,
7
+ writeEnvValues,
8
+ } from "../../env/loadEnv";
9
+ import { SANDBOX_PIN_NAME, sandboxKeyName } from "../../env/sandboxKeyName";
10
+ import { AutumnApiError } from "../../generated/client";
11
+ import { readMarker, resolveProject } from "../../project/resolveProject";
12
+ import { writeRootMarker } from "../../project/rootMarker";
13
+ import {
14
+ ask,
15
+ choose,
16
+ done,
17
+ hint,
18
+ type Prompter,
19
+ soft,
20
+ } from "../../prompt/prompt";
21
+ import { findRepoLayout } from "../../repo/findRepoRoot";
22
+ import { version } from "../../version";
23
+ import type { OrgInfo } from "../env/types/orgInfo";
24
+ import type { LoginResult } from "../login";
25
+ import {
26
+ CONNECT_OPTIONS,
27
+ CONNECT_QUESTION,
28
+ type KeylessDeps,
29
+ runKeylessLogin,
30
+ } from "../login/keyless";
31
+ import { scaffoldConfig } from "../pull/scaffoldConfig";
32
+ import { installSkills, SKILLS_DIR_NAME } from "../skills/skills";
33
+
34
+ /** The network, behind functions so a test can hand over fakes. */
35
+ export type InitDeps = {
36
+ fetchOrgInfo: ({ secretKey }: { secretKey: string }) => Promise<OrgInfo>;
37
+ login: ({ envDirs }: { envDirs: string[] }) => Promise<LoginResult>;
38
+ keyless: KeylessDeps;
39
+ pull: ({
40
+ configDir,
41
+ }: {
42
+ configDir: string;
43
+ }) => Promise<{ appended: string[]; replaced: string[]; deleted: string[] }>;
44
+ /** `<manager> install` at the root, so the new package's config can import the CLI before pull. */
45
+ install: ({
46
+ manager,
47
+ repoRoot,
48
+ }: {
49
+ manager: string;
50
+ repoRoot: string;
51
+ }) => Promise<boolean>;
52
+ };
53
+
54
+ export type InitOptions = {
55
+ cwd?: string;
56
+ /** What the new package depends on for the builders; the published CLI by default. */
57
+ dependencySpec?: string;
58
+ /** Folder for the config, repo-root relative: autumn/ by default, asked for in a monorepo. */
59
+ path?: string;
60
+ /** The package's name; asked for in a monorepo. */
61
+ name?: string;
62
+ /** How to connect when no usable main key is on disk; asked for otherwise. */
63
+ connect?: "login" | "keyless";
64
+ deps: InitDeps;
65
+ prompter: Prompter;
66
+ };
67
+
68
+ export type InitResult = {
69
+ repoRoot: string;
70
+ configDir: string;
71
+ configPath: string;
72
+ org: OrgInfo;
73
+ };
74
+
75
+ const DEFAULT_PACKAGE_DIR = "packages/autumn";
76
+ const DEFAULT_CONFIG_DIR = "autumn";
77
+ const DEFAULT_PACKAGE_NAME = "autumn";
78
+ const PACKAGE_NAME = configPackageName();
79
+
80
+ type KeyCheck =
81
+ | { kind: "main"; info: OrgInfo }
82
+ | { kind: "sub"; info: OrgInfo; secretKey: string }
83
+ | { kind: "missing" };
84
+
85
+ const checkMainKey = async ({
86
+ deps,
87
+ }: {
88
+ deps: InitDeps;
89
+ }): Promise<KeyCheck> => {
90
+ const secretKey = process.env.AUTUMN_SECRET_KEY;
91
+ if (!secretKey) return { kind: "missing" };
92
+ try {
93
+ const info = await deps.fetchOrgInfo({ secretKey });
94
+ return info.is_sandbox === true
95
+ ? { kind: "sub", info, secretKey }
96
+ : { kind: "main", info };
97
+ } catch (error) {
98
+ // A rejected key is a missing key; a server that cannot be reached is not.
99
+ if (
100
+ error instanceof AutumnApiError &&
101
+ (error.status === 401 || error.status === 403)
102
+ )
103
+ return { kind: "missing" };
104
+ throw error;
105
+ }
106
+ };
107
+
108
+ /**
109
+ * A sub-sandbox key in AUTUMN_SECRET_KEY was the user working inside that
110
+ * sandbox; it keeps working under its own name, and the pin keeps them there.
111
+ */
112
+ const relocateSubKey = ({
113
+ check,
114
+ envDirs,
115
+ prompter,
116
+ }: {
117
+ check: Extract<KeyCheck, { kind: "sub" }>;
118
+ envDirs: string[];
119
+ prompter: Prompter;
120
+ }): void => {
121
+ const keyName = sandboxKeyName({ sandboxId: check.info.id });
122
+ removeEnvValues({ dirs: envDirs, keys: ["AUTUMN_SECRET_KEY"] });
123
+ writeEnvValues({
124
+ dirs: envDirs,
125
+ values: { [keyName]: check.secretKey, [SANDBOX_PIN_NAME]: check.info.id },
126
+ });
127
+ delete process.env.AUTUMN_SECRET_KEY;
128
+ prompter.write(
129
+ `${done(`Kept the sandbox key as ${keyName} and pinned ${SANDBOX_PIN_NAME}=${check.info.id}`)}\n`,
130
+ );
131
+ };
132
+
133
+ export const CONNECT_CHOICES = [
134
+ { value: "login", flag: "--login", label: CONNECT_OPTIONS.login },
135
+ { value: "keyless", flag: "--keyless", label: CONNECT_OPTIONS.keyless },
136
+ ] as const;
137
+
138
+ const authenticate = async ({
139
+ repoRoot,
140
+ envDirs,
141
+ connect,
142
+ deps,
143
+ prompter,
144
+ }: {
145
+ repoRoot: string;
146
+ envDirs: string[];
147
+ connect: "login" | "keyless" | undefined;
148
+ deps: InitDeps;
149
+ prompter: Prompter;
150
+ }): Promise<OrgInfo> => {
151
+ const check = await checkMainKey({ deps });
152
+ if (check.kind === "main") {
153
+ prompter.write(
154
+ `${done(`Logged in as ${check.info.name} (${check.info.slug})`)}\n`,
155
+ );
156
+ return check.info;
157
+ }
158
+
159
+ if (check.kind === "sub") {
160
+ prompter.write(
161
+ `${soft(`AUTUMN_SECRET_KEY belongs to sandbox "${check.info.name}" (${check.info.id}), not your main sandbox.`)}\n`,
162
+ );
163
+ prompter.write(
164
+ ` atmn needs your main sandbox key. Logging in mints it; the sandbox key is kept as ${sandboxKeyName({ sandboxId: check.info.id })} and pinned.\n`,
165
+ );
166
+ } else {
167
+ prompter.write(`${soft("No AUTUMN_SECRET_KEY found.")}\n`);
168
+ }
169
+
170
+ const way = await choose({
171
+ prompter,
172
+ value: connect,
173
+ question: CONNECT_QUESTION,
174
+ options: CONNECT_CHOICES,
175
+ defaultValue: "login",
176
+ });
177
+
178
+ if (check.kind === "sub") relocateSubKey({ check, envDirs, prompter });
179
+ if (way === "keyless") {
180
+ await runKeylessLogin({ repoRoot, envDirs, deps: deps.keyless, prompter });
181
+ } else {
182
+ await deps.login({ envDirs });
183
+ // A rejected key exported in the shell would otherwise shadow the one
184
+ // login just wrote, since env files never override the process.
185
+ delete process.env.AUTUMN_SECRET_KEY;
186
+ loadEnvFiles({ dirs: envDirs });
187
+ }
188
+
189
+ const after = await checkMainKey({ deps });
190
+ if (after.kind !== "main")
191
+ throw new Error(
192
+ "Login finished but AUTUMN_SECRET_KEY still does not answer as your main sandbox.",
193
+ );
194
+ prompter.write(
195
+ `${done(`Logged in as ${after.info.name} (${after.info.slug})`)}\n`,
196
+ );
197
+ return after.info;
198
+ };
199
+
200
+ const readJson = (path: string): Record<string, unknown> =>
201
+ JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
202
+
203
+ const writeJson = (path: string, value: Record<string, unknown>): void =>
204
+ writeFileSync(path, `${JSON.stringify(value, null, "\t")}\n`, "utf8");
205
+
206
+ const packageJsonFor = ({
207
+ name,
208
+ dependencySpec,
209
+ }: {
210
+ name: string;
211
+ dependencySpec: string;
212
+ }): Record<string, unknown> => ({
213
+ name,
214
+ private: true,
215
+ type: "module",
216
+ dependencies: { [PACKAGE_NAME]: dependencySpec },
217
+ });
218
+
219
+ /** A repo with no package.json gets a minimal one: the config needs a package to depend from. */
220
+ const ensureRootManifest = ({ repoRoot }: { repoRoot: string }): string => {
221
+ const manifestPath = join(repoRoot, "package.json");
222
+ if (!existsSync(manifestPath))
223
+ writeJson(manifestPath, { name: "autumn", private: true, type: "module" });
224
+ return manifestPath;
225
+ };
226
+
227
+ /** The config imports the CLI, so whichever package owns it depends on it.
228
+ * True when the manifest changed. */
229
+ const addDependency = ({
230
+ manifestPath,
231
+ dependencySpec,
232
+ }: {
233
+ manifestPath: string;
234
+ dependencySpec: string;
235
+ }): boolean => {
236
+ if (!existsSync(manifestPath)) return false;
237
+ const manifest = readJson(manifestPath);
238
+ const deps = (manifest.dependencies ?? {}) as Record<string, string>;
239
+ const devDeps = (manifest.devDependencies ?? {}) as Record<string, string>;
240
+ if (deps[PACKAGE_NAME] !== undefined || devDeps[PACKAGE_NAME] !== undefined)
241
+ return false;
242
+ writeJson(manifestPath, {
243
+ ...manifest,
244
+ dependencies: { ...deps, [PACKAGE_NAME]: dependencySpec },
245
+ });
246
+ return true;
247
+ };
248
+
249
+ const packageManager = ({ repoRoot }: { repoRoot: string }): string => {
250
+ if (
251
+ existsSync(join(repoRoot, "bun.lock")) ||
252
+ existsSync(join(repoRoot, "bun.lockb"))
253
+ )
254
+ return "bun";
255
+ if (existsSync(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
256
+ if (existsSync(join(repoRoot, "yarn.lock"))) return "yarn";
257
+ return "npm";
258
+ };
259
+
260
+ const runnerFor = (manager: string): string =>
261
+ manager === "npm" ? "npm run" : manager === "yarn" ? "yarn" : manager;
262
+
263
+ /**
264
+ * Auth, path, pull, skills, next steps. Every step prints what it did; a
265
+ * step that needs an answer prints what to pass and stops, so running the
266
+ * same command again with the flag continues from there.
267
+ */
268
+ export const runInit = async ({
269
+ cwd = process.cwd(),
270
+ dependencySpec = `^${version}`,
271
+ path,
272
+ name,
273
+ connect,
274
+ deps,
275
+ prompter,
276
+ }: InitOptions): Promise<InitResult> => {
277
+ const { repoRoot, hasWorkspaces } = findRepoLayout({ cwd });
278
+ const envDirs = [repoRoot];
279
+ loadEnvFiles({ dirs: envDirs });
280
+
281
+ const org = await authenticate({
282
+ repoRoot,
283
+ envDirs,
284
+ connect,
285
+ deps,
286
+ prompter,
287
+ });
288
+
289
+ let configDir = cwd;
290
+ let packageName: string | undefined;
291
+ // A repo init already placed keeps its answer: the marker is the path.
292
+ const marker = readMarker({ repoRoot });
293
+ if (hasWorkspaces) {
294
+ prompter.write(`${done("Monorepo detected")}\n`);
295
+ const chosen = await ask({
296
+ prompter,
297
+ value:
298
+ path ??
299
+ (marker === null
300
+ ? undefined
301
+ : dirname(resolve(repoRoot, marker.config))),
302
+ question: "Where should the Autumn package live?",
303
+ flag: "--path <dir>",
304
+ example: `--path ${DEFAULT_PACKAGE_DIR}`,
305
+ defaultValue: DEFAULT_PACKAGE_DIR,
306
+ });
307
+ configDir = resolve(repoRoot, chosen);
308
+ prompter.write(`${done(`Path ${relative(repoRoot, configDir) || "."}`)}\n`);
309
+ const existingName = existsSync(join(configDir, "package.json"))
310
+ ? (readJson(join(configDir, "package.json")).name as string | undefined)
311
+ : undefined;
312
+ packageName = await ask({
313
+ prompter,
314
+ value: name ?? existingName,
315
+ question: "Package name?",
316
+ flag: "--name <name>",
317
+ example: "--name @acme/autumn",
318
+ defaultValue: DEFAULT_PACKAGE_NAME,
319
+ });
320
+ prompter.write(`${done(`Name ${packageName}`)}\n`);
321
+ } else {
322
+ // A plain project gets its own folder too, unless a config already
323
+ // sits beside cwd or the marker names one.
324
+ const found = resolveProject({ cwd }).configPath;
325
+ configDir = resolve(
326
+ repoRoot,
327
+ path ?? (found === null ? DEFAULT_CONFIG_DIR : dirname(found)),
328
+ );
329
+ prompter.write(`${done(`Path ${relative(repoRoot, configDir) || "."}`)}\n`);
330
+ }
331
+
332
+ const configPath = join(configDir, "autumn.config.ts");
333
+ mkdirSync(configDir, { recursive: true });
334
+ const wrote: string[] = [];
335
+ let dependencyAdded = false;
336
+ const manifestPath = join(configDir, "package.json");
337
+ if (packageName !== undefined && !existsSync(manifestPath)) {
338
+ writeJson(
339
+ manifestPath,
340
+ packageJsonFor({ name: packageName, dependencySpec }),
341
+ );
342
+ wrote.push(`${relative(repoRoot, manifestPath)}`);
343
+ dependencyAdded = true;
344
+ } else if (
345
+ addDependency({
346
+ manifestPath: existsSync(manifestPath)
347
+ ? manifestPath
348
+ : ensureRootManifest({ repoRoot }),
349
+ dependencySpec,
350
+ })
351
+ ) {
352
+ dependencyAdded = true;
353
+ prompter.write(`${done(`Added ${PACKAGE_NAME} to package.json`)}\n`);
354
+ }
355
+ if (!existsSync(configPath)) {
356
+ scaffoldConfig({ directory: configDir });
357
+ wrote.push("autumn.config.ts", "features.ts", "plans.ts", "rewards.ts");
358
+ }
359
+ if (wrote.length > 0)
360
+ prompter.write(`${done(`Wrote ${wrote.join(", ")}`)}\n`);
361
+ if (writeRootMarker({ repoRoot, configPath }))
362
+ prompter.write(
363
+ `${done('Wrote "atmn" script and marker to package.json')}\n`,
364
+ );
365
+
366
+ const manager = packageManager({ repoRoot });
367
+ const runner = runnerFor(manager);
368
+ // The scaffolded config imports the CLI; a package written a moment ago
369
+ // cannot resolve it until its dependency is installed.
370
+ if (dependencyAdded) {
371
+ const installed = await deps.install({ manager, repoRoot });
372
+ if (!installed)
373
+ throw new Error(
374
+ `${manager} install failed; run it yourself, then atmn init again.`,
375
+ );
376
+ prompter.write(`${done(`Installed with ${manager}`)}\n`);
377
+ }
378
+
379
+ const pulled = await deps.pull({ configDir });
380
+ const count =
381
+ pulled.appended.length + pulled.replaced.length + pulled.deleted.length;
382
+ prompter.write(
383
+ `${done(count === 0 ? "Sandbox matches the config; nothing to pull" : `Pulled ${count} ${count === 1 ? "entry" : "entries"}`)}\n`,
384
+ );
385
+
386
+ const skillsDir = join(configDir, SKILLS_DIR_NAME);
387
+ const { written } = installSkills({ dir: skillsDir, write: () => {} });
388
+ prompter.write(
389
+ `${done(`Skills: ${relative(repoRoot, skillsDir)}/${written.join(", ")}`)}\n`,
390
+ );
391
+
392
+ prompter.write("\nNext:\n");
393
+ prompter.write(
394
+ `${hint(`${runner} atmn push`)} preview your catalog against the sandbox\n`,
395
+ );
396
+ prompter.write(
397
+ `${hint(`npx skills add ${relative(repoRoot, skillsDir)} -y`)} make the skills visible to your agent\n`,
398
+ );
399
+
400
+ return { repoRoot, configDir, configPath, org };
401
+ };
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import {
4
+ type ClaimStarted,
5
+ type ProvisionedOrg,
6
+ provisionKeylessOrg,
7
+ slugFor,
8
+ startClaim,
9
+ } from "../../auth/keyless";
10
+ import { writeEnvValues } from "../../env/loadEnv";
11
+ import { type Target, targetBaseUrl } from "../../env/resolveTarget";
12
+ import { done, type Prompter } from "../../prompt/prompt";
13
+
14
+ /** The one place the two ways in are described; help text and hints both read it. */
15
+ export const CONNECT_OPTIONS = {
16
+ login: "sign in on the web (opens a browser; prints the URL if it can't)",
17
+ keyless:
18
+ "create a sandbox org now, no account needed; link it to an account later with `atmn login --claim <email>`",
19
+ } as const;
20
+
21
+ export const CONNECT_QUESTION = "How do you want to connect to Autumn?";
22
+
23
+ export type KeylessDeps = {
24
+ provision: (params: {
25
+ name: string;
26
+ slug: string;
27
+ }) => Promise<ProvisionedOrg>;
28
+ startClaim: (params: {
29
+ secretKey: string;
30
+ email: string;
31
+ }) => Promise<ClaimStarted>;
32
+ };
33
+
34
+ export const keylessDepsFor = ({ target }: { target: Target }): KeylessDeps => {
35
+ const baseUrl = targetBaseUrl({ target });
36
+ return {
37
+ provision: ({ name, slug }) => provisionKeylessOrg({ baseUrl, name, slug }),
38
+ startClaim: ({ secretKey, email }) =>
39
+ startClaim({ baseUrl, secretKey, email }),
40
+ };
41
+ };
42
+
43
+ /** The org is named after the project: the root package's name, else the folder. */
44
+ export const projectNameFor = ({ repoRoot }: { repoRoot: string }): string => {
45
+ const manifest = join(repoRoot, "package.json");
46
+ if (existsSync(manifest)) {
47
+ try {
48
+ const name = (
49
+ JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }
50
+ ).name;
51
+ if (typeof name === "string" && name.trim() !== "") return name.trim();
52
+ } catch {
53
+ // Fall through to the folder name.
54
+ }
55
+ }
56
+ return basename(repoRoot) || "autumn";
57
+ };
58
+
59
+ const daysUntil = (iso: string): string => {
60
+ const ms = new Date(iso).getTime() - Date.now();
61
+ if (Number.isNaN(ms)) return iso;
62
+ const days = Math.max(1, Math.round(ms / 86_400_000));
63
+ return `${days} day${days === 1 ? "" : "s"}`;
64
+ };
65
+
66
+ export type KeylessLoginResult = {
67
+ envPath: string;
68
+ orgId: string;
69
+ orgSlug: string;
70
+ claimExpiresAt: string;
71
+ };
72
+
73
+ /** A sandbox org with no owner, its key in .env; the account comes later. */
74
+ export const runKeylessLogin = async ({
75
+ repoRoot,
76
+ envDirs,
77
+ name = projectNameFor({ repoRoot }),
78
+ deps,
79
+ prompter,
80
+ }: {
81
+ repoRoot: string;
82
+ envDirs: string[];
83
+ /** The org's name; the slug derives from it. */
84
+ name?: string;
85
+ deps: KeylessDeps;
86
+ prompter: Prompter;
87
+ }): Promise<KeylessLoginResult> => {
88
+ const provisioned = await deps.provision({ name, slug: slugFor(name) });
89
+ const envPath = writeEnvValues({
90
+ dirs: envDirs,
91
+ values: { AUTUMN_SECRET_KEY: provisioned.apiKey },
92
+ });
93
+ process.env.AUTUMN_SECRET_KEY = provisioned.apiKey;
94
+ prompter.write(
95
+ `${done(`Created sandbox org ${provisioned.organizationSlug} (keyless). Wrote AUTUMN_SECRET_KEY to ${envPath}`)}\n`,
96
+ );
97
+ prompter.write(
98
+ ` This org has no owner yet. Link it within ${daysUntil(provisioned.claimExpiresAt)}: atmn login --claim you@example.com\n`,
99
+ );
100
+ return {
101
+ envPath,
102
+ orgId: provisioned.organizationId,
103
+ orgSlug: provisioned.organizationSlug,
104
+ claimExpiresAt: provisioned.claimExpiresAt,
105
+ };
106
+ };
107
+
108
+ const minutesUntil = (iso: string): string => {
109
+ const ms = new Date(iso).getTime() - Date.now();
110
+ if (Number.isNaN(ms)) return iso;
111
+ return `${Math.max(1, Math.round(ms / 60_000))} min`;
112
+ };
113
+
114
+ /** Create a browser link that attaches this keyless org to the requested account. */
115
+ export const runClaim = async ({
116
+ secretKey,
117
+ email,
118
+ deps,
119
+ prompter,
120
+ }: {
121
+ secretKey: string;
122
+ email: string;
123
+ deps: KeylessDeps;
124
+ prompter: Prompter;
125
+ }): Promise<ClaimStarted> => {
126
+ const started = await deps.startClaim({ secretKey, email });
127
+ prompter.write(
128
+ `${done(`Created a claim link for ${email} (expires in ${minutesUntil(started.expiresAt)})`)}\n`,
129
+ );
130
+ prompter.write(` ${started.claimUrl}\n`);
131
+ prompter.write(
132
+ ` The same link was emailed to ${email}. Sign in there to claim the org; the existing key stays valid.\n`,
133
+ );
134
+ return started;
135
+ };