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 +26 -0
- package/dist/bavitutor.mjs +349 -26
- package/package.json +1 -1
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 |
|
package/dist/bavitutor.mjs
CHANGED
|
@@ -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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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.
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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(
|
|
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(
|
|
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);
|