bavitutor 0.3.1 → 0.3.2

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 CHANGED
@@ -83,12 +83,38 @@ vpx /path/to/bavitutor --no-open # vpx takes the same specs and flags
83
83
  | `--host <host>` | `127.0.0.1` | interface to listen on (this machine only) |
84
84
  | `--data-dir <dir>` | `~/.bavitutor` | database, plus settings in `<dir>/.env` |
85
85
  | `--no-open` | | don't open the browser |
86
+ | `--tailscale` | | also share it on your tailnet (see below) |
86
87
 
87
88
  On start the CLI checks for a Claude login or `ANTHROPIC_API_KEY`, looks for
88
89
  Chrome (`BAVI_CHROME`, then the standard Windows, macOS and Linux locations)
89
90
  and warns if either is missing, and stops with a clear message if the port is
90
91
  taken.
91
92
 
93
+ ### Open it on your other devices
94
+
95
+ ```sh
96
+ npx bavitutor --tailscale
97
+ ```
98
+
99
+ With `--tailscale`, BaviTutor also shares itself on your
100
+ [Tailscale](https://tailscale.com) tailnet, so your phone or another computer
101
+ can open it:
102
+
103
+ ```text
104
+ BaviTutor is running at http://localhost:4100 (Ctrl+C to stop)
105
+ on your tailnet at https://laptop.tail1234.ts.net:4100
106
+ ```
107
+
108
+ It runs `tailscale serve` over HTTPS on the same port number, so anything you
109
+ already serve on 443 stays as it is. Only devices on your tailnet can reach it
110
+ (it is not Funnel), and the share stops when BaviTutor stops. Anyone on your
111
+ tailnet can use it, on your Claude login.
112
+
113
+ It needs Tailscale installed and logged in, with MagicDNS and HTTPS
114
+ Certificates turned on (admin console, DNS page). If something is missing,
115
+ BaviTutor says what to fix and still runs on this computer. If BaviTutor is
116
+ already running, `--tailscale` shares that one until you press Ctrl+C.
117
+
92
118
  ### Configuration (environment, `apps/server/.env` in dev, `<data-dir>/.env` for the CLI)
93
119
 
94
120
  | Variable | Default | Meaning |
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import * as NodeChildProcess from "node:child_process";
4
- import { spawn } from "node:child_process";
4
+ import { execFile, spawn } from "node:child_process";
5
5
  import { Duplex, PassThrough, Readable } from "node:stream";
6
6
  import * as Http from "node:http";
7
7
  import { createServer } from "node:http";
@@ -652,6 +652,71 @@ function isBoolean(input) {
652
652
  return typeof input === "boolean";
653
653
  }
654
654
  /**
655
+ * Checks whether a value is a `symbol`.
656
+ *
657
+ * **When to use**
658
+ *
659
+ * Use when you need a `Predicate` guard to narrow an `unknown` value to a
660
+ * symbol.
661
+ *
662
+ * **Details**
663
+ *
664
+ * Uses `typeof input === "symbol"`.
665
+ *
666
+ * **Example** (Guarding symbols)
667
+ *
668
+ * ```ts import.meta.vitest
669
+ * import { Predicate } from "effect"
670
+ *
671
+ * const data: unknown = Symbol.for("id")
672
+ *
673
+ * if (Predicate.isSymbol(data)) {
674
+ * data.description // => "id"
675
+ * }
676
+ * ```
677
+ *
678
+ * @see {@link isPropertyKey}
679
+ * @category guards
680
+ * @since 2.0.0
681
+ */
682
+ function isSymbol(input) {
683
+ return typeof input === "symbol";
684
+ }
685
+ /**
686
+ * Checks whether a value is a valid `PropertyKey` (string, number, or symbol).
687
+ *
688
+ * **When to use**
689
+ *
690
+ * Use when you need a `Predicate` guard for unknown property keys before
691
+ * indexing.
692
+ *
693
+ * **Details**
694
+ *
695
+ * Uses `isString`, `isNumber`, and `isSymbol`.
696
+ *
697
+ * **Example** (Guarding property keys)
698
+ *
699
+ * ```ts import.meta.vitest
700
+ * import { Predicate } from "effect"
701
+ *
702
+ * const key: unknown = "name"
703
+ * const obj: Record<PropertyKey, unknown> = { name: "Ada" }
704
+ *
705
+ * if (Predicate.isPropertyKey(key) && key in obj) {
706
+ * obj[key] // => "Ada"
707
+ * }
708
+ * ```
709
+ *
710
+ * @see {@link isString}
711
+ * @see {@link isNumber}
712
+ * @see {@link isSymbol}
713
+ * @category guards
714
+ * @since 4.0.0
715
+ */
716
+ function isPropertyKey(u) {
717
+ return isString(u) || isNumber(u) || isSymbol(u);
718
+ }
719
+ /**
655
720
  * Checks whether a value is a `function`.
656
721
  *
657
722
  * **When to use**
@@ -23018,7 +23083,7 @@ const provideService = /*#__PURE__*/ dual(3, (self, key, service) => fromChannel
23018
23083
  * @category destructors
23019
23084
  * @since 2.0.0
23020
23085
  */
23021
- const run$4 = /*#__PURE__*/ dual(2, (self, sink) => scopedWith((scope) => toPullScoped(self.channel, scope).pipe(flatMap$1((upstream) => sink.transform(upstream, scope)), map$3(([a]) => a))));
23086
+ const run$5 = /*#__PURE__*/ dual(2, (self, sink) => scopedWith((scope) => toPullScoped(self.channel, scope).pipe(flatMap$1((upstream) => sink.transform(upstream, scope)), map$3(([a]) => a))));
23022
23087
  /**
23023
23088
  * Runs the stream and collects all elements into an array.
23024
23089
  *
@@ -26761,6 +26826,35 @@ function withConstructorDefault$1(ast, defaultValue) {
26761
26826
  function decodeTo$1(from, to, transformation) {
26762
26827
  return appendTransformation(from, transformation, to);
26763
26828
  }
26829
+ function parseParameter(ast) {
26830
+ const literals = [];
26831
+ const parameters = [];
26832
+ function go(ast) {
26833
+ switch (ast._tag) {
26834
+ case "Literal":
26835
+ if (isPropertyKey(ast.literal) && !literals.includes(ast.literal)) literals.push(ast.literal);
26836
+ return;
26837
+ case "UniqueSymbol":
26838
+ if (!literals.includes(ast.symbol)) literals.push(ast.symbol);
26839
+ return;
26840
+ case "Never": return;
26841
+ case "Union":
26842
+ for (let i = 0; i < ast.types.length; i++) go(ast.types[i]);
26843
+ return;
26844
+ default: parameters.push(ast);
26845
+ }
26846
+ }
26847
+ go(ast);
26848
+ return {
26849
+ literals,
26850
+ parameters
26851
+ };
26852
+ }
26853
+ /** @internal */
26854
+ function record(key, value) {
26855
+ const { literals, parameters: indexSignatures } = parseParameter(key);
26856
+ return new Objects(literals.map((literal) => new PropertySignature(literal, value)), indexSignatures.map((parameter) => new IndexSignature(parameter, value)));
26857
+ }
26764
26858
  /**
26765
26859
  * Returns `true` if the AST node represents an optional property.
26766
26860
  *
@@ -27850,7 +27944,7 @@ const layer$20 = /*#__PURE__*/ effect(ChildProcessSpawner, /* @__PURE__ */ gen(f
27850
27944
  evaluate: () => nodeStream,
27851
27945
  onError: (error) => toPlatformError(`fromWritable(fd${fd})`, toError(error), command)
27852
27946
  });
27853
- if (config.stream) yield* forkScoped(run$4(config.stream, sink));
27947
+ if (config.stream) yield* forkScoped(run$5(config.stream, sink));
27854
27948
  inputSinks.set(fd, sink);
27855
27949
  break;
27856
27950
  }
@@ -27884,7 +27978,7 @@ const layer$20 = /*#__PURE__*/ effect(ChildProcessSpawner, /* @__PURE__ */ gen(f
27884
27978
  endOnDone: config.endOnDone,
27885
27979
  encoding: config.encoding
27886
27980
  });
27887
- if (isStream(config.stream)) return as(forkScoped(run$4(config.stream, sink)), sink);
27981
+ if (isStream(config.stream)) return as(forkScoped(run$5(config.stream, sink)), sink);
27888
27982
  return succeed$3(sink);
27889
27983
  });
27890
27984
  const setupChildOutputStreams = (command, childProcess, stdoutConfig, stderrConfig) => {
@@ -29387,7 +29481,7 @@ function make$37(schema) {
29387
29481
  }
29388
29482
  function makeIs(ast) {
29389
29483
  {
29390
- const parser = asExit(run$3(ast));
29484
+ const parser = asExit(run$4(ast));
29391
29485
  return (input) => {
29392
29486
  const exit = parser(input, defaultParseOptions);
29393
29487
  if (isSuccess(exit)) return true;
@@ -29429,7 +29523,7 @@ function _is(ast) {
29429
29523
  * @since 4.0.0
29430
29524
  */
29431
29525
  function decodeUnknownEffect$1(schema, options) {
29432
- const parser = run$3(schema.ast);
29526
+ const parser = run$4(schema.ast);
29433
29527
  return options === void 0 ? parser : (input, overrideOptions) => parser(input, mergeParseOptions(options, overrideOptions));
29434
29528
  }
29435
29529
  /** @internal */
@@ -29458,7 +29552,7 @@ function decodeUnknownOption$1(schema, options) {
29458
29552
  * @since 4.0.0
29459
29553
  */
29460
29554
  function encodeUnknownEffect$1(schema, options) {
29461
- const parser = run$3(flip(schema.ast));
29555
+ const parser = run$4(flip(schema.ast));
29462
29556
  return options === void 0 ? parser : (input, overrideOptions) => parser(input, mergeParseOptions(options, overrideOptions));
29463
29557
  }
29464
29558
  const mergeParseOptions = (options, overrideOptions) => overrideOptions ? {
@@ -29470,7 +29564,7 @@ const getValue = (value) => {
29470
29564
  return succeed$3(value);
29471
29565
  };
29472
29566
  /** @internal */
29473
- function run$3(ast) {
29567
+ function run$4(ast) {
29474
29568
  return runWithCompiler(normalCompiler, ast);
29475
29569
  }
29476
29570
  function parserResult(result, input) {
@@ -32189,6 +32283,50 @@ function Struct(fields) {
32189
32283
  return makeStruct(struct(fields, void 0), fields);
32190
32284
  }
32191
32285
  /**
32286
+ * Defines a record schema whose dynamic properties are selected by a key schema
32287
+ * and decoded with a value schema.
32288
+ *
32289
+ * **Details**
32290
+ *
32291
+ * For dynamic keys, the key schema selects matching own properties and the
32292
+ * value schema decodes or encodes only those selected properties. Checks on
32293
+ * string, number, symbol, and template literal key schemas narrow which
32294
+ * properties are selected.
32295
+ *
32296
+ * For transformed key schemas, property selection is based on encoded property
32297
+ * names before the selected key is decoded.
32298
+ *
32299
+ * **Gotchas**
32300
+ *
32301
+ * When decoded or encoded key transformations produce the same property key,
32302
+ * sequential parsing applies selected own properties in selection order, so
32303
+ * the later selected property overwrites the earlier value. Dynamic key
32304
+ * selection always examines own properties. Finite literal keys are declared
32305
+ * fields and may therefore be inherited. Output key order is not guaranteed.
32306
+ *
32307
+ * **Example** (Defining a string-keyed record of numbers)
32308
+ *
32309
+ * ```ts import.meta.vitest
32310
+ * import { Schema } from "effect"
32311
+ *
32312
+ * const schema = Schema.Record(Schema.String, Schema.Number)
32313
+ *
32314
+ * // { readonly [x: string]: number }
32315
+ * type R = typeof schema.Type
32316
+ *
32317
+ * Schema.decodeUnknownSync(schema)({ a: 1, b: 2 }) // => { a: 1, b: 2 }
32318
+ * ```
32319
+ *
32320
+ * @category constructors
32321
+ * @since 3.10.0
32322
+ */
32323
+ function Record(key, value) {
32324
+ return make$35(record(key.ast, value.ast), {
32325
+ key,
32326
+ value
32327
+ });
32328
+ }
32329
+ /**
32192
32330
  * @category constructors
32193
32331
  * @since 4.0.0
32194
32332
  */
@@ -36997,7 +37135,7 @@ var PartBase$1 = class extends Class$1 {
36997
37135
  this[TypeId$16] = TypeId$16;
36998
37136
  }
36999
37137
  };
37000
- const defaultWriteFile = (path, file) => flatMap$1(FileSystem, (fs) => mapError$2(run$4(file.content, fs.sink(path)), toMultipartError));
37138
+ const defaultWriteFile = (path, file) => flatMap$1(FileSystem, (fs) => mapError$2(run$5(file.content, fs.sink(path)), toMultipartError));
37001
37139
  /**
37002
37140
  * Persists a stream of multipart parts into a record.
37003
37141
  *
@@ -38233,7 +38371,7 @@ const constInterruptedFiber$1 = /*#__PURE__*/ function() {
38233
38371
  * @category combinators
38234
38372
  * @since 2.0.0
38235
38373
  */
38236
- const run$2 = function() {
38374
+ const run$3 = function() {
38237
38375
  const self = arguments[0];
38238
38376
  if (!isEffect(arguments[1])) {
38239
38377
  const options = arguments[1];
@@ -39315,7 +39453,7 @@ const constInterruptedFiber = /*#__PURE__*/ function() {
39315
39453
  * @category combinators
39316
39454
  * @since 2.0.0
39317
39455
  */
39318
- const run$1 = function() {
39456
+ const run$2 = function() {
39319
39457
  const self = arguments[0];
39320
39458
  if (isEffect(arguments[2])) return runImpl(self, arguments[1], arguments[2], arguments[3]);
39321
39459
  const key = arguments[1];
@@ -47460,7 +47598,7 @@ const fromLiteral = (literal) => codeByLiteral[literal];
47460
47598
  //#endregion
47461
47599
  //#region package.json
47462
47600
  var name = "bavitutor";
47463
- var version$1 = "0.3.1";
47601
+ var version$1 = "0.3.2";
47464
47602
  /**
47465
47603
  * Default Server-Sent Events encoder.
47466
47604
  *
@@ -55384,14 +55522,14 @@ const sqliteCauseWithErrno = (cause) => {
55384
55522
  * @category running
55385
55523
  * @since 4.0.0
55386
55524
  */
55387
- const run = /*#__PURE__*/ make$13({});
55525
+ const run$1 = /*#__PURE__*/ make$13({});
55388
55526
  /**
55389
55527
  * Creates a layer that runs the configured SQLite migrations during layer construction and provides no services.
55390
55528
  *
55391
55529
  * @category layers
55392
55530
  * @since 4.0.0
55393
55531
  */
55394
- const layer = (options) => effectDiscard(run(options));
55532
+ const layer = (options) => effectDiscard(run$1(options));
55395
55533
  const MigratorLive = layer({ loader: fromRecord({
55396
55534
  "0001_initial": gen(function* () {
55397
55535
  const sql = yield* SqlClient;
@@ -56848,7 +56986,7 @@ var Flashcards = class Flashcards extends Service()("bavitutor/Flashcards") {
56848
56986
  return next;
56849
56987
  }).pipe(andThen(succeed$6(entry.deferred, rows)));
56850
56988
  const release = forEach$1(mine, (entry) => settle(entry, []), { discard: true });
56851
- if (mine.length > 0) yield* run$2(writers, forEach$1(chunksOf(mine, 3), (chunk) => writeRows(goalId, chunk.map((entry) => entry.idea)).pipe(flatMap$1((rows) => forEach$1(chunk, (entry) => settle(entry, rows.get(entry.idea.id) ?? []), { discard: true }))), {
56989
+ if (mine.length > 0) yield* run$3(writers, forEach$1(chunksOf(mine, 3), (chunk) => writeRows(goalId, chunk.map((entry) => entry.idea)).pipe(flatMap$1((rows) => forEach$1(chunk, (entry) => settle(entry, rows.get(entry.idea.id) ?? []), { discard: true }))), {
56852
56990
  concurrency: WRITE_CONCURRENCY$1,
56853
56991
  discard: true
56854
56992
  }).pipe(ensuring(release)));
@@ -62124,7 +62262,7 @@ var Research = class Research extends Service()("bavitutor/Research") {
62124
62262
  });
62125
62263
  yield* goals.setStatus(goalId, "Failed");
62126
62264
  })));
62127
- const start = (goalId) => run$1(fibers, goalId, run(goalId)).pipe(asVoid);
62265
+ const start = (goalId) => run$2(fibers, goalId, run(goalId)).pipe(asVoid);
62128
62266
  const cancel = (goalId) => remove$1(fibers, goalId);
62129
62267
  return Research.of({
62130
62268
  start,
@@ -62657,7 +62795,7 @@ var IdeaSeeding = class IdeaSeeding extends Service()("bavitutor/IdeaSeeding") {
62657
62795
  let queued = 0;
62658
62796
  for (const conceptId of conceptIds) if ((yield* goals.ideas(goalId, conceptId)).length > 0) yield* seedConcept(goalId, conceptId, how);
62659
62797
  else if (isResearched) {
62660
- yield* run$1(writing, `${goalId}/${conceptId}`, writeThenSeed(goalId, conceptId, how), { onlyIfMissing: true });
62798
+ yield* run$2(writing, `${goalId}/${conceptId}`, writeThenSeed(goalId, conceptId, how), { onlyIfMissing: true });
62661
62799
  queued++;
62662
62800
  }
62663
62801
  if (queued > 0) yield* logInfo(`Writing key ideas for ${queued} skipped lesson(s) in the background`);
@@ -62948,7 +63086,7 @@ var QuestionBank = class QuestionBank extends Service()("bavitutor/QuestionBank"
62948
63086
  missing: maybeDetail.value.graph.concepts.filter((concept) => !existing.has(concept.id))
62949
63087
  });
62950
63088
  });
62951
- const prepareDiagnostics = (goalId) => run$1(preparing, goalId, gen(function* () {
63089
+ const prepareDiagnostics = (goalId) => run$2(preparing, goalId, gen(function* () {
62952
63090
  const maybeWork = yield* missingConcepts(goalId);
62953
63091
  if (isNone(maybeWork)) return;
62954
63092
  const { subject, missing } = maybeWork.value;
@@ -63396,7 +63534,7 @@ var RenderError = class extends TaggedError()("RenderError", { message: String$2
63396
63534
  const WIDTH = 960;
63397
63535
  const HEIGHT = 600;
63398
63536
  const FRAME_ID = "render";
63399
- const START_TIMEOUT_MS = 6e3;
63537
+ const START_TIMEOUT_MS$1 = 6e3;
63400
63538
  const CHECK_TIMEOUT_MS = 8e3;
63401
63539
  const STEP_TIMEOUT_MS = 6e3;
63402
63540
  const CALL_TIMEOUT = seconds(15);
@@ -63528,7 +63666,7 @@ var Renderer = class Renderer extends Service()("bavitutor/viz/Renderer") {
63528
63666
  from: 0,
63529
63667
  kind: "Start",
63530
63668
  index: 0
63531
- }, START_TIMEOUT_MS);
63669
+ }, START_TIMEOUT_MS$1);
63532
63670
  const startEvents = yield* readEvents(page);
63533
63671
  const isReady = startEvents.some((maybeEvent) => exists(maybeEvent, (event) => event.type === "bv:ready"));
63534
63672
  if (!hasStarted || !isReady) {
@@ -63536,7 +63674,7 @@ var Renderer = class Renderer extends Service()("bavitutor/viz/Renderer") {
63536
63674
  return yield* finish({
63537
63675
  count: 0,
63538
63676
  captions: [],
63539
- errors: isReadonlyArrayNonEmpty(startErrors) ? dedupeIssues(startErrors) : [errorIssue(`The scene did not start within ${START_TIMEOUT_MS / 1e3} s (an endless loop at load time?).`, "load")],
63677
+ errors: isReadonlyArrayNonEmpty(startErrors) ? dedupeIssues(startErrors) : [errorIssue(`The scene did not start within ${START_TIMEOUT_MS$1 / 1e3} s (an endless loop at load time?).`, "load")],
63540
63678
  warnings: [],
63541
63679
  shots: []
63542
63680
  });
@@ -63830,7 +63968,7 @@ var Visuals = class Visuals extends Service()("bavitutor/Visuals") {
63830
63968
  _tag: "Failed",
63831
63969
  note: `Unexpected error: ${pretty(cause).slice(0, 400)}`
63832
63970
  })), withSpan("Visuals.generate"));
63833
- const start = (goalId, visualId) => run$1(running, key(goalId, visualId), generate(goalId, visualId), { onlyIfMissing: true }).pipe(asVoid);
63971
+ const start = (goalId, visualId) => run$2(running, key(goalId, visualId), generate(goalId, visualId), { onlyIfMissing: true }).pipe(asVoid);
63834
63972
  const enqueue = (goalId, visualId, brief, context) => gen(function* () {
63835
63973
  const now = yield* currentTimeMillis;
63836
63974
  const text = brief.trim();
@@ -64243,7 +64381,7 @@ var Lessons = class Lessons extends Service()("bavitutor/Lessons") {
64243
64381
  VALUES (${goalId}, ${conceptId}, 'Generating', ${now})
64244
64382
  ON CONFLICT (goal_id, concept_id) DO UPDATE SET status = 'Generating', error = NULL, started_at = excluded.started_at
64245
64383
  `.pipe(orDie$2);
64246
- yield* run$1(generating, `${goalId}/${conceptId}`, generate(goalId, conceptId), { onlyIfMissing: true });
64384
+ yield* run$2(generating, `${goalId}/${conceptId}`, generate(goalId, conceptId), { onlyIfMissing: true });
64247
64385
  return now;
64248
64386
  });
64249
64387
  const storedArm = (goalId, conceptId) => sql`
@@ -64983,7 +65121,7 @@ var Reviews = class Reviews extends Service()("bavitutor/Reviews") {
64983
65121
  return new Map(entries);
64984
65122
  });
64985
65123
  const leechMap = leeches.active.pipe(map$3((all) => new Map(all.map((leech) => [conceptKey$1(leech.goalId, leech.conceptId), leech]))));
64986
- const prepareItems = (goalId, conceptId) => run$1(preparing, `${goalId}/${conceptId}`, gen(function* () {
65124
+ const prepareItems = (goalId, conceptId) => run$2(preparing, `${goalId}/${conceptId}`, gen(function* () {
64987
65125
  if (((yield* sql`
64988
65126
  SELECT COUNT(*) AS n FROM questions
64989
65127
  WHERE goal_id = ${goalId} AND concept_id = ${conceptId} AND purpose = 'Review'
@@ -66515,6 +66653,147 @@ const makeServer = (options = {}) => {
66515
66653
  })).pipe(provide(ResumeInterrupted), provide(ResumeSkippedIdeas), provide(ServicesLive), provide(ConfigLive(options)));
66516
66654
  };
66517
66655
  //#endregion
66656
+ //#region apps/server/src/tailscale.ts
66657
+ /**
66658
+ * `bavitutor --tailscale`: shares the server with the learner's other devices
66659
+ * through `tailscale serve`, over HTTPS on the same port number, so whatever
66660
+ * the machine already serves on 443 stays as it is. `serve` runs in the
66661
+ * foreground, and Tailscale drops the share when that process ends, so the
66662
+ * share lasts exactly as long as BaviTutor does.
66663
+ */
66664
+ const problem = (reason, ...fix) => ({
66665
+ _tag: "Problem",
66666
+ reason,
66667
+ fix
66668
+ });
66669
+ const decodeStatus = decodeUnknownOption(fromJsonString(Struct({
66670
+ BackendState: String$2,
66671
+ Self: optional(NullOr(Struct({ DNSName: String$2 }))),
66672
+ CertDomains: optional(NullOr(ArraySchema(String$2)))
66673
+ })));
66674
+ const ServeConfig = Struct({
66675
+ TCP: optional(Record(String$2, Unknown)),
66676
+ Web: optional(Record(String$2, Struct({ Handlers: optional(Record(String$2, Struct({ Proxy: optional(String$2) }))) })))
66677
+ });
66678
+ const decodeServeStatus = decodeUnknownOption(fromJsonString(Struct({
66679
+ ...ServeConfig.fields,
66680
+ Foreground: optional(Record(String$2, ServeConfig))
66681
+ })));
66682
+ const TRY_AGAIN = "then try again.";
66683
+ /** Reads `tailscale status --json`: this device's name, or what's in the way. */
66684
+ const readStatus = (json) => decodeStatus(json).pipe(match$5({
66685
+ onNone: () => problem("Tailscale isn't running", `Start the Tailscale app, ${TRY_AGAIN}`),
66686
+ onSome: (status) => {
66687
+ if (status.BackendState === "Stopped") return problem("Tailscale is off", `Turn it on in the Tailscale app or with \`tailscale up\`, ${TRY_AGAIN}`);
66688
+ if (status.BackendState === "NeedsLogin") return problem("Tailscale isn't logged in", `Log in with the Tailscale app or \`tailscale up\`, ${TRY_AGAIN}`);
66689
+ if (status.BackendState === "NeedsMachineAuth") return problem("This computer waits for approval on your tailnet", `Approve it at https://login.tailscale.com/admin/machines, ${TRY_AGAIN}`);
66690
+ const dnsName = status.Self?.DNSName.replace(/\.$/, "") ?? "";
66691
+ if (status.BackendState !== "Running" || dnsName === "") return problem("Tailscale isn't connected yet", `Wait a moment, ${TRY_AGAIN}`);
66692
+ if ((status.CertDomains ?? []).length === 0) return problem("Your tailnet doesn't have HTTPS on", "Turn on MagicDNS and HTTPS Certificates at", `https://login.tailscale.com/admin/dns, ${TRY_AGAIN}`);
66693
+ return { dnsName };
66694
+ }
66695
+ }));
66696
+ /**
66697
+ * Reads `tailscale serve status --json` for one port: `Ours` when that tailnet
66698
+ * port already proxies to the same local port (BaviTutor shared it before),
66699
+ * `Taken` when it serves something else.
66700
+ */
66701
+ const servedPort = (json, port) => decodeServeStatus(json).pipe(match$5({
66702
+ onNone: () => "Free",
66703
+ onSome: (status) => {
66704
+ const configs = [status, ...Object.values(status.Foreground ?? {})];
66705
+ if (configs.some((config) => Object.entries(config.Web ?? {}).some(([hostPort, web]) => {
66706
+ const proxy = web.Handlers?.["/"]?.Proxy;
66707
+ return hostPort.endsWith(`:${port}`) && proxy !== void 0 && URL.canParse(proxy) && new URL(proxy).port === String(port);
66708
+ }))) return "Ours";
66709
+ return configs.some((config) => config.TCP?.[String(port)] !== void 0) ? "Taken" : "Free";
66710
+ }
66711
+ }));
66712
+ /** Where the server listens, as the address `tailscale serve` proxies to. */
66713
+ const serveTarget = (host, port) => {
66714
+ const address = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
66715
+ return `http://${address.includes(":") ? `[${address}]` : address}:${port}`;
66716
+ };
66717
+ const tailnetUrl = (dnsName, port) => port === 443 ? `https://${dnsName}` : `https://${dnsName}:${port}`;
66718
+ /** `tailscale` from the PATH, then where the installers put it. */
66719
+ const cliCandidates = () => ["tailscale", ...(process.platform === "win32" ? [join(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Tailscale", "tailscale.exe")] : process.platform === "darwin" ? [
66720
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
66721
+ "/opt/homebrew/bin/tailscale",
66722
+ "/usr/local/bin/tailscale"
66723
+ ] : []).filter((path) => existsSync(path))];
66724
+ /** Runs the tailscale CLI and resolves with its stdout, or undefined if `cli` doesn't exist. */
66725
+ const run = (cli, args) => new Promise((done) => {
66726
+ execFile(cli, args, {
66727
+ timeout: 5e3,
66728
+ windowsHide: true
66729
+ }, (error, stdout) => done(error?.code === "ENOENT" ? void 0 : stdout));
66730
+ });
66731
+ /** Finds Tailscale and checks it can share over HTTPS. */
66732
+ const checkTailscale = async () => {
66733
+ for (const cli of cliCandidates()) {
66734
+ const status = await run(cli, ["status", "--json"]);
66735
+ if (status !== void 0) {
66736
+ const read = readStatus(status);
66737
+ return "dnsName" in read ? {
66738
+ _tag: "Tailnet",
66739
+ cli,
66740
+ dnsName: read.dnsName
66741
+ } : read;
66742
+ }
66743
+ }
66744
+ return problem("Tailscale not found", `Get it at https://tailscale.com/download and log in, ${TRY_AGAIN}`);
66745
+ };
66746
+ const START_TIMEOUT_MS = 1e4;
66747
+ /**
66748
+ * Shares the server listening on `host:port` at the same port on the tailnet.
66749
+ * Kill the returned process to stop sharing.
66750
+ */
66751
+ const shareOnTailnet = async (tailnet, port, host) => {
66752
+ const url = tailnetUrl(tailnet.dnsName, port);
66753
+ const served = servedPort(await run(tailnet.cli, [
66754
+ "serve",
66755
+ "status",
66756
+ "--json"
66757
+ ]) ?? "", port);
66758
+ if (served === "Ours") return {
66759
+ _tag: "Shared",
66760
+ url,
66761
+ process: void 0
66762
+ };
66763
+ if (served === "Taken") return problem(`Port ${port} on your tailnet is in use`, "See what uses it with `tailscale serve status`, or pick another port:", `bavitutor --tailscale --port ${port === 65535 ? port - 1 : port + 1}`);
66764
+ const args = [
66765
+ "serve",
66766
+ `--https=${port}`,
66767
+ serveTarget(host, port)
66768
+ ];
66769
+ return new Promise((done) => {
66770
+ const child = spawn(tailnet.cli, args, { windowsHide: true });
66771
+ const output = [];
66772
+ const timer = setTimeout(() => {
66773
+ child.kill();
66774
+ done(problem(`Tailscale didn't share port ${port} within ${START_TIMEOUT_MS / 1e3} seconds`, `Run \`tailscale ${args.join(" ")}\` to see why.`));
66775
+ }, START_TIMEOUT_MS);
66776
+ child.stdout.on("data", (chunk) => {
66777
+ output.push(String(chunk));
66778
+ if (output.join("").includes(`https://${tailnet.dnsName}`)) {
66779
+ clearTimeout(timer);
66780
+ done({
66781
+ _tag: "Shared",
66782
+ url,
66783
+ process: child
66784
+ });
66785
+ }
66786
+ });
66787
+ child.stderr.on("data", (chunk) => output.push(String(chunk)));
66788
+ const fail = (detail) => {
66789
+ clearTimeout(timer);
66790
+ done(problem(`Tailscale couldn't share port ${port}`, `It said: ${detail}`));
66791
+ };
66792
+ child.once("error", (error) => fail(error.message));
66793
+ child.once("exit", () => fail(output.join("").trim().split(/\r?\n/).findLast((line) => line.trim() !== "") ?? "nothing"));
66794
+ });
66795
+ };
66796
+ //#endregion
66518
66797
  //#region apps/server/src/cli.ts
66519
66798
  /**
66520
66799
  * Production entry, shipped as the `bavitutor` bin: checks the machine, then
@@ -66531,6 +66810,7 @@ Options:
66531
66810
  --port <port> port to listen on (default: $PORT, then ${DEFAULT_PORT})
66532
66811
  --host <host> interface to listen on (default: ${DEFAULT_HOST}, this machine only)
66533
66812
  --data-dir <dir> database and settings (default: ~/.bavitutor)
66813
+ --tailscale also share it on your tailnet over HTTPS (same port)
66534
66814
  --no-open don't open the browser
66535
66815
  --no-update don't switch to a newer version from npm
66536
66816
  -v, --version print the version
@@ -66551,6 +66831,10 @@ const parseOptions = () => {
66551
66831
  port: { type: "string" },
66552
66832
  host: { type: "string" },
66553
66833
  "data-dir": { type: "string" },
66834
+ tailscale: {
66835
+ type: "boolean",
66836
+ default: false
66837
+ },
66554
66838
  open: {
66555
66839
  type: "boolean",
66556
66840
  default: true
@@ -66808,6 +67092,19 @@ const openBrowser = (url) => {
66808
67092
  say(`Open ${url} in your browser.`);
66809
67093
  }
66810
67094
  };
67095
+ const warnNotShared = (problem) => warn([`${problem.reason}, so only this computer can open BaviTutor.`, ...problem.fix]);
67096
+ /** For `--tailscale`: the tailnet to share on, or undefined after a warning. */
67097
+ const checkTailnet = async () => {
67098
+ const result = await checkTailscale();
67099
+ if (result._tag === "Problem") {
67100
+ warnNotShared(result);
67101
+ return;
67102
+ }
67103
+ say(` tailnet ${result.dnsName}`);
67104
+ return result;
67105
+ };
67106
+ /** Prints the tailnet URL under the local one, lined up on "at". */
67107
+ const sayTailnetUrl = (lead, url, note = "") => say(`${"on your tailnet".padStart(lead.length)} at ${url}${note}`);
66811
67108
  const main = async () => {
66812
67109
  const options = parseOptions();
66813
67110
  if (options.help) {
@@ -66871,10 +67168,25 @@ const main = async () => {
66871
67168
  ]);
66872
67169
  else say(` visuals ${chrome}`);
66873
67170
  }
67171
+ const tailnet = options.tailscale ? await checkTailnet() : void 0;
66874
67172
  const choice = await choosePort(requestedPort, isExplicitPort, host);
66875
67173
  if (choice.isRunning) {
67174
+ const lead = "BaviTutor is already running";
66876
67175
  say();
66877
- say(`BaviTutor is already running at ${urlFor(choice.port)}`);
67176
+ say(`${lead} at ${urlFor(choice.port)}`);
67177
+ const shared = tailnet === void 0 ? void 0 : await shareOnTailnet(tailnet, choice.port, host);
67178
+ if (shared?._tag === "Shared") {
67179
+ const sharing = shared.process;
67180
+ sayTailnetUrl(lead, shared.url, sharing === void 0 ? "" : " (Ctrl+C to stop sharing)");
67181
+ if (sharing !== void 0) {
67182
+ const stopSharing = () => sharing.kill();
67183
+ process.once("SIGINT", stopSharing);
67184
+ process.once("SIGTERM", stopSharing);
67185
+ }
67186
+ } else if (shared !== void 0) {
67187
+ say();
67188
+ warnNotShared(shared);
67189
+ }
66878
67190
  say();
66879
67191
  if (options.open) openBrowser(urlFor(choice.port));
66880
67192
  sayIfOutdated(latest);
@@ -66885,8 +67197,19 @@ const main = async () => {
66885
67197
  const url = urlFor(port);
66886
67198
  effectDiscard(gen(function* () {
66887
67199
  yield* HttpServer;
67200
+ const lead = "BaviTutor is running";
66888
67201
  say();
66889
- say(`BaviTutor is running at ${url} (Ctrl+C to stop)`);
67202
+ say(`${lead} at ${url} (Ctrl+C to stop)`);
67203
+ if (tailnet !== void 0) {
67204
+ const shared = yield* acquireRelease(promise(() => shareOnTailnet(tailnet, port, host)), (result) => sync(() => {
67205
+ if (result._tag === "Shared") result.process?.kill();
67206
+ }));
67207
+ if (shared._tag === "Shared") sayTailnetUrl(lead, shared.url);
67208
+ else {
67209
+ say();
67210
+ warnNotShared(shared);
67211
+ }
67212
+ }
66890
67213
  say();
66891
67214
  if (options.open) openBrowser(url);
66892
67215
  sayIfOutdated(latest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bavitutor",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Describe what you want to learn; BaviTutor researches it, maps it and teaches it.",
5
5
  "keywords": [
6
6
  "claude",