mulmoclaude 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- package/src/composables/useSelectedResult.ts +0 -49
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Git sparse-checkout helper for the external-skill catalog
|
|
2
|
+
// (#1383 / #1335 PR-C).
|
|
3
|
+
//
|
|
4
|
+
// Responsibilities:
|
|
5
|
+
// - Clone or fetch into a workspace-EXTERNAL scratch dir keyed by
|
|
6
|
+
// hash(url) so multiple installs of the same URL (with different
|
|
7
|
+
// subpaths) share one `.git/`.
|
|
8
|
+
// - Apply a sparse-checkout pattern when `subpath` is provided so
|
|
9
|
+
// huge multi-skill repos don't materialise files we won't use.
|
|
10
|
+
// - Return the resolved commit SHA + cache dir so the install
|
|
11
|
+
// layer can scan for SKILL.md and write its own `.source.json`.
|
|
12
|
+
//
|
|
13
|
+
// The git binary is invoked through `execFile` (no shell) and the
|
|
14
|
+
// runner is injectable via `deps.runGit` so unit tests can fake the
|
|
15
|
+
// filesystem layout without ever shelling out.
|
|
16
|
+
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
|
|
24
|
+
import { ONE_MINUTE_MS } from "../../../utils/time.js";
|
|
25
|
+
import { parseGitHubHttpsUrl, urlCacheKey } from "./id.js";
|
|
26
|
+
|
|
27
|
+
const execFileP = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
const GIT_OP_TIMEOUT_MS = ONE_MINUTE_MS;
|
|
30
|
+
|
|
31
|
+
export interface CloneOptions {
|
|
32
|
+
url: string;
|
|
33
|
+
subpath?: string;
|
|
34
|
+
/** Branch / tag / SHA to fetch. Default: "HEAD". */
|
|
35
|
+
ref?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CloneResult {
|
|
39
|
+
/** Absolute path to the populated cache dir (contains `.git/` plus
|
|
40
|
+
* the checked-out tree). */
|
|
41
|
+
cacheDir: string;
|
|
42
|
+
/** Commit SHA pointed to by `HEAD` after fetch. */
|
|
43
|
+
sha: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type RunGit = (args: readonly string[]) => Promise<{ stdout: string; stderr: string }>;
|
|
47
|
+
|
|
48
|
+
/** Default git runner: shells out to `git` on PATH with no shell
|
|
49
|
+
* involvement (execFile, not exec). The workspace init already
|
|
50
|
+
* assumes `git` is available (see `server/workspace/workspace.ts`),
|
|
51
|
+
* so this isn't a new dependency. */
|
|
52
|
+
export const defaultRunGit: RunGit = async (args) => execFileP("git", args.slice(), { timeout: GIT_OP_TIMEOUT_MS });
|
|
53
|
+
|
|
54
|
+
/** Default scratch root: `~/.cache/mulmoclaude/sources/`. Kept
|
|
55
|
+
* outside the workspace so `.git/` clutter doesn't end up in the
|
|
56
|
+
* user's workspace git history or Obsidian sync. */
|
|
57
|
+
export function defaultCacheRoot(): string {
|
|
58
|
+
return path.join(homedir(), ".cache", "mulmoclaude", "sources");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CloneDeps {
|
|
62
|
+
cacheRoot?: string;
|
|
63
|
+
runGit?: RunGit;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One-time repo creation: `git init` + `remote add`. No-op once
|
|
67
|
+
* `.git/` exists (re-installs reuse the existing clone). */
|
|
68
|
+
async function initRepoOnce(cacheDir: string, url: string, runGit: RunGit): Promise<void> {
|
|
69
|
+
if (existsSync(path.join(cacheDir, ".git"))) return;
|
|
70
|
+
await runGit(["init", cacheDir]);
|
|
71
|
+
await runGit(["-C", cacheDir, "remote", "add", "origin", url]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Apply sparse-checkout config to match the CURRENT request — runs
|
|
75
|
+
* on every call, not just first init. Without this, re-installing
|
|
76
|
+
* the same URL with a different (or absent) `subpath` would keep the
|
|
77
|
+
* stale pattern from the first install, so `git checkout` stays
|
|
78
|
+
* constrained and discovery wrongly reports `no-skills`. Caller
|
|
79
|
+
* guarantees `subpath` is already sanitised. */
|
|
80
|
+
async function applySparseCheckout(cacheDir: string, subpath: string | undefined, runGit: RunGit): Promise<void> {
|
|
81
|
+
if (!subpath) {
|
|
82
|
+
// Lift any constraint left by a previous subpath install.
|
|
83
|
+
await runGit(["-C", cacheDir, "config", "core.sparseCheckout", "false"]);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await runGit(["-C", cacheDir, "config", "core.sparseCheckout", "true"]);
|
|
87
|
+
// sparse-checkout pattern is one entry per line; we want the
|
|
88
|
+
// entire subpath subtree.
|
|
89
|
+
const sparseFile = path.join(cacheDir, ".git", "info", "sparse-checkout");
|
|
90
|
+
await mkdir(path.dirname(sparseFile), { recursive: true });
|
|
91
|
+
await writeFile(sparseFile, `${subpath}/*\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetch `ref` at depth 1, check it out, and return the resolved
|
|
95
|
+
* 40-hex SHA. Throws if `rev-parse` yields anything unexpected. */
|
|
96
|
+
async function fetchAndResolveSha(cacheDir: string, ref: string, runGit: RunGit): Promise<string> {
|
|
97
|
+
// `--depth=1` keeps the scratch dir small; we only need the current
|
|
98
|
+
// tree, never history. `--no-tags` avoids pulling release-tag noise.
|
|
99
|
+
await runGit(["-C", cacheDir, "fetch", "--depth=1", "--no-tags", "origin", ref]);
|
|
100
|
+
await runGit(["-C", cacheDir, "checkout", "FETCH_HEAD"]);
|
|
101
|
+
const { stdout: shaRaw } = await runGit(["-C", cacheDir, "rev-parse", "HEAD"]);
|
|
102
|
+
const sha = shaRaw.trim();
|
|
103
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
104
|
+
throw new Error(`git rev-parse returned unexpected output: ${JSON.stringify(shaRaw)}`);
|
|
105
|
+
}
|
|
106
|
+
return sha;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Initialise (or refresh) a scratch clone for the given URL and
|
|
110
|
+
* return the cache dir + resolved SHA. Idempotent: a second call
|
|
111
|
+
* with the same URL only fetches new commits and re-checks-out. */
|
|
112
|
+
export async function cloneOrUpdate(opts: CloneOptions, deps: CloneDeps = {}): Promise<CloneResult> {
|
|
113
|
+
// Defense-in-depth: the install layer already validates via
|
|
114
|
+
// `deriveRepoId`, but re-checking here keeps the function safe
|
|
115
|
+
// against direct callers (tests / future refactors) so an
|
|
116
|
+
// unvalidated URL can never reach `git`.
|
|
117
|
+
if (!parseGitHubHttpsUrl(opts.url)) {
|
|
118
|
+
throw new Error(`invalid GitHub HTTPS URL: ${opts.url}`);
|
|
119
|
+
}
|
|
120
|
+
const runGit = deps.runGit ?? defaultRunGit;
|
|
121
|
+
const cacheRoot = deps.cacheRoot ?? defaultCacheRoot();
|
|
122
|
+
const cacheDir = path.join(cacheRoot, urlCacheKey(opts.url));
|
|
123
|
+
|
|
124
|
+
await mkdir(cacheDir, { recursive: true });
|
|
125
|
+
await initRepoOnce(cacheDir, opts.url, runGit);
|
|
126
|
+
await applySparseCheckout(cacheDir, opts.subpath, runGit);
|
|
127
|
+
const sha = await fetchAndResolveSha(cacheDir, opts.ref ?? "HEAD", runGit);
|
|
128
|
+
return { cacheDir, sha };
|
|
129
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// ID derivation for the external-skill catalog (#1383 / #1335 PR-C).
|
|
2
|
+
//
|
|
3
|
+
// Inputs: a GitHub HTTPS URL + optional subpath. Outputs:
|
|
4
|
+
//
|
|
5
|
+
// - `repoId` — the directory name under `data/skills/catalog/external/`.
|
|
6
|
+
// Built from `<owner>-<repo>` so the same repo always
|
|
7
|
+
// resolves to the same folder regardless of which
|
|
8
|
+
// subpath the user picked (subpath determines what
|
|
9
|
+
// ends up INSIDE the dir, not its name).
|
|
10
|
+
// - `activeId` — the directory name under `.claude/skills/`. Built
|
|
11
|
+
// from `<owner>-<skillFolder>` to keep the slash-
|
|
12
|
+
// command flat and reasonably short. When the repo
|
|
13
|
+
// ships a single SKILL.md at root (no skillFolder),
|
|
14
|
+
// the activeId equals the repoId.
|
|
15
|
+
//
|
|
16
|
+
// Both ids run through the same `safeSlug` filter the rest of the
|
|
17
|
+
// catalog uses (regex whitelist + `path.basename` round-trip — CodeQL's
|
|
18
|
+
// recognised path-injection sanitiser, established in PR-B).
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
// Slug whitelist: lowercase alnum + `-`, must start and end with alnum,
|
|
24
|
+
// at least one character. Matches the convention used by `catalog.ts`'s
|
|
25
|
+
// `safeSlugName` but lower-cases the input first since URLs / repo
|
|
26
|
+
// names are case-insensitive on GitHub.
|
|
27
|
+
//
|
|
28
|
+
// The two `[a-z0-9-]` segments around the required leading + trailing
|
|
29
|
+
// alnum look like nested quantifiers to the security/detect-unsafe-regex
|
|
30
|
+
// rule, but each segment can only consume from a single bounded
|
|
31
|
+
// character class — worst-case backtracking is linear.
|
|
32
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping classes
|
|
33
|
+
const SAFE_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
34
|
+
|
|
35
|
+
function sanitise(raw: string): string | null {
|
|
36
|
+
// Fail-closed denylist FIRST: anything that smells like a path
|
|
37
|
+
// operator is rejected outright. Doing the normalise step below
|
|
38
|
+
// first would silently collapse `..` → `-` and let suspicious
|
|
39
|
+
// input through as benign-looking strings (`../etc` → `etc`).
|
|
40
|
+
if (raw.includes("..") || raw.includes("/") || raw.includes("\\") || raw.includes("\0")) return null;
|
|
41
|
+
// Normalise punctuation to hyphens then trim leading + trailing
|
|
42
|
+
// separators via slice (avoids `/-+$/` which sonar flags as
|
|
43
|
+
// potentially slow even though it's bounded by `$`).
|
|
44
|
+
let lowered = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
45
|
+
while (lowered.startsWith("-")) lowered = lowered.slice(1);
|
|
46
|
+
while (lowered.endsWith("-")) lowered = lowered.slice(0, -1);
|
|
47
|
+
if (!SAFE_SLUG_PATTERN.test(lowered)) return null;
|
|
48
|
+
// `path.basename` round-trip — same launder used elsewhere so CodeQL
|
|
49
|
+
// recognises the result as sanitised when it flows into `path.join`.
|
|
50
|
+
const basename = path.basename(lowered);
|
|
51
|
+
return basename === lowered ? basename : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ParsedGitHubUrl {
|
|
55
|
+
owner: string;
|
|
56
|
+
repo: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const GITHUB_HTTPS_RE = /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100})(?:\.git)?\/?$/;
|
|
60
|
+
|
|
61
|
+
/** Parse a GitHub HTTPS URL into owner/repo. v1 only accepts
|
|
62
|
+
* `https://github.com/<owner>/<repo>` (optional `.git` suffix,
|
|
63
|
+
* optional trailing slash). gitlab / SSH / private hosts are out of
|
|
64
|
+
* scope. Returns `null` on any rejection. */
|
|
65
|
+
export function parseGitHubHttpsUrl(url: string): ParsedGitHubUrl | null {
|
|
66
|
+
const match = GITHUB_HTTPS_RE.exec(url);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
const [, owner, repoRaw] = match;
|
|
69
|
+
// Strip a trailing `.git` if the regex's optional group missed it
|
|
70
|
+
// (it's already handled in the regex, but be defensive).
|
|
71
|
+
const repo = repoRaw.endsWith(".git") ? repoRaw.slice(0, -".git".length) : repoRaw;
|
|
72
|
+
return { owner, repo };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Derive the repo-level catalog directory name from URL.
|
|
76
|
+
* `<owner>-<repo>`. Returns `null` if the URL fails parsing or the
|
|
77
|
+
* resulting slug isn't safe (e.g. owner / repo contains only
|
|
78
|
+
* punctuation). */
|
|
79
|
+
export function deriveRepoId(url: string): string | null {
|
|
80
|
+
const parsed = parseGitHubHttpsUrl(url);
|
|
81
|
+
if (!parsed) return null;
|
|
82
|
+
return sanitise(`${parsed.owner}-${parsed.repo}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Derive the active-layer directory name from a URL + skillFolder.
|
|
86
|
+
* When `skillFolder` is `null`, the skill is at repo root and the
|
|
87
|
+
* active id equals the repoId. */
|
|
88
|
+
export function deriveActiveId(url: string, skillFolder: string | null): string | null {
|
|
89
|
+
const parsed = parseGitHubHttpsUrl(url);
|
|
90
|
+
if (!parsed) return null;
|
|
91
|
+
if (skillFolder === null) {
|
|
92
|
+
return sanitise(`${parsed.owner}-${parsed.repo}`);
|
|
93
|
+
}
|
|
94
|
+
// Validate skillFolder itself first so a path-traversal-shaped
|
|
95
|
+
// input ("..", "foo/bar") fails closed before composition.
|
|
96
|
+
const folderSafe = sanitise(skillFolder);
|
|
97
|
+
if (folderSafe === null) return null;
|
|
98
|
+
return sanitise(`${parsed.owner}-${folderSafe}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Canonical `https://github.com/<owner>/<repo>` (lowercased, no
|
|
102
|
+
* `.git`/trailing slash) — the identity two URLs share iff they
|
|
103
|
+
* point at the same GitHub repo. `null` if the URL doesn't parse.
|
|
104
|
+
* Used both for cache keying and the install-time collision guard
|
|
105
|
+
* (distinct repos whose `repoId` punctuation-collides must be told
|
|
106
|
+
* apart by their canonical URL, not their lossy id). */
|
|
107
|
+
export function canonicalRepoUrl(url: string): string | null {
|
|
108
|
+
const parsed = parseGitHubHttpsUrl(url);
|
|
109
|
+
return parsed ? `https://github.com/${parsed.owner.toLowerCase()}/${parsed.repo.toLowerCase()}` : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stable opaque hash of a URL for keying the scratch-clone cache.
|
|
113
|
+
* Canonicalises first so the accepted variants of the same repo
|
|
114
|
+
* (`/o/r`, `/o/r/`, `/o/r.git`, case differences) map to ONE cache
|
|
115
|
+
* dir — otherwise a re-install via a different form would spawn a
|
|
116
|
+
* duplicate clone and uninstall would only drop the last-recorded
|
|
117
|
+
* variant. */
|
|
118
|
+
export function urlCacheKey(url: string): string {
|
|
119
|
+
return createHash("sha256")
|
|
120
|
+
.update(canonicalRepoUrl(url) ?? url)
|
|
121
|
+
.digest("hex")
|
|
122
|
+
.slice(0, 16);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Shape of a `deriveRepoId` output (`<owner>-<repo>` lowercased). The
|
|
126
|
+
// two `[a-z0-9-]` segments around the required leading + trailing
|
|
127
|
+
// alnum look like nested quantifiers but each reads from a single
|
|
128
|
+
// bounded class — worst-case backtracking is linear. Exported as the
|
|
129
|
+
// single source of truth so the catalog reader + installer agree by
|
|
130
|
+
// construction instead of duplicating the literal.
|
|
131
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping classes
|
|
132
|
+
const SAFE_REPO_ID_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
133
|
+
|
|
134
|
+
/** True when `value` has the shape `deriveRepoId` produces. */
|
|
135
|
+
export function isSafeRepoId(value: string): boolean {
|
|
136
|
+
return SAFE_REPO_ID_RE.test(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Launder a (possibly user-supplied) repo id into a safe directory
|
|
140
|
+
* leaf, or `null` if it fails the shape check or isn't a basename.
|
|
141
|
+
* The `path.basename` round-trip is CodeQL's recognised
|
|
142
|
+
* `js/path-injection` sanitiser — callers MUST compose paths from
|
|
143
|
+
* the returned value (not the raw input) so the taint flow ends
|
|
144
|
+
* here. A regex `.test()` alone is NOT recognised as a sanitiser. */
|
|
145
|
+
export function safeRepoId(raw: string): string | null {
|
|
146
|
+
if (!SAFE_REPO_ID_RE.test(raw)) return null;
|
|
147
|
+
const basename = path.basename(raw);
|
|
148
|
+
return basename === raw ? basename : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Launder a skill-folder leaf name (the directory directly under a
|
|
152
|
+
* repo / subpath that contains `SKILL.md`). This is the SINGLE rule
|
|
153
|
+
* shared by install-time discovery AND read/star addressing — if the
|
|
154
|
+
* two diverged (as they did: install accepted `v1.2` but the read
|
|
155
|
+
* side's stricter regex dropped it), an install could succeed yet be
|
|
156
|
+
* invisible/unaddressable in list/preview/star.
|
|
157
|
+
*
|
|
158
|
+
* Permissive by design (mirrors what install discovery accepts: any
|
|
159
|
+
* non-hidden one-level dir) but still traversal-safe: rejects empty,
|
|
160
|
+
* `.`/`..`, leading-dot (hidden), separators, NUL, and anything that
|
|
161
|
+
* isn't its own `path.basename` (CodeQL `js/path-injection`
|
|
162
|
+
* sanitiser). Dots elsewhere (`v1.2`) are allowed. */
|
|
163
|
+
export function safeSkillFolder(raw: string): string | null {
|
|
164
|
+
if (raw.length === 0 || raw === "." || raw === "..") return null;
|
|
165
|
+
if (raw.startsWith(".")) return null;
|
|
166
|
+
if (raw.includes("/") || raw.includes("\\") || raw.includes("\0")) return null;
|
|
167
|
+
const basename = path.basename(raw);
|
|
168
|
+
return basename === raw ? basename : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A single safe path segment: alnum plus `.`, `-`, `_`. `..` is
|
|
172
|
+
// rejected explicitly by the caller before this is consulted, so the
|
|
173
|
+
// dot allowance can't yield a traversal token.
|
|
174
|
+
const SUBPATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
175
|
+
|
|
176
|
+
/** Sanitise a caller-supplied repo subpath (`POST /external/repos`
|
|
177
|
+
* body). The raw value flows into `path.join(cacheDir, subpath)` and
|
|
178
|
+
* into the git sparse-checkout pattern, so an un-validated `../../etc`
|
|
179
|
+
* / `\0` / newline would escape the scratch dir or inject extra
|
|
180
|
+
* sparse patterns. Returns a normalised `a/b/c` string (no leading or
|
|
181
|
+
* trailing slash, no `.`/`..`/empty segments) or `null` on rejection. */
|
|
182
|
+
export function sanitiseSubpath(raw: string): string | null {
|
|
183
|
+
if (raw.length === 0) return null;
|
|
184
|
+
if (raw.includes("\0") || raw.includes("\\") || raw.includes("\n") || raw.includes("\r")) return null;
|
|
185
|
+
if (raw.startsWith("/")) return null;
|
|
186
|
+
const safe: string[] = [];
|
|
187
|
+
for (const segment of raw.split("/")) {
|
|
188
|
+
if (segment === "" || segment === ".") continue;
|
|
189
|
+
if (segment === ".." || !SUBPATH_SEGMENT_PATTERN.test(segment)) return null;
|
|
190
|
+
safe.push(segment);
|
|
191
|
+
}
|
|
192
|
+
if (safe.length === 0) return null;
|
|
193
|
+
return safe.join("/");
|
|
194
|
+
}
|