relayax-cli 0.1.95 → 0.1.97
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/dist/commands/init.js +0 -118
- package/dist/commands/publish.d.ts +0 -9
- package/dist/commands/publish.js +96 -30
- package/dist/lib/command-adapter.js +49 -24
- package/package.json +1 -1
package/dist/commands/init.js
CHANGED
|
@@ -93,87 +93,6 @@ function isTeamProject(projectPath) {
|
|
|
93
93
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length > 0;
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
|
-
/** 레거시 User 커맨드 ID (이전에 로컬에 설치되던 것) */
|
|
97
|
-
const LEGACY_LOCAL_COMMAND_IDS = ['relay-explore', 'relay-install', 'relay-publish'];
|
|
98
|
-
/**
|
|
99
|
-
* 레거시 구조를 감지하고 마이그레이션한다.
|
|
100
|
-
* - relay.yaml (루트) → .relay/relay.yaml
|
|
101
|
-
* - portfolio/ (루트) → .relay/portfolio/
|
|
102
|
-
* - 로컬 레거시 슬래시 커맨드 제거 (글로벌로 이동되므로)
|
|
103
|
-
*/
|
|
104
|
-
/** 루트에 있으면 .relay/ 안으로 옮길 디렉토리 */
|
|
105
|
-
const LEGACY_CONTENT_DIRS = ['skills', 'agents', 'rules', 'commands', 'portfolio'];
|
|
106
|
-
function detectLegacy(projectPath) {
|
|
107
|
-
const details = [];
|
|
108
|
-
if (fs_1.default.existsSync(path_1.default.join(projectPath, 'relay.yaml'))) {
|
|
109
|
-
details.push('relay.yaml → .relay/relay.yaml');
|
|
110
|
-
}
|
|
111
|
-
for (const dir of LEGACY_CONTENT_DIRS) {
|
|
112
|
-
const legacyDir = path_1.default.join(projectPath, dir);
|
|
113
|
-
if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory()) {
|
|
114
|
-
const hasFiles = fs_1.default.readdirSync(legacyDir).filter((f) => !f.startsWith('.')).length > 0;
|
|
115
|
-
if (hasFiles) {
|
|
116
|
-
details.push(`${dir}/ → .relay/${dir}/`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
// 로컬에 레거시 슬래시 커맨드가 있는지 확인
|
|
121
|
-
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
122
|
-
for (const tool of detected) {
|
|
123
|
-
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
124
|
-
if (!fs_1.default.existsSync(cmdDir))
|
|
125
|
-
continue;
|
|
126
|
-
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
127
|
-
if (fs_1.default.existsSync(path_1.default.join(cmdDir, `${cmdId}.md`))) {
|
|
128
|
-
details.push(`${tool.skillsDir}/commands/relay/ 레거시 커맨드 정리`);
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return { hasLegacy: details.length > 0, details };
|
|
134
|
-
}
|
|
135
|
-
function runMigration(projectPath) {
|
|
136
|
-
const result = { relayYaml: false, portfolio: false, localCommandsCleaned: 0 };
|
|
137
|
-
const relayDir = path_1.default.join(projectPath, '.relay');
|
|
138
|
-
fs_1.default.mkdirSync(relayDir, { recursive: true });
|
|
139
|
-
// 1. relay.yaml 이동
|
|
140
|
-
const legacyYaml = path_1.default.join(projectPath, 'relay.yaml');
|
|
141
|
-
const newYaml = path_1.default.join(relayDir, 'relay.yaml');
|
|
142
|
-
if (fs_1.default.existsSync(legacyYaml) && !fs_1.default.existsSync(newYaml)) {
|
|
143
|
-
fs_1.default.renameSync(legacyYaml, newYaml);
|
|
144
|
-
result.relayYaml = true;
|
|
145
|
-
}
|
|
146
|
-
// 2. 콘텐츠 디렉토리 이동 (skills, agents, rules, commands, portfolio)
|
|
147
|
-
for (const dir of LEGACY_CONTENT_DIRS) {
|
|
148
|
-
const legacyDir = path_1.default.join(projectPath, dir);
|
|
149
|
-
const newDir = path_1.default.join(relayDir, dir);
|
|
150
|
-
if (fs_1.default.existsSync(legacyDir) && fs_1.default.statSync(legacyDir).isDirectory() && !fs_1.default.existsSync(newDir)) {
|
|
151
|
-
fs_1.default.renameSync(legacyDir, newDir);
|
|
152
|
-
if (dir === 'portfolio')
|
|
153
|
-
result.portfolio = true;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
// 3. 로컬 레거시 슬래시 커맨드 제거
|
|
157
|
-
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
158
|
-
for (const tool of detected) {
|
|
159
|
-
const cmdDir = path_1.default.join(projectPath, tool.skillsDir, 'commands', 'relay');
|
|
160
|
-
if (!fs_1.default.existsSync(cmdDir))
|
|
161
|
-
continue;
|
|
162
|
-
for (const cmdId of LEGACY_LOCAL_COMMAND_IDS) {
|
|
163
|
-
const cmdPath = path_1.default.join(cmdDir, `${cmdId}.md`);
|
|
164
|
-
if (fs_1.default.existsSync(cmdPath)) {
|
|
165
|
-
fs_1.default.unlinkSync(cmdPath);
|
|
166
|
-
result.localCommandsCleaned++;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
// 디렉토리가 비었으면 삭제
|
|
170
|
-
const remaining = fs_1.default.readdirSync(cmdDir);
|
|
171
|
-
if (remaining.length === 0) {
|
|
172
|
-
fs_1.default.rmdirSync(cmdDir);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
96
|
function registerInit(program) {
|
|
178
97
|
program
|
|
179
98
|
.command('init')
|
|
@@ -183,42 +102,6 @@ function registerInit(program) {
|
|
|
183
102
|
.action(async (opts) => {
|
|
184
103
|
const json = program.opts().json ?? false;
|
|
185
104
|
const projectPath = process.cwd();
|
|
186
|
-
// ── 0. 레거시 마이그레이션 ──
|
|
187
|
-
const legacy = detectLegacy(projectPath);
|
|
188
|
-
let migrated = false;
|
|
189
|
-
if (legacy.hasLegacy) {
|
|
190
|
-
if (json) {
|
|
191
|
-
// JSON 모드: 자동 마이그레이션
|
|
192
|
-
const migrationResult = runMigration(projectPath);
|
|
193
|
-
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
194
|
-
}
|
|
195
|
-
else if (process.stdin.isTTY) {
|
|
196
|
-
console.log('\n \x1b[33m⚠ 레거시 구조 감지\x1b[0m\n');
|
|
197
|
-
for (const d of legacy.details) {
|
|
198
|
-
console.log(` ${d}`);
|
|
199
|
-
}
|
|
200
|
-
console.log();
|
|
201
|
-
const { confirm } = await import('@inquirer/prompts');
|
|
202
|
-
const doMigrate = await confirm({ message: '마이그레이션할까요?', default: true });
|
|
203
|
-
if (doMigrate) {
|
|
204
|
-
const migrationResult = runMigration(projectPath);
|
|
205
|
-
migrated = true;
|
|
206
|
-
console.log(`\n \x1b[32m✓ 마이그레이션 완료\x1b[0m`);
|
|
207
|
-
if (migrationResult.relayYaml)
|
|
208
|
-
console.log(' relay.yaml → .relay/relay.yaml');
|
|
209
|
-
if (migrationResult.portfolio)
|
|
210
|
-
console.log(' portfolio/ → .relay/portfolio/');
|
|
211
|
-
if (migrationResult.localCommandsCleaned > 0)
|
|
212
|
-
console.log(` 레거시 로컬 커맨드 ${migrationResult.localCommandsCleaned}개 제거`);
|
|
213
|
-
console.log();
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
// 비TTY, 비JSON: 자동 마이그레이션
|
|
218
|
-
const migrationResult = runMigration(projectPath);
|
|
219
|
-
migrated = migrationResult.relayYaml || migrationResult.portfolio || migrationResult.localCommandsCleaned > 0;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
105
|
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectPath);
|
|
223
106
|
const detectedIds = new Set(detected.map((t) => t.value));
|
|
224
107
|
const isBuilder = isTeamProject(projectPath);
|
|
@@ -303,7 +186,6 @@ function registerInit(program) {
|
|
|
303
186
|
console.log(JSON.stringify({
|
|
304
187
|
status: 'ok',
|
|
305
188
|
mode: isBuilder ? 'builder' : 'user',
|
|
306
|
-
migrated,
|
|
307
189
|
global: {
|
|
308
190
|
status: globalStatus,
|
|
309
191
|
path: (0, command_adapter_js_1.getGlobalCommandDir)(),
|
|
@@ -14,14 +14,6 @@ export interface RequiresMcp {
|
|
|
14
14
|
};
|
|
15
15
|
env?: string[];
|
|
16
16
|
}
|
|
17
|
-
export interface RequiresConnector {
|
|
18
|
-
name: string;
|
|
19
|
-
type: string;
|
|
20
|
-
auth?: string;
|
|
21
|
-
env?: string;
|
|
22
|
-
required?: boolean;
|
|
23
|
-
description?: string;
|
|
24
|
-
}
|
|
25
17
|
export interface RequiresEnv {
|
|
26
18
|
name: string;
|
|
27
19
|
required?: boolean;
|
|
@@ -37,7 +29,6 @@ export interface Requires {
|
|
|
37
29
|
npm?: (string | RequiresNpm)[];
|
|
38
30
|
env?: RequiresEnv[];
|
|
39
31
|
teams?: string[];
|
|
40
|
-
connectors?: RequiresConnector[];
|
|
41
32
|
runtime?: {
|
|
42
33
|
node?: string;
|
|
43
34
|
python?: string;
|
package/dist/commands/publish.js
CHANGED
|
@@ -21,13 +21,34 @@ function parseRelayYaml(content) {
|
|
|
21
21
|
const tags = Array.isArray(raw.tags)
|
|
22
22
|
? raw.tags.map((t) => String(t))
|
|
23
23
|
: [];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
// Parse portfolio slots structure
|
|
25
|
+
const rawPortfolio = raw.portfolio;
|
|
26
|
+
const portfolio = {};
|
|
27
|
+
if (rawPortfolio && typeof rawPortfolio === 'object' && !Array.isArray(rawPortfolio)) {
|
|
28
|
+
// New slot-based format: { cover: {...}, demo: {...}, gallery: [...] }
|
|
29
|
+
if (rawPortfolio.cover && typeof rawPortfolio.cover === 'object') {
|
|
30
|
+
const c = rawPortfolio.cover;
|
|
31
|
+
if (c.path)
|
|
32
|
+
portfolio.cover = { path: String(c.path) };
|
|
33
|
+
}
|
|
34
|
+
if (rawPortfolio.demo && typeof rawPortfolio.demo === 'object') {
|
|
35
|
+
const d = rawPortfolio.demo;
|
|
36
|
+
portfolio.demo = {
|
|
37
|
+
type: String(d.type ?? 'gif'),
|
|
38
|
+
path: d.path ? String(d.path) : undefined,
|
|
39
|
+
url: d.url ? String(d.url) : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(rawPortfolio.gallery)) {
|
|
43
|
+
portfolio.gallery = rawPortfolio.gallery
|
|
44
|
+
.map((g) => ({
|
|
45
|
+
path: String(g.path ?? ''),
|
|
46
|
+
title: String(g.title ?? ''),
|
|
47
|
+
description: g.description ? String(g.description) : undefined,
|
|
48
|
+
}))
|
|
49
|
+
.filter((g) => g.path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
31
52
|
const requires = raw.requires;
|
|
32
53
|
const rawVisibility = String(raw.visibility ?? '');
|
|
33
54
|
const visibility = rawVisibility === 'login-only' ? 'login-only'
|
|
@@ -86,28 +107,58 @@ function countDir(teamDir, dirName) {
|
|
|
86
107
|
return fs_1.default.readdirSync(dirPath).filter((f) => !f.startsWith('.')).length;
|
|
87
108
|
}
|
|
88
109
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* 2. 없으면 ./portfolio/ 디렉토리 자동 스캔
|
|
110
|
+
* 슬롯 기반 포트폴리오를 PortfolioEntry[] 로 평탄화한다.
|
|
111
|
+
* relay.yaml에 슬롯이 정의되어 있으면 사용, 없으면 portfolio/ 자동 스캔.
|
|
92
112
|
*/
|
|
93
|
-
function collectPortfolio(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
113
|
+
function collectPortfolio(relayDir, slots) {
|
|
114
|
+
const entries = [];
|
|
115
|
+
// Cover
|
|
116
|
+
if (slots.cover?.path) {
|
|
117
|
+
const absPath = path_1.default.resolve(relayDir, slots.cover.path);
|
|
118
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
119
|
+
entries.push({ path: slots.cover.path, title: 'Cover', slot_type: 'cover' });
|
|
120
|
+
}
|
|
99
121
|
}
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
// Demo
|
|
123
|
+
if (slots.demo) {
|
|
124
|
+
if (slots.demo.type === 'video_url' && slots.demo.url) {
|
|
125
|
+
entries.push({ path: '', title: 'Demo', slot_type: 'demo', demo_url: slots.demo.url });
|
|
126
|
+
}
|
|
127
|
+
else if (slots.demo.path) {
|
|
128
|
+
const absPath = path_1.default.resolve(relayDir, slots.demo.path);
|
|
129
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
130
|
+
entries.push({ path: slots.demo.path, title: 'Demo', slot_type: 'demo' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Gallery
|
|
135
|
+
if (slots.gallery && slots.gallery.length > 0) {
|
|
136
|
+
for (const g of slots.gallery.slice(0, 5)) {
|
|
137
|
+
const absPath = path_1.default.resolve(relayDir, g.path);
|
|
138
|
+
if (fs_1.default.existsSync(absPath)) {
|
|
139
|
+
entries.push({ path: g.path, title: g.title, description: g.description, slot_type: 'gallery' });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// If no slots defined, auto-scan portfolio/ directory
|
|
144
|
+
if (entries.length === 0) {
|
|
145
|
+
const portfolioDir = path_1.default.join(relayDir, 'portfolio');
|
|
146
|
+
if (fs_1.default.existsSync(portfolioDir)) {
|
|
147
|
+
const files = fs_1.default.readdirSync(portfolioDir)
|
|
148
|
+
.filter((f) => IMAGE_EXTS.some((ext) => f.toLowerCase().endsWith(ext)))
|
|
149
|
+
.sort();
|
|
150
|
+
// First image as cover, rest as gallery
|
|
151
|
+
for (let i = 0; i < files.length && i < 6; i++) {
|
|
152
|
+
const f = files[i];
|
|
153
|
+
entries.push({
|
|
154
|
+
path: path_1.default.join('portfolio', f),
|
|
155
|
+
title: path_1.default.basename(f, path_1.default.extname(f)).replace(/[-_]/g, ' '),
|
|
156
|
+
slot_type: i === 0 ? 'cover' : 'gallery',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return entries;
|
|
111
162
|
}
|
|
112
163
|
/**
|
|
113
164
|
* long_description을 결정한다.
|
|
@@ -144,12 +195,24 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
144
195
|
const form = new FormData();
|
|
145
196
|
form.append('package', blob, `${metadata.slug}-${metadata.version}.tar.gz`);
|
|
146
197
|
form.append('metadata', JSON.stringify(metadata));
|
|
147
|
-
// Attach portfolio images (with size validation)
|
|
198
|
+
// Attach portfolio images (with size validation and slot_type)
|
|
148
199
|
if (portfolioEntries.length > 0) {
|
|
149
200
|
const portfolioMeta = [];
|
|
150
201
|
let totalImageSize = 0;
|
|
202
|
+
let fileIndex = 0;
|
|
151
203
|
for (let i = 0; i < portfolioEntries.length; i++) {
|
|
152
204
|
const entry = portfolioEntries[i];
|
|
205
|
+
// video_url demo has no file to upload
|
|
206
|
+
if (entry.slot_type === 'demo' && entry.demo_url && !entry.path) {
|
|
207
|
+
portfolioMeta.push({
|
|
208
|
+
title: entry.title,
|
|
209
|
+
description: entry.description,
|
|
210
|
+
sort_order: i,
|
|
211
|
+
slot_type: entry.slot_type,
|
|
212
|
+
demo_url: entry.demo_url,
|
|
213
|
+
});
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
153
216
|
const absPath = path_1.default.resolve(teamDir, entry.path);
|
|
154
217
|
const imgBuffer = fs_1.default.readFileSync(absPath);
|
|
155
218
|
if (imgBuffer.length > MAX_IMAGE_SIZE) {
|
|
@@ -162,13 +225,16 @@ async function publishToApi(token, tarPath, metadata, teamDir, portfolioEntries)
|
|
|
162
225
|
throw new Error(`포트폴리오 이미지 총 크기가 너무 큽니다 (${totalMB}MB). 최대 ${MAX_TOTAL_UPLOAD_SIZE / 1024 / 1024}MB까지 허용됩니다.`);
|
|
163
226
|
}
|
|
164
227
|
const ext = path_1.default.extname(entry.path).slice(1) || 'png';
|
|
165
|
-
const mimeType = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
228
|
+
const mimeType = ext === 'gif' ? 'image/gif' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
|
|
166
229
|
const imgBlob = new Blob([imgBuffer], { type: mimeType });
|
|
167
|
-
form.append(`portfolio[${
|
|
230
|
+
form.append(`portfolio[${fileIndex}]`, imgBlob, path_1.default.basename(entry.path));
|
|
231
|
+
fileIndex++;
|
|
168
232
|
portfolioMeta.push({
|
|
169
233
|
title: entry.title,
|
|
170
234
|
description: entry.description,
|
|
171
235
|
sort_order: i,
|
|
236
|
+
slot_type: entry.slot_type,
|
|
237
|
+
demo_url: entry.demo_url,
|
|
172
238
|
});
|
|
173
239
|
}
|
|
174
240
|
form.append('portfolio_meta', JSON.stringify(portfolioMeta));
|
|
@@ -229,8 +229,7 @@ ${LOGIN_JIT_GUIDE}
|
|
|
229
229
|
|
|
230
230
|
- **cli**: 파일에서 참조하는 CLI 도구 (playwright, ffmpeg, sharp 등)
|
|
231
231
|
- **npm**: import/require되는 npm 패키지
|
|
232
|
-
- **mcp**: MCP 서버
|
|
233
|
-
- **connectors**: 외부 서비스 연결 (Notion API, Slack webhook, Supabase 등 — type, auth 방식, 필요한 env)
|
|
232
|
+
- **mcp**: MCP 서버 설정 — 외부 서비스 연결 포함 (name, package, config, 필요한 env)
|
|
234
233
|
- **runtime**: Node.js/Python 등 최소 버전 요구
|
|
235
234
|
- **permissions**: 필요한 에이전트 권한 (filesystem, network, shell)
|
|
236
235
|
- **teams**: 의존하는 다른 relay 팀
|
|
@@ -267,13 +266,10 @@ requires:
|
|
|
267
266
|
command: "npx"
|
|
268
267
|
args: ["-y", "@supabase/mcp-server"]
|
|
269
268
|
env: [SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY]
|
|
270
|
-
connectors:
|
|
271
269
|
- name: notion
|
|
272
|
-
|
|
273
|
-
auth: bearer_token
|
|
274
|
-
env: NOTION_API_KEY
|
|
270
|
+
package: "@notionhq/mcp-server"
|
|
275
271
|
required: false
|
|
276
|
-
|
|
272
|
+
env: [NOTION_API_KEY]
|
|
277
273
|
runtime:
|
|
278
274
|
node: ">=18"
|
|
279
275
|
permissions:
|
|
@@ -283,18 +279,36 @@ requires:
|
|
|
283
279
|
- contents-team
|
|
284
280
|
\`\`\`
|
|
285
281
|
|
|
286
|
-
### 4. 포트폴리오 생성
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
-
|
|
292
|
-
|
|
293
|
-
|
|
282
|
+
### 4. 포트폴리오 생성 (슬롯 기반)
|
|
283
|
+
|
|
284
|
+
포트폴리오는 3가지 슬롯으로 구성됩니다. .relay/portfolio/ 디렉토리에 저장합니다.
|
|
285
|
+
|
|
286
|
+
#### 슬롯 1: cover (필수 — 마켓 리스팅 카드)
|
|
287
|
+
- 규격: 1200x630px, WebP, 최대 500KB
|
|
288
|
+
- 팀 구조를 요약하는 카드 HTML을 생성합니다:
|
|
289
|
+
- 팀 이름, 버전
|
|
290
|
+
- Skills 목록 (이름 + 설명)
|
|
291
|
+
- Commands 목록
|
|
292
|
+
- 주요 기능 요약
|
|
293
|
+
- 생성된 HTML을 Playwright로 1200x630 뷰포트에서 스크린샷 캡처합니다.
|
|
294
|
+
- .relay/portfolio/cover.png에 저장합니다.
|
|
295
|
+
- 사용자에게 "이 cover를 사용할까요?" 확인. 직접 제공도 가능.
|
|
296
|
+
|
|
297
|
+
#### 슬롯 2: demo (선택 — 동작 시연)
|
|
298
|
+
- 팀 유형에 따라 제안 여부를 판단합니다:
|
|
299
|
+
- 브라우저 자동화 키워드 감지 (playwright, puppeteer, crawl, scrape, browser) → GIF 데모 제안
|
|
300
|
+
- 그 외 → 건너뜀 (사용자가 원하면 수동 추가)
|
|
301
|
+
- GIF: 최대 5MB, .relay/portfolio/demo.gif에 저장
|
|
302
|
+
- 또는 외부 영상 URL (YouTube, Loom 등)을 relay.yaml에 기록
|
|
303
|
+
- 사용자에게 "데모를 녹화할까요?" / "영상 URL이 있나요?" 확인
|
|
304
|
+
|
|
305
|
+
#### 슬롯 3: gallery (선택 — 결과물 쇼케이스, 최대 5장)
|
|
306
|
+
- 규격: 800x600px 이하, WebP, 각 500KB 이하
|
|
294
307
|
- output/, results/, examples/ 디렉토리를 스캔합니다.
|
|
295
|
-
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
308
|
+
- 큰 이미지 → 사용자에게 "어느 영역을 보여줄까요?" 확인 후 핵심 영역 crop
|
|
309
|
+
- HTML 파일 → Playwright 스크린샷으로 변환
|
|
310
|
+
- 사용자가 포트폴리오에 포함할 항목을 선택합니다.
|
|
311
|
+
- .relay/portfolio/에 저장합니다.
|
|
298
312
|
|
|
299
313
|
### 5. 메타데이터 생성
|
|
300
314
|
- description: skills 내용 기반으로 자동 생성합니다.
|
|
@@ -303,7 +317,19 @@ requires:
|
|
|
303
317
|
- 사용자에게 확인: "이대로 배포할까요?"
|
|
304
318
|
|
|
305
319
|
### 6. .relay/relay.yaml 업데이트
|
|
306
|
-
-
|
|
320
|
+
- 메타데이터, requires, 포트폴리오 슬롯을 .relay/relay.yaml에 반영합니다.
|
|
321
|
+
|
|
322
|
+
\`\`\`yaml
|
|
323
|
+
portfolio:
|
|
324
|
+
cover:
|
|
325
|
+
path: portfolio/cover.png
|
|
326
|
+
demo: # 선택
|
|
327
|
+
type: gif
|
|
328
|
+
path: portfolio/demo.gif
|
|
329
|
+
gallery: # 선택, 최대 5장
|
|
330
|
+
- path: portfolio/example-1.png
|
|
331
|
+
title: "카드뉴스 예시"
|
|
332
|
+
\`\`\`
|
|
307
333
|
|
|
308
334
|
### 7. 배포
|
|
309
335
|
- \`relay publish\` 명령어를 실행합니다.
|
|
@@ -314,12 +340,11 @@ requires:
|
|
|
314
340
|
사용자: /relay-publish
|
|
315
341
|
→ 팀 구조 분석: skills 3개, commands 5개
|
|
316
342
|
→ 보안 스캔: ✓ 시크릿 없음
|
|
317
|
-
→ 환경변수 감지: OPENAI_API_KEY, DATABASE_URL
|
|
318
|
-
→ "OPENAI_API_KEY — 필수인가요?" → Yes
|
|
319
|
-
→ "DATABASE_URL — 필수인가요?" → No, 선택
|
|
320
|
-
→ npm 의존성 감지: sharp (필수)
|
|
343
|
+
→ 환경변수 감지: OPENAI_API_KEY (필수), DATABASE_URL (선택)
|
|
321
344
|
→ requires 업데이트 완료
|
|
322
|
-
→
|
|
345
|
+
→ cover 생성: 팀 구조 카드 HTML → 1200x630 스크린샷
|
|
346
|
+
→ demo: "브라우저 자동화 감지. GIF 데모 녹화할까요?" → Yes → 녹화 완료
|
|
347
|
+
→ gallery: output/ 스캔 → "카드뉴스 예시.png 포함?" → Yes → crop + 리사이즈
|
|
323
348
|
→ relay publish 실행
|
|
324
349
|
→ "배포 완료! URL: https://relayax.com/teams/my-team"`,
|
|
325
350
|
},
|