@przeprogramowani/10x-cli 1.20.0 → 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 +53 -11
- package/dist/index.mjs +2063 -1296
- package/package.json +1 -1
- package/skills/10x-cli-guide/SKILL.md +44 -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,169 +3799,597 @@ async function requireAuth(ctx, options = {}) {
|
|
|
3690
3799
|
throw new Error("requireAuth: unreachable");
|
|
3691
3800
|
}
|
|
3692
3801
|
|
|
3693
|
-
// src/
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
if (options.logout) {
|
|
3702
|
-
runLogout(ctx);
|
|
3703
|
-
return;
|
|
3704
|
-
}
|
|
3705
|
-
await runLogin(ctx, options);
|
|
3706
|
-
});
|
|
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
|
+
}
|
|
3707
3810
|
}
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3811
|
+
var KEYSET = [
|
|
3812
|
+
{
|
|
3813
|
+
keyId: 1,
|
|
3814
|
+
publicKey: "MCowBQYDK2VwAyEANXEwq1y+9CYAJS6ze9m/c212fL4r1BauXwKdT6Zo5Ko="
|
|
3712
3815
|
}
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
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;
|
|
3717
3830
|
}
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
expires_at: auth.expires_at,
|
|
3721
|
-
is_valid: true
|
|
3722
|
-
});
|
|
3723
|
-
}
|
|
3724
|
-
function formatExpiry(expiresAt) {
|
|
3725
|
-
if (!Number.isFinite(expiresAt.getTime()))
|
|
3726
|
-
return "at an unknown time";
|
|
3727
|
-
const diffMs = expiresAt.getTime() - Date.now();
|
|
3728
|
-
if (diffMs <= 0)
|
|
3729
|
-
return "now";
|
|
3730
|
-
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
3731
|
-
if (days >= 1)
|
|
3732
|
-
return `in ${days} day${days === 1 ? "" : "s"}`;
|
|
3733
|
-
const hours = Math.floor(diffMs / (60 * 60 * 1000));
|
|
3734
|
-
if (hours >= 1)
|
|
3735
|
-
return `in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
3736
|
-
const mins = Math.max(1, Math.floor(diffMs / (60 * 1000)));
|
|
3737
|
-
return `in ${mins} minute${mins === 1 ? "" : "s"}`;
|
|
3738
|
-
}
|
|
3739
|
-
function runLogout(ctx) {
|
|
3740
|
-
const had = readAuth() !== null;
|
|
3741
|
-
deleteAuth();
|
|
3742
|
-
output(ctx, had ? "Logged out successfully." : "Already logged out.", {
|
|
3743
|
-
logged_out: true,
|
|
3744
|
-
had_credentials: had
|
|
3745
|
-
});
|
|
3746
|
-
}
|
|
3747
|
-
async function runLogin(ctx, options) {
|
|
3748
|
-
const email = await collectEmail(ctx, options);
|
|
3749
|
-
if (!ctx.json)
|
|
3750
|
-
we("10x auth");
|
|
3751
|
-
const sp = ctx.json ? null : L2();
|
|
3752
|
-
sp?.start("Requesting magic link…");
|
|
3753
|
-
const login = await loginRequest(email);
|
|
3754
|
-
if (!login.ok) {
|
|
3755
|
-
sp?.stop("Magic link request failed.", 1);
|
|
3756
|
-
handleLoginError(ctx, login.status, login.code, login.error);
|
|
3831
|
+
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
3832
|
+
return KEYSET;
|
|
3757
3833
|
}
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
pollSpinner.message(`Waiting for magic link (${formatRemaining(remainingMs)} remaining)`);
|
|
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;
|
|
3766
3841
|
}
|
|
3767
3842
|
}
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
const auth = {
|
|
3772
|
-
version: AUTH_FILE_VERSION,
|
|
3773
|
-
email,
|
|
3774
|
-
access_token: result.tokens.token,
|
|
3775
|
-
refresh_token: result.tokens.refresh_token,
|
|
3776
|
-
expires_at: result.tokens.expires_at,
|
|
3777
|
-
created_at: new Date().toISOString()
|
|
3778
|
-
};
|
|
3779
|
-
saveAuth(auth);
|
|
3780
|
-
if (!ctx.json)
|
|
3781
|
-
fe(`Signed in as ${email}.`);
|
|
3782
|
-
output(ctx, "", {
|
|
3783
|
-
authenticated: true,
|
|
3784
|
-
email,
|
|
3785
|
-
expires_at: auth.expires_at
|
|
3786
|
-
});
|
|
3787
|
-
return;
|
|
3788
|
-
}
|
|
3789
|
-
if (result.kind === "expired") {
|
|
3790
|
-
pollSpinner?.stop("Session expired.", 1);
|
|
3791
|
-
outputError(ctx, "session_expired", result.message || "Magic-link session expired before it was clicked.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' again to request a fresh link.");
|
|
3792
|
-
}
|
|
3793
|
-
if (result.kind === "timeout") {
|
|
3794
|
-
pollSpinner?.stop("Timed out waiting for magic link.", 1);
|
|
3795
|
-
outputError(ctx, "auth_timeout", "Timed out waiting for the magic link to be clicked.", ExitCodes.ERROR, "Run '10x auth' to request a new link.");
|
|
3796
|
-
}
|
|
3797
|
-
if (result.kind === "aborted") {
|
|
3798
|
-
pollSpinner?.stop("Cancelled.", 1);
|
|
3799
|
-
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
3843
|
+
return parsed;
|
|
3844
|
+
} catch {
|
|
3845
|
+
return KEYSET;
|
|
3800
3846
|
}
|
|
3801
|
-
pollSpinner?.stop("Authentication failed.", 1);
|
|
3802
|
-
outputError(ctx, result.code || "auth_error", result.message || "Authentication failed.", ExitCodes.ERROR);
|
|
3803
3847
|
}
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
const trimmed = options.email.trim();
|
|
3807
|
-
if (!isLikelyEmail(trimmed)) {
|
|
3808
|
-
outputError(ctx, "invalid_email", `'${options.email}' does not look like an email address.`, ExitCodes.USAGE, "Pass your 10xDevs email, for example '10x auth --email you@example.com'.");
|
|
3809
|
-
}
|
|
3810
|
-
return trimmed;
|
|
3811
|
-
}
|
|
3812
|
-
if (ctx.json) {
|
|
3813
|
-
outputError(ctx, "missing_email", "Email is required in non-interactive mode.", ExitCodes.USAGE, "Pass --email, for example '10x auth --email you@example.com'.");
|
|
3814
|
-
}
|
|
3815
|
-
const answer = await ue({
|
|
3816
|
-
message: "Enter your 10xDevs email",
|
|
3817
|
-
placeholder: "you@example.com",
|
|
3818
|
-
validate: (value) => {
|
|
3819
|
-
if (!value || value.trim().length === 0)
|
|
3820
|
-
return "Email is required.";
|
|
3821
|
-
if (!isLikelyEmail(value.trim()))
|
|
3822
|
-
return "That does not look like an email address.";
|
|
3823
|
-
return;
|
|
3824
|
-
}
|
|
3825
|
-
});
|
|
3826
|
-
if (BD(answer)) {
|
|
3827
|
-
ve("Authentication cancelled.");
|
|
3828
|
-
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
3829
|
-
}
|
|
3830
|
-
return answer.trim();
|
|
3848
|
+
function sha256Hex(body) {
|
|
3849
|
+
return createHash("sha256").update(body).digest("hex");
|
|
3831
3850
|
}
|
|
3832
|
-
function
|
|
3833
|
-
return
|
|
3851
|
+
function canonicalSigningString(keyId, hashHex) {
|
|
3852
|
+
return `v1:${keyId}:${hashHex}`;
|
|
3834
3853
|
}
|
|
3835
|
-
function
|
|
3836
|
-
|
|
3837
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
3838
|
-
const seconds = totalSeconds % 60;
|
|
3839
|
-
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
3854
|
+
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
3855
|
+
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
3840
3856
|
}
|
|
3841
|
-
function
|
|
3842
|
-
|
|
3843
|
-
if (
|
|
3844
|
-
|
|
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`);
|
|
3845
3861
|
}
|
|
3846
|
-
|
|
3847
|
-
|
|
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.");
|
|
3848
3865
|
}
|
|
3849
|
-
|
|
3850
|
-
|
|
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.");
|
|
3851
3875
|
}
|
|
3852
|
-
|
|
3853
|
-
|
|
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.");
|
|
3854
3881
|
}
|
|
3855
|
-
|
|
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
|
+
|
|
4088
|
+
// src/commands/auth.ts
|
|
4089
|
+
var AUTH_METHODS = ["email", "circle"];
|
|
4090
|
+
function isAuthMethod(value) {
|
|
4091
|
+
return AUTH_METHODS.includes(value);
|
|
4092
|
+
}
|
|
4093
|
+
function registerAuthCommand(cli) {
|
|
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) => {
|
|
4095
|
+
const ctx = resolveContext(options);
|
|
4096
|
+
if (options.status) {
|
|
4097
|
+
await runStatus(ctx);
|
|
4098
|
+
return;
|
|
4099
|
+
}
|
|
4100
|
+
if (options.logout) {
|
|
4101
|
+
runLogout(ctx);
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
await runLogin(ctx, options);
|
|
4105
|
+
});
|
|
4106
|
+
}
|
|
4107
|
+
async function runStatus(ctx) {
|
|
4108
|
+
const auth = readAuth();
|
|
4109
|
+
if (!auth) {
|
|
4110
|
+
outputError(ctx, "auth_required", "You're not signed in.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in.");
|
|
4111
|
+
}
|
|
4112
|
+
const expired = isExpired(auth);
|
|
4113
|
+
const expiresAt = new Date(auth.expires_at);
|
|
4114
|
+
if (expired) {
|
|
4115
|
+
outputError(ctx, "auth_expired", `Your session for ${auth.email} has expired.`, ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
4116
|
+
}
|
|
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}`, {
|
|
4121
|
+
email: auth.email,
|
|
4122
|
+
expires_at: auth.expires_at,
|
|
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 } }
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
function describeMethod(method) {
|
|
4130
|
+
return method === "circle" ? "Circle message" : "email magic link";
|
|
4131
|
+
}
|
|
4132
|
+
function formatExpiry(expiresAt) {
|
|
4133
|
+
if (!Number.isFinite(expiresAt.getTime()))
|
|
4134
|
+
return "at an unknown time";
|
|
4135
|
+
const diffMs = expiresAt.getTime() - Date.now();
|
|
4136
|
+
if (diffMs <= 0)
|
|
4137
|
+
return "now";
|
|
4138
|
+
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
|
4139
|
+
if (days >= 1)
|
|
4140
|
+
return `in ${days} day${days === 1 ? "" : "s"}`;
|
|
4141
|
+
const hours = Math.floor(diffMs / (60 * 60 * 1000));
|
|
4142
|
+
if (hours >= 1)
|
|
4143
|
+
return `in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
4144
|
+
const mins = Math.max(1, Math.floor(diffMs / (60 * 1000)));
|
|
4145
|
+
return `in ${mins} minute${mins === 1 ? "" : "s"}`;
|
|
4146
|
+
}
|
|
4147
|
+
function runLogout(ctx) {
|
|
4148
|
+
const had = readAuth() !== null;
|
|
4149
|
+
deleteAuth();
|
|
4150
|
+
output(ctx, had ? "Logged out successfully." : "Already logged out.", {
|
|
4151
|
+
logged_out: true,
|
|
4152
|
+
had_credentials: had
|
|
4153
|
+
});
|
|
4154
|
+
}
|
|
4155
|
+
async function runLogin(ctx, options) {
|
|
4156
|
+
const method = await collectMethod(ctx, options);
|
|
4157
|
+
const email = await collectEmail(ctx, options);
|
|
4158
|
+
if (method === "circle") {
|
|
4159
|
+
await runCircleLogin(ctx, email);
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
if (!ctx.json)
|
|
4163
|
+
we("10x auth");
|
|
4164
|
+
const sp = ctx.json ? null : L2();
|
|
4165
|
+
sp?.start("Requesting magic link…");
|
|
4166
|
+
const login = await loginRequest(email);
|
|
4167
|
+
if (!login.ok) {
|
|
4168
|
+
sp?.stop("Magic link request failed.", 1);
|
|
4169
|
+
handleLoginError(ctx, login.status, login.code, login.error);
|
|
4170
|
+
}
|
|
4171
|
+
const { session_id } = login.data;
|
|
4172
|
+
sp?.stop(`Magic link sent to ${email}. Check your inbox.`);
|
|
4173
|
+
const pollSpinner = ctx.json ? null : L2();
|
|
4174
|
+
pollSpinner?.start("Waiting for magic link…");
|
|
4175
|
+
const result = await pollVerifySession(session_id, {
|
|
4176
|
+
onTick: (remainingMs) => {
|
|
4177
|
+
if (pollSpinner) {
|
|
4178
|
+
pollSpinner.message(`Waiting for magic link (${formatRemaining(remainingMs)} remaining)`);
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
});
|
|
4182
|
+
if (result.kind === "verified") {
|
|
4183
|
+
pollSpinner?.stop("Authenticated.");
|
|
4184
|
+
const auth = {
|
|
4185
|
+
version: AUTH_FILE_VERSION,
|
|
4186
|
+
email,
|
|
4187
|
+
access_token: result.tokens.token,
|
|
4188
|
+
refresh_token: result.tokens.refresh_token,
|
|
4189
|
+
expires_at: result.tokens.expires_at,
|
|
4190
|
+
created_at: new Date().toISOString(),
|
|
4191
|
+
method: "email"
|
|
4192
|
+
};
|
|
4193
|
+
saveAuth(auth);
|
|
4194
|
+
if (!ctx.json)
|
|
4195
|
+
fe(`Signed in as ${email}.`);
|
|
4196
|
+
output(ctx, "", {
|
|
4197
|
+
authenticated: true,
|
|
4198
|
+
email,
|
|
4199
|
+
expires_at: auth.expires_at,
|
|
4200
|
+
method: "email"
|
|
4201
|
+
});
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
4204
|
+
if (result.kind === "expired") {
|
|
4205
|
+
pollSpinner?.stop("Session expired.", 1);
|
|
4206
|
+
outputError(ctx, "session_expired", result.message || "Magic-link session expired before it was clicked.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' again to request a fresh link.");
|
|
4207
|
+
}
|
|
4208
|
+
if (result.kind === "timeout") {
|
|
4209
|
+
pollSpinner?.stop("Timed out waiting for magic link.", 1);
|
|
4210
|
+
outputError(ctx, "auth_timeout", "Timed out waiting for the magic link to be clicked.", ExitCodes.ERROR, "Run '10x auth' to request a new link.");
|
|
4211
|
+
}
|
|
4212
|
+
if (result.kind === "aborted") {
|
|
4213
|
+
pollSpinner?.stop("Cancelled.", 1);
|
|
4214
|
+
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
4215
|
+
}
|
|
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;
|
|
4340
|
+
}
|
|
4341
|
+
async function collectEmail(ctx, options) {
|
|
4342
|
+
if (options.email) {
|
|
4343
|
+
const trimmed = options.email.trim();
|
|
4344
|
+
if (!isLikelyEmail(trimmed)) {
|
|
4345
|
+
outputError(ctx, "invalid_email", `'${options.email}' does not look like an email address.`, ExitCodes.USAGE, "Pass your 10xDevs email, for example '10x auth --email you@example.com'.");
|
|
4346
|
+
}
|
|
4347
|
+
return trimmed;
|
|
4348
|
+
}
|
|
4349
|
+
if (ctx.json) {
|
|
4350
|
+
outputError(ctx, "missing_email", "Email is required in non-interactive mode.", ExitCodes.USAGE, "Pass --email, for example '10x auth --email you@example.com'.");
|
|
4351
|
+
}
|
|
4352
|
+
const answer = await ue({
|
|
4353
|
+
message: "Enter your 10xDevs email",
|
|
4354
|
+
placeholder: "you@example.com",
|
|
4355
|
+
validate: (value) => {
|
|
4356
|
+
if (!value || value.trim().length === 0)
|
|
4357
|
+
return "Email is required.";
|
|
4358
|
+
if (!isLikelyEmail(value.trim()))
|
|
4359
|
+
return "That does not look like an email address.";
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
});
|
|
4363
|
+
if (BD(answer)) {
|
|
4364
|
+
ve("Authentication cancelled.");
|
|
4365
|
+
outputError(ctx, "auth_cancelled", "Authentication cancelled.", ExitCodes.ERROR);
|
|
4366
|
+
}
|
|
4367
|
+
return answer.trim();
|
|
4368
|
+
}
|
|
4369
|
+
function isLikelyEmail(value) {
|
|
4370
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
4371
|
+
}
|
|
4372
|
+
function formatRemaining(ms) {
|
|
4373
|
+
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
|
4374
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
4375
|
+
const seconds = totalSeconds % 60;
|
|
4376
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
4377
|
+
}
|
|
4378
|
+
function handleLoginError(ctx, status, code, error) {
|
|
4379
|
+
verbose(ctx, `login failed: status=${status} code=${code}`);
|
|
4380
|
+
if (status === 403) {
|
|
4381
|
+
outputError(ctx, "no_access", "This email has no active 10xDevs course membership.", ExitCodes.FORBIDDEN, "Enroll at https://10xdevs.pl, then run '10x auth' again.");
|
|
4382
|
+
}
|
|
4383
|
+
if (status === 429) {
|
|
4384
|
+
outputError(ctx, "rate_limited", "Too many magic-link requests for this email.", ExitCodes.ERROR, "Wait a few minutes, then run '10x auth' again.");
|
|
4385
|
+
}
|
|
4386
|
+
if (status === 502) {
|
|
4387
|
+
outputError(ctx, "email_delivery_failed", "Could not send the magic-link email.", ExitCodes.ERROR, "Try '10x auth' again in a few minutes. If it keeps failing, contact support.");
|
|
4388
|
+
}
|
|
4389
|
+
if (status === 0) {
|
|
4390
|
+
outputError(ctx, "network_error", "Could not reach the 10x-toolkit API.", ExitCodes.ERROR, "Check your internet connection and run '10x auth' again.");
|
|
4391
|
+
}
|
|
4392
|
+
outputError(ctx, code || "auth_error", "Authentication failed.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
3856
4393
|
}
|
|
3857
4394
|
|
|
3858
4395
|
// src/lib/bench-client.ts
|
|
@@ -4043,207 +4580,80 @@ function renderLeaderboard(ctx, data, limit) {
|
|
|
4043
4580
|
totalAttempts: data.totalAttempts,
|
|
4044
4581
|
totalModels: data.totalModels,
|
|
4045
4582
|
models: ranked.map((e2, i) => ({
|
|
4046
|
-
rank: i + 1,
|
|
4047
|
-
model: e2.modelName,
|
|
4048
|
-
modelBaseId: e2.modelBaseId,
|
|
4049
|
-
score: e2.averageScore,
|
|
4050
|
-
maxScore: e2.averageMaxScore,
|
|
4051
|
-
percentage: e2.averagePercentage,
|
|
4052
|
-
attempts: e2.attemptCount,
|
|
4053
|
-
environment: e2.agentEnvironment,
|
|
4054
|
-
averageCost: e2.averageCost
|
|
4055
|
-
}))
|
|
4056
|
-
});
|
|
4057
|
-
return;
|
|
4058
|
-
}
|
|
4059
|
-
if (ranked.length === 0) {
|
|
4060
|
-
output(ctx, "The leaderboard has no results yet.", undefined);
|
|
4061
|
-
return;
|
|
4062
|
-
}
|
|
4063
|
-
const names = ranked.map((e2) => sanitize(e2.modelName));
|
|
4064
|
-
const details = ranked.map((e2) => formatDetail(e2));
|
|
4065
|
-
const nameWidth = Math.max(...names.map((n) => n.length));
|
|
4066
|
-
const detailWidth = Math.max(...details.map((d2) => d2.length));
|
|
4067
|
-
const rankWidth = String(ranked.length).length;
|
|
4068
|
-
const lines = [];
|
|
4069
|
-
lines.push(paint(STYLE.bold, `10xBench — Top ${ranked.length} of ${data.totalModels} models`) + paint(STYLE.dim, ` (${data.totalAttempts} attempts scored)`));
|
|
4070
|
-
lines.push("");
|
|
4071
|
-
for (let i = 0;i < ranked.length; i++) {
|
|
4072
|
-
lines.push(formatRow(ranked[i], i + 1, names[i], details[i], rankWidth, nameWidth, detailWidth));
|
|
4073
|
-
}
|
|
4074
|
-
lines.push("");
|
|
4075
|
-
lines.push(paint(STYLE.dim, `Data as of ${formatReleaseAt(data.generatedAt)} — ${resolveBenchBase()}`));
|
|
4076
|
-
output(ctx, lines.join(`
|
|
4077
|
-
`), undefined);
|
|
4078
|
-
}
|
|
4079
|
-
function formatDetail(entry) {
|
|
4080
|
-
const score = `${entry.averageScore.toFixed(1)}/${entry.averageMaxScore.toFixed(0)}`;
|
|
4081
|
-
const runs = `${entry.attemptCount} run${entry.attemptCount === 1 ? "" : "s"}`;
|
|
4082
|
-
const cost = entry.averageCost === null || entry.averageCost === undefined ? "" : `, ~$${entry.averageCost.toFixed(2)}/run`;
|
|
4083
|
-
return `(${score}, ${runs}${cost})`;
|
|
4084
|
-
}
|
|
4085
|
-
function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth) {
|
|
4086
|
-
const style = scoreStyle(entry.averagePercentage);
|
|
4087
|
-
const rankCell = String(rank).padStart(rankWidth);
|
|
4088
|
-
const nameCell = name.padEnd(nameWidth);
|
|
4089
|
-
const pctCell = `${entry.averagePercentage.toFixed(1)}%`.padStart(6);
|
|
4090
|
-
return [
|
|
4091
|
-
` ${paint(rank <= 3 ? STYLE.bold : STYLE.dim, rankCell)}`,
|
|
4092
|
-
rank <= 3 ? paint(STYLE.bold, nameCell) : nameCell,
|
|
4093
|
-
paint(style, scoreBar(entry.averagePercentage)),
|
|
4094
|
-
paint(style, pctCell),
|
|
4095
|
-
detail.padEnd(detailWidth),
|
|
4096
|
-
paint(STYLE.dim, sanitize(entry.agentEnvironment))
|
|
4097
|
-
].join(" ");
|
|
4098
|
-
}
|
|
4099
|
-
|
|
4100
|
-
// src/commands/bench-kit.ts
|
|
4101
|
-
import { spawn } from "node:child_process";
|
|
4102
|
-
import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync3, rmSync as rmSync3 } from "node:fs";
|
|
4103
|
-
import { tmpdir } from "node:os";
|
|
4104
|
-
import { basename, join as join4, resolve } from "node:path";
|
|
4105
|
-
|
|
4106
|
-
// src/lib/tool-detect.ts
|
|
4107
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
4108
|
-
import { join as join3 } from "node:path";
|
|
4109
|
-
|
|
4110
|
-
// 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
|
-
var MANIFEST_FILENAME = ".10x-cli-manifest.json";
|
|
4115
|
-
var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
|
|
4116
|
-
var MANIFEST_VERSION = 3;
|
|
4117
|
-
function contentHash(content) {
|
|
4118
|
-
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
4119
|
-
}
|
|
4120
|
-
function readManifest(dir) {
|
|
4121
|
-
const manifestPath = join2(dir, MANIFEST_FILENAME);
|
|
4122
|
-
if (!existsSync2(manifestPath))
|
|
4123
|
-
return null;
|
|
4124
|
-
let raw;
|
|
4125
|
-
try {
|
|
4126
|
-
raw = readFileSync2(manifestPath, "utf8");
|
|
4127
|
-
} catch {
|
|
4128
|
-
return null;
|
|
4129
|
-
}
|
|
4130
|
-
let parsed;
|
|
4131
|
-
try {
|
|
4132
|
-
parsed = JSON.parse(raw);
|
|
4133
|
-
} catch {
|
|
4134
|
-
return null;
|
|
4135
|
-
}
|
|
4136
|
-
if (!isManifest(parsed))
|
|
4137
|
-
return null;
|
|
4138
|
-
return parsed;
|
|
4139
|
-
}
|
|
4140
|
-
function writeManifest(dir, manifest) {
|
|
4141
|
-
if (!existsSync2(dir)) {
|
|
4142
|
-
mkdirSync3(dir, { recursive: true });
|
|
4143
|
-
}
|
|
4144
|
-
const manifestPath = join2(dir, MANIFEST_FILENAME);
|
|
4145
|
-
mkdirSync3(dirname2(manifestPath), { recursive: true });
|
|
4146
|
-
const tmp = `${manifestPath}.tmp`;
|
|
4147
|
-
rmSync2(tmp, { force: true });
|
|
4148
|
-
writeFileSync2(tmp, `${JSON.stringify(manifest, null, 2)}
|
|
4149
|
-
`);
|
|
4150
|
-
renameSync2(tmp, manifestPath);
|
|
4151
|
-
}
|
|
4152
|
-
function isManifest(value) {
|
|
4153
|
-
if (typeof value !== "object" || value === null)
|
|
4154
|
-
return false;
|
|
4155
|
-
const v3 = value;
|
|
4156
|
-
if (v3["package"] !== CLI_PACKAGE_NAME)
|
|
4157
|
-
return false;
|
|
4158
|
-
if (typeof v3["version"] !== "string")
|
|
4159
|
-
return false;
|
|
4160
|
-
const mv = v3["manifestVersion"];
|
|
4161
|
-
if (mv !== 2 && mv !== 3)
|
|
4162
|
-
return false;
|
|
4163
|
-
if (typeof v3["lastApplied"] !== "string")
|
|
4164
|
-
return false;
|
|
4165
|
-
if (typeof v3["lessonId"] !== "string")
|
|
4166
|
-
return false;
|
|
4167
|
-
if (typeof v3["course"] !== "string")
|
|
4168
|
-
return false;
|
|
4169
|
-
const files = v3["files"];
|
|
4170
|
-
if (typeof files !== "object" || files === null)
|
|
4171
|
-
return false;
|
|
4172
|
-
const f2 = files;
|
|
4173
|
-
if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
|
|
4174
|
-
return false;
|
|
4175
|
-
const lessons = v3["lessons"];
|
|
4176
|
-
if (lessons !== undefined && !isLessonsRecord(lessons))
|
|
4177
|
-
return false;
|
|
4178
|
-
return true;
|
|
4179
|
-
}
|
|
4180
|
-
function isSkillsRecord(value) {
|
|
4181
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
4182
|
-
return false;
|
|
4183
|
-
for (const entry of Object.values(value)) {
|
|
4184
|
-
if (typeof entry !== "object" || entry === null)
|
|
4185
|
-
return false;
|
|
4186
|
-
const e2 = entry;
|
|
4187
|
-
if (!isStringArray(e2["files"]))
|
|
4188
|
-
return false;
|
|
4583
|
+
rank: i + 1,
|
|
4584
|
+
model: e2.modelName,
|
|
4585
|
+
modelBaseId: e2.modelBaseId,
|
|
4586
|
+
score: e2.averageScore,
|
|
4587
|
+
maxScore: e2.averageMaxScore,
|
|
4588
|
+
percentage: e2.averagePercentage,
|
|
4589
|
+
attempts: e2.attemptCount,
|
|
4590
|
+
environment: e2.agentEnvironment,
|
|
4591
|
+
averageCost: e2.averageCost
|
|
4592
|
+
}))
|
|
4593
|
+
});
|
|
4594
|
+
return;
|
|
4189
4595
|
}
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
4194
|
-
}
|
|
4195
|
-
function isLessonFilesEntry(value) {
|
|
4196
|
-
if (typeof value !== "object" || value === null)
|
|
4197
|
-
return false;
|
|
4198
|
-
const e2 = value;
|
|
4199
|
-
if (typeof e2["appliedAt"] !== "string")
|
|
4200
|
-
return false;
|
|
4201
|
-
if (typeof e2["skills"] !== "object" || e2["skills"] === null || Array.isArray(e2["skills"]))
|
|
4202
|
-
return false;
|
|
4203
|
-
for (const skill of Object.values(e2["skills"])) {
|
|
4204
|
-
if (typeof skill !== "object" || skill === null)
|
|
4205
|
-
return false;
|
|
4206
|
-
if (!isStringArray(skill["files"]))
|
|
4207
|
-
return false;
|
|
4596
|
+
if (ranked.length === 0) {
|
|
4597
|
+
output(ctx, "The leaderboard has no results yet.", undefined);
|
|
4598
|
+
return;
|
|
4208
4599
|
}
|
|
4209
|
-
|
|
4210
|
-
|
|
4600
|
+
const names = ranked.map((e2) => sanitize(e2.modelName));
|
|
4601
|
+
const details = ranked.map((e2) => formatDetail(e2));
|
|
4602
|
+
const nameWidth = Math.max(...names.map((n) => n.length));
|
|
4603
|
+
const detailWidth = Math.max(...details.map((d2) => d2.length));
|
|
4604
|
+
const rankWidth = String(ranked.length).length;
|
|
4605
|
+
const lines = [];
|
|
4606
|
+
lines.push(paint(STYLE.bold, `10xBench — Top ${ranked.length} of ${data.totalModels} models`) + paint(STYLE.dim, ` (${data.totalAttempts} attempts scored)`));
|
|
4607
|
+
lines.push("");
|
|
4608
|
+
for (let i = 0;i < ranked.length; i++) {
|
|
4609
|
+
lines.push(formatRow(ranked[i], i + 1, names[i], details[i], rankWidth, nameWidth, detailWidth));
|
|
4211
4610
|
}
|
|
4212
|
-
|
|
4611
|
+
lines.push("");
|
|
4612
|
+
lines.push(paint(STYLE.dim, `Data as of ${formatReleaseAt(data.generatedAt)} — ${resolveBenchBase()}`));
|
|
4613
|
+
output(ctx, lines.join(`
|
|
4614
|
+
`), undefined);
|
|
4213
4615
|
}
|
|
4214
|
-
function
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
return false;
|
|
4220
|
-
}
|
|
4221
|
-
return true;
|
|
4616
|
+
function formatDetail(entry) {
|
|
4617
|
+
const score = `${entry.averageScore.toFixed(1)}/${entry.averageMaxScore.toFixed(0)}`;
|
|
4618
|
+
const runs = `${entry.attemptCount} run${entry.attemptCount === 1 ? "" : "s"}`;
|
|
4619
|
+
const cost = entry.averageCost === null || entry.averageCost === undefined ? "" : `, ~$${entry.averageCost.toFixed(2)}/run`;
|
|
4620
|
+
return `(${score}, ${runs}${cost})`;
|
|
4222
4621
|
}
|
|
4223
|
-
function
|
|
4224
|
-
const
|
|
4225
|
-
const
|
|
4226
|
-
const
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
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] };
|
|
4622
|
+
function formatRow(entry, rank, name, detail, rankWidth, nameWidth, detailWidth) {
|
|
4623
|
+
const style = scoreStyle(entry.averagePercentage);
|
|
4624
|
+
const rankCell = String(rank).padStart(rankWidth);
|
|
4625
|
+
const nameCell = name.padEnd(nameWidth);
|
|
4626
|
+
const pctCell = `${entry.averagePercentage.toFixed(1)}%`.padStart(6);
|
|
4627
|
+
return [
|
|
4628
|
+
` ${paint(rank <= 3 ? STYLE.bold : STYLE.dim, rankCell)}`,
|
|
4629
|
+
rank <= 3 ? paint(STYLE.bold, nameCell) : nameCell,
|
|
4630
|
+
paint(style, scoreBar(entry.averagePercentage)),
|
|
4631
|
+
paint(style, pctCell),
|
|
4632
|
+
detail.padEnd(detailWidth),
|
|
4633
|
+
paint(STYLE.dim, sanitize(entry.agentEnvironment))
|
|
4634
|
+
].join(" ");
|
|
4245
4635
|
}
|
|
4246
4636
|
|
|
4637
|
+
// src/commands/bench-kit.ts
|
|
4638
|
+
import { spawn } from "node:child_process";
|
|
4639
|
+
import { existsSync as existsSync4, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync4 } from "node:fs";
|
|
4640
|
+
import { tmpdir } from "node:os";
|
|
4641
|
+
import { basename, join as join5, resolve as resolve2 } from "node:path";
|
|
4642
|
+
|
|
4643
|
+
// src/lib/tool-detect.ts
|
|
4644
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
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
|
+
|
|
4247
4657
|
// src/lib/tool-profile.ts
|
|
4248
4658
|
var SENTINEL_BEGIN = "<!-- BEGIN @przeprogramowani/10x-cli -->";
|
|
4249
4659
|
var SENTINEL_END = "<!-- END @przeprogramowani/10x-cli -->";
|
|
@@ -4350,21 +4760,329 @@ var LEGACY_PROFILES = {
|
|
|
4350
4760
|
sentinelBegin: SENTINEL_BEGIN,
|
|
4351
4761
|
sentinelEnd: SENTINEL_END
|
|
4352
4762
|
}
|
|
4353
|
-
};
|
|
4354
|
-
function canonicalToolId(toolId) {
|
|
4355
|
-
return TOOL_ALIASES[toolId] ?? toolId;
|
|
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
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// src/lib/manifest.ts
|
|
4898
|
+
var MANIFEST_FILENAME = ".10x-cli-manifest.json";
|
|
4899
|
+
var CLI_PACKAGE_NAME = "@przeprogramowani/10x-cli";
|
|
4900
|
+
var MANIFEST_VERSION = 3;
|
|
4901
|
+
function contentHash(content) {
|
|
4902
|
+
return createHash2("sha256").update(content).digest("hex");
|
|
4903
|
+
}
|
|
4904
|
+
function readManifest(dir) {
|
|
4905
|
+
const manifestPath = join3(dir, MANIFEST_FILENAME);
|
|
4906
|
+
assertProjectFilePath(dirname2(dir), manifestPath);
|
|
4907
|
+
if (!existsSync2(manifestPath))
|
|
4908
|
+
return null;
|
|
4909
|
+
let raw;
|
|
4910
|
+
try {
|
|
4911
|
+
raw = readFileSync3(manifestPath, "utf8");
|
|
4912
|
+
} catch {
|
|
4913
|
+
return null;
|
|
4914
|
+
}
|
|
4915
|
+
let parsed;
|
|
4916
|
+
try {
|
|
4917
|
+
parsed = JSON.parse(raw);
|
|
4918
|
+
} catch {
|
|
4919
|
+
return null;
|
|
4920
|
+
}
|
|
4921
|
+
if (!isManifest(parsed))
|
|
4922
|
+
return null;
|
|
4923
|
+
return parsed;
|
|
4924
|
+
}
|
|
4925
|
+
function writeManifest(dir, manifest) {
|
|
4926
|
+
assertProjectFilePath(dirname2(dir), join3(dir, MANIFEST_FILENAME));
|
|
4927
|
+
assertProjectFilePath(dirname2(dir), join3(dir, `${MANIFEST_FILENAME}.tmp`));
|
|
4928
|
+
if (!existsSync2(dir)) {
|
|
4929
|
+
mkdirSync3(dir, { recursive: true });
|
|
4930
|
+
}
|
|
4931
|
+
const manifestPath = join3(dir, MANIFEST_FILENAME);
|
|
4932
|
+
mkdirSync3(dirname2(manifestPath), { recursive: true });
|
|
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
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
function isManifest(value) {
|
|
4944
|
+
if (typeof value !== "object" || value === null)
|
|
4945
|
+
return false;
|
|
4946
|
+
const v3 = value;
|
|
4947
|
+
if (v3["package"] !== CLI_PACKAGE_NAME || "directSkills" in v3)
|
|
4948
|
+
return false;
|
|
4949
|
+
if (typeof v3["version"] !== "string")
|
|
4950
|
+
return false;
|
|
4951
|
+
const mv = v3["manifestVersion"];
|
|
4952
|
+
if (mv !== 2 && mv !== 3)
|
|
4953
|
+
return false;
|
|
4954
|
+
if (typeof v3["lastApplied"] !== "string")
|
|
4955
|
+
return false;
|
|
4956
|
+
if (typeof v3["lessonId"] !== "string")
|
|
4957
|
+
return false;
|
|
4958
|
+
if (typeof v3["course"] !== "string")
|
|
4959
|
+
return false;
|
|
4960
|
+
const files = v3["files"];
|
|
4961
|
+
if (typeof files !== "object" || files === null)
|
|
4962
|
+
return false;
|
|
4963
|
+
const f2 = files;
|
|
4964
|
+
if ("directSkills" in f2 || f2["promptHashes"] !== undefined && !isStringRecord(f2["promptHashes"]))
|
|
4965
|
+
return false;
|
|
4966
|
+
if (!(isSkillsRecord(f2["skills"]) && isStringArray(f2["prompts"]) && isStringArray(f2["configs"])))
|
|
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
|
+
}
|
|
4978
|
+
const lessons = v3["lessons"];
|
|
4979
|
+
if (lessons !== undefined && !isLessonsRecord(lessons))
|
|
4980
|
+
return false;
|
|
4981
|
+
return true;
|
|
4982
|
+
}
|
|
4983
|
+
function isSkillsRecord(value) {
|
|
4984
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
4985
|
+
return false;
|
|
4986
|
+
for (const entry of Object.values(value)) {
|
|
4987
|
+
if (typeof entry !== "object" || entry === null)
|
|
4988
|
+
return false;
|
|
4989
|
+
const e2 = entry;
|
|
4990
|
+
if (!isStringArray(e2["files"]) || e2["contentHashes"] !== undefined && !isStringRecord(e2["contentHashes"]))
|
|
4991
|
+
return false;
|
|
4992
|
+
}
|
|
4993
|
+
return true;
|
|
4994
|
+
}
|
|
4995
|
+
function isStringRecord(value) {
|
|
4996
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
4997
|
+
}
|
|
4998
|
+
function isStringArray(value) {
|
|
4999
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
5000
|
+
}
|
|
5001
|
+
function isLessonFilesEntry(value) {
|
|
5002
|
+
if (typeof value !== "object" || value === null)
|
|
5003
|
+
return false;
|
|
5004
|
+
const e2 = value;
|
|
5005
|
+
if (typeof e2["appliedAt"] !== "string")
|
|
5006
|
+
return false;
|
|
5007
|
+
if (typeof e2["skills"] !== "object" || e2["skills"] === null || Array.isArray(e2["skills"]))
|
|
5008
|
+
return false;
|
|
5009
|
+
for (const skill of Object.values(e2["skills"])) {
|
|
5010
|
+
if (typeof skill !== "object" || skill === null)
|
|
5011
|
+
return false;
|
|
5012
|
+
if (!isStringArray(skill["files"]))
|
|
5013
|
+
return false;
|
|
5014
|
+
}
|
|
5015
|
+
if (e2["catalogContentHash"] !== undefined && typeof e2["catalogContentHash"] !== "string") {
|
|
5016
|
+
return false;
|
|
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
|
+
}
|
|
5026
|
+
return isStringArray(e2["prompts"]) && isStringArray(e2["configs"]);
|
|
5027
|
+
}
|
|
5028
|
+
function isLessonsRecord(value) {
|
|
5029
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
5030
|
+
return false;
|
|
5031
|
+
for (const entry of Object.values(value)) {
|
|
5032
|
+
if (!isLessonFilesEntry(entry))
|
|
5033
|
+
return false;
|
|
5034
|
+
}
|
|
5035
|
+
return true;
|
|
4356
5036
|
}
|
|
4357
|
-
function
|
|
4358
|
-
|
|
5037
|
+
function buildUnionFiles(lessons) {
|
|
5038
|
+
const skills = {};
|
|
5039
|
+
const prompts = new Set;
|
|
5040
|
+
const configs = new Set;
|
|
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);
|
|
5057
|
+
}
|
|
5058
|
+
return { skills, prompts: [...prompts], configs: [...configs] };
|
|
4359
5059
|
}
|
|
4360
|
-
function
|
|
4361
|
-
return
|
|
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
|
+
};
|
|
4362
5081
|
}
|
|
4363
|
-
var DEFAULT_TOOL = "claude-code";
|
|
4364
5082
|
|
|
4365
5083
|
// src/lib/tool-detect.ts
|
|
4366
5084
|
function detectTools(projectRoot) {
|
|
4367
|
-
const hit = (rel) => existsSync3(
|
|
5085
|
+
const hit = (rel) => existsSync3(join4(projectRoot, rel));
|
|
4368
5086
|
const signals = [];
|
|
4369
5087
|
if (hit(".claude/" + MANIFEST_FILENAME)) {
|
|
4370
5088
|
signals.push({
|
|
@@ -4498,7 +5216,7 @@ function topDetectedProfile(signals) {
|
|
|
4498
5216
|
|
|
4499
5217
|
// src/commands/bench-kit.ts
|
|
4500
5218
|
var TEMPLATE_REPO_URL = "https://github.com/przeprogramowani/10x-bench-kit";
|
|
4501
|
-
var BOOTSTRAP_ENTRY =
|
|
5219
|
+
var BOOTSTRAP_ENTRY = join5(".bench-kit", "bootstrap", "index.mjs");
|
|
4502
5220
|
var CONTRACT_VERSION = 1;
|
|
4503
5221
|
function registerBenchKitCommand(cli) {
|
|
4504
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) => {
|
|
@@ -4515,7 +5233,7 @@ function registerBenchKitCommand(cli) {
|
|
|
4515
5233
|
});
|
|
4516
5234
|
}
|
|
4517
5235
|
async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
4518
|
-
const targetDir =
|
|
5236
|
+
const targetDir = resolve2(dirArg ?? ".");
|
|
4519
5237
|
const requestedRef = normalizeRef(ctx, options.templateVersion);
|
|
4520
5238
|
await preflight(ctx, deps);
|
|
4521
5239
|
const existingVersion = readInstanceVersion(targetDir);
|
|
@@ -4523,7 +5241,7 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4523
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.");
|
|
4524
5242
|
}
|
|
4525
5243
|
const toolChoice = await resolveInstanceTool(ctx, options, deps, existingVersion !== null);
|
|
4526
|
-
const scratch = mkdtempSync(
|
|
5244
|
+
const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
|
|
4527
5245
|
try {
|
|
4528
5246
|
await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "init");
|
|
4529
5247
|
const entry = requireBootstrap(ctx, scratch);
|
|
@@ -4560,14 +5278,14 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4560
5278
|
}
|
|
4561
5279
|
let baseRepoClone = "skipped";
|
|
4562
5280
|
if (res.baseRepoClone != null) {
|
|
4563
|
-
const cloneDest =
|
|
5281
|
+
const cloneDest = join5(targetDir, res.baseRepoClone.dest);
|
|
4564
5282
|
verbose(ctx, `cloning base repo into ${res.baseRepoClone.dest}`);
|
|
4565
5283
|
const cloned = await deps.cloneBaseRepo(res.baseRepoClone, cloneDest);
|
|
4566
5284
|
if (cloned.ok) {
|
|
4567
5285
|
baseRepoClone = "cloned";
|
|
4568
5286
|
} else {
|
|
4569
5287
|
baseRepoClone = "failed";
|
|
4570
|
-
|
|
5288
|
+
rmSync4(cloneDest, { recursive: true, force: true });
|
|
4571
5289
|
verbose(ctx, `base repo clone failed (${cloned.error.trim().split(`
|
|
4572
5290
|
`).pop() ?? ""})`);
|
|
4573
5291
|
}
|
|
@@ -4614,11 +5332,11 @@ async function runBenchKitInit(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4614
5332
|
warnings: res.warnings ?? []
|
|
4615
5333
|
});
|
|
4616
5334
|
} finally {
|
|
4617
|
-
|
|
5335
|
+
rmSync4(scratch, { recursive: true, force: true });
|
|
4618
5336
|
}
|
|
4619
5337
|
}
|
|
4620
5338
|
async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
4621
|
-
const targetDir =
|
|
5339
|
+
const targetDir = resolve2(dirArg ?? ".");
|
|
4622
5340
|
const requestedRef = normalizeRef(ctx, options.templateVersion);
|
|
4623
5341
|
await preflight(ctx, deps);
|
|
4624
5342
|
const currentVersion = readInstanceVersion(targetDir);
|
|
@@ -4633,7 +5351,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4633
5351
|
if (!status.ok) {
|
|
4634
5352
|
verbose(ctx, "not a git repository — proceeding, but review the changes without git diff");
|
|
4635
5353
|
}
|
|
4636
|
-
const scratch = mkdtempSync(
|
|
5354
|
+
const scratch = mkdtempSync(join5(tmpdir(), "bench-kit-"));
|
|
4637
5355
|
try {
|
|
4638
5356
|
await cloneTemplateOrDie(ctx, deps, requestedRef, scratch, "update");
|
|
4639
5357
|
const entry = requireBootstrap(ctx, scratch);
|
|
@@ -4691,7 +5409,7 @@ async function runBenchKitUpdate(ctx, dirArg, options, deps = defaultDeps) {
|
|
|
4691
5409
|
warnings: res.warnings ?? []
|
|
4692
5410
|
});
|
|
4693
5411
|
} finally {
|
|
4694
|
-
|
|
5412
|
+
rmSync4(scratch, { recursive: true, force: true });
|
|
4695
5413
|
}
|
|
4696
5414
|
}
|
|
4697
5415
|
function describeSync(counts) {
|
|
@@ -4754,17 +5472,17 @@ async function cloneTemplateOrDie(ctx, deps, ref, scratch, action) {
|
|
|
4754
5472
|
}
|
|
4755
5473
|
}
|
|
4756
5474
|
function requireBootstrap(ctx, scratch) {
|
|
4757
|
-
const entry =
|
|
5475
|
+
const entry = join5(scratch, BOOTSTRAP_ENTRY);
|
|
4758
5476
|
if (!existsSync4(entry)) {
|
|
4759
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.");
|
|
4760
5478
|
}
|
|
4761
5479
|
return entry;
|
|
4762
5480
|
}
|
|
4763
5481
|
function readInstanceVersion(dir) {
|
|
4764
|
-
const versionFile =
|
|
5482
|
+
const versionFile = join5(dir, ".bench-kit", "VERSION");
|
|
4765
5483
|
if (!existsSync4(versionFile))
|
|
4766
5484
|
return null;
|
|
4767
|
-
return
|
|
5485
|
+
return readFileSync4(versionFile, "utf8").trim();
|
|
4768
5486
|
}
|
|
4769
5487
|
function toHttpsUrl(url) {
|
|
4770
5488
|
const scp = url.match(/^git@([^:/]+):(.+)$/);
|
|
@@ -4890,214 +5608,76 @@ var defaultDeps = {
|
|
|
4890
5608
|
|
|
4891
5609
|
// src/commands/doctor.ts
|
|
4892
5610
|
import { existsSync as existsSync5, accessSync, constants } from "node:fs";
|
|
4893
|
-
import { join as
|
|
5611
|
+
import { join as join6 } from "node:path";
|
|
4894
5612
|
|
|
4895
|
-
// src/lib/
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
constructor(message) {
|
|
5613
|
+
// src/lib/course-selection.ts
|
|
5614
|
+
class CourseSelectionError extends Error {
|
|
5615
|
+
code;
|
|
5616
|
+
constructor(code, message) {
|
|
4900
5617
|
super(message);
|
|
4901
|
-
this.
|
|
4902
|
-
}
|
|
4903
|
-
}
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
if (!
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
}
|
|
4924
|
-
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
|
|
4925
|
-
return KEYSET;
|
|
4926
|
-
}
|
|
4927
|
-
try {
|
|
4928
|
-
const parsed = JSON.parse(override);
|
|
4929
|
-
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
4930
|
-
return KEYSET;
|
|
4931
|
-
for (const k3 of parsed) {
|
|
4932
|
-
if (typeof k3.keyId !== "number" || typeof k3.publicKey !== "string") {
|
|
4933
|
-
return KEYSET;
|
|
4934
|
-
}
|
|
4935
|
-
}
|
|
4936
|
-
return parsed;
|
|
4937
|
-
} catch {
|
|
4938
|
-
return KEYSET;
|
|
4939
|
-
}
|
|
4940
|
-
}
|
|
4941
|
-
function sha256Hex(body) {
|
|
4942
|
-
return createHash2("sha256").update(body).digest("hex");
|
|
4943
|
-
}
|
|
4944
|
-
function canonicalSigningString(keyId, hashHex) {
|
|
4945
|
-
return `v1:${keyId}:${hashHex}`;
|
|
4946
|
-
}
|
|
4947
|
-
function verifyBundleSignature(rawBody, signature, keyId, headerHash) {
|
|
4948
|
-
verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, loadEffectiveKeyset());
|
|
4949
|
-
}
|
|
4950
|
-
function verifyBundleSignatureWithKeyset(rawBody, signature, keyId, headerHash, keyset) {
|
|
4951
|
-
const key = keyset.find((k3) => k3.keyId === keyId);
|
|
4952
|
-
if (!key) {
|
|
4953
|
-
throw new SignatureError(`Unknown signing key (key_id=${keyId}). Update your CLI: npm update -g @przeprogramowani/10x-cli`);
|
|
4954
|
-
}
|
|
4955
|
-
const computedHash = sha256Hex(rawBody);
|
|
4956
|
-
if (computedHash !== headerHash) {
|
|
4957
|
-
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.");
|
|
4958
|
-
}
|
|
4959
|
-
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) {
|
|
4960
5640
|
try {
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
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);
|
|
4966
5646
|
} catch {
|
|
4967
|
-
|
|
4968
|
-
}
|
|
4969
|
-
const canonical = canonicalSigningString(keyId, computedHash);
|
|
4970
|
-
const sig = Buffer.from(signature, "base64");
|
|
4971
|
-
const valid = verify(null, Buffer.from(canonical), pubKey, sig);
|
|
4972
|
-
if (!valid) {
|
|
4973
|
-
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.");
|
|
4974
|
-
}
|
|
4975
|
-
}
|
|
4976
|
-
|
|
4977
|
-
// src/lib/api-content.ts
|
|
4978
|
-
function fetchCatalog(course, token, options = {}) {
|
|
4979
|
-
return apiGet(`/api/catalog/${encodeURIComponent(course)}`, { token, signal: options.signal });
|
|
4980
|
-
}
|
|
4981
|
-
function fetchModuleDetail(course, module, token, options = {}) {
|
|
4982
|
-
return apiGet(`/api/modules/${encodeURIComponent(course)}/${module}`, { token, signal: options.signal });
|
|
4983
|
-
}
|
|
4984
|
-
async function fetchLesson(course, lessonId, token, options = {}) {
|
|
4985
|
-
const params = new URLSearchParams;
|
|
4986
|
-
if (options.lang)
|
|
4987
|
-
params.set("lang", options.lang);
|
|
4988
|
-
if (options.tool)
|
|
4989
|
-
params.set("tool", options.tool);
|
|
4990
|
-
const qs = params.toString();
|
|
4991
|
-
const path = `/api/lessons/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}${qs ? `?${qs}` : ""}`;
|
|
4992
|
-
const result = await apiGet(path, { token, signal: options.signal });
|
|
4993
|
-
if (!result.ok)
|
|
4994
|
-
return result;
|
|
4995
|
-
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
4996
|
-
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
4997
|
-
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
4998
|
-
if (signature && keyIdRaw && headerHash) {
|
|
4999
|
-
const keyId = Number(keyIdRaw);
|
|
5000
|
-
try {
|
|
5001
|
-
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
5002
|
-
} catch (err) {
|
|
5003
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5004
|
-
return {
|
|
5005
|
-
ok: false,
|
|
5006
|
-
status: 0,
|
|
5007
|
-
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
5008
|
-
error: message
|
|
5009
|
-
};
|
|
5010
|
-
}
|
|
5011
|
-
} else if (signature || keyIdRaw || headerHash) {
|
|
5012
|
-
return {
|
|
5013
|
-
ok: false,
|
|
5014
|
-
status: 0,
|
|
5015
|
-
code: "signature_error",
|
|
5016
|
-
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."
|
|
5017
|
-
};
|
|
5018
|
-
} else if (REQUIRE_SIGNATURES) {
|
|
5019
|
-
return {
|
|
5020
|
-
ok: false,
|
|
5021
|
-
status: 0,
|
|
5022
|
-
code: "signature_missing",
|
|
5023
|
-
error: "Bundle is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
5024
|
-
};
|
|
5025
|
-
} else {
|
|
5026
|
-
process.stderr.write(`Warning: bundle is not signed. Signature verification skipped.
|
|
5027
|
-
`);
|
|
5647
|
+
return false;
|
|
5028
5648
|
}
|
|
5029
|
-
return result;
|
|
5030
5649
|
}
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
params.set("lang", options.lang);
|
|
5035
|
-
const path = `/api/artifacts/${encodeURIComponent(course)}/${encodeURIComponent(lessonId)}/${encodeURIComponent(type)}/${encodeURIComponent(name)}?${params}`;
|
|
5036
|
-
const result = await apiGet(path, { token, signal: options.signal });
|
|
5037
|
-
if (!result.ok)
|
|
5038
|
-
return result;
|
|
5039
|
-
const signature = result.responseHeaders.get("X-Bundle-Signature");
|
|
5040
|
-
const keyIdRaw = result.responseHeaders.get("X-Bundle-Key-Id");
|
|
5041
|
-
const headerHash = result.responseHeaders.get("X-Bundle-Content-Hash");
|
|
5042
|
-
if (signature && keyIdRaw && headerHash) {
|
|
5043
|
-
const keyId = Number(keyIdRaw);
|
|
5044
|
-
try {
|
|
5045
|
-
verifyBundleSignature(result.rawBody, signature, keyId, headerHash);
|
|
5046
|
-
} catch (err) {
|
|
5047
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
5048
|
-
return {
|
|
5049
|
-
ok: false,
|
|
5050
|
-
status: 0,
|
|
5051
|
-
code: err instanceof SignatureError ? "signature_error" : "signature_internal_error",
|
|
5052
|
-
error: message
|
|
5053
|
-
};
|
|
5054
|
-
}
|
|
5055
|
-
} else if (signature || keyIdRaw || headerHash) {
|
|
5056
|
-
return {
|
|
5057
|
-
ok: false,
|
|
5058
|
-
status: 0,
|
|
5059
|
-
code: "signature_error",
|
|
5060
|
-
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."
|
|
5061
|
-
};
|
|
5062
|
-
} else if (REQUIRE_SIGNATURES) {
|
|
5063
|
-
return {
|
|
5064
|
-
ok: false,
|
|
5065
|
-
status: 0,
|
|
5066
|
-
code: "signature_missing",
|
|
5067
|
-
error: "Artifact is missing a signature. The API may be misconfigured or compromised. " + "Do NOT use the content. Report this to the course team."
|
|
5068
|
-
};
|
|
5069
|
-
} else {
|
|
5070
|
-
process.stderr.write(`Warning: artifact is not signed. Signature verification skipped.
|
|
5071
|
-
`);
|
|
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);
|
|
5072
5653
|
}
|
|
5073
|
-
|
|
5654
|
+
throw error;
|
|
5074
5655
|
}
|
|
5075
|
-
async function
|
|
5076
|
-
const
|
|
5077
|
-
const
|
|
5078
|
-
const
|
|
5079
|
-
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();
|
|
5080
5660
|
try {
|
|
5081
|
-
const
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
const
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
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);
|
|
5096
5679
|
}
|
|
5097
5680
|
}
|
|
5098
|
-
function apiBaseUrl() {
|
|
5099
|
-
return resolveApiBase();
|
|
5100
|
-
}
|
|
5101
5681
|
|
|
5102
5682
|
// src/lib/update-check.ts
|
|
5103
5683
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org/@przeprogramowani/10x-cli/latest";
|
|
@@ -5172,7 +5752,7 @@ function registerDoctorCommand(cli) {
|
|
|
5172
5752
|
async function runDoctor(ctx) {
|
|
5173
5753
|
verbose(ctx, "running doctor checks");
|
|
5174
5754
|
const checks = [];
|
|
5175
|
-
checks.push(checkAuth());
|
|
5755
|
+
checks.push(await checkAuth());
|
|
5176
5756
|
checks.push(await checkApiConnectivity());
|
|
5177
5757
|
checks.push(checkConfigDirectory());
|
|
5178
5758
|
checks.push(await checkCliVersion());
|
|
@@ -5221,7 +5801,7 @@ async function runDoctor(ctx) {
|
|
|
5221
5801
|
process.exit(EX_CONFIG);
|
|
5222
5802
|
}
|
|
5223
5803
|
}
|
|
5224
|
-
function checkAuth() {
|
|
5804
|
+
async function checkAuth() {
|
|
5225
5805
|
const auth = readAuth();
|
|
5226
5806
|
if (!auth) {
|
|
5227
5807
|
return {
|
|
@@ -5242,6 +5822,16 @@ function checkAuth() {
|
|
|
5242
5822
|
details: { email: auth.email, expires_at: auth.expires_at }
|
|
5243
5823
|
};
|
|
5244
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 };
|
|
5245
5835
|
const nearExpiry = isNearExpiry(auth);
|
|
5246
5836
|
const prettyDate = formatReleaseAt(auth.expires_at);
|
|
5247
5837
|
if (nearExpiry) {
|
|
@@ -5251,15 +5841,15 @@ function checkAuth() {
|
|
|
5251
5841
|
status: "warn",
|
|
5252
5842
|
message: `Signed in as ${auth.email}, but the session expires ${prettyDate.toLowerCase()}.`,
|
|
5253
5843
|
hint: "The next command will transparently refresh the token.",
|
|
5254
|
-
details: { email: auth.email, expires_at: auth.expires_at }
|
|
5844
|
+
details: { email: auth.email, expires_at: auth.expires_at, ...accessDetails }
|
|
5255
5845
|
};
|
|
5256
5846
|
}
|
|
5257
5847
|
return {
|
|
5258
5848
|
name: "auth",
|
|
5259
5849
|
label: "Auth",
|
|
5260
5850
|
status: "pass",
|
|
5261
|
-
message: `Signed in as ${auth.email} — session expires ${prettyDate.toLowerCase()}.`,
|
|
5262
|
-
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 }
|
|
5263
5853
|
};
|
|
5264
5854
|
}
|
|
5265
5855
|
async function checkApiConnectivity() {
|
|
@@ -5360,7 +5950,7 @@ function checkToolDirectory() {
|
|
|
5360
5950
|
const toolId = readToolConfig()?.tool ?? DEFAULT_TOOL;
|
|
5361
5951
|
const profile = getToolProfile(toolId) ?? PROFILES[DEFAULT_TOOL];
|
|
5362
5952
|
const dirName = profile.manifestDir;
|
|
5363
|
-
const toolDir =
|
|
5953
|
+
const toolDir = join6(cwd, dirName);
|
|
5364
5954
|
if (!existsSync5(toolDir)) {
|
|
5365
5955
|
return {
|
|
5366
5956
|
name: "tool-dir",
|
|
@@ -5393,7 +5983,7 @@ function checkToolDirectory() {
|
|
|
5393
5983
|
}
|
|
5394
5984
|
|
|
5395
5985
|
// src/commands/get.ts
|
|
5396
|
-
import { join as
|
|
5986
|
+
import { join as join10 } from "node:path";
|
|
5397
5987
|
|
|
5398
5988
|
// src/lib/conflict-prompt.ts
|
|
5399
5989
|
init_dist2();
|
|
@@ -5481,108 +6071,163 @@ function parseModuleRef(raw) {
|
|
|
5481
6071
|
init_dist2();
|
|
5482
6072
|
|
|
5483
6073
|
// src/lib/tool-switch.ts
|
|
5484
|
-
import {
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
mkdirSync as mkdirSync5,
|
|
5488
|
-
readFileSync as readFileSync6,
|
|
5489
|
-
renameSync as renameSync3,
|
|
5490
|
-
rmdirSync as rmdirSync2,
|
|
5491
|
-
rmSync as rmSync5,
|
|
5492
|
-
statSync,
|
|
5493
|
-
writeFileSync as writeFileSync4
|
|
5494
|
-
} from "node:fs";
|
|
5495
|
-
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";
|
|
5496
6077
|
|
|
5497
|
-
// src/lib/
|
|
5498
|
-
import { readFileSync as
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
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);
|
|
5504
6123
|
}
|
|
5505
6124
|
}
|
|
5506
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
|
+
|
|
5507
6130
|
// src/lib/sentinel-migration.ts
|
|
5508
6131
|
var OLD_BEGIN = "<!-- BEGIN @przeprogramowani/10x-toolkit -->";
|
|
5509
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
|
+
}
|
|
5510
6146
|
function removeRulesBlockWithMarkers(existing, begin, end) {
|
|
5511
|
-
const
|
|
5512
|
-
|
|
5513
|
-
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
|
6147
|
+
const block = inspectRulesBlock(existing, begin, end);
|
|
6148
|
+
if (!block)
|
|
5514
6149
|
return { content: existing, removed: false };
|
|
5515
|
-
}
|
|
5516
|
-
const beforeRaw = existing.slice(0, beginIdx);
|
|
5517
|
-
const afterRaw = existing.slice(endIdx + end.length);
|
|
5518
|
-
const before = beforeRaw.replace(/[\r\n]*$/, "");
|
|
5519
|
-
const after = afterRaw.replace(/^[\r\n]*/, "");
|
|
5520
|
-
const joiner = before.length > 0 && after.length > 0 ? `
|
|
5521
|
-
|
|
5522
|
-
` : "";
|
|
5523
|
-
const combined = before + joiner + after;
|
|
5524
|
-
const content = combined.length === 0 ? "" : combined.replace(/[\r\n]*$/, `
|
|
5525
|
-
`);
|
|
5526
|
-
return { content, removed: true };
|
|
6150
|
+
return { content: existing.slice(0, block.start) + existing.slice(block.finish), removed: true };
|
|
5527
6151
|
}
|
|
5528
|
-
function applyRulesBlockWithMarkers(
|
|
5529
|
-
for (const marker of [OLD_BEGIN, OLD_END,
|
|
5530
|
-
if (
|
|
5531
|
-
throw new Error(`rules body contains a sentinel marker (${JSON.stringify(marker)})
|
|
5532
|
-
}
|
|
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)})`);
|
|
5533
6156
|
}
|
|
5534
|
-
const
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
content = stripBlock(content, beginMarker, endMarker, "@przeprogramowani/10x-cli", warnings);
|
|
5538
|
-
content = content.replace(/\n{3,}/g, `
|
|
5539
|
-
|
|
5540
|
-
`).trim();
|
|
5541
|
-
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}
|
|
5542
6160
|
|
|
5543
|
-
${
|
|
5544
|
-
|
|
5545
|
-
${
|
|
5546
|
-
const
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
}
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
}
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
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 };
|
|
5572
6220
|
}
|
|
5573
6221
|
|
|
5574
6222
|
// src/lib/writer.ts
|
|
5575
6223
|
import {
|
|
5576
6224
|
chmodSync as chmodSync2,
|
|
5577
|
-
existsSync as
|
|
6225
|
+
existsSync as existsSync8,
|
|
5578
6226
|
mkdirSync as mkdirSync4,
|
|
5579
|
-
readFileSync as
|
|
5580
|
-
|
|
5581
|
-
rmdirSync,
|
|
5582
|
-
rmSync as rmSync4,
|
|
5583
|
-
writeFileSync as writeFileSync3
|
|
6227
|
+
readFileSync as readFileSync7,
|
|
6228
|
+
writeFileSync as writeFileSync4
|
|
5584
6229
|
} from "node:fs";
|
|
5585
|
-
import { dirname as
|
|
6230
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
5586
6231
|
var CLI_VERSION = package_default.version;
|
|
5587
6232
|
var DEFAULT_COURSE = "10xdevs3";
|
|
5588
6233
|
async function applyBundle(bundle, projectRoot, options = {}) {
|
|
@@ -5590,221 +6235,208 @@ async function applyBundle(bundle, projectRoot, options = {}) {
|
|
|
5590
6235
|
const partial = options.partial === true;
|
|
5591
6236
|
const course = options.course ?? DEFAULT_COURSE;
|
|
5592
6237
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
5593
|
-
const onConflict = options.onConflict;
|
|
5594
6238
|
const applyCourseRules = options.applyCourseRules !== false;
|
|
5595
|
-
const
|
|
5596
|
-
const
|
|
5597
|
-
const
|
|
5598
|
-
const
|
|
5599
|
-
|
|
5600
|
-
|
|
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
|
+
};
|
|
5601
6310
|
for (let si = 0;si < bundle.skills.length; si++) {
|
|
5602
6311
|
const skill = bundle.skills[si];
|
|
5603
|
-
const
|
|
5604
|
-
|
|
5605
|
-
const fileWrites = [];
|
|
5606
|
-
const skillHashes = {};
|
|
6312
|
+
const writes = [];
|
|
6313
|
+
result.skills.push({ name: skill.name, files: writes });
|
|
5607
6314
|
for (let fi = 0;fi < skill.files.length; fi++) {
|
|
5608
6315
|
const file = skill.files[fi];
|
|
5609
|
-
const
|
|
5610
|
-
const
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
const resolution = onConflict ? await onConflict({
|
|
5617
|
-
artifactType: "skill",
|
|
5618
|
-
artifactName: `${skill.name}/${file.path}`,
|
|
5619
|
-
filePath: target,
|
|
5620
|
-
relativePath: file.path
|
|
5621
|
-
}) : "skip";
|
|
5622
|
-
if (resolution === "overwrite") {
|
|
5623
|
-
finalAction = "conflict_overwritten";
|
|
5624
|
-
if (!dryRun)
|
|
5625
|
-
writeFileAt(target, file.content);
|
|
5626
|
-
skillHashes[file.path] = contentHash(file.content);
|
|
5627
|
-
} else if (resolution === "save_user") {
|
|
5628
|
-
finalAction = "conflict_saved_user";
|
|
5629
|
-
if (!dryRun) {
|
|
5630
|
-
userBackupPath = buildUserBackupPath(target);
|
|
5631
|
-
copyFileSync(target, userBackupPath);
|
|
5632
|
-
writeFileAt(target, file.content);
|
|
5633
|
-
}
|
|
5634
|
-
skillHashes[file.path] = contentHash(file.content);
|
|
5635
|
-
} else {
|
|
5636
|
-
finalAction = "conflict_skipped";
|
|
5637
|
-
if (storedHash)
|
|
5638
|
-
skillHashes[file.path] = storedHash;
|
|
5639
|
-
}
|
|
5640
|
-
} else {
|
|
5641
|
-
if (!dryRun && action !== "unchanged") {
|
|
5642
|
-
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);
|
|
5643
6323
|
}
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
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();
|
|
5648
6332
|
}
|
|
5649
|
-
fileWrites.push({ path: file.path, absolutePath: target, action: finalAction, userBackupPath });
|
|
5650
6333
|
}
|
|
5651
|
-
nextSkillHashes[skill.name] = skillHashes;
|
|
5652
|
-
skills.push({ name: skill.name, files: fileWrites });
|
|
5653
6334
|
}
|
|
5654
|
-
const prompts = [];
|
|
5655
6335
|
for (let pi = 0;pi < bundle.prompts.length; pi++) {
|
|
5656
6336
|
const prompt = bundle.prompts[pi];
|
|
5657
|
-
const
|
|
5658
|
-
const
|
|
5659
|
-
const
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
if (storedHash)
|
|
5687
|
-
nextPromptHashes[promptFilename] = storedHash;
|
|
5688
|
-
}
|
|
5689
|
-
} else {
|
|
5690
|
-
if (!dryRun && action !== "unchanged") {
|
|
5691
|
-
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);
|
|
5692
6366
|
}
|
|
5693
|
-
nextPromptHashes[promptFilename] = contentHash(prompt.content);
|
|
5694
6367
|
}
|
|
5695
|
-
prompts.push({ name: prompt.name, path: target, action: finalAction, userBackupPath });
|
|
5696
|
-
}
|
|
5697
|
-
const rulesFilePath = join6(projectRoot, profile.rulesFile);
|
|
5698
|
-
const existingRules = readFileOrEmpty(rulesFilePath);
|
|
5699
|
-
const { action: rulesAction, content: newRulesContent } = planRules(existingRules, bundle, profile, applyCourseRules);
|
|
5700
|
-
if (!dryRun && rulesAction !== "unchanged") {
|
|
5701
|
-
writeFileAt(rulesFilePath, newRulesContent);
|
|
5702
6368
|
}
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
};
|
|
5716
|
-
for (
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5741
|
-
|
|
5742
|
-
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
])),
|
|
5755
|
-
prompts: [...prevManifest.files.prompts],
|
|
5756
|
-
configs: [...prevManifest.files.configs]
|
|
5757
|
-
};
|
|
5758
|
-
} else if (prevManifest?.lessons) {
|
|
5759
|
-
baseLessons = { ...prevManifest.lessons };
|
|
5760
|
-
}
|
|
5761
|
-
const lessons = {
|
|
5762
|
-
...baseLessons,
|
|
5763
|
-
[bundle.lessonId]: newLessonEntry
|
|
5764
|
-
};
|
|
5765
|
-
const union = buildUnionFiles(lessons);
|
|
5766
|
-
const unionSkills = {};
|
|
5767
|
-
for (const [name, skill] of Object.entries(union.skills)) {
|
|
5768
|
-
const prevHash = prevManifest?.files.skills[name]?.contentHashes;
|
|
5769
|
-
const currentHash = nextSkillHashes[name];
|
|
5770
|
-
unionSkills[name] = {
|
|
5771
|
-
files: skill.files,
|
|
5772
|
-
contentHashes: { ...prevHash, ...currentHash }
|
|
5773
|
-
};
|
|
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
|
+
}
|
|
5774
6420
|
}
|
|
5775
|
-
const unionPromptHashes = {
|
|
5776
|
-
...prevManifest?.files.promptHashes,
|
|
5777
|
-
...nextPromptHashes
|
|
5778
|
-
};
|
|
5779
|
-
const nextManifest = {
|
|
5780
|
-
package: CLI_PACKAGE_NAME,
|
|
5781
|
-
version: CLI_VERSION,
|
|
5782
|
-
manifestVersion: MANIFEST_VERSION,
|
|
5783
|
-
lastApplied: new Date().toISOString(),
|
|
5784
|
-
lessonId: bundle.lessonId,
|
|
5785
|
-
course,
|
|
5786
|
-
tool: profile.toolId,
|
|
5787
|
-
files: {
|
|
5788
|
-
skills: unionSkills,
|
|
5789
|
-
prompts: union.prompts,
|
|
5790
|
-
configs: union.configs,
|
|
5791
|
-
promptHashes: unionPromptHashes
|
|
5792
|
-
},
|
|
5793
|
-
lessons
|
|
5794
|
-
};
|
|
5795
|
-
writeManifest(manifestDir, nextManifest);
|
|
5796
6421
|
}
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
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;
|
|
5804
6433
|
}
|
|
5805
6434
|
function planBundle(bundle, projectRoot, options = {}) {
|
|
5806
6435
|
const profile = options.profile ?? PROFILES[DEFAULT_TOOL];
|
|
5807
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");
|
|
5808
6440
|
for (const skill of bundle.skills) {
|
|
5809
6441
|
assertSafeName(skill.name, "skill");
|
|
5810
6442
|
for (const file of skill.files)
|
|
@@ -5814,13 +6446,15 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5814
6446
|
assertSafeName(prompt.name, "prompt");
|
|
5815
6447
|
for (const config of bundle.configs)
|
|
5816
6448
|
assertSafeName(config.name, "config");
|
|
5817
|
-
const manifestDir =
|
|
6449
|
+
const manifestDir = join8(projectRoot, profile.manifestDir);
|
|
6450
|
+
assertProjectFilePath(projectRoot, join8(manifestDir, MANIFEST_FILENAME));
|
|
5818
6451
|
const prevManifest = readManifest(manifestDir);
|
|
5819
6452
|
const skills = bundle.skills.map((skill) => {
|
|
5820
|
-
const skillDir =
|
|
6453
|
+
const skillDir = join8(projectRoot, profile.skillDir(skill.name));
|
|
5821
6454
|
const prevEntry = prevManifest?.files.skills[skill.name];
|
|
5822
6455
|
const files = skill.files.map((file) => {
|
|
5823
|
-
const target =
|
|
6456
|
+
const target = join8(skillDir, file.path);
|
|
6457
|
+
assertProjectFilePath(projectRoot, target);
|
|
5824
6458
|
const storedHash = prevEntry?.contentHashes?.[file.path];
|
|
5825
6459
|
const { action, isConflict } = computeFileAction(target, file.content, storedHash);
|
|
5826
6460
|
return {
|
|
@@ -5834,7 +6468,8 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5834
6468
|
return { name: skill.name, files };
|
|
5835
6469
|
});
|
|
5836
6470
|
const prompts = bundle.prompts.map((prompt) => {
|
|
5837
|
-
const target =
|
|
6471
|
+
const target = join8(projectRoot, profile.promptPath(prompt.name));
|
|
6472
|
+
assertProjectFilePath(projectRoot, target);
|
|
5838
6473
|
const storedHash = prevManifest?.files.promptHashes?.[`${prompt.name}.md`];
|
|
5839
6474
|
const { action, isConflict } = computeFileAction(target, prompt.content, storedHash);
|
|
5840
6475
|
return {
|
|
@@ -5845,12 +6480,14 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5845
6480
|
upstreamChanged: computeUpstreamChanged(prompt.content, storedHash, action)
|
|
5846
6481
|
};
|
|
5847
6482
|
});
|
|
5848
|
-
const rulesFilePath =
|
|
5849
|
-
const
|
|
5850
|
-
|
|
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);
|
|
5851
6487
|
const configs = bundle.configs.map((config) => {
|
|
5852
|
-
const target =
|
|
5853
|
-
|
|
6488
|
+
const target = join8(projectRoot, profile.configPath(config.name));
|
|
6489
|
+
assertProjectFilePath(projectRoot, target);
|
|
6490
|
+
const action = existsSync8(target) ? "skipped" : "created";
|
|
5854
6491
|
return {
|
|
5855
6492
|
name: config.name,
|
|
5856
6493
|
path: target,
|
|
@@ -5860,58 +6497,56 @@ function planBundle(bundle, projectRoot, options = {}) {
|
|
|
5860
6497
|
};
|
|
5861
6498
|
});
|
|
5862
6499
|
const removed = computeRemovals(prevManifest, bundle, profile, projectRoot);
|
|
5863
|
-
const
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
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;
|
|
5871
6512
|
return {
|
|
5872
6513
|
skills,
|
|
5873
6514
|
prompts,
|
|
5874
|
-
rules: { action:
|
|
6515
|
+
rules: { action: rulesPlan.action, isConflict: rulesPlan.isConflict, reason: rulesPlan.reason, upstreamChanged: rulesPlan.action !== "unchanged" },
|
|
5875
6516
|
configs,
|
|
5876
6517
|
removals
|
|
5877
6518
|
};
|
|
5878
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
|
+
}
|
|
5879
6541
|
function computeUpstreamChanged(newContent, storedHash, action) {
|
|
5880
6542
|
if (storedHash === undefined)
|
|
5881
6543
|
return action !== "unchanged";
|
|
5882
6544
|
return contentHash(newContent) !== storedHash;
|
|
5883
6545
|
}
|
|
5884
|
-
function planRules(existingRules, bundle, profile, applyCourseRules) {
|
|
5885
|
-
if (!applyCourseRules) {
|
|
5886
|
-
const { content: stripped, removed } = removeRulesBlockWithMarkers(existingRules, profile.sentinelBegin, profile.sentinelEnd);
|
|
5887
|
-
if (removed && stripped !== existingRules)
|
|
5888
|
-
return { action: "removed", content: stripped };
|
|
5889
|
-
return { action: "unchanged", content: existingRules };
|
|
5890
|
-
}
|
|
5891
|
-
if (bundle.rules.length === 0)
|
|
5892
|
-
return { action: "unchanged", content: existingRules };
|
|
5893
|
-
const rulesBody = bundle.rules.map((r2) => r2.content.trim()).join(`
|
|
5894
|
-
|
|
5895
|
-
`);
|
|
5896
|
-
const { content: newRules } = applyRulesBlockWithMarkers(existingRules, rulesBody, profile.sentinelBegin, profile.sentinelEnd);
|
|
5897
|
-
let action;
|
|
5898
|
-
if (newRules === existingRules)
|
|
5899
|
-
action = "unchanged";
|
|
5900
|
-
else if (existingRules.length === 0)
|
|
5901
|
-
action = "created";
|
|
5902
|
-
else
|
|
5903
|
-
action = "updated";
|
|
5904
|
-
return { action, content: newRules };
|
|
5905
|
-
}
|
|
5906
6546
|
function computeFileAction(filePath, newContent, storedHash) {
|
|
5907
|
-
if (!
|
|
6547
|
+
if (!existsSync8(filePath))
|
|
5908
6548
|
return { action: "created", isConflict: false };
|
|
5909
|
-
|
|
5910
|
-
try {
|
|
5911
|
-
current = readFileSync5(filePath, "utf8");
|
|
5912
|
-
} catch {
|
|
5913
|
-
return { action: "updated", isConflict: false };
|
|
5914
|
-
}
|
|
6549
|
+
const current = readFileSync7(filePath, "utf8");
|
|
5915
6550
|
if (current === newContent)
|
|
5916
6551
|
return { action: "unchanged", isConflict: false };
|
|
5917
6552
|
if (storedHash !== undefined) {
|
|
@@ -5924,152 +6559,62 @@ function computeFileAction(filePath, newContent, storedHash) {
|
|
|
5924
6559
|
}
|
|
5925
6560
|
function buildUserBackupPath(filePath) {
|
|
5926
6561
|
const lastDot = filePath.lastIndexOf(".");
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
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;
|
|
5930
6569
|
}
|
|
5931
6570
|
function copyFileSync(src, dest) {
|
|
5932
|
-
mkdirSync4(
|
|
5933
|
-
|
|
5934
|
-
}
|
|
5935
|
-
function readFileOrEmpty(filePath) {
|
|
5936
|
-
if (!existsSync6(filePath))
|
|
5937
|
-
return "";
|
|
5938
|
-
try {
|
|
5939
|
-
return readFileSync5(filePath, "utf8");
|
|
5940
|
-
} catch {
|
|
5941
|
-
return "";
|
|
5942
|
-
}
|
|
6571
|
+
mkdirSync4(dirname4(dest), { recursive: true });
|
|
6572
|
+
writeFileSync4(dest, readFileSync7(src));
|
|
5943
6573
|
}
|
|
5944
6574
|
function writeFileAt(filePath, content) {
|
|
5945
|
-
mkdirSync4(
|
|
5946
|
-
|
|
5947
|
-
}
|
|
5948
|
-
function computeRemovals(
|
|
5949
|
-
const
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
const
|
|
5958
|
-
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
if (lessonId === bundle.lessonId)
|
|
5965
|
-
continue;
|
|
5966
|
-
for (const [name, skill] of Object.entries(entry.skills)) {
|
|
5967
|
-
if (!protectedSkills.has(name))
|
|
5968
|
-
protectedSkills.set(name, new Set);
|
|
5969
|
-
for (const f2 of skill.files)
|
|
5970
|
-
protectedSkills.get(name).add(f2);
|
|
5971
|
-
}
|
|
5972
|
-
for (const p2 of entry.prompts)
|
|
5973
|
-
protectedPrompts.add(p2);
|
|
5974
|
-
for (const c2 of entry.configs)
|
|
5975
|
-
protectedConfigs.add(c2);
|
|
5976
|
-
}
|
|
5977
|
-
const currentSkills = new Map(bundle.skills.map((s) => [s.name, new Set(s.files.map((f2) => f2.path))]));
|
|
5978
|
-
const currentPrompts = new Set(bundle.prompts.map((p2) => `${p2.name}.md`));
|
|
5979
|
-
const currentConfigs = new Set(bundle.configs.map((c2) => c2.name));
|
|
5980
|
-
const removed = {
|
|
5981
|
-
skillDirs: [],
|
|
5982
|
-
skillFiles: [],
|
|
5983
|
-
prompts: [],
|
|
5984
|
-
configs: []
|
|
5985
|
-
};
|
|
5986
|
-
for (const [skillName, skill] of Object.entries(prevLessonEntry.skills)) {
|
|
5987
|
-
if (!isSafeName(skillName))
|
|
5988
|
-
continue;
|
|
5989
|
-
const skillDirAbs = join6(projectRoot, profile.skillDir(skillName));
|
|
5990
|
-
if (!currentSkills.has(skillName)) {
|
|
5991
|
-
if (protectedSkills.has(skillName)) {
|
|
5992
|
-
const prot = protectedSkills.get(skillName);
|
|
5993
|
-
for (const relPath of skill.files) {
|
|
5994
|
-
if (prot.has(relPath))
|
|
5995
|
-
continue;
|
|
5996
|
-
if (!isSafeSkillFilePath(relPath))
|
|
5997
|
-
continue;
|
|
5998
|
-
removed.skillFiles.push({
|
|
5999
|
-
name: `${skillName}/${relPath}`,
|
|
6000
|
-
path: join6(skillDirAbs, relPath),
|
|
6001
|
-
skillDirAbs
|
|
6002
|
-
});
|
|
6003
|
-
}
|
|
6004
|
-
} else {
|
|
6005
|
-
removed.skillDirs.push({ name: skillName, path: skillDirAbs });
|
|
6006
|
-
}
|
|
6007
|
-
continue;
|
|
6008
|
-
}
|
|
6009
|
-
const currentFiles = currentSkills.get(skillName);
|
|
6010
|
-
for (const relPath of skill.files) {
|
|
6011
|
-
if (currentFiles.has(relPath))
|
|
6012
|
-
continue;
|
|
6013
|
-
if (protectedSkills.get(skillName)?.has(relPath))
|
|
6014
|
-
continue;
|
|
6015
|
-
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)))
|
|
6016
6594
|
continue;
|
|
6017
|
-
|
|
6018
|
-
name: `${skillName}/${relPath}`,
|
|
6019
|
-
path: join6(skillDirAbs, relPath),
|
|
6020
|
-
skillDirAbs
|
|
6021
|
-
});
|
|
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)) }));
|
|
6022
6596
|
}
|
|
6023
6597
|
}
|
|
6024
|
-
for (const
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
removed.prompts.push({
|
|
6033
|
-
name: promptFile,
|
|
6034
|
-
path: join6(projectRoot, profile.promptPath(promptName))
|
|
6035
|
-
});
|
|
6036
|
-
}
|
|
6037
|
-
for (const configFile of prevLessonEntry.configs) {
|
|
6038
|
-
if (currentConfigs.has(configFile))
|
|
6039
|
-
continue;
|
|
6040
|
-
if (protectedConfigs.has(configFile))
|
|
6041
|
-
continue;
|
|
6042
|
-
if (!isSafeName(configFile))
|
|
6043
|
-
continue;
|
|
6044
|
-
removed.configs.push({
|
|
6045
|
-
name: configFile,
|
|
6046
|
-
path: join6(projectRoot, profile.configPath(configFile))
|
|
6047
|
-
});
|
|
6048
|
-
}
|
|
6049
|
-
return removed;
|
|
6050
|
-
}
|
|
6051
|
-
function removeEmptyParentDirs(filePath, skillDirAbs) {
|
|
6052
|
-
let dir = dirname3(filePath);
|
|
6053
|
-
while (dir.startsWith(skillDirAbs) && dir !== skillDirAbs) {
|
|
6054
|
-
try {
|
|
6055
|
-
if (readdirSync(dir).length > 0)
|
|
6056
|
-
return;
|
|
6057
|
-
rmdirSync(dir);
|
|
6058
|
-
} catch {
|
|
6059
|
-
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" }));
|
|
6060
6606
|
}
|
|
6061
|
-
|
|
6062
|
-
}
|
|
6607
|
+
return out;
|
|
6063
6608
|
}
|
|
6064
6609
|
function findOrphanedManifests(projectRoot, currentProfile) {
|
|
6065
6610
|
const out = [];
|
|
6066
6611
|
for (const profile of [...Object.values(PROFILES), ...Object.values(LEGACY_PROFILES)]) {
|
|
6067
6612
|
if (profile.toolId === currentProfile.toolId)
|
|
6068
6613
|
continue;
|
|
6069
|
-
const manifestPath =
|
|
6070
|
-
if (!
|
|
6614
|
+
const manifestPath = join8(projectRoot, profile.manifestDir, MANIFEST_FILENAME);
|
|
6615
|
+
if (!existsSync8(manifestPath))
|
|
6071
6616
|
continue;
|
|
6072
|
-
const manifest = readManifest(
|
|
6617
|
+
const manifest = readManifest(join8(projectRoot, profile.manifestDir));
|
|
6073
6618
|
if (!manifest)
|
|
6074
6619
|
continue;
|
|
6075
6620
|
out.push({ profile, manifestPath, manifest });
|
|
@@ -6134,231 +6679,315 @@ function isSafeName(name) {
|
|
|
6134
6679
|
}
|
|
6135
6680
|
|
|
6136
6681
|
// src/lib/tool-switch.ts
|
|
6137
|
-
function
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
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))
|
|
6149
6700
|
continue;
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6155
|
-
|
|
6156
|
-
|
|
6157
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
|
|
6161
|
-
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
anyMoved = true;
|
|
6166
|
-
}
|
|
6167
|
-
tryRemoveEmptyDir(fromSkillDir);
|
|
6168
|
-
if (anyMoved)
|
|
6169
|
-
summary.movedOrRemoved.skills.push(skillName);
|
|
6170
|
-
}
|
|
6171
|
-
for (const promptFile of orphan.manifest.files.prompts) {
|
|
6172
|
-
if (!isSafeName(promptFile)) {
|
|
6173
|
-
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))
|
|
6174
6716
|
continue;
|
|
6175
|
-
}
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
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;
|
|
6182
6748
|
}
|
|
6183
|
-
|
|
6184
|
-
|
|
6185
|
-
|
|
6186
|
-
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
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
|
+
}
|
|
6192
6775
|
}
|
|
6776
|
+
throw error;
|
|
6193
6777
|
}
|
|
6194
|
-
summary.sentinelStripped = stripSentinelFromRulesFile(projectRoot, oldProfile);
|
|
6195
|
-
rmSync5(orphan.manifestPath, { force: true });
|
|
6196
|
-
tryRemoveEmptyDir(join7(projectRoot, oldProfile.manifestDir));
|
|
6197
|
-
return summary;
|
|
6198
6778
|
}
|
|
6199
|
-
function
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
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);
|
|
6211
6847
|
continue;
|
|
6212
6848
|
}
|
|
6213
|
-
const
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
summary.skipped.push({
|
|
6217
|
-
path: `${skillName}/${relPath}`,
|
|
6218
|
-
reason: "unsafe path in manifest"
|
|
6219
|
-
});
|
|
6220
|
-
continue;
|
|
6221
|
-
}
|
|
6222
|
-
rmSync5(join7(skillDir, relPath), { force: true });
|
|
6223
|
-
}
|
|
6224
|
-
rmSync5(skillDir, { recursive: true, force: true });
|
|
6225
|
-
summary.movedOrRemoved.skills.push(skillName);
|
|
6226
|
-
}
|
|
6227
|
-
for (const promptFile of orphan.manifest.files.prompts) {
|
|
6228
|
-
if (!isSafeName(promptFile)) {
|
|
6229
|
-
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" });
|
|
6230
6852
|
continue;
|
|
6231
6853
|
}
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
|
|
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);
|
|
6239
6867
|
continue;
|
|
6240
6868
|
}
|
|
6241
|
-
|
|
6242
|
-
|
|
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));
|
|
6243
6881
|
}
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "skills"));
|
|
6248
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "prompts"));
|
|
6249
|
-
tryRemoveEmptyDir(join7(manifestDirAbs, "config-templates"));
|
|
6250
|
-
tryRemoveEmptyDir(manifestDirAbs);
|
|
6882
|
+
commitSourceRules(root, orphan.profile, source, () => {
|
|
6883
|
+
transferRules(root, orphan.profile, profile, source, destination, summary);
|
|
6884
|
+
});
|
|
6251
6885
|
return summary;
|
|
6252
6886
|
}
|
|
6253
|
-
function
|
|
6254
|
-
|
|
6255
|
-
|
|
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;
|
|
6256
6895
|
}
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6896
|
+
const existing = readFileSync8(from, "utf8");
|
|
6897
|
+
const block = inspectRulesBlock(existing, old.sentinelBegin, old.sentinelEnd);
|
|
6898
|
+
if (!block) {
|
|
6899
|
+
delete source.managedRules;
|
|
6900
|
+
return;
|
|
6260
6901
|
}
|
|
6261
|
-
if (
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
path: to,
|
|
6265
|
-
reason: "destination already exists with different content"
|
|
6266
|
-
});
|
|
6267
|
-
return false;
|
|
6268
|
-
}
|
|
6269
|
-
rmSync5(from, { force: true });
|
|
6270
|
-
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;
|
|
6271
6905
|
}
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
const code = err?.code;
|
|
6277
|
-
if (code !== "EXDEV")
|
|
6278
|
-
throw err;
|
|
6279
|
-
const bytes = readFileSync6(from);
|
|
6280
|
-
const tmp = `${to}.tmp`;
|
|
6281
|
-
writeFileSync4(tmp, bytes);
|
|
6282
|
-
renameSync3(tmp, to);
|
|
6283
|
-
try {
|
|
6284
|
-
rmSync5(from, { force: true });
|
|
6285
|
-
} catch {
|
|
6286
|
-
skipped.push({
|
|
6287
|
-
path: from,
|
|
6288
|
-
reason: "copied to destination but could not remove source"
|
|
6289
|
-
});
|
|
6290
|
-
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;
|
|
6291
6910
|
}
|
|
6911
|
+
destination.managedRules = { ...metadata };
|
|
6912
|
+
writeManifest(join9(root, profile.manifestDir), destination);
|
|
6913
|
+
if (!entries(source).length)
|
|
6914
|
+
delete source.managedRules;
|
|
6915
|
+
return;
|
|
6292
6916
|
}
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
if (!existsSync7(rulesPath))
|
|
6298
|
-
return false;
|
|
6299
|
-
const existing = readFileOrNull(rulesPath);
|
|
6300
|
-
if (existing === null)
|
|
6301
|
-
return false;
|
|
6302
|
-
const { content, removed } = removeRulesBlockWithMarkers(existing, oldProfile.sentinelBegin, oldProfile.sentinelEnd);
|
|
6303
|
-
if (!removed)
|
|
6304
|
-
return false;
|
|
6305
|
-
if (content.trim().length === 0) {
|
|
6306
|
-
rmSync5(rulesPath, { force: true });
|
|
6307
|
-
} else {
|
|
6308
|
-
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;
|
|
6309
6921
|
}
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
const bStat = statSync(b2);
|
|
6316
|
-
if (aStat.size !== bStat.size)
|
|
6317
|
-
return false;
|
|
6318
|
-
return readFileSync6(a2).equals(readFileSync6(b2));
|
|
6319
|
-
} catch {
|
|
6320
|
-
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);
|
|
6321
6927
|
}
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
if (
|
|
6928
|
+
destination.managedRules = plan.next;
|
|
6929
|
+
writeManifest(join9(root, profile.manifestDir), destination);
|
|
6930
|
+
if (entries(source).length)
|
|
6325
6931
|
return;
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
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;
|
|
6329
6973
|
}
|
|
6330
6974
|
|
|
6331
6975
|
// src/lib/tool-prompt.ts
|
|
6332
6976
|
async function resolveToolProfile(flagOverride, projectRoot = process.cwd()) {
|
|
6333
|
-
|
|
6334
|
-
await handleToolSwitch(projectRoot, profile);
|
|
6335
|
-
return profile;
|
|
6977
|
+
return resolveProfileOnly(flagOverride, projectRoot);
|
|
6336
6978
|
}
|
|
6337
6979
|
async function resolveProfileOnly(flagOverride, projectRoot) {
|
|
6338
6980
|
if (flagOverride) {
|
|
6339
|
-
const canonicalId = canonicalToolId(flagOverride);
|
|
6340
6981
|
const profile = getToolProfile(flagOverride);
|
|
6341
6982
|
if (!profile) {
|
|
6342
6983
|
throw new Error(`Unknown tool '${flagOverride}'. Supported: ${Object.keys(PROFILES).join(", ")}`);
|
|
6343
6984
|
}
|
|
6344
|
-
const existing = readToolConfig();
|
|
6345
|
-
if (existing?.tool !== canonicalId) {
|
|
6346
|
-
saveToolConfig({ ...existing ?? {}, tool: canonicalId });
|
|
6347
|
-
if (process.stdout.isTTY) {
|
|
6348
|
-
process.stderr.write(`Default tool set to ${profile.displayName}.
|
|
6349
|
-
`);
|
|
6350
|
-
}
|
|
6351
|
-
}
|
|
6352
6985
|
return profile;
|
|
6353
6986
|
}
|
|
6354
6987
|
const config = readToolConfig();
|
|
6355
6988
|
if (config?.tool) {
|
|
6356
6989
|
const profile = getToolProfile(config.tool);
|
|
6357
6990
|
if (profile) {
|
|
6358
|
-
const canonicalId = canonicalToolId(config.tool);
|
|
6359
|
-
if (canonicalId !== config.tool) {
|
|
6360
|
-
saveToolConfig({ ...config, tool: canonicalId });
|
|
6361
|
-
}
|
|
6362
6991
|
return profile;
|
|
6363
6992
|
}
|
|
6364
6993
|
}
|
|
@@ -6383,11 +7012,25 @@ async function resolveProfileOnly(flagOverride, projectRoot) {
|
|
|
6383
7012
|
ve("Using default (Claude Code).");
|
|
6384
7013
|
return PROFILES[DEFAULT_TOOL];
|
|
6385
7014
|
}
|
|
6386
|
-
saveToolConfig({ ...config ?? {}, tool: choice });
|
|
6387
7015
|
return PROFILES[choice];
|
|
6388
7016
|
}
|
|
6389
7017
|
return PROFILES[DEFAULT_TOOL];
|
|
6390
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
|
+
}
|
|
6391
7034
|
async function handleToolSwitch(projectRoot, newProfile) {
|
|
6392
7035
|
if (!process.stdout.isTTY)
|
|
6393
7036
|
return;
|
|
@@ -6473,9 +7116,8 @@ function resolveCourseRulesFlag(argv) {
|
|
|
6473
7116
|
return true;
|
|
6474
7117
|
return;
|
|
6475
7118
|
}
|
|
6476
|
-
var DEFAULT_COURSE2 = "10xdevs3";
|
|
6477
7119
|
function registerGetCommand(cli) {
|
|
6478
|
-
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) => {
|
|
6479
7121
|
const ctx = resolveContext(options);
|
|
6480
7122
|
await runGet(ctx, ref, options);
|
|
6481
7123
|
});
|
|
@@ -6495,29 +7137,28 @@ async function runGet(ctx, ref, options) {
|
|
|
6495
7137
|
if (options.lang && !SUPPORTED_LANGS.includes(options.lang)) {
|
|
6496
7138
|
outputError(ctx, "invalid_lang", `Unknown language '${options.lang}'.`, ExitCodes.USAGE, `Supported languages: ${SUPPORTED_LANGS.join(", ")}`);
|
|
6497
7139
|
}
|
|
6498
|
-
const
|
|
6499
|
-
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
|
+
}
|
|
6500
7149
|
const profile = await resolveToolProfile(options.tool, process.cwd());
|
|
6501
7150
|
const dryRun = options.dryRun === true;
|
|
6502
7151
|
const lang = options.lang ?? readToolConfig()?.lang ?? "en";
|
|
6503
7152
|
const explicitCourseRules = resolveCourseRulesFlag(process.argv);
|
|
6504
7153
|
const resolvedCourseRules = explicitCourseRules ?? readToolConfig()?.courseRules ?? true;
|
|
6505
|
-
if (!dryRun && (options.lang || explicitCourseRules !== undefined)) {
|
|
6506
|
-
const tool = readToolConfig()?.tool ?? profile.toolId;
|
|
6507
|
-
const patch = { tool };
|
|
6508
|
-
if (options.lang)
|
|
6509
|
-
patch.lang = options.lang;
|
|
6510
|
-
if (explicitCourseRules !== undefined)
|
|
6511
|
-
patch.courseRules = explicitCourseRules;
|
|
6512
|
-
updateToolConfig(patch);
|
|
6513
|
-
}
|
|
6514
7154
|
if (options.print) {
|
|
6515
|
-
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);
|
|
6516
7156
|
return;
|
|
6517
7157
|
}
|
|
6518
7158
|
verbose(ctx, `fetching lesson ${course}/${parsed.lessonId}`);
|
|
6519
7159
|
const result = await fetchLesson(course, parsed.lessonId, auth.access_token, {
|
|
6520
7160
|
lang,
|
|
7161
|
+
...release ? { release } : {},
|
|
6521
7162
|
tool: contentToolId(profile)
|
|
6522
7163
|
});
|
|
6523
7164
|
if (!result.ok) {
|
|
@@ -6536,19 +7177,34 @@ async function runGet(ctx, ref, options) {
|
|
|
6536
7177
|
const isFiltered = options.type !== undefined;
|
|
6537
7178
|
const bundle = filterBundle(ctx, result.data, options);
|
|
6538
7179
|
const isTTY = process.stdout.isTTY === true;
|
|
6539
|
-
const prevManifest = readManifest(
|
|
7180
|
+
const prevManifest = readManifest(join10(process.cwd(), profile.manifestDir));
|
|
6540
7181
|
if (prevManifest && prevManifest.manifestVersion === 2) {
|
|
6541
7182
|
showUpgradeNotice(isTTY);
|
|
6542
7183
|
}
|
|
6543
7184
|
const applyCourseRules = isFiltered ? true : resolvedCourseRules;
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
profile,
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
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
|
+
}
|
|
6551
7205
|
renderGetResult(ctx, bundle, writeResult, dryRun, profile, {
|
|
7206
|
+
course,
|
|
7207
|
+
selectionReason: selection.reason,
|
|
6552
7208
|
language: contentLang ?? lang,
|
|
6553
7209
|
languageFallback: isFallback,
|
|
6554
7210
|
applyCourseRules
|
|
@@ -6581,7 +7237,10 @@ function filterBundle(ctx, bundle, options) {
|
|
|
6581
7237
|
}
|
|
6582
7238
|
return { ...empty, [type]: bundle[type] };
|
|
6583
7239
|
}
|
|
6584
|
-
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
|
+
`);
|
|
6585
7244
|
if (!options.type) {
|
|
6586
7245
|
outputError(ctx, "missing_type", "Specify --type for --print mode.", ExitCodes.USAGE, "Usage: 10x get m1l1 --print --type skills [--name code-review]");
|
|
6587
7246
|
}
|
|
@@ -6590,12 +7249,12 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6590
7249
|
}
|
|
6591
7250
|
if (options.name) {
|
|
6592
7251
|
verbose(ctx, `fetching artifact ${course}/${lessonId}/${options.type}/${options.name}`);
|
|
6593
|
-
const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang });
|
|
7252
|
+
const result = await fetchArtifact(course, lessonId, options.type, options.name, contentToolId(profile), token, { lang, ...release ? { release } : {} });
|
|
6594
7253
|
if (!result.ok) {
|
|
6595
7254
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
6596
7255
|
}
|
|
6597
7256
|
if (ctx.json) {
|
|
6598
|
-
output(ctx, "", result.data);
|
|
7257
|
+
output(ctx, "", { ...result.data, course, selectionReason });
|
|
6599
7258
|
} else {
|
|
6600
7259
|
const data = result.data;
|
|
6601
7260
|
if (data.type === "skills") {
|
|
@@ -6608,7 +7267,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6608
7267
|
}
|
|
6609
7268
|
} else {
|
|
6610
7269
|
verbose(ctx, `fetching lesson ${course}/${lessonId} (filtering by ${options.type})`);
|
|
6611
|
-
const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile) });
|
|
7270
|
+
const result = await fetchLesson(course, lessonId, token, { lang, tool: contentToolId(profile), ...release ? { release } : {} });
|
|
6612
7271
|
if (!result.ok) {
|
|
6613
7272
|
handleLessonError(ctx, result.status, result.code, result.error, result.payload);
|
|
6614
7273
|
}
|
|
@@ -6616,7 +7275,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6616
7275
|
if (type === "skills") {
|
|
6617
7276
|
const skills = result.data.skills;
|
|
6618
7277
|
if (ctx.json) {
|
|
6619
|
-
output(ctx, "", skills);
|
|
7278
|
+
output(ctx, "", { course, selectionReason, artifacts: skills });
|
|
6620
7279
|
} else {
|
|
6621
7280
|
const contents = skills.map((s) => s.files.find((f2) => f2.path === "SKILL.md")?.content ?? "");
|
|
6622
7281
|
process.stdout.write(contents.join(`
|
|
@@ -6628,7 +7287,7 @@ async function runPrintMode(ctx, lessonId, course, profile, token, lang, options
|
|
|
6628
7287
|
} else {
|
|
6629
7288
|
const artifacts = result.data[type];
|
|
6630
7289
|
if (ctx.json) {
|
|
6631
|
-
output(ctx, "", artifacts);
|
|
7290
|
+
output(ctx, "", { course, selectionReason, artifacts });
|
|
6632
7291
|
} else {
|
|
6633
7292
|
const contents = artifacts.map((a2) => a2.content);
|
|
6634
7293
|
process.stdout.write(contents.join(`
|
|
@@ -6648,7 +7307,9 @@ function emitMultiFileSkillNotice(skillName, files) {
|
|
|
6648
7307
|
}
|
|
6649
7308
|
function handleLessonError(ctx, status, code, error, payload) {
|
|
6650
7309
|
verbose(ctx, `lesson fetch failed: status=${status} code=${code}`);
|
|
6651
|
-
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") {
|
|
6652
7313
|
const moduleNum = payload?.["module"];
|
|
6653
7314
|
const releaseAt = payload?.["releaseAt"];
|
|
6654
7315
|
const hasModule = typeof moduleNum === "number";
|
|
@@ -6671,7 +7332,7 @@ function handleLessonError(ctx, status, code, error, payload) {
|
|
|
6671
7332
|
if (status === 401) {
|
|
6672
7333
|
outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
6673
7334
|
}
|
|
6674
|
-
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") {
|
|
6675
7336
|
outputError(ctx, code, error, ExitCodes.ERROR);
|
|
6676
7337
|
}
|
|
6677
7338
|
if (status === 0) {
|
|
@@ -6692,14 +7353,18 @@ function formatAction(action) {
|
|
|
6692
7353
|
}
|
|
6693
7354
|
}
|
|
6694
7355
|
function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
7356
|
+
course: "10xdevs3",
|
|
7357
|
+
selectionReason: "backend_recommendation",
|
|
6695
7358
|
language: "en",
|
|
6696
7359
|
languageFallback: false,
|
|
6697
7360
|
applyCourseRules: true
|
|
6698
7361
|
}) {
|
|
6699
7362
|
const applyCourseRules = langMeta.applyCourseRules;
|
|
6700
|
-
const totalRemovals = writeResult.removals.skills
|
|
7363
|
+
const totalRemovals = [...writeResult.removals.skills, ...writeResult.removals.prompts, ...writeResult.removals.configs].filter((entry) => entry.action === "removed").length;
|
|
6701
7364
|
if (ctx.json) {
|
|
6702
7365
|
output(ctx, "", {
|
|
7366
|
+
course: langMeta.course,
|
|
7367
|
+
selectionReason: langMeta.selectionReason,
|
|
6703
7368
|
lessonId: bundle.lessonId,
|
|
6704
7369
|
title: bundle.title,
|
|
6705
7370
|
summary: bundle.summary,
|
|
@@ -6726,6 +7391,7 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6726
7391
|
}
|
|
6727
7392
|
const targetDir = profile.manifestDir;
|
|
6728
7393
|
const lines = [];
|
|
7394
|
+
lines.push(`Course: ${langMeta.course} (${langMeta.selectionReason})`);
|
|
6729
7395
|
lines.push(`${bundle.lessonId} — ${bundle.title}`);
|
|
6730
7396
|
if (bundle.summary)
|
|
6731
7397
|
lines.push(bundle.summary);
|
|
@@ -6746,7 +7412,9 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6746
7412
|
lines.push(` [${formatAction(prompt.action)}] prompt ${prompt.path}`);
|
|
6747
7413
|
}
|
|
6748
7414
|
if (!applyCourseRules) {
|
|
6749
|
-
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") {
|
|
6750
7418
|
lines.push(` [removed] rules ${profile.rulesFile}`);
|
|
6751
7419
|
} else {
|
|
6752
7420
|
verbose(ctx, `course rules disabled — ${profile.rulesFile} left untouched`);
|
|
@@ -6758,56 +7426,66 @@ function renderGetResult(ctx, bundle, writeResult, dryRun, profile, langMeta = {
|
|
|
6758
7426
|
lines.push(` [${config.action}] config ${config.path}`);
|
|
6759
7427
|
}
|
|
6760
7428
|
for (const entry of writeResult.removals.skills) {
|
|
6761
|
-
lines.push(` [
|
|
7429
|
+
lines.push(` [${entry.action}] skill ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6762
7430
|
}
|
|
6763
7431
|
for (const entry of writeResult.removals.prompts) {
|
|
6764
|
-
lines.push(` [
|
|
7432
|
+
lines.push(` [${entry.action}] prompt ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6765
7433
|
}
|
|
6766
7434
|
for (const entry of writeResult.removals.configs) {
|
|
6767
|
-
lines.push(` [
|
|
7435
|
+
lines.push(` [${entry.action}] config ${entry.path}${entry.reason ? ` (${entry.reason})` : ""}`);
|
|
6768
7436
|
}
|
|
6769
7437
|
output(ctx, lines.join(`
|
|
6770
7438
|
`), undefined);
|
|
6771
7439
|
}
|
|
6772
7440
|
|
|
6773
7441
|
// src/commands/list.ts
|
|
6774
|
-
var DEFAULT_COURSE3 = "10xdevs3";
|
|
6775
7442
|
function registerListCommand(cli) {
|
|
6776
|
-
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) => {
|
|
6777
7444
|
const ctx = resolveContext(options);
|
|
6778
7445
|
await runList(ctx, moduleArg, options);
|
|
6779
7446
|
});
|
|
6780
7447
|
}
|
|
6781
7448
|
async function runList(ctx, moduleArg, options) {
|
|
6782
|
-
const
|
|
6783
|
-
const
|
|
7449
|
+
const selection = await resolveCourseSelection(ctx, { explicit: options.course });
|
|
7450
|
+
const { auth, course } = selection;
|
|
6784
7451
|
if (moduleArg === undefined) {
|
|
6785
|
-
await listAllModules(ctx, course, auth.access_token);
|
|
7452
|
+
await listAllModules(ctx, course, auth.access_token, selection.reason);
|
|
6786
7453
|
return;
|
|
6787
7454
|
}
|
|
6788
7455
|
const module = parseModuleRef(moduleArg);
|
|
6789
7456
|
if (module === null) {
|
|
6790
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'.`);
|
|
6791
7458
|
}
|
|
6792
|
-
await listModuleDetail(ctx, course, module, auth.access_token);
|
|
7459
|
+
await listModuleDetail(ctx, course, module, auth.access_token, selection.reason);
|
|
6793
7460
|
}
|
|
6794
|
-
async function listAllModules(ctx, course, token) {
|
|
7461
|
+
async function listAllModules(ctx, course, token, reason) {
|
|
6795
7462
|
verbose(ctx, `fetching catalog for ${course}`);
|
|
6796
7463
|
const result = await fetchCatalog(course, token);
|
|
6797
7464
|
if (!result.ok) {
|
|
6798
7465
|
handleListError(ctx, result.status, result.code, result.error);
|
|
6799
7466
|
}
|
|
6800
|
-
renderCatalog(ctx, result.data);
|
|
7467
|
+
renderCatalog(ctx, result.data, reason);
|
|
6801
7468
|
}
|
|
6802
|
-
async function listModuleDetail(ctx, course, module, token) {
|
|
7469
|
+
async function listModuleDetail(ctx, course, module, token, reason) {
|
|
6803
7470
|
verbose(ctx, `fetching module detail ${course}/${module}`);
|
|
6804
|
-
|
|
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 } : {});
|
|
6805
7479
|
if (!result.ok) {
|
|
6806
7480
|
handleListError(ctx, result.status, result.code, result.error);
|
|
6807
7481
|
}
|
|
6808
|
-
renderModuleDetail(ctx, result.data);
|
|
7482
|
+
renderModuleDetail(ctx, result.data, course, reason);
|
|
6809
7483
|
}
|
|
6810
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);
|
|
6811
7489
|
if (status === 404) {
|
|
6812
7490
|
outputError(ctx, "not_found", "Couldn't find that course or module.", ExitCodes.NOT_FOUND, "Run '10x list' to see available modules.");
|
|
6813
7491
|
}
|
|
@@ -6819,7 +7497,7 @@ function handleListError(ctx, status, code, error) {
|
|
|
6819
7497
|
}
|
|
6820
7498
|
outputError(ctx, code || "list_failed", "Failed to load the catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
6821
7499
|
}
|
|
6822
|
-
function renderCatalog(ctx, catalog) {
|
|
7500
|
+
function renderCatalog(ctx, catalog, reason) {
|
|
6823
7501
|
const lessonCountByModule = new Map;
|
|
6824
7502
|
for (const lesson of catalog.lessons) {
|
|
6825
7503
|
lessonCountByModule.set(lesson.module, (lessonCountByModule.get(lesson.module) ?? 0) + 1);
|
|
@@ -6827,6 +7505,7 @@ function renderCatalog(ctx, catalog) {
|
|
|
6827
7505
|
if (ctx.json) {
|
|
6828
7506
|
output(ctx, "", {
|
|
6829
7507
|
course: catalog.course,
|
|
7508
|
+
selectionReason: reason,
|
|
6830
7509
|
modules: catalog.modules.map((m3) => ({
|
|
6831
7510
|
module: m3.module,
|
|
6832
7511
|
title: m3.title,
|
|
@@ -6843,7 +7522,7 @@ function renderCatalog(ctx, catalog) {
|
|
|
6843
7522
|
}
|
|
6844
7523
|
const exampleModule = catalog.modules.find((m3) => m3.effectiveState === "unlocked")?.module ?? catalog.modules[0]?.module ?? 0;
|
|
6845
7524
|
const lines = [];
|
|
6846
|
-
lines.push(`Course: ${catalog.course}`);
|
|
7525
|
+
lines.push(`Course: ${catalog.course} (${reason})`);
|
|
6847
7526
|
lines.push("");
|
|
6848
7527
|
for (const m3 of catalog.modules) {
|
|
6849
7528
|
lines.push(formatModuleRow(m3, lessonCountByModule.get(m3.module) ?? 0));
|
|
@@ -6859,10 +7538,12 @@ function formatModuleRow(m3, lessonCount) {
|
|
|
6859
7538
|
const label = `Module ${m3.module}: ${m3.title}`;
|
|
6860
7539
|
return ` ${icon} ${label} — ${lessonCount} lesson${lessonCount === 1 ? "" : "s"} [${state}]`;
|
|
6861
7540
|
}
|
|
6862
|
-
function renderModuleDetail(ctx, module) {
|
|
7541
|
+
function renderModuleDetail(ctx, module, course, reason) {
|
|
6863
7542
|
const isLocked = module.effectiveState === "locked";
|
|
6864
7543
|
if (ctx.json) {
|
|
6865
7544
|
output(ctx, "", {
|
|
7545
|
+
course,
|
|
7546
|
+
selectionReason: reason,
|
|
6866
7547
|
module: module.module,
|
|
6867
7548
|
title: module.title,
|
|
6868
7549
|
state: module.effectiveState,
|
|
@@ -6879,6 +7560,7 @@ function renderModuleDetail(ctx, module) {
|
|
|
6879
7560
|
}
|
|
6880
7561
|
const stateLabel = isLocked ? `locked — unlocks ${formatReleaseAt(module.releaseAt)}` : "unlocked";
|
|
6881
7562
|
const lines = [];
|
|
7563
|
+
lines.push(`Course: ${course} (${reason})`);
|
|
6882
7564
|
lines.push(`Module ${module.module}: ${module.title} [${stateLabel}]`);
|
|
6883
7565
|
lines.push("");
|
|
6884
7566
|
if (isLocked) {
|
|
@@ -6906,11 +7588,53 @@ function renderModuleDetail(ctx, module) {
|
|
|
6906
7588
|
}
|
|
6907
7589
|
|
|
6908
7590
|
// src/commands/sync.ts
|
|
6909
|
-
import { join as
|
|
6910
|
-
|
|
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
|
|
6911
7635
|
var SUPPORTED_LANGS = ["en", "pl"];
|
|
6912
7636
|
function registerSyncCommand(cli) {
|
|
6913
|
-
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) => {
|
|
6914
7638
|
const ctx = resolveContext(options);
|
|
6915
7639
|
await runSync(ctx, options);
|
|
6916
7640
|
});
|
|
@@ -6927,8 +7651,8 @@ async function runSync(ctx, options) {
|
|
|
6927
7651
|
}
|
|
6928
7652
|
moduleFilter = parsed;
|
|
6929
7653
|
}
|
|
6930
|
-
const
|
|
6931
|
-
const
|
|
7654
|
+
const selection = await resolveCourseSelection(ctx, { explicit: options.course, writing: !options.dryRun });
|
|
7655
|
+
const { auth, course } = selection;
|
|
6932
7656
|
const profile = await resolveToolProfile(options.tool, process.cwd());
|
|
6933
7657
|
const dryRun = options.dryRun === true;
|
|
6934
7658
|
const force = options.force === true;
|
|
@@ -6941,6 +7665,7 @@ async function runSync(ctx, options) {
|
|
|
6941
7665
|
handleCatalogError(ctx, catalogResult.status, catalogResult.code, catalogResult.error);
|
|
6942
7666
|
}
|
|
6943
7667
|
const catalog = catalogResult.data;
|
|
7668
|
+
const release = catalogRelease(catalog);
|
|
6944
7669
|
const moduleState = new Map;
|
|
6945
7670
|
for (const mod of catalog.modules)
|
|
6946
7671
|
moduleState.set(mod.module, mod.effectiveState);
|
|
@@ -6952,8 +7677,8 @@ async function runSync(ctx, options) {
|
|
|
6952
7677
|
}
|
|
6953
7678
|
return true;
|
|
6954
7679
|
});
|
|
6955
|
-
const manifest = readManifest(
|
|
6956
|
-
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] : []);
|
|
6957
7682
|
let targets = unlocked;
|
|
6958
7683
|
if (moduleFilter !== undefined)
|
|
6959
7684
|
targets = targets.filter((l2) => l2.module === moduleFilter);
|
|
@@ -6964,12 +7689,24 @@ async function runSync(ctx, options) {
|
|
|
6964
7689
|
const onSigint = () => controller.abort();
|
|
6965
7690
|
process.once("SIGINT", onSigint);
|
|
6966
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
|
+
};
|
|
6967
7702
|
try {
|
|
6968
7703
|
for (const lesson of targets) {
|
|
6969
7704
|
if (controller.signal.aborted)
|
|
6970
7705
|
break;
|
|
6971
7706
|
outcomes.push(await syncLesson(ctx, lesson, {
|
|
6972
7707
|
course,
|
|
7708
|
+
beforeWrite,
|
|
7709
|
+
release,
|
|
6973
7710
|
profile,
|
|
6974
7711
|
lang,
|
|
6975
7712
|
dryRun,
|
|
@@ -6985,6 +7722,7 @@ async function runSync(ctx, options) {
|
|
|
6985
7722
|
}
|
|
6986
7723
|
renderReport(ctx, profile, {
|
|
6987
7724
|
course,
|
|
7725
|
+
selectionReason: selection.reason,
|
|
6988
7726
|
dryRun,
|
|
6989
7727
|
force,
|
|
6990
7728
|
mode: options.all === true ? "all" : "downloaded",
|
|
@@ -6997,8 +7735,9 @@ async function runSync(ctx, options) {
|
|
|
6997
7735
|
}
|
|
6998
7736
|
}
|
|
6999
7737
|
async function syncLesson(ctx, lesson, opts) {
|
|
7000
|
-
const
|
|
7001
|
-
|
|
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)) {
|
|
7002
7741
|
verbose(ctx, `${lesson.lessonId}: upstream unchanged (digest match) — skipping fetch`);
|
|
7003
7742
|
return {
|
|
7004
7743
|
lessonId: lesson.lessonId,
|
|
@@ -7012,7 +7751,8 @@ async function syncLesson(ctx, lesson, opts) {
|
|
|
7012
7751
|
const result = await fetchLesson(opts.course, lesson.lessonId, opts.token, {
|
|
7013
7752
|
lang: opts.lang,
|
|
7014
7753
|
tool: contentToolId(opts.profile),
|
|
7015
|
-
signal: opts.signal
|
|
7754
|
+
signal: opts.signal,
|
|
7755
|
+
...opts.release ? { release: opts.release } : {}
|
|
7016
7756
|
});
|
|
7017
7757
|
if (!result.ok) {
|
|
7018
7758
|
return {
|
|
@@ -7043,14 +7783,23 @@ async function syncLesson(ctx, lesson, opts) {
|
|
|
7043
7783
|
resources: resources2
|
|
7044
7784
|
};
|
|
7045
7785
|
}
|
|
7046
|
-
const onConflict = opts.force ? async () => "overwrite" : async () => "skip";
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
profile: opts.profile,
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
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
|
+
}
|
|
7054
7803
|
const resources = classifyFromWriteResult(writeResult, lesson.lessonId);
|
|
7055
7804
|
return {
|
|
7056
7805
|
lessonId: lesson.lessonId,
|
|
@@ -7068,6 +7817,8 @@ function actionToBucket(action) {
|
|
|
7068
7817
|
case "conflict_overwritten":
|
|
7069
7818
|
case "conflict_saved_user":
|
|
7070
7819
|
return "upstream-updated";
|
|
7820
|
+
case "preserved_local":
|
|
7821
|
+
return "preserved-local";
|
|
7071
7822
|
case "conflict_skipped":
|
|
7072
7823
|
return "skipped-conflict";
|
|
7073
7824
|
case "removed":
|
|
@@ -7104,18 +7855,18 @@ function classifyFromWriteResult(result, lessonId) {
|
|
|
7104
7855
|
...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
|
|
7105
7856
|
});
|
|
7106
7857
|
}
|
|
7107
|
-
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` });
|
|
7108
7859
|
for (const config of result.configs) {
|
|
7109
7860
|
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
|
|
7110
7861
|
}
|
|
7111
7862
|
for (const entry of result.removals.skills) {
|
|
7112
|
-
out.push({ type: "skills", name: entry.name, bucket:
|
|
7863
|
+
out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7113
7864
|
}
|
|
7114
7865
|
for (const entry of result.removals.prompts) {
|
|
7115
|
-
out.push({ type: "prompts", name: entry.name, bucket:
|
|
7866
|
+
out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7116
7867
|
}
|
|
7117
7868
|
for (const entry of result.removals.configs) {
|
|
7118
|
-
out.push({ type: "configs", name: entry.name, bucket:
|
|
7869
|
+
out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7119
7870
|
}
|
|
7120
7871
|
return out;
|
|
7121
7872
|
}
|
|
@@ -7143,18 +7894,18 @@ function classifyFromPlan(plan, lessonId, force) {
|
|
|
7143
7894
|
...bucket === "skipped-conflict" ? { remediation: remediation(lessonId, "prompts", prompt.name) } : {}
|
|
7144
7895
|
});
|
|
7145
7896
|
}
|
|
7146
|
-
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` });
|
|
7147
7898
|
for (const config of plan.configs) {
|
|
7148
7899
|
out.push({ type: "configs", name: config.name, bucket: actionToBucket(config.action) });
|
|
7149
7900
|
}
|
|
7150
7901
|
for (const entry of plan.removals.skills) {
|
|
7151
|
-
out.push({ type: "skills", name: entry.name, bucket:
|
|
7902
|
+
out.push({ type: "skills", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7152
7903
|
}
|
|
7153
7904
|
for (const entry of plan.removals.prompts) {
|
|
7154
|
-
out.push({ type: "prompts", name: entry.name, bucket:
|
|
7905
|
+
out.push({ type: "prompts", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7155
7906
|
}
|
|
7156
7907
|
for (const entry of plan.removals.configs) {
|
|
7157
|
-
out.push({ type: "configs", name: entry.name, bucket:
|
|
7908
|
+
out.push({ type: "configs", name: entry.name, bucket: actionToBucket(entry.action), reason: entry.reason });
|
|
7158
7909
|
}
|
|
7159
7910
|
return out;
|
|
7160
7911
|
}
|
|
@@ -7172,7 +7923,8 @@ function countBuckets(outcomes) {
|
|
|
7172
7923
|
"upstream-updated": 0,
|
|
7173
7924
|
unchanged: 0,
|
|
7174
7925
|
"skipped-conflict": 0,
|
|
7175
|
-
removed: 0
|
|
7926
|
+
removed: 0,
|
|
7927
|
+
"preserved-local": 0
|
|
7176
7928
|
};
|
|
7177
7929
|
for (const o of outcomes) {
|
|
7178
7930
|
for (const r2 of o.resources)
|
|
@@ -7188,6 +7940,7 @@ function renderReport(ctx, profile, input) {
|
|
|
7188
7940
|
if (ctx.json) {
|
|
7189
7941
|
output(ctx, "", {
|
|
7190
7942
|
course: input.course,
|
|
7943
|
+
selectionReason: input.selectionReason,
|
|
7191
7944
|
tool: profile.toolId,
|
|
7192
7945
|
dryRun: input.dryRun,
|
|
7193
7946
|
force: input.force,
|
|
@@ -7207,7 +7960,8 @@ function renderReport(ctx, profile, input) {
|
|
|
7207
7960
|
upstreamUpdated: buckets["upstream-updated"],
|
|
7208
7961
|
unchanged: buckets.unchanged,
|
|
7209
7962
|
skippedConflict: buckets["skipped-conflict"],
|
|
7210
|
-
removed: buckets.removed
|
|
7963
|
+
removed: buckets.removed,
|
|
7964
|
+
preservedLocal: buckets["preserved-local"]
|
|
7211
7965
|
}
|
|
7212
7966
|
}
|
|
7213
7967
|
});
|
|
@@ -7217,7 +7971,7 @@ function renderReport(ctx, profile, input) {
|
|
|
7217
7971
|
const verb = input.dryRun ? "Would sync" : "Synced";
|
|
7218
7972
|
const scope = input.mode === "all" ? "all unlocked lessons" : "downloaded lessons";
|
|
7219
7973
|
const moduleNote = input.module !== undefined ? ` in module ${input.module}` : "";
|
|
7220
|
-
lines.push(`${verb} ${input.course} — ${scope}${moduleNote}:`);
|
|
7974
|
+
lines.push(`${verb} ${input.course} (${input.selectionReason}) — ${scope}${moduleNote}:`);
|
|
7221
7975
|
if (outcomes.length === 0) {
|
|
7222
7976
|
lines.push(" (nothing to sync)");
|
|
7223
7977
|
}
|
|
@@ -7231,6 +7985,10 @@ function renderReport(ctx, profile, input) {
|
|
|
7231
7985
|
const summary = summarizeLesson(o);
|
|
7232
7986
|
lines.push(` ${o.lessonId} — ${o.status}${summary ? ` (${summary})` : ""}`);
|
|
7233
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
|
+
}
|
|
7234
7992
|
if (r2.bucket !== "skipped-conflict")
|
|
7235
7993
|
continue;
|
|
7236
7994
|
const label = r2.file ? `${r2.type}/${r2.name} (${r2.file})` : `${r2.type}/${r2.name}`;
|
|
@@ -7244,9 +8002,9 @@ function renderReport(ctx, profile, input) {
|
|
|
7244
8002
|
lines.push(` ${ex.lessonId} — ${ex.reason}`);
|
|
7245
8003
|
}
|
|
7246
8004
|
lines.push("");
|
|
7247
|
-
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.`);
|
|
7248
8006
|
if (lessonsConflicts > 0 && !input.force) {
|
|
7249
|
-
lines.push("To
|
|
8007
|
+
lines.push("To replace edited skills and prompts: 10x sync --force. Resolve rule conflicts with 10x get <lesson> --type rules.");
|
|
7250
8008
|
}
|
|
7251
8009
|
output(ctx, lines.join(`
|
|
7252
8010
|
`), undefined);
|
|
@@ -7257,7 +8015,8 @@ function summarizeLesson(o) {
|
|
|
7257
8015
|
"upstream-updated": 0,
|
|
7258
8016
|
unchanged: 0,
|
|
7259
8017
|
"skipped-conflict": 0,
|
|
7260
|
-
removed: 0
|
|
8018
|
+
removed: 0,
|
|
8019
|
+
"preserved-local": 0
|
|
7261
8020
|
};
|
|
7262
8021
|
for (const r2 of o.resources)
|
|
7263
8022
|
counts[r2.bucket]++;
|
|
@@ -7275,6 +8034,10 @@ function summarizeLesson(o) {
|
|
|
7275
8034
|
return parts.join(", ");
|
|
7276
8035
|
}
|
|
7277
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);
|
|
7278
8041
|
if (status === 401) {
|
|
7279
8042
|
outputError(ctx, "auth_required", "Your session is no longer valid.", ExitCodes.AUTH_REQUIRED, "Run '10x auth' to log in again.");
|
|
7280
8043
|
}
|
|
@@ -7287,7 +8050,11 @@ function handleCatalogError(ctx, status, code, error) {
|
|
|
7287
8050
|
outputError(ctx, code || "catalog_fetch_failed", "Failed to fetch the course catalog.", ExitCodes.ERROR, error ? `Server said: ${error}` : undefined);
|
|
7288
8051
|
}
|
|
7289
8052
|
function lessonErrorMessage(status, code, error) {
|
|
7290
|
-
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")
|
|
7291
8058
|
return "Module is locked.";
|
|
7292
8059
|
if (status === 404)
|
|
7293
8060
|
return "Lesson not found.";
|