diff --git a/README.md b/README.md index 3d0bf24..4b7a687 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ | 类别 | 路径 | 说明 | |------|------|------| | Docs | [docs/](./docs/) | 学习与说明文档 | +| └ Submodules | [docs/git/submodules.md](./docs/git/submodules.md) | 多目录 / 可配置 submodule 助手用法 | | Skillsets | [skillsets/](./skillsets/) | 独立 Skill 项目集合 | | └ devops-skill | [qdriven/devops-skill](https://github.com/qdriven/devops-skill) | DevOps / Git 工作流 skills([文档站](https://qdriven.github.io/devops-skill/)) | | └ docmd skill | [skillsets/devops-skill/docmd/](./skillsets/devops-skill/docmd/) | docmd + Diátaxis 文档站模版与构建说明 | diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..2081d97 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,88 @@ +# Apps + +Applications and related projects are registered in `apps/registry.yaml`. +Their on-disk `path` may live under any **configured root** (not only `apps/`). + +This repo does **not** enforce `web/desktop/cli` folders. Each entry is a project directory with its own stack and docs. + +## Multi-folder roots + +Full how-to (config fields, CLI examples, env overrides, common errors): [docs/git/submodules.md](../docs/git/submodules.md). + +Allowed parent folders for submodules are configured in `scripts/submodule.config.json`: + +| Key | Default | Meaning | +|-----|---------|---------| +| `roots` | `apps`, `tutorials`, `skillsets`, `ai-infra` | Allowed top-level folders | +| `defaultRoot` | `apps` | Used when only a project name is given | +| `org` | `variableway` | GitHub org for inferred clone URLs | +| `host` | `github.com` | Git host for inferred URLs | + +Override without editing the file: + +- Env: `SUBMODULE_ROOTS`, `SUBMODULE_DEFAULT_ROOT`, `SUBMODULE_ORG`, `SUBMODULE_HOST` +- CLI flags on the helper (below) + +## Local vs submodule + +| `source` in registry | Meaning | +|----------------------|---------| +| `local` | Code lives directly in this repo | +| `submodule` | Linked git submodule (separate GitHub repo) | + +Add a submodule manually: + +```bash +git submodule add https://github.com//.git apps/ +# or another configured root: +git submodule add https://github.com//.git tutorials/ +``` + +Then register it in `apps/registry.yaml` (set `path` to the actual folder) and keep `.gitmodules` in sync. + +Or use the helper script: + +```bash +# default root (apps/) +node scripts/add-submodule.mts + +# other configured root +node scripts/add-submodule.mts --folder tutorials +node scripts/add-submodule.mts --path skillsets/devops-skill --repo https://github.com/qdriven/devops-skill.git + +# explicit URL / org +node scripts/add-submodule.mts my-app --repo https://github.com/acme/my-app.git +node scripts/add-submodule.mts my-app --folder apps --org acme +``` + +Behavior: + +- Default repo URL: `https:////.git` (from config/CLI/env) +- Path must be under an allowed `roots` entry +- If the target folder already exists and is a git clone, and `--repo` was not passed, the script uses that folder's `origin` URL and runs `git submodule add --force` + +## Showcase site (planned) + +`apps/site` can be the monorepo portal (apps list, registry metadata, GitHub Pages). It is not added yet. + +## Registry + +`apps/registry.yaml` is the source of truth for app metadata. Indexed by `scripts/generate-manifest.mjs` → `data/manifest.json`. + +`path` may be under any configured root, e.g. `apps/innate-feeds` or `skillsets/devops-skill`. + +Recommended metadata fields per app entry: + +| Field | Required | Example | Notes | +|------|----------|---------|------| +| `name` | yes | `my-tool` | unique id | +| `title` | yes | `My Tool` | display name | +| `path` | yes | `apps/my-tool` | project path (any configured root) | +| `source` | yes | `local` / `submodule` | code location mode | +| `kind` | optional | `web`, `cli`, `desktop`, `service` | app type label (free-form) | +| `tags` | optional | `[internal, mvp, ai-agent]` | multiple labels for search/filter | +| `visibility` | optional | `public` / `private` | release intent | +| `status` | optional | `planned` / `active` | lifecycle | +| `repo` | submodule only | `https://github.com/org/repo` | source repository | + +Node apps under a registered path with a `package.json` can be pnpm workspace members when a root `pnpm-workspace.yaml` is present. diff --git a/apps/registry.yaml b/apps/registry.yaml new file mode 100644 index 0000000..2e8f25d --- /dev/null +++ b/apps/registry.yaml @@ -0,0 +1,69 @@ +# Application registry — local apps and git submodules. +# Consumed by scripts/generate-manifest.mjs (and future apps/site). +# +# Submodule helper config (multi-folder): scripts/submodule.config.json +# - roots: allowed parent folders for `scripts/add-submodule.mts` +# - defaultRoot / org / host: defaults for inferred paths and URLs +# Override via CLI (--folder/--path/--repo/--org/--host) or env +# (SUBMODULE_ROOTS, SUBMODULE_DEFAULT_ROOT, SUBMODULE_ORG, SUBMODULE_HOST). +# +# App `path` may live under any configured root (apps/, tutorials/, skillsets/, …), +# not only apps/. + +apps: + - name: innate-feeds + title: Innate Feeds + kind: web + tags: [submodule, feed, ai-agent] + path: apps/innate-feeds + source: submodule + repo: https://github.com/variableway/innate-feeds.git + visibility: public + status: active + description: Innate Feeds app linked as a git submodule. + + # Example local app: + # - name: my-tool + # title: My Tool + # kind: web # optional: web/desktop/cli/service/etc. + # tags: [internal, mvp, ai-agent] # optional labels for filtering/search + # path: apps/my-tool + # source: local + # visibility: public + # status: active + # description: ... + + # Example submodule under a non-apps root: + # - name: devops-skill + # title: DevOps Skill + # kind: skillset + # tags: [submodule, skills] + # path: skillsets/devops-skill + # source: submodule + # repo: https://github.com/qdriven/devops-skill.git + # visibility: public + # status: active + # description: DevOps skillset linked as a git submodule. + + # Example submodule app: + # - name: example-project + # title: Example Project + # kind: cli + # tags: [submodule, public] + # path: apps/example-project + # source: submodule + # repo: https://github.com/variableway/example-project + # visibility: public + # status: planned + # description: Sample project linked as a git submodule. + + # Planned showcase site (add when ready): + # - name: site + # title: Agentway Site + # kind: web + # tags: [portal, github-pages] + # path: apps/site + # source: local + # visibility: public + # status: planned + # description: Showcase for skills and apps. GitHub Pages. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..38b8a24 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# Docs + +学习与操作说明(按主题归类)。 + +| 文档 | 说明 | +|------|------| +| [如何添加可配置多目录 Submodule](./git/submodules.md) | `submodule.config.json`、多根目录、CLI / env、registry | +| [Git Worktree 如何工作](./git/worktree.md) | 并行分支隔离检出 | + +应用元数据与 registry 字段见 [apps/README.md](../apps/README.md)。 diff --git a/docs/git/submodules.md b/docs/git/submodules.md new file mode 100644 index 0000000..a8202ed --- /dev/null +++ b/docs/git/submodules.md @@ -0,0 +1,200 @@ +# 如何添加可配置多目录 Submodule + +## 一句话理解 + +用 `scripts/add-submodule.mts` 把外部 Git 仓库挂进本仓的**允许根目录**(默认 `apps` / `tutorials` / `skillsets` / `ai-infra`),路径与远程 URL 可由配置文件、CLI 或环境变量覆盖,不必写死在 `apps/` 下。 + +## 为什么需要它 + +手工 `git submodule add` 容易: + +- 路径写错(越界到未约定的顶层目录) +- 远程 URL 与本仓约定 org/host 不一致 +- 忘了在 `apps/registry.yaml` 登记,`generate-manifest` 扫不到 + +助手脚本统一做三件事:校验路径落在允许根下、推断或采用显式 remote、提示下一步登记 registry。 + +相关文件: + +| 文件 | 作用 | +|------|------| +| `scripts/submodule.config.json` | 默认 org / host / roots / defaultRoot | +| `scripts/add-submodule.mts` | 添加 submodule 的 CLI | +| `scripts/generate-manifest.mjs` | 扫 registry + 技能目录 → `data/manifest.json` | +| `apps/registry.yaml` | 应用/项目元数据(`path` 可在任意配置根下) | + +更偏 registry 字段说明见 [apps/README.md](../../apps/README.md)。 + +## 配置文件 + +路径:`scripts/submodule.config.json` + +```json +{ + "org": "variableway", + "host": "github.com", + "defaultRoot": "apps", + "roots": ["apps", "tutorials", "skillsets", "ai-infra"] +} +``` + +| 字段 | 含义 | +|------|------| +| `roots` | 允许的**顶层**父目录;`--folder` / `--path` 的第一段必须在此列表中 | +| `defaultRoot` | 只传项目名、未指定 `--folder` 时使用的根(默认 `apps`) | +| `org` | 未传 `--repo` 时,推断 URL 的 GitHub org | +| `host` | 未传 `--repo` 时,推断 URL 的 host(如 `github.com`) | + +优先级(从高到低):**CLI 标志 / 对应环境变量** → **配置文件** → **脚本内置默认值**。 + +## 基本用法 + +在仓库根目录执行: + +```bash +# 默认根 apps/ → apps/ +# 推断 URL:https://github.com/variableway/.git +node scripts/add-submodule.mts + +# 查看帮助(含当前允许的 roots) +node scripts/add-submodule.mts --help +``` + +### 挂到不同根目录 + +```bash +# apps(显式) +node scripts/add-submodule.mts my-app --folder apps + +# tutorials +node scripts/add-submodule.mts my-tutorial --folder tutorials + +# skillsets +node scripts/add-submodule.mts devops-skill --folder skillsets + +# ai-infra +node scripts/add-submodule.mts my-proxy --folder ai-infra +``` + +### 用完整相对路径 + +```bash +node scripts/add-submodule.mts --path skillsets/devops-skill \ + --repo https://github.com/qdriven/devops-skill.git +``` + +`--path` 必须是 `/`(至少两段),且不可含 `..` 路径穿越。 + +### 指定远程 / org / host + +```bash +# 显式 URL(不再用 org/host 推断) +node scripts/add-submodule.mts my-app --repo https://github.com/acme/my-app.git + +# 仍推断路径,但换 org / host +node scripts/add-submodule.mts my-app --folder apps --org acme +node scripts/add-submodule.mts my-app --host github.com --org variableway +``` + +### 已有本地 clone + +若目标目录已存在且是 git 仓库,且**没有**传 `--repo`,脚本会读取该目录 `remote.origin.url`,并以 `git submodule add --force` 挂入。 + +## CLI 标志一览 + +| 标志 | 说明 | +|------|------| +| `` | 位置参数;不可含 `/`;与 `--folder` / `defaultRoot` 组成路径 | +| `--folder ` | 父目录,须在 `roots` 中 | +| `--path ` | 完整相对路径;与单独传 name 二选一 | +| `--repo ` | 显式 git remote | +| `--org ` | 覆盖推断用 org | +| `--host ` | 覆盖推断用 host | +| `-h` / `--help` | 打印用法与当前允许 roots | + +## 环境变量覆盖 + +### add-submodule(`SUBMODULE_*`) + +| 变量 | 对应配置 | +|------|----------| +| `SUBMODULE_ORG` | `org` | +| `SUBMODULE_HOST` | `host` | +| `SUBMODULE_DEFAULT_ROOT` | `defaultRoot` | +| `SUBMODULE_ROOTS` | `roots`(逗号分隔,如 `apps,tutorials,skillsets`) | + +示例: + +```bash +SUBMODULE_ORG=acme SUBMODULE_DEFAULT_ROOT=tutorials \ + node scripts/add-submodule.mts demo-course +``` + +### generate-manifest(`MANIFEST_*`) + +| 变量 | 默认 | 说明 | +|------|------|------| +| `MANIFEST_REGISTRY` | `apps/registry.yaml` | 相对仓库根的 registry 路径 | +| `MANIFEST_OUT` | `data/manifest.json` | 输出路径 | +| `MANIFEST_SKILLS_DIRS` | (自动) | 逗号分隔的技能扫描目录;未设时扫描 `skills/` 与配置里**已存在**的 `roots` | + +配置文件的 `roots` 也会写入 manifest 的 `config.submoduleRoots`,便于 showcase 站点读取。 + +```bash +node scripts/generate-manifest.mjs +MANIFEST_OUT=tmp/manifest.json node scripts/generate-manifest.mjs +``` + +## Registry 与 `path` + +添加 submodule 后,若希望出现在 manifest / 门户列表里,在 `apps/registry.yaml` 增加条目,**`path` 写成真实落盘路径**(不必仍以 `apps/` 开头): + +```yaml +apps: + - name: devops-skill + title: DevOps Skill + path: skillsets/devops-skill # 任意配置根下均可 + source: submodule + repo: https://github.com/qdriven/devops-skill.git + visibility: public + status: active +``` + +`source: local` 表示代码直接在本仓;`submodule` 表示独立远程仓库。字段说明见 [apps/README.md](../../apps/README.md)。 + +## 常见错误 + +| 现象 | 原因 / 处理 | +|------|-------------| +| `folder "…" is not allowed` | `--folder` 不在 `roots` / `SUBMODULE_ROOTS` 中;改配置或换合法根 | +| `--path must be under an allowed root` | 路径顶层段不在允许列表,或含 `..` / 绝对路径 | +| `--path must include /` | `--path` 只有一段(如 `apps`),需 `apps/foo` | +| `project name must not contain path separators` | 位置参数里写了 `tutorials/foo`;改用 `--folder` 或 `--path` | +| `git submodule add` 失败 | 远程不存在、权限不足、或路径已被占用;检查 URL 与 `.gitmodules` | +| 加了 submodule 但 manifest 没有 | 未在 `apps/registry.yaml` 登记,或未跑 `generate-manifest.mjs` | + +## 推荐流程 + +```bash +# 1. 按需改 scripts/submodule.config.json(或用 env) +# 2. 添加 +node scripts/add-submodule.mts my-tool --folder tutorials + +# 3. 在 apps/registry.yaml 登记 path / source / repo +# 4. 更新 manifest +node scripts/generate-manifest.mjs + +# 5. 提交:.gitmodules、子模块指针、registry(及 manifest,若纳入版本控制) +``` + +## 最小心智模型 + +> 允许根由 `submodule.config.json` 决定;助手只往这些根下挂 submodule。 +> URL 默认同 org/host 推断,可用 `--repo` 或已有 clone 的 origin 覆盖。 +> 挂完还要在 `apps/registry.yaml` 写对 `path`,manifest 才会看到。 + +## 延伸阅读 + +- [apps/README.md](../../apps/README.md) — registry 字段与 local / submodule 约定 +- [Git Worktree 如何工作](./worktree.md) — 并行分支隔离检出 +- `git submodule --help` / [Git Tools - Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) diff --git a/docs/git/worktree.md b/docs/git/worktree.md index 7fe0ddd..f0da423 100644 --- a/docs/git/worktree.md +++ b/docs/git/worktree.md @@ -150,5 +150,6 @@ git worktree remove ../proj-hotfix - `man git-worktree` / `git worktree --help` - Git 文档:[Git Tools - Multiple Working Trees](https://git-scm.com/docs/git-worktree) +- [如何添加可配置多目录 Submodule](./submodules.md) — 多根目录挂外部仓库 - Agent Skill(devops-skill):[`skillsets/devops-skill/git-worktree/`](../../skillsets/devops-skill/git-worktree/SKILL.md) — 与 git-workflow / local-workflow 组合的隔离开发流程 diff --git a/scripts/add-submodule.mts b/scripts/add-submodule.mts new file mode 100644 index 0000000..fe02756 --- /dev/null +++ b/scripts/add-submodule.mts @@ -0,0 +1,221 @@ +#!/usr/bin/env -S node --no-warnings +/** + * Add a git submodule under a configurable root folder. + * + * Config (priority: CLI/env > scripts/submodule.config.json > defaults): + * org, host, defaultRoot, roots[] + * + * Usage: + * node scripts/add-submodule.mts + * node scripts/add-submodule.mts --folder tutorials + * node scripts/add-submodule.mts --path skillsets/my-skill --repo https://github.com/org/my-skill.git + * node scripts/add-submodule.mts --repo --folder apps + * + * Env overrides: + * SUBMODULE_ORG, SUBMODULE_HOST, SUBMODULE_DEFAULT_ROOT, SUBMODULE_ROOTS (comma-separated) + */ +import { spawnSync } from "node:child_process"; +import { dirname, join, normalize, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync } from "node:fs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const CONFIG_PATH = join(__dirname, "submodule.config.json"); + +const DEFAULTS = { + org: "variableway", + host: "github.com", + defaultRoot: "apps", + roots: ["apps", "tutorials", "skillsets", "ai-infra"], +}; + +type Config = { + org: string; + host: string; + defaultRoot: string; + roots: string[]; +}; + +function loadConfig(): Config { + let fileCfg: Partial = {}; + if (existsSync(CONFIG_PATH)) { + try { + fileCfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8")); + } catch (err) { + console.error(`Warning: failed to parse ${CONFIG_PATH}:`, err); + } + } + + const envRoots = process.env.SUBMODULE_ROOTS?.split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return { + org: process.env.SUBMODULE_ORG || fileCfg.org || DEFAULTS.org, + host: process.env.SUBMODULE_HOST || fileCfg.host || DEFAULTS.host, + defaultRoot: + process.env.SUBMODULE_DEFAULT_ROOT || + fileCfg.defaultRoot || + DEFAULTS.defaultRoot, + roots: envRoots?.length + ? envRoots + : Array.isArray(fileCfg.roots) && fileCfg.roots.length + ? fileCfg.roots + : DEFAULTS.roots, + }; +} + +function run(command: string, args: string[]) { + const result = spawnSync(command, args, { + cwd: root, + stdio: "inherit", + }); + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function capture(command: string, args: string[], cwd = root) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + }); + return { + ok: result.status === 0, + output: (result.stdout || "").trim(), + }; +} + +function printUsage(cfg: Config) { + console.error(`Usage: + node scripts/add-submodule.mts [--folder ] [--repo ] + node scripts/add-submodule.mts --path [--repo ] + +Options: + --folder Parent folder under repo root (default: ${cfg.defaultRoot}) + --path Full relative path (e.g. tutorials/foo); implies folder+name + --repo Explicit git remote URL + --org GitHub org for inferred URL (default: ${cfg.org}) + --host Git host for inferred URL (default: ${cfg.host}) + -h, --help Show this help + +Allowed roots (configurable via scripts/submodule.config.json or SUBMODULE_ROOTS): + ${cfg.roots.join(", ")} +`); +} + +function parseArgs(argv: string[]) { + const positional: string[] = []; + const flags: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "-h" || a === "--help") { + flags.help = true; + continue; + } + if (a.startsWith("--")) { + const key = a.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("-")) { + flags[key] = true; + } else { + flags[key] = next; + i++; + } + continue; + } + positional.push(a); + } + return { positional, flags }; +} + +function isUnderAllowedRoot(relPath: string, roots: string[]): string | null { + const norm = normalize(relPath).replace(/\\/g, "/").replace(/^\.\/+/, ""); + if (norm.startsWith("..") || norm.startsWith("/") || norm.includes("\0")) { + return null; + } + const top = norm.split("/")[0]; + if (!roots.includes(top)) return null; + return norm; +} + +function resolveTarget( + cfg: Config, + positional: string[], + flags: Record, +): { relPath: string; projectName: string } { + if (typeof flags.path === "string" && flags.path) { + const relPath = isUnderAllowedRoot(flags.path, cfg.roots); + if (!relPath) { + console.error( + `Error: --path must be under an allowed root (${cfg.roots.join(", ")}). Got: ${flags.path}`, + ); + process.exit(1); + } + const parts = relPath.split("/"); + if (parts.length < 2) { + console.error("Error: --path must include /"); + process.exit(1); + } + return { relPath, projectName: parts.at(-1)! }; + } + + const projectName = positional[0]; + if (!projectName) { + printUsage(cfg); + process.exit(1); + } + if (projectName.includes("/") || projectName.includes(sep)) { + console.error( + "Error: project name must not contain path separators; use --folder or --path instead.", + ); + process.exit(1); + } + + const folder = + (typeof flags.folder === "string" && flags.folder) || cfg.defaultRoot; + if (!cfg.roots.includes(folder)) { + console.error( + `Error: folder "${folder}" is not allowed. Allowed: ${cfg.roots.join(", ")}`, + ); + process.exit(1); + } + + return { relPath: `${folder}/${projectName}`, projectName }; +} + +const cfg = loadConfig(); +const { positional, flags } = parseArgs(process.argv.slice(2)); + +if (flags.help) { + printUsage(cfg); + process.exit(0); +} + +if (typeof flags.org === "string" && flags.org) cfg.org = flags.org; +if (typeof flags.host === "string" && flags.host) cfg.host = flags.host; + +const { relPath, projectName } = resolveTarget(cfg, positional, flags); +const absPath = join(root, relPath); + +let repoUrl = + typeof flags.repo === "string" && flags.repo + ? flags.repo + : `https://${cfg.host}/${cfg.org}/${projectName}.git`; + +// If folder already exists and is a git repo, prefer its origin URL (unless --repo given). +if (!(typeof flags.repo === "string" && flags.repo) && existsSync(join(absPath, ".git"))) { + const origin = capture( + "git", + ["config", "--get", "remote.origin.url"], + absPath, + ); + if (origin.ok && origin.output) repoUrl = origin.output; + run("git", ["submodule", "add", "--force", repoUrl, relPath]); +} else { + run("git", ["submodule", "add", repoUrl, relPath]); +} + +console.log(`Added submodule: ${relPath} -> ${repoUrl}`); +console.log( + `Next: register it in apps/registry.yaml (path: ${relPath}) if it should appear in the manifest.`, +); diff --git a/scripts/bump-pnpm.mjs b/scripts/bump-pnpm.mjs new file mode 100644 index 0000000..bf6aaaf --- /dev/null +++ b/scripts/bump-pnpm.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * Bump packageManager to latest pnpm on npm (no Corepack required). + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pkgPath = join(root, "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + +const current = pkg.packageManager?.match(/^pnpm@(.+)$/)?.[1] ?? null; +const res = await fetch("https://registry.npmjs.org/pnpm/latest"); +if (!res.ok) throw new Error(`npm registry: ${res.status}`); +const { version: latest } = await res.json(); + +if (current === latest) { + console.log(`pnpm@${latest} is already latest in package.json`); +} else { + pkg.packageManager = `pnpm@${latest}`; + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + console.log(`package.json: pnpm@${current ?? "?"} → pnpm@${latest}`); +} + +function tryCorepack() { + try { + execSync("corepack use pnpm@latest", { stdio: "inherit", cwd: root }); + return true; + } catch { + return false; + } +} + +function trySelfUpdate() { + try { + execSync("pnpm self-update", { stdio: "inherit" }); + return true; + } catch { + return false; + } +} + +if (current !== latest) { + if (tryCorepack()) { + console.log("Corepack switched active pnpm."); + } else if (trySelfUpdate()) { + console.log("pnpm self-update completed."); + } else { + console.log( + `Install pnpm@${latest} manually, e.g. npm install -g pnpm@${latest}`, + ); + } + console.log("Run: pnpm install"); +} diff --git a/scripts/generate-manifest.mjs b/scripts/generate-manifest.mjs new file mode 100644 index 0000000..758c0f3 --- /dev/null +++ b/scripts/generate-manifest.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * Scans skill roots and apps/registry.yaml, writes manifest for the showcase site. + * + * Configurable via env or scripts/submodule.config.json: + * SUBMODULE_ROOTS / config.roots — used as optional skill scan roots (skills/, plus roots) + * MANIFEST_REGISTRY — override registry path (default: apps/registry.yaml) + * MANIFEST_OUT — override output path (default: data/manifest.json) + * MANIFEST_SKILLS_DIRS — comma-separated skill scan dirs (default: skills + config roots that exist) + */ +import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { join, relative, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const CONFIG_PATH = join(__dirname, "submodule.config.json"); + +function loadSubmoduleConfig() { + if (!existsSync(CONFIG_PATH)) { + return { roots: ["apps", "tutorials", "skillsets", "ai-infra"] }; + } + try { + return JSON.parse(readFileSync(CONFIG_PATH, "utf8")); + } catch { + return { roots: ["apps", "tutorials", "skillsets", "ai-infra"] }; + } +} + +const submoduleCfg = loadSubmoduleConfig(); +const REGISTRY = process.env.MANIFEST_REGISTRY + ? join(ROOT, process.env.MANIFEST_REGISTRY) + : join(ROOT, "apps", "registry.yaml"); +const OUT = process.env.MANIFEST_OUT + ? join(ROOT, process.env.MANIFEST_OUT) + : join(ROOT, "data", "manifest.json"); + +function resolveSkillDirs() { + if (process.env.MANIFEST_SKILLS_DIRS) { + return process.env.MANIFEST_SKILLS_DIRS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((d) => join(ROOT, d)); + } + const candidates = ["skills", ...(submoduleCfg.roots ?? [])]; + const dirs = []; + for (const name of candidates) { + const full = join(ROOT, name); + if (existsSync(full) && statSync(full).isDirectory()) dirs.push(full); + } + // Prefer a dedicated skills/ if present; otherwise scan configured roots for SKILL.md + return dirs.length ? dirs : [join(ROOT, "skills")]; +} + +function walkSkills(dir, base = dir) { + const results = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith(".") || entry.name === "README.md") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + const skillFile = join(full, "SKILL.md"); + try { + statSync(skillFile); + results.push(parseSkill(skillFile, full, base)); + } catch { + results.push(...walkSkills(full, base)); + } + } + } + return results; +} + +function parseSkill(filePath, dirPath, base) { + const raw = readFileSync(filePath, "utf8"); + const rel = relative(ROOT, dirPath); + const segments = rel.split("/"); + // scope: first path segment as area (apps/tutorials/skillsets/...), else shared + const top = segments[0]; + const scope = + top === "skills" + ? segments[1] === "apps" + ? `app:${segments[2]}` + : "shared" + : `${top}:${segments[1] ?? top}`; + + let name = segments.at(-1); + let description = ""; + + const fm = raw.match(/^---\n([\s\S]*?)\n---/); + if (fm) { + for (const line of fm[1].split("\n")) { + const m = line.match(/^(\w+):\s*(.+)$/); + if (!m) continue; + if (m[1] === "name") name = m[2].trim(); + if (m[1] === "description") description = m[2].trim(); + } + } + + if (!description) { + const body = fm ? raw.slice(fm[0].length) : raw; + const h1 = body.match(/^#\s+(.+)/m); + const firstPara = body.replace(/^#.*\n+/m, "").trim().split("\n\n")[0]; + description = h1 ? h1[1].trim() : firstPara?.slice(0, 200) ?? ""; + } + + const excerpt = raw + .replace(/^---[\s\S]*?---\n?/, "") + .replace(/^#.*\n+/m, "") + .trim() + .slice(0, 500); + + return { + id: rel.replace(/\//g, "--"), + name, + description, + scope, + path: rel, + excerpt, + }; +} + +function loadRegistry() { + if (!existsSync(REGISTRY)) { + console.warn(`Registry not found: ${REGISTRY}`); + return []; + } + const raw = readFileSync(REGISTRY, "utf8"); + const doc = parseYaml(raw); + return doc.apps ?? []; +} + +const skillDirs = resolveSkillDirs(); +const skills = skillDirs.flatMap((dir) => walkSkills(dir, dir)); +// de-dupe by id if overlapping roots +const seen = new Set(); +const uniqueSkills = skills.filter((s) => { + if (seen.has(s.id)) return false; + seen.add(s.id); + return true; +}); + +const manifest = { + generatedAt: new Date().toISOString(), + skills: uniqueSkills, + apps: loadRegistry(), + config: { + registry: relative(ROOT, REGISTRY), + skillDirs: skillDirs.map((d) => relative(ROOT, d)), + submoduleRoots: submoduleCfg.roots ?? [], + }, +}; + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, JSON.stringify(manifest, null, 2)); +console.log( + `Wrote ${manifest.skills.length} skills, ${manifest.apps.length} apps → ${OUT}`, +); diff --git a/scripts/submodule.config.json b/scripts/submodule.config.json new file mode 100644 index 0000000..0316ea7 --- /dev/null +++ b/scripts/submodule.config.json @@ -0,0 +1,6 @@ +{ + "org": "variableway", + "host": "github.com", + "defaultRoot": "apps", + "roots": ["apps", "tutorials", "skillsets", "ai-infra"] +}