@yoonion/mimi-seed-mcp 0.21.0 → 0.21.2

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
@@ -128,7 +128,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
128
128
  ---
129
129
 
130
130
  <!-- generated:readme-tools-heading:start — edit scripts/docs-spec.mjs, then npm run plugin:sync -->
131
- ## 제공 도구 (150+ 개 · 22개 영역)
131
+ ## 제공 도구 (150+ 개 · 23개 영역)
132
132
  <!-- generated:readme-tools-heading:end -->
133
133
 
134
134
  <!-- generated:readme-tools-table:start — edit scripts/docs-spec.mjs, then npm run plugin:sync -->
@@ -139,9 +139,10 @@ export ANTHROPIC_API_KEY=sk-ant-...
139
139
  | Firebase | 21 | `firebase_create_project` / `firebase_get_remote_config_overview` / `firebase_get_android_config` / `firebase_create_ios_app` |
140
140
  | AdMob | 7 | `admob_list_apps` / `admob_create_ad_unit` / `admob_get_today_earnings` / `admob_get_report` |
141
141
  | CI (GitHub Actions / GitLab) | 6 | `ci_trigger_build` / `ci_get_build_status` / `ci_list_workflows` / `ci_cancel_build` |
142
- | Jenkins (크리덴셜 + 잡) | 10 | `jenkins_create_credential` / `jenkins_upload_keystore` / `jenkins_create_job` / `jenkins_update_job` |
142
+ | Jenkins (크리덴셜 + 잡 + 빌드) | 13 | `jenkins_create_credential` / `jenkins_upload_keystore` / `jenkins_create_job` / `jenkins_update_job` / `jenkins_trigger_build` |
143
143
  | GA4 (Google Analytics 4) | 8 | `ga4_create_property` / `ga4_create_data_stream` / `ga4_plan_bigquery_link` / `ga4_create_bigquery_link` / `ga4_run_report` |
144
144
  | Search Console | 6 | `gsc_inspect_url` / `gsc_search_analytics` / `gsc_submit_sitemap` |
145
+ | 네이버 서치어드바이저 | 2 | `naver_check_page` / `naver_indexnow_submit` |
145
146
  | Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
146
147
  | Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
147
148
  | Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
@@ -78,10 +78,11 @@ row for the job; batching two rows in one `select:` call is fine.
78
78
  | App Store weekly growth insight | `select:appstore_get_weekly_insight,appstore_get_sales_report` |
79
79
  | AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
80
80
  | Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
81
- | Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
81
+ | Search indexing (Search Console / Naver) | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics,naver_check_page,naver_indexnow_submit` |
82
82
  | Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_video,threads_post_carousel` |
83
83
  | TikTok Business video publish | `select:tiktok_business_auth_status,tiktok_business_get_account,tiktok_business_get_video_settings,tiktok_business_plan_video_post,tiktok_business_publish_video,tiktok_business_get_publish_status,tiktok_business_list_publish_audits` |
84
84
  | Jenkins credentials + jobs | `select:jenkins_status,jenkins_save_config,jenkins_list_credentials,jenkins_create_credential,jenkins_delete_credential,jenkins_upload_keystore,jenkins_upload_playstore_sa,jenkins_list_jobs,jenkins_get_job_config,jenkins_create_job,jenkins_update_job` |
85
+ | Jenkins build run + track | `select:jenkins_status,jenkins_trigger_build,jenkins_get_queue_item,jenkins_get_build_status` |
85
86
  | CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds,ci_cancel_build` |
86
87
  | Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_create_credential,jenkins_upload_playstore_sa` |
87
88
  | Service account end-to-end | `select:iam_list_service_accounts,iam_create_service_account,iam_list_keys,iam_create_key,iam_add_iam_policy_binding,setup_playstore_connection,playstore_register_service_account,playstore_verify_service_account,playstore_list_service_accounts,playstore_delete_service_account` |
@@ -159,6 +160,7 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
159
160
  | `play-service-account.json` | default/legacy Play SA (fallback when no per-package match) |
160
161
  | `bigquery-service-account.json` | BigQuery SA (exempt from Workspace reauth; OAuth is the fallback) |
161
162
  | `jenkins.json`, `ci.json` | Jenkins / GitHub-GitLab CI connection |
163
+ | `jenkins-build-requests/` | `jenkins_trigger_build` dispatch records per `request_id` (hashes + queue id only; do not delete to retry) |
162
164
  | `google-ads.json` | Google Ads developer token + customer id |
163
165
  | `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
164
166
  | `social-profiles/<profile>.json` | Named Facebook/Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
@@ -219,7 +221,7 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
219
221
  | **Firebase** | `firebase_create_project` · `firebase_create_android_app` · `firebase_create_ios_app` · `firebase_get_android_config` · `firebase_enable_service` · `firebase_enable_common_services` · `firebase_get_remote_config_overview` · `firebase_list_*_apps` |
220
222
  | **AdMob** | `admob_create_app` · `admob_create_ad_unit` · `admob_list_ad_units` · `admob_get_today_earnings` · `admob_get_report` |
221
223
  | **CI/CD** | `ci_trigger_build` · `ci_get_build_status` · `ci_list_workflows` (**GitHub Actions / GitLab only**) |
222
- | **Jenkins** (credentials + jobs) | `jenkins_status` · `jenkins_save_config` · `jenkins_create_credential` · `jenkins_upload_keystore` · `jenkins_upload_playstore_sa` · `jenkins_create_job` · `jenkins_update_job` |
224
+ | **Jenkins** (credentials + jobs + builds) | `jenkins_status` · `jenkins_save_config` · `jenkins_create_credential` · `jenkins_upload_keystore` · `jenkins_upload_playstore_sa` · `jenkins_create_job` · `jenkins_update_job` · `jenkins_trigger_build` · `jenkins_get_queue_item` · `jenkins_get_build_status` |
223
225
  | **Google Cloud IAM** | `iam_create_service_account` · `iam_create_key` · `iam_add_iam_policy_binding` |
224
226
  | **BigQuery** | `bigquery_run_query` · `bigquery_list_datasets` · `bigquery_get_table_schema` |
225
227
  | **GA4** | `ga4_list_properties` · `ga4_create_property` · `ga4_create_data_stream` · `ga4_run_report` · `ga4_plan_bigquery_link` · `ga4_create_bigquery_link` |
@@ -305,17 +307,17 @@ contact sheet; codec validation alone is not a quality pass.
305
307
  7. Preview then confirm `video_render`; poll `video_job_status`, then run `video_validate` on the completed MP4.
306
308
 
307
309
  > **Mimi Seed does not compile app binaries.** It manages metadata, store releases, and
308
- > CI/Jenkins *credentials and job definitions* — not Xcode/Gradle builds. To produce an
309
- > `.ipa`/`.aab`, use EAS, Xcode, or a CI/Jenkins job. There is **no `jenkins_trigger_build`
310
- > tool**; trigger a Jenkins job via its REST API and use the `jenkins_*` tools for
311
- > credentials and job configs.
310
+ > CI/Jenkins credentials and job definitions, and it can *start* an existing CI or Jenkins job —
311
+ > the job does the Xcode/Gradle build. To produce an `.ipa`/`.aab`, use EAS, Xcode, or a CI/Jenkins job.
312
+ > For Jenkins: `jenkins_trigger_build` (preview, then `confirm: true`) → `jenkins_get_queue_item` for the exact
313
+ > build number → `jenkins_get_build_status`. Never infer the build from `lastBuild` — it may belong to another request.
312
314
 
313
315
  ---
314
316
 
315
317
  ## 5. Safety — irreversible actions need explicit confirmation
316
318
 
317
319
  Every tool marked **D** in the [tool catalog](domain/tool-catalog.md) — submit/promote/release, deletes,
318
- public posts and review replies, IAM keys and bindings, Jenkins job/credential overwrites, Play service-account
320
+ public posts and review replies, IAM keys and bindings, Jenkins build triggers and job/credential overwrites, Play service-account
319
321
  setup, beta invites — is
320
322
  **confirm-gated by the server**, and its MCP annotations say `destructiveHint: true`. Call order:
321
323
 
@@ -335,12 +337,22 @@ not **D** (private is reversible), but public/unlisted still needs `confirmVisib
335
337
 
336
338
  Never pass `confirm: true` on the first call, and never retry an uncertain public write automatically.
337
339
 
340
+ `jenkins_trigger_build` also takes a caller-chosen `request_id` (one per logical build request). The dry-run does
341
+ **not** reserve it, so preview and confirm with the same `request_id`. Once a confirmed call has used it, every
342
+ later call with that `request_id` returns the recorded result (`replayed: true`) instead of POSTing again — the
343
+ record lives under `~/.mimi-seed/jenkins-build-requests/`, per Jenkins URL and user, on this machine only.
344
+ `state: "pending"` means another call with that `request_id` is still in flight — call again with the same
345
+ `request_id` shortly. `state: "unknown"` means the outcome could not be confirmed — check Jenkins before doing
346
+ anything; do not retry with a new `request_id`. `persisted: false` means only the local record failed: a `queued`
347
+ build is already in the queue, so track its `queue_id` and do not retrigger.
348
+
338
349
  | Action | Why |
339
350
  |--------|-----|
340
351
  | `playstore_submit_release` / `playstore_promote_release` with `status=completed` | Starts Google review / full rollout. Near-irreversible. |
341
352
  | `appstore_submit_for_review`, `appstore_release_version` | Submits to Apple review / publishes immediately. |
342
353
  | `appstore_delete_screenshot_set`, `playstore_delete_all_images`, `playstore_replace_images` | Deletes assets. |
343
354
  | `playstore_delete_product`, `jenkins_delete_credential`, `firebase_delete_*_app` | Destructive. |
355
+ | `jenkins_trigger_build` | Runs a Jenkins job, which may deploy or publish. |
344
356
  | `facebook_post_*`, `instagram_post_*`, `threads_post*`, `*_reply_review`, `youtube_reply_comment` | Public, outward-facing. |
345
357
  | `iam_create_key`, `iam_add_iam_policy_binding` | Issues a permanent credential / changes project IAM. |
346
358
 
@@ -384,13 +396,16 @@ General rules:
384
396
  changes you saved-but-didn't-publish in the Play Console UI. Google's own docs warn
385
397
  against editing the same app with both tools at once. Do all listing writes via the
386
398
  API, or finish & publish your Console edits first — never interleave them.
387
- - **`ci_*` is GitHub/GitLab only.** It does not trigger Jenkins builds.
399
+ - **`ci_*` is GitHub/GitLab only.** Jenkins builds use the separate `jenkins_trigger_build` →
400
+ `jenkins_get_queue_item` → `jenkins_get_build_status` flow.
388
401
  - **Identifiers are validated at the schema.** `packageName` / `package_name(s)` must look like an
389
402
  Android application id (`com.example.app`) and `bundleId` like an iOS bundle id; anything else
390
403
  (`../x`, slashes, empty segments) is rejected with `Input validation error` before the tool runs.
391
404
  Google resource ids (project, app, service-account email, AdMob/GA4/billing account) must be a single segment
392
405
  (`A-Z a-z 0-9 - _ . : @`) — `../` is refused before any request. BigQuery ids follow BigQuery's own naming
393
406
  rules (table names may contain Unicode and spaces), and Play ids may not be exactly `.` or `..`.
407
+ Jenkins job paths (`folder/job`) are encoded per segment; `.`/`..` segments are refused, and the build tools
408
+ also refuse empty segments, backslashes, and control characters.
394
409
  - **FFmpeg location is configuration, not a tool argument.** The video/TikTok tools no longer take
395
410
  `ffmpegPath`; set `MIMI_SEED_FFMPEG_PATH` / `MIMI_SEED_FFPROBE_PATH` or put FFmpeg on `PATH`.
396
411
  - **Reward/cash-out apps** are a sensitive Play category — flag policy implications to
@@ -121,10 +121,13 @@ export async function createAppInfoLocalization(appId, locale, fields) {
121
121
  };
122
122
  }
123
123
  export async function listCustomerReviews(appId, opts = {}) {
124
+ // `fields[customerReviews]` 는 JSON:API sparse fieldset 이라 관계에도 적용된다 — `response` 를 빼면
125
+ // relationships.response 가 응답에서 사라져 include 된 답변을 리뷰에 붙일 수 없고, 모든 리뷰가 미답변
126
+ // (response: null)으로 보인다. 그러면 "미답변에만 답하기" 흐름이 기존 답변을 교체한다.
124
127
  const params = {
125
128
  'sort': '-createdDate',
126
129
  'limit': String(opts.limit ?? 50),
127
- 'fields[customerReviews]': 'rating,title,body,reviewerNickname,createdDate,territory',
130
+ 'fields[customerReviews]': 'rating,title,body,reviewerNickname,createdDate,territory,response',
128
131
  'include': 'response',
129
132
  'fields[customerReviewResponses]': 'responseBody,lastModifiedDate,state',
130
133
  };
@@ -0,0 +1,93 @@
1
+ import { z } from 'zod';
2
+ import type { JenkinsConfig } from './config.js';
3
+ export declare const buildJobSchema: z.ZodEffects<z.ZodString, string, string>;
4
+ export declare const buildIdSchema: z.ZodNumber;
5
+ export declare const requestIdSchema: z.ZodString;
6
+ export declare const buildParametersSchema: z.ZodRecord<z.ZodString, z.ZodString>;
7
+ export declare const triggerBuildSchema: z.ZodObject<{
8
+ job: z.ZodEffects<z.ZodString, string, string>;
9
+ request_id: z.ZodString;
10
+ parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
+ }, "strip", z.ZodTypeAny, {
12
+ job: string;
13
+ request_id: string;
14
+ parameters?: Record<string, string> | undefined;
15
+ }, {
16
+ job: string;
17
+ request_id: string;
18
+ parameters?: Record<string, string> | undefined;
19
+ }>;
20
+ type TriggerInput = z.infer<typeof triggerBuildSchema>;
21
+ /**
22
+ * 예약이 이보다 젊으면 결과가 없는 재호출을 `pending`(다른 호출이 처리 중)으로, 더 오래됐으면 `unknown`
23
+ * (도중에 죽음)으로 본다. crumb 조회(1회, 60초) + POST(1회, 60초) 최악 소요보다 넉넉하게 잡는다.
24
+ */
25
+ export declare const PENDING_WINDOW_MS: number;
26
+ /** 예약·receipt 가 사는 곳. os.homedir() 를 호출 시점에 읽는다 (테스트가 HOME 을 바꾼다). */
27
+ export declare function buildRequestsDir(): string;
28
+ /**
29
+ * 파라미터 지문용 설치별 비밀 키 (~/.mimi-seed/jenkins-build-requests/.key, 0600).
30
+ *
31
+ * 지문을 평문 sha256 으로 두면 짧은 파라미터(버전 번호·브랜치 이름)는 receipt 만 보고 사전 대입으로 되찾을 수
32
+ * 있다. 그래서 HMAC 으로 소금 친다. 처음 쓸 때 공용 writer 로 temp 에 쓰고 link(2) 로 "없을 때만" 게시한다 —
33
+ * 두 프로세스가 동시에 만들어도 한 키만 살아남고, 진 쪽은 이긴 키를 다시 읽는다 (각자 다른 키로 지문을 만들면
34
+ * 같은 요청이 "다른 파라미터" 로 거절된다). 키가 사라지면 기존 예약의 재호출은 지문 불일치로 거절된다 — 다시
35
+ * 보내지는 않는다. 예약 디렉터리 이름은 키와 무관한 sha256 이라 키를 지워도 중복 발송이 풀리지 않는다.
36
+ */
37
+ export declare function fingerprintKey(): Buffer;
38
+ /**
39
+ * Local durable at-most-once dispatch, not Jenkins-side exactly-once delivery.
40
+ * Keep reservations indefinitely once the POST may have been sent: a crash after POST must never become a retry.
41
+ * Only an HMAC fingerprint and bounded response metadata are stored, never parameters or tokens.
42
+ * Once the POST may have been sent this function never throws — it always returns what it knows.
43
+ */
44
+ export declare function triggerBuild(cfg: JenkinsConfig, input: TriggerInput): Promise<{
45
+ message?: string | undefined;
46
+ http_status?: number | undefined;
47
+ queue_id?: number | undefined;
48
+ queue_url?: string | undefined;
49
+ request_id: string;
50
+ state: "pending" | "unknown" | "queued" | "rejected";
51
+ replayed: boolean;
52
+ } | {
53
+ persisted: boolean;
54
+ message: string;
55
+ http_status?: number | undefined;
56
+ queue_id?: number | undefined;
57
+ queue_url?: string | undefined;
58
+ request_id: string;
59
+ state: "pending" | "unknown" | "queued" | "rejected";
60
+ replayed: boolean;
61
+ }>;
62
+ export declare function getQueueItem(cfg: JenkinsConfig, queueId: number): Promise<{
63
+ queue_id: number;
64
+ state: string;
65
+ message: string;
66
+ } | {
67
+ blocked: boolean;
68
+ buildable: boolean;
69
+ stuck: boolean;
70
+ why: string | null;
71
+ build_number?: number | undefined;
72
+ queue_id: number;
73
+ queue_url: string;
74
+ state: string;
75
+ message?: undefined;
76
+ }>;
77
+ export declare function getBuildStatus(cfg: JenkinsConfig, job: string, buildNumber: number): Promise<{
78
+ job: string;
79
+ build_number: number;
80
+ state: string;
81
+ build_url: string;
82
+ } | {
83
+ number: number;
84
+ result: "SUCCESS" | "FAILURE" | "UNSTABLE" | "ABORTED" | "NOT_BUILT" | null;
85
+ building: boolean;
86
+ duration?: number | undefined;
87
+ timestamp?: number | undefined;
88
+ job: string;
89
+ build_number: number;
90
+ build_url: string;
91
+ state?: undefined;
92
+ }>;
93
+ export {};
@@ -0,0 +1,324 @@
1
+ // Jenkins 빌드 실행·추적 — jenkins_trigger_build / jenkins_get_queue_item / jenkins_get_build_status.
2
+ //
3
+ // 트리거는 **로컬 영속 at-most-once 발송**이다 (Jenkins 쪽 exactly-once 가 아니다). 호출자가 준 request_id 마다
4
+ // ~/.mimi-seed/jenkins-build-requests/<key>/ 디렉터리를 mkdir 로 원자적으로 예약하고, 그 안의 receipt.json 에
5
+ // 결과를 남긴다. POST 를 보낼 수 있는 시점부터는 예약을 지우지 않는다 — POST 직후 프로세스가 죽어도 같은
6
+ // request_id 재호출이 재전송이 되면 안 된다. POST 전에 실패하면(receipt 저장·crumb 조회) 예약을 풀고 던진다.
7
+ // receipt 에는 HMAC 지문과 제한된 응답 메타데이터만 남긴다. 파라미터 원문·토큰은 절대 저장하지 않는다.
8
+ //
9
+ // confirm 가드와의 관계: 이 도구는 manifest 의 destructive 이고 레지스트라가 confirm 을 주입한다. confirm 없는
10
+ // 호출은 핸들러까지 오지 않으므로(= triggerBuild 가 불리지 않으므로) request_id 를 예약하지도, receipt 를 쓰지도
11
+ // 않는다. dry-run 으로 본 인자 그대로 confirm: true 를 붙여 부르면 그때 처음 한 번 예약·발송된다.
12
+ import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
13
+ import { linkSync, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { z } from 'zod';
17
+ import { CREDENTIAL_DIR_MODE, writeCredentialFile, writeCredentialJson } from '#core/atomic-write.js';
18
+ import { authHeaders, isStrictJobPath, jobUrl, requestCrumb } from './http.js';
19
+ import { fetchWithTimeout } from '../lib/http.js';
20
+ import { encodePathSegment } from '../lib/url-path.js';
21
+ export const buildJobSchema = z
22
+ .string()
23
+ .min(1)
24
+ .max(1024)
25
+ .refine(isStrictJobPath, '잡 경로에는 빈 세그먼트, . 또는 .., 백슬래시, 제어 문자를 쓸 수 없습니다.');
26
+ export const buildIdSchema = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);
27
+ export const requestIdSchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/, 'request_id 는 영숫자·_·- 만 쓸 수 있습니다 (최대 128자).');
28
+ export const buildParametersSchema = z.record(z.string().min(1), z.string());
29
+ export const triggerBuildSchema = z.object({
30
+ job: buildJobSchema,
31
+ request_id: requestIdSchema,
32
+ parameters: buildParametersSchema.optional(),
33
+ });
34
+ const receiptSchema = z.object({
35
+ fingerprint: z.string(),
36
+ // pending: 예약 후 POST 결과를 아직 못 적었다 (진행 중이거나, 오래됐으면 도중에 죽었다).
37
+ state: z.enum(['pending', 'unknown', 'queued', 'rejected']),
38
+ reserved_at: z.number().int().nonnegative().optional(),
39
+ queue_id: buildIdSchema.optional(),
40
+ http_status: z.number().int().optional(),
41
+ });
42
+ /**
43
+ * 예약이 이보다 젊으면 결과가 없는 재호출을 `pending`(다른 호출이 처리 중)으로, 더 오래됐으면 `unknown`
44
+ * (도중에 죽음)으로 본다. crumb 조회(1회, 60초) + POST(1회, 60초) 최악 소요보다 넉넉하게 잡는다.
45
+ */
46
+ export const PENDING_WINDOW_MS = 3 * 60_000;
47
+ const hash = (text) => createHash('sha256').update(text).digest('hex');
48
+ /** 예약·receipt 가 사는 곳. os.homedir() 를 호출 시점에 읽는다 (테스트가 HOME 을 바꾼다). */
49
+ export function buildRequestsDir() {
50
+ return path.join(os.homedir(), '.mimi-seed', 'jenkins-build-requests');
51
+ }
52
+ /**
53
+ * 파라미터 지문용 설치별 비밀 키 (~/.mimi-seed/jenkins-build-requests/.key, 0600).
54
+ *
55
+ * 지문을 평문 sha256 으로 두면 짧은 파라미터(버전 번호·브랜치 이름)는 receipt 만 보고 사전 대입으로 되찾을 수
56
+ * 있다. 그래서 HMAC 으로 소금 친다. 처음 쓸 때 공용 writer 로 temp 에 쓰고 link(2) 로 "없을 때만" 게시한다 —
57
+ * 두 프로세스가 동시에 만들어도 한 키만 살아남고, 진 쪽은 이긴 키를 다시 읽는다 (각자 다른 키로 지문을 만들면
58
+ * 같은 요청이 "다른 파라미터" 로 거절된다). 키가 사라지면 기존 예약의 재호출은 지문 불일치로 거절된다 — 다시
59
+ * 보내지는 않는다. 예약 디렉터리 이름은 키와 무관한 sha256 이라 키를 지워도 중복 발송이 풀리지 않는다.
60
+ */
61
+ export function fingerprintKey() {
62
+ const file = path.join(buildRequestsDir(), '.key');
63
+ const read = () => {
64
+ const text = readFileSync(file, 'utf8').trim();
65
+ if (!/^[0-9a-f]{64}$/.test(text)) {
66
+ throw new Error(`Jenkins 빌드 요청 키 파일이 손상됐습니다 (${path.basename(file)}). 빌드를 요청하지 않았습니다.`);
67
+ }
68
+ return Buffer.from(text, 'hex');
69
+ };
70
+ try {
71
+ return read();
72
+ }
73
+ catch (error) {
74
+ if (error.code !== 'ENOENT')
75
+ throw error;
76
+ }
77
+ const temp = `${file}.${process.pid}.${randomUUID()}.new`;
78
+ writeCredentialFile(temp, `${randomBytes(32).toString('hex')}\n`);
79
+ try {
80
+ linkSync(temp, file);
81
+ }
82
+ catch (error) {
83
+ // EEXIST = 다른 프로세스가 먼저 만들었다 → 그 키를 쓴다. 하드링크를 못 거는 파일시스템이면 그냥 게시한다.
84
+ if (error.code !== 'EEXIST')
85
+ writeCredentialFile(file, readFileSync(temp));
86
+ }
87
+ finally {
88
+ rmSync(temp, { force: true });
89
+ }
90
+ return read();
91
+ }
92
+ function root(cfg) {
93
+ if (!cfg.username?.trim() || !cfg.token?.trim())
94
+ throw new Error('Jenkins 사용자와 API Token 설정이 필요합니다.');
95
+ const url = new URL(cfg.url);
96
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
97
+ throw new Error('Jenkins URL은 인증정보·쿼리·fragment 없는 HTTP(S) 주소여야 합니다.');
98
+ }
99
+ return url.href.replace(/\/+$/, '');
100
+ }
101
+ const queueUrlOf = (base, queueId) => `${base}/queue/item/${encodePathSegment(queueId)}/`;
102
+ const MESSAGES = {
103
+ pending: '같은 request_id 의 요청을 다른 호출이 방금 예약해 처리 중입니다. 잠시 뒤 같은 request_id 로 다시 호출해 결과를 확인하세요. '
104
+ + '새 request_id 로 재시도하지 마세요.',
105
+ unknown: '접수 여부 불명입니다. 자동 재전송하지 않습니다. Jenkins에서 접수 여부를 확인하세요. 같은 request_id 재호출도 POST하지 않습니다.',
106
+ rejected: 'Jenkins가 요청을 거절했습니다. 권한·잡·파라미터를 확인하세요. 수정 후 새 요청에만 새 request_id를 사용하세요.',
107
+ queued: undefined,
108
+ };
109
+ function result(receipt, base, requestId, replayed) {
110
+ const message = MESSAGES[receipt.state];
111
+ return {
112
+ request_id: requestId,
113
+ state: receipt.state,
114
+ replayed,
115
+ ...(receipt.queue_id !== undefined && { queue_id: receipt.queue_id, queue_url: queueUrlOf(base, receipt.queue_id) }),
116
+ ...(receipt.http_status !== undefined && { http_status: receipt.http_status }),
117
+ ...(message && { message }),
118
+ };
119
+ }
120
+ /** 결과 기록을 저장하지 못했을 때 — 로컬 경로·원인 문자열은 싣지 않는다. */
121
+ function notPersistedMessage(receipt) {
122
+ if (receipt.state === 'queued') {
123
+ return 'Jenkins 빌드는 큐에 들어갔습니다 — 다시 트리거하지 마세요. queue_id 를 jenkins_get_queue_item 으로 추적하세요. '
124
+ + '(로컬 결과 기록 저장 실패: 같은 request_id 재호출은 pending/unknown 으로 보이지만 재전송하지 않습니다.) '
125
+ + 'The build WAS queued — do not retrigger; track queue_id with jenkins_get_queue_item. '
126
+ + '(The local result record could not be saved; replays of this request_id report pending/unknown and never re-send.)';
127
+ }
128
+ return `${MESSAGES[receipt.state] ?? ''} 로컬 결과 기록을 저장하지 못했습니다 — 새 request_id 로 재시도하지 마세요. `
129
+ + 'The local result record could not be saved — do not retry with a new request_id.';
130
+ }
131
+ /** 이미 예약된 request_id 재호출 — 기록을 돌려주고 POST 하지 않는다. */
132
+ function replay(dir, file, fingerprint, base, requestId) {
133
+ let receipt = null;
134
+ try {
135
+ receipt = receiptSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
136
+ }
137
+ catch {
138
+ // 다른 호출이 아직 예약 중이거나, 도중에 죽어 기록이 없거나 잘렸다 — 아래에서 나이로 가른다.
139
+ }
140
+ if (receipt && receipt.fingerprint !== fingerprint) {
141
+ throw new Error('같은 request_id에 다른 잡/파라미터를 사용할 수 없습니다. 새 요청이면 새 request_id를 쓰세요.');
142
+ }
143
+ if (receipt && receipt.state !== 'pending')
144
+ return result(receipt, base, requestId, true);
145
+ let since = receipt?.reserved_at;
146
+ if (since === undefined) {
147
+ try {
148
+ since = statSync(dir).mtimeMs;
149
+ }
150
+ catch {
151
+ since = undefined;
152
+ }
153
+ }
154
+ const young = since !== undefined && Date.now() - since < PENDING_WINDOW_MS;
155
+ return result({ fingerprint, state: young ? 'pending' : 'unknown' }, base, requestId, true);
156
+ }
157
+ /** Location 경로 끝의 `/queue/item/<id>/` 에서 ID 만 꺼낸다 — 컨텍스트 경로를 바꾸는 리버스 프록시도 허용. */
158
+ function queueIdFromLocation(location, base) {
159
+ const url = new URL(location, `${base}/`);
160
+ if (!['http:', 'https:'].includes(url.protocol) || url.search || url.hash || url.username || url.password)
161
+ return undefined;
162
+ const match = /\/queue\/item\/(\d+)\/?$/.exec(url.pathname);
163
+ const id = match ? Number(match[1]) : NaN;
164
+ return buildIdSchema.safeParse(id).success ? id : undefined;
165
+ }
166
+ /**
167
+ * Local durable at-most-once dispatch, not Jenkins-side exactly-once delivery.
168
+ * Keep reservations indefinitely once the POST may have been sent: a crash after POST must never become a retry.
169
+ * Only an HMAC fingerprint and bounded response metadata are stored, never parameters or tokens.
170
+ * Once the POST may have been sent this function never throws — it always returns what it knows.
171
+ */
172
+ export async function triggerBuild(cfg, input) {
173
+ const args = triggerBuildSchema.parse(input);
174
+ const base = root(cfg);
175
+ const parameters = Object.entries(args.parameters ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
176
+ mkdirSync(buildRequestsDir(), { recursive: true, mode: CREDENTIAL_DIR_MODE });
177
+ const fingerprint = createHmac('sha256', fingerprintKey())
178
+ .update(JSON.stringify([args.job, args.parameters !== undefined, parameters]))
179
+ .digest('hex');
180
+ const key = hash(JSON.stringify([base, cfg.username, args.request_id]));
181
+ const dir = path.join(buildRequestsDir(), key);
182
+ const file = path.join(dir, 'receipt.json');
183
+ try {
184
+ // mkdir 는 원자적이다 — 동시에 같은 request_id 를 보낸 두 호출 중 하나만 여기를 통과한다.
185
+ mkdirSync(dir, { mode: CREDENTIAL_DIR_MODE });
186
+ }
187
+ catch (error) {
188
+ if (error.code !== 'EEXIST')
189
+ throw error;
190
+ return replay(dir, file, fingerprint, base, args.request_id);
191
+ }
192
+ // ── POST 전: 실패하면 아무것도 보내지 않았으므로 예약을 풀고 던진다 (같은 request_id 로 재시도 가능). ──
193
+ const release = () => {
194
+ try {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ catch {
198
+ // 못 지우면 예약이 남는다 — 재호출은 pending/unknown 으로 보일 뿐 재전송하지 않는다 (안전한 쪽).
199
+ }
200
+ };
201
+ const reservedAt = Date.now();
202
+ let crumb;
203
+ try {
204
+ writeCredentialJson(file, { fingerprint, state: 'pending', reserved_at: reservedAt });
205
+ // API 토큰 인증은 보통 crumb 이 면제지만, 켜 둔 서버도 있다. 인증 헤더를 단 채 리다이렉트를 따라가지 않는다.
206
+ const crumbResult = await requestCrumb({ ...cfg, url: base }, { redirect: 'manual', maxAttempts: 1 });
207
+ if (crumbResult.kind === 'failed') {
208
+ throw new Error(`Jenkins CSRF crumb 조회 실패${crumbResult.status ? ` (HTTP ${crumbResult.status})` : ''} — 빌드를 요청하지 않았습니다. `
209
+ + '인증·서버 상태를 확인한 뒤 같은 request_id 로 다시 시도해도 됩니다.');
210
+ }
211
+ crumb = crumbResult.kind === 'ok' ? crumbResult.headers : {};
212
+ }
213
+ catch (error) {
214
+ release();
215
+ throw error;
216
+ }
217
+ // ── 여기부터 POST 가 Jenkins 에 닿았을 수 있다: 절대 던지지 않는다. ─────────────────────
218
+ let receipt = { fingerprint, state: 'unknown', reserved_at: reservedAt };
219
+ try {
220
+ // No POST retry and no redirects (307/308 could otherwise replay the request at another URL).
221
+ const endpoint = args.parameters === undefined ? 'build' : 'buildWithParameters';
222
+ const response = await fetchWithTimeout(`${jobUrl({ ...cfg, url: base }, args.job)}/${encodePathSegment(endpoint)}`, {
223
+ method: 'POST',
224
+ redirect: 'manual',
225
+ headers: { ...authHeaders(cfg), ...crumb, 'Content-Type': 'application/x-www-form-urlencoded' },
226
+ body: new URLSearchParams(parameters).toString(),
227
+ }, { maxAttempts: 1 });
228
+ receipt = { ...receipt, http_status: response.status };
229
+ if (response.status === 201) {
230
+ const location = response.headers.get('location');
231
+ // Jenkins can advertise a different host or context path behind a reverse proxy.
232
+ // Extract only the ID; subsequent requests always use the configured base.
233
+ const queueId = location ? queueIdFromLocation(location, base) : undefined;
234
+ if (queueId !== undefined)
235
+ receipt = { ...receipt, state: 'queued', queue_id: queueId };
236
+ }
237
+ else if ([400, 401, 403, 404, 405, 422].includes(response.status)) {
238
+ receipt = { ...receipt, state: 'rejected' };
239
+ }
240
+ }
241
+ catch {
242
+ // Network error, timeout, or a malformed response. Provider messages can echo secrets — keep only safe metadata.
243
+ }
244
+ try {
245
+ writeCredentialJson(file, receipt);
246
+ }
247
+ catch {
248
+ return { ...result(receipt, base, args.request_id, false), persisted: false, message: notPersistedMessage(receipt) };
249
+ }
250
+ return result(receipt, base, args.request_id, false);
251
+ }
252
+ async function readJson(cfg, url) {
253
+ let response;
254
+ try {
255
+ response = await fetchWithTimeout(url, { headers: authHeaders(cfg), redirect: 'manual' });
256
+ }
257
+ catch {
258
+ throw new Error('Jenkins 조회 연결 실패/시간 초과. 연결 상태를 확인한 뒤 조회만 재시도하세요.');
259
+ }
260
+ if (response.status === 404)
261
+ return { missing: true };
262
+ if (!response.ok)
263
+ throw new Error(`Jenkins 조회 실패 (HTTP ${response.status}). 인증·권한·서버 상태를 확인하세요.`);
264
+ try {
265
+ return { missing: false, data: await response.json() };
266
+ }
267
+ catch {
268
+ throw new Error('Jenkins JSON 응답을 읽을 수 없습니다.');
269
+ }
270
+ }
271
+ const queueSchema = z.object({
272
+ id: buildIdSchema,
273
+ cancelled: z.boolean().optional(),
274
+ blocked: z.boolean().optional(),
275
+ buildable: z.boolean().optional(),
276
+ stuck: z.boolean().optional(),
277
+ why: z.string().nullable().optional(),
278
+ executable: z.object({ number: buildIdSchema }).nullable().optional(),
279
+ });
280
+ export async function getQueueItem(cfg, queueId) {
281
+ buildIdSchema.parse(queueId);
282
+ const queueUrl = queueUrlOf(root(cfg), queueId);
283
+ const response = await readJson(cfg, `${queueUrl}api/json?tree=id,cancelled,blocked,buildable,stuck,why,executable[number]`);
284
+ if (response.missing) {
285
+ return {
286
+ queue_id: queueId,
287
+ state: 'unavailable',
288
+ message: '큐 항목이 만료됐거나 없습니다. 이미 기록한 빌드 번호로 조회하세요. 재트리거하거나 lastBuild로 추정하지 마세요.',
289
+ };
290
+ }
291
+ const data = queueSchema.safeParse(response.data);
292
+ if (!data.success || data.data.id !== queueId)
293
+ throw new Error('Jenkins 큐 응답 형식 또는 ID가 일치하지 않습니다.');
294
+ const item = data.data;
295
+ return {
296
+ queue_id: queueId,
297
+ queue_url: queueUrl,
298
+ state: item.cancelled ? 'cancelled' : item.executable ? 'started' : 'queued',
299
+ ...(!item.cancelled && item.executable && { build_number: item.executable.number }),
300
+ blocked: item.blocked ?? false,
301
+ buildable: item.buildable ?? false,
302
+ stuck: item.stuck ?? false,
303
+ why: item.why?.slice(0, 1000) ?? null,
304
+ };
305
+ }
306
+ const buildSchema = z.object({
307
+ number: buildIdSchema,
308
+ building: z.boolean(),
309
+ result: z.enum(['SUCCESS', 'FAILURE', 'UNSTABLE', 'ABORTED', 'NOT_BUILT']).nullable(),
310
+ timestamp: z.number().nonnegative().optional(),
311
+ duration: z.number().nonnegative().optional(),
312
+ });
313
+ export async function getBuildStatus(cfg, job, buildNumber) {
314
+ buildJobSchema.parse(job);
315
+ buildIdSchema.parse(buildNumber);
316
+ const buildUrl = `${jobUrl({ ...cfg, url: root(cfg) }, job)}/${encodePathSegment(buildNumber)}/`;
317
+ const response = await readJson(cfg, `${buildUrl}api/json?tree=number,building,result,timestamp,duration`);
318
+ if (response.missing)
319
+ return { job, build_number: buildNumber, state: 'unavailable', build_url: buildUrl };
320
+ const data = buildSchema.safeParse(response.data);
321
+ if (!data.success || data.data.number !== buildNumber)
322
+ throw new Error('Jenkins 빌드 응답 형식 또는 번호가 일치하지 않습니다.');
323
+ return { job, build_number: buildNumber, build_url: buildUrl, ...data.data };
324
+ }
@@ -7,10 +7,39 @@ export declare function authHeaders(cfg: JenkinsConfig): Record<string, string>;
7
7
  * 구버전 Jenkins / 비밀번호 인증 환경에서 POST 403 방지.
8
8
  */
9
9
  export declare function getCrumb(cfg: JenkinsConfig): Promise<Record<string, string>>;
10
+ /** crumb 조회 결과 — `disabled` 는 crumb issuer 가 꺼져 있음(404), `failed` 는 그 밖의 모든 실패. */
11
+ export type CrumbResult = {
12
+ kind: 'ok';
13
+ headers: Record<string, string>;
14
+ } | {
15
+ kind: 'disabled';
16
+ } | {
17
+ kind: 'failed';
18
+ status?: number;
19
+ };
20
+ /**
21
+ * crumb 을 결과 종류와 함께 돌려준다. getCrumb 은 이것의 best-effort 판(실패 = 빈 객체)이다.
22
+ * jenkins_trigger_build 는 `redirect: 'manual'` 로 불러 인증 헤더를 단 채 다른 곳으로 따라가지 않고,
23
+ * `failed` 면 POST 하지 않는다.
24
+ */
25
+ export declare function requestCrumb(cfg: JenkinsConfig, options?: {
26
+ redirect?: RequestRedirect;
27
+ maxAttempts?: number;
28
+ }): Promise<CrumbResult>;
29
+ /**
30
+ * 엄격한 잡 경로 검사 — 빌드 도구(jenkins_trigger_build · jenkins_get_build_status)의 입력 스키마가 쓴다.
31
+ *
32
+ * jobUrl 은 세그먼트마다 encodePathSegment 로 인코딩하고 `.`/`..` 를 거부하지만, 옛 도구 호환 때문에
33
+ * 빈 세그먼트(`a//b`, 끝의 `/`)는 조용히 버린다. 빌드를 **실행**하는 경로에서는 그런 모호함도 받지 않는다:
34
+ * 빈·공백뿐인 세그먼트, `.`/`..`, 백슬래시(일부 프록시가 `/` 로 바꾼다), 제어 문자를 모두 거절한다.
35
+ */
36
+ export declare function isStrictJobPath(jobPath: string): boolean;
10
37
  /**
11
38
  * 잡 경로를 Jenkins URL 로 바꾼다. 폴더는 `/` 로 구분한다.
12
39
  * "my-app" -> <base>/job/my-app
13
40
  * "team-folder/my-app" -> <base>/job/team-folder/job/my-app
41
+ * 세그먼트는 encodePathSegment 로 인코딩한다 — `..` 세그먼트가 URL 정규화로 상위 경로(다른 잡)를
42
+ * 가리키지 못하게 거부한다.
14
43
  */
15
44
  export declare function jobUrl(cfg: JenkinsConfig, jobPath: string): string;
16
45
  /**