create-task-ops 0.1.6 → 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
 
@@ -138,6 +145,22 @@ function copySelectedFiles(sourceDir, targetDir, projectName, force, selectedFil
138
145
  }
139
146
  }
140
147
 
148
+ function assertReceiverPathsAvailable(targetDir, force, selectedFiles) {
149
+ if (force) {
150
+ return;
151
+ }
152
+
153
+ for (const relativeFile of selectedFiles) {
154
+ const targetPath = path.join(targetDir, relativeFile);
155
+ if (existsSync(targetPath)) {
156
+ console.error(`Refusing to overwrite existing receiver file: ${targetPath}`);
157
+ console.error("This path is reserved for task API integration.");
158
+ console.error("Use --force if you want to replace the existing receiver files.");
159
+ process.exit(1);
160
+ }
161
+ }
162
+ }
163
+
141
164
  function main() {
142
165
  const options = parseArgs(args);
143
166
  if (options.command === "add") {
@@ -155,6 +178,9 @@ function main() {
155
178
  const targetDir = options.command === "add"
156
179
  ? process.cwd()
157
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;
158
184
 
159
185
  if (options.command === "create" && existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
160
186
  console.error(`Target directory is not empty: ${targetDir}`);
@@ -162,6 +188,12 @@ function main() {
162
188
  process.exit(1);
163
189
  }
164
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
+
165
197
  mkdirSync(targetDir, { recursive: true });
166
198
 
167
199
  const commonDir = path.join(templatesRoot, "common");
@@ -169,16 +201,23 @@ function main() {
169
201
 
170
202
  const skipExistingDocs = options.command === "add";
171
203
 
204
+ if (options.command === "add" && options.mode === "api") {
205
+ assertReceiverPathsAvailable(receiverTargetDir, options.force, addApiFiles);
206
+ }
207
+
172
208
  copyTemplateTree(commonDir, targetDir, projectName, options.force, skipExistingDocs);
173
209
 
174
210
  if (options.command === "add" && options.mode === "api") {
175
- copySelectedFiles(modeDir, targetDir, projectName, options.force, addApiFiles, false);
211
+ copySelectedFiles(modeDir, receiverTargetDir, projectName, options.force, addApiFiles, false);
176
212
  } else {
177
213
  copyTemplateTree(modeDir, targetDir, projectName, options.force, skipExistingDocs);
178
214
  }
179
215
 
180
216
  console.log(`create-task-ops completed`);
181
217
  console.log(`target: ${targetDir}`);
218
+ if (options.command === "add" && options.mode === "api") {
219
+ console.log(`receiver target: ${receiverTargetDir}`);
220
+ }
182
221
  console.log(`command: ${options.command}`);
183
222
  console.log(`mode: ${options.mode}`);
184
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.6",
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
+ }