@ryuu-reinzz/ryuu-cli 0.5.0 → 0.5.2

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,209 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ import { ConfigError } from '../../src/utils/errors.js';
6
+ import { settingsDir } from '../../src/config/settings.js';
7
+
8
+ /**
9
+ * Konfigurasi plugin GitHub di sisi CLI.
10
+ *
11
+ * Yang disimpan di sini hanya pengaturan non-rahasia: status aktif, preferensi
12
+ * perilaku, dan Client ID publik milik GitHub App. Client Secret, Private Key,
13
+ * Webhook Secret, access token, dan refresh token TIDAK PERNAH ada di CLI —
14
+ * semuanya dimiliki backend.
15
+ *
16
+ * Lokasi mengikuti direktori konfigurasi OS lewat `settingsDir()`:
17
+ * <config>/integrations/github/config.json
18
+ * <config>/integrations/github/state.json (cache non-sensitif)
19
+ * <config>/integrations/github/credentials.enc
20
+ * <config>/.keys/github.key (kunci enkripsi CLI token)
21
+ */
22
+ export const GITHUB_BACKEND_URL = 'https://restapi.ryuu-dev.my.id';
23
+ export const GITHUB_CLIENT_ID = 'Iv23liIt8Xh4waFhX0y1';
24
+
25
+ export const DEFAULT_GITHUB_CONFIG = Object.freeze({
26
+ enabled: false,
27
+ autoRefresh: true,
28
+ confirmDestructive: true,
29
+ defaultRepo: null,
30
+ // Client ID bukan rahasia; dipakai untuk ditampilkan pada /github config.
31
+ clientId: GITHUB_CLIENT_ID,
32
+ // Endpoint backend adalah konstanta aplikasi, bukan pengaturan pengguna.
33
+ backend: GITHUB_BACKEND_URL,
34
+ });
35
+
36
+ export const EDITABLE_KEYS = Object.freeze(['enabled', 'autoRefresh', 'confirmDestructive', 'defaultRepo']);
37
+
38
+ const BLOCKED_KEYS = new Set([
39
+ 'clientSecret',
40
+ 'privateKey',
41
+ 'privateKeyPath',
42
+ 'webhookSecret',
43
+ 'backend',
44
+ 'clientId',
45
+ 'accessToken',
46
+ 'refreshToken',
47
+ 'cliToken',
48
+ 'token',
49
+ 'secret',
50
+ '__proto__',
51
+ 'prototype',
52
+ 'constructor',
53
+ ]);
54
+
55
+ /** Path absolut file milik integrasi GitHub di sisi CLI. */
56
+ export function githubPaths({ env = process.env, home = undefined, platform = undefined } = {}) {
57
+ const root = path.join(settingsDir({ env, home, platform }), 'integrations', 'github');
58
+ return {
59
+ dir: root,
60
+ configFile: path.join(root, 'config.json'),
61
+ credentialFile: path.join(root, 'credentials.enc'),
62
+ stateFile: path.join(root, 'state.json'),
63
+ keyFile: path.join(settingsDir({ env, home, platform }), '.keys', 'github.key'),
64
+ };
65
+ }
66
+
67
+ function boolValue(name, value, issues) {
68
+ if (typeof value === 'boolean') return value;
69
+ const text = String(value ?? '').trim().toLowerCase();
70
+ if (['1', 'true', 'yes', 'y', 'on', 'enable', 'enabled', 'aktif'].includes(text)) return true;
71
+ if (['0', 'false', 'no', 'n', 'off', 'disable', 'disabled', 'nonaktif'].includes(text)) return false;
72
+ issues.push(`${name} harus berupa boolean (true/false), diterima: ${JSON.stringify(value)}`);
73
+ return undefined;
74
+ }
75
+
76
+ /** Validasi satu penulisan `/github config set <key> <value>`. */
77
+ export function validateConfigEntry(key, rawValue) {
78
+ const name = String(key ?? '').trim();
79
+ if (BLOCKED_KEYS.has(name)) {
80
+ throw new ConfigError(
81
+ `Key "${name}" tidak dapat diubah: Client Secret, Private Key, Webhook Secret, endpoint backend, dan token dikelola backend dan tidak boleh ada di CLI.`,
82
+ );
83
+ }
84
+ if (!EDITABLE_KEYS.includes(name)) {
85
+ throw new ConfigError(`Key tidak dikenal: "${name}". Key yang didukung: ${EDITABLE_KEYS.join(', ')}.`);
86
+ }
87
+ const issues = [];
88
+ let value;
89
+ switch (name) {
90
+ case 'enabled':
91
+ case 'autoRefresh':
92
+ case 'confirmDestructive':
93
+ value = boolValue(name, rawValue, issues);
94
+ break;
95
+ case 'defaultRepo': {
96
+ const text = String(rawValue ?? '').trim();
97
+ if (!text || ['none', 'null', '-', 'off'].includes(text.toLowerCase())) value = null;
98
+ else if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(text)) {
99
+ issues.push('defaultRepo harus berformat owner/repo, contoh: ryuu-reinzz/ryuu-cli.');
100
+ } else value = text;
101
+ break;
102
+ }
103
+ default:
104
+ issues.push(`Key tidak dikenal: ${name}`);
105
+ }
106
+ if (issues.length > 0) throw new ConfigError(issues.join(' '), { key: name });
107
+ return value;
108
+ }
109
+
110
+ function normalize(data) {
111
+ const source = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
112
+ const stored = {};
113
+ for (const key of Object.keys(DEFAULT_GITHUB_CONFIG)) {
114
+ if (key in source) stored[key] = source[key];
115
+ }
116
+ const merged = { ...DEFAULT_GITHUB_CONFIG, ...stored };
117
+ // Nilai milik aplikasi selalu kembali ke default.
118
+ merged.clientId = GITHUB_CLIENT_ID;
119
+ merged.backend = GITHUB_BACKEND_URL;
120
+ merged.enabled = Boolean(merged.enabled);
121
+ merged.autoRefresh = merged.autoRefresh !== false;
122
+ merged.confirmDestructive = merged.confirmDestructive !== false;
123
+ merged.defaultRepo =
124
+ typeof merged.defaultRepo === 'string' && /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/.test(merged.defaultRepo)
125
+ ? merged.defaultRepo
126
+ : null;
127
+ return merged;
128
+ }
129
+
130
+ /** Baca konfigurasi; file hilang menghasilkan default bawaan. */
131
+ export async function readGitHubConfig({ paths = githubPaths() } = {}) {
132
+ let text;
133
+ try {
134
+ text = await fsp.readFile(paths.configFile, 'utf8');
135
+ } catch (error) {
136
+ if (error.code === 'ENOENT') return normalize({});
137
+ throw new ConfigError(`Tidak dapat membaca konfigurasi GitHub (${paths.configFile}): ${error.message}`);
138
+ }
139
+ try {
140
+ return normalize(JSON.parse(text));
141
+ } catch (error) {
142
+ throw new ConfigError(
143
+ `Konfigurasi GitHub tidak valid (${paths.configFile}): ${error.message}. File tidak diubah — perbaiki atau hapus file tersebut.`,
144
+ );
145
+ }
146
+ }
147
+
148
+ /** Tulis konfigurasi secara atomik dengan permission 0600. */
149
+ export async function writeGitHubConfig(config, { paths = githubPaths() } = {}) {
150
+ const data = normalize(config);
151
+ await fsp.mkdir(paths.dir, { recursive: true, mode: 0o700 });
152
+ const tmp = path.join(paths.dir, `.config.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
153
+ try {
154
+ await fsp.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
155
+ await fsp.rename(tmp, paths.configFile);
156
+ if (process.platform !== 'win32') await fsp.chmod(paths.configFile, 0o600).catch(() => {});
157
+ } catch (error) {
158
+ await fsp.rm(tmp, { force: true }).catch(() => {});
159
+ throw new ConfigError(`Gagal menyimpan konfigurasi GitHub: ${error.message}`);
160
+ }
161
+ return data;
162
+ }
163
+
164
+ /** Read-modify-write satu key agar perubahan lain tidak hilang. */
165
+ export async function updateGitHubConfig(key, value, { paths = githubPaths() } = {}) {
166
+ const current = await readGitHubConfig({ paths });
167
+ current[key] = value;
168
+ return writeGitHubConfig(current, { paths });
169
+ }
170
+
171
+ /** Cache non-sensitif: kapan terakhir backend diperiksa dan hasilnya. */
172
+ export async function readGitHubState({ paths = githubPaths() } = {}) {
173
+ try {
174
+ const parsed = JSON.parse(await fsp.readFile(paths.stateFile, 'utf8'));
175
+ return parsed && typeof parsed === 'object' ? parsed : {};
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
180
+
181
+ export async function writeGitHubState(state, { paths = githubPaths() } = {}) {
182
+ const data = state && typeof state === 'object' ? state : {};
183
+ await fsp.mkdir(paths.dir, { recursive: true, mode: 0o700 });
184
+ const tmp = path.join(paths.dir, `.state.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
185
+ try {
186
+ await fsp.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
187
+ await fsp.rename(tmp, paths.stateFile);
188
+ if (process.platform !== 'win32') await fsp.chmod(paths.stateFile, 0o600).catch(() => {});
189
+ } catch {
190
+ // Cache tidak boleh menggagalkan operasi utama.
191
+ await fsp.rm(tmp, { force: true }).catch(() => {});
192
+ }
193
+ return data;
194
+ }
195
+
196
+ /** Tampilan konfigurasi non-rahasia untuk `/github config`. */
197
+ export function describeGitHubConfig(config) {
198
+ return {
199
+ enabled: config.enabled ? 'true' : 'false',
200
+ autoRefresh: config.autoRefresh ? 'true' : 'false',
201
+ confirmDestructive: config.confirmDestructive ? 'true' : 'false',
202
+ defaultRepo: config.defaultRepo || '(belum diatur)',
203
+ clientId: config.clientId,
204
+ backend: config.backend,
205
+ clientSecret: 'tidak ada di CLI (disimpan di backend)',
206
+ privateKey: 'tidak ada di CLI (disimpan di backend)',
207
+ webhookSecret: 'tidak ada di CLI (disimpan di backend)',
208
+ };
209
+ }
@@ -0,0 +1,161 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ import { GitHubPluginError, GITHUB_CODES } from './errors.js';
6
+
7
+ /**
8
+ * Penyimpanan kredensial CLI.
9
+ *
10
+ * Yang disimpan HANYA `cliToken` terbitan backend dan cache identitas akun.
11
+ * Access token/refresh token GitHub tetap di server dan tidak pernah menyentuh
12
+ * disk CLI. File dienkripsi AES-256-GCM; kunci 32 byte hidup di direktori lain
13
+ * (`<config>/.keys/github.key`, mode 0600) atau dari `RYUU_GITHUB_KEY`.
14
+ */
15
+ const KEY_BYTES = 32;
16
+ const MAGIC = 'RYUUGITHB1';
17
+
18
+ const credentialError = (message, details = null) =>
19
+ new GitHubPluginError(message, { code: GITHUB_CODES.CREDENTIAL, details });
20
+
21
+ function decodeEnvKey(raw) {
22
+ const text = String(raw || '').trim();
23
+ if (!text) return null;
24
+ const buffer = /^[0-9a-fA-F]{64}$/.test(text) ? Buffer.from(text, 'hex') : Buffer.from(text, 'base64');
25
+ if (buffer.length !== KEY_BYTES) {
26
+ throw credentialError('RYUU_GITHUB_KEY tidak valid: butuh 32 byte (hex 64 karakter atau base64).');
27
+ }
28
+ return buffer;
29
+ }
30
+
31
+ async function readOrCreateKey(keyFile, { env = process.env } = {}) {
32
+ const fromEnv = decodeEnvKey(env.RYUU_GITHUB_KEY);
33
+ if (fromEnv) return fromEnv;
34
+ try {
35
+ const existing = await fsp.readFile(keyFile);
36
+ if (existing.length === KEY_BYTES) return existing;
37
+ throw credentialError(`File kunci GitHub rusak (${keyFile}). Hapus file tersebut lalu jalankan /github login.`);
38
+ } catch (error) {
39
+ if (error.code !== 'ENOENT') throw error;
40
+ }
41
+ const key = crypto.randomBytes(KEY_BYTES);
42
+ await fsp.mkdir(path.dirname(keyFile), { recursive: true, mode: 0o700 });
43
+ try {
44
+ await fsp.writeFile(keyFile, key, { mode: 0o600, flag: 'wx' });
45
+ if (process.platform !== 'win32') await fsp.chmod(keyFile, 0o600).catch(() => {});
46
+ return key;
47
+ } catch (error) {
48
+ if (error.code !== 'EEXIST') throw credentialError(`Gagal menyimpan kunci kredensial GitHub: ${error.message}`);
49
+ const again = await fsp.readFile(keyFile);
50
+ if (again.length !== KEY_BYTES) throw credentialError(`File kunci GitHub rusak (${keyFile}).`);
51
+ return again;
52
+ }
53
+ }
54
+
55
+ /** Simpan cliToken + cache akun. Nilai lain dibuang. */
56
+ export async function saveCredentials(
57
+ { cliToken, ryuuUserId = null, cliTokenExpiresAt = null, account = null, backend = null },
58
+ { paths, env = process.env } = {},
59
+ ) {
60
+ if (typeof cliToken !== 'string' || !cliToken.startsWith('ghcli_')) {
61
+ throw credentialError('Token akses CLI tidak valid.');
62
+ }
63
+ const payload = {
64
+ version: 2,
65
+ cliToken,
66
+ ryuuUserId: typeof ryuuUserId === 'string' ? ryuuUserId : null,
67
+ cliTokenExpiresAt: typeof cliTokenExpiresAt === 'string' ? cliTokenExpiresAt : null,
68
+ backend: typeof backend === 'string' ? backend : null,
69
+ account: account
70
+ ? {
71
+ githubUserId: account.githubUserId || account.id || null,
72
+ login: account.login || null,
73
+ name: account.name || null,
74
+ avatarUrl: account.avatarUrl || null,
75
+ htmlUrl: account.htmlUrl || null,
76
+ type: account.type || 'User',
77
+ scope: account.scope || '',
78
+ }
79
+ : null,
80
+ savedAt: new Date().toISOString(),
81
+ };
82
+ const key = await readOrCreateKey(paths.keyFile, { env });
83
+ const iv = crypto.randomBytes(12);
84
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
85
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
86
+ const tag = cipher.getAuthTag();
87
+ const file = Buffer.concat([Buffer.from(MAGIC, 'utf8'), iv, tag, encrypted]);
88
+
89
+ await fsp.mkdir(paths.dir, { recursive: true, mode: 0o700 });
90
+ const tmp = `${paths.credentialFile}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
91
+ try {
92
+ await fsp.writeFile(tmp, file, { mode: 0o600 });
93
+ await fsp.rename(tmp, paths.credentialFile);
94
+ if (process.platform !== 'win32') await fsp.chmod(paths.credentialFile, 0o600).catch(() => {});
95
+ } catch (error) {
96
+ await fsp.rm(tmp, { force: true }).catch(() => {});
97
+ throw credentialError(`Gagal menyimpan kredensial GitHub: ${error.message}`);
98
+ }
99
+ return { file: paths.credentialFile, savedAt: payload.savedAt };
100
+ }
101
+
102
+ /** Baca dan dekripsi kredensial; null bila belum login. */
103
+ export async function loadCredentials({ paths, env = process.env } = {}) {
104
+ let raw;
105
+ try {
106
+ raw = await fsp.readFile(paths.credentialFile);
107
+ } catch (error) {
108
+ if (error.code === 'ENOENT') return null;
109
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
110
+ throw credentialError(`Izin ditolak saat membaca kredensial GitHub (${paths.credentialFile}).`);
111
+ }
112
+ throw credentialError(`Gagal membaca kredensial GitHub: ${error.message}`);
113
+ }
114
+ if (raw.length < MAGIC.length + 28) throw credentialError('File kredensial GitHub rusak. Jalankan /github login.');
115
+ if (raw.subarray(0, MAGIC.length).toString('utf8') !== MAGIC) {
116
+ throw credentialError('Format file kredensial GitHub tidak dikenali. Jalankan /github login.');
117
+ }
118
+ let key;
119
+ try {
120
+ key = await readOrCreateKey(paths.keyFile, { env });
121
+ } catch (error) {
122
+ if (error.code === GITHUB_CODES.CREDENTIAL) throw error;
123
+ throw credentialError(`Kunci kredensial GitHub tidak tersedia: ${error.message}`);
124
+ }
125
+ const iv = raw.subarray(MAGIC.length, MAGIC.length + 12);
126
+ const tag = raw.subarray(MAGIC.length + 12, MAGIC.length + 28);
127
+ const body = raw.subarray(MAGIC.length + 28);
128
+ try {
129
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
130
+ decipher.setAuthTag(tag);
131
+ const json = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
132
+ return JSON.parse(json);
133
+ } catch {
134
+ throw credentialError(
135
+ 'Kredensial GitHub tidak dapat didekripsi (kunci berbeda atau file rusak). Jalankan /github login untuk menghubungkan ulang.',
136
+ );
137
+ }
138
+ }
139
+
140
+ /** Hapus kredensial lokal beserta kunci CLI. */
141
+ export async function clearCredentials({ paths, env = process.env } = {}) {
142
+ await fsp.rm(paths.credentialFile, { force: true });
143
+ if (!env.RYUU_GITHUB_KEY) await fsp.rm(paths.keyFile, { force: true }).catch(() => {});
144
+ return true;
145
+ }
146
+
147
+ /** Metadata aman dicetak: tidak pernah memuat cliToken. */
148
+ export function describeCredentials(stored) {
149
+ if (!stored) return { connected: false };
150
+ return {
151
+ connected: Boolean(stored.cliToken),
152
+ login: stored.account?.login || null,
153
+ name: stored.account?.name || null,
154
+ type: stored.account?.type || null,
155
+ scope: stored.account?.scope || '',
156
+ ryuuUserId: stored.ryuuUserId || null,
157
+ cliTokenExpiresAt: stored.cliTokenExpiresAt || null,
158
+ backend: stored.backend || null,
159
+ savedAt: stored.savedAt || null,
160
+ };
161
+ }
@@ -0,0 +1,49 @@
1
+ import { RyuuError } from '../../src/utils/errors.js';
2
+
3
+ /**
4
+ * Error plugin GitHub.
5
+ *
6
+ * Setiap kegagalan membawa `code` yang stabil supaya command bisa memberi
7
+ * petunjuk yang tepat tanpa memeriksa teks pesan.
8
+ */
9
+ export class GitHubPluginError extends RyuuError {
10
+ constructor(message, { code = 'GITHUB_ERROR', status = 0, retryable = false, reconnect = false, details = null } = {}) {
11
+ super(message, { code, status, retryable, details });
12
+ this.reconnect = reconnect;
13
+ }
14
+ }
15
+
16
+ export const GITHUB_CODES = Object.freeze({
17
+ DISABLED: 'GITHUB_DISABLED',
18
+ UNAUTHORIZED: 'GITHUB_UNAUTHORIZED',
19
+ BUSY: 'GITHUB_BUSY',
20
+ BACKEND: 'GITHUB_BACKEND',
21
+ BACKEND_UNREACHABLE: 'GITHUB_BACKEND_UNREACHABLE',
22
+ SESSION: 'GITHUB_SESSION',
23
+ LOGIN_TIMEOUT: 'GITHUB_LOGIN_TIMEOUT',
24
+ CONFIG: 'GITHUB_CONFIG',
25
+ CREDENTIAL: 'GITHUB_CREDENTIAL',
26
+ API: 'GITHUB_API',
27
+ RATE_LIMIT: 'GITHUB_RATE_LIMIT',
28
+ NOT_FOUND: 'GITHUB_NOT_FOUND',
29
+ DESTRUCTIVE: 'GITHUB_DESTRUCTIVE_BLOCKED',
30
+ });
31
+
32
+ /**
33
+ * Terjemahkan kegagalan backend/GitHub menjadi kalimat Bahasa Indonesia yang
34
+ * bisa ditindaklanjuti. Tidak pernah menyertakan header, token, atau secret.
35
+ */
36
+ export function describeFailure(error, { action = 'permintaan' } = {}) {
37
+ if (!error) return `${action} gagal tanpa keterangan.`;
38
+ if (error instanceof GitHubPluginError) return error.message;
39
+ const status = Number(error.status || 0);
40
+ if (status === 401) return 'Sesi CLI tidak valid atau sudah berakhir. Jalankan /github login lagi.';
41
+ if (status === 403) return 'Akses ditolak. Periksa permission GitHub App dan instalasinya.';
42
+ if (status === 404) return 'Data yang diminta tidak ditemukan di GitHub.';
43
+ if (status === 429) return 'Batas permintaan GitHub tercapai. Coba lagi beberapa saat lagi.';
44
+ if (status >= 500) return 'Backend atau GitHub sedang bermasalah. Coba lagi nanti.';
45
+ if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
46
+ return 'Tidak dapat menghubungi backend integrasi GitHub. Periksa koneksi internet.';
47
+ }
48
+ return error.message || `${action} gagal.`;
49
+ }
@@ -0,0 +1,41 @@
1
+ import { createGitHubPlugin } from './manager.js';
2
+
3
+ export { GitHubPlugin, createGitHubPlugin } from './manager.js';
4
+ export {
5
+ DEFAULT_GITHUB_CONFIG,
6
+ EDITABLE_KEYS,
7
+ GITHUB_BACKEND_URL,
8
+ GITHUB_CLIENT_ID,
9
+ describeGitHubConfig,
10
+ githubPaths,
11
+ readGitHubConfig,
12
+ readGitHubState,
13
+ updateGitHubConfig,
14
+ validateConfigEntry,
15
+ writeGitHubConfig,
16
+ } from './config.js';
17
+ export { clearCredentials, describeCredentials, loadCredentials, saveCredentials } from './credentials.js';
18
+ export { GitHubBackendClient } from './backend.js';
19
+ export { GitHubApi } from './api.js';
20
+ export { createGitHubTools } from './tools.js';
21
+
22
+ /**
23
+ * Deskriptor plugin untuk host aplikasi.
24
+ *
25
+ * Host hanya membutuhkan id/nama, factory, dan `tools`. Tool baru diberikan ke
26
+ * model setelah plugin enabled DAN authenticated.
27
+ */
28
+ export const githubPluginDescriptor = Object.freeze({
29
+ name: 'github',
30
+ version: '1.0.0',
31
+ description:
32
+ 'Integrasi GitHub: login akun, repo, issue, pull request, release, dan GitHub Actions lewat backend Ryuu.',
33
+ command: 'github',
34
+ dependencies: [],
35
+ });
36
+
37
+ export function createPlugin(options = {}) {
38
+ return createGitHubPlugin(options);
39
+ }
40
+
41
+ export default githubPluginDescriptor;