@messagevisor/core 0.0.1 → 0.1.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 (211) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -0
  4. package/jest.config.js +8 -0
  5. package/lib/benchmark/index.d.ts +2 -0
  6. package/lib/benchmark/index.js +417 -0
  7. package/lib/benchmark/index.js.map +1 -0
  8. package/lib/builder/index.d.ts +70 -0
  9. package/lib/builder/index.js +831 -0
  10. package/lib/builder/index.js.map +1 -0
  11. package/lib/cli/index.d.ts +28 -0
  12. package/lib/cli/index.js +182 -0
  13. package/lib/cli/index.js.map +1 -0
  14. package/lib/config/index.d.ts +61 -0
  15. package/lib/config/index.js +255 -0
  16. package/lib/config/index.js.map +1 -0
  17. package/lib/create/index.d.ts +2 -0
  18. package/lib/create/index.js +405 -0
  19. package/lib/create/index.js.map +1 -0
  20. package/lib/datasource/filesystemAdapter.d.ts +44 -0
  21. package/lib/datasource/filesystemAdapter.js +424 -0
  22. package/lib/datasource/filesystemAdapter.js.map +1 -0
  23. package/lib/datasource/index.d.ts +39 -0
  24. package/lib/datasource/index.js +96 -0
  25. package/lib/datasource/index.js.map +1 -0
  26. package/lib/error.d.ts +6 -0
  27. package/lib/error.js +49 -0
  28. package/lib/error.js.map +1 -0
  29. package/lib/evaluate/cli.d.ts +8 -0
  30. package/lib/evaluate/cli.js +179 -0
  31. package/lib/evaluate/cli.js.map +1 -0
  32. package/lib/evaluate/index.d.ts +10 -0
  33. package/lib/evaluate/index.js +131 -0
  34. package/lib/evaluate/index.js.map +1 -0
  35. package/lib/examples/coerceExampleIsoDates.d.ts +12 -0
  36. package/lib/examples/coerceExampleIsoDates.js +81 -0
  37. package/lib/examples/coerceExampleIsoDates.js.map +1 -0
  38. package/lib/examples/index.d.ts +63 -0
  39. package/lib/examples/index.js +713 -0
  40. package/lib/examples/index.js.map +1 -0
  41. package/lib/exporter/index.d.ts +60 -0
  42. package/lib/exporter/index.js +610 -0
  43. package/lib/exporter/index.js.map +1 -0
  44. package/lib/find-duplicates/index.d.ts +41 -0
  45. package/lib/find-duplicates/index.js +297 -0
  46. package/lib/find-duplicates/index.js.map +1 -0
  47. package/lib/generate-code/index.d.ts +11 -0
  48. package/lib/generate-code/index.js +157 -0
  49. package/lib/generate-code/index.js.map +1 -0
  50. package/lib/generate-code/typescript.d.ts +14 -0
  51. package/lib/generate-code/typescript.js +307 -0
  52. package/lib/generate-code/typescript.js.map +1 -0
  53. package/lib/importer/index.d.ts +64 -0
  54. package/lib/importer/index.js +1092 -0
  55. package/lib/importer/index.js.map +1 -0
  56. package/lib/index.d.ts +18 -0
  57. package/lib/index.js +35 -0
  58. package/lib/index.js.map +1 -0
  59. package/lib/info/index.d.ts +17 -0
  60. package/lib/info/index.js +132 -0
  61. package/lib/info/index.js.map +1 -0
  62. package/lib/init/index.d.ts +30 -0
  63. package/lib/init/index.js +348 -0
  64. package/lib/init/index.js.map +1 -0
  65. package/lib/lint/index.d.ts +1 -0
  66. package/lib/lint/index.js +6 -0
  67. package/lib/lint/index.js.map +1 -0
  68. package/lib/linter/attributeSchema.d.ts +7 -0
  69. package/lib/linter/attributeSchema.js +36 -0
  70. package/lib/linter/attributeSchema.js.map +1 -0
  71. package/lib/linter/checkLocaleCircularDependency.d.ts +7 -0
  72. package/lib/linter/checkLocaleCircularDependency.js +42 -0
  73. package/lib/linter/checkLocaleCircularDependency.js.map +1 -0
  74. package/lib/linter/conditionSchema.d.ts +3 -0
  75. package/lib/linter/conditionSchema.js +283 -0
  76. package/lib/linter/conditionSchema.js.map +1 -0
  77. package/lib/linter/formatSchema.d.ts +325 -0
  78. package/lib/linter/formatSchema.js +165 -0
  79. package/lib/linter/formatSchema.js.map +1 -0
  80. package/lib/linter/icuStyleLint.d.ts +6 -0
  81. package/lib/linter/icuStyleLint.js +226 -0
  82. package/lib/linter/icuStyleLint.js.map +1 -0
  83. package/lib/linter/index.d.ts +34 -0
  84. package/lib/linter/index.js +557 -0
  85. package/lib/linter/index.js.map +1 -0
  86. package/lib/linter/localeSchema.d.ts +672 -0
  87. package/lib/linter/localeSchema.js +50 -0
  88. package/lib/linter/localeSchema.js.map +1 -0
  89. package/lib/linter/messageSchema.d.ts +35 -0
  90. package/lib/linter/messageSchema.js +115 -0
  91. package/lib/linter/messageSchema.js.map +1 -0
  92. package/lib/linter/printError.d.ts +8 -0
  93. package/lib/linter/printError.js +41 -0
  94. package/lib/linter/printError.js.map +1 -0
  95. package/lib/linter/schema.d.ts +33 -0
  96. package/lib/linter/schema.js +192 -0
  97. package/lib/linter/schema.js.map +1 -0
  98. package/lib/linter/segmentSchema.d.ts +8 -0
  99. package/lib/linter/segmentSchema.js +18 -0
  100. package/lib/linter/segmentSchema.js.map +1 -0
  101. package/lib/linter/targetSchema.d.ts +337 -0
  102. package/lib/linter/targetSchema.js +39 -0
  103. package/lib/linter/targetSchema.js.map +1 -0
  104. package/lib/linter/testSchema.d.ts +71 -0
  105. package/lib/linter/testSchema.js +165 -0
  106. package/lib/linter/testSchema.js.map +1 -0
  107. package/lib/linter/zodHelpers.d.ts +2 -0
  108. package/lib/linter/zodHelpers.js +15 -0
  109. package/lib/linter/zodHelpers.js.map +1 -0
  110. package/lib/list/index.d.ts +8 -0
  111. package/lib/list/index.js +524 -0
  112. package/lib/list/index.js.map +1 -0
  113. package/lib/matrix.d.ts +4 -0
  114. package/lib/matrix.js +66 -0
  115. package/lib/matrix.js.map +1 -0
  116. package/lib/promoter/index.d.ts +65 -0
  117. package/lib/promoter/index.js +1208 -0
  118. package/lib/promoter/index.js.map +1 -0
  119. package/lib/prune/index.d.ts +37 -0
  120. package/lib/prune/index.js +673 -0
  121. package/lib/prune/index.js.map +1 -0
  122. package/lib/sets.d.ts +10 -0
  123. package/lib/sets.js +120 -0
  124. package/lib/sets.js.map +1 -0
  125. package/lib/tester/cliFormat.d.ts +8 -0
  126. package/lib/tester/cliFormat.js +15 -0
  127. package/lib/tester/cliFormat.js.map +1 -0
  128. package/lib/tester/index.d.ts +35 -0
  129. package/lib/tester/index.js +713 -0
  130. package/lib/tester/index.js.map +1 -0
  131. package/lib/tester/matrix.d.ts +14 -0
  132. package/lib/tester/matrix.js +76 -0
  133. package/lib/tester/matrix.js.map +1 -0
  134. package/lib/tester/prettyDuration.d.ts +1 -0
  135. package/lib/tester/prettyDuration.js +30 -0
  136. package/lib/tester/prettyDuration.js.map +1 -0
  137. package/lib/tester/printTestResult.d.ts +2 -0
  138. package/lib/tester/printTestResult.js +32 -0
  139. package/lib/tester/printTestResult.js.map +1 -0
  140. package/lib/tester/types.d.ts +29 -0
  141. package/lib/tester/types.js +3 -0
  142. package/lib/tester/types.js.map +1 -0
  143. package/package.json +41 -13
  144. package/src/benchmark/index.spec.ts +375 -0
  145. package/src/benchmark/index.ts +433 -0
  146. package/src/builder/index.spec.ts +822 -0
  147. package/src/builder/index.ts +920 -0
  148. package/src/cli/index.spec.ts +54 -0
  149. package/src/cli/index.ts +150 -0
  150. package/src/config/index.spec.ts +70 -0
  151. package/src/config/index.ts +259 -0
  152. package/src/create/index.spec.ts +272 -0
  153. package/src/create/index.ts +295 -0
  154. package/src/datasource/filesystemAdapter.ts +313 -0
  155. package/src/datasource/index.ts +135 -0
  156. package/src/error.ts +33 -0
  157. package/src/evaluate/cli.spec.ts +368 -0
  158. package/src/evaluate/cli.ts +130 -0
  159. package/src/evaluate/index.ts +161 -0
  160. package/src/examples/coerceExampleIsoDates.spec.ts +81 -0
  161. package/src/examples/coerceExampleIsoDates.ts +98 -0
  162. package/src/examples/index.spec.ts +453 -0
  163. package/src/examples/index.ts +854 -0
  164. package/src/exporter/index.spec.ts +443 -0
  165. package/src/exporter/index.ts +643 -0
  166. package/src/find-duplicates/index.spec.ts +289 -0
  167. package/src/find-duplicates/index.ts +314 -0
  168. package/src/generate-code/index.ts +92 -0
  169. package/src/generate-code/typescript.spec.ts +241 -0
  170. package/src/generate-code/typescript.ts +284 -0
  171. package/src/importer/index.spec.ts +1101 -0
  172. package/src/importer/index.ts +1190 -0
  173. package/src/index.ts +18 -0
  174. package/src/info/index.ts +67 -0
  175. package/src/init/index.spec.ts +279 -0
  176. package/src/init/index.ts +292 -0
  177. package/src/lint/index.ts +1 -0
  178. package/src/linter/attributeSchema.ts +38 -0
  179. package/src/linter/checkLocaleCircularDependency.ts +51 -0
  180. package/src/linter/conditionSchema.ts +386 -0
  181. package/src/linter/formatSchema.ts +170 -0
  182. package/src/linter/icuStyleLint.ts +312 -0
  183. package/src/linter/index.spec.ts +824 -0
  184. package/src/linter/index.ts +460 -0
  185. package/src/linter/localeSchema.ts +70 -0
  186. package/src/linter/messageSchema.ts +152 -0
  187. package/src/linter/printError.ts +52 -0
  188. package/src/linter/schema.ts +230 -0
  189. package/src/linter/segmentSchema.ts +15 -0
  190. package/src/linter/targetSchema.ts +50 -0
  191. package/src/linter/testSchema.spec.ts +405 -0
  192. package/src/linter/testSchema.ts +239 -0
  193. package/src/linter/zodHelpers.ts +16 -0
  194. package/src/list/index.spec.ts +431 -0
  195. package/src/list/index.ts +463 -0
  196. package/src/matrix.ts +69 -0
  197. package/src/promoter/index.spec.ts +584 -0
  198. package/src/promoter/index.ts +1267 -0
  199. package/src/prune/index.spec.ts +418 -0
  200. package/src/prune/index.ts +693 -0
  201. package/src/sets.ts +74 -0
  202. package/src/tester/cliFormat.ts +11 -0
  203. package/src/tester/featurevisorIntegration.spec.ts +101 -0
  204. package/src/tester/index.spec.ts +577 -0
  205. package/src/tester/index.ts +679 -0
  206. package/src/tester/matrix.ts +106 -0
  207. package/src/tester/prettyDuration.ts +34 -0
  208. package/src/tester/printTestResult.ts +40 -0
  209. package/src/tester/types.ts +32 -0
  210. package/tsconfig.cjs.json +11 -0
  211. package/tsconfig.typecheck.json +4 -0
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export * from "./builder";
2
+ export * from "./benchmark";
3
+ export * from "./cli";
4
+ export * from "./config";
5
+ export * from "./create";
6
+ export * from "./datasource";
7
+ export * from "./examples";
8
+ export * from "./prune";
9
+ export * from "./evaluate";
10
+ export * from "./exporter";
11
+ export * from "./find-duplicates";
12
+ export * from "./generate-code";
13
+ export * from "./info";
14
+ export * from "./importer";
15
+ export * from "./init";
16
+ export * from "./linter";
17
+ export * from "./promoter";
18
+ export * from "./tester";
@@ -0,0 +1,67 @@
1
+ import type { Datasource } from "../datasource";
2
+ import { assertProjectSetJsonSelection, getProjectSetExecutions } from "../sets";
3
+
4
+ export async function getProjectInfo(datasource: Datasource) {
5
+ const [locales, messages, segments, attributes, targets, tests] = await Promise.all([
6
+ datasource.listLocales(),
7
+ datasource.listMessages(),
8
+ datasource.listSegments(),
9
+ datasource.listAttributes(),
10
+ datasource.listTargets(),
11
+ datasource.listTests(),
12
+ ]);
13
+
14
+ return {
15
+ locales: locales.length,
16
+ messages: messages.length,
17
+ segments: segments.length,
18
+ attributes: attributes.length,
19
+ targets: targets.length,
20
+ tests: tests.length,
21
+ };
22
+ }
23
+
24
+ export const infoPlugin = {
25
+ command: "info",
26
+ handler: async ({ datasource, parsed }: any) => {
27
+ const projectConfig = datasource.getConfig();
28
+ assertProjectSetJsonSelection(projectConfig, parsed.set, parsed.json);
29
+
30
+ if (projectConfig.sets) {
31
+ const executions = await getProjectSetExecutions(projectConfig, datasource, parsed.set);
32
+ const infoBySet: Record<string, Awaited<ReturnType<typeof getProjectInfo>>> = {};
33
+
34
+ for (const execution of executions) {
35
+ infoBySet[execution.set] = await getProjectInfo(execution.datasource);
36
+ }
37
+
38
+ if (parsed.json) {
39
+ console.log(parsed.pretty ? JSON.stringify(infoBySet, null, 2) : JSON.stringify(infoBySet));
40
+ return;
41
+ }
42
+
43
+ console.log("\nProject info:\n");
44
+ for (const set of Object.keys(infoBySet)) {
45
+ console.log(`Set "${set}":`);
46
+ for (const key of Object.keys(infoBySet[set])) {
47
+ console.log(` - ${key}: ${(infoBySet[set] as any)[key]}`);
48
+ }
49
+ console.log("");
50
+ }
51
+ return;
52
+ }
53
+
54
+ const info = await getProjectInfo(datasource);
55
+
56
+ if (parsed.json) {
57
+ console.log(parsed.pretty ? JSON.stringify(info, null, 2) : JSON.stringify(info));
58
+ return;
59
+ }
60
+
61
+ console.log("\nProject info:\n");
62
+ for (const key of Object.keys(info)) {
63
+ console.log(` - ${key}: ${(info as any)[key]}`);
64
+ }
65
+ },
66
+ examples: [{ command: "info", description: "show project entity counts" }],
67
+ };
@@ -0,0 +1,279 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+
5
+ import { Datasource } from "../datasource";
6
+ import { getProjectConfig } from "../config";
7
+ import { lintProject } from "../linter";
8
+ import { DEFAULT_PROJECT, initProject, initProjectFromCLI } from "./index";
9
+
10
+ const tar: any = require("tar");
11
+
12
+ const REPO_ROOT = path.resolve(__dirname, "../../../..");
13
+ const PROJECT_FIXTURES = [
14
+ "projects/project-yml",
15
+ "projects/project-json",
16
+ "projects/project-raw",
17
+ "projects/project-environments",
18
+ "projects/project-test-envs",
19
+ ];
20
+
21
+ let tarballPathPromise: Promise<string> | undefined;
22
+
23
+ async function getFixtureTarballPath() {
24
+ if (!tarballPathPromise) {
25
+ tarballPathPromise = (async () => {
26
+ const tempDirectoryPath = await fs.promises.mkdtemp(
27
+ path.join(os.tmpdir(), "messagevisor-init-tarball-"),
28
+ );
29
+ const tarballPath = path.join(tempDirectoryPath, "examples.tar.gz");
30
+
31
+ await tar.c(
32
+ {
33
+ gzip: true,
34
+ file: tarballPath,
35
+ cwd: REPO_ROOT,
36
+ prefix: "messagevisor-main/",
37
+ },
38
+ PROJECT_FIXTURES,
39
+ );
40
+
41
+ return tarballPath;
42
+ })();
43
+ }
44
+
45
+ return tarballPathPromise;
46
+ }
47
+
48
+ async function createDestinationDirectory() {
49
+ return fs.promises.mkdtemp(path.join(os.tmpdir(), "messagevisor-init-destination-"));
50
+ }
51
+
52
+ async function installModuleIcuStub(directoryPath: string) {
53
+ const moduleDirectoryPath = path.join(
54
+ directoryPath,
55
+ "node_modules",
56
+ "@messagevisor",
57
+ "module-icu",
58
+ );
59
+
60
+ await fs.promises.mkdir(moduleDirectoryPath, { recursive: true });
61
+ await fs.promises.writeFile(
62
+ path.join(moduleDirectoryPath, "index.js"),
63
+ "exports.createICUModule = function () { return { name: 'icu' }; };\n",
64
+ );
65
+ }
66
+
67
+ describe("initProject", function () {
68
+ it("defaults to the yml starter and strips generated artifacts", async function () {
69
+ const directoryPath = await createDestinationDirectory();
70
+ const tarballPath = await getFixtureTarballPath();
71
+
72
+ const result = await initProject(directoryPath, DEFAULT_PROJECT, {
73
+ tarballPath,
74
+ });
75
+
76
+ expect(result.project).toEqual("yml");
77
+ expect(fs.existsSync(path.join(directoryPath, "messagevisor.config.js"))).toBe(true);
78
+ expect(fs.existsSync(path.join(directoryPath, "messages/nav/contact.yml"))).toBe(true);
79
+ expect(fs.existsSync(path.join(directoryPath, "tests/messages/nav/contact.spec.yml"))).toBe(
80
+ true,
81
+ );
82
+ expect(fs.existsSync(path.join(directoryPath, "datafiles"))).toBe(false);
83
+ expect(fs.existsSync(path.join(directoryPath, ".messagevisor", "REVISION"))).toBe(false);
84
+
85
+ await installModuleIcuStub(directoryPath);
86
+ const projectConfig = getProjectConfig(directoryPath);
87
+ const datasource = new Datasource(projectConfig, directoryPath);
88
+ const lintResult = await lintProject(projectConfig, datasource);
89
+ expect(lintResult.hasError).toBe(false);
90
+ });
91
+
92
+ it("scaffolds the json starter", async function () {
93
+ const directoryPath = await createDestinationDirectory();
94
+ const tarballPath = await getFixtureTarballPath();
95
+
96
+ await initProject(directoryPath, "json", { tarballPath });
97
+
98
+ expect(fs.existsSync(path.join(directoryPath, "locales/en.json"))).toBe(true);
99
+ expect(fs.existsSync(path.join(directoryPath, "tests/messages/nav/contact.spec.json"))).toBe(
100
+ true,
101
+ );
102
+ expect(fs.existsSync(path.join(directoryPath, "messagevisor.config.js"))).toBe(true);
103
+ });
104
+
105
+ it("scaffolds the environments starter with sets and strips generated artifacts", async function () {
106
+ const directoryPath = await createDestinationDirectory();
107
+ const tarballPath = await getFixtureTarballPath();
108
+
109
+ await initProject(directoryPath, "environments", { tarballPath });
110
+
111
+ expect(fs.existsSync(path.join(directoryPath, "sets/dev/messages/nav/contact.yml"))).toBe(true);
112
+ expect(fs.existsSync(path.join(directoryPath, "sets/staging/tests/targets/web.spec.yml"))).toBe(
113
+ true,
114
+ );
115
+ expect(fs.existsSync(path.join(directoryPath, "datafiles"))).toBe(false);
116
+ expect(
117
+ fs.existsSync(path.join(directoryPath, ".messagevisor", "sets", "dev", "REVISION")),
118
+ ).toBe(false);
119
+ });
120
+
121
+ it("fails clearly for unknown projects", async function () {
122
+ const directoryPath = await createDestinationDirectory();
123
+ const tarballPath = await getFixtureTarballPath();
124
+
125
+ await expect(
126
+ initProject(directoryPath, "nope", {
127
+ tarballPath,
128
+ }),
129
+ ).rejects.toThrow(
130
+ 'Unknown project "nope". No matching project-nope found in examples tarball.',
131
+ );
132
+ });
133
+
134
+ it("fails in a non-empty destination without overwrite", async function () {
135
+ const directoryPath = await createDestinationDirectory();
136
+ const tarballPath = await getFixtureTarballPath();
137
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
138
+
139
+ await expect(
140
+ initProject(directoryPath, "yml", {
141
+ tarballPath,
142
+ }),
143
+ ).rejects.toThrow("Pass --overwrite to initialize there and skip conflicting files.");
144
+ });
145
+
146
+ it("skips conflicting files when overwrite is enabled", async function () {
147
+ const directoryPath = await createDestinationDirectory();
148
+ const tarballPath = await getFixtureTarballPath();
149
+ const configPath = path.join(directoryPath, "messagevisor.config.js");
150
+ await fs.promises.writeFile(configPath, "module.exports = { custom: true };\n");
151
+
152
+ const result = await initProject(directoryPath, "yml", {
153
+ tarballPath,
154
+ overwrite: true,
155
+ });
156
+
157
+ expect(result.skippedConflictCount).toBeGreaterThanOrEqual(1);
158
+ expect(await fs.promises.readFile(configPath, "utf8")).toEqual(
159
+ "module.exports = { custom: true };\n",
160
+ );
161
+ expect(fs.existsSync(path.join(directoryPath, "locales/en.yml"))).toBe(true);
162
+ });
163
+ });
164
+
165
+ describe("initProjectFromCLI", function () {
166
+ it("initializes in place when the current directory is empty without prompting", async function () {
167
+ const directoryPath = await createDestinationDirectory();
168
+ const tarballPath = await getFixtureTarballPath();
169
+ const promptDirectoryName = jest.fn();
170
+
171
+ const result = await initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
172
+ tarballPath,
173
+ promptDirectoryName,
174
+ });
175
+
176
+ expect(result.destinationDirectoryPath).toEqual(directoryPath);
177
+ expect(promptDirectoryName).not.toHaveBeenCalled();
178
+ expect(fs.existsSync(path.join(directoryPath, "messagevisor.config.js"))).toBe(true);
179
+ });
180
+
181
+ it("prompts for a child directory when the current directory is not empty", async function () {
182
+ const directoryPath = await createDestinationDirectory();
183
+ const tarballPath = await getFixtureTarballPath();
184
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
185
+
186
+ const result = await initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
187
+ tarballPath,
188
+ promptDirectoryName: async () => "my-project",
189
+ });
190
+ const projectDirectoryPath = path.join(directoryPath, "my-project");
191
+
192
+ expect(result.destinationDirectoryPath).toEqual(projectDirectoryPath);
193
+ expect(fs.existsSync(path.join(directoryPath, "existing.txt"))).toBe(true);
194
+ expect(fs.existsSync(path.join(projectDirectoryPath, "messagevisor.config.js"))).toBe(true);
195
+ });
196
+
197
+ it("rejects a blank prompted directory name", async function () {
198
+ const directoryPath = await createDestinationDirectory();
199
+ const tarballPath = await getFixtureTarballPath();
200
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
201
+
202
+ await expect(
203
+ initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
204
+ tarballPath,
205
+ promptDirectoryName: async () => " ",
206
+ }),
207
+ ).rejects.toThrow("Directory name is required to initialize a new Messagevisor project.");
208
+ });
209
+
210
+ it("rejects absolute and nested prompted directory names", async function () {
211
+ const directoryPath = await createDestinationDirectory();
212
+ const tarballPath = await getFixtureTarballPath();
213
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
214
+
215
+ await expect(
216
+ initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
217
+ tarballPath,
218
+ promptDirectoryName: async () => path.join(directoryPath, "project"),
219
+ }),
220
+ ).rejects.toThrow("Directory name must be a simple folder name");
221
+
222
+ await expect(
223
+ initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
224
+ tarballPath,
225
+ promptDirectoryName: async () => "nested/project",
226
+ }),
227
+ ).rejects.toThrow("Directory name must be a simple folder name");
228
+ });
229
+
230
+ it("rejects an existing non-empty child directory", async function () {
231
+ const directoryPath = await createDestinationDirectory();
232
+ const tarballPath = await getFixtureTarballPath();
233
+ const childDirectoryPath = path.join(directoryPath, "my-project");
234
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
235
+ await fs.promises.mkdir(childDirectoryPath);
236
+ await fs.promises.writeFile(path.join(childDirectoryPath, "existing.txt"), "hello");
237
+
238
+ await expect(
239
+ initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
240
+ tarballPath,
241
+ promptDirectoryName: async () => "my-project",
242
+ }),
243
+ ).rejects.toThrow("Please choose a different directory name.");
244
+ });
245
+
246
+ it("initializes in the current directory without prompting when overwrite is enabled", async function () {
247
+ const directoryPath = await createDestinationDirectory();
248
+ const tarballPath = await getFixtureTarballPath();
249
+ const promptDirectoryName = jest.fn();
250
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
251
+
252
+ const result = await initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
253
+ tarballPath,
254
+ overwrite: true,
255
+ promptDirectoryName,
256
+ });
257
+
258
+ expect(result.destinationDirectoryPath).toEqual(directoryPath);
259
+ expect(promptDirectoryName).not.toHaveBeenCalled();
260
+ expect(fs.existsSync(path.join(directoryPath, "existing.txt"))).toBe(true);
261
+ expect(fs.existsSync(path.join(directoryPath, "messagevisor.config.js"))).toBe(true);
262
+ });
263
+
264
+ it("fails clearly for non-interactive non-empty current directories", async function () {
265
+ const directoryPath = await createDestinationDirectory();
266
+ const tarballPath = await getFixtureTarballPath();
267
+ await fs.promises.writeFile(path.join(directoryPath, "existing.txt"), "hello");
268
+
269
+ await expect(
270
+ initProjectFromCLI(directoryPath, DEFAULT_PROJECT, {
271
+ tarballPath,
272
+ input: { isTTY: false } as NodeJS.ReadableStream & { isTTY: boolean },
273
+ output: { isTTY: false } as NodeJS.WritableStream & { isTTY: boolean },
274
+ }),
275
+ ).rejects.toThrow(
276
+ "Current working directory is not empty. Run `messagevisor init` from an empty directory, or pass --overwrite.",
277
+ );
278
+ });
279
+ });
@@ -0,0 +1,292 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { createInterface } from "readline/promises";
4
+ import { Readable } from "stream";
5
+
6
+ import type { Plugin } from "../cli";
7
+ import { MessagevisorCLIError } from "../error";
8
+
9
+ const tar: any = require("tar");
10
+
11
+ export const DEFAULT_PROJECT = "yml";
12
+ export const EXAMPLES_ORG_NAME = "messagevisor";
13
+ export const EXAMPLES_REPO_NAME = "messagevisor";
14
+ export const EXAMPLES_BRANCH_NAME = "main";
15
+ export const EXAMPLES_TAR_URL = `https://codeload.github.com/${EXAMPLES_ORG_NAME}/${EXAMPLES_REPO_NAME}/tar.gz/${EXAMPLES_BRANCH_NAME}`;
16
+
17
+ export interface InitProjectOptions {
18
+ overwrite?: boolean;
19
+ tarballUrl?: string;
20
+ tarballPath?: string;
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ export interface InitCommandOptions extends InitProjectOptions {
25
+ input?: NodeJS.ReadableStream & { isTTY?: boolean };
26
+ output?: NodeJS.WritableStream & { isTTY?: boolean };
27
+ promptDirectoryName?: (message: string) => Promise<string>;
28
+ }
29
+
30
+ export interface InitProjectResult {
31
+ project: string;
32
+ destinationDirectoryPath: string;
33
+ createdFileCount: number;
34
+ skippedConflictCount: number;
35
+ }
36
+
37
+ function getProjectArchivePath(projectName: string) {
38
+ return `${EXAMPLES_REPO_NAME}-${EXAMPLES_BRANCH_NAME}/projects/project-${projectName}/`;
39
+ }
40
+
41
+ function getExcludedReason(relativePath: string) {
42
+ const normalized = relativePath.replace(/\\/g, "/").replace(/\/+$/, "");
43
+
44
+ if (!normalized) {
45
+ return null;
46
+ }
47
+
48
+ const topLevelName = normalized.split("/")[0];
49
+
50
+ if (["datafiles", "catalog", "exports", "imports", "node_modules"].includes(topLevelName)) {
51
+ return "generated";
52
+ }
53
+
54
+ if (normalized === ".DS_Store" || normalized.endsWith("/.DS_Store")) {
55
+ return "generated";
56
+ }
57
+
58
+ if (
59
+ normalized === ".messagevisor/REVISION" ||
60
+ normalized.startsWith(".messagevisor/promotions/") ||
61
+ normalized.startsWith(".messagevisor/memory/") ||
62
+ /^\.messagevisor\/sets\/[^/]+\/REVISION$/.test(normalized)
63
+ ) {
64
+ return "generated";
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ async function assertDestinationReady(directoryPath: string, overwrite?: boolean) {
71
+ await fs.promises.mkdir(directoryPath, { recursive: true });
72
+ const entries = await fs.promises.readdir(directoryPath);
73
+
74
+ if (entries.length > 0 && !overwrite) {
75
+ throw new MessagevisorCLIError(
76
+ `Destination directory is not empty: ${directoryPath}. Pass --overwrite to initialize there and skip conflicting files.`,
77
+ );
78
+ }
79
+ }
80
+
81
+ async function isDirectoryEmpty(directoryPath: string) {
82
+ await fs.promises.mkdir(directoryPath, { recursive: true });
83
+ const entries = await fs.promises.readdir(directoryPath);
84
+
85
+ return entries.length === 0;
86
+ }
87
+
88
+ function getInitProjectOptions(options: InitCommandOptions): InitProjectOptions {
89
+ return {
90
+ overwrite: options.overwrite,
91
+ tarballUrl: options.tarballUrl,
92
+ tarballPath: options.tarballPath,
93
+ fetchImpl: options.fetchImpl,
94
+ };
95
+ }
96
+
97
+ function validateNewDirectoryName(directoryName: string) {
98
+ const trimmed = directoryName.trim();
99
+
100
+ if (!trimmed) {
101
+ throw new MessagevisorCLIError(
102
+ "Directory name is required to initialize a new Messagevisor project.",
103
+ );
104
+ }
105
+
106
+ if (
107
+ path.isAbsolute(trimmed) ||
108
+ trimmed.includes("/") ||
109
+ trimmed.includes("\\") ||
110
+ trimmed === "." ||
111
+ trimmed === ".."
112
+ ) {
113
+ throw new MessagevisorCLIError(
114
+ "Directory name must be a simple folder name, not an absolute path or nested path.",
115
+ );
116
+ }
117
+
118
+ return trimmed;
119
+ }
120
+
121
+ async function promptForNewDirectoryName(
122
+ message: string,
123
+ options: InitCommandOptions,
124
+ ): Promise<string> {
125
+ if (options.promptDirectoryName) {
126
+ return options.promptDirectoryName(message);
127
+ }
128
+
129
+ const input = options.input || process.stdin;
130
+ const output = options.output || process.stdout;
131
+ const canPrompt = input.isTTY !== false && output.isTTY !== false;
132
+
133
+ if (!canPrompt) {
134
+ throw new MessagevisorCLIError(
135
+ "Current working directory is not empty. Run `messagevisor init` from an empty directory, or pass --overwrite.",
136
+ );
137
+ }
138
+
139
+ const readline = createInterface({ input, output });
140
+
141
+ try {
142
+ return readline.question(message);
143
+ } finally {
144
+ readline.close();
145
+ }
146
+ }
147
+
148
+ async function getTarballStream(options: InitProjectOptions = {}) {
149
+ if (options.tarballPath) {
150
+ return fs.createReadStream(options.tarballPath);
151
+ }
152
+
153
+ const fetchImpl = options.fetchImpl || fetch;
154
+ const response = await fetchImpl(options.tarballUrl || EXAMPLES_TAR_URL);
155
+
156
+ if (!response.ok || !response.body) {
157
+ throw new Error(
158
+ `Unable to download Messagevisor examples tarball: ${response.status} ${response.statusText}`,
159
+ );
160
+ }
161
+
162
+ return Readable.fromWeb(response.body as any);
163
+ }
164
+
165
+ export async function initProject(
166
+ directoryPath: string,
167
+ projectName: string = DEFAULT_PROJECT,
168
+ options: InitProjectOptions = {},
169
+ ): Promise<InitProjectResult> {
170
+ await assertDestinationReady(directoryPath, options.overwrite);
171
+
172
+ const tarballStream = await getTarballStream(options);
173
+ const projectArchivePath = getProjectArchivePath(projectName);
174
+ let createdFileCount = 0;
175
+ let skippedConflictCount = 0;
176
+ let matchedProjectPath = false;
177
+
178
+ await new Promise<void>((resolve, reject) => {
179
+ tarballStream
180
+ .pipe(
181
+ tar.x({
182
+ cwd: directoryPath,
183
+ strip: 3,
184
+ filter: (archivePath, entry) => {
185
+ if (!archivePath.startsWith(projectArchivePath)) {
186
+ return false;
187
+ }
188
+
189
+ matchedProjectPath = true;
190
+ const relativePath = archivePath.slice(projectArchivePath.length);
191
+
192
+ if (!relativePath) {
193
+ return false;
194
+ }
195
+
196
+ if (getExcludedReason(relativePath)) {
197
+ return false;
198
+ }
199
+
200
+ const destinationPath = path.join(directoryPath, relativePath);
201
+ const entryType = (entry as any).type;
202
+
203
+ if (entryType !== "Directory" && fs.existsSync(destinationPath)) {
204
+ skippedConflictCount += 1;
205
+ return false;
206
+ }
207
+
208
+ if (entryType !== "Directory") {
209
+ createdFileCount += 1;
210
+ }
211
+
212
+ return true;
213
+ },
214
+ }),
215
+ )
216
+ .on("error", reject)
217
+ .on("finish", resolve);
218
+ });
219
+
220
+ if (!matchedProjectPath) {
221
+ throw new MessagevisorCLIError(
222
+ `Unknown project "${projectName}". No matching project-${projectName} found in examples tarball.`,
223
+ );
224
+ }
225
+
226
+ return {
227
+ project: projectName,
228
+ destinationDirectoryPath: directoryPath,
229
+ createdFileCount,
230
+ skippedConflictCount,
231
+ };
232
+ }
233
+
234
+ export async function initProjectFromCLI(
235
+ rootDirectoryPath: string,
236
+ projectName: string = DEFAULT_PROJECT,
237
+ options: InitCommandOptions = {},
238
+ ): Promise<InitProjectResult> {
239
+ if (options.overwrite || (await isDirectoryEmpty(rootDirectoryPath))) {
240
+ return initProject(rootDirectoryPath, projectName, getInitProjectOptions(options));
241
+ }
242
+
243
+ const directoryName = validateNewDirectoryName(
244
+ await promptForNewDirectoryName(
245
+ `Current working directory is not empty: ${rootDirectoryPath}\nEnter a new directory name to initialize Messagevisor project: `,
246
+ options,
247
+ ),
248
+ );
249
+ const destinationDirectoryPath = path.join(rootDirectoryPath, directoryName);
250
+
251
+ if (!(await isDirectoryEmpty(destinationDirectoryPath))) {
252
+ throw new MessagevisorCLIError(
253
+ `Destination directory is not empty: ${destinationDirectoryPath}. Please choose a different directory name.`,
254
+ );
255
+ }
256
+
257
+ return initProject(destinationDirectoryPath, projectName, getInitProjectOptions(options));
258
+ }
259
+
260
+ export const initPlugin: Plugin = {
261
+ command: "init",
262
+ handler: async ({ rootDirectoryPath, parsed }) => {
263
+ const result = await initProjectFromCLI(rootDirectoryPath, parsed.project || DEFAULT_PROJECT, {
264
+ overwrite: parsed.overwrite === true,
265
+ });
266
+
267
+ console.log(`Initialized Messagevisor project from "${result.project}"`);
268
+ console.log(`Destination directory: ${result.destinationDirectoryPath}`);
269
+ console.log(`Created files: ${result.createdFileCount}`);
270
+
271
+ if (result.skippedConflictCount > 0) {
272
+ console.log(`Skipped conflicts: ${result.skippedConflictCount}`);
273
+ }
274
+
275
+ console.log(``);
276
+ console.log(`Please run "npm install" in this directory.`);
277
+ },
278
+ examples: [
279
+ {
280
+ command: "init",
281
+ description: "initialize a Messagevisor project from the default yml starter",
282
+ },
283
+ {
284
+ command: "init --project=json",
285
+ description: "initialize a Messagevisor project from the json starter",
286
+ },
287
+ {
288
+ command: "init --project=environments",
289
+ description: "initialize a Messagevisor project from the sets-based environments starter",
290
+ },
291
+ ],
292
+ };
@@ -0,0 +1 @@
1
+ export { lintPlugin } from "../linter";
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+
3
+ import { attributePropertyZodSchema, refineSchemaSemantics } from "./schema";
4
+
5
+ export function getAttributeZodSchema() {
6
+ return attributePropertyZodSchema
7
+ .and(
8
+ z
9
+ .object({
10
+ key: z.string().optional(),
11
+ archived: z.boolean().optional(),
12
+ promotable: z.boolean().optional(),
13
+ description: z.string({
14
+ error: (issue) => (issue.input === undefined ? "Required" : undefined),
15
+ }),
16
+ })
17
+ .strict(),
18
+ )
19
+ .superRefine((data: any, ctx) => {
20
+ if (!data.type && !data.oneOf) {
21
+ ctx.addIssue({
22
+ code: z.ZodIssueCode.custom,
23
+ message: `Attribute must define either \`type\` or \`oneOf\`.`,
24
+ path: ["type"],
25
+ });
26
+ }
27
+
28
+ if (data.type && data.oneOf) {
29
+ ctx.addIssue({
30
+ code: z.ZodIssueCode.custom,
31
+ message: `Attribute cannot define both \`type\` and \`oneOf\`.`,
32
+ path: ["oneOf"],
33
+ });
34
+ }
35
+
36
+ refineSchemaSemantics(data, ctx);
37
+ });
38
+ }