git-vibe-setup 3.0.2 → 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,6 +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.
17
+
18
+ ```bash
19
+ npx git-vibe-setup update
20
+ ```
21
+
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,10 +4,13 @@ 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>;
13
+ export declare function runUpdate(runtime?: SetupCliRuntime): Promise<void>;
11
14
  export declare function setupCli(runtime?: SetupCliRuntime): Promise<number>;
12
15
  export declare function isDirectRun(moduleUrl: string, entrypoint?: string): boolean;
13
16
  export {};
package/dist/cli.js CHANGED
@@ -3,42 +3,79 @@ 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 { blockingInstallPaths, buildInstallFiles, existingFilesError, installFiles, } from "./install.js";
6
+ import { fetchConsumerStarterFiles } from "./consumer-starter.js";
7
+ import { githubTokenFromEnvironment } from "./github-api.js";
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
12
+ git-vibe-setup setup [--release <tag>] [--include-prereleases]
13
+ git-vibe-setup update [--release <tag>] [--include-prereleases]
11
14
  git-vibe-setup
12
15
 
13
16
  Commands:
14
17
  setup Install GitVibe starter files into the current repository.
18
+ update Update GitVibe workflow wrapper files in the current repository.
15
19
 
16
20
  Options:
17
- -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.`;
18
24
  export async function runSetup(runtime = {}) {
19
25
  const cwd = runtime.cwd || process.cwd();
20
- const repositoryRoot = runtime.repositoryRoot || packageRoot();
21
- const releaseTag = await latestStableReleaseTag(runtime.fetchImpl || fetch);
22
- 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 });
23
31
  const blockingPaths = blockingInstallPaths(files);
24
32
  if (blockingPaths.length > 0)
25
33
  throw existingFilesError(blockingPaths, cwd);
26
34
  installFiles(files);
27
35
  (runtime.log || console.log)(renderManualSetupInstructions(releaseTag));
28
36
  }
37
+ export async function runUpdate(runtime = {}) {
38
+ const cwd = runtime.cwd || process.cwd();
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 });
44
+ const unmanagedPaths = unmanagedWorkflowUpdatePaths(files);
45
+ if (unmanagedPaths.length > 0)
46
+ throw unmanagedWorkflowUpdateError(unmanagedPaths, cwd);
47
+ updateFiles(files);
48
+ (runtime.log || console.log)(`GitVibe workflow files updated with reusable workflows pinned to ${releaseTag}.`);
49
+ }
29
50
  export async function setupCli(runtime = {}) {
30
51
  const argv = runtime.argv || process.argv.slice(2);
31
- const command = argv[0] || "setup";
32
- 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") {
33
62
  (runtime.log || console.log)(usage);
34
63
  return 0;
35
64
  }
36
- if (command !== "setup") {
37
- (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}`);
38
67
  return 1;
39
68
  }
69
+ const commandRuntime = {
70
+ ...runtime,
71
+ includePrereleases: runtime.includePrereleases || parsed.options.includePrereleases,
72
+ releaseTag: parsed.options.releaseTag || runtime.releaseTag,
73
+ };
40
74
  try {
41
- await runSetup(runtime);
75
+ if (parsed.options.command === "update")
76
+ await runUpdate(commandRuntime);
77
+ else
78
+ await runSetup(commandRuntime);
42
79
  return 0;
43
80
  }
44
81
  catch (error) {
@@ -47,8 +84,70 @@ export async function setupCli(runtime = {}) {
47
84
  return 1;
48
85
  }
49
86
  }
50
- function packageRoot() {
51
- 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.`);
52
151
  }
53
152
  /* c8 ignore start */
54
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,9 +7,17 @@ export interface InstallFile {
6
7
  export declare function buildInstallFiles(options: {
7
8
  cwd: string;
8
9
  releaseTag: string;
9
- repositoryRoot: string;
10
+ sourceFiles: ConsumerStarterFile[];
11
+ }): InstallFile[];
12
+ export declare function buildWorkflowUpdateFiles(options: {
13
+ cwd: string;
14
+ releaseTag: string;
15
+ sourceFiles: ConsumerStarterFile[];
10
16
  }): InstallFile[];
11
17
  export declare function blockingInstallPaths(files: InstallFile[]): string[];
18
+ export declare function unmanagedWorkflowUpdatePaths(files: InstallFile[]): string[];
12
19
  export declare function installFiles(files: InstallFile[]): void;
20
+ export declare function updateFiles(files: InstallFile[]): void;
13
21
  export declare function existingFilesError(paths: string[], cwd: string): Error;
22
+ export declare function unmanagedWorkflowUpdateError(paths: string[], cwd: string): Error;
14
23
  export declare function pinWorkflowReleaseRefs(content: string, releaseTag: string): string;
package/dist/install.js CHANGED
@@ -1,16 +1,29 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { dirname, join, relative } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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));
21
+ }
22
+ export function buildWorkflowUpdateFiles(options) {
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));
14
27
  }
15
28
  export function blockingInstallPaths(files) {
16
29
  return files
@@ -18,6 +31,12 @@ export function blockingInstallPaths(files) {
18
31
  .filter((targetPath) => existsSync(targetPath))
19
32
  .sort();
20
33
  }
34
+ export function unmanagedWorkflowUpdatePaths(files) {
35
+ return files
36
+ .filter((file) => existsSync(file.targetPath) && !isManagedWorkflowTarget(file))
37
+ .map((file) => file.targetPath)
38
+ .sort();
39
+ }
21
40
  export function installFiles(files) {
22
41
  const createdDirectories = [];
23
42
  const createdFiles = [];
@@ -33,36 +52,55 @@ export function installFiles(files) {
33
52
  throw error;
34
53
  }
35
54
  }
55
+ export function updateFiles(files) {
56
+ const createdDirectories = [];
57
+ const snapshots = [];
58
+ try {
59
+ for (const file of files) {
60
+ ensureDirectory(dirname(file.targetPath), createdDirectories);
61
+ snapshots.push(snapshotFile(file.targetPath));
62
+ writeFileSync(file.targetPath, file.content);
63
+ }
64
+ }
65
+ catch (error) {
66
+ rollbackUpdate(snapshots, createdDirectories);
67
+ throw error;
68
+ }
69
+ }
36
70
  export function existingFilesError(paths, cwd) {
37
71
  const listed = paths.map((path) => `- ${relative(cwd, path) || path}`).join("\n");
38
72
  return new Error(`git-vibe-setup found existing GitVibe files and did not overwrite them:\n${listed}\nRemove the listed files before running setup again.`);
39
73
  }
74
+ export function unmanagedWorkflowUpdateError(paths, cwd) {
75
+ const listed = paths.map((path) => `- ${relative(cwd, path) || path}`).join("\n");
76
+ return new Error(`git-vibe-setup found workflow files that do not look like GitVibe wrappers and did not overwrite them:\n${listed}`);
77
+ }
40
78
  export function pinWorkflowReleaseRefs(content, releaseTag) {
41
79
  return content.replace(/(uses:\s*markhuangai\/git-vibe\/\.github\/workflows\/[^\s@]+)@[^\s]+/g, (_match, workflowReference) => `${workflowReference}@${releaseTag}`);
42
80
  }
43
- function installSources(repositoryRoot) {
44
- return [
45
- {
46
- sourceDirectory: join(repositoryRoot, "templates", ".github"),
47
- targetDirectory: ".github",
48
- },
49
- {
50
- sourceDirectory: join(repositoryRoot, "templates", ".git-vibe"),
51
- targetDirectory: ".git-vibe",
52
- },
53
- ];
54
- }
55
- function listRelativeFiles(directory) {
56
- return readdirSync(directory, { withFileTypes: true })
57
- .flatMap((entry) => {
58
- const entryPath = join(directory, entry.name);
59
- if (entry.isDirectory()) {
60
- return listRelativeFiles(entryPath).map((path) => join(entry.name, path));
61
- }
62
- /* c8 ignore next */
63
- return entry.isFile() ? [entry.name] : [];
64
- })
65
- .sort();
81
+ function isManagedWorkflowTarget(file) {
82
+ try {
83
+ const workflowName = basename(file.targetPath);
84
+ return managedWorkflowPattern(workflowName).test(readFileSync(file.targetPath, "utf8"));
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ function managedWorkflowPattern(workflowName) {
91
+ return new RegExp(`uses:\\s*markhuangai/git-vibe/\\.github/workflows/${escapeRegExp(workflowName)}@`);
92
+ }
93
+ function escapeRegExp(value) {
94
+ return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
95
+ }
96
+ function snapshotFile(targetPath) {
97
+ if (!existsSync(targetPath))
98
+ return { existed: false, targetPath };
99
+ return {
100
+ content: readFileSync(targetPath, "utf8"),
101
+ existed: true,
102
+ targetPath,
103
+ };
66
104
  }
67
105
  function ensureDirectory(directory, createdDirectories) {
68
106
  const missing = missingDirectories(directory);
@@ -84,6 +122,19 @@ function missingDirectories(directory) {
84
122
  }
85
123
  return missing.reverse();
86
124
  }
125
+ function rollbackUpdate(snapshots, createdDirectories) {
126
+ for (const snapshot of [...snapshots].reverse()) {
127
+ if (snapshot.existed) {
128
+ writeFileSync(snapshot.targetPath, snapshot.content || "");
129
+ }
130
+ else {
131
+ rmSync(snapshot.targetPath, { force: true });
132
+ }
133
+ }
134
+ for (const directory of [...createdDirectories].reverse()) {
135
+ rmSync(directory, { force: true, recursive: false });
136
+ }
137
+ }
87
138
  function rollbackInstall(createdFiles, createdDirectories) {
88
139
  for (const file of [...createdFiles].reverse()) {
89
140
  rmSync(file, { force: true });
@@ -92,3 +143,34 @@ function rollbackInstall(createdFiles, createdDirectories) {
92
143
  rmSync(directory, { force: true, recursive: false });
93
144
  }
94
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.2",
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,34 +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
- dry-run:
12
- description: Validate without writing to GitHub.
13
- required: false
14
- type: boolean
15
- default: false
16
- source-comment:
17
- description: JSON source comment metadata for replying to command comments.
18
- required: false
19
- type: string
20
- default: ""
21
-
22
- jobs:
23
- address-feedback:
24
- uses: markhuangai/git-vibe/.github/workflows/address-feedback.yml@v3
25
- with:
26
- pr-number: ${{ inputs.pr-number }}
27
- runner: ubuntu-latest
28
- timeout_minutes: 120
29
- max_turns: 120
30
- dry-run: ${{ inputs.dry-run }}
31
- source-comment: ${{ inputs.source-comment }}
32
- secrets:
33
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
34
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,39 +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
- dry-run:
12
- description: Validate without writing to GitHub.
13
- required: false
14
- type: boolean
15
- default: false
16
- source-comment:
17
- description: JSON source comment metadata for replying to command comments.
18
- required: false
19
- type: string
20
- default: ""
21
-
22
- jobs:
23
- develop:
24
- uses: markhuangai/git-vibe/.github/workflows/develop.yml@v3
25
- with:
26
- issue-number: ${{ inputs.issue-number }}
27
- runner: ubuntu-latest
28
- implementation_timeout_minutes: 120
29
- review_timeout_minutes: 60
30
- create_pr_timeout_minutes: 15
31
- max_turns: 90
32
- implementation_max_turns: 120
33
- validation_repair_attempts: 2
34
- validation_repair_max_turns: 90
35
- dry-run: ${{ inputs.dry-run }}
36
- source-comment: ${{ inputs.source-comment }}
37
- secrets:
38
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
39
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,34 +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
- dry-run:
12
- description: Validate without writing to GitHub.
13
- required: false
14
- type: boolean
15
- default: false
16
- source-comment:
17
- description: JSON source comment metadata for replying to command comments.
18
- required: false
19
- type: string
20
- default: ""
21
-
22
- jobs:
23
- investigate:
24
- uses: markhuangai/git-vibe/.github/workflows/investigate.yml@v3
25
- with:
26
- issue-number: ${{ inputs.issue-number }}
27
- runner: ubuntu-latest
28
- timeout_minutes: 60
29
- max_turns: 90
30
- dry-run: ${{ inputs.dry-run }}
31
- source-comment: ${{ inputs.source-comment }}
32
- secrets:
33
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
34
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,34 +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
- dry-run:
12
- description: Validate without writing to GitHub.
13
- required: false
14
- type: boolean
15
- default: false
16
- source-comment:
17
- description: JSON source comment metadata for replying to command comments.
18
- required: false
19
- type: string
20
- default: ""
21
-
22
- jobs:
23
- materialize:
24
- uses: markhuangai/git-vibe/.github/workflows/materialize.yml@v3
25
- with:
26
- discussion-number: ${{ inputs.discussion-number }}
27
- runner: ubuntu-latest
28
- timeout_minutes: 60
29
- max_turns: 90
30
- dry-run: ${{ inputs.dry-run }}
31
- source-comment: ${{ inputs.source-comment }}
32
- secrets:
33
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
34
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,34 +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
- dry-run:
12
- description: Validate without writing to GitHub.
13
- required: false
14
- type: boolean
15
- default: false
16
- source-comment:
17
- description: JSON source comment metadata for replying to command comments.
18
- required: false
19
- type: string
20
- default: ""
21
-
22
- jobs:
23
- review:
24
- uses: markhuangai/git-vibe/.github/workflows/review.yml@v3
25
- with:
26
- pr-number: ${{ inputs.pr-number }}
27
- runner: ubuntu-latest
28
- timeout_minutes: 60
29
- max_turns: 90
30
- dry-run: ${{ inputs.dry-run }}
31
- source-comment: ${{ inputs.source-comment }}
32
- secrets:
33
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
34
- GITVIBE_AI_ENV_JSON: ${{ secrets.GITVIBE_AI_ENV_JSON }}
@@ -1,41 +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
- dry-run:
18
- description: Validate without writing to GitHub.
19
- required: false
20
- type: boolean
21
- default: false
22
- source-comment:
23
- description: JSON source comment metadata for replying to command comments.
24
- required: false
25
- type: string
26
- default: ""
27
-
28
- jobs:
29
- validate:
30
- uses: markhuangai/git-vibe/.github/workflows/validate.yml@v3
31
- with:
32
- issue-number: ${{ inputs.issue-number }}
33
- discussion-number: ${{ inputs.discussion-number }}
34
- runner: ubuntu-latest
35
- timeout_minutes: 60
36
- max_turns: 90
37
- dry-run: ${{ inputs.dry-run }}
38
- source-comment: ${{ inputs.source-comment }}
39
- secrets:
40
- GITVIBE_GITHUB_TOKEN: ${{ secrets.GITVIBE_GITHUB_TOKEN }}
41
- 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
- }