create-task-ops 0.1.7 → 0.1.10

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,8 @@
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
18
+ node packages/create-task-ops/bin/create-task-ops.js add --print-targets
17
19
  node packages/create-task-ops/bin/create-task-ops.js add --docs-only
18
20
  ```
19
21
 
@@ -23,6 +25,8 @@ publish 후에는 아래처럼 쓸 수 있게 설계했다.
23
25
  npx create-task-ops my-project
24
26
  npx create-task-ops create my-project
25
27
  npx create-task-ops add
28
+ npx create-task-ops add --target apps/server
29
+ npx create-task-ops add --print-targets
26
30
  npx create-task-ops add --docs-only
27
31
  ```
28
32
 
@@ -33,7 +37,11 @@ npx create-task-ops add --docs-only
33
37
  - `create-task-ops create my-project`
34
38
  위와 같지만 명시적으로 create를 적는 형태
35
39
  - `create-task-ops add`
36
- 현재 리포에 task docs + OrbitOps API 추가
40
+ 현재 리포에 task docs + OrbitOps API 추가. `app`, `src/app`, `apps/*/app`, `packages/*/app` 를 스캔해 추천 경로를 고른다.
41
+ - `create-task-ops add --target apps/server`
42
+ 문서와 tasks는 리포 루트에 두고, OrbitOps receiver만 `apps/server` 아래에 추가
43
+ - `create-task-ops add --print-targets`
44
+ 감지된 receiver 후보 경로만 출력하고 종료
37
45
  - `create-task-ops add --docs-only`
38
46
  현재 리포에 문서와 task 파일 규약만 추가
39
47
 
@@ -55,13 +63,30 @@ npx create-task-ops add --docs-only
55
63
  - `AGENT.md`
56
64
  - `docs/TASK_API_CONTRACT.md`
57
65
  - `docs/DASHBOARD_CONNECTION.md`
66
+ - `docs/HEARTBEAT_SETUP.md`
67
+ - `scripts/orbitops-heartbeat.mjs`
68
+ - 기존 `package.json` 이 있으면 `scripts.orbitops:heartbeat` 를 없을 때만 추가
58
69
  - `app/api/orbitops/health/route.ts`
59
70
  - `app/api/orbitops/tasks/route.ts`
60
71
  - `app/api/orbitops/tasks/[id]/route.ts`
72
+ - `lib/orbitops/auth.ts`
61
73
  - `lib/orbitops/task-api.ts`
62
74
 
63
75
  즉 기존 `package.json`, `app/page.tsx`, `app/layout.tsx`, `tsconfig.json` 같은 루트 파일은 건드리지 않는다.
64
76
 
77
+ 모노레포라면 `--target` 으로 receiver를 실제 서버 앱에 넣는 편이 맞다.
78
+
79
+ 예:
80
+
81
+ ```bash
82
+ npx create-task-ops add --target apps/server
83
+ ```
84
+
85
+ 이 경우:
86
+ - `tasks/`, `CLAUDE.md`, `AGENT.md`, `docs/*` 는 리포 루트에 생성
87
+ - `scripts/orbitops-heartbeat.mjs` 도 리포 루트에 생성
88
+ - `app/api/orbitops/*`, `lib/orbitops/auth.ts`, `lib/orbitops/task-api.ts` 는 `apps/server` 아래에 생성
89
+
65
90
  기본 동작에서 기존 `CLAUDE.md`, `AGENT.md`, `tasks/*`, `docs/*` 같은 운영 문서는 스킵한다.
66
91
 
67
92
  대신 아래 receiver 경로가 이미 있으면 충돌 가능성이 크므로 경고 후 중단한다.
@@ -69,6 +94,7 @@ npx create-task-ops add --docs-only
69
94
  - `app/api/orbitops/health/route.ts`
70
95
  - `app/api/orbitops/tasks/route.ts`
71
96
  - `app/api/orbitops/tasks/[id]/route.ts`
97
+ - `lib/orbitops/auth.ts`
72
98
  - `lib/orbitops/task-api.ts`
73
99
 
74
100
  이 receiver 파일까지 강제로 교체하려면 `--force` 를 쓴다.
@@ -79,11 +105,20 @@ npx create-task-ops add --docs-only
79
105
  npm run create-task-ops -- my-project
80
106
  npm run create-task-ops -- create my-project
81
107
  npm run create-task-ops -- add
108
+ npm run create-task-ops -- add --target apps/server
82
109
  npm run create-task-ops -- add --docs-only
83
110
  cd packages/create-task-ops
84
111
  npm run smoke:full
85
112
  ```
86
113
 
114
+ ## Runtime Token 흐름
115
+
116
+ - 중앙 대시보드 heartbeat 응답은 짧은 수명 runtime token 을 반환한다.
117
+ - `scripts/orbitops-heartbeat.mjs` 는 이 토큰을 기본적으로 `.orbitops-runtime-token` 에 저장한다.
118
+ - 생성기 기준으로는 `npm run orbitops:heartbeat` 스크립트도 같이 잡아준다.
119
+ - 생성된 `GET /api/orbitops/tasks` 와 `GET /api/orbitops/tasks/:id` 는 이 파일을 읽어 `Authorization: Bearer <runtimeToken>` 을 검증한다.
120
+ - `GET /api/orbitops/health` 는 상태 확인용으로 열려 있다.
121
+
87
122
  ## 퍼블리시 전 체크
88
123
 
89
124
  ```bash
@@ -14,6 +14,7 @@ const addApiFiles = [
14
14
  "app/api/orbitops/tasks/[id]/route.ts",
15
15
  "lib/orbitops/task-api.ts",
16
16
  ];
17
+ const targetCandidateRoots = ["app", "src/app"];
17
18
 
18
19
  function parseArgs(argv) {
19
20
  const options = {
@@ -22,6 +23,8 @@ function parseArgs(argv) {
22
23
  mode: "full",
23
24
  docsOnly: false,
24
25
  force: false,
26
+ printTargets: false,
27
+ target: null,
25
28
  };
26
29
 
27
30
  for (let index = 0; index < argv.length; index += 1) {
@@ -43,6 +46,15 @@ function parseArgs(argv) {
43
46
  options.docsOnly = true;
44
47
  continue;
45
48
  }
49
+ if (arg === "--target") {
50
+ options.target = argv[index + 1] ?? null;
51
+ index += 1;
52
+ continue;
53
+ }
54
+ if (arg === "--print-targets") {
55
+ options.printTargets = true;
56
+ continue;
57
+ }
46
58
  if (arg === "--force") {
47
59
  options.force = true;
48
60
  continue;
@@ -67,9 +79,82 @@ function printUsage() {
67
79
  console.log(" create-task-ops <project-name>");
68
80
  console.log(" create-task-ops create <project-name>");
69
81
  console.log(" create-task-ops add");
82
+ console.log(" create-task-ops add --target apps/server");
83
+ console.log(" create-task-ops add --print-targets");
70
84
  console.log(" create-task-ops add --docs-only");
71
85
  }
72
86
 
87
+ function listDirectories(dir) {
88
+ if (!existsSync(dir)) {
89
+ return [];
90
+ }
91
+
92
+ return readdirSync(dir).filter((entry) => statSync(path.join(dir, entry)).isDirectory());
93
+ }
94
+
95
+ function detectReceiverTargets(rootDir) {
96
+ const candidates = [];
97
+ const seen = new Set();
98
+
99
+ function pushCandidate(relativeAppPath, reason) {
100
+ const normalizedAppPath = relativeAppPath.replace(/\\/g, "/").replace(/\/+$/, "");
101
+ if (!normalizedAppPath || seen.has(normalizedAppPath)) {
102
+ return;
103
+ }
104
+
105
+ const absoluteAppPath = path.join(rootDir, normalizedAppPath);
106
+ if (!existsSync(absoluteAppPath) || !statSync(absoluteAppPath).isDirectory()) {
107
+ return;
108
+ }
109
+
110
+ seen.add(normalizedAppPath);
111
+
112
+ candidates.push({
113
+ appPath: normalizedAppPath,
114
+ reason,
115
+ targetPath: normalizedAppPath.endsWith("/app") ? normalizedAppPath.slice(0, -4) || "." : ".",
116
+ score:
117
+ normalizedAppPath === "app"
118
+ ? 100
119
+ : normalizedAppPath === "src/app"
120
+ ? 95
121
+ : normalizedAppPath.endsWith("/src/app")
122
+ ? 90
123
+ : normalizedAppPath.endsWith("/app")
124
+ ? 85
125
+ : 70,
126
+ });
127
+ }
128
+
129
+ for (const root of targetCandidateRoots) {
130
+ pushCandidate(root, "root");
131
+ }
132
+
133
+ for (const group of ["apps", "packages"]) {
134
+ const groupDir = path.join(rootDir, group);
135
+ for (const entry of listDirectories(groupDir)) {
136
+ pushCandidate(path.join(group, entry, "app"), `${group} app`);
137
+ pushCandidate(path.join(group, entry, "src/app"), `${group} src/app`);
138
+ }
139
+ }
140
+
141
+ return candidates.sort((left, right) => right.score - left.score || left.appPath.localeCompare(right.appPath));
142
+ }
143
+
144
+ function printDetectedTargets(targets) {
145
+ if (targets.length === 0) {
146
+ console.log("No Next.js app router path was detected.");
147
+ console.log("Tried: app, src/app, apps/*/app, apps/*/src/app, packages/*/app, packages/*/src/app");
148
+ return;
149
+ }
150
+
151
+ console.log("Detected receiver targets:");
152
+ for (const [index, target] of targets.entries()) {
153
+ const recommended = index === 0 ? " (recommended)" : "";
154
+ console.log(`- ${target.targetPath} [${target.appPath}]${recommended}`);
155
+ }
156
+ }
157
+
73
158
  function listFiles(dir, prefix = "") {
74
159
  const entries = readdirSync(dir);
75
160
  return entries.flatMap((entry) => {
@@ -154,6 +239,29 @@ function assertReceiverPathsAvailable(targetDir, force, selectedFiles) {
154
239
  }
155
240
  }
156
241
 
242
+ function ensureHeartbeatScript(packageJsonPath) {
243
+ if (!existsSync(packageJsonPath)) {
244
+ return;
245
+ }
246
+
247
+ try {
248
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
249
+ const scripts = parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
250
+
251
+ if (!scripts["orbitops:heartbeat"]) {
252
+ scripts["orbitops:heartbeat"] = "node scripts/orbitops-heartbeat.mjs";
253
+ parsed.scripts = scripts;
254
+ writeFileSync(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
255
+ console.log(`Added script orbitops:heartbeat to ${packageJsonPath}`);
256
+ return;
257
+ }
258
+
259
+ console.log(`Keeping existing script orbitops:heartbeat in ${packageJsonPath}`);
260
+ } catch (error) {
261
+ console.warn(`Could not update package.json scripts at ${packageJsonPath}: ${error instanceof Error ? error.message : "unknown error"}`);
262
+ }
263
+ }
264
+
157
265
  function main() {
158
266
  const options = parseArgs(args);
159
267
  if (options.command === "add") {
@@ -171,6 +279,18 @@ function main() {
171
279
  const targetDir = options.command === "add"
172
280
  ? process.cwd()
173
281
  : path.resolve(process.cwd(), options.name ?? projectName);
282
+ const detectedTargets = options.command === "add" ? detectReceiverTargets(targetDir) : [];
283
+ const recommendedTarget = detectedTargets[0]?.targetPath ?? ".";
284
+
285
+ if (options.command === "add" && options.printTargets) {
286
+ printDetectedTargets(detectedTargets);
287
+ process.exit(0);
288
+ }
289
+
290
+ const receiverTargetDir =
291
+ options.command === "add"
292
+ ? path.resolve(process.cwd(), options.target ?? recommendedTarget)
293
+ : targetDir;
174
294
 
175
295
  if (options.command === "create" && existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
176
296
  console.error(`Target directory is not empty: ${targetDir}`);
@@ -178,6 +298,23 @@ function main() {
178
298
  process.exit(1);
179
299
  }
180
300
 
301
+ if (options.command === "add" && options.target && !existsSync(receiverTargetDir)) {
302
+ console.error(`Receiver target does not exist: ${receiverTargetDir}`);
303
+ console.error("Create the app directory first, then run add with --target.");
304
+ process.exit(1);
305
+ }
306
+
307
+ if (options.command === "add" && options.mode === "api") {
308
+ printDetectedTargets(detectedTargets);
309
+ if (!options.target && detectedTargets.length > 0) {
310
+ console.log(`No --target provided. Using recommended receiver target: ${recommendedTarget}`);
311
+ console.log("Use --target <path> to override.");
312
+ }
313
+ if (!options.target && detectedTargets.length === 0) {
314
+ console.log("Falling back to current directory. Use --target <path> to set the receiver location manually.");
315
+ }
316
+ }
317
+
181
318
  mkdirSync(targetDir, { recursive: true });
182
319
 
183
320
  const commonDir = path.join(templatesRoot, "common");
@@ -186,19 +323,28 @@ function main() {
186
323
  const skipExistingDocs = options.command === "add";
187
324
 
188
325
  if (options.command === "add" && options.mode === "api") {
189
- assertReceiverPathsAvailable(targetDir, options.force, addApiFiles);
326
+ assertReceiverPathsAvailable(receiverTargetDir, options.force, addApiFiles);
190
327
  }
191
328
 
192
329
  copyTemplateTree(commonDir, targetDir, projectName, options.force, skipExistingDocs);
193
330
 
194
331
  if (options.command === "add" && options.mode === "api") {
195
- copySelectedFiles(modeDir, targetDir, projectName, options.force, addApiFiles, false);
332
+ copySelectedFiles(modeDir, receiverTargetDir, projectName, options.force, addApiFiles, false);
196
333
  } else {
197
334
  copyTemplateTree(modeDir, targetDir, projectName, options.force, skipExistingDocs);
198
335
  }
199
336
 
337
+ if (options.command === "add") {
338
+ ensureHeartbeatScript(path.join(targetDir, "package.json"));
339
+ } else if (options.mode !== "docs") {
340
+ ensureHeartbeatScript(path.join(targetDir, "package.json"));
341
+ }
342
+
200
343
  console.log(`create-task-ops completed`);
201
344
  console.log(`target: ${targetDir}`);
345
+ if (options.command === "add" && options.mode === "api") {
346
+ console.log(`receiver target: ${receiverTargetDir}`);
347
+ }
202
348
  console.log(`command: ${options.command}`);
203
349
  console.log(`mode: ${options.mode}`);
204
350
  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.10",
4
4
  "description": "Next.js-first task-ops scaffold generator for task docs and task APIs",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "smoke:full": "node ./bin/create-task-ops.js /tmp/create-task-ops-full --mode full --force",
31
31
  "smoke:add": "PACKAGE_DIR=\"$PWD\"; mkdir -p /tmp/create-task-ops-add && cd /tmp/create-task-ops-add && node \"$PACKAGE_DIR/bin/create-task-ops.js\" add --force",
32
32
  "smoke:docs": "PACKAGE_DIR=\"$PWD\"; mkdir -p /tmp/create-task-ops-docs && cd /tmp/create-task-ops-docs && node \"$PACKAGE_DIR/bin/create-task-ops.js\" add --docs-only --force",
33
- "prepublishOnly": "npm run smoke:add && npm run smoke:docs && npm run pack:dry-run"
33
+ "prepublishOnly": "npm run smoke:full && npm run smoke:add && npm run smoke:docs && npm run pack:dry-run"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
@@ -1,13 +1,19 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { authorizeOrbitOpsRequest } from "@/lib/orbitops/auth";
2
3
  import { getTaskById } from "@/lib/orbitops/task-api";
3
4
 
4
5
  export const runtime = "nodejs";
5
6
  export const dynamic = "force-dynamic";
6
7
 
7
8
  export async function GET(
8
- _request: Request,
9
+ request: Request,
9
10
  context: { params: Promise<{ id: string }> },
10
11
  ) {
12
+ const authorization = await authorizeOrbitOpsRequest(request);
13
+ if (!authorization.ok) {
14
+ return NextResponse.json({ error: authorization.error }, { status: authorization.status });
15
+ }
16
+
11
17
  const { id } = await context.params;
12
18
  const task = await getTaskById(id);
13
19
 
@@ -1,10 +1,16 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { authorizeOrbitOpsRequest } from "@/lib/orbitops/auth";
2
3
  import { getTasks } from "@/lib/orbitops/task-api";
3
4
 
4
5
  export const runtime = "nodejs";
5
6
  export const dynamic = "force-dynamic";
6
7
 
7
- export async function GET() {
8
+ export async function GET(request: Request) {
9
+ const authorization = await authorizeOrbitOpsRequest(request);
10
+ if (!authorization.ok) {
11
+ return NextResponse.json({ error: authorization.error }, { status: authorization.status });
12
+ }
13
+
8
14
  const tasks = await getTasks();
9
15
 
10
16
  return NextResponse.json({
@@ -0,0 +1,95 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type RuntimeTokenFile = {
5
+ sourceId?: string;
6
+ runtimeToken?: string;
7
+ expiresAt?: string;
8
+ updatedAt?: string;
9
+ };
10
+
11
+ async function fileExists(filePath: string) {
12
+ try {
13
+ await access(filePath);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ async function resolveRuntimeTokenFile() {
21
+ const configuredPath = process.env.ORBITOPS_RUNTIME_TOKEN_FILE;
22
+ if (configuredPath) {
23
+ return configuredPath;
24
+ }
25
+
26
+ let currentDir = process.cwd();
27
+ const { root } = path.parse(currentDir);
28
+
29
+ while (true) {
30
+ const candidate = path.join(currentDir, ".orbitops-runtime-token");
31
+ if (await fileExists(candidate)) {
32
+ return candidate;
33
+ }
34
+
35
+ if (currentDir === root) {
36
+ return candidate;
37
+ }
38
+
39
+ currentDir = path.dirname(currentDir);
40
+ }
41
+ }
42
+
43
+ async function readRuntimeTokenFile() {
44
+ const filePath = await resolveRuntimeTokenFile();
45
+ const raw = await readFile(filePath, "utf8");
46
+ const parsed = JSON.parse(raw) as RuntimeTokenFile;
47
+
48
+ if (!parsed.runtimeToken || !parsed.expiresAt) {
49
+ throw new Error("Runtime token file is missing required fields");
50
+ }
51
+
52
+ return parsed;
53
+ }
54
+
55
+ export async function authorizeOrbitOpsRequest(request: Request) {
56
+ const authorization = request.headers.get("authorization");
57
+ if (!authorization?.startsWith("Bearer ")) {
58
+ return {
59
+ ok: false as const,
60
+ status: 401,
61
+ error: "Missing runtime token",
62
+ };
63
+ }
64
+
65
+ let tokenFile: RuntimeTokenFile;
66
+
67
+ try {
68
+ tokenFile = await readRuntimeTokenFile();
69
+ } catch (error) {
70
+ return {
71
+ ok: false as const,
72
+ status: 503,
73
+ error: error instanceof Error ? error.message : "Runtime token is unavailable",
74
+ };
75
+ }
76
+
77
+ const providedToken = authorization.slice("Bearer ".length).trim();
78
+ if (providedToken !== tokenFile.runtimeToken) {
79
+ return {
80
+ ok: false as const,
81
+ status: 401,
82
+ error: "Invalid runtime token",
83
+ };
84
+ }
85
+
86
+ if (new Date(tokenFile.expiresAt ?? "").getTime() <= Date.now()) {
87
+ return {
88
+ ok: false as const,
89
+ status: 401,
90
+ error: "Runtime token expired",
91
+ };
92
+ }
93
+
94
+ return { ok: true as const };
95
+ }
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "next dev",
7
7
  "build": "next build",
8
- "start": "next start"
8
+ "start": "next start",
9
+ "orbitops:heartbeat": "node scripts/orbitops-heartbeat.mjs"
9
10
  },
10
11
  "dependencies": {
11
12
  "next": "15.2.4",
@@ -7,6 +7,8 @@
7
7
  - `GET /api/orbitops/health`
8
8
  - `GET /api/orbitops/tasks`
9
9
  - `GET /api/orbitops/tasks/:id`
10
+ - task endpoint 는 중앙 heartbeat 로 발급받은 runtime token 으로 보호된다.
11
+ - `POST <central>/api/orbitops/agents/heartbeat` optional
10
12
 
11
13
  ## 로컬 개발 연결 예시
12
14
 
@@ -33,3 +35,5 @@
33
35
  2. `/api/orbitops/tasks` 응답이 정상인가
34
36
  3. source `id` 와 `label` 이 명확한가
35
37
  4. 필요하면 API key 또는 reverse proxy 를 설정했는가
38
+ 5. presence 가 필요하면 `docs/HEARTBEAT_SETUP.md` 를 따라 heartbeat sender 를 설정했는가
39
+ 6. receiver 가 `.orbitops-runtime-token` 파일을 읽을 수 있는가
@@ -0,0 +1,100 @@
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
+ ORBITOPS_BASE_URL=http://127.0.0.1:3001
35
+ ORBITOPS_RUNTIME_TOKEN_FILE=.orbitops-runtime-token
36
+ ```
37
+
38
+ 선택값:
39
+
40
+ - `ORBITOPS_PRESENCE`
41
+ 기본값 `online`
42
+ - `ORBITOPS_HOST`
43
+ - `ORBITOPS_RUN_ID`
44
+
45
+ 이 스크립트는 heartbeat 성공 시 중앙이 내려준 runtime token 을 `ORBITOPS_RUNTIME_TOKEN_FILE` 에 저장한다.
46
+ 생성된 `OrbitOps` receiver API 는 이 파일을 읽어 `Authorization: Bearer <runtimeToken>` 을 검증한다.
47
+
48
+ ## 실행 예시
49
+
50
+ ```bash
51
+ node scripts/orbitops-heartbeat.mjs
52
+ ```
53
+
54
+ 생성기 기준으로는 보통 아래 스크립트도 같이 들어간다.
55
+
56
+ ```bash
57
+ npm run orbitops:heartbeat
58
+ ```
59
+
60
+ ## PM2 예시
61
+
62
+ ```bash
63
+ pm2 start npm --name orbitops-heartbeat -- run orbitops:heartbeat
64
+ pm2 save
65
+ ```
66
+
67
+ ## systemd 예시
68
+
69
+ `/etc/systemd/system/orbitops-heartbeat.service`
70
+
71
+ ```ini
72
+ [Unit]
73
+ Description=OrbitOps Heartbeat Sender
74
+ After=network.target
75
+
76
+ [Service]
77
+ Type=simple
78
+ WorkingDirectory=/path/to/your-repo
79
+ EnvironmentFile=/path/to/your-repo/.env
80
+ ExecStart=/usr/bin/npm run orbitops:heartbeat
81
+ Restart=always
82
+ RestartSec=5
83
+
84
+ [Install]
85
+ WantedBy=multi-user.target
86
+ ```
87
+
88
+ 적용:
89
+
90
+ ```bash
91
+ sudo systemctl daemon-reload
92
+ sudo systemctl enable --now orbitops-heartbeat
93
+ sudo systemctl status orbitops-heartbeat
94
+ ```
95
+
96
+ ## 운영 권장
97
+
98
+ - 개발 중에는 `npm run orbitops:heartbeat`
99
+ - 상시 서버는 `pm2` 또는 `systemd`
100
+ - 앱 프로세스가 lifecycle 을 직접 알 수 있으면, 나중에는 앱 내부 heartbeat 로 옮기는 편이 더 단단하다
@@ -8,6 +8,11 @@
8
8
  - `GET /api/orbitops/tasks`
9
9
  - `GET /api/orbitops/tasks/:id`
10
10
 
11
+ 주의:
12
+
13
+ - 두 task endpoint 는 `Authorization: Bearer <runtimeToken>` 헤더를 요구한다.
14
+ - runtime token 은 중앙 대시보드 heartbeat 응답으로 내려오며, 기본 sender 스크립트가 `.orbitops-runtime-token` 파일에 저장한다.
15
+
11
16
  ## Task Fields
12
17
 
13
18
  - `id`
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+
8
+ const heartbeatUrl = process.env.ORBITOPS_HEARTBEAT_URL;
9
+ const heartbeatSourceId = process.env.ORBITOPS_SOURCE_ID || "__PROJECT_NAME__";
10
+ const heartbeatAgentId =
11
+ process.env.ORBITOPS_AGENT_ID || `${heartbeatSourceId}-${os.hostname()}-${process.pid}`;
12
+ const heartbeatPresence = process.env.ORBITOPS_PRESENCE || "online";
13
+ const heartbeatAgent = process.env.ORBITOPS_AGENT || "codex";
14
+ const heartbeatNote = process.env.ORBITOPS_NOTE || "Task API receiver is online";
15
+ const heartbeatHost = process.env.ORBITOPS_HOST || os.hostname();
16
+ const heartbeatRunId = process.env.ORBITOPS_RUN_ID || "";
17
+ const heartbeatIntervalMs = Number.parseInt(process.env.ORBITOPS_HEARTBEAT_INTERVAL_MS || "15000", 10);
18
+ const heartbeatApiKey = process.env.ORBITOPS_HEARTBEAT_API_KEY || "";
19
+ const heartbeatBaseUrl = process.env.ORBITOPS_BASE_URL || "";
20
+ const runtimeTokenFile =
21
+ process.env.ORBITOPS_RUNTIME_TOKEN_FILE || path.join(process.cwd(), ".orbitops-runtime-token");
22
+
23
+ if (!heartbeatUrl) {
24
+ console.error("ORBITOPS_HEARTBEAT_URL is required");
25
+ process.exit(1);
26
+ }
27
+
28
+ async function persistRuntimeToken(runtimeToken, expiresAt) {
29
+ if (!runtimeToken || !expiresAt) {
30
+ return;
31
+ }
32
+
33
+ await mkdir(path.dirname(runtimeTokenFile), { recursive: true });
34
+ await writeFile(
35
+ runtimeTokenFile,
36
+ JSON.stringify(
37
+ {
38
+ sourceId: heartbeatSourceId,
39
+ runtimeToken,
40
+ expiresAt,
41
+ updatedAt: new Date().toISOString(),
42
+ },
43
+ null,
44
+ 2,
45
+ ),
46
+ "utf8",
47
+ );
48
+ }
49
+
50
+ async function sendHeartbeat(presence = heartbeatPresence) {
51
+ const response = await fetch(heartbeatUrl, {
52
+ method: "POST",
53
+ headers: {
54
+ "Content-Type": "application/json",
55
+ ...(heartbeatApiKey ? { Authorization: `Bearer ${heartbeatApiKey}` } : {}),
56
+ },
57
+ body: JSON.stringify({
58
+ agentId: heartbeatAgentId,
59
+ sourceId: heartbeatSourceId,
60
+ presence,
61
+ baseUrl: heartbeatBaseUrl || undefined,
62
+ host: heartbeatHost,
63
+ pid: process.pid,
64
+ runId: heartbeatRunId || undefined,
65
+ agent: heartbeatAgent,
66
+ note: heartbeatNote,
67
+ sentAt: new Date().toISOString(),
68
+ }),
69
+ });
70
+
71
+ if (!response.ok) {
72
+ const detail = await response.text().catch(() => "");
73
+ throw new Error(`Heartbeat failed: ${response.status} ${detail}`);
74
+ }
75
+
76
+ const payload = await response.json().catch(() => ({}));
77
+ await persistRuntimeToken(payload.runtimeToken, payload.expiresAt);
78
+ console.log(`[orbitops] heartbeat sent: ${presence}`);
79
+ }
80
+
81
+ await sendHeartbeat();
82
+ const timer = setInterval(() => {
83
+ void sendHeartbeat().catch((error) => {
84
+ console.error("[orbitops] heartbeat error", error);
85
+ });
86
+ }, heartbeatIntervalMs);
87
+
88
+ for (const signal of ["SIGINT", "SIGTERM"]) {
89
+ process.on(signal, () => {
90
+ clearInterval(timer);
91
+ void sendHeartbeat("offline")
92
+ .catch(() => null)
93
+ .finally(() => process.exit(0));
94
+ });
95
+ }
@@ -1,13 +1,19 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { authorizeOrbitOpsRequest } from "@/lib/orbitops/auth";
2
3
  import { getTaskById } from "@/lib/orbitops/task-api";
3
4
 
4
5
  export const runtime = "nodejs";
5
6
  export const dynamic = "force-dynamic";
6
7
 
7
8
  export async function GET(
8
- _request: Request,
9
+ request: Request,
9
10
  context: { params: Promise<{ id: string }> },
10
11
  ) {
12
+ const authorization = await authorizeOrbitOpsRequest(request);
13
+ if (!authorization.ok) {
14
+ return NextResponse.json({ error: authorization.error }, { status: authorization.status });
15
+ }
16
+
11
17
  const { id } = await context.params;
12
18
  const task = await getTaskById(id);
13
19
 
@@ -1,10 +1,16 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { authorizeOrbitOpsRequest } from "@/lib/orbitops/auth";
2
3
  import { getTasks } from "@/lib/orbitops/task-api";
3
4
 
4
5
  export const runtime = "nodejs";
5
6
  export const dynamic = "force-dynamic";
6
7
 
7
- export async function GET() {
8
+ export async function GET(request: Request) {
9
+ const authorization = await authorizeOrbitOpsRequest(request);
10
+ if (!authorization.ok) {
11
+ return NextResponse.json({ error: authorization.error }, { status: authorization.status });
12
+ }
13
+
8
14
  const tasks = await getTasks();
9
15
 
10
16
  return NextResponse.json({
@@ -0,0 +1,95 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ type RuntimeTokenFile = {
5
+ sourceId?: string;
6
+ runtimeToken?: string;
7
+ expiresAt?: string;
8
+ updatedAt?: string;
9
+ };
10
+
11
+ async function fileExists(filePath: string) {
12
+ try {
13
+ await access(filePath);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ async function resolveRuntimeTokenFile() {
21
+ const configuredPath = process.env.ORBITOPS_RUNTIME_TOKEN_FILE;
22
+ if (configuredPath) {
23
+ return configuredPath;
24
+ }
25
+
26
+ let currentDir = process.cwd();
27
+ const { root } = path.parse(currentDir);
28
+
29
+ while (true) {
30
+ const candidate = path.join(currentDir, ".orbitops-runtime-token");
31
+ if (await fileExists(candidate)) {
32
+ return candidate;
33
+ }
34
+
35
+ if (currentDir === root) {
36
+ return candidate;
37
+ }
38
+
39
+ currentDir = path.dirname(currentDir);
40
+ }
41
+ }
42
+
43
+ async function readRuntimeTokenFile() {
44
+ const filePath = await resolveRuntimeTokenFile();
45
+ const raw = await readFile(filePath, "utf8");
46
+ const parsed = JSON.parse(raw) as RuntimeTokenFile;
47
+
48
+ if (!parsed.runtimeToken || !parsed.expiresAt) {
49
+ throw new Error("Runtime token file is missing required fields");
50
+ }
51
+
52
+ return parsed;
53
+ }
54
+
55
+ export async function authorizeOrbitOpsRequest(request: Request) {
56
+ const authorization = request.headers.get("authorization");
57
+ if (!authorization?.startsWith("Bearer ")) {
58
+ return {
59
+ ok: false as const,
60
+ status: 401,
61
+ error: "Missing runtime token",
62
+ };
63
+ }
64
+
65
+ let tokenFile: RuntimeTokenFile;
66
+
67
+ try {
68
+ tokenFile = await readRuntimeTokenFile();
69
+ } catch (error) {
70
+ return {
71
+ ok: false as const,
72
+ status: 503,
73
+ error: error instanceof Error ? error.message : "Runtime token is unavailable",
74
+ };
75
+ }
76
+
77
+ const providedToken = authorization.slice("Bearer ".length).trim();
78
+ if (providedToken !== tokenFile.runtimeToken) {
79
+ return {
80
+ ok: false as const,
81
+ status: 401,
82
+ error: "Invalid runtime token",
83
+ };
84
+ }
85
+
86
+ if (new Date(tokenFile.expiresAt ?? "").getTime() <= Date.now()) {
87
+ return {
88
+ ok: false as const,
89
+ status: 401,
90
+ error: "Runtime token expired",
91
+ };
92
+ }
93
+
94
+ return { ok: true as const };
95
+ }
@@ -5,7 +5,8 @@
5
5
  "scripts": {
6
6
  "dev": "next dev",
7
7
  "build": "next build",
8
- "start": "next start"
8
+ "start": "next start",
9
+ "orbitops:heartbeat": "node scripts/orbitops-heartbeat.mjs"
9
10
  },
10
11
  "dependencies": {
11
12
  "next": "15.2.4",