luang-cli 0.0.0-stage → 0.2.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,337 @@
1
+ 'use strict';
2
+
3
+ // lib/commands.js — implementasi semua command luang-cli.
4
+
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+
8
+ const core = require('./core');
9
+ const {
10
+ ApiError,
11
+ apiRequest,
12
+ loadState,
13
+ saveState,
14
+ saveConfig,
15
+ collectProviders,
16
+ heartbeatProviders,
17
+ detectProviders,
18
+ PROVIDER_TYPES,
19
+ DISPLAY_NAMES,
20
+ DEFAULT_TEMPLATES,
21
+ STATE_FILE,
22
+ CONFIG_FILE,
23
+ keyPrefix,
24
+ fmtWhen,
25
+ trunc,
26
+ printTable,
27
+ printBigCode,
28
+ sleep,
29
+ openBrowser,
30
+ } = core;
31
+
32
+ // ── Helper kecil ─────────────────────────────────────────────────────────────
33
+
34
+ function errExit(msg, hint) {
35
+ console.error(`❌ ${msg}`);
36
+ if (hint) console.error(` ${hint}`);
37
+ return 1;
38
+ }
39
+
40
+ function authFailMessage(e) {
41
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
42
+ return errExit(`Server menolak key (HTTP ${e.status}): ${e.message}`, 'Coba `luang-cli login` ulang atau cek `luang-cli whoami`.');
43
+ }
44
+ return errExit(e.message);
45
+ }
46
+
47
+ function ask(question) {
48
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
49
+ return new Promise((resolve) => {
50
+ rl.question(question, (a) => {
51
+ rl.close();
52
+ resolve(String(a === undefined ? '' : a).trim().toLowerCase());
53
+ });
54
+ });
55
+ }
56
+
57
+ function taskIdOf(t) {
58
+ return String((t && (t.taskCode || t.task_code)) || (t && t.id) || '');
59
+ }
60
+
61
+ function normStatus(s) {
62
+ return String(s || '').toLowerCase().replace(/[-_\s]/g, '');
63
+ }
64
+
65
+ const STATUS_ALIASES = { todo: 'todo', 'to-do': 'todo', in_progress: 'inprogress', 'in-progress': 'inprogress' };
66
+
67
+ function statusInputNorm(s) {
68
+ const k = String(s || '').toLowerCase().trim();
69
+ return STATUS_ALIASES[k] || normStatus(k);
70
+ }
71
+
72
+ function normalizeTaskList(res) {
73
+ let d = res && res.data !== undefined ? res.data : res;
74
+ if (Array.isArray(d)) return d;
75
+ if (d && Array.isArray(d.tasks)) return d.tasks;
76
+ if (d && Array.isArray(d.items)) return d.items;
77
+ if (d && Array.isArray(d.data)) return d.data;
78
+ return [];
79
+ }
80
+
81
+ function providersLabel(providers) {
82
+ if (!providers) return '(belum ada)';
83
+ if (Array.isArray(providers)) {
84
+ if (providers.length === 0) return '(belum ada)';
85
+ return providers
86
+ .map((p) => (typeof p === 'string' ? p : p.provider_type || p.type || p.name || '?'))
87
+ .join(', ');
88
+ }
89
+ if (typeof providers === 'object') return Object.keys(providers).join(', ');
90
+ return String(providers);
91
+ }
92
+
93
+ function permLine(label, val) {
94
+ return `${val ? '✅' : '❌'} ${label}`;
95
+ }
96
+
97
+ // ── login (device flow) ──────────────────────────────────────────────────────
98
+
99
+ async function cmdLogin(rt, args) {
100
+ const flags = args.flags;
101
+ const name =
102
+ flags.name && flags.name !== true ? String(flags.name) : `${os.hostname()}-${os.platform()}`;
103
+
104
+ console.log(`📡 Memulai device flow ke ${rt.apiUrl}`);
105
+ let start;
106
+ try {
107
+ start = await apiRequest(rt, 'POST', '/api/v1/agent/device/start', { body: { name } });
108
+ } catch (e) {
109
+ return errExit(e.message, 'Periksa jaringan / LUANG_API_URL.');
110
+ }
111
+ const d = start && start.data;
112
+ if (!d || !d.device_code) return errExit('Respons server tidak sesuai (device_code tidak ada).');
113
+
114
+ console.log('');
115
+ console.log('🔑 Masukkan kode verifikasi berikut di browser:');
116
+ printBigCode(d.user_code || '????-????');
117
+ console.log(`Kode: ${d.user_code}`);
118
+
119
+ // verify_url seharusnya dikirim server; kalau tidak ada, susun dari app_url (fallback aman)
120
+ const verifyUrl = d.verify_url || core.buildApiUrl(rt.appUrl, '/cli/verify', { code: d.user_code });
121
+ console.log(`🌐 Buka: ${verifyUrl}`);
122
+ openBrowser(verifyUrl); // fail aman — URL sudah dicetak di atas
123
+ if (process.env.LUANG_NO_OPEN) console.log(' (LUANG_NO_OPEN aktif — browser tidak dibuka otomatis)');
124
+ console.log('⏳ Menunggu persetujuan di dashboard…');
125
+
126
+ const deadline = Date.now() + 10 * 60 * 1000; // batas 10 menit
127
+ let fails = 0;
128
+ for (;;) {
129
+ let res;
130
+ try {
131
+ res = await apiRequest(rt, 'GET', '/api/v1/agent/device/poll', { query: { device_code: d.device_code } });
132
+ fails = 0;
133
+ } catch (e) {
134
+ fails += 1;
135
+ if (fails >= 5) return errExit(`Polling gagal berulang: ${e.message}`);
136
+ await sleep(2000);
137
+ continue;
138
+ }
139
+ const pd = (res && res.data) || {};
140
+ const st = pd.status || 'pending';
141
+ if (st === 'pending' || st === 'slow_down') {
142
+ process.stdout.write('.');
143
+ await sleep(2000);
144
+ if (Date.now() >= deadline) return errExit('Batas 10 menit lewat. Coba lagi: luang-cli login');
145
+ continue;
146
+ }
147
+ if (st === 'approved') {
148
+ process.stdout.write('\n');
149
+ if (pd.api_key) {
150
+ rt.cfg.key = pd.api_key;
151
+ saveConfig(rt.cfg);
152
+ console.log('');
153
+ console.log(`✅ Login berhasil! Nama sesi: ${pd.name || name}`);
154
+ console.log(`🏢 Tenant : ${pd.tenant || '-'}`);
155
+ console.log(`🔑 Key : ${keyPrefix(pd.api_key)}`);
156
+ console.log('');
157
+ console.log('Selanjutnya: luang-cli whoami · mulai daemon: luang-cli agent');
158
+ return 0;
159
+ }
160
+ console.log('⚠️ Login disetujui, tapi key sudah pernah diambil (key tidak dikirim ulang).');
161
+ console.log(' Pakai key lama di config/LUANG_KEY, atau buat sesi agent baru di dashboard.');
162
+ return 0;
163
+ }
164
+ if (st === 'denied') {
165
+ process.stdout.write('\n');
166
+ return errExit('Login ditolak dari dashboard. Minta owner approve sesi ini, lalu coba lagi.');
167
+ }
168
+ if (st === 'expired') {
169
+ process.stdout.write('\n');
170
+ return errExit('Sesi login kedaluwarsa. Coba lagi: luang-cli login');
171
+ }
172
+ process.stdout.write('.'); // status tak dikenal → dianggap pending
173
+ await sleep(2000);
174
+ if (Date.now() >= deadline) return errExit('Batas 10 menit lewat. Coba lagi: luang-cli login');
175
+ }
176
+ }
177
+
178
+ // ── logout ───────────────────────────────────────────────────────────────────
179
+
180
+ async function cmdLogout(rt, args) {
181
+ if (!rt.key) {
182
+ console.log('ℹ️ Sudah logout (tidak ada key tersimpan).');
183
+ return 0;
184
+ }
185
+ const old = keyPrefix(rt.key);
186
+ const yes = args.flags.y === true || args.flags.yes === true;
187
+ if (!yes) {
188
+ const a = await ask(`Hapus API key (${old}) dari ${CONFIG_FILE}? (y/N) `);
189
+ if (a !== 'y') {
190
+ console.log('Batal.');
191
+ return 0;
192
+ }
193
+ }
194
+ rt.cfg.key = null;
195
+ saveConfig(rt.cfg);
196
+ console.log(`✅ Key ${old} dihapus dari ${CONFIG_FILE}`);
197
+ if (process.env.LUANG_KEY) {
198
+ console.log('⚠️ Env LUANG_KEY masih ter-set — override tetap berlaku di sesi ini.');
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ // ── whoami ───────────────────────────────────────────────────────────────────
204
+
205
+ async function cmdWhoami(rt) {
206
+ if (!rt.key) return errExit('Belum login — jalankan `luang-cli login` atau set LUANG_KEY.');
207
+ let me;
208
+ try {
209
+ me = await apiRequest(rt, 'GET', '/api/v1/agent/me');
210
+ } catch (e) {
211
+ return authFailMessage(e);
212
+ }
213
+ const d = (me && me.data) || {};
214
+ console.log('👤 Sesi Agent');
215
+ console.log(` Nama : ${d.name || '-'}`);
216
+ console.log(` Status : ${d.status || '-'}`);
217
+ console.log(` Key ID : ${d.key_id || '-'}`);
218
+ console.log(` Terakhir : ${fmtWhen(d.last_seen_at)}`);
219
+ console.log(` Tenant : ${d.tenant_slug || d.tenant_id || '-'}`);
220
+ console.log('');
221
+ console.log('🔓 Izin');
222
+ console.log(` ${permLine('Bisa update tugas (can_update_tasks)', !!d.can_update_tasks)}`);
223
+ console.log(` ${permLine('Bisa buat tugas (can_create_tasks)', !!d.can_create_tasks)}`);
224
+ console.log('');
225
+ console.log(`🛠 Provider terdaftar (server): ${providersLabel(d.providers)}`);
226
+ return 0;
227
+ }
228
+
229
+ // ── provider ─────────────────────────────────────────────────────────────────
230
+
231
+ async function syncIfKey(rt) {
232
+ if (!rt.key) {
233
+ console.log('ℹ️ Belum login — sinkronisasi ke server dilewati (terkirim otomatis saat agent login/heartbeat).');
234
+ return;
235
+ }
236
+ try {
237
+ const res = await apiRequest(rt, 'POST', '/api/v1/agent/heartbeat', {
238
+ body: { providers: heartbeatProviders(collectProviders(rt.cfg)) },
239
+ });
240
+ const n = res && res.data && res.data.providers_count;
241
+ console.log(`✅ Sinkron ke server${n != null ? `: ${n} provider` : ' (ok)'}`);
242
+ } catch (e) {
243
+ console.error(`⚠️ Sinkron gagal: ${e.message}`);
244
+ }
245
+ }
246
+
247
+ async function cmdProvider(rt, args) {
248
+ const sub = args.pos[1] || 'list';
249
+ const flags = args.flags;
250
+
251
+ if (sub === 'list') {
252
+ const list = collectProviders(rt.cfg);
253
+ if (flags.json === true) {
254
+ console.log(JSON.stringify(list, null, 2));
255
+ return 0;
256
+ }
257
+ if (!list.length) {
258
+ console.log('⚠️ Tidak ada provider terdeteksi di mesin ini.');
259
+ console.log(' Tambah manual: luang-cli provider add opencode --exec_template \'opencode run "{task}"\'');
260
+ return 0;
261
+ }
262
+ printTable(
263
+ ['Provider', 'Path', 'Exec Template'],
264
+ list.map((p) => [
265
+ (p.is_default ? '★ ' : '') + p.provider_type,
266
+ p.path || '-',
267
+ p.exec_template || '-',
268
+ ])
269
+ );
270
+ console.log('');
271
+ console.log('★ = default provider · deteksi: ~/.claude, ~/.codex, ~/.config/opencode/~/.opencode, ~/.gemini + which(1)');
272
+ await syncIfKey(rt);
273
+ return 0;
274
+ }
275
+
276
+ if (sub === 'add') {
277
+ const type = args.pos[2];
278
+ if (!type) return errExit('Format: luang-cli provider add <type> [--path P] [--exec_template T]', `type: ${PROVIDER_TYPES.join(', ')}`);
279
+ if (!PROVIDER_TYPES.includes(type)) return errExit(`Tipe tidak dikenal: ${type}`, `Pilihan: ${PROVIDER_TYPES.join(', ')}`);
280
+ const existing = collectProviders(rt.cfg).find((p) => p.provider_type === type) || {};
281
+ const tPath = flags.path && flags.path !== true ? String(flags.path) : existing.path || null;
282
+ const tTpl =
283
+ flags.exec_template && flags.exec_template !== true
284
+ ? String(flags.exec_template)
285
+ : existing.exec_template || DEFAULT_TEMPLATES[type] || null;
286
+ rt.cfg.providers[type] = {
287
+ provider_type: type,
288
+ display_name: DISPLAY_NAMES[type] || type,
289
+ path: tPath,
290
+ exec_template: tTpl,
291
+ };
292
+ saveConfig(rt.cfg);
293
+ console.log(`➕ Provider ${type} tersimpan di config:`);
294
+ console.log(` Path : ${tPath || '-'}`);
295
+ console.log(` Exec Template : ${tTpl || '-'}`);
296
+ if (!tTpl || !tTpl.includes('{task}')) {
297
+ console.log('⚠️ Exec template tanpa {task} — tugas tidak bisa dieksekusi otomatis.');
298
+ }
299
+ await syncIfKey(rt);
300
+ return 0;
301
+ }
302
+
303
+ if (sub === 'remove') {
304
+ const type = args.pos[2];
305
+ if (!type) return errExit('Format: luang-cli provider remove <type>');
306
+ if (!rt.cfg.providers[type]) return errExit(`Provider "${type}" tidak ada di config (list: provider list).`);
307
+ delete rt.cfg.providers[type];
308
+ if (rt.cfg.default_provider === type) {
309
+ rt.cfg.default_provider = null;
310
+ console.log('⭐ Default provider ikut direset (provider yang dihapus adalah default).');
311
+ }
312
+ saveConfig(rt.cfg);
313
+ console.log(`➖ Provider ${type} dihapus dari config.`);
314
+ await syncIfKey(rt);
315
+ return 0;
316
+ }
317
+
318
+ if (sub === 'default') {
319
+ const type = args.pos[2];
320
+ if (!type) return errExit('Format: luang-cli provider default <type>', `Pilihan: ${PROVIDER_TYPES.join(', ')}`);
321
+ if (!PROVIDER_TYPES.includes(type)) return errExit(`Tipe tidak dikenal: ${type}`, `Pilihan: ${PROVIDER_TYPES.join(', ')}`);
322
+ rt.cfg.default_provider = type;
323
+ saveConfig(rt.cfg);
324
+ console.log(`⭐ Default provider: ${type}`);
325
+ const tpl = (collectProviders(rt.cfg).find((p) => p.provider_type === type) || {}).exec_template;
326
+ if (!tpl || !tpl.includes('{task}')) {
327
+ console.log(`⚠️ "${type}" belum punya exec_template berisi {task} — mode --exec tidak akan jalan.`);
328
+ console.log(' Set: luang-cli provider add ' + type + ' --exec_template \'… "{task}" …\'');
329
+ }
330
+ await syncIfKey(rt);
331
+ return 0;
332
+ }
333
+
334
+ return errExit(`Sub-command provider tidak dikenal: ${sub}`, 'Gunakan: list | add | remove | default');
335
+ }
336
+
337
+ module.exports = { cmdLogin, cmdLogout, cmdWhoami, cmdProvider, errExit, authFailMessage, taskIdOf, statusInputNorm, normalizeTaskList, ask, providersLabel, syncIfKey, permLine };