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,88 @@
1
+ import type { CollectionSpec } from "../../generated/emitRuntime";
2
+ import type { PreviewEntry } from "./applyPreview";
3
+
4
+ const camelOf = (snake: string): string =>
5
+ snake.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
6
+
7
+ const isObject = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === "object" && value !== null && !Array.isArray(value);
9
+
10
+ const NOOP_ACTIONS = new Set(["none", "skip"]);
11
+
12
+ /** A nested lane (licenses, variants) is work when any row in it acts. */
13
+ const laneHasWork = (value: unknown): boolean =>
14
+ Array.isArray(value) &&
15
+ value.some((row) =>
16
+ Object.entries(row as Record<string, unknown>).some(
17
+ ([key, entry]) =>
18
+ key.endsWith("ction") &&
19
+ typeof entry === "string" &&
20
+ !NOOP_ACTIONS.has(entry),
21
+ ),
22
+ );
23
+
24
+ /**
25
+ * The top-level fixture keys a preview update touches, in patch order:
26
+ * content first, then version slug, then the id, so each lookup still finds
27
+ * the fixture by what the config said before. Null when the preview does not
28
+ * name its fields, which means the whole fixture is rewritten instead.
29
+ */
30
+ export const changedFixtureKeys = ({
31
+ spec,
32
+ entry,
33
+ includeMappings,
34
+ fixtureId,
35
+ rowId,
36
+ }: {
37
+ spec: CollectionSpec;
38
+ entry: PreviewEntry;
39
+ includeMappings: boolean;
40
+ /** The public id the located fixture states; null when not a literal string. */
41
+ fixtureId: string | null;
42
+ /** The id the server row carries now. */
43
+ rowId: unknown;
44
+ }): string[] | null => {
45
+ const keys = new Set<string>();
46
+ const known = new Set(spec.keys);
47
+ const addAttribute = (attribute: string): boolean => {
48
+ const key = camelOf(attribute);
49
+ if (key === "id" || key === spec.idField || key === "internalId")
50
+ return true;
51
+ // Mappings ride include_mappings, never a diff.
52
+ if (key === "processors" && !includeMappings) return true;
53
+ if (!known.has(key)) return false;
54
+ keys.add(key);
55
+ return true;
56
+ };
57
+
58
+ const planChange = entry.planChange;
59
+ if (isObject(planChange)) {
60
+ const previous = planChange.previousAttributes;
61
+ if (isObject(previous)) {
62
+ for (const attribute of Object.keys(previous)) {
63
+ if (!addAttribute(attribute)) return null;
64
+ }
65
+ }
66
+ if (planChange.priceChange !== undefined) keys.add("price");
67
+ if (planChange.freeTrialChange !== undefined) keys.add("freeTrial");
68
+ if (Array.isArray(planChange.itemChanges) && planChange.itemChanges.length)
69
+ keys.add("items");
70
+ if (planChange.customize !== undefined) return null;
71
+ }
72
+ if (isObject(entry.previousAttributes)) {
73
+ for (const attribute of Object.keys(entry.previousAttributes)) {
74
+ if (!addAttribute(attribute)) return null;
75
+ }
76
+ }
77
+ if (laneHasWork(entry.licenses) && known.has("licenses"))
78
+ keys.add("licenses");
79
+ if (laneHasWork(entry.variants) && known.has("variants"))
80
+ keys.add("variants");
81
+
82
+ const ordered = [...keys].filter((key) => key !== "versionSlug");
83
+ if (entry.newVersionSlug !== undefined || keys.has("versionSlug"))
84
+ ordered.push("versionSlug");
85
+ if (typeof rowId === "string" && fixtureId !== null && rowId !== fixtureId)
86
+ ordered.push(spec.idField);
87
+ return ordered.length === 0 ? null : ordered;
88
+ };
@@ -0,0 +1,29 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Every `.ts` file under `directory`, recursively. Generated and scratch
6
+ * directories are skipped: a pull must never rewrite its own inputs, and a
7
+ * fixture literal inside `node_modules` or a `.tmp` folder is not the user's.
8
+ */
9
+ export const listSourceFiles = ({
10
+ directory,
11
+ }: {
12
+ directory: string;
13
+ }): string[] => {
14
+ const files: string[] = [];
15
+ const walk = (dir: string): void => {
16
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
17
+ const path = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ if (entry.name === "node_modules" || entry.name.startsWith("."))
20
+ continue;
21
+ walk(path);
22
+ } else if (entry.isFile() && entry.name.endsWith(".ts")) {
23
+ files.push(path);
24
+ }
25
+ }
26
+ };
27
+ walk(directory);
28
+ return files;
29
+ };
@@ -0,0 +1,78 @@
1
+ import type { SgNode } from "@ast-grep/napi";
2
+ import {
3
+ type FixtureConstraint,
4
+ type FixtureShape,
5
+ findFixture,
6
+ } from "../../surgery/findFixture";
7
+
8
+ /** The file, its source, the node, and the lookup that found it — reuse the
9
+ * same lookup for the edit, or two versions sharing an id get confused. */
10
+ export type LocatedFixture = {
11
+ file: string;
12
+ source: string;
13
+ node: SgNode;
14
+ builder: FixtureShape;
15
+ idField: string;
16
+ id: string;
17
+ where?: FixtureConstraint[];
18
+ };
19
+ export type { FixtureConstraint };
20
+
21
+ /**
22
+ * Where a fixture literal lives: the config file first (the common case), then
23
+ * every other walked source file. A stable id wins over the public one, so a
24
+ * renamed row is still found; `where` narrows rows that share an id, like
25
+ * plan versions. `files` maps absolute paths to their current source, so later
26
+ * edits are seen by later lookups. Several `builder` shapes are tried in order
27
+ * for each lookup, so a stable id in any shape beats a public id in the first.
28
+ */
29
+ export const locateFixture = ({
30
+ configPath,
31
+ files,
32
+ builder,
33
+ idField,
34
+ id,
35
+ internalId,
36
+ where,
37
+ allowDynamic = false,
38
+ }: {
39
+ configPath: string;
40
+ files: Map<string, string>;
41
+ builder: FixtureShape | FixtureShape[];
42
+ idField: string;
43
+ id: string;
44
+ internalId?: string | null;
45
+ where?: FixtureConstraint[];
46
+ allowDynamic?: boolean;
47
+ }): LocatedFixture | null => {
48
+ const others = [...files.keys()].filter((file) => file !== configPath);
49
+ const ordered = [configPath, ...others];
50
+ const attempts: {
51
+ idField: string;
52
+ id: string;
53
+ where?: FixtureConstraint[];
54
+ }[] = [
55
+ ...(typeof internalId === "string"
56
+ ? [{ idField: "internalId", id: internalId }]
57
+ : []),
58
+ { idField, id, where },
59
+ ];
60
+ const shapes = Array.isArray(builder) ? builder : [builder];
61
+ for (const attempt of attempts) {
62
+ for (const file of ordered) {
63
+ const source = files.get(file);
64
+ if (source === undefined) continue;
65
+ for (const shape of shapes) {
66
+ const node = findFixture({
67
+ source,
68
+ builder: shape,
69
+ allowDynamic,
70
+ ...attempt,
71
+ });
72
+ if (node !== null)
73
+ return { file, source, node, builder: shape, ...attempt };
74
+ }
75
+ }
76
+ }
77
+ return null;
78
+ };
@@ -0,0 +1,198 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import type { SgNode } from "@ast-grep/napi";
3
+ import { Lang, parse } from "@ast-grep/napi";
4
+ import { findLiteralBinding } from "../../surgery/arrayBinding";
5
+
6
+ export type CollectionTarget =
7
+ | { kind: "inline"; file: string }
8
+ | { kind: "binding"; file: string; name: string };
9
+
10
+ export const collectionTargetReferenceName = ({
11
+ target,
12
+ files,
13
+ collection,
14
+ name,
15
+ fixtureFile,
16
+ }: {
17
+ target: CollectionTarget;
18
+ files: Map<string, string>;
19
+ collection: string;
20
+ name: string;
21
+ fixtureFile: string;
22
+ }): string | null => {
23
+ const source = files.get(target.file);
24
+ if (source === undefined) return null;
25
+ const root = parse(Lang.TypeScript, source).root();
26
+ const array =
27
+ target.kind === "binding"
28
+ ? findLiteralBinding({ root, name: target.name, kind: "array" })
29
+ : collectionValue({ root, collection });
30
+ if (array?.kind() !== "array") return null;
31
+ for (const element of array.namedChildren()) {
32
+ if (element.kind() !== "identifier") continue;
33
+ const localName = element.text();
34
+ const imported = importedFrom({ root, name: localName });
35
+ if (imported !== null) {
36
+ const importedFile = moduleFileOf({
37
+ from: target.file,
38
+ specifier: imported.specifier,
39
+ files,
40
+ });
41
+ if (importedFile === fixtureFile && imported.exportedName === name)
42
+ return localName;
43
+ continue;
44
+ }
45
+ if (target.file === fixtureFile && localName === name) return localName;
46
+ }
47
+ return null;
48
+ };
49
+
50
+ const NAMED_IMPORT =
51
+ /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/;
52
+
53
+ /**
54
+ * Where a collection's array literal lives: inline in the atmn call, a const
55
+ * the call names, or the const behind the array's last `...spread`; a named
56
+ * const may be local or imported from a relative `.ts` file. Null when the
57
+ * value is something this pull cannot append to.
58
+ */
59
+ export const resolveCollectionTarget = ({
60
+ configPath,
61
+ files,
62
+ collection,
63
+ kind = "array",
64
+ }: {
65
+ configPath: string;
66
+ files: Map<string, string>;
67
+ collection: string;
68
+ /** What the key holds: a collection's array, or a singleton's object. */
69
+ kind?: "array" | "object";
70
+ }): CollectionTarget | null => {
71
+ const source = files.get(configPath);
72
+ if (source === undefined) return null;
73
+ const root = parse(Lang.TypeScript, source).root();
74
+ const value = collectionValue({ root, collection });
75
+ if (value === null) return null;
76
+ if (value.kind() === kind) {
77
+ const spread = kind === "array" ? trailingSpreadName(value) : null;
78
+ return spread === null
79
+ ? { kind: "inline", file: configPath }
80
+ : resolveBinding({ root, name: spread, configPath, files, kind });
81
+ }
82
+ // `atmn({ plans })` names the binding without a pair.
83
+ const named =
84
+ value.kind() === "identifier" ||
85
+ value.kind() === "shorthand_property_identifier";
86
+ if (!named) return null;
87
+ return resolveBinding({ root, name: value.text(), configPath, files, kind });
88
+ };
89
+
90
+ /** `[...a, ...b]` appends into `b`; a literal element last means inline. */
91
+ const trailingSpreadName = (array: SgNode): string | null => {
92
+ const elements = array.namedChildren();
93
+ const last = elements[elements.length - 1];
94
+ if (last === undefined || last.kind() !== "spread_element") return null;
95
+ const argument = last.namedChildren()[0];
96
+ return argument?.kind() === "identifier" ? argument.text() : null;
97
+ };
98
+
99
+ const resolveBinding = ({
100
+ root,
101
+ name,
102
+ configPath,
103
+ files,
104
+ kind,
105
+ }: {
106
+ root: SgNode;
107
+ name: string;
108
+ configPath: string;
109
+ files: Map<string, string>;
110
+ kind: "array" | "object";
111
+ }): CollectionTarget | null => {
112
+ if (findLiteralBinding({ root, name, kind }) !== null)
113
+ return { kind: "binding", file: configPath, name };
114
+ const imported = importedFrom({ root, name });
115
+ if (imported === null) return null;
116
+ const file = moduleFileOf({
117
+ from: configPath,
118
+ specifier: imported.specifier,
119
+ files,
120
+ });
121
+ if (file === null) return null;
122
+ const module = parse(Lang.TypeScript, files.get(file) ?? "").root();
123
+ return findLiteralBinding({
124
+ root: module,
125
+ name: imported.exportedName,
126
+ kind,
127
+ }) !== null
128
+ ? { kind: "binding", file, name: imported.exportedName }
129
+ : null;
130
+ };
131
+
132
+ const collectionValue = ({
133
+ root,
134
+ collection,
135
+ }: {
136
+ root: SgNode;
137
+ collection: string;
138
+ }): SgNode | null => {
139
+ const object = root.find("atmn($ARG)")?.getMatch("ARG") ?? null;
140
+ if (object === null || object.kind() !== "object") return null;
141
+ for (const member of object.children()) {
142
+ if (
143
+ member.kind() === "shorthand_property_identifier" &&
144
+ member.text() === collection
145
+ )
146
+ return member;
147
+ if (member.kind() !== "pair") continue;
148
+ const [key, value] = member.namedChildren();
149
+ if (key?.text() === collection && value !== undefined) return value;
150
+ }
151
+ return null;
152
+ };
153
+
154
+ /** The import binding `name`, with the name it had in the module it came from. */
155
+ const importedFrom = ({
156
+ root,
157
+ name,
158
+ }: {
159
+ root: SgNode;
160
+ name: string;
161
+ }): { specifier: string; exportedName: string } | null => {
162
+ for (const statement of root.findAll({
163
+ rule: { kind: "import_statement" },
164
+ })) {
165
+ const match = NAMED_IMPORT.exec(statement.text());
166
+ if (match === null) continue;
167
+ for (const entry of match[1].split(",")) {
168
+ const [exportedName, localName = exportedName] = entry
169
+ .trim()
170
+ .split(/\s+as\s+/);
171
+ if (localName === name && exportedName !== "")
172
+ return { specifier: match[2], exportedName };
173
+ }
174
+ }
175
+ return null;
176
+ };
177
+
178
+ /** A relative specifier against the walked files: bare, `.ts`, or a folder index. */
179
+ const moduleFileOf = ({
180
+ from,
181
+ specifier,
182
+ files,
183
+ }: {
184
+ from: string;
185
+ specifier: string;
186
+ files: Map<string, string>;
187
+ }): string | null => {
188
+ if (!specifier.startsWith(".")) return null;
189
+ const byResolved = new Map(
190
+ [...files.keys()].map((file) => [resolve(file), file] as const),
191
+ );
192
+ const base = resolve(dirname(from), specifier);
193
+ for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) {
194
+ const file = byResolved.get(candidate);
195
+ if (file !== undefined) return file;
196
+ }
197
+ return null;
198
+ };
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import {
4
+ COLLECTION_FILES,
5
+ type ConfigImports,
6
+ collectionFileSource,
7
+ rootSource,
8
+ } from "./scaffoldConfig";
9
+
10
+ const PACKAGE_NAMES = ["atmn", "atmn-nightly"];
11
+
12
+ /** A file is ours when it imports the config package; anything else is the user's. */
13
+ export const importsConfigPackage = ({
14
+ text,
15
+ imports,
16
+ }: {
17
+ text: string;
18
+ imports: ConfigImports;
19
+ }): boolean =>
20
+ [imports.atmn, imports.builders, ...PACKAGE_NAMES].some(
21
+ (specifier) =>
22
+ text.includes(`from "${specifier}"`) ||
23
+ text.includes(`from '${specifier}'`),
24
+ );
25
+
26
+ /**
27
+ * Overwrite without deleting: the config becomes a fresh shell, and each
28
+ * collection file beside it is rewritten only when it is ours. A collection
29
+ * whose file belongs to the user is kept inline in the config instead.
30
+ */
31
+ export const rewriteConfig = ({
32
+ configPath,
33
+ imports,
34
+ }: {
35
+ configPath: string;
36
+ imports: ConfigImports;
37
+ }): { rewritten: string[]; kept: string[] } => {
38
+ const configDir = dirname(configPath);
39
+ const importedFiles = new Set<string>();
40
+ const rewritten: string[] = [];
41
+ const kept: string[] = [];
42
+ for (const [file, collections] of Object.entries(COLLECTION_FILES)) {
43
+ const path = join(configDir, file);
44
+ const isUsers =
45
+ existsSync(path) &&
46
+ !importsConfigPackage({ text: readFileSync(path, "utf8"), imports });
47
+ if (isUsers) {
48
+ kept.push(path);
49
+ continue;
50
+ }
51
+ writeFileSync(path, collectionFileSource({ collections, imports }), "utf8");
52
+ importedFiles.add(file);
53
+ rewritten.push(path);
54
+ }
55
+ writeFileSync(configPath, rootSource({ imports, importedFiles }), "utf8");
56
+ return { rewritten: [configPath, ...rewritten], kept };
57
+ };
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { configPackageName } from "../../config/configPackageName";
4
+ import { COLLECTIONS } from "../../generated/emit";
5
+
6
+ /** Where a config imports from: the published package, or generated files in-repo. */
7
+ export type ConfigImports = { atmn: string; builders: string };
8
+
9
+ export const packageImports = (): ConfigImports => {
10
+ const name = configPackageName();
11
+ return { atmn: name, builders: name };
12
+ };
13
+
14
+ /**
15
+ * One file per catalog surface. `rewards.ts` holds both reward collections:
16
+ * a referral program names a reward, so they read as one thing.
17
+ */
18
+ export const COLLECTION_FILES: Readonly<Record<string, readonly string[]>> = {
19
+ "features.ts": ["features"],
20
+ "plans.ts": ["plans"],
21
+ "rewards.ts": ["rewards", "referralPrograms"],
22
+ };
23
+
24
+ const builderImportsFor = ({
25
+ collections,
26
+ specifier,
27
+ }: {
28
+ collections: readonly string[];
29
+ specifier: string;
30
+ }): string => {
31
+ const builders = collections
32
+ .flatMap((name) => {
33
+ const spec = COLLECTIONS[name];
34
+ if (spec === undefined) return [];
35
+ return spec.branches
36
+ ? spec.branches.map((branch) => branch.builder)
37
+ : [spec.builder];
38
+ })
39
+ .sort();
40
+ return `import { ${builders.join(", ")} } from "${specifier}";`;
41
+ };
42
+
43
+ export const collectionFileSource = ({
44
+ collections,
45
+ imports,
46
+ }: {
47
+ collections: readonly string[];
48
+ imports: ConfigImports;
49
+ }): string =>
50
+ [
51
+ builderImportsFor({ collections, specifier: imports.builders }),
52
+ "",
53
+ ...collections.map((name) => `export const ${name} = [];`),
54
+ "",
55
+ ].join("\n");
56
+
57
+ /** A collection whose file is not imported stays inline as an empty array. */
58
+ export const rootSource = ({
59
+ imports,
60
+ importedFiles = new Set(Object.keys(COLLECTION_FILES)),
61
+ }: {
62
+ imports: ConfigImports;
63
+ importedFiles?: ReadonlySet<string>;
64
+ }): string => {
65
+ const collectionImports: string[] = [];
66
+ const collectionKeys: string[] = [];
67
+ for (const [file, collections] of Object.entries(COLLECTION_FILES)) {
68
+ if (importedFiles.has(file)) {
69
+ collectionImports.push(
70
+ `import { ${collections.join(", ")} } from "./${file.replace(/\.ts$/, "")}";`,
71
+ );
72
+ collectionKeys.push(...collections.map((name) => `\t${name},`));
73
+ } else {
74
+ collectionKeys.push(...collections.map((name) => `\t${name}: [],`));
75
+ }
76
+ }
77
+ return [
78
+ `import { atmn } from "${imports.atmn}";`,
79
+ ...collectionImports,
80
+ "",
81
+ "/**",
82
+ " * Your catalog, as code. `atmn pull` fills the collection files from the",
83
+ " * server; `atmn push` makes the server match them.",
84
+ " */",
85
+ "export default atmn({",
86
+ ...collectionKeys,
87
+ "\tsettings: {},",
88
+ "});",
89
+ "",
90
+ ].join("\n");
91
+ };
92
+
93
+ /**
94
+ * The first pull's starting point: a root config that imports one empty
95
+ * array per collection from its own file, so the pull that follows appends
96
+ * the whole catalog into them.
97
+ */
98
+ export const scaffoldConfig = ({
99
+ directory,
100
+ configPath = join(directory, "autumn.config.ts"),
101
+ imports = packageImports(),
102
+ }: {
103
+ directory: string;
104
+ /** `-c` may name the file; the folder is created when missing. */
105
+ configPath?: string;
106
+ imports?: ConfigImports;
107
+ }): string => {
108
+ mkdirSync(directory, { recursive: true });
109
+ writeFileSync(configPath, rootSource({ imports }), "utf8");
110
+ // Collection files sit beside the config, wherever `-c` put it.
111
+ const configDir = dirname(configPath);
112
+ for (const [file, collections] of Object.entries(COLLECTION_FILES)) {
113
+ const path = join(configDir, file);
114
+ if (existsSync(path)) continue;
115
+ writeFileSync(path, collectionFileSource({ collections, imports }), "utf8");
116
+ }
117
+ return configPath;
118
+ };