@przeprogramowani/10x-cli 1.19.1 → 1.21.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/README.md +59 -14
- package/dist/index.mjs +1922 -1093
- package/package.json +1 -1
- package/skills/10x-cli-guide/SKILL.md +45 -8
package/dist/index.mjs
CHANGED
|
@@ -3183,7 +3183,7 @@ var cac = (name = "") => new CAC(name);
|
|
|
3183
3183
|
// package.json
|
|
3184
3184
|
var package_default = {
|
|
3185
3185
|
name: "@przeprogramowani/10x-cli",
|
|
3186
|
-
version: "1.
|
|
3186
|
+
version: "1.21.0",
|
|
3187
3187
|
description: "Open-source CLI for 10xDevs course content",
|
|
3188
3188
|
repository: {
|
|
3189
3189
|
type: "git",
|
|
@@ -3350,6 +3350,9 @@ function updateToolConfig(patch) {
|
|
|
3350
3350
|
saveToolConfig(merged);
|
|
3351
3351
|
}
|
|
3352
3352
|
|
|
3353
|
+
// src/lib/auth-flow.ts
|
|
3354
|
+
import { hostname as osHostname, release as osRelease } from "node:os";
|
|
3355
|
+
|
|
3353
3356
|
// src/lib/api-client.ts
|
|
3354
3357
|
var DEFAULT_API_BASE = "https://10x-toolkit-api.przeprogramowani.workers.dev";
|
|
3355
3358
|
var PROD_HOSTNAME = "10x-toolkit-api.przeprogramowani.workers.dev";
|
|
@@ -3382,6 +3385,10 @@ var DEFAULT_TIMEOUT_MS = 30000;
|
|
|
3382
3385
|
var UNKNOWN_API_ERROR_MESSAGE = "An unknown error occurred. Please contact 10xDevs mentors and share the command that caused this. Sorry for the issue!";
|
|
3383
3386
|
var ERROR_CODE_MESSAGES = {
|
|
3384
3387
|
course_not_found: "Course not found.",
|
|
3388
|
+
course_unavailable: "This course has no available published content yet.",
|
|
3389
|
+
course_access_denied: "Your account does not have access to this course.",
|
|
3390
|
+
invalid_release: "The selected content release is invalid.",
|
|
3391
|
+
release_mismatch: "Content does not match the selected release. Run the command again.",
|
|
3385
3392
|
lesson_not_found: "Lesson not found.",
|
|
3386
3393
|
module_not_found: "Module not found.",
|
|
3387
3394
|
artifact_not_found: "Artifact not found.",
|
|
@@ -3395,6 +3402,12 @@ var ERROR_CODE_MESSAGES = {
|
|
|
3395
3402
|
membership_revoked: "Your course membership has been revoked.",
|
|
3396
3403
|
rate_limited: "Too many requests. Please slow down and try again shortly.",
|
|
3397
3404
|
email_send_failed: "Could not send the login email. Try again in a moment.",
|
|
3405
|
+
circle_login_disabled: "Circle login is currently unavailable. Run `10x auth --method email` instead.",
|
|
3406
|
+
dm_rejected: "Circle refused to deliver the login message. Check your Circle direct-message settings or run `10x auth --method email`.",
|
|
3407
|
+
access_denied: "This account is not allowed to sign in. Check your course access.",
|
|
3408
|
+
expired_or_used: "This Circle login link has expired or was already used. Run `10x auth --method circle` again.",
|
|
3409
|
+
slow_down: "Polling too fast. The CLI will wait longer between checks.",
|
|
3410
|
+
auth_cancelled: "Authentication was cancelled before it completed.",
|
|
3398
3411
|
admin_access_required: "This action requires admin access.",
|
|
3399
3412
|
circle_api_error: "The membership service is temporarily unavailable.",
|
|
3400
3413
|
internal_error: "The server hit an unexpected error."
|
|
@@ -3467,7 +3480,7 @@ async function request(method, path, body, options = {}) {
|
|
|
3467
3480
|
return {
|
|
3468
3481
|
ok: false,
|
|
3469
3482
|
status: response.status,
|
|
3470
|
-
code: typeof payload?.code === "string" ? payload.code : `http_${response.status}`,
|
|
3483
|
+
code: typeof payload?.code === "string" ? payload.code : typeof payload?.error === "string" ? payload.error : `http_${response.status}`,
|
|
3471
3484
|
error: errorMessage,
|
|
3472
3485
|
payload
|
|
3473
3486
|
};
|
|
@@ -3541,6 +3554,97 @@ async function pollVerifySession(sessionId, options = {}) {
|
|
|
3541
3554
|
await sleep(intervalMs);
|
|
3542
3555
|
}
|
|
3543
3556
|
}
|
|
3557
|
+
var CLIENT_HOSTNAME_MAX = 128;
|
|
3558
|
+
var CLIENT_OS_MAX = 64;
|
|
3559
|
+
function describeCircleClient() {
|
|
3560
|
+
const client = {};
|
|
3561
|
+
try {
|
|
3562
|
+
const host = osHostname();
|
|
3563
|
+
if (host.length > 0)
|
|
3564
|
+
client.hostname = host.slice(0, CLIENT_HOSTNAME_MAX);
|
|
3565
|
+
} catch {}
|
|
3566
|
+
try {
|
|
3567
|
+
client.os = `${process.platform} ${osRelease()}`.slice(0, CLIENT_OS_MAX);
|
|
3568
|
+
} catch {}
|
|
3569
|
+
return client;
|
|
3570
|
+
}
|
|
3571
|
+
function circleStartRequest(email, client = {}, options = {}) {
|
|
3572
|
+
return apiPost("/auth/circle/start", { email, client }, { signal: options.signal });
|
|
3573
|
+
}
|
|
3574
|
+
async function checkCircleLogin(deviceCode, options = {}) {
|
|
3575
|
+
const result = await apiPost("/auth/circle/poll", { device_code: deviceCode }, { signal: options.signal });
|
|
3576
|
+
if (result.ok) {
|
|
3577
|
+
if (result.status === 200 && result.data && "token" in result.data) {
|
|
3578
|
+
return { kind: "verified", tokens: result.data };
|
|
3579
|
+
}
|
|
3580
|
+
return { kind: "pending" };
|
|
3581
|
+
}
|
|
3582
|
+
if (result.status === 0)
|
|
3583
|
+
return { kind: "pending" };
|
|
3584
|
+
if (result.status === 400 && result.code === "slow_down")
|
|
3585
|
+
return { kind: "slow_down" };
|
|
3586
|
+
if (result.status >= 500 || result.status === 429)
|
|
3587
|
+
return { kind: "pending" };
|
|
3588
|
+
const message = result.payload?.message ?? result.error;
|
|
3589
|
+
if (result.status === 410)
|
|
3590
|
+
return { kind: "expired", message };
|
|
3591
|
+
if (result.status === 403)
|
|
3592
|
+
return { kind: "denied", message };
|
|
3593
|
+
return {
|
|
3594
|
+
kind: "error",
|
|
3595
|
+
code: result.code,
|
|
3596
|
+
message: result.error,
|
|
3597
|
+
status: result.status
|
|
3598
|
+
};
|
|
3599
|
+
}
|
|
3600
|
+
var SLOW_DOWN_INCREMENT_MS = 5000;
|
|
3601
|
+
function abortableSleep(ms, signal) {
|
|
3602
|
+
return new Promise((resolve) => {
|
|
3603
|
+
if (signal?.aborted) {
|
|
3604
|
+
resolve();
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
const timer = setTimeout(() => {
|
|
3608
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3609
|
+
resolve();
|
|
3610
|
+
}, ms);
|
|
3611
|
+
function onAbort() {
|
|
3612
|
+
clearTimeout(timer);
|
|
3613
|
+
resolve();
|
|
3614
|
+
}
|
|
3615
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
async function pollCircleLogin(deviceCode, options = {}) {
|
|
3619
|
+
let intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
3620
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
3621
|
+
const now = options.now ?? Date.now;
|
|
3622
|
+
const sleep = options.sleep ?? ((ms) => abortableSleep(ms, options.signal));
|
|
3623
|
+
const startedAt = now();
|
|
3624
|
+
const deadline = startedAt + timeoutMs;
|
|
3625
|
+
while (true) {
|
|
3626
|
+
if (options.signal?.aborted)
|
|
3627
|
+
return { kind: "aborted" };
|
|
3628
|
+
const remaining = deadline - now();
|
|
3629
|
+
options.onTick?.(Math.max(0, remaining));
|
|
3630
|
+
const outcome = await checkCircleLogin(deviceCode, { signal: options.signal });
|
|
3631
|
+
if (options.signal?.aborted)
|
|
3632
|
+
return { kind: "aborted" };
|
|
3633
|
+
if (outcome.kind === "verified")
|
|
3634
|
+
return outcome;
|
|
3635
|
+
if (outcome.kind === "expired")
|
|
3636
|
+
return outcome;
|
|
3637
|
+
if (outcome.kind === "denied")
|
|
3638
|
+
return outcome;
|
|
3639
|
+
if (outcome.kind === "error")
|
|
3640
|
+
return outcome;
|
|
3641
|
+
if (outcome.kind === "slow_down")
|
|
3642
|
+
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
3643
|
+
if (now() + intervalMs >= deadline)
|
|
3644
|
+
return { kind: "timeout" };
|
|
3645
|
+
await sleep(intervalMs);
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3544
3648
|
|
|
3545
3649
|
// src/lib/auth-guard.ts
|
|
3546
3650
|
var import_proper_lockfile = __toESM(require_proper_lockfile(), 1);
|
|
@@ -3645,7 +3749,8 @@ async function requireAuth(ctx, options = {}) {
|
|
|
3645
3749
|
outputError(ctx, "auth_required", "Not authenticated.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
|
|
3646
3750
|
}
|
|
3647
3751
|
const initialNow = now();
|
|
3648
|
-
|
|
3752
|
+
const forced = options.forceRefreshForToken !== undefined;
|
|
3753
|
+
if (!forced && !isExpired(auth, initialNow) && !isNearExpiry(auth, windowMs, initialNow)) {
|
|
3649
3754
|
return auth;
|
|
3650
3755
|
}
|
|
3651
3756
|
const release = await acquireRefreshLock(ctx, lockTarget);
|
|
@@ -3656,7 +3761,7 @@ async function requireAuth(ctx, options = {}) {
|
|
|
3656
3761
|
}
|
|
3657
3762
|
const freshNow = now();
|
|
3658
3763
|
const freshExpired = isExpired(fresh, freshNow);
|
|
3659
|
-
if (!freshExpired && !isNearExpiry(fresh, windowMs, freshNow)) {
|
|
3764
|
+
if (!freshExpired && !isNearExpiry(fresh, windowMs, freshNow) && (!forced || fresh.access_token !== options.forceRefreshForToken)) {
|
|
3660
3765
|
verbose(ctx, "another process already refreshed — using rotated token");
|
|
3661
3766
|
return fresh;
|
|
3662
3767
|
}
|
|
@@ -3669,11 +3774,15 @@ async function requireAuth(ctx, options = {}) {
|
|
|
3669
3774
|
access_token: refreshed.data.token,
|
|
3670
3775
|
refresh_token: refreshed.data.refresh_token,
|
|
3671
3776
|
expires_at: refreshed.data.expires_at,
|
|
3672
|
-
created_at: fresh.created_at
|
|
3777
|
+
created_at: fresh.created_at,
|
|
3778
|
+
...fresh.method !== undefined ? { method: fresh.method } : {}
|
|
3673
3779
|
};
|
|
3674
3780
|
persist(next);
|
|
3675
3781
|
return next;
|
|
3676
3782
|
}
|
|
3783
|
+
if (forced) {
|
|
3784
|
+
outputError(ctx, refreshed.code, refreshed.error, refreshed.status === 403 ? ExitCodes.FORBIDDEN : refreshed.status === 401 ? ExitCodes.AUTH_REQUIRED : ExitCodes.ERROR);
|
|
3785
|
+
}
|
|
3677
3786
|
if (!freshExpired) {
|
|
3678
3787
|
verbose(ctx, `refresh failed (${refreshed.code}) — using existing token`);
|
|
3679
3788
|
return fresh;
|
|
@@ -3690,12 +3799,302 @@ async function requireAuth(ctx, options = {}) {
|
|
|
3690
3799
|
throw new Error("requireAuth: unreachable");
|
|
3691
3800
|
}
|
|
3692
3801
|
|
|
3802
|
+
// src/lib/signing.ts
|
|
3803
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
3804
|
+
|
|
3805
|
+
class SignatureError extends Error {
|
|
3806
|
+
constructor(message) {
|
|
3807
|
+
super(message);
|
|
3808
|
+
this.name = "SignatureError";
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
var KEYSET = [
|
|
3812
|
+
{
|
|
3813
|
+
keyId: 1,
|
|
3814
|
+
publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
|
|
3815
|
+
}
|
|
3816
|
+
];
|
|
3817
|
+
var REQUIRE_SIGNATURES = true;
|
|
3818
|
+
function loadEffectiveKeyset() {
|
|
3819
|
+
const override = process.env["BUNDLE_PUBLIC_KEYSET"];
|
|
3820
|
+
if (!override)
|
|
3821
|
+
return KEYSET;
|
|
3822
|
+
const apiBase = process.env["API_BASE_URL"];
|
|
3823
|
+
if (!apiBase)
|
|
3824
|
+
return KEYSET;
|
|
3825
|
+
let hostname;
|
|
3826
|
+
try {
|
|
3827
|
+
hostname = new URL(apiBase).hostname;
|
|
3828
|
+
} catch {
|
|
3829
|
+
return KEYSET;
|
|
3830
|
+
}
|
|
3831
|
+
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
3832
|
+
return KEYSET;
|
|
3833
|
+
}
|
|
3834
|
+
try {
|
|
3835
|
+
const parsed = JSON.parse(override);
|
|
3836
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
3837
|
+
return KEYSET;
|
|
3838
|
+
for (const k3 of parsed) {
|
|
3839
|
+
if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
|
|
3840
|
+
return KEYSET;
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
return parsed;
|
|
3844
|
+
} catch {
|
|
3845
|
+
return KEYSET;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
function sha256Hex(body) {
|
|
3849
|
+
return createHash("sha256").update(body).digest("hex");
|
|
3850
|
+
}
|
|
3851
|
+
function canonicalSigningString(keyId, hashHex) {
|
|
3852
|
+
return `v1:${keyId}:${hashHex}`;
|
|
3853
|
+
}
|
|
3854
|
+
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
3855
|
+
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
3856
|
+
}
|
|
3857
|
+
function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
|
|
3858
|
+
const key = keyset.find((k3) => k3.keyId === keyId);
|
|
3859
|
+
if (!key) {
|
|
3860
|
+
throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
|
|
3861
|
+
}
|
|
3862
|
+
const computedHash = sha256Hex(rawBody);
|
|
3863
|
+
if (computedHash !== headerHash) {
|
|
3864
|
+
throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
|
|
3865
|
+
}
|
|
3866
|
+
let pubKey;
|
|
3867
|
+
try {
|
|
3868
|
+
pubKey = createPublicKey({
|
|
3869
|
+
key: Buffer.from(key.publicKey, "base64"),
|
|
3870
|
+
format: "der",
|
|
3871
|
+
type: "spki"
|
|
3872
|
+
});
|
|
3873
|
+
} catch {
|
|
3874
|
+
throw new SignatureError("Failed to load the signing public key. Your CLI binary may be corrupted — reinstall it.");
|
|
3875
|
+
}
|
|
3876
|
+
const canonical = canonicalSigningString(keyId, computedHash);
|
|
3877
|
+
const sig = Buffer.from(signature, "base64");
|
|
3878
|
+
const valid = verify(null, Buffer.from(canonical), pubKey, sig);
|
|
3879
|
+
if (!valid) {
|
|
3880
|
+
throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
// src/lib/api-content.ts
|
|
3885
|
+
function validateCourseDiscovery(value) {
|
|
3886
|
+
if (!value || typeof value !== "object")
|
|
3887
|
+
return false;
|
|
3888
|
+
const data = value;
|
|
3889
|
+
if (!Array.isArray(data.courses) || data.defaultCourse !== null && typeof data.defaultCourse !== "string")
|
|
3890
|
+
return false;
|
|
3891
|
+
const identifiers = new Set;
|
|
3892
|
+
for (const course of data.courses) {
|
|
3893
|
+
if (!course || typeof course.id !== "string" || !course.id || typeof course.slug !== "string" || !course.slug || typeof course.title !== "string" || !Number.isInteger(course.edition) || course.edition < 1 || typeof course.available !== "boolean")
|
|
3894
|
+
return false;
|
|
3895
|
+
for (const identifier of new Set([course.id, course.slug])) {
|
|
3896
|
+
if (identifiers.has(identifier))
|
|
3897
|
+
return false;
|
|
3898
|
+
identifiers.add(identifier);
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
const available = data.courses.filter((course) => course.available);
|
|
3902
|
+
if (data.defaultCourse === null)
|
|
3903
|
+
return available.length === 0;
|
|
3904
|
+
const selected = available.find((course) => course.id === data.defaultCourse || course.slug === data.defaultCourse);
|
|
3905
|
+
return !!selected && available.every((course) => course.edition <= selected.edition);
|
|
3906
|
+
}
|
|
3907
|
+
async function fetchCourses(token) {
|
|
3908
|
+
const result = await apiGet("/api/me/courses", { token });
|
|
3909
|
+
if (!result.ok) {
|
|
3910
|
+
if (result.status === 404)
|
|
3911
|
+
return { ...result, code: "discovery_unsupported", error: "The backend does not support course discovery. Update the backend before using this CLI." };
|
|
3912
|
+
return result;
|
|
3913
|
+
}
|
|
3914
|
+
if (!validateCourseDiscovery(result.data))
|
|
3915
|
+
return { ok: false, status: 0, code: "discovery_invalid", error: "Invalid course discovery response; no course was selected." };
|
|
3916
|
+
return result;
|
|
3917
|
+
}
|
|
3918
|
+
function catalogRelease(catalog) {
|
|
3919
|
+
if (catalog.course !== "10xdevs4")
|
|
3920
|
+
return;
|
|
3921
|
+
if (!/^r-[a-f0-9]{64}$/.test(catalog.releaseId ?? "") || !/^[a-f0-9]{64}$/.test(catalog.releaseManifestHash ?? ""))
|
|
3922
|
+
throw new Error("Invalid or missing v4 catalog release identity");
|
|
3923
|
+
return { course: catalog.course, releaseId: catalog.releaseId, releaseManifestHash: catalog.releaseManifestHash };
|
|
3924
|
+
}
|
|
3925
|
+
function releaseMismatch(course, data, selected) {
|
|
3926
|
+
if (course !== "10xdevs4" && course !== "10xdevs-4")
|
|
3927
|
+
return null;
|
|
3928
|
+
if (!selected || selected.course !== "10xdevs4" || !data || typeof data !== "object")
|
|
3929
|
+
return "V4 content requires the operation's selected catalog release.";
|
|
3930
|
+
const value = data;
|
|
3931
|
+
return value["course"] === selected.course && value["releaseId"] === selected.releaseId && value["releaseManifestHash"] === selected.releaseManifestHash ? null : "Signed content differs from the selected course/release/manifest.";
|
|
3932
|
+
}
|
|
3933
|
+
async function fetchCatalog(course, token, options = {}) {
|
|
3934
|
+
const result = await apiGet(`/api/catalog/${encodeURIComponent(course)}${options.release ? `?release=${encodeURIComponent(options.release.releaseId)}` : ""}`, { token, signal: options.signal });
|
|
3935
|
+
if (result.ok && (course === "10xdevs4" || course === "10xdevs-4")) {
|
|
3936
|
+
try {
|
|
3937
|
+
if (result.data.course !== "10xdevs4")
|
|
3938
|
+
throw new Error("Catalog course mismatch");
|
|
3939
|
+
catalogRelease(result.data);
|
|
3940
|
+
if (options.release) {
|
|
3941
|
+
const mismatch = releaseMismatch(course, result.data, options.release);
|
|
3942
|
+
if (mismatch)
|
|
3943
|
+
throw new Error(mismatch);
|
|
3944
|
+
}
|
|
3945
|
+
} catch (error) {
|
|
3946
|
+
return { ok: false, status: 0, code: "release_mismatch", error: error instanceof Error ? error.message : String(error) };
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
return result;
|
|
3950
|
+
}
|
|
3951
|
+
async function fetchModuleDetail(course, module, token, options = {}) {
|
|
3952
|
+
const result = await apiGet(`/api/modules/${encodeURIComponent(course)}/${module}${options.release ? `?release=${encodeURIComponent(options.release.releaseId)}` : ""}`, { token, signal: options.signal });
|
|
3953
|
+
if (result.ok) {
|
|
3954
|
+
const mismatch = releaseMismatch(course, result.data, options.release);
|
|
3955
|
+
if (mismatch)
|
|
3956
|
+
return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
|
|
3957
|
+
}
|
|
3958
|
+
return result;
|
|
3959
|
+
}
|
|
3960
|
+
async function fetchLesson(course, lessonId, token, options = {}) {
|
|
3961
|
+
const params = new URLSearchParams;
|
|
3962
|
+
if (options.lang)
|
|
3963
|
+
params.set("lang", options.lang);
|
|
3964
|
+
if (options.release)
|
|
3965
|
+
params.set("release", options.release.releaseId);
|
|
3966
|
+
if (options.tool)
|
|
3967
|
+
params.set("tool", options.tool);
|
|
3968
|
+
const qs = params.toString();
|
|
3969
|
+
const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
|
|
3970
|
+
const result = await apiGet(path, { token, signal: options.signal });
|
|
3971
|
+
if (!result.ok)
|
|
3972
|
+
return result;
|
|
3973
|
+
const mismatch = releaseMismatch(course, result.data, options.release);
|
|
3974
|
+
if (mismatch)
|
|
3975
|
+
return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
|
|
3976
|
+
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
3977
|
+
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
3978
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
3979
|
+
if (signature && keyIdRaw && headerHash) {
|
|
3980
|
+
const keyId = Number(keyIdRaw);
|
|
3981
|
+
try {
|
|
3982
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
3983
|
+
} catch (err) {
|
|
3984
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3985
|
+
return {
|
|
3986
|
+
ok: false,
|
|
3987
|
+
status: 0,
|
|
3988
|
+
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
3989
|
+
error: message
|
|
3990
|
+
};
|
|
3991
|
+
}
|
|
3992
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
3993
|
+
return {
|
|
3994
|
+
ok: false,
|
|
3995
|
+
status: 0,
|
|
3996
|
+
code: "signature_error",
|
|
3997
|
+
error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
3998
|
+
};
|
|
3999
|
+
} else if (REQUIRE_SIGNATURES) {
|
|
4000
|
+
return {
|
|
4001
|
+
ok: false,
|
|
4002
|
+
status: 0,
|
|
4003
|
+
code: "signature_missing",
|
|
4004
|
+
error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
4005
|
+
};
|
|
4006
|
+
} else {
|
|
4007
|
+
process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
|
|
4008
|
+
`);
|
|
4009
|
+
}
|
|
4010
|
+
return result;
|
|
4011
|
+
}
|
|
4012
|
+
async function fetchArtifact(course, lessonId, type, name, tool, token, options = {}) {
|
|
4013
|
+
const params = new URLSearchParams({ tool });
|
|
4014
|
+
if (options.lang)
|
|
4015
|
+
params.set("lang", options.lang);
|
|
4016
|
+
if (options.release)
|
|
4017
|
+
params.set("release", options.release.releaseId);
|
|
4018
|
+
const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
|
|
4019
|
+
const result = await apiGet(path, { token, signal: options.signal });
|
|
4020
|
+
if (!result.ok)
|
|
4021
|
+
return result;
|
|
4022
|
+
const mismatch = releaseMismatch(course, result.data, options.release);
|
|
4023
|
+
if (mismatch)
|
|
4024
|
+
return { ok: false, status: 0, code: "release_mismatch", error: mismatch };
|
|
4025
|
+
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
4026
|
+
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
4027
|
+
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
4028
|
+
if (signature && keyIdRaw && headerHash) {
|
|
4029
|
+
const keyId = Number(keyIdRaw);
|
|
4030
|
+
try {
|
|
4031
|
+
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
4032
|
+
} catch (err) {
|
|
4033
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4034
|
+
return {
|
|
4035
|
+
ok: false,
|
|
4036
|
+
status: 0,
|
|
4037
|
+
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
4038
|
+
error: message
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
} else if (signature || keyIdRaw || headerHash) {
|
|
4042
|
+
return {
|
|
4043
|
+
ok: false,
|
|
4044
|
+
status: 0,
|
|
4045
|
+
code: "signature_error",
|
|
4046
|
+
error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
4047
|
+
};
|
|
4048
|
+
} else if (REQUIRE_SIGNATURES) {
|
|
4049
|
+
return {
|
|
4050
|
+
ok: false,
|
|
4051
|
+
status: 0,
|
|
4052
|
+
code: "signature_missing",
|
|
4053
|
+
error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
4054
|
+
};
|
|
4055
|
+
} else {
|
|
4056
|
+
process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
|
|
4057
|
+
`);
|
|
4058
|
+
}
|
|
4059
|
+
return result;
|
|
4060
|
+
}
|
|
4061
|
+
async function fetchHealth(options = {}) {
|
|
4062
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
4063
|
+
const controller = new AbortController;
|
|
4064
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
4065
|
+
const started = Date.now();
|
|
4066
|
+
try {
|
|
4067
|
+
const result = await apiGet("/health", { signal: controller.signal });
|
|
4068
|
+
const latencyMs = Date.now() - started;
|
|
4069
|
+
return { ...result, latencyMs };
|
|
4070
|
+
} catch (err) {
|
|
4071
|
+
const latencyMs = Date.now() - started;
|
|
4072
|
+
const aborted = controller.signal.aborted;
|
|
4073
|
+
return {
|
|
4074
|
+
ok: false,
|
|
4075
|
+
status: 0,
|
|
4076
|
+
code: aborted ? "timeout" : "network_error",
|
|
4077
|
+
error: err instanceof Error ? err.message : String(err),
|
|
4078
|
+
latencyMs
|
|
4079
|
+
};
|
|
4080
|
+
} finally {
|
|
4081
|
+
clearTimeout(timer);
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
function apiBaseUrl() {
|
|
4085
|
+
return resolveApiBase();
|
|
4086
|
+
}
|
|
4087
|
+
|
|
3693
4088
|
// src/commands/auth.ts
|
|
4089
|
+
var AUTH_METHODS = ["email", "circle"];
|
|
4090
|
+
function isAuthMethod(value) {
|
|
4091
|
+
return AUTH_METHODS.includes(value);
|
|
4092
|
+
}
|
|
3694
4093
|
function registerAuthCommand(cli) {
|
|
3695
|
-
cli.command("auth", "Authenticate with 10xDevs via magic link").option("--email <email>", "Email address (skips interactive prompt)").option("--status", "Show current authentication state").option("--logout", "Delete locally stored credentials").action(async (options) => {
|
|
4094
|
+
cli.command("auth", "Authenticate with 10xDevs via magic link or Circle message").option("--email <email>", "Email address (skips interactive prompt)").option("--method <method>", "Login method: 'email' (magic link, default) or 'circle' (approval link sent as a Circle message)").option("--status", "Show current authentication state").option("--logout", "Delete locally stored credentials").action(async (options) => {
|
|
3696
4095
|
const ctx = resolveContext(options);
|
|
3697
4096
|
if (options.status) {
|
|
3698
|
-
runStatus(ctx);
|
|
4097
|
+
await runStatus(ctx);
|
|
3699
4098
|
return;
|
|
3700
4099
|
}
|
|
3701
4100
|
if (options.logout) {
|
|
@@ -3705,7 +4104,7 @@ function registerAuthCommand(cli) {
|
|
|
3705
4104
|
await runLogin(ctx, options);
|
|
3706
4105
|
});
|
|
3707
4106
|
}
|
|
3708
|
-
function runStatus(ctx) {
|
|
4107
|
+
async function runStatus(ctx) {
|
|
3709
4108
|
const auth = readAuth();
|
|
3710
4109
|
if (!auth) {
|
|
3711
4110
|
outputError(ctx, "auth_required", "You're not signed in.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
|
|
@@ -3715,12 +4114,21 @@ function runStatus(ctx) {
|
|
|
3715
4114
|
if (expired) {
|
|
3716
4115
|
outputError(ctx, "auth_expired", `Your session for ${auth.email} has expired.`, ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
3717
4116
|
}
|
|
3718
|
-
|
|
4117
|
+
const access = await fetchCourses(auth.access_token);
|
|
4118
|
+
const accessMessage = access.ok ? `Available courses: ${access.data.courses.filter((course) => course.available).map((course) => course.slug).join(", ") || "none"}.` : `Course access could not be checked (${access.code}).`;
|
|
4119
|
+
const methodMessage = auth.method ? ` Signed in via ${describeMethod(auth.method)}.` : "";
|
|
4120
|
+
output(ctx, `Signed in as ${auth.email} — session expires ${formatExpiry(expiresAt)}. ${accessMessage}${methodMessage}`, {
|
|
3719
4121
|
email: auth.email,
|
|
3720
4122
|
expires_at: auth.expires_at,
|
|
3721
|
-
is_valid: true
|
|
4123
|
+
is_valid: true,
|
|
4124
|
+
...auth.method ? { method: auth.method } : {},
|
|
4125
|
+
access_checked: access.ok,
|
|
4126
|
+
...access.ok ? { courses: access.data.courses, defaultCourse: access.data.defaultCourse } : { access_error: { code: access.code, message: access.error } }
|
|
3722
4127
|
});
|
|
3723
4128
|
}
|
|
4129
|
+
function describeMethod(method) {
|
|
4130
|
+
return method === "circle" ? "Circle message" : "email magic link";
|
|
4131
|
+
}
|
|
3724
4132
|
function formatExpiry(expiresAt) {
|
|
3725
4133
|
if (!Number.isFinite(expiresAt.getTime()))
|
|
3726
4134
|
return "at an unknown time";
|
|
@@ -3745,7 +4153,12 @@ function runLogout(ctx) {
|
|
|
3745
4153
|
});
|
|
3746
4154
|
}
|
|
3747
4155
|
async function runLogin(ctx, options) {
|
|
4156
|
+
const method = await collectMethod(ctx, options);
|
|
3748
4157
|
const email = await collectEmail(ctx, options);
|
|
4158
|
+
if (method === "circle") {
|
|
4159
|
+
await runCircleLogin(ctx, email);
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
3749
4162
|
if (!ctx.json)
|
|
3750
4163
|
we("10x auth");
|
|
3751
4164
|
const sp = ctx.json ? null : L2();
|
|
@@ -3774,7 +4187,8 @@ async function runLogin(ctx, options) {
|
|
|
3774
4187
|
access_token: result.tokens.token,
|
|
3775
4188
|
refresh_token: result.tokens.refresh_token,
|
|
3776
4189
|
expires_at: result.tokens.expires_at,
|
|
3777
|
-
created_at: new Date().toISOString()
|
|
4190
|
+
created_at: new Date().toISOString(),
|
|
4191
|
+
method: "email"
|
|
3778
4192
|
};
|
|
3779
4193
|
saveAuth(auth);
|
|
3780
4194
|
if (!ctx.json)
|
|
@@ -3782,7 +4196,8 @@ async function runLogin(ctx, options) {
|
|
|
3782
4196
|
output(ctx, "", {
|
|
3783
4197
|
authenticated: true,
|
|
3784
4198
|
email,
|
|
3785
|
-
expires_at: auth.expires_at
|
|
4199
|
+
expires_at: auth.expires_at,
|
|
4200
|
+
method: "email"
|
|
3786
4201
|
});
|
|
3787
4202
|
return;
|
|
3788
4203
|
}
|
|
@@ -3798,8 +4213,130 @@ async function runLogin(ctx, options) {
|
|
|
3798
4213
|
pollSpinner?.stop("Cancelled.", 1);
|
|
3799
4214
|
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
3800
4215
|
}
|
|
3801
|
-
pollSpinner?.stop("Authentication failed.", 1);
|
|
3802
|
-
outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
|
|
4216
|
+
pollSpinner?.stop("Authentication failed.", 1);
|
|
4217
|
+
outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
|
|
4218
|
+
}
|
|
4219
|
+
var CIRCLE_SENT_MESSAGE = "A Circle message with an approval link is on its way; open it in Circle on any device.";
|
|
4220
|
+
var CIRCLE_UNKNOWN_MESSAGE = "Circle did not confirm delivery; the message may still arrive. Waiting for approval.";
|
|
4221
|
+
var CIRCLE_EXPIRY_HINT = "Run '10x auth --method email' to sign in with a magic link, or run '10x auth --method circle' to request a fresh Circle message.";
|
|
4222
|
+
async function runCircleLogin(ctx, email) {
|
|
4223
|
+
if (!ctx.json)
|
|
4224
|
+
we("10x auth");
|
|
4225
|
+
const sp = ctx.json ? null : L2();
|
|
4226
|
+
sp?.start("Requesting a Circle message…");
|
|
4227
|
+
const start = await circleStartRequest(email, describeCircleClient());
|
|
4228
|
+
if (!start.ok) {
|
|
4229
|
+
sp?.stop("Circle message request failed.", 1);
|
|
4230
|
+
handleCircleStartError(ctx, start.status, start.code, start.error, start.payload);
|
|
4231
|
+
}
|
|
4232
|
+
const { device_code, expires_in, interval, delivery } = start.data;
|
|
4233
|
+
sp?.stop(delivery === "unknown" ? CIRCLE_UNKNOWN_MESSAGE : CIRCLE_SENT_MESSAGE);
|
|
4234
|
+
verbose(ctx, `circle login started: delivery=${delivery} interval=${interval}s expires_in=${expires_in}s`);
|
|
4235
|
+
const pollSpinner = ctx.json ? null : L2();
|
|
4236
|
+
pollSpinner?.start("Waiting for approval in Circle…");
|
|
4237
|
+
const controller = new AbortController;
|
|
4238
|
+
const onSigint = () => controller.abort();
|
|
4239
|
+
process.on("SIGINT", onSigint);
|
|
4240
|
+
let result;
|
|
4241
|
+
try {
|
|
4242
|
+
result = await pollCircleLogin(device_code, {
|
|
4243
|
+
intervalMs: interval * 1000,
|
|
4244
|
+
timeoutMs: expires_in * 1000,
|
|
4245
|
+
signal: controller.signal,
|
|
4246
|
+
onTick: (remainingMs) => {
|
|
4247
|
+
if (pollSpinner) {
|
|
4248
|
+
pollSpinner.message(`Waiting for approval in Circle (${formatRemaining(remainingMs)} remaining)`);
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
});
|
|
4252
|
+
} finally {
|
|
4253
|
+
process.removeListener("SIGINT", onSigint);
|
|
4254
|
+
}
|
|
4255
|
+
if (result.kind === "verified") {
|
|
4256
|
+
pollSpinner?.stop("Authenticated.");
|
|
4257
|
+
const auth = {
|
|
4258
|
+
version: AUTH_FILE_VERSION,
|
|
4259
|
+
email,
|
|
4260
|
+
access_token: result.tokens.token,
|
|
4261
|
+
refresh_token: result.tokens.refresh_token,
|
|
4262
|
+
expires_at: result.tokens.expires_at,
|
|
4263
|
+
created_at: new Date().toISOString(),
|
|
4264
|
+
method: "circle"
|
|
4265
|
+
};
|
|
4266
|
+
saveAuth(auth);
|
|
4267
|
+
if (!ctx.json)
|
|
4268
|
+
fe(`Signed in as ${email}.`);
|
|
4269
|
+
output(ctx, "", {
|
|
4270
|
+
authenticated: true,
|
|
4271
|
+
email,
|
|
4272
|
+
expires_at: auth.expires_at,
|
|
4273
|
+
method: "circle"
|
|
4274
|
+
});
|
|
4275
|
+
return;
|
|
4276
|
+
}
|
|
4277
|
+
if (result.kind === "expired") {
|
|
4278
|
+
pollSpinner?.stop("Circle login expired.", 1);
|
|
4279
|
+
outputError(ctx, "circle_login_expired", "The Circle login expired before the approval link was opened.", ExitCodes.ERROR, CIRCLE_EXPIRY_HINT);
|
|
4280
|
+
}
|
|
4281
|
+
if (result.kind === "denied") {
|
|
4282
|
+
pollSpinner?.stop("Access denied.", 1);
|
|
4283
|
+
outputError(ctx, "access_denied", "This email has no active 10xDevs course membership, so the login was denied.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth --method circle' again.");
|
|
4284
|
+
}
|
|
4285
|
+
if (result.kind === "timeout") {
|
|
4286
|
+
pollSpinner?.stop("Timed out waiting for approval.", 1);
|
|
4287
|
+
outputError(ctx, "auth_timeout", "Timed out waiting for the Circle approval link to be opened.", ExitCodes.ERROR, CIRCLE_EXPIRY_HINT);
|
|
4288
|
+
}
|
|
4289
|
+
if (result.kind === "aborted") {
|
|
4290
|
+
pollSpinner?.stop("Cancelled.", 1);
|
|
4291
|
+
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
4292
|
+
}
|
|
4293
|
+
pollSpinner?.stop("Authentication failed.", 1);
|
|
4294
|
+
outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
|
|
4295
|
+
}
|
|
4296
|
+
function handleCircleStartError(ctx, status, code, error, payload) {
|
|
4297
|
+
verbose(ctx, `circle start failed: status=${status} code=${code}`);
|
|
4298
|
+
if (status === 403) {
|
|
4299
|
+
outputError(ctx, "no_access", "This email has no active 10xDevs course membership.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth --method circle' again.");
|
|
4300
|
+
}
|
|
4301
|
+
if (status === 429) {
|
|
4302
|
+
const retryAfter = payload?.retry_after_s;
|
|
4303
|
+
const wait = typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? `Wait about ${Math.ceil(retryAfter)} seconds` : "Wait a few minutes";
|
|
4304
|
+
outputError(ctx, "rate_limited", "Too many Circle login requests right now.", ExitCodes.ERROR, `${wait}, then run '10x auth --method circle' again.`);
|
|
4305
|
+
}
|
|
4306
|
+
if (status === 502) {
|
|
4307
|
+
outputError(ctx, "dm_rejected", "Circle refused to deliver the login message.", ExitCodes.ERROR, "Check that direct messages are enabled in your Circle settings, or run '10x auth --method email'.");
|
|
4308
|
+
}
|
|
4309
|
+
if (status === 503) {
|
|
4310
|
+
outputError(ctx, "circle_login_disabled", "Circle login is currently unavailable.", ExitCodes.ERROR, "Run '10x auth --method email' to sign in with a magic link.");
|
|
4311
|
+
}
|
|
4312
|
+
if (status === 0) {
|
|
4313
|
+
outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run '10x auth --method circle' again.");
|
|
4314
|
+
}
|
|
4315
|
+
outputError(ctx, code || "auth_error", "Authentication failed.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
4316
|
+
}
|
|
4317
|
+
async function collectMethod(ctx, options) {
|
|
4318
|
+
if (options.method !== undefined) {
|
|
4319
|
+
const value = String(options.method).trim().toLowerCase();
|
|
4320
|
+
if (!isAuthMethod(value)) {
|
|
4321
|
+
outputError(ctx, "invalid_method", `'${options.method}' is not a login method.`, ExitCodes.USAGE, "Pass '--method email' for a magic link or '--method circle' for a Circle message.");
|
|
4322
|
+
}
|
|
4323
|
+
return value;
|
|
4324
|
+
}
|
|
4325
|
+
if (ctx.json)
|
|
4326
|
+
return "email";
|
|
4327
|
+
const answer = await de({
|
|
4328
|
+
message: "How do you want to sign in?",
|
|
4329
|
+
options: [
|
|
4330
|
+
{ value: "email", label: "Email magic link", hint: "a link sent to your inbox" },
|
|
4331
|
+
{ value: "circle", label: "Circle message", hint: "an approval link sent to you in Circle" }
|
|
4332
|
+
],
|
|
4333
|
+
initialValue: "email"
|
|
4334
|
+
});
|
|
4335
|
+
if (BD(answer)) {
|
|
4336
|
+
ve("Authentication cancelled.");
|
|
4337
|
+
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
4338
|
+
}
|
|
4339
|
+
return answer;
|
|
3803
4340
|
}
|
|
3804
4341
|
async function collectEmail(ctx, options) {
|
|
3805
4342
|
if (options.email) {
|
|
@@ -4099,31 +4636,279 @@ function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth)
|
|
|
4099
4636
|
|
|
4100
4637
|
// src/commands/bench-kit.ts
|
|
4101
4638
|
import { spawn } from "node:child_process";
|
|
4102
|
-
import { existsSync as existsSync4, mkdtempSync, readFileSync as
|
|
4639
|
+
import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4 } from "node:fs";
|
|
4103
4640
|
import { tmpdir } from "node:os";
|
|
4104
|
-
import { basename, join as
|
|
4641
|
+
import { basename, join as join5, resolve as resolve2 } from "node:path";
|
|
4105
4642
|
|
|
4106
4643
|
// src/lib/tool-detect.ts
|
|
4107
4644
|
import { existsSync as existsSync3 } from "node:fs";
|
|
4108
|
-
import { join as
|
|
4645
|
+
import { join as join4 } from "node:path";
|
|
4646
|
+
|
|
4647
|
+
// src/lib/manifest.ts
|
|
4648
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
4649
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4650
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
4651
|
+
|
|
4652
|
+
// src/lib/project-course.ts
|
|
4653
|
+
import { randomUUID } from "node:crypto";
|
|
4654
|
+
import { linkSync, lstatSync, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
4655
|
+
import { isAbsolute, join as join2, relative, resolve, sep } from "node:path";
|
|
4656
|
+
|
|
4657
|
+
// src/lib/tool-profile.ts
|
|
4658
|
+
var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
|
|
4659
|
+
var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
|
|
4660
|
+
var PROFILES = {
|
|
4661
|
+
"claude-code": {
|
|
4662
|
+
toolId: "claude-code",
|
|
4663
|
+
displayName: "Claude Code",
|
|
4664
|
+
skillPath: (n) => `.claude/skills/${n}/SKILL.md`,
|
|
4665
|
+
skillDir: (n) => `.claude/skills/${n}`,
|
|
4666
|
+
promptPath: (n) => `.claude/prompts/${n}.md`,
|
|
4667
|
+
configPath: (n) => `.claude/config-templates/${n}`,
|
|
4668
|
+
rulesFile: "CLAUDE.md",
|
|
4669
|
+
manifestDir: ".claude",
|
|
4670
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4671
|
+
sentinelEnd: SENTINEL_END
|
|
4672
|
+
},
|
|
4673
|
+
cursor: {
|
|
4674
|
+
toolId: "cursor",
|
|
4675
|
+
displayName: "Cursor",
|
|
4676
|
+
skillPath: (n) => `.cursor/skills/${n}/SKILL.md`,
|
|
4677
|
+
skillDir: (n) => `.cursor/skills/${n}`,
|
|
4678
|
+
promptPath: (n) => `.cursor/prompts/${n}.md`,
|
|
4679
|
+
configPath: (n) => `.cursor/config-templates/${n}`,
|
|
4680
|
+
rulesFile: ".cursor/rules/10x-course.mdc",
|
|
4681
|
+
manifestDir: ".cursor",
|
|
4682
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4683
|
+
sentinelEnd: SENTINEL_END
|
|
4684
|
+
},
|
|
4685
|
+
copilot: {
|
|
4686
|
+
toolId: "copilot",
|
|
4687
|
+
displayName: "GitHub Copilot",
|
|
4688
|
+
skillPath: (n) => `.github/skills/${n}/SKILL.md`,
|
|
4689
|
+
skillDir: (n) => `.github/skills/${n}`,
|
|
4690
|
+
promptPath: (n) => `.github/prompts/${n}.md`,
|
|
4691
|
+
configPath: (n) => `.github/config-templates/${n}`,
|
|
4692
|
+
rulesFile: ".github/copilot-instructions.md",
|
|
4693
|
+
manifestDir: ".github",
|
|
4694
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4695
|
+
sentinelEnd: SENTINEL_END
|
|
4696
|
+
},
|
|
4697
|
+
codex: {
|
|
4698
|
+
toolId: "codex",
|
|
4699
|
+
displayName: "Codex CLI",
|
|
4700
|
+
skillPath: (n) => `.agents/skills/${n}/SKILL.md`,
|
|
4701
|
+
skillDir: (n) => `.agents/skills/${n}`,
|
|
4702
|
+
promptPath: (n) => `.agents/prompts/${n}.md`,
|
|
4703
|
+
configPath: (n) => `.agents/config-templates/${n}`,
|
|
4704
|
+
rulesFile: "AGENTS.md",
|
|
4705
|
+
manifestDir: ".agents",
|
|
4706
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4707
|
+
sentinelEnd: SENTINEL_END
|
|
4708
|
+
},
|
|
4709
|
+
"devin-desktop": {
|
|
4710
|
+
toolId: "devin-desktop",
|
|
4711
|
+
contentToolId: "windsurf",
|
|
4712
|
+
displayName: "Devin Desktop",
|
|
4713
|
+
skillPath: (n) => `.devin/skills/${n}/SKILL.md`,
|
|
4714
|
+
skillDir: (n) => `.devin/skills/${n}`,
|
|
4715
|
+
promptPath: (n) => `.devin/prompts/${n}.md`,
|
|
4716
|
+
configPath: (n) => `.devin/config-templates/${n}`,
|
|
4717
|
+
rulesFile: "AGENTS.md",
|
|
4718
|
+
manifestDir: ".devin",
|
|
4719
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4720
|
+
sentinelEnd: SENTINEL_END
|
|
4721
|
+
},
|
|
4722
|
+
gemini: {
|
|
4723
|
+
toolId: "gemini",
|
|
4724
|
+
displayName: "Gemini CLI",
|
|
4725
|
+
skillPath: (n) => `.gemini/skills/${n}/SKILL.md`,
|
|
4726
|
+
skillDir: (n) => `.gemini/skills/${n}`,
|
|
4727
|
+
promptPath: (n) => `.gemini/prompts/${n}.md`,
|
|
4728
|
+
configPath: (n) => `.gemini/config-templates/${n}`,
|
|
4729
|
+
rulesFile: "GEMINI.md",
|
|
4730
|
+
manifestDir: ".gemini",
|
|
4731
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4732
|
+
sentinelEnd: SENTINEL_END
|
|
4733
|
+
},
|
|
4734
|
+
generic: {
|
|
4735
|
+
toolId: "generic",
|
|
4736
|
+
displayName: "Other / Generic",
|
|
4737
|
+
skillPath: (n) => `.ai/skills/${n}/SKILL.md`,
|
|
4738
|
+
skillDir: (n) => `.ai/skills/${n}`,
|
|
4739
|
+
promptPath: (n) => `.ai/prompts/${n}.md`,
|
|
4740
|
+
configPath: (n) => `.ai/config-templates/${n}`,
|
|
4741
|
+
rulesFile: "AGENTS.md",
|
|
4742
|
+
manifestDir: ".ai",
|
|
4743
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4744
|
+
sentinelEnd: SENTINEL_END
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
var TOOL_ALIASES = {
|
|
4748
|
+
windsurf: "devin-desktop"
|
|
4749
|
+
};
|
|
4750
|
+
var LEGACY_PROFILES = {
|
|
4751
|
+
windsurf: {
|
|
4752
|
+
toolId: "windsurf",
|
|
4753
|
+
displayName: "Windsurf",
|
|
4754
|
+
skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
|
|
4755
|
+
skillDir: (n) => `.windsurf/skills/${n}`,
|
|
4756
|
+
promptPath: (n) => `.windsurf/prompts/${n}.md`,
|
|
4757
|
+
configPath: (n) => `.windsurf/config-templates/${n}`,
|
|
4758
|
+
rulesFile: ".windsurfrules",
|
|
4759
|
+
manifestDir: ".windsurf",
|
|
4760
|
+
sentinelBegin: SENTINEL_BEGIN,
|
|
4761
|
+
sentinelEnd: SENTINEL_END
|
|
4762
|
+
}
|
|
4763
|
+
};
|
|
4764
|
+
function canonicalToolId(toolId) {
|
|
4765
|
+
return TOOL_ALIASES[toolId] ?? toolId;
|
|
4766
|
+
}
|
|
4767
|
+
function getToolProfile(toolId) {
|
|
4768
|
+
return PROFILES[canonicalToolId(toolId)];
|
|
4769
|
+
}
|
|
4770
|
+
function contentToolId(profile) {
|
|
4771
|
+
return profile.contentToolId ?? profile.toolId;
|
|
4772
|
+
}
|
|
4773
|
+
var DEFAULT_TOOL = "claude-code";
|
|
4774
|
+
|
|
4775
|
+
// src/lib/project-course.ts
|
|
4776
|
+
var PROJECT_COURSE_FILENAME = ".10x-cli.json";
|
|
4777
|
+
|
|
4778
|
+
class CourseBindingError extends Error {
|
|
4779
|
+
code;
|
|
4780
|
+
constructor(code, message) {
|
|
4781
|
+
super(message);
|
|
4782
|
+
this.code = code;
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
function normalizeProjectCourse(value) {
|
|
4786
|
+
if (value === "10xdevs3" || value === "10xdevs-3")
|
|
4787
|
+
return "10xdevs3";
|
|
4788
|
+
if (value === "10xdevs4" || value === "10xdevs-4")
|
|
4789
|
+
return "10xdevs4";
|
|
4790
|
+
return null;
|
|
4791
|
+
}
|
|
4792
|
+
function assertProjectPath(projectRoot, target) {
|
|
4793
|
+
const root = resolve(projectRoot);
|
|
4794
|
+
const rel = relative(root, resolve(target));
|
|
4795
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`))
|
|
4796
|
+
throw new CourseBindingError("course_binding_invalid", `Path escapes project: ${target}`);
|
|
4797
|
+
let current = root;
|
|
4798
|
+
const parts = rel ? rel.split(sep) : [];
|
|
4799
|
+
for (let i = 0;i <= parts.length; i++) {
|
|
4800
|
+
if (i > 0)
|
|
4801
|
+
current = join2(current, parts[i - 1]);
|
|
4802
|
+
let stat;
|
|
4803
|
+
try {
|
|
4804
|
+
stat = lstatSync(current);
|
|
4805
|
+
} catch (error) {
|
|
4806
|
+
if (error.code === "ENOENT")
|
|
4807
|
+
return;
|
|
4808
|
+
throw error;
|
|
4809
|
+
}
|
|
4810
|
+
if (stat.isSymbolicLink() || i < parts.length && !stat.isDirectory() || i === parts.length && !stat.isFile() && !stat.isDirectory()) {
|
|
4811
|
+
throw new CourseBindingError("course_binding_invalid", `Unsafe project path: ${current}`);
|
|
4812
|
+
}
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
function assertProjectFilePath(projectRoot, target) {
|
|
4816
|
+
assertProjectPath(projectRoot, target);
|
|
4817
|
+
try {
|
|
4818
|
+
if (!lstatSync(target).isFile())
|
|
4819
|
+
throw new CourseBindingError("course_binding_invalid", `Expected a regular file at ${target}.`);
|
|
4820
|
+
} catch (error) {
|
|
4821
|
+
if (error.code !== "ENOENT")
|
|
4822
|
+
throw error;
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
function readJsonIfPresent(projectRoot, target) {
|
|
4826
|
+
assertProjectPath(projectRoot, target);
|
|
4827
|
+
try {
|
|
4828
|
+
if (!lstatSync(target).isFile())
|
|
4829
|
+
throw new Error("Expected a regular file");
|
|
4830
|
+
return JSON.parse(readFileSync2(target, "utf8"));
|
|
4831
|
+
} catch (error) {
|
|
4832
|
+
if (error.code === "ENOENT")
|
|
4833
|
+
return;
|
|
4834
|
+
throw new CourseBindingError("course_binding_invalid", `Cannot read project state ${target}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
function inspectProjectCourse(projectRoot) {
|
|
4838
|
+
const binding = readJsonIfPresent(projectRoot, join2(projectRoot, PROJECT_COURSE_FILENAME));
|
|
4839
|
+
let bound = null;
|
|
4840
|
+
if (binding !== undefined) {
|
|
4841
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding))
|
|
4842
|
+
throw new CourseBindingError("course_binding_invalid", "Invalid .10x-cli.json.");
|
|
4843
|
+
const value = binding;
|
|
4844
|
+
bound = normalizeProjectCourse(value.course);
|
|
4845
|
+
if (value.version !== 1 || !bound || value.course !== bound || Object.keys(value).some((key) => key !== "version" && key !== "course"))
|
|
4846
|
+
throw new CourseBindingError("course_binding_invalid", "Unsupported or corrupt .10x-cli.json; preserve it and repair the project state before writing.");
|
|
4847
|
+
}
|
|
4848
|
+
const courses = new Set;
|
|
4849
|
+
if (bound)
|
|
4850
|
+
courses.add(bound);
|
|
4851
|
+
for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
|
|
4852
|
+
const path = join2(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
|
|
4853
|
+
const value = readJsonIfPresent(projectRoot, path);
|
|
4854
|
+
if (value === undefined)
|
|
4855
|
+
continue;
|
|
4856
|
+
if (!isManifest(value))
|
|
4857
|
+
throw new CourseBindingError("course_binding_invalid", `Unsupported or corrupt manifest: ${path}. Preserve it; this CLI supports manifest versions 2 and 3.`);
|
|
4858
|
+
const course = normalizeProjectCourse(value.course);
|
|
4859
|
+
if (!course)
|
|
4860
|
+
throw new CourseBindingError("course_binding_invalid", `Unsupported course in ${path}.`);
|
|
4861
|
+
courses.add(course);
|
|
4862
|
+
}
|
|
4863
|
+
if (courses.size > 1)
|
|
4864
|
+
throw new CourseBindingError("course_binding_conflict", "Project binding and tool manifests disagree on the course. Resolve the conflict before writing.");
|
|
4865
|
+
return { course: courses.values().next().value ?? null, source: bound ? "binding" : courses.size ? "legacy" : "none" };
|
|
4866
|
+
}
|
|
4867
|
+
function assertProjectCourse(projectRoot, course) {
|
|
4868
|
+
const normalized = normalizeProjectCourse(course);
|
|
4869
|
+
if (!normalized || normalized !== course)
|
|
4870
|
+
throw new CourseBindingError("course_binding_invalid", `Unsupported normalized course: ${course}`);
|
|
4871
|
+
const state = inspectProjectCourse(projectRoot);
|
|
4872
|
+
if (state.course && state.course !== course)
|
|
4873
|
+
throw new CourseBindingError("course_mismatch", `This project uses ${state.course}. Start ${course} in a separate directory; get/sync cannot switch its edition.`);
|
|
4874
|
+
return state;
|
|
4875
|
+
}
|
|
4876
|
+
function establishProjectCourse(projectRoot, course) {
|
|
4877
|
+
const state = assertProjectCourse(projectRoot, course);
|
|
4878
|
+
if (state.source === "binding")
|
|
4879
|
+
return;
|
|
4880
|
+
const destination = join2(projectRoot, PROJECT_COURSE_FILENAME);
|
|
4881
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
4882
|
+
try {
|
|
4883
|
+
writeFileSync2(temporary, `${JSON.stringify({ version: 1, course }, null, 2)}
|
|
4884
|
+
`, { flag: "wx" });
|
|
4885
|
+
try {
|
|
4886
|
+
linkSync(temporary, destination);
|
|
4887
|
+
} catch (error) {
|
|
4888
|
+
if (error.code !== "EEXIST")
|
|
4889
|
+
throw error;
|
|
4890
|
+
assertProjectCourse(projectRoot, course);
|
|
4891
|
+
}
|
|
4892
|
+
} finally {
|
|
4893
|
+
rmSync2(temporary, { force: true });
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4109
4896
|
|
|
4110
4897
|
// src/lib/manifest.ts
|
|
4111
|
-
import { createHash } from "node:crypto";
|
|
4112
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
4113
|
-
import { dirname as dirname2, join as join2 } from "node:path";
|
|
4114
4898
|
var MANIFEST_FILENAME = ".10x-cli-manifest.json";
|
|
4115
4899
|
var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
|
|
4116
4900
|
var MANIFEST_VERSION = 3;
|
|
4117
4901
|
function contentHash(content) {
|
|
4118
|
-
return
|
|
4902
|
+
return createHash2("sha256").update(content).digest("hex");
|
|
4119
4903
|
}
|
|
4120
4904
|
function readManifest(dir) {
|
|
4121
|
-
const manifestPath =
|
|
4905
|
+
const manifestPath = join3(dir, MANIFEST_FILENAME);
|
|
4906
|
+
assertProjectFilePath(dirname2(dir), manifestPath);
|
|
4122
4907
|
if (!existsSync2(manifestPath))
|
|
4123
4908
|
return null;
|
|
4124
4909
|
let raw;
|
|
4125
4910
|
try {
|
|
4126
|
-
raw =
|
|
4911
|
+
raw = readFileSync3(manifestPath, "utf8");
|
|
4127
4912
|
} catch {
|
|
4128
4913
|
return null;
|
|
4129
4914
|
}
|
|
@@ -4138,22 +4923,28 @@ function readManifest(dir) {
|
|
|
4138
4923
|
return parsed;
|
|
4139
4924
|
}
|
|
4140
4925
|
function writeManifest(dir, manifest) {
|
|
4926
|
+
assertProjectFilePath(dirname2(dir), join3(dir, MANIFEST_FILENAME));
|
|
4927
|
+
assertProjectFilePath(dirname2(dir), join3(dir, `${MANIFEST_FILENAME}.tmp`));
|
|
4141
4928
|
if (!existsSync2(dir)) {
|
|
4142
4929
|
mkdirSync3(dir, { recursive: true });
|
|
4143
4930
|
}
|
|
4144
|
-
const manifestPath =
|
|
4931
|
+
const manifestPath = join3(dir, MANIFEST_FILENAME);
|
|
4145
4932
|
mkdirSync3(dirname2(manifestPath), { recursive: true });
|
|
4146
|
-
const tmp = `${manifestPath}.tmp`;
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4933
|
+
const tmp = `${manifestPath}.${randomUUID2()}.tmp`;
|
|
4934
|
+
assertProjectFilePath(dirname2(dir), tmp);
|
|
4935
|
+
try {
|
|
4936
|
+
writeFileSync3(tmp, `${JSON.stringify(manifest, null, 2)}
|
|
4937
|
+
`, { flag: "wx" });
|
|
4938
|
+
renameSync2(tmp, manifestPath);
|
|
4939
|
+
} finally {
|
|
4940
|
+
rmSync3(tmp, { force: true });
|
|
4941
|
+
}
|
|
4151
4942
|
}
|
|
4152
4943
|
function isManifest(value) {
|
|
4153
4944
|
if (typeof value !== "object" || value === null)
|
|
4154
4945
|
return false;
|
|
4155
4946
|
const v3 = value;
|
|
4156
|
-
if (v3["package"] !== CLI_PACKAGE_NAME)
|
|
4947
|
+
if (v3["package"] !== CLI_PACKAGE_NAME || "directSkills" in v3)
|
|
4157
4948
|
return false;
|
|
4158
4949
|
if (typeof v3["version"] !== "string")
|
|
4159
4950
|
return false;
|
|
@@ -4170,8 +4961,20 @@ function isManifest(value) {
|
|
|
4170
4961
|
if (typeof files !== "object" || files === null)
|
|
4171
4962
|
return false;
|
|
4172
4963
|
const f2 = files;
|
|
4964
|
+
if ("directSkills" in f2 || f2["promptHashes"] !== undefined && !isStringRecord(f2["promptHashes"]))
|
|
4965
|
+
return false;
|
|
4173
4966
|
if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
|
|
4174
4967
|
return false;
|
|
4968
|
+
if (f2["configHashes"] !== undefined && !isStringRecord(f2["configHashes"]))
|
|
4969
|
+
return false;
|
|
4970
|
+
const rules = v3["managedRules"];
|
|
4971
|
+
if (rules !== undefined) {
|
|
4972
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules))
|
|
4973
|
+
return false;
|
|
4974
|
+
const r2 = rules;
|
|
4975
|
+
if (!["path", "begin", "end"].every((k3) => typeof r2[k3] === "string") || r2.upstreamHash !== undefined && typeof r2.upstreamHash !== "string")
|
|
4976
|
+
return false;
|
|
4977
|
+
}
|
|
4175
4978
|
const lessons = v3["lessons"];
|
|
4176
4979
|
if (lessons !== undefined && !isLessonsRecord(lessons))
|
|
4177
4980
|
return false;
|
|
@@ -4184,11 +4987,14 @@ function isSkillsRecord(value) {
|
|
|
4184
4987
|
if (typeof entry !== "object" || entry === null)
|
|
4185
4988
|
return false;
|
|
4186
4989
|
const e2 = entry;
|
|
4187
|
-
if (!isStringArray(e2["files"]))
|
|
4990
|
+
if (!isStringArray(e2["files"]) || e2["contentHashes"] !== undefined && !isStringRecord(e2["contentHashes"]))
|
|
4188
4991
|
return false;
|
|
4189
4992
|
}
|
|
4190
4993
|
return true;
|
|
4191
4994
|
}
|
|
4995
|
+
function isStringRecord(value) {
|
|
4996
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
4997
|
+
}
|
|
4192
4998
|
function isStringArray(value) {
|
|
4193
4999
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
4194
5000
|
}
|
|
@@ -4209,6 +5015,14 @@ function isLessonFilesEntry(value) {
|
|
|
4209
5015
|
if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
|
|
4210
5016
|
return false;
|
|
4211
5017
|
}
|
|
5018
|
+
for (const k3 of ["installedReleaseId", "installedManifestHash"])
|
|
5019
|
+
if (e2[k3] !== undefined && typeof e2[k3] !== "string")
|
|
5020
|
+
return false;
|
|
5021
|
+
if (e2.representation !== undefined) {
|
|
5022
|
+
const r2 = e2.representation;
|
|
5023
|
+
if (!r2 || typeof r2 !== "object" || typeof r2.lang !== "string" || typeof r2.tool !== "string" || typeof r2.courseRules !== "boolean" || r2.rules !== undefined && typeof r2.rules !== "boolean")
|
|
5024
|
+
return false;
|
|
5025
|
+
}
|
|
4212
5026
|
return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
|
|
4213
5027
|
}
|
|
4214
5028
|
function isLessonsRecord(value) {
|
|
@@ -4224,120 +5038,51 @@ function buildUnionFiles(lessons) {
|
|
|
4224
5038
|
const skills = {};
|
|
4225
5039
|
const prompts = new Set;
|
|
4226
5040
|
const configs = new Set;
|
|
4227
|
-
for (const entry of Object.values(lessons)) {
|
|
4228
|
-
for (const [name, skill] of Object.entries(entry.skills)) {
|
|
4229
|
-
if (!skills[name]) {
|
|
4230
|
-
skills[name] = { files: [...skill.files] };
|
|
4231
|
-
} else {
|
|
4232
|
-
for (const f2 of skill.files) {
|
|
4233
|
-
if (!skills[name].files.includes(f2)) {
|
|
4234
|
-
skills[name].files.push(f2);
|
|
4235
|
-
}
|
|
4236
|
-
}
|
|
4237
|
-
}
|
|
4238
|
-
}
|
|
4239
|
-
for (const p2 of entry.prompts)
|
|
4240
|
-
prompts.add(p2);
|
|
4241
|
-
for (const c2 of entry.configs)
|
|
4242
|
-
configs.add(c2);
|
|
4243
|
-
}
|
|
4244
|
-
return { skills, prompts: [...prompts], configs: [...configs] };
|
|
4245
|
-
}
|
|
4246
|
-
|
|
4247
|
-
// src/lib/tool-profile.ts
|
|
4248
|
-
var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
|
|
4249
|
-
var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
|
|
4250
|
-
var PROFILES = {
|
|
4251
|
-
"claude-code": {
|
|
4252
|
-
toolId: "claude-code",
|
|
4253
|
-
displayName: "Claude Code",
|
|
4254
|
-
skillPath: (n) => `.claude/skills/${n}/SKILL.md`,
|
|
4255
|
-
skillDir: (n) => `.claude/skills/${n}`,
|
|
4256
|
-
promptPath: (n) => `.claude/prompts/${n}.md`,
|
|
4257
|
-
configPath: (n) => `.claude/config-templates/${n}`,
|
|
4258
|
-
rulesFile: "CLAUDE.md",
|
|
4259
|
-
manifestDir: ".claude",
|
|
4260
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4261
|
-
sentinelEnd: SENTINEL_END
|
|
4262
|
-
},
|
|
4263
|
-
cursor: {
|
|
4264
|
-
toolId: "cursor",
|
|
4265
|
-
displayName: "Cursor",
|
|
4266
|
-
skillPath: (n) => `.cursor/skills/${n}/SKILL.md`,
|
|
4267
|
-
skillDir: (n) => `.cursor/skills/${n}`,
|
|
4268
|
-
promptPath: (n) => `.cursor/prompts/${n}.md`,
|
|
4269
|
-
configPath: (n) => `.cursor/config-templates/${n}`,
|
|
4270
|
-
rulesFile: ".cursor/rules/10x-course.mdc",
|
|
4271
|
-
manifestDir: ".cursor",
|
|
4272
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4273
|
-
sentinelEnd: SENTINEL_END
|
|
4274
|
-
},
|
|
4275
|
-
copilot: {
|
|
4276
|
-
toolId: "copilot",
|
|
4277
|
-
displayName: "GitHub Copilot",
|
|
4278
|
-
skillPath: (n) => `.github/skills/${n}/SKILL.md`,
|
|
4279
|
-
skillDir: (n) => `.github/skills/${n}`,
|
|
4280
|
-
promptPath: (n) => `.github/prompts/${n}.md`,
|
|
4281
|
-
configPath: (n) => `.github/config-templates/${n}`,
|
|
4282
|
-
rulesFile: ".github/copilot-instructions.md",
|
|
4283
|
-
manifestDir: ".github",
|
|
4284
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4285
|
-
sentinelEnd: SENTINEL_END
|
|
4286
|
-
},
|
|
4287
|
-
codex: {
|
|
4288
|
-
toolId: "codex",
|
|
4289
|
-
displayName: "Codex CLI",
|
|
4290
|
-
skillPath: (n) => `.agents/skills/${n}/SKILL.md`,
|
|
4291
|
-
skillDir: (n) => `.agents/skills/${n}`,
|
|
4292
|
-
promptPath: (n) => `.agents/prompts/${n}.md`,
|
|
4293
|
-
configPath: (n) => `.agents/config-templates/${n}`,
|
|
4294
|
-
rulesFile: "AGENTS.md",
|
|
4295
|
-
manifestDir: ".agents",
|
|
4296
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4297
|
-
sentinelEnd: SENTINEL_END
|
|
4298
|
-
},
|
|
4299
|
-
windsurf: {
|
|
4300
|
-
toolId: "windsurf",
|
|
4301
|
-
displayName: "Windsurf",
|
|
4302
|
-
skillPath: (n) => `.windsurf/skills/${n}/SKILL.md`,
|
|
4303
|
-
skillDir: (n) => `.windsurf/skills/${n}`,
|
|
4304
|
-
promptPath: (n) => `.windsurf/prompts/${n}.md`,
|
|
4305
|
-
configPath: (n) => `.windsurf/config-templates/${n}`,
|
|
4306
|
-
rulesFile: ".windsurfrules",
|
|
4307
|
-
manifestDir: ".windsurf",
|
|
4308
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4309
|
-
sentinelEnd: SENTINEL_END
|
|
4310
|
-
},
|
|
4311
|
-
gemini: {
|
|
4312
|
-
toolId: "gemini",
|
|
4313
|
-
displayName: "Gemini CLI",
|
|
4314
|
-
skillPath: (n) => `.gemini/skills/${n}/SKILL.md`,
|
|
4315
|
-
skillDir: (n) => `.gemini/skills/${n}`,
|
|
4316
|
-
promptPath: (n) => `.gemini/prompts/${n}.md`,
|
|
4317
|
-
configPath: (n) => `.gemini/config-templates/${n}`,
|
|
4318
|
-
rulesFile: "GEMINI.md",
|
|
4319
|
-
manifestDir: ".gemini",
|
|
4320
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4321
|
-
sentinelEnd: SENTINEL_END
|
|
4322
|
-
},
|
|
4323
|
-
generic: {
|
|
4324
|
-
toolId: "generic",
|
|
4325
|
-
displayName: "Other / Generic",
|
|
4326
|
-
skillPath: (n) => `.ai/skills/${n}/SKILL.md`,
|
|
4327
|
-
skillDir: (n) => `.ai/skills/${n}`,
|
|
4328
|
-
promptPath: (n) => `.ai/prompts/${n}.md`,
|
|
4329
|
-
configPath: (n) => `.ai/config-templates/${n}`,
|
|
4330
|
-
rulesFile: "AGENTS.md",
|
|
4331
|
-
manifestDir: ".ai",
|
|
4332
|
-
sentinelBegin: SENTINEL_BEGIN,
|
|
4333
|
-
sentinelEnd: SENTINEL_END
|
|
5041
|
+
for (const entry of Object.values(lessons)) {
|
|
5042
|
+
for (const [name, skill] of Object.entries(entry.skills)) {
|
|
5043
|
+
if (!skills[name]) {
|
|
5044
|
+
skills[name] = { files: [...skill.files] };
|
|
5045
|
+
} else {
|
|
5046
|
+
for (const f2 of skill.files) {
|
|
5047
|
+
if (!skills[name].files.includes(f2)) {
|
|
5048
|
+
skills[name].files.push(f2);
|
|
5049
|
+
}
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
for (const p2 of entry.prompts)
|
|
5054
|
+
prompts.add(p2);
|
|
5055
|
+
for (const c2 of entry.configs)
|
|
5056
|
+
configs.add(c2);
|
|
4334
5057
|
}
|
|
4335
|
-
};
|
|
4336
|
-
|
|
5058
|
+
return { skills, prompts: [...prompts], configs: [...configs] };
|
|
5059
|
+
}
|
|
5060
|
+
function seedLessons(manifest) {
|
|
5061
|
+
return structuredClone(manifest.lessons ?? { [manifest.lessonId]: {
|
|
5062
|
+
appliedAt: manifest.lastApplied,
|
|
5063
|
+
skills: Object.fromEntries(Object.entries(manifest.files.skills).map(([name, entry]) => [name, { files: [...entry.files] }])),
|
|
5064
|
+
prompts: [...manifest.files.prompts],
|
|
5065
|
+
configs: [...manifest.files.configs]
|
|
5066
|
+
} });
|
|
5067
|
+
}
|
|
5068
|
+
function rebuildManifestFiles(manifest) {
|
|
5069
|
+
const union = buildUnionFiles(manifest.lessons ?? {});
|
|
5070
|
+
const previous = manifest.files;
|
|
5071
|
+
manifest.files = {
|
|
5072
|
+
skills: Object.fromEntries(Object.entries(union.skills).map(([name, entry]) => [name, {
|
|
5073
|
+
files: entry.files,
|
|
5074
|
+
contentHashes: Object.fromEntries(entry.files.flatMap((file) => previous.skills[name]?.contentHashes?.[file] ? [[file, previous.skills[name].contentHashes[file]]] : []))
|
|
5075
|
+
}])),
|
|
5076
|
+
prompts: union.prompts,
|
|
5077
|
+
configs: union.configs,
|
|
5078
|
+
promptHashes: Object.fromEntries(union.prompts.flatMap((name) => previous.promptHashes?.[name] ? [[name, previous.promptHashes[name]]] : [])),
|
|
5079
|
+
configHashes: Object.fromEntries(union.configs.flatMap((name) => previous.configHashes?.[name] ? [[name, previous.configHashes[name]]] : []))
|
|
5080
|
+
};
|
|
5081
|
+
}
|
|
4337
5082
|
|
|
4338
5083
|
// src/lib/tool-detect.ts
|
|
4339
5084
|
function detectTools(projectRoot) {
|
|
4340
|
-
const hit = (rel) => existsSync3(
|
|
5085
|
+
const hit = (rel) => existsSync3(join4(projectRoot, rel));
|
|
4341
5086
|
const signals = [];
|
|
4342
5087
|
if (hit(".claude/" + MANIFEST_FILENAME)) {
|
|
4343
5088
|
signals.push({
|
|
@@ -4391,16 +5136,34 @@ function detectTools(projectRoot) {
|
|
|
4391
5136
|
} else if (hit("AGENTS.md") && !hit(".ai")) {
|
|
4392
5137
|
signals.push({ profileId: "codex", confidence: "medium", reason: "AGENTS.md" });
|
|
4393
5138
|
}
|
|
4394
|
-
if (hit(".
|
|
5139
|
+
if (hit(".devin/" + MANIFEST_FILENAME)) {
|
|
5140
|
+
signals.push({
|
|
5141
|
+
profileId: "devin-desktop",
|
|
5142
|
+
confidence: "strong",
|
|
5143
|
+
reason: ".devin/.10x-cli-manifest.json"
|
|
5144
|
+
});
|
|
5145
|
+
} else if (hit(".devin/rules")) {
|
|
5146
|
+
signals.push({ profileId: "devin-desktop", confidence: "strong", reason: ".devin/rules/" });
|
|
5147
|
+
} else if (hit(".devin")) {
|
|
5148
|
+
signals.push({ profileId: "devin-desktop", confidence: "medium", reason: ".devin/ directory" });
|
|
5149
|
+
} else if (hit(".windsurf/" + MANIFEST_FILENAME)) {
|
|
4395
5150
|
signals.push({
|
|
4396
|
-
profileId: "
|
|
5151
|
+
profileId: "devin-desktop",
|
|
4397
5152
|
confidence: "strong",
|
|
4398
|
-
reason: ".windsurf/.10x-cli-manifest.json"
|
|
5153
|
+
reason: ".windsurf/.10x-cli-manifest.json (legacy)"
|
|
4399
5154
|
});
|
|
4400
5155
|
} else if (hit(".windsurfrules")) {
|
|
4401
|
-
signals.push({
|
|
5156
|
+
signals.push({
|
|
5157
|
+
profileId: "devin-desktop",
|
|
5158
|
+
confidence: "strong",
|
|
5159
|
+
reason: ".windsurfrules (legacy)"
|
|
5160
|
+
});
|
|
4402
5161
|
} else if (hit(".windsurf")) {
|
|
4403
|
-
signals.push({
|
|
5162
|
+
signals.push({
|
|
5163
|
+
profileId: "devin-desktop",
|
|
5164
|
+
confidence: "medium",
|
|
5165
|
+
reason: ".windsurf/ directory (legacy)"
|
|
5166
|
+
});
|
|
4404
5167
|
}
|
|
4405
5168
|
if (hit(".gemini/" + MANIFEST_FILENAME)) {
|
|
4406
5169
|
signals.push({
|
|
@@ -4427,7 +5190,15 @@ function detectTools(projectRoot) {
|
|
|
4427
5190
|
return rankSignals(signals);
|
|
4428
5191
|
}
|
|
4429
5192
|
var CONFIDENCE_ORDER = { strong: 3, medium: 2, weak: 1 };
|
|
4430
|
-
var PROFILE_ORDER = [
|
|
5193
|
+
var PROFILE_ORDER = [
|
|
5194
|
+
"claude-code",
|
|
5195
|
+
"cursor",
|
|
5196
|
+
"copilot",
|
|
5197
|
+
"codex",
|
|
5198
|
+
"devin-desktop",
|
|
5199
|
+
"gemini",
|
|
5200
|
+
"generic"
|
|
5201
|
+
];
|
|
4431
5202
|
function rankSignals(signals) {
|
|
4432
5203
|
return [...signals].sort((a2, b2) => {
|
|
4433
5204
|
const c2 = CONFIDENCE_ORDER[b2.confidence] - CONFIDENCE_ORDER[a2.confidence];
|
|
@@ -4445,7 +5216,7 @@ function topDetectedProfile(signals) {
|
|
|
4445
5216
|
|
|
4446
5217
|
// src/commands/bench-kit.ts
|
|
4447
5218
|
var TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
|
|
4448
|
-
var BOOTSTRAP_ENTRY =
|
|
5219
|
+
var BOOTSTRAP_ENTRY = join5(".bench-kit", "bootstrap", "index.mjs");
|
|
4449
5220
|
var CONTRACT_VERSION = 1;
|
|
4450
5221
|
function registerBenchKitCommand(cli) {
|
|
4451
5222
|
cli.command("bench-kit <action> [dir]", "Manage a benchmark instance (actions: init, update)").option("--template-version <tag>", "Template tag to install (default: latest)").option("--tool <id>", `Agent tool for skill placement (${Object.keys(PROFILES).join(", ")})`).option("--yes", "Run non-interactively, accepting defaults").example("10x bench-kit init my-benchmark").example("10x bench-kit init my-benchmark --template-version v0.1.0").example("10x bench-kit update").action(async (action, dir, options) => {
|
|
@@ -4462,7 +5233,7 @@ function registerBenchKitCommand(cli) {
|
|
|
4462
5233
|
});
|
|
4463
5234
|
}
|
|
4464
5235
|
async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
4465
|
-
const targetDir =
|
|
5236
|
+
const targetDir = resolve2(dirArg ?? ".");
|
|
4466
5237
|
const requestedRef = normalizeRef(ctx, options.templateVersion);
|
|
4467
5238
|
await preflight(ctx, deps);
|
|
4468
5239
|
const existingVersion = readInstanceVersion(targetDir);
|
|
@@ -4470,7 +5241,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4470
5241
|
outputError(ctx, "version_conflict", `This directory already holds a benchmark instance on template version ${existingVersion}.`, ExitCodes.USAGE, "Run '10x bench-kit update' to change the template version of an existing instance.");
|
|
4471
5242
|
}
|
|
4472
5243
|
const toolChoice = await resolveInstanceTool(ctx, options, deps, existingVersion !== null);
|
|
4473
|
-
const scratch = mkdtempSync(
|
|
5244
|
+
const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
|
|
4474
5245
|
try {
|
|
4475
5246
|
await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "init");
|
|
4476
5247
|
const entry = requireBootstrap(ctx, scratch);
|
|
@@ -4507,14 +5278,14 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4507
5278
|
}
|
|
4508
5279
|
let baseRepoClone = "skipped";
|
|
4509
5280
|
if (res.baseRepoClone != null) {
|
|
4510
|
-
const cloneDest =
|
|
5281
|
+
const cloneDest = join5(targetDir, res.baseRepoClone.dest);
|
|
4511
5282
|
verbose(ctx, `cloning base repo into ${res.baseRepoClone.dest}`);
|
|
4512
5283
|
const cloned = await deps.cloneBaseRepo(res.baseRepoClone, cloneDest);
|
|
4513
5284
|
if (cloned.ok) {
|
|
4514
5285
|
baseRepoClone = "cloned";
|
|
4515
5286
|
} else {
|
|
4516
5287
|
baseRepoClone = "failed";
|
|
4517
|
-
|
|
5288
|
+
rmSync4(cloneDest, { recursive: true, force: true });
|
|
4518
5289
|
verbose(ctx, `base repo clone failed (${cloned.error.trim().split(`
|
|
4519
5290
|
`).pop() ?? ""})`);
|
|
4520
5291
|
}
|
|
@@ -4541,7 +5312,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4541
5312
|
...res.runnerDeps === "failed" ? ["Runner dependencies did not install — run 'npm ci --prefix .bench-kit/runner' yourself."] : res.runnerDeps === "installed" ? ["Runner dependencies installed (.bench-kit/runner/node_modules)."] : [],
|
|
4542
5313
|
res.committed === true ? "Initialized a fresh git repository with an initial commit." : "Initialized a fresh git repository (initial commit skipped — commit the files yourself).",
|
|
4543
5314
|
...(res.warnings ?? []).map((warning) => `Warning: ${warning}`),
|
|
4544
|
-
|
|
5315
|
+
`Next: go to ${targetDir}, launch you preferred agent and apply skill *bench-wiring*`
|
|
4545
5316
|
];
|
|
4546
5317
|
output(ctx, humanLines.join(`
|
|
4547
5318
|
`), {
|
|
@@ -4561,11 +5332,11 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4561
5332
|
warnings: res.warnings ?? []
|
|
4562
5333
|
});
|
|
4563
5334
|
} finally {
|
|
4564
|
-
|
|
5335
|
+
rmSync4(scratch, { recursive: true, force: true });
|
|
4565
5336
|
}
|
|
4566
5337
|
}
|
|
4567
5338
|
async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
4568
|
-
const targetDir =
|
|
5339
|
+
const targetDir = resolve2(dirArg ?? ".");
|
|
4569
5340
|
const requestedRef = normalizeRef(ctx, options.templateVersion);
|
|
4570
5341
|
await preflight(ctx, deps);
|
|
4571
5342
|
const currentVersion = readInstanceVersion(targetDir);
|
|
@@ -4580,7 +5351,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4580
5351
|
if (!status.ok) {
|
|
4581
5352
|
verbose(ctx, "not a git repository — proceeding, but review the changes without git diff");
|
|
4582
5353
|
}
|
|
4583
|
-
const scratch = mkdtempSync(
|
|
5354
|
+
const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
|
|
4584
5355
|
try {
|
|
4585
5356
|
await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "update");
|
|
4586
5357
|
const entry = requireBootstrap(ctx, scratch);
|
|
@@ -4638,7 +5409,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4638
5409
|
warnings: res.warnings ?? []
|
|
4639
5410
|
});
|
|
4640
5411
|
} finally {
|
|
4641
|
-
|
|
5412
|
+
rmSync4(scratch, { recursive: true, force: true });
|
|
4642
5413
|
}
|
|
4643
5414
|
}
|
|
4644
5415
|
function describeSync(counts) {
|
|
@@ -4648,10 +5419,11 @@ function describeSync(counts) {
|
|
|
4648
5419
|
}
|
|
4649
5420
|
async function resolveInstanceTool(ctx, options, deps, existingInstance) {
|
|
4650
5421
|
if (options.tool !== undefined) {
|
|
4651
|
-
|
|
5422
|
+
const canonicalId = canonicalToolId(options.tool);
|
|
5423
|
+
if (!getToolProfile(options.tool)) {
|
|
4652
5424
|
outputError(ctx, "unknown_tool", `'${options.tool}' is not a supported agent tool.`, ExitCodes.USAGE, `Supported: ${Object.keys(PROFILES).join(", ")}.`);
|
|
4653
5425
|
}
|
|
4654
|
-
return { id:
|
|
5426
|
+
return { id: canonicalId, explicit: true };
|
|
4655
5427
|
}
|
|
4656
5428
|
const signals = deps.detectToolSignals(process.cwd());
|
|
4657
5429
|
const top = signals[0];
|
|
@@ -4666,7 +5438,7 @@ async function resolveInstanceTool(ctx, options, deps, existingInstance) {
|
|
|
4666
5438
|
return { id: choice !== null && PROFILES[choice] ? choice : initial, explicit: choice !== null };
|
|
4667
5439
|
}
|
|
4668
5440
|
function skillRootFor(toolId) {
|
|
4669
|
-
const profile =
|
|
5441
|
+
const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
|
|
4670
5442
|
return `${profile.manifestDir}/skills`;
|
|
4671
5443
|
}
|
|
4672
5444
|
function allToolProfiles() {
|
|
@@ -4700,17 +5472,17 @@ async function cloneTemplateOrDie(ctx, deps, ref, scratch, action) {
|
|
|
4700
5472
|
}
|
|
4701
5473
|
}
|
|
4702
5474
|
function requireBootstrap(ctx, scratch) {
|
|
4703
|
-
const entry =
|
|
5475
|
+
const entry = join5(scratch, BOOTSTRAP_ENTRY);
|
|
4704
5476
|
if (!existsSync4(entry)) {
|
|
4705
5477
|
outputError(ctx, "template_incomplete", `The downloaded template has no ${BOOTSTRAP_ENTRY} — it predates the bootstrap contract.`, ExitCodes.ERROR, "This CLI needs template v0.10.0 or newer; drop --template-version or pass a newer tag.");
|
|
4706
5478
|
}
|
|
4707
5479
|
return entry;
|
|
4708
5480
|
}
|
|
4709
5481
|
function readInstanceVersion(dir) {
|
|
4710
|
-
const versionFile =
|
|
5482
|
+
const versionFile = join5(dir, ".bench-kit", "VERSION");
|
|
4711
5483
|
if (!existsSync4(versionFile))
|
|
4712
5484
|
return null;
|
|
4713
|
-
return
|
|
5485
|
+
return readFileSync4(versionFile, "utf8").trim();
|
|
4714
5486
|
}
|
|
4715
5487
|
function toHttpsUrl(url) {
|
|
4716
5488
|
const scp = url.match(/^git@([^:/]+):(.+)$/);
|
|
@@ -4836,214 +5608,76 @@ var defaultDeps = {
|
|
|
4836
5608
|
|
|
4837
5609
|
// src/commands/doctor.ts
|
|
4838
5610
|
import { existsSync as existsSync5, accessSync, constants } from "node:fs";
|
|
4839
|
-
import { join as
|
|
5611
|
+
import { join as join6 } from "node:path";
|
|
4840
5612
|
|
|
4841
|
-
// src/lib/
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
constructor(message) {
|
|
5613
|
+
// src/lib/course-selection.ts
|
|
5614
|
+
class CourseSelectionError extends Error {
|
|
5615
|
+
code;
|
|
5616
|
+
constructor(code, message) {
|
|
4846
5617
|
super(message);
|
|
4847
|
-
this.
|
|
4848
|
-
}
|
|
4849
|
-
}
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
if (!
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
}
|
|
4870
|
-
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
4871
|
-
return KEYSET;
|
|
4872
|
-
}
|
|
4873
|
-
try {
|
|
4874
|
-
const parsed = JSON.parse(override);
|
|
4875
|
-
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
4876
|
-
return KEYSET;
|
|
4877
|
-
for (const k3 of parsed) {
|
|
4878
|
-
if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
|
|
4879
|
-
return KEYSET;
|
|
4880
|
-
}
|
|
4881
|
-
}
|
|
4882
|
-
return parsed;
|
|
4883
|
-
} catch {
|
|
4884
|
-
return KEYSET;
|
|
4885
|
-
}
|
|
4886
|
-
}
|
|
4887
|
-
function sha256Hex(body) {
|
|
4888
|
-
return createHash2("sha256").update(body).digest("hex");
|
|
4889
|
-
}
|
|
4890
|
-
function canonicalSigningString(keyId, hashHex) {
|
|
4891
|
-
return `v1:${keyId}:${hashHex}`;
|
|
4892
|
-
}
|
|
4893
|
-
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
4894
|
-
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
4895
|
-
}
|
|
4896
|
-
function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
|
|
4897
|
-
const key = keyset.find((k3) => k3.keyId === keyId);
|
|
4898
|
-
if (!key) {
|
|
4899
|
-
throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
|
|
4900
|
-
}
|
|
4901
|
-
const computedHash = sha256Hex(rawBody);
|
|
4902
|
-
if (computedHash !== headerHash) {
|
|
4903
|
-
throw new SignatureError(`Bundle content hash mismatch (header=${headerHash.slice(0, 12)}…, computed=${computedHash.slice(0, 12)}…). ` + "The bundle may have been tampered with in transit. Do NOT use the content.");
|
|
4904
|
-
}
|
|
4905
|
-
let pubKey;
|
|
5618
|
+
this.code = code;
|
|
5619
|
+
}
|
|
5620
|
+
}
|
|
5621
|
+
function selectCourse(discovery, state, explicit, writing = false) {
|
|
5622
|
+
if (!validateCourseDiscovery(discovery))
|
|
5623
|
+
throw new CourseSelectionError("discovery_invalid", "Invalid course discovery response.");
|
|
5624
|
+
const requested = explicit ?? state.course ?? discovery.defaultCourse;
|
|
5625
|
+
if (!requested)
|
|
5626
|
+
throw new CourseSelectionError(discovery.courses.length ? "course_unavailable" : "course_access_denied", discovery.courses.length ? "Your courses have no available published content yet." : "Your account has no active course access.");
|
|
5627
|
+
const selected = discovery.courses.find((course) => course.id === requested || course.slug === requested);
|
|
5628
|
+
const normalized = selected?.slug ?? normalizeProjectCourse(requested);
|
|
5629
|
+
if (writing && state.course && normalized && state.course !== normalized)
|
|
5630
|
+
throw new CourseBindingError("course_mismatch", `This project uses ${state.course}. Start ${normalized} in a separate directory; get/sync cannot switch its edition.`);
|
|
5631
|
+
if (!selected)
|
|
5632
|
+
throw new CourseSelectionError("course_access_denied", `Your account does not have access to ${requested}.`);
|
|
5633
|
+
if (!selected.available)
|
|
5634
|
+
throw new CourseSelectionError("course_unavailable", `${selected.slug} has no available published content yet.`);
|
|
5635
|
+
if (normalizeProjectCourse(selected.slug) !== selected.slug)
|
|
5636
|
+
throw new CourseSelectionError("course_unsupported", `This CLI does not support ${selected.slug}. Update the CLI before writing its content.`);
|
|
5637
|
+
return { course: selected.slug, courseId: selected.id, reason: explicit !== undefined ? "explicit" : state.source === "binding" ? "project_binding" : state.source === "legacy" ? "legacy_manifest" : "backend_recommendation" };
|
|
5638
|
+
}
|
|
5639
|
+
function tokenLacksCourse(token, selection) {
|
|
4906
5640
|
try {
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
5641
|
+
const segment = token.split(".")[1];
|
|
5642
|
+
if (!segment)
|
|
5643
|
+
return false;
|
|
5644
|
+
const payload = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
|
|
5645
|
+
return Array.isArray(payload.courses) && !payload.courses.includes(selection.courseId) && !payload.courses.includes(selection.course);
|
|
4912
5646
|
} catch {
|
|
4913
|
-
|
|
4914
|
-
}
|
|
4915
|
-
const canonical = canonicalSigningString(keyId, computedHash);
|
|
4916
|
-
const sig = Buffer.from(signature, "base64");
|
|
4917
|
-
const valid = verify(null, Buffer.from(canonical), pubKey, sig);
|
|
4918
|
-
if (!valid) {
|
|
4919
|
-
throw new SignatureError("Bundle signature verification failed — the bundle may have been tampered with. " + "Do NOT use the content. Report this to the course team.");
|
|
4920
|
-
}
|
|
4921
|
-
}
|
|
4922
|
-
|
|
4923
|
-
// src/lib/api-content.ts
|
|
4924
|
-
function fetchCatalog(course, token, options = {}) {
|
|
4925
|
-
return apiGet(`/api/catalog/${encodeURIComponent(course)}`, { token, signal: options.signal });
|
|
4926
|
-
}
|
|
4927
|
-
function fetchModuleDetail(course, module, token, options = {}) {
|
|
4928
|
-
return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
|
|
4929
|
-
}
|
|
4930
|
-
async function fetchLesson(course, lessonId, token, options = {}) {
|
|
4931
|
-
const params = new URLSearchParams;
|
|
4932
|
-
if (options.lang)
|
|
4933
|
-
params.set("lang", options.lang);
|
|
4934
|
-
if (options.tool)
|
|
4935
|
-
params.set("tool", options.tool);
|
|
4936
|
-
const qs = params.toString();
|
|
4937
|
-
const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
|
|
4938
|
-
const result = await apiGet(path, { token, signal: options.signal });
|
|
4939
|
-
if (!result.ok)
|
|
4940
|
-
return result;
|
|
4941
|
-
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
4942
|
-
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
4943
|
-
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
4944
|
-
if (signature && keyIdRaw && headerHash) {
|
|
4945
|
-
const keyId = Number(keyIdRaw);
|
|
4946
|
-
try {
|
|
4947
|
-
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
4948
|
-
} catch (err) {
|
|
4949
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4950
|
-
return {
|
|
4951
|
-
ok: false,
|
|
4952
|
-
status: 0,
|
|
4953
|
-
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
4954
|
-
error: message
|
|
4955
|
-
};
|
|
4956
|
-
}
|
|
4957
|
-
} else if (signature || keyIdRaw || headerHash) {
|
|
4958
|
-
return {
|
|
4959
|
-
ok: false,
|
|
4960
|
-
status: 0,
|
|
4961
|
-
code: "signature_error",
|
|
4962
|
-
error: "Bundle signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
4963
|
-
};
|
|
4964
|
-
} else if (REQUIRE_SIGNATURES) {
|
|
4965
|
-
return {
|
|
4966
|
-
ok: false,
|
|
4967
|
-
status: 0,
|
|
4968
|
-
code: "signature_missing",
|
|
4969
|
-
error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
4970
|
-
};
|
|
4971
|
-
} else {
|
|
4972
|
-
process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
|
|
4973
|
-
`);
|
|
5647
|
+
return false;
|
|
4974
5648
|
}
|
|
4975
|
-
return result;
|
|
4976
5649
|
}
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
params.set("lang", options.lang);
|
|
4981
|
-
const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
|
|
4982
|
-
const result = await apiGet(path, { token, signal: options.signal });
|
|
4983
|
-
if (!result.ok)
|
|
4984
|
-
return result;
|
|
4985
|
-
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
4986
|
-
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
4987
|
-
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
4988
|
-
if (signature && keyIdRaw && headerHash) {
|
|
4989
|
-
const keyId = Number(keyIdRaw);
|
|
4990
|
-
try {
|
|
4991
|
-
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
4992
|
-
} catch (err) {
|
|
4993
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4994
|
-
return {
|
|
4995
|
-
ok: false,
|
|
4996
|
-
status: 0,
|
|
4997
|
-
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
4998
|
-
error: message
|
|
4999
|
-
};
|
|
5000
|
-
}
|
|
5001
|
-
} else if (signature || keyIdRaw || headerHash) {
|
|
5002
|
-
return {
|
|
5003
|
-
ok: false,
|
|
5004
|
-
status: 0,
|
|
5005
|
-
code: "signature_error",
|
|
5006
|
-
error: "Artifact signing headers are incomplete (expected X-Bundle-Signature, X-Bundle-Key-Id, and X-Bundle-Content-Hash together). " + "The API may be misconfigured. Do NOT use the content. Report this to the course team."
|
|
5007
|
-
};
|
|
5008
|
-
} else if (REQUIRE_SIGNATURES) {
|
|
5009
|
-
return {
|
|
5010
|
-
ok: false,
|
|
5011
|
-
status: 0,
|
|
5012
|
-
code: "signature_missing",
|
|
5013
|
-
error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
5014
|
-
};
|
|
5015
|
-
} else {
|
|
5016
|
-
process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
|
|
5017
|
-
`);
|
|
5650
|
+
function reportCourseError(ctx, error) {
|
|
5651
|
+
if (error instanceof CourseBindingError || error instanceof CourseSelectionError) {
|
|
5652
|
+
outputError(ctx, error.code, error.message, error.code === "course_access_denied" ? ExitCodes.FORBIDDEN : error.code === "course_mismatch" ? ExitCodes.USAGE : ExitCodes.ERROR);
|
|
5018
5653
|
}
|
|
5019
|
-
|
|
5654
|
+
throw error;
|
|
5020
5655
|
}
|
|
5021
|
-
async function
|
|
5022
|
-
const
|
|
5023
|
-
const
|
|
5024
|
-
const
|
|
5025
|
-
const started = Date.now();
|
|
5656
|
+
async function resolveCourseSelection(ctx, options, dependencies = {}) {
|
|
5657
|
+
const authenticate = dependencies.authenticate ?? requireAuth;
|
|
5658
|
+
const discover = dependencies.discover ?? fetchCourses;
|
|
5659
|
+
const root = options.projectRoot ?? process.cwd();
|
|
5026
5660
|
try {
|
|
5027
|
-
const
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
const
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5661
|
+
const state = inspectProjectCourse(root);
|
|
5662
|
+
let auth = await authenticate(ctx);
|
|
5663
|
+
let discovery = await discover(auth.access_token);
|
|
5664
|
+
if (!discovery.ok)
|
|
5665
|
+
outputError(ctx, discovery.code, discovery.error, discovery.status === 401 ? ExitCodes.AUTH_REQUIRED : discovery.status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
|
|
5666
|
+
const selected = selectCourse(discovery.data, state, options.explicit, options.writing);
|
|
5667
|
+
if (tokenLacksCourse(auth.access_token, selected)) {
|
|
5668
|
+
auth = await authenticate(ctx, { forceRefreshForToken: auth.access_token });
|
|
5669
|
+
discovery = await discover(auth.access_token);
|
|
5670
|
+
if (!discovery.ok)
|
|
5671
|
+
outputError(ctx, discovery.code, discovery.error, discovery.status === 401 ? ExitCodes.AUTH_REQUIRED : discovery.status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
|
|
5672
|
+
selectCourse(discovery.data, state, selected.course, options.writing);
|
|
5673
|
+
if (tokenLacksCourse(auth.access_token, selected))
|
|
5674
|
+
throw new CourseSelectionError("course_access_denied", "The refreshed session still does not grant the selected course. Sign in again.");
|
|
5675
|
+
}
|
|
5676
|
+
return { ...selected, auth };
|
|
5677
|
+
} catch (error) {
|
|
5678
|
+
return reportCourseError(ctx, error);
|
|
5042
5679
|
}
|
|
5043
5680
|
}
|
|
5044
|
-
function apiBaseUrl() {
|
|
5045
|
-
return resolveApiBase();
|
|
5046
|
-
}
|
|
5047
5681
|
|
|
5048
5682
|
// src/lib/update-check.ts
|
|
5049
5683
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org/@przeprogramowani/10x-cli/latest";
|
|
@@ -5118,7 +5752,7 @@ function registerDoctorCommand(cli) {
|
|
|
5118
5752
|
async function runDoctor(ctx) {
|
|
5119
5753
|
verbose(ctx, "running doctor checks");
|
|
5120
5754
|
const checks = [];
|
|
5121
|
-
checks.push(checkAuth());
|
|
5755
|
+
checks.push(await checkAuth());
|
|
5122
5756
|
checks.push(await checkApiConnectivity());
|
|
5123
5757
|
checks.push(checkConfigDirectory());
|
|
5124
5758
|
checks.push(await checkCliVersion());
|
|
@@ -5167,7 +5801,7 @@ async function runDoctor(ctx) {
|
|
|
5167
5801
|
process.exit(EX_CONFIG);
|
|
5168
5802
|
}
|
|
5169
5803
|
}
|
|
5170
|
-
function checkAuth() {
|
|
5804
|
+
async function checkAuth() {
|
|
5171
5805
|
const auth = readAuth();
|
|
5172
5806
|
if (!auth) {
|
|
5173
5807
|
return {
|
|
@@ -5188,6 +5822,16 @@ function checkAuth() {
|
|
|
5188
5822
|
details: { email: auth.email, expires_at: auth.expires_at }
|
|
5189
5823
|
};
|
|
5190
5824
|
}
|
|
5825
|
+
const access = await fetchCourses(auth.access_token);
|
|
5826
|
+
if (!access.ok)
|
|
5827
|
+
return { name: "auth", label: "Auth", status: "fail", message: `Session exists, but course access could not be verified: ${access.error}`, details: { email: auth.email, expires_at: auth.expires_at, code: access.code, access_checked: false } };
|
|
5828
|
+
let selected;
|
|
5829
|
+
try {
|
|
5830
|
+
selected = selectCourse(access.data, inspectProjectCourse(process.cwd()));
|
|
5831
|
+
} catch (error) {
|
|
5832
|
+
return { name: "auth", label: "Auth", status: "fail", message: error instanceof Error ? error.message : String(error), details: { access_checked: true, courses: access.data.courses, code: error && typeof error === "object" && "code" in error ? error.code : "course_error" } };
|
|
5833
|
+
}
|
|
5834
|
+
const accessDetails = { access_checked: true, courses: access.data.courses, course: selected.course, selectionReason: selected.reason };
|
|
5191
5835
|
const nearExpiry = isNearExpiry(auth);
|
|
5192
5836
|
const prettyDate = formatReleaseAt(auth.expires_at);
|
|
5193
5837
|
if (nearExpiry) {
|
|
@@ -5197,15 +5841,15 @@ function checkAuth() {
|
|
|
5197
5841
|
status: "warn",
|
|
5198
5842
|
message: `Signed in as ${auth.email}, but the session expires ${prettyDate.toLowerCase()}.`,
|
|
5199
5843
|
hint: "The next command will transparently refresh the token.",
|
|
5200
|
-
details: { email: auth.email, expires_at: auth.expires_at }
|
|
5844
|
+
details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
|
|
5201
5845
|
};
|
|
5202
5846
|
}
|
|
5203
5847
|
return {
|
|
5204
5848
|
name: "auth",
|
|
5205
5849
|
label: "Auth",
|
|
5206
5850
|
status: "pass",
|
|
5207
|
-
message: `Signed in as ${auth.email} — session expires ${prettyDate.toLowerCase()}.`,
|
|
5208
|
-
details: { email: auth.email, expires_at: auth.expires_at }
|
|
5851
|
+
message: `Signed in as ${auth.email} — ${selected.course} (${selected.reason}); session expires ${prettyDate.toLowerCase()}.`,
|
|
5852
|
+
details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
|
|
5209
5853
|
};
|
|
5210
5854
|
}
|
|
5211
5855
|
async function checkApiConnectivity() {
|
|
@@ -5304,9 +5948,9 @@ async function checkCliVersion() {
|
|
|
5304
5948
|
function checkToolDirectory() {
|
|
5305
5949
|
const cwd = process.cwd();
|
|
5306
5950
|
const toolId = readToolConfig()?.tool ?? DEFAULT_TOOL;
|
|
5307
|
-
const profile =
|
|
5951
|
+
const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
|
|
5308
5952
|
const dirName = profile.manifestDir;
|
|
5309
|
-
const toolDir =
|
|
5953
|
+
const toolDir = join6(cwd, dirName);
|
|
5310
5954
|
if (!existsSync5(toolDir)) {
|
|
5311
5955
|
return {
|
|
5312
5956
|
name: "tool-dir",
|
|
@@ -5339,7 +5983,7 @@ function checkToolDirectory() {
|
|
|
5339
5983
|
}
|
|
5340
5984
|
|
|
5341
5985
|
// src/commands/get.ts
|
|
5342
|
-
import { join as
|
|
5986
|
+
import { join as join10 } from "node:path";
|
|
5343
5987
|
|
|
5344
5988
|
// src/lib/conflict-prompt.ts
|
|
5345
5989
|
init_dist2();
|
|
@@ -5427,108 +6071,163 @@ function parseModuleRef(raw) {
|
|
|
5427
6071
|
init_dist2();
|
|
5428
6072
|
|
|
5429
6073
|
// src/lib/tool-switch.ts
|
|
5430
|
-
import {
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
mkdirSync as mkdirSync5,
|
|
5434
|
-
readFileSync as readFileSync6,
|
|
5435
|
-
renameSync as renameSync3,
|
|
5436
|
-
rmdirSync as rmdirSync2,
|
|
5437
|
-
rmSync as rmSync5,
|
|
5438
|
-
statSync,
|
|
5439
|
-
writeFileSync as writeFileSync4
|
|
5440
|
-
} from "node:fs";
|
|
5441
|
-
import { dirname as dirname4, join as join7 } from "node:path";
|
|
6074
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync6, writeFileSync as writeFileSync5, statSync, chmodSync as chmodSync3, openSync, closeSync } from "node:fs";
|
|
6075
|
+
import { dirname as dirname5, join as join9 } from "node:path";
|
|
6076
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
5442
6077
|
|
|
5443
|
-
// src/lib/
|
|
5444
|
-
import { readFileSync as
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
6078
|
+
// src/lib/managed-removal.ts
|
|
6079
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5, rmSync as rmSync5, rmdirSync } from "node:fs";
|
|
6080
|
+
import { dirname as dirname3, relative as relative2, sep as sep2 } from "node:path";
|
|
6081
|
+
function planManagedRemoval(root, input) {
|
|
6082
|
+
assertProjectFilePath(root, input.path);
|
|
6083
|
+
const base = { name: input.name, path: input.path, storedHash: input.storedHash, pruneRoot: input.pruneRoot };
|
|
6084
|
+
if (!existsSync6(input.path))
|
|
6085
|
+
return { ...base, action: "unchanged", reason: "already_missing" };
|
|
6086
|
+
if (input.protected)
|
|
6087
|
+
return { ...base, action: "preserved_local", reason: "still_owned" };
|
|
6088
|
+
if (input.config)
|
|
6089
|
+
return { ...base, action: "preserved_local", reason: "config_template" };
|
|
6090
|
+
if (!input.storedHash)
|
|
6091
|
+
return { ...base, action: "preserved_local", reason: "missing_baseline" };
|
|
6092
|
+
if (contentHash(readFileSync5(input.path)) !== input.storedHash)
|
|
6093
|
+
return { ...base, action: "preserved_local", reason: "locally_modified" };
|
|
6094
|
+
return { ...base, action: "removed" };
|
|
6095
|
+
}
|
|
6096
|
+
function executeManagedRemoval(root, plan) {
|
|
6097
|
+
assertProjectFilePath(root, plan.path);
|
|
6098
|
+
if (plan.action !== "removed")
|
|
6099
|
+
return plan;
|
|
6100
|
+
if (!existsSync6(plan.path))
|
|
6101
|
+
return { ...plan, action: "unchanged", reason: "already_missing" };
|
|
6102
|
+
if (!plan.storedHash || contentHash(readFileSync5(plan.path)) !== plan.storedHash)
|
|
6103
|
+
return { ...plan, action: "preserved_local", reason: "locally_modified" };
|
|
6104
|
+
rmSync5(plan.path);
|
|
6105
|
+
if (plan.pruneRoot)
|
|
6106
|
+
pruneEmptyParents(root, plan.path, plan.pruneRoot);
|
|
6107
|
+
return plan;
|
|
6108
|
+
}
|
|
6109
|
+
function pruneEmptyParents(root, file, boundary) {
|
|
6110
|
+
let dir = dirname3(file);
|
|
6111
|
+
while (dir === boundary || relative2(boundary, dir) !== ".." && !relative2(boundary, dir).startsWith(`..${sep2}`)) {
|
|
6112
|
+
assertProjectPath(root, dir);
|
|
6113
|
+
try {
|
|
6114
|
+
rmdirSync(dir);
|
|
6115
|
+
} catch (error) {
|
|
6116
|
+
if (["ENOENT", "ENOTEMPTY", "EEXIST", "EACCES", "EPERM"].includes(error.code ?? ""))
|
|
6117
|
+
return;
|
|
6118
|
+
throw error;
|
|
6119
|
+
}
|
|
6120
|
+
if (dir === boundary)
|
|
6121
|
+
return;
|
|
6122
|
+
dir = dirname3(dir);
|
|
5450
6123
|
}
|
|
5451
6124
|
}
|
|
5452
6125
|
|
|
6126
|
+
// src/lib/managed-rules.ts
|
|
6127
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
|
|
6128
|
+
import { join as join7 } from "node:path";
|
|
6129
|
+
|
|
5453
6130
|
// src/lib/sentinel-migration.ts
|
|
5454
6131
|
var OLD_BEGIN = "<!-- BEGIN @przeprogramowani/10x-toolkit -->";
|
|
5455
6132
|
var OLD_END = "<!-- END @przeprogramowani/10x-toolkit -->";
|
|
6133
|
+
function inspectRulesBlock(content, begin, end) {
|
|
6134
|
+
const starts = content.split(begin).length - 1;
|
|
6135
|
+
const ends = content.split(end).length - 1;
|
|
6136
|
+
if (!starts && !ends)
|
|
6137
|
+
return null;
|
|
6138
|
+
const start = content.indexOf(begin);
|
|
6139
|
+
const stop = content.indexOf(end);
|
|
6140
|
+
if (starts !== 1 || ends !== 1 || stop < start) {
|
|
6141
|
+
throw new Error("Rules markers need repair: orphan, duplicate, or out-of-order sentinel. Preserve the file and resolve its markers before continuing.");
|
|
6142
|
+
}
|
|
6143
|
+
const finish = stop + end.length;
|
|
6144
|
+
return { start, finish, text: content.slice(start, finish), body: content.slice(start + begin.length, stop).trim() };
|
|
6145
|
+
}
|
|
5456
6146
|
function removeRulesBlockWithMarkers(existing, begin, end) {
|
|
5457
|
-
const
|
|
5458
|
-
|
|
5459
|
-
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
6147
|
+
const block = inspectRulesBlock(existing, begin, end);
|
|
6148
|
+
if (!block)
|
|
5460
6149
|
return { content: existing, removed: false };
|
|
5461
|
-
}
|
|
5462
|
-
const beforeRaw = existing.slice(0, beginIdx);
|
|
5463
|
-
const afterRaw = existing.slice(endIdx + end.length);
|
|
5464
|
-
const before = beforeRaw.replace(/[\r\n]*$/, "");
|
|
5465
|
-
const after = afterRaw.replace(/^[\r\n]*/, "");
|
|
5466
|
-
const joiner = before.length > 0 && after.length > 0 ? `
|
|
5467
|
-
|
|
5468
|
-
` : "";
|
|
5469
|
-
const combined = before + joiner + after;
|
|
5470
|
-
const content = combined.length === 0 ? "" : combined.replace(/[\r\n]*$/, `
|
|
5471
|
-
`);
|
|
5472
|
-
return { content, removed: true };
|
|
6150
|
+
return { content: existing.slice(0, block.start) + existing.slice(block.finish), removed: true };
|
|
5473
6151
|
}
|
|
5474
|
-
function applyRulesBlockWithMarkers(
|
|
5475
|
-
for (const marker of [OLD_BEGIN, OLD_END,
|
|
5476
|
-
if (
|
|
5477
|
-
throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)})
|
|
5478
|
-
}
|
|
6152
|
+
function applyRulesBlockWithMarkers(existing, body, begin, end) {
|
|
6153
|
+
for (const marker of [OLD_BEGIN, OLD_END, begin, end]) {
|
|
6154
|
+
if (body.includes(marker))
|
|
6155
|
+
throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)})`);
|
|
5479
6156
|
}
|
|
5480
|
-
const
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
content = stripBlock(content, beginMarker, endMarker, "@przeprogramowani/10x-cli", warnings);
|
|
5484
|
-
content = content.replace(/\n{3,}/g, `
|
|
5485
|
-
|
|
5486
|
-
`).trim();
|
|
5487
|
-
const block = `${beginMarker}
|
|
6157
|
+
const current = inspectRulesBlock(existing, begin, end);
|
|
6158
|
+
const legacy = begin === OLD_BEGIN ? null : inspectRulesBlock(existing, OLD_BEGIN, OLD_END);
|
|
6159
|
+
const fresh = `${begin}
|
|
5488
6160
|
|
|
5489
|
-
${
|
|
5490
|
-
|
|
5491
|
-
${
|
|
5492
|
-
const
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
}
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
}
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
6161
|
+
${body.trim()}
|
|
6162
|
+
|
|
6163
|
+
${end}`;
|
|
6164
|
+
const blocks = [current, legacy].filter((entry) => entry !== null).sort((a2, b2) => a2.start - b2.start);
|
|
6165
|
+
if (blocks.length > 1 && blocks[0].finish > blocks[1].start)
|
|
6166
|
+
throw new Error("Rules markers need repair: nested sentinel blocks.");
|
|
6167
|
+
if (!blocks.length)
|
|
6168
|
+
return { content: `${existing}${existing && !existing.endsWith(`
|
|
6169
|
+
`) ? `
|
|
6170
|
+
` : ""}${fresh}
|
|
6171
|
+
`, warnings: [] };
|
|
6172
|
+
let content = existing;
|
|
6173
|
+
for (let i = blocks.length - 1;i >= 0; i--) {
|
|
6174
|
+
const block = blocks[i];
|
|
6175
|
+
content = content.slice(0, block.start) + (i === 0 ? fresh : "") + content.slice(block.finish);
|
|
6176
|
+
}
|
|
6177
|
+
return { content, warnings: [] };
|
|
6178
|
+
}
|
|
6179
|
+
|
|
6180
|
+
// src/lib/managed-rules.ts
|
|
6181
|
+
function otherRulesOwners(root, profile) {
|
|
6182
|
+
const owners = [];
|
|
6183
|
+
for (const candidate of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
|
|
6184
|
+
if (candidate.manifestDir === profile.manifestDir || candidate.rulesFile !== profile.rulesFile)
|
|
6185
|
+
continue;
|
|
6186
|
+
const manifest = readManifest(join7(root, candidate.manifestDir));
|
|
6187
|
+
if (!manifest)
|
|
6188
|
+
continue;
|
|
6189
|
+
if (manifest.managedRules || !manifest.lessons || Object.values(manifest.lessons).some((lesson) => lesson.representation?.courseRules !== false))
|
|
6190
|
+
owners.push({ profile: candidate, rules: manifest.managedRules });
|
|
6191
|
+
}
|
|
6192
|
+
return owners;
|
|
6193
|
+
}
|
|
6194
|
+
function planManagedRules(root, profile, body, enabled, baseline) {
|
|
6195
|
+
const path = join7(root, profile.rulesFile);
|
|
6196
|
+
assertProjectFilePath(root, path);
|
|
6197
|
+
const existing = existsSync7(path) ? readFileSync6(path, "utf8") : "";
|
|
6198
|
+
const block = inspectRulesBlock(existing, profile.sentinelBegin, profile.sentinelEnd);
|
|
6199
|
+
const legacy = inspectRulesBlock(existing, OLD_BEGIN, OLD_END);
|
|
6200
|
+
if (block && legacy && Math.max(block.start, legacy.start) < Math.min(block.finish, legacy.finish))
|
|
6201
|
+
throw new Error("Rules markers need repair: nested sentinel blocks.");
|
|
6202
|
+
if (body === undefined && enabled)
|
|
6203
|
+
return { action: "unchanged", content: existing, isConflict: false, next: baseline };
|
|
6204
|
+
const validBaseline = baseline?.path === profile.rulesFile && baseline.begin === profile.sentinelBegin && baseline.end === profile.sentinelEnd ? baseline : undefined;
|
|
6205
|
+
const owners = otherRulesOwners(root, profile);
|
|
6206
|
+
const desired = enabled ? applyRulesBlockWithMarkers(existing, body ?? "", profile.sentinelBegin, profile.sentinelEnd).content : removeRulesBlockWithMarkers(removeRulesBlockWithMarkers(existing, profile.sentinelBegin, profile.sentinelEnd).content, OLD_BEGIN, OLD_END).content;
|
|
6207
|
+
const desiredBlock = enabled ? inspectRulesBlock(desired, profile.sentinelBegin, profile.sentinelEnd) : null;
|
|
6208
|
+
const next = desiredBlock ? { path: profile.rulesFile, begin: profile.sentinelBegin, end: profile.sentinelEnd, upstreamHash: contentHash(desiredBlock.text) } : undefined;
|
|
6209
|
+
if (!enabled && owners.length)
|
|
6210
|
+
return { action: "unchanged", content: existing, isConflict: false, reason: "still_owned" };
|
|
6211
|
+
if (enabled && owners.some((owner) => !owner.rules?.upstreamHash || owner.rules.path !== profile.rulesFile || owner.rules.begin !== profile.sentinelBegin || owner.rules.end !== profile.sentinelEnd || owner.rules.upstreamHash !== next?.upstreamHash)) {
|
|
6212
|
+
return { action: "conflict_skipped", content: existing, isConflict: true, blocked: true, reason: "incompatible_shared_owner", next: baseline };
|
|
6213
|
+
}
|
|
6214
|
+
const unknownOrEdited = !!legacy || !!block && (!validBaseline?.upstreamHash || contentHash(block.text) !== validBaseline.upstreamHash);
|
|
6215
|
+
if (unknownOrEdited)
|
|
6216
|
+
return { action: "conflict_skipped", content: desired, isConflict: true, reason: validBaseline?.upstreamHash ? "locally_modified" : "missing_baseline", next };
|
|
6217
|
+
if (desired === existing)
|
|
6218
|
+
return { action: "unchanged", content: existing, isConflict: false, next: enabled ? next ?? baseline : undefined };
|
|
6219
|
+
return { action: !enabled ? "removed" : existing ? "updated" : "created", content: desired, isConflict: false, next };
|
|
5518
6220
|
}
|
|
5519
6221
|
|
|
5520
6222
|
// src/lib/writer.ts
|
|
5521
6223
|
import {
|
|
5522
6224
|
chmodSync as chmodSync2,
|
|
5523
|
-
existsSync as
|
|
6225
|
+
existsSync as existsSync8,
|
|
5524
6226
|
mkdirSync as mkdirSync4,
|
|
5525
|
-
readFileSync as
|
|
5526
|
-
|
|
5527
|
-
rmdirSync,
|
|
5528
|
-
rmSync as rmSync4,
|
|
5529
|
-
writeFileSync as writeFileSync3
|
|
6227
|
+
readFileSync as readFileSync7,
|
|
6228
|
+
writeFileSync as writeFileSync4
|
|
5530
6229
|
} from "node:fs";
|
|
5531
|
-
import { dirname as
|
|
6230
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
5532
6231
|
var CLI_VERSION = package_default.version;
|
|
5533
6232
|
var DEFAULT_COURSE = "10xdevs3";
|
|
5534
6233
|
async function applyBundle(bundle, projectRoot, options = {}) {
|
|
@@ -5536,221 +6235,208 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
5536
6235
|
const partial = options.partial === true;
|
|
5537
6236
|
const course = options.course ?? DEFAULT_COURSE;
|
|
5538
6237
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
5539
|
-
const onConflict = options.onConflict;
|
|
5540
6238
|
const applyCourseRules = options.applyCourseRules !== false;
|
|
5541
|
-
const
|
|
5542
|
-
const
|
|
5543
|
-
const
|
|
5544
|
-
const
|
|
5545
|
-
|
|
5546
|
-
|
|
6239
|
+
const plan = planBundle(bundle, projectRoot, { course, profile, applyCourseRules });
|
|
6240
|
+
const manifestDir = join8(projectRoot, profile.manifestDir);
|
|
6241
|
+
const previous = readManifest(manifestDir);
|
|
6242
|
+
const ledger = previous ? structuredClone(previous) : {
|
|
6243
|
+
package: CLI_PACKAGE_NAME,
|
|
6244
|
+
version: CLI_VERSION,
|
|
6245
|
+
manifestVersion: MANIFEST_VERSION,
|
|
6246
|
+
lastApplied: new Date().toISOString(),
|
|
6247
|
+
lessonId: bundle.lessonId,
|
|
6248
|
+
course,
|
|
6249
|
+
tool: profile.toolId,
|
|
6250
|
+
files: { skills: {}, prompts: [], configs: [] },
|
|
6251
|
+
lessons: {}
|
|
6252
|
+
};
|
|
6253
|
+
ledger.lessons = previous ? seedLessons(previous) : {};
|
|
6254
|
+
ledger.manifestVersion = MANIFEST_VERSION;
|
|
6255
|
+
ledger.course = course;
|
|
6256
|
+
ledger.tool = profile.toolId;
|
|
6257
|
+
const lesson = ledger.lessons[bundle.lessonId] ?? { appliedAt: new Date().toISOString(), skills: {}, prompts: [], configs: [] };
|
|
6258
|
+
ledger.lessons[bundle.lessonId] = lesson;
|
|
6259
|
+
const oldRepresentation = lesson.representation;
|
|
6260
|
+
const oldDigest = oldRepresentation?.lang === (options.lang ?? "en") && oldRepresentation.tool === profile.toolId ? lesson.catalogContentHash : undefined;
|
|
6261
|
+
delete lesson.representation;
|
|
6262
|
+
delete lesson.catalogContentHash;
|
|
6263
|
+
delete lesson.installedReleaseId;
|
|
6264
|
+
delete lesson.installedManifestHash;
|
|
6265
|
+
const invalidateOtherOwners = (kind, name, file) => {
|
|
6266
|
+
for (const [id, owner] of Object.entries(ledger.lessons)) {
|
|
6267
|
+
if (id === bundle.lessonId)
|
|
6268
|
+
continue;
|
|
6269
|
+
if (kind === "rules" || (kind === "skills" ? owner.skills[name]?.files.includes(file) : owner.prompts.includes(name))) {
|
|
6270
|
+
const representation = owner.representation;
|
|
6271
|
+
if (!partial && isEarlierLesson(id, bundle.lessonId) && representation?.lang === (options.lang ?? "en") && representation.tool === profile.toolId && representation.courseRules === applyCourseRules)
|
|
6272
|
+
continue;
|
|
6273
|
+
delete owner.representation;
|
|
6274
|
+
delete owner.catalogContentHash;
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
};
|
|
6278
|
+
const persist = () => {
|
|
6279
|
+
if (!dryRun) {
|
|
6280
|
+
rebuildManifestFiles(ledger);
|
|
6281
|
+
writeManifest(manifestDir, ledger);
|
|
6282
|
+
}
|
|
6283
|
+
};
|
|
6284
|
+
if (!dryRun) {
|
|
6285
|
+
assertProjectCourse(projectRoot, course);
|
|
6286
|
+
establishProjectCourse(projectRoot, course);
|
|
6287
|
+
}
|
|
6288
|
+
const result = { skills: [], prompts: [], configs: [], rules: { action: "unchanged" }, removals: { skills: [], prompts: [], configs: [] } };
|
|
6289
|
+
const deliver = async (file, content, info) => {
|
|
6290
|
+
let action = file.action;
|
|
6291
|
+
let userBackupPath;
|
|
6292
|
+
if (file.isConflict) {
|
|
6293
|
+
const resolution = dryRun ? "skip" : await options.onConflict?.(info) ?? "skip";
|
|
6294
|
+
if (resolution === "skip")
|
|
6295
|
+
return { action: "conflict_skipped", delivered: false };
|
|
6296
|
+
action = resolution === "save_user" ? "conflict_saved_user" : "conflict_overwritten";
|
|
6297
|
+
if (resolution === "save_user") {
|
|
6298
|
+
userBackupPath = buildUserBackupPath(file.path);
|
|
6299
|
+
assertProjectFilePath(projectRoot, userBackupPath);
|
|
6300
|
+
assertProjectFilePath(projectRoot, file.path);
|
|
6301
|
+
copyFileSync(file.path, userBackupPath);
|
|
6302
|
+
}
|
|
6303
|
+
}
|
|
6304
|
+
if (!dryRun && action !== "unchanged") {
|
|
6305
|
+
assertProjectFilePath(projectRoot, file.path);
|
|
6306
|
+
writeFileAt(file.path, content);
|
|
6307
|
+
}
|
|
6308
|
+
return { action, userBackupPath, delivered: true };
|
|
6309
|
+
};
|
|
5547
6310
|
for (let si = 0;si < bundle.skills.length; si++) {
|
|
5548
6311
|
const skill = bundle.skills[si];
|
|
5549
|
-
const
|
|
5550
|
-
|
|
5551
|
-
const fileWrites = [];
|
|
5552
|
-
const skillHashes = {};
|
|
6312
|
+
const writes = [];
|
|
6313
|
+
result.skills.push({ name: skill.name, files: writes });
|
|
5553
6314
|
for (let fi = 0;fi < skill.files.length; fi++) {
|
|
5554
6315
|
const file = skill.files[fi];
|
|
5555
|
-
const
|
|
5556
|
-
const
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
const resolution = onConflict ? await onConflict({
|
|
5563
|
-
artifactType: "skill",
|
|
5564
|
-
artifactName: `${skill.name}/${file.path}`,
|
|
5565
|
-
filePath: target,
|
|
5566
|
-
relativePath: file.path
|
|
5567
|
-
}) : "skip";
|
|
5568
|
-
if (resolution === "overwrite") {
|
|
5569
|
-
finalAction = "conflict_overwritten";
|
|
5570
|
-
if (!dryRun)
|
|
5571
|
-
writeFileAt(target, file.content);
|
|
5572
|
-
skillHashes[file.path] = contentHash(file.content);
|
|
5573
|
-
} else if (resolution === "save_user") {
|
|
5574
|
-
finalAction = "conflict_saved_user";
|
|
5575
|
-
if (!dryRun) {
|
|
5576
|
-
userBackupPath = buildUserBackupPath(target);
|
|
5577
|
-
copyFileSync(target, userBackupPath);
|
|
5578
|
-
writeFileAt(target, file.content);
|
|
5579
|
-
}
|
|
5580
|
-
skillHashes[file.path] = contentHash(file.content);
|
|
5581
|
-
} else {
|
|
5582
|
-
finalAction = "conflict_skipped";
|
|
5583
|
-
if (storedHash)
|
|
5584
|
-
skillHashes[file.path] = storedHash;
|
|
5585
|
-
}
|
|
5586
|
-
} else {
|
|
5587
|
-
if (!dryRun && action !== "unchanged") {
|
|
5588
|
-
writeFileAt(target, file.content);
|
|
6316
|
+
const planned = plan.skills[si].files[fi];
|
|
6317
|
+
const outcome = await deliver(planned, file.content, { artifactType: "skill", artifactName: `${skill.name}/${file.path}`, filePath: planned.path, relativePath: file.path });
|
|
6318
|
+
writes.push({ path: file.path, absolutePath: planned.path, action: outcome.action, userBackupPath: outcome.userBackupPath });
|
|
6319
|
+
if (outcome.delivered) {
|
|
6320
|
+
if (!dryRun && file.executable && outcome.action !== "unchanged") {
|
|
6321
|
+
assertProjectFilePath(projectRoot, planned.path);
|
|
6322
|
+
chmodSync2(planned.path, 493);
|
|
5589
6323
|
}
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
6324
|
+
const entry = lesson.skills[skill.name] ??= { files: [] };
|
|
6325
|
+
if (!entry.files.includes(file.path))
|
|
6326
|
+
entry.files.push(file.path);
|
|
6327
|
+
const global2 = ledger.files.skills[skill.name] ??= { files: [] };
|
|
6328
|
+
if (global2.contentHashes?.[file.path] !== contentHash(file.content))
|
|
6329
|
+
invalidateOtherOwners("skills", skill.name, file.path);
|
|
6330
|
+
(global2.contentHashes ??= {})[file.path] = contentHash(file.content);
|
|
6331
|
+
persist();
|
|
5594
6332
|
}
|
|
5595
|
-
fileWrites.push({ path: file.path, absolutePath: target, action: finalAction, userBackupPath });
|
|
5596
6333
|
}
|
|
5597
|
-
nextSkillHashes[skill.name] = skillHashes;
|
|
5598
|
-
skills.push({ name: skill.name, files: fileWrites });
|
|
5599
6334
|
}
|
|
5600
|
-
const prompts = [];
|
|
5601
6335
|
for (let pi = 0;pi < bundle.prompts.length; pi++) {
|
|
5602
6336
|
const prompt = bundle.prompts[pi];
|
|
5603
|
-
const
|
|
5604
|
-
const
|
|
5605
|
-
const
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
if (storedHash)
|
|
5633
|
-
nextPromptHashes[promptFilename] = storedHash;
|
|
5634
|
-
}
|
|
5635
|
-
} else {
|
|
5636
|
-
if (!dryRun && action !== "unchanged") {
|
|
5637
|
-
writeFileAt(target, prompt.content);
|
|
6337
|
+
const file = plan.prompts[pi];
|
|
6338
|
+
const name = `${prompt.name}.md`;
|
|
6339
|
+
const outcome = await deliver(file, prompt.content, { artifactType: "prompt", artifactName: prompt.name, filePath: file.path, relativePath: name });
|
|
6340
|
+
result.prompts.push({ name: prompt.name, path: file.path, action: outcome.action, userBackupPath: outcome.userBackupPath });
|
|
6341
|
+
if (outcome.delivered) {
|
|
6342
|
+
if (!lesson.prompts.includes(name))
|
|
6343
|
+
lesson.prompts.push(name);
|
|
6344
|
+
if (ledger.files.promptHashes?.[name] !== contentHash(prompt.content))
|
|
6345
|
+
invalidateOtherOwners("prompts", name);
|
|
6346
|
+
(ledger.files.promptHashes ??= {})[name] = contentHash(prompt.content);
|
|
6347
|
+
persist();
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
6350
|
+
const rules = planManagedRules(projectRoot, profile, bundle.rules.length ? bundle.rules.map((r2) => r2.content.trim()).join(`
|
|
6351
|
+
|
|
6352
|
+
`) : undefined, applyCourseRules, previous?.managedRules);
|
|
6353
|
+
let rulesAction = rules.action;
|
|
6354
|
+
let rulesDelivered = !rules.isConflict;
|
|
6355
|
+
let rulesBackup;
|
|
6356
|
+
if (rules.isConflict && !rules.blocked && !dryRun) {
|
|
6357
|
+
const resolution = await options.onConflict?.({ artifactType: "rules", artifactName: "course-rules", filePath: join8(projectRoot, profile.rulesFile), relativePath: profile.rulesFile }) ?? "skip";
|
|
6358
|
+
if (resolution !== "skip") {
|
|
6359
|
+
rulesDelivered = true;
|
|
6360
|
+
rulesAction = resolution === "save_user" ? "conflict_saved_user" : "conflict_overwritten";
|
|
6361
|
+
if (resolution === "save_user") {
|
|
6362
|
+
rulesBackup = buildUserBackupPath(join8(projectRoot, profile.rulesFile));
|
|
6363
|
+
assertProjectFilePath(projectRoot, rulesBackup);
|
|
6364
|
+
assertProjectFilePath(projectRoot, join8(projectRoot, profile.rulesFile));
|
|
6365
|
+
copyFileSync(join8(projectRoot, profile.rulesFile), rulesBackup);
|
|
5638
6366
|
}
|
|
5639
|
-
nextPromptHashes[promptFilename] = contentHash(prompt.content);
|
|
5640
6367
|
}
|
|
5641
|
-
prompts.push({ name: prompt.name, path: target, action: finalAction, userBackupPath });
|
|
5642
|
-
}
|
|
5643
|
-
const rulesFilePath = join6(projectRoot, profile.rulesFile);
|
|
5644
|
-
const existingRules = readFileOrEmpty(rulesFilePath);
|
|
5645
|
-
const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
|
|
5646
|
-
if (!dryRun && rulesAction !== "unchanged") {
|
|
5647
|
-
writeFileAt(rulesFilePath, newRulesContent);
|
|
5648
6368
|
}
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
};
|
|
5662
|
-
for (
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
])),
|
|
5701
|
-
prompts: [...prevManifest.files.prompts],
|
|
5702
|
-
configs: [...prevManifest.files.configs]
|
|
5703
|
-
};
|
|
5704
|
-
} else if (prevManifest?.lessons) {
|
|
5705
|
-
baseLessons = { ...prevManifest.lessons };
|
|
5706
|
-
}
|
|
5707
|
-
const lessons = {
|
|
5708
|
-
...baseLessons,
|
|
5709
|
-
[bundle.lessonId]: newLessonEntry
|
|
5710
|
-
};
|
|
5711
|
-
const union = buildUnionFiles(lessons);
|
|
5712
|
-
const unionSkills = {};
|
|
5713
|
-
for (const [name, skill] of Object.entries(union.skills)) {
|
|
5714
|
-
const prevHash = prevManifest?.files.skills[name]?.contentHashes;
|
|
5715
|
-
const currentHash = nextSkillHashes[name];
|
|
5716
|
-
unionSkills[name] = {
|
|
5717
|
-
files: skill.files,
|
|
5718
|
-
contentHashes: { ...prevHash, ...currentHash }
|
|
5719
|
-
};
|
|
6369
|
+
if (rulesDelivered) {
|
|
6370
|
+
if (!dryRun && rulesAction !== "unchanged") {
|
|
6371
|
+
assertProjectFilePath(projectRoot, join8(projectRoot, profile.rulesFile));
|
|
6372
|
+
const path = join8(projectRoot, profile.rulesFile);
|
|
6373
|
+
if (!existsSync8(path) || readFileSync7(path, "utf8") !== rules.content)
|
|
6374
|
+
writeFileAt(path, rules.content);
|
|
6375
|
+
}
|
|
6376
|
+
if (ledger.managedRules?.upstreamHash !== rules.next?.upstreamHash)
|
|
6377
|
+
invalidateOtherOwners("rules");
|
|
6378
|
+
ledger.managedRules = rules.next;
|
|
6379
|
+
persist();
|
|
6380
|
+
}
|
|
6381
|
+
result.rules = { action: rulesAction, reason: rules.reason, userBackupPath: rulesBackup };
|
|
6382
|
+
for (let ci = 0;ci < bundle.configs.length; ci++) {
|
|
6383
|
+
const config = bundle.configs[ci];
|
|
6384
|
+
const planned = plan.configs[ci];
|
|
6385
|
+
if (planned.action === "created") {
|
|
6386
|
+
if (!dryRun) {
|
|
6387
|
+
assertProjectFilePath(projectRoot, planned.path);
|
|
6388
|
+
writeFileAt(planned.path, config.content);
|
|
6389
|
+
}
|
|
6390
|
+
if (!lesson.configs.includes(config.name))
|
|
6391
|
+
lesson.configs.push(config.name);
|
|
6392
|
+
(ledger.files.configHashes ??= {})[config.name] = contentHash(config.content);
|
|
6393
|
+
persist();
|
|
6394
|
+
}
|
|
6395
|
+
if (planned.action === "skipped" && previous?.files.configs.includes(config.name) && !lesson.configs.includes(config.name)) {
|
|
6396
|
+
lesson.configs.push(config.name);
|
|
6397
|
+
persist();
|
|
6398
|
+
}
|
|
6399
|
+
result.configs.push({ name: config.name, path: planned.path, action: planned.action });
|
|
6400
|
+
}
|
|
6401
|
+
if (!partial) {
|
|
6402
|
+
for (const kind of ["skills", "prompts", "configs"]) {
|
|
6403
|
+
for (const candidate of plan.removals[kind]) {
|
|
6404
|
+
const removed = dryRun ? candidate : executeManagedRemoval(projectRoot, candidate);
|
|
6405
|
+
result.removals[kind].push({ name: removed.name, path: removed.path, action: removed.action, reason: removed.reason });
|
|
6406
|
+
if (kind === "skills") {
|
|
6407
|
+
const slash = candidate.name.indexOf("/");
|
|
6408
|
+
const name = candidate.name.slice(0, slash);
|
|
6409
|
+
const file = candidate.name.slice(slash + 1);
|
|
6410
|
+
const entry = lesson.skills[name];
|
|
6411
|
+
if (entry) {
|
|
6412
|
+
entry.files = entry.files.filter((value) => value !== file);
|
|
6413
|
+
if (!entry.files.length)
|
|
6414
|
+
delete lesson.skills[name];
|
|
6415
|
+
}
|
|
6416
|
+
} else
|
|
6417
|
+
lesson[kind] = lesson[kind].filter((value) => value !== candidate.name);
|
|
6418
|
+
persist();
|
|
6419
|
+
}
|
|
5720
6420
|
}
|
|
5721
|
-
const unionPromptHashes = {
|
|
5722
|
-
...prevManifest?.files.promptHashes,
|
|
5723
|
-
...nextPromptHashes
|
|
5724
|
-
};
|
|
5725
|
-
const nextManifest = {
|
|
5726
|
-
package: CLI_PACKAGE_NAME,
|
|
5727
|
-
version: CLI_VERSION,
|
|
5728
|
-
manifestVersion: MANIFEST_VERSION,
|
|
5729
|
-
lastApplied: new Date().toISOString(),
|
|
5730
|
-
lessonId: bundle.lessonId,
|
|
5731
|
-
course,
|
|
5732
|
-
tool: profile.toolId,
|
|
5733
|
-
files: {
|
|
5734
|
-
skills: unionSkills,
|
|
5735
|
-
prompts: union.prompts,
|
|
5736
|
-
configs: union.configs,
|
|
5737
|
-
promptHashes: unionPromptHashes
|
|
5738
|
-
},
|
|
5739
|
-
lessons
|
|
5740
|
-
};
|
|
5741
|
-
writeManifest(manifestDir, nextManifest);
|
|
5742
6421
|
}
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
6422
|
+
ledger.lessonId = bundle.lessonId;
|
|
6423
|
+
ledger.lastApplied = lesson.appliedAt = new Date().toISOString();
|
|
6424
|
+
const conflicts = result.skills.some((s) => s.files.some((f2) => f2.action === "conflict_skipped")) || result.prompts.some((p2) => p2.action === "conflict_skipped") || rulesAction === "conflict_skipped";
|
|
6425
|
+
if (!partial && !conflicts) {
|
|
6426
|
+
lesson.catalogContentHash = options.catalogContentHash ?? oldDigest;
|
|
6427
|
+
lesson.representation = { lang: options.lang ?? "en", tool: profile.toolId, courseRules: applyCourseRules, rules: applyCourseRules && bundle.rules.length > 0 };
|
|
6428
|
+
lesson.installedReleaseId = bundle.releaseId;
|
|
6429
|
+
lesson.installedManifestHash = bundle.releaseManifestHash;
|
|
6430
|
+
}
|
|
6431
|
+
persist();
|
|
6432
|
+
return result;
|
|
5750
6433
|
}
|
|
5751
6434
|
function planBundle(bundle, projectRoot, options = {}) {
|
|
5752
6435
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
5753
6436
|
const applyCourseRules = options.applyCourseRules !== false;
|
|
6437
|
+
validateBundlePayload(bundle);
|
|
6438
|
+
if (options.course && bundle.course !== undefined && bundle.course !== options.course)
|
|
6439
|
+
throw new Error("Bundle course differs from selected project course");
|
|
5754
6440
|
for (const skill of bundle.skills) {
|
|
5755
6441
|
assertSafeName(skill.name, "skill");
|
|
5756
6442
|
for (const file of skill.files)
|
|
@@ -5760,13 +6446,15 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5760
6446
|
assertSafeName(prompt.name, "prompt");
|
|
5761
6447
|
for (const config of bundle.configs)
|
|
5762
6448
|
assertSafeName(config.name, "config");
|
|
5763
|
-
const manifestDir =
|
|
6449
|
+
const manifestDir = join8(projectRoot, profile.manifestDir);
|
|
6450
|
+
assertProjectFilePath(projectRoot, join8(manifestDir, MANIFEST_FILENAME));
|
|
5764
6451
|
const prevManifest = readManifest(manifestDir);
|
|
5765
6452
|
const skills = bundle.skills.map((skill) => {
|
|
5766
|
-
const skillDir =
|
|
6453
|
+
const skillDir = join8(projectRoot, profile.skillDir(skill.name));
|
|
5767
6454
|
const prevEntry = prevManifest?.files.skills[skill.name];
|
|
5768
6455
|
const files = skill.files.map((file) => {
|
|
5769
|
-
const target =
|
|
6456
|
+
const target = join8(skillDir, file.path);
|
|
6457
|
+
assertProjectFilePath(projectRoot, target);
|
|
5770
6458
|
const storedHash = prevEntry?.contentHashes?.[file.path];
|
|
5771
6459
|
const { action, isConflict } = computeFileAction(target, file.content, storedHash);
|
|
5772
6460
|
return {
|
|
@@ -5780,7 +6468,8 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5780
6468
|
return { name: skill.name, files };
|
|
5781
6469
|
});
|
|
5782
6470
|
const prompts = bundle.prompts.map((prompt) => {
|
|
5783
|
-
const target =
|
|
6471
|
+
const target = join8(projectRoot, profile.promptPath(prompt.name));
|
|
6472
|
+
assertProjectFilePath(projectRoot, target);
|
|
5784
6473
|
const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
|
|
5785
6474
|
const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
|
|
5786
6475
|
return {
|
|
@@ -5791,12 +6480,14 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5791
6480
|
upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
|
|
5792
6481
|
};
|
|
5793
6482
|
});
|
|
5794
|
-
const rulesFilePath =
|
|
5795
|
-
const
|
|
5796
|
-
|
|
6483
|
+
const rulesFilePath = join8(projectRoot, profile.rulesFile);
|
|
6484
|
+
const rulesPlan = planManagedRules(projectRoot, profile, bundle.rules.length ? bundle.rules.map((r2) => r2.content.trim()).join(`
|
|
6485
|
+
|
|
6486
|
+
`) : undefined, applyCourseRules, prevManifest?.managedRules);
|
|
5797
6487
|
const configs = bundle.configs.map((config) => {
|
|
5798
|
-
const target =
|
|
5799
|
-
|
|
6488
|
+
const target = join8(projectRoot, profile.configPath(config.name));
|
|
6489
|
+
assertProjectFilePath(projectRoot, target);
|
|
6490
|
+
const action = existsSync8(target) ? "skipped" : "created";
|
|
5800
6491
|
return {
|
|
5801
6492
|
name: config.name,
|
|
5802
6493
|
path: target,
|
|
@@ -5806,58 +6497,56 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5806
6497
|
};
|
|
5807
6498
|
});
|
|
5808
6499
|
const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
|
|
5809
|
-
const
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
6500
|
+
const targets = [
|
|
6501
|
+
join8(manifestDir, MANIFEST_FILENAME),
|
|
6502
|
+
join8(manifestDir, `${MANIFEST_FILENAME}.tmp`),
|
|
6503
|
+
rulesFilePath,
|
|
6504
|
+
buildUserBackupPath(rulesFilePath),
|
|
6505
|
+
...skills.flatMap((skill) => skill.files.flatMap((file) => [file.path, buildUserBackupPath(file.path)])),
|
|
6506
|
+
...prompts.flatMap((prompt) => [prompt.path, buildUserBackupPath(prompt.path)]),
|
|
6507
|
+
...configs.map((config) => config.path)
|
|
6508
|
+
];
|
|
6509
|
+
for (const target of targets)
|
|
6510
|
+
assertProjectFilePath(projectRoot, target);
|
|
6511
|
+
const removals = removed;
|
|
5817
6512
|
return {
|
|
5818
6513
|
skills,
|
|
5819
6514
|
prompts,
|
|
5820
|
-
rules: { action:
|
|
6515
|
+
rules: { action: rulesPlan.action, isConflict: rulesPlan.isConflict, reason: rulesPlan.reason, upstreamChanged: rulesPlan.action !== "unchanged" },
|
|
5821
6516
|
configs,
|
|
5822
6517
|
removals
|
|
5823
6518
|
};
|
|
5824
6519
|
}
|
|
6520
|
+
function isEarlierLesson(left, right) {
|
|
6521
|
+
const a2 = /^m(\d+)l(\d+)$/.exec(left);
|
|
6522
|
+
const b2 = /^m(\d+)l(\d+)$/.exec(right);
|
|
6523
|
+
if (!a2 || !b2)
|
|
6524
|
+
return false;
|
|
6525
|
+
return Number(a2[1]) < Number(b2[1]) || Number(a2[1]) === Number(b2[1]) && Number(a2[2]) < Number(b2[2]);
|
|
6526
|
+
}
|
|
6527
|
+
function validateBundlePayload(bundle) {
|
|
6528
|
+
if (!bundle || typeof bundle.lessonId !== "string" || !Array.isArray(bundle.skills) || !Array.isArray(bundle.prompts) || !Array.isArray(bundle.rules) || !Array.isArray(bundle.configs))
|
|
6529
|
+
throw new Error("Invalid lesson bundle payload");
|
|
6530
|
+
for (const skill of bundle.skills) {
|
|
6531
|
+
if (!skill || typeof skill.name !== "string" || !Array.isArray(skill.files))
|
|
6532
|
+
throw new Error("Invalid skill payload");
|
|
6533
|
+
for (const file of skill.files)
|
|
6534
|
+
if (!file || typeof file.path !== "string" || typeof file.content !== "string" || file.executable !== undefined && typeof file.executable !== "boolean")
|
|
6535
|
+
throw new Error("Invalid skill file payload");
|
|
6536
|
+
}
|
|
6537
|
+
for (const artifact of [...bundle.prompts, ...bundle.rules, ...bundle.configs])
|
|
6538
|
+
if (!artifact || typeof artifact.name !== "string" || typeof artifact.content !== "string")
|
|
6539
|
+
throw new Error("Invalid artifact payload");
|
|
6540
|
+
}
|
|
5825
6541
|
function computeUpstreamChanged(newContent, storedHash, action) {
|
|
5826
6542
|
if (storedHash === undefined)
|
|
5827
6543
|
return action !== "unchanged";
|
|
5828
6544
|
return contentHash(newContent) !== storedHash;
|
|
5829
6545
|
}
|
|
5830
|
-
function planRules(existingRules, bundle, profile, applyCourseRules) {
|
|
5831
|
-
if (!applyCourseRules) {
|
|
5832
|
-
const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
|
|
5833
|
-
if (removed && stripped !== existingRules)
|
|
5834
|
-
return { action: "removed", content: stripped };
|
|
5835
|
-
return { action: "unchanged", content: existingRules };
|
|
5836
|
-
}
|
|
5837
|
-
if (bundle.rules.length === 0)
|
|
5838
|
-
return { action: "unchanged", content: existingRules };
|
|
5839
|
-
const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
|
|
5840
|
-
|
|
5841
|
-
`);
|
|
5842
|
-
const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
|
|
5843
|
-
let action;
|
|
5844
|
-
if (newRules === existingRules)
|
|
5845
|
-
action = "unchanged";
|
|
5846
|
-
else if (existingRules.length === 0)
|
|
5847
|
-
action = "created";
|
|
5848
|
-
else
|
|
5849
|
-
action = "updated";
|
|
5850
|
-
return { action, content: newRules };
|
|
5851
|
-
}
|
|
5852
6546
|
function computeFileAction(filePath, newContent, storedHash) {
|
|
5853
|
-
if (!
|
|
6547
|
+
if (!existsSync8(filePath))
|
|
5854
6548
|
return { action: "created", isConflict: false };
|
|
5855
|
-
|
|
5856
|
-
try {
|
|
5857
|
-
current = readFileSync5(filePath, "utf8");
|
|
5858
|
-
} catch {
|
|
5859
|
-
return { action: "updated", isConflict: false };
|
|
5860
|
-
}
|
|
6549
|
+
const current = readFileSync7(filePath, "utf8");
|
|
5861
6550
|
if (current === newContent)
|
|
5862
6551
|
return { action: "unchanged", isConflict: false };
|
|
5863
6552
|
if (storedHash !== undefined) {
|
|
@@ -5870,152 +6559,62 @@ function computeFileAction(filePath, newContent, storedHash) {
|
|
|
5870
6559
|
}
|
|
5871
6560
|
function buildUserBackupPath(filePath) {
|
|
5872
6561
|
const lastDot = filePath.lastIndexOf(".");
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
6562
|
+
const stem = lastDot === -1 ? filePath : filePath.slice(0, lastDot);
|
|
6563
|
+
const extension = lastDot === -1 ? "" : filePath.slice(lastDot);
|
|
6564
|
+
let candidate = `${stem}.user${extension}`;
|
|
6565
|
+
let suffix = 1;
|
|
6566
|
+
while (existsSync8(candidate))
|
|
6567
|
+
candidate = `${stem}.user.${suffix++}${extension}`;
|
|
6568
|
+
return candidate;
|
|
5876
6569
|
}
|
|
5877
6570
|
function copyFileSync(src, dest) {
|
|
5878
|
-
mkdirSync4(
|
|
5879
|
-
|
|
5880
|
-
}
|
|
5881
|
-
function readFileOrEmpty(filePath) {
|
|
5882
|
-
if (!existsSync6(filePath))
|
|
5883
|
-
return "";
|
|
5884
|
-
try {
|
|
5885
|
-
return readFileSync5(filePath, "utf8");
|
|
5886
|
-
} catch {
|
|
5887
|
-
return "";
|
|
5888
|
-
}
|
|
6571
|
+
mkdirSync4(dirname4(dest), { recursive: true });
|
|
6572
|
+
writeFileSync4(dest, readFileSync7(src));
|
|
5889
6573
|
}
|
|
5890
6574
|
function writeFileAt(filePath, content) {
|
|
5891
|
-
mkdirSync4(
|
|
5892
|
-
|
|
5893
|
-
}
|
|
5894
|
-
function computeRemovals(
|
|
5895
|
-
const
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
const
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
if (lessonId === bundle.lessonId)
|
|
5911
|
-
continue;
|
|
5912
|
-
for (const [name, skill] of Object.entries(entry.skills)) {
|
|
5913
|
-
if (!protectedSkills.has(name))
|
|
5914
|
-
protectedSkills.set(name, new Set);
|
|
5915
|
-
for (const f2 of skill.files)
|
|
5916
|
-
protectedSkills.get(name).add(f2);
|
|
5917
|
-
}
|
|
5918
|
-
for (const p2 of entry.prompts)
|
|
5919
|
-
protectedPrompts.add(p2);
|
|
5920
|
-
for (const c2 of entry.configs)
|
|
5921
|
-
protectedConfigs.add(c2);
|
|
5922
|
-
}
|
|
5923
|
-
const currentSkills = new Map(bundle.skills.map((s) => [s.name, new Set(s.files.map((f2) => f2.path))]));
|
|
5924
|
-
const currentPrompts = new Set(bundle.prompts.map((p2) => `${p2.name}.md`));
|
|
5925
|
-
const currentConfigs = new Set(bundle.configs.map((c2) => c2.name));
|
|
5926
|
-
const removed = {
|
|
5927
|
-
skillDirs: [],
|
|
5928
|
-
skillFiles: [],
|
|
5929
|
-
prompts: [],
|
|
5930
|
-
configs: []
|
|
5931
|
-
};
|
|
5932
|
-
for (const [skillName, skill] of Object.entries(prevLessonEntry.skills)) {
|
|
5933
|
-
if (!isSafeName(skillName))
|
|
5934
|
-
continue;
|
|
5935
|
-
const skillDirAbs = join6(projectRoot, profile.skillDir(skillName));
|
|
5936
|
-
if (!currentSkills.has(skillName)) {
|
|
5937
|
-
if (protectedSkills.has(skillName)) {
|
|
5938
|
-
const prot = protectedSkills.get(skillName);
|
|
5939
|
-
for (const relPath of skill.files) {
|
|
5940
|
-
if (prot.has(relPath))
|
|
5941
|
-
continue;
|
|
5942
|
-
if (!isSafeSkillFilePath(relPath))
|
|
5943
|
-
continue;
|
|
5944
|
-
removed.skillFiles.push({
|
|
5945
|
-
name: `${skillName}/${relPath}`,
|
|
5946
|
-
path: join6(skillDirAbs, relPath),
|
|
5947
|
-
skillDirAbs
|
|
5948
|
-
});
|
|
5949
|
-
}
|
|
5950
|
-
} else {
|
|
5951
|
-
removed.skillDirs.push({ name: skillName, path: skillDirAbs });
|
|
5952
|
-
}
|
|
5953
|
-
continue;
|
|
5954
|
-
}
|
|
5955
|
-
const currentFiles = currentSkills.get(skillName);
|
|
5956
|
-
for (const relPath of skill.files) {
|
|
5957
|
-
if (currentFiles.has(relPath))
|
|
5958
|
-
continue;
|
|
5959
|
-
if (protectedSkills.get(skillName)?.has(relPath))
|
|
5960
|
-
continue;
|
|
5961
|
-
if (!isSafeSkillFilePath(relPath))
|
|
6575
|
+
mkdirSync4(dirname4(filePath), { recursive: true });
|
|
6576
|
+
writeFileSync4(filePath, content);
|
|
6577
|
+
}
|
|
6578
|
+
function computeRemovals(previous, bundle, profile, root) {
|
|
6579
|
+
const out = { skills: [], prompts: [], configs: [] };
|
|
6580
|
+
if (!previous)
|
|
6581
|
+
return out;
|
|
6582
|
+
const lessons = seedLessons(previous);
|
|
6583
|
+
const before = lessons[bundle.lessonId];
|
|
6584
|
+
if (!before)
|
|
6585
|
+
return out;
|
|
6586
|
+
const others = Object.entries(lessons).filter(([id]) => id !== bundle.lessonId).map(([, entry]) => entry);
|
|
6587
|
+
for (const [name, skill] of Object.entries(before.skills)) {
|
|
6588
|
+
if (!isSafeName(name))
|
|
6589
|
+
throw new Error("Unsafe skill name in manifest");
|
|
6590
|
+
for (const file of skill.files) {
|
|
6591
|
+
if (!isSafeSkillFilePath(file))
|
|
6592
|
+
throw new Error("Unsafe skill path in manifest");
|
|
6593
|
+
if (bundle.skills.some((entry) => entry.name === name && entry.files.some((entry2) => entry2.path === file)))
|
|
5962
6594
|
continue;
|
|
5963
|
-
|
|
5964
|
-
name: `${skillName}/${relPath}`,
|
|
5965
|
-
path: join6(skillDirAbs, relPath),
|
|
5966
|
-
skillDirAbs
|
|
5967
|
-
});
|
|
6595
|
+
out.skills.push(planManagedRemoval(root, { name: `${name}/${file}`, path: join8(root, profile.skillDir(name), file), storedHash: previous.files.skills[name]?.contentHashes?.[file], protected: others.some((entry) => entry.skills[name]?.files.includes(file)), pruneRoot: join8(root, profile.skillDir(name)) }));
|
|
5968
6596
|
}
|
|
5969
6597
|
}
|
|
5970
|
-
for (const
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
removed.prompts.push({
|
|
5979
|
-
name: promptFile,
|
|
5980
|
-
path: join6(projectRoot, profile.promptPath(promptName))
|
|
5981
|
-
});
|
|
5982
|
-
}
|
|
5983
|
-
for (const configFile of prevLessonEntry.configs) {
|
|
5984
|
-
if (currentConfigs.has(configFile))
|
|
5985
|
-
continue;
|
|
5986
|
-
if (protectedConfigs.has(configFile))
|
|
5987
|
-
continue;
|
|
5988
|
-
if (!isSafeName(configFile))
|
|
5989
|
-
continue;
|
|
5990
|
-
removed.configs.push({
|
|
5991
|
-
name: configFile,
|
|
5992
|
-
path: join6(projectRoot, profile.configPath(configFile))
|
|
5993
|
-
});
|
|
5994
|
-
}
|
|
5995
|
-
return removed;
|
|
5996
|
-
}
|
|
5997
|
-
function removeEmptyParentDirs(filePath, skillDirAbs) {
|
|
5998
|
-
let dir = dirname3(filePath);
|
|
5999
|
-
while (dir.startsWith(skillDirAbs) && dir !== skillDirAbs) {
|
|
6000
|
-
try {
|
|
6001
|
-
if (readdirSync(dir).length > 0)
|
|
6002
|
-
return;
|
|
6003
|
-
rmdirSync(dir);
|
|
6004
|
-
} catch {
|
|
6005
|
-
return;
|
|
6598
|
+
for (const kind of ["prompts", "configs"])
|
|
6599
|
+
for (const name of before[kind]) {
|
|
6600
|
+
if (!isSafeName(name))
|
|
6601
|
+
throw new Error("Unsafe file name in manifest");
|
|
6602
|
+
if ((kind === "prompts" ? bundle.prompts.map((p2) => `${p2.name}.md`) : bundle.configs.map((c2) => c2.name)).includes(name))
|
|
6603
|
+
continue;
|
|
6604
|
+
const path = join8(root, kind === "prompts" ? profile.promptPath(name.replace(/\.md$/, "")) : profile.configPath(name));
|
|
6605
|
+
out[kind].push(planManagedRemoval(root, { name, path, storedHash: kind === "prompts" ? previous.files.promptHashes?.[name] : previous.files.configHashes?.[name], protected: others.some((entry) => entry[kind].includes(name)), config: kind === "configs" }));
|
|
6006
6606
|
}
|
|
6007
|
-
|
|
6008
|
-
}
|
|
6607
|
+
return out;
|
|
6009
6608
|
}
|
|
6010
6609
|
function findOrphanedManifests(projectRoot, currentProfile) {
|
|
6011
6610
|
const out = [];
|
|
6012
|
-
for (const profile of Object.values(PROFILES)) {
|
|
6611
|
+
for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
|
|
6013
6612
|
if (profile.toolId === currentProfile.toolId)
|
|
6014
6613
|
continue;
|
|
6015
|
-
const manifestPath =
|
|
6016
|
-
if (!
|
|
6614
|
+
const manifestPath = join8(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
|
|
6615
|
+
if (!existsSync8(manifestPath))
|
|
6017
6616
|
continue;
|
|
6018
|
-
const manifest = readManifest(
|
|
6617
|
+
const manifest = readManifest(join8(projectRoot, profile.manifestDir));
|
|
6019
6618
|
if (!manifest)
|
|
6020
6619
|
continue;
|
|
6021
6620
|
out.push({ profile, manifestPath, manifest });
|
|
@@ -6080,225 +6679,317 @@ function isSafeName(name) {
|
|
|
6080
6679
|
}
|
|
6081
6680
|
|
|
6082
6681
|
// src/lib/tool-switch.ts
|
|
6083
|
-
function
|
|
6084
|
-
|
|
6085
|
-
|
|
6086
|
-
|
|
6087
|
-
|
|
6088
|
-
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
6092
|
-
|
|
6093
|
-
|
|
6094
|
-
|
|
6682
|
+
function entries(manifest) {
|
|
6683
|
+
return [
|
|
6684
|
+
...Object.entries(manifest.files.skills).flatMap(([name, entry]) => entry.files.map((file) => ({ kind: "skills", name, file, hash: entry.contentHashes?.[file] }))),
|
|
6685
|
+
...manifest.files.prompts.map((name) => ({ kind: "prompts", name, hash: manifest.files.promptHashes?.[name] })),
|
|
6686
|
+
...manifest.files.configs.map((name) => ({ kind: "configs", name, hash: manifest.files.configHashes?.[name] }))
|
|
6687
|
+
];
|
|
6688
|
+
}
|
|
6689
|
+
function entryPath(root, profile, entry) {
|
|
6690
|
+
if (!isSafeName(entry.name) || entry.file !== undefined && !isSafeSkillFilePath(entry.file))
|
|
6691
|
+
throw new Error("unsafe name or path in manifest");
|
|
6692
|
+
return join9(root, entry.kind === "skills" ? join9(profile.skillDir(entry.name), entry.file) : entry.kind === "prompts" ? profile.promptPath(entry.name.replace(/\.md$/, "")) : profile.configPath(entry.name));
|
|
6693
|
+
}
|
|
6694
|
+
function owns(lesson, entry) {
|
|
6695
|
+
return entry.kind === "skills" ? !!lesson.skills[entry.name]?.files.includes(entry.file) : lesson[entry.kind].includes(entry.name);
|
|
6696
|
+
}
|
|
6697
|
+
function retire(manifest, entry) {
|
|
6698
|
+
for (const lesson of Object.values(manifest.lessons)) {
|
|
6699
|
+
if (!owns(lesson, entry))
|
|
6095
6700
|
continue;
|
|
6096
|
-
|
|
6097
|
-
|
|
6098
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
anyMoved = true;
|
|
6112
|
-
}
|
|
6113
|
-
tryRemoveEmptyDir(fromSkillDir);
|
|
6114
|
-
if (anyMoved)
|
|
6115
|
-
summary.movedOrRemoved.skills.push(skillName);
|
|
6116
|
-
}
|
|
6117
|
-
for (const promptFile of orphan.manifest.files.prompts) {
|
|
6118
|
-
if (!isSafeName(promptFile)) {
|
|
6119
|
-
summary.skipped.push({ path: promptFile, reason: "unsafe name in manifest" });
|
|
6701
|
+
if (entry.kind === "skills") {
|
|
6702
|
+
const skill = lesson.skills[entry.name];
|
|
6703
|
+
skill.files = skill.files.filter((file) => file !== entry.file);
|
|
6704
|
+
if (!skill.files.length)
|
|
6705
|
+
delete lesson.skills[entry.name];
|
|
6706
|
+
} else
|
|
6707
|
+
lesson[entry.kind] = lesson[entry.kind].filter((name) => name !== entry.name);
|
|
6708
|
+
delete lesson.representation;
|
|
6709
|
+
delete lesson.catalogContentHash;
|
|
6710
|
+
}
|
|
6711
|
+
rebuildManifestFiles(manifest);
|
|
6712
|
+
}
|
|
6713
|
+
function acquire(destination, source, entry) {
|
|
6714
|
+
for (const [id, prior] of Object.entries(source.lessons)) {
|
|
6715
|
+
if (!owns(prior, entry))
|
|
6120
6716
|
continue;
|
|
6121
|
-
}
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6717
|
+
const lesson = destination.lessons[id] ??= { appliedAt: prior.appliedAt, skills: {}, prompts: [], configs: [] };
|
|
6718
|
+
delete lesson.representation;
|
|
6719
|
+
delete lesson.catalogContentHash;
|
|
6720
|
+
if (entry.kind === "skills") {
|
|
6721
|
+
const skill = lesson.skills[entry.name] ??= { files: [] };
|
|
6722
|
+
if (!skill.files.includes(entry.file))
|
|
6723
|
+
skill.files.push(entry.file);
|
|
6724
|
+
} else if (!lesson[entry.kind].includes(entry.name))
|
|
6725
|
+
lesson[entry.kind].push(entry.name);
|
|
6726
|
+
}
|
|
6727
|
+
if (entry.kind === "skills") {
|
|
6728
|
+
const skill = destination.files.skills[entry.name] ??= { files: [] };
|
|
6729
|
+
if (entry.hash)
|
|
6730
|
+
(skill.contentHashes ??= {})[entry.file] = entry.hash;
|
|
6731
|
+
} else if (entry.hash)
|
|
6732
|
+
(entry.kind === "prompts" ? destination.files.promptHashes ??= {} : destination.files.configHashes ??= {})[entry.name] = entry.hash;
|
|
6733
|
+
rebuildManifestFiles(destination);
|
|
6734
|
+
}
|
|
6735
|
+
function summaryFor(orphan, action) {
|
|
6736
|
+
return { action, oldToolId: orphan.profile.toolId, movedOrRemoved: { skills: [], prompts: [], configs: [] }, sentinelStripped: false, skipped: [] };
|
|
6737
|
+
}
|
|
6738
|
+
function record(summary, entry) {
|
|
6739
|
+
if (!summary.movedOrRemoved[entry.kind].includes(entry.name))
|
|
6740
|
+
summary.movedOrRemoved[entry.kind].push(entry.name);
|
|
6741
|
+
}
|
|
6742
|
+
function persistSource(root, profile, source) {
|
|
6743
|
+
rebuildManifestFiles(source);
|
|
6744
|
+
const dir = join9(root, profile.manifestDir);
|
|
6745
|
+
if (entries(source).length || source.managedRules) {
|
|
6746
|
+
writeManifest(dir, source);
|
|
6747
|
+
return;
|
|
6128
6748
|
}
|
|
6129
|
-
|
|
6130
|
-
|
|
6131
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6749
|
+
const path = join9(dir, MANIFEST_FILENAME);
|
|
6750
|
+
assertProjectFilePath(root, path);
|
|
6751
|
+
rmSync6(path, { force: true });
|
|
6752
|
+
pruneEmptyParents(root, path, dir);
|
|
6753
|
+
}
|
|
6754
|
+
function commitSourceRules(root, profile, source, update) {
|
|
6755
|
+
const rulesPath = join9(root, profile.rulesFile);
|
|
6756
|
+
const manifestPath = join9(root, profile.manifestDir, MANIFEST_FILENAME);
|
|
6757
|
+
assertProjectFilePath(root, rulesPath);
|
|
6758
|
+
assertProjectFilePath(root, manifestPath);
|
|
6759
|
+
const rulesBefore = existsSync9(rulesPath) ? readFileSync8(rulesPath) : undefined;
|
|
6760
|
+
const manifestBefore = existsSync9(manifestPath) ? readFileSync8(manifestPath) : undefined;
|
|
6761
|
+
const metadataBefore = source.managedRules;
|
|
6762
|
+
try {
|
|
6763
|
+
update();
|
|
6764
|
+
persistSource(root, profile, source);
|
|
6765
|
+
} catch (error) {
|
|
6766
|
+
source.managedRules = metadataBefore;
|
|
6767
|
+
for (const [path, bytes] of [[rulesPath, rulesBefore], [manifestPath, manifestBefore]]) {
|
|
6768
|
+
if (bytes === undefined)
|
|
6769
|
+
continue;
|
|
6770
|
+
assertProjectFilePath(root, path);
|
|
6771
|
+
if (!existsSync9(path) || !readFileSync8(path).equals(bytes)) {
|
|
6772
|
+
mkdirSync5(dirname5(path), { recursive: true });
|
|
6773
|
+
writeFileSync5(path, bytes);
|
|
6774
|
+
}
|
|
6138
6775
|
}
|
|
6776
|
+
throw error;
|
|
6139
6777
|
}
|
|
6140
|
-
summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
|
|
6141
|
-
rmSync5(orphan.manifestPath, { force: true });
|
|
6142
|
-
tryRemoveEmptyDir(join7(projectRoot, oldProfile.manifestDir));
|
|
6143
|
-
return summary;
|
|
6144
6778
|
}
|
|
6145
|
-
function
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
6149
|
-
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6779
|
+
function copyForTransfer(root, from, to, bytes) {
|
|
6780
|
+
assertProjectFilePath(root, to);
|
|
6781
|
+
mkdirSync5(dirname5(to), { recursive: true });
|
|
6782
|
+
const temporary = `${to}.${randomUUID3()}.tmp`;
|
|
6783
|
+
assertProjectFilePath(root, temporary);
|
|
6784
|
+
let owned = false;
|
|
6785
|
+
try {
|
|
6786
|
+
const fd = openSync(temporary, "wx");
|
|
6787
|
+
owned = true;
|
|
6788
|
+
closeSync(fd);
|
|
6789
|
+
writeFileSync5(temporary, bytes);
|
|
6790
|
+
chmodSync3(temporary, statSync(from).mode & 511);
|
|
6791
|
+
renameSync3(temporary, to);
|
|
6792
|
+
} finally {
|
|
6793
|
+
if (owned) {
|
|
6794
|
+
assertProjectFilePath(root, temporary);
|
|
6795
|
+
rmSync6(temporary, { force: true });
|
|
6796
|
+
}
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
function preflightProfilePaths(root, orphan, destination) {
|
|
6800
|
+
assertProjectFilePath(root, orphan.manifestPath);
|
|
6801
|
+
for (const profile of destination ? [orphan.profile, destination] : [orphan.profile]) {
|
|
6802
|
+
assertProjectPath(root, join9(root, profile.manifestDir));
|
|
6803
|
+
for (const file of [MANIFEST_FILENAME, `${MANIFEST_FILENAME}.tmp`])
|
|
6804
|
+
assertProjectFilePath(root, join9(root, profile.manifestDir, file));
|
|
6805
|
+
assertProjectFilePath(root, join9(root, profile.rulesFile));
|
|
6806
|
+
for (const entry of entries(orphan.manifest)) {
|
|
6807
|
+
const path = entryPath(root, profile, entry);
|
|
6808
|
+
assertProjectFilePath(root, path);
|
|
6809
|
+
assertProjectFilePath(root, `${path}.tmp`);
|
|
6810
|
+
}
|
|
6811
|
+
const rulesPath = join9(root, profile.rulesFile);
|
|
6812
|
+
if (existsSync9(rulesPath))
|
|
6813
|
+
inspectRulesBlock(readFileSync8(rulesPath, "utf8"), profile.sentinelBegin, profile.sentinelEnd);
|
|
6814
|
+
}
|
|
6815
|
+
}
|
|
6816
|
+
function begin(root, orphan, destination) {
|
|
6817
|
+
const course = normalizeProjectCourse(orphan.manifest.course);
|
|
6818
|
+
if (!course)
|
|
6819
|
+
throw new Error("Unsupported course in orphan manifest");
|
|
6820
|
+
assertProjectCourse(root, course);
|
|
6821
|
+
preflightProfilePaths(root, orphan, destination);
|
|
6822
|
+
establishProjectCourse(root, course);
|
|
6823
|
+
const source = structuredClone(orphan.manifest);
|
|
6824
|
+
source.course = course;
|
|
6825
|
+
source.lessons = seedLessons(source);
|
|
6826
|
+
const rulesPath = join9(root, orphan.profile.rulesFile);
|
|
6827
|
+
if (!source.managedRules && existsSync9(rulesPath) && inspectRulesBlock(readFileSync8(rulesPath, "utf8"), orphan.profile.sentinelBegin, orphan.profile.sentinelEnd)) {
|
|
6828
|
+
source.managedRules = { path: orphan.profile.rulesFile, begin: orphan.profile.sentinelBegin, end: orphan.profile.sentinelEnd };
|
|
6829
|
+
}
|
|
6830
|
+
return source;
|
|
6831
|
+
}
|
|
6832
|
+
function migrateArtifacts(root, orphan, profile) {
|
|
6833
|
+
const summary = summaryFor(orphan, "migrated");
|
|
6834
|
+
if (!existsSync9(orphan.manifestPath))
|
|
6835
|
+
return summary;
|
|
6836
|
+
const source = begin(root, orphan, profile);
|
|
6837
|
+
const destination = readManifest(join9(root, profile.manifestDir)) ?? { ...structuredClone(source), tool: profile.toolId, files: { skills: {}, prompts: [], configs: [] }, lessons: {}, managedRules: undefined };
|
|
6838
|
+
destination.lessons = seedLessons(destination);
|
|
6839
|
+
destination.course = source.course;
|
|
6840
|
+
destination.tool = profile.toolId;
|
|
6841
|
+
for (const entry of entries(source)) {
|
|
6842
|
+
const from = entryPath(root, orphan.profile, entry);
|
|
6843
|
+
const to = entryPath(root, profile, entry);
|
|
6844
|
+
if (!existsSync9(from)) {
|
|
6845
|
+
retire(source, entry);
|
|
6846
|
+
persistSource(root, orphan.profile, source);
|
|
6157
6847
|
continue;
|
|
6158
6848
|
}
|
|
6159
|
-
const
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
summary.skipped.push({
|
|
6163
|
-
path: `${skillName}/${relPath}`,
|
|
6164
|
-
reason: "unsafe path in manifest"
|
|
6165
|
-
});
|
|
6166
|
-
continue;
|
|
6167
|
-
}
|
|
6168
|
-
rmSync5(join7(skillDir, relPath), { force: true });
|
|
6169
|
-
}
|
|
6170
|
-
rmSync5(skillDir, { recursive: true, force: true });
|
|
6171
|
-
summary.movedOrRemoved.skills.push(skillName);
|
|
6172
|
-
}
|
|
6173
|
-
for (const promptFile of orphan.manifest.files.prompts) {
|
|
6174
|
-
if (!isSafeName(promptFile)) {
|
|
6175
|
-
summary.skipped.push({ path: promptFile, reason: "unsafe name in manifest" });
|
|
6849
|
+
const bytes = readFileSync8(from);
|
|
6850
|
+
if (existsSync9(to) && !readFileSync8(to).equals(bytes)) {
|
|
6851
|
+
summary.skipped.push({ path: to, reason: "destination already exists with different content" });
|
|
6176
6852
|
continue;
|
|
6177
6853
|
}
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
6183
|
-
|
|
6184
|
-
|
|
6854
|
+
if (!existsSync9(to))
|
|
6855
|
+
copyForTransfer(root, from, to, bytes);
|
|
6856
|
+
acquire(destination, source, entry);
|
|
6857
|
+
writeManifest(join9(root, profile.manifestDir), destination);
|
|
6858
|
+
record(summary, entry);
|
|
6859
|
+
try {
|
|
6860
|
+
assertProjectFilePath(root, from);
|
|
6861
|
+
if (!readFileSync8(from).equals(bytes))
|
|
6862
|
+
throw new Error("source changed during transfer");
|
|
6863
|
+
rmSync6(from);
|
|
6864
|
+
} catch (error) {
|
|
6865
|
+
summary.skipped.push({ path: from, reason: `copied to destination but could not remove source: ${error instanceof Error ? error.message : String(error)}` });
|
|
6866
|
+
persistSource(root, orphan.profile, source);
|
|
6185
6867
|
continue;
|
|
6186
6868
|
}
|
|
6187
|
-
|
|
6188
|
-
|
|
6869
|
+
retire(source, entry);
|
|
6870
|
+
try {
|
|
6871
|
+
persistSource(root, orphan.profile, source);
|
|
6872
|
+
} catch (error) {
|
|
6873
|
+
assertProjectFilePath(root, from);
|
|
6874
|
+
if (!existsSync9(from)) {
|
|
6875
|
+
mkdirSync5(dirname5(from), { recursive: true });
|
|
6876
|
+
writeFileSync5(from, bytes);
|
|
6877
|
+
}
|
|
6878
|
+
throw error;
|
|
6879
|
+
}
|
|
6880
|
+
pruneEmptyParents(root, from, join9(root, orphan.profile.manifestDir));
|
|
6189
6881
|
}
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "skills"));
|
|
6194
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "prompts"));
|
|
6195
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "config-templates"));
|
|
6196
|
-
tryRemoveEmptyDir(manifestDirAbs);
|
|
6882
|
+
commitSourceRules(root, orphan.profile, source, () => {
|
|
6883
|
+
transferRules(root, orphan.profile, profile, source, destination, summary);
|
|
6884
|
+
});
|
|
6197
6885
|
return summary;
|
|
6198
6886
|
}
|
|
6199
|
-
function
|
|
6200
|
-
|
|
6201
|
-
|
|
6887
|
+
function transferRules(root, old, profile, source, destination, summary) {
|
|
6888
|
+
const metadata = source.managedRules;
|
|
6889
|
+
if (!metadata)
|
|
6890
|
+
return;
|
|
6891
|
+
const from = join9(root, old.rulesFile);
|
|
6892
|
+
if (!existsSync9(from)) {
|
|
6893
|
+
delete source.managedRules;
|
|
6894
|
+
return;
|
|
6202
6895
|
}
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6896
|
+
const existing = readFileSync8(from, "utf8");
|
|
6897
|
+
const block = inspectRulesBlock(existing, old.sentinelBegin, old.sentinelEnd);
|
|
6898
|
+
if (!block) {
|
|
6899
|
+
delete source.managedRules;
|
|
6900
|
+
return;
|
|
6206
6901
|
}
|
|
6207
|
-
if (
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
path: to,
|
|
6211
|
-
reason: "destination already exists with different content"
|
|
6212
|
-
});
|
|
6213
|
-
return false;
|
|
6214
|
-
}
|
|
6215
|
-
rmSync5(from, { force: true });
|
|
6216
|
-
return true;
|
|
6902
|
+
if (metadata.path !== old.rulesFile || metadata.begin !== old.sentinelBegin || metadata.end !== old.sentinelEnd || !metadata.upstreamHash || contentHash(block.text) !== metadata.upstreamHash) {
|
|
6903
|
+
summary.skipped.push({ path: from, reason: "managed rules require explicit resolution: missing baseline or local edits" });
|
|
6904
|
+
return;
|
|
6217
6905
|
}
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
const code = err?.code;
|
|
6223
|
-
if (code !== "EXDEV")
|
|
6224
|
-
throw err;
|
|
6225
|
-
const bytes = readFileSync6(from);
|
|
6226
|
-
const tmp = `${to}.tmp`;
|
|
6227
|
-
writeFileSync4(tmp, bytes);
|
|
6228
|
-
renameSync3(tmp, to);
|
|
6229
|
-
try {
|
|
6230
|
-
rmSync5(from, { force: true });
|
|
6231
|
-
} catch {
|
|
6232
|
-
skipped.push({
|
|
6233
|
-
path: from,
|
|
6234
|
-
reason: "copied to destination but could not remove source"
|
|
6235
|
-
});
|
|
6236
|
-
return true;
|
|
6906
|
+
if (old.rulesFile === profile.rulesFile) {
|
|
6907
|
+
if (destination.managedRules && destination.managedRules.upstreamHash !== metadata.upstreamHash) {
|
|
6908
|
+
summary.skipped.push({ path: from, reason: "incompatible shared rules ownership" });
|
|
6909
|
+
return;
|
|
6237
6910
|
}
|
|
6911
|
+
destination.managedRules = { ...metadata };
|
|
6912
|
+
writeManifest(join9(root, profile.manifestDir), destination);
|
|
6913
|
+
if (!entries(source).length)
|
|
6914
|
+
delete source.managedRules;
|
|
6915
|
+
return;
|
|
6238
6916
|
}
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
if (!existsSync7(rulesPath))
|
|
6244
|
-
return false;
|
|
6245
|
-
const existing = readFileOrNull(rulesPath);
|
|
6246
|
-
if (existing === null)
|
|
6247
|
-
return false;
|
|
6248
|
-
const { content, removed } = removeRulesBlockWithMarkers(existing, oldProfile.sentinelBegin, oldProfile.sentinelEnd);
|
|
6249
|
-
if (!removed)
|
|
6250
|
-
return false;
|
|
6251
|
-
if (content.trim().length === 0) {
|
|
6252
|
-
rmSync5(rulesPath, { force: true });
|
|
6253
|
-
} else {
|
|
6254
|
-
writeFileSync4(rulesPath, content);
|
|
6917
|
+
const plan = planManagedRules(root, profile, block.body, true, destination.managedRules);
|
|
6918
|
+
if (plan.isConflict) {
|
|
6919
|
+
summary.skipped.push({ path: join9(root, profile.rulesFile), reason: plan.reason ?? "rules conflict" });
|
|
6920
|
+
return;
|
|
6255
6921
|
}
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
const bStat = statSync(b2);
|
|
6262
|
-
if (aStat.size !== bStat.size)
|
|
6263
|
-
return false;
|
|
6264
|
-
return readFileSync6(a2).equals(readFileSync6(b2));
|
|
6265
|
-
} catch {
|
|
6266
|
-
return false;
|
|
6922
|
+
const to = join9(root, profile.rulesFile);
|
|
6923
|
+
if (plan.action !== "unchanged") {
|
|
6924
|
+
assertProjectFilePath(root, to);
|
|
6925
|
+
mkdirSync5(dirname5(to), { recursive: true });
|
|
6926
|
+
writeFileSync5(to, plan.content);
|
|
6267
6927
|
}
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
if (
|
|
6928
|
+
destination.managedRules = plan.next;
|
|
6929
|
+
writeManifest(join9(root, profile.manifestDir), destination);
|
|
6930
|
+
if (entries(source).length)
|
|
6271
6931
|
return;
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6932
|
+
if (!otherRulesOwners(root, old).length) {
|
|
6933
|
+
const stripped = removeRulesBlockWithMarkers(existing, old.sentinelBegin, old.sentinelEnd);
|
|
6934
|
+
assertProjectFilePath(root, from);
|
|
6935
|
+
writeFileSync5(from, stripped.content);
|
|
6936
|
+
summary.sentinelStripped = stripped.removed;
|
|
6937
|
+
}
|
|
6938
|
+
delete source.managedRules;
|
|
6939
|
+
}
|
|
6940
|
+
function deleteArtifacts(root, orphan) {
|
|
6941
|
+
const summary = summaryFor(orphan, "deleted");
|
|
6942
|
+
if (!existsSync9(orphan.manifestPath))
|
|
6943
|
+
return summary;
|
|
6944
|
+
const source = begin(root, orphan);
|
|
6945
|
+
for (const entry of entries(source)) {
|
|
6946
|
+
const path = entryPath(root, orphan.profile, entry);
|
|
6947
|
+
const plan = planManagedRemoval(root, { name: entry.name, path, storedHash: entry.hash, config: entry.kind === "configs", pruneRoot: join9(root, orphan.profile.manifestDir) });
|
|
6948
|
+
const result = executeManagedRemoval(root, plan);
|
|
6949
|
+
if (result.action === "removed")
|
|
6950
|
+
record(summary, entry);
|
|
6951
|
+
else if (result.action === "preserved_local")
|
|
6952
|
+
summary.skipped.push({ path, reason: `preserved_local: ${result.reason}` });
|
|
6953
|
+
retire(source, entry);
|
|
6954
|
+
persistSource(root, orphan.profile, source);
|
|
6955
|
+
}
|
|
6956
|
+
commitSourceRules(root, orphan.profile, source, () => {
|
|
6957
|
+
if (source.managedRules) {
|
|
6958
|
+
const path = join9(root, orphan.profile.rulesFile);
|
|
6959
|
+
const plan = planManagedRules(root, orphan.profile, undefined, false, source.managedRules);
|
|
6960
|
+
if (plan.isConflict)
|
|
6961
|
+
summary.skipped.push({ path, reason: "managed rules require explicit resolution: missing baseline or local edits" });
|
|
6962
|
+
else {
|
|
6963
|
+
if (plan.action === "removed") {
|
|
6964
|
+
assertProjectFilePath(root, path);
|
|
6965
|
+
writeFileSync5(path, plan.content);
|
|
6966
|
+
summary.sentinelStripped = true;
|
|
6967
|
+
}
|
|
6968
|
+
delete source.managedRules;
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
});
|
|
6972
|
+
return summary;
|
|
6275
6973
|
}
|
|
6276
6974
|
|
|
6277
6975
|
// src/lib/tool-prompt.ts
|
|
6278
6976
|
async function resolveToolProfile(flagOverride, projectRoot = process.cwd()) {
|
|
6279
|
-
|
|
6280
|
-
await handleToolSwitch(projectRoot, profile);
|
|
6281
|
-
return profile;
|
|
6977
|
+
return resolveProfileOnly(flagOverride, projectRoot);
|
|
6282
6978
|
}
|
|
6283
6979
|
async function resolveProfileOnly(flagOverride, projectRoot) {
|
|
6284
6980
|
if (flagOverride) {
|
|
6285
|
-
const profile =
|
|
6981
|
+
const profile = getToolProfile(flagOverride);
|
|
6286
6982
|
if (!profile) {
|
|
6287
6983
|
throw new Error(`Unknown tool '${flagOverride}'. Supported: ${Object.keys(PROFILES).join(", ")}`);
|
|
6288
6984
|
}
|
|
6289
|
-
const existing = readToolConfig();
|
|
6290
|
-
if (existing?.tool !== flagOverride) {
|
|
6291
|
-
saveToolConfig({ ...existing ?? {}, tool: flagOverride });
|
|
6292
|
-
if (process.stdout.isTTY) {
|
|
6293
|
-
process.stderr.write(`Default tool set to ${profile.displayName}.
|
|
6294
|
-
`);
|
|
6295
|
-
}
|
|
6296
|
-
}
|
|
6297
6985
|
return profile;
|
|
6298
6986
|
}
|
|
6299
6987
|
const config = readToolConfig();
|
|
6300
|
-
if (config?.tool
|
|
6301
|
-
|
|
6988
|
+
if (config?.tool) {
|
|
6989
|
+
const profile = getToolProfile(config.tool);
|
|
6990
|
+
if (profile) {
|
|
6991
|
+
return profile;
|
|
6992
|
+
}
|
|
6302
6993
|
}
|
|
6303
6994
|
if (process.stdout.isTTY) {
|
|
6304
6995
|
const signals = detectTools(projectRoot);
|
|
@@ -6321,11 +7012,25 @@ async function resolveProfileOnly(flagOverride, projectRoot) {
|
|
|
6321
7012
|
ve("Using default (Claude Code).");
|
|
6322
7013
|
return PROFILES[DEFAULT_TOOL];
|
|
6323
7014
|
}
|
|
6324
|
-
saveToolConfig({ ...config ?? {}, tool: choice });
|
|
6325
7015
|
return PROFILES[choice];
|
|
6326
7016
|
}
|
|
6327
7017
|
return PROFILES[DEFAULT_TOOL];
|
|
6328
7018
|
}
|
|
7019
|
+
async function prepareToolForWrite(projectRoot, profile, course, patch = {}, interactive = true) {
|
|
7020
|
+
assertProjectCourse(projectRoot, course);
|
|
7021
|
+
if (interactive && process.stdout.isTTY) {
|
|
7022
|
+
for (const orphan of findOrphanedManifests(projectRoot, profile))
|
|
7023
|
+
preflightProfilePaths(projectRoot, orphan, profile);
|
|
7024
|
+
}
|
|
7025
|
+
establishProjectCourse(projectRoot, course);
|
|
7026
|
+
if (interactive)
|
|
7027
|
+
await handleToolSwitch(projectRoot, profile);
|
|
7028
|
+
const previous = readToolConfig();
|
|
7029
|
+
updateToolConfig({ ...patch, tool: profile.toolId });
|
|
7030
|
+
if (previous?.tool !== profile.toolId && process.stdout.isTTY)
|
|
7031
|
+
process.stderr.write(`Default tool set to ${profile.displayName}.
|
|
7032
|
+
`);
|
|
7033
|
+
}
|
|
6329
7034
|
async function handleToolSwitch(projectRoot, newProfile) {
|
|
6330
7035
|
if (!process.stdout.isTTY)
|
|
6331
7036
|
return;
|
|
@@ -6411,9 +7116,8 @@ function resolveCourseRulesFlag(argv) {
|
|
|
6411
7116
|
return true;
|
|
6412
7117
|
return;
|
|
6413
7118
|
}
|
|
6414
|
-
var DEFAULT_COURSE2 = "10xdevs3";
|
|
6415
7119
|
function registerGetCommand(cli) {
|
|
6416
|
-
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "
|
|
7120
|
+
cli.command("get <ref>", "Fetch and apply a lesson pack").option("--dry-run", "Show what would be written without touching the filesystem").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)").option("--print", "Print artifact content to stdout instead of writing to files").option("--type <type>", "Artifact type filter: skills, prompts, rules, configs").option("--name <name>", "Artifact name filter (requires --type)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (ref, options) => {
|
|
6417
7121
|
const ctx = resolveContext(options);
|
|
6418
7122
|
await runGet(ctx, ref, options);
|
|
6419
7123
|
});
|
|
@@ -6433,30 +7137,29 @@ async function runGet(ctx, ref, options) {
|
|
|
6433
7137
|
if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
|
|
6434
7138
|
outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
|
|
6435
7139
|
}
|
|
6436
|
-
const
|
|
6437
|
-
const
|
|
7140
|
+
const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun && !options.print });
|
|
7141
|
+
const { auth, course } = selection;
|
|
7142
|
+
let release;
|
|
7143
|
+
if (course === "10xdevs4" || course === "10xdevs-4") {
|
|
7144
|
+
const catalog = await fetchCatalog(course, auth.access_token);
|
|
7145
|
+
if (!catalog.ok)
|
|
7146
|
+
handleLessonError(ctx, catalog.status, catalog.code, catalog.error, catalog.payload);
|
|
7147
|
+
release = catalogRelease(catalog.data);
|
|
7148
|
+
}
|
|
6438
7149
|
const profile = await resolveToolProfile(options.tool, process.cwd());
|
|
6439
7150
|
const dryRun = options.dryRun === true;
|
|
6440
7151
|
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
|
|
6441
7152
|
const explicitCourseRules = resolveCourseRulesFlag(process.argv);
|
|
6442
7153
|
const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
|
|
6443
|
-
if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
|
|
6444
|
-
const tool = readToolConfig()?.tool ?? profile.toolId;
|
|
6445
|
-
const patch = { tool };
|
|
6446
|
-
if (options.lang)
|
|
6447
|
-
patch.lang = options.lang;
|
|
6448
|
-
if (explicitCourseRules !== undefined)
|
|
6449
|
-
patch.courseRules = explicitCourseRules;
|
|
6450
|
-
updateToolConfig(patch);
|
|
6451
|
-
}
|
|
6452
7154
|
if (options.print) {
|
|
6453
|
-
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options);
|
|
7155
|
+
await runPrintMode(ctx, parsed.lessonId, course, profile, auth.access_token, lang, options, selection.reason, release);
|
|
6454
7156
|
return;
|
|
6455
7157
|
}
|
|
6456
7158
|
verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
|
|
6457
7159
|
const result = await fetchLesson(course, parsed.lessonId, auth.access_token, {
|
|
6458
7160
|
lang,
|
|
6459
|
-
|
|
7161
|
+
...release ? { release } : {},
|
|
7162
|
+
tool: contentToolId(profile)
|
|
6460
7163
|
});
|
|
6461
7164
|
if (!result.ok) {
|
|
6462
7165
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
@@ -6474,19 +7177,34 @@ async function runGet(ctx, ref, options) {
|
|
|
6474
7177
|
const isFiltered = options.type !== undefined;
|
|
6475
7178
|
const bundle = filterBundle(ctx, result.data, options);
|
|
6476
7179
|
const isTTY = process.stdout.isTTY === true;
|
|
6477
|
-
const prevManifest = readManifest(
|
|
7180
|
+
const prevManifest = readManifest(join10(process.cwd(), profile.manifestDir));
|
|
6478
7181
|
if (prevManifest && prevManifest.manifestVersion === 2) {
|
|
6479
7182
|
showUpgradeNotice(isTTY);
|
|
6480
7183
|
}
|
|
6481
7184
|
const applyCourseRules = isFiltered ? true : resolvedCourseRules;
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
profile,
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
7185
|
+
let writeResult;
|
|
7186
|
+
try {
|
|
7187
|
+
planBundle(bundle, process.cwd(), { course, profile, applyCourseRules });
|
|
7188
|
+
if (!dryRun)
|
|
7189
|
+
await prepareToolForWrite(process.cwd(), profile, course, {
|
|
7190
|
+
...options.lang ? { lang: options.lang } : {},
|
|
7191
|
+
...explicitCourseRules !== undefined ? { courseRules: explicitCourseRules } : {}
|
|
7192
|
+
});
|
|
7193
|
+
writeResult = await applyBundle(bundle, process.cwd(), {
|
|
7194
|
+
course,
|
|
7195
|
+
dryRun,
|
|
7196
|
+
profile,
|
|
7197
|
+
partial: isFiltered,
|
|
7198
|
+
lang: contentLang ?? lang,
|
|
7199
|
+
onConflict: createConflictResolver(isTTY && !dryRun),
|
|
7200
|
+
applyCourseRules
|
|
7201
|
+
});
|
|
7202
|
+
} catch (error) {
|
|
7203
|
+
reportCourseError(ctx, error);
|
|
7204
|
+
}
|
|
6489
7205
|
renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
|
|
7206
|
+
course,
|
|
7207
|
+
selectionReason: selection.reason,
|
|
6490
7208
|
language: contentLang ?? lang,
|
|
6491
7209
|
languageFallback: isFallback,
|
|
6492
7210
|
applyCourseRules
|
|
@@ -6519,7 +7237,10 @@ function filterBundle(ctx, bundle, options) {
|
|
|
6519
7237
|
}
|
|
6520
7238
|
return { ...empty, [type]: bundle[type] };
|
|
6521
7239
|
}
|
|
6522
|
-
async function runPrintMode(ctx, lessonId, course, profile, token, lang, options) {
|
|
7240
|
+
async function runPrintMode(ctx, lessonId, course, profile, token, lang, options, selectionReason, release) {
|
|
7241
|
+
if (!ctx.json)
|
|
7242
|
+
process.stderr.write(`Course: ${course} (${selectionReason})
|
|
7243
|
+
`);
|
|
6523
7244
|
if (!options.type) {
|
|
6524
7245
|
outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
|
|
6525
7246
|
}
|
|
@@ -6528,12 +7249,12 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6528
7249
|
}
|
|
6529
7250
|
if (options.name) {
|
|
6530
7251
|
verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
|
|
6531
|
-
const result = await fetchArtifact(course, lessonId, options.type, options.name, profile
|
|
7252
|
+
const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang, ...release ? { release } : {} });
|
|
6532
7253
|
if (!result.ok) {
|
|
6533
7254
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
6534
7255
|
}
|
|
6535
7256
|
if (ctx.json) {
|
|
6536
|
-
output(ctx, "", result.data);
|
|
7257
|
+
output(ctx, "", { ...result.data, course, selectionReason });
|
|
6537
7258
|
} else {
|
|
6538
7259
|
const data = result.data;
|
|
6539
7260
|
if (data.type === "skills") {
|
|
@@ -6546,7 +7267,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6546
7267
|
}
|
|
6547
7268
|
} else {
|
|
6548
7269
|
verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
|
|
6549
|
-
const result = await fetchLesson(course, lessonId, token, { lang, tool: profile
|
|
7270
|
+
const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile), ...release ? { release } : {} });
|
|
6550
7271
|
if (!result.ok) {
|
|
6551
7272
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
6552
7273
|
}
|
|
@@ -6554,7 +7275,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6554
7275
|
if (type === "skills") {
|
|
6555
7276
|
const skills = result.data.skills;
|
|
6556
7277
|
if (ctx.json) {
|
|
6557
|
-
output(ctx, "", skills);
|
|
7278
|
+
output(ctx, "", { course, selectionReason, artifacts: skills });
|
|
6558
7279
|
} else {
|
|
6559
7280
|
const contents = skills.map((s) => s.files.find((f2) => f2.path === "SKILL.md")?.content ?? "");
|
|
6560
7281
|
process.stdout.write(contents.join(`
|
|
@@ -6566,7 +7287,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6566
7287
|
} else {
|
|
6567
7288
|
const artifacts = result.data[type];
|
|
6568
7289
|
if (ctx.json) {
|
|
6569
|
-
output(ctx, "", artifacts);
|
|
7290
|
+
output(ctx, "", { course, selectionReason, artifacts });
|
|
6570
7291
|
} else {
|
|
6571
7292
|
const contents = artifacts.map((a2) => a2.content);
|
|
6572
7293
|
process.stdout.write(contents.join(`
|
|
@@ -6586,7 +7307,9 @@ function emitMultiFileSkillNotice(skillName, files) {
|
|
|
6586
7307
|
}
|
|
6587
7308
|
function handleLessonError(ctx, status, code, error, payload) {
|
|
6588
7309
|
verbose(ctx, `lesson fetch failed: status=${status} code=${code}`);
|
|
6589
|
-
if (
|
|
7310
|
+
if (code === "course_access_denied" || code === "course_unavailable")
|
|
7311
|
+
outputError(ctx, code, error, code === "course_access_denied" ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
|
|
7312
|
+
if (status === 403 && code === "module_locked") {
|
|
6590
7313
|
const moduleNum = payload?.["module"];
|
|
6591
7314
|
const releaseAt = payload?.["releaseAt"];
|
|
6592
7315
|
const hasModule = typeof moduleNum === "number";
|
|
@@ -6609,7 +7332,7 @@ function handleLessonError(ctx, status, code, error, payload) {
|
|
|
6609
7332
|
if (status === 401) {
|
|
6610
7333
|
outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
6611
7334
|
}
|
|
6612
|
-
if (code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
|
|
7335
|
+
if (code === "release_mismatch" || code === "signature_error" || code === "signature_missing" || code === "signature_internal_error") {
|
|
6613
7336
|
outputError(ctx, code, error, ExitCodes.ERROR);
|
|
6614
7337
|
}
|
|
6615
7338
|
if (status === 0) {
|
|
@@ -6630,14 +7353,18 @@ function formatAction(action) {
|
|
|
6630
7353
|
}
|
|
6631
7354
|
}
|
|
6632
7355
|
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
7356
|
+
course: "10xdevs3",
|
|
7357
|
+
selectionReason: "backend_recommendation",
|
|
6633
7358
|
language: "en",
|
|
6634
7359
|
languageFallback: false,
|
|
6635
7360
|
applyCourseRules: true
|
|
6636
7361
|
}) {
|
|
6637
7362
|
const applyCourseRules = langMeta.applyCourseRules;
|
|
6638
|
-
const totalRemovals = writeResult.removals.skills
|
|
7363
|
+
const totalRemovals = [...writeResult.removals.skills, ...writeResult.removals.prompts, ...writeResult.removals.configs].filter((entry) => entry.action === "removed").length;
|
|
6639
7364
|
if (ctx.json) {
|
|
6640
7365
|
output(ctx, "", {
|
|
7366
|
+
course: langMeta.course,
|
|
7367
|
+
selectionReason: langMeta.selectionReason,
|
|
6641
7368
|
lessonId: bundle.lessonId,
|
|
6642
7369
|
title: bundle.title,
|
|
6643
7370
|
summary: bundle.summary,
|
|
@@ -6664,6 +7391,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6664
7391
|
}
|
|
6665
7392
|
const targetDir = profile.manifestDir;
|
|
6666
7393
|
const lines = [];
|
|
7394
|
+
lines.push(`Course: ${langMeta.course} (${langMeta.selectionReason})`);
|
|
6667
7395
|
lines.push(`${bundle.lessonId} — ${bundle.title}`);
|
|
6668
7396
|
if (bundle.summary)
|
|
6669
7397
|
lines.push(bundle.summary);
|
|
@@ -6684,7 +7412,9 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6684
7412
|
lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
|
|
6685
7413
|
}
|
|
6686
7414
|
if (!applyCourseRules) {
|
|
6687
|
-
if (writeResult.rules.action === "
|
|
7415
|
+
if (writeResult.rules.action === "conflict_skipped") {
|
|
7416
|
+
lines.push(` [conflict: skipped] rules ${profile.rulesFile} (${writeResult.rules.reason ?? "explicit resolution required"})`);
|
|
7417
|
+
} else if (writeResult.rules.action === "removed") {
|
|
6688
7418
|
lines.push(` [removed] rules ${profile.rulesFile}`);
|
|
6689
7419
|
} else {
|
|
6690
7420
|
verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
|
|
@@ -6696,56 +7426,66 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6696
7426
|
lines.push(` [${config.action}] config ${config.path}`);
|
|
6697
7427
|
}
|
|
6698
7428
|
for (const entry of writeResult.removals.skills) {
|
|
6699
|
-
lines.push(` [
|
|
7429
|
+
lines.push(` [${entry.action}] skill ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6700
7430
|
}
|
|
6701
7431
|
for (const entry of writeResult.removals.prompts) {
|
|
6702
|
-
lines.push(` [
|
|
7432
|
+
lines.push(` [${entry.action}] prompt ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6703
7433
|
}
|
|
6704
7434
|
for (const entry of writeResult.removals.configs) {
|
|
6705
|
-
lines.push(` [
|
|
7435
|
+
lines.push(` [${entry.action}] config ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6706
7436
|
}
|
|
6707
7437
|
output(ctx, lines.join(`
|
|
6708
7438
|
`), undefined);
|
|
6709
7439
|
}
|
|
6710
7440
|
|
|
6711
7441
|
// src/commands/list.ts
|
|
6712
|
-
var DEFAULT_COURSE3 = "10xdevs3";
|
|
6713
7442
|
function registerListCommand(cli) {
|
|
6714
|
-
cli.command("list [module]", "Browse available modules and lessons").option("--course <course>", "
|
|
7443
|
+
cli.command("list [module]", "Browse available modules and lessons").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").action(async (moduleArg, options) => {
|
|
6715
7444
|
const ctx = resolveContext(options);
|
|
6716
7445
|
await runList(ctx, moduleArg, options);
|
|
6717
7446
|
});
|
|
6718
7447
|
}
|
|
6719
7448
|
async function runList(ctx, moduleArg, options) {
|
|
6720
|
-
const
|
|
6721
|
-
const
|
|
7449
|
+
const selection = await resolveCourseSelection(ctx, { explicit: options.course });
|
|
7450
|
+
const { auth, course } = selection;
|
|
6722
7451
|
if (moduleArg === undefined) {
|
|
6723
|
-
await listAllModules(ctx, course, auth.access_token);
|
|
7452
|
+
await listAllModules(ctx, course, auth.access_token, selection.reason);
|
|
6724
7453
|
return;
|
|
6725
7454
|
}
|
|
6726
7455
|
const module = parseModuleRef(moduleArg);
|
|
6727
7456
|
if (module === null) {
|
|
6728
7457
|
outputError(ctx, "invalid_module", `'${moduleArg}' is not a valid module reference.`, ExitCodes.USAGE, `Pass a module between ${MIN_MODULE} and ${MAX_MODULE}, for example '10x list 1' or '10x list m1'.`);
|
|
6729
7458
|
}
|
|
6730
|
-
await listModuleDetail(ctx, course, module, auth.access_token);
|
|
7459
|
+
await listModuleDetail(ctx, course, module, auth.access_token, selection.reason);
|
|
6731
7460
|
}
|
|
6732
|
-
async function listAllModules(ctx, course, token) {
|
|
7461
|
+
async function listAllModules(ctx, course, token, reason) {
|
|
6733
7462
|
verbose(ctx, `fetching catalog for ${course}`);
|
|
6734
7463
|
const result = await fetchCatalog(course, token);
|
|
6735
7464
|
if (!result.ok) {
|
|
6736
7465
|
handleListError(ctx, result.status, result.code, result.error);
|
|
6737
7466
|
}
|
|
6738
|
-
renderCatalog(ctx, result.data);
|
|
7467
|
+
renderCatalog(ctx, result.data, reason);
|
|
6739
7468
|
}
|
|
6740
|
-
async function listModuleDetail(ctx, course, module, token) {
|
|
7469
|
+
async function listModuleDetail(ctx, course, module, token, reason) {
|
|
6741
7470
|
verbose(ctx, `fetching module detail ${course}/${module}`);
|
|
6742
|
-
|
|
7471
|
+
let release;
|
|
7472
|
+
if (course === "10xdevs4" || course === "10xdevs-4") {
|
|
7473
|
+
const catalog = await fetchCatalog(course, token);
|
|
7474
|
+
if (!catalog.ok)
|
|
7475
|
+
handleListError(ctx, catalog.status, catalog.code, catalog.error);
|
|
7476
|
+
release = catalogRelease(catalog.data);
|
|
7477
|
+
}
|
|
7478
|
+
const result = await fetchModuleDetail(course, module, token, release ? { release } : {});
|
|
6743
7479
|
if (!result.ok) {
|
|
6744
7480
|
handleListError(ctx, result.status, result.code, result.error);
|
|
6745
7481
|
}
|
|
6746
|
-
renderModuleDetail(ctx, result.data);
|
|
7482
|
+
renderModuleDetail(ctx, result.data, course, reason);
|
|
6747
7483
|
}
|
|
6748
7484
|
function handleListError(ctx, status, code, error) {
|
|
7485
|
+
if (["course_access_denied", "module_locked", "course_unavailable"].includes(code))
|
|
7486
|
+
outputError(ctx, code, error, status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
|
|
7487
|
+
if (code === "release_mismatch")
|
|
7488
|
+
outputError(ctx, code, error, ExitCodes.ERROR);
|
|
6749
7489
|
if (status === 404) {
|
|
6750
7490
|
outputError(ctx, "not_found", "Couldn't find that course or module.", ExitCodes.NOT_FOUND, "Run '10x list' to see available modules.");
|
|
6751
7491
|
}
|
|
@@ -6757,7 +7497,7 @@ function handleListError(ctx, status, code, error) {
|
|
|
6757
7497
|
}
|
|
6758
7498
|
outputError(ctx, code || "list_failed", "Failed to load the catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
6759
7499
|
}
|
|
6760
|
-
function renderCatalog(ctx, catalog) {
|
|
7500
|
+
function renderCatalog(ctx, catalog, reason) {
|
|
6761
7501
|
const lessonCountByModule = new Map;
|
|
6762
7502
|
for (const lesson of catalog.lessons) {
|
|
6763
7503
|
lessonCountByModule.set(lesson.module, (lessonCountByModule.get(lesson.module) ?? 0) + 1);
|
|
@@ -6765,6 +7505,7 @@ function renderCatalog(ctx, catalog) {
|
|
|
6765
7505
|
if (ctx.json) {
|
|
6766
7506
|
output(ctx, "", {
|
|
6767
7507
|
course: catalog.course,
|
|
7508
|
+
selectionReason: reason,
|
|
6768
7509
|
modules: catalog.modules.map((m3) => ({
|
|
6769
7510
|
module: m3.module,
|
|
6770
7511
|
title: m3.title,
|
|
@@ -6781,7 +7522,7 @@ function renderCatalog(ctx, catalog) {
|
|
|
6781
7522
|
}
|
|
6782
7523
|
const exampleModule = catalog.modules.find((m3) => m3.effectiveState === "unlocked")?.module ?? catalog.modules[0]?.module ?? 0;
|
|
6783
7524
|
const lines = [];
|
|
6784
|
-
lines.push(`Course: ${catalog.course}`);
|
|
7525
|
+
lines.push(`Course: ${catalog.course} (${reason})`);
|
|
6785
7526
|
lines.push("");
|
|
6786
7527
|
for (const m3 of catalog.modules) {
|
|
6787
7528
|
lines.push(formatModuleRow(m3, lessonCountByModule.get(m3.module) ?? 0));
|
|
@@ -6797,10 +7538,12 @@ function formatModuleRow(m3, lessonCount) {
|
|
|
6797
7538
|
const label = `Module ${m3.module}: ${m3.title}`;
|
|
6798
7539
|
return ` ${icon} ${label} — ${lessonCount} lesson${lessonCount === 1 ? "" : "s"} [${state}]`;
|
|
6799
7540
|
}
|
|
6800
|
-
function renderModuleDetail(ctx, module) {
|
|
7541
|
+
function renderModuleDetail(ctx, module, course, reason) {
|
|
6801
7542
|
const isLocked = module.effectiveState === "locked";
|
|
6802
7543
|
if (ctx.json) {
|
|
6803
7544
|
output(ctx, "", {
|
|
7545
|
+
course,
|
|
7546
|
+
selectionReason: reason,
|
|
6804
7547
|
module: module.module,
|
|
6805
7548
|
title: module.title,
|
|
6806
7549
|
state: module.effectiveState,
|
|
@@ -6817,6 +7560,7 @@ function renderModuleDetail(ctx, module) {
|
|
|
6817
7560
|
}
|
|
6818
7561
|
const stateLabel = isLocked ? `locked — unlocks ${formatReleaseAt(module.releaseAt)}` : "unlocked";
|
|
6819
7562
|
const lines = [];
|
|
7563
|
+
lines.push(`Course: ${course} (${reason})`);
|
|
6820
7564
|
lines.push(`Module ${module.module}: ${module.title} [${stateLabel}]`);
|
|
6821
7565
|
lines.push("");
|
|
6822
7566
|
if (isLocked) {
|
|
@@ -6844,11 +7588,53 @@ function renderModuleDetail(ctx, module) {
|
|
|
6844
7588
|
}
|
|
6845
7589
|
|
|
6846
7590
|
// src/commands/sync.ts
|
|
6847
|
-
import { join as
|
|
6848
|
-
|
|
7591
|
+
import { join as join12 } from "node:path";
|
|
7592
|
+
|
|
7593
|
+
// src/lib/sync-freshness.ts
|
|
7594
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
|
|
7595
|
+
import { join as join11 } from "node:path";
|
|
7596
|
+
function isLessonFresh(root, manifest, id, profile, lang, courseRules) {
|
|
7597
|
+
const lesson = manifest?.lessons?.[id];
|
|
7598
|
+
const representation = lesson?.representation;
|
|
7599
|
+
if (!manifest || !lesson || !representation || representation.lang !== lang || representation.tool !== profile.toolId || representation.courseRules !== courseRules || typeof representation.rules !== "boolean")
|
|
7600
|
+
return false;
|
|
7601
|
+
const matches = (path, hash) => {
|
|
7602
|
+
assertProjectFilePath(root, path);
|
|
7603
|
+
return !!hash && existsSync10(path) && contentHash(readFileSync9(path)) === hash;
|
|
7604
|
+
};
|
|
7605
|
+
try {
|
|
7606
|
+
for (const [name, skill] of Object.entries(lesson.skills)) {
|
|
7607
|
+
if (!isSafeName(name))
|
|
7608
|
+
return false;
|
|
7609
|
+
for (const file of skill.files)
|
|
7610
|
+
if (!isSafeSkillFilePath(file) || !matches(join11(root, profile.skillDir(name), file), manifest.files.skills[name]?.contentHashes?.[file]))
|
|
7611
|
+
return false;
|
|
7612
|
+
}
|
|
7613
|
+
for (const name of lesson.prompts)
|
|
7614
|
+
if (!isSafeName(name) || !matches(join11(root, profile.promptPath(name.replace(/\.md$/, ""))), manifest.files.promptHashes?.[name]))
|
|
7615
|
+
return false;
|
|
7616
|
+
for (const name of lesson.configs)
|
|
7617
|
+
if (!isSafeName(name) || !matches(join11(root, profile.configPath(name)), manifest.files.configHashes?.[name]))
|
|
7618
|
+
return false;
|
|
7619
|
+
const rulesPath = join11(root, profile.rulesFile);
|
|
7620
|
+
assertProjectFilePath(root, rulesPath);
|
|
7621
|
+
const existing = existsSync10(rulesPath) ? readFileSync9(rulesPath, "utf8") : "";
|
|
7622
|
+
const block = inspectRulesBlock(existing, profile.sentinelBegin, profile.sentinelEnd);
|
|
7623
|
+
if (representation.rules) {
|
|
7624
|
+
const rules = manifest.managedRules;
|
|
7625
|
+
if (!rules || rules.path !== profile.rulesFile || rules.begin !== profile.sentinelBegin || rules.end !== profile.sentinelEnd || !block || contentHash(block.text) !== rules.upstreamHash)
|
|
7626
|
+
return false;
|
|
7627
|
+
}
|
|
7628
|
+
return true;
|
|
7629
|
+
} catch {
|
|
7630
|
+
return false;
|
|
7631
|
+
}
|
|
7632
|
+
}
|
|
7633
|
+
|
|
7634
|
+
// src/commands/sync.ts
|
|
6849
7635
|
var SUPPORTED_LANGS = ["en", "pl"];
|
|
6850
7636
|
function registerSyncCommand(cli) {
|
|
6851
|
-
cli.command("sync", "Bulk-download and update lessons, reporting what changed upstream").option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)").option("--dry-run", "Show what would change without writing").option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream").option("--module <module>", "Limit to one module (e.g. 'm2' or '2')").option("--course <course>", "
|
|
7637
|
+
cli.command("sync", "Bulk-download and update lessons, reporting what changed upstream").option("--all", "Sync every unlocked lesson (default: only lessons you've already downloaded)").option("--dry-run", "Show what would change without writing").option("--force", "Ignore the cheap-skip digest and overwrite local edits with upstream").option("--module <module>", "Limit to one module (e.g. 'm2' or '2')").option("--course <course>", "Select course ID or slug (default: project edition or API recommendation)").option("--tool <tool>", "AI coding tool (claude-code, cursor, copilot, codex, devin-desktop, gemini, generic)").option("--lang <lang>", "Content language: en (default) or pl").option("--no-course-rules", "Skip applying the course rules block to your rules file (CLAUDE.md/AGENTS.md)").action(async (options) => {
|
|
6852
7638
|
const ctx = resolveContext(options);
|
|
6853
7639
|
await runSync(ctx, options);
|
|
6854
7640
|
});
|
|
@@ -6865,8 +7651,8 @@ async function runSync(ctx, options) {
|
|
|
6865
7651
|
}
|
|
6866
7652
|
moduleFilter = parsed;
|
|
6867
7653
|
}
|
|
6868
|
-
const
|
|
6869
|
-
const
|
|
7654
|
+
const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun });
|
|
7655
|
+
const { auth, course } = selection;
|
|
6870
7656
|
const profile = await resolveToolProfile(options.tool, process.cwd());
|
|
6871
7657
|
const dryRun = options.dryRun === true;
|
|
6872
7658
|
const force = options.force === true;
|
|
@@ -6879,6 +7665,7 @@ async function runSync(ctx, options) {
|
|
|
6879
7665
|
handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
|
|
6880
7666
|
}
|
|
6881
7667
|
const catalog = catalogResult.data;
|
|
7668
|
+
const release = catalogRelease(catalog);
|
|
6882
7669
|
const moduleState = new Map;
|
|
6883
7670
|
for (const mod of catalog.modules)
|
|
6884
7671
|
moduleState.set(mod.module, mod.effectiveState);
|
|
@@ -6890,8 +7677,8 @@ async function runSync(ctx, options) {
|
|
|
6890
7677
|
}
|
|
6891
7678
|
return true;
|
|
6892
7679
|
});
|
|
6893
|
-
const manifest = readManifest(
|
|
6894
|
-
const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : []);
|
|
7680
|
+
const manifest = readManifest(join12(process.cwd(), profile.manifestDir));
|
|
7681
|
+
const manifestLessonIds = new Set(manifest?.lessons ? Object.keys(manifest.lessons) : manifest ? [manifest.lessonId] : []);
|
|
6895
7682
|
let targets = unlocked;
|
|
6896
7683
|
if (moduleFilter !== undefined)
|
|
6897
7684
|
targets = targets.filter((l2) => l2.module === moduleFilter);
|
|
@@ -6902,12 +7689,24 @@ async function runSync(ctx, options) {
|
|
|
6902
7689
|
const onSigint = () => controller.abort();
|
|
6903
7690
|
process.once("SIGINT", onSigint);
|
|
6904
7691
|
const outcomes = [];
|
|
7692
|
+
let prepared = false;
|
|
7693
|
+
const beforeWrite = async () => {
|
|
7694
|
+
if (prepared)
|
|
7695
|
+
return;
|
|
7696
|
+
await prepareToolForWrite(process.cwd(), profile, course, {
|
|
7697
|
+
...options.lang ? { lang: options.lang } : {},
|
|
7698
|
+
...explicitCourseRules !== undefined ? { courseRules: explicitCourseRules } : {}
|
|
7699
|
+
}, false);
|
|
7700
|
+
prepared = true;
|
|
7701
|
+
};
|
|
6905
7702
|
try {
|
|
6906
7703
|
for (const lesson of targets) {
|
|
6907
7704
|
if (controller.signal.aborted)
|
|
6908
7705
|
break;
|
|
6909
7706
|
outcomes.push(await syncLesson(ctx, lesson, {
|
|
6910
7707
|
course,
|
|
7708
|
+
beforeWrite,
|
|
7709
|
+
release,
|
|
6911
7710
|
profile,
|
|
6912
7711
|
lang,
|
|
6913
7712
|
dryRun,
|
|
@@ -6923,6 +7722,7 @@ async function runSync(ctx, options) {
|
|
|
6923
7722
|
}
|
|
6924
7723
|
renderReport(ctx, profile, {
|
|
6925
7724
|
course,
|
|
7725
|
+
selectionReason: selection.reason,
|
|
6926
7726
|
dryRun,
|
|
6927
7727
|
force,
|
|
6928
7728
|
mode: options.all === true ? "all" : "downloaded",
|
|
@@ -6935,8 +7735,9 @@ async function runSync(ctx, options) {
|
|
|
6935
7735
|
}
|
|
6936
7736
|
}
|
|
6937
7737
|
async function syncLesson(ctx, lesson, opts) {
|
|
6938
|
-
const
|
|
6939
|
-
|
|
7738
|
+
const currentManifest = readManifest(join12(process.cwd(), opts.profile.manifestDir));
|
|
7739
|
+
const stored = currentManifest?.lessons?.[lesson.lessonId]?.catalogContentHash;
|
|
7740
|
+
if (!opts.force && lesson.contentHash !== undefined && stored !== undefined && lesson.contentHash === stored && isLessonFresh(process.cwd(), currentManifest, lesson.lessonId, opts.profile, opts.lang, opts.applyCourseRules)) {
|
|
6940
7741
|
verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
|
|
6941
7742
|
return {
|
|
6942
7743
|
lessonId: lesson.lessonId,
|
|
@@ -6949,8 +7750,9 @@ async function syncLesson(ctx, lesson, opts) {
|
|
|
6949
7750
|
verbose(ctx, `${lesson.lessonId}: fetching`);
|
|
6950
7751
|
const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
|
|
6951
7752
|
lang: opts.lang,
|
|
6952
|
-
tool: opts.profile
|
|
6953
|
-
signal: opts.signal
|
|
7753
|
+
tool: contentToolId(opts.profile),
|
|
7754
|
+
signal: opts.signal,
|
|
7755
|
+
...opts.release ? { release: opts.release } : {}
|
|
6954
7756
|
});
|
|
6955
7757
|
if (!result.ok) {
|
|
6956
7758
|
return {
|
|
@@ -6981,14 +7783,23 @@ async function syncLesson(ctx, lesson, opts) {
|
|
|
6981
7783
|
resources: resources2
|
|
6982
7784
|
};
|
|
6983
7785
|
}
|
|
6984
|
-
const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
profile: opts.profile,
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
7786
|
+
const onConflict = opts.force ? async (info) => info.artifactType === "rules" ? "skip" : "overwrite" : async () => "skip";
|
|
7787
|
+
let writeResult;
|
|
7788
|
+
try {
|
|
7789
|
+
planBundle(bundle, process.cwd(), { course: opts.course, profile: opts.profile, applyCourseRules: opts.applyCourseRules });
|
|
7790
|
+
await opts.beforeWrite();
|
|
7791
|
+
writeResult = await applyBundle(bundle, process.cwd(), {
|
|
7792
|
+
course: opts.course,
|
|
7793
|
+
profile: opts.profile,
|
|
7794
|
+
onConflict,
|
|
7795
|
+
applyCourseRules: opts.applyCourseRules,
|
|
7796
|
+
catalogContentHash: lesson.contentHash,
|
|
7797
|
+
lang: result.responseHeaders.get("X-Content-Language") ?? opts.lang
|
|
7798
|
+
});
|
|
7799
|
+
} catch (error) {
|
|
7800
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "write_failed";
|
|
7801
|
+
return { lessonId: lesson.lessonId, title: lesson.title, fetched: true, status: "errored", resources: [], error: { code, message: error instanceof Error ? error.message : String(error), retry: `10x get ${lesson.lessonId}` } };
|
|
7802
|
+
}
|
|
6992
7803
|
const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
|
|
6993
7804
|
return {
|
|
6994
7805
|
lessonId: lesson.lessonId,
|
|
@@ -7006,6 +7817,8 @@ function actionToBucket(action) {
|
|
|
7006
7817
|
case "conflict_overwritten":
|
|
7007
7818
|
case "conflict_saved_user":
|
|
7008
7819
|
return "upstream-updated";
|
|
7820
|
+
case "preserved_local":
|
|
7821
|
+
return "preserved-local";
|
|
7009
7822
|
case "conflict_skipped":
|
|
7010
7823
|
return "skipped-conflict";
|
|
7011
7824
|
case "removed":
|
|
@@ -7042,18 +7855,18 @@ function classifyFromWriteResult(result, lessonId) {
|
|
|
7042
7855
|
...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
|
|
7043
7856
|
});
|
|
7044
7857
|
}
|
|
7045
|
-
out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action) });
|
|
7858
|
+
out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(result.rules.action), remediation: `10x get ${lessonId} --type rules` });
|
|
7046
7859
|
for (const config of result.configs) {
|
|
7047
7860
|
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
|
|
7048
7861
|
}
|
|
7049
7862
|
for (const entry of result.removals.skills) {
|
|
7050
|
-
out.push({ type: "skills", name: entry.name, bucket:
|
|
7863
|
+
out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7051
7864
|
}
|
|
7052
7865
|
for (const entry of result.removals.prompts) {
|
|
7053
|
-
out.push({ type: "prompts", name: entry.name, bucket:
|
|
7866
|
+
out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7054
7867
|
}
|
|
7055
7868
|
for (const entry of result.removals.configs) {
|
|
7056
|
-
out.push({ type: "configs", name: entry.name, bucket:
|
|
7869
|
+
out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7057
7870
|
}
|
|
7058
7871
|
return out;
|
|
7059
7872
|
}
|
|
@@ -7081,18 +7894,18 @@ function classifyFromPlan(plan, lessonId, force) {
|
|
|
7081
7894
|
...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
|
|
7082
7895
|
});
|
|
7083
7896
|
}
|
|
7084
|
-
out.push({ type: "rules", name: "course-rules", bucket: actionToBucket(plan.rules.action) });
|
|
7897
|
+
out.push({ type: "rules", name: "course-rules", bucket: plan.rules.isConflict ? "skipped-conflict" : actionToBucket(plan.rules.action), remediation: `10x get ${lessonId} --type rules` });
|
|
7085
7898
|
for (const config of plan.configs) {
|
|
7086
7899
|
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
|
|
7087
7900
|
}
|
|
7088
7901
|
for (const entry of plan.removals.skills) {
|
|
7089
|
-
out.push({ type: "skills", name: entry.name, bucket:
|
|
7902
|
+
out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7090
7903
|
}
|
|
7091
7904
|
for (const entry of plan.removals.prompts) {
|
|
7092
|
-
out.push({ type: "prompts", name: entry.name, bucket:
|
|
7905
|
+
out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7093
7906
|
}
|
|
7094
7907
|
for (const entry of plan.removals.configs) {
|
|
7095
|
-
out.push({ type: "configs", name: entry.name, bucket:
|
|
7908
|
+
out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7096
7909
|
}
|
|
7097
7910
|
return out;
|
|
7098
7911
|
}
|
|
@@ -7110,7 +7923,8 @@ function countBuckets(outcomes) {
|
|
|
7110
7923
|
"upstream-updated": 0,
|
|
7111
7924
|
unchanged: 0,
|
|
7112
7925
|
"skipped-conflict": 0,
|
|
7113
|
-
removed: 0
|
|
7926
|
+
removed: 0,
|
|
7927
|
+
"preserved-local": 0
|
|
7114
7928
|
};
|
|
7115
7929
|
for (const o of outcomes) {
|
|
7116
7930
|
for (const r2 of o.resources)
|
|
@@ -7126,6 +7940,7 @@ function renderReport(ctx, profile, input) {
|
|
|
7126
7940
|
if (ctx.json) {
|
|
7127
7941
|
output(ctx, "", {
|
|
7128
7942
|
course: input.course,
|
|
7943
|
+
selectionReason: input.selectionReason,
|
|
7129
7944
|
tool: profile.toolId,
|
|
7130
7945
|
dryRun: input.dryRun,
|
|
7131
7946
|
force: input.force,
|
|
@@ -7145,7 +7960,8 @@ function renderReport(ctx, profile, input) {
|
|
|
7145
7960
|
upstreamUpdated: buckets["upstream-updated"],
|
|
7146
7961
|
unchanged: buckets.unchanged,
|
|
7147
7962
|
skippedConflict: buckets["skipped-conflict"],
|
|
7148
|
-
removed: buckets.removed
|
|
7963
|
+
removed: buckets.removed,
|
|
7964
|
+
preservedLocal: buckets["preserved-local"]
|
|
7149
7965
|
}
|
|
7150
7966
|
}
|
|
7151
7967
|
});
|
|
@@ -7155,7 +7971,7 @@ function renderReport(ctx, profile, input) {
|
|
|
7155
7971
|
const verb = input.dryRun ? "Would sync" : "Synced";
|
|
7156
7972
|
const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
|
|
7157
7973
|
const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
|
|
7158
|
-
lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
|
|
7974
|
+
lines.push(`${verb} ${input.course} (${input.selectionReason}) — ${scope}${moduleNote}:`);
|
|
7159
7975
|
if (outcomes.length === 0) {
|
|
7160
7976
|
lines.push(" (nothing to sync)");
|
|
7161
7977
|
}
|
|
@@ -7169,6 +7985,10 @@ function renderReport(ctx, profile, input) {
|
|
|
7169
7985
|
const summary = summarizeLesson(o);
|
|
7170
7986
|
lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
|
|
7171
7987
|
for (const r2 of o.resources) {
|
|
7988
|
+
if (r2.bucket === "preserved-local") {
|
|
7989
|
+
lines.push(` preserved ${r2.type}/${r2.name} — ${r2.reason ?? "local file"}`);
|
|
7990
|
+
continue;
|
|
7991
|
+
}
|
|
7172
7992
|
if (r2.bucket !== "skipped-conflict")
|
|
7173
7993
|
continue;
|
|
7174
7994
|
const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
|
|
@@ -7182,9 +8002,9 @@ function renderReport(ctx, profile, input) {
|
|
|
7182
8002
|
lines.push(` ${ex.lessonId} — ${ex.reason}`);
|
|
7183
8003
|
}
|
|
7184
8004
|
lines.push("");
|
|
7185
|
-
lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed.`);
|
|
8005
|
+
lines.push(`Totals: ${buckets["upstream-updated"]} updated, ${buckets.created} new, ` + `${buckets.unchanged} unchanged, ${buckets["skipped-conflict"]} skipped (conflicts), ` + `${buckets.removed} removed, ${buckets["preserved-local"]} preserved locally.`);
|
|
7186
8006
|
if (lessonsConflicts > 0 && !input.force) {
|
|
7187
|
-
lines.push("To
|
|
8007
|
+
lines.push("To replace edited skills and prompts: 10x sync --force. Resolve rule conflicts with 10x get <lesson> --type rules.");
|
|
7188
8008
|
}
|
|
7189
8009
|
output(ctx, lines.join(`
|
|
7190
8010
|
`), undefined);
|
|
@@ -7195,7 +8015,8 @@ function summarizeLesson(o) {
|
|
|
7195
8015
|
"upstream-updated": 0,
|
|
7196
8016
|
unchanged: 0,
|
|
7197
8017
|
"skipped-conflict": 0,
|
|
7198
|
-
removed: 0
|
|
8018
|
+
removed: 0,
|
|
8019
|
+
"preserved-local": 0
|
|
7199
8020
|
};
|
|
7200
8021
|
for (const r2 of o.resources)
|
|
7201
8022
|
counts[r2.bucket]++;
|
|
@@ -7213,6 +8034,10 @@ function summarizeLesson(o) {
|
|
|
7213
8034
|
return parts.join(", ");
|
|
7214
8035
|
}
|
|
7215
8036
|
function handleCatalogError(ctx, status, code, error) {
|
|
8037
|
+
if (["course_access_denied", "module_locked", "course_unavailable"].includes(code))
|
|
8038
|
+
outputError(ctx, code, error, status === 403 ? ExitCodes.FORBIDDEN : ExitCodes.ERROR);
|
|
8039
|
+
if (code === "release_mismatch")
|
|
8040
|
+
outputError(ctx, code, error, ExitCodes.ERROR);
|
|
7216
8041
|
if (status === 401) {
|
|
7217
8042
|
outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
7218
8043
|
}
|
|
@@ -7225,7 +8050,11 @@ function handleCatalogError(ctx, status, code, error) {
|
|
|
7225
8050
|
outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
7226
8051
|
}
|
|
7227
8052
|
function lessonErrorMessage(status, code, error) {
|
|
7228
|
-
if (
|
|
8053
|
+
if (code === "release_mismatch")
|
|
8054
|
+
return error;
|
|
8055
|
+
if (code === "course_access_denied" || code === "course_unavailable")
|
|
8056
|
+
return error;
|
|
8057
|
+
if (status === 403 && code === "module_locked")
|
|
7229
8058
|
return "Module is locked.";
|
|
7230
8059
|
if (status === 404)
|
|
7231
8060
|
return "Lesson not found.";
|