handzon-core 0.12.1 → 0.13.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.
@@ -24,6 +24,12 @@ interface AuthConfigOptions {
24
24
  * builds and for `pnpm build` on a fresh checkout without a database.
25
25
  */
26
26
  db: Parameters<typeof DrizzleAdapter>[0] | null;
27
+ /**
28
+ * Override auth-astro's route prefix when a fronting proxy rewrites
29
+ * paths before the request reaches the Handzon app. Defaults to
30
+ * Astro's configured `base` plus `/api/auth`.
31
+ */
32
+ authPrefix?: string;
27
33
  }
28
34
 
29
35
  /**
@@ -56,13 +62,14 @@ function resolveAuthUrl(): string | undefined {
56
62
  return undefined;
57
63
  }
58
64
 
59
- function resolveAuthPrefix(): string {
65
+ function resolveAuthPrefix(authPrefix?: string): string {
66
+ if (authPrefix) return authPrefix;
60
67
  const base = import.meta.env.BASE_URL;
61
68
  const normalizedBase = base === "/" ? "" : base.replace(/\/$/, "");
62
69
  return `${normalizedBase}/api/auth`;
63
70
  }
64
71
 
65
- export function createAuthConfig({ db }: AuthConfigOptions) {
72
+ export function createAuthConfig({ db, authPrefix }: AuthConfigOptions) {
66
73
  // Auth.js v5 reads `AUTH_URL` from process.env on each request, so
67
74
  // we resolve once and write it back. Idempotent: if AUTH_URL was
68
75
  // already a full URL, this is a no-op aside from the trailing-slash
@@ -72,18 +79,20 @@ export function createAuthConfig({ db }: AuthConfigOptions) {
72
79
  process.env.AUTH_URL = resolvedUrl;
73
80
  }
74
81
 
82
+ const prefix = resolveAuthPrefix(authPrefix);
83
+
75
84
  if (!db) {
76
85
  // No database → no adapter → no providers. auth-astro logs a warning
77
86
  // and any sign-in attempt fails gracefully instead of crashing the
78
87
  // build.
79
- return defineConfig({ providers: [] });
88
+ return defineConfig({ prefix, providers: [] });
80
89
  }
81
90
  return defineConfig({
82
91
  // auth-astro defaults to /api/auth, but Astro apps mounted with
83
92
  // `base` receive requests at /<base>/api/auth. Use the same base
84
93
  // that powers app links and API calls so the catch-all auth route
85
94
  // returns a Response instead of falling through with undefined.
86
- prefix: resolveAuthPrefix(),
95
+ prefix,
87
96
  adapter: DrizzleAdapter(db, {
88
97
  usersTable: users,
89
98
  accountsTable: accounts,
@@ -1,5 +1,13 @@
1
1
  import type { StarterSpec, VerifySpec } from "../../collections.ts";
2
- import { type McpTool, text } from "./protocol.ts";
2
+ import {
3
+ isStarterSpec,
4
+ isVerifySpec,
5
+ resolveForTrack,
6
+ type TrackScoped,
7
+ } from "../../lib/track-scoped.ts";
8
+ import type { TrackOption } from "../../lib/tracks.ts";
9
+ import { type McpContext, type McpTool, text } from "./protocol.ts";
10
+ import { resolveMcpTrack } from "./tracks.ts";
3
11
 
4
12
  interface StartTutorialStep {
5
13
  slug: string;
@@ -7,7 +15,7 @@ interface StartTutorialStep {
7
15
  title: string;
8
16
  summary?: string;
9
17
  duration?: string;
10
- verify?: VerifySpec | null;
18
+ verify?: TrackScoped<VerifySpec> | null;
11
19
  }
12
20
 
13
21
  interface StartTutorialInput {
@@ -17,14 +25,18 @@ interface StartTutorialInput {
17
25
  description: string;
18
26
  difficulty: string;
19
27
  tags: string[];
20
- starter?: StarterSpec;
28
+ tracks?: TrackOption[];
29
+ defaultTrack?: string;
30
+ starter?: TrackScoped<StarterSpec>;
21
31
  };
22
32
  steps: StartTutorialStep[];
23
33
  workspaceName?: string;
34
+ resolvedTrack?: string;
24
35
  }
25
36
 
26
37
  export type LoadStartTutorial = (
27
38
  slug: string,
39
+ ctx: McpContext,
28
40
  ) => Promise<Omit<StartTutorialInput, "workspaceName"> | null>;
29
41
 
30
42
  export interface StartTutorialPayload {
@@ -34,6 +46,8 @@ export interface StartTutorialPayload {
34
46
  description: string;
35
47
  difficulty: string;
36
48
  tags: string[];
49
+ tracks: TrackOption[];
50
+ track?: string | null;
37
51
  };
38
52
  starter: StarterSpec | null;
39
53
  workspace: {
@@ -80,19 +94,39 @@ function buildCommands(starter: StarterSpec | undefined, targetDir: string, open
80
94
  return commands;
81
95
  }
82
96
 
97
+ function resolveStarterForTrack(
98
+ starter: TrackScoped<StarterSpec> | undefined,
99
+ trackId: string | undefined,
100
+ ) {
101
+ return resolveForTrack(starter, trackId, isStarterSpec as (v: unknown) => v is StarterSpec);
102
+ }
103
+
104
+ function resolveVerifyForTrack(
105
+ verify: TrackScoped<VerifySpec> | undefined,
106
+ trackId: string | undefined,
107
+ ) {
108
+ return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
109
+ }
110
+
83
111
  export function buildStartTutorialPayload({
84
112
  tutorial,
85
113
  steps,
86
114
  workspaceName,
115
+ resolvedTrack,
87
116
  }: StartTutorialInput): StartTutorialPayload {
88
- const [firstStep] = [...steps].sort((a, b) => a.order - b.order);
117
+ const resolvedSteps = steps.map((step) => ({
118
+ ...step,
119
+ verify: resolveVerifyForTrack(step.verify ?? undefined, resolvedTrack) ?? null,
120
+ }));
121
+ const [firstStep] = [...resolvedSteps].sort((a, b) => a.order - b.order);
89
122
  if (!firstStep) {
90
123
  throw new Error(`Tutorial ${tutorial.slug} has no steps.`);
91
124
  }
92
125
 
93
- const targetDir = resolveTargetDir(tutorial.slug, tutorial.starter, workspaceName);
94
- const openPath = resolveOpenPath(targetDir, tutorial.starter);
95
- const commands = buildCommands(tutorial.starter, targetDir, openPath);
126
+ const starter = resolveStarterForTrack(tutorial.starter, resolvedTrack);
127
+ const targetDir = resolveTargetDir(tutorial.slug, starter, workspaceName);
128
+ const openPath = resolveOpenPath(targetDir, starter);
129
+ const commands = buildCommands(starter, targetDir, openPath);
96
130
 
97
131
  return {
98
132
  tutorial: {
@@ -101,8 +135,10 @@ export function buildStartTutorialPayload({
101
135
  description: tutorial.description,
102
136
  difficulty: tutorial.difficulty,
103
137
  tags: tutorial.tags,
138
+ tracks: tutorial.tracks ?? [],
139
+ track: resolvedTrack ?? null,
104
140
  },
105
- starter: tutorial.starter ?? null,
141
+ starter: starter ?? null,
106
142
  workspace: { targetDir, openPath },
107
143
  commands,
108
144
  firstStep,
@@ -131,20 +167,42 @@ export function createStartTutorialTool(load: LoadStartTutorial): McpTool {
131
167
  description:
132
168
  "Optional local directory name to use instead of the tutorial's default targetDir.",
133
169
  },
170
+ track: {
171
+ type: "string",
172
+ minLength: 1,
173
+ description:
174
+ "Optional tutorial track id. Overrides the learner's persisted prefs.track for this call.",
175
+ },
134
176
  },
135
177
  required: ["slug"],
136
178
  additionalProperties: false,
137
179
  },
138
- handler: async (args) => {
139
- const { slug, workspaceName } = args as { slug: string; workspaceName?: string };
140
- const loaded = await load(slug);
180
+ handler: async (args, ctx) => {
181
+ const { slug, workspaceName, track } = args as {
182
+ slug: string;
183
+ workspaceName?: string;
184
+ track?: string;
185
+ };
186
+ const loaded = await load(slug, ctx);
141
187
  if (!loaded) {
142
188
  return {
143
189
  content: [{ type: "text", text: `No tutorial with slug "${slug}".` }],
144
190
  isError: true,
145
191
  };
146
192
  }
147
- return text(JSON.stringify(buildStartTutorialPayload({ ...loaded, workspaceName }), null, 2));
193
+ const resolvedTrack = await resolveMcpTrack({
194
+ tracks: loaded.tutorial.tracks,
195
+ defaultTrack: loaded.tutorial.defaultTrack,
196
+ explicitTrack: track,
197
+ learnerId: ctx.learnerId,
198
+ });
199
+ return text(
200
+ JSON.stringify(
201
+ buildStartTutorialPayload({ ...loaded, workspaceName, resolvedTrack }),
202
+ null,
203
+ 2,
204
+ ),
205
+ );
148
206
  },
149
207
  };
150
208
  }
@@ -1,4 +1,5 @@
1
1
  import { eq } from "drizzle-orm";
2
+ import type { VerifySpec } from "../../collections.ts";
2
3
  import {
3
4
  getStep,
4
5
  getStepsForTutorial,
@@ -6,10 +7,13 @@ import {
6
7
  getTutorials,
7
8
  parseStepId,
8
9
  } from "../../lib/content.ts";
10
+ import { isVerifySpec, resolveForTrack, type TrackScoped } from "../../lib/track-scoped.ts";
11
+ import { stripInactiveTrackBlocks } from "../../lib/track-source.ts";
9
12
  import { getDb } from "../db/client.ts";
10
13
  import { progressEntries } from "../db/schema.ts";
11
14
  import { type McpTool, text } from "./protocol.ts";
12
15
  import { createStartTutorialTool } from "./startTutorial.ts";
16
+ import { resolveMcpTrack } from "./tracks.ts";
13
17
  import { progressWriteTools, verificationTools } from "./writeTools.ts";
14
18
 
15
19
  /**
@@ -24,6 +28,13 @@ function extractCheckpointLabel(body: string): string | undefined {
24
28
  return m[1] ?? m[2] ?? m[3];
25
29
  }
26
30
 
31
+ function resolveVerifyForTrack(
32
+ verify: TrackScoped<VerifySpec> | undefined,
33
+ trackId: string | undefined,
34
+ ) {
35
+ return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
36
+ }
37
+
27
38
  /**
28
39
  * Catalog read tools. No auth required beyond a valid bearer token —
29
40
  * agents browsing what's available before deciding which tutorial to
@@ -42,6 +53,8 @@ export const catalogReadTools: McpTool[] = [
42
53
  description: t.data.description,
43
54
  difficulty: t.data.difficulty,
44
55
  tags: t.data.tags,
56
+ tracks: t.data.tracks,
57
+ defaultTrack: t.data.defaultTrack,
45
58
  }));
46
59
  return text(JSON.stringify({ tutorials: rows }, null, 2));
47
60
  },
@@ -73,6 +86,8 @@ export const catalogReadTools: McpTool[] = [
73
86
  difficulty: tutorial.data.difficulty,
74
87
  tags: tutorial.data.tags,
75
88
  gated: tutorial.data.gated,
89
+ tracks: tutorial.data.tracks,
90
+ defaultTrack: tutorial.data.defaultTrack,
76
91
  starter: tutorial.data.starter ?? null,
77
92
  steps: steps.map((s) => {
78
93
  const { stepSlug, order } = parseStepId(s.id);
@@ -99,6 +114,8 @@ export const catalogReadTools: McpTool[] = [
99
114
  description: tutorial.data.description,
100
115
  difficulty: tutorial.data.difficulty,
101
116
  tags: tutorial.data.tags,
117
+ tracks: tutorial.data.tracks,
118
+ defaultTrack: tutorial.data.defaultTrack,
102
119
  starter: tutorial.data.starter,
103
120
  },
104
121
  steps: steps.map((s) => {
@@ -122,12 +139,18 @@ export const catalogReadTools: McpTool[] = [
122
139
  properties: {
123
140
  tutorial: { type: "string", minLength: 1 },
124
141
  step: { type: "string", minLength: 1 },
142
+ track: {
143
+ type: "string",
144
+ minLength: 1,
145
+ description:
146
+ "Optional tutorial track id. Overrides the learner's persisted prefs.track for this call.",
147
+ },
125
148
  },
126
149
  required: ["tutorial", "step"],
127
150
  additionalProperties: false,
128
151
  },
129
152
  handler: async (args) => {
130
- const { tutorial, step } = args as { tutorial: string; step: string };
153
+ const { tutorial, step, track } = args as { tutorial: string; step: string; track?: string };
131
154
  const tut = await getTutorialBySlug(tutorial);
132
155
  if (!tut) {
133
156
  return {
@@ -143,12 +166,22 @@ export const catalogReadTools: McpTool[] = [
143
166
  };
144
167
  }
145
168
  const { stepSlug, order } = parseStepId(stepEntry.id);
146
- const body = stepEntry.body ?? "";
147
- const verifySpec = (stepEntry.data as { verify?: unknown }).verify;
169
+ const activeTrack = await resolveMcpTrack({
170
+ tracks: tut.data.tracks,
171
+ defaultTrack: tut.data.defaultTrack,
172
+ explicitTrack: track,
173
+ learnerId: ctx.learnerId,
174
+ });
175
+ const body = stripInactiveTrackBlocks(stepEntry.body ?? "", activeTrack);
176
+ const verifySpec = resolveVerifyForTrack(
177
+ (stepEntry.data as { verify?: TrackScoped<VerifySpec> }).verify,
178
+ activeTrack,
179
+ );
148
180
  const payload = {
149
181
  tutorial,
150
182
  slug: stepSlug,
151
183
  order,
184
+ track: activeTrack ?? null,
152
185
  title: stepEntry.data.title,
153
186
  summary: stepEntry.data.summary,
154
187
  duration: stepEntry.data.duration,
@@ -0,0 +1,43 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { resolveActiveTrack, type TrackOption } from "../../lib/tracks.ts";
3
+ import { getDb } from "../db/client.ts";
4
+ import { progressEntries } from "../db/schema.ts";
5
+
6
+ export async function readPersistedTrack(
7
+ learnerId: string | undefined,
8
+ ): Promise<string | undefined> {
9
+ if (!learnerId) return undefined;
10
+ const db = getDb();
11
+ const [row] = await db
12
+ .select({ value: progressEntries.value })
13
+ .from(progressEntries)
14
+ .where(
15
+ and(
16
+ eq(progressEntries.learnerId, learnerId),
17
+ eq(progressEntries.kind, "pref"),
18
+ eq(progressEntries.scope, "global"),
19
+ eq(progressEntries.key, "track"),
20
+ ),
21
+ )
22
+ .limit(1);
23
+ return typeof row?.value === "string" ? row.value : undefined;
24
+ }
25
+
26
+ export async function resolveMcpTrack({
27
+ tracks,
28
+ defaultTrack,
29
+ explicitTrack,
30
+ learnerId,
31
+ }: {
32
+ tracks?: TrackOption[];
33
+ defaultTrack?: string;
34
+ explicitTrack?: string;
35
+ learnerId?: string;
36
+ }): Promise<string | undefined> {
37
+ return resolveActiveTrack({
38
+ tracks,
39
+ explicitTrack,
40
+ preferredTrack: await readPersistedTrack(learnerId),
41
+ defaultTrack,
42
+ });
43
+ }
@@ -1,12 +1,22 @@
1
- import { getStep } from "../../lib/content.ts";
1
+ import type { VerifySpec } from "../../collections.ts";
2
+ import { getStep, getTutorialBySlug } from "../../lib/content.ts";
3
+ import { isVerifySpec, resolveForTrack, type TrackScoped } from "../../lib/track-scoped.ts";
2
4
  import { getDb } from "../db/client.ts";
3
5
  import { helpRequests } from "../db/schema.ts";
4
6
  import { writeProgressEntries } from "../progress.ts";
5
7
  import { type CheckObservation, evaluate } from "../verify/evaluator.ts";
6
8
  import { errorResult, type McpTool, text } from "./protocol.ts";
9
+ import { resolveMcpTrack } from "./tracks.ts";
7
10
 
8
11
  const SCOPE = "progress:write";
9
12
 
13
+ function resolveVerifyForTrack(
14
+ verify: TrackScoped<VerifySpec> | undefined,
15
+ trackId: string | undefined,
16
+ ) {
17
+ return resolveForTrack(verify, trackId, isVerifySpec as (v: unknown) => v is VerifySpec);
18
+ }
19
+
10
20
  function requireLearner(learnerId: string | undefined) {
11
21
  if (!learnerId) {
12
22
  throw new Error("No resolved learner — bearer token required.");
@@ -312,6 +322,12 @@ export const verificationTools: McpTool[] = [
312
322
  properties: {
313
323
  tutorial: { type: "string", minLength: 1 },
314
324
  step: { type: "string", minLength: 1 },
325
+ track: {
326
+ type: "string",
327
+ minLength: 1,
328
+ description:
329
+ "Optional tutorial track id. Overrides the learner's persisted prefs.track for this verification.",
330
+ },
315
331
  observations: {
316
332
  type: "array",
317
333
  description:
@@ -326,16 +342,28 @@ export const verificationTools: McpTool[] = [
326
342
  const a = args as {
327
343
  tutorial: string;
328
344
  step: string;
345
+ track?: string;
329
346
  observations: CheckObservation[];
330
347
  };
331
348
  const learnerId = requireLearner(ctx.learnerId);
349
+ const tutorialEntry = await getTutorialBySlug(a.tutorial);
350
+ if (!tutorialEntry) {
351
+ return errorResult(`No tutorial "${a.tutorial}".`);
352
+ }
332
353
  const stepEntry = await getStep(a.tutorial, a.step);
333
354
  if (!stepEntry) {
334
355
  return errorResult(`No step "${a.step}" in "${a.tutorial}".`);
335
356
  }
336
- const spec = (stepEntry.data as { verify?: unknown }).verify as
337
- | import("../../collections.ts").VerifySpec
338
- | undefined;
357
+ const activeTrack = await resolveMcpTrack({
358
+ tracks: tutorialEntry.data.tracks,
359
+ defaultTrack: tutorialEntry.data.defaultTrack,
360
+ explicitTrack: a.track,
361
+ learnerId,
362
+ });
363
+ const spec = resolveVerifyForTrack(
364
+ (stepEntry.data as { verify?: TrackScoped<VerifySpec> }).verify,
365
+ activeTrack,
366
+ );
339
367
  if (!spec) {
340
368
  return errorResult(
341
369
  `Step ${a.tutorial}/${a.step} has no verify block. Use complete_checkpoint for prose-fallback verification.`,
@@ -356,6 +384,7 @@ export const verificationTools: McpTool[] = [
356
384
  source: "verify",
357
385
  tutorial: a.tutorial,
358
386
  step: a.step,
387
+ track: activeTrack ?? null,
359
388
  results: a.observations,
360
389
  ts,
361
390
  },
@@ -383,6 +412,7 @@ export const verificationTools: McpTool[] = [
383
412
  key: spec.id,
384
413
  value: {
385
414
  pass: false,
415
+ track: activeTrack ?? null,
386
416
  failingCheckIndex: verdict.failingCheckIndex,
387
417
  reason: verdict.reason,
388
418
  hint: verdict.hint,
@@ -0,0 +1,44 @@
1
+ .track-selector {
2
+ display: grid;
3
+ gap: 0.5rem;
4
+ margin-top: 1rem;
5
+ padding-top: 1rem;
6
+ border-top: var(--border-default, 2px) solid var(--color-border);
7
+ }
8
+
9
+ .track-selector-label {
10
+ font-family: var(--font-mono);
11
+ font-size: 0.72em;
12
+ color: var(--color-muted);
13
+ text-transform: uppercase;
14
+ letter-spacing: 0.08em;
15
+ }
16
+
17
+ .track-selector-list {
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ gap: 0.4rem;
21
+ }
22
+
23
+ .track-selector-option {
24
+ appearance: none;
25
+ border: var(--border-default, 2px) solid var(--color-border);
26
+ background: var(--color-bg);
27
+ color: var(--color-fg);
28
+ cursor: pointer;
29
+ font: inherit;
30
+ font-family: var(--font-mono);
31
+ font-size: 0.75em;
32
+ line-height: 1;
33
+ padding: 0.45rem 0.6rem;
34
+ }
35
+
36
+ .track-selector-option:hover {
37
+ border-color: var(--color-accent);
38
+ }
39
+
40
+ .track-selector-option[data-active="true"] {
41
+ background: var(--color-accent);
42
+ border-color: var(--color-accent);
43
+ color: var(--color-accent-fg);
44
+ }
@@ -15,6 +15,7 @@
15
15
 
16
16
  /* Site chrome */
17
17
  @import "./components/progress.css";
18
+ @import "./components/track-selector.css";
18
19
 
19
20
  /* AI assistant */
20
21
  @import "./components/chat.css";