yadflow 4.0.0-next.3 → 4.0.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.
- package/CHANGELOG.md +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// `yad new` · `yad init` · `yad join` (E79) — the three ways into a workspace.
|
|
2
|
+
//
|
|
3
|
+
// A WORKSPACE is the folder that holds the Product and the code repos side by side:
|
|
4
|
+
//
|
|
5
|
+
// my-product/
|
|
6
|
+
// product/ <- the Product (goals, epics, decisions) — its own git repo
|
|
7
|
+
// backend/ <- a code repo, registered in product/.sdlc/repos.json as `../backend`
|
|
8
|
+
// frontend/
|
|
9
|
+
//
|
|
10
|
+
// The workspace is the Product folder's parent (`insideWorkspace` in setup.mjs, issue #129). All three
|
|
11
|
+
// commands write `.yad-workspace.json` there (E80, cli/find-product.mjs), so yad finds the Product from
|
|
12
|
+
// inside any repo it registers.
|
|
13
|
+
//
|
|
14
|
+
// yad new <name> greenfield: make <name>/product/, `git init` it, run setup inside it
|
|
15
|
+
// yad init brownfield: in a folder that already holds code repos, make (or pick) the
|
|
16
|
+
// Product folder, run setup, and offer the repos found beside it
|
|
17
|
+
// yad join <url> anyone else: clone the Product and every repo it registers, then do the
|
|
18
|
+
// per-machine steps only
|
|
19
|
+
//
|
|
20
|
+
// `yad setup` is unchanged: new and init are front doors over it, not a replacement.
|
|
21
|
+
//
|
|
22
|
+
// NOTHING HERE WRITES OUTSIDE THIS MACHINE. `new` never creates a remote — it prints the exact
|
|
23
|
+
// `gh repo create` / `glab repo create` line. `join` never commits, never pushes, and never changes a
|
|
24
|
+
// file the team shares: it installs only what git ignores in the Product (the per-machine skill copies)
|
|
25
|
+
// and the git pre-commit hook, which git never shares (E48).
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { c, log, ok, info, warn, hand, fail, run, exists, readJSON, readJSONStrict, forTerminal } from './lib.mjs';
|
|
31
|
+
import { productConfigPath, PROJECT_FILES } from './manifest.mjs';
|
|
32
|
+
import { runSetup, insideWorkspace, throughGitDir, selectIdeTargets } from './setup.mjs';
|
|
33
|
+
import { moduleActions, gitHookActions } from './plan.mjs';
|
|
34
|
+
import { writeWorkspaceFile, WORKSPACE_FILE } from './find-product.mjs';
|
|
35
|
+
|
|
36
|
+
// E80: the file that lets yad find the Product from inside the repos it registers. Written by all three.
|
|
37
|
+
function noteWorkspaceFile(product) {
|
|
38
|
+
const r = writeWorkspaceFile(product);
|
|
39
|
+
if (r === 'written') ok(`wrote ${WORKSPACE_FILE} — yad finds the Product from any repo it registers`);
|
|
40
|
+
else if (r.startsWith('skipped')) warn(`${WORKSPACE_FILE} not written (${r.replace(/^skipped: /, '')})`);
|
|
41
|
+
return r;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The Product's folder inside a workspace. `join` clones to it and `new` creates it, so a workspace
|
|
45
|
+
// made by `new` and one made by `join` from its remote look the same.
|
|
46
|
+
export const PRODUCT_DIR = 'product';
|
|
47
|
+
|
|
48
|
+
// A folder name we create: letters, digits, `.`, `_`, `-`, not starting with `.` or `-`. No separator,
|
|
49
|
+
// so it is always one folder under the one it is made in.
|
|
50
|
+
const FOLDER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
51
|
+
export const validFolderName = (name) => typeof name === 'string' && FOLDER_RE.test(name);
|
|
52
|
+
|
|
53
|
+
// Text read from a shared file is shown in the terminal through `shown` — `forTerminal` in lib.mjs, one
|
|
54
|
+
// cleaner for every module that prints registry text (E81 review 12).
|
|
55
|
+
export const shown = forTerminal;
|
|
56
|
+
|
|
57
|
+
// A folder as the person's shell reaches it: relative to where they ran yad (which `--dir` may not
|
|
58
|
+
// be), quoted when it holds a space.
|
|
59
|
+
const fromShell = (dir) => { const r = path.relative(process.cwd(), dir) || '.'; return /\s/.test(r) ? `"${r}"` : r; };
|
|
60
|
+
|
|
61
|
+
const isEmptyDir = (dir) => { try { return fs.readdirSync(dir).length === 0; } catch { return false; } };
|
|
62
|
+
// Empty, or holding only `.git`: a fresh clone of an empty remote.
|
|
63
|
+
const isBareStart = (dir) => { try { return fs.readdirSync(dir).every((n) => n === '.git'); } catch { return false; } };
|
|
64
|
+
|
|
65
|
+
// `insideWorkspace` and `throughGitDir` judge the path TEXT. A link the Product commits makes the text
|
|
66
|
+
// lie: `evil -> ../../..` carries `evil/x` outside the workspace, and `g -> .git` carries `g/hooks` into
|
|
67
|
+
// git's own storage, where a cloned hook or config runs commands (E79 reviews 1 and 3). So a clone
|
|
68
|
+
// target is refused when any folder on the way to it, below the workspace, is a link — a dangling one
|
|
69
|
+
// too. What is not there yet is made by this clone, so it cannot be a link.
|
|
70
|
+
function throughLink(productRoot, target) {
|
|
71
|
+
const root = path.resolve(productRoot);
|
|
72
|
+
const workspace = path.dirname(root) === root ? root : path.dirname(root);
|
|
73
|
+
let cur = workspace;
|
|
74
|
+
for (const part of path.relative(workspace, target).split(path.sep).filter(Boolean)) {
|
|
75
|
+
cur = path.join(cur, part);
|
|
76
|
+
let st;
|
|
77
|
+
try { st = fs.lstatSync(cur); } catch { return false; }
|
|
78
|
+
if (st.isSymbolicLink()) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const insideGitRepo = (dir) => run('git', ['rev-parse', '--show-toplevel'], { cwd: dir });
|
|
83
|
+
|
|
84
|
+
// The folder `join` makes from a remote URL: its last path segment without `.git`. So the remote
|
|
85
|
+
// `new acme` tells you to create (`acme`) joins back into `acme/`.
|
|
86
|
+
export function workspaceNameFromUrl(url) {
|
|
87
|
+
const last = String(url || '').replace(/[\\/]+$/, '').split(/[\\/:]/).pop() || '';
|
|
88
|
+
return last.replace(/\.git$/i, '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// `git init` on a named branch. `-b` needs git 2.28; an older git gets the same result from pointing
|
|
92
|
+
// the unborn HEAD at the branch.
|
|
93
|
+
export function gitInit(dir, branch = 'main') {
|
|
94
|
+
if (run('git', ['init', '-q', '-b', branch], { cwd: dir }).ok) return true;
|
|
95
|
+
if (!run('git', ['init', '-q'], { cwd: dir }).ok) return false;
|
|
96
|
+
return run('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: dir }).ok;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// How a clone may reach its source. `join`'s Product URL is the person's own, typed by them: git's own
|
|
100
|
+
// rules. A REGISTERED repo's URL was chosen by the shared registry, so git is told which transports it
|
|
101
|
+
// may use, by `GIT_ALLOW_PROTOCOL` (git 2.6.1+) — which git obeys over every `protocol.*.allow` setting,
|
|
102
|
+
// so a value the person's environment carries cannot widen it (E81 review 5) — and by
|
|
103
|
+
// `protocol.file.allow` as a second word. A network URL gets the network transports only, never the
|
|
104
|
+
// file one: git reads `evil.com:x` as a LOCAL folder when one by that name exists (E81 reviews 3–4). A
|
|
105
|
+
// local source (the YAD_ALLOW_LOCAL_REMOTES door) gets the file transport only.
|
|
106
|
+
const TRANSPORTS = { network: 'https:http:ssh:git', local: 'file' };
|
|
107
|
+
function gitClone(url, target, env, { kind = null } = {}) {
|
|
108
|
+
const allow = TRANSPORTS[kind];
|
|
109
|
+
const args = allow ? ['-c', `protocol.file.allow=${kind === 'local' ? 'always' : 'never'}`] : [];
|
|
110
|
+
const r = spawnSync('git', [...args, 'clone', '-q', '--', url, target], { encoding: 'utf8', env: allow ? { ...env, GIT_ALLOW_PROTOCOL: allow } : env, windowsHide: true });
|
|
111
|
+
let error = (r.stderr || r.error?.message || '').trim().split('\n').pop() || `git clone exited ${r.status}`;
|
|
112
|
+
if (kind === 'network' && /transport '.*' not allowed/.test(error)) error += ' — a registered repo is cloned over the network only (an `insteadOf` rule of yours may point it at a local folder)';
|
|
113
|
+
return { ok: r.status === 0, error };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A registered repo's `git_url` is shared text, handed to `git clone`. Only a network address is cloned:
|
|
117
|
+
// `https://`, `http://`, `ssh://`, `git://`, or scp-style `user@host:path` / `host:path` (an ssh alias
|
|
118
|
+
// too). A plain path, `file://` or any `<helper>::` URL is refused — a local path can name a folder the
|
|
119
|
+
// Product itself commits (E81 review 3). The pattern is the first gate; the transports `gitClone` allows
|
|
120
|
+
// are the second.
|
|
121
|
+
//
|
|
122
|
+
// A team whose remotes really are on a disk sets YAD_ALLOW_LOCAL_REMOTES=1: then a local path or
|
|
123
|
+
// `file://` URL is cloned when it names a FOLDER THAT EXISTS outside the workspace. It is resolved on
|
|
124
|
+
// disk (links followed, `file:///C:/x` read as Windows does, `%` refused because git decodes it), and git
|
|
125
|
+
// is handed that resolved path — never the typed text, which git could read another way (it tries
|
|
126
|
+
// `<path>.git` when `<path>` is not there; E81 review 5). Compared without case where the disk ignores it.
|
|
127
|
+
// The host keeps to letters, digits, `.`, `_` and `-` (an IPv6 host in brackets), and the user and
|
|
128
|
+
// password to those plus `~` and `+`, in every form git hands to ssh: an ssh config whose `ProxyCommand`
|
|
129
|
+
// or `Match exec` uses `%h` or `%r` on an OpenSSH before 9.6 puts them in a shell command
|
|
130
|
+
// (CVE-2023-51385; E81 reviews 6–7). No `%` there: git decodes `%24%28` to `$(` before ssh sees it.
|
|
131
|
+
// Only `http(s)://`, which goes to curl and never to ssh, may carry `%` in its user and password (a
|
|
132
|
+
// login that is an email address, `me%40corp.com@`).
|
|
133
|
+
// Each starts with a letter or digit — never `-`, which ssh would read as an option (git refuses it too).
|
|
134
|
+
const HOST = String.raw`(?:\w[\w-]*(?:\.[\w-]+)*|\[[0-9a-f:.]+\])`;
|
|
135
|
+
const USER = String.raw`\w[\w.~+-]*`;
|
|
136
|
+
const HTTP_USER = String.raw`[\w%][\w.~%+-]*`;
|
|
137
|
+
const NETWORK_URL_RE = new RegExp(String.raw`^(?![^/]*::)(?:https?:\/\/(?:${HTTP_USER}(?::${HTTP_USER}?)?@)?${HOST}(?::\d*)?(?:\/|$)|(?:ssh|git|git\+ssh|ssh\+git):\/\/(?:${USER}(?::${USER}?)?@)?${HOST}(?::\d*)?(?:\/|$)|${USER}@${HOST}:(?!\/\/)|\w[\w-]+(?:\.[\w-]+)*:(?!\/\/))`, 'i');
|
|
138
|
+
// A control character anywhere in the URL, typed or %-encoded (`%0d`, `%1b`), is refused. git decodes
|
|
139
|
+
// the user, password and path of a `scheme://` URL and writes them to the person's credential helper one
|
|
140
|
+
// `key=value` per line: a carriage return there makes a helper that ends lines on it (Git Credential
|
|
141
|
+
// Manager does) read `host=github.com` and hand that token to the URL's own host (CVE-2024-52006); an
|
|
142
|
+
// escape in the user repaints the terminal in git's password prompt (CVE-2024-50349). git before 2.48.1
|
|
143
|
+
// guards against neither (E81 review 8). The C1 controls (U+0080–U+009F — U+009B starts a terminal
|
|
144
|
+
// escape), the line and paragraph separators and the bidi controls are refused raw or as their UTF-8
|
|
145
|
+
// %-escapes too (`%c2%9b`, `%e2%80%ae`; E81 review 9). `%40` and every other printable escape pass.
|
|
146
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
147
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200e\u200f\u202a-\u202e\u2066-\u2069\u061c]|%(?:[01][0-9a-f]|7f|c2%[89][0-9a-f]|d8%9c|e2%80%(?:a[89a-e]|8[ef])|e2%81%a[6-9])/i;
|
|
148
|
+
export const localRemotesAllowed = (env = process.env) => env.YAD_ALLOW_LOCAL_REMOTES === '1';
|
|
149
|
+
const folded = (p) => (process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p);
|
|
150
|
+
const under = (p, dir) => p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep);
|
|
151
|
+
// { kind: 'network', url } — the URL as recorded; { kind: 'local', url } — the resolved folder; or null.
|
|
152
|
+
export function cloneSource(productRoot, url, env = process.env) {
|
|
153
|
+
if (CONTROL_RE.test(url)) return null;
|
|
154
|
+
if (NETWORK_URL_RE.test(url)) return { kind: 'network', url };
|
|
155
|
+
if (!localRemotesAllowed(env) || url.includes('%') || url.includes('::')) return null;
|
|
156
|
+
let p = url;
|
|
157
|
+
if (/^file:/i.test(url)) { try { p = fileURLToPath(url); } catch { return null; } }
|
|
158
|
+
if (!path.isAbsolute(p)) return null;
|
|
159
|
+
let real;
|
|
160
|
+
try { real = fs.realpathSync.native(p); if (!fs.statSync(real).isDirectory()) return null; } catch { return null; }
|
|
161
|
+
const root = path.resolve(productRoot);
|
|
162
|
+
let workspace = path.dirname(root) === root ? root : path.dirname(root);
|
|
163
|
+
try { workspace = fs.realpathSync.native(workspace); } catch { /* as resolved */ }
|
|
164
|
+
return under(folded(real), folded(workspace)) ? null : { kind: 'local', url: real };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A judged entry git may run in: a checkout that is there (or the Product itself), and not reached through
|
|
168
|
+
// a link inside a repo's tree (E81). Doctor's checks and `sync`'s stale count use it; the `list`,
|
|
169
|
+
// `refresh` and `sync` loops branch on the same states themselves, to say which one applies.
|
|
170
|
+
export const runnable = (j) => (j.state === 'present' || j.state === 'product') && !j.linked;
|
|
171
|
+
|
|
172
|
+
// A clone that needs a password must not sit waiting at a prompt nobody will answer.
|
|
173
|
+
const cloneEnv = () => (process.env.SDLC_NONINTERACTIVE ? { ...process.env, GIT_TERMINAL_PROMPT: '0' } : process.env);
|
|
174
|
+
|
|
175
|
+
// WHERE A REGISTRY ENTRY STANDS ON THIS MACHINE, judged before any git runs (E81). Shared by every
|
|
176
|
+
// command that acts on the registered repos — the clone step (`yad join`, `yad repo clone`), `yad repo
|
|
177
|
+
// list`, `yad repo sync` and `yad doctor` — so one never says "run X" for an entry X then refuses.
|
|
178
|
+
//
|
|
179
|
+
// The registry is SHARED content — anyone who can push to the Product wrote it — so a path outside the
|
|
180
|
+
// workspace is refused (the same bound setup keeps), a path through a `.git` folder or a link is refused,
|
|
181
|
+
// and a folder that exists but holds no `.git` is refused too: a Product can commit a folder shaped like
|
|
182
|
+
// a BARE git repo (`HEAD`, `config`, `objects/`), and git run there reads that `config`, whose
|
|
183
|
+
// `core.fsmonitor` runs a command. The Product itself (`.`, a monorepo) is its own state.
|
|
184
|
+
//
|
|
185
|
+
// Returns { name, path, target, state, reason?, inside?, linked?, url?, urlKind? } — `url` and `urlKind`
|
|
186
|
+
// ('network' | 'local') only for 'missing': what the clone step hands git. State is one of:
|
|
187
|
+
// 'product' the Product itself; never cloned
|
|
188
|
+
// 'present' a git checkout is there (`.git` at the path), or the path is a folder inside one (`inside`
|
|
189
|
+
// names the checkout). `linked` is set when a folder on the way, deeper than the workspace
|
|
190
|
+
// folder itself, is a link — see `inRepoLink`
|
|
191
|
+
// 'missing' nothing there yet, and the clone step can make it
|
|
192
|
+
// 'refused' not there, or not a checkout, and the clone step will not make it; `reason` says why
|
|
193
|
+
export function judgeRepo(productRoot, repo) {
|
|
194
|
+
if (!repo || typeof repo !== 'object' || Array.isArray(repo)) return { name: '(not an entry)', path: '', state: 'refused', reason: 'this entry in repos.json is not an object' };
|
|
195
|
+
const name = shown(repo?.name || '(unnamed)');
|
|
196
|
+
const rpath = typeof repo?.path === 'string' ? repo.path : '';
|
|
197
|
+
const entry = { name, path: shown(rpath) };
|
|
198
|
+
if (!rpath) return { ...entry, state: 'refused', reason: 'no path recorded' };
|
|
199
|
+
const target = path.resolve(productRoot, rpath);
|
|
200
|
+
const at = { ...entry, target };
|
|
201
|
+
if (target === path.resolve(productRoot)) return { ...at, state: 'product', reason: 'the Product itself' };
|
|
202
|
+
if (throughGitDir(productRoot, rpath)) return { ...at, state: 'refused', reason: 'it runs through a .git folder — git\'s own storage, never a code repo' };
|
|
203
|
+
if (!insideWorkspace(productRoot, rpath)) return { ...at, state: 'refused', reason: 'outside the workspace (the Product folder\'s parent)' };
|
|
204
|
+
if (exists(path.join(target, '.git'))) return { ...at, state: 'present', ...(inRepoLink(productRoot, target) ? { linked: true } : {}) };
|
|
205
|
+
// A folder inside a checkout (the monorepo layout: `apps/web` in the Product's own repo) has no `.git`
|
|
206
|
+
// of its own, and git finds the checkout by walking up. It is present when no link deeper than the
|
|
207
|
+
// workspace folder is on the way (so the walk git makes is the one read here — a link of the person's
|
|
208
|
+
// own directly in the workspace folder is followed, as for a checkout) and no folder on the way could
|
|
209
|
+
// be taken for a bare repo, where git would stop and read that folder's `config`.
|
|
210
|
+
const top = exists(target) && !inRepoLink(productRoot, target) ? enclosingCheckout(productRoot, target) : null;
|
|
211
|
+
if (top) {
|
|
212
|
+
if (headOnWay(target, top)) return { ...at, state: 'refused', reason: 'a folder on its way holds an entry named HEAD, so git could take it for a bare repo and read its config' };
|
|
213
|
+
return { ...at, state: 'present', inside: top };
|
|
214
|
+
}
|
|
215
|
+
if (throughLink(productRoot, target)) return { ...at, state: 'refused', reason: 'a folder on its path is a link, so where the clone lands is not what the path says' };
|
|
216
|
+
if (exists(target) && !isEmptyDir(target)) return { ...at, state: 'refused', reason: 'the folder exists and is not a git repo — move it aside and re-run' };
|
|
217
|
+
const url = typeof repo.git_url === 'string' ? repo.git_url.trim() : '';
|
|
218
|
+
if (!url) return { ...at, state: 'refused', reason: 'no git_url recorded — clone it by hand' };
|
|
219
|
+
if (url.startsWith('-')) return { ...at, state: 'refused', reason: 'the recorded git_url starts with "-"' };
|
|
220
|
+
const src = cloneSource(productRoot, url);
|
|
221
|
+
if (!src) return { ...at, state: 'refused', reason: 'the recorded git_url is not a network address (https://, http://, ssh://, git://, user@host:path or host:path) — clone it by hand, or set YAD_ALLOW_LOCAL_REMOTES=1 for a local folder outside the workspace' };
|
|
222
|
+
return { ...at, state: 'missing', url: src.url, urlKind: src.kind };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The nearest folder above `target`, below the workspace folder, that holds `.git`: the checkout git
|
|
226
|
+
// finds from `target`. Null when there is none.
|
|
227
|
+
function enclosingCheckout(productRoot, target) {
|
|
228
|
+
const root = path.resolve(productRoot);
|
|
229
|
+
const workspace = path.dirname(root) === root ? root : path.dirname(root);
|
|
230
|
+
for (let cur = path.dirname(target); cur.startsWith(workspace + path.sep); cur = path.dirname(cur)) {
|
|
231
|
+
if (exists(path.join(cur, '.git'))) return cur;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Git takes a folder for a bare repo only when it holds `HEAD` — then `objects/` and `refs/` may come
|
|
237
|
+
// from anywhere (a `commondir` file points elsewhere), and `HEAD` may be a link that points nowhere
|
|
238
|
+
// (E81 review 2 made git run a command both ways). So the one sound test is the part git always needs:
|
|
239
|
+
// ANY entry named `HEAD`, of any kind, read with `lstat` so a dangling link counts. The file system
|
|
240
|
+
// matches the name as git's own lookup does (without case on macOS and Windows). From `target` up to,
|
|
241
|
+
// not including, the checkout `top`.
|
|
242
|
+
const hasHead = (dir) => { try { fs.lstatSync(path.join(dir, 'HEAD')); return true; } catch { return false; } };
|
|
243
|
+
function headOnWay(target, top) {
|
|
244
|
+
for (let cur = target; cur !== top && cur.startsWith(top + path.sep); cur = path.dirname(cur)) {
|
|
245
|
+
if (hasHead(cur)) return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Where `yad repo refresh` writes a repo's pack and where `--push` stages its code-map: from the SHARED
|
|
251
|
+
// registry (`contextPack`, `codeMap`, or a default built from the repo's `name`). Allowed only under
|
|
252
|
+
// `.sdlc/code-context/` in the Product, with no `..`, no `.git` part and no link on the way — else a
|
|
253
|
+
// registry entry chooses a file outside the Product to write, or one inside it to commit and push.
|
|
254
|
+
export const CODE_CONTEXT_DIR = '.sdlc/code-context';
|
|
255
|
+
export function codeContextPathOk(productRoot, rel) {
|
|
256
|
+
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) return false;
|
|
257
|
+
const norm = path.posix.normalize(rel.replace(/\\/g, '/'));
|
|
258
|
+
if (!norm.startsWith(`${CODE_CONTEXT_DIR}/`)) return false;
|
|
259
|
+
const parts = norm.split('/');
|
|
260
|
+
if (parts.some((p) => p === '..' || /^\.git$/i.test(p.split(':')[0].replace(/[. ]+$/, '')) || /^git~\d+$/i.test(p))) return false;
|
|
261
|
+
let cur = path.resolve(productRoot);
|
|
262
|
+
for (const part of parts) {
|
|
263
|
+
cur = path.join(cur, part);
|
|
264
|
+
let st;
|
|
265
|
+
try { st = fs.lstatSync(cur); } catch { return true; }
|
|
266
|
+
if (st.isSymbolicLink()) return false;
|
|
267
|
+
}
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A link on the way to a PRESENT repo that lies inside some repo's tree — deeper than the workspace
|
|
272
|
+
// folder's own children. The workspace folder is the person's: `ws/backend -> /src/backend` is their own
|
|
273
|
+
// layout, and a clone there is theirs. A link any deeper was written by whoever writes that repo — the
|
|
274
|
+
// Product's `evil -> ../../..` — so `yad repo sync` will not run git through it.
|
|
275
|
+
function inRepoLink(productRoot, target) {
|
|
276
|
+
const root = path.resolve(productRoot);
|
|
277
|
+
const workspace = path.dirname(root) === root ? root : path.dirname(root);
|
|
278
|
+
const parts = path.relative(workspace, target).split(path.sep).filter(Boolean);
|
|
279
|
+
let cur = workspace;
|
|
280
|
+
for (const [i, part] of parts.entries()) {
|
|
281
|
+
cur = path.join(cur, part);
|
|
282
|
+
let st;
|
|
283
|
+
try { st = fs.lstatSync(cur); } catch { return false; }
|
|
284
|
+
if (i > 0 && st.isSymbolicLink()) return true;
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// THE CLONE STEP — `yad join` and `yad repo clone` (E81). For each repo the registry lists that
|
|
290
|
+
// `judgeRepo` finds missing, put a clone at its `path` (relative to the Product). The URL is passed after
|
|
291
|
+
// `--` so it can never be read as a git option. A failure is recorded and the next repo is tried: one bad
|
|
292
|
+
// entry never stops the rest (the roadmap: "partial clone failures never fail the whole join"). A present
|
|
293
|
+
// repo is only ever skipped — nothing here runs git in it.
|
|
294
|
+
//
|
|
295
|
+
// Returns { cloned, present, failed }, each a list of { name, path, reason? }.
|
|
296
|
+
export function cloneMissingRepos(productRoot, registry, { clone = gitClone, env = cloneEnv() } = {}) {
|
|
297
|
+
const out = { cloned: [], present: [], failed: [] };
|
|
298
|
+
// A `repos` that is not a list (`{}`, `5`) is a broken shared file: nothing to clone, and the caller
|
|
299
|
+
// says so — the per-machine steps still run.
|
|
300
|
+
for (const repo of Array.isArray(registry?.repos) ? registry.repos : []) {
|
|
301
|
+
const j = judgeRepo(productRoot, repo);
|
|
302
|
+
const entry = { name: j.name, path: j.path };
|
|
303
|
+
if (j.state === 'product') { out.present.push({ ...entry, reason: j.reason }); continue; }
|
|
304
|
+
if (j.state === 'present') { out.present.push(entry); continue; }
|
|
305
|
+
if (j.state === 'refused') { out.failed.push({ ...entry, reason: j.reason }); continue; }
|
|
306
|
+
// A path through a file (`.sdlc/hub.json/x`) or a dangling link fails here — for this entry only.
|
|
307
|
+
let r;
|
|
308
|
+
try {
|
|
309
|
+
fs.mkdirSync(path.dirname(j.target), { recursive: true });
|
|
310
|
+
// The transports by what the URL passed as (E81 reviews 4–5): see `gitClone`.
|
|
311
|
+
r = clone(j.url, j.target, env, { kind: j.urlKind });
|
|
312
|
+
} catch (e) { r = { ok: false, error: `cannot make its folder (${e.code || e.message})` }; }
|
|
313
|
+
if (r.ok) out.cloned.push(entry);
|
|
314
|
+
else out.failed.push({ ...entry, reason: shown(r.error) });
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// What the clone step did, said the same way by `join` and `repo clone`.
|
|
320
|
+
export function reportClones(repos) {
|
|
321
|
+
for (const r of repos.cloned) ok(`cloned ${r.name} → ${r.path}`);
|
|
322
|
+
for (const r of repos.present) info(`${r.name}: ${r.reason || 'already there'}`);
|
|
323
|
+
for (const r of repos.failed) warn(`${r.name} (${r.path}): ${r.reason}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The Product's registry, read strictly (E81, from join): a file that does not parse (a merge-conflict
|
|
327
|
+
// marker left in it) must be SAID, not read as "no repos". Returns { registry, problem } — `problem` is
|
|
328
|
+
// the sentence to show, with the file's absolute path (a home folder name, spaces and all) taken out;
|
|
329
|
+
// `registry` is then `{ repos: [] }`. A file that parses and holds no list (`null`, `[]`, `5`) is a
|
|
330
|
+
// problem too.
|
|
331
|
+
export function readRegistry(productRoot) {
|
|
332
|
+
const regFile = path.join(productRoot, PROJECT_FILES.reposRegistry);
|
|
333
|
+
let registry;
|
|
334
|
+
try { registry = readJSONStrict(regFile, { repos: [] }); }
|
|
335
|
+
catch (e) {
|
|
336
|
+
const why = (e.message || '').replace(`corrupt JSON in ${regFile}: `, '').split(regFile).join(PROJECT_FILES.reposRegistry);
|
|
337
|
+
return { registry: { repos: [] }, problem: `${PROJECT_FILES.reposRegistry} in the Product cannot be read (${shown(why)})` };
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(registry?.repos)) return { registry: { repos: [] }, problem: `${PROJECT_FILES.reposRegistry} has no list of repos` };
|
|
340
|
+
return { registry, problem: null };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The lines that put the Product on a platform — printed, never run.
|
|
344
|
+
export function remoteSteps({ name, platform, branch = 'main' }) {
|
|
345
|
+
const first = ['git add -A', `git commit -m "chore: start the ${name} Product"`];
|
|
346
|
+
if (platform === 'github') return [...first, `gh repo create ${name} --private --source=. --remote=origin --push`];
|
|
347
|
+
if (platform === 'gitlab') return [...first, `glab repo create ${name} --private`, 'git remote add origin <the URL glab printed>', `git push -u origin ${branch}`];
|
|
348
|
+
return [...first, 'git remote add origin <your remote URL>', `git push -u origin ${branch}`];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const refuse = (message) => { fail(message); process.exitCode = 1; return null; };
|
|
352
|
+
|
|
353
|
+
// ---- yad new <name> ------------------------------------------------------------------------------
|
|
354
|
+
export async function runNew(cwd, name, opts = {}) {
|
|
355
|
+
log(c.bold('\nyad new'));
|
|
356
|
+
if (!validFolderName(name)) return refuse(`usage: yad new <name> — a folder name of letters, digits, ".", "_" or "-" (got ${name === undefined ? 'nothing' : JSON.stringify(shown(name))})`);
|
|
357
|
+
const inside = insideGitRepo(cwd);
|
|
358
|
+
if (inside.ok) return refuse(`${cwd} is inside the git repo ${inside.stdout} — a workspace holds its repos side by side, so run \`yad new\` from a folder outside any repo`);
|
|
359
|
+
const workspace = path.join(cwd, name);
|
|
360
|
+
if (exists(workspace) && !isEmptyDir(workspace)) return refuse(`${workspace} already exists and is not empty — pick another name, or run \`yad init\` inside it to connect what is there`);
|
|
361
|
+
const product = path.join(workspace, PRODUCT_DIR);
|
|
362
|
+
fs.mkdirSync(product, { recursive: true });
|
|
363
|
+
if (!gitInit(product)) return refuse(`git init failed in ${product}`);
|
|
364
|
+
ok(`created ${path.join(name, PRODUCT_DIR)}/ (git, branch main)`);
|
|
365
|
+
|
|
366
|
+
const setup = await runSetup(product, { ...opts, greenfield: !opts.brownfield });
|
|
367
|
+
if (process.exitCode) return null;
|
|
368
|
+
noteWorkspaceFile(product);
|
|
369
|
+
|
|
370
|
+
// Setup asked for the default branch; the repo has no commit yet, so HEAD can still follow the answer.
|
|
371
|
+
const productConfig = readJSON(productConfigPath(product), {}) || {};
|
|
372
|
+
const branch = productConfig.default_branch || 'main';
|
|
373
|
+
if (branch !== 'main' && !run('git', ['rev-parse', '-q', '--verify', 'HEAD'], { cwd: product }).ok) {
|
|
374
|
+
run('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: product });
|
|
375
|
+
}
|
|
376
|
+
const steps = remoteSteps({ name, platform: productConfig.platform, branch });
|
|
377
|
+
log('');
|
|
378
|
+
log(c.bold('Put the Product on your platform') + c.dim(' (yad creates nothing outside this machine — run these yourself):'));
|
|
379
|
+
log(` cd ${fromShell(product)}`);
|
|
380
|
+
for (const s of steps) log(` ${s}`);
|
|
381
|
+
hand('teammates then join with: `yad join <the Product\'s clone URL>`');
|
|
382
|
+
hand(`then, from here: cd ${fromShell(product)} && yad next`);
|
|
383
|
+
return { workspace, product, platform: productConfig.platform ?? null, branch, remoteSteps: steps, setup };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---- yad init ------------------------------------------------------------------------------------
|
|
387
|
+
export async function runInit(cwd, opts = {}) {
|
|
388
|
+
log(c.bold('\nyad init'));
|
|
389
|
+
const inside = insideGitRepo(cwd);
|
|
390
|
+
if (inside.ok) {
|
|
391
|
+
return refuse(`${cwd} is inside the git repo ${inside.stdout}. \`yad init\` runs in the folder that HOLDS your repos. For one repo that holds all the code, run \`yad setup --brownfield --monorepo\` inside it instead`);
|
|
392
|
+
}
|
|
393
|
+
const children = fs.readdirSync(cwd, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name).sort();
|
|
394
|
+
let productName = opts.path;
|
|
395
|
+
if (productName !== undefined && !validFolderName(productName)) return refuse(`--path must be one folder name (got ${JSON.stringify(shown(productName))})`);
|
|
396
|
+
if (productName === undefined) {
|
|
397
|
+
const products = children.filter((n) => exists(productConfigPath(path.join(cwd, n))));
|
|
398
|
+
if (products.length > 1) return refuse(`more than one Product here (${products.join(', ')}) — name the one to use with --path <folder>`);
|
|
399
|
+
productName = products[0] || PRODUCT_DIR;
|
|
400
|
+
}
|
|
401
|
+
const product = path.join(cwd, productName);
|
|
402
|
+
// An existing folder becomes the Product only when it already is one, or has nothing in it yet (a
|
|
403
|
+
// fresh clone of an empty remote holds just `.git`). Anything else is someone's code: setup would
|
|
404
|
+
// write the Product's files into it.
|
|
405
|
+
if (exists(product) && !exists(productConfigPath(product)) && !isBareStart(product)) {
|
|
406
|
+
return refuse(`${productName}/ already holds files and is not a Product — name a new folder for the Product with --path <folder>`);
|
|
407
|
+
}
|
|
408
|
+
if (!exists(product)) {
|
|
409
|
+
fs.mkdirSync(product);
|
|
410
|
+
if (!gitInit(product)) return refuse(`git init failed in ${product}`);
|
|
411
|
+
ok(`created ${productName}/ (git, branch main)`);
|
|
412
|
+
} else info(`Product folder: ${productName}/`);
|
|
413
|
+
|
|
414
|
+
const repos = children.filter((n) => n !== productName && exists(path.join(cwd, n, '.git')));
|
|
415
|
+
const found = repos.filter(validFolderName);
|
|
416
|
+
const odd = repos.filter((n) => !validFolderName(n));
|
|
417
|
+
if (odd.length) warn(`not offered (a repo name must be letters, digits, ".", "_" or "-"): ${odd.map(shown).join(', ')} — connect them with \`yad setup\` under another name`);
|
|
418
|
+
if (found.length) info(`found ${found.length} repo(s) beside it: ${found.join(', ')}`);
|
|
419
|
+
else info('found no repos beside it — connect them later with `yad setup`');
|
|
420
|
+
const discovered = found.map((n) => ({ name: n, rpath: `../${n}` }));
|
|
421
|
+
const layout = opts.monorepo ? {} : { separate: true };
|
|
422
|
+
const setup = await runSetup(product, { ...opts, brownfield: !opts.greenfield, ...layout, discovered });
|
|
423
|
+
if (process.exitCode) return null;
|
|
424
|
+
noteWorkspaceFile(product);
|
|
425
|
+
// Setup's own "yad next" is said from the Product; the person is still in the workspace folder.
|
|
426
|
+
hand(`start with: cd ${fromShell(product)} && yad next`);
|
|
427
|
+
return { workspace: cwd, product, found, setup };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---- yad join <url> [folder] ---------------------------------------------------------------------
|
|
431
|
+
// The per-machine skill copies: only what git IGNORES in the Product. A copy the team commits is a
|
|
432
|
+
// shared file — installing a missing one, or refreshing an old one, is `yad update`'s job, pushed as a
|
|
433
|
+
// change everyone sees. So those are counted and named, never written.
|
|
434
|
+
function installLocalSkills(product, ideTargets) {
|
|
435
|
+
const ignored = (rel) => spawnSync('git', ['check-ignore', '-q', '--', rel], { cwd: product, windowsHide: true }).status === 0;
|
|
436
|
+
// `check-ignore` is asked about a path that may not exist yet, so a folder-only pattern
|
|
437
|
+
// (`/.claude/skills/yad-*/`) is not matched and the copy is counted as shared — the safe side. Ignore
|
|
438
|
+
// the folder itself (`.claude/`, `.claude/skills/`) for join to install into it.
|
|
439
|
+
const res = { installed: [], stale: [], shared: [] };
|
|
440
|
+
for (const a of moduleActions(product, ideTargets)) {
|
|
441
|
+
if (a.status === 'ok') continue;
|
|
442
|
+
const rel = a.paths[0];
|
|
443
|
+
const local = !!rel && ignored(rel);
|
|
444
|
+
if (local && (a.status === 'new' || a.status === 'missing')) { a.apply(); res.installed.push(rel); }
|
|
445
|
+
else (local ? res.stale : res.shared).push(rel || `${a.scope}/${a.item}`);
|
|
446
|
+
}
|
|
447
|
+
return res;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export async function runJoin(cwd, url, folder, opts = {}) {
|
|
451
|
+
log(c.bold('\nyad join'));
|
|
452
|
+
if (typeof url !== 'string' || !url.trim() || url.startsWith('-')) return refuse('usage: yad join <the Product\'s clone URL> [folder]');
|
|
453
|
+
const wsName = folder ?? workspaceNameFromUrl(url);
|
|
454
|
+
if (!validFolderName(wsName)) return refuse(`cannot make a folder name from ${JSON.stringify(shown(url))} — pass one: yad join <url> <folder>`);
|
|
455
|
+
const inside = insideGitRepo(cwd);
|
|
456
|
+
if (inside.ok) return refuse(`${cwd} is inside the git repo ${inside.stdout} — run \`yad join\` from a folder outside any repo`);
|
|
457
|
+
const workspace = path.join(cwd, wsName);
|
|
458
|
+
const product = path.join(workspace, PRODUCT_DIR);
|
|
459
|
+
|
|
460
|
+
// A re-run after a partial join finds the Product already there and carries on with what is missing.
|
|
461
|
+
if (exists(path.join(product, '.git'))) info(`${path.join(wsName, PRODUCT_DIR)}/ already cloned`);
|
|
462
|
+
else {
|
|
463
|
+
if (exists(product) && !isEmptyDir(product)) return refuse(`${product} exists and is not a git repo — move it aside and re-run`);
|
|
464
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
465
|
+
const r = gitClone(url, product, cloneEnv());
|
|
466
|
+
if (!r.ok) return refuse(`could not clone the Product: ${shown(r.error)}`);
|
|
467
|
+
ok(`cloned the Product into ${path.join(wsName, PRODUCT_DIR)}/`);
|
|
468
|
+
}
|
|
469
|
+
if (!exists(productConfigPath(product))) return refuse(`${path.join(wsName, PRODUCT_DIR)}/ has no .sdlc/product.json — that repo is not a yad Product`);
|
|
470
|
+
|
|
471
|
+
const { registry, problem } = readRegistry(product);
|
|
472
|
+
if (problem) warn(`${problem} — nothing to clone; fix it in the Product and re-run`);
|
|
473
|
+
const repos = cloneMissingRepos(product, registry);
|
|
474
|
+
reportClones(repos);
|
|
475
|
+
|
|
476
|
+
// The per-machine steps.
|
|
477
|
+
const ideTargets = await selectIdeTargets(product, opts.ideTargets);
|
|
478
|
+
const skills = installLocalSkills(product, ideTargets);
|
|
479
|
+
if (skills.installed.length) ok(`installed ${skills.installed.length} skill folder(s) the Product's git ignores (per machine)`);
|
|
480
|
+
if (skills.stale.length) info(`${skills.stale.length} per-machine skill copy(ies) differ from this yadflow's — left as they are; \`yad update\` refreshes them`);
|
|
481
|
+
if (skills.shared.length) info(`${skills.shared.length} managed file(s) are missing or out of date here in folders the Product's git does not ignore — writing them is a change the team shares (\`yad update\`), so join leaves them`);
|
|
482
|
+
noteWorkspaceFile(product);
|
|
483
|
+
const hook = gitHookActions(product);
|
|
484
|
+
for (const a of hook) a.apply();
|
|
485
|
+
if (hook.length) ok('installed the git pre-commit hook in this clone');
|
|
486
|
+
|
|
487
|
+
log('');
|
|
488
|
+
if (repos.failed.length) hand(`${repos.failed.length} repo(s) not cloned — fix what is named above and re-run \`yad join ${shown(url)}\`; it keeps what is already there`);
|
|
489
|
+
hand(`start with: cd ${fromShell(product)} && yad next`);
|
|
490
|
+
return {
|
|
491
|
+
workspace, product,
|
|
492
|
+
repos, skills: { installed: skills.installed, stale: skills.stale.length, shared: skills.shared.length },
|
|
493
|
+
hook: hook.length ? 'installed' : 'unchanged',
|
|
494
|
+
};
|
|
495
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "4.0.0
|
|
4
|
-
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (new/init/join, next, gate, capture/fold, commit/ship, repo, thread, history, doctor/migrate/update). Plus 38 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
7
7
|
"license": "MIT",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"!cli/test-threads.mjs",
|
|
25
25
|
"!cli/test-golden.mjs",
|
|
26
26
|
"!cli/test-migrate.mjs",
|
|
27
|
+
"!cli/test-hooks.mjs",
|
|
28
|
+
"!cli/test-fuzz.js",
|
|
27
29
|
"!cli/fixtures",
|
|
28
30
|
"skills/",
|
|
29
31
|
"README.md",
|
|
@@ -40,10 +42,10 @@
|
|
|
40
42
|
"scripts": {
|
|
41
43
|
"yad": "node bin/yad.mjs",
|
|
42
44
|
"lint": "eslint cli bin",
|
|
43
|
-
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
|
|
45
|
+
"test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-fuzz.js",
|
|
44
46
|
"test:e2e": "bash test/e2e/run.sh",
|
|
45
47
|
"release-check": "bash scripts/release-check.sh",
|
|
46
|
-
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs",
|
|
48
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-exclude='cli/test*.js' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs cli/test-golden.mjs cli/test-migrate.mjs cli/test-hooks.mjs cli/test-fuzz.js",
|
|
47
49
|
"diagrams": "npx -y @mermaid-js/mermaid-cli -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent",
|
|
48
50
|
"prepublishOnly": "npm test"
|
|
49
51
|
},
|
|
@@ -69,6 +71,7 @@
|
|
|
69
71
|
"@semantic-release/changelog": "^7.0.0",
|
|
70
72
|
"@semantic-release/git": "^11.0.1",
|
|
71
73
|
"eslint": "^10.5.0",
|
|
74
|
+
"fast-check": "^4.10.2",
|
|
72
75
|
"semantic-release": "^25.0.3"
|
|
73
76
|
}
|
|
74
77
|
}
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -70,7 +70,7 @@ lifecycle:
|
|
|
70
70
|
# marks any step optional. Each epic RECORDS its route as `profile` in `state.json`
|
|
71
71
|
# (file shape 6); `yad epic new` writes it when it seeds the chain, and for an epic that predates the
|
|
72
72
|
# field `yad migrate` fills it in by matching the chain. The chain stays the truth — `yad doctor`
|
|
73
|
-
# compares the two. NOT the `
|
|
73
|
+
# compares the two. NOT the `product.json` `profile`, which is the unrelated setup record
|
|
74
74
|
# { codebase, repo_layout, team_size }.
|
|
75
75
|
profiles: [classic, analysis-first, chore, spike, discovery, foundation]
|
|
76
76
|
# The PRODUCT level's phase (E75) — a ladder of its own, run once per product before any epic, and
|
|
@@ -113,7 +113,7 @@ review_gate:
|
|
|
113
113
|
# only the base holds a gate.
|
|
114
114
|
default_reviewers: 1 # legacy, not read since E62
|
|
115
115
|
# Solo mode (a lone developer): a relaxed, opt-in safety guarantee, recorded per-project in
|
|
116
|
-
# .sdlc/
|
|
116
|
+
# .sdlc/product.json (`solo: true`). On GitHub you CANNOT approve your own PR, so requiring an approval
|
|
117
117
|
# would deadlock a solo user. Solo waives the APPROVAL requirement only — the review PR/MR and its
|
|
118
118
|
# MERGE stay (CI still runs on the PR, and the merge is what advances the step). Net: the gate passes
|
|
119
119
|
# on `merged + all comment threads resolved`, no approval needed. NOT a default; team gates are
|
|
@@ -137,7 +137,7 @@ review_gate:
|
|
|
137
137
|
# default: both count, a bare approve still passes but draws a friendly public @-mention nudge so
|
|
138
138
|
# review quality is VISIBLE without blocking anyone ("visible, not impossible"). Flip to true to make
|
|
139
139
|
# only verified approvals count (the signal is gameable by design — it raises the cost of a rubber-
|
|
140
|
-
# stamp, it does not prove a human read the artifact). Persisted per-project in
|
|
140
|
+
# stamp, it does not prove a human read the artifact). Persisted per-project in product.json as
|
|
141
141
|
# `review.requireEngagement`; companion comments carry `<!-- yad:noblock -->` so they never block.
|
|
142
142
|
require_engagement: false
|
|
143
143
|
# Pair Review (yad-pair-review) — the guided, two-way, TEACHING walkthrough; the AI-driven 5th
|
|
@@ -173,7 +173,8 @@ build:
|
|
|
173
173
|
# Commit ownership + per-commit AI co-author (yad-implement installs the .gitmessage template).
|
|
174
174
|
# The human git author OWNS the commit; the assisting AI is recorded per-commit as a Co-Authored-By
|
|
175
175
|
# trailer, chosen by the owner from `ai_coauthor.allowed`. The AI is never the author. `none` is an
|
|
176
|
-
# explicit human-only choice. Trailer order is Task: -> Contract-Change: (if any) ->
|
|
176
|
+
# explicit human-only choice. Trailer order is Task: -> Contract-Change: (if any) ->
|
|
177
|
+
# Ledger-Override: (only with `yad commit --manual --reason`) -> Co-Authored-By:.
|
|
177
178
|
commit_owner: git_author
|
|
178
179
|
ai_coauthor:
|
|
179
180
|
trailer: "Co-Authored-By"
|
|
@@ -190,7 +191,7 @@ build:
|
|
|
190
191
|
# Shape artifacts can never go stale), and reconcile-debt (a hotfix's ship-first debt blocks the next
|
|
191
192
|
# change on its thread until paid). See the `change:` block below.
|
|
192
193
|
gates: [spec-link, contract-check, build-test-lint, lineage-check, epic-open, reconcile-debt]
|
|
193
|
-
# Pattern gates — commit subject + PR/MR title + PR/MR template-usage. Profile-aware (code|hub
|
|
194
|
+
# Pattern gates — commit subject + PR/MR title + PR/MR template-usage. Profile-aware (code|product; `hub` is the old name for product): code
|
|
194
195
|
# repos validate the Conventional-Commits / task-PR conventions; the Product validates its
|
|
195
196
|
# artifact-review conventions (review/EP-<slug>/<artifact>, the Product PR template). verified-commits is
|
|
196
197
|
# the separate security gate (a platform-Verified signature only; no author allowlist since E62). All blocking in CI; both repos + Product.
|
|
@@ -201,7 +202,7 @@ build:
|
|
|
201
202
|
pr_templates:
|
|
202
203
|
github: ".github/pull_request_template.md"
|
|
203
204
|
gitlab: ".gitlab/merge_request_templates/Default.md"
|
|
204
|
-
|
|
205
|
+
product: # Shape artifact-review PR/MR on the Product (yad-pr-template repo:product; older projects: `hub:`)
|
|
205
206
|
github: ".github/pull_request_template.md"
|
|
206
207
|
gitlab: ".gitlab/merge_request_templates/Default.md"
|
|
207
208
|
risk_levels: [low, medium, high] # high +1 / a contract surface +2 to the approver count (advisory; yad-review-gate count)
|
|
@@ -304,13 +305,13 @@ learning:
|
|
|
304
305
|
# generated React+Vite+Tailwind doc SITES. yad-docs builds a per-epic site (epics/EP-<slug>/docs-site/)
|
|
305
306
|
# from the authored artifacts, themed by the connected design system; yad-docs-overview builds the
|
|
306
307
|
# project SDLC-overview site (docs/sdlc-site/) from the pipeline definition, superseding docs/index.html.
|
|
307
|
-
# The deploy TARGET is a Pages host, auto-detected from
|
|
308
|
+
# The deploy TARGET is a Pages host, auto-detected from the Product's `platform` (github->github-pages,
|
|
308
309
|
# gitlab->gitlab-pages, null->build-only). The yad CLI does the npm build + Pages wiring + staleness;
|
|
309
310
|
# the content generation is the AI step. Docs are an OUTPUT ENRICHMENT — never a gate, never touch state.
|
|
310
311
|
docs:
|
|
311
312
|
registry: "{project-root}/.sdlc/docs.json" # project-wide docs/Pages connection (NOT per-epic)
|
|
312
|
-
targets: [github-pages, gitlab-pages] # supported Pages hosts; auto-detected from
|
|
313
|
-
scope:
|
|
313
|
+
targets: [github-pages, gitlab-pages] # supported Pages hosts; auto-detected from the Product's platform
|
|
314
|
+
scope: product # publish from: product (default; older projects: `hub`) | <repo-name> | dedicated
|
|
314
315
|
degrade: build-only # no Pages host / no gh|glab => build the dist, publish via CI
|
|
315
316
|
login_gate: false # public docs by default (the client-side gate is presentational only)
|
|
316
317
|
manifest: "{project-root}/epics/EP-<slug>/.sdlc/docs-build.json" # per-epic staleness baseline (yad-docs)
|
|
@@ -319,7 +320,7 @@ docs:
|
|
|
319
320
|
# tokens — publishing runs as the LOCAL user's own gh/glab or the platform CI. Idempotent, refreshable.
|
|
320
321
|
auth: user
|
|
321
322
|
|
|
322
|
-
# Product platform + Shape review bridge (yad-connect-repos `detect-
|
|
323
|
+
# Product platform + Shape review bridge (yad-connect-repos `detect-product`; yad-review-gate + yad-product-bridge).
|
|
323
324
|
# The Product is itself a git repo on a platform. With a verified ledger, the Shape review/
|
|
324
325
|
# comment/approval cycle runs through a real PR/MR on the Product: a review PR is opened per artifact, reviewers
|
|
325
326
|
# approve/comment on the platform (their own gh/glab auth — NO stored tokens), and `yad-review-gate action: sync`
|
|
@@ -330,14 +331,14 @@ docs:
|
|
|
330
331
|
# both. The paths below carry both names too, for the same reason the files do.
|
|
331
332
|
product:
|
|
332
333
|
config: "{project-root}/.sdlc/product.json" # product platform (committed, like repos.json); no roster since E62
|
|
333
|
-
config_legacy: "{project-root}/.sdlc/hub.json" # the older name
|
|
334
|
+
config_legacy: "{project-root}/.sdlc/hub.json" # the older name: still written with identical bytes until v5; read only when product.json is absent (E122)
|
|
334
335
|
pr_ledger: "{project-root}/epics/EP-<slug>/.sdlc/product-prs.json" # per-step review-PR record (sibling of approvals.json)
|
|
335
|
-
pr_ledger_legacy: "{project-root}/epics/EP-<slug>/.sdlc/hub-prs.json" # the older name — written alongside it
|
|
336
|
+
pr_ledger_legacy: "{project-root}/epics/EP-<slug>/.sdlc/hub-prs.json" # the older name — written alongside it; read only when product-prs.json is absent, like config_legacy
|
|
336
337
|
bridge: true # master enable; false => local Shape gates everywhere
|
|
337
338
|
platforms: [github, gitlab] # detected from the Product's own `git remote get-url origin`
|
|
338
339
|
artifact_branch: "review/EP-<slug>/<artifact-base>" # branch a review PR is opened on, per artifact
|
|
339
340
|
# No roster since E62: yadflow stores no people. Approvals record the platform login as `approver`. A
|
|
340
|
-
# `roster` in
|
|
341
|
+
# `roster` in the Product settings or `domain_owner(s)` in repos.json left by an older release decides nothing (the
|
|
341
342
|
# roster's name -> login pairs only let the first sync recognise older approvals).
|
|
342
343
|
|
|
343
344
|
# Phase 4 (automation) — the SECOND dial made real: the orchestrator (yad-run) reads it and acts. Since
|