Merge pull request #564 from pikasTech/fix/v02-device-pod-first-setup-seed
feat: seed device pods during v02 setup
This commit is contained in:
@@ -256,6 +256,7 @@ manages: many devicePodId
|
||||
`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现,稳定 runner 入口仍是 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。该 `.mjs` 只作为兼容启动器,通过 Bun 运行 TypeScript CLI。正式 CLI 的默认行为是:
|
||||
|
||||
- `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。
|
||||
- `setup first-admin` 和 `admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json` 或 `--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。
|
||||
- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。
|
||||
- mutating job 继续由 cloud-api 侧强制 lease/reason;CLI 只转发 `reason` 和 `leaseToken`,不在本地绕过。
|
||||
- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。
|
||||
|
||||
@@ -272,7 +272,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
|
||||
| `POST /auth/login` | 校验本地账号并写入 `user_sessions` token hash。 |
|
||||
| `GET /auth/session` | 从 cookie 恢复 actor、role 和 session 状态。 |
|
||||
| `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | REST 状态和兼容入口;不得读取或返回 password hash、session token 原文或 Secret 值。 |
|
||||
| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 |
|
||||
| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;可选 `devicePod` 或 `devicePods[]` 一次性种下首批服务端权威 profile 并授权给首个 admin;一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 |
|
||||
| `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 |
|
||||
| `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 |
|
||||
| `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 |
|
||||
@@ -281,6 +281,8 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
|
||||
| `POST /v1/device-pods/{devicePodId}/leases`、`GET/DELETE /v1/device-pods/{devicePodId}/leases/current` | 对已授权 device pod 获取、查看和释放互斥 lease;强副作用 job 必须携带有效 lease token。 |
|
||||
| `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 |
|
||||
|
||||
`POST /v1/setup/first-admin` 的 device-pod 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含 `devicePodId` 和 object `profile`;cloud-api 会写入 `device_pods.profile_json/profile_hash` 并创建 `device_pod_grants(device_pod_id, first_admin_user_id)`。响应只能返回脱敏 profile、profileHash 和 grant summary,不得返回 `gatewaySessionId`、`hostWorkspaceRoot`、password 或 session token 原文。
|
||||
|
||||
## 微服务设计
|
||||
|
||||
v0.2 不新增独立用户管理微服务。用户管理、登录、session、device pod grant 和 code agent owner 校验全部放在 `hwlab-cloud-api` 内,理由是:
|
||||
|
||||
@@ -590,7 +590,22 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
|
||||
const setup = await postJson(port, "/v1/setup/first-admin", {
|
||||
username: "admin",
|
||||
password: "admin-pass",
|
||||
displayName: "Initial Admin"
|
||||
displayName: "Initial Admin",
|
||||
devicePod: {
|
||||
devicePodId: "device-pod-71-freq",
|
||||
name: "71-FREQ",
|
||||
profile: {
|
||||
schemaVersion: 1,
|
||||
target: { id: "target-71-freq" },
|
||||
route: {
|
||||
gatewaySessionId: "gws_first_admin_seed",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_device_host_cli",
|
||||
hostWorkspaceRoot: "F:\\Work\\Project",
|
||||
hostCli: "node tools/device-host-cli.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.equal(setup.status, 201);
|
||||
assert.equal(setup.body.created, true);
|
||||
@@ -598,7 +613,13 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
|
||||
assert.equal(setup.body.actor.role, "admin");
|
||||
assert.equal(setup.body.actor.username, "admin");
|
||||
assert.equal(setup.body.setupRequired, false);
|
||||
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 1, initialized: 1 });
|
||||
assert.equal(setup.body.devicePodsInitialized[0].devicePod.devicePodId, "device-pod-71-freq");
|
||||
assert.equal(setup.body.devicePodsInitialized[0].grant.userId, setup.body.actor.id);
|
||||
assert.equal(setup.body.devicePodsInitialized[0].devicePod.profile.route.gatewaySessionId, "redacted");
|
||||
assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false);
|
||||
assert.equal(JSON.stringify(setup.body).includes("gws_first_admin_seed"), false);
|
||||
assert.equal(JSON.stringify(setup.body).includes("F:\\Work\\Project"), false);
|
||||
assert.equal(typeof setup.cookie, "string");
|
||||
|
||||
const session = await getJson(port, "/v1/users/me", setup.cookie);
|
||||
@@ -613,6 +634,20 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
|
||||
assert.equal(second.status, 409);
|
||||
assert.equal(second.body.error.code, "setup_already_completed");
|
||||
|
||||
const pods = await getJson(port, "/v1/device-pods", setup.cookie);
|
||||
assert.equal(pods.status, 200);
|
||||
assert.equal(pods.body.devicePods.length, 1);
|
||||
assert.equal(pods.body.devicePods[0].devicePodId, "device-pod-71-freq");
|
||||
assert.match(pods.body.devicePods[0].profileHash, /^sha256:/u);
|
||||
|
||||
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
||||
intent: "workspace.ls",
|
||||
args: { path: "." }
|
||||
}, setup.cookie);
|
||||
assert.equal(job.status, 409);
|
||||
assert.equal(job.body.devicePodId, "device-pod-71-freq");
|
||||
assert.equal(job.body.blocker.code, "gateway_dispatch_unavailable");
|
||||
|
||||
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
||||
assert.equal(login.status, 200);
|
||||
assert.equal(login.body.actor.role, "admin");
|
||||
@@ -621,6 +656,48 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api first-admin setup validates device-pod seed before creating admin", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
||||
now: () => "2026-05-28T00:00:00.000Z"
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const invalid = await postJson(port, "/v1/setup/first-admin", {
|
||||
username: "admin",
|
||||
password: "admin-pass",
|
||||
devicePod: { devicePodId: "device-pod-71-freq" }
|
||||
});
|
||||
assert.equal(invalid.status, 400);
|
||||
assert.equal(invalid.body.error.code, "invalid_params");
|
||||
|
||||
const before = await getJson(port, "/v1/setup/status");
|
||||
assert.equal(before.status, 200);
|
||||
assert.equal(before.body.setupRequired, true);
|
||||
|
||||
const ambiguous = await postJson(port, "/v1/setup/first-admin", {
|
||||
username: "admin",
|
||||
password: "admin-pass",
|
||||
devicePod: { devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1 } },
|
||||
devicePods: [{ devicePodId: "device-pod-alt", profile: { schemaVersion: 1 } }]
|
||||
});
|
||||
assert.equal(ambiguous.status, 400);
|
||||
assert.equal(ambiguous.body.error.code, "invalid_params");
|
||||
|
||||
const setup = await postJson(port, "/v1/setup/first-admin", {
|
||||
username: "admin",
|
||||
password: "admin-pass"
|
||||
});
|
||||
assert.equal(setup.status, 201);
|
||||
assert.equal(setup.body.actor.role, "admin");
|
||||
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 0, initialized: 0 });
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
async function postJson(port, path, body, cookie = null, extraHeaders = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -219,6 +219,7 @@ class AccessController {
|
||||
|
||||
const body = await jsonBody(request);
|
||||
const password = requiredText(body.password, "password");
|
||||
const devicePodSeeds = firstAdminDevicePodSeeds(body);
|
||||
const now = this.now();
|
||||
const user = await this.store.createFirstAdmin?.({
|
||||
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
|
||||
@@ -230,6 +231,20 @@ class AccessController {
|
||||
if (!user) {
|
||||
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
|
||||
}
|
||||
const devicePodsInitialized = [];
|
||||
for (const seed of devicePodSeeds) {
|
||||
const pod = await this.store.upsertDevicePod({ ...seed, now });
|
||||
const grant = await this.store.grantDevicePod({
|
||||
devicePodId: pod.id,
|
||||
userId: user.id,
|
||||
createdByAdminId: user.id,
|
||||
now
|
||||
});
|
||||
devicePodsInitialized.push({
|
||||
devicePod: publicDevicePod(pod, { includeAdmin: true }),
|
||||
grant
|
||||
});
|
||||
}
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const session = await this.store.createSession({
|
||||
userId: user.id,
|
||||
@@ -244,6 +259,11 @@ class AccessController {
|
||||
actor: publicActor(user),
|
||||
session: publicSession({ ...session, user }),
|
||||
setupRequired: false,
|
||||
devicePodBootstrap: {
|
||||
requested: devicePodSeeds.length,
|
||||
initialized: devicePodsInitialized.length
|
||||
},
|
||||
devicePodsInitialized,
|
||||
contractVersion: "user-access-v1"
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -1132,6 +1152,27 @@ async function jsonBody(request) {
|
||||
function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; }
|
||||
function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; }
|
||||
function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; }
|
||||
function firstAdminDevicePodSeeds(body = {}) {
|
||||
const hasSingle = Object.hasOwn(body, "devicePod");
|
||||
const hasMany = Object.hasOwn(body, "devicePods");
|
||||
if (hasSingle && hasMany) throw Object.assign(new Error("Use devicePod or devicePods, not both"), { statusCode: 400, code: "invalid_params" });
|
||||
if (!hasSingle && !hasMany) return [];
|
||||
const values = hasMany ? body.devicePods : [body.devicePod];
|
||||
if (!Array.isArray(values)) throw Object.assign(new Error("devicePods must be an array"), { statusCode: 400, code: "invalid_params" });
|
||||
return values.map((item, index) => firstAdminDevicePodSeed(item, hasSingle ? "devicePod" : `devicePods[${index}]`));
|
||||
}
|
||||
function firstAdminDevicePodSeed(value, field) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error(`${field} must be an object`), { statusCode: 400, code: "invalid_params" });
|
||||
const id = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`);
|
||||
const profile = normalizeObject(value.profile ?? value.profileJson);
|
||||
if (Object.keys(profile).length === 0) throw Object.assign(new Error(`${field}.profile is required`), { statusCode: 400, code: "invalid_params" });
|
||||
return {
|
||||
id,
|
||||
name: textOr(value.name, id),
|
||||
status: value.status === "disabled" ? "disabled" : "active",
|
||||
profile
|
||||
};
|
||||
}
|
||||
function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; }
|
||||
function boundedLeaseTtlSeconds(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DEVICE_LEASE_TTL_SECONDS, 1), MAX_DEVICE_LEASE_TTL_SECONDS); }
|
||||
function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); }
|
||||
|
||||
@@ -6,6 +6,69 @@ import { test } from "bun:test";
|
||||
|
||||
import { runDevicePodCli } from "./src/device-pod-cli-lib.ts";
|
||||
|
||||
test("device-pod-cli first-admin setup can seed a cloud-api device pod without local profile authority", async () => {
|
||||
const seen: any[] = [];
|
||||
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
|
||||
const result = await runDevicePodCli([
|
||||
"--api-base-url", "http://cloud.test",
|
||||
"--username", "admin",
|
||||
"--password", "admin-pass",
|
||||
"--pod-id", "device-pod-71-freq",
|
||||
"--name", "71-FREQ",
|
||||
"--profile-json", JSON.stringify(profile),
|
||||
"setup",
|
||||
"first-admin"
|
||||
], {
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return new Response(JSON.stringify({ created: true, actor: { id: "usr_admin", role: "admin" }, devicePodBootstrap: { requested: 1, initialized: 1 } }), { status: 201, headers: { "content-type": "application/json", "set-cookie": "hwlab_session=session-a" } });
|
||||
},
|
||||
now: () => "2026-05-29T00:00:00.000Z"
|
||||
});
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(result.payload.action, "setup.first-admin");
|
||||
assert.equal(result.payload.devicePodSeedProvided, true);
|
||||
assert.equal(result.payload.localProfileAuthority, false);
|
||||
assert.equal(result.payload.setCookie, "hwlab_session=session-a");
|
||||
assert.equal(seen[0].url, "http://cloud.test/v1/setup/first-admin");
|
||||
assert.equal(seen[0].init.headers["x-hwlab-session-token"], undefined);
|
||||
assert.deepEqual(seen[0].body.devicePod, { devicePodId: "device-pod-71-freq", name: "71-FREQ", profile });
|
||||
});
|
||||
|
||||
test("device-pod-cli admin wrappers call cloud-api profile and grant routes", async () => {
|
||||
const seen: any[] = [];
|
||||
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
|
||||
const fetchImpl = async (url, init) => {
|
||||
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return jsonResponse(201, { ok: true });
|
||||
};
|
||||
const upsert = await runDevicePodCli([
|
||||
"--api-base-url", "http://cloud.test",
|
||||
"--session-token", "session-a",
|
||||
"--pod-id", "device-pod-71-freq",
|
||||
"--profile-json", JSON.stringify(profile),
|
||||
"admin",
|
||||
"device-pod",
|
||||
"upsert"
|
||||
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
||||
assert.equal(upsert.exitCode, 0);
|
||||
assert.equal(seen[0].url, "http://cloud.test/v1/admin/device-pods");
|
||||
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
|
||||
assert.deepEqual(seen[0].body, { devicePodId: "device-pod-71-freq", profile });
|
||||
|
||||
const grant = await runDevicePodCli([
|
||||
"--api-base-url", "http://cloud.test",
|
||||
"--session-token", "session-a",
|
||||
"--pod-id", "device-pod-71-freq",
|
||||
"--user-id", "usr_alice",
|
||||
"admin",
|
||||
"grant"
|
||||
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
|
||||
assert.equal(grant.exitCode, 0);
|
||||
assert.equal(seen[1].url, "http://cloud.test/v1/admin/device-pod-grants");
|
||||
assert.deepEqual(seen[1].body, { devicePodId: "device-pod-71-freq", userId: "usr_alice" });
|
||||
});
|
||||
|
||||
test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-"));
|
||||
const profileDir = path.join(root, ".device-pod");
|
||||
|
||||
@@ -30,6 +30,8 @@ export async function runDevicePodCli(argv: string[], options: { env?: EnvLike;
|
||||
|
||||
async function dispatch(target: string, context: any) {
|
||||
if (["help", "--help", "-h"].includes(target)) return help();
|
||||
if (target === "setup") return setupCommand(context);
|
||||
if (target === "admin") return adminCommand(context);
|
||||
if (target === "login") return login(context);
|
||||
if (target === "doctor") return doctor(context);
|
||||
if (target === "health") return health(context);
|
||||
@@ -53,6 +55,9 @@ function help() {
|
||||
},
|
||||
usage: [
|
||||
"device-pod-cli login --api-base-url URL --username USER --password PASS",
|
||||
"device-pod-cli setup first-admin --api-base-url URL --username USER --password PASS --pod-id device-pod-71-freq --profile-json JSON",
|
||||
"device-pod-cli admin device-pod upsert --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --profile-json JSON",
|
||||
"device-pod-cli admin grant --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --user-id USER_ID",
|
||||
"device-pod-cli profile list --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN",
|
||||
"device-pod-cli device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN",
|
||||
@@ -63,6 +68,45 @@ function help() {
|
||||
});
|
||||
}
|
||||
|
||||
async function setupCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const subcommand = rest[0] || "first-admin";
|
||||
if (subcommand !== "first-admin") throw cliError("unsupported_setup_command", `unsupported setup command: ${subcommand}`);
|
||||
const devicePod = devicePodSeedFromOptions(parsed, { required: false });
|
||||
const body = clean({
|
||||
username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"),
|
||||
password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD"),
|
||||
displayName: text(parsed.displayName),
|
||||
...(devicePod ? { devicePod } : {})
|
||||
});
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/setup/first-admin", body, auth: false });
|
||||
return responsePayload("setup.first-admin", response, { setCookie: response.headers.get("set-cookie") ?? null, devicePodSeedProvided: Boolean(devicePod), localProfileAuthority: false });
|
||||
}
|
||||
|
||||
async function adminCommand(context: any) {
|
||||
const subcommand = context.rest[0] || "help";
|
||||
if (subcommand === "device-pod" || subcommand === "profile") return adminDevicePodCommand({ ...context, rest: context.rest.slice(1) });
|
||||
if (subcommand === "grant" || subcommand === "device-pod-grant") return adminGrantCommand({ ...context, rest: context.rest.slice(1) });
|
||||
throw cliError("unsupported_admin_command", `unsupported admin command: ${subcommand}`);
|
||||
}
|
||||
|
||||
async function adminDevicePodCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const subcommand = rest[0] || "upsert";
|
||||
if (!["create", "upsert", "update", "put"].includes(subcommand)) throw cliError("unsupported_admin_device_pod_command", `unsupported admin device-pod command: ${subcommand}`);
|
||||
const devicePod = devicePodSeedFromOptions(parsed, { required: true });
|
||||
const method = subcommand === "update" || subcommand === "put" ? "PUT" : "POST";
|
||||
const path = method === "PUT" ? `/v1/admin/device-pods/${encodeURIComponent(devicePod.devicePodId)}` : "/v1/admin/device-pods";
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method, path, body: devicePod });
|
||||
return responsePayload(`admin.device-pod.${subcommand}`, response, { route: { method, path }, devicePodId: devicePod.devicePodId, localProfileAuthority: false });
|
||||
}
|
||||
|
||||
async function adminGrantCommand({ parsed, rest, env, fetchImpl }: any) {
|
||||
const podId = requiredText(parsed.podId ?? parsed.devicePodId ?? rest[0], "podId");
|
||||
const userId = requiredText(parsed.userId ?? rest[1], "userId");
|
||||
const body = { devicePodId: podId, userId };
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/admin/device-pod-grants", body });
|
||||
return responsePayload("admin.device-pod-grant", response, { devicePodId: podId, userId });
|
||||
}
|
||||
|
||||
async function login({ parsed, env, fetchImpl }: any) {
|
||||
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/auth/login", body: { username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"), password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD") }, auth: false });
|
||||
return responsePayload("login", response, { setCookie: response.headers.get("set-cookie") ?? null });
|
||||
@@ -257,6 +301,37 @@ function requiredText(value: unknown, field: string) { const valueText = text(va
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
|
||||
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; }
|
||||
function devicePodSeedFromOptions(parsed: ParsedArgs, { required = false } = {}) {
|
||||
const devicePodText = text(parsed.devicePodJson ?? parsed.devicePod);
|
||||
if (devicePodText) return normalizeDevicePodSeed(parseJsonObjectOption(devicePodText, "devicePodJson"), "devicePodJson");
|
||||
const profileText = text(parsed.profileJson ?? parsed.profile);
|
||||
if (!profileText) {
|
||||
if (required) throw cliError("device_pod_profile_required", "--profile-json or --device-pod-json is required", { field: "profileJson" });
|
||||
return null;
|
||||
}
|
||||
return normalizeDevicePodSeed({
|
||||
devicePodId: parsed.podId ?? parsed.devicePodId,
|
||||
name: parsed.name,
|
||||
status: parsed.status,
|
||||
profile: parseJsonObjectOption(profileText, "profileJson")
|
||||
}, "devicePod");
|
||||
}
|
||||
function normalizeDevicePodSeed(value: any, field: string) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_device_pod_seed", `${field} must be a JSON object`, { field });
|
||||
const devicePodId = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`);
|
||||
const profile = value.profile ?? value.profileJson;
|
||||
if (!profile || typeof profile !== "object" || Array.isArray(profile)) throw cliError("invalid_device_pod_profile", `${field}.profile must be a JSON object`, { field: `${field}.profile` });
|
||||
return clean({ devicePodId, name: text(value.name), status: text(value.status), profile });
|
||||
}
|
||||
function parseJsonObjectOption(value: string, field: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
||||
return parsed;
|
||||
} catch (error: any) {
|
||||
throw cliError("invalid_json_option", `${field} must be a JSON object`, { field, reason: error?.message ?? String(error) });
|
||||
}
|
||||
}
|
||||
function normalizePath(value: string) { return String(value || ".").trim().replace(/\\/gu, "/").replace(/\s*\/\s*/gu, "/").replace(/\/+/gu, "/").replace(/^\/+|\/+$/gu, "") || "."; }
|
||||
function normalizeProbePath(value: string) { const normalized = normalizePath(value); if (normalized === ".") return "uart/1"; if (normalized === "uart") throw cliError("invalid_io_probe_path", "io-probe path must be concrete, for example /uart/1"); return normalized; }
|
||||
function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; return normalizePath(`${left}/${right}`); }
|
||||
|
||||
Reference in New Issue
Block a user