relayax-cli 0.1.95 → 0.1.96
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.js +96 -30
- package/dist/lib/command-adapter.js +46 -17
- 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)(),
|
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));
|
|
@@ -283,18 +283,36 @@ requires:
|
|
|
283
283
|
- contents-team
|
|
284
284
|
\`\`\`
|
|
285
285
|
|
|
286
|
-
### 4. 포트폴리오 생성
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
-
|
|
292
|
-
|
|
293
|
-
|
|
286
|
+
### 4. 포트폴리오 생성 (슬롯 기반)
|
|
287
|
+
|
|
288
|
+
포트폴리오는 3가지 슬롯으로 구성됩니다. .relay/portfolio/ 디렉토리에 저장합니다.
|
|
289
|
+
|
|
290
|
+
#### 슬롯 1: cover (필수 — 마켓 리스팅 카드)
|
|
291
|
+
- 규격: 1200x630px, WebP, 최대 500KB
|
|
292
|
+
- 팀 구조를 요약하는 카드 HTML을 생성합니다:
|
|
293
|
+
- 팀 이름, 버전
|
|
294
|
+
- Skills 목록 (이름 + 설명)
|
|
295
|
+
- Commands 목록
|
|
296
|
+
- 주요 기능 요약
|
|
297
|
+
- 생성된 HTML을 Playwright로 1200x630 뷰포트에서 스크린샷 캡처합니다.
|
|
298
|
+
- .relay/portfolio/cover.png에 저장합니다.
|
|
299
|
+
- 사용자에게 "이 cover를 사용할까요?" 확인. 직접 제공도 가능.
|
|
300
|
+
|
|
301
|
+
#### 슬롯 2: demo (선택 — 동작 시연)
|
|
302
|
+
- 팀 유형에 따라 제안 여부를 판단합니다:
|
|
303
|
+
- 브라우저 자동화 키워드 감지 (playwright, puppeteer, crawl, scrape, browser) → GIF 데모 제안
|
|
304
|
+
- 그 외 → 건너뜀 (사용자가 원하면 수동 추가)
|
|
305
|
+
- GIF: 최대 5MB, .relay/portfolio/demo.gif에 저장
|
|
306
|
+
- 또는 외부 영상 URL (YouTube, Loom 등)을 relay.yaml에 기록
|
|
307
|
+
- 사용자에게 "데모를 녹화할까요?" / "영상 URL이 있나요?" 확인
|
|
308
|
+
|
|
309
|
+
#### 슬롯 3: gallery (선택 — 결과물 쇼케이스, 최대 5장)
|
|
310
|
+
- 규격: 800x600px 이하, WebP, 각 500KB 이하
|
|
294
311
|
- output/, results/, examples/ 디렉토리를 스캔합니다.
|
|
295
|
-
-
|
|
296
|
-
-
|
|
297
|
-
-
|
|
312
|
+
- 큰 이미지 → 사용자에게 "어느 영역을 보여줄까요?" 확인 후 핵심 영역 crop
|
|
313
|
+
- HTML 파일 → Playwright 스크린샷으로 변환
|
|
314
|
+
- 사용자가 포트폴리오에 포함할 항목을 선택합니다.
|
|
315
|
+
- .relay/portfolio/에 저장합니다.
|
|
298
316
|
|
|
299
317
|
### 5. 메타데이터 생성
|
|
300
318
|
- description: skills 내용 기반으로 자동 생성합니다.
|
|
@@ -303,7 +321,19 @@ requires:
|
|
|
303
321
|
- 사용자에게 확인: "이대로 배포할까요?"
|
|
304
322
|
|
|
305
323
|
### 6. .relay/relay.yaml 업데이트
|
|
306
|
-
-
|
|
324
|
+
- 메타데이터, requires, 포트폴리오 슬롯을 .relay/relay.yaml에 반영합니다.
|
|
325
|
+
|
|
326
|
+
\`\`\`yaml
|
|
327
|
+
portfolio:
|
|
328
|
+
cover:
|
|
329
|
+
path: portfolio/cover.png
|
|
330
|
+
demo: # 선택
|
|
331
|
+
type: gif
|
|
332
|
+
path: portfolio/demo.gif
|
|
333
|
+
gallery: # 선택, 최대 5장
|
|
334
|
+
- path: portfolio/example-1.png
|
|
335
|
+
title: "카드뉴스 예시"
|
|
336
|
+
\`\`\`
|
|
307
337
|
|
|
308
338
|
### 7. 배포
|
|
309
339
|
- \`relay publish\` 명령어를 실행합니다.
|
|
@@ -314,12 +344,11 @@ requires:
|
|
|
314
344
|
사용자: /relay-publish
|
|
315
345
|
→ 팀 구조 분석: skills 3개, commands 5개
|
|
316
346
|
→ 보안 스캔: ✓ 시크릿 없음
|
|
317
|
-
→ 환경변수 감지: OPENAI_API_KEY, DATABASE_URL
|
|
318
|
-
→ "OPENAI_API_KEY — 필수인가요?" → Yes
|
|
319
|
-
→ "DATABASE_URL — 필수인가요?" → No, 선택
|
|
320
|
-
→ npm 의존성 감지: sharp (필수)
|
|
347
|
+
→ 환경변수 감지: OPENAI_API_KEY (필수), DATABASE_URL (선택)
|
|
321
348
|
→ requires 업데이트 완료
|
|
322
|
-
→
|
|
349
|
+
→ cover 생성: 팀 구조 카드 HTML → 1200x630 스크린샷
|
|
350
|
+
→ demo: "브라우저 자동화 감지. GIF 데모 녹화할까요?" → Yes → 녹화 완료
|
|
351
|
+
→ gallery: output/ 스캔 → "카드뉴스 예시.png 포함?" → Yes → crop + 리사이즈
|
|
323
352
|
→ relay publish 실행
|
|
324
353
|
→ "배포 완료! URL: https://relayax.com/teams/my-team"`,
|
|
325
354
|
},
|