@serviceme/devtools-core 0.1.8 → 0.2.0

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.
Files changed (47) hide show
  1. package/dist/auth.d.mts +590 -0
  2. package/dist/auth.d.ts +590 -0
  3. package/dist/auth.js +842 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/auth.mjs +804 -0
  6. package/dist/auth.mjs.map +1 -0
  7. package/dist/device.d.mts +456 -0
  8. package/dist/device.d.ts +456 -0
  9. package/dist/device.js +696 -0
  10. package/dist/device.js.map +1 -0
  11. package/dist/device.mjs +647 -0
  12. package/dist/device.mjs.map +1 -0
  13. package/dist/index-CrNC-aao.d.ts +277 -0
  14. package/dist/index-Dmyy4urr.d.mts +277 -0
  15. package/dist/index.d.mts +1372 -27
  16. package/dist/index.d.ts +1372 -27
  17. package/dist/index.js +5125 -888
  18. package/dist/index.js.map +1 -0
  19. package/dist/index.mjs +5022 -906
  20. package/dist/index.mjs.map +1 -0
  21. package/dist/skill-linker.d.mts +188 -0
  22. package/dist/skill-linker.d.ts +188 -0
  23. package/dist/skill-linker.js +374 -0
  24. package/dist/skill-linker.js.map +1 -0
  25. package/dist/skill-linker.mjs +330 -0
  26. package/dist/skill-linker.mjs.map +1 -0
  27. package/dist/skill-store.d.mts +35 -0
  28. package/dist/skill-store.d.ts +35 -0
  29. package/dist/skill-store.js +291 -0
  30. package/dist/skill-store.js.map +1 -0
  31. package/dist/skill-store.mjs +254 -0
  32. package/dist/skill-store.mjs.map +1 -0
  33. package/dist/submit.d.mts +3 -0
  34. package/dist/submit.d.ts +3 -0
  35. package/dist/submit.js +166 -0
  36. package/dist/submit.js.map +1 -0
  37. package/dist/submit.mjs +130 -0
  38. package/dist/submit.mjs.map +1 -0
  39. package/dist/toolbox.d.mts +240 -0
  40. package/dist/toolbox.d.ts +240 -0
  41. package/dist/toolbox.js +530 -0
  42. package/dist/toolbox.js.map +1 -0
  43. package/dist/toolbox.mjs +482 -0
  44. package/dist/toolbox.mjs.map +1 -0
  45. package/dist/types-B9gk3dXH.d.mts +62 -0
  46. package/dist/types-B9gk3dXH.d.ts +62 -0
  47. package/package.json +50 -5
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/device/deviceAuth.ts","../src/device/Enroller.ts","../src/device/InstallationId.ts","../src/device/IdentityStore.ts","../src/paths/userHome.ts","../src/device/types.ts","../src/device/DeviceCore.ts"],"sourcesContent":["/**\n * deviceAuth — Pure HMAC-SHA-256 signing helpers for device auth headers.\n *\n * Ported byte-for-byte from\n * `apps/extension/src/services/device/deviceAuth.ts:11-26` (the wire\n * algorithm is locked by the server-side `device-signature-guard.ts`\n * verifier, see `docs/architecture/phase-5-device-header-spec.md` §5).\n *\n * The basis is `METHOD\\nPATH\\nTIMESTAMP\\nBODY\\nSECRET` (LF-joined,\n * NOT JSON). Output is lowercase hex SHA-256.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `deviceAuth.ts HMAC 签名(纯 node:crypto,直接搬)`\n * - `docs/architecture/phase-5-device-header-spec.md` §5 (signature format)\n */\n\nimport { createHash } from \"node:crypto\";\n\n/** Canonical header names — MUST match server's `device-signature-guard.ts:9-15`. */\nexport const DeviceAuthHeaders = {\n\tdeviceId: \"x-ms-device-id\",\n\tdeviceSecret: \"x-ms-device-secret\",\n\tsignature: \"x-ms-device-signature\",\n\ttimestamp: \"x-ms-device-timestamp\",\n\tsecretVersion: \"x-ms-device-secret-version\",\n} as const;\n\nexport interface DeviceRequestSignatureParams {\n\tmethod: string;\n\tpath: string;\n\ttimestamp: number;\n\tbody: string;\n\tsecret: string;\n}\n\n/**\n * Compute the HMAC-SHA-256 hex digest of the canonical basis.\n *\n * Server contract (`apps/server/src/lib/auth/device-signature-guard.ts:46-62`)\n * is line-for-line identical: same LF-joined basis, same lowercase\n * hex output. Any divergence breaks `device-signature-guard.test.ts`.\n */\nexport function createDeviceRequestSignature(params: DeviceRequestSignatureParams): string {\n\tconst basis = [\n\t\tparams.method.toUpperCase(),\n\t\tparams.path,\n\t\tString(params.timestamp),\n\t\tparams.body,\n\t\tparams.secret,\n\t].join(\"\\n\");\n\treturn createHash(\"sha256\").update(basis).digest(\"hex\");\n}\n\n/** 5-header map consumed by `fetch()` callers (CLI bridge, Extension). */\nexport interface DeviceSignedHeaders {\n\t[DeviceAuthHeaders.deviceId]: string;\n\t[DeviceAuthHeaders.deviceSecret]: string;\n\t[DeviceAuthHeaders.signature]: string;\n\t[DeviceAuthHeaders.timestamp]: string;\n\t[DeviceAuthHeaders.secretVersion]: string;\n}\n\nexport interface BuildSignedHeadersParams {\n\tmethod: string;\n\tpath: string;\n\tbody: string;\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\t/** Override for deterministic tests. */\n\ttimestamp?: number;\n}\n\n/**\n * Build the canonical 5-header map. The `body` parameter MUST be the\n * exact byte sequence sent on the wire (no whitespace re-canonicalization\n * between client serialization and signature basis construction).\n */\nexport function buildSignedHeaders(params: BuildSignedHeadersParams): DeviceSignedHeaders {\n\tconst timestamp = params.timestamp ?? Date.now();\n\tconst signature = createDeviceRequestSignature({\n\t\tmethod: params.method,\n\t\tpath: params.path,\n\t\ttimestamp,\n\t\tbody: params.body,\n\t\tsecret: params.deviceSecret,\n\t});\n\treturn {\n\t\t[DeviceAuthHeaders.deviceId]: params.publicId,\n\t\t[DeviceAuthHeaders.deviceSecret]: params.deviceSecret,\n\t\t[DeviceAuthHeaders.signature]: signature,\n\t\t[DeviceAuthHeaders.timestamp]: String(timestamp),\n\t\t[DeviceAuthHeaders.secretVersion]: String(params.secretVersion),\n\t};\n}\n","/**\n * Enroller — State machine for `device.enroll` and `device.rotate-secret`.\n *\n * States per `2.需求澄清.md` §1.2:\n * anonymous → pending → claimed → expired\n *\n * - `anonymous` (initial): no device has ever enrolled. Server returns\n * a fresh `publicId` + secret.\n * - `pending`: enrollment HTTP call has been issued but the server\n * hasn't confirmed yet. In-flight state — never persisted.\n * - `claimed`: user has linked this device to their account (via\n * `/api/v1/devices/claim`). Sticky binding locks future re-enrolls\n * to the same `userId` (server-side matrix).\n * - `expired`: server returned a device-expiry error. Forces a fresh\n * enroll on next call.\n *\n * `--force` semantics: any non-anonymous state can be force-reset to\n * `anonymous` by wiping the local identity file. The next enroll will\n * be treated as a brand-new install by the server (no sticky binding).\n *\n * The Enroller is the **state machine**; the actual HTTP I/O is the\n * caller's responsibility (the `DeviceSyncClient` in Phase 5.4 wires\n * the server). This split keeps the Enroller unit-testable without\n * a live server.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `Enroller.ts anonymous → pending → claimed → expired`\n * - `2.需求澄清.md` §1.2 — binding-state machine\n */\n\nimport { randomBytes } from \"node:crypto\";\n\nimport type { DeviceBindingState, DeviceEnrollResult } from \"@serviceme/devtools-protocol\";\nimport type { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\n/** 32 bytes of HMAC secret material — matches the server's `device-registration.ts:73-80` generator. */\nconst SECRET_BYTES = 32;\n/** Server returns `publicId` as 32-char hex (16 bytes). Match the wire length. */\nconst PUBLIC_ID_BYTES = 16;\n\ntype RandomBytesFn = (size: number) => Buffer;\n\nconst defaultRandomBytes: RandomBytesFn = (size) => {\n\treturn randomBytes(size);\n};\n\nexport interface EnrollerOptions {\n\tidentityStore: IdentityStore;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\t/** Override the random source (tests). */\n\trandomBytes?: (size: number) => Buffer;\n\t/** Caller-supplied enroll HTTP function. Phase 5.4 wires the real one. */\n\tenrollRequest?: EnrollRequestFn;\n}\n\nexport type EnrollRequestFn = (input: {\n\tinstallationId: string;\n\tmachineId: string;\n\tplatform: string;\n\texisting: PersistedDeviceIdentity | null;\n\tforce: boolean;\n\trequireAuth: boolean;\n}) => Promise<EnrollResponse>;\n\nexport interface EnrollResponse {\n\tpublicId: string;\n\tdeviceSecret: string;\n\tsecretVersion: number;\n\tbindingState: DeviceBindingState;\n\texpiresAt?: string;\n}\n\n/** Sentinel error — re-enroll on a claimed device without auth. */\nexport class DeviceReenrollRequiresAuthError extends Error {\n\tconstructor(\n\t\tmessage = \"Re-enroll on a claimed device requires current device credentials or the bound user\"\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceReenrollRequiresAuthError\";\n\t}\n}\n\n/** Sentinel error — server returned a 410 / version-mismatch after rotation. */\nexport class DeviceSecretVersionMismatchError extends Error {\n\tconstructor(message = \"Device secret version mismatch — server has rotated past the local copy\") {\n\t\tsuper(message);\n\t\tthis.name = \"DeviceSecretVersionMismatchError\";\n\t}\n}\n\nexport class Enroller {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly now: () => Date;\n\tprivate readonly random: (size: number) => Buffer;\n\tprivate readonly enrollRequest?: EnrollRequestFn;\n\tprivate inflight: Promise<DeviceEnrollResult> | null = null;\n\n\tconstructor(opts: EnrollerOptions) {\n\t\tthis.identity = opts.identityStore;\n\t\tthis.now = opts.now ?? (() => new Date());\n\t\tthis.random = opts.randomBytes ?? defaultRandomBytes;\n\t\tthis.enrollRequest = opts.enrollRequest;\n\t}\n\n\t/**\n\t * Read the current binding state without touching the disk.\n\t * Returns `anonymous` when no identity is stored.\n\t */\n\tasync currentState(): Promise<DeviceBindingState> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored?.bindingState ?? \"anonymous\";\n\t}\n\n\t/**\n\t * Drive the enrollment flow.\n\t *\n\t * @param force when true, drop the local identity and start fresh\n\t * (server treats this as a brand-new install).\n\t * @param requireAuth when true, refuse to silently re-enroll an\n\t * existing claimed device — throw\n\t * `DeviceReenrollRequiresAuthError` instead.\n\t */\n\t/**\n\t * Resolve when any in-flight enrollment completes. Returns immediately\n\t * when no enrollment is in progress. Allows callers (e.g. the extension's\n\t * `buildDeviceAuthHeaders`) to wait for a concurrent `syncDeviceInfo()`\n\t * enrollment before attempting to read the identity from the store.\n\t */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tif (this.inflight) {\n\t\t\tawait this.inflight;\n\t\t}\n\t}\n\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\t// Concurrency guard — multiple in-flight calls share the same promise.\n\t\tif (this.inflight) {\n\t\t\treturn this.inflight;\n\t\t}\n\t\tconst promise = this.runEnroll(opts);\n\t\tthis.inflight = promise;\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tif (this.inflight === promise) this.inflight = null;\n\t\t}\n\t}\n\n\t/** Test seam — surface the underlying identity store. */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers (e.g. the extension's `buildDeviceAuthHeaders`) to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.inflight !== null;\n\t}\n\n\tprivate async runEnroll(opts: {\n\t\tforce?: boolean;\n\t\trequireAuth?: boolean;\n\t}): Promise<DeviceEnrollResult> {\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tconst existing = opts.force ? null : current;\n\n\t\t\tif (!opts.force && current) {\n\t\t\t\tif (current.bindingState === \"expired\") {\n\t\t\t\t\t// Expired identities are forced to re-enroll as if they were new.\n\t\t\t\t} else if (\n\t\t\t\t\topts.requireAuth &&\n\t\t\t\t\t(current.bindingState === \"claimed\" || current.bindingState === \"pending\")\n\t\t\t\t) {\n\t\t\t\t\t// Caller asserted the device must be claimed, but local state\n\t\t\t\t\t// shows it's still in flight. This is a CLI-only guard — the\n\t\t\t\t\t// server is the final arbiter.\n\t\t\t\t\tthrow new DeviceReenrollRequiresAuthError();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst installationId = current?.installationId ?? deriveInstallationId();\n\t\t\tconst machineId = current?.machineId ?? \"unknown\";\n\t\t\tconst platform = current?.platform ?? \"unknown\";\n\n\t\t\tlet response: EnrollResponse;\n\t\t\tif (this.enrollRequest) {\n\t\t\t\tresponse = await this.enrollRequest({\n\t\t\t\t\tinstallationId,\n\t\t\t\t\tmachineId,\n\t\t\t\t\tplatform,\n\t\t\t\t\texisting,\n\t\t\t\t\tforce: Boolean(opts.force),\n\t\t\t\t\trequireAuth: Boolean(opts.requireAuth),\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// Test path / no live HTTP — synthesize a fresh identity. This\n\t\t\t\t// branch is what unit tests exercise; production wires\n\t\t\t\t// `enrollRequest` in Phase 5.4.\n\t\t\t\tresponse = synthesizeEnrollResponse(this.random, existing);\n\t\t\t}\n\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\tversion: current?.version ?? 1,\n\t\t\t\tinstallationId,\n\t\t\t\tmachineId,\n\t\t\t\tplatform,\n\t\t\t\thostname: current?.hostname,\n\t\t\t\tpublicId: response.publicId,\n\t\t\t\tsecretVersion: response.secretVersion,\n\t\t\t\tbindingState: response.bindingState,\n\t\t\t\tdeviceSecret: response.deviceSecret,\n\t\t\t\tpreviousDeviceSecret: existing?.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt:\n\t\t\t\t\topts.force || response.secretVersion === (existing?.secretVersion ?? 0) + 1\n\t\t\t\t\t\t? undefined\n\t\t\t\t\t\t: existing?.previousSecretExpiresAt,\n\t\t\t\tlastEnrollAt: this.now().toISOString(),\n\t\t\t\tlastSyncAt: existing?.lastSyncAt,\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tbindingState: written.bindingState,\n\t\t\texpiresAt: deriveExpiresAt(written, this.now),\n\t\t};\n\t}\n\n\t/**\n\t * Rotate the HMAC secret. Keeps the previous secret for the grace\n\t * window (default 7 days per `2.需求澄清.md` §1.2) — the\n\t * `previousSecretExpiresAt` is stamped on the persisted identity.\n\t */\n\tasync rotateSecret(\n\t\topts: { gracePeriodDays?: number } = {}\n\t): Promise<{ publicId: string; secretVersion: number; gracePeriodDays: number }> {\n\t\tconst gracePeriodDays = opts.gracePeriodDays ?? 7;\n\t\tconst now = this.now();\n\t\tconst newSecret = this.random(SECRET_BYTES).toString(\"hex\");\n\n\t\tconst { written } = await this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot rotate-secret without a prior enrollment\");\n\t\t\t}\n\t\t\tconst graceExpiresAt = new Date(now.getTime() + gracePeriodDays * 24 * 60 * 60 * 1000);\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tdeviceSecret: newSecret,\n\t\t\t\tpreviousDeviceSecret: current.deviceSecret,\n\t\t\t\tpreviousSecretExpiresAt: graceExpiresAt.toISOString(),\n\t\t\t\tsecretVersion: current.secretVersion + 1,\n\t\t\t\tlastEnrollAt: now.toISOString(),\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\n\t\treturn {\n\t\t\tpublicId: written.publicId,\n\t\t\tsecretVersion: written.secretVersion,\n\t\t\tgracePeriodDays,\n\t\t};\n\t}\n\n\t/**\n\t * Mark the device as `expired`. Used when the server returns a\n\t * device-expiry response; the next `enroll()` call forces a fresh\n\t * round-trip.\n\t */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\t// Nothing to expire.\n\t\t\t\treturn { next: current ?? (await emptyIdentity(this.random)), result: undefined };\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = { ...current, bindingState: \"expired\" };\n\t\t\treturn { next };\n\t\t});\n\t}\n\n\t/**\n\t * Mark the device as `claimed`. Called by the bridge after a\n\t * successful `device.claim` server response.\n\t */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.identity.mutate(async (current) => {\n\t\t\tif (!current) {\n\t\t\t\tthrow new Error(\"Cannot mark-claimed without a prior enrollment\");\n\t\t\t}\n\t\t\tconst next: PersistedDeviceIdentity = {\n\t\t\t\t...current,\n\t\t\t\tbindingState: \"claimed\",\n\t\t\t\tlastSyncAt: this.now().toISOString(),\n\t\t\t\tlastSyncError: undefined,\n\t\t\t};\n\t\t\treturn { next };\n\t\t});\n\t}\n}\n\nfunction deriveExpiresAt(_identity: PersistedDeviceIdentity, _now: () => Date): string | undefined {\n\t// No explicit expiry on the server today (per phase-5-device-header-spec.md\n\t// §9 #1: 7-day grace is rotation-only). The shape is here for forward\n\t// compatibility — when the server adds a per-device expiry, this\n\t// pulls the value from the response without changing the call site.\n\treturn undefined;\n}\n\nfunction synthesizeEnrollResponse(\n\trandom: RandomBytesFn,\n\texisting: PersistedDeviceIdentity | null\n): EnrollResponse {\n\tconst publicId = existing?.publicId ?? random(PUBLIC_ID_BYTES).toString(\"hex\");\n\tconst secretVersion = (existing?.secretVersion ?? 0) + 1;\n\treturn {\n\t\tpublicId,\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tsecretVersion,\n\t\tbindingState: existing?.bindingState === \"claimed\" ? \"claimed\" : \"anonymous\",\n\t};\n}\n\nasync function emptyIdentity(random: RandomBytesFn): Promise<PersistedDeviceIdentity> {\n\treturn {\n\t\tversion: 1,\n\t\tinstallationId: deriveInstallationId(),\n\t\tmachineId: \"unknown\",\n\t\tplatform: \"unknown\",\n\t\tpublicId: random(PUBLIC_ID_BYTES).toString(\"hex\"),\n\t\tsecretVersion: 1,\n\t\tbindingState: \"anonymous\",\n\t\tdeviceSecret: random(SECRET_BYTES).toString(\"hex\"),\n\t\tlastEnrollAt: new Date().toISOString(),\n\t};\n}\n","/**\n * InstallationId — Derive a stable per-machine identifier from\n * `os.hostname()` + `os.userInfo()`.\n *\n * Per `docs/requirements/phase-5-auth-device-toolbox/3.功能拆分.md` B2,\n * `installationId` MUST survive Extension re-installs but vary across\n * machines. We compute a UUID v5-style hash over hostname + username +\n * platform so the result is:\n * - deterministic (same machine → same id)\n * - collision-resistant (SHA-256, 128-bit truncated)\n * - browser-safe (no PII survives — username never enters output)\n *\n * Note: this intentionally differs from `vscode.env.machineId`, which\n * is per-Extension-install and uses a different algorithm. The two\n * coexist: `installationId` is what gets sent to the server, while\n * `machineId` (raw `os.hostname()`) is for diagnostics.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `InstallationId.ts os.hostname() + os.userInfo() 哈希生成`\n * - 3.功能拆分.md B2 — installationId semantics\n */\n\nimport { createHash, randomUUID } from \"node:crypto\";\nimport * as os from \"node:os\";\n\n/** Hex-encoded SHA-256 input. Format: `<hostname>|<username>|<platform>|<nodeVersion>`. */\nfunction fingerprintMaterial(): string {\n\t// `os.userInfo()` is undefined-ish on Windows in some sandboxes; fall\n\t// back to a process env user, then to a constant placeholder. We never\n\t// emit the raw username to the caller — it only enters the hash.\n\tlet username = \"unknown\";\n\ttry {\n\t\tusername = os.userInfo().username;\n\t} catch {\n\t\tusername = process.env.USER ?? process.env.USERNAME ?? \"unknown\";\n\t}\n\treturn [\n\t\tos.hostname(),\n\t\tusername,\n\t\tos.platform(),\n\t\tos.arch(),\n\t\tprocess.versions.node ?? \"unknown\",\n\t].join(\"|\");\n}\n\n/**\n * Returns a deterministic installation id for the current machine.\n * Use this when you need an id that survives Extension reinstalls\n * but stays stable across restarts on the same machine.\n */\nexport function deriveInstallationId(): string {\n\tconst material = fingerprintMaterial();\n\tconst digest = createHash(\"sha256\").update(material).digest(\"hex\");\n\t// Take the first 32 hex chars (128 bits) and reformat as UUID v4-shape\n\t// so the output looks like a UUID to downstream consumers while\n\t// remaining a pure SHA-256 truncation.\n\treturn formatAsV4(digest.slice(0, 32));\n}\n\n/**\n * Returns a random installation id (UUID v4). Use this for fresh\n * installs when no fingerprint input is available (e.g. containerized\n * CI runners where `os.hostname()` is meaningless).\n */\nexport function randomInstallationId(): string {\n\treturn randomUUID();\n}\n\n/** SHA-256 fingerprint material exposed for tests + diagnostics. */\nexport function fingerprintSource(): string {\n\treturn fingerprintMaterial();\n}\n\nfunction formatAsV4(hex32: string): string {\n\t// Stamp version 4 + variant bits per RFC 4122 §4.4. The bits are\n\t// cosmetic — the underlying entropy is still SHA-256.\n\tconst chars = hex32.split(\"\");\n\t// Version nibble (position 12 in canonical UUID, index 13 of the 32-char string).\n\tconst versionIdx = 12;\n\tconst variantIdx = 16;\n\tconst versionChar = (parseInt(chars[versionIdx] ?? \"8\", 16) & 0x0) | 0x4;\n\tchars[versionIdx] = versionChar.toString(16);\n\t// Variant nibble: 10xx → first hex char of the 17th position.\n\tconst variantChar = (parseInt(chars[variantIdx] ?? \"8\", 16) & 0x3) | 0x8;\n\tchars[variantIdx] = variantChar.toString(16);\n\tconst formatted = chars.join(\"\");\n\treturn `${formatted.slice(0, 8)}-${formatted.slice(8, 12)}-${formatted.slice(12, 16)}-${formatted.slice(16, 20)}-${formatted.slice(20, 32)}`;\n}\n","/**\n * IdentityStore — Atomic JSON persistence for the device identity file.\n *\n * Stores the `PersistedDeviceIdentity` (incl. the HMAC secret cleartext)\n * at `~/.serviceme/device.json` (per `phase-5-device-header-spec.md`\n * §3.1). Writes are atomic via `write-tmp + fsync + rename`, matching\n * the `SkillStore` / `ToolboxStore` precedent. Concurrent writes are\n * serialized with a mkdir-based file lock (POSIX-atomic) — proper\n * cross-process locking is deferred to Phase 6+ per the open spec.\n *\n * The file mode is `0600` (owner read/write only) so the cleartext\n * secret stays safe at rest. On Windows the mode hint is a no-op\n * (Windows uses ACLs) but `writeFile` still succeeds.\n *\n * Migration — IdentityStore auto-detects a v0-shape (pre-Phase-5.2)\n * file written by the Extension's old `globalState` blob:\n * { version: 1, claimed: false, publicKeyFingerprint: null }\n * In that case the file is migrated forward to the v1 schema on the\n * next write (the data fields are empty and a fresh enroll is required).\n * The full Extension `globalState` → JSON migration happens in the\n * Phase 5.5 adapter (`apps/extension/.../DeviceService.ts`) since the\n * adapter holds the live `globalState` access.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `IdentityStore.ts 持久化到 ~/.config/serviceme/device.json, 原子写`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1, §2.5\n */\n\nimport * as fsp from \"node:fs/promises\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { setTimeout as delay } from \"node:timers/promises\";\n\nimport { getDeviceJsonPath, getServicemeHome } from \"../paths/userHome\";\n\nimport {\n\ttype AtomicWriteResult,\n\tDEVICE_JSON_SCHEMA_VERSION,\n\ttype IdentityStoreHooks,\n\ttype PersistedDeviceIdentity,\n} from \"./types\";\n\nconst FILE_MODE = 0o600;\nconst LOCK_DIR_MODE = 0o700;\nconst DEFAULT_LOCK_TIMEOUT_MS = 5000;\nconst DEFAULT_LOCK_RETRY_MS = 25;\nconst TMP_SUFFIX = \".tmp\";\n\n/**\n * Minimal interface for reading + writing the persisted identity file.\n * Default impl uses `getDeviceJsonPath()` (which honors `SERVICEME_HOME`),\n * but tests can substitute a custom path for isolation.\n */\nexport interface IdentityFileBackend {\n\tread(filePath: string): Promise<PersistedDeviceIdentity | null>;\n\twrite(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult>;\n\texists(filePath: string): Promise<boolean>;\n\tdelete(filePath: string): Promise<void>;\n\tlistDir?(dir: string): Promise<string[]>;\n}\n\nexport interface IdentityStoreOptions {\n\tfilePath?: string;\n\thooks?: IdentityStoreHooks;\n\tlockTimeoutMs?: number;\n\tlockRetryMs?: number;\n\t/** Injectable clock for deterministic tests. */\n\tnow?: () => Date;\n\tbackend?: IdentityFileBackend;\n}\n\n/**\n * Default file backend — uses `node:fs/promises` with the canonical\n * tmp-then-rename atomic-write pattern.\n */\nexport class FsIdentityFileBackend implements IdentityFileBackend {\n\tasync exists(filePath: string): Promise<boolean> {\n\t\ttry {\n\t\t\tawait fsp.access(filePath);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync read(filePath: string): Promise<PersistedDeviceIdentity | null> {\n\t\ttry {\n\t\t\tconst buf = await fsp.readFile(filePath, \"utf8\");\n\t\t\tconst parsed = JSON.parse(buf) as unknown;\n\t\t\treturn migratePersistedIdentity(parsed);\n\t\t} catch (err) {\n\t\t\tif (isNodeError(err) && err.code === \"ENOENT\") return null;\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\tasync write(filePath: string, payload: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait fsp.mkdir(path.dirname(filePath), { recursive: true });\n\t\tconst tmpPath = `${filePath}${TMP_SUFFIX}`;\n\t\tconst bytes = Buffer.from(JSON.stringify(payload, null, \"\\t\"), \"utf8\");\n\t\t// Ensure tmp is fresh (in case a previous run died mid-write).\n\t\tawait fsp.rm(tmpPath, { force: true });\n\t\tconst handle = await fsp.open(tmpPath, \"w\", FILE_MODE);\n\t\ttry {\n\t\t\tawait handle.writeFile(bytes);\n\t\t\tawait handle.sync();\n\t\t} finally {\n\t\t\tawait handle.close();\n\t\t}\n\t\tawait fsp.rename(tmpPath, filePath);\n\t\t// Best-effort chmod for filesystems that ignore mode on create (Windows).\n\t\tawait fsp.chmod(filePath, FILE_MODE).catch(() => undefined);\n\t\treturn { bytesWritten: bytes.byteLength, tmpPath };\n\t}\n\n\tasync delete(filePath: string): Promise<void> {\n\t\tawait fsp.rm(filePath, { force: true });\n\t}\n}\n\n/**\n * Reconcile an unknown on-disk shape into the current `PersistedDeviceIdentity`.\n *\n * - v1 IdentityStore files (current shape) pass through unchanged.\n * - v0 bootstrap files (`{ version: 1, claimed: false, publicKeyFingerprint: null }`)\n * are recognized by their placeholder keys and discarded; the next\n * enroll writes a fresh identity.\n * - Anything else throws — refuse to silently drop user data.\n */\nfunction migratePersistedIdentity(parsed: unknown): PersistedDeviceIdentity | null {\n\tif (!isRecord(parsed)) {\n\t\tthrow new Error(\"device.json: top-level must be an object\");\n\t}\n\tconst version = parsed.version;\n\tif (version === DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 placeholder shape carries `claimed` /\n\t\t// `publicKeyFingerprint` but no real device fields. Recognize\n\t\t// the marker and return null so the next enroll writes fresh data.\n\t\tif (\n\t\t\tparsed.publicId === undefined &&\n\t\t\tparsed.deviceSecret === undefined &&\n\t\t\t(\"claimed\" in parsed || \"publicKeyFingerprint\" in parsed)\n\t\t) {\n\t\t\treturn null;\n\t\t}\n\t\t// Trust the schema — the writer is also us.\n\t\treturn parsed as unknown as PersistedDeviceIdentity;\n\t}\n\tif (typeof version === \"number\" && version < DEVICE_JSON_SCHEMA_VERSION) {\n\t\t// Pre-Phase-5.2 bootstrap shape — the file is empty placeholder\n\t\t// data; nothing to migrate. Return null to signal \"no identity\".\n\t\treturn null;\n\t}\n\tthrow new Error(`device.json: unsupported schema version ${String(version)}`);\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn typeof value === \"object\" && value !== null;\n}\n\nfunction isNodeError(value: unknown): value is NodeJS.ErrnoException {\n\treturn value instanceof Error && typeof (value as { code?: unknown }).code === \"string\";\n}\n\n/**\n * mkdir-based advisory file lock. POSIX mkdir is atomic; on Windows\n * modern filesystems (NTFS) it's also atomic at the API level. Sufficient\n * for single-host, single-user scenarios (which is the SERVICEME threat\n * model). Cross-host locking is out of scope.\n */\nclass FileLock {\n\tprivate readonly dirPath: string;\n\tprivate readonly timeoutMs: number;\n\tprivate readonly retryMs: number;\n\tprivate acquired = false;\n\n\tconstructor(filePath: string, timeoutMs: number, retryMs: number) {\n\t\tthis.dirPath = `${filePath}.lock`;\n\t\tthis.timeoutMs = timeoutMs;\n\t\tthis.retryMs = retryMs;\n\t}\n\n\tasync acquire(): Promise<void> {\n\t\tconst start = Date.now();\n\t\twhile (true) {\n\t\t\ttry {\n\t\t\t\tawait fsp.mkdir(this.dirPath, { mode: LOCK_DIR_MODE });\n\t\t\t\tthis.acquired = true;\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tif (!isNodeError(err) || err.code !== \"EEXIST\") {\n\t\t\t\t\tthrow err;\n\t\t\t\t}\n\t\t\t\tif (Date.now() - start >= this.timeoutMs) {\n\t\t\t\t\tthrow new Error(`IdentityStore lock acquisition timed out for ${this.dirPath}`);\n\t\t\t\t}\n\t\t\t\tawait delay(this.retryMs);\n\t\t\t}\n\t\t}\n\t}\n\n\tasync release(): Promise<void> {\n\t\tif (!this.acquired) return;\n\t\tthis.acquired = false;\n\t\tawait fsp.rm(this.dirPath, { recursive: true, force: true });\n\t}\n}\n\nexport class IdentityStore {\n\tprivate readonly filePath: string;\n\tprivate readonly backend: IdentityFileBackend;\n\tprivate readonly hooks: IdentityStoreHooks;\n\tprivate readonly lockTimeoutMs: number;\n\tprivate readonly lockRetryMs: number;\n\tprivate readonly now: () => Date;\n\n\tconstructor(opts: IdentityStoreOptions = {}) {\n\t\tthis.filePath = opts.filePath ?? getDeviceJsonPath();\n\t\tthis.backend = opts.backend ?? new FsIdentityFileBackend();\n\t\tthis.hooks = opts.hooks ?? {};\n\t\tthis.lockTimeoutMs = opts.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;\n\t\tthis.lockRetryMs = opts.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;\n\t\tthis.now = opts.now ?? (() => new Date());\n\t}\n\n\t/** Absolute path to the underlying JSON file (test seam). */\n\tgetFilePath(): string {\n\t\treturn this.filePath;\n\t}\n\n\t/** True when the JSON file already exists on disk. */\n\tasync exists(): Promise<boolean> {\n\t\treturn this.backend.exists(this.filePath);\n\t}\n\n\t/** Read the persisted identity; returns `null` when no identity is stored. */\n\tasync read(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.backend.read(this.filePath);\n\t}\n\n\t/**\n\t * Atomically write the given identity. Concurrent writers are\n\t * serialized via the file lock; the read-modify-write happens\n\t * inside the lock so callers can't see a partial state.\n\t */\n\tasync write(next: PersistedDeviceIdentity): Promise<AtomicWriteResult> {\n\t\tawait this.hooks.beforeWrite?.(next);\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tconst result = await this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn result;\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/**\n\t * Read-modify-write under the same lock. The mutator receives the\n\t * current identity (or `null` on first call) and returns the\n\t * replacement. Throwing inside the mutator aborts the write.\n\t */\n\tasync mutate<T>(\n\t\tmutator: (\n\t\t\tcurrent: PersistedDeviceIdentity | null\n\t\t) => Promise<{ next: PersistedDeviceIdentity; result?: T }>\n\t): Promise<{ result: T | undefined; written: PersistedDeviceIdentity }> {\n\t\tconst lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);\n\t\tawait lock.acquire();\n\t\ttry {\n\t\t\tconst current = await this.backend.read(this.filePath);\n\t\t\tconst { next, result } = await mutator(current);\n\t\t\tconst stamped: PersistedDeviceIdentity = {\n\t\t\t\t...next,\n\t\t\t\tversion: DEVICE_JSON_SCHEMA_VERSION,\n\t\t\t};\n\t\t\tawait this.hooks.beforeWrite?.(stamped);\n\t\t\tawait this.backend.write(this.filePath, stamped);\n\t\t\tawait this.hooks.afterWrite?.(stamped);\n\t\t\treturn { result, written: stamped };\n\t\t} finally {\n\t\t\tawait lock.release();\n\t\t}\n\t}\n\n\t/** Wipe the persisted identity (used by `device.enroll --force`). */\n\tasync clear(): Promise<void> {\n\t\tawait this.backend.delete(this.filePath);\n\t}\n\n\t/**\n\t * Resolve the installation metadata for the current machine.\n\t * Pure helper — no I/O, just `os.*` calls.\n\t */\n\tresolveInstallationMetadata(): Pick<\n\t\tPersistedDeviceIdentity,\n\t\t\"installationId\" | \"machineId\" | \"platform\"\n\t> {\n\t\tconst machineId = os.hostname();\n\t\tconst platform = os.platform();\n\t\t// installationId is derived by `InstallationId.ts` — pass the\n\t\t// caller's already-computed value via `material` so we don't\n\t\t// recompute the SHA twice in a row.\n\t\treturn {\n\t\t\tinstallationId: \"\", // intentionally empty; caller fills via deriveInstallationId()\n\t\t\tmachineId,\n\t\t\tplatform,\n\t\t};\n\t}\n\n\t/**\n\t * Ensure the parent directory exists (`~/.serviceme/`). Idempotent.\n\t * Useful when the bootstrap phase5 placeholder wasn't run yet.\n\t */\n\tasync ensureHome(): Promise<void> {\n\t\tawait fsp.mkdir(getServicemeHome(), { recursive: true });\n\t\tawait fsp.mkdir(path.dirname(this.filePath), { recursive: true });\n\t}\n}\n","import * as os from \"node:os\";\nimport * as path from \"node:path\";\n\n/**\n * SERVICEME user home directory helpers.\n *\n * All paths resolve under {@link getServicemeHome}, which is either the\n * `SERVICEME_HOME` environment variable (when set and non-empty) or\n * `$HOME/.serviceme` on POSIX / `%USERPROFILE%\\.serviceme` on Windows.\n *\n * Tests inject `homeDir` and `servicemeHomeEnv` overrides via\n * {@link setUserHomeOverrides} / {@link resetUserHomeOverrides} so they can\n * exercise the path logic without touching the real user environment.\n */\n\n/** Layout constants — kept in one place so other modules can reuse them. */\nexport const SERVICEME_DIR_NAME = \".serviceme\";\nexport const REPOS_SUBDIR = \"repos\";\nexport const CACHE_SUBDIR = \"cache\";\nexport const DRAFTS_SUBDIR = \"drafts\";\nexport const SKILL_DRAFTS_SUBDIR = \"skills\";\nexport const AGENT_DRAFTS_SUBDIR = \"agents\";\nexport const REPOS_CONFIG_FILENAME = \"repos.json\";\n\n/**\n * Repo id regex — used to validate any `repoId` argument before it is joined\n * into a filesystem path. Keeps path traversal attempts out and gives us a\n * predictable on-disk shape.\n */\nexport const SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n/** Environment variable that overrides the user-home root directory. */\nexport const SERVICEME_HOME_ENV = \"SERVICEME_HOME\";\n\n/**\n * Test seam: lets unit tests inject deterministic values for `os.homedir()`\n * and the `SERVICEME_HOME` env override without actually mutating\n * `process.env` (which would leak into other tests).\n */\ninterface UserHomeOverrides {\n\thomeDir?: string | undefined;\n\tservicemeHomeEnv?: string | undefined;\n\tplatform?: NodeJS.Platform | undefined;\n}\n\nlet activeOverrides: UserHomeOverrides = {};\n\nexport function setUserHomeOverrides(overrides: UserHomeOverrides): void {\n\tactiveOverrides = { ...overrides };\n}\n\nexport function resetUserHomeOverrides(): void {\n\tactiveOverrides = {};\n}\n\nfunction resolveHomeDir(): string {\n\tconst injected = activeOverrides.homeDir;\n\tif (injected !== undefined) {\n\t\treturn injected;\n\t}\n\treturn os.homedir();\n}\n\nfunction resolveServicemeHomeEnv(): string | undefined {\n\tconst injected = activeOverrides.servicemeHomeEnv;\n\tif (injected !== undefined) {\n\t\t// Treat empty string as \"not set\" — `process.env` always returns a string\n\t\t// but tests may deliberately pass \"\" to opt out.\n\t\treturn injected.length > 0 ? injected : undefined;\n\t}\n\tconst envValue = process.env[SERVICEME_HOME_ENV];\n\treturn envValue && envValue.length > 0 ? envValue : undefined;\n}\n\nfunction resolvePlatform(): NodeJS.Platform {\n\treturn activeOverrides.platform ?? process.platform;\n}\n\nexport function assertSafeRepoId(repoId: string): string {\n\tif (typeof repoId !== \"string\" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {\n\t\tthrow new Error(\n\t\t\t`Invalid repo id: ${JSON.stringify(repoId)}. ` +\n\t\t\t\t`Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then ` +\n\t\t\t\t`alphanumerics / underscores / hyphens, ≤ 64 chars).`\n\t\t);\n\t}\n\treturn repoId;\n}\n\n/**\n * The raw OS home directory (`os.homedir()`), honoring test overrides\n * ({@link setUserHomeOverrides}). Exported for callers that need a\n * home-relative path *outside* of `~/.serviceme` — e.g. the\n * `~/.agents/{skills,agents}` convention used by `SkillLinker` for\n * user-scope links.\n */\nexport function getHomeDir(): string {\n\treturn resolveHomeDir();\n}\n\n/**\n * Root directory for all SERVICEME user-level state. Resolves to\n * `${SERVICEME_HOME}` when that env var is set, otherwise `${HOME}/.serviceme`\n * (POSIX) or `%USERPROFILE%\\.serviceme` (Windows via `os.homedir`).\n */\nexport function getServicemeHome(): string {\n\tconst override = resolveServicemeHomeEnv();\n\tif (override !== undefined) {\n\t\treturn path.resolve(override);\n\t}\n\treturn path.join(resolveHomeDir(), SERVICEME_DIR_NAME);\n}\n\n/** `$HOME/.serviceme/repos` (or `${SERVICEME_HOME}/repos`). */\nexport function getReposDir(): string {\n\treturn path.join(getServicemeHome(), REPOS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/repos/<repoId>` — validates `repoId` first. */\nexport function getRepoDir(repoId: string): string {\n\treturn path.join(getReposDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/cache` — generic per-user cache. */\nexport function getCacheDir(): string {\n\treturn path.join(getServicemeHome(), CACHE_SUBDIR);\n}\n\n/** `$HOME/.serviceme/cache/<repoId>` — per-repo sync state lives here. */\nexport function getRepoCacheDir(repoId: string): string {\n\treturn path.join(getCacheDir(), assertSafeRepoId(repoId));\n}\n\n/** `$HOME/.serviceme/drafts` — local edits not yet pushed upstream. */\nexport function getDraftsDir(): string {\n\treturn path.join(getServicemeHome(), DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/skills` — local skill drafts. */\nexport function getSkillDraftsDir(): string {\n\treturn path.join(getDraftsDir(), SKILL_DRAFTS_SUBDIR);\n}\n\n/** `$HOME/.serviceme/drafts/agents` — local agent drafts. */\nexport function getAgentDraftsDir(): string {\n\treturn path.join(getDraftsDir(), AGENT_DRAFTS_SUBDIR);\n}\n\n/**\n * `$HOME/.serviceme/repos.json` — the single config entry point for repo\n * metadata. Always under the resolved home root (i.e. follows\n * `SERVICEME_HOME` overrides too).\n */\nexport function getReposConfigPath(): string {\n\treturn path.join(getServicemeHome(), REPOS_CONFIG_FILENAME);\n}\n\n/**\n * Convenience helper for callers that need to switch behaviour on platform\n * (e.g. `SkillLinker` chooses symlink vs junction). Exported mostly so tests\n * can pin the value without touching `process.platform` directly.\n */\nexport function getHomePlatform(): NodeJS.Platform {\n\treturn resolvePlatform();\n}\n\n// ─── Scheduled Tasks paths (added by M1.5.3) ───────────────────────────────\n\n/** Layout constants for scheduled-tasks files. Kept here so other modules\n * can reuse them and the names stay in sync with the design doc. */\nexport const SCHEDULED_TASKS_CONFIG_FILENAME = \"scheduled-tasks.json\";\nexport const SCHEDULED_TASKS_LOG_FILENAME = \"scheduled-tasks-log.json\";\nexport const SCHEDULER_PID_FILENAME = \"scheduler.pid\";\nexport const SCHEDULER_LOCK_FILENAME = \"scheduler.lock\";\nexport const SCHEDULER_LOG_FILENAME = \"scheduler.log\";\nexport const MIGRATION_FAILURES_FILENAME = \"migration-failures.json\";\nexport const KNOWN_WORKSPACES_FILENAME = \"known-workspaces.json\";\n\n/** `~/.serviceme/scheduled-tasks.json` — the global v2 task list. */\nexport function getScheduledTasksConfigPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/scheduled-tasks-log.json` — the global execution log (200 LRU). */\nexport function getScheduledTasksLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULED_TASKS_LOG_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.pid` — global daemon PID file. */\nexport function getSchedulerPidPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_PID_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.lock` — global daemon startup flock. */\nexport function getSchedulerLockPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOCK_FILENAME);\n}\n\n/** `~/.serviceme/scheduler.log` — global daemon log (daemon lifecycle, not task execution). */\nexport function getSchedulerLogPath(): string {\n\treturn path.join(getServicemeHome(), SCHEDULER_LOG_FILENAME);\n}\n\n/** `~/.serviceme/migration-failures.json` — diagnostics for failed v1→v2 imports. */\nexport function getMigrationFailuresPath(): string {\n\treturn path.join(getServicemeHome(), MIGRATION_FAILURES_FILENAME);\n}\n\n/** `~/.serviceme/known-workspaces.json` — workspace list the extension has seen. */\nexport function getKnownWorkspacesPath(): string {\n\treturn path.join(getServicemeHome(), KNOWN_WORKSPACES_FILENAME);\n}\n\n// ─── Phase 5 (auth + device + toolbox) placeholders (Spec §11.12) ────────\n\n/** Layout constants for the Phase 5 client-side state files. The server\n * already has the corresponding routes (`/api/v1/auth/...`,\n * `/api/v1/device/...`, etc.) — the client just needs the on-disk\n * file paths + a first-run bootstrap so the auth/device/toolbox\n * services can open + read them without a per-call existence check. */\nexport const CREDENTIALS_CONFIG_FILENAME = \"credentials.json\";\nexport const DEVICE_JSON_FILENAME = \"device.json\";\nexport const TOOLBOX_JSON_FILENAME = \"toolbox.json\";\nexport const MACHINE_ID_FILENAME = \"machine-id\";\nexport const PROFILES_JSON_FILENAME = \"profiles.json\";\n\n/** `~/.serviceme/credentials.json` — better-auth session tokens + refresh state (client-side cache). */\nexport function getCredentialsConfigPath(): string {\n\treturn path.join(getServicemeHome(), CREDENTIALS_CONFIG_FILENAME);\n}\n\n/** `~/.serviceme/device.json` — device enrollment payload (id, public key fingerprint, claimed-by). */\nexport function getDeviceJsonPath(): string {\n\treturn path.join(getServicemeHome(), DEVICE_JSON_FILENAME);\n}\n\n/** `~/.serviceme/toolbox.json` — local toolbox state (installed tool refs, per-user prefs). */\nexport function getToolboxJsonPath(): string {\n\treturn path.join(getServicemeHome(), TOOLBOX_JSON_FILENAME);\n}\n\n/** `~/.serviceme/machine-id` — opaque stable per-install id (uuid v4 string, no JSON wrapper). */\nexport function getMachineIdPath(): string {\n\treturn path.join(getServicemeHome(), MACHINE_ID_FILENAME);\n}\n\n/** `~/.serviceme/profiles.json` — cached projection of server-side profile rows; refresh on demand. */\nexport function getProfilesJsonPath(): string {\n\treturn path.join(getServicemeHome(), PROFILES_JSON_FILENAME);\n}\n","/**\n * Internal types for the device domain.\n *\n * These types are NOT re-exported from the protocol package — they are\n * implementation details of the IdentityStore + Enroller. Public data\n * models (the bridge wire shape) live in `@serviceme/devtools-protocol`'s\n * `device.ts`.\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `device/types.ts`\n */\n\nimport type { DeviceBindingState } from \"@serviceme/devtools-protocol\";\n\n/**\n * Schema version of the on-disk `device.json` file. Bumped when the\n * shape changes incompatibly. IdentityStore checks this on read and\n * either migrates (versions ≤ 1) or refuses (versions > supported).\n */\nexport const DEVICE_JSON_SCHEMA_VERSION = 1;\n\n/** Internal representation of the persisted identity file. */\nexport interface PersistedDeviceIdentity {\n\tversion: number;\n\t/** Stable per-machine id (UUID v4 shape) — survives secret rotates. */\n\tinstallationId: string;\n\t/** Raw `os.hostname()` for diagnostics. */\n\tmachineId: string;\n\t/** Platform string (e.g. \"darwin\"). */\n\tplatform: string;\n\t/** Optional hostname override for environments where `os.hostname()` is unstable. */\n\thostname?: string;\n\t/** Public, non-secret id returned by the server. 32-char hex. */\n\tpublicId: string;\n\t/** Monotonic secret version counter, starts at 1 after first enroll. */\n\tsecretVersion: number;\n\t/** Current binding state — drives the re-enroll matrix. */\n\tbindingState: DeviceBindingState;\n\t/** HMAC secret (32 bytes hex-encoded = 64 chars). Persisted per `device-header-spec.md` §3.1. */\n\tdeviceSecret: string;\n\t/** Optional: previous secret retained during the grace window (rotation). */\n\tpreviousDeviceSecret?: string;\n\t/** Optional: ISO timestamp at which the previous secret stops being accepted. */\n\tpreviousSecretExpiresAt?: string;\n\t/** ISO timestamp of the most recent successful enroll / rotate. */\n\tlastEnrollAt: string;\n\t/** Optional ISO timestamp of the most recent server sync. */\n\tlastSyncAt?: string;\n\t/** Optional human-readable message for the last sync error. */\n\tlastSyncError?: string;\n}\n\n/** Result of a single atomic write. */\nexport interface AtomicWriteResult {\n\tbytesWritten: number;\n\t/** Path to the temp file (post-rename it no longer exists; useful for diagnostics). */\n\ttmpPath: string;\n}\n\n/** Hook called before/after every identity write — used by tests to assert concurrency safety. */\nexport interface IdentityStoreHooks {\n\tbeforeWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n\tafterWrite?: (next: PersistedDeviceIdentity) => void | Promise<void>;\n}\n","/**\n * DeviceCore — Main entry for the device domain.\n *\n * Aggregates `IdentityStore` + `Enroller` + signer helpers into a single\n * surface that the CLI / Extension / Bridge can call. Pure orchestration\n * — no HTTP of its own (the actual `POST /api/v1/devices/enroll` lives\n * behind `EnrollerOptions.enrollRequest`, wired in Phase 5.4).\n *\n * Refs:\n * - 4.功能规划.md §2.2 — `DeviceCore.ts 主入口`\n * - ADL-003 — data model in `@serviceme/devtools-protocol`\n * - `docs/architecture/phase-5-device-header-spec.md` §3.1\n */\n\nimport type {\n\tDeviceEnrollResult,\n\tDeviceIdentityState,\n\tDeviceMetadata,\n\tDeviceRotateSecretResult,\n\tDeviceStatus,\n} from \"@serviceme/devtools-protocol\";\nimport { buildSignedHeaders, DeviceAuthHeaders, type DeviceSignedHeaders } from \"./deviceAuth\";\nimport { Enroller, type EnrollRequestFn } from \"./Enroller\";\nimport { IdentityStore } from \"./IdentityStore\";\nimport { deriveInstallationId } from \"./InstallationId\";\nimport type { PersistedDeviceIdentity } from \"./types\";\n\nexport interface DeviceCoreOptions {\n\tidentityStore?: IdentityStore;\n\tenrollRequest?: EnrollRequestFn;\n\tnow?: () => Date;\n\t/** Optional override for `deriveInstallationId` (used by tests for determinism). */\n\tresolveInstallationId?: () => string;\n}\n\nexport class DeviceCore {\n\tprivate readonly identity: IdentityStore;\n\tprivate readonly enroller: Enroller;\n\tprivate readonly resolveInstallationId: () => string;\n\n\tconstructor(opts: DeviceCoreOptions = {}) {\n\t\tthis.identity = opts.identityStore ?? new IdentityStore();\n\t\tthis.enroller = new Enroller({\n\t\t\tidentityStore: this.identity,\n\t\t\tenrollRequest: opts.enrollRequest,\n\t\t\tnow: opts.now,\n\t\t});\n\t\tthis.resolveInstallationId = opts.resolveInstallationId ?? deriveInstallationId;\n\t}\n\n\t/** Read-only snapshot of the device status (matches `device.status` wire shape). */\n\tasync status(): Promise<DeviceStatus> {\n\t\tconst stored = await this.identity.read();\n\t\treturn stored\n\t\t\t? {\n\t\t\t\t\tbindingState: stored.bindingState,\n\t\t\t\t\tidentity: projectIdentity(stored),\n\t\t\t\t\tmetadata: projectMetadata(stored),\n\t\t\t\t\tlastSyncAt: stored.lastSyncAt,\n\t\t\t\t\tlastSyncError: stored.lastSyncError,\n\t\t\t\t}\n\t\t\t: { bindingState: \"anonymous\" };\n\t}\n\n\t/** Enroll (or re-enroll) the device. */\n\tasync enroll(opts: { force?: boolean; requireAuth?: boolean } = {}): Promise<DeviceEnrollResult> {\n\t\treturn this.enroller.enroll(opts);\n\t}\n\n\t/** Wait for any in-flight enrollment to finish. Use before `buildSignedHeaders` so that a concurrent `syncDeviceInfo` enrollment has time to write the identity to the store. */\n\tasync waitForEnrollment(): Promise<void> {\n\t\tawait this.enroller.waitForEnrollment();\n\t}\n\n\t/** True when an enrollment is currently in-flight. Used by callers to skip triggering a competing enrollment. */\n\tisEnrolling(): boolean {\n\t\treturn this.enroller.isEnrolling();\n\t}\n\n\t/** Rotate the HMAC secret while keeping the previous one for the grace window. */\n\tasync rotateSecret(opts: { gracePeriodDays?: number } = {}): Promise<DeviceRotateSecretResult> {\n\t\tconst result = await this.enroller.rotateSecret(opts);\n\t\tconst stored = await this.identity.read();\n\t\treturn {\n\t\t\t...result,\n\t\t\tgracePeriodEndsAt: stored?.previousSecretExpiresAt,\n\t\t};\n\t}\n\n\t/** Build the canonical 5-header map for an outbound signed request. */\n\tasync buildSignedHeaders(input: {\n\t\tmethod: string;\n\t\tpath: string;\n\t\tbody: string;\n\t}): Promise<DeviceSignedHeaders | null> {\n\t\tconst stored = await this.identity.read();\n\t\tif (!stored) return null;\n\t\treturn buildSignedHeaders({\n\t\t\tmethod: input.method,\n\t\t\tpath: input.path,\n\t\t\tbody: input.body,\n\t\t\tpublicId: stored.publicId,\n\t\t\tdeviceSecret: stored.deviceSecret,\n\t\t\tsecretVersion: stored.secretVersion,\n\t\t});\n\t}\n\n\t/** Raw stored identity (CLI/extension internal use). Test seam too. */\n\tasync readIdentity(): Promise<PersistedDeviceIdentity | null> {\n\t\treturn this.identity.read();\n\t}\n\n\t/** Wipe the local identity (the `--force` path before re-enroll). */\n\tasync clear(): Promise<void> {\n\t\tawait this.identity.clear();\n\t}\n\n\t/** Mark the device as claimed (called by the bridge after a successful claim). */\n\tasync markClaimed(): Promise<void> {\n\t\tawait this.enroller.markClaimed();\n\t}\n\n\t/** Mark the device as expired (server returned an expiry response). */\n\tasync markExpired(): Promise<void> {\n\t\tawait this.enroller.markExpired();\n\t}\n\n\t/** Expose the identity store (CLI uses it for direct file access in tests). */\n\tgetIdentityStore(): IdentityStore {\n\t\treturn this.identity;\n\t}\n\n\t/** Expose the enroller (CLI uses it for state inspection). */\n\tgetEnroller(): Enroller {\n\t\treturn this.enroller;\n\t}\n\n\t/** Header name constants — re-exported from `deviceAuth.ts`. */\n\tgetHeaderNames(): typeof DeviceAuthHeaders {\n\t\treturn DeviceAuthHeaders;\n\t}\n\n\t/** Compute the installation id for the current machine. */\n\tgetInstallationId(): string {\n\t\treturn this.resolveInstallationId();\n\t}\n}\n\nfunction projectIdentity(stored: PersistedDeviceIdentity): DeviceIdentityState {\n\treturn {\n\t\tpublicId: stored.publicId,\n\t\tsecretVersion: stored.secretVersion,\n\t\tbindingState: stored.bindingState,\n\t};\n}\n\nfunction projectMetadata(stored: PersistedDeviceIdentity): DeviceMetadata {\n\treturn {\n\t\tinstallationId: stored.installationId,\n\t\tmachineId: stored.machineId,\n\t\tplatform: stored.platform,\n\t\thostname: stored.hostname,\n\t};\n}\n"],"mappings":";AAgBA,SAAS,kBAAkB;AAGpB,IAAM,oBAAoB;AAAA,EAChC,UAAU;AAAA,EACV,cAAc;AAAA,EACd,WAAW;AAAA,EACX,WAAW;AAAA,EACX,eAAe;AAChB;AAiBO,SAAS,6BAA6B,QAA8C;AAC1F,QAAM,QAAQ;AAAA,IACb,OAAO,OAAO,YAAY;AAAA,IAC1B,OAAO;AAAA,IACP,OAAO,OAAO,SAAS;AAAA,IACvB,OAAO;AAAA,IACP,OAAO;AAAA,EACR,EAAE,KAAK,IAAI;AACX,SAAO,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACvD;AA2BO,SAAS,mBAAmB,QAAuD;AACzF,QAAM,YAAY,OAAO,aAAa,KAAK,IAAI;AAC/C,QAAM,YAAY,6BAA6B;AAAA,IAC9C,QAAQ,OAAO;AAAA,IACf,MAAM,OAAO;AAAA,IACb;AAAA,IACA,MAAM,OAAO;AAAA,IACb,QAAQ,OAAO;AAAA,EAChB,CAAC;AACD,SAAO;AAAA,IACN,CAAC,kBAAkB,QAAQ,GAAG,OAAO;AAAA,IACrC,CAAC,kBAAkB,YAAY,GAAG,OAAO;AAAA,IACzC,CAAC,kBAAkB,SAAS,GAAG;AAAA,IAC/B,CAAC,kBAAkB,SAAS,GAAG,OAAO,SAAS;AAAA,IAC/C,CAAC,kBAAkB,aAAa,GAAG,OAAO,OAAO,aAAa;AAAA,EAC/D;AACD;;;AChEA,SAAS,mBAAmB;;;ACR5B,SAAS,cAAAA,aAAY,kBAAkB;AACvC,YAAY,QAAQ;AAGpB,SAAS,sBAA8B;AAItC,MAAI,WAAW;AACf,MAAI;AACH,eAAc,YAAS,EAAE;AAAA,EAC1B,QAAQ;AACP,eAAW,QAAQ,IAAI,QAAQ,QAAQ,IAAI,YAAY;AAAA,EACxD;AACA,SAAO;AAAA,IACH,YAAS;AAAA,IACZ;AAAA,IACG,YAAS;AAAA,IACT,QAAK;AAAA,IACR,QAAQ,SAAS,QAAQ;AAAA,EAC1B,EAAE,KAAK,GAAG;AACX;AAOO,SAAS,uBAA+B;AAC9C,QAAM,WAAW,oBAAoB;AACrC,QAAM,SAASA,YAAW,QAAQ,EAAE,OAAO,QAAQ,EAAE,OAAO,KAAK;AAIjE,SAAO,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC;AACtC;AAOO,SAAS,uBAA+B;AAC9C,SAAO,WAAW;AACnB;AAGO,SAAS,oBAA4B;AAC3C,SAAO,oBAAoB;AAC5B;AAEA,SAAS,WAAW,OAAuB;AAG1C,QAAM,QAAQ,MAAM,MAAM,EAAE;AAE5B,QAAM,aAAa;AACnB,QAAM,aAAa;AACnB,QAAM,cAAe,SAAS,MAAM,UAAU,KAAK,KAAK,EAAE,IAAI,IAAO;AACrE,QAAM,UAAU,IAAI,YAAY,SAAS,EAAE;AAE3C,QAAM,cAAe,SAAS,MAAM,UAAU,KAAK,KAAK,EAAE,IAAI,IAAO;AACrE,QAAM,UAAU,IAAI,YAAY,SAAS,EAAE;AAC3C,QAAM,YAAY,MAAM,KAAK,EAAE;AAC/B,SAAO,GAAG,UAAU,MAAM,GAAG,CAAC,CAAC,IAAI,UAAU,MAAM,GAAG,EAAE,CAAC,IAAI,UAAU,MAAM,IAAI,EAAE,CAAC,IAAI,UAAU,MAAM,IAAI,EAAE,CAAC,IAAI,UAAU,MAAM,IAAI,EAAE,CAAC;AAC3I;;;ADjDA,IAAM,eAAe;AAErB,IAAM,kBAAkB;AAIxB,IAAM,qBAAoC,CAAC,SAAS;AACnD,SAAO,YAAY,IAAI;AACxB;AA8BO,IAAM,kCAAN,cAA8C,MAAM;AAAA,EAC1D,YACC,UAAU,uFACT;AACD,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACb;AACD;AAGO,IAAM,mCAAN,cAA+C,MAAM;AAAA,EAC3D,YAAY,UAAU,gFAA2E;AAChG,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACb;AACD;AAEO,IAAM,WAAN,MAAe;AAAA,EAOrB,YAAY,MAAuB;AAFnC,SAAQ,WAA+C;AAGtD,SAAK,WAAW,KAAK;AACrB,SAAK,MAAM,KAAK,QAAQ,MAAM,oBAAI,KAAK;AACvC,SAAK,SAAS,KAAK,eAAe;AAClC,SAAK,gBAAgB,KAAK;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,eAA4C;AACjD,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AACxC,WAAO,QAAQ,gBAAgB;AAAA,EAChC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBA,MAAM,oBAAmC;AACxC,QAAI,KAAK,UAAU;AAClB,YAAM,KAAK;AAAA,IACZ;AAAA,EACD;AAAA,EAEA,MAAM,OAAO,OAAmD,CAAC,GAAgC;AAEhG,QAAI,KAAK,UAAU;AAClB,aAAO,KAAK;AAAA,IACb;AACA,UAAM,UAAU,KAAK,UAAU,IAAI;AACnC,SAAK,WAAW;AAChB,QAAI;AACH,aAAO,MAAM;AAAA,IACd,UAAE;AACD,UAAI,KAAK,aAAa,QAAS,MAAK,WAAW;AAAA,IAChD;AAAA,EACD;AAAA;AAAA,EAGA,mBAAkC;AACjC,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGA,cAAuB;AACtB,WAAO,KAAK,aAAa;AAAA,EAC1B;AAAA,EAEA,MAAc,UAAU,MAGQ;AAC/B,UAAM,EAAE,QAAQ,IAAI,MAAM,KAAK,SAAS,OAAO,OAAO,YAAY;AACjE,YAAM,WAAW,KAAK,QAAQ,OAAO;AAErC,UAAI,CAAC,KAAK,SAAS,SAAS;AAC3B,YAAI,QAAQ,iBAAiB,WAAW;AAAA,QAExC,WACC,KAAK,gBACJ,QAAQ,iBAAiB,aAAa,QAAQ,iBAAiB,YAC/D;AAID,gBAAM,IAAI,gCAAgC;AAAA,QAC3C;AAAA,MACD;AAEA,YAAM,iBAAiB,SAAS,kBAAkB,qBAAqB;AACvE,YAAM,YAAY,SAAS,aAAa;AACxC,YAAMC,YAAW,SAAS,YAAY;AAEtC,UAAI;AACJ,UAAI,KAAK,eAAe;AACvB,mBAAW,MAAM,KAAK,cAAc;AAAA,UACnC;AAAA,UACA;AAAA,UACA,UAAAA;AAAA,UACA;AAAA,UACA,OAAO,QAAQ,KAAK,KAAK;AAAA,UACzB,aAAa,QAAQ,KAAK,WAAW;AAAA,QACtC,CAAC;AAAA,MACF,OAAO;AAIN,mBAAW,yBAAyB,KAAK,QAAQ,QAAQ;AAAA,MAC1D;AAEA,YAAM,OAAgC;AAAA,QACrC,SAAS,SAAS,WAAW;AAAA,QAC7B;AAAA,QACA;AAAA,QACA,UAAAA;AAAA,QACA,UAAU,SAAS;AAAA,QACnB,UAAU,SAAS;AAAA,QACnB,eAAe,SAAS;AAAA,QACxB,cAAc,SAAS;AAAA,QACvB,cAAc,SAAS;AAAA,QACvB,sBAAsB,UAAU;AAAA,QAChC,yBACC,KAAK,SAAS,SAAS,mBAAmB,UAAU,iBAAiB,KAAK,IACvE,SACA,UAAU;AAAA,QACd,cAAc,KAAK,IAAI,EAAE,YAAY;AAAA,QACrC,YAAY,UAAU;AAAA,QACtB,eAAe;AAAA,MAChB;AACA,aAAO,EAAE,KAAK;AAAA,IACf,CAAC;AAED,WAAO;AAAA,MACN,UAAU,QAAQ;AAAA,MAClB,cAAc,QAAQ;AAAA,MACtB,WAAW,gBAAgB,SAAS,KAAK,GAAG;AAAA,IAC7C;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,aACL,OAAqC,CAAC,GAC0C;AAChF,UAAM,kBAAkB,KAAK,mBAAmB;AAChD,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,YAAY,KAAK,OAAO,YAAY,EAAE,SAAS,KAAK;AAE1D,UAAM,EAAE,QAAQ,IAAI,MAAM,KAAK,SAAS,OAAO,OAAO,YAAY;AACjE,UAAI,CAAC,SAAS;AACb,cAAM,IAAI,MAAM,iDAAiD;AAAA,MAClE;AACA,YAAM,iBAAiB,IAAI,KAAK,IAAI,QAAQ,IAAI,kBAAkB,KAAK,KAAK,KAAK,GAAI;AACrF,YAAM,OAAgC;AAAA,QACrC,GAAG;AAAA,QACH,cAAc;AAAA,QACd,sBAAsB,QAAQ;AAAA,QAC9B,yBAAyB,eAAe,YAAY;AAAA,QACpD,eAAe,QAAQ,gBAAgB;AAAA,QACvC,cAAc,IAAI,YAAY;AAAA,MAC/B;AACA,aAAO,EAAE,KAAK;AAAA,IACf,CAAC;AAED,WAAO;AAAA,MACN,UAAU,QAAQ;AAAA,MAClB,eAAe,QAAQ;AAAA,MACvB;AAAA,IACD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,cAA6B;AAClC,UAAM,KAAK,SAAS,OAAO,OAAO,YAAY;AAC7C,UAAI,CAAC,SAAS;AAEb,eAAO,EAAE,MAAM,WAAY,MAAM,cAAc,KAAK,MAAM,GAAI,QAAQ,OAAU;AAAA,MACjF;AACA,YAAM,OAAgC,EAAE,GAAG,SAAS,cAAc,UAAU;AAC5E,aAAO,EAAE,KAAK;AAAA,IACf,CAAC;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,cAA6B;AAClC,UAAM,KAAK,SAAS,OAAO,OAAO,YAAY;AAC7C,UAAI,CAAC,SAAS;AACb,cAAM,IAAI,MAAM,gDAAgD;AAAA,MACjE;AACA,YAAM,OAAgC;AAAA,QACrC,GAAG;AAAA,QACH,cAAc;AAAA,QACd,YAAY,KAAK,IAAI,EAAE,YAAY;AAAA,QACnC,eAAe;AAAA,MAChB;AACA,aAAO,EAAE,KAAK;AAAA,IACf,CAAC;AAAA,EACF;AACD;AAEA,SAAS,gBAAgB,WAAoC,MAAsC;AAKlG,SAAO;AACR;AAEA,SAAS,yBACR,QACA,UACiB;AACjB,QAAM,WAAW,UAAU,YAAY,OAAO,eAAe,EAAE,SAAS,KAAK;AAC7E,QAAM,iBAAiB,UAAU,iBAAiB,KAAK;AACvD,SAAO;AAAA,IACN;AAAA,IACA,cAAc,OAAO,YAAY,EAAE,SAAS,KAAK;AAAA,IACjD;AAAA,IACA,cAAc,UAAU,iBAAiB,YAAY,YAAY;AAAA,EAClE;AACD;AAEA,eAAe,cAAc,QAAyD;AACrF,SAAO;AAAA,IACN,SAAS;AAAA,IACT,gBAAgB,qBAAqB;AAAA,IACrC,WAAW;AAAA,IACX,UAAU;AAAA,IACV,UAAU,OAAO,eAAe,EAAE,SAAS,KAAK;AAAA,IAChD,eAAe;AAAA,IACf,cAAc;AAAA,IACd,cAAc,OAAO,YAAY,EAAE,SAAS,KAAK;AAAA,IACjD,eAAc,oBAAI,KAAK,GAAE,YAAY;AAAA,EACtC;AACD;;;AErTA,YAAY,SAAS;AACrB,YAAYC,SAAQ;AACpB,YAAYC,WAAU;AACtB,SAAS,cAAc,aAAa;;;AC/BpC,YAAYC,SAAQ;AACpB,YAAY,UAAU;AAef,IAAM,qBAAqB;AAgB3B,IAAM,qBAAqB;AAalC,IAAI,kBAAqC,CAAC;AAU1C,SAAS,iBAAyB;AACjC,QAAM,WAAW,gBAAgB;AACjC,MAAI,aAAa,QAAW;AAC3B,WAAO;AAAA,EACR;AACA,SAAU,YAAQ;AACnB;AAEA,SAAS,0BAA8C;AACtD,QAAM,WAAW,gBAAgB;AACjC,MAAI,aAAa,QAAW;AAG3B,WAAO,SAAS,SAAS,IAAI,WAAW;AAAA,EACzC;AACA,QAAM,WAAW,QAAQ,IAAI,kBAAkB;AAC/C,SAAO,YAAY,SAAS,SAAS,IAAI,WAAW;AACrD;AAiCO,SAAS,mBAA2B;AAC1C,QAAM,WAAW,wBAAwB;AACzC,MAAI,aAAa,QAAW;AAC3B,WAAY,aAAQ,QAAQ;AAAA,EAC7B;AACA,SAAY,UAAK,eAAe,GAAG,kBAAkB;AACtD;AA8GO,IAAM,uBAAuB;AAW7B,SAAS,oBAA4B;AAC3C,SAAY,UAAK,iBAAiB,GAAG,oBAAoB;AAC1D;;;ACvNO,IAAM,6BAA6B;;;AFuB1C,IAAM,YAAY;AAClB,IAAM,gBAAgB;AACtB,IAAM,0BAA0B;AAChC,IAAM,wBAAwB;AAC9B,IAAM,aAAa;AA6BZ,IAAM,wBAAN,MAA2D;AAAA,EACjE,MAAM,OAAO,UAAoC;AAChD,QAAI;AACH,YAAU,WAAO,QAAQ;AACzB,aAAO;AAAA,IACR,QAAQ;AACP,aAAO;AAAA,IACR;AAAA,EACD;AAAA,EAEA,MAAM,KAAK,UAA2D;AACrE,QAAI;AACH,YAAM,MAAM,MAAU,aAAS,UAAU,MAAM;AAC/C,YAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,aAAO,yBAAyB,MAAM;AAAA,IACvC,SAAS,KAAK;AACb,UAAI,YAAY,GAAG,KAAK,IAAI,SAAS,SAAU,QAAO;AACtD,YAAM;AAAA,IACP;AAAA,EACD;AAAA,EAEA,MAAM,MAAM,UAAkB,SAA8D;AAC3F,UAAU,UAAW,cAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;AAC3D,UAAM,UAAU,GAAG,QAAQ,GAAG,UAAU;AACxC,UAAM,QAAQ,OAAO,KAAK,KAAK,UAAU,SAAS,MAAM,GAAI,GAAG,MAAM;AAErE,UAAU,OAAG,SAAS,EAAE,OAAO,KAAK,CAAC;AACrC,UAAM,SAAS,MAAU,SAAK,SAAS,KAAK,SAAS;AACrD,QAAI;AACH,YAAM,OAAO,UAAU,KAAK;AAC5B,YAAM,OAAO,KAAK;AAAA,IACnB,UAAE;AACD,YAAM,OAAO,MAAM;AAAA,IACpB;AACA,UAAU,WAAO,SAAS,QAAQ;AAElC,UAAU,UAAM,UAAU,SAAS,EAAE,MAAM,MAAM,MAAS;AAC1D,WAAO,EAAE,cAAc,MAAM,YAAY,QAAQ;AAAA,EAClD;AAAA,EAEA,MAAM,OAAO,UAAiC;AAC7C,UAAU,OAAG,UAAU,EAAE,OAAO,KAAK,CAAC;AAAA,EACvC;AACD;AAWA,SAAS,yBAAyB,QAAiD;AAClF,MAAI,CAAC,SAAS,MAAM,GAAG;AACtB,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC3D;AACA,QAAM,UAAU,OAAO;AACvB,MAAI,YAAY,4BAA4B;AAI3C,QACC,OAAO,aAAa,UACpB,OAAO,iBAAiB,WACvB,aAAa,UAAU,0BAA0B,SACjD;AACD,aAAO;AAAA,IACR;AAEA,WAAO;AAAA,EACR;AACA,MAAI,OAAO,YAAY,YAAY,UAAU,4BAA4B;AAGxE,WAAO;AAAA,EACR;AACA,QAAM,IAAI,MAAM,2CAA2C,OAAO,OAAO,CAAC,EAAE;AAC7E;AAEA,SAAS,SAAS,OAAkD;AACnE,SAAO,OAAO,UAAU,YAAY,UAAU;AAC/C;AAEA,SAAS,YAAY,OAAgD;AACpE,SAAO,iBAAiB,SAAS,OAAQ,MAA6B,SAAS;AAChF;AAQA,IAAM,WAAN,MAAe;AAAA,EAMd,YAAY,UAAkB,WAAmB,SAAiB;AAFlE,SAAQ,WAAW;AAGlB,SAAK,UAAU,GAAG,QAAQ;AAC1B,SAAK,YAAY;AACjB,SAAK,UAAU;AAAA,EAChB;AAAA,EAEA,MAAM,UAAyB;AAC9B,UAAM,QAAQ,KAAK,IAAI;AACvB,WAAO,MAAM;AACZ,UAAI;AACH,cAAU,UAAM,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AACrD,aAAK,WAAW;AAChB;AAAA,MACD,SAAS,KAAK;AACb,YAAI,CAAC,YAAY,GAAG,KAAK,IAAI,SAAS,UAAU;AAC/C,gBAAM;AAAA,QACP;AACA,YAAI,KAAK,IAAI,IAAI,SAAS,KAAK,WAAW;AACzC,gBAAM,IAAI,MAAM,gDAAgD,KAAK,OAAO,EAAE;AAAA,QAC/E;AACA,cAAM,MAAM,KAAK,OAAO;AAAA,MACzB;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAM,UAAyB;AAC9B,QAAI,CAAC,KAAK,SAAU;AACpB,SAAK,WAAW;AAChB,UAAU,OAAG,KAAK,SAAS,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EAC5D;AACD;AAEO,IAAM,gBAAN,MAAoB;AAAA,EAQ1B,YAAY,OAA6B,CAAC,GAAG;AAC5C,SAAK,WAAW,KAAK,YAAY,kBAAkB;AACnD,SAAK,UAAU,KAAK,WAAW,IAAI,sBAAsB;AACzD,SAAK,QAAQ,KAAK,SAAS,CAAC;AAC5B,SAAK,gBAAgB,KAAK,iBAAiB;AAC3C,SAAK,cAAc,KAAK,eAAe;AACvC,SAAK,MAAM,KAAK,QAAQ,MAAM,oBAAI,KAAK;AAAA,EACxC;AAAA;AAAA,EAGA,cAAsB;AACrB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGA,MAAM,SAA2B;AAChC,WAAO,KAAK,QAAQ,OAAO,KAAK,QAAQ;AAAA,EACzC;AAAA;AAAA,EAGA,MAAM,OAAgD;AACrD,WAAO,KAAK,QAAQ,KAAK,KAAK,QAAQ;AAAA,EACvC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,MAAM,MAA2D;AACtE,UAAM,KAAK,MAAM,cAAc,IAAI;AACnC,UAAM,OAAO,IAAI,SAAS,KAAK,UAAU,KAAK,eAAe,KAAK,WAAW;AAC7E,UAAM,KAAK,QAAQ;AACnB,QAAI;AACH,YAAM,UAAmC;AAAA,QACxC,GAAG;AAAA,QACH,SAAS;AAAA,MACV;AACA,YAAM,SAAS,MAAM,KAAK,QAAQ,MAAM,KAAK,UAAU,OAAO;AAC9D,YAAM,KAAK,MAAM,aAAa,OAAO;AACrC,aAAO;AAAA,IACR,UAAE;AACD,YAAM,KAAK,QAAQ;AAAA,IACpB;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,OACL,SAGuE;AACvE,UAAM,OAAO,IAAI,SAAS,KAAK,UAAU,KAAK,eAAe,KAAK,WAAW;AAC7E,UAAM,KAAK,QAAQ;AACnB,QAAI;AACH,YAAM,UAAU,MAAM,KAAK,QAAQ,KAAK,KAAK,QAAQ;AACrD,YAAM,EAAE,MAAM,OAAO,IAAI,MAAM,QAAQ,OAAO;AAC9C,YAAM,UAAmC;AAAA,QACxC,GAAG;AAAA,QACH,SAAS;AAAA,MACV;AACA,YAAM,KAAK,MAAM,cAAc,OAAO;AACtC,YAAM,KAAK,QAAQ,MAAM,KAAK,UAAU,OAAO;AAC/C,YAAM,KAAK,MAAM,aAAa,OAAO;AACrC,aAAO,EAAE,QAAQ,SAAS,QAAQ;AAAA,IACnC,UAAE;AACD,YAAM,KAAK,QAAQ;AAAA,IACpB;AAAA,EACD;AAAA;AAAA,EAGA,MAAM,QAAuB;AAC5B,UAAM,KAAK,QAAQ,OAAO,KAAK,QAAQ;AAAA,EACxC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,8BAGE;AACD,UAAM,YAAe,aAAS;AAC9B,UAAMC,YAAc,aAAS;AAI7B,WAAO;AAAA,MACN,gBAAgB;AAAA;AAAA,MAChB;AAAA,MACA,UAAAA;AAAA,IACD;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,aAA4B;AACjC,UAAU,UAAM,iBAAiB,GAAG,EAAE,WAAW,KAAK,CAAC;AACvD,UAAU,UAAW,cAAQ,KAAK,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;AAAA,EACjE;AACD;;;AGhSO,IAAM,aAAN,MAAiB;AAAA,EAKvB,YAAY,OAA0B,CAAC,GAAG;AACzC,SAAK,WAAW,KAAK,iBAAiB,IAAI,cAAc;AACxD,SAAK,WAAW,IAAI,SAAS;AAAA,MAC5B,eAAe,KAAK;AAAA,MACpB,eAAe,KAAK;AAAA,MACpB,KAAK,KAAK;AAAA,IACX,CAAC;AACD,SAAK,wBAAwB,KAAK,yBAAyB;AAAA,EAC5D;AAAA;AAAA,EAGA,MAAM,SAAgC;AACrC,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AACxC,WAAO,SACJ;AAAA,MACA,cAAc,OAAO;AAAA,MACrB,UAAU,gBAAgB,MAAM;AAAA,MAChC,UAAU,gBAAgB,MAAM;AAAA,MAChC,YAAY,OAAO;AAAA,MACnB,eAAe,OAAO;AAAA,IACvB,IACC,EAAE,cAAc,YAAY;AAAA,EAChC;AAAA;AAAA,EAGA,MAAM,OAAO,OAAmD,CAAC,GAAgC;AAChG,WAAO,KAAK,SAAS,OAAO,IAAI;AAAA,EACjC;AAAA;AAAA,EAGA,MAAM,oBAAmC;AACxC,UAAM,KAAK,SAAS,kBAAkB;AAAA,EACvC;AAAA;AAAA,EAGA,cAAuB;AACtB,WAAO,KAAK,SAAS,YAAY;AAAA,EAClC;AAAA;AAAA,EAGA,MAAM,aAAa,OAAqC,CAAC,GAAsC;AAC9F,UAAM,SAAS,MAAM,KAAK,SAAS,aAAa,IAAI;AACpD,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AACxC,WAAO;AAAA,MACN,GAAG;AAAA,MACH,mBAAmB,QAAQ;AAAA,IAC5B;AAAA,EACD;AAAA;AAAA,EAGA,MAAM,mBAAmB,OAIe;AACvC,UAAM,SAAS,MAAM,KAAK,SAAS,KAAK;AACxC,QAAI,CAAC,OAAQ,QAAO;AACpB,WAAO,mBAAmB;AAAA,MACzB,QAAQ,MAAM;AAAA,MACd,MAAM,MAAM;AAAA,MACZ,MAAM,MAAM;AAAA,MACZ,UAAU,OAAO;AAAA,MACjB,cAAc,OAAO;AAAA,MACrB,eAAe,OAAO;AAAA,IACvB,CAAC;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,eAAwD;AAC7D,WAAO,KAAK,SAAS,KAAK;AAAA,EAC3B;AAAA;AAAA,EAGA,MAAM,QAAuB;AAC5B,UAAM,KAAK,SAAS,MAAM;AAAA,EAC3B;AAAA;AAAA,EAGA,MAAM,cAA6B;AAClC,UAAM,KAAK,SAAS,YAAY;AAAA,EACjC;AAAA;AAAA,EAGA,MAAM,cAA6B;AAClC,UAAM,KAAK,SAAS,YAAY;AAAA,EACjC;AAAA;AAAA,EAGA,mBAAkC;AACjC,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGA,cAAwB;AACvB,WAAO,KAAK;AAAA,EACb;AAAA;AAAA,EAGA,iBAA2C;AAC1C,WAAO;AAAA,EACR;AAAA;AAAA,EAGA,oBAA4B;AAC3B,WAAO,KAAK,sBAAsB;AAAA,EACnC;AACD;AAEA,SAAS,gBAAgB,QAAsD;AAC9E,SAAO;AAAA,IACN,UAAU,OAAO;AAAA,IACjB,eAAe,OAAO;AAAA,IACtB,cAAc,OAAO;AAAA,EACtB;AACD;AAEA,SAAS,gBAAgB,QAAiD;AACzE,SAAO;AAAA,IACN,gBAAgB,OAAO;AAAA,IACvB,WAAW,OAAO;AAAA,IAClB,UAAU,OAAO;AAAA,IACjB,UAAU,OAAO;AAAA,EAClB;AACD;","names":["createHash","platform","os","path","os","platform"]}
@@ -0,0 +1,277 @@
1
+ import { SpawnOptions } from 'node:child_process';
2
+ import { b as SkillFile } from './types-B9gk3dXH.js';
3
+
4
+ /**
5
+ * Skill & Agent v2 — Client GitClient Types (M3)
6
+ *
7
+ * GitClient wraps the local `git` CLI so all commands the SERVICEME
8
+ * pipeline issues (clone, fetch, push, ls-remote) flow through the
9
+ * server's git smart-HTTP proxy instead of talking directly to GitHub.
10
+ *
11
+ * The single trick: `git` only cares about the URL we hand it as a
12
+ * remote. We rewrite `https://github.com/owner/repo.git` to
13
+ * `http://server:port/git-proxy/<id>` and `git` does the rest — the
14
+ * server transparently forwards + injects PATs as needed.
15
+ *
16
+ * @see docs/architecture/skill-agent-v2-repo.md §5.4 GitClient
17
+ */
18
+ /** Result of a `git fetch` (called via the proxy's git-upload-pack). */
19
+ interface PullResult {
20
+ /** Whether new commits were fetched (false = already up-to-date). */
21
+ updated: boolean;
22
+ /** Commit SHA at FETCH_HEAD after the pull. */
23
+ commitSha: string;
24
+ /** Branch name that was pulled (e.g. "v2"). */
25
+ branch: string;
26
+ }
27
+ /** Result of a `git push` (called via the proxy's git-receive-pack). */
28
+ interface PushResult {
29
+ /** Remote ref updated (e.g. "refs/heads/v2"). */
30
+ ref: string;
31
+ /** New commit SHA on the remote. */
32
+ commitSha: string;
33
+ }
34
+ /** A branch as advertised by `git ls-remote`. */
35
+ interface RemoteBranch {
36
+ /** Full ref name, e.g. `refs/heads/main`. */
37
+ ref: string;
38
+ /** Commit SHA the ref points at. */
39
+ sha: string;
40
+ }
41
+ /** Outcome of any spawned `git` process. */
42
+ interface GitSpawnResult {
43
+ stdout: string;
44
+ stderr: string;
45
+ code: number;
46
+ }
47
+ /** Strategy for executing git commands. Tests inject a stub. */
48
+ interface GitSpawner {
49
+ spawn(args: string[], opts: SpawnOptions): Promise<GitSpawnResult>;
50
+ }
51
+ /** Options for instantiating GitClient. */
52
+ interface GitClientOptions {
53
+ /** Base URL of the server git proxy. e.g. `http://localhost:3000/git-proxy`. */
54
+ serverProxyBase: string;
55
+ /** Inject a custom spawner (default: spawn real `git` CLI). */
56
+ spawner?: GitSpawner;
57
+ /** Inject an env override for spawned git (default: process.env minus proxy secrets). */
58
+ env?: NodeJS.ProcessEnv;
59
+ }
60
+ /** Sentinel error when git exits non-zero. */
61
+ declare class GitError extends Error {
62
+ readonly args: string[];
63
+ readonly code: number;
64
+ readonly stderr: string;
65
+ readonly stdout: string;
66
+ constructor(args: string[], result: GitSpawnResult);
67
+ }
68
+
69
+ /**
70
+ * Skill & Agent v2 — Client GitClient (M3)
71
+ *
72
+ * Wraps `git` so all upstream traffic flows through the server's git
73
+ * proxy at `serverProxyBase`. See docs/architecture/skill-agent-v2-repo.md
74
+ * §5.4.
75
+ *
76
+ * Why a wrapper instead of using libgit2 directly:
77
+ * 1. `git` is already installed everywhere we run (extension host,
78
+ * CLI). No native deps.
79
+ * 2. Users can debug their skill repos with plain `git` commands when
80
+ * SERVICEME is misbehaving — the wrappers keep a familiar CLI.
81
+ * 3. libgit2's packfile negotiation has subtle correctness gaps that
82
+ * we don't want to debug server-side.
83
+ *
84
+ * URL rewrite:
85
+ * `https://github.com/owner/repo.git` → `<proxyBase>/<id>`
86
+ *
87
+ * When git hits `<proxyBase>/<id>` it discovers the proxy's
88
+ * `/info/refs?service=git-upload-pack` endpoint via the smart-HTTP
89
+ * protocol. From there the server takes over.
90
+ */
91
+ declare class GitClient {
92
+ private readonly serverProxyBase;
93
+ private readonly spawner;
94
+ constructor(opts: GitClientOptions);
95
+ /**
96
+ * Rewrite an upstream URL to its proxy form.
97
+ *
98
+ * Examples:
99
+ * rewriteRemoteUrl("medalsoftchina-ms-skills",
100
+ * "https://github.com/medalsoftchina/ms-skills.git")
101
+ * → "http://localhost:3000/git-proxy/medalsoftchina-ms-skills"
102
+ *
103
+ * rewriteRemoteUrl("my-team-internal",
104
+ * "git@github.com:medalsoftchina/ms-skills.git")
105
+ * → "http://localhost:3000/git-proxy/my-team-internal"
106
+ *
107
+ * The original URL's host/owner is intentionally DROPPED — the proxy
108
+ * knows the upstream from the per-repo config, not from the URL. This
109
+ * means callers can't accidentally route a user-repo URL through the
110
+ * default-repo proxy slot.
111
+ */
112
+ rewriteRemoteUrl(_repoId: string, _originalUrl: string): string;
113
+ /**
114
+ * `git clone <proxyUrl> <localPath>` — initialize a new local repo
115
+ * from the proxy. Returns when the clone succeeds; throws on failure.
116
+ */
117
+ clone(repoId: string, originalUrl: string, localPath: string): Promise<void>;
118
+ /**
119
+ * `git fetch <remote> <branch>` + return the resulting commit SHA.
120
+ * Operates on an already-cloned repo at `localPath`.
121
+ */
122
+ pull(repoId: string, localPath: string): Promise<PullResult>;
123
+ /**
124
+ * `git push origin <branch>` via the proxy. Caller is responsible
125
+ * for committing locally first. The server's GitProxy injects the
126
+ * PAT on the way upstream.
127
+ */
128
+ push(repoId: string, localPath: string, branch: string): Promise<PushResult>;
129
+ /**
130
+ * `git ls-remote <proxyUrl>` — list advertised branches without
131
+ * cloning. Used by addUserRepo to detect the default branch.
132
+ */
133
+ lsRemote(repoId: string, originalUrl: string): Promise<RemoteBranch[]>;
134
+ /**
135
+ * `git add <pathspec…>` followed by `git commit -m <message>`. The
136
+ * commit message convention follows the spec: `feat(skills): add
137
+ * <name>` / `feat(agents): add <name>`.
138
+ */
139
+ commit(localPath: string, message: string, addPath?: string): Promise<{
140
+ commitSha: string;
141
+ }>;
142
+ private runOrThrow;
143
+ private getRemoteUrl;
144
+ private safeRevParse;
145
+ private currentBranch;
146
+ }
147
+
148
+ declare class NodeGitSpawner implements GitSpawner {
149
+ spawn(args: string[], opts: {
150
+ cwd?: string;
151
+ }): Promise<GitSpawnResult>;
152
+ }
153
+ /** Convenience: a record-based stub spawner for tests. */
154
+ declare class StubGitSpawner implements GitSpawner {
155
+ /** queue of canned responses, consumed FIFO per spawn() call. */
156
+ readonly script: GitSpawnResult[];
157
+ /** All spawn() invocations, in order, for assertions. */
158
+ readonly calls: Array<{
159
+ args: string[];
160
+ cwd: string | undefined;
161
+ }>;
162
+ constructor(script: GitSpawnResult[]);
163
+ spawn(args: string[], opts: {
164
+ cwd?: string;
165
+ }): Promise<GitSpawnResult>;
166
+ }
167
+ /**
168
+ * Helper: encode a bare absolute file URL (`file:///...`) for local
169
+ * git clone tests. Not used by GitClient directly — exposed for
170
+ * RepoManager's `localSeed` use case.
171
+ */
172
+ declare function toFileUrl(absolutePath: string): string;
173
+
174
+ /**
175
+ * Skill & Agent v2 — SubmitClient Types (M4)
176
+ *
177
+ * Mirrors the server's POST /api/v1/skills/validate contract (M2
178
+ * SubmitApi). Defined here as a separate types file so the test
179
+ * fixtures + the client can both import without circular deps.
180
+ */
181
+ /** Reasons the server may deny a submission. */
182
+ type DenyReason = "repo_not_writable" | "file_too_large" | "path_traversal" | "invalid_frontmatter" | "name_conflict"
183
+ /** Local-client-side errors that don't come from the server. */
184
+ | "network_error" | "write_error" | "commit_error" | "push_error" | "unknown";
185
+ /** Request payload. Identical to the server's SubmitValidationRequest. */
186
+ interface SubmitValidationRequest {
187
+ repoId: string;
188
+ skillName: string;
189
+ files: Array<{
190
+ path: string;
191
+ content: string;
192
+ }>;
193
+ }
194
+ /** Response payload. Identical to the server's SubmitValidationResponse. */
195
+ interface SubmitValidationResponse {
196
+ allow: boolean;
197
+ reason?: DenyReason;
198
+ detail?: string;
199
+ }
200
+ /** Sentinel error thrown by SubmitClient.submit() / validate(). */
201
+ declare class SubmitError extends Error {
202
+ readonly reason: DenyReason;
203
+ readonly detail: string;
204
+ readonly status?: number;
205
+ constructor(reason: DenyReason, detail: string, options?: {
206
+ status?: number;
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Skill & Agent v2 — SubmitClient (M4)
212
+ *
213
+ * SubmitClient orchestrates the "validate then push" flow described
214
+ * in docs/architecture/skill-agent-v2-repo.md §5.7:
215
+ *
216
+ * 1. **validate** — POST the candidate files to the server's
217
+ * `/api/v1/skills/validate` endpoint (5 deny reasons: repo_not_
218
+ * writable, file_too_large, path_traversal, invalid_frontmatter,
219
+ * name_conflict).
220
+ * 2. **write** — materialize the files into the local repo clone at
221
+ * `~/.serviceme/repos/<repoId>/skills/<name>/` (or `agents/`).
222
+ * 3. **commit** — `git add . && git commit -m "feat(skills): add <name>"`.
223
+ * 4. **push** — `git push origin <branch>` via the server proxy.
224
+ *
225
+ * The client is intentionally thin: it does NOT do its own validation
226
+ * (the server is the gate), and it does NOT cache anything across
227
+ * calls. The single source of truth for "is this submission allowed?"
228
+ * is the server's validate endpoint.
229
+ *
230
+ * @see docs/architecture/skill-agent-v2-repo.md §5.7 SubmitClient
231
+ */
232
+ interface SubmitOptions {
233
+ /** Override the server's validate URL. Defaults to `http://localhost:3000/api/v1`. */
234
+ serverBaseUrl?: string;
235
+ /** Override the branch to push. Defaults to the repo's `branch` field. */
236
+ branch?: string;
237
+ /** Skip the actual push (for tests + dry-runs). When true, step 4 returns a synthetic PushResult. */
238
+ skipPush?: boolean;
239
+ }
240
+ interface SubmitResult {
241
+ repoId: string;
242
+ skillName: string;
243
+ commitSha: string;
244
+ pushedRef?: string;
245
+ pushedSha?: string;
246
+ }
247
+ interface SubmitClientOptions {
248
+ gitClient: GitClient;
249
+ /** Lookup the default branch for a repo (config-driven). */
250
+ getRepoBranch?: (repoId: string) => string | undefined;
251
+ /** HTTP fetch impl (defaults to the global `fetch`). */
252
+ fetcher?: typeof fetch;
253
+ /** Default server base URL when no override is supplied. */
254
+ defaultServerBaseUrl?: string;
255
+ }
256
+ declare class SubmitClient {
257
+ private readonly git;
258
+ private readonly getRepoBranch;
259
+ private readonly fetcher;
260
+ private readonly defaultServerBaseUrl;
261
+ constructor(opts: SubmitClientOptions);
262
+ /**
263
+ * Validate-only path. Useful for the UI's "Save Draft" flow which
264
+ * wants to surface validation errors without committing or pushing.
265
+ */
266
+ validate(req: SubmitValidationRequest): Promise<SubmitValidationResponse>;
267
+ /**
268
+ * Full submit pipeline. Throws `SubmitError` on:
269
+ * - validate deny (reason echoed)
270
+ * - network failure (network_error)
271
+ * - local write failure
272
+ * - commit/push failure
273
+ */
274
+ submit(repoId: string, skillName: string, files: SkillFile[], opts?: SubmitOptions): Promise<SubmitResult>;
275
+ }
276
+
277
+ export { GitClient as G, NodeGitSpawner as N, type PullResult as P, StubGitSpawner as S, GitError as a, SubmitClient as b, type SubmitClientOptions as c, SubmitError as d, type SubmitOptions as e, type SubmitResult as f, toFileUrl as t };