learn-anything-cli 1.2.2 → 1.3.1

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/dist/cli/index.js CHANGED
@@ -95,6 +95,7 @@ program
95
95
  .command('serve [path]')
96
96
  .description(m.cli.serveCommandDescription)
97
97
  .option('--port <number>', m.cli.portOption, parseInt)
98
+ .option('--strict-port', m.cli.strictPortOption)
98
99
  .option('--no-open', m.cli.noOpenOption)
99
100
  .option('--lang <locale>', m.cli.langOption)
100
101
  .action(async (targetPath = '.', options) => {
@@ -105,6 +106,7 @@ program
105
106
  await executeServe({
106
107
  targetPath,
107
108
  port: options?.port,
109
+ strictPort: options?.strictPort,
108
110
  open: options?.open,
109
111
  locale: cliLocale,
110
112
  });
@@ -2,6 +2,7 @@ import type { SupportedLocale } from '../i18n/types.js';
2
2
  export interface ServeOptions {
3
3
  targetPath?: string;
4
4
  port?: number;
5
+ strictPort?: boolean;
5
6
  open?: boolean;
6
7
  locale?: SupportedLocale;
7
8
  }
@@ -5,6 +5,7 @@ import * as fs from 'fs';
5
5
  import { createRequire } from 'module';
6
6
  import { LEARN_DIR } from '../core/config.js';
7
7
  import { getMessages } from '../i18n/index.js';
8
+ import { DEFAULT_PORT, findFreePort, isPortFree } from '../utils/port.js';
8
9
  export async function executeServe(options) {
9
10
  const locale = options.locale ?? 'en';
10
11
  const msg = getMessages(locale);
@@ -27,7 +28,29 @@ export async function executeServe(options) {
27
28
  console.log(chalk.cyan(m.startingServer));
28
29
  const require = createRequire(import.meta.url);
29
30
  const siteDistDir = path.join(path.dirname(require.resolve('../../package.json')), 'site-dist');
30
- const port = options.port ?? 24278;
31
+ const requestedPort = options.port ?? DEFAULT_PORT;
32
+ let port;
33
+ if (options.strictPort) {
34
+ // Honour the exact port; surface a clear error if it is taken.
35
+ if (!(await isPortFree(requestedPort))) {
36
+ console.error(chalk.red(m.portInUse(requestedPort)));
37
+ process.exit(1);
38
+ }
39
+ port = requestedPort;
40
+ }
41
+ else {
42
+ try {
43
+ port = await findFreePort(requestedPort);
44
+ }
45
+ catch {
46
+ const endPort = requestedPort + 50 - 1;
47
+ console.error(chalk.red(m.portRangeExhausted(requestedPort, endPort)));
48
+ process.exit(1);
49
+ }
50
+ if (port !== requestedPort) {
51
+ console.log(chalk.yellow(m.portSwitched(requestedPort, port)));
52
+ }
53
+ }
31
54
  if (!fs.existsSync(path.join(siteDistDir, 'serve.mjs'))) {
32
55
  console.error(chalk.red(m.siteNotBuilt));
33
56
  process.exit(1);
@@ -12,6 +12,7 @@ export const en = {
12
12
  forceOption: 'Skip confirmation prompt',
13
13
  langOption: 'Display language: zh-CN or en (default: system locale)',
14
14
  portOption: 'Port for the dev server (default: 24278)',
15
+ strictPortOption: 'Use the exact port from --port; do not auto-pick a free one when busy',
15
16
  noOpenOption: 'Do not open browser automatically',
16
17
  serveCommandDescription: 'Start a local site to visualize learning progress',
17
18
  serveHint: 'Run npx learn-anything serve to view your learning progress in browser',
@@ -36,6 +37,8 @@ export const en = {
36
37
  startingServer: 'Starting server...',
37
38
  siteReady: (url) => `Site ready at ${url}`,
38
39
  portInUse: (port) => `Port ${port} is already in use. Try a different port with --port option.`,
40
+ portSwitched: (from, to) => `Port ${from} is already in use — switching to port ${to}.`,
41
+ portRangeExhausted: (start, end) => `No free port found in range ${start}-${end}. Specify one manually with --port.`,
39
42
  emptyTopics: 'No learning topics found in .learn/topics/. Start learning with /learn:topic.',
40
43
  serverStopped: 'Server stopped.',
41
44
  siteNotBuilt: 'Site files not found. Please reinstall the package or run the build step.',
@@ -12,6 +12,7 @@ export const zhCN = {
12
12
  forceOption: '跳过确认提示',
13
13
  langOption: '界面语言:zh-CN 或 en(默认读取系统语言设置)',
14
14
  portOption: '开发服务器端口(默认:24278)',
15
+ strictPortOption: '严格使用 --port 指定的端口,被占用时不自动寻找空闲端口',
15
16
  noOpenOption: '不自动打开浏览器',
16
17
  serveCommandDescription: '启动本地站点以可视化学习进度',
17
18
  serveHint: '运行 npx learn-anything serve 在浏览器中查看学习进度',
@@ -36,6 +37,8 @@ export const zhCN = {
36
37
  startingServer: '正在启动服务器...',
37
38
  siteReady: (url) => `站点已就绪: ${url}`,
38
39
  portInUse: (port) => `端口 ${port} 已被占用。请使用 --port 选项指定其他端口。`,
40
+ portSwitched: (from, to) => `端口 ${from} 已被占用 —— 自动切换到端口 ${to}。`,
41
+ portRangeExhausted: (start, end) => `在 ${start}-${end} 范围内未找到可用端口。请使用 --port 手动指定。`,
39
42
  emptyTopics: '.learn/topics/ 中未找到学习主题。使用 /learn:topic 开始学习。',
40
43
  serverStopped: '服务器已停止。',
41
44
  siteNotBuilt: '未找到站点文件。请重新安装包或运行构建步骤。',
@@ -3,6 +3,8 @@ export interface ServeMessages {
3
3
  startingServer: string;
4
4
  siteReady: (url: string) => string;
5
5
  portInUse: (port: number) => string;
6
+ portSwitched: (from: number, to: number) => string;
7
+ portRangeExhausted: (start: number, end: number) => string;
6
8
  emptyTopics: string;
7
9
  serverStopped: string;
8
10
  siteNotBuilt: string;
@@ -20,6 +22,7 @@ export interface CLIMessages {
20
22
  forceOption: string;
21
23
  langOption: string;
22
24
  portOption: string;
25
+ strictPortOption: string;
23
26
  noOpenOption: string;
24
27
  serveCommandDescription: string;
25
28
  serveHint: string;
@@ -0,0 +1,17 @@
1
+ export declare const DEFAULT_PORT = 24278;
2
+ export declare const DEFAULT_MAX_ATTEMPTS = 50;
3
+ /**
4
+ * Test whether a TCP port is free to bind.
5
+ *
6
+ * Probes without binding a specific host so the check matches the behaviour of
7
+ * `server.listen(port)` used by the dev server (which listens on the wildcard
8
+ * address). The probe socket is unreffed so it never keeps the process alive.
9
+ */
10
+ export declare function isPortFree(port: number): Promise<boolean>;
11
+ /**
12
+ * Find the first free port starting from `startPort`, incrementing one at a
13
+ * time up to `maxAttempts` (inclusive of the start port). Throws when the
14
+ * whole range is occupied.
15
+ */
16
+ export declare function findFreePort(startPort: number, maxAttempts?: number): Promise<number>;
17
+ //# sourceMappingURL=port.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { createServer } from 'node:net';
2
+ export const DEFAULT_PORT = 24278;
3
+ export const DEFAULT_MAX_ATTEMPTS = 50;
4
+ /**
5
+ * Test whether a TCP port is free to bind.
6
+ *
7
+ * Probes without binding a specific host so the check matches the behaviour of
8
+ * `server.listen(port)` used by the dev server (which listens on the wildcard
9
+ * address). The probe socket is unreffed so it never keeps the process alive.
10
+ */
11
+ export function isPortFree(port) {
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const probe = createServer();
15
+ probe.unref();
16
+ const done = (result) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ probe.removeAllListeners();
21
+ resolve(result);
22
+ };
23
+ probe.once('error', () => {
24
+ // EADDRINUSE / EACCES / permission issues → port unavailable.
25
+ done(false);
26
+ });
27
+ probe.once('listening', () => {
28
+ // Successfully bound — close and hand the port back.
29
+ probe.close(() => done(true));
30
+ });
31
+ probe.listen(port);
32
+ });
33
+ }
34
+ /**
35
+ * Find the first free port starting from `startPort`, incrementing one at a
36
+ * time up to `maxAttempts` (inclusive of the start port). Throws when the
37
+ * whole range is occupied.
38
+ */
39
+ export async function findFreePort(startPort, maxAttempts = DEFAULT_MAX_ATTEMPTS) {
40
+ for (let offset = 0; offset < maxAttempts; offset++) {
41
+ const port = startPort + offset;
42
+ if (await isPortFree(port)) {
43
+ return port;
44
+ }
45
+ }
46
+ const endPort = startPort + maxAttempts - 1;
47
+ throw new Error(`No free port found in range ${startPort}-${endPort}`);
48
+ }
49
+ //# sourceMappingURL=port.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "learn-anything-cli",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "AI-powered recursive learning system with Socratic method and TDD practice",
5
5
  "keywords": [
6
6
  "learn-anything-cli",
@@ -1 +1 @@
1
- import{d as u,u as m,c as n,a as s,t,b as a,e as x,F as _,r as h,f as p,o as l,n as f,g,l as b}from"./index-DVWAD3fd.js";const v={class:"w-full"},y={class:"mb-4"},k={key:0,class:"text-sm text-text-3 mb-10"},C={key:1,class:"flex flex-col items-center justify-center py-24 text-center"},w={class:"text-sm font-medium text-text-2 mb-2"},T={class:"text-xs text-text-3 max-w-md"},B={key:2,class:"grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4"},$=["onClick"],D={class:"text-base font-semibold text-text-1 leading-snug mb-3"},F={class:"text-[13px] text-text-2 leading-relaxed"},L={class:"mt-4 flex items-center gap-3"},N={class:"flex-1 h-1 bg-(--color-divider) rounded-full overflow-hidden"},S={class:"text-xs font-semibold tabular-nums text-text-2"},E=u({__name:"Dashboard",setup(V){const c=g(),{t:o}=m(),r=p(()=>b());function i(d){c.push(`/topics/${d}`)}return(d,j)=>(l(),n("div",v,[s("h1",y,t(a(o)("dashboard.title")),1),r.value.length>0?(l(),n("p",k,t(r.value.length)+" "+t(r.value.length===1?"topic":"topics"),1)):x("",!0),r.value.length===0?(l(),n("div",C,[s("p",w,t(a(o)("dashboard.noTopics")),1),s("p",T,t(a(o)("dashboard.startLearning")),1)])):(l(),n("div",B,[(l(!0),n(_,null,h(r.value,e=>(l(),n("button",{key:e.slug,class:"text-left bg-(--color-bg-soft) rounded-xl border border-(--color-divider) p-6 hover:border-brand-2 transition-colors duration-150 cursor-pointer",onClick:z=>i(e.slug)},[s("h3",D,t(e.name),1),s("p",F,t(e.domainCount)+" "+t(a(o)("topic.domains"))+" · "+t(e.totalConcepts)+" "+t(a(o)("topic.concepts"))+" · "+t(e.masteredCount)+"/"+t(e.totalConcepts)+" "+t(a(o)("topic.mastered")),1),s("div",L,[s("div",N,[s("div",{class:"h-full rounded-full bg-brand-2 transition-all duration-500",style:f({width:`${e.percentage}%`})},null,4)]),s("span",S,t(e.percentage)+"% ",1)])],8,$))),128))]))]))}});export{E as default};
1
+ import{d as u,u as m,c as n,a as s,t,b as a,e as x,F as _,r as h,f as p,o as l,n as f,g,l as b}from"./index-C63UI5iQ.js";const v={class:"w-full"},y={class:"mb-4"},k={key:0,class:"text-sm text-text-3 mb-10"},C={key:1,class:"flex flex-col items-center justify-center py-24 text-center"},w={class:"text-sm font-medium text-text-2 mb-2"},T={class:"text-xs text-text-3 max-w-md"},B={key:2,class:"grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4"},$=["onClick"],D={class:"text-base font-semibold text-text-1 leading-snug mb-3"},F={class:"text-[13px] text-text-2 leading-relaxed"},L={class:"mt-4 flex items-center gap-3"},N={class:"flex-1 h-1 bg-(--color-divider) rounded-full overflow-hidden"},S={class:"text-xs font-semibold tabular-nums text-text-2"},E=u({__name:"Dashboard",setup(V){const c=g(),{t:o}=m(),r=p(()=>b());function i(d){c.push(`/topics/${d}`)}return(d,j)=>(l(),n("div",v,[s("h1",y,t(a(o)("dashboard.title")),1),r.value.length>0?(l(),n("p",k,t(r.value.length)+" "+t(r.value.length===1?"topic":"topics"),1)):x("",!0),r.value.length===0?(l(),n("div",C,[s("p",w,t(a(o)("dashboard.noTopics")),1),s("p",T,t(a(o)("dashboard.startLearning")),1)])):(l(),n("div",B,[(l(!0),n(_,null,h(r.value,e=>(l(),n("button",{key:e.slug,class:"text-left bg-(--color-bg-soft) rounded-xl border border-(--color-divider) p-6 hover:border-brand-2 transition-colors duration-150 cursor-pointer",onClick:z=>i(e.slug)},[s("h3",D,t(e.name),1),s("p",F,t(e.domainCount)+" "+t(a(o)("topic.domains"))+" · "+t(e.totalConcepts)+" "+t(a(o)("topic.concepts"))+" · "+t(e.masteredCount)+"/"+t(e.totalConcepts)+" "+t(a(o)("topic.mastered")),1),s("div",L,[s("div",N,[s("div",{class:"h-full rounded-full bg-brand-2 transition-all duration-500",style:f({width:`${e.percentage}%`})},null,4)]),s("span",S,t(e.percentage)+"% ",1)])],8,$))),128))]))]))}});export{E as default};
@@ -0,0 +1 @@
1
+ import{d as _,u as k,c as f,a as n,t as g,b as h,F as M,r as I,e as L,o as c,w as N,n as S,h as B,i as E,j as x,k as j,m as F,p as H,q as b,f as v,s as T,v as V,x as R,y as z,z as w,A,B as D}from"./index-C63UI5iQ.js";const q={key:0,class:"toc-nav","aria-label":"Table of contents"},K={class:"mb-3 text-sm font-semibold text-text-1"},O={class:"border-l border-divider"},P=["href","onClick"],U=_({__name:"TableOfContents",props:{headings:{},activeId:{}},emits:["navigate"],setup(i,{emit:e}){const a=e,{t:d}=k();return(s,t)=>i.headings.length?(c(),f("nav",q,[n("p",K,g(h(d)("toc.title")),1),n("ul",O,[(c(!0),f(M,null,I(i.headings,o=>(c(),f("li",{key:o.id},[n("a",{href:`#${o.id}`,class:B(["block border-l-2 -ml-px py-1 pr-2 text-[13px] leading-6 transition-colors truncate",o.id===i.activeId?"border-brand-2 text-brand-2 font-medium":"border-transparent text-text-3 hover:text-text-1"]),style:S({paddingLeft:`${(o.level-2)*12+12}px`}),onClick:N(l=>a("navigate",o.id),["prevent"])},g(o.text),15,P)]))),128))])])):L("",!0)}});function G(i){const e=x([]),a=x(""),d=x(!1);let s=null,t=null;function o(){const r=i.value;if(!r){e.value=[];return}const m=Array.from(r.querySelectorAll("h2[id], h3[id]"));e.value=m.map(u=>{var p;return{id:u.id,text:((p=u.textContent)==null?void 0:p.replace(/^#\s*/,"").trim())??"",level:Number(u.tagName.substring(1))}}),s==null||s.disconnect(),m.length!==0&&(s=new IntersectionObserver(u=>{if(d.value)return;const p=u.filter(y=>y.isIntersecting);p.length!==0&&(p.sort((y,C)=>y.boundingClientRect.top-C.boundingClientRect.top),a.value=p[0].target.id)},{rootMargin:"-80px 0px -70% 0px"}),m.forEach(u=>s.observe(u)))}function l(r){const m=document.getElementById(r);if(!m)return;m.scrollIntoView({behavior:"smooth",block:"start"}),a.value=r,d.value=!0,t&&clearTimeout(t);const u=()=>{t&&clearTimeout(t),t=setTimeout(()=>{d.value=!1,window.removeEventListener("scroll",u),t=null},150)};window.addEventListener("scroll",u,{passive:!0}),typeof history<"u"&&history.replaceState(null,"",`#${r}`)}return E(()=>{s==null||s.disconnect(),t&&clearTimeout(t)}),{headings:e,activeId:a,refresh:o,scrollTo:l}}const J={class:"xl:flex xl:justify-center xl:gap-8"},Q=["innerHTML"],W={class:"hidden shrink-0 xl:block w-48 2xl:w-64"},X={class:"sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto"},$=_({__name:"TocLayout",props:{html:{}},setup(i){const e=i,a=x(),{headings:d,activeId:s,refresh:t,scrollTo:o}=G(a);return j(()=>e.html,()=>H(()=>t()),{immediate:!0}),(l,r)=>(c(),f("div",J,[n("article",{ref_key:"contentRef",ref:a,class:"prose-content xl:mx-0! xl:min-w-0 xl:flex-1",innerHTML:i.html},null,8,Q),n("aside",W,[n("div",X,[F(U,{headings:h(d),"active-id":h(s),onNavigate:h(o)},null,8,["headings","active-id","onNavigate"])])])]))}}),Y={class:"h-full"},Z={key:0,class:"flex items-center justify-center h-full min-h-75 text-sm text-text-3"},ee={key:1,class:"prose-content space-y-3","aria-hidden":"true"},te={key:3,class:"prose-content rounded-lg overflow-hidden bg-(--color-bg-alt)"},ne={class:"flex items-center justify-between px-5 py-2 bg-transparent"},se={class:"text-xs text-text-3 font-mono"},oe={class:"m-0! p-0! bg-transparent! text-left overflow-x-auto"},le=["innerHTML"],ie=_({__name:"ContentViewer",props:{file:{}},setup(i){const e=i,a=v(()=>e.file&&e.file.path.split("/").pop()||""),d=v(()=>R(a.value)),s=v(()=>{var l;return((l=e.file)==null?void 0:l.content)!==void 0}),t=v(()=>{var l;return((l=e.file)==null?void 0:l.content)===void 0?"":e.file.type==="markdown"?T(e.file.content):V(e.file.content,d.value)}),o=v(()=>{var l;return((l=e.file)==null?void 0:l.type)==="markdown"});return(l,r)=>(c(),f("div",Y,[i.file?s.value?o.value?(c(),b($,{key:2,html:t.value},null,8,["html"])):(c(),f("div",te,[n("div",ne,[n("span",se,g(a.value),1)]),n("pre",oe,[n("code",{class:"block px-6! py-5! leading-[1.7] font-mono text-[0.875em] text-text-2",innerHTML:t.value},null,8,le)])])):(c(),f("div",ee,[...r[0]||(r[0]=[n("div",{class:"h-5 w-2/3 rounded bg-(--color-divider)"},null,-1),n("div",{class:"h-3 w-full rounded bg-(--color-divider)"},null,-1),n("div",{class:"h-3 w-full rounded bg-(--color-divider)"},null,-1),n("div",{class:"h-3 w-4/5 rounded bg-(--color-divider)"},null,-1)])])):(c(),f("div",Z," Select a file from the sidebar to view its content "))]))}}),re={key:0,class:"flex flex-col items-center justify-center py-24 text-center"},ae={class:"text-base text-(--color-pencil)"},ce={key:1},ue=_({__name:"TopicPage",props:{slug:{}},setup(i){const e=i,{t:a}=k(),d=v(()=>(w(),A(e.slug))),s=v(()=>(w(),D(e.slug))),t=v(()=>{const r=s.value;return r?T(r):""}),o=z("topicSelectedFile",x(null)),l=v(()=>!o.value);return(r,m)=>d.value?(c(),f("div",ce,[l.value?(c(),b($,{key:0,html:t.value},null,8,["html"])):(c(),b(ie,{key:1,file:h(o)},null,8,["file"]))])):(c(),f("div",re,[m[0]||(m[0]=n("div",{class:"text-4xl mb-4 opacity-60 select-none"},"🔍",-1)),n("p",ae,g(h(a)("topic.notFound"))+": "+g(i.slug),1)]))}});export{ue as default};