safe-push 0.3.0 → 0.5.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/.claude/settings.local.json +12 -0
- package/.mcp.json +8 -0
- package/bun.lock +233 -0
- package/config.schema.json +47 -0
- package/dist/index.js +28862 -4751
- package/dist/mcp.js +29184 -0
- package/package.json +11 -3
- package/scripts/generate-schema.ts +13 -0
- package/src/checker.ts +79 -60
- package/src/commands/check.ts +17 -10
- package/src/commands/mcp.ts +383 -0
- package/src/commands/push.ts +35 -22
- package/src/config.ts +9 -1
- package/src/git.ts +136 -100
- package/src/index.ts +40 -2
- package/src/telemetry.ts +95 -0
- package/src/types.ts +18 -0
package/src/commands/push.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
printCheckResultHuman,
|
|
10
10
|
promptConfirm,
|
|
11
11
|
} from "./utils";
|
|
12
|
+
import { ExitError } from "../types";
|
|
13
|
+
import { withSpan } from "../telemetry";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* pushコマンドを作成
|
|
@@ -21,34 +23,41 @@ export function createPushCommand(): Command {
|
|
|
21
23
|
.allowUnknownOption()
|
|
22
24
|
.action(async (options: { force?: boolean; dryRun?: boolean }, command: Command) => {
|
|
23
25
|
const gitArgs = command.args;
|
|
24
|
-
|
|
26
|
+
await withSpan("safe-push.push", async (rootSpan) => {
|
|
25
27
|
// Gitリポジトリ内か確認
|
|
26
28
|
if (!(await isGitRepository())) {
|
|
27
29
|
printError("Not a git repository");
|
|
28
|
-
|
|
30
|
+
throw new ExitError(1);
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// コミットが存在するか確認
|
|
32
34
|
if (!(await hasCommits())) {
|
|
33
35
|
printError("No commits found");
|
|
34
|
-
|
|
36
|
+
throw new ExitError(1);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const config = loadConfig();
|
|
38
40
|
|
|
41
|
+
rootSpan.addEvent("config.loaded", {
|
|
42
|
+
forbiddenPaths: JSON.stringify(config.forbiddenPaths),
|
|
43
|
+
onForbidden: config.onForbidden,
|
|
44
|
+
hasVisibilityRule: !!(config.allowedVisibility && config.allowedVisibility.length > 0),
|
|
45
|
+
});
|
|
46
|
+
|
|
39
47
|
// visibility チェック(--force でもバイパスできない)
|
|
40
48
|
if (config.allowedVisibility && config.allowedVisibility.length > 0) {
|
|
41
49
|
try {
|
|
42
50
|
const visibilityResult = await checkVisibility(config.allowedVisibility);
|
|
43
51
|
if (visibilityResult && !visibilityResult.allowed) {
|
|
44
52
|
printError(visibilityResult.reason);
|
|
45
|
-
|
|
53
|
+
throw new ExitError(1);
|
|
46
54
|
}
|
|
47
55
|
} catch (error) {
|
|
56
|
+
if (error instanceof ExitError) throw error;
|
|
48
57
|
printError(
|
|
49
58
|
`Failed to check repository visibility. Ensure 'gh' CLI is installed and authenticated.\n ${error instanceof Error ? error.message : String(error)}`
|
|
50
59
|
);
|
|
51
|
-
|
|
60
|
+
throw new ExitError(1);
|
|
52
61
|
}
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -58,16 +67,19 @@ export function createPushCommand(): Command {
|
|
|
58
67
|
|
|
59
68
|
if (options.dryRun) {
|
|
60
69
|
printSuccess("Dry run: would push (checks bypassed)");
|
|
61
|
-
|
|
70
|
+
throw new ExitError(0);
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
const result = await execPush(gitArgs);
|
|
65
74
|
if (result.success) {
|
|
75
|
+
if (result.output) {
|
|
76
|
+
console.log(result.output);
|
|
77
|
+
}
|
|
66
78
|
printSuccess("Push successful");
|
|
67
|
-
|
|
79
|
+
return;
|
|
68
80
|
} else {
|
|
69
81
|
printError(`Push failed: ${result.output}`);
|
|
70
|
-
|
|
82
|
+
throw new ExitError(1);
|
|
71
83
|
}
|
|
72
84
|
}
|
|
73
85
|
|
|
@@ -88,45 +100,46 @@ export function createPushCommand(): Command {
|
|
|
88
100
|
if (confirmed) {
|
|
89
101
|
if (options.dryRun) {
|
|
90
102
|
printSuccess("Dry run: would push (user confirmed)");
|
|
91
|
-
|
|
103
|
+
throw new ExitError(0);
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
const result = await execPush(gitArgs);
|
|
95
107
|
if (result.success) {
|
|
108
|
+
if (result.output) {
|
|
109
|
+
console.log(result.output);
|
|
110
|
+
}
|
|
96
111
|
printSuccess("Push successful");
|
|
97
|
-
|
|
112
|
+
return;
|
|
98
113
|
} else {
|
|
99
114
|
printError(`Push failed: ${result.output}`);
|
|
100
|
-
|
|
115
|
+
throw new ExitError(1);
|
|
101
116
|
}
|
|
102
117
|
} else {
|
|
103
118
|
printError("Push cancelled by user");
|
|
104
|
-
|
|
119
|
+
throw new ExitError(1);
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
throw new ExitError(1);
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
// チェック通過、pushを実行
|
|
112
127
|
if (options.dryRun) {
|
|
113
128
|
printSuccess("Dry run: would push");
|
|
114
|
-
|
|
129
|
+
throw new ExitError(0);
|
|
115
130
|
}
|
|
116
131
|
|
|
117
132
|
const result = await execPush(gitArgs);
|
|
118
133
|
if (result.success) {
|
|
134
|
+
if (result.output) {
|
|
135
|
+
console.log(result.output);
|
|
136
|
+
}
|
|
119
137
|
printSuccess("Push successful");
|
|
120
|
-
|
|
138
|
+
return;
|
|
121
139
|
} else {
|
|
122
140
|
printError(`Push failed: ${result.output}`);
|
|
123
|
-
|
|
141
|
+
throw new ExitError(1);
|
|
124
142
|
}
|
|
125
|
-
}
|
|
126
|
-
printError(
|
|
127
|
-
`Push failed: ${error instanceof Error ? error.message : String(error)}`
|
|
128
|
-
);
|
|
129
|
-
process.exit(1);
|
|
130
|
-
}
|
|
143
|
+
});
|
|
131
144
|
});
|
|
132
145
|
}
|
package/src/config.ts
CHANGED
|
@@ -4,6 +4,9 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as jsonc from "jsonc-parser";
|
|
5
5
|
import { ConfigSchema, ConfigError, type Config } from "./types";
|
|
6
6
|
|
|
7
|
+
const CONFIG_SCHEMA_URL =
|
|
8
|
+
"https://raw.githubusercontent.com/shoppingjaws/safe-push/main/config.schema.json";
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* 設定ファイルのデフォルトパス
|
|
9
12
|
*/
|
|
@@ -92,11 +95,16 @@ export function saveConfig(config: Config, configPath?: string): void {
|
|
|
92
95
|
? `,\n // 許可するリポジトリ visibility: "public" | "private" | "internal"\n "allowedVisibility": ${JSON.stringify(config.allowedVisibility)}`
|
|
93
96
|
: "";
|
|
94
97
|
|
|
98
|
+
const traceSection = config.trace
|
|
99
|
+
? `,\n // トレーシング: "otlp" | "console" (省略で無効)\n "trace": "${config.trace}"`
|
|
100
|
+
: "";
|
|
101
|
+
|
|
95
102
|
const content = `{
|
|
103
|
+
"$schema": "${CONFIG_SCHEMA_URL}",
|
|
96
104
|
// 禁止エリア(Globパターン)
|
|
97
105
|
"forbiddenPaths": ${JSON.stringify(config.forbiddenPaths, null, 4).replace(/\n/g, "\n ")},
|
|
98
106
|
// 禁止時の動作: "error" | "prompt"
|
|
99
|
-
"onForbidden": "${config.onForbidden}"${allowedVisibilitySection}
|
|
107
|
+
"onForbidden": "${config.onForbidden}"${allowedVisibilitySection}${traceSection}
|
|
100
108
|
}
|
|
101
109
|
`;
|
|
102
110
|
|
package/src/git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
2
|
import { GitError } from "./types";
|
|
3
|
+
import { withSpan } from "./telemetry";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Gitコマンドを実行し、結果を返す
|
|
@@ -28,40 +29,48 @@ async function execGit(args: string[]): Promise<string> {
|
|
|
28
29
|
* 現在のブランチ名を取得
|
|
29
30
|
*/
|
|
30
31
|
export async function getCurrentBranch(): Promise<string> {
|
|
31
|
-
return
|
|
32
|
+
return withSpan("safe-push.git.getCurrentBranch", async () => {
|
|
33
|
+
return execGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* リモートに存在しない新規ブランチかどうかを判定
|
|
36
39
|
*/
|
|
37
40
|
export async function isNewBranch(remote = "origin"): Promise<boolean> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
return withSpan("safe-push.git.isNewBranch", async () => {
|
|
42
|
+
const branch = await getCurrentBranch();
|
|
43
|
+
try {
|
|
44
|
+
await execGit(["rev-parse", "--verify", `${remote}/${branch}`]);
|
|
45
|
+
return false;
|
|
46
|
+
} catch {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
53
|
* 最後のコミットの作者メールアドレスを取得
|
|
49
54
|
*/
|
|
50
55
|
export async function getLastCommitAuthorEmail(): Promise<string> {
|
|
51
|
-
return
|
|
56
|
+
return withSpan("safe-push.git.getLastCommitAuthorEmail", async () => {
|
|
57
|
+
return execGit(["log", "-1", "--format=%ae"]);
|
|
58
|
+
});
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
62
|
* ローカルのGit設定からメールアドレスを取得
|
|
56
63
|
*/
|
|
57
64
|
export async function getLocalEmail(): Promise<string> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
return withSpan("safe-push.git.getLocalEmail", async () => {
|
|
66
|
+
// 環境変数が設定されている場合はそちらを優先
|
|
67
|
+
const envEmail = process.env.SAFE_PUSH_EMAIL;
|
|
68
|
+
if (envEmail) {
|
|
69
|
+
return envEmail;
|
|
70
|
+
}
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
return execGit(["config", "user.email"]);
|
|
73
|
+
});
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/**
|
|
@@ -69,34 +78,36 @@ export async function getLocalEmail(): Promise<string> {
|
|
|
69
78
|
* 新規ブランチの場合はmainまたはmasterとの差分を取得
|
|
70
79
|
*/
|
|
71
80
|
export async function getDiffFiles(remote = "origin"): Promise<string[]> {
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
return withSpan("safe-push.git.getDiffFiles", async () => {
|
|
82
|
+
const branch = await getCurrentBranch();
|
|
83
|
+
const isNew = await isNewBranch(remote);
|
|
74
84
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
await execGit(["rev-parse", "--verify", `${remote}/main`]);
|
|
80
|
-
baseBranch = `${remote}/main`;
|
|
81
|
-
} catch {
|
|
85
|
+
let baseBranch: string;
|
|
86
|
+
if (isNew) {
|
|
87
|
+
// 新規ブランチの場合、mainまたはmasterを基準にする
|
|
82
88
|
try {
|
|
83
|
-
await execGit(["rev-parse", "--verify", `${remote}/
|
|
84
|
-
baseBranch = `${remote}/
|
|
89
|
+
await execGit(["rev-parse", "--verify", `${remote}/main`]);
|
|
90
|
+
baseBranch = `${remote}/main`;
|
|
85
91
|
} catch {
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
try {
|
|
93
|
+
await execGit(["rev-parse", "--verify", `${remote}/master`]);
|
|
94
|
+
baseBranch = `${remote}/master`;
|
|
95
|
+
} catch {
|
|
96
|
+
// mainもmasterもない場合は空の配列を返す
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
88
99
|
}
|
|
100
|
+
} else {
|
|
101
|
+
baseBranch = `${remote}/${branch}`;
|
|
89
102
|
}
|
|
90
|
-
} else {
|
|
91
|
-
baseBranch = `${remote}/${branch}`;
|
|
92
|
-
}
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
const output = await execGit(["diff", "--name-only", baseBranch, "HEAD"]);
|
|
105
|
+
if (!output) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
98
108
|
|
|
99
|
-
|
|
109
|
+
return output.split("\n").filter(Boolean);
|
|
110
|
+
});
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/**
|
|
@@ -106,95 +117,120 @@ export async function execPush(
|
|
|
106
117
|
args: string[] = [],
|
|
107
118
|
remote = "origin"
|
|
108
119
|
): Promise<{ success: boolean; output: string }> {
|
|
109
|
-
|
|
120
|
+
return withSpan("safe-push.git.execPush", async (span) => {
|
|
121
|
+
let pushArgs: string[];
|
|
110
122
|
|
|
111
|
-
|
|
112
|
-
|
|
123
|
+
// ユーザーがremote/refspecを明示的に指定したか判定
|
|
124
|
+
const hasUserRefspec = args.some((arg) => !arg.startsWith("-"));
|
|
113
125
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
if (hasUserRefspec) {
|
|
127
|
+
// ユーザー指定のremote/refspecをそのまま使用
|
|
128
|
+
pushArgs = ["push", ...args];
|
|
129
|
+
} else {
|
|
130
|
+
// 自動でremote/branchを決定
|
|
131
|
+
const branch = await getCurrentBranch();
|
|
132
|
+
const isNew = await isNewBranch(remote);
|
|
121
133
|
|
|
122
|
-
|
|
134
|
+
pushArgs = ["push"];
|
|
123
135
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
// 新規ブランチ、またはユーザーが-uを指定した場合に追加
|
|
137
|
+
const hasSetUpstream = args.some(
|
|
138
|
+
(a) => a === "-u" || a === "--set-upstream"
|
|
139
|
+
);
|
|
140
|
+
if (isNew || hasSetUpstream) {
|
|
141
|
+
pushArgs.push("-u");
|
|
142
|
+
}
|
|
131
143
|
|
|
132
|
-
|
|
144
|
+
pushArgs.push(remote, branch);
|
|
133
145
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
146
|
+
// -u/--set-upstream以外のフラグを追加
|
|
147
|
+
const remainingFlags = args.filter(
|
|
148
|
+
(a) => a !== "-u" && a !== "--set-upstream"
|
|
149
|
+
);
|
|
150
|
+
pushArgs.push(...remainingFlags);
|
|
151
|
+
}
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
let result: { success: boolean; output: string };
|
|
154
|
+
try {
|
|
155
|
+
const proc = await $`git ${pushArgs}`.quiet();
|
|
156
|
+
const stdout = proc.stdout.toString().trim();
|
|
157
|
+
const stderr = proc.stderr.toString().trim();
|
|
158
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
159
|
+
result = { success: true, output };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
162
|
+
const stderr =
|
|
163
|
+
"stderr" in error ? String((error as { stderr: unknown }).stderr).trim() : "";
|
|
164
|
+
const stdout =
|
|
165
|
+
"stdout" in error ? String((error as { stdout: unknown }).stdout).trim() : "";
|
|
166
|
+
const output = [stdout, stderr].filter(Boolean).join("\n");
|
|
167
|
+
result = { success: false, output: output || `Push failed with exit code ${(error as { exitCode: number }).exitCode}` };
|
|
168
|
+
} else {
|
|
169
|
+
result = {
|
|
170
|
+
success: false,
|
|
171
|
+
output: error instanceof Error ? error.message : String(error),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
147
174
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
175
|
+
|
|
176
|
+
span.addEvent("push.result", {
|
|
177
|
+
success: result.success,
|
|
178
|
+
hasUserRefspec,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
153
183
|
}
|
|
154
184
|
|
|
155
185
|
/**
|
|
156
186
|
* リポジトリの visibility を取得(gh CLI を使用)
|
|
157
187
|
*/
|
|
158
188
|
export async function getRepoVisibility(): Promise<string> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
return withSpan("safe-push.git.getRepoVisibility", async () => {
|
|
190
|
+
const command = "gh repo view --json visibility --jq '.visibility'";
|
|
191
|
+
try {
|
|
192
|
+
const result = await $`gh repo view --json visibility --jq .visibility`.quiet();
|
|
193
|
+
return result.stdout.toString().trim().toLowerCase();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
196
|
+
const exitCode = (error as { exitCode: number }).exitCode;
|
|
197
|
+
const stderr =
|
|
198
|
+
"stderr" in error ? String((error as { stderr: unknown }).stderr) : "";
|
|
199
|
+
throw new GitError(
|
|
200
|
+
`Failed to get repository visibility: ${stderr || command}`,
|
|
201
|
+
command,
|
|
202
|
+
exitCode
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
throw new GitError(`Failed to get repository visibility: ${command}`, command, null);
|
|
173
206
|
}
|
|
174
|
-
|
|
175
|
-
}
|
|
207
|
+
});
|
|
176
208
|
}
|
|
177
209
|
|
|
178
210
|
/**
|
|
179
211
|
* Gitリポジトリ内かどうかを確認
|
|
180
212
|
*/
|
|
181
213
|
export async function isGitRepository(): Promise<boolean> {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
214
|
+
return withSpan("safe-push.git.isGitRepository", async () => {
|
|
215
|
+
try {
|
|
216
|
+
await execGit(["rev-parse", "--git-dir"]);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
188
222
|
}
|
|
189
223
|
|
|
190
224
|
/**
|
|
191
225
|
* コミットが存在するか確認
|
|
192
226
|
*/
|
|
193
227
|
export async function hasCommits(): Promise<boolean> {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
228
|
+
return withSpan("safe-push.git.hasCommits", async () => {
|
|
229
|
+
try {
|
|
230
|
+
await execGit(["rev-parse", "HEAD"]);
|
|
231
|
+
return true;
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
200
236
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,16 +3,54 @@ import { Command } from "commander";
|
|
|
3
3
|
import { createCheckCommand } from "./commands/check";
|
|
4
4
|
import { createPushCommand } from "./commands/push";
|
|
5
5
|
import { createConfigCommand } from "./commands/config";
|
|
6
|
+
import { createMcpCommand } from "./commands/mcp";
|
|
7
|
+
import { initTelemetry, shutdownTelemetry } from "./telemetry";
|
|
8
|
+
import { loadConfig } from "./config";
|
|
9
|
+
import { ExitError } from "./types";
|
|
10
|
+
import packageJson from "../package.json";
|
|
6
11
|
|
|
7
12
|
const program = new Command();
|
|
8
13
|
|
|
9
14
|
program
|
|
10
15
|
.name("safe-push")
|
|
11
16
|
.description("Git push safety checker - blocks pushes to forbidden areas")
|
|
12
|
-
.version(
|
|
17
|
+
.version(packageJson.version)
|
|
18
|
+
.option("--trace [exporter]", "Enable OpenTelemetry tracing (otlp|console)");
|
|
19
|
+
|
|
20
|
+
program.hook("preAction", async () => {
|
|
21
|
+
const traceOpt = program.opts().trace;
|
|
22
|
+
|
|
23
|
+
// CLI フラグ優先、なければ config を参照
|
|
24
|
+
let exporter: "otlp" | "console" | undefined;
|
|
25
|
+
if (traceOpt) {
|
|
26
|
+
exporter = traceOpt === "otlp" ? "otlp" : "console";
|
|
27
|
+
} else {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
if (config.trace) {
|
|
30
|
+
exporter = config.trace;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (exporter) {
|
|
35
|
+
await initTelemetry(exporter);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
13
38
|
|
|
14
39
|
program.addCommand(createCheckCommand());
|
|
15
40
|
program.addCommand(createPushCommand());
|
|
16
41
|
program.addCommand(createConfigCommand());
|
|
42
|
+
program.addCommand(createMcpCommand());
|
|
17
43
|
|
|
18
|
-
|
|
44
|
+
let exitCode = 0;
|
|
45
|
+
try {
|
|
46
|
+
await program.parseAsync();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof ExitError) {
|
|
49
|
+
exitCode = error.exitCode;
|
|
50
|
+
} else {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
} finally {
|
|
54
|
+
await shutdownTelemetry();
|
|
55
|
+
}
|
|
56
|
+
process.exit(exitCode);
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { trace, type Span, SpanStatusCode, type Attributes } from "@opentelemetry/api";
|
|
2
|
+
|
|
3
|
+
const TRACER_NAME = "safe-push";
|
|
4
|
+
|
|
5
|
+
let shutdownFn: (() => Promise<void>) | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* OpenTelemetry トレーシングを初期化する。
|
|
9
|
+
* SDK は動的 import で読み込み、--trace 未使用時はゼロオーバーヘッドとする。
|
|
10
|
+
*/
|
|
11
|
+
export async function initTelemetry(exporter: "otlp" | "console"): Promise<void> {
|
|
12
|
+
const { BasicTracerProvider, SimpleSpanProcessor } = await import(
|
|
13
|
+
"@opentelemetry/sdk-trace-base"
|
|
14
|
+
);
|
|
15
|
+
const { resourceFromAttributes } = await import("@opentelemetry/resources");
|
|
16
|
+
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = await import(
|
|
17
|
+
"@opentelemetry/semantic-conventions"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
let spanExporter;
|
|
21
|
+
if (exporter === "otlp") {
|
|
22
|
+
const { OTLPTraceExporter } = await import(
|
|
23
|
+
"@opentelemetry/exporter-trace-otlp-http"
|
|
24
|
+
);
|
|
25
|
+
spanExporter = new OTLPTraceExporter();
|
|
26
|
+
} else {
|
|
27
|
+
const { ConsoleSpanExporter } = await import(
|
|
28
|
+
"@opentelemetry/sdk-trace-base"
|
|
29
|
+
);
|
|
30
|
+
spanExporter = new ConsoleSpanExporter();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const resource = resourceFromAttributes({
|
|
34
|
+
[ATTR_SERVICE_NAME]: "safe-push",
|
|
35
|
+
[ATTR_SERVICE_VERSION]: "0.3.0",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const provider = new BasicTracerProvider({
|
|
39
|
+
resource,
|
|
40
|
+
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
|
|
41
|
+
});
|
|
42
|
+
trace.setGlobalTracerProvider(provider);
|
|
43
|
+
|
|
44
|
+
shutdownFn = async () => {
|
|
45
|
+
await provider.forceFlush();
|
|
46
|
+
await provider.shutdown();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* トレーシングをシャットダウンし、バッファ内のスパンをフラッシュする。
|
|
52
|
+
*/
|
|
53
|
+
export async function shutdownTelemetry(): Promise<void> {
|
|
54
|
+
if (shutdownFn) {
|
|
55
|
+
await shutdownFn();
|
|
56
|
+
shutdownFn = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* トレーサーを取得する。
|
|
62
|
+
* initTelemetry() 未呼び出し時は no-op tracer が返る。
|
|
63
|
+
*/
|
|
64
|
+
export function getTracer() {
|
|
65
|
+
return trace.getTracer(TRACER_NAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* スパンを作成し、関数を実行する。エラー時はスパンにエラーを記録して再 throw する。
|
|
70
|
+
*/
|
|
71
|
+
export async function withSpan<T>(
|
|
72
|
+
name: string,
|
|
73
|
+
fn: (span: Span) => Promise<T>,
|
|
74
|
+
attributes?: Attributes,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const tracer = getTracer();
|
|
77
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
78
|
+
try {
|
|
79
|
+
const result = await fn(span);
|
|
80
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
81
|
+
return result;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
span.setStatus({
|
|
84
|
+
code: SpanStatusCode.ERROR,
|
|
85
|
+
message: error instanceof Error ? error.message : String(error),
|
|
86
|
+
});
|
|
87
|
+
span.recordException(
|
|
88
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
89
|
+
);
|
|
90
|
+
throw error;
|
|
91
|
+
} finally {
|
|
92
|
+
span.end();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,12 @@ export type OnForbidden = z.infer<typeof OnForbiddenSchema>;
|
|
|
12
12
|
export const RepoVisibilitySchema = z.enum(["public", "private", "internal"]);
|
|
13
13
|
export type RepoVisibility = z.infer<typeof RepoVisibilitySchema>;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* トレースエクスポーター
|
|
17
|
+
*/
|
|
18
|
+
export const TraceExporterSchema = z.enum(["otlp", "console"]);
|
|
19
|
+
export type TraceExporter = z.infer<typeof TraceExporterSchema>;
|
|
20
|
+
|
|
15
21
|
/**
|
|
16
22
|
* 設定ファイルのスキーマ
|
|
17
23
|
*/
|
|
@@ -19,6 +25,7 @@ export const ConfigSchema = z.object({
|
|
|
19
25
|
forbiddenPaths: z.array(z.string()).default([".github/"]),
|
|
20
26
|
onForbidden: OnForbiddenSchema.default("error"),
|
|
21
27
|
allowedVisibility: z.array(RepoVisibilitySchema).optional(),
|
|
28
|
+
trace: TraceExporterSchema.optional(),
|
|
22
29
|
});
|
|
23
30
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
24
31
|
|
|
@@ -67,3 +74,14 @@ export class ConfigError extends Error {
|
|
|
67
74
|
this.name = "ConfigError";
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* コマンドハンドラから exit code を伝搬するためのエラー。
|
|
80
|
+
* process.exit() を直接呼ばず、index.ts で shutdown 後に exit する。
|
|
81
|
+
*/
|
|
82
|
+
export class ExitError extends Error {
|
|
83
|
+
constructor(public readonly exitCode: number) {
|
|
84
|
+
super(`Process exiting with code ${exitCode}`);
|
|
85
|
+
this.name = "ExitError";
|
|
86
|
+
}
|
|
87
|
+
}
|