deel-local-cli 0.5.0 → 0.8.0

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.
@@ -0,0 +1,272 @@
1
+ // 플러그인 설치·삭제·묶기.
2
+ //
3
+ // 온라인 기기에서 /plugin install 로 받고, /plugin pack 으로 묶어
4
+ // 오프라인 기기에 반입한다. 오프라인에서는 압축만 풀면 그대로 인식된다.
5
+ import { execFile } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { join, dirname, basename } from 'node:path';
8
+ import {
9
+ existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
10
+ readdirSync, statSync,
11
+ } from 'node:fs';
12
+ import { untargz, stripTop } from '../pack/tar.js';
13
+ import { makeZip } from '../pack/zip.js';
14
+ import { allowTemporarily, checkUrl, isOffline } from '../safety/network.js';
15
+ import { copyDir } from '../tools/fsutil.js';
16
+
17
+ export const pluginsDir = (home = homedir()) => join(home, '.deel', 'plugins');
18
+
19
+ // owner/repo · owner/repo#가지 · 전체 URL 을 모두 받는다.
20
+ export function parseSpec(spec) {
21
+ let s = String(spec).trim();
22
+ let ref = null;
23
+ const hash = s.lastIndexOf('#');
24
+ if (hash > 0) { ref = s.slice(hash + 1); s = s.slice(0, hash); }
25
+
26
+ s = s.replace(/\.git$/, '');
27
+ const m = /^(?:https?:\/\/github\.com\/)?([\w.-]+)\/([\w.-]+)$/.exec(s);
28
+ if (!m) return null;
29
+ return { owner: m[1], repo: m[2], ref, url: `https://github.com/${m[1]}/${m[2]}.git` };
30
+ }
31
+
32
+ function has(cmd) {
33
+ return new Promise((res) => {
34
+ execFile(cmd, ['--version'], { timeout: 8000, windowsHide: true }, (err) => res(!err));
35
+ });
36
+ }
37
+
38
+ function run(cmd, args, opts = {}) {
39
+ return new Promise((res) => {
40
+ execFile(cmd, args, { timeout: 180000, windowsHide: true, maxBuffer: 1 << 24, ...opts },
41
+ (err, stdout, stderr) => res({ ok: !err, out: `${stdout}${stderr}`.trim() }));
42
+ });
43
+ }
44
+
45
+ // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
46
+ async function fetchInto(spec, dest, onStep) {
47
+ if (await has('git')) {
48
+ onStep?.('git clone');
49
+ rmSync(dest, { recursive: true, force: true });
50
+ mkdirSync(dirname(dest), { recursive: true });
51
+ const args = ['clone', '--depth', '1'];
52
+ if (spec.ref) args.push('--branch', spec.ref);
53
+ args.push(spec.url, dest);
54
+ const r = await run('git', args);
55
+ if (!r.ok) return { error: `git clone 실패 — ${r.out.split('\n').slice(-2).join(' ')}` };
56
+ rmSync(join(dest, '.git'), { recursive: true, force: true });
57
+ return { how: 'git' };
58
+ }
59
+
60
+ onStep?.('tarball 내려받기');
61
+ if (isOffline()) return { error: '오프라인 모드입니다 — 받아 올 수 없습니다. 풀어 놓은 폴더 경로를 주세요.' };
62
+ for (const branch of spec.ref ? [spec.ref] : ['main', 'master']) {
63
+ const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
64
+ // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
65
+ const close = allowTemporarily(url);
66
+ let res;
67
+ try { checkUrl(url); res = await fetch(url, { signal: AbortSignal.timeout(120000) }); }
68
+ catch (err) { continue; }
69
+ finally { close(); }
70
+ if (!res.ok) continue;
71
+ const gz = Buffer.from(await res.arrayBuffer());
72
+ const files = stripTop(untargz(gz));
73
+ if (!files.length) return { error: '받은 묶음이 비어 있습니다' };
74
+ rmSync(dest, { recursive: true, force: true });
75
+ for (const f of files) {
76
+ const p = join(dest, f.name);
77
+ mkdirSync(dirname(p), { recursive: true });
78
+ writeFileSync(p, f.data);
79
+ }
80
+ return { how: 'tarball', branch };
81
+ }
82
+ return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
83
+ }
84
+
85
+ function manifestOf(dir) {
86
+ const f = join(dir, '.claude-plugin', 'plugin.json');
87
+ if (!existsSync(f)) return null;
88
+ try { return JSON.parse(readFileSync(f, 'utf8')); } catch { return null; }
89
+ }
90
+
91
+ function countIn(dir) {
92
+ let skills = 0;
93
+ let commands = 0;
94
+ let hooks = 0;
95
+ for (const d of ['skills', join('.agents', 'skills'), join('.claude', 'skills')]) {
96
+ const p = join(dir, d);
97
+ if (!existsSync(p)) continue;
98
+ for (const e of readdirSync(p, { withFileTypes: true })) {
99
+ if (e.isDirectory() && existsSync(join(p, e.name, 'SKILL.md'))) skills++;
100
+ }
101
+ }
102
+ for (const d of ['commands', join('.claude', 'commands'), join('.agents', 'commands')]) {
103
+ const p = join(dir, d);
104
+ if (!existsSync(p)) continue;
105
+ for (const e of readdirSync(p, { withFileTypes: true })) {
106
+ if (e.isFile() && e.name.endsWith('.md')) commands++;
107
+ }
108
+ }
109
+ hooks = walkCount(dir, (n) => /\.(js|cjs|mjs|sh|ps1|cmd|bat|py)$/i.test(n));
110
+ return { skills, commands, hooks };
111
+ }
112
+
113
+ function walkCount(dir, match, depth = 5) {
114
+ let n = 0;
115
+ const stack = [{ d: dir, k: 0 }];
116
+ while (stack.length) {
117
+ const { d, k } = stack.pop();
118
+ if (k > depth) continue;
119
+ let es;
120
+ try { es = readdirSync(d, { withFileTypes: true }); } catch { continue; }
121
+ for (const e of es) {
122
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== '.git') stack.push({ d: join(d, e.name), k: k + 1 }); }
123
+ else if (match(e.name)) n++;
124
+ }
125
+ }
126
+ return n;
127
+ }
128
+
129
+ export async function install(spec, { home = homedir(), onStep } = {}) {
130
+ const base = pluginsDir(home);
131
+
132
+ // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
133
+ const asPath = String(spec).trim().replace(/^["']|["']$/g, '');
134
+ const isLocal = existsSync(asPath) && statSync(asPath).isDirectory();
135
+
136
+ const parsed = isLocal ? null : parseSpec(spec);
137
+ if (!isLocal && !parsed) {
138
+ return { error: `주소를 알아볼 수 없습니다: ${spec}\n 예: affaan-m/ECC · https://github.com/affaan-m/ECC · C:\\받은폴더\\ecc` };
139
+ }
140
+
141
+ const tmp = join(base, `.tmp-${isLocal ? 'local' : parsed.repo}`);
142
+ let got;
143
+ if (isLocal) {
144
+ onStep?.('폴더 복사');
145
+ rmSync(tmp, { recursive: true, force: true });
146
+ mkdirSync(dirname(tmp), { recursive: true });
147
+ copyDir(asPath, tmp);
148
+ rmSync(join(tmp, '.git'), { recursive: true, force: true });
149
+ got = { how: '폴더', from: asPath };
150
+ } else {
151
+ got = await fetchInto(parsed, tmp, onStep);
152
+ }
153
+ if (got.error) { rmSync(tmp, { recursive: true, force: true }); return { error: got.error }; }
154
+
155
+ const info = manifestOf(tmp);
156
+ const name = info?.name || (isLocal ? basename(asPath) : parsed.repo);
157
+ const dest = join(base, name);
158
+
159
+ const counts = countIn(tmp);
160
+ if (!counts.skills && !counts.commands) {
161
+ rmSync(tmp, { recursive: true, force: true });
162
+ return { error: `스킬도 명령도 없습니다. 플러그인이 맞는지 확인하세요 (${isLocal ? asPath : parsed.owner + '/' + parsed.repo})` };
163
+ }
164
+
165
+ rmSync(dest, { recursive: true, force: true });
166
+ mkdirSync(dirname(dest), { recursive: true });
167
+ copyDir(tmp, dest);
168
+ rmSync(tmp, { recursive: true, force: true });
169
+
170
+ // 어디서 왔는지 남긴다 — 나중에 반입 심사에서 출처를 물어본다.
171
+ writeFileSync(join(dest, '.deel-source.json'), JSON.stringify({
172
+ from: isLocal ? asPath : `${parsed.owner}/${parsed.repo}`,
173
+ ref: got.branch ?? parsed?.ref ?? null,
174
+ how: got.how,
175
+ at: new Date().toISOString(),
176
+ license: info?.license ?? null,
177
+ }, null, 2) + '\n', 'utf8');
178
+
179
+ return { name, version: info?.version ?? '', license: info?.license ?? null, path: dest, ...counts, how: got.how };
180
+ }
181
+
182
+ export function list({ home = homedir() } = {}) {
183
+ const base = pluginsDir(home);
184
+ if (!existsSync(base)) return [];
185
+ const out = [];
186
+ for (const e of readdirSync(base, { withFileTypes: true })) {
187
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
188
+ const dir = join(base, e.name);
189
+ const info = manifestOf(dir);
190
+ let src = null;
191
+ const sf = join(dir, '.deel-source.json');
192
+ if (existsSync(sf)) { try { src = JSON.parse(readFileSync(sf, 'utf8')); } catch {} }
193
+ out.push({
194
+ name: info?.name || e.name,
195
+ version: info?.version ?? '',
196
+ license: info?.license ?? src?.license ?? null,
197
+ from: src?.from ?? '(직접 넣음)',
198
+ path: dir,
199
+ ...countIn(dir),
200
+ });
201
+ }
202
+ return out.sort((a, b) => b.skills - a.skills);
203
+ }
204
+
205
+ export function remove(name, { home = homedir() } = {}) {
206
+ const dir = join(pluginsDir(home), name);
207
+ if (!existsSync(dir)) return { error: `설치돼 있지 않습니다: ${name}` };
208
+ rmSync(dir, { recursive: true, force: true });
209
+ return { removed: name };
210
+ }
211
+
212
+ // 반입용 묶음. 실행 스크립트는 빼고 스킬·명령만 담는다.
213
+ const PACK_SKIP_DIRS = new Set(['node_modules', '.git', 'test', 'tests', '__pycache__', '.github']);
214
+ const PACK_SKIP_EXT = /\.(js|cjs|mjs|sh|ps1|cmd|bat|py|exe|dll|so|dylib)$/i;
215
+
216
+ export function pack(outFile, { home = homedir(), only = null } = {}) {
217
+ const base = pluginsDir(home);
218
+ if (!existsSync(base)) return { error: '설치된 플러그인이 없습니다.' };
219
+
220
+ const entries = [];
221
+ const included = [];
222
+ let skipped = 0;
223
+
224
+ for (const p of list({ home })) {
225
+ if (only?.length && !only.includes(p.name)) continue;
226
+ let n = 0;
227
+ const stack = [p.path];
228
+ while (stack.length) {
229
+ const dir = stack.pop();
230
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
231
+ const full = join(dir, e.name);
232
+ if (e.isDirectory()) {
233
+ if (!PACK_SKIP_DIRS.has(e.name)) stack.push(full);
234
+ continue;
235
+ }
236
+ if (PACK_SKIP_EXT.test(e.name)) { skipped++; continue; }
237
+ const rel = full.slice(base.length + 1).split(/[\\/]/).join('/');
238
+ entries.push({ name: rel, data: readFileSync(full), mtime: statSync(full).mtime });
239
+ n++;
240
+ }
241
+ }
242
+ included.push({ ...p, files: n });
243
+ }
244
+
245
+ if (!entries.length) return { error: '담을 파일이 없습니다.' };
246
+
247
+ // 무엇이 들어 있는지 사람이 읽을 수 있게 같이 담는다 — 반입 심사에 쓴다.
248
+ const manifest = [
249
+ 'deel 플러그인 묶음',
250
+ `만든 시각 ${new Date().toISOString().replace('T', ' ').slice(0, 19)}`,
251
+ `플러그인 ${included.length}개`,
252
+ `파일 ${entries.length}개`,
253
+ `제외한 실행 스크립트 ${skipped}개 (js·sh·ps1·py 등은 담지 않습니다)`,
254
+ '',
255
+ '이름'.padEnd(24) + '판'.padEnd(10) + '라이선스'.padEnd(16) + '스킬 명령 출처',
256
+ '-'.repeat(96),
257
+ ...included.map((p) =>
258
+ String(p.name).padEnd(24) + String(p.version || '-').padEnd(10) +
259
+ String(p.license || '미상').padEnd(16) +
260
+ String(p.skills).padStart(4) + String(p.commands).padStart(6) + ' ' + p.from),
261
+ '',
262
+ '푸는 법: 이 zip 을 오프라인 기기의 ~/.deel/plugins/ 에 풀면 됩니다.',
263
+ ' deel 을 켜면 자동으로 인식합니다. 설치 명령은 필요 없습니다.',
264
+ '',
265
+ ].join('\n');
266
+ entries.unshift({ name: '사용안내.txt', data: Buffer.from(manifest, 'utf8') });
267
+
268
+ const zip = makeZip(entries);
269
+ mkdirSync(dirname(outFile), { recursive: true });
270
+ writeFileSync(outFile, zip);
271
+ return { out: outFile, plugins: included, files: entries.length, skipped, bytes: zip.length, manifest };
272
+ }