@y80163442/naver-thin-runner 0.1.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - initial public packaging metadata (`publishConfig.access=public`)
6
+ - added `setup --setup-url` self-onboarding command
7
+ - added `service install --mode runner|daemon|both`
8
+ - added `doctor --json` machine-readable health output
9
+ - added setup session status reporting (`READY_FOR_LOGIN` / `READY`)
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @y80163442/naver-thin-runner
2
+
3
+ Naver Blog Writer의 로컬 실행 클라이언트(thin runner)입니다.
4
+
5
+ ## 설치
6
+
7
+ ```bash
8
+ npx @y80163442/naver-thin-runner --help
9
+ ```
10
+
11
+ 또는 전역 설치:
12
+
13
+ ```bash
14
+ npm i -g @y80163442/naver-thin-runner
15
+ ```
16
+
17
+ ## 핵심 명령
18
+
19
+ ```bash
20
+ naver-thin-runner setup --setup-url "https://<control-plane>/v2/onboarding/setup-sessions/start?setup_token=..." --auto-service both
21
+ naver-thin-runner login
22
+ naver-thin-runner start
23
+ naver-thin-runner daemon start --port 19090
24
+ naver-thin-runner doctor --json
25
+ ```
26
+
27
+ ## Self-Onboarding 흐름
28
+
29
+ 1. 판매자/운영자가 setup invite를 발급합니다.
30
+ 2. buyer는 `setup_url` 하나로 아래를 자동 실행합니다.
31
+ - setup session 시작
32
+ - runner 등록(onboarding token 소비)
33
+ - optional service 설치(`--auto-service`)
34
+ - health check(`doctor --json`)
35
+ 3. buyer가 네이버 로그인만 1회 수동으로 수행합니다.
36
+
37
+ ## 주의사항
38
+
39
+ - 1차 지원 OS: macOS
40
+ - launchd service 설치는 macOS만 지원합니다.
41
+ - `login/start`는 repo 루트의 `dist/cli/index.js`(bridge)가 필요합니다.
42
+
43
+ 자세한 릴리즈/호환성 정책은 `/docs/THIN_RUNNER_RELEASE.md`를 참고하세요.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@y80163442/naver-thin-runner",
3
+ "version": "0.1.0",
4
+ "description": "Thin runner client for Naver Blog Writer control-plane",
5
+ "private": false,
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "naver-thin-runner": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/cli.js",
12
+ "test": "node -e \"console.log('no tests')\""
13
+ },
14
+ "files": [
15
+ "src/cli.js",
16
+ "README.md",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "license": "MIT"
26
+ }
package/src/cli.js ADDED
@@ -0,0 +1,767 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const http = require('node:http');
7
+ const { spawn } = require('node:child_process');
8
+
9
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'naver-thin-runner');
10
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
11
+ const DEFAULT_DAEMON_PORT = 19090;
12
+ const DEFAULT_RUNNER_SERVICE_LABEL = 'com.naver-blog-writer.thin-runner';
13
+ const DEFAULT_DAEMON_SERVICE_LABEL = 'com.naver-blog-writer.thin-runner.daemon';
14
+
15
+ const readRunnerVersion = () => {
16
+ try {
17
+ const packagePath = path.join(__dirname, '..', 'package.json');
18
+ const parsed = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
19
+ if (parsed && typeof parsed.version === 'string' && parsed.version.trim()) {
20
+ return parsed.version.trim();
21
+ }
22
+ } catch {
23
+ // ignore
24
+ }
25
+ return '0.0.0';
26
+ };
27
+
28
+ const RUNNER_VERSION = readRunnerVersion();
29
+
30
+ const parseArgs = (argv) => {
31
+ const out = { _: [] };
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ const token = argv[i];
34
+ if (!token.startsWith('--')) {
35
+ out._.push(token);
36
+ continue;
37
+ }
38
+
39
+ const key = token.slice(2);
40
+ const next = argv[i + 1];
41
+ if (!next || next.startsWith('--')) {
42
+ out[key] = true;
43
+ continue;
44
+ }
45
+
46
+ out[key] = next;
47
+ i += 1;
48
+ }
49
+ return out;
50
+ };
51
+
52
+ const ensureConfigDir = () => {
53
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
54
+ };
55
+
56
+ const saveConfig = (value) => {
57
+ ensureConfigDir();
58
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
59
+ };
60
+
61
+ const loadConfig = () => {
62
+ if (!fs.existsSync(CONFIG_PATH)) {
63
+ throw new Error(`config missing: ${CONFIG_PATH}`);
64
+ }
65
+
66
+ const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
67
+ if (!parsed.serverUrl || !parsed.runnerApiKey || !parsed.runnerId || !parsed.localToken) {
68
+ throw new Error(`invalid config: ${CONFIG_PATH}`);
69
+ }
70
+ return parsed;
71
+ };
72
+
73
+ const printHelp = () => {
74
+ console.log(`naver-thin-runner (v${RUNNER_VERSION})
75
+
76
+ Commands:
77
+ setup --setup-url <url> [--repo-root <path>] [--auto-service runner|daemon|both]
78
+ install --server-url <url> --onboarding-token <token> [--tenant-id <id>] [--repo-root <path>]
79
+ daemon start [--port 19090]
80
+ service install [--repo-root <path>] [--mode runner|daemon|both] [--label <runner-label>] [--daemon-label <daemon-label>] [--daemon-port <port>]
81
+ login [--repo-root <path>]
82
+ start [--repo-root <path>]
83
+ doctor [--json]
84
+ logs [--repo-root <path>] [--kind stdout|stderr|both] [--lines 120]
85
+ `);
86
+ };
87
+
88
+ const requestJson = async (url, init = {}) => {
89
+ const response = await fetch(url, init);
90
+ const payload = await response.json().catch(() => ({}));
91
+ return {
92
+ ok: response.ok,
93
+ status: response.status,
94
+ payload,
95
+ };
96
+ };
97
+
98
+ const postJson = async (url, body, headers = {}) => {
99
+ const response = await requestJson(url, {
100
+ method: 'POST',
101
+ headers: {
102
+ 'content-type': 'application/json',
103
+ ...headers,
104
+ },
105
+ body: JSON.stringify(body),
106
+ });
107
+
108
+ if (!response.ok) {
109
+ const reason = response.payload && typeof response.payload === 'object' && typeof response.payload.error === 'string'
110
+ ? response.payload.error
111
+ : 'request_failed';
112
+ throw new Error(`${response.status}:${reason}`);
113
+ }
114
+
115
+ return response.payload;
116
+ };
117
+
118
+ const detectRepoRoot = (override, configured) => {
119
+ if (override) {
120
+ return path.resolve(override);
121
+ }
122
+ if (configured) {
123
+ return path.resolve(configured);
124
+ }
125
+ return process.cwd();
126
+ };
127
+
128
+ const runNodeCli = (repoRoot, args, extraEnv = {}) => {
129
+ const cliPath = path.join(repoRoot, 'dist', 'cli', 'index.js');
130
+ if (!fs.existsSync(cliPath)) {
131
+ throw new Error(`runner bridge not found: ${cliPath}`);
132
+ }
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const child = spawn(process.execPath, [cliPath, ...args], {
136
+ cwd: repoRoot,
137
+ stdio: 'inherit',
138
+ env: {
139
+ ...process.env,
140
+ ...extraEnv,
141
+ },
142
+ });
143
+
144
+ child.on('error', reject);
145
+ child.on('exit', (code) => {
146
+ if (code === 0) {
147
+ resolve();
148
+ } else {
149
+ reject(new Error(`child exited with code ${code ?? -1}`));
150
+ }
151
+ });
152
+ });
153
+ };
154
+
155
+ const parseMajorVersion = (version) => {
156
+ const value = String(version || '').trim();
157
+ if (!value) {
158
+ return null;
159
+ }
160
+ const major = Number.parseInt(value.split('.')[0], 10);
161
+ return Number.isFinite(major) ? major : null;
162
+ };
163
+
164
+ const postSetupSessionStatus = async (controlPlaneUrl, setupSessionId, setupSessionToken, status, lastError = null) => {
165
+ if (!controlPlaneUrl || !setupSessionId || !setupSessionToken) {
166
+ return;
167
+ }
168
+
169
+ try {
170
+ await postJson(
171
+ `${String(controlPlaneUrl).replace(/\/$/, '')}/v2/onboarding/setup-sessions/${encodeURIComponent(setupSessionId)}/status`,
172
+ {
173
+ status,
174
+ last_error: lastError,
175
+ },
176
+ {
177
+ 'x-setup-session-token': setupSessionToken,
178
+ },
179
+ );
180
+ } catch {
181
+ // setup session status updates are best-effort
182
+ }
183
+ };
184
+
185
+ const cmdInstall = async (args, options = {}) => {
186
+ const serverUrl = String(args['server-url'] || '').trim();
187
+ const onboardingToken = String(args['onboarding-token'] || '').trim();
188
+ const tenantId = String(args['tenant-id'] || 'tenant-default').trim();
189
+ const repoRoot = detectRepoRoot(args['repo-root'], null);
190
+ const setupSession = options.setupSession && typeof options.setupSession === 'object'
191
+ ? options.setupSession
192
+ : null;
193
+
194
+ if (!serverUrl || !onboardingToken) {
195
+ throw new Error('install requires --server-url and --onboarding-token');
196
+ }
197
+
198
+ const registered = await postJson(
199
+ `${serverUrl.replace(/\/$/, '')}/v2/runners/register-with-token`,
200
+ {
201
+ onboarding_token: onboardingToken,
202
+ label: `thin-runner-${os.hostname()}`,
203
+ },
204
+ );
205
+
206
+ const localToken = crypto.randomBytes(24).toString('hex');
207
+ saveConfig({
208
+ serverUrl: serverUrl.replace(/\/$/, ''),
209
+ tenantId,
210
+ runnerId: registered.runner_id,
211
+ runnerApiKey: registered.api_key,
212
+ localToken,
213
+ repoRoot,
214
+ runnerVersion: RUNNER_VERSION,
215
+ installedAt: new Date().toISOString(),
216
+ setupSession: setupSession
217
+ ? {
218
+ id: setupSession.id,
219
+ token: setupSession.token,
220
+ }
221
+ : null,
222
+ });
223
+
224
+ const result = {
225
+ installed: true,
226
+ config_path: CONFIG_PATH,
227
+ runner_id: registered.runner_id,
228
+ tenant_id: registered.tenant_id,
229
+ local_daemon_token: localToken,
230
+ runner_version: RUNNER_VERSION,
231
+ };
232
+
233
+ if (!options.quiet) {
234
+ console.log(JSON.stringify(result, null, 2));
235
+ }
236
+
237
+ return result;
238
+ };
239
+
240
+ const cmdDaemonStart = async (args) => {
241
+ const cfg = loadConfig();
242
+ const port = Number.parseInt(String(args.port || DEFAULT_DAEMON_PORT), 10);
243
+ const sealKey = crypto.createHash('sha256').update(cfg.localToken).digest();
244
+
245
+ const writeJson = (res, statusCode, payload) => {
246
+ const body = Buffer.from(JSON.stringify(payload, null, 2));
247
+ res.statusCode = statusCode;
248
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
249
+ res.setHeader('Content-Length', String(body.length));
250
+ res.end(body);
251
+ };
252
+
253
+ const parseBody = (req) => new Promise((resolve, reject) => {
254
+ const chunks = [];
255
+ req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
256
+ req.on('end', () => {
257
+ try {
258
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf8') : '{}';
259
+ resolve(JSON.parse(text || '{}'));
260
+ } catch (error) {
261
+ reject(error);
262
+ }
263
+ });
264
+ req.on('error', reject);
265
+ });
266
+
267
+ const isAuthorized = (req) => {
268
+ const token = req.headers['x-local-token'];
269
+ const value = Array.isArray(token) ? token[0] : token;
270
+ return typeof value === 'string' && value === cfg.localToken;
271
+ };
272
+
273
+ const server = http.createServer(async (req, res) => {
274
+ if (!req.url || !req.method) {
275
+ return writeJson(res, 400, { error: 'INVALID_REQUEST' });
276
+ }
277
+
278
+ if (!isAuthorized(req)) {
279
+ return writeJson(res, 401, { error: 'UNAUTHORIZED' });
280
+ }
281
+
282
+ if (req.method === 'GET' && req.url === '/v1/local/identity') {
283
+ const attest = await postJson(
284
+ `${cfg.serverUrl}/v2/runners/attest`,
285
+ { ttl_seconds: 120 },
286
+ { 'x-runner-api-key': cfg.runnerApiKey },
287
+ );
288
+
289
+ return writeJson(res, 200, {
290
+ runner_id: cfg.runnerId,
291
+ tenant_id: cfg.tenantId,
292
+ runner_public_key: null,
293
+ runner_attestation: attest.runner_attestation,
294
+ attestation_expires_at: attest.expires_at,
295
+ });
296
+ }
297
+
298
+ if (req.method === 'POST' && req.url === '/v1/local/seal-job') {
299
+ let body;
300
+ try {
301
+ body = await parseBody(req);
302
+ } catch {
303
+ return writeJson(res, 400, { error: 'INVALID_JSON' });
304
+ }
305
+
306
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
307
+ const bodyMarkdown = typeof body.body_markdown === 'string' ? body.body_markdown : '';
308
+ const tags = Array.isArray(body.tags) ? body.tags.filter((tag) => typeof tag === 'string') : [];
309
+ const publishAt = typeof body.publish_at === 'string' ? body.publish_at : null;
310
+ const idempotencyKey = typeof body.idempotency_key === 'string' && body.idempotency_key.trim().length > 0
311
+ ? body.idempotency_key.trim()
312
+ : crypto.randomUUID();
313
+
314
+ if (!title || !bodyMarkdown) {
315
+ return writeJson(res, 400, { error: 'title and body_markdown are required' });
316
+ }
317
+
318
+ const clearPayload = {
319
+ title,
320
+ body_markdown: bodyMarkdown,
321
+ tags,
322
+ publish_at: publishAt,
323
+ };
324
+
325
+ const iv = crypto.randomBytes(12);
326
+ const cipher = crypto.createCipheriv('aes-256-gcm', sealKey, iv);
327
+ const encrypted = Buffer.concat([
328
+ cipher.update(Buffer.from(JSON.stringify(clearPayload), 'utf8')),
329
+ cipher.final(),
330
+ ]);
331
+ const authTag = cipher.getAuthTag();
332
+ const ciphertext = Buffer.concat([encrypted, authTag]).toString('base64url');
333
+
334
+ const sealedPayload = {
335
+ ciphertext,
336
+ nonce: iv.toString('base64url'),
337
+ ephemeral_pubkey: 'aes-gcm-local',
338
+ schema_version: 'sealed-aes-gcm-v1',
339
+ };
340
+
341
+ const payloadDigest = crypto.createHash('sha256').update(JSON.stringify(sealedPayload)).digest('hex');
342
+ return writeJson(res, 200, {
343
+ idempotency_key: idempotencyKey,
344
+ sealed_payload: sealedPayload,
345
+ payload_digest: payloadDigest,
346
+ });
347
+ }
348
+
349
+ return writeJson(res, 404, { error: 'NOT_FOUND' });
350
+ });
351
+
352
+ await new Promise((resolve, reject) => {
353
+ server.listen(port, '127.0.0.1', () => resolve());
354
+ server.on('error', reject);
355
+ });
356
+
357
+ console.log(`thin-runner daemon started on 127.0.0.1:${port}`);
358
+ await new Promise(() => {});
359
+ };
360
+
361
+ const createLaunchdPlist = (params) => {
362
+ const argsXml = params.programArgs.map((value) => ` <string>${value}</string>`).join('\n');
363
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key>\n <string>${params.label}</string>\n <key>ProgramArguments</key>\n <array>\n${argsXml}\n </array>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>StandardOutPath</key>\n <string>${params.stdoutPath}</string>\n <key>StandardErrorPath</key>\n <string>${params.stderrPath}</string>\n</dict>\n</plist>\n`;
364
+ };
365
+
366
+ const installLaunchdService = (params) => {
367
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${params.label}.plist`);
368
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
369
+ fs.writeFileSync(plistPath, createLaunchdPlist(params), 'utf8');
370
+ return {
371
+ label: params.label,
372
+ plist: plistPath,
373
+ };
374
+ };
375
+
376
+ const cmdServiceInstall = async (args, options = {}) => {
377
+ if (process.platform !== 'darwin') {
378
+ throw new Error('service install currently supports macOS launchd only');
379
+ }
380
+
381
+ const cfg = loadConfig();
382
+ const repoRoot = detectRepoRoot(args['repo-root'], cfg.repoRoot);
383
+ const modeRaw = String(args.mode || 'runner').trim().toLowerCase();
384
+ const mode = modeRaw || 'runner';
385
+ if (!['runner', 'daemon', 'both'].includes(mode)) {
386
+ throw new Error('service install --mode must be runner|daemon|both');
387
+ }
388
+
389
+ const daemonPort = Number.parseInt(String(args['daemon-port'] || DEFAULT_DAEMON_PORT), 10);
390
+ const runnerLabel = String(args.label || DEFAULT_RUNNER_SERVICE_LABEL).trim();
391
+ const daemonLabel = String(args['daemon-label'] || `${runnerLabel}.daemon` || DEFAULT_DAEMON_SERVICE_LABEL).trim();
392
+
393
+ const logDir = path.join(repoRoot, 'data');
394
+ fs.mkdirSync(logDir, { recursive: true });
395
+
396
+ const installed = [];
397
+
398
+ if (mode === 'runner' || mode === 'both') {
399
+ installed.push(installLaunchdService({
400
+ label: runnerLabel,
401
+ programArgs: [
402
+ process.execPath,
403
+ path.join(__dirname, 'cli.js'),
404
+ 'start',
405
+ '--repo-root',
406
+ repoRoot,
407
+ ],
408
+ stdoutPath: path.join(logDir, 'thin-runner.stdout.log'),
409
+ stderrPath: path.join(logDir, 'thin-runner.stderr.log'),
410
+ }));
411
+ }
412
+
413
+ if (mode === 'daemon' || mode === 'both') {
414
+ installed.push(installLaunchdService({
415
+ label: daemonLabel,
416
+ programArgs: [
417
+ process.execPath,
418
+ path.join(__dirname, 'cli.js'),
419
+ 'daemon',
420
+ 'start',
421
+ '--port',
422
+ String(daemonPort),
423
+ ],
424
+ stdoutPath: path.join(logDir, 'thin-runner-daemon.stdout.log'),
425
+ stderrPath: path.join(logDir, 'thin-runner-daemon.stderr.log'),
426
+ }));
427
+ }
428
+
429
+ const result = {
430
+ installed: true,
431
+ mode,
432
+ services: installed,
433
+ note: 'LaunchAgent plist files created. Load with launchctl if not auto-loaded.',
434
+ };
435
+
436
+ if (!options.quiet) {
437
+ console.log(JSON.stringify(result, null, 2));
438
+ }
439
+
440
+ return result;
441
+ };
442
+
443
+ const cmdLogin = async (args) => {
444
+ const cfg = loadConfig();
445
+ const repoRoot = detectRepoRoot(args['repo-root'], cfg.repoRoot);
446
+
447
+ try {
448
+ await runNodeCli(repoRoot, ['runner', 'login'], {
449
+ ACP_RUNNER_SERVER_URL: cfg.serverUrl,
450
+ ACP_RUNNER_API_KEY: cfg.runnerApiKey,
451
+ ACP_RUNNER_ID: cfg.runnerId,
452
+ ACP_RUNNER_TENANT_ID: cfg.tenantId,
453
+ THIN_RUNNER_LOCAL_TOKEN: cfg.localToken,
454
+ });
455
+
456
+ const setupSession = cfg.setupSession && typeof cfg.setupSession === 'object'
457
+ ? cfg.setupSession
458
+ : null;
459
+
460
+ if (setupSession && setupSession.id && setupSession.token) {
461
+ await postSetupSessionStatus(cfg.serverUrl, setupSession.id, setupSession.token, 'READY', null);
462
+ const updated = {
463
+ ...cfg,
464
+ setupSession: null,
465
+ readyAt: new Date().toISOString(),
466
+ };
467
+ saveConfig(updated);
468
+ }
469
+ } catch (error) {
470
+ const setupSession = cfg.setupSession && typeof cfg.setupSession === 'object'
471
+ ? cfg.setupSession
472
+ : null;
473
+
474
+ if (setupSession && setupSession.id && setupSession.token) {
475
+ await postSetupSessionStatus(
476
+ cfg.serverUrl,
477
+ setupSession.id,
478
+ setupSession.token,
479
+ 'READY_FOR_LOGIN',
480
+ error instanceof Error ? error.message : String(error),
481
+ );
482
+ }
483
+
484
+ throw error;
485
+ }
486
+ };
487
+
488
+ const cmdStart = async (args) => {
489
+ const cfg = loadConfig();
490
+ const repoRoot = detectRepoRoot(args['repo-root'], cfg.repoRoot);
491
+ await runNodeCli(repoRoot, ['runner', 'start'], {
492
+ ACP_RUNNER_SERVER_URL: cfg.serverUrl,
493
+ ACP_RUNNER_API_KEY: cfg.runnerApiKey,
494
+ ACP_RUNNER_ID: cfg.runnerId,
495
+ ACP_RUNNER_TENANT_ID: cfg.tenantId,
496
+ THIN_RUNNER_LOCAL_TOKEN: cfg.localToken,
497
+ });
498
+ };
499
+
500
+ const cmdDoctor = async (args = {}, options = {}) => {
501
+ const cfg = loadConfig();
502
+
503
+ let heartbeatOk = false;
504
+ let heartbeatStatus = 0;
505
+ let heartbeatError = null;
506
+
507
+ try {
508
+ const heartbeat = await fetch(`${cfg.serverUrl}/v1/runners/heartbeat`, {
509
+ method: 'POST',
510
+ headers: {
511
+ 'content-type': 'application/json',
512
+ 'x-runner-api-key': cfg.runnerApiKey,
513
+ },
514
+ body: JSON.stringify({ status: 'ready' }),
515
+ });
516
+ heartbeatOk = heartbeat.ok;
517
+ heartbeatStatus = heartbeat.status;
518
+ } catch (error) {
519
+ heartbeatError = error instanceof Error ? error.message : String(error);
520
+ }
521
+
522
+ let versionInfo = null;
523
+ try {
524
+ const versionRes = await requestJson(`${cfg.serverUrl}/v2/version`, {
525
+ method: 'GET',
526
+ headers: {
527
+ accept: 'application/json',
528
+ },
529
+ });
530
+
531
+ if (versionRes.ok && versionRes.payload && typeof versionRes.payload === 'object') {
532
+ versionInfo = versionRes.payload;
533
+ }
534
+ } catch {
535
+ // ignore version check failures
536
+ }
537
+
538
+ const runnerMajor = parseMajorVersion(RUNNER_VERSION);
539
+ const supportedMajor = versionInfo && Number.isFinite(Number(versionInfo.supported_runner_major))
540
+ ? Number(versionInfo.supported_runner_major)
541
+ : null;
542
+
543
+ const versionCompatible = supportedMajor === null || runnerMajor === null
544
+ ? null
545
+ : runnerMajor === supportedMajor;
546
+
547
+ const report = {
548
+ ok: heartbeatOk && versionCompatible !== false,
549
+ config_path: CONFIG_PATH,
550
+ runner_id: cfg.runnerId,
551
+ tenant_id: cfg.tenantId,
552
+ server_url: cfg.serverUrl,
553
+ runner_version: RUNNER_VERSION,
554
+ heartbeat_ok: heartbeatOk,
555
+ heartbeat_status: heartbeatStatus,
556
+ heartbeat_error: heartbeatError,
557
+ protocol: versionInfo
558
+ ? {
559
+ service_version: versionInfo.service_version || null,
560
+ protocol_version: versionInfo.protocol_version || null,
561
+ min_runner_version: versionInfo.min_runner_version || null,
562
+ supported_runner_major: supportedMajor,
563
+ }
564
+ : null,
565
+ version_compatible: versionCompatible,
566
+ };
567
+
568
+ const wantsJson = Boolean(args.json);
569
+ if (wantsJson || options.quiet) {
570
+ if (!options.quiet) {
571
+ console.log(JSON.stringify(report, null, 2));
572
+ }
573
+ return report;
574
+ }
575
+
576
+ console.log(`runner_id=${report.runner_id}`);
577
+ console.log(`tenant_id=${report.tenant_id}`);
578
+ console.log(`server_url=${report.server_url}`);
579
+ console.log(`runner_version=${report.runner_version}`);
580
+ console.log(`heartbeat_ok=${report.heartbeat_ok} (status=${report.heartbeat_status})`);
581
+ if (report.heartbeat_error) {
582
+ console.log(`heartbeat_error=${report.heartbeat_error}`);
583
+ }
584
+ if (report.protocol) {
585
+ console.log(`protocol_version=${report.protocol.protocol_version}`);
586
+ console.log(`service_version=${report.protocol.service_version}`);
587
+ console.log(`min_runner_version=${report.protocol.min_runner_version}`);
588
+ console.log(`supported_runner_major=${report.protocol.supported_runner_major}`);
589
+ console.log(`version_compatible=${report.version_compatible}`);
590
+ }
591
+
592
+ return report;
593
+ };
594
+
595
+ const cmdSetup = async (args) => {
596
+ const setupUrl = String(args['setup-url'] || '').trim();
597
+ const autoService = String(args['auto-service'] || '').trim().toLowerCase();
598
+ const repoRoot = args['repo-root'] ? String(args['repo-root']).trim() : null;
599
+
600
+ if (!setupUrl) {
601
+ throw new Error('setup requires --setup-url');
602
+ }
603
+
604
+ let controlPlaneUrl = null;
605
+ let setupSessionId = null;
606
+ let setupSessionToken = null;
607
+
608
+ const reportFailure = async (message) => {
609
+ if (!controlPlaneUrl || !setupSessionId || !setupSessionToken) {
610
+ return;
611
+ }
612
+
613
+ await postSetupSessionStatus(
614
+ controlPlaneUrl,
615
+ setupSessionId,
616
+ setupSessionToken,
617
+ 'WAITING_INSTALL',
618
+ message,
619
+ );
620
+ };
621
+
622
+ try {
623
+ const started = await postJson(setupUrl, {
624
+ client_meta: {
625
+ host: os.hostname(),
626
+ platform: process.platform,
627
+ arch: process.arch,
628
+ runner_version: RUNNER_VERSION,
629
+ },
630
+ });
631
+
632
+ const onboardingToken = typeof started.onboarding_token === 'string' ? started.onboarding_token.trim() : '';
633
+ controlPlaneUrl = typeof started.control_plane_url === 'string' ? started.control_plane_url.trim().replace(/\/$/, '') : null;
634
+ setupSessionId = typeof started.setup_session_id === 'string' ? started.setup_session_id.trim() : null;
635
+ setupSessionToken = typeof started.setup_session_token === 'string' ? started.setup_session_token.trim() : null;
636
+ const tenantId = typeof started.tenant_id === 'string' ? started.tenant_id.trim() : 'tenant-default';
637
+
638
+ if (!onboardingToken || !controlPlaneUrl || !setupSessionId || !setupSessionToken) {
639
+ throw new Error('setup response missing required fields');
640
+ }
641
+
642
+ const installResult = await cmdInstall({
643
+ 'server-url': controlPlaneUrl,
644
+ 'onboarding-token': onboardingToken,
645
+ 'tenant-id': tenantId,
646
+ 'repo-root': repoRoot || undefined,
647
+ }, {
648
+ quiet: true,
649
+ setupSession: {
650
+ id: setupSessionId,
651
+ token: setupSessionToken,
652
+ },
653
+ });
654
+
655
+ if (autoService) {
656
+ if (!['runner', 'daemon', 'both'].includes(autoService)) {
657
+ throw new Error('setup --auto-service must be runner|daemon|both');
658
+ }
659
+
660
+ await cmdServiceInstall({
661
+ 'repo-root': repoRoot || undefined,
662
+ mode: autoService,
663
+ }, { quiet: true });
664
+ }
665
+
666
+ const doctor = await cmdDoctor({ json: true }, { quiet: true });
667
+
668
+ await postSetupSessionStatus(controlPlaneUrl, setupSessionId, setupSessionToken, 'READY_FOR_LOGIN', null);
669
+
670
+ console.log(JSON.stringify({
671
+ setup_completed: true,
672
+ setup_session_id: setupSessionId,
673
+ runner_id: installResult.runner_id,
674
+ tenant_id: installResult.tenant_id,
675
+ doctor_ok: doctor.ok,
676
+ config_path: installResult.config_path,
677
+ next_actions: [
678
+ 'Run: npx @y80163442/naver-thin-runner login',
679
+ 'After login, run: npx @y80163442/naver-thin-runner start',
680
+ 'Start local daemon if needed: npx @y80163442/naver-thin-runner daemon start',
681
+ ],
682
+ }, null, 2));
683
+ } catch (error) {
684
+ await reportFailure(error instanceof Error ? error.message : String(error));
685
+ throw error;
686
+ }
687
+ };
688
+
689
+ const cmdLogs = async (args) => {
690
+ const cfg = loadConfig();
691
+ const repoRoot = detectRepoRoot(args['repo-root'], cfg.repoRoot);
692
+ const kind = String(args.kind || 'both');
693
+ const lines = Number.parseInt(String(args.lines || 120), 10);
694
+ const takeTail = (input) => input.split(/\r?\n/).slice(-Math.max(1, lines)).join('\n');
695
+
696
+ const stdoutPath = path.join(repoRoot, 'data', 'runner.stdout.log');
697
+ const stderrPath = path.join(repoRoot, 'data', 'runner.stderr.log');
698
+
699
+ if (kind === 'stdout' || kind === 'both') {
700
+ const text = fs.existsSync(stdoutPath) ? fs.readFileSync(stdoutPath, 'utf8') : '';
701
+ console.log(`\n[stdout] ${stdoutPath}`);
702
+ console.log(text ? takeTail(text) : '(empty)');
703
+ }
704
+
705
+ if (kind === 'stderr' || kind === 'both') {
706
+ const text = fs.existsSync(stderrPath) ? fs.readFileSync(stderrPath, 'utf8') : '';
707
+ console.log(`\n[stderr] ${stderrPath}`);
708
+ console.log(text ? takeTail(text) : '(empty)');
709
+ }
710
+ };
711
+
712
+ const main = async () => {
713
+ const args = parseArgs(process.argv.slice(2));
714
+ const [command, subcommand] = args._;
715
+
716
+ if (!command) {
717
+ printHelp();
718
+ return;
719
+ }
720
+
721
+ if (command === 'setup') {
722
+ await cmdSetup(args);
723
+ return;
724
+ }
725
+
726
+ if (command === 'install') {
727
+ await cmdInstall(args);
728
+ return;
729
+ }
730
+
731
+ if (command === 'daemon' && subcommand === 'start') {
732
+ await cmdDaemonStart(args);
733
+ return;
734
+ }
735
+
736
+ if (command === 'service' && subcommand === 'install') {
737
+ await cmdServiceInstall(args);
738
+ return;
739
+ }
740
+
741
+ if (command === 'login') {
742
+ await cmdLogin(args);
743
+ return;
744
+ }
745
+
746
+ if (command === 'start') {
747
+ await cmdStart(args);
748
+ return;
749
+ }
750
+
751
+ if (command === 'doctor') {
752
+ await cmdDoctor(args);
753
+ return;
754
+ }
755
+
756
+ if (command === 'logs') {
757
+ await cmdLogs(args);
758
+ return;
759
+ }
760
+
761
+ printHelp();
762
+ };
763
+
764
+ main().catch((error) => {
765
+ console.error(error instanceof Error ? error.message : String(error));
766
+ process.exit(1);
767
+ });