mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
Merge origin/master into feat/quality-loop-cli-20260914
Resolve package.json version in favor of master (1.22.2). Keep the offline quality-gate scripts. Release/publish workflow stays master's.
This commit is contained in:
@@ -67,37 +67,29 @@ jobs:
|
||||
echo "OUT=$OUT" >> "$GITHUB_ENV"
|
||||
env:
|
||||
OUT: ${{ runner.temp }}/release-package
|
||||
- name: Refuse a version that already exists on the registry
|
||||
- name: Decide whether to publish or resume a matching registry copy
|
||||
id: decide
|
||||
run: node scripts/publish-npm-verify.mjs decide
|
||||
env:
|
||||
OUT: ${{ runner.temp }}/release-package
|
||||
CLI_SHA: ${{ inputs.cli_sha }}
|
||||
- name: Publish the exact directory once
|
||||
if: steps.decide.outputs.action == 'publish'
|
||||
run: |
|
||||
set -eu
|
||||
VERSION=$(node -p 'JSON.parse(require("fs").readFileSync(process.env.OUT + "/candidate.json","utf8")).version')
|
||||
if npm view "@przeprogramowani/10x-cli@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "::error::@przeprogramowani/10x-cli@$VERSION already published; never republish"; exit 1
|
||||
fi
|
||||
- name: Publish the exact directory once
|
||||
run: |
|
||||
set -eu
|
||||
node -e 'require("fs").writeFileSync(process.env.NPM_CONFIG_USERCONFIG, "registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=" + process.env.NPM_TOKEN + "\n", { mode: 0o600 })'
|
||||
npm publish . --ignore-scripts --access public
|
||||
env:
|
||||
NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/release-npmrc
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Verify actual registry bytes against the pack
|
||||
run: |
|
||||
set -eu
|
||||
node -e '
|
||||
const fs = require("fs"), crypto = require("crypto");
|
||||
const c = JSON.parse(fs.readFileSync(process.env.OUT + "/candidate.json", "utf8"));
|
||||
(async () => {
|
||||
const meta = await (await fetch(`https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${c.version}`)).json();
|
||||
const bytes = Buffer.from(await (await fetch(meta.dist.tarball)).arrayBuffer());
|
||||
const integrity = "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64");
|
||||
const result = { version: meta.version, gitHead: meta.gitHead, expectedIntegrity: c.integrity, registryIntegrity: meta.dist.integrity, actualIntegrity: integrity, sourceSha: process.env.CLI_SHA };
|
||||
console.log(JSON.stringify(result));
|
||||
fs.writeFileSync(process.env.OUT + "/published-result.json", JSON.stringify(result));
|
||||
if (meta.gitHead !== process.env.CLI_SHA || meta.dist.integrity !== c.integrity || integrity !== c.integrity) throw new Error("Published package differs from the pack; release incomplete, never republish");
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
'
|
||||
run: node scripts/publish-npm-verify.mjs verify
|
||||
env:
|
||||
OUT: ${{ runner.temp }}/release-package
|
||||
CLI_SHA: ${{ inputs.cli_sha }}
|
||||
- name: Tag the published source
|
||||
# The tag may already exist (pushed by hand, or a previous attempt).
|
||||
|
||||
@@ -128,6 +128,8 @@ artifacts/rules before applying; repeat a skill filter for a narrow update:
|
||||
10x_cli auth --status
|
||||
# If login is needed: 10x_cli auth (email or Circle); see auth commands below.
|
||||
10x_cli list --course 10xdevs4
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
@@ -143,7 +145,9 @@ artifacts/rules before applying; repeat a skill filter for a narrow update:
|
||||
|
||||
The guide uses lesson 1's existing 10xCards example and produces
|
||||
`context/foundation/shape-notes.md`, then `context/foundation/prd.md`.
|
||||
Read all three installed skill trees; PRD requires the sibling
|
||||
Lesson setup installs all four skill trees. Use `10x-idea-check` and its references
|
||||
first when you want to assess an idea; it is optional before init → shape → PRD.
|
||||
Read the installed entrypoints and their references; PRD requires the sibling
|
||||
`.claude/skills/10x-shape/references/prd-schema.md`. Preserve existing outputs and
|
||||
follow the skills' collision choices. `CLAUDE-m1l1` is a separate lesson rule;
|
||||
see the guide for prerequisite checks without a full-get fallback. `10x-plan`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@przeprogramowani/10x-cli",
|
||||
"version": "1.22.1",
|
||||
"version": "1.22.2",
|
||||
"description": "Open-source CLI for 10xDevs course content",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export class BranchUpdateRequiredError extends Error {}
|
||||
export function stableVersion(value: unknown): boolean;
|
||||
export function calculateVersion(input: any): Promise<any>;
|
||||
export function packageWithVersion(text: string, version: string): string;
|
||||
|
||||
@@ -7,6 +7,12 @@ import { Bumper } from "conventional-recommended-bump";
|
||||
const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s);
|
||||
export const stableVersion = (s) => typeof s === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(s);
|
||||
const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim();
|
||||
export class BranchUpdateRequiredError extends Error {
|
||||
constructor() {
|
||||
super("Branch update required: integrate current master before version preparation; no record was prepared.");
|
||||
this.name = "BranchUpdateRequiredError";
|
||||
}
|
||||
}
|
||||
export function validateBaseline(baseline, { cwd = process.cwd(), master }) {
|
||||
if (!baseline || !stableVersion(baseline.version) || baseline.tag !== `v${baseline.version}` || !sha(baseline.sha) || baseline.gitHead !== baseline.sha || !sha(master)) throw new Error("Verified published baseline required");
|
||||
if (git(cwd, "rev-parse", `${baseline.tag}^{commit}`) !== baseline.sha) throw new Error("Published tag changed");
|
||||
@@ -23,8 +29,13 @@ export function packageFilesChanged(cwd, from, to) {
|
||||
export async function calculateVersion({ cwd = process.cwd(), head, master, baseline }) {
|
||||
if (!sha(head)) throw new Error("Exact candidate head required");
|
||||
validateBaseline(baseline, { cwd, master });
|
||||
try {
|
||||
git(cwd, "merge-base", "--is-ancestor", master, head);
|
||||
} catch (error) {
|
||||
if (error.status === 1) throw new BranchUpdateRequiredError();
|
||||
throw error;
|
||||
}
|
||||
git(cwd, "merge-base", "--is-ancestor", baseline.sha, head);
|
||||
git(cwd, "merge-base", "--is-ancestor", master, head);
|
||||
if (!packageFilesChanged(cwd, baseline.sha, head)) return null;
|
||||
const reader = new Bumper(cwd).loadPreset("angular").tag(baseline.tag).commits({ from: baseline.sha, to: head }, {});
|
||||
const initial = await reader.bump();
|
||||
|
||||
@@ -3,13 +3,14 @@ import { realpathSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs";
|
||||
import { BranchUpdateRequiredError, calculateVersion, packageWithVersion, stableVersion } from "./auto-version.mjs";
|
||||
import { CLI_REPOSITORY, fullSha, github, canonicalRun, successfulJobs } from "./release-github.mjs";
|
||||
import { readReceiptArchive } from "./verify-coordinated-receipt.mjs";
|
||||
|
||||
const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 }).trim();
|
||||
export function validatePullRequest(pr, expectedHead, expectedBase) {
|
||||
if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || pr.base.sha !== expectedBase || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required");
|
||||
if (!pr || pr.state !== "open" || pr.head?.repo?.full_name !== CLI_REPOSITORY || pr.base?.repo?.full_name !== CLI_REPOSITORY || pr.base?.ref !== "master" || pr.head.sha !== expectedHead || !fullSha(pr.base.sha) || !fullSha(expectedHead) || !fullSha(expectedBase) || !Number.isSafeInteger(pr.number) || !/^[a-zA-Z0-9_./-]+$/.test(pr.head.ref) || pr.head.ref === "master") throw new Error("Live exact same-repository PR and base required");
|
||||
if (pr.base.sha !== expectedBase) throw new BranchUpdateRequiredError();
|
||||
return pr;
|
||||
}
|
||||
export async function publishedBaseline(get, registry = async () => {
|
||||
@@ -168,7 +169,16 @@ export async function reconcileVersionEvent({ env, event, bootstrap = false }, {
|
||||
for (const hint of prs) {
|
||||
if (hint.head?.repo?.full_name !== CLI_REPOSITORY) continue;
|
||||
if (!fullSha(hint.head.sha)) throw new Error("Exact head required");
|
||||
records.push(await prepare(hint));
|
||||
try {
|
||||
records.push(await prepare(hint));
|
||||
} catch (error) {
|
||||
if (error instanceof BranchUpdateRequiredError) {
|
||||
console.error(error.message);
|
||||
records.push(null);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -209,4 +219,4 @@ function isEntrypoint() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isEntrypoint()) main().catch(() => { console.error("Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; });
|
||||
if (isEntrypoint()) main().catch((error) => { console.error(error instanceof BranchUpdateRequiredError ? error.message : (error instanceof Error && error.message) || "Trusted version preparation rejected; inspect exact head/base/baseline metadata."); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const DEFAULT_WAIT: { timeoutMs: number; initialDelayMs: number; maxDelayMs: number };
|
||||
export function metadataIsComplete(meta: any): boolean;
|
||||
export function fetchVersionMetadata(version: string, fetchFn?: typeof fetch): Promise<{ status: number; body: any }>;
|
||||
export function waitForPublishedMetadata(version: string, options?: any): Promise<any>;
|
||||
export function assertPackMatchesRegistry(input: any): any;
|
||||
export function downloadTarball(metadata: any, fetchFn?: typeof fetch): Promise<Buffer>;
|
||||
export function classifyPublishDecision(input: any): Promise<any>;
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REGISTRY_VERSION = (version) => `https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${version}`;
|
||||
export const DEFAULT_WAIT = { timeoutMs: 180_000, initialDelayMs: 1_000, maxDelayMs: 8_000 };
|
||||
|
||||
const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s);
|
||||
const integrityOf = (bytes) => "sha512-" + createHash("sha512").update(bytes).digest("base64");
|
||||
|
||||
export function metadataIsComplete(meta) {
|
||||
return Boolean(meta && meta.version && meta.dist && typeof meta.dist.tarball === "string" && meta.dist.tarball.startsWith("https://") && typeof meta.dist.integrity === "string" && meta.dist.integrity.startsWith("sha512-"));
|
||||
}
|
||||
|
||||
export async function fetchVersionMetadata(version, fetchFn = fetch) {
|
||||
const response = await fetchFn(REGISTRY_VERSION(version), { signal: AbortSignal.timeout(30000) });
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try { body = JSON.parse(text); } catch { body = null; }
|
||||
return { status: response.status, body };
|
||||
}
|
||||
|
||||
export async function waitForPublishedMetadata(version, { fetchFn = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), log = console.error, timeoutMs = DEFAULT_WAIT.timeoutMs, initialDelayMs = DEFAULT_WAIT.initialDelayMs, maxDelayMs = DEFAULT_WAIT.maxDelayMs } = {}) {
|
||||
const deadline = now() + timeoutMs;
|
||||
let delay = initialDelayMs, attempt = 0;
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
const { status, body } = await fetchVersionMetadata(version, fetchFn);
|
||||
if (status === 200 && metadataIsComplete(body)) {
|
||||
log(`registry metadata ready for ${version} after ${attempt} attempt(s)`);
|
||||
return body;
|
||||
}
|
||||
const remaining = deadline - now();
|
||||
if (remaining <= 0) throw new Error(`Registry metadata for ${version} did not become complete within ${timeoutMs}ms after ${attempt} attempt(s); never republish`);
|
||||
log(`registry wait attempt ${attempt}: status=${status} complete=${metadataIsComplete(body)} next=${Math.min(delay, remaining)}ms`);
|
||||
await sleep(Math.min(delay, remaining));
|
||||
delay = Math.min(delay * 2, maxDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPackMatchesRegistry({ metadata, tarballBytes, expectedIntegrity, sourceSha }) {
|
||||
if (!sha(sourceSha)) throw new Error("Exact candidate SHA required");
|
||||
if (!expectedIntegrity || !expectedIntegrity.startsWith("sha512-")) throw new Error("Pack integrity required");
|
||||
if (!metadataIsComplete(metadata)) throw new Error("Complete registry metadata required");
|
||||
const actualIntegrity = integrityOf(tarballBytes);
|
||||
if (metadata.gitHead !== sourceSha || metadata.dist.integrity !== expectedIntegrity || actualIntegrity !== expectedIntegrity || metadata.dist.integrity !== actualIntegrity) {
|
||||
throw new Error("Published package differs from the pack; release incomplete, never republish");
|
||||
}
|
||||
return { version: metadata.version, gitHead: metadata.gitHead, expectedIntegrity, registryIntegrity: metadata.dist.integrity, actualIntegrity, sourceSha };
|
||||
}
|
||||
|
||||
export async function downloadTarball(metadata, fetchFn = fetch) {
|
||||
const response = await fetchFn(metadata.dist.tarball, { signal: AbortSignal.timeout(60000) });
|
||||
if (!response.ok) throw new Error("Registry tarball unavailable");
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export async function classifyPublishDecision({ version, expectedIntegrity, sourceSha, fetchFn = fetch, wait }) {
|
||||
const { status, body } = await fetchVersionMetadata(version, fetchFn);
|
||||
if (status === 404 || (body && body.error === "Not found")) return { action: "publish" };
|
||||
const metadata = metadataIsComplete(body) ? body : await waitForPublishedMetadata(version, { fetchFn, ...wait });
|
||||
const bytes = await downloadTarball(metadata, fetchFn);
|
||||
const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity, sourceSha });
|
||||
return { action: "resume", result };
|
||||
}
|
||||
|
||||
function candidate(out) {
|
||||
return JSON.parse(readFileSync(`${out}/candidate.json`, "utf8"));
|
||||
}
|
||||
|
||||
function writeGithubOutput(name, value) {
|
||||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function decide() {
|
||||
const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out);
|
||||
const decision = await classifyPublishDecision({ version: c.version, expectedIntegrity: c.integrity, sourceSha });
|
||||
writeGithubOutput("action", decision.action);
|
||||
if (decision.result) writeFileSync(`${out}/published-result.json`, JSON.stringify(decision.result));
|
||||
console.log(JSON.stringify({ action: decision.action, version: c.version }));
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out);
|
||||
const metadata = await waitForPublishedMetadata(c.version);
|
||||
const bytes = await downloadTarball(metadata);
|
||||
const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity: c.integrity, sourceSha });
|
||||
writeFileSync(`${out}/published-result.json`, JSON.stringify(result));
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
function isEntrypoint() {
|
||||
try { return Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
if (isEntrypoint()) {
|
||||
const command = process.argv[2];
|
||||
const run = command === "decide" ? decide : command === "verify" ? verify : null;
|
||||
if (!run) { console.error("publish-npm-verify requires decide or verify"); process.exitCode = 2; }
|
||||
else run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });
|
||||
}
|
||||
@@ -79,10 +79,15 @@ refresh transparently; re-login is needed only when refresh cannot recover them.
|
||||
|
||||
The launch exercise follows lesson 1, “Od pomysłu do PRD”, using its existing
|
||||
10xCards example. `10x-plan` is not part of this launch demonstration.
|
||||
After checking each name's capability and availability, download three separate
|
||||
complete selected skill trees. Inspect each dry-run before its corresponding write:
|
||||
Prepare all four lesson skills: `10x-idea-check`, `10x-init`, `10x-shape` and
|
||||
`10x-prd`. Idea assessment is optional to run; its files should still be available
|
||||
when preparing lesson 1. If the user requested only a specific skill, retain that
|
||||
narrower scope. After checking each name's capability and availability, download
|
||||
four separate complete selected skill trees. Inspect each dry-run before its corresponding write:
|
||||
|
||||
```bash
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
@@ -96,6 +101,11 @@ chosen macOS/zsh exercise directory, each of these checks must succeed before us
|
||||
(stop on any failure; do not infer success from the last check alone):
|
||||
|
||||
```bash
|
||||
test -s .claude/skills/10x-idea-check/SKILL.md
|
||||
test -s .claude/skills/10x-idea-check/references/examples.md
|
||||
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
|
||||
test -s .claude/skills/10x-init/SKILL.md
|
||||
test -s .claude/skills/10x-shape/SKILL.md
|
||||
test -s .claude/skills/10x-shape/references/prd-schema.md
|
||||
@@ -108,14 +118,14 @@ own directory. A standalone PRD tree is insufficient. Read all installed
|
||||
entrypoints and every reference they require; the paths above are the known
|
||||
source minimum, not permission to discard extra files from a published bundle.
|
||||
Also inspect `.claude/.10x-cli-manifest.json`: `lessons.m1l1.skills` must include
|
||||
all three names, with file hashes in `files.skills`. These are lesson-owned
|
||||
all four names, with file hashes in `files.skills`. These are lesson-owned
|
||||
partial downloads, not independent owners. Inspect `.10x-cli.json` for the course
|
||||
binding; partial downloads do not establish a complete lesson release identity.
|
||||
|
||||
CLI 1.21.0 is published with v4 and filtered skill downloads; production m1l1 EN/PL
|
||||
contains init/shape/prd and their references. These revised helpers are a separate
|
||||
contains idea-check/init/shape/prd and their references. These revised helpers are a separate
|
||||
source change, not proof that their course copies have been published.
|
||||
Verify all three names against the actual selected release. If any name, schema,
|
||||
Verify all four names against the actual selected release. If any name, schema,
|
||||
owner or release is missing/mismatched, preserve the precise error and stop the
|
||||
exercise; never silently substitute a whole lesson, another course or filtered get.
|
||||
|
||||
@@ -140,7 +150,11 @@ the learner's actual answers from lesson 1. If those inputs are absent, ask for
|
||||
them; do not invent product requirements, a replacement task.md or a ready-made
|
||||
plan. Keep private lesson text out of public fixtures and transcripts.
|
||||
Do not assume native slash/$ discovery or automatic activation from npm install.
|
||||
Give the agent explicit local paths and work through these steps separately:
|
||||
Give the agent explicit local paths. If the learner wants to assess whether an
|
||||
idea fits their experience, time and course goals, first read
|
||||
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
|
||||
Do not make assessment a prerequisite when the learner is ready to shape.
|
||||
Then work through these steps separately:
|
||||
|
||||
1. Read `.claude/skills/10x-init/SKILL.md` and follow it in the chosen project.
|
||||
Inspect the create-if-absent context/changes, context/archive and
|
||||
@@ -179,7 +193,7 @@ workaround:
|
||||
```
|
||||
|
||||
Normal sync refreshes the full lessons recorded in the manifest, not just the
|
||||
three selected skills. `--all` broadens scope to unlocked lessons and is not needed
|
||||
four selected skills. `--all` broadens scope to unlocked lessons and is not needed
|
||||
for this exercise. Missing managed files should be repaired; local edits should
|
||||
remain visible as conflicts or preserved files. Read all report outcomes and
|
||||
resource counts even if exit is 0: skipped conflicts alone are not process errors.
|
||||
@@ -252,6 +266,7 @@ visible. Doctor exit 78 can coexist with outer JSON `status: "ok"`.
|
||||
| Locked or unpublished v4 | Inspect module availability/release evidence; do not bypass the gate or fall back to v3. |
|
||||
| Unsupported name/missing index | Verify exact CLI package and content release; preserve the error for the release owner. |
|
||||
| Network/API failure | Keep diagnostics, retry the same context when service returns; no config reset. |
|
||||
| Missing `10x-idea-check` after setup | Older helper journeys selected only init/shape/prd. Check the actual commands, selected profile path and manifest; use the idea-check preview/get above to add its complete tree. If the files already exist, read that exact SKILL.md and check the agent's discovery/reload behavior before reinstalling. An absent slash command alone does not prove missing files. |
|
||||
| Wrong directory/profile | Recheck cwd and explicit flags; a fresh project may legitimately have no tool directory. |
|
||||
| Signature/release mismatch | Preserve failure and source identity; do not disable verification or reuse unrelated bytes. |
|
||||
| Edition/manifest conflict | Preserve binding and manifests for repair; use a separate v4 project rather than deleting them. |
|
||||
|
||||
@@ -162,7 +162,7 @@ already managed by CLI should be used/updated through CLI, not overwritten here.
|
||||
### CLI channel
|
||||
|
||||
Use this channel after setup/auth and only with verified skill-filter support and
|
||||
available v4 content. These helpers and the launch chain `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
|
||||
available v4 content. These helpers and the lesson skills `10x-idea-check`, `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
|
||||
and inherit course membership and module availability. Their source membership
|
||||
does not prove that the content has been published or unlocked.
|
||||
|
||||
@@ -201,11 +201,17 @@ place two updaters over the same files. Normal use does not require any takeover
|
||||
|
||||
## Download, use, update
|
||||
|
||||
The launch example is lesson 1's existing 10xCards: init → shape → PRD.
|
||||
Prepare lesson 1's four skills, including `10x-idea-check` with its references.
|
||||
The launch example remains the existing 10xCards: init → shape → PRD.
|
||||
Idea assessment is optional to run before that chain; install its tree during
|
||||
lesson setup so the learner can use it. Retain a narrower scope when the user
|
||||
explicitly requested only a specific skill.
|
||||
`10x-plan` is not available for the launch demonstration. After capability and
|
||||
content checks for each name, inspect each preview before its corresponding write:
|
||||
|
||||
```bash
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
@@ -214,10 +220,15 @@ content checks for each name, inspect each preview before its corresponding writ
|
||||
10x_cli get m1l1 --type skills --name 10x-prd --course 10xdevs4 --tool claude-code --lang pl
|
||||
```
|
||||
|
||||
Require the complete three trees and inspect each installed entrypoint/reference.
|
||||
Require the complete four trees and inspect each installed entrypoint/reference.
|
||||
All checks below must succeed before use; stop on any failure:
|
||||
|
||||
```bash
|
||||
test -s .claude/skills/10x-idea-check/SKILL.md
|
||||
test -s .claude/skills/10x-idea-check/references/examples.md
|
||||
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
|
||||
test -s .claude/skills/10x-init/SKILL.md
|
||||
test -s .claude/skills/10x-shape/SKILL.md
|
||||
test -s .claude/skills/10x-shape/references/prd-schema.md
|
||||
@@ -227,11 +238,11 @@ test -s .claude/skills/10x-prd/../10x-shape/references/prd-schema.md
|
||||
|
||||
PRD reads `../10x-shape/references/prd-schema.md` relative to its SKILL.md;
|
||||
isolated PRD download is insufficient. These are the source minimum: preserve
|
||||
additional supporting files in the selected release. Inspect all three names in
|
||||
additional supporting files in the selected release. Inspect all four names in
|
||||
`lessons.m1l1.skills`, their hashes in `files.skills`, and the project edition
|
||||
binding. Partial downloads do not establish complete lesson freshness/release identity.
|
||||
Membership in source is candidate evidence; actual filtered availability, full PL
|
||||
references and release identity still need verification for all three names.
|
||||
references and release identity still need verification for all four names.
|
||||
|
||||
`CLAUDE-m1l1` is a separate lesson rule and is not included in these filtered gets.
|
||||
The inspected three skill sources do not require it for the chain. This is not
|
||||
@@ -240,8 +251,10 @@ require it, inspect an existing rule's provenance, or report the missing
|
||||
prerequisite and ask the lesson/release owner for a supported route before that
|
||||
step. Never invent a command, overwrite a rule or fall back to full lesson get.
|
||||
|
||||
Have the agent explicitly read each installed SKILL.md and its references in
|
||||
order: init preserves/scaffolds context directories; shape conducts the actual
|
||||
If the learner wants to assess their idea before shaping, have the agent read
|
||||
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
|
||||
A learner ready to shape can skip assessment. Then read the chain's entrypoints
|
||||
and references in order: init preserves/scaffolds context directories; shape conducts the actual
|
||||
10xCards discovery with the learner and writes
|
||||
`context/foundation/shape-notes.md`; after the learner approves those notes, PRD
|
||||
uses them and the sibling schema to produce `context/foundation/prd.md`.
|
||||
@@ -252,7 +265,7 @@ work. Stop at PRD, without stack selection or implementation. Download alone is
|
||||
not use; native slash/$ discovery needs separate agent evidence. Keep private
|
||||
lesson text out of public fixtures. The guide supplies the detailed agent steps.
|
||||
|
||||
Sync below refreshes entire recorded lessons, not only the three skill filters.
|
||||
Sync below refreshes entire recorded lessons, not only the four skill filters.
|
||||
Preview may include other skills, prompts, configs and course rules; apply only
|
||||
when the user accepts that scope. For a narrow update, repeat the selected skill
|
||||
filter instead. Never use sync to silently bypass a missing lesson-rule prerequisite.
|
||||
|
||||
@@ -125,9 +125,15 @@ Auth: valid / login required / unknown; live course access and module state
|
||||
Setup helper: actual path, public or CLI owner, observed source ref if known
|
||||
Guide helper: actual path, owner and source ref if known; full reference present
|
||||
Readiness: verified checks; remaining release/network/access issues, if any
|
||||
Next task: guide's download → use → sync journey, retaining this context
|
||||
Next task: guide's lesson setup (idea-check/init/shape/prd) → use → sync journey, or the user's narrower request
|
||||
```
|
||||
|
||||
For lesson 1 setup, carry forward all four skills in the handoff:
|
||||
`10x-idea-check`, `10x-init`, `10x-shape` and `10x-prd`, with their references.
|
||||
Idea assessment is optional to run, not a reason to omit its files during lesson
|
||||
setup. A request for one specific skill remains a narrower download. Verify
|
||||
materialized files separately from native slash-command discovery.
|
||||
|
||||
Once ready, continue in guide without rerunning installation or asking the user
|
||||
to repeat choices already supplied. Report remaining blockers precisely if the
|
||||
download cannot yet run; do not present preparation as a completed real journey.
|
||||
|
||||
@@ -162,7 +162,7 @@ already managed by CLI should be used/updated through CLI, not overwritten here.
|
||||
### CLI channel
|
||||
|
||||
Use this channel after setup/auth and only with verified skill-filter support and
|
||||
available v4 content. These helpers and the launch chain `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
|
||||
available v4 content. These helpers and the lesson skills `10x-idea-check`, `10x-init`, `10x-shape`, `10x-prd` belong to m1l1
|
||||
and inherit course membership and module availability. Their source membership
|
||||
does not prove that the content has been published or unlocked.
|
||||
|
||||
@@ -201,11 +201,17 @@ place two updaters over the same files. Normal use does not require any takeover
|
||||
|
||||
## Download, use, update
|
||||
|
||||
The launch example is lesson 1's existing 10xCards: init → shape → PRD.
|
||||
Prepare lesson 1's four skills, including `10x-idea-check` with its references.
|
||||
The launch example remains the existing 10xCards: init → shape → PRD.
|
||||
Idea assessment is optional to run before that chain; install its tree during
|
||||
lesson setup so the learner can use it. Retain a narrower scope when the user
|
||||
explicitly requested only a specific skill.
|
||||
`10x-plan` is not available for the launch demonstration. After capability and
|
||||
content checks for each name, inspect each preview before its corresponding write:
|
||||
|
||||
```bash
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-idea-check --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
10x_cli get m1l1 --type skills --name 10x-init --course 10xdevs4 --tool claude-code --lang pl
|
||||
10x_cli get m1l1 --type skills --name 10x-shape --course 10xdevs4 --tool claude-code --lang pl --dry-run
|
||||
@@ -214,10 +220,15 @@ content checks for each name, inspect each preview before its corresponding writ
|
||||
10x_cli get m1l1 --type skills --name 10x-prd --course 10xdevs4 --tool claude-code --lang pl
|
||||
```
|
||||
|
||||
Require the complete three trees and inspect each installed entrypoint/reference.
|
||||
Require the complete four trees and inspect each installed entrypoint/reference.
|
||||
All checks below must succeed before use; stop on any failure:
|
||||
|
||||
```bash
|
||||
test -s .claude/skills/10x-idea-check/SKILL.md
|
||||
test -s .claude/skills/10x-idea-check/references/examples.md
|
||||
test -s .claude/skills/10x-idea-check/references/assessment-guide.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-dates.md
|
||||
test -s .claude/skills/10x-idea-check/references/10xdevs-4-certification.md
|
||||
test -s .claude/skills/10x-init/SKILL.md
|
||||
test -s .claude/skills/10x-shape/SKILL.md
|
||||
test -s .claude/skills/10x-shape/references/prd-schema.md
|
||||
@@ -227,11 +238,11 @@ test -s .claude/skills/10x-prd/../10x-shape/references/prd-schema.md
|
||||
|
||||
PRD reads `../10x-shape/references/prd-schema.md` relative to its SKILL.md;
|
||||
isolated PRD download is insufficient. These are the source minimum: preserve
|
||||
additional supporting files in the selected release. Inspect all three names in
|
||||
additional supporting files in the selected release. Inspect all four names in
|
||||
`lessons.m1l1.skills`, their hashes in `files.skills`, and the project edition
|
||||
binding. Partial downloads do not establish complete lesson freshness/release identity.
|
||||
Membership in source is candidate evidence; actual filtered availability, full PL
|
||||
references and release identity still need verification for all three names.
|
||||
references and release identity still need verification for all four names.
|
||||
|
||||
`CLAUDE-m1l1` is a separate lesson rule and is not included in these filtered gets.
|
||||
The inspected three skill sources do not require it for the chain. This is not
|
||||
@@ -240,8 +251,10 @@ require it, inspect an existing rule's provenance, or report the missing
|
||||
prerequisite and ask the lesson/release owner for a supported route before that
|
||||
step. Never invent a command, overwrite a rule or fall back to full lesson get.
|
||||
|
||||
Have the agent explicitly read each installed SKILL.md and its references in
|
||||
order: init preserves/scaffolds context directories; shape conducts the actual
|
||||
If the learner wants to assess their idea before shaping, have the agent read
|
||||
`.claude/skills/10x-idea-check/SKILL.md` and its references and follow that skill.
|
||||
A learner ready to shape can skip assessment. Then read the chain's entrypoints
|
||||
and references in order: init preserves/scaffolds context directories; shape conducts the actual
|
||||
10xCards discovery with the learner and writes
|
||||
`context/foundation/shape-notes.md`; after the learner approves those notes, PRD
|
||||
uses them and the sibling schema to produce `context/foundation/prd.md`.
|
||||
@@ -252,7 +265,7 @@ work. Stop at PRD, without stack selection or implementation. Download alone is
|
||||
not use; native slash/$ discovery needs separate agent evidence. Keep private
|
||||
lesson text out of public fixtures. The guide supplies the detailed agent steps.
|
||||
|
||||
Sync below refreshes entire recorded lessons, not only the three skill filters.
|
||||
Sync below refreshes entire recorded lessons, not only the four skill filters.
|
||||
Preview may include other skills, prompts, configs and course rules; apply only
|
||||
when the user accepts that scope. For a narrow update, repeat the selected skill
|
||||
filter instead. Never use sync to silently bypass a missing lesson-rule prerequisite.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs";
|
||||
import { BranchUpdateRequiredError, calculateVersion, packageWithVersion } from "../scripts/auto-version.mjs";
|
||||
import { preparePullRequest, validatePullRequest, verifyMergedPreparation, reconcileVersionEvent } from "../scripts/prepare-version.mjs";
|
||||
const sha = (c: string) => c.repeat(40);
|
||||
function fixture() {
|
||||
@@ -131,3 +131,64 @@ describe("generated version filtering uses content, never commit scope", () => {
|
||||
} finally { f.cleanup(); }
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("stale PR version preparation", () => {
|
||||
it("classifies unrebased open PRs, writes nothing, and lets reconcile skip them while preparing current PRs", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.git("checkout", "-qb", "candidate");
|
||||
mkdirSync(join(f.cwd, "skills")); writeFileSync(join(f.cwd, "skills/SKILL.md"), "helper\n");
|
||||
f.git("add", "."); f.git("commit", "-qm", "feat: helper candidate");
|
||||
let staleHead = f.git("rev-parse", "HEAD");
|
||||
f.git("checkout", "-qb", "current-master", f.base);
|
||||
writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 2;\n");
|
||||
f.git("add", "."); f.git("commit", "-qm", "feat: merged sibling"); f.git("tag", "v1.1.0");
|
||||
const master = f.git("rev-parse", "HEAD"), repo = { full_name: "przeprogramowani/10x-cli" };
|
||||
f.git("checkout", "-qb", "current-candidate", master);
|
||||
writeFileSync(join(f.cwd, "src/index.ts"), "export const value = 3;\n");
|
||||
f.git("add", "."); f.git("commit", "-qm", "fix: current open candidate");
|
||||
const currentHead = f.git("rev-parse", "HEAD");
|
||||
let baseline = f.baseline;
|
||||
const stale = { number: 47, state: "open", head: { sha: staleHead, ref: "candidate", repo }, base: { sha: f.base, ref: "master", repo } };
|
||||
const current = { number: 49, state: "open", head: { sha: currentHead, ref: "current-candidate", repo }, base: { sha: master, ref: "master", repo } };
|
||||
const writes: Array<{ path: string; method: string }> = [];
|
||||
const get = async (path: string, method = "GET"): Promise<any> => {
|
||||
if (method !== "GET") { writes.push({ path, method }); return { sha: sha("d") }; }
|
||||
if (path === "pulls/47") return stale;
|
||||
if (path === "pulls/49") return current;
|
||||
if (path === "pulls?state=open&base=master&per_page=100") return [stale, current];
|
||||
if (path === "git/ref/heads/master") return { object: { sha: master } };
|
||||
return { tree: { sha: sha("c") } };
|
||||
};
|
||||
const prepareHint = (hint: { number: number }) => preparePullRequest({ number: hint.number, runId: "201", runAttempt: 1, workflowSha: master }, {
|
||||
get, calculate: (input: any) => calculateVersion({ cwd: f.cwd, ...input }),
|
||||
readPackage: async (value: string) => f.git("show", `${value}:package.json`), baseline: async () => baseline,
|
||||
});
|
||||
await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" });
|
||||
expect(writes).toEqual([]);
|
||||
stale.base.sha = master;
|
||||
await expect(prepareHint(stale)).rejects.toMatchObject({ name: "BranchUpdateRequiredError" });
|
||||
expect(writes).toEqual([]);
|
||||
for (const input of [{ head: staleHead, baseline: { ...baseline, gitHead: sha("f") } }, { head: sha("f"), baseline }]) {
|
||||
let error: any;
|
||||
try { await calculateVersion({ cwd: f.cwd, master, ...input }); } catch (caught) { error = caught; }
|
||||
expect(error).toBeDefined(); expect(error).not.toBeInstanceOf(BranchUpdateRequiredError);
|
||||
}
|
||||
stale.base.sha = f.base;
|
||||
const env = { GITHUB_REPOSITORY: repo.full_name, GITHUB_REF: "refs/heads/master", GITHUB_EVENT_NAME: "push" };
|
||||
const records = await reconcileVersionEvent({ env, event: {} }, { get, prepare: prepareHint, baseline: async () => baseline });
|
||||
expect(records[0]).toBeNull();
|
||||
expect(records[1]?.prNumber).toBe(49);
|
||||
expect(records[1]?.version).toBe("1.1.0");
|
||||
expect(records[1]?.inputHead).toBe(currentHead);
|
||||
expect(writes.some((write) => write.path === "git/refs/heads/current-candidate" && write.method === "PATCH")).toBe(true);
|
||||
expect(writes.some((write) => write.path.startsWith("git/refs/heads/candidate"))).toBe(false);
|
||||
f.git("checkout", "-q", "candidate");
|
||||
f.git("merge", "--no-edit", "current-master");
|
||||
staleHead = f.git("rev-parse", "HEAD"); stale.head.sha = staleHead; stale.base.sha = master;
|
||||
writes.length = 0;
|
||||
const updated = await prepareHint(stale);
|
||||
expect(updated?.baseSha).toBe(master); expect(updated?.inputHead).toBe(staleHead); expect(updated?.version).toBe("1.1.0");
|
||||
} finally { f.cleanup(); }
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -7,11 +7,23 @@ import type { LessonBundle } from "../src/lib/api-content";
|
||||
import { applyBundle } from "../src/lib/writer";
|
||||
|
||||
const repo = resolve(import.meta.dir, "..");
|
||||
const names = ["10x-init", "10x-shape", "10x-prd"];
|
||||
const names = ["10x-idea-check", "10x-init", "10x-shape", "10x-prd"];
|
||||
const ideaCheckReferences = [
|
||||
"examples.md",
|
||||
"assessment-guide.md",
|
||||
"10xdevs-4-dates.md",
|
||||
"10xdevs-4-certification.md",
|
||||
];
|
||||
const schema = "../10x-shape/references/prd-schema.md";
|
||||
const required = names.map((name) => `.claude/skills/${name}/SKILL.md`);
|
||||
required.splice(2, 0, ".claude/skills/10x-shape/references/prd-schema.md");
|
||||
required.push(`.claude/skills/10x-prd/${schema}`);
|
||||
const required = [
|
||||
".claude/skills/10x-idea-check/SKILL.md",
|
||||
...ideaCheckReferences.map((name) => `.claude/skills/10x-idea-check/references/${name}`),
|
||||
".claude/skills/10x-init/SKILL.md",
|
||||
".claude/skills/10x-shape/SKILL.md",
|
||||
".claude/skills/10x-shape/references/prd-schema.md",
|
||||
".claude/skills/10x-prd/SKILL.md",
|
||||
`.claude/skills/10x-prd/${schema}`,
|
||||
];
|
||||
const docs = [
|
||||
"skills/10x-cli-guide/SKILL.md",
|
||||
"skills/10x-cli-guide/references/compatibility.md",
|
||||
@@ -22,7 +34,8 @@ const expectedGets = names.flatMap((name) => [
|
||||
`10x_cli get m1l1 --type skills --name ${name} ${flags} --dry-run`, `10x_cli get m1l1 --type skills --name ${name} ${flags}`,
|
||||
]);
|
||||
function demoGets(text: string) {
|
||||
return text.split(/\r?\n/).filter((line) => /^10x_cli get m1l1 --type skills --name 10x-(?:init|shape|prd) /.test(line));
|
||||
return text.split(/\r?\n/).filter((line) =>
|
||||
/^10x_cli get m1l1 --type skills --name (?!10x-cli-(?:setup|guide)\b)\S+ /.test(line));
|
||||
}
|
||||
function preflightPaths(text: string) {
|
||||
return text.split(/\r?\n/).filter((line) => line.startsWith("test -s ")).map((line) => line.slice(8));
|
||||
@@ -37,6 +50,11 @@ function fixture(name: string): LessonBundle {
|
||||
? `# Synthetic PRD fixture\nRead \`${schema}\` before using context/foundation/shape-notes.md to write context/foundation/prd.md.\n`
|
||||
: `# Synthetic ${name} fixture\n` }];
|
||||
if (name === "10x-shape") files.push({ path: "references/prd-schema.md", content: "# Synthetic schema fixture\n" });
|
||||
if (name === "10x-idea-check") {
|
||||
for (const reference of ideaCheckReferences) {
|
||||
files.push({ path: `references/${reference}`, content: `# Synthetic ${reference} fixture\n` });
|
||||
}
|
||||
}
|
||||
return { lessonId: "m1l1", module: 1, lesson: 1, title: "Offline fixture", summary: "Not lesson content",
|
||||
skills: [{ name, files }], prompts: [], rules: [], configs: [] };
|
||||
}
|
||||
@@ -52,7 +70,7 @@ afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||
describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with newline %j", (newline) => {
|
||||
// Exercise both checkout line endings on every platform, including negative cases.
|
||||
const read = (path: string) => readFileSync(join(repo, path), "utf8").replace(/\r?\n/g, newline);
|
||||
it("documents separate preview/write pairs in init → shape → PRD order with the same context", () => {
|
||||
it("documents separate preview/write pairs in idea-check → init → shape → PRD order with the same context", () => {
|
||||
for (const path of [...docs, "README.md"]) {
|
||||
const text = read(path);
|
||||
expect(demoGets(text)).toEqual(expectedGets);
|
||||
@@ -62,7 +80,7 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
|
||||
}
|
||||
for (const path of docs) expect(preflightPaths(read(path))).toEqual(required);
|
||||
});
|
||||
it("materializes the three documented trees with the real writer and resolves the PRD sibling schema", async () => {
|
||||
it("materializes all four documented trees and their supporting files with the real writer", async () => {
|
||||
// Actual sequential partial writer, without an HTTP or learner-use claim.
|
||||
await materialize(names);
|
||||
for (const path of docs) expect(missingPaths(root, preflightPaths(read(path)))).toEqual([]);
|
||||
@@ -70,9 +88,25 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
|
||||
const ref = /Read `([^`]+)`/.exec(readFileSync(prd, "utf8"))![1]!;
|
||||
expect(resolve(dirname(prd), ref)).toBe(join(root, ".claude/skills/10x-shape/references/prd-schema.md"));
|
||||
expect(readFileSync(resolve(dirname(prd), ref), "utf8")).toBe("# Synthetic schema fixture\n");
|
||||
for (const reference of ideaCheckReferences) {
|
||||
expect(readFileSync(join(root, `.claude/skills/10x-idea-check/references/${reference}`), "utf8"))
|
||||
.toBe(`# Synthetic ${reference} fixture\n`);
|
||||
}
|
||||
expect(existsSync(join(root, "CLAUDE.md"))).toBe(false);
|
||||
expect(existsSync(join(root, "context/foundation/prd.md"))).toBe(false);
|
||||
});
|
||||
it("detects every missing or empty idea-check support file", async () => {
|
||||
await materialize(names);
|
||||
for (const reference of ideaCheckReferences) {
|
||||
const path = `.claude/skills/10x-idea-check/references/${reference}`;
|
||||
const bytes = readFileSync(join(root, path));
|
||||
for (const mode of ["missing", "empty"]) {
|
||||
if (mode === "missing") rmSync(join(root, path)); else writeFileSync(join(root, path), "");
|
||||
for (const doc of docs) expect(missingPaths(root, preflightPaths(read(doc)))).toEqual([path]);
|
||||
writeFileSync(join(root, path), bytes);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("rejects isolated PRD and missing or empty schema despite the presence of all entrypoints", async () => {
|
||||
await materialize(["10x-prd"]);
|
||||
expect(missingPaths(root, required)).toEqual(required.filter((path) => !path.endsWith("10x-prd/SKILL.md")));
|
||||
@@ -81,7 +115,8 @@ describe.each(["\n", "\r\n"])("launch journey and complete supporting trees with
|
||||
for (const mode of ["missing", "empty"]) {
|
||||
if (mode === "missing") rmSync(path); else writeFileSync(path, "");
|
||||
for (const doc of docs) expect(missingPaths(root, preflightPaths(read(doc)))).toEqual([
|
||||
required[2]!, required[4]!,
|
||||
".claude/skills/10x-shape/references/prd-schema.md",
|
||||
`.claude/skills/10x-prd/${schema}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -694,9 +694,10 @@ describe("10x get — course rules opt-out", () => {
|
||||
|
||||
|
||||
describe("helper launch commands match the released lesson filter", () => {
|
||||
it("executes the documented preview/write sequence and retains all three lesson-owned trees", async () => {
|
||||
it("executes the documented preview/write sequence and retains all four lesson-owned trees", async () => {
|
||||
writeValidAuth();
|
||||
const names = ["10x-init", "10x-shape", "10x-prd"];
|
||||
const names = ["10x-idea-check", "10x-init", "10x-shape", "10x-prd"];
|
||||
const ideaCheckReferences = ["examples.md", "assessment-guide.md", "10xdevs-4-dates.md", "10xdevs-4-certification.md"];
|
||||
const release = { course: "10xdevs4", releaseId: `r-${"a".repeat(64)}`, releaseManifestHash: "b".repeat(64) };
|
||||
apiContentMockState.fetchCoursesImpl = () => ({ ok: true, status: 200, responseHeaders: new Headers(), rawBody: "", data: {
|
||||
courses: [{ id: "10xdevs-4", slug: "10xdevs4", title: "Synthetic v4", edition: 4, available: true }], defaultCourse: "10xdevs4",
|
||||
@@ -705,6 +706,9 @@ describe("helper launch commands match the released lesson filter", () => {
|
||||
const bundle = makeBundle({ ...release, skills: names.map((name) => ({ name, files: [
|
||||
{ path: "SKILL.md", content: name === "10x-prd" ? "Read ../10x-shape/references/prd-schema.md" : `Synthetic ${name}` },
|
||||
...(name === "10x-shape" ? [{ path: "references/prd-schema.md", content: "Synthetic schema" }] : []),
|
||||
...(name === "10x-idea-check" ? ideaCheckReferences.map((reference) => ({
|
||||
path: `references/${reference}`, content: `Synthetic ${reference}`,
|
||||
})) : []),
|
||||
] })) });
|
||||
let fetches = 0;
|
||||
apiContentMockState.fetchLessonImpl = (course, lesson, _token, options) => {
|
||||
@@ -714,11 +718,18 @@ describe("helper launch commands match the released lesson filter", () => {
|
||||
};
|
||||
const guide = readFileSync(new URL("../skills/10x-cli-guide/SKILL.md", import.meta.url), "utf8");
|
||||
const commands = guide.split(/\r?\n/).filter((line) => /^10x_cli get (?!-)/.test(line));
|
||||
expect(commands).toHaveLength(6);
|
||||
expect(commands).toHaveLength(8);
|
||||
for (const [index, command] of commands.entries()) {
|
||||
const name = names[Math.floor(index / 2)]!;
|
||||
const target = join(projectRoot, `.claude/skills/${name}/SKILL.md`);
|
||||
if (index % 2 === 0) expect(existsSync(target)).toBe(false);
|
||||
if (index % 2 === 0) {
|
||||
expect(existsSync(target)).toBe(false);
|
||||
if (name === "10x-idea-check") {
|
||||
for (const reference of ideaCheckReferences) {
|
||||
expect(existsSync(join(projectRoot, `.claude/skills/10x-idea-check/references/${reference}`))).toBe(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await runGet(command.split(/\s+/).slice(1));
|
||||
// Let this existing capture harness restore streams before the next invocation.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
@@ -728,16 +739,21 @@ describe("helper launch commands match the released lesson filter", () => {
|
||||
for (const prior of names.slice(0, Math.floor(index / 2) + 1)) expect(existsSync(join(projectRoot, `.claude/skills/${prior}/SKILL.md`))).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(fetches).toBe(6);
|
||||
expect(fetches).toBe(8);
|
||||
const manifest = readManifest(join(projectRoot, ".claude"))!;
|
||||
expect(Object.keys(manifest.lessons!)).toEqual(["m1l1"]);
|
||||
expect(Object.keys(manifest.lessons!.m1l1!.skills)).toEqual(names);
|
||||
expect(manifest.lessons!.m1l1!.representation).toBeUndefined();
|
||||
for (const reference of ideaCheckReferences) {
|
||||
expect(readFileSync(join(projectRoot, `.claude/skills/10x-idea-check/references/${reference}`), "utf8"))
|
||||
.toBe(`Synthetic ${reference}`);
|
||||
expect(manifest.lessons!.m1l1!.skills["10x-idea-check"]!.files).toContain(`references/${reference}`);
|
||||
}
|
||||
expect(readFileSync(join(projectRoot, ".claude/skills/10x-prd/../10x-shape/references/prd-schema.md"), "utf8")).toBe("Synthetic schema");
|
||||
expect(existsSync(join(projectRoot, "CLAUDE.md"))).toBe(false);
|
||||
expect(manifest.files.prompts).toEqual([]); expect(manifest.files.configs).toEqual([]);
|
||||
const invalid = await runGet(["get", "10x-init", "--course", "10xdevs4"]);
|
||||
expect(invalid.exitCode).toBe(2); parseErr(invalid.stdout, "invalid_lesson_ref");
|
||||
expect(fetches).toBe(6);
|
||||
expect(fetches).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "yaml";
|
||||
import { assertPackMatchesRegistry, classifyPublishDecision, metadataIsComplete, waitForPublishedMetadata } from "../scripts/publish-npm-verify.mjs";
|
||||
|
||||
const sha = (c: string) => c.repeat(40);
|
||||
const integrity = "sha512-UpH51iLC2WQmjhzVj2HbfOu+79ob0EtcQ3XI2epIHrEq3Vl8NxnBoqjCSHCRVdvUNXCgmmCC8kU/06IW/lsGew==";
|
||||
const tarball = "https://registry.npmjs.org/@przeprogramowani/10x-cli/-/10x-cli-1.22.1.tgz";
|
||||
const complete = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity } };
|
||||
|
||||
describe("publish-npm registry wait and resume", () => {
|
||||
it("polls until dist.tarball and integrity exist, then matches pack bytes", async () => {
|
||||
const log: string[] = [];
|
||||
let n = 0;
|
||||
const fetchFn = async () => {
|
||||
n += 1;
|
||||
if (n === 1) return { status: 404, text: async () => '{"error":"Not found"}', ok: false };
|
||||
if (n === 2) return { status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true };
|
||||
return { status: 200, text: async () => JSON.stringify(complete), ok: true };
|
||||
};
|
||||
const meta = await waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 1); })(), log: (m: string) => log.push(m), timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 });
|
||||
expect(metadataIsComplete(meta)).toBe(true);
|
||||
expect(n).toBe(3);
|
||||
expect(log.some((line) => line.includes("attempt 1"))).toBe(true);
|
||||
expect(log.at(-1)).toContain("ready");
|
||||
});
|
||||
|
||||
it("fails closed when the wait window expires without complete metadata", async () => {
|
||||
const fetchFn = async () => ({ status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true });
|
||||
await expect(waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 50); })(), log: () => {}, timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 })).rejects.toThrow(/never republish/);
|
||||
});
|
||||
|
||||
it("rejects a registry copy whose gitHead or integrity differs from the pack", () => {
|
||||
expect(() => assertPackMatchesRegistry({ metadata: complete, tarballBytes: Buffer.from("nope"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/);
|
||||
expect(() => assertPackMatchesRegistry({ metadata: { ...complete, gitHead: sha("e") }, tarballBytes: Buffer.from("x"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/);
|
||||
});
|
||||
|
||||
it("publishes when the version is absent and resumes when registry bytes already match", async () => {
|
||||
const missing = async (url: string) => {
|
||||
if (String(url).includes("/1.22.1") && !String(url).includes("/-/")) return { status: 404, text: async () => '{"error":"Not found"}', ok: false };
|
||||
throw new Error(url);
|
||||
};
|
||||
expect(await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: integrity, sourceSha: sha("d"), fetchFn: missing as any })).toEqual({ action: "publish" });
|
||||
|
||||
const bytes = Buffer.from("pack-bytes");
|
||||
const { createHash } = await import("node:crypto");
|
||||
const matching = "sha512-" + createHash("sha512").update(bytes).digest("base64");
|
||||
const meta = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity: matching } };
|
||||
const fetchFn = async (url: string) => {
|
||||
if (String(url) === tarball) return { ok: true, arrayBuffer: async () => bytes };
|
||||
return { status: 200, text: async () => JSON.stringify(meta), ok: true };
|
||||
};
|
||||
const decision = await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: matching, sourceSha: sha("d"), fetchFn: fetchFn as any });
|
||||
expect(decision.action).toBe("resume");
|
||||
expect(decision.result?.gitHead).toBe(sha("d"));
|
||||
expect(decision.result?.actualIntegrity).toBe(matching);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publish-npm workflow uses bounded verify and resume", () => {
|
||||
it("calls the verifier for decide/verify and publishes only when decide says publish", () => {
|
||||
const workflow = parse(readFileSync(new URL("../.github/workflows/publish-npm.yml", import.meta.url), "utf8"));
|
||||
const steps = workflow.jobs.publish.steps;
|
||||
const decide = steps.find((step: any) => step.id === "decide");
|
||||
expect(decide.run).toBe("node scripts/publish-npm-verify.mjs decide");
|
||||
expect(decide.env.CLI_SHA).toBe("${{ inputs.cli_sha }}");
|
||||
const publish = steps.find((step: any) => step.name === "Publish the exact directory once");
|
||||
expect(publish.if).toBe("steps.decide.outputs.action == 'publish'");
|
||||
expect(publish.run).toContain("npm publish . --ignore-scripts --access public");
|
||||
const verify = steps.find((step: any) => step.name === "Verify actual registry bytes against the pack");
|
||||
expect(verify.run).toBe("node scripts/publish-npm-verify.mjs verify");
|
||||
expect(verify.if).toBeUndefined();
|
||||
const source = readFileSync(new URL("../scripts/publish-npm-verify.mjs", import.meta.url), "utf8");
|
||||
expect(source).toContain("never republish");
|
||||
expect(source).not.toMatch(/timeoutMs:\s*[5-9]\d{5,}/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user