Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ shadow-dev issue execute --name <名称> --confirm
| `bind status` | 绑定状态 | 各宿主在场情况与托管技能清单 |
| `bind unbind` | 解绑 | `--host <名> --confirm`;按 sidecar 精确移除 |

产物能力契约(与 workflow 仓 `pack.mjs` 清单同源登记):产物 `package.json` 可声明 `requiresCommands: ["worktree.inspect", …]`,列出内容层依赖的命令键。CLI 在 **任何落盘动作之前** 拿自身命令目录比对:

- 缺失即 `ARTIFACT_INCOMPATIBLE`(退出码 1),`CURRENT`/`PREVIOUS`/`LINK` 一律不动,不留半成品版本目录;`workflow link` 直通轨同一断言。
- 未声明 = 兼容(旧产物零破坏);`workflow plan` 对可离线解析的来源(`--from` 目录/tarball)透出 `missingCommands` 预览,下载轨为 `null`。
- `workflow status` 回 `artifactVersion`/`cliVersion`/`missingCommands` 三元组,供引导页与巡检读一个信号即可判断「内容是否比 CLI 新」。

安装布局:

```
Expand Down Expand Up @@ -203,7 +209,7 @@ shadow-dev issue execute --name <名称> --confirm
| 码 | 含义 | 典型错误码 |
|----|------|-----------|
| 0 | 成功 | — |
| 1 | 输入/校验错误 | `PLAN_HASH_INVALID`、`BRIEF_NOT_FOUND`、`NAME_REQUIRED`、`TASKS_NOT_COMPLETE`、`REVIEW_NOT_PASSED`、`PR_NOT_MERGED`、`ARTIFACT_INVALID`、`UNMANAGED_TARGET`、`BLOG_FILE_NOT_FOUND`、`BLOG_TITLE_REQUIRED` |
| 1 | 输入/校验错误 | `PLAN_HASH_INVALID`、`BRIEF_NOT_FOUND`、`NAME_REQUIRED`、`TASKS_NOT_COMPLETE`、`REVIEW_NOT_PASSED`、`PR_NOT_MERGED`、`ARTIFACT_INVALID`、`ARTIFACT_INCOMPATIBLE`、`UNMANAGED_TARGET`、`BLOG_FILE_NOT_FOUND`、`BLOG_TITLE_REQUIRED` |
| 2 | 缺少确认或凭证 | `CONFIRMATION_REQUIRED`、`PLAN_HASH_REQUIRED`、`BLOG_FILE_REQUIRED` |
| 3 | 外部系统失败 | `GITHUB_TOKEN_REQUIRED`、`GITHUB_API_ERROR`、`API_TIMEOUT`、`GIT_PUSH_FAILED`、`DOWNLOAD_FAILED`、`RELEASE_NOT_FOUND` |
| 4 | 不支持的操作 | `UNSUPPORTED_OPERATION`(如 `git add .`、绝对路径) |
Expand Down Expand Up @@ -282,6 +288,7 @@ bash scripts/install-cli.sh unlink # 移除 CLI 的 LINK
| `GITHUB_TOKEN_REQUIRED` | `export GH_TOKEN="$(gh auth token)"` |
| `WORKFLOW_NOT_INSTALLED` | 先跑 workflow plan + execute,或直接跑 bootstrap |
| `ADAPTERS_MISSING` | workflow 产物过旧(< v6.3.1),更新产物 |
| `ARTIFACT_INCOMPATIBLE` | 产物声明的 `requiresCommands` 当前 CLI 不提供(内容比 CLI 新);先升级 CLI 再重新 `workflow plan`——拒绝发生在落盘前,`CURRENT`/`PREVIOUS`/`LINK` 与已装版本都不会被改动 |
| `UNMANAGED_TARGET` | 宿主目录存在同名非托管技能;手动移除后重试(绝不静默覆盖) |
| `DIRTY_WORKTREE` | 工作区有未提交业务改动,先 commit 或还原 |
| 插件 hook 提示「未能就位」 | 离线导致;联网后重开会话,或手动 `bash scripts/install-cli.sh install` |
Expand Down
8 changes: 4 additions & 4 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ export const COMMANDS = {
'blog.publish.execute': c('blog publish execute', '创建 Issue 并即时触发主站同步(无 brief 域,--plan-hash 为唯一凭证;同步失败不阻断)', 'create the issue and ping the sync endpoint (briefless: --plan-hash is the only credential; sync failure is best-effort)', [f('--file', true, '与 plan 相同的文章路径(参与 planHash 重算)', 'same article path as the plan (re-hashed for the planHash check)'), f('--repository', false, '与 plan 一致的目标仓库', 'same target repository as the plan'), f('--sync-url', false, '与 plan 一致的同步基址', 'same sync base url as the plan'), f('--plan-hash', true, 'blog publish plan 的输出', 'hash from blog publish plan'), CF], 'shadow-dev blog publish execute --file <article.md> --plan-hash <hash> --confirm', null),
'index.rebuild.plan': c('index rebuild plan', '预览变更索引重建', 'plan rebuilding the change index', [], 'shadow-dev index rebuild plan', 'index rebuild execute --plan-hash {planHash} --confirm'),
'index.rebuild.execute': c('index rebuild execute', '重建 INDEX.md(无 brief 域,--plan-hash 为唯一凭证)', 'rebuild INDEX.md (briefless command: --plan-hash is the only credential)', [f('--plan-hash', true, 'index rebuild plan 的输出', 'hash from index rebuild plan'), CF], 'shadow-dev index rebuild execute --plan-hash <hash> --confirm', null),
'workflow.plan': c('workflow plan', '预览 workflow 产物安装(--release/--from/latest 三路解析)', 'plan the workflow artifact install (--release/--from/latest)', [f('--release', false, '固定 release tag,缺省取 latest', 'pin a release tag; defaults to latest'), f('--from', false, '本地目录或 tarball(离线安装)', 'local dir or tarball (offline install)'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow plan', 'workflow execute --plan-hash {planHash} --confirm'),
'workflow.execute': c('workflow execute', '物化产物并切指针(先冒烟后落盘,留 PREVIOUS 供回滚)', 'materialize the artifact and flip pointers (smoke first, PREVIOUS kept for rollback)', [f('--plan-hash', true, 'workflow plan 的输出', 'hash from workflow plan'), f('--release', false, '固定 release tag', 'pin a release tag'), f('--from', false, '本地目录或 tarball', 'local dir or tarball'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow execute --plan-hash <hash> --confirm', 'bind plan --host auto'),
'workflow.plan': c('workflow plan', '预览 workflow 产物安装(--release/--from/latest 三路解析),离线可解析来源时透出 missingCommands 预览', 'plan the workflow artifact install (--release/--from/latest), previewing missingCommands whenever the source is resolvable offline', [f('--release', false, '固定 release tag,缺省取 latest', 'pin a release tag; defaults to latest'), f('--from', false, '本地目录或 tarball(离线安装)', 'local dir or tarball (offline install)'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow plan', 'workflow execute --plan-hash {planHash} --confirm'),
'workflow.execute': c('workflow execute', '物化产物:落盘前断言 requiresCommands,能力不齐 ARTIFACT_INCOMPATIBLE 且指针不动', 'materialize the artifact, asserting requiresCommands before any write; ARTIFACT_INCOMPATIBLE keeps the pointers untouched', [f('--plan-hash', true, 'workflow plan 的输出', 'hash from workflow plan'), f('--release', false, '固定 release tag', 'pin a release tag'), f('--from', false, '本地目录或 tarball', 'local dir or tarball'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow execute --plan-hash <hash> --confirm', 'bind plan --host auto'),
'workflow.rollback': c('workflow rollback', '回滚到上一版(离线,PREVIOUS/CURRENT 对调)', 'roll back to the previous version (offline pointer swap)', [CF, f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow rollback --confirm', null),
'workflow.status': c('workflow status', '查看 current/previous/linked 与解析出的产物根', 'show current/previous/linked and the resolved artifact root', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow status', null),
'workflow.link': c('workflow link', 'link 直通轨:指向本机 checkout,改动即生效', 'link track: point at a local checkout, edits take effect immediately', [f('--dir', true, '产物目录(含 marketplace.json/package.json/skills)', 'artifact dir with marketplace.json/package.json/skills'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow link --dir <path> --confirm', 'bind plan --host auto'),
'workflow.status': c('workflow status', '查看 current/previous/linked、产物根与版本三元组 artifactVersion/cliVersion/missingCommands', 'show current/previous/linked, the artifact root and the version triplet artifactVersion/cliVersion/missingCommands', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev workflow status', null),
'workflow.link': c('workflow link', 'link 直通轨:指向本机 checkout,改动即生效;同样先过能力断言', 'link track: point at a local checkout, edits take effect immediately; the capability assertion applies here too', [f('--dir', true, '产物目录(含 marketplace.json/package.json/skills)', 'artifact dir with marketplace.json/package.json/skills'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow link --dir <path> --confirm', 'bind plan --host auto'),
'workflow.unlink': c('workflow unlink', '移除 LINK,回落 CURRENT 版本', 'remove the LINK pointer and fall back to CURRENT', [f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev workflow unlink --confirm', null),
'bind.plan': c('bind plan', '预览按 adapters/<host>.json 把 skills 绑入宿主目录(--host auto 探测)', 'plan binding skills into host dirs via adapters/<host>.json (--host auto detects)', [f('--host', false, 'auto|claude-code|zcode…,缺省 auto', 'auto|claude-code|zcode…; defaults to auto'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix')], 'shadow-dev bind plan --host auto', 'bind execute --plan-hash {planHash} --confirm'),
'bind.execute': c('bind execute', '执行绑定(复制 + sidecar 托管标记,非托管同名目录拒绝覆盖)', 'run the binding (copy + sidecar marker; unmanaged targets are refused)', [f('--plan-hash', true, 'bind plan 的输出', 'hash from bind plan'), f('--host', false, 'auto|claude-code|zcode…', 'auto|claude-code|zcode…'), f('--prefix', false, '覆盖安装前缀', 'override the install prefix'), CF], 'shadow-dev bind execute --plan-hash <hash> --confirm', 'bind status'),
Expand Down
34 changes: 29 additions & 5 deletions lib/domains/workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { execFileSync, spawnSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { homedir, platform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { COMMANDS } from '../commands.mjs'
import { VERSION } from '../version.mjs'
import { err } from '../errors.mjs'
import { plan } from '../plan.mjs'
import { confirm } from '../input.mjs'
Expand Down Expand Up @@ -57,6 +59,24 @@ async function download(url, out) {
}

const verOf = d => { try { return JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).version } catch { throw err('ARTIFACT_INVALID', 'ARTIFACT_INVALID: package.json unreadable', 3) } }
const safeVer = d => { try { return JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).version ?? null } catch { return null } }

// 能力契约(产物 package.json 的可选第四件):requiresCommands 声明内容层需要的命令键。
// 判定发生在任何落盘之前——与「冒烟不过则 CURRENT 不动」同一安全语义(install-distribution 卡)。
// 排序保证 planHash 与投影字节确定;缺省/非数组 = 旧产物,视为兼容(零破坏)。
export const requiresOf = d => {
try {
const r = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')).requiresCommands
return Array.isArray(r) ? [...r].sort() : []
} catch { return [] }
}
export const missingFor = req => req.filter(k => !Object.hasOwn(COMMANDS, k))
export const missingCommandsOf = d => missingFor(requiresOf(d))
// 断言只在能离线解析来源时给 plan 透出预览;release/latest 轨下载前无从得知,恒为 null
function assertCapabilities(d, ver) {
const missing = missingCommandsOf(d)
if (missing.length) throw err('ARTIFACT_INCOMPATIBLE', `ARTIFACT_INCOMPATIBLE: artifact ${ver ?? '(unknown)'} requires commands this CLI ${VERSION} does not provide: ${missing.join(', ')} — upgrade shadow-dev, then plan again`, 1)
}

// 产物契约(与 workflow 仓 pack.mjs 布局同源):marketplace.json/package.json/skills 三件必备
export function requireArtifact(d) {
Expand All @@ -66,22 +86,23 @@ export function requireArtifact(d) {

// 计划:三路同源解析目标版本——--from(目录/tarball)、--release(固定 tag)、latest
export async function planData(_r, o) {
const base = { action: 'install', prefix: prefix(o), current: current(o), previous: previous(o), linked: linked(o) }
const base = { action: 'install', prefix: prefix(o), current: current(o), previous: previous(o), linked: linked(o), missingCommands: null }
if (o.from) {
const src = resolve(o.from)
if (statSync(src).isDirectory()) { requireArtifact(src); return { ...base, version: verOf(src), source: { dir: src } } }
if (statSync(src).isDirectory()) { requireArtifact(src); return { ...base, version: verOf(src), source: { dir: src }, missingCommands: missingCommandsOf(src) } }
const tmp = mkdtempSync(join(tmpdir(), 'wf-plan-'))
try {
untar(src, tmp)
const w = join(tmp, 'shadow-dev-workflow')
requireArtifact(w)
return { ...base, version: verOf(w), source: { tarball: resolve(src) } }
return { ...base, version: verOf(w), source: { tarball: resolve(src) }, missingCommands: missingCommandsOf(w) }
} finally { rmSync(tmp, { recursive: true, force: true }) }
}
const rel = await ghJson(o.release ? `/repos/${REPO}/releases/tags/${o.release}` : `/repos/${REPO}/releases/latest`)
const asset = (rel.assets || []).find(a => /^shadow-dev-workflow-v[0-9][0-9.]*\.tar\.gz$/.test(a.name))
if (!asset) throw err('RELEASE_NOT_FOUND', 'RELEASE_NOT_FOUND: release carries no shadow-dev-workflow tarball asset', 3)
return { ...base, version: String(rel.tag_name).replace(/^v/, ''), source: { url: asset.browser_download_url, tag: rel.tag_name } }
// 来源需下载后才可读:能力预览留 null,断言仍由 execute 在落盘前执行
return { ...base, version: String(rel.tag_name).replace(/^v/, ''), source: { url: asset.browser_download_url, tag: rel.tag_name }, missingCommands: null }
}

function prune(p, ver) {
Expand Down Expand Up @@ -111,6 +132,7 @@ export async function executeDomain(o) {
}
requireArtifact(src)
const p = x.prefix, ver = verOf(src)
assertCapabilities(src, ver)
const dest = join(p, `shadow-dev-workflow-${ver}`)
rmSync(dest, { recursive: true, force: true })
mkdirSync(p, { recursive: true })
Expand Down Expand Up @@ -139,6 +161,7 @@ export function linkDomain(o) {
if (!o.dir) throw err('DIR_REQUIRED', 'DIR_REQUIRED: pass --dir <path to shadow-dev-workflow checkout>', 1)
const target = resolve(o.dir)
requireArtifact(target)
assertCapabilities(target, verOf(target))
const p = prefix(o)
mkdirSync(p, { recursive: true })
// LINK 只删不写于 install 轨;语义互斥与 CLI 自身安装器同款(unlink 只删 LINK)
Expand All @@ -159,7 +182,8 @@ export function statusDomain(o) {
const l = readPtr(p, 'LINK')
const c = readPtr(p, 'CURRENT')
const resolved = l && existsSync(l) ? l : c && existsSync(join(p, `shadow-dev-workflow-${c}`)) ? join(p, `shadow-dev-workflow-${c}`) : null
return { prefix: p, current: c, previous: readPtr(p, 'PREVIOUS'), linked: l, resolved }
// 可观测面:status 恒不抛——产物不可读时版本为 null、能力缺口为空集
return { prefix: p, current: c, previous: readPtr(p, 'PREVIOUS'), linked: l, resolved, artifactVersion: resolved ? safeVer(resolved) : null, cliVersion: VERSION, missingCommands: resolved ? missingCommandsOf(resolved) : [] }
}

export async function handle(a, o) {
Expand Down
1 change: 1 addition & 0 deletions lib/i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const HINTS = {
API_TIMEOUT: { zh: 'GitHub API 超时(SHADOW_API_TIMEOUT_MS 可调)', en: 'GitHub API timed out (tune SHADOW_API_TIMEOUT_MS)' },
WORKFLOW_NOT_INSTALLED: { zh: 'workflow 产物未安装;先运行 workflow plan + workflow execute', en: 'workflow artifact not installed; run workflow plan + workflow execute first' },
ARTIFACT_INVALID: { zh: '产物缺少 marketplace.json/package.json/skills 契约文件', en: 'artifact is missing its marketplace.json/package.json/skills contract' },
ARTIFACT_INCOMPATIBLE: { zh: '产物声明的 requiresCommands 在当前 CLI 里不存在;升级 shadow-dev 后重新 plan', en: 'the artifact declares requiresCommands this CLI does not provide; upgrade shadow-dev and plan again' },
RELEASE_NOT_FOUND: { zh: '找不到 release 或其 tarball 资产(--release 可固定 tag)', en: 'release or its tarball asset not found (pin one with --release)' },
DOWNLOAD_FAILED: { zh: 'release 资产下载失败(网络或 URL 形态异常)', en: 'release asset download failed (network or malformed url)' },
NO_PREVIOUS: { zh: '没有可回滚的上一版本', en: 'no previous version available for rollback' },
Expand Down
Loading
Loading