create-task-ops 0.1.8 → 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 +17 -2
- package/bin/create-task-ops.js +130 -3
- 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 +2 -0
- package/templates/common/docs/HEARTBEAT_SETUP.md +49 -5
- package/templates/common/docs/TASK_API_CONTRACT.md +5 -0
- package/templates/common/scripts/orbitops-heartbeat.mjs +30 -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
|
@@ -15,6 +15,7 @@ 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
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
|
|
18
19
|
node packages/create-task-ops/bin/create-task-ops.js add --docs-only
|
|
19
20
|
```
|
|
20
21
|
|
|
@@ -25,6 +26,7 @@ npx create-task-ops my-project
|
|
|
25
26
|
npx create-task-ops create my-project
|
|
26
27
|
npx create-task-ops add
|
|
27
28
|
npx create-task-ops add --target apps/server
|
|
29
|
+
npx create-task-ops add --print-targets
|
|
28
30
|
npx create-task-ops add --docs-only
|
|
29
31
|
```
|
|
30
32
|
|
|
@@ -35,9 +37,11 @@ npx create-task-ops add --docs-only
|
|
|
35
37
|
- `create-task-ops create my-project`
|
|
36
38
|
위와 같지만 명시적으로 create를 적는 형태
|
|
37
39
|
- `create-task-ops add`
|
|
38
|
-
현재 리포에 task docs + OrbitOps API
|
|
40
|
+
현재 리포에 task docs + OrbitOps API 추가. `app`, `src/app`, `apps/*/app`, `packages/*/app` 를 스캔해 추천 경로를 고른다.
|
|
39
41
|
- `create-task-ops add --target apps/server`
|
|
40
42
|
문서와 tasks는 리포 루트에 두고, OrbitOps receiver만 `apps/server` 아래에 추가
|
|
43
|
+
- `create-task-ops add --print-targets`
|
|
44
|
+
감지된 receiver 후보 경로만 출력하고 종료
|
|
41
45
|
- `create-task-ops add --docs-only`
|
|
42
46
|
현재 리포에 문서와 task 파일 규약만 추가
|
|
43
47
|
|
|
@@ -61,9 +65,11 @@ npx create-task-ops add --docs-only
|
|
|
61
65
|
- `docs/DASHBOARD_CONNECTION.md`
|
|
62
66
|
- `docs/HEARTBEAT_SETUP.md`
|
|
63
67
|
- `scripts/orbitops-heartbeat.mjs`
|
|
68
|
+
- 기존 `package.json` 이 있으면 `scripts.orbitops:heartbeat` 를 없을 때만 추가
|
|
64
69
|
- `app/api/orbitops/health/route.ts`
|
|
65
70
|
- `app/api/orbitops/tasks/route.ts`
|
|
66
71
|
- `app/api/orbitops/tasks/[id]/route.ts`
|
|
72
|
+
- `lib/orbitops/auth.ts`
|
|
67
73
|
- `lib/orbitops/task-api.ts`
|
|
68
74
|
|
|
69
75
|
즉 기존 `package.json`, `app/page.tsx`, `app/layout.tsx`, `tsconfig.json` 같은 루트 파일은 건드리지 않는다.
|
|
@@ -79,7 +85,7 @@ npx create-task-ops add --target apps/server
|
|
|
79
85
|
이 경우:
|
|
80
86
|
- `tasks/`, `CLAUDE.md`, `AGENT.md`, `docs/*` 는 리포 루트에 생성
|
|
81
87
|
- `scripts/orbitops-heartbeat.mjs` 도 리포 루트에 생성
|
|
82
|
-
- `app/api/orbitops/*`, `lib/orbitops/task-api.ts` 는 `apps/server` 아래에 생성
|
|
88
|
+
- `app/api/orbitops/*`, `lib/orbitops/auth.ts`, `lib/orbitops/task-api.ts` 는 `apps/server` 아래에 생성
|
|
83
89
|
|
|
84
90
|
기본 동작에서 기존 `CLAUDE.md`, `AGENT.md`, `tasks/*`, `docs/*` 같은 운영 문서는 스킵한다.
|
|
85
91
|
|
|
@@ -88,6 +94,7 @@ npx create-task-ops add --target apps/server
|
|
|
88
94
|
- `app/api/orbitops/health/route.ts`
|
|
89
95
|
- `app/api/orbitops/tasks/route.ts`
|
|
90
96
|
- `app/api/orbitops/tasks/[id]/route.ts`
|
|
97
|
+
- `lib/orbitops/auth.ts`
|
|
91
98
|
- `lib/orbitops/task-api.ts`
|
|
92
99
|
|
|
93
100
|
이 receiver 파일까지 강제로 교체하려면 `--force` 를 쓴다.
|
|
@@ -104,6 +111,14 @@ cd packages/create-task-ops
|
|
|
104
111
|
npm run smoke:full
|
|
105
112
|
```
|
|
106
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
|
+
|
|
107
122
|
## 퍼블리시 전 체크
|
|
108
123
|
|
|
109
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,7 @@ function parseArgs(argv) {
|
|
|
22
23
|
mode: "full",
|
|
23
24
|
docsOnly: false,
|
|
24
25
|
force: false,
|
|
26
|
+
printTargets: false,
|
|
25
27
|
target: null,
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -49,6 +51,10 @@ function parseArgs(argv) {
|
|
|
49
51
|
index += 1;
|
|
50
52
|
continue;
|
|
51
53
|
}
|
|
54
|
+
if (arg === "--print-targets") {
|
|
55
|
+
options.printTargets = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
52
58
|
if (arg === "--force") {
|
|
53
59
|
options.force = true;
|
|
54
60
|
continue;
|
|
@@ -74,9 +80,81 @@ function printUsage() {
|
|
|
74
80
|
console.log(" create-task-ops create <project-name>");
|
|
75
81
|
console.log(" create-task-ops add");
|
|
76
82
|
console.log(" create-task-ops add --target apps/server");
|
|
83
|
+
console.log(" create-task-ops add --print-targets");
|
|
77
84
|
console.log(" create-task-ops add --docs-only");
|
|
78
85
|
}
|
|
79
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
|
+
|
|
80
158
|
function listFiles(dir, prefix = "") {
|
|
81
159
|
const entries = readdirSync(dir);
|
|
82
160
|
return entries.flatMap((entry) => {
|
|
@@ -161,6 +239,29 @@ function assertReceiverPathsAvailable(targetDir, force, selectedFiles) {
|
|
|
161
239
|
}
|
|
162
240
|
}
|
|
163
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
|
+
|
|
164
265
|
function main() {
|
|
165
266
|
const options = parseArgs(args);
|
|
166
267
|
if (options.command === "add") {
|
|
@@ -178,9 +279,18 @@ function main() {
|
|
|
178
279
|
const targetDir = options.command === "add"
|
|
179
280
|
? process.cwd()
|
|
180
281
|
: path.resolve(process.cwd(), options.name ?? projectName);
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
294
|
|
|
185
295
|
if (options.command === "create" && existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.force) {
|
|
186
296
|
console.error(`Target directory is not empty: ${targetDir}`);
|
|
@@ -194,6 +304,17 @@ function main() {
|
|
|
194
304
|
process.exit(1);
|
|
195
305
|
}
|
|
196
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
|
+
|
|
197
318
|
mkdirSync(targetDir, { recursive: true });
|
|
198
319
|
|
|
199
320
|
const commonDir = path.join(templatesRoot, "common");
|
|
@@ -213,6 +334,12 @@ function main() {
|
|
|
213
334
|
copyTemplateTree(modeDir, targetDir, projectName, options.force, skipExistingDocs);
|
|
214
335
|
}
|
|
215
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
|
+
|
|
216
343
|
console.log(`create-task-ops completed`);
|
|
217
344
|
console.log(`target: ${targetDir}`);
|
|
218
345
|
if (options.command === "add" && options.mode === "api") {
|
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,7 @@
|
|
|
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 으로 보호된다.
|
|
10
11
|
- `POST <central>/api/orbitops/agents/heartbeat` optional
|
|
11
12
|
|
|
12
13
|
## 로컬 개발 연결 예시
|
|
@@ -35,3 +36,4 @@
|
|
|
35
36
|
3. source `id` 와 `label` 이 명확한가
|
|
36
37
|
4. 필요하면 API key 또는 reverse proxy 를 설정했는가
|
|
37
38
|
5. presence 가 필요하면 `docs/HEARTBEAT_SETUP.md` 를 따라 heartbeat sender 를 설정했는가
|
|
39
|
+
6. receiver 가 `.orbitops-runtime-token` 파일을 읽을 수 있는가
|
|
@@ -31,6 +31,8 @@ ORBITOPS_AGENT=codex
|
|
|
31
31
|
ORBITOPS_NOTE=Task API receiver is online
|
|
32
32
|
ORBITOPS_HEARTBEAT_INTERVAL_MS=15000
|
|
33
33
|
ORBITOPS_HEARTBEAT_API_KEY=
|
|
34
|
+
ORBITOPS_BASE_URL=http://127.0.0.1:3001
|
|
35
|
+
ORBITOPS_RUNTIME_TOKEN_FILE=.orbitops-runtime-token
|
|
34
36
|
```
|
|
35
37
|
|
|
36
38
|
선택값:
|
|
@@ -40,17 +42,59 @@ ORBITOPS_HEARTBEAT_API_KEY=
|
|
|
40
42
|
- `ORBITOPS_HOST`
|
|
41
43
|
- `ORBITOPS_RUN_ID`
|
|
42
44
|
|
|
45
|
+
이 스크립트는 heartbeat 성공 시 중앙이 내려준 runtime token 을 `ORBITOPS_RUNTIME_TOKEN_FILE` 에 저장한다.
|
|
46
|
+
생성된 `OrbitOps` receiver API 는 이 파일을 읽어 `Authorization: Bearer <runtimeToken>` 을 검증한다.
|
|
47
|
+
|
|
43
48
|
## 실행 예시
|
|
44
49
|
|
|
45
50
|
```bash
|
|
46
51
|
node scripts/orbitops-heartbeat.mjs
|
|
47
52
|
```
|
|
48
53
|
|
|
49
|
-
|
|
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 예시
|
|
50
68
|
|
|
51
|
-
|
|
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
|
+
```
|
|
52
95
|
|
|
53
|
-
|
|
54
|
-
- process manager 에서 sender 를 같이 올리는 방식이 낫다.
|
|
96
|
+
## 운영 권장
|
|
55
97
|
|
|
56
|
-
|
|
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`
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
4
6
|
import process from "node:process";
|
|
5
7
|
|
|
6
8
|
const heartbeatUrl = process.env.ORBITOPS_HEARTBEAT_URL;
|
|
@@ -14,12 +16,37 @@ const heartbeatHost = process.env.ORBITOPS_HOST || os.hostname();
|
|
|
14
16
|
const heartbeatRunId = process.env.ORBITOPS_RUN_ID || "";
|
|
15
17
|
const heartbeatIntervalMs = Number.parseInt(process.env.ORBITOPS_HEARTBEAT_INTERVAL_MS || "15000", 10);
|
|
16
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");
|
|
17
22
|
|
|
18
23
|
if (!heartbeatUrl) {
|
|
19
24
|
console.error("ORBITOPS_HEARTBEAT_URL is required");
|
|
20
25
|
process.exit(1);
|
|
21
26
|
}
|
|
22
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
|
+
|
|
23
50
|
async function sendHeartbeat(presence = heartbeatPresence) {
|
|
24
51
|
const response = await fetch(heartbeatUrl, {
|
|
25
52
|
method: "POST",
|
|
@@ -31,6 +58,7 @@ async function sendHeartbeat(presence = heartbeatPresence) {
|
|
|
31
58
|
agentId: heartbeatAgentId,
|
|
32
59
|
sourceId: heartbeatSourceId,
|
|
33
60
|
presence,
|
|
61
|
+
baseUrl: heartbeatBaseUrl || undefined,
|
|
34
62
|
host: heartbeatHost,
|
|
35
63
|
pid: process.pid,
|
|
36
64
|
runId: heartbeatRunId || undefined,
|
|
@@ -45,6 +73,8 @@ async function sendHeartbeat(presence = heartbeatPresence) {
|
|
|
45
73
|
throw new Error(`Heartbeat failed: ${response.status} ${detail}`);
|
|
46
74
|
}
|
|
47
75
|
|
|
76
|
+
const payload = await response.json().catch(() => ({}));
|
|
77
|
+
await persistRuntimeToken(payload.runtimeToken, payload.expiresAt);
|
|
48
78
|
console.log(`[orbitops] heartbeat sent: ${presence}`);
|
|
49
79
|
}
|
|
50
80
|
|
|
@@ -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
|
+
}
|