@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,3 @@
1
+ export { b as SubmitClient, c as SubmitClientOptions, d as SubmitError, e as SubmitOptions, f as SubmitResult } from './index-Dmyy4urr.mjs';
2
+ import './types-B9gk3dXH.mjs';
3
+ import 'node:child_process';
@@ -0,0 +1,3 @@
1
+ export { b as SubmitClient, c as SubmitClientOptions, d as SubmitError, e as SubmitOptions, f as SubmitResult } from './index-CrNC-aao.js';
2
+ import './types-B9gk3dXH.js';
3
+ import 'node:child_process';
package/dist/submit.js ADDED
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/submit/index.ts
31
+ var submit_exports = {};
32
+ __export(submit_exports, {
33
+ SubmitClient: () => SubmitClient,
34
+ SubmitError: () => SubmitError
35
+ });
36
+ module.exports = __toCommonJS(submit_exports);
37
+ var fs = __toESM(require("fs/promises"));
38
+ var path2 = __toESM(require("path"));
39
+
40
+ // src/paths/userHome.ts
41
+ var os = __toESM(require("os"));
42
+ var path = __toESM(require("path"));
43
+ var SERVICEME_DIR_NAME = ".serviceme";
44
+ var REPOS_SUBDIR = "repos";
45
+ var SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
46
+ var SERVICEME_HOME_ENV = "SERVICEME_HOME";
47
+ var activeOverrides = {};
48
+ function resolveHomeDir() {
49
+ const injected = activeOverrides.homeDir;
50
+ if (injected !== void 0) {
51
+ return injected;
52
+ }
53
+ return os.homedir();
54
+ }
55
+ function resolveServicemeHomeEnv() {
56
+ const injected = activeOverrides.servicemeHomeEnv;
57
+ if (injected !== void 0) {
58
+ return injected.length > 0 ? injected : void 0;
59
+ }
60
+ const envValue = process.env[SERVICEME_HOME_ENV];
61
+ return envValue && envValue.length > 0 ? envValue : void 0;
62
+ }
63
+ function assertSafeRepoId(repoId) {
64
+ if (typeof repoId !== "string" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {
65
+ throw new Error(
66
+ `Invalid repo id: ${JSON.stringify(repoId)}. Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then alphanumerics / underscores / hyphens, \u2264 64 chars).`
67
+ );
68
+ }
69
+ return repoId;
70
+ }
71
+ function getServicemeHome() {
72
+ const override = resolveServicemeHomeEnv();
73
+ if (override !== void 0) {
74
+ return path.resolve(override);
75
+ }
76
+ return path.join(resolveHomeDir(), SERVICEME_DIR_NAME);
77
+ }
78
+ function getReposDir() {
79
+ return path.join(getServicemeHome(), REPOS_SUBDIR);
80
+ }
81
+ function getRepoDir(repoId) {
82
+ return path.join(getReposDir(), assertSafeRepoId(repoId));
83
+ }
84
+
85
+ // src/submit/types.ts
86
+ var SubmitError = class extends Error {
87
+ constructor(reason, detail, options) {
88
+ super(`submit failed (${reason}): ${detail}`);
89
+ this.name = "SubmitError";
90
+ this.reason = reason;
91
+ this.detail = detail;
92
+ this.status = options?.status;
93
+ }
94
+ };
95
+
96
+ // src/submit/index.ts
97
+ var SubmitClient = class {
98
+ constructor(opts) {
99
+ this.git = opts.gitClient;
100
+ this.getRepoBranch = opts.getRepoBranch ?? (() => void 0);
101
+ this.fetcher = opts.fetcher ?? globalThis.fetch;
102
+ this.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? "http://localhost:3000";
103
+ }
104
+ /**
105
+ * Validate-only path. Useful for the UI's "Save Draft" flow which
106
+ * wants to surface validation errors without committing or pushing.
107
+ */
108
+ async validate(req) {
109
+ const url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;
110
+ const res = await this.fetcher(url, {
111
+ method: "POST",
112
+ headers: { "content-type": "application/json" },
113
+ body: JSON.stringify(req)
114
+ });
115
+ if (!res.ok) {
116
+ throw new SubmitError("network_error", `validate request failed: HTTP ${res.status}`);
117
+ }
118
+ return await res.json();
119
+ }
120
+ /**
121
+ * Full submit pipeline. Throws `SubmitError` on:
122
+ * - validate deny (reason echoed)
123
+ * - network failure (network_error)
124
+ * - local write failure
125
+ * - commit/push failure
126
+ */
127
+ async submit(repoId, skillName, files, opts = {}) {
128
+ const v = await this.validate({ repoId, skillName, files });
129
+ if (!v.allow) {
130
+ throw new SubmitError(v.reason ?? "unknown", v.detail ?? "validation denied");
131
+ }
132
+ const localRepoPath = getRepoDir(repoId);
133
+ const targetDir = path2.join(localRepoPath, "skills", skillName);
134
+ await fs.mkdir(targetDir, { recursive: true });
135
+ for (const f of files) {
136
+ const full = path2.join(targetDir, f.path);
137
+ await fs.mkdir(path2.dirname(full), { recursive: true });
138
+ const tmp = `${full}.${process.pid}.${Date.now()}.tmp`;
139
+ await fs.writeFile(tmp, f.content, "utf8");
140
+ await fs.rename(tmp, full);
141
+ }
142
+ const commitMessage = `feat(skills): add ${skillName}`;
143
+ const { commitSha } = await this.git.commit(localRepoPath, commitMessage);
144
+ let pushedRef;
145
+ let pushedSha;
146
+ if (!opts.skipPush) {
147
+ const branch = opts.branch ?? this.getRepoBranch(repoId) ?? "main";
148
+ const pushResult = await this.git.push(repoId, localRepoPath, branch);
149
+ pushedRef = pushResult.ref;
150
+ pushedSha = pushResult.commitSha;
151
+ }
152
+ return {
153
+ repoId,
154
+ skillName,
155
+ commitSha,
156
+ pushedRef,
157
+ pushedSha
158
+ };
159
+ }
160
+ };
161
+ // Annotate the CommonJS export names for ESM import in node:
162
+ 0 && (module.exports = {
163
+ SubmitClient,
164
+ SubmitError
165
+ });
166
+ //# sourceMappingURL=submit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/submit/index.ts","../src/paths/userHome.ts","../src/submit/types.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport type { GitClient } from \"../git-client\";\nimport { getRepoDir } from \"../paths/userHome\";\nimport type { SkillFile } from \"../skill-store/types\";\nimport type { SubmitValidationRequest, SubmitValidationResponse } from \"./types\";\nimport { SubmitError } from \"./types\";\n\n// Re-export so consumers (bridge handlers, CLI commands) can detect\n// SubmitError via `instanceof` without reaching into the internal\n// `./types` module.\nexport { SubmitError } from \"./types\";\n\n/**\n * Skill & Agent v2 — SubmitClient (M4)\n *\n * SubmitClient orchestrates the \"validate then push\" flow described\n * in docs/architecture/skill-agent-v2-repo.md §5.7:\n *\n * 1. **validate** — POST the candidate files to the server's\n * `/api/v1/skills/validate` endpoint (5 deny reasons: repo_not_\n * writable, file_too_large, path_traversal, invalid_frontmatter,\n * name_conflict).\n * 2. **write** — materialize the files into the local repo clone at\n * `~/.serviceme/repos/<repoId>/skills/<name>/` (or `agents/`).\n * 3. **commit** — `git add . && git commit -m \"feat(skills): add <name>\"`.\n * 4. **push** — `git push origin <branch>` via the server proxy.\n *\n * The client is intentionally thin: it does NOT do its own validation\n * (the server is the gate), and it does NOT cache anything across\n * calls. The single source of truth for \"is this submission allowed?\"\n * is the server's validate endpoint.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.7 SubmitClient\n */\n\nexport interface SubmitOptions {\n\t/** Override the server's validate URL. Defaults to `http://localhost:3000/api/v1`. */\n\tserverBaseUrl?: string;\n\t/** Override the branch to push. Defaults to the repo's `branch` field. */\n\tbranch?: string;\n\t/** Skip the actual push (for tests + dry-runs). When true, step 4 returns a synthetic PushResult. */\n\tskipPush?: boolean;\n}\n\nexport interface SubmitResult {\n\trepoId: string;\n\tskillName: string;\n\tcommitSha: string;\n\tpushedRef?: string;\n\tpushedSha?: string;\n}\n\nexport interface SubmitClientOptions {\n\tgitClient: GitClient;\n\t/** Lookup the default branch for a repo (config-driven). */\n\tgetRepoBranch?: (repoId: string) => string | undefined;\n\t/** HTTP fetch impl (defaults to the global `fetch`). */\n\tfetcher?: typeof fetch;\n\t/** Default server base URL when no override is supplied. */\n\tdefaultServerBaseUrl?: string;\n}\n\nexport class SubmitClient {\n\tprivate readonly git: GitClient;\n\tprivate readonly getRepoBranch: (repoId: string) => string | undefined;\n\tprivate readonly fetcher: typeof fetch;\n\tprivate readonly defaultServerBaseUrl: string;\n\n\tconstructor(opts: SubmitClientOptions) {\n\t\tthis.git = opts.gitClient;\n\t\tthis.getRepoBranch = opts.getRepoBranch ?? (() => undefined);\n\t\tthis.fetcher = opts.fetcher ?? (globalThis.fetch as typeof fetch);\n\t\tthis.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? \"http://localhost:3000\";\n\t}\n\n\t/**\n\t * Validate-only path. Useful for the UI's \"Save Draft\" flow which\n\t * wants to surface validation errors without committing or pushing.\n\t */\n\tasync validate(req: SubmitValidationRequest): Promise<SubmitValidationResponse> {\n\t\tconst url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;\n\t\tconst res = await this.fetcher(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify(req),\n\t\t});\n\t\tif (!res.ok) {\n\t\t\tthrow new SubmitError(\"network_error\", `validate request failed: HTTP ${res.status}`);\n\t\t}\n\t\treturn (await res.json()) as SubmitValidationResponse;\n\t}\n\n\t/**\n\t * Full submit pipeline. Throws `SubmitError` on:\n\t * - validate deny (reason echoed)\n\t * - network failure (network_error)\n\t * - local write failure\n\t * - commit/push failure\n\t */\n\tasync submit(\n\t\trepoId: string,\n\t\tskillName: string,\n\t\tfiles: SkillFile[],\n\t\topts: SubmitOptions = {}\n\t): Promise<SubmitResult> {\n\t\t// (1) Validate\n\t\tconst v = await this.validate({ repoId, skillName, files });\n\t\tif (!v.allow) {\n\t\t\tthrow new SubmitError(v.reason ?? \"unknown\", v.detail ?? \"validation denied\");\n\t\t}\n\n\t\t// (2) Write files into the local repo clone\n\t\tconst localRepoPath = getRepoDir(repoId);\n\t\tconst targetDir = path.join(localRepoPath, \"skills\", skillName);\n\t\tawait fs.mkdir(targetDir, { recursive: true });\n\t\tfor (const f of files) {\n\t\t\tconst full = path.join(targetDir, f.path);\n\t\t\tawait fs.mkdir(path.dirname(full), { recursive: true });\n\t\t\tconst tmp = `${full}.${process.pid}.${Date.now()}.tmp`;\n\t\t\tawait fs.writeFile(tmp, f.content, \"utf8\");\n\t\t\tawait fs.rename(tmp, full);\n\t\t}\n\n\t\t// (3) Commit (convention: `feat(skills): add <name>`)\n\t\tconst commitMessage = `feat(skills): add ${skillName}`;\n\t\tconst { commitSha } = await this.git.commit(localRepoPath, commitMessage);\n\n\t\t// (4) Push\n\t\tlet pushedRef: string | undefined;\n\t\tlet pushedSha: string | undefined;\n\t\tif (!opts.skipPush) {\n\t\t\tconst branch = opts.branch ?? this.getRepoBranch(repoId) ?? \"main\";\n\t\t\tconst pushResult = await this.git.push(repoId, localRepoPath, branch);\n\t\t\tpushedRef = pushResult.ref;\n\t\t\tpushedSha = pushResult.commitSha;\n\t\t}\n\n\t\treturn {\n\t\t\trepoId,\n\t\t\tskillName,\n\t\t\tcommitSha,\n\t\t\tpushedRef,\n\t\t\tpushedSha,\n\t\t};\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 * Skill & Agent v2 — SubmitClient Types (M4)\n *\n * Mirrors the server's POST /api/v1/skills/validate contract (M2\n * SubmitApi). Defined here as a separate types file so the test\n * fixtures + the client can both import without circular deps.\n */\n\n/** Reasons the server may deny a submission. */\nexport type DenyReason =\n\t| \"repo_not_writable\"\n\t| \"file_too_large\"\n\t| \"path_traversal\"\n\t| \"invalid_frontmatter\"\n\t| \"name_conflict\"\n\t/** Local-client-side errors that don't come from the server. */\n\t| \"network_error\"\n\t| \"write_error\"\n\t| \"commit_error\"\n\t| \"push_error\"\n\t| \"unknown\";\n\n/** Request payload. Identical to the server's SubmitValidationRequest. */\nexport interface SubmitValidationRequest {\n\trepoId: string;\n\tskillName: string;\n\tfiles: Array<{ path: string; content: string }>;\n}\n\n/** Response payload. Identical to the server's SubmitValidationResponse. */\nexport interface SubmitValidationResponse {\n\tallow: boolean;\n\treason?: DenyReason;\n\tdetail?: string;\n}\n\n/** Sentinel error thrown by SubmitClient.submit() / validate(). */\nexport class SubmitError extends Error {\n\treadonly reason: DenyReason;\n\treadonly detail: string;\n\treadonly status?: number;\n\n\tconstructor(reason: DenyReason, detail: string, options?: { status?: number }) {\n\t\tsuper(`submit failed (${reason}): ${detail}`);\n\t\tthis.name = \"SubmitError\";\n\t\tthis.reason = reason;\n\t\tthis.detail = detail;\n\t\tthis.status = options?.status;\n\t}\n}\n"],"mappings":"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","names":["path"]}
@@ -0,0 +1,130 @@
1
+ // src/submit/index.ts
2
+ import * as fs from "fs/promises";
3
+ import * as path2 from "path";
4
+
5
+ // src/paths/userHome.ts
6
+ import * as os from "os";
7
+ import * as path from "path";
8
+ var SERVICEME_DIR_NAME = ".serviceme";
9
+ var REPOS_SUBDIR = "repos";
10
+ var SAFE_REPO_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
11
+ var SERVICEME_HOME_ENV = "SERVICEME_HOME";
12
+ var activeOverrides = {};
13
+ function resolveHomeDir() {
14
+ const injected = activeOverrides.homeDir;
15
+ if (injected !== void 0) {
16
+ return injected;
17
+ }
18
+ return os.homedir();
19
+ }
20
+ function resolveServicemeHomeEnv() {
21
+ const injected = activeOverrides.servicemeHomeEnv;
22
+ if (injected !== void 0) {
23
+ return injected.length > 0 ? injected : void 0;
24
+ }
25
+ const envValue = process.env[SERVICEME_HOME_ENV];
26
+ return envValue && envValue.length > 0 ? envValue : void 0;
27
+ }
28
+ function assertSafeRepoId(repoId) {
29
+ if (typeof repoId !== "string" || repoId.length === 0 || !SAFE_REPO_ID_PATTERN.test(repoId)) {
30
+ throw new Error(
31
+ `Invalid repo id: ${JSON.stringify(repoId)}. Must match ${SAFE_REPO_ID_PATTERN} (alphanumeric start, then alphanumerics / underscores / hyphens, \u2264 64 chars).`
32
+ );
33
+ }
34
+ return repoId;
35
+ }
36
+ function getServicemeHome() {
37
+ const override = resolveServicemeHomeEnv();
38
+ if (override !== void 0) {
39
+ return path.resolve(override);
40
+ }
41
+ return path.join(resolveHomeDir(), SERVICEME_DIR_NAME);
42
+ }
43
+ function getReposDir() {
44
+ return path.join(getServicemeHome(), REPOS_SUBDIR);
45
+ }
46
+ function getRepoDir(repoId) {
47
+ return path.join(getReposDir(), assertSafeRepoId(repoId));
48
+ }
49
+
50
+ // src/submit/types.ts
51
+ var SubmitError = class extends Error {
52
+ constructor(reason, detail, options) {
53
+ super(`submit failed (${reason}): ${detail}`);
54
+ this.name = "SubmitError";
55
+ this.reason = reason;
56
+ this.detail = detail;
57
+ this.status = options?.status;
58
+ }
59
+ };
60
+
61
+ // src/submit/index.ts
62
+ var SubmitClient = class {
63
+ constructor(opts) {
64
+ this.git = opts.gitClient;
65
+ this.getRepoBranch = opts.getRepoBranch ?? (() => void 0);
66
+ this.fetcher = opts.fetcher ?? globalThis.fetch;
67
+ this.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? "http://localhost:3000";
68
+ }
69
+ /**
70
+ * Validate-only path. Useful for the UI's "Save Draft" flow which
71
+ * wants to surface validation errors without committing or pushing.
72
+ */
73
+ async validate(req) {
74
+ const url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;
75
+ const res = await this.fetcher(url, {
76
+ method: "POST",
77
+ headers: { "content-type": "application/json" },
78
+ body: JSON.stringify(req)
79
+ });
80
+ if (!res.ok) {
81
+ throw new SubmitError("network_error", `validate request failed: HTTP ${res.status}`);
82
+ }
83
+ return await res.json();
84
+ }
85
+ /**
86
+ * Full submit pipeline. Throws `SubmitError` on:
87
+ * - validate deny (reason echoed)
88
+ * - network failure (network_error)
89
+ * - local write failure
90
+ * - commit/push failure
91
+ */
92
+ async submit(repoId, skillName, files, opts = {}) {
93
+ const v = await this.validate({ repoId, skillName, files });
94
+ if (!v.allow) {
95
+ throw new SubmitError(v.reason ?? "unknown", v.detail ?? "validation denied");
96
+ }
97
+ const localRepoPath = getRepoDir(repoId);
98
+ const targetDir = path2.join(localRepoPath, "skills", skillName);
99
+ await fs.mkdir(targetDir, { recursive: true });
100
+ for (const f of files) {
101
+ const full = path2.join(targetDir, f.path);
102
+ await fs.mkdir(path2.dirname(full), { recursive: true });
103
+ const tmp = `${full}.${process.pid}.${Date.now()}.tmp`;
104
+ await fs.writeFile(tmp, f.content, "utf8");
105
+ await fs.rename(tmp, full);
106
+ }
107
+ const commitMessage = `feat(skills): add ${skillName}`;
108
+ const { commitSha } = await this.git.commit(localRepoPath, commitMessage);
109
+ let pushedRef;
110
+ let pushedSha;
111
+ if (!opts.skipPush) {
112
+ const branch = opts.branch ?? this.getRepoBranch(repoId) ?? "main";
113
+ const pushResult = await this.git.push(repoId, localRepoPath, branch);
114
+ pushedRef = pushResult.ref;
115
+ pushedSha = pushResult.commitSha;
116
+ }
117
+ return {
118
+ repoId,
119
+ skillName,
120
+ commitSha,
121
+ pushedRef,
122
+ pushedSha
123
+ };
124
+ }
125
+ };
126
+ export {
127
+ SubmitClient,
128
+ SubmitError
129
+ };
130
+ //# sourceMappingURL=submit.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/submit/index.ts","../src/paths/userHome.ts","../src/submit/types.ts"],"sourcesContent":["import * as fs from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport type { GitClient } from \"../git-client\";\nimport { getRepoDir } from \"../paths/userHome\";\nimport type { SkillFile } from \"../skill-store/types\";\nimport type { SubmitValidationRequest, SubmitValidationResponse } from \"./types\";\nimport { SubmitError } from \"./types\";\n\n// Re-export so consumers (bridge handlers, CLI commands) can detect\n// SubmitError via `instanceof` without reaching into the internal\n// `./types` module.\nexport { SubmitError } from \"./types\";\n\n/**\n * Skill & Agent v2 — SubmitClient (M4)\n *\n * SubmitClient orchestrates the \"validate then push\" flow described\n * in docs/architecture/skill-agent-v2-repo.md §5.7:\n *\n * 1. **validate** — POST the candidate files to the server's\n * `/api/v1/skills/validate` endpoint (5 deny reasons: repo_not_\n * writable, file_too_large, path_traversal, invalid_frontmatter,\n * name_conflict).\n * 2. **write** — materialize the files into the local repo clone at\n * `~/.serviceme/repos/<repoId>/skills/<name>/` (or `agents/`).\n * 3. **commit** — `git add . && git commit -m \"feat(skills): add <name>\"`.\n * 4. **push** — `git push origin <branch>` via the server proxy.\n *\n * The client is intentionally thin: it does NOT do its own validation\n * (the server is the gate), and it does NOT cache anything across\n * calls. The single source of truth for \"is this submission allowed?\"\n * is the server's validate endpoint.\n *\n * @see docs/architecture/skill-agent-v2-repo.md §5.7 SubmitClient\n */\n\nexport interface SubmitOptions {\n\t/** Override the server's validate URL. Defaults to `http://localhost:3000/api/v1`. */\n\tserverBaseUrl?: string;\n\t/** Override the branch to push. Defaults to the repo's `branch` field. */\n\tbranch?: string;\n\t/** Skip the actual push (for tests + dry-runs). When true, step 4 returns a synthetic PushResult. */\n\tskipPush?: boolean;\n}\n\nexport interface SubmitResult {\n\trepoId: string;\n\tskillName: string;\n\tcommitSha: string;\n\tpushedRef?: string;\n\tpushedSha?: string;\n}\n\nexport interface SubmitClientOptions {\n\tgitClient: GitClient;\n\t/** Lookup the default branch for a repo (config-driven). */\n\tgetRepoBranch?: (repoId: string) => string | undefined;\n\t/** HTTP fetch impl (defaults to the global `fetch`). */\n\tfetcher?: typeof fetch;\n\t/** Default server base URL when no override is supplied. */\n\tdefaultServerBaseUrl?: string;\n}\n\nexport class SubmitClient {\n\tprivate readonly git: GitClient;\n\tprivate readonly getRepoBranch: (repoId: string) => string | undefined;\n\tprivate readonly fetcher: typeof fetch;\n\tprivate readonly defaultServerBaseUrl: string;\n\n\tconstructor(opts: SubmitClientOptions) {\n\t\tthis.git = opts.gitClient;\n\t\tthis.getRepoBranch = opts.getRepoBranch ?? (() => undefined);\n\t\tthis.fetcher = opts.fetcher ?? (globalThis.fetch as typeof fetch);\n\t\tthis.defaultServerBaseUrl = opts.defaultServerBaseUrl ?? \"http://localhost:3000\";\n\t}\n\n\t/**\n\t * Validate-only path. Useful for the UI's \"Save Draft\" flow which\n\t * wants to surface validation errors without committing or pushing.\n\t */\n\tasync validate(req: SubmitValidationRequest): Promise<SubmitValidationResponse> {\n\t\tconst url = `${this.defaultServerBaseUrl}/api/v1/skills/validate`;\n\t\tconst res = await this.fetcher(url, {\n\t\t\tmethod: \"POST\",\n\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\tbody: JSON.stringify(req),\n\t\t});\n\t\tif (!res.ok) {\n\t\t\tthrow new SubmitError(\"network_error\", `validate request failed: HTTP ${res.status}`);\n\t\t}\n\t\treturn (await res.json()) as SubmitValidationResponse;\n\t}\n\n\t/**\n\t * Full submit pipeline. Throws `SubmitError` on:\n\t * - validate deny (reason echoed)\n\t * - network failure (network_error)\n\t * - local write failure\n\t * - commit/push failure\n\t */\n\tasync submit(\n\t\trepoId: string,\n\t\tskillName: string,\n\t\tfiles: SkillFile[],\n\t\topts: SubmitOptions = {}\n\t): Promise<SubmitResult> {\n\t\t// (1) Validate\n\t\tconst v = await this.validate({ repoId, skillName, files });\n\t\tif (!v.allow) {\n\t\t\tthrow new SubmitError(v.reason ?? \"unknown\", v.detail ?? \"validation denied\");\n\t\t}\n\n\t\t// (2) Write files into the local repo clone\n\t\tconst localRepoPath = getRepoDir(repoId);\n\t\tconst targetDir = path.join(localRepoPath, \"skills\", skillName);\n\t\tawait fs.mkdir(targetDir, { recursive: true });\n\t\tfor (const f of files) {\n\t\t\tconst full = path.join(targetDir, f.path);\n\t\t\tawait fs.mkdir(path.dirname(full), { recursive: true });\n\t\t\tconst tmp = `${full}.${process.pid}.${Date.now()}.tmp`;\n\t\t\tawait fs.writeFile(tmp, f.content, \"utf8\");\n\t\t\tawait fs.rename(tmp, full);\n\t\t}\n\n\t\t// (3) Commit (convention: `feat(skills): add <name>`)\n\t\tconst commitMessage = `feat(skills): add ${skillName}`;\n\t\tconst { commitSha } = await this.git.commit(localRepoPath, commitMessage);\n\n\t\t// (4) Push\n\t\tlet pushedRef: string | undefined;\n\t\tlet pushedSha: string | undefined;\n\t\tif (!opts.skipPush) {\n\t\t\tconst branch = opts.branch ?? this.getRepoBranch(repoId) ?? \"main\";\n\t\t\tconst pushResult = await this.git.push(repoId, localRepoPath, branch);\n\t\t\tpushedRef = pushResult.ref;\n\t\t\tpushedSha = pushResult.commitSha;\n\t\t}\n\n\t\treturn {\n\t\t\trepoId,\n\t\t\tskillName,\n\t\t\tcommitSha,\n\t\t\tpushedRef,\n\t\t\tpushedSha,\n\t\t};\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 * Skill & Agent v2 — SubmitClient Types (M4)\n *\n * Mirrors the server's POST /api/v1/skills/validate contract (M2\n * SubmitApi). Defined here as a separate types file so the test\n * fixtures + the client can both import without circular deps.\n */\n\n/** Reasons the server may deny a submission. */\nexport type DenyReason =\n\t| \"repo_not_writable\"\n\t| \"file_too_large\"\n\t| \"path_traversal\"\n\t| \"invalid_frontmatter\"\n\t| \"name_conflict\"\n\t/** Local-client-side errors that don't come from the server. */\n\t| \"network_error\"\n\t| \"write_error\"\n\t| \"commit_error\"\n\t| \"push_error\"\n\t| \"unknown\";\n\n/** Request payload. Identical to the server's SubmitValidationRequest. */\nexport interface SubmitValidationRequest {\n\trepoId: string;\n\tskillName: string;\n\tfiles: Array<{ path: string; content: string }>;\n}\n\n/** Response payload. Identical to the server's SubmitValidationResponse. */\nexport interface SubmitValidationResponse {\n\tallow: boolean;\n\treason?: DenyReason;\n\tdetail?: string;\n}\n\n/** Sentinel error thrown by SubmitClient.submit() / validate(). */\nexport class SubmitError extends Error {\n\treadonly reason: DenyReason;\n\treadonly detail: string;\n\treadonly status?: number;\n\n\tconstructor(reason: DenyReason, detail: string, options?: { status?: number }) {\n\t\tsuper(`submit failed (${reason}): ${detail}`);\n\t\tthis.name = \"SubmitError\";\n\t\tthis.reason = reason;\n\t\tthis.detail = detail;\n\t\tthis.status = options?.status;\n\t}\n}\n"],"mappings":"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","names":["path"]}
@@ -0,0 +1,240 @@
1
+ import { ExternalTool, ToolboxScope, ToolboxList } from '@serviceme/devtools-protocol';
2
+
3
+ /**
4
+ * toolbox sort + dedup — pure helpers, no I/O.
5
+ *
6
+ * The Extension's `ToolBoxService` keeps a stable `order` index and
7
+ * surfaces "recently used" via the `lastUsedAt` ISO timestamp. These
8
+ * helpers codify that algorithm so the CLI and Extension agree on the
9
+ * "最近使用置顶" UX.
10
+ *
11
+ * Refs:
12
+ * - 4.功能规划.md §2.3 — `sort.ts 排序与去重纯函数`
13
+ */
14
+
15
+ /**
16
+ * Sort by "recently used" — entries with newer `lastUsedAt` float to
17
+ * the top, entries with no `lastUsedAt` sort to the bottom (preserving
18
+ * their relative `order` index when both are absent).
19
+ *
20
+ * Ties (same `lastUsedAt` or both missing) are broken by `order`, then
21
+ * by `id` for determinism.
22
+ */
23
+ declare function sortByRecentFirst(tools: readonly ExternalTool[]): ExternalTool[];
24
+ /**
25
+ * Sort by user-defined `order` field. Entries without `order` are
26
+ * appended in their input order (stable sort via `id` tiebreaker).
27
+ */
28
+ declare function sortByUserOrder(tools: readonly ExternalTool[]): ExternalTool[];
29
+ /**
30
+ * Merge built-in defaults with user-stored tools. Defaults keep
31
+ * `isDefault: true` and their `order`; user tools are appended with
32
+ * their stored metadata. Output is sorted by user order.
33
+ */
34
+ declare function mergeWithDefaults(defaults: readonly ExternalTool[], userTools: readonly ExternalTool[]): ExternalTool[];
35
+ /**
36
+ * Re-number `order` for a list of tool ids. Used by
37
+ * `toolbox.update` when the user drags-and-drops entries in the UI.
38
+ */
39
+ declare function reindexOrder(tools: ExternalTool[], newOrderIds: readonly string[]): ExternalTool[];
40
+ /**
41
+ * Stamp `lastUsedAt` on the targeted tool (clones the array). Returns
42
+ * a new array; the input is left untouched.
43
+ */
44
+ declare function touchLastUsedAt(tools: readonly ExternalTool[], id: string, when?: Date): ExternalTool[];
45
+
46
+ /**
47
+ * toolbox types — internal-only data shapes.
48
+ *
49
+ * The public data model lives in `@serviceme/devtools-protocol/toolbox`
50
+ * (`ExternalTool`, `ToolboxScope`, `ToolboxList`). The types below are
51
+ * the persisted on-disk shape and the patch helpers, scoped to the
52
+ * toolbox domain only.
53
+ *
54
+ * Refs:
55
+ * - 4.功能规划.md §2.3 — `toolbox/types.ts`
56
+ */
57
+
58
+ /** Schema version of the on-disk toolbox JSON files. Bumped on breaking changes. */
59
+ declare const TOOLBOX_JSON_SCHEMA_VERSION = 1;
60
+ interface PersistedToolbox {
61
+ version: number;
62
+ tools: ExternalTool[];
63
+ }
64
+ /** Patch payload accepted by `ToolboxCore.update` (mirrors `ExternalToolPatch`). */
65
+ type ToolboxPatch = Partial<Omit<ExternalTool, "id" | "isDefault">>;
66
+ /**
67
+ * Built-in (default) tool seeds. Mirrors the Extension's
68
+ * `apps/extension/src/config/external-tools.ts`. The Core stores these
69
+ * verbatim and never mutates them — the Extension's `ToolBoxService`
70
+ * runs the same merge on top of the Core's view.
71
+ */
72
+ interface DefaultToolSeed {
73
+ id: string;
74
+ name: string;
75
+ description: string;
76
+ icon: string;
77
+ url: string;
78
+ }
79
+ /** Built-in tools rendered when the JSON file is missing or empty. */
80
+ declare const BUILTIN_DEFAULT_TOOLS: readonly DefaultToolSeed[];
81
+ /** Resolved toolbox shape returned by `ToolboxStore.read()`. */
82
+ interface ResolvedToolbox {
83
+ scope: ToolboxScope;
84
+ tools: ExternalTool[];
85
+ }
86
+
87
+ /**
88
+ * ToolboxStore — JSON persistence for the toolbox entries.
89
+ *
90
+ * Two scopes:
91
+ * - `user` → `~/.serviceme/toolbox.json`
92
+ * - `workspace` → `<cwd>/.github/.serviceme-toolbox.json`
93
+ *
94
+ * Both files share the `PersistedToolbox` shape and the same atomic
95
+ * write pattern (tmp + rename + fsync) as `IdentityStore`. Concurrent
96
+ * writers are serialized via a mkdir-based file lock (Phase 6+ may
97
+ * upgrade to `proper-lockfile`).
98
+ *
99
+ * Refs:
100
+ * - 4.功能规划.md §2.3 — `ToolboxStore.ts JSON 持久化(user + workspace scope)`
101
+ * - `3.功能拆分.md` §3 — toolbox wire shape
102
+ */
103
+
104
+ declare const WORKSPACE_TOOLBOX_RELATIVE_PATH: string;
105
+ interface ToolboxFileBackend {
106
+ read(filePath: string): Promise<PersistedToolbox | null>;
107
+ write(filePath: string, payload: PersistedToolbox): Promise<void>;
108
+ exists(filePath: string): Promise<boolean>;
109
+ }
110
+ interface ToolboxStoreOptions {
111
+ /** Override the user-scope file path (default: `getToolboxJsonPath()`). */
112
+ userFilePath?: string;
113
+ /** Override the workspace-scope file path resolver (default: cwd-relative). */
114
+ resolveWorkspacePath?: () => string | null;
115
+ /** Injectable built-in tool seeds (default: `BUILTIN_DEFAULT_TOOLS`). */
116
+ defaultTools?: readonly DefaultToolSeed[];
117
+ hooks?: ToolboxStoreHooks;
118
+ backend?: ToolboxFileBackend;
119
+ lockTimeoutMs?: number;
120
+ lockRetryMs?: number;
121
+ }
122
+ interface ToolboxStoreHooks {
123
+ beforeWrite?: (scope: ToolboxScope, payload: PersistedToolbox) => void | Promise<void>;
124
+ afterWrite?: (scope: ToolboxScope, payload: PersistedToolbox) => void | Promise<void>;
125
+ }
126
+ declare class FsToolboxFileBackend implements ToolboxFileBackend {
127
+ exists(filePath: string): Promise<boolean>;
128
+ read(filePath: string): Promise<PersistedToolbox | null>;
129
+ private backupCorruptedFile;
130
+ write(filePath: string, payload: PersistedToolbox): Promise<void>;
131
+ }
132
+ declare class ToolboxStore {
133
+ private readonly userFilePath;
134
+ private readonly resolveWorkspacePath;
135
+ private readonly defaults;
136
+ private readonly hooks;
137
+ private readonly backend;
138
+ private readonly lockTimeoutMs;
139
+ private readonly lockRetryMs;
140
+ constructor(opts?: ToolboxStoreOptions);
141
+ /** Read a scope; returns the resolved toolbox (with defaults merged when empty). */
142
+ read(scope: ToolboxScope): Promise<ResolvedToolbox>;
143
+ /**
144
+ * Atomic write under a file lock. Replaces the entire `tools` array
145
+ * with the provided snapshot.
146
+ */
147
+ write(scope: ToolboxScope, tools: readonly ExternalTool[]): Promise<void>;
148
+ /**
149
+ * Read-modify-write under the file lock. The mutator receives the
150
+ * current user-only list (defaults not included) and returns the
151
+ * replacement list. Throwing inside the mutator aborts the write.
152
+ */
153
+ mutate(scope: ToolboxScope, mutator: (current: ExternalTool[]) => Promise<ExternalTool[]>): Promise<ExternalTool[]>;
154
+ /** Wipe a scope entirely (used by `toolbox.remove --all` extensions). */
155
+ clear(scope: ToolboxScope): Promise<void>;
156
+ /** Test seam — resolve the user-scope file path. */
157
+ getUserFilePath(): string;
158
+ /** Test seam — resolve the workspace-scope file path (or null when disabled). */
159
+ getWorkspaceFilePath(): string | null;
160
+ private filePathFor;
161
+ }
162
+
163
+ /**
164
+ * ToolboxCore — Main entry for the toolbox domain.
165
+ *
166
+ * Orchestrates the user + workspace scopes via `ToolboxStore`, applies
167
+ * the "最近使用置顶" sort, and exposes a small surface that mirrors
168
+ * the Phase 5.1 bridge methods:
169
+ *
170
+ * - `list(scope?)` → `toolbox.list`
171
+ * - `add(tool, scope)` → `toolbox.add`
172
+ * - `remove(id, scope?)` → `toolbox.remove`
173
+ * - `update(id, patch, scope?)` → `toolbox.update`
174
+ *
175
+ * Default tools (built-in seeds) are never user-deletable. `remove()`
176
+ * is a no-op for default tools and surfaces a structured error so
177
+ * callers can map it to `TOOLBOX_DEFAULT_IMMUTABLE` (Phase 5.3 error
178
+ * code).
179
+ *
180
+ * Refs:
181
+ * - 4.功能规划.md §2.3 — `ToolboxCore.ts 主入口`
182
+ * - `3.功能拆分.md` §3 — wire shape
183
+ */
184
+
185
+ /** Sentinel — caller tried to remove a built-in (immutable) tool. */
186
+ declare class DefaultToolImmutableError extends Error {
187
+ readonly toolId: string;
188
+ constructor(toolId: string);
189
+ }
190
+ interface ToolboxCoreOptions {
191
+ store?: ToolboxStore;
192
+ defaultTools?: readonly DefaultToolSeed[];
193
+ /** Sort applied to the merged list returned by `list()`. Default: `sortByUserOrder`. */
194
+ listSort?: (tools: readonly ExternalTool[]) => ExternalTool[];
195
+ }
196
+ declare class ToolboxCore {
197
+ private readonly store;
198
+ private readonly defaults;
199
+ private readonly listSort;
200
+ constructor(opts?: ToolboxCoreOptions);
201
+ /** List all tools in a scope. Built-in defaults are merged in. */
202
+ list(scope?: ToolboxScope): Promise<ToolboxList>;
203
+ /**
204
+ * Append a new tool to the requested scope. Default tools are
205
+ * rejected (they are seeds, not user entries).
206
+ */
207
+ add(tool: ExternalTool, scope?: ToolboxScope): Promise<ToolboxList>;
208
+ /**
209
+ * Remove a tool by id. Returns `success: false` when the id is a
210
+ * built-in default (idempotent, never throws on missing entries).
211
+ */
212
+ remove(id: string, scope?: ToolboxScope): Promise<{
213
+ scope: ToolboxScope;
214
+ toolId: string;
215
+ success: boolean;
216
+ }>;
217
+ /**
218
+ * Patch a tool by id. Default tools can only have their `order`
219
+ * updated; other patches are silently ignored for default entries
220
+ * (callers can compare before/after to detect the ignore).
221
+ */
222
+ update(id: string, patch: ToolboxPatch, scope?: ToolboxScope): Promise<ToolboxList>;
223
+ /**
224
+ * Stamp `lastUsedAt` on the targeted tool. This is the "recently
225
+ * used" hook the Extension's webview uses when a user clicks a
226
+ * toolbox entry.
227
+ */
228
+ recordUsage(id: string, scope?: ToolboxScope, when?: Date): Promise<ExternalTool | null>;
229
+ /**
230
+ * Combined view: user + workspace scopes merged, sorted by recent
231
+ * usage. Workspace tools overlay user tools (workspace entries win
232
+ * on `id` collision).
233
+ */
234
+ listMerged(): Promise<ToolboxList>;
235
+ /** Expose the underlying store (CLI / Bridge use it for path-level access). */
236
+ getStore(): ToolboxStore;
237
+ private isDefaultId;
238
+ }
239
+
240
+ export { BUILTIN_DEFAULT_TOOLS, DefaultToolImmutableError, type DefaultToolSeed, FsToolboxFileBackend, type PersistedToolbox, type ResolvedToolbox, TOOLBOX_JSON_SCHEMA_VERSION, ToolboxCore, type ToolboxCoreOptions, type ToolboxFileBackend, type ToolboxPatch, ToolboxStore, type ToolboxStoreHooks, type ToolboxStoreOptions, WORKSPACE_TOOLBOX_RELATIVE_PATH, mergeWithDefaults, reindexOrder, sortByRecentFirst, sortByUserOrder, touchLastUsedAt };