relayax-cli 0.3.45 → 0.3.48
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/package.js +12 -0
- package/dist/lib/ai-tools.d.ts +16 -0
- package/dist/lib/ai-tools.js +34 -0
- package/dist/lib/config.js +18 -10
- package/dist/lib/guide.js +5 -6
- package/dist/prompts/_setup-cli.md +15 -11
- package/dist/prompts/_setup-environment.md +32 -0
- package/dist/prompts/_setup-login.md +9 -15
- package/dist/prompts/index.d.ts +1 -0
- package/dist/prompts/index.js +2 -1
- package/package.json +1 -1
package/dist/commands/package.js
CHANGED
|
@@ -298,6 +298,18 @@ function registerPackage(program) {
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
|
+
// 마운트 경로 스캔 (Cowork/sandbox 환경)
|
|
302
|
+
for (const { tool, basePath } of (0, ai_tools_js_1.detectMountedCLIs)()) {
|
|
303
|
+
const items = (0, ai_tools_js_1.scanMountedItems)(basePath, tool);
|
|
304
|
+
if (items.length > 0) {
|
|
305
|
+
sources.push({
|
|
306
|
+
path: `${basePath}/${tool.skillsDir}`,
|
|
307
|
+
location: 'global',
|
|
308
|
+
name: `${tool.name} (mounted)`,
|
|
309
|
+
items,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
301
313
|
// ~/.relay/agents/ 에 기존 에이전트 패키지가 있는지 스캔
|
|
302
314
|
const globalAgentsDir = path_1.default.join(homeDir ?? os_1.default.homedir(), '.relay', 'agents');
|
|
303
315
|
const existingAgents = [];
|
package/dist/lib/ai-tools.d.ts
CHANGED
|
@@ -12,6 +12,18 @@ export declare const AI_TOOLS: AITool[];
|
|
|
12
12
|
* 프로젝트 디렉토리에서 에이전트 CLI 디렉토리를 감지한다.
|
|
13
13
|
*/
|
|
14
14
|
export declare function detectAgentCLIs(projectPath: string): AITool[];
|
|
15
|
+
/**
|
|
16
|
+
* Cowork/sandbox 환경의 마운트 경로 후보를 반환한다.
|
|
17
|
+
* /sessions/<id>/mnt/ 같은 경로에 실제 파일이 마운트됨.
|
|
18
|
+
*/
|
|
19
|
+
export declare function detectMountPaths(): string[];
|
|
20
|
+
/**
|
|
21
|
+
* 마운트 경로에서 에이전트 CLI 디렉토리를 감지한다.
|
|
22
|
+
*/
|
|
23
|
+
export declare function detectMountedCLIs(): {
|
|
24
|
+
tool: AITool;
|
|
25
|
+
basePath: string;
|
|
26
|
+
}[];
|
|
15
27
|
/**
|
|
16
28
|
* 홈 디렉토리에서 글로벌 에이전트 CLI 디렉토리를 감지한다.
|
|
17
29
|
* ~/{skillsDir}/ 가 존재하는 CLI를 반환.
|
|
@@ -32,3 +44,7 @@ export declare function scanLocalItems(projectPath: string, tool: AITool): Conte
|
|
|
32
44
|
* 글로벌 홈 디렉토리 소스의 개별 스킬/에이전트/커맨드/룰 항목을 반환한다.
|
|
33
45
|
*/
|
|
34
46
|
export declare function scanGlobalItems(tool: AITool, home?: string): ContentItem[];
|
|
47
|
+
/**
|
|
48
|
+
* 마운트 경로의 개별 스킬/에이전트/커맨드/룰 항목을 반환한다.
|
|
49
|
+
*/
|
|
50
|
+
export declare function scanMountedItems(basePath: string, tool: AITool): ContentItem[];
|
package/dist/lib/ai-tools.js
CHANGED
|
@@ -5,9 +5,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AI_TOOLS = void 0;
|
|
7
7
|
exports.detectAgentCLIs = detectAgentCLIs;
|
|
8
|
+
exports.detectMountPaths = detectMountPaths;
|
|
9
|
+
exports.detectMountedCLIs = detectMountedCLIs;
|
|
8
10
|
exports.detectGlobalCLIs = detectGlobalCLIs;
|
|
9
11
|
exports.scanLocalItems = scanLocalItems;
|
|
10
12
|
exports.scanGlobalItems = scanGlobalItems;
|
|
13
|
+
exports.scanMountedItems = scanMountedItems;
|
|
11
14
|
const fs_1 = __importDefault(require("fs"));
|
|
12
15
|
const os_1 = __importDefault(require("os"));
|
|
13
16
|
const path_1 = __importDefault(require("path"));
|
|
@@ -47,6 +50,31 @@ exports.AI_TOOLS = [
|
|
|
47
50
|
function detectAgentCLIs(projectPath) {
|
|
48
51
|
return exports.AI_TOOLS.filter((tool) => fs_1.default.existsSync(path_1.default.join(projectPath, tool.skillsDir)));
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Cowork/sandbox 환경의 마운트 경로 후보를 반환한다.
|
|
55
|
+
* /sessions/<id>/mnt/ 같은 경로에 실제 파일이 마운트됨.
|
|
56
|
+
*/
|
|
57
|
+
function detectMountPaths() {
|
|
58
|
+
const home = os_1.default.homedir();
|
|
59
|
+
const mntPath = path_1.default.join(home, 'mnt');
|
|
60
|
+
if (!fs_1.default.existsSync(mntPath))
|
|
61
|
+
return [];
|
|
62
|
+
return [mntPath];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 마운트 경로에서 에이전트 CLI 디렉토리를 감지한다.
|
|
66
|
+
*/
|
|
67
|
+
function detectMountedCLIs() {
|
|
68
|
+
const results = [];
|
|
69
|
+
for (const mnt of detectMountPaths()) {
|
|
70
|
+
for (const tool of exports.AI_TOOLS) {
|
|
71
|
+
if (fs_1.default.existsSync(path_1.default.join(mnt, tool.skillsDir))) {
|
|
72
|
+
results.push({ tool, basePath: mnt });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
50
78
|
/**
|
|
51
79
|
* 홈 디렉토리에서 글로벌 에이전트 CLI 디렉토리를 감지한다.
|
|
52
80
|
* ~/{skillsDir}/ 가 존재하는 CLI를 반환.
|
|
@@ -100,3 +128,9 @@ function scanGlobalItems(tool, home) {
|
|
|
100
128
|
const basePath = path_1.default.join(home ?? os_1.default.homedir(), tool.skillsDir);
|
|
101
129
|
return scanItemsIn(basePath);
|
|
102
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* 마운트 경로의 개별 스킬/에이전트/커맨드/룰 항목을 반환한다.
|
|
133
|
+
*/
|
|
134
|
+
function scanMountedItems(basePath, tool) {
|
|
135
|
+
return scanItemsIn(path_1.default.join(basePath, tool.skillsDir));
|
|
136
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -22,7 +22,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
22
22
|
const os_1 = __importDefault(require("os"));
|
|
23
23
|
const ai_tools_js_1 = require("./ai-tools.js");
|
|
24
24
|
exports.API_URL = 'https://www.relayax.com';
|
|
25
|
-
const GLOBAL_RELAY_DIR = path_1.default.join(os_1.default.homedir(), '.relay');
|
|
25
|
+
const GLOBAL_RELAY_DIR = path_1.default.join(process.env.RELAY_HOME ?? os_1.default.homedir(), '.relay');
|
|
26
26
|
/**
|
|
27
27
|
* 설치 경로를 결정한다.
|
|
28
28
|
* 1. --path 옵션이 있으면 그대로 사용
|
|
@@ -31,17 +31,18 @@ const GLOBAL_RELAY_DIR = path_1.default.join(os_1.default.homedir(), '.relay');
|
|
|
31
31
|
*/
|
|
32
32
|
function getInstallPath(override) {
|
|
33
33
|
if (override) {
|
|
34
|
+
const homeDir = process.env.RELAY_HOME ?? os_1.default.homedir();
|
|
34
35
|
const resolved = override.startsWith('~')
|
|
35
|
-
? path_1.default.join(
|
|
36
|
+
? path_1.default.join(homeDir, override.slice(1))
|
|
36
37
|
: path_1.default.resolve(override);
|
|
37
38
|
return resolved;
|
|
38
39
|
}
|
|
39
|
-
const
|
|
40
|
-
const detected = (0, ai_tools_js_1.detectAgentCLIs)(
|
|
40
|
+
const projectRoot = getProjectRoot();
|
|
41
|
+
const detected = (0, ai_tools_js_1.detectAgentCLIs)(projectRoot);
|
|
41
42
|
if (detected.length >= 1) {
|
|
42
|
-
return path_1.default.join(
|
|
43
|
+
return path_1.default.join(projectRoot, detected[0].skillsDir);
|
|
43
44
|
}
|
|
44
|
-
return
|
|
45
|
+
return projectRoot;
|
|
45
46
|
}
|
|
46
47
|
/** ~/.relay/ — 글로벌 (token, CLI cache) */
|
|
47
48
|
function ensureGlobalRelayDir() {
|
|
@@ -49,9 +50,13 @@ function ensureGlobalRelayDir() {
|
|
|
49
50
|
fs_1.default.mkdirSync(GLOBAL_RELAY_DIR, { recursive: true });
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
/** 프로젝트 루트 경로 (RELAY_PROJECT_PATH > cwd) */
|
|
54
|
+
function getProjectRoot() {
|
|
55
|
+
return process.env.RELAY_PROJECT_PATH ?? process.cwd();
|
|
56
|
+
}
|
|
52
57
|
/** cwd/.relay/ — 프로젝트 로컬 (installed.json, agents/) */
|
|
53
58
|
function ensureProjectRelayDir() {
|
|
54
|
-
const dir = path_1.default.join(
|
|
59
|
+
const dir = path_1.default.join(getProjectRoot(), '.relay');
|
|
55
60
|
if (!fs_1.default.existsSync(dir)) {
|
|
56
61
|
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
57
62
|
}
|
|
@@ -90,7 +95,7 @@ function saveToken(token) {
|
|
|
90
95
|
fs_1.default.writeFileSync(tokenFile, JSON.stringify({ access_token: token }), { mode: 0o600 });
|
|
91
96
|
fs_1.default.chmodSync(tokenFile, 0o600);
|
|
92
97
|
}
|
|
93
|
-
const LOCK_FILE = path_1.default.join(
|
|
98
|
+
const LOCK_FILE = path_1.default.join(GLOBAL_RELAY_DIR, '.token.lock');
|
|
94
99
|
const LOCK_TIMEOUT = 15000; // 15s
|
|
95
100
|
/**
|
|
96
101
|
* 파일 기반 lock — 여러 CLI 프로세스가 동시에 refresh하는 것을 방지.
|
|
@@ -154,6 +159,9 @@ async function doRefresh(refreshToken) {
|
|
|
154
159
|
* - refresh 실패해도 access_token이 아직 유효하면 계속 사용
|
|
155
160
|
*/
|
|
156
161
|
async function getValidToken() {
|
|
162
|
+
// RELAY_TOKEN 환경변수가 있으면 최우선 사용 (sandbox/CI 환경)
|
|
163
|
+
if (process.env.RELAY_TOKEN)
|
|
164
|
+
return process.env.RELAY_TOKEN;
|
|
157
165
|
// 매번 파일에서 새로 읽음 (다른 프로세스가 갱신했을 수 있으므로)
|
|
158
166
|
const data = loadTokenData();
|
|
159
167
|
if (!data)
|
|
@@ -218,7 +226,7 @@ function normalizeInstalledRegistry(raw) {
|
|
|
218
226
|
}
|
|
219
227
|
/** 프로젝트 로컬 installed.json 읽기 */
|
|
220
228
|
function loadInstalled() {
|
|
221
|
-
const file = path_1.default.join(
|
|
229
|
+
const file = path_1.default.join(getProjectRoot(), '.relay', 'installed.json');
|
|
222
230
|
if (!fs_1.default.existsSync(file)) {
|
|
223
231
|
return {};
|
|
224
232
|
}
|
|
@@ -232,7 +240,7 @@ function loadInstalled() {
|
|
|
232
240
|
/** 프로젝트 로컬 installed.json 쓰기 */
|
|
233
241
|
function saveInstalled(registry) {
|
|
234
242
|
ensureProjectRelayDir();
|
|
235
|
-
const file = path_1.default.join(
|
|
243
|
+
const file = path_1.default.join(getProjectRoot(), '.relay', 'installed.json');
|
|
236
244
|
fs_1.default.writeFileSync(file, JSON.stringify(registry, null, 2));
|
|
237
245
|
}
|
|
238
246
|
// ─── 글로벌 레지스트리 ───
|
package/dist/lib/guide.js
CHANGED
|
@@ -80,18 +80,17 @@ ${commands.map((cmd) => `- \`/${cmd.name}\`: ${cmd.description}`).join('\n')}
|
|
|
80
80
|
> ${config.description}
|
|
81
81
|
|
|
82
82
|
이 가이드는 Relay 에이전트를 설치하는 절차입니다.
|
|
83
|
-
각 Step의 명령어를 순서대로 실행하세요. 요약하지 말고 바로 Step
|
|
83
|
+
각 Step의 명령어를 순서대로 실행하세요. 요약하지 말고 바로 Step 0(환경 감지)부터 시작하세요.
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
Claude Code · Claude Cowork · Codex · Cursor · Antigravity
|
|
85
|
+
${index_js_1.SETUP_ENVIRONMENT}
|
|
87
86
|
|
|
88
87
|
${setupSection}
|
|
89
88
|
|
|
90
89
|
## Step ${installStepNum}. 에이전트 설치
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
relay install ${scopedSlug}
|
|
94
|
-
|
|
91
|
+
감지된 환경에 맞게 실행하세요:
|
|
92
|
+
- 환경 A (터미널): \`relay install ${scopedSlug}\`
|
|
93
|
+
- 환경 B (샌드박스): \`npx relayax-cli install ${scopedSlug}\`
|
|
95
94
|
|
|
96
95
|
설치 후 아래 의존성을 확인하고 환경을 구성합니다.
|
|
97
96
|
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
## Step 1. Relay CLI
|
|
1
|
+
## Step 1. Relay CLI 준비
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
### 환경 A (터미널)
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g relayax-cli
|
|
7
|
+
relay init
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
이미 설치되어 있다면 최신 버전으로 업데이트합니다:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm update -g relayax-cli
|
|
14
|
+
relay init
|
|
7
15
|
```
|
|
8
16
|
|
|
9
17
|
Node.js가 없는 경우:
|
|
@@ -11,16 +19,12 @@ Node.js가 없는 경우:
|
|
|
11
19
|
- Windows: `winget install OpenJS.NodeJS.LTS`
|
|
12
20
|
- Linux: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && source ~/.bashrc && nvm install --lts`
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
mkdir -p ~/.npm-global && npm config set prefix '~/.npm-global'
|
|
17
|
-
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc && source ~/.zshrc
|
|
18
|
-
npm install -g relayax-cli
|
|
19
|
-
```
|
|
22
|
+
### 환경 B (샌드박스)
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
설치 없이 `npx`로 실행합니다. 이후 모든 `relay` 명령어를 `npx relayax-cli`로 대체하세요.
|
|
22
25
|
|
|
23
26
|
```bash
|
|
24
|
-
|
|
25
|
-
relay init
|
|
27
|
+
npx relayax-cli --version
|
|
26
28
|
```
|
|
29
|
+
|
|
30
|
+
정상 출력되면 준비 완료입니다.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
## Step 0. 환경 감지
|
|
2
|
+
|
|
3
|
+
이 가이드는 여러 환경에서 실행될 수 있습니다. 아래 환경 감지를 먼저 수행하고, 이후 모든 Step에서 감지된 환경에 맞는 방법을 사용하세요.
|
|
4
|
+
|
|
5
|
+
### 환경 판별
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
which relay 2>/dev/null && echo "cli-ok" || echo "cli-missing"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- `cli-ok` → **환경 A (터미널 + CLI 설치됨)** — 바로 Step 1로
|
|
12
|
+
- `cli-missing` → 아래 확인:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g relayax-cli 2>/dev/null && echo "install-ok" || echo "sandbox"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `install-ok` → **환경 A (터미널)** (`relay init` 실행 후 Step 1로)
|
|
19
|
+
- `sandbox` → **환경 B (샌드박스)** — `npx relayax-cli`로 실행
|
|
20
|
+
|
|
21
|
+
### 환경별 실행 방법
|
|
22
|
+
|
|
23
|
+
이후 가이드의 모든 `relay <명령어>`는 감지된 환경에 따라 실행합니다:
|
|
24
|
+
|
|
25
|
+
| 가이드 지시 | A. 터미널 | B. 샌드박스 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| "relay whoami" | `relay whoami` | `npx relayax-cli whoami` |
|
|
28
|
+
| "relay install X" | `relay install X` | `npx relayax-cli install X` |
|
|
29
|
+
| "relay publish" | `relay publish` | `npx relayax-cli publish` |
|
|
30
|
+
| "relay login" | `relay login` | `npx relayax-cli login --device` |
|
|
31
|
+
|
|
32
|
+
처음 판별한 환경을 이후 계속 사용합니다.
|
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
## Step 2. 로그인
|
|
2
2
|
|
|
3
|
-
먼저
|
|
3
|
+
먼저 로그인 상태를 확인합니다. 이미 로그인되어 있으면 이 단계를 건너뛰세요.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- 환경 A: `relay whoami`
|
|
6
|
+
- 환경 B: `npx relayax-cli whoami`
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
로그인이 필요하면 아래 방법 중 환경에 맞는 것을 사용하세요.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
`relay login`은 로컬 콜백 서버를 띄우고 브라우저 로그인 완료를 기다립니다.
|
|
11
|
-
반드시 포그라운드에서 실행하고, timeout을 300초(5분) 이상으로 설정하세요.
|
|
10
|
+
### 방법 1: 브라우저 콜백 (환경 A — 로컬 터미널)
|
|
12
11
|
|
|
13
12
|
```bash
|
|
14
13
|
relay login
|
|
15
14
|
```
|
|
16
15
|
|
|
17
16
|
- 브라우저가 자동으로 열리면 GitHub 또는 카카오 계정으로 로그인합니다.
|
|
18
|
-
- 브라우저가 열리지 않으면
|
|
17
|
+
- 브라우저가 열리지 않으면 출력된 URL을 별도로 엽니다: `open <URL>` (macOS) / `xdg-open <URL>` (Linux)
|
|
19
18
|
|
|
20
19
|
"✓ 로그인 완료"가 출력되면 다음 단계로 진행합니다.
|
|
21
20
|
|
|
22
|
-
### 방법
|
|
23
|
-
|
|
24
|
-
Cowork, 원격 서버 등 브라우저 콜백이 localhost에 도달할 수 없는 환경에서는 이 방법을 사용합니다.
|
|
21
|
+
### 방법 2: Device Code (환경 B — 샌드박스)
|
|
25
22
|
|
|
26
23
|
```bash
|
|
27
|
-
|
|
24
|
+
npx relayax-cli login --device
|
|
28
25
|
```
|
|
29
26
|
|
|
30
27
|
- 화면에 URL과 8자리 코드가 표시됩니다.
|
|
31
|
-
-
|
|
32
|
-
- 브라우저에서 코드를 입력하고 로그인을 승인합니다.
|
|
28
|
+
- 표시된 URL을 브라우저에서 열고, 코드를 입력하고 로그인을 승인합니다.
|
|
33
29
|
- CLI가 자동으로 승인을 감지하고 "✓ 로그인 완료"를 출력합니다.
|
|
34
|
-
|
|
35
|
-
`relay whoami`로 로그인 성공을 확인한 후 다음 단계로 진행합니다.
|
package/dist/prompts/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const REQUIREMENTS_CHECK: string;
|
|
2
2
|
export declare const ERROR_HANDLING_GUIDE: string;
|
|
3
|
+
export declare const SETUP_ENVIRONMENT: string;
|
|
3
4
|
export declare const SETUP_CLI: string;
|
|
4
5
|
export declare const SETUP_LOGIN: string;
|
|
5
6
|
export declare const INSTALL_PROMPT: string;
|
package/dist/prompts/index.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.PUBLISH_PROMPT = exports.INSTALL_PROMPT = exports.SETUP_LOGIN = exports.SETUP_CLI = exports.ERROR_HANDLING_GUIDE = exports.REQUIREMENTS_CHECK = void 0;
|
|
6
|
+
exports.PUBLISH_PROMPT = exports.INSTALL_PROMPT = exports.SETUP_LOGIN = exports.SETUP_CLI = exports.SETUP_ENVIRONMENT = exports.ERROR_HANDLING_GUIDE = exports.REQUIREMENTS_CHECK = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
function readPrompt(filename) {
|
|
@@ -15,6 +15,7 @@ function interpolate(template, vars) {
|
|
|
15
15
|
// ─── 공유 조각 ───
|
|
16
16
|
exports.REQUIREMENTS_CHECK = readPrompt('_requirements-check.md');
|
|
17
17
|
exports.ERROR_HANDLING_GUIDE = readPrompt('_error-handling.md');
|
|
18
|
+
exports.SETUP_ENVIRONMENT = readPrompt('_setup-environment.md');
|
|
18
19
|
exports.SETUP_CLI = readPrompt('_setup-cli.md');
|
|
19
20
|
exports.SETUP_LOGIN = readPrompt('_setup-login.md');
|
|
20
21
|
const fragments = {
|