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 +36 -1
- package/bin/create-task-ops.js +148 -2
- package/package.json +2 -2
- package/templates/api/app/api/orbitops/tasks/[id]/route.ts +7 -1
- package/templates/api/app/api/orbitops/tasks/route.ts +7 -1
- package/templates/api/lib/orbitops/auth.ts +95 -0
- package/templates/api/package.json +2 -1
- package/templates/common/docs/DASHBOARD_CONNECTION.md +4 -0
- package/templates/common/docs/HEARTBEAT_SETUP.md +100 -0
- package/templates/common/docs/TASK_API_CONTRACT.md +5 -0
- package/templates/common/scripts/orbitops-heartbeat.mjs +95 -0
- package/templates/full/app/api/orbitops/tasks/[id]/route.ts +7 -1
- package/templates/full/app/api/orbitops/tasks/route.ts +7 -1
- package/templates/full/lib/orbitops/auth.ts +95 -0
- package/templates/full/package.json +2 -1
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
|
package/bin/create-task-ops.js
CHANGED
|
@@ -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(
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
+
}
|