create-task-ops 0.1.7 → 0.1.8

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/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  node packages/create-task-ops/bin/create-task-ops.js my-project
15
15
  node packages/create-task-ops/bin/create-task-ops.js create my-project
16
16
  node packages/create-task-ops/bin/create-task-ops.js add
17
+ node packages/create-task-ops/bin/create-task-ops.js add --target apps/server
17
18
  node packages/create-task-ops/bin/create-task-ops.js add --docs-only
18
19
  ```
19
20
 
@@ -23,6 +24,7 @@ publish 후에는 아래처럼 쓸 수 있게 설계했다.
23
24
  npx create-task-ops my-project
24
25
  npx create-task-ops create my-project
25
26
  npx create-task-ops add
27
+ npx create-task-ops add --target apps/server
26
28
  npx create-task-ops add --docs-only
27
29
  ```
28
30
 
@@ -34,6 +36,8 @@ npx create-task-ops add --docs-only
34
36
  위와 같지만 명시적으로 create를 적는 형태
35
37
  - `create-task-ops add`
36
38
  현재 리포에 task docs + OrbitOps API 추가
39
+ - `create-task-ops add --target apps/server`
40
+ 문서와 tasks는 리포 루트에 두고, OrbitOps receiver만 `apps/server` 아래에 추가
37
41
  - `create-task-ops add --docs-only`
38
42
  현재 리포에 문서와 task 파일 규약만 추가
39
43
 
@@ -55,6 +59,8 @@ npx create-task-ops add --docs-only
55
59
  - `AGENT.md`
56
60
  - `docs/TASK_API_CONTRACT.md`
57
61
  - `docs/DASHBOARD_CONNECTION.md`
62
+ - `docs/HEARTBEAT_SETUP.md`
63
+ - `scripts/orbitops-heartbeat.mjs`
58
64
  - `app/api/orbitops/health/route.ts`
59
65
  - `app/api/orbitops/tasks/route.ts`
60
66
  - `app/api/orbitops/tasks/[id]/route.ts`
@@ -62,6 +68,19 @@ npx create-task-ops add --docs-only
62
68
 
63
69
  즉 기존 `package.json`, `app/page.tsx`, `app/layout.tsx`, `tsconfig.json` 같은 루트 파일은 건드리지 않는다.
64
70
 
71
+ 모노레포라면 `--target` 으로 receiver를 실제 서버 앱에 넣는 편이 맞다.
72
+
73
+ 예:
74
+
75
+ ```bash
76
+ npx create-task-ops add --target apps/server
77
+ ```
78
+
79
+ 이 경우:
80
+ - `tasks/`, `CLAUDE.md`, `AGENT.md`, `docs/*` 는 리포 루트에 생성
81
+ - `scripts/orbitops-heartbeat.mjs` 도 리포 루트에 생성
82
+ - `app/api/orbitops/*`, `lib/orbitops/task-api.ts` 는 `apps/server` 아래에 생성
83
+
65
84
  기본 동작에서 기존 `CLAUDE.md`, `AGENT.md`, `tasks/*`, `docs/*` 같은 운영 문서는 스킵한다.
66
85
 
67
86
  대신 아래 receiver 경로가 이미 있으면 충돌 가능성이 크므로 경고 후 중단한다.
@@ -79,6 +98,7 @@ npx create-task-ops add --docs-only
79
98
  npm run create-task-ops -- my-project
80
99
  npm run create-task-ops -- create my-project
81
100
  npm run create-task-ops -- add
101
+ npm run create-task-ops -- add --target apps/server
82
102
  npm run create-task-ops -- add --docs-only
83
103
  cd packages/create-task-ops
84
104
  npm run smoke:full
@@ -22,6 +22,7 @@ function parseArgs(argv) {
22
22
  mode: "full",
23
23
  docsOnly: false,
24
24
  force: false,
25
+ target: null,
25
26
  };
26
27
 
27
28
  for (let index = 0; index < argv.length; index += 1) {
@@ -43,6 +44,11 @@ function parseArgs(argv) {
43
44
  options.docsOnly = true;
44
45
  continue;
45
46
  }
47
+ if (arg === "--target") {
48
+ options.target = argv[index + 1] ?? null;
49
+ index += 1;
50
+ continue;
51
+ }
46
52
  if (arg === "--force") {
47
53
  options.force = true;
48
54
  continue;
@@ -67,6 +73,7 @@ function printUsage() {
67
73
  console.log(" create-task-ops <project-name>");
68
74
  console.log(" create-task-ops create <project-name>");
69
75
  console.log(" create-task-ops add");
76
+ console.log(" create-task-ops add --target apps/server");
70
77
  console.log(" create-task-ops add --docs-only");
71
78
  }
72
79
 
@@ -171,6 +178,9 @@ function main() {
171
178
  const targetDir = options.command === "add"
172
179
  ? process.cwd()
173
180
  : path.resolve(process.cwd(), options.name ?? projectName);
181
+ const receiverTargetDir = options.command === "add" && options.target
182
+ ? path.resolve(process.cwd(), options.target)
183
+ : targetDir;
174
184
 
175
185
  if (options.command === "create" && existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
176
186
  console.error(`Target directory is not empty: ${targetDir}`);
@@ -178,6 +188,12 @@ function main() {
178
188
  process.exit(1);
179
189
  }
180
190
 
191
+ if (options.command === "add" && options.target && !existsSync(receiverTargetDir)) {
192
+ console.error(`Receiver target does not exist: ${receiverTargetDir}`);
193
+ console.error("Create the app directory first, then run add with --target.");
194
+ process.exit(1);
195
+ }
196
+
181
197
  mkdirSync(targetDir, { recursive: true });
182
198
 
183
199
  const commonDir = path.join(templatesRoot, "common");
@@ -186,19 +202,22 @@ function main() {
186
202
  const skipExistingDocs = options.command === "add";
187
203
 
188
204
  if (options.command === "add" && options.mode === "api") {
189
- assertReceiverPathsAvailable(targetDir, options.force, addApiFiles);
205
+ assertReceiverPathsAvailable(receiverTargetDir, options.force, addApiFiles);
190
206
  }
191
207
 
192
208
  copyTemplateTree(commonDir, targetDir, projectName, options.force, skipExistingDocs);
193
209
 
194
210
  if (options.command === "add" && options.mode === "api") {
195
- copySelectedFiles(modeDir, targetDir, projectName, options.force, addApiFiles, false);
211
+ copySelectedFiles(modeDir, receiverTargetDir, projectName, options.force, addApiFiles, false);
196
212
  } else {
197
213
  copyTemplateTree(modeDir, targetDir, projectName, options.force, skipExistingDocs);
198
214
  }
199
215
 
200
216
  console.log(`create-task-ops completed`);
201
217
  console.log(`target: ${targetDir}`);
218
+ if (options.command === "add" && options.mode === "api") {
219
+ console.log(`receiver target: ${receiverTargetDir}`);
220
+ }
202
221
  console.log(`command: ${options.command}`);
203
222
  console.log(`mode: ${options.mode}`);
204
223
  console.log(`project: ${projectName}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-task-ops",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Next.js-first task-ops scaffold generator for task docs and task APIs",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -7,6 +7,7 @@
7
7
  - `GET /api/orbitops/health`
8
8
  - `GET /api/orbitops/tasks`
9
9
  - `GET /api/orbitops/tasks/:id`
10
+ - `POST <central>/api/orbitops/agents/heartbeat` optional
10
11
 
11
12
  ## 로컬 개발 연결 예시
12
13
 
@@ -33,3 +34,4 @@
33
34
  2. `/api/orbitops/tasks` 응답이 정상인가
34
35
  3. source `id` 와 `label` 이 명확한가
35
36
  4. 필요하면 API key 또는 reverse proxy 를 설정했는가
37
+ 5. presence 가 필요하면 `docs/HEARTBEAT_SETUP.md` 를 따라 heartbeat sender 를 설정했는가
@@ -0,0 +1,56 @@
1
+ # Heartbeat Setup
2
+
3
+ 이 프로젝트는 중앙 대시보드로 `presence` heartbeat 를 보낼 수 있다.
4
+
5
+ ## 목적
6
+
7
+ - 리모트 에이전트가 현재 `online`, `idle`, `paused`, `offline` 인지 중앙에서 본다.
8
+ - heartbeat 가 끊기면 중앙은 일정 시간 뒤 `unreachable` 로 처리한다.
9
+
10
+ ## 중앙 대시보드 엔드포인트
11
+
12
+ - `POST /api/orbitops/agents/heartbeat`
13
+
14
+ ## 포함된 sender 스크립트
15
+
16
+ - `scripts/orbitops-heartbeat.mjs`
17
+
18
+ 이 스크립트는:
19
+
20
+ - 시작 시 즉시 heartbeat 전송
21
+ - 이후 주기적으로 heartbeat 반복 전송
22
+ - 프로세스 종료 시 `offline` 전송 시도
23
+
24
+ ## 필요한 환경 변수
25
+
26
+ ```bash
27
+ ORBITOPS_HEARTBEAT_URL=http://127.0.0.1:3000/api/orbitops/agents/heartbeat
28
+ ORBITOPS_SOURCE_ID=__PROJECT_NAME__
29
+ ORBITOPS_AGENT_ID=__PROJECT_NAME__-server-01
30
+ ORBITOPS_AGENT=codex
31
+ ORBITOPS_NOTE=Task API receiver is online
32
+ ORBITOPS_HEARTBEAT_INTERVAL_MS=15000
33
+ ORBITOPS_HEARTBEAT_API_KEY=
34
+ ```
35
+
36
+ 선택값:
37
+
38
+ - `ORBITOPS_PRESENCE`
39
+ 기본값 `online`
40
+ - `ORBITOPS_HOST`
41
+ - `ORBITOPS_RUN_ID`
42
+
43
+ ## 실행 예시
44
+
45
+ ```bash
46
+ node scripts/orbitops-heartbeat.mjs
47
+ ```
48
+
49
+ ## systemd 또는 tmux 사용 권장
50
+
51
+ 실서비스에서는 앱 프로세스와 따로 heartbeat sender 를 띄우기보다:
52
+
53
+ - 앱이 직접 heartbeat 를 보내게 하거나
54
+ - process manager 에서 sender 를 같이 올리는 방식이 낫다.
55
+
56
+ 지금 스크립트는 파일럿 및 초기 운영용 기본 예제다.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from "node:os";
4
+ import process from "node:process";
5
+
6
+ const heartbeatUrl = process.env.ORBITOPS_HEARTBEAT_URL;
7
+ const heartbeatSourceId = process.env.ORBITOPS_SOURCE_ID || "__PROJECT_NAME__";
8
+ const heartbeatAgentId =
9
+ process.env.ORBITOPS_AGENT_ID || `${heartbeatSourceId}-${os.hostname()}-${process.pid}`;
10
+ const heartbeatPresence = process.env.ORBITOPS_PRESENCE || "online";
11
+ const heartbeatAgent = process.env.ORBITOPS_AGENT || "codex";
12
+ const heartbeatNote = process.env.ORBITOPS_NOTE || "Task API receiver is online";
13
+ const heartbeatHost = process.env.ORBITOPS_HOST || os.hostname();
14
+ const heartbeatRunId = process.env.ORBITOPS_RUN_ID || "";
15
+ const heartbeatIntervalMs = Number.parseInt(process.env.ORBITOPS_HEARTBEAT_INTERVAL_MS || "15000", 10);
16
+ const heartbeatApiKey = process.env.ORBITOPS_HEARTBEAT_API_KEY || "";
17
+
18
+ if (!heartbeatUrl) {
19
+ console.error("ORBITOPS_HEARTBEAT_URL is required");
20
+ process.exit(1);
21
+ }
22
+
23
+ async function sendHeartbeat(presence = heartbeatPresence) {
24
+ const response = await fetch(heartbeatUrl, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ ...(heartbeatApiKey ? { Authorization: `Bearer ${heartbeatApiKey}` } : {}),
29
+ },
30
+ body: JSON.stringify({
31
+ agentId: heartbeatAgentId,
32
+ sourceId: heartbeatSourceId,
33
+ presence,
34
+ host: heartbeatHost,
35
+ pid: process.pid,
36
+ runId: heartbeatRunId || undefined,
37
+ agent: heartbeatAgent,
38
+ note: heartbeatNote,
39
+ sentAt: new Date().toISOString(),
40
+ }),
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const detail = await response.text().catch(() => "");
45
+ throw new Error(`Heartbeat failed: ${response.status} ${detail}`);
46
+ }
47
+
48
+ console.log(`[orbitops] heartbeat sent: ${presence}`);
49
+ }
50
+
51
+ await sendHeartbeat();
52
+ const timer = setInterval(() => {
53
+ void sendHeartbeat().catch((error) => {
54
+ console.error("[orbitops] heartbeat error", error);
55
+ });
56
+ }, heartbeatIntervalMs);
57
+
58
+ for (const signal of ["SIGINT", "SIGTERM"]) {
59
+ process.on(signal, () => {
60
+ clearInterval(timer);
61
+ void sendHeartbeat("offline")
62
+ .catch(() => null)
63
+ .finally(() => process.exit(0));
64
+ });
65
+ }