git-vibe-setup 3.0.3 → 3.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.
package/README.md CHANGED
@@ -6,16 +6,34 @@ Local initializer for GitVibe consumer repositories.
6
6
  npx git-vibe-setup setup
7
7
  ```
8
8
 
9
- The `setup` command writes `.github` and `.git-vibe` starter files, pins reusable
10
- workflow refs to the latest stable `markhuangai/git-vibe` release, and fails
11
- before writing if release lookup or target-file validation fails.
9
+ The `setup` command fetches `examples/consumer` from the latest stable
10
+ `markhuangai/git-vibe` release, writes `.github` and `.git-vibe` starter files,
11
+ pins reusable workflow refs to that release, and fails before writing if release
12
+ lookup, starter fetch, or target-file validation fails.
13
+
14
+ When `GITHUB_TOKEN` or `GH_TOKEN` is set, `git-vibe-setup` uses it only to
15
+ authenticate GitHub release and starter-file reads. This avoids anonymous API
16
+ throttling in CI and shared-network environments.
12
17
 
13
18
  ```bash
14
19
  npx git-vibe-setup update
15
20
  ```
16
21
 
17
- The `update` command rewrites only `.github/workflows/*.yml` GitVibe wrapper
18
- files from the latest package templates and pins them to the latest stable
19
- release. It does not update `.github/git-vibe.yml`, `.git-vibe`, secrets, or
20
- variables, and it refuses to overwrite workflow files that do not look like
21
- GitVibe wrappers.
22
+ The `update` command fetches `examples/consumer` from the latest stable
23
+ `markhuangai/git-vibe` release, rewrites only `.github/workflows/*.yml` GitVibe
24
+ wrapper files, and pins them to that release. It does not update
25
+ `.github/git-vibe.yml`, `.git-vibe`, secrets, or variables, and it refuses to
26
+ overwrite workflow files that do not look like GitVibe wrappers.
27
+
28
+ To test a specific release or prerelease from a consumer repository, pass the
29
+ release tag explicitly:
30
+
31
+ ```bash
32
+ npx git-vibe-setup update --release v3.0.4-rc.1
33
+ ```
34
+
35
+ To let automatic latest-release lookup choose prereleases, opt in explicitly:
36
+
37
+ ```bash
38
+ npx git-vibe-setup update --include-prereleases
39
+ ```
package/dist/cli.d.ts CHANGED
@@ -4,8 +4,10 @@ interface SetupCliRuntime {
4
4
  cwd?: string;
5
5
  error?: (message: string) => void;
6
6
  fetchImpl?: typeof fetch;
7
+ githubToken?: string;
8
+ includePrereleases?: boolean;
7
9
  log?: (message: string) => void;
8
- repositoryRoot?: string;
10
+ releaseTag?: string;
9
11
  }
10
12
  export declare function runSetup(runtime?: SetupCliRuntime): Promise<void>;
11
13
  export declare function runUpdate(runtime?: SetupCliRuntime): Promise<void>;
package/dist/cli.js CHANGED
@@ -3,12 +3,14 @@ import { realpathSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { fetchConsumerStarterFiles } from "./consumer-starter.js";
7
+ import { githubTokenFromEnvironment } from "./github-api.js";
6
8
  import { blockingInstallPaths, buildInstallFiles, buildWorkflowUpdateFiles, existingFilesError, installFiles, unmanagedWorkflowUpdateError, unmanagedWorkflowUpdatePaths, updateFiles, } from "./install.js";
7
9
  import { renderManualSetupInstructions } from "./instructions.js";
8
- import { latestStableReleaseTag } from "./releases.js";
10
+ import { latestReleaseTag } from "./releases.js";
9
11
  const usage = `Usage:
10
- git-vibe-setup setup
11
- git-vibe-setup update
12
+ git-vibe-setup setup [--release <tag>] [--include-prereleases]
13
+ git-vibe-setup update [--release <tag>] [--include-prereleases]
12
14
  git-vibe-setup
13
15
 
14
16
  Commands:
@@ -16,12 +18,16 @@ Commands:
16
18
  update Update GitVibe workflow wrapper files in the current repository.
17
19
 
18
20
  Options:
19
- -h, --help Show this help message.`;
21
+ --release <tag> Use a specific GitVibe release tag, including prereleases.
22
+ --include-prereleases Allow latest-release lookup to select prereleases.
23
+ -h, --help Show this help message.`;
20
24
  export async function runSetup(runtime = {}) {
21
25
  const cwd = runtime.cwd || process.cwd();
22
- const repositoryRoot = runtime.repositoryRoot || packageRoot();
23
- const releaseTag = await latestStableReleaseTag(runtime.fetchImpl || fetch);
24
- const files = buildInstallFiles({ cwd, releaseTag, repositoryRoot });
26
+ const fetchImpl = runtime.fetchImpl || fetch;
27
+ const githubToken = runtime.githubToken || githubTokenFromEnvironment();
28
+ const releaseTag = await resolveReleaseTag(runtime, fetchImpl, githubToken);
29
+ const sourceFiles = await fetchConsumerStarterFiles({ fetchImpl, githubToken, releaseTag });
30
+ const files = buildInstallFiles({ cwd, releaseTag, sourceFiles });
25
31
  const blockingPaths = blockingInstallPaths(files);
26
32
  if (blockingPaths.length > 0)
27
33
  throw existingFilesError(blockingPaths, cwd);
@@ -30,9 +36,11 @@ export async function runSetup(runtime = {}) {
30
36
  }
31
37
  export async function runUpdate(runtime = {}) {
32
38
  const cwd = runtime.cwd || process.cwd();
33
- const repositoryRoot = runtime.repositoryRoot || packageRoot();
34
- const releaseTag = await latestStableReleaseTag(runtime.fetchImpl || fetch);
35
- const files = buildWorkflowUpdateFiles({ cwd, releaseTag, repositoryRoot });
39
+ const fetchImpl = runtime.fetchImpl || fetch;
40
+ const githubToken = runtime.githubToken || githubTokenFromEnvironment();
41
+ const releaseTag = await resolveReleaseTag(runtime, fetchImpl, githubToken);
42
+ const sourceFiles = await fetchConsumerStarterFiles({ fetchImpl, githubToken, releaseTag });
43
+ const files = buildWorkflowUpdateFiles({ cwd, releaseTag, sourceFiles });
36
44
  const unmanagedPaths = unmanagedWorkflowUpdatePaths(files);
37
45
  if (unmanagedPaths.length > 0)
38
46
  throw unmanagedWorkflowUpdateError(unmanagedPaths, cwd);
@@ -41,20 +49,33 @@ export async function runUpdate(runtime = {}) {
41
49
  }
42
50
  export async function setupCli(runtime = {}) {
43
51
  const argv = runtime.argv || process.argv.slice(2);
44
- const command = argv[0] || "setup";
45
- if (command === "--help" || command === "-h" || command === "help") {
52
+ let parsed;
53
+ try {
54
+ parsed = parseCliOptions(argv);
55
+ }
56
+ catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ (runtime.error || console.error)(`${message}\n\n${usage}`);
59
+ return 1;
60
+ }
61
+ if (parsed.kind === "help") {
46
62
  (runtime.log || console.log)(usage);
47
63
  return 0;
48
64
  }
49
- if (command !== "setup" && command !== "update") {
50
- (runtime.error || console.error)(`Unknown command: ${command}\n\n${usage}`);
65
+ if (parsed.kind === "error") {
66
+ (runtime.error || console.error)(`${parsed.message}\n\n${usage}`);
51
67
  return 1;
52
68
  }
69
+ const commandRuntime = {
70
+ ...runtime,
71
+ includePrereleases: runtime.includePrereleases || parsed.options.includePrereleases,
72
+ releaseTag: parsed.options.releaseTag || runtime.releaseTag,
73
+ };
53
74
  try {
54
- if (command === "update")
55
- await runUpdate(runtime);
75
+ if (parsed.options.command === "update")
76
+ await runUpdate(commandRuntime);
56
77
  else
57
- await runSetup(runtime);
78
+ await runSetup(commandRuntime);
58
79
  return 0;
59
80
  }
60
81
  catch (error) {
@@ -63,8 +84,70 @@ export async function setupCli(runtime = {}) {
63
84
  return 1;
64
85
  }
65
86
  }
66
- function packageRoot() {
67
- return fileURLToPath(new URL("../", import.meta.url));
87
+ async function resolveReleaseTag(runtime, fetchImpl, githubToken) {
88
+ if (runtime.releaseTag)
89
+ return validateReleaseTag(runtime.releaseTag);
90
+ const releaseTag = await latestReleaseTag({
91
+ fetchImpl,
92
+ githubToken,
93
+ includePrereleases: runtime.includePrereleases,
94
+ });
95
+ return validateReleaseTag(releaseTag);
96
+ }
97
+ function parseCliOptions(argv) {
98
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
99
+ return { kind: "help" };
100
+ }
101
+ const command = commandName(argv[0]);
102
+ if (argv[0] && !command && !argv[0].startsWith("-")) {
103
+ return { kind: "error", message: `Unknown command: ${argv[0]}` };
104
+ }
105
+ const options = parseOptionArgs(command ? argv.slice(1) : argv);
106
+ if (options.kind !== "command")
107
+ return options;
108
+ return {
109
+ kind: "command",
110
+ options: {
111
+ ...options.options,
112
+ command: command || "setup",
113
+ },
114
+ };
115
+ }
116
+ function parseOptionArgs(args) {
117
+ const options = { includePrereleases: false };
118
+ for (let index = 0; index < args.length; index += 1) {
119
+ const arg = args[index] || "";
120
+ if (arg === "--include-prereleases") {
121
+ options.includePrereleases = true;
122
+ }
123
+ else if (arg === "--release") {
124
+ index += 1;
125
+ const releaseTag = args[index];
126
+ if (!releaseTag || releaseTag.startsWith("-")) {
127
+ return { kind: "error", message: "--release requires a release tag" };
128
+ }
129
+ options.releaseTag = validateReleaseTag(releaseTag);
130
+ }
131
+ else if (arg.startsWith("--release=")) {
132
+ options.releaseTag = validateReleaseTag(arg.slice("--release=".length));
133
+ }
134
+ else {
135
+ return { kind: "error", message: `Unknown option: ${arg}` };
136
+ }
137
+ }
138
+ return { kind: "command", options };
139
+ }
140
+ function commandName(value) {
141
+ if (value === "setup" || value === "update")
142
+ return value;
143
+ return undefined;
144
+ }
145
+ function validateReleaseTag(releaseTag) {
146
+ const trimmed = releaseTag.trim();
147
+ if (/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(trimmed)) {
148
+ return trimmed;
149
+ }
150
+ throw new Error(`Invalid release tag: ${releaseTag}. Release tags must look like v3.0.4 or v3.0.4-rc.1.`);
68
151
  }
69
152
  /* c8 ignore start */
70
153
  if (isDirectRun(import.meta.url)) {
@@ -0,0 +1,10 @@
1
+ export interface ConsumerStarterFile {
2
+ content: string;
3
+ relativePath: string;
4
+ sourcePath: string;
5
+ }
6
+ export declare function fetchConsumerStarterFiles(options: {
7
+ fetchImpl?: typeof fetch;
8
+ githubToken?: string;
9
+ releaseTag: string;
10
+ }): Promise<ConsumerStarterFile[]>;
@@ -0,0 +1,116 @@
1
+ import { githubApiHeaders } from "./github-api.js";
2
+ const consumerStarterRoot = "examples/consumer";
3
+ const repositoryContentsUrl = "https://api.github.com/repos/markhuangai/git-vibe/contents";
4
+ const maxStarterFileBytes = 128 * 1024;
5
+ export async function fetchConsumerStarterFiles(options) {
6
+ const fetchImpl = options.fetchImpl || fetch;
7
+ const files = await fetchDirectoryFiles(fetchImpl, options.releaseTag, consumerStarterRoot, options.githubToken);
8
+ if (files.length === 0) {
9
+ throw invalidStarterBundleError(options.releaseTag, `${consumerStarterRoot} contains no files`);
10
+ }
11
+ return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
12
+ }
13
+ async function fetchDirectoryFiles(fetchImpl, releaseTag, path, githubToken) {
14
+ const data = await fetchGitHubJson(fetchImpl, contentsUrl(path, releaseTag), releaseTag, githubToken);
15
+ if (!Array.isArray(data)) {
16
+ throw invalidStarterBundleError(releaseTag, `${path} is not a directory`);
17
+ }
18
+ const fileGroups = await Promise.all(data.map((entry) => fetchEntryFiles(fetchImpl, releaseTag, asContentEntry(entry), githubToken)));
19
+ return fileGroups.flat();
20
+ }
21
+ async function fetchEntryFiles(fetchImpl, releaseTag, entry, githubToken) {
22
+ const path = entryPath(entry, releaseTag);
23
+ relativeConsumerPath(path, releaseTag);
24
+ const type = entryType(entry, releaseTag, path);
25
+ if (type === "dir")
26
+ return fetchDirectoryFiles(fetchImpl, releaseTag, path, githubToken);
27
+ if (type === "file")
28
+ return [await fetchFile(fetchImpl, releaseTag, path, githubToken)];
29
+ throw invalidStarterBundleError(releaseTag, `${path} has unsupported type ${type}`);
30
+ }
31
+ async function fetchFile(fetchImpl, releaseTag, path, githubToken) {
32
+ const data = asContentEntry(await fetchGitHubJson(fetchImpl, contentsUrl(path, releaseTag), releaseTag, githubToken));
33
+ if (data.type !== "file" || typeof data.content !== "string" || data.encoding !== "base64") {
34
+ throw invalidStarterBundleError(releaseTag, `${path} is not a base64 file`);
35
+ }
36
+ const encodedContent = data.content.replace(/\s/g, "");
37
+ if (!/^[A-Za-z0-9+/=]*$/.test(encodedContent)) {
38
+ throw invalidStarterBundleError(releaseTag, `${path} has invalid base64 content`);
39
+ }
40
+ const content = Buffer.from(encodedContent, "base64");
41
+ if (content.byteLength > maxStarterFileBytes) {
42
+ throw invalidStarterBundleError(releaseTag, `${path} is larger than 128KiB`);
43
+ }
44
+ return {
45
+ content: content.toString("utf8"),
46
+ relativePath: relativeConsumerPath(path, releaseTag),
47
+ sourcePath: path,
48
+ };
49
+ }
50
+ async function fetchGitHubJson(fetchImpl, url, releaseTag, githubToken) {
51
+ let response;
52
+ const headers = githubApiHeaders(githubToken);
53
+ try {
54
+ response = await fetchImpl(url, {
55
+ headers,
56
+ });
57
+ }
58
+ catch {
59
+ throw unavailableStarterBundleError(releaseTag);
60
+ }
61
+ if (!response.ok)
62
+ throw unavailableStarterBundleError(releaseTag);
63
+ try {
64
+ return await response.json();
65
+ }
66
+ catch {
67
+ throw unavailableStarterBundleError(releaseTag);
68
+ }
69
+ }
70
+ function contentsUrl(path, releaseTag) {
71
+ const url = new URL(`${repositoryContentsUrl}/${encodePath(path)}`);
72
+ url.searchParams.set("ref", releaseTag);
73
+ return url;
74
+ }
75
+ function encodePath(path) {
76
+ return path.split("/").map(encodeURIComponent).join("/");
77
+ }
78
+ function asContentEntry(value) {
79
+ return typeof value === "object" && value !== null ? value : {};
80
+ }
81
+ function entryPath(entry, releaseTag) {
82
+ if (typeof entry.path !== "string") {
83
+ throw invalidStarterBundleError(releaseTag, "a content entry is missing path");
84
+ }
85
+ return entry.path;
86
+ }
87
+ function entryType(entry, releaseTag, path) {
88
+ if (typeof entry.type !== "string") {
89
+ throw invalidStarterBundleError(releaseTag, `${path} is missing type`);
90
+ }
91
+ return entry.type;
92
+ }
93
+ function relativeConsumerPath(path, releaseTag) {
94
+ const prefix = `${consumerStarterRoot}/`;
95
+ if (!path.startsWith(prefix)) {
96
+ throw invalidStarterBundleError(releaseTag, `${path} is outside ${consumerStarterRoot}`);
97
+ }
98
+ const relativePath = path.slice(prefix.length);
99
+ if (!isSafeRelativePath(relativePath)) {
100
+ throw invalidStarterBundleError(releaseTag, `${path} has an unsafe relative path`);
101
+ }
102
+ return relativePath;
103
+ }
104
+ function isSafeRelativePath(relativePath) {
105
+ const segments = relativePath.split("/");
106
+ return (relativePath.length > 0 &&
107
+ !relativePath.startsWith("/") &&
108
+ !relativePath.includes("\\") &&
109
+ segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."));
110
+ }
111
+ function unavailableStarterBundleError(releaseTag) {
112
+ return new Error(`git-vibe-setup could not fetch the GitVibe consumer starter from markhuangai/git-vibe@${releaseTag} because the GitHub content service is unavailable. No files were written.`);
113
+ }
114
+ function invalidStarterBundleError(releaseTag, detail) {
115
+ return new Error(`git-vibe-setup found an invalid GitVibe consumer starter at markhuangai/git-vibe@${releaseTag}: ${detail}. No files were written.`);
116
+ }
@@ -0,0 +1,2 @@
1
+ export declare function githubApiHeaders(githubToken?: string): Record<string, string>;
2
+ export declare function githubTokenFromEnvironment(env?: Record<string, string | undefined>): string | undefined;
@@ -0,0 +1,24 @@
1
+ const defaultGitHubApiHeaders = {
2
+ accept: "application/vnd.github+json",
3
+ "user-agent": "git-vibe-setup",
4
+ "x-github-api-version": "2022-11-28",
5
+ };
6
+ export function githubApiHeaders(githubToken) {
7
+ const headers = { ...defaultGitHubApiHeaders };
8
+ const token = normalizeGitHubToken(githubToken);
9
+ if (token)
10
+ headers.authorization = `Bearer ${token}`;
11
+ return headers;
12
+ }
13
+ export function githubTokenFromEnvironment(env = process.env) {
14
+ return normalizeGitHubToken(env.GITHUB_TOKEN) || normalizeGitHubToken(env.GH_TOKEN);
15
+ }
16
+ function normalizeGitHubToken(githubToken) {
17
+ const token = githubToken?.trim();
18
+ if (!token)
19
+ return undefined;
20
+ if (/[\r\n]/.test(token)) {
21
+ throw new Error("git-vibe-setup found an invalid GitHub token value. No files were written.");
22
+ }
23
+ return token;
24
+ }
package/dist/install.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConsumerStarterFile } from "./consumer-starter.js";
1
2
  export interface InstallFile {
2
3
  content: string;
3
4
  sourcePath: string;
@@ -6,12 +7,12 @@ export interface InstallFile {
6
7
  export declare function buildInstallFiles(options: {
7
8
  cwd: string;
8
9
  releaseTag: string;
9
- repositoryRoot: string;
10
+ sourceFiles: ConsumerStarterFile[];
10
11
  }): InstallFile[];
11
12
  export declare function buildWorkflowUpdateFiles(options: {
12
13
  cwd: string;
13
14
  releaseTag: string;
14
- repositoryRoot: string;
15
+ sourceFiles: ConsumerStarterFile[];
15
16
  }): InstallFile[];
16
17
  export declare function blockingInstallPaths(files: InstallFile[]): string[];
17
18
  export declare function unmanagedWorkflowUpdatePaths(files: InstallFile[]): string[];
package/dist/install.js CHANGED
@@ -1,29 +1,29 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
+ const requiredInstallSourcePaths = [
4
+ ".github/git-vibe.yml",
5
+ ".github/workflows/address-feedback.yml",
6
+ ".github/workflows/develop.yml",
7
+ ".github/workflows/investigate.yml",
8
+ ".github/workflows/materialize.yml",
9
+ ".github/workflows/review.yml",
10
+ ".github/workflows/validate.yml",
11
+ ".git-vibe/role-group/correctness.md",
12
+ ".git-vibe/role-group/maintainability.md",
13
+ ".git-vibe/role-group/security.md",
14
+ ];
15
+ const requiredWorkflowSourcePaths = requiredInstallSourcePaths.filter(isWorkflowSourcePath);
3
16
  export function buildInstallFiles(options) {
4
- return installSources(options.repositoryRoot).flatMap((source) => listRelativeFiles(source.sourceDirectory).map((relativePath) => {
5
- const sourcePath = join(source.sourceDirectory, relativePath);
6
- const targetPath = join(options.cwd, source.targetDirectory, relativePath);
7
- const content = readFileSync(sourcePath, "utf8");
8
- return {
9
- content: pinWorkflowReleaseRefs(content, options.releaseTag),
10
- sourcePath,
11
- targetPath,
12
- };
13
- }));
17
+ requireSourcePaths(options.sourceFiles, requiredInstallSourcePaths, options.releaseTag);
18
+ return options.sourceFiles
19
+ .filter((file) => isInstallSourcePath(file.relativePath))
20
+ .map((file) => buildInstallFile(file, options.cwd, options.releaseTag));
14
21
  }
15
22
  export function buildWorkflowUpdateFiles(options) {
16
- const sourceDirectory = join(options.repositoryRoot, "templates", ".github", "workflows");
17
- return listRelativeFiles(sourceDirectory).map((relativePath) => {
18
- const sourcePath = join(sourceDirectory, relativePath);
19
- const targetPath = join(options.cwd, ".github", "workflows", relativePath);
20
- const content = readFileSync(sourcePath, "utf8");
21
- return {
22
- content: pinWorkflowReleaseRefs(content, options.releaseTag),
23
- sourcePath,
24
- targetPath,
25
- };
26
- });
23
+ requireSourcePaths(options.sourceFiles, requiredWorkflowSourcePaths, options.releaseTag);
24
+ return options.sourceFiles
25
+ .filter((file) => isWorkflowSourcePath(file.relativePath))
26
+ .map((file) => buildInstallFile(file, options.cwd, options.releaseTag));
27
27
  }
28
28
  export function blockingInstallPaths(files) {
29
29
  return files
@@ -78,18 +78,6 @@ export function unmanagedWorkflowUpdateError(paths, cwd) {
78
78
  export function pinWorkflowReleaseRefs(content, releaseTag) {
79
79
  return content.replace(/(uses:\s*markhuangai\/git-vibe\/\.github\/workflows\/[^\s@]+)@[^\s]+/g, (_match, workflowReference) => `${workflowReference}@${releaseTag}`);
80
80
  }
81
- function installSources(repositoryRoot) {
82
- return [
83
- {
84
- sourceDirectory: join(repositoryRoot, "templates", ".github"),
85
- targetDirectory: ".github",
86
- },
87
- {
88
- sourceDirectory: join(repositoryRoot, "templates", ".git-vibe"),
89
- targetDirectory: ".git-vibe",
90
- },
91
- ];
92
- }
93
81
  function isManagedWorkflowTarget(file) {
94
82
  try {
95
83
  const workflowName = basename(file.targetPath);
@@ -105,18 +93,6 @@ function managedWorkflowPattern(workflowName) {
105
93
  function escapeRegExp(value) {
106
94
  return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
107
95
  }
108
- function listRelativeFiles(directory) {
109
- return readdirSync(directory, { withFileTypes: true })
110
- .flatMap((entry) => {
111
- const entryPath = join(directory, entry.name);
112
- if (entry.isDirectory()) {
113
- return listRelativeFiles(entryPath).map((path) => join(entry.name, path));
114
- }
115
- /* c8 ignore next */
116
- return entry.isFile() ? [entry.name] : [];
117
- })
118
- .sort();
119
- }
120
96
  function snapshotFile(targetPath) {
121
97
  if (!existsSync(targetPath))
122
98
  return { existed: false, targetPath };
@@ -167,3 +143,34 @@ function rollbackInstall(createdFiles, createdDirectories) {
167
143
  rmSync(directory, { force: true, recursive: false });
168
144
  }
169
145
  }
146
+ function buildInstallFile(sourceFile, cwd, releaseTag) {
147
+ return {
148
+ content: pinWorkflowReleaseRefs(sourceFile.content, releaseTag),
149
+ sourcePath: sourceFile.sourcePath,
150
+ targetPath: join(cwd, safeRelativePath(sourceFile.relativePath)),
151
+ };
152
+ }
153
+ function requireSourcePaths(sourceFiles, requiredPaths, releaseTag) {
154
+ const sourcePaths = new Set(sourceFiles.map((file) => file.relativePath));
155
+ const missingPaths = requiredPaths.filter((path) => !sourcePaths.has(path));
156
+ if (missingPaths.length === 0)
157
+ return;
158
+ const listed = missingPaths.map((path) => `- examples/consumer/${path}`).join("\n");
159
+ throw new Error(`git-vibe-setup found an incomplete GitVibe consumer starter at markhuangai/git-vibe@${releaseTag}. Missing files:\n${listed}\nNo files were written.`);
160
+ }
161
+ function isInstallSourcePath(relativePath) {
162
+ return relativePath.startsWith(".github/") || relativePath.startsWith(".git-vibe/");
163
+ }
164
+ function isWorkflowSourcePath(relativePath) {
165
+ return relativePath.startsWith(".github/workflows/") && relativePath.endsWith(".yml");
166
+ }
167
+ function safeRelativePath(relativePath) {
168
+ const segments = relativePath.split("/");
169
+ const isSafe = relativePath.length > 0 &&
170
+ !relativePath.startsWith("/") &&
171
+ !relativePath.includes("\\") &&
172
+ segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
173
+ if (isSafe)
174
+ return relativePath;
175
+ throw new Error(`git-vibe-setup found an unsafe starter file path: ${relativePath}`);
176
+ }
@@ -7,5 +7,15 @@ export interface GitHubRelease {
7
7
  }
8
8
  export declare class ReleaseLookupError extends Error {
9
9
  }
10
+ export interface ReleaseLookupOptions {
11
+ fetchImpl?: typeof fetch;
12
+ githubToken?: string;
13
+ includePrereleases?: boolean;
14
+ }
15
+ export interface ReleaseSelectionOptions {
16
+ includePrereleases?: boolean;
17
+ }
10
18
  export declare function latestStableReleaseTag(fetchImpl?: typeof fetch): Promise<string>;
19
+ export declare function latestReleaseTag(options?: ReleaseLookupOptions): Promise<string>;
11
20
  export declare function selectLatestStableRelease(releases: GitHubRelease[]): GitHubRelease | undefined;
21
+ export declare function selectLatestRelease(releases: GitHubRelease[], options?: ReleaseSelectionOptions): GitHubRelease | undefined;
package/dist/releases.js CHANGED
@@ -1,38 +1,42 @@
1
+ import { githubApiHeaders } from "./github-api.js";
1
2
  export class ReleaseLookupError extends Error {
2
3
  }
3
4
  const releasesUrl = "https://api.github.com/repos/markhuangai/git-vibe/releases";
4
5
  const releasesPerPage = 100;
5
6
  export async function latestStableReleaseTag(fetchImpl = fetch) {
6
- const releases = await fetchReleases(fetchImpl);
7
- const release = selectLatestStableRelease(releases);
7
+ return latestReleaseTag({ fetchImpl });
8
+ }
9
+ export async function latestReleaseTag(options = {}) {
10
+ const releases = await fetchReleases(options.fetchImpl || fetch, options.githubToken);
11
+ const release = selectLatestRelease(releases, options);
8
12
  if (!release?.tag_name) {
9
- throw new ReleaseLookupError("git-vibe-setup could not check the latest GitVibe update because no stable release is available. No files were written.");
13
+ throw new ReleaseLookupError(`git-vibe-setup could not check the latest GitVibe update because ${missingReleaseReason(options)}. No files were written.`);
10
14
  }
11
15
  return release.tag_name;
12
16
  }
13
17
  export function selectLatestStableRelease(releases) {
18
+ return selectLatestRelease(releases);
19
+ }
20
+ export function selectLatestRelease(releases, options = {}) {
14
21
  return releases
15
- .filter((release) => !release.draft && !release.prerelease && release.tag_name)
22
+ .filter((release) => !release.draft && (options.includePrereleases || !release.prerelease) && release.tag_name)
16
23
  .sort(compareReleaseFreshness)[0];
17
24
  }
18
- async function fetchReleases(fetchImpl) {
25
+ async function fetchReleases(fetchImpl, githubToken) {
19
26
  const releases = [];
20
27
  for (let page = 1;; page += 1) {
21
- const data = await fetchReleasePage(fetchImpl, page);
28
+ const data = await fetchReleasePage(fetchImpl, page, githubToken);
22
29
  releases.push(...data);
23
30
  if (data.length < releasesPerPage)
24
31
  return releases;
25
32
  }
26
33
  }
27
- async function fetchReleasePage(fetchImpl, page) {
34
+ async function fetchReleasePage(fetchImpl, page, githubToken) {
28
35
  let response;
36
+ const headers = githubApiHeaders(githubToken);
29
37
  try {
30
38
  response = await fetchImpl(releasePageUrl(page), {
31
- headers: {
32
- accept: "application/vnd.github+json",
33
- "user-agent": "git-vibe-setup",
34
- "x-github-api-version": "2022-11-28",
35
- },
39
+ headers,
36
40
  });
37
41
  }
38
42
  catch {
@@ -63,3 +67,8 @@ function releaseTime(release) {
63
67
  function unavailableReleaseError() {
64
68
  return new ReleaseLookupError("git-vibe-setup could not check the latest GitVibe update because the GitHub release service is unavailable. No files were written.");
65
69
  }
70
+ function missingReleaseReason(options) {
71
+ return options.includePrereleases
72
+ ? "no stable or prerelease release is available"
73
+ : "no stable release is available";
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-vibe-setup",
3
- "version": "3.0.3",
3
+ "version": "3.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,6 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
- "templates",
12
11
  "README.md"
13
12
  ],
14
13
  "scripts": {
@@ -1,7 +0,0 @@
1
- # Correctness Reviewer
2
-
3
- Review the stage result for behavioral correctness. Check that conclusions are
4
- grounded in the GitHub context and repository evidence, and that any required
5
- next action is specific enough to execute.
6
-
7
- Return only the current stage schema.
@@ -1,7 +0,0 @@
1
- # Maintainability Reviewer
2
-
3
- Review the stage result for implementation clarity, long-term maintenance,
4
- operability, and fit with existing repository patterns. Prefer small,
5
- evidence-backed changes over broad redesigns.
6
-
7
- Return only the current stage schema.
@@ -1,7 +0,0 @@
1
- # Security Reviewer
2
-
3
- Review the stage result for token handling, permissions, untrusted input, file
4
- access, workflow authority, and GitHub write safety. Flag only concrete risks
5
- that affect the requested work.
6
-
7
- Return only the current stage schema.
@@ -1,150 +0,0 @@
1
- # Copy this file to .github/git-vibe.yml in the consumer repository.
2
-
3
- version: 1
4
-
5
- permissions:
6
- # Users with write, maintain, or admin can approve automation.
7
- approver_roles:
8
- - write
9
- - maintain
10
- - admin
11
-
12
- labels:
13
- story: gvi:story
14
- needs_discussion: gvi:needs-discussion
15
- investigate: git-vibe:investigate
16
- investigating: gvi:investigating
17
- investigated: gvi:investigated
18
- ready_for_approval: gvi:ready-for-approval
19
- approved: git-vibe:approved
20
- review: git-vibe:review
21
- reviewing: gvi:reviewing
22
- in_progress: gvi:in-progress
23
- blocked: gvi:blocked
24
- pr_opened: gvi:pr-opened
25
- pr_approved: gvi:pr-approved
26
- pr_merged: gvi:pr-merged
27
- validate: git-vibe:validate
28
- validating: gvi:validating
29
- validated: gvi:validated
30
-
31
- commands:
32
- prefix: /git-vibe
33
- allow_external_agent_mentions: false
34
-
35
- event_delivery:
36
- # webhook: repository webhook points at the self-hosted GitVibe server.
37
- # relay: webhook proxy/tunnel such as Smee, Hookdeck, Cloudflare Tunnel, or ngrok.
38
- # actions: no-server receiver workflows in the consumer repository.
39
- # polling: local/scheduled worker polls GitHub APIs with cursors/ETags.
40
- mode: webhook
41
- relay:
42
- provider: smee
43
- # GitHub Actions Secret name. Relay URLs may contain delivery tokens; do not commit the real URL.
44
- url_secret: GITVIBE_RELAY_URL
45
- actions_receiver:
46
- enabled: false
47
- scheduled_scan: "*/15 * * * *"
48
- polling:
49
- enabled: false
50
- interval_seconds: 300
51
-
52
- github_auth:
53
- # Self-hosted default: the GitVibe server uses a fine-grained PAT scoped to this repository.
54
- mode: webhook-pat
55
- # GitHub Actions Secret name. Stores the GitHub write token.
56
- token_secret: GITVIBE_GITHUB_TOKEN
57
-
58
- tests:
59
- commands: []
60
-
61
- ai:
62
- security:
63
- web:
64
- # Default for public repositories: let AI search current GitHub project material only.
65
- # Add domains only for websites the repository owner trusts AI to search and fetch.
66
- allowed_domains: []
67
- # - github.com
68
- # - "*.github.com"
69
- profiles:
70
- local_proxy:
71
- adapter: ai-sdk-agentool
72
- # Optional: set to the selected model's context window.
73
- # context_window_tokens: 128000
74
- provider:
75
- # openai, anthropic, or openai-compatible.
76
- type: openai-compatible
77
- # Model names are configuration, not credentials.
78
- model: glm-5
79
- # Keys inside GITVIBE_AI_ENV_JSON. Store provider credentials and endpoints there.
80
- base_url:
81
- from_bundle: GITVIBE_AI_BASE_URL
82
- api_key:
83
- from_bundle: GITVIBE_AI_API_KEY
84
- reasoning:
85
- effort: high
86
- provider_options:
87
- openai:
88
- reasoningEffort: high
89
- reasoningSummary: concise
90
- anthropic:
91
- effort: high
92
- codex_cli:
93
- adapter: cli-codex
94
- # Key inside GITVIBE_AI_ENV_JSON. Stores escaped auth.json text from jq -Rs .
95
- # Requires GITVIBE_GITHUB_TOKEN repository Secrets read/write permission for refresh write-back.
96
- auth_json:
97
- from_bundle: CODEX_AUTH_JSON
98
- model: gpt-5.3-codex
99
- reasoning:
100
- effort: high
101
- summary: concise
102
- claude_code:
103
- adapter: cli-claude-code
104
- # Key inside GITVIBE_AI_ENV_JSON. Stores the Claude Code OAuth access token.
105
- env:
106
- CLAUDE_CODE_OAUTH_TOKEN:
107
- from_bundle: CLAUDE_OAUTH_TOKEN
108
- model: opus
109
- reasoning:
110
- effort: xhigh
111
- role_groups:
112
- review_gate:
113
- synthesizer: local_proxy
114
- parallel: 2
115
- roles:
116
- - role: correctness.md
117
- profile: local_proxy
118
- - role: security.md
119
- profile: local_proxy
120
- - role: maintainability.md
121
- profile: local_proxy
122
- budgets:
123
- default_timeout_minutes: 60
124
- review_timeout_minutes: 60
125
- implementation_timeout_minutes: 120
126
- feedback_timeout_minutes: 120
127
- create_pr_timeout_minutes: 15
128
- default_max_turns: 90
129
- implementation_max_turns: 200
130
- feedback_max_turns: 120
131
- validation_repair_attempts: 3
132
- validation_repair_max_turns: 45
133
- pr_feedback_max_iterations: 3
134
- request_retry_attempts: 3
135
- request_retry_delay_seconds: 60
136
- stages:
137
- investigate:
138
- role_group: review_gate
139
- validate:
140
- role_group: review_gate
141
- materialize:
142
- profile: local_proxy
143
- implement:
144
- profile: local_proxy
145
- review-matrix:
146
- role_group: review_gate
147
- create-pr:
148
- profile: local_proxy
149
- address-pr-feedback:
150
- profile: local_proxy
@@ -1,44 +0,0 @@
1
- name: GitVibe address feedback
2
- run-name: "[git-vibe][address-feedback]: PR #${{ inputs.pr-number }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- pr-number:
8
- description: Pull request number with feedback to address.
9
- required: true
10
- type: string
11
- timeout_minutes:
12
- description: Maximum minutes for the feedback remediation job.
13
- required: false
14
- type: number
15
- default: 120
16
- max_turns:
17
- description: Maximum AI turns for feedback remediation.
18
- required: false
19
- type: number
20
- default: 120
21
- dry-run:
22
- description: Validate without writing to GitHub.
23
- required: false
24
- type: boolean
25
- default: false
26
- source-comment:
27
- description: JSON source comment metadata for replying to command comments.
28
- required: false
29
- type: string
30
- default: ""
31
-
32
- jobs:
33
- address-feedback:
34
- uses: markhuangai/git-vibe/.github/workflows/address-feedback.yml@v3
35
- with:
36
- pr-number: ${{ inputs.pr-number }}
37
- runner: ubuntu-latest
38
- timeout_minutes: ${{ inputs.timeout_minutes }}
39
- max_turns: ${{ inputs.max_turns }}
40
- dry-run: ${{ inputs.dry-run }}
41
- source-comment: ${{ inputs.source-comment }}
42
- secrets:
43
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
44
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,74 +0,0 @@
1
- name: GitVibe develop
2
- run-name: "[git-vibe][develop]: Issue #${{ inputs.issue-number }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- issue-number:
8
- description: Approved implementation issue number.
9
- required: true
10
- type: string
11
- implementation_timeout_minutes:
12
- description: Maximum minutes for the implementation job.
13
- required: false
14
- type: number
15
- default: 120
16
- review_timeout_minutes:
17
- description: Maximum minutes for the review matrix job.
18
- required: false
19
- type: number
20
- default: 60
21
- create_pr_timeout_minutes:
22
- description: Maximum minutes for the PR creation job.
23
- required: false
24
- type: number
25
- default: 15
26
- max_turns:
27
- description: Default maximum AI turns for stages in this workflow.
28
- required: false
29
- type: number
30
- default: 90
31
- implementation_max_turns:
32
- description: Maximum AI turns for implementation and feedback-style coding work.
33
- required: false
34
- type: number
35
- default: 200
36
- validation_repair_attempts:
37
- description: Maximum validation repair attempts inside each implementation run.
38
- required: false
39
- type: number
40
- default: 3
41
- validation_repair_max_turns:
42
- description: Maximum AI turns for each validation repair attempt.
43
- required: false
44
- type: number
45
- default: 45
46
- dry-run:
47
- description: Validate without writing to GitHub.
48
- required: false
49
- type: boolean
50
- default: false
51
- source-comment:
52
- description: JSON source comment metadata for replying to command comments.
53
- required: false
54
- type: string
55
- default: ""
56
-
57
- jobs:
58
- develop:
59
- uses: markhuangai/git-vibe/.github/workflows/develop.yml@v3
60
- with:
61
- issue-number: ${{ inputs.issue-number }}
62
- runner: ubuntu-latest
63
- implementation_timeout_minutes: ${{ inputs.implementation_timeout_minutes }}
64
- review_timeout_minutes: ${{ inputs.review_timeout_minutes }}
65
- create_pr_timeout_minutes: ${{ inputs.create_pr_timeout_minutes }}
66
- max_turns: ${{ inputs.max_turns }}
67
- implementation_max_turns: ${{ inputs.implementation_max_turns }}
68
- validation_repair_attempts: ${{ inputs.validation_repair_attempts }}
69
- validation_repair_max_turns: ${{ inputs.validation_repair_max_turns }}
70
- dry-run: ${{ inputs.dry-run }}
71
- source-comment: ${{ inputs.source-comment }}
72
- secrets:
73
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
74
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,44 +0,0 @@
1
- name: GitVibe investigate
2
- run-name: "[git-vibe][investigate]: Issue #${{ inputs.issue-number }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- issue-number:
8
- description: Issue number to investigate.
9
- required: true
10
- type: string
11
- timeout_minutes:
12
- description: Maximum minutes for the investigation job.
13
- required: false
14
- type: number
15
- default: 60
16
- max_turns:
17
- description: Maximum AI turns for this stage.
18
- required: false
19
- type: number
20
- default: 90
21
- dry-run:
22
- description: Validate without writing to GitHub.
23
- required: false
24
- type: boolean
25
- default: false
26
- source-comment:
27
- description: JSON source comment metadata for replying to command comments.
28
- required: false
29
- type: string
30
- default: ""
31
-
32
- jobs:
33
- investigate:
34
- uses: markhuangai/git-vibe/.github/workflows/investigate.yml@v3
35
- with:
36
- issue-number: ${{ inputs.issue-number }}
37
- runner: ubuntu-latest
38
- timeout_minutes: ${{ inputs.timeout_minutes }}
39
- max_turns: ${{ inputs.max_turns }}
40
- dry-run: ${{ inputs.dry-run }}
41
- source-comment: ${{ inputs.source-comment }}
42
- secrets:
43
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
44
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,44 +0,0 @@
1
- name: GitVibe materialize
2
- run-name: "[git-vibe][materialize]: Discussion #${{ inputs.discussion-number }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- discussion-number:
8
- description: Discussion number to materialize.
9
- required: true
10
- type: string
11
- timeout_minutes:
12
- description: Maximum minutes for this job.
13
- required: false
14
- type: number
15
- default: 60
16
- max_turns:
17
- description: Maximum AI turns for this stage.
18
- required: false
19
- type: number
20
- default: 90
21
- dry-run:
22
- description: Validate without writing to GitHub.
23
- required: false
24
- type: boolean
25
- default: false
26
- source-comment:
27
- description: JSON source comment metadata for replying to command comments.
28
- required: false
29
- type: string
30
- default: ""
31
-
32
- jobs:
33
- materialize:
34
- uses: markhuangai/git-vibe/.github/workflows/materialize.yml@v3
35
- with:
36
- discussion-number: ${{ inputs.discussion-number }}
37
- runner: ubuntu-latest
38
- timeout_minutes: ${{ inputs.timeout_minutes }}
39
- max_turns: ${{ inputs.max_turns }}
40
- dry-run: ${{ inputs.dry-run }}
41
- source-comment: ${{ inputs.source-comment }}
42
- secrets:
43
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
44
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,44 +0,0 @@
1
- name: GitVibe review
2
- run-name: "[git-vibe][review]: PR #${{ inputs.pr-number }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- pr-number:
8
- description: Pull request number to review.
9
- required: true
10
- type: string
11
- timeout_minutes:
12
- description: Maximum minutes for the review matrix job.
13
- required: false
14
- type: number
15
- default: 60
16
- max_turns:
17
- description: Maximum AI turns for the review stage.
18
- required: false
19
- type: number
20
- default: 90
21
- dry-run:
22
- description: Validate without writing to GitHub.
23
- required: false
24
- type: boolean
25
- default: false
26
- source-comment:
27
- description: JSON source comment metadata for replying to command comments.
28
- required: false
29
- type: string
30
- default: ""
31
-
32
- jobs:
33
- review:
34
- uses: markhuangai/git-vibe/.github/workflows/review.yml@v3
35
- with:
36
- pr-number: ${{ inputs.pr-number }}
37
- runner: ubuntu-latest
38
- timeout_minutes: ${{ inputs.timeout_minutes }}
39
- max_turns: ${{ inputs.max_turns }}
40
- dry-run: ${{ inputs.dry-run }}
41
- source-comment: ${{ inputs.source-comment }}
42
- secrets:
43
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
44
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,51 +0,0 @@
1
- name: GitVibe validate
2
- run-name: "[git-vibe][validate]: ${{ inputs.discussion-number != '' && format('Discussion #{0}', inputs.discussion-number) || inputs.issue-number != '' && format('Issue #{0}', inputs.issue-number) || 'Artifact' }}"
3
-
4
- on:
5
- workflow_dispatch:
6
- inputs:
7
- issue-number:
8
- description: Issue number to validate.
9
- required: false
10
- type: string
11
- default: ""
12
- discussion-number:
13
- description: Discussion number to validate.
14
- required: false
15
- type: string
16
- default: ""
17
- timeout_minutes:
18
- description: Maximum minutes for this job.
19
- required: false
20
- type: number
21
- default: 60
22
- max_turns:
23
- description: Maximum AI turns for this stage.
24
- required: false
25
- type: number
26
- default: 90
27
- dry-run:
28
- description: Validate without writing to GitHub.
29
- required: false
30
- type: boolean
31
- default: false
32
- source-comment:
33
- description: JSON source comment metadata for replying to command comments.
34
- required: false
35
- type: string
36
- default: ""
37
-
38
- jobs:
39
- validate:
40
- uses: markhuangai/git-vibe/.github/workflows/validate.yml@v3
41
- with:
42
- issue-number: ${{ inputs.issue-number }}
43
- discussion-number: ${{ inputs.discussion-number }}
44
- runner: ubuntu-latest
45
- timeout_minutes: ${{ inputs.timeout_minutes }}
46
- max_turns: ${{ inputs.max_turns }}
47
- dry-run: ${{ inputs.dry-run }}
48
- source-comment: ${{ inputs.source-comment }}
49
- secrets:
50
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
51
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,8 +0,0 @@
1
- {
2
- "CLAUDE_OAUTH_TOKEN": "replace-with-claude-oauth-token",
3
- "CODEX_AUTH_JSON": "{\"tokens\":[]}",
4
- "GITVIBE_AI_API_KEY": "replace-with-ai-provider-api-key",
5
- "GITVIBE_AI_BASE_URL": "https://api.provider.example/v1",
6
- "MINIMAX_API_KEY": "replace-with-minimax-api-key",
7
- "MINIMAX_ANTHROPIC_BASE_URL": "https://api.minimax.example/anthropic"
8
- }