@yoonion/mimi-seed-mcp 0.21.0 → 0.21.1
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 +1 -1
- package/assets/agent-guide.md +22 -7
- package/dist/appstore/apps.js +4 -1
- package/dist/jenkins/builds.d.ts +93 -0
- package/dist/jenkins/builds.js +324 -0
- package/dist/jenkins/http.d.ts +29 -0
- package/dist/jenkins/http.js +47 -13
- package/dist/jenkins/messages.js +1 -0
- package/dist/lib/tool-registrar.d.ts +1 -0
- package/dist/lib/tool-registrar.js +14 -2
- package/dist/playstore/reviews.d.ts +46 -3
- package/dist/playstore/reviews.js +84 -9
- package/dist/prompts.js +1 -1
- package/dist/registers/jenkins.js +31 -1
- package/dist/registers/playstore.js +7 -3
- package/dist/video/schemas.d.ts +10 -10
- package/package.json +1 -1
- package/tool-manifest.json +8 -4
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ 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 (크리덴셜 + 잡) |
|
|
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
145
|
| Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
|
package/assets/agent-guide.md
CHANGED
|
@@ -82,6 +82,7 @@ row for the job; batching two rows in one `select:` call is fine.
|
|
|
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
|
|
309
|
-
> `.ipa`/`.aab`, use EAS, Xcode, or a CI/Jenkins job.
|
|
310
|
-
>
|
|
311
|
-
>
|
|
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.**
|
|
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
|
package/dist/appstore/apps.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/jenkins/http.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/jenkins/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
import { encodePathSegment } from '../lib/url-path.js';
|
|
2
3
|
export function basicAuth(username, token) {
|
|
3
4
|
return 'Basic ' + Buffer.from(`${username}:${token}`).toString('base64');
|
|
4
5
|
}
|
|
@@ -13,32 +14,67 @@ export function authHeaders(cfg) {
|
|
|
13
14
|
* 구버전 Jenkins / 비밀번호 인증 환경에서 POST 403 방지.
|
|
14
15
|
*/
|
|
15
16
|
export async function getCrumb(cfg) {
|
|
17
|
+
const result = await requestCrumb(cfg);
|
|
18
|
+
return result.kind === 'ok' ? result.headers : {};
|
|
19
|
+
}
|
|
20
|
+
/** 헤더 이름·값으로 안전한가 — 서버가 준 문자열을 그대로 헤더에 넣으면 fetch 가 요청 도중 던질 수 있다. */
|
|
21
|
+
const CRUMB_FIELD = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
|
|
22
|
+
const CRUMB_VALUE = /^[\x21-\x7e]{1,512}$/;
|
|
23
|
+
/**
|
|
24
|
+
* crumb 을 결과 종류와 함께 돌려준다. getCrumb 은 이것의 best-effort 판(실패 = 빈 객체)이다.
|
|
25
|
+
* jenkins_trigger_build 는 `redirect: 'manual'` 로 불러 인증 헤더를 단 채 다른 곳으로 따라가지 않고,
|
|
26
|
+
* `failed` 면 POST 하지 않는다.
|
|
27
|
+
*/
|
|
28
|
+
export async function requestCrumb(cfg, options = {}) {
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetchWithTimeout(`${baseUrl(cfg)}/crumbIssuer/api/json`, { headers: authHeaders(cfg), ...(options.redirect && { redirect: options.redirect }) }, options.maxAttempts ? { maxAttempts: options.maxAttempts } : {});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { kind: 'failed' };
|
|
35
|
+
}
|
|
36
|
+
if (res.status === 404)
|
|
37
|
+
return { kind: 'disabled' };
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
return { kind: 'failed', status: res.status };
|
|
16
40
|
try {
|
|
17
|
-
const res = await fetchWithTimeout(`${baseUrl(cfg)}/crumbIssuer/api/json`, {
|
|
18
|
-
headers: authHeaders(cfg),
|
|
19
|
-
});
|
|
20
|
-
if (!res.ok)
|
|
21
|
-
return {};
|
|
22
41
|
const data = (await res.json());
|
|
23
|
-
if (data.crumbRequestField && data.
|
|
24
|
-
|
|
42
|
+
if (typeof data.crumbRequestField === 'string' && CRUMB_FIELD.test(data.crumbRequestField)
|
|
43
|
+
&& typeof data.crumb === 'string' && CRUMB_VALUE.test(data.crumb)) {
|
|
44
|
+
return { kind: 'ok', headers: { [data.crumbRequestField]: data.crumb } };
|
|
25
45
|
}
|
|
26
|
-
return {};
|
|
27
46
|
}
|
|
28
47
|
catch {
|
|
29
|
-
|
|
48
|
+
// JSON 아님
|
|
30
49
|
}
|
|
50
|
+
return { kind: 'failed', status: res.status };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 엄격한 잡 경로 검사 — 빌드 도구(jenkins_trigger_build · jenkins_get_build_status)의 입력 스키마가 쓴다.
|
|
54
|
+
*
|
|
55
|
+
* jobUrl 은 세그먼트마다 encodePathSegment 로 인코딩하고 `.`/`..` 를 거부하지만, 옛 도구 호환 때문에
|
|
56
|
+
* 빈 세그먼트(`a//b`, 끝의 `/`)는 조용히 버린다. 빌드를 **실행**하는 경로에서는 그런 모호함도 받지 않는다:
|
|
57
|
+
* 빈·공백뿐인 세그먼트, `.`/`..`, 백슬래시(일부 프록시가 `/` 로 바꾼다), 제어 문자를 모두 거절한다.
|
|
58
|
+
*/
|
|
59
|
+
export function isStrictJobPath(jobPath) {
|
|
60
|
+
return jobPath.split('/').every((segment) => segment.trim().length > 0
|
|
61
|
+
&& segment !== '.'
|
|
62
|
+
&& segment !== '..'
|
|
63
|
+
&& !segment.includes('\\')
|
|
64
|
+
&& ![...segment].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f));
|
|
31
65
|
}
|
|
32
66
|
/**
|
|
33
67
|
* 잡 경로를 Jenkins URL 로 바꾼다. 폴더는 `/` 로 구분한다.
|
|
34
68
|
* "my-app" -> <base>/job/my-app
|
|
35
69
|
* "team-folder/my-app" -> <base>/job/team-folder/job/my-app
|
|
70
|
+
* 세그먼트는 encodePathSegment 로 인코딩한다 — `..` 세그먼트가 URL 정규화로 상위 경로(다른 잡)를
|
|
71
|
+
* 가리키지 못하게 거부한다.
|
|
36
72
|
*/
|
|
37
73
|
export function jobUrl(cfg, jobPath) {
|
|
38
74
|
const segments = jobPath.split('/').filter(Boolean);
|
|
39
75
|
if (segments.length === 0)
|
|
40
76
|
throw new Error('잡 이름이 비어 있습니다.');
|
|
41
|
-
const suffix = segments.map((s) => `job/${
|
|
77
|
+
const suffix = segments.map((s) => `job/${encodePathSegment(s)}`).join('/');
|
|
42
78
|
return `${baseUrl(cfg)}/${suffix}`;
|
|
43
79
|
}
|
|
44
80
|
/**
|
|
@@ -50,9 +86,7 @@ export function createItemUrl(cfg, jobPath) {
|
|
|
50
86
|
if (segments.length === 0)
|
|
51
87
|
throw new Error('잡 이름이 비어 있습니다.');
|
|
52
88
|
const leaf = segments.pop();
|
|
53
|
-
const parent = segments.length
|
|
54
|
-
? `${baseUrl(cfg)}/${segments.map((s) => `job/${encodeURIComponent(s)}`).join('/')}`
|
|
55
|
-
: baseUrl(cfg);
|
|
89
|
+
const parent = segments.length ? jobUrl(cfg, segments.join('/')) : baseUrl(cfg);
|
|
56
90
|
return `${parent}/createItem?name=${encodeURIComponent(leaf)}`;
|
|
57
91
|
}
|
|
58
92
|
/** 실패 응답의 본문 일부를 붙여 에러를 만든다. Jenkins 는 원인을 본문에만 담는 경우가 많다. */
|
package/dist/jenkins/messages.js
CHANGED
|
@@ -101,5 +101,6 @@ export function jenkinsConfiguredLines({ url, username }) {
|
|
|
101
101
|
'jenkins_list_credentials 로 등록된 credential 목록을 확인하거나,',
|
|
102
102
|
'jenkins_create_credential / jenkins_upload_keystore 로 credential을 추가하세요.',
|
|
103
103
|
'잡은 jenkins_list_jobs / jenkins_get_job_config / jenkins_create_job / jenkins_update_job 로 다룹니다.',
|
|
104
|
+
'빌드는 jenkins_trigger_build → jenkins_get_queue_item → jenkins_get_build_status 순서로 실행·추적합니다.',
|
|
104
105
|
];
|
|
105
106
|
}
|
|
@@ -50,6 +50,7 @@ export declare function toolTitle(name: string): string;
|
|
|
50
50
|
export declare function annotationsFor(meta: ToolMeta): ToolAnnotations & {
|
|
51
51
|
title: string;
|
|
52
52
|
};
|
|
53
|
+
export declare function previewValue(key: string, value: unknown): string;
|
|
53
54
|
export declare function confirmPreview(name: string, args: Record<string, unknown>): CallToolResult;
|
|
54
55
|
export interface ToolRegistrar {
|
|
55
56
|
/** `McpServer.tool(name, description, shape, handler)` 와 같은 모양 — register 파일 호출부는 그대로다. */
|
|
@@ -79,10 +79,22 @@ export function annotationsFor(meta) {
|
|
|
79
79
|
}
|
|
80
80
|
// ── confirm guard ──────────────────────────────────────────────────────────
|
|
81
81
|
const SECRET_KEY = /secret|token|password|passphrase|private|base64|credential_json|serviceAccountJson/i;
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* 중첩된 객체·배열 안의 비밀처럼 보이는 키도 가린다 — 예: jenkins_trigger_build 의
|
|
84
|
+
* `parameters: { DEPLOY_TOKEN: … }`. 최상위 키만 보던 시절엔 값이 preview 에 그대로 찍혔다.
|
|
85
|
+
*/
|
|
86
|
+
function redactNested(value) {
|
|
87
|
+
if (Array.isArray(value))
|
|
88
|
+
return value.map(redactNested);
|
|
89
|
+
if (value !== null && typeof value === 'object') {
|
|
90
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? '(redacted)' : redactNested(v)]));
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
export function previewValue(key, value) {
|
|
83
95
|
if (SECRET_KEY.test(key))
|
|
84
96
|
return '(redacted)';
|
|
85
|
-
const raw = typeof value === 'string' ? value : JSON.stringify(value);
|
|
97
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(redactNested(value));
|
|
86
98
|
if (raw === undefined)
|
|
87
99
|
return 'undefined';
|
|
88
100
|
return raw.length > 200 ? `${raw.slice(0, 200)}… (${raw.length}자)` : raw;
|
|
@@ -1,12 +1,55 @@
|
|
|
1
1
|
import type { OAuth2Client, JWT } from 'google-auth-library';
|
|
2
|
+
import type { androidpublisher_v3 } from 'googleapis/build/src/apis/androidpublisher/index.js';
|
|
3
|
+
/** 리뷰 목록과, 목록이 잘렸다면 그 이유(아니면 null). 도구 응답은 이걸 쓴다. */
|
|
4
|
+
export declare function listReviewsWithStatus(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
5
|
+
reviews: {
|
|
6
|
+
reviewId: string | null | undefined;
|
|
7
|
+
authorName: string | null | undefined;
|
|
8
|
+
developerComment: {
|
|
9
|
+
text: string | null | undefined;
|
|
10
|
+
lastModified: string | null | undefined;
|
|
11
|
+
} | null;
|
|
12
|
+
comments: ({
|
|
13
|
+
text: string | null | undefined;
|
|
14
|
+
starRating: number | null | undefined;
|
|
15
|
+
lastModified: string | null | undefined;
|
|
16
|
+
deviceMetadata: string | null | undefined;
|
|
17
|
+
developerComment?: undefined;
|
|
18
|
+
} | {
|
|
19
|
+
developerComment: {
|
|
20
|
+
text: string | null | undefined;
|
|
21
|
+
lastModified: string | null | undefined;
|
|
22
|
+
};
|
|
23
|
+
text?: undefined;
|
|
24
|
+
starRating?: undefined;
|
|
25
|
+
lastModified?: undefined;
|
|
26
|
+
deviceMetadata?: undefined;
|
|
27
|
+
})[] | undefined;
|
|
28
|
+
}[];
|
|
29
|
+
truncated: string | null;
|
|
30
|
+
}>;
|
|
2
31
|
export declare function listReviews(auth: OAuth2Client | JWT, packageName: string): Promise<{
|
|
3
32
|
reviewId: string | null | undefined;
|
|
4
33
|
authorName: string | null | undefined;
|
|
5
|
-
|
|
34
|
+
developerComment: {
|
|
35
|
+
text: string | null | undefined;
|
|
36
|
+
lastModified: string | null | undefined;
|
|
37
|
+
} | null;
|
|
38
|
+
comments: ({
|
|
6
39
|
text: string | null | undefined;
|
|
7
40
|
starRating: number | null | undefined;
|
|
8
41
|
lastModified: string | null | undefined;
|
|
9
42
|
deviceMetadata: string | null | undefined;
|
|
10
|
-
|
|
43
|
+
developerComment?: undefined;
|
|
44
|
+
} | {
|
|
45
|
+
developerComment: {
|
|
46
|
+
text: string | null | undefined;
|
|
47
|
+
lastModified: string | null | undefined;
|
|
48
|
+
};
|
|
49
|
+
text?: undefined;
|
|
50
|
+
starRating?: undefined;
|
|
51
|
+
lastModified?: undefined;
|
|
52
|
+
deviceMetadata?: undefined;
|
|
53
|
+
})[] | undefined;
|
|
11
54
|
}[]>;
|
|
12
|
-
export declare function replyToReview(auth: OAuth2Client | JWT, packageName: string, reviewId: string, replyText: string): Promise<
|
|
55
|
+
export declare function replyToReview(auth: OAuth2Client | JWT, packageName: string, reviewId: string, replyText: string): Promise<androidpublisher_v3.Schema$ReviewsReplyResponse>;
|
|
@@ -2,18 +2,93 @@
|
|
|
2
2
|
// playstore/tools.ts 가 이 모듈을 그대로 re-export 한다 — 호출부는 tools.js 경로를 계속 쓴다.
|
|
3
3
|
import { publisher } from './edits.js';
|
|
4
4
|
// ─── 리뷰 조회 ───
|
|
5
|
+
// 개발자 답변(developerComment)을 반드시 보존한다. 예전엔 userComment 만 골라 담아서 답변한 리뷰도
|
|
6
|
+
// 미답변처럼 보였고, "미답변 리뷰에 답하기" 흐름이 이미 답한 리뷰에 다시 답해 기존 답변을 덮어썼다
|
|
7
|
+
// (Play 는 리뷰당 답변 하나 — reply 는 교체다). developerComment 가 null 이면 미답변.
|
|
8
|
+
// reviews.list 는 한 페이지씩 준다 — 첫 페이지만 보면 "미답변" 목록이 잘린다. 반복 토큰·과도한 페이지는 끊는다.
|
|
9
|
+
// Reviews API 는 호출 할당량이 빡빡한 편이라 페이지 상한을 낮게 둔다 (한 번 조회에 최대 1,000건).
|
|
10
|
+
const REVIEW_PAGE_SIZE = 100;
|
|
11
|
+
const REVIEW_MAX_PAGES = 10;
|
|
12
|
+
/**
|
|
13
|
+
* 모든 페이지를 모은다. 첫 페이지 실패는 그대로 던진다(그땐 돌려줄 게 없다). 이후 페이지 실패나 페이지
|
|
14
|
+
* 상한은 **모은 만큼 돌려주고** `truncated` 로 알린다 — 할당량이 빡빡한 API 라 2페이지 오류로 1페이지까지
|
|
15
|
+
* 버리면 첫 페이지만 읽던 예전보다 나빠진다. 부분 목록이어도 각 리뷰의 답변 여부는 정확하다.
|
|
16
|
+
*/
|
|
17
|
+
async function fetchAllReviews(auth, packageName) {
|
|
18
|
+
const all = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let token;
|
|
21
|
+
for (let page = 0; page < REVIEW_MAX_PAGES; page++) {
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await publisher().reviews.list({
|
|
25
|
+
auth,
|
|
26
|
+
packageName,
|
|
27
|
+
maxResults: REVIEW_PAGE_SIZE,
|
|
28
|
+
...(token ? { token } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (page === 0)
|
|
33
|
+
throw err;
|
|
34
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
35
|
+
return { reviews: all, truncated: `${page + 1}페이지 조회 실패로 ${all.length}건까지만 가져왔다 (${reason})` };
|
|
36
|
+
}
|
|
37
|
+
all.push(...(res.data.reviews ?? []));
|
|
38
|
+
const next = res.data.tokenPagination?.nextPageToken ?? undefined;
|
|
39
|
+
if (!next || seen.has(next))
|
|
40
|
+
return { reviews: all, truncated: null };
|
|
41
|
+
seen.add(next);
|
|
42
|
+
token = next;
|
|
43
|
+
}
|
|
44
|
+
return { reviews: all, truncated: `페이지 상한(${REVIEW_MAX_PAGES}페이지)에 걸려 최근 ${all.length}건까지만 가져왔다` };
|
|
45
|
+
}
|
|
46
|
+
// 리뷰당 답변은 하나지만, 혹시 여러 개면 가장 최근 것이 현재 게시된 답변이다.
|
|
47
|
+
function latestDeveloperComment(comments) {
|
|
48
|
+
const replies = (comments ?? []).flatMap((c) => (c.developerComment ? [c.developerComment] : []));
|
|
49
|
+
return replies.reduce((latest, c) => {
|
|
50
|
+
const at = Number(c.lastModified?.seconds ?? 0);
|
|
51
|
+
return !latest || at >= Number(latest.lastModified?.seconds ?? 0) ? c : latest;
|
|
52
|
+
}, undefined);
|
|
53
|
+
}
|
|
54
|
+
/** 리뷰 목록과, 목록이 잘렸다면 그 이유(아니면 null). 도구 응답은 이걸 쓴다. */
|
|
55
|
+
export async function listReviewsWithStatus(auth, packageName) {
|
|
56
|
+
const { reviews, truncated } = await fetchAllReviews(auth, packageName);
|
|
57
|
+
return { reviews: reviews.map(toReviewSummary), truncated };
|
|
58
|
+
}
|
|
5
59
|
export async function listReviews(auth, packageName) {
|
|
6
|
-
|
|
7
|
-
|
|
60
|
+
return (await listReviewsWithStatus(auth, packageName)).reviews;
|
|
61
|
+
}
|
|
62
|
+
function toReviewSummary(r) {
|
|
63
|
+
const developerComment = latestDeveloperComment(r.comments);
|
|
64
|
+
return {
|
|
8
65
|
reviewId: r.reviewId,
|
|
9
66
|
authorName: r.authorName,
|
|
10
|
-
|
|
11
|
-
text:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
67
|
+
developerComment: developerComment
|
|
68
|
+
? { text: developerComment.text, lastModified: developerComment.lastModified?.seconds }
|
|
69
|
+
: null,
|
|
70
|
+
comments: r.comments
|
|
71
|
+
?.map((c) => {
|
|
72
|
+
if (c.userComment) {
|
|
73
|
+
return {
|
|
74
|
+
text: c.userComment.text,
|
|
75
|
+
starRating: c.userComment.starRating,
|
|
76
|
+
lastModified: c.userComment.lastModified?.seconds,
|
|
77
|
+
deviceMetadata: c.userComment.deviceMetadata?.productName,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (c.developerComment) {
|
|
81
|
+
return {
|
|
82
|
+
developerComment: {
|
|
83
|
+
text: c.developerComment.text,
|
|
84
|
+
lastModified: c.developerComment.lastModified?.seconds,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
})
|
|
90
|
+
.filter((c) => c !== null),
|
|
91
|
+
};
|
|
17
92
|
}
|
|
18
93
|
// ─── 리뷰 답변 ───
|
|
19
94
|
export async function replyToReview(auth, packageName, reviewId, replyText) {
|
package/dist/prompts.js
CHANGED
|
@@ -76,7 +76,7 @@ export function registerPrompts(server) {
|
|
|
76
76
|
'',
|
|
77
77
|
'진행 순서:',
|
|
78
78
|
'1. playstore_list_reviews 또는 appstore_list_reviews 로 최근 리뷰 조회',
|
|
79
|
-
'2. 미답변
|
|
79
|
+
'2. 미답변 리뷰만 골라 별점 낮은 순으로 정렬 — Play 는 developerComment, App Store 는 response 가 null 인 리뷰. 이미 답변이 있는 리뷰에 다시 답하면 기존 답변이 교체된다',
|
|
80
80
|
'3. 각 리뷰에 generate_review_reply 로 답변 초안 생성',
|
|
81
81
|
` 톤: ${tone ?? 'empathetic'}`,
|
|
82
82
|
'4. 초안 보여주고 게시 여부 확인 (비가역 — 반드시 동의 받을 것)',
|
|
@@ -2,7 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
import { jenkinsUrlWarning, loadJenkinsConfig, requireJenkinsConfig, saveJenkinsConfig, } from '../jenkins/config.js';
|
|
3
3
|
import * as creds from '../jenkins/credentials.js';
|
|
4
4
|
import * as jobs from '../jenkins/jobs.js';
|
|
5
|
-
import
|
|
5
|
+
import * as builds from '../jenkins/builds.js';
|
|
6
|
+
import { jsonResult, textResult } from '../lib/mcp-response.js';
|
|
6
7
|
import { SIGNING_SECRET_FIELDS, resolveKeystoreInput, resolveSecretInput } from '../android/keystore-store.js';
|
|
7
8
|
import { ambiguousDefaultIdNote, existingCredentialPreview, jenkinsConfiguredLines, jenkinsNotConfiguredLines, } from '../jenkins/messages.js';
|
|
8
9
|
/** 기존 id 교체 dry-run — 무엇을 덮어쓰게 되는지(id·종류·설명)와 모호한 기본 id 경고를 싣는다. */
|
|
@@ -191,4 +192,33 @@ export function registerJenkinsTools(server) {
|
|
|
191
192
|
await jobs.updateJob(cfg, job, config_xml);
|
|
192
193
|
return textResult(`✅ Jenkins 잡 updated: \`${job}\``);
|
|
193
194
|
});
|
|
195
|
+
// ── 10. 빌드 실행 ──────────────────────────────────────────────────────────
|
|
196
|
+
server.tool('jenkins_trigger_build', [
|
|
197
|
+
'Jenkins 잡 빌드를 실행합니다 (배포·출시 잡일 수 있음). 사용자가 승인한 잡/파라미터만 전달하세요.',
|
|
198
|
+
'request_id 는 논리적 요청마다 새로 만들고, dry-run 으로 본 인자와 불확실한 응답의 재호출에는 반드시 같은 값을 쓰세요.',
|
|
199
|
+
'dry-run(confirm 생략)은 request_id 를 예약하지도 기록하지도 않습니다 — confirm: true 호출이 처음 한 번만 POST 합니다.',
|
|
200
|
+
'로컬 영속 기록(~/.mimi-seed/jenkins-build-requests/)으로 같은 request_id 의 중복 POST 를 막습니다 (다른 PC·기록 삭제까지는 보장하지 않음).',
|
|
201
|
+
'state=pending 이면 같은 request_id 의 다른 호출이 처리 중이니 잠시 뒤 같은 request_id 로 다시 확인하세요.',
|
|
202
|
+
'state=unknown 이면 Jenkins 에서 접수 여부를 확인하고 새 request_id 로 재시도하지 마세요.',
|
|
203
|
+
'persisted: false 는 로컬 기록만 실패한 것이다 — queued 면 빌드는 이미 큐에 있으니 다시 트리거하지 마세요.',
|
|
204
|
+
'반환된 queue_id 를 jenkins_get_queue_item 으로 추적하세요.',
|
|
205
|
+
'parameters 생략은 /build, 빈 객체를 포함해 지정하면 /buildWithParameters 입니다. 자격증명 값은 파라미터에 넣지 마세요.',
|
|
206
|
+
].join(' '), {
|
|
207
|
+
job: builds.buildJobSchema.describe('잡 경로 (예: my-app, team-folder/my-app)'),
|
|
208
|
+
request_id: builds.requestIdSchema.describe('이 논리적 빌드 요청의 고유 ID (영숫자·_·-, 최대 128자). 재호출에는 같은 값'),
|
|
209
|
+
parameters: builds.buildParametersSchema
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('빌드 파라미터 (문자열 값). 생략하면 파라미터 없는 /build'),
|
|
212
|
+
}, async (args) => jsonResult(await builds.triggerBuild(requireJenkinsConfig(), args)));
|
|
213
|
+
// ── 11. 큐 항목 조회 ───────────────────────────────────────────────────────
|
|
214
|
+
server.tool('jenkins_get_queue_item', [
|
|
215
|
+
'트리거 응답의 정확한 queue_id 로 대기 사유·취소·배정된 build_number 를 조회합니다.',
|
|
216
|
+
'state=started 가 되면 원래 job 과 build_number 를 저장하고 jenkins_get_build_status 를 쓰세요.',
|
|
217
|
+
'unavailable 은 만료/없음이며 재트리거하지 마세요. lastBuild 로 번호를 추정하지 않습니다.',
|
|
218
|
+
].join(' '), { queue_id: builds.buildIdSchema.describe('jenkins_trigger_build 가 반환한 Jenkins 큐 ID') }, async ({ queue_id }) => jsonResult(await builds.getQueueItem(requireJenkinsConfig(), queue_id)));
|
|
219
|
+
// ── 12. 빌드 상태 조회 ─────────────────────────────────────────────────────
|
|
220
|
+
server.tool('jenkins_get_build_status', '큐에서 배정받은 정확한 job 과 build_number 의 building/result 를 조회합니다. 빌드를 새로 실행하지 않습니다.', {
|
|
221
|
+
job: builds.buildJobSchema.describe('원래 빌드 잡 경로 (예: team-folder/my-app)'),
|
|
222
|
+
build_number: builds.buildIdSchema.describe('jenkins_get_queue_item 이 반환한 빌드 번호'),
|
|
223
|
+
}, async ({ job, build_number }) => jsonResult(await builds.getBuildStatus(requireJenkinsConfig(), job, build_number)));
|
|
194
224
|
}
|
|
@@ -212,10 +212,14 @@ export function registerPlaystoreTools(server) {
|
|
|
212
212
|
}
|
|
213
213
|
return textResult(`${lines.join('\n')}\n\n${JSON.stringify(allResults, null, 2)}`);
|
|
214
214
|
});
|
|
215
|
-
server.tool('playstore_list_reviews', 'Google Play 리뷰 목록
|
|
215
|
+
server.tool('playstore_list_reviews', 'Google Play 리뷰 목록 조회. developerComment 가 null 이면 미답변, 답변이 있으면 본문과 수정 시각을 반환한다 (답변은 리뷰당 하나 — playstore_reply_review 는 기존 답변을 교체한다).', { packageName: androidPackageName.describe('패키지명') }, async ({ packageName }) => {
|
|
216
216
|
const auth = requirePlayStoreAuth(packageName);
|
|
217
|
-
const reviews = await playstore.
|
|
218
|
-
|
|
217
|
+
const { reviews, truncated } = await playstore.listReviewsWithStatus(auth, packageName);
|
|
218
|
+
const result = jsonResult(reviews);
|
|
219
|
+
// 목록이 잘렸을 때만 안내 줄을 덧붙인다 — 전부 가져왔으면 응답은 예전과 같은 JSON 하나다.
|
|
220
|
+
if (truncated)
|
|
221
|
+
result.content.push({ type: 'text', text: `⚠️ 일부만 조회됨: ${truncated}` });
|
|
222
|
+
return result;
|
|
219
223
|
});
|
|
220
224
|
server.tool('playstore_reply_review', 'Google Play 리뷰에 답변', {
|
|
221
225
|
packageName: androidPackageName.describe('패키지명'),
|
package/dist/video/schemas.d.ts
CHANGED
|
@@ -186,10 +186,10 @@ export declare const assetSchema: z.ZodEffects<z.ZodObject<{
|
|
|
186
186
|
id: string;
|
|
187
187
|
path: string;
|
|
188
188
|
kind: "image" | "audio" | "video";
|
|
189
|
+
sha256: string;
|
|
189
190
|
createdAt: string;
|
|
190
191
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
191
192
|
license: string;
|
|
192
|
-
sha256: string;
|
|
193
193
|
allowedForRendering: boolean;
|
|
194
194
|
prompt?: string | undefined;
|
|
195
195
|
author?: string | undefined;
|
|
@@ -200,10 +200,10 @@ export declare const assetSchema: z.ZodEffects<z.ZodObject<{
|
|
|
200
200
|
id: string;
|
|
201
201
|
path: string;
|
|
202
202
|
kind: "image" | "audio" | "video";
|
|
203
|
+
sha256: string;
|
|
203
204
|
createdAt: string;
|
|
204
205
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
205
206
|
license: string;
|
|
206
|
-
sha256: string;
|
|
207
207
|
allowedForRendering: boolean;
|
|
208
208
|
prompt?: string | undefined;
|
|
209
209
|
author?: string | undefined;
|
|
@@ -214,10 +214,10 @@ export declare const assetSchema: z.ZodEffects<z.ZodObject<{
|
|
|
214
214
|
id: string;
|
|
215
215
|
path: string;
|
|
216
216
|
kind: "image" | "audio" | "video";
|
|
217
|
+
sha256: string;
|
|
217
218
|
createdAt: string;
|
|
218
219
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
219
220
|
license: string;
|
|
220
|
-
sha256: string;
|
|
221
221
|
allowedForRendering: boolean;
|
|
222
222
|
prompt?: string | undefined;
|
|
223
223
|
author?: string | undefined;
|
|
@@ -228,10 +228,10 @@ export declare const assetSchema: z.ZodEffects<z.ZodObject<{
|
|
|
228
228
|
id: string;
|
|
229
229
|
path: string;
|
|
230
230
|
kind: "image" | "audio" | "video";
|
|
231
|
+
sha256: string;
|
|
231
232
|
createdAt: string;
|
|
232
233
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
233
234
|
license: string;
|
|
234
|
-
sha256: string;
|
|
235
235
|
allowedForRendering: boolean;
|
|
236
236
|
prompt?: string | undefined;
|
|
237
237
|
author?: string | undefined;
|
|
@@ -260,10 +260,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
260
260
|
id: string;
|
|
261
261
|
path: string;
|
|
262
262
|
kind: "image" | "audio" | "video";
|
|
263
|
+
sha256: string;
|
|
263
264
|
createdAt: string;
|
|
264
265
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
265
266
|
license: string;
|
|
266
|
-
sha256: string;
|
|
267
267
|
allowedForRendering: boolean;
|
|
268
268
|
prompt?: string | undefined;
|
|
269
269
|
author?: string | undefined;
|
|
@@ -274,10 +274,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
274
274
|
id: string;
|
|
275
275
|
path: string;
|
|
276
276
|
kind: "image" | "audio" | "video";
|
|
277
|
+
sha256: string;
|
|
277
278
|
createdAt: string;
|
|
278
279
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
279
280
|
license: string;
|
|
280
|
-
sha256: string;
|
|
281
281
|
allowedForRendering: boolean;
|
|
282
282
|
prompt?: string | undefined;
|
|
283
283
|
author?: string | undefined;
|
|
@@ -288,10 +288,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
288
288
|
id: string;
|
|
289
289
|
path: string;
|
|
290
290
|
kind: "image" | "audio" | "video";
|
|
291
|
+
sha256: string;
|
|
291
292
|
createdAt: string;
|
|
292
293
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
293
294
|
license: string;
|
|
294
|
-
sha256: string;
|
|
295
295
|
allowedForRendering: boolean;
|
|
296
296
|
prompt?: string | undefined;
|
|
297
297
|
author?: string | undefined;
|
|
@@ -302,10 +302,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
302
302
|
id: string;
|
|
303
303
|
path: string;
|
|
304
304
|
kind: "image" | "audio" | "video";
|
|
305
|
+
sha256: string;
|
|
305
306
|
createdAt: string;
|
|
306
307
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
307
308
|
license: string;
|
|
308
|
-
sha256: string;
|
|
309
309
|
allowedForRendering: boolean;
|
|
310
310
|
prompt?: string | undefined;
|
|
311
311
|
author?: string | undefined;
|
|
@@ -320,10 +320,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
320
320
|
id: string;
|
|
321
321
|
path: string;
|
|
322
322
|
kind: "image" | "audio" | "video";
|
|
323
|
+
sha256: string;
|
|
323
324
|
createdAt: string;
|
|
324
325
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
325
326
|
license: string;
|
|
326
|
-
sha256: string;
|
|
327
327
|
allowedForRendering: boolean;
|
|
328
328
|
prompt?: string | undefined;
|
|
329
329
|
author?: string | undefined;
|
|
@@ -338,10 +338,10 @@ export declare const assetManifestSchema: z.ZodObject<{
|
|
|
338
338
|
id: string;
|
|
339
339
|
path: string;
|
|
340
340
|
kind: "image" | "audio" | "video";
|
|
341
|
+
sha256: string;
|
|
341
342
|
createdAt: string;
|
|
342
343
|
sourceType: "stock" | "generated" | "user-owned" | "licensed" | "reference-only";
|
|
343
344
|
license: string;
|
|
344
|
-
sha256: string;
|
|
345
345
|
allowedForRendering: boolean;
|
|
346
346
|
prompt?: string | undefined;
|
|
347
347
|
author?: string | undefined;
|
package/package.json
CHANGED
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary) + 도구 분류(write·destructive·local·idempotent → MCP annotations·confirm 가드) + toolset 그룹·폐기 별칭의 SSOT (src/lib/tool-registrar.ts 가 읽는다). src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 245,
|
|
4
4
|
"alwaysOn": [
|
|
5
5
|
"auth",
|
|
6
6
|
"checks"
|
|
@@ -557,7 +557,7 @@
|
|
|
557
557
|
"jenkins": {
|
|
558
558
|
"label": "Jenkins",
|
|
559
559
|
"credential": "Jenkins URL + API 토큰 (mimi-seed auth jenkins)",
|
|
560
|
-
"summary": "크리덴셜·keystore 업로드·잡
|
|
560
|
+
"summary": "크리덴셜·keystore 업로드·잡 생성/수정·빌드 실행(request_id 로 로컬 중복 방지, confirm 필요)·큐/빌드 상태 조회",
|
|
561
561
|
"tools": [
|
|
562
562
|
"jenkins_status",
|
|
563
563
|
"jenkins_save_config",
|
|
@@ -568,7 +568,10 @@
|
|
|
568
568
|
"jenkins_list_jobs",
|
|
569
569
|
"jenkins_get_job_config",
|
|
570
570
|
"jenkins_create_job",
|
|
571
|
-
"jenkins_update_job"
|
|
571
|
+
"jenkins_update_job",
|
|
572
|
+
"jenkins_trigger_build",
|
|
573
|
+
"jenkins_get_queue_item",
|
|
574
|
+
"jenkins_get_build_status"
|
|
572
575
|
],
|
|
573
576
|
"write": [
|
|
574
577
|
"jenkins_save_config"
|
|
@@ -578,7 +581,8 @@
|
|
|
578
581
|
"jenkins_create_job",
|
|
579
582
|
"jenkins_update_job",
|
|
580
583
|
"jenkins_create_credential",
|
|
581
|
-
"jenkins_upload_keystore"
|
|
584
|
+
"jenkins_upload_keystore",
|
|
585
|
+
"jenkins_trigger_build"
|
|
582
586
|
],
|
|
583
587
|
"ownGate": [
|
|
584
588
|
"jenkins_create_credential",
|