forgedock 1.25.1 → 1.27.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/bin/engine/outcome-codes.mjs +15 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +85 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +304 -0
- package/bin/lean/conformance.test.mjs +192 -0
- package/bin/lean/effects.mjs +119 -11
- package/bin/lean/lean.mjs +533 -70
- package/bin/lean/lean.test.mjs +1065 -112
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +94 -57
- package/bin/lean/render.mjs +52 -7
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +53 -3
- package/docs/OUTCOME-CODES.md +14 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +17 -2
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
package/bin/init-detect.mjs
CHANGED
|
@@ -171,7 +171,20 @@ function detectDefaultBranch(cwd) {
|
|
|
171
171
|
// symbolic-ref not set — try next strategy
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Strategy 2:
|
|
174
|
+
// Strategy 2: unborn branch (forge#4173) — a freshly `git init`-ed repo
|
|
175
|
+
// with no commits has no resolvable HEAD commit, so `rev-parse` below
|
|
176
|
+
// fails, but `symbolic-ref --short HEAD` still names the branch the first
|
|
177
|
+
// commit will land on. Prefer it over guessing "main".
|
|
178
|
+
const unborn = detectUnbornBranch(cwd);
|
|
179
|
+
if (unborn) {
|
|
180
|
+
return medium(
|
|
181
|
+
unborn,
|
|
182
|
+
"git symbolic-ref --short HEAD",
|
|
183
|
+
`Repository has no commits yet; HEAD points at unborn branch ${unborn}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Strategy 3: current local branch
|
|
175
188
|
try {
|
|
176
189
|
const cur = execFileSync(
|
|
177
190
|
"git",
|
|
@@ -194,10 +207,53 @@ function detectDefaultBranch(cwd) {
|
|
|
194
207
|
// git unavailable or not a git repo
|
|
195
208
|
}
|
|
196
209
|
|
|
197
|
-
// Strategy
|
|
210
|
+
// Strategy 4: fall back
|
|
198
211
|
return low("main", "default placeholder", "Could not determine branch from git; defaulting to 'main'");
|
|
199
212
|
}
|
|
200
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Run a read-only git command, returning trimmed stdout or null on failure.
|
|
216
|
+
* @param {string} cwd
|
|
217
|
+
* @param {string[]} args
|
|
218
|
+
* @returns {string|null}
|
|
219
|
+
*/
|
|
220
|
+
function gitRead(cwd, args) {
|
|
221
|
+
try {
|
|
222
|
+
return execFileSync("git", args, {
|
|
223
|
+
cwd,
|
|
224
|
+
encoding: "utf-8",
|
|
225
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
226
|
+
timeout: 5000,
|
|
227
|
+
}).trim();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Return the unborn branch name when `cwd` is a git repo with no commits,
|
|
235
|
+
* else "" (forge#4173). Never throws.
|
|
236
|
+
* @param {string} cwd
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function detectUnbornBranch(cwd) {
|
|
240
|
+
if (gitRead(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"])) return "";
|
|
241
|
+
return gitRead(cwd, ["symbolic-ref", "--short", "HEAD"]) || "";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Detect repository state signals for the repo-setup step (forge#4173):
|
|
246
|
+
* whether `cwd` is inside a git work tree and whether HEAD has any commit.
|
|
247
|
+
* @param {string} cwd
|
|
248
|
+
* @returns {{ isGitRepo: boolean, hasCommits: boolean, unbornBranch: string }}
|
|
249
|
+
*/
|
|
250
|
+
function detectRepoState(cwd) {
|
|
251
|
+
const isGitRepo = gitRead(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
252
|
+
if (!isGitRepo) return { isGitRepo: false, hasCommits: false, unbornBranch: "" };
|
|
253
|
+
const hasCommits = Boolean(gitRead(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"]));
|
|
254
|
+
return { isGitRepo, hasCommits, unbornBranch: hasCommits ? "" : detectUnbornBranch(cwd) };
|
|
255
|
+
}
|
|
256
|
+
|
|
201
257
|
/**
|
|
202
258
|
* Detect the staging branch.
|
|
203
259
|
*
|
|
@@ -360,6 +416,10 @@ function deriveProjectName(repoSlug) {
|
|
|
360
416
|
* },
|
|
361
417
|
* meta: {
|
|
362
418
|
* remoteDetected: boolean,
|
|
419
|
+
* isGitRepo: boolean, // cwd resolved to a git work tree (forge#4173)
|
|
420
|
+
* hasCommits: boolean, // HEAD resolves to a commit
|
|
421
|
+
* unbornBranch: string, // branch name when the repo has no commits, else ""
|
|
422
|
+
* stagingMissing: boolean, // no origin/staging — the setup step may create it
|
|
363
423
|
* },
|
|
364
424
|
* }} ConfigDraft
|
|
365
425
|
*/
|
|
@@ -401,6 +461,7 @@ export async function detectConfig(cwd = process.cwd()) {
|
|
|
401
461
|
// Branch detection
|
|
402
462
|
const defaultBranchField = detectDefaultBranch(gitRoot);
|
|
403
463
|
const stagingBranchField = detectStagingBranch(gitRoot, defaultBranchField.value);
|
|
464
|
+
const repoState = detectRepoState(gitRoot);
|
|
404
465
|
|
|
405
466
|
return {
|
|
406
467
|
project: { owner, repo, name },
|
|
@@ -409,7 +470,11 @@ export async function detectConfig(cwd = process.cwd()) {
|
|
|
409
470
|
default: defaultBranchField,
|
|
410
471
|
staging: stagingBranchField,
|
|
411
472
|
},
|
|
412
|
-
meta: {
|
|
473
|
+
meta: {
|
|
474
|
+
remoteDetected,
|
|
475
|
+
...repoState,
|
|
476
|
+
stagingMissing: stagingBranchField.source !== "git branch -r",
|
|
477
|
+
},
|
|
413
478
|
};
|
|
414
479
|
}
|
|
415
480
|
|
package/bin/init-enrich.mjs
CHANGED
|
@@ -119,6 +119,9 @@ export function resolveEnrichBackend({
|
|
|
119
119
|
*/
|
|
120
120
|
export async function enrich(draft, opts = {}) {
|
|
121
121
|
const { cwd = process.cwd(), env = process.env, isCliAvailableFn, bin, spawnFn, timeoutMs } = opts;
|
|
122
|
+
// An empty repository with no remote (forge#4173) has nothing for either
|
|
123
|
+
// backend to read — skip the (slow, possibly billed) call entirely.
|
|
124
|
+
if (draft?.meta?.remoteDetected === false && draft?.meta?.hasCommits === false) return draft;
|
|
122
125
|
const backend =
|
|
123
126
|
opts.backend ?? resolveEnrichBackend({ cwd, env, isCliAvailableFn });
|
|
124
127
|
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init-repo.mjs — side-effecting repository setup helpers for
|
|
3
|
+
* `forgedock init` (issue #4173).
|
|
4
|
+
*
|
|
5
|
+
* bin/init-detect.mjs stays pure (reads only). Everything that creates a
|
|
6
|
+
* GitHub repository, makes the first commit, or pushes branches lives here,
|
|
7
|
+
* behind an injectable `exec(cmd, args)` so tests can drive every helper
|
|
8
|
+
* with a stub.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY — every call is an argv array through execFileSync, never a
|
|
11
|
+
* shell. Owner and repo names come from the user and are additionally
|
|
12
|
+
* validated against GitHub's name charset before they reach `gh`.
|
|
13
|
+
*
|
|
14
|
+
* GitHub App coverage: the ForgeDock App can only act on repositories of
|
|
15
|
+
* accounts it is installed on (field test: App on the org, repo created
|
|
16
|
+
* under the personal account → the bot got a 404). listAppInstallations()
|
|
17
|
+
* reports each owner as "installed", "not-installed", or "unknown" — a
|
|
18
|
+
* failed lookup is never reported as covered.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "child_process";
|
|
22
|
+
|
|
23
|
+
export const APP_SLUG = "rapiercraft-forgedock";
|
|
24
|
+
export const APP_INSTALL_URL = `https://github.com/apps/${APP_SLUG}/installations/new`;
|
|
25
|
+
|
|
26
|
+
/** GitHub account / repository name charset. */
|
|
27
|
+
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default exec: run `cmd args` without a shell, return trimmed stdout.
|
|
31
|
+
* Throws on non-zero exit.
|
|
32
|
+
* @param {string} cmd
|
|
33
|
+
* @param {string[]} args
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function defaultExec(cmd, args) {
|
|
37
|
+
return execFileSync(cmd, args, {
|
|
38
|
+
encoding: "utf-8",
|
|
39
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
40
|
+
timeout: 120000,
|
|
41
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
42
|
+
}).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tryExec(exec, cmd, args) {
|
|
46
|
+
try {
|
|
47
|
+
return { ok: true, out: String(exec(cmd, args) ?? "").trim() };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return { ok: false, out: "", error: err };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lines(out) {
|
|
54
|
+
return out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* List the accounts the authenticated `gh` user can create repositories
|
|
59
|
+
* under: their own login first, then their organizations.
|
|
60
|
+
* @param {Function} exec
|
|
61
|
+
* @returns {{ login: string, type: 'User'|'Organization' }[]}
|
|
62
|
+
*/
|
|
63
|
+
export function listOwners(exec = defaultExec) {
|
|
64
|
+
const owners = [];
|
|
65
|
+
const me = tryExec(exec, "gh", ["api", "user", "--jq", ".login"]);
|
|
66
|
+
if (me.ok && me.out) owners.push({ login: me.out, type: "User" });
|
|
67
|
+
const orgs = tryExec(exec, "gh", ["api", "user/orgs", "--paginate", "--jq", ".[].login"]);
|
|
68
|
+
if (orgs.ok) {
|
|
69
|
+
for (const login of lines(orgs.out)) owners.push({ login, type: "Organization" });
|
|
70
|
+
}
|
|
71
|
+
return owners;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Determine, per owner, whether the ForgeDock GitHub App is installed.
|
|
76
|
+
*
|
|
77
|
+
* Strategy:
|
|
78
|
+
* 1. `gh api /user/installations` — installations visible to the user.
|
|
79
|
+
* Authoritative for the user's own account when it succeeds.
|
|
80
|
+
* 2. Organizations not found in (1): `gh api /orgs/{org}/installations`
|
|
81
|
+
* (needs org admin). Success → installed / not-installed.
|
|
82
|
+
* 3. Anything else → "unknown". Never "installed" without evidence.
|
|
83
|
+
*
|
|
84
|
+
* @param {Function} exec
|
|
85
|
+
* @param {{ login: string, type: string }[]} owners
|
|
86
|
+
* @param {string} [appSlug]
|
|
87
|
+
* @returns {Map<string, 'installed'|'not-installed'|'unknown'>}
|
|
88
|
+
*/
|
|
89
|
+
export function listAppInstallations(exec = defaultExec, owners = [], appSlug = APP_SLUG) {
|
|
90
|
+
const coverage = new Map();
|
|
91
|
+
const userList = tryExec(exec, "gh", [
|
|
92
|
+
"api", "/user/installations", "--paginate",
|
|
93
|
+
"--jq", ".installations[] | [.app_slug, .account.login] | @tsv",
|
|
94
|
+
]);
|
|
95
|
+
const covered = new Set();
|
|
96
|
+
if (userList.ok) {
|
|
97
|
+
for (const line of lines(userList.out)) {
|
|
98
|
+
const [slug, login] = line.split("\t");
|
|
99
|
+
if (slug === appSlug && login) covered.add(login.toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const { login, type } of owners) {
|
|
104
|
+
const key = login.toLowerCase();
|
|
105
|
+
if (covered.has(key)) {
|
|
106
|
+
coverage.set(login, "installed");
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (type === "Organization") {
|
|
110
|
+
const org = tryExec(exec, "gh", [
|
|
111
|
+
"api", `/orgs/${login}/installations`, "--paginate",
|
|
112
|
+
"--jq", ".installations[].app_slug",
|
|
113
|
+
]);
|
|
114
|
+
if (org.ok) {
|
|
115
|
+
coverage.set(login, lines(org.out).includes(appSlug) ? "installed" : "not-installed");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
coverage.set(login, "unknown");
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
coverage.set(login, userList.ok ? "not-installed" : "unknown");
|
|
122
|
+
}
|
|
123
|
+
return coverage;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Warning text when the chosen owner is not covered by the App, else "".
|
|
128
|
+
* @param {string} owner
|
|
129
|
+
* @param {'installed'|'not-installed'|'unknown'|undefined} status
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
export function ownerCoverageWarning(owner, status) {
|
|
133
|
+
if (status === "installed") return "";
|
|
134
|
+
if (status === "not-installed") {
|
|
135
|
+
return `The ForgeDock GitHub App is not installed on '${owner}'. Pipeline bot actions on this repo will fail (404) until you install it: ${APP_INSTALL_URL}`;
|
|
136
|
+
}
|
|
137
|
+
return `Could not verify whether the ForgeDock GitHub App is installed on '${owner}'. Check, and install it if needed: ${APP_INSTALL_URL}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isGitRepo(exec, root) {
|
|
141
|
+
const r = tryExec(exec, "git", ["-C", root, "rev-parse", "--is-inside-work-tree"]);
|
|
142
|
+
return r.ok && r.out === "true";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasCommits(exec, root) {
|
|
146
|
+
return tryExec(exec, "git", ["-C", root, "rev-parse", "--verify", "--quiet", "HEAD"]).ok;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create the GitHub repository and wire it up as `origin`.
|
|
151
|
+
* Runs `git init -b <branch>` first when `root` is not a git repository.
|
|
152
|
+
*
|
|
153
|
+
* @param {Function} exec
|
|
154
|
+
* @param {{ owner: string, name: string, private?: boolean, root: string, branch?: string }} opts
|
|
155
|
+
* @returns {{ fullName: string, visibility: 'private'|'public' }}
|
|
156
|
+
*/
|
|
157
|
+
export function createRepo(exec = defaultExec, { owner, name, private: isPrivate = true, root, branch = "main" }) {
|
|
158
|
+
if (!NAME_RE.test(owner || "")) throw new Error(`Invalid GitHub owner: '${owner}'`);
|
|
159
|
+
if (!NAME_RE.test(name || "")) throw new Error(`Invalid repository name: '${name}'`);
|
|
160
|
+
if (!isGitRepo(exec, root)) exec("git", ["init", "-b", branch, root]);
|
|
161
|
+
const visibility = isPrivate === false ? "public" : "private";
|
|
162
|
+
exec("gh", [
|
|
163
|
+
"repo", "create", `${owner}/${name}`,
|
|
164
|
+
`--${visibility}`,
|
|
165
|
+
"--source", root,
|
|
166
|
+
"--remote", "origin",
|
|
167
|
+
]);
|
|
168
|
+
return { fullName: `${owner}/${name}`, visibility };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Make an empty initial commit when HEAD is unborn, so `main` can be pushed.
|
|
173
|
+
* Never commits the user's files.
|
|
174
|
+
* @param {Function} exec
|
|
175
|
+
* @param {string} root
|
|
176
|
+
* @returns {{ created: boolean }}
|
|
177
|
+
*/
|
|
178
|
+
export function ensureInitialCommit(exec = defaultExec, root) {
|
|
179
|
+
if (hasCommits(exec, root)) return { created: false };
|
|
180
|
+
exec("git", ["-C", root, "commit", "--allow-empty", "-m", "chore: initial commit"]);
|
|
181
|
+
return { created: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Push the default branch and record it as the remote HEAD.
|
|
186
|
+
* @param {Function} exec
|
|
187
|
+
* @param {string} root
|
|
188
|
+
* @param {string} branch
|
|
189
|
+
*/
|
|
190
|
+
export function pushMain(exec = defaultExec, root, branch = "main") {
|
|
191
|
+
exec("git", ["-C", root, "push", "-u", "origin", branch]);
|
|
192
|
+
// Best effort: lets detection read the remote default authoritatively.
|
|
193
|
+
tryExec(exec, "git", ["-C", root, "remote", "set-head", "origin", branch]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create `staging` on origin from `from`. No-op when it already exists.
|
|
198
|
+
* @param {Function} exec
|
|
199
|
+
* @param {string} root
|
|
200
|
+
* @param {string} from
|
|
201
|
+
* @returns {{ created: boolean }}
|
|
202
|
+
*/
|
|
203
|
+
export function createStaging(exec = defaultExec, root, from = "main") {
|
|
204
|
+
const existing = tryExec(exec, "git", ["-C", root, "ls-remote", "--heads", "origin", "staging"]);
|
|
205
|
+
if (existing.ok && existing.out) {
|
|
206
|
+
tryExec(exec, "git", ["-C", root, "fetch", "origin", "staging"]);
|
|
207
|
+
return { created: false };
|
|
208
|
+
}
|
|
209
|
+
exec("git", ["-C", root, "push", "origin", `${from}:refs/heads/staging`]);
|
|
210
|
+
// Refresh origin/staging so re-detection sees it.
|
|
211
|
+
tryExec(exec, "git", ["-C", root, "fetch", "origin", "staging"]);
|
|
212
|
+
return { created: true };
|
|
213
|
+
}
|