@robbeverhelst/do 1.282.0 → 1.283.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.
Files changed (2) hide show
  1. package/dist/do.js +646 -13
  2. package/package.json +2 -1
package/dist/do.js CHANGED
@@ -1038,7 +1038,7 @@ function flagBool(flags, name) {
1038
1038
 
1039
1039
  // src/commands.ts
1040
1040
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1041
- import { basename, dirname as dirname2, join as join3 } from "path";
1041
+ import { basename as basename2, dirname as dirname2, join as join4 } from "path";
1042
1042
 
1043
1043
  // ../../node_modules/drizzle-orm/entity.js
1044
1044
  var entityKind = Symbol.for("drizzle:entityKind");
@@ -4191,6 +4191,7 @@ var contacts = pgTable("contacts", {
4191
4191
  tenantId: text("tenant_id").notNull().references(() => tenants.id),
4192
4192
  kind: contactKind("kind").notNull().default("person"),
4193
4193
  email: text("email"),
4194
+ emailInvalidAt: timestamp("email_invalid_at", { withTimezone: true }),
4194
4195
  phone: text("phone"),
4195
4196
  company: text("company"),
4196
4197
  website: text("website"),
@@ -22235,7 +22236,14 @@ var setHealthLabAnalyteInputSchema = object({
22235
22236
 
22236
22237
  // ../../packages/schema/src/contracts/files.ts
22237
22238
  var FILE_TEXT_MAX = 1e6;
22238
- var FILE_SOURCES = ["upload", "chat", "doccle", "doccle_zip", "scan"];
22239
+ var FILE_SOURCES = [
22240
+ "upload",
22241
+ "chat",
22242
+ "doccle",
22243
+ "doccle_zip",
22244
+ "scan",
22245
+ "game_save"
22246
+ ];
22239
22247
  var fileSourceSchema = union([
22240
22248
  _enum2(FILE_SOURCES),
22241
22249
  string2().regex(/^gmail:[A-Za-z0-9_-]{1,200}$/, "gmail:<message id>")
@@ -24144,7 +24152,8 @@ var contactSchema = createSelectSchema(contacts, {
24144
24152
  lastContactedAt: exports_coerce.date().nullable(),
24145
24153
  userId: string2().nullable().default(null),
24146
24154
  rhythmDays: number2().int().nullable().default(null),
24147
- rhythmSnoozedUntil: string2().nullable().default(null)
24155
+ rhythmSnoozedUntil: string2().nullable().default(null),
24156
+ emailInvalidAt: exports_coerce.date().nullable().default(null)
24148
24157
  });
24149
24158
  var contactImportantDateSchema = object({
24150
24159
  id: ulidSchema,
@@ -24188,7 +24197,7 @@ var createContactInputSchema = createInsertSchema(contacts, {
24188
24197
  lastContactedAt: exports_coerce.date().nullish(),
24189
24198
  rhythmDays: contactPeopleInputShape.rhythmDays,
24190
24199
  rhythmSnoozedUntil: contactPeopleInputShape.rhythmSnoozedUntil
24191
- }).omit({ tenantId: true, keepInTouchRuleId: true, userId: true }).extend({
24200
+ }).omit({ tenantId: true, keepInTouchRuleId: true, userId: true, emailInvalidAt: true }).extend({
24192
24201
  title: string2().min(1),
24193
24202
  parentId: ulidSchema.nullish(),
24194
24203
  importantDates: array(contactImportantDateInputSchema).optional(),
@@ -24205,7 +24214,7 @@ var updateContactInputSchema = createUpdateSchema(contacts, {
24205
24214
  lastContactedAt: exports_coerce.date().nullish(),
24206
24215
  rhythmDays: contactPeopleInputShape.rhythmDays,
24207
24216
  rhythmSnoozedUntil: contactPeopleInputShape.rhythmSnoozedUntil
24208
- }).omit({ id: true, tenantId: true, keepInTouchRuleId: true, userId: true }).extend({
24217
+ }).omit({ id: true, tenantId: true, keepInTouchRuleId: true, userId: true, emailInvalidAt: true }).extend({
24209
24218
  title: string2().min(1).optional(),
24210
24219
  parentId: ulidSchema.nullish(),
24211
24220
  importantDates: array(contactImportantDateInputSchema).optional(),
@@ -25394,6 +25403,35 @@ var runInstallSchema = object({
25394
25403
  lastBackup: isoDate.nullish(),
25395
25404
  notes: text5.nullish()
25396
25405
  }).strict();
25406
+ var SAVE_SCREENSHOT_MAX_BASE64 = 1500000;
25407
+ var runSaveFields = {
25408
+ game: _enum2(["skyrim"]).default("skyrim"),
25409
+ fileName: shortText.nullish(),
25410
+ saveNumber: number2().int().min(0).max(1e9),
25411
+ playerName: name,
25412
+ level: number2().int().min(0).max(1e4),
25413
+ location: shortText,
25414
+ race: shortText.nullish(),
25415
+ sex: _enum2(["male", "female"]).nullish(),
25416
+ playTimeMinutes: number2().int().min(0).max(1e4 * 60).nullish(),
25417
+ savedAt: string2().datetime({ offset: true }).nullish(),
25418
+ gameDate: object({
25419
+ text: shortText.pipe(string2().min(1)),
25420
+ hour: number2().min(0).max(24).nullish(),
25421
+ daysPassed: number2().min(0).max(1e6).nullish()
25422
+ }).strict().nullish(),
25423
+ plugins: array(shortText).max(600).optional(),
25424
+ stats: record(shortText, number2().int()).refine((r) => Object.keys(r).length <= 300, "at most 300 stats").optional()
25425
+ };
25426
+ var runSaveSchema = object({
25427
+ ...runSaveFields,
25428
+ screenshotFileId: string2().min(1).max(64).nullish(),
25429
+ uploadedAt: string2().datetime({ offset: true })
25430
+ }).strict();
25431
+ var uploadRunSaveHeaderInputSchema = object({
25432
+ ...runSaveFields,
25433
+ screenshot: string2().max(SAVE_SCREENSHOT_MAX_BASE64).regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64").nullish()
25434
+ }).strict();
25397
25435
  var runFacetSchema = object({
25398
25436
  theme: runThemeSchema.default("generic"),
25399
25437
  edition: shortText.nullish(),
@@ -25414,6 +25452,7 @@ var runFacetSchema = object({
25414
25452
  achievements: runAchievementsSchema.nullish(),
25415
25453
  rareFinds: runRareFindsSchema.nullish(),
25416
25454
  install: runInstallSchema.nullish(),
25455
+ save: runSaveSchema.nullish(),
25417
25456
  money: object({ goal: shortText.nullish(), purpose: shortText.nullish() }).strict().nullish(),
25418
25457
  links: array(object({ label: name, url: string2().url().max(2000) }).strict()).max(50).optional()
25419
25458
  }).strict();
@@ -25441,6 +25480,7 @@ var runValueSectionValues = [
25441
25480
  "achievements",
25442
25481
  "rareFinds",
25443
25482
  "install",
25483
+ "save",
25444
25484
  "money"
25445
25485
  ];
25446
25486
  var runValueSectionSchema = _enum2(runValueSectionValues);
@@ -25544,6 +25584,12 @@ var runChangesResultSchema = object({
25544
25584
  sessionId: string2(),
25545
25585
  batch: runChangeBatchSchema
25546
25586
  });
25587
+ var runSaveHeaderResultSchema = object({
25588
+ run: runDetailSchema,
25589
+ unchanged: boolean3(),
25590
+ sessionId: string2().nullable(),
25591
+ batch: runChangeBatchSchema.nullable()
25592
+ });
25547
25593
  var createRunInputSchema = object({
25548
25594
  id: ulidSchema,
25549
25595
  gameId: ulidSchema,
@@ -28315,7 +28361,7 @@ function parseCapture2(input, now) {
28315
28361
 
28316
28362
  // src/meta.ts
28317
28363
  var CLI_NAME = "do";
28318
- var VERSION = "1.282.0";
28364
+ var VERSION = "1.283.0";
28319
28365
  var API_PREFIX = "/api/v1";
28320
28366
  var DEFAULT_API_URL = "http://localhost:3001";
28321
28367
 
@@ -33305,6 +33351,34 @@ var COMMAND_SPECS = [
33305
33351
  returns: "RunChangesResult",
33306
33352
  notes: "POST /api/v1/runs/{id}/changes. Ops: set|merge on a one-value section (character, arc, nextSteps, achievements, install, \u2026), upsert|remove on a list section matched by name (quests, choices, homes, people, mounts, uniques, orderOfPlay, team, checklists, links), tick on a checklist. The batch joins the last session if it was active in the last 6 hours, else opens a new one. A change that leaves the run invalid writes nothing."
33307
33353
  },
33354
+ {
33355
+ path: ["runs", "save-header"],
33356
+ summary: "Read a Skyrim save (read-only) and send its header and screenshot to a run.",
33357
+ args: [
33358
+ { name: "id", required: true, description: "Run ULID." },
33359
+ {
33360
+ name: "save",
33361
+ required: true,
33362
+ description: "A .ess save, or the Saves folder (its newest save)."
33363
+ }
33364
+ ],
33365
+ flags: [
33366
+ {
33367
+ name: "watch",
33368
+ type: "boolean",
33369
+ description: "Keep watching the Saves folder; send each new save once the game has written it."
33370
+ },
33371
+ {
33372
+ name: "interval",
33373
+ type: "string",
33374
+ description: "With --watch: seconds between looks (default 10)."
33375
+ },
33376
+ { name: "dry-run", type: "boolean", description: "Print what the save says; send nothing." }
33377
+ ],
33378
+ scopes: ["collections:write"],
33379
+ returns: "RunSaveHeaderResult",
33380
+ notes: "POST /api/v1/runs/{id}/save-header. The save sync (run tracker phase 4): name, level, location, race, play time, the in-game date, plugins and General Stats become the run's `save` section and its character's level, as one update with a receipt and Undo; the screenshot is a file on the run (the last one is dropped). The same save twice changes nothing. Needs a member's key (the screenshot is stored as theirs). The save itself is only read. See docs/agents/skyrim-save-sync.md."
33381
+ },
33308
33382
  {
33309
33383
  path: ["runs", "undo"],
33310
33384
  summary: "Undo a run's latest chat update (or the one given).",
@@ -35288,6 +35362,534 @@ function renderEvents(data, mode) {
35288
35362
  `);
35289
35363
  }
35290
35364
 
35365
+ // src/save-sync.ts
35366
+ import { readFile, stat as stat2 } from "fs/promises";
35367
+ import { basename } from "path";
35368
+
35369
+ // ../../packages/skyrim-save/src/body.ts
35370
+ import { inflateSync } from "zlib";
35371
+
35372
+ // ../../packages/skyrim-save/src/reader.ts
35373
+ class SaveFormatError extends Error {
35374
+ name = "SaveFormatError";
35375
+ }
35376
+
35377
+ class Reader {
35378
+ bytes;
35379
+ view;
35380
+ offset;
35381
+ constructor(bytes, offset = 0) {
35382
+ this.bytes = bytes;
35383
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
35384
+ this.offset = offset;
35385
+ }
35386
+ get remaining() {
35387
+ return this.bytes.byteLength - this.offset;
35388
+ }
35389
+ need(n, what) {
35390
+ if (n < 0 || this.offset + n > this.bytes.byteLength) {
35391
+ throw new SaveFormatError(`the save ends before its ${what} (at byte ${this.offset})`);
35392
+ }
35393
+ const at = this.offset;
35394
+ this.offset += n;
35395
+ return at;
35396
+ }
35397
+ u8(what) {
35398
+ return this.view.getUint8(this.need(1, what));
35399
+ }
35400
+ u16(what) {
35401
+ return this.view.getUint16(this.need(2, what), true);
35402
+ }
35403
+ u32(what) {
35404
+ return this.view.getUint32(this.need(4, what), true);
35405
+ }
35406
+ f32(what) {
35407
+ return this.view.getFloat32(this.need(4, what), true);
35408
+ }
35409
+ u64(what) {
35410
+ return this.view.getBigUint64(this.need(8, what), true);
35411
+ }
35412
+ take(n, what) {
35413
+ const at = this.need(n, what);
35414
+ return this.bytes.subarray(at, at + n);
35415
+ }
35416
+ wstring(what) {
35417
+ const len = this.u16(what);
35418
+ return decodeLatin1(this.take(len, what));
35419
+ }
35420
+ }
35421
+ function decodeLatin1(bytes) {
35422
+ let out = "";
35423
+ for (const b of bytes)
35424
+ out += String.fromCharCode(b);
35425
+ return out;
35426
+ }
35427
+
35428
+ // ../../packages/skyrim-save/src/lz4.ts
35429
+ function lz4DecompressBlock(src, size) {
35430
+ const out = new Uint8Array(size);
35431
+ let i = 0;
35432
+ let o = 0;
35433
+ while (i < src.length) {
35434
+ const token = src[i++];
35435
+ let literals = token >> 4;
35436
+ if (literals === 15) {
35437
+ let b;
35438
+ do {
35439
+ b = byteAt(src, i++);
35440
+ literals += b;
35441
+ } while (b === 255);
35442
+ }
35443
+ if (i + literals > src.length || o + literals > size) {
35444
+ throw new SaveFormatError("the save's body is cut short (LZ4 literals)");
35445
+ }
35446
+ out.set(src.subarray(i, i + literals), o);
35447
+ i += literals;
35448
+ o += literals;
35449
+ if (i >= src.length)
35450
+ break;
35451
+ const offset = byteAt(src, i) | byteAt(src, i + 1) << 8;
35452
+ i += 2;
35453
+ if (offset === 0 || offset > o)
35454
+ throw new SaveFormatError("a bad LZ4 match in the save's body");
35455
+ let length = (token & 15) + 4;
35456
+ if ((token & 15) === 15) {
35457
+ let b;
35458
+ do {
35459
+ b = byteAt(src, i++);
35460
+ length += b;
35461
+ } while (b === 255);
35462
+ }
35463
+ if (o + length > size)
35464
+ throw new SaveFormatError("the save's body is longer than it says");
35465
+ for (let k = 0;k < length; k++, o++)
35466
+ out[o] = out[o - offset];
35467
+ }
35468
+ if (o !== size)
35469
+ throw new SaveFormatError("the save's body is shorter than it says");
35470
+ return out;
35471
+ }
35472
+ function byteAt(src, i) {
35473
+ const b = src[i];
35474
+ if (b === undefined)
35475
+ throw new SaveFormatError("the save's body is cut short (LZ4)");
35476
+ return b;
35477
+ }
35478
+
35479
+ // ../../packages/skyrim-save/src/body.ts
35480
+ var SAVE_STAT_CATEGORIES = [
35481
+ "general",
35482
+ "quest",
35483
+ "combat",
35484
+ "magic",
35485
+ "crafting",
35486
+ "crime",
35487
+ "dlc"
35488
+ ];
35489
+ var TAMRIEL_MONTHS = [
35490
+ "Morning Star",
35491
+ "Sun's Dawn",
35492
+ "First Seed",
35493
+ "Rain's Hand",
35494
+ "Second Seed",
35495
+ "Midyear",
35496
+ "Sun's Height",
35497
+ "Last Seed",
35498
+ "Hearthfire",
35499
+ "Frostfall",
35500
+ "Sun's Dusk",
35501
+ "Evening Star"
35502
+ ];
35503
+ var CLOCK_GLOBALS = {
35504
+ year: 53,
35505
+ month: 54,
35506
+ day: 55,
35507
+ hour: 56,
35508
+ daysPassed: 57
35509
+ };
35510
+ var MISC_STATS = 0;
35511
+ var GLOBAL_VARIABLES = 3;
35512
+ var LIGHT_PLUGINS_FROM = 78;
35513
+ function decompressBody(bytes, header) {
35514
+ if (header.compression === "none")
35515
+ return bytes.subarray(header.bodyOffset);
35516
+ const r = new Reader(bytes, header.bodyOffset);
35517
+ const uncompressed = r.u32("uncompressed size");
35518
+ const compressed = r.u32("compressed size");
35519
+ if (uncompressed > 512 * 1024 * 1024)
35520
+ throw new SaveFormatError("the save's body is too big");
35521
+ const block = r.take(compressed, "compressed body");
35522
+ if (header.compression === "lz4")
35523
+ return lz4DecompressBlock(block, uncompressed);
35524
+ const out = new Uint8Array(inflateSync(block));
35525
+ if (out.byteLength !== uncompressed) {
35526
+ throw new SaveFormatError("the save's body is not the size it says");
35527
+ }
35528
+ return out;
35529
+ }
35530
+ function parseSaveBody(bytes, header) {
35531
+ const body = decompressBody(bytes, header);
35532
+ const r = new Reader(body);
35533
+ const formVersion = r.u8("form version");
35534
+ const pluginInfoSize = r.u32("plugin info size");
35535
+ const pluginsEnd = r.offset + pluginInfoSize;
35536
+ const plugins = [];
35537
+ const pluginCount = r.u8("plugin count");
35538
+ for (let i = 0;i < pluginCount; i++)
35539
+ plugins.push(r.wstring("plugin name"));
35540
+ const lightPlugins = [];
35541
+ if (header.version >= 12 && formVersion >= LIGHT_PLUGINS_FROM) {
35542
+ const lightCount = r.u16("light plugin count");
35543
+ for (let i = 0;i < lightCount; i++)
35544
+ lightPlugins.push(r.wstring("light plugin name"));
35545
+ }
35546
+ if (r.offset !== pluginsEnd) {
35547
+ throw new SaveFormatError("the save's plugin list is not the size it says");
35548
+ }
35549
+ const formIdArrayAt = r.u32("form id array offset");
35550
+ r.u32("unknown table offset");
35551
+ const globals1At = r.u32("global data table 1 offset");
35552
+ r.u32("global data table 2 offset");
35553
+ r.u32("change forms offset");
35554
+ r.u32("global data table 3 offset");
35555
+ const globals1Count = r.u32("global data table 1 count");
35556
+ const base = header.bodyOffset;
35557
+ const formIds = readFormIdArray(body, formIdArrayAt - base);
35558
+ const { globals, stats } = readGlobalData(body, globals1At - base, globals1Count, formIds);
35559
+ return { formVersion, plugins, lightPlugins, gameTime: clockFrom(globals), stats };
35560
+ }
35561
+ function readFormIdArray(body, at) {
35562
+ const r = new Reader(body, at);
35563
+ const count = r.u32("form id count");
35564
+ if (count * 4 > r.remaining)
35565
+ throw new SaveFormatError("the save's form id array is cut short");
35566
+ const out = new Uint32Array(count);
35567
+ for (let i = 0;i < count; i++)
35568
+ out[i] = r.u32("form id");
35569
+ return out;
35570
+ }
35571
+ function readGlobalData(body, at, count, formIds) {
35572
+ const r = new Reader(body, at);
35573
+ const globals = new Map;
35574
+ const stats = [];
35575
+ for (let i = 0;i < count; i++) {
35576
+ const type = r.u32("global data type");
35577
+ const length = r.u32("global data length");
35578
+ const g = new Reader(r.take(length, "global data"));
35579
+ if (type === MISC_STATS) {
35580
+ const n = g.u32("stat count");
35581
+ for (let k = 0;k < n; k++) {
35582
+ const name = g.wstring("stat name");
35583
+ const category = SAVE_STAT_CATEGORIES[g.u8("stat category")] ?? "general";
35584
+ const value = g.u32("stat value") | 0;
35585
+ if (value !== 0)
35586
+ stats.push({ name, category, value });
35587
+ }
35588
+ } else if (type === GLOBAL_VARIABLES) {
35589
+ const n = vsval(g);
35590
+ for (let k = 0;k < n; k++) {
35591
+ const id = refId(g, formIds);
35592
+ globals.set(id, g.f32("global value"));
35593
+ }
35594
+ }
35595
+ }
35596
+ return { globals, stats };
35597
+ }
35598
+ function clockFrom(globals) {
35599
+ const get = (id) => globals.get(id);
35600
+ const year = get(CLOCK_GLOBALS.year);
35601
+ const month = get(CLOCK_GLOBALS.month);
35602
+ const day = get(CLOCK_GLOBALS.day);
35603
+ const hour = get(CLOCK_GLOBALS.hour);
35604
+ const daysPassed = get(CLOCK_GLOBALS.daysPassed);
35605
+ if (year === undefined || month === undefined || day === undefined || hour === undefined) {
35606
+ return null;
35607
+ }
35608
+ return {
35609
+ year: Math.round(year),
35610
+ month: Math.round(month),
35611
+ day: Math.round(day),
35612
+ hour,
35613
+ daysPassed: daysPassed ?? 0
35614
+ };
35615
+ }
35616
+ function vsval(r) {
35617
+ const first = r.u8("vsval");
35618
+ const size = first & 3;
35619
+ if (size === 0)
35620
+ return first >> 2;
35621
+ if (size === 1)
35622
+ return (first | r.u8("vsval") << 8) >> 2;
35623
+ const rest = r.u16("vsval");
35624
+ return (first | rest << 8) >>> 2;
35625
+ }
35626
+ function refId(r, formIds) {
35627
+ const b0 = r.u8("ref id");
35628
+ const b1 = r.u8("ref id");
35629
+ const b2 = r.u8("ref id");
35630
+ const value = (b0 & 63) << 16 | b1 << 8 | b2;
35631
+ const type = b0 >> 6;
35632
+ if (type === 1)
35633
+ return value;
35634
+ if (type === 2)
35635
+ return (4278190080 | value) >>> 0;
35636
+ if (type === 0)
35637
+ return value === 0 ? 0 : formIds[value - 1] ?? 0;
35638
+ return 0;
35639
+ }
35640
+ function formatGameDate(time) {
35641
+ const month = TAMRIEL_MONTHS[time.month] ?? `month ${time.month + 1}`;
35642
+ return `${ordinal(time.day)} of ${month}, 4E ${time.year}`;
35643
+ }
35644
+ function ordinal(n) {
35645
+ const tens = n % 100;
35646
+ if (tens >= 11 && tens <= 13)
35647
+ return `${n}th`;
35648
+ const suffix = n % 10 === 1 ? "st" : n % 10 === 2 ? "nd" : n % 10 === 3 ? "rd" : "th";
35649
+ return `${n}${suffix}`;
35650
+ }
35651
+ // ../../packages/skyrim-save/src/header.ts
35652
+ var SAVE_MAGIC = "TESV_SAVEGAME";
35653
+ var SE_VERSION = 12;
35654
+ var FILETIME_UNIX_EPOCH = 116444736000000000n;
35655
+ function parseSaveHeader(bytes) {
35656
+ const r = new Reader(bytes);
35657
+ const magic = String.fromCharCode(...r.take(SAVE_MAGIC.length, "magic"));
35658
+ if (magic !== SAVE_MAGIC)
35659
+ throw new SaveFormatError("not a Skyrim save (no TESV_SAVEGAME magic)");
35660
+ const headerSize = r.u32("header size");
35661
+ const headerStart = r.offset;
35662
+ const version = r.u32("version");
35663
+ if (version < 7 || version > 64)
35664
+ throw new SaveFormatError(`unknown save version ${version}`);
35665
+ const saveNumber = r.u32("save number");
35666
+ const playerName = r.wstring("player name");
35667
+ const level = r.u32("level");
35668
+ const location = r.wstring("location");
35669
+ const gameDate = r.wstring("game date");
35670
+ const raceEditorId = r.wstring("race");
35671
+ const sexCode = r.u16("sex");
35672
+ const current = r.f32("experience");
35673
+ const toNextLevel = r.f32("experience to next level");
35674
+ const filetime = r.u64("save time");
35675
+ const width = r.u32("screenshot width");
35676
+ const height = r.u32("screenshot height");
35677
+ let compression = "none";
35678
+ if (version >= SE_VERSION) {
35679
+ const code = r.u16("compression");
35680
+ compression = code === 1 ? "zlib" : code === 2 ? "lz4" : "none";
35681
+ }
35682
+ if (r.offset - headerStart !== headerSize) {
35683
+ throw new SaveFormatError(`the header says ${headerSize} bytes but its fields take ${r.offset - headerStart}`);
35684
+ }
35685
+ if (width > 4096 || height > 4096) {
35686
+ throw new SaveFormatError(`a ${width}\xD7${height} screenshot is not a save's`);
35687
+ }
35688
+ const bytesPerPixel = version >= SE_VERSION ? 4 : 3;
35689
+ const pixels = r.take(width * height * bytesPerPixel, "screenshot");
35690
+ return {
35691
+ version,
35692
+ saveNumber,
35693
+ playerName,
35694
+ level,
35695
+ location,
35696
+ gameDate,
35697
+ playTimeMinutes: playTimeMinutes(gameDate),
35698
+ raceEditorId,
35699
+ race: raceName(raceEditorId),
35700
+ sex: sexCode === 1 ? "female" : "male",
35701
+ experience: { current, toNextLevel },
35702
+ savedAt: filetimeToIso(filetime),
35703
+ screenshot: { width, height, bytesPerPixel, pixels },
35704
+ compression,
35705
+ bodyOffset: r.offset
35706
+ };
35707
+ }
35708
+ function playTimeMinutes(gameDate) {
35709
+ const m = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/.exec(gameDate.trim());
35710
+ if (!m)
35711
+ return null;
35712
+ return Number(m[1]) * 60 + Number(m[2]);
35713
+ }
35714
+ function raceName(editorId) {
35715
+ const base = editorId.replace(/Vampire$/, "").replace(/Race$/, "");
35716
+ return base.replace(/([a-z])([A-Z])/g, "$1 $2") || editorId;
35717
+ }
35718
+ function filetimeToIso(filetime) {
35719
+ if (filetime <= FILETIME_UNIX_EPOCH)
35720
+ return null;
35721
+ const ms = Number((filetime - FILETIME_UNIX_EPOCH) / 10000n);
35722
+ const date = new Date(ms);
35723
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
35724
+ }
35725
+ // ../../packages/skyrim-save/src/png.ts
35726
+ import { deflateSync } from "zlib";
35727
+ var SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
35728
+ function screenshotToPng(shot) {
35729
+ const { width, height, bytesPerPixel, pixels } = shot;
35730
+ if (width === 0 || height === 0)
35731
+ throw new RangeError("the save has no screenshot");
35732
+ const stride = width * bytesPerPixel;
35733
+ const raw = new Uint8Array((stride + 1) * height);
35734
+ for (let y = 0;y < height; y++) {
35735
+ raw.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
35736
+ }
35737
+ const ihdr = new Uint8Array(13);
35738
+ const v = new DataView(ihdr.buffer);
35739
+ v.setUint32(0, width);
35740
+ v.setUint32(4, height);
35741
+ ihdr[8] = 8;
35742
+ ihdr[9] = bytesPerPixel === 4 ? 6 : 2;
35743
+ return concat([
35744
+ SIGNATURE,
35745
+ chunk("IHDR", ihdr),
35746
+ chunk("IDAT", new Uint8Array(deflateSync(raw))),
35747
+ chunk("IEND", new Uint8Array(0))
35748
+ ]);
35749
+ }
35750
+ function chunk(type, data) {
35751
+ const out = new Uint8Array(12 + data.byteLength);
35752
+ const v = new DataView(out.buffer);
35753
+ v.setUint32(0, data.byteLength);
35754
+ for (let i = 0;i < 4; i++)
35755
+ out[4 + i] = type.charCodeAt(i);
35756
+ out.set(data, 8);
35757
+ v.setUint32(8 + data.byteLength, crc32(out.subarray(4, 8 + data.byteLength)));
35758
+ return out;
35759
+ }
35760
+ var table = null;
35761
+ function crc32(bytes) {
35762
+ if (!table) {
35763
+ table = new Uint32Array(256);
35764
+ for (let n = 0;n < 256; n++) {
35765
+ let c = n;
35766
+ for (let k = 0;k < 8; k++)
35767
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
35768
+ table[n] = c >>> 0;
35769
+ }
35770
+ }
35771
+ let crc = 4294967295;
35772
+ for (const b of bytes)
35773
+ crc = table[(crc ^ b) & 255] ^ crc >>> 8;
35774
+ return (crc ^ 4294967295) >>> 0;
35775
+ }
35776
+ function concat(parts) {
35777
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
35778
+ let at = 0;
35779
+ for (const p of parts) {
35780
+ out.set(p, at);
35781
+ at += p.byteLength;
35782
+ }
35783
+ return out;
35784
+ }
35785
+ // ../../packages/skyrim-save/src/save.ts
35786
+ function readSave(bytes) {
35787
+ const header = parseSaveHeader(bytes);
35788
+ let body = null;
35789
+ let bodyError = null;
35790
+ try {
35791
+ body = parseSaveBody(bytes, header);
35792
+ } catch (err) {
35793
+ if (!(err instanceof SaveFormatError))
35794
+ throw err;
35795
+ bodyError = err.message;
35796
+ }
35797
+ const shot = header.screenshot;
35798
+ return {
35799
+ header,
35800
+ body,
35801
+ bodyError,
35802
+ summary: summarize(header, body),
35803
+ screenshotPng: shot.width > 0 && shot.height > 0 ? screenshotToPng(shot) : null
35804
+ };
35805
+ }
35806
+ function summarize(header, body) {
35807
+ const time = body?.gameTime ?? null;
35808
+ return {
35809
+ version: header.version,
35810
+ saveNumber: header.saveNumber,
35811
+ playerName: header.playerName,
35812
+ level: header.level,
35813
+ location: header.location,
35814
+ race: header.race,
35815
+ sex: header.sex,
35816
+ playTimeMinutes: header.playTimeMinutes,
35817
+ savedAt: header.savedAt,
35818
+ gameDate: time ? {
35819
+ text: formatGameDate(time),
35820
+ hour: Math.round(time.hour * 100) / 100,
35821
+ daysPassed: Math.round(time.daysPassed * 100) / 100
35822
+ } : null,
35823
+ plugins: body?.plugins ?? [],
35824
+ lightPlugins: body?.lightPlugins ?? [],
35825
+ stats: Object.fromEntries((body?.stats ?? []).map((s) => [s.name, s.value]))
35826
+ };
35827
+ }
35828
+ // ../../packages/skyrim-save/src/newest.ts
35829
+ import { readdir, stat } from "fs/promises";
35830
+ import { join as join3 } from "path";
35831
+ async function newestSave(dir) {
35832
+ let best = null;
35833
+ for (const name of await readdir(dir)) {
35834
+ if (!name.toLowerCase().endsWith(".ess"))
35835
+ continue;
35836
+ const path = join3(dir, name);
35837
+ const info = await stat(path);
35838
+ if (!info.isFile())
35839
+ continue;
35840
+ if (!best || info.mtimeMs > best.at)
35841
+ best = { path, at: info.mtimeMs };
35842
+ }
35843
+ return best?.path ?? null;
35844
+ }
35845
+ // src/save-sync.ts
35846
+ async function resolveSave(path) {
35847
+ const info = await stat2(path).catch(() => null);
35848
+ if (!info)
35849
+ throw usageError(`no such file or folder: ${path}`);
35850
+ if (!info.isDirectory())
35851
+ return path;
35852
+ const newest = await newestSave(path);
35853
+ if (!newest)
35854
+ throw usageError(`no .ess save in ${path}`);
35855
+ return newest;
35856
+ }
35857
+ async function saveUpload(path) {
35858
+ const save = readSave(new Uint8Array(await readFile(path)));
35859
+ const { version: _version, lightPlugins, plugins, ...summary } = save.summary;
35860
+ return uploadRunSaveHeaderInputSchema.parse({
35861
+ ...summary,
35862
+ fileName: basename(path),
35863
+ plugins: [...plugins, ...lightPlugins].slice(0, 600),
35864
+ screenshot: save.screenshotPng ? Buffer.from(save.screenshotPng).toString("base64") : null
35865
+ });
35866
+ }
35867
+ async function watchSaves(opts) {
35868
+ const interval = opts.intervalMs ?? 1e4;
35869
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
35870
+ let seen = null;
35871
+ let sent = null;
35872
+ while (!opts.signal?.aborted) {
35873
+ try {
35874
+ const path = await newestSave(opts.dir);
35875
+ if (path) {
35876
+ const info = await stat2(path);
35877
+ const mark = `${path}|${info.mtimeMs}|${info.size}`;
35878
+ if (mark === seen && mark !== sent) {
35879
+ await opts.onSave(path);
35880
+ sent = mark;
35881
+ }
35882
+ seen = mark;
35883
+ }
35884
+ } catch (err) {
35885
+ opts.onError?.(err);
35886
+ }
35887
+ if (opts.signal?.aborted)
35888
+ break;
35889
+ await sleep(interval);
35890
+ }
35891
+ }
35892
+
35291
35893
  // src/today.ts
35292
35894
  var OPEN_STATUSES = new Set(["todo", "in_progress"]);
35293
35895
  function isTaskLike(value) {
@@ -35864,7 +36466,7 @@ var HANDLERS = {
35864
36466
  });
35865
36467
  if (out !== undefined) {
35866
36468
  for (const file of data.files) {
35867
- const target = join3(out, file.path);
36469
+ const target = join4(out, file.path);
35868
36470
  mkdirSync2(dirname2(target), { recursive: true });
35869
36471
  writeFileSync2(target, file.markdown);
35870
36472
  }
@@ -36050,7 +36652,7 @@ var HANDLERS = {
36050
36652
  } catch (cause) {
36051
36653
  throw usageError(`can't read ${path}: ${cause instanceof Error ? cause.message : cause}`);
36052
36654
  }
36053
- const name = flagString(flags, "title") ?? flagString(flags, "name") ?? basename(path);
36655
+ const name = flagString(flags, "title") ?? flagString(flags, "name") ?? basename2(path);
36054
36656
  const form = new FormData;
36055
36657
  form.append("nodeId", node);
36056
36658
  const fields = {
@@ -36077,7 +36679,7 @@ var HANDLERS = {
36077
36679
  "files download": async ({ args, flags, requireClient }) => {
36078
36680
  const id = requireArg(args, 0, "file id");
36079
36681
  const file = await requireClient().getBytes(`/files/${id}/download`);
36080
- const out = flagString(flags, "out") ?? basename(file.name ?? id);
36682
+ const out = flagString(flags, "out") ?? basename2(file.name ?? id);
36081
36683
  writeFileSync2(out, file.bytes);
36082
36684
  return {
36083
36685
  data: { id, saved: out, size: file.bytes.byteLength, type: file.contentType },
@@ -36144,7 +36746,7 @@ var HANDLERS = {
36144
36746
  if (value !== undefined)
36145
36747
  form.append(key, value);
36146
36748
  }
36147
- const name = basename(path);
36749
+ const name = basename2(path);
36148
36750
  const type = flagString(flags, "type") ?? Bun.file(name).type;
36149
36751
  form.append("file", new File([Uint8Array.from(bytes)], name, { type }), name);
36150
36752
  }
@@ -37501,7 +38103,7 @@ var HANDLERS = {
37501
38103
  const since = flagString(flags, "since");
37502
38104
  if (since !== undefined)
37503
38105
  form.append("since", since);
37504
- const name = basename(path);
38106
+ const name = basename2(path);
37505
38107
  form.append("file", new File([Uint8Array.from(bytes)], name, { type: "text/csv" }), name);
37506
38108
  const route = flagBool(flags, "commit") ? "/money/imports" : "/money/imports/preview";
37507
38109
  return { data: await requireClient().upload(route, form), render: renderRecord };
@@ -37705,6 +38307,37 @@ var HANDLERS = {
37705
38307
  const input = parseInput(applyRunChangesInputSchema, body);
37706
38308
  return { data: await requireClient().post(`/runs/${id}/changes`, input), render: renderRecord };
37707
38309
  },
38310
+ "runs save-header": async ({ args, flags, io, requireClient }) => {
38311
+ const id = requireArg(args, 0, "run id");
38312
+ const target = requireArg(args, 1, "save file or Saves folder");
38313
+ const dryRun = flagBool(flags, "dry-run");
38314
+ const send = async (path) => {
38315
+ const input = await saveUpload(path);
38316
+ if (dryRun)
38317
+ return { ...input, screenshot: input.screenshot ? "(png)" : null };
38318
+ return requireClient().post(`/runs/${id}/save-header`, input);
38319
+ };
38320
+ if (!flagBool(flags, "watch")) {
38321
+ return { data: await send(await resolveSave(target)), render: renderRecord };
38322
+ }
38323
+ const seconds = Number(flagString(flags, "interval") ?? "10");
38324
+ if (!Number.isFinite(seconds) || seconds < 1)
38325
+ throw usageError("--interval is seconds, 1 or more");
38326
+ io.stderr(`watching ${target} for new saves (every ${seconds}s); Ctrl-C stops
38327
+ `);
38328
+ await watchSaves({
38329
+ dir: target,
38330
+ intervalMs: seconds * 1000,
38331
+ onSave: async (path) => {
38332
+ const result = await send(path);
38333
+ io.stderr(`${result.unchanged ? "already had" : dryRun ? "read" : "sent"} ${basename2(path)}
38334
+ `);
38335
+ },
38336
+ onError: (err) => io.stderr(`save sync: ${err.message}
38337
+ `)
38338
+ });
38339
+ return { data: null, render: () => "" };
38340
+ },
37708
38341
  "runs undo": async ({ args, flags, requireClient }) => {
37709
38342
  const id = requireArg(args, 0, "run id");
37710
38343
  const batch = flagString(flags, "batch");
@@ -37894,7 +38527,7 @@ var HANDLERS = {
37894
38527
  } catch (cause) {
37895
38528
  throw usageError(`can't read ${path}: ${cause instanceof Error ? cause.message : cause}`);
37896
38529
  }
37897
- const name = basename(path);
38530
+ const name = basename2(path);
37898
38531
  const form = new FormData;
37899
38532
  const textFile = flagString(flags, "text-file");
37900
38533
  if (textFile)
@@ -38101,7 +38734,7 @@ var HANDLERS = {
38101
38734
  form.append("sourceName", fields.sourceName);
38102
38735
  if (fields.sourceEmail)
38103
38736
  form.append("sourceEmail", fields.sourceEmail);
38104
- const name = basename(path);
38737
+ const name = basename2(path);
38105
38738
  form.append("file", new File([Uint8Array.from(bytes)], name, { type: "text/plain" }), name);
38106
38739
  return { data: await requireClient().upload("/family-tree", form), render: renderRecord };
38107
38740
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robbeverhelst/do",
3
- "version": "1.282.0",
3
+ "version": "1.283.0",
4
4
  "private": false,
5
5
  "description": "do — the agent-drivable CLI over the public /api/v1 surface",
6
6
  "homepage": "https://github.com/robbeverhelst/do/tree/main/apps/cli",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "devDependencies": {
35
35
  "@do/schema": "workspace:*",
36
+ "@do/skyrim-save": "workspace:*",
36
37
  "@types/bun": "^1.4.3",
37
38
  "@types/node": "^25.9.3",
38
39
  "ulidx": "^2.4.1",