@beignet/cli 0.0.1 → 0.0.4

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 (98) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +497 -90
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +39 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +26 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +12 -0
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +19 -24
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +37 -0
  28. package/dist/db.d.ts.map +1 -0
  29. package/dist/db.js +146 -0
  30. package/dist/db.js.map +1 -0
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +710 -400
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +45 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +2191 -100
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +22 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +370 -38
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +109 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +2225 -333
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates.d.ts +12 -8
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +2144 -385
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +47 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +32 -28
  82. package/src/db.ts +222 -0
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1119 -535
  85. package/src/inspect.ts +3372 -134
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +533 -45
  88. package/src/make.ts +3010 -397
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +2282 -462
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Interactive selection flow for `beignet create`.
3
+ *
4
+ * The decision helper is pure so the TTY gating stays unit-testable, and
5
+ * `@clack/prompts` is loaded lazily inside the prompt flow so non-interactive
6
+ * runs and CLI startup never pay for it.
7
+ */
8
+
9
+ import {
10
+ type FeatureName,
11
+ featureChoices,
12
+ type IntegrationName,
13
+ integrationChoices,
14
+ type PackageManager,
15
+ type PresetName,
16
+ packageManagerChoices,
17
+ presetIntegrationDefaults,
18
+ } from "./choices.js";
19
+
20
+ /**
21
+ * Selections shared by create flags and interactive prompt answers.
22
+ */
23
+ export type CreateSelections = {
24
+ directory?: string;
25
+ preset?: PresetName;
26
+ features?: readonly FeatureName[];
27
+ integrations?: readonly IntegrationName[];
28
+ packageManager?: PackageManager;
29
+ };
30
+
31
+ type CreatePromptFlags = CreateSelections & {
32
+ yes?: boolean;
33
+ };
34
+
35
+ type CreatePromptTty = {
36
+ stdin: boolean;
37
+ stdout: boolean;
38
+ };
39
+
40
+ /**
41
+ * Decide whether `beignet create` should run the interactive prompt flow.
42
+ *
43
+ * Prompts run only on a full TTY, only when `--yes` is absent, and only when
44
+ * no selection flag was passed. Any explicit preset, feature, integration, or
45
+ * package manager selection means the invocation is scripted, so the
46
+ * non-interactive path stays byte-identical for agents and CI.
47
+ */
48
+ export function shouldPromptInteractively(
49
+ flags: CreatePromptFlags,
50
+ tty: CreatePromptTty,
51
+ ): boolean {
52
+ if (!tty.stdin || !tty.stdout) return false;
53
+ if (flags.yes) return false;
54
+
55
+ const selectionFlagPassed =
56
+ flags.preset !== undefined ||
57
+ (flags.features?.length ?? 0) > 0 ||
58
+ (flags.integrations?.length ?? 0) > 0 ||
59
+ flags.packageManager !== undefined;
60
+
61
+ return !selectionFlagPassed;
62
+ }
63
+
64
+ const featureHints: Record<FeatureName, string> = {
65
+ client: "Typed Beignet HTTP client",
66
+ "react-query": "TanStack Query helpers for contracts",
67
+ forms: "React Hook Form wired to contract bodies",
68
+ openapi: "OpenAPI document route generated from contracts",
69
+ };
70
+
71
+ const integrationHints: Record<IntegrationName, string> = {
72
+ "better-auth": "Better Auth routes and auth provider wiring",
73
+ "drizzle-turso": "Drizzle/libSQL persistence with Turso-ready config",
74
+ inngest: "Inngest-backed background jobs",
75
+ pino: "Pino logger provider",
76
+ resend: "Resend-backed mail provider",
77
+ "upstash-rate-limit": "Upstash-backed rate limiting",
78
+ };
79
+
80
+ /**
81
+ * Run the interactive `beignet create` prompt flow.
82
+ *
83
+ * Returns the completed selections, or undefined when the user cancels.
84
+ */
85
+ export async function promptCreateSelections(
86
+ initial: CreateSelections,
87
+ ): Promise<CreateSelections | undefined> {
88
+ const prompts = await import("@clack/prompts");
89
+
90
+ prompts.intro("beignet create");
91
+
92
+ let directory = initial.directory;
93
+ if (directory === undefined) {
94
+ const answer = await prompts.text({
95
+ message: "Where should the app be created?",
96
+ placeholder: "my-app",
97
+ validate: (value) =>
98
+ value === undefined || value.trim() === ""
99
+ ? "Enter a project directory."
100
+ : undefined,
101
+ });
102
+ if (prompts.isCancel(answer)) return undefined;
103
+ directory = answer;
104
+ }
105
+
106
+ const preset = await prompts.select<PresetName>({
107
+ message: "Which starter preset?",
108
+ options: [
109
+ {
110
+ value: "standard",
111
+ label: "standard",
112
+ hint: "Full app layout with auth, database, jobs, and devtools",
113
+ },
114
+ {
115
+ value: "minimal",
116
+ label: "minimal",
117
+ hint: "Smallest contract/server/use-case loop",
118
+ },
119
+ ],
120
+ initialValue: "standard",
121
+ });
122
+ if (prompts.isCancel(preset)) return undefined;
123
+
124
+ // The standard preset already includes every starter feature, so the
125
+ // feature multiselect only appears when the preset leaves features open.
126
+ let features: readonly FeatureName[] | undefined;
127
+ if (preset === "minimal") {
128
+ const answer = await prompts.multiselect<FeatureName>({
129
+ message: "Add starter features? (press enter to skip)",
130
+ options: featureChoices.map((feature) => ({
131
+ value: feature,
132
+ label: feature,
133
+ hint: featureHints[feature],
134
+ })),
135
+ required: false,
136
+ });
137
+ if (prompts.isCancel(answer)) return undefined;
138
+ features = answer;
139
+ }
140
+
141
+ const presetIntegrations = new Set(presetIntegrationDefaults[preset]);
142
+ const integrationOptions = integrationChoices.filter(
143
+ (integration) => !presetIntegrations.has(integration),
144
+ );
145
+ let integrations: readonly IntegrationName[] | undefined;
146
+ if (integrationOptions.length > 0) {
147
+ const answer = await prompts.multiselect<IntegrationName>({
148
+ message:
149
+ presetIntegrations.size > 0
150
+ ? "Add provider integrations beyond the preset defaults? (press enter to skip)"
151
+ : "Add provider integrations? (press enter to skip)",
152
+ options: integrationOptions.map((integration) => ({
153
+ value: integration,
154
+ label: integration,
155
+ hint: integrationHints[integration],
156
+ })),
157
+ required: false,
158
+ });
159
+ if (prompts.isCancel(answer)) return undefined;
160
+ integrations = answer;
161
+ }
162
+
163
+ const packageManager = await prompts.select<PackageManager>({
164
+ message: "Which package manager?",
165
+ options: packageManagerChoices.map((manager) => ({
166
+ value: manager,
167
+ label: manager,
168
+ })),
169
+ initialValue: "bun",
170
+ });
171
+ if (prompts.isCancel(packageManager)) return undefined;
172
+
173
+ prompts.outro(`Creating ${directory}`);
174
+
175
+ return {
176
+ directory,
177
+ preset,
178
+ features,
179
+ integrations,
180
+ packageManager,
181
+ };
182
+ }
package/src/create.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { mkdir, readdir, writeFile } from "node:fs/promises";
3
2
  import path from "node:path";
3
+ import { presetFeatureDefaults, presetIntegrationDefaults } from "./choices.js";
4
4
  import {
5
5
  type FeatureName,
6
6
  featureChoices,
@@ -11,7 +11,11 @@ import {
11
11
  type PresetName,
12
12
  type TemplateName,
13
13
  } from "./templates.js";
14
+ import { getCliVersion } from "./version.js";
14
15
 
16
+ /**
17
+ * Options for creating a new Beignet app.
18
+ */
15
19
  export type CreateOptions = {
16
20
  name: string;
17
21
  cwd?: string;
@@ -21,16 +25,25 @@ export type CreateOptions = {
21
25
  packageManager?: PackageManager;
22
26
  integrations?: IntegrationName[];
23
27
  force?: boolean;
28
+ dryRun?: boolean;
24
29
  beignetVersion?: string;
25
30
  };
26
31
 
32
+ /**
33
+ * Result returned after a new app is scaffolded or previewed.
34
+ */
27
35
  export type CreateResult = {
36
+ schemaVersion: 1;
28
37
  name: string;
29
38
  targetDir: string;
39
+ dryRun: boolean;
30
40
  files: string[];
31
41
  packageManager: PackageManager;
32
42
  };
33
43
 
44
+ /**
45
+ * Create a Beignet app from a starter template.
46
+ */
34
47
  export async function createProject(
35
48
  options: CreateOptions,
36
49
  ): Promise<CreateResult> {
@@ -49,8 +62,9 @@ export async function createProject(
49
62
  );
50
63
  }
51
64
 
65
+ const dryRun = Boolean(options.dryRun);
66
+
52
67
  await assertCanWrite(targetDir, Boolean(options.force));
53
- await mkdir(targetDir, { recursive: true });
54
68
 
55
69
  const files = getTemplateFiles({
56
70
  name,
@@ -61,15 +75,21 @@ export async function createProject(
61
75
  integrations,
62
76
  });
63
77
 
64
- for (const file of files) {
65
- const destination = path.join(targetDir, file.path);
66
- await mkdir(path.dirname(destination), { recursive: true });
67
- await writeFile(destination, file.content);
78
+ if (!dryRun) {
79
+ await mkdir(targetDir, { recursive: true });
80
+
81
+ for (const file of files) {
82
+ const destination = path.join(targetDir, file.path);
83
+ await mkdir(path.dirname(destination), { recursive: true });
84
+ await writeFile(destination, file.content);
85
+ }
68
86
  }
69
87
 
70
88
  return {
89
+ schemaVersion: 1,
71
90
  name,
72
91
  targetDir,
92
+ dryRun,
73
93
  files: files.map((file) => file.path),
74
94
  packageManager,
75
95
  };
@@ -79,13 +99,7 @@ function resolveFeatures(
79
99
  preset: PresetName,
80
100
  explicitFeatures: FeatureName[],
81
101
  ): FeatureName[] {
82
- const features = new Set<FeatureName>();
83
-
84
- if (preset === "standard") {
85
- for (const feature of featureChoices) {
86
- features.add(feature);
87
- }
88
- }
102
+ const features = new Set<FeatureName>(presetFeatureDefaults[preset]);
89
103
 
90
104
  for (const feature of explicitFeatures) {
91
105
  features.add(feature);
@@ -105,11 +119,9 @@ function resolveIntegrations(
105
119
  preset: PresetName,
106
120
  explicitIntegrations: IntegrationName[],
107
121
  ): IntegrationName[] {
108
- const integrations = new Set<IntegrationName>();
109
-
110
- if (preset === "standard") {
111
- integrations.add("pino");
112
- }
122
+ const integrations = new Set<IntegrationName>(
123
+ presetIntegrationDefaults[preset],
124
+ );
113
125
 
114
126
  for (const integration of explicitIntegrations) {
115
127
  integrations.add(integration);
@@ -121,16 +133,8 @@ function resolveIntegrations(
121
133
  }
122
134
 
123
135
  function getDefaultBeignetVersion(): string {
124
- try {
125
- const packageJsonUrl = new URL("../package.json", import.meta.url);
126
- const packageJson = JSON.parse(readFileSync(packageJsonUrl, "utf8")) as {
127
- version?: string;
128
- };
129
-
130
- return packageJson.version ? `^${packageJson.version}` : "latest";
131
- } catch {
132
- return "latest";
133
- }
136
+ const version = getCliVersion();
137
+ return version === "0.0.0" ? "latest" : `^${version}`;
134
138
  }
135
139
 
136
140
  function normalizeProjectName(name: string): string {
package/src/db.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * Database lifecycle operations supported by the Beignet CLI.
7
+ */
8
+ export type DatabaseCommand = "generate" | "migrate" | "seed" | "reset";
9
+
10
+ /**
11
+ * Options for running a database lifecycle command.
12
+ */
13
+ export type RunDatabaseCommandOptions = {
14
+ command: DatabaseCommand;
15
+ cwd?: string;
16
+ captureOutput?: boolean;
17
+ dryRun?: boolean;
18
+ };
19
+
20
+ /**
21
+ * Result returned by a database lifecycle command.
22
+ */
23
+ export type RunDatabaseCommandResult = {
24
+ schemaVersion: 1;
25
+ command: DatabaseCommand;
26
+ cwd: string;
27
+ script: string;
28
+ runner: string;
29
+ args: string[];
30
+ stdout?: string;
31
+ stderr?: string;
32
+ dryRun: boolean;
33
+ exitCode: number;
34
+ };
35
+
36
+ type PackageJson = {
37
+ scripts?: Record<string, string>;
38
+ };
39
+
40
+ const databaseScripts: Record<DatabaseCommand, string> = {
41
+ generate: "db:generate",
42
+ migrate: "db:migrate",
43
+ seed: "db:seed",
44
+ reset: "db:reset",
45
+ };
46
+
47
+ /**
48
+ * Run an app-owned database lifecycle script.
49
+ *
50
+ * Beignet owns the command surface; the app owns the actual Drizzle script.
51
+ * This keeps `beignet db ...` stable while leaving migrations, schema paths,
52
+ * database URLs, and destructive reset behavior explicit in app code.
53
+ */
54
+ export async function runDatabaseCommand(
55
+ options: RunDatabaseCommandOptions,
56
+ ): Promise<RunDatabaseCommandResult> {
57
+ const cwd = path.resolve(options.cwd ?? process.cwd());
58
+ const script = databaseScripts[options.command];
59
+ const packageJson = await readPackageJson(cwd);
60
+ const scriptCommand = packageJson.scripts?.[script];
61
+
62
+ if (!scriptCommand) {
63
+ throw new Error(missingDatabaseScriptMessage(options.command, script));
64
+ }
65
+
66
+ await assertDatabaseCommandPreflight(cwd, options.command, scriptCommand);
67
+
68
+ const runner = await detectPackageManager(cwd);
69
+ const args = ["run", script];
70
+
71
+ if (options.dryRun) {
72
+ return {
73
+ schemaVersion: 1,
74
+ command: options.command,
75
+ cwd,
76
+ script,
77
+ runner,
78
+ args,
79
+ dryRun: true,
80
+ exitCode: 0,
81
+ };
82
+ }
83
+
84
+ const commandResult = await spawnCommand(runner, args, cwd, {
85
+ captureOutput: Boolean(options.captureOutput),
86
+ });
87
+
88
+ return {
89
+ schemaVersion: 1,
90
+ command: options.command,
91
+ cwd,
92
+ script,
93
+ runner,
94
+ args,
95
+ dryRun: false,
96
+ exitCode: commandResult.exitCode,
97
+ ...(options.captureOutput
98
+ ? { stdout: commandResult.stdout, stderr: commandResult.stderr }
99
+ : {}),
100
+ };
101
+ }
102
+
103
+ async function assertDatabaseCommandPreflight(
104
+ cwd: string,
105
+ command: DatabaseCommand,
106
+ scriptCommand: string,
107
+ ): Promise<void> {
108
+ if (
109
+ (command === "generate" || command === "migrate") &&
110
+ /\bdrizzle-kit\b/.test(scriptCommand) &&
111
+ !hasExplicitDrizzleConfigFlag(scriptCommand) &&
112
+ !(await exists(path.join(cwd, "drizzle.config.ts"))) &&
113
+ !(await exists(path.join(cwd, "drizzle.config.mts"))) &&
114
+ !(await exists(path.join(cwd, "drizzle.config.js"))) &&
115
+ !(await exists(path.join(cwd, "drizzle.config.mjs")))
116
+ ) {
117
+ throw new Error(
118
+ `Missing Drizzle config. beignet db ${command} runs "${scriptCommand}", but no drizzle.config.ts, drizzle.config.mts, drizzle.config.js, or drizzle.config.mjs exists in the app root.`,
119
+ );
120
+ }
121
+
122
+ const standardEntrypoints: Partial<Record<DatabaseCommand, string>> = {
123
+ seed: "infra/db/seed.ts",
124
+ reset: "infra/db/reset.ts",
125
+ };
126
+ const standardEntrypoint = standardEntrypoints[command];
127
+ if (
128
+ standardEntrypoint &&
129
+ scriptCommand.includes(standardEntrypoint) &&
130
+ !(await exists(path.join(cwd, standardEntrypoint)))
131
+ ) {
132
+ throw new Error(
133
+ `Missing database ${command} entrypoint ${standardEntrypoint}. beignet db ${command} runs "${scriptCommand}", so restore ${standardEntrypoint} or update package.json script "${databaseScripts[command]}".`,
134
+ );
135
+ }
136
+ }
137
+
138
+ function hasExplicitDrizzleConfigFlag(scriptCommand: string): boolean {
139
+ return /(?:^|\s)--config(?:=|\s)/.test(scriptCommand);
140
+ }
141
+
142
+ function missingDatabaseScriptMessage(
143
+ command: DatabaseCommand,
144
+ script: string,
145
+ ): string {
146
+ if (command === "generate" || command === "migrate") {
147
+ return `Missing package.json script "${script}". Standard Drizzle apps use "${script}": "drizzle-kit ${command}" with drizzle.config.ts at the app root.`;
148
+ }
149
+
150
+ const entrypoint =
151
+ command === "seed" ? "infra/db/seed.ts" : "infra/db/reset.ts";
152
+ return `Missing package.json script "${script}". Standard Drizzle apps use "${script}": "bun ${entrypoint}" so beignet db ${command} can run the app-owned ${command} entrypoint.`;
153
+ }
154
+
155
+ async function readPackageJson(cwd: string): Promise<PackageJson> {
156
+ try {
157
+ return JSON.parse(
158
+ await readFile(path.join(cwd, "package.json"), "utf8"),
159
+ ) as PackageJson;
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
162
+ throw new Error(
163
+ "Could not find package.json. Run beignet db from the app root.",
164
+ );
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ async function detectPackageManager(cwd: string): Promise<string> {
171
+ if (await exists(path.join(cwd, "bun.lock"))) return "bun";
172
+ if (await exists(path.join(cwd, "bun.lockb"))) return "bun";
173
+ if (await exists(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
174
+ if (await exists(path.join(cwd, "yarn.lock"))) return "yarn";
175
+ if (await exists(path.join(cwd, "package-lock.json"))) return "npm";
176
+ return "bun";
177
+ }
178
+
179
+ async function exists(filePath: string): Promise<boolean> {
180
+ try {
181
+ await access(filePath);
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function spawnCommand(
189
+ command: string,
190
+ args: readonly string[],
191
+ cwd: string,
192
+ options: { captureOutput: boolean },
193
+ ): Promise<{ exitCode: number; stdout?: string; stderr?: string }> {
194
+ return new Promise((resolve, reject) => {
195
+ const child = spawn(command, args, {
196
+ cwd,
197
+ env: process.env,
198
+ stdio: options.captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
199
+ });
200
+
201
+ const stdout: Buffer[] = [];
202
+ const stderr: Buffer[] = [];
203
+
204
+ if (options.captureOutput) {
205
+ child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
206
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
207
+ }
208
+
209
+ child.on("error", reject);
210
+ child.on("close", (code) =>
211
+ resolve({
212
+ exitCode: code ?? 1,
213
+ ...(options.captureOutput
214
+ ? {
215
+ stdout: Buffer.concat(stdout).toString("utf8"),
216
+ stderr: Buffer.concat(stderr).toString("utf8"),
217
+ }
218
+ : {}),
219
+ }),
220
+ );
221
+ });
222
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Severity levels understood by GitHub Actions workflow commands.
3
+ */
4
+ export type GithubAnnotationSeverity = "error" | "warning" | "notice";
5
+
6
+ /**
7
+ * Format one GitHub Actions annotation workflow command.
8
+ *
9
+ * Absent properties are omitted and the message is percent-encoded so
10
+ * multiline messages survive the single-line command format.
11
+ */
12
+ export function formatGithubAnnotation(annotation: {
13
+ severity: GithubAnnotationSeverity;
14
+ message: string;
15
+ file?: string;
16
+ line?: number;
17
+ col?: number;
18
+ }): string {
19
+ const properties = [
20
+ annotation.file === undefined ? undefined : `file=${annotation.file}`,
21
+ annotation.line === undefined ? undefined : `line=${annotation.line}`,
22
+ annotation.col === undefined ? undefined : `col=${annotation.col}`,
23
+ ].filter((property): property is string => property !== undefined);
24
+ const propertySection =
25
+ properties.length > 0 ? ` ${properties.join(",")}` : "";
26
+
27
+ return `::${annotation.severity}${propertySection}::${encodeGithubAnnotationMessage(
28
+ annotation.message,
29
+ )}`;
30
+ }
31
+
32
+ function encodeGithubAnnotationMessage(message: string): string {
33
+ return message
34
+ .replaceAll("%", "%25")
35
+ .replaceAll("\r", "%0D")
36
+ .replaceAll("\n", "%0A");
37
+ }