sanook-cli 0.5.1 → 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.
Files changed (144) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +57 -8
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3026 -196
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +70 -36
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +14 -47
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +48 -8
  85. package/dist/ui/history.js +37 -5
  86. package/dist/ui/mentions.js +3 -2
  87. package/dist/ui/setup.js +17 -4
  88. package/dist/update.js +24 -11
  89. package/dist/worktree.js +175 -4
  90. package/package.json +4 -4
  91. package/second-brain/AGENTS.md +6 -4
  92. package/second-brain/CLAUDE.md +7 -1
  93. package/second-brain/Evals/_Index.md +10 -2
  94. package/second-brain/Evals/quality-ledger.md +9 -1
  95. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  96. package/second-brain/GEMINI.md +5 -4
  97. package/second-brain/Home.md +1 -1
  98. package/second-brain/Projects/_Index.md +3 -1
  99. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  100. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  101. package/second-brain/README.md +1 -1
  102. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  103. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  104. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  105. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  106. package/second-brain/Research/_Index.md +6 -1
  107. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  108. package/second-brain/Reviews/_Index.md +1 -1
  109. package/second-brain/Runbooks/_Index.md +6 -1
  110. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  111. package/second-brain/SANOOK.md +45 -0
  112. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  113. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  114. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  115. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  116. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  117. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  118. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  119. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  120. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  121. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  124. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  125. package/second-brain/Sessions/_Index.md +15 -1
  126. package/second-brain/Shared/AI-Context-Index.md +22 -0
  127. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  128. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  129. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  130. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  131. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  132. package/second-brain/Shared/Scripts/_Index.md +3 -1
  133. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  134. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  135. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  136. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  137. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  138. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  139. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  140. package/second-brain/Templates/_Index.md +9 -0
  141. package/second-brain/Templates/final-lite.md +111 -0
  142. package/second-brain/Templates/final.md +231 -0
  143. package/second-brain/Vault Structure Map.md +2 -1
  144. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { listTasks, enqueueTask } from './ledger.js';
3
3
  import { parseSchedule } from './schedule.js';
4
+ import { formatTarget, parseSendTarget } from './targets.js';
4
5
  import { tokenMatches } from './auth.js';
5
6
  import { runAgent } from '../loop.js';
6
7
  import { redactKey } from '../providers/keys.js';
@@ -9,21 +10,111 @@ function send(res, status, body) {
9
10
  res.writeHead(status, { 'content-type': 'application/json' });
10
11
  res.end(JSON.stringify(body));
11
12
  }
13
+ function sendRaw(res, status, contentType, body) {
14
+ res.writeHead(status, { 'content-type': contentType });
15
+ res.end(body);
16
+ }
17
+ function sendSse(res, body) {
18
+ res.write(`data: ${typeof body === 'string' ? body : JSON.stringify(body)}\n\n`);
19
+ }
20
+ export function optionalString(value) {
21
+ if (typeof value !== 'string')
22
+ return undefined;
23
+ const trimmed = value.trim();
24
+ return trimmed || undefined;
25
+ }
26
+ export function parseBearerToken(authorization) {
27
+ if (!authorization)
28
+ return undefined;
29
+ const tokenMatch = /^Bearer +(\S+)$/i.exec(authorization);
30
+ if (!tokenMatch)
31
+ return undefined;
32
+ return tokenMatch[1];
33
+ }
34
+ export function parseWebhookRouteName(pathname) {
35
+ if (!pathname.startsWith('/webhooks/'))
36
+ return undefined;
37
+ try {
38
+ const routeName = decodeURIComponent(pathname.slice('/webhooks/'.length)).replace(/^\/+|\/+$/g, '');
39
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(routeName) ? routeName : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ export function parseOptionalSchedule(value, now) {
46
+ if (value == null)
47
+ return { schedule: null };
48
+ if (typeof value !== 'string')
49
+ return { schedule: null, invalid: 'ต้องเป็นข้อความ' };
50
+ const scheduleText = optionalString(value);
51
+ if (!scheduleText)
52
+ return { schedule: null };
53
+ const schedule = parseSchedule(scheduleText, now);
54
+ return schedule ? { schedule } : { schedule: null, invalid: scheduleText };
55
+ }
56
+ export function parseRequiredTaskSpec(value) {
57
+ if (typeof value !== 'string') {
58
+ return value == null ? { invalid: 'ต้องมี spec' } : { invalid: 'spec ต้องเป็นข้อความ' };
59
+ }
60
+ const spec = value.trim();
61
+ return spec ? { spec } : { invalid: 'ต้องมี spec' };
62
+ }
63
+ export function parseOptionalDeliverTarget(value) {
64
+ if (value == null)
65
+ return {};
66
+ if (typeof value !== 'string')
67
+ return { invalid: 'deliver ต้องเป็นข้อความ' };
68
+ const deliverText = optionalString(value);
69
+ if (!deliverText)
70
+ return {};
71
+ try {
72
+ return { deliver: formatTarget(parseSendTarget(deliverText)) };
73
+ }
74
+ catch (e) {
75
+ return { invalid: e.message };
76
+ }
77
+ }
78
+ export function parseOptionalTaskModel(value) {
79
+ if (value == null)
80
+ return {};
81
+ if (typeof value !== 'string')
82
+ return { invalid: 'model ต้องเป็นข้อความ' };
83
+ const model = optionalString(value);
84
+ return model ? { model } : {};
85
+ }
12
86
  const MAX_BODY = 1_000_000; // 1MB กัน memory blowup
87
+ /** error ที่พก HTTP status — ให้ client เห็น 400/413 (client error) แทน 500 (server error) */
88
+ class HttpError extends Error {
89
+ status;
90
+ constructor(status, message) {
91
+ super(message);
92
+ this.status = status;
93
+ }
94
+ }
13
95
  async function readBody(req) {
96
+ const raw = await readRawBody(req);
97
+ if (!raw)
98
+ return {};
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(raw);
102
+ }
103
+ catch {
104
+ throw new HttpError(400, 'invalid JSON body'); // Bad Request — ไม่ leak ข้อความ parser
105
+ }
106
+ return parsed && typeof parsed === 'object' ? parsed : {};
107
+ }
108
+ async function readRawBody(req) {
14
109
  const chunks = [];
15
110
  let size = 0;
16
111
  for await (const c of req) {
17
112
  size += c.length;
18
113
  if (size > MAX_BODY)
19
- throw new Error('request body ใหญ่เกิน');
114
+ throw new HttpError(413, 'request body ใหญ่เกิน'); // Payload Too Large
20
115
  chunks.push(c);
21
116
  }
22
- const raw = Buffer.concat(chunks).toString('utf8');
23
- if (!raw)
24
- return {};
25
- const parsed = JSON.parse(raw);
26
- return parsed && typeof parsed === 'object' ? parsed : {};
117
+ return Buffer.concat(chunks).toString('utf8');
27
118
  }
28
119
  /**
29
120
  * gateway HTTP — bind 127.0.0.1 เท่านั้น (loopback, ไม่ expose ออกเน็ต), ทุก endpoint ยกเว้น /health ต้อง bearer token
@@ -33,7 +124,7 @@ async function readBody(req) {
33
124
  export function startServer(opts) {
34
125
  const server = createServer((req, res) => {
35
126
  // redact กัน API key/secret รั่วใน error response (provider error อาจฝัง key)
36
- void handle(req, res, opts).catch((err) => send(res, 500, { error: redactKey(err.message ?? String(err)) }));
127
+ void handle(req, res, opts).catch((err) => send(res, err.status ?? 500, { error: redactKey(err.message ?? String(err)) }));
37
128
  });
38
129
  // '127.0.0.1' = loopback only — สำคัญ: ห้าม 0.0.0.0 (จะเปิดให้ทั้ง LAN)
39
130
  server.listen(opts.port, '127.0.0.1', () => opts.onLog?.(`http://127.0.0.1:${opts.port} (loopback)`));
@@ -45,9 +136,102 @@ async function handle(req, res, opts) {
45
136
  if (req.method === 'GET' && url.pathname === '/health') {
46
137
  return send(res, 200, { ok: true, service: BRAND.gatewayServiceName });
47
138
  }
139
+ if (req.method === 'GET' && url.pathname === '/line/webhook/health') {
140
+ return send(res, 200, { status: 'ok', platform: 'line' });
141
+ }
142
+ if (req.method === 'GET' && url.pathname === '/sms/webhook/health') {
143
+ return send(res, 200, { status: 'ok', platform: 'sms' });
144
+ }
145
+ if (req.method === 'GET' && url.pathname === '/whatsapp/webhook/health') {
146
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
147
+ const whatsapp = resolveWhatsAppConfig(await readGatewayConfig());
148
+ return send(res, 200, {
149
+ status: 'ok',
150
+ platform: 'whatsapp',
151
+ phone_number_id_configured: Boolean(whatsapp.phoneNumberId),
152
+ access_token_configured: Boolean(whatsapp.accessToken),
153
+ app_secret_configured: Boolean(whatsapp.appSecret),
154
+ verify_token_configured: Boolean(whatsapp.verifyToken),
155
+ });
156
+ }
157
+ if (req.method === 'GET' && url.pathname === '/webhooks/health') {
158
+ return send(res, 200, { status: 'ok', platform: 'webhook' });
159
+ }
160
+ if (req.method === 'POST' && url.pathname === '/line/webhook') {
161
+ const rawBody = await readRawBody(req);
162
+ const signature = Array.isArray(req.headers['x-line-signature']) ? req.headers['x-line-signature'][0] : req.headers['x-line-signature'];
163
+ const { readGatewayConfig, resolveLineConfig } = await import('./config.js');
164
+ const { handleLineWebhook } = await import('./line.js');
165
+ const result = await handleLineWebhook({
166
+ rawBody,
167
+ signature,
168
+ config: resolveLineConfig(await readGatewayConfig()),
169
+ model: opts.defaultModel,
170
+ budgetUsd: opts.budgetUsd,
171
+ permissionMode: opts.permissionMode ?? 'ask',
172
+ onLog: opts.onLog,
173
+ });
174
+ return send(res, result.status, result.body);
175
+ }
176
+ if (req.method === 'POST' && url.pathname === '/sms/webhook') {
177
+ const rawBody = await readRawBody(req);
178
+ const signature = Array.isArray(req.headers['x-twilio-signature']) ? req.headers['x-twilio-signature'][0] : req.headers['x-twilio-signature'];
179
+ const { readGatewayConfig, resolveSmsConfig } = await import('./config.js');
180
+ const { handleSmsWebhook } = await import('./sms.js');
181
+ const result = await handleSmsWebhook({
182
+ rawBody,
183
+ signature,
184
+ config: resolveSmsConfig(await readGatewayConfig()),
185
+ model: opts.defaultModel,
186
+ budgetUsd: opts.budgetUsd,
187
+ permissionMode: opts.permissionMode ?? 'ask',
188
+ onLog: opts.onLog,
189
+ });
190
+ return sendRaw(res, result.status, result.contentType, result.body);
191
+ }
192
+ if (req.method === 'GET' && url.pathname === '/whatsapp/webhook') {
193
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
194
+ const { handleWhatsAppChallenge } = await import('./whatsapp.js');
195
+ const result = handleWhatsAppChallenge(resolveWhatsAppConfig(await readGatewayConfig()), url.searchParams);
196
+ return sendRaw(res, result.status, result.contentType, result.body);
197
+ }
198
+ if (req.method === 'POST' && url.pathname === '/whatsapp/webhook') {
199
+ const rawBody = await readRawBody(req);
200
+ const signature = Array.isArray(req.headers['x-hub-signature-256']) ? req.headers['x-hub-signature-256'][0] : req.headers['x-hub-signature-256'];
201
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
202
+ const { handleWhatsAppWebhook } = await import('./whatsapp.js');
203
+ const result = await handleWhatsAppWebhook({
204
+ rawBody,
205
+ signature,
206
+ config: resolveWhatsAppConfig(await readGatewayConfig()),
207
+ model: opts.defaultModel,
208
+ budgetUsd: opts.budgetUsd,
209
+ permissionMode: opts.permissionMode ?? 'ask',
210
+ onLog: opts.onLog,
211
+ });
212
+ return send(res, result.status, result.body);
213
+ }
214
+ if (req.method === 'POST' && url.pathname.startsWith('/webhooks/')) {
215
+ const routeName = parseWebhookRouteName(url.pathname);
216
+ if (!routeName)
217
+ return send(res, 400, { error: 'invalid_webhook_route' });
218
+ const rawBody = await readRawBody(req);
219
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./config.js');
220
+ const { handleWebhookRequest } = await import('./webhooks.js');
221
+ const result = await handleWebhookRequest({
222
+ routeName,
223
+ rawBody,
224
+ headers: req.headers,
225
+ config: resolveWebhookConfig(await readGatewayConfig()),
226
+ model: opts.defaultModel,
227
+ budgetUsd: opts.budgetUsd,
228
+ permissionMode: opts.permissionMode ?? 'ask',
229
+ onLog: opts.onLog,
230
+ });
231
+ return send(res, result.status, result.body);
232
+ }
48
233
  // ทุก endpoint อื่น → bearer token
49
- const auth = req.headers.authorization ?? '';
50
- const provided = auth.startsWith('Bearer ') ? auth.slice(7) : undefined;
234
+ const provided = parseBearerToken(req.headers.authorization);
51
235
  if (!tokenMatches(opts.token, provided)) {
52
236
  return send(res, 401, { error: 'unauthorized' });
53
237
  }
@@ -63,8 +247,51 @@ async function handle(req, res, opts) {
63
247
  const history = msgs
64
248
  .slice(0, lastUserIdx)
65
249
  .map((m) => ({ role: m.role, content: m.content }));
66
- const model = typeof body.model === 'string' && body.model ? body.model : opts.defaultModel;
67
- const { text } = await runAgent({
250
+ const { model: requestedModel, invalid: invalidModel } = parseOptionalTaskModel(body.model);
251
+ if (invalidModel)
252
+ return send(res, 400, { error: invalidModel });
253
+ const model = requestedModel ?? opts.defaultModel;
254
+ const runner = opts.runner ?? runAgent;
255
+ if (body.stream === true) {
256
+ res.writeHead(200, {
257
+ 'content-type': 'text/event-stream; charset=utf-8',
258
+ 'cache-control': 'no-cache, no-transform',
259
+ connection: 'keep-alive',
260
+ });
261
+ sendSse(res, {
262
+ object: 'chat.completion.chunk',
263
+ model,
264
+ choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
265
+ });
266
+ try {
267
+ await runner({
268
+ model,
269
+ prompt,
270
+ history,
271
+ maxSteps: 20,
272
+ budgetUsd: opts.budgetUsd,
273
+ permissionMode: opts.permissionMode ?? 'ask',
274
+ onEvent: (e) => {
275
+ if (e.type !== 'text' || !e.text)
276
+ return;
277
+ sendSse(res, {
278
+ object: 'chat.completion.chunk',
279
+ model,
280
+ choices: [{ index: 0, delta: { content: e.text }, finish_reason: null }],
281
+ });
282
+ },
283
+ });
284
+ sendSse(res, { object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
285
+ sendSse(res, '[DONE]');
286
+ }
287
+ catch (e) {
288
+ sendSse(res, { error: redactKey(e.message ?? String(e)) });
289
+ sendSse(res, '[DONE]');
290
+ }
291
+ res.end();
292
+ return;
293
+ }
294
+ const { text } = await runner({
68
295
  model,
69
296
  prompt,
70
297
  history,
@@ -83,17 +310,24 @@ async function handle(req, res, opts) {
83
310
  }
84
311
  if (req.method === 'POST' && url.pathname === '/tasks') {
85
312
  const body = await readBody(req);
86
- const spec = String(body.spec ?? '').trim();
87
- if (!spec)
88
- return send(res, 400, { error: 'ต้องมี spec' });
89
- const sched = body.schedule ? parseSchedule(String(body.schedule), Date.now()) : null;
90
- if (body.schedule && !sched)
91
- return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${String(body.schedule)}` });
313
+ const specInput = parseRequiredTaskSpec(body.spec);
314
+ if ('invalid' in specInput)
315
+ return send(res, 400, { error: specInput.invalid });
316
+ const { schedule: sched, invalid } = parseOptionalSchedule(body.schedule, Date.now());
317
+ if (invalid)
318
+ return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${invalid}` });
319
+ const { model, invalid: invalidModel } = parseOptionalTaskModel(body.model);
320
+ if (invalidModel)
321
+ return send(res, 400, { error: invalidModel });
322
+ const { deliver, invalid: invalidDeliver } = parseOptionalDeliverTarget(body.deliver);
323
+ if (invalidDeliver)
324
+ return send(res, 400, { error: invalidDeliver });
92
325
  const task = await enqueueTask({
93
326
  kind: sched?.recurring ? 'cron' : 'once',
94
- spec,
327
+ spec: specInput.spec,
95
328
  schedule: sched?.recurring ? sched.normalized : undefined,
96
- model: typeof body.model === 'string' ? body.model : undefined,
329
+ model,
330
+ deliver,
97
331
  runAt: sched?.runAt ?? Date.now(),
98
332
  });
99
333
  return send(res, 201, { task });
@@ -0,0 +1,224 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync } from 'node:fs';
3
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { appHomePath, BRAND } from '../brand.js';
7
+ const SERVICE_STATE_PATH = appHomePath('gateway', 'service.json');
8
+ const SERVICE_LOG_PATH = appHomePath('gateway', 'gateway.log');
9
+ export function gatewayServiceStatePath() {
10
+ return SERVICE_STATE_PATH;
11
+ }
12
+ export function gatewayServiceLogPath() {
13
+ return SERVICE_LOG_PATH;
14
+ }
15
+ export function processAlive(pid) {
16
+ if (!Number.isInteger(pid) || pid <= 0)
17
+ return false;
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (e) {
23
+ return e.code === 'EPERM';
24
+ }
25
+ }
26
+ export async function readGatewayServiceState() {
27
+ try {
28
+ const parsed = JSON.parse(await readFile(SERVICE_STATE_PATH, 'utf8'));
29
+ const pid = parsed.pid;
30
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || !parsed.command || !Array.isArray(parsed.args))
31
+ return null;
32
+ return {
33
+ pid,
34
+ startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : '',
35
+ command: parsed.command,
36
+ args: parsed.args.filter((a) => typeof a === 'string'),
37
+ cwd: typeof parsed.cwd === 'string' ? parsed.cwd : process.cwd(),
38
+ logPath: typeof parsed.logPath === 'string' ? parsed.logPath : SERVICE_LOG_PATH,
39
+ };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export async function gatewayServiceStatus() {
46
+ const state = await readGatewayServiceState();
47
+ return {
48
+ running: state ? processAlive(state.pid) : false,
49
+ state,
50
+ statePath: SERVICE_STATE_PATH,
51
+ logPath: SERVICE_LOG_PATH,
52
+ };
53
+ }
54
+ async function writeState(state) {
55
+ await mkdir(dirname(SERVICE_STATE_PATH), { recursive: true });
56
+ await writeFile(SERVICE_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
57
+ await chmod(SERVICE_STATE_PATH, 0o600).catch(() => { });
58
+ }
59
+ export async function clearGatewayServiceState() {
60
+ await rm(SERVICE_STATE_PATH, { force: true }).catch(() => { });
61
+ }
62
+ export async function startGatewayService(opts) {
63
+ const existing = await readGatewayServiceState();
64
+ if (existing && processAlive(existing.pid))
65
+ return { started: false, state: existing };
66
+ const command = opts.command ?? process.execPath;
67
+ const entrypoint = resolve(opts.entrypoint);
68
+ const args = [entrypoint, 'gateway', 'run', ...(opts.gatewayArgs ?? [])];
69
+ const cwd = opts.cwd ?? process.cwd();
70
+ await mkdir(dirname(SERVICE_LOG_PATH), { recursive: true });
71
+ const fd = openSync(SERVICE_LOG_PATH, 'a');
72
+ let child;
73
+ try {
74
+ child = (opts.spawnFn ?? spawn)(command, args, {
75
+ cwd,
76
+ detached: true,
77
+ env: opts.env ?? process.env,
78
+ stdio: ['ignore', fd, fd],
79
+ });
80
+ }
81
+ finally {
82
+ closeSync(fd);
83
+ }
84
+ child.unref();
85
+ if (!child.pid)
86
+ throw new Error('เริ่ม gateway service ไม่สำเร็จ: ไม่มี pid จาก child process');
87
+ const state = {
88
+ pid: child.pid,
89
+ startedAt: new Date().toISOString(),
90
+ command,
91
+ args,
92
+ cwd,
93
+ logPath: SERVICE_LOG_PATH,
94
+ };
95
+ await writeState(state);
96
+ return { started: true, state };
97
+ }
98
+ async function waitUntilStopped(pid, timeoutMs) {
99
+ const start = Date.now();
100
+ while (Date.now() - start < timeoutMs) {
101
+ if (!processAlive(pid))
102
+ return true;
103
+ await new Promise((r) => setTimeout(r, 100));
104
+ }
105
+ return !processAlive(pid);
106
+ }
107
+ export async function stopGatewayService(timeoutMs = 3000) {
108
+ const state = await readGatewayServiceState();
109
+ if (!state)
110
+ return { stopped: false, state: null };
111
+ if (!processAlive(state.pid)) {
112
+ await clearGatewayServiceState();
113
+ return { stopped: false, state };
114
+ }
115
+ process.kill(state.pid, 'SIGTERM');
116
+ const stopped = await waitUntilStopped(state.pid, timeoutMs);
117
+ if (!stopped && processAlive(state.pid)) {
118
+ try {
119
+ process.kill(state.pid, 'SIGKILL');
120
+ }
121
+ catch {
122
+ /* already gone */
123
+ }
124
+ }
125
+ await clearGatewayServiceState();
126
+ return { stopped: true, state };
127
+ }
128
+ function escapeXml(value) {
129
+ return value
130
+ .replaceAll('&', '&amp;')
131
+ .replaceAll('<', '&lt;')
132
+ .replaceAll('>', '&gt;')
133
+ .replaceAll('"', '&quot;')
134
+ .replaceAll("'", '&apos;');
135
+ }
136
+ function quoteSystemdArg(value) {
137
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
138
+ }
139
+ function quoteCmdArg(value) {
140
+ return `"${value.replaceAll('"', '""')}"`;
141
+ }
142
+ export async function installGatewayService(entrypoint) {
143
+ const command = process.execPath;
144
+ const script = resolve(entrypoint);
145
+ if (process.platform === 'darwin') {
146
+ const path = join(homedir(), 'Library', 'LaunchAgents', `com.${BRAND.cliName}.gateway.plist`);
147
+ const log = SERVICE_LOG_PATH;
148
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
149
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
150
+ <plist version="1.0">
151
+ <dict>
152
+ <key>Label</key><string>com.${BRAND.cliName}.gateway</string>
153
+ <key>ProgramArguments</key>
154
+ <array>
155
+ <string>${escapeXml(command)}</string>
156
+ <string>${escapeXml(script)}</string>
157
+ <string>gateway</string>
158
+ <string>run</string>
159
+ </array>
160
+ <key>RunAtLoad</key><true/>
161
+ <key>KeepAlive</key><true/>
162
+ <key>StandardOutPath</key><string>${escapeXml(log)}</string>
163
+ <key>StandardErrorPath</key><string>${escapeXml(log)}</string>
164
+ </dict>
165
+ </plist>
166
+ `;
167
+ await mkdir(dirname(path), { recursive: true });
168
+ await writeFile(path, plist, { mode: 0o644 });
169
+ return {
170
+ path,
171
+ kind: 'launchd',
172
+ instructions: [`launchctl load ${path}`, `launchctl start com.${BRAND.cliName}.gateway`],
173
+ };
174
+ }
175
+ if (process.platform === 'linux') {
176
+ const path = join(homedir(), '.config', 'systemd', 'user', `${BRAND.cliName}-gateway.service`);
177
+ const unit = `[Unit]
178
+ Description=${BRAND.productName} Gateway
179
+
180
+ [Service]
181
+ ExecStart=${quoteSystemdArg(command)} ${quoteSystemdArg(script)} gateway run
182
+ Restart=always
183
+ WorkingDirectory=${quoteSystemdArg(process.cwd())}
184
+ StandardOutput=${quoteSystemdArg(`append:${SERVICE_LOG_PATH}`)}
185
+ StandardError=${quoteSystemdArg(`append:${SERVICE_LOG_PATH}`)}
186
+
187
+ [Install]
188
+ WantedBy=default.target
189
+ `;
190
+ await mkdir(dirname(path), { recursive: true });
191
+ await writeFile(path, unit, { mode: 0o644 });
192
+ return {
193
+ path,
194
+ kind: 'systemd',
195
+ instructions: ['systemctl --user daemon-reload', `systemctl --user enable --now ${BRAND.cliName}-gateway.service`],
196
+ };
197
+ }
198
+ const path = appHomePath('gateway', `${BRAND.cliName}-gateway.cmd`);
199
+ await mkdir(dirname(path), { recursive: true });
200
+ await writeFile(path, `${quoteCmdArg(command)} ${quoteCmdArg(script)} gateway run\r\n`, { mode: 0o700 });
201
+ return {
202
+ path,
203
+ kind: 'cmd',
204
+ instructions: [`Run ${path} from your preferred Windows service manager or Task Scheduler.`],
205
+ };
206
+ }
207
+ export async function uninstallGatewayService() {
208
+ const paths = [
209
+ join(homedir(), 'Library', 'LaunchAgents', `com.${BRAND.cliName}.gateway.plist`),
210
+ join(homedir(), '.config', 'systemd', 'user', `${BRAND.cliName}-gateway.service`),
211
+ appHomePath('gateway', `${BRAND.cliName}-gateway.cmd`),
212
+ ];
213
+ const removed = [];
214
+ for (const path of paths) {
215
+ try {
216
+ await rm(path, { force: true });
217
+ removed.push(path);
218
+ }
219
+ catch {
220
+ /* best effort */
221
+ }
222
+ }
223
+ return removed;
224
+ }