@canonical/summon-package 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.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Tests for summon-package generator
3
+ */
4
+
5
+ import { dryRun } from "@canonical/summon";
6
+ import { describe, expect, it } from "vitest";
7
+ import { generator } from "../package/index.js";
8
+ import {
9
+ createTemplateContext,
10
+ getEntryPoints,
11
+ getLicense,
12
+ getRuleset,
13
+ type MonorepoInfo,
14
+ type PackageAnswers,
15
+ validatePackageName,
16
+ } from "../shared/index.js";
17
+
18
+ // =============================================================================
19
+ // Shared Utilities Tests
20
+ // =============================================================================
21
+
22
+ describe("validatePackageName", () => {
23
+ it("accepts valid package names", () => {
24
+ expect(validatePackageName("my-package")).toBe(true);
25
+ expect(validatePackageName("package")).toBe(true);
26
+ expect(validatePackageName("my-cool-package")).toBe(true);
27
+ expect(validatePackageName("pkg123")).toBe(true);
28
+ expect(validatePackageName("a")).toBe(true);
29
+ });
30
+
31
+ it("rejects invalid package names", () => {
32
+ expect(validatePackageName("")).not.toBe(true);
33
+ expect(validatePackageName("-package")).not.toBe(true);
34
+ expect(validatePackageName("package-")).not.toBe(true);
35
+ expect(validatePackageName("My-Package")).not.toBe(true);
36
+ expect(validatePackageName("my_package")).not.toBe(true);
37
+ });
38
+
39
+ it("strips @canonical/ prefix for validation", () => {
40
+ expect(validatePackageName("@canonical/my-package")).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("getLicense", () => {
45
+ it("returns GPL-3.0 for tool-ts", () => {
46
+ expect(getLicense("tool-ts")).toBe("GPL-3.0");
47
+ });
48
+
49
+ it("returns LGPL-3.0 for library", () => {
50
+ expect(getLicense("library")).toBe("LGPL-3.0");
51
+ });
52
+
53
+ it("returns LGPL-3.0 for css", () => {
54
+ expect(getLicense("css")).toBe("LGPL-3.0");
55
+ });
56
+ });
57
+
58
+ describe("getEntryPoints", () => {
59
+ it("returns src/ paths for tool-ts", () => {
60
+ const entry = getEntryPoints("tool-ts");
61
+ expect(entry.module).toBe("src/index.ts");
62
+ expect(entry.types).toBe("src/index.ts");
63
+ expect(entry.files).toContain("src");
64
+ expect(entry.needsBuild).toBe(false);
65
+ });
66
+
67
+ it("returns dist/ paths for library", () => {
68
+ const entry = getEntryPoints("library");
69
+ expect(entry.module).toBe("dist/esm/index.js");
70
+ expect(entry.types).toBe("dist/types/index.d.ts");
71
+ expect(entry.files).toContain("dist");
72
+ expect(entry.needsBuild).toBe(true);
73
+ });
74
+
75
+ it("returns src/index.css for css packages", () => {
76
+ const entry = getEntryPoints("css");
77
+ expect(entry.module).toBe("src/index.css");
78
+ expect(entry.types).toBeNull();
79
+ expect(entry.files).toContain("src");
80
+ expect(entry.needsBuild).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe("getRuleset", () => {
85
+ it("returns package-react when withReact is true", () => {
86
+ expect(getRuleset("tool-ts", true)).toBe("package-react");
87
+ expect(getRuleset("library", true)).toBe("package-react");
88
+ });
89
+
90
+ it("returns package type when withReact is false", () => {
91
+ expect(getRuleset("tool-ts", false)).toBe("tool-ts");
92
+ expect(getRuleset("library", false)).toBe("library");
93
+ });
94
+
95
+ it("returns base for css packages", () => {
96
+ expect(getRuleset("css", false)).toBe("base");
97
+ });
98
+ });
99
+
100
+ describe("createTemplateContext", () => {
101
+ const baseAnswers: PackageAnswers = {
102
+ name: "@canonical/test-pkg",
103
+ type: "tool-ts",
104
+ description: "Test package",
105
+ withReact: false,
106
+ withStorybook: false,
107
+ withCli: false,
108
+ runInstall: false,
109
+ };
110
+
111
+ it("creates context with monorepo version", () => {
112
+ const monorepoInfo: MonorepoInfo = { isMonorepo: true, version: "1.2.3" };
113
+ const ctx = createTemplateContext(baseAnswers, monorepoInfo);
114
+
115
+ expect(ctx.name).toBe("@canonical/test-pkg");
116
+ expect(ctx.shortName).toBe("test-pkg");
117
+ expect(ctx.version).toBe("1.2.3");
118
+ expect(ctx.license).toBe("GPL-3.0");
119
+ });
120
+
121
+ it("creates context with default version when not in monorepo", () => {
122
+ const monorepoInfo: MonorepoInfo = { isMonorepo: false };
123
+ const ctx = createTemplateContext(baseAnswers, monorepoInfo);
124
+
125
+ expect(ctx.version).toBe("0.1.0");
126
+ });
127
+
128
+ it("handles unscoped package names", () => {
129
+ const unscopedAnswers: PackageAnswers = {
130
+ ...baseAnswers,
131
+ name: "my-package",
132
+ };
133
+ const monorepoInfo: MonorepoInfo = { isMonorepo: false };
134
+ const ctx = createTemplateContext(unscopedAnswers, monorepoInfo);
135
+
136
+ expect(ctx.name).toBe("my-package");
137
+ expect(ctx.shortName).toBe("my-package");
138
+ });
139
+
140
+ it("sets correct entry points for tool-ts", () => {
141
+ const monorepoInfo: MonorepoInfo = { isMonorepo: false };
142
+ const ctx = createTemplateContext(baseAnswers, monorepoInfo);
143
+
144
+ expect(ctx.module).toBe("src/index.ts");
145
+ expect(ctx.types).toBe("src/index.ts");
146
+ expect(ctx.needsBuild).toBe(false);
147
+ });
148
+
149
+ it("sets correct entry points for library", () => {
150
+ const answers: PackageAnswers = { ...baseAnswers, type: "library" };
151
+ const monorepoInfo: MonorepoInfo = { isMonorepo: false };
152
+ const ctx = createTemplateContext(answers, monorepoInfo);
153
+
154
+ expect(ctx.module).toBe("dist/esm/index.js");
155
+ expect(ctx.types).toBe("dist/types/index.d.ts");
156
+ expect(ctx.license).toBe("LGPL-3.0");
157
+ expect(ctx.needsBuild).toBe(true);
158
+ });
159
+
160
+ it("sets correct entry points for css", () => {
161
+ const answers: PackageAnswers = { ...baseAnswers, type: "css" };
162
+ const monorepoInfo: MonorepoInfo = { isMonorepo: false };
163
+ const ctx = createTemplateContext(answers, monorepoInfo);
164
+
165
+ expect(ctx.module).toBe("src/index.css");
166
+ expect(ctx.types).toBeNull();
167
+ expect(ctx.license).toBe("LGPL-3.0");
168
+ expect(ctx.needsBuild).toBe(false);
169
+ });
170
+ });
171
+
172
+ // =============================================================================
173
+ // Generator Dry-Run Tests
174
+ // =============================================================================
175
+
176
+ describe("package generator", () => {
177
+ it("has correct meta information", () => {
178
+ expect(generator.meta.name).toBe("package");
179
+ expect(generator.meta.version).toBe("0.1.0");
180
+ expect(generator.meta.description).toBeDefined();
181
+ });
182
+
183
+ it("defines required prompts", () => {
184
+ const promptNames = generator.prompts.map((p) => p.name);
185
+
186
+ expect(promptNames).toContain("name");
187
+ expect(promptNames).toContain("type");
188
+ expect(promptNames).toContain("description");
189
+ expect(promptNames).toContain("withReact");
190
+ expect(promptNames).toContain("withCli");
191
+ expect(promptNames).toContain("runInstall");
192
+ });
193
+
194
+ it("generates expected files for tool-ts package", () => {
195
+ const answers: PackageAnswers = {
196
+ name: "@canonical/my-tool",
197
+ type: "tool-ts",
198
+ description: "My tool",
199
+ withReact: false,
200
+ withStorybook: false,
201
+ withCli: false,
202
+ runInstall: false,
203
+ };
204
+
205
+ const task = generator.generate(answers);
206
+ const result = dryRun(task);
207
+
208
+ const writePaths = result.effects
209
+ .filter((e) => e._tag === "WriteFile")
210
+ .map((e) => (e as { path: string }).path);
211
+
212
+ // Check core files are created
213
+ expect(writePaths.some((p) => p.endsWith("package.json"))).toBe(true);
214
+ expect(writePaths.some((p) => p.endsWith("tsconfig.json"))).toBe(true);
215
+ expect(writePaths.some((p) => p.endsWith("biome.json"))).toBe(true);
216
+ expect(writePaths.some((p) => p.endsWith("index.ts"))).toBe(true);
217
+ expect(writePaths.some((p) => p.endsWith("README.md"))).toBe(true);
218
+
219
+ // Check CLI is NOT created when withCli is false
220
+ expect(writePaths.some((p) => p.endsWith("cli.ts"))).toBe(false);
221
+ });
222
+
223
+ it("generates CLI file when withCli is true", () => {
224
+ const answers: PackageAnswers = {
225
+ name: "@canonical/my-cli",
226
+ type: "tool-ts",
227
+ description: "My CLI",
228
+ withReact: false,
229
+ withStorybook: false,
230
+ withCli: true,
231
+ runInstall: false,
232
+ };
233
+
234
+ const task = generator.generate(answers);
235
+ const result = dryRun(task);
236
+
237
+ const writePaths = result.effects
238
+ .filter((e) => e._tag === "WriteFile")
239
+ .map((e) => (e as { path: string }).path);
240
+
241
+ expect(writePaths.some((p) => p.endsWith("cli.ts"))).toBe(true);
242
+ });
243
+
244
+ it("generates CSS package with index.css", () => {
245
+ const answers: PackageAnswers = {
246
+ name: "@canonical/my-styles",
247
+ type: "css",
248
+ description: "My styles",
249
+ withReact: false,
250
+ withStorybook: false,
251
+ withCli: false,
252
+ runInstall: false,
253
+ };
254
+
255
+ const task = generator.generate(answers);
256
+ const result = dryRun(task);
257
+
258
+ const writePaths = result.effects
259
+ .filter((e) => e._tag === "WriteFile")
260
+ .map((e) => (e as { path: string }).path);
261
+
262
+ // Check CSS-specific files
263
+ expect(writePaths.some((p) => p.endsWith("index.css"))).toBe(true);
264
+ expect(writePaths.some((p) => p.endsWith("package.json"))).toBe(true);
265
+ expect(writePaths.some((p) => p.endsWith("biome.json"))).toBe(true);
266
+ expect(writePaths.some((p) => p.endsWith("README.md"))).toBe(true);
267
+
268
+ // Check no TypeScript files for CSS package
269
+ expect(writePaths.some((p) => p.endsWith("index.ts"))).toBe(false);
270
+ expect(writePaths.some((p) => p.endsWith("tsconfig.json"))).toBe(false);
271
+ });
272
+
273
+ it("creates directory structure using short name", () => {
274
+ const answers: PackageAnswers = {
275
+ name: "@canonical/my-pkg",
276
+ type: "tool-ts",
277
+ description: "",
278
+ withReact: false,
279
+ withStorybook: false,
280
+ withCli: false,
281
+ runInstall: false,
282
+ };
283
+
284
+ const task = generator.generate(answers);
285
+ const result = dryRun(task);
286
+
287
+ const mkdirPaths = result.effects
288
+ .filter((e) => e._tag === "MakeDir")
289
+ .map((e) => (e as { path: string }).path);
290
+
291
+ // Directory should be the short name (without scope)
292
+ expect(mkdirPaths.some((p) => p === "my-pkg")).toBe(true);
293
+ expect(mkdirPaths.some((p) => p.endsWith("src"))).toBe(true);
294
+ });
295
+ });
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @canonical/summon-package
3
+ *
4
+ * Package generator for Summon - scaffold new npm packages with proper configuration.
5
+ */
6
+
7
+ import type { AnyGenerator } from "@canonical/summon";
8
+ import { generator as packageGenerator } from "./package/index.js";
9
+
10
+ export const generators: Record<string, AnyGenerator> = {
11
+ package: packageGenerator as unknown as AnyGenerator,
12
+ };
13
+
14
+ export default generators;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Package Generator
3
+ *
4
+ * Generates a new npm package with proper configuration for the pragma monorepo.
5
+ */
6
+
7
+ import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import {
10
+ exec,
11
+ flatMap,
12
+ type GeneratorDefinition,
13
+ info,
14
+ mkdir,
15
+ type PromptDefinition,
16
+ sequence_,
17
+ template,
18
+ when,
19
+ } from "@canonical/summon";
20
+
21
+ import {
22
+ createTemplateContext,
23
+ detectMonorepo,
24
+ detectPackageManager,
25
+ getPackageShortName,
26
+ type PackageAnswers,
27
+ validatePackageName,
28
+ } from "../shared/index.js";
29
+
30
+ // =============================================================================
31
+ // Template Paths
32
+ // =============================================================================
33
+
34
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
35
+ const templatesDir = path.join(__dirname, "..", "templates");
36
+
37
+ const templates = {
38
+ packageJson: path.join(templatesDir, "package.json.ejs"),
39
+ tsconfig: path.join(templatesDir, "tsconfig.json.ejs"),
40
+ tsconfigReact: path.join(templatesDir, "tsconfig-react.json.ejs"),
41
+ biome: path.join(templatesDir, "biome.json.ejs"),
42
+ indexTs: path.join(templatesDir, "index.ts.ejs"),
43
+ indexCss: path.join(templatesDir, "index.css.ejs"),
44
+ cliTs: path.join(templatesDir, "cli.ts.ejs"),
45
+ readme: path.join(templatesDir, "README.md.ejs"),
46
+ storybookMain: path.join(templatesDir, "storybook-main.ts.ejs"),
47
+ storybookPreview: path.join(templatesDir, "storybook-preview.ts.ejs"),
48
+ };
49
+
50
+ // =============================================================================
51
+ // Prompts
52
+ // =============================================================================
53
+
54
+ const prompts: PromptDefinition[] = [
55
+ {
56
+ name: "name",
57
+ type: "text",
58
+ message: "Package name:",
59
+ default: "@canonical/my-package",
60
+ validate: validatePackageName,
61
+ group: "Package",
62
+ },
63
+ {
64
+ name: "type",
65
+ type: "select",
66
+ message: "Package type:",
67
+ choices: [
68
+ {
69
+ label: "tool-ts - TypeScript tool (runs from src/, no build)",
70
+ value: "tool-ts",
71
+ },
72
+ {
73
+ label: "library - Publishable library (dist/ build output)",
74
+ value: "library",
75
+ },
76
+ {
77
+ label: "css - CSS package (src/index.css, no build)",
78
+ value: "css",
79
+ },
80
+ ],
81
+ default: "tool-ts",
82
+ group: "Package",
83
+ },
84
+ {
85
+ name: "description",
86
+ type: "text",
87
+ message: "Package description:",
88
+ default: "",
89
+ group: "Package",
90
+ },
91
+ {
92
+ name: "withReact",
93
+ type: "confirm",
94
+ message: "Include React dependencies?",
95
+ default: false,
96
+ group: "Options",
97
+ },
98
+ {
99
+ name: "withStorybook",
100
+ type: "confirm",
101
+ message: "Include Storybook setup?",
102
+ default: false,
103
+ group: "Options",
104
+ },
105
+ {
106
+ name: "withCli",
107
+ type: "confirm",
108
+ message: "Include CLI binary entry point?",
109
+ default: false,
110
+ group: "Options",
111
+ },
112
+ {
113
+ name: "runInstall",
114
+ type: "confirm",
115
+ message: "Run package manager install after creation?",
116
+ default: true,
117
+ group: "Post-setup",
118
+ },
119
+ ];
120
+
121
+ // =============================================================================
122
+ // Generator Definition
123
+ // =============================================================================
124
+
125
+ export const generator: GeneratorDefinition<PackageAnswers> = {
126
+ meta: {
127
+ name: "package",
128
+ description:
129
+ "Generate a new npm package with proper configuration for the pragma monorepo",
130
+ version: "0.1.0",
131
+ help: `Generate a new npm package with proper configuration.
132
+
133
+ PACKAGE TYPES:
134
+ tool-ts TypeScript tool that runs directly from src/ (no build step)
135
+ License: GPL-3.0, Entry: src/index.ts
136
+ Examples: summon, webarchitect
137
+
138
+ library Publishable library with dist/ build output
139
+ License: LGPL-3.0, Entry: dist/esm/index.js
140
+ Examples: utils, ds-types
141
+
142
+ css CSS-only package (no TypeScript, no build)
143
+ License: LGPL-3.0, Entry: src/index.css
144
+ Examples: styles/primitives, styles/modes
145
+
146
+ OPTIONS:
147
+ --with-react Add React dependencies and TypeScript React config
148
+ --with-storybook Add Storybook configuration
149
+ --with-cli Add CLI binary entry point (src/cli.ts)
150
+
151
+ The generator auto-detects:
152
+ - Monorepo: Uses lerna.json version when in pragma monorepo
153
+ - Package manager: Detects bun/yarn/pnpm (defaults to bun)`,
154
+ examples: [
155
+ "summon package --name=@canonical/my-tool --type=tool-ts",
156
+ "summon package --name=@canonical/my-lib --type=library --with-react",
157
+ "summon package --name=@canonical/my-cli --type=tool-ts --with-cli",
158
+ "summon package --name=my-styles --type=css",
159
+ "summon package --name=@canonical/my-pkg --type=library --no-run-install",
160
+ ],
161
+ },
162
+
163
+ prompts,
164
+
165
+ generate: (answers) => {
166
+ const packageDir = getPackageShortName(answers.name);
167
+ const cwd = process.cwd();
168
+ const isCss = answers.type === "css";
169
+ const needsTs = !isCss;
170
+
171
+ return flatMap(detectMonorepo(cwd), (monorepoInfo) => {
172
+ const ctx = createTemplateContext(answers, monorepoInfo);
173
+
174
+ return sequence_([
175
+ info(`Creating package: ${answers.name}`),
176
+ info(`Type: ${answers.type}`),
177
+ when(
178
+ monorepoInfo.isMonorepo,
179
+ info(`Monorepo detected, using version: ${monorepoInfo.version}`),
180
+ ),
181
+
182
+ // Create directory structure
183
+ mkdir(packageDir),
184
+ mkdir(path.join(packageDir, "src")),
185
+
186
+ // Create package.json
187
+ template({
188
+ source: templates.packageJson,
189
+ dest: path.join(packageDir, "package.json"),
190
+ vars: ctx,
191
+ }),
192
+
193
+ // Create tsconfig.json (only for non-CSS packages)
194
+ when(
195
+ needsTs && answers.withReact,
196
+ template({
197
+ source: templates.tsconfigReact,
198
+ dest: path.join(packageDir, "tsconfig.json"),
199
+ vars: ctx,
200
+ }),
201
+ ),
202
+ when(
203
+ needsTs && !answers.withReact,
204
+ template({
205
+ source: templates.tsconfig,
206
+ dest: path.join(packageDir, "tsconfig.json"),
207
+ vars: ctx,
208
+ }),
209
+ ),
210
+
211
+ // Create biome.json
212
+ template({
213
+ source: templates.biome,
214
+ dest: path.join(packageDir, "biome.json"),
215
+ vars: ctx,
216
+ }),
217
+
218
+ // Create src/index.ts (for TS packages)
219
+ when(
220
+ needsTs,
221
+ template({
222
+ source: templates.indexTs,
223
+ dest: path.join(packageDir, "src", "index.ts"),
224
+ vars: ctx,
225
+ }),
226
+ ),
227
+
228
+ // Create src/index.css (for CSS packages)
229
+ when(
230
+ isCss,
231
+ template({
232
+ source: templates.indexCss,
233
+ dest: path.join(packageDir, "src", "index.css"),
234
+ vars: ctx,
235
+ }),
236
+ ),
237
+
238
+ // Create src/cli.ts (conditional, only for TS packages)
239
+ when(
240
+ needsTs && answers.withCli,
241
+ template({
242
+ source: templates.cliTs,
243
+ dest: path.join(packageDir, "src", "cli.ts"),
244
+ vars: ctx,
245
+ }),
246
+ ),
247
+
248
+ // Create README.md
249
+ template({
250
+ source: templates.readme,
251
+ dest: path.join(packageDir, "README.md"),
252
+ vars: ctx,
253
+ }),
254
+
255
+ // Create .storybook folder (conditional)
256
+ when(answers.withStorybook, mkdir(path.join(packageDir, ".storybook"))),
257
+ when(
258
+ answers.withStorybook,
259
+ mkdir(path.join(packageDir, "src", "assets")),
260
+ ),
261
+ when(answers.withStorybook, mkdir(path.join(packageDir, "public"))),
262
+ when(
263
+ answers.withStorybook,
264
+ template({
265
+ source: templates.storybookMain,
266
+ dest: path.join(packageDir, ".storybook", "main.ts"),
267
+ vars: ctx,
268
+ }),
269
+ ),
270
+ when(
271
+ answers.withStorybook,
272
+ template({
273
+ source: templates.storybookPreview,
274
+ dest: path.join(packageDir, ".storybook", "preview.ts"),
275
+ vars: ctx,
276
+ }),
277
+ ),
278
+
279
+ info(`Package created at ./${packageDir}`),
280
+
281
+ // Run install (conditional)
282
+ when(
283
+ answers.runInstall,
284
+ flatMap(detectPackageManager(cwd), (pm) => {
285
+ return sequence_([
286
+ info(`Running ${pm} install...`),
287
+ flatMap(exec(pm, ["install"], packageDir), () =>
288
+ info(`Dependencies installed successfully`),
289
+ ),
290
+ ]);
291
+ }),
292
+ ),
293
+
294
+ when(!answers.runInstall, info("Skipping install step")),
295
+
296
+ info(""),
297
+ info("Next steps:"),
298
+ info(` cd ${packageDir}`),
299
+ info(" bun run check"),
300
+ info(""),
301
+ ]);
302
+ });
303
+ },
304
+ };
305
+
306
+ export default generator;